1use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, HashSet};
8
9use crate::waymark::schema::*;
10
11#[derive(Debug, Clone, PartialEq)]
17pub enum SchemaVersion {
18 Legacy,
20 V1_0_0,
22 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#[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#[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
112pub 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
127pub struct ItemsFile {
128 #[serde(default)]
129 pub items: Vec<ItemDefinition>,
130}
131
132#[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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
187pub struct EnemiesFile {
188 #[serde(default)]
189 pub enemies: Vec<EnemyDefinition>,
190}
191
192#[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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
235pub struct AbilitiesFile {
236 #[serde(default)]
237 pub abilities: Vec<AbilityDefinition>,
238}
239
240#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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
478const 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
488const MAX_GRID_DIMENSION: u32 = 1024;
490
491fn 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
504pub struct PackLoader;
513
514impl PackLoader {
515 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 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 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 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 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 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 {
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 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 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 {
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 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 {
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 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 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 {
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 {
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 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 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
1189pub struct LoadedScene {
1195 pub objects: crate::game_object::GameObjectScene,
1197 pub seed: u64,
1199}
1200
1201pub 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 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 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 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#[cfg(test)]
1257mod tests {
1258 use super::*;
1259
1260 #[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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}