#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>
178 lines
5.8 KiB
Rust
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
|
|
}
|
|
}
|