Fix all Clippy warnings across the server codebase (2411 insertions, 1341 deletions). Raise type-complexity-threshold to 750 and too-many-arguments to 12 in .clippy.toml for idiomatic Bevy ECS system signatures. The server now passes `cargo clippy -- --deny warnings` cleanly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
835 lines
28 KiB
Rust
835 lines
28 KiB
Rust
//! NPC vision system (#115, D-011).
|
||
//!
|
||
//! Active-tier NPCs use the same symmetric shadowcasting as the player.
|
||
//! Results stored in `NpcVisionState`; `NpcMemory` tracks last-known
|
||
//! positions and zone inferences ("saw you enter building → knows you're
|
||
//! inside").
|
||
//!
|
||
//! ## System ordering
|
||
//!
|
||
//! 1. `compute_npc_vision` — after movement/tier updates, before snapshot
|
||
//! 2. `emit_npc_vision_events` — after compute, before process_knowledge_events
|
||
//! 3. `degrade_npc_inferences` — once per game-minute (D-031)
|
||
//!
|
||
//! ## Performance
|
||
//!
|
||
//! 30–80 Active NPCs × symmetric shadowcast per tick. Confirmed within
|
||
//! D-026 Active tier budget by architecture review.
|
||
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
|
||
use bevy_ecs::prelude::*;
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::knowledge::types::StableId;
|
||
use crate::knowledge::{
|
||
EntityRegistry, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType, KnowledgeGraph,
|
||
};
|
||
use crate::npc::Npc;
|
||
use crate::perception::shadowcast::compute_fov;
|
||
use crate::perception::vision_cone::{apply_vision_cone, Facing, VisionConeConfig};
|
||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||
use crate::simulation::spatial::{NaiveSpatialIndex, SpatialIndex};
|
||
use crate::simulation::tier::ActiveSim;
|
||
use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE};
|
||
|
||
/// NPC vision range in tiles (matches player forward range from VisionConeConfig).
|
||
pub const NPC_VISION_RANGE: i32 = 20;
|
||
|
||
/// Ticks before a zone inference degrades. 600 ticks = 60 game-minutes = 1 game-hour.
|
||
pub const INFERENCE_DEGRADE_TICKS: u64 = 600;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Components
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Current field-of-view results for an NPC.
|
||
/// Updated each tick for Active-tier NPCs. BTreeSet for determinism (D-010).
|
||
#[derive(Component, Debug, Clone, Default)]
|
||
pub struct NpcVisionState {
|
||
/// StableIds of entities currently in this NPC's LOS.
|
||
pub visible_entities: BTreeSet<StableId>,
|
||
/// Whether the player character is currently visible.
|
||
pub player_visible: bool,
|
||
}
|
||
|
||
/// Persistent memory of entities this NPC has seen.
|
||
/// Survives after entities leave LOS (D-011: "saw you enter building →
|
||
/// knows you're inside").
|
||
#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)]
|
||
pub struct NpcMemory {
|
||
/// Last known position + tick for entities this NPC has seen.
|
||
/// Key: StableId of the observed entity.
|
||
pub last_known: BTreeMap<StableId, LastKnownEntry>,
|
||
}
|
||
|
||
/// Record of last-known position for a single entity.
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct LastKnownEntry {
|
||
/// Position where entity was last seen.
|
||
pub position: TilePosition,
|
||
/// Tick when the entity was last observed.
|
||
pub observed_tick: u64,
|
||
/// Zone inference: if entity was last seen before leaving LOS,
|
||
/// the NPC infers they are still nearby.
|
||
pub zone_inference: Option<ZoneInference>,
|
||
}
|
||
|
||
/// Inference that an entity is still near a position based on last observation.
|
||
/// Degrades after `degrades_at_tick`.
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct ZoneInference {
|
||
/// The position where the entity was last seen.
|
||
pub last_seen_position: TilePosition,
|
||
/// Tick when the entity was seen at this position.
|
||
pub observed_tick: u64,
|
||
/// Tick at which this inference degrades (NPC stops assuming entity is here).
|
||
pub degrades_at_tick: u64,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Systems
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Compute NPC field-of-view for all Active-tier NPCs.
|
||
///
|
||
/// For each NPC: run symmetric shadowcasting (same algorithm as player per D-011),
|
||
/// apply vision cone if the NPC has a `Facing` component, then check which entities
|
||
/// from the spatial index are at visible tiles.
|
||
pub fn compute_npc_vision(
|
||
walkability: Option<Res<WalkabilityMap>>,
|
||
registry: Res<EntityRegistry>,
|
||
spatial_index: Res<NaiveSpatialIndex>,
|
||
player_query: Query<Entity, With<PlayerCharacter>>,
|
||
mut npc_query: Query<
|
||
(Entity, &TilePosition, Option<&Facing>, &mut NpcVisionState),
|
||
(With<Npc>, With<ActiveSim>),
|
||
>,
|
||
entity_positions: Query<&TilePosition>,
|
||
) {
|
||
let Some(walkability) = walkability else {
|
||
return;
|
||
};
|
||
let player_entity = player_query.iter().next();
|
||
let player_stable_id = player_entity.and_then(|e| registry.to_stable(e));
|
||
let config = VisionConeConfig::default();
|
||
|
||
for (npc_entity, npc_pos, facing, mut vision_state) in npc_query.iter_mut() {
|
||
let z = npc_pos.z;
|
||
|
||
// Run symmetric shadowcasting — same algorithm as player (D-011, D-035)
|
||
let fov = compute_fov(
|
||
|x, y| !walkability.can_move_to(&TilePosition::new(x, y, z)),
|
||
npc_pos.x,
|
||
npc_pos.y,
|
||
NPC_VISION_RANGE,
|
||
z,
|
||
);
|
||
|
||
// Collect visible tile positions — apply vision cone if NPC has facing
|
||
let visible_positions: BTreeSet<(i32, i32)> = if let Some(facing_comp) = facing {
|
||
let cone_tiles = apply_vision_cone(&fov, npc_pos.x, npc_pos.y, facing_comp.0, &config);
|
||
cone_tiles.into_iter().map(|(x, y, _)| (x, y)).collect()
|
||
} else {
|
||
// No facing → omnidirectional vision (full FOV)
|
||
fov.visible_tiles().collect()
|
||
};
|
||
|
||
// Find entities at visible positions via spatial index
|
||
let mut new_visible = BTreeSet::new();
|
||
let mut player_vis = false;
|
||
|
||
// Single-pass query: all entities within vision range including own tile
|
||
let candidates = spatial_index.entities_within(npc_pos, NPC_VISION_RANGE as u32);
|
||
|
||
for entity in candidates {
|
||
if entity == npc_entity {
|
||
continue;
|
||
}
|
||
let Ok(entity_pos) = entity_positions.get(entity) else {
|
||
continue;
|
||
};
|
||
if entity_pos.z != z {
|
||
continue;
|
||
}
|
||
if !visible_positions.contains(&(entity_pos.x, entity_pos.y)) {
|
||
continue;
|
||
}
|
||
let Some(stable_id) = registry.to_stable(entity) else {
|
||
continue;
|
||
};
|
||
|
||
new_visible.insert(stable_id);
|
||
if Some(stable_id) == player_stable_id {
|
||
player_vis = true;
|
||
}
|
||
}
|
||
|
||
vision_state.visible_entities = new_visible;
|
||
vision_state.player_visible = player_vis;
|
||
}
|
||
}
|
||
|
||
/// Emit knowledge events when NPCs gain or lose sight of the player.
|
||
///
|
||
/// Mirrors the player's `emit_observation_events` pattern but scoped to
|
||
/// NPC→player tracking only. NPC-to-NPC vision is stored in `NpcVisionState`
|
||
/// for direct query by downstream systems (#244 awareness) without flooding
|
||
/// the knowledge event queue.
|
||
///
|
||
/// Also updates `NpcMemory` with last-known positions and zone inferences.
|
||
pub fn emit_npc_vision_events(
|
||
time: Res<SimulationTime>,
|
||
registry: Res<EntityRegistry>,
|
||
mut event_queue: ResMut<KnowledgeEventQueue>,
|
||
player_query: Query<(Entity, &TilePosition), With<PlayerCharacter>>,
|
||
mut npc_query: Query<
|
||
(
|
||
Entity,
|
||
&NpcVisionState,
|
||
&mut NpcMemory,
|
||
Option<&KnowledgeGraph>,
|
||
),
|
||
(With<Npc>, With<ActiveSim>),
|
||
>,
|
||
) {
|
||
let Ok((player_entity, player_pos)) = player_query.single() else {
|
||
return;
|
||
};
|
||
let Some(player_sid) = registry.to_stable(player_entity) else {
|
||
return;
|
||
};
|
||
|
||
for (npc_entity, vision_state, mut memory, knowledge_graph) in npc_query.iter_mut() {
|
||
if vision_state.player_visible {
|
||
// Player is in LOS — update memory and emit DirectObservation
|
||
memory.last_known.insert(
|
||
player_sid,
|
||
LastKnownEntry {
|
||
position: *player_pos,
|
||
observed_tick: time.tick,
|
||
zone_inference: None, // Active observation clears inference
|
||
},
|
||
);
|
||
|
||
event_queue.push(KnowledgeEvent {
|
||
observer: npc_entity,
|
||
tick: time.tick,
|
||
event_type: KnowledgeEventType::DirectObservation {
|
||
target: player_entity,
|
||
position: *player_pos,
|
||
},
|
||
});
|
||
} else {
|
||
// Player NOT in LOS — check if they WERE Direct (just left)
|
||
let was_direct = knowledge_graph
|
||
.and_then(|kg| {
|
||
kg.entity_knowledge(&player_sid)
|
||
.map(|k| k.confidence == crate::knowledge::KnowledgeConfidence::Direct)
|
||
})
|
||
.unwrap_or(false);
|
||
|
||
if was_direct {
|
||
// Player just left this NPC's LOS — emit LeftLOS
|
||
event_queue.push(KnowledgeEvent {
|
||
observer: npc_entity,
|
||
tick: time.tick,
|
||
event_type: KnowledgeEventType::LeftLOS {
|
||
target: player_entity,
|
||
},
|
||
});
|
||
|
||
// Create zone inference — NPC remembers where they last saw the player
|
||
if let Some(entry) = memory.last_known.get_mut(&player_sid) {
|
||
entry.zone_inference = Some(ZoneInference {
|
||
last_seen_position: entry.position,
|
||
observed_tick: entry.observed_tick,
|
||
degrades_at_tick: time.tick + INFERENCE_DEGRADE_TICKS,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Degrade stale zone inferences in NPC memory.
|
||
///
|
||
/// Runs once per game-minute (every 10 ticks per D-031). When a zone
|
||
/// inference passes its degradation tick, the inference is removed.
|
||
pub fn degrade_npc_inferences(
|
||
time: Res<SimulationTime>,
|
||
mut npc_query: Query<&mut NpcMemory, (With<Npc>, With<ActiveSim>)>,
|
||
) {
|
||
if !time.tick.is_multiple_of(TICKS_PER_GAME_MINUTE) {
|
||
return;
|
||
}
|
||
|
||
for mut memory in npc_query.iter_mut() {
|
||
for entry in memory.last_known.values_mut() {
|
||
if let Some(ref inference) = entry.zone_inference {
|
||
if time.tick >= inference.degrades_at_tick {
|
||
entry.zone_inference = None;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
|
||
use bevy_ecs::world::World;
|
||
|
||
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::<KnowledgeEventQueue>();
|
||
world.init_resource::<EntityRegistry>();
|
||
world.init_resource::<NaiveSpatialIndex>();
|
||
world.init_resource::<VisibilityGeometry>();
|
||
world.init_resource::<ActivePerceptionMode>();
|
||
world
|
||
}
|
||
|
||
fn pos(x: i32, y: i32) -> TilePosition {
|
||
TilePosition::new(x, y, 0)
|
||
}
|
||
|
||
// --- compute_npc_vision tests ---
|
||
|
||
#[test]
|
||
fn npc_sees_nearby_entity_in_open_field() {
|
||
let mut world = setup_world(32, 32);
|
||
let mut registry = EntityRegistry::new(0);
|
||
let mut spatial = NaiveSpatialIndex::new();
|
||
|
||
// NPC at (16, 16), target at (16, 14) — 2 tiles away, clear LOS
|
||
let npc = world
|
||
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||
.id();
|
||
let npc_sid = registry.register(npc);
|
||
spatial.update(npc, pos(16, 16));
|
||
|
||
let target = world.spawn(pos(16, 14)).id();
|
||
let target_sid = registry.register(target);
|
||
spatial.update(target, pos(16, 14));
|
||
|
||
// Player entity (required for player_stable_id lookup)
|
||
let player = world.spawn((PlayerCharacter, pos(0, 0))).id();
|
||
registry.register(player);
|
||
|
||
world.insert_resource(registry);
|
||
world.insert_resource(spatial);
|
||
|
||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||
schedule.add_systems(compute_npc_vision);
|
||
schedule.run(&mut world);
|
||
|
||
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||
assert!(
|
||
vision.visible_entities.contains(&target_sid),
|
||
"NPC should see nearby entity in open field"
|
||
);
|
||
assert!(!vision.player_visible, "player is far away");
|
||
let _ = npc_sid; // registered for completeness
|
||
}
|
||
|
||
#[test]
|
||
fn npc_cannot_see_through_wall() {
|
||
let mut world = setup_world(32, 32);
|
||
let mut registry = EntityRegistry::new(0);
|
||
let mut spatial = NaiveSpatialIndex::new();
|
||
|
||
let npc = world
|
||
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||
.id();
|
||
registry.register(npc);
|
||
spatial.update(npc, pos(16, 16));
|
||
|
||
// Target behind a wall
|
||
let target = world.spawn(pos(16, 14)).id();
|
||
let target_sid = registry.register(target);
|
||
spatial.update(target, pos(16, 14));
|
||
|
||
// Wall between NPC and target
|
||
let mut walkability = world.resource_mut::<WalkabilityMap>();
|
||
walkability.set_walkable(&pos(16, 15), false);
|
||
drop(walkability);
|
||
|
||
world.insert_resource(registry);
|
||
world.insert_resource(spatial);
|
||
|
||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||
schedule.add_systems(compute_npc_vision);
|
||
schedule.run(&mut world);
|
||
|
||
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||
assert!(
|
||
!vision.visible_entities.contains(&target_sid),
|
||
"NPC should not see entity behind wall"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn npc_detects_player_visible() {
|
||
let mut world = setup_world(32, 32);
|
||
let mut registry = EntityRegistry::new(0);
|
||
let mut spatial = NaiveSpatialIndex::new();
|
||
|
||
let npc = world
|
||
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||
.id();
|
||
registry.register(npc);
|
||
spatial.update(npc, pos(16, 16));
|
||
|
||
let player = world.spawn((PlayerCharacter, pos(16, 14))).id();
|
||
registry.register(player);
|
||
spatial.update(player, pos(16, 14));
|
||
|
||
world.insert_resource(registry);
|
||
world.insert_resource(spatial);
|
||
|
||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||
schedule.add_systems(compute_npc_vision);
|
||
schedule.run(&mut world);
|
||
|
||
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||
assert!(vision.player_visible, "NPC should detect player in LOS");
|
||
}
|
||
|
||
#[test]
|
||
fn npc_does_not_see_entity_on_different_z_level() {
|
||
let mut world = setup_world(32, 32);
|
||
let mut registry = EntityRegistry::new(0);
|
||
let mut spatial = NaiveSpatialIndex::new();
|
||
|
||
let npc = world
|
||
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||
.id();
|
||
registry.register(npc);
|
||
spatial.update(npc, pos(16, 16));
|
||
|
||
// Target at same x/y but different z
|
||
let target = world.spawn(TilePosition::new(16, 14, 1)).id();
|
||
let target_sid = registry.register(target);
|
||
spatial.update(target, TilePosition::new(16, 14, 1));
|
||
|
||
world.insert_resource(registry);
|
||
world.insert_resource(spatial);
|
||
|
||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||
schedule.add_systems(compute_npc_vision);
|
||
schedule.run(&mut world);
|
||
|
||
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||
assert!(
|
||
!vision.visible_entities.contains(&target_sid),
|
||
"NPC should not see entity on different z-level"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn npc_does_not_see_entity_beyond_range() {
|
||
let mut world = setup_world(64, 64);
|
||
let mut registry = EntityRegistry::new(0);
|
||
let mut spatial = NaiveSpatialIndex::new();
|
||
|
||
let npc = world
|
||
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||
.id();
|
||
registry.register(npc);
|
||
spatial.update(npc, pos(16, 16));
|
||
|
||
// Target beyond NPC_VISION_RANGE (20 tiles)
|
||
let target = world.spawn(pos(16 + NPC_VISION_RANGE + 5, 16)).id();
|
||
let target_sid = registry.register(target);
|
||
spatial.update(target, pos(16 + NPC_VISION_RANGE + 5, 16));
|
||
|
||
world.insert_resource(registry);
|
||
world.insert_resource(spatial);
|
||
|
||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||
schedule.add_systems(compute_npc_vision);
|
||
schedule.run(&mut world);
|
||
|
||
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||
assert!(
|
||
!vision.visible_entities.contains(&target_sid),
|
||
"NPC should not see entity beyond vision range"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn npc_does_not_see_self() {
|
||
let mut world = setup_world(32, 32);
|
||
let mut registry = EntityRegistry::new(0);
|
||
let mut spatial = NaiveSpatialIndex::new();
|
||
|
||
let npc = world
|
||
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||
.id();
|
||
let npc_sid = registry.register(npc);
|
||
spatial.update(npc, pos(16, 16));
|
||
|
||
world.insert_resource(registry);
|
||
world.insert_resource(spatial);
|
||
|
||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||
schedule.add_systems(compute_npc_vision);
|
||
schedule.run(&mut world);
|
||
|
||
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||
assert!(
|
||
!vision.visible_entities.contains(&npc_sid),
|
||
"NPC should not include itself in visible entities"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn npc_sees_non_npc_entity_on_same_tile() {
|
||
let mut world = setup_world(32, 32);
|
||
let mut registry = EntityRegistry::new(0);
|
||
let mut spatial = NaiveSpatialIndex::new();
|
||
|
||
// NPC at (16, 16)
|
||
let npc = world
|
||
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||
.id();
|
||
registry.register(npc);
|
||
spatial.update(npc, pos(16, 16));
|
||
|
||
// Non-NPC entity on the same tile (e.g. dropped item)
|
||
let item = world.spawn(pos(16, 16)).id();
|
||
let item_sid = registry.register(item);
|
||
spatial.update(item, pos(16, 16));
|
||
|
||
world.insert_resource(registry);
|
||
world.insert_resource(spatial);
|
||
|
||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||
schedule.add_systems(compute_npc_vision);
|
||
schedule.run(&mut world);
|
||
|
||
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||
assert!(
|
||
vision.visible_entities.contains(&item_sid),
|
||
"NPC should see non-NPC entity sharing its tile"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn background_npc_not_processed() {
|
||
let mut world = setup_world(32, 32);
|
||
let mut registry = EntityRegistry::new(0);
|
||
let mut spatial = NaiveSpatialIndex::new();
|
||
|
||
// Background NPC — should NOT have its vision computed
|
||
let npc = world
|
||
.spawn((
|
||
Npc,
|
||
crate::simulation::tier::BackgroundSim,
|
||
pos(16, 16),
|
||
NpcVisionState::default(),
|
||
))
|
||
.id();
|
||
registry.register(npc);
|
||
spatial.update(npc, pos(16, 16));
|
||
|
||
let target = world.spawn(pos(16, 14)).id();
|
||
let target_sid = registry.register(target);
|
||
spatial.update(target, pos(16, 14));
|
||
|
||
world.insert_resource(registry);
|
||
world.insert_resource(spatial);
|
||
|
||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||
schedule.add_systems(compute_npc_vision);
|
||
schedule.run(&mut world);
|
||
|
||
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||
assert!(
|
||
vision.visible_entities.is_empty(),
|
||
"Background NPC should not have vision computed"
|
||
);
|
||
let _ = target_sid;
|
||
}
|
||
|
||
// --- emit_npc_vision_events tests ---
|
||
|
||
#[test]
|
||
fn npc_seeing_player_emits_direct_observation() {
|
||
let mut world = setup_world(32, 32);
|
||
let mut registry = EntityRegistry::new(0);
|
||
|
||
let player = world.spawn((PlayerCharacter, pos(16, 14))).id();
|
||
let player_sid = registry.register(player);
|
||
|
||
let mut vision = NpcVisionState::default();
|
||
vision.visible_entities.insert(player_sid);
|
||
vision.player_visible = true;
|
||
|
||
let npc = world
|
||
.spawn((
|
||
Npc,
|
||
ActiveSim,
|
||
pos(16, 16),
|
||
vision,
|
||
NpcMemory::default(),
|
||
KnowledgeGraph::new(),
|
||
))
|
||
.id();
|
||
registry.register(npc);
|
||
|
||
world.insert_resource(registry);
|
||
|
||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||
schedule.add_systems(emit_npc_vision_events);
|
||
schedule.run(&mut world);
|
||
|
||
let queue = world.resource::<KnowledgeEventQueue>();
|
||
assert!(
|
||
!queue.is_empty(),
|
||
"should emit DirectObservation for player"
|
||
);
|
||
|
||
let event = &queue.events[0];
|
||
assert_eq!(event.observer, npc);
|
||
assert!(matches!(
|
||
event.event_type,
|
||
KnowledgeEventType::DirectObservation { target, .. } if target == player
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn npc_losing_player_emits_left_los() {
|
||
let mut world = setup_world(32, 32);
|
||
let mut registry = EntityRegistry::new(0);
|
||
|
||
let player = world.spawn((PlayerCharacter, pos(16, 14))).id();
|
||
let player_sid = registry.register(player);
|
||
|
||
// NPC that had Direct knowledge of player (player was just visible)
|
||
let mut kg = KnowledgeGraph::new();
|
||
kg.observe_entity(player_sid, pos(16, 14), 50);
|
||
|
||
// Memory with last known position
|
||
let mut memory = NpcMemory::default();
|
||
memory.last_known.insert(
|
||
player_sid,
|
||
LastKnownEntry {
|
||
position: pos(16, 14),
|
||
observed_tick: 50,
|
||
zone_inference: None,
|
||
},
|
||
);
|
||
|
||
// Vision state: player NOT visible now
|
||
let vision = NpcVisionState::default();
|
||
|
||
let npc = world
|
||
.spawn((Npc, ActiveSim, pos(16, 16), vision, memory, kg))
|
||
.id();
|
||
registry.register(npc);
|
||
|
||
world.insert_resource(registry);
|
||
|
||
let mut time = SimulationTime::default();
|
||
time.tick = 60;
|
||
world.insert_resource(time);
|
||
|
||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||
schedule.add_systems(emit_npc_vision_events);
|
||
schedule.run(&mut world);
|
||
|
||
let queue = world.resource::<KnowledgeEventQueue>();
|
||
let has_left_los = queue.events.iter().any(|e| {
|
||
matches!(
|
||
e.event_type,
|
||
KnowledgeEventType::LeftLOS { target } if target == player
|
||
)
|
||
});
|
||
assert!(
|
||
has_left_los,
|
||
"should emit LeftLOS when player leaves NPC LOS"
|
||
);
|
||
|
||
// Check zone inference was created
|
||
let npc_memory = world.get::<NpcMemory>(npc).unwrap();
|
||
let entry = npc_memory.last_known.get(&player_sid).unwrap();
|
||
assert!(
|
||
entry.zone_inference.is_some(),
|
||
"zone inference should be created when player leaves LOS"
|
||
);
|
||
assert_eq!(
|
||
entry.zone_inference.as_ref().unwrap().degrades_at_tick,
|
||
60 + INFERENCE_DEGRADE_TICKS
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn npc_updates_memory_on_player_observation() {
|
||
let mut world = setup_world(32, 32);
|
||
let mut registry = EntityRegistry::new(0);
|
||
|
||
let player = world.spawn((PlayerCharacter, pos(10, 10))).id();
|
||
let player_sid = registry.register(player);
|
||
|
||
let mut vision = NpcVisionState::default();
|
||
vision.visible_entities.insert(player_sid);
|
||
vision.player_visible = true;
|
||
|
||
let npc = world
|
||
.spawn((
|
||
Npc,
|
||
ActiveSim,
|
||
pos(16, 16),
|
||
vision,
|
||
NpcMemory::default(),
|
||
KnowledgeGraph::new(),
|
||
))
|
||
.id();
|
||
registry.register(npc);
|
||
|
||
let mut time = SimulationTime::default();
|
||
time.tick = 100;
|
||
world.insert_resource(time);
|
||
world.insert_resource(registry);
|
||
|
||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||
schedule.add_systems(emit_npc_vision_events);
|
||
schedule.run(&mut world);
|
||
|
||
let memory = world.get::<NpcMemory>(npc).unwrap();
|
||
let entry = memory.last_known.get(&player_sid).unwrap();
|
||
assert_eq!(entry.position, pos(10, 10));
|
||
assert_eq!(entry.observed_tick, 100);
|
||
assert!(
|
||
entry.zone_inference.is_none(),
|
||
"active observation = no inference"
|
||
);
|
||
}
|
||
|
||
// --- degrade_npc_inferences tests ---
|
||
|
||
#[test]
|
||
fn inference_degrades_after_threshold() {
|
||
let mut world = setup_world(32, 32);
|
||
|
||
let player_sid = StableId(1);
|
||
let mut memory = NpcMemory::default();
|
||
memory.last_known.insert(
|
||
player_sid,
|
||
LastKnownEntry {
|
||
position: pos(10, 10),
|
||
observed_tick: 50,
|
||
zone_inference: Some(ZoneInference {
|
||
last_seen_position: pos(10, 10),
|
||
observed_tick: 50,
|
||
degrades_at_tick: 100,
|
||
}),
|
||
},
|
||
);
|
||
|
||
let npc = world.spawn((Npc, ActiveSim, memory)).id();
|
||
|
||
// Tick 90 (before degradation, on minute boundary)
|
||
let mut time = SimulationTime::default();
|
||
time.tick = 90;
|
||
world.insert_resource(time);
|
||
|
||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||
schedule.add_systems(degrade_npc_inferences);
|
||
schedule.run(&mut world);
|
||
|
||
let mem = world.get::<NpcMemory>(npc).unwrap();
|
||
assert!(
|
||
mem.last_known[&player_sid].zone_inference.is_some(),
|
||
"inference should survive before degradation tick"
|
||
);
|
||
|
||
// Tick 100 (degradation tick, on minute boundary)
|
||
let mut time = SimulationTime::default();
|
||
time.tick = 100;
|
||
world.insert_resource(time);
|
||
|
||
let mut schedule2 = bevy_ecs::schedule::Schedule::default();
|
||
schedule2.add_systems(degrade_npc_inferences);
|
||
schedule2.run(&mut world);
|
||
|
||
let mem = world.get::<NpcMemory>(npc).unwrap();
|
||
assert!(
|
||
mem.last_known[&player_sid].zone_inference.is_none(),
|
||
"inference should be removed at degradation tick"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn inference_degradation_skips_non_minute_ticks() {
|
||
let mut world = setup_world(32, 32);
|
||
|
||
let player_sid = StableId(1);
|
||
let mut memory = NpcMemory::default();
|
||
memory.last_known.insert(
|
||
player_sid,
|
||
LastKnownEntry {
|
||
position: pos(10, 10),
|
||
observed_tick: 50,
|
||
zone_inference: Some(ZoneInference {
|
||
last_seen_position: pos(10, 10),
|
||
observed_tick: 50,
|
||
degrades_at_tick: 55, // already past
|
||
}),
|
||
},
|
||
);
|
||
|
||
let npc = world.spawn((Npc, ActiveSim, memory)).id();
|
||
|
||
// Tick 57 — past degradation but NOT a minute boundary
|
||
let mut time = SimulationTime::default();
|
||
time.tick = 57;
|
||
world.insert_resource(time);
|
||
|
||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||
schedule.add_systems(degrade_npc_inferences);
|
||
schedule.run(&mut world);
|
||
|
||
let mem = world.get::<NpcMemory>(npc).unwrap();
|
||
assert!(
|
||
mem.last_known[&player_sid].zone_inference.is_some(),
|
||
"degradation should not run on non-minute ticks"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn no_events_when_no_player_entity() {
|
||
let mut world = setup_world(32, 32);
|
||
let registry = EntityRegistry::new(0);
|
||
|
||
let vision = NpcVisionState::default();
|
||
world.spawn((
|
||
Npc,
|
||
ActiveSim,
|
||
pos(16, 16),
|
||
vision,
|
||
NpcMemory::default(),
|
||
KnowledgeGraph::new(),
|
||
));
|
||
|
||
world.insert_resource(registry);
|
||
|
||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||
schedule.add_systems(emit_npc_vision_events);
|
||
schedule.run(&mut world); // should not panic
|
||
|
||
let queue = world.resource::<KnowledgeEventQueue>();
|
||
assert!(queue.is_empty(), "no events when no player entity exists");
|
||
}
|
||
}
|