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.
|
- Status effects persist in the database and continue ticking while players are offline.
|
||||||
- The `GameDb` trait abstracts the database backend (currently SQLite).
|
- The `GameDb` trait abstracts the database backend (currently SQLite).
|
||||||
- World data is loaded from TOML at startup. Content changes don't require recompilation.
|
- 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
|
## Architecture
|
||||||
|
|
||||||
@@ -76,6 +79,23 @@ src/
|
|||||||
2. Currently: hostile NPCs auto-engage players in their room
|
2. Currently: hostile NPCs auto-engage players in their room
|
||||||
3. Add new behaviors there (e.g. NPC movement, dialogue triggers)
|
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
|
### New world content
|
||||||
1. Add TOML files under `world/<region>/` — no code changes needed
|
1. Add TOML files under `world/<region>/` — no code changes needed
|
||||||
2. NPCs without a `[combat]` section get default stats (20hp/4atk/2def/5xp)
|
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
|
## Character Creation
|
||||||
- [ ] New player SSH → gets chargen flow (race + class selection)
|
- [ ] New player SSH → gets chargen flow (race + class selection)
|
||||||
- [ ] Chargen accepts both number and name input
|
- [ ] 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
|
- [ ] 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 saved to DB after creation
|
||||||
|
|
||||||
## Player Persistence
|
## 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)
|
- [ ] Damage varies between hits (not identical each time)
|
||||||
- [ ] Multiple rapid attacks produce different damage values
|
- [ ] Multiple rapid attacks produce different damage values
|
||||||
|
|
||||||
## Items
|
## Items & Equipment Slots
|
||||||
- [ ] `take <item>` picks up takeable objects
|
- [ ] `take <item>` picks up takeable objects
|
||||||
- [ ] `drop <item>` places item in room
|
- [ ] `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>` heals and removes item (immediate out of combat)
|
||||||
- [ ] `use <consumable>` in combat queues for next tick
|
- [ ] `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
|
## Status Effects
|
||||||
- [ ] Poison deals damage each tick, shows message to player
|
- [ ] 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
|
- [ ] Server remains responsive to immediate commands between ticks
|
||||||
- [ ] Multiple players in separate combats are processed independently per tick
|
- [ ] 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
|
## Attitude System
|
||||||
- [ ] Per-player NPC attitudes stored in DB
|
- [ ] Per-player NPC attitudes stored in DB
|
||||||
- [ ] `examine` shows attitude label per-player
|
- [ ] `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
|
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));
|
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!(
|
out.push_str(&format!(
|
||||||
" Inventory: {} item(s) | Weapon: {} | Armor: {}\r\n",
|
" Inventory: {} item(s) | Equipped: {}\r\n",
|
||||||
p.inventory.len(),
|
p.inventory.len(),
|
||||||
p.equipped_weapon
|
equipped_str,
|
||||||
.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"),
|
|
||||||
));
|
));
|
||||||
let attitudes = st.db.load_attitudes(&p.name);
|
let attitudes = st.db.load_attitudes(&p.name);
|
||||||
if !attitudes.is_empty() {
|
if !attitudes.is_empty() {
|
||||||
|
|||||||
@@ -114,8 +114,7 @@ fn cmd_players(db: &SqliteDb, args: &[String]) {
|
|||||||
println!(" Room: {}", p.room_id);
|
println!(" Room: {}", p.room_id);
|
||||||
println!(" Admin: {}", p.is_admin);
|
println!(" Admin: {}", p.is_admin);
|
||||||
println!(" Inventory: {}", p.inventory_json);
|
println!(" Inventory: {}", p.inventory_json);
|
||||||
if let Some(ref w) = p.equipped_weapon_json { println!(" Weapon: {w}"); }
|
println!(" Equipped: {}", p.equipped_json);
|
||||||
if let Some(ref a) = p.equipped_armor_json { println!(" Armor: {a}"); }
|
|
||||||
let attitudes = db.load_attitudes(name);
|
let attitudes = db.load_attitudes(name);
|
||||||
if !attitudes.is_empty() {
|
if !attitudes.is_empty() {
|
||||||
println!(" Attitudes:");
|
println!(" Attitudes:");
|
||||||
|
|||||||
@@ -44,6 +44,31 @@ impl ChargenState {
|
|||||||
},
|
},
|
||||||
ansi::color(ansi::DIM, &race.description),
|
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!(
|
out.push_str(&format!(
|
||||||
"\r\n{}",
|
"\r\n{}",
|
||||||
@@ -179,6 +204,8 @@ fn format_stat_mods(stats: &crate::world::StatModifiers) -> String {
|
|||||||
("CON", stats.constitution),
|
("CON", stats.constitution),
|
||||||
("INT", stats.intelligence),
|
("INT", stats.intelligence),
|
||||||
("WIS", stats.wisdom),
|
("WIS", stats.wisdom),
|
||||||
|
("PER", stats.perception),
|
||||||
|
("CHA", stats.charisma),
|
||||||
];
|
];
|
||||||
for (label, val) in fields {
|
for (label, val) in fields {
|
||||||
if val != 0 {
|
if val != 0 {
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ pub fn resolve_combat_tick(
|
|||||||
|
|
||||||
let npc_hp_before = instance.hp;
|
let npc_hp_before = instance.hp;
|
||||||
let conn = state.players.get(&player_id)?;
|
let conn = state.players.get(&player_id)?;
|
||||||
let p_atk = conn.player.effective_attack();
|
let p_atk = conn.player.effective_attack(&state.world);
|
||||||
let p_def = conn.player.effective_defense();
|
let p_def = conn.player.effective_defense(&state.world);
|
||||||
let _ = conn;
|
let _ = conn;
|
||||||
|
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
|
|||||||
132
src/commands.rs
132
src/commands.rs
@@ -545,19 +545,25 @@ async fn cmd_inventory(pid: usize, state: &SharedState) -> CommandResult {
|
|||||||
None => return simple("Error\r\n"),
|
None => return simple("Error\r\n"),
|
||||||
};
|
};
|
||||||
let mut out = format!("\r\n{}\r\n", ansi::bold("=== Inventory ==="));
|
let mut out = format!("\r\n{}\r\n", ansi::bold("=== Inventory ==="));
|
||||||
if let Some(ref w) = conn.player.equipped_weapon {
|
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!(
|
out.push_str(&format!(
|
||||||
" Weapon: {} {}\r\n",
|
" {}: {} {}\r\n",
|
||||||
ansi::color(ansi::CYAN, &w.name),
|
ansi::color(ansi::YELLOW, slot),
|
||||||
ansi::system_msg(&format!("(+{} dmg)", w.stats.damage.unwrap_or(0)))
|
ansi::color(ansi::CYAN, &obj.name),
|
||||||
|
ansi::system_msg(&bonus),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
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.inventory.is_empty() {
|
if conn.player.inventory.is_empty() {
|
||||||
out.push_str(&format!(" {}\r\n", ansi::system_msg("(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");
|
return simple("Equip what?\r\n");
|
||||||
}
|
}
|
||||||
let mut st = state.lock().await;
|
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) {
|
let conn = match st.players.get_mut(&pid) {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => return simple("Error\r\n"),
|
None => return simple("Error\r\n"),
|
||||||
@@ -609,48 +626,47 @@ async fn cmd_equip(pid: usize, target: &str, state: &SharedState) -> CommandResu
|
|||||||
};
|
};
|
||||||
let obj = conn.player.inventory.remove(idx);
|
let obj = conn.player.inventory.remove(idx);
|
||||||
let name = obj.name.clone();
|
let name = obj.name.clone();
|
||||||
let kind = obj.kind.as_deref().unwrap_or("").to_string();
|
|
||||||
match kind.as_str() {
|
let slot = if let Some(ref s) = obj.slot {
|
||||||
"weapon" => {
|
s.clone()
|
||||||
if let Some(old) = conn.player.equipped_weapon.take() {
|
} else {
|
||||||
conn.player.inventory.push(old);
|
match obj.kind.as_deref() {
|
||||||
}
|
Some("weapon") => "main_hand".into(),
|
||||||
conn.player.equipped_weapon = Some(obj);
|
Some("armor") => "torso".into(),
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"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);
|
conn.player.inventory.push(obj);
|
||||||
simple(&format!(
|
return simple(&format!(
|
||||||
"{}\r\n",
|
"{}\r\n",
|
||||||
ansi::error_msg(&format!("You can't equip the {}.", name))
|
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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_use(pid: usize, target: &str, state: &SharedState) -> CommandResult {
|
async fn cmd_use(pid: usize, target: &str, state: &SharedState) -> CommandResult {
|
||||||
@@ -1094,20 +1110,22 @@ async fn cmd_stats(pid: usize, state: &SharedState) -> CommandResult {
|
|||||||
s.max_hp,
|
s.max_hp,
|
||||||
ansi::RESET
|
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!(
|
out.push_str(&format!(
|
||||||
" {} {} (+{} equip) {} {} (+{} equip)\r\n",
|
" {} {} (+{} equip, +{} natural) {} {} (+{} equip, +{} natural)\r\n",
|
||||||
ansi::color(ansi::DIM, "ATK:"),
|
ansi::color(ansi::DIM, "ATK:"),
|
||||||
s.attack,
|
s.attack, equip_dmg.max(race_natural_atk), race_natural_atk,
|
||||||
p.equipped_weapon
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|w| w.stats.damage)
|
|
||||||
.unwrap_or(0),
|
|
||||||
ansi::color(ansi::DIM, "DEF:"),
|
ansi::color(ansi::DIM, "DEF:"),
|
||||||
s.defense,
|
s.defense, equip_arm, race_natural_def,
|
||||||
p.equipped_armor
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|a| a.stats.armor)
|
|
||||||
.unwrap_or(0)
|
|
||||||
));
|
));
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
" {} {}\r\n",
|
" {} {}\r\n",
|
||||||
|
|||||||
60
src/db.rs
60
src/db.rs
@@ -14,8 +14,7 @@ pub struct SavedPlayer {
|
|||||||
pub attack: i32,
|
pub attack: i32,
|
||||||
pub defense: i32,
|
pub defense: i32,
|
||||||
pub inventory_json: String,
|
pub inventory_json: String,
|
||||||
pub equipped_weapon_json: Option<String>,
|
pub equipped_json: String,
|
||||||
pub equipped_armor_json: Option<String>,
|
|
||||||
pub is_admin: bool,
|
pub is_admin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,8 +79,7 @@ impl SqliteDb {
|
|||||||
attack INTEGER NOT NULL,
|
attack INTEGER NOT NULL,
|
||||||
defense INTEGER NOT NULL,
|
defense INTEGER NOT NULL,
|
||||||
inventory_json TEXT NOT NULL DEFAULT '[]',
|
inventory_json TEXT NOT NULL DEFAULT '[]',
|
||||||
equipped_weapon_json TEXT,
|
equipped_json TEXT NOT NULL DEFAULT '{}',
|
||||||
equipped_armor_json TEXT,
|
|
||||||
is_admin INTEGER NOT NULL DEFAULT 0
|
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());
|
log::info!("Database opened: {}", path.display());
|
||||||
Ok(SqliteDb {
|
Ok(SqliteDb {
|
||||||
conn: std::sync::Mutex::new(conn),
|
conn: std::sync::Mutex::new(conn),
|
||||||
@@ -132,8 +157,7 @@ impl GameDb for SqliteDb {
|
|||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
"SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
||||||
attack, defense, inventory_json, equipped_weapon_json,
|
attack, defense, inventory_json, equipped_json, is_admin
|
||||||
equipped_armor_json, is_admin
|
|
||||||
FROM players WHERE name = ?1",
|
FROM players WHERE name = ?1",
|
||||||
[name],
|
[name],
|
||||||
|row| {
|
|row| {
|
||||||
@@ -149,9 +173,8 @@ impl GameDb for SqliteDb {
|
|||||||
attack: row.get(8)?,
|
attack: row.get(8)?,
|
||||||
defense: row.get(9)?,
|
defense: row.get(9)?,
|
||||||
inventory_json: row.get(10)?,
|
inventory_json: row.get(10)?,
|
||||||
equipped_weapon_json: row.get(11)?,
|
equipped_json: row.get::<_, String>(11).unwrap_or_else(|_| "{}".into()),
|
||||||
equipped_armor_json: row.get(12)?,
|
is_admin: row.get::<_, i32>(12)? != 0,
|
||||||
is_admin: row.get::<_, i32>(13)? != 0,
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -162,15 +185,13 @@ impl GameDb for SqliteDb {
|
|||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let _ = conn.execute(
|
let _ = conn.execute(
|
||||||
"INSERT INTO players (name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
"INSERT INTO players (name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
||||||
attack, defense, inventory_json, equipped_weapon_json,
|
attack, defense, inventory_json, equipped_json, is_admin)
|
||||||
equipped_armor_json, is_admin)
|
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13)
|
||||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)
|
|
||||||
ON CONFLICT(name) DO UPDATE SET
|
ON CONFLICT(name) DO UPDATE SET
|
||||||
room_id=excluded.room_id, level=excluded.level, xp=excluded.xp,
|
room_id=excluded.room_id, level=excluded.level, xp=excluded.xp,
|
||||||
hp=excluded.hp, max_hp=excluded.max_hp, attack=excluded.attack,
|
hp=excluded.hp, max_hp=excluded.max_hp, attack=excluded.attack,
|
||||||
defense=excluded.defense, inventory_json=excluded.inventory_json,
|
defense=excluded.defense, inventory_json=excluded.inventory_json,
|
||||||
equipped_weapon_json=excluded.equipped_weapon_json,
|
equipped_json=excluded.equipped_json,
|
||||||
equipped_armor_json=excluded.equipped_armor_json,
|
|
||||||
is_admin=excluded.is_admin",
|
is_admin=excluded.is_admin",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
p.name,
|
p.name,
|
||||||
@@ -184,8 +205,7 @@ impl GameDb for SqliteDb {
|
|||||||
p.attack,
|
p.attack,
|
||||||
p.defense,
|
p.defense,
|
||||||
p.inventory_json,
|
p.inventory_json,
|
||||||
p.equipped_weapon_json,
|
p.equipped_json,
|
||||||
p.equipped_armor_json,
|
|
||||||
p.is_admin as i32,
|
p.is_admin as i32,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -214,8 +234,7 @@ impl GameDb for SqliteDb {
|
|||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
"SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
||||||
attack, defense, inventory_json, equipped_weapon_json,
|
attack, defense, inventory_json, equipped_json, is_admin
|
||||||
equipped_armor_json, is_admin
|
|
||||||
FROM players ORDER BY name",
|
FROM players ORDER BY name",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -232,9 +251,8 @@ impl GameDb for SqliteDb {
|
|||||||
attack: row.get(8)?,
|
attack: row.get(8)?,
|
||||||
defense: row.get(9)?,
|
defense: row.get(9)?,
|
||||||
inventory_json: row.get(10)?,
|
inventory_json: row.get(10)?,
|
||||||
equipped_weapon_json: row.get(11)?,
|
equipped_json: row.get::<_, String>(11).unwrap_or_else(|_| "{}".into()),
|
||||||
equipped_armor_json: row.get(12)?,
|
is_admin: row.get::<_, i32>(12)? != 0,
|
||||||
is_admin: row.get::<_, i32>(13)? != 0,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
74
src/game.rs
74
src/game.rs
@@ -27,28 +27,43 @@ pub struct Player {
|
|||||||
pub room_id: String,
|
pub room_id: String,
|
||||||
pub stats: PlayerStats,
|
pub stats: PlayerStats,
|
||||||
pub inventory: Vec<Object>,
|
pub inventory: Vec<Object>,
|
||||||
pub equipped_weapon: Option<Object>,
|
pub equipped: HashMap<String, Object>,
|
||||||
pub equipped_armor: Option<Object>,
|
|
||||||
pub is_admin: bool,
|
pub is_admin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Player {
|
impl Player {
|
||||||
pub fn effective_attack(&self) -> i32 {
|
pub fn equipped_in_slot(&self, slot: &str) -> Option<&Object> {
|
||||||
let bonus = self
|
self.equipped.get(slot)
|
||||||
.equipped_weapon
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|w| w.stats.damage)
|
|
||||||
.unwrap_or(0);
|
|
||||||
self.stats.attack + bonus
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn effective_defense(&self) -> i32 {
|
pub fn total_equipped_damage(&self) -> i32 {
|
||||||
let bonus = self
|
self.equipped.values()
|
||||||
.equipped_armor
|
.filter_map(|o| o.stats.damage)
|
||||||
.as_ref()
|
.sum()
|
||||||
.and_then(|a| a.stats.armor)
|
}
|
||||||
|
|
||||||
|
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);
|
.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,
|
room_id,
|
||||||
stats,
|
stats,
|
||||||
inventory: Vec::new(),
|
inventory: Vec::new(),
|
||||||
equipped_weapon: None,
|
equipped: HashMap::new(),
|
||||||
equipped_armor: None,
|
|
||||||
is_admin: false,
|
is_admin: false,
|
||||||
},
|
},
|
||||||
channel,
|
channel,
|
||||||
@@ -259,14 +273,8 @@ impl GameState {
|
|||||||
) {
|
) {
|
||||||
let inventory: Vec<Object> =
|
let inventory: Vec<Object> =
|
||||||
serde_json::from_str(&saved.inventory_json).unwrap_or_default();
|
serde_json::from_str(&saved.inventory_json).unwrap_or_default();
|
||||||
let equipped_weapon: Option<Object> = saved
|
let equipped: HashMap<String, Object> =
|
||||||
.equipped_weapon_json
|
serde_json::from_str(&saved.equipped_json).unwrap_or_default();
|
||||||
.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 room_id = if self.world.rooms.contains_key(&saved.room_id) {
|
let room_id = if self.world.rooms.contains_key(&saved.room_id) {
|
||||||
saved.room_id
|
saved.room_id
|
||||||
@@ -294,8 +302,7 @@ impl GameState {
|
|||||||
room_id,
|
room_id,
|
||||||
stats,
|
stats,
|
||||||
inventory,
|
inventory,
|
||||||
equipped_weapon,
|
equipped,
|
||||||
equipped_armor,
|
|
||||||
is_admin: saved.is_admin,
|
is_admin: saved.is_admin,
|
||||||
},
|
},
|
||||||
channel,
|
channel,
|
||||||
@@ -310,14 +317,8 @@ impl GameState {
|
|||||||
let p = &conn.player;
|
let p = &conn.player;
|
||||||
let inv_json =
|
let inv_json =
|
||||||
serde_json::to_string(&p.inventory).unwrap_or_else(|_| "[]".into());
|
serde_json::to_string(&p.inventory).unwrap_or_else(|_| "[]".into());
|
||||||
let weapon_json = p
|
let equipped_json =
|
||||||
.equipped_weapon
|
serde_json::to_string(&p.equipped).unwrap_or_else(|_| "{}".into());
|
||||||
.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()));
|
|
||||||
|
|
||||||
self.db.save_player(&SavedPlayer {
|
self.db.save_player(&SavedPlayer {
|
||||||
name: p.name.clone(),
|
name: p.name.clone(),
|
||||||
@@ -331,8 +332,7 @@ impl GameState {
|
|||||||
attack: p.stats.attack,
|
attack: p.stats.attack,
|
||||||
defense: p.stats.defense,
|
defense: p.stats.defense,
|
||||||
inventory_json: inv_json,
|
inventory_json: inv_json,
|
||||||
equipped_weapon_json: weapon_json,
|
equipped_json,
|
||||||
equipped_armor_json: armor_json,
|
|
||||||
is_admin: p.is_admin,
|
is_admin: p.is_admin,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
172
src/world.rs
172
src/world.rs
@@ -134,11 +134,15 @@ pub struct ObjectFile {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub kind: Option<String>,
|
pub kind: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub slot: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub takeable: bool,
|
pub takeable: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stats: Option<ObjectStatsFile>,
|
pub stats: Option<ObjectStatsFile>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Race TOML schema ---
|
||||||
|
|
||||||
#[derive(Deserialize, Default, Clone)]
|
#[derive(Deserialize, Default, Clone)]
|
||||||
pub struct StatModifiers {
|
pub struct StatModifiers {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -151,6 +155,86 @@ pub struct StatModifiers {
|
|||||||
pub intelligence: i32,
|
pub intelligence: i32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub wisdom: i32,
|
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)]
|
#[derive(Deserialize)]
|
||||||
@@ -158,9 +242,29 @@ pub struct RaceFile {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub metarace: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub stats: StatModifiers,
|
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)]
|
#[derive(Deserialize, Default, Clone)]
|
||||||
pub struct ClassBaseStats {
|
pub struct ClassBaseStats {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -238,16 +342,47 @@ pub struct Object {
|
|||||||
pub description: String,
|
pub description: String,
|
||||||
pub room: Option<String>,
|
pub room: Option<String>,
|
||||||
pub kind: Option<String>,
|
pub kind: Option<String>,
|
||||||
|
pub slot: Option<String>,
|
||||||
pub takeable: bool,
|
pub takeable: bool,
|
||||||
pub stats: ObjectStats,
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct Race {
|
pub struct Race {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
|
pub metarace: Option<String>,
|
||||||
pub stats: StatModifiers,
|
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)]
|
#[derive(Clone)]
|
||||||
@@ -280,7 +415,36 @@ impl World {
|
|||||||
let mut races = Vec::new();
|
let mut races = Vec::new();
|
||||||
load_entities_from_dir(&world_dir.join("races"), "race", &mut |id, content| {
|
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}"))?;
|
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(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -325,7 +489,11 @@ impl World {
|
|||||||
load_entities_from_dir(®ion_path.join("objects"), ®ion_name, &mut |id, content| {
|
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 of: ObjectFile = toml::from_str(content).map_err(|e| format!("Bad object {id}: {e}"))?;
|
||||||
let stats = of.stats.unwrap_or_default();
|
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(())
|
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
|
constitution = 2
|
||||||
intelligence = 0
|
intelligence = 0
|
||||||
wisdom = 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
|
constitution = -1
|
||||||
intelligence = 2
|
intelligence = 2
|
||||||
wisdom = 0
|
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
|
constitution = 0
|
||||||
intelligence = 0
|
intelligence = 0
|
||||||
wisdom = 1
|
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
|
constitution = 0
|
||||||
intelligence = 0
|
intelligence = 0
|
||||||
wisdom = 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
|
constitution = 1
|
||||||
intelligence = -2
|
intelligence = -2
|
||||||
wisdom = -1
|
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."
|
description = "A dented but serviceable round shield bearing the blacksmith's mark."
|
||||||
room = "town:forge"
|
room = "town:forge"
|
||||||
kind = "armor"
|
kind = "armor"
|
||||||
|
slot = "off_hand"
|
||||||
takeable = true
|
takeable = true
|
||||||
|
|
||||||
[stats]
|
[stats]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ name = "Rusty Sword"
|
|||||||
description = "A battered iron blade with a cracked leather grip. It's seen better days."
|
description = "A battered iron blade with a cracked leather grip. It's seen better days."
|
||||||
room = "town:cellar"
|
room = "town:cellar"
|
||||||
kind = "weapon"
|
kind = "weapon"
|
||||||
|
slot = "main_hand"
|
||||||
takeable = true
|
takeable = true
|
||||||
|
|
||||||
[stats]
|
[stats]
|
||||||
|
|||||||
Reference in New Issue
Block a user