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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user