Files
settled-reach/server/src/npc/vision.rs
T
jpmschweitzerandClaude Opus 4.6 aa79dd97e7 fix(simulation): Clippy cleanup and CI enforcement (#635)
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>
2026-03-17 10:33:15 +01:00

835 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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
//!
//! 3080 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");
}
}