Fix all Clippy warnings across the server codebase (2411 insertions, 1341 deletions). Raise type-complexity-threshold to 750 and too-many-arguments to 12 in .clippy.toml for idiomatic Bevy ECS system signatures. The server now passes `cargo clippy -- --deny warnings` cleanly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1036 lines
32 KiB
Rust
1036 lines
32 KiB
Rust
//! 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::tolerance::ToleranceBreached;
|
|
use crate::npc::{Npc, ToleranceThreshold};
|
|
use crate::perception::query::VisibilityGeometry;
|
|
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
|
use crate::simulation::time::SimulationTime;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<FollowEndEvent>,
|
|
}
|
|
|
|
impl FollowEndEventQueue {
|
|
pub fn push(&mut self, event: FollowEndEvent) {
|
|
self.events.push(event);
|
|
}
|
|
|
|
pub fn drain(&mut self) -> Vec<FollowEndEvent> {
|
|
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<SimulationTime>,
|
|
geometry: Res<VisibilityGeometry>,
|
|
mut end_queue: ResMut<FollowEndEventQueue>,
|
|
mut player_query: Query<(Entity, &TilePosition, &mut FollowTarget), With<PlayerCharacter>>,
|
|
target_query: Query<(&TilePosition, Option<&ToleranceBreached>), With<Npc>>,
|
|
mut tolerance_query: Query<&mut ToleranceThreshold, With<Npc>>,
|
|
) {
|
|
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::<FollowTarget>();
|
|
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::<FollowTarget>();
|
|
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::<FollowTarget>();
|
|
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::<SimulationTime>();
|
|
world.init_resource::<FollowEndEventQueue>();
|
|
// 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::<VisibilityGeometry>();
|
|
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::<FollowTarget>(player).is_none(),
|
|
"follow should end after LOS timeout"
|
|
);
|
|
|
|
let queue = world.resource::<FollowEndEventQueue>();
|
|
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::<FollowTarget>(player).is_none(),
|
|
"follow should end when target detects player"
|
|
);
|
|
|
|
let queue = world.resource::<FollowEndEventQueue>();
|
|
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::<FollowTarget>(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::<ToleranceThreshold>(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::<ToleranceThreshold>(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::<ToleranceThreshold>(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::<ToleranceThreshold>(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::<ToleranceThreshold>(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::<SimulationTime>().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::<FollowEndEventQueue>();
|
|
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);
|
|
}
|
|
}
|