feat(perception): add cognitive delay system for fog recognition (#423)

New CognitiveDelay component buffers perception events before emitting
KnowledgeEvents. Normal delay: 6 ticks (0.6s), urgent: 3 ticks (0.3s).
Drain system runs after emit_observation_events, before process_knowledge_events.

Adds pending_recognitions to ObserverSnapshot (protocol v7) for client
fog entity visualization. Includes cancellation when entity leaves LOS.

Monologue fires during delay (not after) per D-060 — cross-system
wiring deferred to follow-up ticket #451.

Ref: D-060 (cognitive delay), D-031 (10 tps), D-059 (fog layers)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 00:41:08 +01:00
co-authored by Claude Opus 4.6
parent 3a2cfc37e0
commit 4d8fe996fc
17 changed files with 933 additions and 105 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+24 -1
View File
@@ -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<MonologueEvent>,
/// 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<PendingRecognitionWire>,
}
/// 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)]
+19 -4
View File
@@ -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);
+7 -1
View File
@@ -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(),
+509
View File
@@ -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<PendingRecognition>,
}
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<PendingRecognition> {
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<PendingRecognition> {
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<SimulationTime>,
mut query: Query<(Entity, &mut CognitiveDelay)>,
mut event_queue: ResMut<KnowledgeEventQueue>,
) {
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::<KnowledgeEventQueue>();
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::<KnowledgeEventQueue>();
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::<KnowledgeEventQueue>();
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::<KnowledgeEventQueue>();
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::<KnowledgeEventQueue>();
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::<KnowledgeEventQueue>();
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::<KnowledgeEventQueue>();
world.init_resource::<SimulationTime>();
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::<KnowledgeEventQueue>();
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::<CognitiveDelay>().unwrap();
assert!(cd.is_empty(), "pending should be cleared after drain");
}
}
+9 -3
View File
@@ -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::<query::ActivePerceptionMode>()
.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");
}
+67 -11
View File
@@ -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<SimulationTime>,
buffer: Res<SnapshotBuffer>,
registry: Res<EntityRegistry>,
observer_query: Query<(Entity, &KnowledgeGraph), With<PlayerCharacter>>,
mut observer_query: Query<
(Entity, &KnowledgeGraph, Option<&mut CognitiveDelay>),
With<PlayerCharacter>,
>,
mut event_queue: ResMut<KnowledgeEventQueue>,
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<crate::knowledge::types::StableId> =
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]
+61 -8
View File
@@ -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<VisibilityGeometry>,
registry: Res<EntityRegistry>,
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<PlayerCharacter>,
>,
all_entities: Query<(
@@ -65,8 +77,18 @@ pub fn compute_observer_snapshot(
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
mut buffer: ResMut<SnapshotBuffer>,
) {
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,
+143 -50
View File
@@ -57,7 +57,7 @@ fn player_always_visible_in_snapshot() {
let buffer = world.resource::<SnapshotBuffer>();
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::<SnapshotBuffer>();
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::<SnapshotBuffer>();
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::<SnapshotBuffer>();
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]
+5 -1
View File
@@ -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,
},
},
];
+5 -1
View File
@@ -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,
},
},
];
+6 -1
View File
@@ -33,6 +33,7 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> 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(
+78 -24
View File
@@ -22,6 +22,7 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> 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::<ObserverSnapshot>(&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::<Vec<PlayerInput>>(&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<InventoryItem> = (0..9).map(|i| InventoryItem {
item_id: 100 + i as u64,
name: format!("Item {}", i),
slot: i,
}).collect();
let items: Vec<InventoryItem> = (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)
);
}