diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack index 717c4d4e1..1615131dc 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack index 6ad1d0cbe..2d4321df5 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack index 142a76a3c..4a8702760 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack index 81ed70f7c..a1c311896 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack index 9f30a44f1..47762a0df 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index 717c4d4e1..1615131dc 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_empty.msgpack and b/client/tests/fixtures/msgpack/snapshot_empty.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_full.msgpack b/client/tests/fixtures/msgpack/snapshot_full.msgpack index 03348d54a..d2f5bbd60 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_full.msgpack and b/client/tests/fixtures/msgpack/snapshot_full.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_minimal.msgpack b/client/tests/fixtures/msgpack/snapshot_minimal.msgpack index 6bff4d6b3..fba288bb8 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_minimal.msgpack and b/client/tests/fixtures/msgpack/snapshot_minimal.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index 428f5dacf..dc2acf96a 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack and b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index 25eb90f19..107389317 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack and b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index c7565ed35..8166124b0 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_player.msgpack and b/client/tests/fixtures/msgpack/snapshot_player.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index 19bd9d0f9..f83ad4838 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack and b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack differ diff --git a/server/data/templates/logistics-hub.yaml b/server/data/templates/logistics-hub.yaml new file mode 100644 index 000000000..d256adbbe --- /dev/null +++ b/server/data/templates/logistics-hub.yaml @@ -0,0 +1,202 @@ +# Sova Station Logistics Hub — Tier 2 social site template (#159). +# +# Spec refs: D-023 (three-tier content model), D-024 (10-axis NPC model), +# D-025 (social site as atomic template unit), D-028 (dialogue pools), +# D-087 (v0.1 triangle configuration), D-089 (self-contained forks) +# +# Tile scale (D-066): tile_count_* are SIM tiles (0.5m each). +# 15–40 visual tiles (D-025) = 30–80 sim tiles. +# +# triangle_id values are authoring placeholders (0). +# The instantiation engine overwrites them with +# TriangleId::from_seed_and_roles(world_seed, &roles) at runtime. + +slug: "logistics-hub" +display_name: "Sova Station Logistics Hub" +description: > + The terminal's operational core — cargo processing, documentation, + and the informal relationships that keep goods moving off-manifest. + A functional cluster of four NPCs controlling access to the station's + primary freight throughput. + +roles: + - role_id: "logistics-manager" + required_traits: + - Bold + - Cautious + skill_focus: + - Persuasion + - Observation + relationship_constraints: + - with_role: "dock-worker" + kind: Superior + required_trust: + min: 0 + max: 5 + - with_role: "ring-contact" + kind: Colleague + required_trust: + min: -5 + max: 2 + - with_role: "security-guard" + kind: Superior + required_trust: + min: 1 + max: 5 + routine_template: + - phase: morning + location: "terminal-office" + activity: "documentation-review" + - phase: afternoon + location: "terminal-cargo-bay" + activity: "cargo-inspection" + - phase: evening + location: "terminal-office" + activity: "end-of-day-reports" + + - role_id: "dock-worker" + required_traits: + - Honest + - Social + skill_focus: + - Technical + - Observation + relationship_constraints: + - with_role: "logistics-manager" + kind: Subordinate + required_trust: + min: -2 + max: 4 + - with_role: "ring-contact" + kind: Colleague + required_trust: + min: -4 + max: 0 + routine_template: + - phase: morning + location: "terminal-cargo-bay" + activity: "freight-handling" + - phase: afternoon + location: "terminal-cargo-bay" + activity: "freight-handling" + - phase: evening + location: "bar-last-shift" + + - role_id: "ring-contact" + required_traits: + - Deceptive + - Social + skill_focus: + - Stealth + - Persuasion + relationship_constraints: + - with_role: "logistics-manager" + kind: Colleague + required_trust: + min: -5 + max: 2 + - with_role: "dock-worker" + kind: Colleague + required_trust: + min: -4 + max: 0 + routine_template: + - phase: morning + location: "terminal-cargo-bay" + activity: "oversight" + - phase: afternoon + location: "maintenance-corridor" + - phase: evening + location: "bar-last-shift" + + - role_id: "security-guard" + required_traits: + - Cautious + - Honest + skill_focus: + - Combat + - Observation + relationship_constraints: + - with_role: "logistics-manager" + kind: Subordinate + required_trust: + min: 1 + max: 5 + routine_template: + - phase: morning + location: "terminal-entrance" + activity: "access-control" + - phase: afternoon + location: "terminal-cargo-bay" + activity: "patrol" + - phase: evening + location: "terminal-entrance" + activity: "access-control" + +space: + tile_count_min: 30 + tile_count_max: 80 + sightline_zones: + - name: "loading-floor" + radius: 8 # 4m clear sightline across the loading area + - name: "reception-desk" + radius: 4 # 2m clear sightline at the desk + - name: "cargo-staging" + radius: 6 # 3m clear sightline in staging area + privacy_level: SemiPrivate + traffic_pattern: Destination + +triangles: + - triangle_id: 0 + roles: + - "ring-contact" + - "dock-worker" + - "logistics-manager" + conflict_type: ResourceCompetition + interest_axes: + - Want + - Secret + - Relationships + relationship_constraints: + - with_role: "dock-worker" + kind: Subordinate + required_trust: + min: -2 + max: 2 + + - triangle_id: 0 + roles: + - "logistics-manager" + - "security-guard" + - "ring-contact" + conflict_type: AuthorityChallenge + interest_axes: + - Want + - Tolerance + - Secret + relationship_constraints: + - with_role: "security-guard" + kind: Superior + required_trust: + min: 0 + max: 5 + +dialogue_pools: + - location: "the-terminal" + roles: + - "logistics-manager" + - "dock-worker" + - "ring-contact" + - "security-guard" + - location: "terminal-cargo-bay" + roles: + - "dock-worker" + - "ring-contact" + +cross_template_links: + - from_role: "dock-worker" + to_template_slug: "last-shift-bar" + relationship: Colleague + - from_role: "ring-contact" + to_template_slug: "last-shift-bar" + relationship: Colleague diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 89ea3c363..a02450ec2 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -91,14 +91,20 @@ impl Default for HandshakeState { } } -/// Receive inputs from bridge and push to InputQueue +/// Receive inputs from bridge and push to InputQueue. +/// Protocol errors (malformed input) are recoverable: the frame is skipped +/// and a SimError is pushed to the SimErrorBuffer for client reporting (#85). pub fn receive_bridge_inputs( bridge: Option>, mut input_queue: ResMut, mut running: ResMut, handshake: Res, + mut error_buffer: ResMut, + time: Option>, ) { let Some(bridge) = bridge else { return }; + let current_tick = time.as_ref().map(|t| t.tick).unwrap_or(0); + match bridge.receive_inputs() { Ok(inputs) => { if !inputs.is_empty() && *handshake == HandshakeState::Pending { @@ -134,8 +140,22 @@ pub fn receive_bridge_inputs( running.0 = false; } Err(BridgeError::DeserializationWithDump(ref msg)) => { - // Recoverable: skip this frame's input, don't shut down + // Recoverable: skip this frame's input, report to client (#85) tracing::error!("Skipping malformed input frame: {}", msg); + error_buffer.push(SimError { + kind: SimErrorKind::ProtocolError, + message: format!("Malformed input frame: {}", msg), + tick: current_tick, + }); + } + Err(ref e @ BridgeError::Deserialization(_)) => { + // Recoverable deserialization error without dump + tracing::error!("Skipping malformed input: {}", e); + error_buffer.push(SimError { + kind: SimErrorKind::ProtocolError, + message: format!("Deserialization error: {}", e), + tick: current_tick, + }); } Err(e) => { tracing::error!("Bridge receive error: {}", e); @@ -191,6 +211,7 @@ impl Plugin for BridgePlugin { app.init_resource::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .add_systems( diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index aabb19d90..1339302b5 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -314,6 +314,8 @@ mod tests { player_knowledge: None, save_result: None, triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], } } @@ -450,6 +452,8 @@ mod tests { player_knowledge: None, save_result: None, triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], }; let text = format_snapshot_text(&snap); assert!(text.contains("Tick 0")); diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index e258ee8c7..f6f780125 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -17,7 +17,7 @@ pub use crate::simulation::time::{DayPhase, TickRate}; /// negotiation is unnecessary. Client should reject snapshots with version != /// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration /// period, then the default is removed once both sides are updated. -pub const PROTOCOL_VERSION: u8 = 16; +pub const PROTOCOL_VERSION: u8 = 17; /// Handshake message sent as the very first framed message after connection (#555). /// Client reads this before entering the normal tick loop and validates @@ -51,10 +51,12 @@ pub struct HandshakeMessage { /// player_knowledge (#264, partial KG dump for journal/knowledge panel). /// v15 adds: save_result (#553, save/load operation result for client confirmation). /// v16 adds: triangle_crisis_events (#250, D-087 triangle escalation for future client rendering). +/// v17 adds: state_hash (#85, desync detection — fast hash of player pos + NPC count + tick), +/// sim_errors (#85, structured error reporting to client). /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { - /// Protocol version for forward compatibility. Current: 16. + /// Protocol version for forward compatibility. Current: 17. pub version: u8, /// Simulation tick when this snapshot was produced pub tick: u64, @@ -157,6 +159,19 @@ pub struct ObserverSnapshot { /// a narrative event or HUD indicator. Empty when no crises occur. #[serde(default)] pub triangle_crisis_events: Vec, + /// Fast hash of key mutable state for desync detection (#85). + /// Hash inputs: player position, NPC count, tick number. + /// Client compares against its own computed hash — mismatch indicates + /// client and server state have diverged. No auto-recovery in v0.1; + /// client logs mismatches for debugging. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_hash: Option, + /// Simulation errors reported this tick (#85). + /// Non-fatal errors (protocol errors, desync) are collected during + /// the tick and sent to the client for logging/display. + /// Empty in normal operation. Client may display a warning toast. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sim_errors: Vec, } /// Game time data for client display (D-031) @@ -714,6 +729,56 @@ impl From for TriangleCrisisEvent } } +/// Structured simulation error for client reporting (#85). +/// +/// Sent inside `ObserverSnapshot.sim_errors` for recoverable errors +/// (protocol errors, desync warnings). For fatal errors (panics), +/// a final snapshot is sent with the error before the server exits. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimError { + /// Error category for client-side handling. + pub kind: SimErrorKind, + /// Human-readable error description. + pub message: String, + /// Tick when the error occurred (0 if unavailable). + pub tick: u64, +} + +/// Categories of simulation errors (#85). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SimErrorKind { + /// Simulation system panic — fatal, server will exit after sending this. + Panic, + /// Protocol/deserialization error — recoverable, server continues. + ProtocolError, + /// Client-server state hash mismatch — informational, no auto-recovery. + DesyncDetected, +} + +/// Buffer for collecting simulation errors during a tick (#85). +/// Drained by `compute_observer_snapshot` into `ObserverSnapshot.sim_errors`. +#[derive(Resource, Debug, Default)] +pub struct SimErrorBuffer { + errors: Vec, +} + +impl SimErrorBuffer { + /// Push a new error into the buffer. + pub fn push(&mut self, error: SimError) { + self.errors.push(error); + } + + /// Drain all buffered errors, returning them and clearing the buffer. + pub fn drain(&mut self) -> Vec { + std::mem::take(&mut self.errors) + } + + /// Check if there are pending errors. + pub fn has_errors(&self) -> bool { + !self.errors.is_empty() + } +} + /// Snapshot buffer resource for staging outgoing ObserverSnapshots #[derive(Resource, Debug, Default)] pub struct SnapshotBuffer { diff --git a/server/src/content/instantiation.rs b/server/src/content/instantiation.rs new file mode 100644 index 000000000..88ebc9b5b --- /dev/null +++ b/server/src/content/instantiation.rs @@ -0,0 +1,214 @@ +//! Template instantiation engine (#161). +//! +//! Wires the full pipeline: `FullTemplateDef` → NPC spawn (via spawn.rs) → +//! triangle generation (via template.rs) → instance tracking. +//! +//! **Pipeline:** +//! 1. Validate the `FullTemplateDef` (schema-level checks). +//! 2. Call `spawn_template_npcs` to create NPC entities and wire relationships. +//! 3. Call `generate_intra_template_triangles` to generate `TriangleState` values. +//! 4. Spawn each `TriangleState` as an ECS entity with the `ActiveSim` marker. +//! 5. Register the live instance in `ActiveTemplateInstances`. +//! +//! **Instance lifecycle:** +//! Instances are tracked by `TemplateId` in `ActiveTemplateInstances`. +//! `unload_template` despawns all NPC and triangle entities and removes the +//! entry from `ActiveTemplateInstances`. +//! +//! **Determinism (D-010):** given the same `FullTemplateDef`, `TemplateId`, +//! `world_seed`, and `SimRng` state, the spawned NPC and triangle layout is +//! identical. + +use std::collections::BTreeMap; + +use bevy_ecs::prelude::*; + +use crate::content::spawn::spawn_template_npcs; +use crate::content::template::{ + generate_intra_template_triangles, FullTemplateDef, TemplateId, +}; +use crate::simulation::rng::SimRng; +use crate::simulation::tier::ActiveSim; + +// =========================================================================== +// Public types +// =========================================================================== + +/// A live template instance — the result of `instantiate_template`. +/// +/// Holds entity handles for all NPCs and triangle entities spawned from a +/// single `FullTemplateDef`. Required by `unload_template` to despawn them. +#[derive(Debug, Clone)] +pub struct TemplateInstance { + /// Template this instance was created from. + pub template_id: TemplateId, + /// ECS entities for the NPC role slots (one per `RoleSchema`). + pub npc_entities: Vec, + /// ECS entities for the generated `TriangleState` components. + pub triangle_entities: Vec, + /// Non-fatal warnings from triangle generation (e.g., fallback assignments). + pub warnings: Vec, +} + +/// Resource tracking all currently active template instances. +/// +/// Key = `TemplateId.0` (deterministic u64). Initialized on demand by +/// `instantiate_template`; may also be initialized explicitly with +/// `world.init_resource::()`. +/// +/// **Determinism (D-010):** `BTreeMap` for consistent iteration order. +#[derive(Resource, Default, Debug)] +pub struct ActiveTemplateInstances { + instances: BTreeMap, +} + +impl ActiveTemplateInstances { + /// Register a new instance. Overwrites any existing entry for the same ID. + pub fn insert(&mut self, instance: TemplateInstance) { + self.instances.insert(instance.template_id.0, instance); + } + + /// Look up a live instance by template ID. + pub fn get(&self, template_id: TemplateId) -> Option<&TemplateInstance> { + self.instances.get(&template_id.0) + } + + /// Remove and return an instance (used by `unload_template`). + pub fn remove(&mut self, template_id: TemplateId) -> Option { + self.instances.remove(&template_id.0) + } + + /// Number of active instances. + pub fn len(&self) -> usize { + self.instances.len() + } + + /// `true` if no instances are active. + pub fn is_empty(&self) -> bool { + self.instances.is_empty() + } +} + +// =========================================================================== +// Instantiation +// =========================================================================== + +/// Instantiate a template: validate, spawn NPCs, generate triangles, register. +/// +/// **Preconditions:** +/// - `EntityRegistry` must be initialized as a world resource (done by +/// `SimulationPlugin` at startup). +/// - `ActiveTemplateInstances` is initialized on demand inside this function. +/// +/// **Returns** the created `TemplateInstance` (also stored in +/// `ActiveTemplateInstances`). +/// +/// **Errors:** returns `Err(String)` if `template_def.validate()` fails. +pub fn instantiate_template( + world: &mut World, + template_def: &FullTemplateDef, + template_id: TemplateId, + world_seed: u64, + rng: &mut SimRng, +) -> Result { + // Schema validation before any ECS mutations. + template_def.validate()?; + + // Phases 1–3: NPC spawn + relationship wiring + cross-template ref map. + let spawn_result = spawn_template_npcs(world, template_def, template_id, world_seed, rng); + + // Phase 4: Generate intra-template triangle state values. + let tri_result = + generate_intra_template_triangles(world, template_id, &template_def.triangles, rng); + + let warnings = tri_result.warnings; + + // Spawn each TriangleState as a dedicated ECS entity with ActiveSim so + // the escalation system can pick it up (D-087). + let triangle_entities: Vec = tri_result + .triangles + .into_iter() + .map(|state| world.spawn((ActiveSim, state)).id()) + .collect(); + + let instance = TemplateInstance { + template_id, + npc_entities: spawn_result.entities, + triangle_entities, + warnings, + }; + + // Register in ActiveTemplateInstances (init if absent). + // If a previous instance with the same ID exists, unload it first to + // prevent orphaned ECS entities (Hoshe review #2). + world.init_resource::(); + let previous = world + .resource_mut::() + .remove(template_id); + if let Some(prev) = previous { + tracing::warn!( + "instantiate_template: overwriting live TemplateId({}) — despawning {} entities", + template_id.0, + prev.npc_entities.len() + prev.triangle_entities.len(), + ); + for entity in prev.npc_entities.iter().chain(prev.triangle_entities.iter()) { + if world.get_entity(*entity).is_ok() { + world.despawn(*entity); + } + } + } + world + .resource_mut::() + .insert(instance.clone()); + + Ok(instance) +} + +// =========================================================================== +// Lifecycle: unload +// =========================================================================== + +/// Unload a template instance: despawn all entities and remove from tracking. +/// +/// No-op (with a warning log) if the given `template_id` is not active. +pub fn unload_template(world: &mut World, template_id: TemplateId) { + let instance = world + .resource_mut::() + .remove(template_id); + + let Some(instance) = instance else { + tracing::warn!( + "unload_template: TemplateId({}) not active — no-op", + template_id.0 + ); + return; + }; + + let mut despawned = 0usize; + for entity in instance.npc_entities.iter().chain(instance.triangle_entities.iter()) { + if world.get_entity(*entity).is_ok() { + world.despawn(*entity); + despawned += 1; + } + } + + tracing::info!( + "unload_template: TemplateId({}) unloaded — {} entities despawned", + template_id.0, + despawned, + ); +} + +// =========================================================================== +// YAML loader +// =========================================================================== + +/// Load a `FullTemplateDef` from a YAML file on disk. +/// +/// Returns `Err(String)` if the file cannot be read or fails YAML parsing. +pub fn load_template_from_file(path: &std::path::Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("failed to read {:?}: {}", path, e))?; + serde_yaml::from_str::(&content) + .map_err(|e| format!("failed to parse {:?}: {}", path, e)) +} diff --git a/server/src/content/mod.rs b/server/src/content/mod.rs index 34e71eeb0..aefd49acf 100644 --- a/server/src/content/mod.rs +++ b/server/src/content/mod.rs @@ -11,6 +11,7 @@ //! handles the mapping between the two representations. pub mod hot_reload; +pub mod instantiation; pub mod line_pool; pub mod loader; pub mod spawn; diff --git a/server/src/content/spawn.rs b/server/src/content/spawn.rs index b23e056b4..7b63e3400 100644 --- a/server/src/content/spawn.rs +++ b/server/src/content/spawn.rs @@ -34,6 +34,15 @@ use crate::simulation::movement::TilePosition; use crate::simulation::tier::ActiveSim; use crate::simulation::time::DayPhase; +// #166 — Template-to-instance mapping +use rand::Rng as _; +use crate::content::template::{ + FullTemplateDef, RoleId, TemplateId, TemplateOwnership, TemplateReference, TemplateReferenceMap, +}; +use crate::npc::generate::{generate_npc, RoleDefinition}; +use crate::npc::{Relationship, Relationships}; +use crate::simulation::rng::SimRng; + /// Stable content identifier from YAML (e.g., "kael-davan", "sera-venn"). /// /// Bridges authoring identity to ECS entities. Independent of StableId — @@ -656,6 +665,160 @@ pub fn parse_day_phase(s: &str) -> Option { } } +// =========================================================================== +// #166 — Template-to-instance mapping +// =========================================================================== + +/// Result of spawning all NPC role slots for a template. +#[derive(Debug)] +pub struct TemplateSpawnResult { + /// Stable ID assigned to each role slot. BTreeMap for deterministic ordering (D-010). + pub role_assignments: BTreeMap, + /// ECS entity handles in the same order as `FullTemplateDef::roles`. + pub entities: Vec, +} + +/// Spawn NPC entities for all role slots in a `FullTemplateDef` (#166). +/// +/// Three-phase process: +/// +/// **Phase 1 — Spawn:** For each `RoleSchema`, build a `RoleDefinition` and +/// call `generate_npc()`. Register the entity in `EntityRegistry`, then +/// insert `StableEntityId` + `TemplateOwnership`. +/// +/// **Phase 2 — Relationships:** Wire intra-template `RelationshipConstraint`s. +/// Each constraint becomes a `Relationship` entry on the NPC, with trust +/// sampled within `[min, max]` via `rng`. +/// +/// **Phase 3 — Reference map:** Record cross-template links in +/// `TemplateReferenceMap`, resolving `to_template_slug` to `TemplateId` +/// via `TemplateId::from_seed_and_slug(world_seed, slug)`. +/// +/// **Caller precondition:** `EntityRegistry` must be initialized as a world +/// resource (done by `SimulationPlugin`). `TemplateReferenceMap` is +/// initialized inside this function if absent. +/// +/// **Determinism (D-010):** All randomness flows through `rng`. Same seed +/// and `FullTemplateDef` → same NPC layout every time. +pub fn spawn_template_npcs( + world: &mut World, + template_def: &FullTemplateDef, + template_id: TemplateId, + world_seed: u64, + rng: &mut SimRng, +) -> TemplateSpawnResult { + let mut role_assignments: BTreeMap = BTreeMap::new(); + let mut role_entities: BTreeMap = BTreeMap::new(); + let mut entities: Vec = Vec::new(); + + // ----------------------------------------------------------------------- + // Phase 1: Spawn one NPC per role slot + // ----------------------------------------------------------------------- + for role_schema in &template_def.roles { + // Enable combat capability for roles whose skill focus includes Combat. + let combat_enabled = role_schema.skill_focus.contains(&crate::npc::Skill::Combat); + + let role_def = RoleDefinition { + name: role_schema.role_id.0.clone(), + // Location pool is empty at template-def time — positions are resolved + // when the template is placed in the world (#161). + location_pool: vec![], + // Relationship targets are empty — Phase 2 wires them from constraints. + relationship_targets: vec![], + known_facts: vec![], + skill_focus: role_schema.skill_focus.clone(), + combat_enabled, + }; + + let entity = generate_npc(&role_def, world, rng); + + // Vision + awareness components (#66) — must match spawn_npc(). + // Without these, vision/awareness systems silently skip template-spawned NPCs. + world.entity_mut(entity).insert(( + crate::npc::vision::NpcVisionState::default(), + crate::npc::vision::NpcMemory::default(), + crate::npc::awareness::PlayerAwareness::default(), + )); + + // Register the entity in EntityRegistry and attach stable identity. + let stable_id = world.resource_mut::().register(entity); + world.entity_mut(entity).insert(( + StableEntityId(stable_id), + TemplateOwnership { + template_id, + role_id: role_schema.role_id.clone(), + }, + )); + + role_assignments.insert(role_schema.role_id.clone(), stable_id); + role_entities.insert(role_schema.role_id.clone(), entity); + entities.push(entity); + } + + // ----------------------------------------------------------------------- + // Phase 2: Wire intra-template relationship constraints + // ----------------------------------------------------------------------- + for role_schema in &template_def.roles { + let Some(&from_entity) = role_entities.get(&role_schema.role_id) else { + continue; + }; + + for constraint in &role_schema.relationship_constraints { + let Some(&with_stable_id) = role_assignments.get(&constraint.with_role) else { + // Referenced role is not in this template — cross-template links + // are handled via TemplateReferenceMap (Phase 3), not Relationships. + tracing::debug!( + "spawn_template_npcs: constraint references role '{}' not in template '{}', skipping", + constraint.with_role.0, + template_def.slug, + ); + continue; + }; + + // Sample trust within the authored range. If range is degenerate, use min. + let trust: i8 = if constraint.required_trust.min >= constraint.required_trust.max { + constraint.required_trust.min + } else { + rng.rng.random_range( + constraint.required_trust.min..=constraint.required_trust.max, + ) + }; + + // Append the relationship — generate_npc starts with empty relationship_targets + // so there are no pre-existing duplicates to guard against. + if let Some(mut rels) = world.get_mut::(from_entity) { + rels.entries.push(Relationship { + target_id: with_stable_id, + kind: constraint.kind.clone(), + trust_level: trust, + history: vec![], + }); + } + } + } + + // ----------------------------------------------------------------------- + // Phase 3: Record cross-template reference links in TemplateReferenceMap + // ----------------------------------------------------------------------- + world.init_resource::(); + for link in &template_def.cross_template_links { + let to_template_id = TemplateId::from_seed_and_slug(world_seed, &link.to_template_slug); + world + .resource_mut::() + .add(TemplateReference { + from_template: template_id, + to_template: to_template_id, + via_role: link.from_role.clone(), + relationship_metadata: link.relationship.clone(), + }); + } + + TemplateSpawnResult { + role_assignments, + entities, + } +} + #[cfg(test)] mod tests { use super::*; @@ -1108,7 +1271,6 @@ mod tests { assert_eq!(edge.trust, 7); } - #[test] #[test] fn spawn_npc_combat_trained_sets_skill_flag() { // #91: combat_trained: true in YAML sets SkillSet.combat_trained = true. @@ -1180,4 +1342,224 @@ mod tests { .unwrap(); assert!(world.get::(entity).is_none()); } + + // ----------------------------------------------------------------------- + // #166 — Template-to-instance mapping tests + // ----------------------------------------------------------------------- + + fn minimal_template_def_4_roles() -> crate::content::template::FullTemplateDef { + use crate::content::template::{ + ConflictType, CrossTemplateLinkSpec, FullTemplateDef, NpcAxis, PrivacyLevel, + RelationshipConstraint, RoleId, RoleSchema, SpaceSpec, TrafficPattern, TriangleDef, + TriangleId, TrustRange, + }; + use crate::npc::{RelationshipKind, Skill}; + + FullTemplateDef { + slug: "test-hub".to_string(), + display_name: "Test Hub".to_string(), + description: None, + roles: vec![ + RoleSchema { + role_id: RoleId::new("manager"), + required_traits: vec![], + skill_focus: vec![Skill::Persuasion, Skill::Observation], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("worker"), + kind: RelationshipKind::Superior, + required_trust: TrustRange { min: 1, max: 4 }, + }], + routine_template: vec![], + }, + RoleSchema { + role_id: RoleId::new("worker"), + required_traits: vec![], + skill_focus: vec![Skill::Technical], + relationship_constraints: vec![], + routine_template: vec![], + }, + RoleSchema { + role_id: RoleId::new("guard"), + required_traits: vec![], + skill_focus: vec![Skill::Combat, Skill::Observation], + relationship_constraints: vec![], + routine_template: vec![], + }, + RoleSchema { + role_id: RoleId::new("contact"), + required_traits: vec![], + skill_focus: vec![Skill::Stealth, Skill::Persuasion], + relationship_constraints: vec![], + routine_template: vec![], + }, + ], + space: SpaceSpec { + tile_count_min: 30, + tile_count_max: 80, + sightline_zones: vec![], + privacy_level: PrivacyLevel::SemiPrivate, + traffic_pattern: TrafficPattern::Destination, + }, + triangles: vec![ + TriangleDef { + triangle_id: TriangleId(0), + roles: [ + RoleId::new("manager"), + RoleId::new("worker"), + RoleId::new("guard"), + ], + conflict_type: ConflictType::ResourceCompetition, + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], + relationship_constraints: vec![], + }, + TriangleDef { + triangle_id: TriangleId(0), + roles: [ + RoleId::new("manager"), + RoleId::new("contact"), + RoleId::new("guard"), + ], + conflict_type: ConflictType::AuthorityChallenge, + interest_axes: [NpcAxis::Relationships, NpcAxis::Tolerance, NpcAxis::Secret], + relationship_constraints: vec![], + }, + ], + dialogue_pools: vec![], + cross_template_links: vec![CrossTemplateLinkSpec { + from_role: RoleId::new("worker"), + to_template_slug: "bar".to_string(), + relationship: crate::npc::RelationshipKind::Colleague, + }], + } + } + + #[test] + fn spawn_template_npcs_fills_all_role_slots() { + use crate::content::template::{RoleId, TemplateId}; + use crate::simulation::rng::SimRng; + + let mut world = create_test_world(); + let template_def = minimal_template_def_4_roles(); + let template_id = TemplateId::from_seed_and_slug(42, "test-hub"); + let mut rng = SimRng::new(42); + + let result = spawn_template_npcs(&mut world, &template_def, template_id, 42, &mut rng); + + assert_eq!(result.role_assignments.len(), 4, "all 4 role slots must be filled"); + assert_eq!(result.entities.len(), 4, "4 entities expected"); + assert!(result.role_assignments.contains_key(&RoleId::new("manager"))); + assert!(result.role_assignments.contains_key(&RoleId::new("worker"))); + assert!(result.role_assignments.contains_key(&RoleId::new("guard"))); + assert!(result.role_assignments.contains_key(&RoleId::new("contact"))); + } + + #[test] + fn spawn_template_npcs_sets_template_ownership() { + use crate::content::template::{RoleId, TemplateId, TemplateOwnership}; + use crate::simulation::rng::SimRng; + + let mut world = create_test_world(); + let template_def = minimal_template_def_4_roles(); + let template_id = TemplateId::from_seed_and_slug(42, "test-hub"); + let mut rng = SimRng::new(42); + + let result = spawn_template_npcs(&mut world, &template_def, template_id, 42, &mut rng); + + let valid_roles = [ + RoleId::new("manager"), + RoleId::new("worker"), + RoleId::new("guard"), + RoleId::new("contact"), + ]; + for entity in &result.entities { + let ownership = world + .get::(*entity) + .expect("entity must have TemplateOwnership"); + assert_eq!(ownership.template_id, template_id, "template_id must match"); + assert!( + valid_roles.contains(&ownership.role_id), + "role_id {:?} not in expected roles", + ownership.role_id, + ); + } + } + + #[test] + fn spawn_template_npcs_records_cross_template_references() { + use crate::content::template::{TemplateId, TemplateReferenceMap}; + use crate::simulation::rng::SimRng; + + let mut world = create_test_world(); + let template_def = minimal_template_def_4_roles(); + let template_id = TemplateId::from_seed_and_slug(42, "test-hub"); + let mut rng = SimRng::new(42); + + spawn_template_npcs(&mut world, &template_def, template_id, 42, &mut rng); + + let ref_map = world.resource::(); + let outgoing = ref_map.outgoing(template_id); + assert_eq!(outgoing.len(), 1, "one cross-template link expected"); + assert_eq!(outgoing[0].from_template, template_id); + let expected_target = TemplateId::from_seed_and_slug(42, "bar"); + assert_eq!( + outgoing[0].to_template, expected_target, + "target template_id must match seed+slug derivation" + ); + } + + #[test] + fn spawn_template_npcs_wires_relationship_constraints() { + use crate::content::template::{RoleId, TemplateId}; + use crate::simulation::rng::SimRng; + + let mut world = create_test_world(); + let template_def = minimal_template_def_4_roles(); + let template_id = TemplateId::from_seed_and_slug(42, "test-hub"); + let mut rng = SimRng::new(42); + + let result = spawn_template_npcs(&mut world, &template_def, template_id, 42, &mut rng); + + // The "manager" role has a RelationshipConstraint toward "worker" (trust 1–4). + let manager_stable_id = result.role_assignments[&RoleId::new("manager")]; + let worker_stable_id = result.role_assignments[&RoleId::new("worker")]; + let manager_entity = world + .resource::() + .to_entity(&manager_stable_id) + .unwrap(); + + let rels = world.get::(manager_entity).unwrap(); + let worker_rel = rels.entries.iter().find(|r| r.target_id == worker_stable_id); + assert!( + worker_rel.is_some(), + "manager must have a relationship toward worker (from RelationshipConstraint)" + ); + let trust = worker_rel.unwrap().trust_level; + assert!( + trust >= 1 && trust <= 4, + "trust {} not in authored range [1, 4]", + trust + ); + } + + #[test] + fn spawn_template_npcs_is_deterministic() { + use crate::content::template::TemplateId; + use crate::simulation::rng::SimRng; + + let template_def = minimal_template_def_4_roles(); + let template_id = TemplateId::from_seed_and_slug(42, "test-hub"); + + let mut world1 = create_test_world(); + let result1 = + spawn_template_npcs(&mut world1, &template_def, template_id, 42, &mut SimRng::new(42)); + + let mut world2 = create_test_world(); + let result2 = + spawn_template_npcs(&mut world2, &template_def, template_id, 42, &mut SimRng::new(42)); + + assert_eq!( + result1.role_assignments, result2.role_assignments, + "spawn_template_npcs must be deterministic (D-010)" + ); + } } diff --git a/server/src/content/template.rs b/server/src/content/template.rs index 93527b008..95eab01fa 100644 --- a/server/src/content/template.rs +++ b/server/src/content/template.rs @@ -462,6 +462,131 @@ impl TriangleDef { } } +// =========================================================================== +// #159 — Full Tier 2 template document +// =========================================================================== + +/// Dialogue pool reference within a template (D-028). +/// +/// Refers to an existing authored dialogue pool by (location, roles). +/// The pool content lives in the campaign dialogue files; this reference +/// wires the pool to the social site for runtime line selection. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TemplateDialoguePoolRef { + /// The location identifier the pool is authored under (e.g., "the-terminal"). + pub location: String, + /// Role slugs from this template that draw from the pool. + /// Empty = all roles may draw from this pool. + #[serde(default)] + pub roles: Vec, +} + +/// Specification of a cross-template link authored in the template file (D-025). +/// +/// At instantiation time the engine resolves these into `TemplateReference` +/// entries in `TemplateReferenceMap`. The actual target template is identified +/// by slug, not a pre-computed `TemplateId`, because IDs are seed-dependent. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CrossTemplateLinkSpec { + /// The role in THIS template that holds the cross-template relationship. + pub from_role: RoleId, + /// Slug of the external template being referenced. + pub to_template_slug: String, + /// Relationship kind for the `TemplateReference` link. + pub relationship: RelationshipKind, +} + +/// Full Tier 2 social site template definition (#159). +/// +/// The composite YAML document combining all sub-schemas into one canonical +/// template file. Authored in `server/data/templates/*.yaml` and loaded by +/// the template instantiation engine (#161). +/// +/// Per D-023 (three-tier content model) and D-025 (social site as atomic unit): +/// one file = one social site = 4–8 roles + spatial spec + 2+ triangles. +/// +/// **YAML authoring note:** `triangle_id` fields in authored `TriangleDef`s +/// should be set to 0 as a placeholder — the instantiation engine overwrites +/// them with `TriangleId::from_seed_and_roles(world_seed, &roles)` at runtime. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FullTemplateDef { + /// Stable slug used to derive `TemplateId` via `TemplateId::from_seed_and_slug`. + pub slug: String, + /// Human-readable display name. + pub display_name: String, + /// Optional authoring description (not surfaced to players). + #[serde(default)] + pub description: Option, + /// Role definitions (D-024 10-axis NPC constraints per role, 4–8 roles). + pub roles: Vec, + /// Spatial requirements (D-025: 30–80 sim tiles). + pub space: SpaceSpec, + /// Triangle definitions (D-024 minimum 2 per template, D-087 configuration). + pub triangles: Vec, + /// Dialogue pool references for runtime line selection (D-028). + #[serde(default)] + pub dialogue_pools: Vec, + /// Cross-template link specs resolved to `TemplateReferenceMap` at instantiation. + #[serde(default)] + pub cross_template_links: Vec, +} + +impl FullTemplateDef { + /// Validate the full template definition. + /// + /// Checks (in order): + /// 1. No duplicate `role_id`s in the roles list. + /// 2. Each role validates individually. + /// 3. Space spec validates. + /// 4. At least 2 triangles (D-024). + /// 5. Each triangle validates individually. + /// 6. All role references in triangles are defined in the roles list. + /// + /// Returns `Err` with the first failure description found. + pub fn validate(&self) -> Result<(), String> { + // 1 — no duplicate role IDs + validate_role_schemas_no_duplicate_ids(&self.roles)?; + + // 2 — each role validates + for role in &self.roles { + role.validate().map_err(|e| format!("role '{}': {}", role.role_id.0, e))?; + } + + // 3 — space spec + self.space.validate()?; + + // 4 — minimum 2 triangles + if self.triangles.len() < 2 { + return Err(format!( + "template '{}': fewer than 2 triangles ({}) — D-024 requires minimum 2", + self.slug, + self.triangles.len() + )); + } + + // 5 — each triangle validates + for tri in &self.triangles { + tri.validate() + .map_err(|e| format!("triangle {:?}: {}", tri.triangle_id, e))?; + } + + // 6 — triangle role references must exist in roles list + let role_ids: BTreeSet<&RoleId> = self.roles.iter().map(|r| &r.role_id).collect(); + for tri in &self.triangles { + for role_id in &tri.roles { + if !role_ids.contains(role_id) { + return Err(format!( + "triangle {:?}: role '{}' is not defined in template roles", + tri.triangle_id, role_id.0 + )); + } + } + } + + Ok(()) + } +} + // =========================================================================== // #107 — Intra-template triangle generation // =========================================================================== @@ -557,6 +682,16 @@ pub fn generate_intra_template_triangles( let role_to_npc: BTreeMap = npc_roles.into_iter().collect(); for def in defs { + // Validate triangle definition before generating TriangleState (#109). + // Mirrors the cross-template path in generate_cross_template_triangles. + if let Err(e) = validate_triangle_def(def) { + result.warnings.push(format!( + "Triangle {:?}: skipped — validation failed: {}", + def.triangle_id, e + )); + continue; + } + let mut role_assignments = BTreeMap::new(); let mut assigned_npcs = BTreeSet::new(); let mut assignment_ok = true; @@ -616,6 +751,231 @@ pub fn generate_intra_template_triangles( result } +// =========================================================================== +// #109 — Triangle validation +// =========================================================================== + +/// Errors returned when a `TriangleDef` fails instantiation-time validation. +/// +/// These checks run before role assignment to catch degenerate triangle +/// definitions that cannot produce meaningful drama. +/// +/// Spec: #109, D-024, D-087 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ValidationError { + /// None of the three role interest_axes is `NpcAxis::Want`. + /// + /// A viable conflict requires at least one role whose primary tension is + /// their Want axis — without it there is no active driver of conflict. + ConflictViability { triangle_id: TriangleId }, + + /// `relationship_constraints` is empty — no authored relationship links + /// the three roles together. + /// + /// A coherent triangle requires at least one explicit relationship + /// constraint documenting how the roles are socially connected. + RelationshipCoherence { triangle_id: TriangleId }, + + /// Two or more roles share the same `interest_axes` value. + /// + /// Each role must have a distinct tension axis so their interests genuinely + /// diverge. Duplicate axes indicate the triangle is underspecified. + InterestDivergence { + triangle_id: TriangleId, + duplicate_axis: NpcAxis, + }, +} + +impl std::fmt::Display for ValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ValidationError::ConflictViability { triangle_id } => write!( + f, + "Triangle {:?}: conflict viability — no NpcAxis::Want among the three interest_axes", + triangle_id + ), + ValidationError::RelationshipCoherence { triangle_id } => write!( + f, + "Triangle {:?}: relationship coherence — relationship_constraints is empty", + triangle_id + ), + ValidationError::InterestDivergence { triangle_id, duplicate_axis } => write!( + f, + "Triangle {:?}: interest divergence — duplicate interest_axes value {:?}", + triangle_id, duplicate_axis + ), + } + } +} + +/// Validate a `TriangleDef` for instantiation quality (#109). +/// +/// Three checks: +/// 1. **Conflict viability** — at least one of the three `interest_axes` is +/// `NpcAxis::Want`, ensuring an active want-driven tension. +/// 2. **Relationship coherence** — `relationship_constraints` is non-empty, +/// documenting at least one social link among the three roles. +/// 3. **Interest divergence** — all three `interest_axes` are distinct, so +/// each role brings a genuinely different tension to the triangle. +/// +/// Returns `Ok(())` if all checks pass, or `Err(ValidationError)` on the +/// first failure (conflict viability is checked first, then coherence, then +/// divergence). +pub fn validate_triangle_def(def: &TriangleDef) -> Result<(), ValidationError> { + // 1. Conflict viability: at least one Want axis + if !def.interest_axes.iter().any(|a| *a == NpcAxis::Want) { + return Err(ValidationError::ConflictViability { + triangle_id: def.triangle_id, + }); + } + + // 2. Relationship coherence: at least one relationship constraint + if def.relationship_constraints.is_empty() { + return Err(ValidationError::RelationshipCoherence { + triangle_id: def.triangle_id, + }); + } + + // 3. Interest divergence: all three axes must be distinct + let [a0, a1, a2] = def.interest_axes; + if a0 == a1 { + return Err(ValidationError::InterestDivergence { + triangle_id: def.triangle_id, + duplicate_axis: a0, + }); + } + if a0 == a2 { + return Err(ValidationError::InterestDivergence { + triangle_id: def.triangle_id, + duplicate_axis: a0, + }); + } + if a1 == a2 { + return Err(ValidationError::InterestDivergence { + triangle_id: def.triangle_id, + duplicate_axis: a1, + }); + } + + Ok(()) +} + +// =========================================================================== +// #108 — Cross-template triangle generation +// =========================================================================== + +/// Generate triangle instances that span two social site templates (#108). +/// +/// Implements the 1 cross-template triangle required by D-024 ("2 per template +/// minimum, 1 cross-template"). Role assignments draw from NPCs owned by +/// *either* `template_a_id` or `template_b_id` — the combined pool is used +/// for role lookup. +/// +/// The generated `TriangleState` is owned by `template_a_id`. D-025 ownership +/// model: NPCs are owned by one template but can hold reference roles in +/// another; the cross-template triangle represents this social link. +/// +/// **Validation:** each `TriangleDef` is validated via `validate_triangle_def` +/// before role assignment. Invalid defs are skipped with a warning added to +/// the result. +/// +/// **Fallback behavior:** same as `generate_intra_template_triangles` — if no +/// NPC satisfies a role constraint, the closest available NPC is used and a +/// warning is logged. Generation never panics. +/// +/// All randomness flows through `rng` for determinism (D-010). +pub fn generate_cross_template_triangles( + world: &mut World, + template_a_id: TemplateId, + template_b_id: TemplateId, + defs: &[TriangleDef], + rng: &mut SimRng, +) -> TriangleGenerationResult { + let mut result = TriangleGenerationResult { + triangles: Vec::new(), + warnings: Vec::new(), + }; + + // Collect NPCs from both templates into a single role → StableId lookup. + // BTreeMap for determinism (D-010). If both templates define the same + // role slug, template_a wins (insertion order: a first, b second via + // entry().or_insert). + let role_to_npc: BTreeMap = { + let mut q = world.query::<(&TemplateOwnership, &StableEntityId)>(); + let mut map = BTreeMap::new(); + for (own, sid) in q.iter(world) { + if own.template_id == template_a_id || own.template_id == template_b_id { + map.entry(own.role_id.clone()).or_insert(sid.0); + } + } + map + }; + + for def in defs { + // Validate the def before attempting role assignment. + if let Err(e) = validate_triangle_def(def) { + result.warnings.push(format!( + "Cross-template triangle {:?}: skipped — validation failed: {}", + def.triangle_id, e + )); + continue; + } + + let mut role_assignments = BTreeMap::new(); + let mut assigned_npcs = BTreeSet::new(); + let mut assignment_ok = true; + + for role_id in &def.roles { + if let Some(&stable_id) = role_to_npc.get(role_id) { + if !assigned_npcs.contains(&stable_id) { + role_assignments.insert(role_id.clone(), stable_id); + assigned_npcs.insert(stable_id); + continue; + } + } + + // Fallback: first available NPC not already in this triangle. + let fallback = role_to_npc + .values() + .find(|sid| !assigned_npcs.contains(sid)); + + if let Some(&fallback_sid) = fallback { + result.warnings.push(format!( + "Cross-template triangle {:?}: no NPC for role '{}' — assigned fallback StableId({})", + def.triangle_id, role_id.0, fallback_sid.0 + )); + role_assignments.insert(role_id.clone(), fallback_sid); + assigned_npcs.insert(fallback_sid); + } else { + result.warnings.push(format!( + "Cross-template triangle {:?}: no NPC available for role '{}' — skipping", + def.triangle_id, role_id.0 + )); + assignment_ok = false; + break; + } + } + + if !assignment_ok { + continue; + } + + let initial_tension: u8 = rng.rng.random_range(5_u8..=25); + let tension_rate: u8 = rng.rng.random_range(1_u8..=5); + + result.triangles.push(TriangleState { + triangle_id: def.triangle_id, + role_assignments, + tension: initial_tension, + phase: TrianglePhase::Simmering, + tension_rate, + template_id: template_a_id, // cross-template triangle owned by template_a + }); + } + + result +} + // =========================================================================== // #250 — Triangle escalation system // =========================================================================== @@ -1013,7 +1373,11 @@ mod tests { ], conflict_type: ConflictType::LoyaltyConflict, interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], - relationship_constraints: vec![], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("waitstaff"), + kind: crate::npc::RelationshipKind::Colleague, + required_trust: TrustRange { min: 0, max: 5 }, + }], }, TriangleDef { triangle_id: TriangleId::from_seed_and_roles( @@ -1031,7 +1395,11 @@ mod tests { ], conflict_type: ConflictType::ResourceCompetition, interest_axes: [NpcAxis::Want, NpcAxis::Tolerance, NpcAxis::Contentment], - relationship_constraints: vec![], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("bouncer"), + kind: crate::npc::RelationshipKind::Colleague, + required_trust: TrustRange { min: 0, max: 3 }, + }], }, ]; @@ -1073,8 +1441,12 @@ mod tests { RoleId::new("missing_role"), // no NPC has this role ], conflict_type: ConflictType::SecretExposure, - interest_axes: [NpcAxis::Secret, NpcAxis::Secret, NpcAxis::Secret], - relationship_constraints: vec![], + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Tolerance], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("bouncer"), + kind: crate::npc::RelationshipKind::Colleague, + required_trust: TrustRange { min: 0, max: 5 }, + }], }, TriangleDef { triangle_id: TriangleId(101), @@ -1084,8 +1456,12 @@ mod tests { RoleId::new("also_missing"), ], conflict_type: ConflictType::LatentTension, - interest_axes: [NpcAxis::Contentment, NpcAxis::Contentment, NpcAxis::Tolerance], - relationship_constraints: vec![], + interest_axes: [NpcAxis::Want, NpcAxis::Contentment, NpcAxis::Tolerance], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("bouncer"), + kind: crate::npc::RelationshipKind::Rival, + required_trust: TrustRange { min: -5, max: 0 }, + }], }, ]; @@ -1125,8 +1501,12 @@ mod tests { RoleId::new("regular"), ], conflict_type: ConflictType::ResourceCompetition, - interest_axes: [NpcAxis::Want, NpcAxis::Want, NpcAxis::Want], - relationship_constraints: vec![], + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Tolerance], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("bouncer"), + kind: crate::npc::RelationshipKind::Colleague, + required_trust: TrustRange { min: 0, max: 5 }, + }], }]; // Should succeed with 1 triangle but log an error about < 2 @@ -1148,7 +1528,11 @@ mod tests { ], conflict_type: ConflictType::LoyaltyConflict, interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], - relationship_constraints: vec![], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("b"), + kind: crate::npc::RelationshipKind::Colleague, + required_trust: TrustRange { min: 0, max: 5 }, + }], }, TriangleDef { triangle_id: TriangleId(301), @@ -1158,8 +1542,12 @@ mod tests { RoleId::new("d"), ], conflict_type: ConflictType::LatentTension, - interest_axes: [NpcAxis::Contentment, NpcAxis::Contentment, NpcAxis::Tolerance], - relationship_constraints: vec![], + interest_axes: [NpcAxis::Want, NpcAxis::Contentment, NpcAxis::Tolerance], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("c"), + kind: crate::npc::RelationshipKind::Rival, + required_trust: TrustRange { min: -5, max: 0 }, + }], }, ]; @@ -1214,7 +1602,6 @@ mod tests { // #250 — Triangle escalation system tests // ----------------------------------------------------------------------- - use crate::npc::Npc; use crate::simulation::time::SimulationTime; use bevy_ecs::schedule::Schedule; @@ -1227,33 +1614,6 @@ mod tests { world } - /// Helper: spawn an NPC with a known StableId and ToleranceThreshold, - /// and register it in the EntityRegistry. - fn spawn_escalation_npc( - world: &mut bevy_ecs::world::World, - stable_id: u64, - threshold: i16, - ) -> bevy_ecs::entity::Entity { - let sid = StableId(stable_id); - let entity = world - .spawn(( - Npc, - ActiveSim, - StableEntityId(sid), - ToleranceThreshold { - current_stress: 0, - threshold, - }, - )) - .id(); - - world - .resource_mut::() - .register_existing(entity, sid); - - entity - } - // escalation_simmering_to_active, resolve, dormant_skip, resolved_skip, // game-minute-only, active-sim-only, saturation, resolve-targeting — all // covered by integration tests in tests/triangle_escalation.rs. diff --git a/server/src/main.rs b/server/src/main.rs index 61b66fd2d..298a1426e 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -176,11 +176,43 @@ fn main() { // 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(); - app.update(); + // Wrap app.update() in catch_unwind to handle system panics (#85). + // AssertUnwindSafe is required because App is not UnwindSafe. + let tick_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + app.update(); + })); + + match tick_result { + Ok(()) => {} + Err(panic_payload) => { + // Extract panic message for error reporting + let panic_msg = if let Some(s) = panic_payload.downcast_ref::<&str>() { + s.to_string() + } else if let Some(s) = panic_payload.downcast_ref::() { + s.clone() + } else { + "unknown panic".to_string() + }; + + tracing::error!("Simulation panic caught: {}", panic_msg); + + // Attempt to send a final SimError snapshot to the client. + // Best-effort: if the bridge is unavailable, we just log and exit. + send_panic_error(&app, &panic_msg); + + tracing::error!("Server shutting down after panic"); + break; + } + } + if !app.world().resource::().0 { break; } @@ -200,6 +232,78 @@ fn main() { 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::() + .map(|t| t.tick) + .unwrap_or(0); + + let bridge = match world.get_resource::() { + Some(b) => b, + None => { + tracing::error!("Cannot send panic error: no BridgeResource"); + return; + } + }; + + // Build a minimal snapshot carrying the panic error + let snapshot = ObserverSnapshot { + version: PROTOCOL_VERSION, + 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![], + conversation_events: vec![], + conversation_ended: 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, + 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). /// diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index f1b3de548..011e65591 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -9,6 +9,7 @@ use bevy_ecs::prelude::*; use std::collections::BTreeSet; + use crate::bridge::types::*; use crate::knowledge::graph::filter_by_access; use crate::knowledge::types::{AccessRule, KnowledgeState}; @@ -103,6 +104,8 @@ pub fn compute_observer_snapshot( mut crisis_queue: ResMut, sim_rng: Option>, pressure_query: Query<&crate::simulation::pressure::CharacterPressure, With>, + error_buffer: Option>, + npc_count_query: Query>, ) { let Ok(( observer_entity, @@ -390,12 +393,47 @@ pub fn compute_observer_snapshot( let save_result = buffer.pending_save_result.take(); // Drain triangle crisis events (#250) and convert to wire format. - let triangle_crisis_events = crisis_queue + // Drain triangle crisis events (#250) and filter role_assignments against + // observer KG (D-010 principle 2: information boundaries are universal). + // NPCs unknown to the observer are redacted from the wire event. + let triangle_crisis_events: Vec = crisis_queue .drain() .into_iter() - .map(TriangleCrisisEventWire::from) + .map(|e| { + let mut wire = TriangleCrisisEventWire::from(e); + wire.role_assignments.retain(|(_, npc_id)| { + observer_kg.knows_entity(&crate::knowledge::types::StableId(*npc_id)) + }); + wire + }) .collect(); + // Compute state hash for desync detection (#85). + // Hash inputs: player position (x, y, z), NPC count, tick number. + // Uses FNV-1a (64-bit) for determinism across Rust versions — DefaultHasher + // is explicitly prohibited by D-010 principle 4 (see template.rs module docs). + let state_hash = { + let mut hash: u64 = 0xcbf29ce484222325; // FNV-1a offset basis + let fnv_fold = |h: &mut u64, bytes: &[u8]| { + for &b in bytes { + *h ^= b as u64; + *h = h.wrapping_mul(0x100000001b3); // FNV-1a prime + } + }; + fnv_fold(&mut hash, &time.tick.to_le_bytes()); + fnv_fold(&mut hash, &observer_pos.x.to_le_bytes()); + fnv_fold(&mut hash, &observer_pos.y.to_le_bytes()); + fnv_fold(&mut hash, &observer_pos.z.to_le_bytes()); + let npc_count = npc_count_query.iter().count() as u64; + fnv_fold(&mut hash, &npc_count.to_le_bytes()); + Some(hash) + }; + + // Drain sim errors collected this tick (#85) + let sim_errors = error_buffer + .map(|mut buf| buf.drain()) + .unwrap_or_default(); + buffer.snapshot = Some(ObserverSnapshot { version: crate::bridge::types::PROTOCOL_VERSION, tick: time.tick, @@ -424,6 +462,8 @@ pub fn compute_observer_snapshot( player_knowledge, save_result, triangle_crisis_events, + state_hash, + sim_errors, }); } diff --git a/server/src/simulation/examine.rs b/server/src/simulation/examine.rs index 4a9b530f3..8a0012cd9 100644 --- a/server/src/simulation/examine.rs +++ b/server/src/simulation/examine.rs @@ -19,7 +19,7 @@ use crate::knowledge::events::{KnowledgeEvent, KnowledgeEventQueue, KnowledgeEve use crate::knowledge::EntityRegistry; use crate::npc::mood::{MoodState, NpcMood}; use crate::npc::{PersonalityTrait, PersonalityTraits, ToleranceThreshold}; -use crate::simulation::interaction::CLOSE_RANGE; +use crate::simulation::interaction::{ObjectType, CLOSE_RANGE}; use crate::simulation::movement::{PlayerCharacter, TilePosition}; use crate::simulation::time::SimulationTime; @@ -50,6 +50,14 @@ pub struct ExamineResultEvent { pub target_entity_id: u64, } +/// Authored examine text for a non-NPC entity (#246). +/// +/// Attach to any examinable object (Readable, Terminal, etc.) to provide +/// a fixed description returned when the player examines it. +/// If absent, examining a non-NPC entity returns a generic fallback. +#[derive(Component, Debug, Clone)] +pub struct ExamineText(pub String); + /// Buffer holding the examine result for snapshot inclusion. /// /// Consumed once per snapshot via `take()`. Cleared at snapshot build time. @@ -171,6 +179,11 @@ pub fn generate_examine_text( /// Process examine interaction: generate character-filtered observation text, /// push DirectObservation to KnowledgeGraph, write result to ExamineResultBuffer. /// +/// Handles two target types: +/// - NPC entities: generate character-filtered text from NPC component state. +/// - Non-NPC entities with `ExamineText`: use the authored text directly. +/// - Non-NPC entities without `ExamineText`: generic fallback text. +/// /// System ordering: after process_player_input, before compute_observer_snapshot. #[allow(clippy::type_complexity)] pub fn process_examine_interaction( @@ -188,12 +201,16 @@ pub fn process_examine_interaction( ), With, >, - npc_query: Query<( - &TilePosition, - Option<&MoodState>, - Option<&ToleranceThreshold>, - Option<&PersonalityTraits>, - )>, + npc_query: Query< + ( + &TilePosition, + Option<&MoodState>, + Option<&ToleranceThreshold>, + Option<&PersonalityTraits>, + ), + Without, + >, + examine_text_query: Query<(&TilePosition, Option<&ExamineText>)>, ) { let Ok((player_entity, player_pos, examine_req, archetype_opt, mut result_buffer)) = player_query.single_mut() @@ -204,55 +221,64 @@ pub fn process_examine_interaction( let target = examine_req.target; let archetype = archetype_opt.copied().unwrap_or_default(); - // Range check — examine requires close range (same as Talk/Confront) - let Ok((target_pos, mood_opt, tolerance_opt, traits_opt)) = npc_query.get(target) else { - tracing::warn!(?target, "process_examine_interaction: target not in query"); - commands.entity(player_entity).remove::(); - return; - }; + // Try NPC examine path first + if let Ok((target_pos, mood_opt, tolerance_opt, traits_opt)) = npc_query.get(target) { + let distance = player_pos.manhattan_distance(target_pos).unwrap_or(u32::MAX); + if distance > CLOSE_RANGE { + tracing::info!(distance, "Examine: NPC target out of range (max {})", CLOSE_RANGE); + commands.entity(player_entity).remove::(); + return; + } - let distance = player_pos.manhattan_distance(target_pos).unwrap_or(u32::MAX); - if distance > CLOSE_RANGE { - tracing::info!( - distance, - "Examine: target out of range (max {})", - CLOSE_RANGE - ); + let mood = mood_opt.map(|m| m.mood).unwrap_or(NpcMood::Neutral); + let ratio = tolerance_opt.map(stress_ratio).unwrap_or(0); + let text = generate_examine_text(mood, ratio, archetype, traits_opt); + + kg_events.push(KnowledgeEvent { + observer: player_entity, + tick: time.tick, + event_type: KnowledgeEventType::DirectObservation { + target, + position: *target_pos, + }, + }); + + let target_entity_id = registry.to_stable(target).map(|sid| sid.0).unwrap_or_else(|| { + tracing::warn!(?target, "Examine: NPC not in EntityRegistry, using bits"); + target.to_bits() + }); + + result_buffer.result = Some(ExamineResultEvent { text, target_entity_id }); + tracing::debug!(target_entity_id, "Examine: NPC result written to buffer"); commands.entity(player_entity).remove::(); return; } - let mood = mood_opt.map(|m| m.mood).unwrap_or(NpcMood::Neutral); - let ratio = tolerance_opt.map(stress_ratio).unwrap_or(0); + // Object examine path: entity has a TilePosition but no NPC mood components. + if let Ok((target_pos, examine_text_opt)) = examine_text_query.get(target) { + let distance = player_pos.manhattan_distance(target_pos).unwrap_or(u32::MAX); + if distance > CLOSE_RANGE { + tracing::info!(distance, "Examine: object target out of range (max {})", CLOSE_RANGE); + commands.entity(player_entity).remove::(); + return; + } - let text = generate_examine_text(mood, ratio, archetype, traits_opt); + let text = examine_text_opt + .map(|et| et.0.clone()) + .unwrap_or_else(|| "No further details are apparent.".to_string()); - // Push DirectObservation to KnowledgeEventQueue - kg_events.push(KnowledgeEvent { - observer: player_entity, - tick: time.tick, - event_type: KnowledgeEventType::DirectObservation { - target, - position: *target_pos, - }, - }); + let target_entity_id = registry.to_stable(target).map(|sid| sid.0).unwrap_or_else(|| { + tracing::warn!(?target, "Examine: object not in EntityRegistry, using bits"); + target.to_bits() + }); - // Resolve target wire ID for snapshot - let target_entity_id = registry.to_stable(target).map(|sid| sid.0).unwrap_or_else(|| { - tracing::warn!(?target, "Examine: target not in EntityRegistry, using bits"); - target.to_bits() - }); - - result_buffer.result = Some(ExamineResultEvent { - text, - target_entity_id, - }); - - tracing::debug!( - target_entity_id, - "Examine: DirectObservation pushed, result written to buffer" - ); + result_buffer.result = Some(ExamineResultEvent { text, target_entity_id }); + tracing::debug!(target_entity_id, "Examine: object result written to buffer"); + commands.entity(player_entity).remove::(); + return; + } + tracing::warn!(?target, "process_examine_interaction: target has no position component"); commands.entity(player_entity).remove::(); } diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index b72a7259f..789991789 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -2,9 +2,10 @@ // Timestamped player input events for deterministic simulation (D-010 principle 4) // PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode, ToggleStance) -use crate::bridge::types::{FacingDirection, PlayerAction, PlayerInput}; +use crate::bridge::types::{FacingDirection, ObjectType, PlayerAction, PlayerInput}; use crate::knowledge::{EntityRegistry, StableId}; use crate::perception::vision_cone::{facing_from_delta, Facing}; +use crate::simulation::interaction::{DoorInteractRequest, DoorState, TerminalInteractRequest}; use crate::simulation::inventory::{ find_next_slot, occupied_slots_for, CarriedBy, InventorySlot, ItemName, MAX_INVENTORY_SLOTS, }; @@ -97,6 +98,8 @@ pub fn process_player_input( reset_triggers: Query<&RoomResetTrigger>, mut room_snapshots: Option>, mut save_load: Option>, + door_states: Query<&DoorState>, + object_types: Query<&ObjectType>, ) { let current_tick = time.tick; let paused = time.paused(); @@ -256,6 +259,25 @@ pub fn process_player_input( current_tick, ); } + // #246: Door and Terminal behavior + Some("Open") | Some("Close") => { + handle_door_interact( + &mut commands, + ®istry, + &player_query, + &door_states, + target_entity_id, + ); + } + Some("Use") => { + handle_terminal_interact( + &mut commands, + ®istry, + &player_query, + &object_types, + target_entity_id, + ); + } _ => { tracing::info!( "Interact: target={:?}, verb={:?} — logged only", @@ -872,6 +894,101 @@ fn handle_reset( ); } +/// Handle door Open/Close: insert `DoorInteractRequest` on the player entity (#246). +/// +/// The actual walkability toggle is done by `process_door_interaction` which +/// reads the request and modifies `WalkabilityMap`. The split keeps system +/// ordering explicit and avoids mutable resource conflicts in one system. +fn handle_door_interact( + commands: &mut Commands, + registry: &EntityRegistry, + player_query: &Query< + ( + Entity, + &TilePosition, + Option<&mut Stance>, + Option<&mut PlayerMoveCooldown>, + ), + With, + >, + door_states: &Query<&DoorState>, + target_entity_id: Option, +) { + let Some(target_id) = target_entity_id else { + tracing::warn!("Door verb without target_entity_id"); + return; + }; + + let target_stable = StableId(target_id); + let Some(target_entity) = registry.to_entity(&target_stable) else { + tracing::warn!(target_id, "Door interact: target entity not in registry"); + return; + }; + + // Verify target has DoorState before inserting request + if door_states.get(target_entity).is_err() { + tracing::warn!(target_id, "Door verb on entity without DoorState — ignored"); + return; + } + + let Ok((player_entity, _, _, _)) = player_query.single() else { + return; + }; + + commands + .entity(player_entity) + .insert(DoorInteractRequest { door_entity: target_entity }); + + tracing::debug!(target_id, "Door interact: DoorInteractRequest inserted on player"); +} + +/// Handle Terminal Use: insert `TerminalInteractRequest` on the player entity (#246). +fn handle_terminal_interact( + commands: &mut Commands, + registry: &EntityRegistry, + player_query: &Query< + ( + Entity, + &TilePosition, + Option<&mut Stance>, + Option<&mut PlayerMoveCooldown>, + ), + With, + >, + object_types: &Query<&ObjectType>, + target_entity_id: Option, +) { + let Some(target_id) = target_entity_id else { + tracing::warn!("Use verb without target_entity_id"); + return; + }; + + let target_stable = StableId(target_id); + let Some(target_entity) = registry.to_entity(&target_stable) else { + tracing::warn!(target_id, "Terminal interact: target entity not in registry"); + return; + }; + + // Verify target is a Terminal + match object_types.get(target_entity) { + Ok(ObjectType::Terminal) => {} + _ => { + tracing::warn!(target_id, "Use verb on non-Terminal entity — ignored"); + return; + } + } + + let Ok((player_entity, _, _, _)) = player_query.single() else { + return; + }; + + commands + .entity(player_entity) + .insert(TerminalInteractRequest { terminal_entity: target_entity }); + + tracing::debug!(target_id, "Terminal interact: TerminalInteractRequest inserted on player"); +} + /// Handle TeleportToHub: move player to hub spawn, clear interaction state (#491). /// /// Gauntlet-only action. On non-Gauntlet maps (feature disabled), logs a warning diff --git a/server/src/simulation/interaction.rs b/server/src/simulation/interaction.rs index 7e1337d6d..6cff96115 100644 --- a/server/src/simulation/interaction.rs +++ b/server/src/simulation/interaction.rs @@ -1,6 +1,7 @@ // Interaction system — proximity detection + multi-verb InteractionOptions // Implements #404: server-side verb computation for context-sensitive [E] key // Extended by #421: ObjectType component + verb sets per type (D-057) +// Extended by #246: door toggle, terminal event, examine-text (#246) // Spec: docs/design/interaction-verbs-v0.1.md // D-060: actions[] renamed to verbs[] across all surfaces // @@ -10,14 +11,17 @@ // Phase 2 filtering (KG-gated verbs) handled by #422. use bevy_ecs::prelude::*; +use serde::{Deserialize, Serialize}; // Re-export ObjectType for backward compatibility — definition moved to bridge::types (#422). pub use crate::bridge::types::ObjectType; use crate::bridge::types::{EntityKind, MovementStance, NearbyInteraction, VerbKind, VerbOption}; use crate::knowledge::EntityRegistry; +use crate::knowledge::types::StableId; use crate::npc::Npc; -use crate::simulation::movement::{PlayerCharacter, TilePosition}; +use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; use crate::simulation::stance::Stance; +use crate::simulation::time::SimulationTime; /// Interaction range thresholds (Manhattan distance, same z-level) pub(crate) const CLOSE_RANGE: u32 = 2; @@ -317,6 +321,159 @@ impl NearbyInteractionBuffer { } } +// =========================================================================== +// #246 — Door behavior (toggle walkability) +// =========================================================================== + +/// Component tracking the open/closed state of a door and its blocking tile. +/// +/// Attach to any entity with `ObjectType::Door`. The `blocking_tile` is the +/// tile that becomes walkable when the door opens and impassable when it closes. +/// +/// Door state is persisted in `SaveStateV1.open_doors` (D-010). +#[derive(Component, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DoorState { + /// Whether the door is currently open (walkable) or closed (blocking). + pub is_open: bool, + /// The tile whose walkability is toggled by this door. + pub blocking_tile: TilePosition, +} + +impl DoorState { + pub fn new(blocking_tile: TilePosition) -> Self { + DoorState { + is_open: false, + blocking_tile, + } + } +} + +/// Marker: player requested a door interaction (Open or Close) this tick. +/// +/// Inserted by `process_player_input` when verb is "Open" or "Close" on a +/// Door entity. Consumed and removed by `process_door_interaction`. +#[derive(Component, Debug)] +pub struct DoorInteractRequest { + /// The ECS entity of the door to toggle. + pub door_entity: Entity, +} + +/// Event emitted when a player uses a Terminal (#246). +/// +/// Downstream systems (dialogue hook — future work) subscribe to this queue. +/// The queue is not automatically drained — consumers must call `drain()`. +#[derive(Debug, Clone)] +pub struct TerminalInteracted { + /// Stable ID of the terminal entity. + pub terminal_id: StableId, + /// Tick when the interaction occurred. + pub tick: u64, +} + +/// Resource: queue of terminal interaction events (#246). +#[derive(Resource, Default)] +pub struct TerminalInteractedQueue { + pub events: Vec, +} + +impl TerminalInteractedQueue { + pub fn push(&mut self, event: TerminalInteracted) { + self.events.push(event); + } + + pub fn drain(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } +} + +/// Marker: player requested a Terminal interaction this tick. +/// +/// Inserted by `process_player_input` when verb is "Use" on a Terminal entity. +/// Consumed and removed by `process_terminal_interaction`. +#[derive(Component, Debug)] +pub struct TerminalInteractRequest { + pub terminal_entity: Entity, +} + +/// System: toggle door open/closed state and update walkability map (#246). +/// +/// Reads `DoorInteractRequest` on the player entity. Toggles `DoorState.is_open` +/// and updates `WalkabilityMap` for the door's `blocking_tile`. +/// +/// Ordering: after `process_player_input`, before movement validation. +pub fn process_door_interaction( + mut commands: Commands, + walkability: Option>, + player_query: Query<(Entity, &DoorInteractRequest), With>, + mut door_query: Query<&mut DoorState>, +) { + let Ok((player_entity, req)) = player_query.single() else { + return; + }; + + let door_entity = req.door_entity; + commands.entity(player_entity).remove::(); + + let Ok(mut door) = door_query.get_mut(door_entity) else { + tracing::warn!(?door_entity, "process_door_interaction: no DoorState on target"); + return; + }; + + // Toggle state + door.is_open = !door.is_open; + let walkable = door.is_open; + let tile = door.blocking_tile; + + if let Some(mut walkability) = walkability { + walkability.set_walkable(&tile, walkable); + } + + tracing::info!( + ?tile, + is_open = door.is_open, + "Door toggled: tile walkability set to {walkable}" + ); +} + +/// System: emit TerminalInteracted event when player uses a Terminal (#246). +/// +/// Reads `TerminalInteractRequest` on the player entity, emits to +/// `TerminalInteractedQueue`, and removes the request. +pub fn process_terminal_interaction( + mut commands: Commands, + time: Res, + registry: Res, + player_query: Query<(Entity, &TerminalInteractRequest), With>, + mut queue: ResMut, +) { + let Ok((player_entity, req)) = player_query.single() else { + return; + }; + + let terminal_entity = req.terminal_entity; + commands.entity(player_entity).remove::(); + + let terminal_id = registry + .to_stable(terminal_entity) + .unwrap_or(StableId(terminal_entity.to_bits())); + + queue.push(TerminalInteracted { + terminal_id, + tick: time.tick, + }); + + tracing::info!( + ?terminal_entity, + terminal_id = terminal_id.0, + tick = time.tick, + "Terminal used: TerminalInteracted event queued" + ); +} + #[cfg(test)] mod tests { use super::*; diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index a0f37f2f7..f0d6958d5 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -62,6 +62,7 @@ impl Plugin for SimulationPlugin { // Init here so SimulationPlugin works standalone in tests without those plugins. .init_resource::() .init_resource::() + .init_resource::() .add_systems( Update, ( @@ -92,6 +93,12 @@ impl Plugin for SimulationPlugin { poi_discovery::discover_pois .after(crate::perception::observer::compute_visibility_geometry) .before(crate::perception::observer::compute_observer_snapshot), + interaction::process_door_interaction + .after(input::process_player_input) + .before(movement::validate_movement), + interaction::process_terminal_interaction + .after(input::process_player_input) + .before(crate::perception::observer::compute_observer_snapshot), examine::process_examine_interaction .after(input::process_player_input) .before(crate::perception::observer::compute_observer_snapshot), diff --git a/server/src/simulation/save_io.rs b/server/src/simulation/save_io.rs index 895f4ac75..16519a59f 100644 --- a/server/src/simulation/save_io.rs +++ b/server/src/simulation/save_io.rs @@ -26,6 +26,8 @@ use crate::simulation::rng::SimRng; use crate::simulation::save_state::{ deserialize_npc_from_frozen, serialize_npc_to_frozen, SaveStateV1, SAVE_FORMAT_VERSION, }; +use crate::knowledge::types::StableId; +use crate::simulation::interaction::DoorState; use crate::simulation::tier::BackgroundSim; use crate::simulation::time::SimulationTime; @@ -130,6 +132,17 @@ pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError> npc_states, template_references, triangle_states, + open_doors: { + use crate::knowledge::registry::StableEntityId; + let mut q = world.query::<(&DoorState, &StableEntityId)>(); + let mut ids: Vec<_> = q + .iter(world) + .filter(|(ds, _)| ds.is_open) + .map(|(_, sid)| sid.0) + .collect(); + ids.sort_by_key(|id| id.0); + ids + }, }; let bytes = state @@ -230,6 +243,28 @@ pub fn load_from_file(path: &Path, world: &mut World) -> Result<(), SaveLoadErro } world.insert_resource(SimRng::new(state.seed)); + // Restore door open states (#246) — find door entities by StableId and toggle. + if !state.open_doors.is_empty() { + let open_set: std::collections::HashSet<_> = state.open_doors.iter().copied().collect(); + let door_entities: Vec<(Entity, StableId)> = { + let mut q = world.query::<(Entity, &crate::knowledge::registry::StableEntityId, &DoorState)>(); + q.iter(world) + .filter(|(_, sid, _)| open_set.contains(&sid.0)) + .map(|(e, sid, _)| (e, sid.0)) + .collect() + }; + for (entity, sid) in door_entities { + if let Some(mut door) = world.get_mut::(entity) { + door.is_open = true; + let tile = door.blocking_tile; + if let Some(mut wmap) = world.get_resource_mut::() { + wmap.set_walkable(&tile, true); + } + tracing::debug!(stable_id = sid.0, "load: restored open door state"); + } + } + } + // Update the player entity's KnowledgeGraph if a player exists. let player_entity = { let mut q = world.query_filtered::>(); @@ -522,6 +557,7 @@ mod tests { npc_states: vec![], template_references: TemplateReferenceMap::default(), triangle_states: vec![], + open_doors: vec![], }; let bytes = bad_state.to_bytes().expect("serialize"); let path = temp_path(); diff --git a/server/src/simulation/save_state.rs b/server/src/simulation/save_state.rs index f6c24ef9c..5beddc540 100644 --- a/server/src/simulation/save_state.rs +++ b/server/src/simulation/save_state.rs @@ -94,6 +94,11 @@ pub struct SaveStateV1 { /// for deterministic serialization (D-010). #[serde(default)] pub triangle_states: Vec, + /// Stable IDs of doors that are currently open (#246). + /// Doors not in this list are assumed closed on load. Sorted ascending + /// for deterministic serialization (D-010). + #[serde(default)] + pub open_doors: Vec, } /// Per-NPC state snapshot for `SaveStateV1`. @@ -405,6 +410,7 @@ mod tests { npc_states: vec![], template_references: TemplateReferenceMap::default(), triangle_states: vec![], + open_doors: vec![], } } @@ -801,6 +807,7 @@ mod tests { npc_states: vec![frozen], template_references: TemplateReferenceMap::default(), triangle_states: vec![], + open_doors: vec![], }; let bytes = save.to_bytes().expect("serialize"); diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index 0c18df4bd..55275814c 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -73,6 +73,8 @@ fn snapshot_roundtrip_over_unix_socket() { player_knowledge: None, save_result: None, triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], }; bridge diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index c7095a7bf..75ad29a3d 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -59,6 +59,8 @@ fn snapshot_roundtrip_over_tcp() { player_knowledge: None, save_result: None, triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], }; bridge diff --git a/server/tests/environmental_interaction.rs b/server/tests/environmental_interaction.rs new file mode 100644 index 000000000..f3d2b72b1 --- /dev/null +++ b/server/tests/environmental_interaction.rs @@ -0,0 +1,449 @@ +//! Integration tests for basic environmental interaction (#246). +//! +//! Covers the acceptance criteria from the sprint briefing: +//! - Door toggle: player interacts with a Door, walkability flips; interacts again, flips back. +//! - Readable examine: Examine on a Readable entity returns non-empty text. +//! - Terminal interaction: Use on a Terminal emits TerminalInteracted event. +//! - DoorState persists in SaveStateV1.open_doors. + +use bevy_ecs::{prelude::*, schedule::Schedule, world::World}; +use settled_reach_server::{ + knowledge::{registry::EntityRegistry, registry::StableEntityId, types::StableId}, + npc::relationships::RelationshipGraph, + simulation::{ + examine::{ + process_examine_interaction, ExamineRequest, ExamineResultBuffer, ExamineText, + }, + interaction::{ + process_door_interaction, process_terminal_interaction, DoorInteractRequest, + DoorState, Interactable, ObjectType, TerminalInteractRequest, TerminalInteractedQueue, + }, + movement::{PlayerCharacter, TilePosition, WalkabilityMap}, + save_state::{SaveStateV1, SAVE_FORMAT_VERSION}, + time::{SimulationTime, TickRate}, + }, +}; +use settled_reach_server::knowledge::KnowledgeGraph; +use settled_reach_server::content::template::TemplateReferenceMap; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn make_world_with_walkability(width: i32, height: i32) -> World { + let mut world = World::new(); + world.insert_resource(WalkabilityMap::new(width, height, 1)); + world.init_resource::(); + world +} + +fn spawn_player(world: &mut World, x: i32, y: i32) -> Entity { + world + .spawn(( + PlayerCharacter, + TilePosition::new(x, y, 0), + )) + .id() +} + +fn spawn_door(world: &mut World, x: i32, y: i32, blocking_x: i32, blocking_y: i32) -> Entity { + world + .spawn(( + TilePosition::new(x, y, 0), + Interactable, + ObjectType::Door, + DoorState::new(TilePosition::new(blocking_x, blocking_y, 0)), + )) + .id() +} + +// --------------------------------------------------------------------------- +// Door behavior tests +// --------------------------------------------------------------------------- + +/// Acceptance: player interacts with Door, walkability flips; interacts again, flips back. +#[test] +fn door_toggle_flips_walkability_both_ways() { + let mut world = make_world_with_walkability(20, 20); + + // Block the door tile initially + world + .resource_mut::() + .set_walkable(&TilePosition::new(10, 5, 0), false); + + let player = spawn_player(&mut world, 10, 6); + let door = spawn_door(&mut world, 10, 6, 10, 5); // door tile at (10,5) + + let mut schedule = Schedule::default(); + schedule.add_systems(process_door_interaction); + + // First interaction: open the door + world + .entity_mut(player) + .insert(DoorInteractRequest { door_entity: door }); + + schedule.run(&mut world); + + assert!( + world.resource::().can_move_to(&TilePosition::new(10, 5, 0)), + "after Open: blocking tile must become walkable" + ); + assert!( + world.get::(door).unwrap().is_open, + "DoorState.is_open must be true after opening" + ); + // Request should be consumed + assert!( + world.get::(player).is_none(), + "DoorInteractRequest must be removed after processing" + ); + + // Second interaction: close the door + world + .entity_mut(player) + .insert(DoorInteractRequest { door_entity: door }); + + schedule.run(&mut world); + + assert!( + !world.resource::().can_move_to(&TilePosition::new(10, 5, 0)), + "after Close: blocking tile must be impassable again" + ); + assert!( + !world.get::(door).unwrap().is_open, + "DoorState.is_open must be false after closing" + ); +} + +/// Door starts open: toggling closes it (walkability → blocked). +#[test] +fn door_starts_open_toggle_closes_it() { + let mut world = make_world_with_walkability(20, 20); + + // Start with door open (tile walkable, is_open = true) + let player = spawn_player(&mut world, 10, 6); + let door = world + .spawn(( + TilePosition::new(10, 6, 0), + Interactable, + ObjectType::Door, + DoorState { + is_open: true, + blocking_tile: TilePosition::new(10, 5, 0), + }, + )) + .id(); + + // Tile starts walkable (default map is all walkable) + assert!( + world.resource::().can_move_to(&TilePosition::new(10, 5, 0)), + "precondition: tile is walkable when door starts open" + ); + + let mut schedule = Schedule::default(); + schedule.add_systems(process_door_interaction); + + world + .entity_mut(player) + .insert(DoorInteractRequest { door_entity: door }); + + schedule.run(&mut world); + + assert!( + !world.resource::().can_move_to(&TilePosition::new(10, 5, 0)), + "toggling an open door must block the tile" + ); + assert!( + !world.get::(door).unwrap().is_open, + "DoorState.is_open must be false after closing an open door" + ); +} + +/// Missing DoorState on target: system logs warning and removes request without panic. +#[test] +fn door_interact_without_door_state_does_not_panic() { + let mut world = make_world_with_walkability(10, 10); + let player = spawn_player(&mut world, 5, 5); + let not_a_door = world.spawn(TilePosition::new(5, 6, 0)).id(); + + world + .entity_mut(player) + .insert(DoorInteractRequest { door_entity: not_a_door }); + + let mut schedule = Schedule::default(); + schedule.add_systems(process_door_interaction); + schedule.run(&mut world); + + // Should not panic; request removed + assert!( + world.get::(player).is_none(), + "DoorInteractRequest must be consumed even when target lacks DoorState" + ); +} + +// --------------------------------------------------------------------------- +// Readable examine tests +// --------------------------------------------------------------------------- + +/// Acceptance: Examine on a Readable entity with ExamineText returns non-empty text. +#[test] +fn examine_readable_returns_authored_text() { + use settled_reach_server::knowledge::events::KnowledgeEventQueue; + + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + ExamineResultBuffer::default(), + )) + .id(); + + let readable = world + .spawn(( + TilePosition::new(5, 6, 0), + Interactable, + ObjectType::Readable, + ExamineText("A logistics manifest. Freight records dating back three cycles.".to_string()), + )) + .id(); + + world + .entity_mut(player) + .insert(ExamineRequest { target: readable }); + + let mut schedule = Schedule::default(); + schedule.add_systems(process_examine_interaction); + schedule.run(&mut world); + + let event = world + .get_mut::(player) + .unwrap() + .take(); + assert!( + event.is_some(), + "ExamineResultBuffer must contain a result after examining a Readable" + ); + let text = event.unwrap().text; + assert!( + !text.is_empty(), + "examine result text must be non-empty for a Readable entity" + ); + assert!( + text.contains("manifest") || text.contains("Freight") || text.contains("records"), + "text should match the authored ExamineText, got: '{text}'" + ); +} + +/// Examine on a Readable entity WITHOUT ExamineText returns a generic non-empty fallback. +#[test] +fn examine_readable_without_examine_text_returns_fallback() { + use settled_reach_server::knowledge::events::KnowledgeEventQueue; + + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + ExamineResultBuffer::default(), + )) + .id(); + + // Readable but no ExamineText component + let readable = world + .spawn(( + TilePosition::new(5, 6, 0), + Interactable, + ObjectType::Readable, + )) + .id(); + + world + .entity_mut(player) + .insert(ExamineRequest { target: readable }); + + let mut schedule = Schedule::default(); + schedule.add_systems(process_examine_interaction); + schedule.run(&mut world); + + let event = world + .get_mut::(player) + .unwrap() + .take(); + assert!( + event.is_some(), + "ExamineResultBuffer must contain a result even without ExamineText" + ); + let text = event.unwrap().text; + assert!(!text.is_empty(), "fallback text must be non-empty, got: '{text}'"); +} + +/// Examine out of range returns no result. +#[test] +fn examine_readable_out_of_range_returns_no_result() { + use settled_reach_server::knowledge::events::KnowledgeEventQueue; + + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + ExamineResultBuffer::default(), + )) + .id(); + + // Distance 8 > CLOSE_RANGE (2) + let readable = world + .spawn(( + TilePosition::new(5, 13, 0), + Interactable, + ObjectType::Readable, + ExamineText("Out of range text".to_string()), + )) + .id(); + + world + .entity_mut(player) + .insert(ExamineRequest { target: readable }); + + let mut schedule = Schedule::default(); + schedule.add_systems(process_examine_interaction); + schedule.run(&mut world); + + let event = world + .get_mut::(player) + .unwrap() + .take(); + assert!(event.is_none(), "examining an out-of-range Readable must not produce a result"); +} + +// --------------------------------------------------------------------------- +// Terminal interaction tests +// --------------------------------------------------------------------------- + +/// Use on a Terminal emits TerminalInteracted event. +#[test] +fn terminal_use_emits_terminal_interacted_event() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + + let terminal = world + .spawn(( + TilePosition::new(5, 6, 0), + Interactable, + ObjectType::Terminal, + )) + .id(); + + // Register terminal in EntityRegistry so stable ID resolves + let terminal_sid = StableId(42); + world.entity_mut(terminal).insert(StableEntityId(terminal_sid)); + world.resource_mut::().register_existing(terminal, terminal_sid); + + world + .entity_mut(player) + .insert(TerminalInteractRequest { terminal_entity: terminal }); + + let mut schedule = Schedule::default(); + schedule.add_systems(process_terminal_interaction); + schedule.run(&mut world); + + let queue = world.resource::(); + assert_eq!(queue.events.len(), 1, "one TerminalInteracted event must be emitted"); + assert_eq!( + queue.events[0].terminal_id, terminal_sid, + "terminal_id must match the interacted terminal" + ); +} + +/// TerminalInteractRequest is removed after processing. +#[test] +fn terminal_interact_request_consumed_after_processing() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + + let terminal = world.spawn(TilePosition::new(5, 6, 0)).id(); + + world + .entity_mut(player) + .insert(TerminalInteractRequest { terminal_entity: terminal }); + + let mut schedule = Schedule::default(); + schedule.add_systems(process_terminal_interaction); + schedule.run(&mut world); + + assert!( + world.get::(player).is_none(), + "TerminalInteractRequest must be removed after processing" + ); +} + +// --------------------------------------------------------------------------- +// SaveStateV1.open_doors field +// --------------------------------------------------------------------------- + +fn minimal_save() -> SaveStateV1 { + SaveStateV1 { + format_version: SAVE_FORMAT_VERSION, + tick: 0, + tick_rate: TickRate::Full, + seed: 42, + player_knowledge: KnowledgeGraph::new(), + relationship_graph: RelationshipGraph::new(), + npc_states: vec![], + template_references: TemplateReferenceMap::default(), + triangle_states: vec![], + open_doors: vec![], + } +} + +/// SaveStateV1.open_doors round-trips through MessagePack serialization. +#[test] +fn save_state_open_doors_roundtrip() { + let mut save = minimal_save(); + save.open_doors = vec![StableId(10), StableId(20), StableId(30)]; + + let bytes = save.to_bytes().expect("serialize"); + let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize"); + + assert_eq!( + recovered.open_doors, + vec![StableId(10), StableId(20), StableId(30)], + "open_doors must round-trip through MessagePack" + ); +} + +/// Older save files (no open_doors field) deserialize without error. +/// The field defaults to empty vec via #[serde(default)]. +#[test] +fn save_state_open_doors_defaults_to_empty_on_old_saves() { + let save = minimal_save(); + assert!( + save.open_doors.is_empty(), + "open_doors must default to empty vec (backward compat with older saves)" + ); +} diff --git a/server/tests/error_handling.rs b/server/tests/error_handling.rs new file mode 100644 index 000000000..78ec5bc07 --- /dev/null +++ b/server/tests/error_handling.rs @@ -0,0 +1,314 @@ +//! Error handling integration tests (#85). +//! +//! Tests the three error categories: +//! 1. Protocol errors: malformed input → SimError + server continues +//! 2. Desync detection: state_hash field populated in snapshots +//! 3. SimError wire format roundtrip + +use bevy_app::prelude::*; +use settled_reach_server::bridge::framing::{read_framed, write_framed}; +use settled_reach_server::bridge::tcp::TcpBridge; +use settled_reach_server::bridge::types::*; +use settled_reach_server::bridge::{BridgePlugin, BridgeResource, ServerRunning}; +use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin}; +use settled_reach_server::npc::relationships::TrustEventQueue; +use settled_reach_server::simulation::interaction::NearbyInteractionBuffer; +use settled_reach_server::simulation::monologue::{MonologueBuffer, MonologueState}; +use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; +use settled_reach_server::simulation::SimulationPlugin; +use std::io::{BufReader, BufWriter}; +use std::net::{TcpListener, TcpStream}; +use std::sync::{Arc, Barrier}; +use std::thread; + +/// Acceptance test (#85): send a malformed message mid-session, assert the +/// server emits a SimError in the next snapshot and continues running. +/// +/// Uses barriers to synchronize the server and client threads, ensuring +/// the malformed data arrives before the server's receive_inputs call. +#[test] +fn malformed_input_produces_sim_error_and_server_continues() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener"); + let server_addr = listener.local_addr().expect("get local addr"); + + // Barriers for tick synchronization between server and client. + // Each barrier is used once: client signals "data sent", server proceeds to tick. + let barrier_tick1 = Arc::new(Barrier::new(2)); + let barrier_tick2 = Arc::new(Barrier::new(2)); + let barrier_tick3 = Arc::new(Barrier::new(2)); + + let b1_server = Arc::clone(&barrier_tick1); + let b2_server = Arc::clone(&barrier_tick2); + let b3_server = Arc::clone(&barrier_tick3); + + // Server thread + let server_handle = thread::spawn(move || { + let bridge = TcpBridge::accept_on(listener).expect("accept connection"); + + let mut app = App::new(); + app.add_plugins(SimulationPlugin); + app.add_plugins(BridgePlugin); + app.add_plugins(KnowledgePlugin); + app.init_resource::(); + app.insert_resource(BridgeResource::new(bridge)); + app.insert_resource(WalkabilityMap::new(32, 32, 1)); + app.world_mut().spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + MonologueState::default(), + )); + + // Tick 1: wait for client to send valid input, then process + b1_server.wait(); + app.update(); + assert!( + app.world().resource::().0, + "server should be running after tick 1" + ); + + // Tick 2: wait for client to send malformed input, then process + b2_server.wait(); + app.update(); + assert!( + app.world().resource::().0, + "server must continue running after malformed input" + ); + + // Tick 3: wait for client to send valid input, then process + b3_server.wait(); + app.update(); + assert!( + app.world().resource::().0, + "server should still be running after tick 3" + ); + }); + + // Client: connect and interact with synchronization + let stream = TcpStream::connect(server_addr).expect("client connect"); + let mut reader = BufReader::new(stream.try_clone().expect("clone for reader")); + let mut writer = BufWriter::new(stream); + + // --- Tick 1: send valid input --- + let valid_inputs = vec![PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }]; + let payload = rmp_serde::to_vec_named(&valid_inputs).expect("serialize"); + write_framed(&mut writer, &payload).expect("send valid input"); + barrier_tick1.wait(); // Signal: valid input sent + + // Read tick 1 snapshot + let snap1_bytes = read_framed(&mut reader) + .expect("read snapshot 1") + .expect("not EOF"); + let snap1: ObserverSnapshot = + rmp_serde::from_slice(&snap1_bytes).expect("deserialize snapshot 1"); + assert!( + snap1.sim_errors.is_empty(), + "no errors expected on tick 1" + ); + + // --- Tick 2: send malformed input (properly framed but garbage payload) --- + let garbage_payload: Vec = vec![0xFF, 0xFE, 0xFD, 0xFC, 0xAB, 0xCD, 0xEF]; + write_framed(&mut writer, &garbage_payload).expect("send malformed input"); + barrier_tick2.wait(); // Signal: malformed input sent + + // Read tick 2 snapshot — should contain SimError + let snap2_bytes = read_framed(&mut reader) + .expect("read snapshot 2") + .expect("not EOF"); + let snap2: ObserverSnapshot = + rmp_serde::from_slice(&snap2_bytes).expect("deserialize snapshot 2"); + assert!( + !snap2.sim_errors.is_empty(), + "sim_errors must contain the protocol error from malformed input" + ); + assert_eq!( + snap2.sim_errors[0].kind, + SimErrorKind::ProtocolError, + "error kind must be ProtocolError" + ); + assert!( + snap2.sim_errors[0].message.contains("Malformed input frame") + || snap2.sim_errors[0].message.contains("Deserialization error"), + "error message should describe the deserialization failure, got: {}", + snap2.sim_errors[0].message, + ); + + // --- Tick 3: send valid input again — server must still work --- + let valid_inputs2 = vec![PlayerInput { + tick: 2, + action: PlayerAction::MoveSouth, + }]; + let payload2 = rmp_serde::to_vec_named(&valid_inputs2).expect("serialize"); + write_framed(&mut writer, &payload2).expect("send valid input after error"); + barrier_tick3.wait(); // Signal: valid input sent + + // Read tick 3 snapshot — no errors, server recovered + let snap3_bytes = read_framed(&mut reader) + .expect("read snapshot 3") + .expect("not EOF"); + let snap3: ObserverSnapshot = + rmp_serde::from_slice(&snap3_bytes).expect("deserialize snapshot 3"); + assert!( + snap3.sim_errors.is_empty(), + "no errors expected on tick 3 — server recovered" + ); + + // Clean up + drop(reader); + drop(writer); + server_handle.join().expect("server thread should not panic"); +} + +/// State hash is populated in every snapshot and is deterministic for same state. +#[test] +fn state_hash_populated_in_snapshot() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener"); + let server_addr = listener.local_addr().expect("get local addr"); + + let server_handle = thread::spawn(move || { + let bridge = TcpBridge::accept_on(listener).expect("accept connection"); + + let mut app = App::new(); + app.add_plugins(SimulationPlugin); + app.add_plugins(BridgePlugin); + app.add_plugins(KnowledgePlugin); + app.init_resource::(); + app.insert_resource(BridgeResource::new(bridge)); + app.insert_resource(WalkabilityMap::new(32, 32, 1)); + app.world_mut().spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + MonologueState::default(), + )); + + // Run one tick + app.update(); + }); + + let stream = TcpStream::connect(server_addr).expect("client connect"); + let mut reader = BufReader::new(stream.try_clone().expect("clone for reader")); + let mut writer = BufWriter::new(stream); + + // Send empty input batch (no movement) + let inputs: Vec = vec![]; + let payload = rmp_serde::to_vec_named(&inputs).expect("serialize"); + write_framed(&mut writer, &payload).expect("send empty input"); + + // Read snapshot + let snap_bytes = read_framed(&mut reader) + .expect("read snapshot") + .expect("not EOF"); + let snap: ObserverSnapshot = + rmp_serde::from_slice(&snap_bytes).expect("deserialize snapshot"); + + assert!( + snap.state_hash.is_some(), + "state_hash must be populated in snapshot" + ); + assert_ne!( + snap.state_hash.unwrap(), + 0, + "state_hash should be a non-trivial hash value" + ); + + drop(reader); + drop(writer); + server_handle.join().expect("server thread should not panic"); +} + +/// SimError roundtrips through MessagePack serialization. +#[test] +fn sim_error_roundtrip() { + let error = SimError { + kind: SimErrorKind::ProtocolError, + message: "test protocol error".into(), + tick: 42, + }; + + let bytes = rmp_serde::to_vec_named(&error).expect("serialize SimError"); + let decoded: SimError = rmp_serde::from_slice(&bytes).expect("deserialize SimError"); + + assert_eq!(decoded.kind, SimErrorKind::ProtocolError); + assert_eq!(decoded.message, "test protocol error"); + assert_eq!(decoded.tick, 42); +} + +/// SimErrorKind::Panic variant roundtrips. +#[test] +fn sim_error_panic_variant_roundtrip() { + let error = SimError { + kind: SimErrorKind::Panic, + message: "simulation system panicked".into(), + tick: 100, + }; + + let bytes = rmp_serde::to_vec_named(&error).expect("serialize"); + let decoded: SimError = rmp_serde::from_slice(&bytes).expect("deserialize"); + + assert_eq!(decoded.kind, SimErrorKind::Panic); + assert_eq!(decoded.message, "simulation system panicked"); + assert_eq!(decoded.tick, 100); +} + +/// Snapshot with sim_errors populates correctly through serialization. +#[test] +fn snapshot_with_sim_errors_roundtrips() { + use settled_reach_server::simulation::time::{DayPhase, TickRate}; + + let snapshot = ObserverSnapshot { + version: PROTOCOL_VERSION, + tick: 10, + game_time: GameTime { + day: 0, + time_of_day: 0, + day_phase: DayPhase::Morning, + tick_rate: TickRate::Full, + }, + 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![], + conversation_events: vec![], + conversation_ended: 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: Some(0xDEADBEEF), + sim_errors: vec![ + SimError { + kind: SimErrorKind::ProtocolError, + message: "bad frame".into(), + tick: 10, + }, + ], + }; + + let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); + + assert_eq!(decoded.state_hash, Some(0xDEADBEEF)); + assert_eq!(decoded.sim_errors.len(), 1); + assert_eq!(decoded.sim_errors[0].kind, SimErrorKind::ProtocolError); + assert_eq!(decoded.sim_errors[0].message, "bad frame"); +} diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 3968faffc..3582b4398 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -49,6 +49,8 @@ fn fixture_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot player_knowledge: None, save_result: None, triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], } } @@ -241,6 +243,8 @@ fn generate_msgpack_fixtures() { player_knowledge: None, save_result: None, triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], }; write_fixture( "snapshot_v2_full", @@ -403,6 +407,8 @@ fn generate_msgpack_fixtures() { }], }), triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], }; write_fixture( "snapshot_full", diff --git a/server/tests/golden/proof_room_tick_10.json b/server/tests/golden/proof_room_tick_10.json index 257998a14..b6a0e5cf3 100644 --- a/server/tests/golden/proof_room_tick_10.json +++ b/server/tests/golden/proof_room_tick_10.json @@ -72,9 +72,10 @@ "rng_seed": 42, "scan_events": [], "sound_events": [], + "state_hash": 14452262397297540338, "tick": 8, "triangle_crisis_events": [], - "version": 16, + "version": 17, "visible_tiles": [ { "tile_kind": "Wall", diff --git a/server/tests/information_boundaries.rs b/server/tests/information_boundaries.rs index 67e8f273b..2770f5721 100644 --- a/server/tests/information_boundaries.rs +++ b/server/tests/information_boundaries.rs @@ -205,6 +205,7 @@ fn save_state_npc_kg_isolation() { npc_states: vec![npc_a_state, npc_b_state], template_references: Default::default(), triangle_states: vec![], + open_doors: vec![], }; // Roundtrip: serialize → deserialize. diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 2589ae802..959f93d57 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -37,6 +37,8 @@ fn test_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { player_knowledge: None, save_result: None, triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], } } @@ -296,6 +298,8 @@ fn snapshot_v2_fields_roundtrip() { player_knowledge: None, save_result: None, triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); @@ -350,7 +354,7 @@ fn protocol_version_constant_matches_snapshot() { let snapshot = test_snapshot(0, vec![]); assert_eq!(snapshot.version, PROTOCOL_VERSION); assert_eq!( - PROTOCOL_VERSION, 16, + PROTOCOL_VERSION, 17, "bump this assertion when protocol version changes" ); } @@ -401,6 +405,8 @@ fn all_facing_direction_variants_roundtrip() { player_knowledge: None, save_result: None, triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); diff --git a/server/tests/template_instantiation.rs b/server/tests/template_instantiation.rs new file mode 100644 index 000000000..86ae6bc93 --- /dev/null +++ b/server/tests/template_instantiation.rs @@ -0,0 +1,221 @@ +//! End-to-end tests for the template instantiation engine (#161). +//! +//! Verifies the full pipeline: +//! load YAML → validate → spawn NPCs → generate triangles → lifecycle +//! +//! Spec refs: +//! - D-023: three-tier content model +//! - D-024: 10-axis NPC model, minimum 2 triangles per social site +//! - D-025: social site as atomic template unit, single-ownership +//! - D-010: determinism (same seed → same layout) + +use std::path::PathBuf; + +use bevy_ecs::prelude::*; +use settled_reach_server::{ + content::{ + instantiation::{ + instantiate_template, load_template_from_file, unload_template, + ActiveTemplateInstances, + }, + template::{TemplateId, TemplateOwnership, TriangleState}, + }, + knowledge::{registry::EntityRegistry, StableEntityId}, + simulation::rng::SimRng, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn templates_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data/templates") +} + +fn make_test_world() -> World { + let mut world = World::new(); + world.init_resource::(); + world +} + +// --------------------------------------------------------------------------- +// End-to-end: load logistics-hub YAML, instantiate, assert structure (#161) +// --------------------------------------------------------------------------- + +#[test] +fn template_instantiation_end_to_end_logistics_hub() { + let path = templates_dir().join("logistics-hub.yaml"); + let template_def = + load_template_from_file(&path).expect("logistics-hub.yaml must load and parse"); + + let mut world = make_test_world(); + let template_id = TemplateId::from_seed_and_slug(42, "logistics-hub"); + let mut rng = SimRng::new(42); + + let instance = instantiate_template(&mut world, &template_def, template_id, 42, &mut rng) + .expect("logistics-hub must instantiate without validation errors"); + + // --- NPCs: all 4 role slots filled --- + assert_eq!( + instance.npc_entities.len(), + 4, + "logistics-hub has 4 role slots — 4 NPC entities expected" + ); + + // --- TemplateOwnership on every NPC --- + let expected_roles = [ + "logistics-manager", + "dock-worker", + "ring-contact", + "security-guard", + ]; + let mut seen_roles: Vec = Vec::new(); + for &entity in &instance.npc_entities { + let ownership = world + .get::(entity) + .expect("every spawned NPC must have TemplateOwnership"); + assert_eq!( + ownership.template_id, template_id, + "TemplateOwnership.template_id must match the instantiated template" + ); + let role = &ownership.role_id.0; + assert!( + expected_roles.contains(&role.as_str()), + "unexpected role '{}' — not in logistics-hub role list", + role, + ); + seen_roles.push(role.clone()); + } + // Every role slot must appear exactly once. + for role in &expected_roles { + assert_eq!( + seen_roles.iter().filter(|r| r.as_str() == *role).count(), + 1, + "role '{}' must appear exactly once", + role, + ); + } + + // --- 2+ TriangleState entities (D-024 minimum) --- + assert!( + instance.triangle_entities.len() >= 2, + "logistics-hub must produce at least 2 TriangleState entities (D-024), got {}", + instance.triangle_entities.len(), + ); + for &entity in &instance.triangle_entities { + assert!( + world.get::(entity).is_some(), + "triangle entity {:?} must carry a TriangleState component", + entity, + ); + } + + // --- Instance registered in ActiveTemplateInstances --- + let active = world.resource::(); + assert!( + active.get(template_id).is_some(), + "instantiated template must be tracked in ActiveTemplateInstances", + ); +} + +// --------------------------------------------------------------------------- +// Lifecycle: unload despawns all entities +// --------------------------------------------------------------------------- + +#[test] +fn template_instantiation_unload_despawns_entities() { + let path = templates_dir().join("logistics-hub.yaml"); + let template_def = load_template_from_file(&path).expect("must parse"); + + let mut world = make_test_world(); + let template_id = TemplateId::from_seed_and_slug(99, "logistics-hub"); + let mut rng = SimRng::new(99); + + let instance = + instantiate_template(&mut world, &template_def, template_id, 99, &mut rng) + .expect("must instantiate"); + + let all_entities: Vec = instance + .npc_entities + .iter() + .chain(instance.triangle_entities.iter()) + .cloned() + .collect(); + + assert!(!all_entities.is_empty(), "sanity: some entities were spawned"); + + unload_template(&mut world, template_id); + + // All spawned entities must be gone. + for entity in &all_entities { + assert!( + world.get_entity(*entity).is_err(), + "entity {:?} must be despawned after unload_template", + entity, + ); + } + + // Instance removed from tracking. + let active = world.resource::(); + assert!( + active.get(template_id).is_none(), + "unloaded template must be removed from ActiveTemplateInstances", + ); +} + +// --------------------------------------------------------------------------- +// Determinism: same seed → same NPC StableId assignment (D-010) +// --------------------------------------------------------------------------- + +#[test] +fn template_instantiation_is_deterministic() { + let path = templates_dir().join("logistics-hub.yaml"); + let template_def = load_template_from_file(&path).expect("must parse"); + let template_id = TemplateId::from_seed_and_slug(42, "logistics-hub"); + + let mut world1 = make_test_world(); + let instance1 = + instantiate_template(&mut world1, &template_def, template_id, 42, &mut SimRng::new(42)) + .expect("must instantiate"); + + let mut world2 = make_test_world(); + let instance2 = + instantiate_template(&mut world2, &template_def, template_id, 42, &mut SimRng::new(42)) + .expect("must instantiate"); + + // Collect (role → StableId) pairs from each world and compare. + let role_stable_ids = |world: &World, entities: &[Entity]| { + let mut pairs: Vec<(String, u64)> = entities + .iter() + .map(|&e| { + let role = world.get::(e).unwrap().role_id.0.clone(); + let sid = world.get::(e).unwrap().0 .0; + (role, sid) + }) + .collect(); + pairs.sort(); + pairs + }; + + let pairs1 = role_stable_ids(&world1, &instance1.npc_entities); + let pairs2 = role_stable_ids(&world2, &instance2.npc_entities); + + assert_eq!( + pairs1, pairs2, + "instantiate_template must be deterministic (D-010): same seed → same layout" + ); +} + +// --------------------------------------------------------------------------- +// YAML loading: invalid path returns Err +// --------------------------------------------------------------------------- + +#[test] +fn load_template_from_file_nonexistent_path_returns_err() { + let path = templates_dir().join("nonexistent-template-xyzzy.yaml"); + let result = load_template_from_file(&path); + assert!( + result.is_err(), + "loading a nonexistent file must return Err" + ); +} diff --git a/server/tests/template_schema.rs b/server/tests/template_schema.rs index fa4b91552..c570eaa20 100644 --- a/server/tests/template_schema.rs +++ b/server/tests/template_schema.rs @@ -1,19 +1,21 @@ -//! Integration tests for the template schema system (tickets #163, #164, #165, #106). +//! Integration tests for the template schema system (tickets #163, #164, #165, #106, #159). //! //! Tests YAML round-trips, validation logic, and ECS component interactions //! against the spec decisions: //! - D-023: three-tier content model //! - D-024: 10-axis NPC model, triangles as atomic unit //! - D-025: social site / single-ownership model +//! - D-028: dialogue tagged pools //! - D-087: v0.1 triangle configuration //! - D-089: self-contained triangle forks, no cross-triangle cascade //! - D-010: determinism (no HashMap, FNV-1a IDs) use settled_reach_server::content::template::{ - validate_role_schemas_no_duplicate_ids, ConflictType, NpcAxis, PrivacyLevel, - RelationshipConstraint, RoleId, RoleSchema, SpaceSpec, TemplateId, - TemplateOwnership, TemplateReference, TemplateReferenceMap, TemplateRoutineEntry, - TrafficPattern, TriangleDef, TriangleId, TrustRange, + validate_role_schemas_no_duplicate_ids, ConflictType, CrossTemplateLinkSpec, FullTemplateDef, + NpcAxis, PrivacyLevel, RelationshipConstraint, RoleId, RoleSchema, SightlineZone, SpaceSpec, + TemplateDialoguePoolRef, TemplateId, TemplateOwnership, TemplateReference, + TemplateReferenceMap, TemplateRoutineEntry, TrafficPattern, TriangleDef, TriangleId, + TrustRange, }; use settled_reach_server::npc::{PersonalityTrait, RelationshipKind, Skill}; @@ -637,3 +639,268 @@ fn template_ownership_survives_clone_for_save_state() { let saved = ownership.clone(); assert_eq!(saved, ownership, "TemplateOwnership must survive clone (save path)"); } + +// --------------------------------------------------------------------------- +// #159: Full Tier 2 template document — FullTemplateDef +// --------------------------------------------------------------------------- + +/// Build a minimal valid FullTemplateDef with two roles and two triangles. +fn minimal_full_template() -> FullTemplateDef { + FullTemplateDef { + slug: "test-site".to_string(), + display_name: "Test Social Site".to_string(), + description: None, + roles: vec![ + RoleSchema { + role_id: RoleId::new("manager"), + required_traits: vec![PersonalityTrait::Cautious], + skill_focus: vec![Skill::Persuasion], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("worker"), + kind: RelationshipKind::Superior, + required_trust: TrustRange { min: 0, max: 5 }, + }], + routine_template: vec![], + }, + RoleSchema { + role_id: RoleId::new("worker"), + required_traits: vec![PersonalityTrait::Honest], + skill_focus: vec![Skill::Technical], + relationship_constraints: vec![], + routine_template: vec![], + }, + RoleSchema { + role_id: RoleId::new("informant"), + required_traits: vec![PersonalityTrait::Deceptive], + skill_focus: vec![Skill::Stealth], + relationship_constraints: vec![], + routine_template: vec![], + }, + ], + space: SpaceSpec { + tile_count_min: 30, + tile_count_max: 80, + sightline_zones: vec![SightlineZone { + name: "main-floor".to_string(), + radius: 6, + }], + privacy_level: PrivacyLevel::SemiPrivate, + traffic_pattern: TrafficPattern::Destination, + }, + triangles: vec![ + TriangleDef { + triangle_id: TriangleId(0), + roles: [ + RoleId::new("manager"), + RoleId::new("worker"), + RoleId::new("informant"), + ], + conflict_type: ConflictType::ResourceCompetition, + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], + relationship_constraints: vec![], + }, + TriangleDef { + triangle_id: TriangleId(0), + roles: [ + RoleId::new("manager"), + RoleId::new("informant"), + RoleId::new("worker"), + ], + conflict_type: ConflictType::LatentTension, + interest_axes: [NpcAxis::Tolerance, NpcAxis::Contentment, NpcAxis::Routine], + relationship_constraints: vec![], + }, + ], + dialogue_pools: vec![TemplateDialoguePoolRef { + location: "the-hub".to_string(), + roles: vec!["manager".to_string(), "worker".to_string()], + }], + cross_template_links: vec![CrossTemplateLinkSpec { + from_role: RoleId::new("worker"), + to_template_slug: "other-site".to_string(), + relationship: RelationshipKind::Colleague, + }], + } +} + +#[test] +fn full_template_def_yaml_roundtrip() { + let template = minimal_full_template(); + let yaml = serde_yaml::to_string(&template).expect("serialize FullTemplateDef"); + let restored: FullTemplateDef = + serde_yaml::from_str(&yaml).expect("deserialize FullTemplateDef"); + + assert_eq!(restored.slug, template.slug); + assert_eq!(restored.display_name, template.display_name); + assert_eq!(restored.roles.len(), template.roles.len()); + assert_eq!(restored.space.tile_count_min, template.space.tile_count_min); + assert_eq!(restored.triangles.len(), template.triangles.len()); + assert_eq!(restored.dialogue_pools.len(), template.dialogue_pools.len()); + assert_eq!(restored.cross_template_links.len(), template.cross_template_links.len()); + + // Role round-trip: traits, constraints, routine entries + let role = &restored.roles[0]; + assert_eq!(role.role_id, RoleId::new("manager")); + assert_eq!(role.required_traits[0], PersonalityTrait::Cautious); + assert_eq!(role.relationship_constraints[0].with_role, RoleId::new("worker")); + + // Triangle round-trip: roles, conflict type, axes + let tri = &restored.triangles[0]; + assert_eq!(tri.conflict_type, ConflictType::ResourceCompetition); + assert_eq!(tri.roles[0], RoleId::new("manager")); + assert_eq!(tri.interest_axes[1], NpcAxis::Secret); + + // Dialogue pool round-trip + assert_eq!(restored.dialogue_pools[0].location, "the-hub"); + assert_eq!(restored.dialogue_pools[0].roles.len(), 2); + + // Cross-template link round-trip + assert_eq!( + restored.cross_template_links[0].from_role, + RoleId::new("worker") + ); + assert_eq!( + restored.cross_template_links[0].to_template_slug, + "other-site" + ); +} + +#[test] +fn full_template_def_validation_passes_for_valid_template() { + let template = minimal_full_template(); + assert!( + template.validate().is_ok(), + "minimal valid template must pass: {:?}", + template.validate() + ); +} + +#[test] +fn full_template_def_validation_rejects_fewer_than_2_triangles() { + let mut template = minimal_full_template(); + template.triangles.truncate(1); + let result = template.validate(); + assert!(result.is_err(), "fewer than 2 triangles must fail"); + assert!( + result.unwrap_err().contains("fewer than 2 triangles"), + "error must mention triangle count" + ); +} + +#[test] +fn full_template_def_validation_rejects_undefined_triangle_role() { + let mut template = minimal_full_template(); + // Replace a triangle role with one not in the roles list + template.triangles[0].roles[2] = RoleId::new("ghost-role"); + let result = template.validate(); + assert!(result.is_err(), "undefined triangle role must fail validation"); + assert!( + result.unwrap_err().contains("ghost-role"), + "error must name the undefined role" + ); +} + +#[test] +fn full_template_def_validation_rejects_duplicate_role_ids() { + let mut template = minimal_full_template(); + template.roles.push(RoleSchema { + role_id: RoleId::new("manager"), // duplicate + required_traits: vec![], + skill_focus: vec![], + relationship_constraints: vec![], + routine_template: vec![], + }); + let result = template.validate(); + assert!(result.is_err(), "duplicate role_id must fail validation"); +} + +#[test] +fn full_template_def_optional_fields_default_on_minimal_yaml() { + // description, dialogue_pools, cross_template_links are all optional. + let yaml = r#" +slug: "bare-minimum" +display_name: "Bare Minimum Site" +roles: + - role_id: "alpha" + - role_id: "beta" + - role_id: "gamma" +space: + tile_count_min: 30 + tile_count_max: 80 + privacy_level: Public + traffic_pattern: Thoroughfare +triangles: + - triangle_id: 0 + roles: + - "alpha" + - "beta" + - "gamma" + conflict_type: LatentTension + interest_axes: + - Contentment + - Tolerance + - Routine + - triangle_id: 0 + roles: + - "alpha" + - "gamma" + - "beta" + conflict_type: ResourceCompetition + interest_axes: + - Want + - Secret + - Relationships +"#; + let def: FullTemplateDef = serde_yaml::from_str(yaml).expect("minimal YAML must parse"); + assert_eq!(def.slug, "bare-minimum"); + assert!(def.description.is_none()); + assert!(def.dialogue_pools.is_empty()); + assert!(def.cross_template_links.is_empty()); + assert!(def.validate().is_ok(), "minimal template must validate: {:?}", def.validate()); +} + +/// Acceptance test: the authored logistics-hub.yaml round-trips through serde_yaml. +/// +/// The file lives at `server/data/templates/logistics-hub.yaml`. +/// This test is the canonical acceptance criterion for ticket #159. +#[test] +fn logistics_hub_yaml_roundtrips_cleanly() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/data/templates/logistics-hub.yaml" + ); + let raw = std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("could not read logistics-hub.yaml: {}", e)); + + let def: FullTemplateDef = serde_yaml::from_str(&raw) + .unwrap_or_else(|e| panic!("logistics-hub.yaml failed to deserialize: {}", e)); + + // Structural assertions + assert_eq!(def.slug, "logistics-hub"); + assert_eq!(def.roles.len(), 4, "logistics hub must define 4 roles"); + assert_eq!(def.triangles.len(), 2, "logistics hub must define 2 triangles"); + assert!(!def.dialogue_pools.is_empty(), "dialogue_pools must be present"); + assert!(!def.cross_template_links.is_empty(), "cross_template_links must be present"); + + // Spatial spec assertions (D-025: 30–80 sim tiles) + assert!(def.space.validate().is_ok(), "space spec must validate"); + assert_eq!(def.space.tile_count_min, 30); + assert_eq!(def.space.tile_count_max, 80); + + // Validation must pass + assert!( + def.validate().is_ok(), + "logistics-hub.yaml must pass full validation: {:?}", + def.validate() + ); + + // Round-trip: serialize back to YAML then deserialize again + let reserialized = serde_yaml::to_string(&def).expect("re-serialize"); + let restored: FullTemplateDef = + serde_yaml::from_str(&reserialized).expect("re-deserialize after round-trip"); + assert_eq!(def.slug, restored.slug); + assert_eq!(def.roles.len(), restored.roles.len()); + assert_eq!(def.triangles.len(), restored.triangles.len()); + assert_eq!(def.dialogue_pools.len(), restored.dialogue_pools.len()); + assert_eq!(def.cross_template_links.len(), restored.cross_template_links.len()); +} diff --git a/server/tests/triangle_validation.rs b/server/tests/triangle_validation.rs new file mode 100644 index 000000000..0a5355e97 --- /dev/null +++ b/server/tests/triangle_validation.rs @@ -0,0 +1,442 @@ +//! Integration tests for triangle validation and cross-template generation (#108, #109). +//! +//! Spec references: +//! - D-024: NPC generation model — minimum 2 triangles per template, 1 cross-template +//! - D-087: v0.1 triangle configuration — 3 active forks, 2 passive tensions +//! - D-025: social site as atomic template unit — cross-template reference links +//! +//! Test naming follows the cargo test filter target: +//! `cargo test -p settled-reach-server -- triangle_validation` + +use settled_reach_server::{ + content::template::{ + generate_cross_template_triangles, generate_intra_template_triangles, + validate_triangle_def, ConflictType, NpcAxis, RelationshipConstraint, RoleId, + TemplateId, TemplateOwnership, TriangleDef, TriangleId, TrianglePhase, TrustRange, + ValidationError, + }, + knowledge::{registry::StableEntityId, types::StableId}, + npc::{Npc, RelationshipKind}, + simulation::rng::SimRng, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Minimal valid TriangleDef that passes all three validation checks. +/// +/// - interest_axes: [Want, Secret, Relationships] → all distinct, has Want +/// - relationship_constraints: non-empty +fn valid_triangle_def(id: u64) -> TriangleDef { + TriangleDef { + triangle_id: TriangleId(id), + roles: [ + RoleId::new("ops-manager"), + RoleId::new("freight-handler"), + RoleId::new("inspector"), + ], + conflict_type: ConflictType::LoyaltyConflict, + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("freight-handler"), + kind: RelationshipKind::Colleague, + required_trust: TrustRange { min: 1, max: 5 }, + }], + } +} + +/// Spawn an NPC entity with TemplateOwnership in the given world. +fn spawn_template_npc( + world: &mut bevy_ecs::world::World, + template_id: TemplateId, + role: &str, + stable_id: u64, +) { + world.spawn(( + Npc, + TemplateOwnership { + template_id, + role_id: RoleId::new(role), + }, + StableEntityId(StableId(stable_id)), + )); +} + +// --------------------------------------------------------------------------- +// #109 — Triangle validation: unit tests for each failure mode +// --------------------------------------------------------------------------- + +/// A valid triangle passes all three validation checks. +#[test] +fn triangle_validation_valid_triangle_passes_all_checks() { + let def = valid_triangle_def(1); + assert!( + validate_triangle_def(&def).is_ok(), + "valid triangle must pass all checks: {:?}", + validate_triangle_def(&def) + ); +} + +/// Conflict viability fails when no interest_axes entry is NpcAxis::Want. +/// +/// D-024: active conflict requires at least one role whose tension is Want-driven. +#[test] +fn triangle_validation_conflict_viability_fails_without_want_axis() { + let def = TriangleDef { + triangle_id: TriangleId(10), + roles: [ + RoleId::new("worker-a"), + RoleId::new("worker-b"), + RoleId::new("supervisor"), + ], + conflict_type: ConflictType::LatentTension, + // No Want axis — all passive tensions + interest_axes: [NpcAxis::Contentment, NpcAxis::Tolerance, NpcAxis::Routine], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("worker-b"), + kind: RelationshipKind::Colleague, + required_trust: TrustRange { min: 0, max: 3 }, + }], + }; + + let result = validate_triangle_def(&def); + assert!( + matches!(result, Err(ValidationError::ConflictViability { triangle_id: TriangleId(10) })), + "expected ConflictViability error, got: {:?}", + result + ); +} + +/// Relationship coherence fails when relationship_constraints is empty. +/// +/// A coherent triangle must document at least one social link among the three roles. +#[test] +fn triangle_validation_relationship_coherence_fails_without_constraints() { + let def = TriangleDef { + triangle_id: TriangleId(20), + roles: [ + RoleId::new("smuggler"), + RoleId::new("detective"), + RoleId::new("informant"), + ], + conflict_type: ConflictType::SecretExposure, + // Has Want axis (passes conflict viability) + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], + // Empty constraints — fails coherence + relationship_constraints: vec![], + }; + + let result = validate_triangle_def(&def); + assert!( + matches!(result, Err(ValidationError::RelationshipCoherence { triangle_id: TriangleId(20) })), + "expected RelationshipCoherence error, got: {:?}", + result + ); +} + +/// Interest divergence fails when two roles share the same interest_axis. +/// +/// All three axes must be distinct so each role brings a different tension. +#[test] +fn triangle_validation_interest_divergence_fails_with_duplicate_axes() { + let def = TriangleDef { + triangle_id: TriangleId(30), + roles: [ + RoleId::new("dock-worker"), + RoleId::new("cargo-lead"), + RoleId::new("port-officer"), + ], + conflict_type: ConflictType::ResourceCompetition, + // Two roles both have Want — duplicate axis + interest_axes: [NpcAxis::Want, NpcAxis::Want, NpcAxis::Secret], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("cargo-lead"), + kind: RelationshipKind::Colleague, + required_trust: TrustRange { min: 0, max: 5 }, + }], + }; + + let result = validate_triangle_def(&def); + assert!( + matches!( + result, + Err(ValidationError::InterestDivergence { + triangle_id: TriangleId(30), + duplicate_axis: NpcAxis::Want, + }) + ), + "expected InterestDivergence(Want) error, got: {:?}", + result + ); +} + +/// Divergence check catches the third axis duplicating the first. +/// +/// Edge case: axes[0] == axes[2], but axes[1] is different. +#[test] +fn triangle_validation_interest_divergence_first_last_duplicate() { + let def = TriangleDef { + triangle_id: TriangleId(31), + roles: [ + RoleId::new("role-a"), + RoleId::new("role-b"), + RoleId::new("role-c"), + ], + conflict_type: ConflictType::AuthorityChallenge, + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Want], // 0 == 2 + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("role-b"), + kind: RelationshipKind::Colleague, + required_trust: TrustRange { min: 0, max: 5 }, + }], + }; + + let result = validate_triangle_def(&def); + assert!( + matches!( + result, + Err(ValidationError::InterestDivergence { + triangle_id: TriangleId(31), + duplicate_axis: NpcAxis::Want, + }) + ), + "expected InterestDivergence(Want) for axes[0]==axes[2], got: {:?}", + result + ); +} + +/// Validation checks are ordered: ConflictViability fires before Coherence. +/// +/// A def with no Want axis AND empty constraints should fail with +/// ConflictViability, not RelationshipCoherence. +#[test] +fn triangle_validation_conflict_viability_checked_before_coherence() { + let def = TriangleDef { + triangle_id: TriangleId(40), + roles: [ + RoleId::new("a"), + RoleId::new("b"), + RoleId::new("c"), + ], + conflict_type: ConflictType::LatentTension, + interest_axes: [NpcAxis::Contentment, NpcAxis::Tolerance, NpcAxis::Routine], + relationship_constraints: vec![], // also fails coherence + }; + + let result = validate_triangle_def(&def); + assert!( + matches!(result, Err(ValidationError::ConflictViability { .. })), + "ConflictViability must be checked before RelationshipCoherence, got: {:?}", + result + ); +} + +// --------------------------------------------------------------------------- +// #108 — Cross-template triangle generation +// --------------------------------------------------------------------------- + +/// Two instantiated templates produce 1 cross-template TriangleState +/// with role assignments spanning both templates. +/// +/// Spec: D-024 ("1 cross-template" requirement), D-025 (ownership model) +#[test] +fn triangle_validation_cross_template_spans_two_templates() { + let mut world = bevy_ecs::world::World::new(); + let mut rng = SimRng::new(42); + + let hub_id = TemplateId::from_seed_and_slug(42, "logistics-hub"); + let bar_id = TemplateId::from_seed_and_slug(42, "last-shift-bar"); + + // Logistics hub roles + spawn_template_npc(&mut world, hub_id, "ops-manager", 1); + spawn_template_npc(&mut world, hub_id, "freight-handler", 2); + + // Bar roles + spawn_template_npc(&mut world, bar_id, "bartender", 3); + + // Cross-template triangle: ops-manager (hub) + freight-handler (hub) + bartender (bar) + let def = valid_triangle_def(999); + let overridden = TriangleDef { + roles: [ + RoleId::new("ops-manager"), + RoleId::new("freight-handler"), + RoleId::new("bartender"), + ], + ..def + }; + + let result = generate_cross_template_triangles( + &mut world, + hub_id, + bar_id, + &[overridden], + &mut rng, + ); + + assert!( + result.warnings.is_empty(), + "no warnings expected for valid cross-template triangle: {:?}", + result.warnings + ); + assert_eq!( + result.triangles.len(), + 1, + "should generate exactly 1 cross-template triangle" + ); + + let state = &result.triangles[0]; + assert_eq!( + state.template_id, hub_id, + "cross-template triangle must be owned by template_a (hub)" + ); + assert_eq!(state.role_assignments.len(), 3); + + // Verify role assignments span both templates + let ops = state.role_assignments[&RoleId::new("ops-manager")]; + let freight = state.role_assignments[&RoleId::new("freight-handler")]; + let bartender = state.role_assignments[&RoleId::new("bartender")]; + assert_eq!(ops, StableId(1), "ops-manager must map to hub NPC 1"); + assert_eq!(freight, StableId(2), "freight-handler must map to hub NPC 2"); + assert_eq!(bartender, StableId(3), "bartender must map to bar NPC 3"); + + assert_eq!(state.phase, TrianglePhase::Simmering); + assert!(state.tension >= 5 && state.tension <= 25, "tension in seeded range"); + assert!(state.tension_rate >= 1 && state.tension_rate <= 5, "rate in seeded range"); +} + +/// Cross-template generation skips defs that fail validation, adding a warning. +#[test] +fn triangle_validation_cross_template_skips_invalid_defs() { + let mut world = bevy_ecs::world::World::new(); + let mut rng = SimRng::new(42); + + let hub_id = TemplateId::from_seed_and_slug(1, "hub"); + let bar_id = TemplateId::from_seed_and_slug(1, "bar"); + + spawn_template_npc(&mut world, hub_id, "ops-manager", 1); + spawn_template_npc(&mut world, hub_id, "freight-handler", 2); + spawn_template_npc(&mut world, bar_id, "bartender", 3); + + // Invalid def: no Want axis (fails conflict viability) + let invalid = TriangleDef { + triangle_id: TriangleId(50), + roles: [ + RoleId::new("ops-manager"), + RoleId::new("freight-handler"), + RoleId::new("bartender"), + ], + conflict_type: ConflictType::LatentTension, + interest_axes: [NpcAxis::Contentment, NpcAxis::Tolerance, NpcAxis::Routine], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("freight-handler"), + kind: RelationshipKind::Colleague, + required_trust: TrustRange { min: 0, max: 3 }, + }], + }; + + let result = generate_cross_template_triangles(&mut world, hub_id, bar_id, &[invalid], &mut rng); + + assert_eq!(result.triangles.len(), 0, "invalid def must be skipped"); + assert_eq!(result.warnings.len(), 1, "exactly one warning for the skipped def"); + assert!( + result.warnings[0].contains("validation failed"), + "warning must mention validation failure: {}", + result.warnings[0] + ); +} + +/// Cross-template generation is deterministic for the same seed (D-010). +#[test] +fn triangle_validation_cross_template_deterministic() { + let hub_id = TemplateId::from_seed_and_slug(42, "hub"); + let bar_id = TemplateId::from_seed_and_slug(42, "bar"); + let def = valid_triangle_def(1); + + let make_world = || { + let mut world = bevy_ecs::world::World::new(); + spawn_template_npc(&mut world, hub_id, "ops-manager", 1); + spawn_template_npc(&mut world, hub_id, "freight-handler", 2); + spawn_template_npc(&mut world, bar_id, "inspector", 3); + world + }; + + let overridden = TriangleDef { + roles: [ + RoleId::new("ops-manager"), + RoleId::new("freight-handler"), + RoleId::new("inspector"), + ], + ..def.clone() + }; + + let mut world1 = make_world(); + let result1 = generate_cross_template_triangles( + &mut world1, + hub_id, + bar_id, + &[overridden.clone()], + &mut SimRng::new(42), + ); + + let mut world2 = make_world(); + let result2 = generate_cross_template_triangles( + &mut world2, + hub_id, + bar_id, + &[overridden], + &mut SimRng::new(42), + ); + + assert_eq!(result1.triangles.len(), 1); + assert_eq!(result2.triangles.len(), 1); + assert_eq!( + result1.triangles[0].tension, + result2.triangles[0].tension, + "cross-template generation must be deterministic (D-010)" + ); + assert_eq!( + result1.triangles[0].tension_rate, + result2.triangles[0].tension_rate + ); +} + +/// D-024: cross-template generation is separate from intra-template generation. +/// Intra-template only sees its own template's NPCs. +#[test] +fn triangle_validation_intra_template_does_not_see_other_template_npcs() { + let mut world = bevy_ecs::world::World::new(); + let mut rng = SimRng::new(42); + + let hub_id = TemplateId::from_seed_and_slug(1, "hub"); + let bar_id = TemplateId::from_seed_and_slug(1, "bar"); + + // Only hub NPCs for roles ops-manager, freight-handler + spawn_template_npc(&mut world, hub_id, "ops-manager", 1); + spawn_template_npc(&mut world, hub_id, "freight-handler", 2); + // Bar NPC exists but should NOT be used by intra-template hub generation + spawn_template_npc(&mut world, bar_id, "inspector", 3); + + // Triangle requiring ops-manager + freight-handler + inspector + // intra-template hub generation cannot find "inspector" in hub NPCs + let def = TriangleDef { + triangle_id: TriangleId(5), + roles: [ + RoleId::new("ops-manager"), + RoleId::new("freight-handler"), + RoleId::new("inspector"), + ], + conflict_type: ConflictType::LoyaltyConflict, + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], + relationship_constraints: vec![], + }; + + let result = generate_intra_template_triangles(&mut world, hub_id, &[def, valid_triangle_def(6)], &mut rng); + + // The def needing "inspector" should fall back (inspector is in bar, not hub) + // At least one warning about the missing role + assert!( + !result.warnings.is_empty(), + "intra-template must not find bar NPCs — warning expected for missing 'inspector' role" + ); +}