diff --git a/server/src/simulation/follow.rs b/server/src/simulation/follow.rs new file mode 100644 index 000000000..266938dbe --- /dev/null +++ b/server/src/simulation/follow.rs @@ -0,0 +1,1032 @@ +//! 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::{Npc, ToleranceThreshold}; +use crate::perception::query::VisibilityGeometry; +use crate::simulation::movement::{PlayerCharacter, TilePosition}; +use crate::simulation::time::SimulationTime; +use crate::npc::tolerance::ToleranceBreached; + +// --------------------------------------------------------------------------- +// 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); + } +} diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index 90564ec3f..953e4531e 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -180,7 +180,16 @@ pub fn process_player_input( PlayerAction::Interact { target_entity_id, ref verb, - } => match verb.as_deref() { + } => { + // Cancel follow when player uses any non-Follow verb (#241). + if verb.as_deref() != Some("Follow") { + if let Ok((player_entity, _, _, _)) = player_query.single() { + commands + .entity(player_entity) + .remove::(); + } + } + match verb.as_deref() { Some("Take") => { handle_take( &mut commands, @@ -202,6 +211,16 @@ pub fn process_player_input( target_entity_id, ); } + Some("Follow") => { + handle_follow( + &mut commands, + ®istry, + &player_query, + &all_positions, + target_entity_id, + current_tick, + ); + } Some("Confront") => { handle_confront( &mut commands, @@ -228,7 +247,7 @@ pub fn process_player_input( verb, ); } - }, + }} PlayerAction::WalkAway => { if let Ok((player_entity, _, _, _)) = player_query.single() { commands @@ -501,6 +520,69 @@ fn handle_confront( ); } +/// Handle Follow verb: designate an NPC as follow target (#241). +/// Sets FollowTarget on the player entity. Replaces any existing follow target. +/// Server-side range check: Follow requires CLOSE_RANGE (same as Talk). +#[allow(clippy::type_complexity)] +fn handle_follow( + commands: &mut Commands, + registry: &EntityRegistry, + player_query: &Query< + ( + Entity, + &TilePosition, + Option<&mut Stance>, + Option<&mut PlayerMoveCooldown>, + ), + With, + >, + all_positions: &Query<&TilePosition>, + target_entity_id: Option, + current_tick: u64, +) { + let Some(target_id) = target_entity_id else { + tracing::warn!("Follow verb without target_entity_id"); + return; + }; + + let Ok((player_entity, player_pos, _, _)) = player_query.single() else { + return; + }; + + let target_stable = StableId(target_id); + let Some(target_entity) = registry.to_entity(&target_stable) else { + tracing::warn!(target_id, "Follow: target entity not in registry"); + return; + }; + + // Server-side range check: reject Follow if target is beyond close range + if let Ok(target_pos) = all_positions.get(target_entity) { + let distance = player_pos + .manhattan_distance(target_pos) + .unwrap_or(u32::MAX); + if distance > crate::simulation::interaction::CLOSE_RANGE { + tracing::info!( + target_id, + distance, + "Follow: target out of range (max {})", + crate::simulation::interaction::CLOSE_RANGE, + ); + return; + } + } + + commands + .entity(player_entity) + .insert(crate::simulation::follow::FollowTarget { + target: target_entity, + started_tick: current_tick, + proximity_ticks: 0, + los_lost_ticks: 0, + }); + + tracing::debug!(target_id, "Follow: FollowTarget set on player"); +} + /// Handle Place verb: remove an item from inventory and place it on the ground /// at the player's current position. Removes CarriedBy + InventorySlot, adds /// TilePosition at the player's current tile. diff --git a/server/src/simulation/interaction.rs b/server/src/simulation/interaction.rs index 1116c8ca7..7e1337d6d 100644 --- a/server/src/simulation/interaction.rs +++ b/server/src/simulation/interaction.rs @@ -208,9 +208,9 @@ pub fn compute_nearby_interactions( let mut verbs = Vec::new(); if is_npc.is_some() { - // NPC verb logic — unchanged from #404 + // NPC verb logic — Talk + ExamineNpc (#404), Follow (#241) if is_close { - // Default priority: Talk first, Observe second. + // Default priority: Talk first, Observe second, Follow third. // Observer adjusts priority for POI entities. verbs.push(VerbOption { kind: VerbKind::Talk, @@ -224,8 +224,14 @@ pub fn compute_nearby_interactions( priority: 2, available: true, }); + verbs.push(VerbOption { + kind: VerbKind::Follow, + label: "Follow".into(), + priority: 3, + available: true, + }); } else { - // Mid range: only Examine NPC (Talk requires close range) + // Mid range: only Examine NPC (Talk + Follow require close range) verbs.push(VerbOption { kind: VerbKind::ExamineNpc, label: "Examine NPC".into(), @@ -346,7 +352,7 @@ mod tests { // ----------------------------------------------------------------------- #[test] - fn npc_in_close_range_gets_talk_and_observe() { + fn npc_in_close_range_gets_talk_examine_follow() { let mut world = setup_world(); spawn_player(&mut world, 5, 5); world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable)); @@ -357,11 +363,13 @@ mod tests { let buffer = read_buffer(&mut world); assert_eq!(buffer.interactions.len(), 1); - assert_eq!(buffer.interactions[0].verbs.len(), 2); + assert_eq!(buffer.interactions[0].verbs.len(), 3); assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Talk); assert_eq!(buffer.interactions[0].verbs[0].priority, 1); assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::ExamineNpc); assert_eq!(buffer.interactions[0].verbs[1].priority, 2); + assert_eq!(buffer.interactions[0].verbs[2].kind, VerbKind::Follow); + assert_eq!(buffer.interactions[0].verbs[2].priority, 3); } #[test] @@ -845,10 +853,11 @@ mod tests { let buffer = read_buffer(&mut world); assert_eq!(buffer.interactions.len(), 1); - // Should get NPC verbs (Talk + ExamineNpc), NOT Terminal verbs (Use + Observe) - assert_eq!(buffer.interactions[0].verbs.len(), 2); + // Should get NPC verbs (Talk + ExamineNpc + Follow), NOT Terminal verbs (Use + Observe) + assert_eq!(buffer.interactions[0].verbs.len(), 3); assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Talk); assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::ExamineNpc); + assert_eq!(buffer.interactions[0].verbs[2].kind, VerbKind::Follow); } /// All ObjectType primary verbs are close_only (except Observe).