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:
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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." },
|
||||
]
|
||||
Generated
+13
@@ -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
@@ -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]
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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)>,
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
|
||||
@@ -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()),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>)>,
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -62,6 +62,7 @@ fn snapshot_roundtrip_over_unix_socket() {
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
sound_events: vec![],
|
||||
rng_seed: None,
|
||||
};
|
||||
|
||||
bridge
|
||||
|
||||
@@ -48,6 +48,7 @@ fn snapshot_roundtrip_over_tcp() {
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
sound_events: vec![],
|
||||
rng_seed: None,
|
||||
};
|
||||
|
||||
bridge
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user