//! 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, } 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 { 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 { 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" ); } }