feat(simulation): Sprint 18 — 9 server systems
Background tier state machines (#95), NPC vision (#115), player- awareness behavior (#244), skill system & combat flag (#91), social propagation (#249), examine mechanic (#242), character pressure framework (#248), save state data model (#256), tell state wiring (#337). Protocol version bumped 13→14 for examine_result and character_pressure snapshot fields. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,605 @@
|
||||
//! NPC player-awareness system (#244).
|
||||
//!
|
||||
//! Tracks how aware an NPC is of the player's attention. When the player
|
||||
//! lingers in an NPC's field of view, the NPC's suspicion accumulates and
|
||||
//! feeds stress into `ToleranceThreshold` (D-024 axis 4).
|
||||
//!
|
||||
//! Reads `NpcVisionState.player_visible` from the NPC vision system (#115).
|
||||
//! Separate from the follow mechanic (#241): follow tracks player-initiated
|
||||
//! proximity, awareness tracks NPC-perceived attention.
|
||||
//!
|
||||
//! ## System ordering
|
||||
//!
|
||||
//! `detect_player_awareness` runs:
|
||||
//! - after `vision::compute_npc_vision` (needs player_visible)
|
||||
//! - before `tolerance::check_tolerance_threshold` (feeds stress)
|
||||
//!
|
||||
//! ## Suspicion lifecycle
|
||||
//!
|
||||
//! 1. Player enters NPC's LOS → `consecutive_los_ticks` starts counting
|
||||
//! 2. After `AWARENESS_NOTICE_TICKS` consecutive ticks → suspicion starts building
|
||||
//! 3. Each tick past threshold: `suspicion_level` increases, stress added to tolerance
|
||||
//! 4. Player leaves LOS → `consecutive_los_ticks` resets, suspicion decays slowly
|
||||
//!
|
||||
//! All arithmetic is integer-only (D-010 determinism).
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::npc::vision::NpcVisionState;
|
||||
use crate::npc::{Npc, ToleranceThreshold};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Consecutive ticks the player must remain in an NPC's LOS before suspicion
|
||||
/// starts building. 30 ticks = 3 game-minutes (D-031: 10 ticks = 1 game-minute).
|
||||
pub const AWARENESS_NOTICE_TICKS: u64 = 30;
|
||||
|
||||
/// Stress increment per tick applied to `ToleranceThreshold` once the NPC
|
||||
/// notices sustained player attention. Lighter than follow stress (2/tick)
|
||||
/// because watching is less intrusive than tailing.
|
||||
pub const AWARENESS_STRESS_PER_TICK: i16 = 1;
|
||||
|
||||
/// Ticks between suspicion decay checks when player is NOT in LOS.
|
||||
/// 10 ticks = 1 game-minute (D-031).
|
||||
pub const AWARENESS_DECAY_INTERVAL: u64 = 10;
|
||||
|
||||
/// Suspicion decay amount per interval when player is not in LOS.
|
||||
pub const AWARENESS_DECAY_AMOUNT: i16 = 1;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Tracks an NPC's awareness of sustained player attention (#244).
|
||||
///
|
||||
/// Updated each tick by `detect_player_awareness` for Active-tier NPCs.
|
||||
/// Feeds stress into `ToleranceThreshold` when `suspicion_level` rises.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `player_in_los` — mirrors `NpcVisionState.player_visible` for downstream queries
|
||||
/// - `consecutive_los_ticks` — resets when player leaves LOS
|
||||
/// - `suspicion_level` — 0–100, accumulates while player watches, decays when they leave
|
||||
#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct PlayerAwareness {
|
||||
/// Whether the player is currently in this NPC's line of sight.
|
||||
pub player_in_los: bool,
|
||||
/// Consecutive ticks the player has been in this NPC's LOS.
|
||||
pub consecutive_los_ticks: u64,
|
||||
/// Accumulated suspicion level (0–100, integer for D-010).
|
||||
pub suspicion_level: i16,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Detect sustained player attention and build NPC suspicion.
|
||||
///
|
||||
/// For each Active-tier NPC with `PlayerAwareness`:
|
||||
/// - Sync `player_in_los` from `NpcVisionState.player_visible`
|
||||
/// - If player visible: increment `consecutive_los_ticks`; once past
|
||||
/// `AWARENESS_NOTICE_TICKS`, increase `suspicion_level` and apply
|
||||
/// stress to `ToleranceThreshold`
|
||||
/// - If player NOT visible: reset `consecutive_los_ticks`, decay
|
||||
/// `suspicion_level` periodically
|
||||
pub fn detect_player_awareness(
|
||||
time: Res<SimulationTime>,
|
||||
mut npc_query: Query<
|
||||
(
|
||||
&NpcVisionState,
|
||||
&mut PlayerAwareness,
|
||||
&mut ToleranceThreshold,
|
||||
),
|
||||
(With<Npc>, With<ActiveSim>),
|
||||
>,
|
||||
) {
|
||||
for (vision, mut awareness, mut tolerance) in npc_query.iter_mut() {
|
||||
awareness.player_in_los = vision.player_visible;
|
||||
|
||||
if vision.player_visible {
|
||||
awareness.consecutive_los_ticks += 1;
|
||||
|
||||
// Once the NPC has noticed sustained player attention, build suspicion
|
||||
if awareness.consecutive_los_ticks >= AWARENESS_NOTICE_TICKS {
|
||||
awareness.suspicion_level =
|
||||
(awareness.suspicion_level + AWARENESS_STRESS_PER_TICK).min(100);
|
||||
tolerance.current_stress = tolerance
|
||||
.current_stress
|
||||
.saturating_add(AWARENESS_STRESS_PER_TICK);
|
||||
}
|
||||
} else {
|
||||
// Player left LOS — reset consecutive counter
|
||||
awareness.consecutive_los_ticks = 0;
|
||||
|
||||
// Decay suspicion slowly when player is not visible
|
||||
if time.tick % AWARENESS_DECAY_INTERVAL == 0 && awareness.suspicion_level > 0 {
|
||||
awareness.suspicion_level =
|
||||
(awareness.suspicion_level - AWARENESS_DECAY_AMOUNT).max(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::npc::vision::NpcVisionState;
|
||||
use crate::npc::{Npc, ToleranceThreshold};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
fn setup_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world
|
||||
}
|
||||
|
||||
fn run_system(world: &mut World) {
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(detect_player_awareness);
|
||||
schedule.run(world);
|
||||
}
|
||||
|
||||
fn default_tolerance() -> ToleranceThreshold {
|
||||
ToleranceThreshold {
|
||||
current_stress: 0,
|
||||
threshold: 80,
|
||||
}
|
||||
}
|
||||
|
||||
fn vision_with_player(visible: bool) -> NpcVisionState {
|
||||
NpcVisionState {
|
||||
player_visible: visible,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Basic LOS tracking
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn player_in_los_increments_consecutive_ticks() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness::default(),
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert!(awareness.player_in_los);
|
||||
assert_eq!(awareness.consecutive_los_ticks, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_leaving_los_resets_consecutive_ticks() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(false),
|
||||
PlayerAwareness {
|
||||
player_in_los: true,
|
||||
consecutive_los_ticks: 20,
|
||||
suspicion_level: 5,
|
||||
},
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert!(!awareness.player_in_los);
|
||||
assert_eq!(awareness.consecutive_los_ticks, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_in_los_syncs_from_vision_state() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness {
|
||||
player_in_los: false, // was false
|
||||
consecutive_los_ticks: 0,
|
||||
suspicion_level: 0,
|
||||
},
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert!(awareness.player_in_los, "should sync from NpcVisionState");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Suspicion threshold — no stress before notice ticks
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn no_stress_before_notice_threshold() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness {
|
||||
player_in_los: true,
|
||||
consecutive_los_ticks: AWARENESS_NOTICE_TICKS - 2, // not yet at threshold
|
||||
suspicion_level: 0,
|
||||
},
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert_eq!(
|
||||
awareness.suspicion_level, 0,
|
||||
"suspicion should not build before notice threshold"
|
||||
);
|
||||
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
|
||||
assert_eq!(
|
||||
tolerance.current_stress, 0,
|
||||
"stress should not increase before notice threshold"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stress_starts_at_notice_threshold() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness {
|
||||
player_in_los: true,
|
||||
consecutive_los_ticks: AWARENESS_NOTICE_TICKS - 1, // will reach threshold
|
||||
suspicion_level: 0,
|
||||
},
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert_eq!(
|
||||
awareness.consecutive_los_ticks, AWARENESS_NOTICE_TICKS,
|
||||
"consecutive ticks should reach threshold"
|
||||
);
|
||||
assert_eq!(
|
||||
awareness.suspicion_level, AWARENESS_STRESS_PER_TICK,
|
||||
"suspicion should increase at threshold"
|
||||
);
|
||||
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
|
||||
assert_eq!(
|
||||
tolerance.current_stress, AWARENESS_STRESS_PER_TICK,
|
||||
"stress should increase at threshold"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Suspicion accumulation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn suspicion_accumulates_past_threshold() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness {
|
||||
player_in_los: true,
|
||||
consecutive_los_ticks: AWARENESS_NOTICE_TICKS + 5,
|
||||
suspicion_level: 10,
|
||||
},
|
||||
ToleranceThreshold {
|
||||
current_stress: 20,
|
||||
threshold: 80,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert_eq!(
|
||||
awareness.suspicion_level,
|
||||
10 + AWARENESS_STRESS_PER_TICK,
|
||||
"suspicion should accumulate"
|
||||
);
|
||||
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
|
||||
assert_eq!(
|
||||
tolerance.current_stress,
|
||||
20 + AWARENESS_STRESS_PER_TICK,
|
||||
"stress should accumulate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suspicion_caps_at_100() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness {
|
||||
player_in_los: true,
|
||||
consecutive_los_ticks: AWARENESS_NOTICE_TICKS + 100,
|
||||
suspicion_level: 100,
|
||||
},
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert_eq!(awareness.suspicion_level, 100, "suspicion should cap at 100");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerance_stress_saturates() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness {
|
||||
player_in_los: true,
|
||||
consecutive_los_ticks: AWARENESS_NOTICE_TICKS,
|
||||
suspicion_level: 50,
|
||||
},
|
||||
ToleranceThreshold {
|
||||
current_stress: i16::MAX - 1,
|
||||
threshold: i16::MAX,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
|
||||
assert_eq!(
|
||||
tolerance.current_stress,
|
||||
i16::MAX,
|
||||
"stress should saturate, not overflow"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Suspicion decay
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn suspicion_decays_when_player_not_in_los_on_interval() {
|
||||
let mut world = setup_world();
|
||||
// Set tick to a decay interval boundary
|
||||
world.resource_mut::<SimulationTime>().tick = 20; // divisible by AWARENESS_DECAY_INTERVAL
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(false),
|
||||
PlayerAwareness {
|
||||
player_in_los: false,
|
||||
consecutive_los_ticks: 0,
|
||||
suspicion_level: 10,
|
||||
},
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert_eq!(
|
||||
awareness.suspicion_level,
|
||||
10 - AWARENESS_DECAY_AMOUNT,
|
||||
"suspicion should decay on interval tick"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suspicion_does_not_decay_off_interval() {
|
||||
let mut world = setup_world();
|
||||
// Set tick to a non-interval boundary
|
||||
world.resource_mut::<SimulationTime>().tick = 13; // not divisible by AWARENESS_DECAY_INTERVAL
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(false),
|
||||
PlayerAwareness {
|
||||
player_in_los: false,
|
||||
consecutive_los_ticks: 0,
|
||||
suspicion_level: 10,
|
||||
},
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert_eq!(
|
||||
awareness.suspicion_level, 10,
|
||||
"suspicion should not decay on non-interval tick"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suspicion_does_not_go_below_zero() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(false),
|
||||
PlayerAwareness {
|
||||
player_in_los: false,
|
||||
consecutive_los_ticks: 0,
|
||||
suspicion_level: 0,
|
||||
},
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert_eq!(
|
||||
awareness.suspicion_level, 0,
|
||||
"suspicion should not go below zero"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Background NPC not processed
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn background_npc_not_processed() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
crate::simulation::tier::BackgroundSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness::default(),
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert_eq!(
|
||||
awareness.consecutive_los_ticks, 0,
|
||||
"background NPC should not have awareness processed"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// No panic with missing components
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn npc_without_awareness_no_panic() {
|
||||
let mut world = setup_world();
|
||||
|
||||
// NPC with vision but no PlayerAwareness — system should skip
|
||||
world.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
default_tolerance(),
|
||||
));
|
||||
|
||||
run_system(&mut world); // should not panic
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Integration: follow + awareness stress stacking
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn awareness_stress_stacks_with_existing_stress() {
|
||||
let mut world = setup_world();
|
||||
|
||||
// NPC already has stress from other sources (e.g., follow mechanic)
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness {
|
||||
player_in_los: true,
|
||||
consecutive_los_ticks: AWARENESS_NOTICE_TICKS, // past threshold
|
||||
suspicion_level: 5,
|
||||
},
|
||||
ToleranceThreshold {
|
||||
current_stress: 30, // pre-existing stress
|
||||
threshold: 80,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
|
||||
assert_eq!(
|
||||
tolerance.current_stress,
|
||||
30 + AWARENESS_STRESS_PER_TICK,
|
||||
"awareness stress should stack with existing stress"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Constant value assertions (#244 spec compliance)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn awareness_constants_have_expected_values() {
|
||||
assert_eq!(
|
||||
AWARENESS_NOTICE_TICKS, 30,
|
||||
"#244: NPC notices sustained attention after 30 ticks (3 game-minutes)"
|
||||
);
|
||||
assert_eq!(
|
||||
AWARENESS_STRESS_PER_TICK, 1,
|
||||
"#244: stress per tick lighter than follow stress (D-010 integer)"
|
||||
);
|
||||
assert_eq!(
|
||||
AWARENESS_DECAY_INTERVAL, 10,
|
||||
"#244: suspicion decays every 10 ticks (1 game-minute, D-031)"
|
||||
);
|
||||
assert_eq!(
|
||||
AWARENESS_DECAY_AMOUNT, 1,
|
||||
"#244: suspicion decays by 1 per interval"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
//! Background tier state machines (#95, D-026).
|
||||
//!
|
||||
//! Four lightweight state machines for Background-tier NPCs, firing once per
|
||||
//! game-minute (D-031: `TICKS_PER_GAME_MINUTE` = 10).
|
||||
//!
|
||||
//! ## State machines
|
||||
//! 1. **Schedule** — set `ActivityState` from `DailyRoutine` + `DayPhase` (no pathfinding)
|
||||
//! 2. **Mood** — stress-based derivation (simplified: no phase or warm flag)
|
||||
//! 3. **Relationships** — per-NPC `trust_level` drifts toward 0 (baseline)
|
||||
//! 4. **Job** — `JobPerformance.score` drifts based on `Contentment.level`
|
||||
//!
|
||||
//! ## Design constraints (D-026)
|
||||
//! - No pathfinding, LOS, or dialogue — those are Active-tier only.
|
||||
//! - All arithmetic is integer-only (D-010 determinism requirement).
|
||||
//! - Background NPCs promoted to Active retain their state machine state (no reset on promotion).
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::npc::{
|
||||
Contentment, DailyRoutine, JobPerformance, Npc, Relationships, ToleranceThreshold,
|
||||
};
|
||||
use crate::npc::mood::{MoodState, NpcMood};
|
||||
use crate::npc::routine::ActivityState;
|
||||
use crate::simulation::tier::BackgroundSim;
|
||||
use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mood derivation (background-tier)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Derive simplified mood for a Background-tier NPC.
|
||||
///
|
||||
/// No phase or warm-flag considerations — background NPCs have no active
|
||||
/// interactions. Priority order:
|
||||
/// 1. Hostile — stress ≥ threshold
|
||||
/// 2. Anxious — stress ≥ 60% of threshold (integer arithmetic, D-010)
|
||||
/// 3. Content — stress < 20
|
||||
/// 4. Neutral — otherwise
|
||||
pub fn derive_background_mood(current_stress: i16, threshold: i16) -> NpcMood {
|
||||
// 1. Hostile: at or above threshold
|
||||
if current_stress >= threshold {
|
||||
return NpcMood::Hostile;
|
||||
}
|
||||
|
||||
// 2. Anxious: 60% of threshold reached
|
||||
// Guard: skip if threshold == 0 (entity already Hostile from rule 1).
|
||||
if threshold > 0 && (current_stress as i32) * 100 >= (threshold as i32) * 60 {
|
||||
return NpcMood::Anxious;
|
||||
}
|
||||
|
||||
// 3. Content: low stress
|
||||
if current_stress < 20 {
|
||||
return NpcMood::Content;
|
||||
}
|
||||
|
||||
// 4. Neutral: default
|
||||
NpcMood::Neutral
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Job performance drift
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Drift job performance score one point per game-minute based on contentment.
|
||||
///
|
||||
/// - contentment > 0 → score + 1 (clamped at 100)
|
||||
/// - contentment < 0 → score - 1 (clamped at 0)
|
||||
/// - contentment = 0 → unchanged
|
||||
pub fn drift_job_performance(score: i16, contentment_level: i16) -> i16 {
|
||||
match contentment_level.cmp(&0) {
|
||||
std::cmp::Ordering::Greater => (score + 1).min(100),
|
||||
std::cmp::Ordering::Less => (score - 1).max(0),
|
||||
std::cmp::Ordering::Equal => score,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System: background_tick
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// System: run all four background-tier state machines once per game-minute.
|
||||
///
|
||||
/// Fires when `time.tick % TICKS_PER_GAME_MINUTE == 0`. Scoped to
|
||||
/// `With<BackgroundSim>` — Active-tier NPCs are handled by their dedicated
|
||||
/// per-tick systems.
|
||||
///
|
||||
/// Machine execution order per NPC:
|
||||
/// 1. Schedule — insert/update `ActivityState` from `DailyRoutine` + `DayPhase`
|
||||
/// 2. Mood — update `MoodState` from stress/threshold (simplified)
|
||||
/// 3. Relationships — drift `Relationships.entries[].trust_level` toward 0
|
||||
/// 4. Job — drift `JobPerformance.score` from `Contentment.level`
|
||||
///
|
||||
/// Commands for `ActivityState` are deferred (applied after system runs).
|
||||
/// Mutable component mutations happen immediately within the iteration.
|
||||
pub fn background_tick(
|
||||
time: Res<SimulationTime>,
|
||||
mut commands: Commands,
|
||||
mut query: Query<
|
||||
(
|
||||
Entity,
|
||||
Option<&ActivityState>,
|
||||
&mut MoodState,
|
||||
Option<&mut Relationships>,
|
||||
Option<&ToleranceThreshold>,
|
||||
Option<&DailyRoutine>,
|
||||
Option<&Contentment>,
|
||||
Option<&mut JobPerformance>,
|
||||
),
|
||||
(With<Npc>, With<BackgroundSim>),
|
||||
>,
|
||||
) {
|
||||
// Fire once per game-minute (D-031: 10 ticks/minute)
|
||||
if time.tick % TICKS_PER_GAME_MINUTE != 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let phase = time.day_phase();
|
||||
let tick = time.tick;
|
||||
|
||||
for (
|
||||
entity,
|
||||
activity_opt,
|
||||
mut mood_state,
|
||||
rels_opt,
|
||||
tolerance_opt,
|
||||
routine_opt,
|
||||
contentment_opt,
|
||||
job_opt,
|
||||
) in query.iter_mut()
|
||||
{
|
||||
// --- 1. Schedule: sync ActivityState to current DayPhase ---
|
||||
// Background NPCs don't pathfind — we directly declare the activity.
|
||||
if let Some(routine) = routine_opt {
|
||||
if let Some(entry) = routine.entry_for_phase(phase) {
|
||||
let needs_update = match activity_opt {
|
||||
Some(a) => a.phase != phase || a.activity != entry.activity,
|
||||
None => true,
|
||||
};
|
||||
if needs_update {
|
||||
commands.entity(entity).insert(ActivityState {
|
||||
activity: entry.activity.clone(),
|
||||
phase,
|
||||
started_tick: tick,
|
||||
});
|
||||
}
|
||||
} else if activity_opt.is_some() {
|
||||
// No routine entry for this phase — clear stale activity
|
||||
commands.entity(entity).remove::<ActivityState>();
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2. Mood: simplified stress-based derivation ---
|
||||
let (stress, threshold) = tolerance_opt
|
||||
.map(|t| (t.current_stress, t.threshold))
|
||||
.unwrap_or((0, 50)); // Default: no stress, moderate threshold
|
||||
let new_mood = derive_background_mood(stress, threshold);
|
||||
if mood_state.mood != new_mood {
|
||||
mood_state.mood = new_mood;
|
||||
mood_state.changed_tick = tick;
|
||||
}
|
||||
|
||||
// --- 3. Relationships: trust drift toward 0 (baseline) ---
|
||||
if let Some(mut rels) = rels_opt {
|
||||
for rel in &mut rels.entries {
|
||||
if rel.trust_level > 0 {
|
||||
rel.trust_level -= 1;
|
||||
} else if rel.trust_level < 0 {
|
||||
rel.trust_level += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4. Job: performance drift from contentment ---
|
||||
if let Some(mut job) = job_opt {
|
||||
let contentment = contentment_opt.map(|c| c.level).unwrap_or(0);
|
||||
job.score = drift_job_performance(job.score, contentment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::npc::{
|
||||
Contentment, DailyRoutine, JobPerformance, Npc, Relationship, RelationshipKind,
|
||||
Relationships, RoutineEntry, ToleranceThreshold,
|
||||
};
|
||||
use crate::npc::mood::{MoodState, NpcMood};
|
||||
use crate::npc::routine::ActivityState;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::tier::{ActiveSim, BackgroundSim};
|
||||
use crate::simulation::time::{DayPhase, SimulationTime, TICKS_PER_GAME_MINUTE};
|
||||
use crate::knowledge::types::StableId;
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
// --- derive_background_mood ---
|
||||
|
||||
#[test]
|
||||
fn background_mood_hostile_at_threshold() {
|
||||
assert_eq!(derive_background_mood(50, 50), NpcMood::Hostile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_mood_hostile_above_threshold() {
|
||||
assert_eq!(derive_background_mood(80, 50), NpcMood::Hostile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_mood_anxious_at_60_percent() {
|
||||
// 60% of threshold=100 is 60. stress=60 → Anxious (60*100 >= 100*60)
|
||||
assert_eq!(derive_background_mood(60, 100), NpcMood::Anxious);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_mood_anxious_boundary_below_threshold() {
|
||||
// threshold=50: 60% = 30. stress=30 → Anxious (30*100=3000 >= 50*60=3000)
|
||||
assert_eq!(derive_background_mood(30, 50), NpcMood::Anxious);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_mood_content_low_stress() {
|
||||
// stress=10 < 20 → Content (not hostile, not anxious)
|
||||
assert_eq!(derive_background_mood(10, 50), NpcMood::Content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_mood_neutral_moderate_stress() {
|
||||
// stress=25, threshold=50: not hostile, not anxious (25*100=2500 < 50*60=3000),
|
||||
// not content (25 >= 20) → Neutral
|
||||
assert_eq!(derive_background_mood(25, 50), NpcMood::Neutral);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_mood_zero_threshold_is_hostile() {
|
||||
// stress=0 >= threshold=0 → Hostile
|
||||
assert_eq!(derive_background_mood(0, 0), NpcMood::Hostile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_mood_zero_stress_moderate_threshold_is_content() {
|
||||
// stress=0 < 20 → Content
|
||||
assert_eq!(derive_background_mood(0, 50), NpcMood::Content);
|
||||
}
|
||||
|
||||
// --- drift_job_performance ---
|
||||
|
||||
#[test]
|
||||
fn job_drift_up_when_positive_contentment() {
|
||||
assert_eq!(drift_job_performance(50, 10), 51);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_drift_down_when_negative_contentment() {
|
||||
assert_eq!(drift_job_performance(50, -10), 49);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_drift_unchanged_at_zero_contentment() {
|
||||
assert_eq!(drift_job_performance(50, 0), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_drift_clamps_at_100() {
|
||||
assert_eq!(drift_job_performance(100, 5), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_drift_clamps_at_0() {
|
||||
assert_eq!(drift_job_performance(0, -5), 0);
|
||||
}
|
||||
|
||||
// --- background_tick system integration tests ---
|
||||
|
||||
fn setup_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world
|
||||
}
|
||||
|
||||
fn run_system(world: &mut World) {
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(background_tick);
|
||||
schedule.run(world);
|
||||
}
|
||||
|
||||
fn make_routine(phase: DayPhase, activity: &str) -> DailyRoutine {
|
||||
DailyRoutine {
|
||||
entries: vec![RoutineEntry {
|
||||
phase,
|
||||
location: TilePosition::new(5, 5, 0),
|
||||
activity: activity.to_string(),
|
||||
}],
|
||||
description: "Test routine".into(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tick gating ---
|
||||
|
||||
#[test]
|
||||
fn does_not_fire_on_non_minute_tick() {
|
||||
let mut world = setup_world();
|
||||
// Set tick to 5 — not a multiple of TICKS_PER_GAME_MINUTE
|
||||
world.resource_mut::<SimulationTime>().tick = 5;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
ToleranceThreshold { current_stress: 60, threshold: 50 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
// Mood should NOT have updated (Hostile would fire if it ran)
|
||||
let mood = world.get::<MoodState>(npc).unwrap();
|
||||
assert_eq!(mood.mood, NpcMood::Neutral, "should not fire at tick=5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_at_tick_zero() {
|
||||
let mut world = setup_world();
|
||||
// tick=0 is 0 % 10 == 0, so it fires
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
ToleranceThreshold { current_stress: 60, threshold: 50 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let mood = world.get::<MoodState>(npc).unwrap();
|
||||
assert_eq!(mood.mood, NpcMood::Hostile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_at_tick_multiple_of_ticks_per_game_minute() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = TICKS_PER_GAME_MINUTE * 5;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
ToleranceThreshold { current_stress: 60, threshold: 50 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let mood = world.get::<MoodState>(npc).unwrap();
|
||||
assert_eq!(mood.mood, NpcMood::Hostile);
|
||||
}
|
||||
|
||||
// --- Active-tier NPCs not processed ---
|
||||
|
||||
#[test]
|
||||
fn active_npcs_not_processed() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
// ActiveSim NPC — must NOT be processed by background_tick
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
MoodState {
|
||||
mood: NpcMood::Warm,
|
||||
changed_tick: 0,
|
||||
},
|
||||
ToleranceThreshold { current_stress: 60, threshold: 50 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let mood = world.get::<MoodState>(npc).unwrap();
|
||||
assert_eq!(mood.mood, NpcMood::Warm, "ActiveSim NPC must not be updated by background_tick");
|
||||
}
|
||||
|
||||
// --- Mood state machine ---
|
||||
|
||||
#[test]
|
||||
fn mood_hostile_when_stress_at_threshold() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
ToleranceThreshold { current_stress: 50, threshold: 50 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
assert_eq!(world.get::<MoodState>(npc).unwrap().mood, NpcMood::Hostile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_content_when_no_tolerance() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
// No ToleranceThreshold → defaults (0, 50) → Content (0 < 20)
|
||||
let npc = world
|
||||
.spawn((Npc, BackgroundSim, MoodState::default()))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
assert_eq!(world.get::<MoodState>(npc).unwrap().mood, NpcMood::Content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_records_changed_tick() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = TICKS_PER_GAME_MINUTE * 3;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState { mood: NpcMood::Warm, changed_tick: 0 },
|
||||
ToleranceThreshold { current_stress: 55, threshold: 50 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let mood = world.get::<MoodState>(npc).unwrap();
|
||||
assert_eq!(mood.mood, NpcMood::Hostile);
|
||||
assert_eq!(mood.changed_tick, TICKS_PER_GAME_MINUTE * 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_unchanged_tick_not_updated() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
// Already Content, will derive Content → no change to changed_tick
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState { mood: NpcMood::Content, changed_tick: 42 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let mood = world.get::<MoodState>(npc).unwrap();
|
||||
assert_eq!(mood.mood, NpcMood::Content);
|
||||
assert_eq!(mood.changed_tick, 42, "changed_tick must not update when mood unchanged");
|
||||
}
|
||||
|
||||
// --- Schedule state machine ---
|
||||
|
||||
#[test]
|
||||
fn schedule_sets_activity_state_for_current_phase() {
|
||||
let mut world = setup_world();
|
||||
// tick=0 → DayPhase::Morning
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
make_routine(DayPhase::Morning, "Work"),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let activity = world.get::<ActivityState>(npc).unwrap();
|
||||
assert_eq!(activity.activity, "Work");
|
||||
assert_eq!(activity.phase, DayPhase::Morning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_no_activity_when_no_routine_entry_for_phase() {
|
||||
let mut world = setup_world();
|
||||
// tick=0 → Morning, but routine only has Afternoon
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
make_routine(DayPhase::Afternoon, "Meeting"),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
// No ActivityState should be inserted (Morning has no entry)
|
||||
assert!(world.get::<ActivityState>(npc).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_preserves_correct_activity_state() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0; // Morning
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
make_routine(DayPhase::Morning, "Work"),
|
||||
// Already has correct ActivityState — should not be re-inserted
|
||||
ActivityState {
|
||||
activity: "Work".into(),
|
||||
phase: DayPhase::Morning,
|
||||
started_tick: 0,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let activity = world.get::<ActivityState>(npc).unwrap();
|
||||
assert_eq!(activity.activity, "Work");
|
||||
}
|
||||
|
||||
// --- Relationships state machine ---
|
||||
|
||||
#[test]
|
||||
fn relationships_positive_trust_drifts_toward_zero() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
Relationships {
|
||||
entries: vec![Relationship {
|
||||
target_id: StableId(1),
|
||||
kind: RelationshipKind::Friend,
|
||||
trust_level: 5,
|
||||
history: vec![],
|
||||
}],
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let rels = world.get::<Relationships>(npc).unwrap();
|
||||
assert_eq!(rels.entries[0].trust_level, 4, "positive trust decrements by 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relationships_negative_trust_drifts_toward_zero() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
Relationships {
|
||||
entries: vec![Relationship {
|
||||
target_id: StableId(2),
|
||||
kind: RelationshipKind::Rival,
|
||||
trust_level: -4,
|
||||
history: vec![],
|
||||
}],
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let rels = world.get::<Relationships>(npc).unwrap();
|
||||
assert_eq!(rels.entries[0].trust_level, -3, "negative trust increments by 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relationships_zero_trust_stays_zero() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
Relationships {
|
||||
entries: vec![Relationship {
|
||||
target_id: StableId(3),
|
||||
kind: RelationshipKind::Colleague,
|
||||
trust_level: 0,
|
||||
history: vec![],
|
||||
}],
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let rels = world.get::<Relationships>(npc).unwrap();
|
||||
assert_eq!(rels.entries[0].trust_level, 0, "zero trust unchanged");
|
||||
}
|
||||
|
||||
// --- Job state machine ---
|
||||
|
||||
#[test]
|
||||
fn job_performance_rises_with_positive_contentment() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
JobPerformance { score: 60 },
|
||||
Contentment { level: 20 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let job = world.get::<JobPerformance>(npc).unwrap();
|
||||
assert_eq!(job.score, 61);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_performance_falls_with_negative_contentment() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
JobPerformance { score: 60 },
|
||||
Contentment { level: -15 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let job = world.get::<JobPerformance>(npc).unwrap();
|
||||
assert_eq!(job.score, 59);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_performance_unchanged_at_zero_contentment() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
JobPerformance { score: 50 },
|
||||
Contentment { level: 0 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let job = world.get::<JobPerformance>(npc).unwrap();
|
||||
assert_eq!(job.score, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_performance_without_component_no_panic() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
// No JobPerformance — system must not panic
|
||||
let _npc = world.spawn((Npc, BackgroundSim, MoodState::default())).id();
|
||||
|
||||
run_system(&mut world); // must not panic
|
||||
}
|
||||
|
||||
// --- Multiple NPCs independent ---
|
||||
|
||||
#[test]
|
||||
fn multiple_background_npcs_processed_independently() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let calm = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
ToleranceThreshold { current_stress: 5, threshold: 50 },
|
||||
))
|
||||
.id();
|
||||
|
||||
let hostile = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
ToleranceThreshold { current_stress: 55, threshold: 50 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
assert_eq!(world.get::<MoodState>(calm).unwrap().mood, NpcMood::Content);
|
||||
assert_eq!(world.get::<MoodState>(hostile).unwrap().mood, NpcMood::Hostile);
|
||||
}
|
||||
}
|
||||
@@ -31,12 +31,15 @@ use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::knowledge::types::{FactId, KnowledgeConfidence, StableId};
|
||||
use crate::npc::{
|
||||
CombatCapability, CombatStyle, Contentment, DailyRoutine, InformationInventory, KnownFact,
|
||||
Npc, PersonalityTrait, PersonalityTraits, Relationship, RelationshipKind, Relationships,
|
||||
RoutineEntry, Secret, SecretSeverity, Skill, SkillSet, Tell, TellSystem, TellTrigger,
|
||||
ToleranceThreshold, Want, WantKind, MAX_KEY_RELATIONSHIPS,
|
||||
CombatCapability, CombatStyle, Contentment, DailyRoutine, InformationInventory, JobPerformance,
|
||||
KnownFact, Npc, PersonalityTrait, PersonalityTraits, Relationship, RelationshipKind,
|
||||
Relationships, RoutineEntry, Secret, SecretSeverity, Skill, SkillSet, Tell, TellSystem,
|
||||
TellTrigger, ToleranceThreshold, Want, WantKind, MAX_KEY_RELATIONSHIPS,
|
||||
};
|
||||
use crate::knowledge::graph::KnowledgeGraph;
|
||||
use crate::npc::mood::MoodState;
|
||||
use crate::npc::awareness::PlayerAwareness;
|
||||
use crate::npc::vision::{NpcMemory, NpcVisionState};
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::rng::SimRng;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
@@ -480,6 +483,16 @@ pub fn generate_npc(role: &RoleDefinition, world: &mut World, rng: &mut SimRng)
|
||||
tells,
|
||||
skills,
|
||||
MoodState::default(),
|
||||
JobPerformance::default(),
|
||||
));
|
||||
|
||||
// Vision + knowledge + awareness components (#115, #244, D-041).
|
||||
// Separate insert to stay within tuple bundle element limit.
|
||||
entity_builder.insert((
|
||||
KnowledgeGraph::new(),
|
||||
NpcVisionState::default(),
|
||||
NpcMemory::default(),
|
||||
PlayerAwareness::default(),
|
||||
));
|
||||
|
||||
if let Some(cap) = combat_opt {
|
||||
@@ -876,6 +889,69 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Combat capability — positive case (#91, D-024)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn combat_role_probabilistically_produces_combat_capability() {
|
||||
// #91 positive-case: at least one seed must produce CombatCapability for
|
||||
// a combat-enabled role. The probability is 2/3 per seed (RNG < 2/3 range),
|
||||
// so 30 seeds is overwhelmingly likely to produce at least one match.
|
||||
let role = minimal_role(); // combat_enabled = true
|
||||
let mut any_combat = false;
|
||||
for seed in 0..30_u64 {
|
||||
let mut world = World::new();
|
||||
let mut rng = make_rng(seed);
|
||||
let entity = generate_npc(&role, &mut world, &mut rng);
|
||||
if world.get::<CombatCapability>(entity).is_some() {
|
||||
any_combat = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
any_combat,
|
||||
"combat-enabled role must produce CombatCapability for at least one seed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combat_trained_flag_matches_combat_capability_presence() {
|
||||
// #91 invariant: SkillSet.combat_trained must be consistent with CombatCapability.
|
||||
// If CombatCapability is present, combat_trained must be true and vice versa.
|
||||
let role = minimal_role(); // combat_enabled = true
|
||||
for seed in 0..50_u64 {
|
||||
let mut world = World::new();
|
||||
let mut rng = make_rng(seed);
|
||||
let entity = generate_npc(&role, &mut world, &mut rng);
|
||||
let skills = world.get::<SkillSet>(entity).unwrap();
|
||||
let has_cap = world.get::<CombatCapability>(entity).is_some();
|
||||
assert_eq!(
|
||||
skills.combat_trained, has_cap,
|
||||
"seed {seed}: SkillSet.combat_trained={} must match CombatCapability presence={}",
|
||||
skills.combat_trained, has_cap
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combat_capability_proficiency_in_valid_range() {
|
||||
// #91: CombatCapability.weapon_proficiency must be in 3-8 range per gen_skills().
|
||||
let role = minimal_role(); // combat_enabled = true
|
||||
for seed in 0..100_u64 {
|
||||
let mut world = World::new();
|
||||
let mut rng = make_rng(seed);
|
||||
let entity = generate_npc(&role, &mut world, &mut rng);
|
||||
if let Some(cap) = world.get::<CombatCapability>(entity) {
|
||||
assert!(
|
||||
cap.weapon_proficiency >= 3 && cap.weapon_proficiency <= 8,
|
||||
"seed {seed}: weapon_proficiency {} out of valid range 3-8",
|
||||
cap.weapon_proficiency
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ActiveSim tier
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// Implements D-024: 10-axis NPC model + CombatCapability component
|
||||
// Background tier state machines for schedule, mood, relationships, job
|
||||
|
||||
pub mod awareness;
|
||||
pub mod background;
|
||||
pub mod disclosure;
|
||||
pub mod generate;
|
||||
pub mod interaction;
|
||||
@@ -11,6 +13,7 @@ pub mod routine;
|
||||
pub mod tell_state;
|
||||
pub mod tolerance;
|
||||
pub mod trait_modifiers;
|
||||
pub mod vision;
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
@@ -29,6 +32,8 @@ impl Plugin for NpcPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<relationships::RelationshipGraph>()
|
||||
.init_resource::<relationships::TrustEventQueue>()
|
||||
.init_resource::<relationships::PropagationQueue>()
|
||||
.init_resource::<relationships::DelayedTrustQueue>()
|
||||
.init_resource::<crate::knowledge::KnowledgeEventQueue>()
|
||||
.init_resource::<routine::PreviousDayPhase>()
|
||||
.init_resource::<tolerance::ToleranceBreachEventQueue>()
|
||||
@@ -49,6 +54,9 @@ impl Plugin for NpcPlugin {
|
||||
.after(crate::simulation::dialogue::process_confrontation_response)
|
||||
.after(crate::simulation::dialogue::process_dialogue_response)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
relationships::propagate_social_actions
|
||||
.after(relationships::update_trust)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
relationships::update_relationship_dynamics
|
||||
.after(relationships::update_trust)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
@@ -65,11 +73,29 @@ impl Plugin for NpcPlugin {
|
||||
.after(mood::update_mood)
|
||||
.after(routine::detect_routine_deviation)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
background::background_tick
|
||||
.after(crate::simulation::movement::validate_movement)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
disclosure::derive_disclosure_candidates
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
disclosure::process_unprompted_disclosure
|
||||
.after(disclosure::derive_disclosure_candidates)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
// NPC player-awareness (#244)
|
||||
awareness::detect_player_awareness
|
||||
.after(vision::compute_npc_vision)
|
||||
.before(tolerance::check_tolerance_threshold),
|
||||
// NPC vision system (#115, D-011)
|
||||
vision::compute_npc_vision
|
||||
.after(crate::simulation::movement::validate_movement)
|
||||
.after(crate::simulation::tier::update_tier_markers)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
vision::emit_npc_vision_events
|
||||
.after(vision::compute_npc_vision)
|
||||
.before(crate::knowledge::events::process_knowledge_events),
|
||||
vision::degrade_npc_inferences
|
||||
.after(vision::emit_npc_vision_events)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
crate::simulation::dialogue::process_talk_interaction
|
||||
.after(crate::simulation::input::process_player_input),
|
||||
crate::simulation::dialogue::process_walk_away
|
||||
@@ -268,6 +294,30 @@ pub struct Contentment {
|
||||
pub level: i16, // -100..+100, integer for determinism (D-010)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Job performance (D-026 background state machine — feeds from Contentment)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Tracks how well an NPC performs their job role.
|
||||
///
|
||||
/// Drifts based on `Contentment` level in the background-tier state machine:
|
||||
/// - contentment > 0 → score increases toward 100
|
||||
/// - contentment < 0 → score decreases toward 0
|
||||
/// - contentment = 0 → no change
|
||||
///
|
||||
/// Updated by `background::background_tick` once per game-minute for
|
||||
/// Background-tier NPCs. Persists through tier promotions (D-026).
|
||||
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JobPerformance {
|
||||
pub score: i16, // 0..=100, integer for determinism (D-010)
|
||||
}
|
||||
|
||||
impl Default for JobPerformance {
|
||||
fn default() -> Self {
|
||||
Self { score: 50 }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Supporting axis 1: Personality traits (D-024)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -201,16 +201,122 @@ impl RelationshipGraph {
|
||||
// System: update_trust (#324)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Social propagation (#249, D-029)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Minimum absolute trust required on a relationship edge for that edge
|
||||
/// to carry propagation (D-029: "strong relationships", trust > 3).
|
||||
pub const PROPAGATION_TRUST_THRESHOLD: i8 = 3;
|
||||
|
||||
/// Delay in ticks before third-order propagation is applied.
|
||||
/// 30 ticks ≈ 3 game-minutes (D-031: 10 ticks/minute).
|
||||
pub const THIRD_ORDER_DELAY_TICKS: u64 = 30;
|
||||
|
||||
/// A first-order trust change that needs social propagation.
|
||||
///
|
||||
/// Produced by `update_trust` for each processed event, consumed by
|
||||
/// `propagate_social_actions` to fan out second- and third-order effects.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PropagationEvent {
|
||||
/// The NPC directly affected by the player action (first-order subject).
|
||||
pub npc: StableId,
|
||||
/// The player entity (target of how NPCs feel).
|
||||
pub player: StableId,
|
||||
/// The first-order delta (same as what was applied to the graph).
|
||||
pub delta: i8,
|
||||
}
|
||||
|
||||
/// Queue of propagation events emitted by `update_trust`.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct PropagationQueue {
|
||||
events: Vec<PropagationEvent>,
|
||||
}
|
||||
|
||||
impl PropagationQueue {
|
||||
pub fn push(&mut self, event: PropagationEvent) {
|
||||
self.events.push(event);
|
||||
}
|
||||
|
||||
pub fn drain(&mut self) -> Vec<PropagationEvent> {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
}
|
||||
|
||||
/// A deferred trust change for third-order propagation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DelayedTrustChange {
|
||||
/// NPC whose trust toward the player will be adjusted.
|
||||
pub npc: StableId,
|
||||
/// Player entity.
|
||||
pub player: StableId,
|
||||
/// Scaled delta to apply (already clamped to meaningful range).
|
||||
pub delta: i8,
|
||||
/// Tick at which this change should be applied.
|
||||
pub apply_at_tick: u64,
|
||||
}
|
||||
|
||||
/// Queue of deferred third-order trust changes.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct DelayedTrustQueue {
|
||||
pending: Vec<DelayedTrustChange>,
|
||||
}
|
||||
|
||||
impl DelayedTrustQueue {
|
||||
pub fn push(&mut self, change: DelayedTrustChange) {
|
||||
self.pending.push(change);
|
||||
}
|
||||
|
||||
/// Drain changes that are due at or before `current_tick`.
|
||||
pub fn drain_due(&mut self, current_tick: u64) -> Vec<DelayedTrustChange> {
|
||||
let mut due = Vec::new();
|
||||
let mut remaining = Vec::new();
|
||||
for change in self.pending.drain(..) {
|
||||
if change.apply_at_tick <= current_tick {
|
||||
due.push(change);
|
||||
} else {
|
||||
remaining.push(change);
|
||||
}
|
||||
}
|
||||
self.pending = remaining;
|
||||
due
|
||||
}
|
||||
|
||||
/// Number of pending deferred changes.
|
||||
pub fn pending_count(&self) -> usize {
|
||||
self.pending.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Scale a first-order delta by a propagation factor, using integer arithmetic
|
||||
/// (D-010: integer-only determinism). Returns 0 when the scaled value would
|
||||
/// round to zero — small deltas naturally attenuate to nothing.
|
||||
///
|
||||
/// Factor is expressed in tenths (e.g. 4 = 40%, 2 = 15%... using 2/10=20%
|
||||
/// as closest deterministic integer approximation of 15%).
|
||||
///
|
||||
/// Rounding: away from zero (ceiling of abs value, preserving sign).
|
||||
fn scale_delta(delta: i8, factor_tenths: i8) -> i8 {
|
||||
// Multiply by factor, round up (ceiling of absolute value)
|
||||
let scaled_abs = (delta.unsigned_abs() as i16 * factor_tenths as i16 + 9) / 10;
|
||||
let scaled = scaled_abs.min(10) as i8;
|
||||
if delta < 0 { -(scaled as i8) } else { scaled as i8 }
|
||||
}
|
||||
|
||||
/// Drain pending trust events and apply deltas to the RelationshipGraph.
|
||||
///
|
||||
/// Each event adjusts the NPC→player trust edge. If no edge exists,
|
||||
/// one is created with default Colleague kind and trust 0 before applying
|
||||
/// the delta. Trust is clamped to [-10, +10] per D-010.
|
||||
///
|
||||
/// Also queues a `PropagationEvent` for each processed event so that
|
||||
/// `propagate_social_actions` can fan out second- and third-order effects.
|
||||
///
|
||||
/// System ordering: after dialogue systems (which emit the events),
|
||||
/// before advance_tick.
|
||||
pub fn update_trust(
|
||||
mut queue: ResMut<TrustEventQueue>,
|
||||
mut propagation_queue: ResMut<PropagationQueue>,
|
||||
mut graph: ResMut<RelationshipGraph>,
|
||||
registry: Res<EntityRegistry>,
|
||||
time: Res<SimulationTime>,
|
||||
@@ -244,6 +350,116 @@ pub fn update_trust(
|
||||
new_trust = edge.trust,
|
||||
"Trust updated"
|
||||
);
|
||||
|
||||
// Queue propagation event for second/third-order effects (#249)
|
||||
propagation_queue.push(PropagationEvent {
|
||||
npc: npc_sid,
|
||||
player: player_sid,
|
||||
delta,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// System: fan out player-action trust changes through the social graph (#249, D-029).
|
||||
///
|
||||
/// Processes `PropagationQueue` events (produced by `update_trust`) and applies:
|
||||
/// - **Second-order** (immediate, 40%): NPCs with trust > 3 toward the first-order NPC
|
||||
/// - **Third-order** (delayed 30 ticks, 15%): one further hop, same threshold
|
||||
///
|
||||
/// Cycle prevention: a visited set per propagation pass prevents A→B→A loops.
|
||||
/// Propagation topology varies per seed (D-029 anti-metagaming property) because
|
||||
/// the `RelationshipGraph` is seeded differently per run.
|
||||
///
|
||||
/// System ordering: after `update_trust`, before `advance_tick`.
|
||||
pub fn propagate_social_actions(
|
||||
mut propagation_queue: ResMut<PropagationQueue>,
|
||||
mut delay_queue: ResMut<DelayedTrustQueue>,
|
||||
mut graph: ResMut<RelationshipGraph>,
|
||||
time: Res<SimulationTime>,
|
||||
) {
|
||||
// --- Apply any due delayed third-order changes first ---
|
||||
for change in delay_queue.drain_due(time.tick) {
|
||||
if change.delta != 0 {
|
||||
graph.ensure_edge(change.npc, change.player, time.tick);
|
||||
graph.adjust_trust(&change.npc, &change.player, change.delta);
|
||||
tracing::trace!(
|
||||
npc = change.npc.0,
|
||||
player = change.player.0,
|
||||
delta = change.delta,
|
||||
"Third-order trust propagated (delayed)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fan out new propagation events ---
|
||||
for event in propagation_queue.drain() {
|
||||
let PropagationEvent { npc, player, delta } = event;
|
||||
|
||||
// Visited set prevents cycles (D-029)
|
||||
let mut visited = std::collections::BTreeSet::new();
|
||||
visited.insert(npc);
|
||||
|
||||
// --- Second-order (immediate, 40% of delta) ---
|
||||
let second_delta = scale_delta(delta, 4); // 40%
|
||||
if second_delta != 0 {
|
||||
// Find NPCs that the first-order NPC trusts strongly
|
||||
let second_order: Vec<StableId> = graph
|
||||
.relationships_of(&npc)
|
||||
.into_iter()
|
||||
.filter(|(_, edge)| edge.trust > PROPAGATION_TRUST_THRESHOLD)
|
||||
.map(|(target, _)| *target)
|
||||
.collect();
|
||||
|
||||
for second in &second_order {
|
||||
if visited.contains(second) {
|
||||
continue;
|
||||
}
|
||||
visited.insert(*second);
|
||||
graph.ensure_edge(*second, player, time.tick);
|
||||
graph.adjust_trust(second, &player, second_delta);
|
||||
tracing::trace!(
|
||||
first_order_npc = npc.0,
|
||||
second_order_npc = second.0,
|
||||
player = player.0,
|
||||
delta = second_delta,
|
||||
"Second-order trust propagated"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Third-order (delayed 30 ticks, 15% of delta) ---
|
||||
let third_delta = scale_delta(delta, 2); // ~15% (2/10 = 20%, nearest integer approx)
|
||||
if third_delta != 0 {
|
||||
let apply_at = time.tick + THIRD_ORDER_DELAY_TICKS;
|
||||
for second in &second_order {
|
||||
let third_order: Vec<StableId> = graph
|
||||
.relationships_of(second)
|
||||
.into_iter()
|
||||
.filter(|(_, edge)| edge.trust > PROPAGATION_TRUST_THRESHOLD)
|
||||
.map(|(target, _)| *target)
|
||||
.collect();
|
||||
|
||||
for third in third_order {
|
||||
if visited.contains(&third) {
|
||||
continue;
|
||||
}
|
||||
visited.insert(third);
|
||||
delay_queue.push(DelayedTrustChange {
|
||||
npc: third,
|
||||
player,
|
||||
delta: third_delta,
|
||||
apply_at_tick: apply_at,
|
||||
});
|
||||
tracing::trace!(
|
||||
third_order_npc = third.0,
|
||||
player = player.0,
|
||||
delta = third_delta,
|
||||
apply_at,
|
||||
"Third-order trust queued for delayed propagation"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,6 +709,7 @@ mod tests {
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<RelationshipGraph>();
|
||||
world.init_resource::<TrustEventQueue>();
|
||||
world.init_resource::<PropagationQueue>();
|
||||
world
|
||||
}
|
||||
|
||||
@@ -748,6 +965,380 @@ mod tests {
|
||||
assert_eq!(edge.trust, 6); // 5 + 1
|
||||
}
|
||||
|
||||
// -- PropagationQueue tests (#249) ----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn propagation_queue_push_and_drain() {
|
||||
let mut q = PropagationQueue::default();
|
||||
q.push(PropagationEvent {
|
||||
npc: StableId(1),
|
||||
player: StableId(2),
|
||||
delta: 1,
|
||||
});
|
||||
q.push(PropagationEvent {
|
||||
npc: StableId(3),
|
||||
player: StableId(2),
|
||||
delta: -2,
|
||||
});
|
||||
let drained = q.drain();
|
||||
assert_eq!(drained.len(), 2);
|
||||
assert!(q.drain().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_trust_queue_drain_due_filters_by_tick() {
|
||||
let mut q = DelayedTrustQueue::default();
|
||||
q.push(DelayedTrustChange {
|
||||
npc: StableId(1),
|
||||
player: StableId(10),
|
||||
delta: 1,
|
||||
apply_at_tick: 50,
|
||||
});
|
||||
q.push(DelayedTrustChange {
|
||||
npc: StableId(2),
|
||||
player: StableId(10),
|
||||
delta: -1,
|
||||
apply_at_tick: 100,
|
||||
});
|
||||
|
||||
// Only change at tick 50 is due at tick 60
|
||||
let due = q.drain_due(60);
|
||||
assert_eq!(due.len(), 1);
|
||||
assert_eq!(due[0].npc, StableId(1));
|
||||
// Change at tick 100 is still pending
|
||||
assert_eq!(q.pending_count(), 1);
|
||||
|
||||
// At tick 100 it becomes due
|
||||
let due2 = q.drain_due(100);
|
||||
assert_eq!(due2.len(), 1);
|
||||
assert_eq!(due2[0].npc, StableId(2));
|
||||
assert_eq!(q.pending_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_delta_forty_percent() {
|
||||
// factor_tenths=4 → 40%
|
||||
assert_eq!(scale_delta(1, 4), 1); // 0.4 → rounds up to 1
|
||||
assert_eq!(scale_delta(2, 4), 1); // 0.8 → rounds up to 1
|
||||
assert_eq!(scale_delta(5, 4), 2); // 2.0 → 2
|
||||
assert_eq!(scale_delta(10, 4), 4); // 4.0 → 4
|
||||
assert_eq!(scale_delta(-2, 4), -1); // negative preserved
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_delta_zero_when_too_small() {
|
||||
// delta=0 → always 0
|
||||
assert_eq!(scale_delta(0, 4), 0);
|
||||
}
|
||||
|
||||
// -- propagate_social_actions system tests (#249) -------------------------
|
||||
|
||||
fn setup_propagation_world() -> bevy_ecs::world::World {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world.init_resource::<RelationshipGraph>();
|
||||
world.init_resource::<PropagationQueue>();
|
||||
world.init_resource::<DelayedTrustQueue>();
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_order_trust_propagates_immediately() {
|
||||
// Spec (#249, D-029): NPCs strongly connected to the first-order NPC
|
||||
// receive 40% of the delta in the same tick.
|
||||
//
|
||||
// Graph: A → B (trust 5, > threshold 3)
|
||||
// A → player (will be first-order)
|
||||
// Event: player action affects A (delta=+2)
|
||||
// Expected: B gains 40% of 2 = 1 (ceil) toward player
|
||||
let mut world = setup_propagation_world();
|
||||
|
||||
let npc_a = StableId(1);
|
||||
let npc_b = StableId(2);
|
||||
let player = StableId(99);
|
||||
|
||||
// A strongly trusts B (A→B trust=5)
|
||||
world.resource_mut::<RelationshipGraph>().set_relationship(
|
||||
npc_a,
|
||||
npc_b,
|
||||
make_edge(RelationshipKind::Friend, 5),
|
||||
);
|
||||
|
||||
// Queue propagation from A
|
||||
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||
npc: npc_a,
|
||||
player,
|
||||
delta: 2,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(propagate_social_actions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// B should now have a trust edge toward the player (positive)
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
let edge = graph.get_relationship(&npc_b, &player).expect("B should have edge to player");
|
||||
assert!(
|
||||
edge.trust > 0,
|
||||
"Second-order NPC should trust player more after positive first-order event"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weak_relationship_does_not_propagate() {
|
||||
// Spec (#249): Only edges with trust > PROPAGATION_TRUST_THRESHOLD (3) carry propagation.
|
||||
//
|
||||
// Graph: A → B (trust 2, ≤ threshold 3)
|
||||
// Event: player action affects A (delta=+5)
|
||||
// Expected: B gets no propagation (trust ≤ threshold)
|
||||
let mut world = setup_propagation_world();
|
||||
|
||||
let npc_a = StableId(1);
|
||||
let npc_b = StableId(2);
|
||||
let player = StableId(99);
|
||||
|
||||
// A weakly trusts B (below threshold)
|
||||
world.resource_mut::<RelationshipGraph>().set_relationship(
|
||||
npc_a,
|
||||
npc_b,
|
||||
make_edge(RelationshipKind::Colleague, 2),
|
||||
);
|
||||
|
||||
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||
npc: npc_a,
|
||||
player,
|
||||
delta: 5,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(propagate_social_actions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// B should NOT have any edge to the player
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
assert!(
|
||||
graph.get_relationship(&npc_b, &player).is_none(),
|
||||
"Weak relationship should not carry propagation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn third_order_trust_is_deferred_by_thirty_ticks() {
|
||||
// Spec (#249): Third-order changes are queued for 30 ticks in the future.
|
||||
//
|
||||
// Graph: A → B (trust 5), B → C (trust 5)
|
||||
// Event: player action affects A (delta=+5)
|
||||
// Expected: C's change is queued for tick+30, not applied immediately
|
||||
let mut world = setup_propagation_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10; // Set a known tick
|
||||
|
||||
let npc_a = StableId(1);
|
||||
let npc_b = StableId(2);
|
||||
let npc_c = StableId(3);
|
||||
let player = StableId(99);
|
||||
|
||||
let mut graph = world.resource_mut::<RelationshipGraph>();
|
||||
graph.set_relationship(npc_a, npc_b, make_edge(RelationshipKind::Friend, 5));
|
||||
graph.set_relationship(npc_b, npc_c, make_edge(RelationshipKind::Friend, 5));
|
||||
|
||||
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||
npc: npc_a,
|
||||
player,
|
||||
delta: 5,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(propagate_social_actions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// C should NOT have an edge yet (it's deferred)
|
||||
{
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
assert!(
|
||||
graph.get_relationship(&npc_c, &player).is_none(),
|
||||
"Third-order changes should be deferred, not applied immediately"
|
||||
);
|
||||
}
|
||||
|
||||
// Delay queue should have one pending change for C at tick 10+30=40
|
||||
let delay_queue = world.resource::<DelayedTrustQueue>();
|
||||
assert_eq!(delay_queue.pending_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_changes_applied_when_due() {
|
||||
// Once the tick advances past apply_at_tick, the delayed change is applied.
|
||||
let mut world = setup_propagation_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
|
||||
let npc_a = StableId(1);
|
||||
let npc_b = StableId(2);
|
||||
let npc_c = StableId(3);
|
||||
let player = StableId(99);
|
||||
|
||||
let mut graph = world.resource_mut::<RelationshipGraph>();
|
||||
graph.set_relationship(npc_a, npc_b, make_edge(RelationshipKind::Friend, 5));
|
||||
graph.set_relationship(npc_b, npc_c, make_edge(RelationshipKind::Friend, 5));
|
||||
|
||||
// First run: queue the third-order change
|
||||
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||
npc: npc_a,
|
||||
player,
|
||||
delta: 5,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(propagate_social_actions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Advance tick to 40 (past apply_at_tick = 40)
|
||||
world.resource_mut::<SimulationTime>().tick = 40;
|
||||
schedule.run(&mut world);
|
||||
|
||||
// C should now have an edge with positive trust
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
let edge = graph.get_relationship(&npc_c, &player)
|
||||
.expect("Delayed change should have been applied by now");
|
||||
assert!(edge.trust > 0, "Third-order trust should be positive after delayed application");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_prevention_no_a_to_b_to_a_loop() {
|
||||
// Spec (#249, D-029): visited set prevents A→B→A cycles.
|
||||
//
|
||||
// Graph: A ↔ B (both trust each other at 5)
|
||||
// Event: player action affects A (delta=+2)
|
||||
// A should propagate to B, but B should NOT propagate back to A.
|
||||
let mut world = setup_propagation_world();
|
||||
|
||||
let npc_a = StableId(1);
|
||||
let npc_b = StableId(2);
|
||||
let player = StableId(99);
|
||||
|
||||
// Bidirectional strong trust
|
||||
world.resource_mut::<RelationshipGraph>().set_relationship(
|
||||
npc_a,
|
||||
npc_b,
|
||||
make_edge(RelationshipKind::Friend, 5),
|
||||
);
|
||||
world.resource_mut::<RelationshipGraph>().set_relationship(
|
||||
npc_b,
|
||||
npc_a,
|
||||
make_edge(RelationshipKind::Friend, 5),
|
||||
);
|
||||
|
||||
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||
npc: npc_a,
|
||||
player,
|
||||
delta: 2,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(propagate_social_actions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// A should not get a second-order change from the cycle (A was the origin)
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
// The only A→player edge effect should be through the propagation event
|
||||
// (no direct creation in propagate_social_actions — only update_trust does that).
|
||||
// B should have an edge to player.
|
||||
assert!(
|
||||
graph.get_relationship(&npc_b, &player).is_some(),
|
||||
"B should get second-order propagation from A"
|
||||
);
|
||||
// A should NOT have a re-propagated edge (cycle prevented)
|
||||
assert!(
|
||||
graph.get_relationship(&npc_a, &player).is_none(),
|
||||
"A should not receive back-propagation from B (cycle prevention)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_delta_propagates_as_negative() {
|
||||
// Spec (#249): Negative deltas (walk-away, confrontation) also propagate
|
||||
// with the same sign.
|
||||
let mut world = setup_propagation_world();
|
||||
|
||||
let npc_a = StableId(1);
|
||||
let npc_b = StableId(2);
|
||||
let player = StableId(99);
|
||||
|
||||
world.resource_mut::<RelationshipGraph>().set_relationship(
|
||||
npc_a,
|
||||
npc_b,
|
||||
make_edge(RelationshipKind::Friend, 5),
|
||||
);
|
||||
|
||||
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||
npc: npc_a,
|
||||
player,
|
||||
delta: -2, // confrontation
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(propagate_social_actions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// B should trust player LESS after A was confronted
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
let edge = graph.get_relationship(&npc_b, &player)
|
||||
.expect("B should have edge to player");
|
||||
assert!(
|
||||
edge.trust < 0,
|
||||
"Negative propagation should reduce B's trust in player"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_relationships_means_no_propagation() {
|
||||
// If the first-order NPC has no relationships, the queue is drained
|
||||
// but nothing propagates.
|
||||
let mut world = setup_propagation_world();
|
||||
|
||||
let npc_a = StableId(1);
|
||||
let player = StableId(99);
|
||||
|
||||
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||
npc: npc_a,
|
||||
player,
|
||||
delta: 5,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(propagate_social_actions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
assert!(graph.is_empty(), "No relationships → no propagation edges created");
|
||||
|
||||
let delay_queue = world.resource::<DelayedTrustQueue>();
|
||||
assert_eq!(delay_queue.pending_count(), 0, "No delay queue entries either");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_trust_populates_propagation_queue() {
|
||||
// Integration: update_trust should push to PropagationQueue for downstream #249.
|
||||
let mut world = setup_trust_world();
|
||||
|
||||
let npc = world.spawn_empty().id();
|
||||
let player = world.spawn_empty().id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
world
|
||||
.resource_mut::<TrustEventQueue>()
|
||||
.push(TrustEvent::TalkCompleted { npc, player });
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(update_trust);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// PropagationQueue should have 1 event (for downstream propagation)
|
||||
let prop_events = world.resource_mut::<PropagationQueue>().drain();
|
||||
assert_eq!(prop_events.len(), 1);
|
||||
assert_eq!(prop_events[0].delta, TALK_TRUST_DELTA);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregistered_entity_event_is_skipped() {
|
||||
let mut world = setup_trust_world();
|
||||
|
||||
@@ -0,0 +1,795 @@
|
||||
//! 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::time::SimulationTime;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
/// 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;
|
||||
|
||||
// Query entities within vision range, then filter by FOV tile set
|
||||
let nearby = spatial_index.entities_in_range(npc_pos, NPC_VISION_RANGE as u32);
|
||||
let at_origin = spatial_index.entities_at(npc_pos);
|
||||
|
||||
for entity in nearby.into_iter().chain(at_origin.into_iter()) {
|
||||
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 % 10 != 0 {
|
||||
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 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user