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
+12 -13
View File
@@ -102,17 +102,16 @@ fn apply_move(
dx: i32,
dy: i32,
) {
if let Ok((entity, pos)) = player_query.single() {
commands.entity(entity).insert(MoveIntent {
target: TilePosition::new(pos.x + dx, pos.y + dy, pos.z),
});
// Update facing direction based on movement (D-015 vision cone)
commands
.entity(entity)
.insert(Facing(facing_from_delta(dx, dy)));
} else {
tracing::warn!("No player entity found for movement input");
}
let (entity, pos) = player_query
.single()
.expect("PlayerCharacter entity must exist when processing input");
commands.entity(entity).insert(MoveIntent {
target: TilePosition::new(pos.x + dx, pos.y + dy, pos.z),
});
// Update facing direction based on movement (D-015 vision cone)
commands
.entity(entity)
.insert(Facing(facing_from_delta(dx, dy)));
}
#[cfg(test)]
@@ -220,7 +219,8 @@ mod tests {
}
#[test]
fn process_input_no_player_no_panic() {
#[should_panic(expected = "PlayerCharacter entity must exist")]
fn process_input_no_player_panics() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
@@ -232,7 +232,6 @@ mod tests {
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
// Should not panic
schedule.run(&mut world);
}
+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);
}
}
+20
View File
@@ -92,6 +92,11 @@ impl SimulationTime {
/// Advance the simulation tick based on current tick rate.
/// Full: +1 every frame. Half: +1 every 2 frames. Paused: no advance.
///
/// Uses fractional accumulation: each frame adds `tick_rate.scale()` to an
/// internal accumulator. When it reaches 1.0, a tick fires and the accumulator
/// subtracts 1.0. This ensures Half rate produces exactly N/2 ticks over N
/// frames with no floating-point drift (0.5 is exactly representable in f32).
pub fn advance_tick(mut time: ResMut<SimulationTime>) {
let scale = time.tick_rate.scale();
if scale <= 0.0 {
@@ -193,4 +198,19 @@ mod tests {
let time = SimulationTime { tick: 3 * MINUTES_PER_DAY * TICKS_PER_GAME_MINUTE + 100, ..Default::default() };
assert_eq!(time.day(), 3);
}
#[test]
fn half_rate_no_drift_over_10000_frames() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(SimulationTime { tick_rate: TickRate::Half, ..Default::default() });
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(advance_tick);
for _ in 0..10_000 {
schedule.run(&mut world);
}
// 10,000 frames at Half rate (0.5) should yield exactly 5,000 ticks
assert_eq!(world.resource::<SimulationTime>().tick, 5_000);
}
}