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.
13 kB · 372 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373//! Screenreader event stream (docs/05 §5.5, docs/03 §3.10).//!//! In screenreader mode the renderer bypasses the raycaster and emits a//! **prioritized text event stream**: threat > exit > loot > flavor,//! throttled and interruptible. A blind delver must be able to win from this//! stream alone, so the ordering contract is load-bearing://!//! - [`Stream::drain`] always emits in priority order//! Threat → Exit → Loot → Flavor, regardless of push order (the//! three-glance rule, docs/05 §5.4 law 1, rendered as text: a threat is//! never buried under flavor).//! - Output is budget-capped at [`Stream::budget_per_tick`] events per drain;//! over-budget flavor is **dropped**, never backlogged (throttling by event//! count, not wall-clock — deterministic, no timers).//! - [`Stream::interrupt`] drops queued Loot + Flavor but keeps Threat +//! Exit: a new threat interrupts flavor, never the reverse.//!//! Pure data logic only: no I/O, no time, no RNG. The parent feeds sim events//! into [`Stream::push`] and flushes once per tick via [`Stream::drain`].
/// Default per-tick event budget (docs/05 §5.5 "throttled").pub const DEFAULT_BUDGET_PER_TICK: usize = 8;
/// One screenreader event (docs/05 §5.5).#[derive(Clone, Debug, PartialEq, Eq)]pub enum StreamEvent { /// Danger: monsters, traps, draft-gas warnings. Spoken first, always. Threat(String), /// Exits / vertical connections: stairs, shafts, doors, vents. Exit(String), /// Loot glints: wire, relics, corpses worth hauling. Loot(String), /// Atmosphere: smells, flavor, room dressing. Dropped first under load. Flavor(String),}
impl StreamEvent { /// Priority rank: lower drains first (Threat 0 → Flavor 3). fn priority(&self) -> u8 { match self { StreamEvent::Threat(_) => 0, StreamEvent::Exit(_) => 1, StreamEvent::Loot(_) => 2, StreamEvent::Flavor(_) => 3, } }}
/// Format one event for the screenreader (docs/05 §5.5).////// Threat/Exit/Loot carry a spoken prefix so the listener can triage without/// hearing the whole line; flavor is verbatim (it is already prose).#[must_use]pub fn format_event(ev: &StreamEvent) -> String { match ev { StreamEvent::Threat(s) => format!("THREAT: {s}"), StreamEvent::Exit(s) => format!("EXIT: {s}"), StreamEvent::Loot(s) => format!("LOOT: {s}"), StreamEvent::Flavor(s) => s.clone(), }}
/// Prioritized, throttled, interruptible event queue (docs/05 §5.5).#[derive(Clone, Debug)]pub struct Stream { queue: Vec<StreamEvent>, budget_per_tick: usize,}
impl Stream { /// New stream with the default per-tick budget ([`DEFAULT_BUDGET_PER_TICK`]). pub fn new() -> Self { Self { queue: Vec::new(), budget_per_tick: DEFAULT_BUDGET_PER_TICK, } }
/// New stream with an explicit per-tick budget (for tests and tunables). pub fn with_budget(budget_per_tick: usize) -> Self { Self { queue: Vec::new(), budget_per_tick, } }
/// Enqueue one sim event. Order of pushing does not matter; /// [`Stream::drain`] re-sorts by priority. pub fn push(&mut self, ev: StreamEvent) { self.queue.push(ev); }
/// Drain up to `budget_per_tick` events in Threat → Exit → Loot → Flavor /// order (FIFO within a tier), formatted via [`format_event`]. /// /// The `tick` parameter documents the cadence contract (docs/04 §4.2: /// call once per 10 Hz sim tick); the budget resets on every call and /// `tick` is otherwise unused, honestly: there is no wall-clock here. /// Events beyond budget are **dropped, lowest priority first** — the queue /// is empty after every drain, so a flavor flood can never backlog a /// later threat. pub fn drain(&mut self, tick: u64) -> Vec<String> { // Cadence marker only: budget resets each call, no carryover, no clock. let _ = tick; let mut order: Vec<usize> = (0..self.queue.len()).collect(); // `sort_by_key` is stable: FIFO within a priority tier. order.sort_by_key(|&i| self.queue[i].priority()); let out: Vec<String> = order .into_iter() .take(self.budget_per_tick) .map(|i| format_event(&self.queue[i])) .collect(); self.queue.clear(); out }
/// Interrupt flavor: drop queued Loot + Flavor, keep Threat + Exit. /// /// A new threat interrupts flavor, never the reverse (docs/05 §5.5 /// "interruptible"): the parent calls this when a threat arrives so the /// next drain speaks danger first. pub fn interrupt(&mut self) { self.queue .retain(|ev| matches!(ev, StreamEvent::Threat(_) | StreamEvent::Exit(_))); }
/// Number of events currently queued (pre-drain depth). pub fn len(&self) -> usize { self.queue.len() }
/// True when nothing is queued. pub fn is_empty(&self) -> bool { self.queue.is_empty() }
/// The per-tick budget this stream drains with. pub fn budget(&self) -> usize { self.budget_per_tick }}
impl Default for Stream { fn default() -> Self { Self::new() }}
#[cfg(test)]mod tests { use super::*;
fn demo_room() -> Stream { // Fixed demo-room script, pushed deliberately out of order: // 2 flavor, 1 loot, 1 exit, 1 threat (docs/05 §5.5 winnability). let mut s = Stream::new(); s.push(StreamEvent::Flavor( "Damp air. Water ticks somewhere below.".to_owned(), )); s.push(StreamEvent::Loot( "Copper wire glints by the grate.".to_owned(), )); s.push(StreamEvent::Flavor( "Your lantern gutters in a side draft.".to_owned(), )); s.push(StreamEvent::Exit( "Stone stair climbs north, draft rising.".to_owned(), )); s.push(StreamEvent::Threat( "Cable-rat in the rafters, amber spark building.".to_owned(), )); s }
#[test] fn default_budget_is_8() { assert_eq!(Stream::new().budget(), 8); assert_eq!(DEFAULT_BUDGET_PER_TICK, 8); }
#[test] fn empty_drain_is_empty() { let mut s = Stream::new(); assert!(s.is_empty()); assert!(s.drain(0).is_empty()); }
#[test] fn format_threat_prefix() { assert_eq!( format_event(&StreamEvent::Threat("rat above".to_owned())), "THREAT: rat above" ); }
#[test] fn format_exit_prefix() { assert_eq!( format_event(&StreamEvent::Exit("stair north".to_owned())), "EXIT: stair north" ); }
#[test] fn format_loot_prefix() { assert_eq!( format_event(&StreamEvent::Loot("wire".to_owned())), "LOOT: wire" ); }
#[test] fn format_flavor_verbatim() { let line = "The dark breathes back.".to_owned(); assert_eq!(format_event(&StreamEvent::Flavor(line.clone())), line); }
#[test] fn drain_orders_threat_exit_loot_flavor() { let out = demo_room().drain(1); assert_eq!(out.len(), 5); assert!(out[0].starts_with("THREAT: "), "threat first: {out:?}"); assert!(out[1].starts_with("EXIT: "), "exit second: {out:?}"); assert!(out[2].starts_with("LOOT: "), "loot third: {out:?}"); assert_eq!( out, vec![ "THREAT: Cable-rat in the rafters, amber spark building.".to_owned(), "EXIT: Stone stair climbs north, draft rising.".to_owned(), "LOOT: Copper wire glints by the grate.".to_owned(), "Damp air. Water ticks somewhere below.".to_owned(), "Your lantern gutters in a side draft.".to_owned(), ], "priority order regardless of push order, flavor verbatim" ); }
#[test] fn winnability_demo_room_threat_first_all_within_budget() { // Blind-delver-winnable (docs/05 §5.5): the whole survival-relevant // set fits one tick, threat spoken before anything else. let out = demo_room().drain(7); assert_eq!(out.len(), 5, "all 5 demo events fit the default budget"); assert!(out[0].starts_with("THREAT: ")); assert!(out.iter().any(|l| l.starts_with("EXIT: "))); assert!(out.iter().any(|l| l.starts_with("LOOT: "))); }
#[test] fn over_budget_drops_flavor_first() { let mut s = Stream::new(); for i in 0..12 { s.push(StreamEvent::Flavor(format!("mote drift {i}"))); } s.push(StreamEvent::Threat("bell tolls below".to_owned())); let out = s.drain(2); assert_eq!(out.len(), 8, "drain is capped at exactly budget"); assert!( out.iter().any(|l| l.starts_with("THREAT: ")), "threat survives the cut: {out:?}" ); assert_eq!( out[0], "THREAT: bell tolls below", "threat jumps the flavor queue" ); }
#[test] fn over_budget_keeps_threat_exit_loot() { let mut s = Stream::with_budget(4); for i in 0..10 { s.push(StreamEvent::Flavor(format!("dust {i}"))); } s.push(StreamEvent::Loot("phial".to_owned())); s.push(StreamEvent::Exit("door east".to_owned())); s.push(StreamEvent::Threat("rust saint wakes".to_owned())); let out = s.drain(3); assert_eq!(out.len(), 4); assert!(out[0].starts_with("THREAT: ")); assert!(out[1].starts_with("EXIT: ")); assert!(out[2].starts_with("LOOT: ")); }
#[test] fn interrupt_keeps_threat_and_exit_only() { let mut s = Stream::new(); s.push(StreamEvent::Flavor("moss glows softly".to_owned())); s.push(StreamEvent::Threat("rat king stirs".to_owned())); s.push(StreamEvent::Loot("wire".to_owned())); s.push(StreamEvent::Exit("hatch above".to_owned())); s.interrupt(); assert_eq!(s.len(), 2); let out = s.drain(4); assert_eq!(out.len(), 2); assert!(out[0].starts_with("THREAT: ")); assert!(out[1].starts_with("EXIT: ")); }
#[test] fn interrupt_on_empty_stays_empty() { let mut s = Stream::new(); s.interrupt(); assert!(s.is_empty()); assert!(s.drain(0).is_empty()); }
#[test] fn drain_empties_queue_so_flavor_cannot_backlog() { let mut s = Stream::with_budget(2); s.push(StreamEvent::Flavor("a".to_owned())); s.push(StreamEvent::Flavor("b".to_owned())); s.push(StreamEvent::Flavor("c".to_owned())); let out = s.drain(0); assert_eq!(out.len(), 2); assert!(s.is_empty(), "over-budget remainder is dropped, not queued"); }
#[test] fn fifo_within_every_priority_tier() { let mut s = Stream::new(); s.push(StreamEvent::Flavor("flavor 1".to_owned())); s.push(StreamEvent::Threat("threat 1".to_owned())); s.push(StreamEvent::Loot("loot 1".to_owned())); s.push(StreamEvent::Exit("exit 1".to_owned())); s.push(StreamEvent::Flavor("flavor 2".to_owned())); s.push(StreamEvent::Exit("exit 2".to_owned())); s.push(StreamEvent::Threat("threat 2".to_owned())); s.push(StreamEvent::Loot("loot 2".to_owned())); assert_eq!( s.drain(0), vec![ "THREAT: threat 1".to_owned(), "THREAT: threat 2".to_owned(), "EXIT: exit 1".to_owned(), "EXIT: exit 2".to_owned(), "LOOT: loot 1".to_owned(), "LOOT: loot 2".to_owned(), "flavor 1".to_owned(), "flavor 2".to_owned(), ] ); }
#[test] fn zero_budget_drops_everything_without_backlog() { let mut s = Stream::with_budget(0); s.push(StreamEvent::Threat("danger".to_owned())); s.push(StreamEvent::Exit("stairs".to_owned())); assert!(s.drain(0).is_empty()); assert!(s.is_empty()); }
#[test] fn interrupt_preserves_fifo_for_survival_tiers() { let mut s = Stream::new(); s.push(StreamEvent::Exit("first exit".to_owned())); s.push(StreamEvent::Threat("first threat".to_owned())); s.push(StreamEvent::Loot("discard".to_owned())); s.push(StreamEvent::Threat("second threat".to_owned())); s.push(StreamEvent::Exit("second exit".to_owned())); s.interrupt(); assert_eq!( s.drain(0), vec![ "THREAT: first threat".to_owned(), "THREAT: second threat".to_owned(), "EXIT: first exit".to_owned(), "EXIT: second exit".to_owned(), ] ); }}