Files
settled-reach/server/src/simulation/triangle.rs
T
jpmschweitzerandClaude Opus 4.6 aa79dd97e7 fix(simulation): Clippy cleanup and CI enforcement (#635)
Fix all Clippy warnings across the server codebase (2411 insertions, 1341
deletions). Raise type-complexity-threshold to 750 and too-many-arguments
to 12 in .clippy.toml for idiomatic Bevy ECS system signatures. The server
now passes `cargo clippy -- --deny warnings` cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:33:15 +01:00

1790 lines
67 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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
//! 48 NPCs in a 1540 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<RoleId> 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 25.
#[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<String>,
}
/// 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<PersonalityTrait>,
/// Skills biased toward for this role.
#[serde(default)]
pub skill_focus: Vec<Skill>,
/// Relationship constraints with other roles in the same template.
#[serde(default)]
pub relationship_constraints: Vec<RelationshipConstraint>,
/// Routine template: phase → location name mappings.
#[serde(default)]
pub routine_template: Vec<TemplateRoutineEntry>,
}
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 1540 visual tile space (per D-025) = 3080 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<SightlineZone>,
/// 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<TemplateId, Vec<TemplateReference>>,
}
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<Item = &TemplateReference> {
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<TriangleId> 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<RelationshipConstraint>,
}
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<String>,
}
/// 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 = 48 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<String>,
/// Role definitions (D-024 10-axis NPC constraints per role, 48 roles).
pub roles: Vec<RoleSchema>,
/// Spatial requirements (D-025: 3080 sim tiles).
pub space: SpaceSpec,
/// Triangle definitions (D-024 minimum 2 per template, D-087 configuration).
pub triangles: Vec<TriangleDef>,
/// Dialogue pool references for runtime line selection (D-028).
#[serde(default)]
pub dialogue_pools: Vec<TemplateDialoguePoolRef>,
/// Cross-template link specs resolved to `TemplateReferenceMap` at instantiation.
#[serde(default)]
pub cross_template_links: Vec<CrossTemplateLinkSpec>,
}
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<RoleId, StableId>,
/// Current tension level (0255). 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<TriangleState>,
/// Warnings emitted during generation (e.g., fallback assignments).
pub warnings: Vec<String>,
}
/// 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<RoleId, StableId> = 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<RoleId, StableId> = {
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<RoleId, StableId>,
/// 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<TriangleCrisisEvent>,
}
impl TriangleCrisisEventQueue {
pub fn push(&mut self, event: TriangleCrisisEvent) {
self.events.push(event);
}
pub fn drain(&mut self) -> Vec<TriangleCrisisEvent> {
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<ResolveTriangleCommand>,
}
impl ResolveTriangleQueue {
pub fn push(&mut self, cmd: ResolveTriangleCommand) {
self.commands.push(cmd);
}
pub fn drain(&mut self) -> Vec<ResolveTriangleCommand> {
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<SimulationTime>,
registry: Res<EntityRegistry>,
mut crisis_queue: ResMut<TriangleCrisisEventQueue>,
mut triangles: Query<&mut TriangleState, With<ActiveSim>>,
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<ResolveTriangleQueue>,
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<TriangleId, Entity> = 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::<SimulationTime>();
world.init_resource::<TriangleCrisisEventQueue>();
world.init_resource::<EntityRegistry>();
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::<SimulationTime>().tick = 10;
schedule.run(&mut world);
world.resource_mut::<SimulationTime>().tick = 20;
schedule.run(&mut world);
let state = world.get::<TriangleState>(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::<TriangleCrisisEventQueue>();
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::<SimulationTime>().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::<TriangleState>(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::<ResolveTriangleQueue>();
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::<ResolveTriangleQueue>()
.push(ResolveTriangleCommand(TriangleId(100)));
let mut schedule = Schedule::default();
schedule.add_systems(apply_resolve_triangle);
schedule.run(&mut world);
assert_eq!(
world.get::<TriangleState>(target).unwrap().phase,
TrianglePhase::Resolved,
"targeted triangle should be Resolved"
);
assert_eq!(
world.get::<TriangleState>(bystander).unwrap().phase,
TrianglePhase::Simmering,
"D-089: non-targeted triangle must not be affected (no cascade)"
);
}
}