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 · 365 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366//! Notice-board logic over [`Contract`](crate::schema::Contract)//! (docs/03-systems-design.md §3.7).//!//! Design traces://! - docs/03 §3.7 (notice boards in every town: 60+ launch contracts across//! tiers vermin → hauntings → saints → Deep doors; each lists pay, known//! hazards, *what the poster didn't know* — discoverable by asking around,//! i.e. gossip checks; prep-before-fight law: every hard contract is//! solvable cleanly with the right loadout + questions asked).//! - docs/02-lore-bible.md §3 (county places: Milthollow, Copperwash Mere,//! Gallowgate Toll, Chapel of the Drowned Saint, Barrowfield) and §4//! (coaching-inn gossip sim: steal in Milthollow, Copperwash hears in 3//! days — gossip carries keywords with latency).//! - docs/02 §8 (mystery reserve — devs only): this module never confirms the//! came-back-right rumor. Contract prose hints at most; see the//! `vault_choice_*` intents, which name a seat and a question and nothing more.//!//! What this module owns (and does NOT own)://! - [`board_for`] filters a contract slice to what a town board shows and//! sorts it. Town scoping decision (parent-blessed): contracts carry NO town//! field — a contract names its poster + place in free text (`poster`), and//! towns list posted contract ids (`Town::contracts`). So per-town scoping//! is parent-side (intersect the board with the town's id list, e.g. the//! Milthollow-posts list); this function only gates on tier and sorts.//! Documented here so the wiring step is mechanical, not archaeological.//! - [`unknowns_hint`] returns the gossip-discoverable hook: the parent feeds//! its text into the Phase 9 gossip queue, which matches the embedded//! "ask X" keywords against NPC knowledge.//! - [`prep_sufficient`] is the machine-checked SHAPE of the prep-before-fight//! law (non-empty prep + hazards + hint). Full solvability — "do the notes//! plus §3.3 laws actually suffice, with no stat wall?" — is design review,//! not code; this gates emptiness so an empty field can never ship silent.//! - [`main_thread`] pins the 6 main-thread contract ids in story order//! (docs/03 §3.7: Widow Rennick's husband → Drowned Saint → Deep Door →//! Vault Choice, thin and ignorable to floor 8). The last two ids are the//! SEAL vs CROWN branch pair — both listed, player takes exactly one.//!//! Determinism law (docs/04 §4.10.2): pure reads over borrowed slices, no RNG,//! no time, no I/O, no threads. MSRV 1.75: no `let ... else`, no//! `is_multiple_of`, no inline format args capture.
use crate::schema::Contract;
/// Tier band minimums the canonical pack must meet (docs/03 §3.7 + §3.11:/// 60+ contracts). Tests pin these as FLOORS (allow growth, forbid shrink):/// tier 1 (vermin) ≥ 15, tier 2 (hauntings) ≥ 12, tier 3 (saints-and-tolls) ≥/// 10, tier 4 (Works doors) ≥ 6, tier 5 (Deep doors) ≥ 5.pub const TIER_MINIMUMS: [(u8, usize); 5] = [(1, 15), (2, 12), (3, 10), (4, 6), (5, 5)];
/// Town board listing: contracts with `tier <= tier_max`, sorted tier/// ascending then pay descending (docs/03 §3.7: vermin first, richest of the/// band on top).////// `town` selects nothing by itself — contracts carry no town field (see/// module docs): the parent intersects this result with the town's posted id/// list (`Town::contracts`, e.g. the Milthollow-posts list). The parameter is/// kept (named, not `_town`) because per-town tier caps ride on it next:/// Milthollow caps low tiers early-game, Deep-door towns uncap. Today every/// town passes its cap explicitly via `tier_max`; the name documents intent.////// Deterministic: stable sort over the input slice order, so equal/// (tier, pay) rows keep pack order.#[must_use]pub fn board_for<'a>(contracts: &'a [Contract], town: &str, tier_max: u8) -> Vec<&'a Contract> { let _ = town; let mut out: Vec<&'a Contract> = contracts.iter().filter(|c| c.tier <= tier_max).collect(); out.sort_by(|a, b| a.tier.cmp(&b.tier).then_with(|| b.pay_cu.cmp(&a.pay_cu))); out}
/// The gossip-discoverable hook (docs/03 §3.7: *what the poster didn't know*).////// Returns [`Contract::unknown_hint`] verbatim: the parent feeds this text/// into the Phase 9 gossip queue, which matches the embedded "ask X"/// keywords against NPC knowledge with coaching-inn latency (docs/02 §4)./// Validation guarantees non-empty; this accessor is the single choke point/// so the feed never drifts to a different field.#[must_use]pub fn unknowns_hint(contract: &Contract) -> &str { &contract.unknown_hint}
/// Prep-before-fight audit, machine-checked shape (docs/03 §3.7).////// True when `prep_notes`, `known_hazards`, AND `unknown_hint` are all/// non-empty (hazards: at least one non-blank entry). This gates emptiness:/// a contract with no stated loadout, no admitted hazard, or no gossip half/// cannot be "solvable cleanly with the right loadout + questions asked"/// because two of the three inputs are missing. Whether the notes PLUS the/// §3.3 laws actually suffice with no stat wall is design review — every/// canonical entry was reviewed at authoring (see each `intent`).#[must_use]pub fn prep_sufficient(contract: &Contract) -> bool { if contract.prep_notes.trim().is_empty() { return false; } if contract.unknown_hint.trim().is_empty() { return false; } if contract.known_hazards.is_empty() { return false; } contract.known_hazards.iter().any(|h| !h.trim().is_empty())}
/// The 6 main-thread contract ids in story order (docs/03 §3.7).////// `widow_rennick_husband` (tier 2, floor 3) → `drowned_saint_audience`/// (tier 3, floor 6 chapel) → `deep_door_ward` (tier 4, floor 8, Wardens/// seal) → the branch pair `vault_choice_seal` + `vault_choice_crown`/// (tier 5, floor 12, BOTH endings as contracts). The branch: SEAL buries/// the machine (chapel wall + dark-free towns), CROWN takes the Core's seat/// (NG+ haunts replays as a boss) — parent wires `vault_choice` + endings/// (see docs/phase10-wiring-plan.md §2). `drowned_saint` (tier 5, floor 11,/// bring-him-or-don't) is the thread's emotional climax but NOT a gate: the/// Choice pair is reachable without it, per the ignorable-to-8 law.#[must_use]pub fn main_thread() -> Vec<&'static str> { vec![ "widow_rennick_husband", "drowned_saint_audience", "deep_door_ward", "drowned_saint", "vault_choice_seal", "vault_choice_crown", ]}
#[cfg(test)]mod tests { use super::*; use crate::{load::load_file, validate::validate_contracts, ContractsFile};
fn good_contract(id: &str, tier: u8, pay: u32) -> Contract { Contract { id: id.into(), title: format!("title {id}"), tier, pay_cu: pay, poster: "Poster Name of Somewhere".into(), known_hazards: vec!["an honest hazard".into()], unknown_hint: "the poster does not know the cellar drafts lie; ask somebody".into(), floor: 2, prep_notes: "bring a pole and questions".into(), intent: "test fixture".into(), } }
/// Canonical pack straight from the data file (docs/04 §4.4: designers /// edit TOML, tests read the live file — never a hardcoded mirror). fn canonical() -> Vec<Contract> { let path = concat!( env!("CARGO_MANIFEST_DIR"), "/../../assets/data/contracts.toml" ); let data: ContractsFile = load_file(path).expect("contracts.toml must parse"); data.contract }
#[test] fn board_filters_by_tier_max() { let contracts = vec![ good_contract("a", 1, 20), good_contract("b", 3, 300), good_contract("c", 5, 2000), ]; let board = board_for(&contracts, "milthollow", 3); let ids: Vec<&str> = board.iter().map(|c| c.id.as_str()).collect(); assert_eq!(ids, vec!["a", "b"], "tier 5 filtered out: {ids:?}"); }
#[test] fn board_sorts_tier_then_pay_desc() { let contracts = vec![ good_contract("cheap_deep", 2, 60), good_contract("rich_vermin", 1, 60), good_contract("rich_haunt", 2, 150), good_contract("poor_vermin", 1, 20), ]; let board = board_for(&contracts, "milthollow", 5); let ids: Vec<&str> = board.iter().map(|c| c.id.as_str()).collect(); assert_eq!( ids, vec!["rich_vermin", "poor_vermin", "rich_haunt", "cheap_deep"], "tier asc, pay desc: {ids:?}" ); }
#[test] fn board_town_param_does_not_filter_by_itself() { // Town scoping is parent-side (module docs): same tier_max, different // town names, identical boards. let contracts = vec![good_contract("a", 1, 20)]; let m = board_for(&contracts, "milthollow", 5); let c = board_for(&contracts, "copperwash", 5); assert_eq!(m.len(), c.len()); assert_eq!(m[0].id, c[0].id); }
#[test] fn board_empty_in_empty_out() { let contracts: Vec<Contract> = vec![]; assert!(board_for(&contracts, "milthollow", 5).is_empty()); let contracts = vec![good_contract("a", 4, 500)]; assert!( board_for(&contracts, "milthollow", 1).is_empty(), "tier_max 1 hides tier 4" ); }
#[test] fn unknowns_hint_returns_the_hook_verbatim() { let c = good_contract("a", 1, 20); assert_eq!(unknowns_hint(&c), c.unknown_hint.as_str()); assert!( unknowns_hint(&c).contains("ask "), "gossip hook carries an ask-keyword: {:?}", unknowns_hint(&c) ); }
#[test] fn prep_sufficient_happy_path() { assert!(prep_sufficient(&good_contract("a", 1, 20))); }
#[test] fn prep_sufficient_rejects_each_empty_leg() { let mut no_prep = good_contract("a", 1, 20); no_prep.prep_notes = " ".into(); assert!(!prep_sufficient(&no_prep), "blank prep_notes fails");
let mut no_hint = good_contract("b", 1, 20); no_hint.unknown_hint = String::new(); assert!(!prep_sufficient(&no_hint), "blank unknown_hint fails");
let mut no_hazards = good_contract("c", 1, 20); no_hazards.known_hazards = vec![]; assert!(!prep_sufficient(&no_hazards), "empty hazards fails");
let mut blank_hazards = good_contract("d", 1, 20); blank_hazards.known_hazards = vec![" ".into()]; assert!(!prep_sufficient(&blank_hazards), "blank hazards fail"); }
#[test] fn main_thread_order_and_branch() { let thread = main_thread(); assert_eq!( thread.len(), 6, "widow → audience → ward → saint → SEAL+CROWN" ); assert_eq!(thread[0], "widow_rennick_husband"); assert_eq!(thread[1], "drowned_saint_audience"); assert_eq!(thread[2], "deep_door_ward"); assert_eq!(thread[3], "drowned_saint"); // Branch pair last: the player takes exactly one (parent wires). let branch = &thread[4..]; assert!( branch.contains(&"vault_choice_seal") && branch.contains(&"vault_choice_crown"), "both endings listed: {thread:?}" ); }
#[test] fn canonical_pack_parses_and_validates() { let contracts = canonical(); assert!( contracts.len() >= 60, "60+ launch contracts (docs/03 §3.11): got {}", contracts.len() ); validate_contracts(&contracts).expect("canonical contracts must validate"); }
#[test] fn canonical_unknowns_nonempty_everywhere() { for c in &canonical() { assert!( !unknowns_hint(c).trim().is_empty(), "'{}' lists nothing the poster didn't know (docs/03 §3.7)", c.id ); } }
#[test] fn canonical_prep_audit_everywhere() { let mut failing = Vec::new(); for c in &canonical() { if !prep_sufficient(c) { failing.push(c.id.clone()); } } assert!( failing.is_empty(), "prep-before-fight shape failures: {failing:?}" ); }
#[test] fn canonical_main_thread_ids_exist() { let contracts = canonical(); let ids: Vec<&str> = contracts.iter().map(|c| c.id.as_str()).collect(); for wanted in main_thread() { assert!( ids.contains(&wanted), "main-thread id '{wanted}' missing from canonical pack" ); } }
#[test] fn canonical_tier_spread_meets_minimums() { let contracts = canonical(); for (tier, min) in TIER_MINIMUMS { let count = contracts.iter().filter(|c| c.tier == tier).count(); assert!( count >= min, "tier {tier} has {count}, needs ≥{min} (docs/03 §3.7 spread)" ); } }
#[test] fn canonical_thread_floors_ascend() { // Thin-to-8 law (docs/03 §3.7): widow(3) → audience(6) → ward(8) → // saint(11) → choice pair(12). Floors must ascend along the thread. let contracts = canonical(); let floor_of = |id: &str| { contracts .iter() .find(|c| c.id == id) .unwrap_or_else(|| panic!("thread id '{id}' must exist")) .floor }; let floors: Vec<u8> = main_thread().iter().map(|id| floor_of(id)).collect(); assert_eq!(floors[0], 3, "widow on floor 3"); assert_eq!(floors[1], 6, "audience on floor 6"); assert_eq!(floors[2], 8, "ward on floor 8"); assert_eq!(floors[4], 12, "SEAL on floor 12"); assert_eq!(floors[5], 12, "CROWN on floor 12"); for pair in floors.windows(2) { assert!(pair[0] <= pair[1], "thread floors must ascend: {floors:?}"); } }
#[test] fn canonical_boss_puzzle_contracts_exist() { // Bosses-as-puzzles surface as contracts (docs/03 §3.8): rat-king, // Saint of Rust, Matriarch, Seraph, Drowned Saint. let contracts = canonical(); let ids: Vec<&str> = contracts.iter().map(|c| c.id.as_str()).collect(); for wanted in [ "rat_king_nest", "saint_of_rust_bargain", "toll_matriarch_terms", "audit_seraph_ledger", "drowned_saint", ] { assert!(ids.contains(&wanted), "boss contract '{wanted}' missing"); } }}