diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e019597d..b6edc55c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] ### Added +- Social site template schema — RoleSchema (#163), SpaceSpec (#164), TriangleDef (#106) with YAML deserialization, sample templates at server/data/templates/ +- Single-ownership model — TemplateOwnership component, TemplateReferenceMap resource, cross-template reference links preserved across save/load and tier eviction (#165, D-025) +- Triangle generation — intra-template constraint satisfaction assigns NPCs to triangle roles, minimum 2 triangles per template with fallback on imperfect seeds (#107) +- Triangle escalation system — tick_triangle_escalation runs per game-minute, tension increments toward ToleranceThreshold, TriangleCrisisEvent emitted on Active phase entry, ResolveTriangle stub command (#250, D-087) +- Protocol v16 — TriangleCrisisEventWire on ObserverSnapshot for future client rendering of triangle crises - D-093: Sova Transit District spatial layout — 4 social sites (Terminal, Bar, Gate Cluster, Sector 3), 2 encounter nodes, zone palette, gate cluster 7-zone spec, z-level scheme (z=0 maintenance, z=1 main, z=2 observation gallery), 3 investigation paths, corridor widths - D-094: Spatial hierarchy — chunk (64×64 sim) → block (128×128 sim) → district (4×4 blocks, 256×256 visual), supersedes D-014 estimate - D-095: Horizon stations and transport lore — span gates (human-built, dual-use), horizon stations (alien-built, 4-8 apertures), "The Ring" per-system naming, sequential hop travel, The Loop internal tram @@ -14,6 +19,15 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - SnapshotEventRouter — callable-based snapshot dispatch replaces inline if-has blocks in main.gd (#559) - YamlParser shared utility — unified YAML parsing for UI strings and checklist conditions (#560) +### Fixed +- Wire triangle crisis event queue into observer snapshot — clients now receive TriangleCrisisEventWire via protocol v16 (was always empty) +- Persist TriangleState in SaveStateV1 — triangle phase and tension survive save/load cycles +- Validate dangling with_role references in TriangleDef constraint validation +- Replace O(n²) fallback NPC assignment with BTreeSet; prevent same NPC assigned to two roles in one triangle +- Replace O(N*M) scan in apply_resolve_triangle with BTreeMap index for O(1) per-command lookup +- Add From impls for RoleId, TriangleId, StableId, TriangleCrisisEventWire — eliminate fragile .0 newtype access +- Consolidate near-identical unit tests with integration counterparts + ### Changed - Sova station profile updated — horizon gates located at The Krenn Ring (800 AU), not on Station Sova; Admin Hub houses transit processing facility only - game_state.gd: stationary_ticks and zone_id now read from server snapshot with deprecated client-side fallbacks (#557, D-020) diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack index 47a9ea59e..717c4d4e1 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 ffd3ba965..6ad1d0cbe 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 d275e542c..142a76a3c 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 888250358..81ed70f7c 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 13547db7c..9f30a44f1 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 47a9ea59e..717c4d4e1 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 88d7aaa31..03348d54a 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 3e54cc521..6bff4d6b3 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 64ffe56ad..428f5dacf 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 6e7cbb7fa..25eb90f19 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 1c6340e12..c7565ed35 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 09def6a2c..19bd9d0f9 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/Cargo.lock b/server/Cargo.lock index 6b618aa1d..9b58cf422 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1092,7 +1092,7 @@ dependencies = [ [[package]] name = "settled-reach-server" -version = "0.1.15" +version = "0.1.19" dependencies = [ "bevy_app", "bevy_ecs", diff --git a/server/data/templates/dock-worker.yaml b/server/data/templates/dock-worker.yaml new file mode 100644 index 000000000..b704aeced --- /dev/null +++ b/server/data/templates/dock-worker.yaml @@ -0,0 +1,31 @@ +# Sample role schema for the dock-worker role in the terminal social site. +# Spec ref: D-023 (Tier 2 templates), D-024 (10-axis model), D-025 (social site) +# +# Tile scale note (D-066): tile_count_* fields are in SIM tiles (0.5m each). +# 1 visual tile = 2 sim tiles. A 15-40 visual tile space = 30-80 sim tiles. + +role_id: "dock-worker" +required_traits: + - Honest + - Social +skill_focus: + - Technical + - Observation +relationship_constraints: + - with_role: "logistics-manager" + kind: Colleague + 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" + - phase: afternoon + location: "terminal-cargo-bay" + - phase: evening + location: "bar-last-shift" diff --git a/server/data/templates/terminal-social-site.yaml b/server/data/templates/terminal-social-site.yaml new file mode 100644 index 000000000..0b9866f13 --- /dev/null +++ b/server/data/templates/terminal-social-site.yaml @@ -0,0 +1,18 @@ +# Sample space spec for the terminal social site (Sova Logistics Hub). +# Spec ref: D-025 (15-40 visual tiles = 30-80 sim tiles), D-064 (dual-scale grid) +# +# Tile scale (D-066): all tile counts are SIM tiles (0.5m each). +# Terminal: 44×28 visual tiles = 88×56 sim tiles = 4928 sim tiles. +# Using a subsection for one functional zone: ~44×8 visual = 88×16 sim = 1408 sim. + +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 diff --git a/server/data/templates/terminal-triangle-01.yaml b/server/data/templates/terminal-triangle-01.yaml new file mode 100644 index 000000000..8764546b5 --- /dev/null +++ b/server/data/templates/terminal-triangle-01.yaml @@ -0,0 +1,24 @@ +# Sample triangle definition: T4 Drin-System-Ring (D-087 active fork). +# Spec ref: D-024 (triangles as atomic social unit), D-087 (T4 configuration), +# D-089 (self-contained, no cross-triangle cascade) +# +# Note: triangle_id is computed at world-gen time from seed + roles. +# The value below is a placeholder for YAML authoring — the runtime +# calls TriangleId::from_seed_and_roles() to derive the actual ID. + +triangle_id: 0 +roles: + - "ring-leader" + - "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 diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index 777c32e50..aabb19d90 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -313,6 +313,7 @@ mod tests { examine_result: None, player_knowledge: None, save_result: None, + triangle_crisis_events: vec![], } } @@ -448,6 +449,7 @@ mod tests { examine_result: None, player_knowledge: None, save_result: None, + triangle_crisis_events: 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 6274ea1fa..e258ee8c7 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 = 15; +pub const PROTOCOL_VERSION: u8 = 16; /// Handshake message sent as the very first framed message after connection (#555). /// Client reads this before entering the normal tick loop and validates @@ -50,10 +50,11 @@ pub struct HandshakeMessage { /// examine_result (#242, character-filtered examine observation text), /// 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). /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { - /// Protocol version for forward compatibility. Current: 15. + /// Protocol version for forward compatibility. Current: 16. pub version: u8, /// Simulation tick when this snapshot was produced pub tick: u64, @@ -151,6 +152,11 @@ pub struct ObserverSnapshot { /// Client shows a confirmation toast (success) or error modal (failure). #[serde(default, skip_serializing_if = "Option::is_none")] pub save_result: Option, + /// Triangle crisis events this tick (#250, D-087). + /// Emitted when a triangle enters Active phase. Client may render + /// a narrative event or HUD indicator. Empty when no crises occur. + #[serde(default)] + pub triangle_crisis_events: Vec, } /// Game time data for client display (D-031) @@ -676,6 +682,38 @@ pub struct SaveLoadResultWire { pub error: Option, } +/// Triangle crisis event for future client rendering (#250, D-087). +/// +/// Emitted when a triangle transitions from Simmering to Active. +/// Client may display a narrative beat, HUD indicator, or tension meter. +/// Wire format uses primitives for cross-boundary safety. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TriangleCrisisEventWire { + /// Triangle identifier (TriangleId as u64). + pub triangle_id: u64, + /// NPC role assignments: (role_slug, stable_npc_id). + pub role_assignments: Vec<(String, u64)>, + /// The NPC whose tolerance threshold triggered the crisis. + pub trigger_npc_id: u64, + /// Tick when the crisis was triggered. + pub tick: u64, +} + +impl From for TriangleCrisisEventWire { + fn from(e: crate::content::template::TriangleCrisisEvent) -> Self { + Self { + triangle_id: e.triangle_id.into(), + role_assignments: e + .role_assignments + .into_iter() + .map(|(role, sid)| (String::from(role), u64::from(sid))) + .collect(), + trigger_npc_id: e.trigger_npc.into(), + tick: e.tick, + } + } +} + /// Snapshot buffer resource for staging outgoing ObserverSnapshots #[derive(Resource, Debug, Default)] pub struct SnapshotBuffer { diff --git a/server/src/content/mod.rs b/server/src/content/mod.rs index dd150c73e..34e71eeb0 100644 --- a/server/src/content/mod.rs +++ b/server/src/content/mod.rs @@ -14,6 +14,7 @@ pub mod hot_reload; pub mod line_pool; pub mod loader; pub mod spawn; +pub mod template; pub mod types; use bevy_app::prelude::*; diff --git a/server/src/content/template.rs b/server/src/content/template.rs new file mode 100644 index 000000000..93527b008 --- /dev/null +++ b/server/src/content/template.rs @@ -0,0 +1,1379 @@ +//! Social site template schema types (#163, #164, #165, #106, #250). +//! +//! This module defines the Tier 2 template system — the foundational schema for +//! social sites (D-025). Templates describe the role structure, spatial layout, +//! ownership model, and triangle conflict patterns for a functional cluster of +//! 4–8 NPCs in a 15–40 visual tile space. +//! +//! ## Architecture +//! +//! Content-side types (YAML deserialization): `RoleSchema`, `SpaceSpec`, `TriangleDef` +//! ECS-side types (runtime components/resources): `TemplateOwnership`, `TemplateReferenceMap` +//! +//! The content types are authored in YAML at `server/data/templates/` and consumed +//! by the template instantiation pipeline. The ECS types are assigned at spawn time +//! and persisted across save/load and tier transitions. +//! +//! ## Escalation (#250) +//! +//! `tick_triangle_escalation` runs once per game-minute (D-031) and increments +//! tension on Simmering/Active triangles. When tension exceeds the lowest +//! `ToleranceThreshold` among the triangle's NPCs, the triangle transitions +//! from Simmering → Active and a `TriangleCrisisEvent` is emitted. +//! +//! ## Determinism (D-010) +//! +//! - `TemplateId` and `TriangleId` use FNV-1a hashing for deterministic generation +//! from seed + slug. Never use `std::hash::DefaultHasher` (non-deterministic). +//! - All collections use `BTreeMap` / `Vec` (no `HashMap`). + +use bevy_ecs::prelude::*; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +use crate::knowledge::registry::StableEntityId; +use crate::knowledge::types::StableId; +use crate::knowledge::EntityRegistry; +use crate::npc::{PersonalityTrait, RelationshipKind, Skill, ToleranceThreshold}; +use crate::simulation::rng::SimRng; +use crate::simulation::tier::ActiveSim; +use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE}; + +// =========================================================================== +// #163 — Role definition schema +// =========================================================================== + +/// Stable role identifier within a template. +/// +/// A string slug (e.g. "bartender", "dock-worker") that is stable across +/// save/load. Not a bevy `Entity` — serializes cleanly via serde. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct RoleId(pub String); + +impl RoleId { + pub fn new(id: &str) -> Self { + RoleId(id.to_string()) + } +} + +impl From for String { + fn from(id: RoleId) -> Self { + id.0 + } +} + +/// Trust range constraint for a relationship. +/// Both bounds are inclusive: the generated trust value must be in `[min, max]`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TrustRange { + pub min: i8, + pub max: i8, +} + +/// Constraint on a relationship that must exist within the same template. +/// +/// Example: the "bartender" role must have a `Colleague` relationship with the +/// "waitstaff" role at trust level 2–5. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RelationshipConstraint { + /// The other role this relationship targets (must exist in the same template). + pub with_role: RoleId, + /// Required relationship kind. + pub kind: RelationshipKind, + /// Required trust range for the generated relationship. + pub required_trust: TrustRange, +} + +/// A routine entry template: phase → location name mapping. +/// +/// Phase is a string slug (e.g. "morning", "evening") resolved to `DayPhase` +/// during template instantiation. Location names are resolved to tile positions. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TemplateRoutineEntry { + pub phase: String, + pub location: String, + #[serde(default)] + pub activity: Option, +} + +/// Tier 2 role schema — constraints fed into the NPC generator (D-024). +/// +/// Distinct from `RoleDefinition` in `npc/generate.rs`: `RoleSchema` is the +/// authored content specification; `RoleDefinition` is the runtime builder +/// derived from it during template instantiation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RoleSchema { + /// Unique role identifier within the template. + pub role_id: RoleId, + /// Personality traits that NPCs filling this role should have. + #[serde(default)] + pub required_traits: Vec, + /// Skills biased toward for this role. + #[serde(default)] + pub skill_focus: Vec, + /// Relationship constraints with other roles in the same template. + #[serde(default)] + pub relationship_constraints: Vec, + /// Routine template: phase → location name mappings. + #[serde(default)] + pub routine_template: Vec, +} + +impl RoleSchema { + /// Validate this role schema. + /// + /// Checks: + /// - No self-referential relationship constraints (with_role != role_id). + /// - Trust range min <= max. + /// + /// Returns `Err` with a description of the first validation failure. + pub fn validate(&self) -> Result<(), String> { + for (i, constraint) in self.relationship_constraints.iter().enumerate() { + if constraint.with_role == self.role_id { + return Err(format!( + "relationship_constraints[{}]: self-referential constraint (with_role == role_id '{}')", + i, self.role_id.0 + )); + } + if constraint.required_trust.min > constraint.required_trust.max { + return Err(format!( + "relationship_constraints[{}]: trust min ({}) > max ({})", + i, constraint.required_trust.min, constraint.required_trust.max + )); + } + } + + Ok(()) + } +} + +/// Validate that a collection of `RoleSchema`s contains no duplicate `role_id`s. +/// +/// Returns `Err` naming the first duplicate found. +pub fn validate_role_schemas_no_duplicate_ids(schemas: &[RoleSchema]) -> Result<(), String> { + let mut seen = std::collections::BTreeSet::new(); + for schema in schemas { + if !seen.insert(&schema.role_id) { + return Err(format!("duplicate role_id: '{}'", schema.role_id.0)); + } + } + Ok(()) +} + +// =========================================================================== +// #164 — Spatial requirement specification +// =========================================================================== + +/// Named sub-area with a sightline coverage radius. +/// +/// Radius is in sim tiles (0.5m each per D-066). Example: `radius: 4` = 2m +/// clear sightline from the zone center. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SightlineZone { + pub name: String, + /// Coverage radius in sim tiles (0.5m each). 4 sim tiles = 2m. + pub radius: u32, +} + +/// Privacy level governing NPC disclosure behavior. +/// +/// NPCs are less likely to disclose secrets in `Public` spaces. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PrivacyLevel { + Public, + SemiPrivate, + Private, +} + +/// Traffic pattern governing procedural NPC routine routing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TrafficPattern { + /// High-traffic corridor — many NPCs route through. + Thoroughfare, + /// Destination point — NPCs travel TO this space, don't pass through. + Destination, + /// Limited access — only assigned NPCs enter. + Restricted, +} + +/// Spatial requirement specification for a template. +/// +/// Tile counts are in **sim tiles** (0.5m each per D-066). +/// A 15–40 visual tile space (per D-025) = 30–80 sim tiles. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpaceSpec { + /// Minimum tile count in sim tiles (0.5m each). + pub tile_count_min: u32, + /// Maximum tile count in sim tiles (0.5m each). + pub tile_count_max: u32, + /// Named sightline zones within this space. + #[serde(default)] + pub sightline_zones: Vec, + /// Privacy level for NPC behavior modulation. + pub privacy_level: PrivacyLevel, + /// Traffic pattern for routine routing. + pub traffic_pattern: TrafficPattern, +} + +impl SpaceSpec { + /// Validate this space spec. + /// + /// Checks: + /// - tile_count_min <= tile_count_max. + /// - tile_count_min > 0. + /// + /// Returns `Err` with a description of the first validation failure. + pub fn validate(&self) -> Result<(), String> { + if self.tile_count_min == 0 { + return Err("tile_count_min must be > 0".to_string()); + } + if self.tile_count_min > self.tile_count_max { + return Err(format!( + "tile_count_min ({}) > tile_count_max ({})", + self.tile_count_min, self.tile_count_max + )); + } + + Ok(()) + } +} + +// =========================================================================== +// #165 — Single-ownership model +// =========================================================================== + +/// Deterministic template identifier. +/// +/// Generated from world seed + template slug via FNV-1a hash. Stable across +/// save/load — never derived from bevy `Entity` handles. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct TemplateId(pub u64); + +impl TemplateId { + /// Compute a deterministic `TemplateId` from a world seed and template slug. + /// + /// Uses FNV-1a (64-bit) for determinism — `std::hash::DefaultHasher` is + /// prohibited by D-010 principle 4 (non-deterministic across Rust versions). + pub fn from_seed_and_slug(seed: u64, slug: &str) -> Self { + let mut hash = seed ^ 0xcbf29ce484222325; // FNV-1a offset basis, XOR'd with seed + for byte in slug.as_bytes() { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x100000001b3); // FNV-1a prime + } + TemplateId(hash) + } +} + +/// ECS component: which template owns this NPC and which role it fills. +/// +/// Assigned at template instantiation, **never reassigned** (D-025 single-ownership). +/// Preserved across tier transitions and save/load. +#[derive(Component, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TemplateOwnership { + pub template_id: TemplateId, + pub role_id: RoleId, +} + +/// A cross-template reference link. +/// +/// Records that a role in one template has a relationship with a role in +/// another template. Preserved when templates are unloaded (tier eviction) +/// so the social web metadata survives even when NPCs aren't in Active tier. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TemplateReference { + pub from_template: TemplateId, + pub to_template: TemplateId, + pub via_role: RoleId, + pub relationship_metadata: RelationshipKind, +} + +/// Resource: all cross-template reference links, indexed by source template. +/// +/// Uses `BTreeMap` for deterministic iteration (D-010). +/// Preserved across save/load — inserted into `SaveStateV1`. +#[derive(Resource, Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct TemplateReferenceMap { + pub entries: BTreeMap>, +} + +impl TemplateReferenceMap { + /// Add a reference link. Appends to the entry list for `ref_link.from_template`. + pub fn add(&mut self, ref_link: TemplateReference) { + self.entries + .entry(ref_link.from_template) + .or_default() + .push(ref_link); + } + + /// Get all outgoing references from a template. + pub fn outgoing(&self, template_id: TemplateId) -> &[TemplateReference] { + self.entries + .get(&template_id) + .map(|v| v.as_slice()) + .unwrap_or(&[]) + } + + /// Iterate over all references across all templates. + /// Iteration order is deterministic (BTreeMap key ordering). + pub fn all_references(&self) -> impl Iterator { + self.entries.values().flat_map(|v| v.iter()) + } +} + +// =========================================================================== +// #106 — Triangle definition schema +// =========================================================================== + +/// Deterministic triangle identifier. +/// +/// Generated from template seed + sorted role triple via FNV-1a hash. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct TriangleId(pub u64); + +impl TriangleId { + /// Compute a deterministic `TriangleId` from a seed and three role IDs. + /// + /// Roles are sorted before hashing to ensure the same triple always produces + /// the same ID regardless of input order. + pub fn from_seed_and_roles(seed: u64, roles: &[RoleId; 3]) -> Self { + let mut sorted: Vec<&str> = roles.iter().map(|r| r.0.as_str()).collect(); + sorted.sort(); + + let mut hash = seed ^ 0xcbf29ce484222325; // FNV-1a offset basis + for role_str in sorted { + for byte in role_str.as_bytes() { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + // Separator to avoid "ab" + "c" == "a" + "bc" + hash ^= 0xFF; + hash = hash.wrapping_mul(0x100000001b3); + } + TriangleId(hash) + } +} + +impl From for u64 { + fn from(id: TriangleId) -> Self { + id.0 + } +} + +/// Which NPC axis is in tension for a given role in a triangle. +/// +/// Maps to the D-024 10-axis model. Used to specify which axis diverges +/// for each of the three roles in a `TriangleDef`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum NpcAxis { + Want, + Secret, + Relationships, + Tolerance, + Routine, + InformationInventory, + Contentment, + PersonalityTraits, + TellSystem, + SkillSet, +} + +/// Conflict type classification per D-087 active fork patterns. +/// +/// Active forks use `ResourceCompetition`, `LoyaltyConflict`, `SecretExposure`, +/// or `AuthorityChallenge`. Passive tensions use `LatentTension`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ConflictType { + ResourceCompetition, + LoyaltyConflict, + SecretExposure, + AuthorityChallenge, + LatentTension, +} + +/// Triangle definition — the atomic unit of social intrigue (D-024). +/// +/// Three roles, each with a conflicting NPC axis. Authored as part of a template +/// definition or as a standalone `triangles.yaml`. +/// +/// D-089: self-contained for v0.1. No cross-triangle cascade fields. +/// Cross-template triangles reference a `RoleId` from a different `TemplateId` +/// via the role itself, not a special triangle type. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TriangleDef { + /// Deterministic triangle identifier. + pub triangle_id: TriangleId, + /// The three roles involved. Must all be distinct. + pub roles: [RoleId; 3], + /// Classification of the conflict. + pub conflict_type: ConflictType, + /// Which NPC axis is in tension for each of the three roles. + pub interest_axes: [NpcAxis; 3], + /// Additional relationship constraints specific to this triangle. + #[serde(default)] + pub relationship_constraints: Vec, +} + +impl TriangleDef { + /// Validate this triangle definition. + /// + /// Checks: + /// - All three roles are distinct. + /// - Relationship constraint trust ranges are valid (min <= max). + /// + /// Returns `Err` with a description of the first validation failure. + pub fn validate(&self) -> Result<(), String> { + if self.roles[0] == self.roles[1] { + return Err(format!( + "roles[0] and roles[1] are identical: '{}'", + self.roles[0].0 + )); + } + if self.roles[0] == self.roles[2] { + return Err(format!( + "roles[0] and roles[2] are identical: '{}'", + self.roles[0].0 + )); + } + if self.roles[1] == self.roles[2] { + return Err(format!( + "roles[1] and roles[2] are identical: '{}'", + self.roles[1].0 + )); + } + + let role_set: std::collections::BTreeSet<&RoleId> = self.roles.iter().collect(); + for (i, constraint) in self.relationship_constraints.iter().enumerate() { + if constraint.required_trust.min > constraint.required_trust.max { + return Err(format!( + "relationship_constraints[{}]: trust min ({}) > max ({})", + i, constraint.required_trust.min, constraint.required_trust.max + )); + } + if !role_set.contains(&constraint.with_role) { + return Err(format!( + "relationship_constraints[{}]: with_role '{}' not in triangle roles", + i, constraint.with_role.0 + )); + } + } + + Ok(()) + } +} + +// =========================================================================== +// #107 — Intra-template triangle generation +// =========================================================================== + +/// Phase of a triangle's lifecycle. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TrianglePhase { + /// Not yet active — waiting for conditions. + Dormant, + /// Tension is building but hasn't reached a crisis. + Simmering, + /// Tension has exceeded a threshold — crisis in progress. + Active, + /// Triangle has been resolved (by player or system). D-089: no cascade. + Resolved, +} + +/// Runtime state of an instantiated triangle (#107). +/// +/// ECS component attached to a dedicated triangle entity (not on an NPC). +/// Tracks the current tension level and phase for a specific triangle instance. +#[derive(Component, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TriangleState { + /// Which triangle definition this instance was generated from. + pub triangle_id: TriangleId, + /// NPC role → StableId assignments for this triangle instance. + /// Uses `BTreeMap` for deterministic iteration (D-010). + pub role_assignments: BTreeMap, + /// Current tension level (0–255). Starts at a seeded value. + pub tension: u8, + /// Current lifecycle phase. + pub phase: TrianglePhase, + /// Per-tick tension increment rate, seeded at world-gen time. + /// Stored here so escalation system doesn't need to recompute. + pub tension_rate: u8, + /// Which template owns this triangle. + pub template_id: TemplateId, +} + +/// Result of triangle generation for a single template. +#[derive(Debug)] +pub struct TriangleGenerationResult { + /// Successfully generated triangle states. + pub triangles: Vec, + /// Warnings emitted during generation (e.g., fallback assignments). + pub warnings: Vec, +} + +/// Generate triangle instances from triangle definitions for a template (#107). +/// +/// For each `TriangleDef`, assigns NPCs (by `StableId`) to the three roles. +/// NPCs are queried from the ECS world by their `TemplateOwnership` component. +/// +/// Minimum 2 triangles per template — emits an error log if fewer than 2 +/// `TriangleDef` entries are provided. +/// +/// **Fallback behavior:** If no NPC satisfies a strict role constraint, the +/// closest match is used and a warning is logged. Generation never panics. +/// +/// All randomness flows through `rng` for determinism (D-010). +/// +/// Takes `&mut World` (inherently single-threaded) because it spawns +/// `TriangleState` entities. Consistent with `spawn.rs` template instantiation. +pub fn generate_intra_template_triangles( + world: &mut World, + template_id: TemplateId, + defs: &[TriangleDef], + rng: &mut SimRng, +) -> TriangleGenerationResult { + let mut result = TriangleGenerationResult { + triangles: Vec::new(), + warnings: Vec::new(), + }; + + if defs.len() < 2 { + tracing::error!( + "Template {:?}: fewer than 2 TriangleDefs provided ({}). D-024 requires minimum 2.", + template_id, + defs.len() + ); + } + + // Collect all NPCs owned by this template: (RoleId, StableId) + let npc_roles: Vec<(RoleId, StableId)> = { + let mut q = world.query::<(&TemplateOwnership, &StableEntityId)>(); + q.iter(world) + .filter(|(own, _)| own.template_id == template_id) + .map(|(own, sid)| (own.role_id.clone(), sid.0)) + .collect() + }; + + // Build a role → StableId lookup (BTreeMap for determinism) + let role_to_npc: BTreeMap = npc_roles.into_iter().collect(); + + for def in defs { + 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) { + // This NPC is already assigned to another role in this triangle. + // Fall through to fallback instead of duplicating. + } else { + role_assignments.insert(role_id.clone(), stable_id); + assigned_npcs.insert(stable_id); + continue; + } + } + + // Fallback: pick the first available NPC not already assigned to this triangle. + let fallback = role_to_npc + .values() + .find(|sid| !assigned_npcs.contains(sid)); + + if let Some(&fallback_sid) = fallback { + result.warnings.push(format!( + "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!( + "Triangle {:?}: no NPC available for role '{}' — skipping triangle", + def.triangle_id, role_id.0 + )); + assignment_ok = false; + break; + } + } + + if !assignment_ok { + continue; + } + + // Seed initial tension and rate from RNG + 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, + }); + } + + result +} + +// =========================================================================== +// #250 — Triangle escalation system +// =========================================================================== + +/// Event emitted when a triangle transitions from Simmering to Active. +/// +/// Downstream systems (monologue, knowledge graph) can subscribe to this event +/// for narrative responses — wiring those subscribers is future work. +/// +/// Spec: #250, D-087 (v0.1 triangle config), D-089 (self-contained, no cascade) +#[derive(Debug, Clone)] +pub struct TriangleCrisisEvent { + /// Which triangle entered crisis. + pub triangle_id: TriangleId, + /// NPC role assignments at the time of crisis. + pub role_assignments: BTreeMap, + /// The NPC whose tolerance threshold was lowest (trigger). + pub trigger_npc: StableId, + /// Tick when the crisis was triggered. + pub tick: u64, +} + +/// Resource: queue of triangle crisis events (#250). +/// +/// Populated by `tick_triangle_escalation`. Drained by consumers +/// (monologue system, knowledge system — future work). +#[derive(Resource, Default)] +pub struct TriangleCrisisEventQueue { + pub events: Vec, +} + +impl TriangleCrisisEventQueue { + pub fn push(&mut self, event: TriangleCrisisEvent) { + self.events.push(event); + } + + pub fn drain(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + pub fn len(&self) -> usize { + self.events.len() + } + + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } +} + +/// Command to resolve an active triangle fork (#250, D-089). +/// +/// Resolution mechanism TBD — this is a stub. +/// D-089: resolution does not cascade to other triangles. +#[derive(Debug, Clone)] +pub struct ResolveTriangleCommand(pub TriangleId); + +/// Resource: queue of triangle resolution commands (#250). +/// +/// Populated by player actions (future) or scripted events. +/// Consumed by `apply_resolve_triangle` system. +#[derive(Resource, Default)] +pub struct ResolveTriangleQueue { + pub commands: Vec, +} + +impl ResolveTriangleQueue { + pub fn push(&mut self, cmd: ResolveTriangleCommand) { + self.commands.push(cmd); + } + + pub fn drain(&mut self) -> Vec { + std::mem::take(&mut self.commands) + } +} + +/// System: escalate triangle tension once per game-minute (#250). +/// +/// Runs every 10 ticks (per D-031). For each `TriangleState` on an +/// Active-tier entity in `Simmering` or `Active` phase: +/// - Increments `tension` by the triangle's seeded `tension_rate`. +/// - For Simmering: when tension exceeds the lowest `ToleranceThreshold` +/// among the triangle's three NPCs, transitions to `Active` and emits +/// a `TriangleCrisisEvent`. +/// - For Active: tension continues incrementing (narrative tracking). +/// +/// Dormant and Resolved triangles are not processed. +/// +/// Scoped to `ActiveSim` entities (D-026 tier boundary). +pub fn tick_triangle_escalation( + time: Res, + registry: Res, + mut crisis_queue: ResMut, + mut triangles: Query<&mut TriangleState, With>, + thresholds: Query<&ToleranceThreshold>, +) { + // Only process on game-minute boundaries (every 10 ticks, D-031) + if time.tick % TICKS_PER_GAME_MINUTE != 0 { + return; + } + + for mut state in triangles.iter_mut() { + match state.phase { + TrianglePhase::Simmering => { + state.tension = state.tension.saturating_add(state.tension_rate); + + // Find the lowest ToleranceThreshold among the triangle's NPCs. + // The NPC with the lowest threshold is the "weakest link" that + // triggers the crisis transition. + let mut min_threshold: Option<(i16, StableId)> = None; + for stable_id in state.role_assignments.values() { + if let Some(entity) = registry.to_entity(stable_id) { + if let Ok(tt) = thresholds.get(entity) { + match min_threshold { + None => min_threshold = Some((tt.threshold, *stable_id)), + Some((current_min, _)) if tt.threshold < current_min => { + min_threshold = Some((tt.threshold, *stable_id)); + } + _ => {} + } + } + } + } + + if let Some((threshold, trigger_npc)) = min_threshold { + if i16::from(state.tension) > threshold { + state.phase = TrianglePhase::Active; + crisis_queue.push(TriangleCrisisEvent { + triangle_id: state.triangle_id, + role_assignments: state.role_assignments.clone(), + trigger_npc, + tick: time.tick, + }); + tracing::info!( + "Triangle {:?}: Simmering → Active (tension={}, threshold={}, trigger={:?}) at tick {}", + state.triangle_id, + state.tension, + threshold, + trigger_npc, + time.tick + ); + } + } + } + TrianglePhase::Active => { + // Continue incrementing for narrative tracking + state.tension = state.tension.saturating_add(state.tension_rate); + } + // Dormant and Resolved: no action + TrianglePhase::Dormant | TrianglePhase::Resolved => {} + } + } +} + +/// System: apply triangle resolution commands (#250, D-089). +/// +/// Reads `ResolveTriangleQueue` and sets matching `TriangleState.phase` to +/// `Resolved`. D-089: resolution does not cascade to other triangles. +pub fn apply_resolve_triangle( + mut queue: ResMut, + mut triangles: Query<(Entity, &mut TriangleState)>, +) { + let commands = queue.drain(); + if commands.is_empty() { + return; + } + + // Build index: O(N) scan once, then O(1) per resolve command. + // Avoids O(N*M) full scan when multiple resolves fire in one tick. + let id_to_entity: BTreeMap = triangles + .iter() + .map(|(entity, state)| (state.triangle_id.clone(), entity)) + .collect(); + + for cmd in commands { + if let Some(&entity) = id_to_entity.get(&cmd.0) { + if let Ok((_, mut state)) = triangles.get_mut(entity) { + state.phase = TrianglePhase::Resolved; + tracing::info!( + "Triangle {:?}: resolved (D-089, no cascade)", + state.triangle_id + ); + } + } + } +} + +// =========================================================================== +// Tests +// =========================================================================== + +#[cfg(test)] +mod tests { + use super::*; + + // ----------------------------------------------------------------------- + // #163 — RoleSchema tests + // (YAML roundtrip, validation, duplicates covered by integration tests + // in tests/template_schema.rs — only unique tests here) + // ----------------------------------------------------------------------- + + // ----------------------------------------------------------------------- + // #164 — SpaceSpec tests + // (YAML roundtrip, min>max covered by integration tests — + // zero_min and valid_passes are unique) + // ----------------------------------------------------------------------- + + #[test] + fn space_spec_validate_catches_min_gt_max() { + let spec = SpaceSpec { + tile_count_min: 100, + tile_count_max: 50, + sightline_zones: vec![], + privacy_level: PrivacyLevel::Public, + traffic_pattern: TrafficPattern::Thoroughfare, + }; + + assert!(spec.validate().is_err()); + } + + #[test] + fn space_spec_validate_catches_zero_min() { + let spec = SpaceSpec { + tile_count_min: 0, + tile_count_max: 50, + sightline_zones: vec![], + privacy_level: PrivacyLevel::Public, + traffic_pattern: TrafficPattern::Thoroughfare, + }; + + assert!(spec.validate().is_err()); + } + + #[test] + fn space_spec_valid_passes() { + let spec = SpaceSpec { + tile_count_min: 30, + tile_count_max: 80, + sightline_zones: vec![SightlineZone { + name: "main_area".into(), + radius: 6, + }], + privacy_level: PrivacyLevel::Private, + traffic_pattern: TrafficPattern::Restricted, + }; + + assert!(spec.validate().is_ok()); + } + + // ----------------------------------------------------------------------- + // #165 — Single-ownership model tests + // (TemplateId determinism covered by integration tests — + // serialization roundtrips are unique) + // ----------------------------------------------------------------------- + + #[test] + fn template_ownership_serialize_roundtrip() { + let ownership = TemplateOwnership { + template_id: TemplateId::from_seed_and_slug(42, "cantina"), + role_id: RoleId::new("bartender"), + }; + + let bytes = rmp_serde::to_vec_named(&ownership).expect("serialize"); + let recovered: TemplateOwnership = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!(ownership, recovered); + } + + #[test] + fn template_reference_map_add_and_query() { + let mut map = TemplateReferenceMap::default(); + let t1 = TemplateId::from_seed_and_slug(1, "cantina"); + let t2 = TemplateId::from_seed_and_slug(1, "dock_office"); + + map.add(TemplateReference { + from_template: t1, + to_template: t2, + via_role: RoleId::new("informant"), + relationship_metadata: RelationshipKind::Colleague, + }); + + assert_eq!(map.outgoing(t1).len(), 1); + assert_eq!(map.outgoing(t2).len(), 0); + assert_eq!(map.outgoing(t1)[0].to_template, t2); + } + + #[test] + fn template_reference_map_serialize_roundtrip() { + let mut map = TemplateReferenceMap::default(); + let t1 = TemplateId(100); + let t2 = TemplateId(200); + + map.add(TemplateReference { + from_template: t1, + to_template: t2, + via_role: RoleId::new("courier"), + relationship_metadata: RelationshipKind::Friend, + }); + + let bytes = rmp_serde::to_vec_named(&map).expect("serialize"); + let recovered: TemplateReferenceMap = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!(map, recovered); + } + + // ----------------------------------------------------------------------- + // #106 — Triangle definition schema tests + // (YAML roundtrip, validation, D-087 T1 covered by integration tests — + // order-independence and passive tension are unique) + // ----------------------------------------------------------------------- + + #[test] + fn triangle_id_deterministic_and_order_independent() { + let roles = [ + RoleId::new("smuggler"), + RoleId::new("detective"), + RoleId::new("informant"), + ]; + let id1 = TriangleId::from_seed_and_roles(42, &roles); + + let roles_reordered = [ + RoleId::new("detective"), + RoleId::new("informant"), + RoleId::new("smuggler"), + ]; + let id2 = TriangleId::from_seed_and_roles(42, &roles_reordered); + assert_eq!(id1, id2); + + let id3 = TriangleId::from_seed_and_roles(99, &roles); + assert_ne!(id1, id3); + } + + #[test] + fn d087_passive_tension_expressible() { + let def = TriangleDef { + triangle_id: TriangleId(999), + roles: [ + RoleId::new("worker_a"), + RoleId::new("worker_b"), + RoleId::new("supervisor"), + ], + conflict_type: ConflictType::LatentTension, + interest_axes: [NpcAxis::Contentment, NpcAxis::Contentment, NpcAxis::Tolerance], + relationship_constraints: vec![], + }; + + assert!(def.validate().is_ok()); + } + + // ----------------------------------------------------------------------- + // #107 — Intra-template triangle generation tests + // ----------------------------------------------------------------------- + + fn spawn_template_npc( + world: &mut World, + template_id: TemplateId, + role: &str, + stable_id: u64, + ) -> bevy_ecs::entity::Entity { + world + .spawn(( + crate::npc::Npc, + TemplateOwnership { + template_id, + role_id: RoleId::new(role), + }, + StableEntityId(StableId(stable_id)), + )) + .id() + } + + #[test] + fn generate_triangles_basic_4_npc_template() { + let mut world = bevy_ecs::world::World::new(); + let tid = TemplateId::from_seed_and_slug(42, "test-site"); + let mut rng = SimRng::new(42); + + // Spawn 4 NPCs + spawn_template_npc(&mut world, tid, "bartender", 1); + spawn_template_npc(&mut world, tid, "waitstaff", 2); + spawn_template_npc(&mut world, tid, "bouncer", 3); + spawn_template_npc(&mut world, tid, "regular", 4); + + let defs = vec![ + TriangleDef { + triangle_id: TriangleId::from_seed_and_roles( + 42, + &[ + RoleId::new("bartender"), + RoleId::new("waitstaff"), + RoleId::new("bouncer"), + ], + ), + roles: [ + RoleId::new("bartender"), + RoleId::new("waitstaff"), + RoleId::new("bouncer"), + ], + conflict_type: ConflictType::LoyaltyConflict, + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], + relationship_constraints: vec![], + }, + TriangleDef { + triangle_id: TriangleId::from_seed_and_roles( + 42, + &[ + RoleId::new("bartender"), + RoleId::new("bouncer"), + RoleId::new("regular"), + ], + ), + roles: [ + RoleId::new("bartender"), + RoleId::new("bouncer"), + RoleId::new("regular"), + ], + conflict_type: ConflictType::ResourceCompetition, + interest_axes: [NpcAxis::Want, NpcAxis::Tolerance, NpcAxis::Contentment], + relationship_constraints: vec![], + }, + ]; + + let result = generate_intra_template_triangles(&mut world, tid, &defs, &mut rng); + + assert_eq!(result.triangles.len(), 2, "should generate 2 triangles"); + assert!(result.warnings.is_empty(), "no warnings expected: {:?}", result.warnings); + + // Verify role assignments + let t1 = &result.triangles[0]; + assert_eq!(t1.role_assignments.len(), 3); + assert_eq!(t1.role_assignments[&RoleId::new("bartender")], StableId(1)); + assert_eq!(t1.role_assignments[&RoleId::new("waitstaff")], StableId(2)); + assert_eq!(t1.role_assignments[&RoleId::new("bouncer")], StableId(3)); + assert_eq!(t1.phase, TrianglePhase::Simmering); + assert!(t1.tension >= 5 && t1.tension <= 25); + assert!(t1.tension_rate >= 1 && t1.tension_rate <= 5); + + let t2 = &result.triangles[1]; + assert_eq!(t2.role_assignments[&RoleId::new("regular")], StableId(4)); + } + + #[test] + fn generate_triangles_fallback_on_missing_role() { + let mut world = bevy_ecs::world::World::new(); + let tid = TemplateId::from_seed_and_slug(42, "test-site"); + let mut rng = SimRng::new(42); + + // Only 2 NPCs but triangle needs 3 roles + spawn_template_npc(&mut world, tid, "bartender", 1); + spawn_template_npc(&mut world, tid, "bouncer", 2); + + let defs = vec![ + TriangleDef { + triangle_id: TriangleId(100), + roles: [ + RoleId::new("bartender"), + RoleId::new("bouncer"), + RoleId::new("missing_role"), // no NPC has this role + ], + conflict_type: ConflictType::SecretExposure, + interest_axes: [NpcAxis::Secret, NpcAxis::Secret, NpcAxis::Secret], + relationship_constraints: vec![], + }, + TriangleDef { + triangle_id: TriangleId(101), + roles: [ + RoleId::new("bartender"), + RoleId::new("bouncer"), + RoleId::new("also_missing"), + ], + conflict_type: ConflictType::LatentTension, + interest_axes: [NpcAxis::Contentment, NpcAxis::Contentment, NpcAxis::Tolerance], + relationship_constraints: vec![], + }, + ]; + + let result = generate_intra_template_triangles(&mut world, tid, &defs, &mut rng); + + // First triangle: bartender(1) + bouncer(2) assigned, missing_role gets fallback + // But all NPCs are already used, so it should skip + // Actually: bartender(1) assigned, bouncer(2) assigned, missing_role needs fallback + // from remaining NPCs not already in this triangle's assignments. + // Both 1 and 2 are taken, so no fallback available → skip. + // Wait, let me re-check the logic... the fallback finds any NPC not already assigned + // to THIS triangle's role_assignments. After bartender(1) and bouncer(2), + // the remaining NPCs that aren't in role_assignments are... none (only 2 NPCs total). + // So this triangle gets skipped. + + // Actually with only 2 NPCs, both are already assigned before we need a 3rd. + // The triangle should be skipped with a warning. + assert!(!result.warnings.is_empty(), "should have warnings about missing roles"); + } + + #[test] + fn generate_triangles_fewer_than_2_defs_logs_error() { + // This test just verifies it doesn't panic — the error is logged. + let mut world = bevy_ecs::world::World::new(); + let tid = TemplateId::from_seed_and_slug(42, "test-site"); + let mut rng = SimRng::new(42); + + spawn_template_npc(&mut world, tid, "bartender", 1); + spawn_template_npc(&mut world, tid, "bouncer", 2); + spawn_template_npc(&mut world, tid, "regular", 3); + + let defs = vec![TriangleDef { + triangle_id: TriangleId(200), + roles: [ + RoleId::new("bartender"), + RoleId::new("bouncer"), + RoleId::new("regular"), + ], + conflict_type: ConflictType::ResourceCompetition, + interest_axes: [NpcAxis::Want, NpcAxis::Want, NpcAxis::Want], + relationship_constraints: vec![], + }]; + + // Should succeed with 1 triangle but log an error about < 2 + let result = generate_intra_template_triangles(&mut world, tid, &defs, &mut rng); + assert_eq!(result.triangles.len(), 1); + } + + #[test] + fn generate_triangles_deterministic() { + let tid = TemplateId::from_seed_and_slug(42, "test-site"); + + let defs = vec![ + TriangleDef { + triangle_id: TriangleId(300), + roles: [ + RoleId::new("a"), + RoleId::new("b"), + RoleId::new("c"), + ], + conflict_type: ConflictType::LoyaltyConflict, + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], + relationship_constraints: vec![], + }, + TriangleDef { + triangle_id: TriangleId(301), + roles: [ + RoleId::new("a"), + RoleId::new("c"), + RoleId::new("d"), + ], + conflict_type: ConflictType::LatentTension, + interest_axes: [NpcAxis::Contentment, NpcAxis::Contentment, NpcAxis::Tolerance], + relationship_constraints: vec![], + }, + ]; + + // Run twice with same seed + let mut world1 = bevy_ecs::world::World::new(); + spawn_template_npc(&mut world1, tid, "a", 1); + spawn_template_npc(&mut world1, tid, "b", 2); + spawn_template_npc(&mut world1, tid, "c", 3); + spawn_template_npc(&mut world1, tid, "d", 4); + let mut rng1 = SimRng::new(99); + let result1 = generate_intra_template_triangles(&mut world1, tid, &defs, &mut rng1); + + let mut world2 = bevy_ecs::world::World::new(); + spawn_template_npc(&mut world2, tid, "a", 1); + spawn_template_npc(&mut world2, tid, "b", 2); + spawn_template_npc(&mut world2, tid, "c", 3); + spawn_template_npc(&mut world2, tid, "d", 4); + let mut rng2 = SimRng::new(99); + let result2 = generate_intra_template_triangles(&mut world2, tid, &defs, &mut rng2); + + assert_eq!(result1.triangles.len(), result2.triangles.len()); + for (t1, t2) in result1.triangles.iter().zip(result2.triangles.iter()) { + assert_eq!(t1.tension, t2.tension, "tension must be deterministic"); + assert_eq!(t1.tension_rate, t2.tension_rate, "tension_rate must be deterministic"); + assert_eq!(t1.role_assignments, t2.role_assignments); + } + } + + #[test] + fn triangle_state_serialize_roundtrip() { + let state = TriangleState { + triangle_id: TriangleId(42), + role_assignments: { + let mut m = BTreeMap::new(); + m.insert(RoleId::new("a"), StableId(1)); + m.insert(RoleId::new("b"), StableId(2)); + m.insert(RoleId::new("c"), StableId(3)); + m + }, + tension: 15, + phase: TrianglePhase::Simmering, + tension_rate: 3, + template_id: TemplateId(100), + }; + + let bytes = rmp_serde::to_vec_named(&state).expect("serialize"); + let recovered: TriangleState = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!(state, recovered); + } + + // ----------------------------------------------------------------------- + // #250 — Triangle escalation system tests + // ----------------------------------------------------------------------- + + use crate::npc::Npc; + use crate::simulation::time::SimulationTime; + use bevy_ecs::schedule::Schedule; + + /// Helper: set up a world with all resources needed for escalation tests. + fn setup_escalation_world() -> bevy_ecs::world::World { + let mut world = bevy_ecs::world::World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + 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. + // Only active_triangle_continues_incrementing is unique here. + + #[test] + fn active_triangle_continues_incrementing() { + let mut world = setup_escalation_world(); + + let triangle = world + .spawn(( + TriangleState { + triangle_id: TriangleId(100), + role_assignments: BTreeMap::new(), + tension: 50, + phase: TrianglePhase::Active, + tension_rate: 5, + template_id: TemplateId(1), + }, + ActiveSim, + )) + .id(); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + + // Run at tick 10 and 20 + world.resource_mut::().tick = 10; + schedule.run(&mut world); + world.resource_mut::().tick = 20; + schedule.run(&mut world); + + let state = world.get::(triangle).unwrap(); + assert_eq!(state.tension, 60, "Active triangle should gain +5 per game-minute × 2"); + assert_eq!( + state.phase, + TrianglePhase::Active, + "Active should remain Active" + ); + + // No crisis event for already-Active triangles + let queue = world.resource::(); + assert!( + queue.is_empty(), + "no crisis event for triangles already in Active phase" + ); + } + + #[test] + fn tension_saturates_at_255() { + let mut world = setup_escalation_world(); + world.resource_mut::().tick = 10; + + let triangle = world + .spawn(( + TriangleState { + triangle_id: TriangleId(100), + role_assignments: BTreeMap::new(), + tension: 254, + phase: TrianglePhase::Active, + tension_rate: 5, + template_id: TemplateId(1), + }, + ActiveSim, + )) + .id(); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + schedule.run(&mut world); + + let state = world.get::(triangle).unwrap(); + assert_eq!(state.tension, 255, "tension should saturate at u8::MAX (255)"); + } + + #[test] + fn resolve_only_targets_matching_triangle() { + let mut world = bevy_ecs::world::World::new(); + world.init_resource::(); + + let target = world + .spawn(TriangleState { + triangle_id: TriangleId(100), + role_assignments: BTreeMap::new(), + tension: 50, + phase: TrianglePhase::Active, + tension_rate: 3, + template_id: TemplateId(1), + }) + .id(); + + let bystander = world + .spawn(TriangleState { + triangle_id: TriangleId(200), + role_assignments: BTreeMap::new(), + tension: 30, + phase: TrianglePhase::Simmering, + tension_rate: 2, + template_id: TemplateId(1), + }) + .id(); + + // Resolve only triangle 100 + world + .resource_mut::() + .push(ResolveTriangleCommand(TriangleId(100))); + + let mut schedule = Schedule::default(); + schedule.add_systems(apply_resolve_triangle); + schedule.run(&mut world); + + assert_eq!( + world.get::(target).unwrap().phase, + TrianglePhase::Resolved, + "targeted triangle should be Resolved" + ); + assert_eq!( + world.get::(bystander).unwrap().phase, + TrianglePhase::Simmering, + "D-089: non-targeted triangle must not be affected (no cascade)" + ); + } +} diff --git a/server/src/knowledge/types.rs b/server/src/knowledge/types.rs index 76bed1018..ef1e72e3d 100644 --- a/server/src/knowledge/types.rs +++ b/server/src/knowledge/types.rs @@ -18,6 +18,12 @@ use crate::simulation::movement::TilePosition; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct StableId(pub u64); +impl From for u64 { + fn from(id: StableId) -> Self { + id.0 + } +} + /// Typed fact identifier for non-entity knowledge. /// Format: "category.topic" (e.g., "contraband.ring_exists"). /// Lexicographic ordering in BTreeMap provides deterministic iteration. diff --git a/server/src/perception/interpretation.rs b/server/src/perception/interpretation.rs index 8ea8ef2f0..cdbd52a28 100644 --- a/server/src/perception/interpretation.rs +++ b/server/src/perception/interpretation.rs @@ -213,6 +213,7 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world } diff --git a/server/src/perception/observation.rs b/server/src/perception/observation.rs index fcfc776bb..e00c62262 100644 --- a/server/src/perception/observation.rs +++ b/server/src/perception/observation.rs @@ -168,6 +168,7 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world } diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index 32b552515..f1b3de548 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -27,6 +27,7 @@ use crate::simulation::monologue::{MonologueBuffer, SprintAnomalyQueue}; use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; use crate::simulation::poi::PointOfInterest; use crate::simulation::rng::SimRng; +use crate::content::template::TriangleCrisisEventQueue; use crate::simulation::sound::SoundEventQueue; use crate::simulation::stance::Stance; use crate::simulation::time::SimulationTime; @@ -99,6 +100,7 @@ pub fn compute_observer_snapshot( inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, poi_query: Query<&PointOfInterest>, mut buffer: ResMut, + mut crisis_queue: ResMut, sim_rng: Option>, pressure_query: Query<&crate::simulation::pressure::CharacterPressure, With>, ) { @@ -387,6 +389,13 @@ pub fn compute_observer_snapshot( // Consume pending save/load result for this tick (#553). 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() + .into_iter() + .map(TriangleCrisisEventWire::from) + .collect(); + buffer.snapshot = Some(ObserverSnapshot { version: crate::bridge::types::PROTOCOL_VERSION, tick: time.tick, @@ -414,6 +423,7 @@ pub fn compute_observer_snapshot( examine_result, player_knowledge, save_result, + triangle_crisis_events, }); } diff --git a/server/src/perception/observer/tests.rs b/server/src/perception/observer/tests.rs index 9dae79a8c..51ac44fc4 100644 --- a/server/src/perception/observer/tests.rs +++ b/server/src/perception/observer/tests.rs @@ -16,6 +16,7 @@ fn setup_world(width: i32, height: i32) -> World { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world } diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index de0f3436b..a0f37f2f7 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -51,6 +51,9 @@ impl Plugin for SimulationPlugin { .init_resource::() .init_resource::() .init_resource::() + // Triangle escalation resources (#250) + .init_resource::() + .init_resource::() // discover_pois reads VisibilityGeometry (also populated by PerceptionPlugin). // Init here so SimulationPlugin works standalone in tests without PerceptionPlugin. .init_resource::() @@ -96,6 +99,14 @@ impl Plugin for SimulationPlugin { pressure::update_character_pressure .after(crate::npc::awareness::detect_player_awareness) .before(crate::perception::observer::compute_observer_snapshot), + // Triangle escalation (#250) — runs on game-minute boundaries (every 10 ticks) + crate::content::template::tick_triangle_escalation + .after(crate::npc::tolerance::check_tolerance_threshold) + .before(crate::perception::observer::compute_observer_snapshot), + // Triangle resolution (#250, D-089) — apply player resolve commands + crate::content::template::apply_resolve_triangle + .after(input::process_player_input) + .before(crate::perception::observer::compute_observer_snapshot), time::advance_tick.after(path_follow::cleanup_path_blocked), ), ); diff --git a/server/src/simulation/save_io.rs b/server/src/simulation/save_io.rs index 47b155b57..895f4ac75 100644 --- a/server/src/simulation/save_io.rs +++ b/server/src/simulation/save_io.rs @@ -16,6 +16,7 @@ use thiserror::Error; use crate::bridge::types::SaveLoadResultWire; use crate::bridge::types::SnapshotBuffer; +use crate::content::template::{TemplateReferenceMap, TriangleState}; use crate::knowledge::graph::KnowledgeGraph; use crate::knowledge::registry::EntityRegistry; use crate::npc::Npc; @@ -105,6 +106,20 @@ pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError> npc_states.sort_by_key(|s| s.stable_id.0); let npc_count = npc_states.len(); + + // Capture TemplateReferenceMap if present — default to empty if not yet initialised. + let template_references = world + .get_resource::() + .cloned() + .unwrap_or_default(); + + // Capture TriangleState components — sorted by triangle_id for determinism (D-010). + let mut triangle_states: Vec = { + let mut q = world.query::<&TriangleState>(); + q.iter(world).cloned().collect() + }; + triangle_states.sort_by_key(|t| t.triangle_id.0); + let state = SaveStateV1 { format_version: SAVE_FORMAT_VERSION, tick, @@ -113,6 +128,8 @@ pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError> player_knowledge, relationship_graph, npc_states, + template_references, + triangle_states, }; let bytes = state @@ -200,6 +217,12 @@ pub fn load_from_file(path: &Path, world: &mut World) -> Result<(), SaveLoadErro // Restore simulation resources. world.insert_resource(state.relationship_graph); + world.insert_resource(state.template_references); + + // Restore triangle states (#250) — spawn dedicated entities for each. + for ts in &state.triangle_states { + world.spawn(ts.clone()); + } { let mut t = world.resource_mut::(); t.tick = state.tick; @@ -487,6 +510,7 @@ mod tests { #[test] fn load_from_file_rejects_wrong_format_version() { + use crate::content::template::TemplateReferenceMap; // Craft a save with a wrong format_version let bad_state = SaveStateV1 { format_version: 0xFF, // deliberately wrong @@ -496,6 +520,8 @@ mod tests { player_knowledge: KnowledgeGraph::new(), relationship_graph: RelationshipGraph::new(), npc_states: vec![], + template_references: TemplateReferenceMap::default(), + triangle_states: 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 c9d8a2936..f6c24ef9c 100644 --- a/server/src/simulation/save_state.rs +++ b/server/src/simulation/save_state.rs @@ -39,6 +39,7 @@ use bevy_ecs::entity::Entity; use bevy_ecs::world::World; use serde::{Deserialize, Serialize}; +use crate::content::template::{TemplateOwnership, TemplateReferenceMap, TriangleState}; use crate::knowledge::graph::KnowledgeGraph; use crate::knowledge::registry::StableEntityId; use crate::knowledge::types::StableId; @@ -83,6 +84,16 @@ pub struct SaveStateV1 { /// Per-NPC summary state for each simulated NPC. /// Order is deterministic (sorted by stable_id in ascending order). pub npc_states: Vec, + /// Cross-template reference links (#165). + /// Preserved across save/load so that tier-evicted templates retain their + /// relationship metadata even when their NPCs are not in Active tier. + #[serde(default)] + pub template_references: TemplateReferenceMap, + /// Triangle escalation states (#250). + /// Persisted so tension/phase survive save/load. Sorted by triangle_id + /// for deterministic serialization (D-010). + #[serde(default)] + pub triangle_states: Vec, } /// Per-NPC state snapshot for `SaveStateV1`. @@ -184,6 +195,12 @@ pub struct NpcSaveState { /// Job performance score — drifts over time, persist across tier transitions. #[serde(default)] pub job_performance: Option, + + /// Template ownership (#165): which template owns this NPC and which role it fills. + /// `None` for NPCs that predate the template system or were hand-authored without + /// template assignment. Preserved across tier transitions (D-025 single-ownership). + #[serde(default)] + pub template_ownership: Option, } impl SaveStateV1 { @@ -258,6 +275,7 @@ pub fn serialize_npc_to_frozen(entity: Entity, world: &World) -> NpcSaveState { combat_capability: world.get::(entity).cloned(), mood_state: world.get::(entity).cloned(), job_performance: world.get::(entity).cloned(), + template_ownership: world.get::(entity).cloned(), } } @@ -353,6 +371,10 @@ pub fn deserialize_npc_from_frozen(state: &NpcSaveState, world: &mut World) -> E if let Some(combat) = state.combat_capability.clone() { em.insert(combat); } + // Restore template ownership if present — never reassigned after initial spawn (D-025). + if let Some(ownership) = state.template_ownership.clone() { + em.insert(ownership); + } } entity @@ -381,6 +403,8 @@ mod tests { player_knowledge: KnowledgeGraph::new(), relationship_graph: RelationshipGraph::new(), npc_states: vec![], + template_references: TemplateReferenceMap::default(), + triangle_states: vec![], } } @@ -449,6 +473,7 @@ mod tests { combat_capability: None, mood_state: None, job_performance: None, + template_ownership: None, }, NpcSaveState { stable_id: StableId(202), @@ -469,6 +494,7 @@ mod tests { combat_capability: None, mood_state: None, job_performance: None, + template_ownership: None, }, ]; @@ -577,6 +603,7 @@ mod tests { combat_capability: None, mood_state: None, job_performance: None, + template_ownership: None, }]; let bytes = state.to_bytes().expect("serialize"); @@ -734,6 +761,7 @@ mod tests { combat_capability: None, mood_state: None, job_performance: None, + template_ownership: None, }; let mut world = World::new(); @@ -771,6 +799,8 @@ mod tests { player_knowledge: KnowledgeGraph::new(), relationship_graph: RelationshipGraph::new(), npc_states: vec![frozen], + template_references: TemplateReferenceMap::default(), + triangle_states: vec![], }; let bytes = save.to_bytes().expect("serialize"); diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index c84a98273..0c18df4bd 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -72,6 +72,7 @@ fn snapshot_roundtrip_over_unix_socket() { examine_result: None, player_knowledge: None, save_result: None, + triangle_crisis_events: vec![], }; bridge diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index f608c3ba7..c7095a7bf 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -58,6 +58,7 @@ fn snapshot_roundtrip_over_tcp() { examine_result: None, player_knowledge: None, save_result: None, + triangle_crisis_events: vec![], }; bridge diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 46c361ab3..3968faffc 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -48,6 +48,7 @@ fn fixture_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot examine_result: None, player_knowledge: None, save_result: None, + triangle_crisis_events: vec![], } } @@ -239,6 +240,7 @@ fn generate_msgpack_fixtures() { examine_result: None, player_knowledge: None, save_result: None, + triangle_crisis_events: vec![], }; write_fixture( "snapshot_v2_full", @@ -400,6 +402,7 @@ fn generate_msgpack_fixtures() { acquired_tick: 10, }], }), + triangle_crisis_events: 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 3b8bbad72..257998a14 100644 --- a/server/tests/golden/proof_room_tick_10.json +++ b/server/tests/golden/proof_room_tick_10.json @@ -73,7 +73,8 @@ "scan_events": [], "sound_events": [], "tick": 8, - "version": 15, + "triangle_crisis_events": [], + "version": 16, "visible_tiles": [ { "tile_kind": "Wall", diff --git a/server/tests/information_boundaries.rs b/server/tests/information_boundaries.rs index 1f6d11cb7..67e8f273b 100644 --- a/server/tests/information_boundaries.rs +++ b/server/tests/information_boundaries.rs @@ -169,6 +169,7 @@ fn save_state_npc_kg_isolation() { combat_capability: None, mood_state: None, job_performance: None, + template_ownership: None, }; // NPC_B (Background tier) does not carry a KG. @@ -191,6 +192,7 @@ fn save_state_npc_kg_isolation() { combat_capability: None, mood_state: None, job_performance: None, + template_ownership: None, }; let save = SaveStateV1 { @@ -201,6 +203,8 @@ fn save_state_npc_kg_isolation() { player_knowledge: KnowledgeGraph::new(), relationship_graph: RelationshipGraph::new(), npc_states: vec![npc_a_state, npc_b_state], + template_references: Default::default(), + triangle_states: vec![], }; // Roundtrip: serialize → deserialize. diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 7b6ec17fa..2589ae802 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -36,6 +36,7 @@ fn test_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { examine_result: None, player_knowledge: None, save_result: None, + triangle_crisis_events: vec![], } } @@ -294,6 +295,7 @@ fn snapshot_v2_fields_roundtrip() { examine_result: None, player_knowledge: None, save_result: None, + triangle_crisis_events: vec![], }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); @@ -348,7 +350,7 @@ fn protocol_version_constant_matches_snapshot() { let snapshot = test_snapshot(0, vec![]); assert_eq!(snapshot.version, PROTOCOL_VERSION); assert_eq!( - PROTOCOL_VERSION, 15, + PROTOCOL_VERSION, 16, "bump this assertion when protocol version changes" ); } @@ -398,6 +400,7 @@ fn all_facing_direction_variants_roundtrip() { examine_result: None, player_knowledge: None, save_result: None, + triangle_crisis_events: 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_schema.rs b/server/tests/template_schema.rs new file mode 100644 index 000000000..fa4b91552 --- /dev/null +++ b/server/tests/template_schema.rs @@ -0,0 +1,639 @@ +//! Integration tests for the template schema system (tickets #163, #164, #165, #106). +//! +//! 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-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, +}; +use settled_reach_server::npc::{PersonalityTrait, RelationshipKind, Skill}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn make_role_schema(id: &str) -> RoleSchema { + RoleSchema { + role_id: RoleId::new(id), + required_traits: vec![], + skill_focus: vec![], + relationship_constraints: vec![], + routine_template: vec![], + } +} + +fn make_triangle(roles: [&str; 3], conflict: ConflictType) -> TriangleDef { + let role_arr = [ + RoleId::new(roles[0]), + RoleId::new(roles[1]), + RoleId::new(roles[2]), + ]; + let triangle_id = TriangleId::from_seed_and_roles(42, &role_arr); + TriangleDef { + triangle_id, + roles: role_arr, + conflict_type: conflict, + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], + relationship_constraints: vec![], + } +} + +// --------------------------------------------------------------------------- +// #163: Role definition schema — YAML round-trips +// --------------------------------------------------------------------------- + +#[test] +fn role_schema_minimal_yaml_parse() { + let yaml = r#" +role_id: "guard" +skill_focus: + - Combat + - Observation +"#; + let schema: RoleSchema = serde_yaml::from_str(yaml).expect("minimal schema must parse"); + assert_eq!(schema.role_id, RoleId::new("guard")); + assert_eq!(schema.skill_focus.len(), 2); + assert!(schema.required_traits.is_empty()); + assert!(schema.relationship_constraints.is_empty()); + assert!(schema.routine_template.is_empty()); +} + +#[test] +fn role_schema_full_yaml_parse() { + let yaml = r#" +role_id: "dock-worker" +required_traits: + - Cautious + - Honest +skill_focus: + - Technical + - Observation +relationship_constraints: + - with_role: "ring-contact" + kind: Colleague + required_trust: + min: -2 + max: 2 +routine_template: + - phase: "morning" + location: "terminal-cargo-bay" + activity: "freight-handling" + - phase: "evening" + location: "bar-last-shift" +"#; + let schema: RoleSchema = serde_yaml::from_str(yaml).expect("full schema must parse"); + assert_eq!(schema.role_id, RoleId::new("dock-worker")); + assert_eq!(schema.required_traits.len(), 2); + assert_eq!(schema.required_traits[0], PersonalityTrait::Cautious); + assert_eq!(schema.skill_focus.len(), 2); + assert_eq!(schema.relationship_constraints.len(), 1); + assert_eq!( + schema.relationship_constraints[0].with_role, + RoleId::new("ring-contact") + ); + assert_eq!(schema.relationship_constraints[0].required_trust.min, -2); + assert_eq!(schema.relationship_constraints[0].required_trust.max, 2); + assert_eq!(schema.routine_template.len(), 2); + assert_eq!(schema.routine_template[0].phase, "morning"); + assert_eq!(schema.routine_template[0].activity, Some("freight-handling".to_string())); + assert_eq!(schema.routine_template[1].activity, None); +} + +#[test] +fn role_schema_yaml_roundtrip_preserves_all_fields() { + let schema = RoleSchema { + role_id: RoleId::new("ring-contact"), + required_traits: vec![PersonalityTrait::Deceptive, PersonalityTrait::Social], + skill_focus: vec![Skill::Stealth, Skill::Persuasion], + relationship_constraints: vec![ + RelationshipConstraint { + with_role: RoleId::new("dock-worker"), + kind: RelationshipKind::Colleague, + required_trust: TrustRange { min: 0, max: 4 }, + }, + RelationshipConstraint { + with_role: RoleId::new("ring-leader"), + kind: RelationshipKind::Superior, + required_trust: TrustRange { min: 1, max: 4 }, + }, + ], + routine_template: vec![ + TemplateRoutineEntry { + phase: "morning".into(), + location: "terminal-cargo-bay".into(), + activity: Some("oversight".into()), + }, + TemplateRoutineEntry { + phase: "evening".into(), + location: "maintenance-corridor".into(), + activity: None, + }, + ], + }; + + let yaml = serde_yaml::to_string(&schema).expect("serialize"); + let restored: RoleSchema = serde_yaml::from_str(&yaml).expect("deserialize"); + + assert_eq!(restored.role_id, schema.role_id); + assert_eq!(restored.required_traits, schema.required_traits); + assert_eq!(restored.skill_focus, schema.skill_focus); + assert_eq!( + restored.relationship_constraints.len(), + schema.relationship_constraints.len() + ); + assert_eq!( + restored.relationship_constraints[0].required_trust, + schema.relationship_constraints[0].required_trust + ); + assert_eq!(restored.routine_template.len(), schema.routine_template.len()); + assert_eq!( + restored.routine_template[0].activity, + schema.routine_template[0].activity + ); +} + +// --------------------------------------------------------------------------- +// #163: Role definition schema — validation +// --------------------------------------------------------------------------- + +#[test] +fn self_referential_constraint_rejected() { + let schema = RoleSchema { + role_id: RoleId::new("dock-worker"), + required_traits: vec![], + skill_focus: vec![], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("dock-worker"), // same as role_id + kind: RelationshipKind::Colleague, + required_trust: TrustRange { min: 0, max: 4 }, + }], + routine_template: vec![], + }; + let result = schema.validate(); + assert!(result.is_err(), "self-referential constraint must be rejected"); + assert!( + result.unwrap_err().contains("self-referential"), + "error must mention self-referential" + ); +} + +#[test] +fn invalid_trust_range_rejected() { + let schema = RoleSchema { + role_id: RoleId::new("guard"), + required_traits: vec![], + skill_focus: vec![], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("captain"), + kind: RelationshipKind::Superior, + required_trust: TrustRange { min: 3, max: 1 }, // invalid: min > max + }], + routine_template: vec![], + }; + let result = schema.validate(); + assert!(result.is_err(), "TrustRange min > max must be rejected"); + assert!( + result.unwrap_err().contains("trust min"), + "error must mention trust min" + ); +} + +#[test] +fn collection_with_duplicate_role_ids_rejected() { + let schemas = vec![ + make_role_schema("dock-worker"), + make_role_schema("ring-contact"), + make_role_schema("dock-worker"), // duplicate + ]; + let result = validate_role_schemas_no_duplicate_ids(&schemas); + assert!(result.is_err(), "duplicate role_ids must be rejected"); + let msg = result.unwrap_err(); + assert!(msg.contains("dock-worker"), "error must name the duplicate: {}", msg); +} + +#[test] +fn collection_with_unique_role_ids_ok() { + let schemas = vec![ + make_role_schema("dock-worker"), + make_role_schema("ring-contact"), + make_role_schema("logistics-manager"), + ]; + assert!(validate_role_schemas_no_duplicate_ids(&schemas).is_ok()); +} + +// --------------------------------------------------------------------------- +// #164: Spatial requirement specification — YAML round-trips +// --------------------------------------------------------------------------- + +#[test] +fn space_spec_minimal_yaml_parse() { + let yaml = r#" +tile_count_min: 30 +tile_count_max: 80 +privacy_level: Public +traffic_pattern: Thoroughfare +"#; + let spec: SpaceSpec = serde_yaml::from_str(yaml).expect("minimal SpaceSpec must parse"); + assert_eq!(spec.tile_count_min, 30); + assert_eq!(spec.tile_count_max, 80); + assert_eq!(spec.privacy_level, PrivacyLevel::Public); + assert_eq!(spec.traffic_pattern, TrafficPattern::Thoroughfare); + assert!(spec.sightline_zones.is_empty()); +} + +#[test] +fn space_spec_full_yaml_parse() { + let yaml = r#" +tile_count_min: 30 +tile_count_max: 80 +sightline_zones: + - name: "bar-counter" + radius: 4 + - name: "corner-booth" + radius: 2 +privacy_level: SemiPrivate +traffic_pattern: Destination +"#; + let spec: SpaceSpec = serde_yaml::from_str(yaml).expect("full SpaceSpec must parse"); + assert_eq!(spec.sightline_zones.len(), 2); + assert_eq!(spec.sightline_zones[0].name, "bar-counter"); + assert_eq!(spec.sightline_zones[0].radius, 4); + assert_eq!(spec.sightline_zones[1].name, "corner-booth"); + assert_eq!(spec.sightline_zones[1].radius, 2); + assert_eq!(spec.privacy_level, PrivacyLevel::SemiPrivate); + assert_eq!(spec.traffic_pattern, TrafficPattern::Destination); +} + +/// D-025 scale assertion: 15-40 visual tiles = 30-80 sim tiles (D-066). +#[test] +fn space_spec_d025_tile_count_range() { + let spec = SpaceSpec { + tile_count_min: 30, + tile_count_max: 80, + sightline_zones: vec![], + privacy_level: PrivacyLevel::Public, + traffic_pattern: TrafficPattern::Destination, + }; + assert!(spec.validate().is_ok(), "D-025 tile range (30-80 sim) must be valid"); +} + +#[test] +fn space_spec_validation_min_gt_max_fails() { + let spec = SpaceSpec { + tile_count_min: 100, + tile_count_max: 50, + sightline_zones: vec![], + privacy_level: PrivacyLevel::Private, + traffic_pattern: TrafficPattern::Restricted, + }; + let result = spec.validate(); + assert!(result.is_err(), "min > max must fail validation"); + let msg = result.unwrap_err(); + assert!(msg.contains("tile_count_min"), "error must mention tile_count_min: {}", msg); +} + +#[test] +fn all_privacy_levels_yaml_roundtrip() { + for level in &[PrivacyLevel::Public, PrivacyLevel::SemiPrivate, PrivacyLevel::Private] { + let yaml = serde_yaml::to_string(level).unwrap(); + let decoded: PrivacyLevel = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(level, &decoded, "{:?} must survive YAML round-trip", level); + } +} + +#[test] +fn all_traffic_patterns_yaml_roundtrip() { + for pattern in &[ + TrafficPattern::Thoroughfare, + TrafficPattern::Destination, + TrafficPattern::Restricted, + ] { + let yaml = serde_yaml::to_string(pattern).unwrap(); + let decoded: TrafficPattern = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(pattern, &decoded, "{:?} must survive YAML round-trip", pattern); + } +} + +// --------------------------------------------------------------------------- +// #165: Single-ownership model — TemplateId determinism +// --------------------------------------------------------------------------- + +#[test] +fn template_id_fnv1a_stable_across_calls() { + let id = TemplateId::from_seed_and_slug(0, ""); + assert_eq!( + id, + TemplateId::from_seed_and_slug(0, ""), + "empty slug + seed 0 must be stable" + ); + + let id2 = TemplateId::from_seed_and_slug(42, "the-terminal"); + assert_eq!( + id2, + TemplateId::from_seed_and_slug(42, "the-terminal"), + "non-empty slug must be stable" + ); +} + +#[test] +fn template_ownership_component_single_owner_invariant() { + // D-025: NPCs are owned by exactly one template, never reassigned. + let seed = 1u64; + let tid = TemplateId::from_seed_and_slug(seed, "terminal"); + let rid = RoleId::new("dock-worker"); + + let ownership = TemplateOwnership { template_id: tid, role_id: rid.clone() }; + assert_eq!(ownership.template_id, tid); + assert_eq!(ownership.role_id, rid); + + // Clone (as would happen in save-state) must preserve values. + let cloned = ownership.clone(); + assert_eq!(cloned.template_id, ownership.template_id); + assert_eq!(cloned.role_id, ownership.role_id); +} + +#[test] +fn template_reference_map_preserves_links_on_unload() { + // D-025: reference links must be preserved when a template is unloaded. + let mut map = TemplateReferenceMap::default(); + let tid_a = TemplateId::from_seed_and_slug(1, "template-a"); + let tid_b = TemplateId::from_seed_and_slug(1, "template-b"); + + map.add(TemplateReference { + from_template: tid_a, + to_template: tid_b, + via_role: RoleId::new("ring-contact"), + relationship_metadata: RelationshipKind::Colleague, + }); + + // Simulate "unload template-a" by cloning (the save path). + let preserved = map.clone(); + assert_eq!(preserved.outgoing(tid_a).len(), 1); + assert_eq!(preserved.outgoing(tid_a)[0].to_template, tid_b); +} + +#[test] +fn template_reference_map_btreemap_deterministic_ordering() { + // D-010: BTreeMap ensures deterministic iteration order. + let mut map = TemplateReferenceMap::default(); + + let tid_high = TemplateId(u64::MAX - 1); + let tid_low = TemplateId(1); + + map.add(TemplateReference { + from_template: tid_high, + to_template: tid_low, + via_role: RoleId::new("role-a"), + relationship_metadata: RelationshipKind::Colleague, + }); + map.add(TemplateReference { + from_template: tid_low, + to_template: tid_high, + via_role: RoleId::new("role-b"), + relationship_metadata: RelationshipKind::Colleague, + }); + + // Collect all references via all_references() (deterministic BTreeMap order). + let all: Vec<&TemplateReference> = map.all_references().collect(); + assert_eq!(all.len(), 2); + // First entry's from_template must be the lower ID (BTreeMap key order). + assert!( + all[0].from_template <= all[1].from_template, + "BTreeMap must iterate in ascending key order" + ); +} + +// --------------------------------------------------------------------------- +// #106: Triangle definition schema — YAML round-trips +// --------------------------------------------------------------------------- + +#[test] +fn triangle_def_yaml_parse_with_computed_id() { + // TriangleId is stored in YAML but computed at world-gen time. + // Authors use 0 as placeholder; runtime overwrites with computed value. + let yaml = r#" +triangle_id: 0 +roles: + - "ring-smuggler" + - "dock-worker" + - "operations-manager" +conflict_type: ResourceCompetition +interest_axes: + - Want + - Secret + - Relationships +"#; + let def: TriangleDef = serde_yaml::from_str(yaml).expect("TriangleDef must parse from YAML"); + assert_eq!(def.triangle_id, TriangleId(0)); + assert_eq!(def.roles[0], RoleId::new("ring-smuggler")); + assert_eq!(def.conflict_type, ConflictType::ResourceCompetition); + assert!(def.relationship_constraints.is_empty()); +} + +#[test] +fn triangle_def_yaml_parse_with_constraints() { + let yaml = r#" +triangle_id: 0 +roles: + - "ring-leader" + - "dock-worker" + - "logistics-manager" +conflict_type: LoyaltyConflict +interest_axes: + - Relationships + - Secret + - Tolerance +relationship_constraints: + - with_role: "dock-worker" + kind: Subordinate + required_trust: + min: -2 + max: 2 +"#; + let def: TriangleDef = + serde_yaml::from_str(yaml).expect("TriangleDef with constraints must parse"); + assert_eq!(def.conflict_type, ConflictType::LoyaltyConflict); + assert_eq!(def.relationship_constraints.len(), 1); + assert_eq!(def.relationship_constraints[0].with_role, RoleId::new("dock-worker")); + assert_eq!(def.relationship_constraints[0].kind, RelationshipKind::Subordinate); +} + +#[test] +fn triangle_def_all_conflict_types_yaml_roundtrip() { + let conflict_types = [ + ConflictType::ResourceCompetition, + ConflictType::LoyaltyConflict, + ConflictType::SecretExposure, + ConflictType::AuthorityChallenge, + ConflictType::LatentTension, + ]; + for ct in &conflict_types { + let yaml = serde_yaml::to_string(ct).unwrap(); + let decoded: ConflictType = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(ct, &decoded, "{:?} must round-trip", ct); + } +} + +#[test] +fn triangle_def_all_npc_axes_yaml_roundtrip() { + let axes = [ + NpcAxis::Want, + NpcAxis::Secret, + NpcAxis::Relationships, + NpcAxis::Tolerance, + NpcAxis::Routine, + NpcAxis::InformationInventory, + NpcAxis::Contentment, + NpcAxis::PersonalityTraits, + NpcAxis::TellSystem, + NpcAxis::SkillSet, + ]; + for axis in &axes { + let yaml = serde_yaml::to_string(axis).unwrap(); + let decoded: NpcAxis = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(axis, &decoded, "{:?} must round-trip", axis); + } +} + +/// D-087: T1-T5 triangle configuration must be expressible in the schema. +#[test] +fn d087_v01_triangle_configurations_expressible() { + // T1: Kael-Smuggler-Ring (ResourceCompetition, active fork) + let t1 = make_triangle( + ["kael-davan", "smuggler", "ring-contact"], + ConflictType::ResourceCompetition, + ); + assert!(t1.validate().is_ok(), "T1 must be valid: {:?}", t1.validate()); + + // T2: Sera-Detective-Commission (SecretExposure, active fork) + let t2 = make_triangle( + ["sera-venn", "detective", "commission-inspector"], + ConflictType::SecretExposure, + ); + assert!(t2.validate().is_ok(), "T2 must be valid: {:?}", t2.validate()); + + // T4: Drin-System-Ring (ResourceCompetition, active fork per D-087) + let t4 = make_triangle( + ["drin", "ring-system", "dock-supervisor"], + ConflictType::ResourceCompetition, + ); + assert!(t4.validate().is_ok(), "T4 must be valid: {:?}", t4.validate()); + + // T3: passive tension (LatentTension variant per D-087) + let t3 = make_triangle(["naia", "kael-davan", "hael"], ConflictType::LatentTension); + assert!(t3.validate().is_ok(), "T3 passive tension must be valid: {:?}", t3.validate()); + + // T5: background worried partner (LatentTension variant) + let t5 = make_triangle( + ["worried-partner", "ring-member", "neighbor"], + ConflictType::LatentTension, + ); + assert!(t5.validate().is_ok(), "T5 passive tension must be valid: {:?}", t5.validate()); +} + +/// D-089: TriangleDef must not contain cross-triangle cascade state. +#[test] +fn d089_no_cross_triangle_cascade_fields() { + let def = make_triangle(["role-a", "role-b", "role-c"], ConflictType::ResourceCompetition); + let yaml = serde_yaml::to_string(&def).expect("serialize"); + assert!(!yaml.contains("cascade"), "no cascade field should appear in serialized TriangleDef"); + assert!(!yaml.contains("cross_triangle"), "no cross_triangle field should appear"); + assert!(!yaml.contains("triggers"), "no triggers field should appear"); +} + +// --------------------------------------------------------------------------- +// #165: ECS integration — spawn two templates with cross-references +// --------------------------------------------------------------------------- + +#[test] +fn ecs_two_templates_with_cross_references_and_ownerships() { + use bevy_ecs::world::World; + + let seed = 999u64; + let tid_terminal = TemplateId::from_seed_and_slug(seed, "terminal-social-site"); + let tid_bar = TemplateId::from_seed_and_slug(seed, "last-shift-bar"); + + let mut world = World::new(); + world.init_resource::(); + + // Spawn 3 NPCs: 2 in terminal, 1 in bar. + let npc_logistics = world + .spawn(TemplateOwnership { + template_id: tid_terminal, + role_id: RoleId::new("logistics-manager"), + }) + .id(); + let npc_dock = world + .spawn(TemplateOwnership { + template_id: tid_terminal, + role_id: RoleId::new("dock-worker"), + }) + .id(); + let npc_bar_regular = world + .spawn(TemplateOwnership { + template_id: tid_bar, + role_id: RoleId::new("bar-regular"), + }) + .id(); + + // Add cross-template reference: dock-worker at terminal references bar-regular at bar. + { + let mut ref_map = world.resource_mut::(); + ref_map.add(TemplateReference { + from_template: tid_terminal, + to_template: tid_bar, + via_role: RoleId::new("dock-worker"), + relationship_metadata: RelationshipKind::Colleague, + }); + } + + // Verify all TemplateOwnership components are correct. + let own_logistics = world.get::(npc_logistics).unwrap(); + assert_eq!( + own_logistics.template_id, tid_terminal, + "logistics-manager must be owned by terminal" + ); + assert_eq!(own_logistics.role_id, RoleId::new("logistics-manager")); + + let own_dock = world.get::(npc_dock).unwrap(); + assert_eq!( + own_dock.template_id, tid_terminal, + "dock-worker must be owned by terminal" + ); + assert_eq!(own_dock.role_id, RoleId::new("dock-worker")); + + let own_bar = world.get::(npc_bar_regular).unwrap(); + assert_eq!(own_bar.template_id, tid_bar, "bar-regular must be owned by bar"); + + // Verify TemplateReferenceMap entries. + let ref_map = world.resource::(); + let terminal_refs = ref_map.outgoing(tid_terminal); + assert_eq!(terminal_refs.len(), 1, "terminal should have 1 cross-template reference"); + assert_eq!(terminal_refs[0].to_template, tid_bar); + assert_eq!(terminal_refs[0].via_role, RoleId::new("dock-worker")); + + // Bar template has no outgoing references. + assert!( + ref_map.outgoing(tid_bar).is_empty(), + "bar template has no outgoing references" + ); +} + +#[test] +fn template_ownership_survives_clone_for_save_state() { + // D-026: TemplateOwnership must be preserved when tier drops to State-saved. + let tid = TemplateId::from_seed_and_slug(42, "terminal"); + let rid = RoleId::new("dock-worker"); + let ownership = TemplateOwnership { template_id: tid, role_id: rid.clone() }; + let saved = ownership.clone(); + assert_eq!(saved, ownership, "TemplateOwnership must survive clone (save path)"); +} diff --git a/server/tests/triangle_escalation.rs b/server/tests/triangle_escalation.rs new file mode 100644 index 000000000..78007c317 --- /dev/null +++ b/server/tests/triangle_escalation.rs @@ -0,0 +1,613 @@ +//! Integration tests for the triangle escalation system (#250). +//! +//! Covers the public API from a black-box perspective: +//! - D-087: seed-dependent tension rates produce different 30-min arc timings +//! - D-089: resolution does not cascade (only targeted triangle changes) +//! - D-026: escalation only runs on Active-tier entities +//! - D-031: escalation runs once per game-minute (every 10 ticks) +//! +//! These tests complement the lib unit tests in `src/content/template.rs` +//! with integration-level coverage using the public crate API. + +use std::collections::BTreeMap; + +use bevy_ecs::{schedule::Schedule, world::World}; +use settled_reach_server::{ + content::template::{ + apply_resolve_triangle, tick_triangle_escalation, ResolveTriangleCommand, + ResolveTriangleQueue, TemplateId, TriangleCrisisEventQueue, TriangleDef, TriangleId, + TrianglePhase, TriangleState, + }, + knowledge::{registry::EntityRegistry, types::StableId, StableEntityId}, + npc::ToleranceThreshold, + simulation::{ + tier::ActiveSim, + time::SimulationTime, + }, +}; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +/// Minimal world with all resources required by `tick_triangle_escalation`. +fn make_escalation_world() -> World { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world +} + +/// Spawn an NPC with a known StableId and ToleranceThreshold. +fn spawn_npc_with_threshold(world: &mut World, stable_id_val: u64, threshold: i16) -> StableId { + let sid = StableId(stable_id_val); + let entity = world + .spawn((ActiveSim, StableEntityId(sid), ToleranceThreshold { current_stress: 0, threshold })) + .id(); + world.resource_mut::().register_existing(entity, sid); + sid +} + +/// Spawn a triangle entity with the given state (ActiveSim marker included). +fn spawn_triangle( + world: &mut World, + triangle_id: u64, + tension: u8, + tension_rate: u8, + phase: TrianglePhase, + role_assignments: BTreeMap, +) -> bevy_ecs::entity::Entity { + world + .spawn(( + ActiveSim, + TriangleState { + triangle_id: TriangleId(triangle_id), + role_assignments, + tension, + phase, + tension_rate, + template_id: TemplateId(1), + }, + )) + .id() +} + +/// Run the escalation schedule at a specific tick. +fn run_at_tick(world: &mut World, schedule: &mut Schedule, tick: u64) { + world.resource_mut::().tick = tick; + schedule.run(world); +} + +// --------------------------------------------------------------------------- +// #250: Escalation happy path +// --------------------------------------------------------------------------- + +/// D-031: escalation runs once per game-minute. 10 ticks = 1 game-minute. +/// Tension should only increment on multiples of 10. +#[test] +fn escalation_only_fires_on_game_minute_boundaries() { + let mut world = make_escalation_world(); + let entity = spawn_triangle( + &mut world, + 1, + 0, + 5, + TrianglePhase::Simmering, + BTreeMap::new(), + ); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + + // Ticks 1-9: not a game-minute, tension must not change. + for tick in 1..10 { + run_at_tick(&mut world, &mut schedule, tick); + } + assert_eq!( + world.get::(entity).unwrap().tension, + 0, + "tension must not change on sub-minute ticks" + ); + + // Tick 10: first game-minute, tension should increment. + run_at_tick(&mut world, &mut schedule, 10); + assert_eq!( + world.get::(entity).unwrap().tension, + 5, + "tension must increment at tick 10 (first game-minute)" + ); +} + +/// Simmering → Active transition at the expected game-minute. +/// +/// Known setup: +/// - tension_rate = 5, starting tension = 0 +/// - Lowest NPC threshold = 25 +/// - After 5 game-minutes (50 ticks): tension = 25, not > 25 → Simmering +/// - After 6 game-minutes (60 ticks): tension = 30, 30 > 25 → Active +#[test] +fn simmering_transitions_to_active_at_expected_minute() { + let mut world = make_escalation_world(); + + let npc_a = spawn_npc_with_threshold(&mut world, 1, 40); + let npc_b = spawn_npc_with_threshold(&mut world, 2, 25); // lowest + let npc_c = spawn_npc_with_threshold(&mut world, 3, 60); + + use settled_reach_server::content::template::RoleId; + let mut assignments = BTreeMap::new(); + assignments.insert(RoleId::new("role-a"), npc_a); + assignments.insert(RoleId::new("role-b"), npc_b); + assignments.insert(RoleId::new("role-c"), npc_c); + + let entity = spawn_triangle(&mut world, 42, 0, 5, TrianglePhase::Simmering, assignments); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + + // Run through 50 ticks (5 game-minutes): should remain Simmering. + for tick in 1..=50 { + run_at_tick(&mut world, &mut schedule, tick); + } + let state = world.get::(entity).unwrap(); + assert_eq!( + state.phase, + TrianglePhase::Simmering, + "after 5 game-minutes (tension=25), must still be Simmering (not > 25)" + ); + assert_eq!(state.tension, 25); + + // Run through tick 60 (6th game-minute): tension becomes 30, > 25 → Active. + for tick in 51..=60 { + run_at_tick(&mut world, &mut schedule, tick); + } + let state = world.get::(entity).unwrap(); + assert_eq!( + state.phase, + TrianglePhase::Active, + "at tick 60 (tension=30 > threshold=25), must transition to Active" + ); + assert_eq!(state.tension, 30); +} + +/// D-087: different seeds produce different escalation timings. +/// Verify that two triangles with different tension rates escalate at different times. +#[test] +fn d087_seed_dependent_escalation_timing() { + // Triangle A: slower escalation (rate 2) + // Triangle B: faster escalation (rate 8) + // Both share same NPC threshold (30). + // A triggers at: ceil(30 / 2) + 1 = 16th game-minute (tension hits 32 at minute 16) + // B triggers at: ceil(30 / 8) + 1 = 5th game-minute (tension hits 32 at minute 4) + + let mut world = make_escalation_world(); + + let npc = spawn_npc_with_threshold(&mut world, 1, 30); + + use settled_reach_server::content::template::RoleId; + let mut assignments = BTreeMap::new(); + assignments.insert(RoleId::new("r"), npc); + + // Spawn as separate triangles. + let slow = spawn_triangle(&mut world, 10, 0, 2, TrianglePhase::Simmering, assignments.clone()); + let fast = spawn_triangle(&mut world, 20, 0, 8, TrianglePhase::Simmering, assignments); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + + // Run 40 game-minutes (400 ticks). + for tick in 1..=400 { + run_at_tick(&mut world, &mut schedule, tick); + } + + // Both should be Active by 400 ticks. + assert_eq!(world.get::(slow).unwrap().phase, TrianglePhase::Active); + assert_eq!(world.get::(fast).unwrap().phase, TrianglePhase::Active); + + // Fast triangle should have activated earlier (higher tension accumulated faster). + let fast_tension = world.get::(fast).unwrap().tension; + let slow_tension = world.get::(slow).unwrap().tension; + assert!( + fast_tension > slow_tension, + "fast triangle (rate=8) should have higher tension than slow (rate=2) after equal time" + ); +} + +/// The trigger NPC in the crisis event is the one with the lowest threshold. +#[test] +fn crisis_event_trigger_npc_is_lowest_threshold() { + let mut world = make_escalation_world(); + + let npc_high = spawn_npc_with_threshold(&mut world, 1, 50); // high tolerance + let npc_low = spawn_npc_with_threshold(&mut world, 2, 10); // low tolerance — trigger + + use settled_reach_server::content::template::RoleId; + let mut assignments = BTreeMap::new(); + assignments.insert(RoleId::new("r-high"), npc_high); + assignments.insert(RoleId::new("r-low"), npc_low); + + // tension_rate = 11 so after 1 game-minute tension = 11 > 10. + spawn_triangle(&mut world, 99, 0, 11, TrianglePhase::Simmering, assignments); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + run_at_tick(&mut world, &mut schedule, 10); + + let queue = world.resource::(); + assert_eq!(queue.events.len(), 1, "exactly one crisis event"); + assert_eq!( + queue.events[0].trigger_npc, npc_low, + "trigger NPC must be the one with the lowest threshold" + ); + assert_eq!(queue.events[0].tick, 10, "crisis tick must match the game-minute"); +} + +/// No crisis event when tension hasn't exceeded the threshold. +#[test] +fn no_crisis_event_below_threshold() { + let mut world = make_escalation_world(); + let npc = spawn_npc_with_threshold(&mut world, 1, 100); // high threshold + + use settled_reach_server::content::template::RoleId; + let mut assignments = BTreeMap::new(); + assignments.insert(RoleId::new("r"), npc); + + spawn_triangle(&mut world, 1, 0, 5, TrianglePhase::Simmering, assignments); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + run_at_tick(&mut world, &mut schedule, 10); + + let queue = world.resource::(); + assert!(queue.is_empty(), "no crisis event when tension (5) < threshold (100)"); +} + +// --------------------------------------------------------------------------- +// #250: Active phase behavior +// --------------------------------------------------------------------------- + +/// Active triangle continues incrementing tension (narrative tracking). +/// No additional crisis event emitted. +#[test] +fn active_triangle_continues_incrementing_no_new_event() { + let mut world = make_escalation_world(); + + spawn_triangle(&mut world, 1, 50, 3, TrianglePhase::Active, BTreeMap::new()); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + run_at_tick(&mut world, &mut schedule, 10); + run_at_tick(&mut world, &mut schedule, 20); + + let queue = world.resource::(); + assert!(queue.is_empty(), "no crisis event for already-Active triangle"); +} + +/// Active triangle tension saturates at u8::MAX (255). +#[test] +fn active_triangle_tension_saturates_at_u8_max() { + let mut world = make_escalation_world(); + spawn_triangle(&mut world, 1, 252, 10, TrianglePhase::Active, BTreeMap::new()); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + run_at_tick(&mut world, &mut schedule, 10); + + // First call: 252 + 10 = 262, saturates to 255 + let entity = world.query::().iter(&world).next().unwrap(); + // Can't query TriangleState after mutable borrow; check via resource + // (we verify by spawning directly and checking post-run) + let _ = entity; // entity used to ensure spawn worked + + // Re-run test cleanly + let mut world2 = make_escalation_world(); + let e2 = spawn_triangle(&mut world2, 2, 254, 50, TrianglePhase::Active, BTreeMap::new()); + let mut sched2 = Schedule::default(); + sched2.add_systems(tick_triangle_escalation); + run_at_tick(&mut world2, &mut sched2, 10); + + let state = world2.get::(e2).unwrap(); + assert_eq!(state.tension, 255, "tension saturates at u8::MAX"); +} + +// --------------------------------------------------------------------------- +// #250: D-026 tier boundary +// --------------------------------------------------------------------------- + +/// Triangles without ActiveSim marker are NOT escalated (D-026 tier boundary). +#[test] +fn d026_non_active_tier_triangle_not_escalated() { + let mut world = make_escalation_world(); + world.resource_mut::().tick = 10; + + // Spawn WITHOUT ActiveSim. + let entity = world + .spawn(TriangleState { + triangle_id: TriangleId(1), + role_assignments: BTreeMap::new(), + tension: 10, + phase: TrianglePhase::Simmering, + tension_rate: 5, + template_id: TemplateId(1), + }) + .id(); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + schedule.run(&mut world); + + assert_eq!( + world.get::(entity).unwrap().tension, + 10, + "D-026: triangle without ActiveSim must not be escalated" + ); +} + +/// Dormant triangles are skipped even when in Active tier. +#[test] +fn dormant_triangle_not_escalated() { + let mut world = make_escalation_world(); + let entity = spawn_triangle(&mut world, 1, 0, 10, TrianglePhase::Dormant, BTreeMap::new()); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + run_at_tick(&mut world, &mut schedule, 10); + + assert_eq!( + world.get::(entity).unwrap().tension, + 0, + "Dormant triangle must not be escalated" + ); +} + +/// Resolved triangles are skipped (D-089: resolution is permanent). +#[test] +fn resolved_triangle_not_escalated() { + let mut world = make_escalation_world(); + let entity = spawn_triangle(&mut world, 1, 50, 5, TrianglePhase::Resolved, BTreeMap::new()); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + run_at_tick(&mut world, &mut schedule, 10); + + assert_eq!( + world.get::(entity).unwrap().tension, + 50, + "Resolved triangle must not be escalated (D-089)" + ); +} + +// --------------------------------------------------------------------------- +// #250: Resolution (D-089) +// --------------------------------------------------------------------------- + +/// ResolveTriangleCommand sets the target triangle to Resolved. +#[test] +fn resolve_command_sets_phase_to_resolved() { + let mut world = World::new(); + world.init_resource::(); + + let entity = world + .spawn(TriangleState { + triangle_id: TriangleId(100), + role_assignments: BTreeMap::new(), + tension: 50, + phase: TrianglePhase::Active, + tension_rate: 3, + template_id: TemplateId(1), + }) + .id(); + + world.resource_mut::().push(ResolveTriangleCommand(TriangleId(100))); + + let mut schedule = Schedule::default(); + schedule.add_systems(apply_resolve_triangle); + schedule.run(&mut world); + + let state = world.get::(entity).unwrap(); + assert_eq!(state.phase, TrianglePhase::Resolved, "resolve command must set phase to Resolved"); + assert_eq!(state.tension, 50, "tension must not change on resolve"); +} + +/// D-089: Resolution does NOT cascade to other triangles. +#[test] +fn d089_resolve_does_not_cascade() { + let mut world = World::new(); + world.init_resource::(); + + let target = world + .spawn(TriangleState { + triangle_id: TriangleId(100), + role_assignments: BTreeMap::new(), + tension: 50, + phase: TrianglePhase::Active, + tension_rate: 3, + template_id: TemplateId(1), + }) + .id(); + + let bystander_a = world + .spawn(TriangleState { + triangle_id: TriangleId(200), + role_assignments: BTreeMap::new(), + tension: 20, + phase: TrianglePhase::Simmering, + tension_rate: 2, + template_id: TemplateId(1), + }) + .id(); + + let bystander_b = world + .spawn(TriangleState { + triangle_id: TriangleId(300), + role_assignments: BTreeMap::new(), + tension: 80, + phase: TrianglePhase::Active, + tension_rate: 4, + template_id: TemplateId(1), + }) + .id(); + + // Resolve only triangle 100. + world.resource_mut::().push(ResolveTriangleCommand(TriangleId(100))); + + let mut schedule = Schedule::default(); + schedule.add_systems(apply_resolve_triangle); + schedule.run(&mut world); + + assert_eq!( + world.get::(target).unwrap().phase, + TrianglePhase::Resolved + ); + assert_eq!( + world.get::(bystander_a).unwrap().phase, + TrianglePhase::Simmering, + "D-089: bystander_a must remain Simmering" + ); + assert_eq!( + world.get::(bystander_b).unwrap().phase, + TrianglePhase::Active, + "D-089: bystander_b must remain Active" + ); +} + +/// Resolving the same triangle twice is idempotent. +#[test] +fn resolve_twice_is_idempotent() { + let mut world = World::new(); + world.init_resource::(); + + let entity = world + .spawn(TriangleState { + triangle_id: TriangleId(100), + role_assignments: BTreeMap::new(), + tension: 30, + phase: TrianglePhase::Active, + tension_rate: 1, + template_id: TemplateId(1), + }) + .id(); + + let mut schedule = Schedule::default(); + schedule.add_systems(apply_resolve_triangle); + + world.resource_mut::().push(ResolveTriangleCommand(TriangleId(100))); + schedule.run(&mut world); + world.resource_mut::().push(ResolveTriangleCommand(TriangleId(100))); + schedule.run(&mut world); + + assert_eq!( + world.get::(entity).unwrap().phase, + TrianglePhase::Resolved, + "double-resolve must remain Resolved" + ); +} + +// --------------------------------------------------------------------------- +// #250: Crisis event queue behavior +// --------------------------------------------------------------------------- + +/// Crisis events accumulate in the queue until drained. +#[test] +fn crisis_events_accumulate_until_drained() { + let mut world = make_escalation_world(); + + let npc = spawn_npc_with_threshold(&mut world, 1, 5); + + use settled_reach_server::content::template::RoleId; + let mut assignments = BTreeMap::new(); + assignments.insert(RoleId::new("r"), npc); + + // Two triangles that will both escalate. + spawn_triangle(&mut world, 10, 0, 6, TrianglePhase::Simmering, assignments.clone()); + spawn_triangle(&mut world, 20, 0, 6, TrianglePhase::Simmering, assignments); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + run_at_tick(&mut world, &mut schedule, 10); + + let queue = world.resource::(); + assert_eq!( + queue.events.len(), + 2, + "both triangles should emit crisis events in the same game-minute" + ); +} + +/// `TriangleCrisisEventQueue::drain` clears the queue. +#[test] +fn crisis_queue_drain_clears_events() { + let mut world = make_escalation_world(); + + let npc = spawn_npc_with_threshold(&mut world, 1, 5); + + use settled_reach_server::content::template::RoleId; + let mut assignments = BTreeMap::new(); + assignments.insert(RoleId::new("r"), npc); + + spawn_triangle(&mut world, 1, 0, 10, TrianglePhase::Simmering, assignments); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + run_at_tick(&mut world, &mut schedule, 10); + + // Drain the queue. + let drained = world.resource_mut::().drain(); + assert_eq!(drained.len(), 1, "drain should return the 1 event"); + assert!( + world.resource::().is_empty(), + "queue must be empty after drain" + ); +} + +// --------------------------------------------------------------------------- +// #250: YAML triangle def → escalation pipeline +// --------------------------------------------------------------------------- + +/// End-to-end: TriangleDef from YAML can describe all 5 v0.1 triangles +/// (D-087) and those defs produce escalatable TriangleState instances. +#[test] +fn d087_all_v01_conflict_types_produce_escalatable_states() { + use settled_reach_server::content::template::{ConflictType, NpcAxis, RoleId}; + + let defs = [ + ("kael-davan", "smuggler", "ring-contact", ConflictType::ResourceCompetition), + ("sera-venn", "detective", "commission-inspector", ConflictType::SecretExposure), + ("naia", "kael-davan", "hael", ConflictType::LatentTension), + ("drin", "ring-system", "dock-supervisor", ConflictType::ResourceCompetition), + ("worried-partner", "ring-member", "neighbor", ConflictType::LatentTension), + ]; + + for (r0, r1, r2, conflict) in &defs { + let roles = [RoleId::new(r0), RoleId::new(r1), RoleId::new(r2)]; + let tid = settled_reach_server::content::template::TriangleId::from_seed_and_roles(42, &roles); + let def = TriangleDef { + triangle_id: tid, + roles: roles.clone(), + conflict_type: *conflict, + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], + relationship_constraints: vec![], + }; + assert!(def.validate().is_ok(), "D-087 triangle must be valid: {:?}", def.validate()); + + // Can construct a TriangleState from the def. + let mut assignments = BTreeMap::new(); + for role in &roles { + assignments.insert(role.clone(), StableId(0)); + } + let state = TriangleState { + triangle_id: tid, + role_assignments: assignments, + tension: 0, + phase: TrianglePhase::Simmering, + tension_rate: 3, + template_id: TemplateId(1), + }; + assert_eq!( + state.phase, + TrianglePhase::Simmering, + "{:?} triangle must start Simmering", + conflict + ); + } +}