- Hoshe #3: replace O(n²) Vec scan in fallback NPC assignment with BTreeSet; prevent same NPC assigned to two roles in one triangle - Hoshe #4: add From impls for RoleId, TriangleId, StableId, and TriangleCrisisEventWire — eliminate fragile .0 access on newtypes - Hoshe #5: consolidate near-identical unit tests with integration counterparts — keep only unique tests in #[cfg(test)] module - Tyre #3: replace O(N*M) scan in apply_resolve_triangle with BTreeMap<TriangleId, Entity> index for O(1) per-command lookup - Tyre #4: document &mut World on generate_intra_template_triangles - Observer snapshot uses TriangleCrisisEventWire::from instead of manual field mapping Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1380 lines
50 KiB
Rust
1380 lines
50 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 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(())
|
||
}
|
||
}
|
||
|
||
// ===========================================================================
|
||
// #107 — Intra-template triangle generation
|
||
// ===========================================================================
|
||
|
||
/// Phase of a triangle's lifecycle.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum TrianglePhase {
|
||
/// Not yet active — waiting for conditions.
|
||
Dormant,
|
||
/// Tension is building but hasn't reached a crisis.
|
||
Simmering,
|
||
/// Tension has exceeded a threshold — crisis in progress.
|
||
Active,
|
||
/// Triangle has been resolved (by player or system). D-089: no cascade.
|
||
Resolved,
|
||
}
|
||
|
||
/// Runtime state of an instantiated triangle (#107).
|
||
///
|
||
/// ECS component attached to a dedicated triangle entity (not on an NPC).
|
||
/// Tracks the current tension level and phase for a specific triangle instance.
|
||
#[derive(Component, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct TriangleState {
|
||
/// Which triangle definition this instance was generated from.
|
||
pub triangle_id: TriangleId,
|
||
/// NPC role → StableId assignments for this triangle instance.
|
||
/// Uses `BTreeMap` for deterministic iteration (D-010).
|
||
pub role_assignments: BTreeMap<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,
|
||
}
|
||
|
||
/// 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 {
|
||
let mut role_assignments = BTreeMap::new();
|
||
let mut assigned_npcs = BTreeSet::new();
|
||
let mut assignment_ok = true;
|
||
|
||
for role_id in &def.roles {
|
||
if let Some(&stable_id) = role_to_npc.get(role_id) {
|
||
if assigned_npcs.contains(&stable_id) {
|
||
// This NPC is already assigned to another role in this triangle.
|
||
// Fall through to fallback instead of duplicating.
|
||
} else {
|
||
role_assignments.insert(role_id.clone(), stable_id);
|
||
assigned_npcs.insert(stable_id);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Fallback: pick the first available NPC not already assigned to this triangle.
|
||
let fallback = role_to_npc
|
||
.values()
|
||
.find(|sid| !assigned_npcs.contains(sid));
|
||
|
||
if let Some(&fallback_sid) = fallback {
|
||
result.warnings.push(format!(
|
||
"Triangle {:?}: no NPC for role '{}' — assigned fallback StableId({})",
|
||
def.triangle_id, role_id.0, fallback_sid.0
|
||
));
|
||
role_assignments.insert(role_id.clone(), fallback_sid);
|
||
assigned_npcs.insert(fallback_sid);
|
||
} else {
|
||
result.warnings.push(format!(
|
||
"Triangle {:?}: no NPC available for role '{}' — skipping triangle",
|
||
def.triangle_id, role_id.0
|
||
));
|
||
assignment_ok = false;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if !assignment_ok {
|
||
continue;
|
||
}
|
||
|
||
// Seed initial tension and rate from RNG
|
||
let initial_tension: u8 = rng.rng.random_range(5_u8..=25);
|
||
let tension_rate: u8 = rng.rng.random_range(1_u8..=5);
|
||
|
||
result.triangles.push(TriangleState {
|
||
triangle_id: def.triangle_id,
|
||
role_assignments,
|
||
tension: initial_tension,
|
||
phase: TrianglePhase::Simmering,
|
||
tension_rate,
|
||
template_id,
|
||
});
|
||
}
|
||
|
||
result
|
||
}
|
||
|
||
// ===========================================================================
|
||
// #250 — Triangle escalation system
|
||
// ===========================================================================
|
||
|
||
/// Event emitted when a triangle transitions from Simmering to Active.
|
||
///
|
||
/// Downstream systems (monologue, knowledge graph) can subscribe to this event
|
||
/// for narrative responses — wiring those subscribers is future work.
|
||
///
|
||
/// Spec: #250, D-087 (v0.1 triangle config), D-089 (self-contained, no cascade)
|
||
#[derive(Debug, Clone)]
|
||
pub struct TriangleCrisisEvent {
|
||
/// Which triangle entered crisis.
|
||
pub triangle_id: TriangleId,
|
||
/// NPC role assignments at the time of crisis.
|
||
pub role_assignments: BTreeMap<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 % TICKS_PER_GAME_MINUTE != 0 {
|
||
return;
|
||
}
|
||
|
||
for mut state in triangles.iter_mut() {
|
||
match state.phase {
|
||
TrianglePhase::Simmering => {
|
||
state.tension = state.tension.saturating_add(state.tension_rate);
|
||
|
||
// Find the lowest ToleranceThreshold among the triangle's NPCs.
|
||
// The NPC with the lowest threshold is the "weakest link" that
|
||
// triggers the crisis transition.
|
||
let mut min_threshold: Option<(i16, StableId)> = None;
|
||
for stable_id in state.role_assignments.values() {
|
||
if let Some(entity) = registry.to_entity(stable_id) {
|
||
if let Ok(tt) = thresholds.get(entity) {
|
||
match min_threshold {
|
||
None => min_threshold = Some((tt.threshold, *stable_id)),
|
||
Some((current_min, _)) if tt.threshold < current_min => {
|
||
min_threshold = Some((tt.threshold, *stable_id));
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if let Some((threshold, trigger_npc)) = min_threshold {
|
||
if i16::from(state.tension) > threshold {
|
||
state.phase = TrianglePhase::Active;
|
||
crisis_queue.push(TriangleCrisisEvent {
|
||
triangle_id: state.triangle_id,
|
||
role_assignments: state.role_assignments.clone(),
|
||
trigger_npc,
|
||
tick: time.tick,
|
||
});
|
||
tracing::info!(
|
||
"Triangle {:?}: Simmering → Active (tension={}, threshold={}, trigger={:?}) at tick {}",
|
||
state.triangle_id,
|
||
state.tension,
|
||
threshold,
|
||
trigger_npc,
|
||
time.tick
|
||
);
|
||
}
|
||
}
|
||
}
|
||
TrianglePhase::Active => {
|
||
// Continue incrementing for narrative tracking
|
||
state.tension = state.tension.saturating_add(state.tension_rate);
|
||
}
|
||
// Dormant and Resolved: no action
|
||
TrianglePhase::Dormant | TrianglePhase::Resolved => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// System: apply triangle resolution commands (#250, D-089).
|
||
///
|
||
/// Reads `ResolveTriangleQueue` and sets matching `TriangleState.phase` to
|
||
/// `Resolved`. D-089: resolution does not cascade to other triangles.
|
||
pub fn apply_resolve_triangle(
|
||
mut queue: ResMut<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.clone(), entity))
|
||
.collect();
|
||
|
||
for cmd in commands {
|
||
if let Some(&entity) = id_to_entity.get(&cmd.0) {
|
||
if let Ok((_, mut state)) = triangles.get_mut(entity) {
|
||
state.phase = TrianglePhase::Resolved;
|
||
tracing::info!(
|
||
"Triangle {:?}: resolved (D-089, no cascade)",
|
||
state.triangle_id
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Tests
|
||
// ===========================================================================
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
// -----------------------------------------------------------------------
|
||
// #163 — RoleSchema tests
|
||
// (YAML roundtrip, validation, duplicates covered by integration tests
|
||
// in tests/template_schema.rs — only unique tests here)
|
||
// -----------------------------------------------------------------------
|
||
|
||
// -----------------------------------------------------------------------
|
||
// #164 — SpaceSpec tests
|
||
// (YAML roundtrip, min>max covered by integration tests —
|
||
// zero_min and valid_passes are unique)
|
||
// -----------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn space_spec_validate_catches_min_gt_max() {
|
||
let spec = SpaceSpec {
|
||
tile_count_min: 100,
|
||
tile_count_max: 50,
|
||
sightline_zones: vec![],
|
||
privacy_level: PrivacyLevel::Public,
|
||
traffic_pattern: TrafficPattern::Thoroughfare,
|
||
};
|
||
|
||
assert!(spec.validate().is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn space_spec_validate_catches_zero_min() {
|
||
let spec = SpaceSpec {
|
||
tile_count_min: 0,
|
||
tile_count_max: 50,
|
||
sightline_zones: vec![],
|
||
privacy_level: PrivacyLevel::Public,
|
||
traffic_pattern: TrafficPattern::Thoroughfare,
|
||
};
|
||
|
||
assert!(spec.validate().is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn space_spec_valid_passes() {
|
||
let spec = SpaceSpec {
|
||
tile_count_min: 30,
|
||
tile_count_max: 80,
|
||
sightline_zones: vec![SightlineZone {
|
||
name: "main_area".into(),
|
||
radius: 6,
|
||
}],
|
||
privacy_level: PrivacyLevel::Private,
|
||
traffic_pattern: TrafficPattern::Restricted,
|
||
};
|
||
|
||
assert!(spec.validate().is_ok());
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// #165 — Single-ownership model tests
|
||
// (TemplateId determinism covered by integration tests —
|
||
// serialization roundtrips are unique)
|
||
// -----------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn template_ownership_serialize_roundtrip() {
|
||
let ownership = TemplateOwnership {
|
||
template_id: TemplateId::from_seed_and_slug(42, "cantina"),
|
||
role_id: RoleId::new("bartender"),
|
||
};
|
||
|
||
let bytes = rmp_serde::to_vec_named(&ownership).expect("serialize");
|
||
let recovered: TemplateOwnership = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||
assert_eq!(ownership, recovered);
|
||
}
|
||
|
||
#[test]
|
||
fn template_reference_map_add_and_query() {
|
||
let mut map = TemplateReferenceMap::default();
|
||
let t1 = TemplateId::from_seed_and_slug(1, "cantina");
|
||
let t2 = TemplateId::from_seed_and_slug(1, "dock_office");
|
||
|
||
map.add(TemplateReference {
|
||
from_template: t1,
|
||
to_template: t2,
|
||
via_role: RoleId::new("informant"),
|
||
relationship_metadata: RelationshipKind::Colleague,
|
||
});
|
||
|
||
assert_eq!(map.outgoing(t1).len(), 1);
|
||
assert_eq!(map.outgoing(t2).len(), 0);
|
||
assert_eq!(map.outgoing(t1)[0].to_template, t2);
|
||
}
|
||
|
||
#[test]
|
||
fn template_reference_map_serialize_roundtrip() {
|
||
let mut map = TemplateReferenceMap::default();
|
||
let t1 = TemplateId(100);
|
||
let t2 = TemplateId(200);
|
||
|
||
map.add(TemplateReference {
|
||
from_template: t1,
|
||
to_template: t2,
|
||
via_role: RoleId::new("courier"),
|
||
relationship_metadata: RelationshipKind::Friend,
|
||
});
|
||
|
||
let bytes = rmp_serde::to_vec_named(&map).expect("serialize");
|
||
let recovered: TemplateReferenceMap = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||
assert_eq!(map, recovered);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// #106 — Triangle definition schema tests
|
||
// (YAML roundtrip, validation, D-087 T1 covered by integration tests —
|
||
// order-independence and passive tension are unique)
|
||
// -----------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn triangle_id_deterministic_and_order_independent() {
|
||
let roles = [
|
||
RoleId::new("smuggler"),
|
||
RoleId::new("detective"),
|
||
RoleId::new("informant"),
|
||
];
|
||
let id1 = TriangleId::from_seed_and_roles(42, &roles);
|
||
|
||
let roles_reordered = [
|
||
RoleId::new("detective"),
|
||
RoleId::new("informant"),
|
||
RoleId::new("smuggler"),
|
||
];
|
||
let id2 = TriangleId::from_seed_and_roles(42, &roles_reordered);
|
||
assert_eq!(id1, id2);
|
||
|
||
let id3 = TriangleId::from_seed_and_roles(99, &roles);
|
||
assert_ne!(id1, id3);
|
||
}
|
||
|
||
#[test]
|
||
fn d087_passive_tension_expressible() {
|
||
let def = TriangleDef {
|
||
triangle_id: TriangleId(999),
|
||
roles: [
|
||
RoleId::new("worker_a"),
|
||
RoleId::new("worker_b"),
|
||
RoleId::new("supervisor"),
|
||
],
|
||
conflict_type: ConflictType::LatentTension,
|
||
interest_axes: [NpcAxis::Contentment, NpcAxis::Contentment, NpcAxis::Tolerance],
|
||
relationship_constraints: vec![],
|
||
};
|
||
|
||
assert!(def.validate().is_ok());
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// #107 — Intra-template triangle generation tests
|
||
// -----------------------------------------------------------------------
|
||
|
||
fn spawn_template_npc(
|
||
world: &mut World,
|
||
template_id: TemplateId,
|
||
role: &str,
|
||
stable_id: u64,
|
||
) -> bevy_ecs::entity::Entity {
|
||
world
|
||
.spawn((
|
||
crate::npc::Npc,
|
||
TemplateOwnership {
|
||
template_id,
|
||
role_id: RoleId::new(role),
|
||
},
|
||
StableEntityId(StableId(stable_id)),
|
||
))
|
||
.id()
|
||
}
|
||
|
||
#[test]
|
||
fn generate_triangles_basic_4_npc_template() {
|
||
let mut world = bevy_ecs::world::World::new();
|
||
let tid = TemplateId::from_seed_and_slug(42, "test-site");
|
||
let mut rng = SimRng::new(42);
|
||
|
||
// Spawn 4 NPCs
|
||
spawn_template_npc(&mut world, tid, "bartender", 1);
|
||
spawn_template_npc(&mut world, tid, "waitstaff", 2);
|
||
spawn_template_npc(&mut world, tid, "bouncer", 3);
|
||
spawn_template_npc(&mut world, tid, "regular", 4);
|
||
|
||
let defs = vec![
|
||
TriangleDef {
|
||
triangle_id: TriangleId::from_seed_and_roles(
|
||
42,
|
||
&[
|
||
RoleId::new("bartender"),
|
||
RoleId::new("waitstaff"),
|
||
RoleId::new("bouncer"),
|
||
],
|
||
),
|
||
roles: [
|
||
RoleId::new("bartender"),
|
||
RoleId::new("waitstaff"),
|
||
RoleId::new("bouncer"),
|
||
],
|
||
conflict_type: ConflictType::LoyaltyConflict,
|
||
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
|
||
relationship_constraints: vec![],
|
||
},
|
||
TriangleDef {
|
||
triangle_id: TriangleId::from_seed_and_roles(
|
||
42,
|
||
&[
|
||
RoleId::new("bartender"),
|
||
RoleId::new("bouncer"),
|
||
RoleId::new("regular"),
|
||
],
|
||
),
|
||
roles: [
|
||
RoleId::new("bartender"),
|
||
RoleId::new("bouncer"),
|
||
RoleId::new("regular"),
|
||
],
|
||
conflict_type: ConflictType::ResourceCompetition,
|
||
interest_axes: [NpcAxis::Want, NpcAxis::Tolerance, NpcAxis::Contentment],
|
||
relationship_constraints: vec![],
|
||
},
|
||
];
|
||
|
||
let result = generate_intra_template_triangles(&mut world, tid, &defs, &mut rng);
|
||
|
||
assert_eq!(result.triangles.len(), 2, "should generate 2 triangles");
|
||
assert!(result.warnings.is_empty(), "no warnings expected: {:?}", result.warnings);
|
||
|
||
// Verify role assignments
|
||
let t1 = &result.triangles[0];
|
||
assert_eq!(t1.role_assignments.len(), 3);
|
||
assert_eq!(t1.role_assignments[&RoleId::new("bartender")], StableId(1));
|
||
assert_eq!(t1.role_assignments[&RoleId::new("waitstaff")], StableId(2));
|
||
assert_eq!(t1.role_assignments[&RoleId::new("bouncer")], StableId(3));
|
||
assert_eq!(t1.phase, TrianglePhase::Simmering);
|
||
assert!(t1.tension >= 5 && t1.tension <= 25);
|
||
assert!(t1.tension_rate >= 1 && t1.tension_rate <= 5);
|
||
|
||
let t2 = &result.triangles[1];
|
||
assert_eq!(t2.role_assignments[&RoleId::new("regular")], StableId(4));
|
||
}
|
||
|
||
#[test]
|
||
fn generate_triangles_fallback_on_missing_role() {
|
||
let mut world = bevy_ecs::world::World::new();
|
||
let tid = TemplateId::from_seed_and_slug(42, "test-site");
|
||
let mut rng = SimRng::new(42);
|
||
|
||
// Only 2 NPCs but triangle needs 3 roles
|
||
spawn_template_npc(&mut world, tid, "bartender", 1);
|
||
spawn_template_npc(&mut world, tid, "bouncer", 2);
|
||
|
||
let defs = vec![
|
||
TriangleDef {
|
||
triangle_id: TriangleId(100),
|
||
roles: [
|
||
RoleId::new("bartender"),
|
||
RoleId::new("bouncer"),
|
||
RoleId::new("missing_role"), // no NPC has this role
|
||
],
|
||
conflict_type: ConflictType::SecretExposure,
|
||
interest_axes: [NpcAxis::Secret, NpcAxis::Secret, NpcAxis::Secret],
|
||
relationship_constraints: vec![],
|
||
},
|
||
TriangleDef {
|
||
triangle_id: TriangleId(101),
|
||
roles: [
|
||
RoleId::new("bartender"),
|
||
RoleId::new("bouncer"),
|
||
RoleId::new("also_missing"),
|
||
],
|
||
conflict_type: ConflictType::LatentTension,
|
||
interest_axes: [NpcAxis::Contentment, NpcAxis::Contentment, NpcAxis::Tolerance],
|
||
relationship_constraints: vec![],
|
||
},
|
||
];
|
||
|
||
let result = generate_intra_template_triangles(&mut world, tid, &defs, &mut rng);
|
||
|
||
// First triangle: bartender(1) + bouncer(2) assigned, missing_role gets fallback
|
||
// But all NPCs are already used, so it should skip
|
||
// Actually: bartender(1) assigned, bouncer(2) assigned, missing_role needs fallback
|
||
// from remaining NPCs not already in this triangle's assignments.
|
||
// Both 1 and 2 are taken, so no fallback available → skip.
|
||
// Wait, let me re-check the logic... the fallback finds any NPC not already assigned
|
||
// to THIS triangle's role_assignments. After bartender(1) and bouncer(2),
|
||
// the remaining NPCs that aren't in role_assignments are... none (only 2 NPCs total).
|
||
// So this triangle gets skipped.
|
||
|
||
// Actually with only 2 NPCs, both are already assigned before we need a 3rd.
|
||
// The triangle should be skipped with a warning.
|
||
assert!(!result.warnings.is_empty(), "should have warnings about missing roles");
|
||
}
|
||
|
||
#[test]
|
||
fn generate_triangles_fewer_than_2_defs_logs_error() {
|
||
// This test just verifies it doesn't panic — the error is logged.
|
||
let mut world = bevy_ecs::world::World::new();
|
||
let tid = TemplateId::from_seed_and_slug(42, "test-site");
|
||
let mut rng = SimRng::new(42);
|
||
|
||
spawn_template_npc(&mut world, tid, "bartender", 1);
|
||
spawn_template_npc(&mut world, tid, "bouncer", 2);
|
||
spawn_template_npc(&mut world, tid, "regular", 3);
|
||
|
||
let defs = vec![TriangleDef {
|
||
triangle_id: TriangleId(200),
|
||
roles: [
|
||
RoleId::new("bartender"),
|
||
RoleId::new("bouncer"),
|
||
RoleId::new("regular"),
|
||
],
|
||
conflict_type: ConflictType::ResourceCompetition,
|
||
interest_axes: [NpcAxis::Want, NpcAxis::Want, NpcAxis::Want],
|
||
relationship_constraints: vec![],
|
||
}];
|
||
|
||
// Should succeed with 1 triangle but log an error about < 2
|
||
let result = generate_intra_template_triangles(&mut world, tid, &defs, &mut rng);
|
||
assert_eq!(result.triangles.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn generate_triangles_deterministic() {
|
||
let tid = TemplateId::from_seed_and_slug(42, "test-site");
|
||
|
||
let defs = vec![
|
||
TriangleDef {
|
||
triangle_id: TriangleId(300),
|
||
roles: [
|
||
RoleId::new("a"),
|
||
RoleId::new("b"),
|
||
RoleId::new("c"),
|
||
],
|
||
conflict_type: ConflictType::LoyaltyConflict,
|
||
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
|
||
relationship_constraints: vec![],
|
||
},
|
||
TriangleDef {
|
||
triangle_id: TriangleId(301),
|
||
roles: [
|
||
RoleId::new("a"),
|
||
RoleId::new("c"),
|
||
RoleId::new("d"),
|
||
],
|
||
conflict_type: ConflictType::LatentTension,
|
||
interest_axes: [NpcAxis::Contentment, NpcAxis::Contentment, NpcAxis::Tolerance],
|
||
relationship_constraints: vec![],
|
||
},
|
||
];
|
||
|
||
// Run twice with same seed
|
||
let mut world1 = bevy_ecs::world::World::new();
|
||
spawn_template_npc(&mut world1, tid, "a", 1);
|
||
spawn_template_npc(&mut world1, tid, "b", 2);
|
||
spawn_template_npc(&mut world1, tid, "c", 3);
|
||
spawn_template_npc(&mut world1, tid, "d", 4);
|
||
let mut rng1 = SimRng::new(99);
|
||
let result1 = generate_intra_template_triangles(&mut world1, tid, &defs, &mut rng1);
|
||
|
||
let mut world2 = bevy_ecs::world::World::new();
|
||
spawn_template_npc(&mut world2, tid, "a", 1);
|
||
spawn_template_npc(&mut world2, tid, "b", 2);
|
||
spawn_template_npc(&mut world2, tid, "c", 3);
|
||
spawn_template_npc(&mut world2, tid, "d", 4);
|
||
let mut rng2 = SimRng::new(99);
|
||
let result2 = generate_intra_template_triangles(&mut world2, tid, &defs, &mut rng2);
|
||
|
||
assert_eq!(result1.triangles.len(), result2.triangles.len());
|
||
for (t1, t2) in result1.triangles.iter().zip(result2.triangles.iter()) {
|
||
assert_eq!(t1.tension, t2.tension, "tension must be deterministic");
|
||
assert_eq!(t1.tension_rate, t2.tension_rate, "tension_rate must be deterministic");
|
||
assert_eq!(t1.role_assignments, t2.role_assignments);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn triangle_state_serialize_roundtrip() {
|
||
let state = TriangleState {
|
||
triangle_id: TriangleId(42),
|
||
role_assignments: {
|
||
let mut m = BTreeMap::new();
|
||
m.insert(RoleId::new("a"), StableId(1));
|
||
m.insert(RoleId::new("b"), StableId(2));
|
||
m.insert(RoleId::new("c"), StableId(3));
|
||
m
|
||
},
|
||
tension: 15,
|
||
phase: TrianglePhase::Simmering,
|
||
tension_rate: 3,
|
||
template_id: TemplateId(100),
|
||
};
|
||
|
||
let bytes = rmp_serde::to_vec_named(&state).expect("serialize");
|
||
let recovered: TriangleState = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||
assert_eq!(state, recovered);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// #250 — Triangle escalation system tests
|
||
// -----------------------------------------------------------------------
|
||
|
||
use crate::npc::Npc;
|
||
use crate::simulation::time::SimulationTime;
|
||
use bevy_ecs::schedule::Schedule;
|
||
|
||
/// Helper: set up a world with all resources needed for escalation tests.
|
||
fn setup_escalation_world() -> bevy_ecs::world::World {
|
||
let mut world = bevy_ecs::world::World::new();
|
||
world.init_resource::<SimulationTime>();
|
||
world.init_resource::<TriangleCrisisEventQueue>();
|
||
world.init_resource::<EntityRegistry>();
|
||
world
|
||
}
|
||
|
||
/// Helper: spawn an NPC with a known StableId and ToleranceThreshold,
|
||
/// and register it in the EntityRegistry.
|
||
fn spawn_escalation_npc(
|
||
world: &mut bevy_ecs::world::World,
|
||
stable_id: u64,
|
||
threshold: i16,
|
||
) -> bevy_ecs::entity::Entity {
|
||
let sid = StableId(stable_id);
|
||
let entity = world
|
||
.spawn((
|
||
Npc,
|
||
ActiveSim,
|
||
StableEntityId(sid),
|
||
ToleranceThreshold {
|
||
current_stress: 0,
|
||
threshold,
|
||
},
|
||
))
|
||
.id();
|
||
|
||
world
|
||
.resource_mut::<EntityRegistry>()
|
||
.register_existing(entity, sid);
|
||
|
||
entity
|
||
}
|
||
|
||
// escalation_simmering_to_active, resolve, dormant_skip, resolved_skip,
|
||
// game-minute-only, active-sim-only, saturation, resolve-targeting — all
|
||
// covered by integration tests in tests/triangle_escalation.rs.
|
||
// Only active_triangle_continues_incrementing is unique here.
|
||
|
||
#[test]
|
||
fn active_triangle_continues_incrementing() {
|
||
let mut world = setup_escalation_world();
|
||
|
||
let triangle = world
|
||
.spawn((
|
||
TriangleState {
|
||
triangle_id: TriangleId(100),
|
||
role_assignments: BTreeMap::new(),
|
||
tension: 50,
|
||
phase: TrianglePhase::Active,
|
||
tension_rate: 5,
|
||
template_id: TemplateId(1),
|
||
},
|
||
ActiveSim,
|
||
))
|
||
.id();
|
||
|
||
let mut schedule = Schedule::default();
|
||
schedule.add_systems(tick_triangle_escalation);
|
||
|
||
// Run at tick 10 and 20
|
||
world.resource_mut::<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),
|
||
},
|
||
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),
|
||
})
|
||
.id();
|
||
|
||
let bystander = world
|
||
.spawn(TriangleState {
|
||
triangle_id: TriangleId(200),
|
||
role_assignments: BTreeMap::new(),
|
||
tension: 30,
|
||
phase: TrianglePhase::Simmering,
|
||
tension_rate: 2,
|
||
template_id: TemplateId(1),
|
||
})
|
||
.id();
|
||
|
||
// Resolve only triangle 100
|
||
world
|
||
.resource_mut::<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)"
|
||
);
|
||
}
|
||
}
|