//! Follow mechanic (#241) — player designates an NPC as follow target. //! //! Server tracks: target NPC entity, distance, LOS state, proximity ticks. //! NPC suspicion increases via `ToleranceThreshold` stress when the player //! maintains close proximity + LOS for sustained ticks ("too close too long"). //! //! Follow ends when: //! - Target leaves LOS for `FOLLOW_LOS_LOST_TIMEOUT` ticks (deep fog) //! - Target's tolerance threshold is breached (suspicion detected) //! - Player issues a different Interact verb (handled in input.rs) //! //! All arithmetic is integer-only (D-010 determinism). use bevy_ecs::prelude::*; use crate::npc::tolerance::ToleranceBreached; use crate::npc::{Npc, ToleranceThreshold}; use crate::perception::query::VisibilityGeometry; use crate::simulation::movement::{PlayerCharacter, TilePosition}; use crate::simulation::time::SimulationTime; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /// Manhattan distance within which follow counts as "too close" (tiles). pub const FOLLOW_PROXIMITY_RANGE: u32 = 2; /// Ticks of sustained close proximity before suspicion stress starts. pub const FOLLOW_SUSPICION_TICKS: u64 = 60; /// Ticks without LOS before follow auto-ends (deep fog timeout). pub const FOLLOW_LOS_LOST_TIMEOUT: u64 = 30; /// Stress increment per tick when player is "too close too long" (integer, D-010). pub const FOLLOW_STRESS_PER_TICK: i16 = 2; // --------------------------------------------------------------------------- // Components // --------------------------------------------------------------------------- /// Marker component on the player: designates an NPC as the follow target. /// /// Inserted by `handle_follow` in `input.rs` when the player uses the Follow verb. /// Removed by `update_follow_state` when follow ends, or by input processing /// when the player issues a different action. #[derive(Component, Debug, Clone)] pub struct FollowTarget { /// The NPC entity being followed. pub target: Entity, /// Tick when follow started. pub started_tick: u64, /// Consecutive ticks player has been within FOLLOW_PROXIMITY_RANGE with LOS. pub proximity_ticks: u64, /// Consecutive ticks LOS to target has been lost. pub los_lost_ticks: u64, } /// Event emitted when follow ends, for downstream systems to react. #[derive(Debug, Clone)] pub struct FollowEndEvent { /// The player entity that was following. pub player: Entity, /// The NPC entity that was being followed. pub target: Entity, /// Why the follow ended. pub reason: FollowEndReason, /// Tick when follow ended. pub tick: u64, } /// Why a follow ended. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum FollowEndReason { /// Target left LOS for too long. LosLost, /// Target's tolerance threshold was breached (detected player). Detected, /// Player issued a different action. PlayerAction, } /// Resource: queue of follow end events (drained per tick). #[derive(Resource, Default)] pub struct FollowEndEventQueue { pub events: Vec, } impl FollowEndEventQueue { pub fn push(&mut self, event: FollowEndEvent) { self.events.push(event); } pub fn drain(&mut self) -> Vec { std::mem::take(&mut self.events) } pub fn is_empty(&self) -> bool { self.events.is_empty() } } // --------------------------------------------------------------------------- // Wire type for ObserverSnapshot // --------------------------------------------------------------------------- /// Follow state sent to the client for HUD display (#241). /// /// Present when the player is actively following an NPC. /// Client shows follow-mode indicator with distance and LOS state. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct FollowStateWire { /// Wire-format entity ID of the follow target. pub target_entity_id: u64, /// Current Manhattan distance to target. pub distance: u32, /// Whether the player currently has LOS to the target. pub has_los: bool, /// Consecutive ticks of close proximity (for UI tension indicator). pub proximity_ticks: u64, } // --------------------------------------------------------------------------- // System // --------------------------------------------------------------------------- /// System: update follow state each tick for players with a FollowTarget. /// /// Runs after movement validation and visibility geometry computation. /// For each player with `FollowTarget`: /// /// 1. Check if target still exists and has a position. /// 2. Compute Manhattan distance to target. /// 3. Check LOS via VisibilityGeometry (player's precomputed FOV). /// 4. Update proximity_ticks and los_lost_ticks counters. /// 5. Apply suspicion stress to target's ToleranceThreshold when "too close too long". /// 6. End follow if LOS lost for timeout or target's tolerance is breached. /// /// Scoped to players only (FollowTarget is a player component). pub fn update_follow_state( mut commands: Commands, time: Res, geometry: Res, mut end_queue: ResMut, mut player_query: Query<(Entity, &TilePosition, &mut FollowTarget), With>, target_query: Query<(&TilePosition, Option<&ToleranceBreached>), With>, mut tolerance_query: Query<&mut ToleranceThreshold, With>, ) { let tick = time.tick; let Ok((player_entity, player_pos, mut follow)) = player_query.single_mut() else { return; }; // Check target still exists with a position let Ok((target_pos, is_detected)) = target_query.get(follow.target) else { // Target despawned or lost position — end follow commands.entity(player_entity).remove::(); end_queue.push(FollowEndEvent { player: player_entity, target: follow.target, reason: FollowEndReason::LosLost, tick, }); return; }; // Check if target's tolerance has been breached (detected player) if is_detected.is_some() { commands.entity(player_entity).remove::(); end_queue.push(FollowEndEvent { player: player_entity, target: follow.target, reason: FollowEndReason::Detected, tick, }); return; } // Compute distance let distance = player_pos .manhattan_distance(target_pos) .unwrap_or(u32::MAX); // Check LOS: target position must be in the player's precomputed visibility let has_los = target_pos.z == geometry.observer_z && geometry .visible_positions .contains(&(target_pos.x, target_pos.y)); if has_los { // Reset LOS lost counter follow.los_lost_ticks = 0; // Track close proximity if distance <= FOLLOW_PROXIMITY_RANGE { follow.proximity_ticks += 1; } else { // Not close — reset proximity counter (must be sustained) follow.proximity_ticks = 0; } // Apply suspicion stress when "too close too long" if follow.proximity_ticks >= FOLLOW_SUSPICION_TICKS { if let Ok(mut tolerance) = tolerance_query.get_mut(follow.target) { tolerance.current_stress = tolerance .current_stress .saturating_add(FOLLOW_STRESS_PER_TICK); tracing::debug!( target = ?follow.target, stress = tolerance.current_stress, proximity_ticks = follow.proximity_ticks, "Follow: applying suspicion stress" ); } } } else { // LOS lost — increment counter follow.los_lost_ticks += 1; // Reset proximity counter (can't be "too close" without LOS) follow.proximity_ticks = 0; // Check timeout if follow.los_lost_ticks >= FOLLOW_LOS_LOST_TIMEOUT { commands.entity(player_entity).remove::(); end_queue.push(FollowEndEvent { player: player_entity, target: follow.target, reason: FollowEndReason::LosLost, tick, }); tracing::debug!( target = ?follow.target, los_lost_ticks = follow.los_lost_ticks, "Follow: ended — LOS lost for too long" ); } } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::npc::{Npc, ToleranceThreshold}; use crate::perception::query::VisibilityGeometry; use crate::simulation::movement::{PlayerCharacter, TilePosition}; use crate::simulation::time::SimulationTime; use bevy_ecs::world::World; use std::collections::BTreeSet; fn setup_world() -> World { let mut world = World::new(); world.init_resource::(); world.init_resource::(); // Empty visibility geometry — tests override as needed world.insert_resource(VisibilityGeometry { visible_tiles: vec![], visible_positions: BTreeSet::new(), sector_lookup: Default::default(), observer_z: 0, }); world } fn run_system(world: &mut World) { let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(update_follow_state); schedule.run(world); } /// Make target visible in the geometry resource. fn make_visible(world: &mut World, x: i32, y: i32) { let mut geom = world.resource_mut::(); geom.visible_positions.insert((x, y)); } // ----------------------------------------------------------------------- // Basic follow tracking // ----------------------------------------------------------------------- #[test] fn follow_tracks_proximity_ticks_when_close_with_los() { let mut world = setup_world(); let target = world .spawn(( Npc, TilePosition::new(5, 6, 0), ToleranceThreshold { current_stress: 0, threshold: 80, }, )) .id(); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), FollowTarget { target, started_tick: 0, proximity_ticks: 0, los_lost_ticks: 0, }, )); // Target is visible make_visible(&mut world, 5, 6); run_system(&mut world); let mut query = world.query::<&FollowTarget>(); let follow = query.single(&world).unwrap(); assert_eq!(follow.proximity_ticks, 1, "proximity should tick up"); assert_eq!(follow.los_lost_ticks, 0, "LOS is not lost"); } #[test] fn follow_resets_proximity_when_far() { let mut world = setup_world(); let target = world .spawn(( Npc, TilePosition::new(5, 10, 0), // distance 5 from player ToleranceThreshold { current_stress: 0, threshold: 80, }, )) .id(); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), FollowTarget { target, started_tick: 0, proximity_ticks: 10, los_lost_ticks: 0, }, )); make_visible(&mut world, 5, 10); run_system(&mut world); let mut query = world.query::<&FollowTarget>(); let follow = query.single(&world).unwrap(); assert_eq!( follow.proximity_ticks, 0, "proximity resets when beyond close range" ); } // ----------------------------------------------------------------------- // LOS tracking // ----------------------------------------------------------------------- #[test] fn follow_tracks_los_lost_ticks() { let mut world = setup_world(); let target = world .spawn(( Npc, TilePosition::new(5, 6, 0), ToleranceThreshold { current_stress: 0, threshold: 80, }, )) .id(); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), FollowTarget { target, started_tick: 0, proximity_ticks: 0, los_lost_ticks: 0, }, )); // Target NOT visible (not in visible_positions) run_system(&mut world); let mut query = world.query::<&FollowTarget>(); let follow = query.single(&world).unwrap(); assert_eq!(follow.los_lost_ticks, 1, "LOS lost should increment"); assert_eq!( follow.proximity_ticks, 0, "proximity should not increment without LOS" ); } #[test] fn follow_los_regained_resets_counter() { let mut world = setup_world(); let target = world .spawn(( Npc, TilePosition::new(5, 6, 0), ToleranceThreshold { current_stress: 0, threshold: 80, }, )) .id(); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), FollowTarget { target, started_tick: 0, proximity_ticks: 0, los_lost_ticks: 15, // Was losing LOS }, )); make_visible(&mut world, 5, 6); run_system(&mut world); let mut query = world.query::<&FollowTarget>(); let follow = query.single(&world).unwrap(); assert_eq!(follow.los_lost_ticks, 0, "LOS regained resets counter"); } // ----------------------------------------------------------------------- // Follow end conditions // ----------------------------------------------------------------------- #[test] fn follow_ends_on_los_timeout() { let mut world = setup_world(); let target = world .spawn(( Npc, TilePosition::new(5, 6, 0), ToleranceThreshold { current_stress: 0, threshold: 80, }, )) .id(); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), FollowTarget { target, started_tick: 0, proximity_ticks: 0, los_lost_ticks: FOLLOW_LOS_LOST_TIMEOUT - 1, // One tick away }, )) .id(); // Target NOT visible run_system(&mut world); assert!( world.get::(player).is_none(), "follow should end after LOS timeout" ); let queue = world.resource::(); assert_eq!(queue.events.len(), 1); assert_eq!(queue.events[0].reason, FollowEndReason::LosLost); } #[test] fn follow_ends_on_detection() { let mut world = setup_world(); let target = world .spawn(( Npc, TilePosition::new(5, 6, 0), ToleranceThreshold { current_stress: 80, threshold: 80, }, ToleranceBreached, // Already detected )) .id(); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), FollowTarget { target, started_tick: 0, proximity_ticks: 0, los_lost_ticks: 0, }, )) .id(); make_visible(&mut world, 5, 6); run_system(&mut world); assert!( world.get::(player).is_none(), "follow should end when target detects player" ); let queue = world.resource::(); assert_eq!(queue.events.len(), 1); assert_eq!(queue.events[0].reason, FollowEndReason::Detected); } #[test] fn follow_ends_on_target_despawn() { let mut world = setup_world(); // Spawn and immediately despawn the target let target = world .spawn(( Npc, TilePosition::new(5, 6, 0), ToleranceThreshold { current_stress: 0, threshold: 80, }, )) .id(); world.despawn(target); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), FollowTarget { target, started_tick: 0, proximity_ticks: 0, los_lost_ticks: 0, }, )) .id(); run_system(&mut world); assert!( world.get::(player).is_none(), "follow should end when target is despawned" ); } // ----------------------------------------------------------------------- // Suspicion stress // ----------------------------------------------------------------------- #[test] fn stress_not_applied_before_threshold() { let mut world = setup_world(); let target = world .spawn(( Npc, TilePosition::new(5, 6, 0), ToleranceThreshold { current_stress: 0, threshold: 80, }, )) .id(); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), FollowTarget { target, started_tick: 0, proximity_ticks: FOLLOW_SUSPICION_TICKS - 2, // Not yet at threshold los_lost_ticks: 0, }, )); make_visible(&mut world, 5, 6); run_system(&mut world); let tolerance = world.get::(target).unwrap(); assert_eq!( tolerance.current_stress, 0, "stress should not increase before suspicion threshold" ); } #[test] fn stress_applied_at_suspicion_threshold() { let mut world = setup_world(); let target = world .spawn(( Npc, TilePosition::new(5, 6, 0), ToleranceThreshold { current_stress: 0, threshold: 80, }, )) .id(); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), FollowTarget { target, started_tick: 0, proximity_ticks: FOLLOW_SUSPICION_TICKS - 1, // Will reach threshold this tick los_lost_ticks: 0, }, )); make_visible(&mut world, 5, 6); run_system(&mut world); let tolerance = world.get::(target).unwrap(); assert_eq!( tolerance.current_stress, FOLLOW_STRESS_PER_TICK, "stress should increase when proximity reaches suspicion threshold" ); } #[test] fn stress_accumulates_over_ticks() { let mut world = setup_world(); let target = world .spawn(( Npc, TilePosition::new(5, 6, 0), ToleranceThreshold { current_stress: 10, threshold: 80, }, )) .id(); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), FollowTarget { target, started_tick: 0, proximity_ticks: FOLLOW_SUSPICION_TICKS + 10, // Well past threshold los_lost_ticks: 0, }, )); make_visible(&mut world, 5, 6); run_system(&mut world); let tolerance = world.get::(target).unwrap(); assert_eq!( tolerance.current_stress, 10 + FOLLOW_STRESS_PER_TICK, "stress should accumulate from existing value" ); } #[test] fn stress_saturates_at_max() { let mut world = setup_world(); let target = world .spawn(( Npc, TilePosition::new(5, 6, 0), ToleranceThreshold { current_stress: i16::MAX - 1, threshold: 80, }, )) .id(); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), FollowTarget { target, started_tick: 0, proximity_ticks: FOLLOW_SUSPICION_TICKS, los_lost_ticks: 0, }, )); make_visible(&mut world, 5, 6); run_system(&mut world); let tolerance = world.get::(target).unwrap(); assert_eq!( tolerance.current_stress, i16::MAX, "stress should saturate, not overflow" ); } // ----------------------------------------------------------------------- // No player / no follow // ----------------------------------------------------------------------- #[test] fn no_player_no_panic() { let mut world = setup_world(); // No player entity run_system(&mut world); // should not panic } #[test] fn player_without_follow_no_panic() { let mut world = setup_world(); world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); run_system(&mut world); // should not panic } // ----------------------------------------------------------------------- // Different z-level // ----------------------------------------------------------------------- #[test] fn different_z_level_counts_as_no_los() { let mut world = setup_world(); let target = world .spawn(( Npc, TilePosition::new(5, 6, 1), // Different z-level ToleranceThreshold { current_stress: 0, threshold: 80, }, )) .id(); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), FollowTarget { target, started_tick: 0, proximity_ticks: 0, los_lost_ticks: 0, }, )); // Even if position is in visible set, z-level mismatch = no LOS make_visible(&mut world, 5, 6); run_system(&mut world); let mut query = world.query::<&FollowTarget>(); let follow = query.single(&world).unwrap(); assert_eq!( follow.los_lost_ticks, 1, "different z-level should count as LOS lost" ); } // ----------------------------------------------------------------------- // Constant value assertions (#241 spec compliance) // ----------------------------------------------------------------------- #[test] fn follow_constants_have_expected_values() { // Spec-defined in #241 — changes here break the design contract assert_eq!( FOLLOW_PROXIMITY_RANGE, 2, "D-241: 'too close' range is 2 Manhattan tiles" ); assert_eq!( FOLLOW_SUSPICION_TICKS, 60, "D-241: suspicion starts after 60 sustained proximity ticks" ); assert_eq!( FOLLOW_LOS_LOST_TIMEOUT, 30, "D-241: follow ends after 30 consecutive LOS-lost ticks" ); assert_eq!( FOLLOW_STRESS_PER_TICK, 2, "D-241: stress increment per tick (integer, D-010)" ); } // ----------------------------------------------------------------------- // FOLLOW_PROXIMITY_RANGE boundary tests // ----------------------------------------------------------------------- #[test] fn proximity_at_range_boundary_counts_as_close() { // Distance == FOLLOW_PROXIMITY_RANGE (2) should count as close let mut world = setup_world(); let target = world .spawn(( Npc, TilePosition::new(5, 7, 0), // distance exactly 2 from (5,5) ToleranceThreshold { current_stress: 0, threshold: 80, }, )) .id(); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), FollowTarget { target, started_tick: 0, proximity_ticks: 0, los_lost_ticks: 0, }, )); make_visible(&mut world, 5, 7); run_system(&mut world); let mut query = world.query::<&FollowTarget>(); let follow = query.single(&world).unwrap(); assert_eq!( follow.proximity_ticks, 1, "distance 2 should count as close (at FOLLOW_PROXIMITY_RANGE boundary)" ); } #[test] fn proximity_one_beyond_range_resets_counter() { // Distance == FOLLOW_PROXIMITY_RANGE + 1 (3) should NOT count as close let mut world = setup_world(); let target = world .spawn(( Npc, TilePosition::new(5, 8, 0), // distance 3 ToleranceThreshold { current_stress: 0, threshold: 80, }, )) .id(); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), FollowTarget { target, started_tick: 0, proximity_ticks: 20, // Had been accumulating los_lost_ticks: 0, }, )); make_visible(&mut world, 5, 8); run_system(&mut world); let mut query = world.query::<&FollowTarget>(); let follow = query.single(&world).unwrap(); assert_eq!( follow.proximity_ticks, 0, "distance 3 (> FOLLOW_PROXIMITY_RANGE 2) should reset proximity counter" ); } // ----------------------------------------------------------------------- // LOS required for stress and proximity // ----------------------------------------------------------------------- #[test] fn stress_not_applied_when_los_is_lost_despite_proximity() { // Being physically close but with LOS lost should NOT trigger stress. // Proximity tracking only applies when target is visible. let mut world = setup_world(); let target = world .spawn(( Npc, TilePosition::new(5, 6, 0), // distance 1 — very close ToleranceThreshold { current_stress: 0, threshold: 80, }, )) .id(); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), FollowTarget { target, started_tick: 0, proximity_ticks: FOLLOW_SUSPICION_TICKS + 5, // Well past suspicion threshold los_lost_ticks: 0, }, )); // Target NOT in visible_positions — LOS lost run_system(&mut world); let tolerance = world.get::(target).unwrap(); assert_eq!( tolerance.current_stress, 0, "stress must not accumulate when LOS is lost, even if physically close" ); } #[test] fn proximity_ticks_reset_to_zero_when_los_lost() { // When LOS is lost, accumulated proximity_ticks must reset. // This prevents suspicion stress "banked" from previous close approaches. let mut world = setup_world(); let target = world .spawn(( Npc, TilePosition::new(5, 6, 0), ToleranceThreshold { current_stress: 0, threshold: 80, }, )) .id(); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), FollowTarget { target, started_tick: 0, proximity_ticks: FOLLOW_SUSPICION_TICKS - 5, // Nearly at threshold los_lost_ticks: 0, }, )); // Target NOT visible — LOS lost run_system(&mut world); let mut query = world.query::<&FollowTarget>(); let follow = query.single(&world).unwrap(); assert_eq!( follow.proximity_ticks, 0, "proximity_ticks must reset to 0 when LOS is lost" ); assert_eq!(follow.los_lost_ticks, 1, "los_lost_ticks should increment"); } // ----------------------------------------------------------------------- // FollowStateWire serde roundtrip // ----------------------------------------------------------------------- #[test] fn follow_state_wire_roundtrips_via_serde() { let wire = FollowStateWire { target_entity_id: 12345, distance: 3, has_los: true, proximity_ticks: 55, }; let json = serde_json::to_string(&wire).expect("FollowStateWire should serialize"); let decoded: FollowStateWire = serde_json::from_str(&json).expect("FollowStateWire should deserialize"); assert_eq!(decoded.target_entity_id, 12345); assert_eq!(decoded.distance, 3); assert!(decoded.has_los); assert_eq!(decoded.proximity_ticks, 55); } // ----------------------------------------------------------------------- // Follow end event metadata // ----------------------------------------------------------------------- #[test] fn follow_end_event_contains_correct_metadata() { let mut world = setup_world(); world.resource_mut::().tick = 42; let target = world .spawn(( Npc, TilePosition::new(5, 6, 0), ToleranceThreshold { current_stress: 80, threshold: 80, }, ToleranceBreached, )) .id(); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), FollowTarget { target, started_tick: 10, proximity_ticks: 50, los_lost_ticks: 0, }, )) .id(); make_visible(&mut world, 5, 6); run_system(&mut world); let queue = world.resource::(); assert_eq!(queue.events.len(), 1); let event = &queue.events[0]; assert_eq!(event.player, player); assert_eq!(event.target, target); assert_eq!(event.tick, 42); assert_eq!(event.reason, FollowEndReason::Detected); } }