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>
2724 lines
86 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|