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:
+147
-56
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user