Something went wrong. Try again.
HEXWORK - A Lantern in the Deep: 3D ASCII terminal roguelike RPG in Rust.
Entirely designed and built by Muse Spark 1.3 to evaluate if I like the model.
Something went wrong. Try again.
20 kB · 641 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642//! 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<u64>, /// 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<Debt>,}
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<usize> { 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" ); }}