Skip to main content

dreamwell_engine/waymark/
loader.rs

1// Dreamwell v1.0.0 — Waymark pack validation, loading, and backward-compatible import.
2//
3// Loads and validates Waymark content packs. Handles both the legacy pack.json format
4// (no schema_version field) and the dreamwell_waymark_v1.0.0 schema.
5
6use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, HashSet};
8
9use crate::waymark::schema::*;
10
11// =============================================================================
12// Schema version detection
13// =============================================================================
14
15/// Detected schema version of a pack configuration.
16#[derive(Debug, Clone, PartialEq)]
17pub enum SchemaVersion {
18    /// Legacy pack.json format (no schema_version field).
19    Legacy,
20    /// dreamwell_waymark_v1.0.0.
21    V1_0_0,
22    /// Unrecognized schema version string.
23    Unknown(String),
24}
25
26impl std::fmt::Display for SchemaVersion {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            SchemaVersion::Legacy => write!(f, "legacy"),
30            SchemaVersion::V1_0_0 => write!(f, "dreamwell_waymark_v1.0.0"),
31            SchemaVersion::Unknown(v) => write!(f, "{}", v),
32        }
33    }
34}
35
36// =============================================================================
37// Validation result types
38// =============================================================================
39
40/// A validation error that prevents the pack from loading.
41#[derive(Debug, Clone)]
42pub struct PackError {
43    pub code: String,
44    pub message: String,
45    pub field: Option<String>,
46}
47
48impl PackError {
49    fn new(code: &str, message: &str) -> Self {
50        Self {
51            code: code.to_string(),
52            message: message.to_string(),
53            field: None,
54        }
55    }
56
57    fn with_field(code: &str, message: &str, field: &str) -> Self {
58        Self {
59            code: code.to_string(),
60            message: message.to_string(),
61            field: Some(field.to_string()),
62        }
63    }
64}
65
66impl std::fmt::Display for PackError {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        if let Some(ref field) = self.field {
69            write!(f, "[{}] {} (field: {})", self.code, self.message, field)
70        } else {
71            write!(f, "[{}] {}", self.code, self.message)
72        }
73    }
74}
75
76/// A validation warning that does not prevent loading but indicates a potential issue.
77#[derive(Debug, Clone)]
78pub struct PackWarning {
79    pub code: String,
80    pub message: String,
81    pub field: Option<String>,
82}
83
84impl PackWarning {
85    fn new(code: &str, message: &str) -> Self {
86        Self {
87            code: code.to_string(),
88            message: message.to_string(),
89            field: None,
90        }
91    }
92
93    fn with_field(code: &str, message: &str, field: &str) -> Self {
94        Self {
95            code: code.to_string(),
96            message: message.to_string(),
97            field: Some(field.to_string()),
98        }
99    }
100}
101
102impl std::fmt::Display for PackWarning {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        if let Some(ref field) = self.field {
105            write!(f, "[{}] {} (field: {})", self.code, self.message, field)
106        } else {
107            write!(f, "[{}] {}", self.code, self.message)
108        }
109    }
110}
111
112/// Result of validating a pack configuration.
113pub struct PackValidationResult {
114    pub errors: Vec<PackError>,
115    pub warnings: Vec<PackWarning>,
116    pub is_valid: bool,
117    pub schema_version: String,
118    pub pack_id: String,
119}
120
121// =============================================================================
122// Content file schemas
123// =============================================================================
124
125/// Content items definition file (content/items.json).
126#[derive(Debug, Clone, Serialize, Deserialize, Default)]
127pub struct ItemsFile {
128    #[serde(default)]
129    pub items: Vec<ItemDefinition>,
130}
131
132/// A single item definition within an items file.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ItemDefinition {
135    pub id: String,
136    pub name: String,
137    #[serde(default)]
138    pub glyph: String,
139    #[serde(default)]
140    pub item_type: String,
141    #[serde(default)]
142    pub equip_slot: Option<String>,
143    #[serde(default, alias = "slot")]
144    pub slot: Option<String>,
145    #[serde(default)]
146    pub attack: i32,
147    #[serde(default, alias = "attack_bonus")]
148    pub attack_bonus: Option<i32>,
149    #[serde(default)]
150    pub defense: i32,
151    #[serde(default, alias = "defense_bonus")]
152    pub defense_bonus: Option<i32>,
153    #[serde(default)]
154    pub hp_bonus: i32,
155    #[serde(default, alias = "base_value")]
156    pub value: i32,
157    #[serde(default)]
158    pub weight: f32,
159    #[serde(default)]
160    pub rarity: String,
161    #[serde(default)]
162    pub description: String,
163    #[serde(default)]
164    pub tags: Vec<String>,
165    #[serde(default)]
166    pub is_consumable: bool,
167    #[serde(default)]
168    pub use_effect: Option<String>,
169    #[serde(default)]
170    pub use_value: i32,
171    #[serde(default)]
172    pub mana_bonus: i32,
173    #[serde(default)]
174    pub is_two_handed: bool,
175    #[serde(default)]
176    pub grants_spell: bool,
177    #[serde(default)]
178    pub granted_spell_id: Option<String>,
179    #[serde(default)]
180    pub damage_dice: Option<String>,
181    #[serde(default)]
182    pub properties: HashMap<String, serde_json::Value>,
183}
184
185/// Content enemies definition file (content/enemies.json).
186#[derive(Debug, Clone, Serialize, Deserialize, Default)]
187pub struct EnemiesFile {
188    #[serde(default)]
189    pub enemies: Vec<EnemyDefinition>,
190}
191
192/// A single enemy definition within an enemies file.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct EnemyDefinition {
195    pub id: String,
196    pub name: String,
197    #[serde(default)]
198    pub glyph: String,
199    #[serde(default, alias = "max_health")]
200    pub health: i32,
201    #[serde(default)]
202    pub attack: i32,
203    #[serde(default)]
204    pub defense: i32,
205    #[serde(default)]
206    pub speed: i32,
207    #[serde(default)]
208    pub xp_value: i32,
209    #[serde(default)]
210    pub gold_value: i32,
211    #[serde(default)]
212    pub level: u32,
213    #[serde(default)]
214    pub abilities: Vec<String>,
215    #[serde(default)]
216    pub loot_table: String,
217    #[serde(default)]
218    pub behavior: String,
219    #[serde(default)]
220    pub description: String,
221    #[serde(default)]
222    pub max_mana: i32,
223    #[serde(default)]
224    pub innate_ability: Option<String>,
225    #[serde(default)]
226    pub ai_brain: String,
227    #[serde(default)]
228    pub personality: Option<serde_json::Value>,
229    #[serde(default)]
230    pub properties: HashMap<String, serde_json::Value>,
231}
232
233/// Content abilities file (content/abilities.json).
234#[derive(Debug, Clone, Serialize, Deserialize, Default)]
235pub struct AbilitiesFile {
236    #[serde(default)]
237    pub abilities: Vec<AbilityDefinition>,
238}
239
240/// A single ability definition within an abilities file.
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct AbilityDefinition {
243    pub id: String,
244    pub name: String,
245    #[serde(default)]
246    pub school: String,
247    #[serde(default, alias = "damage_base")]
248    pub damage: i32,
249    #[serde(default)]
250    pub mana_cost: i32,
251    #[serde(default)]
252    pub cooldown: u32,
253    #[serde(default)]
254    pub range: i32,
255    #[serde(default)]
256    pub aoe_radius: i32,
257    #[serde(default)]
258    pub damage_type: String,
259    #[serde(default)]
260    pub description: String,
261    #[serde(default)]
262    pub targeting: String,
263    #[serde(default)]
264    pub category: String,
265    #[serde(default)]
266    pub effect_type: String,
267    #[serde(default)]
268    pub effect_duration: u32,
269    #[serde(default)]
270    pub push_distance: i32,
271    #[serde(default)]
272    pub required_weapon_tags: String,
273    #[serde(default)]
274    pub effects: Vec<AbilityEffect>,
275}
276
277/// An effect applied by an ability.
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct AbilityEffect {
280    pub effect_type: String,
281    #[serde(default)]
282    pub duration: u32,
283    #[serde(default)]
284    pub magnitude: i32,
285    #[serde(default)]
286    pub chance: f32,
287}
288
289/// Loot tables file (content/loot_tables.json).
290///
291/// Supports two formats:
292/// - Array format: `{ "tables": [ { "id": "...", "entries": [...] } ] }`
293/// - Object format: `{ "loot_tables": { "table_id": { "entries": [...] } } }`
294#[derive(Debug, Clone, Serialize, Deserialize, Default)]
295pub struct LootTablesFile {
296    #[serde(default)]
297    pub tables: Vec<LootTable>,
298    #[serde(default)]
299    pub loot_tables: HashMap<String, LootTableInline>,
300}
301
302/// A named loot table (array format).
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct LootTable {
305    pub id: String,
306    #[serde(default)]
307    pub entries: Vec<LootEntry>,
308    #[serde(default)]
309    pub picks: u32,
310}
311
312/// An inline loot table (object format, keyed by ID).
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct LootTableInline {
315    #[serde(default)]
316    pub entries: Vec<LootEntry>,
317    #[serde(default)]
318    pub picks: u32,
319}
320
321/// A single entry in a loot table.
322#[derive(Debug, Clone, Serialize, Deserialize)]
323pub struct LootEntry {
324    pub item_id: String,
325    #[serde(default = "default_weight")]
326    pub weight: f32,
327    #[serde(default = "default_quantity_min")]
328    pub quantity_min: u32,
329    #[serde(default = "default_quantity_max")]
330    pub quantity_max: u32,
331}
332
333fn default_weight() -> f32 {
334    1.0
335}
336fn default_quantity_min() -> u32 {
337    1
338}
339fn default_quantity_max() -> u32 {
340    1
341}
342
343/// Economy file (content/economy.json).
344#[derive(Debug, Clone, Serialize, Deserialize, Default)]
345pub struct EconomyFile {
346    #[serde(default)]
347    pub shops: Vec<ShopDefinition>,
348    #[serde(default)]
349    pub currencies: Vec<CurrencyDefinition>,
350    #[serde(default)]
351    pub currency_id: String,
352    #[serde(default)]
353    pub currency_name: String,
354    #[serde(default)]
355    pub starting_gold: i64,
356    #[serde(default = "default_buy_multiplier")]
357    pub default_buy_multiplier: f32,
358    #[serde(default = "default_sell_multiplier")]
359    pub default_sell_multiplier: f32,
360}
361
362fn default_buy_multiplier() -> f32 {
363    1.0
364}
365fn default_sell_multiplier() -> f32 {
366    0.5
367}
368
369/// A shop definition within an economy file.
370#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct ShopDefinition {
372    pub id: String,
373    pub name: String,
374    #[serde(default)]
375    pub items: Vec<ShopItem>,
376    #[serde(default, alias = "inventory")]
377    pub inventory: Vec<ShopItem>,
378    #[serde(default, alias = "buy_multiplier")]
379    pub buy_rate: f32,
380    #[serde(default, alias = "sell_multiplier")]
381    pub sell_rate: f32,
382    #[serde(default)]
383    pub gold: i64,
384    #[serde(default)]
385    pub restocks: bool,
386    #[serde(default)]
387    pub accepts_tags: Vec<String>,
388}
389
390/// An item listing in a shop.
391#[derive(Debug, Clone, Serialize, Deserialize)]
392pub struct ShopItem {
393    pub item_id: String,
394    #[serde(default)]
395    pub stock: i32,
396    #[serde(default)]
397    pub price_override: Option<i64>,
398}
399
400/// A currency type definition.
401#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct CurrencyDefinition {
403    pub id: String,
404    pub name: String,
405    #[serde(default)]
406    pub symbol: String,
407    #[serde(default)]
408    pub decimal_places: u32,
409}
410
411/// Stats customization file (content/stats.json).
412#[derive(Debug, Clone, Serialize, Deserialize, Default)]
413pub struct StatsFile {
414    #[serde(default, alias = "custom_stats")]
415    pub stats: Vec<StatDefinition>,
416    #[serde(default)]
417    pub damage_types: Vec<DamageTypeDefinition>,
418    #[serde(default)]
419    pub status_effects: Vec<StatusEffectDefinition>,
420    #[serde(default)]
421    pub equipment_slots: Vec<EquipmentSlotDefinition>,
422}
423
424/// A custom stat declaration.
425#[derive(Debug, Clone, Serialize, Deserialize)]
426pub struct StatDefinition {
427    pub id: String,
428    pub name: String,
429    #[serde(default)]
430    pub default_value: f32,
431    #[serde(default)]
432    pub min_value: f32,
433    #[serde(default = "default_stat_max")]
434    pub max_value: f32,
435    #[serde(default)]
436    pub category: String,
437    #[serde(default)]
438    pub show_in_ui: bool,
439}
440
441fn default_stat_max() -> f32 {
442    9999.0
443}
444
445/// A damage type declaration.
446#[derive(Debug, Clone, Serialize, Deserialize)]
447pub struct DamageTypeDefinition {
448    pub id: String,
449    pub name: String,
450    #[serde(default)]
451    pub resistance_stat: Option<String>,
452    #[serde(default)]
453    pub color: String,
454}
455
456/// A status effect declaration.
457#[derive(Debug, Clone, Serialize, Deserialize)]
458pub struct StatusEffectDefinition {
459    pub id: String,
460    pub name: String,
461    #[serde(default)]
462    pub stack_mode: String,
463    #[serde(default)]
464    pub max_stacks: u32,
465    #[serde(default)]
466    pub tick_effect: String,
467}
468
469/// An equipment slot declaration from stats.json.
470#[derive(Debug, Clone, Serialize, Deserialize)]
471pub struct EquipmentSlotDefinition {
472    pub id: String,
473    pub name: String,
474    #[serde(default)]
475    pub display_order: u32,
476}
477
478// =============================================================================
479// Valid equip slot constants
480// =============================================================================
481
482/// The set of valid equip slot names recognized by the engine.
483const VALID_EQUIP_SLOTS: &[&str] = &[
484    "Weapon", "Shield", "Head", "Body", "Hands", "Feet", "Ring", "Ring1", "Ring2", "Ring3", "Ring4", "Amulet", "Belt",
485    "Legs", "Trinket", "Back", "Offhand", "Cloak",
486];
487
488/// Maximum allowed grid dimension (width or height).
489const MAX_GRID_DIMENSION: u32 = 1024;
490
491// =============================================================================
492// ID validation
493// =============================================================================
494
495/// Returns true if `id` is non-empty and contains only lowercase alphanumeric characters and
496/// underscores.
497fn is_valid_id(id: &str) -> bool {
498    !id.is_empty()
499        && id
500            .bytes()
501            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
502}
503
504// =============================================================================
505// PackLoader — stateless loader
506// =============================================================================
507
508/// Stateless loader for Waymark content packs.
509///
510/// All methods are associated functions (no `&self`) — create no instance, just call
511/// `PackLoader::load_pack_config(json)`, etc.
512pub struct PackLoader;
513
514impl PackLoader {
515    // -------------------------------------------------------------------------
516    // Version detection
517    // -------------------------------------------------------------------------
518
519    /// Detect schema version from raw JSON value.
520    ///
521    /// Looks for `schema_version` at the top level. If absent, returns `Legacy`.
522    /// If present and equal to `"dreamwell_waymark_v1.0.0"`, returns `V1_0_0`.
523    /// Otherwise returns `Unknown(value)`.
524    pub fn detect_version(json: &serde_json::Value) -> SchemaVersion {
525        match json.get("schema_version").and_then(|v| v.as_str()) {
526            None => SchemaVersion::Legacy,
527            Some("dreamwell_waymark_v1.0.0") => SchemaVersion::V1_0_0,
528            Some(other) => SchemaVersion::Unknown(other.to_string()),
529        }
530    }
531
532    // -------------------------------------------------------------------------
533    // Pack config loading
534    // -------------------------------------------------------------------------
535
536    /// Load a pack configuration from a JSON string.
537    ///
538    /// Detects schema version and deserializes into `DreamwellPackV1`. Legacy packs
539    /// (without `schema_version`) are loaded directly since `DreamwellPackV1` uses
540    /// `serde(default)` on all optional fields.
541    pub fn load_pack_config(json: &str) -> Result<DreamwellPackV1, PackError> {
542        let raw: serde_json::Value =
543            serde_json::from_str(json).map_err(|e| PackError::new("parse_error", &format!("Invalid JSON: {}", e)))?;
544
545        let version = Self::detect_version(&raw);
546
547        match version {
548            SchemaVersion::Legacy | SchemaVersion::V1_0_0 => serde_json::from_value(raw).map_err(|e| {
549                PackError::new(
550                    "deserialize_error",
551                    &format!("Failed to deserialize pack config: {}", e),
552                )
553            }),
554            SchemaVersion::Unknown(ref v) => Err(PackError::with_field(
555                "unknown_schema_version",
556                &format!(
557                    "Unrecognized schema version '{}'. Expected 'dreamwell_waymark_v1.0.0' or omit for legacy.",
558                    v
559                ),
560                "schema_version",
561            )),
562        }
563    }
564
565    // -------------------------------------------------------------------------
566    // Pack validation
567    // -------------------------------------------------------------------------
568
569    /// Validate a loaded pack configuration. Returns errors and warnings.
570    pub fn validate_pack(pack: &DreamwellPackV1) -> PackValidationResult {
571        let mut errors: Vec<PackError> = Vec::new();
572        let mut warnings: Vec<PackWarning> = Vec::new();
573
574        // --- id ---
575        if pack.id.is_empty() {
576            errors.push(PackError::with_field("missing_id", "Pack id must be non-empty.", "id"));
577        } else if !is_valid_id(&pack.id) {
578            errors.push(PackError::with_field(
579                "invalid_id",
580                "Pack id must contain only lowercase alphanumeric characters and underscores.",
581                "id",
582            ));
583        }
584
585        // --- title ---
586        if pack.title.is_empty() {
587            errors.push(PackError::with_field(
588                "missing_title",
589                "Pack title must be non-empty.",
590                "title",
591            ));
592        }
593
594        // --- version (warning only) ---
595        if pack.version.is_empty() {
596            warnings.push(PackWarning::with_field(
597                "missing_version",
598                "Pack version is recommended for pack management.",
599                "version",
600            ));
601        }
602
603        // --- grid dimensions ---
604        {
605            let grid = &pack.grid;
606            if grid.width == 0 {
607                errors.push(PackError::with_field(
608                    "invalid_grid_width",
609                    "Grid width must be greater than 0.",
610                    "grid.width",
611                ));
612            } else if grid.width > MAX_GRID_DIMENSION {
613                errors.push(PackError::with_field(
614                    "grid_width_exceeded",
615                    &format!("Grid width {} exceeds maximum of {}.", grid.width, MAX_GRID_DIMENSION),
616                    "grid.width",
617                ));
618            }
619            if grid.height == 0 {
620                errors.push(PackError::with_field(
621                    "invalid_grid_height",
622                    "Grid height must be greater than 0.",
623                    "grid.height",
624                ));
625            } else if grid.height > MAX_GRID_DIMENSION {
626                errors.push(PackError::with_field(
627                    "grid_height_exceeded",
628                    &format!("Grid height {} exceeds maximum of {}.", grid.height, MAX_GRID_DIMENSION),
629                    "grid.height",
630                ));
631            }
632        }
633
634        // --- equip_slots ---
635        for (i, slot) in pack.equip_slots.iter().enumerate() {
636            if !VALID_EQUIP_SLOTS.contains(&slot.as_str()) {
637                warnings.push(PackWarning::with_field(
638                    "unknown_equip_slot",
639                    &format!("Equip slot '{}' at index {} is not in the recognized set.", slot, i),
640                    "equip_slots",
641                ));
642            }
643        }
644
645        // --- scenario entry point (warning) ---
646        if pack.scenario.is_none() && pack.scenario_script.is_none() {
647            warnings.push(PackWarning::new(
648                "no_entry_point",
649                "Neither 'scenario' nor 'scenario_script' specified. Pack has no entry point.",
650            ));
651        }
652
653        // --- props ---
654        {
655            let mut prop_ids: HashSet<&str> = HashSet::new();
656            for (i, prop) in pack.props.iter().enumerate() {
657                let field_prefix = format!("props[{}]", i);
658
659                if prop.id.is_empty() {
660                    errors.push(PackError::with_field(
661                        "missing_prop_id",
662                        &format!("Prop at index {} has an empty id.", i),
663                        &format!("{}.id", field_prefix),
664                    ));
665                } else if !is_valid_id(&prop.id) {
666                    errors.push(PackError::with_field(
667                        "invalid_prop_id",
668                        &format!(
669                            "Prop id '{}' must contain only lowercase alphanumeric characters and underscores.",
670                            prop.id
671                        ),
672                        &format!("{}.id", field_prefix),
673                    ));
674                } else if !prop_ids.insert(&prop.id) {
675                    errors.push(PackError::with_field(
676                        "duplicate_prop_id",
677                        &format!("Duplicate prop id '{}'.", prop.id),
678                        &format!("{}.id", field_prefix),
679                    ));
680                }
681
682                // Validate states references
683                if let Some(ref default_state) = prop.default_state {
684                    if !prop.states.is_empty() {
685                        if !prop.states.contains_key(default_state) {
686                            errors.push(PackError::with_field(
687                                "invalid_default_state",
688                                &format!(
689                                    "Prop '{}' default_state '{}' does not match any key in states.",
690                                    prop.id, default_state
691                                ),
692                                &format!("{}.default_state", field_prefix),
693                            ));
694                        }
695                    } else {
696                        warnings.push(PackWarning::with_field(
697                            "default_state_without_states",
698                            &format!(
699                                "Prop '{}' has default_state '{}' but no states map defined.",
700                                prop.id, default_state
701                            ),
702                            &format!("{}.default_state", field_prefix),
703                        ));
704                    }
705                }
706
707                // Validate state on_interact references
708                {
709                    let states = &prop.states;
710                    for (state_key, state_def) in states {
711                        if let Some(ref on_interact) = state_def.on_interact {
712                            if !states.contains_key(on_interact) {
713                                // on_interact can reference either another state or an action name,
714                                // so only warn rather than error.
715                                warnings.push(PackWarning::with_field(
716                                    "on_interact_target_missing",
717                                    &format!(
718                                        "Prop '{}' state '{}' on_interact '{}' does not match any sibling state.",
719                                        prop.id, state_key, on_interact
720                                    ),
721                                    &format!("{}.states.{}.on_interact", field_prefix, state_key),
722                                ));
723                            }
724                        }
725                        if let Some(ref on_secondary) = state_def.on_secondary_interact {
726                            if !states.contains_key(on_secondary) {
727                                warnings.push(PackWarning::with_field(
728                                    "on_secondary_interact_target_missing",
729                                    &format!(
730                                        "Prop '{}' state '{}' on_secondary_interact '{}' does not match any sibling state.",
731                                        prop.id, state_key, on_secondary
732                                    ),
733                                    &format!(
734                                        "{}.states.{}.on_secondary_interact",
735                                        field_prefix, state_key
736                                    ),
737                                ));
738                            }
739                        }
740                    }
741                }
742
743                // Warn on container config without container feature
744                if prop.has_inventory && prop.loot_table.is_none() {
745                    warnings.push(PackWarning::with_field(
746                        "container_no_loot_table",
747                        &format!("Prop '{}' has has_inventory=true but no loot_table specified.", prop.id),
748                        &format!("{}.loot_table", field_prefix),
749                    ));
750                }
751            }
752        }
753
754        // --- eviction config ---
755        {
756            let eviction = &pack.eviction;
757            if eviction.max_cached == 0 {
758                warnings.push(PackWarning::with_field(
759                    "zero_max_cached",
760                    "Eviction max_cached is 0, which means no maps will be cached.",
761                    "eviction.max_cached",
762                ));
763            }
764        }
765
766        let schema_version = pack.schema_version.clone();
767        let pack_id = pack.id.clone();
768        let is_valid = errors.is_empty();
769
770        PackValidationResult {
771            errors,
772            warnings,
773            is_valid,
774            schema_version,
775            pack_id,
776        }
777    }
778
779    // -------------------------------------------------------------------------
780    // Content file loaders
781    // -------------------------------------------------------------------------
782
783    /// Load and parse an items file from JSON.
784    pub fn load_items(json: &str) -> Result<ItemsFile, PackError> {
785        serde_json::from_str(json)
786            .map_err(|e| PackError::new("items_parse_error", &format!("Failed to parse items file: {}", e)))
787    }
788
789    /// Load and parse an enemies file from JSON.
790    pub fn load_enemies(json: &str) -> Result<EnemiesFile, PackError> {
791        serde_json::from_str(json)
792            .map_err(|e| PackError::new("enemies_parse_error", &format!("Failed to parse enemies file: {}", e)))
793    }
794
795    /// Load and parse an abilities file from JSON.
796    pub fn load_abilities(json: &str) -> Result<AbilitiesFile, PackError> {
797        serde_json::from_str(json).map_err(|e| {
798            PackError::new(
799                "abilities_parse_error",
800                &format!("Failed to parse abilities file: {}", e),
801            )
802        })
803    }
804
805    /// Load and parse a loot tables file from JSON.
806    ///
807    /// Supports both the array format (`"tables": [...]`) and the object format
808    /// (`"loot_tables": { "id": { ... } }`). When the object format is detected,
809    /// entries are normalized into the `tables` vec.
810    pub fn load_loot_tables(json: &str) -> Result<LootTablesFile, PackError> {
811        let mut file: LootTablesFile = serde_json::from_str(json).map_err(|e| {
812            PackError::new(
813                "loot_tables_parse_error",
814                &format!("Failed to parse loot tables file: {}", e),
815            )
816        })?;
817
818        // Normalize object format into the tables vec.
819        if file.tables.is_empty() && !file.loot_tables.is_empty() {
820            for (id, inline) in &file.loot_tables {
821                file.tables.push(LootTable {
822                    id: id.clone(),
823                    entries: inline.entries.clone(),
824                    picks: inline.picks,
825                });
826            }
827        }
828
829        Ok(file)
830    }
831
832    /// Load and parse an economy file from JSON.
833    pub fn load_economy(json: &str) -> Result<EconomyFile, PackError> {
834        serde_json::from_str(json)
835            .map_err(|e| PackError::new("economy_parse_error", &format!("Failed to parse economy file: {}", e)))
836    }
837
838    /// Load and parse a stats file from JSON.
839    pub fn load_stats(json: &str) -> Result<StatsFile, PackError> {
840        serde_json::from_str(json)
841            .map_err(|e| PackError::new("stats_parse_error", &format!("Failed to parse stats file: {}", e)))
842    }
843
844    // -------------------------------------------------------------------------
845    // Content cross-reference validation
846    // -------------------------------------------------------------------------
847
848    /// Validate content cross-references across multiple content files.
849    ///
850    /// Checks:
851    /// - Loot table `item_id` references exist in items.
852    /// - Enemy `loot_table` references exist in loot tables.
853    /// - Enemy `abilities` references exist in abilities.
854    /// - Enemy `innate_ability` references exist in abilities.
855    /// - Loot entry weights are positive.
856    /// - Item IDs are unique.
857    /// - Enemy IDs are unique.
858    /// - Ability IDs are unique.
859    /// - Loot table IDs are unique.
860    pub fn validate_content_refs(
861        items: &ItemsFile,
862        enemies: &EnemiesFile,
863        abilities: &AbilitiesFile,
864        loot_tables: &LootTablesFile,
865    ) -> Vec<PackWarning> {
866        let mut warnings: Vec<PackWarning> = Vec::new();
867
868        // Build lookup sets.
869        let mut item_ids: HashSet<&str> = HashSet::new();
870        for item in &items.items {
871            if item.id.is_empty() {
872                warnings.push(PackWarning::with_field(
873                    "empty_item_id",
874                    "Item has an empty id.",
875                    "items",
876                ));
877            } else if !item_ids.insert(&item.id) {
878                warnings.push(PackWarning::with_field(
879                    "duplicate_item_id",
880                    &format!("Duplicate item id '{}'.", item.id),
881                    "items",
882                ));
883            }
884        }
885
886        let mut ability_ids: HashSet<&str> = HashSet::new();
887        for ability in &abilities.abilities {
888            if ability.id.is_empty() {
889                warnings.push(PackWarning::with_field(
890                    "empty_ability_id",
891                    "Ability has an empty id.",
892                    "abilities",
893                ));
894            } else if !ability_ids.insert(&ability.id) {
895                warnings.push(PackWarning::with_field(
896                    "duplicate_ability_id",
897                    &format!("Duplicate ability id '{}'.", ability.id),
898                    "abilities",
899                ));
900            }
901        }
902
903        let mut loot_table_ids: HashSet<&str> = HashSet::new();
904        for table in &loot_tables.tables {
905            if table.id.is_empty() {
906                warnings.push(PackWarning::with_field(
907                    "empty_loot_table_id",
908                    "Loot table has an empty id.",
909                    "loot_tables",
910                ));
911            } else if !loot_table_ids.insert(&table.id) {
912                warnings.push(PackWarning::with_field(
913                    "duplicate_loot_table_id",
914                    &format!("Duplicate loot table id '{}'.", table.id),
915                    "loot_tables",
916                ));
917            }
918        }
919
920        let mut enemy_ids: HashSet<&str> = HashSet::new();
921        for enemy in &enemies.enemies {
922            if enemy.id.is_empty() {
923                warnings.push(PackWarning::with_field(
924                    "empty_enemy_id",
925                    "Enemy has an empty id.",
926                    "enemies",
927                ));
928            } else if !enemy_ids.insert(&enemy.id) {
929                warnings.push(PackWarning::with_field(
930                    "duplicate_enemy_id",
931                    &format!("Duplicate enemy id '{}'.", enemy.id),
932                    "enemies",
933                ));
934            }
935        }
936
937        // Validate loot table item references.
938        for table in &loot_tables.tables {
939            for (i, entry) in table.entries.iter().enumerate() {
940                if !item_ids.contains(entry.item_id.as_str()) {
941                    warnings.push(PackWarning::with_field(
942                        "loot_item_not_found",
943                        &format!(
944                            "Loot table '{}' entry {} references item '{}' which does not exist in items.",
945                            table.id, i, entry.item_id
946                        ),
947                        &format!("loot_tables.{}.entries[{}].item_id", table.id, i),
948                    ));
949                }
950                if entry.weight <= 0.0 {
951                    warnings.push(PackWarning::with_field(
952                        "invalid_loot_weight",
953                        &format!(
954                            "Loot table '{}' entry {} has non-positive weight {}.",
955                            table.id, i, entry.weight
956                        ),
957                        &format!("loot_tables.{}.entries[{}].weight", table.id, i),
958                    ));
959                }
960                if entry.quantity_min > entry.quantity_max {
961                    warnings.push(PackWarning::with_field(
962                        "invalid_loot_quantity",
963                        &format!(
964                            "Loot table '{}' entry {} has quantity_min ({}) > quantity_max ({}).",
965                            table.id, i, entry.quantity_min, entry.quantity_max
966                        ),
967                        &format!("loot_tables.{}.entries[{}]", table.id, i),
968                    ));
969                }
970            }
971        }
972
973        // Validate enemy ability references.
974        for enemy in &enemies.enemies {
975            for ability_ref in &enemy.abilities {
976                if !ability_ids.contains(ability_ref.as_str()) {
977                    warnings.push(PackWarning::with_field(
978                        "enemy_ability_not_found",
979                        &format!(
980                            "Enemy '{}' references ability '{}' which does not exist in abilities.",
981                            enemy.id, ability_ref
982                        ),
983                        &format!("enemies.{}.abilities", enemy.id),
984                    ));
985                }
986            }
987
988            if let Some(ref innate) = enemy.innate_ability {
989                if !ability_ids.contains(innate.as_str()) {
990                    warnings.push(PackWarning::with_field(
991                        "enemy_innate_ability_not_found",
992                        &format!(
993                            "Enemy '{}' references innate_ability '{}' which does not exist in abilities.",
994                            enemy.id, innate
995                        ),
996                        &format!("enemies.{}.innate_ability", enemy.id),
997                    ));
998                }
999            }
1000
1001            // Validate enemy loot_table reference.
1002            if !enemy.loot_table.is_empty() && !loot_table_ids.contains(enemy.loot_table.as_str()) {
1003                warnings.push(PackWarning::with_field(
1004                    "enemy_loot_table_not_found",
1005                    &format!(
1006                        "Enemy '{}' references loot_table '{}' which does not exist.",
1007                        enemy.id, enemy.loot_table
1008                    ),
1009                    &format!("enemies.{}.loot_table", enemy.id),
1010                ));
1011            }
1012        }
1013
1014        // Validate ability effects.
1015        for ability in &abilities.abilities {
1016            for (i, effect) in ability.effects.iter().enumerate() {
1017                if effect.effect_type.is_empty() {
1018                    warnings.push(PackWarning::with_field(
1019                        "empty_effect_type",
1020                        &format!(
1021                            "Ability '{}' effect at index {} has an empty effect_type.",
1022                            ability.id, i
1023                        ),
1024                        &format!("abilities.{}.effects[{}].effect_type", ability.id, i),
1025                    ));
1026                }
1027                if (effect.chance < 0.0 || effect.chance > 1.0) && effect.chance != 0.0 {
1028                    // 0.0 is the serde default
1029                    warnings.push(PackWarning::with_field(
1030                        "invalid_effect_chance",
1031                        &format!(
1032                            "Ability '{}' effect at index {} has chance {} outside [0.0, 1.0].",
1033                            ability.id, i, effect.chance
1034                        ),
1035                        &format!("abilities.{}.effects[{}].chance", ability.id, i),
1036                    ));
1037                }
1038            }
1039        }
1040
1041        // Validate item stat ranges.
1042        for item in &items.items {
1043            if item.weight < 0.0 {
1044                warnings.push(PackWarning::with_field(
1045                    "negative_item_weight",
1046                    &format!("Item '{}' has negative weight {}.", item.id, item.weight),
1047                    &format!("items.{}.weight", item.id),
1048                ));
1049            }
1050        }
1051
1052        warnings
1053    }
1054
1055    // -------------------------------------------------------------------------
1056    // Prop validation
1057    // -------------------------------------------------------------------------
1058
1059    /// Validate all prop definitions in a pack.
1060    ///
1061    /// Checks:
1062    /// - `id` is valid (non-empty, lowercase alphanumeric + underscore).
1063    /// - No duplicate `id` values.
1064    /// - `default_state` references an existing state key.
1065    /// - State `on_interact` and `on_secondary_interact` reference valid state keys (warning).
1066    /// - Props with `has_secondary_state` have states defined.
1067    pub fn validate_props(props: &[PropDefinition]) -> Vec<PackWarning> {
1068        let mut warnings: Vec<PackWarning> = Vec::new();
1069        let mut seen_ids: HashSet<&str> = HashSet::new();
1070
1071        for (i, prop) in props.iter().enumerate() {
1072            let field_prefix = format!("props[{}]", i);
1073
1074            // ID validation.
1075            if prop.id.is_empty() {
1076                warnings.push(PackWarning::with_field(
1077                    "empty_prop_id",
1078                    &format!("Prop at index {} has an empty id.", i),
1079                    &format!("{}.id", field_prefix),
1080                ));
1081            } else if !is_valid_id(&prop.id) {
1082                warnings.push(PackWarning::with_field(
1083                    "invalid_prop_id",
1084                    &format!(
1085                        "Prop id '{}' should contain only lowercase alphanumeric characters and underscores.",
1086                        prop.id
1087                    ),
1088                    &format!("{}.id", field_prefix),
1089                ));
1090            } else if !seen_ids.insert(&prop.id) {
1091                warnings.push(PackWarning::with_field(
1092                    "duplicate_prop_id",
1093                    &format!("Duplicate prop id '{}'.", prop.id),
1094                    &format!("{}.id", field_prefix),
1095                ));
1096            }
1097
1098            // default_state validation.
1099            if let Some(ref default_state) = prop.default_state {
1100                if !prop.states.is_empty() {
1101                    if !prop.states.contains_key(default_state) {
1102                        warnings.push(PackWarning::with_field(
1103                            "invalid_default_state",
1104                            &format!(
1105                                "Prop '{}' default_state '{}' does not match any key in states.",
1106                                prop.id, default_state
1107                            ),
1108                            &format!("{}.default_state", field_prefix),
1109                        ));
1110                    }
1111                } else {
1112                    warnings.push(PackWarning::with_field(
1113                        "default_state_without_states",
1114                        &format!(
1115                            "Prop '{}' has default_state '{}' but no states map defined.",
1116                            prop.id, default_state
1117                        ),
1118                        &format!("{}.default_state", field_prefix),
1119                    ));
1120                }
1121            }
1122
1123            // has_secondary_state without states.
1124            if prop.has_secondary_state && prop.states.is_empty() {
1125                warnings.push(PackWarning::with_field(
1126                    "secondary_state_without_states",
1127                    &format!("Prop '{}' has has_secondary_state=true but no states defined.", prop.id),
1128                    &format!("{}.has_secondary_state", field_prefix),
1129                ));
1130            }
1131
1132            // State transition validation.
1133            {
1134                let states = &prop.states;
1135                for (state_key, state_def) in states {
1136                    if let Some(ref on_interact) = state_def.on_interact {
1137                        if !states.contains_key(on_interact) {
1138                            warnings.push(PackWarning::with_field(
1139                                "on_interact_target_missing",
1140                                &format!(
1141                                    "Prop '{}' state '{}' on_interact '{}' does not match any sibling state.",
1142                                    prop.id, state_key, on_interact
1143                                ),
1144                                &format!("{}.states.{}.on_interact", field_prefix, state_key),
1145                            ));
1146                        }
1147                    }
1148
1149                    if let Some(ref on_secondary) = state_def.on_secondary_interact {
1150                        if !states.contains_key(on_secondary) {
1151                            warnings.push(PackWarning::with_field(
1152                                "on_secondary_interact_target_missing",
1153                                &format!(
1154                                    "Prop '{}' state '{}' on_secondary_interact '{}' does not match any sibling state.",
1155                                    prop.id, state_key, on_secondary
1156                                ),
1157                                &format!("{}.states.{}.on_secondary_interact", field_prefix, state_key),
1158                            ));
1159                        }
1160                    }
1161
1162                    // Glyph validation: warn if empty.
1163                    if let Some(ref glyph) = state_def.glyph {
1164                        if glyph.is_empty() {
1165                            warnings.push(PackWarning::with_field(
1166                                "empty_state_glyph",
1167                                &format!("Prop '{}' state '{}' has an empty glyph.", prop.id, state_key),
1168                                &format!("{}.states.{}.glyph", field_prefix, state_key),
1169                            ));
1170                        }
1171                    }
1172                }
1173            }
1174
1175            // Glyph validation: warn if empty on the prop itself.
1176            if prop.glyph.as_deref().is_none_or(|g| g.is_empty()) {
1177                warnings.push(PackWarning::with_field(
1178                    "empty_prop_glyph",
1179                    &format!("Prop '{}' has an empty glyph.", prop.id),
1180                    &format!("{}.glyph", field_prefix),
1181                ));
1182            }
1183        }
1184
1185        warnings
1186    }
1187}
1188
1189// =============================================================================
1190// Scene loading from pack (runtime / editor preview)
1191// =============================================================================
1192
1193/// Result of converting a waymark pack to a runtime scene.
1194pub struct LoadedScene {
1195    /// Game objects extracted from pack topology.
1196    pub objects: crate::game_object::GameObjectScene,
1197    /// World seed from the pack (0 if unspecified).
1198    pub seed: u64,
1199}
1200
1201/// Convert a validated `DreamwellPackV1` into a `LoadedScene` suitable for runtime.
1202///
1203/// Extracts topology definitions (worlds, areas, locations) and creates
1204/// waypoint/trigger game objects from them. This is a lightweight conversion
1205/// that does not require SpacetimeDB — it builds a local GameObjectScene
1206/// from pack content for preview and offline play.
1207pub fn load_pack_to_scene(pack: &DreamwellPackV1) -> LoadedScene {
1208    use crate::game_object::{GameObjectScene, PrimitiveKind};
1209
1210    let name = if pack.title.is_empty() {
1211        pack.id.clone()
1212    } else {
1213        pack.title.clone()
1214    };
1215    let mut scene = GameObjectScene::new(name);
1216
1217    let topo = &pack.topology;
1218
1219    // Create a ground plane for each area.
1220    for (i, area) in topo.areas.iter().enumerate() {
1221        let area_name = area.name.clone();
1222        if let Ok(id) = scene.spawn_primitive(area_name, PrimitiveKind::Plane) {
1223            if let Some(obj) = scene.find_mut(id) {
1224                obj.transform.scale = [10.0, 1.0, 10.0];
1225                obj.transform.position = [i as f32 * 20.0, 0.0, 0.0];
1226            }
1227        }
1228    }
1229
1230    // Create waypoint markers for locations.
1231    for (i, loc) in topo.locations.iter().enumerate() {
1232        let loc_name = loc.name.clone();
1233        if let Ok(id) = scene.spawn_primitive(loc_name, PrimitiveKind::Sphere) {
1234            if let Some(obj) = scene.find_mut(id) {
1235                obj.transform.scale = [0.5, 0.5, 0.5];
1236                obj.transform.position = [i as f32 * 3.0, 0.5, 0.0];
1237            }
1238        }
1239    }
1240
1241    // If topology yielded nothing, create a minimal scene.
1242    if scene.is_empty() {
1243        let _ = scene.spawn_primitive("Ground".into(), PrimitiveKind::Plane);
1244    }
1245
1246    LoadedScene {
1247        objects: scene,
1248        seed: 0,
1249    }
1250}
1251
1252// =============================================================================
1253// Tests
1254// =============================================================================
1255
1256#[cfg(test)]
1257mod tests {
1258    use super::*;
1259
1260    // -------------------------------------------------------------------------
1261    // Version detection
1262    // -------------------------------------------------------------------------
1263
1264    #[test]
1265    fn detect_version_legacy_no_schema_version() {
1266        let json = r#"{"id": "test_pack", "title": "Test Pack"}"#;
1267        let raw: serde_json::Value = serde_json::from_str(json).unwrap();
1268        assert_eq!(PackLoader::detect_version(&raw), SchemaVersion::Legacy);
1269    }
1270
1271    #[test]
1272    fn detect_version_v1() {
1273        let json = r#"{"schema_version": "dreamwell_waymark_v1.0.0", "id": "v1"}"#;
1274        let raw: serde_json::Value = serde_json::from_str(json).unwrap();
1275        assert_eq!(PackLoader::detect_version(&raw), SchemaVersion::V1_0_0);
1276    }
1277
1278    #[test]
1279    fn detect_version_unknown() {
1280        let json = r#"{"schema_version": "future_v99.0.0"}"#;
1281        let raw: serde_json::Value = serde_json::from_str(json).unwrap();
1282        assert_eq!(
1283            PackLoader::detect_version(&raw),
1284            SchemaVersion::Unknown("future_v99.0.0".to_string())
1285        );
1286    }
1287
1288    // -------------------------------------------------------------------------
1289    // ID validation
1290    // -------------------------------------------------------------------------
1291
1292    #[test]
1293    fn is_valid_id_accepts_lowercase_alphanumeric_underscore() {
1294        assert!(is_valid_id("test_pack"));
1295        assert!(is_valid_id("my_pack_123"));
1296        assert!(is_valid_id("a"));
1297    }
1298
1299    #[test]
1300    fn is_valid_id_rejects_empty_uppercase_hyphen_space_dot() {
1301        assert!(!is_valid_id(""));
1302        assert!(!is_valid_id("Test_Pack"));
1303        assert!(!is_valid_id("test-pack"));
1304        assert!(!is_valid_id("test pack"));
1305        assert!(!is_valid_id("test.pack"));
1306    }
1307
1308    // -------------------------------------------------------------------------
1309    // load_pack_config
1310    // -------------------------------------------------------------------------
1311
1312    #[test]
1313    fn load_pack_config_invalid_json_returns_parse_error() {
1314        let result = PackLoader::load_pack_config("not json");
1315        assert!(result.is_err());
1316        assert_eq!(result.unwrap_err().code, "parse_error");
1317    }
1318
1319    #[test]
1320    fn load_pack_config_unknown_schema_version_returns_error() {
1321        let json = r#"{"schema_version": "nope", "id": "x", "title": "X"}"#;
1322        let result = PackLoader::load_pack_config(json);
1323        assert!(result.is_err());
1324        assert_eq!(result.unwrap_err().code, "unknown_schema_version");
1325    }
1326
1327    #[test]
1328    fn load_pack_config_legacy_format_succeeds() {
1329        // Legacy packs have no schema_version field.
1330        let json = r#"{"id": "my_pack", "title": "My Pack"}"#;
1331        let result = PackLoader::load_pack_config(json);
1332        assert!(result.is_ok());
1333        let pack = result.unwrap();
1334        assert_eq!(pack.id, "my_pack");
1335        assert_eq!(pack.title, "My Pack");
1336    }
1337
1338    #[test]
1339    fn load_pack_config_v1_format_succeeds() {
1340        let json = r#"{
1341            "schema_version": "dreamwell_waymark_v1.0.0",
1342            "id": "v1_pack",
1343            "title": "V1 Pack",
1344            "version": "1.0.0"
1345        }"#;
1346        let result = PackLoader::load_pack_config(json);
1347        assert!(result.is_ok());
1348        let pack = result.unwrap();
1349        assert_eq!(pack.id, "v1_pack");
1350    }
1351
1352    // -------------------------------------------------------------------------
1353    // validate_pack
1354    // -------------------------------------------------------------------------
1355
1356    #[test]
1357    fn validate_pack_missing_id_produces_error() {
1358        let pack = DreamwellPackV1 {
1359            id: String::new(),
1360            title: "Has Title".to_string(),
1361            ..Default::default()
1362        };
1363        let result = PackLoader::validate_pack(&pack);
1364        assert!(!result.is_valid);
1365        assert!(result.errors.iter().any(|e| e.code == "missing_id"));
1366    }
1367
1368    #[test]
1369    fn validate_pack_missing_title_produces_error() {
1370        let pack = DreamwellPackV1 {
1371            id: "valid_id".to_string(),
1372            title: String::new(),
1373            ..Default::default()
1374        };
1375        let result = PackLoader::validate_pack(&pack);
1376        assert!(!result.is_valid);
1377        assert!(result.errors.iter().any(|e| e.code == "missing_title"));
1378    }
1379
1380    #[test]
1381    fn validate_pack_zero_grid_width_produces_error() {
1382        let json = r#"{
1383            "id": "test", "title": "Test",
1384            "grid": {"width": 0, "height": 50}
1385        }"#;
1386        let pack: DreamwellPackV1 = serde_json::from_str(json).unwrap();
1387        let result = PackLoader::validate_pack(&pack);
1388        assert!(!result.is_valid);
1389        assert!(result.errors.iter().any(|e| e.code == "invalid_grid_width"));
1390    }
1391
1392    #[test]
1393    fn validate_pack_exceeds_max_grid_height_produces_error() {
1394        let json = r#"{
1395            "id": "test", "title": "Test",
1396            "grid": {"width": 80, "height": 2000}
1397        }"#;
1398        let pack: DreamwellPackV1 = serde_json::from_str(json).unwrap();
1399        let result = PackLoader::validate_pack(&pack);
1400        assert!(!result.is_valid);
1401        assert!(result.errors.iter().any(|e| e.code == "grid_height_exceeded"));
1402    }
1403
1404    #[test]
1405    fn validate_pack_unknown_equip_slot_is_warning_not_error() {
1406        let json = r#"{
1407            "id": "test", "title": "Test",
1408            "equip_slots": ["weapon", "jetpack_illegal"]
1409        }"#;
1410        let pack: DreamwellPackV1 = serde_json::from_str(json).unwrap();
1411        let result = PackLoader::validate_pack(&pack);
1412        // unknown slot is a warning only
1413        assert!(result.warnings.iter().any(|w| w.code == "unknown_equip_slot"));
1414    }
1415
1416    #[test]
1417    fn validate_pack_valid_pack_is_valid() {
1418        let pack = DreamwellPackV1 {
1419            id: "good_pack".to_string(),
1420            title: "Good Pack".to_string(),
1421            version: "1.0.0".to_string(),
1422            ..Default::default()
1423        };
1424        let result = PackLoader::validate_pack(&pack);
1425        assert!(result.is_valid);
1426    }
1427
1428    // -------------------------------------------------------------------------
1429    // load_items
1430    // -------------------------------------------------------------------------
1431
1432    #[test]
1433    fn load_items_from_wrapper_format() {
1434        let json = r#"{
1435            "items": [
1436                {"id": "sword", "name": "Iron Sword"},
1437                {"id": "potion", "name": "Health Potion"}
1438            ]
1439        }"#;
1440        let items = PackLoader::load_items(json).unwrap();
1441        assert_eq!(items.items.len(), 2);
1442        assert_eq!(items.items[0].id, "sword");
1443        assert_eq!(items.items[1].id, "potion");
1444    }
1445
1446    #[test]
1447    fn load_items_empty_array() {
1448        let json = r#"{"items": []}"#;
1449        let items = PackLoader::load_items(json).unwrap();
1450        assert_eq!(items.items.len(), 0);
1451    }
1452
1453    #[test]
1454    fn load_items_invalid_json_returns_error() {
1455        let result = PackLoader::load_items("not json");
1456        assert!(result.is_err());
1457        assert_eq!(result.unwrap_err().code, "items_parse_error");
1458    }
1459
1460    // -------------------------------------------------------------------------
1461    // load_enemies
1462    // -------------------------------------------------------------------------
1463
1464    #[test]
1465    fn load_enemies_from_wrapper_format() {
1466        let json = r#"{
1467            "enemies": [
1468                {"id": "goblin", "name": "Goblin", "health": 10, "attack": 3}
1469            ]
1470        }"#;
1471        let enemies = PackLoader::load_enemies(json).unwrap();
1472        assert_eq!(enemies.enemies.len(), 1);
1473        assert_eq!(enemies.enemies[0].id, "goblin");
1474        assert_eq!(enemies.enemies[0].health, 10);
1475    }
1476
1477    // -------------------------------------------------------------------------
1478    // load_abilities
1479    // -------------------------------------------------------------------------
1480
1481    #[test]
1482    fn load_abilities_from_wrapper_format() {
1483        let json = r#"{
1484            "abilities": [
1485                {"id": "fireball", "name": "Fireball"}
1486            ]
1487        }"#;
1488        let abilities = PackLoader::load_abilities(json).unwrap();
1489        assert_eq!(abilities.abilities.len(), 1);
1490        assert_eq!(abilities.abilities[0].id, "fireball");
1491    }
1492
1493    // -------------------------------------------------------------------------
1494    // load_loot_tables
1495    // -------------------------------------------------------------------------
1496
1497    #[test]
1498    fn load_loot_tables_array_format() {
1499        let json = r#"{
1500            "tables": [
1501                {"id": "common", "entries": [{"item_id": "potion", "weight": 10.0}]}
1502            ]
1503        }"#;
1504        let tables = PackLoader::load_loot_tables(json).unwrap();
1505        assert_eq!(tables.tables.len(), 1);
1506        assert_eq!(tables.tables[0].id, "common");
1507    }
1508
1509    #[test]
1510    fn load_loot_tables_object_format() {
1511        let json = r#"{
1512            "loot_tables": {
1513                "chest_common": {
1514                    "picks": 2,
1515                    "entries": [
1516                        {"item_id": "potion", "weight": 10.0},
1517                        {"item_id": "gold_coin", "weight": 5.0}
1518                    ]
1519                }
1520            }
1521        }"#;
1522        let tables = PackLoader::load_loot_tables(json).unwrap();
1523        assert_eq!(tables.tables.len(), 1);
1524        assert_eq!(tables.tables[0].id, "chest_common");
1525        assert_eq!(tables.tables[0].entries.len(), 2);
1526    }
1527
1528    // -------------------------------------------------------------------------
1529    // load_economy
1530    // -------------------------------------------------------------------------
1531
1532    #[test]
1533    fn load_economy_basic() {
1534        let json = r#"{
1535            "currency_id": "gold",
1536            "currency_name": "Gold",
1537            "shops": [{"id": "blacksmith", "name": "The Forge"}]
1538        }"#;
1539        let economy = PackLoader::load_economy(json).unwrap();
1540        assert_eq!(economy.currency_id, "gold");
1541        assert_eq!(economy.shops.len(), 1);
1542    }
1543
1544    // -------------------------------------------------------------------------
1545    // load_stats
1546    // -------------------------------------------------------------------------
1547
1548    #[test]
1549    fn load_stats_basic() {
1550        let json = r#"{
1551            "stats": [{"id": "strength", "name": "Strength"}],
1552            "damage_types": [{"id": "fire", "name": "Fire"}],
1553            "status_effects": []
1554        }"#;
1555        let stats = PackLoader::load_stats(json).unwrap();
1556        assert_eq!(stats.stats.len(), 1);
1557        assert_eq!(stats.damage_types.len(), 1);
1558    }
1559
1560    // -------------------------------------------------------------------------
1561    // validate_content_refs — via JSON deserialization to avoid field init
1562    // -------------------------------------------------------------------------
1563
1564    #[test]
1565    fn validate_content_refs_missing_loot_item_produces_warning() {
1566        let items: ItemsFile = serde_json::from_str(r#"{"items": [{"id": "sword", "name": "Sword"}]}"#).unwrap();
1567        let enemies: EnemiesFile = serde_json::from_str(r#"{"enemies": []}"#).unwrap();
1568        let abilities: AbilitiesFile = serde_json::from_str(r#"{"abilities": []}"#).unwrap();
1569        let loot_tables: LootTablesFile = serde_json::from_str(
1570            r#"{
1571            "tables": [{"id": "common", "entries": [
1572                {"item_id": "sword", "weight": 1.0},
1573                {"item_id": "nonexistent", "weight": 1.0}
1574            ]}]
1575        }"#,
1576        )
1577        .unwrap();
1578        let warnings = PackLoader::validate_content_refs(&items, &enemies, &abilities, &loot_tables);
1579        assert!(warnings.iter().any(|w| w.code == "loot_item_not_found"));
1580        assert!(!warnings
1581            .iter()
1582            .any(|w| w.code == "loot_item_not_found" && w.message.contains("sword")));
1583    }
1584
1585    #[test]
1586    fn validate_content_refs_missing_enemy_ability_produces_warning() {
1587        let items: ItemsFile = serde_json::from_str(r#"{"items": []}"#).unwrap();
1588        let enemies: EnemiesFile = serde_json::from_str(
1589            r#"{
1590            "enemies": [{"id": "goblin", "name": "Goblin", "health": 10, "attack": 3,
1591                         "abilities": ["slash", "missing_skill"]}]
1592        }"#,
1593        )
1594        .unwrap();
1595        let abilities: AbilitiesFile = serde_json::from_str(
1596            r#"{
1597            "abilities": [{"id": "slash", "name": "Slash"}]
1598        }"#,
1599        )
1600        .unwrap();
1601        let loot_tables: LootTablesFile = serde_json::from_str(r#"{"tables": []}"#).unwrap();
1602        let warnings = PackLoader::validate_content_refs(&items, &enemies, &abilities, &loot_tables);
1603        assert!(warnings
1604            .iter()
1605            .any(|w| w.code == "enemy_ability_not_found" && w.message.contains("missing_skill")));
1606        assert!(!warnings
1607            .iter()
1608            .any(|w| w.code == "enemy_ability_not_found" && w.message.contains("slash")));
1609    }
1610
1611    #[test]
1612    fn validate_content_refs_duplicate_item_ids_produces_warning() {
1613        let items: ItemsFile = serde_json::from_str(
1614            r#"{
1615            "items": [
1616                {"id": "sword", "name": "Sword"},
1617                {"id": "sword", "name": "Sword Dup"}
1618            ]
1619        }"#,
1620        )
1621        .unwrap();
1622        let enemies: EnemiesFile = serde_json::from_str(r#"{"enemies": []}"#).unwrap();
1623        let abilities: AbilitiesFile = serde_json::from_str(r#"{"abilities": []}"#).unwrap();
1624        let loot_tables: LootTablesFile = serde_json::from_str(r#"{"tables": []}"#).unwrap();
1625        let warnings = PackLoader::validate_content_refs(&items, &enemies, &abilities, &loot_tables);
1626        assert!(warnings.iter().any(|w| w.code == "duplicate_item_id"));
1627    }
1628
1629    // -------------------------------------------------------------------------
1630    // validate_props
1631    // -------------------------------------------------------------------------
1632
1633    #[test]
1634    fn validate_props_duplicate_id_produces_warning() {
1635        let props: Vec<PropDefinition> = serde_json::from_str(
1636            r#"[
1637            {"id": "table", "name": "Table"},
1638            {"id": "table", "name": "Table"}
1639        ]"#,
1640        )
1641        .unwrap();
1642        let warnings = PackLoader::validate_props(&props);
1643        assert!(warnings.iter().any(|w| w.code == "duplicate_prop_id"));
1644    }
1645
1646    #[test]
1647    fn validate_props_invalid_default_state_produces_warning() {
1648        let props: Vec<PropDefinition> = serde_json::from_str(
1649            r#"[
1650            {
1651                "id": "lever",
1652                "name": "Lever",
1653                "default_state": "missing_state",
1654                "states": {"up": {}, "down": {}}
1655            }
1656        ]"#,
1657        )
1658        .unwrap();
1659        let warnings = PackLoader::validate_props(&props);
1660        assert!(warnings.iter().any(|w| w.code == "invalid_default_state"));
1661    }
1662
1663    #[test]
1664    fn validate_props_has_secondary_state_without_states_warns() {
1665        let props: Vec<PropDefinition> = serde_json::from_str(
1666            r#"[
1667            {"id": "chest", "name": "Chest", "has_secondary_state": true}
1668        ]"#,
1669        )
1670        .unwrap();
1671        let warnings = PackLoader::validate_props(&props);
1672        assert!(warnings.iter().any(|w| w.code == "secondary_state_without_states"));
1673    }
1674
1675    // -------------------------------------------------------------------------
1676    // SchemaVersion Display
1677    // -------------------------------------------------------------------------
1678
1679    #[test]
1680    fn schema_version_display() {
1681        assert_eq!(format!("{}", SchemaVersion::Legacy), "legacy");
1682        assert_eq!(format!("{}", SchemaVersion::V1_0_0), "dreamwell_waymark_v1.0.0");
1683        assert_eq!(format!("{}", SchemaVersion::Unknown("x".to_string())), "x");
1684    }
1685}