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.
7.3 kB · 210 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211//! 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"); } }}