1074 lines
45 KiB
Rust
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
|
|
}
|
|
}
|