Files
mudserver/src/commands.rs
lily 7bab50b431
All checks were successful
Smoke tests / Build and smoke test (pull_request) Successful in 1m28s
Smoke tests / Build and smoke test (push) Successful in 1m28s
Merge branch 'main' into feature/robust-logging
2026-03-19 21:11:44 -06:00

2059 lines
71 KiB
Rust

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<BroadcastMsg>,
pub kick_targets: Vec<KickTarget>,
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<bool, russh::Error> {
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<String> = 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<String> = 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<String> = 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::<Vec<_>>()
.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<String> = 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 <npc> [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 <item>'."),
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 <spell> [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::<String>),
};
// 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<String> = 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 <item> | shop sell <item>\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 <guild>")));
}
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 <guild>")));
}
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 <guild>")));
}
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 <name> | guild join <name> | guild leave <name>\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 <dir>, n/s/e/w/u/d", "Move in a direction"),
("say <msg>", "Say something to the room"),
("who", "See who's online"),
("examine <target>, x", "Inspect an NPC, object, or item"),
("talk <npc>", "Talk to a friendly NPC"),
("take <item>", "Pick up an object"),
("drop <item>", "Drop an item from inventory"),
("inventory, i", "View your inventory"),
("equip <item>", "Equip a weapon or armor"),
("use <item>", "Use a consumable item"),
("attack <target>, a", "Engage/attack a hostile NPC (tick-based)"),
("defend, def", "Defend next tick (reduces incoming damage)"),
("flee", "Attempt to flee combat (tick-based)"),
("cast <spell>, 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 <subcommand>"),
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,
}
}