Files
settled-reach/server/src/simulation/time.rs
T
jpmschweitzerandClaude Opus 4.6 d0663c4193 refactor(server): apply rustfmt and clippy suggestions
Formatting pass across simulation, perception, knowledge, NPC, and
test modules. Includes two clippy fixes in monologue.rs (.values()
instead of for (_, v) pattern).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 00:40:43 +01:00

284 lines
8.8 KiB
Rust

// 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<SimulationTime>) {
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::<SimulationTime>().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::<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() {
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::<SimulationTime>().tick, 0);
// Switch to Full mid-accumulation (0.5 remainder)
world.resource_mut::<SimulationTime>().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::<SimulationTime>().tick, 1);
// Switch to Paused: no advance regardless of accumulator
world.resource_mut::<SimulationTime>().tick_rate = TickRate::Paused;
schedule.run(&mut world);
assert_eq!(world.resource::<SimulationTime>().tick, 1);
// Switch back to Half: accumulator still has 0.5 from overshoot
world.resource_mut::<SimulationTime>().tick_rate = TickRate::Half;
schedule.run(&mut world);
// 0.5 (leftover) + 0.5 (Half) = 1.0 → tick fires
assert_eq!(world.resource::<SimulationTime>().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::<SimulationTime>().tick, 5_000);
}
}