//! 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 a canonical slug. /// /// Used for authored triangles loaded from content YAML (#188). /// Mirrors `TemplateId::from_seed_and_slug` — same FNV-1a pattern (D-010). 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 } TriangleId(hash) } /// 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(()) } } // =========================================================================== // #159 — Full Tier 2 template document // =========================================================================== /// Dialogue pool reference within a template (D-028). /// /// Refers to an existing authored dialogue pool by (location, roles). /// The pool content lives in the campaign dialogue files; this reference /// wires the pool to the social site for runtime line selection. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TemplateDialoguePoolRef { /// The location identifier the pool is authored under (e.g., "the-terminal"). pub location: String, /// Role slugs from this template that draw from the pool. /// Empty = all roles may draw from this pool. #[serde(default)] pub roles: Vec, } /// Specification of a cross-template link authored in the template file (D-025). /// /// At instantiation time the engine resolves these into `TemplateReference` /// entries in `TemplateReferenceMap`. The actual target template is identified /// by slug, not a pre-computed `TemplateId`, because IDs are seed-dependent. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CrossTemplateLinkSpec { /// The role in THIS template that holds the cross-template relationship. pub from_role: RoleId, /// Slug of the external template being referenced. pub to_template_slug: String, /// Relationship kind for the `TemplateReference` link. pub relationship: RelationshipKind, } /// Full Tier 2 social site template definition (#159). /// /// The composite YAML document combining all sub-schemas into one canonical /// template file. Authored in `server/data/templates/*.yaml` and loaded by /// the template instantiation engine (#161). /// /// Per D-023 (three-tier content model) and D-025 (social site as atomic unit): /// one file = one social site = 4–8 roles + spatial spec + 2+ triangles. /// /// **YAML authoring note:** `triangle_id` fields in authored `TriangleDef`s /// should be set to 0 as a placeholder — the instantiation engine overwrites /// them with `TriangleId::from_seed_and_roles(world_seed, &roles)` at runtime. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FullTemplateDef { /// Stable slug used to derive `TemplateId` via `TemplateId::from_seed_and_slug`. pub slug: String, /// Human-readable display name. pub display_name: String, /// Optional authoring description (not surfaced to players). #[serde(default)] pub description: Option, /// Role definitions (D-024 10-axis NPC constraints per role, 4–8 roles). pub roles: Vec, /// Spatial requirements (D-025: 30–80 sim tiles). pub space: SpaceSpec, /// Triangle definitions (D-024 minimum 2 per template, D-087 configuration). pub triangles: Vec, /// Dialogue pool references for runtime line selection (D-028). #[serde(default)] pub dialogue_pools: Vec, /// Cross-template link specs resolved to `TemplateReferenceMap` at instantiation. #[serde(default)] pub cross_template_links: Vec, } impl FullTemplateDef { /// Validate the full template definition. /// /// Checks (in order): /// 1. No duplicate `role_id`s in the roles list. /// 2. Each role validates individually. /// 3. Space spec validates. /// 4. At least 2 triangles (D-024). /// 5. Each triangle validates individually. /// 6. All role references in triangles are defined in the roles list. /// /// Returns `Err` with the first failure description found. pub fn validate(&self) -> Result<(), String> { // 1 — no duplicate role IDs validate_role_schemas_no_duplicate_ids(&self.roles)?; // 2 — each role validates for role in &self.roles { role.validate() .map_err(|e| format!("role '{}': {}", role.role_id.0, e))?; } // 3 — space spec self.space.validate()?; // 4 — minimum 2 triangles if self.triangles.len() < 2 { return Err(format!( "template '{}': fewer than 2 triangles ({}) — D-024 requires minimum 2", self.slug, self.triangles.len() )); } // 5 — each triangle validates for tri in &self.triangles { tri.validate() .map_err(|e| format!("triangle {:?}: {}", tri.triangle_id, e))?; } // 6 — triangle role references must exist in roles list let role_ids: BTreeSet<&RoleId> = self.roles.iter().map(|r| &r.role_id).collect(); for tri in &self.triangles { for role_id in &tri.roles { if !role_ids.contains(role_id) { return Err(format!( "triangle {:?}: role '{}' is not defined in template roles", tri.triangle_id, role_id.0 )); } } } Ok(()) } } // =========================================================================== // #107 — Intra-template triangle generation // =========================================================================== /// Narrative classification of a triangle (D-087, #188). /// /// Active forks drive narrative conflict — the player's decisions directly /// affect outcomes. Passive tensions provide background pressure — observable /// behavioral signals without a direct player decision point. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum TriangleClassification { /// Drives narrative conflict — player decisions affect outcomes (D-087). #[default] ActiveFork, /// Background tension — observable tells without direct decision point. PassiveTension, } /// 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, /// Narrative classification (D-087, #188): active fork vs passive tension. #[serde(default)] pub classification: TriangleClassification, } /// 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 { // Validate triangle definition before generating TriangleState (#109). // Mirrors the cross-template path in generate_cross_template_triangles. if let Err(e) = validate_triangle_def(def) { result.warnings.push(format!( "Triangle {:?}: skipped — validation failed: {}", def.triangle_id, e )); continue; } let mut role_assignments = BTreeMap::new(); let mut assigned_npcs = BTreeSet::new(); let mut assignment_ok = true; 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, classification: TriangleClassification::ActiveFork, }); } result } // =========================================================================== // #109 — Triangle validation // =========================================================================== /// Errors returned when a `TriangleDef` fails instantiation-time validation. /// /// These checks run before role assignment to catch degenerate triangle /// definitions that cannot produce meaningful drama. /// /// Spec: #109, D-024, D-087 #[derive(Debug, Clone, PartialEq, Eq)] pub enum ValidationError { /// None of the three role interest_axes is `NpcAxis::Want`. /// /// A viable conflict requires at least one role whose primary tension is /// their Want axis — without it there is no active driver of conflict. ConflictViability { triangle_id: TriangleId }, /// `relationship_constraints` is empty — no authored relationship links /// the three roles together. /// /// A coherent triangle requires at least one explicit relationship /// constraint documenting how the roles are socially connected. RelationshipCoherence { triangle_id: TriangleId }, /// Two or more roles share the same `interest_axes` value. /// /// Each role must have a distinct tension axis so their interests genuinely /// diverge. Duplicate axes indicate the triangle is underspecified. InterestDivergence { triangle_id: TriangleId, duplicate_axis: NpcAxis, }, } impl std::fmt::Display for ValidationError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ValidationError::ConflictViability { triangle_id } => write!( f, "Triangle {:?}: conflict viability — no NpcAxis::Want among the three interest_axes", triangle_id ), ValidationError::RelationshipCoherence { triangle_id } => write!( f, "Triangle {:?}: relationship coherence — relationship_constraints is empty", triangle_id ), ValidationError::InterestDivergence { triangle_id, duplicate_axis } => write!( f, "Triangle {:?}: interest divergence — duplicate interest_axes value {:?}", triangle_id, duplicate_axis ), } } } /// Validate a `TriangleDef` for instantiation quality (#109). /// /// Three checks: /// 1. **Conflict viability** — at least one of the three `interest_axes` is /// `NpcAxis::Want`, ensuring an active want-driven tension. /// 2. **Relationship coherence** — `relationship_constraints` is non-empty, /// documenting at least one social link among the three roles. /// 3. **Interest divergence** — all three `interest_axes` are distinct, so /// each role brings a genuinely different tension to the triangle. /// /// Returns `Ok(())` if all checks pass, or `Err(ValidationError)` on the /// first failure (conflict viability is checked first, then coherence, then /// divergence). pub fn validate_triangle_def(def: &TriangleDef) -> Result<(), ValidationError> { // 1. Conflict viability: at least one Want axis if !def.interest_axes.contains(&NpcAxis::Want) { return Err(ValidationError::ConflictViability { triangle_id: def.triangle_id, }); } // 2. Relationship coherence: at least one relationship constraint if def.relationship_constraints.is_empty() { return Err(ValidationError::RelationshipCoherence { triangle_id: def.triangle_id, }); } // 3. Interest divergence: all three axes must be distinct let [a0, a1, a2] = def.interest_axes; if a0 == a1 { return Err(ValidationError::InterestDivergence { triangle_id: def.triangle_id, duplicate_axis: a0, }); } if a0 == a2 { return Err(ValidationError::InterestDivergence { triangle_id: def.triangle_id, duplicate_axis: a0, }); } if a1 == a2 { return Err(ValidationError::InterestDivergence { triangle_id: def.triangle_id, duplicate_axis: a1, }); } Ok(()) } // =========================================================================== // #108 — Cross-template triangle generation // =========================================================================== /// Generate triangle instances that span two social site templates (#108). /// /// Implements the 1 cross-template triangle required by D-024 ("2 per template /// minimum, 1 cross-template"). Role assignments draw from NPCs owned by /// *either* `template_a_id` or `template_b_id` — the combined pool is used /// for role lookup. /// /// The generated `TriangleState` is owned by `template_a_id`. D-025 ownership /// model: NPCs are owned by one template but can hold reference roles in /// another; the cross-template triangle represents this social link. /// /// **Validation:** each `TriangleDef` is validated via `validate_triangle_def` /// before role assignment. Invalid defs are skipped with a warning added to /// the result. /// /// **Fallback behavior:** same as `generate_intra_template_triangles` — if no /// NPC satisfies a role constraint, the closest available NPC is used and a /// warning is logged. Generation never panics. /// /// All randomness flows through `rng` for determinism (D-010). pub fn generate_cross_template_triangles( world: &mut World, template_a_id: TemplateId, template_b_id: TemplateId, defs: &[TriangleDef], rng: &mut SimRng, ) -> TriangleGenerationResult { let mut result = TriangleGenerationResult { triangles: Vec::new(), warnings: Vec::new(), }; // Collect NPCs from both templates into a single role → StableId lookup. // BTreeMap for determinism (D-010). If both templates define the same // role slug, template_a wins (insertion order: a first, b second via // entry().or_insert). let role_to_npc: BTreeMap = { let mut q = world.query::<(&TemplateOwnership, &StableEntityId)>(); let mut map = BTreeMap::new(); for (own, sid) in q.iter(world) { if own.template_id == template_a_id || own.template_id == template_b_id { map.entry(own.role_id.clone()).or_insert(sid.0); } } map }; for def in defs { // Validate the def before attempting role assignment. if let Err(e) = validate_triangle_def(def) { result.warnings.push(format!( "Cross-template triangle {:?}: skipped — validation failed: {}", def.triangle_id, e )); continue; } let mut role_assignments = BTreeMap::new(); let mut assigned_npcs = BTreeSet::new(); let mut assignment_ok = true; for role_id in &def.roles { if let Some(&stable_id) = role_to_npc.get(role_id) { if !assigned_npcs.contains(&stable_id) { role_assignments.insert(role_id.clone(), stable_id); assigned_npcs.insert(stable_id); continue; } } // Fallback: first available NPC not already in this triangle. let fallback = role_to_npc .values() .find(|sid| !assigned_npcs.contains(sid)); if let Some(&fallback_sid) = fallback { result.warnings.push(format!( "Cross-template triangle {:?}: no NPC for role '{}' — assigned fallback StableId({})", def.triangle_id, role_id.0, fallback_sid.0 )); role_assignments.insert(role_id.clone(), fallback_sid); assigned_npcs.insert(fallback_sid); } else { result.warnings.push(format!( "Cross-template triangle {:?}: no NPC available for role '{}' — skipping", def.triangle_id, role_id.0 )); assignment_ok = false; break; } } if !assignment_ok { continue; } let initial_tension: u8 = rng.rng.random_range(5_u8..=25); let tension_rate: u8 = rng.rng.random_range(1_u8..=5); result.triangles.push(TriangleState { triangle_id: def.triangle_id, role_assignments, tension: initial_tension, phase: TrianglePhase::Simmering, tension_rate, template_id: template_a_id, // cross-template triangle owned by template_a classification: TriangleClassification::ActiveFork, }); } 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.is_multiple_of(TICKS_PER_GAME_MINUTE) { 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, 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![RelationshipConstraint { with_role: RoleId::new("waitstaff"), kind: crate::npc::RelationshipKind::Colleague, required_trust: TrustRange { min: 0, max: 5 }, }], }, TriangleDef { triangle_id: TriangleId::from_seed_and_roles( 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![RelationshipConstraint { with_role: RoleId::new("bouncer"), kind: crate::npc::RelationshipKind::Colleague, required_trust: TrustRange { min: 0, max: 3 }, }], }, ]; 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::Want, NpcAxis::Secret, NpcAxis::Tolerance], relationship_constraints: vec![RelationshipConstraint { with_role: RoleId::new("bouncer"), kind: crate::npc::RelationshipKind::Colleague, required_trust: TrustRange { min: 0, max: 5 }, }], }, TriangleDef { triangle_id: TriangleId(101), roles: [ RoleId::new("bartender"), RoleId::new("bouncer"), RoleId::new("also_missing"), ], conflict_type: ConflictType::LatentTension, interest_axes: [NpcAxis::Want, NpcAxis::Contentment, NpcAxis::Tolerance], relationship_constraints: vec![RelationshipConstraint { with_role: RoleId::new("bouncer"), kind: crate::npc::RelationshipKind::Rival, required_trust: TrustRange { min: -5, max: 0 }, }], }, ]; 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::Secret, NpcAxis::Tolerance], relationship_constraints: vec![RelationshipConstraint { with_role: RoleId::new("bouncer"), kind: crate::npc::RelationshipKind::Colleague, required_trust: TrustRange { min: 0, max: 5 }, }], }]; // Should succeed with 1 triangle but log an error about < 2 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![RelationshipConstraint { with_role: RoleId::new("b"), kind: crate::npc::RelationshipKind::Colleague, required_trust: TrustRange { min: 0, max: 5 }, }], }, TriangleDef { triangle_id: TriangleId(301), roles: [RoleId::new("a"), RoleId::new("c"), RoleId::new("d")], conflict_type: ConflictType::LatentTension, interest_axes: [NpcAxis::Want, NpcAxis::Contentment, NpcAxis::Tolerance], relationship_constraints: vec![RelationshipConstraint { with_role: RoleId::new("c"), kind: crate::npc::RelationshipKind::Rival, required_trust: TrustRange { min: -5, max: 0 }, }], }, ]; // 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), classification: TriangleClassification::ActiveFork, }; 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::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 } // 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), classification: TriangleClassification::ActiveFork, }, 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), classification: TriangleClassification::ActiveFork, }, 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), classification: TriangleClassification::ActiveFork, }) .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), classification: TriangleClassification::ActiveFork, }) .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)" ); } }