Add SQLite persistence, per-player NPC attitude system, character creation, and combat
- Add trait-based DB layer (db.rs) with SQLite backend for easy future swapping - Player state persisted to SQLite: stats, inventory, equipment, room position - Returning players skip chargen and resume where they left off - Replace boolean hostile flag with 5-tier attitude system (friendly/neutral/wary/aggressive/hostile) - Per-player NPC attitudes stored in DB, shift on kills with faction propagation - Add character creation flow (chargen.rs) with data-driven races and classes from TOML - Add turn-based combat system (combat.rs) with XP, leveling, and NPC respawn - Add commands: take, drop, inventory, equip, use, examine, talk, attack, flee, stats - Add world data: 5 races, 4 classes, hostile NPCs (rat, thief), new items Made-with: Cursor
This commit is contained in:
699
src/commands.rs
699
src/commands.rs
@@ -2,7 +2,9 @@ use russh::server::Session;
|
||||
use russh::{ChannelId, CryptoVec};
|
||||
|
||||
use crate::ansi;
|
||||
use crate::game::SharedState;
|
||||
use crate::combat;
|
||||
use crate::game::{CombatState, SharedState};
|
||||
use crate::world::Attitude;
|
||||
|
||||
pub struct BroadcastMsg {
|
||||
pub channel: ChannelId,
|
||||
@@ -16,75 +18,62 @@ pub struct CommandResult {
|
||||
pub quit: bool,
|
||||
}
|
||||
|
||||
const DIRECTION_ALIASES: &[(&str, &str)] = &[
|
||||
("n", "north"),
|
||||
("s", "south"),
|
||||
("e", "east"),
|
||||
("w", "west"),
|
||||
("u", "up"),
|
||||
("d", "down"),
|
||||
const DIR_ALIASES: &[(&str, &str)] = &[
|
||||
("n","north"),("s","south"),("e","east"),("w","west"),("u","up"),("d","down"),
|
||||
];
|
||||
|
||||
fn resolve_direction(input: &str) -> &str {
|
||||
for &(alias, full) in DIRECTION_ALIASES {
|
||||
if input == alias {
|
||||
return full;
|
||||
}
|
||||
}
|
||||
fn resolve_dir(input: &str) -> &str {
|
||||
for &(a, f) in DIR_ALIASES { if input == a { return f; } }
|
||||
input
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
input: &str,
|
||||
player_id: usize,
|
||||
state: &SharedState,
|
||||
session: &mut Session,
|
||||
channel: ChannelId,
|
||||
input: &str, player_id: usize, state: &SharedState,
|
||||
session: &mut Session, channel: ChannelId,
|
||||
) -> Result<bool, russh::Error> {
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
send(session, channel, &ansi::prompt())?;
|
||||
return Ok(true);
|
||||
}
|
||||
if input.is_empty() { send(session, channel, &ansi::prompt())?; return Ok(true); }
|
||||
|
||||
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
|
||||
{ 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"|"flee"|"look"|"l"|"quit"|"exit") {
|
||||
drop(st);
|
||||
send(session, channel, &format!("{}\r\n{}", ansi::error_msg("You're in combat! Use 'attack', 'flee', or 'look'."), ansi::prompt()))?;
|
||||
return Ok(true);
|
||||
}
|
||||
}}
|
||||
|
||||
let result = match cmd.as_str() {
|
||||
"look" | "l" => cmd_look(player_id, state).await,
|
||||
"look"|"l" => cmd_look(player_id, 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_direction(&cmd), state).await,
|
||||
"say" | "'" => cmd_say(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,
|
||||
"help" | "h" | "?" => cmd_help(),
|
||||
"quit" | "exit" => CommandResult {
|
||||
output: format!("{}\r\n", ansi::system_msg("Farewell, adventurer...")),
|
||||
broadcasts: Vec::new(),
|
||||
quit: true,
|
||||
},
|
||||
_ => CommandResult {
|
||||
output: format!(
|
||||
"{}\r\n",
|
||||
ansi::error_msg(&format!("Unknown command: '{cmd}'. Type 'help' for commands."))
|
||||
),
|
||||
broadcasts: Vec::new(),
|
||||
quit: false,
|
||||
},
|
||||
"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,
|
||||
"flee" => cmd_flee(player_id, state).await,
|
||||
"stats"|"st" => cmd_stats(player_id, state).await,
|
||||
"help"|"h"|"?" => cmd_help(),
|
||||
"quit"|"exit" => CommandResult { output: format!("{}\r\n", ansi::system_msg("Farewell, adventurer...")), broadcasts: Vec::new(), quit: true },
|
||||
_ => simple(&format!("{}\r\n", ansi::error_msg(&format!("Unknown command: '{cmd}'. Type 'help' for commands.")))),
|
||||
};
|
||||
|
||||
send(session, channel, &result.output)?;
|
||||
|
||||
for msg in result.broadcasts {
|
||||
let _ = msg.handle.data(msg.channel, msg.data).await;
|
||||
}
|
||||
|
||||
if result.quit {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
for msg in result.broadcasts { let _ = msg.handle.data(msg.channel, msg.data).await; }
|
||||
if result.quit { return Ok(false); }
|
||||
send(session, channel, &ansi::prompt())?;
|
||||
Ok(true)
|
||||
}
|
||||
@@ -94,323 +83,409 @@ fn send(session: &mut Session, channel: ChannelId, text: &str) -> Result<(), rus
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_room_view(
|
||||
room_id: &str,
|
||||
player_id: usize,
|
||||
state: &tokio::sync::MutexGuard<'_, crate::game::GameState>,
|
||||
) -> String {
|
||||
let room = match state.world.get_room(room_id) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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 mut out = String::new();
|
||||
out.push_str(&format!(
|
||||
"\r\n{} {}\r\n",
|
||||
ansi::room_name(&room.name),
|
||||
ansi::system_msg(&format!("[{}]", room.region))
|
||||
));
|
||||
out.push_str(&format!(" {}\r\n", room.description));
|
||||
let mut out = format!("\r\n{} {}\r\n {}\r\n",
|
||||
ansi::room_name(&room.name), ansi::system_msg(&format!("[{}]", room.region)), room.description);
|
||||
|
||||
if !room.npcs.is_empty() {
|
||||
let npc_names: Vec<String> = room
|
||||
.npcs
|
||||
.iter()
|
||||
.filter_map(|id| state.world.get_npc(id))
|
||||
.map(|n| ansi::color(ansi::YELLOW, &n.name))
|
||||
.collect();
|
||||
if !npc_names.is_empty() {
|
||||
out.push_str(&format!(
|
||||
"\r\n{}{}\r\n",
|
||||
ansi::color(ansi::DIM, "Present: "),
|
||||
npc_names.join(", ")
|
||||
));
|
||||
}
|
||||
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(", ")));
|
||||
}
|
||||
|
||||
if !room.objects.is_empty() {
|
||||
let obj_names: Vec<String> = room
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|id| state.world.get_object(id))
|
||||
.map(|o| ansi::color(ansi::CYAN, &o.name))
|
||||
.collect();
|
||||
if !obj_names.is_empty() {
|
||||
out.push_str(&format!(
|
||||
"{}{}\r\n",
|
||||
ansi::color(ansi::DIM, "You see: "),
|
||||
obj_names.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 = state.players_in_room(room_id, player_id);
|
||||
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(", ")
|
||||
));
|
||||
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();
|
||||
let dir_strs: Vec<String> = dirs.iter().map(|d| ansi::direction(d)).collect();
|
||||
out.push_str(&format!(
|
||||
"{} {}\r\n",
|
||||
ansi::color(ansi::DIM, "Exits:"),
|
||||
dir_strs.join(", ")
|
||||
));
|
||||
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(player_id: usize, state: &SharedState) -> CommandResult {
|
||||
let state = state.lock().await;
|
||||
let room_id = match state.players.get(&player_id) {
|
||||
Some(c) => c.player.room_id.clone(),
|
||||
None => {
|
||||
return CommandResult {
|
||||
output: format!("{}\r\n", ansi::error_msg("You don't seem to exist.")),
|
||||
broadcasts: Vec::new(),
|
||||
quit: false,
|
||||
}
|
||||
async fn cmd_look(pid: usize, state: &SharedState) -> CommandResult {
|
||||
let st = state.lock().await;
|
||||
let rid = match st.players.get(&pid) { Some(c) => c.player.room_id.clone(), None => return simple("Error\r\n") };
|
||||
CommandResult { output: render_room_view(&rid, pid, &st), broadcasts: 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;
|
||||
|
||||
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}.")))),
|
||||
}
|
||||
};
|
||||
|
||||
CommandResult {
|
||||
output: render_room_view(&room_id, player_id, &state),
|
||||
broadcasts: Vec::new(),
|
||||
quit: false,
|
||||
}
|
||||
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) { bcast.push(BroadcastMsg { channel: c.channel, handle: c.handle.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) { bcast.push(BroadcastMsg { channel: c.channel, handle: c.handle.clone(), data: arrive.clone() }); }
|
||||
|
||||
st.save_player_to_db(pid);
|
||||
let output = render_room_view(&new_rid, pid, &st);
|
||||
CommandResult { output, broadcasts: bcast, quit: false }
|
||||
}
|
||||
|
||||
async fn cmd_go(player_id: usize, direction: &str, state: &SharedState) -> CommandResult {
|
||||
let direction_lower = direction.to_lowercase();
|
||||
let direction = resolve_direction(&direction_lower);
|
||||
let mut state = state.lock().await;
|
||||
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().map(|c| BroadcastMsg { channel: c.channel, handle: c.handle.clone(), data: other.clone() }).collect();
|
||||
CommandResult { output: self_msg, broadcasts: bcast, quit: false }
|
||||
}
|
||||
|
||||
let (old_room_id, new_room_id, player_name) = {
|
||||
let conn = match state.players.get(&player_id) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
return CommandResult {
|
||||
output: format!("{}\r\n", ansi::error_msg("You don't exist.")),
|
||||
broadcasts: Vec::new(),
|
||||
quit: false,
|
||||
}
|
||||
}
|
||||
};
|
||||
let room = match state.world.get_room(&conn.player.room_id) {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
return CommandResult {
|
||||
output: format!("{}\r\n", ansi::error_msg("You are in the void.")),
|
||||
broadcasts: Vec::new(),
|
||||
quit: false,
|
||||
}
|
||||
}
|
||||
};
|
||||
let dest = match room.exits.get(direction) {
|
||||
Some(id) => id.clone(),
|
||||
None => {
|
||||
return CommandResult {
|
||||
output: format!(
|
||||
"{}\r\n",
|
||||
ansi::error_msg(&format!("You can't go {direction}."))
|
||||
),
|
||||
broadcasts: Vec::new(),
|
||||
quit: false,
|
||||
}
|
||||
}
|
||||
};
|
||||
(
|
||||
conn.player.room_id.clone(),
|
||||
dest,
|
||||
conn.player.name.clone(),
|
||||
)
|
||||
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 { "" };
|
||||
out.push_str(&format!(" {} — {}{}\r\n", ansi::player_name(&c.player.name), ansi::room_name(rn), ansi::system_msg(m)));
|
||||
}
|
||||
out.push_str(&format!("{}\r\n", ansi::system_msg(&format!("{} player(s) online", st.players.len()))));
|
||||
CommandResult { output: out, broadcasts: 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(), quit: false }
|
||||
}
|
||||
|
||||
let leave_msg = CryptoVec::from(
|
||||
format!(
|
||||
"{}\r\n{}",
|
||||
ansi::system_msg(&format!("{player_name} heads {direction}.")),
|
||||
ansi::prompt()
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
let mut broadcasts = Vec::new();
|
||||
for conn in state.players_in_room(&old_room_id, player_id) {
|
||||
broadcasts.push(BroadcastMsg {
|
||||
channel: conn.channel,
|
||||
handle: conn.handle.clone(),
|
||||
data: leave_msg.clone(),
|
||||
});
|
||||
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(), 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 let Some(ref w) = conn.player.equipped_weapon {
|
||||
out.push_str(&format!(" Weapon: {} {}\r\n", ansi::color(ansi::CYAN, &w.name), ansi::system_msg(&format!("(+{} dmg)", w.stats.damage.unwrap_or(0)))));
|
||||
}
|
||||
|
||||
if let Some(conn) = state.players.get_mut(&player_id) {
|
||||
conn.player.room_id = new_room_id.clone();
|
||||
if let Some(ref a) = conn.player.equipped_armor {
|
||||
out.push_str(&format!(" Armor: {} {}\r\n", ansi::color(ansi::CYAN, &a.name), ansi::system_msg(&format!("(+{} def)", a.stats.armor.unwrap_or(0)))));
|
||||
}
|
||||
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(), quit: false }
|
||||
}
|
||||
|
||||
let arrive_msg = CryptoVec::from(
|
||||
format!(
|
||||
"\r\n{}\r\n{}",
|
||||
ansi::system_msg(&format!("{player_name} arrives.")),
|
||||
ansi::prompt()
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
for conn in state.players_in_room(&new_room_id, player_id) {
|
||||
broadcasts.push(BroadcastMsg {
|
||||
channel: conn.channel,
|
||||
handle: conn.handle.clone(),
|
||||
data: arrive_msg.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let output = render_room_view(&new_room_id, player_id, &state);
|
||||
|
||||
CommandResult {
|
||||
output,
|
||||
broadcasts,
|
||||
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;
|
||||
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 kind = obj.kind.as_deref().unwrap_or("").to_string();
|
||||
match kind.as_str() {
|
||||
"weapon" => {
|
||||
if let Some(old) = conn.player.equipped_weapon.take() { conn.player.inventory.push(old); }
|
||||
conn.player.equipped_weapon = Some(obj);
|
||||
st.save_player_to_db(pid);
|
||||
CommandResult { output: format!("You equip the {} as your weapon.\r\n", ansi::color(ansi::CYAN, &name)), broadcasts: Vec::new(), quit: false }
|
||||
}
|
||||
"armor" => {
|
||||
if let Some(old) = conn.player.equipped_armor.take() { conn.player.inventory.push(old); }
|
||||
conn.player.equipped_armor = Some(obj);
|
||||
st.save_player_to_db(pid);
|
||||
CommandResult { output: format!("You equip the {} as armor.\r\n", ansi::color(ansi::CYAN, &name)), broadcasts: Vec::new(), quit: false }
|
||||
}
|
||||
_ => { conn.player.inventory.push(obj); simple(&format!("{}\r\n", ansi::error_msg(&format!("You can't equip the {}.", name)))) }
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_say(player_id: usize, message: &str, state: &SharedState) -> CommandResult {
|
||||
if message.is_empty() {
|
||||
return CommandResult {
|
||||
output: format!("{}\r\n", ansi::error_msg("Say what?")),
|
||||
broadcasts: 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}'.")))),
|
||||
};
|
||||
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(), quit: false }
|
||||
}
|
||||
|
||||
let state = state.lock().await;
|
||||
let conn = match state.players.get(&player_id) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
return CommandResult {
|
||||
output: format!("{}\r\n", ansi::error_msg("You don't exist.")),
|
||||
broadcasts: 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 alive = st.npc_instances.get(nid).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 !alive {
|
||||
out.push_str(&format!(" {}\r\n", ansi::color(ansi::RED, "(dead)")));
|
||||
} else if let Some(ref c) = npc.combat {
|
||||
let hp = st.npc_instances.get(nid).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(), 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(), quit: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let name = &conn.player.name;
|
||||
let room_id = conn.player.room_id.clone();
|
||||
|
||||
let self_msg = format!(
|
||||
"{}You say: {}{}\r\n",
|
||||
ansi::BOLD,
|
||||
ansi::RESET,
|
||||
ansi::color(ansi::WHITE, message)
|
||||
);
|
||||
|
||||
let other_msg = CryptoVec::from(
|
||||
format!(
|
||||
"\r\n{} says: {}{}\r\n{}",
|
||||
ansi::player_name(name),
|
||||
ansi::RESET,
|
||||
ansi::color(ansi::WHITE, message),
|
||||
ansi::prompt()
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
|
||||
let mut broadcasts = Vec::new();
|
||||
for other in state.players_in_room(&room_id, player_id) {
|
||||
broadcasts.push(BroadcastMsg {
|
||||
channel: other.channel,
|
||||
handle: other.handle.clone(),
|
||||
data: other_msg.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
CommandResult {
|
||||
output: self_msg,
|
||||
broadcasts,
|
||||
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(), quit: false };
|
||||
}
|
||||
}
|
||||
simple(&format!("{}\r\n", ansi::error_msg(&format!("You don't see '{target}'."))))
|
||||
}
|
||||
|
||||
async fn cmd_who(player_id: usize, state: &SharedState) -> CommandResult {
|
||||
let state = state.lock().await;
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!(
|
||||
"\r\n{}\r\n",
|
||||
ansi::bold("=== Who's Online ===")
|
||||
));
|
||||
async fn cmd_talk(pid: usize, target: &str, state: &SharedState) -> CommandResult {
|
||||
if target.is_empty() { return simple("Talk to whom?\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 low = target.to_lowercase();
|
||||
let pname = &conn.player.name;
|
||||
|
||||
let self_name = state
|
||||
.players
|
||||
.get(&player_id)
|
||||
.map(|c| c.player.name.as_str())
|
||||
.unwrap_or("");
|
||||
for nid in &room.npcs {
|
||||
if let Some(npc) = st.world.get_npc(nid) {
|
||||
if npc.name.to_lowercase().contains(&low) {
|
||||
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)));
|
||||
}
|
||||
let greeting = npc.greeting.as_deref().unwrap_or("...");
|
||||
return CommandResult {
|
||||
output: format!("\r\n{} says: \"{}\"\r\n", ansi::color(ansi::YELLOW, &npc.name), ansi::color(ansi::WHITE, greeting)),
|
||||
broadcasts: Vec::new(), quit: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
simple(&format!("{}\r\n", ansi::error_msg(&format!("You don't see '{target}' here to talk to."))))
|
||||
}
|
||||
|
||||
for conn in state.players.values() {
|
||||
let room_name = state
|
||||
.world
|
||||
.get_room(&conn.player.room_id)
|
||||
.map(|r| r.name.as_str())
|
||||
.unwrap_or("???");
|
||||
let marker = if conn.player.name == self_name {
|
||||
" (you)"
|
||||
async fn cmd_attack(pid: usize, target: &str, state: &SharedState) -> CommandResult {
|
||||
let mut st = state.lock().await;
|
||||
|
||||
let npc_id = {
|
||||
let conn = match st.players.get(&pid) { Some(c) => c, None => return simple("Error\r\n") };
|
||||
if let Some(ref combat) = conn.combat {
|
||||
combat.npc_id.clone()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
out.push_str(&format!(
|
||||
" {} — {}{}\r\n",
|
||||
ansi::player_name(&conn.player.name),
|
||||
ansi::room_name(room_name),
|
||||
ansi::system_msg(marker)
|
||||
));
|
||||
if target.is_empty() { return simple("Attack what?\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 pname = &conn.player.name;
|
||||
let found = room.npcs.iter().find(|nid| {
|
||||
if let Some(npc) = st.world.get_npc(nid) {
|
||||
if !npc.name.to_lowercase().contains(&low) { return false; }
|
||||
let att = st.npc_attitude_toward(nid, pname);
|
||||
att.can_be_attacked() && 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.")))),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Set combat state if not already
|
||||
if st.players.get(&pid).map(|c| c.combat.is_none()).unwrap_or(false) {
|
||||
if let Some(c) = st.players.get_mut(&pid) {
|
||||
c.combat = Some(CombatState { npc_id: npc_id.clone() });
|
||||
}
|
||||
}
|
||||
|
||||
let count = state.players.len();
|
||||
out.push_str(&format!(
|
||||
"{}\r\n",
|
||||
ansi::system_msg(&format!("{count} player(s) online"))
|
||||
));
|
||||
st.check_respawns();
|
||||
|
||||
CommandResult {
|
||||
output: out,
|
||||
broadcasts: Vec::new(),
|
||||
quit: false,
|
||||
let player_name = st.players.get(&pid).map(|c| c.player.name.clone()).unwrap_or_default();
|
||||
let result = combat::do_attack(pid, &npc_id, &mut st);
|
||||
|
||||
match result {
|
||||
Some(round) => {
|
||||
let mut out = round.output;
|
||||
if round.npc_died {
|
||||
// Attitude shift: this NPC and faction
|
||||
st.shift_attitude(&npc_id, &player_name, -10);
|
||||
if let Some(faction) = st.world.get_npc(&npc_id).and_then(|n| n.faction.clone()) {
|
||||
st.shift_faction_attitude(&faction, &player_name, -5);
|
||||
}
|
||||
if let Some(msg) = st.check_level_up(pid) {
|
||||
out.push_str(&format!("\r\n {} {}\r\n", ansi::color(ansi::GREEN, "***"), ansi::bold(&msg)));
|
||||
}
|
||||
}
|
||||
if round.player_died {
|
||||
out.push_str(&combat::player_death_respawn(pid, &mut st));
|
||||
let rid = st.players.get(&pid).map(|c| c.player.room_id.clone()).unwrap_or_default();
|
||||
out.push_str(&render_room_view(&rid, pid, &st));
|
||||
}
|
||||
st.save_player_to_db(pid);
|
||||
CommandResult { output: out, broadcasts: Vec::new(), quit: false }
|
||||
}
|
||||
None => simple(&format!("{}\r\n", ansi::error_msg("That target can't be attacked right now."))),
|
||||
}
|
||||
}
|
||||
|
||||
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."))); }
|
||||
conn.combat = None;
|
||||
CommandResult { output: format!("{}\r\n", ansi::system_msg("You disengage and flee from combat!")), broadcasts: Vec::new(), quit: false }
|
||||
}
|
||||
|
||||
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));
|
||||
out.push_str(&format!(" {} {} (+{} equip) {} {} (+{} equip)\r\n",
|
||||
ansi::color(ansi::DIM, "ATK:"), s.attack, p.equipped_weapon.as_ref().and_then(|w| w.stats.damage).unwrap_or(0),
|
||||
ansi::color(ansi::DIM, "DEF:"), s.defense, p.equipped_armor.as_ref().and_then(|a| a.stats.armor).unwrap_or(0)));
|
||||
out.push_str(&format!(" {} {}\r\n", ansi::color(ansi::DIM, "Level:"), s.level));
|
||||
out.push_str(&format!(" {} {}/{}\r\n", ansi::color(ansi::DIM, "XP:"), s.xp, s.xp_to_next));
|
||||
CommandResult { output: out, broadcasts: Vec::new(), quit: false }
|
||||
}
|
||||
|
||||
fn cmd_help() -> CommandResult {
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!("\r\n{}\r\n", ansi::bold("=== Commands ===")));
|
||||
let mut out = format!("\r\n{}\r\n", ansi::bold("=== Commands ==="));
|
||||
let cmds = [
|
||||
("look, l", "Look around the current room"),
|
||||
(
|
||||
"go <dir>, north/n, south/s, east/e, west/w",
|
||||
"Move in a direction",
|
||||
),
|
||||
("say <msg>, ' <msg>", "Say something to players in the room"),
|
||||
("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", "Attack a hostile NPC"),
|
||||
("flee", "Disengage from combat"),
|
||||
("stats, st", "View your character stats"),
|
||||
("help, h, ?", "Show this help"),
|
||||
("quit, exit", "Leave the game"),
|
||||
];
|
||||
for (cmd, desc) in cmds {
|
||||
out.push_str(&format!(
|
||||
" {:<44} {}\r\n",
|
||||
ansi::color(ansi::YELLOW, cmd),
|
||||
ansi::color(ansi::DIM, desc)
|
||||
));
|
||||
}
|
||||
CommandResult {
|
||||
output: out,
|
||||
broadcasts: Vec::new(),
|
||||
quit: false,
|
||||
}
|
||||
for (c, d) in cmds { out.push_str(&format!(" {:<30} {}\r\n", ansi::color(ansi::YELLOW, c), ansi::color(ansi::DIM, d))); }
|
||||
CommandResult { output: out, broadcasts: Vec::new(), quit: false }
|
||||
}
|
||||
|
||||
fn simple(msg: &str) -> CommandResult {
|
||||
CommandResult { output: msg.to_string(), broadcasts: Vec::new(), quit: false }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user