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.
15 kB · 350 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351//! Content schemas: `serde` shapes for the seven validated content kinds.//!//! Design traces://! - docs/04-software-architecture.md §4.4 ("Content as data"): designers edit//! TOML, not Rust. Every monster/item/hymn/god/contract is a validated data//! file. Balance numbers carry `// intent:` comments — here that comment is//! the REQUIRED `intent` field on every struct, enforced non-empty by//! [`crate::validate`].//! - docs/02-lore-bible.md §5 (salvage economy: the 2-uses law), §6 (bestiary//! seeds: a habit, a tell, a use), §7 (magic words are boot commands in dead//! Latin: `FIAT LUX`, `APERI`, `MEMENTO`).//! - docs/03-systems-design.md §3.4 (item categories, identification, curses &//! blessings, conditions), §3.5 (hymns as punchcard programs, bargains as//! god transactions), §3.6 (town services), §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), §3.11 (volume targets: 40+ monsters, 120+//! items, 40+ hymns, 5 gods, 60+ contracts, 12 floors, towns).//!//! These structs are pure data ([`serde::Deserialize`]): no gameplay logic//! lives here (docs/04 §4.10 hard rule 1). Validation lives in//! [`crate::validate`]; TOML loading lives in workstream 2, which feeds//! in-memory structs into those validators.//!//! Canonical name spellings (see [`crate::validate`] for the enforced sets)://! - `Item::material` names a `hexwork_core::voxel::material` constant//! verbatim and case-sensitively: `AIR`, `WOOD`, `IRON`, `COPPER`, `STONE`,//! `BRASS`, `FLESH`, `SAINT_BONE`, `GLASS`, `ROPE`, `PAPER`.//! - `Monster::blood` names a `hexwork_core::fluids::fluid` kind lowercased,//! or `"none"`: `none`, `water`, `oil`, `blood`, `bile`, `aether_mist`.//! - `Floor::stratum` is one of `Shallows`, `Works`, `Deep`.//! - `Item::category` spells the [`ItemCategory`] variant lowercase in TOML//! (`category = "weapon"`); `TradeGood` also accepts `"trade"`.//! (docs/03 §3.4 prose).
use serde::Deserialize;
/// Item category (docs/03 §3.4).////// TOML spells the variant lowercase: `category = "weapon"`. `TradeGood`/// accepts the alias `"trade"` (docs/03 §3.4 prose) alongside `"tradegood"`.#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]#[serde(rename_all = "lowercase")]pub enum ItemCategory { /// Weapons — polearms rule in shafts; silvered vs. the Drowned. Weapon, /// Armor — material matters more than rating. Armor, /// Tools — rope, pitons, poles, nets, sledges, bells, flour, chalk. Tool, /// Consumables — oil, rations, moss, bandages, antidotes. Consumable, /// Relics — hymn-cards, aether-cells, saint-bone charms, Drowned bells. Relic, /// Trade goods — wire, pelts, scrap. #[serde(alias = "trade")] TradeGood, /// Corpses — butcher/eat/carry/cure (NetHack law). Corpse,}
/// One validated game item (docs/02 §5, docs/03 §3.4, docs/04 §4.4).////// The 2-uses law (docs/02 §5: "everything has at least two uses") is enforced/// on [`Item::uses`] by validation: 2+ entries, each non-blank.#[derive(Clone, Debug, PartialEq, Deserialize)]#[serde(deny_unknown_fields)]pub struct Item { /// snake_case id (`lamp_oil`, never `Lamp Oil`). pub id: String, /// Display name. pub name: String, /// Voxel material name, verbatim (`IRON`, `COPPER`, … — see module docs). pub material: String, /// Item category (TOML: `category = "weapon"`, or `"trade"` for trade goods). pub category: ItemCategory, /// Haul weight in kg; must be > 0 (docs/03 §3.2 HAUL: weight + bulk). pub weight_kg: f32, /// Inventory bulk (Tetris sizing, docs/03 §3.1). pub bulk: u8, /// Base price in copper units (docs/02 §5: coppers, silver, oil scrip). pub base_price_cu: u32, /// 2+ declared uses (docs/02 §5). The two-uses law is a TEST, not a guideline. pub uses: Vec<String>, /// Identification state (docs/03 §3.4: by use, by tinker, by trial room, by ghost). pub identified: bool, /// NetHack-descended: cursed gear sticks and lies (docs/03 §3.4). pub cursed: bool, /// Blessed gear hums true (docs/03 §3.4). pub blessed: bool, /// Condition: rust, damp, cracked, humming, owed, … (docs/03 §3.4). pub condition: String, /// Silver-plate against the Drowned (docs/03 §3.3 law 6: silver stills Drowned). pub silvered: bool, /// Audit-illegal goods empty shops on sight (docs/03 §3.6: dropping a /// Drowned bell in a shop empties it — then the keeper bills you). pub illegal: bool, /// Machine tags for sim rules (docs/03 §3.4): `polearm` (shaft bonus), /// `silvered-weapon` alias honesty, `armor-head/body`, `tool-*`, `corpse`, /// `perishable`, … Non-blank entries; empty = no special rules. #[serde(default)] pub tags: Vec<String>, /// The `// intent:` balance comment (docs/04 §4.4). REQUIRED non-empty: /// designers must say why the numbers are what they are. pub intent: String,}
/// One monster (docs/02 §6, docs/04 §4.4).////// Design law per monster: a habit, a tell, a use — all three required.#[derive(Clone, Debug, PartialEq, Deserialize)]#[serde(deny_unknown_fields)]pub struct Monster { /// snake_case id (`cable_rat`). pub id: String, /// Display name. pub name: String, /// Hit points; must be > 0. pub hp: i32, /// Ecology/behavior note (docs/02 §6). Non-empty. pub habit: String, /// Player-readable warning sign, e.g. ozone smell (docs/02 §6). Non-empty. pub tell: String, /// 1+ declared uses, e.g. hinge-eater weak-acid blood (docs/02 §6). Non-blank entries. pub uses: Vec<String>, /// Whether the monster's body conducts hymns/shocks (docs/03 §3.3 law 8; /// `hexwork_core::materials::conducts` owns the material side). pub conducts: bool, /// Fluid left behind, lowercased fluid-kind name or `"none"` (see module docs). pub blood: String, /// Named fears for AI courage checks (docs/04 §4.2 phase 1 intents). pub fears: Vec<String>, /// Shallowest floor the monster appears on, 1–12. pub depth_min: u8, /// Deepest floor the monster appears on, 1–12; must be >= `depth_min`. pub depth_max: u8, /// Bosses-as-puzzles framing (docs/03 §3.8): rat-king, Saint of Rust, … pub boss: bool, /// The `// intent:` balance comment (docs/04 §4.4). REQUIRED non-empty. pub intent: String,}
/// One hymn: a punchcard program slotted into a hymn-case (docs/03 §3.5).#[derive(Clone, Debug, PartialEq, Deserialize)]#[serde(deny_unknown_fields)]pub struct Hymn { /// snake_case id (`hymn_12_elevator`). pub id: String, /// Dead-Latin boot verb in caps: `FIAT LUX`, `APERI`, … (docs/02 §7). pub verb: String, /// Rules effect text. pub effect: String, /// Base conduct chance 0–100 (Aether + card condition + copper nearby /// modify at cast time; Phase 8 owns the roll). pub conduct_base: u8, /// Hymn-case slots occupied; must be 1+ (loadout strategy, docs/03 §3.5). pub slots: u8, /// Whether the card is fragile (heat sets off hymn-cards, docs/03 §3.3 law 5). pub card_fragile: bool, /// 1+ miscast outcomes; miscasts follow debugging logic (docs/03 §3.5). pub miscast_table: Vec<String>, /// The `// intent:` balance comment (docs/04 §4.4). REQUIRED non-empty. pub intent: String,}
/// One divine boon: a bargain line in a god's ledger (docs/02 §2, docs/03 §3.5).////// Witcher rule: gods are transactional — every boon has small print.#[derive(Clone, Debug, PartialEq, Deserialize)]#[serde(deny_unknown_fields)]pub struct Boon { /// snake_case id (`lamplighter_hold_the_light`). pub id: String, /// What the delver asks for. pub ask: String, /// The small print: what the god takes (debt, never free). Non-empty. pub price: String, /// The trap inside the price: the clause that bites (docs/02 §2: every /// boon has small print). Non-empty. pub small_print: String, /// The `// intent:` balance comment (docs/04 §4.4). REQUIRED non-empty. pub intent: String,}
/// One small god: a fragment of the Core's attention (docs/02 §2).////// Prayer is a UI for a bargain; piety = credit score, blasphemy = debt/// collection at 3D coordinates of the god's choosing.#[derive(Clone, Debug, PartialEq, Deserialize)]#[serde(deny_unknown_fields)]pub struct God { /// snake_case id (`the_lamplighter`). pub id: String, /// Display name. pub name: String, /// What the god governs: mercy, passage, grief, decay, arrears, … pub domain: String, /// The bargain table. Must hold 1+ boons. pub boons: Vec<Boon>, /// What collections looks like — REQUIRED non-empty (the god always collects). pub blasphemy: String, /// The `// intent:` balance comment (docs/04 §4.4). REQUIRED non-empty. pub intent: String,}
/// One notice-board contract: the Witcher heartbeat (docs/03 §3.7).////// Each lists pay, known hazards, and *what the poster didn't know*/// ([`Contract::unknown_hint`], discoverable by asking around — gossip checks)./// Prep-before-fight law: every hard contract is solvable cleanly with the/// right loadout + questions asked.#[derive(Clone, Debug, PartialEq, Deserialize)]#[serde(deny_unknown_fields)]pub struct Contract { /// snake_case id (`rats_in_the_relay`). pub id: String, /// Board title. pub title: String, /// Contract tier, 1–5 (vermin → hauntings → saints → Deep doors). pub tier: u8, /// Pay in copper units. pub pay_cu: u32, /// Who posted it (gossip checks start here). pub poster: String, /// Hazards the poster admits to. pub known_hazards: Vec<String>, /// What the poster doesn't know — REQUIRED non-empty (docs/03 §3.7). pub unknown_hint: String, /// Floor the work is on, 1–12. pub floor: u8, /// Loadout/approach notes (prep-before-fight law, docs/03 §3.7). pub prep_notes: String, /// The `// intent:` balance comment (docs/04 §4.4). REQUIRED non-empty. pub intent: String,}
/// One dungeon floor of the 12-floor Deep (docs/02 §3, docs/03 §3.8).////// Strata: Shallows 1–3 (tutorializes verticality kindly), Works 4–8 (the game/// proper), Deep 9–12 (dream-logic, Choir territory). Every floor carries 3+/// vertical connections, an airflow network, faction presence, and a secret/// vent layer in its data (workstream 2); the schema pins the shape.#[derive(Clone, Debug, PartialEq, Deserialize)]#[serde(deny_unknown_fields)]pub struct Floor { /// snake_case id (`floor_06_audit_vaults`). pub id: String, /// Display name (`Copperwash Undermere`). pub name: String, /// Depth, 1–12. Must agree with [`Floor::stratum`]. pub depth: u8, /// `Shallows` (1–3) | `Works` (4–8) | `Deep` (9–12). Exact spelling. pub stratum: String, /// Hand-designed anchor rooms; 1+ (docs/03 §3.8: anchors + systemic fill). pub anchors: Vec<String>, /// Vertical connections: stairs, shafts, vents, wells, elevators (docs/03 /// §3.8: 3+ per floor). Named strings designers write; worldgen resolves. pub connectors: Vec<String>, /// Faction presence on this floor (docs/02 §4). pub factions: Vec<String>, /// Where the water runs (docs/03 §3.3 law 4: every floor has a water path). pub water_path: String, /// Hidden vent edges awaiting flour/smoke reveal (docs/03 §3.3 law 3). /// Named strings (the flour-reveal play names them); count = len. pub secret_vents: Vec<String>, /// The `// intent:` balance comment (docs/04 §4.4). REQUIRED non-empty. pub intent: String,}
/// One town: the sim runs without the player (docs/02 §3–4, docs/03 §3.6).////// Townsfolk have names, jobs, homes, debts, gossip; coaches carry gossip/// between towns with latency (Milthollow→Copperwash: 3 days).#[derive(Clone, Debug, PartialEq, Deserialize)]#[serde(deny_unknown_fields)]pub struct Town { /// snake_case id (`milthollow`). pub id: String, /// Display name. pub name: String, /// Population (Milthollow: 312, docs/02 §3). pub pop: u32, /// Coaching inn, verbatim (docs/02 §3: The Gilded Whetstone). pub inn: String, /// Chapel, verbatim (docs/02 §3: chapel of the Lamplighter). pub chapel: String, /// Services: inn, tinker, lampwright, warden post, menders, fence, auction, … pub services: Vec<String>, /// Notice-board contract ids. Entries must be non-empty strings; id /// existence is cross-checked by the parent against loaded packs. pub contracts: Vec<String>, /// Gossip latency in days (steal in Milthollow, Copperwash hears in 3). pub gossip_days: u8, /// The `// intent:` balance comment (docs/04 §4.4). REQUIRED non-empty. pub intent: String,}
#[cfg(test)]mod tests { use super::*;
/// Minimal item TOML in the shape workstream 2 loaders will produce. const ITEM_TOML: &str = r#"id = "lamp_oil"name = "Lamp oil"material = "GLASS"category = "consumable"weight_kg = 0.8bulk = 1base_price_cu = 5uses = ["burn", "bribe (scrip)"]identified = truecursed = falseblessed = falsecondition = "sealed"silvered = falseillegal = falseintent = "Oil scrip is legal tender: drinking money is literally burning money.""#;
#[test] fn item_parses_from_toml() { let item: Item = toml::from_str(ITEM_TOML).expect("fixture must parse"); assert_eq!(item.id, "lamp_oil"); assert_eq!(item.category, ItemCategory::Consumable); assert_eq!(item.uses.len(), 2); }
#[test] fn unknown_fields_are_rejected() { let bad = format!("{ITEM_TOML}bogus_field = 1\n"); let parsed: Result<Item, _> = toml::from_str(&bad); assert!(parsed.is_err(), "deny_unknown_fields must reject typos"); }
#[test] fn category_spelling_is_lowercase_with_trade_alias() { // Lowercase is the canon (docs/03 §3.4 prose); PascalCase is rejected. let parsed: Item = toml::from_str(ITEM_TOML).expect("lowercase fixture must parse"); assert_eq!(parsed.category, ItemCategory::Consumable); let pascal = ITEM_TOML.replace("category = \"consumable\"", "category = \"Consumable\""); assert!( toml::from_str::<Item>(&pascal).is_err(), "PascalCase category must fail (canon is lowercase)" ); // "trade" is the docs-prose alias for TradeGood. let trade = ITEM_TOML.replace("category = \"consumable\"", "category = \"trade\""); let parsed: Item = toml::from_str(&trade).expect("\"trade\" alias must parse"); assert_eq!(parsed.category, ItemCategory::TradeGood); }}