//! Auto-explore + auto-loot filters (docs/01 §7, docs/03 §3.10). //! //! The kindness layer (docs/01 §7: "kindness removes *friction*, never //! *consequence*"): auto-explore walks the boring corridors and auto-loot //! pockets the obvious valuables, but neither ever spends a consequence on //! the player's behalf — explore stops the moment anything deserves a //! decision, and loot never takes what could hurt (cursed) or burden //! (heavy/pricey-judgment) the delver. //! //! Pure data + pure functions: no I/O, no terminal calls, no RNG. /// Why auto-explore stops (docs/03 §3.10). /// /// The sane stop set, in priority order: /// /// - [`StopReason::NewSmell`] / [`StopReason::NewSound`] / /// [`StopReason::NewLight`] — a new sense datum (docs/01 §6: /// *"You smell ozone and wet wool..."*). Information is a decision. /// - [`StopReason::CopperVein`] — the miller pays for copper (docs/01 §2); /// veins are money worth a manual look. /// - [`StopReason::GrateAbove`] — something moves *above* you (docs/01 §2: /// `x look up`). Verticality kills the inattentive. /// - [`StopReason::Threat`] — any `?` glyph, monster, or damage: the /// NetHack-descended full stop. /// - [`StopReason::ExitFound`] — stairs/exits, so the player notices the /// way on (no quest compass, docs/03 §3.7 — the exit IS the news). /// - [`StopReason::Done`] — nothing left to explore. The only reason that /// does NOT stop: it reports completion, not news. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum StopReason { /// A new smell on the air. NewSmell, /// A new sound (`(` ripples, docs/01 §6). NewSound, /// A new light source at the edge of vision. NewLight, /// A vein of copper worth a manual look. CopperVein, /// A grate (or shaft mouth) overhead — check up. GrateAbove, /// Any `?`, monster, or damage. Threat, /// Stairs / exit found. ExitFound, /// Exploration complete — not news, never stops. Done, } /// Whether auto-explore should stop given what was seen this step. /// /// Returns `true` if `seen` holds any reason except [`StopReason::Done`]; /// empty and Done-only both mean "keep walking". Mixed `[Done, Threat]` /// stops — one real reason outranks any amount of completion. #[must_use] pub fn should_stop(seen: &[StopReason]) -> bool { seen.iter().any(|r| !matches!(r, StopReason::Done)) } /// What auto-loot pockets without asking (docs/03 §3.10). /// /// Only small obvious valuables are auto-take classes — relics, wire, oil. /// Everything else (weapons, armor, pelts, rations, ...) needs a hand /// (`g`et), because every kilo slows you (docs/03 §3.2 HAUL) and weight is /// a decision, not friction. #[derive(Clone, Debug, PartialEq, Eq)] pub struct LootFilter { /// Take relics (hymn-cards, aether-cells, charms — never Drowned bells /// by hand; the keeper bills you, docs/03 §3.6). pub take_relics: bool, /// Take wire-class trade goods (copper wire: the miller pays, docs/01 §2). pub take_wire: bool, /// Take lamp oil (oil scrip is legal tender, docs/02 §5). pub take_oil: bool, /// Floor in copper units: cheaper piles stay on the floor. pub min_price_cu: u32, } impl Default for LootFilter { /// Sane defaults: pocket relics, wire, and oil; no price floor. fn default() -> Self { Self { take_relics: true, take_wire: true, take_oil: true, min_price_cu: 0, } } } /// Whether auto-loot takes an item of `category` priced at `price` copper. /// /// - Cursed is NEVER auto-taken (NetHack law, docs/03 §3.4: cursed gear /// sticks and lies — touching it is a decision). /// - Then the price floor: `price < min_price_cu` stays. /// - Then the category gate: `"relic"`, wire-class (`"wire"`/`"trade"` / /// `"tradegood"`/`"copper wire"`), oil-class (`"oil"`/`"lamp oil"`). /// Anything else — weapons, armor, pelts, rations — needs a hand. /// Matching is case-insensitive; surrounding whitespace is ignored. #[must_use] pub fn wants(f: &LootFilter, category: &str, price: u32, cursed: bool) -> bool { if cursed { return false; } if price < f.min_price_cu { return false; } match category.trim().to_lowercase().as_str() { "relic" | "relics" => f.take_relics, "wire" | "trade" | "tradegood" | "trade_good" | "trade good" | "copper wire" => f.take_wire, "oil" | "lamp oil" | "lamp_oil" | "lamp-oil" => f.take_oil, _ => false, } } #[cfg(test)] mod tests { use super::*; #[test] fn stops_on_sense_data() { for r in [ StopReason::NewSmell, StopReason::NewSound, StopReason::NewLight, ] { assert!(should_stop(&[r]), "{r:?} must stop explore"); } } #[test] fn stops_on_copper_vein() { assert!(should_stop(&[StopReason::CopperVein])); } #[test] fn stops_on_grate_above() { assert!(should_stop(&[StopReason::GrateAbove])); } #[test] fn stops_on_threat() { assert!(should_stop(&[StopReason::Threat])); } #[test] fn stops_on_exit_found() { assert!(should_stop(&[StopReason::ExitFound])); } #[test] fn done_and_empty_do_not_stop() { assert!(!should_stop(&[])); assert!(!should_stop(&[StopReason::Done])); assert!(!should_stop(&[StopReason::Done, StopReason::Done])); } #[test] fn one_real_reason_outranks_done() { assert!(should_stop(&[StopReason::Done, StopReason::Threat])); assert!(should_stop(&[StopReason::Done, StopReason::ExitFound])); } #[test] fn cursed_is_never_autotaken() { let f = LootFilter::default(); for cat in ["relic", "wire", "oil", "trade"] { assert!(!wants(&f, cat, 500, true), "cursed {cat} must stay"); } } #[test] fn price_floor_holds() { let f = LootFilter { min_price_cu: 10, ..LootFilter::default() }; assert!(!wants(&f, "relic", 5, false), "cheap stays on the floor"); assert!(wants(&f, "relic", 10, false), "floor price is taken"); assert!(wants(&f, "relic", 50, false)); } #[test] fn defaults_take_relic_wire_oil() { let f = LootFilter::default(); assert!(f.take_relics && f.take_wire && f.take_oil); assert_eq!(f.min_price_cu, 0); for cat in ["relic", "wire", "oil", "trade", "lamp oil"] { assert!(wants(&f, cat, 0, false), "default takes {cat}"); } } #[test] fn toggles_gate_their_class() { let f = LootFilter { take_wire: false, ..LootFilter::default() }; assert!(!wants(&f, "wire", 50, false)); assert!(!wants(&f, "trade", 50, false)); assert!(wants(&f, "relic", 50, false), "relic toggle untouched"); } #[test] fn heavy_or_handy_categories_need_a_hand() { let f = LootFilter::default(); for cat in ["weapon", "armor", "ration", "pelt", "corpse", ""] { assert!(!wants(&f, cat, 500, false), "{cat:?} needs a hand"); } } }