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 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 17:41:05 +01:00
co-authored by Claude Opus 4.6
parent af8e20ab9a
commit ed7498cb24
+147 -56
View File
@@ -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 <PORT> Bind to specific port (0 = OS-assigned). Overrides positional addr.
// --seed <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<String> = 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::<u16>().ok());
let seed_flag = args
.iter()
.position(|a| a == "--seed")
.and_then(|i| args.get(i + 1))
.and_then(|s| s.parse::<u64>().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::<ServerRunning>().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::<WalkabilityMap>();
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::<ServerRunning>().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");
}