// 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, ConnectionListener, ConnectionRole, 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); // D-254 §2/T-1130: the FIRST connection is still accepted here, exactly // as before — one blocking listener.accept() call, byte-identical to // pre-D-254 behavior when nobody else ever connects. What changes is // AFTER: the listener is set non-blocking and handed to // ConnectionListener (inserted below) so accept_new_connections can // keep accepting additional connections once the tick loop starts, // instead of the original bug where a second accept() call never // happened at all and a second client hung forever. // // accept_on() consumes the listener; clone it first so both the first // accept AND the later non-blocking accept-loop have a working handle // on the same underlying socket (TcpListener::try_clone shares the fd, // not a new listener — connections queued on either handle are visible // to both, same as TcpStream::try_clone is already used for read/write // halves throughout this bridge). let listener_for_loop = listener.try_clone().unwrap_or_else(|e| { tracing::error!("Failed to clone listener for accept-loop: {}", e); std::process::exit(1); }); 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). // Carries no version field (D-192 dropped the PROTOCOL_VERSION lockstep); // the client reads it and replies with its StartupMessage. 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(); settled_reach_server::tick_phases::TickPhase::configure(&mut app); app.add_plugins(SimulationPlugin { seed }); 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.add_plugins(settled_reach_server::bookmark::BookmarkPlugin::default()); app.add_plugins(settled_reach_server::atlas::GenerationPlugin); // Initialize culture resolver (#679, D-128). // systems.db is shipped read-only alongside the binary. let systems_db_path = resolve_systems_db_path(); match settled_reach_server::knowledge::CultureResolver::open(&systems_db_path) { Ok(resolver) => { tracing::info!("Culture resolver opened: {:?}", systems_db_path); app.insert_resource(settled_reach_server::knowledge::CultureResolverResource( resolver, )); } Err(e) => { tracing::warn!( "Culture resolver unavailable ({}). Culture lookups will not work.", e ); } } // Mod-first body source resolver for the atlas layer proxy (#969, D-225). // terrain_reference is repo-root-relative; the repo root is systems.db's // 3rd ancestor (/server/data/systems.db). This ancestor arithmetic // is only correct against an ABSOLUTE path — `.canonicalize()` resolves a // *relative* path against the CURRENT CWD, so if `systems_db_path` were // still cwd-relative (as it was before `resolve_systems_db_path()`, T-1131 // follow-up), a wrong-cwd launch (e.g. the D-254 companion spawning from // the repo root) would make `.canonicalize()` fail outright, falling back // to the nonsense `".."` default below. `resolve_systems_db_path()` // already verified `systems_db_path` exists before returning it, so // `.canonicalize()` here always succeeds and `nth(3)` is genuinely correct // — not "pretends to work by accident when cwd happens to be server/". let world_root = systems_db_path .canonicalize() .ok() .and_then(|p| p.ancestors().nth(3).map(std::path::Path::to_path_buf)) .unwrap_or_else(|| std::path::PathBuf::from("..")); match settled_reach_server::atlas::source_resolver::BodySourceResolver::open( &systems_db_path, vec![world_root.clone()], ) { Ok(resolver) => { tracing::info!("Body source resolver opened (root: {:?})", world_root); app.insert_resource( settled_reach_server::atlas::source_resolver::BodySourceResolverResource(resolver), ); } Err(e) => tracing::warn!( "Body source resolver unavailable ({}). Atlas layer requests will error.", e ), } // Star-map dataset proxy (T-949a): resolve the repo-root-relative path to // the client's pre-generated star_map_data.json (tooling/generate-star-map-data.py). // Read fresh on every request — no caching, see atlas_data_proxy module doc. let star_map_data_path = world_root.join("client/data/star_map_data.json"); if star_map_data_path.exists() { tracing::info!("Star map data path resolved: {:?}", star_map_data_path); } else { tracing::warn!( "star_map_data.json not found at {:?}. Star map requests will error until it exists.", star_map_data_path ); } app.insert_resource( settled_reach_server::atlas::atlas_data_proxy::StarMapDataPath(star_map_data_path), ); // Settlement reader for Layer-3 placement (#955): reads a body's settlements // from systems.db on a cache miss so the cascade work item stays DB-free. match settled_reach_server::atlas::city_context_reader::CityContextReader::open( &systems_db_path, ) { Ok(reader) => { tracing::info!("City context reader opened: {:?}", systems_db_path); app.insert_resource( settled_reach_server::atlas::city_context_reader::CityContextReaderResource(reader), ); } Err(e) => tracing::warn!( "City context reader unavailable ({}). Settlements will not be placed.", e ), } // Data browser reader (D-254 §4, T-1131): read-only access to the six v1 // registry-tier entity kinds (star systems, bodies, stations, // corporations, commodities, trait templates) for the companion app's // browse UI. Absent -> BrowseRequests are answered with an Error status // per request rather than a hard failure (matches every other reader's // "unavailable, log + degrade" convention below). match settled_reach_server::atlas::browse_reader::BrowseReader::open(&systems_db_path) { Ok(reader) => { tracing::info!("Browse reader opened: {:?}", systems_db_path); app.insert_resource( settled_reach_server::atlas::browse_reader::BrowseReaderResource(reader), ); } Err(e) => tracing::warn!( "Browse reader unavailable ({}). Browse requests will error.", e ), } // Body physical params reader for DistrictProfile carrier layer (T-1032, D-239 §1, D-240): // reads hydrosphere / atmosphere / planet_class on a cache miss so the Rayon // cascade work item stays DB-free (D-225 pattern). // D-240: orbit/star fields are non-canonical and are no longer read. match settled_reach_server::atlas::body_params_reader::BodyParamsReader::open(&systems_db_path) { Ok(reader) => { tracing::info!("Body params reader opened: {:?}", systems_db_path); app.insert_resource( settled_reach_server::atlas::body_params_reader::BodyParamsReaderResource(reader), ); } Err(e) => tracing::warn!( "Body params reader unavailable ({}). DistrictProfile layer will be skipped.", e ), } // D-232 trait-template catalog reader (T-994): reads `trait_templates` + // `atlas_body_trait_bias` on a body-analysis completion so the L3→L4 dispatch // aggregation (atlas::plugin::drain_generation_completions) can run the // three-phase draw. Absent → trait_selection stays empty for every body // (the pre-T-994 degenerate behaviour), not a hard failure. match settled_reach_server::atlas::trait_catalog_reader::TraitCatalogReader::open( &systems_db_path, ) { Ok(reader) => { tracing::info!("Trait catalog reader opened: {:?}", systems_db_path); app.insert_resource( settled_reach_server::atlas::trait_catalog_reader::TraitCatalogReaderResource( reader, ), ); } Err(e) => tracing::warn!( "Trait catalog reader unavailable ({}). Architecture-flavor draw will stay empty.", e ), } // 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 ); } } // D-254 §2/T-1130: the FIRST connection's role, exactly as it does for // every later accept-loop connection (main.rs's accept-loop handles // connections 2+; this handles the honest first-connection case a // spawn-mode Reader server actually needs — D-254 §1's spawn-mode // Atlas companion connects as the ONLY connection to a freshly-spawned // server, so "first connection" and "Reader" are not mutually // exclusive). `BridgeResource::default()` + explicit insert_player/ // insert_reader replaces the old unconditional `BridgeResource::new` // (which always meant "install as Player" — there was no other role // before this ticket). let mut bridge_resource = BridgeResource::default(); match startup.role { ConnectionRole::Player => { bridge_resource.insert_player(bridge); } ConnectionRole::Reader => { tracing::info!( "first connection is a Reader (D-254 §1 spawn-mode) — no character will be spawned for it" ); bridge_resource.insert_reader(bridge); } } app.insert_resource(bridge_resource); app.insert_resource(HandshakeState::Complete); // D-254 §2/T-1130: wire the cloned listener non-blocking so // accept_new_connections (BridgePlugin, PreInput) can accept additional // connections every tick without ever blocking the tick loop. This is // the actual fix for the original starvation bug — before this, there // was exactly one listener.accept() call in the whole process lifetime. listener_for_loop.set_nonblocking(true).unwrap_or_else(|e| { tracing::error!("Failed to set accept-loop listener non-blocking: {}", e); std::process::exit(1); }); app.insert_resource(ConnectionListener(Some(listener_for_loop))); // SimulationPlugin { seed } already inserts SimRng with the correct seed // during plugin build. We re-insert here as a defensive override for one // specific ordering risk: any future plugin that registers *before* // SimulationPlugin in `App::add_plugins` order (e.g. a pre-simulation // observability plugin) and consumes SimRng at plugin build time would // see a stale resource that was never seeded from StartupMessage. This // `insert_resource` call happens AFTER all plugins have built, so it // always overwrites whatever SimRng is currently in the world with the // authoritative value from the StartupMessage. If you remove this line, // also audit every `app.add_plugins(...)` call in this file and in // `SimulationPlugin::build` for plugins that touch SimRng, and verify // none of them run before SimulationPlugin's seeding logic. #826 thread. 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(), ), ); // Gauntlet test world for --test-mode, proof room for normal mode. // // D-254 §2/T-1130 scope note: this call is NOT gated on the first // connection's role, even though it spawns a `PlayerCharacter` entity // unconditionally. A Reader-only spawned server (D-254 §1 spawn-mode) // therefore has an inert, unpiloted `PlayerCharacter` entity sitting in // its ECS world — nothing drives it (no Player connection exists to // send it inputs), and the Reader never learns it exists: `send_ // bridge_snapshot` routes `ObserverSnapshot` to the Player connection // ONLY and is a documented no-op with no Player installed (see // `bridge::send_bridge_snapshot`), so this entity's existence has no // observable effect on a Reader-only session. Splitting character-spawn // out of `setup_proof_room`/`setup_gauntlet` (both of which also wire // NPCs, the walkability map, and the relationship graph — genuinely // "whole world setup", not just "spawn the player") into an optional // step is real refactoring work, correctly out of scope for this // gating ticket; tracked as follow-up, not required for the D-010 // information-boundary guarantee this ticket exists to establish. if test_mode { #[cfg(feature = "gauntlet")] settled_reach_server::test_world::setup_gauntlet(&mut app); #[cfg(not(feature = "gauntlet"))] { eprintln!("--test-mode requires the 'gauntlet' feature"); std::process::exit(1); } } else { setup_proof_room(&mut app, seed); } 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"); } /// Candidate `systems.db` paths, in try-order, for a given executable path /// (T-1131 follow-up). Pure/no I/O — the ONLY thing that makes this /// deterministic and unit-testable given `exe_path` (unlike /// [`resolve_systems_db_path`], which additionally calls /// `std::env::current_exe()` and stats the filesystem). Kept separate /// specifically so the candidate ORDER and SHAPE can be tested without a /// process-spawning harness — see `tests::` below. /// /// 1. Exe-anchored `/../../data/systems.db` (unjoined — the caller /// canonicalizes and existence-checks; this function never touches disk). /// Only present if `exe_path` has a parent directory. /// 2. Cwd-relative `data/systems.db` (today's pre-fix behavior — /// `cd server && cargo run` leaves cwd at `server/`). /// 3. Cwd-relative `server/data/systems.db` (repo-root invocations). fn systems_db_candidates(exe_path: Option<&std::path::Path>) -> Vec { let mut candidates = Vec::with_capacity(3); if let Some(exe_dir) = exe_path.and_then(std::path::Path::parent) { candidates.push(exe_dir.join("../../data/systems.db")); } candidates.push(std::path::PathBuf::from("data/systems.db")); candidates.push(std::path::PathBuf::from("server/data/systems.db")); candidates } /// Resolve `systems.db`'s path, ANCHORED TO THE EXECUTABLE rather than the /// current working directory (T-1131 follow-up). /// /// **The bug this fixes:** `PathBuf::from("data/systems.db")` is cwd-relative. /// `make game` (`cd server && cargo run`) happens to leave cwd at `server/`, /// so that path resolves — but the D-254 companion app spawns this binary via /// Godot's `OS.create_process`/`OS.execute_with_pipe` (`server_process.gd`), /// neither of which sets a working directory: the child inherits GODOT's cwd, /// which for `make atlas`/`make game` is the REPO ROOT (the Makefile has no /// `cd` before launching Godot itself — only before `cargo run`). From the /// repo root, `data/systems.db` doesn't exist (it's `server/data/systems.db`), /// so every DB-backed reader (`CultureResolver`, `CityContextReader`, and now /// `BrowseReader`) silently fails to open in every spawned-server context. /// This went unnoticed through T-1130's wave 1 because the star map is served /// from `star_map_data.json` via `world_root` (itself derived from /// `systems_db_path`, so ALSO broken — but `.exists()`-checked with a `warn`, /// not a hard dependency any single-connection smoke test would surface) — /// "renders 301 systems" never actually touched `systems.db`. /// /// **The fix:** resolve relative to `std::env::current_exe()` first — in the /// dev build layout the binary is `server/target/debug/settled-reach-server`, /// so `exe_dir/../../data/systems.db` is `server/data/systems.db` regardless /// of cwd. Falls through to the two cwd-relative candidates (today's /// behavior, and the repo-root equivalent) so `cd server && cargo run` and a /// repo-root-relative invocation both keep working without needing the /// exe-anchoring to succeed (e.g. `current_exe()` can fail in exotic /// sandboxed environments per its own documented caveats). /// /// Candidate order/shape lives in [`systems_db_candidates`] (pure, /// unit-tested); this function adds the I/O layer: canonicalize + existence /// check per candidate, first EXISTING one wins, with an `info` log /// recording which candidate resolved (so a future "browse reader /// unavailable" report is diagnosable from the startup log alone). /// /// If none exist, returns the cwd-relative `data/systems.db` default — /// today's pre-fix behavior — so every downstream `Reader::open()` call /// still gets a path to fail on and log its own existing /// `warn`-and-degrade message. This function does not invent a new failure /// mode, it just tries harder before giving up. fn resolve_systems_db_path() -> std::path::PathBuf { let exe_path = std::env::current_exe().ok(); let candidates = systems_db_candidates(exe_path.as_deref()); for candidate in &candidates { let canonical = candidate.canonicalize(); if let Ok(ref resolved) = canonical { if resolved.exists() { tracing::info!( "systems.db resolved: {:?} (candidate: {:?}, exe: {:?})", resolved, candidate, exe_path ); return resolved.clone(); } } else if candidate.exists() { // canonicalize() can fail even when the path exists (e.g. a // component permission error) — exists() is the true signal; // canonicalize() is just how we get an absolute path for // world_root's ancestor arithmetic to work correctly. tracing::info!( "systems.db resolved (uncanonicalized): {:?} (exe: {:?})", candidate, exe_path ); return candidate.clone(); } } // None of the candidates exist. Fall back to the cwd-relative // `data/systems.db` default — today's pre-fix behavior — NOT the // exe-anchored candidate (which, per systems_db_candidates' doc, is // unjoined/uncanonicalized and only meaningful once verified to exist; // returning it here unverified would be a worse default than the plain // relative path every downstream Reader::open() already knows how to // fail on cleanly). let fallback = std::path::PathBuf::from("data/systems.db"); tracing::warn!( "systems.db not found via any of {:?} (exe: {:?}) — falling back to {:?} \ (every DB-backed reader will report unavailable and degrade)", candidates, exe_path, fallback ); fallback } /// 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 { 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![], 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, economy_snapshot: None, bookmark_catalog: 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(); settled_reach_server::tick_phases::TickPhase::configure(&mut app); app.add_plugins(SimulationPlugin { seed: 0 }); 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.add_plugins(settled_reach_server::bookmark::BookmarkPlugin::default()); app.add_plugins(settled_reach_server::atlas::GenerationPlugin); 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, world_seed: u64) { 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::rng::EntityRng; 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-specific spawn logic was removed in Sprint 37 // (D-032 purge); per-culture/per-role voice is reintroduced in Phase 6. let profile = MovementProfile::default(); let player = app .world_mut() .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing::default(), KnowledgeGraph::new(), NearbyInteractionBuffer::default(), MonologueState::default(), MonologueBuffer::default(), SprintAnomalyQueue::default(), CognitiveDelay::default(), ListeningFocus::new(TilePosition::new(16, 16, 0)), profile, profile.initial_stance(), PlayerMoveCooldown::default(), )) .id(); let player_sid = registry.register(player); app.world_mut() .entity_mut(player) .insert(EntityRng::from_seed_and_id(world_seed, player_sid.0)); // 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); app.world_mut() .entity_mut(npc1) .insert(EntityRng::from_seed_and_id(world_seed, npc1_sid.0)); // 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); app.world_mut() .entity_mut(npc2) .insert(EntityRng::from_seed_and_id(world_seed, npc2_sid.0)); // 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); app.world_mut() .entity_mut(npc3) .insert(EntityRng::from_seed_and_id(world_seed, npc3_sid.0)); // 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); } #[cfg(test)] mod tests { use super::*; /// T-1131 follow-up: the exe-anchored candidate must resolve to /// `server/data/systems.db` from the DEV BUILD LAYOUT exe path /// (`server/target/debug/settled-reach-server`) — this is the whole /// point of the fix, so pin the exact join shape, not just "some path /// containing systems.db". #[test] fn exe_anchored_candidate_targets_server_data_from_dev_build_layout() { let exe = std::path::Path::new("/repo/server/target/debug/settled-reach-server"); let candidates = systems_db_candidates(Some(exe)); assert_eq!( candidates.len(), 3, "exe with a parent dir must produce all three candidates" ); assert_eq!( candidates[0], std::path::PathBuf::from("/repo/server/target/debug/../../data/systems.db"), "exe-anchored candidate must be unjoined (caller canonicalizes) \ but built from exe_dir/../../data/systems.db" ); // The whole point: once normalized (what canonicalize() does at // runtime against a real filesystem), this lands on // /repo/server/data/systems.db — the actual DB location — not // /repo/data/systems.db (the pre-fix cwd-relative bug's target). let normalized = normalize_lexically(&candidates[0]); assert_eq!( normalized, std::path::PathBuf::from("/repo/server/data/systems.db") ); } /// The two cwd-relative fallback candidates are present regardless of /// whether an exe path resolved, in the documented order: `data/systems.db` /// before `server/data/systems.db` (today's pre-fix behavior stays the /// first fallback, not silently reordered behind the new repo-root case). #[test] fn cwd_relative_candidates_present_and_ordered_when_exe_path_is_some() { let exe = std::path::Path::new("/repo/server/target/debug/settled-reach-server"); let candidates = systems_db_candidates(Some(exe)); assert_eq!(candidates[1], std::path::PathBuf::from("data/systems.db")); assert_eq!( candidates[2], std::path::PathBuf::from("server/data/systems.db") ); } /// `current_exe()` can fail (documented caveat, e.g. sandboxed /// environments) — `None` must degrade to exactly the two cwd-relative /// candidates, not panic or produce a malformed exe-anchored entry. #[test] fn no_exe_path_yields_only_the_two_cwd_relative_candidates() { let candidates = systems_db_candidates(None); assert_eq!(candidates.len(), 2); assert_eq!(candidates[0], std::path::PathBuf::from("data/systems.db")); assert_eq!( candidates[1], std::path::PathBuf::from("server/data/systems.db") ); } /// An exe path that IS genuinely parentless (`Path::parent()` returns /// `None` only for the empty path or filesystem root — confirmed against /// the standard library, not assumed) must not panic and must degrade /// the same as `exe_path: None`. #[test] fn genuinely_parentless_exe_path_degrades_like_no_exe_path() { let exe = std::path::Path::new(""); assert!( exe.parent().is_none(), "test premise: Path::new(\"\").parent() must be None" ); let candidates = systems_db_candidates(Some(exe)); assert_eq!(candidates.len(), 2); assert_eq!(candidates[0], std::path::PathBuf::from("data/systems.db")); } /// A bare relative filename with no directory separator (e.g. the exe /// path Godot's `OS.create_process` might report on some platform/launch /// combination) is NOT the parentless case above — `Path::parent()` /// returns `Some("")` for it (an empty-but-present parent), a real /// standard-library quirk worth pinning explicitly since it's easy to /// assume `.parent()` is `None` whenever there's "no directory in the /// string". The exe-anchored candidate still gets produced (joined onto /// the empty parent), just degenerately — `../../data/systems.db` /// relative to cwd, which is harmless: it'll fail existence-checks /// exactly like any other wrong candidate and fall through the loop. #[test] fn bare_filename_exe_path_has_an_empty_but_present_parent() { let exe = std::path::Path::new("settled-reach-server"); assert_eq!( exe.parent(), Some(std::path::Path::new("")), "Path::parent() of a bare filename is Some(\"\"), not None — \ pinning this stdlib behavior since it's the reason a bare \ filename still produces 3 candidates, not 2" ); let candidates = systems_db_candidates(Some(exe)); assert_eq!( candidates.len(), 3, "a present-but-empty parent still yields an exe-anchored candidate" ); assert_eq!( candidates[0], std::path::PathBuf::from("../../data/systems.db"), "joined onto an empty parent, the exe-anchored candidate is bare \ ../../data/systems.db (cwd-relative in practice, but still a \ DISTINCT candidate from candidates[1]'s exact data/systems.db)" ); } /// Lexical `..`/`.` normalization for test assertions ONLY — a stand-in /// for `Path::canonicalize()` (which needs a real filesystem + cwd, /// which unit tests must not depend on per the coordinator's "don't /// build a process-spawning/filesystem harness for this" guidance). /// `resolve_systems_db_path` itself still uses the real /// `canonicalize()` at runtime — this helper exists only so /// `exe_anchored_candidate_targets_server_data_from_dev_build_layout` /// can assert the join shape actually lands on the right final path /// without touching disk. fn normalize_lexically(path: &std::path::Path) -> std::path::PathBuf { let mut out = std::path::PathBuf::new(); for component in path.components() { match component { std::path::Component::ParentDir => { out.pop(); } std::path::Component::CurDir => {} other => out.push(other.as_os_str()), } } out } }