feat(simulation): add test client binary scaffolding (#480)

Separate workspace crate at tooling/test-client/ importing bridge
types from server crate. CLI: --connect, --replay, --text, --json,
--quiet, --golden, --ticks. Exit codes: 0=success, 1=golden mismatch,
2=connection error. Golden file comparison with recursive JSON diff.
JSONL replay loader for tick-scheduled input sending.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 01:39:37 +01:00
co-authored by Claude Opus 4.6
parent f83815fd9a
commit 1da81a21ea
4 changed files with 332 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "settled-reach-test-client"
version = "0.1.0"
edition = "2021"
description = "Test client for the Settled Reach simulation server. Connects via TCP, receives ObserverSnapshots, sends replay inputs."
[[bin]]
name = "settled-reach-test-client"
path = "src/main.rs"
[dependencies]
settled-reach-server = { path = "../../server" }
clap = { version = "4", features = ["derive"] }
serde_json = "1"
serde = { version = "1", features = ["derive"] }
rmp-serde = "1"
+104
View File
@@ -0,0 +1,104 @@
// Golden file comparison for test-client.
// Compares the final ObserverSnapshot (as JSON) against a golden file.
// Reports field-by-field differences with JSON paths.
use serde_json::Value;
use settled_reach_server::bridge::types::ObserverSnapshot;
use std::path::Path;
/// Compare an ObserverSnapshot against a golden JSON file.
/// Returns a list of difference descriptions (empty = match).
pub fn compare_golden(
golden_path: &Path,
snapshot: &ObserverSnapshot,
) -> Result<Vec<String>, String> {
let golden_str = std::fs::read_to_string(golden_path).map_err(|e| {
format!(
"failed to read golden file {}: {}",
golden_path.display(),
e
)
})?;
let golden: Value = serde_json::from_str(&golden_str)
.map_err(|e| format!("failed to parse golden file: {}", e))?;
let actual: Value = serde_json::to_value(snapshot)
.map_err(|e| format!("failed to serialize snapshot: {}", e))?;
let mut diffs = Vec::new();
diff_values("", &golden, &actual, &mut diffs);
Ok(diffs)
}
fn diff_values(path: &str, expected: &Value, actual: &Value, diffs: &mut Vec<String>) {
match (expected, actual) {
(Value::Object(e), Value::Object(a)) => {
for key in e.keys() {
let child_path = if path.is_empty() {
format!(".{}", key)
} else {
format!("{}.{}", path, key)
};
match a.get(key) {
Some(av) => diff_values(&child_path, &e[key], av, diffs),
None => diffs.push(format!(
"{}: expected {}, got <missing>",
child_path,
format_value(&e[key])
)),
}
}
for key in a.keys() {
if !e.contains_key(key) {
let child_path = if path.is_empty() {
format!(".{}", key)
} else {
format!("{}.{}", path, key)
};
diffs.push(format!(
"{}: expected <missing>, got {}",
child_path,
format_value(&a[key])
));
}
}
}
(Value::Array(e), Value::Array(a)) => {
let max_len = e.len().max(a.len());
for i in 0..max_len {
let child_path = format!("{}[{}]", path, i);
match (e.get(i), a.get(i)) {
(Some(ev), Some(av)) => diff_values(&child_path, ev, av, diffs),
(Some(ev), None) => diffs.push(format!(
"{}: expected {}, got <missing>",
child_path,
format_value(ev)
)),
(None, Some(av)) => diffs.push(format!(
"{}: expected <missing>, got {}",
child_path,
format_value(av)
)),
(None, None) => unreachable!(),
}
}
}
_ => {
if expected != actual {
diffs.push(format!(
"{}: expected {}, got {}",
path,
format_value(expected),
format_value(actual)
));
}
}
}
}
fn format_value(v: &Value) -> String {
match v {
Value::String(s) => format!("{:?}", s),
Value::Null => "null".to_string(),
other => other.to_string(),
}
}
+188
View File
@@ -0,0 +1,188 @@
// test-client: Test client for the Settled Reach simulation server.
//
// Connects via TCP, receives ObserverSnapshots, optionally sends replay
// inputs, and outputs snapshots as text/JSON. Supports golden file
// comparison for deterministic regression testing.
//
// Exit codes:
// 0 = success
// 1 = golden file mismatch
// 2 = connection/protocol error
use clap::Parser;
use std::io::{BufReader, BufWriter};
use std::net::TcpStream;
use std::path::PathBuf;
use std::process;
use settled_reach_server::bridge::framing::{read_framed, write_framed};
use settled_reach_server::bridge::text_renderer::format_snapshot_text;
use settled_reach_server::bridge::types::ObserverSnapshot;
mod golden;
mod replay;
#[derive(Parser)]
#[command(
name = "test-client",
about = "Test client for the Settled Reach simulation server"
)]
struct Cli {
/// Server address (host:port)
#[arg(long, default_value = "127.0.0.1:9876")]
connect: String,
/// JSONL replay file (one JSON array of PlayerInput per tick)
#[arg(long)]
replay: Option<PathBuf>,
/// Output format: structured text to stdout (default)
#[arg(long)]
text: bool,
/// Output format: JSON snapshots to stdout
#[arg(long)]
json: bool,
/// No output (CI assertions only)
#[arg(long)]
quiet: bool,
/// Compare final snapshot against golden JSON file, exit 1 on diff
#[arg(long)]
golden: Option<PathBuf>,
/// Disconnect after N ticks
#[arg(long)]
ticks: Option<u64>,
}
enum OutputMode {
Text,
Json,
Quiet,
}
fn main() {
let cli = Cli::parse();
let output_mode = if cli.quiet {
OutputMode::Quiet
} else if cli.json {
OutputMode::Json
} else {
OutputMode::Text
};
// Connect to server
let stream = match TcpStream::connect(&cli.connect) {
Ok(s) => s,
Err(e) => {
eprintln!("Connection error: {} (address: {})", e, cli.connect);
process::exit(2);
}
};
let mut reader = BufReader::new(stream.try_clone().unwrap_or_else(|e| {
eprintln!("Failed to clone TCP stream: {}", e);
process::exit(2);
}));
let mut writer = BufWriter::new(stream);
// Load replay inputs if provided
let replay_inputs = cli.replay.as_ref().map(|path| {
replay::load_replay(path).unwrap_or_else(|e| {
eprintln!("Replay load error: {}", e);
process::exit(2);
})
});
let mut tick_count: u64 = 0;
let mut last_snapshot: Option<ObserverSnapshot> = None;
loop {
// Receive snapshot (blocking read)
let payload = match read_framed(&mut reader) {
Ok(Some(data)) => data,
Ok(None) => {
// Server closed connection — normal shutdown
break;
}
Err(e) => {
eprintln!("Read error: {}", e);
process::exit(2);
}
};
let snapshot: ObserverSnapshot = match rmp_serde::from_slice(&payload) {
Ok(s) => s,
Err(e) => {
eprintln!("Deserialization error: {}", e);
process::exit(2);
}
};
tick_count += 1;
// Output snapshot
match output_mode {
OutputMode::Text => {
print!("{}", format_snapshot_text(&snapshot));
}
OutputMode::Json => {
let json = serde_json::to_string_pretty(&snapshot).unwrap();
println!("{}", json);
}
OutputMode::Quiet => {}
}
last_snapshot = Some(snapshot);
// Check tick limit before sending next input
if let Some(max_ticks) = cli.ticks {
if tick_count >= max_ticks {
break;
}
}
// Send inputs for next tick
let inputs = replay_inputs
.as_ref()
.and_then(|r| r.get(tick_count as usize - 1))
.cloned()
.unwrap_or_default();
let input_payload = rmp_serde::to_vec(&inputs).unwrap_or_else(|e| {
eprintln!("Input serialization error: {}", e);
process::exit(2);
});
if let Err(e) = write_framed(&mut writer, &input_payload) {
eprintln!("Write error: {}", e);
process::exit(2);
}
}
// Golden file comparison
if let Some(golden_path) = &cli.golden {
let snapshot = match last_snapshot {
Some(s) => s,
None => {
eprintln!("No snapshot received for golden comparison");
process::exit(2);
}
};
let diffs = golden::compare_golden(golden_path, &snapshot).unwrap_or_else(|e| {
eprintln!("Golden file error: {}", e);
process::exit(2);
});
if !diffs.is_empty() {
eprintln!("GOLDEN FILE MISMATCH: {}", golden_path.display());
for diff in &diffs {
eprintln!(" {}", diff);
}
process::exit(1);
}
}
}
+24
View File
@@ -0,0 +1,24 @@
// 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.
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)
}