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>
1790 lines
67 KiB
Rust
1790 lines
67 KiB
Rust
//! 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<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 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<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 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<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 = 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<String>,
|
||
/// Role definitions (D-024 10-axis NPC constraints per role, 4–8 roles).
|
||
pub roles: Vec<RoleSchema>,
|
||
/// 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<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 (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<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)"
|
||
);
|
||
}
|
||
}
|