Something went wrong. Try again.
HEXWORK - A Lantern in the Deep: 3D ASCII terminal roguelike RPG in Rust.
Entirely designed and built by Muse Spark 1.3 to evaluate if I like the model.
Something went wrong. Try again.
34 kB · 938 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939//! 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<ContentError>`. Loaders attach real `file`/`line` afterwards (see//! [`ContentError`] and the parent-side notes in the return payload); direct//! in-memory use reports `file = "<memory>"` ([`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 = "<memory>";
/// 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<u8> { 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<u8> { 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>"` ([`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 `"<memory>"` for in-memory validation. pub file: String, /// 1-indexed source line, when the loader knows it. pub line: Option<usize>, /// 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<String>, line: Option<usize>, message: impl Into<String>) -> Self { Self { file: file.into(), line, message: message.into(), } }
/// Build an in-memory error (`file = "<memory>"`, no line). pub fn memory(message: impl Into<String>) -> 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<ContentError>> { 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<ContentError>> { 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<ContentError>> { 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<ContentError>> { 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<ContentError>> { 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<ContentError>> { 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<ContentError>> { let mut errors = Vec::new(); for town in towns { check_town(town, &mut errors); } collect(errors)}
fn collect(errors: Vec<ContentError>) -> Result<(), Vec<ContentError>> { if errors.is_empty() { Ok(()) } else { Err(errors) }}
fn check_id(errors: &mut Vec<ContentError>, 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<ContentError>, 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<ContentError>, 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<ContentError>) { 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<ContentError>) { 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<ContentError>) { 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<ContentError>) { 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<ContentError>) { 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<ContentError>) { 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<ContentError>) { 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(), "<memory>: 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) ); }}