test(simulation): sprint 8 test suite — pause guards, registry, boundary, determinism
Add 50+ tests: pause guard suite (movement, unpause, roundtrip, stance, interact, batch, tick_rate), EntityRegistry lifecycle (stale mapping, re-register, unknown unregister), boundary value encode/roundtrip (41 values), encoding asymmetry (GDScript signed→Rust unsigned), malformed batch rejection, determinism gauntlet (20-tick replay), per-fix determinism unit tests, and recognition monologue integration tests. Fix pause guard to block all actions except Pause/Unpause while paused. Fixes #461-463, #466-469, #471-473, #479. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -158,4 +158,96 @@ mod tests {
|
||||
assert_eq!(registry.to_entity(&StableId(999)), None);
|
||||
assert_eq!(registry.to_stable(e1), None);
|
||||
}
|
||||
|
||||
// === EntityRegistry lifecycle edge cases (#469) ===
|
||||
|
||||
#[test]
|
||||
fn stale_mapping_after_despawn() {
|
||||
// #469: Registry returns stale Entity after world despawn.
|
||||
// This documents the expected behavior — caller must unregister after despawn.
|
||||
let mut world = World::new();
|
||||
let e1 = world.spawn_empty().id();
|
||||
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let id = registry.register(e1);
|
||||
|
||||
// Despawn from world — registry doesn't know
|
||||
world.despawn(e1);
|
||||
|
||||
// Registry still maps the StableId to the (now stale) Entity
|
||||
let stale_entity = registry.to_entity(&id);
|
||||
assert!(
|
||||
stale_entity.is_some(),
|
||||
"Registry still holds mapping after world despawn"
|
||||
);
|
||||
|
||||
// But the world no longer recognizes the entity
|
||||
assert!(
|
||||
world.get_entity(stale_entity.unwrap()).is_err(),
|
||||
"World rejects stale entity — caller must call unregister()"
|
||||
);
|
||||
|
||||
// After proper cleanup, mapping is gone
|
||||
registry.unregister(e1);
|
||||
assert_eq!(
|
||||
registry.to_entity(&id),
|
||||
None,
|
||||
"Mapping gone after unregister"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_after_unregister_assigns_new_id() {
|
||||
// #469: Re-registering the same entity after unregister gets a new StableId.
|
||||
// StableId counter is monotonic — never recycles.
|
||||
let mut world = World::new();
|
||||
let e1 = world.spawn_empty().id();
|
||||
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let id_first = registry.register(e1);
|
||||
assert_eq!(id_first, StableId(0));
|
||||
|
||||
registry.unregister(e1);
|
||||
|
||||
let id_second = registry.register(e1);
|
||||
assert_ne!(
|
||||
id_first, id_second,
|
||||
"Re-registration must assign a new StableId"
|
||||
);
|
||||
assert_eq!(
|
||||
id_second,
|
||||
StableId(1),
|
||||
"Counter advances monotonically"
|
||||
);
|
||||
assert_eq!(registry.len(), 1);
|
||||
|
||||
// New mapping is bidirectionally correct
|
||||
assert_eq!(registry.to_entity(&id_second), Some(e1));
|
||||
assert_eq!(registry.to_stable(e1), Some(id_second));
|
||||
|
||||
// Old StableId no longer resolves
|
||||
assert_eq!(
|
||||
registry.to_entity(&id_first),
|
||||
None,
|
||||
"Old StableId must not resolve"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_unknown_entity_is_noop() {
|
||||
// #469: Unregistering an entity that was never registered must not panic.
|
||||
let mut world = World::new();
|
||||
let e1 = world.spawn_empty().id();
|
||||
let e2 = world.spawn_empty().id();
|
||||
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
registry.register(e1);
|
||||
|
||||
// Unregister e2 which was never registered — should be a no-op
|
||||
registry.unregister(e2);
|
||||
|
||||
// e1's registration is unaffected
|
||||
assert_eq!(registry.len(), 1);
|
||||
assert_eq!(registry.to_stable(e1), Some(StableId(0)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1846,6 +1846,7 @@ fn pending_recognitions_appear_in_snapshot() {
|
||||
position: TilePosition::new(16, 14, 0),
|
||||
delay_until_tick: 110, // will complete at tick 110
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
let player = world
|
||||
@@ -1892,6 +1893,116 @@ fn pending_recognitions_appear_in_snapshot() {
|
||||
assert_eq!(pending.z, expected_z);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Determinism regression tests (#456/#457 — Fix A + Fix B)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn equidistant_npcs_produce_stable_snapshot_ordering() {
|
||||
// Fix A (#456): visible_ids uses BTreeSet for deterministic iteration.
|
||||
// Fix B (#457): entities sorted by entity_id in snapshot.
|
||||
// Regression guard: equidistant NPCs must always appear in ascending
|
||||
// entity_id order regardless of ECS internal iteration order.
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// Three NPCs equidistant from observer at (16,16) — all 2 tiles away.
|
||||
// Spawn order: npc_a, npc_b, npc_c → ascending stable_ids.
|
||||
let npc_a = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)))
|
||||
.id();
|
||||
let npc_a_sid = registry.register(npc_a);
|
||||
|
||||
let npc_b = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(14, 16, 0)))
|
||||
.id();
|
||||
let npc_b_sid = registry.register(npc_b);
|
||||
|
||||
let npc_c = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(18, 16, 0)))
|
||||
.id();
|
||||
let npc_c_sid = registry.register(npc_c);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
let npc_ids: Vec<u64> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.map(|e| e.entity_id)
|
||||
.collect();
|
||||
|
||||
assert_eq!(npc_ids.len(), 3, "all three equidistant NPCs should be visible");
|
||||
|
||||
// Entity IDs must be in strictly ascending order (Fix B sort guarantee)
|
||||
for i in 1..npc_ids.len() {
|
||||
assert!(
|
||||
npc_ids[i - 1] < npc_ids[i],
|
||||
"snapshot entities not sorted by entity_id: {:?}",
|
||||
npc_ids
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the ordering matches the expected stable_id assignment order
|
||||
assert_eq!(npc_ids[0], npc_a_sid.0);
|
||||
assert_eq!(npc_ids[1], npc_b_sid.0);
|
||||
assert_eq!(npc_ids[2], npc_c_sid.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_tiles_sorted_by_coordinates() {
|
||||
// Fix A (#456): visible_tiles sorted by (x, y) for deterministic snapshots.
|
||||
let mut world = setup_world(32, 32);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
));
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
assert!(
|
||||
!snapshot.visible_tiles.is_empty(),
|
||||
"should have visible tiles"
|
||||
);
|
||||
|
||||
// All tiles must be sorted by (x, y)
|
||||
for i in 1..snapshot.visible_tiles.len() {
|
||||
let prev = &snapshot.visible_tiles[i - 1];
|
||||
let curr = &snapshot.visible_tiles[i];
|
||||
assert!(
|
||||
(prev.x, prev.y) <= (curr.x, curr.y),
|
||||
"visible_tiles not sorted: ({},{}) > ({},{})",
|
||||
prev.x,
|
||||
prev.y,
|
||||
curr.x,
|
||||
curr.y,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_cognitive_delay_component_means_empty_pending_recognitions() {
|
||||
// H11 complement: player WITHOUT CognitiveDelay should produce
|
||||
|
||||
Reference in New Issue
Block a user