Merge remote-tracking branch 'origin/ci'

# Conflicts:
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack
#	client/tests/fixtures/msgpack/snapshot_empty.msgpack
#	client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack
#	client/tests/fixtures/msgpack/snapshot_one_npc.msgpack
#	client/tests/fixtures/msgpack/snapshot_player.msgpack
#	client/tests/fixtures/msgpack/snapshot_v2_full.msgpack
#	server/Cargo.toml
#	server/src/bridge/text_renderer.rs
#	server/src/bridge/types.rs
#	server/src/perception/observer/mod.rs
#	server/src/simulation/path_follow.rs
#	server/tests/bridge_ipc.rs
#	server/tests/bridge_tcp.rs
#	server/tests/gen_fixtures.rs
#	server/tests/serialization.rs
This commit is contained in:
2026-02-19 15:35:56 +01:00
38 changed files with 307 additions and 50 deletions
+9
View File
@@ -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." },
]
+13
View File
@@ -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]]
+1 -1
View File
@@ -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]
+2
View File
@@ -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"));
+8 -1
View File
@@ -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<crate::simulation::sound::SoundEvent>,
/// 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<u64>,
}
/// Game time data for client display (D-031)
+83 -10
View File
@@ -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 <PORT> Bind to specific port (0 = OS-assigned). Overrides positional addr.
// --seed <SEED> RNG seed (default: 0, test-mode default: 42)
// --test-mode Enable test mode (fixed seed, LISTENING signal, quieter logs)
// --port <PORT> Bind to specific port (0 = OS-assigned). Overrides positional addr.
// --seed <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<String> = 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::<Schedules>();
println!("=== Schedule Graph (settled-reach-server) ===");
let mut entries: Vec<String> = 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) {
+1
View File
@@ -40,6 +40,7 @@ pub fn clear_anomaly_markers(mut commands: Commands, markers: Query<Entity, With
/// OR KG.state == Contradicted. Skips the player entity.
///
/// System ordering: after clear_anomaly_markers, before emit_observation_events.
#[tracing::instrument(level = "debug", skip_all)]
pub fn detect_anomalies(
mut commands: Commands,
observer_query: Query<(Entity, &KnowledgeGraph), With<PlayerCharacter>>,
+1
View File
@@ -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<SimulationTime>,
mut query: Query<(Entity, &mut CognitiveDelay)>,
+4
View File
@@ -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::*;
+6 -1
View File
@@ -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<WalkabilityMap>,
mode: Res<ActivePerceptionMode>,
@@ -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<SimulationTime>,
geometry: Res<VisibilityGeometry>,
@@ -85,6 +88,7 @@ pub fn compute_observer_snapshot(
)>,
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
mut buffer: ResMut<SnapshotBuffer>,
sim_rng: Option<Res<SimRng>>,
) {
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()),
});
}
+5
View File
@@ -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};
+6
View File
@@ -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;
+23 -11
View File
@@ -80,7 +80,10 @@ impl ScanEventBuffer {
pub fn check_contraband_scan(
time: Res<SimulationTime>,
registry: Res<EntityRegistry>,
mut npc_query: Query<(Entity, &TilePosition, &mut KnowledgeGraph), (With<Npc>, With<ScanAuthority>)>,
mut npc_query: Query<
(Entity, &TilePosition, &mut KnowledgeGraph),
(With<Npc>, With<ScanAuthority>),
>,
mut player_query: Query<(Entity, &TilePosition, &mut ScanEventBuffer), With<PlayerCharacter>>,
items_query: Query<(&CarriedBy, Option<&Contraband>)>,
) {
@@ -423,7 +426,10 @@ mod tests {
let mut buffer = world.get_mut::<ScanEventBuffer>(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::<EntityRegistry>().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::<KnowledgeGraph>(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::<ScanEventBuffer>(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::<ScanEventBuffer>(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));
}
+2
View File
@@ -179,6 +179,7 @@ pub fn available_access_tiers(relationship: RelationshipState) -> Vec<AccessTier
/// - Secret: Friendly + KnowsDetails+ (deep rapport + actionable knowledge)
/// - Real: (Friendly or Known) + KnowsOf+ (rapport + substantive knowledge)
/// - Surface: everything else (baseline, always available)
///
/// Map relationship + knowledge confidence to trust tier (D-075).
///
/// Trust tier gates which dialogue lines are available. The layered gate
@@ -336,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,
+1
View File
@@ -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<
+3 -3
View File
@@ -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<String>,
pub shown_ids: BTreeSet<String>,
/// 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(),
}
+24 -8
View File
@@ -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<ChunkCoord, ChunkData>,
chunks: BTreeMap<ChunkCoord, ChunkData>,
}
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<Res<WalkabilityMap>>,
@@ -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);
+1
View File
@@ -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<Npc>, With<ActiveSim>)>,
+1
View File
@@ -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<Res<WalkabilityMap>>,
+2 -2
View File
@@ -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};
+1
View File
@@ -62,6 +62,7 @@ fn snapshot_roundtrip_over_unix_socket() {
blocked_entities: vec![],
scan_events: vec![],
sound_events: vec![],
rng_seed: None,
};
bridge
+1
View File
@@ -48,6 +48,7 @@ fn snapshot_roundtrip_over_tcp() {
blocked_entities: vec![],
scan_events: vec![],
sound_events: vec![],
rng_seed: None,
};
bridge
+5 -5
View File
@@ -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!(
+13 -7
View File
@@ -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::<KnowledgeGraph>(player)
.unwrap()
.entity_count(),
world.get::<KnowledgeGraph>(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::<ListeningFocus>(player).unwrap().stationary_ticks,
world
.get::<ListeningFocus>(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::<ListeningFocus>(player).unwrap().stationary_ticks,
world
.get::<ListeningFocus>(player)
.unwrap()
.stationary_ticks,
0,
"T8 transition: movement tick must reset stationary_ticks to 0"
);
+2
View File
@@ -38,6 +38,7 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> 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",
@@ -64,6 +64,7 @@
"player_facing": "North",
"player_inventory": [],
"player_stance": "Sprint",
"rng_seed": 42,
"scan_events": [],
"sound_events": [],
"tick": 8,
+80
View File
@@ -27,6 +27,7 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> 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<InventoryItem>,
entities: Vec<VisibleEntity>,
visible_tiles: Vec<VisibleTile>,
nearby_interactions: Vec<NearbyInteraction>,
current_monologue: Option<MonologueEvent>,
pending_recognitions: Vec<PendingRecognitionWire>,
dialogue_response: Option<DialogueResponseEvent>,
blocked_entities: Vec<u64>,
scan_events: Vec<settled_reach_server::simulation::contraband::ScanEvent>,
}
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]