diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index d76779755..e87009e6b 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_empty.msgpack and b/client/tests/fixtures/msgpack/snapshot_empty.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index 94055d595..edcc2b3b5 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack and b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index 698b97852..b8edf9b55 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack and b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index a3be5bea6..e45b6ef57 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_player.msgpack and b/client/tests/fixtures/msgpack/snapshot_player.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index 2b830526b..c1488bd9a 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack and b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack differ diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 3251c94c1..7dbc94bec 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -15,7 +15,7 @@ pub use crate::simulation::time::{DayPhase, TickRate}; /// negotiation is unnecessary. Client should reject snapshots with version != /// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration /// period, then the default is removed once both sides are updated. -pub const PROTOCOL_VERSION: u8 = 6; +pub const PROTOCOL_VERSION: u8 = 7; /// The ONLY data structure crossing the client-server boundary (D-020) /// Contains all information visible to the observer at a given tick. @@ -25,6 +25,7 @@ pub const PROTOCOL_VERSION: u8 = 6; /// v4 adds: nearby_interactions (D-060, #404 proximity + verbs[]). /// v5 adds: current_monologue (#414 internal monologue pipeline). /// v6 adds: player_stance (#449, D-053), player_inventory (#449, D-065). +/// v7 adds: pending_recognitions (#423, D-060 cognitive delay). /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { @@ -56,6 +57,11 @@ pub struct ObserverSnapshot { /// None when no monologue is triggered. Client shows text and auto-fades. #[serde(default)] pub current_monologue: Option, + /// Entities undergoing cognitive delay recognition (#423, D-060). + /// Client renders these as grey blobs at position until recognition completes. + /// Empty when no recognitions are pending. + #[serde(default)] + pub pending_recognitions: Vec, } /// Game time data for client display (D-031) @@ -382,6 +388,23 @@ pub enum VerbKind { ExamineObject, } +/// A recognition pending cognitive delay, included in ObserverSnapshot (#423, D-060). +/// Client renders entity as grey blob at position until recognition completes. +/// Visual transition: grey blob -> D-033 color + silhouette over ~0.3s. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PendingRecognitionWire { + /// Wire entity ID (may match entity_id in VisibleEntity if also in entities list) + pub entity_id: u64, + /// Render position + pub x: f32, + pub y: f32, + pub z: i32, + /// Remaining ticks until recognition completes (for progress animation) + pub remaining_ticks: u64, + /// Total delay for this recognition (for animation timing) + pub total_delay_ticks: u64, +} + /// Internal monologue event sent to the client for display (#414). /// Contains the text and display duration. Client auto-fades after duration. #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/server/src/knowledge/events.rs b/server/src/knowledge/events.rs index 440ba6564..e97e9bc58 100644 --- a/server/src/knowledge/events.rs +++ b/server/src/knowledge/events.rs @@ -79,8 +79,15 @@ pub fn process_knowledge_events( if let Some(stable_id) = registry.to_stable(target) { observer_kg.observe_entity(stable_id, position, event.tick); } else { - debug_assert!(false, "DirectObservation target {:?} not in EntityRegistry", target); - tracing::error!("DirectObservation target {:?} not in EntityRegistry", target); + debug_assert!( + false, + "DirectObservation target {:?} not in EntityRegistry", + target + ); + tracing::error!( + "DirectObservation target {:?} not in EntityRegistry", + target + ); } } KnowledgeEventType::LeftLOS { target } => { @@ -262,7 +269,11 @@ mod tests { world.insert_resource(thresholds); // Tick 7: not a multiple of 10, decay should NOT run - world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 7; t }); + world.insert_resource({ + let mut t = SimulationTime::default(); + t.tick = 7; + t + }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(decay_knowledge); schedule.run(&mut world); @@ -275,7 +286,11 @@ mod tests { ); // Tick 10: multiple of 10, decay SHOULD run (age = 10 > decay_after = 5) - world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 10; t }); + world.insert_resource({ + let mut t = SimulationTime::default(); + t.tick = 10; + t + }); let mut schedule2 = bevy_ecs::schedule::Schedule::default(); schedule2.add_systems(decay_knowledge); schedule2.run(&mut world); diff --git a/server/src/main.rs b/server/src/main.rs index 0301e2f61..750312267 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -13,9 +13,13 @@ use settled_reach_server::npc::{ Contentment, DailyRoutine, Npc, NpcPlugin, RelationshipKind, RoutineEntry, ToleranceThreshold, Want, WantKind, }; +use settled_reach_server::perception::cognitive_delay::CognitiveDelay; use settled_reach_server::perception::vision_cone::Facing; use settled_reach_server::simulation::interaction::{Interactable, NearbyInteractionBuffer}; -use settled_reach_server::simulation::monologue::{MonologueBuffer, MonologueState, SprintAnomalyQueue}; +use settled_reach_server::simulation::listening::ListeningFocus; +use settled_reach_server::simulation::monologue::{ + MonologueBuffer, MonologueState, SprintAnomalyQueue, +}; use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; use settled_reach_server::simulation::path_follow::MovementSpeed; use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown}; @@ -77,6 +81,8 @@ fn main() { MonologueState::default(), MonologueBuffer::default(), SprintAnomalyQueue::default(), + CognitiveDelay::default(), + ListeningFocus::new(TilePosition::new(16, 16, 0)), profile, profile.initial_stance(), PlayerMoveCooldown::default(), diff --git a/server/src/perception/cognitive_delay.rs b/server/src/perception/cognitive_delay.rs new file mode 100644 index 000000000..35977aca2 --- /dev/null +++ b/server/src/perception/cognitive_delay.rs @@ -0,0 +1,509 @@ +//! Cognitive delay system for fog recognition (#423, D-060). +//! +//! Recognition is NOT instant. When the observer first perceives an entity +//! not yet in their knowledge graph, recognition is buffered through a +//! cognitive delay: 0.6s (6 ticks) normal, 0.3s (3 ticks) urgent. +//! +//! Flow: observation.rs detects new entity -> CognitiveDelay buffers -> +//! process_cognitive_delay drains on expiry -> KnowledgeEvent emitted. +//! +//! Client-facing: ObserverSnapshot.pending_recognitions lists entities +//! undergoing recognition (grey blobs with no identity until delay expires). + +use bevy_ecs::prelude::*; + +use crate::knowledge::types::StableId; +use crate::knowledge::{KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType}; +use crate::simulation::movement::TilePosition; +use crate::simulation::time::SimulationTime; + +/// Base cognitive delay: 0.6 seconds = 6 ticks at 10 tps (D-031, D-060). +/// Tunable: expect playtesting adjustments. +pub const NORMAL_DELAY_TICKS: u64 = 6; + +/// Urgent cognitive delay: 0.3 seconds = 3 ticks at 10 tps (D-031, D-060). +/// Triggered when observe_anomaly context is active. +pub const URGENT_DELAY_TICKS: u64 = 3; + +/// How the recognition was triggered, determines delay duration. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecognitionTrigger { + /// Standard perception event. 0.6s = 6 ticks. + Normal, + /// Urgent context (observe_anomaly). 0.3s = 3 ticks. + Urgent, +} + +impl RecognitionTrigger { + /// Delay in simulation ticks for this trigger type. + pub fn delay_ticks(self) -> u64 { + match self { + Self::Normal => NORMAL_DELAY_TICKS, + Self::Urgent => URGENT_DELAY_TICKS, + } + } +} + +/// A single pending recognition in the cognitive delay pipeline. +#[derive(Debug, Clone)] +pub struct PendingRecognition { + /// Bevy entity being recognized. + pub target: Entity, + /// Stable entity ID (for KG and wire format). + pub stable_id: StableId, + /// Position where the entity was perceived. + pub position: TilePosition, + /// Tick when recognition will complete. + pub delay_until_tick: u64, + /// What triggered this recognition. + pub trigger: RecognitionTrigger, +} + +/// Component: cognitive delay buffer for entity recognition (D-060). +/// +/// Attached to entities with a KnowledgeGraph (player character, NPCs in future). +/// Tracks pending recognitions -- entities that have been perceived but not yet +/// identified. Drained by `process_cognitive_delay` each tick. +#[derive(Component, Debug, Default)] +pub struct CognitiveDelay { + pending: Vec, +} + +impl CognitiveDelay { + /// Queue a new pending recognition. + pub fn push(&mut self, recognition: PendingRecognition) { + self.pending.push(recognition); + } + + /// Check if an entity is already pending recognition. + pub fn is_pending(&self, stable_id: &StableId) -> bool { + self.pending.iter().any(|p| p.stable_id == *stable_id) + } + + /// Cancel a pending recognition (e.g., entity left perception range). + /// Returns the cancelled entry if it existed. + pub fn cancel(&mut self, stable_id: &StableId) -> Option { + if let Some(idx) = self.pending.iter().position(|p| p.stable_id == *stable_id) { + Some(self.pending.swap_remove(idx)) + } else { + None + } + } + + /// Drain all recognitions whose delay has expired (delay_until_tick <= current_tick). + pub fn drain_ready(&mut self, current_tick: u64) -> Vec { + let mut ready = Vec::new(); + self.pending.retain(|p| { + if current_tick >= p.delay_until_tick { + ready.push(p.clone()); + false + } else { + true + } + }); + ready + } + + /// Read-only access to pending recognitions (for snapshot assembly). + pub fn pending(&self) -> &[PendingRecognition] { + &self.pending + } + + /// Number of pending recognitions. + pub fn len(&self) -> usize { + self.pending.len() + } + + /// Whether there are any pending recognitions. + pub fn is_empty(&self) -> bool { + self.pending.is_empty() + } +} + +/// System: drain expired cognitive delays and emit KnowledgeEvents (#423, D-060). +/// +/// Each tick, checks CognitiveDelay components for expired recognition timers. +/// Expired recognitions are converted to DirectObservation KnowledgeEvents. +/// +/// System ordering: after emit_observation_events, before process_knowledge_events. +pub fn process_cognitive_delay( + time: Res, + mut query: Query<(Entity, &mut CognitiveDelay)>, + mut event_queue: ResMut, +) { + for (observer, mut delay) in query.iter_mut() { + let ready = delay.drain_ready(time.tick); + for recognition in ready { + event_queue.push(KnowledgeEvent { + observer, + tick: time.tick, + event_type: KnowledgeEventType::DirectObservation { + target: recognition.target, + position: recognition.position, + }, + }); + tracing::debug!( + "Cognitive delay resolved: target={:?}, stable_id={}, trigger={:?}, tick={}", + recognition.target, + recognition.stable_id.0, + recognition.trigger, + time.tick, + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::knowledge::registry::EntityRegistry; + use crate::knowledge::KnowledgeGraph; + use bevy_ecs::world::World; + + // --- RecognitionTrigger --- + + #[test] + fn trigger_delay_values() { + assert_eq!(RecognitionTrigger::Normal.delay_ticks(), NORMAL_DELAY_TICKS); + assert_eq!(RecognitionTrigger::Urgent.delay_ticks(), URGENT_DELAY_TICKS); + assert_eq!(NORMAL_DELAY_TICKS, 6); + assert_eq!(URGENT_DELAY_TICKS, 3); + } + + // --- CognitiveDelay component --- + + #[test] + fn default_is_empty() { + let delay = CognitiveDelay::default(); + assert!(delay.is_empty()); + assert_eq!(delay.len(), 0); + assert!(delay.pending().is_empty()); + } + + #[test] + fn push_adds_pending() { + let mut world = World::new(); + let target = world.spawn_empty().id(); + + let mut delay = CognitiveDelay::default(); + delay.push(PendingRecognition { + target, + stable_id: StableId(1), + position: TilePosition::new(5, 5, 0), + delay_until_tick: 106, + trigger: RecognitionTrigger::Normal, + }); + + assert_eq!(delay.len(), 1); + assert!(delay.is_pending(&StableId(1))); + assert!(!delay.is_pending(&StableId(2))); + } + + #[test] + fn cancel_removes_and_returns_entry() { + let mut world = World::new(); + let target = world.spawn_empty().id(); + + let mut delay = CognitiveDelay::default(); + delay.push(PendingRecognition { + target, + stable_id: StableId(1), + position: TilePosition::new(5, 5, 0), + delay_until_tick: 106, + trigger: RecognitionTrigger::Normal, + }); + + let cancelled = delay.cancel(&StableId(1)); + assert!(cancelled.is_some()); + assert_eq!(cancelled.unwrap().stable_id, StableId(1)); + assert!(delay.is_empty()); + } + + #[test] + fn cancel_missing_returns_none() { + let mut delay = CognitiveDelay::default(); + assert!(delay.cancel(&StableId(999)).is_none()); + } + + #[test] + fn drain_ready_before_delay() { + let mut world = World::new(); + let target = world.spawn_empty().id(); + + let mut delay = CognitiveDelay::default(); + delay.push(PendingRecognition { + target, + stable_id: StableId(1), + position: TilePosition::new(5, 5, 0), + delay_until_tick: 106, + trigger: RecognitionTrigger::Normal, + }); + + let ready = delay.drain_ready(105); + assert!(ready.is_empty()); + assert_eq!(delay.len(), 1, "entry should remain pending"); + } + + #[test] + fn drain_ready_at_exact_tick() { + let mut world = World::new(); + let target = world.spawn_empty().id(); + + let mut delay = CognitiveDelay::default(); + delay.push(PendingRecognition { + target, + stable_id: StableId(1), + position: TilePosition::new(5, 5, 0), + delay_until_tick: 106, + trigger: RecognitionTrigger::Normal, + }); + + let ready = delay.drain_ready(106); + assert_eq!(ready.len(), 1); + assert_eq!(ready[0].stable_id, StableId(1)); + assert!(delay.is_empty()); + } + + #[test] + fn drain_ready_past_tick() { + let mut world = World::new(); + let target = world.spawn_empty().id(); + + let mut delay = CognitiveDelay::default(); + delay.push(PendingRecognition { + target, + stable_id: StableId(1), + position: TilePosition::new(5, 5, 0), + delay_until_tick: 106, + trigger: RecognitionTrigger::Normal, + }); + + let ready = delay.drain_ready(200); + assert_eq!(ready.len(), 1); + assert!(delay.is_empty()); + } + + #[test] + fn drain_ready_partial_mixed_triggers() { + let mut world = World::new(); + let t1 = world.spawn_empty().id(); + let t2 = world.spawn_empty().id(); + + let mut delay = CognitiveDelay::default(); + delay.push(PendingRecognition { + target: t1, + stable_id: StableId(1), + position: TilePosition::new(5, 5, 0), + delay_until_tick: 103, // Urgent: 3 ticks from tick 100 + trigger: RecognitionTrigger::Urgent, + }); + delay.push(PendingRecognition { + target: t2, + stable_id: StableId(2), + position: TilePosition::new(10, 10, 0), + delay_until_tick: 106, // Normal: 6 ticks from tick 100 + trigger: RecognitionTrigger::Normal, + }); + + // Tick 103: only urgent should drain + let ready = delay.drain_ready(103); + assert_eq!(ready.len(), 1); + assert_eq!(ready[0].stable_id, StableId(1)); + assert_eq!(delay.len(), 1); + + // Tick 106: normal should drain + let ready = delay.drain_ready(106); + assert_eq!(ready.len(), 1); + assert_eq!(ready[0].stable_id, StableId(2)); + assert!(delay.is_empty()); + } + + #[test] + fn is_pending_checks_stable_id() { + let mut world = World::new(); + let t1 = world.spawn_empty().id(); + let t2 = world.spawn_empty().id(); + + let mut delay = CognitiveDelay::default(); + delay.push(PendingRecognition { + target: t1, + stable_id: StableId(1), + position: TilePosition::new(5, 5, 0), + delay_until_tick: 106, + trigger: RecognitionTrigger::Normal, + }); + + assert!(delay.is_pending(&StableId(1))); + assert!(!delay.is_pending(&StableId(2))); + + delay.push(PendingRecognition { + target: t2, + stable_id: StableId(2), + position: TilePosition::new(10, 10, 0), + delay_until_tick: 106, + trigger: RecognitionTrigger::Normal, + }); + + assert!(delay.is_pending(&StableId(1))); + assert!(delay.is_pending(&StableId(2))); + } + + // --- process_cognitive_delay system --- + + #[test] + fn process_emits_knowledge_event_on_expiry() { + let mut world = World::new(); + let mut registry = EntityRegistry::new(0); + + let target = world.spawn_empty().id(); + let target_sid = registry.register(target); + + let mut cd = CognitiveDelay::default(); + cd.push(PendingRecognition { + target, + stable_id: target_sid, + position: TilePosition::new(5, 5, 0), + delay_until_tick: 106, + trigger: RecognitionTrigger::Normal, + }); + world.spawn((KnowledgeGraph::new(), cd)); + + world.insert_resource(registry); + world.init_resource::(); + world.insert_resource({ + let mut t = SimulationTime::default(); + t.tick = 106; + t + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_cognitive_delay); + schedule.run(&mut world); + + let queue = world.resource::(); + assert_eq!(queue.len(), 1, "should emit one KnowledgeEvent on expiry"); + } + + #[test] + fn process_does_not_emit_before_delay() { + let mut world = World::new(); + let mut registry = EntityRegistry::new(0); + + let target = world.spawn_empty().id(); + let target_sid = registry.register(target); + + let mut cd = CognitiveDelay::default(); + cd.push(PendingRecognition { + target, + stable_id: target_sid, + position: TilePosition::new(5, 5, 0), + delay_until_tick: 106, + trigger: RecognitionTrigger::Normal, + }); + world.spawn((KnowledgeGraph::new(), cd)); + + world.insert_resource(registry); + world.init_resource::(); + world.insert_resource({ + let mut t = SimulationTime::default(); + t.tick = 105; + t + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_cognitive_delay); + schedule.run(&mut world); + + let queue = world.resource::(); + assert!(queue.is_empty(), "should not emit before delay expires"); + } + + #[test] + fn process_urgent_resolves_faster() { + let mut world = World::new(); + let mut registry = EntityRegistry::new(0); + + let t1 = world.spawn_empty().id(); + let t1_sid = registry.register(t1); + let t2 = world.spawn_empty().id(); + let t2_sid = registry.register(t2); + + let mut cd = CognitiveDelay::default(); + cd.push(PendingRecognition { + target: t1, + stable_id: t1_sid, + position: TilePosition::new(5, 5, 0), + delay_until_tick: 103, // Urgent + trigger: RecognitionTrigger::Urgent, + }); + cd.push(PendingRecognition { + target: t2, + stable_id: t2_sid, + position: TilePosition::new(10, 10, 0), + delay_until_tick: 106, // Normal + trigger: RecognitionTrigger::Normal, + }); + world.spawn((KnowledgeGraph::new(), cd)); + + world.insert_resource(registry); + world.init_resource::(); + world.insert_resource({ + let mut t = SimulationTime::default(); + t.tick = 103; + t + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_cognitive_delay); + schedule.run(&mut world); + + let queue = world.resource::(); + assert_eq!(queue.len(), 1, "only urgent should resolve at tick 103"); + } + + #[test] + fn process_no_crash_without_pending() { + let mut world = World::new(); + world.spawn((KnowledgeGraph::new(), CognitiveDelay::default())); + world.init_resource::(); + world.init_resource::(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_cognitive_delay); + schedule.run(&mut world); // Should not panic + } + + #[test] + fn process_clears_pending_after_drain() { + let mut world = World::new(); + let mut registry = EntityRegistry::new(0); + + let target = world.spawn_empty().id(); + let target_sid = registry.register(target); + + let mut cd = CognitiveDelay::default(); + cd.push(PendingRecognition { + target, + stable_id: target_sid, + position: TilePosition::new(5, 5, 0), + delay_until_tick: 106, + trigger: RecognitionTrigger::Normal, + }); + let observer = world.spawn((KnowledgeGraph::new(), cd)).id(); + + world.insert_resource(registry); + world.init_resource::(); + world.insert_resource({ + let mut t = SimulationTime::default(); + t.tick = 106; + t + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_cognitive_delay); + schedule.run(&mut world); + + let cd = world.entity(observer).get::().unwrap(); + assert!(cd.is_empty(), "pending should be cleared after drain"); + } +} diff --git a/server/src/perception/mod.rs b/server/src/perception/mod.rs index 472b86ae1..b182770f3 100644 --- a/server/src/perception/mod.rs +++ b/server/src/perception/mod.rs @@ -5,6 +5,7 @@ use bevy_app::prelude::*; use bevy_ecs::schedule::IntoScheduleConfigs; +pub mod cognitive_delay; pub mod interpretation; pub mod observation; pub mod observer; @@ -23,9 +24,14 @@ impl Plugin for PerceptionPlugin { .init_resource::() .add_systems( Update, - interpretation::generate_observation_events - .after(observation::emit_observation_events) - .before(crate::knowledge::events::process_knowledge_events), + ( + cognitive_delay::process_cognitive_delay + .after(observation::emit_observation_events) + .before(crate::knowledge::events::process_knowledge_events), + interpretation::generate_observation_events + .after(observation::emit_observation_events) + .before(crate::knowledge::events::process_knowledge_events), + ), ); tracing::debug!("PerceptionPlugin initialized"); } diff --git a/server/src/perception/observation.rs b/server/src/perception/observation.rs index 35a480100..a2bdd9902 100644 --- a/server/src/perception/observation.rs +++ b/server/src/perception/observation.rs @@ -9,6 +9,7 @@ use crate::bridge::types::*; use crate::knowledge::{ EntityRegistry, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType, KnowledgeGraph, }; +use crate::perception::cognitive_delay::{CognitiveDelay, PendingRecognition, RecognitionTrigger}; use crate::simulation::movement::{PlayerCharacter, TilePosition}; use crate::simulation::time::SimulationTime; @@ -21,7 +22,10 @@ pub fn emit_observation_events( time: Res, buffer: Res, registry: Res, - observer_query: Query<(Entity, &KnowledgeGraph), With>, + mut observer_query: Query< + (Entity, &KnowledgeGraph, Option<&mut CognitiveDelay>), + With, + >, mut event_queue: ResMut, entity_positions: Query<&TilePosition>, ) { @@ -29,7 +33,8 @@ pub fn emit_observation_events( return; }; - let Ok((observer_entity, observer_kg)) = observer_query.single() else { + let Ok((observer_entity, observer_kg, mut cognitive_delay)) = observer_query.single_mut() + else { return; }; @@ -41,7 +46,9 @@ pub fn emit_observation_events( .map(|e| e.entity_id) .collect(); - // Emit DirectObservation for each visible non-player entity + // Emit DirectObservation for each visible non-player entity. + // NEW entities (not in observer's KG) are routed through cognitive delay + // when the CognitiveDelay component is present (#423, D-060). for visible in &snapshot.entities { if matches!(visible.kind, EntityKind::Player) { continue; @@ -54,7 +61,43 @@ pub fn emit_observation_events( }; // Get tile position for knowledge tracking - if let Ok(pos) = entity_positions.get(entity) { + let Ok(pos) = entity_positions.get(entity) else { + continue; + }; + + if observer_kg.knows_entity(&stable_id) { + // Known entity: immediate DirectObservation (position update) + event_queue.push(KnowledgeEvent { + observer: observer_entity, + tick: time.tick, + event_type: KnowledgeEventType::DirectObservation { + target: entity, + position: *pos, + }, + }); + } else if let Some(ref mut delay) = cognitive_delay { + // New entity + cognitive delay available: buffer recognition + if !delay.is_pending(&stable_id) { + // TODO: wire RecognitionTrigger::Urgent for observe_anomaly triggers + let trigger = RecognitionTrigger::Normal; + delay.push(PendingRecognition { + target: entity, + stable_id, + position: *pos, + delay_until_tick: time.tick + trigger.delay_ticks(), + trigger, + }); + tracing::debug!( + "Cognitive delay queued: stable_id={}, position=({},{},{}), delay_until={}", + stable_id.0, + pos.x, + pos.y, + pos.z, + time.tick + trigger.delay_ticks(), + ); + } + } else { + // No CognitiveDelay component: immediate (backward compat) event_queue.push(KnowledgeEvent { observer: observer_entity, tick: time.tick, @@ -83,6 +126,20 @@ pub fn emit_observation_events( } } } + + // Cancel pending cognitive delays for entities that left perception range. + // Without this, a delay could expire and emit a DirectObservation for an + // entity that is no longer visible (stale position, 1-tick flash). + if let Some(ref mut delay) = cognitive_delay { + let pending_ids: Vec = + delay.pending().iter().map(|p| p.stable_id).collect(); + for sid in pending_ids { + if !visible_stable_ids.contains(&sid.0) { + delay.cancel(&sid); + tracing::debug!("Cognitive delay cancelled: stable_id={} left LOS", sid.0); + } + } + } } #[cfg(test)] @@ -124,9 +181,7 @@ mod tests { .id(); registry.register(player); - let npc = world - .spawn((Npc, TilePosition::new(16, 14, 0))) - .id(); + let npc = world.spawn((Npc, TilePosition::new(16, 14, 0))).id(); registry.register(npc); world.insert_resource(registry); @@ -152,9 +207,7 @@ mod tests { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); - let npc = world - .spawn((Npc, TilePosition::new(16, 14, 0))) - .id(); + let npc = world.spawn((Npc, TilePosition::new(16, 14, 0))).id(); let npc_sid = registry.register(npc); // Player with pre-existing Direct knowledge of NPC @@ -195,7 +248,10 @@ mod tests { KnowledgeEventType::LeftLOS { target } if target == npc ) }); - assert!(has_left_los, "should emit LeftLOS when NPC is no longer visible"); + assert!( + has_left_los, + "should emit LeftLOS when NPC is no longer visible" + ); } #[test] diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index cd80ec780..e39429eb2 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -12,6 +12,7 @@ use std::collections::HashSet; use crate::bridge::types::*; use crate::knowledge::types::KnowledgeState; use crate::knowledge::{EntityRegistry, KnowledgeGraph, StableId}; +use crate::perception::cognitive_delay::CognitiveDelay; use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; use crate::perception::vision_cone::Facing; use crate::simulation::interaction::NearbyInteractionBuffer; @@ -53,7 +54,18 @@ pub fn compute_observer_snapshot( geometry: Res, registry: Res, mut observer_query: Query< - (Entity, &TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer, &mut MonologueBuffer, Option<&Stance>, Option<&CharacterArchetype>, Option<&mut SprintAnomalyQueue>), + ( + Entity, + &TilePosition, + Option<&Facing>, + &KnowledgeGraph, + &mut NearbyInteractionBuffer, + &mut MonologueBuffer, + Option<&Stance>, + Option<&CharacterArchetype>, + Option<&mut SprintAnomalyQueue>, + Option<&CognitiveDelay>, + ), With, >, all_entities: Query<( @@ -65,8 +77,18 @@ pub fn compute_observer_snapshot( inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, mut buffer: ResMut, ) { - let Ok((observer_entity, _observer_pos, facing_opt, observer_kg, mut interaction_buffer, mut monologue_buffer, stance_opt, archetype_opt, mut anomaly_queue_opt)) = - observer_query.single_mut() + let Ok(( + observer_entity, + _observer_pos, + facing_opt, + observer_kg, + mut interaction_buffer, + mut monologue_buffer, + stance_opt, + archetype_opt, + mut anomaly_queue_opt, + cognitive_delay_opt, + )) = observer_query.single_mut() else { return; }; @@ -141,6 +163,27 @@ pub fn compute_observer_snapshot( let current_monologue = monologue_buffer.take(); + // Build pending recognitions from CognitiveDelay (#423, D-060) + let pending_recognitions = cognitive_delay_opt + .map(|delay| { + delay + .pending() + .iter() + .map(|p| { + let (rx, ry, rz) = p.position.to_render_coords(); + PendingRecognitionWire { + entity_id: p.stable_id.0, + x: rx, + y: ry, + z: rz, + remaining_ticks: p.delay_until_tick.saturating_sub(time.tick), + total_delay_ticks: p.trigger.delay_ticks(), + } + }) + .collect() + }) + .unwrap_or_default(); + buffer.snapshot = Some(ObserverSnapshot { version: crate::bridge::types::PROTOCOL_VERSION, tick: time.tick, @@ -152,6 +195,7 @@ pub fn compute_observer_snapshot( visible_tiles: geometry.visible_tiles.clone(), nearby_interactions, current_monologue, + pending_recognitions, }); } @@ -360,13 +404,16 @@ fn apply_phase2_verb_filter( // This implements D-057: "Character differentiation via Phase 2 observer // filter, not separate verb systems." for verb in &mut interaction.verbs { - if let Some(label) = archetype_verb_label(archetype, interaction.object_type, verb.kind) { + if let Some(label) = archetype_verb_label(archetype, interaction.object_type, verb.kind) + { verb.label = label.into(); } } // Re-sort after priority changes and verb additions - interaction.verbs.sort_by_key(|v| (v.priority, v.kind as u8)); + interaction + .verbs + .sort_by_key(|v| (v.priority, v.kind as u8)); } } @@ -385,11 +432,17 @@ fn archetype_verb_label( match (archetype, object_type, kind) { // Smuggler: Container verbs — physical manipulation vocabulary (CharacterArchetype::Smuggler, Some(ObjectType::Container), VerbKind::Open) => Some("Move"), - (CharacterArchetype::Smuggler, Some(ObjectType::Container), VerbKind::Search) => Some("Stash"), + (CharacterArchetype::Smuggler, Some(ObjectType::Container), VerbKind::Search) => { + Some("Stash") + } // Detective: Container verbs — investigation vocabulary - (CharacterArchetype::Detective, Some(ObjectType::Container), VerbKind::Open) => Some("Scan"), - (CharacterArchetype::Detective, Some(ObjectType::Container), VerbKind::Search) => Some("Flag"), + (CharacterArchetype::Detective, Some(ObjectType::Container), VerbKind::Open) => { + Some("Scan") + } + (CharacterArchetype::Detective, Some(ObjectType::Container), VerbKind::Search) => { + Some("Flag") + } // All other combinations: keep Phase 1 default label _ => None, diff --git a/server/src/perception/observer/tests.rs b/server/src/perception/observer/tests.rs index a84f1b351..6d6ddcea7 100644 --- a/server/src/perception/observer/tests.rs +++ b/server/src/perception/observer/tests.rs @@ -57,7 +57,7 @@ fn player_always_visible_in_snapshot() { let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); - assert_eq!(snapshot.version, 6); + assert_eq!(snapshot.version, PROTOCOL_VERSION); assert_eq!(snapshot.entities.len(), 1); assert!(matches!(snapshot.entities[0].kind, EntityKind::Player)); assert_eq!(snapshot.entities[0].observation, EntityVisibility::Visible); @@ -198,7 +198,10 @@ fn game_time_populated() { snapshot.game_time.day_phase, crate::simulation::time::DayPhase::Evening ); - assert_eq!(snapshot.game_time.tick_rate, crate::simulation::time::TickRate::Paused); + assert_eq!( + snapshot.game_time.tick_rate, + crate::simulation::time::TickRate::Paused + ); } #[test] @@ -298,7 +301,11 @@ fn remembered_entity_appears_as_ghost() { .id(); registry.register(player); world.insert_resource(registry); - world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 100; t }); + world.insert_resource({ + let mut t = SimulationTime::default(); + t.tick = 100; + t + }); run_observer_pipeline(&mut world); @@ -312,13 +319,20 @@ fn remembered_entity_appears_as_ghost() { .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) .collect(); assert_eq!(remembered.len(), 1, "should have one remembered entity"); - assert_eq!(remembered[0].relationship, RelationshipState::PersonOfInterest); + assert_eq!( + remembered[0].relationship, + RelationshipState::PersonOfInterest + ); // Remembered entity at last_known_position (16, 28), not actual (16, 30) assert_eq!(remembered[0].x, 16.5); assert_eq!(remembered[0].y, 28.5); - if let EntityVisibility::Remembered { confidence, age_ticks } = &remembered[0].observation { + if let EntityVisibility::Remembered { + confidence, + age_ticks, + } = &remembered[0].observation + { assert_eq!(*confidence, KnowledgeConfidence::KnowsDetails); assert_eq!(*age_ticks, 50); // tick 100 - last_observed 50 } @@ -479,16 +493,19 @@ fn knowledge_without_position_not_shown() { // Player knows about NPC but has never seen it (no position) let mut kg = KnowledgeGraph::new(); // Insert knowledge manually without a position - kg.entities.insert(npc_sid, crate::knowledge::EntityKnowledge { - last_known_position: None, - last_observed_tick: 0, - last_updated_tick: 50, - confidence: KnowledgeConfidence::KnowsOf, - source: crate::knowledge::KnowledgeSource::Background, - state: crate::knowledge::KnowledgeState::Active, - relationship: RelationshipState::PersonOfInterest, - known_attributes: std::collections::BTreeMap::new(), - }); + kg.entities.insert( + npc_sid, + crate::knowledge::EntityKnowledge { + last_known_position: None, + last_observed_tick: 0, + last_updated_tick: 50, + confidence: KnowledgeConfidence::KnowsOf, + source: crate::knowledge::KnowledgeSource::Background, + state: crate::knowledge::KnowledgeState::Active, + relationship: RelationshipState::PersonOfInterest, + known_attributes: std::collections::BTreeMap::new(), + }, + ); let player = world .spawn(( @@ -547,7 +564,9 @@ fn multiple_npcs_in_los_all_visible() { .filter(|e| matches!(e.kind, EntityKind::Npc)) .collect(); assert_eq!(npcs.len(), 3); - assert!(npcs.iter().all(|n| n.observation == EntityVisibility::Visible)); + assert!(npcs + .iter() + .all(|n| n.observation == EntityVisibility::Visible)); } #[test] @@ -657,9 +676,19 @@ fn snapshot_v6_fields_default_through_pipeline() { let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); - assert_eq!(snapshot.version, 6, "should be protocol v6"); - assert_eq!(snapshot.player_stance, MovementStance::Walk, "default stance is Walk"); - assert!(snapshot.player_inventory.is_empty(), "default inventory is empty"); + assert_eq!( + snapshot.version, PROTOCOL_VERSION, + "should be current protocol version" + ); + assert_eq!( + snapshot.player_stance, + MovementStance::Walk, + "default stance is Walk" + ); + assert!( + snapshot.player_inventory.is_empty(), + "default inventory is empty" + ); } #[test] @@ -731,8 +760,14 @@ fn phase2_confront_injected_for_npc_with_knows_details() { let interaction = &snapshot.nearby_interactions[0]; // Should have Talk, ExamineNpc, AND Confront (Phase 2 injected) assert_eq!(interaction.verbs.len(), 3); - let confront = interaction.verbs.iter().find(|v| v.kind == VerbKind::Confront); - assert!(confront.is_some(), "Confront should be injected for KnowsDetails+"); + let confront = interaction + .verbs + .iter() + .find(|v| v.kind == VerbKind::Confront); + assert!( + confront.is_some(), + "Confront should be injected for KnowsDetails+" + ); assert_eq!(confront.unwrap().priority, 3); assert_eq!(confront.unwrap().label, "Confront"); } @@ -753,16 +788,19 @@ fn phase2_no_confront_without_knows_details() { // Player only Suspects this NPC (below KnowsDetails threshold) let mut kg = KnowledgeGraph::new(); - kg.entities.insert(npc_sid, crate::knowledge::EntityKnowledge { - last_known_position: Some(TilePosition::new(16, 15, 0)), - last_observed_tick: 50, - last_updated_tick: 50, - confidence: KnowledgeConfidence::Suspects, - source: crate::knowledge::KnowledgeSource::Background, - state: KnowledgeState::Active, - relationship: RelationshipState::Unknown, - known_attributes: std::collections::BTreeMap::new(), - }); + kg.entities.insert( + npc_sid, + crate::knowledge::EntityKnowledge { + last_known_position: Some(TilePosition::new(16, 15, 0)), + last_observed_tick: 50, + last_updated_tick: 50, + confidence: KnowledgeConfidence::Suspects, + source: crate::knowledge::KnowledgeSource::Background, + state: KnowledgeState::Active, + relationship: RelationshipState::Unknown, + known_attributes: std::collections::BTreeMap::new(), + }, + ); let player = world .spawn(( @@ -783,8 +821,14 @@ fn phase2_no_confront_without_knows_details() { let snapshot = buffer.snapshot.as_ref().unwrap(); assert_eq!(snapshot.nearby_interactions.len(), 1); let interaction = &snapshot.nearby_interactions[0]; - let confront = interaction.verbs.iter().find(|v| v.kind == VerbKind::Confront); - assert!(confront.is_none(), "Confront should NOT appear for Suspects confidence"); + let confront = interaction + .verbs + .iter() + .find(|v| v.kind == VerbKind::Confront); + assert!( + confront.is_none(), + "Confront should NOT appear for Suspects confidence" + ); } #[test] @@ -827,7 +871,10 @@ fn phase2_no_confront_at_mid_range() { assert_eq!(snapshot.nearby_interactions.len(), 1); let interaction = &snapshot.nearby_interactions[0]; // Mid range: only ExamineNpc, no Talk, no Confront - let confront = interaction.verbs.iter().find(|v| v.kind == VerbKind::Confront); + let confront = interaction + .verbs + .iter() + .find(|v| v.kind == VerbKind::Confront); assert!(confront.is_none(), "Confront requires close range"); } @@ -954,8 +1001,14 @@ fn phase2_smuggler_relabels_container_verbs() { let interaction = &snapshot.nearby_interactions[0]; // Container at close range: Open→"Move", Search→"Stash", Observe stays "Observe" let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open); - let search_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Search); - let observe_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Observe); + let search_verb = interaction + .verbs + .iter() + .find(|v| v.kind == VerbKind::Search); + let observe_verb = interaction + .verbs + .iter() + .find(|v| v.kind == VerbKind::Observe); assert_eq!(open_verb.unwrap().label, "Move", "smuggler Open→Move"); assert_eq!(search_verb.unwrap().label, "Stash", "smuggler Search→Stash"); assert_eq!(observe_verb.unwrap().label, "Observe", "Observe unchanged"); @@ -997,7 +1050,10 @@ fn phase2_detective_relabels_container_verbs() { assert_eq!(snapshot.nearby_interactions.len(), 1); let interaction = &snapshot.nearby_interactions[0]; let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open); - let search_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Search); + let search_verb = interaction + .verbs + .iter() + .find(|v| v.kind == VerbKind::Search); assert_eq!(open_verb.unwrap().label, "Scan", "detective Open→Scan"); assert_eq!(search_verb.unwrap().label, "Flag", "detective Search→Flag"); } @@ -1039,7 +1095,11 @@ fn phase2_default_archetype_is_detective() { let interaction = &snapshot.nearby_interactions[0]; // Default = Detective labels let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open); - assert_eq!(open_verb.unwrap().label, "Scan", "default archetype should use Detective labels"); + assert_eq!( + open_verb.unwrap().label, + "Scan", + "default archetype should use Detective labels" + ); } #[test] @@ -1078,7 +1138,11 @@ fn phase2_non_container_keeps_default_labels() { assert_eq!(snapshot.nearby_interactions.len(), 1); let interaction = &snapshot.nearby_interactions[0]; let read_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Read); - assert_eq!(read_verb.unwrap().label, "Read", "Readable labels unchanged for smuggler"); + assert_eq!( + read_verb.unwrap().label, + "Read", + "Readable labels unchanged for smuggler" + ); } #[test] @@ -1155,8 +1219,7 @@ fn phase2_npc_object_type_is_none() { let snapshot = buffer.snapshot.as_ref().unwrap(); assert_eq!(snapshot.nearby_interactions.len(), 1); assert_eq!( - snapshot.nearby_interactions[0].object_type, - None, + snapshot.nearby_interactions[0].object_type, None, "NPC should have object_type=None" ); } @@ -1256,7 +1319,11 @@ fn phase2_poi_with_confront_verb_order() { let snapshot = buffer.snapshot.as_ref().unwrap(); assert_eq!(snapshot.nearby_interactions.len(), 1); let verbs = &snapshot.nearby_interactions[0].verbs; - assert_eq!(verbs.len(), 3, "POI+KnowsDetails: ExamineNpc + Talk + Confront"); + assert_eq!( + verbs.len(), + 3, + "POI+KnowsDetails: ExamineNpc + Talk + Confront" + ); // POI flips ExamineNpc to priority 1, Talk to 2, Confront at 3 assert_eq!(verbs[0].kind, VerbKind::ExamineNpc); assert_eq!(verbs[0].priority, 1); @@ -1306,7 +1373,11 @@ fn carried_item_appears_in_snapshot_inventory() { let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); - assert_eq!(snapshot.player_inventory.len(), 1, "carried item should appear in snapshot"); + assert_eq!( + snapshot.player_inventory.len(), + 1, + "carried item should appear in snapshot" + ); assert_eq!(snapshot.player_inventory[0].name, "Manifest Copy"); assert_eq!(snapshot.player_inventory[0].slot, 0); } @@ -1380,7 +1451,11 @@ fn multiple_carried_items_sorted_in_snapshot() { let player_sid = registry.register(player); // Spawn 3 v0.1 items in reverse slot order - for (slot, name) in [(2, "Personal Comm Log"), (0, "Manifest Copy"), (1, "Corridor Access Token")] { + for (slot, name) in [ + (2, "Personal Comm Log"), + (0, "Manifest Copy"), + (1, "Corridor Access Token"), + ] { let item = world .spawn(( CarriedBy(player_sid), @@ -1449,7 +1524,10 @@ fn sprint_past_contradicted_npc_queues_anomaly() { // Anomaly should be queued let mut query = world.query::<&SprintAnomalyQueue>(); let queue = query.single(&world).unwrap(); - assert!(queue.has_pending(), "contradicted NPC while sprinting should queue anomaly"); + assert!( + queue.has_pending(), + "contradicted NPC while sprinting should queue anomaly" + ); } #[test] @@ -1488,7 +1566,10 @@ fn walk_past_contradicted_npc_does_not_queue_anomaly() { let mut query = world.query::<&SprintAnomalyQueue>(); let queue = query.single(&world).unwrap(); - assert!(!queue.has_pending(), "walking past contradicted NPC should NOT queue anomaly"); + assert!( + !queue.has_pending(), + "walking past contradicted NPC should NOT queue anomaly" + ); } #[test] @@ -1527,7 +1608,10 @@ fn sprint_past_active_npc_does_not_queue_anomaly() { let mut query = world.query::<&SprintAnomalyQueue>(); let queue = query.single(&world).unwrap(); - assert!(!queue.has_pending(), "sprint past Active NPC should NOT queue anomaly"); + assert!( + !queue.has_pending(), + "sprint past Active NPC should NOT queue anomaly" + ); } #[test] @@ -1612,7 +1696,10 @@ fn sprint_anomaly_without_queue_component_no_crash() { run_observer_pipeline(&mut world); let buffer = world.resource::(); - assert!(buffer.snapshot.is_some(), "snapshot should still be produced"); + assert!( + buffer.snapshot.is_some(), + "snapshot should still be produced" + ); } #[test] @@ -1667,12 +1754,18 @@ fn sprint_anomaly_npc_visible_but_interactions_suppressed() { assert_eq!(npcs.len(), 1, "NPC should be visible during sprint"); // Interactions should be empty (sprint suppression) - assert!(snapshot.nearby_interactions.is_empty(), "sprint suppresses interactions"); + assert!( + snapshot.nearby_interactions.is_empty(), + "sprint suppresses interactions" + ); // Anomaly should be queued let mut query = world.query::<&SprintAnomalyQueue>(); let queue = query.single(&world).unwrap(); - assert!(queue.has_pending(), "anomaly should be queued despite interaction suppression"); + assert!( + queue.has_pending(), + "anomaly should be queued despite interaction suppression" + ); } #[test] diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index 69e52ddc5..f1aa4eb18 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -57,6 +57,7 @@ fn snapshot_roundtrip_over_unix_socket() { visible_tiles: vec![], nearby_interactions: vec![], current_monologue: None, + pending_recognitions: vec![], }; bridge @@ -119,7 +120,10 @@ fn input_roundtrip_over_unix_socket() { }, PlayerInput { tick: 11, - action: PlayerAction::Interact { target_entity_id: None, verb: None }, + action: PlayerAction::Interact { + target_entity_id: None, + verb: None, + }, }, ]; diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 3e980b3d7..bc30231e7 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -43,6 +43,7 @@ fn snapshot_roundtrip_over_tcp() { visible_tiles: vec![], nearby_interactions: vec![], current_monologue: None, + pending_recognitions: vec![], }; bridge @@ -98,7 +99,10 @@ fn input_roundtrip_over_tcp() { }, PlayerInput { tick: 11, - action: PlayerAction::Interact { target_entity_id: None, verb: None }, + action: PlayerAction::Interact { + target_entity_id: None, + verb: None, + }, }, ]; diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index af7b8c352..73208deb9 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -33,6 +33,7 @@ fn fixture_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot visible_tiles: vec![], nearby_interactions: vec![], current_monologue: None, + pending_recognitions: vec![], } } @@ -200,6 +201,7 @@ fn generate_msgpack_fixtures() { ], nearby_interactions: vec![], current_monologue: None, + pending_recognitions: vec![], }; write_fixture( "snapshot_v2_full", @@ -214,7 +216,10 @@ fn generate_msgpack_fixtures() { }, PlayerInput { tick: 0, - action: PlayerAction::Interact { target_entity_id: None, verb: None }, + action: PlayerAction::Interact { + target_entity_id: None, + verb: None, + }, }, ]; write_fixture( diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 5b6418ec8..e91583325 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -22,6 +22,7 @@ fn test_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { visible_tiles: vec![], nearby_interactions: vec![], current_monologue: None, + pending_recognitions: vec![], } } @@ -86,7 +87,10 @@ fn all_player_action_variants_roundtrip() { PlayerAction::MoveNorthwest, PlayerAction::MoveSoutheast, PlayerAction::MoveSouthwest, - PlayerAction::Interact { target_entity_id: None, verb: None }, + PlayerAction::Interact { + target_entity_id: None, + verb: None, + }, PlayerAction::UsePerceptionMode("thermal".to_string()), PlayerAction::Pause, PlayerAction::Unpause, @@ -134,7 +138,11 @@ fn all_fixtures_deserialize() { if name.starts_with("snapshot") { let snap = rmp_serde::from_slice::(&bytes) .unwrap_or_else(|e| panic!("deserialize snapshot fixture {}: {}", name, e)); - assert_eq!(snap.version, PROTOCOL_VERSION, "fixture {} has wrong version", name); + assert_eq!( + snap.version, PROTOCOL_VERSION, + "fixture {} has wrong version", + name + ); } else if name.starts_with("input_batch") { rmp_serde::from_slice::>(&bytes) .unwrap_or_else(|e| panic!("deserialize batch input fixture {}: {}", name, e)); @@ -225,6 +233,7 @@ fn snapshot_v2_fields_roundtrip() { ], nearby_interactions: vec![], current_monologue: None, + pending_recognitions: vec![], }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); @@ -237,8 +246,14 @@ fn snapshot_v2_fields_roundtrip() { assert_eq!(decoded.game_time.tick_rate, TickRate::Paused); assert_eq!(decoded.player_facing, FacingDirection::Southeast); assert_eq!(decoded.visible_tiles.len(), 2); - assert_eq!(decoded.visible_tiles[0].visibility, VisibilitySector::Forward); - assert_eq!(decoded.visible_tiles[1].visibility, VisibilitySector::Peripheral); + assert_eq!( + decoded.visible_tiles[0].visibility, + VisibilitySector::Forward + ); + assert_eq!( + decoded.visible_tiles[1].visibility, + VisibilitySector::Peripheral + ); assert_eq!(decoded.entities[0].visibility, VisibilitySector::Forward); } @@ -259,7 +274,11 @@ fn entity_to_bits_roundtrip() { for entity in [e1, e2, e3, e4] { let bits = entity.to_bits(); let restored = Entity::from_bits(bits); - assert_eq!(entity, restored, "Entity::to_bits() roundtrip failed for {:?}", entity); + assert_eq!( + entity, restored, + "Entity::to_bits() roundtrip failed for {:?}", + entity + ); } } @@ -268,7 +287,10 @@ fn entity_to_bits_roundtrip() { fn protocol_version_constant_matches_snapshot() { let snapshot = test_snapshot(0, vec![]); assert_eq!(snapshot.version, PROTOCOL_VERSION); - assert_eq!(PROTOCOL_VERSION, 6, "bump this assertion when protocol version changes"); + assert_eq!( + PROTOCOL_VERSION, 7, + "bump this assertion when protocol version changes" + ); } /// All FacingDirection variants round-trip @@ -302,6 +324,7 @@ fn all_facing_direction_variants_roundtrip() { visible_tiles: vec![], nearby_interactions: vec![], current_monologue: None, + pending_recognitions: vec![], }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); @@ -412,19 +435,35 @@ fn v5_payload_deserializes_into_v6_struct() { // New fields should get their defaults assert_eq!(decoded.version, 5, "version field preserved from v5"); assert_eq!(decoded.tick, 42); - assert_eq!(decoded.player_stance, MovementStance::Walk, "missing stance should default to Walk"); - assert!(decoded.player_inventory.is_empty(), "missing inventory should default to empty"); - assert!(decoded.current_monologue.is_none(), "missing monologue should default to None"); + assert_eq!( + decoded.player_stance, + MovementStance::Walk, + "missing stance should default to Walk" + ); + assert!( + decoded.player_inventory.is_empty(), + "missing inventory should default to empty" + ); + assert!( + decoded.current_monologue.is_none(), + "missing monologue should default to None" + ); + assert!( + decoded.pending_recognitions.is_empty(), + "missing pending_recognitions should default to empty" + ); } /// Full 9-slot inventory roundtrip (D-065: 3x3 grid = 9 slots universal) #[test] fn snapshot_v6_full_inventory_roundtrip() { - let items: Vec = (0..9).map(|i| InventoryItem { - item_id: 100 + i as u64, - name: format!("Item {}", i), - slot: i, - }).collect(); + let items: Vec = (0..9) + .map(|i| InventoryItem { + item_id: 100 + i as u64, + name: format!("Item {}", i), + slot: i, + }) + .collect(); let mut snapshot = test_snapshot(0, vec![]); snapshot.player_inventory = items; @@ -482,7 +521,8 @@ fn all_verb_kind_variants_roundtrip() { assert_eq!(decoded.nearby_interactions.len(), 1); assert_eq!( decoded.nearby_interactions[0].verbs[0].kind, kind, - "VerbKind::{:?} did not roundtrip", kind + "VerbKind::{:?} did not roundtrip", + kind ); } } @@ -506,7 +546,11 @@ fn all_object_type_variants_roundtrip() { for obj_type in types { let bytes = rmp_serde::to_vec_named(&obj_type).expect("serialize"); let decoded: ObjectType = rmp_serde::from_slice(&bytes).expect("deserialize"); - assert_eq!(decoded, obj_type, "ObjectType::{:?} roundtrip failed", obj_type); + assert_eq!( + decoded, obj_type, + "ObjectType::{:?} roundtrip failed", + obj_type + ); } } @@ -533,7 +577,10 @@ fn verb_kind_confront_roundtrip() { let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); assert_eq!(decoded.nearby_interactions.len(), 1); - assert_eq!(decoded.nearby_interactions[0].verbs[0].kind, VerbKind::Confront); + assert_eq!( + decoded.nearby_interactions[0].verbs[0].kind, + VerbKind::Confront + ); assert_eq!(decoded.nearby_interactions[0].verbs[0].label, "Confront"); } @@ -541,15 +588,16 @@ fn verb_kind_confront_roundtrip() { /// Used in Phase 2 label relabeling — must survive the wire. #[test] fn all_character_archetype_variants_roundtrip() { - let archetypes = [ - CharacterArchetype::Smuggler, - CharacterArchetype::Detective, - ]; + let archetypes = [CharacterArchetype::Smuggler, CharacterArchetype::Detective]; for archetype in archetypes { let bytes = rmp_serde::to_vec_named(&archetype).expect("serialize"); let decoded: CharacterArchetype = rmp_serde::from_slice(&bytes).expect("deserialize"); - assert_eq!(decoded, archetype, "CharacterArchetype::{:?} roundtrip failed", archetype); + assert_eq!( + decoded, archetype, + "CharacterArchetype::{:?} roundtrip failed", + archetype + ); } } @@ -575,7 +623,10 @@ fn nearby_interaction_contradicted_roundtrip() { let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); - assert!(decoded.nearby_interactions[0].contradicted, "contradicted flag should survive roundtrip"); + assert!( + decoded.nearby_interactions[0].contradicted, + "contradicted flag should survive roundtrip" + ); } /// NearbyInteraction.object_type round-trips through MessagePack (#422). @@ -600,5 +651,8 @@ fn nearby_interaction_object_type_roundtrip() { let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); - assert_eq!(decoded.nearby_interactions[0].object_type, Some(ObjectType::Container)); + assert_eq!( + decoded.nearby_interactions[0].object_type, + Some(ObjectType::Container) + ); }