//! 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 { 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 = 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 = 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"); } } }