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.
23 kB · 634 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635//! Pure pacing only: no clock reads, simulation, input-device, or death policy.//!//! The worker supplies monotonic nanoseconds, calls `observe` once per iteration,//! and executes each `next_tick` permit before calling `complete_tick`. Heading//! values must already be resolved into world space. Death/save/session handling//! belongs to the caller. Never execute a permit more than once.
pub const TICK_NS: u64 = 100_000_000;pub const MAX_TICKS_PER_ITERATION: u8 = 5;pub const MAX_ACCUMULATOR_NS: u64 = TICK_NS * MAX_TICKS_PER_ITERATION as u64;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]pub enum Policy { #[default] ActionPaced, RealTimeTactical, RealTimeContinuous,}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum Pause { Running, Tactical, System, OverloadPause,}
/// Authoritative state reported by the simulation, not inferred by pacing.#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub struct Settlement { pub grounded: bool, pub pending_action: bool, pub pending_fall: bool,}
impl Settlement { pub const IDLE: Self = Self { grounded: true, pending_action: false, pending_fall: false, };
pub fn is_settled(self) -> bool { self.grounded && !self.pending_action && !self.pending_fall }}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum Error { ClockWentBackwards, TickInFlight, NoTickInFlight, NotInteractive, NotSettled, WrongPolicy,}
/// `None` advances time without beginning a new movement action.#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub struct TickPermit<H> { pub movement: Option<H>,}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum Step<H> { Wait, CompleteAction(H), /// Continue an arbitrary action already admitted by the caller. PendingAction,}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub struct Snapshot { pub policy: Policy, pub pause: Pause, pub tactical_pause_requested: bool, pub accumulator_ns: u64, /// Unadmitted overload delta and elapsed time observed while paused. pub discarded_ns: u128, pub accepted_movement: bool, pub tick_in_flight: bool,}
#[derive(Clone, Debug)]pub struct Scheduler<H> { policy: Policy, pause: Pause, baseline_ns: u64, accumulator_ns: u64, discarded_ns: u128, remaining: u8, settlement: Settlement, held: Option<H>, // Whole overdue ticks plus the next fresh tick. A fractional remainder // belongs to that fresh tick and must not force an extra tick of delay. accepted: Option<(H, u64)>, in_flight: bool, tactical_requested: bool, step: Option<Step<H>>, step_started: bool,}
impl<H: Copy> Scheduler<H> { pub fn new(now_ns: u64, settlement: Settlement) -> Self { Self { policy: Policy::default(), pause: Pause::Running, baseline_ns: now_ns, accumulator_ns: 0, discarded_ns: 0, remaining: 0, settlement, held: None, accepted: None, in_flight: false, tactical_requested: false, step: None, step_started: false, } }
pub fn snapshot(&self) -> Snapshot { Snapshot { policy: self.policy, pause: self.pause, tactical_pause_requested: self.tactical_requested, accumulator_ns: self.accumulator_ns, discarded_ns: self.discarded_ns, accepted_movement: self.accepted.is_some(), tick_in_flight: self.in_flight, } }
fn settled(&self) -> bool { self.settlement.is_settled() && self.accepted.is_none() && !self.in_flight }
fn check_time(&self, now_ns: u64) -> Result<u64, Error> { now_ns .checked_sub(self.baseline_ns) .ok_or(Error::ClockWentBackwards) }
/// Admit time before processing this iteration's input. The entire delta is /// rejected on overload, rather than clamped or partially admitted. pub fn observe(&mut self, now_ns: u64) -> Result<(), Error> { let delta = self.check_time(now_ns)?; if self.in_flight { return Err(Error::TickInFlight); } self.baseline_ns = now_ns; self.remaining = MAX_TICKS_PER_ITERATION; if self.pause != Pause::Running { self.discarded_ns = self.discarded_ns.saturating_add(delta as u128); return Ok(()); } let active = self.policy != Policy::ActionPaced || !self.settled() || self.held.is_some(); if active { if delta > MAX_ACCUMULATOR_NS - self.accumulator_ns { self.discarded_ns = self.discarded_ns.saturating_add(delta as u128); self.pause = Pause::OverloadPause; self.held = None; self.step = None; return Ok(()); } self.sample_held(); self.accumulator_ns += delta; } Ok(()) }
fn sample_held(&mut self) { if self.settled() && !self.tactical_requested { if let Some(heading) = self.held { self.accepted = Some((heading, (self.accumulator_ns / TICK_NS + 1) * TICK_NS)); } } }
/// Call after `observe(now)` for a press/change. Release is also valid while /// paused and clears only unadmitted intent, never an accepted sample. /// Repeated calls do not replenish the iteration's tick budget. pub fn set_heading(&mut self, heading: Option<H>) -> Result<(), Error> { if heading.is_some() && (self.pause != Pause::Running || self.tactical_requested) { return Err(Error::NotInteractive); } self.held = heading; if self.pause == Pause::Running { self.sample_held(); } Ok(()) }
/// At most five permits between observations, with one permit in flight. pub fn next_tick(&mut self) -> Result<Option<TickPermit<H>>, Error> { if self.in_flight { return Err(Error::TickInFlight); } if self.remaining == 0 { return Ok(None); } let movement; if let Some(step) = self.step { if self.pause != Pause::Tactical { return Ok(None); } movement = if self.step_started { None } else { match step { Step::Wait | Step::PendingAction => None, Step::CompleteAction(h) => Some(h), } }; self.step_started = true; } else { if self.pause != Pause::Running || self.accumulator_ns < TICK_NS { return Ok(None); } self.accumulator_ns -= TICK_NS; movement = match self.accepted { Some((h, delay)) if delay <= TICK_NS && self.settlement.is_settled() => { self.accepted = None; Some(h) } Some((h, delay)) => { self.accepted = Some((h, delay.saturating_sub(TICK_NS))); None } None => None, }; } self.remaining -= 1; self.in_flight = true; Ok(Some(TickPermit { movement })) }
pub fn complete_tick(&mut self, settlement: Settlement) -> Result<(), Error> { if !self.in_flight { return Err(Error::NoTickInFlight); } self.in_flight = false; self.settlement = settlement; if matches!(self.step, Some(Step::Wait)) || self.settled() { self.step = None; } if self.pause == Pause::Running && self.tactical_requested && self.settled() { self.pause = Pause::Tactical; self.tactical_requested = false; } Ok(()) }
pub fn request_tactical_pause(&mut self) -> Result<(), Error> { if self.policy != Policy::RealTimeTactical { return Err(Error::WrongPolicy); } if self.pause != Pause::Running { return Err(Error::NotInteractive); } self.held = None; self.tactical_requested = true; if self.settled() { self.pause = Pause::Tactical; self.tactical_requested = false; } Ok(()) }
/// Call after observing the pause event's timestamp. Accepted movement and /// all previously admitted time remain owed, even in a system pause. pub fn system_pause(&mut self) { self.pause = Pause::System; self.held = None; self.step = None; }
/// Explicitly resume, accounting for but never admitting paused elapsed time. pub fn resume(&mut self, now_ns: u64) -> Result<(), Error> { let delta = self.check_time(now_ns)?; if self.in_flight { return Err(Error::TickInFlight); } if self.pause == Pause::Running { return Err(Error::NotInteractive); } self.discarded_ns = self.discarded_ns.saturating_add(delta as u128); self.baseline_ns = now_ns; self.pause = Pause::Running; self.tactical_requested = false; self.step = None; self.remaining = 0; // Next worker iteration must observe the fresh baseline. Ok(()) }
/// Tactical stepping bypasses wall-clock debt: one wait tick, or enough /// ticks to settle one supplied movement action. Long actions continue over /// iterations; each observation still permits no more than five ticks. pub fn begin_step(&mut self, step: Step<H>) -> Result<(), Error> { if self.pause != Pause::Tactical || self.step.is_some() { return Err(Error::NotInteractive); } if !self.settled() { return Err(Error::NotSettled); } if matches!(step, Step::PendingAction) { return Err(Error::NotSettled); // Use begin_action_step with busy state. } self.step = Some(step); self.step_started = false; Ok(()) }
/// Report caller-side action admission or settlement without fabricating a /// tick. Call after observing the event timestamp. Busy state blocks new /// movement, including accepted movement (which is retained until settled). /// This is not permission to replace an action: caller validates admission. pub fn update_settlement(&mut self, settlement: Settlement) -> Result<(), Error> { if self.in_flight { return Err(Error::TickInFlight); } if matches!(self.pause, Pause::System | Pause::OverloadPause) { return Err(Error::NotInteractive); } self.settlement = settlement; if self.settled() { self.step = None; if self.tactical_requested { self.pause = Pause::Tactical; self.tactical_requested = false; } } Ok(()) }
/// Begin tactical completion of an arbitrary caller-admitted action. The /// caller supplies its resulting busy settlement; permits carry no heading. /// Call only after validating action admission, before advancing simulation. pub fn begin_action_step(&mut self, settlement: Settlement) -> Result<(), Error> { if self.pause != Pause::Tactical || self.step.is_some() { return Err(Error::NotInteractive); } if !self.settled() || settlement.is_settled() { return Err(Error::NotSettled); } self.settlement = settlement; self.step = Some(Step::PendingAction); self.step_started = false; Ok(()) }
/// No held input, accepted sample, simulation debt, or admitted time may /// cross a policy boundary. Only running or tactical-paused callers may switch. pub fn set_policy(&mut self, policy: Policy) -> Result<(), Error> { if matches!(self.pause, Pause::System | Pause::OverloadPause) { return Err(Error::NotInteractive); } if !self.settled() || self.held.is_some() || self.accumulator_ns != 0 || self.step.is_some() || self.tactical_requested { return Err(Error::NotSettled); } self.policy = policy; if self.pause == Pause::Tactical { self.pause = Pause::System; // Switching never implicitly resumes time. } Ok(()) }}
#[cfg(test)]mod tests { use super::*; const FALL: Settlement = Settlement { grounded: false, pending_action: false, pending_fall: true, };
fn drain(s: &mut Scheduler<u8>, state: Settlement) -> Vec<Option<u8>> { let mut result = Vec::new(); while let Some(p) = s.next_tick().unwrap() { result.push(p.movement); s.complete_tick(state).unwrap(); } result }
#[test] fn pacing_fake_clock_policy_matrix() { // Policy-only coverage: death/session integration is external. for policy in [ Policy::ActionPaced, Policy::RealTimeTactical, Policy::RealTimeContinuous, ] { let mut s = Scheduler::<u8>::new(0, Settlement::IDLE); s.set_policy(policy).unwrap(); s.observe(TICK_NS).unwrap(); assert_eq!( drain(&mut s, Settlement::IDLE).len(), if policy == Policy::ActionPaced { 0 } else { 1 } ); s.set_heading(Some(3)).unwrap(); s.set_heading(None).unwrap(); s.observe(2 * TICK_NS).unwrap(); assert_eq!(drain(&mut s, FALL), vec![Some(3)]); s.observe(3 * TICK_NS).unwrap(); assert_eq!(drain(&mut s, Settlement::IDLE), vec![None]); } }
#[test] fn scheduler_stall_saturation_release_order() { let mut s = Scheduler::new(0, Settlement::IDLE); s.set_policy(Policy::RealTimeContinuous).unwrap(); s.observe(MAX_ACCUMULATOR_NS).unwrap(); s.set_heading(Some(4)).unwrap(); s.set_heading(Some(5)).unwrap(); // Cannot replace accepted world heading. s.set_heading(None).unwrap(); assert_eq!(drain(&mut s, Settlement::IDLE), vec![None; 5]); s.observe(MAX_ACCUMULATOR_NS + TICK_NS).unwrap(); assert_eq!(drain(&mut s, Settlement::IDLE), vec![Some(4)]); s.observe(MAX_ACCUMULATOR_NS + 2 * TICK_NS).unwrap(); assert_eq!(drain(&mut s, Settlement::IDLE), vec![None]); }
#[test] fn tick_overload_retains_debt() { let mut s = Scheduler::new(0, Settlement::IDLE); s.set_heading(Some(2)).unwrap(); s.observe(TICK_NS).unwrap(); s.observe(7 * TICK_NS).unwrap(); let snap = s.snapshot(); assert_eq!(snap.pause, Pause::OverloadPause); assert_eq!(snap.accumulator_ns, TICK_NS); assert_eq!(snap.discarded_ns, 6 * TICK_NS as u128); assert!(snap.accepted_movement); s.set_heading(None).unwrap(); assert!(drain(&mut s, Settlement::IDLE).is_empty()); s.observe(9 * TICK_NS).unwrap(); s.resume(10 * TICK_NS).unwrap(); s.observe(10 * TICK_NS).unwrap(); assert_eq!(drain(&mut s, FALL), vec![Some(2)]); assert_eq!(s.snapshot().discarded_ns, 9 * TICK_NS as u128); s.observe(11 * TICK_NS).unwrap(); assert_eq!(drain(&mut s, Settlement::IDLE), vec![None]); }
#[test] fn policy_switch_boundaries() { let mut s = Scheduler::new(0, FALL); assert_eq!( s.set_policy(Policy::RealTimeContinuous), Err(Error::NotSettled) ); s.observe(TICK_NS).unwrap(); drain(&mut s, Settlement::IDLE); s.set_policy(Policy::RealTimeTactical).unwrap(); s.set_heading(Some(1)).unwrap(); s.set_heading(None).unwrap(); assert_eq!(s.set_policy(Policy::ActionPaced), Err(Error::NotSettled)); s.request_tactical_pause().unwrap(); assert_eq!(s.snapshot().pause, Pause::Running); s.observe(2 * TICK_NS).unwrap(); drain(&mut s, FALL); s.observe(3 * TICK_NS).unwrap(); drain(&mut s, Settlement::IDLE); assert_eq!(s.snapshot().pause, Pause::Tactical); s.set_policy(Policy::ActionPaced).unwrap(); assert_eq!(s.snapshot().pause, Pause::System); }
#[test] fn steps_and_system_pause_are_explicit() { let mut s = Scheduler::<u8>::new(0, Settlement::IDLE); s.set_policy(Policy::RealTimeTactical).unwrap(); s.request_tactical_pause().unwrap(); s.observe(TICK_NS).unwrap(); s.begin_step(Step::Wait).unwrap(); assert_eq!(drain(&mut s, Settlement::IDLE), vec![None]); s.begin_step(Step::CompleteAction(6)).unwrap(); assert_eq!(s.next_tick().unwrap().unwrap().movement, Some(6)); assert_eq!(s.next_tick(), Err(Error::TickInFlight)); s.complete_tick(FALL).unwrap(); assert_eq!(drain(&mut s, Settlement::IDLE), vec![None]); s.resume(2 * TICK_NS).unwrap(); s.set_heading(Some(2)).unwrap(); s.observe(3 * TICK_NS).unwrap(); s.system_pause(); assert_eq!(s.set_heading(Some(1)), Err(Error::NotInteractive)); assert!(drain(&mut s, Settlement::IDLE).is_empty()); s.resume(100 * TICK_NS).unwrap(); s.observe(100 * TICK_NS).unwrap(); assert_eq!(drain(&mut s, Settlement::IDLE), vec![Some(2)]); }
#[test] fn system_pause_wins_over_deferred_tactical_pause() { let mut s = Scheduler::new(0, Settlement::IDLE); s.set_policy(Policy::RealTimeTactical).unwrap(); s.set_heading(Some(1)).unwrap(); s.observe(TICK_NS).unwrap(); s.next_tick().unwrap().unwrap(); s.request_tactical_pause().unwrap(); s.system_pause(); s.complete_tick(Settlement::IDLE).unwrap(); assert_eq!(s.snapshot().pause, Pause::System); assert_eq!(s.begin_step(Step::Wait), Err(Error::NotInteractive)); }
#[test] fn held_cadence_at_30_60_144_hz() { for hz in [30_u64, 60, 144] { for policy in [ Policy::ActionPaced, Policy::RealTimeTactical, Policy::RealTimeContinuous, ] { let mut s = Scheduler::new(0, Settlement::IDLE); s.set_policy(policy).unwrap(); s.set_heading(Some(7)).unwrap(); let mut emitted = Vec::new(); for frame in 1..=hz * 3 { let now = frame * 1_000_000_000 / hz; s.observe(now).unwrap(); for movement in drain(&mut s, Settlement::IDLE) { assert_eq!(movement, Some(7), "hz={hz} policy={policy:?}"); emitted.push(now); } } assert_eq!(emitted.len(), 30, "hz={hz} policy={policy:?}"); for (i, now) in emitted.into_iter().enumerate() { let deadline = (i as u64 + 1) * TICK_NS; assert!(now >= deadline && now - deadline <= 1_000_000_000 / hz + 1); } } } }
#[test] fn fractional_backlog_skips_only_whole_overdue_ticks() { let mut s = Scheduler::new(0, Settlement::IDLE); s.set_policy(Policy::RealTimeContinuous).unwrap(); s.observe(216_000_000).unwrap(); s.set_heading(Some(8)).unwrap(); s.set_heading(None).unwrap(); assert_eq!(drain(&mut s, Settlement::IDLE), vec![None, None]); s.observe(300_000_000).unwrap(); assert_eq!(drain(&mut s, Settlement::IDLE), vec![Some(8)]); }
#[test] fn caller_action_admission_and_busy_movement_retention() { let busy = Settlement { pending_action: true, ..Settlement::IDLE }; let mut s = Scheduler::new(0, Settlement::IDLE); s.update_settlement(busy).unwrap(); // No fabricated tick needed. s.set_heading(Some(9)).unwrap(); s.observe(TICK_NS).unwrap(); assert_eq!(drain(&mut s, busy), vec![None]); assert!(!s.snapshot().accepted_movement); s.update_settlement(Settlement::IDLE).unwrap(); s.observe(2 * TICK_NS).unwrap(); // A held sample now exists, but a caller-side action can make us busy. assert!(s.snapshot().accepted_movement); s.update_settlement(busy).unwrap(); s.set_heading(None).unwrap(); assert_eq!(drain(&mut s, busy), vec![None]); s.observe(3 * TICK_NS).unwrap(); assert_eq!(drain(&mut s, busy), vec![None]); assert!(s.snapshot().accepted_movement); s.update_settlement(Settlement::IDLE).unwrap(); s.observe(4 * TICK_NS).unwrap(); assert_eq!(drain(&mut s, Settlement::IDLE), vec![Some(9)]); }
#[test] fn tactical_generic_action_completes_without_heading() { let busy = Settlement { pending_action: true, ..Settlement::IDLE }; let mut s = Scheduler::<u8>::new(0, Settlement::IDLE); s.set_policy(Policy::RealTimeTactical).unwrap(); s.request_tactical_pause().unwrap(); s.begin_action_step(busy).unwrap(); s.observe(TICK_NS).unwrap(); assert_eq!(drain(&mut s, busy), vec![None; 5]); s.observe(2 * TICK_NS).unwrap(); assert_eq!(drain(&mut s, Settlement::IDLE), vec![None]); assert_eq!(s.snapshot().pause, Pause::Tactical); assert_eq!(s.snapshot().accumulator_ns, 0); }
#[test] fn noninteractive_pauses_reject_policy_and_action_mutation() { for overload in [false, true] { let mut s = Scheduler::<u8>::new(0, Settlement::IDLE); s.set_policy(Policy::RealTimeContinuous).unwrap(); if overload { s.observe(MAX_ACCUMULATOR_NS + 1).unwrap(); } else { s.system_pause(); } let before = s.snapshot(); assert_eq!( s.set_policy(Policy::ActionPaced), Err(Error::NotInteractive) ); assert_eq!(s.update_settlement(FALL), Err(Error::NotInteractive)); assert_eq!(s.begin_action_step(FALL), Err(Error::NotInteractive)); assert_eq!(s.begin_step(Step::Wait), Err(Error::NotInteractive)); assert_eq!(s.set_heading(Some(1)), Err(Error::NotInteractive)); assert_eq!(s.snapshot(), before); } }
#[test] fn checked_clock_is_transactional() { let mut s = Scheduler::<u8>::new(u64::MAX - 1, Settlement::IDLE); let before = s.snapshot(); assert_eq!(s.observe(0), Err(Error::ClockWentBackwards)); assert_eq!(s.snapshot(), before); s.set_policy(Policy::RealTimeContinuous).unwrap(); s.observe(u64::MAX).unwrap(); assert_eq!(s.snapshot().accumulator_ns, 1); }}