From ed7498cb24d43c6521750c7cf04d311b656bb5b1 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 17 Feb 2026 17:41:05 +0100 Subject: [PATCH] feat(simulation): add --test-mode, --port, --seed CLI flags Add CLI argument parsing for test infrastructure: --test-mode enables deterministic seed (42) and warn-level tracing to stderr, --port allows OS-assigned ports (--port 0), --seed overrides RNG seed. Prints LISTENING:{port} to stdout after bind for test harness discovery. Extracts setup_proof_room() for reuse. Fixes #459. Co-Authored-By: Claude Opus 4.6 --- server/src/main.rs | 203 ++++++++++++++++++++++++++++++++------------- 1 file changed, 147 insertions(+), 56 deletions(-) diff --git a/server/src/main.rs b/server/src/main.rs index acaf1266f..bb984eb74 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,66 +1,180 @@ // The Settled Reach - Simulation Server // Entry point for standalone simulation binary +// +// Supports --test-mode for automated testing: +// --test-mode Enable test mode (fixed seed, LISTENING signal, quieter logs) +// --port Bind to specific port (0 = OS-assigned). Overrides positional addr. +// --seed RNG seed (default: 0, test-mode default: 42) use bevy_app::prelude::*; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use settled_reach_server::bridge::tcp::TcpBridge; use settled_reach_server::bridge::{BridgePlugin, BridgeResource, ServerRunning}; -use settled_reach_server::knowledge::registry::EntityRegistry; -use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin}; -use settled_reach_server::npc::relationships::{RelationshipEdge, RelationshipGraph}; -use settled_reach_server::npc::{ - Contentment, DailyRoutine, Npc, NpcPlugin, RelationshipKind, RoutineEntry, ToleranceThreshold, - Want, WantKind, -}; -use settled_reach_server::perception::cognitive_delay::CognitiveDelay; -use settled_reach_server::perception::vision_cone::Facing; -use settled_reach_server::simulation::interaction::{Interactable, NearbyInteractionBuffer}; -use settled_reach_server::simulation::listening::ListeningFocus; -use settled_reach_server::simulation::monologue::{ - MonologueBuffer, MonologueState, SprintAnomalyQueue, -}; -use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; -use settled_reach_server::simulation::path_follow::MovementSpeed; -use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown}; -use settled_reach_server::simulation::time::DayPhase; use settled_reach_server::simulation::SimulationPlugin; fn main() { - // Initialize tracing subscriber for logging + let args: Vec = std::env::args().collect(); + let test_mode = args.iter().any(|a| a == "--test-mode"); + + let port_flag = args + .iter() + .position(|a| a == "--port") + .and_then(|i| args.get(i + 1)) + .and_then(|s| s.parse::().ok()); + + let seed_flag = args + .iter() + .position(|a| a == "--seed") + .and_then(|i| args.get(i + 1)) + .and_then(|s| s.parse::().ok()); + + // Tracing: quieter in test mode, always to stderr so stdout stays clean + // for the LISTENING:{port} handshake signal. + let default_filter = if test_mode { + "settled_reach_server=warn" + } else { + "settled_reach_server=debug" + }; tracing_subscriber::registry() .with( tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "settled_reach_server=debug".into()), + .unwrap_or_else(|_| default_filter.into()), ) - .with(tracing_subscriber::fmt::layer()) + .with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr)) .init(); - let addr = std::env::args() - .nth(1) - .or_else(|| std::env::var("SR_ADDR").ok()) - .unwrap_or_else(|| "127.0.0.1:9876".to_string()); + // Resolve bind address. + // --port flag overrides everything (most common in test mode). + // Otherwise: positional arg > SR_ADDR env > default. + let addr = if let Some(port) = port_flag { + format!("127.0.0.1:{}", port) + } else { + // Find positional address arg, skipping flags and their values. + let positional = { + let mut skip_next = false; + let mut found = None; + for arg in args.iter().skip(1) { + if skip_next { + skip_next = false; + continue; + } + if arg == "--port" || arg == "--seed" { + skip_next = true; + continue; + } + if arg.starts_with("--") { + continue; + } + found = Some(arg.clone()); + break; + } + found + }; + positional + .or_else(|| std::env::var("SR_ADDR").ok()) + .unwrap_or_else(|| "127.0.0.1:9876".to_string()) + }; - tracing::info!("The Settled Reach - Simulation Server starting"); - tracing::info!("Waiting for client connection on {}", addr); + // Bind FIRST, print port, THEN accept. + // Critical for --port 0: the OS assigns a random port at bind time. + // The LISTENING:{port} line is the handshake signal for the test client. + let listener = std::net::TcpListener::bind(&addr).unwrap_or_else(|e| { + eprintln!("Failed to bind {}: {}", addr, e); + std::process::exit(1); + }); + let actual_port = listener.local_addr().unwrap().port(); - let bridge = TcpBridge::accept(&addr).unwrap_or_else(|e| { - tracing::error!("Failed to accept client connection on {}: {}", addr, e); + // LISTENING signal to stdout. The test client parses this to discover the port. + // All tracing goes to stderr (see .with_writer above), so stdout is clean. + println!("LISTENING:{}", actual_port); + { + use std::io::Write; + std::io::stdout().flush().ok(); + } + + tracing::info!("Waiting for client connection on port {}", actual_port); + let bridge = TcpBridge::accept_on(listener).unwrap_or_else(|e| { + tracing::error!("Failed to accept: {}", e); std::process::exit(1); }); tracing::info!("Client connected, initializing simulation"); - // Create the bevy App and add plugins + // RNG seed: test-mode defaults to 42 for deterministic replay + let seed = seed_flag.unwrap_or(if test_mode { 42 } else { 0 }); + let mut app = App::new(); app.add_plugins(SimulationPlugin); app.add_plugins(BridgePlugin); - app.add_plugins(KnowledgePlugin); - app.add_plugins(NpcPlugin); + app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin); + app.add_plugins(settled_reach_server::npc::NpcPlugin); app.add_plugins(settled_reach_server::content::ContentPlugin); app.insert_resource(BridgeResource::new(bridge)); + + // Override SimRng with the chosen seed (SimulationPlugin defaults to seed 0) + app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(seed)); + + if test_mode { + // Gauntlet content: deferred until Gauntlet loader exists. + // For now, fall back to the proof room setup. + setup_proof_room(&mut app); + } else { + setup_proof_room(&mut app); + } + + tracing::info!( + "Simulation initialized (seed={}, test_mode={})", + seed, + test_mode + ); + + // Game loop: run until client disconnects. + // Targets ~20 ticks/sec (2 game-minutes/sec). The TCP bridge uses + // non-blocking reads, so without throttling this loop would spin. + // Remaining frame budget is available for NPC AI and pathfinding. + let target_frame_time = std::time::Duration::from_millis(50); + loop { + let frame_start = std::time::Instant::now(); + + app.update(); + if !app.world().resource::().0 { + break; + } + + let elapsed = frame_start.elapsed(); + if elapsed < target_frame_time { + std::thread::sleep(target_frame_time - elapsed); + } + } + + tracing::info!("Simulation server shutting down"); +} + +/// Proof room: 32x32 map, wall at (16,14), player at (16,16), 3 NPCs. +/// Extracted from the original inline setup for reuse by both test-mode and normal mode. +fn setup_proof_room(app: &mut App) { + use settled_reach_server::knowledge::registry::EntityRegistry; + use settled_reach_server::knowledge::KnowledgeGraph; + use settled_reach_server::npc::relationships::{RelationshipEdge, RelationshipGraph}; + use settled_reach_server::npc::{ + Contentment, DailyRoutine, Npc, RelationshipKind, RoutineEntry, ToleranceThreshold, Want, + WantKind, + }; + use settled_reach_server::perception::cognitive_delay::CognitiveDelay; + use settled_reach_server::perception::vision_cone::Facing; + use settled_reach_server::simulation::interaction::{Interactable, NearbyInteractionBuffer}; + use settled_reach_server::simulation::listening::ListeningFocus; + use settled_reach_server::simulation::monologue::{ + MonologueBuffer, MonologueState, SprintAnomalyQueue, + }; + use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; + use settled_reach_server::simulation::path_follow::MovementSpeed; + use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown}; + use settled_reach_server::simulation::time::DayPhase; + app.insert_resource(WalkabilityMap::new(32, 32, 1)); - // Proof room: wall at (16,14) between player and NPC 1 + // Wall at (16,14) between player and NPC 1 { let mut wm = app.world_mut().resource_mut::(); wm.set_walkable(&TilePosition::new(16, 14, 0), false); @@ -223,27 +337,4 @@ fn main() { } app.insert_resource(registry); - - tracing::info!("Simulation initialized, entering game loop"); - - // Game loop: run until client disconnects. - // Targets ~20 ticks/sec (2 game-minutes/sec). The TCP bridge uses - // non-blocking reads, so without throttling this loop would spin. - // Remaining frame budget is available for NPC AI and pathfinding. - let target_frame_time = std::time::Duration::from_millis(50); - loop { - let frame_start = std::time::Instant::now(); - - app.update(); - if !app.world().resource::().0 { - break; - } - - let elapsed = frame_start.elapsed(); - if elapsed < target_frame_time { - std::thread::sleep(target_frame_time - elapsed); - } - } - - tracing::info!("Simulation server shutting down"); }