refactor(server): address PR #16 review feedback
Hoshe + Tyre review items: - Use StableId consistently for wire entity_id (H4) across observer, observation, interpretation, and interaction systems - Make NearbyInteractionBuffer.interactions private with take() (H1/H20) - Add system ordering constraint for compute_nearby_interactions (H5) - Panic on missing PlayerCharacter in input processing (H2) - Remove redundant paused field from GameTime (Tyre8) - Remove #[serde(default)] from nearby_interactions (H3) - Change NearbyInteraction.distance from f32 to u32 (H8) - Add sort stability for equal verb priorities (H6) - Scope constants to pub(crate) (H7) - Add debug_assert for last_observed_tick ordering (H10) - Strengthen unregistered entity handling to debug_assert + error (H11) - Document fractional tick accumulation (Tyre9) - Extract collect_remembered_entities helper (Tyre2/H17) - Add half_rate_no_drift_over_10000_frames test (H14) - Add mid-range and deterministic sort tests (H15) - Add fixture version assertion (H16) - Regenerate msgpack fixtures for wire format changes 146 unit + 19 integration tests pass, zero clippy warnings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -110,39 +110,39 @@ pub fn generate_observation_events(
|
||||
continue;
|
||||
}
|
||||
|
||||
let entity = Entity::from_bits(visible.entity_id);
|
||||
// Convert wire StableId back to bevy Entity via registry
|
||||
let stable_id = StableId(visible.entity_id);
|
||||
let Some(entity) = registry.to_entity(&stable_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Check if this is a new entity (not in observer's knowledge graph)
|
||||
if let Some(stable_id) = registry.to_stable(entity) {
|
||||
if !observer_kg.knows_entity(&stable_id) {
|
||||
// Reconstruct tile position from render coords
|
||||
let tile_pos = TilePosition::from_render_coords(visible.x, visible.y, visible.z);
|
||||
event_queue.push(ObservationEvent {
|
||||
tick: time.tick,
|
||||
trigger: ObservationTrigger::NewEntity {
|
||||
entity: stable_id,
|
||||
location: tile_pos,
|
||||
},
|
||||
observer: observer_entity,
|
||||
});
|
||||
}
|
||||
if !observer_kg.knows_entity(&stable_id) {
|
||||
// Reconstruct tile position from render coords
|
||||
let tile_pos = TilePosition::from_render_coords(visible.x, visible.y, visible.z);
|
||||
event_queue.push(ObservationEvent {
|
||||
tick: time.tick,
|
||||
trigger: ObservationTrigger::NewEntity {
|
||||
entity: stable_id,
|
||||
location: tile_pos,
|
||||
},
|
||||
observer: observer_entity,
|
||||
});
|
||||
}
|
||||
|
||||
// Check routine deviation: visible NPC not at expected location
|
||||
if let Ok((actual_pos, routine)) = npc_query.get(entity) {
|
||||
if let Some(expected_pos) = routine.expected_location(current_phase) {
|
||||
if *actual_pos != expected_pos {
|
||||
if let Some(stable_id) = registry.to_stable(entity) {
|
||||
event_queue.push(ObservationEvent {
|
||||
tick: time.tick,
|
||||
trigger: ObservationTrigger::RoutineDeviation {
|
||||
npc: stable_id,
|
||||
expected: expected_pos,
|
||||
actual: *actual_pos,
|
||||
},
|
||||
observer: observer_entity,
|
||||
});
|
||||
}
|
||||
event_queue.push(ObservationEvent {
|
||||
tick: time.tick,
|
||||
trigger: ObservationTrigger::RoutineDeviation {
|
||||
npc: stable_id,
|
||||
expected: expected_pos,
|
||||
actual: *actual_pos,
|
||||
},
|
||||
observer: observer_entity,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,8 +157,8 @@ pub fn generate_observation_events(
|
||||
continue;
|
||||
};
|
||||
|
||||
// Skip if currently visible
|
||||
if visible_npc_bits.contains(&entity.to_bits()) {
|
||||
// Skip if currently visible (visible_npc_bits contains wire StableId values)
|
||||
if visible_npc_bits.contains(&stable_id.0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ pub fn emit_observation_events(
|
||||
return;
|
||||
};
|
||||
|
||||
// Collect visible entity IDs (Vec — linear search is faster at 5-20 entities)
|
||||
let visible_entity_ids: Vec<u64> = snapshot
|
||||
// Collect visible stable IDs (wire entity_id is now StableId, not Entity::to_bits())
|
||||
let visible_stable_ids: Vec<u64> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| !matches!(e.kind, EntityKind::Player))
|
||||
@@ -47,8 +47,11 @@ pub fn emit_observation_events(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look up the bevy Entity from the entity_id (which is Entity::to_bits())
|
||||
let entity = Entity::from_bits(visible.entity_id);
|
||||
// Convert wire StableId back to bevy Entity via registry
|
||||
let stable_id = crate::knowledge::types::StableId(visible.entity_id);
|
||||
let Some(entity) = registry.to_entity(&stable_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Get tile position for knowledge tracking
|
||||
if let Ok(pos) = entity_positions.get(entity) {
|
||||
@@ -69,10 +72,9 @@ pub fn emit_observation_events(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this entity is still visible in the current snapshot
|
||||
if let Some(entity) = registry.to_entity(stable_id) {
|
||||
let entity_bits = entity.to_bits();
|
||||
if !visible_entity_ids.contains(&entity_bits) {
|
||||
// Check if this entity's StableId is still visible in the current snapshot
|
||||
if !visible_stable_ids.contains(&stable_id.0) {
|
||||
if let Some(entity) = registry.to_entity(stable_id) {
|
||||
event_queue.push(KnowledgeEvent {
|
||||
observer: observer_entity,
|
||||
tick: time.tick,
|
||||
|
||||
@@ -23,7 +23,7 @@ pub fn compute_observer_snapshot(
|
||||
time: Res<SimulationTime>,
|
||||
walkability: Res<WalkabilityMap>,
|
||||
registry: Res<EntityRegistry>,
|
||||
interaction_buffer: Res<NearbyInteractionBuffer>,
|
||||
mut interaction_buffer: ResMut<NearbyInteractionBuffer>,
|
||||
observer_query: Query<(&TilePosition, Option<&Facing>, &KnowledgeGraph), With<PlayerCharacter>>,
|
||||
all_entities: Query<(
|
||||
Entity,
|
||||
@@ -115,9 +115,13 @@ pub fn compute_observer_snapshot(
|
||||
RelationshipState::Unknown
|
||||
};
|
||||
|
||||
visible_entity_bits.insert(entity.to_bits());
|
||||
let wire_id = registry
|
||||
.to_stable(entity)
|
||||
.map(|sid| sid.0)
|
||||
.unwrap_or_else(|| entity.to_bits());
|
||||
visible_entity_bits.insert(wire_id);
|
||||
entities.push(VisibleEntity {
|
||||
entity_id: entity.to_bits(),
|
||||
entity_id: wire_id,
|
||||
x: rx,
|
||||
y: ry,
|
||||
z: rz,
|
||||
@@ -129,65 +133,20 @@ pub fn compute_observer_snapshot(
|
||||
}
|
||||
|
||||
// Step 6: Add remembered entities from knowledge graph (#366)
|
||||
// Entities the observer knows about but can't currently see.
|
||||
for (stable_id, knowledge) in observer_kg.known_entities_iter() {
|
||||
// Skip if currently visible (already in the entity list)
|
||||
if let Some(entity) = registry.to_entity(stable_id) {
|
||||
if visible_entity_bits.contains(&entity.to_bits()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if no known position (never directly observed)
|
||||
let Some(position) = knowledge.last_known_position else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Skip if remembered position is on a different z-level than the observer
|
||||
if position.z != z {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if the remembered tile is currently visible — if the player
|
||||
// can see the tile and the entity isn't there, don't show a ghost.
|
||||
if visible_positions.contains(&(position.x, position.y)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Direct confidence means the entity should be in LOS — if it isn't,
|
||||
// that's a transient data inconsistency. Skip rather than show a ghost.
|
||||
if knowledge.confidence == KnowledgeConfidence::Direct {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (rx, ry, rz) = position.to_render_coords();
|
||||
let age_ticks = time.tick.saturating_sub(knowledge.last_observed_tick);
|
||||
let entity_id = registry
|
||||
.to_entity(stable_id)
|
||||
.map(|e| e.to_bits())
|
||||
.unwrap_or(stable_id.0);
|
||||
|
||||
entities.push(VisibleEntity {
|
||||
entity_id,
|
||||
x: rx,
|
||||
y: ry,
|
||||
z: rz,
|
||||
kind: EntityKind::Npc, // Remembered entities are NPCs (only NPCs are tracked)
|
||||
visibility: VisibilitySector::Forward, // Not meaningful for remembered entities
|
||||
relationship: knowledge.relationship,
|
||||
observation: EntityVisibility::Remembered {
|
||||
confidence: knowledge.confidence,
|
||||
age_ticks,
|
||||
},
|
||||
});
|
||||
}
|
||||
collect_remembered_entities(
|
||||
observer_kg,
|
||||
&visible_entity_bits,
|
||||
&visible_positions,
|
||||
z,
|
||||
time.tick,
|
||||
&mut entities,
|
||||
);
|
||||
|
||||
// Step 7: Build GameTime from SimulationTime
|
||||
let game_time = GameTime {
|
||||
day: time.day(),
|
||||
time_of_day: time.time_of_day_minutes(),
|
||||
day_phase: time.day_phase(),
|
||||
paused: time.paused(),
|
||||
tick_rate: time.tick_rate,
|
||||
};
|
||||
|
||||
@@ -207,10 +166,70 @@ pub fn compute_observer_snapshot(
|
||||
player_facing: facing,
|
||||
entities,
|
||||
visible_tiles,
|
||||
nearby_interactions: interaction_buffer.interactions.clone(),
|
||||
nearby_interactions: interaction_buffer.take(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Collect remembered entities from the knowledge graph — entities the observer
|
||||
/// knows about but can't currently see. Filters out: already-visible entities,
|
||||
/// entities without known positions, wrong z-level, visible-tile ghosts, and
|
||||
/// transient Direct-confidence inconsistencies.
|
||||
fn collect_remembered_entities(
|
||||
observer_kg: &KnowledgeGraph,
|
||||
visible_ids: &HashSet<u64>,
|
||||
visible_positions: &HashSet<(i32, i32)>,
|
||||
observer_z: i32,
|
||||
current_tick: u64,
|
||||
entities: &mut Vec<VisibleEntity>,
|
||||
) {
|
||||
for (stable_id, knowledge) in observer_kg.known_entities_iter() {
|
||||
if visible_ids.contains(&stable_id.0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(position) = knowledge.last_known_position else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if position.z != observer_z {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tile is visible but entity isn't there — player knows it moved
|
||||
if visible_positions.contains(&(position.x, position.y)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Direct confidence = should be in LOS; skip transient inconsistency
|
||||
if knowledge.confidence == KnowledgeConfidence::Direct {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (rx, ry, rz) = position.to_render_coords();
|
||||
debug_assert!(
|
||||
knowledge.last_observed_tick <= current_tick,
|
||||
"last_observed_tick {} > current tick {}",
|
||||
knowledge.last_observed_tick,
|
||||
current_tick,
|
||||
);
|
||||
let age_ticks = current_tick.saturating_sub(knowledge.last_observed_tick);
|
||||
|
||||
entities.push(VisibleEntity {
|
||||
entity_id: stable_id.0,
|
||||
x: rx,
|
||||
y: ry,
|
||||
z: rz,
|
||||
kind: EntityKind::Npc,
|
||||
visibility: VisibilitySector::Forward,
|
||||
relationship: knowledge.relationship,
|
||||
observation: EntityVisibility::Remembered {
|
||||
confidence: knowledge.confidence,
|
||||
age_ticks,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -386,7 +405,7 @@ mod tests {
|
||||
snapshot.game_time.day_phase,
|
||||
crate::simulation::time::DayPhase::Evening
|
||||
);
|
||||
assert!(snapshot.game_time.paused);
|
||||
assert_eq!(snapshot.game_time.tick_rate, crate::simulation::time::TickRate::Paused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user