diff --git a/server/src/knowledge/events.rs b/server/src/knowledge/events.rs index c81cad404..ea102ae66 100644 --- a/server/src/knowledge/events.rs +++ b/server/src/knowledge/events.rs @@ -260,7 +260,7 @@ mod tests { world.insert_resource(thresholds); // Tick 7: not a multiple of 10, decay should NOT run - world.insert_resource(SimulationTime { tick: 7, paused: false }); + world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 7; t }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(decay_knowledge); schedule.run(&mut world); @@ -273,7 +273,7 @@ mod tests { ); // Tick 10: multiple of 10, decay SHOULD run (age = 10 > decay_after = 5) - world.insert_resource(SimulationTime { tick: 10, paused: false }); + world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 10; t }); let mut schedule2 = bevy_ecs::schedule::Schedule::default(); schedule2.add_systems(decay_knowledge); schedule2.run(&mut world); diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index f5e9a2ffc..72bf3f278 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -5,7 +5,7 @@ use crate::bridge::types::{PlayerAction, PlayerInput}; use crate::perception::vision_cone::{facing_from_delta, Facing}; use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition}; -use crate::simulation::time::SimulationTime; +use crate::simulation::time::{SimulationTime, TickRate}; use bevy_ecs::prelude::*; use std::collections::VecDeque; @@ -75,13 +75,17 @@ pub fn process_player_input( PlayerAction::MoveSoutheast => apply_move(&player_query, &mut commands, 1, 1), PlayerAction::MoveSouthwest => apply_move(&player_query, &mut commands, -1, 1), PlayerAction::Pause => { - time.paused = true; + time.tick_rate = TickRate::Paused; tracing::debug!("Simulation paused by player input"); } PlayerAction::Unpause => { - time.paused = false; + time.tick_rate = TickRate::Full; tracing::debug!("Simulation unpaused by player input"); } + PlayerAction::SetTickRate(rate) => { + time.tick_rate = rate; + tracing::debug!("Tick rate set to {:?} by player input", rate); + } PlayerAction::Interact => { tracing::trace!("Interact action — no-op for Sprint 1"); } @@ -160,10 +164,7 @@ mod tests { fn process_input_move_creates_intent() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); - world.insert_resource(SimulationTime { - tick: 0, - paused: false, - }); + world.insert_resource(SimulationTime::default()); let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) @@ -183,13 +184,10 @@ mod tests { } #[test] - fn process_input_pause_toggles() { + fn process_input_pause_sets_paused() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); - world.insert_resource(SimulationTime { - tick: 0, - paused: false, - }); + world.insert_resource(SimulationTime::default()); world.resource_mut::().push(PlayerInput { tick: 0, @@ -200,17 +198,32 @@ mod tests { schedule.add_systems(process_player_input); schedule.run(&mut world); - assert!(world.resource::().paused); + assert_eq!(world.resource::().tick_rate, TickRate::Paused); + } + + #[test] + fn process_input_set_tick_rate() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::SetTickRate(TickRate::Half), + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); + + assert_eq!(world.resource::().tick_rate, TickRate::Half); } #[test] fn process_input_no_player_no_panic() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); - world.insert_resource(SimulationTime { - tick: 0, - paused: false, - }); + world.insert_resource(SimulationTime::default()); world.resource_mut::().push(PlayerInput { tick: 0, @@ -227,10 +240,7 @@ mod tests { fn process_input_future_tick_ignored() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); - world.insert_resource(SimulationTime { - tick: 0, - paused: false, - }); + world.insert_resource(SimulationTime::default()); let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) diff --git a/server/src/simulation/time.rs b/server/src/simulation/time.rs index 753eefc1c..dd80aad2f 100644 --- a/server/src/simulation/time.rs +++ b/server/src/simulation/time.rs @@ -18,12 +18,54 @@ pub enum DayPhase { 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 pause state for deterministic simulation -#[derive(Resource, Debug, Clone, Default)] +/// 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 paused: bool, + 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 { @@ -48,10 +90,17 @@ impl SimulationTime { } } -/// Advance the simulation tick if not paused +/// Advance the simulation tick based on current tick rate. +/// Full: +1 every frame. Half: +1 every 2 frames. Paused: no advance. pub fn advance_tick(mut time: ResMut) { - if !time.paused { + 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; } } @@ -61,44 +110,26 @@ mod tests { #[test] fn tick_to_minute_conversion() { - let time = SimulationTime { - tick: 10, - paused: false, - }; + let time = SimulationTime { tick: 10, ..Default::default() }; assert_eq!(time.game_minutes(), 1); } #[test] fn day_phase_boundaries() { - let time = SimulationTime { - tick: 0, - paused: false, - }; + let time = SimulationTime::default(); assert_eq!(time.day_phase(), DayPhase::Morning); - let time = SimulationTime { - tick: 360 * TICKS_PER_GAME_MINUTE, - paused: false, - }; + 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, - paused: false, - }; + 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, - paused: false, - }; + let time = SimulationTime { tick: 1080 * TICKS_PER_GAME_MINUTE, ..Default::default() }; assert_eq!(time.day_phase(), DayPhase::Night); } #[test] - fn pause_prevents_tick_advance() { + fn paused_prevents_tick_advance() { let mut world = bevy_ecs::world::World::new(); - world.insert_resource(SimulationTime { - tick: 0, - paused: true, - }); + 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); @@ -106,25 +137,52 @@ mod tests { } #[test] - fn unpause_allows_tick_advance() { + fn full_rate_advances_every_frame() { let mut world = bevy_ecs::world::World::new(); - world.insert_resource(SimulationTime { - tick: 0, - paused: false, - }); + 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() { - // 1440 minutes = 1 full day, should wrap back to Morning - let time = SimulationTime { - tick: MINUTES_PER_DAY * TICKS_PER_GAME_MINUTE, - paused: false, - }; + 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); @@ -132,10 +190,7 @@ mod tests { #[test] fn day_calculation() { - let time = SimulationTime { - tick: 3 * MINUTES_PER_DAY * TICKS_PER_GAME_MINUTE + 100, - paused: false, - }; + let time = SimulationTime { tick: 3 * MINUTES_PER_DAY * TICKS_PER_GAME_MINUTE + 100, ..Default::default() }; assert_eq!(time.day(), 3); } }