// 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 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))?; let mut ticks = Vec::new(); for (i, line) in content.lines().enumerate() { let line = line.trim(); if line.is_empty() { continue; } let inputs: Vec = 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 } }