use russh::server::Session; use russh::{ChannelId, CryptoVec}; use crate::admin; use crate::ansi; use crate::game::{CombatAction, CombatState, SharedState}; use crate::world::Attitude; pub struct BroadcastMsg { pub channel: ChannelId, pub handle: russh::server::Handle, pub data: CryptoVec, } pub struct KickTarget { pub channel: ChannelId, pub handle: russh::server::Handle, } pub struct CommandResult { pub output: String, pub broadcasts: Vec, pub kick_targets: Vec, pub quit: bool, } const DIR_ALIASES: &[(&str, &str)] = &[ ("n", "north"), ("s", "south"), ("e", "east"), ("w", "west"), ("u", "up"), ("d", "down"), ]; fn resolve_dir(input: &str) -> &str { for &(a, f) in DIR_ALIASES { if input == a { return f; } } input } pub async fn execute_for_ssh( input: &str, player_id: usize, state: &SharedState, session: &mut Session, channel: ChannelId, ) -> Result { let result = execute(input, player_id, state).await; send(session, channel, &result.output)?; for msg in result.broadcasts { let _ = msg.handle.data(msg.channel, msg.data).await; } for kick in result.kick_targets { let _ = kick.handle.close(kick.channel).await; } if result.quit { return Ok(false); } send(session, channel, &ansi::prompt())?; Ok(true) } pub async fn execute( input: &str, player_id: usize, state: &SharedState, ) -> CommandResult { let input = input.trim(); if input.is_empty() { return CommandResult { output: ansi::prompt(), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, }; } let (cmd, args) = match input.split_once(' ') { Some((c, a)) => (c.to_lowercase(), a.trim().to_string()), None => (input.to_lowercase(), String::new()), }; // Combat lockout: only certain commands allowed { let st = state.lock().await; if let Some(conn) = st.players.get(&player_id) { if conn.combat.is_some() && !matches!( cmd.as_str(), "attack" | "a" | "defend" | "def" | "flee" | "use" | "cast" | "c" | "look" | "l" | "stats" | "st" | "inventory" | "inv" | "i" | "spells" | "skills" | "quit" | "exit" ) { return CommandResult { output: format!( "{}\r\n{}", ansi::error_msg( "You're in combat! Use 'attack', 'defend', 'flee', 'cast', 'use', 'look', 'stats', or 'inventory'." ), ansi::prompt() ), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, }; } } } match cmd.as_str() { "look" | "l" => cmd_look(player_id, &args, state).await, "go" => cmd_go(player_id, &args, state).await, "north" | "south" | "east" | "west" | "up" | "down" | "n" | "s" | "e" | "w" | "u" | "d" => cmd_go(player_id, resolve_dir(&cmd), state).await, "say" | "'" => cmd_say(player_id, &args, state).await, "who" => cmd_who(player_id, state).await, "take" | "get" => cmd_take(player_id, &args, state).await, "drop" => cmd_drop(player_id, &args, state).await, "inventory" | "inv" | "i" => cmd_inventory(player_id, state).await, "equip" | "eq" => cmd_equip(player_id, &args, state).await, "use" => cmd_use(player_id, &args, state).await, "examine" | "ex" | "x" => cmd_examine(player_id, &args, state).await, "talk" => cmd_talk(player_id, &args, state).await, "attack" | "a" => cmd_attack(player_id, &args, state).await, "defend" | "def" => cmd_defend(player_id, state).await, "flee" => cmd_flee(player_id, state).await, "cast" | "c" => cmd_cast(player_id, &args, state).await, "spells" | "skills" => cmd_spells(player_id, state).await, "guild" => cmd_guild(player_id, &args, state).await, "stats" | "st" => cmd_stats(player_id, state).await, "shop" => cmd_shop(player_id, &args, state).await, "admin" => cmd_admin(player_id, &args, state).await, "help" | "h" | "?" => cmd_help(player_id, state).await, "quit" | "exit" => CommandResult { output: format!("{}\r\n", ansi::system_msg("Farewell, adventurer...")), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: true, }, _ => CommandResult { output: format!( "{}\r\n", ansi::error_msg(&format!( "Unknown command: '{cmd}'. Type 'help' for commands." )) ), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, }, } } pub fn get_command_list() -> Vec<&'static str> { vec![ "look", "go", "north", "south", "east", "west", "up", "down", "say", "who", "take", "drop", "inventory", "equip", "use", "examine", "talk", "attack", "defend", "flee", "cast", "spells", "skills", "guild", "stats", "help", "shop", ] } fn send(session: &mut Session, channel: ChannelId, text: &str) -> Result<(), russh::Error> { session.data(channel, CryptoVec::from(text.as_bytes()))?; Ok(()) } fn attitude_color(att: Attitude) -> &'static str { match att { Attitude::Friendly => ansi::GREEN, Attitude::Neutral | Attitude::Wary => ansi::YELLOW, Attitude::Aggressive | Attitude::Hostile => ansi::RED, } } pub fn get_time_of_day(tick: u64) -> &'static str { let day_tick = tick % 1440; if day_tick < 360 { "Night" } else if day_tick < 720 { "Morning" } else if day_tick < 1080 { "Afternoon" } else { "Evening" } } pub fn render_room_view( room_id: &str, player_id: usize, st: &crate::game::GameState, ) -> String { let room = match st.world.get_room(room_id) { Some(r) => r, None => return format!("{}\r\n", ansi::error_msg("You are in the void.")), }; let player_name = st .players .get(&player_id) .map(|c| c.player.name.as_str()) .unwrap_or(""); let time_of_day = get_time_of_day(st.tick_count); let mut out = format!( "\r\n{} {} {}\r\n {}\r\n", ansi::room_name(&room.name), ansi::system_msg(&format!("[{}]", room.region)), ansi::color(ansi::YELLOW, &format!("[{}]", time_of_day)), room.description ); if room.outdoors { out.push_str(&format!(" {}\r\n", ansi::color(ansi::CYAN, st.weather.kind.description()))); } let npc_strs: Vec = room .npcs .iter() .filter_map(|id| { let npc = st.world.get_npc(id)?; if !st.npc_instances.get(id).map(|i| i.alive).unwrap_or(true) { return None; } let att = st.npc_attitude_toward(id, player_name); Some(ansi::color(attitude_color(att), &npc.name)) }) .collect(); if !npc_strs.is_empty() { out.push_str(&format!( "\r\n{}{}\r\n", ansi::color(ansi::DIM, "Present: "), npc_strs.join(", ") )); } let obj_strs: Vec = room .objects .iter() .filter_map(|id| st.world.get_object(id)) .map(|o| ansi::color(ansi::CYAN, &o.name)) .collect(); if !obj_strs.is_empty() { out.push_str(&format!( "{}{}\r\n", ansi::color(ansi::DIM, "You see: "), obj_strs.join(", ") )); } let others = st.players_in_room(room_id, player_id); if !others.is_empty() { let names: Vec = others .iter() .map(|c| ansi::player_name(&c.player.name)) .collect(); out.push_str(&format!( "{}{}\r\n", ansi::color(ansi::GREEN, "Players here: "), names.join(", ") )); } if !room.exits.is_empty() { let mut dirs: Vec<&String> = room.exits.keys().collect(); dirs.sort(); out.push_str(&format!( "{} {}\r\n", ansi::color(ansi::DIM, "Exits:"), dirs.iter() .map(|d| ansi::direction(d)) .collect::>() .join(", ") )); } out } async fn cmd_look(pid: usize, target: &str, state: &SharedState) -> CommandResult { let st = state.lock().await; let conn = match st.players.get(&pid) { Some(c) => c, None => return simple("Error\r\n"), }; let rid = conn.player.room_id.clone(); let pname = conn.player.name.clone(); if !target.is_empty() { let low = target.to_lowercase(); // Check NPCs in the room if let Some(room) = st.world.get_room(&rid) { for nid in &room.npcs { if let Some(npc) = st.world.get_npc(nid) { if npc.name.to_lowercase().contains(&low) { let inst = st.npc_instances.get(nid); let alive = inst.map(|i| i.alive).unwrap_or(true); let att = st.npc_attitude_toward(nid, &pname); let mut out = format!( "\r\n{}\r\n {}\r\n", ansi::bold(&npc.name), npc.description ); if let Some(inst) = inst { let rname = st.world.races.iter().find(|r| r.id == inst.race_id).map(|r| r.name.as_str()).unwrap_or("???"); let cname = st.world.classes.iter().find(|c| c.id == inst.class_id).map(|c| c.name.as_str()).unwrap_or("???"); out.push_str(&format!(" {} {}\r\n", ansi::color(ansi::CYAN, rname), ansi::color(ansi::DIM, cname))); } if !alive { out.push_str(&format!(" {}\r\n", ansi::color(ansi::RED, "(dead)"))); } else if let Some(ref c) = npc.combat { let hp = inst.map(|i| i.hp).unwrap_or(c.max_hp); out.push_str(&format!( " HP: {}/{} | ATK: {} | DEF: {}\r\n", hp, c.max_hp, c.attack, c.defense )); } out.push_str(&format!( " Attitude: {}\r\n", ansi::color(attitude_color(att), att.label()) )); return CommandResult { output: out, broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, }; } } } // Check objects in the room for oid in &room.objects { if let Some(obj) = st.world.get_object(oid) { if obj.name.to_lowercase().contains(&low) { return CommandResult { output: format!( "\r\n{}\r\n {}\r\n", ansi::bold(&obj.name), obj.description ), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, }; } } } // Check exits for (dir, dest_id) in &room.exits { if dir.to_lowercase().contains(&low) { let dest_name = st.world.get_room(dest_id) .map(|r| r.name.as_str()) .unwrap_or("???"); return CommandResult { output: format!( "{} leads to {}.\r\n", ansi::direction(dir), ansi::room_name(dest_name), ), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, }; } } } // Check inventory for obj in &conn.player.inventory { if obj.name.to_lowercase().contains(&low) { return CommandResult { output: format!( "\r\n{}\r\n {}\r\n", ansi::bold(&obj.name), obj.description ), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, }; } } // Check other players in the room for other in st.players_in_room(&rid, pid) { if other.player.name.to_lowercase().contains(&low) { let race_name = st.world.races.iter() .find(|r| r.id == other.player.race_id) .map(|r| r.name.as_str()) .unwrap_or("???"); let combat_str = if other.combat.is_some() { " [in combat]" } else { "" }; return CommandResult { output: format!( "\r\n{} — a {} {}{}\r\n", ansi::player_name(&other.player.name), race_name, ansi::system_msg(&format!("(level {})", other.player.stats.level)), ansi::color(ansi::RED, combat_str), ), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, }; } } return simple(&format!( "{}\r\n", ansi::error_msg(&format!("You don't see '{target}' here.")) )); } // No target — show the room let mut out = render_room_view(&rid, pid, &st); if let Some(conn) = st.players.get(&pid) { if let Some(ref combat) = conn.combat { if let Some(npc) = st.world.get_npc(&combat.npc_id) { let npc_hp = st .npc_instances .get(&combat.npc_id) .map(|i| i.hp) .unwrap_or(0); let npc_max = npc .combat .as_ref() .map(|c| c.max_hp) .unwrap_or(1); out.push_str(&format!( "\r\n {} In combat with {} (HP: {}/{})\r\n", ansi::color(ansi::RED, "!!"), ansi::color(ansi::RED, &npc.name), npc_hp, npc_max, )); } } } CommandResult { output: out, broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, } } async fn cmd_go(pid: usize, direction: &str, state: &SharedState) -> CommandResult { let dl = direction.to_lowercase(); let direction = resolve_dir(&dl); let mut st = state.lock().await; // Block movement in combat if let Some(conn) = st.players.get(&pid) { if conn.combat.is_some() { return simple(&format!( "{}\r\n", ansi::error_msg("You can't move while in combat! Use 'flee' to escape.") )); } } let (old_rid, new_rid, pname) = { let conn = match st.players.get(&pid) { Some(c) => c, None => return simple("Error\r\n"), }; let room = match st.world.get_room(&conn.player.room_id) { Some(r) => r, None => return simple("Void\r\n"), }; match room.exits.get(direction) { Some(dest) => ( conn.player.room_id.clone(), dest.clone(), conn.player.name.clone(), ), None => { return simple(&format!( "{}\r\n", ansi::error_msg(&format!("You can't go {direction}.")) )) } } }; let leave = CryptoVec::from( format!( "{}\r\n{}", ansi::system_msg(&format!("{pname} heads {direction}.")), ansi::prompt() ) .as_bytes(), ); let mut bcast = Vec::new(); for c in st.players_in_room(&old_rid, pid) { if let (Some(ch), Some(h)) = (c.channel, &c.handle) { bcast.push(BroadcastMsg { channel: ch, handle: h.clone(), data: leave.clone(), }); } } if let Some(c) = st.players.get_mut(&pid) { c.player.room_id = new_rid.clone(); } let arrive = CryptoVec::from( format!( "\r\n{}\r\n{}", ansi::system_msg(&format!("{pname} arrives.")), ansi::prompt() ) .as_bytes(), ); for c in st.players_in_room(&new_rid, pid) { if let (Some(ch), Some(h)) = (c.channel, &c.handle) { bcast.push(BroadcastMsg { channel: ch, handle: h.clone(), data: arrive.clone(), }); } } st.save_player_to_db(pid); let output = render_room_view(&new_rid, pid, &st); CommandResult { output, broadcasts: bcast, kick_targets: Vec::new(), quit: false, } } async fn cmd_say(pid: usize, msg: &str, state: &SharedState) -> CommandResult { if msg.is_empty() { return simple(&format!("{}\r\n", ansi::error_msg("Say what?"))); } let st = state.lock().await; let conn = match st.players.get(&pid) { Some(c) => c, None => return simple("Error\r\n"), }; let name = conn.player.name.clone(); let rid = conn.player.room_id.clone(); let self_msg = format!( "{}You say: {}{}\r\n", ansi::BOLD, ansi::RESET, ansi::color(ansi::WHITE, msg) ); let other = CryptoVec::from( format!( "\r\n{} says: {}{}\r\n{}", ansi::player_name(&name), ansi::RESET, ansi::color(ansi::WHITE, msg), ansi::prompt() ) .as_bytes(), ); let bcast: Vec<_> = st .players_in_room(&rid, pid) .iter() .filter_map(|c| { if let (Some(ch), Some(h)) = (c.channel, &c.handle) { Some(BroadcastMsg { channel: ch, handle: h.clone(), data: other.clone(), }) } else { None } }) .collect(); CommandResult { output: self_msg, broadcasts: bcast, kick_targets: Vec::new(), quit: false, } } async fn cmd_who(pid: usize, state: &SharedState) -> CommandResult { let st = state.lock().await; let sn = st .players .get(&pid) .map(|c| c.player.name.clone()) .unwrap_or_default(); let mut out = format!("\r\n{}\r\n", ansi::bold("=== Who's Online ===")); for c in st.players.values() { let rn = st .world .get_room(&c.player.room_id) .map(|r| r.name.as_str()) .unwrap_or("???"); let m = if c.player.name == sn { " (you)" } else { "" }; let admin_tag = if c.player.is_admin { " [ADMIN]" } else { "" }; let combat_tag = if c.combat.is_some() { " [COMBAT]" } else { "" }; out.push_str(&format!( " {} — {}{}{}{}\r\n", ansi::player_name(&c.player.name), ansi::room_name(rn), ansi::system_msg(m), ansi::color(ansi::YELLOW, admin_tag), ansi::color(ansi::RED, combat_tag), )); } out.push_str(&format!( "{}\r\n", ansi::system_msg(&format!("{} player(s) online", st.players.len())) )); CommandResult { output: out, broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, } } async fn cmd_take(pid: usize, target: &str, state: &SharedState) -> CommandResult { if target.is_empty() { return simple("Take what?\r\n"); } let mut st = state.lock().await; let rid = match st.players.get(&pid) { Some(c) => c.player.room_id.clone(), None => return simple("Error\r\n"), }; let room = match st.world.rooms.get(&rid) { Some(r) => r, None => return simple("Void\r\n"), }; let low = target.to_lowercase(); let oid = match room.objects.iter().find(|id| { st.world .get_object(id) .map(|o| o.name.to_lowercase().contains(&low)) .unwrap_or(false) }) { Some(id) => id.clone(), None => { return simple(&format!( "{}\r\n", ansi::error_msg(&format!("You don't see '{target}' here.")) )) } }; let obj = match st.world.get_object(&oid) { Some(o) => o.clone(), None => return simple("Gone.\r\n"), }; if !obj.takeable { return simple(&format!( "{}\r\n", ansi::error_msg(&format!("You can't take the {}.", obj.name)) )); } if let Some(room) = st.world.rooms.get_mut(&rid) { room.objects.retain(|id| id != &oid); } if let Some(c) = st.players.get_mut(&pid) { c.player.inventory.push(obj.clone()); } st.save_player_to_db(pid); CommandResult { output: format!( "You pick up the {}.\r\n", ansi::color(ansi::CYAN, &obj.name) ), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, } } async fn cmd_drop(pid: usize, target: &str, state: &SharedState) -> CommandResult { if target.is_empty() { return simple("Drop what?\r\n"); } let mut st = state.lock().await; let conn = match st.players.get_mut(&pid) { Some(c) => c, None => return simple("Error\r\n"), }; let low = target.to_lowercase(); let idx = match conn .player .inventory .iter() .position(|o| o.name.to_lowercase().contains(&low)) { Some(i) => i, None => { return simple(&format!( "{}\r\n", ansi::error_msg(&format!("You don't have '{target}'.")) )) } }; let obj = conn.player.inventory.remove(idx); let name = obj.name.clone(); let oid = obj.id.clone(); let rid = conn.player.room_id.clone(); if let Some(room) = st.world.rooms.get_mut(&rid) { room.objects.push(oid); } st.save_player_to_db(pid); CommandResult { output: format!("You drop the {}.\r\n", ansi::color(ansi::CYAN, &name)), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, } } async fn cmd_inventory(pid: usize, state: &SharedState) -> CommandResult { let st = state.lock().await; let conn = match st.players.get(&pid) { Some(c) => c, None => return simple("Error\r\n"), }; let mut out = format!("\r\n{}\r\n", ansi::bold("=== Inventory ===")); if !conn.player.equipped.is_empty() { out.push_str(&format!(" {}\r\n", ansi::color(ansi::DIM, "Equipped:"))); let mut slots: Vec<(&String, &crate::world::Object)> = conn.player.equipped.iter().collect(); slots.sort_by_key(|(s, _)| (*s).clone()); for (slot, obj) in &slots { let bonus = if let Some(dmg) = obj.stats.damage { format!(" (+{} dmg)", dmg) } else if let Some(arm) = obj.stats.armor { format!(" (+{} def)", arm) } else { String::new() }; out.push_str(&format!( " {}: {} {}\r\n", ansi::color(ansi::YELLOW, slot), ansi::color(ansi::CYAN, &obj.name), ansi::system_msg(&bonus), )); } } if conn.player.inventory.is_empty() { out.push_str(&format!(" {}\r\n", ansi::system_msg("(empty)"))); } else { for o in &conn.player.inventory { let k = o .kind .as_deref() .map(|k| format!(" [{}]", k)) .unwrap_or_default(); out.push_str(&format!( " {} {}\r\n", ansi::color(ansi::CYAN, &o.name), ansi::system_msg(&k) )); } } CommandResult { output: out, broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, } } async fn cmd_equip(pid: usize, target: &str, state: &SharedState) -> CommandResult { if target.is_empty() { return simple("Equip what?\r\n"); } let mut st = state.lock().await; // Extract race slots before mutable borrow let race_id = match st.players.get(&pid) { Some(c) => c.player.race_id.clone(), None => return simple("Error\r\n"), }; let race_slots: Vec = st.world.races.iter() .find(|r| r.id == race_id) .map(|r| r.slots.clone()) .unwrap_or_else(|| crate::world::DEFAULT_HUMANOID_SLOTS.iter().map(|s| s.to_string()).collect()); let conn = match st.players.get_mut(&pid) { Some(c) => c, None => return simple("Error\r\n"), }; let low = target.to_lowercase(); let idx = match conn .player .inventory .iter() .position(|o| o.name.to_lowercase().contains(&low)) { Some(i) => i, None => { return simple(&format!( "{}\r\n", ansi::error_msg(&format!("You don't have '{target}'.")) )) } }; let obj = conn.player.inventory.remove(idx); let name = obj.name.clone(); let slot = if let Some(ref s) = obj.slot { s.clone() } else { match obj.kind.as_deref() { Some("weapon") => "main_hand".into(), Some("armor") => "torso".into(), _ => { conn.player.inventory.push(obj); return simple(&format!( "{}\r\n", ansi::error_msg(&format!("You can't equip the {}.", name)) )); } } }; if !race_slots.contains(&slot) { conn.player.inventory.push(obj); return simple(&format!( "{}\r\n", ansi::error_msg(&format!("Your body doesn't have a {} slot.", slot)) )); } if let Some(old) = conn.player.equipped.remove(&slot) { conn.player.inventory.push(old); } conn.player.equipped.insert(slot.clone(), obj); let _ = conn; st.save_player_to_db(pid); CommandResult { output: format!( "You equip the {} in your {} slot.\r\n", ansi::color(ansi::CYAN, &name), ansi::color(ansi::YELLOW, &slot), ), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, } } async fn cmd_use(pid: usize, target: &str, state: &SharedState) -> CommandResult { if target.is_empty() { return simple("Use what?\r\n"); } let mut st = state.lock().await; let conn = match st.players.get_mut(&pid) { Some(c) => c, None => return simple("Error\r\n"), }; let low = target.to_lowercase(); let idx = match conn .player .inventory .iter() .position(|o| o.name.to_lowercase().contains(&low)) { Some(i) => i, None => { return simple(&format!( "{}\r\n", ansi::error_msg(&format!("You don't have '{target}'.")) )) } }; // In combat: queue the use action for the next tick if conn.combat.is_some() { let obj = &conn.player.inventory[idx]; if obj.kind.as_deref() != Some("consumable") { return simple(&format!( "{}\r\n", ansi::error_msg(&format!("You can't use the {} in combat.", obj.name)) )); } if let Some(ref mut combat) = conn.combat { combat.action = Some(CombatAction::UseItem(idx)); } let name = obj.name.clone(); return CommandResult { output: format!( "{}\r\n", ansi::system_msg(&format!( "You prepare to use the {}... (resolves next tick)", name )) ), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, }; } // Out of combat: use immediately let obj = &conn.player.inventory[idx]; if obj.kind.as_deref() != Some("consumable") { return simple(&format!( "{}\r\n", ansi::error_msg(&format!("You can't use the {}.", obj.name)) )); } let heal = obj.stats.heal_amount.unwrap_or(0); let name = obj.name.clone(); conn.player.inventory.remove(idx); let old_hp = conn.player.stats.hp; conn.player.stats.hp = (conn.player.stats.hp + heal).min(conn.player.stats.max_hp); let healed = conn.player.stats.hp - old_hp; let new_hp = conn.player.stats.hp; let max_hp = conn.player.stats.max_hp; let _ = conn; st.save_player_to_db(pid); CommandResult { output: format!( "You use the {}. Restored {} HP. ({}/{})\r\n", ansi::color(ansi::CYAN, &name), ansi::color(ansi::GREEN, &healed.to_string()), new_hp, max_hp ), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, } } async fn cmd_examine(pid: usize, target: &str, state: &SharedState) -> CommandResult { if target.is_empty() { return simple("Examine what?\r\n"); } let st = state.lock().await; let conn = match st.players.get(&pid) { Some(c) => c, None => return simple("Error\r\n"), }; let low = target.to_lowercase(); let pname = &conn.player.name; if let Some(room) = st.world.get_room(&conn.player.room_id) { for nid in &room.npcs { if let Some(npc) = st.world.get_npc(nid) { if npc.name.to_lowercase().contains(&low) { let inst = st.npc_instances.get(nid); let alive = inst.map(|i| i.alive).unwrap_or(true); let att = st.npc_attitude_toward(nid, pname); let mut out = format!("\r\n{}\r\n {}\r\n", ansi::bold(&npc.name), npc.description); if let Some(inst) = inst { let rname = st.world.races.iter().find(|r| r.id == inst.race_id).map(|r| r.name.as_str()).unwrap_or("???"); let cname = st.world.classes.iter().find(|c| c.id == inst.class_id).map(|c| c.name.as_str()).unwrap_or("???"); out.push_str(&format!(" {} {}\r\n", ansi::color(ansi::CYAN, rname), ansi::color(ansi::DIM, cname))); } if !alive { out.push_str(&format!(" {}\r\n", ansi::color(ansi::RED, "(dead)"))); } else if let Some(ref c) = npc.combat { let hp = inst.map(|i| i.hp).unwrap_or(c.max_hp); out.push_str(&format!( " HP: {}/{} | ATK: {} | DEF: {}\r\n", hp, c.max_hp, c.attack, c.defense )); } out.push_str(&format!( " Attitude: {}\r\n", ansi::color(attitude_color(att), att.label()) )); return CommandResult { output: out, broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, }; } } } for oid in &room.objects { if let Some(obj) = st.world.get_object(oid) { if obj.name.to_lowercase().contains(&low) { return CommandResult { output: format!( "\r\n{}\r\n {}\r\n", ansi::bold(&obj.name), obj.description ), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, }; } } } } for obj in &conn.player.inventory { if obj.name.to_lowercase().contains(&low) { return CommandResult { output: format!( "\r\n{}\r\n {}\r\n", ansi::bold(&obj.name), obj.description ), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, }; } } simple(&format!( "{}\r\n", ansi::error_msg(&format!("You don't see '{target}'.")) )) } async fn cmd_talk(pid: usize, input: &str, state: &SharedState) -> CommandResult { if input.is_empty() { return simple("Talk to whom? (Usage: talk [keyword])\r\n"); } let st = state.lock().await; let conn = match st.players.get(&pid) { Some(c) => c, None => return simple("Error\r\n"), }; let room = match st.world.get_room(&conn.player.room_id) { Some(r) => r, None => return simple("Void\r\n"), }; let (target, keyword) = match input.split_once(' ') { Some((t, k)) => (t.to_lowercase(), k.trim().to_lowercase()), None => (input.to_lowercase(), String::new()), }; let pname = &conn.player.name; for nid in &room.npcs { if let Some(npc) = st.world.get_npc(nid) { if npc.name.to_lowercase().contains(&target) { if !st.npc_instances.get(nid).map(|i| i.alive).unwrap_or(true) { return simple(&format!( "{}\r\n", ansi::error_msg(&format!("{} is dead.", npc.name)) )); } let att = st.npc_attitude_toward(nid, pname); if !att.will_talk() { return simple(&format!( "{} snarls at you menacingly.\r\n", ansi::color(ansi::RED, &npc.name) )); } if !keyword.is_empty() { if let Some(response) = npc.keywords.get(&keyword) { return CommandResult { output: format!( "\r\n{} says: \"{}\"\r\n", ansi::color(ansi::YELLOW, &npc.name), ansi::color(ansi::WHITE, response) ), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, }; } else { return simple(&format!( "{} looks at you blankly, not understanding '{}'.\r\n", ansi::color(ansi::YELLOW, &npc.name), keyword )); } } let greeting = npc.greeting.as_deref().unwrap_or("..."); let mut output = format!( "\r\n{} says: \"{}\"\r\n", ansi::color(ansi::YELLOW, &npc.name), ansi::color(ansi::WHITE, greeting) ); if !npc.keywords.is_empty() { let mut keys: Vec<_> = npc.keywords.keys().cloned().collect(); keys.sort(); output.push_str(&format!( " {} {}\r\n", ansi::color(ansi::DIM, "You can ask about:"), keys.join(", ") )); } if npc.shop.is_some() { output.push_str(&format!( " {}\r\n", ansi::color(ansi::CYAN, "This person appears to be a merchant. Try 'shop list'.") )); } return CommandResult { output, broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, }; } } } simple(&format!( "{}\r\n", ansi::error_msg(&format!("You don't see '{target}' here to talk to.")) )) } async fn cmd_attack(pid: usize, target: &str, state: &SharedState) -> CommandResult { let mut st = state.lock().await; // If already in combat, queue attack action let already_in_combat = st .players .get(&pid) .map(|c| c.combat.is_some()) .unwrap_or(false); if already_in_combat { let npc_name = st .players .get(&pid) .and_then(|c| c.combat.as_ref()) .and_then(|combat| st.world.get_npc(&combat.npc_id)) .map(|n| n.name.clone()) .unwrap_or_else(|| "???".into()); if let Some(conn) = st.players.get_mut(&pid) { if let Some(ref mut combat) = conn.combat { combat.action = Some(CombatAction::Attack); } } return CommandResult { output: format!( "{}\r\n", ansi::system_msg(&format!( "You ready an attack against {}... (resolves next tick)", npc_name )) ), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, }; } // Not in combat: initiate combat if target.is_empty() { return simple("Attack what?\r\n"); } let npc_id = { let conn = match st.players.get(&pid) { Some(c) => c, None => return simple("Error\r\n"), }; let room = match st.world.get_room(&conn.player.room_id) { Some(r) => r, None => return simple("Void\r\n"), }; let low = target.to_lowercase(); let found = room.npcs.iter().find(|nid| { if let Some(npc) = st.world.get_npc(nid) { npc.name.to_lowercase().contains(&low) && npc.combat.is_some() } else { false } }); match found { Some(id) => { if !st .npc_instances .get(id) .map(|i| i.alive) .unwrap_or(false) { return simple(&format!( "{}\r\n", ansi::error_msg("That target is already dead.") )); } id.clone() } None => { return simple(&format!( "{}\r\n", ansi::error_msg(&format!("No attackable target '{target}' here.")) )) } } }; let npc_name = st .world .get_npc(&npc_id) .map(|n| n.name.clone()) .unwrap_or_default(); // Attitude penalty for attacking non-hostile NPCs let pname = st .players .get(&pid) .map(|c| c.player.name.clone()) .unwrap_or_default(); let att = st.npc_attitude_toward(&npc_id, &pname); let mut extra_msg = String::new(); if !att.is_hostile() { st.shift_attitude(&npc_id, &pname, -30); if let Some(faction) = st.world.get_npc(&npc_id).and_then(|n| n.faction.clone()) { st.shift_faction_attitude(&faction, &pname, -15); } extra_msg = format!( "{}\r\n", ansi::color(ansi::RED, " The locals look on in horror as you attack without provocation!") ); } if let Some(c) = st.players.get_mut(&pid) { c.combat = Some(CombatState { npc_id: npc_id.clone(), action: Some(CombatAction::Attack), defending: false, }); } log::info!(target: "{combat}", "Combat: Player '{}' (ID {}) engaged NPC '{}' ({}) in combat", pname, pid, npc_name, npc_id); CommandResult { output: format!( "{}\r\n{}\r\n{}", ansi::system_msg(&format!("You engage {} in combat!", npc_name)), ansi::system_msg("Your attack will resolve on the next tick. Use 'attack', 'defend', 'flee', or 'use '."), extra_msg, ), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, } } async fn cmd_defend(pid: usize, state: &SharedState) -> CommandResult { let mut st = state.lock().await; let conn = match st.players.get_mut(&pid) { Some(c) => c, None => return simple("Error\r\n"), }; if conn.combat.is_none() { return simple(&format!( "{}\r\n", ansi::error_msg("You're not in combat.") )); } if let Some(ref mut combat) = conn.combat { combat.action = Some(CombatAction::Defend); } CommandResult { output: format!( "{}\r\n", ansi::system_msg("You prepare to defend... (resolves next tick)") ), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, } } async fn cmd_flee(pid: usize, state: &SharedState) -> CommandResult { let mut st = state.lock().await; let conn = match st.players.get_mut(&pid) { Some(c) => c, None => return simple("Error\r\n"), }; if conn.combat.is_none() { return simple(&format!( "{}\r\n", ansi::error_msg("You're not in combat.") )); } if let Some(ref mut combat) = conn.combat { combat.action = Some(CombatAction::Flee); } CommandResult { output: format!( "{}\r\n", ansi::system_msg("You prepare to flee... (resolves next tick)") ), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, } } async fn cmd_cast(pid: usize, target: &str, state: &SharedState) -> CommandResult { if target.is_empty() { return simple(&format!("{}\r\n", ansi::error_msg("Cast what? Usage: cast [target]"))); } let mut st = state.lock().await; let (spell_parts, _npc_target) = match target.split_once(' ') { Some((s, t)) => (s.to_lowercase(), Some(t.to_string())), None => (target.to_lowercase(), None::), }; // Find spell by name match across all guilds the player belongs to let player_guilds: Vec<(String, i32)> = match st.players.get(&pid) { Some(c) => c.player.guilds.iter().map(|(k, v)| (k.clone(), *v)).collect(), None => return simple("Error\r\n"), }; let mut found_spell: Option = None; for (gid, glvl) in &player_guilds { let available = st.world.spells_for_guild(gid, *glvl); for spell in available { if spell.name.to_lowercase().contains(&spell_parts) || spell.id.ends_with(&format!(":{}", spell_parts)) { found_spell = Some(spell.id.clone()); break; } } if found_spell.is_some() { break; } } let spell_id = match found_spell { Some(id) => id, None => return simple(&format!("{}\r\n", ansi::error_msg(&format!("You don't know a spell called '{}'.", spell_parts)))), }; let spell = match st.world.get_spell(&spell_id) { Some(s) => s.clone(), None => return simple("Error\r\n"), }; let conn = match st.players.get(&pid) { Some(c) => c, None => return simple("Error\r\n"), }; // Check cooldown if let Some(&cd) = conn.player.cooldowns.get(&spell_id) { if cd > 0 { return simple(&format!("{}\r\n", ansi::error_msg(&format!("{} is on cooldown ({} ticks remaining).", spell.name, cd)))); } } // Check resource cost if spell.cost_mana > 0 && conn.player.stats.mana < spell.cost_mana { return simple(&format!("{}\r\n", ansi::error_msg(&format!("Not enough mana for {} (need {}, have {}).", spell.name, spell.cost_mana, conn.player.stats.mana)))); } if spell.cost_endurance > 0 && conn.player.stats.endurance < spell.cost_endurance { return simple(&format!("{}\r\n", ansi::error_msg(&format!("Not enough endurance for {} (need {}, have {}).", spell.name, spell.cost_endurance, conn.player.stats.endurance)))); } let in_combat = conn.combat.is_some(); let _ = conn; if in_combat { // Queue the cast as a combat action if let Some(conn) = st.players.get_mut(&pid) { if let Some(ref mut combat) = conn.combat { combat.action = Some(CombatAction::Cast(spell_id.clone())); } } return CommandResult { output: format!("{}\r\n", ansi::system_msg(&format!("You begin casting {}... (resolves next tick)", spell.name)) ), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, }; } // Out of combat: resolve immediately for heal/utility spells if spell.spell_type == "heal" { if let Some(conn) = st.players.get_mut(&pid) { conn.player.stats.mana -= spell.cost_mana; conn.player.stats.endurance -= spell.cost_endurance; let old_hp = conn.player.stats.hp; conn.player.stats.hp = (conn.player.stats.hp + spell.heal).min(conn.player.stats.max_hp); let healed = conn.player.stats.hp - old_hp; if spell.cooldown_ticks > 0 { conn.player.cooldowns.insert(spell_id, spell.cooldown_ticks); } let _ = conn; st.save_player_to_db(pid); return CommandResult { output: format!("You cast {}! Restored {} HP.\r\n", ansi::color(ansi::CYAN, &spell.name), ansi::color(ansi::GREEN, &healed.to_string()), ), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, }; } } if spell.spell_type == "utility" { if let Some(conn) = st.players.get_mut(&pid) { conn.player.stats.mana -= spell.cost_mana; conn.player.stats.endurance -= spell.cost_endurance; if spell.cooldown_ticks > 0 { conn.player.cooldowns.insert(spell_id, spell.cooldown_ticks); } let pname = conn.player.name.clone(); let eff_clone = spell.effect.clone(); let eff_dur = spell.effect_duration; let eff_mag = spell.effect_magnitude; let _ = conn; if let Some(ref eff) = eff_clone { st.db.save_effect(&pname, eff, eff_dur, eff_mag); } st.save_player_to_db(pid); return CommandResult { output: format!("You cast {}!\r\n", ansi::color(ansi::CYAN, &spell.name)), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, }; } } // Offensive spells require a target / combat simple(&format!("{}\r\n", ansi::error_msg(&format!("{} is an offensive spell — enter combat first.", spell.name)))) } async fn cmd_spells(pid: usize, state: &SharedState) -> CommandResult { let st = state.lock().await; let conn = match st.players.get(&pid) { Some(c) => c, None => return simple("Error\r\n"), }; if conn.player.guilds.is_empty() { return simple(&format!("{}\r\n", ansi::system_msg("You haven't joined any guilds."))); } let mut out = format!("\r\n{}\r\n", ansi::bold("=== Known Spells & Skills ===")); let mut guild_list: Vec<_> = conn.player.guilds.iter().collect(); guild_list.sort_by_key(|(id, _)| (*id).clone()); for (gid, glvl) in &guild_list { let gname = st.world.get_guild(gid) .map(|g| g.name.as_str()) .unwrap_or("???"); out.push_str(&format!("\r\n {} (level {}):\r\n", ansi::color(ansi::YELLOW, gname), glvl)); let spells = st.world.spells_for_guild(gid, **glvl); if spells.is_empty() { out.push_str(&format!(" {}\r\n", ansi::system_msg("(no spells at this level)"))); } else { for spell in &spells { let cost = if spell.cost_mana > 0 { format!("{}mp", spell.cost_mana) } else if spell.cost_endurance > 0 { format!("{}ep", spell.cost_endurance) } else { "free".into() }; let cd = if spell.cooldown_ticks > 0 { format!(" cd:{}t", spell.cooldown_ticks) } else { String::new() }; let player_cd = conn.player.cooldowns.get(&spell.id).copied().unwrap_or(0); let cd_str = if player_cd > 0 { format!(" {}", ansi::color(ansi::RED, &format!("[{} ticks]", player_cd))) } else { String::new() }; out.push_str(&format!( " {} {} [{}{}]{}\r\n {}\r\n", ansi::color(ansi::CYAN, &spell.name), ansi::system_msg(&format!("({})", spell.spell_type)), cost, cd, cd_str, ansi::color(ansi::DIM, &spell.description), )); } } } CommandResult { output: out, broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, } } async fn cmd_shop(pid: usize, args: &str, state: &SharedState) -> CommandResult { let mut st = state.lock().await; let rid = match st.players.get(&pid) { Some(c) => c.player.room_id.clone(), None => return simple("Error\r\n"), }; // Find a merchant in the room let mut merchant_id = None; if let Some(room) = st.world.get_room(&rid) { for nid in &room.npcs { if let Some(npc) = st.world.get_npc(nid) { if npc.shop.is_some() { merchant_id = Some(nid.clone()); break; } } } } let merchant_id = match merchant_id { Some(id) => id, None => return simple("There is no merchant here.\r\n"), }; let (subcmd, subargs) = match args.split_once(' ') { Some((c, a)) => (c.to_lowercase(), a.trim()), None => (args.to_lowercase(), ""), }; match subcmd.as_str() { "list" | "ls" | "" => { let npc = st.world.get_npc(&merchant_id).unwrap(); let shop = npc.shop.as_ref().unwrap(); let mut out = format!( "\r\n{}'s Shop Inventory (Markup: x{:.1})\r\n", ansi::bold(&npc.name), shop.markup ); if shop.sells.is_empty() { out.push_str(" (nothing for sale)\r\n"); } else { for item_id in &shop.sells { if let Some(obj) = st.world.get_object(item_id) { let total_copper = (obj.value_gold * 10000 + obj.value_silver * 100 + obj.value_copper) as f32; let price_copper = (total_copper * shop.markup).ceil() as i32; let g = price_copper / 10000; let s = (price_copper % 10000) / 100; let c = price_copper % 100; out.push_str(&format!( " - {} [{}g {}s {}c]\r\n", ansi::color(ansi::CYAN, &obj.name), g, s, c )); } } } simple(&out) } "buy" => { if subargs.is_empty() { return simple("Buy what?\r\n"); } let (shop, _npc_name) = { let npc = st.world.get_npc(&merchant_id).unwrap(); (npc.shop.as_ref().unwrap().clone(), npc.name.clone()) }; let item_id = shop.sells.iter().find(|id| { if let Some(obj) = st.world.get_object(*id) { obj.name.to_lowercase().contains(&subargs.to_lowercase()) } else { false } }).cloned(); if let Some(id) = item_id { let obj = st.world.get_object(&id).unwrap().clone(); let total_copper = (obj.value_gold * 10000 + obj.value_silver * 100 + obj.value_copper) as f32; let price_copper = (total_copper * shop.markup).ceil() as i32; if let Some(conn) = st.players.get_mut(&pid) { let player_total_copper = conn.player.gold * 10000 + conn.player.silver * 100 + conn.player.copper; if player_total_copper < price_copper { return simple("You don't have enough money.\r\n"); } // Deduct money let mut remaining = player_total_copper - price_copper; conn.player.gold = remaining / 10000; remaining %= 10000; conn.player.silver = remaining / 100; conn.player.copper = remaining % 100; // Add to inventory conn.player.inventory.push(obj.clone()); simple(&format!( "You buy {} for {} copper equivalents.\r\n", ansi::color(ansi::CYAN, &obj.name), price_copper )) } else { simple("Error\r\n") } } else { simple("The merchant doesn't sell that.\r\n") } } "sell" => { if subargs.is_empty() { return simple("Sell what?\r\n"); } let shop = st.world.get_npc(&merchant_id).unwrap().shop.as_ref().unwrap().clone(); let item_info = if let Some(conn) = st.players.get(&pid) { conn.player.inventory.iter().position(|o| o.name.to_lowercase().contains(&subargs.to_lowercase())) .map(|idx| (idx, conn.player.inventory[idx].clone())) } else { None }; if let Some((idx, obj)) = item_info { // Check if merchant buys this kind of item let can_sell = shop.buys.is_empty() || shop.buys.iter().any(|k| { if let Some(kind) = &obj.kind { kind.to_lowercase() == k.to_lowercase() } else { false } }); if !can_sell { return simple("The merchant isn't interested in that kind of item.\r\n"); } let total_copper = (obj.value_gold * 10000 + obj.value_silver * 100 + obj.value_copper) as f32; let price_copper = (total_copper * shop.markdown).floor() as i32; if let Some(conn) = st.players.get_mut(&pid) { // Add money to player let mut player_total_copper = conn.player.gold * 10000 + conn.player.silver * 100 + conn.player.copper; player_total_copper += price_copper; conn.player.gold = player_total_copper / 10000; player_total_copper %= 10000; conn.player.silver = player_total_copper / 100; conn.player.copper = player_total_copper % 100; // Remove from inventory conn.player.inventory.remove(idx); simple(&format!( "You sell {} for {} copper equivalents.\r\n", ansi::color(ansi::CYAN, &obj.name), price_copper )) } else { simple("Error\r\n") } } else { simple("You don't have that in your inventory.\r\n") } } _ => simple("Usage: shop list | shop buy | shop sell \r\n"), } } async fn cmd_guild(pid: usize, args: &str, state: &SharedState) -> CommandResult { let (subcmd, subargs) = match args.split_once(' ') { Some((c, a)) => (c.to_lowercase(), a.trim().to_string()), None => (args.to_lowercase(), String::new()), }; match subcmd.as_str() { "list" | "ls" | "" => { let st = state.lock().await; let mut out = format!("\r\n{}\r\n", ansi::bold("=== Available Guilds ===")); let mut guild_list: Vec<_> = st.world.guilds.values().collect(); guild_list.sort_by_key(|g| &g.name); for g in &guild_list { let resource = &g.resource; out.push_str(&format!( " {} {} (max level: {}, resource: {})\r\n {}\r\n", ansi::color(ansi::CYAN, &g.name), ansi::system_msg(&format!("[{}]", g.id)), g.max_level, resource, ansi::color(ansi::DIM, &g.description), )); } if guild_list.is_empty() { out.push_str(&format!(" {}\r\n", ansi::system_msg("No guilds defined."))); } CommandResult { output: out, broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false } } "info" => { if subargs.is_empty() { return simple(&format!("{}\r\n", ansi::error_msg("Usage: guild info "))); } let st = state.lock().await; let low = subargs.to_lowercase(); let guild = st.world.guilds.values().find(|g| g.name.to_lowercase().contains(&low) || g.id.ends_with(&format!(":{}", low))); match guild { Some(g) => { let mut out = format!("\r\n{}\r\n {}\r\n", ansi::bold(&g.name), g.description); out.push_str(&format!(" Max level: {} | Resource: {}\r\n", g.max_level, g.resource)); if g.min_player_level > 0 { out.push_str(&format!(" Requires player level: {}\r\n", g.min_player_level)); } let gr = &g.growth; out.push_str(&format!(" Growth/lvl: +{}hp +{}mp +{}ep +{}atk +{}def\r\n", gr.hp_per_level, gr.mana_per_level, gr.endurance_per_level, gr.attack_per_level, gr.defense_per_level)); if !g.spells.is_empty() { out.push_str(&format!(" Spells ({}):\r\n", g.spells.len())); for sid in &g.spells { if let Some(sp) = st.world.get_spell(sid) { out.push_str(&format!(" {} (lvl {}) — {}\r\n", ansi::color(ansi::CYAN, &sp.name), sp.min_guild_level, ansi::color(ansi::DIM, &sp.description), )); } } } CommandResult { output: out, broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false } } None => simple(&format!("{}\r\n", ansi::error_msg(&format!("Guild '{}' not found.", subargs)))), } } "join" => { if subargs.is_empty() { return simple(&format!("{}\r\n", ansi::error_msg("Usage: guild join "))); } let mut st = state.lock().await; let low = subargs.to_lowercase(); let guild = st.world.guilds.values() .find(|g| g.name.to_lowercase().contains(&low) || g.id.ends_with(&format!(":{}", low))) .cloned(); match guild { Some(g) => { let conn = match st.players.get(&pid) { Some(c) => c, None => return simple("Error\r\n"), }; if conn.player.guilds.contains_key(&g.id) { return simple(&format!("{}\r\n", ansi::error_msg(&format!("You're already in the {}.", g.name)))); } if g.min_player_level > 0 && conn.player.stats.level < g.min_player_level { return simple(&format!("{}\r\n", ansi::error_msg(&format!("You need player level {} to join {}.", g.min_player_level, g.name)))); } // Race restriction check if !g.race_restricted.is_empty() && g.race_restricted.contains(&conn.player.race_id) { return simple(&format!("{}\r\n", ansi::error_msg(&format!("Your race cannot join the {}.", g.name)))); } let pname = conn.player.name.clone(); let gid = g.id.clone(); let gname = g.name.clone(); let _ = conn; if let Some(conn) = st.players.get_mut(&pid) { conn.player.guilds.insert(gid.clone(), 1); // Add base resources from guild conn.player.stats.max_mana += g.base_mana; conn.player.stats.mana += g.base_mana; conn.player.stats.max_endurance += g.base_endurance; conn.player.stats.endurance += g.base_endurance; } st.db.save_guild_membership(&pname, &gid, 1); st.save_player_to_db(pid); CommandResult { output: format!("{}\r\n", ansi::system_msg(&format!("You have joined the {}!", gname))), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, } } None => simple(&format!("{}\r\n", ansi::error_msg(&format!("Guild '{}' not found.", subargs)))), } } "leave" => { if subargs.is_empty() { return simple(&format!("{}\r\n", ansi::error_msg("Usage: guild leave "))); } let mut st = state.lock().await; let low = subargs.to_lowercase(); let guild = st.world.guilds.values() .find(|g| g.name.to_lowercase().contains(&low) || g.id.ends_with(&format!(":{}", low))) .cloned(); match guild { Some(g) => { let conn = match st.players.get(&pid) { Some(c) => c, None => return simple("Error\r\n"), }; if !conn.player.guilds.contains_key(&g.id) { return simple(&format!("{}\r\n", ansi::error_msg(&format!("You're not in the {}.", g.name)))); } let pname = conn.player.name.clone(); let gid = g.id.clone(); let gname = g.name.clone(); let _ = conn; if let Some(conn) = st.players.get_mut(&pid) { conn.player.guilds.remove(&gid); } st.db.remove_guild_membership(&pname, &gid); st.save_player_to_db(pid); CommandResult { output: format!("{}\r\n", ansi::system_msg(&format!("You have left the {}.", gname))), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, } } None => simple(&format!("{}\r\n", ansi::error_msg(&format!("Guild '{}' not found.", subargs)))), } } _ => simple(&format!("{}\r\nUsage: guild list | guild info | guild join | guild leave \r\n", ansi::error_msg(&format!("Unknown guild subcommand: '{subcmd}'.")))), } } async fn cmd_stats(pid: usize, state: &SharedState) -> CommandResult { let st = state.lock().await; let conn = match st.players.get(&pid) { Some(c) => c, None => return simple("Error\r\n"), }; let p = &conn.player; let s = &p.stats; let rn = st .world .races .iter() .find(|r| r.id == p.race_id) .map(|r| r.name.as_str()) .unwrap_or("???"); let cn = st .world .classes .iter() .find(|c| c.id == p.class_id) .map(|c| c.name.as_str()) .unwrap_or("???"); let hpc = if s.hp * 3 < s.max_hp { ansi::RED } else if s.hp * 3 < s.max_hp * 2 { ansi::YELLOW } else { ansi::GREEN }; let mut out = format!("\r\n{}\r\n", ansi::bold(&format!("=== {} ===", p.name))); out.push_str(&format!( " {} {} | {} {}\r\n", ansi::color(ansi::DIM, "Race:"), ansi::color(ansi::CYAN, rn), ansi::color(ansi::DIM, "Class:"), ansi::color(ansi::CYAN, cn) )); out.push_str(&format!( " {} {}{}/{}{}\r\n", ansi::color(ansi::DIM, "HP:"), hpc, s.hp, s.max_hp, ansi::RESET )); let race_natural_atk = st.world.races.iter() .find(|r| r.id == p.race_id) .map(|r| r.natural_attacks.iter().map(|a| a.damage).max().unwrap_or(0)) .unwrap_or(0); let race_natural_def = st.world.races.iter() .find(|r| r.id == p.race_id) .map(|r| r.natural_armor) .unwrap_or(0); let equip_dmg = p.total_equipped_damage(); let equip_arm = p.total_equipped_armor(); out.push_str(&format!( " {} {} (+{} equip, +{} natural) {} {} (+{} equip, +{} natural)\r\n", ansi::color(ansi::DIM, "ATK:"), s.attack, equip_dmg.max(race_natural_atk), race_natural_atk, ansi::color(ansi::DIM, "DEF:"), s.defense, equip_arm, race_natural_def, )); out.push_str(&format!( " {} {}\r\n", ansi::color(ansi::DIM, "Level:"), s.level )); if s.max_mana > 0 { out.push_str(&format!( " {} {}{}/{}{}\r\n", ansi::color(ansi::DIM, "Mana:"), ansi::BLUE, s.mana, s.max_mana, ansi::RESET, )); } if s.max_endurance > 0 { out.push_str(&format!( " {} {}{}/{}{}\r\n", ansi::color(ansi::DIM, "Endurance:"), ansi::YELLOW, s.endurance, s.max_endurance, ansi::RESET, )); } out.push_str(&format!( " {} {}/{}\r\n", ansi::color(ansi::DIM, "XP:"), s.xp, s.xp_to_next )); out.push_str(&format!( " {} {}{}g {}{}s {}{}c{}\r\n", ansi::color(ansi::DIM, "Money:"), ansi::YELLOW, p.gold, ansi::WHITE, p.silver, ansi::RED, p.copper, ansi::RESET, )); if !p.guilds.is_empty() { out.push_str(&format!(" {}\r\n", ansi::color(ansi::DIM, "Guilds:"))); let mut guild_list: Vec<_> = p.guilds.iter().collect(); guild_list.sort_by_key(|(id, _)| (*id).clone()); for (gid, glvl) in &guild_list { let gname = st.world.get_guild(gid) .map(|g| g.name.as_str()) .unwrap_or("???"); out.push_str(&format!( " {} (level {})\r\n", ansi::color(ansi::CYAN, gname), glvl, )); } } // Show combat status if let Some(ref combat) = conn.combat { let npc_name = st .world .get_npc(&combat.npc_id) .map(|n| n.name.clone()) .unwrap_or_else(|| "???".into()); out.push_str(&format!( " {} {}\r\n", ansi::color(ansi::RED, "Combat:"), ansi::color(ansi::RED, &npc_name) )); } // Show active status effects let effects = st.db.load_effects(&p.name); if !effects.is_empty() { out.push_str(&format!(" {}\r\n", ansi::color(ansi::DIM, "Effects:"))); for eff in &effects { out.push_str(&format!( " {} (mag: {}, {} ticks left)\r\n", ansi::color(ansi::MAGENTA, &eff.kind), eff.magnitude, eff.remaining_ticks, )); } } if p.is_admin { out.push_str(&format!( " {}\r\n", ansi::color(ansi::YELLOW, "[ADMIN]") )); } CommandResult { output: out, broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, } } async fn cmd_admin(pid: usize, args: &str, state: &SharedState) -> CommandResult { let st = state.lock().await; let is_admin = st .players .get(&pid) .map(|c| c.player.is_admin) .unwrap_or(false); drop(st); if !is_admin { return simple(&format!( "{}\r\n", ansi::error_msg("You don't have admin privileges.") )); } admin::execute_admin(args, pid, state).await } async fn cmd_help(pid: usize, state: &SharedState) -> CommandResult { let st = state.lock().await; let is_admin = st .players .get(&pid) .map(|c| c.player.is_admin) .unwrap_or(false); drop(st); let mut out = format!("\r\n{}\r\n", ansi::bold("=== Commands ===")); let cmds = [ ("look [target], l", "Look at room, NPC, object, exit, or player"), ("go , n/s/e/w/u/d", "Move in a direction"), ("say ", "Say something to the room"), ("who", "See who's online"), ("examine , x", "Inspect an NPC, object, or item"), ("talk ", "Talk to a friendly NPC"), ("take ", "Pick up an object"), ("drop ", "Drop an item from inventory"), ("inventory, i", "View your inventory"), ("equip ", "Equip a weapon or armor"), ("use ", "Use a consumable item"), ("attack , a", "Engage/attack a hostile NPC (tick-based)"), ("defend, def", "Defend next tick (reduces incoming damage)"), ("flee", "Attempt to flee combat (tick-based)"), ("cast , c", "Cast a spell (tick-based in combat)"), ("spells / skills", "List your available spells"), ("guild list/info/join/leave", "Guild management"), ("stats, st", "View your character stats"), ("help, h, ?", "Show this help"), ("quit, exit", "Leave the game"), ]; for (c, d) in cmds { out.push_str(&format!( " {:<30} {}\r\n", ansi::color(ansi::YELLOW, c), ansi::color(ansi::DIM, d) )); } if is_admin { out.push_str(&format!( "\r\n {} {}\r\n", ansi::color(ansi::YELLOW, "admin "), ansi::color(ansi::DIM, "Admin commands (use 'admin help')") )); } CommandResult { output: out, broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, } } fn simple(msg: &str) -> CommandResult { CommandResult { output: msg.to_string(), broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, } }