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.
17 kB · 477 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478//! Identification: the four paths from mystery to mastery//! (docs/03-systems-design.md §3.4, §3.1, §3.9; docs/02-lore-bible.md §5).//!//! The four identification paths (docs/03 §3.4)://!//! 1. **By use** (NetHack law): wield it, eat it, burn it — the first use//! shows an effect but hides stats ([`IdfState::Tried`]); the second use//! confirms them ([`IdfState::Known`]).//! 2. **By tinker** (paid, sometimes lies if underpaid — and the ledger//! remembers): full price buys truth; half price buys a coin flip between//! truth and a plausible fib; less than half buys a refusal. The tinker//! lies ONLY when underpaid — a fairly paid tinker is honest.//! 3. **By trial room** (the Lampwrights' testing chapel: cages, water//! barrels, grounding rods — a beloved safe-toybox): pure substring//! matching over declared uses. The room shows what it shows and never//! lies; an empty result is honest silence, not ignorance.//! 4. **By ghost** (a dead delver's shade identifies one item for a memory)://! instant knowledge; the memory cost is collected parent-side (Phase 11).//!//! Honesty law (docs/03 §3.4): the GAME never lies — tooltips (see//! `hexwork_ui::look`, extended by `hexwork_ui::identify_ui`) show only what//! the delver has earned. Only NPCs lie (the underpaid tinker), and the lie//! is built here, parent-side of tooltips, so it can never launder itself//! into a tooltip.//!//! Lineage law (docs/03 §3.1, §3.9): identified recipes persist across//! delvers of one lineage; gear does not. The [`IdfBook`] is therefore keyed//! by TEMPLATE id and is the unit that persists — item instances come and//! go, the book remembers. (Placement is the parent's call; see `ghost.rs`//! / save-vault recommendation in module docs of [`IdfBook`].)//!//! Pure logic only: deterministic ([`std::collections::BTreeMap`], no clock,//! no I/O). The sole randomness is the tinker-lie coin flip, drawn from a//! caller-supplied `&mut Rng` so runs stay reproducible given the stream.
use std::collections::BTreeMap;
use hexwork_core::rng::Rng;
/// Identification state of one item template (docs/03 §3.4).#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]pub enum IdfState { /// Never used, never bought true, never trialed: stats hidden. #[default] Unknown, /// Used once: the effect was seen, stats stay hidden. Tried, /// Fully learned: tooltips may show the uses. Known,}
/// Per-template knowledge record.#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]pub struct IdfRecord { /// What the lineage knows about this template. pub state: IdfState, /// Physical uses witnessed (NetHack ladder counter). Independent of /// [`IdfState`]: tinker/ghost knowledge flips `state` without touching /// this counter — the delver knows WITHOUT having used it twice. pub uses_seen: u8,}
/// Per-LINEAGE knowledge book: template id → record (docs/03 §3.1, §3.9).////// The book persists; item instances don't. Recommended placement: the book/// lives per-delver in `State` during a run and merges into the lineage/// vault at death (mirroring the maps/recipes pattern — learning persists,/// gear does not). The parent owns that wiring; this type is just data.#[derive(Clone, Debug, Default, PartialEq, Eq)]pub struct IdfBook { /// Template id → knowledge record. Missing entry ≡ [`IdfState::Unknown`]. pub map: BTreeMap<String, IdfRecord>,}
impl IdfBook { /// Empty book: a lineage that has learned nothing yet. #[must_use] pub fn new() -> Self { Self::default() }
/// Look up a template; missing entries read as unknown with zero uses. #[must_use] pub fn get(&self, template: &str) -> IdfRecord { self.map.get(template).copied().unwrap_or_default() }}
/// NetHack identify-by-use law (docs/03 §3.4).////// First witnessed use: `Unknown → Tried` (effect seen, stats hidden)./// Second witnessed use: `Tried → Known` (`uses_seen >= 2` confirms)./// `Known` stays known; the counter still ticks (saturating).pub fn identify_by_use(book: &mut IdfBook, template: &str) { let rec = book.map.entry(template.to_owned()).or_default(); rec.uses_seen = rec.uses_seen.saturating_add(1); rec.state = match rec.state { IdfState::Known => IdfState::Known, _ if rec.uses_seen >= 2 => IdfState::Known, _ => IdfState::Tried, };}
/// The tinker's fixed fib table (docs/03 §3.4: "sometimes lies if underpaid").////// Four plausible-sounding deflections. The callee — never the caller — picks/// from this table by RNG, so every lie in the game is one of these four/// strings and stays greppable/auditable.pub const TINKER_FIBS: [&str; 4] = [ "cuts cleaner than it looks", "Army standard, mostly", "hums true when oiled", "saint-blessed, surely",];
/// Outcome of paying the tinker for an identification (docs/03 §3.4).#[derive(Clone, Debug, PartialEq, Eq)]pub enum TinkerOut { /// The tinker told the truth; the template is now [`IdfState::Known`]. Identified, /// Underpaid coin flip came up lies: the string is the fib (one of /// [`TINKER_FIBS`]). The book is NOT marked known — the delver was told /// a story, not the truth. LiedAbout(String), /// Paid less than half of fair: refused outright. The book is untouched. /// ("The ledger remembers" is parent-side: the town ledger records the /// insult offer; see docs/02 §4 Gallowgate law.) Refused,}
/// Pay the tinker for an identification (docs/03 §3.4; prices docs/02 §5).////// Bands (all comparisons in copper units):////// - `paid >= fair` → [`TinkerOut::Identified`], template marked/// [`IdfState::Known`]. A fairly paid tinker is ALWAYS honest./// - `fair/2 <= paid < fair` (lower bound rounded up) → 50/50:/// [`TinkerOut::Identified`] vs [`TinkerOut::LiedAbout`] with the fib drawn/// from [`TINKER_FIBS`] by `rng` — deterministic given the stream. The/// tinker lies ONLY in this band./// - `paid < fair/2` → [`TinkerOut::Refused`]; the book is untouched.////// A zero `fair` price means any payment (even nothing) is "fair" → honest.pub fn tinker_identify( book: &mut IdfBook, template: &str, paid_cu: u32, fair_cu: u32, rng: &mut Rng,) -> TinkerOut { if paid_cu >= fair_cu { mark_known(book, template); return TinkerOut::Identified; } if paid_cu < fair_cu.div_ceil(2) { return TinkerOut::Refused; } // Underpaid band: one coin flip, then (only on lies) one fib pick. if rng.below(2) == 0 { mark_known(book, template); TinkerOut::Identified } else { let fib = TINKER_FIBS[rng.below(TINKER_FIBS.len() as u64) as usize]; TinkerOut::LiedAbout(fib.to_owned()) }}
/// Flip a template to [`IdfState::Known`] without touching `uses_seen`/// (knowledge WITHOUT physical use — see [`IdfRecord`]).fn mark_known(book: &mut IdfBook, template: &str) { book.map.entry(template.to_owned()).or_default().state = IdfState::Known;}
/// Testing-chapel toybox rooms (docs/03 §3.4: cages, water barrels,/// grounding rods — the Lampwrights' beloved safe-toybox).#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum TrialRoom { /// Cages: reveals beast-related uses. Cage, /// Water barrels: reveals rust/drown/float uses. WaterBarrel, /// Grounding rods: reveals conduct/shock uses. GroundingRod,}
/// Keyword table for [`TrialRoom::Cage`]: beast-related uses.pub const CAGE_KEYS: &[&str] = &["beast", "trap", "bait"];/// Keyword table for [`TrialRoom::WaterBarrel`]: rust/drown/float uses.pub const BARREL_KEYS: &[&str] = &["rust", "drown", "float", "wet"];/// Keyword table for [`TrialRoom::GroundingRod`]: conduct/shock uses.pub const ROD_KEYS: &[&str] = &["conduct", "shock", "ground", "aether"];
/// Run one template's declared uses through one trial room.////// Pure case-insensitive substring match against the room's keyword table;/// returns the matched uses verbatim (possibly empty). The room NEVER lies:/// an empty vec is honest silence ("the room shows nothing"), never a claim/// that the item is useless.#[must_use]pub fn trial_room_check(template_uses: &[String], trial: TrialRoom) -> Vec<String> { let keys = match trial { TrialRoom::Cage => CAGE_KEYS, TrialRoom::WaterBarrel => BARREL_KEYS, TrialRoom::GroundingRod => ROD_KEYS, }; template_uses .iter() .filter(|u| { let low = u.to_lowercase(); keys.iter().any(|k| low.contains(k)) }) .cloned() .collect()}
/// A dead delver's shade identifies ONE item for a memory (docs/03 §3.9:/// ghosts "remember one item ID and one warning").////// `Unknown`/`Tried → Known`, returns `true` when newly known; already-`Known`/// returns `false` (no double charge). The MEMORY cost (which memory the/// delver gives up) is collected parent-side — Phase 11 owns the hook.pub fn ghost_identify(book: &mut IdfBook, template: &str) -> bool { let rec = book.map.entry(template.to_owned()).or_default(); if rec.state == IdfState::Known { false } else { rec.state = IdfState::Known; true }}
/// Shop-stock honesty label (docs/03 §3.4: shops sell unidentified stock/// HONESTLY LABELED as such — shops never lie about the LABEL; only NPCs/// lie about content, and never through tooltips).#[must_use]pub const fn shop_label(identified: bool) -> &'static str { if identified { "identified" } else { "unidentified — priced as mystery, honestly labeled" }}
#[cfg(test)]mod tests { use super::*;
fn uses(v: &[&str]) -> Vec<String> { v.iter().map(|s| (*s).to_owned()).collect() }
#[test] fn use_ladder_first_use_tried() { let mut book = IdfBook::new(); identify_by_use(&mut book, "lamp_oil"); let rec = book.get("lamp_oil"); assert_eq!(rec.state, IdfState::Tried); assert_eq!(rec.uses_seen, 1); }
#[test] fn use_ladder_second_use_known() { let mut book = IdfBook::new(); identify_by_use(&mut book, "lamp_oil"); identify_by_use(&mut book, "lamp_oil"); let rec = book.get("lamp_oil"); assert_eq!(rec.state, IdfState::Known); assert_eq!(rec.uses_seen, 2); }
#[test] fn use_known_stays_known_and_counter_ticks() { let mut book = IdfBook::new(); for _ in 0..5 { identify_by_use(&mut book, "lamp_oil"); } let rec = book.get("lamp_oil"); assert_eq!(rec.state, IdfState::Known); assert_eq!(rec.uses_seen, 5); }
#[test] fn missing_template_reads_as_unknown() { let book = IdfBook::new(); assert_eq!(book.get("never_seen"), IdfRecord::default()); assert_eq!(book.get("never_seen").state, IdfState::Unknown); }
#[test] fn book_tracks_templates_independently() { let mut book = IdfBook::new(); identify_by_use(&mut book, "lamp_oil"); identify_by_use(&mut book, "lamp_oil"); assert_eq!(book.get("lamp_oil").state, IdfState::Known); assert_eq!(book.get("cable_rat_corpse").state, IdfState::Unknown); }
#[test] fn tinker_fair_price_identifies() { let mut book = IdfBook::new(); let mut rng = Rng::from_seed(1); let out = tinker_identify(&mut book, "phial_green", 10, 10, &mut rng); assert_eq!(out, TinkerOut::Identified); assert_eq!(book.get("phial_green").state, IdfState::Known); }
#[test] fn tinker_overpay_identifies() { let mut book = IdfBook::new(); let mut rng = Rng::from_seed(7); let out = tinker_identify(&mut book, "phial_green", 99, 10, &mut rng); assert_eq!(out, TinkerOut::Identified); assert_eq!(book.get("phial_green").state, IdfState::Known); }
#[test] fn tinker_fair_price_never_lies_across_streams() { for seed in 0..64 { let mut book = IdfBook::new(); let mut rng = Rng::from_seed(seed); let out = tinker_identify(&mut book, "x", 12, 12, &mut rng); assert_eq!( out, TinkerOut::Identified, "fair pay must be honest (seed {seed})" ); } }
#[test] fn tinker_refused_below_half_leaves_book_untouched() { let mut book = IdfBook::new(); let mut rng = Rng::from_seed(3); // fair 10 → half band starts at 5; 4 is a refusal. let out = tinker_identify(&mut book, "phial_green", 4, 10, &mut rng); assert_eq!(out, TinkerOut::Refused); assert!( !book.map.contains_key("phial_green"), "refusal must not create a record" ); }
#[test] fn tinker_refused_boundary_rounds_up() { // fair 5 → half is 2.5 → band starts at 3; 2 refuses. let mut rng = Rng::from_seed(0); let mut book = IdfBook::new(); assert_eq!( tinker_identify(&mut book, "x", 2, 5, &mut rng), TinkerOut::Refused ); assert!(!book.map.contains_key("x")); }
#[test] fn tinker_cheap_band_never_refuses_and_lies_come_from_fib_table() { let mut saw_identified = false; let mut saw_lie = false; for seed in 0..1024 { let mut book = IdfBook::new(); let mut rng = Rng::from_seed(seed); match tinker_identify(&mut book, "x", 5, 10, &mut rng) { TinkerOut::Identified => { saw_identified = true; assert_eq!(book.get("x").state, IdfState::Known); } TinkerOut::LiedAbout(fib) => { saw_lie = true; assert!( TINKER_FIBS.contains(&fib.as_str()), "lies must come from the fib table, got {fib:?}" ); // A lie teaches nothing. assert_eq!(book.get("x").state, IdfState::Unknown); } TinkerOut::Refused => panic!("half pay must never refuse (seed {seed})"), } } assert!( saw_identified && saw_lie, "coin flip must be live both ways" ); }
#[test] fn tinker_lie_is_deterministic_given_stream() { let run = |seed| { let mut book = IdfBook::new(); let mut rng = Rng::from_seed(seed); tinker_identify(&mut book, "x", 5, 10, &mut rng) }; for seed in [0, 1, 42, 777, 9999] { assert_eq!(run(seed), run(seed), "same seed must repeat (seed {seed})"); } }
#[test] fn trial_cage_reveals_beast_uses() { let u = uses(&["bait a beast snare", "burn for light", "trap-tripwire"]); let got = trial_room_check(&u, TrialRoom::Cage); assert_eq!(got, uses(&["bait a beast snare", "trap-tripwire"])); }
#[test] fn trial_barrel_reveals_rust_drown_float() { let u = uses(&[ "watch it rust", "drown a fuse", "float a boat", "polish brass", ]); let got = trial_room_check(&u, TrialRoom::WaterBarrel); assert_eq!( got, uses(&["watch it rust", "drown a fuse", "float a boat"]) ); }
#[test] fn trial_rod_reveals_conduct_shock() { let u = uses(&["conduct hymns", "ground the charge", "eat (do not)"]); let got = trial_room_check(&u, TrialRoom::GroundingRod); assert_eq!(got, uses(&["conduct hymns", "ground the charge"])); }
#[test] fn trial_match_is_case_insensitive() { let u = uses(&["BEAST-bane"]); assert_eq!(trial_room_check(&u, TrialRoom::Cage), u); }
#[test] fn trial_empty_is_honest_silence() { let u = uses(&["burn for light", "bribe (scrip)"]); for room in [ TrialRoom::Cage, TrialRoom::WaterBarrel, TrialRoom::GroundingRod, ] { assert!( trial_room_check(&u, room).is_empty(), "no keywords must mean empty, never invented ({room:?})" ); } }
#[test] fn ghost_newly_known_returns_true() { let mut book = IdfBook::new(); assert!(ghost_identify(&mut book, "saint_charm")); assert_eq!(book.get("saint_charm").state, IdfState::Known); }
#[test] fn ghost_lifts_tried_to_known() { let mut book = IdfBook::new(); identify_by_use(&mut book, "saint_charm"); assert!(ghost_identify(&mut book, "saint_charm")); assert_eq!(book.get("saint_charm").state, IdfState::Known); }
#[test] fn ghost_reidentify_returns_false() { let mut book = IdfBook::new(); assert!(ghost_identify(&mut book, "saint_charm")); assert!(!ghost_identify(&mut book, "saint_charm")); }
#[test] fn shop_labels_are_honest() { assert_eq!(shop_label(true), "identified"); assert_eq!( shop_label(false), "unidentified — priced as mystery, honestly labeled" ); }}