// 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) // --dump-schedule Print bevy_ecs schedule graph and exit (no TCP required) 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, HandshakeState, ServerRunning}; use settled_reach_server::simulation::SimulationPlugin; fn main() { let args: Vec = std::env::args().collect(); let test_mode = args.iter().any(|a| a == "--test-mode"); let dump_schedule = args.iter().any(|a| a == "--dump-schedule"); 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. // CI=true → JSON format for structured log ingestion. // RUST_LOG_FORMAT=json → same effect for local debugging. let default_filter = if test_mode { "settled_reach_server=warn" } else { "settled_reach_server=debug" }; let env_filter = tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| default_filter.into()); let use_json = std::env::var("CI").is_ok() || std::env::var("RUST_LOG_FORMAT").as_deref() == Ok("json"); if use_json { tracing_subscriber::registry() .with(env_filter) .with( tracing_subscriber::fmt::layer() .json() .with_writer(std::io::stderr), ) .init(); } else { tracing_subscriber::registry() .with(env_filter) .with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr)) .init(); } // 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()) }; // --dump-schedule: print bevy_ecs schedule graph and exit (no TCP required). // Useful for PR artifacts and detecting unintended system reordering (#346). if dump_schedule { dump_schedule_graph(); return; } // 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(); // 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, sending protocol handshake"); // Protocol handshake: first framed message on the wire (#555). // Client reads this and validates protocol_version before sending any input. use settled_reach_server::bridge::SimBridge; bridge.send_handshake().unwrap_or_else(|e| { tracing::error!("Failed to send handshake: {}", e); std::process::exit(1); }); // Read client's startup message containing world_seed (#175). // Client sends this immediately after validating the handshake. let startup = bridge.receive_startup().unwrap_or_else(|e| { tracing::error!("Failed to receive startup message: {}", e); std::process::exit(1); }); tracing::info!("Handshake complete, initializing simulation"); // RNG seed: --seed flag overrides client's world_seed (useful for testing). // Production: client sends world_seed via StartupMessage (#175). // Test mode default: 42 for deterministic replay. let seed = seed_flag.unwrap_or(if test_mode { 42 } else { startup.world_seed }); let mut app = App::new(); app.add_plugins(SimulationPlugin); app.add_plugins(BridgePlugin); app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin); app.add_plugins(settled_reach_server::npc::NpcPlugin); app.add_plugins(settled_reach_server::storyteller::StorytellerPlugin); app.add_plugins(settled_reach_server::settings::SettingsPlugin); // Initialize SQLite settings store (#627). // Path: alongside save files in the server's working directory. let settings_path = std::path::PathBuf::from("settings.db"); match settled_reach_server::settings::SettingsStore::open(&settings_path) { Ok(store) => { tracing::info!("Settings store opened: {:?}", settings_path); app.insert_resource(settled_reach_server::settings::SettingsStoreResource::new( store, "default".to_string(), )); } Err(e) => { tracing::error!( "Failed to open settings store: {}. Settings will not persist.", e ); } } app.insert_resource(BridgeResource::new(bridge)); app.insert_resource(HandshakeState::Complete); // Override SimRng with the chosen seed (SimulationPlugin defaults to seed 0) app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(seed)); // Initialize empty line pool index (populated by generator pipeline in v0.2). app.insert_resource( settled_reach_server::simulation::line_pool::LinePoolIndexResource( settled_reach_server::simulation::line_pool::LinePoolIndex::default(), ), ); // Character archetype from client's StartupMessage (#587). let archetype = startup.character_archetype; // Gauntlet test world for --test-mode, proof room for normal mode. if test_mode { #[cfg(feature = "gauntlet")] settled_reach_server::test_world::setup_gauntlet(&mut app, archetype); #[cfg(not(feature = "gauntlet"))] { eprintln!("--test-mode requires the 'gauntlet' feature"); std::process::exit(1); } } else { setup_proof_room(&mut app, archetype); } 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. // // Panic supervision (#85): each tick is wrapped in catch_unwind. // On panic, the server sends a structured SimError to the client // before shutting down, rather than an abrupt disconnect. let target_frame_time = std::time::Duration::from_millis(50); loop { let frame_start = std::time::Instant::now(); // Wrap app.update() in catch_unwind to handle system panics (#85). // AssertUnwindSafe is required because App is not UnwindSafe. let tick_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { app.update(); })); match tick_result { Ok(()) => {} Err(panic_payload) => { // Extract panic message for error reporting let panic_msg = if let Some(s) = panic_payload.downcast_ref::<&str>() { s.to_string() } else if let Some(s) = panic_payload.downcast_ref::() { s.clone() } else { "unknown panic".to_string() }; tracing::error!("Simulation panic caught: {}", panic_msg); // Attempt to send a final SimError snapshot to the client. // Best-effort: if the bridge is unavailable, we just log and exit. send_panic_error(&app, &panic_msg); tracing::error!("Server shutting down after panic"); break; } } if !app.world().resource::().0 { break; } let elapsed = frame_start.elapsed(); tracing::debug!( tick_ms = elapsed.as_millis(), budget_ms = target_frame_time.as_millis(), over_budget = elapsed > target_frame_time, "tick" ); if elapsed < target_frame_time { std::thread::sleep(target_frame_time - elapsed); } } tracing::info!("Simulation server shutting down"); } /// Best-effort: send a final SimError snapshot to the client on panic (#85). /// /// Builds a minimal ObserverSnapshot with the panic error and sends it /// through the bridge. If the bridge is unavailable or sending fails, /// the error is logged but not fatal (we're already crashing). fn send_panic_error(app: &App, panic_msg: &str) { use settled_reach_server::bridge::types::*; use settled_reach_server::simulation::time::{DayPhase, TickRate}; let world = app.world(); // Try to read current tick from SimulationTime let tick = world .get_resource::() .map(|t| t.tick) .unwrap_or(0); let bridge = match world.get_resource::() { Some(b) => b, None => { tracing::error!("Cannot send panic error: no BridgeResource"); return; } }; // Build a minimal snapshot carrying the panic error let snapshot = ObserverSnapshot { version: PROTOCOL_VERSION, tick, game_time: GameTime { day: 0, time_of_day: 0, day_phase: DayPhase::Morning, tick_rate: TickRate::Paused, }, player_facing: FacingDirection::North, player_stance: MovementStance::default(), player_inventory: vec![], entities: vec![], visible_tiles: vec![], nearby_interactions: vec![], current_monologue: None, pending_recognitions: vec![], dialogue_response: None, blocked_entities: vec![], scan_events: vec![], sound_events: vec![], conversation_events: vec![], conversation_ended: vec![], follow_state: None, character_pressure: None, rng_seed: None, poi_list: vec![], examine_result: None, player_knowledge: None, save_result: None, triangle_crisis_events: vec![], state_hash: None, debug_response: None, current_ticker: None, settings_response: None, sim_errors: vec![SimError { kind: SimErrorKind::Panic, message: format!("Simulation panic: {}", panic_msg), tick, }], }; if let Err(e) = bridge.send_snapshot(&snapshot) { tracing::error!("Failed to send panic error to client: {}", e); } else { tracing::info!("Sent panic SimError to client at tick {}", tick); } } /// Print bevy_ecs schedule graph and exit. /// Invoked by --dump-schedule CLI flag (#346). /// /// Builds the full app with all plugins (no TCP bridge or world entities), /// then prints each registered schedule and its system count to stdout. /// Systems are counted from the registered (pre-initialization) graph, so /// counts reflect what was registered by plugins. /// /// CI integration: run on each PR via `make debug-schedule`, diff output /// against a committed baseline to catch unintended system reordering. fn dump_schedule_graph() { use bevy_ecs::schedule::Schedules; let mut app = App::new(); app.add_plugins(SimulationPlugin); app.add_plugins(BridgePlugin); app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin); app.add_plugins(settled_reach_server::npc::NpcPlugin); app.add_plugins(settled_reach_server::storyteller::StorytellerPlugin); app.add_plugins(settled_reach_server::settings::SettingsPlugin); app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(0)); // Access Schedules resource directly — schedules are populated by plugins // via add_systems() before any tick runs. No app.update() needed here: // running a tick would require full world setup (WalkabilityMap, etc.) that // isn't needed for schedule inspection. let world = app.world(); let schedules = world.resource::(); println!("=== Schedule Graph (settled-reach-server) ==="); let mut entries: Vec = schedules .iter() .map(|(label, schedule)| format!(" {:?} [{} systems]", label, schedule.systems_len())) .collect(); entries.sort(); // deterministic output for baseline diffs let schedule_count = entries.len(); for entry in &entries { println!("{}", entry); } println!("=== {} schedules total ===", schedule_count); println!(); println!("Note: use RUST_LOG=trace with the live server for per-tick timing."); println!(" system names visible with `cargo build --features bevy/debug`."); } /// 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, archetype: settled_reach_server::bridge::types::CharacterArchetype, ) { 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)); // 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); } let mut registry = EntityRegistry::new(0); // Player at (16,16) — archetype from StartupMessage (#587, D-053) let profile = match archetype { settled_reach_server::bridge::types::CharacterArchetype::Smuggler => { MovementProfile::smuggler() } settled_reach_server::bridge::types::CharacterArchetype::Detective => { MovementProfile::detective() } }; let monologue_state = MonologueState { character: archetype.as_monologue_key().to_string(), ..Default::default() }; let player = app .world_mut() .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing::default(), KnowledgeGraph::new(), NearbyInteractionBuffer::default(), monologue_state, MonologueBuffer::default(), SprintAnomalyQueue::default(), CognitiveDelay::default(), ListeningFocus::new(TilePosition::new(16, 16, 0)), archetype, profile, profile.initial_stance(), PlayerMoveCooldown::default(), )) .id(); registry.register(player); // NPC 1: Dock worker at (16,13) — behind wall, full routine let npc1 = app .world_mut() .spawn(( Npc, Interactable, TilePosition::new(16, 13, 0), Want { primary: WantKind::Wealth, intensity: 6, description: "Wants a bigger share of docking fees".into(), }, DailyRoutine { entries: vec![ RoutineEntry { phase: DayPhase::Morning, location: TilePosition::new(16, 13, 0), activity: "Prep cargo bay".into(), }, RoutineEntry { phase: DayPhase::Afternoon, location: TilePosition::new(20, 10, 0), activity: "Unload freight".into(), }, RoutineEntry { phase: DayPhase::Evening, location: TilePosition::new(10, 20, 0), activity: "Drink at canteen".into(), }, RoutineEntry { phase: DayPhase::Night, location: TilePosition::new(16, 13, 0), activity: "Sleep in bunk".into(), }, ], description: "Dock worker shift pattern".into(), }, Contentment { level: 20 }, ToleranceThreshold { current_stress: 30, threshold: 70, }, MovementSpeed::new(2), )) .id(); let npc1_sid = registry.register(npc1); // NPC 2: Field tech at (14,18) — visible to player, has routine let npc2 = app .world_mut() .spawn(( Npc, Interactable, TilePosition::new(14, 18, 0), Want { primary: WantKind::Knowledge, intensity: 8, description: "Obsessed with pre-Collapse sensor arrays".into(), }, DailyRoutine { entries: vec![ RoutineEntry { phase: DayPhase::Morning, location: TilePosition::new(14, 18, 0), activity: "Calibrate instruments".into(), }, RoutineEntry { phase: DayPhase::Afternoon, location: TilePosition::new(22, 22, 0), activity: "Field survey".into(), }, ], description: "Field tech survey pattern".into(), }, Contentment { level: 45 }, ToleranceThreshold { current_stress: 10, threshold: 60, }, MovementSpeed::default(), )) .id(); let npc2_sid = registry.register(npc2); // NPC 3: Guard at (18,14) — stationary, no routine let npc3 = app .world_mut() .spawn(( Npc, Interactable, TilePosition::new(18, 14, 0), Want { primary: WantKind::Safety, intensity: 4, description: "Wants a quiet shift".into(), }, Contentment { level: -5 }, ToleranceThreshold { current_stress: 45, threshold: 55, }, )) .id(); let npc3_sid = registry.register(npc3); // Populate RelationshipGraph with a few edges { let mut rel_graph = app.world_mut().resource_mut::(); // Dock worker and guard are colleagues with moderate trust rel_graph.set_relationship( npc1_sid, npc3_sid, RelationshipEdge { kind: RelationshipKind::Colleague, trust: 3, history: vec![], last_interaction_tick: 0, }, ); // Guard distrusts the field tech (rival for resources) rel_graph.set_relationship( npc3_sid, npc2_sid, RelationshipEdge { kind: RelationshipKind::Rival, trust: -4, history: vec![], last_interaction_tick: 0, }, ); } app.insert_resource(registry); }