Files
settled-reach/server/tests/information_boundaries.rs
T
jpmschweitzerandClaude Opus 4.6 aa79dd97e7 fix(simulation): Clippy cleanup and CI enforcement (#635)
Fix all Clippy warnings across the server codebase (2411 insertions, 1341
deletions). Raise type-complexity-threshold to 750 and too-many-arguments
to 12 in .clippy.toml for idiomatic Bevy ECS system signatures. The server
now passes `cargo clippy -- --deny warnings` cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:33:15 +01:00

326 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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::bridge::types::FacingDirection;
use settled_reach_server::knowledge::events::{
process_knowledge_events, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType,
};
use settled_reach_server::knowledge::types::StableId;
use settled_reach_server::knowledge::{ContradictionDetectedQueue, EntityRegistry, KnowledgeGraph};
use settled_reach_server::npc::relationships::RelationshipGraph;
use settled_reach_server::npc::{Npc, SecretSeverity};
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;
// ===========================================================================
// 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: tile directly ahead (1 step north) is visible ---
let adjacent_pos = TilePosition::new(5, 4, 0);
assert!(
geometry
.visible_positions
.contains(&(adjacent_pos.x, adjacent_pos.y)),
"IB-2 sanity: tile directly ahead of 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,
template_ownership: 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,
template_ownership: 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],
template_references: Default::default(),
triangle_states: vec![],
open_doors: vec![],
modifications: vec![],
contamination_active: false,
activated_count: 0,
last_activation_tick: None,
};
// 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()
);
}