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 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),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user