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

@@ -27,28 +27,43 @@ pub struct Player {
pub room_id: String,
pub stats: PlayerStats,
pub inventory: Vec<Object>,
pub equipped_weapon: Option<Object>,
pub equipped_armor: Option<Object>,
pub equipped: HashMap<String, Object>,
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<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());
let equipped: HashMap<String, Object> =
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,
});
}