//! 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::(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`. Each `NpcSaveState` /// has its own optional `knowledge_graph: Option`. 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(), }; // 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::(); world.init_resource::(); world.init_resource::(); // 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::(); 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::() .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::(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() ); }