feat(simulation): add ListeningFocus eavesdrop positioning (#426)

New ListeningFocus component tracks stationary_ticks for eavesdrop
mechanic. Increments when position unchanged, resets on movement.
Sprint stance blocks accumulation, Careful reduces threshold from
30 to 20 ticks. Registered in SimulationPlugin after validate_movement.

17 tests covering all stances, thresholds, and edge cases.

Ref: D-053 (stance system), D-018 (sound model)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 00:40:54 +01:00
co-authored by Claude Opus 4.6
parent d0663c4193
commit 3a2cfc37e0
2 changed files with 461 additions and 2 deletions
+458
View File
@@ -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<StableId>,
/// 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<u32> {
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<PlayerCharacter>>,
) {
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::<ListeningFocus>(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::<TilePosition>(entity).unwrap() = make_position(5, 6);
run_system(&mut world);
let focus = world.get::<ListeningFocus>(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::<ListeningFocus>(entity)
.unwrap()
.stationary_ticks,
5
);
// Move
*world.get_mut::<TilePosition>(entity).unwrap() = make_position(5, 6);
run_system(&mut world);
assert_eq!(
world
.get::<ListeningFocus>(entity)
.unwrap()
.stationary_ticks,
0
);
// Stop again — counter restarts from 0
for _ in 0..3 {
run_system(&mut world);
}
assert_eq!(
world
.get::<ListeningFocus>(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::<ListeningFocus>(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::<ListeningFocus>(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::<ListeningFocus>(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::<ListeningFocus>(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::<ListeningFocus>(entity)
.unwrap()
.eavesdrop_target = Some(StableId(42));
world
.get_mut::<ListeningFocus>(entity)
.unwrap()
.stationary_ticks = 50;
// Move
*world.get_mut::<TilePosition>(entity).unwrap() = make_position(5, 6);
run_system(&mut world);
let focus = world.get::<ListeningFocus>(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::<ListeningFocus>(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::<ListeningFocus>(entity)
.unwrap()
.eavesdrop_target = Some(StableId(99));
// Switch to Walk stance — threshold goes from 20 to 30, so 25 < 30
world.get_mut::<Stance>(entity).unwrap().0 = MovementStance::Walk;
run_system(&mut world);
let focus = world.get::<ListeningFocus>(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::<ListeningFocus>(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::<ListeningFocus>(entity).unwrap();
assert_eq!(focus.stationary_ticks, u32::MAX);
// One more tick should not overflow
run_system(&mut world);
let focus = world.get::<ListeningFocus>(entity).unwrap();
assert_eq!(focus.stationary_ticks, u32::MAX);
}
}
+3 -2
View File
@@ -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),
),
);