Files
settled-reach/server/src/npc/awareness.rs
T
jpmschweitzerandClaude Opus 4.6 276538cde1 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>
2026-02-25 09:50:37 +01:00

606 lines
20 KiB
Rust
Raw Blame History

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