feat(server): initialize Rust/bevy_ecs simulation server

Server boilerplate epic (276) complete. Establishes the Rust simulation
server foundation per D-020 (subprocess/IPC architecture).

Structure:
- bevy_ecs 0.18 + bevy_app 0.18, MessagePack serialization (rmp-serde)
- SimulationPlugin with deterministic resources: SimulationTime (D-031),
  SimRng (D-030), InputQueue (D-010)
- Core IPC types: ObserverSnapshot, PlayerInput, SimBridge trait (D-020)
- CauseChain production component for provenance tracking (D-030)
- SimulationTier types with LRU eviction support (D-026)
- NPC 10-axis model components (D-024)
- 15 tests: inline unit tests + integration smoke/serialization tests
- make ci-server passes (clippy, fmt, build, test)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 16:54:40 +01:00
co-authored by Claude Opus 4.6
parent 84925b334a
commit f67caf1986
18 changed files with 2036 additions and 0 deletions
+117
View File
@@ -0,0 +1,117 @@
// 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::*;
pub const TICKS_PER_GAME_MINUTE: u64 = 10;
pub const MINUTES_PER_PHASE: u64 = 360;
pub const MINUTES_PER_DAY: u64 = 1440;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DayPhase {
Morning,
Afternoon,
Evening,
Night,
}
/// Simulation time resource
/// Tracks current tick and pause state for deterministic simulation
#[derive(Resource, Debug, Clone, Default)]
pub struct SimulationTime {
pub tick: u64,
pub paused: bool,
}
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();
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 if not paused
pub fn advance_tick(mut time: ResMut<SimulationTime>) {
if !time.paused {
time.tick += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tick_to_minute_conversion() {
let time = SimulationTime {
tick: 10,
paused: false,
};
assert_eq!(time.game_minutes(), 1);
}
#[test]
fn day_phase_boundaries() {
let time = SimulationTime {
tick: 0,
paused: false,
};
assert_eq!(time.day_phase(), DayPhase::Morning);
let time = SimulationTime {
tick: 360 * TICKS_PER_GAME_MINUTE,
paused: false,
};
assert_eq!(time.day_phase(), DayPhase::Afternoon);
let time = SimulationTime {
tick: 720 * TICKS_PER_GAME_MINUTE,
paused: false,
};
assert_eq!(time.day_phase(), DayPhase::Evening);
let time = SimulationTime {
tick: 1080 * TICKS_PER_GAME_MINUTE,
paused: false,
};
assert_eq!(time.day_phase(), DayPhase::Night);
}
#[test]
fn pause_prevents_tick_advance() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(SimulationTime {
tick: 0,
paused: true,
});
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 unpause_allows_tick_advance() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(SimulationTime {
tick: 0,
paused: false,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(advance_tick);
schedule.run(&mut world);
assert_eq!(world.resource::<SimulationTime>().tick, 1);
}
}