//! 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 { 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, /// Physically held key direction (terminal key-repeat), cleared on release. hold: Option, /// Locked toggle-walk direction; survives without a release event. walk: Option, /// Last movement direction seen, used as the toggle-walk default. last: Option, } 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 { 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) -> 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>, 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> { 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"); } }