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
*.db
*.db-shm
*.db-wal

102
Cargo.lock generated
View File

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

View File

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

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

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::sync::Arc;
use std::time::Instant;
use tokio::sync::Mutex;
use russh::server::Handle;
use russh::ChannelId;
use crate::world::World;
use crate::db::{GameDb, SavedPlayer};
use crate::world::{Attitude, Object, World};
#[derive(Clone)]
pub struct PlayerStats {
pub max_hp: i32,
pub hp: i32,
pub attack: i32,
pub defense: i32,
pub level: i32,
pub xp: i32,
pub xp_to_next: i32,
}
pub struct Player {
pub name: String,
pub race_id: String,
pub class_id: String,
pub room_id: String,
pub stats: PlayerStats,
pub inventory: Vec<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 player: Player,
pub channel: ChannelId,
pub handle: Handle,
pub combat: Option<CombatState>,
}
pub struct GameState {
pub world: World,
pub db: Arc<dyn GameDb>,
pub players: HashMap<usize, PlayerConnection>,
pub npc_instances: HashMap<String, NpcInstance>,
}
pub type SharedState = Arc<Mutex<GameState>>;
impl GameState {
pub fn new(world: World) -> Self {
GameState {
world,
players: HashMap::new(),
pub fn new(world: World, db: Arc<dyn GameDb>) -> Self {
let mut npc_instances = HashMap::new();
for npc in world.npcs.values() {
if let Some(ref combat) = npc.combat {
npc_instances.insert(npc.id.clone(), NpcInstance {
hp: combat.max_hp, alive: true, death_time: None,
});
}
}
GameState { world, db, players: HashMap::new(), npc_instances }
}
pub fn spawn_room(&self) -> &str {
&self.world.spawn_room
}
pub fn add_player(&mut self, id: usize, name: String, channel: ChannelId, handle: Handle) {
// Get effective attitude of an NPC towards a specific player
pub fn npc_attitude_toward(&self, npc_id: &str, player_name: &str) -> Attitude {
if let Some(val) = self.db.get_attitude(player_name, npc_id) {
return Attitude::from_value(val);
}
self.world.get_npc(npc_id)
.map(|n| n.base_attitude)
.unwrap_or(Attitude::Neutral)
}
pub fn npc_attitude_value(&self, npc_id: &str, player_name: &str) -> i32 {
if let Some(val) = self.db.get_attitude(player_name, npc_id) {
return val;
}
self.world.get_npc(npc_id)
.map(|n| n.base_attitude.default_value())
.unwrap_or(0)
}
pub fn shift_attitude(&self, npc_id: &str, player_name: &str, delta: i32) {
let current = self.npc_attitude_value(npc_id, player_name);
let new_val = (current + delta).clamp(-100, 100);
self.db.save_attitude(player_name, npc_id, new_val);
}
// Shift attitude for all NPCs in the same faction
pub fn shift_faction_attitude(&self, faction: &str, player_name: &str, delta: i32) {
for npc in self.world.npcs.values() {
if npc.faction.as_deref() == Some(faction) {
self.shift_attitude(&npc.id, player_name, delta);
}
}
}
pub fn create_new_player(
&mut self, id: usize, name: String, race_id: String, class_id: String,
channel: ChannelId, handle: Handle,
) {
let room_id = self.world.spawn_room.clone();
self.players.insert(
id,
PlayerConnection {
player: Player { name, room_id },
channel,
handle,
let race = self.world.races.iter().find(|r| r.id == race_id);
let class = self.world.classes.iter().find(|c| c.id == class_id);
let (base_hp, base_atk, base_def) = match class {
Some(c) => (c.base_stats.max_hp, c.base_stats.attack, c.base_stats.defense),
None => (100, 10, 10),
};
let (con_mod, str_mod, dex_mod) = match race {
Some(r) => (r.stats.constitution, r.stats.strength, r.stats.dexterity),
None => (0, 0, 0),
};
let max_hp = base_hp + con_mod * 5;
let attack = base_atk + str_mod + dex_mod / 2;
let defense = base_def + con_mod / 2;
let stats = PlayerStats {
max_hp, hp: max_hp, attack, defense, level: 1, xp: 0, xp_to_next: 100,
};
self.players.insert(id, PlayerConnection {
player: Player { name, race_id, class_id, room_id, stats, inventory: Vec::new(), equipped_weapon: None, equipped_armor: None },
channel, handle, combat: None,
});
}
pub fn load_existing_player(
&mut self, id: usize, saved: SavedPlayer, channel: ChannelId, handle: Handle,
) {
let inventory: Vec<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> {
self.save_player_to_db(id);
self.players.remove(&id)
}
pub fn players_in_room(&self, room_id: &str, exclude_id: usize) -> Vec<&PlayerConnection> {
self.players
.iter()
self.players.iter()
.filter(|(&id, conn)| conn.player.room_id == room_id && id != exclude_id)
.map(|(_, conn)| conn)
.collect()
.map(|(_, conn)| conn).collect()
}
pub fn all_player_names(&self) -> Vec<&str> {
self.players
.values()
.map(|c| c.player.name.as_str())
.collect()
pub fn check_respawns(&mut self) {
let now = Instant::now();
for (npc_id, instance) in self.npc_instances.iter_mut() {
if instance.alive { continue; }
let npc = match self.world.npcs.get(npc_id) { Some(n) => n, None => continue };
let respawn_secs = match npc.respawn_secs { Some(s) => s, None => continue };
if let Some(death_time) = instance.death_time {
if now.duration_since(death_time).as_secs() >= respawn_secs {
if let Some(ref combat) = npc.combat {
instance.hp = combat.max_hp;
instance.alive = true;
instance.death_time = None;
}
}
}
}
}
pub fn check_level_up(&mut self, player_id: usize) -> Option<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 chargen;
mod combat;
mod commands;
mod db;
mod game;
mod ssh;
mod world;
@@ -14,6 +17,7 @@ use tokio::net::TcpListener;
const DEFAULT_PORT: u16 = 2222;
const DEFAULT_WORLD_DIR: &str = "./world";
const DEFAULT_DB_PATH: &str = "./mudserver.db";
#[tokio::main]
async fn main() {
@@ -21,6 +25,7 @@ async fn main() {
let mut port = DEFAULT_PORT;
let mut world_dir = PathBuf::from(DEFAULT_WORLD_DIR);
let mut db_path = PathBuf::from(DEFAULT_DB_PATH);
let args: Vec<String> = std::env::args().collect();
let mut i = 1;
@@ -28,26 +33,25 @@ async fn main() {
match args[i].as_str() {
"--port" | "-p" => {
i += 1;
port = args
.get(i)
.and_then(|s| s.parse().ok())
.expect("--port requires a number");
port = args.get(i).and_then(|s| s.parse().ok()).expect("--port requires a number");
}
"--world" | "-w" => {
i += 1;
world_dir = PathBuf::from(
args.get(i).expect("--world requires a path"),
);
world_dir = PathBuf::from(args.get(i).expect("--world requires a path"));
}
"--db" | "-d" => {
i += 1;
db_path = PathBuf::from(args.get(i).expect("--db requires a path"));
}
"--help" => {
eprintln!("Usage: mudserver [--port PORT] [--world PATH]");
eprintln!("Usage: mudserver [OPTIONS]");
eprintln!(" --port, -p Listen port (default: {DEFAULT_PORT})");
eprintln!(" --world, -w World directory (default: {DEFAULT_WORLD_DIR})");
eprintln!(" --db, -d Database path (default: {DEFAULT_DB_PATH})");
std::process::exit(0);
}
other => {
eprintln!("Unknown argument: {other}");
eprintln!("Run with --help for usage.");
std::process::exit(1);
}
}
@@ -60,9 +64,14 @@ async fn main() {
std::process::exit(1);
});
let key =
russh::keys::PrivateKey::random(&mut OsRng, russh::keys::Algorithm::Ed25519).unwrap();
log::info!("Opening database: {}", db_path.display());
let database = db::SqliteDb::open(&db_path).unwrap_or_else(|e| {
eprintln!("Failed to open database: {e}");
std::process::exit(1);
});
let db: Arc<dyn db::GameDb> = Arc::new(database);
let key = russh::keys::PrivateKey::random(&mut OsRng, russh::keys::Algorithm::Ed25519).unwrap();
let config = russh::server::Config {
inactivity_timeout: Some(std::time::Duration::from_secs(3600)),
auth_rejection_time: std::time::Duration::from_secs(1),
@@ -72,7 +81,7 @@ async fn main() {
};
let config = Arc::new(config);
let state = Arc::new(Mutex::new(game::GameState::new(loaded_world)));
let state = Arc::new(Mutex::new(game::GameState::new(loaded_world, db)));
let mut server = ssh::MudServer::new(state);
let listener = TcpListener::bind(("0.0.0.0", port)).await.unwrap();

View File

@@ -4,6 +4,7 @@ use russh::server::{Auth, Handle, Msg, Server, Session};
use russh::{Channel, ChannelId, CryptoVec, Pty};
use crate::ansi;
use crate::chargen::ChargenState;
use crate::commands;
use crate::game::SharedState;
@@ -14,10 +15,7 @@ pub struct MudServer {
impl MudServer {
pub fn new(state: SharedState) -> Self {
MudServer {
state,
next_id: AtomicUsize::new(1),
}
MudServer { state, next_id: AtomicUsize::new(1) }
}
}
@@ -28,12 +26,8 @@ impl Server for MudServer {
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
log::info!("New connection (id={id}) from {addr:?}");
MudHandler {
id,
username: String::new(),
channel: None,
handle: None,
line_buffer: String::new(),
state: self.state.clone(),
id, username: String::new(), channel: None, handle: None,
line_buffer: String::new(), chargen: None, state: self.state.clone(),
}
}
@@ -48,129 +42,150 @@ pub struct MudHandler {
channel: Option<ChannelId>,
handle: Option<Handle>,
line_buffer: String,
// None = not yet determined, Some(None) = returning player, Some(Some(cg)) = in chargen
chargen: Option<Option<ChargenState>>,
state: SharedState,
}
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 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 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();
let msg = format!("{}\r\n", ansi::system_msg("Welcome back! Your character has been restored."));
drop(state);
self.send_text(session, channel, &msg);
self.chargen = Some(None); // signal: no chargen needed
self.enter_world(session, channel).await;
} else {
// New player — start chargen
let cg = ChargenState::new();
let state = self.state.lock().await;
let prompt = cg.prompt_text(&state.world);
drop(state);
self.send_text(session, channel, &prompt);
self.chargen = Some(Some(cg));
}
}
async fn enter_world(&mut self, session: &mut Session, channel: ChannelId) {
let state = self.state.lock().await;
let (room_id, player_name) = match state.players.get(&self.id) {
Some(c) => (c.player.room_id.clone(), c.player.name.clone()),
None => return,
};
// Broadcast arrival
let arrival = CryptoVec::from(
format!("\r\n{}\r\n{}", ansi::system_msg(&format!("{player_name} has entered the world.")), ansi::prompt()).as_bytes(),
);
let others: Vec<_> = state.players_in_room(&room_id, self.id).iter().map(|c| (c.channel, c.handle.clone())).collect();
// Render room
let room_view = render_entry_room(&state, &room_id, &player_name, self.id);
drop(state);
self.send_text(session, channel, &room_view);
for (ch, h) in others {
let _ = h.data(ch, arrival.clone()).await;
}
}
async fn send_welcome(&self, session: &mut Session, channel: ChannelId) {
let state = self.state.lock().await;
let world_name = state.world.name.clone();
async fn finish_chargen(&mut self, race_id: String, class_id: String, session: &mut Session, channel: ChannelId) {
let handle = session.handle();
let mut state = self.state.lock().await;
let race_name = state.world.races.iter().find(|r| r.id == race_id).map(|r| r.name.clone()).unwrap_or_default();
let class_name = state.world.classes.iter().find(|c| c.id == class_id).map(|c| c.name.clone()).unwrap_or_default();
state.create_new_player(self.id, self.username.clone(), race_id, class_id, channel, handle);
state.save_player_to_db(self.id);
drop(state);
let welcome = format!(
"{}\r\n{}Welcome to {}, {}! Type {} to get started.\r\n\r\n",
ansi::CLEAR_SCREEN,
ansi::welcome_banner(),
ansi::bold(&world_name),
ansi::player_name(&self.username),
ansi::color(ansi::YELLOW, "'help'")
);
let _ = session.data(channel, CryptoVec::from(welcome.as_bytes()));
}
let msg = format!("\r\n{}\r\n\r\n", ansi::system_msg(&format!("Character created: {} the {} {}", self.username, race_name, class_name)));
self.send_text(session, channel, &msg);
async fn show_room(&self, session: &mut Session, channel: ChannelId) {
let state = self.state.lock().await;
let room_id = match state.players.get(&self.id) {
Some(c) => c.player.room_id.clone(),
None => return,
};
let room = match state.world.get_room(&room_id) {
Some(r) => r,
None => return,
};
let mut out = String::new();
out.push_str(&format!(
"{} {}\r\n",
ansi::room_name(&room.name),
ansi::system_msg(&format!("[{}]", room.region))
));
out.push_str(&format!(" {}\r\n", room.description));
if !room.npcs.is_empty() {
let npc_names: Vec<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()));
self.chargen = Some(None);
self.enter_world(session, channel).await;
}
async fn handle_disconnect(&self) {
let mut state = self.state.lock().await;
if let Some(conn) = state.remove_player(self.id) {
let departure = CryptoVec::from(
format!(
"\r\n{}\r\n{}",
ansi::system_msg(&format!("{} has left the world.", conn.player.name)),
ansi::prompt()
)
.as_bytes(),
format!("\r\n{}\r\n{}", ansi::system_msg(&format!("{} has left the world.", conn.player.name)), ansi::prompt()).as_bytes(),
);
let others: Vec<_> = state
.players_in_room(&conn.player.room_id, self.id)
.iter()
.map(|c| (c.channel, c.handle.clone()))
.collect();
let others: Vec<_> = state.players_in_room(&conn.player.room_id, self.id).iter().map(|c| (c.channel, c.handle.clone())).collect();
drop(state);
for (ch, h) in others {
let _ = h.data(ch, departure.clone()).await;
}
for (ch, h) in others { let _ = h.data(ch, departure.clone()).await; }
log::info!("{} disconnected (id={})", conn.player.name, self.id);
}
}
}
fn render_entry_room(state: &crate::game::GameState, room_id: &str, player_name: &str, player_id: usize) -> String {
let room = match state.world.get_room(room_id) { Some(r) => r, None => return String::new() };
let mut out = String::new();
out.push_str(&format!("{} {}\r\n", ansi::room_name(&room.name), ansi::system_msg(&format!("[{}]", room.region))));
out.push_str(&format!(" {}\r\n", room.description));
let npc_strs: Vec<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 {
type Error = russh::Error;
@@ -180,13 +195,8 @@ impl russh::server::Handler for MudHandler {
Ok(Auth::Accept)
}
async fn auth_publickey(
&mut self,
user: &str,
_key: &russh::keys::ssh_key::PublicKey,
) -> Result<Auth, Self::Error> {
async fn auth_publickey(&mut self, user: &str, _key: &russh::keys::ssh_key::PublicKey) -> Result<Auth, Self::Error> {
self.username = user.to_string();
log::info!("Pubkey auth accepted for '{}' (id={})", user, self.id);
Ok(Auth::Accept)
}
@@ -195,51 +205,24 @@ impl russh::server::Handler for MudHandler {
Ok(Auth::Accept)
}
async fn channel_open_session(
&mut self,
channel: Channel<Msg>,
session: &mut Session,
) -> Result<bool, Self::Error> {
async fn channel_open_session(&mut self, channel: Channel<Msg>, session: &mut Session) -> Result<bool, Self::Error> {
self.channel = Some(channel.id());
self.handle = Some(session.handle());
Ok(true)
}
async fn pty_request(
&mut self,
channel: ChannelId,
_term: &str,
_col_width: u32,
_row_height: u32,
_pix_width: u32,
_pix_height: u32,
_modes: &[(Pty, u32)],
session: &mut Session,
) -> Result<(), Self::Error> {
async fn pty_request(&mut self, channel: ChannelId, _term: &str, _col_width: u32, _row_height: u32, _pix_width: u32, _pix_height: u32, _modes: &[(Pty, u32)], session: &mut Session) -> Result<(), Self::Error> {
session.channel_success(channel)?;
Ok(())
}
async fn shell_request(
&mut self,
channel: ChannelId,
session: &mut Session,
) -> Result<(), Self::Error> {
async fn shell_request(&mut self, channel: ChannelId, session: &mut Session) -> Result<(), Self::Error> {
session.channel_success(channel)?;
self.send_welcome(session, channel).await;
self.register_player(session, channel).await;
self.show_room(session, channel).await;
self.start_session(session, channel).await;
Ok(())
}
async fn data(
&mut self,
channel: ChannelId,
data: &[u8],
session: &mut Session,
) -> Result<(), Self::Error> {
async fn data(&mut self, channel: ChannelId, data: &[u8], session: &mut Session) -> Result<(), Self::Error> {
for &byte in data {
match byte {
3 | 4 => {
@@ -254,15 +237,47 @@ impl russh::server::Handler for MudHandler {
}
}
b'\r' | b'\n' => {
if byte == b'\n' && self.line_buffer.is_empty() {
if byte == b'\n' && self.line_buffer.is_empty() { continue; }
session.data(channel, CryptoVec::from(&b"\r\n"[..]))?;
let line = std::mem::take(&mut self.line_buffer);
// Handle chargen flow
let mut chargen_done = None;
let mut chargen_active = false;
if let Some(ref mut chargen_opt) = self.chargen {
if let Some(ref mut cg) = chargen_opt {
chargen_active = true;
let result = {
let state = self.state.lock().await;
cg.handle_input(&line, &state.world)
};
let msg_text = match result { Ok(msg) | Err(msg) => msg };
let _ = session.data(channel, CryptoVec::from(msg_text.as_bytes()));
if cg.is_done() {
chargen_done = cg.result();
}
}
}
if let Some((race_id, class_id)) = chargen_done {
self.chargen = None;
self.finish_chargen(race_id, class_id, session, channel).await;
continue;
}
if chargen_active {
// Still in chargen, show next prompt
if let Some(Some(ref cg)) = self.chargen {
let state = self.state.lock().await;
let prompt = cg.prompt_text(&state.world);
drop(state);
let _ = session.data(channel, CryptoVec::from(prompt.as_bytes()));
}
continue;
}
if self.chargen.is_none() {
continue;
}
session.data(channel, CryptoVec::from(&b"\r\n"[..]))?;
let line = std::mem::take(&mut self.line_buffer);
let keep_going =
commands::execute(&line, self.id, &self.state, session, channel).await?;
let keep_going = commands::execute(&line, self.id, &self.state, session, channel).await?;
if !keep_going {
self.handle_disconnect().await;
session.close(channel)?;
@@ -280,20 +295,12 @@ impl russh::server::Handler for MudHandler {
Ok(())
}
async fn channel_eof(
&mut self,
_channel: ChannelId,
_session: &mut Session,
) -> Result<(), Self::Error> {
async fn channel_eof(&mut self, _channel: ChannelId, _session: &mut Session) -> Result<(), Self::Error> {
self.handle_disconnect().await;
Ok(())
}
async fn channel_close(
&mut self,
_channel: ChannelId,
_session: &mut Session,
) -> Result<(), Self::Error> {
async fn channel_close(&mut self, _channel: ChannelId, _session: &mut Session) -> Result<(), Self::Error> {
self.handle_disconnect().await;
Ok(())
}

View File

@@ -1,7 +1,63 @@
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
// --- Attitude system ---
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Attitude {
Friendly, // 50 to 100
Neutral, // 10 to 49
Wary, // -24 to 9
Aggressive, // -74 to -25 (will attack if provoked)
Hostile, // -100 to -75 (attacks on sight)
}
impl Attitude {
pub fn default_value(self) -> i32 {
match self {
Attitude::Friendly => 75,
Attitude::Neutral => 30,
Attitude::Wary => 0,
Attitude::Aggressive => -50,
Attitude::Hostile => -90,
}
}
pub fn from_value(v: i32) -> Self {
match v {
50..=i32::MAX => Attitude::Friendly,
10..=49 => Attitude::Neutral,
-24..=9 => Attitude::Wary,
-74..=-25 => Attitude::Aggressive,
_ => Attitude::Hostile,
}
}
pub fn label(self) -> &'static str {
match self {
Attitude::Friendly => "friendly",
Attitude::Neutral => "neutral",
Attitude::Wary => "wary",
Attitude::Aggressive => "aggressive",
Attitude::Hostile => "hostile",
}
}
pub fn will_attack(self) -> bool {
matches!(self, Attitude::Hostile)
}
pub fn can_be_attacked(self) -> bool {
matches!(self, Attitude::Hostile | Attitude::Aggressive)
}
pub fn will_talk(self) -> bool {
matches!(self, Attitude::Friendly | Attitude::Neutral | Attitude::Wary)
}
}
// --- On-disk TOML schemas ---
#[derive(Deserialize)]
@@ -13,8 +69,6 @@ pub struct Manifest {
#[derive(Deserialize)]
pub struct RegionFile {
pub name: String,
#[serde(default)]
pub description: String,
}
#[derive(Deserialize)]
@@ -25,13 +79,50 @@ pub struct RoomFile {
pub exits: HashMap<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)]
pub struct NpcFile {
pub name: String,
pub description: String,
pub room: String,
#[serde(default = "default_attitude")]
pub base_attitude: Attitude,
#[serde(default)]
pub dialogue: Option<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)]
@@ -42,6 +133,62 @@ pub struct ObjectFile {
pub room: Option<String>,
#[serde(default)]
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 ---
@@ -56,20 +203,60 @@ pub struct Room {
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 id: String,
pub name: String,
pub description: 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 id: String,
pub name: String,
pub description: String,
pub room: 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 {
@@ -78,204 +265,102 @@ pub struct World {
pub rooms: HashMap<String, Room>,
pub npcs: HashMap<String, Npc>,
pub objects: HashMap<String, Object>,
pub races: Vec<Race>,
pub classes: Vec<Class>,
}
impl World {
pub fn load(world_dir: &Path) -> Result<Self, String> {
let manifest_path = world_dir.join("manifest.toml");
let manifest: Manifest = load_toml(&manifest_path)?;
let manifest: Manifest = load_toml(&world_dir.join("manifest.toml"))?;
let mut rooms = HashMap::new();
let mut npcs = HashMap::new();
let mut objects = HashMap::new();
let entries = std::fs::read_dir(world_dir)
.map_err(|e| format!("Cannot read world dir {}: {e}", world_dir.display()))?;
let mut races = Vec::new();
load_entities_from_dir(&world_dir.join("races"), "race", &mut |id, content| {
let rf: RaceFile = toml::from_str(content).map_err(|e| format!("Bad race {id}: {e}"))?;
races.push(Race { id, name: rf.name, description: rf.description, stats: rf.stats });
Ok(())
})?;
let mut classes = Vec::new();
load_entities_from_dir(&world_dir.join("classes"), "class", &mut |id, content| {
let cf: ClassFile = toml::from_str(content).map_err(|e| format!("Bad class {id}: {e}"))?;
classes.push(Class { id, name: cf.name, description: cf.description, base_stats: cf.base_stats, growth: cf.growth });
Ok(())
})?;
let entries = std::fs::read_dir(world_dir)
.map_err(|e| format!("Cannot read world dir: {e}"))?;
let mut region_dirs: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
.filter(|e| { let n = e.file_name().to_string_lossy().to_string(); n != "races" && n != "classes" })
.collect();
region_dirs.sort_by_key(|e| e.file_name());
for entry in region_dirs {
let region_name = entry.file_name().to_string_lossy().to_string();
let region_path = entry.path();
let region_toml = region_path.join("region.toml");
if !region_toml.exists() {
log::debug!("Skipping directory without region.toml: {}", region_path.display());
continue;
}
let _region_meta: RegionFile = load_toml(&region_toml)?;
if !region_path.join("region.toml").exists() { continue; }
let _rm: RegionFile = load_toml(&region_path.join("region.toml"))?;
log::info!("Loading region: {region_name}");
load_entities_from_dir(
&region_path.join("rooms"),
&region_name,
&mut |id, content| {
let rf: RoomFile = toml::from_str(content)
.map_err(|e| format!("Bad room {id}: {e}"))?;
rooms.insert(
id.clone(),
Room {
id: id.clone(),
region: region_name.clone(),
name: rf.name,
description: rf.description,
exits: rf.exits,
npcs: Vec::new(),
objects: Vec::new(),
},
);
load_entities_from_dir(&region_path.join("rooms"), &region_name, &mut |id, content| {
let rf: RoomFile = toml::from_str(content).map_err(|e| format!("Bad room {id}: {e}"))?;
rooms.insert(id.clone(), Room { id: id.clone(), region: region_name.clone(), name: rf.name, description: rf.description, exits: rf.exits, npcs: Vec::new(), objects: Vec::new() });
Ok(())
},
)?;
})?;
load_entities_from_dir(
&region_path.join("npcs"),
&region_name,
&mut |id, content| {
let nf: NpcFile = toml::from_str(content)
.map_err(|e| format!("Bad npc {id}: {e}"))?;
npcs.insert(
id.clone(),
Npc {
id: id.clone(),
name: nf.name,
description: nf.description,
room: nf.room.clone(),
dialogue: nf.dialogue,
},
);
load_entities_from_dir(&region_path.join("npcs"), &region_name, &mut |id, content| {
let nf: NpcFile = toml::from_str(content).map_err(|e| format!("Bad npc {id}: {e}"))?;
let combat = nf.combat.map(|c| NpcCombatStats { max_hp: c.max_hp, attack: c.attack, defense: c.defense, xp_reward: c.xp_reward });
let greeting = nf.dialogue.and_then(|d| d.greeting);
npcs.insert(id.clone(), Npc { id: id.clone(), name: nf.name, description: nf.description, room: nf.room, base_attitude: nf.base_attitude, faction: nf.faction, respawn_secs: nf.respawn_secs, greeting, combat });
Ok(())
},
)?;
})?;
load_entities_from_dir(
&region_path.join("objects"),
&region_name,
&mut |id, content| {
let of: ObjectFile = toml::from_str(content)
.map_err(|e| format!("Bad object {id}: {e}"))?;
objects.insert(
id.clone(),
Object {
id: id.clone(),
name: of.name,
description: of.description,
room: of.room,
kind: of.kind,
},
);
load_entities_from_dir(&region_path.join("objects"), &region_name, &mut |id, content| {
let of: ObjectFile = toml::from_str(content).map_err(|e| format!("Bad object {id}: {e}"))?;
let stats = of.stats.unwrap_or_default();
objects.insert(id.clone(), Object { id: id.clone(), name: of.name, description: of.description, room: of.room, kind: of.kind, takeable: of.takeable, stats: ObjectStats { damage: stats.damage, armor: stats.armor, heal_amount: stats.heal_amount } });
Ok(())
},
)?;
})?;
}
// Place NPCs and objects into their rooms
for npc in npcs.values() {
if let Some(room) = rooms.get_mut(&npc.room) {
room.npcs.push(npc.id.clone());
}
}
for obj in objects.values() {
if let Some(ref room_id) = obj.room {
if let Some(room) = rooms.get_mut(room_id) {
room.objects.push(obj.id.clone());
}
}
for npc in npcs.values() { if let Some(room) = rooms.get_mut(&npc.room) { room.npcs.push(npc.id.clone()); } }
for obj in objects.values() { if let Some(ref rid) = obj.room { if let Some(room) = rooms.get_mut(rid) { room.objects.push(obj.id.clone()); } } }
if !rooms.contains_key(&manifest.spawn_room) { return Err(format!("Spawn room '{}' not found", manifest.spawn_room)); }
for room in rooms.values() { for (dir, target) in &room.exits { if !rooms.contains_key(target) { return Err(format!("Room '{}' exit '{dir}' -> unknown '{target}'", room.id)); } } }
if races.is_empty() { return Err("No races defined".into()); }
if classes.is_empty() { return Err("No classes defined".into()); }
log::info!("World '{}': {} rooms, {} npcs, {} objects, {} races, {} classes", manifest.name, rooms.len(), npcs.len(), objects.len(), races.len(), classes.len());
Ok(World { name: manifest.name, spawn_room: manifest.spawn_room, rooms, npcs, objects, races, classes })
}
// Validate
if !rooms.contains_key(&manifest.spawn_room) {
return Err(format!(
"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)
}
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> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("Cannot read {}: {e}", path.display()))?;
let content = std::fs::read_to_string(path).map_err(|e| format!("Cannot read {}: {e}", path.display()))?;
toml::from_str(&content).map_err(|e| format!("Bad TOML in {}: {e}", path.display()))
}
fn load_entities_from_dir(
dir: &Path,
region: &str,
handler: &mut dyn FnMut(String, &str) -> Result<(), String>,
) -> Result<(), String> {
if !dir.exists() {
return Ok(());
}
let mut entries: Vec<_> = std::fs::read_dir(dir)
.map_err(|e| format!("Cannot read {}: {e}", dir.display()))?
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map(|ext| ext == "toml")
.unwrap_or(false)
})
.collect();
fn load_entities_from_dir(dir: &Path, prefix: &str, handler: &mut dyn FnMut(String, &str) -> Result<(), String>) -> Result<(), String> {
if !dir.exists() { return Ok(()); }
let mut entries: Vec<_> = std::fs::read_dir(dir).map_err(|e| format!("Cannot read {}: {e}", dir.display()))?
.filter_map(|e| e.ok()).filter(|e| e.path().extension().map(|ext| ext == "toml").unwrap_or(false)).collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let stem = entry
.path()
.file_stem()
.unwrap()
.to_string_lossy()
.to_string();
let id = format!("{region}:{stem}");
let content = std::fs::read_to_string(entry.path())
.map_err(|e| format!("Cannot read {}: {e}", entry.path().display()))?;
let stem = entry.path().file_stem().unwrap().to_string_lossy().to_string();
let id = format!("{prefix}:{stem}");
let content = std::fs::read_to_string(entry.path()).map_err(|e| format!("Cannot read {}: {e}", entry.path().display()))?;
handler(id, &content)?;
}
Ok(())
}

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"
description = "A weathered man with thick forearms and a permanent scowl. He polishes the same mug endlessly."
room = "town:tavern"
dialogue = "Welcome to The Rusty Tankard. We've got ale, and we've got stronger ale. Pick one."
base_attitude = "friendly"
[dialogue]
greeting = "Welcome to The Rusty Tankard. We've got ale, and we've got stronger ale."

View File

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

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."
room = "town:temple"
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."
room = "town:cellar"
kind = "weapon"
takeable = true
[stats]
damage = 5