diff --git a/server/src/simulation/listening.rs b/server/src/simulation/listening.rs new file mode 100644 index 000000000..458293246 --- /dev/null +++ b/server/src/simulation/listening.rs @@ -0,0 +1,458 @@ +// ListeningFocus — eavesdrop positioning via stationary_ticks +// Implements #426: deliberate positioning mechanic for sound perception bonus +// Decision refs: D-053 (stance system), D-018 (three-range sound model) +// +// When the player stands still for EAVESDROP_THRESHOLD ticks, they gain a +// sound perception bonus if within range of a conversation. Sprint stance +// blocks eavesdrop (too high-alert). Careful stance reduces the threshold. + +use bevy_ecs::prelude::*; + +use crate::bridge::types::MovementStance; +use crate::knowledge::types::StableId; +use crate::simulation::movement::{PlayerCharacter, TilePosition}; +use crate::simulation::stance::Stance; + +/// Ticks of being stationary before eavesdrop activates (~3 seconds at 10 tps). +pub const EAVESDROP_THRESHOLD: u32 = 30; + +/// Reduced eavesdrop threshold when in Careful stance (~2 seconds). +pub const EAVESDROP_THRESHOLD_CAREFUL: u32 = 20; + +/// Maximum eavesdrop range in tiles (Manhattan distance, same z-level). +/// Player must be within this distance of a conversation source. +pub const EAVESDROP_RANGE: u32 = 5; + +/// Component tracking stationary time for eavesdrop positioning. +/// Attached to the PlayerCharacter entity. +#[derive(Component, Debug, Clone)] +pub struct ListeningFocus { + /// Consecutive ticks the entity has been stationary. + pub stationary_ticks: u32, + /// Entity currently being eavesdropped on, if any. + /// Set when stationary_ticks exceeds threshold AND a conversation + /// source is within EAVESDROP_RANGE. + pub eavesdrop_target: Option, + /// Position at end of last tick — used to detect movement. + last_position: TilePosition, +} + +impl ListeningFocus { + pub fn new(position: TilePosition) -> Self { + Self { + stationary_ticks: 0, + eavesdrop_target: None, + last_position: position, + } + } + + /// Whether eavesdrop is currently active (threshold exceeded). + pub fn is_eavesdropping(&self) -> bool { + self.eavesdrop_target.is_some() + } + + /// Get the effective eavesdrop threshold for a given stance. + /// Returns None for Sprint (eavesdrop blocked entirely). + pub fn threshold_for_stance(stance: MovementStance) -> Option { + match stance { + MovementStance::Sprint => None, // Sprint blocks eavesdrop + MovementStance::Careful => Some(EAVESDROP_THRESHOLD_CAREFUL), + _ => Some(EAVESDROP_THRESHOLD), + } + } +} + +/// System: update ListeningFocus stationary tick counter. +/// +/// Runs each tick. Compares current position against last known position. +/// If unchanged, increments stationary_ticks. If changed (or in Sprint), +/// resets to zero and clears eavesdrop target. +/// +/// Does NOT evaluate eavesdrop targets — that requires knowledge of nearby +/// conversations, which is a perception concern. This system only tracks +/// the stationary state. Target evaluation is done by a separate perception +/// system that reads ListeningFocus.stationary_ticks. +pub fn update_listening_focus( + mut query: Query<(&TilePosition, &mut ListeningFocus, Option<&Stance>), With>, +) { + for (position, mut focus, stance_opt) in query.iter_mut() { + let stance = stance_opt.map(|s| s.0).unwrap_or(MovementStance::Walk); + + // Check if position changed since last tick + let moved = *position != focus.last_position; + focus.last_position = *position; + + if moved { + // Any movement resets the counter + focus.stationary_ticks = 0; + focus.eavesdrop_target = None; + continue; + } + + // Sprint stance: stationary but too high-alert to listen + let Some(threshold) = ListeningFocus::threshold_for_stance(stance) else { + focus.stationary_ticks = 0; + focus.eavesdrop_target = None; + continue; + }; + + // Increment stationary counter (saturating to prevent overflow) + focus.stationary_ticks = focus.stationary_ticks.saturating_add(1); + + // Clear eavesdrop target if below threshold (e.g. stance changed + // from Careful to Walk, raising the threshold above current ticks) + if focus.stationary_ticks < threshold { + focus.eavesdrop_target = None; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy_ecs::world::World; + + fn make_position(x: i32, y: i32) -> TilePosition { + TilePosition::new(x, y, 0) + } + + fn spawn_player(world: &mut World, x: i32, y: i32) -> Entity { + let pos = make_position(x, y); + world + .spawn((PlayerCharacter, pos, ListeningFocus::new(pos))) + .id() + } + + fn spawn_player_with_stance( + world: &mut World, + x: i32, + y: i32, + stance: MovementStance, + ) -> Entity { + let pos = make_position(x, y); + world + .spawn(( + PlayerCharacter, + pos, + ListeningFocus::new(pos), + Stance(stance), + )) + .id() + } + + fn run_system(world: &mut World) { + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_listening_focus); + schedule.run(world); + } + + // ----------------------------------------------------------------------- + // Stationary tick accumulation + // ----------------------------------------------------------------------- + + #[test] + fn stationary_ticks_increment_when_not_moving() { + let mut world = World::new(); + let entity = spawn_player(&mut world, 5, 5); + + // Run 10 ticks without moving + for _ in 0..10 { + run_system(&mut world); + } + + let focus = world.get::(entity).unwrap(); + assert_eq!(focus.stationary_ticks, 10); + } + + #[test] + fn stationary_ticks_reset_on_movement() { + let mut world = World::new(); + let entity = spawn_player(&mut world, 5, 5); + + // Accumulate 10 stationary ticks + for _ in 0..10 { + run_system(&mut world); + } + + // Move the player + *world.get_mut::(entity).unwrap() = make_position(5, 6); + run_system(&mut world); + + let focus = world.get::(entity).unwrap(); + assert_eq!(focus.stationary_ticks, 0); + } + + #[test] + fn stationary_ticks_resume_after_stop() { + let mut world = World::new(); + let entity = spawn_player(&mut world, 5, 5); + + // 5 ticks stationary + for _ in 0..5 { + run_system(&mut world); + } + assert_eq!( + world + .get::(entity) + .unwrap() + .stationary_ticks, + 5 + ); + + // Move + *world.get_mut::(entity).unwrap() = make_position(5, 6); + run_system(&mut world); + assert_eq!( + world + .get::(entity) + .unwrap() + .stationary_ticks, + 0 + ); + + // Stop again — counter restarts from 0 + for _ in 0..3 { + run_system(&mut world); + } + assert_eq!( + world + .get::(entity) + .unwrap() + .stationary_ticks, + 3 + ); + } + + // ----------------------------------------------------------------------- + // Stance interaction + // ----------------------------------------------------------------------- + + #[test] + fn sprint_stance_blocks_stationary_ticks() { + let mut world = World::new(); + let entity = spawn_player_with_stance(&mut world, 5, 5, MovementStance::Sprint); + + for _ in 0..50 { + run_system(&mut world); + } + + let focus = world.get::(entity).unwrap(); + assert_eq!( + focus.stationary_ticks, 0, + "Sprint should block stationary tick accumulation" + ); + } + + #[test] + fn walk_stance_accumulates_normally() { + let mut world = World::new(); + let entity = spawn_player_with_stance(&mut world, 5, 5, MovementStance::Walk); + + for _ in 0..10 { + run_system(&mut world); + } + + let focus = world.get::(entity).unwrap(); + assert_eq!(focus.stationary_ticks, 10); + } + + #[test] + fn careful_stance_accumulates_normally() { + let mut world = World::new(); + let entity = spawn_player_with_stance(&mut world, 5, 5, MovementStance::Careful); + + for _ in 0..10 { + run_system(&mut world); + } + + let focus = world.get::(entity).unwrap(); + assert_eq!(focus.stationary_ticks, 10); + } + + #[test] + fn crouch_stance_accumulates_normally() { + let mut world = World::new(); + let entity = spawn_player_with_stance(&mut world, 5, 5, MovementStance::Crouch); + + for _ in 0..10 { + run_system(&mut world); + } + + let focus = world.get::(entity).unwrap(); + assert_eq!(focus.stationary_ticks, 10); + } + + // ----------------------------------------------------------------------- + // Threshold calculations + // ----------------------------------------------------------------------- + + #[test] + fn threshold_for_sprint_is_none() { + assert!(ListeningFocus::threshold_for_stance(MovementStance::Sprint).is_none()); + } + + #[test] + fn threshold_for_careful_is_reduced() { + let careful = ListeningFocus::threshold_for_stance(MovementStance::Careful).unwrap(); + let walk = ListeningFocus::threshold_for_stance(MovementStance::Walk).unwrap(); + assert!(careful < walk, "Careful threshold should be less than Walk"); + assert_eq!(careful, EAVESDROP_THRESHOLD_CAREFUL); + assert_eq!(walk, EAVESDROP_THRESHOLD); + } + + #[test] + fn threshold_for_walk_and_crouch_equal() { + let walk = ListeningFocus::threshold_for_stance(MovementStance::Walk).unwrap(); + let crouch = ListeningFocus::threshold_for_stance(MovementStance::Crouch).unwrap(); + assert_eq!(walk, crouch); + } + + // ----------------------------------------------------------------------- + // Eavesdrop target management + // ----------------------------------------------------------------------- + + #[test] + fn eavesdrop_target_cleared_on_movement() { + let mut world = World::new(); + let entity = spawn_player(&mut world, 5, 5); + + // Manually set an eavesdrop target + world + .get_mut::(entity) + .unwrap() + .eavesdrop_target = Some(StableId(42)); + world + .get_mut::(entity) + .unwrap() + .stationary_ticks = 50; + + // Move + *world.get_mut::(entity).unwrap() = make_position(5, 6); + run_system(&mut world); + + let focus = world.get::(entity).unwrap(); + assert!( + focus.eavesdrop_target.is_none(), + "movement should clear eavesdrop target" + ); + } + + #[test] + fn eavesdrop_target_cleared_on_sprint() { + let mut world = World::new(); + let pos = make_position(5, 5); + let entity = world + .spawn(( + PlayerCharacter, + pos, + ListeningFocus { + stationary_ticks: 50, + eavesdrop_target: Some(StableId(42)), + last_position: pos, + }, + Stance(MovementStance::Sprint), + )) + .id(); + + run_system(&mut world); + + let focus = world.get::(entity).unwrap(); + assert!( + focus.eavesdrop_target.is_none(), + "sprint should clear eavesdrop target" + ); + assert_eq!(focus.stationary_ticks, 0); + } + + #[test] + fn eavesdrop_target_cleared_below_threshold_on_stance_change() { + let mut world = World::new(); + let entity = spawn_player_with_stance(&mut world, 5, 5, MovementStance::Careful); + + // Accumulate 25 ticks (above Careful threshold of 20, below Walk threshold of 30) + for _ in 0..25 { + run_system(&mut world); + } + // Manually set eavesdrop target (as perception system would) + world + .get_mut::(entity) + .unwrap() + .eavesdrop_target = Some(StableId(99)); + + // Switch to Walk stance — threshold goes from 20 to 30, so 25 < 30 + world.get_mut::(entity).unwrap().0 = MovementStance::Walk; + run_system(&mut world); + + let focus = world.get::(entity).unwrap(); + assert!( + focus.eavesdrop_target.is_none(), + "eavesdrop should clear when ticks drop below new stance threshold" + ); + // But ticks should still be incrementing (26 now) + assert_eq!(focus.stationary_ticks, 26); + } + + // ----------------------------------------------------------------------- + // is_eavesdropping helper + // ----------------------------------------------------------------------- + + #[test] + fn is_eavesdropping_false_without_target() { + let focus = ListeningFocus::new(make_position(0, 0)); + assert!(!focus.is_eavesdropping()); + } + + #[test] + fn is_eavesdropping_true_with_target() { + let mut focus = ListeningFocus::new(make_position(0, 0)); + focus.eavesdrop_target = Some(StableId(1)); + assert!(focus.is_eavesdropping()); + } + + // ----------------------------------------------------------------------- + // No stance component (backward compatibility) + // ----------------------------------------------------------------------- + + #[test] + fn no_stance_defaults_to_walk_behavior() { + let mut world = World::new(); + let entity = spawn_player(&mut world, 5, 5); // no Stance component + + for _ in 0..EAVESDROP_THRESHOLD { + run_system(&mut world); + } + + let focus = world.get::(entity).unwrap(); + assert_eq!(focus.stationary_ticks, EAVESDROP_THRESHOLD); + } + + // ----------------------------------------------------------------------- + // Edge cases + // ----------------------------------------------------------------------- + + #[test] + fn stationary_ticks_saturate_not_overflow() { + let mut world = World::new(); + let pos = make_position(5, 5); + let entity = world + .spawn(( + PlayerCharacter, + pos, + ListeningFocus { + stationary_ticks: u32::MAX - 1, + eavesdrop_target: None, + last_position: pos, + }, + )) + .id(); + + run_system(&mut world); + let focus = world.get::(entity).unwrap(); + assert_eq!(focus.stationary_ticks, u32::MAX); + + // One more tick should not overflow + run_system(&mut world); + let focus = world.get::(entity).unwrap(); + assert_eq!(focus.stationary_ticks, u32::MAX); + } +} diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index 92c04a52b..10bee8fec 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -7,6 +7,7 @@ use bevy_ecs::schedule::IntoScheduleConfigs; pub mod input; pub mod interaction; pub mod inventory; +pub mod listening; pub mod monologue; pub mod movement; pub mod path_follow; @@ -35,8 +36,8 @@ impl Plugin for SimulationPlugin { path_follow::follow_paths.after(pathfinding::compute_paths), movement::validate_movement.after(path_follow::follow_paths), path_follow::cleanup_path_blocked.after(movement::validate_movement), - time::advance_tick - .after(path_follow::cleanup_path_blocked), + listening::update_listening_focus.after(movement::validate_movement), + time::advance_tick.after(path_follow::cleanup_path_blocked), ), );