//! Content validation: rich `file:line` errors over in-memory structs. //! //! Design traces: //! - docs/04-software-architecture.md §4.4 ("Content as data"): `hexwork-content` //! owns schema structs plus validation errors with `file:line`. The 2-uses law //! is a TEST, not a guideline; balance numbers carry `// intent:` comments //! (the REQUIRED [`crate::schema`] `intent` field, enforced non-empty here). //! - docs/04 §4.10 hard rule 4 (no single-use items) and rule 1 (no gameplay //! logic outside `core` + data files — this module only validates data). //! - docs/02-lore-bible.md §5 (salvage economy: everything has 2+ uses), //! §6 (bestiary: a habit, a tell, a use), §7 (magic words are dead-Latin //! boot commands: `FIAT LUX`, `APERI`, `MEMENTO`). //! - docs/03-systems-design.md §3.4 (items), §3.5 (hymns, bargains), §3.7 //! (contracts: pay, known hazards, what the poster didn't know), §3.8 (the //! 12-floor Deep: Shallows 1–3, Works 4–8, Deep 9–12). //! //! Validation works on in-memory structs so workstream 2's TOML loaders can //! feed parsed packs into the [`validate_*`](validate_items) pack validators. //! Pack validators collect ALL errors (never fail fast) and return them as a //! `Vec`. Loaders attach real `file`/`line` afterwards (see //! [`ContentError`] and the parent-side notes in the return payload); direct //! in-memory use reports `file = ""` ([`MEMORY_FILE`]). //! //! Cross-kind limits: only what in-memory structs allow is checked here //! (contract `floor` in 1–12, town `contracts` entries non-blank). Full //! id-existence cross-refs (town contract ids name loaded contracts, monster //! blood names a fluid — beyond the name spelling) are parent/test-side with //! loaded packs. use crate::schema::{Contract, Floor, God, Hymn, Item, Monster, Town}; use std::fmt; /// In-memory validation source marker used when no loader file:line exists. pub const MEMORY_FILE: &str = ""; /// Valid `Item::material` spellings: the `hexwork_core::voxel::material` /// constants verbatim and case-sensitively (`AIR`..`PAPER`; see /// [`material_id`]). pub const VALID_MATERIALS: &[&str] = &[ "AIR", "WOOD", "IRON", "COPPER", "STONE", "BRASS", "FLESH", "SAINT_BONE", "GLASS", "ROPE", "PAPER", ]; /// Valid `Monster::blood` spellings: the `hexwork_core::fluids::fluid` kinds /// lowercased, plus `"none"` (see [`fluid_id`]). pub const VALID_FLUIDS: &[&str] = &["none", "water", "oil", "blood", "bile", "aether_mist"]; /// Valid `Floor::stratum` spellings (docs/03 §3.8). pub const VALID_STRATA: &[&str] = &["Shallows", "Works", "Deep"]; /// Deepest dungeon floor (docs/02 §3, docs/03 §3.8: the 12-floor Deep). pub const MAX_DEPTH: u8 = 12; /// Highest contract tier (docs/03 §3.7: vermin → hauntings → saints → Deep doors). pub const MAX_TIER: u8 = 5; /// Resolve a material name to its `hexwork_core::voxel::material` id. /// /// Only the eleven protocol materials resolve; anything else (including /// lowercase spellings like `"iron"`) returns `None`. The `match` arms name /// the core constants, so adding a material to core without updating this /// table is a visible, deliberate diff. pub fn material_id(name: &str) -> Option { use hexwork_core::voxel::material; Some(match name { "AIR" => material::AIR, "WOOD" => material::WOOD, "IRON" => material::IRON, "COPPER" => material::COPPER, "STONE" => material::STONE, "BRASS" => material::BRASS, "FLESH" => material::FLESH, "SAINT_BONE" => material::SAINT_BONE, "GLASS" => material::GLASS, "ROPE" => material::ROPE, "PAPER" => material::PAPER, _ => return None, }) } /// Resolve a blood/fluid name to its `hexwork_core::fluids::fluid` kind id. /// /// `"none"` maps to `fluid::NONE` (bloodless — glass, bone, audit cherubs); /// the rest map 1:1 onto the lowercase fluid kinds. Anything else (including /// the docs/04 §4.4 sketch's `"weak_acid"` prose — cable-rat blood is fluid /// `BLOOD`) returns `None`. pub fn fluid_id(name: &str) -> Option { use hexwork_core::fluids::fluid; Some(match name { "none" => fluid::NONE, "water" => fluid::WATER, "oil" => fluid::OIL, "blood" => fluid::BLOOD, "bile" => fluid::BILE, "aether_mist" => fluid::AETHER_MIST, _ => return None, }) } /// True when `id` is snake_case: non-empty, lowercase ASCII alnum + underscore. /// /// Design note: ids are keys designers type into TOML tables and cross-ref /// from other files, so the rule is deliberately narrow — no spaces, no /// dashes, no capitals. pub fn is_snake_case(id: &str) -> bool { !id.is_empty() && id .bytes() .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') } /// One content validation failure. /// /// `file` names the source TOML file; `line` the 1-indexed source line when /// the loader knows it. In-memory validation (all validators in this module) /// uses `file = ""` ([`MEMORY_FILE`]) and `line = None`; workstream 2 /// loaders overwrite `file`/`line` per entry. Displays as `file:line: message` /// with a line, or `file: message` without one. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ContentError { /// Source file, or `""` for in-memory validation. pub file: String, /// 1-indexed source line, when the loader knows it. pub line: Option, /// What failed and which design law it breaks. pub message: String, } impl ContentError { /// Build an error for `file` (and optional 1-indexed `line`). pub fn new(file: impl Into, line: Option, message: impl Into) -> Self { Self { file: file.into(), line, message: message.into(), } } /// Build an in-memory error (`file = ""`, no line). pub fn memory(message: impl Into) -> Self { Self::new(MEMORY_FILE, None, message) } } impl fmt::Display for ContentError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self.line { Some(line) => write!(f, "{}:{}: {}", self.file, line, self.message), None => write!(f, "{}: {}", self.file, self.message), } } } impl std::error::Error for ContentError {} /// Validate the two-uses law (docs/02 §5, docs/04 §4.10 hard rule 4). /// /// Canon successor of the `lib.rs` stub: rejects items declaring fewer than 2 /// uses. Pack validation ([`validate_items`]) calls this rather than /// re-implementing the count, so the law has one source of truth. pub fn validate_item(item: &Item) -> Result<(), ContentError> { if item.uses.len() < 2 { return Err(ContentError::memory(format!( "item '{}' declares only {} use(s); the two-uses law requires 2+ (docs/02 §5)", item.id, item.uses.len() ))); } Ok(()) } /// Validate a pack of items, collecting ALL errors (docs/02 §5, docs/03 §3.4). pub fn validate_items(items: &[Item]) -> Result<(), Vec> { let mut errors = Vec::new(); for item in items { check_item(item, &mut errors); } collect(errors) } /// Validate a pack of monsters, collecting ALL errors (docs/02 §6). pub fn validate_monsters(monsters: &[Monster]) -> Result<(), Vec> { let mut errors = Vec::new(); for monster in monsters { check_monster(monster, &mut errors); } collect(errors) } /// Validate a pack of hymns, collecting ALL errors (docs/03 §3.5). pub fn validate_hymns(hymns: &[Hymn]) -> Result<(), Vec> { let mut errors = Vec::new(); for hymn in hymns { check_hymn(hymn, &mut errors); } collect(errors) } /// Validate a pack of gods, collecting ALL errors (docs/02 §2, docs/03 §3.5). pub fn validate_gods(gods: &[God]) -> Result<(), Vec> { let mut errors = Vec::new(); for god in gods { check_god(god, &mut errors); } collect(errors) } /// Validate a pack of contracts, collecting ALL errors (docs/03 §3.7). pub fn validate_contracts(contracts: &[Contract]) -> Result<(), Vec> { let mut errors = Vec::new(); for contract in contracts { check_contract(contract, &mut errors); } collect(errors) } /// Validate a pack of floors, collecting ALL errors (docs/03 §3.8). pub fn validate_floors(floors: &[Floor]) -> Result<(), Vec> { let mut errors = Vec::new(); for floor in floors { check_floor(floor, &mut errors); } collect(errors) } /// Validate a pack of towns, collecting ALL errors (docs/03 §3.6). /// /// Contract-id existence is NOT checked here — it needs loaded packs and is /// parent/test-side. Only the in-memory shape is checked: entries non-blank. pub fn validate_towns(towns: &[Town]) -> Result<(), Vec> { let mut errors = Vec::new(); for town in towns { check_town(town, &mut errors); } collect(errors) } fn collect(errors: Vec) -> Result<(), Vec> { if errors.is_empty() { Ok(()) } else { Err(errors) } } fn check_id(errors: &mut Vec, kind: &str, id: &str) { if !is_snake_case(id) { errors.push(ContentError::memory(format!( "{kind} id '{id}' is not snake_case (lowercase alnum + underscore)" ))); } } fn check_name(errors: &mut Vec, ctx: &str, field: &str, value: &str) { if value.trim().is_empty() { errors.push(ContentError::memory(format!( "{ctx}: {field} must be non-empty" ))); } } fn check_intent(errors: &mut Vec, ctx: &str, intent: &str) { if intent.trim().is_empty() { errors.push(ContentError::memory(format!( "{ctx} is missing its `intent:` balance comment (docs/04 §4.4 — REQUIRED, designers must say why the numbers are what they are)" ))); } } fn check_item(item: &Item, errors: &mut Vec) { let ctx = format!("item '{}'", item.id); check_id(errors, "item", &item.id); check_name(errors, &ctx, "name", &item.name); if material_id(&item.material).is_none() { errors.push(ContentError::memory(format!( "{ctx}: unknown material '{}' (must name a hexwork_core::voxel::material: {})", item.material, VALID_MATERIALS.join(", ") ))); } if !item.weight_kg.is_finite() || item.weight_kg <= 0.0 { errors.push(ContentError::memory(format!( "{ctx}: weight_kg must be finite and > 0 (docs/03 §3.2 HAUL), got {}", item.weight_kg ))); } // The two-uses law has one source of truth: `validate_item`. if let Err(err) = validate_item(item) { errors.push(err); } for use_ in &item.uses { if use_.trim().is_empty() { errors.push(ContentError::memory(format!( "{ctx}: declares a blank use; every use must say what the item is FOR (docs/02 §5)" ))); } } for tag in &item.tags { if tag.trim().is_empty() { errors.push(ContentError::memory(format!( "{ctx}: declares a blank machine tag (docs/03 §3.4)" ))); } } check_intent(errors, &ctx, &item.intent); } fn check_monster(monster: &Monster, errors: &mut Vec) { let ctx = format!("monster '{}'", monster.id); check_id(errors, "monster", &monster.id); check_name(errors, &ctx, "name", &monster.name); if monster.hp <= 0 { errors.push(ContentError::memory(format!( "{ctx}: hp must be > 0, got {}", monster.hp ))); } if monster.habit.trim().is_empty() { errors.push(ContentError::memory(format!( "{ctx}: habit must be non-empty (docs/02 §6: a habit, a tell, a use)" ))); } if monster.tell.trim().is_empty() { errors.push(ContentError::memory(format!( "{ctx}: tell must be non-empty (docs/02 §6: the player-readable warning sign)" ))); } if monster.uses.is_empty() { errors.push(ContentError::memory(format!( "{ctx}: must declare 1+ uses (docs/02 §6)" ))); } for use_ in &monster.uses { if use_.trim().is_empty() { errors.push(ContentError::memory(format!( "{ctx}: declares a blank use; every use must say what the monster is FOR (docs/02 §6)" ))); } } if fluid_id(&monster.blood).is_none() { errors.push(ContentError::memory(format!( "{ctx}: unknown blood/fluid '{}' (one of: {})", monster.blood, VALID_FLUIDS.join(", ") ))); } let min_ok = (1..=MAX_DEPTH).contains(&monster.depth_min); let max_ok = (1..=MAX_DEPTH).contains(&monster.depth_max); if !min_ok { errors.push(ContentError::memory(format!( "{ctx}: depth_min {} out of range 1-{MAX_DEPTH}", monster.depth_min ))); } if !max_ok { errors.push(ContentError::memory(format!( "{ctx}: depth_max {} out of range 1-{MAX_DEPTH}", monster.depth_max ))); } if min_ok && max_ok && monster.depth_min > monster.depth_max { errors.push(ContentError::memory(format!( "{ctx}: depth_min {} > depth_max {}", monster.depth_min, monster.depth_max ))); } check_intent(errors, &ctx, &monster.intent); } fn check_hymn(hymn: &Hymn, errors: &mut Vec) { let ctx = format!("hymn '{}'", hymn.id); check_id(errors, "hymn", &hymn.id); if hymn.verb.trim().is_empty() { errors.push(ContentError::memory(format!( "{ctx}: verb must be non-empty (dead-Latin boot command, docs/02 §7)" ))); } else if !hymn.verb.chars().any(|c| c.is_ascii_uppercase()) || hymn.verb.chars().any(|c| c.is_ascii_lowercase()) { errors.push(ContentError::memory(format!( "{ctx}: verb '{}' must be dead-Latin caps like FIAT LUX (docs/02 §7)", hymn.verb ))); } check_name(errors, &ctx, "effect", &hymn.effect); if hymn.conduct_base > 100 { errors.push(ContentError::memory(format!( "{ctx}: conduct_base {} out of range 0-100 (base conduct chance)", hymn.conduct_base ))); } if hymn.slots == 0 { errors.push(ContentError::memory(format!( "{ctx}: slots must be 1+ (hymn-case loadout strategy, docs/03 §3.5)" ))); } if hymn.miscast_table.is_empty() { errors.push(ContentError::memory(format!( "{ctx}: miscast_table must hold 1+ outcomes (miscasts follow debugging logic, docs/03 §3.5)" ))); } for outcome in &hymn.miscast_table { if outcome.trim().is_empty() { errors.push(ContentError::memory(format!( "{ctx}: miscast_table holds a blank outcome" ))); } } check_intent(errors, &ctx, &hymn.intent); } fn check_god(god: &God, errors: &mut Vec) { let ctx = format!("god '{}'", god.id); check_id(errors, "god", &god.id); check_name(errors, &ctx, "name", &god.name); check_name(errors, &ctx, "domain", &god.domain); if god.boons.is_empty() { errors.push(ContentError::memory(format!( "{ctx}: must offer 1+ boons (gods are transactional, docs/02 §2)" ))); } for boon in &god.boons { let bctx = format!("{ctx} boon '{}'", boon.id); check_id(errors, "boon", &boon.id); check_name(errors, &bctx, "ask", &boon.ask); if boon.price.trim().is_empty() { errors.push(ContentError::memory(format!( "{bctx}: price must be non-empty (every boon has small print, docs/02 §2)" ))); } check_name(errors, &bctx, "small_print", &boon.small_print); check_intent(errors, &bctx, &boon.intent); } if god.blasphemy.trim().is_empty() { errors.push(ContentError::memory(format!( "{ctx}: blasphemy must be non-empty — the god always collects (docs/02 §2)" ))); } check_intent(errors, &ctx, &god.intent); } fn check_contract(contract: &Contract, errors: &mut Vec) { let ctx = format!("contract '{}'", contract.id); check_id(errors, "contract", &contract.id); check_name(errors, &ctx, "title", &contract.title); if !(1..=MAX_TIER).contains(&contract.tier) { errors.push(ContentError::memory(format!( "{ctx}: tier {} out of range 1-{MAX_TIER} (docs/03 §3.7)", contract.tier ))); } check_name(errors, &ctx, "poster", &contract.poster); if contract.unknown_hint.trim().is_empty() { errors.push(ContentError::memory(format!( "{ctx}: unknown_hint must be non-empty — every contract lists what the poster didn't know (docs/03 §3.7)" ))); } if !(1..=MAX_DEPTH).contains(&contract.floor) { errors.push(ContentError::memory(format!( "{ctx}: floor {} out of range 1-{MAX_DEPTH}", contract.floor ))); } check_intent(errors, &ctx, &contract.intent); } fn check_floor(floor: &Floor, errors: &mut Vec) { let ctx = format!("floor '{}'", floor.id); check_id(errors, "floor", &floor.id); if !(1..=MAX_DEPTH).contains(&floor.depth) { errors.push(ContentError::memory(format!( "{ctx}: depth {} out of range 1-{MAX_DEPTH}", floor.depth ))); } let expected = match floor.stratum.as_str() { "Shallows" => Some(1..=3), "Works" => Some(4..=8), "Deep" => Some(9..=MAX_DEPTH), _ => None, }; match expected { None => errors.push(ContentError::memory(format!( "{ctx}: unknown stratum '{}' (one of: {})", floor.stratum, VALID_STRATA.join(", ") ))), Some(range) => { if (1..=MAX_DEPTH).contains(&floor.depth) && !range.contains(&floor.depth) { errors.push(ContentError::memory(format!( "{ctx}: stratum '{}' does not match depth {} (Shallows 1-3, Works 4-8, Deep 9-12; docs/03 §3.8)", floor.stratum, floor.depth ))); } } } if floor.anchors.is_empty() { errors.push(ContentError::memory(format!( "{ctx}: must declare 1+ anchor rooms (anchors + systemic fill, docs/03 §3.8)" ))); } for anchor in &floor.anchors { if anchor.trim().is_empty() { errors.push(ContentError::memory(format!( "{ctx}: declares a blank anchor room" ))); } } check_name(errors, &ctx, "name", &floor.name); if floor.connectors.len() < 3 { errors.push(ContentError::memory(format!( "{ctx}: must declare 3+ vertical connectors, got {} (docs/03 §3.8)", floor.connectors.len() ))); } for connector in &floor.connectors { if connector.trim().is_empty() { errors.push(ContentError::memory(format!( "{ctx}: declares a blank connector" ))); } } check_name(errors, &ctx, "water_path", &floor.water_path); for vent in &floor.secret_vents { if vent.trim().is_empty() { errors.push(ContentError::memory(format!( "{ctx}: declares a blank secret vent" ))); } } check_intent(errors, &ctx, &floor.intent); } fn check_town(town: &Town, errors: &mut Vec) { let ctx = format!("town '{}'", town.id); check_id(errors, "town", &town.id); check_name(errors, &ctx, "name", &town.name); check_name(errors, &ctx, "inn", &town.inn); check_name(errors, &ctx, "chapel", &town.chapel); for contract in &town.contracts { if contract.trim().is_empty() { errors.push(ContentError::memory(format!( "{ctx}: lists a blank contract id (contract-id existence is cross-checked by the parent against loaded packs)" ))); } } check_intent(errors, &ctx, &town.intent); } #[cfg(test)] mod tests { use super::*; use crate::schema::Boon; fn good_item() -> Item { Item { id: "lamp_oil".into(), name: "Lamp oil".into(), material: "GLASS".into(), category: crate::schema::ItemCategory::Consumable, weight_kg: 0.8, bulk: 1, base_price_cu: 5, uses: vec!["burn".into(), "bribe (scrip)".into()], identified: true, cursed: false, blessed: false, condition: "sealed".into(), silvered: false, illegal: false, tags: vec![], intent: "Oil scrip is legal tender: drinking money is literally burning money.".into(), } } fn good_monster() -> Monster { Monster { id: "cable_rat".into(), name: "Cable-rat".into(), hp: 6, habit: "gnaws live wire; nests in walls".into(), tell: "ozone smell + skritch above".into(), uses: vec!["weak-acid blood pits iron".into()], conducts: true, blood: "blood".into(), fears: vec!["cats".into(), "bells".into()], depth_min: 1, depth_max: 4, boss: false, intent: "Tutorial menace: teaches conducts-the-hard-way before floor 4.".into(), } } fn good_hymn() -> Hymn { Hymn { id: "hymn_01_fiat_lux".into(), verb: "FIAT LUX".into(), effect: "lights the bearer".into(), conduct_base: 60, slots: 1, card_fragile: false, miscast_table: vec!["lights the wrong target".into()], intent: "Baseline light hymn so dousing the lamp stays a choice.".into(), } } fn good_god() -> God { God { id: "the_lamplighter".into(), name: "The Lamplighter".into(), domain: "mercy, light, owed debts".into(), boons: vec![Boon { id: "hold_the_light".into(), ask: "hold my light when I cannot".into(), price: "bury the next dead you find".into(), small_print: "the NEXT dead, even mid-fight".into(), intent: "Mercy with a shovel attached.".into(), }], blasphemy: "your lantern gutters wherever you walk until the debt is paid".into(), intent: "The kind god, priced to keep pity from being free.".into(), } } fn good_contract() -> Contract { Contract { id: "rats_in_the_relay".into(), title: "Rats in the relay".into(), tier: 1, pay_cu: 40, poster: "Lampwrights' Guild".into(), known_hazards: vec!["live cable".into()], unknown_hint: "the nest is inside the wall, not under it".into(), floor: 2, prep_notes: "bring a grounding rod and chalk".into(), intent: "Tier-1 vermin job that teaches asking around first.".into(), } } fn good_floor() -> Floor { Floor { id: "floor_06_audit_vaults".into(), name: "Vault of Arrears".into(), depth: 6, stratum: "Works".into(), anchors: vec!["vault of arrears".into()], connectors: vec![ "audit stair".into(), "arrears shaft".into(), "vent flue".into(), ], factions: vec!["The Gilded Audit".into()], water_path: "condensation runs off the vault seals".into(), secret_vents: vec!["arrears flue".into()], intent: "Works mid-floor: audit pressure before the flooded shafts.".into(), } } fn good_town() -> Town { Town { id: "milthollow".into(), name: "Milthollow".into(), pop: 312, inn: "The Gilded Whetstone".into(), chapel: "chapel of the Lamplighter".into(), services: vec!["inn".into(), "tinker".into()], contracts: vec!["rats_in_the_relay".into()], gossip_days: 3, intent: "Starter town: every service within a coaching-inn walk.".into(), } } #[test] fn items_happy_path() { assert!(validate_item(&good_item()).is_ok()); assert!(validate_items(&[good_item()]).is_ok()); } #[test] fn monsters_happy_path() { assert!(validate_monsters(&[good_monster()]).is_ok()); } #[test] fn hymns_happy_path() { assert!(validate_hymns(&[good_hymn()]).is_ok()); } #[test] fn gods_happy_path() { assert!(validate_gods(&[good_god()]).is_ok()); } #[test] fn contracts_happy_path() { assert!(validate_contracts(&[good_contract()]).is_ok()); } #[test] fn floors_happy_path() { assert!(validate_floors(&[good_floor()]).is_ok()); } #[test] fn towns_happy_path() { assert!(validate_towns(&[good_town()]).is_ok()); } #[test] fn rejects_bad_id() { let mut bad = good_item(); bad.id = "Lamp-Oil".into(); let errs = validate_items(&[bad]).expect_err("caps + dash must fail snake_case"); assert!(errs.iter().any(|e| e.message.contains("snake_case"))); assert!(errs.iter().all(|e| e.file == MEMORY_FILE)); } #[test] fn rejects_unknown_material() { let mut bad = good_item(); bad.material = "iron".into(); let errs = validate_items(&[bad]).expect_err("lowercase iron must fail"); assert!( errs.iter().any(|e| e.message.contains("unknown material")), "unexpected: {errs:?}" ); let mut worse = good_item(); worse.material = "MITHRIL".into(); let errs = validate_items(&[worse]).expect_err("mithril must fail"); assert!(errs.iter().any(|e| e.message.contains("MITHRIL"))); } #[test] fn rejects_single_use_item() { let mut bad = good_item(); bad.uses = vec!["burn".into()]; // The canon single validator keeps the lib.rs behavior with rich errors. let err = validate_item(&bad).expect_err("one use must fail"); assert_eq!(err.file, MEMORY_FILE); assert!(err.line.is_none()); assert!(err.message.contains("two-uses law")); // The pack validator routes through the same law (no conflicting logic). let errs = validate_items(&[bad]).expect_err("pack must fail too"); assert!(errs.iter().any(|e| e.message.contains("two-uses law"))); } #[test] fn rejects_empty_habit_and_tell_collecting_all_errors() { let mut bad = good_monster(); bad.habit = " ".into(); bad.tell = String::new(); let errs = validate_monsters(&[bad]).expect_err("empty habit+tell must fail"); // Collects ALL errors, never fails fast. assert!(errs.iter().any(|e| e.message.contains("habit"))); assert!(errs.iter().any(|e| e.message.contains("tell"))); assert!(errs.len() >= 2, "must not fail fast: {errs:?}"); } #[test] fn rejects_inverted_and_out_of_range_depths() { let mut bad = good_monster(); bad.depth_min = 6; bad.depth_max = 2; let errs = validate_monsters(&[bad]).expect_err("inverted range must fail"); assert!(errs .iter() .any(|e| e.message.contains("depth_min 6 > depth_max 2"))); let mut shallow = good_monster(); shallow.depth_min = 0; let errs = validate_monsters(&[shallow]).expect_err("depth 0 must fail"); assert!(errs.iter().any(|e| e.message.contains("depth_min"))); let mut deep = good_monster(); deep.depth_max = 13; let errs = validate_monsters(&[deep]).expect_err("depth 13 must fail"); assert!(errs.iter().any(|e| e.message.contains("depth_max"))); } #[test] fn rejects_stratum_mismatch_and_unknown_stratum() { let mut bad = good_floor(); bad.depth = 2; bad.stratum = "Deep".into(); let errs = validate_floors(&[bad]).expect_err("Shallows depth in Deep must fail"); assert!(errs.iter().any(|e| e.message.contains("does not match"))); let mut stranger = good_floor(); stranger.stratum = "deeps".into(); let errs = validate_floors(&[stranger]).expect_err("lowercase stratum must fail"); assert!(errs.iter().any(|e| e.message.contains("unknown stratum"))); } #[test] fn rejects_tier_zero_and_tier_six() { let mut bad = good_contract(); bad.tier = 0; let errs = validate_contracts(std::slice::from_ref(&bad)).expect_err("tier 0 must fail"); assert!(errs.iter().any(|e| e.message.contains("tier 0"))); bad.tier = 6; let errs = validate_contracts(std::slice::from_ref(&bad)).expect_err("tier 6 must fail"); assert!(errs.iter().any(|e| e.message.contains("tier 6"))); } #[test] fn rejects_zero_slots() { let mut bad = good_hymn(); bad.slots = 0; let errs = validate_hymns(&[bad]).expect_err("zero slots must fail"); assert!(errs.iter().any(|e| e.message.contains("slots"))); } #[test] fn rejects_empty_boons() { let mut bad = good_god(); bad.boons = vec![]; let errs = validate_gods(&[bad]).expect_err("no boons must fail"); assert!(errs.iter().any(|e| e.message.contains("1+ boons"))); } #[test] fn rejects_empty_blasphemy() { let mut bad = good_god(); bad.blasphemy = " ".into(); let errs = validate_gods(&[bad]).expect_err("blank blasphemy must fail"); assert!(errs.iter().any(|e| e.message.contains("blasphemy"))); } #[test] fn rejects_empty_intent_everywhere() { let mut item = good_item(); item.intent = String::new(); assert!( validate_items(&[item]).is_err(), "item without intent must fail" ); let mut monster = good_monster(); monster.intent = " \n ".into(); assert!( validate_monsters(&[monster]).is_err(), "monster without intent must fail" ); let mut boon_god = good_god(); boon_god.boons[0].intent = String::new(); let errs = validate_gods(&[boon_god]).expect_err("boon without intent must fail"); assert!(errs.iter().any(|e| e.message.contains("boon"))); } #[test] fn rejects_unknown_blood_and_lowercase_verb_and_blank_town_contract() { let mut bad = good_monster(); bad.blood = "weak_acid".into(); let errs = validate_monsters(&[bad]).expect_err("prose blood must fail"); assert!(errs.iter().any(|e| e.message.contains("unknown blood"))); let mut hymn = good_hymn(); hymn.verb = "Fiat lux".into(); let errs = validate_hymns(&[hymn]).expect_err("lowercase verb must fail"); assert!(errs.iter().any(|e| e.message.contains("caps"))); let mut town = good_town(); town.contracts = vec![" ".into()]; let errs = validate_towns(&[town]).expect_err("blank contract id must fail"); assert!(errs.iter().any(|e| e.message.contains("blank contract id"))); } #[test] fn rejects_nonpositive_numbers() { let mut bad = good_item(); bad.weight_kg = 0.0; let errs = validate_items(&[bad]).expect_err("zero weight must fail"); assert!(errs.iter().any(|e| e.message.contains("weight_kg"))); let mut light = good_item(); light.weight_kg = -1.5; assert!( validate_items(&[light]).is_err(), "negative weight must fail" ); let mut dead = good_monster(); dead.hp = 0; let errs = validate_monsters(&[dead]).expect_err("zero hp must fail"); assert!(errs.iter().any(|e| e.message.contains("hp"))); } #[test] fn error_display_shape() { let with_line = ContentError::new("monsters.toml", Some(12), "boom"); assert_eq!(with_line.to_string(), "monsters.toml:12: boom"); let without_line = ContentError::memory("boom"); assert_eq!(without_line.file, MEMORY_FILE); assert_eq!(without_line.line, None); assert_eq!(without_line.to_string(), ": boom"); } #[test] fn material_and_fluid_tables_match_core_protocol() { // Every name resolves; near-miss spellings do not. assert_eq!(VALID_MATERIALS.len(), 11); for name in VALID_MATERIALS { assert!(material_id(name).is_some(), "{name} must resolve"); } assert!(material_id("iron").is_none()); assert!(material_id("MITHRIL").is_none()); for name in VALID_FLUIDS { assert!(fluid_id(name).is_some(), "{name} must resolve"); } assert!(fluid_id("weak_acid").is_none()); assert!(fluid_id("BLOOD").is_none()); // Spot-check the ids against the core protocol values. assert_eq!( material_id("IRON"), Some(hexwork_core::voxel::material::IRON) ); assert_eq!( material_id("SAINT_BONE"), Some(hexwork_core::voxel::material::SAINT_BONE) ); assert_eq!(fluid_id("none"), Some(hexwork_core::fluids::fluid::NONE)); assert_eq!(fluid_id("blood"), Some(hexwork_core::fluids::fluid::BLOOD)); assert_eq!( fluid_id("aether_mist"), Some(hexwork_core::fluids::fluid::AETHER_MIST) ); } }