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:
Generated
+44
-1
@@ -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"
|
||||
|
||||
@@ -14,3 +14,6 @@ clap = { version = "4", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
rmp-serde = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user