Critical fixes: - storyteller: replace .expect() with guard + log in activation_pass (Hoshe #1) - content/loader: validate inverted tile_bounds before iteration (Hoshe #C) - save_io: persist ActivationState on save/load (Tyre #7) High-priority fixes: - storyteller: f64 intermediate for observation_time_ticks scoring (Hoshe #A) - storyteller: deduplicate copresent entities before scoring (Hoshe #B) - storyteller: skip activation_pass at tick 0 (Hoshe #G) - storyteller: explicit .before(advance_tick) ordering (Tyre #9) - save_state: insert EngagementRecord on NPC deserialize (Tyre #10) - save_io: reset TriangleActivatedQueue + MovementHistoryBuffer on load (Tyre #8) - debug: validate teleport target walkability (Hoshe #E) - debug: reject SkipToContamination when tick past delay (Hoshe #F) - debug: DebugEnabled defaults to cfg!(debug_assertions) (Hoshe #3) - debug: log when response overwritten (Hoshe #2) - content/loader: error on tile dimension mismatch (Hoshe #5) - content/types: DistrictMeta.description optional (Hoshe #D) - types: version docs updated to v18 (Tyre #1, #2, #3) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
324 lines
13 KiB
Rust
324 lines
13 KiB
Rust
//! 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: 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()
|
||
);
|
||
}
|