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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use bevy_ecs::schedule::IntoScheduleConfigs;
|
|||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod interaction;
|
pub mod interaction;
|
||||||
pub mod inventory;
|
pub mod inventory;
|
||||||
|
pub mod listening;
|
||||||
pub mod monologue;
|
pub mod monologue;
|
||||||
pub mod movement;
|
pub mod movement;
|
||||||
pub mod path_follow;
|
pub mod path_follow;
|
||||||
@@ -35,8 +36,8 @@ impl Plugin for SimulationPlugin {
|
|||||||
path_follow::follow_paths.after(pathfinding::compute_paths),
|
path_follow::follow_paths.after(pathfinding::compute_paths),
|
||||||
movement::validate_movement.after(path_follow::follow_paths),
|
movement::validate_movement.after(path_follow::follow_paths),
|
||||||
path_follow::cleanup_path_blocked.after(movement::validate_movement),
|
path_follow::cleanup_path_blocked.after(movement::validate_movement),
|
||||||
time::advance_tick
|
listening::update_listening_focus.after(movement::validate_movement),
|
||||||
.after(path_follow::cleanup_path_blocked),
|
time::advance_tick.after(path_follow::cleanup_path_blocked),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user