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
View File
+1301
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "settled-reach-server"
version = "0.1.0"
edition = "2024"
[dependencies]
bevy_ecs = "0.18"
bevy_app = "0.18"
serde = { version = "1", features = ["derive"] }
rmp-serde = "1"
bincode = "1"
rand = "0.9"
rand_chacha = "0.9"
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+40
View File
@@ -0,0 +1,40 @@
// Bridge module - Client-server communication
// Implements D-020 subprocess/IPC architecture
// MessagePack serialization for Rust<->Godot communication
use bevy_app::prelude::*;
pub mod types;
pub use types::*;
/// Error type for bridge operations
#[derive(Debug, thiserror::Error)]
pub enum BridgeError {
#[error("serialization error: {0}")]
Serialization(#[from] rmp_serde::encode::Error),
#[error("deserialization error: {0}")]
Deserialization(#[from] rmp_serde::decode::Error),
#[error("transport error: {0}")]
Transport(String),
}
/// Abstracts transport layer (D-020)
/// Implemented by LocalBridge (stdio) and future NetworkBridge
pub trait SimBridge: Send + Sync {
/// Send an observer snapshot to the client
fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError>;
/// Receive player inputs from the client
fn receive_inputs(&self) -> Result<Vec<PlayerInput>, BridgeError>;
}
/// Bridge plugin for client-server communication
/// Abstracts transport layer (LocalBridge/NetworkBridge)
pub struct BridgePlugin;
impl Plugin for BridgePlugin {
fn build(&self, _app: &mut App) {
// Stub implementation - will be populated in phase 2
tracing::debug!("BridgePlugin initialized");
}
}
+56
View File
@@ -0,0 +1,56 @@
// Bridge type definitions
// ObserverSnapshot: data crossing the client-server boundary
// PlayerInput: semantic actions from client
use serde::{Deserialize, Serialize};
/// The ONLY data structure crossing the client-server boundary (D-020)
/// Contains all information visible to the observer at a given tick
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObserverSnapshot {
/// Simulation tick when this snapshot was produced
pub tick: u64,
/// All entities visible to the observer
pub entities: Vec<VisibleEntity>,
}
/// A visible entity in the simulation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VisibleEntity {
pub entity_id: u64,
pub x: f32,
pub y: f32,
pub z: i32,
pub kind: EntityKind,
}
/// Category of visible entity
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum EntityKind {
Npc,
Object,
Terrain,
}
/// Semantic player actions, not raw key events (D-020)
/// Timestamped for deterministic processing
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlayerInput {
/// Tick when this input was issued
pub tick: u64,
/// The action to perform
pub action: PlayerAction,
}
/// Player action variants
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PlayerAction {
MoveNorth,
MoveSouth,
MoveEast,
MoveWest,
Interact,
UsePerceptionMode(String),
Pause,
Unpause,
}
+72
View File
@@ -0,0 +1,72 @@
// Cause chain tracking system
// Production component for information provenance (D-030)
// Tracks monologue triggers, journal entries, debug causality
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct CauseChain {
pub causes: Vec<Cause>,
}
impl CauseChain {
pub fn new() -> Self {
Self { causes: Vec::new() }
}
pub fn with_cause(mut self, cause: Cause) -> Self {
self.causes.push(cause);
self
}
pub fn push(&mut self, cause: Cause) {
self.causes.push(cause);
}
pub fn latest(&self) -> Option<&Cause> {
self.causes.last()
}
}
impl Default for CauseChain {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Cause {
pub tick: u64,
pub kind: CauseKind,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum CauseKind {
Observed,
Heard,
Informed,
Inferred,
Background,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chain_preserves_order() {
let chain = CauseChain::new()
.with_cause(Cause {
tick: 1,
kind: CauseKind::Observed,
description: "Saw NPC".into(),
})
.with_cause(Cause {
tick: 5,
kind: CauseKind::Inferred,
description: "NPC still inside".into(),
});
assert_eq!(chain.causes.len(), 2);
assert_eq!(chain.latest().unwrap().tick, 5);
assert_eq!(chain.latest().unwrap().kind, CauseKind::Inferred);
}
}
+9
View File
@@ -0,0 +1,9 @@
// The Settled Reach - Simulation Server
// Rust/bevy_ecs simulation server for D-010 client-server architecture
pub mod bridge;
pub mod cause_chain;
pub mod npc;
pub mod perception;
pub mod simulation;
pub mod storyteller;
+31
View File
@@ -0,0 +1,31 @@
// The Settled Reach - Simulation Server
// Entry point for standalone simulation binary
use bevy_app::prelude::*;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use settled_reach_server::bridge::BridgePlugin;
use settled_reach_server::simulation::SimulationPlugin;
fn main() {
// Initialize tracing subscriber for logging
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "settled_reach_server=debug".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
tracing::info!("The Settled Reach - Simulation Server starting");
// Create the bevy App and add plugins
let mut app = App::new();
app.add_plugins(SimulationPlugin);
app.add_plugins(BridgePlugin);
// Run one update cycle
app.update();
tracing::info!("Simulation server update complete");
}
+76
View File
@@ -0,0 +1,76 @@
// NPC module - NPC entity definitions and AI systems
// Implements D-024: 10-axis NPC model + CombatCapability component
// Background tier state machines for schedule, mood, relationships, job
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Component, Debug)]
pub struct Npc;
// 7 essential axes (D-024)
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct Want {
pub description: String,
}
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct Secret {
pub description: String,
}
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct Relationships {
pub entries: Vec<Relationship>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Relationship {
pub target_name: String,
pub kind: String,
pub trust_level: f32,
}
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct ToleranceThreshold {
pub current_stress: f32,
pub threshold: f32,
}
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct DailyRoutine {
pub description: String,
}
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct InformationInventory {
pub known_facts: Vec<String>,
}
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct Contentment {
pub level: f32,
}
// 3 supporting axes (D-024)
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct PersonalityTraits {
pub traits: Vec<String>,
}
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct TellSystem {
pub tells: Vec<String>,
}
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct SkillSet {
pub skills: Vec<String>,
pub combat_trained: bool,
}
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct CombatCapability {
pub weapon_proficiency: f32,
pub combat_style: String,
}
+16
View File
@@ -0,0 +1,16 @@
// Perception module - Information boundary system
// Implements D-010 principle 2: every piece of state is tagged with who knows it
// Generates ObserverSnapshot for client rendering
use bevy_app::prelude::*;
/// Perception system plugin
/// Manages information boundaries and observer snapshots
pub struct PerceptionPlugin;
impl Plugin for PerceptionPlugin {
fn build(&self, _app: &mut App) {
// Stub implementation - will be populated in phase 2
tracing::debug!("PerceptionPlugin initialized");
}
}
+77
View File
@@ -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());
}
}
+25
View File
@@ -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");
}
}
+54
View File
@@ -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);
}
}
+58
View File
@@ -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
);
}
}
+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);
}
}
+15
View File
@@ -0,0 +1,15 @@
// Storyteller module - Rimworld-style storyteller system
// Event generation, pacing, hubris wall mechanics
use bevy_app::prelude::*;
/// Storyteller plugin
/// Manages narrative pacing and event generation
pub struct StorytellerPlugin;
impl Plugin for StorytellerPlugin {
fn build(&self, _app: &mut App) {
// Stub implementation - will be populated in phase 2
tracing::debug!("StorytellerPlugin initialized");
}
}
+51
View File
@@ -0,0 +1,51 @@
//! IPC serialization round-trip tests (D-030 Layer 1: fixture-based).
use settled_reach_server::bridge::types::*;
#[test]
fn observer_snapshot_roundtrip() {
let snapshot = ObserverSnapshot {
tick: 42,
entities: vec![VisibleEntity {
entity_id: 1,
x: 10.0,
y: 20.0,
z: 0,
kind: EntityKind::Npc,
}],
};
let bytes = rmp_serde::to_vec(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.tick, 42);
assert_eq!(decoded.entities.len(), 1);
assert_eq!(decoded.entities[0].entity_id, 1);
}
#[test]
fn player_input_roundtrip() {
let input = PlayerInput {
tick: 100,
action: PlayerAction::MoveNorth,
};
let bytes = rmp_serde::to_vec(&input).expect("serialize");
let decoded: PlayerInput = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.tick, 100);
}
#[test]
fn empty_snapshot_roundtrip() {
let snapshot = ObserverSnapshot {
tick: 0,
entities: vec![],
};
let bytes = rmp_serde::to_vec(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.tick, 0);
assert!(decoded.entities.is_empty());
}
+22
View File
@@ -0,0 +1,22 @@
//! Smoke test: the simulation world boots and can run a single tick.
use bevy_app::prelude::*;
use settled_reach_server::simulation::SimulationPlugin;
use settled_reach_server::simulation::time::SimulationTime;
#[test]
fn world_boots_and_ticks() {
let mut app = App::new();
app.add_plugins(SimulationPlugin);
// Verify initial state
let time = app.world().resource::<SimulationTime>();
assert_eq!(time.tick, 0);
// Run one update cycle
app.update();
// Verify tick advanced
let time = app.world().resource::<SimulationTime>();
assert_eq!(time.tick, 1);
}