feat(simulation): add tick rate scaling system

Replace binary paused flag with TickRate enum (Full/Half/Paused)
per D-052. Full rate advances every frame, Half every 2 frames
via fractional accumulation, Paused blocks all advances. Add
SetTickRate player action for client-driven rate changes.
Implements ticket #406.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 19:48:43 +01:00
co-authored by Claude Opus 4.6
parent ad55dfaa72
commit 5afea99622
3 changed files with 132 additions and 67 deletions
+2 -2
View File
@@ -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);
+31 -21
View File
@@ -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::<InputQueue>().push(PlayerInput {
tick: 0,
@@ -200,17 +198,32 @@ mod tests {
schedule.add_systems(process_player_input);
schedule.run(&mut world);
assert!(world.resource::<SimulationTime>().paused);
assert_eq!(world.resource::<SimulationTime>().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::<InputQueue>().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::<SimulationTime>().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::<InputQueue>().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)))
+99 -44
View File
@@ -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<SimulationTime>) {
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::<SimulationTime>().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::<SimulationTime>().tick, 0);
// Frame 2: accumulate 1.0, tick advances
schedule.run(&mut world);
assert_eq!(world.resource::<SimulationTime>().tick, 1);
// Frame 3: accumulate 0.5 again, no tick
schedule.run(&mut world);
assert_eq!(world.resource::<SimulationTime>().tick, 1);
// Frame 4: tick advances again
schedule.run(&mut world);
assert_eq!(world.resource::<SimulationTime>().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);
}
}