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:
2026-02-12 20:14:35 +01:00
co-authored by Claude Opus 4.6
parent 458be0f621
commit 9943684f2c
19 changed files with 232 additions and 136 deletions
+73 -9
View File
@@ -11,8 +11,8 @@ use crate::npc::Npc;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
/// Interaction range thresholds (Manhattan distance, same z-level)
pub const CLOSE_RANGE: u32 = 2;
pub const MID_RANGE: u32 = 5;
pub(crate) const CLOSE_RANGE: u32 = 2;
pub(crate) const MID_RANGE: u32 = 5;
/// Component marking an entity as having available interactions.
/// Attached to NPCs and examinable objects by the world setup or content loader.
@@ -124,13 +124,18 @@ pub fn compute_nearby_interactions(
continue;
}
// Sort by priority (lower number = higher priority)
verbs.sort_by_key(|v| v.priority);
// Sort by priority (lower = higher), then by kind discriminant for stability
verbs.sort_by_key(|v| (v.priority, v.kind as u8));
let wire_id = registry
.to_stable(entity)
.map(|sid| sid.0)
.unwrap_or_else(|| entity.to_bits());
buffer.interactions.push(NearbyInteraction {
entity_id: entity.to_bits(),
entity_id: wire_id,
entity_type,
distance: distance as f32,
distance,
verbs,
});
}
@@ -138,13 +143,22 @@ pub fn compute_nearby_interactions(
// Sort interactions by distance (nearest first)
buffer
.interactions
.sort_by(|a, b| a.distance.partial_cmp(&b.distance).unwrap());
.sort_by_key(|a| a.distance);
}
/// Buffer for nearby interaction results, consumed by snapshot generation
/// Buffer for nearby interaction results, consumed by snapshot generation.
/// Field is private — use `take()` to drain results into the snapshot.
#[derive(Resource, Debug, Default)]
pub struct NearbyInteractionBuffer {
pub interactions: Vec<NearbyInteraction>,
interactions: Vec<NearbyInteraction>,
}
impl NearbyInteractionBuffer {
/// Drain and return interactions, leaving the buffer empty.
/// Avoids cloning per-frame; snapshot owns the Vec after take.
pub fn take(&mut self) -> Vec<NearbyInteraction> {
std::mem::take(&mut self.interactions)
}
}
#[cfg(test)]
@@ -329,4 +343,54 @@ mod tests {
let buffer = world.resource::<NearbyInteractionBuffer>();
assert!(buffer.interactions.is_empty());
}
#[test]
fn poi_npc_at_mid_range_gets_observe_only() {
// POI priority flip only applies at close range — mid range always Observe-only
let mut world = setup_world();
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((Npc, TilePosition::new(5, 9, 0), Interactable))
.id();
let npc_sid = registry.register(npc);
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(5, 9, 0), 50);
kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest);
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0), kg));
world.insert_resource(registry);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = world.resource::<NearbyInteractionBuffer>();
assert_eq!(buffer.interactions.len(), 1);
assert_eq!(buffer.interactions[0].verbs.len(), 1);
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineNpc);
}
#[test]
fn equidistant_npcs_sorted_deterministically() {
let mut world = setup_world();
// Two NPCs at equal distance (1 tile each)
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
KnowledgeGraph::new(),
));
world.spawn((Npc, TilePosition::new(6, 5, 0), Interactable));
world.spawn((Npc, TilePosition::new(4, 5, 0), Interactable));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = world.resource::<NearbyInteractionBuffer>();
assert_eq!(buffer.interactions.len(), 2);
// Both at distance 1 — order should be stable across runs
assert_eq!(buffer.interactions[0].distance, buffer.interactions[1].distance);
}
}