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.
22 kB · 581 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582//! Packs: haul weight/bulk/noise (HAUL) + equip slots.//!//! Design traces://! - docs/03-systems-design.md §3.2 HAUL (weight + bulk + noise://! overburdened = loud, slow, fall-prone on ropes; mules + sledges +//! rope-rigging extend range). This module owns the weight/bulk/noise//! arithmetic; slowness, fall-proneness, mules, and sledges are parent-side//! movement/loadout hooks that read these numbers.//! - docs/03 §3.4 (polearms rule in shafts — [`Pack::reach_bonus`]; cursed//! gear sticks — wield/wear/drop enforce it here; corpses under NetHack//! law — butcher/eat/carry/cure are ACTION hooks in Phase 9/10, this module//! only names corpses and rot-risk via [`Pack::is_corpse`] ///! [`Pack::will_rot`]).//! - docs/03 §3.3 law 6 (silver stills Drowned — [`Pack::silvered_stills`]//! reports the wielded-silvered fact; the quell hook is parent-side).//! - docs/04 §4.2 (equip changes arrive as phase-1 intents; noise feeds the//! phase-9 sound emission) and §4.10.2 (determinism: `BTreeMap` keyed by//! instance id — iteration order is spawn order, never hashed; no RNG, no//! time, no I/O, no threads; weight sums over the ordered map so the total//! is bit-identical run to run).//!//! Ownership notes://! - Weight/bulk tables (`template id → kg`, `template id → bulk`) are//! CONTENT data owned parent-side (from `weight_kg`/`bulk` columns);//! unknown templates fall back to [`UNKNOWN_WEIGHT_KG`] / [`UNKNOWN_BULK`]//! and the fallback is documented, never silent — a pack full of unknown//! templates weighs exactly N kg, which playtesters can see and report.//! - No trait objects in core hot paths: totals take `&BTreeMap` tables, not//! `&dyn Fn` closures.//! - MSRV 1.75: no `is_multiple_of`, no newer std APIs.
use std::collections::BTreeMap;
use crate::item::Instance;
/// Fallback haul weight for a template missing from the content weight/// table: exactly 1.0kg (documented, visible, parent-tunable).pub const UNKNOWN_WEIGHT_KG: f32 = 1.0;/// Fallback bulk for a template missing from the content bulk table.pub const UNKNOWN_BULK: u8 = 1;/// Default carry ceiling in kg (docs/03 §3.2: mules/sledges extend this).pub const DEFAULT_MAX_WEIGHT_KG: f32 = 40.0;/// Default bulk ceiling in Tetris units (docs/03 §3.1 inventory Tetris).pub const DEFAULT_MAX_BULK: u8 = 20;/// Noise emitted while over the weight ceiling (docs/03 §3.2: loud).pub const NOISE_OVER_WEIGHT: u8 = 2;/// Noise emitted while over the bulk ceiling (clanking, snagging — loud).pub const NOISE_OVER_BULK: u8 = 2;
/// One delver's pack: owned instances plus two equip slots.////// Equipped items stay in [`Pack::items`] — the slots are pointers, so/// wielding never duplicates or loses an instance. Corpse-carrying,/// drag-rigs, and mule packs are parent-side containers that read the same/// totals off their own `Pack`.#[derive(Clone, Debug, PartialEq)]pub struct Pack { /// Owned instances keyed by live id (BTreeMap: deterministic order). pub items: BTreeMap<u64, Instance>, /// Wielded weapon instance id (polearm bonus, silvered stilling). pub equipped_weapon: Option<u64>, /// Worn armor instance id. pub equipped_armor: Option<u64>, /// Carry ceiling in kg. pub max_weight_kg: f32, /// Bulk ceiling in Tetris units. pub max_bulk: u8,}
impl Pack { /// Empty pack with default ceilings ([`DEFAULT_MAX_WEIGHT_KG`], /// [`DEFAULT_MAX_BULK`]). pub fn new() -> Self { Self { items: BTreeMap::new(), equipped_weapon: None, equipped_armor: None, max_weight_kg: DEFAULT_MAX_WEIGHT_KG, max_bulk: DEFAULT_MAX_BULK, } }
/// Stow an instance. Ids come from the parent spawn counter and must be /// unique; re-adding an id replaces the row (never duplicates). pub fn add(&mut self, item: Instance) { self.items.insert(item.id, item); }
/// Borrow an instance by live id. pub fn get(&self, id: u64) -> Option<&Instance> { self.items.get(&id) }
/// True when the pack holds a live id. pub fn contains(&self, id: u64) -> bool { self.items.contains_key(&id) }
/// Wielded instance, if any. pub fn wielded(&self) -> Option<&Instance> { self.equipped_weapon.and_then(|id| self.items.get(&id)) }
/// Worn armor instance, if any. pub fn worn(&self) -> Option<&Instance> { self.equipped_armor.and_then(|id| self.items.get(&id)) }
/// Total haul weight in kg over the content weight table /// (`template id → kg`). Unknown templates count [`UNKNOWN_WEIGHT_KG`] /// each — documented fallback, never silent zero. pub fn total_weight(&self, weights: &BTreeMap<String, f32>) -> f32 { self.items .values() .map(|i| { weights .get(&i.template) .copied() .unwrap_or(UNKNOWN_WEIGHT_KG) }) .sum() }
/// Total bulk in Tetris units over the content bulk table. Unknown /// templates count [`UNKNOWN_BULK`] each. Saturates: a pack cannot /// overflow u8 no matter how much corpse it holds. pub fn total_bulk(&self, bulks: &BTreeMap<String, u8>) -> u8 { self.items.values().fold(0u8, |acc, i| { acc.saturating_add(bulks.get(&i.template).copied().unwrap_or(UNKNOWN_BULK)) }) }
/// True while over the weight ceiling. pub fn overweight(&self, weights: &BTreeMap<String, f32>) -> bool { self.total_weight(weights) > self.max_weight_kg }
/// True while over the bulk ceiling. pub fn overbulk(&self, bulks: &BTreeMap<String, u8>) -> bool { self.total_bulk(bulks) > self.max_bulk }
/// True while over EITHER axis (docs/03 §3.2: overburdened = loud, /// slow, fall-prone — the parent reads this for all three). pub fn overburdened( &self, weights: &BTreeMap<String, f32>, bulks: &BTreeMap<String, u8>, ) -> bool { self.overweight(weights) || self.overbulk(bulks) }
/// HAUL noise: 0 clean, [`NOISE_OVER_WEIGHT`] over weight, /// [`NOISE_OVER_BULK`] over bulk (stacking). The parent feeds this into /// phase-9 sound emission (docs/03 §3.3 law 2: monsters investigate /// positions, and an overburdened delver is a position). pub fn noise_penalty( &self, weights: &BTreeMap<String, f32>, bulks: &BTreeMap<String, u8>, ) -> u8 { let mut noise = 0u8; if self.overweight(weights) { noise += NOISE_OVER_WEIGHT; } if self.overbulk(bulks) { noise += NOISE_OVER_BULK; } noise }
/// Wield instance `id` as the weapon. /// /// - `Err("no such item")` when the id is not in the pack. /// - A stuck (cursed) currently-wielded item blocks the swap with /// `Err("it sticks to your hands")` — EXCEPT when the incoming item /// carries the `blessed` FLAG (not `Condition::Blessed`: the flag is /// the equip rule; the condition is flavor/identify state, and a /// Blessed-condition item without the flag never occurs in canonical /// data). Blessed gear hums true and the curse yields for the /// grip-swap only — dropping the cursed item still sticks (WS1 /// ruling, consistent with docs/03 §3.4: blessed hums true, /// remove-curse is the Corpse-tender, parent-side). The displaced /// item stays stowed; nothing is lost. Deliberate, parent-audited. pub fn wield(&mut self, id: u64) -> Result<(), &'static str> { let incoming = self.items.get(&id).ok_or("no such item")?; let incoming_blessed = incoming.blessed; if let Some(cur) = self.equipped_weapon { if cur != id { if let Some(held) = self.items.get(&cur) { if held.sticks() && !incoming_blessed { return Err("it sticks to your hands"); } } } } self.equipped_weapon = Some(id); Ok(()) }
/// Empty the weapon hand. /// /// `Err("it sticks to your hands")` while a cursed item is wielded /// (docs/03 §3.4: remove-curse is a person, the Corpse-tender — /// parent-side, not a scroll). `Ok` with an already-empty hand. pub fn unwield(&mut self) -> Result<(), &'static str> { if let Some(cur) = self.equipped_weapon { if let Some(held) = self.items.get(&cur) { if held.sticks() { return Err("it sticks to your hands"); } } } self.equipped_weapon = None; Ok(()) }
/// Wear instance `id` as armor. Same contract as [`Pack::wield`]: /// missing ids fail, stuck armor blocks the swap unless the incoming /// piece is blessed. pub fn wear(&mut self, id: u64) -> Result<(), &'static str> { let incoming = self.items.get(&id).ok_or("no such item")?; let incoming_blessed = incoming.blessed; if let Some(cur) = self.equipped_armor { if cur != id { if let Some(worn) = self.items.get(&cur) { if worn.sticks() && !incoming_blessed { return Err("it sticks to your hands"); } } } } self.equipped_armor = Some(id); Ok(()) }
/// Remove armor. Same stick rule as [`Pack::unwield`]. pub fn remove(&mut self) -> Result<(), &'static str> { if let Some(cur) = self.equipped_armor { if let Some(worn) = self.items.get(&cur) { if worn.sticks() { return Err("it sticks to your hands"); } } } self.equipped_armor = None; Ok(()) }
/// Drop instance `id` out of the pack (floor pile, offering, evidence — /// parent-side landing). /// /// Cursed items stick: `Err("it sticks to your hands")` and the pack is /// unchanged. Dropping an equipped, non-stuck item clears its slot first /// so slots never dangle. Missing ids are `Err("no such item")`. pub fn drop(&mut self, id: u64) -> Result<Instance, &'static str> { let item = self.items.get(&id).ok_or("no such item")?; if item.sticks() { return Err("it sticks to your hands"); } if self.equipped_weapon == Some(id) { self.equipped_weapon = None; } if self.equipped_armor == Some(id) { self.equipped_armor = None; } self.items.remove(&id).ok_or("no such item") }
/// Shaft reach (docs/03 §3.4 "polearms rule in shafts"): +2 while the /// wielded item carries the `polearm` machine tag and the fight is NOT /// `confined` (shaft/vent flag, parent-side). Anything else — confined /// polearm, open-field blade, empty hands — is +0. Honest and narrow: /// no penalty for short arms in tight vents here; accuracy-in-confined /// is a combat-table concern (Phase 9/10), not pack state. pub fn reach_bonus(&self, confined: bool) -> i8 { match self.wielded() { Some(w) if !confined && w.has_tag("polearm") => 2, _ => 0, } }
/// True while the wielded item is silvered (docs/03 §3.3 law 6: silver /// stills Drowned). The Drowned-quell resolution is a parent-side hook; /// this is the fact it reads. pub fn silvered_stills(&self) -> bool { self.wielded().is_some_and(|w| w.silvered) }
/// True when live id `id` names a corpse (tags contain `corpse`). /// Missing ids are false, never an error — the dead do not litigate. pub fn is_corpse(&self, id: u64) -> bool { self.items.get(&id).is_some_and(|i| i.has_tag("corpse")) }
/// True when live id `id` will rot: `perishable` machine tag OR /// `Perishable` condition. Butcher/eat/carry/cure are ACTION hooks /// parent-side (Phase 9/10); this only flags the clock-starters. /// Missing ids are false. pub fn will_rot(&self, id: u64) -> bool { self.items.get(&id).is_some_and(|i| { i.has_tag("perishable") || i.condition == crate::item::Condition::Perishable }) }}
impl Default for Pack { fn default() -> Self { Self::new() }}
#[cfg(test)]mod tests { use super::*; use crate::item::{spawn, Condition, ItemTemplate};
fn sword(id: u64) -> Instance { spawn(&ItemTemplate::new("iron_sword", "IRON", "sound"), id).unwrap() }
fn lamp(id: u64) -> Instance { let mut t = ItemTemplate::new("brass_lamp", "BRASS", "sound"); t.tags = vec!["lamp".to_string()]; spawn(&t, id).unwrap() }
fn pike(id: u64) -> Instance { let mut t = ItemTemplate::new("ash_pike", "WOOD", "sound"); t.tags = vec!["polearm".to_string()]; spawn(&t, id).unwrap() }
fn corpse(id: u64) -> Instance { let mut t = ItemTemplate::new("rat_corpse", "FLESH", "perishable"); t.tags = vec!["corpse".to_string(), "perishable".to_string()]; spawn(&t, id).unwrap() }
fn cursed_ring(id: u64) -> Instance { let mut t = ItemTemplate::new("black_ring", "BRASS", "sound"); t.cursed = true; spawn(&t, id).unwrap() }
fn tables() -> (BTreeMap<String, f32>, BTreeMap<String, u8>) { let weights: BTreeMap<String, f32> = [("iron_sword", 3.0), ("brass_lamp", 1.5), ("ash_pike", 4.0)] .iter() .map(|(k, v)| ((*k).to_string(), *v)) .collect(); let bulks: BTreeMap<String, u8> = [("iron_sword", 3), ("brass_lamp", 1), ("ash_pike", 5)] .iter() .map(|(k, v)| ((*k).to_string(), *v)) .collect(); (weights, bulks) }
fn close_enough(got: f32, want: f32) -> bool { (got - want).abs() < 1e-6 }
#[test] fn weight_sums_known_templates() { let (w, _) = tables(); let mut pack = Pack::new(); pack.add(sword(1)); pack.add(lamp(2)); assert!(close_enough(pack.total_weight(&w), 4.5)); }
#[test] fn weight_unknown_template_falls_back() { let (w, _) = tables(); let mut pack = Pack::new(); pack.add(corpse(9)); assert!(close_enough(pack.total_weight(&w), UNKNOWN_WEIGHT_KG)); }
#[test] fn bulk_sums_and_falls_back() { let (_, b) = tables(); let mut pack = Pack::new(); pack.add(sword(1)); pack.add(pike(2)); assert_eq!(pack.total_bulk(&b), 8); pack.add(corpse(9)); assert_eq!(pack.total_bulk(&b), 8 + UNKNOWN_BULK); }
#[test] fn overburdened_on_weight_axis() { let (w, b) = tables(); let mut pack = Pack::new(); pack.max_weight_kg = 4.0; pack.add(sword(1)); pack.add(lamp(2)); assert!(pack.overweight(&w)); assert!(!pack.overbulk(&b)); assert!(pack.overburdened(&w, &b)); }
#[test] fn overburdened_on_bulk_axis() { let (w, b) = tables(); let mut pack = Pack::new(); pack.max_bulk = 4; pack.add(pike(1)); assert!(!pack.overweight(&w)); assert!(pack.overbulk(&b)); assert!(pack.overburdened(&w, &b)); }
#[test] fn clean_pack_is_not_overburdened() { let (w, b) = tables(); let mut pack = Pack::new(); pack.add(sword(1)); assert!(!pack.overburdened(&w, &b)); assert_eq!(pack.noise_penalty(&w, &b), 0); }
#[test] fn noise_penalty_values() { let (w, b) = tables(); let mut pack = Pack::new(); pack.add(sword(1)); pack.add(pike(2)); assert_eq!(pack.noise_penalty(&w, &b), 0); pack.max_weight_kg = 1.0; assert_eq!(pack.noise_penalty(&w, &b), NOISE_OVER_WEIGHT); pack.max_weight_kg = DEFAULT_MAX_WEIGHT_KG; pack.max_bulk = 1; assert_eq!(pack.noise_penalty(&w, &b), NOISE_OVER_BULK); pack.max_weight_kg = 1.0; assert_eq!( pack.noise_penalty(&w, &b), NOISE_OVER_WEIGHT + NOISE_OVER_BULK ); }
#[test] fn wield_and_unwield_happy_path() { let mut pack = Pack::new(); pack.add(sword(1)); pack.wield(1).expect("present item must wield"); assert_eq!(pack.wielded().map(|i| i.id), Some(1)); pack.unwield().expect("clean blade must unwield"); assert!(pack.wielded().is_none()); // Empty hand unwields cleanly. pack.unwield().expect("empty unwield must be Ok"); }
#[test] fn wield_missing_is_err() { let mut pack = Pack::new(); assert_eq!(pack.wield(99), Err("no such item")); assert_eq!(pack.wear(99), Err("no such item")); assert_eq!(pack.drop(99).map(|_| ()), Err("no such item")); }
#[test] fn unwield_cursed_sticks() { let mut pack = Pack::new(); pack.add(cursed_ring(1)); pack.add(sword(2)); // Wielding the cursed ring first is unremarkable… pack.wield(1).expect("cursed ring wields fine"); // …but it will not let go for a clean blade. assert_eq!(pack.wield(2), Err("it sticks to your hands")); assert_eq!(pack.unwield(), Err("it sticks to your hands")); assert_eq!(pack.wielded().map(|i| i.id), Some(1)); }
#[test] fn blessed_displaces_stuck_curse() { // Design ruling, documented on `wield`: blessed gear hums true and // the curse yields ("blessed always allowed", docs/03 §3.4). let mut pack = Pack::new(); pack.add(cursed_ring(1)); let mut t = ItemTemplate::new("choir_blade", "SAINT_BONE", "sound"); t.blessed = true; pack.add(spawn(&t, 2).unwrap()); pack.wield(1).expect("cursed ring wields fine"); pack.wield(2).expect("blessed blade must always wield"); assert_eq!(pack.wielded().map(|i| i.id), Some(2)); // The curse stays stowed, not destroyed: it can be re-gripped later. assert!(pack.contains(1)); }
#[test] fn wear_and_remove_armor() { let mut pack = Pack::new(); pack.add(sword(1)); pack.wear(1).expect("armor slot takes any stowed item"); assert_eq!(pack.worn().map(|i| i.id), Some(1)); pack.remove().expect("clean armor removes"); assert!(pack.worn().is_none()); pack.remove().expect("empty remove must be Ok"); }
#[test] fn remove_cursed_armor_sticks() { let mut pack = Pack::new(); pack.add(cursed_ring(1)); pack.add(sword(2)); pack.wear(1).expect("cursed armor wears fine"); assert_eq!(pack.remove(), Err("it sticks to your hands")); assert_eq!(pack.wear(2), Err("it sticks to your hands")); }
#[test] fn drop_returns_instance_and_clears_slots() { let mut pack = Pack::new(); pack.add(sword(1)); pack.wield(1).expect("wield first"); let out = pack.drop(1).expect("clean blade must drop"); assert_eq!(out.id, 1); assert!(!pack.contains(1)); assert!(pack.wielded().is_none(), "slot must not dangle"); }
#[test] fn drop_cursed_sticks() { let mut pack = Pack::new(); pack.add(cursed_ring(1)); assert_eq!(pack.drop(1).map(|_| ()), Err("it sticks to your hands")); assert!(pack.contains(1), "stuck item stays stowed"); }
#[test] fn reach_bonus_polearm_open_only() { let mut pack = Pack::new(); assert_eq!(pack.reach_bonus(false), 0, "empty hands reach nothing"); pack.add(sword(1)); pack.add(pike(2)); pack.wield(1).expect("blade wields"); assert_eq!(pack.reach_bonus(false), 0, "blade gets no shaft bonus"); pack.wield(2).expect("pike wields"); assert_eq!(pack.reach_bonus(false), 2, "polearm rules open shafts"); assert_eq!(pack.reach_bonus(true), 0, "confined polearm is honest +0"); }
#[test] fn silvered_stills_only_while_wielded() { let mut pack = Pack::new(); assert!(!pack.silvered_stills(), "empty hands still nothing"); let mut t = ItemTemplate::new("silver_dagger", "BRASS", "sound"); t.silvered = true; pack.add(spawn(&t, 1).unwrap()); pack.add(sword(2)); assert!(!pack.silvered_stills(), "stowed silver stills nothing"); pack.wield(1).expect("silver wields"); assert!(pack.silvered_stills()); pack.wield(2).expect("blade swaps in"); assert!(!pack.silvered_stills()); }
#[test] fn corpse_and_rot_tags() { let mut pack = Pack::new(); pack.add(sword(1)); pack.add(corpse(2)); assert!(!pack.is_corpse(1)); assert!(pack.is_corpse(2)); assert!(!pack.is_corpse(404), "missing ids are false, not errors"); assert!(!pack.will_rot(1)); assert!(pack.will_rot(2), "corpse carries the perishable tag"); // Condition alone also flags rot (untagged perishable template). let mut lonely = Pack::new(); lonely.add(spawn(&ItemTemplate::new("stew", "FLESH", "perishable"), 3).unwrap()); assert!(lonely.will_rot(3)); assert!(!lonely.will_rot(404)); // Spent lamps do not rot. let mut lamp_pack = Pack::new(); let mut dead = lamp(4); dead.condition = Condition::Spent; lamp_pack.add(dead); assert!(!lamp_pack.will_rot(4)); }}