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
+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)))