//! 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, mut npc_query: Query< ( &NpcVisionState, &mut PlayerAwareness, &mut ToleranceThreshold, ), (With, With), >, ) { 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::(); 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::(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::(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::(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::(npc).unwrap(); assert_eq!( awareness.suspicion_level, 0, "suspicion should not build before notice threshold" ); let tolerance = world.get::(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::(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::(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::(npc).unwrap(); assert_eq!( awareness.suspicion_level, 10 + AWARENESS_STRESS_PER_TICK, "suspicion should accumulate" ); let tolerance = world.get::(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::(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::(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::().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::(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::().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::(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::().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::(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::(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::(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" ); } }