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.
9.4 kB · 249 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250//! Pure Classic-death DTO and policy helpers for downstream controllers.//!//! Design traces://! - docs/03-systems-design.md §3.9 (Classic: permadeath with epitaph,//! cause-chain, replay, ghost memory, inheritable knowledge, and auctioned//! gear; buying back YOUR OWN sword at +10% is beloved).//! - docs/03 §3.11 (200+ epitaphs at 1.0; the epitaph TEXT comes from//! [`crate::epitaphs::epitaph`] — this module only carries the stamped//! string onto the stone, never picks it).//!//! Split of duties (read before wiring)://! - [`crate::epitaphs`]: picks the death line ([`crate::epitaphs::epitaph`])//! and per-link replay subtitles ([`crate::epitaphs::subtitle`]).//! - [`crate::cause`]: the live ordered damage record during play; the//! parent flattens its links to `(rule, detail)` pairs for [`record`].//! - [`crate::services::auction_buyback`]: the CANONICAL +10% humiliation//! math — [`buyback_price`] delegates to it, never reimplements it.//! - THIS module: pure DTO/policy outputs that a downstream death controller//! may use. It neither owns terminal authority nor writes any artifact.//!//! Core law: pure functions only. No RNG, no time, no I/O, no threads, no//! maps of any kind (plain owned data). MSRV 1.75.
/// The carved stone: everything a Classic death leaves behind except the/// ghost ([`Ghost`]) and the vault merge ([`persists`]).////// `seed` / `tick` / `state_hash` re-seed the `.hexdemo` replay viewer/// (bytes are parent-side in `hexwork-save`); `epitaph` is the stamped line/// from [`crate::epitaphs::epitaph`]; `cause_chain` is `(rule, detail)` per/// link, oldest first, flattened from [`crate::cause::CauseChain`].#[derive(Clone, Debug, PartialEq, Eq)]pub struct DeathRecord { /// Run seed (re-seeds the replay viewer). pub seed: u64, /// Tick of death. pub tick: u64, /// Canonical [`crate::state::State::hash_state`] at death. pub state_hash: u64, /// Stamped epitaph line (NetHack voice, replay-stable). pub epitaph: String, /// `(rule, detail)` per cause link, oldest first. pub cause_chain: Vec<(String, String)>,}
/// Snapshot a Classic death: copies every field, clones the cause pairs.////// The parent passes the terminal epitaph (stamped via/// [`crate::epitaphs::epitaph`]) and the flattened/// [`crate::cause::CauseChain`] links. Empty chains are legal (unwitnessed/// deaths still carve a stone); the empty-epitaph case never reaches here/// (the table is never empty — see [`crate::epitaphs::EPITAPHS`]).#[must_use]pub fn record( seed: u64, tick: u64, state_hash: u64, epitaph: &str, causes: &[(String, String)],) -> DeathRecord { DeathRecord { seed, tick, state_hash, epitaph: epitaph.to_owned(), cause_chain: causes.to_vec(), }}
/// A ghost left in-world: exactly one item ID and one warning (docs/03/// §3.9 — "they remember one item ID and one warning").////// DECISION: [`ghost_for`] returns [`Ghost`] directly and does NOT reject/// empty strings. Non-emptiness is enforced parent-side at spawn (the spawn/// gate drops ghosts with an empty `item_id` or `warning` and logs why —/// the sim kernel never invents or silently repairs content). The contract/// test below pins the pass-through so a future "helpful" validation cannot/// sneak in here unnoticed.#[derive(Clone, Debug, PartialEq, Eq)]pub struct Ghost { /// The one item id the ghost remembers (opaque slug, e.g. `"sword-fang"`). pub item_id: String, /// The one warning the ghost gives (NetHack voice, caller-composed). pub warning: String,}
/// Leave a ghost remembering `item_id` with `warning`.////// Pass-through by design (see [`Ghost`]): the parent validates non-empty/// at spawn. Both strings are stored verbatim — no trimming, no repair.#[must_use]pub fn ghost_for(item_id: &str, warning: &str) -> Ghost { Ghost { item_id: item_id.to_owned(), warning: warning.to_owned(), }}
/// Audit auction buyback for a `base_cu` hammer price: base +10% humiliation/// premium, truncating, saturating (docs/03 §3.9 — beloved).////// CANONICALIZATION: [`crate::services::auction_buyback`] is canonical./// This function delegates to it verbatim so downstream death/auction code has/// a core policy name without a second copy of the math. If the premium ever changes, change/// [`crate::services::AUCTION_FEE_PCT`] once; the equality test below fails/// loudly if the two ever disagree.#[must_use]pub fn buyback_price(base_cu: u32) -> u32 { crate::services::auction_buyback(base_cu)}
/// Product-policy allowlist for downstream typed lineage projection (docs/03/// §3.9). Core does not perform the projection or write a vault.////// - `"maps"` → mapped region ids ([`crate::runtime::RunKnowledge::mapped_regions`])./// - `"recipes"` → identified recipe ids./// - `"translations"` → translated hymn ids./// - `"identified"` → identified item-template ids (content identify/// ledger — what the lineage KNOWS, not what it HOLDS)./// - `"ghosts"` → met ghosts (`LineageVault::ghosts`).////// NEVER persisted: gear instances (auctioned), bargain debts (die with the/// delver or convert to ledger negatives + Audit arrears — see/// `State::phase_death`), gold/coin (spent or scattered). The absence test/// below machine-checks that none of those words enter this list.#[must_use]pub fn persists() -> Vec<&'static str> { vec!["maps", "recipes", "translations", "identified", "ghosts"]}
#[cfg(test)]mod tests { use super::*;
fn causes() -> Vec<(String, String)> { vec![ ("heat rises".to_owned(), "the smoke climbed".to_owned()), ( "gravity is fair".to_owned(), "the delver followed".to_owned(), ), ] }
#[test] fn record_stores_all_fields() { let d = record(7, 42, 0xdead, "a stone line", &causes()); assert_eq!(d.seed, 7); assert_eq!(d.tick, 42); assert_eq!(d.state_hash, 0xdead); assert_eq!(d.epitaph, "a stone line"); assert_eq!(d.cause_chain, causes()); }
#[test] fn record_clones_causes_and_epitaph() { let mut c = causes(); let d = record(1, 2, 3, "stone", &c); c.clear(); assert_eq!(d.cause_chain.len(), 2, "record must own its chain"); assert_eq!(d.epitaph, "stone"); }
#[test] fn record_empty_chain_is_legal() { let d = record(1, 2, 3, "died without witnesses", &[]); assert!(d.cause_chain.is_empty()); assert_eq!(d.epitaph, "died without witnesses"); }
#[test] fn record_keeps_link_order_oldest_first() { let d = record(1, 2, 3, "stone", &causes()); assert_eq!(d.cause_chain[0].0, "heat rises"); assert_eq!(d.cause_chain[1].0, "gravity is fair"); }
#[test] fn ghost_stores_item_and_warning() { let g = ghost_for("sword-fang", "check the grate above"); assert_eq!(g.item_id, "sword-fang"); assert_eq!(g.warning, "check the grate above"); }
#[test] fn ghost_contract_passes_through_parent_validates() { // DECISION contract: empty strings pass through verbatim — the // SPAWN GATE parent-side rejects them, never this constructor. let g = ghost_for("", ""); assert_eq!(g.item_id, ""); assert_eq!(g.warning, ""); let h = ghost_for(" sword-fang ", " check grates "); assert_eq!(h.item_id, " sword-fang ", "no trimming, no repair"); }
#[test] fn buyback_beloved_hundred_costs_110() { assert_eq!(buyback_price(100), 110, "YOUR OWN sword costs 110%"); }
#[test] fn buyback_zero_and_truncation() { assert_eq!(buyback_price(0), 0, "nothing in, nothing out"); assert_eq!(buyback_price(10), 11); assert_eq!( buyback_price(7), 7, "7.7 truncates down, house favors house" ); assert_eq!(buyback_price(1), 1, "1.1 truncates to 1"); }
#[test] fn buyback_saturates_never_wraps() { assert_eq!(buyback_price(u32::MAX), u32::MAX); }
#[test] fn buyback_matches_services_canonical() { // If services.rs ever reprices the premium, this fails loudly — // services::auction_buyback is canonical, this delegates. for hammer in [0, 1, 7, 10, 99, 100, 101, 8_000, u32::MAX - 1, u32::MAX] { assert_eq!( buyback_price(hammer), crate::services::auction_buyback(hammer), "death-side alias drifted from canonical at {hammer}" ); } }
#[test] fn persists_is_exact_allowlist() { assert_eq!( persists(), vec!["maps", "recipes", "translations", "identified", "ghosts"], "lineage policy shape: exact, ordered, five categories" ); }
#[test] fn persists_never_gear_debts_gold() { let kept = persists(); for banned in ["gear", "debts", "debt", "gold", "coin", "sword", "items"] { assert!( !kept.contains(&banned), "vault must never persist {banned:?}: {kept:?}" ); } }}