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:
@@ -0,0 +1,77 @@
|
||||
// Input processing system
|
||||
// Timestamped player input events for deterministic simulation (D-010 principle 4)
|
||||
// PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode)
|
||||
|
||||
use crate::bridge::types::PlayerInput;
|
||||
use bevy_ecs::prelude::*;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Queue of pending player inputs, ordered by tick
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct InputQueue {
|
||||
queue: VecDeque<PlayerInput>,
|
||||
}
|
||||
|
||||
impl InputQueue {
|
||||
/// Add a new input to the queue
|
||||
pub fn push(&mut self, input: PlayerInput) {
|
||||
self.queue.push_back(input);
|
||||
}
|
||||
|
||||
/// Drain all inputs for ticks <= the given tick
|
||||
/// Returns inputs in FIFO order
|
||||
pub fn drain_for_tick(&mut self, tick: u64) -> Vec<PlayerInput> {
|
||||
let mut result = Vec::new();
|
||||
while let Some(front) = self.queue.front() {
|
||||
if front.tick <= tick {
|
||||
result.push(self.queue.pop_front().unwrap());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Get the current queue length
|
||||
pub fn len(&self) -> usize {
|
||||
self.queue.len()
|
||||
}
|
||||
|
||||
/// Check if the queue is empty
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.queue.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::bridge::types::PlayerAction;
|
||||
|
||||
#[test]
|
||||
fn drain_returns_inputs_up_to_tick() {
|
||||
let mut queue = InputQueue::default();
|
||||
queue.push(PlayerInput {
|
||||
tick: 1,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
queue.push(PlayerInput {
|
||||
tick: 2,
|
||||
action: PlayerAction::MoveSouth,
|
||||
});
|
||||
queue.push(PlayerInput {
|
||||
tick: 5,
|
||||
action: PlayerAction::Interact,
|
||||
});
|
||||
let inputs = queue.drain_for_tick(3);
|
||||
assert_eq!(inputs.len(), 2);
|
||||
assert_eq!(queue.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_empty_queue_returns_empty() {
|
||||
let mut queue = InputQueue::default();
|
||||
let inputs = queue.drain_for_tick(10);
|
||||
assert!(inputs.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Simulation module - Core simulation plugin and systems
|
||||
// Implements deterministic tick-based simulation (D-010 principle 4)
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
pub mod input;
|
||||
pub mod rng;
|
||||
pub mod tier;
|
||||
pub mod time;
|
||||
|
||||
/// Core simulation plugin
|
||||
/// Manages simulation time, RNG, input processing, and tier transitions
|
||||
pub struct SimulationPlugin;
|
||||
|
||||
impl Plugin for SimulationPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// Initialize core simulation resources
|
||||
app.init_resource::<time::SimulationTime>()
|
||||
.insert_resource(rng::SimRng::new(0))
|
||||
.init_resource::<input::InputQueue>()
|
||||
.add_systems(Update, time::advance_tick);
|
||||
|
||||
tracing::debug!("SimulationPlugin initialized");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Simulation RNG system
|
||||
// Injectable ChaCha RNG resource for deterministic replay (D-030)
|
||||
// Ensures same seed produces same outcomes
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use rand::SeedableRng;
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
|
||||
/// Simulation RNG resource
|
||||
/// ChaCha20 RNG with stored seed for deterministic replay
|
||||
#[derive(Resource)]
|
||||
pub struct SimRng {
|
||||
pub rng: ChaCha20Rng,
|
||||
seed: u64,
|
||||
}
|
||||
|
||||
impl SimRng {
|
||||
/// Create a new SimRng with the given seed
|
||||
pub fn new(seed: u64) -> Self {
|
||||
Self {
|
||||
rng: ChaCha20Rng::seed_from_u64(seed),
|
||||
seed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the seed used to initialize this RNG
|
||||
pub fn seed(&self) -> u64 {
|
||||
self.seed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rand::Rng;
|
||||
|
||||
#[test]
|
||||
fn same_seed_same_sequence() {
|
||||
let mut rng1 = SimRng::new(42);
|
||||
let mut rng2 = SimRng::new(42);
|
||||
let vals1: Vec<u32> = (0..100).map(|_| rng1.rng.random()).collect();
|
||||
let vals2: Vec<u32> = (0..100).map(|_| rng2.rng.random()).collect();
|
||||
assert_eq!(vals1, vals2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_seed_different_sequence() {
|
||||
let mut rng1 = SimRng::new(42);
|
||||
let mut rng2 = SimRng::new(43);
|
||||
let val1: u32 = rng1.rng.random();
|
||||
let val2: u32 = rng2.rng.random();
|
||||
assert_ne!(val1, val2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Simulation tier system
|
||||
// Implements D-026: Active/Background/State-saved/Ungenerated tiers
|
||||
// Timestamp-based LRU eviction for simulation space management
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum SimulationTier {
|
||||
Active,
|
||||
Background,
|
||||
StateSaved,
|
||||
Ungenerated,
|
||||
}
|
||||
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct LastInteraction {
|
||||
pub tick: u64,
|
||||
}
|
||||
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct ScopeTag {
|
||||
pub tags: Vec<ScopeKind>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ScopeKind {
|
||||
Neighborhood,
|
||||
ActiveQuest,
|
||||
Colleague,
|
||||
KnownContact,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tier_can_be_added_and_queried() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
let entity = world.spawn(SimulationTier::Active).id();
|
||||
assert_eq!(
|
||||
*world.get::<SimulationTier>(entity).unwrap(),
|
||||
SimulationTier::Active
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_can_transition() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
let entity = world.spawn(SimulationTier::Active).id();
|
||||
world.entity_mut(entity).insert(SimulationTier::Background);
|
||||
assert_eq!(
|
||||
*world.get::<SimulationTier>(entity).unwrap(),
|
||||
SimulationTier::Background
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user