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>
284 lines
8.8 KiB
Rust
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);
|
|
}
|
|
}
|