diff --git a/AGENTS.md b/AGENTS.md index 35e5ce3..81dd514 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,9 @@ This is a Rust MUD server that accepts SSH connections. The architecture separat - Status effects persist in the database and continue ticking while players are offline. - The `GameDb` trait abstracts the database backend (currently SQLite). - World data is loaded from TOML at startup. Content changes don't require recompilation. +- **Races are deeply data-driven**: body shape, equipment slots, natural weapons/armor, resistances, traits, regen rates — all defined in TOML. A dragon has different slots than a human. +- **Equipment is slot-based**: each race defines available body slots. Items declare their slot. The engine validates compatibility at equip time. +- **Items magically resize** — no size restrictions. A dragon can wield a human sword. ## Architecture @@ -76,6 +79,23 @@ src/ 2. Currently: hostile NPCs auto-engage players in their room 3. Add new behaviors there (e.g. NPC movement, dialogue triggers) +### New race +1. Create `world/races/.toml` — see `dragon.toml` for a complex example +2. Required: `name`, `description` +3. All other fields have sensible defaults via `#[serde(default)]` +4. Key sections: `[stats]` (7 stats), `[body]` (size, weight, slots), `[natural]` (armor, attacks), `[resistances]`, `[regen]`, `[misc]`, `[guild_compatibility]` +5. `traits` and `disadvantages` are free-form string arrays +6. If `[body] slots` is empty, defaults to humanoid slots +7. Natural attacks: use `[natural.attacks.]` with `damage`, `type`, optional `cooldown_ticks` +8. Resistances: damage_type → multiplier (0.0 = immune, 1.0 = normal, 1.5 = vulnerable) +9. `xp_rate` modifies XP gain (< 1.0 = slower leveling, for powerful races) + +### New equipment slot +1. Add the slot name to a race's `[body] slots` array +2. Create objects with `slot = ""` in their TOML +3. The `kind` field (`weapon`/`armor`) still works as fallback for `main_hand`/`torso` +4. Items can also have an explicit `slot` field to target any slot + ### New world content 1. Add TOML files under `world//` — no code changes needed 2. NPCs without a `[combat]` section get default stats (20hp/4atk/2def/5xp) diff --git a/TESTING.md b/TESTING.md index 66b5cab..1b69934 100644 --- a/TESTING.md +++ b/TESTING.md @@ -15,7 +15,10 @@ Run through these checks before every commit to ensure consistent feature covera ## Character Creation - [ ] New player SSH → gets chargen flow (race + class selection) - [ ] Chargen accepts both number and name input +- [ ] All races display with expanded info (size, traits, natural attacks, vision) +- [ ] Dragon race shows custom body slots, natural armor, fire breath, vision types - [ ] After chargen, player appears in spawn room with correct stats +- [ ] Stats reflect race modifiers (STR, DEX, CON, INT, WIS, PER, CHA) - [ ] Player saved to DB after creation ## Player Persistence @@ -68,13 +71,18 @@ Run through these checks before every commit to ensure consistent feature covera - [ ] Damage varies between hits (not identical each time) - [ ] Multiple rapid attacks produce different damage values -## Items +## Items & Equipment Slots - [ ] `take ` picks up takeable objects - [ ] `drop ` places item in room -- [ ] `equip ` works, old gear returns to inventory +- [ ] `equip ` equips to `main_hand` slot (backwards-compat via kind) +- [ ] `equip ` equips to appropriate slot (obj `slot` field or fallback) +- [ ] Equipping to an occupied slot returns old item to inventory +- [ ] `equip` fails if race doesn't have the required slot +- [ ] Objects with explicit `slot` field use that slot - [ ] `use ` heals and removes item (immediate out of combat) - [ ] `use ` in combat queues for next tick -- [ ] `inventory` shows equipped + bag items +- [ ] `inventory` shows equipped items by slot name + bag items +- [ ] `stats` shows equipment bonuses and natural bonuses separately ## Status Effects - [ ] Poison deals damage each tick, shows message to player @@ -97,6 +105,19 @@ Run through these checks before every commit to ensure consistent feature covera - [ ] Server remains responsive to immediate commands between ticks - [ ] Multiple players in separate combats are processed independently per tick +## Race System +- [ ] Existing races (Human, Elf, Dwarf, Orc, Halfling) load with expanded fields +- [ ] Dragon race loads with custom body, natural attacks, resistances, traits +- [ ] Dragon gets custom equipment slots (forelegs, hindlegs, wings, tail) +- [ ] Dragon's natural armor (8) shows in stats and affects defense +- [ ] Dragon's natural attacks (fire breath 15dmg) affect effective attack +- [ ] Items magically resize — no size restrictions on gear (dragon can use swords) +- [ ] Races without explicit [body.slots] get default humanoid slots +- [ ] Stat modifiers include PER (perception) and CHA (charisma) +- [ ] Race traits and disadvantages display during chargen +- [ ] XP rate modifier stored per race (dragon = 0.7x) +- [ ] Regen modifiers stored per race (dragon HP regen = 1.5x) + ## Attitude System - [ ] Per-player NPC attitudes stored in DB - [ ] `examine` shows attitude label per-player diff --git a/src/admin.rs b/src/admin.rs index 1059f04..d58d06f 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -470,17 +470,15 @@ async fn admin_info(target: &str, state: &SharedState) -> CommandResult { s.hp, s.max_hp, s.attack, s.defense, s.level, s.xp, s.xp_to_next )); out.push_str(&format!(" Room: {}\r\n", p.room_id)); + let equipped_str = if p.equipped.is_empty() { + "none".to_string() + } else { + p.equipped.iter().map(|(s, o)| format!("{}={}", s, o.name)).collect::>().join(", ") + }; out.push_str(&format!( - " Inventory: {} item(s) | Weapon: {} | Armor: {}\r\n", + " Inventory: {} item(s) | Equipped: {}\r\n", p.inventory.len(), - p.equipped_weapon - .as_ref() - .map(|w| w.name.as_str()) - .unwrap_or("none"), - p.equipped_armor - .as_ref() - .map(|a| a.name.as_str()) - .unwrap_or("none"), + equipped_str, )); let attitudes = st.db.load_attitudes(&p.name); if !attitudes.is_empty() { diff --git a/src/bin/mudtool.rs b/src/bin/mudtool.rs index 1aed7b3..f7d96f0 100644 --- a/src/bin/mudtool.rs +++ b/src/bin/mudtool.rs @@ -114,8 +114,7 @@ fn cmd_players(db: &SqliteDb, args: &[String]) { println!(" Room: {}", p.room_id); println!(" Admin: {}", p.is_admin); println!(" Inventory: {}", p.inventory_json); - if let Some(ref w) = p.equipped_weapon_json { println!(" Weapon: {w}"); } - if let Some(ref a) = p.equipped_armor_json { println!(" Armor: {a}"); } + println!(" Equipped: {}", p.equipped_json); let attitudes = db.load_attitudes(name); if !attitudes.is_empty() { println!(" Attitudes:"); diff --git a/src/chargen.rs b/src/chargen.rs index 362d5af..acef897 100644 --- a/src/chargen.rs +++ b/src/chargen.rs @@ -44,6 +44,31 @@ impl ChargenState { }, ansi::color(ansi::DIM, &race.description), )); + let mut extras = Vec::new(); + if race.size != "medium" { + extras.push(format!("Size: {}", race.size)); + } + if !race.traits.is_empty() { + extras.push(format!("Traits: {}", race.traits.join(", "))); + } + if race.natural_armor > 0 { + extras.push(format!("Natural armor: {}", race.natural_armor)); + } + if !race.natural_attacks.is_empty() { + let atks: Vec = race.natural_attacks.iter() + .map(|a| format!("{} ({}dmg {})", a.name, a.damage, a.damage_type)) + .collect(); + extras.push(format!("Natural attacks: {}", atks.join(", "))); + } + if !race.vision.is_empty() { + extras.push(format!("Vision: {}", race.vision.join(", "))); + } + if !extras.is_empty() { + out.push_str(&format!( + " {}\r\n", + ansi::color(ansi::DIM, &extras.join(" | ")) + )); + } } out.push_str(&format!( "\r\n{}", @@ -179,6 +204,8 @@ fn format_stat_mods(stats: &crate::world::StatModifiers) -> String { ("CON", stats.constitution), ("INT", stats.intelligence), ("WIS", stats.wisdom), + ("PER", stats.perception), + ("CHA", stats.charisma), ]; for (label, val) in fields { if val != 0 { diff --git a/src/combat.rs b/src/combat.rs index f49a337..368c76a 100644 --- a/src/combat.rs +++ b/src/combat.rs @@ -45,8 +45,8 @@ pub fn resolve_combat_tick( 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(); + let p_atk = conn.player.effective_attack(&state.world); + let p_def = conn.player.effective_defense(&state.world); let _ = conn; let mut out = String::new(); diff --git a/src/commands.rs b/src/commands.rs index a94c99e..8a38470 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -545,19 +545,25 @@ async fn cmd_inventory(pid: usize, state: &SharedState) -> CommandResult { 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.equipped.is_empty() { + out.push_str(&format!(" {}\r\n", ansi::color(ansi::DIM, "Equipped:"))); + let mut slots: Vec<(&String, &crate::world::Object)> = conn.player.equipped.iter().collect(); + slots.sort_by_key(|(s, _)| (*s).clone()); + for (slot, obj) in &slots { + let bonus = if let Some(dmg) = obj.stats.damage { + format!(" (+{} dmg)", dmg) + } else if let Some(arm) = obj.stats.armor { + format!(" (+{} def)", arm) + } else { + String::new() + }; + out.push_str(&format!( + " {}: {} {}\r\n", + ansi::color(ansi::YELLOW, slot), + ansi::color(ansi::CYAN, &obj.name), + ansi::system_msg(&bonus), + )); + } } if conn.player.inventory.is_empty() { out.push_str(&format!(" {}\r\n", ansi::system_msg("(empty)"))); @@ -588,6 +594,17 @@ async fn cmd_equip(pid: usize, target: &str, state: &SharedState) -> CommandResu return simple("Equip what?\r\n"); } let mut st = state.lock().await; + + // Extract race slots before mutable borrow + let race_id = match st.players.get(&pid) { + Some(c) => c.player.race_id.clone(), + None => return simple("Error\r\n"), + }; + let race_slots: Vec = st.world.races.iter() + .find(|r| r.id == race_id) + .map(|r| r.slots.clone()) + .unwrap_or_else(|| crate::world::DEFAULT_HUMANOID_SLOTS.iter().map(|s| s.to_string()).collect()); + let conn = match st.players.get_mut(&pid) { Some(c) => c, None => return simple("Error\r\n"), @@ -609,47 +626,46 @@ async fn cmd_equip(pid: usize, target: &str, state: &SharedState) -> CommandResu }; 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(), - kick_targets: Vec::new(), - quit: false, + + let slot = if let Some(ref s) = obj.slot { + s.clone() + } else { + match obj.kind.as_deref() { + Some("weapon") => "main_hand".into(), + Some("armor") => "torso".into(), + _ => { + conn.player.inventory.push(obj); + return simple(&format!( + "{}\r\n", + ansi::error_msg(&format!("You can't equip the {}.", name)) + )); } } - "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(), - kick_targets: Vec::new(), - quit: false, - } - } - _ => { - conn.player.inventory.push(obj); - simple(&format!( - "{}\r\n", - ansi::error_msg(&format!("You can't equip the {}.", name)) - )) - } + }; + + if !race_slots.contains(&slot) { + conn.player.inventory.push(obj); + return simple(&format!( + "{}\r\n", + ansi::error_msg(&format!("Your body doesn't have a {} slot.", slot)) + )); + } + + if let Some(old) = conn.player.equipped.remove(&slot) { + conn.player.inventory.push(old); + } + conn.player.equipped.insert(slot.clone(), obj); + let _ = conn; + st.save_player_to_db(pid); + CommandResult { + output: format!( + "You equip the {} in your {} slot.\r\n", + ansi::color(ansi::CYAN, &name), + ansi::color(ansi::YELLOW, &slot), + ), + broadcasts: Vec::new(), + kick_targets: Vec::new(), + quit: false, } } @@ -1094,20 +1110,22 @@ async fn cmd_stats(pid: usize, state: &SharedState) -> CommandResult { s.max_hp, ansi::RESET )); + let race_natural_atk = st.world.races.iter() + .find(|r| r.id == p.race_id) + .map(|r| r.natural_attacks.iter().map(|a| a.damage).max().unwrap_or(0)) + .unwrap_or(0); + let race_natural_def = st.world.races.iter() + .find(|r| r.id == p.race_id) + .map(|r| r.natural_armor) + .unwrap_or(0); + let equip_dmg = p.total_equipped_damage(); + let equip_arm = p.total_equipped_armor(); out.push_str(&format!( - " {} {} (+{} equip) {} {} (+{} equip)\r\n", + " {} {} (+{} equip, +{} natural) {} {} (+{} equip, +{} natural)\r\n", ansi::color(ansi::DIM, "ATK:"), - s.attack, - p.equipped_weapon - .as_ref() - .and_then(|w| w.stats.damage) - .unwrap_or(0), + s.attack, equip_dmg.max(race_natural_atk), race_natural_atk, ansi::color(ansi::DIM, "DEF:"), - s.defense, - p.equipped_armor - .as_ref() - .and_then(|a| a.stats.armor) - .unwrap_or(0) + s.defense, equip_arm, race_natural_def, )); out.push_str(&format!( " {} {}\r\n", diff --git a/src/db.rs b/src/db.rs index 7e42bcc..a0bf4f6 100644 --- a/src/db.rs +++ b/src/db.rs @@ -14,8 +14,7 @@ pub struct SavedPlayer { pub attack: i32, pub defense: i32, pub inventory_json: String, - pub equipped_weapon_json: Option, - pub equipped_armor_json: Option, + pub equipped_json: String, pub is_admin: bool, } @@ -80,8 +79,7 @@ impl SqliteDb { attack INTEGER NOT NULL, defense INTEGER NOT NULL, inventory_json TEXT NOT NULL DEFAULT '[]', - equipped_weapon_json TEXT, - equipped_armor_json TEXT, + equipped_json TEXT NOT NULL DEFAULT '{}', is_admin INTEGER NOT NULL DEFAULT 0 ); @@ -120,6 +118,33 @@ impl SqliteDb { ); } + // Migration: equipped_weapon_json/equipped_armor_json -> equipped_json + let has_old_weapon: bool = conn + .prepare("SELECT COUNT(*) FROM pragma_table_info('players') WHERE name='equipped_weapon_json'") + .and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0))) + .map(|c| c > 0) + .unwrap_or(false); + let has_equipped: bool = conn + .prepare("SELECT COUNT(*) FROM pragma_table_info('players') WHERE name='equipped_json'") + .and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0))) + .map(|c| c > 0) + .unwrap_or(false); + if has_old_weapon && !has_equipped { + let _ = conn.execute( + "ALTER TABLE players ADD COLUMN equipped_json TEXT NOT NULL DEFAULT '{}'", + [], + ); + log::info!("Migrating equipped_weapon_json/equipped_armor_json to equipped_json..."); + let _ = conn.execute_batch( + "UPDATE players SET equipped_json = '{}' WHERE equipped_weapon_json IS NULL AND equipped_armor_json IS NULL;" + ); + } else if !has_equipped { + let _ = conn.execute( + "ALTER TABLE players ADD COLUMN equipped_json TEXT NOT NULL DEFAULT '{}'", + [], + ); + } + log::info!("Database opened: {}", path.display()); Ok(SqliteDb { conn: std::sync::Mutex::new(conn), @@ -132,8 +157,7 @@ impl GameDb for SqliteDb { 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, is_admin + attack, defense, inventory_json, equipped_json, is_admin FROM players WHERE name = ?1", [name], |row| { @@ -149,9 +173,8 @@ impl GameDb for SqliteDb { 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)?, - is_admin: row.get::<_, i32>(13)? != 0, + equipped_json: row.get::<_, String>(11).unwrap_or_else(|_| "{}".into()), + is_admin: row.get::<_, i32>(12)? != 0, }) }, ) @@ -162,15 +185,13 @@ impl GameDb for SqliteDb { 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, is_admin) - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14) + attack, defense, inventory_json, equipped_json, is_admin) + 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, + equipped_json=excluded.equipped_json, is_admin=excluded.is_admin", rusqlite::params![ p.name, @@ -184,8 +205,7 @@ impl GameDb for SqliteDb { p.attack, p.defense, p.inventory_json, - p.equipped_weapon_json, - p.equipped_armor_json, + p.equipped_json, p.is_admin as i32, ], ); @@ -214,8 +234,7 @@ impl GameDb for SqliteDb { let mut stmt = conn .prepare( "SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp, - attack, defense, inventory_json, equipped_weapon_json, - equipped_armor_json, is_admin + attack, defense, inventory_json, equipped_json, is_admin FROM players ORDER BY name", ) .unwrap(); @@ -232,9 +251,8 @@ impl GameDb for SqliteDb { 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)?, - is_admin: row.get::<_, i32>(13)? != 0, + equipped_json: row.get::<_, String>(11).unwrap_or_else(|_| "{}".into()), + is_admin: row.get::<_, i32>(12)? != 0, }) }) .unwrap() diff --git a/src/game.rs b/src/game.rs index b9456cf..e6cbadb 100644 --- a/src/game.rs +++ b/src/game.rs @@ -27,28 +27,43 @@ pub struct Player { pub room_id: String, pub stats: PlayerStats, pub inventory: Vec, - pub equipped_weapon: Option, - pub equipped_armor: Option, + pub equipped: HashMap, pub is_admin: bool, } 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 equipped_in_slot(&self, slot: &str) -> Option<&Object> { + self.equipped.get(slot) } - pub fn effective_defense(&self) -> i32 { - let bonus = self - .equipped_armor - .as_ref() - .and_then(|a| a.stats.armor) + pub fn total_equipped_damage(&self) -> i32 { + self.equipped.values() + .filter_map(|o| o.stats.damage) + .sum() + } + + pub fn total_equipped_armor(&self) -> i32 { + self.equipped.values() + .filter_map(|o| o.stats.armor) + .sum() + } + + pub fn effective_attack(&self, world: &World) -> i32 { + let race_natural = world.races.iter() + .find(|r| r.id == self.race_id) + .map(|r| r.natural_attacks.iter().map(|a| a.damage).max().unwrap_or(0)) .unwrap_or(0); - self.stats.defense + bonus + let weapon_bonus = self.total_equipped_damage(); + let unarmed_or_weapon = weapon_bonus.max(race_natural); + self.stats.attack + unarmed_or_weapon + } + + pub fn effective_defense(&self, world: &World) -> i32 { + let race_natural = world.races.iter() + .find(|r| r.id == self.race_id) + .map(|r| r.natural_armor) + .unwrap_or(0); + self.stats.defense + self.total_equipped_armor() + race_natural } } @@ -239,8 +254,7 @@ impl GameState { room_id, stats, inventory: Vec::new(), - equipped_weapon: None, - equipped_armor: None, + equipped: HashMap::new(), is_admin: false, }, channel, @@ -259,14 +273,8 @@ impl GameState { ) { let inventory: Vec = serde_json::from_str(&saved.inventory_json).unwrap_or_default(); - let equipped_weapon: Option = saved - .equipped_weapon_json - .as_deref() - .and_then(|j| serde_json::from_str(j).ok()); - let equipped_armor: Option = saved - .equipped_armor_json - .as_deref() - .and_then(|j| serde_json::from_str(j).ok()); + let equipped: HashMap = + serde_json::from_str(&saved.equipped_json).unwrap_or_default(); let room_id = if self.world.rooms.contains_key(&saved.room_id) { saved.room_id @@ -294,8 +302,7 @@ impl GameState { room_id, stats, inventory, - equipped_weapon, - equipped_armor, + equipped, is_admin: saved.is_admin, }, channel, @@ -310,14 +317,8 @@ impl GameState { 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())); + let equipped_json = + serde_json::to_string(&p.equipped).unwrap_or_else(|_| "{}".into()); self.db.save_player(&SavedPlayer { name: p.name.clone(), @@ -331,8 +332,7 @@ impl GameState { attack: p.stats.attack, defense: p.stats.defense, inventory_json: inv_json, - equipped_weapon_json: weapon_json, - equipped_armor_json: armor_json, + equipped_json, is_admin: p.is_admin, }); } diff --git a/src/world.rs b/src/world.rs index 0633a4b..2986895 100644 --- a/src/world.rs +++ b/src/world.rs @@ -134,11 +134,15 @@ pub struct ObjectFile { #[serde(default)] pub kind: Option, #[serde(default)] + pub slot: Option, + #[serde(default)] pub takeable: bool, #[serde(default)] pub stats: Option, } +// --- Race TOML schema --- + #[derive(Deserialize, Default, Clone)] pub struct StatModifiers { #[serde(default)] @@ -151,6 +155,86 @@ pub struct StatModifiers { pub intelligence: i32, #[serde(default)] pub wisdom: i32, + #[serde(default)] + pub perception: i32, + #[serde(default)] + pub charisma: i32, +} + +#[derive(Deserialize, Default, Clone)] +pub struct BodyFile { + #[serde(default = "default_size")] + pub size: String, + #[serde(default)] + pub weight: i32, + #[serde(default)] + pub slots: Vec, +} + +fn default_size() -> String { + "medium".into() +} + +#[derive(Deserialize, Default, Clone)] +pub struct NaturalAttack { + #[serde(default)] + pub damage: i32, + #[serde(default = "default_damage_type")] + pub r#type: String, + #[serde(default)] + pub cooldown_ticks: Option, +} + +fn default_damage_type() -> String { + "physical".into() +} + +#[derive(Deserialize, Default, Clone)] +pub struct NaturalFile { + #[serde(default)] + pub armor: i32, + #[serde(default)] + pub attacks: HashMap, +} + +#[derive(Deserialize, Default, Clone)] +pub struct RegenFile { + #[serde(default = "default_one")] + pub hp: f32, + #[serde(default = "default_one")] + pub mana: f32, + #[serde(default = "default_one")] + pub endurance: f32, +} + +fn default_one() -> f32 { + 1.0 +} + +#[derive(Deserialize, Default, Clone)] +pub struct GuildCompatibilityFile { + #[serde(default)] + pub good: Vec, + #[serde(default)] + pub average: Vec, + #[serde(default)] + pub poor: Vec, + #[serde(default)] + pub restricted: Vec, +} + +#[derive(Deserialize, Default, Clone)] +pub struct RaceMiscFile { + #[serde(default)] + pub lifespan: Option, + #[serde(default)] + pub diet: Option, + #[serde(default)] + pub xp_rate: Option, + #[serde(default)] + pub natural_terrain: Vec, + #[serde(default)] + pub vision: Vec, } #[derive(Deserialize)] @@ -158,9 +242,29 @@ pub struct RaceFile { pub name: String, pub description: String, #[serde(default)] + pub metarace: Option, + #[serde(default)] pub stats: StatModifiers, + #[serde(default)] + pub body: BodyFile, + #[serde(default)] + pub natural: NaturalFile, + #[serde(default)] + pub resistances: HashMap, + #[serde(default)] + pub traits: Vec, + #[serde(default)] + pub disadvantages: Vec, + #[serde(default)] + pub regen: RegenFile, + #[serde(default)] + pub guild_compatibility: GuildCompatibilityFile, + #[serde(default)] + pub misc: RaceMiscFile, } +// --- Class TOML schema --- + #[derive(Deserialize, Default, Clone)] pub struct ClassBaseStats { #[serde(default)] @@ -238,16 +342,47 @@ pub struct Object { pub description: String, pub room: Option, pub kind: Option, + pub slot: Option, pub takeable: bool, pub stats: ObjectStats, } +pub const DEFAULT_HUMANOID_SLOTS: &[&str] = &[ + "head", "neck", "torso", "legs", "feet", "main_hand", "off_hand", "finger", "finger", +]; + +#[derive(Clone)] +pub struct NaturalAttackDef { + pub name: String, + pub damage: i32, + pub damage_type: String, + pub cooldown_ticks: Option, +} + #[derive(Clone)] pub struct Race { pub id: String, pub name: String, pub description: String, + pub metarace: Option, pub stats: StatModifiers, + pub size: String, + pub weight: i32, + pub slots: Vec, + pub natural_armor: i32, + pub natural_attacks: Vec, + pub resistances: HashMap, + pub traits: Vec, + pub disadvantages: Vec, + pub regen_hp: f32, + pub regen_mana: f32, + pub regen_endurance: f32, + pub guild_compatibility: GuildCompatibilityFile, + pub lifespan: Option, + pub diet: Option, + pub xp_rate: f32, + pub natural_terrain: Vec, + pub vision: Vec, } #[derive(Clone)] @@ -280,7 +415,36 @@ impl World { 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 }); + let slots = if rf.body.slots.is_empty() { + DEFAULT_HUMANOID_SLOTS.iter().map(|s| s.to_string()).collect() + } else { + rf.body.slots + }; + let natural_attacks = rf.natural.attacks.into_iter().map(|(name, a)| { + NaturalAttackDef { name, damage: a.damage, damage_type: a.r#type, cooldown_ticks: a.cooldown_ticks } + }).collect(); + races.push(Race { + id, name: rf.name, description: rf.description, + metarace: rf.metarace, + stats: rf.stats, + size: rf.body.size, + weight: rf.body.weight, + slots, + natural_armor: rf.natural.armor, + natural_attacks, + resistances: rf.resistances, + traits: rf.traits, + disadvantages: rf.disadvantages, + regen_hp: rf.regen.hp, + regen_mana: rf.regen.mana, + regen_endurance: rf.regen.endurance, + guild_compatibility: rf.guild_compatibility, + lifespan: rf.misc.lifespan, + diet: rf.misc.diet, + xp_rate: rf.misc.xp_rate.unwrap_or(1.0), + natural_terrain: rf.misc.natural_terrain, + vision: rf.misc.vision, + }); Ok(()) })?; @@ -325,7 +489,11 @@ impl World { load_entities_from_dir(®ion_path.join("objects"), ®ion_name, &mut |id, content| { let of: ObjectFile = toml::from_str(content).map_err(|e| format!("Bad object {id}: {e}"))?; let stats = of.stats.unwrap_or_default(); - objects.insert(id.clone(), Object { id: id.clone(), name: of.name, description: of.description, room: of.room, kind: of.kind, takeable: of.takeable, stats: ObjectStats { damage: stats.damage, armor: stats.armor, heal_amount: stats.heal_amount } }); + objects.insert(id.clone(), Object { + id: id.clone(), name: of.name, description: of.description, room: of.room, + kind: of.kind, slot: of.slot, takeable: of.takeable, + stats: ObjectStats { damage: stats.damage, armor: stats.armor, heal_amount: stats.heal_amount }, + }); Ok(()) })?; } diff --git a/world/races/dragon.toml b/world/races/dragon.toml new file mode 100644 index 0000000..1e128ec --- /dev/null +++ b/world/races/dragon.toml @@ -0,0 +1,65 @@ +name = "Dragon" +description = "Ancient and mighty, dragons are creatures of immense power and terrifying intellect. Their scaled bodies house furnace-hot hearts and minds sharp enough to match any scholar." +metarace = "draconic" + +[stats] +strength = 4 +dexterity = -2 +constitution = 3 +intelligence = 1 +wisdom = 1 +perception = 2 +charisma = -1 + +[body] +size = "huge" +weight = 2000 +slots = ["head", "neck", "torso", "forelegs", "hindlegs", "tail", "wings", "main_hand", "off_hand"] + +[natural] +armor = 8 + +[natural.attacks] +[natural.attacks.bite] +damage = 12 +type = "physical" + +[natural.attacks.claw] +damage = 8 +type = "physical" + +[natural.attacks.tail_sweep] +damage = 6 +type = "physical" + +[natural.attacks.fire_breath] +damage = 15 +type = "fire" +cooldown_ticks = 5 + +traits = ["flight", "fire_breath", "darkvision", "frightful_presence", "ancient_knowledge", "treasure_sense"] +disadvantages = ["conspicuous", "slow_leveling", "large_target", "cannot_enter_small_spaces"] + +[resistances] +fire = 0.0 +cold = 1.5 +physical = 0.7 +poison = 0.3 + +[regen] +hp = 1.5 +mana = 1.2 +endurance = 0.8 + +[guild_compatibility] +good = ["sorcerer", "elementalist"] +average = ["warrior", "berserker"] +poor = ["thief", "bard"] +restricted = ["monk"] + +[misc] +lifespan = 5000 +diet = "carnivore" +xp_rate = 0.7 +natural_terrain = ["mountains", "caves", "volcanic"] +vision = ["normal", "darkvision", "infravision", "thermal"] diff --git a/world/races/dwarf.toml b/world/races/dwarf.toml index 3698bcf..70489ac 100644 --- a/world/races/dwarf.toml +++ b/world/races/dwarf.toml @@ -7,3 +7,26 @@ dexterity = -1 constitution = 2 intelligence = 0 wisdom = 0 +perception = 0 +charisma = -1 + +[body] +size = "small" +weight = 150 + +traits = ["darkvision", "poison_resistance", "stonecunning"] + +[resistances] +poison = 0.5 +earth = 0.7 + +[regen] +hp = 1.2 +mana = 0.8 +endurance = 1.1 + +[misc] +lifespan = 350 +diet = "omnivore" +xp_rate = 1.0 +vision = ["normal", "darkvision"] diff --git a/world/races/elf.toml b/world/races/elf.toml index 072afe9..8ffeb0e 100644 --- a/world/races/elf.toml +++ b/world/races/elf.toml @@ -7,3 +7,26 @@ dexterity = 2 constitution = -1 intelligence = 2 wisdom = 0 +perception = 1 +charisma = 0 + +[body] +size = "medium" +weight = 130 + +traits = ["infravision", "magic_affinity"] +disadvantages = ["iron_sensitivity"] + +[resistances] +charm = 0.5 + +[regen] +hp = 0.8 +mana = 1.3 +endurance = 0.9 + +[misc] +lifespan = 800 +diet = "omnivore" +xp_rate = 0.95 +vision = ["normal", "infravision"] diff --git a/world/races/halfling.toml b/world/races/halfling.toml index a239803..d9c11a2 100644 --- a/world/races/halfling.toml +++ b/world/races/halfling.toml @@ -7,3 +7,25 @@ dexterity = 3 constitution = 0 intelligence = 0 wisdom = 1 +perception = 1 +charisma = 1 + +[body] +size = "tiny" +weight = 60 + +traits = ["lucky", "stealthy", "brave"] + +[resistances] +fear = 0.3 + +[regen] +hp = 1.0 +mana = 1.0 +endurance = 1.1 + +[misc] +lifespan = 150 +diet = "omnivore" +xp_rate = 0.95 +vision = ["normal"] diff --git a/world/races/human.toml b/world/races/human.toml index 00f2cde..16b74f0 100644 --- a/world/races/human.toml +++ b/world/races/human.toml @@ -7,3 +7,20 @@ dexterity = 0 constitution = 0 intelligence = 0 wisdom = 0 +perception = 0 +charisma = 1 + +[body] +size = "medium" +weight = 170 + +[regen] +hp = 1.0 +mana = 1.0 +endurance = 1.0 + +[misc] +lifespan = 80 +diet = "omnivore" +xp_rate = 1.0 +vision = ["normal"] diff --git a/world/races/orc.toml b/world/races/orc.toml index 604e1d4..ab9bbbd 100644 --- a/world/races/orc.toml +++ b/world/races/orc.toml @@ -7,3 +7,29 @@ dexterity = 0 constitution = 1 intelligence = -2 wisdom = -1 +perception = 0 +charisma = -1 + +[body] +size = "large" +weight = 250 + +[natural] +armor = 1 + +traits = ["berserker_rage", "intimidating"] +disadvantages = ["light_sensitivity"] + +[resistances] +physical = 0.9 + +[regen] +hp = 1.3 +mana = 0.6 +endurance = 1.2 + +[misc] +lifespan = 60 +diet = "carnivore" +xp_rate = 1.05 +vision = ["normal", "low_light"] diff --git a/world/town/objects/iron_shield.toml b/world/town/objects/iron_shield.toml index 1182e31..09eb208 100644 --- a/world/town/objects/iron_shield.toml +++ b/world/town/objects/iron_shield.toml @@ -2,6 +2,7 @@ name = "Iron Shield" description = "A dented but serviceable round shield bearing the blacksmith's mark." room = "town:forge" kind = "armor" +slot = "off_hand" takeable = true [stats] diff --git a/world/town/objects/rusty_sword.toml b/world/town/objects/rusty_sword.toml index cdb8584..fa20dc2 100644 --- a/world/town/objects/rusty_sword.toml +++ b/world/town/objects/rusty_sword.toml @@ -2,6 +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" +slot = "main_hand" takeable = true [stats]