Files
settled-reach/server/src/content/template.rs
T
jpmschweitzerandClaude Opus 4.6 d808d95659 refactor(simulation): address PR #72 review suggestions
- 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>
2026-02-25 23:35:23 +01:00

1380 lines
50 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Social site template schema types (#163, #164, #165, #106, #250).
//!
//! This module defines the Tier 2 template system — the foundational schema for
//! social sites (D-025). Templates describe the role structure, spatial layout,
//! ownership model, and triangle conflict patterns for a functional cluster of
//! 48 NPCs in a 1540 visual tile space.
//!
//! ## Architecture
//!
//! Content-side types (YAML deserialization): `RoleSchema`, `SpaceSpec`, `TriangleDef`
//! ECS-side types (runtime components/resources): `TemplateOwnership`, `TemplateReferenceMap`
//!
//! The content types are authored in YAML at `server/data/templates/` and consumed
//! by the template instantiation pipeline. The ECS types are assigned at spawn time
//! and persisted across save/load and tier transitions.
//!
//! ## Escalation (#250)
//!
//! `tick_triangle_escalation` runs once per game-minute (D-031) and increments
//! tension on Simmering/Active triangles. When tension exceeds the lowest
//! `ToleranceThreshold` among the triangle's NPCs, the triangle transitions
//! from Simmering → Active and a `TriangleCrisisEvent` is emitted.
//!
//! ## Determinism (D-010)
//!
//! - `TemplateId` and `TriangleId` use FNV-1a hashing for deterministic generation
//! from seed + slug. Never use `std::hash::DefaultHasher` (non-deterministic).
//! - All collections use `BTreeMap` / `Vec` (no `HashMap`).
use bevy_ecs::prelude::*;
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use crate::knowledge::registry::StableEntityId;
use crate::knowledge::types::StableId;
use crate::knowledge::EntityRegistry;
use crate::npc::{PersonalityTrait, RelationshipKind, Skill, ToleranceThreshold};
use crate::simulation::rng::SimRng;
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE};
// ===========================================================================
// #163 — Role definition schema
// ===========================================================================
/// Stable role identifier within a template.
///
/// A string slug (e.g. "bartender", "dock-worker") that is stable across
/// save/load. Not a bevy `Entity` — serializes cleanly via serde.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct RoleId(pub String);
impl RoleId {
pub fn new(id: &str) -> Self {
RoleId(id.to_string())
}
}
impl From<RoleId> for String {
fn from(id: RoleId) -> Self {
id.0
}
}
/// Trust range constraint for a relationship.
/// Both bounds are inclusive: the generated trust value must be in `[min, max]`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrustRange {
pub min: i8,
pub max: i8,
}
/// Constraint on a relationship that must exist within the same template.
///
/// Example: the "bartender" role must have a `Colleague` relationship with the
/// "waitstaff" role at trust level 25.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RelationshipConstraint {
/// The other role this relationship targets (must exist in the same template).
pub with_role: RoleId,
/// Required relationship kind.
pub kind: RelationshipKind,
/// Required trust range for the generated relationship.
pub required_trust: TrustRange,
}
/// A routine entry template: phase → location name mapping.
///
/// Phase is a string slug (e.g. "morning", "evening") resolved to `DayPhase`
/// during template instantiation. Location names are resolved to tile positions.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TemplateRoutineEntry {
pub phase: String,
pub location: String,
#[serde(default)]
pub activity: Option<String>,
}
/// Tier 2 role schema — constraints fed into the NPC generator (D-024).
///
/// Distinct from `RoleDefinition` in `npc/generate.rs`: `RoleSchema` is the
/// authored content specification; `RoleDefinition` is the runtime builder
/// derived from it during template instantiation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoleSchema {
/// Unique role identifier within the template.
pub role_id: RoleId,
/// Personality traits that NPCs filling this role should have.
#[serde(default)]
pub required_traits: Vec<PersonalityTrait>,
/// Skills biased toward for this role.
#[serde(default)]
pub skill_focus: Vec<Skill>,
/// Relationship constraints with other roles in the same template.
#[serde(default)]
pub relationship_constraints: Vec<RelationshipConstraint>,
/// Routine template: phase → location name mappings.
#[serde(default)]
pub routine_template: Vec<TemplateRoutineEntry>,
}
impl RoleSchema {
/// Validate this role schema.
///
/// Checks:
/// - No self-referential relationship constraints (with_role != role_id).
/// - Trust range min <= max.
///
/// Returns `Err` with a description of the first validation failure.
pub fn validate(&self) -> Result<(), String> {
for (i, constraint) in self.relationship_constraints.iter().enumerate() {
if constraint.with_role == self.role_id {
return Err(format!(
"relationship_constraints[{}]: self-referential constraint (with_role == role_id '{}')",
i, self.role_id.0
));
}
if constraint.required_trust.min > constraint.required_trust.max {
return Err(format!(
"relationship_constraints[{}]: trust min ({}) > max ({})",
i, constraint.required_trust.min, constraint.required_trust.max
));
}
}
Ok(())
}
}
/// Validate that a collection of `RoleSchema`s contains no duplicate `role_id`s.
///
/// Returns `Err` naming the first duplicate found.
pub fn validate_role_schemas_no_duplicate_ids(schemas: &[RoleSchema]) -> Result<(), String> {
let mut seen = std::collections::BTreeSet::new();
for schema in schemas {
if !seen.insert(&schema.role_id) {
return Err(format!("duplicate role_id: '{}'", schema.role_id.0));
}
}
Ok(())
}
// ===========================================================================
// #164 — Spatial requirement specification
// ===========================================================================
/// Named sub-area with a sightline coverage radius.
///
/// Radius is in sim tiles (0.5m each per D-066). Example: `radius: 4` = 2m
/// clear sightline from the zone center.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SightlineZone {
pub name: String,
/// Coverage radius in sim tiles (0.5m each). 4 sim tiles = 2m.
pub radius: u32,
}
/// Privacy level governing NPC disclosure behavior.
///
/// NPCs are less likely to disclose secrets in `Public` spaces.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PrivacyLevel {
Public,
SemiPrivate,
Private,
}
/// Traffic pattern governing procedural NPC routine routing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TrafficPattern {
/// High-traffic corridor — many NPCs route through.
Thoroughfare,
/// Destination point — NPCs travel TO this space, don't pass through.
Destination,
/// Limited access — only assigned NPCs enter.
Restricted,
}
/// Spatial requirement specification for a template.
///
/// Tile counts are in **sim tiles** (0.5m each per D-066).
/// A 1540 visual tile space (per D-025) = 3080 sim tiles.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpaceSpec {
/// Minimum tile count in sim tiles (0.5m each).
pub tile_count_min: u32,
/// Maximum tile count in sim tiles (0.5m each).
pub tile_count_max: u32,
/// Named sightline zones within this space.
#[serde(default)]
pub sightline_zones: Vec<SightlineZone>,
/// Privacy level for NPC behavior modulation.
pub privacy_level: PrivacyLevel,
/// Traffic pattern for routine routing.
pub traffic_pattern: TrafficPattern,
}
impl SpaceSpec {
/// Validate this space spec.
///
/// Checks:
/// - tile_count_min <= tile_count_max.
/// - tile_count_min > 0.
///
/// Returns `Err` with a description of the first validation failure.
pub fn validate(&self) -> Result<(), String> {
if self.tile_count_min == 0 {
return Err("tile_count_min must be > 0".to_string());
}
if self.tile_count_min > self.tile_count_max {
return Err(format!(
"tile_count_min ({}) > tile_count_max ({})",
self.tile_count_min, self.tile_count_max
));
}
Ok(())
}
}
// ===========================================================================
// #165 — Single-ownership model
// ===========================================================================
/// Deterministic template identifier.
///
/// Generated from world seed + template slug via FNV-1a hash. Stable across
/// save/load — never derived from bevy `Entity` handles.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct TemplateId(pub u64);
impl TemplateId {
/// Compute a deterministic `TemplateId` from a world seed and template slug.
///
/// Uses FNV-1a (64-bit) for determinism — `std::hash::DefaultHasher` is
/// prohibited by D-010 principle 4 (non-deterministic across Rust versions).
pub fn from_seed_and_slug(seed: u64, slug: &str) -> Self {
let mut hash = seed ^ 0xcbf29ce484222325; // FNV-1a offset basis, XOR'd with seed
for byte in slug.as_bytes() {
hash ^= *byte as u64;
hash = hash.wrapping_mul(0x100000001b3); // FNV-1a prime
}
TemplateId(hash)
}
}
/// ECS component: which template owns this NPC and which role it fills.
///
/// Assigned at template instantiation, **never reassigned** (D-025 single-ownership).
/// Preserved across tier transitions and save/load.
#[derive(Component, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TemplateOwnership {
pub template_id: TemplateId,
pub role_id: RoleId,
}
/// A cross-template reference link.
///
/// Records that a role in one template has a relationship with a role in
/// another template. Preserved when templates are unloaded (tier eviction)
/// so the social web metadata survives even when NPCs aren't in Active tier.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TemplateReference {
pub from_template: TemplateId,
pub to_template: TemplateId,
pub via_role: RoleId,
pub relationship_metadata: RelationshipKind,
}
/// Resource: all cross-template reference links, indexed by source template.
///
/// Uses `BTreeMap` for deterministic iteration (D-010).
/// Preserved across save/load — inserted into `SaveStateV1`.
#[derive(Resource, Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TemplateReferenceMap {
pub entries: BTreeMap<TemplateId, Vec<TemplateReference>>,
}
impl TemplateReferenceMap {
/// Add a reference link. Appends to the entry list for `ref_link.from_template`.
pub fn add(&mut self, ref_link: TemplateReference) {
self.entries
.entry(ref_link.from_template)
.or_default()
.push(ref_link);
}
/// Get all outgoing references from a template.
pub fn outgoing(&self, template_id: TemplateId) -> &[TemplateReference] {
self.entries
.get(&template_id)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
/// Iterate over all references across all templates.
/// Iteration order is deterministic (BTreeMap key ordering).
pub fn all_references(&self) -> impl Iterator<Item = &TemplateReference> {
self.entries.values().flat_map(|v| v.iter())
}
}
// ===========================================================================
// #106 — Triangle definition schema
// ===========================================================================
/// Deterministic triangle identifier.
///
/// Generated from template seed + sorted role triple via FNV-1a hash.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct TriangleId(pub u64);
impl TriangleId {
/// Compute a deterministic `TriangleId` from a seed and 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 (0255). Starts at a seeded value.
pub tension: u8,
/// Current lifecycle phase.
pub phase: TrianglePhase,
/// Per-tick tension increment rate, seeded at world-gen time.
/// Stored here so escalation system doesn't need to recompute.
pub tension_rate: u8,
/// Which template owns this triangle.
pub template_id: TemplateId,
}
/// 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)"
);
}
}