755 lines
23 KiB
Rust
755 lines
23 KiB
Rust
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<String, String>,
|
|
#[serde(default)]
|
|
pub outdoors: bool,
|
|
}
|
|
|
|
#[derive(Deserialize, Clone)]
|
|
pub struct ShopFile {
|
|
pub buys: Vec<String>, // List of item kinds or IDs the shop buys
|
|
pub sells: Vec<String>, // 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<String>,
|
|
#[serde(default)]
|
|
pub keywords: HashMap<String, String>, // 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<String>,
|
|
#[serde(default)]
|
|
pub race: Option<String>,
|
|
#[serde(default)]
|
|
pub class: Option<String>,
|
|
#[serde(default)]
|
|
pub respawn_secs: Option<u64>,
|
|
#[serde(default)]
|
|
pub dialogue: Option<NpcDialogue>,
|
|
#[serde(default)]
|
|
pub combat: Option<NpcCombatFile>,
|
|
#[serde(default)]
|
|
pub shop: Option<ShopFile>,
|
|
#[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<i32>,
|
|
#[serde(default)]
|
|
pub armor: Option<i32>,
|
|
#[serde(default)]
|
|
pub heal_amount: Option<i32>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ObjectFile {
|
|
pub name: String,
|
|
pub description: String,
|
|
#[serde(default)]
|
|
pub room: Option<String>,
|
|
#[serde(default)]
|
|
pub kind: Option<String>,
|
|
#[serde(default)]
|
|
pub slot: Option<String>,
|
|
#[serde(default)]
|
|
pub takeable: bool,
|
|
#[serde(default)]
|
|
pub stats: Option<ObjectStatsFile>,
|
|
#[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<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)]
|
|
pub struct RaceFile {
|
|
pub name: String,
|
|
pub description: String,
|
|
#[serde(default)]
|
|
pub metarace: Option<String>,
|
|
#[serde(default)]
|
|
pub hidden: bool,
|
|
#[serde(default)]
|
|
pub default_class: 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 (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<String>,
|
|
}
|
|
|
|
// --- 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<String>,
|
|
#[serde(default)]
|
|
pub base_mana: i32,
|
|
#[serde(default)]
|
|
pub base_endurance: i32,
|
|
#[serde(default)]
|
|
pub growth: GuildGrowth,
|
|
#[serde(default)]
|
|
pub spells: Vec<String>,
|
|
#[serde(default)]
|
|
pub min_player_level: i32,
|
|
#[serde(default)]
|
|
pub race_restricted: Vec<String>,
|
|
}
|
|
|
|
// --- Spell TOML schema ---
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct SpellFile {
|
|
pub name: String,
|
|
pub description: String,
|
|
#[serde(default)]
|
|
pub spell_type: Option<String>,
|
|
#[serde(default)]
|
|
pub damage: i32,
|
|
#[serde(default)]
|
|
pub heal: i32,
|
|
#[serde(default)]
|
|
pub damage_type: Option<String>,
|
|
#[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<String>,
|
|
#[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<String, String>,
|
|
pub npcs: Vec<String>,
|
|
pub objects: Vec<String>,
|
|
pub outdoors: bool,
|
|
}
|
|
|
|
#[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<String>,
|
|
pub fixed_race: Option<String>,
|
|
pub fixed_class: Option<String>,
|
|
pub respawn_secs: Option<u64>,
|
|
pub greeting: Option<String>,
|
|
pub keywords: HashMap<String, String>,
|
|
pub combat: Option<NpcCombatStats>,
|
|
pub shop: Option<ShopFile>,
|
|
pub gold: i32,
|
|
pub silver: i32,
|
|
pub copper: i32,
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize)]
|
|
pub struct ObjectStats {
|
|
pub damage: Option<i32>,
|
|
pub armor: Option<i32>,
|
|
pub heal_amount: Option<i32>,
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize)]
|
|
pub struct Object {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub description: String,
|
|
pub room: Option<String>,
|
|
pub kind: Option<String>,
|
|
pub slot: Option<String>,
|
|
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<i32>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct Race {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub description: String,
|
|
pub metarace: Option<String>,
|
|
pub hidden: bool,
|
|
pub default_class: 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)]
|
|
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<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
pub min_player_level: i32,
|
|
pub race_restricted: Vec<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
pub effect_duration: i32,
|
|
pub effect_magnitude: i32,
|
|
}
|
|
|
|
pub struct World {
|
|
pub name: String,
|
|
pub spawn_room: String,
|
|
pub rooms: HashMap<String, Room>,
|
|
pub npcs: HashMap<String, Npc>,
|
|
pub objects: HashMap<String, Object>,
|
|
pub races: Vec<Race>,
|
|
pub classes: Vec<Class>,
|
|
pub guilds: HashMap<String, Guild>,
|
|
pub spells: HashMap<String, Spell>,
|
|
}
|
|
|
|
impl World {
|
|
pub fn load(world_dir: &Path) -> Result<Self, String> {
|
|
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(), outdoors: rf.outdoors });
|
|
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<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, String> {
|
|
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(())
|
|
}
|