Merge pull request #2: feat(simulation): add Rust/bevy_ecs server boilerplate
Merge origin/server into main. Resolve CHANGELOG.md conflict by keeping both sets of entries (main's review-pr skill + server's boilerplate entries). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
# Build and cache
|
# Build and cache
|
||||||
.cache/
|
.cache/
|
||||||
|
server/target/
|
||||||
|
|
||||||
# Database journal files (transient, not the DB itself)
|
# Database journal files (transient, not the DB itself)
|
||||||
db/commonwealth.db-wal
|
db/commonwealth.db-wal
|
||||||
|
|||||||
@@ -10,6 +10,21 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|||||||
- `/review-pr` skill — dual-agent PR review with Hoshe (code quality) and Tyre (architecture) in parallel, Gitea integration, vendor file exclusion patterns
|
- `/review-pr` skill — dual-agent PR review with Hoshe (code quality) and Tyre (architecture) in parallel, Gitea integration, vendor file exclusion patterns
|
||||||
- Automated Rust install via `make setup` (tooling/install-rust script, rustup + clippy + rustfmt)
|
- Automated Rust install via `make setup` (tooling/install-rust script, rustup + clippy + rustfmt)
|
||||||
- Automated Godot download/install via `make setup` (tooling/install-godot script, installs to ~/bin/godot4)
|
- Automated Godot download/install via `make setup` (tooling/install-godot script, installs to ~/bin/godot4)
|
||||||
|
- InputQueue tick ordering enforcement via debug_assert (determinism guard)
|
||||||
|
- Serialization roundtrip tests for all PlayerAction and EntityKind variants (D-030 Layer 1)
|
||||||
|
- Edge case tests: day wraparound at midnight, day() calculation, out-of-order input rejection
|
||||||
|
- Test runner switched to cargo-nextest (D-030 requirement)
|
||||||
|
- Rust/bevy_ecs simulation server boilerplate (epic 276) — Cargo project, module structure, core ECS types, plugin scaffolding, deterministic simulation resources, test infrastructure
|
||||||
|
- SimulationTime resource with D-031 time system (10 ticks/game-minute, 4 day phases)
|
||||||
|
- SimRng deterministic RNG resource (ChaCha20, seeded for replay)
|
||||||
|
- InputQueue resource for timestamped semantic player actions
|
||||||
|
- ObserverSnapshot and PlayerInput IPC types with MessagePack serialization (D-020)
|
||||||
|
- SimBridge trait abstracting client-server transport
|
||||||
|
- CauseChain production component for information provenance tracking (D-030)
|
||||||
|
- SimulationTier types with LRU eviction support (D-026: Active/Background/StateSaved/Ungenerated)
|
||||||
|
- NPC 10-axis model components (D-024: 7 essential + 3 supporting + CombatCapability)
|
||||||
|
- Server test infrastructure: 11 inline unit tests + 4 integration tests (smoke + serialization round-trips)
|
||||||
|
- make ci-server pipeline verified green (clippy, fmt, build, test)
|
||||||
- Round 18 v0.1 gap analysis workshop — 7 agents, 2 rounds, 4 tracks (concept proof, wow factor, missing systems, testability)
|
- Round 18 v0.1 gap analysis workshop — 7 agents, 2 rounds, 4 tracks (concept proof, wow factor, missing systems, testability)
|
||||||
- D-030: Testability architecture — 8 sub-decisions for ticket #214 (gdUnit4, hybrid Rust testing, CauseChain component, three-layer IPC testing)
|
- D-030: Testability architecture — 8 sub-decisions for ticket #214 (gdUnit4, hybrid Rust testing, CauseChain component, three-layer IPC testing)
|
||||||
- D-031: Time system — 10 ticks = 1 game-minute, 4 day phases (Morning/Afternoon/Evening/Night), diegetic clock display
|
- D-031: Time system — 10 ticks = 1 game-minute, 4 day phases (Morning/Afternoon/Evening/Night), diegetic clock display
|
||||||
@@ -52,6 +67,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|||||||
- Rejected alternatives R-004 through R-010 documented (pure Bevy, pure Godot, GDExtension, C++ GDExtension, Fyrox, custom framework, protobuf)
|
- Rejected alternatives R-004 through R-010 documented (pure Bevy, pure Godot, GDExtension, C++ GDExtension, Fyrox, custom framework, protobuf)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- Cargo.toml edition 2024 → 2021 for broader toolchain compatibility
|
||||||
|
- Relationship.target_name: String → target_id: u64 for entity scalability (Tyre review)
|
||||||
|
- DayPhase enum now derives Serialize/Deserialize (consistent with other enums)
|
||||||
- Replaced all absolute paths (macOS and Linux) with project-relative paths across round-16 docs
|
- Replaced all absolute paths (macOS and Linux) with project-relative paths across round-16 docs
|
||||||
- Removed duplicate ROUND-16-STATUS.md from project root (content already in round-16-session-notes.md)
|
- Removed duplicate ROUND-16-STATUS.md from project root (content already in round-16-session-notes.md)
|
||||||
- Added relative-path convention to Qatux agent persona for cross-system consistency
|
- Added relative-path convention to Qatux agent persona for cross-system consistency
|
||||||
|
|||||||
@@ -109,7 +109,22 @@ Key rules:
|
|||||||
Use conventional commits with project-specific scopes:
|
Use conventional commits with project-specific scopes:
|
||||||
`agents`, `skills`, `docs`, `briefings`, `discussions`, `schema`, `db`, `config`, `engine`, `simulation`, `client`, `ui`, `audio`, `assets`, `meta`
|
`agents`, `skills`, `docs`, `briefings`, `discussions`, `schema`, `db`, `config`, `engine`, `simulation`, `client`, `ui`, `audio`, `assets`, `meta`
|
||||||
|
|
||||||
|
### Pull requests
|
||||||
|
**Use `tea` (Gitea CLI), not `gh` (GitHub CLI).** The remote is Gitea at `git.schweitz.internal`.
|
||||||
|
|
||||||
|
Always provide all required flags to ensure non-interactive execution:
|
||||||
|
```bash
|
||||||
|
tea pr create \
|
||||||
|
--repo jpmschweitzer/settled-reach \
|
||||||
|
--login schweitz \
|
||||||
|
--title "feat(scope): short description" \
|
||||||
|
--description "PR body here" \
|
||||||
|
--base main \
|
||||||
|
--head branch-name
|
||||||
|
```
|
||||||
|
|
||||||
### Local services
|
### Local services
|
||||||
|
- Gitea: `http://git.schweitz.internal` (login: `schweitz`)
|
||||||
- Qdrant: `http://tower-of-joy:6333/`
|
- Qdrant: `http://tower-of-joy:6333/`
|
||||||
- Ollama: `http://tower-of-joy:11434/` (nomic-embed-text)
|
- Ollama: `http://tower-of-joy:11434/` (nomic-embed-text)
|
||||||
- Collection: `commonwealth` (768 dimensions, cosine distance)
|
- Collection: `commonwealth` (768 dimensions, cosine distance)
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ client:
|
|||||||
test: test-server test-client
|
test: test-server test-client
|
||||||
|
|
||||||
test-server:
|
test-server:
|
||||||
cd server && cargo test
|
cd server && cargo nextest run
|
||||||
|
|
||||||
test-client:
|
test-client:
|
||||||
@echo "Client tests run via gdUnit4 inside Godot."
|
@echo "Client tests run via gdUnit4 inside Godot."
|
||||||
|
|||||||
Binary file not shown.
Generated
+1301
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "settled-reach-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[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"] }
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// 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.
|
||||||
|
///
|
||||||
|
/// TODO: Planned fields — fog/visibility data, ambient sound events,
|
||||||
|
/// internal monologue triggers, HUD state (D-020 expansion).
|
||||||
|
#[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 {
|
||||||
|
/// Wire-format entity identifier — NOT a bevy ECS Entity.
|
||||||
|
/// Stable across serialization for client-server boundary (D-020).
|
||||||
|
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,
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
// Single tick for smoke verification; real game loop in phase 2
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
tracing::info!("Simulation server update complete");
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// 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 {
|
||||||
|
/// Wire-format entity ID of the relationship target (scales to 10K+ NPCs)
|
||||||
|
pub target_id: u64,
|
||||||
|
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,
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// 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.
|
||||||
|
/// Inputs must be pushed in tick order for deterministic processing.
|
||||||
|
/// Panics in debug builds if tick ordering is violated.
|
||||||
|
pub fn push(&mut self, input: PlayerInput) {
|
||||||
|
debug_assert!(
|
||||||
|
self.queue.back().is_none_or(|last| last.tick <= input.tick),
|
||||||
|
"InputQueue: tick ordering violated (last={}, new={})",
|
||||||
|
self.queue.back().map_or(0, |last| last.tick),
|
||||||
|
input.tick,
|
||||||
|
);
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "tick ordering violated")]
|
||||||
|
fn push_rejects_out_of_order_in_debug() {
|
||||||
|
let mut queue = InputQueue::default();
|
||||||
|
queue.push(PlayerInput {
|
||||||
|
tick: 5,
|
||||||
|
action: PlayerAction::MoveNorth,
|
||||||
|
});
|
||||||
|
queue.push(PlayerInput {
|
||||||
|
tick: 2,
|
||||||
|
action: PlayerAction::MoveSouth,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,141 @@
|
|||||||
|
// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
// 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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn day_wraparound_at_midnight() {
|
||||||
|
// 1440 minutes = 1 full day, should wrap back to Morning
|
||||||
|
let time = SimulationTime {
|
||||||
|
tick: MINUTES_PER_DAY * TICKS_PER_GAME_MINUTE,
|
||||||
|
paused: false,
|
||||||
|
};
|
||||||
|
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,
|
||||||
|
paused: false,
|
||||||
|
};
|
||||||
|
assert_eq!(time.day(), 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
//! 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All PlayerAction variants must survive MessagePack round-trip (D-030 Layer 1)
|
||||||
|
#[test]
|
||||||
|
fn all_player_action_variants_roundtrip() {
|
||||||
|
let actions = vec![
|
||||||
|
PlayerAction::MoveNorth,
|
||||||
|
PlayerAction::MoveSouth,
|
||||||
|
PlayerAction::MoveEast,
|
||||||
|
PlayerAction::MoveWest,
|
||||||
|
PlayerAction::Interact,
|
||||||
|
PlayerAction::UsePerceptionMode("thermal".to_string()),
|
||||||
|
PlayerAction::Pause,
|
||||||
|
PlayerAction::Unpause,
|
||||||
|
];
|
||||||
|
|
||||||
|
for action in actions {
|
||||||
|
let input = PlayerInput {
|
||||||
|
tick: 1,
|
||||||
|
action: action.clone(),
|
||||||
|
};
|
||||||
|
let bytes = rmp_serde::to_vec(&input).expect("serialize");
|
||||||
|
let decoded: PlayerInput = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||||
|
assert_eq!(decoded.tick, 1);
|
||||||
|
// Verify the variant survived by re-serializing and comparing bytes
|
||||||
|
let re_bytes = rmp_serde::to_vec(&decoded).expect("re-serialize");
|
||||||
|
assert_eq!(bytes, re_bytes, "round-trip mismatch for action variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All EntityKind variants must survive MessagePack round-trip (D-030 Layer 1)
|
||||||
|
#[test]
|
||||||
|
fn all_entity_kind_variants_roundtrip() {
|
||||||
|
let kinds = vec![EntityKind::Npc, EntityKind::Object, EntityKind::Terrain];
|
||||||
|
|
||||||
|
for (i, kind) in kinds.into_iter().enumerate() {
|
||||||
|
let entity = VisibleEntity {
|
||||||
|
entity_id: i as u64,
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.0,
|
||||||
|
z: 0,
|
||||||
|
kind,
|
||||||
|
};
|
||||||
|
let snapshot = ObserverSnapshot {
|
||||||
|
tick: 0,
|
||||||
|
entities: vec![entity],
|
||||||
|
};
|
||||||
|
let bytes = rmp_serde::to_vec(&snapshot).expect("serialize");
|
||||||
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||||
|
let re_bytes = rmp_serde::to_vec(&decoded).expect("re-serialize");
|
||||||
|
assert_eq!(
|
||||||
|
bytes, re_bytes,
|
||||||
|
"round-trip mismatch for EntityKind variant"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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::time::SimulationTime;
|
||||||
|
use settled_reach_server::simulation::SimulationPlugin;
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user