From 6c62e2228fa558ea0d29638f9319ba1a6e82a336 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 12:58:27 +0100 Subject: [PATCH] =?UTF-8?q?test(simulation):=20sprint=2010=20=E2=80=94=20r?= =?UTF-8?q?eplay=20loading,=20content=20scaling,=20serialization=20v9,=20o?= =?UTF-8?q?bserver=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #483: Replay loading in test-client — JSONL file loading, tick-scheduled PlayerInput sending, 13 unit tests, 3 sample replay files. #500: Content scaling test — baseline + extra NPC comparative, tick budget assertion (D-026), determinism check across content packs. #514: Serialization tests for protocol v9 — blocked_entities roundtrip, backward compat (v5→v9, v8→v9), regenerated msgpack fixtures. Observer perception tests for confrontation + walk-away mechanics. Co-Authored-By: Claude Opus 4.6 --- .../msgpack/snapshot_boundary_tick_0.msgpack | Bin 241 -> 259 bytes .../snapshot_boundary_tick_127.msgpack | Bin 241 -> 259 bytes .../snapshot_boundary_tick_2b31m1.msgpack | Bin 245 -> 263 bytes .../snapshot_boundary_tick_2b32.msgpack | Bin 249 -> 267 bytes .../snapshot_boundary_tick_32767.msgpack | Bin 243 -> 261 bytes .../fixtures/msgpack/snapshot_empty.msgpack | Bin 241 -> 259 bytes .../msgpack/snapshot_multi_entity.msgpack | Bin 646 -> 664 bytes .../fixtures/msgpack/snapshot_one_npc.msgpack | Bin 339 -> 357 bytes .../fixtures/msgpack/snapshot_player.msgpack | Bin 342 -> 360 bytes .../fixtures/msgpack/snapshot_v2_full.msgpack | Bin 488 -> 506 bytes server/src/perception/observer/tests.rs | 210 ++++++++++++++ server/tests/bridge_ipc.rs | 1 + server/tests/bridge_tcp.rs | 1 + server/tests/content_scaling.rs | 257 ++++++++++++++++++ server/tests/gen_fixtures.rs | 2 + server/tests/golden/proof_room_tick_10.json | 5 +- server/tests/serialization.rs | 93 ++++++- tests/replays/gauntlet_hub_teleport.jsonl | 5 + tests/replays/gauntlet_idle.jsonl | 5 + tests/replays/gauntlet_walk_north.jsonl | 5 + tooling/test-client/Cargo.lock | 45 ++- tooling/test-client/Cargo.toml | 3 + tooling/test-client/src/replay.rs | 153 +++++++++++ 23 files changed, 782 insertions(+), 3 deletions(-) create mode 100644 server/tests/content_scaling.rs create mode 100644 tests/replays/gauntlet_hub_teleport.jsonl create mode 100644 tests/replays/gauntlet_idle.jsonl create mode 100644 tests/replays/gauntlet_walk_north.jsonl diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack index cafdb40685aface7dc81461666d1be3015b7aa93..3700cbd9c29a8d4ad3b455b2fe77fe1c3077aa25 100644 GIT binary patch delta 37 scmey!*v!P$ySyy5s5mn}k8>i|bD<4MIr+)isVVWPc_o=8nW@DS02+%AF#rGn delta 18 ZcmZo>`pC%Dv%D;|s5mn}k7FX&a{xmS2Soq? diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack index e6c1d093d515e73ff554c3944f737b849d1023fd..8f350a9506d8f002f67a02d51b3d72ea3cebc25e 100644 GIT binary patch delta 37 scmey!*v!P$ySyy5s5mn}k8>i|bD<4MIr+)isVVWPc_o=8nW@DS02+%AF#rGn delta 18 ZcmZo>`pC%Dv%D;|s5mn}k7FX&a{xmS2Soq? diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack index 561968e8d11e12083d0acb78715cbe098a4d5dab..829fc4dabb266abdcd641c306f2cf934b256b992 100644 GIT binary patch delta 37 scmey$*v`b&ySyy5s5mn}k8>i|YoQHEIr+)isVVWPc_o=8nW@DS02})cJpcdz delta 18 ZcmZo?`pU@Fv%D;|s5mn}k7FX&YXC!>2T=e3 diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack index 44153c26af800bf698b90008c427bebfb9f06f07..8723189c6b95e0586a8884f66f41a6948755c022 100644 GIT binary patch delta 37 scmey#*v-V%ySyy5s5mn}k8>i|d!Y?UIr+)isVVWPc_o=8nW@DS03B-&NdN!< delta 18 ZcmeBX`pL-Ev%D;|s5mn}k7FX&djLcz2VDRF diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack index 1374de60b9a254a7e904438e0adfc27657c3e5c8..25a2615afb7c4683aba49befe4e3724fa2657cc3 100644 GIT binary patch delta 37 scmey&*viD!ySyy5s5mn}k8>i|OQ8)(Ir+)isVVWPc_o=8nW@DS02@OOHvj+t delta 18 ZcmZo=`pn4Hv%D;|s5mn}k7FX&O8`Sw2TK3| diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index cafdb40685aface7dc81461666d1be3015b7aa93..3700cbd9c29a8d4ad3b455b2fe77fe1c3077aa25 100644 GIT binary patch delta 37 scmey!*v!P$ySyy5s5mn}k8>i|bD<4MIr+)isVVWPc_o=8nW@DS02+%AF#rGn delta 18 ZcmZo>`pC%Dv%D;|s5mn}k7FX&a{xmS2Soq? diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index bea3ffa615e3abd022fee22fc8e54f278f982316..c17c621184e0a2f3e4933e52e57dc044f3142c6c 100644 GIT binary patch delta 38 tcmZo;ox#f0ySyy5s5mn}k8>ke4U^D@q@4WZ?9`O_)Vz|+lFZcN2>=ix4w(P| delta 19 acmbQi+Q!P&v%D;|s5mn}k7FZO4HE!F>jtd= diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index b3d6a619cbcc8127723465ab334ea2c0d7ce4326..89d9285580e4004faff9338800b1c756ddda73ff 100644 GIT binary patch delta 38 tcmcc2^puIKcX?TAQE_H|9_L0bPe!2)Njdq+*{Lb+#5PeuSv@&^q7 diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index 3f9a7316ea5d3db7467a015b669540f1f042d264..29bb08964bfd1e9dd82705516b2d2c7cadb1173e 100644 GIT binary patch delta 38 tcmcb{^n!`2cX?TAQE_H|9_L0bA4Z`KNjdq+*{Lb+#5A4ULAWCs=i diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index f92affd5020ce74f66342b17f0d3985466ac862c..dea8f77dd6130ff7aef5a80fd4eb44d9927a2192 100644 GIT binary patch delta 38 ucmaFC{EL~ZcX?TAQE_H|9_L1`dyGOGl5+Bsvr|*zQ}aqPOEOc7CjbC5+z+|{ delta 19 acmeyx{DPUQXL(s_QE_H|9>+$mdyD{6G6&88 diff --git a/server/src/perception/observer/tests.rs b/server/src/perception/observer/tests.rs index d2e579d01..73316530f 100644 --- a/server/src/perception/observer/tests.rs +++ b/server/src/perception/observer/tests.rs @@ -2030,3 +2030,213 @@ fn no_cognitive_delay_component_means_empty_pending_recognitions() { "no CognitiveDelay component should produce empty pending_recognitions" ); } + +// ----------------------------------------------------------------------- +// blocked_entities debug field tests (#514) +// ----------------------------------------------------------------------- + +#[test] +fn blocked_entities_empty_when_all_visible() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))) + .id(); + registry.register(npc); + + 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::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert!( + snapshot.blocked_entities.is_empty(), + "no blocked entities when NPC is in LOS" + ); +} + +#[test] +fn npc_behind_wall_appears_in_blocked_entities() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // Wall between player and NPC + world + .resource_mut::() + .set_walkable(&TilePosition::new(16, 14, 0), false); + + // NPC behind the wall + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(16, 12, 0))) + .id(); + let npc_sid = registry.register(npc); + + 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::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert!( + snapshot.blocked_entities.contains(&npc_sid.0), + "NPC behind wall should appear in blocked_entities" + ); + // Not in visible entities + let npc_visible = snapshot + .entities + .iter() + .any(|e| e.entity_id == npc_sid.0); + assert!(!npc_visible, "NPC should not be in visible entities"); +} + +#[test] +fn npc_behind_player_appears_in_blocked_entities() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // NPC far behind player (south, outside vision cone) + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(16, 26, 0))) + .id(); + let npc_sid = registry.register(npc); + + 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::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert!( + snapshot.blocked_entities.contains(&npc_sid.0), + "NPC in blind spot should appear in blocked_entities" + ); +} + +#[test] +fn different_z_level_not_in_blocked_entities() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // NPC on a different z-level + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(16, 14, 1))) + .id(); + let npc_sid = registry.register(npc); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert!( + !snapshot.blocked_entities.contains(&npc_sid.0), + "NPC on different z-level should NOT be in blocked_entities" + ); +} + +#[test] +fn blocked_entities_sorted_ascending() { + // Multiple blocked NPCs should appear in ascending entity_id order + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // Wall blocks north + world + .resource_mut::() + .set_walkable(&TilePosition::new(16, 14, 0), false); + + // Two NPCs behind wall + one behind player + let npc_a = world + .spawn((crate::npc::Npc, TilePosition::new(16, 12, 0))) + .id(); + let npc_a_sid = registry.register(npc_a); + + let npc_b = world + .spawn((crate::npc::Npc, TilePosition::new(16, 10, 0))) + .id(); + let npc_b_sid = registry.register(npc_b); + + let npc_c = world + .spawn((crate::npc::Npc, TilePosition::new(16, 26, 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::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + + assert!(snapshot.blocked_entities.len() >= 3); + // Must be sorted ascending (BTreeSet guarantee) + for i in 1..snapshot.blocked_entities.len() { + assert!( + snapshot.blocked_entities[i - 1] < snapshot.blocked_entities[i], + "blocked_entities not sorted: {:?}", + snapshot.blocked_entities + ); + } + // All three NPCs should be present + assert!(snapshot.blocked_entities.contains(&npc_a_sid.0)); + assert!(snapshot.blocked_entities.contains(&npc_b_sid.0)); + assert!(snapshot.blocked_entities.contains(&npc_c_sid.0)); +} diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index af41dab66..cab7ed4e8 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -59,6 +59,7 @@ fn snapshot_roundtrip_over_unix_socket() { current_monologue: None, pending_recognitions: vec![], dialogue_response: None, + blocked_entities: vec![], }; bridge diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 93b8d884d..9a7063f86 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -45,6 +45,7 @@ fn snapshot_roundtrip_over_tcp() { current_monologue: None, pending_recognitions: vec![], dialogue_response: None, + blocked_entities: vec![], }; bridge diff --git a/server/tests/content_scaling.rs b/server/tests/content_scaling.rs new file mode 100644 index 000000000..451db7003 --- /dev/null +++ b/server/tests/content_scaling.rs @@ -0,0 +1,257 @@ +//! Content scaling test (#500, D-026). +//! +//! Verifies that adding extra NPCs doesn't degrade tick timing beyond +//! acceptable bounds. Runs the Gauntlet baseline, then adds additional +//! NPCs and compares: +//! 1. Tick timing stays within D-026 budget (100ms) +//! 2. Baseline entities still behave identically (deterministic) +//! +//! Run with: cargo test --test content_scaling -- --nocapture + +use bevy_app::prelude::*; +use std::time::Instant; + +use settled_reach_server::bridge::types::*; +use settled_reach_server::bridge::BridgePlugin; +use settled_reach_server::knowledge::registry::{EntityRegistry, StableEntityId}; +use settled_reach_server::knowledge::KnowledgePlugin; +use settled_reach_server::npc::{Contentment, Npc, NpcPlugin, ToleranceThreshold, Want, WantKind}; +use settled_reach_server::simulation::interaction::Interactable; +use settled_reach_server::simulation::movement::TilePosition; +use settled_reach_server::simulation::path_follow::MovementSpeed; +use settled_reach_server::simulation::SimulationPlugin; + +/// Number of ticks to run for timing measurements. +const TIMING_TICKS: usize = 50; + +/// D-026 budget: 100ms per tick maximum. +const MAX_TICK_MS: f64 = 100.0; + +/// Extra NPC counts for scaling tiers. +const EXTRA_NPC_COUNTS: &[usize] = &[0, 15, 50]; + +/// Set up a Gauntlet world and return the app. +fn setup_baseline() -> App { + let mut app = App::new(); + app.add_plugins(SimulationPlugin); + app.add_plugins(BridgePlugin); + app.add_plugins(KnowledgePlugin); + app.add_plugins(NpcPlugin); + + #[cfg(feature = "gauntlet")] + settled_reach_server::test_world::setup_gauntlet(&mut app); + + app +} + +/// Spawn N extra NPCs spread across the Gauntlet hub area. +/// NPCs are placed in a grid starting at (40, 48) to stay within walkable space. +fn spawn_extra_npcs(app: &mut App, count: usize) { + // Remove registry from world so we can mutate it while also spawning entities. + let mut registry = app + .world_mut() + .remove_resource::() + .expect("EntityRegistry should exist after setup_gauntlet"); + let cols = 10; + + for i in 0..count { + let x = 40 + (i % cols) as i32; + let y = 48 + (i / cols) as i32; + let pos = TilePosition::new(x, y, 0); + + let entity = app + .world_mut() + .spawn(( + Npc, + Interactable, + pos, + Want { + primary: WantKind::Safety, + intensity: 5, + description: format!("extra_npc_{}", i), + }, + Contentment { level: 0 }, + ToleranceThreshold { + current_stress: 0, + threshold: 50, + }, + MovementSpeed::default(), + )) + .id(); + let sid = registry.register(entity); + app.world_mut() + .entity_mut(entity) + .insert(StableEntityId(sid)); + } + + app.insert_resource(registry); +} + +/// Tick the app N times and return average milliseconds per tick. +fn measure_tick_timing(app: &mut App, ticks: usize) -> f64 { + // Warm-up tick (first tick has startup overhead) + app.update(); + + let start = Instant::now(); + for _ in 0..ticks { + app.update(); + } + let elapsed = start.elapsed(); + elapsed.as_secs_f64() * 1000.0 / ticks as f64 +} + +/// Collect snapshot entity IDs from the VisibilityGeometry and entity count. +fn count_entities(app: &App) -> usize { + let registry = app.world().resource::(); + registry.len() as usize +} + +/// Baseline tick timing: Gauntlet with default entities stays within D-026 budget. +#[test] +#[cfg(feature = "gauntlet")] +fn baseline_tick_timing_within_budget() { + let mut app = setup_baseline(); + let entity_count = count_entities(&app); + let avg_ms = measure_tick_timing(&mut app, TIMING_TICKS); + + eprintln!( + "Baseline: {} entities, avg {:.3}ms/tick over {} ticks", + entity_count, avg_ms, TIMING_TICKS + ); + + assert!( + avg_ms < MAX_TICK_MS, + "Baseline tick timing ({:.3}ms) exceeds D-026 budget ({}ms)", + avg_ms, + MAX_TICK_MS + ); +} + +/// Scaling test: adding NPCs keeps tick timing within D-026 budget. +/// Tests 0 (baseline), 15, and 50 extra NPCs. +#[test] +#[cfg(feature = "gauntlet")] +fn scaling_tick_timing_within_budget() { + let mut results: Vec<(usize, usize, f64)> = Vec::new(); + + for &extra_count in EXTRA_NPC_COUNTS { + let mut app = setup_baseline(); + if extra_count > 0 { + spawn_extra_npcs(&mut app, extra_count); + } + let total_entities = count_entities(&app); + let avg_ms = measure_tick_timing(&mut app, TIMING_TICKS); + results.push((extra_count, total_entities, avg_ms)); + } + + eprintln!("\n=== Content Scaling Results (D-026: {}ms budget) ===", MAX_TICK_MS); + eprintln!("{:<12} {:<10} {:<15}", "Extra NPCs", "Total", "Avg ms/tick"); + eprintln!("{:-<37}", ""); + for &(extra, total, avg_ms) in &results { + let status = if avg_ms < MAX_TICK_MS { "OK" } else { "OVER" }; + eprintln!("{:<12} {:<10} {:<15.3} {}", extra, total, avg_ms, status); + } + + // Assert all tiers stay within budget + for &(extra, _total, avg_ms) in &results { + assert!( + avg_ms < MAX_TICK_MS, + "Tick timing with +{} NPCs ({:.3}ms) exceeds D-026 budget ({}ms)", + extra, + avg_ms, + MAX_TICK_MS + ); + } + + // Assert scaling is reasonable: +50 NPCs shouldn't more than 5x the baseline + if results.len() >= 2 { + let baseline_ms = results[0].2; + let max_extra_ms = results.last().unwrap().2; + let scaling_factor = max_extra_ms / baseline_ms; + eprintln!( + "\nScaling factor (baseline → +{} NPCs): {:.2}x", + results.last().unwrap().0, + scaling_factor + ); + assert!( + scaling_factor < 5.0, + "Scaling factor {:.2}x exceeds 5x threshold — possible O(n^2) regression", + scaling_factor + ); + } +} + +/// Determinism test: baseline entities produce identical snapshots regardless +/// of extra NPCs being present. The original Gauntlet entities (StableId 0-51) +/// should have the same positions and visibility after the same number of ticks. +#[test] +#[cfg(feature = "gauntlet")] +fn extra_npcs_dont_affect_baseline_behavior() { + // Run baseline + let mut baseline_app = setup_baseline(); + for _ in 0..10 { + baseline_app.update(); + } + let baseline_buffer = baseline_app + .world() + .resource::() + .snapshot + .clone(); + + // Run with extra NPCs + let mut scaled_app = setup_baseline(); + spawn_extra_npcs(&mut scaled_app, 15); + for _ in 0..10 { + scaled_app.update(); + } + let scaled_buffer = scaled_app + .world() + .resource::() + .snapshot + .clone(); + + let baseline_snap = baseline_buffer.expect("baseline should produce a snapshot"); + let scaled_snap = scaled_buffer.expect("scaled should produce a snapshot"); + + // Same tick + assert_eq!(baseline_snap.tick, scaled_snap.tick, "tick count should match"); + + // Same game time + assert_eq!( + baseline_snap.game_time.time_of_day, scaled_snap.game_time.time_of_day, + "game time should match" + ); + + // Player position should be identical + let baseline_player = baseline_snap.entities.iter().find(|e| e.kind == EntityKind::Player); + let scaled_player = scaled_snap.entities.iter().find(|e| e.kind == EntityKind::Player); + assert!(baseline_player.is_some(), "baseline should have player"); + assert!(scaled_player.is_some(), "scaled should have player"); + + let bp = baseline_player.unwrap(); + let sp = scaled_player.unwrap(); + assert_eq!(bp.x, sp.x, "player x should match"); + assert_eq!(bp.y, sp.y, "player y should match"); + + // Original entities (entity_id <= 51) visible in baseline should still be + // visible in scaled run. Extra NPCs may add to the visible set, but + // shouldn't remove baseline visibility. + let baseline_original_ids: Vec = baseline_snap + .entities + .iter() + .filter(|e| e.entity_id <= 51) + .map(|e| e.entity_id) + .collect(); + + let scaled_original_ids: Vec = scaled_snap + .entities + .iter() + .filter(|e| e.entity_id <= 51) + .map(|e| e.entity_id) + .collect(); + + assert_eq!( + baseline_original_ids, scaled_original_ids, + "Original Gauntlet entities (id<=51) should be identical in both runs" + ); +} diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 7becb342b..5eaf8fb5f 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -35,6 +35,7 @@ fn fixture_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot current_monologue: None, pending_recognitions: vec![], dialogue_response: None, + blocked_entities: vec![], } } @@ -204,6 +205,7 @@ fn generate_msgpack_fixtures() { current_monologue: None, pending_recognitions: vec![], dialogue_response: None, + blocked_entities: vec![], }; write_fixture( "snapshot_v2_full", diff --git a/server/tests/golden/proof_room_tick_10.json b/server/tests/golden/proof_room_tick_10.json index d6db3f02b..aa692bcee 100644 --- a/server/tests/golden/proof_room_tick_10.json +++ b/server/tests/golden/proof_room_tick_10.json @@ -1,4 +1,7 @@ { + "blocked_entities": [ + 2 + ], "current_monologue": null, "dialogue_response": null, "entities": [ @@ -62,7 +65,7 @@ "player_inventory": [], "player_stance": "Sprint", "tick": 8, - "version": 8, + "version": 9, "visible_tiles": [ { "tile_kind": "Wall", diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 2a094c5d4..766422c7c 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -24,6 +24,7 @@ fn test_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { current_monologue: None, pending_recognitions: vec![], dialogue_response: None, + blocked_entities: vec![], } } @@ -250,6 +251,7 @@ fn snapshot_v2_fields_roundtrip() { current_monologue: None, pending_recognitions: vec![], dialogue_response: None, + blocked_entities: vec![], }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); @@ -304,7 +306,7 @@ fn protocol_version_constant_matches_snapshot() { let snapshot = test_snapshot(0, vec![]); assert_eq!(snapshot.version, PROTOCOL_VERSION); assert_eq!( - PROTOCOL_VERSION, 8, + PROTOCOL_VERSION, 9, "bump this assertion when protocol version changes" ); } @@ -342,6 +344,7 @@ fn all_facing_direction_variants_roundtrip() { current_monologue: None, pending_recognitions: vec![], dialogue_response: None, + blocked_entities: vec![], }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); @@ -469,6 +472,10 @@ fn v5_payload_deserializes_into_v6_struct() { decoded.pending_recognitions.is_empty(), "missing pending_recognitions should default to empty" ); + assert!( + decoded.blocked_entities.is_empty(), + "missing blocked_entities should default to empty" + ); } /// Full 9-slot inventory roundtrip (D-065: 3x3 grid = 9 slots universal) @@ -1095,6 +1102,90 @@ fn gdscript_generated_fixtures_deserialize() { eprintln!("Verified {} GDScript-generated fixtures", count); } +/// blocked_entities Vec round-trips through MessagePack (#514). +/// Guards the debug field survives serialization. +#[test] +fn blocked_entities_roundtrip() { + let mut snapshot = test_snapshot(0, vec![]); + snapshot.blocked_entities = vec![42, 99, 1024]; + + let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); + + assert_eq!( + decoded.blocked_entities, + vec![42, 99, 1024], + "blocked_entities should survive roundtrip" + ); +} + +/// Empty blocked_entities round-trips correctly (#514). +#[test] +fn blocked_entities_empty_roundtrip() { + let snapshot = test_snapshot(0, vec![]); + assert!(snapshot.blocked_entities.is_empty()); + + let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); + + assert!( + decoded.blocked_entities.is_empty(), + "empty blocked_entities should survive roundtrip" + ); +} + +/// v8 payloads (without blocked_entities) must deserialize into the v9 struct +/// via #[serde(default)]. Guards backwards compat during migration (#514). +#[test] +fn v8_payload_deserializes_into_v9_struct() { + #[derive(serde::Serialize)] + struct ObserverSnapshotV8 { + version: u8, + tick: u64, + game_time: GameTime, + player_facing: FacingDirection, + player_stance: MovementStance, + player_inventory: Vec, + entities: Vec, + visible_tiles: Vec, + nearby_interactions: Vec, + current_monologue: Option, + pending_recognitions: Vec, + dialogue_response: Option, + } + + let v8 = ObserverSnapshotV8 { + version: 8, + tick: 100, + game_time: GameTime { + day: 0, + time_of_day: 0, + day_phase: DayPhase::Morning, + tick_rate: TickRate::Full, + }, + player_facing: FacingDirection::North, + player_stance: MovementStance::Walk, + player_inventory: vec![], + entities: vec![], + visible_tiles: vec![], + nearby_interactions: vec![], + current_monologue: None, + pending_recognitions: vec![], + dialogue_response: None, + }; + + let bytes = rmp_serde::to_vec_named(&v8).expect("serialize v8"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes) + .expect("v8 payload should deserialize into v9 struct via serde(default)"); + + assert_eq!(decoded.version, 8, "version field preserved from v8"); + assert_eq!(decoded.tick, 100); + assert!( + decoded.blocked_entities.is_empty(), + "missing blocked_entities should default to empty" + ); +} + /// NearbyInteraction.object_type round-trips through MessagePack (#422). /// Verifies object_type=Some(Container) survives the wire. #[test] diff --git a/tests/replays/gauntlet_hub_teleport.jsonl b/tests/replays/gauntlet_hub_teleport.jsonl new file mode 100644 index 000000000..6b0491825 --- /dev/null +++ b/tests/replays/gauntlet_hub_teleport.jsonl @@ -0,0 +1,5 @@ +[{"tick":0,"action":"MoveNorth"}] +[{"tick":1,"action":"MoveNorth"}] +[{"tick":2,"action":"MoveEast"}] +[{"tick":3,"action":"TeleportToHub"}] +[{"tick":4,"action":"MoveNorth"}] diff --git a/tests/replays/gauntlet_idle.jsonl b/tests/replays/gauntlet_idle.jsonl new file mode 100644 index 000000000..5cbe1a286 --- /dev/null +++ b/tests/replays/gauntlet_idle.jsonl @@ -0,0 +1,5 @@ +[] +[] +[] +[] +[] diff --git a/tests/replays/gauntlet_walk_north.jsonl b/tests/replays/gauntlet_walk_north.jsonl new file mode 100644 index 000000000..92231c1a3 --- /dev/null +++ b/tests/replays/gauntlet_walk_north.jsonl @@ -0,0 +1,5 @@ +[{"tick":0,"action":"MoveNorth"}] +[{"tick":1,"action":"MoveNorth"}] +[{"tick":2,"action":"MoveNorth"}] +[{"tick":3,"action":"MoveNorth"}] +[{"tick":4,"action":"MoveNorth"}] diff --git a/tooling/test-client/Cargo.lock b/tooling/test-client/Cargo.lock index 95813535b..821826966 100644 --- a/tooling/test-client/Cargo.lock +++ b/tooling/test-client/Cargo.lock @@ -556,6 +556,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -799,6 +809,12 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "log" version = "0.4.29" @@ -1067,6 +1083,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1143,7 +1172,7 @@ dependencies = [ [[package]] name = "settled-reach-server" -version = "0.1.0" +version = "0.1.9" dependencies = [ "bevy_app", "bevy_ecs", @@ -1168,6 +1197,7 @@ dependencies = [ "serde", "serde_json", "settled-reach-server", + "tempfile", ] [[package]] @@ -1241,6 +1271,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "2.0.18" diff --git a/tooling/test-client/Cargo.toml b/tooling/test-client/Cargo.toml index 5e59ee4ab..a96939aa5 100644 --- a/tooling/test-client/Cargo.toml +++ b/tooling/test-client/Cargo.toml @@ -14,3 +14,6 @@ clap = { version = "4", features = ["derive"] } serde_json = "1" serde = { version = "1", features = ["derive"] } rmp-serde = "1" + +[dev-dependencies] +tempfile = "3" diff --git a/tooling/test-client/src/replay.rs b/tooling/test-client/src/replay.rs index 45c01b4f1..2a1776b82 100644 --- a/tooling/test-client/src/replay.rs +++ b/tooling/test-client/src/replay.rs @@ -6,6 +6,10 @@ use settled_reach_server::bridge::types::PlayerInput; use std::path::Path; /// Load a JSONL replay file. Returns one Vec per tick. +/// +/// Format: one JSON array per line. Each array contains PlayerInput objects +/// for that tick. Blank lines are skipped. Returns Err with line number on +/// parse failure. pub fn load_replay(path: &Path) -> Result>, String> { let content = std::fs::read_to_string(path) .map_err(|e| format!("failed to read replay file {}: {}", path.display(), e))?; @@ -22,3 +26,152 @@ pub fn load_replay(path: &Path) -> Result>, String> { } Ok(ticks) } + +#[cfg(test)] +mod tests { + use super::*; + use settled_reach_server::bridge::types::PlayerAction; + use std::io::Write; + + fn write_temp_file(content: &str) -> tempfile::NamedTempFile { + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(content.as_bytes()).unwrap(); + f.flush().unwrap(); + f + } + + #[test] + fn load_single_tick_single_action() { + let f = write_temp_file(r#"[{"tick":0,"action":"MoveNorth"}]"#); + let ticks = load_replay(f.path()).unwrap(); + assert_eq!(ticks.len(), 1); + assert_eq!(ticks[0].len(), 1); + assert!(ticks[0][0].action.is_movement()); + } + + #[test] + fn load_multiple_ticks() { + let content = r#"[{"tick":0,"action":"MoveNorth"}] +[{"tick":1,"action":"MoveEast"}] +[{"tick":2,"action":"MoveSouth"}]"#; + let f = write_temp_file(content); + let ticks = load_replay(f.path()).unwrap(); + assert_eq!(ticks.len(), 3); + } + + #[test] + fn load_multiple_actions_per_tick() { + let content = r#"[{"tick":0,"action":"MoveNorth"},{"tick":0,"action":"Pause"}]"#; + let f = write_temp_file(content); + let ticks = load_replay(f.path()).unwrap(); + assert_eq!(ticks.len(), 1); + assert_eq!(ticks[0].len(), 2); + } + + #[test] + fn load_empty_array_idle_tick() { + let content = "[]"; + let f = write_temp_file(content); + let ticks = load_replay(f.path()).unwrap(); + assert_eq!(ticks.len(), 1); + assert!(ticks[0].is_empty()); + } + + #[test] + fn blank_lines_skipped() { + let content = r#"[{"tick":0,"action":"MoveNorth"}] + +[{"tick":2,"action":"MoveSouth"}] + +"#; + let f = write_temp_file(content); + let ticks = load_replay(f.path()).unwrap(); + assert_eq!(ticks.len(), 2, "blank lines should be skipped, not counted as ticks"); + } + + #[test] + fn empty_file_returns_empty_vec() { + let f = write_temp_file(""); + let ticks = load_replay(f.path()).unwrap(); + assert!(ticks.is_empty()); + } + + #[test] + fn whitespace_only_file_returns_empty_vec() { + let f = write_temp_file(" \n \n\n "); + let ticks = load_replay(f.path()).unwrap(); + assert!(ticks.is_empty()); + } + + #[test] + fn invalid_json_reports_line_number() { + let content = r#"[{"tick":0,"action":"MoveNorth"}] +not valid json +[{"tick":2,"action":"MoveSouth"}]"#; + let f = write_temp_file(content); + let err = load_replay(f.path()).unwrap_err(); + assert!(err.contains("replay line 2"), "error should reference line 2, got: {}", err); + } + + #[test] + fn missing_file_returns_error() { + let err = load_replay(Path::new("/nonexistent/replay.jsonl")).unwrap_err(); + assert!(err.contains("failed to read replay file"), "got: {}", err); + } + + #[test] + fn interact_action_parses() { + let content = + r#"[{"tick":0,"action":{"Interact":{"target_entity_id":42,"verb":"Talk"}}}]"#; + let f = write_temp_file(content); + let ticks = load_replay(f.path()).unwrap(); + assert_eq!(ticks.len(), 1); + match &ticks[0][0].action { + PlayerAction::Interact { + target_entity_id, + verb, + } => { + assert_eq!(*target_entity_id, Some(42)); + assert_eq!(verb.as_deref(), Some("Talk")); + } + other => panic!("expected Interact, got {:?}", other), + } + } + + #[test] + fn teleport_to_hub_parses() { + let content = r#"[{"tick":0,"action":"TeleportToHub"}]"#; + let f = write_temp_file(content); + let ticks = load_replay(f.path()).unwrap(); + assert_eq!(ticks.len(), 1); + assert!(matches!(ticks[0][0].action, PlayerAction::TeleportToHub)); + } + + #[test] + fn walk_away_parses() { + let content = r#"[{"tick":0,"action":"WalkAway"}]"#; + let f = write_temp_file(content); + let ticks = load_replay(f.path()).unwrap(); + assert!(matches!(ticks[0][0].action, PlayerAction::WalkAway)); + } + + #[test] + fn mixed_replay_scenario() { + // Simulates a realistic Gauntlet replay: move, idle, interact, move, teleport + let content = r#"[{"tick":0,"action":"MoveNorth"}] +[{"tick":1,"action":"MoveNorth"}] +[] +[{"tick":3,"action":{"Interact":{"target_entity_id":100,"verb":"Talk"}}}] +[{"tick":4,"action":"MoveEast"}] +[{"tick":5,"action":"TeleportToHub"}]"#; + let f = write_temp_file(content); + let ticks = load_replay(f.path()).unwrap(); + assert_eq!(ticks.len(), 6); + assert_eq!(ticks[0].len(), 1); // MoveNorth + assert_eq!(ticks[1].len(), 1); // MoveNorth + assert_eq!(ticks[2].len(), 0); // Idle + assert_eq!(ticks[3].len(), 1); // Interact + assert_eq!(ticks[4].len(), 1); // MoveEast + assert_eq!(ticks[5].len(), 1); // TeleportToHub + } +}