test(simulation): sprint 10 — replay loading, content scaling, serialization v9, observer tests

#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 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 12:58:27 +01:00
co-authored by Claude Opus 4.6
parent 273d29f26f
commit 6c62e2228f
23 changed files with 782 additions and 3 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+210
View File
@@ -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::<SnapshotBuffer>();
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::<WalkabilityMap>()
.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::<SnapshotBuffer>();
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::<SnapshotBuffer>();
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::<SnapshotBuffer>();
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::<WalkabilityMap>()
.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::<SnapshotBuffer>();
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));
}
+1
View File
@@ -59,6 +59,7 @@ fn snapshot_roundtrip_over_unix_socket() {
current_monologue: None,
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
};
bridge
+1
View File
@@ -45,6 +45,7 @@ fn snapshot_roundtrip_over_tcp() {
current_monologue: None,
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
};
bridge
+257
View File
@@ -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::<EntityRegistry>()
.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::<EntityRegistry>();
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::<SnapshotBuffer>()
.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::<SnapshotBuffer>()
.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<u64> = baseline_snap
.entities
.iter()
.filter(|e| e.entity_id <= 51)
.map(|e| e.entity_id)
.collect();
let scaled_original_ids: Vec<u64> = 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"
);
}
+2
View File
@@ -35,6 +35,7 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> 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",
+4 -1
View File
@@ -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",
+92 -1
View File
@@ -24,6 +24,7 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> 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<u64> 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<InventoryItem>,
entities: Vec<VisibleEntity>,
visible_tiles: Vec<VisibleTile>,
nearby_interactions: Vec<NearbyInteraction>,
current_monologue: Option<MonologueEvent>,
pending_recognitions: Vec<PendingRecognitionWire>,
dialogue_response: Option<DialogueResponseEvent>,
}
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]
@@ -0,0 +1,5 @@
[{"tick":0,"action":"MoveNorth"}]
[{"tick":1,"action":"MoveNorth"}]
[{"tick":2,"action":"MoveEast"}]
[{"tick":3,"action":"TeleportToHub"}]
[{"tick":4,"action":"MoveNorth"}]
+5
View File
@@ -0,0 +1,5 @@
[]
[]
[]
[]
[]
+5
View File
@@ -0,0 +1,5 @@
[{"tick":0,"action":"MoveNorth"}]
[{"tick":1,"action":"MoveNorth"}]
[{"tick":2,"action":"MoveNorth"}]
[{"tick":3,"action":"MoveNorth"}]
[{"tick":4,"action":"MoveNorth"}]
+44 -1
View File
@@ -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"
+3
View File
@@ -14,3 +14,6 @@ clap = { version = "4", features = ["derive"] }
serde_json = "1"
serde = { version = "1", features = ["derive"] }
rmp-serde = "1"
[dev-dependencies]
tempfile = "3"
+153
View File
@@ -6,6 +6,10 @@ use settled_reach_server::bridge::types::PlayerInput;
use std::path::Path;
/// Load a JSONL replay file. Returns one Vec<PlayerInput> 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<Vec<Vec<PlayerInput>>, 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<Vec<Vec<PlayerInput>>, 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
}
}