From 680f48477eaca0205658f08c69b1cab916f21755 Mon Sep 17 00:00:00 2001 From: AI Agent Date: Sat, 14 Mar 2026 13:58:22 -0600 Subject: [PATCH] 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 --- .gitignore | 3 + Cargo.lock | 102 +++- Cargo.toml | 2 + src/chargen.rs | 194 +++++++ src/combat.rs | 154 ++++++ src/commands.rs | 699 ++++++++++++++----------- src/db.rs | 173 ++++++ src/game.rs | 223 +++++++- src/main.rs | 33 +- src/ssh.rs | 325 ++++++------ src/world.rs | 415 +++++++++------ world/classes/cleric.toml | 12 + world/classes/mage.toml | 12 + world/classes/rogue.toml | 12 + world/classes/warrior.toml | 12 + world/races/dwarf.toml | 9 + world/races/elf.toml | 9 + world/races/halfling.toml | 9 + world/races/human.toml | 9 + world/races/orc.toml | 9 + world/town/npcs/barkeep.toml | 5 +- world/town/npcs/guard.toml | 5 +- world/town/npcs/rat.toml | 11 + world/town/npcs/thief.toml | 12 + world/town/objects/gold_coin.toml | 5 + world/town/objects/healing_potion.toml | 4 + world/town/objects/iron_shield.toml | 8 + world/town/objects/rusty_sword.toml | 4 + 28 files changed, 1797 insertions(+), 673 deletions(-) create mode 100644 src/chargen.rs create mode 100644 src/combat.rs create mode 100644 src/db.rs create mode 100644 world/classes/cleric.toml create mode 100644 world/classes/mage.toml create mode 100644 world/classes/rogue.toml create mode 100644 world/classes/warrior.toml create mode 100644 world/races/dwarf.toml create mode 100644 world/races/elf.toml create mode 100644 world/races/halfling.toml create mode 100644 world/races/human.toml create mode 100644 world/races/orc.toml create mode 100644 world/town/npcs/rat.toml create mode 100644 world/town/npcs/thief.toml create mode 100644 world/town/objects/gold_coin.toml create mode 100644 world/town/objects/iron_shield.toml diff --git a/.gitignore b/.gitignore index ea8c4bf..c536345 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ /target +*.db +*.db-shm +*.db-wal diff --git a/Cargo.lock b/Cargo.lock index bdb8165..54b1545 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -508,6 +508,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "ff" version = "0.13.1" @@ -530,6 +542,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "futures" version = "0.3.32" @@ -663,12 +681,30 @@ dependencies = [ "subtle", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "hex" version = "0.4.3" @@ -739,7 +775,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", ] [[package]] @@ -785,6 +821,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + [[package]] name = "jiff" version = "0.2.23" @@ -840,6 +882,17 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libsqlite3-sys" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "947e6816f7825b2b45027c2c32e7085da9934defa535de4a6a46b10a4d5257fa" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -884,8 +937,10 @@ version = "0.1.0" dependencies = [ "env_logger", "log", + "rusqlite", "russh", "serde", + "serde_json", "tokio", "toml", ] @@ -1129,6 +1184,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "poly1305" version = "0.8.0" @@ -1316,6 +1377,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rusqlite" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a22715a5d6deef63c637207afbe68d0c72c3f8d0022d7cf9714c442d6157606b" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "russh" version = "0.54.5" @@ -1492,6 +1567,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -1756,6 +1844,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -2157,3 +2251,9 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 8662b66..85a2fbe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,8 @@ edition = "2021" russh = { version = "0.54", default-features = false, features = ["ring"] } tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } +serde_json = "1" toml = "0.8" +rusqlite = { version = "0.35", features = ["bundled"] } log = "0.4" env_logger = "0.11" diff --git a/src/chargen.rs b/src/chargen.rs new file mode 100644 index 0000000..362d5af --- /dev/null +++ b/src/chargen.rs @@ -0,0 +1,194 @@ +use crate::ansi; +use crate::world::World; + +#[derive(Clone)] +pub enum ChargenStep { + AwaitingRace, + AwaitingClass, + Done { race_id: String, class_id: String }, +} + +pub struct ChargenState { + pub step: ChargenStep, + pub race_id: Option, +} + +impl ChargenState { + pub fn new() -> Self { + ChargenState { + step: ChargenStep::AwaitingRace, + race_id: None, + } + } + + pub fn prompt_text(&self, world: &World) -> String { + match &self.step { + ChargenStep::AwaitingRace => { + let mut out = String::new(); + out.push_str(&format!( + "\r\n{}\r\n\r\n", + ansi::bold("=== Choose Your Race ===") + )); + for (i, race) in world.races.iter().enumerate() { + let mods = format_stat_mods(&race.stats); + out.push_str(&format!( + " {}{}.{} {} {}\r\n {}\r\n", + ansi::BOLD, + i + 1, + ansi::RESET, + ansi::color(ansi::CYAN, &race.name), + if mods.is_empty() { + String::new() + } else { + ansi::system_msg(&format!("({})", mods)) + }, + ansi::color(ansi::DIM, &race.description), + )); + } + out.push_str(&format!( + "\r\n{}", + ansi::color(ansi::YELLOW, "Enter number or name: ") + )); + out + } + ChargenStep::AwaitingClass => { + let mut out = String::new(); + out.push_str(&format!( + "\r\n{}\r\n\r\n", + ansi::bold("=== Choose Your Class ===") + )); + for (i, class) in world.classes.iter().enumerate() { + out.push_str(&format!( + " {}{}.{} {} {}\r\n {}\r\n {}HP:{} {}ATK:{} {}DEF:{}{}\r\n", + ansi::BOLD, + i + 1, + ansi::RESET, + ansi::color(ansi::CYAN, &class.name), + ansi::system_msg(&format!( + "(+{}hp/+{}atk/+{}def per level)", + class.growth.hp_per_level, + class.growth.attack_per_level, + class.growth.defense_per_level, + )), + ansi::color(ansi::DIM, &class.description), + ansi::GREEN, + class.base_stats.max_hp, + ansi::RED, + class.base_stats.attack, + ansi::BLUE, + class.base_stats.defense, + ansi::RESET, + )); + } + out.push_str(&format!( + "\r\n{}", + ansi::color(ansi::YELLOW, "Enter number or name: ") + )); + out + } + ChargenStep::Done { .. } => String::new(), + } + } + + pub fn handle_input(&mut self, input: &str, world: &World) -> Result { + let input = input.trim(); + match &self.step { + ChargenStep::AwaitingRace => { + let race = find_by_input( + input, + &world.races.iter().map(|r| (r.id.clone(), r.name.clone())).collect::>(), + ); + match race { + Some((id, name)) => { + self.race_id = Some(id); + self.step = ChargenStep::AwaitingClass; + Ok(format!( + "\r\n{}\r\n", + ansi::system_msg(&format!("Race selected: {name}")) + )) + } + None => Err(format!( + "{}\r\n", + ansi::error_msg("Invalid choice. Enter a number or name.") + )), + } + } + ChargenStep::AwaitingClass => { + let class = find_by_input( + input, + &world + .classes + .iter() + .map(|c| (c.id.clone(), c.name.clone())) + .collect::>(), + ); + match class { + Some((id, name)) => { + let race_id = self.race_id.clone().unwrap(); + self.step = ChargenStep::Done { + race_id, + class_id: id, + }; + Ok(format!( + "\r\n{}\r\n", + ansi::system_msg(&format!("Class selected: {name}")) + )) + } + None => Err(format!( + "{}\r\n", + ansi::error_msg("Invalid choice. Enter a number or name.") + )), + } + } + ChargenStep::Done { .. } => Ok(String::new()), + } + } + + pub fn is_done(&self) -> bool { + matches!(self.step, ChargenStep::Done { .. }) + } + + pub fn result(&self) -> Option<(String, String)> { + match &self.step { + ChargenStep::Done { race_id, class_id } => { + Some((race_id.clone(), class_id.clone())) + } + _ => None, + } + } +} + +fn find_by_input(input: &str, options: &[(String, String)]) -> Option<(String, String)> { + if let Ok(num) = input.parse::() { + if num >= 1 && num <= options.len() { + return Some(options[num - 1].clone()); + } + } + let lower = input.to_lowercase(); + options + .iter() + .find(|(_, name)| name.to_lowercase() == lower) + .cloned() +} + +fn format_stat_mods(stats: &crate::world::StatModifiers) -> String { + let mut parts = Vec::new(); + let fields = [ + ("STR", stats.strength), + ("DEX", stats.dexterity), + ("CON", stats.constitution), + ("INT", stats.intelligence), + ("WIS", stats.wisdom), + ]; + for (label, val) in fields { + if val != 0 { + parts.push(format!( + "{}{} {}", + if val > 0 { "+" } else { "" }, + val, + label + )); + } + } + parts.join(", ") +} diff --git a/src/combat.rs b/src/combat.rs new file mode 100644 index 0000000..d185db2 --- /dev/null +++ b/src/combat.rs @@ -0,0 +1,154 @@ +use std::time::Instant; + +use crate::ansi; +use crate::game::GameState; + +pub struct CombatRoundResult { + pub output: String, + pub npc_died: bool, + pub player_died: bool, + pub xp_gained: i32, +} + +pub fn do_attack(player_id: usize, npc_id: &str, state: &mut GameState) -> Option { + let npc_template = state.world.get_npc(npc_id)?.clone(); + let npc_combat = npc_template.combat.as_ref()?; + + let instance = state.npc_instances.get(npc_id)?; + if !instance.alive { + return None; + } + let npc_hp_before = instance.hp; + + let conn = state.players.get(&player_id)?; + let p_atk = conn.player.effective_attack(); + let p_def = conn.player.effective_defense(); + + // Player attacks NPC + let roll: i32 = (simple_random() % 6) as i32 + 1; + let player_dmg = (p_atk - npc_combat.defense / 2 + roll).max(1); + + let new_npc_hp = (npc_hp_before - player_dmg).max(0); + + let mut out = String::new(); + out.push_str(&format!( + " {} You strike {} for {} damage!{}\r\n", + ansi::color(ansi::YELLOW, ">>"), + ansi::color(ansi::RED, &npc_template.name), + ansi::bold(&player_dmg.to_string()), + ansi::RESET, + )); + + let mut npc_died = false; + let mut player_died = false; + let mut xp_gained = 0; + + if new_npc_hp <= 0 { + // NPC dies + if let Some(inst) = state.npc_instances.get_mut(npc_id) { + inst.alive = false; + inst.hp = 0; + inst.death_time = Some(Instant::now()); + } + npc_died = true; + xp_gained = npc_combat.xp_reward; + + out.push_str(&format!( + " {} {} collapses! You gain {} XP.\r\n", + ansi::color(ansi::GREEN, "**"), + ansi::color(ansi::RED, &npc_template.name), + ansi::bold(&xp_gained.to_string()), + )); + + // Clear combat state + if let Some(conn) = state.players.get_mut(&player_id) { + conn.combat = None; + conn.player.stats.xp += xp_gained; + } + } else { + // Update NPC HP + if let Some(inst) = state.npc_instances.get_mut(npc_id) { + inst.hp = new_npc_hp; + } + + out.push_str(&format!( + " {} {} HP: {}/{}\r\n", + ansi::color(ansi::DIM, " "), + npc_template.name, + new_npc_hp, + npc_combat.max_hp, + )); + + // NPC attacks player + let npc_roll: i32 = (simple_random() % 6) as i32 + 1; + let npc_dmg = (npc_combat.attack - p_def / 2 + npc_roll).max(1); + + if let Some(conn) = state.players.get_mut(&player_id) { + conn.player.stats.hp = (conn.player.stats.hp - npc_dmg).max(0); + let hp = conn.player.stats.hp; + let max_hp = conn.player.stats.max_hp; + + out.push_str(&format!( + " {} {} strikes you for {} damage!\r\n", + ansi::color(ansi::RED, "<<"), + ansi::color(ansi::RED, &npc_template.name), + ansi::bold(&npc_dmg.to_string()), + )); + + let hp_color = if hp * 3 < max_hp { + ansi::RED + } else if hp * 3 < max_hp * 2 { + ansi::YELLOW + } else { + ansi::GREEN + }; + out.push_str(&format!( + " {} Your HP: {}{}/{}{}\r\n", + ansi::color(ansi::DIM, " "), + hp_color, + hp, + max_hp, + ansi::RESET, + )); + + if hp <= 0 { + player_died = true; + conn.combat = None; + } + } + } + + Some(CombatRoundResult { + output: out, + npc_died, + player_died, + xp_gained, + }) +} + +pub fn player_death_respawn(player_id: usize, state: &mut GameState) -> String { + let spawn_room = state.spawn_room().to_string(); + if let Some(conn) = state.players.get_mut(&player_id) { + conn.player.stats.hp = conn.player.stats.max_hp; + conn.player.room_id = spawn_room; + conn.combat = None; + } + + format!( + "\r\n{}\r\n{}\r\n{}\r\n", + ansi::color(ansi::RED, " ╔═══════════════════════════╗"), + ansi::color(ansi::RED, " ║ YOU HAVE DIED! ║"), + ansi::color(ansi::RED, " ╚═══════════════════════════╝"), + ) + &format!( + "{}\r\n", + ansi::system_msg("You awaken at the town square, fully healed.") + ) +} + +fn simple_random() -> u32 { + use std::time::SystemTime; + let d = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default(); + ((d.as_nanos() >> 4) ^ (d.as_nanos() >> 16)) as u32 +} diff --git a/src/commands.rs b/src/commands.rs index bcb0e5d..8b6b771 100644 --- a/src/commands.rs +++ b/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 { 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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::>().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 , north/n, south/s, east/e, west/w", - "Move in a direction", - ), - ("say , ' ", "Say something to players in the room"), + ("go , n/s/e/w/u/d", "Move in a direction"), + ("say ", "Say something to the room"), ("who", "See who's online"), + ("examine , x", "Inspect an NPC, object, or item"), + ("talk ", "Talk to a friendly NPC"), + ("take ", "Pick up an object"), + ("drop ", "Drop an item from inventory"), + ("inventory, i", "View your inventory"), + ("equip ", "Equip a weapon or armor"), + ("use ", "Use a consumable item"), + ("attack , a", "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 } } diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..ff2d924 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,173 @@ +use std::path::Path; + +// --- Abstract interface for swapping backends later --- + +pub struct SavedPlayer { + pub name: String, + pub race_id: String, + pub class_id: String, + pub room_id: String, + pub level: i32, + pub xp: i32, + pub hp: i32, + pub max_hp: i32, + pub attack: i32, + pub defense: i32, + pub inventory_json: String, + pub equipped_weapon_json: Option, + pub equipped_armor_json: Option, +} + +pub struct NpcAttitudeRow { + pub npc_id: String, + pub value: i32, +} + +pub trait GameDb: Send + Sync { + fn load_player(&self, name: &str) -> Option; + fn save_player(&self, player: &SavedPlayer); + fn delete_player(&self, name: &str); + + fn load_attitudes(&self, player_name: &str) -> Vec; + fn save_attitude(&self, player_name: &str, npc_id: &str, value: i32); + fn get_attitude(&self, player_name: &str, npc_id: &str) -> Option; +} + +// --- SQLite implementation --- + +pub struct SqliteDb { + conn: std::sync::Mutex, +} + +impl SqliteDb { + pub fn open(path: &Path) -> Result { + let conn = rusqlite::Connection::open(path) + .map_err(|e| format!("Failed to open database {}: {e}", path.display()))?; + + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;") + .map_err(|e| format!("Failed to set pragmas: {e}"))?; + + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS players ( + name TEXT PRIMARY KEY, + race_id TEXT NOT NULL, + class_id TEXT NOT NULL, + room_id TEXT NOT NULL, + level INTEGER NOT NULL DEFAULT 1, + xp INTEGER NOT NULL DEFAULT 0, + hp INTEGER NOT NULL, + max_hp INTEGER NOT NULL, + attack INTEGER NOT NULL, + defense INTEGER NOT NULL, + inventory_json TEXT NOT NULL DEFAULT '[]', + equipped_weapon_json TEXT, + equipped_armor_json TEXT + ); + + CREATE TABLE IF NOT EXISTS npc_attitudes ( + player_name TEXT NOT NULL, + npc_id TEXT NOT NULL, + value INTEGER NOT NULL, + PRIMARY KEY (player_name, npc_id) + );", + ) + .map_err(|e| format!("Failed to create tables: {e}"))?; + + log::info!("Database opened: {}", path.display()); + Ok(SqliteDb { + conn: std::sync::Mutex::new(conn), + }) + } +} + +impl GameDb for SqliteDb { + fn load_player(&self, name: &str) -> Option { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp, + attack, defense, inventory_json, equipped_weapon_json, equipped_armor_json + FROM players WHERE name = ?1", + [name], + |row| { + Ok(SavedPlayer { + name: row.get(0)?, + race_id: row.get(1)?, + class_id: row.get(2)?, + room_id: row.get(3)?, + level: row.get(4)?, + xp: row.get(5)?, + hp: row.get(6)?, + max_hp: row.get(7)?, + attack: row.get(8)?, + defense: row.get(9)?, + inventory_json: row.get(10)?, + equipped_weapon_json: row.get(11)?, + equipped_armor_json: row.get(12)?, + }) + }, + ) + .ok() + } + + fn save_player(&self, p: &SavedPlayer) { + let conn = self.conn.lock().unwrap(); + let _ = conn.execute( + "INSERT INTO players (name, race_id, class_id, room_id, level, xp, hp, max_hp, + attack, defense, inventory_json, equipped_weapon_json, equipped_armor_json) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13) + ON CONFLICT(name) DO UPDATE SET + room_id=excluded.room_id, level=excluded.level, xp=excluded.xp, + hp=excluded.hp, max_hp=excluded.max_hp, attack=excluded.attack, + defense=excluded.defense, inventory_json=excluded.inventory_json, + equipped_weapon_json=excluded.equipped_weapon_json, + equipped_armor_json=excluded.equipped_armor_json", + rusqlite::params![ + p.name, p.race_id, p.class_id, p.room_id, p.level, p.xp, + p.hp, p.max_hp, p.attack, p.defense, p.inventory_json, + p.equipped_weapon_json, p.equipped_armor_json, + ], + ); + } + + fn delete_player(&self, name: &str) { + let conn = self.conn.lock().unwrap(); + let _ = conn.execute("DELETE FROM players WHERE name = ?1", [name]); + let _ = conn.execute("DELETE FROM npc_attitudes WHERE player_name = ?1", [name]); + } + + fn load_attitudes(&self, player_name: &str) -> Vec { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare("SELECT npc_id, value FROM npc_attitudes WHERE player_name = ?1") + .unwrap(); + stmt.query_map([player_name], |row| { + Ok(NpcAttitudeRow { + npc_id: row.get(0)?, + value: row.get(1)?, + }) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() + } + + fn save_attitude(&self, player_name: &str, npc_id: &str, value: i32) { + let conn = self.conn.lock().unwrap(); + let _ = conn.execute( + "INSERT INTO npc_attitudes (player_name, npc_id, value) + VALUES (?1, ?2, ?3) + ON CONFLICT(player_name, npc_id) DO UPDATE SET value=excluded.value", + rusqlite::params![player_name, npc_id, value], + ); + } + + fn get_attitude(&self, player_name: &str, npc_id: &str) -> Option { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT value FROM npc_attitudes WHERE player_name = ?1 AND npc_id = ?2", + [player_name, npc_id], + |row| row.get(0), + ) + .ok() + } +} diff --git a/src/game.rs b/src/game.rs index bb1ef03..3ff0cfb 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1,70 +1,249 @@ use std::collections::HashMap; use std::sync::Arc; +use std::time::Instant; use tokio::sync::Mutex; use russh::server::Handle; use russh::ChannelId; -use crate::world::World; +use crate::db::{GameDb, SavedPlayer}; +use crate::world::{Attitude, Object, World}; + +#[derive(Clone)] +pub struct PlayerStats { + pub max_hp: i32, + pub hp: i32, + pub attack: i32, + pub defense: i32, + pub level: i32, + pub xp: i32, + pub xp_to_next: i32, +} pub struct Player { pub name: String, + pub race_id: String, + pub class_id: String, pub room_id: String, + pub stats: PlayerStats, + pub inventory: Vec, + pub equipped_weapon: Option, + pub equipped_armor: Option, +} + +impl Player { + pub fn effective_attack(&self) -> i32 { + let bonus = self.equipped_weapon.as_ref().and_then(|w| w.stats.damage).unwrap_or(0); + self.stats.attack + bonus + } + + pub fn effective_defense(&self) -> i32 { + let bonus = self.equipped_armor.as_ref().and_then(|a| a.stats.armor).unwrap_or(0); + self.stats.defense + bonus + } +} + +pub struct CombatState { + pub npc_id: String, +} + +pub struct NpcInstance { + pub hp: i32, + pub alive: bool, + pub death_time: Option, } pub struct PlayerConnection { pub player: Player, pub channel: ChannelId, pub handle: Handle, + pub combat: Option, } pub struct GameState { pub world: World, + pub db: Arc, pub players: HashMap, + pub npc_instances: HashMap, } pub type SharedState = Arc>; impl GameState { - pub fn new(world: World) -> Self { - GameState { - world, - players: HashMap::new(), + pub fn new(world: World, db: Arc) -> Self { + let mut npc_instances = HashMap::new(); + for npc in world.npcs.values() { + if let Some(ref combat) = npc.combat { + npc_instances.insert(npc.id.clone(), NpcInstance { + hp: combat.max_hp, alive: true, death_time: None, + }); + } } + GameState { world, db, players: HashMap::new(), npc_instances } } pub fn spawn_room(&self) -> &str { &self.world.spawn_room } - pub fn add_player(&mut self, id: usize, name: String, channel: ChannelId, handle: Handle) { + // Get effective attitude of an NPC towards a specific player + pub fn npc_attitude_toward(&self, npc_id: &str, player_name: &str) -> Attitude { + if let Some(val) = self.db.get_attitude(player_name, npc_id) { + return Attitude::from_value(val); + } + self.world.get_npc(npc_id) + .map(|n| n.base_attitude) + .unwrap_or(Attitude::Neutral) + } + + pub fn npc_attitude_value(&self, npc_id: &str, player_name: &str) -> i32 { + if let Some(val) = self.db.get_attitude(player_name, npc_id) { + return val; + } + self.world.get_npc(npc_id) + .map(|n| n.base_attitude.default_value()) + .unwrap_or(0) + } + + pub fn shift_attitude(&self, npc_id: &str, player_name: &str, delta: i32) { + let current = self.npc_attitude_value(npc_id, player_name); + let new_val = (current + delta).clamp(-100, 100); + self.db.save_attitude(player_name, npc_id, new_val); + } + + // Shift attitude for all NPCs in the same faction + pub fn shift_faction_attitude(&self, faction: &str, player_name: &str, delta: i32) { + for npc in self.world.npcs.values() { + if npc.faction.as_deref() == Some(faction) { + self.shift_attitude(&npc.id, player_name, delta); + } + } + } + + pub fn create_new_player( + &mut self, id: usize, name: String, race_id: String, class_id: String, + channel: ChannelId, handle: Handle, + ) { let room_id = self.world.spawn_room.clone(); - self.players.insert( - id, - PlayerConnection { - player: Player { name, room_id }, - channel, - handle, + let race = self.world.races.iter().find(|r| r.id == race_id); + let class = self.world.classes.iter().find(|c| c.id == class_id); + + let (base_hp, base_atk, base_def) = match class { + Some(c) => (c.base_stats.max_hp, c.base_stats.attack, c.base_stats.defense), + None => (100, 10, 10), + }; + let (con_mod, str_mod, dex_mod) = match race { + Some(r) => (r.stats.constitution, r.stats.strength, r.stats.dexterity), + None => (0, 0, 0), + }; + + let max_hp = base_hp + con_mod * 5; + let attack = base_atk + str_mod + dex_mod / 2; + let defense = base_def + con_mod / 2; + + let stats = PlayerStats { + max_hp, hp: max_hp, attack, defense, level: 1, xp: 0, xp_to_next: 100, + }; + + self.players.insert(id, PlayerConnection { + player: Player { name, race_id, class_id, room_id, stats, inventory: Vec::new(), equipped_weapon: None, equipped_armor: None }, + channel, handle, combat: None, + }); + } + + pub fn load_existing_player( + &mut self, id: usize, saved: SavedPlayer, channel: ChannelId, handle: Handle, + ) { + let inventory: Vec = serde_json::from_str(&saved.inventory_json).unwrap_or_default(); + let equipped_weapon: Option = saved.equipped_weapon_json.as_deref().and_then(|j| serde_json::from_str(j).ok()); + let equipped_armor: Option = saved.equipped_armor_json.as_deref().and_then(|j| serde_json::from_str(j).ok()); + + // Validate room still exists, else spawn + let room_id = if self.world.rooms.contains_key(&saved.room_id) { + saved.room_id + } else { + self.world.spawn_room.clone() + }; + + let stats = PlayerStats { + max_hp: saved.max_hp, hp: saved.hp, attack: saved.attack, defense: saved.defense, + level: saved.level, xp: saved.xp, xp_to_next: saved.level * 100, + }; + + self.players.insert(id, PlayerConnection { + player: Player { + name: saved.name, race_id: saved.race_id, class_id: saved.class_id, + room_id, stats, inventory, equipped_weapon, equipped_armor, }, - ); + channel, handle, combat: None, + }); + } + + pub fn save_player_to_db(&self, player_id: usize) { + if let Some(conn) = self.players.get(&player_id) { + let p = &conn.player; + let inv_json = serde_json::to_string(&p.inventory).unwrap_or_else(|_| "[]".into()); + let weapon_json = p.equipped_weapon.as_ref().map(|w| serde_json::to_string(w).unwrap_or_else(|_| "null".into())); + let armor_json = p.equipped_armor.as_ref().map(|a| serde_json::to_string(a).unwrap_or_else(|_| "null".into())); + + self.db.save_player(&SavedPlayer { + name: p.name.clone(), race_id: p.race_id.clone(), class_id: p.class_id.clone(), + room_id: p.room_id.clone(), level: p.stats.level, xp: p.stats.xp, + hp: p.stats.hp, max_hp: p.stats.max_hp, attack: p.stats.attack, + defense: p.stats.defense, inventory_json: inv_json, + equipped_weapon_json: weapon_json, equipped_armor_json: armor_json, + }); + } } pub fn remove_player(&mut self, id: usize) -> Option { + self.save_player_to_db(id); self.players.remove(&id) } pub fn players_in_room(&self, room_id: &str, exclude_id: usize) -> Vec<&PlayerConnection> { - self.players - .iter() + self.players.iter() .filter(|(&id, conn)| conn.player.room_id == room_id && id != exclude_id) - .map(|(_, conn)| conn) - .collect() + .map(|(_, conn)| conn).collect() } - pub fn all_player_names(&self) -> Vec<&str> { - self.players - .values() - .map(|c| c.player.name.as_str()) - .collect() + pub fn check_respawns(&mut self) { + let now = Instant::now(); + for (npc_id, instance) in self.npc_instances.iter_mut() { + if instance.alive { continue; } + let npc = match self.world.npcs.get(npc_id) { Some(n) => n, None => continue }; + let respawn_secs = match npc.respawn_secs { Some(s) => s, None => continue }; + if let Some(death_time) = instance.death_time { + if now.duration_since(death_time).as_secs() >= respawn_secs { + if let Some(ref combat) = npc.combat { + instance.hp = combat.max_hp; + instance.alive = true; + instance.death_time = None; + } + } + } + } + } + + pub fn check_level_up(&mut self, player_id: usize) -> Option { + let conn = self.players.get_mut(&player_id)?; + let player = &mut conn.player; + if player.stats.xp < player.stats.xp_to_next { return None; } + + player.stats.xp -= player.stats.xp_to_next; + player.stats.level += 1; + player.stats.xp_to_next = player.stats.level * 100; + + let class = self.world.classes.iter().find(|c| c.id == player.class_id); + let (hp_g, atk_g, def_g) = match class { + Some(c) => (c.growth.hp_per_level, c.growth.attack_per_level, c.growth.defense_per_level), + None => (10, 2, 1), + }; + player.stats.max_hp += hp_g; + player.stats.hp = player.stats.max_hp; + player.stats.attack += atk_g; + player.stats.defense += def_g; + + Some(format!("You are now level {}! HP:{} ATK:{} DEF:{}", player.stats.level, player.stats.max_hp, player.stats.attack, player.stats.defense)) } } diff --git a/src/main.rs b/src/main.rs index 44d1a0f..65ad751 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,8 @@ mod ansi; +mod chargen; +mod combat; mod commands; +mod db; mod game; mod ssh; mod world; @@ -14,6 +17,7 @@ use tokio::net::TcpListener; const DEFAULT_PORT: u16 = 2222; const DEFAULT_WORLD_DIR: &str = "./world"; +const DEFAULT_DB_PATH: &str = "./mudserver.db"; #[tokio::main] async fn main() { @@ -21,6 +25,7 @@ async fn main() { let mut port = DEFAULT_PORT; let mut world_dir = PathBuf::from(DEFAULT_WORLD_DIR); + let mut db_path = PathBuf::from(DEFAULT_DB_PATH); let args: Vec = std::env::args().collect(); let mut i = 1; @@ -28,26 +33,25 @@ async fn main() { match args[i].as_str() { "--port" | "-p" => { i += 1; - port = args - .get(i) - .and_then(|s| s.parse().ok()) - .expect("--port requires a number"); + port = args.get(i).and_then(|s| s.parse().ok()).expect("--port requires a number"); } "--world" | "-w" => { i += 1; - world_dir = PathBuf::from( - args.get(i).expect("--world requires a path"), - ); + world_dir = PathBuf::from(args.get(i).expect("--world requires a path")); + } + "--db" | "-d" => { + i += 1; + db_path = PathBuf::from(args.get(i).expect("--db requires a path")); } "--help" => { - eprintln!("Usage: mudserver [--port PORT] [--world PATH]"); + eprintln!("Usage: mudserver [OPTIONS]"); eprintln!(" --port, -p Listen port (default: {DEFAULT_PORT})"); eprintln!(" --world, -w World directory (default: {DEFAULT_WORLD_DIR})"); + eprintln!(" --db, -d Database path (default: {DEFAULT_DB_PATH})"); std::process::exit(0); } other => { eprintln!("Unknown argument: {other}"); - eprintln!("Run with --help for usage."); std::process::exit(1); } } @@ -60,9 +64,14 @@ async fn main() { std::process::exit(1); }); - let key = - russh::keys::PrivateKey::random(&mut OsRng, russh::keys::Algorithm::Ed25519).unwrap(); + log::info!("Opening database: {}", db_path.display()); + let database = db::SqliteDb::open(&db_path).unwrap_or_else(|e| { + eprintln!("Failed to open database: {e}"); + std::process::exit(1); + }); + let db: Arc = Arc::new(database); + let key = russh::keys::PrivateKey::random(&mut OsRng, russh::keys::Algorithm::Ed25519).unwrap(); let config = russh::server::Config { inactivity_timeout: Some(std::time::Duration::from_secs(3600)), auth_rejection_time: std::time::Duration::from_secs(1), @@ -72,7 +81,7 @@ async fn main() { }; let config = Arc::new(config); - let state = Arc::new(Mutex::new(game::GameState::new(loaded_world))); + let state = Arc::new(Mutex::new(game::GameState::new(loaded_world, db))); let mut server = ssh::MudServer::new(state); let listener = TcpListener::bind(("0.0.0.0", port)).await.unwrap(); diff --git a/src/ssh.rs b/src/ssh.rs index 1e29f2e..1f9ee5f 100644 --- a/src/ssh.rs +++ b/src/ssh.rs @@ -4,6 +4,7 @@ use russh::server::{Auth, Handle, Msg, Server, Session}; use russh::{Channel, ChannelId, CryptoVec, Pty}; use crate::ansi; +use crate::chargen::ChargenState; use crate::commands; use crate::game::SharedState; @@ -14,10 +15,7 @@ pub struct MudServer { impl MudServer { pub fn new(state: SharedState) -> Self { - MudServer { - state, - next_id: AtomicUsize::new(1), - } + MudServer { state, next_id: AtomicUsize::new(1) } } } @@ -28,12 +26,8 @@ impl Server for MudServer { let id = self.next_id.fetch_add(1, Ordering::SeqCst); log::info!("New connection (id={id}) from {addr:?}"); MudHandler { - id, - username: String::new(), - channel: None, - handle: None, - line_buffer: String::new(), - state: self.state.clone(), + id, username: String::new(), channel: None, handle: None, + line_buffer: String::new(), chargen: None, state: self.state.clone(), } } @@ -48,129 +42,150 @@ pub struct MudHandler { channel: Option, handle: Option, line_buffer: String, + // None = not yet determined, Some(None) = returning player, Some(Some(cg)) = in chargen + chargen: Option>, state: SharedState, } impl MudHandler { - async fn register_player(&self, session: &mut Session, channel: ChannelId) { - let handle = session.handle(); - let mut state = self.state.lock().await; - state.add_player(self.id, self.username.clone(), channel, handle); + fn send_text(&self, session: &mut Session, channel: ChannelId, text: &str) { + let _ = session.data(channel, CryptoVec::from(text.as_bytes())); + } - let spawn_room = state.spawn_room().to_string(); - let arrival = CryptoVec::from( - format!( - "\r\n{}\r\n{}", - ansi::system_msg(&format!("{} has entered the world.", self.username)), - ansi::prompt() - ) - .as_bytes(), - ); - let others: Vec<_> = state - .players_in_room(&spawn_room, self.id) - .iter() - .map(|c| (c.channel, c.handle.clone())) - .collect(); + async fn start_session(&mut self, session: &mut Session, channel: ChannelId) { + let state = self.state.lock().await; + let world_name = state.world.name.clone(); + + // Check if returning player + let saved = state.db.load_player(&self.username); drop(state); + let welcome = format!( + "{}\r\n{}Welcome to {}, {}!\r\n", + ansi::CLEAR_SCREEN, ansi::welcome_banner(), + ansi::bold(&world_name), ansi::player_name(&self.username), + ); + self.send_text(session, channel, &welcome); + + if let Some(saved) = saved { + // Returning player — load from DB + let handle = session.handle(); + let mut state = self.state.lock().await; + state.load_existing_player(self.id, saved, channel, handle); + + let msg = format!("{}\r\n", ansi::system_msg("Welcome back! Your character has been restored.")); + drop(state); + self.send_text(session, channel, &msg); + + self.chargen = Some(None); // signal: no chargen needed + self.enter_world(session, channel).await; + } else { + // New player — start chargen + let cg = ChargenState::new(); + let state = self.state.lock().await; + let prompt = cg.prompt_text(&state.world); + drop(state); + self.send_text(session, channel, &prompt); + self.chargen = Some(Some(cg)); + } + } + + async fn enter_world(&mut self, session: &mut Session, channel: ChannelId) { + let state = self.state.lock().await; + + let (room_id, player_name) = match state.players.get(&self.id) { + Some(c) => (c.player.room_id.clone(), c.player.name.clone()), + None => return, + }; + + // Broadcast arrival + let arrival = CryptoVec::from( + format!("\r\n{}\r\n{}", ansi::system_msg(&format!("{player_name} has entered the world.")), ansi::prompt()).as_bytes(), + ); + let others: Vec<_> = state.players_in_room(&room_id, self.id).iter().map(|c| (c.channel, c.handle.clone())).collect(); + + // Render room + let room_view = render_entry_room(&state, &room_id, &player_name, self.id); + drop(state); + + self.send_text(session, channel, &room_view); + for (ch, h) in others { let _ = h.data(ch, arrival.clone()).await; } } - async fn send_welcome(&self, session: &mut Session, channel: ChannelId) { - let state = self.state.lock().await; - let world_name = state.world.name.clone(); + async fn finish_chargen(&mut self, race_id: String, class_id: String, session: &mut Session, channel: ChannelId) { + let handle = session.handle(); + let mut state = self.state.lock().await; + + let race_name = state.world.races.iter().find(|r| r.id == race_id).map(|r| r.name.clone()).unwrap_or_default(); + let class_name = state.world.classes.iter().find(|c| c.id == class_id).map(|c| c.name.clone()).unwrap_or_default(); + + state.create_new_player(self.id, self.username.clone(), race_id, class_id, channel, handle); + state.save_player_to_db(self.id); + drop(state); - let welcome = format!( - "{}\r\n{}Welcome to {}, {}! Type {} to get started.\r\n\r\n", - ansi::CLEAR_SCREEN, - ansi::welcome_banner(), - ansi::bold(&world_name), - ansi::player_name(&self.username), - ansi::color(ansi::YELLOW, "'help'") - ); - let _ = session.data(channel, CryptoVec::from(welcome.as_bytes())); - } + let msg = format!("\r\n{}\r\n\r\n", ansi::system_msg(&format!("Character created: {} the {} {}", self.username, race_name, class_name))); + self.send_text(session, channel, &msg); - async fn show_room(&self, session: &mut Session, channel: ChannelId) { - let state = self.state.lock().await; - let room_id = match state.players.get(&self.id) { - Some(c) => c.player.room_id.clone(), - None => return, - }; - let room = match state.world.get_room(&room_id) { - Some(r) => r, - None => return, - }; - - let mut out = String::new(); - out.push_str(&format!( - "{} {}\r\n", - ansi::room_name(&room.name), - ansi::system_msg(&format!("[{}]", room.region)) - )); - out.push_str(&format!(" {}\r\n", room.description)); - - if !room.npcs.is_empty() { - let npc_names: Vec = 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(", ") - )); - } - } - - if !room.exits.is_empty() { - let mut dirs: Vec<&String> = room.exits.keys().collect(); - dirs.sort(); - let dir_strs: Vec = 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(&ansi::prompt()); - let _ = session.data(channel, CryptoVec::from(out.as_bytes())); + self.chargen = Some(None); + self.enter_world(session, channel).await; } async fn handle_disconnect(&self) { let mut state = self.state.lock().await; if let Some(conn) = state.remove_player(self.id) { let departure = CryptoVec::from( - format!( - "\r\n{}\r\n{}", - ansi::system_msg(&format!("{} has left the world.", conn.player.name)), - ansi::prompt() - ) - .as_bytes(), + format!("\r\n{}\r\n{}", ansi::system_msg(&format!("{} has left the world.", conn.player.name)), ansi::prompt()).as_bytes(), ); - let others: Vec<_> = state - .players_in_room(&conn.player.room_id, self.id) - .iter() - .map(|c| (c.channel, c.handle.clone())) - .collect(); + let others: Vec<_> = state.players_in_room(&conn.player.room_id, self.id).iter().map(|c| (c.channel, c.handle.clone())).collect(); drop(state); - - for (ch, h) in others { - let _ = h.data(ch, departure.clone()).await; - } - + for (ch, h) in others { let _ = h.data(ch, departure.clone()).await; } log::info!("{} disconnected (id={})", conn.player.name, self.id); } } } +fn render_entry_room(state: &crate::game::GameState, room_id: &str, player_name: &str, player_id: usize) -> String { + let room = match state.world.get_room(room_id) { Some(r) => r, None => return String::new() }; + let mut out = String::new(); + out.push_str(&format!("{} {}\r\n", ansi::room_name(&room.name), ansi::system_msg(&format!("[{}]", room.region)))); + out.push_str(&format!(" {}\r\n", room.description)); + + let npc_strs: Vec = room.npcs.iter().filter_map(|id| { + let npc = state.world.get_npc(id)?; + let alive = state.npc_instances.get(id).map(|i| i.alive).unwrap_or(true); + if !alive { return None; } + let att = state.npc_attitude_toward(id, player_name); + let color = match att { + crate::world::Attitude::Friendly => ansi::GREEN, + crate::world::Attitude::Neutral | crate::world::Attitude::Wary => ansi::YELLOW, + _ => ansi::RED, + }; + Some(ansi::color(color, &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 others = state.players_in_room(room_id, player_id); + if !others.is_empty() { + let names: Vec = others.iter().map(|c| ansi::player_name(&c.player.name)).collect(); + out.push_str(&format!("{}{}\r\n", ansi::color(ansi::GREEN, "Players here: "), names.join(", "))); + } + + if !room.exits.is_empty() { + let mut dirs: Vec<&String> = room.exits.keys().collect(); + dirs.sort(); + let dir_strs: Vec = 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(&ansi::prompt()); + out +} + impl russh::server::Handler for MudHandler { type Error = russh::Error; @@ -180,13 +195,8 @@ impl russh::server::Handler for MudHandler { Ok(Auth::Accept) } - async fn auth_publickey( - &mut self, - user: &str, - _key: &russh::keys::ssh_key::PublicKey, - ) -> Result { + async fn auth_publickey(&mut self, user: &str, _key: &russh::keys::ssh_key::PublicKey) -> Result { self.username = user.to_string(); - log::info!("Pubkey auth accepted for '{}' (id={})", user, self.id); Ok(Auth::Accept) } @@ -195,51 +205,24 @@ impl russh::server::Handler for MudHandler { Ok(Auth::Accept) } - async fn channel_open_session( - &mut self, - channel: Channel, - session: &mut Session, - ) -> Result { + async fn channel_open_session(&mut self, channel: Channel, session: &mut Session) -> Result { self.channel = Some(channel.id()); self.handle = Some(session.handle()); Ok(true) } - async fn pty_request( - &mut self, - channel: ChannelId, - _term: &str, - _col_width: u32, - _row_height: u32, - _pix_width: u32, - _pix_height: u32, - _modes: &[(Pty, u32)], - session: &mut Session, - ) -> Result<(), Self::Error> { + async fn pty_request(&mut self, channel: ChannelId, _term: &str, _col_width: u32, _row_height: u32, _pix_width: u32, _pix_height: u32, _modes: &[(Pty, u32)], session: &mut Session) -> Result<(), Self::Error> { session.channel_success(channel)?; Ok(()) } - async fn shell_request( - &mut self, - channel: ChannelId, - session: &mut Session, - ) -> Result<(), Self::Error> { + async fn shell_request(&mut self, channel: ChannelId, session: &mut Session) -> Result<(), Self::Error> { session.channel_success(channel)?; - - self.send_welcome(session, channel).await; - self.register_player(session, channel).await; - self.show_room(session, channel).await; - + self.start_session(session, channel).await; Ok(()) } - async fn data( - &mut self, - channel: ChannelId, - data: &[u8], - session: &mut Session, - ) -> Result<(), Self::Error> { + async fn data(&mut self, channel: ChannelId, data: &[u8], session: &mut Session) -> Result<(), Self::Error> { for &byte in data { match byte { 3 | 4 => { @@ -254,15 +237,47 @@ impl russh::server::Handler for MudHandler { } } b'\r' | b'\n' => { - if byte == b'\n' && self.line_buffer.is_empty() { + if byte == b'\n' && self.line_buffer.is_empty() { continue; } + session.data(channel, CryptoVec::from(&b"\r\n"[..]))?; + let line = std::mem::take(&mut self.line_buffer); + + // Handle chargen flow + let mut chargen_done = None; + let mut chargen_active = false; + if let Some(ref mut chargen_opt) = self.chargen { + if let Some(ref mut cg) = chargen_opt { + chargen_active = true; + let result = { + let state = self.state.lock().await; + cg.handle_input(&line, &state.world) + }; + let msg_text = match result { Ok(msg) | Err(msg) => msg }; + let _ = session.data(channel, CryptoVec::from(msg_text.as_bytes())); + if cg.is_done() { + chargen_done = cg.result(); + } + } + } + if let Some((race_id, class_id)) = chargen_done { + self.chargen = None; + self.finish_chargen(race_id, class_id, session, channel).await; + continue; + } + if chargen_active { + // Still in chargen, show next prompt + if let Some(Some(ref cg)) = self.chargen { + let state = self.state.lock().await; + let prompt = cg.prompt_text(&state.world); + drop(state); + let _ = session.data(channel, CryptoVec::from(prompt.as_bytes())); + } + continue; + } + if self.chargen.is_none() { continue; } - session.data(channel, CryptoVec::from(&b"\r\n"[..]))?; - - let line = std::mem::take(&mut self.line_buffer); - let keep_going = - commands::execute(&line, self.id, &self.state, session, channel).await?; + let keep_going = commands::execute(&line, self.id, &self.state, session, channel).await?; if !keep_going { self.handle_disconnect().await; session.close(channel)?; @@ -280,20 +295,12 @@ impl russh::server::Handler for MudHandler { Ok(()) } - async fn channel_eof( - &mut self, - _channel: ChannelId, - _session: &mut Session, - ) -> Result<(), Self::Error> { + async fn channel_eof(&mut self, _channel: ChannelId, _session: &mut Session) -> Result<(), Self::Error> { self.handle_disconnect().await; Ok(()) } - async fn channel_close( - &mut self, - _channel: ChannelId, - _session: &mut Session, - ) -> Result<(), Self::Error> { + async fn channel_close(&mut self, _channel: ChannelId, _session: &mut Session) -> Result<(), Self::Error> { self.handle_disconnect().await; Ok(()) } diff --git a/src/world.rs b/src/world.rs index 1b15a43..437a4e2 100644 --- a/src/world.rs +++ b/src/world.rs @@ -1,7 +1,63 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::Path; +// --- Attitude system --- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Attitude { + Friendly, // 50 to 100 + Neutral, // 10 to 49 + Wary, // -24 to 9 + Aggressive, // -74 to -25 (will attack if provoked) + Hostile, // -100 to -75 (attacks on sight) +} + +impl Attitude { + pub fn default_value(self) -> i32 { + match self { + Attitude::Friendly => 75, + Attitude::Neutral => 30, + Attitude::Wary => 0, + Attitude::Aggressive => -50, + Attitude::Hostile => -90, + } + } + + pub fn from_value(v: i32) -> Self { + match v { + 50..=i32::MAX => Attitude::Friendly, + 10..=49 => Attitude::Neutral, + -24..=9 => Attitude::Wary, + -74..=-25 => Attitude::Aggressive, + _ => Attitude::Hostile, + } + } + + pub fn label(self) -> &'static str { + match self { + Attitude::Friendly => "friendly", + Attitude::Neutral => "neutral", + Attitude::Wary => "wary", + Attitude::Aggressive => "aggressive", + Attitude::Hostile => "hostile", + } + } + + pub fn will_attack(self) -> bool { + matches!(self, Attitude::Hostile) + } + + pub fn can_be_attacked(self) -> bool { + matches!(self, Attitude::Hostile | Attitude::Aggressive) + } + + pub fn will_talk(self) -> bool { + matches!(self, Attitude::Friendly | Attitude::Neutral | Attitude::Wary) + } +} + // --- On-disk TOML schemas --- #[derive(Deserialize)] @@ -13,8 +69,6 @@ pub struct Manifest { #[derive(Deserialize)] pub struct RegionFile { pub name: String, - #[serde(default)] - pub description: String, } #[derive(Deserialize)] @@ -25,13 +79,50 @@ pub struct RoomFile { pub exits: HashMap, } +#[derive(Deserialize)] +pub struct NpcDialogue { + #[serde(default)] + pub greeting: Option, +} + +#[derive(Deserialize)] +pub struct NpcCombatFile { + pub max_hp: i32, + pub attack: i32, + pub defense: i32, + #[serde(default)] + pub xp_reward: i32, +} + #[derive(Deserialize)] pub struct NpcFile { pub name: String, pub description: String, pub room: String, + #[serde(default = "default_attitude")] + pub base_attitude: Attitude, #[serde(default)] - pub dialogue: Option, + pub faction: Option, + #[serde(default)] + pub respawn_secs: Option, + #[serde(default)] + pub dialogue: Option, + #[serde(default)] + pub combat: Option, +} + +fn default_attitude() -> Attitude { + Attitude::Neutral +} + +#[derive(Deserialize, Default)] +pub struct ObjectStatsFile { + #[serde(default)] + pub damage: Option, + #[serde(default)] + pub armor: Option, + #[serde(default)] + pub heal_amount: Option, } #[derive(Deserialize)] @@ -42,6 +133,62 @@ pub struct ObjectFile { pub room: Option, #[serde(default)] pub kind: Option, + #[serde(default)] + pub takeable: bool, + #[serde(default)] + pub stats: Option, +} + +#[derive(Deserialize, Default, Clone)] +pub struct StatModifiers { + #[serde(default)] + pub strength: i32, + #[serde(default)] + pub dexterity: i32, + #[serde(default)] + pub constitution: i32, + #[serde(default)] + pub intelligence: i32, + #[serde(default)] + pub wisdom: i32, +} + +#[derive(Deserialize)] +pub struct RaceFile { + pub name: String, + pub description: String, + #[serde(default)] + pub stats: StatModifiers, +} + +#[derive(Deserialize, Default, Clone)] +pub struct ClassBaseStats { + #[serde(default)] + pub max_hp: i32, + #[serde(default)] + pub attack: i32, + #[serde(default)] + pub defense: i32, +} + +#[derive(Deserialize, Default, Clone)] +pub struct ClassGrowth { + #[serde(default)] + pub hp_per_level: i32, + #[serde(default)] + pub attack_per_level: i32, + #[serde(default)] + pub defense_per_level: i32, +} + +#[derive(Deserialize)] +pub struct ClassFile { + pub name: String, + pub description: String, + #[serde(default)] + pub base_stats: ClassBaseStats, + #[serde(default)] + pub growth: ClassGrowth, } // --- Runtime types --- @@ -56,20 +203,60 @@ pub struct Room { pub objects: Vec, } +#[derive(Clone)] +pub struct NpcCombatStats { + pub max_hp: i32, + pub attack: i32, + pub defense: i32, + pub xp_reward: i32, +} + +#[derive(Clone)] pub struct Npc { pub id: String, pub name: String, pub description: String, pub room: String, - pub dialogue: Option, + pub base_attitude: Attitude, + pub faction: Option, + pub respawn_secs: Option, + pub greeting: Option, + pub combat: Option, } +#[derive(Clone, Serialize, Deserialize)] +pub struct ObjectStats { + pub damage: Option, + pub armor: Option, + pub heal_amount: Option, +} + +#[derive(Clone, Serialize, Deserialize)] pub struct Object { pub id: String, pub name: String, pub description: String, pub room: Option, pub kind: Option, + pub takeable: bool, + pub stats: ObjectStats, +} + +#[derive(Clone)] +pub struct Race { + pub id: String, + pub name: String, + pub description: String, + pub stats: StatModifiers, +} + +#[derive(Clone)] +pub struct Class { + pub id: String, + pub name: String, + pub description: String, + pub base_stats: ClassBaseStats, + pub growth: ClassGrowth, } pub struct World { @@ -78,204 +265,102 @@ pub struct World { pub rooms: HashMap, pub npcs: HashMap, pub objects: HashMap, + pub races: Vec, + pub classes: Vec, } impl World { pub fn load(world_dir: &Path) -> Result { - let manifest_path = world_dir.join("manifest.toml"); - let manifest: Manifest = load_toml(&manifest_path)?; + let manifest: Manifest = load_toml(&world_dir.join("manifest.toml"))?; let mut rooms = HashMap::new(); let mut npcs = HashMap::new(); let mut objects = HashMap::new(); - let entries = std::fs::read_dir(world_dir) - .map_err(|e| format!("Cannot read world dir {}: {e}", world_dir.display()))?; + let mut races = Vec::new(); + load_entities_from_dir(&world_dir.join("races"), "race", &mut |id, content| { + let rf: RaceFile = toml::from_str(content).map_err(|e| format!("Bad race {id}: {e}"))?; + races.push(Race { id, name: rf.name, description: rf.description, stats: rf.stats }); + Ok(()) + })?; + let mut classes = Vec::new(); + load_entities_from_dir(&world_dir.join("classes"), "class", &mut |id, content| { + let cf: ClassFile = toml::from_str(content).map_err(|e| format!("Bad class {id}: {e}"))?; + classes.push(Class { id, name: cf.name, description: cf.description, base_stats: cf.base_stats, growth: cf.growth }); + Ok(()) + })?; + + let entries = std::fs::read_dir(world_dir) + .map_err(|e| format!("Cannot read world dir: {e}"))?; let mut region_dirs: Vec<_> = entries .filter_map(|e| e.ok()) .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) + .filter(|e| { let n = e.file_name().to_string_lossy().to_string(); n != "races" && n != "classes" }) .collect(); region_dirs.sort_by_key(|e| e.file_name()); for entry in region_dirs { let region_name = entry.file_name().to_string_lossy().to_string(); let region_path = entry.path(); - - let region_toml = region_path.join("region.toml"); - if !region_toml.exists() { - log::debug!("Skipping directory without region.toml: {}", region_path.display()); - continue; - } - - let _region_meta: RegionFile = load_toml(®ion_toml)?; + if !region_path.join("region.toml").exists() { continue; } + let _rm: RegionFile = load_toml(®ion_path.join("region.toml"))?; log::info!("Loading region: {region_name}"); - load_entities_from_dir( - ®ion_path.join("rooms"), - ®ion_name, - &mut |id, content| { - let rf: RoomFile = toml::from_str(content) - .map_err(|e| format!("Bad room {id}: {e}"))?; - rooms.insert( - id.clone(), - Room { - id: id.clone(), - region: region_name.clone(), - name: rf.name, - description: rf.description, - exits: rf.exits, - npcs: Vec::new(), - objects: Vec::new(), - }, - ); - Ok(()) - }, - )?; + load_entities_from_dir(®ion_path.join("rooms"), ®ion_name, &mut |id, content| { + let rf: RoomFile = toml::from_str(content).map_err(|e| format!("Bad room {id}: {e}"))?; + rooms.insert(id.clone(), Room { id: id.clone(), region: region_name.clone(), name: rf.name, description: rf.description, exits: rf.exits, npcs: Vec::new(), objects: Vec::new() }); + Ok(()) + })?; - load_entities_from_dir( - ®ion_path.join("npcs"), - ®ion_name, - &mut |id, content| { - let nf: NpcFile = toml::from_str(content) - .map_err(|e| format!("Bad npc {id}: {e}"))?; - npcs.insert( - id.clone(), - Npc { - id: id.clone(), - name: nf.name, - description: nf.description, - room: nf.room.clone(), - dialogue: nf.dialogue, - }, - ); - Ok(()) - }, - )?; + load_entities_from_dir(®ion_path.join("npcs"), ®ion_name, &mut |id, content| { + let nf: NpcFile = toml::from_str(content).map_err(|e| format!("Bad npc {id}: {e}"))?; + let combat = nf.combat.map(|c| NpcCombatStats { max_hp: c.max_hp, attack: c.attack, defense: c.defense, xp_reward: c.xp_reward }); + let greeting = nf.dialogue.and_then(|d| d.greeting); + npcs.insert(id.clone(), Npc { id: id.clone(), name: nf.name, description: nf.description, room: nf.room, base_attitude: nf.base_attitude, faction: nf.faction, respawn_secs: nf.respawn_secs, greeting, combat }); + Ok(()) + })?; - load_entities_from_dir( - ®ion_path.join("objects"), - ®ion_name, - &mut |id, content| { - let of: ObjectFile = toml::from_str(content) - .map_err(|e| format!("Bad object {id}: {e}"))?; - objects.insert( - id.clone(), - Object { - id: id.clone(), - name: of.name, - description: of.description, - room: of.room, - kind: of.kind, - }, - ); - Ok(()) - }, - )?; + load_entities_from_dir(®ion_path.join("objects"), ®ion_name, &mut |id, content| { + let of: ObjectFile = toml::from_str(content).map_err(|e| format!("Bad object {id}: {e}"))?; + let stats = of.stats.unwrap_or_default(); + objects.insert(id.clone(), Object { id: id.clone(), name: of.name, description: of.description, room: of.room, kind: of.kind, takeable: of.takeable, stats: ObjectStats { damage: stats.damage, armor: stats.armor, heal_amount: stats.heal_amount } }); + Ok(()) + })?; } - // Place NPCs and objects into their rooms - for npc in npcs.values() { - if let Some(room) = rooms.get_mut(&npc.room) { - room.npcs.push(npc.id.clone()); - } - } - for obj in objects.values() { - if let Some(ref room_id) = obj.room { - if let Some(room) = rooms.get_mut(room_id) { - room.objects.push(obj.id.clone()); - } - } - } + for npc in npcs.values() { if let Some(room) = rooms.get_mut(&npc.room) { room.npcs.push(npc.id.clone()); } } + for obj in objects.values() { if let Some(ref rid) = obj.room { if let Some(room) = rooms.get_mut(rid) { room.objects.push(obj.id.clone()); } } } - // Validate - if !rooms.contains_key(&manifest.spawn_room) { - return Err(format!( - "Spawn room '{}' not found in loaded rooms", - manifest.spawn_room - )); - } + if !rooms.contains_key(&manifest.spawn_room) { return Err(format!("Spawn room '{}' not found", manifest.spawn_room)); } + for room in rooms.values() { for (dir, target) in &room.exits { if !rooms.contains_key(target) { return Err(format!("Room '{}' exit '{dir}' -> unknown '{target}'", room.id)); } } } + if races.is_empty() { return Err("No races defined".into()); } + if classes.is_empty() { return Err("No classes defined".into()); } - for room in rooms.values() { - for (dir, target) in &room.exits { - if !rooms.contains_key(target) { - return Err(format!( - "Room '{}' exit '{dir}' points to unknown room '{target}'", - room.id - )); - } - } - } - - log::info!( - "World '{}' loaded: {} rooms, {} npcs, {} objects", - manifest.name, - rooms.len(), - npcs.len(), - objects.len() - ); - - Ok(World { - name: manifest.name, - spawn_room: manifest.spawn_room, - rooms, - npcs, - objects, - }) + log::info!("World '{}': {} rooms, {} npcs, {} objects, {} races, {} classes", manifest.name, rooms.len(), npcs.len(), objects.len(), races.len(), classes.len()); + Ok(World { name: manifest.name, spawn_room: manifest.spawn_room, rooms, npcs, objects, races, classes }) } - pub fn get_room(&self, id: &str) -> Option<&Room> { - self.rooms.get(id) - } - - pub fn get_npc(&self, id: &str) -> Option<&Npc> { - self.npcs.get(id) - } - - pub fn get_object(&self, id: &str) -> Option<&Object> { - self.objects.get(id) - } + pub fn get_room(&self, id: &str) -> Option<&Room> { self.rooms.get(id) } + pub fn get_npc(&self, id: &str) -> Option<&Npc> { self.npcs.get(id) } + pub fn get_object(&self, id: &str) -> Option<&Object> { self.objects.get(id) } } fn load_toml(path: &Path) -> Result { - let content = std::fs::read_to_string(path) - .map_err(|e| format!("Cannot read {}: {e}", path.display()))?; + let content = std::fs::read_to_string(path).map_err(|e| format!("Cannot read {}: {e}", path.display()))?; toml::from_str(&content).map_err(|e| format!("Bad TOML in {}: {e}", path.display())) } -fn load_entities_from_dir( - dir: &Path, - region: &str, - handler: &mut dyn FnMut(String, &str) -> Result<(), String>, -) -> Result<(), String> { - if !dir.exists() { - return Ok(()); - } - - let mut entries: Vec<_> = std::fs::read_dir(dir) - .map_err(|e| format!("Cannot read {}: {e}", dir.display()))? - .filter_map(|e| e.ok()) - .filter(|e| { - e.path() - .extension() - .map(|ext| ext == "toml") - .unwrap_or(false) - }) - .collect(); +fn load_entities_from_dir(dir: &Path, prefix: &str, handler: &mut dyn FnMut(String, &str) -> Result<(), String>) -> Result<(), String> { + if !dir.exists() { return Ok(()); } + let mut entries: Vec<_> = std::fs::read_dir(dir).map_err(|e| format!("Cannot read {}: {e}", dir.display()))? + .filter_map(|e| e.ok()).filter(|e| e.path().extension().map(|ext| ext == "toml").unwrap_or(false)).collect(); entries.sort_by_key(|e| e.file_name()); - for entry in entries { - let stem = entry - .path() - .file_stem() - .unwrap() - .to_string_lossy() - .to_string(); - let id = format!("{region}:{stem}"); - let content = std::fs::read_to_string(entry.path()) - .map_err(|e| format!("Cannot read {}: {e}", entry.path().display()))?; + let stem = entry.path().file_stem().unwrap().to_string_lossy().to_string(); + let id = format!("{prefix}:{stem}"); + let content = std::fs::read_to_string(entry.path()).map_err(|e| format!("Cannot read {}: {e}", entry.path().display()))?; handler(id, &content)?; } - Ok(()) } diff --git a/world/classes/cleric.toml b/world/classes/cleric.toml new file mode 100644 index 0000000..32311de --- /dev/null +++ b/world/classes/cleric.toml @@ -0,0 +1,12 @@ +name = "Cleric" +description = "Devout healers and protectors, clerics channel divine power to mend and shield." + +[base_stats] +max_hp = 100 +attack = 10 +defense = 14 + +[growth] +hp_per_level = 12 +attack_per_level = 2 +defense_per_level = 3 diff --git a/world/classes/mage.toml b/world/classes/mage.toml new file mode 100644 index 0000000..314594a --- /dev/null +++ b/world/classes/mage.toml @@ -0,0 +1,12 @@ +name = "Mage" +description = "Wielders of arcane power, mages trade resilience for devastating force." + +[base_stats] +max_hp = 70 +attack = 18 +defense = 6 + +[growth] +hp_per_level = 8 +attack_per_level = 4 +defense_per_level = 1 diff --git a/world/classes/rogue.toml b/world/classes/rogue.toml new file mode 100644 index 0000000..462f056 --- /dev/null +++ b/world/classes/rogue.toml @@ -0,0 +1,12 @@ +name = "Rogue" +description = "Quick and cunning, rogues strike from the shadows with lethal precision." + +[base_stats] +max_hp = 85 +attack = 16 +defense = 8 + +[growth] +hp_per_level = 10 +attack_per_level = 4 +defense_per_level = 1 diff --git a/world/classes/warrior.toml b/world/classes/warrior.toml new file mode 100644 index 0000000..74ec226 --- /dev/null +++ b/world/classes/warrior.toml @@ -0,0 +1,12 @@ +name = "Warrior" +description = "Masters of arms and armor, warriors lead the charge and hold the line." + +[base_stats] +max_hp = 120 +attack = 14 +defense = 12 + +[growth] +hp_per_level = 15 +attack_per_level = 3 +defense_per_level = 2 diff --git a/world/races/dwarf.toml b/world/races/dwarf.toml new file mode 100644 index 0000000..3698bcf --- /dev/null +++ b/world/races/dwarf.toml @@ -0,0 +1,9 @@ +name = "Dwarf" +description = "Stout and unyielding, dwarves are born of stone and stubbornness." + +[stats] +strength = 1 +dexterity = -1 +constitution = 2 +intelligence = 0 +wisdom = 0 diff --git a/world/races/elf.toml b/world/races/elf.toml new file mode 100644 index 0000000..072afe9 --- /dev/null +++ b/world/races/elf.toml @@ -0,0 +1,9 @@ +name = "Elf" +description = "Graceful and keen-eyed, elves possess an innate affinity for magic." + +[stats] +strength = -1 +dexterity = 2 +constitution = -1 +intelligence = 2 +wisdom = 0 diff --git a/world/races/halfling.toml b/world/races/halfling.toml new file mode 100644 index 0000000..a239803 --- /dev/null +++ b/world/races/halfling.toml @@ -0,0 +1,9 @@ +name = "Halfling" +description = "Small and nimble, halflings slip through danger with uncanny luck." + +[stats] +strength = -2 +dexterity = 3 +constitution = 0 +intelligence = 0 +wisdom = 1 diff --git a/world/races/human.toml b/world/races/human.toml new file mode 100644 index 0000000..00f2cde --- /dev/null +++ b/world/races/human.toml @@ -0,0 +1,9 @@ +name = "Human" +description = "Versatile and adaptable, humans excel through sheer determination." + +[stats] +strength = 0 +dexterity = 0 +constitution = 0 +intelligence = 0 +wisdom = 0 diff --git a/world/races/orc.toml b/world/races/orc.toml new file mode 100644 index 0000000..604e1d4 --- /dev/null +++ b/world/races/orc.toml @@ -0,0 +1,9 @@ +name = "Orc" +description = "Powerful and fierce, orcs channel raw fury into everything they do." + +[stats] +strength = 3 +dexterity = 0 +constitution = 1 +intelligence = -2 +wisdom = -1 diff --git a/world/town/npcs/barkeep.toml b/world/town/npcs/barkeep.toml index 2c83f92..854e17d 100644 --- a/world/town/npcs/barkeep.toml +++ b/world/town/npcs/barkeep.toml @@ -1,4 +1,7 @@ name = "Grizzled Barkeep" description = "A weathered man with thick forearms and a permanent scowl. He polishes the same mug endlessly." room = "town:tavern" -dialogue = "Welcome to The Rusty Tankard. We've got ale, and we've got stronger ale. Pick one." +base_attitude = "friendly" + +[dialogue] +greeting = "Welcome to The Rusty Tankard. We've got ale, and we've got stronger ale." diff --git a/world/town/npcs/guard.toml b/world/town/npcs/guard.toml index aba196b..6a3ec34 100644 --- a/world/town/npcs/guard.toml +++ b/world/town/npcs/guard.toml @@ -1,4 +1,7 @@ name = "Town Guard" description = "A bored-looking guard in dented chainmail. He leans on his spear and watches passersby." room = "town:gate" -dialogue = "Move along. Nothing to see here." +base_attitude = "neutral" + +[dialogue] +greeting = "Move along. Nothing to see here." diff --git a/world/town/npcs/rat.toml b/world/town/npcs/rat.toml new file mode 100644 index 0000000..dce808d --- /dev/null +++ b/world/town/npcs/rat.toml @@ -0,0 +1,11 @@ +name = "Giant Rat" +description = "A mangy rat the size of a small dog. Its eyes gleam with feral hunger." +room = "town:cellar" +base_attitude = "hostile" +respawn_secs = 60 + +[combat] +max_hp = 25 +attack = 6 +defense = 2 +xp_reward = 15 diff --git a/world/town/npcs/thief.toml b/world/town/npcs/thief.toml new file mode 100644 index 0000000..186eb09 --- /dev/null +++ b/world/town/npcs/thief.toml @@ -0,0 +1,12 @@ +name = "Shadowy Thief" +description = "A cloaked figure lurking in the darkness, fingers twitching near a concealed blade." +room = "town:dark_alley" +base_attitude = "aggressive" +faction = "underworld" +respawn_secs = 90 + +[combat] +max_hp = 45 +attack = 12 +defense = 6 +xp_reward = 35 diff --git a/world/town/objects/gold_coin.toml b/world/town/objects/gold_coin.toml new file mode 100644 index 0000000..0f66325 --- /dev/null +++ b/world/town/objects/gold_coin.toml @@ -0,0 +1,5 @@ +name = "Gold Coin" +description = "A single gold coin stamped with the crest of Thornwall." +room = "town:market" +kind = "treasure" +takeable = true diff --git a/world/town/objects/healing_potion.toml b/world/town/objects/healing_potion.toml index e7eb7a1..5165a79 100644 --- a/world/town/objects/healing_potion.toml +++ b/world/town/objects/healing_potion.toml @@ -2,3 +2,7 @@ name = "Healing Potion" description = "A small glass vial filled with a shimmering red liquid." room = "town:temple" kind = "consumable" +takeable = true + +[stats] +heal_amount = 30 diff --git a/world/town/objects/iron_shield.toml b/world/town/objects/iron_shield.toml new file mode 100644 index 0000000..1182e31 --- /dev/null +++ b/world/town/objects/iron_shield.toml @@ -0,0 +1,8 @@ +name = "Iron Shield" +description = "A dented but serviceable round shield bearing the blacksmith's mark." +room = "town:forge" +kind = "armor" +takeable = true + +[stats] +armor = 4 diff --git a/world/town/objects/rusty_sword.toml b/world/town/objects/rusty_sword.toml index 7b236c9..cdb8584 100644 --- a/world/town/objects/rusty_sword.toml +++ b/world/town/objects/rusty_sword.toml @@ -2,3 +2,7 @@ name = "Rusty Sword" description = "A battered iron blade with a cracked leather grip. It's seen better days." room = "town:cellar" kind = "weapon" +takeable = true + +[stats] +damage = 5