//! Pure frontend scheduling and autosave cadence. //! //! This module never receives mutable simulation state. It only determines how //! many fixed ticks are due; the application controller advances each one via //! `GameSession::advance_one_tick` and stops at lifecycle boundaries. pub const TICK_MS: u64 = 100; pub const AUTOSAVE_EVERY_DEFAULT: u64 = 600; #[derive(Clone, Debug, Default)] pub struct Loop { acc_ms: u64, } impl Loop { pub fn new() -> Self { Self::default() } /// Accumulate bounded frame time and return due fixed steps without touching /// authoritative state. pub fn due_ticks(&mut self, dt_ms: u64) -> u64 { self.acc_ms = self.acc_ms.saturating_add(dt_ms); let due = self.acc_ms / TICK_MS; self.acc_ms %= TICK_MS; due } #[cfg(test)] pub fn reset(&mut self) { self.acc_ms = 0; } } #[derive(Clone, Debug)] pub struct AutosaveCadence { elapsed_since_success: u64, every: u64, } impl AutosaveCadence { pub fn new() -> Self { Self { elapsed_since_success: 0, every: AUTOSAVE_EVERY_DEFAULT, } } /// Charge actual elapsed simulation ticks. Crossing is sticky until a /// successful save, so render frames neither lose nor repeat a write. pub fn advance(&mut self, elapsed: u64) -> bool { self.elapsed_since_success = self.elapsed_since_success.saturating_add(elapsed); self.due() } pub fn due(&self) -> bool { self.elapsed_since_success >= self.every } pub fn saved_successfully(&mut self) { self.elapsed_since_success = 0; } pub fn reset_after_load_or_rollback(&mut self) { self.elapsed_since_success = 0; } } impl Default for AutosaveCadence { fn default() -> Self { Self::new() } } #[cfg(test)] #[derive(Clone, Debug, Default)] pub struct SpsProbe { pub events: u64, } #[cfg(test)] impl SpsProbe { pub fn new() -> Self { Self::default() } pub fn record_event(&mut self) { self.events = self.events.saturating_add(1); } } #[cfg(test)] pub fn story_worthiness(events_per_session: u64) -> bool { events_per_session >= 1 } #[cfg(test)] mod tests { use super::*; #[test] fn accumulator_reports_due_steps_without_state() { let mut scheduler = Loop::new(); assert_eq!(scheduler.due_ticks(99), 0); assert_eq!(scheduler.due_ticks(1), 1); assert_eq!(scheduler.due_ticks(1050), 10); assert_eq!(scheduler.due_ticks(50), 1); scheduler.reset(); assert_eq!(scheduler.due_ticks(99), 0); } #[test] fn autosave_is_crossing_based_and_success_resets() { let mut cadence = AutosaveCadence::new(); assert!(!cadence.advance(599)); assert!(cadence.advance(8_640)); assert!(cadence.due()); assert!(cadence.advance(0)); cadence.saved_successfully(); assert!(!cadence.due()); assert!(cadence.advance(600)); cadence.reset_after_load_or_rollback(); assert!(!cadence.due()); } #[test] fn sps_threshold() { let mut probe = SpsProbe::new(); assert!(!story_worthiness(probe.events)); probe.record_event(); assert!(story_worthiness(probe.events)); } }