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:
20
AGENTS.md
20
AGENTS.md
@@ -12,6 +12,9 @@ This is a Rust MUD server that accepts SSH connections. The architecture separat
|
||||
- Status effects persist in the database and continue ticking while players are offline.
|
||||
- The `GameDb` trait abstracts the database backend (currently SQLite).
|
||||
- World data is loaded from TOML at startup. Content changes don't require recompilation.
|
||||
- **Races are deeply data-driven**: body shape, equipment slots, natural weapons/armor, resistances, traits, regen rates — all defined in TOML. A dragon has different slots than a human.
|
||||
- **Equipment is slot-based**: each race defines available body slots. Items declare their slot. The engine validates compatibility at equip time.
|
||||
- **Items magically resize** — no size restrictions. A dragon can wield a human sword.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -76,6 +79,23 @@ src/
|
||||
2. Currently: hostile NPCs auto-engage players in their room
|
||||
3. Add new behaviors there (e.g. NPC movement, dialogue triggers)
|
||||
|
||||
### New race
|
||||
1. Create `world/races/<name>.toml` — see `dragon.toml` for a complex example
|
||||
2. Required: `name`, `description`
|
||||
3. All other fields have sensible defaults via `#[serde(default)]`
|
||||
4. Key sections: `[stats]` (7 stats), `[body]` (size, weight, slots), `[natural]` (armor, attacks), `[resistances]`, `[regen]`, `[misc]`, `[guild_compatibility]`
|
||||
5. `traits` and `disadvantages` are free-form string arrays
|
||||
6. If `[body] slots` is empty, defaults to humanoid slots
|
||||
7. Natural attacks: use `[natural.attacks.<name>]` with `damage`, `type`, optional `cooldown_ticks`
|
||||
8. Resistances: damage_type → multiplier (0.0 = immune, 1.0 = normal, 1.5 = vulnerable)
|
||||
9. `xp_rate` modifies XP gain (< 1.0 = slower leveling, for powerful races)
|
||||
|
||||
### New equipment slot
|
||||
1. Add the slot name to a race's `[body] slots` array
|
||||
2. Create objects with `slot = "<slot_name>"` in their TOML
|
||||
3. The `kind` field (`weapon`/`armor`) still works as fallback for `main_hand`/`torso`
|
||||
4. Items can also have an explicit `slot` field to target any slot
|
||||
|
||||
### New world content
|
||||
1. Add TOML files under `world/<region>/` — no code changes needed
|
||||
2. NPCs without a `[combat]` section get default stats (20hp/4atk/2def/5xp)
|
||||
|
||||
27
TESTING.md
27
TESTING.md
@@ -15,7 +15,10 @@ Run through these checks before every commit to ensure consistent feature covera
|
||||
## Character Creation
|
||||
- [ ] New player SSH → gets chargen flow (race + class selection)
|
||||
- [ ] Chargen accepts both number and name input
|
||||
- [ ] All races display with expanded info (size, traits, natural attacks, vision)
|
||||
- [ ] Dragon race shows custom body slots, natural armor, fire breath, vision types
|
||||
- [ ] After chargen, player appears in spawn room with correct stats
|
||||
- [ ] Stats reflect race modifiers (STR, DEX, CON, INT, WIS, PER, CHA)
|
||||
- [ ] Player saved to DB after creation
|
||||
|
||||
## Player Persistence
|
||||
@@ -68,13 +71,18 @@ Run through these checks before every commit to ensure consistent feature covera
|
||||
- [ ] Damage varies between hits (not identical each time)
|
||||
- [ ] Multiple rapid attacks produce different damage values
|
||||
|
||||
## Items
|
||||
## Items & Equipment Slots
|
||||
- [ ] `take <item>` picks up takeable objects
|
||||
- [ ] `drop <item>` places item in room
|
||||
- [ ] `equip <weapon/armor>` works, old gear returns to inventory
|
||||
- [ ] `equip <weapon>` equips to `main_hand` slot (backwards-compat via kind)
|
||||
- [ ] `equip <armor>` equips to appropriate slot (obj `slot` field or fallback)
|
||||
- [ ] Equipping to an occupied slot returns old item to inventory
|
||||
- [ ] `equip` fails if race doesn't have the required slot
|
||||
- [ ] Objects with explicit `slot` field use that slot
|
||||
- [ ] `use <consumable>` heals and removes item (immediate out of combat)
|
||||
- [ ] `use <consumable>` in combat queues for next tick
|
||||
- [ ] `inventory` shows equipped + bag items
|
||||
- [ ] `inventory` shows equipped items by slot name + bag items
|
||||
- [ ] `stats` shows equipment bonuses and natural bonuses separately
|
||||
|
||||
## Status Effects
|
||||
- [ ] Poison deals damage each tick, shows message to player
|
||||
@@ -97,6 +105,19 @@ Run through these checks before every commit to ensure consistent feature covera
|
||||
- [ ] Server remains responsive to immediate commands between ticks
|
||||
- [ ] Multiple players in separate combats are processed independently per tick
|
||||
|
||||
## Race System
|
||||
- [ ] Existing races (Human, Elf, Dwarf, Orc, Halfling) load with expanded fields
|
||||
- [ ] Dragon race loads with custom body, natural attacks, resistances, traits
|
||||
- [ ] Dragon gets custom equipment slots (forelegs, hindlegs, wings, tail)
|
||||
- [ ] Dragon's natural armor (8) shows in stats and affects defense
|
||||
- [ ] Dragon's natural attacks (fire breath 15dmg) affect effective attack
|
||||
- [ ] Items magically resize — no size restrictions on gear (dragon can use swords)
|
||||
- [ ] Races without explicit [body.slots] get default humanoid slots
|
||||
- [ ] Stat modifiers include PER (perception) and CHA (charisma)
|
||||
- [ ] Race traits and disadvantages display during chargen
|
||||
- [ ] XP rate modifier stored per race (dragon = 0.7x)
|
||||
- [ ] Regen modifiers stored per race (dragon HP regen = 1.5x)
|
||||
|
||||
## Attitude System
|
||||
- [ ] Per-player NPC attitudes stored in DB
|
||||
- [ ] `examine` shows attitude label per-player
|
||||
|
||||
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(())
|
||||
})?;
|
||||
}
|
||||
|
||||
65
world/races/dragon.toml
Normal file
65
world/races/dragon.toml
Normal file
@@ -0,0 +1,65 @@
|
||||
name = "Dragon"
|
||||
description = "Ancient and mighty, dragons are creatures of immense power and terrifying intellect. Their scaled bodies house furnace-hot hearts and minds sharp enough to match any scholar."
|
||||
metarace = "draconic"
|
||||
|
||||
[stats]
|
||||
strength = 4
|
||||
dexterity = -2
|
||||
constitution = 3
|
||||
intelligence = 1
|
||||
wisdom = 1
|
||||
perception = 2
|
||||
charisma = -1
|
||||
|
||||
[body]
|
||||
size = "huge"
|
||||
weight = 2000
|
||||
slots = ["head", "neck", "torso", "forelegs", "hindlegs", "tail", "wings", "main_hand", "off_hand"]
|
||||
|
||||
[natural]
|
||||
armor = 8
|
||||
|
||||
[natural.attacks]
|
||||
[natural.attacks.bite]
|
||||
damage = 12
|
||||
type = "physical"
|
||||
|
||||
[natural.attacks.claw]
|
||||
damage = 8
|
||||
type = "physical"
|
||||
|
||||
[natural.attacks.tail_sweep]
|
||||
damage = 6
|
||||
type = "physical"
|
||||
|
||||
[natural.attacks.fire_breath]
|
||||
damage = 15
|
||||
type = "fire"
|
||||
cooldown_ticks = 5
|
||||
|
||||
traits = ["flight", "fire_breath", "darkvision", "frightful_presence", "ancient_knowledge", "treasure_sense"]
|
||||
disadvantages = ["conspicuous", "slow_leveling", "large_target", "cannot_enter_small_spaces"]
|
||||
|
||||
[resistances]
|
||||
fire = 0.0
|
||||
cold = 1.5
|
||||
physical = 0.7
|
||||
poison = 0.3
|
||||
|
||||
[regen]
|
||||
hp = 1.5
|
||||
mana = 1.2
|
||||
endurance = 0.8
|
||||
|
||||
[guild_compatibility]
|
||||
good = ["sorcerer", "elementalist"]
|
||||
average = ["warrior", "berserker"]
|
||||
poor = ["thief", "bard"]
|
||||
restricted = ["monk"]
|
||||
|
||||
[misc]
|
||||
lifespan = 5000
|
||||
diet = "carnivore"
|
||||
xp_rate = 0.7
|
||||
natural_terrain = ["mountains", "caves", "volcanic"]
|
||||
vision = ["normal", "darkvision", "infravision", "thermal"]
|
||||
@@ -7,3 +7,26 @@ dexterity = -1
|
||||
constitution = 2
|
||||
intelligence = 0
|
||||
wisdom = 0
|
||||
perception = 0
|
||||
charisma = -1
|
||||
|
||||
[body]
|
||||
size = "small"
|
||||
weight = 150
|
||||
|
||||
traits = ["darkvision", "poison_resistance", "stonecunning"]
|
||||
|
||||
[resistances]
|
||||
poison = 0.5
|
||||
earth = 0.7
|
||||
|
||||
[regen]
|
||||
hp = 1.2
|
||||
mana = 0.8
|
||||
endurance = 1.1
|
||||
|
||||
[misc]
|
||||
lifespan = 350
|
||||
diet = "omnivore"
|
||||
xp_rate = 1.0
|
||||
vision = ["normal", "darkvision"]
|
||||
|
||||
@@ -7,3 +7,26 @@ dexterity = 2
|
||||
constitution = -1
|
||||
intelligence = 2
|
||||
wisdom = 0
|
||||
perception = 1
|
||||
charisma = 0
|
||||
|
||||
[body]
|
||||
size = "medium"
|
||||
weight = 130
|
||||
|
||||
traits = ["infravision", "magic_affinity"]
|
||||
disadvantages = ["iron_sensitivity"]
|
||||
|
||||
[resistances]
|
||||
charm = 0.5
|
||||
|
||||
[regen]
|
||||
hp = 0.8
|
||||
mana = 1.3
|
||||
endurance = 0.9
|
||||
|
||||
[misc]
|
||||
lifespan = 800
|
||||
diet = "omnivore"
|
||||
xp_rate = 0.95
|
||||
vision = ["normal", "infravision"]
|
||||
|
||||
@@ -7,3 +7,25 @@ dexterity = 3
|
||||
constitution = 0
|
||||
intelligence = 0
|
||||
wisdom = 1
|
||||
perception = 1
|
||||
charisma = 1
|
||||
|
||||
[body]
|
||||
size = "tiny"
|
||||
weight = 60
|
||||
|
||||
traits = ["lucky", "stealthy", "brave"]
|
||||
|
||||
[resistances]
|
||||
fear = 0.3
|
||||
|
||||
[regen]
|
||||
hp = 1.0
|
||||
mana = 1.0
|
||||
endurance = 1.1
|
||||
|
||||
[misc]
|
||||
lifespan = 150
|
||||
diet = "omnivore"
|
||||
xp_rate = 0.95
|
||||
vision = ["normal"]
|
||||
|
||||
@@ -7,3 +7,20 @@ dexterity = 0
|
||||
constitution = 0
|
||||
intelligence = 0
|
||||
wisdom = 0
|
||||
perception = 0
|
||||
charisma = 1
|
||||
|
||||
[body]
|
||||
size = "medium"
|
||||
weight = 170
|
||||
|
||||
[regen]
|
||||
hp = 1.0
|
||||
mana = 1.0
|
||||
endurance = 1.0
|
||||
|
||||
[misc]
|
||||
lifespan = 80
|
||||
diet = "omnivore"
|
||||
xp_rate = 1.0
|
||||
vision = ["normal"]
|
||||
|
||||
@@ -7,3 +7,29 @@ dexterity = 0
|
||||
constitution = 1
|
||||
intelligence = -2
|
||||
wisdom = -1
|
||||
perception = 0
|
||||
charisma = -1
|
||||
|
||||
[body]
|
||||
size = "large"
|
||||
weight = 250
|
||||
|
||||
[natural]
|
||||
armor = 1
|
||||
|
||||
traits = ["berserker_rage", "intimidating"]
|
||||
disadvantages = ["light_sensitivity"]
|
||||
|
||||
[resistances]
|
||||
physical = 0.9
|
||||
|
||||
[regen]
|
||||
hp = 1.3
|
||||
mana = 0.6
|
||||
endurance = 1.2
|
||||
|
||||
[misc]
|
||||
lifespan = 60
|
||||
diet = "carnivore"
|
||||
xp_rate = 1.05
|
||||
vision = ["normal", "low_light"]
|
||||
|
||||
@@ -2,6 +2,7 @@ name = "Iron Shield"
|
||||
description = "A dented but serviceable round shield bearing the blacksmith's mark."
|
||||
room = "town:forge"
|
||||
kind = "armor"
|
||||
slot = "off_hand"
|
||||
takeable = true
|
||||
|
||||
[stats]
|
||||
|
||||
@@ -2,6 +2,7 @@ name = "Rusty Sword"
|
||||
description = "A battered iron blade with a cracked leather grip. It's seen better days."
|
||||
room = "town:cellar"
|
||||
kind = "weapon"
|
||||
slot = "main_hand"
|
||||
takeable = true
|
||||
|
||||
[stats]
|
||||
|
||||
Reference in New Issue
Block a user