// 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, /// 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, /// Disconnect after N ticks #[arg(long)] ticks: Option, } 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 = 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); } } }