fix(simulation): determinism fixes — BTreeSet ordering, entity sort, mover sort
Replace HashSet with BTreeSet for visible_ids, sort visible_tiles by coordinates, sort visible entities in snapshot by entity_id, and sort movers by Entity bits in validate_movement. Required by D-010 principle 4 (deterministic simulation). Fixes #456, #457, #458. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -285,12 +285,19 @@ pub fn validate_movement(
|
||||
occupied.insert((*pos, layer), entity);
|
||||
}
|
||||
|
||||
for (entity, intent, mut position, presence) in movers.iter_mut() {
|
||||
let target = &intent.target;
|
||||
let layer = presence.copied().unwrap_or_default();
|
||||
let slot = (*target, layer);
|
||||
// Sort movers by Entity::to_bits() for deterministic collision resolution (#458)
|
||||
let mut mover_entities: Vec<Entity> = movers.iter().map(|(e, _, _, _)| e).collect();
|
||||
mover_entities.sort_by_key(|e| e.to_bits());
|
||||
|
||||
if !map.can_move_to(target) {
|
||||
for entity in mover_entities {
|
||||
let Ok((_, intent, mut position, presence)) = movers.get_mut(entity) else {
|
||||
continue;
|
||||
};
|
||||
let target = intent.target;
|
||||
let layer = presence.copied().unwrap_or_default();
|
||||
let slot = (target, layer);
|
||||
|
||||
if !map.can_move_to(&target) {
|
||||
tracing::trace!("Entity {:?} blocked by terrain at {:?}", entity, target);
|
||||
} else if occupied.contains_key(&slot) {
|
||||
tracing::trace!(
|
||||
@@ -309,7 +316,7 @@ pub fn validate_movement(
|
||||
);
|
||||
// Free old layer slot, claim new one
|
||||
occupied.remove(&(*position, layer));
|
||||
*position = *target;
|
||||
*position = target;
|
||||
occupied.insert(slot, entity);
|
||||
}
|
||||
commands.entity(entity).remove::<MoveIntent>();
|
||||
@@ -868,6 +875,59 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Determinism regression test (#458 — Fix D)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn same_tile_movers_resolve_by_entity_bits() {
|
||||
// Fix D (#458): movers sorted by Entity::to_bits() before collision
|
||||
// resolution. The entity with the lower bits value processes first
|
||||
// and wins the tile. This prevents non-deterministic outcomes from
|
||||
// ECS iteration order.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(WalkabilityMap::new(10, 10, 1));
|
||||
|
||||
let target = TilePosition::new(5, 5, 0);
|
||||
let origin_a = TilePosition::new(5, 4, 0);
|
||||
let origin_b = TilePosition::new(5, 6, 0);
|
||||
|
||||
let entity_a = world
|
||||
.spawn((TilePosition::new(5, 4, 0), MoveIntent { target }))
|
||||
.id();
|
||||
|
||||
let entity_b = world
|
||||
.spawn((TilePosition::new(5, 6, 0), MoveIntent { target }))
|
||||
.id();
|
||||
|
||||
// Determine which entity has lower bits (not guaranteed by spawn order)
|
||||
let (lower, higher, _lower_origin, higher_origin) =
|
||||
if entity_a.to_bits() < entity_b.to_bits() {
|
||||
(entity_a, entity_b, origin_a, origin_b)
|
||||
} else {
|
||||
(entity_b, entity_a, origin_b, origin_a)
|
||||
};
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(validate_movement);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let pos_lower = *world.get::<TilePosition>(lower).unwrap();
|
||||
let pos_higher = *world.get::<TilePosition>(higher).unwrap();
|
||||
|
||||
// Entity with lower bits processes first and claims the target
|
||||
assert_eq!(
|
||||
pos_lower, target,
|
||||
"entity with lower Entity::to_bits() ({}) should win the tile",
|
||||
lower.to_bits()
|
||||
);
|
||||
assert_eq!(
|
||||
pos_higher, higher_origin,
|
||||
"entity with higher Entity::to_bits() ({}) should stay at origin",
|
||||
higher.to_bits()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_four_layers_coexist_on_same_tile() {
|
||||
// D-054: Standing + Prone + Seated + Fixture all share one tile
|
||||
|
||||
Reference in New Issue
Block a user