Flexible race system with slot-based equipment and dragon race

- Expand race TOML schema: 7 stats, body shape (size/weight/custom slots),
  natural armor and attacks with damage types, resistances, traits/disadvantages,
  regen multipliers, vision types, XP rate, guild compatibility
- Replace equipped_weapon/equipped_armor with slot-based HashMap<String, Object>
- Each race defines available equipment slots; default humanoid slots as fallback
- Combat uses natural weapons/armor from race when no gear equipped
- DB migration from old weapon/armor columns to equipped_json
- Add Dragon race: huge body, custom slots (forelegs/wings/tail), fire breath,
  natural armor 8, fire immune, slow XP rate for balance
- Update all existing races with expanded fields (traits, resistances, vision, regen)
- Objects gain optional slot field; kind=weapon/armor still works as fallback
- Update chargen to display race traits, size, natural attacks, vision
- Update stats display to show equipment and natural bonuses separately
- Update TESTING.md and AGENTS.md with race/slot system documentation

Made-with: Cursor
This commit is contained in:
AI Agent
2026-03-14 15:37:20 -06:00
parent 3f164e4697
commit 005c4faf08
18 changed files with 586 additions and 139 deletions

View File

@@ -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. - Status effects persist in the database and continue ticking while players are offline.
- The `GameDb` trait abstracts the database backend (currently SQLite). - The `GameDb` trait abstracts the database backend (currently SQLite).
- World data is loaded from TOML at startup. Content changes don't require recompilation. - 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 ## Architecture
@@ -76,6 +79,23 @@ src/
2. Currently: hostile NPCs auto-engage players in their room 2. Currently: hostile NPCs auto-engage players in their room
3. Add new behaviors there (e.g. NPC movement, dialogue triggers) 3. Add new behaviors there (e.g. NPC movement, dialogue triggers)
### New race
1. Create `world/races/<name>.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.<name>]` 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 = "<slot_name>"` 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 ### New world content
1. Add TOML files under `world/<region>/` — no code changes needed 1. Add TOML files under `world/<region>/` — no code changes needed
2. NPCs without a `[combat]` section get default stats (20hp/4atk/2def/5xp) 2. NPCs without a `[combat]` section get default stats (20hp/4atk/2def/5xp)

View File

@@ -15,7 +15,10 @@ Run through these checks before every commit to ensure consistent feature covera
## Character Creation ## Character Creation
- [ ] New player SSH → gets chargen flow (race + class selection) - [ ] New player SSH → gets chargen flow (race + class selection)
- [ ] Chargen accepts both number and name input - [ ] 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 - [ ] 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 saved to DB after creation
## Player Persistence ## 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) - [ ] Damage varies between hits (not identical each time)
- [ ] Multiple rapid attacks produce different damage values - [ ] Multiple rapid attacks produce different damage values
## Items ## Items & Equipment Slots
- [ ] `take <item>` picks up takeable objects - [ ] `take <item>` picks up takeable objects
- [ ] `drop <item>` places item in room - [ ] `drop <item>` places item in room
- [ ] `equip <weapon/armor>` works, old gear returns to inventory - [ ] `equip <weapon>` equips to `main_hand` slot (backwards-compat via kind)
- [ ] `equip <armor>` 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 <consumable>` heals and removes item (immediate out of combat) - [ ] `use <consumable>` heals and removes item (immediate out of combat)
- [ ] `use <consumable>` in combat queues for next tick - [ ] `use <consumable>` 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 ## Status Effects
- [ ] Poison deals damage each tick, shows message to player - [ ] 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 - [ ] Server remains responsive to immediate commands between ticks
- [ ] Multiple players in separate combats are processed independently per tick - [ ] 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 ## Attitude System
- [ ] Per-player NPC attitudes stored in DB - [ ] Per-player NPC attitudes stored in DB
- [ ] `examine` shows attitude label per-player - [ ] `examine` shows attitude label per-player

View File

@@ -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 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)); 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::<Vec<_>>().join(", ")
};
out.push_str(&format!( out.push_str(&format!(
" Inventory: {} item(s) | Weapon: {} | Armor: {}\r\n", " Inventory: {} item(s) | Equipped: {}\r\n",
p.inventory.len(), p.inventory.len(),
p.equipped_weapon equipped_str,
.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"),
)); ));
let attitudes = st.db.load_attitudes(&p.name); let attitudes = st.db.load_attitudes(&p.name);
if !attitudes.is_empty() { if !attitudes.is_empty() {

View File

@@ -114,8 +114,7 @@ fn cmd_players(db: &SqliteDb, args: &[String]) {
println!(" Room: {}", p.room_id); println!(" Room: {}", p.room_id);
println!(" Admin: {}", p.is_admin); println!(" Admin: {}", p.is_admin);
println!(" Inventory: {}", p.inventory_json); println!(" Inventory: {}", p.inventory_json);
if let Some(ref w) = p.equipped_weapon_json { println!(" Weapon: {w}"); } println!(" Equipped: {}", p.equipped_json);
if let Some(ref a) = p.equipped_armor_json { println!(" Armor: {a}"); }
let attitudes = db.load_attitudes(name); let attitudes = db.load_attitudes(name);
if !attitudes.is_empty() { if !attitudes.is_empty() {
println!(" Attitudes:"); println!(" Attitudes:");

View File

@@ -44,6 +44,31 @@ impl ChargenState {
}, },
ansi::color(ansi::DIM, &race.description), 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<String> = 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!( out.push_str(&format!(
"\r\n{}", "\r\n{}",
@@ -179,6 +204,8 @@ fn format_stat_mods(stats: &crate::world::StatModifiers) -> String {
("CON", stats.constitution), ("CON", stats.constitution),
("INT", stats.intelligence), ("INT", stats.intelligence),
("WIS", stats.wisdom), ("WIS", stats.wisdom),
("PER", stats.perception),
("CHA", stats.charisma),
]; ];
for (label, val) in fields { for (label, val) in fields {
if val != 0 { if val != 0 {

View File

@@ -45,8 +45,8 @@ pub fn resolve_combat_tick(
let npc_hp_before = instance.hp; let npc_hp_before = instance.hp;
let conn = state.players.get(&player_id)?; let conn = state.players.get(&player_id)?;
let p_atk = conn.player.effective_attack(); let p_atk = conn.player.effective_attack(&state.world);
let p_def = conn.player.effective_defense(); let p_def = conn.player.effective_defense(&state.world);
let _ = conn; let _ = conn;
let mut out = String::new(); let mut out = String::new();

View File

@@ -545,19 +545,25 @@ async fn cmd_inventory(pid: usize, state: &SharedState) -> CommandResult {
None => return simple("Error\r\n"), None => return simple("Error\r\n"),
}; };
let mut out = format!("\r\n{}\r\n", ansi::bold("=== Inventory ===")); let mut out = format!("\r\n{}\r\n", ansi::bold("=== Inventory ==="));
if let Some(ref w) = conn.player.equipped_weapon { 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!( out.push_str(&format!(
" Weapon: {} {}\r\n", " {}: {} {}\r\n",
ansi::color(ansi::CYAN, &w.name), ansi::color(ansi::YELLOW, slot),
ansi::system_msg(&format!("(+{} dmg)", w.stats.damage.unwrap_or(0))) ansi::color(ansi::CYAN, &obj.name),
ansi::system_msg(&bonus),
)); ));
} }
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() { if conn.player.inventory.is_empty() {
out.push_str(&format!(" {}\r\n", ansi::system_msg("(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"); return simple("Equip what?\r\n");
} }
let mut st = state.lock().await; 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<String> = 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) { let conn = match st.players.get_mut(&pid) {
Some(c) => c, Some(c) => c,
None => return simple("Error\r\n"), None => return simple("Error\r\n"),
@@ -609,48 +626,47 @@ async fn cmd_equip(pid: usize, target: &str, state: &SharedState) -> CommandResu
}; };
let obj = conn.player.inventory.remove(idx); let obj = conn.player.inventory.remove(idx);
let name = obj.name.clone(); let name = obj.name.clone();
let kind = obj.kind.as_deref().unwrap_or("").to_string();
match kind.as_str() { let slot = if let Some(ref s) = obj.slot {
"weapon" => { s.clone()
if let Some(old) = conn.player.equipped_weapon.take() { } else {
conn.player.inventory.push(old); match obj.kind.as_deref() {
} Some("weapon") => "main_hand".into(),
conn.player.equipped_weapon = Some(obj); Some("armor") => "torso".into(),
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,
}
}
"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); conn.player.inventory.push(obj);
simple(&format!( return simple(&format!(
"{}\r\n", "{}\r\n",
ansi::error_msg(&format!("You can't equip the {}.", name)) 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,
}
} }
async fn cmd_use(pid: usize, target: &str, state: &SharedState) -> CommandResult { async fn cmd_use(pid: usize, target: &str, state: &SharedState) -> CommandResult {
@@ -1094,20 +1110,22 @@ async fn cmd_stats(pid: usize, state: &SharedState) -> CommandResult {
s.max_hp, s.max_hp,
ansi::RESET 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!( out.push_str(&format!(
" {} {} (+{} equip) {} {} (+{} equip)\r\n", " {} {} (+{} equip, +{} natural) {} {} (+{} equip, +{} natural)\r\n",
ansi::color(ansi::DIM, "ATK:"), ansi::color(ansi::DIM, "ATK:"),
s.attack, s.attack, equip_dmg.max(race_natural_atk), race_natural_atk,
p.equipped_weapon
.as_ref()
.and_then(|w| w.stats.damage)
.unwrap_or(0),
ansi::color(ansi::DIM, "DEF:"), ansi::color(ansi::DIM, "DEF:"),
s.defense, s.defense, equip_arm, race_natural_def,
p.equipped_armor
.as_ref()
.and_then(|a| a.stats.armor)
.unwrap_or(0)
)); ));
out.push_str(&format!( out.push_str(&format!(
" {} {}\r\n", " {} {}\r\n",

View File

@@ -14,8 +14,7 @@ pub struct SavedPlayer {
pub attack: i32, pub attack: i32,
pub defense: i32, pub defense: i32,
pub inventory_json: String, pub inventory_json: String,
pub equipped_weapon_json: Option<String>, pub equipped_json: String,
pub equipped_armor_json: Option<String>,
pub is_admin: bool, pub is_admin: bool,
} }
@@ -80,8 +79,7 @@ impl SqliteDb {
attack INTEGER NOT NULL, attack INTEGER NOT NULL,
defense INTEGER NOT NULL, defense INTEGER NOT NULL,
inventory_json TEXT NOT NULL DEFAULT '[]', inventory_json TEXT NOT NULL DEFAULT '[]',
equipped_weapon_json TEXT, equipped_json TEXT NOT NULL DEFAULT '{}',
equipped_armor_json TEXT,
is_admin INTEGER NOT NULL DEFAULT 0 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()); log::info!("Database opened: {}", path.display());
Ok(SqliteDb { Ok(SqliteDb {
conn: std::sync::Mutex::new(conn), conn: std::sync::Mutex::new(conn),
@@ -132,8 +157,7 @@ impl GameDb for SqliteDb {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
conn.query_row( conn.query_row(
"SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp, "SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp,
attack, defense, inventory_json, equipped_weapon_json, attack, defense, inventory_json, equipped_json, is_admin
equipped_armor_json, is_admin
FROM players WHERE name = ?1", FROM players WHERE name = ?1",
[name], [name],
|row| { |row| {
@@ -149,9 +173,8 @@ impl GameDb for SqliteDb {
attack: row.get(8)?, attack: row.get(8)?,
defense: row.get(9)?, defense: row.get(9)?,
inventory_json: row.get(10)?, inventory_json: row.get(10)?,
equipped_weapon_json: row.get(11)?, equipped_json: row.get::<_, String>(11).unwrap_or_else(|_| "{}".into()),
equipped_armor_json: row.get(12)?, is_admin: row.get::<_, i32>(12)? != 0,
is_admin: row.get::<_, i32>(13)? != 0,
}) })
}, },
) )
@@ -162,15 +185,13 @@ impl GameDb for SqliteDb {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let _ = conn.execute( let _ = conn.execute(
"INSERT INTO players (name, race_id, class_id, room_id, level, xp, hp, max_hp, "INSERT INTO players (name, race_id, class_id, room_id, level, xp, hp, max_hp,
attack, defense, inventory_json, equipped_weapon_json, attack, defense, inventory_json, equipped_json, is_admin)
equipped_armor_json, is_admin) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)
ON CONFLICT(name) DO UPDATE SET ON CONFLICT(name) DO UPDATE SET
room_id=excluded.room_id, level=excluded.level, xp=excluded.xp, room_id=excluded.room_id, level=excluded.level, xp=excluded.xp,
hp=excluded.hp, max_hp=excluded.max_hp, attack=excluded.attack, hp=excluded.hp, max_hp=excluded.max_hp, attack=excluded.attack,
defense=excluded.defense, inventory_json=excluded.inventory_json, defense=excluded.defense, inventory_json=excluded.inventory_json,
equipped_weapon_json=excluded.equipped_weapon_json, equipped_json=excluded.equipped_json,
equipped_armor_json=excluded.equipped_armor_json,
is_admin=excluded.is_admin", is_admin=excluded.is_admin",
rusqlite::params![ rusqlite::params![
p.name, p.name,
@@ -184,8 +205,7 @@ impl GameDb for SqliteDb {
p.attack, p.attack,
p.defense, p.defense,
p.inventory_json, p.inventory_json,
p.equipped_weapon_json, p.equipped_json,
p.equipped_armor_json,
p.is_admin as i32, p.is_admin as i32,
], ],
); );
@@ -214,8 +234,7 @@ impl GameDb for SqliteDb {
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
"SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp, "SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp,
attack, defense, inventory_json, equipped_weapon_json, attack, defense, inventory_json, equipped_json, is_admin
equipped_armor_json, is_admin
FROM players ORDER BY name", FROM players ORDER BY name",
) )
.unwrap(); .unwrap();
@@ -232,9 +251,8 @@ impl GameDb for SqliteDb {
attack: row.get(8)?, attack: row.get(8)?,
defense: row.get(9)?, defense: row.get(9)?,
inventory_json: row.get(10)?, inventory_json: row.get(10)?,
equipped_weapon_json: row.get(11)?, equipped_json: row.get::<_, String>(11).unwrap_or_else(|_| "{}".into()),
equipped_armor_json: row.get(12)?, is_admin: row.get::<_, i32>(12)? != 0,
is_admin: row.get::<_, i32>(13)? != 0,
}) })
}) })
.unwrap() .unwrap()

View File

@@ -27,28 +27,43 @@ pub struct Player {
pub room_id: String, pub room_id: String,
pub stats: PlayerStats, pub stats: PlayerStats,
pub inventory: Vec<Object>, pub inventory: Vec<Object>,
pub equipped_weapon: Option<Object>, pub equipped: HashMap<String, Object>,
pub equipped_armor: Option<Object>,
pub is_admin: bool, pub is_admin: bool,
} }
impl Player { impl Player {
pub fn effective_attack(&self) -> i32 { pub fn equipped_in_slot(&self, slot: &str) -> Option<&Object> {
let bonus = self self.equipped.get(slot)
.equipped_weapon
.as_ref()
.and_then(|w| w.stats.damage)
.unwrap_or(0);
self.stats.attack + bonus
} }
pub fn effective_defense(&self) -> i32 { pub fn total_equipped_damage(&self) -> i32 {
let bonus = self self.equipped.values()
.equipped_armor .filter_map(|o| o.stats.damage)
.as_ref() .sum()
.and_then(|a| a.stats.armor) }
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); .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, room_id,
stats, stats,
inventory: Vec::new(), inventory: Vec::new(),
equipped_weapon: None, equipped: HashMap::new(),
equipped_armor: None,
is_admin: false, is_admin: false,
}, },
channel, channel,
@@ -259,14 +273,8 @@ impl GameState {
) { ) {
let inventory: Vec<Object> = let inventory: Vec<Object> =
serde_json::from_str(&saved.inventory_json).unwrap_or_default(); serde_json::from_str(&saved.inventory_json).unwrap_or_default();
let equipped_weapon: Option<Object> = saved let equipped: HashMap<String, Object> =
.equipped_weapon_json serde_json::from_str(&saved.equipped_json).unwrap_or_default();
.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());
let room_id = if self.world.rooms.contains_key(&saved.room_id) { let room_id = if self.world.rooms.contains_key(&saved.room_id) {
saved.room_id saved.room_id
@@ -294,8 +302,7 @@ impl GameState {
room_id, room_id,
stats, stats,
inventory, inventory,
equipped_weapon, equipped,
equipped_armor,
is_admin: saved.is_admin, is_admin: saved.is_admin,
}, },
channel, channel,
@@ -310,14 +317,8 @@ impl GameState {
let p = &conn.player; let p = &conn.player;
let inv_json = let inv_json =
serde_json::to_string(&p.inventory).unwrap_or_else(|_| "[]".into()); serde_json::to_string(&p.inventory).unwrap_or_else(|_| "[]".into());
let weapon_json = p let equipped_json =
.equipped_weapon serde_json::to_string(&p.equipped).unwrap_or_else(|_| "{}".into());
.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 { self.db.save_player(&SavedPlayer {
name: p.name.clone(), name: p.name.clone(),
@@ -331,8 +332,7 @@ impl GameState {
attack: p.stats.attack, attack: p.stats.attack,
defense: p.stats.defense, defense: p.stats.defense,
inventory_json: inv_json, inventory_json: inv_json,
equipped_weapon_json: weapon_json, equipped_json,
equipped_armor_json: armor_json,
is_admin: p.is_admin, is_admin: p.is_admin,
}); });
} }

View File

@@ -134,11 +134,15 @@ pub struct ObjectFile {
#[serde(default)] #[serde(default)]
pub kind: Option<String>, pub kind: Option<String>,
#[serde(default)] #[serde(default)]
pub slot: Option<String>,
#[serde(default)]
pub takeable: bool, pub takeable: bool,
#[serde(default)] #[serde(default)]
pub stats: Option<ObjectStatsFile>, pub stats: Option<ObjectStatsFile>,
} }
// --- Race TOML schema ---
#[derive(Deserialize, Default, Clone)] #[derive(Deserialize, Default, Clone)]
pub struct StatModifiers { pub struct StatModifiers {
#[serde(default)] #[serde(default)]
@@ -151,6 +155,86 @@ pub struct StatModifiers {
pub intelligence: i32, pub intelligence: i32,
#[serde(default)] #[serde(default)]
pub wisdom: i32, 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<String>,
}
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<i32>,
}
fn default_damage_type() -> String {
"physical".into()
}
#[derive(Deserialize, Default, Clone)]
pub struct NaturalFile {
#[serde(default)]
pub armor: i32,
#[serde(default)]
pub attacks: HashMap<String, NaturalAttack>,
}
#[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<String>,
#[serde(default)]
pub average: Vec<String>,
#[serde(default)]
pub poor: Vec<String>,
#[serde(default)]
pub restricted: Vec<String>,
}
#[derive(Deserialize, Default, Clone)]
pub struct RaceMiscFile {
#[serde(default)]
pub lifespan: Option<i32>,
#[serde(default)]
pub diet: Option<String>,
#[serde(default)]
pub xp_rate: Option<f32>,
#[serde(default)]
pub natural_terrain: Vec<String>,
#[serde(default)]
pub vision: Vec<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -158,9 +242,29 @@ pub struct RaceFile {
pub name: String, pub name: String,
pub description: String, pub description: String,
#[serde(default)] #[serde(default)]
pub metarace: Option<String>,
#[serde(default)]
pub stats: StatModifiers, pub stats: StatModifiers,
#[serde(default)]
pub body: BodyFile,
#[serde(default)]
pub natural: NaturalFile,
#[serde(default)]
pub resistances: HashMap<String, f32>,
#[serde(default)]
pub traits: Vec<String>,
#[serde(default)]
pub disadvantages: Vec<String>,
#[serde(default)]
pub regen: RegenFile,
#[serde(default)]
pub guild_compatibility: GuildCompatibilityFile,
#[serde(default)]
pub misc: RaceMiscFile,
} }
// --- Class TOML schema ---
#[derive(Deserialize, Default, Clone)] #[derive(Deserialize, Default, Clone)]
pub struct ClassBaseStats { pub struct ClassBaseStats {
#[serde(default)] #[serde(default)]
@@ -238,16 +342,47 @@ pub struct Object {
pub description: String, pub description: String,
pub room: Option<String>, pub room: Option<String>,
pub kind: Option<String>, pub kind: Option<String>,
pub slot: Option<String>,
pub takeable: bool, pub takeable: bool,
pub stats: ObjectStats, 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<i32>,
}
#[derive(Clone)] #[derive(Clone)]
pub struct Race { pub struct Race {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub description: String, pub description: String,
pub metarace: Option<String>,
pub stats: StatModifiers, pub stats: StatModifiers,
pub size: String,
pub weight: i32,
pub slots: Vec<String>,
pub natural_armor: i32,
pub natural_attacks: Vec<NaturalAttackDef>,
pub resistances: HashMap<String, f32>,
pub traits: Vec<String>,
pub disadvantages: Vec<String>,
pub regen_hp: f32,
pub regen_mana: f32,
pub regen_endurance: f32,
pub guild_compatibility: GuildCompatibilityFile,
pub lifespan: Option<i32>,
pub diet: Option<String>,
pub xp_rate: f32,
pub natural_terrain: Vec<String>,
pub vision: Vec<String>,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -280,7 +415,36 @@ impl World {
let mut races = Vec::new(); let mut races = Vec::new();
load_entities_from_dir(&world_dir.join("races"), "race", &mut |id, content| { 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}"))?; 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(()) Ok(())
})?; })?;
@@ -325,7 +489,11 @@ impl World {
load_entities_from_dir(&region_path.join("objects"), &region_name, &mut |id, content| { 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 of: ObjectFile = toml::from_str(content).map_err(|e| format!("Bad object {id}: {e}"))?;
let stats = of.stats.unwrap_or_default(); 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(()) Ok(())
})?; })?;
} }

65
world/races/dragon.toml Normal file
View File

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

View File

@@ -7,3 +7,26 @@ dexterity = -1
constitution = 2 constitution = 2
intelligence = 0 intelligence = 0
wisdom = 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"]

View File

@@ -7,3 +7,26 @@ dexterity = 2
constitution = -1 constitution = -1
intelligence = 2 intelligence = 2
wisdom = 0 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"]

View File

@@ -7,3 +7,25 @@ dexterity = 3
constitution = 0 constitution = 0
intelligence = 0 intelligence = 0
wisdom = 1 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"]

View File

@@ -7,3 +7,20 @@ dexterity = 0
constitution = 0 constitution = 0
intelligence = 0 intelligence = 0
wisdom = 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"]

View File

@@ -7,3 +7,29 @@ dexterity = 0
constitution = 1 constitution = 1
intelligence = -2 intelligence = -2
wisdom = -1 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"]

View File

@@ -2,6 +2,7 @@ name = "Iron Shield"
description = "A dented but serviceable round shield bearing the blacksmith's mark." description = "A dented but serviceable round shield bearing the blacksmith's mark."
room = "town:forge" room = "town:forge"
kind = "armor" kind = "armor"
slot = "off_hand"
takeable = true takeable = true
[stats] [stats]

View File

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