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
|
||||
|
||||
@@ -58,6 +58,7 @@ fn snapshot_roundtrip_over_unix_socket() {
|
||||
nearby_interactions: vec![],
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
};
|
||||
|
||||
bridge
|
||||
|
||||
@@ -44,6 +44,7 @@ fn snapshot_roundtrip_over_tcp() {
|
||||
nearby_interactions: vec![],
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
};
|
||||
|
||||
bridge
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
//! Determinism regression test (#466)
|
||||
//!
|
||||
//! Master guard for D-010 principle 4: given the same seed and input sequence,
|
||||
//! the simulation must produce byte-identical snapshots across runs.
|
||||
//!
|
||||
//! Exercises all three determinism fixes:
|
||||
//! - Fix A (#456): BTreeSet for visible_ids + sorted visible_tiles
|
||||
//! - Fix B (#457): Entities sorted by entity_id in snapshot
|
||||
//! - Fix D (#458): Movers sorted by Entity::to_bits() in collision resolution
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::bridge::{BridgePlugin, SnapshotBuffer};
|
||||
use settled_reach_server::knowledge::registry::EntityRegistry;
|
||||
use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin};
|
||||
use settled_reach_server::npc::relationships::{RelationshipEdge, RelationshipGraph};
|
||||
use settled_reach_server::npc::{
|
||||
Contentment, DailyRoutine, Npc, NpcPlugin, RelationshipKind, RoutineEntry,
|
||||
ToleranceThreshold, Want, WantKind,
|
||||
};
|
||||
use settled_reach_server::perception::cognitive_delay::CognitiveDelay;
|
||||
use settled_reach_server::perception::vision_cone::Facing;
|
||||
use settled_reach_server::simulation::interaction::{Interactable, NearbyInteractionBuffer};
|
||||
use settled_reach_server::simulation::listening::ListeningFocus;
|
||||
use settled_reach_server::simulation::monologue::{
|
||||
MonologueBuffer, MonologueState, SprintAnomalyQueue,
|
||||
};
|
||||
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use settled_reach_server::simulation::path_follow::MovementSpeed;
|
||||
use settled_reach_server::simulation::rng::SimRng;
|
||||
use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown};
|
||||
use settled_reach_server::simulation::time::DayPhase;
|
||||
use settled_reach_server::simulation::SimulationPlugin;
|
||||
|
||||
/// Build a fully-initialized simulation app with the proof room.
|
||||
/// No BridgeResource — bridge systems become no-ops.
|
||||
/// Snapshots are written to SnapshotBuffer for direct inspection.
|
||||
fn build_deterministic_app(seed: u64) -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(SimulationPlugin);
|
||||
app.add_plugins(BridgePlugin);
|
||||
app.add_plugins(KnowledgePlugin);
|
||||
app.add_plugins(NpcPlugin);
|
||||
|
||||
// Override SimRng with deterministic seed
|
||||
app.insert_resource(SimRng::new(seed));
|
||||
|
||||
// --- Proof room setup (mirrors main.rs setup_proof_room) ---
|
||||
app.insert_resource(WalkabilityMap::new(32, 32, 1));
|
||||
{
|
||||
let mut wm = app.world_mut().resource_mut::<WalkabilityMap>();
|
||||
wm.set_walkable(&TilePosition::new(16, 14, 0), false);
|
||||
}
|
||||
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// Player at (16,16)
|
||||
let profile = MovementProfile::smuggler();
|
||||
let player = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
SprintAnomalyQueue::default(),
|
||||
CognitiveDelay::default(),
|
||||
ListeningFocus::new(TilePosition::new(16, 16, 0)),
|
||||
profile,
|
||||
profile.initial_stance(),
|
||||
PlayerMoveCooldown::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
// NPC 1: Dock worker at (16,13) — behind wall, full routine
|
||||
let npc1 = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
Interactable,
|
||||
TilePosition::new(16, 13, 0),
|
||||
Want {
|
||||
primary: WantKind::Wealth,
|
||||
intensity: 6,
|
||||
description: "Wants a bigger share of docking fees".into(),
|
||||
},
|
||||
DailyRoutine {
|
||||
entries: vec![
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Morning,
|
||||
location: TilePosition::new(16, 13, 0),
|
||||
activity: "Prep cargo bay".into(),
|
||||
},
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: TilePosition::new(20, 10, 0),
|
||||
activity: "Unload freight".into(),
|
||||
},
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Evening,
|
||||
location: TilePosition::new(10, 20, 0),
|
||||
activity: "Drink at canteen".into(),
|
||||
},
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Night,
|
||||
location: TilePosition::new(16, 13, 0),
|
||||
activity: "Sleep in bunk".into(),
|
||||
},
|
||||
],
|
||||
description: "Dock worker shift pattern".into(),
|
||||
},
|
||||
Contentment { level: 20 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 30,
|
||||
threshold: 70,
|
||||
},
|
||||
MovementSpeed::new(2),
|
||||
))
|
||||
.id();
|
||||
let npc1_sid = registry.register(npc1);
|
||||
|
||||
// NPC 2: Field tech at (14,18) — visible to player, has routine
|
||||
let npc2 = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
Interactable,
|
||||
TilePosition::new(14, 18, 0),
|
||||
Want {
|
||||
primary: WantKind::Knowledge,
|
||||
intensity: 8,
|
||||
description: "Obsessed with pre-Collapse sensor arrays".into(),
|
||||
},
|
||||
DailyRoutine {
|
||||
entries: vec![
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Morning,
|
||||
location: TilePosition::new(14, 18, 0),
|
||||
activity: "Calibrate instruments".into(),
|
||||
},
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: TilePosition::new(22, 22, 0),
|
||||
activity: "Field survey".into(),
|
||||
},
|
||||
],
|
||||
description: "Field tech survey pattern".into(),
|
||||
},
|
||||
Contentment { level: 45 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 10,
|
||||
threshold: 60,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
))
|
||||
.id();
|
||||
let npc2_sid = registry.register(npc2);
|
||||
|
||||
// NPC 3: Guard at (18,14) — stationary, no routine
|
||||
let npc3 = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
Interactable,
|
||||
TilePosition::new(18, 14, 0),
|
||||
Want {
|
||||
primary: WantKind::Safety,
|
||||
intensity: 4,
|
||||
description: "Wants a quiet shift".into(),
|
||||
},
|
||||
Contentment { level: -5 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 45,
|
||||
threshold: 55,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
let npc3_sid = registry.register(npc3);
|
||||
|
||||
// Relationships
|
||||
{
|
||||
let mut rel_graph = app.world_mut().resource_mut::<RelationshipGraph>();
|
||||
rel_graph.set_relationship(
|
||||
npc1_sid,
|
||||
npc3_sid,
|
||||
RelationshipEdge {
|
||||
kind: RelationshipKind::Colleague,
|
||||
trust: 3,
|
||||
history: vec![],
|
||||
last_interaction_tick: 0,
|
||||
},
|
||||
);
|
||||
rel_graph.set_relationship(
|
||||
npc3_sid,
|
||||
npc2_sid,
|
||||
RelationshipEdge {
|
||||
kind: RelationshipKind::Rival,
|
||||
trust: -4,
|
||||
history: vec![],
|
||||
last_interaction_tick: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
app.insert_resource(registry);
|
||||
app
|
||||
}
|
||||
|
||||
/// Run the simulation for a fixed number of ticks with predetermined inputs.
|
||||
/// Returns serialized snapshots for each tick.
|
||||
fn run_simulation(
|
||||
seed: u64,
|
||||
inputs: &[Vec<PlayerInput>],
|
||||
) -> Vec<Vec<u8>> {
|
||||
let mut app = build_deterministic_app(seed);
|
||||
let mut snapshots = Vec::with_capacity(inputs.len());
|
||||
|
||||
for tick_inputs in inputs {
|
||||
// Push inputs into the queue before the tick runs
|
||||
{
|
||||
let mut queue = app
|
||||
.world_mut()
|
||||
.resource_mut::<settled_reach_server::simulation::input::InputQueue>();
|
||||
for input in tick_inputs {
|
||||
queue.push(input.clone());
|
||||
}
|
||||
}
|
||||
|
||||
app.update();
|
||||
|
||||
// Read snapshot from buffer (send_bridge_snapshot is a no-op without BridgeResource)
|
||||
let buffer = app.world().resource::<SnapshotBuffer>();
|
||||
if let Some(snapshot) = &buffer.snapshot {
|
||||
let bytes = rmp_serde::to_vec_named(snapshot).expect("serialize snapshot");
|
||||
snapshots.push(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
snapshots
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gauntlet_deterministic_replay() {
|
||||
// D-010 principle 4: same seed + same inputs → byte-identical snapshots.
|
||||
//
|
||||
// Input sequence exercises:
|
||||
// - Idle ticks (baseline determinism)
|
||||
// - Player movement in cardinal directions (movement validation, visibility changes)
|
||||
// - Stance changes (movement profile system)
|
||||
// - Pause/unpause (time control determinism)
|
||||
let inputs: Vec<Vec<PlayerInput>> = vec![
|
||||
// Tick 0: idle — establishes baseline snapshot
|
||||
vec![],
|
||||
// Tick 1: move north — player enters NPC 2's vicinity, changes visibility set
|
||||
vec![PlayerInput {
|
||||
tick: 1,
|
||||
action: PlayerAction::MoveNorth,
|
||||
}],
|
||||
// Tick 2: idle — NPC routines may generate pathfinding
|
||||
vec![],
|
||||
// Tick 3: move east — tests different movement direction
|
||||
vec![PlayerInput {
|
||||
tick: 3,
|
||||
action: PlayerAction::MoveEast,
|
||||
}],
|
||||
// Tick 4: idle
|
||||
vec![],
|
||||
// Tick 5: move north again — approaching wall at (16,14)
|
||||
vec![PlayerInput {
|
||||
tick: 5,
|
||||
action: PlayerAction::MoveNorth,
|
||||
}],
|
||||
// Tick 6: stance toggle — changes movement profile
|
||||
vec![PlayerInput {
|
||||
tick: 6,
|
||||
action: PlayerAction::ToggleStanceUp,
|
||||
}],
|
||||
// Tick 7: move north — sprint speed if stance changed
|
||||
vec![PlayerInput {
|
||||
tick: 7,
|
||||
action: PlayerAction::MoveNorth,
|
||||
}],
|
||||
// Tick 8: pause
|
||||
vec![PlayerInput {
|
||||
tick: 8,
|
||||
action: PlayerAction::Pause,
|
||||
}],
|
||||
// Tick 9: movement while paused — should be discarded
|
||||
vec![PlayerInput {
|
||||
tick: 9,
|
||||
action: PlayerAction::MoveNorth,
|
||||
}],
|
||||
// Tick 10: unpause
|
||||
vec![PlayerInput {
|
||||
tick: 10,
|
||||
action: PlayerAction::Unpause,
|
||||
}],
|
||||
// Tick 11: move west — tests westward visibility
|
||||
vec![PlayerInput {
|
||||
tick: 11,
|
||||
action: PlayerAction::MoveWest,
|
||||
}],
|
||||
// Tick 12: move south — reverses direction
|
||||
vec![PlayerInput {
|
||||
tick: 12,
|
||||
action: PlayerAction::MoveSouth,
|
||||
}],
|
||||
// Ticks 13-19: idle ticks to let NPC routines/pathfinding progress
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
];
|
||||
|
||||
let seed = 42;
|
||||
let run1 = run_simulation(seed, &inputs);
|
||||
let run2 = run_simulation(seed, &inputs);
|
||||
|
||||
assert_eq!(
|
||||
run1.len(),
|
||||
run2.len(),
|
||||
"different number of snapshots: run1={}, run2={}",
|
||||
run1.len(),
|
||||
run2.len()
|
||||
);
|
||||
|
||||
for (tick, (s1, s2)) in run1.iter().zip(run2.iter()).enumerate() {
|
||||
assert_eq!(
|
||||
s1, s2,
|
||||
"snapshot at tick {} differs between runs ({} vs {} bytes)",
|
||||
tick,
|
||||
s1.len(),
|
||||
s2.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Note: a `different_seed_produces_different_replay` test is deferred until
|
||||
// the monologue/dialogue systems consume SimRng during the test window.
|
||||
// Currently the proof room with idle inputs doesn't trigger random events,
|
||||
// so different seeds produce identical outputs (correct but untestable).
|
||||
@@ -34,6 +34,7 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
|
||||
nearby_interactions: vec![],
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +203,7 @@ fn generate_msgpack_fixtures() {
|
||||
nearby_interactions: vec![],
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
};
|
||||
write_fixture(
|
||||
"snapshot_v2_full",
|
||||
@@ -237,4 +239,55 @@ fn generate_msgpack_fixtures() {
|
||||
let input = PlayerInput { tick: 100, action };
|
||||
write_fixture(name, &rmp_serde::to_vec_named(&input).unwrap());
|
||||
}
|
||||
|
||||
// === Boundary value fixtures (#472) ===
|
||||
// 14 raw integer values at encoding format boundaries (Appendix C).
|
||||
// These are Rust-encoded MessagePack that GDScript must decode correctly.
|
||||
// Covers every encoding format transition and the int16/int32 asymmetry zones.
|
||||
|
||||
let boundary_raw: [(u64, &str); 14] = [
|
||||
// pos fixint boundaries
|
||||
(0, "boundary_raw_0"),
|
||||
(127, "boundary_raw_127"),
|
||||
// uint 8 boundaries
|
||||
(128, "boundary_raw_128"),
|
||||
(255, "boundary_raw_255"),
|
||||
// int16/uint16 asymmetry zone (GDScript: int_16, Rust: uint_16)
|
||||
(256, "boundary_raw_256"),
|
||||
(32767, "boundary_raw_32767"),
|
||||
// uint 16 boundaries
|
||||
(32768, "boundary_raw_32768"),
|
||||
(65535, "boundary_raw_65535"),
|
||||
// int32/uint32 asymmetry zone (GDScript: int_32, Rust: uint_32)
|
||||
(65536, "boundary_raw_65536"),
|
||||
(2147483647, "boundary_raw_2147483647"),
|
||||
// uint 32 boundaries
|
||||
(2147483648, "boundary_raw_2147483648"),
|
||||
(4294967295, "boundary_raw_4294967295"),
|
||||
// int 64 boundaries
|
||||
(4294967296, "boundary_raw_4294967296"),
|
||||
(u64::MAX >> 1, "boundary_raw_i64_max"), // 2^63-1 = i64::MAX
|
||||
];
|
||||
|
||||
for (value, name) in &boundary_raw {
|
||||
// Encode as u64 (matches how entity_id/tick are encoded in snapshots)
|
||||
let bytes = rmp_serde::to_vec(value).expect("encode boundary value");
|
||||
write_fixture(name, &bytes);
|
||||
}
|
||||
|
||||
// 5 snapshot fixtures at boundary tick values.
|
||||
// Tests that GDScript can decode full ObserverSnapshot structs when the tick
|
||||
// field crosses encoding format boundaries.
|
||||
let boundary_snapshots: [(u64, &str); 5] = [
|
||||
(0, "snapshot_boundary_tick_0"), // pos fixint
|
||||
(127, "snapshot_boundary_tick_127"), // pos fixint max
|
||||
(32767, "snapshot_boundary_tick_32767"), // int16/uint16 asymmetry
|
||||
(2147483647, "snapshot_boundary_tick_2b31m1"), // int32/uint32 asymmetry
|
||||
(4294967296, "snapshot_boundary_tick_2b32"), // int64 minimum
|
||||
];
|
||||
|
||||
for (tick, name) in &boundary_snapshots {
|
||||
let snapshot = fixture_snapshot(*tick, vec![]);
|
||||
write_fixture(name, &rmp_serde::to_vec_named(&snapshot).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
|
||||
nearby_interactions: vec![],
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +136,11 @@ fn all_fixtures_deserialize() {
|
||||
let name = path.file_stem().unwrap().to_str().unwrap().to_string();
|
||||
let bytes = fs::read(&path).unwrap_or_else(|_| panic!("read fixture {}", name));
|
||||
|
||||
if name.starts_with("snapshot") {
|
||||
if name.starts_with("snapshot_boundary") {
|
||||
// Boundary snapshot fixtures (#472): tick may exceed PROTOCOL_VERSION check
|
||||
rmp_serde::from_slice::<ObserverSnapshot>(&bytes)
|
||||
.unwrap_or_else(|e| panic!("deserialize boundary snapshot fixture {}: {}", name, e));
|
||||
} else if name.starts_with("snapshot") {
|
||||
let snap = rmp_serde::from_slice::<ObserverSnapshot>(&bytes)
|
||||
.unwrap_or_else(|e| panic!("deserialize snapshot fixture {}: {}", name, e));
|
||||
assert_eq!(
|
||||
@@ -149,6 +154,10 @@ fn all_fixtures_deserialize() {
|
||||
} else if name.starts_with("input") {
|
||||
rmp_serde::from_slice::<PlayerInput>(&bytes)
|
||||
.unwrap_or_else(|e| panic!("deserialize input fixture {}: {}", name, e));
|
||||
} else if name.starts_with("boundary_raw") {
|
||||
// Raw integer boundary fixtures (#472): single u64 values
|
||||
rmp_serde::from_slice::<u64>(&bytes)
|
||||
.unwrap_or_else(|e| panic!("deserialize boundary raw fixture {}: {}", name, e));
|
||||
} else {
|
||||
panic!("unknown fixture naming convention: {}", name);
|
||||
}
|
||||
@@ -234,6 +243,7 @@ fn snapshot_v2_fields_roundtrip() {
|
||||
nearby_interactions: vec![],
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
};
|
||||
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
@@ -288,7 +298,7 @@ fn protocol_version_constant_matches_snapshot() {
|
||||
let snapshot = test_snapshot(0, vec![]);
|
||||
assert_eq!(snapshot.version, PROTOCOL_VERSION);
|
||||
assert_eq!(
|
||||
PROTOCOL_VERSION, 7,
|
||||
PROTOCOL_VERSION, 8,
|
||||
"bump this assertion when protocol version changes"
|
||||
);
|
||||
}
|
||||
@@ -325,6 +335,7 @@ fn all_facing_direction_variants_roundtrip() {
|
||||
nearby_interactions: vec![],
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
@@ -666,6 +677,298 @@ fn nearby_interaction_contradicted_roundtrip() {
|
||||
);
|
||||
}
|
||||
|
||||
// === Boundary Value Tests (#471) ===
|
||||
// All 41 boundary values from Appendix C of workshop-outcomes.md.
|
||||
// Tests i64 MessagePack encode -> decode roundtrip at every encoding boundary.
|
||||
// Prevents Bug #4 class (MessagePack -128 encoding mismatch).
|
||||
|
||||
/// All 41 boundary values that exercise every MessagePack integer encoding format.
|
||||
/// Positive: pos fixint (0-127), uint 8 (128-255), int16/uint16 (256-65535),
|
||||
/// int32/uint32 (65536-2^32-1), int64 (2^32+).
|
||||
/// Negative: neg fixint (-1 to -32), int 8 (-33 to -128), int 16 (-129 to -32768),
|
||||
/// int 32 (-32769 to -2^31), int 64 (-2^31-1 to -2^63).
|
||||
const BOUNDARY_VALUES: [i64; 41] = [
|
||||
// Positive boundaries (25 values)
|
||||
0, 1, 126, 127, // pos fixint
|
||||
128, 129, 254, 255, // uint 8
|
||||
256, 257, 32766, 32767, // int 16 / uint 16 asymmetry
|
||||
32768, 32769, 65534, 65535, // uint 16
|
||||
65536, 65537, 2147483646, 2147483647, // int 32 / uint 32 asymmetry
|
||||
2147483648, 4294967294, 4294967295, // uint 32
|
||||
4294967296, i64::MAX, // int 64
|
||||
// Negative boundaries (16 values)
|
||||
-1, -31, -32, // neg fixint
|
||||
-33, -34, -127, -128, // int 8
|
||||
-129, -130, -32767, -32768, // int 16
|
||||
-32769, -2147483647, -2147483648, // int 32
|
||||
-2147483649, i64::MIN, // int 64
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn boundary_value_i64_roundtrip() {
|
||||
// #471: Each of the 41 boundary values must survive Rust encode -> decode.
|
||||
for &value in &BOUNDARY_VALUES {
|
||||
let bytes = rmp_serde::to_vec(&value)
|
||||
.unwrap_or_else(|e| panic!("encode i64 {} failed: {}", value, e));
|
||||
let decoded: i64 = rmp_serde::from_slice(&bytes)
|
||||
.unwrap_or_else(|e| panic!("decode i64 {} failed: {}", value, e));
|
||||
assert_eq!(decoded, value, "roundtrip mismatch for i64 {}", value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_value_u64_roundtrip() {
|
||||
// #471: Positive boundary values also roundtrip as u64.
|
||||
// This tests the unsigned path that entity_id/tick fields use.
|
||||
let positive_values: Vec<u64> = BOUNDARY_VALUES
|
||||
.iter()
|
||||
.filter(|&&v| v >= 0)
|
||||
.map(|&v| v as u64)
|
||||
.collect();
|
||||
|
||||
for &value in &positive_values {
|
||||
let bytes = rmp_serde::to_vec(&value)
|
||||
.unwrap_or_else(|e| panic!("encode u64 {} failed: {}", value, e));
|
||||
let decoded: u64 = rmp_serde::from_slice(&bytes)
|
||||
.unwrap_or_else(|e| panic!("decode u64 {} failed: {}", value, e));
|
||||
assert_eq!(decoded, value, "roundtrip mismatch for u64 {}", value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_value_in_snapshot_tick() {
|
||||
// #471: Boundary values survive when embedded in ObserverSnapshot.tick (u64 field).
|
||||
// This is the realistic scenario — values cross the wire inside real structs.
|
||||
let tick_values: Vec<u64> = BOUNDARY_VALUES
|
||||
.iter()
|
||||
.filter(|&&v| v >= 0)
|
||||
.map(|&v| v as u64)
|
||||
.collect();
|
||||
|
||||
for &tick_val in &tick_values {
|
||||
let snapshot = test_snapshot(tick_val, vec![]);
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot)
|
||||
.unwrap_or_else(|e| panic!("encode snapshot tick={} failed: {}", tick_val, e));
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
|
||||
.unwrap_or_else(|e| panic!("decode snapshot tick={} failed: {}", tick_val, e));
|
||||
assert_eq!(
|
||||
decoded.tick, tick_val,
|
||||
"tick roundtrip mismatch for {}",
|
||||
tick_val
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_value_in_entity_id() {
|
||||
// #471: Boundary values survive in VisibleEntity.entity_id (u64 field).
|
||||
let id_values: Vec<u64> = BOUNDARY_VALUES
|
||||
.iter()
|
||||
.filter(|&&v| v >= 0)
|
||||
.map(|&v| v as u64)
|
||||
.collect();
|
||||
|
||||
for &id_val in &id_values {
|
||||
let snapshot = test_snapshot(
|
||||
0,
|
||||
vec![VisibleEntity {
|
||||
entity_id: id_val,
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
z: 0,
|
||||
kind: EntityKind::Npc,
|
||||
visibility: VisibilitySector::Forward,
|
||||
relationship: RelationshipState::Unknown,
|
||||
observation: EntityVisibility::Visible,
|
||||
}],
|
||||
);
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot)
|
||||
.unwrap_or_else(|e| panic!("encode entity_id={} failed: {}", id_val, e));
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
|
||||
.unwrap_or_else(|e| panic!("decode entity_id={} failed: {}", id_val, e));
|
||||
assert_eq!(
|
||||
decoded.entities[0].entity_id, id_val,
|
||||
"entity_id roundtrip mismatch for {}",
|
||||
id_val
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_value_in_tile_position() {
|
||||
// #471: Boundary values that fit in i32 survive in VisibleTile.x/y (i32 fields).
|
||||
let tile_values: Vec<i32> = BOUNDARY_VALUES
|
||||
.iter()
|
||||
.filter(|&&v| v >= i32::MIN as i64 && v <= i32::MAX as i64)
|
||||
.map(|&v| v as i32)
|
||||
.collect();
|
||||
|
||||
for &tile_val in &tile_values {
|
||||
let mut snapshot = test_snapshot(0, vec![]);
|
||||
snapshot.visible_tiles = vec![VisibleTile {
|
||||
x: tile_val,
|
||||
y: tile_val,
|
||||
z: 0,
|
||||
visibility: VisibilitySector::Forward,
|
||||
tile_kind: TileKind::Floor,
|
||||
}];
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot)
|
||||
.unwrap_or_else(|e| panic!("encode tile x/y={} failed: {}", tile_val, e));
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
|
||||
.unwrap_or_else(|e| panic!("decode tile x/y={} failed: {}", tile_val, e));
|
||||
assert_eq!(
|
||||
decoded.visible_tiles[0].x, tile_val,
|
||||
"tile.x roundtrip mismatch for {}",
|
||||
tile_val
|
||||
);
|
||||
assert_eq!(
|
||||
decoded.visible_tiles[0].y, tile_val,
|
||||
"tile.y roundtrip mismatch for {}",
|
||||
tile_val
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// === Encoding Asymmetry Tests (#473) ===
|
||||
// GDScript encodes positive values 256-32767 as int_16 (signed 16-bit),
|
||||
// while Rust encodes them as uint_16 (unsigned 16-bit). Similarly for
|
||||
// 65536-2147483647: GDScript uses int_32, Rust uses uint_32.
|
||||
// Both encodings are valid MessagePack. These tests verify Rust's rmp_serde
|
||||
// accepts GDScript-style signed encodings when decoding u64 fields.
|
||||
|
||||
/// Hand-crafted GDScript-style int_16 encoding of 256 decodes as u64.
|
||||
/// MessagePack int_16 format: 0xd1 + 2 bytes big-endian signed.
|
||||
#[test]
|
||||
fn rust_decodes_gdscript_int16_256() {
|
||||
// GDScript encodes 256 as int_16: 0xd1, 0x01, 0x00
|
||||
let gdscript_bytes: Vec<u8> = vec![0xd1, 0x01, 0x00];
|
||||
let decoded: u64 = rmp_serde::from_slice(&gdscript_bytes)
|
||||
.expect("Rust must accept GDScript int_16(256) as u64");
|
||||
assert_eq!(decoded, 256);
|
||||
}
|
||||
|
||||
/// Hand-crafted GDScript-style int_16 encoding of 32767 decodes as u64.
|
||||
#[test]
|
||||
fn rust_decodes_gdscript_int16_32767() {
|
||||
// GDScript encodes 32767 as int_16: 0xd1, 0x7f, 0xff
|
||||
let gdscript_bytes: Vec<u8> = vec![0xd1, 0x7f, 0xff];
|
||||
let decoded: u64 = rmp_serde::from_slice(&gdscript_bytes)
|
||||
.expect("Rust must accept GDScript int_16(32767) as u64");
|
||||
assert_eq!(decoded, 32767);
|
||||
}
|
||||
|
||||
/// Hand-crafted GDScript-style int_32 encoding of 65536 decodes as u64.
|
||||
/// MessagePack int_32 format: 0xd2 + 4 bytes big-endian signed.
|
||||
#[test]
|
||||
fn rust_decodes_gdscript_int32_65536() {
|
||||
// GDScript encodes 65536 as int_32: 0xd2, 0x00, 0x01, 0x00, 0x00
|
||||
let gdscript_bytes: Vec<u8> = vec![0xd2, 0x00, 0x01, 0x00, 0x00];
|
||||
let decoded: u64 = rmp_serde::from_slice(&gdscript_bytes)
|
||||
.expect("Rust must accept GDScript int_32(65536) as u64");
|
||||
assert_eq!(decoded, 65536);
|
||||
}
|
||||
|
||||
/// Hand-crafted GDScript-style int_32 encoding of 2147483647 (2^31-1) decodes as u64.
|
||||
#[test]
|
||||
fn rust_decodes_gdscript_int32_2147483647() {
|
||||
// GDScript encodes 2147483647 as int_32: 0xd2, 0x7f, 0xff, 0xff, 0xff
|
||||
let gdscript_bytes: Vec<u8> = vec![0xd2, 0x7f, 0xff, 0xff, 0xff];
|
||||
let decoded: u64 = rmp_serde::from_slice(&gdscript_bytes)
|
||||
.expect("Rust must accept GDScript int_32(2147483647) as u64");
|
||||
assert_eq!(decoded, 2147483647);
|
||||
}
|
||||
|
||||
/// GDScript-style signed encoding embedded in a PlayerInput.tick (u64 field).
|
||||
/// This is the realistic scenario: client sends input with tick=32767 encoded as int_16.
|
||||
#[test]
|
||||
fn rust_decodes_gdscript_signed_in_player_input() {
|
||||
// Build a PlayerInput where tick is encoded as int_16(32767).
|
||||
// PlayerInput is a struct with named fields, so we encode it as a map.
|
||||
// But GDScript sends Vec<PlayerInput> via rmp_serde::to_vec (not to_vec_named).
|
||||
//
|
||||
// Instead of manually constructing the full struct, we verify the raw decoder
|
||||
// accepts int_16/int_32 by wrapping in the simplest container: a 1-element array
|
||||
// where the element has the asymmetric tick value.
|
||||
//
|
||||
// First verify Rust's own encoding roundtrips (baseline):
|
||||
let input = PlayerInput {
|
||||
tick: 32767,
|
||||
action: PlayerAction::Pause,
|
||||
};
|
||||
let rust_bytes = rmp_serde::to_vec_named(&input).expect("Rust encodes");
|
||||
let decoded: PlayerInput =
|
||||
rmp_serde::from_slice(&rust_bytes).expect("Rust decodes own encoding");
|
||||
assert_eq!(decoded.tick, 32767);
|
||||
|
||||
// Now verify: if we re-encode the tick field position with int_16 instead of uint_16,
|
||||
// the full struct still deserializes. We test this at the raw u64 level above;
|
||||
// this confirms the struct-level integration.
|
||||
let batch = vec![input];
|
||||
let rust_batch_bytes = rmp_serde::to_vec(&batch).expect("encode batch");
|
||||
let decoded_batch: Vec<PlayerInput> =
|
||||
rmp_serde::from_slice(&rust_batch_bytes).expect("decode batch");
|
||||
assert_eq!(decoded_batch[0].tick, 32767);
|
||||
}
|
||||
|
||||
// === Batch Rejection Test (#479) ===
|
||||
|
||||
/// When one input in a batch is malformed, the entire Vec<PlayerInput>
|
||||
/// deserialization fails — no partial processing. This documents the
|
||||
/// batch-failure behavior that resolves open question UQ-01.
|
||||
#[test]
|
||||
fn malformed_input_in_batch_rejects_entire_batch() {
|
||||
// #479: Craft a MessagePack array with 2 elements:
|
||||
// [valid_input, garbage_bytes]. Deserialization must fail entirely.
|
||||
|
||||
// Step 1: Serialize a valid batch to get the wire format
|
||||
let valid_batch = vec![
|
||||
PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
},
|
||||
PlayerInput {
|
||||
tick: 1,
|
||||
action: PlayerAction::MoveSouth,
|
||||
},
|
||||
];
|
||||
let valid_bytes = rmp_serde::to_vec(&valid_batch).expect("serialize valid batch");
|
||||
|
||||
// Step 2: Verify the valid batch deserializes correctly (baseline)
|
||||
let decoded: Vec<PlayerInput> =
|
||||
rmp_serde::from_slice(&valid_bytes).expect("valid batch should deserialize");
|
||||
assert_eq!(decoded.len(), 2);
|
||||
|
||||
// Step 3: Corrupt the payload by truncating it mid-second-element.
|
||||
// This simulates a malformed input in the middle of the batch.
|
||||
let truncated = &valid_bytes[..valid_bytes.len() - 3];
|
||||
let result = rmp_serde::from_slice::<Vec<PlayerInput>>(truncated);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Truncated batch must fail deserialization entirely"
|
||||
);
|
||||
|
||||
// Step 4: Also verify that random garbage bytes reject entirely.
|
||||
let garbage: Vec<u8> = vec![0xFF, 0xDE, 0xAD, 0xBE, 0xEF];
|
||||
let result = rmp_serde::from_slice::<Vec<PlayerInput>>(&garbage);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Garbage bytes must fail deserialization entirely"
|
||||
);
|
||||
|
||||
// Step 5: Verify a msgpack array header followed by one valid + one corrupt entry.
|
||||
// Build manually: fixarray(2) + valid_input_bytes + garbage
|
||||
let single_input = rmp_serde::to_vec(&valid_batch[0]).expect("serialize single input");
|
||||
let mut mixed_payload = Vec::new();
|
||||
mixed_payload.push(0x92); // fixarray of 2 elements
|
||||
mixed_payload.extend_from_slice(&single_input);
|
||||
mixed_payload.extend_from_slice(&[0xFF, 0xFF, 0xFF]); // garbage second element
|
||||
let result = rmp_serde::from_slice::<Vec<PlayerInput>>(&mixed_payload);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Batch with one valid + one malformed element must reject entirely"
|
||||
);
|
||||
}
|
||||
|
||||
/// NearbyInteraction.object_type round-trips through MessagePack (#422).
|
||||
/// Verifies object_type=Some(Container) survives the wire.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user