Add SQLite persistence, per-player NPC attitude system, character creation, and combat

- Add trait-based DB layer (db.rs) with SQLite backend for easy future swapping
- Player state persisted to SQLite: stats, inventory, equipment, room position
- Returning players skip chargen and resume where they left off
- Replace boolean hostile flag with 5-tier attitude system (friendly/neutral/wary/aggressive/hostile)
- Per-player NPC attitudes stored in DB, shift on kills with faction propagation
- Add character creation flow (chargen.rs) with data-driven races and classes from TOML
- Add turn-based combat system (combat.rs) with XP, leveling, and NPC respawn
- Add commands: take, drop, inventory, equip, use, examine, talk, attack, flee, stats
- Add world data: 5 races, 4 classes, hostile NPCs (rat, thief), new items

Made-with: Cursor
This commit is contained in:
AI Agent
2026-03-14 13:58:22 -06:00
parent c82f57a720
commit 680f48477e
28 changed files with 1797 additions and 673 deletions

3
.gitignore vendored
View File

@@ -1 +1,4 @@
/target /target
*.db
*.db-shm
*.db-wal

102
Cargo.lock generated
View File

@@ -508,6 +508,18 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "ff" name = "ff"
version = "0.13.1" version = "0.13.1"
@@ -530,6 +542,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "futures" name = "futures"
version = "0.3.32" version = "0.3.32"
@@ -663,12 +681,30 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.16.1" version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" 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]] [[package]]
name = "hex" name = "hex"
version = "0.4.3" version = "0.4.3"
@@ -739,7 +775,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown", "hashbrown 0.16.1",
] ]
[[package]] [[package]]
@@ -785,6 +821,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]] [[package]]
name = "jiff" name = "jiff"
version = "0.2.23" version = "0.2.23"
@@ -840,6 +882,17 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" 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]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -884,8 +937,10 @@ version = "0.1.0"
dependencies = [ dependencies = [
"env_logger", "env_logger",
"log", "log",
"rusqlite",
"russh", "russh",
"serde", "serde",
"serde_json",
"tokio", "tokio",
"toml", "toml",
] ]
@@ -1129,6 +1184,12 @@ dependencies = [
"spki", "spki",
] ]
[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]] [[package]]
name = "poly1305" name = "poly1305"
version = "0.8.0" version = "0.8.0"
@@ -1316,6 +1377,20 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "russh" name = "russh"
version = "0.54.5" version = "0.54.5"
@@ -1492,6 +1567,19 @@ dependencies = [
"syn", "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]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "0.6.9" version = "0.6.9"
@@ -1756,6 +1844,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
@@ -2157,3 +2251,9 @@ name = "zeroize"
version = "1.8.2" version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View File

@@ -7,6 +7,8 @@ edition = "2021"
russh = { version = "0.54", default-features = false, features = ["ring"] } russh = { version = "0.54", default-features = false, features = ["ring"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8" toml = "0.8"
rusqlite = { version = "0.35", features = ["bundled"] }
log = "0.4" log = "0.4"
env_logger = "0.11" env_logger = "0.11"

194
src/chargen.rs Normal file
View File

@@ -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<String>,
}
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<String, String> {
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::<Vec<_>>(),
);
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::<Vec<_>>(),
);
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::<usize>() {
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(", ")
}

154
src/combat.rs Normal file
View File

@@ -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<CombatRoundResult> {
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
}

View File

@@ -2,7 +2,9 @@ use russh::server::Session;
use russh::{ChannelId, CryptoVec}; use russh::{ChannelId, CryptoVec};
use crate::ansi; use crate::ansi;
use crate::game::SharedState; use crate::combat;
use crate::game::{CombatState, SharedState};
use crate::world::Attitude;
pub struct BroadcastMsg { pub struct BroadcastMsg {
pub channel: ChannelId, pub channel: ChannelId,
@@ -16,75 +18,62 @@ pub struct CommandResult {
pub quit: bool, pub quit: bool,
} }
const DIRECTION_ALIASES: &[(&str, &str)] = &[ const DIR_ALIASES: &[(&str, &str)] = &[
("n", "north"), ("n","north"),("s","south"),("e","east"),("w","west"),("u","up"),("d","down"),
("s", "south"),
("e", "east"),
("w", "west"),
("u", "up"),
("d", "down"),
]; ];
fn resolve_direction(input: &str) -> &str { fn resolve_dir(input: &str) -> &str {
for &(alias, full) in DIRECTION_ALIASES { for &(a, f) in DIR_ALIASES { if input == a { return f; } }
if input == alias {
return full;
}
}
input input
} }
pub async fn execute( pub async fn execute(
input: &str, input: &str, player_id: usize, state: &SharedState,
player_id: usize, session: &mut Session, channel: ChannelId,
state: &SharedState,
session: &mut Session,
channel: ChannelId,
) -> Result<bool, russh::Error> { ) -> Result<bool, russh::Error> {
let input = input.trim(); let input = input.trim();
if input.is_empty() { if input.is_empty() { send(session, channel, &ansi::prompt())?; return Ok(true); }
send(session, channel, &ansi::prompt())?;
return Ok(true);
}
let (cmd, args) = match input.split_once(' ') { let (cmd, args) = match input.split_once(' ') {
Some((c, a)) => (c.to_lowercase(), a.trim().to_string()), Some((c, a)) => (c.to_lowercase(), a.trim().to_string()),
None => (input.to_lowercase(), String::new()), 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() { 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, "go" => cmd_go(player_id, &args, state).await,
"north" | "south" | "east" | "west" | "up" | "down" | "n" | "s" | "e" | "w" | "u" "north"|"south"|"east"|"west"|"up"|"down"|"n"|"s"|"e"|"w"|"u"|"d" =>
| "d" => cmd_go(player_id, resolve_direction(&cmd), state).await, cmd_go(player_id, resolve_dir(&cmd), state).await,
"say"|"'" => cmd_say(player_id, &args, state).await, "say"|"'" => cmd_say(player_id, &args, state).await,
"who" => cmd_who(player_id, state).await, "who" => cmd_who(player_id, state).await,
"take"|"get" => cmd_take(player_id, &args, state).await,
"drop" => cmd_drop(player_id, &args, state).await,
"inventory"|"inv"|"i" => cmd_inventory(player_id, state).await,
"equip"|"eq" => cmd_equip(player_id, &args, state).await,
"use" => cmd_use(player_id, &args, state).await,
"examine"|"ex"|"x" => cmd_examine(player_id, &args, state).await,
"talk" => cmd_talk(player_id, &args, state).await,
"attack"|"a" => cmd_attack(player_id, &args, state).await,
"flee" => cmd_flee(player_id, state).await,
"stats"|"st" => cmd_stats(player_id, state).await,
"help"|"h"|"?" => cmd_help(), "help"|"h"|"?" => cmd_help(),
"quit" | "exit" => CommandResult { "quit"|"exit" => CommandResult { output: format!("{}\r\n", ansi::system_msg("Farewell, adventurer...")), broadcasts: Vec::new(), quit: true },
output: format!("{}\r\n", ansi::system_msg("Farewell, adventurer...")), _ => simple(&format!("{}\r\n", ansi::error_msg(&format!("Unknown command: '{cmd}'. Type 'help' for commands.")))),
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,
},
}; };
send(session, channel, &result.output)?; send(session, channel, &result.output)?;
for msg in result.broadcasts { let _ = msg.handle.data(msg.channel, msg.data).await; }
for msg in result.broadcasts { if result.quit { return Ok(false); }
let _ = msg.handle.data(msg.channel, msg.data).await;
}
if result.quit {
return Ok(false);
}
send(session, channel, &ansi::prompt())?; send(session, channel, &ansi::prompt())?;
Ok(true) Ok(true)
} }
@@ -94,323 +83,409 @@ fn send(session: &mut Session, channel: ChannelId, text: &str) -> Result<(), rus
Ok(()) Ok(())
} }
fn render_room_view( fn attitude_color(att: Attitude) -> &'static str {
room_id: &str, match att {
player_id: usize, Attitude::Friendly => ansi::GREEN,
state: &tokio::sync::MutexGuard<'_, crate::game::GameState>, Attitude::Neutral | Attitude::Wary => ansi::YELLOW,
) -> String { Attitude::Aggressive | Attitude::Hostile => ansi::RED,
let room = match state.world.get_room(room_id) { }
}
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, Some(r) => r,
None => return format!("{}\r\n", ansi::error_msg("You are in the void.")), 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(); let mut out = format!("\r\n{} {}\r\n {}\r\n",
out.push_str(&format!( ansi::room_name(&room.name), ansi::system_msg(&format!("[{}]", room.region)), room.description);
"\r\n{} {}\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_strs: Vec<String> = room.npcs.iter().filter_map(|id| {
let npc_names: Vec<String> = room let npc = st.world.get_npc(id)?;
.npcs if !st.npc_instances.get(id).map(|i| i.alive).unwrap_or(true) { return None; }
.iter() let att = st.npc_attitude_toward(id, player_name);
.filter_map(|id| state.world.get_npc(id)) Some(ansi::color(attitude_color(att), &npc.name))
.map(|n| ansi::color(ansi::YELLOW, &n.name)) }).collect();
.collect(); if !npc_strs.is_empty() {
if !npc_names.is_empty() { out.push_str(&format!("\r\n{}{}\r\n", ansi::color(ansi::DIM, "Present: "), npc_strs.join(", ")));
out.push_str(&format!(
"\r\n{}{}\r\n",
ansi::color(ansi::DIM, "Present: "),
npc_names.join(", ")
));
}
} }
if !room.objects.is_empty() { let obj_strs: Vec<String> = room.objects.iter()
let obj_names: Vec<String> = room .filter_map(|id| st.world.get_object(id))
.objects .map(|o| ansi::color(ansi::CYAN, &o.name)).collect();
.iter() if !obj_strs.is_empty() {
.filter_map(|id| state.world.get_object(id)) out.push_str(&format!("{}{}\r\n", ansi::color(ansi::DIM, "You see: "), obj_strs.join(", ")));
.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 others = state.players_in_room(room_id, player_id); let others = st.players_in_room(room_id, player_id);
if !others.is_empty() { if !others.is_empty() {
let names: Vec<String> = others let names: Vec<String> = others.iter().map(|c| ansi::player_name(&c.player.name)).collect();
.iter() out.push_str(&format!("{}{}\r\n", ansi::color(ansi::GREEN, "Players here: "), names.join(", ")));
.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() { if !room.exits.is_empty() {
let mut dirs: Vec<&String> = room.exits.keys().collect(); let mut dirs: Vec<&String> = room.exits.keys().collect();
dirs.sort(); dirs.sort();
let dir_strs: Vec<String> = dirs.iter().map(|d| ansi::direction(d)).collect(); out.push_str(&format!("{} {}\r\n", ansi::color(ansi::DIM, "Exits:"),
out.push_str(&format!( dirs.iter().map(|d| ansi::direction(d)).collect::<Vec<_>>().join(", ")));
"{} {}\r\n",
ansi::color(ansi::DIM, "Exits:"),
dir_strs.join(", ")
));
} }
out out
} }
async fn cmd_look(player_id: usize, state: &SharedState) -> CommandResult { async fn cmd_look(pid: usize, state: &SharedState) -> CommandResult {
let state = state.lock().await; let st = state.lock().await;
let room_id = match state.players.get(&player_id) { let rid = match st.players.get(&pid) { Some(c) => c.player.room_id.clone(), None => return simple("Error\r\n") };
Some(c) => c.player.room_id.clone(), CommandResult { output: render_room_view(&rid, pid, &st), broadcasts: Vec::new(), quit: false }
None => { }
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}.")))),
}
};
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_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 }
}
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 }
}
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(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 }
}
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_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 }
}
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 };
}
}
}
}
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_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;
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 { return CommandResult {
output: format!("{}\r\n", ansi::error_msg("You don't seem to exist.")), output: format!("\r\n{} says: \"{}\"\r\n", ansi::color(ansi::YELLOW, &npc.name), ansi::color(ansi::WHITE, greeting)),
broadcasts: Vec::new(), broadcasts: Vec::new(), quit: false,
quit: false,
}
}
};
CommandResult {
output: render_room_view(&room_id, player_id, &state),
broadcasts: Vec::new(),
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;
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(),
)
};
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(),
});
}
if let Some(conn) = state.players.get_mut(&player_id) {
conn.player.room_id = new_room_id.clone();
}
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_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,
}; };
} }
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,
} }
} }
}; simple(&format!("{}\r\n", ansi::error_msg(&format!("You don't see '{target}' here to talk to."))))
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 { async fn cmd_attack(pid: usize, target: &str, state: &SharedState) -> CommandResult {
output: self_msg, let mut st = state.lock().await;
broadcasts,
quit: false,
}
}
async fn cmd_who(player_id: usize, state: &SharedState) -> CommandResult { let npc_id = {
let state = state.lock().await; let conn = match st.players.get(&pid) { Some(c) => c, None => return simple("Error\r\n") };
let mut out = String::new(); if let Some(ref combat) = conn.combat {
out.push_str(&format!( combat.npc_id.clone()
"\r\n{}\r\n",
ansi::bold("=== Who's Online ===")
));
let self_name = state
.players
.get(&player_id)
.map(|c| c.player.name.as_str())
.unwrap_or("");
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)"
} else { } else {
"" 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.")))),
}
}
}; };
out.push_str(&format!(
" {}{}{}\r\n", // Set combat state if not already
ansi::player_name(&conn.player.name), if st.players.get(&pid).map(|c| c.combat.is_none()).unwrap_or(false) {
ansi::room_name(room_name), if let Some(c) = st.players.get_mut(&pid) {
ansi::system_msg(marker) c.combat = Some(CombatState { npc_id: npc_id.clone() });
)); }
} }
let count = state.players.len(); st.check_respawns();
out.push_str(&format!(
"{}\r\n",
ansi::system_msg(&format!("{count} player(s) online"))
));
CommandResult { let player_name = st.players.get(&pid).map(|c| c.player.name.clone()).unwrap_or_default();
output: out, let result = combat::do_attack(pid, &npc_id, &mut st);
broadcasts: Vec::new(),
quit: false, 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 { fn cmd_help() -> CommandResult {
let mut out = String::new(); let mut out = format!("\r\n{}\r\n", ansi::bold("=== Commands ==="));
out.push_str(&format!("\r\n{}\r\n", ansi::bold("=== Commands ===")));
let cmds = [ let cmds = [
("look, l", "Look around the current room"), ("look, l", "Look around the current room"),
( ("go <dir>, n/s/e/w/u/d", "Move in a direction"),
"go <dir>, north/n, south/s, east/e, west/w", ("say <msg>", "Say something to the room"),
"Move in a direction",
),
("say <msg>, ' <msg>", "Say something to players in the room"),
("who", "See who's online"), ("who", "See who's online"),
("examine <target>, x", "Inspect an NPC, object, or item"),
("talk <npc>", "Talk to a friendly NPC"),
("take <item>", "Pick up an object"),
("drop <item>", "Drop an item from inventory"),
("inventory, i", "View your inventory"),
("equip <item>", "Equip a weapon or armor"),
("use <item>", "Use a consumable item"),
("attack <target>, a", "Attack a hostile NPC"),
("flee", "Disengage from combat"),
("stats, st", "View your character stats"),
("help, h, ?", "Show this help"), ("help, h, ?", "Show this help"),
("quit, exit", "Leave the game"), ("quit, exit", "Leave the game"),
]; ];
for (cmd, desc) in cmds { for (c, d) in cmds { out.push_str(&format!(" {:<30} {}\r\n", ansi::color(ansi::YELLOW, c), ansi::color(ansi::DIM, d))); }
out.push_str(&format!( CommandResult { output: out, broadcasts: Vec::new(), quit: false }
" {:<44} {}\r\n",
ansi::color(ansi::YELLOW, cmd),
ansi::color(ansi::DIM, desc)
));
}
CommandResult {
output: out,
broadcasts: Vec::new(),
quit: false,
} }
fn simple(msg: &str) -> CommandResult {
CommandResult { output: msg.to_string(), broadcasts: Vec::new(), quit: false }
} }

173
src/db.rs Normal file
View File

@@ -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<String>,
pub equipped_armor_json: Option<String>,
}
pub struct NpcAttitudeRow {
pub npc_id: String,
pub value: i32,
}
pub trait GameDb: Send + Sync {
fn load_player(&self, name: &str) -> Option<SavedPlayer>;
fn save_player(&self, player: &SavedPlayer);
fn delete_player(&self, name: &str);
fn load_attitudes(&self, player_name: &str) -> Vec<NpcAttitudeRow>;
fn save_attitude(&self, player_name: &str, npc_id: &str, value: i32);
fn get_attitude(&self, player_name: &str, npc_id: &str) -> Option<i32>;
}
// --- SQLite implementation ---
pub struct SqliteDb {
conn: std::sync::Mutex<rusqlite::Connection>,
}
impl SqliteDb {
pub fn open(path: &Path) -> Result<Self, String> {
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<SavedPlayer> {
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<NpcAttitudeRow> {
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<i32> {
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()
}
}

View File

@@ -1,70 +1,249 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use russh::server::Handle; use russh::server::Handle;
use russh::ChannelId; 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 struct Player {
pub name: String, pub name: String,
pub race_id: String,
pub class_id: String,
pub room_id: String, pub room_id: String,
pub stats: PlayerStats,
pub inventory: Vec<Object>,
pub equipped_weapon: Option<Object>,
pub equipped_armor: Option<Object>,
}
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<Instant>,
} }
pub struct PlayerConnection { pub struct PlayerConnection {
pub player: Player, pub player: Player,
pub channel: ChannelId, pub channel: ChannelId,
pub handle: Handle, pub handle: Handle,
pub combat: Option<CombatState>,
} }
pub struct GameState { pub struct GameState {
pub world: World, pub world: World,
pub db: Arc<dyn GameDb>,
pub players: HashMap<usize, PlayerConnection>, pub players: HashMap<usize, PlayerConnection>,
pub npc_instances: HashMap<String, NpcInstance>,
} }
pub type SharedState = Arc<Mutex<GameState>>; pub type SharedState = Arc<Mutex<GameState>>;
impl GameState { impl GameState {
pub fn new(world: World) -> Self { pub fn new(world: World, db: Arc<dyn GameDb>) -> Self {
GameState { let mut npc_instances = HashMap::new();
world, for npc in world.npcs.values() {
players: HashMap::new(), 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 { pub fn spawn_room(&self) -> &str {
&self.world.spawn_room &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(); let room_id = self.world.spawn_room.clone();
self.players.insert( let race = self.world.races.iter().find(|r| r.id == race_id);
id, let class = self.world.classes.iter().find(|c| c.id == class_id);
PlayerConnection {
player: Player { name, room_id }, let (base_hp, base_atk, base_def) = match class {
channel, Some(c) => (c.base_stats.max_hp, c.base_stats.attack, c.base_stats.defense),
handle, 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<Object> = serde_json::from_str(&saved.inventory_json).unwrap_or_default();
let equipped_weapon: Option<Object> = saved.equipped_weapon_json.as_deref().and_then(|j| serde_json::from_str(j).ok());
let equipped_armor: Option<Object> = 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<PlayerConnection> { pub fn remove_player(&mut self, id: usize) -> Option<PlayerConnection> {
self.save_player_to_db(id);
self.players.remove(&id) self.players.remove(&id)
} }
pub fn players_in_room(&self, room_id: &str, exclude_id: usize) -> Vec<&PlayerConnection> { pub fn players_in_room(&self, room_id: &str, exclude_id: usize) -> Vec<&PlayerConnection> {
self.players self.players.iter()
.iter()
.filter(|(&id, conn)| conn.player.room_id == room_id && id != exclude_id) .filter(|(&id, conn)| conn.player.room_id == room_id && id != exclude_id)
.map(|(_, conn)| conn) .map(|(_, conn)| conn).collect()
.collect()
} }
pub fn all_player_names(&self) -> Vec<&str> { pub fn check_respawns(&mut self) {
self.players let now = Instant::now();
.values() for (npc_id, instance) in self.npc_instances.iter_mut() {
.map(|c| c.player.name.as_str()) if instance.alive { continue; }
.collect() 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<String> {
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))
} }
} }

View File

@@ -1,5 +1,8 @@
mod ansi; mod ansi;
mod chargen;
mod combat;
mod commands; mod commands;
mod db;
mod game; mod game;
mod ssh; mod ssh;
mod world; mod world;
@@ -14,6 +17,7 @@ use tokio::net::TcpListener;
const DEFAULT_PORT: u16 = 2222; const DEFAULT_PORT: u16 = 2222;
const DEFAULT_WORLD_DIR: &str = "./world"; const DEFAULT_WORLD_DIR: &str = "./world";
const DEFAULT_DB_PATH: &str = "./mudserver.db";
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
@@ -21,6 +25,7 @@ async fn main() {
let mut port = DEFAULT_PORT; let mut port = DEFAULT_PORT;
let mut world_dir = PathBuf::from(DEFAULT_WORLD_DIR); let mut world_dir = PathBuf::from(DEFAULT_WORLD_DIR);
let mut db_path = PathBuf::from(DEFAULT_DB_PATH);
let args: Vec<String> = std::env::args().collect(); let args: Vec<String> = std::env::args().collect();
let mut i = 1; let mut i = 1;
@@ -28,26 +33,25 @@ async fn main() {
match args[i].as_str() { match args[i].as_str() {
"--port" | "-p" => { "--port" | "-p" => {
i += 1; i += 1;
port = args port = args.get(i).and_then(|s| s.parse().ok()).expect("--port requires a number");
.get(i)
.and_then(|s| s.parse().ok())
.expect("--port requires a number");
} }
"--world" | "-w" => { "--world" | "-w" => {
i += 1; i += 1;
world_dir = PathBuf::from( world_dir = PathBuf::from(args.get(i).expect("--world requires a path"));
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" => { "--help" => {
eprintln!("Usage: mudserver [--port PORT] [--world PATH]"); eprintln!("Usage: mudserver [OPTIONS]");
eprintln!(" --port, -p Listen port (default: {DEFAULT_PORT})"); eprintln!(" --port, -p Listen port (default: {DEFAULT_PORT})");
eprintln!(" --world, -w World directory (default: {DEFAULT_WORLD_DIR})"); eprintln!(" --world, -w World directory (default: {DEFAULT_WORLD_DIR})");
eprintln!(" --db, -d Database path (default: {DEFAULT_DB_PATH})");
std::process::exit(0); std::process::exit(0);
} }
other => { other => {
eprintln!("Unknown argument: {other}"); eprintln!("Unknown argument: {other}");
eprintln!("Run with --help for usage.");
std::process::exit(1); std::process::exit(1);
} }
} }
@@ -60,9 +64,14 @@ async fn main() {
std::process::exit(1); std::process::exit(1);
}); });
let key = log::info!("Opening database: {}", db_path.display());
russh::keys::PrivateKey::random(&mut OsRng, russh::keys::Algorithm::Ed25519).unwrap(); let database = db::SqliteDb::open(&db_path).unwrap_or_else(|e| {
eprintln!("Failed to open database: {e}");
std::process::exit(1);
});
let db: Arc<dyn db::GameDb> = Arc::new(database);
let key = russh::keys::PrivateKey::random(&mut OsRng, russh::keys::Algorithm::Ed25519).unwrap();
let config = russh::server::Config { let config = russh::server::Config {
inactivity_timeout: Some(std::time::Duration::from_secs(3600)), inactivity_timeout: Some(std::time::Duration::from_secs(3600)),
auth_rejection_time: std::time::Duration::from_secs(1), auth_rejection_time: std::time::Duration::from_secs(1),
@@ -72,7 +81,7 @@ async fn main() {
}; };
let config = Arc::new(config); 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 mut server = ssh::MudServer::new(state);
let listener = TcpListener::bind(("0.0.0.0", port)).await.unwrap(); let listener = TcpListener::bind(("0.0.0.0", port)).await.unwrap();

View File

@@ -4,6 +4,7 @@ use russh::server::{Auth, Handle, Msg, Server, Session};
use russh::{Channel, ChannelId, CryptoVec, Pty}; use russh::{Channel, ChannelId, CryptoVec, Pty};
use crate::ansi; use crate::ansi;
use crate::chargen::ChargenState;
use crate::commands; use crate::commands;
use crate::game::SharedState; use crate::game::SharedState;
@@ -14,10 +15,7 @@ pub struct MudServer {
impl MudServer { impl MudServer {
pub fn new(state: SharedState) -> Self { pub fn new(state: SharedState) -> Self {
MudServer { MudServer { state, next_id: AtomicUsize::new(1) }
state,
next_id: AtomicUsize::new(1),
}
} }
} }
@@ -28,12 +26,8 @@ impl Server for MudServer {
let id = self.next_id.fetch_add(1, Ordering::SeqCst); let id = self.next_id.fetch_add(1, Ordering::SeqCst);
log::info!("New connection (id={id}) from {addr:?}"); log::info!("New connection (id={id}) from {addr:?}");
MudHandler { MudHandler {
id, id, username: String::new(), channel: None, handle: None,
username: String::new(), line_buffer: String::new(), chargen: None, state: self.state.clone(),
channel: None,
handle: None,
line_buffer: String::new(),
state: self.state.clone(),
} }
} }
@@ -48,129 +42,150 @@ pub struct MudHandler {
channel: Option<ChannelId>, channel: Option<ChannelId>,
handle: Option<Handle>, handle: Option<Handle>,
line_buffer: String, line_buffer: String,
// None = not yet determined, Some(None) = returning player, Some(Some(cg)) = in chargen
chargen: Option<Option<ChargenState>>,
state: SharedState, state: SharedState,
} }
impl MudHandler { impl MudHandler {
async fn register_player(&self, session: &mut Session, channel: ChannelId) { fn send_text(&self, session: &mut Session, channel: ChannelId, text: &str) {
let _ = session.data(channel, CryptoVec::from(text.as_bytes()));
}
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 handle = session.handle();
let mut state = self.state.lock().await; let mut state = self.state.lock().await;
state.add_player(self.id, self.username.clone(), channel, handle); state.load_existing_player(self.id, saved, channel, handle);
let spawn_room = state.spawn_room().to_string(); let msg = format!("{}\r\n", ansi::system_msg("Welcome back! Your character has been restored."));
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();
drop(state); 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 { for (ch, h) in others {
let _ = h.data(ch, arrival.clone()).await; let _ = h.data(ch, arrival.clone()).await;
} }
} }
async fn send_welcome(&self, session: &mut Session, channel: ChannelId) { async fn finish_chargen(&mut self, race_id: String, class_id: String, session: &mut Session, channel: ChannelId) {
let state = self.state.lock().await; let handle = session.handle();
let world_name = state.world.name.clone(); 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); drop(state);
let welcome = format!( let msg = format!("\r\n{}\r\n\r\n", ansi::system_msg(&format!("Character created: {} the {} {}", self.username, race_name, class_name)));
"{}\r\n{}Welcome to {}, {}! Type {} to get started.\r\n\r\n", self.send_text(session, channel, &msg);
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()));
}
async fn show_room(&self, session: &mut Session, channel: ChannelId) { self.chargen = Some(None);
let state = self.state.lock().await; self.enter_world(session, channel).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<String> = room
.npcs
.iter()
.filter_map(|id| state.world.get_npc(id))
.map(|n| ansi::color(ansi::YELLOW, &n.name))
.collect();
if !npc_names.is_empty() {
out.push_str(&format!(
"\r\n{}{}\r\n",
ansi::color(ansi::DIM, "Present: "),
npc_names.join(", ")
));
}
}
if !room.exits.is_empty() {
let mut dirs: Vec<&String> = room.exits.keys().collect();
dirs.sort();
let dir_strs: Vec<String> = dirs.iter().map(|d| ansi::direction(d)).collect();
out.push_str(&format!(
"{} {}\r\n",
ansi::color(ansi::DIM, "Exits:"),
dir_strs.join(", ")
));
}
out.push_str(&ansi::prompt());
let _ = session.data(channel, CryptoVec::from(out.as_bytes()));
} }
async fn handle_disconnect(&self) { async fn handle_disconnect(&self) {
let mut state = self.state.lock().await; let mut state = self.state.lock().await;
if let Some(conn) = state.remove_player(self.id) { if let Some(conn) = state.remove_player(self.id) {
let departure = CryptoVec::from( let departure = CryptoVec::from(
format!( format!("\r\n{}\r\n{}", ansi::system_msg(&format!("{} has left the world.", conn.player.name)), ansi::prompt()).as_bytes(),
"\r\n{}\r\n{}",
ansi::system_msg(&format!("{} has left the world.", conn.player.name)),
ansi::prompt()
)
.as_bytes(),
); );
let others: Vec<_> = state let others: Vec<_> = state.players_in_room(&conn.player.room_id, self.id).iter().map(|c| (c.channel, c.handle.clone())).collect();
.players_in_room(&conn.player.room_id, self.id)
.iter()
.map(|c| (c.channel, c.handle.clone()))
.collect();
drop(state); 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); 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<String> = 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<String> = others.iter().map(|c| ansi::player_name(&c.player.name)).collect();
out.push_str(&format!("{}{}\r\n", ansi::color(ansi::GREEN, "Players here: "), names.join(", ")));
}
if !room.exits.is_empty() {
let mut dirs: Vec<&String> = room.exits.keys().collect();
dirs.sort();
let dir_strs: Vec<String> = dirs.iter().map(|d| ansi::direction(d)).collect();
out.push_str(&format!("{} {}\r\n", ansi::color(ansi::DIM, "Exits:"), dir_strs.join(", ")));
}
out.push_str(&ansi::prompt());
out
}
impl russh::server::Handler for MudHandler { impl russh::server::Handler for MudHandler {
type Error = russh::Error; type Error = russh::Error;
@@ -180,13 +195,8 @@ impl russh::server::Handler for MudHandler {
Ok(Auth::Accept) Ok(Auth::Accept)
} }
async fn auth_publickey( async fn auth_publickey(&mut self, user: &str, _key: &russh::keys::ssh_key::PublicKey) -> Result<Auth, Self::Error> {
&mut self,
user: &str,
_key: &russh::keys::ssh_key::PublicKey,
) -> Result<Auth, Self::Error> {
self.username = user.to_string(); self.username = user.to_string();
log::info!("Pubkey auth accepted for '{}' (id={})", user, self.id);
Ok(Auth::Accept) Ok(Auth::Accept)
} }
@@ -195,51 +205,24 @@ impl russh::server::Handler for MudHandler {
Ok(Auth::Accept) Ok(Auth::Accept)
} }
async fn channel_open_session( async fn channel_open_session(&mut self, channel: Channel<Msg>, session: &mut Session) -> Result<bool, Self::Error> {
&mut self,
channel: Channel<Msg>,
session: &mut Session,
) -> Result<bool, Self::Error> {
self.channel = Some(channel.id()); self.channel = Some(channel.id());
self.handle = Some(session.handle()); self.handle = Some(session.handle());
Ok(true) Ok(true)
} }
async fn pty_request( 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> {
&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)?; session.channel_success(channel)?;
Ok(()) Ok(())
} }
async fn shell_request( async fn shell_request(&mut self, channel: ChannelId, session: &mut Session) -> Result<(), Self::Error> {
&mut self,
channel: ChannelId,
session: &mut Session,
) -> Result<(), Self::Error> {
session.channel_success(channel)?; session.channel_success(channel)?;
self.start_session(session, channel).await;
self.send_welcome(session, channel).await;
self.register_player(session, channel).await;
self.show_room(session, channel).await;
Ok(()) Ok(())
} }
async fn data( async fn data(&mut self, channel: ChannelId, data: &[u8], session: &mut Session) -> Result<(), Self::Error> {
&mut self,
channel: ChannelId,
data: &[u8],
session: &mut Session,
) -> Result<(), Self::Error> {
for &byte in data { for &byte in data {
match byte { match byte {
3 | 4 => { 3 | 4 => {
@@ -254,15 +237,47 @@ impl russh::server::Handler for MudHandler {
} }
} }
b'\r' | b'\n' => { 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; 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 { if !keep_going {
self.handle_disconnect().await; self.handle_disconnect().await;
session.close(channel)?; session.close(channel)?;
@@ -280,20 +295,12 @@ impl russh::server::Handler for MudHandler {
Ok(()) Ok(())
} }
async fn channel_eof( async fn channel_eof(&mut self, _channel: ChannelId, _session: &mut Session) -> Result<(), Self::Error> {
&mut self,
_channel: ChannelId,
_session: &mut Session,
) -> Result<(), Self::Error> {
self.handle_disconnect().await; self.handle_disconnect().await;
Ok(()) Ok(())
} }
async fn channel_close( async fn channel_close(&mut self, _channel: ChannelId, _session: &mut Session) -> Result<(), Self::Error> {
&mut self,
_channel: ChannelId,
_session: &mut Session,
) -> Result<(), Self::Error> {
self.handle_disconnect().await; self.handle_disconnect().await;
Ok(()) Ok(())
} }

View File

@@ -1,7 +1,63 @@
use serde::Deserialize; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::Path; 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 --- // --- On-disk TOML schemas ---
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -13,8 +69,6 @@ pub struct Manifest {
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct RegionFile { pub struct RegionFile {
pub name: String, pub name: String,
#[serde(default)]
pub description: String,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -25,13 +79,50 @@ pub struct RoomFile {
pub exits: HashMap<String, String>, pub exits: HashMap<String, String>,
} }
#[derive(Deserialize)]
pub struct NpcDialogue {
#[serde(default)]
pub greeting: Option<String>,
}
#[derive(Deserialize)]
pub struct NpcCombatFile {
pub max_hp: i32,
pub attack: i32,
pub defense: i32,
#[serde(default)]
pub xp_reward: i32,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct NpcFile { pub struct NpcFile {
pub name: String, pub name: String,
pub description: String, pub description: String,
pub room: String, pub room: String,
#[serde(default = "default_attitude")]
pub base_attitude: Attitude,
#[serde(default)] #[serde(default)]
pub dialogue: Option<String>, pub faction: Option<String>,
#[serde(default)]
pub respawn_secs: Option<u64>,
#[serde(default)]
pub dialogue: Option<NpcDialogue>,
#[serde(default)]
pub combat: Option<NpcCombatFile>,
}
fn default_attitude() -> Attitude {
Attitude::Neutral
}
#[derive(Deserialize, Default)]
pub struct ObjectStatsFile {
#[serde(default)]
pub damage: Option<i32>,
#[serde(default)]
pub armor: Option<i32>,
#[serde(default)]
pub heal_amount: Option<i32>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -42,6 +133,62 @@ pub struct ObjectFile {
pub room: Option<String>, pub room: Option<String>,
#[serde(default)] #[serde(default)]
pub kind: Option<String>, pub kind: Option<String>,
#[serde(default)]
pub takeable: bool,
#[serde(default)]
pub stats: Option<ObjectStatsFile>,
}
#[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 --- // --- Runtime types ---
@@ -56,20 +203,60 @@ pub struct Room {
pub objects: Vec<String>, pub objects: Vec<String>,
} }
#[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 struct Npc {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub description: String, pub description: String,
pub room: String, pub room: String,
pub dialogue: Option<String>, pub base_attitude: Attitude,
pub faction: Option<String>,
pub respawn_secs: Option<u64>,
pub greeting: Option<String>,
pub combat: Option<NpcCombatStats>,
} }
#[derive(Clone, Serialize, Deserialize)]
pub struct ObjectStats {
pub damage: Option<i32>,
pub armor: Option<i32>,
pub heal_amount: Option<i32>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Object { pub struct Object {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub description: String, pub description: String,
pub room: Option<String>, pub room: Option<String>,
pub kind: Option<String>, pub kind: Option<String>,
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 { pub struct World {
@@ -78,204 +265,102 @@ pub struct World {
pub rooms: HashMap<String, Room>, pub rooms: HashMap<String, Room>,
pub npcs: HashMap<String, Npc>, pub npcs: HashMap<String, Npc>,
pub objects: HashMap<String, Object>, pub objects: HashMap<String, Object>,
pub races: Vec<Race>,
pub classes: Vec<Class>,
} }
impl World { impl World {
pub fn load(world_dir: &Path) -> Result<Self, String> { pub fn load(world_dir: &Path) -> Result<Self, String> {
let manifest_path = world_dir.join("manifest.toml"); let manifest: Manifest = load_toml(&world_dir.join("manifest.toml"))?;
let manifest: Manifest = load_toml(&manifest_path)?;
let mut rooms = HashMap::new(); let mut rooms = HashMap::new();
let mut npcs = HashMap::new(); let mut npcs = HashMap::new();
let mut objects = HashMap::new(); let mut objects = HashMap::new();
let entries = std::fs::read_dir(world_dir) let mut races = Vec::new();
.map_err(|e| format!("Cannot read world dir {}: {e}", world_dir.display()))?; 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 let mut region_dirs: Vec<_> = entries
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) .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(); .collect();
region_dirs.sort_by_key(|e| e.file_name()); region_dirs.sort_by_key(|e| e.file_name());
for entry in region_dirs { for entry in region_dirs {
let region_name = entry.file_name().to_string_lossy().to_string(); let region_name = entry.file_name().to_string_lossy().to_string();
let region_path = entry.path(); let region_path = entry.path();
if !region_path.join("region.toml").exists() { continue; }
let region_toml = region_path.join("region.toml"); let _rm: RegionFile = load_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(&region_toml)?;
log::info!("Loading region: {region_name}"); log::info!("Loading region: {region_name}");
load_entities_from_dir( load_entities_from_dir(&region_path.join("rooms"), &region_name, &mut |id, content| {
&region_path.join("rooms"), let rf: RoomFile = toml::from_str(content).map_err(|e| format!("Bad room {id}: {e}"))?;
&region_name, 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() });
&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(()) Ok(())
}, })?;
)?;
load_entities_from_dir( load_entities_from_dir(&region_path.join("npcs"), &region_name, &mut |id, content| {
&region_path.join("npcs"), let nf: NpcFile = toml::from_str(content).map_err(|e| format!("Bad npc {id}: {e}"))?;
&region_name, let combat = nf.combat.map(|c| NpcCombatStats { max_hp: c.max_hp, attack: c.attack, defense: c.defense, xp_reward: c.xp_reward });
&mut |id, content| { let greeting = nf.dialogue.and_then(|d| d.greeting);
let nf: NpcFile = toml::from_str(content) 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 });
.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(()) Ok(())
}, })?;
)?;
load_entities_from_dir( load_entities_from_dir(&region_path.join("objects"), &region_name, &mut |id, content| {
&region_path.join("objects"), let of: ObjectFile = toml::from_str(content).map_err(|e| format!("Bad object {id}: {e}"))?;
&region_name, let stats = of.stats.unwrap_or_default();
&mut |id, content| { 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 } });
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(()) 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 npc in npcs.values() { 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()); } } }
if let Some(room) = rooms.get_mut(&npc.room) {
room.npcs.push(npc.id.clone()); 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()); }
for obj in objects.values() { if classes.is_empty() { return Err("No classes defined".into()); }
if let Some(ref room_id) = obj.room {
if let Some(room) = rooms.get_mut(room_id) { log::info!("World '{}': {} rooms, {} npcs, {} objects, {} races, {} classes", manifest.name, rooms.len(), npcs.len(), objects.len(), races.len(), classes.len());
room.objects.push(obj.id.clone()); Ok(World { name: manifest.name, spawn_room: manifest.spawn_room, rooms, npcs, objects, races, classes })
}
}
} }
// Validate pub fn get_room(&self, id: &str) -> Option<&Room> { self.rooms.get(id) }
if !rooms.contains_key(&manifest.spawn_room) { pub fn get_npc(&self, id: &str) -> Option<&Npc> { self.npcs.get(id) }
return Err(format!( pub fn get_object(&self, id: &str) -> Option<&Object> { self.objects.get(id) }
"Spawn room '{}' not found in loaded rooms",
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}' 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,
})
}
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<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, String> { fn load_toml<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, String> {
let content = std::fs::read_to_string(path) let content = std::fs::read_to_string(path).map_err(|e| format!("Cannot read {}: {e}", path.display()))?;
.map_err(|e| format!("Cannot read {}: {e}", path.display()))?;
toml::from_str(&content).map_err(|e| format!("Bad TOML in {}: {e}", path.display())) toml::from_str(&content).map_err(|e| format!("Bad TOML in {}: {e}", path.display()))
} }
fn load_entities_from_dir( fn load_entities_from_dir(dir: &Path, prefix: &str, handler: &mut dyn FnMut(String, &str) -> Result<(), String>) -> Result<(), String> {
dir: &Path, if !dir.exists() { return Ok(()); }
region: &str, let mut entries: Vec<_> = std::fs::read_dir(dir).map_err(|e| format!("Cannot read {}: {e}", dir.display()))?
handler: &mut dyn FnMut(String, &str) -> Result<(), String>, .filter_map(|e| e.ok()).filter(|e| e.path().extension().map(|ext| ext == "toml").unwrap_or(false)).collect();
) -> 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()); entries.sort_by_key(|e| e.file_name());
for entry in entries { for entry in entries {
let stem = entry let stem = entry.path().file_stem().unwrap().to_string_lossy().to_string();
.path() let id = format!("{prefix}:{stem}");
.file_stem() let content = std::fs::read_to_string(entry.path()).map_err(|e| format!("Cannot read {}: {e}", entry.path().display()))?;
.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()))?;
handler(id, &content)?; handler(id, &content)?;
} }
Ok(()) Ok(())
} }

12
world/classes/cleric.toml Normal file
View File

@@ -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

12
world/classes/mage.toml Normal file
View File

@@ -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

12
world/classes/rogue.toml Normal file
View File

@@ -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

View File

@@ -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

9
world/races/dwarf.toml Normal file
View File

@@ -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

9
world/races/elf.toml Normal file
View File

@@ -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

View File

@@ -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

9
world/races/human.toml Normal file
View File

@@ -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

9
world/races/orc.toml Normal file
View File

@@ -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

View File

@@ -1,4 +1,7 @@
name = "Grizzled Barkeep" name = "Grizzled Barkeep"
description = "A weathered man with thick forearms and a permanent scowl. He polishes the same mug endlessly." description = "A weathered man with thick forearms and a permanent scowl. He polishes the same mug endlessly."
room = "town:tavern" 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."

View File

@@ -1,4 +1,7 @@
name = "Town Guard" name = "Town Guard"
description = "A bored-looking guard in dented chainmail. He leans on his spear and watches passersby." description = "A bored-looking guard in dented chainmail. He leans on his spear and watches passersby."
room = "town:gate" room = "town:gate"
dialogue = "Move along. Nothing to see here." base_attitude = "neutral"
[dialogue]
greeting = "Move along. Nothing to see here."

11
world/town/npcs/rat.toml Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -2,3 +2,7 @@ name = "Healing Potion"
description = "A small glass vial filled with a shimmering red liquid." description = "A small glass vial filled with a shimmering red liquid."
room = "town:temple" room = "town:temple"
kind = "consumable" kind = "consumable"
takeable = true
[stats]
heal_amount = 30

View File

@@ -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

View File

@@ -2,3 +2,7 @@ name = "Rusty Sword"
description = "A battered iron blade with a cracked leather grip. It's seen better days." description = "A battered iron blade with a cracked leather grip. It's seen better days."
room = "town:cellar" room = "town:cellar"
kind = "weapon" kind = "weapon"
takeable = true
[stats]
damage = 5