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>
606 lines
20 KiB
Rust
606 lines
20 KiB
Rust
//! 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"
|
||
);
|
||
}
|
||
}
|