Files
settled-reach/server/src/main.rs
T
jpmschweitzerandClaude Opus 4.7 1bece0b201 feat(simulation): serve atlas layer requests over the bridge — completes #969 (D-225)
Closes the server-side layer-stream loop. serve_atlas_requests (PreInput) drains
the AtlasRequestBuffer and runs each through handle_atlas_request (cache hit ->
Ready; miss -> resolve via BodySourceResolver + enqueue an Immediate AnalyzeBody
-> Pending), buffering AtlasLayerResponses. send_atlas_responses (PostSnapshot)
flushes them to the client. main.rs wires BodySourceResolverResource (base root
= systems.db's 3rd ancestor; mod roots layer on later). Misses flow through the
#968 background tier and a re-request hits the now-warm cache.

Full path now live server-side: client request -> receive() demux -> serve ->
proxy -> (cache | queue+cascade) -> response -> client. The client half (send
request, decode response, render overlays) is #960.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:06:33 +02:00

649 lines
25 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, 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);
let bridge = TcpBridge::accept_on(listener).unwrap_or_else(|e| {
tracing::error!("Failed to accept: {}", e);
std::process::exit(1);
});
tracing::info!("Client connected, sending protocol handshake");
// Protocol handshake: first framed message on the wire (#555).
// Client reads this and validates protocol_version before sending any input.
use settled_reach_server::bridge::SimBridge;
bridge.send_handshake().unwrap_or_else(|e| {
tracing::error!("Failed to send handshake: {}", e);
std::process::exit(1);
});
// Read client's startup message containing world_seed (#175).
// Client sends this immediately after validating the handshake.
let startup = bridge.receive_startup().unwrap_or_else(|e| {
tracing::error!("Failed to receive startup message: {}", e);
std::process::exit(1);
});
tracing::info!("Handshake complete, initializing simulation");
// RNG seed: --seed flag overrides client's world_seed (useful for testing).
// Production: client sends world_seed via StartupMessage (#175).
// Test mode default: 42 for deterministic replay.
let seed = seed_flag.unwrap_or(if test_mode { 42 } else { startup.world_seed });
let mut app = App::new();
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 = std::path::PathBuf::from("data/systems.db");
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).
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
),
}
// Initialize SQLite settings store (#627).
// Path: alongside save files in the server's working directory.
let settings_path = std::path::PathBuf::from("settings.db");
match settled_reach_server::settings::SettingsStore::open(&settings_path) {
Ok(store) => {
tracing::info!("Settings store opened: {:?}", settings_path);
app.insert_resource(settled_reach_server::settings::SettingsStoreResource::new(
store,
"default".to_string(),
));
}
Err(e) => {
tracing::error!(
"Failed to open settings store: {}. Settings will not persist.",
e
);
}
}
app.insert_resource(BridgeResource::new(bridge));
app.insert_resource(HandshakeState::Complete);
// 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.
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");
}
/// 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);
}