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/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..9e248b60a --- /dev/null +++ b/server/src/content/template.rs @@ -0,0 +1,1794 @@ +//! 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; + +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()) + } +} + +/// 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) + } +} + +/// 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 + )); + } + + 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 + )); + } + } + + 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). +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 assignment_ok = true; + + for role_id in &def.roles { + if let Some(&stable_id) = role_to_npc.get(role_id) { + role_assignments.insert(role_id.clone(), stable_id); + } else { + // Fallback: pick the first available NPC not already assigned + let already_assigned: Vec = + role_assignments.values().copied().collect(); + let fallback = role_to_npc + .values() + .find(|sid| !already_assigned.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); + } 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<&mut TriangleState>, +) { + let commands = queue.drain(); + for cmd in commands { + for mut state in triangles.iter_mut() { + if state.triangle_id == cmd.0 { + 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 + // ----------------------------------------------------------------------- + + #[test] + fn role_schema_yaml_roundtrip() { + let yaml = r#" +role_id: bartender +required_traits: + - Social + - Honest +skill_focus: + - Persuasion + - Observation +relationship_constraints: + - with_role: waitstaff + kind: Colleague + required_trust: + min: 2 + max: 5 +routine_template: + - phase: morning + location: bar_counter + - phase: evening + location: bar_counter +"#; + + let schema: RoleSchema = serde_yaml::from_str(yaml).expect("deserialize"); + assert_eq!(schema.role_id, RoleId::new("bartender")); + assert_eq!(schema.required_traits.len(), 2); + assert_eq!(schema.skill_focus.len(), 2); + assert_eq!(schema.relationship_constraints.len(), 1); + assert_eq!(schema.routine_template.len(), 2); + + // Re-serialize and verify round-trip + let reserialized = serde_yaml::to_string(&schema).expect("serialize"); + let recovered: RoleSchema = serde_yaml::from_str(&reserialized).expect("re-deserialize"); + assert_eq!(schema, recovered); + } + + #[test] + fn role_schema_validate_catches_self_reference() { + let schema = RoleSchema { + role_id: RoleId::new("guard"), + required_traits: vec![], + skill_focus: vec![], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("guard"), + kind: RelationshipKind::Colleague, + required_trust: TrustRange { min: 0, max: 5 }, + }], + routine_template: vec![], + }; + + assert!(schema.validate().is_err()); + } + + #[test] + fn role_schema_validate_catches_bad_trust_range() { + let schema = RoleSchema { + role_id: RoleId::new("guard"), + required_traits: vec![], + skill_focus: vec![], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("supervisor"), + kind: RelationshipKind::Superior, + required_trust: TrustRange { min: 5, max: 2 }, + }], + routine_template: vec![], + }; + + assert!(schema.validate().is_err()); + } + + #[test] + fn role_schema_valid_schema_passes() { + let schema = RoleSchema { + role_id: RoleId::new("technician"), + required_traits: vec![PersonalityTrait::Curious], + skill_focus: vec![Skill::Technical], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("supervisor"), + kind: RelationshipKind::Subordinate, + required_trust: TrustRange { min: 1, max: 7 }, + }], + routine_template: vec![TemplateRoutineEntry { + phase: "morning".into(), + location: "workshop".into(), + activity: None, + }], + }; + + assert!(schema.validate().is_ok()); + } + + #[test] + fn validate_role_schemas_catches_duplicates() { + let schemas = vec![ + RoleSchema { + role_id: RoleId::new("guard"), + required_traits: vec![], + skill_focus: vec![], + relationship_constraints: vec![], + routine_template: vec![], + }, + RoleSchema { + role_id: RoleId::new("guard"), + required_traits: vec![], + skill_focus: vec![], + relationship_constraints: vec![], + routine_template: vec![], + }, + ]; + + let result = validate_role_schemas_no_duplicate_ids(&schemas); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("guard")); + } + + // ----------------------------------------------------------------------- + // #164 — SpaceSpec tests + // ----------------------------------------------------------------------- + + #[test] + fn space_spec_yaml_roundtrip() { + let yaml = r#" +tile_count_min: 30 +tile_count_max: 80 +sightline_zones: + - name: bar_counter + radius: 4 + - name: back_room + radius: 2 +privacy_level: SemiPrivate +traffic_pattern: Destination +"#; + + let spec: SpaceSpec = serde_yaml::from_str(yaml).expect("deserialize"); + assert_eq!(spec.tile_count_min, 30); + assert_eq!(spec.tile_count_max, 80); + assert_eq!(spec.sightline_zones.len(), 2); + assert_eq!(spec.privacy_level, PrivacyLevel::SemiPrivate); + assert_eq!(spec.traffic_pattern, TrafficPattern::Destination); + + let reserialized = serde_yaml::to_string(&spec).expect("serialize"); + let recovered: SpaceSpec = serde_yaml::from_str(&reserialized).expect("re-deserialize"); + assert_eq!(spec, recovered); + } + + #[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 + // ----------------------------------------------------------------------- + + #[test] + fn template_id_deterministic() { + let id1 = TemplateId::from_seed_and_slug(42, "bar_grill"); + let id2 = TemplateId::from_seed_and_slug(42, "bar_grill"); + assert_eq!(id1, id2); + + let id3 = TemplateId::from_seed_and_slug(42, "dock_office"); + assert_ne!(id1, id3); + + let id4 = TemplateId::from_seed_and_slug(99, "bar_grill"); + assert_ne!(id1, id4); + } + + #[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 + // ----------------------------------------------------------------------- + + #[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 triangle_def_yaml_roundtrip() { + let yaml = r#" +triangle_id: 12345 +roles: + - smuggler + - detective + - informant +conflict_type: SecretExposure +interest_axes: + - Secret + - InformationInventory + - Relationships +relationship_constraints: [] +"#; + + let def: TriangleDef = serde_yaml::from_str(yaml).expect("deserialize"); + assert_eq!(def.triangle_id, TriangleId(12345)); + assert_eq!(def.roles[0], RoleId::new("smuggler")); + assert_eq!(def.conflict_type, ConflictType::SecretExposure); + assert_eq!(def.interest_axes[0], NpcAxis::Secret); + + let reserialized = serde_yaml::to_string(&def).expect("serialize"); + let recovered: TriangleDef = serde_yaml::from_str(&reserialized).expect("re-deserialize"); + assert_eq!(def, recovered); + } + + #[test] + fn triangle_def_validate_catches_duplicate_roles() { + let def = TriangleDef { + triangle_id: TriangleId(1), + roles: [ + RoleId::new("guard"), + RoleId::new("guard"), + RoleId::new("prisoner"), + ], + conflict_type: ConflictType::AuthorityChallenge, + interest_axes: [NpcAxis::Tolerance, NpcAxis::Want, NpcAxis::Contentment], + relationship_constraints: vec![], + }; + + assert!(def.validate().is_err()); + } + + #[test] + fn triangle_def_valid_passes() { + let def = TriangleDef { + triangle_id: TriangleId::from_seed_and_roles( + 42, + &[ + RoleId::new("smuggler"), + RoleId::new("detective"), + RoleId::new("informant"), + ], + ), + roles: [ + RoleId::new("smuggler"), + RoleId::new("detective"), + RoleId::new("informant"), + ], + conflict_type: ConflictType::LoyaltyConflict, + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], + relationship_constraints: vec![], + }; + + assert!(def.validate().is_ok()); + } + + #[test] + fn d087_t1_expressible() { + let def = TriangleDef { + triangle_id: TriangleId::from_seed_and_roles( + 1, + &[ + RoleId::new("kael_supplier"), + RoleId::new("smuggler_lead"), + RoleId::new("ring_enforcer"), + ], + ), + roles: [ + RoleId::new("kael_supplier"), + RoleId::new("smuggler_lead"), + RoleId::new("ring_enforcer"), + ], + conflict_type: ConflictType::ResourceCompetition, + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Tolerance], + relationship_constraints: vec![], + }; + + assert!(def.validate().is_ok()); + } + + #[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 + } + + #[test] + fn escalation_simmering_to_active_at_expected_tick() { + let mut world = setup_escalation_world(); + + // Spawn 3 NPCs with different thresholds. NPC B has the lowest (20). + let npc_a_sid = StableId(10); + let npc_b_sid = StableId(11); + let npc_c_sid = StableId(12); + spawn_escalation_npc(&mut world, 10, 30); + spawn_escalation_npc(&mut world, 11, 20); // lowest threshold + spawn_escalation_npc(&mut world, 12, 50); + + let mut role_assignments = BTreeMap::new(); + role_assignments.insert(RoleId::new("a"), npc_a_sid); + role_assignments.insert(RoleId::new("b"), npc_b_sid); + role_assignments.insert(RoleId::new("c"), npc_c_sid); + + let triangle = world + .spawn(( + TriangleState { + triangle_id: TriangleId(100), + role_assignments, + tension: 10, // starting tension + phase: TrianglePhase::Simmering, + tension_rate: 3, // +3 per game-minute + template_id: TemplateId(1), + }, + ActiveSim, + )) + .id(); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + + // Advance through 60 ticks. + // Escalation fires at tick % 10 == 0: ticks 10, 20, 30, 40, 50, 60. + // + // Tension progression (threshold = 20): + // tick 10: 10 + 3 = 13 (13 > 20? no) + // tick 20: 13 + 3 = 16 (16 > 20? no) + // tick 30: 16 + 3 = 19 (19 > 20? no) + // tick 40: 19 + 3 = 22 (22 > 20? yes → Active!) + // tick 50: 22 + 3 = 25 (Active, continues incrementing) + // tick 60: 25 + 3 = 28 + for tick in 1..=60 { + world.resource_mut::().tick = tick; + schedule.run(&mut world); + } + + let state = world.get::(triangle).unwrap(); + assert_eq!( + state.phase, + TrianglePhase::Active, + "triangle should transition to Active when tension exceeds lowest threshold" + ); + assert_eq!(state.tension, 28, "tension should be 28 after 6 game-minutes"); + + // Verify crisis event + let queue = world.resource::(); + assert_eq!(queue.events.len(), 1, "exactly one crisis event expected"); + assert_eq!(queue.events[0].triangle_id, TriangleId(100)); + assert_eq!( + queue.events[0].trigger_npc, npc_b_sid, + "trigger NPC should be the one with lowest threshold" + ); + assert_eq!(queue.events[0].tick, 40, "crisis should fire at tick 40"); + } + + #[test] + fn resolve_triangle_sets_phase_resolved() { + let mut world = bevy_ecs::world::World::new(); + world.init_resource::(); + + let triangle_id = TriangleId(100); + let triangle = world + .spawn(TriangleState { + triangle_id, + role_assignments: BTreeMap::new(), + tension: 50, + phase: TrianglePhase::Active, + tension_rate: 3, + template_id: TemplateId(1), + }) + .id(); + + world + .resource_mut::() + .push(ResolveTriangleCommand(triangle_id)); + + let mut schedule = Schedule::default(); + schedule.add_systems(apply_resolve_triangle); + schedule.run(&mut world); + + let state = world.get::(triangle).unwrap(); + assert_eq!( + state.phase, + TrianglePhase::Resolved, + "ResolveTriangleCommand must set phase to Resolved" + ); + // D-089: no cascade — only the targeted triangle is affected + assert_eq!(state.tension, 50, "tension should not change on resolve"); + } + + #[test] + fn dormant_triangle_not_escalated() { + 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: 10, + phase: TrianglePhase::Dormant, + tension_rate: 3, + 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, 10, + "Dormant triangle should not have tension incremented" + ); + assert_eq!(state.phase, TrianglePhase::Dormant); + } + + #[test] + fn resolved_triangle_not_escalated() { + 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: 50, + phase: TrianglePhase::Resolved, + tension_rate: 3, + 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, 50, + "Resolved triangle should not have tension incremented (D-089)" + ); + } + + #[test] + fn escalation_skips_non_game_minute_ticks() { + let mut world = setup_escalation_world(); + world.resource_mut::().tick = 7; // 7 % 10 != 0 + + let triangle = world + .spawn(( + TriangleState { + triangle_id: TriangleId(100), + role_assignments: BTreeMap::new(), + tension: 10, + phase: TrianglePhase::Simmering, + tension_rate: 3, + 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, 10, + "should not escalate on non-game-minute ticks" + ); + } + + #[test] + fn escalation_without_active_sim_marker_skipped() { + let mut world = setup_escalation_world(); + world.resource_mut::().tick = 10; + + // Triangle entity WITHOUT ActiveSim — should not be processed + let triangle = world + .spawn(TriangleState { + triangle_id: TriangleId(100), + role_assignments: BTreeMap::new(), + tension: 10, + phase: TrianglePhase::Simmering, + tension_rate: 3, + template_id: TemplateId(1), + }) + .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, 10, + "triangle without ActiveSim should not be escalated (D-026)" + ); + } + + #[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/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)"); +}