Files
settled-reach/server/src/perception/observer/tests.rs
T
jpmschweitzerandClaude Opus 4.6 b86bb00a55 refactor(simulation): PR #137 review — audit override + regression tests + docs
Addresses Tyre, Hoshe, and lead review comments on PR #137:

- **Audit doc amendment** (Tyre E1 / Hoshe H1 / Lead): add "Lead override
  (2026-04-21)" section at top of docs/architecture/sprint-37-878-audit.md.
  Rewrites the conclusion to "DECISION: STRIP" with the cascade-based
  rationale. Preserves the original audit body as the pre-override record.

- **Regression tests** (Lead 2a-2b / Hoshe H2 / H3): add POSITIVE
  assertions of the new uniform behavior so silent reintroduction fails.
  - `phase2_container_verb_labels_uniform_regardless_of_player_state` —
    two trials (empty KG, POI-bearing KG) assert container verb labels
    equal Phase-1 defaults.
  - `monologue_pool_selection_uniform_no_archetype_key` — two observers
    with divergent MonologueState both draw from OBSERVE_NPC_LINES.

- **Decision record amendments** (Lead 3 / Tyre S2): D-032, D-035, and
  D-057 amended with Phase 6 deferral wording. "Retired pending Phase 6,
  not deferred with scaffolding." Reintroduction gate: a confirmed
  Phase 6 character-model design.

- **types.rs doc fixes** (Tyre S1 / Hoshe H5): StartupMessage protocol-
  flow comment updated to reflect no-version handshake (D-192).
  ObserverSnapshot version-history block grows a "Sprint 37 wire-format
  shifts" section documenting D-192 + #878 schema drops.

- **observer/tests.rs:944 comment** (Hoshe H6): rewritten to cite
  cascade rationale instead of the stale D-032-SUPERSEDED premise.

- **tests/run-atlas-determinism exit** (Hoshe H7): exit 0 when EXIT_CODE=2
  (venv/DB missing = skip, not fail). Preserves skip semantics for
  tests/run-all on machines without the Python venv.

Follow-up tickets filed:
- #895 (server, low): expand check-systems-db-stamp GENERATOR_SOURCES
  to cover gemma_naming.py + naming_core.py (Tyre S3).
- #896 (planning, low): add CLAUDE.md carveout for server wiki writes
  closing coverage gates (Tyre S4 / Hoshe H8).

H4 investigation: v01_integration_playthrough.rs was not the only E2E
handshake→tick→snapshot test; coverage preserved by bridge_ipc.rs,
bridge_tcp.rs, and game_loop.rs (the latter is pre-existing-broken
per #885). No replacement test needed.

1142/1142 lib tests pass. cargo clippy -- -D warnings clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 10:09:35 +02:00

2724 lines
86 KiB
Rust

use super::*;
use crate::knowledge::types::{KnowledgeState, ObserverAccess};
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
use crate::perception::vision_cone::Facing;
use crate::simulation::monologue::MonologueBuffer;
use bevy_ecs::world::World;
/// Helper: set up a test world with resources for the two-stage observer pipeline.
fn setup_world(width: i32, height: i32) -> World {
let mut world = World::new();
world.insert_resource(SimulationTime::default());
world.insert_resource(WalkabilityMap::new(width, height, 1));
world.init_resource::<SnapshotBuffer>();
world.init_resource::<EntityRegistry>();
world.init_resource::<VisibilityGeometry>();
world.init_resource::<ActivePerceptionMode>();
world.init_resource::<crate::simulation::sound::SoundEventQueue>();
world.init_resource::<crate::simulation::triangle::TriangleCrisisEventQueue>();
world
}
/// Run the two-stage observer pipeline: geometry + snapshot.
fn run_observer_pipeline(world: &mut World) {
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems((
compute_visibility_geometry,
compute_observer_snapshot.after(compute_visibility_geometry),
));
schedule.run(world);
}
/// Run the full pipeline including interaction system.
fn run_full_pipeline(world: &mut World) {
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems((
crate::simulation::interaction::compute_nearby_interactions,
compute_visibility_geometry,
compute_observer_snapshot
.after(compute_visibility_geometry)
.after(crate::simulation::interaction::compute_nearby_interactions),
));
schedule.run(world);
}
#[test]
fn player_always_visible_in_snapshot() {
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
assert_eq!(snapshot.entities.len(), 1);
assert!(matches!(snapshot.entities[0].kind, EntityKind::Player));
assert_eq!(snapshot.entities[0].observation, EntityVisibility::Visible);
}
#[test]
fn npc_in_los_visible() {
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
// NPC directly north of player (in forward cone)
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)));
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert_eq!(snapshot.entities.len(), 2);
let npc = snapshot
.entities
.iter()
.find(|e| matches!(e.kind, EntityKind::Npc))
.expect("NPC should be visible");
assert_eq!(npc.visibility, VisibilitySector::Forward);
assert_eq!(npc.observation, EntityVisibility::Visible);
}
#[test]
fn npc_behind_wall_not_visible() {
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
// Wall between player and NPC
let mut walkability = world.resource_mut::<WalkabilityMap>();
walkability.set_walkable(&TilePosition::new(16, 14, 0), false);
// NPC behind the wall
world.spawn((crate::npc::Npc, TilePosition::new(16, 12, 0)));
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
// Only player should be visible, not the NPC behind the wall
let npcs: Vec<_> = snapshot
.entities
.iter()
.filter(|e| matches!(e.kind, EntityKind::Npc))
.collect();
assert!(npcs.is_empty(), "NPC behind wall should not be visible");
}
#[test]
fn npc_behind_player_not_visible() {
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
// NPC far behind player (south, in blind spot)
world.spawn((crate::npc::Npc, TilePosition::new(16, 26, 0)));
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
let npcs: Vec<_> = snapshot
.entities
.iter()
.filter(|e| matches!(e.kind, EntityKind::Npc))
.collect();
assert!(npcs.is_empty(), "NPC in blind spot should not be visible");
}
#[test]
fn different_z_level_not_visible() {
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
// NPC on different z-level
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 1)));
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
let npcs: Vec<_> = snapshot
.entities
.iter()
.filter(|e| matches!(e.kind, EntityKind::Npc))
.collect();
assert!(npcs.is_empty(), "NPC on different z should not be visible");
}
#[test]
fn game_time_populated() {
let mut world = setup_world(32, 32);
let mut time = SimulationTime::default();
time.tick = 7200; // 720 minutes = Evening
time.tick_rate = crate::simulation::time::TickRate::Paused;
world.insert_resource(time);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert_eq!(snapshot.game_time.time_of_day, 720);
assert_eq!(
snapshot.game_time.day_phase,
crate::simulation::time::DayPhase::Evening
);
assert_eq!(
snapshot.game_time.tick_rate,
crate::simulation::time::TickRate::Paused
);
}
#[test]
fn visible_tiles_populated() {
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert!(
!snapshot.visible_tiles.is_empty(),
"should have visible tiles"
);
// Observer's tile should be in the list
let has_observer_tile = snapshot
.visible_tiles
.iter()
.any(|t| t.x == 16 && t.y == 16 && t.z == 0);
assert!(has_observer_tile, "observer tile should be visible");
}
#[test]
fn visible_npc_has_relationship_from_knowledge() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)))
.id();
let npc_sid = registry.register(npc);
// Player knows NPC is hostile
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
kg.set_relationship(&npc_sid, RelationshipState::Hostile);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
world.insert_resource(registry);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
let npc_entity = snapshot
.entities
.iter()
.find(|e| matches!(e.kind, EntityKind::Npc))
.expect("NPC should be visible");
assert_eq!(npc_entity.relationship, RelationshipState::Hostile);
assert_eq!(npc_entity.observation, EntityVisibility::Visible);
}
#[test]
fn remembered_entity_appears_as_ghost() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// NPC exists far behind the player (not visible)
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(16, 30, 0)))
.id();
let npc_sid = registry.register(npc);
// Player previously saw NPC at (16, 28) — behind the player (south),
// well beyond peripheral range. The tile is NOT in the player's FOV.
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 28, 0), 50);
kg.observe_entity_leaving_los(&npc_sid, 60);
kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
world.insert_resource({
let mut t = SimulationTime::default();
t.tick = 100;
t
});
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
// Should have player (visible) + NPC (remembered)
let remembered: Vec<_> = snapshot
.entities
.iter()
.filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. }))
.collect();
assert_eq!(remembered.len(), 1, "should have one remembered entity");
assert_eq!(
remembered[0].relationship,
RelationshipState::PersonOfInterest
);
// Remembered entity at last_known_position (16, 28), not actual (16, 30)
assert_eq!(remembered[0].x, 16.5);
assert_eq!(remembered[0].y, 28.5);
if let EntityVisibility::Remembered {
confidence,
age_ticks,
} = &remembered[0].observation
{
assert_eq!(*confidence, KnowledgeConfidence::KnowsDetails);
assert_eq!(*age_ticks, 50); // tick 100 - last_observed 50
}
}
#[test]
fn direct_confidence_not_shown_as_remembered() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// NPC exists but not in LOS
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(16, 10, 0)))
.id();
let npc_sid = registry.register(npc);
// Knowledge still shows Direct (transient inconsistency)
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
// Still Direct — don't show as ghost
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
// Wall blocks actual NPC position
let mut walkability = world.resource_mut::<WalkabilityMap>();
walkability.set_walkable(&TilePosition::new(16, 12, 0), false);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
let remembered: Vec<_> = snapshot
.entities
.iter()
.filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. }))
.collect();
assert!(
remembered.is_empty(),
"Direct-confidence entities should not appear as remembered ghosts"
);
}
#[test]
fn remembered_entity_on_visible_tile_not_shown() {
// If the player can see a tile and the entity isn't there,
// don't show a ghost — the player knows it moved.
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(30, 30, 0)))
.id();
let npc_sid = registry.register(npc);
// Player remembers NPC at (16, 15) — a tile the player can currently see
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50);
kg.observe_entity_leaving_los(&npc_sid, 60);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
let remembered: Vec<_> = snapshot
.entities
.iter()
.filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. }))
.collect();
assert!(
remembered.is_empty(),
"ghost should not appear on a tile the player can currently see"
);
}
#[test]
fn remembered_entity_different_z_not_shown() {
// Remembered entity on a different z-level should not appear
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 5, 1)))
.id();
let npc_sid = registry.register(npc);
// Player remembers NPC at z=1, but player is at z=0
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(5, 5, 1), 50);
kg.observe_entity_leaving_los(&npc_sid, 60);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
let remembered: Vec<_> = snapshot
.entities
.iter()
.filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. }))
.collect();
assert!(
remembered.is_empty(),
"remembered entity on different z-level should not appear in snapshot"
);
}
#[test]
fn knowledge_without_position_not_shown() {
// Entity known via gossip (no last_known_position) should not appear
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 5, 0)))
.id();
let npc_sid = registry.register(npc);
// Player knows about NPC but has never seen it (no position)
let mut kg = KnowledgeGraph::new();
// Insert knowledge manually without a position
kg.entities.insert(
npc_sid,
crate::knowledge::EntityKnowledge {
last_known_position: None,
last_observed_tick: 0,
last_updated_tick: 50,
confidence: KnowledgeConfidence::KnowsOf,
source: crate::knowledge::KnowledgeSource::Background,
state: crate::knowledge::KnowledgeState::Active,
relationship: RelationshipState::PersonOfInterest,
known_attributes: std::collections::BTreeMap::new(),
contradicted_claim: None,
},
);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
let remembered: Vec<_> = snapshot
.entities
.iter()
.filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. }))
.collect();
assert!(
remembered.is_empty(),
"entity without last_known_position should not appear as ghost"
);
}
#[test]
fn multiple_npcs_in_los_all_visible() {
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
// Three NPCs in front of player, no walls
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)));
world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0)));
world.spawn((crate::npc::Npc, TilePosition::new(18, 14, 0)));
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
// Player + 3 NPCs = 4 entities
assert_eq!(snapshot.entities.len(), 4);
let npcs: Vec<_> = snapshot
.entities
.iter()
.filter(|e| matches!(e.kind, EntityKind::Npc))
.collect();
assert_eq!(npcs.len(), 3);
assert!(npcs
.iter()
.all(|n| n.observation == EntityVisibility::Visible));
}
#[test]
fn npc_behind_wall_excluded_from_multi_entity_snapshot() {
let mut world = setup_world(32, 32);
// Wall at (16,14)
world
.resource_mut::<WalkabilityMap>()
.set_walkable(&TilePosition::new(16, 14, 0), false);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
// NPC 1: behind wall (should be hidden)
world.spawn((crate::npc::Npc, TilePosition::new(16, 13, 0)));
// NPC 2: NW diagonal, in forward cone (should be visible)
world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0)));
// NPC 3: NE diagonal, in forward cone (should be visible)
world.spawn((crate::npc::Npc, TilePosition::new(18, 14, 0)));
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
// Player + 2 visible NPCs = 3 (NPC behind wall excluded)
let npcs: Vec<_> = snapshot
.entities
.iter()
.filter(|e| matches!(e.kind, EntityKind::Npc))
.collect();
assert_eq!(npcs.len(), 2, "NPC behind wall should be excluded");
}
#[test]
fn poi_interaction_gets_observe_first_priority() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// NPC in close range, directly north of player and in LOS
let npc = world
.spawn((
crate::npc::Npc,
TilePosition::new(16, 15, 0),
crate::simulation::interaction::Interactable,
))
.id();
let npc_sid = registry.register(npc);
// Player knows NPC as PersonOfInterest
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50);
kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
// Run full pipeline: interaction computes default priority,
// then observer applies POI adjustment
run_full_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert_eq!(snapshot.nearby_interactions.len(), 1);
let interaction = &snapshot.nearby_interactions[0];
// POI: Observe takes priority over Talk
assert_eq!(interaction.verbs[0].kind, VerbKind::ExamineNpc);
assert_eq!(interaction.verbs[0].priority, 1);
assert_eq!(interaction.verbs[1].kind, VerbKind::Talk);
assert_eq!(interaction.verbs[1].priority, 2);
}
// -----------------------------------------------------------------------
// v6 field tests (Hoshe QA, Sprint 6 — #449)
// -----------------------------------------------------------------------
#[test]
fn snapshot_v6_fields_default_through_pipeline() {
// Until #417 (stance) and #424 (inventory) wire up the components,
// the observer system should produce Walk stance and empty inventory.
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
assert_eq!(
snapshot.player_stance,
MovementStance::Walk,
"default stance is Walk"
);
assert!(
snapshot.player_inventory.is_empty(),
"default inventory is empty"
);
}
// -----------------------------------------------------------------------
// Phase 2 verb filter tests (#422, D-057)
// -----------------------------------------------------------------------
#[test]
fn phase2_confront_injected_for_npc_with_knows_details() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// NPC at close range, directly north in LOS
let npc = world
.spawn((
crate::npc::Npc,
TilePosition::new(16, 15, 0),
crate::simulation::interaction::Interactable,
))
.id();
let npc_sid = registry.register(npc);
// Player has KnowsDetails confidence on NPC
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50);
kg.observe_entity_leaving_los(&npc_sid, 60);
// Now at KnowsDetails (one step below Direct)
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_full_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert_eq!(snapshot.nearby_interactions.len(), 1);
let interaction = &snapshot.nearby_interactions[0];
// Should have Talk, ExamineNpc, Follow, AND Confront (Phase 2 injected)
assert_eq!(interaction.verbs.len(), 4);
let confront = interaction
.verbs
.iter()
.find(|v| v.kind == VerbKind::Confront);
assert!(
confront.is_some(),
"Confront should be injected for KnowsDetails+"
);
assert_eq!(confront.unwrap().priority, 3);
assert_eq!(confront.unwrap().label, "Confront");
}
#[test]
fn phase2_no_confront_without_knows_details() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((
crate::npc::Npc,
TilePosition::new(16, 15, 0),
crate::simulation::interaction::Interactable,
))
.id();
let npc_sid = registry.register(npc);
// Player only Suspects this NPC (below KnowsDetails threshold)
let mut kg = KnowledgeGraph::new();
kg.entities.insert(
npc_sid,
crate::knowledge::EntityKnowledge {
last_known_position: Some(TilePosition::new(16, 15, 0)),
last_observed_tick: 50,
last_updated_tick: 50,
confidence: KnowledgeConfidence::Suspects,
source: crate::knowledge::KnowledgeSource::Background,
state: KnowledgeState::Active,
relationship: RelationshipState::Unknown,
known_attributes: std::collections::BTreeMap::new(),
contradicted_claim: None,
},
);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_full_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert_eq!(snapshot.nearby_interactions.len(), 1);
let interaction = &snapshot.nearby_interactions[0];
let confront = interaction
.verbs
.iter()
.find(|v| v.kind == VerbKind::Confront);
assert!(
confront.is_none(),
"Confront should NOT appear for Suspects confidence"
);
}
#[test]
fn phase2_no_confront_at_mid_range() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// NPC at mid range (distance 4, > CLOSE_RANGE=2)
let npc = world
.spawn((
crate::npc::Npc,
TilePosition::new(16, 12, 0),
crate::simulation::interaction::Interactable,
))
.id();
let npc_sid = registry.register(npc);
// Player has KnowsDetails
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 12, 0), 50);
kg.observe_entity_leaving_los(&npc_sid, 60);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_full_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert_eq!(snapshot.nearby_interactions.len(), 1);
let interaction = &snapshot.nearby_interactions[0];
// Mid range: only ExamineNpc, no Talk, no Confront
let confront = interaction
.verbs
.iter()
.find(|v| v.kind == VerbKind::Confront);
assert!(confront.is_none(), "Confront requires close range");
}
#[test]
fn phase2_contradiction_marks_interaction() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((
crate::npc::Npc,
TilePosition::new(16, 15, 0),
crate::simulation::interaction::Interactable,
))
.id();
let npc_sid = registry.register(npc);
// Player has contradicted knowledge about NPC
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50);
kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted;
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_full_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert_eq!(snapshot.nearby_interactions.len(), 1);
assert!(
snapshot.nearby_interactions[0].contradicted,
"interaction should be marked contradicted"
);
}
#[test]
fn phase2_no_contradiction_for_active_knowledge() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((
crate::npc::Npc,
TilePosition::new(16, 15, 0),
crate::simulation::interaction::Interactable,
))
.id();
let npc_sid = registry.register(npc);
// Player has Active (normal) knowledge — no contradiction
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_full_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert_eq!(snapshot.nearby_interactions.len(), 1);
assert!(
!snapshot.nearby_interactions[0].contradicted,
"interaction should NOT be contradicted for Active knowledge"
);
}
#[test]
fn phase2_non_container_keeps_default_labels() {
// Readable objects keep their Phase 1 verb labels unchanged through Phase 2.
// Regression guard: archetype-verb differentiation is Phase 6 detail — not present
// in the current server per the development cascade (CLAUDE.md). D-057 superseded.
// If this test fails, a character-class relabelling branch was reintroduced before
// Phase 6 scope is confirmed by the team lead.
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let readable = world
.spawn((
TilePosition::new(16, 15, 0),
crate::simulation::interaction::Interactable,
ObjectType::Readable,
))
.id();
registry.register(readable);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_full_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert_eq!(snapshot.nearby_interactions.len(), 1);
let interaction = &snapshot.nearby_interactions[0];
let read_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Read);
assert_eq!(
read_verb.unwrap().label,
"Read",
"Readable labels unchanged"
);
}
#[test]
fn phase2_object_type_carried_through_snapshot() {
// NearbyInteraction.object_type should be populated from Phase 1
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let terminal = world
.spawn((
TilePosition::new(16, 15, 0),
crate::simulation::interaction::Interactable,
ObjectType::Terminal,
))
.id();
registry.register(terminal);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_full_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert_eq!(snapshot.nearby_interactions.len(), 1);
assert_eq!(
snapshot.nearby_interactions[0].object_type,
Some(ObjectType::Terminal),
"object_type should be carried through from Phase 1"
);
}
#[test]
fn phase2_npc_object_type_is_none() {
// NPCs should have object_type = None
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((
crate::npc::Npc,
TilePosition::new(16, 15, 0),
crate::simulation::interaction::Interactable,
))
.id();
registry.register(npc);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_full_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert_eq!(snapshot.nearby_interactions.len(), 1);
assert_eq!(
snapshot.nearby_interactions[0].object_type, None,
"NPC should have object_type=None"
);
}
// -----------------------------------------------------------------------
// Sprint suppression end-to-end (#419 QA, D-055)
// -----------------------------------------------------------------------
#[test]
fn sprint_suppresses_interactions_through_full_pipeline() {
use crate::simulation::stance::Stance;
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// NPC at close range, directly north in LOS
let npc = world
.spawn((
crate::npc::Npc,
TilePosition::new(16, 15, 0),
crate::simulation::interaction::Interactable,
))
.id();
registry.register(npc);
// Player in Sprint stance
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
Stance(MovementStance::Sprint),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_full_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
// NPC should be VISIBLE (sprint suppresses interpretation, not data per D-055)
let npcs: Vec<_> = snapshot
.entities
.iter()
.filter(|e| matches!(e.kind, EntityKind::Npc))
.collect();
assert_eq!(npcs.len(), 1, "NPC should still be visible during sprint");
// But interactions should be empty
assert!(
snapshot.nearby_interactions.is_empty(),
"sprint should suppress all nearby_interactions in final snapshot"
);
// Stance should be Sprint in snapshot
assert_eq!(snapshot.player_stance, MovementStance::Sprint);
}
#[test]
fn phase2_poi_with_confront_verb_order() {
// POI NPC with KnowsDetails: ExamineNpc(1), Talk(2), Confront(3)
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((
crate::npc::Npc,
TilePosition::new(16, 15, 0),
crate::simulation::interaction::Interactable,
))
.id();
let npc_sid = registry.register(npc);
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50);
kg.observe_entity_leaving_los(&npc_sid, 60);
kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_full_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert_eq!(snapshot.nearby_interactions.len(), 1);
let verbs = &snapshot.nearby_interactions[0].verbs;
assert_eq!(
verbs.len(),
4,
"POI+KnowsDetails: ExamineNpc + Talk + Follow + Confront"
);
// POI flips ExamineNpc to priority 1, Talk to 2, Follow at 3, Confront at 3
assert_eq!(verbs[0].kind, VerbKind::ExamineNpc);
assert_eq!(verbs[0].priority, 1);
assert_eq!(verbs[1].kind, VerbKind::Talk);
assert_eq!(verbs[1].priority, 2);
// Follow and Confront both at priority 3 — sorted by VerbKind discriminant
assert!(verbs.iter().any(|v| v.kind == VerbKind::Follow));
assert!(verbs.iter().any(|v| v.kind == VerbKind::Confront));
}
// -----------------------------------------------------------------------
// Inventory through observer pipeline (#424 QA, D-065)
// -----------------------------------------------------------------------
#[test]
fn carried_item_appears_in_snapshot_inventory() {
// D-065: player_inventory populated via collect_inventory_for through full pipeline
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
let player_sid = registry.register(player);
// Item carried by player (no TilePosition — in inventory)
let item = world
.spawn((
CarriedBy(player_sid),
ItemName("Manifest Copy".into()),
InventorySlot(0),
))
.id();
registry.register(item);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert_eq!(
snapshot.player_inventory.len(),
1,
"carried item should appear in snapshot"
);
assert_eq!(snapshot.player_inventory[0].name, "Manifest Copy");
assert_eq!(snapshot.player_inventory[0].slot, 0);
}
#[test]
fn carried_item_not_in_visible_entities() {
// D-065 info boundary: carried items have no TilePosition, so they
// must NOT appear in the visible entity list (spatial queries skip them).
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
let player_sid = registry.register(player);
// Item in inventory: has CarriedBy but NO TilePosition
let item = world
.spawn((
CarriedBy(player_sid),
ItemName("Corridor Access Token".into()),
InventorySlot(1),
))
.id();
registry.register(item);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
// Only the player should be in visible entities — carried item has no TilePosition
assert_eq!(
snapshot.entities.len(),
1,
"carried item without TilePosition must not appear in visible entities"
);
assert!(matches!(snapshot.entities[0].kind, EntityKind::Player));
// But it IS in the inventory
assert_eq!(snapshot.player_inventory.len(), 1);
}
#[test]
fn multiple_carried_items_sorted_in_snapshot() {
// D-065: 3 v0.1 items, verify sorting by slot through pipeline
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
let player_sid = registry.register(player);
// Spawn 3 v0.1 items in reverse slot order
for (slot, name) in [
(2, "Personal Comm Log"),
(0, "Manifest Copy"),
(1, "Corridor Access Token"),
] {
let item = world
.spawn((
CarriedBy(player_sid),
ItemName(name.into()),
InventorySlot(slot),
))
.id();
registry.register(item);
}
world.insert_resource(registry);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert_eq!(snapshot.player_inventory.len(), 3);
assert_eq!(snapshot.player_inventory[0].slot, 0);
assert_eq!(snapshot.player_inventory[0].name, "Manifest Copy");
assert_eq!(snapshot.player_inventory[1].slot, 1);
assert_eq!(snapshot.player_inventory[1].name, "Corridor Access Token");
assert_eq!(snapshot.player_inventory[2].slot, 2);
assert_eq!(snapshot.player_inventory[2].name, "Personal Comm Log");
}
// -----------------------------------------------------------------------
// Sprint anomaly detection tests (#428, D-055)
// -----------------------------------------------------------------------
#[test]
fn sprint_past_contradicted_npc_queues_anomaly() {
use crate::simulation::monologue::SprintAnomalyQueue;
use crate::simulation::stance::Stance;
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// NPC in LOS, directly north
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)))
.id();
let npc_sid = registry.register(npc);
// Player has Contradicted knowledge about the NPC
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted;
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
Stance(MovementStance::Sprint),
SprintAnomalyQueue::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
// Anomaly should be queued
let mut query = world.query::<&SprintAnomalyQueue>();
let queue = query.single(&world).unwrap();
assert!(
queue.has_pending(),
"contradicted NPC while sprinting should queue anomaly"
);
}
#[test]
fn walk_past_contradicted_npc_does_not_queue_anomaly() {
use crate::simulation::monologue::SprintAnomalyQueue;
use crate::simulation::stance::Stance;
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)))
.id();
let npc_sid = registry.register(npc);
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted;
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
Stance(MovementStance::Walk), // Walking, not sprinting
SprintAnomalyQueue::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
let mut query = world.query::<&SprintAnomalyQueue>();
let queue = query.single(&world).unwrap();
assert!(
!queue.has_pending(),
"walking past contradicted NPC should NOT queue anomaly"
);
}
#[test]
fn sprint_past_active_npc_does_not_queue_anomaly() {
use crate::simulation::monologue::SprintAnomalyQueue;
use crate::simulation::stance::Stance;
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)))
.id();
let npc_sid = registry.register(npc);
// Active knowledge (not contradicted)
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
Stance(MovementStance::Sprint),
SprintAnomalyQueue::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
let mut query = world.query::<&SprintAnomalyQueue>();
let queue = query.single(&world).unwrap();
assert!(
!queue.has_pending(),
"sprint past Active NPC should NOT queue anomaly"
);
}
#[test]
fn sprint_anomaly_not_queued_when_already_pending() {
use crate::simulation::monologue::SprintAnomalyQueue;
use crate::simulation::stance::Stance;
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)))
.id();
let npc_sid = registry.register(npc);
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted;
// Pre-fill anomaly queue with an existing pending entry
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(999, 0); // Different entity, already pending
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
Stance(MovementStance::Sprint),
queue,
))
.id();
registry.register(player);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
// Queue should still have the original entry (first-in wins)
let mut query = world.query::<&SprintAnomalyQueue>();
let queue = query.single(&world).unwrap();
assert!(queue.has_pending());
// The original entity_id should be preserved (not overwritten)
// We can't directly inspect the entity_id, but we can verify via take_ready
}
#[test]
fn sprint_anomaly_without_queue_component_no_crash() {
use crate::simulation::stance::Stance;
// Player without SprintAnomalyQueue should still work (backward compat)
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)))
.id();
let npc_sid = registry.register(npc);
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted;
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
Stance(MovementStance::Sprint),
// No SprintAnomalyQueue — should not crash
))
.id();
registry.register(player);
world.insert_resource(registry);
// Should run without panicking
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
assert!(
buffer.snapshot.is_some(),
"snapshot should still be produced"
);
}
#[test]
fn sprint_anomaly_npc_visible_but_interactions_suppressed() {
// D-055: sprint suppresses interpretation, not sensory data.
// The NPC should be visible AND queue an anomaly, but interactions empty.
use crate::simulation::monologue::SprintAnomalyQueue;
use crate::simulation::stance::Stance;
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((
crate::npc::Npc,
TilePosition::new(16, 15, 0),
crate::simulation::interaction::Interactable,
))
.id();
let npc_sid = registry.register(npc);
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50);
kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted;
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
Stance(MovementStance::Sprint),
SprintAnomalyQueue::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_full_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
// NPC should be visible (sprint doesn't suppress visibility)
let npcs: Vec<_> = snapshot
.entities
.iter()
.filter(|e| matches!(e.kind, EntityKind::Npc))
.collect();
assert_eq!(npcs.len(), 1, "NPC should be visible during sprint");
// Interactions should be empty (sprint suppression)
assert!(
snapshot.nearby_interactions.is_empty(),
"sprint suppresses interactions"
);
// Anomaly should be queued
let mut query = world.query::<&SprintAnomalyQueue>();
let queue = query.single(&world).unwrap();
assert!(
queue.has_pending(),
"anomaly should be queued despite interaction suppression"
);
}
#[test]
fn sprint_anomaly_multiple_contradicted_npcs_only_first_queued() {
// D-055: first-in wins — only the first Contradicted entity per scan is queued
use crate::simulation::monologue::SprintAnomalyQueue;
use crate::simulation::stance::Stance;
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// Two contradicted NPCs in LOS
let npc1 = world
.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)))
.id();
let npc1_sid = registry.register(npc1);
let npc2 = world
.spawn((crate::npc::Npc, TilePosition::new(16, 12, 0)))
.id();
let npc2_sid = registry.register(npc2);
// Player has Contradicted knowledge about BOTH NPCs
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc1_sid, TilePosition::new(16, 14, 0), 50);
kg.entities.get_mut(&npc1_sid).unwrap().state = KnowledgeState::Contradicted;
kg.observe_entity(npc2_sid, TilePosition::new(16, 12, 0), 50);
kg.entities.get_mut(&npc2_sid).unwrap().state = KnowledgeState::Contradicted;
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
Stance(MovementStance::Sprint),
SprintAnomalyQueue::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
// Exactly one anomaly should be queued (first-in wins, break after first)
let mut query = world.query::<&SprintAnomalyQueue>();
let queue = query.single(&world).unwrap();
assert!(queue.has_pending(), "one anomaly should be queued");
}
// -----------------------------------------------------------------------
// Pending recognitions in observer snapshot (#423, D-060)
// -----------------------------------------------------------------------
#[test]
fn pending_recognitions_appear_in_snapshot() {
// H11: When a player entity has a CognitiveDelay component with pending
// recognitions, compute_observer_snapshot should include them in
// pending_recognitions for the client to render as grey blobs.
use crate::perception::cognitive_delay::{
CognitiveDelay, PendingRecognition, RecognitionTrigger,
};
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// Target entity that is being "recognized"
let target = world.spawn_empty().id();
let target_sid = registry.register(target);
// Player with CognitiveDelay containing a pending recognition
let mut cd = CognitiveDelay::default();
cd.push(PendingRecognition {
target,
stable_id: target_sid,
position: TilePosition::new(16, 14, 0),
delay_until_tick: 110, // will complete at tick 110
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
cd,
))
.id();
registry.register(player);
world.insert_resource(registry);
world.insert_resource({
let mut t = SimulationTime::default();
t.tick = 106; // 4 ticks remaining until recognition
t
});
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
assert_eq!(
snapshot.pending_recognitions.len(),
1,
"should have one pending recognition in snapshot"
);
let pending = &snapshot.pending_recognitions[0];
assert_eq!(pending.entity_id, target_sid.0);
assert_eq!(pending.remaining_ticks, 4, "110 - 106 = 4 remaining");
assert_eq!(
pending.total_delay_ticks,
crate::perception::cognitive_delay::NORMAL_DELAY_TICKS,
"total delay should match Normal trigger"
);
// Position should be render coords of (16, 14, 0)
let (expected_x, expected_y, expected_z) = TilePosition::new(16, 14, 0).to_render_coords();
assert_eq!(pending.x, expected_x);
assert_eq!(pending.y, expected_y);
assert_eq!(pending.z, expected_z);
}
// -----------------------------------------------------------------------
// Determinism regression tests (#456/#457 — Fix A + Fix B)
// -----------------------------------------------------------------------
#[test]
fn equidistant_npcs_produce_stable_snapshot_ordering() {
// Fix A (#456): visible_ids uses BTreeSet for deterministic iteration.
// Fix B (#457): entities sorted by entity_id in snapshot.
// Regression guard: equidistant NPCs must always appear in ascending
// entity_id order regardless of ECS internal iteration order.
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// Three NPCs equidistant from observer at (16,16) — all within forward cone.
// Spawn order: npc_a, npc_b, npc_c → ascending stable_ids.
let npc_a = world
.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)))
.id();
let npc_a_sid = registry.register(npc_a);
let npc_b = world
.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0)))
.id();
let npc_b_sid = registry.register(npc_b);
let npc_c = world
.spawn((crate::npc::Npc, TilePosition::new(18, 14, 0)))
.id();
let npc_c_sid = registry.register(npc_c);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
let npc_ids: Vec<u64> = snapshot
.entities
.iter()
.filter(|e| matches!(e.kind, EntityKind::Npc))
.map(|e| e.entity_id)
.collect();
assert_eq!(
npc_ids.len(),
3,
"all three equidistant NPCs should be visible"
);
// Entity IDs must be in strictly ascending order (Fix B sort guarantee)
for i in 1..npc_ids.len() {
assert!(
npc_ids[i - 1] < npc_ids[i],
"snapshot entities not sorted by entity_id: {:?}",
npc_ids
);
}
// Verify the ordering matches the expected stable_id assignment order
assert_eq!(npc_ids[0], npc_a_sid.0);
assert_eq!(npc_ids[1], npc_b_sid.0);
assert_eq!(npc_ids[2], npc_c_sid.0);
}
#[test]
fn visible_tiles_sorted_by_coordinates() {
// Fix A (#456): visible_tiles sorted by (x, y) for deterministic snapshots.
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert!(
!snapshot.visible_tiles.is_empty(),
"should have visible tiles"
);
// All tiles must be sorted by (x, y)
for i in 1..snapshot.visible_tiles.len() {
let prev = &snapshot.visible_tiles[i - 1];
let curr = &snapshot.visible_tiles[i];
assert!(
(prev.x, prev.y) <= (curr.x, curr.y),
"visible_tiles not sorted: ({},{}) > ({},{})",
prev.x,
prev.y,
curr.x,
curr.y,
);
}
}
#[test]
fn no_cognitive_delay_component_means_empty_pending_recognitions() {
// H11 complement: player WITHOUT CognitiveDelay should produce
// an empty pending_recognitions vec (backward compatibility).
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert!(
snapshot.pending_recognitions.is_empty(),
"no CognitiveDelay component should produce empty pending_recognitions"
);
}
// -----------------------------------------------------------------------
// blocked_entities debug field tests (#514)
// -----------------------------------------------------------------------
#[test]
fn blocked_entities_empty_when_all_visible() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)))
.id();
registry.register(npc);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert!(
snapshot.blocked_entities.is_empty(),
"no blocked entities when NPC is in LOS"
);
}
#[test]
fn npc_behind_wall_appears_in_blocked_entities() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// Wall between player and NPC
world
.resource_mut::<WalkabilityMap>()
.set_walkable(&TilePosition::new(16, 14, 0), false);
// NPC behind the wall
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(16, 12, 0)))
.id();
let npc_sid = registry.register(npc);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert!(
snapshot.blocked_entities.contains(&npc_sid.0),
"NPC behind wall should appear in blocked_entities"
);
// Not in visible entities
let npc_visible = snapshot.entities.iter().any(|e| e.entity_id == npc_sid.0);
assert!(!npc_visible, "NPC should not be in visible entities");
}
#[test]
fn npc_behind_player_appears_in_blocked_entities() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// NPC far behind player (south, outside vision cone)
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(16, 26, 0)))
.id();
let npc_sid = registry.register(npc);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert!(
snapshot.blocked_entities.contains(&npc_sid.0),
"NPC in blind spot should appear in blocked_entities"
);
}
#[test]
fn different_z_level_not_in_blocked_entities() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// NPC on a different z-level
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(16, 14, 1)))
.id();
let npc_sid = registry.register(npc);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert!(
!snapshot.blocked_entities.contains(&npc_sid.0),
"NPC on different z-level should NOT be in blocked_entities"
);
}
// -----------------------------------------------------------------------
// Component-level access control tests (#139, D-010 principle 2)
// -----------------------------------------------------------------------
#[test]
fn access_rule_owner_only_redacts_relationship() {
// THE critical negative test for #139: NPC with OwnerOnly access rule
// is physically visible (in LOS) but relationship data is redacted.
use crate::knowledge::types::AccessRule;
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// NPC with OwnerOnly access rule — only the NPC itself can read its data
let npc = world
.spawn((
crate::npc::Npc,
TilePosition::new(16, 14, 0),
AccessRule(ObserverAccess::OwnerOnly),
))
.id();
let npc_sid = registry.register(npc);
// Player knows NPC as Hostile — but access should be denied
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
kg.set_relationship(&npc_sid, RelationshipState::Hostile);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
// NPC should be visible (physically in LOS)
let npc_entity = snapshot
.entities
.iter()
.find(|e| matches!(e.kind, EntityKind::Npc))
.expect("NPC should be visible even with OwnerOnly access");
// But relationship must be redacted to Unknown (access denied)
assert_eq!(
npc_entity.relationship,
RelationshipState::Unknown,
"OwnerOnly access should redact relationship to Unknown for non-owner observer"
);
assert_eq!(npc_entity.observation, EntityVisibility::Visible);
}
#[test]
fn access_rule_knowledge_gated_passes_with_matching_fact() {
// Positive test: observer has the required fact, relationship visible.
use crate::knowledge::types::{AccessRule, FactId, FactKnowledge, KnowledgeSource};
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// NPC gated on a specific fact
let npc = world
.spawn((
crate::npc::Npc,
TilePosition::new(16, 14, 0),
AccessRule(ObserverAccess::KnowledgeGated(
"contraband.ring_exists".into(),
)),
))
.id();
let npc_sid = registry.register(npc);
// Player knows the required fact AND has a relationship with the NPC
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest);
kg.facts.insert(
FactId("contraband.ring_exists".into()),
FactKnowledge {
confidence: KnowledgeConfidence::KnowsOf,
source: KnowledgeSource::Background,
state: KnowledgeState::Active,
acquired_tick: 0,
disclosure_blocked: false,
},
);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
let npc_entity = snapshot
.entities
.iter()
.find(|e| matches!(e.kind, EntityKind::Npc))
.expect("NPC should be visible");
// Observer has the required fact — relationship should be visible
assert_eq!(
npc_entity.relationship,
RelationshipState::PersonOfInterest,
"KnowledgeGated access should pass when observer has the required fact"
);
}
#[test]
fn access_rule_knowledge_gated_redacts_without_fact() {
// Negative test: observer lacks the required fact, relationship redacted.
use crate::knowledge::types::AccessRule;
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// NPC gated on a fact the observer doesn't have
let npc = world
.spawn((
crate::npc::Npc,
TilePosition::new(16, 14, 0),
AccessRule(ObserverAccess::KnowledgeGated(
"conspiracy.mastermind".into(),
)),
))
.id();
let npc_sid = registry.register(npc);
// Player has relationship but NOT the required fact
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
kg.set_relationship(&npc_sid, RelationshipState::Hostile);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
let npc_entity = snapshot
.entities
.iter()
.find(|e| matches!(e.kind, EntityKind::Npc))
.expect("NPC should be visible (in LOS)");
assert_eq!(
npc_entity.relationship,
RelationshipState::Unknown,
"KnowledgeGated access should redact relationship when observer lacks the fact"
);
}
#[test]
fn no_access_rule_defaults_to_public() {
// Existing behavior: entities without AccessRule are fully visible.
// This is a regression guard — existing tests also cover this implicitly.
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// NPC with NO AccessRule component
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)))
.id();
let npc_sid = registry.register(npc);
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
kg.set_relationship(&npc_sid, RelationshipState::Friendly);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
let npc_entity = snapshot
.entities
.iter()
.find(|e| matches!(e.kind, EntityKind::Npc))
.expect("NPC should be visible");
assert_eq!(
npc_entity.relationship,
RelationshipState::Friendly,
"No AccessRule should default to Public — relationship fully visible"
);
}
#[test]
fn blocked_entities_sorted_ascending() {
// Multiple blocked NPCs should appear in ascending entity_id order
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// Wall blocks north
world
.resource_mut::<WalkabilityMap>()
.set_walkable(&TilePosition::new(16, 14, 0), false);
// Two NPCs behind wall + one behind player
let npc_a = world
.spawn((crate::npc::Npc, TilePosition::new(16, 12, 0)))
.id();
let npc_a_sid = registry.register(npc_a);
let npc_b = world
.spawn((crate::npc::Npc, TilePosition::new(16, 10, 0)))
.id();
let npc_b_sid = registry.register(npc_b);
let npc_c = world
.spawn((crate::npc::Npc, TilePosition::new(16, 26, 0)))
.id();
let npc_c_sid = registry.register(npc_c);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert!(snapshot.blocked_entities.len() >= 3);
// Must be sorted ascending (BTreeSet guarantee)
for i in 1..snapshot.blocked_entities.len() {
assert!(
snapshot.blocked_entities[i - 1] < snapshot.blocked_entities[i],
"blocked_entities not sorted: {:?}",
snapshot.blocked_entities
);
}
// All three NPCs should be present
assert!(snapshot.blocked_entities.contains(&npc_a_sid.0));
assert!(snapshot.blocked_entities.contains(&npc_b_sid.0));
assert!(snapshot.blocked_entities.contains(&npc_c_sid.0));
}
// -----------------------------------------------------------------------
// Zone enrichment tests (#523, D-077)
// -----------------------------------------------------------------------
#[test]
fn zone_map_enriches_visible_tiles_with_zone_id() {
// Tile inside a zone should get zone_id = Some(zone_id)
use crate::simulation::zone::ZoneMap;
let mut world = setup_world(32, 32);
let mut zone_map = ZoneMap::default();
// Zone 42 covers (14..18, 14..18) — includes the observer's tile at (16,16)
zone_map.set_rect(14, 14, 4, 4, 0, 42);
world.insert_resource(zone_map);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
// Observer's tile (16, 16) is inside zone 42
let observer_tile = snapshot
.visible_tiles
.iter()
.find(|t| t.x == 16 && t.y == 16 && t.z == 0)
.expect("observer tile should be visible");
assert_eq!(
observer_tile.zone_id,
Some(42),
"tile inside zone should have zone_id"
);
}
#[test]
fn zone_map_tiles_outside_zone_get_none() {
// Tile outside any zone should get zone_id = None
use crate::simulation::zone::ZoneMap;
let mut world = setup_world(32, 32);
let mut zone_map = ZoneMap::default();
// Zone only covers (0..2, 0..2) — far from observer at (16,16)
zone_map.set_rect(0, 0, 2, 2, 0, 7);
world.insert_resource(zone_map);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
// Observer's tile (16, 16) is outside any zone
let observer_tile = snapshot
.visible_tiles
.iter()
.find(|t| t.x == 16 && t.y == 16 && t.z == 0)
.expect("observer tile should be visible");
assert_eq!(
observer_tile.zone_id, None,
"tile outside any zone should have zone_id = None"
);
}
#[test]
fn no_zone_map_resource_tiles_have_no_zone_id() {
// When ZoneMap resource is absent, all tiles should have zone_id = None
let mut world = setup_world(32, 32);
// Do NOT insert ZoneMap resource
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert!(
!snapshot.visible_tiles.is_empty(),
"should have visible tiles"
);
// All tiles should have zone_id = None when no ZoneMap exists
for tile in &snapshot.visible_tiles {
assert_eq!(
tile.zone_id, None,
"tile ({},{}) should have zone_id = None without ZoneMap resource",
tile.x, tile.y
);
}
}
// ---------------------------------------------------------------------------
// #337 — Tell state → snapshot integration (D-024 tell system)
// ---------------------------------------------------------------------------
/// Helper: run derive_tell_state + two-stage observer pipeline together.
fn run_tell_plus_observer_pipeline(world: &mut World) {
use crate::npc::tell_state::derive_tell_state;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems((
derive_tell_state,
compute_visibility_geometry.after(derive_tell_state),
compute_observer_snapshot
.after(compute_visibility_geometry)
.after(derive_tell_state),
));
schedule.run(world);
}
#[test]
fn tell_state_nervous_appears_in_snapshot_for_major_secret_high_stress() {
// Spec (#337, D-024): NPC with Major secret + stress past midpoint shows
// TellCategory::Nervous in ObserverSnapshot.entities[].tell_state.
// End-to-end pipeline: axis values → derive_tell_state → DerivedTellState
// → compute_observer_snapshot → VisibleEntity.tell_state.
use crate::npc::mood::{MoodState, NpcMood};
use crate::npc::tell_state::{DerivedTellState, TellCategory};
use crate::npc::{Contentment, Npc, Secret, SecretSeverity, ToleranceThreshold};
use crate::simulation::tier::ActiveSim;
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
// NPC directly north — in forward LOS — with Major secret + stress > midpoint.
// stress=60, threshold=100 → stress*2=120 > 100 → Nervous (D-024 priority 2)
world.spawn((
Npc,
ActiveSim,
TilePosition::new(16, 14, 0),
Secret {
description: "criminal record".into(),
severity: SecretSeverity::Major,
known_by: vec![],
},
ToleranceThreshold {
current_stress: 60,
threshold: 100,
},
Contentment { level: 0 },
MoodState {
mood: NpcMood::Neutral,
changed_tick: 0,
},
DerivedTellState::default(),
));
run_tell_plus_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
let npc = snapshot
.entities
.iter()
.find(|e| matches!(e.kind, EntityKind::Npc))
.expect("NPC should be visible in snapshot");
assert_eq!(
npc.tell_state,
Some(TellCategory::Nervous),
"NPC with Major secret + stress past midpoint should show Nervous tell in snapshot"
);
}
#[test]
fn tell_state_none_for_neutral_npc_in_snapshot() {
// Spec (#337, D-024): neutral NPC shows tell_state = None in snapshot.
// Verifies the pipeline correctly omits tell when no conditions are met.
use crate::npc::mood::{MoodState, NpcMood};
use crate::npc::tell_state::DerivedTellState;
use crate::npc::{Contentment, Npc, Secret, SecretSeverity, ToleranceThreshold};
use crate::simulation::tier::ActiveSim;
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
// NPC in LOS — neutral state (Minor secret, low stress, neutral mood, low contentment)
world.spawn((
Npc,
ActiveSim,
TilePosition::new(16, 14, 0),
Secret {
description: "minor embarrassment".into(),
severity: SecretSeverity::Minor,
known_by: vec![],
},
ToleranceThreshold {
current_stress: 10,
threshold: 100,
},
Contentment { level: 0 },
MoodState {
mood: NpcMood::Neutral,
changed_tick: 0,
},
DerivedTellState::default(),
));
run_tell_plus_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
let npc = snapshot
.entities
.iter()
.find(|e| matches!(e.kind, EntityKind::Npc))
.expect("NPC should be visible in snapshot");
assert_eq!(
npc.tell_state, None,
"neutral NPC should have no tell state in snapshot"
);
}
#[test]
fn tell_state_none_when_npc_has_no_derived_tell_component() {
// Spec (#337): NPC without DerivedTellState component has tell_state = None.
// Verifies Option<&DerivedTellState> query handles absent component gracefully.
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
// NPC with no DerivedTellState component at all
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)));
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
let npc = snapshot
.entities
.iter()
.find(|e| matches!(e.kind, EntityKind::Npc))
.expect("NPC should be visible in snapshot");
assert_eq!(
npc.tell_state, None,
"NPC without DerivedTellState component should have tell_state = None"
);
}
// -----------------------------------------------------------------------
// Regression: Phase 2 container verb labels are uniform (D-057 / #878)
// -----------------------------------------------------------------------
#[test]
fn phase2_container_verb_labels_uniform_regardless_of_player_state() {
// Regression guard (D-057 superseded, #878 cascade purge):
// apply_phase2_verb_filter no longer has an archetype branch that relabels
// container verbs. Labels must be the Phase-1 defaults — "Open", "Search",
// "Observe" — regardless of the observer's KnowledgeGraph contents or
// relationship state with other entities.
//
// This test FAILS if a character-class verb-label branch is reintroduced
// without a confirmed Phase 6 scope decision from the team lead.
// --- Trial A: empty KnowledgeGraph ---
{
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let container = world
.spawn((
TilePosition::new(16, 15, 0),
crate::simulation::interaction::Interactable,
ObjectType::Container,
))
.id();
registry.register(container);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
world.insert_resource(registry);
run_full_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert_eq!(snapshot.nearby_interactions.len(), 1);
let labels: Vec<&str> = snapshot.nearby_interactions[0]
.verbs
.iter()
.map(|v| v.label.as_str())
.collect();
assert_eq!(
labels,
["Open", "Search", "Observe"],
"Trial A (empty KG): container verb labels must equal Phase-1 defaults"
);
}
// --- Trial B: KG with PersonOfInterest NPC nearby ---
// Player has a non-trivial knowledge state; container labels must still be
// the Phase-1 defaults — Phase 2 NPC-specific logic must not bleed into
// ObjectType::Container interactions.
{
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let container = world
.spawn((
TilePosition::new(16, 15, 0),
crate::simulation::interaction::Interactable,
ObjectType::Container,
))
.id();
registry.register(container);
// NPC out of close-range so Confront is not injected; still in KG as POI.
let npc = world
.spawn((
crate::npc::Npc,
TilePosition::new(16, 13, 0),
crate::simulation::interaction::Interactable,
))
.id();
let npc_sid = registry.register(npc);
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 13, 0), 5);
kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
world.insert_resource(registry);
run_full_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
let container_interaction = snapshot
.nearby_interactions
.iter()
.find(|i| i.object_type == Some(ObjectType::Container))
.expect("container interaction must be present");
let labels: Vec<&str> = container_interaction
.verbs
.iter()
.map(|v| v.label.as_str())
.collect();
assert_eq!(
labels,
["Open", "Search", "Observe"],
"Trial B (POI NPC in KG): container verb labels must equal Phase-1 defaults"
);
}
}