Merge remote-tracking branch 'origin/main' into 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/src/perception/observer/mod.rs
This commit is contained in:
2026-02-25 12:51:41 +01:00
46 changed files with 3722 additions and 31 deletions
+1
View File
@@ -71,6 +71,7 @@ fn snapshot_roundtrip_over_unix_socket() {
poi_list: vec![],
examine_result: None,
player_knowledge: None,
save_result: None,
};
bridge
+1
View File
@@ -57,6 +57,7 @@ fn snapshot_roundtrip_over_tcp() {
poi_list: vec![],
examine_result: None,
player_knowledge: None,
save_result: None,
};
bridge
+3
View File
@@ -47,6 +47,7 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
poi_list: vec![],
examine_result: None,
player_knowledge: None,
save_result: None,
}
}
@@ -237,6 +238,7 @@ fn generate_msgpack_fixtures() {
poi_list: vec![],
examine_result: None,
player_knowledge: None,
save_result: None,
};
write_fixture(
"snapshot_v2_full",
@@ -379,6 +381,7 @@ fn generate_msgpack_fixtures() {
text: "A smuggler, probably. The way they hold themselves.".to_string(),
confidence: KnowledgeConfidence::KnowsOf,
}),
save_result: None,
player_knowledge: Some(PlayerKnowledgeWire {
entities: vec![KnownEntityWire {
entity_id: 99,
+1 -1
View File
@@ -73,7 +73,7 @@
"scan_events": [],
"sound_events": [],
"tick": 8,
"version": 14,
"version": 15,
"visible_tiles": [
{
"tile_kind": "Wall",
+314
View File
@@ -0,0 +1,314 @@
//! Information boundary negative test suite (D-010, D-030, ticket #272).
//!
//! THE core asymmetric information claim: entity X cannot see what entity Y
//! knows, unless the observation system explicitly grants it.
//!
//! These are NEGATIVE tests — they assert that information does NOT cross
//! boundaries. Each test uses `assert!(x.is_none())` or equivalent absence
//! patterns, not just "test passed because nothing happened."
//!
//! ## Test layers (D-030)
//!
//! Layer 1 (pure unit, no ECS):
//! - `player_kg_has_no_passive_npc_leakage` — KG starts empty, stays empty
//! - `save_state_npc_kg_isolation` — per-NPC KG serialization isolation
//! - `snapshot_excludes_entities_outside_los` — FOV geometry excludes far tiles
//!
//! Layer 2 (minimal ECS world, no subprocess):
//! - `background_npc_kg_not_updated_by_active_tier_events` — tier boundary holds
//!
//! Spec references: D-010 (info boundaries), D-026 (tiers), D-030 (testability),
//! D-041 (knowledge graph), Q-029 (save format)
use bevy_ecs::prelude::*;
use bevy_ecs::schedule::Schedule;
use settled_reach_server::knowledge::events::{
process_knowledge_events, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType,
};
use settled_reach_server::knowledge::{
ContradictionDetectedQueue, EntityRegistry, KnowledgeGraph,
};
use settled_reach_server::knowledge::types::StableId;
use settled_reach_server::npc::{Npc, SecretSeverity};
use settled_reach_server::npc::relationships::RelationshipGraph;
use settled_reach_server::perception::query::{NaturalVision, PerceptionQuery};
use settled_reach_server::simulation::movement::{TilePosition, WalkabilityMap};
use settled_reach_server::simulation::save_state::{NpcSaveState, SaveStateV1, SAVE_FORMAT_VERSION};
use settled_reach_server::simulation::tier::{ActiveSim, BackgroundSim};
use settled_reach_server::simulation::time::TickRate;
use settled_reach_server::bridge::types::FacingDirection;
// ===========================================================================
// Layer 1 — Pure unit: no ECS world, no subprocess
// ===========================================================================
/// IB-1 (Layer 1): A fresh KnowledgeGraph contains no entries for any entity.
///
/// Core claim: player knowledge is never passively populated. The KG starts
/// empty and can only be written by `observe_entity()`, `record_knowledge()`,
/// or knowledge events processed by `process_knowledge_events`. Simply
/// existing in the simulation world does not leak an NPC's existence into
/// the player's knowledge graph.
///
/// Spec reference: D-010 principle 2 (information boundaries as first-class system)
#[test]
fn player_kg_has_no_passive_npc_leakage() {
let player_kg = KnowledgeGraph::new();
let npc_id = StableId(42);
// Negative assertion: a freshly created KG contains no entity references.
assert!(
player_kg.entities.get(&npc_id).is_none(),
"IB-1: fresh KnowledgeGraph must not contain any entity (passive leakage — D-010 principle 2)"
);
assert!(
player_kg.is_empty(),
"IB-1: KnowledgeGraph::new() must be completely empty"
);
// Negative assertion: spawning a bare ECS entity doesn't populate a KG.
// The knowledge graph is a component, not a global shared resource.
let mut world = World::new();
let player = world
.spawn(KnowledgeGraph::new())
.id();
// Spawn an NPC in the same world — no observation system runs.
let _npc = world.spawn((Npc, TilePosition::new(50, 50, 0))).id();
// Player's KG must be empty regardless of NPCs existing nearby.
let kg = world.get::<KnowledgeGraph>(player).unwrap();
assert!(
kg.entities.get(&npc_id).is_none(),
"IB-1: spawning an NPC in the world must not passively populate the player's KG"
);
assert!(
kg.is_empty(),
"IB-1: player KG must stay empty until an observation system explicitly populates it"
);
}
/// IB-2 (Layer 1): FOV geometry excludes positions beyond the vision range.
///
/// The observer snapshot system (compute_observer_snapshot) includes entities
/// by testing whether their tile position is in `VisibilityGeometry.visible_positions`.
/// This test verifies that the FOV computation — the upstream source of that set —
/// correctly excludes positions far from the observer, so no entity outside LOS
/// can ever appear in the snapshot.
///
/// Spec reference: D-010 principle 2, D-011 (symmetric shadowcasting), D-030 Layer 1
#[test]
fn snapshot_excludes_entities_outside_los() {
// All-walkable 100×100 map at z=0 — no walls to cast shadows.
let walkability = WalkabilityMap::new(100, 100, 1);
let observer_pos = TilePosition::new(5, 5, 0);
let facing = FacingDirection::North;
let geometry = NaturalVision.compute_geometry(&observer_pos, facing, &walkability);
// --- Far entity: 45 tiles away, well outside FOV range (~12 tiles) ---
let far_npc_pos = TilePosition::new(50, 5, 0);
assert!(
!geometry.visible_positions.contains(&(far_npc_pos.x, far_npc_pos.y)),
"IB-2: entity at {:?} (45 tiles from observer) must NOT be in FOV — \
observer snapshot would exclude this entity (fog of perception, D-010 principle 2)",
far_npc_pos
);
// --- Sanity check: the observer's own position is visible ---
assert!(
geometry.visible_positions.contains(&(observer_pos.x, observer_pos.y)),
"IB-2 sanity: observer's own position must always be in the FOV set"
);
// --- Additional sanity: an immediately adjacent tile (1 step) is visible ---
let adjacent_pos = TilePosition::new(6, 5, 0);
assert!(
geometry.visible_positions.contains(&(adjacent_pos.x, adjacent_pos.y)),
"IB-2 sanity: tile immediately adjacent to observer must be visible"
);
}
/// IB-4 (Layer 1): NPC save states do not bleed each other's KnowledgeGraphs.
///
/// `SaveStateV1.npc_states` is a flat `Vec<NpcSaveState>`. Each `NpcSaveState`
/// has its own optional `knowledge_graph: Option<KnowledgeGraph>`. After a
/// serialise → deserialise roundtrip:
/// - NPC_A's `NpcSaveState.knowledge_graph` contains ONLY NPC_A's own KG.
/// - NPC_B's `NpcSaveState.knowledge_graph` is `None` (Background tier,
/// no KG carried) — it must not be overwritten by NPC_A's KG data.
///
/// Spec reference: D-010 principle 2, D-026 (tier serialization), Q-029 (save format)
#[test]
fn save_state_npc_kg_isolation() {
let npc_a_id = StableId(1);
let npc_b_id = StableId(2);
// NPC_A (Active tier) carries a KG that has observed NPC_B.
let mut npc_a_kg = KnowledgeGraph::new();
// NPC_A has observed NPC_B at some position — this puts NPC_B in NPC_A's KG.
let _ = npc_a_kg.observe_entity(npc_b_id, TilePosition::new(10, 10, 0), 5);
let npc_a_state = NpcSaveState {
stable_id: npc_a_id,
position: TilePosition::new(5, 5, 0),
secret_severity: SecretSeverity::Minor,
relationships: None,
current_stress: 0,
tolerance_threshold: 20,
contentment: 50,
knowledge_graph: Some(npc_a_kg), // Active NPC carries KG
want: None,
secret: None,
routine: None,
information_inventory: None,
personality_traits: None,
tell_system: None,
skill_set: None,
combat_capability: None,
mood_state: None,
job_performance: None,
};
// NPC_B (Background tier) does not carry a KG.
let npc_b_state = NpcSaveState {
stable_id: npc_b_id,
position: TilePosition::new(20, 20, 0),
secret_severity: SecretSeverity::Minor,
relationships: None,
current_stress: 0,
tolerance_threshold: 20,
contentment: 50,
knowledge_graph: None, // Background NPC carries no KG
want: None,
secret: None,
routine: None,
information_inventory: None,
personality_traits: None,
tell_system: None,
skill_set: None,
combat_capability: None,
mood_state: None,
job_performance: None,
};
let save = SaveStateV1 {
format_version: SAVE_FORMAT_VERSION,
tick: 10,
tick_rate: TickRate::Full,
seed: 42,
player_knowledge: KnowledgeGraph::new(),
relationship_graph: RelationshipGraph::new(),
npc_states: vec![npc_a_state, npc_b_state],
};
// Roundtrip: serialize → deserialize.
let bytes = save.to_bytes().expect("IB-4: serialize SaveStateV1");
let recovered = SaveStateV1::from_bytes(&bytes).expect("IB-4: deserialize SaveStateV1");
// --- Negative assertion: NPC_B's state must NOT contain a KnowledgeGraph ---
let npc_b_recovered = recovered
.npc_states
.iter()
.find(|s| s.stable_id == npc_b_id)
.expect("IB-4: NPC_B must be present in recovered npc_states");
assert!(
npc_b_recovered.knowledge_graph.is_none(),
"IB-4: NPC_B's recovered state must not contain a KnowledgeGraph — \
serialization must not bleed NPC_A's KG data into NPC_B's entry (D-010 principle 2)"
);
// --- Sanity: NPC_A's state must contain its own KG (not lost in roundtrip) ---
let npc_a_recovered = recovered
.npc_states
.iter()
.find(|s| s.stable_id == npc_a_id)
.expect("IB-4: NPC_A must be present in recovered npc_states");
let kg = npc_a_recovered
.knowledge_graph
.as_ref()
.expect("IB-4: NPC_A's KG must survive roundtrip");
// NPC_A's KG entry for NPC_B is NPC_A's OBSERVATION DATA (where NPC_A saw NPC_B).
// This is not NPC_B's own KG — it's NPC_A's record of NPC_B's position.
assert!(
kg.entities.get(&npc_b_id).is_some(),
"IB-4 sanity: NPC_A's KG should still contain its observation of NPC_B after roundtrip"
);
}
// ===========================================================================
// Layer 2 — Minimal ECS world (no subprocess)
// ===========================================================================
/// IB-3 (Layer 2): `process_knowledge_events` only modifies the observer entity.
///
/// Background-tier NPC KnowledgeGraphs must not be modified when Active-tier
/// events are processed. The `process_knowledge_events` system routes events
/// via `event.observer` (an ECS Entity handle) — only the targeted entity's KG
/// is written. This test confirms that a Background-tier NPC, not named in any
/// event's `observer` field, has its KG left completely unchanged.
///
/// Spec reference: D-010 principle 2, D-026 (tier boundary), D-030 Layer 2
#[test]
fn background_npc_kg_not_updated_by_active_tier_events() {
let mut world = World::new();
// Required resources for process_knowledge_events.
world.init_resource::<KnowledgeEventQueue>();
world.init_resource::<ContradictionDetectedQueue>();
world.init_resource::<EntityRegistry>();
// Active-tier NPC: will be the observer in the knowledge event.
let active_npc = world
.spawn((Npc, ActiveSim, KnowledgeGraph::new()))
.id();
// Background-tier NPC: must NOT be affected.
let background_npc = world
.spawn((Npc, BackgroundSim, KnowledgeGraph::new()))
.id();
// A separate "observed" entity (the target of the DirectObservation).
// Register it in the EntityRegistry so process_knowledge_events can resolve its StableId.
let observed_entity = world.spawn_empty().id();
{
let mut registry = world.resource_mut::<EntityRegistry>();
registry.register(observed_entity);
}
// Push a DirectObservation event targeting only the Active NPC as observer.
// The Background NPC is not mentioned anywhere in this event.
world
.resource_mut::<KnowledgeEventQueue>()
.push(KnowledgeEvent {
observer: active_npc,
tick: 1,
event_type: KnowledgeEventType::DirectObservation {
target: observed_entity,
position: TilePosition::new(5, 5, 0),
},
});
// Run the knowledge event processing system.
let mut schedule = Schedule::default();
schedule.add_systems(process_knowledge_events);
schedule.run(&mut world);
// --- Negative assertion: Background NPC's KG must be completely unchanged ---
let bg_kg = world
.get::<KnowledgeGraph>(background_npc)
.expect("IB-3: BackgroundSim NPC must still have KnowledgeGraph component");
assert!(
bg_kg.is_empty(),
"IB-3: Background-tier NPC KG must not be modified by Active-tier events. \
process_knowledge_events must only update the event.observer entity (D-026 tier boundary, \
D-010 principle 2). Found {} entity entries and {} fact entries.",
bg_kg.entity_count(),
bg_kg.fact_count()
);
}
+60
View File
@@ -0,0 +1,60 @@
//! Layer 3 integration test entry point (D-030, ticket #200).
//!
//! ## Three-layer test architecture (D-030 sub-decision 3)
//!
//! ```text
//! Layer 1 — Fixture-based serialization (FAST, run on every edit)
//! Scope: Pure unit tests. No ECS world. No subprocess.
//! Tools: Rust #[test] + data structures directly.
//! Speed: <1ms each.
//! Files: tests/serialization.rs, tests/information_boundaries.rs (Layer 1 tests),
//! #[cfg(test)] mod tests within src/ modules
//!
//! Layer 2 — Mock subprocess / minimal ECS world (MEDIUM, run on every PR)
//! Scope: Minimal bevy App or World. Real systems, no real subprocess.
//! IPC roundtrip over Unix socket without spawning the binary.
//! Tools: bevy_ecs World + Schedule, or LocalBridge with in-process simulation.
//! Speed: 1ms–100ms each.
//! Files: tests/bridge_ipc.rs, tests/bridge_tcp.rs,
//! tests/information_boundaries.rs (Layer 2 tests),
//! tests/determinism.rs, tests/movement.rs, tests/smoke.rs
//!
//! Layer 3 — Real subprocess integration (SLOW, run daily / pre-merge)
//! Scope: Full binary spawned as a child process. No mocks. Real IPC.
//! Exercises the complete path: spawn → handshake → tick → snapshot.
//! Tools: std::process::Command, TcpStream.
//! Speed: 1s–15s each (process startup dominates).
//! Files: tests/layer3.rs, tests/integration/ (this module)
//! ```
//!
//! ## Layer 3 test guidelines
//!
//! - Always set a deadline for server startup (`LISTEN_TIMEOUT`).
//! - Always kill the child process in teardown (even on test failure — use a
//! RAII guard or drop the handle at end of test).
//! - Use `--port 0` to get a kernel-assigned port; parse `LISTENING:{port}` from
//! stdout to obtain the actual port.
//! - Serialize `PlayerInput` via `rmp_serde`, frame with `bridge::framing::write_framed`.
//! - Deserialize `ObserverSnapshot` via `rmp_serde` after `bridge::framing::read_framed`.
//!
//! Spec reference: D-030 (testability architecture), D-020 (subprocess IPC protocol)
// ---------------------------------------------------------------------------
// Stub: Layer 3 startup smoke test
// ---------------------------------------------------------------------------
/// Placeholder for future Layer 3 tests that require full subprocess setup.
///
/// Non-blocking tests that exercise the simulation binary end-to-end live in
/// `tests/layer3.rs`. This module is the organisational entry point for tests
/// that exercise multi-message Layer 3 scenarios (multi-tick sequences,
/// save/load roundtrip over IPC, protocol version negotiation).
///
/// See `tests/layer3.rs::server_subprocess_sends_snapshot_on_connect` for the
/// canonical Layer 3 pattern.
#[test]
fn layer3_module_entry_point_placeholder() {
// This test exists to verify the integration module compiles and is
// discovered by cargo test. Real Layer 3 scenario tests replace this.
// D-030 Layer 3 stubs are acceptable until the IPC handshake (#555) lands.
}
+16 -4
View File
@@ -72,7 +72,19 @@ fn server_subprocess_sends_snapshot_on_connect() {
let mut reader = BufReader::new(stream.try_clone().expect("clone stream for reader"));
let mut writer = BufWriter::new(stream);
// 4. Send one PlayerInput (idle tick 0)
// 4. Read the protocol handshake (first framed message, #555)
let handshake_frame = read_framed(&mut reader)
.expect("read handshake frame")
.expect("server closed connection before sending handshake");
let handshake: HandshakeMessage =
rmp_serde::from_slice(&handshake_frame).expect("deserialize HandshakeMessage");
assert_eq!(
handshake.protocol_version, PROTOCOL_VERSION,
"handshake protocol_version mismatch: got {}, expected {}",
handshake.protocol_version, PROTOCOL_VERSION
);
// 5. Send one PlayerInput (idle tick 0)
let inputs = vec![PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
@@ -80,14 +92,14 @@ fn server_subprocess_sends_snapshot_on_connect() {
let payload = rmp_serde::to_vec_named(&inputs).expect("serialize PlayerInput");
write_framed(&mut writer, &payload).expect("send PlayerInput to server");
// 5. Read one ObserverSnapshot
// 6. Read one ObserverSnapshot
let response = read_framed(&mut reader)
.expect("read snapshot frame")
.expect("server closed connection before sending snapshot");
let snapshot: ObserverSnapshot =
rmp_serde::from_slice(&response).expect("deserialize ObserverSnapshot");
// 6. Assert protocol correctness (D-020)
// 7. Assert protocol correctness (D-020)
assert_eq!(
snapshot.version, PROTOCOL_VERSION,
"protocol version mismatch: got {}, expected {}",
@@ -105,7 +117,7 @@ fn server_subprocess_sends_snapshot_on_connect() {
.any(|e| matches!(e.kind, EntityKind::Player));
assert!(has_player, "snapshot must contain a Player entity");
// 7. Clean up: drop connection so the server exits its game loop
// 8. Clean up: drop connection so the server exits its game loop
drop(reader);
drop(writer);
+11 -2
View File
@@ -35,6 +35,7 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
poi_list: vec![],
examine_result: None,
player_knowledge: None,
save_result: None,
}
}
@@ -119,6 +120,12 @@ fn all_player_action_variants_roundtrip() {
target_entity_id: 42,
response_id: "kael-davan_d_001".to_string(),
},
PlayerAction::SaveGame {
path: "/tmp/test.msgpack".to_string(),
},
PlayerAction::LoadGame {
path: "/tmp/test.msgpack".to_string(),
},
];
for action in actions {
@@ -286,6 +293,7 @@ fn snapshot_v2_fields_roundtrip() {
poi_list: vec![],
examine_result: None,
player_knowledge: None,
save_result: None,
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
@@ -340,7 +348,7 @@ fn protocol_version_constant_matches_snapshot() {
let snapshot = test_snapshot(0, vec![]);
assert_eq!(snapshot.version, PROTOCOL_VERSION);
assert_eq!(
PROTOCOL_VERSION, 14,
PROTOCOL_VERSION, 15,
"bump this assertion when protocol version changes"
);
}
@@ -389,6 +397,7 @@ fn all_facing_direction_variants_roundtrip() {
poi_list: vec![],
examine_result: None,
player_knowledge: None,
save_result: None,
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
@@ -1432,7 +1441,7 @@ fn serde_default_fields_fill_in_when_missing_from_wire() {
let decoded: ObserverSnapshot =
serde_json::from_value(minimal_json).expect("minimal JSON must deserialize");
// Version matches what was in the wire (13, simulating older server)
// Version matches what was in the wire
assert_eq!(decoded.version, 13);
assert_eq!(decoded.tick, 42);
assert_eq!(decoded.entities.len(), 1);