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

@@ -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<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) {
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",