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>
649 lines
25 KiB
Rust
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);
|
|
}
|