Files
settled-reach/server/src/perception/observer/tests.rs
T
jpmschweitzerandClaude Opus 4.6 6c62e2228f test(simulation): sprint 10 — replay loading, content scaling, serialization v9, observer tests
#483: Replay loading in test-client — JSONL file loading, tick-scheduled
PlayerInput sending, 13 unit tests, 3 sample replay files.
#500: Content scaling test — baseline + extra NPC comparative, tick budget
assertion (D-026), determinism check across content packs.
#514: Serialization tests for protocol v9 — blocked_entities roundtrip,
backward compat (v5→v9, v8→v9), regenerated msgpack fixtures.
Observer perception tests for confrontation + walk-away mechanics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:58:27 +01:00

2243 lines
70 KiB
Rust

use super::*;
use crate::knowledge::types::KnowledgeState;
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
}
/// 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.version, PROTOCOL_VERSION);
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(),
},
);
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: to the side, no wall (should be visible)
world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0)));
// NPC 3: also visible
world.spawn((crate::npc::Npc, TilePosition::new(18, 15, 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.version, PROTOCOL_VERSION,
"should be current protocol version"
);
assert_eq!(
snapshot.player_stance,
MovementStance::Walk,
"default stance is Walk"
);
assert!(
snapshot.player_inventory.is_empty(),
"default inventory is empty"
);
}
#[test]
fn snapshot_v6_version_is_protocol_version() {
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_eq!(
snapshot.version,
crate::bridge::types::PROTOCOL_VERSION,
"snapshot version must match PROTOCOL_VERSION constant"
);
}
// -----------------------------------------------------------------------
// 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, AND Confront (Phase 2 injected)
assert_eq!(interaction.verbs.len(), 3);
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(),
},
);
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_smuggler_relabels_container_verbs() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// Container at close range, north of player
let container = world
.spawn((
TilePosition::new(16, 15, 0),
crate::simulation::interaction::Interactable,
ObjectType::Container,
))
.id();
registry.register(container);
// Smuggler player
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
CharacterArchetype::Smuggler,
))
.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];
// Container at close range: Open→"Move", Search→"Stash", Observe stays "Observe"
let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open);
let search_verb = interaction
.verbs
.iter()
.find(|v| v.kind == VerbKind::Search);
let observe_verb = interaction
.verbs
.iter()
.find(|v| v.kind == VerbKind::Observe);
assert_eq!(open_verb.unwrap().label, "Move", "smuggler Open→Move");
assert_eq!(search_verb.unwrap().label, "Stash", "smuggler Search→Stash");
assert_eq!(observe_verb.unwrap().label, "Observe", "Observe unchanged");
}
#[test]
fn phase2_detective_relabels_container_verbs() {
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);
// Detective player (explicit)
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
CharacterArchetype::Detective,
))
.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 open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open);
let search_verb = interaction
.verbs
.iter()
.find(|v| v.kind == VerbKind::Search);
assert_eq!(open_verb.unwrap().label, "Scan", "detective Open→Scan");
assert_eq!(search_verb.unwrap().label, "Flag", "detective Search→Flag");
}
#[test]
fn phase2_default_archetype_is_detective() {
// When no CharacterArchetype component attached, defaults to Detective
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);
// Player WITHOUT CharacterArchetype component
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];
// Default = Detective labels
let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open);
assert_eq!(
open_verb.unwrap().label,
"Scan",
"default archetype should use Detective labels"
);
}
#[test]
fn phase2_non_container_keeps_default_labels() {
// Readable objects should keep their default labels regardless of archetype
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(),
CharacterArchetype::Smuggler,
))
.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 for smuggler"
);
}
#[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(),
3,
"POI+KnowsDetails: ExamineNpc + Talk + Confront"
);
// POI flips ExamineNpc to priority 1, Talk to 2, 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);
assert_eq!(verbs[2].kind, VerbKind::Confront);
assert_eq!(verbs[2].priority, 3);
}
// -----------------------------------------------------------------------
// 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 2 tiles away.
// 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, 16, 0)))
.id();
let npc_b_sid = registry.register(npc_b);
let npc_c = world
.spawn((crate::npc::Npc, TilePosition::new(18, 16, 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"
);
}
#[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));
}