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:
2026-02-25 09:50:37 +01:00
co-authored by Claude Opus 4.6
parent 2eca8e961b
commit 276538cde1
16 changed files with 4450 additions and 5 deletions
+605
View File
@@ -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"
);
}
}
+730
View File
@@ -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);
}
}
+80 -4
View File
@@ -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
// -----------------------------------------------------------------------
+50
View File
@@ -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)
// ---------------------------------------------------------------------------
+591
View File
@@ -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();
+795
View File
@@ -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");
}
}