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.
8.9 kB · 247 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248//! Run modes: Classic permadeath, Story checkpoints, Ironcartographer daily seed.//!//! Design traces://! - docs/03-systems-design.md §3.9 (Classic: permadeath, default. Story://! same sim, checkpoint at town entries, death wakes at the chapel owing//! the Corpse-tender. Ironcartographer, opt-in: one map, no recalls,//! seeded daily — the streamer mode).//!//! Split of duties (read before wiring)://! - THIS module: mode tag + permadeath table, town-entry checkpoint//! snapshot, chapel-debt resurrection state, and the integer daily-seed//! hash. Save bytes, checkpoint restore, debt quest billing, and the CLI//! `--daily` flag are parent-side.//! - [`crate::death`]: the Classic death snapshot (stone + ghost + vault//! allowlist) — Story deaths bypass it via [`resurrection_debt`].//!//! Core law: pure functions only. No RNG, no system time, no I/O, no//! threads, no `HashMap`. [`daily_seed`] hashes the caller-passed civil//! date with integer math — determinism law: the same date yields the same//! seed on every machine. MSRV 1.75.
/// Which run contract the delver signed (docs/03 §3.9).#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]pub enum Mode { /// Default: permadeath, epitaph + ghost + vault merge ([`crate::death`]). Classic, /// Same sim, checkpoints at town entries; death wakes at the chapel /// owing the Corpse-tender ([`resurrection_debt`]). No shame, same stories. Story, /// Opt-in streamer mode: one map, no recalls, seeded daily /// ([`daily_seed`]); permadeath like Classic. Ironcartographer,}
/// Whether death ends the run (`true`) or wakes the delver (`false`).////// Classic and Ironcartographer are permadeath; Story wakes at the chapel/// in chapel debt instead of dying.#[must_use]pub fn permadeath(m: Mode) -> bool { matches!(m, Mode::Classic | Mode::Ironcartographer)}
/// A Story checkpoint stamped at town entry: everything needed to resume/// the delver at the town gate (docs/03 §3.9 — "checkpoint at town/// entries").////// The parent stores the snapshot bytes keyed by this header and restores/// them on Story death (after billing [`resurrection_debt`]).#[derive(Clone, Debug, PartialEq, Eq)]pub struct Checkpoint { /// Run seed at stamp time. pub seed: u64, /// Tick stamped at the town gate. pub tick: u64, /// Canonical [`crate::state::State::hash_state`] at stamp time. pub state_hash: u64, /// Town entered (e.g. `"milthollow"`). pub town: String,}
/// Stamp a town-entry checkpoint: copies every field.#[must_use]pub fn checkpoint(seed: u64, tick: u64, state_hash: u64, town: &str) -> Checkpoint { Checkpoint { seed, tick, state_hash, town: town.to_owned(), }}
/// What a Story death costs: burial labor owed to the Corpse-tender/// (docs/03 §3.9 — "death = wake at chapel owing the Corpse-tender (debt/// quest, not game over)").#[derive(Clone, Debug, PartialEq, Eq)]pub struct ChapelDebt { /// Burials still owed; resurrection is complete when this reaches 0 /// (see [`debt_paid`]). pub burials_owed: u8, /// Which chapel the delver woke in (pinned: the Lamplighter's). pub chapel: String,}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum ChapelDebtError { UnknownActor, DeadActor, NoDebt,}
/// Exact canonical chapel name stored in Story resurrection debts.pub const LAMPLIGHTER_CHAPEL: &str = "chapel of the Lamplighter";
/// Wake at the chapel: 1 burial owed at the chapel of the Lamplighter.////// Both values are pinned — the Corpse-tender's terms are not negotiable/// and not randomized (deducible cruelty: the price of a second chance is/// readable before it bites).#[must_use]pub fn resurrection_debt() -> ChapelDebt { ChapelDebt { burials_owed: 1, chapel: LAMPLIGHTER_CHAPEL.to_owned(), }}
/// Work off one burial: saturating-decrements `burials_owed`, returns/// `true` when nothing is owed (resurrection complete).////// Saturating: paying at zero stays at zero and still reports paid — extra/// rites are never punished, and the counter can never wrap to 255.pub fn debt_paid(d: &mut ChapelDebt) -> bool { d.burials_owed = d.burials_owed.saturating_sub(1); d.burials_owed == 0}
/// Ironcartographer daily seed: FNV-1a (64-bit) over the little-endian/// bytes of `(year, month, day)` as three `u32` words (docs/03 §3.9 —/// "seeded daily").////// NO system time is read here — the caller passes the civil date (the CLI/// `--daily` flag), so the same date yields the same seed on every machine/// (streamer mode: the whole chat delves the same Deep). Month/day are/// hashed verbatim, never validated: `daily_seed(2026, 9, 4)` and/// `daily_seed(2026, 4, 9)` simply differ, and impossible dates still hash/// (the calendar is the caller's problem, the seed is always total).#[must_use]pub fn daily_seed(year: u32, month: u32, day: u32) -> u64 { let mut hash: u64 = 0xcbf29ce484222325; for word in [year, month, day] { for byte in word.to_le_bytes() { hash ^= u64::from(byte); hash = hash.wrapping_mul(0x100000001b3); } } hash}
#[cfg(test)]mod tests { use super::*;
#[test] fn permadeath_table() { assert!(permadeath(Mode::Classic), "Classic is permadeath"); assert!( permadeath(Mode::Ironcartographer), "Ironcartographer is permadeath" ); assert!(!permadeath(Mode::Story), "Story wakes, never dies"); }
#[test] fn mode_is_copy_and_exhaustive() { let m = Mode::Story; let n = m; assert_eq!(m, n, "mode tags copy freely into save headers"); // Exhaustive match: adding a fourth mode fails to compile here // until its permadeath row is decided. for mode in [Mode::Classic, Mode::Story, Mode::Ironcartographer] { let _ = match mode { Mode::Classic => "permadeath", Mode::Story => "chapel", Mode::Ironcartographer => "daily permadeath", }; } }
#[test] fn checkpoint_stores_all_fields() { let c = checkpoint(9, 100, 0xbeef, "milthollow"); assert_eq!(c.seed, 9); assert_eq!(c.tick, 100); assert_eq!(c.state_hash, 0xbeef); assert_eq!(c.town, "milthollow"); }
#[test] fn checkpoint_town_verbatim() { let c = checkpoint(1, 2, 3, "Copperwash Undermere"); assert_eq!(c.town, "Copperwash Undermere", "no normalization"); let e = checkpoint(1, 2, 3, ""); assert_eq!(e.town, "", "empty towns stamp, parent gates"); }
#[test] fn resurrection_debt_is_one_burial_at_lamplighter() { let d = resurrection_debt(); assert_eq!(d.burials_owed, 1, "a second chance costs one burial"); assert_eq!( d.chapel, LAMPLIGHTER_CHAPEL, "the Corpse-tender keeps the Lamplighter's chapel" ); }
#[test] fn debt_paid_clears_single_burial() { let mut d = resurrection_debt(); assert!(debt_paid(&mut d), "one burial worked = debt clear"); assert_eq!(d.burials_owed, 0); }
#[test] fn debt_paid_counts_down_longer_debts() { let mut d = ChapelDebt { burials_owed: 2, chapel: LAMPLIGHTER_CHAPEL.to_owned(), }; assert!(!debt_paid(&mut d), "one of two worked = still owed"); assert_eq!(d.burials_owed, 1); assert!(debt_paid(&mut d), "second rite clears it"); assert_eq!(d.burials_owed, 0); }
#[test] fn debt_paid_at_zero_stays_zero_and_reports_paid() { let mut d = ChapelDebt { burials_owed: 0, chapel: LAMPLIGHTER_CHAPEL.to_owned(), }; assert!(debt_paid(&mut d), "extra rites are never punished"); assert_eq!(d.burials_owed, 0, "saturates, never wraps to 255"); }
#[test] fn daily_seed_is_deterministic() { assert_eq!(daily_seed(2026, 9, 4), daily_seed(2026, 9, 4)); assert_eq!(daily_seed(1999, 12, 31), daily_seed(1999, 12, 31)); }
#[test] fn daily_seed_pins() { // Regression pins: FNV-1a over LE bytes of (y, m, d) as u32s. // Change the hash and these fail loudly — replay stability demands it. assert_eq!(daily_seed(2026, 9, 4), 5_209_206_873_913_379_539); assert_eq!(daily_seed(1999, 12, 31), 3_564_900_217_459_044_528); }
#[test] fn daily_seed_distinct_dates_differ() { let a = daily_seed(2026, 9, 4); assert_ne!(a, daily_seed(2026, 9, 5), "tomorrow delves elsewhere"); assert_ne!(a, daily_seed(2026, 4, 9), "transposed m/d differ"); assert_ne!(a, daily_seed(2000, 1, 1), "eras differ"); }}