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:
16
src/admin.rs
16
src/admin.rs
@@ -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() {
|
||||
|
||||
@@ -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:");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
144
src/commands.rs
144
src/commands.rs
@@ -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",
|
||||
|
||||
60
src/db.rs
60
src/db.rs
@@ -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()
|
||||
|
||||
74
src/game.rs
74
src/game.rs
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
172
src/world.rs
172
src/world.rs
@@ -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(®ion_path.join("objects"), ®ion_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(())
|
||||
})?;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user