use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use crate::waymark::schema::*;
#[derive(Debug, Clone, PartialEq)]
pub enum SchemaVersion {
Legacy,
V1_0_0,
Unknown(String),
}
impl std::fmt::Display for SchemaVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SchemaVersion::Legacy => write!(f, "legacy"),
SchemaVersion::V1_0_0 => write!(f, "dreamwell_waymark_v1.0.0"),
SchemaVersion::Unknown(v) => write!(f, "{}", v),
}
}
}
#[derive(Debug, Clone)]
pub struct PackError {
pub code: String,
pub message: String,
pub field: Option<String>,
}
impl PackError {
fn new(code: &str, message: &str) -> Self {
Self {
code: code.to_string(),
message: message.to_string(),
field: None,
}
}
fn with_field(code: &str, message: &str, field: &str) -> Self {
Self {
code: code.to_string(),
message: message.to_string(),
field: Some(field.to_string()),
}
}
}
impl std::fmt::Display for PackError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(ref field) = self.field {
write!(f, "[{}] {} (field: {})", self.code, self.message, field)
} else {
write!(f, "[{}] {}", self.code, self.message)
}
}
}
#[derive(Debug, Clone)]
pub struct PackWarning {
pub code: String,
pub message: String,
pub field: Option<String>,
}
impl PackWarning {
fn new(code: &str, message: &str) -> Self {
Self {
code: code.to_string(),
message: message.to_string(),
field: None,
}
}
fn with_field(code: &str, message: &str, field: &str) -> Self {
Self {
code: code.to_string(),
message: message.to_string(),
field: Some(field.to_string()),
}
}
}
impl std::fmt::Display for PackWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(ref field) = self.field {
write!(f, "[{}] {} (field: {})", self.code, self.message, field)
} else {
write!(f, "[{}] {}", self.code, self.message)
}
}
}
pub struct PackValidationResult {
pub errors: Vec<PackError>,
pub warnings: Vec<PackWarning>,
pub is_valid: bool,
pub schema_version: String,
pub pack_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ItemsFile {
#[serde(default)]
pub items: Vec<ItemDefinition>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ItemDefinition {
pub id: String,
pub name: String,
#[serde(default)]
pub glyph: String,
#[serde(default)]
pub item_type: String,
#[serde(default)]
pub equip_slot: Option<String>,
#[serde(default, alias = "slot")]
pub slot: Option<String>,
#[serde(default)]
pub attack: i32,
#[serde(default, alias = "attack_bonus")]
pub attack_bonus: Option<i32>,
#[serde(default)]
pub defense: i32,
#[serde(default, alias = "defense_bonus")]
pub defense_bonus: Option<i32>,
#[serde(default)]
pub hp_bonus: i32,
#[serde(default, alias = "base_value")]
pub value: i32,
#[serde(default)]
pub weight: f32,
#[serde(default)]
pub rarity: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub is_consumable: bool,
#[serde(default)]
pub use_effect: Option<String>,
#[serde(default)]
pub use_value: i32,
#[serde(default)]
pub mana_bonus: i32,
#[serde(default)]
pub is_two_handed: bool,
#[serde(default)]
pub grants_spell: bool,
#[serde(default)]
pub granted_spell_id: Option<String>,
#[serde(default)]
pub damage_dice: Option<String>,
#[serde(default)]
pub properties: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EnemiesFile {
#[serde(default)]
pub enemies: Vec<EnemyDefinition>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnemyDefinition {
pub id: String,
pub name: String,
#[serde(default)]
pub glyph: String,
#[serde(default, alias = "max_health")]
pub health: i32,
#[serde(default)]
pub attack: i32,
#[serde(default)]
pub defense: i32,
#[serde(default)]
pub speed: i32,
#[serde(default)]
pub xp_value: i32,
#[serde(default)]
pub gold_value: i32,
#[serde(default)]
pub level: u32,
#[serde(default)]
pub abilities: Vec<String>,
#[serde(default)]
pub loot_table: String,
#[serde(default)]
pub behavior: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub max_mana: i32,
#[serde(default)]
pub innate_ability: Option<String>,
#[serde(default)]
pub ai_brain: String,
#[serde(default)]
pub personality: Option<serde_json::Value>,
#[serde(default)]
pub properties: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AbilitiesFile {
#[serde(default)]
pub abilities: Vec<AbilityDefinition>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AbilityDefinition {
pub id: String,
pub name: String,
#[serde(default)]
pub school: String,
#[serde(default, alias = "damage_base")]
pub damage: i32,
#[serde(default)]
pub mana_cost: i32,
#[serde(default)]
pub cooldown: u32,
#[serde(default)]
pub range: i32,
#[serde(default)]
pub aoe_radius: i32,
#[serde(default)]
pub damage_type: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub targeting: String,
#[serde(default)]
pub category: String,
#[serde(default)]
pub effect_type: String,
#[serde(default)]
pub effect_duration: u32,
#[serde(default)]
pub push_distance: i32,
#[serde(default)]
pub required_weapon_tags: String,
#[serde(default)]
pub effects: Vec<AbilityEffect>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AbilityEffect {
pub effect_type: String,
#[serde(default)]
pub duration: u32,
#[serde(default)]
pub magnitude: i32,
#[serde(default)]
pub chance: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LootTablesFile {
#[serde(default)]
pub tables: Vec<LootTable>,
#[serde(default)]
pub loot_tables: HashMap<String, LootTableInline>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LootTable {
pub id: String,
#[serde(default)]
pub entries: Vec<LootEntry>,
#[serde(default)]
pub picks: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LootTableInline {
#[serde(default)]
pub entries: Vec<LootEntry>,
#[serde(default)]
pub picks: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LootEntry {
pub item_id: String,
#[serde(default = "default_weight")]
pub weight: f32,
#[serde(default = "default_quantity_min")]
pub quantity_min: u32,
#[serde(default = "default_quantity_max")]
pub quantity_max: u32,
}
fn default_weight() -> f32 {
1.0
}
fn default_quantity_min() -> u32 {
1
}
fn default_quantity_max() -> u32 {
1
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EconomyFile {
#[serde(default)]
pub shops: Vec<ShopDefinition>,
#[serde(default)]
pub currencies: Vec<CurrencyDefinition>,
#[serde(default)]
pub currency_id: String,
#[serde(default)]
pub currency_name: String,
#[serde(default)]
pub starting_gold: i64,
#[serde(default = "default_buy_multiplier")]
pub default_buy_multiplier: f32,
#[serde(default = "default_sell_multiplier")]
pub default_sell_multiplier: f32,
}
fn default_buy_multiplier() -> f32 {
1.0
}
fn default_sell_multiplier() -> f32 {
0.5
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShopDefinition {
pub id: String,
pub name: String,
#[serde(default)]
pub items: Vec<ShopItem>,
#[serde(default, alias = "inventory")]
pub inventory: Vec<ShopItem>,
#[serde(default, alias = "buy_multiplier")]
pub buy_rate: f32,
#[serde(default, alias = "sell_multiplier")]
pub sell_rate: f32,
#[serde(default)]
pub gold: i64,
#[serde(default)]
pub restocks: bool,
#[serde(default)]
pub accepts_tags: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShopItem {
pub item_id: String,
#[serde(default)]
pub stock: i32,
#[serde(default)]
pub price_override: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CurrencyDefinition {
pub id: String,
pub name: String,
#[serde(default)]
pub symbol: String,
#[serde(default)]
pub decimal_places: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StatsFile {
#[serde(default, alias = "custom_stats")]
pub stats: Vec<StatDefinition>,
#[serde(default)]
pub damage_types: Vec<DamageTypeDefinition>,
#[serde(default)]
pub status_effects: Vec<StatusEffectDefinition>,
#[serde(default)]
pub equipment_slots: Vec<EquipmentSlotDefinition>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatDefinition {
pub id: String,
pub name: String,
#[serde(default)]
pub default_value: f32,
#[serde(default)]
pub min_value: f32,
#[serde(default = "default_stat_max")]
pub max_value: f32,
#[serde(default)]
pub category: String,
#[serde(default)]
pub show_in_ui: bool,
}
fn default_stat_max() -> f32 {
9999.0
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DamageTypeDefinition {
pub id: String,
pub name: String,
#[serde(default)]
pub resistance_stat: Option<String>,
#[serde(default)]
pub color: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatusEffectDefinition {
pub id: String,
pub name: String,
#[serde(default)]
pub stack_mode: String,
#[serde(default)]
pub max_stacks: u32,
#[serde(default)]
pub tick_effect: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EquipmentSlotDefinition {
pub id: String,
pub name: String,
#[serde(default)]
pub display_order: u32,
}
const VALID_EQUIP_SLOTS: &[&str] = &[
"Weapon", "Shield", "Head", "Body", "Hands", "Feet", "Ring", "Ring1", "Ring2", "Ring3", "Ring4", "Amulet", "Belt",
"Legs", "Trinket", "Back", "Offhand", "Cloak",
];
const MAX_GRID_DIMENSION: u32 = 1024;
fn is_valid_id(id: &str) -> bool {
!id.is_empty()
&& id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
}
pub struct PackLoader;
impl PackLoader {
pub fn detect_version(json: &serde_json::Value) -> SchemaVersion {
match json.get("schema_version").and_then(|v| v.as_str()) {
None => SchemaVersion::Legacy,
Some("dreamwell_waymark_v1.0.0") => SchemaVersion::V1_0_0,
Some(other) => SchemaVersion::Unknown(other.to_string()),
}
}
pub fn load_pack_config(json: &str) -> Result<DreamwellPackV1, PackError> {
let raw: serde_json::Value =
serde_json::from_str(json).map_err(|e| PackError::new("parse_error", &format!("Invalid JSON: {}", e)))?;
let version = Self::detect_version(&raw);
match version {
SchemaVersion::Legacy | SchemaVersion::V1_0_0 => serde_json::from_value(raw).map_err(|e| {
PackError::new(
"deserialize_error",
&format!("Failed to deserialize pack config: {}", e),
)
}),
SchemaVersion::Unknown(ref v) => Err(PackError::with_field(
"unknown_schema_version",
&format!(
"Unrecognized schema version '{}'. Expected 'dreamwell_waymark_v1.0.0' or omit for legacy.",
v
),
"schema_version",
)),
}
}
pub fn validate_pack(pack: &DreamwellPackV1) -> PackValidationResult {
let mut errors: Vec<PackError> = Vec::new();
let mut warnings: Vec<PackWarning> = Vec::new();
if pack.id.is_empty() {
errors.push(PackError::with_field("missing_id", "Pack id must be non-empty.", "id"));
} else if !is_valid_id(&pack.id) {
errors.push(PackError::with_field(
"invalid_id",
"Pack id must contain only lowercase alphanumeric characters and underscores.",
"id",
));
}
if pack.title.is_empty() {
errors.push(PackError::with_field(
"missing_title",
"Pack title must be non-empty.",
"title",
));
}
if pack.version.is_empty() {
warnings.push(PackWarning::with_field(
"missing_version",
"Pack version is recommended for pack management.",
"version",
));
}
{
let grid = &pack.grid;
if grid.width == 0 {
errors.push(PackError::with_field(
"invalid_grid_width",
"Grid width must be greater than 0.",
"grid.width",
));
} else if grid.width > MAX_GRID_DIMENSION {
errors.push(PackError::with_field(
"grid_width_exceeded",
&format!("Grid width {} exceeds maximum of {}.", grid.width, MAX_GRID_DIMENSION),
"grid.width",
));
}
if grid.height == 0 {
errors.push(PackError::with_field(
"invalid_grid_height",
"Grid height must be greater than 0.",
"grid.height",
));
} else if grid.height > MAX_GRID_DIMENSION {
errors.push(PackError::with_field(
"grid_height_exceeded",
&format!("Grid height {} exceeds maximum of {}.", grid.height, MAX_GRID_DIMENSION),
"grid.height",
));
}
}
for (i, slot) in pack.equip_slots.iter().enumerate() {
if !VALID_EQUIP_SLOTS.contains(&slot.as_str()) {
warnings.push(PackWarning::with_field(
"unknown_equip_slot",
&format!("Equip slot '{}' at index {} is not in the recognized set.", slot, i),
"equip_slots",
));
}
}
if pack.scenario.is_none() && pack.scenario_script.is_none() {
warnings.push(PackWarning::new(
"no_entry_point",
"Neither 'scenario' nor 'scenario_script' specified. Pack has no entry point.",
));
}
{
let mut prop_ids: HashSet<&str> = HashSet::new();
for (i, prop) in pack.props.iter().enumerate() {
let field_prefix = format!("props[{}]", i);
if prop.id.is_empty() {
errors.push(PackError::with_field(
"missing_prop_id",
&format!("Prop at index {} has an empty id.", i),
&format!("{}.id", field_prefix),
));
} else if !is_valid_id(&prop.id) {
errors.push(PackError::with_field(
"invalid_prop_id",
&format!(
"Prop id '{}' must contain only lowercase alphanumeric characters and underscores.",
prop.id
),
&format!("{}.id", field_prefix),
));
} else if !prop_ids.insert(&prop.id) {
errors.push(PackError::with_field(
"duplicate_prop_id",
&format!("Duplicate prop id '{}'.", prop.id),
&format!("{}.id", field_prefix),
));
}
if let Some(ref default_state) = prop.default_state {
if !prop.states.is_empty() {
if !prop.states.contains_key(default_state) {
errors.push(PackError::with_field(
"invalid_default_state",
&format!(
"Prop '{}' default_state '{}' does not match any key in states.",
prop.id, default_state
),
&format!("{}.default_state", field_prefix),
));
}
} else {
warnings.push(PackWarning::with_field(
"default_state_without_states",
&format!(
"Prop '{}' has default_state '{}' but no states map defined.",
prop.id, default_state
),
&format!("{}.default_state", field_prefix),
));
}
}
{
let states = &prop.states;
for (state_key, state_def) in states {
if let Some(ref on_interact) = state_def.on_interact {
if !states.contains_key(on_interact) {
warnings.push(PackWarning::with_field(
"on_interact_target_missing",
&format!(
"Prop '{}' state '{}' on_interact '{}' does not match any sibling state.",
prop.id, state_key, on_interact
),
&format!("{}.states.{}.on_interact", field_prefix, state_key),
));
}
}
if let Some(ref on_secondary) = state_def.on_secondary_interact {
if !states.contains_key(on_secondary) {
warnings.push(PackWarning::with_field(
"on_secondary_interact_target_missing",
&format!(
"Prop '{}' state '{}' on_secondary_interact '{}' does not match any sibling state.",
prop.id, state_key, on_secondary
),
&format!(
"{}.states.{}.on_secondary_interact",
field_prefix, state_key
),
));
}
}
}
}
if prop.has_inventory && prop.loot_table.is_none() {
warnings.push(PackWarning::with_field(
"container_no_loot_table",
&format!("Prop '{}' has has_inventory=true but no loot_table specified.", prop.id),
&format!("{}.loot_table", field_prefix),
));
}
}
}
{
let eviction = &pack.eviction;
if eviction.max_cached == 0 {
warnings.push(PackWarning::with_field(
"zero_max_cached",
"Eviction max_cached is 0, which means no maps will be cached.",
"eviction.max_cached",
));
}
}
let schema_version = pack.schema_version.clone();
let pack_id = pack.id.clone();
let is_valid = errors.is_empty();
PackValidationResult {
errors,
warnings,
is_valid,
schema_version,
pack_id,
}
}
pub fn load_items(json: &str) -> Result<ItemsFile, PackError> {
serde_json::from_str(json)
.map_err(|e| PackError::new("items_parse_error", &format!("Failed to parse items file: {}", e)))
}
pub fn load_enemies(json: &str) -> Result<EnemiesFile, PackError> {
serde_json::from_str(json)
.map_err(|e| PackError::new("enemies_parse_error", &format!("Failed to parse enemies file: {}", e)))
}
pub fn load_abilities(json: &str) -> Result<AbilitiesFile, PackError> {
serde_json::from_str(json).map_err(|e| {
PackError::new(
"abilities_parse_error",
&format!("Failed to parse abilities file: {}", e),
)
})
}
pub fn load_loot_tables(json: &str) -> Result<LootTablesFile, PackError> {
let mut file: LootTablesFile = serde_json::from_str(json).map_err(|e| {
PackError::new(
"loot_tables_parse_error",
&format!("Failed to parse loot tables file: {}", e),
)
})?;
if file.tables.is_empty() && !file.loot_tables.is_empty() {
for (id, inline) in &file.loot_tables {
file.tables.push(LootTable {
id: id.clone(),
entries: inline.entries.clone(),
picks: inline.picks,
});
}
}
Ok(file)
}
pub fn load_economy(json: &str) -> Result<EconomyFile, PackError> {
serde_json::from_str(json)
.map_err(|e| PackError::new("economy_parse_error", &format!("Failed to parse economy file: {}", e)))
}
pub fn load_stats(json: &str) -> Result<StatsFile, PackError> {
serde_json::from_str(json)
.map_err(|e| PackError::new("stats_parse_error", &format!("Failed to parse stats file: {}", e)))
}
pub fn validate_content_refs(
items: &ItemsFile,
enemies: &EnemiesFile,
abilities: &AbilitiesFile,
loot_tables: &LootTablesFile,
) -> Vec<PackWarning> {
let mut warnings: Vec<PackWarning> = Vec::new();
let mut item_ids: HashSet<&str> = HashSet::new();
for item in &items.items {
if item.id.is_empty() {
warnings.push(PackWarning::with_field(
"empty_item_id",
"Item has an empty id.",
"items",
));
} else if !item_ids.insert(&item.id) {
warnings.push(PackWarning::with_field(
"duplicate_item_id",
&format!("Duplicate item id '{}'.", item.id),
"items",
));
}
}
let mut ability_ids: HashSet<&str> = HashSet::new();
for ability in &abilities.abilities {
if ability.id.is_empty() {
warnings.push(PackWarning::with_field(
"empty_ability_id",
"Ability has an empty id.",
"abilities",
));
} else if !ability_ids.insert(&ability.id) {
warnings.push(PackWarning::with_field(
"duplicate_ability_id",
&format!("Duplicate ability id '{}'.", ability.id),
"abilities",
));
}
}
let mut loot_table_ids: HashSet<&str> = HashSet::new();
for table in &loot_tables.tables {
if table.id.is_empty() {
warnings.push(PackWarning::with_field(
"empty_loot_table_id",
"Loot table has an empty id.",
"loot_tables",
));
} else if !loot_table_ids.insert(&table.id) {
warnings.push(PackWarning::with_field(
"duplicate_loot_table_id",
&format!("Duplicate loot table id '{}'.", table.id),
"loot_tables",
));
}
}
let mut enemy_ids: HashSet<&str> = HashSet::new();
for enemy in &enemies.enemies {
if enemy.id.is_empty() {
warnings.push(PackWarning::with_field(
"empty_enemy_id",
"Enemy has an empty id.",
"enemies",
));
} else if !enemy_ids.insert(&enemy.id) {
warnings.push(PackWarning::with_field(
"duplicate_enemy_id",
&format!("Duplicate enemy id '{}'.", enemy.id),
"enemies",
));
}
}
for table in &loot_tables.tables {
for (i, entry) in table.entries.iter().enumerate() {
if !item_ids.contains(entry.item_id.as_str()) {
warnings.push(PackWarning::with_field(
"loot_item_not_found",
&format!(
"Loot table '{}' entry {} references item '{}' which does not exist in items.",
table.id, i, entry.item_id
),
&format!("loot_tables.{}.entries[{}].item_id", table.id, i),
));
}
if entry.weight <= 0.0 {
warnings.push(PackWarning::with_field(
"invalid_loot_weight",
&format!(
"Loot table '{}' entry {} has non-positive weight {}.",
table.id, i, entry.weight
),
&format!("loot_tables.{}.entries[{}].weight", table.id, i),
));
}
if entry.quantity_min > entry.quantity_max {
warnings.push(PackWarning::with_field(
"invalid_loot_quantity",
&format!(
"Loot table '{}' entry {} has quantity_min ({}) > quantity_max ({}).",
table.id, i, entry.quantity_min, entry.quantity_max
),
&format!("loot_tables.{}.entries[{}]", table.id, i),
));
}
}
}
for enemy in &enemies.enemies {
for ability_ref in &enemy.abilities {
if !ability_ids.contains(ability_ref.as_str()) {
warnings.push(PackWarning::with_field(
"enemy_ability_not_found",
&format!(
"Enemy '{}' references ability '{}' which does not exist in abilities.",
enemy.id, ability_ref
),
&format!("enemies.{}.abilities", enemy.id),
));
}
}
if let Some(ref innate) = enemy.innate_ability {
if !ability_ids.contains(innate.as_str()) {
warnings.push(PackWarning::with_field(
"enemy_innate_ability_not_found",
&format!(
"Enemy '{}' references innate_ability '{}' which does not exist in abilities.",
enemy.id, innate
),
&format!("enemies.{}.innate_ability", enemy.id),
));
}
}
if !enemy.loot_table.is_empty() && !loot_table_ids.contains(enemy.loot_table.as_str()) {
warnings.push(PackWarning::with_field(
"enemy_loot_table_not_found",
&format!(
"Enemy '{}' references loot_table '{}' which does not exist.",
enemy.id, enemy.loot_table
),
&format!("enemies.{}.loot_table", enemy.id),
));
}
}
for ability in &abilities.abilities {
for (i, effect) in ability.effects.iter().enumerate() {
if effect.effect_type.is_empty() {
warnings.push(PackWarning::with_field(
"empty_effect_type",
&format!(
"Ability '{}' effect at index {} has an empty effect_type.",
ability.id, i
),
&format!("abilities.{}.effects[{}].effect_type", ability.id, i),
));
}
if (effect.chance < 0.0 || effect.chance > 1.0) && effect.chance != 0.0 {
warnings.push(PackWarning::with_field(
"invalid_effect_chance",
&format!(
"Ability '{}' effect at index {} has chance {} outside [0.0, 1.0].",
ability.id, i, effect.chance
),
&format!("abilities.{}.effects[{}].chance", ability.id, i),
));
}
}
}
for item in &items.items {
if item.weight < 0.0 {
warnings.push(PackWarning::with_field(
"negative_item_weight",
&format!("Item '{}' has negative weight {}.", item.id, item.weight),
&format!("items.{}.weight", item.id),
));
}
}
warnings
}
pub fn validate_props(props: &[PropDefinition]) -> Vec<PackWarning> {
let mut warnings: Vec<PackWarning> = Vec::new();
let mut seen_ids: HashSet<&str> = HashSet::new();
for (i, prop) in props.iter().enumerate() {
let field_prefix = format!("props[{}]", i);
if prop.id.is_empty() {
warnings.push(PackWarning::with_field(
"empty_prop_id",
&format!("Prop at index {} has an empty id.", i),
&format!("{}.id", field_prefix),
));
} else if !is_valid_id(&prop.id) {
warnings.push(PackWarning::with_field(
"invalid_prop_id",
&format!(
"Prop id '{}' should contain only lowercase alphanumeric characters and underscores.",
prop.id
),
&format!("{}.id", field_prefix),
));
} else if !seen_ids.insert(&prop.id) {
warnings.push(PackWarning::with_field(
"duplicate_prop_id",
&format!("Duplicate prop id '{}'.", prop.id),
&format!("{}.id", field_prefix),
));
}
if let Some(ref default_state) = prop.default_state {
if !prop.states.is_empty() {
if !prop.states.contains_key(default_state) {
warnings.push(PackWarning::with_field(
"invalid_default_state",
&format!(
"Prop '{}' default_state '{}' does not match any key in states.",
prop.id, default_state
),
&format!("{}.default_state", field_prefix),
));
}
} else {
warnings.push(PackWarning::with_field(
"default_state_without_states",
&format!(
"Prop '{}' has default_state '{}' but no states map defined.",
prop.id, default_state
),
&format!("{}.default_state", field_prefix),
));
}
}
if prop.has_secondary_state && prop.states.is_empty() {
warnings.push(PackWarning::with_field(
"secondary_state_without_states",
&format!("Prop '{}' has has_secondary_state=true but no states defined.", prop.id),
&format!("{}.has_secondary_state", field_prefix),
));
}
{
let states = &prop.states;
for (state_key, state_def) in states {
if let Some(ref on_interact) = state_def.on_interact {
if !states.contains_key(on_interact) {
warnings.push(PackWarning::with_field(
"on_interact_target_missing",
&format!(
"Prop '{}' state '{}' on_interact '{}' does not match any sibling state.",
prop.id, state_key, on_interact
),
&format!("{}.states.{}.on_interact", field_prefix, state_key),
));
}
}
if let Some(ref on_secondary) = state_def.on_secondary_interact {
if !states.contains_key(on_secondary) {
warnings.push(PackWarning::with_field(
"on_secondary_interact_target_missing",
&format!(
"Prop '{}' state '{}' on_secondary_interact '{}' does not match any sibling state.",
prop.id, state_key, on_secondary
),
&format!("{}.states.{}.on_secondary_interact", field_prefix, state_key),
));
}
}
if let Some(ref glyph) = state_def.glyph {
if glyph.is_empty() {
warnings.push(PackWarning::with_field(
"empty_state_glyph",
&format!("Prop '{}' state '{}' has an empty glyph.", prop.id, state_key),
&format!("{}.states.{}.glyph", field_prefix, state_key),
));
}
}
}
}
if prop.glyph.as_deref().is_none_or(|g| g.is_empty()) {
warnings.push(PackWarning::with_field(
"empty_prop_glyph",
&format!("Prop '{}' has an empty glyph.", prop.id),
&format!("{}.glyph", field_prefix),
));
}
}
warnings
}
}
pub struct LoadedScene {
pub objects: crate::game_object::GameObjectScene,
pub seed: u64,
}
pub fn load_pack_to_scene(pack: &DreamwellPackV1) -> LoadedScene {
use crate::game_object::{GameObjectScene, PrimitiveKind};
let name = if pack.title.is_empty() {
pack.id.clone()
} else {
pack.title.clone()
};
let mut scene = GameObjectScene::new(name);
let topo = &pack.topology;
for (i, area) in topo.areas.iter().enumerate() {
let area_name = area.name.clone();
if let Ok(id) = scene.spawn_primitive(area_name, PrimitiveKind::Plane) {
if let Some(obj) = scene.find_mut(id) {
obj.transform.scale = [10.0, 1.0, 10.0];
obj.transform.position = [i as f32 * 20.0, 0.0, 0.0];
}
}
}
for (i, loc) in topo.locations.iter().enumerate() {
let loc_name = loc.name.clone();
if let Ok(id) = scene.spawn_primitive(loc_name, PrimitiveKind::Sphere) {
if let Some(obj) = scene.find_mut(id) {
obj.transform.scale = [0.5, 0.5, 0.5];
obj.transform.position = [i as f32 * 3.0, 0.5, 0.0];
}
}
}
if scene.is_empty() {
let _ = scene.spawn_primitive("Ground".into(), PrimitiveKind::Plane);
}
LoadedScene {
objects: scene,
seed: 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_version_legacy_no_schema_version() {
let json = r#"{"id": "test_pack", "title": "Test Pack"}"#;
let raw: serde_json::Value = serde_json::from_str(json).unwrap();
assert_eq!(PackLoader::detect_version(&raw), SchemaVersion::Legacy);
}
#[test]
fn detect_version_v1() {
let json = r#"{"schema_version": "dreamwell_waymark_v1.0.0", "id": "v1"}"#;
let raw: serde_json::Value = serde_json::from_str(json).unwrap();
assert_eq!(PackLoader::detect_version(&raw), SchemaVersion::V1_0_0);
}
#[test]
fn detect_version_unknown() {
let json = r#"{"schema_version": "future_v99.0.0"}"#;
let raw: serde_json::Value = serde_json::from_str(json).unwrap();
assert_eq!(
PackLoader::detect_version(&raw),
SchemaVersion::Unknown("future_v99.0.0".to_string())
);
}
#[test]
fn is_valid_id_accepts_lowercase_alphanumeric_underscore() {
assert!(is_valid_id("test_pack"));
assert!(is_valid_id("my_pack_123"));
assert!(is_valid_id("a"));
}
#[test]
fn is_valid_id_rejects_empty_uppercase_hyphen_space_dot() {
assert!(!is_valid_id(""));
assert!(!is_valid_id("Test_Pack"));
assert!(!is_valid_id("test-pack"));
assert!(!is_valid_id("test pack"));
assert!(!is_valid_id("test.pack"));
}
#[test]
fn load_pack_config_invalid_json_returns_parse_error() {
let result = PackLoader::load_pack_config("not json");
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, "parse_error");
}
#[test]
fn load_pack_config_unknown_schema_version_returns_error() {
let json = r#"{"schema_version": "nope", "id": "x", "title": "X"}"#;
let result = PackLoader::load_pack_config(json);
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, "unknown_schema_version");
}
#[test]
fn load_pack_config_legacy_format_succeeds() {
let json = r#"{"id": "my_pack", "title": "My Pack"}"#;
let result = PackLoader::load_pack_config(json);
assert!(result.is_ok());
let pack = result.unwrap();
assert_eq!(pack.id, "my_pack");
assert_eq!(pack.title, "My Pack");
}
#[test]
fn load_pack_config_v1_format_succeeds() {
let json = r#"{
"schema_version": "dreamwell_waymark_v1.0.0",
"id": "v1_pack",
"title": "V1 Pack",
"version": "1.0.0"
}"#;
let result = PackLoader::load_pack_config(json);
assert!(result.is_ok());
let pack = result.unwrap();
assert_eq!(pack.id, "v1_pack");
}
#[test]
fn validate_pack_missing_id_produces_error() {
let pack = DreamwellPackV1 {
id: String::new(),
title: "Has Title".to_string(),
..Default::default()
};
let result = PackLoader::validate_pack(&pack);
assert!(!result.is_valid);
assert!(result.errors.iter().any(|e| e.code == "missing_id"));
}
#[test]
fn validate_pack_missing_title_produces_error() {
let pack = DreamwellPackV1 {
id: "valid_id".to_string(),
title: String::new(),
..Default::default()
};
let result = PackLoader::validate_pack(&pack);
assert!(!result.is_valid);
assert!(result.errors.iter().any(|e| e.code == "missing_title"));
}
#[test]
fn validate_pack_zero_grid_width_produces_error() {
let json = r#"{
"id": "test", "title": "Test",
"grid": {"width": 0, "height": 50}
}"#;
let pack: DreamwellPackV1 = serde_json::from_str(json).unwrap();
let result = PackLoader::validate_pack(&pack);
assert!(!result.is_valid);
assert!(result.errors.iter().any(|e| e.code == "invalid_grid_width"));
}
#[test]
fn validate_pack_exceeds_max_grid_height_produces_error() {
let json = r#"{
"id": "test", "title": "Test",
"grid": {"width": 80, "height": 2000}
}"#;
let pack: DreamwellPackV1 = serde_json::from_str(json).unwrap();
let result = PackLoader::validate_pack(&pack);
assert!(!result.is_valid);
assert!(result.errors.iter().any(|e| e.code == "grid_height_exceeded"));
}
#[test]
fn validate_pack_unknown_equip_slot_is_warning_not_error() {
let json = r#"{
"id": "test", "title": "Test",
"equip_slots": ["weapon", "jetpack_illegal"]
}"#;
let pack: DreamwellPackV1 = serde_json::from_str(json).unwrap();
let result = PackLoader::validate_pack(&pack);
assert!(result.warnings.iter().any(|w| w.code == "unknown_equip_slot"));
}
#[test]
fn validate_pack_valid_pack_is_valid() {
let pack = DreamwellPackV1 {
id: "good_pack".to_string(),
title: "Good Pack".to_string(),
version: "1.0.0".to_string(),
..Default::default()
};
let result = PackLoader::validate_pack(&pack);
assert!(result.is_valid);
}
#[test]
fn load_items_from_wrapper_format() {
let json = r#"{
"items": [
{"id": "sword", "name": "Iron Sword"},
{"id": "potion", "name": "Health Potion"}
]
}"#;
let items = PackLoader::load_items(json).unwrap();
assert_eq!(items.items.len(), 2);
assert_eq!(items.items[0].id, "sword");
assert_eq!(items.items[1].id, "potion");
}
#[test]
fn load_items_empty_array() {
let json = r#"{"items": []}"#;
let items = PackLoader::load_items(json).unwrap();
assert_eq!(items.items.len(), 0);
}
#[test]
fn load_items_invalid_json_returns_error() {
let result = PackLoader::load_items("not json");
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, "items_parse_error");
}
#[test]
fn load_enemies_from_wrapper_format() {
let json = r#"{
"enemies": [
{"id": "goblin", "name": "Goblin", "health": 10, "attack": 3}
]
}"#;
let enemies = PackLoader::load_enemies(json).unwrap();
assert_eq!(enemies.enemies.len(), 1);
assert_eq!(enemies.enemies[0].id, "goblin");
assert_eq!(enemies.enemies[0].health, 10);
}
#[test]
fn load_abilities_from_wrapper_format() {
let json = r#"{
"abilities": [
{"id": "fireball", "name": "Fireball"}
]
}"#;
let abilities = PackLoader::load_abilities(json).unwrap();
assert_eq!(abilities.abilities.len(), 1);
assert_eq!(abilities.abilities[0].id, "fireball");
}
#[test]
fn load_loot_tables_array_format() {
let json = r#"{
"tables": [
{"id": "common", "entries": [{"item_id": "potion", "weight": 10.0}]}
]
}"#;
let tables = PackLoader::load_loot_tables(json).unwrap();
assert_eq!(tables.tables.len(), 1);
assert_eq!(tables.tables[0].id, "common");
}
#[test]
fn load_loot_tables_object_format() {
let json = r#"{
"loot_tables": {
"chest_common": {
"picks": 2,
"entries": [
{"item_id": "potion", "weight": 10.0},
{"item_id": "gold_coin", "weight": 5.0}
]
}
}
}"#;
let tables = PackLoader::load_loot_tables(json).unwrap();
assert_eq!(tables.tables.len(), 1);
assert_eq!(tables.tables[0].id, "chest_common");
assert_eq!(tables.tables[0].entries.len(), 2);
}
#[test]
fn load_economy_basic() {
let json = r#"{
"currency_id": "gold",
"currency_name": "Gold",
"shops": [{"id": "blacksmith", "name": "The Forge"}]
}"#;
let economy = PackLoader::load_economy(json).unwrap();
assert_eq!(economy.currency_id, "gold");
assert_eq!(economy.shops.len(), 1);
}
#[test]
fn load_stats_basic() {
let json = r#"{
"stats": [{"id": "strength", "name": "Strength"}],
"damage_types": [{"id": "fire", "name": "Fire"}],
"status_effects": []
}"#;
let stats = PackLoader::load_stats(json).unwrap();
assert_eq!(stats.stats.len(), 1);
assert_eq!(stats.damage_types.len(), 1);
}
#[test]
fn validate_content_refs_missing_loot_item_produces_warning() {
let items: ItemsFile = serde_json::from_str(r#"{"items": [{"id": "sword", "name": "Sword"}]}"#).unwrap();
let enemies: EnemiesFile = serde_json::from_str(r#"{"enemies": []}"#).unwrap();
let abilities: AbilitiesFile = serde_json::from_str(r#"{"abilities": []}"#).unwrap();
let loot_tables: LootTablesFile = serde_json::from_str(
r#"{
"tables": [{"id": "common", "entries": [
{"item_id": "sword", "weight": 1.0},
{"item_id": "nonexistent", "weight": 1.0}
]}]
}"#,
)
.unwrap();
let warnings = PackLoader::validate_content_refs(&items, &enemies, &abilities, &loot_tables);
assert!(warnings.iter().any(|w| w.code == "loot_item_not_found"));
assert!(!warnings
.iter()
.any(|w| w.code == "loot_item_not_found" && w.message.contains("sword")));
}
#[test]
fn validate_content_refs_missing_enemy_ability_produces_warning() {
let items: ItemsFile = serde_json::from_str(r#"{"items": []}"#).unwrap();
let enemies: EnemiesFile = serde_json::from_str(
r#"{
"enemies": [{"id": "goblin", "name": "Goblin", "health": 10, "attack": 3,
"abilities": ["slash", "missing_skill"]}]
}"#,
)
.unwrap();
let abilities: AbilitiesFile = serde_json::from_str(
r#"{
"abilities": [{"id": "slash", "name": "Slash"}]
}"#,
)
.unwrap();
let loot_tables: LootTablesFile = serde_json::from_str(r#"{"tables": []}"#).unwrap();
let warnings = PackLoader::validate_content_refs(&items, &enemies, &abilities, &loot_tables);
assert!(warnings
.iter()
.any(|w| w.code == "enemy_ability_not_found" && w.message.contains("missing_skill")));
assert!(!warnings
.iter()
.any(|w| w.code == "enemy_ability_not_found" && w.message.contains("slash")));
}
#[test]
fn validate_content_refs_duplicate_item_ids_produces_warning() {
let items: ItemsFile = serde_json::from_str(
r#"{
"items": [
{"id": "sword", "name": "Sword"},
{"id": "sword", "name": "Sword Dup"}
]
}"#,
)
.unwrap();
let enemies: EnemiesFile = serde_json::from_str(r#"{"enemies": []}"#).unwrap();
let abilities: AbilitiesFile = serde_json::from_str(r#"{"abilities": []}"#).unwrap();
let loot_tables: LootTablesFile = serde_json::from_str(r#"{"tables": []}"#).unwrap();
let warnings = PackLoader::validate_content_refs(&items, &enemies, &abilities, &loot_tables);
assert!(warnings.iter().any(|w| w.code == "duplicate_item_id"));
}
#[test]
fn validate_props_duplicate_id_produces_warning() {
let props: Vec<PropDefinition> = serde_json::from_str(
r#"[
{"id": "table", "name": "Table"},
{"id": "table", "name": "Table"}
]"#,
)
.unwrap();
let warnings = PackLoader::validate_props(&props);
assert!(warnings.iter().any(|w| w.code == "duplicate_prop_id"));
}
#[test]
fn validate_props_invalid_default_state_produces_warning() {
let props: Vec<PropDefinition> = serde_json::from_str(
r#"[
{
"id": "lever",
"name": "Lever",
"default_state": "missing_state",
"states": {"up": {}, "down": {}}
}
]"#,
)
.unwrap();
let warnings = PackLoader::validate_props(&props);
assert!(warnings.iter().any(|w| w.code == "invalid_default_state"));
}
#[test]
fn validate_props_has_secondary_state_without_states_warns() {
let props: Vec<PropDefinition> = serde_json::from_str(
r#"[
{"id": "chest", "name": "Chest", "has_secondary_state": true}
]"#,
)
.unwrap();
let warnings = PackLoader::validate_props(&props);
assert!(warnings.iter().any(|w| w.code == "secondary_state_without_states"));
}
#[test]
fn schema_version_display() {
assert_eq!(format!("{}", SchemaVersion::Legacy), "legacy");
assert_eq!(format!("{}", SchemaVersion::V1_0_0), "dreamwell_waymark_v1.0.0");
assert_eq!(format!("{}", SchemaVersion::Unknown("x".to_string())), "x");
}
}