From 0f5a990f8212f6037c2993cb56cf943c290f35b2 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 13:34:04 +0100 Subject: [PATCH 1/7] feat(ci): ban HashMap in simulation crate via clippy (#343) Add clippy::disallowed_types for std::collections::HashMap scoped to the simulation crate. Replace HashMap with BTreeMap in movement.rs for deterministic iteration order. Allow exception in perception/query.rs where iteration order is irrelevant (per-frame scratch buffer). Co-Authored-By: Claude Opus 4.6 --- server/.clippy.toml | 9 +++++++++ server/src/perception/query.rs | 5 +++++ server/src/simulation/movement.rs | 19 +++++++++++-------- 3 files changed, 25 insertions(+), 8 deletions(-) create mode 100644 server/.clippy.toml 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/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/simulation/movement.rs b/server/src/simulation/movement.rs index 5fd5e7aec..027ffa166 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; /// Chunk size in tiles (32x32 per chunk) pub const CHUNK_SIZE: i32 = 32; @@ -22,7 +22,10 @@ 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] @@ -39,7 +42,7 @@ 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, @@ -116,7 +119,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, @@ -161,14 +164,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 { @@ -183,7 +186,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 { @@ -279,7 +282,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); From 9ea9fd2a7410529a5b68e1cab08c6ac84b41ecc1 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 13:34:14 +0100 Subject: [PATCH 2/7] feat(engine): add rng_seed to ObserverSnapshot for deterministic replay (#527) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add rng_seed: Option to ObserverSnapshot (protocol v10). Populated from SimRng state each tick. Completes the WRONG button capture loop — seed.txt now writes a valid u64 instead of "unavailable", enabling deterministic replay from bug reports. Co-Authored-By: Claude Opus 4.6 --- server/src/bridge/text_renderer.rs | 2 ++ server/src/bridge/types.rs | 8 +++++++- server/src/perception/observer/mod.rs | 3 +++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index 79d746029..ea0929978 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -299,6 +299,7 @@ mod tests { dialogue_response: None, blocked_entities: vec![], scan_events: vec![], + rng_seed: None, } } @@ -422,6 +423,7 @@ mod tests { dialogue_response: None, blocked_entities: vec![], scan_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 dee8ed37d..7395e0505 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -15,7 +15,7 @@ pub use crate::simulation::time::{DayPhase, TickRate}; /// negotiation is unnecessary. Client should reject snapshots with version != /// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration /// period, then the default is removed once both sides are updated. -pub const PROTOCOL_VERSION: u8 = 9; +pub const PROTOCOL_VERSION: u8 = 10; /// The ONLY data structure crossing the client-server boundary (D-020) /// Contains all information visible to the observer at a given tick. @@ -28,6 +28,7 @@ pub const PROTOCOL_VERSION: u8 = 9; /// 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: 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 { @@ -79,6 +80,11 @@ pub struct ObserverSnapshot { /// Sorted ascending for deterministic output. Client can safely ignore. #[serde(default)] pub blocked_entities: 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/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index aa7887317..1b196310d 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -21,6 +21,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::stance::Stance; use crate::simulation::time::SimulationTime; @@ -81,6 +82,7 @@ pub fn compute_observer_snapshot( )>, inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, mut buffer: ResMut, + sim_rng: Option>, ) { let Ok(( observer_entity, @@ -215,6 +217,7 @@ pub fn compute_observer_snapshot( dialogue_response, blocked_entities, scan_events, + rng_seed: sim_rng.as_deref().map(|r| r.seed()), }); } From 3248603838dcffe5816e3753295079f152575518 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 14:14:07 +0100 Subject: [PATCH 3/7] feat(simulation): ban HashMap via clippy disallowed_types, fix violations (#343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds server/.clippy.toml with disallowed-types for std::collections::HashMap and std::collections::HashSet. HashMap iteration order is non-deterministic and breaks deterministic simulation replay (D-030). Changes: - server/.clippy.toml: disallow HashMap and HashSet crate-wide - simulation/movement.rs: WalkabilityMap.chunks and occupied map → BTreeMap; add PartialOrd+Ord to ChunkCoord, TilePosition, TilePresence - simulation/monologue.rs: MonologueState.shown_ids → BTreeSet (simulation state) - perception/shadowcast.rs: #![allow] — per-frame FOV scratch, iteration irrelevant - perception/interpretation.rs: #![allow] — per-frame lookup table, key-only access - perception/query.rs: #![allow] — sector_lookup is a per-frame read-only cache Also applies cargo fmt to pre-existing format drift in contraband.rs, dialogue.rs, test_world/mod.rs, and several integration tests. Co-Authored-By: Claude Sonnet 4.6 --- server/src/perception/interpretation.rs | 4 +++ server/src/perception/shadowcast.rs | 6 +++++ server/src/simulation/contraband.rs | 34 +++++++++++++++++-------- server/src/simulation/dialogue.rs | 1 + server/src/simulation/monologue.rs | 6 ++--- server/src/simulation/movement.rs | 16 ++++++++++-- server/src/test_world/mod.rs | 4 +-- server/tests/content_scaling.rs | 10 ++++---- server/tests/cross_room_transitions.rs | 20 ++++++++++----- 9 files changed, 71 insertions(+), 30 deletions(-) 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/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..c9b9366d3 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, + 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 027ffa166..a0c38aa68 100644 --- a/server/src/simulation/movement.rs +++ b/server/src/simulation/movement.rs @@ -23,7 +23,17 @@ 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, PartialOrd, Ord, Default, Serialize, + Component, + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + PartialOrd, + Ord, + Default, + Serialize, Deserialize, )] pub enum TilePresence { @@ -42,7 +52,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, PartialOrd, Ord, Serialize, Deserialize)] +#[derive( + Component, Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, +)] pub struct TilePosition { pub x: i32, pub y: i32, 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/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" ); From 6a7dc915ded80953aee722ced4c7db70fadbdf72 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 14:15:34 +0100 Subject: [PATCH 4/7] feat(bridge): add rng_seed to ObserverSnapshot for deterministic replay (#527) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds rng_seed: Option to ObserverSnapshot. The WRONG button (#507) captures inputs.jsonl and seed.txt for replay, but seed.txt was writing "unavailable" because the server did not include the RNG seed in ObserverSnapshot. Changes: - bridge/types.rs: PROTOCOL_VERSION 9→10, rng_seed field with serde(default, skip_serializing_if = "Option::is_none") for backward compatibility - perception/observer/mod.rs: inject Res into compute_observer_snapshot, populate rng_seed: Some(rng.seed()) each tick - All test files: add rng_seed: None to ObserverSnapshot constructors - tests/serialization.rs: bump protocol_version_constant assertion 9→10 - Regenerate msgpack fixtures and golden file for protocol v10 Completes the WRONG button capture loop: replays can now fully reproduce observed bugs with the exact RNG seed from the capture. Co-Authored-By: Claude Sonnet 4.6 --- .../msgpack/snapshot_boundary_tick_0.msgpack | Bin 259 -> 272 bytes .../msgpack/snapshot_boundary_tick_127.msgpack | Bin 259 -> 272 bytes .../snapshot_boundary_tick_2b31m1.msgpack | Bin 263 -> 276 bytes .../msgpack/snapshot_boundary_tick_2b32.msgpack | Bin 267 -> 280 bytes .../snapshot_boundary_tick_32767.msgpack | Bin 261 -> 274 bytes .../fixtures/msgpack/snapshot_empty.msgpack | Bin 259 -> 272 bytes .../msgpack/snapshot_multi_entity.msgpack | Bin 664 -> 677 bytes .../fixtures/msgpack/snapshot_one_npc.msgpack | Bin 357 -> 370 bytes .../fixtures/msgpack/snapshot_player.msgpack | Bin 360 -> 373 bytes .../fixtures/msgpack/snapshot_v2_full.msgpack | Bin 506 -> 519 bytes server/src/perception/observer/mod.rs | 2 +- server/tests/bridge_ipc.rs | 1 + server/tests/bridge_tcp.rs | 1 + server/tests/gen_fixtures.rs | 2 ++ server/tests/golden/proof_room_tick_10.json | 3 ++- server/tests/serialization.rs | 5 ++++- 16 files changed, 11 insertions(+), 3 deletions(-) diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack index 3700cbd9c29a8d4ad3b455b2fe77fe1c3077aa25..9095db5ddc85f662e9dd8313ad8845a7202ca90d 100644 GIT binary patch delta 50 zcmZo>n!v=>x4bO1s5mn}k82{=bNSW9$%%RKsb#5oCB+jqB<18MXQ!sb19_PxnW@DS E0Q(FSv;Y7A delta 37 scmbQh)Xc=yySyy5s5mn}k8>i|bD<4MIr+)isVVWPc_o=8nW@DS01T=Q(EtDd diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack index 8f350a9506d8f002f67a02d51b3d72ea3cebc25e..0c1f3fe032ac3c5b2340532a7dd0fc8233c2564b 100644 GIT binary patch delta 50 zcmZo>n!v=>x4bO1s5mn}k82{=bNSW9$%%RKsb#5oCB+jqB<18MXQ!sb19_PxnW@DS E0Q(FSv;Y7A delta 37 scmbQh)Xc=yySyy5s5mn}k8>i|bD<4MIr+)isVVWPc_o=8nW@DS01T=Q(EtDd diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack index 829fc4dabb266abdcd641c306f2cf934b256b992..a5351b64b24a166427acb5383210cb4de322fbfd 100644 GIT binary patch delta 50 zcmZo?n!?1@x4bO1s5mn}k82{=Yx&j1$%%RKsb#5oCB+jqB<18MXQ!sb19_PxnW@DS E0R0#gzyJUM delta 37 scmbQj)Xv1!ySyy5s5mn}k8>i|YoQHEIr+)isVVWPc_o=8nW@DS01g@s-2eap diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack index 8723189c6b95e0586a8884f66f41a6948755c022..605b81dd13716cb344dbef0910d91fb71d3a031e 100644 GIT binary patch delta 50 zcmeBXn!&`?x4bO1s5mn}k82{=d->JH$%%RKsb#5oCB+jqB<18MXQ!sb19_PxnW@DS E0RJQu%m4rY delta 37 scmbQi)Xl`zySyy5s5mn}k8>i|d!Y?UIr+)isVVWPc_o=8nW@DS01t`|=>Px# diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack index 25a2615afb7c4683aba49befe4e3724fa2657cc3..2f71b1cc1b610141f56ee387345d5f06864ef143 100644 GIT binary patch delta 50 zcmZo=n#9D_x4bO1s5mn}k82{=OZnBs$%%RKsb#5oCB+jqB<18MXQ!sb19_PxnW@DS E0Q?dZx&QzG delta 37 scmbQl)XK!wySyy5s5mn}k8>i|OQ8)(Ir+)isVVWPc_o=8nW@DS01aXe*8l(j diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index 3700cbd9c29a8d4ad3b455b2fe77fe1c3077aa25..9095db5ddc85f662e9dd8313ad8845a7202ca90d 100644 GIT binary patch delta 50 zcmZo>n!v=>x4bO1s5mn}k82{=bNSW9$%%RKsb#5oCB+jqB<18MXQ!sb19_PxnW@DS E0Q(FSv;Y7A delta 37 scmbQh)Xc=yySyy5s5mn}k8>i|bD<4MIr+)isVVWPc_o=8nW@DS01T=Q(EtDd diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index c17c621184e0a2f3e4933e52e57dc044f3142c6c..4046cbbb756ed67974115972ac6947750bdd6366 100644 GIT binary patch delta 51 zcmbQix|EfxZ+Tg2QE_H|9@j>$8YcPG#mR|z@u_90c_qaYHYDZbCugUo!~=PmC7G$k F6956~6q^75 delta 38 tcmZ3=I)jy~cX?TAQE_H|9_L1`8YZC)Njdq+*{LbJx2M}#mR|z@u_90c_qaYHYDZbCugUo!~=PmC7G$k F6960m6+Hj| delta 38 tcmZo?`NhoDySyy5s5mn}k8>l}Jw~AoNjdq+*{Lb, geometry: Res, diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index d913aa905..9f6f08738 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -61,6 +61,7 @@ fn snapshot_roundtrip_over_unix_socket() { dialogue_response: None, blocked_entities: vec![], scan_events: vec![], + rng_seed: None, }; bridge diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 676589b86..e6f12c5aa 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -47,6 +47,7 @@ fn snapshot_roundtrip_over_tcp() { dialogue_response: None, blocked_entities: vec![], scan_events: vec![], + rng_seed: None, }; bridge diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 2c8bc879c..9d81eeae9 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -37,6 +37,7 @@ fn fixture_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot dialogue_response: None, blocked_entities: vec![], scan_events: vec![], + rng_seed: None, } } @@ -208,6 +209,7 @@ fn generate_msgpack_fixtures() { dialogue_response: None, blocked_entities: vec![], scan_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 ac0bd2d5d..5bdf56161 100644 --- a/server/tests/golden/proof_room_tick_10.json +++ b/server/tests/golden/proof_room_tick_10.json @@ -64,9 +64,10 @@ "player_facing": "North", "player_inventory": [], "player_stance": "Sprint", + "rng_seed": 42, "scan_events": [], "tick": 8, - "version": 9, + "version": 10, "visible_tiles": [ { "tile_kind": "Wall", diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 6bc7e0d3e..7addfa2e7 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -26,6 +26,7 @@ fn test_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { dialogue_response: None, blocked_entities: vec![], scan_events: vec![], + rng_seed: None, } } @@ -254,6 +255,7 @@ fn snapshot_v2_fields_roundtrip() { dialogue_response: None, blocked_entities: vec![], scan_events: vec![], + rng_seed: None, }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); @@ -308,7 +310,7 @@ fn protocol_version_constant_matches_snapshot() { let snapshot = test_snapshot(0, vec![]); assert_eq!(snapshot.version, PROTOCOL_VERSION); assert_eq!( - PROTOCOL_VERSION, 9, + PROTOCOL_VERSION, 10, "bump this assertion when protocol version changes" ); } @@ -348,6 +350,7 @@ fn all_facing_direction_variants_roundtrip() { dialogue_response: None, blocked_entities: vec![], scan_events: vec![], + rng_seed: None, }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); From a71218f6bc12da56b7c9e7a55e7a4bd699801518 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 14:15:46 +0100 Subject: [PATCH 5/7] feat(ci): tracing JSON format in CI, tick duration logging, schedule dump (#344, #346) Tracing (#344): - Add 'json' feature to tracing-subscriber dependency - Emit JSON log format when CI=true or RUST_LOG_FORMAT=json is set (structured log ingestion in CI pipelines) - Add tracing::debug! with tick_ms/budget_ms/over_budget fields on each tick for performance profiling and tier system debugging prerequisite Schedule dump (#346): - Add --dump-schedule CLI flag that prints bevy_ecs schedule graph and exits without requiring TCP bridge or world setup - Add make debug-schedule target for CI artifact generation and diff-based regression detection of unintended system reordering Co-Authored-By: Claude Sonnet 4.6 --- Makefile | 9 ++++- server/Cargo.lock | 15 +++++++- server/Cargo.toml | 2 +- server/src/main.rs | 96 +++++++++++++++++++++++++++++++++++++++++----- 4 files changed, 109 insertions(+), 13 deletions(-) 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/server/Cargo.lock b/server/Cargo.lock index 40f0aa4bf..3e73145b0 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -978,7 +978,7 @@ dependencies = [ [[package]] name = "settled-reach-server" -version = "0.1.10" +version = "0.1.11" dependencies = [ "bevy_app", "bevy_ecs", @@ -1162,6 +1162,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" @@ -1172,12 +1182,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 1c68ade0c..c96287e62 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"] } [features] default = ["gauntlet"] diff --git a/server/src/main.rs b/server/src/main.rs index ec5e85547..8cb12aa60 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,53 @@ fn main() { tracing::info!("Simulation server shutting down"); } +/// Print bevy_ecs schedule graph and exit. +/// Invoked by --dump-schedule CLI flag (#346). +/// +/// 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) { From 3d306b8a2b9a1eaf20012ec579bedc65c1c261b0 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 14:55:00 +0100 Subject: [PATCH 6/7] feat(ci): add tracing::instrument to heavy per-tick systems, fix stale protocol comment (#344, #527) Co-Authored-By: Claude Opus 4.6 --- server/src/bridge/types.rs | 2 +- server/src/perception/anomaly.rs | 1 + server/src/perception/cognitive_delay.rs | 1 + server/src/perception/observer/mod.rs | 2 ++ server/src/simulation/dialogue.rs | 1 + server/src/simulation/interaction.rs | 1 + server/src/simulation/movement.rs | 1 + server/src/simulation/path_follow.rs | 1 + server/src/simulation/pathfinding.rs | 1 + 9 files changed, 10 insertions(+), 1 deletion(-) diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 7395e0505..5eb9272c7 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -32,7 +32,7 @@ pub const PROTOCOL_VERSION: u8 = 10; /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { - /// Protocol version for forward compatibility. Current: 9. + /// Protocol version for forward compatibility. Current: 10. pub version: u8, /// Simulation tick when this snapshot was produced pub tick: u64, 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/observer/mod.rs b/server/src/perception/observer/mod.rs index 55c9d7bf8..b41e652e4 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -29,6 +29,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, @@ -52,6 +53,7 @@ pub fn compute_visibility_geometry( /// /// System ordering: after compute_visibility_geometry + compute_nearby_interactions, /// before advance_tick. +#[tracing::instrument(level = "debug", skip_all)] #[allow(clippy::type_complexity, clippy::too_many_arguments)] pub fn compute_observer_snapshot( time: Res, diff --git a/server/src/simulation/dialogue.rs b/server/src/simulation/dialogue.rs index c9b9366d3..ff3603fe1 100644 --- a/server/src/simulation/dialogue.rs +++ b/server/src/simulation/dialogue.rs @@ -337,6 +337,7 @@ pub fn select_dialogue_line<'a>( /// 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/movement.rs b/server/src/simulation/movement.rs index a0c38aa68..4bfa456f5 100644 --- a/server/src/simulation/movement.rs +++ b/server/src/simulation/movement.rs @@ -273,6 +273,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>, diff --git a/server/src/simulation/path_follow.rs b/server/src/simulation/path_follow.rs index 64a11c3fe..560110540 100644 --- a/server/src/simulation/path_follow.rs +++ b/server/src/simulation/path_follow.rs @@ -48,6 +48,7 @@ impl MovementSpeed { /// System: NPC entities with ComputedPath advance along their path. /// Creates MoveIntent for the next step. Removes ComputedPath when complete. +#[tracing::instrument(level = "debug", skip_all)] pub fn follow_paths( mut commands: Commands, mut query: Query<(Entity, &mut ComputedPath, Option<&mut MovementSpeed>), 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>, From e047218ad2c65632c2a5823dfbec1b5713b04f29 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 15:05:56 +0100 Subject: [PATCH 7/7] fix(ci): remove duplicate doc comment, add rng_seed serialization tests (#344, #527) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes: deduplicate dump_schedule_graph doc comment, add rng_seed round-trip test and v9→v10 backward compat test. Co-Authored-By: Claude Opus 4.6 --- server/src/main.rs | 3 -- server/tests/serialization.rs | 74 +++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/server/src/main.rs b/server/src/main.rs index 8cb12aa60..89e409881 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -184,9 +184,6 @@ fn main() { tracing::info!("Simulation server shutting down"); } -/// Print bevy_ecs schedule graph and exit. -/// Invoked by --dump-schedule CLI flag (#346). -/// /// Print bevy_ecs schedule graph and exit. /// Invoked by --dump-schedule CLI flag (#346). /// diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 7addfa2e7..e945e1201 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -1187,6 +1187,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]