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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user