Files
settled-reach/tooling/test-client/src/replay.rs
T
jpmschweitzerandClaude Opus 4.6 6c62e2228f 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>
2026-02-18 12:58:27 +01:00

178 lines
5.8 KiB
Rust

// JSONL replay file loader for test-client.
// Each line is a JSON array of PlayerInput for one tick.
// Empty array = idle tick (no input sent).
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))?;
let mut ticks = Vec::new();
for (i, line) in content.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let inputs: Vec<PlayerInput> =
serde_json::from_str(line).map_err(|e| format!("replay line {}: {}", i + 1, e))?;
ticks.push(inputs);
}
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
}
}