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.
17 kB · 428 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429//! Action-paced locomotion driver over the tested core [`Scheduler`].//!//! The core scheduler (`hexwork_core::pacing`) owns *only* pacing: accumulator,//! permits, overload/pause bookkeeping. This module owns the *frontend* half of//! Action-paced locomotion: the player's movement intent (a physically held key//! or a locked toggle-walk), the compass heading that intent resolves to, and//! the mapping between a permit's heading and a [`Direction`] the session can//! admit.//!//! The driver never reads a clock. The caller supplies monotonic nanoseconds via//! [`LocomotionPacer::observe`], which makes the whole loop fake-clock testable.//!//! Scope guard: this is Action-paced locomotion only. It does not implement the//! pending-action/timed-action migration and does not implement full tactical or//! continuous behaviour. Tactical/continuous policy labels exist only so the UI//! can name a reserved state.
use hexwork_core::action::Direction;use hexwork_core::heading::Heading;use hexwork_core::pacing::{Error as PacingError, Pause, Policy, Scheduler, Settlement, Snapshot, TickPermit, TICK_NS};
/// Canonical world heading for a compass direction (north is -Y, east +X).////// This is the frontend half of the compass→heading contract already used by/// `GameSession::move_player`; it is duplicated here so the pacer carries no/// dependency on session internals.pub fn heading_for(direction: &Direction) -> Heading { match direction { Direction::North => Heading::NORTH, Direction::NorthEast => Heading::new(512).expect("bounded NE heading"), Direction::East => Heading::EAST, Direction::SouthEast => Heading::new(1536).expect("bounded SE heading"), Direction::South => Heading::SOUTH, Direction::SouthWest => Heading::new(2560).expect("bounded SW heading"), Direction::West => Heading::WEST, Direction::NorthWest => Heading::new(3584).expect("bounded NW heading"), }}
/// Inverse of [`heading_for`] for the eight canonical compass bins.////// Off-compass headings (fractional samples) map to `None`; the accepted sample/// still spends its tick, but carries no admissable compass direction.pub fn direction_for_heading(heading: Heading) -> Option<Direction> { Some(match heading.bin() { 0 => Direction::North, 512 => Direction::NorthEast, 1024 => Direction::East, 1536 => Direction::SouthEast, 2048 => Direction::South, 2560 => Direction::SouthWest, 3072 => Direction::West, 3584 => Direction::NorthWest, _ => return None, })}
/// Visible pacing state for the shared composed frame.#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum PacePhase { /// No authorized simulation work and no movement intent: the world waits. Frozen, /// An action is pacing; `n` whole ticks of debt remain outstanding. Acting(u8), /// Real-time continuous ticking (reserved; not produced by Action-paced play). Live, /// Explicit system pause (signal/controller). Requires an explicit resume. SystemPause, /// Tactical pause (reserved label; stepping bypasses wall-clock debt). TacticalPause, /// A wall-clock delta exceeded the 500 ms accumulator cap. Requires an /// explicit resume. OverloadPause,}
impl PacePhase { pub fn label(self) -> String { match self { PacePhase::Frozen => "FROZEN".to_owned(), PacePhase::Acting(ticks) => format!("ACTING {ticks} ticks"), PacePhase::Live => "LIVE".to_owned(), PacePhase::SystemPause => "SYSTEM PAUSE".to_owned(), PacePhase::TacticalPause => "TACTICAL PAUSE".to_owned(), PacePhase::OverloadPause => "OVERLOAD PAUSE".to_owned(), } }}
/// Action-paced locomotion intent and its pacing scheduler.#[derive(Clone, Debug)]pub struct LocomotionPacer { scheduler: Scheduler<Heading>, /// Physically held key direction (terminal key-repeat), cleared on release. hold: Option<Direction>, /// Locked toggle-walk direction; survives without a release event. walk: Option<Direction>, /// Last movement direction seen, used as the toggle-walk default. last: Option<Direction>,}
impl LocomotionPacer { pub fn new(now_ns: u64) -> Self { Self { scheduler: Scheduler::new(now_ns, Settlement::IDLE), hold: None, walk: None, last: None, } }
pub fn snapshot(&self) -> Snapshot { self.scheduler.snapshot() }
pub fn policy(&self) -> Policy { self.scheduler.snapshot().policy }
pub fn set_policy(&mut self, policy: Policy) -> Result<(), PacingError> { self.scheduler.set_policy(policy) }
/// Total elapsed nanoseconds discarded without admission (pause + overload). pub fn discarded_ns(&self) -> u128 { self.scheduler.snapshot().discarded_ns }
/// Whether an explicit resume is required before time is admitted again. pub fn requires_resume(&self) -> bool { matches!( self.scheduler.snapshot().pause, Pause::System | Pause::OverloadPause ) }
pub fn walk_active(&self) -> bool { self.walk.is_some() }
/// Current effective intent: a physical hold takes precedence over walking. fn intent(&self) -> Option<Direction> { self.hold.clone().or_else(|| self.walk.clone()) }
fn sync_heading(&mut self) -> Result<(), PacingError> { let heading = self.intent().map(|direction| heading_for(&direction)); self.scheduler.set_heading(heading) }
/// A held movement key repeat starts or continues intent; release clears it. /// A `Some` while paused is rejected by the scheduler, never silently held. pub fn set_hold(&mut self, direction: Option<Direction>) -> Result<(), PacingError> { if direction.is_some() { self.last = direction.clone(); } self.hold = direction; self.sync_heading() }
/// Toggle continuous travel. A second toggle stops it; without a current /// hold it walks in the last direction seen. pub fn toggle_walk(&mut self) -> Result<(), PacingError> { if self.walk.is_some() { self.walk = None; } else { let direction = self.hold.clone().or_else(|| self.last.clone()); if direction.is_some() { self.last = direction.clone(); } self.walk = direction; } self.sync_heading() }
pub fn stop_walk(&mut self) -> Result<(), PacingError> { self.walk = None; self.sync_heading() }
/// Clear every unadmitted intent (hold and walk). An accepted sample, if /// any, is retained by the scheduler and still spends its tick. pub fn clear_intent(&mut self) -> Result<(), PacingError> { self.hold = None; self.walk = None; self.sync_heading() }
/// Remember the last movement direction for toggle-walk without starting a /// hold. A discrete tap therefore still registers as the walk default. pub fn note_direction(&mut self, direction: &Direction) { self.last = Some(direction.clone()); }
/// Admit elapsed time. `now_ns` must be monotonic and non-decreasing. pub fn observe(&mut self, now_ns: u64) -> Result<(), PacingError> { self.scheduler.observe(now_ns) }
/// Explicit resume after a system or overload pause. pub fn resume(&mut self, now_ns: u64) -> Result<(), PacingError> { self.scheduler.resume(now_ns) }
/// Enter a visible system pause. Accepted movement and admitted time remain /// owed; new intent is refused until an explicit [`Self::resume`]. pub fn system_pause(&mut self) { self.hold = None; self.scheduler.system_pause(); }
/// Take the next authorized tick permit, if any. pub fn next_permit(&mut self) -> Result<Option<TickPermit<Heading>>, PacingError> { self.scheduler.next_tick() }
/// Report the settlement of the tick just executed. pub fn complete(&mut self, settlement: Settlement) -> Result<(), PacingError> { self.scheduler.complete_tick(settlement) }
/// Report the authoritative busy settlement without executing a tick. /// /// A held or blocked move, an occupied pending action, or an unsettled fall /// keeps Action-paced cadence running; only a grounded, idle, empty world /// settles. Callers invoke this after admitting or completing a pending /// action (e.g. at a worker-iteration boundary). pub fn set_settlement(&mut self, settlement: Settlement) -> Result<(), PacingError> { self.scheduler.update_settlement(settlement) }
/// Visible state for the shared composed frame. pub fn phase(&self) -> PacePhase { let snapshot = self.scheduler.snapshot(); match snapshot.pause { Pause::OverloadPause => return PacePhase::OverloadPause, Pause::System => return PacePhase::SystemPause, Pause::Tactical => return PacePhase::TacticalPause, Pause::Running => {} } if snapshot.policy != Policy::ActionPaced { return PacePhase::Live; } let owed = snapshot.accumulator_ns / TICK_NS + u64::from(snapshot.accepted_movement) + u64::from(snapshot.tick_in_flight); if self.hold.is_some() || self.walk.is_some() || owed > 0 { PacePhase::Acting(owed.max(1).min(u64::from(u8::MAX)) as u8) } else { PacePhase::Frozen } }}
#[cfg(test)]mod tests { use super::*;
/// Drain every currently authorized permit, recording movement headings. fn drain(pacer: &mut LocomotionPacer) -> Vec<Option<Heading>> { let mut emitted = Vec::new(); while let Some(permit) = pacer.next_permit().unwrap() { emitted.push(permit.movement); pacer.complete(Settlement::IDLE).unwrap(); } emitted }
/// Drive 100 × 10 ms frames (one simulated second) and count moves/idle. fn run_second(pacer: &mut LocomotionPacer, base_ns: u64) -> (u64, u64) { let (mut moves, mut idle) = (0, 0); for frame in 1..=100u64 { let now = base_ns + frame * 10_000_000; pacer.observe(now).unwrap(); for movement in drain(pacer) { if movement.is_some() { moves += 1; } else { idle += 1; } } } (moves, idle) }
#[test] fn held_movement_emits_ten_moves_per_simulated_second() { let mut pacer = LocomotionPacer::new(0); pacer.set_hold(Some(Direction::East)).unwrap(); let mut moves = 0; for frame in 1..=100u64 { let now = frame * 10_000_000; pacer.observe(now).unwrap(); for movement in drain(&mut pacer) { assert_eq!(movement, Some(Heading::EAST)); moves += 1; } } assert_eq!(moves, 10, "held movement is exactly one Move per 100 ms tick"); }
#[test] fn idle_with_no_input_emits_no_ticks() { let mut pacer = LocomotionPacer::new(0); let (moves, idle) = run_second(&mut pacer, 0); assert_eq!((moves, idle), (0, 0)); assert_eq!(pacer.phase(), PacePhase::Frozen); }
#[test] fn release_mid_stream_stops_new_samples() { let mut pacer = LocomotionPacer::new(0); pacer.set_hold(Some(Direction::South)).unwrap(); let mut moves = 0; for frame in 1..=50u64 { pacer.observe(frame * 10_000_000).unwrap(); for movement in drain(&mut pacer) { assert_eq!(movement, Some(Heading::SOUTH)); moves += 1; } } assert_eq!(moves, 5, "five ticks in the first half-second"); pacer.set_hold(None).unwrap(); moves += drain(&mut pacer).iter().filter(|m| m.is_some()).count() as u64; for frame in 51..=100u64 { pacer.observe(frame * 10_000_000).unwrap(); moves += drain(&mut pacer).iter().filter(|m| m.is_some()).count() as u64; } assert_eq!(moves, 5, "release stops new samples; no retroactive backlog"); assert_eq!(pacer.phase(), PacePhase::Frozen); }
#[test] fn toggle_walk_runs_without_release_and_second_toggle_stops() { let mut pacer = LocomotionPacer::new(0); pacer.toggle_walk().unwrap(); assert!(!pacer.walk_active(), "no direction seen yet: toggle is a no-op"); pacer.set_hold(Some(Direction::West)).unwrap(); pacer.set_hold(None).unwrap(); pacer.toggle_walk().unwrap(); assert!(pacer.walk_active()); let (moves, _) = run_second(&mut pacer, 0); assert_eq!(moves, 10, "walking needs no key release"); pacer.toggle_walk().unwrap(); assert!(!pacer.walk_active()); let (moves_after, _) = run_second(&mut pacer, 1_000_000_000); assert_eq!(moves_after, 0, "second toggle stops new samples"); }
#[test] fn overload_pause_retains_debt_and_needs_explicit_resume() { let mut pacer = LocomotionPacer::new(0); pacer.set_hold(Some(Direction::North)).unwrap(); pacer.observe(TICK_NS).unwrap(); pacer.observe(7 * TICK_NS).unwrap(); assert_eq!(pacer.phase(), PacePhase::OverloadPause); assert!(pacer.requires_resume()); let snapshot = pacer.snapshot(); assert_eq!(snapshot.accumulator_ns, TICK_NS, "the ≤500 ms accumulator is retained"); assert_eq!(snapshot.discarded_ns, 6 * TICK_NS as u128); assert!(snapshot.accepted_movement, "the accepted sample is retained"); assert!(snapshot.pause == Pause::OverloadPause); assert!(drain(&mut pacer).is_empty(), "no ticks while overloaded"); pacer.observe(9 * TICK_NS).unwrap(); assert!(drain(&mut pacer).is_empty()); pacer.resume(10 * TICK_NS).unwrap(); pacer.observe(10 * TICK_NS).unwrap(); assert_eq!(drain(&mut pacer), vec![Some(Heading::NORTH)]); assert_eq!(pacer.discarded_ns(), 9 * TICK_NS as u128); }
#[test] fn blocked_held_movement_still_spends_every_tick() { // "Blocked" is a session concern: the pacer only sees a settled // settlement. A held intent must therefore keep authorizing ticks at the // full 100 ms cadence whether or not the cell changes. let mut pacer = LocomotionPacer::new(0); pacer.set_hold(Some(Direction::East)).unwrap(); let mut ticks = 0; for frame in 1..=100u64 { pacer.observe(frame * 10_000_000).unwrap(); ticks += drain(&mut pacer).len() as u64; } assert_eq!(ticks, 10, "blocked movement spends exactly the same ticks"); }
#[test] fn fractional_remainder_does_not_force_extra_delay() { // 30 Hz frames: a sample created on a 33 ms boundary must still land on // the following 100 ms tick, not one tick later. let mut pacer = LocomotionPacer::new(0); pacer.set_hold(Some(Direction::NorthWest)).unwrap(); let mut moves = 0; for frame in 1..=90u64 { let now = frame * 1_000_000_000 / 30; pacer.observe(now).unwrap(); moves += drain(&mut pacer).iter().filter(|m| m.is_some()).count() as u64; } assert_eq!(moves, 30, "three seconds at 100 ms cadence"); }
#[test] fn heading_and_direction_round_trip() { for direction in [ Direction::North, Direction::NorthEast, Direction::East, Direction::SouthEast, Direction::South, Direction::SouthWest, Direction::West, Direction::NorthWest, ] { let heading = heading_for(&direction); assert_eq!(direction_for_heading(heading).as_ref(), Some(&direction)); } assert_eq!(direction_for_heading(Heading::new(1).unwrap()), None); }
#[test] fn phase_labels_cover_reserved_states() { assert_eq!(PacePhase::Frozen.label(), "FROZEN"); assert_eq!(PacePhase::Acting(3).label(), "ACTING 3 ticks"); assert_eq!(PacePhase::Live.label(), "LIVE"); assert_eq!(PacePhase::SystemPause.label(), "SYSTEM PAUSE"); assert_eq!(PacePhase::TacticalPause.label(), "TACTICAL PAUSE"); assert_eq!(PacePhase::OverloadPause.label(), "OVERLOAD PAUSE"); }}