diff --git a/Makefile b/Makefile index bd6ef6196..815817092 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null) pre-pr-server pre-pr-client pre-pr-content \ fixtures-client golden-diff golden-update \ checklist-validate checklist-generate \ - perf-baseline + perf-baseline debug-schedule # --- Configuration --- @@ -53,6 +53,7 @@ help: @echo " make pre-pr-content Content-scoped pre-PR (schema + cross-ref validation)" @echo "" @echo " make setup-hooks Install pre-commit hooks (included in setup)" + @echo " make debug-schedule Print bevy_ecs schedule graph (diff for PR artifacts)" @echo "" @echo " GODOT_VERSION=4.6 make setup Override Godot version" @@ -285,6 +286,12 @@ checklist-generate: perf-baseline: @tooling/perf-baseline +# --- Schedule debug (#346) --- + +debug-schedule: + @echo "Dumping bevy_ecs schedule graph..." + @cd server && cargo run -- --dump-schedule + content-ron: cd tooling/content-converter && cargo build --release tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack index 3b4999391..9095db5dd 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack index 56c001dcf..0c1f3fe03 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack index 6ae68786f..a5351b64b 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack index 0822dc9c3..605b81dd1 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack index 69657bc29..2f71b1cc1 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index 3b4999391..9095db5dd 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_empty.msgpack and b/client/tests/fixtures/msgpack/snapshot_empty.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index 673d30a77..4046cbbb7 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack and b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index 0d336a704..9c19045e1 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack and b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index d22a72532..32489d545 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_player.msgpack and b/client/tests/fixtures/msgpack/snapshot_player.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index 2aa4f71c8..cc84d8ae4 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack and b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack differ diff --git a/server/.clippy.toml b/server/.clippy.toml new file mode 100644 index 000000000..88f1e5b57 --- /dev/null +++ b/server/.clippy.toml @@ -0,0 +1,9 @@ +# Clippy configuration for settled-reach-server +# Enforces determinism-safe collection types in simulation code (D-030). + +# Ban std::collections::HashMap — non-deterministic iteration order breaks replay. +# Use BTreeMap (ordered by key) or IndexMap (insertion-ordered) instead. +disallowed-types = [ + { path = "std::collections::HashMap", reason = "HashMap iteration order is non-deterministic. Use BTreeMap or IndexMap for deterministic simulation." }, + { path = "std::collections::HashSet", reason = "HashSet iteration order is non-deterministic. Use BTreeSet or IndexSet." }, +] diff --git a/server/Cargo.lock b/server/Cargo.lock index accac7522..21c88b2a2 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1283,6 +1283,16 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.22" @@ -1293,12 +1303,15 @@ dependencies = [ "nu-ansi-term", "once_cell", "regex-automata", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] diff --git a/server/Cargo.toml b/server/Cargo.toml index bb360ef3f..09c48d56e 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -15,7 +15,7 @@ rand_chacha = "0.9" pathfinding = "4.11" thiserror = "2" tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } clap = { version = "4", features = ["derive"] } [features] diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index a56d70b48..baf887368 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -300,6 +300,7 @@ mod tests { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], + rng_seed: None, } } @@ -424,6 +425,7 @@ mod tests { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], + rng_seed: None, }; let text = format_snapshot_text(&snap); assert!(text.contains("Tick 0")); diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 4f83b46e4..93fd77650 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -28,7 +28,9 @@ pub const PROTOCOL_VERSION: u8 = 10; /// v7 adds: pending_recognitions (#423, D-060 cognitive delay). /// v8 adds: dialogue_response (#305, D-028 dialogue pipeline). /// v9 adds: blocked_entities (#514, debug field for LOS-blocked entities). -/// v10 adds: sound_events (#124, D-038 server sound event pipeline). +/// v10 adds: sound_events (#124, D-038 server sound event pipeline), +/// rng_seed (#527, deterministic replay — completes WRONG button loop). +/// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { /// Protocol version for forward compatibility. Current: 10. @@ -85,6 +87,11 @@ pub struct ObserverSnapshot { /// Empty when no sounds are in range. #[serde(default)] pub sound_events: Vec, + /// RNG seed active at this tick for deterministic replay (#527). + /// The WRONG button writes this to seed.txt so replays reproduce observed bugs. + /// None when the RNG resource is unavailable (should not occur in practice). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rng_seed: Option, } /// Game time data for client display (D-031) diff --git a/server/src/main.rs b/server/src/main.rs index ec5e85547..89e409881 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -2,9 +2,10 @@ // Entry point for standalone simulation binary // // Supports --test-mode for automated testing: -// --test-mode Enable test mode (fixed seed, LISTENING signal, quieter logs) -// --port Bind to specific port (0 = OS-assigned). Overrides positional addr. -// --seed RNG seed (default: 0, test-mode default: 42) +// --test-mode Enable test mode (fixed seed, LISTENING signal, quieter logs) +// --port Bind to specific port (0 = OS-assigned). Overrides positional addr. +// --seed RNG seed (default: 0, test-mode default: 42) +// --dump-schedule Print bevy_ecs schedule graph and exit (no TCP required) use bevy_app::prelude::*; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -16,6 +17,7 @@ use settled_reach_server::simulation::SimulationPlugin; fn main() { let args: Vec = std::env::args().collect(); let test_mode = args.iter().any(|a| a == "--test-mode"); + let dump_schedule = args.iter().any(|a| a == "--dump-schedule"); let port_flag = args .iter() @@ -31,18 +33,32 @@ fn main() { // Tracing: quieter in test mode, always to stderr so stdout stays clean // for the LISTENING:{port} handshake signal. + // CI=true → JSON format for structured log ingestion. + // RUST_LOG_FORMAT=json → same effect for local debugging. let default_filter = if test_mode { "settled_reach_server=warn" } else { "settled_reach_server=debug" }; - tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| default_filter.into()), - ) - .with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr)) - .init(); + let env_filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| default_filter.into()); + let use_json = + std::env::var("CI").is_ok() || std::env::var("RUST_LOG_FORMAT").as_deref() == Ok("json"); + if use_json { + tracing_subscriber::registry() + .with(env_filter) + .with( + tracing_subscriber::fmt::layer() + .json() + .with_writer(std::io::stderr), + ) + .init(); + } else { + tracing_subscriber::registry() + .with(env_filter) + .with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr)) + .init(); + } // Resolve bind address. // --port flag overrides everything (most common in test mode). @@ -76,6 +92,13 @@ fn main() { .unwrap_or_else(|| "127.0.0.1:9876".to_string()) }; + // --dump-schedule: print bevy_ecs schedule graph and exit (no TCP required). + // Useful for PR artifacts and detecting unintended system reordering (#346). + if dump_schedule { + dump_schedule_graph(); + return; + } + // Bind FIRST, print port, THEN accept. // Critical for --port 0: the OS assigns a random port at bind time. // The LISTENING:{port} line is the handshake signal for the test client. @@ -147,6 +170,12 @@ fn main() { } let elapsed = frame_start.elapsed(); + tracing::debug!( + tick_ms = elapsed.as_millis(), + budget_ms = target_frame_time.as_millis(), + over_budget = elapsed > target_frame_time, + "tick" + ); if elapsed < target_frame_time { std::thread::sleep(target_frame_time - elapsed); } @@ -155,6 +184,50 @@ fn main() { tracing::info!("Simulation server shutting down"); } +/// Print bevy_ecs schedule graph and exit. +/// Invoked by --dump-schedule CLI flag (#346). +/// +/// Builds the full app with all plugins (no TCP bridge or world entities), +/// then prints each registered schedule and its system count to stdout. +/// Systems are counted from the registered (pre-initialization) graph, so +/// counts reflect what was registered by plugins. +/// +/// CI integration: run on each PR via `make debug-schedule`, diff output +/// against a committed baseline to catch unintended system reordering. +fn dump_schedule_graph() { + use bevy_ecs::schedule::Schedules; + + let mut app = App::new(); + app.add_plugins(SimulationPlugin); + app.add_plugins(BridgePlugin); + app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin); + app.add_plugins(settled_reach_server::npc::NpcPlugin); + app.add_plugins(settled_reach_server::content::ContentPlugin); + app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(0)); + + // Access Schedules resource directly — schedules are populated by plugins + // via add_systems() before any tick runs. No app.update() needed here: + // running a tick would require full world setup (WalkabilityMap, etc.) that + // isn't needed for schedule inspection. + let world = app.world(); + let schedules = world.resource::(); + + println!("=== Schedule Graph (settled-reach-server) ==="); + let mut entries: Vec = schedules + .iter() + .map(|(label, schedule)| format!(" {:?} [{} systems]", label, schedule.systems_len())) + .collect(); + entries.sort(); // deterministic output for baseline diffs + let schedule_count = entries.len(); + for entry in &entries { + println!("{}", entry); + } + println!("=== {} schedules total ===", schedule_count); + println!(); + println!("Note: use RUST_LOG=trace with the live server for per-tick timing."); + println!(" system names visible with `cargo build --features bevy/debug`."); +} + /// Proof room: 32x32 map, wall at (16,14), player at (16,16), 3 NPCs. /// Extracted from the original inline setup for reuse by both test-mode and normal mode. fn setup_proof_room(app: &mut App) { diff --git a/server/src/perception/anomaly.rs b/server/src/perception/anomaly.rs index bea13b139..415e05fb7 100644 --- a/server/src/perception/anomaly.rs +++ b/server/src/perception/anomaly.rs @@ -40,6 +40,7 @@ pub fn clear_anomaly_markers(mut commands: Commands, markers: Query>, diff --git a/server/src/perception/cognitive_delay.rs b/server/src/perception/cognitive_delay.rs index 14517ca7b..faaba4fc9 100644 --- a/server/src/perception/cognitive_delay.rs +++ b/server/src/perception/cognitive_delay.rs @@ -148,6 +148,7 @@ impl CognitiveDelay { /// Expired recognitions are converted to DirectObservation KnowledgeEvents. /// /// System ordering: after emit_observation_events, before process_knowledge_events. +#[tracing::instrument(level = "debug", skip_all)] pub fn process_cognitive_delay( time: Res, mut query: Query<(Entity, &mut CognitiveDelay)>, diff --git a/server/src/perception/interpretation.rs b/server/src/perception/interpretation.rs index 95fd52300..0e8ad310a 100644 --- a/server/src/perception/interpretation.rs +++ b/server/src/perception/interpretation.rs @@ -3,6 +3,10 @@ //! Interprets what the observer sees (and doesn't see) against known NPC //! routines and knowledge graph state. Produces high-level observation events //! that drive monologue and investigation triggers. +//! +//! Note: HashSet is used as a per-frame lookup table (visible tiles/NPCs). +//! Only membership checks — iteration order is irrelevant. Not simulation state. +#![allow(clippy::disallowed_types)] use bevy_ecs::prelude::*; diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index 2fe1619ad..aadd251df 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -22,6 +22,7 @@ use crate::simulation::interaction::NearbyInteractionBuffer; use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName}; use crate::simulation::monologue::{MonologueBuffer, SprintAnomalyQueue}; use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; +use crate::simulation::rng::SimRng; use crate::simulation::sound::SoundEventQueue; use crate::simulation::stance::Stance; use crate::simulation::time::SimulationTime; @@ -30,6 +31,7 @@ use crate::simulation::time::SimulationTime; /// Stage 1 of the observer pipeline: FOV + vision cone → VisibilityGeometry. /// /// System ordering: after validate_movement, before compute_observer_snapshot. +#[tracing::instrument(level = "debug", skip_all)] pub fn compute_visibility_geometry( walkability: Res, mode: Res, @@ -53,7 +55,8 @@ pub fn compute_visibility_geometry( /// /// System ordering: after compute_visibility_geometry + compute_nearby_interactions, /// before advance_tick. -#[allow(clippy::type_complexity)] +#[tracing::instrument(level = "debug", skip_all)] +#[allow(clippy::type_complexity, clippy::too_many_arguments)] pub fn compute_observer_snapshot( time: Res, geometry: Res, @@ -85,6 +88,7 @@ pub fn compute_observer_snapshot( )>, inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, mut buffer: ResMut, + sim_rng: Option>, ) { let Ok(( observer_entity, @@ -233,6 +237,7 @@ pub fn compute_observer_snapshot( blocked_entities, scan_events, sound_events, + rng_seed: sim_rng.as_deref().map(|r| r.seed()), }); } diff --git a/server/src/perception/query.rs b/server/src/perception/query.rs index 9789f884d..af6057345 100644 --- a/server/src/perception/query.rs +++ b/server/src/perception/query.rs @@ -4,6 +4,11 @@ //! (natural vision, thermal, EM, etc.) implements PerceptionQuery to //! provide mode-specific FOV and visibility sector computation. //! v0.1 implements only NaturalVision. +//! +//! Note: HashMap is used for `sector_lookup` — a per-frame scratch buffer +//! looked up only by key. Iteration order is irrelevant here. Not subject to +//! the simulation determinism constraint (see server/.clippy.toml). +#![allow(clippy::disallowed_types)] use std::collections::{BTreeSet, HashMap}; diff --git a/server/src/perception/shadowcast.rs b/server/src/perception/shadowcast.rs index e89659e05..2b1b46155 100644 --- a/server/src/perception/shadowcast.rs +++ b/server/src/perception/shadowcast.rs @@ -9,6 +9,12 @@ //! References: //! - Symmetric: https://www.albertford.com/shadowcasting/ //! - Traditional: RogueBasin recursive shadowcasting +//! +//! Note: HashSet is used here as a per-frame scratch accumulator for visible +//! tile positions during the FOV sweep. Only `insert` and `contains` are used; +//! iteration order never affects the output (results are handed to BTreeSet in +//! query.rs). Not simulation state — exempt from the determinism constraint. +#![allow(clippy::disallowed_types)] use std::collections::HashSet; diff --git a/server/src/simulation/contraband.rs b/server/src/simulation/contraband.rs index aa3fc9799..a772b6b45 100644 --- a/server/src/simulation/contraband.rs +++ b/server/src/simulation/contraband.rs @@ -80,7 +80,10 @@ impl ScanEventBuffer { pub fn check_contraband_scan( time: Res, registry: Res, - mut npc_query: Query<(Entity, &TilePosition, &mut KnowledgeGraph), (With, With)>, + mut npc_query: Query< + (Entity, &TilePosition, &mut KnowledgeGraph), + (With, With), + >, mut player_query: Query<(Entity, &TilePosition, &mut ScanEventBuffer), With>, items_query: Query<(&CarriedBy, Option<&Contraband>)>, ) { @@ -423,7 +426,10 @@ mod tests { let mut buffer = world.get_mut::(player).unwrap(); let events = buffer.take(); - assert!(events.is_empty(), "NPC without ScanAuthority should not scan"); + assert!( + events.is_empty(), + "NPC without ScanAuthority should not scan" + ); } #[test] @@ -461,12 +467,7 @@ mod tests { ); let npc = world - .spawn(( - Npc, - TilePosition::new(5, 6, 0), - npc_kg, - ScanAuthority, - )) + .spawn((Npc, TilePosition::new(5, 6, 0), npc_kg, ScanAuthority)) .id(); world.resource_mut::().register(npc); @@ -477,12 +478,19 @@ mod tests { // NPC already knew — KG should not be re-written (fact tick stays 0) let npc_kg = world.get::(npc).unwrap(); let fact = npc_kg.facts.get(&fact_id).unwrap(); - assert_eq!(fact.acquired_tick, 0, "should not overwrite existing knowledge"); + assert_eq!( + fact.acquired_tick, 0, + "should not overwrite existing knowledge" + ); // Scan event should still fire even though NPC already knew let mut buffer = world.get_mut::(player).unwrap(); let events = buffer.take(); - assert_eq!(events.len(), 1, "scan event should emit even for already-known contraband"); + assert_eq!( + events.len(), + 1, + "scan event should emit even for already-known contraband" + ); assert!(events[0].detected_contraband); } @@ -542,7 +550,11 @@ mod tests { // Both should emit separate scan events let mut buffer = world.get_mut::(player).unwrap(); let events = buffer.take(); - assert_eq!(events.len(), 2, "each ScanAuthority NPC should emit a scan event"); + assert_eq!( + events.len(), + 2, + "each ScanAuthority NPC should emit a scan event" + ); assert!(events.iter().all(|e| e.detected_contraband)); } diff --git a/server/src/simulation/dialogue.rs b/server/src/simulation/dialogue.rs index de7919052..ff3603fe1 100644 --- a/server/src/simulation/dialogue.rs +++ b/server/src/simulation/dialogue.rs @@ -179,6 +179,7 @@ pub fn available_access_tiers(relationship: RelationshipState) -> Vec( /// line to DialogueResponseBuffer. /// /// System ordering: after process_player_input, before compute_observer_snapshot. +#[tracing::instrument(level = "debug", skip_all)] #[allow(clippy::type_complexity, clippy::too_many_arguments)] pub fn process_talk_interaction( mut commands: Commands, diff --git a/server/src/simulation/interaction.rs b/server/src/simulation/interaction.rs index 5c0025256..1116c8ca7 100644 --- a/server/src/simulation/interaction.rs +++ b/server/src/simulation/interaction.rs @@ -164,6 +164,7 @@ impl ObjectType { /// priority adjustment (e.g. POI -> Observe first) is applied by the observer /// system after taking the buffer. This keeps the simulation phase free of /// knowledge graph dependencies (D-010 phase boundary). +#[tracing::instrument(level = "debug", skip_all)] #[allow(clippy::type_complexity)] pub fn compute_nearby_interactions( mut player_query: Query< diff --git a/server/src/simulation/monologue.rs b/server/src/simulation/monologue.rs index 38276dd27..4ac939283 100644 --- a/server/src/simulation/monologue.rs +++ b/server/src/simulation/monologue.rs @@ -8,7 +8,7 @@ // When sprinting past a Contradicted entity, a delayed "double-take" monologue // fires retroactively. Detection in observer pipeline, processing here. -use std::collections::HashSet; +use std::collections::BTreeSet; use bevy_ecs::prelude::*; use rand::Rng; @@ -77,7 +77,7 @@ pub struct MonologueState { /// Whether the enter_location monologue has fired this session. pub entered: bool, /// IDs of lines already shown (dedup within session). - pub shown_ids: HashSet, + pub shown_ids: BTreeSet, /// Character type for pool filtering. v0.1: always "detective". pub character: String, } @@ -89,7 +89,7 @@ impl Default for MonologueState { last_position: None, idle_ticks: 0, entered: false, - shown_ids: HashSet::new(), + shown_ids: BTreeSet::new(), // v0.1: default to detective; character selection sets this character: "detective".to_string(), } diff --git a/server/src/simulation/movement.rs b/server/src/simulation/movement.rs index 2c1ca9407..1ca9111eb 100644 --- a/server/src/simulation/movement.rs +++ b/server/src/simulation/movement.rs @@ -6,7 +6,7 @@ use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::BTreeMap; use crate::bridge::types::MovementStance; use crate::knowledge::types::SoundRange; @@ -27,7 +27,20 @@ pub struct PlayerCharacter; /// /// Examples: a Standing character can walk past a Seated NPC at a console, /// a Fixture (terminal) shares a tile with someone Seated at it. -#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +#[derive( + Component, + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + PartialOrd, + Ord, + Default, + Serialize, + Deserialize, +)] pub enum TilePresence { /// Upright position — walking, standing, sprinting. Default for all entities. #[default] @@ -44,7 +57,9 @@ pub enum TilePresence { /// Tile position component for grid-based movement. /// Discrete integer coordinates used in simulation; converted to f32 /// at the bridge boundary for VisibleEntity wire format. -#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive( + Component, Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, +)] pub struct TilePosition { pub x: i32, pub y: i32, @@ -121,7 +136,7 @@ impl TilePosition { } /// Chunk coordinate for chunk-based map storage (D-012). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct ChunkCoord { pub cx: i32, pub cy: i32, @@ -166,14 +181,14 @@ impl ChunkData { /// Unloaded chunks are treated as unwalkable. #[derive(Resource, Debug, Clone)] pub struct WalkabilityMap { - chunks: HashMap, + chunks: BTreeMap, } impl WalkabilityMap { /// Create a walkability map covering a rectangular area with all tiles walkable. /// Generates chunks to cover the specified dimensions on z-level 0..z_levels. pub fn new(width: i32, height: i32, z_levels: i32) -> Self { - let mut chunks = HashMap::new(); + let mut chunks = BTreeMap::new(); let cx_max = (width + CHUNK_SIZE - 1) / CHUNK_SIZE; let cy_max = (height + CHUNK_SIZE - 1) / CHUNK_SIZE; for z in 0..z_levels { @@ -188,7 +203,7 @@ impl WalkabilityMap { /// Create a walkability map covering a rectangular area with all tiles blocked. pub fn new_blocked(width: i32, height: i32, z_levels: i32) -> Self { - let mut chunks = HashMap::new(); + let mut chunks = BTreeMap::new(); let cx_max = (width + CHUNK_SIZE - 1) / CHUNK_SIZE; let cy_max = (height + CHUNK_SIZE - 1) / CHUNK_SIZE; for z in 0..z_levels { @@ -263,6 +278,7 @@ pub struct MoveIntent { /// entities without intents, then resolve movers in order — first valid claim /// to a layer slot wins. /// Always removes MoveIntent component after processing. +#[tracing::instrument(level = "debug", skip_all)] pub fn validate_movement( mut commands: Commands, walkability: Option>, @@ -285,7 +301,7 @@ pub fn validate_movement( // Collect layer slots occupied by stationary entities (no MoveIntent). // Key: (position, layer) — two entities can share a tile if different layers. - let mut occupied: HashMap<(TilePosition, TilePresence), Entity> = HashMap::new(); + let mut occupied: BTreeMap<(TilePosition, TilePresence), Entity> = BTreeMap::new(); for (entity, pos, presence) in stationary.iter() { let layer = presence.copied().unwrap_or_default(); occupied.insert((*pos, layer), entity); diff --git a/server/src/simulation/path_follow.rs b/server/src/simulation/path_follow.rs index 9e156a2fa..39702a655 100644 --- a/server/src/simulation/path_follow.rs +++ b/server/src/simulation/path_follow.rs @@ -53,6 +53,7 @@ impl MovementSpeed { /// Scoped to `ActiveSim` NPCs — only entities in the Active tier execute /// path movement each tick (D-026, #94). Background/StateSaved NPCs do not /// process path steps. +#[tracing::instrument(level = "debug", skip_all)] pub fn follow_paths( mut commands: Commands, mut query: Query<(Entity, &mut ComputedPath, Option<&mut MovementSpeed>), (With, With)>, diff --git a/server/src/simulation/pathfinding.rs b/server/src/simulation/pathfinding.rs index cc1b1a365..51cd71ab6 100644 --- a/server/src/simulation/pathfinding.rs +++ b/server/src/simulation/pathfinding.rs @@ -58,6 +58,7 @@ pub struct PathBlocked; /// Cardinal-only is a deliberate v0.1 simplification: diagonal movement /// would require √2 cost handling and diagonal wall-clipping checks. /// Removes PathRequest and inserts ComputedPath or PathBlocked. +#[tracing::instrument(level = "debug", skip_all)] pub fn compute_paths( mut commands: Commands, walkability: Option>, diff --git a/server/src/test_world/mod.rs b/server/src/test_world/mod.rs index 6cebdc9ba..55eac4c93 100644 --- a/server/src/test_world/mod.rs +++ b/server/src/test_world/mod.rs @@ -52,13 +52,13 @@ use crate::perception::cognitive_delay::CognitiveDelay; #[cfg(feature = "gauntlet")] use crate::perception::vision_cone::Facing; #[cfg(feature = "gauntlet")] +use crate::simulation::contraband::ScanEventBuffer; +#[cfg(feature = "gauntlet")] use crate::simulation::interaction::{Interactable, NearbyInteractionBuffer}; #[cfg(feature = "gauntlet")] use crate::simulation::inventory::ItemName; #[cfg(feature = "gauntlet")] use crate::simulation::listening::ListeningFocus; -#[cfg(feature = "gauntlet")] -use crate::simulation::contraband::ScanEventBuffer; use crate::simulation::monologue::{MonologueBuffer, MonologueState, SprintAnomalyQueue}; #[cfg(feature = "gauntlet")] use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index 6bbf5a3a0..238560d35 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -62,6 +62,7 @@ fn snapshot_roundtrip_over_unix_socket() { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], + rng_seed: None, }; bridge diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 95a62e734..3e3d8377b 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -48,6 +48,7 @@ fn snapshot_roundtrip_over_tcp() { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], + rng_seed: None, }; bridge diff --git a/server/tests/content_scaling.rs b/server/tests/content_scaling.rs index e1acf2b9e..90fec761f 100644 --- a/server/tests/content_scaling.rs +++ b/server/tests/content_scaling.rs @@ -21,7 +21,9 @@ use std::time::Instant; use settled_reach_server::bridge::types::*; use settled_reach_server::bridge::BridgePlugin; use settled_reach_server::knowledge::registry::{EntityRegistry, StableEntityId}; -use settled_reach_server::knowledge::{KnowledgeConfidence, KnowledgeGraph, KnowledgePlugin, StableId}; +use settled_reach_server::knowledge::{ + KnowledgeConfidence, KnowledgeGraph, KnowledgePlugin, StableId, +}; use settled_reach_server::npc::{Contentment, Npc, NpcPlugin, ToleranceThreshold, Want, WantKind}; use settled_reach_server::simulation::interaction::Interactable; use settled_reach_server::simulation::movement::TilePosition; @@ -462,10 +464,8 @@ fn max_npc_pack_behavioral_regression() { .clone(); let stress_kg = player_kg_snapshot(&stress_app, max_gauntlet_id); - let baseline_snap = baseline_snapshot - .expect("baseline Gauntlet should produce a snapshot"); - let stress_snap = stress_snapshot - .expect("80-NPC stress run should produce a snapshot"); + let baseline_snap = baseline_snapshot.expect("baseline Gauntlet should produce a snapshot"); + let stress_snap = stress_snapshot.expect("80-NPC stress run should produce a snapshot"); // Tick index must match (same number of updates). assert_eq!( diff --git a/server/tests/cross_room_transitions.rs b/server/tests/cross_room_transitions.rs index 983462d75..c4b086a40 100644 --- a/server/tests/cross_room_transitions.rs +++ b/server/tests/cross_room_transitions.rs @@ -349,10 +349,7 @@ fn t4_knowledge_graph_survives_room_transition() { "T4 post: KG entry must persist after player moves to Hub" ); assert_eq!( - world - .get::(player) - .unwrap() - .entity_count(), + world.get::(player).unwrap().entity_count(), 1, "T4 post: exactly 1 KG entry after room transition" ); @@ -542,7 +539,10 @@ fn t7_confrontation_verb_disappears_on_retreat_beyond_mid_range() { "T7 close: NPC at distance 2 must appear in interaction buffer" ); assert!( - interactions[0].verbs.iter().any(|v| v.kind == VerbKind::Talk), + interactions[0] + .verbs + .iter() + .any(|v| v.kind == VerbKind::Talk), "T7 close: Talk must be available at CLOSE_RANGE (confrontation possible)" ); @@ -599,7 +599,10 @@ fn t8_sprint_blocks_eavesdrop_then_careful_enables_accumulation() { run_listening_system(&mut world); } assert_eq!( - world.get::(player).unwrap().stationary_ticks, + world + .get::(player) + .unwrap() + .stationary_ticks, 0, "T8 sprint: Sprint must block stationary_ticks (50 ticks at sprint, still 0)" ); @@ -613,7 +616,10 @@ fn t8_sprint_blocks_eavesdrop_then_careful_enables_accumulation() { // resets stationary_ticks to 0, and updates last_position to alcove_pos. run_listening_system(&mut world); assert_eq!( - world.get::(player).unwrap().stationary_ticks, + world + .get::(player) + .unwrap() + .stationary_ticks, 0, "T8 transition: movement tick must reset stationary_ticks to 0" ); diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index d621d05bc..f82126f3c 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -38,6 +38,7 @@ fn fixture_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot blocked_entities: vec![], scan_events: vec![], sound_events: vec![], + rng_seed: None, } } @@ -210,6 +211,7 @@ fn generate_msgpack_fixtures() { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], + rng_seed: None, }; write_fixture( "snapshot_v2_full", diff --git a/server/tests/golden/proof_room_tick_10.json b/server/tests/golden/proof_room_tick_10.json index b22c54827..63153764b 100644 --- a/server/tests/golden/proof_room_tick_10.json +++ b/server/tests/golden/proof_room_tick_10.json @@ -64,6 +64,7 @@ "player_facing": "North", "player_inventory": [], "player_stance": "Sprint", + "rng_seed": 42, "scan_events": [], "sound_events": [], "tick": 8, diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 677cd29df..3c9bcfdcc 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -27,6 +27,7 @@ fn test_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], + rng_seed: None, } } @@ -256,6 +257,7 @@ fn snapshot_v2_fields_roundtrip() { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], + rng_seed: None, }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); @@ -350,7 +352,11 @@ fn all_facing_direction_variants_roundtrip() { dialogue_response: None, blocked_entities: vec![], scan_events: vec![], +<<<<<<< HEAD sound_events: vec![], +======= + rng_seed: None, +>>>>>>> origin/ci }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); @@ -1187,6 +1193,80 @@ fn v8_payload_deserializes_into_v9_struct() { ); } +/// rng_seed round-trips through MessagePack (#527). +/// Verifies Some(seed) survives the wire and None is omitted. +#[test] +fn rng_seed_roundtrip() { + let mut snapshot = test_snapshot(0, vec![]); + snapshot.rng_seed = Some(123456789); + let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!(decoded.rng_seed, Some(123456789)); + + // None case: skip_serializing_if omits the field, default restores it + let mut snapshot_none = test_snapshot(0, vec![]); + snapshot_none.rng_seed = None; + let bytes_none = rmp_serde::to_vec_named(&snapshot_none).expect("serialize"); + let decoded_none: ObserverSnapshot = rmp_serde::from_slice(&bytes_none).expect("deserialize"); + assert_eq!(decoded_none.rng_seed, None); +} + +/// v9 payloads (without rng_seed) must deserialize into the v10 struct +/// via #[serde(default)]. Guards backwards compat during migration (#527). +#[test] +fn v9_payload_deserializes_into_v10_struct() { + #[derive(serde::Serialize)] + struct ObserverSnapshotV9 { + version: u8, + tick: u64, + game_time: GameTime, + player_facing: FacingDirection, + player_stance: MovementStance, + player_inventory: Vec, + entities: Vec, + visible_tiles: Vec, + nearby_interactions: Vec, + current_monologue: Option, + pending_recognitions: Vec, + dialogue_response: Option, + blocked_entities: Vec, + scan_events: Vec, + } + + let v9 = ObserverSnapshotV9 { + version: 9, + tick: 200, + game_time: GameTime { + day: 0, + time_of_day: 0, + day_phase: DayPhase::Morning, + tick_rate: TickRate::Full, + }, + player_facing: FacingDirection::North, + player_stance: MovementStance::Walk, + player_inventory: vec![], + entities: vec![], + visible_tiles: vec![], + nearby_interactions: vec![], + current_monologue: None, + pending_recognitions: vec![], + dialogue_response: None, + blocked_entities: vec![], + scan_events: vec![], + }; + + let bytes = rmp_serde::to_vec_named(&v9).expect("serialize v9"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes) + .expect("v9 payload should deserialize into v10 struct via serde(default)"); + + assert_eq!(decoded.version, 9, "version field preserved from v9"); + assert_eq!(decoded.tick, 200); + assert_eq!( + decoded.rng_seed, None, + "missing rng_seed should default to None" + ); +} + /// NearbyInteraction.object_type round-trips through MessagePack (#422). /// Verifies object_type=Some(Container) survives the wire. #[test]