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 can_be_attacked(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)] pub struct NpcDialogue { #[serde(default)] pub greeting: Option, } #[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 respawn_secs: Option, #[serde(default)] pub dialogue: Option, #[serde(default)] pub combat: Option, } 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 takeable: bool, #[serde(default)] pub stats: Option, } #[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, } #[derive(Deserialize)] pub struct RaceFile { pub name: String, pub description: String, #[serde(default)] pub stats: StatModifiers, } #[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 base_stats: ClassBaseStats, #[serde(default)] pub growth: ClassGrowth, } // --- 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 respawn_secs: Option, pub greeting: Option, pub combat: Option, } #[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 takeable: bool, pub stats: ObjectStats, } #[derive(Clone)] pub struct Race { pub id: String, pub name: String, pub description: String, pub stats: StatModifiers, } #[derive(Clone)] pub struct Class { pub id: String, pub name: String, pub description: String, pub base_stats: ClassBaseStats, pub growth: ClassGrowth, } 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, } 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}"))?; races.push(Race { id, name: rf.name, description: rf.description, stats: rf.stats }); 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, base_stats: cf.base_stats, growth: cf.growth }); 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" }) .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 combat = nf.combat.map(|c| NpcCombatStats { max_hp: c.max_hp, attack: c.attack, defense: c.defense, xp_reward: c.xp_reward }); let greeting = nf.dialogue.and_then(|d| d.greeting); 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, respawn_secs: nf.respawn_secs, greeting, combat }); 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, takeable: of.takeable, stats: ObjectStats { damage: stats.damage, armor: stats.armor, heal_amount: stats.heal_amount } }); 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.is_empty() { return Err("No races defined".into()); } if classes.is_empty() { return Err("No classes defined".into()); } log::info!("World '{}': {} rooms, {} npcs, {} objects, {} races, {} classes", manifest.name, rooms.len(), npcs.len(), objects.len(), races.len(), classes.len()); Ok(World { name: manifest.name, spawn_room: manifest.spawn_room, rooms, npcs, objects, races, classes }) } 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) } } 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(()) }