//! 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, 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 { // Cadence marker only: budget resets each call, no carryover, no clock. let _ = tick; let mut order: Vec = (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 = 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(), ] ); } }