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

@@ -134,11 +134,15 @@ pub struct ObjectFile {
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub slot: Option<String>,
#[serde(default)]
pub takeable: bool,
#[serde(default)]
pub stats: Option<ObjectStatsFile>,
}
// --- 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<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)]
@@ -158,9 +242,29 @@ pub struct RaceFile {
pub name: String,
pub description: String,
#[serde(default)]
pub metarace: Option<String>,
#[serde(default)]
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)]
pub struct ClassBaseStats {
#[serde(default)]
@@ -238,16 +342,47 @@ pub struct Object {
pub description: String,
pub room: Option<String>,
pub kind: Option<String>,
pub slot: Option<String>,
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<i32>,
}
#[derive(Clone)]
pub struct Race {
pub id: String,
pub name: String,
pub description: String,
pub metarace: Option<String>,
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)]
@@ -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(&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 } });
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(())
})?;
}