use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::Path; // --- Attitude system --- #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Attitude { Friendly, // 50 to 100 Neutral, // 10 to 49 Wary, // -24 to 9 Aggressive, // -74 to -25 (will attack if provoked) Hostile, // -100 to -75 (attacks on sight) } impl Attitude { pub fn default_value(self) -> i32 { match self { Attitude::Friendly => 75, Attitude::Neutral => 30, Attitude::Wary => 0, Attitude::Aggressive => -50, Attitude::Hostile => -90, } } pub fn from_value(v: i32) -> Self { match v { 50..=i32::MAX => Attitude::Friendly, 10..=49 => Attitude::Neutral, -24..=9 => Attitude::Wary, -74..=-25 => Attitude::Aggressive, _ => Attitude::Hostile, } } pub fn label(self) -> &'static str { match self { Attitude::Friendly => "friendly", Attitude::Neutral => "neutral", Attitude::Wary => "wary", Attitude::Aggressive => "aggressive", Attitude::Hostile => "hostile", } } pub fn will_attack(self) -> bool { matches!(self, Attitude::Hostile) } pub fn is_hostile(self) -> bool { matches!(self, Attitude::Hostile | Attitude::Aggressive) } pub fn will_talk(self) -> bool { matches!(self, Attitude::Friendly | Attitude::Neutral | Attitude::Wary) } } // --- On-disk TOML schemas --- #[derive(Deserialize)] pub struct Manifest { pub name: String, pub spawn_room: String, } #[derive(Deserialize)] pub struct RegionFile { pub name: String, } #[derive(Deserialize)] pub struct RoomFile { pub name: String, pub description: String, #[serde(default)] pub exits: HashMap, } #[derive(Deserialize, Clone)] pub struct ShopFile { pub buys: Vec, // List of item kinds or IDs the shop buys pub sells: Vec, // List of item IDs the shop sells #[serde(default)] pub markup: f32, // Multiplier for sell price (default 1.0) #[serde(default)] pub markdown: f32, // Multiplier for buy price (default 0.5) } #[derive(Deserialize, Clone)] pub struct NpcDialogue { #[serde(default)] pub greeting: Option, #[serde(default)] pub keywords: HashMap, // keyword -> response } #[derive(Deserialize)] pub struct NpcCombatFile { pub max_hp: i32, pub attack: i32, pub defense: i32, #[serde(default)] pub xp_reward: i32, } #[derive(Deserialize)] pub struct NpcFile { pub name: String, pub description: String, pub room: String, #[serde(default = "default_attitude")] pub base_attitude: Attitude, #[serde(default)] pub faction: Option, #[serde(default)] pub race: Option, #[serde(default)] pub class: Option, #[serde(default)] pub respawn_secs: Option, #[serde(default)] pub dialogue: Option, #[serde(default)] pub combat: Option, #[serde(default)] pub shop: Option, #[serde(default)] pub gold: i32, #[serde(default)] pub silver: i32, #[serde(default)] pub copper: i32, } fn default_attitude() -> Attitude { Attitude::Neutral } #[derive(Deserialize, Default)] pub struct ObjectStatsFile { #[serde(default)] pub damage: Option, #[serde(default)] pub armor: Option, #[serde(default)] pub heal_amount: Option, } #[derive(Deserialize)] pub struct ObjectFile { pub name: String, pub description: String, #[serde(default)] pub room: Option, #[serde(default)] pub kind: Option, #[serde(default)] pub slot: Option, #[serde(default)] pub takeable: bool, #[serde(default)] pub stats: Option, #[serde(default)] pub value_gold: i32, #[serde(default)] pub value_silver: i32, #[serde(default)] pub value_copper: i32, } // --- Race TOML schema --- #[derive(Deserialize, Default, Clone)] pub struct StatModifiers { #[serde(default)] pub strength: i32, #[serde(default)] pub dexterity: i32, #[serde(default)] pub constitution: i32, #[serde(default)] 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, } 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, } fn default_damage_type() -> String { "physical".into() } #[derive(Deserialize, Default, Clone)] pub struct NaturalFile { #[serde(default)] pub armor: i32, #[serde(default)] pub attacks: HashMap, } #[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, #[serde(default)] pub average: Vec, #[serde(default)] pub poor: Vec, #[serde(default)] pub restricted: Vec, } #[derive(Deserialize, Default, Clone)] pub struct RaceMiscFile { #[serde(default)] pub lifespan: Option, #[serde(default)] pub diet: Option, #[serde(default)] pub xp_rate: Option, #[serde(default)] pub natural_terrain: Vec, #[serde(default)] pub vision: Vec, } #[derive(Deserialize)] pub struct RaceFile { pub name: String, pub description: String, #[serde(default)] pub metarace: Option, #[serde(default)] pub hidden: bool, #[serde(default)] pub default_class: Option, #[serde(default)] pub stats: StatModifiers, #[serde(default)] pub body: BodyFile, #[serde(default)] pub natural: NaturalFile, #[serde(default)] pub resistances: HashMap, #[serde(default)] pub traits: Vec, #[serde(default)] pub disadvantages: Vec, #[serde(default)] pub regen: RegenFile, #[serde(default)] pub guild_compatibility: GuildCompatibilityFile, #[serde(default)] pub misc: RaceMiscFile, } // --- Class TOML schema (starter class — seeds initial guild on character creation) --- #[derive(Deserialize, Default, Clone)] pub struct ClassBaseStats { #[serde(default)] pub max_hp: i32, #[serde(default)] pub attack: i32, #[serde(default)] pub defense: i32, } #[derive(Deserialize, Default, Clone)] pub struct ClassGrowth { #[serde(default)] pub hp_per_level: i32, #[serde(default)] pub attack_per_level: i32, #[serde(default)] pub defense_per_level: i32, } #[derive(Deserialize)] pub struct ClassFile { pub name: String, pub description: String, #[serde(default)] pub hidden: bool, #[serde(default)] pub base_stats: ClassBaseStats, #[serde(default)] pub growth: ClassGrowth, #[serde(default)] pub guild: Option, } // --- Guild TOML schema --- #[derive(Deserialize, Default, Clone)] pub struct GuildGrowth { #[serde(default)] pub hp_per_level: i32, #[serde(default)] pub mana_per_level: i32, #[serde(default)] pub endurance_per_level: i32, #[serde(default)] pub attack_per_level: i32, #[serde(default)] pub defense_per_level: i32, } #[derive(Deserialize)] pub struct GuildFile { pub name: String, pub description: String, #[serde(default)] pub max_level: i32, #[serde(default)] pub resource: Option, #[serde(default)] pub base_mana: i32, #[serde(default)] pub base_endurance: i32, #[serde(default)] pub growth: GuildGrowth, #[serde(default)] pub spells: Vec, #[serde(default)] pub min_player_level: i32, #[serde(default)] pub race_restricted: Vec, } // --- Spell TOML schema --- #[derive(Deserialize)] pub struct SpellFile { pub name: String, pub description: String, #[serde(default)] pub spell_type: Option, #[serde(default)] pub damage: i32, #[serde(default)] pub heal: i32, #[serde(default)] pub damage_type: Option, #[serde(default)] pub cost_mana: i32, #[serde(default)] pub cost_endurance: i32, #[serde(default)] pub cooldown_ticks: i32, #[serde(default)] pub casting_ticks: i32, #[serde(default)] pub min_guild_level: i32, #[serde(default)] pub effect: Option, #[serde(default)] pub effect_duration: i32, #[serde(default)] pub effect_magnitude: i32, } // --- Runtime types --- pub struct Room { pub id: String, pub region: String, pub name: String, pub description: String, pub exits: HashMap, pub npcs: Vec, pub objects: Vec, } #[derive(Clone)] pub struct NpcCombatStats { pub max_hp: i32, pub attack: i32, pub defense: i32, pub xp_reward: i32, } #[derive(Clone)] pub struct Npc { pub id: String, pub name: String, pub description: String, pub room: String, pub base_attitude: Attitude, pub faction: Option, pub fixed_race: Option, pub fixed_class: Option, pub respawn_secs: Option, pub greeting: Option, pub keywords: HashMap, pub combat: Option, pub shop: Option, pub gold: i32, pub silver: i32, pub copper: i32, } #[derive(Clone, Serialize, Deserialize)] pub struct ObjectStats { pub damage: Option, pub armor: Option, pub heal_amount: Option, } #[derive(Clone, Serialize, Deserialize)] pub struct Object { pub id: String, pub name: String, pub description: String, pub room: Option, pub kind: Option, pub slot: Option, pub takeable: bool, pub stats: ObjectStats, pub value_gold: i32, pub value_silver: i32, pub value_copper: i32, } 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, } #[derive(Clone)] pub struct Race { pub id: String, pub name: String, pub description: String, pub metarace: Option, pub hidden: bool, pub default_class: Option, pub stats: StatModifiers, pub size: String, pub weight: i32, pub slots: Vec, pub natural_armor: i32, pub natural_attacks: Vec, pub resistances: HashMap, pub traits: Vec, pub disadvantages: Vec, pub regen_hp: f32, pub regen_mana: f32, pub regen_endurance: f32, pub guild_compatibility: GuildCompatibilityFile, pub lifespan: Option, pub diet: Option, pub xp_rate: f32, pub natural_terrain: Vec, pub vision: Vec, } #[derive(Clone)] pub struct Class { pub id: String, pub name: String, pub description: String, pub hidden: bool, pub base_stats: ClassBaseStats, pub growth: ClassGrowth, pub guild: Option, } #[derive(Clone)] pub struct Guild { pub id: String, pub name: String, pub description: String, pub max_level: i32, pub resource: String, pub base_mana: i32, pub base_endurance: i32, pub growth: GuildGrowth, pub spells: Vec, pub min_player_level: i32, pub race_restricted: Vec, } #[derive(Clone)] pub struct Spell { pub id: String, pub name: String, pub description: String, pub spell_type: String, pub damage: i32, pub heal: i32, pub damage_type: String, pub cost_mana: i32, pub cost_endurance: i32, pub cooldown_ticks: i32, pub casting_ticks: i32, pub min_guild_level: i32, pub effect: Option, pub effect_duration: i32, pub effect_magnitude: i32, } pub struct World { pub name: String, pub spawn_room: String, pub rooms: HashMap, pub npcs: HashMap, pub objects: HashMap, pub races: Vec, pub classes: Vec, pub guilds: HashMap, pub spells: HashMap, } impl World { pub fn load(world_dir: &Path) -> Result { let manifest: Manifest = load_toml(&world_dir.join("manifest.toml"))?; let mut rooms = HashMap::new(); let mut npcs = HashMap::new(); let mut objects = HashMap::new(); 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}"))?; 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, hidden: rf.hidden, default_class: rf.default_class, 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(()) })?; let mut classes = Vec::new(); load_entities_from_dir(&world_dir.join("classes"), "class", &mut |id, content| { let cf: ClassFile = toml::from_str(content).map_err(|e| format!("Bad class {id}: {e}"))?; classes.push(Class { id, name: cf.name, description: cf.description, hidden: cf.hidden, base_stats: cf.base_stats, growth: cf.growth, guild: cf.guild }); Ok(()) })?; let mut guilds = HashMap::new(); load_entities_from_dir(&world_dir.join("guilds"), "guild", &mut |id, content| { let gf: GuildFile = toml::from_str(content).map_err(|e| format!("Bad guild {id}: {e}"))?; guilds.insert(id.clone(), Guild { id, name: gf.name, description: gf.description, max_level: if gf.max_level == 0 { 50 } else { gf.max_level }, resource: gf.resource.unwrap_or_else(|| "mana".into()), base_mana: gf.base_mana, base_endurance: gf.base_endurance, growth: gf.growth, spells: gf.spells, min_player_level: gf.min_player_level, race_restricted: gf.race_restricted, }); Ok(()) })?; let mut spells = HashMap::new(); load_entities_from_dir(&world_dir.join("spells"), "spell", &mut |id, content| { let sf: SpellFile = toml::from_str(content).map_err(|e| format!("Bad spell {id}: {e}"))?; spells.insert(id.clone(), Spell { id, name: sf.name, description: sf.description, spell_type: sf.spell_type.unwrap_or_else(|| "offensive".into()), damage: sf.damage, heal: sf.heal, damage_type: sf.damage_type.unwrap_or_else(|| "magical".into()), cost_mana: sf.cost_mana, cost_endurance: sf.cost_endurance, cooldown_ticks: sf.cooldown_ticks, casting_ticks: sf.casting_ticks, min_guild_level: sf.min_guild_level, effect: sf.effect, effect_duration: sf.effect_duration, effect_magnitude: sf.effect_magnitude, }); Ok(()) })?; let entries = std::fs::read_dir(world_dir) .map_err(|e| format!("Cannot read world dir: {e}"))?; let mut region_dirs: Vec<_> = entries .filter_map(|e| e.ok()) .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) .filter(|e| { let n = e.file_name().to_string_lossy().to_string(); n != "races" && n != "classes" && n != "guilds" && n != "spells" }) .collect(); region_dirs.sort_by_key(|e| e.file_name()); for entry in region_dirs { let region_name = entry.file_name().to_string_lossy().to_string(); let region_path = entry.path(); if !region_path.join("region.toml").exists() { continue; } let _rm: RegionFile = load_toml(®ion_path.join("region.toml"))?; log::info!("Loading region: {region_name}"); load_entities_from_dir(®ion_path.join("rooms"), ®ion_name, &mut |id, content| { let rf: RoomFile = toml::from_str(content).map_err(|e| format!("Bad room {id}: {e}"))?; rooms.insert(id.clone(), Room { id: id.clone(), region: region_name.clone(), name: rf.name, description: rf.description, exits: rf.exits, npcs: Vec::new(), objects: Vec::new() }); Ok(()) })?; load_entities_from_dir(®ion_path.join("npcs"), ®ion_name, &mut |id, content| { let nf: NpcFile = toml::from_str(content).map_err(|e| format!("Bad npc {id}: {e}"))?; let (greeting, keywords) = match nf.dialogue { Some(d) => (d.greeting, d.keywords), None => (None, HashMap::new()), }; let combat = Some(nf.combat.map(|c| NpcCombatStats { max_hp: c.max_hp, attack: c.attack, defense: c.defense, xp_reward: c.xp_reward }) .unwrap_or(NpcCombatStats { max_hp: 20, attack: 4, defense: 2, xp_reward: 5 })); npcs.insert(id.clone(), Npc { id: id.clone(), name: nf.name, description: nf.description, room: nf.room, base_attitude: nf.base_attitude, faction: nf.faction, fixed_race: nf.race, fixed_class: nf.class, respawn_secs: nf.respawn_secs, greeting, keywords, combat, shop: nf.shop, gold: nf.gold, silver: nf.silver, copper: nf.copper, }); Ok(()) })?; 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, slot: of.slot, takeable: of.takeable, stats: ObjectStats { damage: stats.damage, armor: stats.armor, heal_amount: stats.heal_amount }, value_gold: of.value_gold, value_silver: of.value_silver, value_copper: of.value_copper, }); Ok(()) })?; } for npc in npcs.values() { if let Some(room) = rooms.get_mut(&npc.room) { room.npcs.push(npc.id.clone()); } } for obj in objects.values() { if let Some(ref rid) = obj.room { if let Some(room) = rooms.get_mut(rid) { room.objects.push(obj.id.clone()); } } } if !rooms.contains_key(&manifest.spawn_room) { return Err(format!("Spawn room '{}' not found", manifest.spawn_room)); } for room in rooms.values() { for (dir, target) in &room.exits { if !rooms.contains_key(target) { return Err(format!("Room '{}' exit '{dir}' -> unknown '{target}'", room.id)); } } } if races.iter().filter(|r| !r.hidden).count() == 0 { return Err("No playable (non-hidden) races defined".into()); } if classes.iter().filter(|c| !c.hidden).count() == 0 { return Err("No playable (non-hidden) classes defined".into()); } log::info!("World '{}': {} rooms, {} npcs, {} objects, {} races, {} classes, {} guilds, {} spells", manifest.name, rooms.len(), npcs.len(), objects.len(), races.len(), classes.len(), guilds.len(), spells.len()); Ok(World { name: manifest.name, spawn_room: manifest.spawn_room, rooms, npcs, objects, races, classes, guilds, spells }) } pub fn get_room(&self, id: &str) -> Option<&Room> { self.rooms.get(id) } pub fn get_npc(&self, id: &str) -> Option<&Npc> { self.npcs.get(id) } pub fn get_object(&self, id: &str) -> Option<&Object> { self.objects.get(id) } pub fn get_guild(&self, id: &str) -> Option<&Guild> { self.guilds.get(id) } pub fn get_spell(&self, id: &str) -> Option<&Spell> { self.spells.get(id) } pub fn spells_for_guild(&self, guild_id: &str, guild_level: i32) -> Vec<&Spell> { let guild = match self.guilds.get(guild_id) { Some(g) => g, None => return Vec::new(), }; guild.spells.iter() .filter_map(|sid| self.spells.get(sid)) .filter(|s| s.min_guild_level <= guild_level) .collect() } } fn load_toml(path: &Path) -> Result { let content = std::fs::read_to_string(path).map_err(|e| format!("Cannot read {}: {e}", path.display()))?; toml::from_str(&content).map_err(|e| format!("Bad TOML in {}: {e}", path.display())) } fn load_entities_from_dir(dir: &Path, prefix: &str, handler: &mut dyn FnMut(String, &str) -> Result<(), String>) -> Result<(), String> { if !dir.exists() { return Ok(()); } let mut entries: Vec<_> = std::fs::read_dir(dir).map_err(|e| format!("Cannot read {}: {e}", dir.display()))? .filter_map(|e| e.ok()).filter(|e| e.path().extension().map(|ext| ext == "toml").unwrap_or(false)).collect(); entries.sort_by_key(|e| e.file_name()); for entry in entries { let stem = entry.path().file_stem().unwrap().to_string_lossy().to_string(); let id = format!("{prefix}:{stem}"); let content = std::fs::read_to_string(entry.path()).map_err(|e| format!("Cannot read {}: {e}", entry.path().display()))?; handler(id, &content)?; } Ok(()) }