// Simulation time system // Implements D-031: 10 ticks = 1 game-minute, 4 day phases // Injectable time resource for deterministic replay (D-030) use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize}; pub const TICKS_PER_GAME_MINUTE: u64 = 10; pub const MINUTES_PER_PHASE: u64 = 360; pub const MINUTES_PER_DAY: u64 = 1440; // 4 equal 6-hour phases (adjustable — D-031 allows rebalancing phase durations) #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum DayPhase { Morning, Afternoon, Evening, Night, } /// Tick rate states per D-052 /// Full: normal gameplay. Half: UI overlay open (knowledge panel, dialogue). /// Paused: spacebar pause (0 ticks advance). #[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] pub enum TickRate { #[default] Full, Half, Paused, } impl TickRate { /// Scale factor: Full=1.0, Half=0.5, Paused=0.0 pub fn scale(self) -> f32 { match self { TickRate::Full => 1.0, TickRate::Half => 0.5, TickRate::Paused => 0.0, } } } /// Simulation time resource /// Tracks current tick and tick rate for deterministic simulation (D-052). /// Uses fractional accumulation: at Half speed, one tick advances every 2 frames. #[derive(Resource, Debug, Clone)] pub struct SimulationTime { pub tick: u64, pub tick_rate: TickRate, /// Fractional tick accumulator for sub-1.0 rates accumulated: f32, } impl Default for SimulationTime { fn default() -> Self { Self { tick: 0, tick_rate: TickRate::Full, accumulated: 0.0, } } } impl SimulationTime { /// Whether the simulation is effectively paused (tick_rate == Paused) pub fn paused(&self) -> bool { self.tick_rate == TickRate::Paused } } impl SimulationTime { pub fn game_minutes(&self) -> u64 { self.tick / TICKS_PER_GAME_MINUTE } pub fn time_of_day_minutes(&self) -> u64 { self.game_minutes() % MINUTES_PER_DAY } pub fn day_phase(&self) -> DayPhase { let tod = self.time_of_day_minutes(); // Morning: [0, 360), Afternoon: [360, 720), Evening: [720, 1080), Night: [1080, 1440) match tod { 0..360 => DayPhase::Morning, 360..720 => DayPhase::Afternoon, 720..1080 => DayPhase::Evening, _ => DayPhase::Night, } } pub fn day(&self) -> u64 { self.game_minutes() / MINUTES_PER_DAY } } /// Advance the simulation tick based on current tick rate. /// Full: +1 every frame. Half: +1 every 2 frames. Paused: no advance. /// /// Uses fractional accumulation: each frame adds `tick_rate.scale()` to an /// internal accumulator. When it reaches 1.0, a tick fires and the accumulator /// subtracts 1.0. This ensures Half rate produces exactly N/2 ticks over N /// frames with no floating-point drift (0.5 is exactly representable in f32). pub fn advance_tick(mut time: ResMut) { let scale = time.tick_rate.scale(); if scale <= 0.0 { return; } time.accumulated += scale; if time.accumulated >= 1.0 { time.tick += 1; time.accumulated -= 1.0; } } #[cfg(test)] mod tests { use super::*; #[test] fn tick_to_minute_conversion() { let time = SimulationTime { tick: 10, ..Default::default() }; assert_eq!(time.game_minutes(), 1); } #[test] fn day_phase_boundaries() { let time = SimulationTime::default(); assert_eq!(time.day_phase(), DayPhase::Morning); let time = SimulationTime { tick: 360 * TICKS_PER_GAME_MINUTE, ..Default::default() }; assert_eq!(time.day_phase(), DayPhase::Afternoon); let time = SimulationTime { tick: 720 * TICKS_PER_GAME_MINUTE, ..Default::default() }; assert_eq!(time.day_phase(), DayPhase::Evening); let time = SimulationTime { tick: 1080 * TICKS_PER_GAME_MINUTE, ..Default::default() }; assert_eq!(time.day_phase(), DayPhase::Night); } #[test] fn paused_prevents_tick_advance() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(SimulationTime { tick_rate: TickRate::Paused, ..Default::default() }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(advance_tick); schedule.run(&mut world); assert_eq!(world.resource::().tick, 0); } #[test] fn full_rate_advances_every_frame() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(SimulationTime::default()); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(advance_tick); schedule.run(&mut world); assert_eq!(world.resource::().tick, 1); } #[test] fn half_rate_advances_every_two_frames() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(SimulationTime { tick_rate: TickRate::Half, ..Default::default() }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(advance_tick); // Frame 1: accumulate 0.5, no tick schedule.run(&mut world); assert_eq!(world.resource::().tick, 0); // Frame 2: accumulate 1.0, tick advances schedule.run(&mut world); assert_eq!(world.resource::().tick, 1); // Frame 3: accumulate 0.5 again, no tick schedule.run(&mut world); assert_eq!(world.resource::().tick, 1); // Frame 4: tick advances again schedule.run(&mut world); assert_eq!(world.resource::().tick, 2); } #[test] fn paused_helper_method() { let time = SimulationTime::default(); assert!(!time.paused()); let time = SimulationTime { tick_rate: TickRate::Paused, ..Default::default() }; assert!(time.paused()); let time = SimulationTime { tick_rate: TickRate::Half, ..Default::default() }; assert!(!time.paused()); } #[test] fn day_wraparound_at_midnight() { let time = SimulationTime { tick: MINUTES_PER_DAY * TICKS_PER_GAME_MINUTE, ..Default::default() }; assert_eq!(time.day_phase(), DayPhase::Morning); assert_eq!(time.time_of_day_minutes(), 0); assert_eq!(time.day(), 1); } #[test] fn day_calculation() { let time = SimulationTime { tick: 3 * MINUTES_PER_DAY * TICKS_PER_GAME_MINUTE + 100, ..Default::default() }; assert_eq!(time.day(), 3); } #[test] fn tick_rate_switch_mid_accumulation() { // Half->Full with 0.5 remainder: Full should tick immediately (0.5 + 1.0 >= 1.0) let mut world = bevy_ecs::world::World::new(); world.insert_resource(SimulationTime { tick_rate: TickRate::Half, ..Default::default() }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(advance_tick); // Frame 1: Half rate, accumulate 0.5, no tick schedule.run(&mut world); assert_eq!(world.resource::().tick, 0); // Switch to Full mid-accumulation (0.5 remainder) world.resource_mut::().tick_rate = TickRate::Full; // Frame 2: Full rate adds 1.0 to 0.5 remainder → tick fires schedule.run(&mut world); assert_eq!(world.resource::().tick, 1); // Switch to Paused: no advance regardless of accumulator world.resource_mut::().tick_rate = TickRate::Paused; schedule.run(&mut world); assert_eq!(world.resource::().tick, 1); // Switch back to Half: accumulator still has 0.5 from overshoot world.resource_mut::().tick_rate = TickRate::Half; schedule.run(&mut world); // 0.5 (leftover) + 0.5 (Half) = 1.0 → tick fires assert_eq!(world.resource::().tick, 2); } #[test] fn half_rate_no_drift_over_10000_frames() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(SimulationTime { tick_rate: TickRate::Half, ..Default::default() }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(advance_tick); for _ in 0..10_000 { schedule.run(&mut world); } // 10,000 frames at Half rate (0.5) should yield exactly 5,000 ticks assert_eq!(world.resource::().tick, 5_000); } }