Files
settled-reach/server/src/main.rs
T

1074 lines
45 KiB
Rust

// 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)
// --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<String> = 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::<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.
// 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 (<repo>/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::<String>() {
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::<ServerRunning>().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 `<exe_dir>/../../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<std::path::PathBuf> {
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::<settled_reach_server::simulation::time::SimulationTime>()
.map(|t| t.tick)
.unwrap_or(0);
let bridge = match world.get_resource::<BridgeResource>() {
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::<Schedules>();
println!("=== Schedule Graph (settled-reach-server) ===");
let mut entries: Vec<String> = 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::<WalkabilityMap>();
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::<RelationshipGraph>();
// 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
}
}