2059 lines
71 KiB
Rust
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,
|
|
}
|
|
}
|