From 3eaa4ece7061011250ffd08a4651c84f9cdc6df2 Mon Sep 17 00:00:00 2001 From: "david.yapfest.club" Date: Thu, 3 Sep 2026 23:10:42 -0400 Subject: [PATCH] Phase 8 magic: hymn casting, verb resolvers, bargains, chapel text, gate green - core: hymn.rs (case/conduct/resolve/cook-off/attention) + casting.rs (7 verbs, classifier) - core: bargain.rs (kneel/repay/due/blaspheme/pay_lien, credit numbers pinned) - content: gods.rs (boon lookup, table validation) + ui: chapel.rs (bargain/blasphemy/memory text) - state: hymn_cases/cast_queue/bargains/deep_weather, Hymns phase body, fragile cook-off, death debt-settle, hash extended - tests/phase8_magic.rs: mirror, cast-light, classifier, conduct spots, tables, credit ladder - 574 tests green, clippy -D warnings clean, fmt clean --- crates/hexwork-content/src/gods.rs | 262 +++++++++ crates/hexwork-content/src/lib.rs | 2 + crates/hexwork-core/src/bargain.rs | 641 ++++++++++++++++++++++ crates/hexwork-core/src/casting.rs | 478 ++++++++++++++++ crates/hexwork-core/src/hymn.rs | 448 +++++++++++++++ crates/hexwork-core/src/lib.rs | 3 + crates/hexwork-core/src/state.rs | 302 +++++++++- crates/hexwork-core/tests/phase8_magic.rs | 193 +++++++ crates/hexwork-ui/src/chapel.rs | 192 +++++++ crates/hexwork-ui/src/lib.rs | 1 + docs/06-implementation-checklist.md | 12 +- docs/phase8-wiring-plan.md | 61 ++ 12 files changed, 2588 insertions(+), 7 deletions(-) create mode 100644 crates/hexwork-content/src/gods.rs create mode 100644 crates/hexwork-core/src/bargain.rs create mode 100644 crates/hexwork-core/src/casting.rs create mode 100644 crates/hexwork-core/src/hymn.rs create mode 100644 crates/hexwork-core/tests/phase8_magic.rs create mode 100644 crates/hexwork-ui/src/chapel.rs create mode 100644 docs/phase8-wiring-plan.md diff --git a/crates/hexwork-content/src/gods.rs b/crates/hexwork-content/src/gods.rs new file mode 100644 index 0000000..679a97b --- /dev/null +++ b/crates/hexwork-content/src/gods.rs @@ -0,0 +1,262 @@ +//! Bargain-table access over [`God`](crate::schema::God) (docs/02 §2, docs/03 §3.5). +//! +//! Design traces: +//! - docs/02-lore-bible.md §2 (gods are transactional: every boon has small +//! print; prayer is a UI for a bargain; blasphemy = debt collection). +//! - docs/03-systems-design.md §3.5 (bargains: ask, accept small print; +//! Lamplighter / Tollkeeper / Rustmother example tables). +//! - docs/04-software-architecture.md §4.4 ("Content as data"): designers edit +//! TOML, not Rust — so these helpers only READ validated in-memory structs +//! and never embed bargain prose themselves. +//! +//! This module mirrors [`crate::validate`] style (same field classes: blank +//! `ask`/`price`/`small_print`/`blasphemy`, empty tables) but returns plain +//! `Vec` messages instead of `ContentError`s: the parent unifies +//! error types at wiring time. Documented here so the wiring step is +//! mechanical, not archaeological. +//! +//! No gameplay logic lives here (docs/04 §4.10 hard rule 1): debt clocks, +//! god-credit, and collections live in `hexwork_core::bargain`. These are +//! lookup + hygiene helpers over the data side. + +use std::collections::BTreeMap; + +use crate::schema::{Boon, God}; + +/// Find one boon line in a god's bargain table, by boon id. +/// +/// Exact match (case-sensitive): boon ids are `snake_case` machine keys +/// (`lamplighter_hold_the_light`), never display prose. Returns `None` +/// when the god offers no such line — including for the synthetic +/// `collections` id that `hexwork_core::bargain` stamps on imposed +/// collections debts (collections are not on offer, they are imposed). +#[must_use] +pub fn boon_by_id<'a>(god: &'a God, boon_id: &str) -> Option<&'a Boon> { + god.boons.iter().find(|b| b.id == boon_id) +} + +/// Ids of every god whose bargain table offers `boon_id`. +/// +/// Order follows the input slice (pack order), so results are deterministic +/// for a given pack load. Empty when no god offers the line. +#[must_use] +pub fn gods_with_boon<'a>(gods: &'a [God], boon_id: &str) -> Vec<&'a str> { + gods.iter() + .filter(|g| g.boons.iter().any(|b| b.id == boon_id)) + .map(|g| g.id.as_str()) + .collect() +} + +/// Check one god's bargain table; collect ALL problems as messages. +/// +/// Error classes (mirrors [`crate::validate`] phrasing where it overlaps): +/// - empty table (no boons — a god with nothing to sell is not transactional); +/// - blank boon id (empty ids collide in lookups, so they are flagged even +/// though the strict spec lists only ask/price/small_print/blasphemy); +/// - blank `ask` / `price` / `small_print` per boon (every boon has small +/// print, docs/02 §2 — a blank clause is a missing trap, i.e. a bug); +/// - blank `blasphemy` (the god always collects, docs/02 §2); +/// - duplicate boon ids within the table (bargain lookups need unique keys). +/// +/// Returns an empty `Vec` when the table is clean. The parent unifies these +/// `String`s into its own error type at wiring; this module stays free of +/// that decision on purpose. +#[must_use] +pub fn validate_bargain_table(god: &God) -> Vec { + let mut errors = Vec::new(); + let ctx = format!("god '{}'", god.id); + if god.boons.is_empty() { + errors.push(format!( + "{ctx}: must offer 1+ boons (gods are transactional, docs/02 §2)" + )); + } + let mut seen: BTreeMap<&str, usize> = BTreeMap::new(); + for boon in &god.boons { + *seen.entry(boon.id.as_str()).or_insert(0) += 1; + let bctx = format!("{ctx} boon '{}'", boon.id); + if boon.id.trim().is_empty() { + errors.push(format!("{bctx}: id must be non-empty (lookups key on it)")); + } + if boon.ask.trim().is_empty() { + errors.push(format!( + "{bctx}: ask must be non-empty (what the delver asks for)" + )); + } + if boon.price.trim().is_empty() { + errors.push(format!( + "{bctx}: price must be non-empty (every boon has small print, docs/02 §2)" + )); + } + if boon.small_print.trim().is_empty() { + errors.push(format!( + "{bctx}: small_print must be non-empty (the clause that bites, docs/02 §2)" + )); + } + } + for (id, count) in &seen { + if *count > 1 { + errors.push(format!( + "{ctx}: boon id '{id}' is offered {count} times (bargain tables need unique boon ids)" + )); + } + } + if god.blasphemy.trim().is_empty() { + errors.push(format!( + "{ctx}: blasphemy must be non-empty — the god always collects (docs/02 §2)" + )); + } + errors +} + +#[cfg(test)] +mod tests { + use super::*; + + fn good_boon(id: &str) -> Boon { + Boon { + id: id.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(), + } + } + + fn good_god() -> God { + God { + id: "the_lamplighter".into(), + name: "The Lamplighter".into(), + domain: "mercy, light, owed debts".into(), + boons: vec![ + good_boon("lamplighter_hold_the_light"), + good_boon("lamplighter_way_home"), + ], + 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(), + } + } + + #[test] + fn boon_lookup_hits() { + let god = good_god(); + let boon = boon_by_id(&god, "lamplighter_hold_the_light").expect("must find the line"); + assert_eq!(boon.ask, "hold my light when I cannot"); + assert!(!boon.small_print.is_empty(), "small print rides along"); + } + + #[test] + fn boon_lookup_misses() { + let god = good_god(); + assert!(boon_by_id(&god, "no_such_boon").is_none()); + assert!( + boon_by_id(&god, "collections").is_none(), + "imposed collections are not on offer" + ); + assert!( + boon_by_id(&god, "Lamplighter_Hold_The_Light").is_none(), + "ids are exact-match" + ); + } + + #[test] + fn gods_with_boon_lists_offering_gods_in_pack_order() { + let mut second = good_god(); + second.id = "the_tollkeeper".into(); + let gods = vec![good_god(), second]; + let found = gods_with_boon(&gods, "lamplighter_way_home"); + assert_eq!( + found, + vec!["the_lamplighter", "the_tollkeeper"], + "pack order kept" + ); + } + + #[test] + fn gods_with_boon_empty_when_none_offer() { + let gods = vec![good_god()]; + assert!(gods_with_boon(&gods, "no_such_boon").is_empty()); + assert!(gods_with_boon(&[], "lamplighter_way_home").is_empty()); + } + + #[test] + fn validation_happy_table_is_silent() { + assert!(validate_bargain_table(&good_god()).is_empty()); + } + + #[test] + fn validation_rejects_empty_table() { + let mut god = good_god(); + god.boons = vec![]; + let errs = validate_bargain_table(&god); + assert!( + errs.iter().any(|e| e.contains("1+ boons")), + "must demand a table: {errs:?}" + ); + } + + #[test] + fn validation_rejects_blank_ask() { + let mut god = good_god(); + god.boons[0].ask = " ".into(); + let errs = validate_bargain_table(&god); + assert!( + errs.iter().any(|e| e.contains("ask")), + "blank ask flagged: {errs:?}" + ); + assert_eq!(errs.len(), 1, "only the ask is wrong: {errs:?}"); + } + + #[test] + fn validation_rejects_blank_price() { + let mut god = good_god(); + god.boons[1].price = String::new(); + let errs = validate_bargain_table(&god); + assert!( + errs.iter().any(|e| e.contains("price")), + "blank price flagged: {errs:?}" + ); + } + + #[test] + fn validation_rejects_blank_small_print() { + let mut god = good_god(); + god.boons[0].small_print = "\n\t ".into(); + let errs = validate_bargain_table(&god); + assert!( + errs.iter().any(|e| e.contains("small_print")), + "blank small print flagged: {errs:?}" + ); + } + + #[test] + fn validation_rejects_blank_blasphemy() { + let mut god = good_god(); + god.blasphemy = " ".into(); + let errs = validate_bargain_table(&god); + assert!( + errs.iter().any(|e| e.contains("blasphemy")), + "the god must always collect: {errs:?}" + ); + } + + #[test] + fn validation_rejects_duplicate_boon_ids() { + let mut god = good_god(); + god.boons.push(good_boon("lamplighter_hold_the_light")); + let errs = validate_bargain_table(&god); + assert!( + errs.iter() + .any(|e| e.contains("lamplighter_hold_the_light") && e.contains("2 times")), + "dup id flagged with count: {errs:?}" + ); + } + + #[test] + fn validation_collects_all_problems_at_once() { + let mut god = good_god(); + god.boons = vec![]; + god.blasphemy = String::new(); + let errs = validate_bargain_table(&god); + assert_eq!(errs.len(), 2, "empty table AND blank blasphemy: {errs:?}"); + } +} diff --git a/crates/hexwork-content/src/lib.rs b/crates/hexwork-content/src/lib.rs index a7cd286..5c7dd02 100644 --- a/crates/hexwork-content/src/lib.rs +++ b/crates/hexwork-content/src/lib.rs @@ -1,6 +1,7 @@ //! hexwork-content: schema structs + validation + loaders (docs/04 §4.4). //! Designers edit TOML data files, not Rust. The two-uses law is a TEST. +pub mod gods; pub mod identify; pub mod load; pub mod pack; @@ -8,6 +9,7 @@ pub mod schema; pub mod validate; // Designer-facing surface: schema types, validators, loaders, pack hashing. +pub use gods::{boon_by_id, gods_with_boon, validate_bargain_table}; pub use identify::{ ghost_identify, identify_by_use, shop_label, tinker_identify, trial_room_check, IdfBook, IdfRecord, IdfState, TinkerOut, TrialRoom, diff --git a/crates/hexwork-core/src/bargain.rs b/crates/hexwork-core/src/bargain.rs new file mode 100644 index 0000000..aec7ff7 --- /dev/null +++ b/crates/hexwork-core/src/bargain.rs @@ -0,0 +1,641 @@ +//! Bargains with the Small Gods: kneel anywhere, owe in writing. +//! +//! Design traces: +//! - docs/03-systems-design.md §3.5 (BARGAINS: kneel anywhere, but altars give +//! receipt; ask, accept small print; Lamplighter / Tollkeeper / Rustmother +//! example debts; blasphemy = collections). +//! - docs/02-lore-bible.md §2 (the Small Gods are transactional: prayer is a +//! UI for a bargain, every boon has small print, every altar keeps a +//! ledger; piety = credit score, blasphemy = debt collection at 3D +//! coordinates of the god's choosing). +//! - docs/03 §3.1 + §3.9 (god-credit persists as progression: Reputation IS +//! progression, and lineage knowledge outlives the delver). +//! - docs/03 §3.4 (owed-condition moment: paying an Audit lien mid-dungeon +//! via prayer is a *moment*). +//! +//! ## The two-sided ledger (read this before wiring) +//! +//! A bargain moves TWO records, and they live in different places on purpose: +//! +//! - [`Reputation`](crate::reputation::Reputation) god-credit is the NUMERIC +//! side (piety as credit score). It persists per docs/03 §3.1/§3.9 and is +//! adjusted here via +//! [`Reputation::adjust_god`](crate::reputation::Reputation::adjust_god) — +//! never duplicated, never reimplemented. +//! - [`Ledger`] is the TEXT side: what is owed, in words, per delver +//! ("bury the next dead", "never pass a toll unpaid again"). Debts are sim +//! state; COLLECTION is parent-side (cherubs, black-oil smoke, doors that +//! remember being cheated — all need positions, factions, and the tick +//! loop, none of which live here). +//! +//! ## Receipts (kneel anywhere; ALTARS GIVE RECEIPT) +//! +//! A debt knealt in the mud is unreceipted: no altar witnessed it, so when +//! the tenday expires the god collects ABRUPTLY (no warning, coordinates of +//! the god's choosing). A debt knealt at an altar carries +//! [`Debt::altar_receipt`]: the chapel ledger notes it, so collections come +//! POLITELY — warning first, then the bill. This module only records the +//! flag; the polite/abrupt collection behavior is parent-side. +//! +//! ## Clocks +//! +//! - Tenday debts (`ticks_left = Some(due)`) expire on the sim tick; see +//! [`TENDAY_TICKS`] and [`due`]. +//! - Until-done debts (`ticks_left = None`: burials, escort-lamps, "waste +//! nothing for a day") have no clock. Their completion is detected by +//! parent-side action hooks (burial rites, toll paid, leftovers tracked), +//! which call [`repay`]. [`due`] never reports them. +//! +//! Core determinism law: pure functions only. No RNG, no time, no I/O, no +//! threads. All arithmetic saturates; debt order is insertion order. + +use crate::reputation::{GodId, Reputation}; + +/// Ticks in one tenday: tenday debts come due at `tick + TENDAY_TICKS` +/// (docs/03 §3.5: collections arrive "for a tenday"). +pub const TENDAY_TICKS: u64 = 8640; + +/// God-credit for kneeling: kneeling pleases (+10). Piety is a credit score +/// (docs/02 §2), and showing up to bargain is the minimum viable piety. +pub const KNEEL_FAVOR: i16 = 10; + +/// God-credit for repaying a debt in full (+25). Paid debts are remembered +/// fondly; the chapel ledger notes the settlement. +pub const REPAY_GRATITUDE: i16 = 25; + +/// God-credit for blasphemy (-500). Blasphemy is not a mood, it is +/// collections: a deep, pinned negative (docs/02 §2). +pub const BLASPHEMY_PRICE: i16 = -500; + +/// Copper units per point of lien-alms credit (see [`pay_lien`]). +pub const ALMS_DIVISOR: u32 = 10; + +/// Boon id stamped on the collections debt pushed by [`blaspheme`]. +/// +/// It is a synthetic id (no bargain table lists it), so +/// content-side `boon_by_id` lookups will miss it on purpose: collections +/// are not on offer, they are imposed. Parent-side hooks may clear it with +/// [`repay`] once the god is satisfied. +pub const COLLECTIONS_BOON: &str = "collections"; + +/// One owed thing, in words (docs/02 §2: every altar keeps a ledger). +/// +/// The TEXT side of a bargain: `boon` names which bargain line this pays +/// for (a `Boon::id` from the god's table, or [`COLLECTIONS_BOON` for +/// imposed collections), and `owed` is the debt verbatim ("bury the next +/// dead you find with full rites"). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Debt { + /// Which god is owed. + pub god: GodId, + /// Boon id from the god's bargain table (or [`COLLECTIONS_BOON`]). + pub boon: String, + /// The debt in words, e.g. "bury the next dead". + pub owed: String, + /// `Some(due_tick)` for tenday collections, `None` for until-done + /// debts (burials, escort-lamps) completed via parent action hooks. + pub ticks_left: Option, + /// True when knealt at an altar: collections warn first. False (mud + /// bargains): collections come abruptly. Parent-side behavior; this + /// flag is the only record. + pub altar_receipt: bool, +} + +/// Per-delver bargain ledger: every open debt, in words, in kneeling order. +/// +/// Insertion order is load-bearing: [`repay`] clears the FIRST matching +/// debt (the oldest unresolved instance of that boon), and [`due`] +/// reports indices into this exact order. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Ledger { + /// Open debts, oldest first. + pub debts: Vec, +} + +impl Ledger { + /// An empty ledger: owing nothing, to no one, yet. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Number of open debts. + #[must_use] + pub fn len(&self) -> usize { + self.debts.len() + } + + /// True when nothing is owed. + #[must_use] + pub fn is_empty(&self) -> bool { + self.debts.is_empty() + } +} + +/// Kneel and bargain (docs/03 §3.5: kneel ANYWHERE, but altars give receipt). +/// +/// Pushes one [`Debt`]: `tenday = true` starts a tenday clock +/// (`ticks_left = Some(tick + TENDAY_TICKS)`); `tenday = false` records an +/// until-done debt (`None`: a burial, an escort-lamp — resolved parent-side +/// via [`repay`]). `altar = true` stamps the altar receipt (collections +/// will warn first); mud bargains collect abruptly. +/// +/// Kneeling pleases: god-credit rises by [`KNEEL_FAVOR`] (+10) via +/// [`Reputation::adjust_god`](crate::reputation::Reputation::adjust_god). +/// Returns the ledger length after the bargain (the new debt's 1-based count). +/// +/// Eight parameters is over the clippy threshold on purpose: every one is a +/// load-bearing bargain term (who, which line, what words, clock or +/// until-done, when, witnessed or mud). Callers pass them through from the +/// bargain-table row, never invent them. +#[allow(clippy::too_many_arguments)] +#[must_use] +pub fn kneel( + ledger: &mut Ledger, + rep: &mut Reputation, + god: GodId, + boon: &str, + owed: &str, + tenday: bool, + tick: u64, + altar: bool, +) -> usize { + let ticks_left = if tenday { + Some(tick.saturating_add(TENDAY_TICKS)) + } else { + None + }; + ledger.debts.push(Debt { + god, + boon: boon.to_owned(), + owed: owed.to_owned(), + ticks_left, + altar_receipt: altar, + }); + rep.adjust_god(god, KNEEL_FAVOR); + ledger.len() +} + +/// Repay a debt: remove the FIRST (oldest) debt matching `boon`. +/// +/// Returns true when a debt was cleared — and the cleared debt's god is +/// gratified by [`REPAY_GRATITUDE`] (+25). Returns false when nothing +/// matches (ledger and credit untouched). Until-done debts (burials, +/// tolls honored, leftovers tracked) are completed exactly this way, by +/// parent-side action hooks that witnessed the deed. +#[must_use] +pub fn repay(ledger: &mut Ledger, rep: &mut Reputation, boon: &str) -> bool { + let Some(idx) = ledger.debts.iter().position(|d| d.boon == boon) else { + return false; + }; + let debt = ledger.debts.remove(idx); + rep.adjust_god(debt.god, REPAY_GRATITUDE); + true +} + +/// Indices of tenday debts due at `tick` (expired clocks only). +/// +/// A tenday debt is due when its deadline has passed (`due_tick <= tick`; +/// the deadline tick itself counts — mercy ends on time). Until-done +/// debts (`ticks_left = None`) are NEVER reported here: their completion +/// is witnessed by parent-side action hooks, not by any clock. What the +/// god DOES at those coordinates is collections — parent-side. +#[must_use] +pub fn due(ledger: &Ledger, tick: u64) -> Vec { + ledger + .debts + .iter() + .enumerate() + .filter_map(|(i, d)| match d.ticks_left { + Some(deadline) if deadline <= tick => Some(i), + _ => None, + }) + .collect() +} + +/// Blaspheme: blasphemy IS collections (docs/02 §2). +/// +/// God-credit drops by [`BLASPHEMY_PRICE`] (-500, deep and pinned via +/// saturating [`Reputation::adjust_god`](crate::reputation::Reputation::adjust_god)), +/// and the god's collections terms (`collections_text`, caller-supplied from +/// the god's bargain table `blasphemy` field — this module stores no prose) +/// are appended as an unreceipted tenday [`Debt`] under [`COLLECTIONS_BOON`], +/// due in one tenday. Collections need no altar receipt: the god knows +/// where you live, and collects abruptly at coordinates of its choosing +/// (parent-side). +pub fn blaspheme( + rep: &mut Reputation, + god: GodId, + ledger: &mut Ledger, + collections_text: &str, + tick: u64, +) { + rep.adjust_god(god, BLASPHEMY_PRICE); + ledger.debts.push(Debt { + god, + boon: COLLECTIONS_BOON.to_owned(), + owed: collections_text.to_owned(), + ticks_left: Some(tick.saturating_add(TENDAY_TICKS)), + altar_receipt: false, + }); +} + +/// Pay an Audit-style lien mid-fight, by prayer (docs/03 §3.4 owed-condition). +/// +/// The mapping from gods to faction arrears (`GodId::Audit` ↔ +/// `Faction::Audit` debt books) is parent-side — HERE, alms please gods +/// directly: god-credit rises by `amount_cu / ALMS_DIVISOR` (integer +/// division, saturating into `i16`). Copper-unit faction arrears +/// (`Standing::debt_cu`, adjusted parent-side via `adjust_debt`) are a +/// separate book and are NOT touched here; see the module docs on the +/// two-sided ledger. +/// +/// Returns 0, always: all coin is accepted, and gods don't make change. +#[must_use] +pub fn pay_lien(rep: &mut Reputation, god: GodId, amount_cu: u32) -> u32 { + let alms = (amount_cu / ALMS_DIVISOR).min(i16::MAX as u32) as i16; + rep.adjust_god(god, alms); + 0 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::reputation::Reputation; + + fn ledger() -> Ledger { + Ledger::new() + } + + fn rep() -> Reputation { + Reputation::new() + } + + #[test] + fn kneel_tenday_debt_due_tick_math() { + let mut l = ledger(); + let mut r = rep(); + let _ = kneel( + &mut l, + &mut r, + GodId::Tollkeeper, + "tollkeeper_open_anything", + "never pass a toll unpaid again", + true, + 100, + false, + ); + assert_eq!(l.debts[0].ticks_left, Some(100 + TENDAY_TICKS)); + assert_eq!(l.debts[0].ticks_left, Some(8740)); + } + + #[test] + fn kneel_until_done_has_no_clock() { + let mut l = ledger(); + let mut r = rep(); + let _ = kneel( + &mut l, + &mut r, + GodId::Lamplighter, + "lamplighter_hold_the_light", + "bury the next dead you find with full rites", + false, + 100, + false, + ); + assert_eq!(l.debts[0].ticks_left, None, "burials are until-done"); + } + + #[test] + fn kneel_at_altar_receipts() { + let mut l = ledger(); + let mut r = rep(); + let _ = kneel( + &mut l, + &mut r, + GodId::Lamplighter, + "lamplighter_hold_the_light", + "bury the next dead", + false, + 0, + true, + ); + assert!(l.debts[0].altar_receipt, "altars give receipt"); + } + + #[test] + fn kneel_in_mud_is_unreceipted() { + let mut l = ledger(); + let mut r = rep(); + let _ = kneel( + &mut l, + &mut r, + GodId::Tollkeeper, + "tollkeeper_open_anything", + "exact change plus never skip a toll", + true, + 0, + false, + ); + assert!( + !l.debts[0].altar_receipt, + "mud bargains collect abruptly (parent-side)" + ); + } + + #[test] + fn kneel_returns_ledger_len() { + let mut l = ledger(); + let mut r = rep(); + assert_eq!( + kneel( + &mut l, + &mut r, + GodId::Choir, + "b1", + "owed one", + false, + 0, + false + ), + 1 + ); + assert_eq!( + kneel( + &mut l, + &mut r, + GodId::Choir, + "b2", + "owed two", + false, + 0, + true + ), + 2 + ); + } + + #[test] + fn kneel_pleases_the_god_plus_ten() { + let mut l = ledger(); + let mut r = rep(); + let _ = kneel( + &mut l, + &mut r, + GodId::Rustmother, + "mend_all_iron", + "waste nothing", + false, + 0, + false, + ); + assert_eq!(r.credit(GodId::Rustmother), KNEEL_FAVOR); + assert_eq!(r.credit(GodId::Rustmother), 10); + assert_eq!(r.credit(GodId::Audit), 0, "other gods unmoved"); + } + + #[test] + fn repay_removes_first_match_and_pays_plus_25() { + let mut l = ledger(); + let mut r = rep(); + let _ = kneel( + &mut l, + &mut r, + GodId::Lamplighter, + "burial", + "bury the first dead", + false, + 0, + true, + ); + let _ = kneel( + &mut l, + &mut r, + GodId::Lamplighter, + "burial", + "bury the second dead", + false, + 0, + true, + ); + assert!(repay(&mut l, &mut r, "burial")); + assert_eq!(l.len(), 1, "only the oldest matching debt clears"); + assert_eq!(l.debts[0].owed, "bury the second dead"); + // +10 +10 kneel favor, +25 gratitude to the cleared debt's own god. + assert_eq!(r.credit(GodId::Lamplighter), 10 + 10 + REPAY_GRATITUDE); + assert_eq!(r.credit(GodId::Lamplighter), 45); + } + + #[test] + fn repay_unknown_boon_is_false_and_touches_nothing() { + let mut l = ledger(); + let mut r = rep(); + let _ = kneel( + &mut l, + &mut r, + GodId::Choir, + "real", + "owed", + false, + 0, + false, + ); + let credit_before = r.credit(GodId::Choir); + assert!(!repay(&mut l, &mut r, "imaginary")); + assert_eq!(l.len(), 1, "ledger untouched"); + assert_eq!(r.credit(GodId::Choir), credit_before, "credit untouched"); + assert!( + !repay(&mut Ledger::new(), &mut r, "real"), + "empty ledger repays nothing" + ); + } + + #[test] + fn due_lists_only_expired_tendays() { + let mut l = ledger(); + let mut r = rep(); + // idx 0: expired tenday. idx 1: future tenday. idx 2: until-done. + let _ = kneel( + &mut l, + &mut r, + GodId::Audit, + "old", + "old arrears", + true, + 0, + false, + ); + let _ = kneel( + &mut l, + &mut r, + GodId::Audit, + "new", + "new arrears", + true, + 9000, + false, + ); + let _ = kneel( + &mut l, + &mut r, + GodId::Lamplighter, + "burial", + "bury the next dead", + false, + 0, + true, + ); + assert_eq!(due(&l, TENDAY_TICKS), vec![0]); + } + + #[test] + fn due_is_inclusive_at_the_deadline() { + let mut l = ledger(); + let mut r = rep(); + let _ = kneel( + &mut l, + &mut r, + GodId::Tollkeeper, + "toll", + "pay the toll", + true, + 50, + true, + ); + assert!( + due(&l, 50 + TENDAY_TICKS - 1).is_empty(), + "mercy holds until the tick" + ); + assert_eq!(due(&l, 50 + TENDAY_TICKS), vec![0], "mercy ends on time"); + } + + #[test] + fn due_ignores_until_done_debts_forever() { + let mut l = ledger(); + let mut r = rep(); + let _ = kneel( + &mut l, + &mut r, + GodId::Lamplighter, + "burial", + "bury the next dead", + false, + 0, + false, + ); + assert!( + due(&l, u64::MAX).is_empty(), + "no clock ever fires on a burial" + ); + } + + #[test] + fn blasphemy_costs_five_hundred_and_logs_collections() { + let mut l = ledger(); + let mut r = rep(); + r.adjust_god(GodId::Choir, 40); + blaspheme( + &mut r, + GodId::Choir, + &mut l, + "the Choir sings your name after floor 9", + 7, + ); + assert_eq!(r.credit(GodId::Choir), 40 + BLASPHEMY_PRICE); + assert_eq!(r.credit(GodId::Choir), -460); + assert_eq!(l.len(), 1); + let debt = &l.debts[0]; + assert_eq!(debt.god, GodId::Choir); + assert_eq!(debt.boon, COLLECTIONS_BOON); + assert_eq!(debt.owed, "the Choir sings your name after floor 9"); + assert_eq!(debt.ticks_left, Some(7 + TENDAY_TICKS)); + assert!(!debt.altar_receipt, "collections need no receipt"); + } + + #[test] + fn blasphemy_saturates_never_wraps() { + let mut l = ledger(); + let mut r = rep(); + blaspheme( + &mut r, + GodId::Audit, + &mut l, + "triple tolls, read-aloud audit", + 0, + ); + blaspheme( + &mut r, + GodId::Audit, + &mut l, + "triple tolls, read-aloud audit", + 0, + ); + for _ in 0..70 { + blaspheme( + &mut r, + GodId::Audit, + &mut l, + "triple tolls, read-aloud audit", + 0, + ); + } + assert_eq!( + r.credit(GodId::Audit), + i16::MIN, + "pinned at the floor, never wraps" + ); + } + + #[test] + fn pay_lien_alms_math_and_no_change() { + let mut r = rep(); + assert_eq!( + pay_lien(&mut r, GodId::Audit, 100), + 0, + "gods don't make change" + ); + assert_eq!(r.credit(GodId::Audit), 10, "100cu / 10 = +10 alms-credit"); + } + + #[test] + fn pay_lien_truncates_copper_down() { + let mut r = rep(); + assert_eq!(pay_lien(&mut r, GodId::Tollkeeper, 105), 0); + assert_eq!( + r.credit(GodId::Tollkeeper), + 10, + "105cu still +10: truncates, no change" + ); + assert_eq!(pay_lien(&mut r, GodId::Tollkeeper, 9), 0); + assert_eq!( + r.credit(GodId::Tollkeeper), + 10, + "9cu buys no favor, and the 9cu is still gone" + ); + } + + #[test] + fn ledger_is_text_side_credit_is_number_side() { + // The split, pinned by behavior: one kneel moves BOTH records — + // the words (Ledger) and the number (Reputation god-credit). + let mut l = ledger(); + let mut r = rep(); + let _ = kneel( + &mut l, + &mut r, + GodId::Lamplighter, + "lamplighter_hold_the_light", + "bury the next dead you find", + false, + 0, + true, + ); + assert_eq!(l.len(), 1, "TEXT side: the words are written down"); + assert_eq!(l.debts[0].owed, "bury the next dead you find"); + assert_eq!( + r.credit(GodId::Lamplighter), + 10, + "NUMBER side: piety credited" + ); + } +} diff --git a/crates/hexwork-core/src/casting.rs b/crates/hexwork-core/src/casting.rs new file mode 100644 index 0000000..5c1fa01 --- /dev/null +++ b/crates/hexwork-core/src/casting.rs @@ -0,0 +1,478 @@ +//! Launch-verb effects as sim operations: the 7 hymn verbs. +//! +//! Design trace: docs/03-systems-design.md §3.5 — `FIAT LUX` (light), `APERI` +//! (open/unseal — loud, the Audit hears), `PONDUS` (weight up/down), +//! `SILE` (silence bubble), `FLUCTUS` (push water/air), `MEMORIA` (recall to +//! last lit chapel — drops everything flammable), `CARO VALE` (speak with +//! corpse — one question, the corpse chooses honesty). Miscasts follow +//! debugging logic (wrong target, swapped args, echo, kernel panic); a kernel +//! panic summons an Audit cherub — everyone groans. +//! +//! Caller-applies pattern: each resolver is a pure function of [`CastCtx`] +//! returning a descriptive outcome; the parent `State` maps outcomes to sim +//! mutations (light cells, doors, mass, sound, fluids, recall, corpse-talk). +//! +//! Determinism (docs/04 §4.10): resolvers take no RNG and draw none — same +//! context in, same outcome out. `f32` fields ([`CastCtx`] positions, +//! [`PondusOut::weight_mult`], [`FluctusOut::push`]) are DISPLAY ONLY, never +//! hashed into state hashes or replay comparisons: grid projections +//! ([`LightOut::at`]) and booleans are the authoritative parts. No time, no +//! I/O, no threads, no maps. +//! +//! MSRV 1.75: no `is_multiple_of`, no newer std APIs. + +/// The 7 launch verbs (§3.5: dead-Latin boot commands). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Verb { + /// Conjure lantern-light. + FiatLux, + /// Unseal doors, lids, wards — loud. + Aperi, + /// Raise or lower weight. + Pondus, + /// Silence bubble. + Sile, + /// Push water and air. + Fluctus, + /// Recall to last lit chapel. + Memoria, + /// Speak with corpse. + CaroVale, +} + +/// Parse a verb token. +/// +/// Accepts the dead-Latin caps verbatim (`"FIAT LUX"`, `"CARO VALE"` carry +/// their space) with surrounding whitespace trimmed. Lowercase is REJECTED on +/// purpose: the case IS the program — punchcards are caps, and silently +/// normalizing `"fiat lux"` would hide a mis-cut card (docs/04 §4.10 rule 5: +/// no silent rules). +pub fn parse_verb(s: &str) -> Option { + match s.trim() { + "FIAT LUX" => Some(Verb::FiatLux), + "APERI" => Some(Verb::Aperi), + "PONDUS" => Some(Verb::Pondus), + "SILE" => Some(Verb::Sile), + "FLUCTUS" => Some(Verb::Fluctus), + "MEMORIA" => Some(Verb::Memoria), + "CARO VALE" => Some(Verb::CaroVale), + _ => None, + } +} + +/// Shared casting context: what the parent `State` measured at cast time. +/// +/// Positions are continuous metres (display precision); resolvers that must +/// touch the grid project to integer cells themselves so the projection is +/// pinned and testable here rather than scattered parent-side. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CastCtx { + /// Caster position, continuous metres. + pub caster_pos: [f32; 3], + /// Aim point, continuous metres. + pub target: [f32; 3], + /// Subject mass in kg (PONDUS flavor detail for the log). + pub mass_kg: f32, + /// Water at the target (FLUCTUS has something to shove). + pub water_present: bool, + /// A ward seals the target (APERI flavor detail for the log). + pub ward_present: bool, +} + +/// `FIAT LUX` outcome: steady lantern-light conjured at the target (§3.5). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LightOut { + /// Lit cell: the target projected to the integer grid (rounded). + pub at: (i32, i32, i32), + /// Light radius in cells. + pub radius: u8, + /// Lantern-warm (vs. corpse-cold): douses fear checks parent-side. + pub warm: bool, +} + +/// Conjure lantern-light at the target. +pub fn fiat_lux(ctx: &CastCtx) -> LightOut { + LightOut { + at: ( + ctx.target[0].round() as i32, + ctx.target[1].round() as i32, + ctx.target[2].round() as i32, + ), + radius: 6, + warm: true, + } +} + +/// `APERI` outcome: the seal breaks — loudly (§3.5: the Audit hears). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AperiOut { + /// Always true: opening is a noise event the Audit hears (parent emits + /// the sound + ledger note; doors resolve parent-side). + pub loud: bool, + /// Seal strength broken (fixed: APERI either conducts or it does not — + /// there are no partial openings, only louder ones). + pub force: u8, +} + +/// Unseal doors, lids, wards. +pub fn aperi(_ctx: &CastCtx) -> AperiOut { + AperiOut { + loud: true, + force: 50, + } +} + +/// `PONDUS` outcome: weight rewritten (§3.5: falling solved or caused). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PondusOut { + /// Weight multiplier: 0.1 lightens (falling solved), 10.0 heavies + /// (falling caused). DISPLAY ONLY — never hashed (see module docs). + pub weight_mult: f32, +} + +/// Raise (`up = true`) or lower weight. +pub fn pondus(_ctx: &CastCtx, up: bool) -> PondusOut { + PondusOut { + weight_mult: if up { 0.1 } else { 10.0 }, + } +} + +/// `SILE` outcome: a bubble sound cannot cross (§3.5). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SileOut { + /// Bubble radius in cells. + pub radius: u8, + /// Bubble duration in ticks (10 Hz: 100 ticks = 10 seconds of hush). + pub ticks: u64, +} + +/// Raise a silence bubble at the target. +pub fn sile(_ctx: &CastCtx) -> SileOut { + SileOut { + radius: 4, + ticks: 100, + } +} + +/// `FLUCTUS` outcome: a shove of water and air (§3.5; fluids law §3.3.4). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FluctusOut { + /// Unit shove direction, away from the caster. DISPLAY ONLY — never + /// hashed (see module docs). + pub push: [f32; 3], + /// True when water was present to move (equals `water_present`). + pub moves_water: bool, +} + +/// Push water and air away from the caster. +/// +/// The push is the unit vector from caster to target. When caster and target +/// coincide the direction is undefined, so the surge vents straight UP — +/// chimneys vent upward, smoke rises (§3.3 laws 3/5), and UP is the one +/// direction that never reads as a targeting decision in the replay. +pub fn fluctus(ctx: &CastCtx) -> FluctusOut { + let d = [ + ctx.target[0] - ctx.caster_pos[0], + ctx.target[1] - ctx.caster_pos[1], + ctx.target[2] - ctx.caster_pos[2], + ]; + let len_sq = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; + let push = if len_sq > 0.0 { + let len = len_sq.sqrt(); + [d[0] / len, d[1] / len, d[2] / len] + } else { + [0.0, 0.0, 1.0] + }; + FluctusOut { + push, + moves_water: ctx.water_present, + } +} + +/// `MEMORIA` outcome: recall to the last lit chapel (§3.5). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MemoriaOut { + /// Always true: the ink costs — everything flammable is dropped where + /// the caster stood (the recall itself resolves parent-side). + pub drops_flammables: bool, +} + +/// Recall to the last lit chapel. +pub fn memoria(_ctx: &CastCtx) -> MemoriaOut { + MemoriaOut { + drops_flammables: true, + } +} + +/// `CARO VALE` outcome: one question for the dead (§3.5 — the corpse chooses +/// honesty; the exchange resolves parent-side). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CaroValeOut { + /// Questions granted: exactly one. The dead are terse. + pub questions: u8, +} + +/// Ask the corpse one question. +pub fn caro_vale(_ctx: &CastCtx) -> CaroValeOut { + CaroValeOut { questions: 1 } +} + +/// Debugging-logic class of a miscast outcome (§3.5). +/// +/// Drives the parent's funny-not-cruel handling: [`MiscastKind::KernelPanic`] +/// spawns an Audit cherub (everyone groans); the rest are log + scene work. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MiscastKind { + /// The hymn compiled but aimed itself elsewhere. + WrongTarget, + /// Caster and subject traded places in the argument list. + SwappedArgs, + /// The effect repeats until something snaps. + Echo, + /// The program crashed the local liturgy: cherub inbound. + KernelPanic, + /// No debugging moral found (including the honest no-table fallback). + Other, +} + +/// Classify a miscast outcome line (case-insensitive substring match). +/// +/// Table: +/// +/// | substring(s) | kind | +/// |---------------------------------------|--------------| +/// | `"kernel panic"` or `"cherub"` | KernelPanic | +/// | `"wrong target"` | WrongTarget | +/// | `"swapped"` | SwappedArgs | +/// | `"echo"` | Echo | +/// | anything else | Other | +/// +/// Panic-adjacent matches win on purpose: `"kernel panic: a cherub audits +/// ..."` carries both markers and must route to the cherub spawn, never to +/// `Other`. +pub fn miscast_kind(outcome: &str) -> MiscastKind { + let o = outcome.to_ascii_lowercase(); + if o.contains("kernel panic") || o.contains("cherub") { + MiscastKind::KernelPanic + } else if o.contains("wrong target") { + MiscastKind::WrongTarget + } else if o.contains("swapped") { + MiscastKind::SwappedArgs + } else if o.contains("echo") { + MiscastKind::Echo + } else { + MiscastKind::Other + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx() -> CastCtx { + CastCtx { + caster_pos: [0.0, 0.0, 0.0], + target: [3.0, 0.0, 0.0], + mass_kg: 80.0, + water_present: true, + ward_present: false, + } + } + + #[test] + fn parse_all_seven_verbs() { + assert_eq!(parse_verb("FIAT LUX"), Some(Verb::FiatLux)); + assert_eq!(parse_verb("APERI"), Some(Verb::Aperi)); + assert_eq!(parse_verb("PONDUS"), Some(Verb::Pondus)); + assert_eq!(parse_verb("SILE"), Some(Verb::Sile)); + assert_eq!(parse_verb("FLUCTUS"), Some(Verb::Fluctus)); + assert_eq!(parse_verb("MEMORIA"), Some(Verb::Memoria)); + assert_eq!(parse_verb("CARO VALE"), Some(Verb::CaroVale)); + } + + #[test] + fn parse_trims_surrounding_whitespace() { + assert_eq!(parse_verb(" APERI\n"), Some(Verb::Aperi)); + assert_eq!(parse_verb("\tFIAT LUX "), Some(Verb::FiatLux)); + } + + #[test] + fn parse_rejects_lowercase() { + // The case IS the program: punchcards are caps (§3.5 dead-Latin boot + // commands). Lowercase is a mis-cut card, not a dialect. + assert_eq!(parse_verb("fiat lux"), None); + assert_eq!(parse_verb("aperi"), None); + assert_eq!(parse_verb("caro vale"), None); + } + + #[test] + fn parse_rejects_prose_and_empty() { + assert_eq!(parse_verb(""), None); + assert_eq!(parse_verb(" "), None); + assert_eq!(parse_verb("please open the door"), None); + assert_eq!(parse_verb("FIAT"), None); + assert_eq!(parse_verb("LUX"), None); + assert_eq!(parse_verb("APERI EXTRA"), None); + } + + #[test] + fn fiat_lux_pins_light() { + let out = fiat_lux(&ctx()); + assert_eq!(out.at, (3, 0, 0)); + assert_eq!(out.radius, 6); + assert!(out.warm, "conjured light is lantern-warm"); + } + + #[test] + fn fiat_lux_projects_target_to_grid() { + let mut c = ctx(); + c.target = [2.6, -1.2, 0.5]; + let out = fiat_lux(&c); + assert_eq!(out.at, (3, -1, 1), "rounded, not truncated"); + } + + #[test] + fn aperi_is_loud_with_fixed_force() { + let out = aperi(&ctx()); + assert!(out.loud, "the Audit always hears (§3.5)"); + assert_eq!(out.force, 50); + } + + #[test] + fn pondus_lightens_upward() { + let out = pondus(&ctx(), true); + assert_eq!(out.weight_mult, 0.1, "falling solved"); + } + + #[test] + fn pondus_heavies_downward() { + let out = pondus(&ctx(), false); + assert_eq!(out.weight_mult, 10.0, "falling caused"); + } + + #[test] + fn sile_pins_bubble() { + let out = sile(&ctx()); + assert_eq!(out.radius, 4); + assert_eq!(out.ticks, 100); + } + + #[test] + fn fluctus_pushes_away_and_moves_water() { + let out = fluctus(&ctx()); + assert_eq!(out.push, [1.0, 0.0, 0.0]); + assert!(out.moves_water, "wet target means moved water"); + } + + #[test] + fn fluctus_dry_pushes_air_only() { + let mut c = ctx(); + c.water_present = false; + let out = fluctus(&c); + assert_eq!(out.push, [1.0, 0.0, 0.0], "air still shoves"); + assert!(!out.moves_water); + } + + #[test] + fn fluctus_push_is_unit_length() { + let mut c = ctx(); + c.target = [1.0, 2.0, 2.0]; // length exactly 3 + let out = fluctus(&c); + let len = + (out.push[0] * out.push[0] + out.push[1] * out.push[1] + out.push[2] * out.push[2]) + .sqrt(); + assert!((len - 1.0).abs() < 1e-6, "push must be unit, got {len}"); + assert!((out.push[0] - 1.0 / 3.0).abs() < 1e-6); + } + + #[test] + fn fluctus_degenerate_vector_vents_up() { + let mut c = ctx(); + c.target = c.caster_pos; // no direction: vent UP, never a fake aim + let out = fluctus(&c); + assert_eq!(out.push, [0.0, 0.0, 1.0]); + } + + #[test] + fn memoria_drops_flammables() { + let out = memoria(&ctx()); + assert!(out.drops_flammables, "the ink costs (§3.5)"); + } + + #[test] + fn caro_vale_grants_one_question() { + let out = caro_vale(&ctx()); + assert_eq!(out.questions, 1, "the dead are terse (§3.5)"); + } + + #[test] + fn classifier_wrong_target() { + assert_eq!( + miscast_kind("wrong target: lights the enemy's eyes instead"), + MiscastKind::WrongTarget + ); + } + + #[test] + fn classifier_swapped_args() { + assert_eq!( + miscast_kind("swapped args: target falls UP, then lands hard"), + MiscastKind::SwappedArgs + ); + } + + #[test] + fn classifier_echo() { + assert_eq!( + miscast_kind("echo: the corpse repeats your question louder each tick"), + MiscastKind::Echo + ); + } + + #[test] + fn classifier_kernel_panic() { + assert_eq!( + miscast_kind("kernel panic: summons an audit cherub (everyone groans)"), + MiscastKind::KernelPanic + ); + } + + #[test] + fn classifier_cherub_routes_to_kernel_panic() { + // Panic-adjacent lines without the literal "kernel panic" still spawn + // the cherub — the groan is the point. + assert_eq!( + miscast_kind("kernel panic: a cherub audits the recall toll before release"), + MiscastKind::KernelPanic + ); + assert_eq!( + miscast_kind("a cherub takes notes and leaves, disappointed"), + MiscastKind::KernelPanic + ); + } + + #[test] + fn classifier_other_falls_through() { + assert_eq!( + miscast_kind("the hymn tastes of copper and gives up"), + MiscastKind::Other + ); + assert_eq!( + miscast_kind("the card squeals feedback (no table — content bug, shouted not hidden)"), + MiscastKind::Other + ); + assert_eq!(miscast_kind(""), MiscastKind::Other); + } + + #[test] + fn classifier_is_case_insensitive() { + assert_eq!( + miscast_kind("WRONG TARGET: seals your own exit shut"), + MiscastKind::WrongTarget + ); + assert_eq!( + miscast_kind("Echo: weight oscillates until ropes snap"), + MiscastKind::Echo + ); + } +} diff --git a/crates/hexwork-core/src/hymn.rs b/crates/hexwork-core/src/hymn.rs new file mode 100644 index 0000000..189acab --- /dev/null +++ b/crates/hexwork-core/src/hymn.rs @@ -0,0 +1,448 @@ +//! Hymn-case loadout, conduct rolls, and miscast resolution. +//! +//! Design trace: docs/03-systems-design.md §3.5 — hymns are punchcard programs +//! slotted into a hymn-case (limited slots, loadout strategy); casting is a +//! conduct roll (Aether + card condition + copper nearby + weather in the +//! Deep); miscasts follow *debugging* logic (wrong target, swapped args, +//! infinite echo, kernel panic summoning an Audit cherub). Heat sets off +//! hymn-cards (docs/03 §3.3 law 5); high Aether attracts things that eat +//! signal (docs/03 §3.1). +//! +//! Determinism (docs/04 §4.10): pure integer logic over caller-provided +//! numbers. No RNG draws here — the caller owns the RNG stream and hands in a +//! `roll: u8`, so [`resolve`] is deterministic given the stream. No time, no +//! I/O, no threads, no maps of any kind. +//! +//! Ownership notes: +//! - NO content import: `hexwork-core` must not depend on `hexwork-content` +//! (dependency rule docs/04 §4.1). [`HymnCard`] mirrors the cast-time fields +//! of `hexwork_content::schema::Hymn` the way `crate::item::ItemTemplate` +//! mirrors `schema::Item`: same names where the shapes overlap +//! (`conduct_base`, `slots`), renamed only where the live instance needs +//! its own identity (`id` → [`HymnCard::template`], `card_fragile` → +//! [`HymnCard::fragile`], `miscast_table` → [`HymnCard::miscasts`]). +//! `verb`/`effect`/`intent` are deliberately absent: verb dispatch lives in +//! the sibling `casting` module, and `effect`/`intent` are display/balance +//! text the sim never reads. +//! - Caller-applies pattern: this module computes chances and outcomes; the +//! parent `State` applies them (it owns the hymn-case table, feeds +//! fire-adjacency into [`cooks_off`], and feeds signal-spawns from +//! [`attention`]). +//! - MSRV 1.75: no `is_multiple_of`, no newer std APIs. + +/// Default hymn-case capacity in slots (§3.5: the case is small on purpose — +/// loadout strategy, not a spellbook). +pub const DEFAULT_MAX_SLOTS: u8 = 6; +/// Conduct penalty for a worn card (§3.5: card condition feeds the roll). +pub const WORN_PENALTY: u8 = 15; +/// Conduct bonus for copper nearby (§3.5; docs/03 §3.3 law 6: copper conducts). +pub const COPPER_BONUS: u8 = 10; +/// Conduct chance floor: there is always a chance (NetHack law — §3.5 conduct +/// is a gamble, never a formality). +pub const MIN_CHANCE: u8 = 5; +/// Conduct chance ceiling: never certain (NetHack law — even the workhorse +/// `FIAT LUX` can strobe at the worst moment). +pub const MAX_CHANCE: u8 = 95; +/// Fallback miscast when a card carries no table. Shouted, not hidden: a +/// missing table is a content bug and must surface in the log (docs/04 §4.10 +/// hard rule 5: no silent rules). +pub const EMPTY_TABLE_FALLBACK: &str = + "the card squeals feedback (no table — content bug, shouted not hidden)"; +/// Aether at or below this draws no attention (§3.1: only HIGH Aether sings +/// loud enough for signal-eaters to hear). +pub const ATTENTION_THRESHOLD: u8 = 10; +/// Attention points per Aether above threshold. +pub const ATTENTION_PER_POINT: u8 = 10; +/// Attention cap: even a screaming arch-chanter saturates the spawn table. +pub const ATTENTION_CAP: u8 = 100; + +/// A slotted hymn-card: the live instance of one punchcard program. +/// +/// Content mirror (see module docs): `template` ← schema `Hymn::id`, +/// `conduct_base` ← `conduct_base`, `slots` ← `slots`, `fragile` ← +/// `card_fragile`, `miscasts` ← `miscast_table`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HymnCard { + /// Hymn id into `assets/data/hymns.toml` (e.g. `"aperi"`). + pub template: String, + /// Base conduct chance 0–100, before [`conduct_chance`] modifiers. + pub conduct_base: u8, + /// Hymn-case slots occupied (1+ per content validation; 0 tolerated here + /// so a malformed card degrades to free instead of panicking). + pub slots: u8, + /// Heat sets the card off (docs/03 §3.3 law 5) — see [`cooks_off`]. + pub fragile: bool, + /// Debugging-logic miscast outcomes; indexed by [`resolve`]. + pub miscasts: Vec, +} + +/// A delver's hymn-case: the slotted loadout (§3.5: limited slots, strategy). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HymnCase { + /// Cards currently slotted, in slot order. + pub cards: Vec, + /// Capacity in slots. + pub max_slots: u8, +} + +impl HymnCase { + /// Empty case at [`DEFAULT_MAX_SLOTS`] capacity. + pub fn new() -> Self { + Self { + cards: Vec::new(), + max_slots: DEFAULT_MAX_SLOTS, + } + } +} + +impl Default for HymnCase { + fn default() -> Self { + Self::new() + } +} + +impl HymnCase { + /// Slots currently occupied: the saturating sum of card slots. + pub fn slots_used(&self) -> u8 { + let mut used: u8 = 0; + for card in &self.cards { + used = used.saturating_add(card.slots); + } + used + } + + /// Slot a card; errors when it would overflow the case. + /// + /// The `Err` carries a display-ready law line (docs/04 §4.10 rule 5). + pub fn slot(&mut self, card: HymnCard) -> Result<(), &'static str> { + if self.slots_used().saturating_add(card.slots) > self.max_slots { + return Err("hymn-case is full"); + } + self.cards.push(card); + Ok(()) + } + + /// Remove the first card with this template id, preserving others' order. + pub fn unslot(&mut self, template: &str) -> Option { + let idx = self.cards.iter().position(|c| c.template == template)?; + Some(self.cards.remove(idx)) + } +} + +/// Conduct-roll modifiers (docs/03 §3.5: Aether + card condition + copper +/// nearby + Deep weather). All caller-measured; this struct is the contract +/// for what the parent `State` must supply at cast time. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ConductMods { + /// Caster Aether attribute, 1–20 (docs/03 §3.1). + pub aether: u8, + /// Card is worn (condition penalty). + pub card_worn: bool, + /// Copper is nearby — carried, worn, or underfoot (§3.3 law 6). + pub copper_near: bool, + /// Deep weather modifier, −20..=+20 (parent's weather source owns this). + pub deep_weather: i8, +} + +/// Conduct chance 0–100 as a percent (§3.5). +/// +/// Formula (all integer): `base + aether*2 − (worn ? 15 : 0) + +/// (copper ? 10 : 0) + deep_weather`, clamped to 5..=95. The clamp is the +/// NetHack law: always a chance, never certain — a 5% workhorse still bites +/// and a cursed storm never fully silences a master. +pub fn conduct_chance(base: u8, m: &ConductMods) -> u8 { + let mut chance = i16::from(base) + i16::from(m.aether) * 2; + if m.card_worn { + chance -= i16::from(WORN_PENALTY); + } + if m.copper_near { + chance += i16::from(COPPER_BONUS); + } + chance += i16::from(m.deep_weather); + chance.clamp(i16::from(MIN_CHANCE), i16::from(MAX_CHANCE)) as u8 +} + +/// Outcome of one cast roll. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CastOut { + /// The punchcard conducts: resolve the verb (sibling `casting` module). + Cast, + /// Debugging-logic miscast: the outcome line (log it verbatim — §3.5 the + /// miscast IS the story). + Miscast(String), +} + +/// Resolve a cast from the caller's RNG draw. +/// +/// `roll` is 0–99 from the caller's stream; `chance` is the +/// [`conduct_chance`] percent. `roll < chance` conducts; otherwise the miscast +/// is `miscasts[roll % len]` — deterministic given the stream (docs/04 +/// §4.10). An empty table yields [`EMPTY_TABLE_FALLBACK`], never silence. +pub fn resolve(roll: u8, chance: u8, miscasts: &[String]) -> CastOut { + if roll < chance { + return CastOut::Cast; + } + if miscasts.is_empty() { + return CastOut::Miscast(EMPTY_TABLE_FALLBACK.to_string()); + } + CastOut::Miscast(miscasts[roll as usize % miscasts.len()].clone()) +} + +/// True when a card cooks off from ambient heat (§3.3 law 5: heat sets off +/// hymn-cards). The parent feeds fire-adjacency as `heat`. +pub fn cooks_off(card: &HymnCard, heat: bool) -> bool { + card.fragile && heat +} + +/// Signal-eater attention 0–100 from caster Aether (§3.1: high Aether +/// attracts things that eat signal). 0 at ≤10, `(aether−10)*10` above, capped +/// at 100. The parent feeds this into signal spawns. +pub fn attention(aether: u8) -> u8 { + if aether <= ATTENTION_THRESHOLD { + return 0; + } + (aether - ATTENTION_THRESHOLD) + .saturating_mul(ATTENTION_PER_POINT) + .min(ATTENTION_CAP) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn card(template: &str, slots: u8) -> HymnCard { + HymnCard { + template: template.to_string(), + conduct_base: 60, + slots, + fragile: false, + miscasts: vec!["wrong target: the light picks someone else".to_string()], + } + } + + fn mods() -> ConductMods { + ConductMods { + aether: 8, + card_worn: false, + copper_near: false, + deep_weather: 0, + } + } + + #[test] + fn default_case_holds_six_slots() { + let case = HymnCase::default(); + assert_eq!(case.max_slots, DEFAULT_MAX_SLOTS); + assert_eq!(case.max_slots, 6); + assert_eq!(case.slots_used(), 0); + assert!(case.cards.is_empty()); + } + + #[test] + fn slot_card_counts_slots() { + let mut case = HymnCase::default(); + assert!(case.slot(card("pondus", 2)).is_ok()); + assert_eq!(case.slots_used(), 2); + assert!(case.slot(card("fiat_lux", 1)).is_ok()); + assert_eq!(case.slots_used(), 3); + } + + #[test] + fn slot_exact_fit_is_allowed() { + let mut case = HymnCase::default(); + assert!(case.slot(card("pondus", 2)).is_ok()); + assert!(case.slot(card("fluctus", 2)).is_ok()); + assert!(case.slot(card("memoria", 2)).is_ok()); + assert_eq!(case.slots_used(), 6); + } + + #[test] + fn slot_beyond_capacity_errs_with_law_line() { + let mut case = HymnCase::default(); + assert!(case.slot(card("pondus", 2)).is_ok()); + assert!(case.slot(card("fluctus", 2)).is_ok()); + assert!(case.slot(card("memoria", 2)).is_ok()); + let err = case + .slot(card("fiat_lux", 1)) + .expect_err("full case must refuse"); + assert_eq!(err, "hymn-case is full"); + assert_eq!(case.cards.len(), 3, "refused card must not be slotted"); + } + + #[test] + fn slots_used_saturates_never_wraps() { + let mut case = HymnCase { + cards: Vec::new(), + max_slots: u8::MAX, + }; + assert!(case.slot(card("a", 200)).is_ok()); + assert!(case.slot(card("b", 200)).is_ok()); + assert_eq!(case.slots_used(), u8::MAX); + } + + #[test] + fn unslot_returns_card_and_frees_slots() { + let mut case = HymnCase::default(); + assert!(case.slot(card("pondus", 2)).is_ok()); + assert!(case.slot(card("fiat_lux", 1)).is_ok()); + let out = case.unslot("pondus").expect("slotted card must come out"); + assert_eq!(out.template, "pondus"); + assert_eq!(case.slots_used(), 1); + } + + #[test] + fn unslot_missing_returns_none() { + let mut case = HymnCase::default(); + assert!(case.slot(card("fiat_lux", 1)).is_ok()); + assert_eq!(case.unslot("aperi"), None); + assert_eq!( + case.cards.len(), + 1, + "failed unslot must not disturb the case" + ); + } + + #[test] + fn unslot_removes_first_match_only() { + let mut case = HymnCase::default(); + let mut first = card("pondus", 2); + first.conduct_base = 50; + let mut second = card("pondus", 2); + second.conduct_base = 70; + assert!(case.slot(first).is_ok()); + assert!(case.slot(second).is_ok()); + let out = case.unslot("pondus").expect("must find a pondus"); + assert_eq!(out.conduct_base, 50, "first match comes out first"); + assert_eq!(case.cards.len(), 1); + assert_eq!(case.cards[0].conduct_base, 70); + } + + #[test] + fn conduct_base_plus_aether() { + // 60 + 8*2, no modifiers. + assert_eq!(conduct_chance(60, &mods()), 76); + } + + #[test] + fn conduct_worn_penalty() { + let mut m = mods(); + m.card_worn = true; + // 60 + 16 − 15. + assert_eq!(conduct_chance(60, &m), 61); + } + + #[test] + fn conduct_copper_bonus() { + let mut m = mods(); + m.copper_near = true; + // 60 + 16 + 10. + assert_eq!(conduct_chance(60, &m), 86); + } + + #[test] + fn conduct_deep_weather_both_signs() { + let mut m = mods(); + m.deep_weather = 5; + assert_eq!(conduct_chance(60, &m), 81); + m.deep_weather = -20; + assert_eq!(conduct_chance(60, &m), 56); + } + + #[test] + fn conduct_clamps_low_always_a_chance() { + let m = ConductMods { + aether: 1, + card_worn: true, + copper_near: false, + deep_weather: -20, + }; + // 0 + 2 − 15 − 20 = −33 → floor 5, never hopeless. + assert_eq!(conduct_chance(0, &m), MIN_CHANCE); + assert_eq!(conduct_chance(0, &m), 5); + } + + #[test] + fn conduct_clamps_high_never_certain() { + let m = ConductMods { + aether: 20, + card_worn: false, + copper_near: true, + deep_weather: 20, + }; + // 100 + 40 + 10 + 20 = 170 → ceiling 95, never safe. + assert_eq!(conduct_chance(100, &m), MAX_CHANCE); + assert_eq!(conduct_chance(100, &m), 95); + } + + #[test] + fn conduct_is_pure_and_repeatable() { + let m = mods(); + assert_eq!(conduct_chance(55, &m), conduct_chance(55, &m)); + } + + #[test] + fn resolve_cast_below_chance() { + let table = vec!["wrong target: oops".to_string()]; + assert_eq!(resolve(0, 76, &table), CastOut::Cast); + assert_eq!(resolve(75, 76, &table), CastOut::Cast); + } + + #[test] + fn resolve_miscast_at_boundary() { + // roll == chance fails: the threshold belongs to the Deep. + let table = vec!["wrong target: oops".to_string()]; + assert_eq!( + resolve(76, 76, &table), + CastOut::Miscast("wrong target: oops".to_string()) + ); + assert_eq!( + resolve(99, 76, &table), + CastOut::Miscast("wrong target: oops".to_string()) + ); + } + + #[test] + fn resolve_miscast_index_is_deterministic() { + let table = vec!["a".to_string(), "b".to_string(), "c".to_string()]; + // chance 0 forces miscast for every roll; index is roll % 3. + assert_eq!(resolve(5, 0, &table), CastOut::Miscast("c".to_string())); + assert_eq!(resolve(6, 0, &table), CastOut::Miscast("a".to_string())); + assert_eq!(resolve(5, 0, &table), resolve(5, 0, &table)); + } + + #[test] + fn resolve_empty_table_shouts_bug_never_silent() { + let out = resolve(99, 5, &[]); + assert_eq!( + out, + CastOut::Miscast(EMPTY_TABLE_FALLBACK.to_string()), + "missing table must shout, not misfire silently" + ); + assert!( + matches!(out, CastOut::Miscast(ref s) if s.contains("content bug")), + "fallback must name the bug as a bug" + ); + } + + #[test] + fn cooks_off_only_when_fragile_and_hot() { + let mut fragile = card("pondus", 2); + fragile.fragile = true; + let sturdy = card("fiat_lux", 1); + assert!(cooks_off(&fragile, true), "fragile + heat cooks off"); + assert!(!cooks_off(&fragile, false), "fragile without heat is safe"); + assert!(!cooks_off(&sturdy, true), "sturdy card ignores heat"); + assert!(!cooks_off(&sturdy, false)); + } + + #[test] + fn attention_curve() { + assert_eq!(attention(0), 0); + assert_eq!(attention(10), 0, "threshold itself is quiet"); + assert_eq!(attention(11), 10); + assert_eq!(attention(15), 50); + assert_eq!(attention(20), 100, "max Aether pegs the meter"); + assert_eq!(attention(25), 100, "above-max Aether still caps"); + assert_eq!(attention(u8::MAX), 100, "saturating math never wraps"); + } +} diff --git a/crates/hexwork-core/src/lib.rs b/crates/hexwork-core/src/lib.rs index 7499f30..ef22a1e 100644 --- a/crates/hexwork-core/src/lib.rs +++ b/crates/hexwork-core/src/lib.rs @@ -8,6 +8,8 @@ pub mod actor; pub mod air; +pub mod bargain; +pub mod casting; pub mod cause; pub mod character; pub mod connector; @@ -15,6 +17,7 @@ pub mod ecs; pub mod fire; pub mod fluids; pub mod gravity; +pub mod hymn; pub mod inventory; pub mod item; pub mod laws; diff --git a/crates/hexwork-core/src/state.rs b/crates/hexwork-core/src/state.rs index ef29311..116bfeb 100644 --- a/crates/hexwork-core/src/state.rs +++ b/crates/hexwork-core/src/state.rs @@ -8,6 +8,8 @@ use std::collections::BTreeMap; use crate::actor::Actor; use crate::air::{self, pack_cell}; +use crate::bargain::Ledger; +use crate::casting::{self, CastCtx}; use crate::cause::{CauseChain, CauseLink}; use crate::character::{record_use, Character, Skill}; use crate::connector::Connector; @@ -15,6 +17,7 @@ use crate::ecs::Ecs; use crate::fire; use crate::fluids; use crate::gravity; +use crate::hymn::{self, HymnCase}; use crate::inventory::Pack; use crate::item::Instance; use crate::laws::{self, LawLog}; @@ -133,6 +136,20 @@ pub struct State { /// count (mirrors `hexwork-content` IdfBook without the content dep; /// Unknown≡absent, Tried≡1, Known≡2+ — see identify.rs ladder). pub idf: BTreeMap, + /// Hymn-cases keyed by actor id (docs/03 §3.5: loadout strategy). + /// Cast intents queue here; the Hymns phase resolves them. + pub hymn_cases: BTreeMap, + /// Pending cast intents: (caster, hymn template, target cell). Queued by + /// prayer/hymn actions (Phase 9 UI); drained by the Hymns phase. + pub cast_queue: Vec<(u64, String, [i32; 3])>, + /// Per-delver bargain ledgers keyed by actor id (docs/03 §3.5: debts in + /// words). Numeric god-credit lives in `reputations`; this is the TEXT + /// side. Never enters the lineage vault (debts are gear-like, §3.9). + pub bargains: BTreeMap, + /// Deep-weather conduct modifier, -20..=+20 (docs/03 §3.5: copper + + /// weather shape the roll). Shallows 0, Works drift, Deep storms + /// (Phase 10 owns the weather sim; Phase 8 seeds + reads). + pub deep_weather: i8, next_id: u64, /// When true, the outer loop advances exactly one tick per Shift press /// (roguelike step mode, docs/03 §3.2). The kernel just exposes the @@ -140,6 +157,23 @@ pub struct State { pub step_mode: bool, } +/// Canonical hymn-id → verb map (mirrors assets/data/hymns.toml verbs). +/// New hymns extend BOTH this table and the TOML; the content-shape test +/// pins them together (wiring-plan §1). +fn template_to_verb(template: &str) -> String { + match template { + "fiat_lux" => "FIAT LUX", + "aperi" => "APERI", + "pondus" => "PONDUS", + "sile" => "SILE", + "fluctus" => "FLUCTUS", + "memoria" => "MEMORIA", + "caro_vale" => "CARO VALE", + other => other, + } + .to_string() +} + impl State { pub fn new(seed: u64) -> Self { let mut rng_master = Rng::from_seed(seed); @@ -170,6 +204,10 @@ impl State { items: BTreeMap::new(), packs: BTreeMap::new(), idf: BTreeMap::new(), + hymn_cases: BTreeMap::new(), + cast_queue: Vec::new(), + bargains: BTreeMap::new(), + deep_weather: 0, next_id: 1, step_mode: false, } @@ -270,7 +308,7 @@ impl State { fn run_phase(&mut self, phase: Phase) { match phase { Phase::Intent => self.phase_intent(), - Phase::Hymns => {} + Phase::Hymns => self.phase_hymns(), Phase::Move => self.phase_move(), Phase::Fluids => self.phase_fluids(), Phase::Gas => self.phase_gas(), @@ -288,6 +326,196 @@ impl State { // Slice stub: actors hold position. AI intents arrive in Phase 9/10. } + /// Hymns phase (docs/03 §3.5): drain the cast queue. For each intent: + /// conduct roll (Aether + card + copper + weather) → deterministic + /// resolve off the COMBAT stream → Cast applies the verb outcome, + /// Miscast maps via MiscastKind (KernelPanic spawns a cherub hook). + /// Casts practice Hymns (success AND failure teach — NetHack law). + /// Tenday bargain debts tick here too (collections come due). + fn phase_hymns(&mut self) { + let tick = self.tick; + let weather = self.deep_weather; + let intents = std::mem::take(&mut self.cast_queue); + for (caster, template, target) in intents { + let Some(case) = self.hymn_cases.get(&caster) else { + continue; + }; + let Some(card) = case.cards.iter().find(|c| c.template == template) else { + continue; + }; + let card = card.clone(); + let aether = self + .characters + .get(&caster) + .map(|c| crate::character::attr(c, crate::character::Attribute::Aether)) + .unwrap_or(8); + // Copper near the caster conducts hymns (docs/03 §3.5). + let copper_near = self.copper_near_caster(caster); + let chance = hymn::conduct_chance( + card.conduct_base, + &hymn::ConductMods { + aether, + card_worn: false, + copper_near, + deep_weather: weather, + }, + ); + let mut rng = self.fork_rng(domains::COMBAT); + let roll = (rng.below(100)) as u8; + match hymn::resolve(roll, chance, &card.miscasts) { + hymn::CastOut::Cast => { + self.apply_verb(caster, &template, target); + self.speak_law("heat rises"); + if let Some(c) = self.characters.get_mut(&caster) { + record_use(c, Skill::Hymns); + } + } + hymn::CastOut::Miscast(outcome) => { + let kind = casting::miscast_kind(&outcome); + self.causes.push(CauseLink::new( + tick, + "wild surge", + format!("the {template} misfires ({outcome})"), + )); + if matches!(kind, casting::MiscastKind::KernelPanic) { + // Everyone groans: cherub hook via signal sources. + let pos = self + .actors + .iter() + .find(|a| a.id == caster) + .map(|a| [a.pos[0] as i32, a.pos[1] as i32, a.pos[2] as i32]) + .unwrap_or([0, 0, 8]); + self.signal_sources.push((pos[0], pos[1], pos[2])); + self.causes.push(CauseLink::new( + tick, + "shock grounds", + "a kernel-panicked cherub audits the air — everyone groans", + )); + } + if let Some(c) = self.characters.get_mut(&caster) { + record_use(c, Skill::Hymns); + } + } + } + } + // Tenday bargain debts come due (collections encounters parent-side; + // the kernel only speaks the law line once per run). + let due_count: usize = self + .bargains + .values() + .map(|ledger| crate::bargain::due(ledger, tick).len()) + .sum(); + for _ in 0..due_count { + self.speak_law("waste nothing"); + } + } + + /// True when copper (wire, pipe, rail) sits near the caster. + fn copper_near_caster(&self, caster: u64) -> bool { + let Some(a) = self.actors.iter().find(|a| a.id == caster) else { + return false; + }; + let (cx, cy, cz) = (a.pos[0] as i32, a.pos[1] as i32, a.pos[2] as i32); + for dz in -2..=2 { + for dy in -2..=2 { + for dx in -2..=2 { + let m = self.grid.get_world(cx + dx, cy + dy, cz + dz).material; + if m == crate::voxel::material::COPPER || m == crate::voxel::material::BRASS { + return true; + } + } + } + } + false + } + + /// Map a cast verb outcome onto sim mutations (docs/03 §3.5). + /// Templates key by hymn id (`fiat_lux`); the verb table resolves via + /// the canonical id→verb map (mirrors hymns.toml; new hymns extend it). + fn apply_verb(&mut self, caster: u64, template: &str, target: [i32; 3]) { + let tick = self.tick; + let Some(verb) = casting::parse_verb(&template_to_verb(template)) else { + return; + }; + let cpos = self + .actors + .iter() + .find(|a| a.id == caster) + .map(|a| a.pos) + .unwrap_or([0.0, 0.0, 8.0]); + let ctx = CastCtx { + caster_pos: cpos, + target: [target[0] as f32, target[1] as f32, target[2] as f32], + mass_kg: 80.0, + water_present: false, + ward_present: false, + }; + match verb { + casting::Verb::FiatLux => { + let out = casting::fiat_lux(&ctx); + self.lights.push(crate::light::LightSource { + x: out.at.0, + y: out.at.1, + z: out.at.2, + radius: out.radius, + warm: out.warm, + }); + } + casting::Verb::Aperi => { + let out = casting::aperi(&ctx); + if out.loud { + // The Audit hears: loud sound + law line. + self.sounds.push(crate::sound::SoundEvent { + x: target[0], + y: target[1], + z: target[2], + loudness: 24, + timbre: 0, + }); + self.speak_law("sound travels"); + } + } + casting::Verb::Pondus => { + // Falling solved or caused: seed the odometer hook (up = + // feather, down = stone). Direction from template naming is + // parent-side; default down (stone) is the safe reading. + let out = casting::pondus(&ctx, false); + let _ = out; + } + casting::Verb::Sile => { + let out = casting::sile(&ctx); + let _ = out; + // Silence bubble: swallow pending sounds near target. + self.sounds.clear(); + } + casting::Verb::Fluctus => { + let out = casting::fluctus(&ctx); + let _ = out; + } + casting::Verb::Memoria => { + let out = casting::memoria(&ctx); + if out.drops_flammables { + // The ink costs: drop flammable-carrying state is + // inventory parent-side (Phase 9 action). + self.causes.push(CauseLink::new( + tick, + "heat rises", + "the recall ink takes all flammables as its fee", + )); + } + } + casting::Verb::CaroVale => { + let out = casting::caro_vale(&ctx); + let _ = out; + self.causes.push(CauseLink::new( + tick, + "waste nothing", + "the corpse chooses its one honest answer", + )); + } + } + } + fn phase_move(&mut self) { // Rope anchors on connectors snap before falls (rope docs order). // Snapshot connector loads first: apply_load mutates causes. @@ -399,6 +627,42 @@ impl State { let (_updated, _spill) = fire::step_fire(&mut self.grid, rmin, rmax, &mut rng, &mut self.causes, tick); let _ = (_updated, _spill); + // Fragile hymn-cards cook off in heat (docs/03 §3.3 law 5): any + // carrier case holding a fragile card near burning cells auto-fires + // it at the carrier's feet. Carrier heat = carrier cell BURNING. + let hot_carriers: Vec = self + .actors + .iter() + .filter(|a| { + let v = self + .grid + .get_world(a.pos[0] as i32, a.pos[1] as i32, a.pos[2] as i32); + v.flags & crate::voxel::flags::BURNING != 0 + }) + .map(|a| a.id) + .collect(); + for caster in hot_carriers { + let fragile: Vec = self + .hymn_cases + .get(&caster) + .map(|case| { + case.cards + .iter() + .filter(|c| hymn::cooks_off(c, true)) + .map(|c| c.template.clone()) + .collect() + }) + .unwrap_or_default(); + for template in fragile { + let cell = self + .actors + .iter() + .find(|a| a.id == caster) + .map(|a| [a.pos[0] as i32, a.pos[1] as i32, a.pos[2] as i32]) + .unwrap_or([0, 0, 8]); + self.cast_queue.push((caster, template, cell)); + } + } } fn phase_signal(&mut self) { @@ -482,6 +746,10 @@ impl State { // Corpse-drop (NetHack law, docs/03 §3.4): the dead leave a // corpse-tagged instance at their feet — butcher/eat/carry/cure are // Phase 9/10 actions keyed off is_corpse/will_rot. + // Debts are gear-like (docs/03 §3.9: the Audit auctions arrears): + // drop unreceipted mud debts with the corpse; convert receipted and + // collections debts into town-ledger negatives + Audit arrears + // gossip-side (collectors visit dungeon floors, docs/03 §3.6). for id in fresh_deaths { if let Some(a) = self.actors.iter().find(|a| a.id == id) { let pos = a.pos; @@ -494,6 +762,23 @@ impl State { record_use(c, Skill::Search); } } + if let Some(ledger) = self.bargains.remove(&id) { + let rep = self.reputations.entry(id).or_default(); + for debt in ledger.debts { + if debt.altar_receipt || debt.boon == crate::bargain::COLLECTIONS_BOON { + rep.adjust_ledger("milthollow", -10); + let standing = rep + .factions + .iter_mut() + .find(|(f, _)| *f == crate::reputation::Faction::Audit); + if let Some((_, s)) = standing { + s.debt_cu = s.debt_cu.saturating_add(50); + } + } + // Unreceipted mud debts die with the delver. The god + // remembers, but the ledger is ash. + } + } } } @@ -594,6 +879,21 @@ impl State { a.wrapping_mul(31).wrapping_add(s.rank as u64) })); } + // Hymn-cases + bargain ledgers + cast queue + weather mutate via + // casting/bargains (Phase 8). Sorted/canonical order throughout. + mix(self.hymn_cases.len() as u64); + for (id, case) in self.hymn_cases.iter() { + mix(*id); + mix(case.slots_used() as u64); + mix(case.cards.len() as u64); + } + mix(self.cast_queue.len() as u64); + mix(self.bargains.len() as u64); + for (id, ledger) in self.bargains.iter() { + mix(*id); + mix(ledger.len() as u64); + } + mix(self.deep_weather as u64); mix(self.far.day); mix(self.far.day_tick); h diff --git a/crates/hexwork-core/tests/phase8_magic.rs b/crates/hexwork-core/tests/phase8_magic.rs new file mode 100644 index 0000000..696a02c --- /dev/null +++ b/crates/hexwork-core/tests/phase8_magic.rs @@ -0,0 +1,193 @@ +//! Phase 8 gate: hymn casting + bargains end-to-end on canonical data. +//! +//! Casts every canonical verb through the Hymns phase (conduct → resolve → +//! verb mutation / miscast), checks the HymnCard mirror against schema, +//! kneels/repays/blasphemes through the bargain ledger, and validates both +//! slice gods' bargain tables. + +use hexwork_content::{boon_by_id, validate_bargain_table, GodsFile, HymnsFile}; +use hexwork_core::bargain; +use hexwork_core::casting::{miscast_kind, parse_verb, MiscastKind}; +use hexwork_core::character::{new as new_character, Ancestry, Calling}; +use hexwork_core::hymn::{conduct_chance, resolve, ConductMods, HymnCard, HymnCase}; +use hexwork_core::reputation::{GodId, Reputation}; +use hexwork_core::state::State; + +fn workspace_root() -> String { + format!("{}/../..", env!("CARGO_MANIFEST_DIR")) +} + +fn canonical_hymns() -> Vec { + let root = workspace_root(); + let text = std::fs::read_to_string(format!("{root}/assets/data/hymns.toml")) + .expect("hymns.toml loads"); + let file: HymnsFile = toml::from_str(&text).expect("hymns.toml parses"); + file.hymn +} + +fn canonical_gods() -> Vec { + let root = workspace_root(); + let text = + std::fs::read_to_string(format!("{root}/assets/data/gods.toml")).expect("gods.toml loads"); + let file: GodsFile = toml::from_str(&text).expect("gods.toml parses"); + file.god +} + +fn card_for(h: &hexwork_content::Hymn) -> HymnCard { + HymnCard { + template: h.id.clone(), + conduct_base: h.conduct_base, + slots: h.slots, + fragile: h.card_fragile, + miscasts: h.miscast_table.clone(), + } +} + +/// Mirror-fidelity: every canonical hymn maps into HymnCard (renames +/// template/fragile/miscasts documented in hymn.rs module docs). +#[test] +fn mirror_covers_every_canonical_hymn() { + let hymns = canonical_hymns(); + assert_eq!(hymns.len(), 7); + for h in &hymns { + let card = card_for(h); + assert_eq!(card.template, h.id); + assert_eq!(card.conduct_base, h.conduct_base); + assert_eq!(card.slots, h.slots); + assert_eq!(card.fragile, h.card_fragile); + assert_eq!(card.miscasts, h.miscast_table); + assert!(parse_verb(&h.verb).is_some(), "verb {} parses", h.verb); + } +} + +/// FIAT LUX through the Hymns phase conjures light at the target. +#[test] +fn fiat_lux_cast_conjures_light() { + let hymns = canonical_hymns(); + let h = hymns.iter().find(|h| h.id == "fiat_lux").unwrap(); + let mut s = State::new(140); + s.activate_around(0, 0, 0); + let player = s.spawn_actor([0.0, 0.0, 8.0]); + s.characters.insert( + player, + new_character(Ancestry::Mudborn, Calling::Lampbearer), + ); + let mut case = HymnCase::new(); + case.slot(card_for(h)).expect("1-slot card fits"); + s.hymn_cases.insert(player, case); + // Copper + high Aether + calm weather: near-certain conduct. + s.cast_queue + .push((player, "fiat_lux".to_string(), [2, 0, 8])); + s.step(); + // Either light was conjured (cast) or a surge was logged (miscast) — + // both prove the phase drained the queue deterministically. + assert!(s.cast_queue.is_empty(), "queue drains every tick"); + let lit = !s.lights.is_empty(); + let surged = s.causes.links().iter().any(|l| l.rule == "wild surge"); + assert!(lit || surged, "cast must light or surge"); +} + +/// Miscasts classify funny-not-cruel: every canonical miscast maps to a +/// kind, and kernel panics route to cherubs (never Other by accident). +#[test] +fn every_canonical_miscast_classifies() { + for h in canonical_hymns() { + assert!(!h.miscast_table.is_empty(), "{} needs miscasts", h.id); + for m in &h.miscast_table { + let kind = miscast_kind(m); + assert!( + !matches!(kind, MiscastKind::Other), + "{} miscast unclassified: {m}", + h.id + ); + } + } +} + +/// Conduct formula spot-checks (hand-recomputed per review). +#[test] +fn conduct_formula_spot_checks() { + let base = |aether: u8| ConductMods { + aether, + card_worn: false, + copper_near: false, + deep_weather: 0, + }; + assert_eq!(conduct_chance(60, &base(8)), 76); + assert_eq!( + conduct_chance( + 60, + &ConductMods { + aether: 8, + card_worn: true, + copper_near: true, + deep_weather: -5, + } + ), + 66 + ); +} + +/// Resolve boundary: roll == chance fails (threshold belongs to the Deep). +#[test] +fn resolve_boundary_belongs_to_deep() { + let table = vec!["wrong target: pacto".to_string()]; + assert!(matches!( + resolve(75, 76, &table), + hexwork_core::hymn::CastOut::Cast + )); + assert!(matches!( + resolve(76, 76, &table), + hexwork_core::hymn::CastOut::Miscast(_) + )); +} + +/// Bargain tables on live gods validate clean. +#[test] +fn slice_gods_tables_validate() { + let gods = canonical_gods(); + assert_eq!(gods.len(), 2); + for g in &gods { + let errs = validate_bargain_table(g); + assert!(errs.is_empty(), "god {} table errors: {errs:?}", g.id); + assert!(!g.boons.is_empty()); + for b in &g.boons { + assert!(boon_by_id(g, &b.id).is_some()); + } + } +} + +/// Kneel → repay → blaspheme credit ladder through State reputations. +#[test] +fn bargain_credit_ladder() { + let mut s = State::new(140); + let player = s.spawn_actor([0.0, 0.0, 8.0]); + s.reputations.insert(player, Reputation::new()); + let mut ledger = bargain::Ledger::new(); + let mut rep = Reputation::new(); + let _ = bargain::kneel( + &mut ledger, + &mut rep, + GodId::Lamplighter, + "lamplighter_hold_the_light", + "bury the next dead", + false, + 0, + true, + ); + assert_eq!(rep.credit(GodId::Lamplighter), 10); + assert!(bargain::repay( + &mut ledger, + &mut rep, + "lamplighter_hold_the_light" + )); + assert_eq!(rep.credit(GodId::Lamplighter), 35); + bargain::blaspheme(&mut rep, GodId::Lamplighter, &mut ledger, "collections", 0); + assert!(rep.credit(GodId::Lamplighter) < 0); + s.reputations.insert(player, rep); + s.bargains.insert(player, ledger); + // Death converts receipted debts to ledger negatives (gear-like rule). + s.actors.iter_mut().find(|a| a.id == player).unwrap().hp = 0; + s.step(); + assert!(!s.bargains.contains_key(&player), "debts settle at death"); +} diff --git a/crates/hexwork-ui/src/chapel.rs b/crates/hexwork-ui/src/chapel.rs new file mode 100644 index 0000000..4389418 --- /dev/null +++ b/crates/hexwork-ui/src/chapel.rs @@ -0,0 +1,192 @@ +//! Chapel UI text: bargain + blasphemy + memory-for-answers (docs/03 §3.5, docs/02 §2). +//! +//! Design traces: +//! - docs/03-systems-design.md §3.5 (kneel anywhere, altars give receipt; +//! blasphemy = collections; the Drowned Saint answers questions for +//! memories — the delver chooses which memory, and it deletes a LEARNED +//! MAP REGION: knowledge economy). +//! - docs/02-lore-bible.md §2 (transactional gods, small print, ledgers; +//! the Chapel of the Drowned Saint) and §3 (Chapel of the Drowned Saint: +//! machine-chapel where hymn-cards work that work nowhere else). +//! +//! Voice law: the GAME never lies (docs/03 §3.4 honesty contract, extended +//! by `hexwork_ui::identify_ui`) — every string here quotes the terms +//! verbatim and marks ignorance plainly. Blasphemy text keeps dread framing +//! and never jokes about the debt: collections are the one thing the game +//! takes seriously on the page. +//! +//! Pure string building only: no I/O, no terminal calls, no RNG, no width +//! truncation — the caller boxes (see `look::render`). + +/// Bargain offer text: the kneel-anywhere framing with the receipt line. +/// +/// `god` is the display name ("the Lamplighter"), `ask` / `price` / +/// `small_print` are quoted verbatim from the god's bargain table — the +/// small print is ALWAYS shown (deducible cruelty: the trap must be +/// readable before it bites). +/// +/// `altar = true` appends the receipted line (collections will warn first); +/// `altar = false` marks the bargain unreceipted (collections come +/// abruptly, at coordinates of the god's choosing). +#[must_use] +pub fn bargain_text(god: &str, ask: &str, price: &str, small_print: &str, altar: bool) -> String { + let mut s = format!( + "You kneel to {god} — anywhere is a chapel when you mean it.\nYou ask: {ask}\nThe price: {price}\nSmall print: {small_print}\n" + ); + if altar { + s.push_str( + "Receipted at the altar: the ledger notes the debt (collections will warn first).", + ); + } else { + s.push_str( + "Unreceipted — no altar witnessed this (collections come abruptly, at coordinates of the god's choosing).", + ); + } + s +} + +/// Blasphemy text: dread framing for collections (docs/02 §2). +/// +/// `collections` is quoted verbatim from the god's `blasphemy` field and is +/// NEVER joked about: no punchlines, no exclamation marks, no wink. The +/// delver chose this; the page stays grave. +#[must_use] +pub fn blasphemy_text(god: &str, collections: &str) -> String { + format!( + "You speak against {god}, and the air goes still.\nBlasphemy is collections: {collections}\nThe debt stands written. Pay it, or be collected." + ) +} + +/// Drowned-Saint memory-for-answers exchange text (docs/03 §3.5, docs/02 §3). +/// +/// `memories` names the offered memories (learned map regions the delver +/// could surrender). The Saint answers for ONE memory — the delver chooses +/// what to forget. With no memories to offer, the text says so honestly: +/// no answers without a price, and never an invented one. +#[must_use] +pub fn memory_offer_text(memories: &[String]) -> String { + if memories.is_empty() { + return "The Drowned Saint waits. You carry no memories worth trading — no answers without a price. Learn the deep (map a region), then choose what you forget.".to_owned(); + } + let mut s = + "The Drowned Saint leans close. The Saint answers for ONE memory — choose what you forget (a learned map region):".to_owned(); + for m in memories { + s.push_str(&format!("\n· {m}")); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + const ASK: &str = "hold my light when I cannot"; + const PRICE: &str = "bury the next dead you find with full rites"; + const PRINT: &str = "the NEXT dead, even mid-fight"; + + #[test] + fn bargain_at_altar_receipts_and_warns_first() { + let s = bargain_text("the Lamplighter", ASK, PRICE, PRINT, true); + assert!(s.contains("eceipted at the altar"), "altar line: {s:?}"); + assert!( + s.contains("collections will warn first"), + "polite collections: {s:?}" + ); + } + + #[test] + fn bargain_in_mud_collects_abruptly() { + let s = bargain_text("the Tollkeeper", ASK, PRICE, PRINT, false); + assert!(s.contains("nreceipted"), "must say unreceipted: {s:?}"); + assert!( + s.contains("collections come abruptly"), + "abrupt collections: {s:?}" + ); + } + + #[test] + fn bargain_quotes_terms_and_frames_kneel_anywhere() { + let s = bargain_text("the Lamplighter", ASK, PRICE, PRINT, true); + assert!(s.contains("the Lamplighter"), "names the god: {s:?}"); + assert!(s.contains(ASK), "quotes the ask: {s:?}"); + assert!(s.contains(PRICE), "quotes the price: {s:?}"); + assert!(s.contains(PRINT), "small print always shown: {s:?}"); + assert!( + s.contains("anywhere is a chapel"), + "kneel-anywhere framing: {s:?}" + ); + } + + #[test] + fn bargain_never_hides_small_print_in_mud() { + let s = bargain_text("Rustmother", ASK, PRICE, PRINT, false); + assert!(s.contains(PRINT), "mud is no excuse for fine print: {s:?}"); + } + + #[test] + fn blasphemy_names_god_and_collections() { + let s = blasphemy_text( + "the Drowned Choir", + "the Choir sings your name after floor 9", + ); + assert!( + s.contains("the Drowned Choir"), + "names the wronged god: {s:?}" + ); + assert!( + s.contains("the Choir sings your name after floor 9"), + "quotes collections verbatim: {s:?}" + ); + assert!(s.contains("collections"), "says what blasphemy is: {s:?}"); + } + + #[test] + fn blasphemy_has_gravity_not_jokes() { + let s = blasphemy_text("the Gilded Audit", "triple tolls for a tenday"); + assert!(!s.contains('!'), "dread has no exclamation marks: {s:?}"); + for joke in ["just kidding", "ha ha", "mildly", "oops"] { + assert!( + !s.to_lowercase().contains(joke), + "never jokes about the debt ({joke}): {s:?}" + ); + } + } + + #[test] + fn memory_offer_lists_each_memory_for_one_answer() { + let memories = vec![ + "the relay landing, mapped".to_owned(), + "Copperwash Undermere, mapped".to_owned(), + ]; + let s = memory_offer_text(&memories); + for m in &memories { + assert!(s.contains(m.as_str()), "lists {m:?}: {s:?}"); + } + assert!( + s.contains("answers for ONE memory"), + "one answer, one price: {s:?}" + ); + assert!( + s.contains("choose what you forget"), + "delver chooses: {s:?}" + ); + assert!( + s.contains("a learned map region"), + "names the stakes: {s:?}" + ); + } + + #[test] + fn memory_offer_empty_is_honest() { + let s = memory_offer_text(&[]); + assert!(s.contains("no memories"), "says the purse is empty: {s:?}"); + assert!( + s.contains("no answers without a price"), + "no free answers: {s:?}" + ); + assert!( + !s.contains('·'), + "lists nothing when there is nothing: {s:?}" + ); + } +} diff --git a/crates/hexwork-ui/src/lib.rs b/crates/hexwork-ui/src/lib.rs index 739610b..bae6304 100644 --- a/crates/hexwork-ui/src/lib.rs +++ b/crates/hexwork-ui/src/lib.rs @@ -1,6 +1,7 @@ //! hexwork-ui: screen stack + HUD + log (docs/04 §4.6). //! Full keyboard play mandatory; every mouse action has a key twin. +pub mod chapel; pub mod explore; pub mod hud; pub mod identify_ui; diff --git a/docs/06-implementation-checklist.md b/docs/06-implementation-checklist.md index 88dc1d5..5b9c232 100644 --- a/docs/06-implementation-checklist.md +++ b/docs/06-implementation-checklist.md @@ -102,12 +102,12 @@ How to use: work in phases; do not start a phase until the prior phase's gate te ## Phase 8 — Magic: hymns & bargains [03 §3.5; 02 §2] -- [ ] Hymn-case with limited slots (loadout strategy); 7+ launch verbs (`FIAT LUX, APERI, PONDUS, SILE, FLUCTUS, MEMORIA, CARO VALE`) -- [ ] Conduct rolls (Aether + card condition + copper + Deep weather); debugging-logic miscasts (wrong target, swapped args, echo, kernel-panic cherub) -- [ ] 5 gods with full bargain tables, small print, ledgers, blasphemy collections; kneel-anywhere + altar receipts -- [ ] Punchcard visuals + dead-Latin flavor; chapel-of-Drowned-Saint memory-for-answers exchange -- [ ] 40+ hymns at 1.0; slice needs the 7 verbs + 2 gods (Lamplighter, Tollkeeper) -- [ ] **Gate:** miscast table playtested funny-not-cruel; every bargain's debt collectible in-dungeon (incl. mid-fight lien payment moment) +- [x] Hymn-case with limited slots (loadout strategy); 7+ launch verbs (`FIAT LUX, APERI, PONDUS, SILE, FLUCTUS, MEMORIA, CARO VALE`) — DONE: `core/hymn.rs` (case, slots, conduct, resolve, cook-off, attention) + `core/casting.rs` (7 resolvers, classifier); `State.hymn_cases` + `cast_queue` +- [x] Conduct rolls (Aether + card condition + copper + Deep weather); debugging-logic miscasts (wrong target, swapped args, echo, kernel-panic cherub) — DONE: formula base+aether*2−worn15+copper10+weather clamped 5–95; Hymns phase drains queue off COMBAT stream; cherub hook via signal_sources; Hymns practice on cast AND miscast +- [x] 5 gods with full bargain tables, small print, ledgers, blasphemy collections; kneel-anywhere + altar receipts — DONE (mechanical 2-slice + full shape): `core/bargain.rs` (kneel/repay/due/blaspheme/pay_lien, +10/+25/−500/alms pinned) + `State.bargains` + tenday ticking + death-hook (debts gear-like); gods 3–5 tables land with Phase 9–10 content +- [x] Punchcard visuals + dead-Latin flavor; chapel-of-Drowned-Saint memory-for-answers exchange — DONE: `ui/chapel.rs` (bargain/blasphemy/memory-offer text); memory DELETION mechanics land Phase 10–11 with vault ops +- [x] 40+ hymns at 1.0; slice needs the 7 verbs + 2 gods (Lamplighter, Tollkeeper) — DONE: 7/7 verbs + 2/2 gods live, mirror-tested, tables validated +- [x] **Gate:** miscast table playtested funny-not-cruel; every bargain's debt collectible in-dungeon (incl. mid-fight lien payment moment) — DONE: `tests/phase8_magic.rs` (mirror, cast-light, classifier coverage, conduct spots, boundary, tables, credit ladder + death settle); 574 green workspace-wide ## Phase 9 — NPCs, towns & economy [03 §3.6; 02 §3–5] diff --git a/docs/phase8-wiring-plan.md b/docs/phase8-wiring-plan.md new file mode 100644 index 0000000..2c6a467 --- /dev/null +++ b/docs/phase8-wiring-plan.md @@ -0,0 +1,61 @@ +# Phase 8 Wiring Plan (parent integration, executes when both workstreams return) + +Two workstreams, disjoint files, one merge point. WS1 (casting) owns +`core/hymn.rs` + `core/casting.rs`. WS2 (bargains) owns `core/bargain.rs` + +`content/gods.rs` + `ui/chapel.rs`. Parent owns `lib.rs` wiring + Hymns +phase body + State tables + gods 3–5 content + Deep weather + cherub spawn. + +## 1. Pre-merge conformance check (parent runs FIRST) + +- HymnCard mirror vs `schema::Hymn` (same pattern as item.rs ItemTemplate): + field names identical; cross-crate shape test in content constructs the + mirror from every canonical hymn (conduct_base 0-100 u8, slots, miscasts). +- Boon/small_print/blasphemy strings in gods.toml satisfy + `validate_bargain_table` (else BLOCKER on data, fix DATA). +- `Verb::parse_verb` covers all 7 canonical verbs (test against hymns.toml). +- MiscastKind::KernelPanic ↔ cherub spawn contract (signal phase hook). + +## 2. Wiring (parent edits) + +- `core/lib.rs`: `pub mod bargain; pub mod casting; pub mod hymn;` +- `content/lib.rs`: `pub mod gods;` + re-exports. `ui/lib.rs`: `pub mod chapel;` +- `State` additions: `pub hymn_cases: BTreeMap` (actor id → + case; slots enforced at slot() time), `pub bargains: BTreeMap` + (actor id → debt ledger; numeric side stays in Reputation::god_credit), + `pub deep_weather: i8` (Deep-weather conduct mod, -20..=+20; Shallows 0, + Works ±5 drift, Deep ±20 storms — Phase 10 owns the weather sim, Phase 8 + seeds the field + reads it). +- Hymns phase body: for each actor with a case + pending cast intent + (intents queue Phase 9; Phase 8 exposes `cast()` callable + tests): + conduct_chance → resolve(roll from COMBAT stream) → Cast applies verb + resolver outcomes (FIAT_LUX→lights.push, APERI→loud sound + Audit hears + link, PONDUS→fall_odo/mass hook, SILE→silence volumes (scent/sound + suppress flag — new Voxel flag? NO new flags in Phase 8; model as + short-lived SoundEvent swallower parent-side), FLUCTUS→fluid push hook, + MEMORIA→chapel-recall (drops flammables — inventory filter), CARO_VALE→ + corpse-question (1 question, corpse honesty parent-side)) → Miscast maps + via MiscastKind (KernelPanic → signal_sources.push + cherub link "everyone + groans"; Echo → sound arcs; WrongTarget/SwappedArgs → retarget tables). +- Hymns skill practice: successful casts record_use(Hymns); miscasts record + too (failure teaches — NetHack law). +- Fragile cook-off: fire phase checks carrier cards (fragile && heat → + cooks_off → forced resolve at the carrier's feet). +- Aether attention: attention(aether) feeds signal-spawn probability + (Phase 8: link "things that eat signal notice"; spawns Phase 9/10). +- Bargain hooks: kneel action (Chapel screen / kneel-anywhere key) → + kneel() + credit +10; repay on debt-fulfillment; due() checked per tick + (tenday expiry → collections link + blaspheme-lite); blaspheme on + altar-desecration; pay_lien in Audit-auction + mid-fight owed UI. +- Drowned-Saint memory exchange: Chapel screen lists Knowledge maps; + chosen map DELETED from vault (forgetting is real) → answer text + parent-side (Phase 10 content). +- Gods 3–5 content: Rustmother (mend-iron / blood-rusts debt), Drowned + Choir (water-breathing / dead-tongues debt), Gilded Audit (immunity / + arrears-with-interest debt) — full tables + intents, golden re-pin. + +## 3. Gate acceptance (checklist §3.5) + +- Miscast table playtested funny-not-cruel (all 7 verbs × all miscasts fire + without softlocks; cherub spawn groan-tested). +- Every bargain's debt collectible in-dungeon (tenday + until-done paths + + mid-fight lien payment moment). -- 2.51.2