feat(simulation): Sprint 19 — 7 server systems

Protocol handshake (#555): HandshakeMessage as first IPC frame,
HandshakeState resource, forward-compatible input handling.

State serialization (#96): serialize_npc_to_frozen/deserialize with
full D-024 axis coverage (10 new optional fields on NpcSaveState).

Scope tags (#98): ScopeTagKind enum, ScopePinned marker, automatic
assignment from KnowledgeGraph and RelationshipGraph.

Timestamp eviction (#97): LastInteractionTick, SimSpacePressure,
BinaryHeap LRU eviction respecting ScopePinned entities.

Save/load (#553): save_to_file/load_from_file via MessagePack,
SaveGame/LoadGame IPC commands, SaveLoadResultWire on snapshot.

Test infrastructure (#200): Layer 3 integration test entry point,
three-layer architecture documented per D-030.

Information boundary tests (#272): 4 negative tests proving no
passive KG leakage, LOS fog holds, tier boundary holds, save
isolation per NPC.

1063 tests passing, 0 failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 12:13:03 +01:00
co-authored by Claude Opus 4.6
parent 0dd33690f7
commit 6ed8d11502
22 changed files with 2561 additions and 35 deletions
+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()
);
}