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

@@ -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::<Vec<_>>().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() {

View File

@@ -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:");

View File

@@ -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<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!(
"\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 {

View File

@@ -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();

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",

View File

@@ -14,8 +14,7 @@ pub struct SavedPlayer {
pub attack: i32,
pub defense: i32,
pub inventory_json: String,
pub equipped_weapon_json: Option<String>,
pub equipped_armor_json: Option<String>,
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()

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,
});
}

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(())
})?;
}