From 42febf4059e34ef041a905f55cb64715ad371c7b Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 28 Feb 2026 16:34:36 +0100 Subject: [PATCH] feat(simulation): wire NPC pool generation and authored triangle instantiation (#176, #188) Production startup now spawns 23 Sova NPCs with EntanglementTag (Flat/Intrigue) based on triangle_membership. Three-phase spawn: entity creation, cross-reference resolution, and authored triangle instantiation. Five triangles (3 ActiveFork, 2 PassiveTension per D-087) with deterministic IDs via FNV-1a hashing. Co-Authored-By: Claude Opus 4.6 --- .../transit/triangles/worried-knowledge.yaml | 5 +- .../transit/triangles/worried-partner.yaml | 5 +- server/src/content/spawn.rs | 118 ++++++++++- server/src/content/template.rs | 37 ++++ server/src/content/types.rs | 3 + server/src/npc/mod.rs | 19 ++ server/tests/content_loading.rs | 192 ++++++++++++++++++ server/tests/triangle_escalation.rs | 12 +- 8 files changed, 381 insertions(+), 10 deletions(-) diff --git a/content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/worried-knowledge.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/worried-knowledge.yaml index 77745045c..76a5d6034 100644 --- a/content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/worried-knowledge.yaml +++ b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/worried-knowledge.yaml @@ -1,7 +1,8 @@ -# Triangle 2: Worried Knowledge — unreported evidence and protective silence -# Active fork — Sera's choice about what to do with what she knows +# Triangle 3: Worried Knowledge — unreported evidence and protective silence +# Passive tension (D-087) — background pressure, not a direct player decision point canonical_id: worried-knowledge display_name: "Worried Knowledge" +classification: passive_tension description: > Sera Venn (Commission field tech, detective's FRIEND) is sitting on unreported evidence about Kael's manifest discrepancies. She's protecting diff --git a/content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/worried-partner.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/worried-partner.yaml index 2a2d31e2f..2a38e23dc 100644 --- a/content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/worried-partner.yaml +++ b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/worried-partner.yaml @@ -1,7 +1,8 @@ -# Triangle 4: Worried Partner — ring pressure on personal relationships -# Active fork — the emotional heart of the smuggler's FRIEND arc +# Triangle 5: Worried Partner — ring pressure on personal relationships +# Passive tension (D-087) — background pressure, not a direct player decision point canonical_id: worried-partner display_name: "Worried Partner" +classification: passive_tension description: > Kael Davan (dock worker, ring member, smuggler's FRIEND) is under increasing ring pressure from Devra (ring coordinator). Naia Tamm diff --git a/server/src/content/spawn.rs b/server/src/content/spawn.rs index 7b63e3400..bb9fd7bed 100644 --- a/server/src/content/spawn.rs +++ b/server/src/content/spawn.rs @@ -1,4 +1,4 @@ -//! Content → ECS entity spawning (two-phase). +//! Content → ECS entity spawning (three-phase). //! //! Maps intermediate content types from the loader into bevy_ecs //! Components and Resources. The separation ensures content schema @@ -38,6 +38,7 @@ use crate::simulation::time::DayPhase; use rand::Rng as _; use crate::content::template::{ FullTemplateDef, RoleId, TemplateId, TemplateOwnership, TemplateReference, TemplateReferenceMap, + TriangleClassification, TriangleId, TrianglePhase, TriangleState, }; use crate::npc::generate::{generate_npc, RoleDefinition}; use crate::npc::{Relationship, Relationships}; @@ -68,7 +69,8 @@ pub struct SpawnResult { /// Spawn all loaded content into the ECS world. /// /// This is the main entry point for content → ECS conversion. -/// Runs Phase 1 (entity spawning) then Phase 2 (cross-reference resolution). +/// Runs Phase 1 (entity spawning), Phase 2 (cross-reference resolution), +/// then Phase 3 (authored triangle instantiation, #188). pub fn spawn_content(world: &mut World, store: &ContentStore) -> SpawnResult { // Phase 1: spawn entities and build canonical_id → StableId map let result = spawn_entities(world, store); @@ -76,9 +78,13 @@ pub fn spawn_content(world: &mut World, store: &ContentStore) -> SpawnResult { // Phase 2: resolve cross-references using the id map resolve_cross_references(world, store, &result); + // Phase 3: instantiate authored triangles (#188, D-087) + let triangles_spawned = instantiate_authored_triangles(world, store, &result); + tracing::info!( - "Content spawn complete: {} NPCs (phase 1), cross-references resolved (phase 2)", - result.npcs_spawned + "Content spawn complete: {} NPCs (phase 1), cross-references resolved (phase 2), {} triangles (phase 3)", + result.npcs_spawned, + triangles_spawned ); result @@ -214,6 +220,16 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR // TODO: CombatCapability — no content schema type exists yet. When combat content // is authored, add weapon_proficiency + combat_style mapping here. + // Entanglement tag (D-029, #176) — derived from authored triangle_membership. + // Non-empty triangle_membership → Intrigue (active narrative participant). + // Empty → Flat (background population). Mundane reserved for procedural NPCs. + let entanglement = if profile.triangle_membership.is_empty() { + npc::EntanglementTag::Flat + } else { + npc::EntanglementTag::Intrigue + }; + entity_commands.insert(entanglement); + // Vision + awareness components (#115, #244) — must match generate_npc(). // Without these, vision/awareness systems silently skip content-spawned NPCs. entity_commands.insert(( @@ -535,6 +551,100 @@ fn resolve_routines( tracing::debug!("Resolved routines for {} NPCs", routines_resolved); } +// =========================================================================== +// Phase 3: Authored triangle instantiation (#188, D-087) +// =========================================================================== + +/// Phase 3: Instantiate authored triangles from content YAML. +/// +/// Authored triangles (triangles/*.yaml) define fixed narrative structures +/// with NPC members referenced by canonical_id. Unlike template triangles +/// (generated from TriangleDef role triples), authored triangles carry +/// a canonical slug, explicit member assignments, and D-087 classification. +/// +/// Returns the number of triangles successfully spawned. +fn instantiate_authored_triangles( + world: &mut World, + store: &ContentStore, + result: &SpawnResult, +) -> u32 { + let mut count = 0u32; + + // Sentinel template_id for authored (non-template) triangles. + let authored_template_id = TemplateId::from_seed_and_slug(0, "authored"); + + for (district_id, content) in &store.districts { + for triangle in &content.triangles { + // Resolve NPC member references to StableIds. + // YAML uses "npc:kael-davan" — matches NpcProfile.canonical_id directly. + let mut role_assignments = BTreeMap::new(); + let mut all_resolved = true; + + for member in &triangle.members { + let Some(&stable_id) = result.npc_ids.get(&member.npc) else { + tracing::warn!( + "Triangle '{}' in district '{}': cannot resolve member '{}' — NPC not in npc_ids map", + triangle.canonical_id, + district_id, + member.npc, + ); + all_resolved = false; + break; + }; + + role_assignments.insert(RoleId::new(&member.role), stable_id); + } + + if !all_resolved { + continue; + } + + // Determine D-087 classification from YAML field. + let classification = match triangle.classification.as_deref() { + Some("passive_tension") => TriangleClassification::PassiveTension, + _ => TriangleClassification::ActiveFork, + }; + + // Initial phase: ActiveFork starts Simmering (tension building), + // PassiveTension starts Dormant (background, awaiting conditions). + let phase = match classification { + TriangleClassification::ActiveFork => TrianglePhase::Simmering, + TriangleClassification::PassiveTension => TrianglePhase::Dormant, + }; + + // Deterministic TriangleId from canonical_id slug (D-010). + let triangle_id = TriangleId::from_seed_and_slug(0, &triangle.canonical_id); + + let state = TriangleState { + triangle_id, + role_assignments, + tension: 0, + phase, + tension_rate: 1, + template_id: authored_template_id, + classification, + }; + + world.spawn((state, ActiveSim)); + count += 1; + + tracing::debug!( + "Spawned authored triangle: {} ({:?}, {:?})", + triangle.canonical_id, + classification, + phase, + ); + } + } + + tracing::info!( + "Phase 3 complete: {} authored triangles spawned", + count + ); + + count +} + // --------------------------------------------------------------------------- // Content value → ECS enum mapping functions // --------------------------------------------------------------------------- diff --git a/server/src/content/template.rs b/server/src/content/template.rs index 95eab01fa..622704f23 100644 --- a/server/src/content/template.rs +++ b/server/src/content/template.rs @@ -332,6 +332,19 @@ impl TemplateReferenceMap { pub struct TriangleId(pub u64); impl TriangleId { + /// Compute a deterministic `TriangleId` from a seed and a canonical slug. + /// + /// Used for authored triangles loaded from content YAML (#188). + /// Mirrors `TemplateId::from_seed_and_slug` — same FNV-1a pattern (D-010). + pub fn from_seed_and_slug(seed: u64, slug: &str) -> Self { + let mut hash = seed ^ 0xcbf29ce484222325; // FNV-1a offset basis, XOR'd with seed + for byte in slug.as_bytes() { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x100000001b3); // FNV-1a prime + } + TriangleId(hash) + } + /// Compute a deterministic `TriangleId` from a seed and three role IDs. /// /// Roles are sorted before hashing to ensure the same triple always produces @@ -591,6 +604,20 @@ impl FullTemplateDef { // #107 — Intra-template triangle generation // =========================================================================== +/// Narrative classification of a triangle (D-087, #188). +/// +/// Active forks drive narrative conflict — the player's decisions directly +/// affect outcomes. Passive tensions provide background pressure — observable +/// behavioral signals without a direct player decision point. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum TriangleClassification { + /// Drives narrative conflict — player decisions affect outcomes (D-087). + #[default] + ActiveFork, + /// Background tension — observable tells without direct decision point. + PassiveTension, +} + /// Phase of a triangle's lifecycle. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum TrianglePhase { @@ -624,6 +651,9 @@ pub struct TriangleState { pub tension_rate: u8, /// Which template owns this triangle. pub template_id: TemplateId, + /// Narrative classification (D-087, #188): active fork vs passive tension. + #[serde(default)] + pub classification: TriangleClassification, } /// Result of triangle generation for a single template. @@ -745,6 +775,7 @@ pub fn generate_intra_template_triangles( phase: TrianglePhase::Simmering, tension_rate, template_id, + classification: TriangleClassification::ActiveFork, }); } @@ -970,6 +1001,7 @@ pub fn generate_cross_template_triangles( phase: TrianglePhase::Simmering, tension_rate, template_id: template_a_id, // cross-template triangle owned by template_a + classification: TriangleClassification::ActiveFork, }); } @@ -1591,6 +1623,7 @@ mod tests { phase: TrianglePhase::Simmering, tension_rate: 3, template_id: TemplateId(100), + classification: TriangleClassification::ActiveFork, }; let bytes = rmp_serde::to_vec_named(&state).expect("serialize"); @@ -1632,6 +1665,7 @@ mod tests { phase: TrianglePhase::Active, tension_rate: 5, template_id: TemplateId(1), + classification: TriangleClassification::ActiveFork, }, ActiveSim, )) @@ -1676,6 +1710,7 @@ mod tests { phase: TrianglePhase::Active, tension_rate: 5, template_id: TemplateId(1), + classification: TriangleClassification::ActiveFork, }, ActiveSim, )) @@ -1702,6 +1737,7 @@ mod tests { phase: TrianglePhase::Active, tension_rate: 3, template_id: TemplateId(1), + classification: TriangleClassification::ActiveFork, }) .id(); @@ -1713,6 +1749,7 @@ mod tests { phase: TrianglePhase::Simmering, tension_rate: 2, template_id: TemplateId(1), + classification: TriangleClassification::ActiveFork, }) .id(); diff --git a/server/src/content/types.rs b/server/src/content/types.rs index 50b9e6c7a..695ced368 100644 --- a/server/src/content/types.rs +++ b/server/src/content/types.rs @@ -196,6 +196,9 @@ pub struct Triangle { pub forks: Vec, #[serde(default)] pub resolution_states: Vec, + /// D-087 classification: "active_fork" (default) or "passive_tension". + #[serde(default)] + pub classification: Option, } #[derive(Debug, Deserialize)] diff --git a/server/src/npc/mod.rs b/server/src/npc/mod.rs index 9df5d6087..e9e4be756 100644 --- a/server/src/npc/mod.rs +++ b/server/src/npc/mod.rs @@ -152,6 +152,25 @@ pub enum DeviationTrigger { Confrontation, } +// --------------------------------------------------------------------------- +// Entanglement tag (D-029, #176) +// --------------------------------------------------------------------------- + +/// Marks an NPC's narrative entanglement level (D-029). +/// +/// - `Flat`: background population — no triangle involvement, minimal story role. +/// - `Mundane`: has routines and personality but no active triangle membership. +/// - `Intrigue`: participates in at least one triangle — drives narrative tension. +/// +/// For authored NPCs: determined by YAML `triangle_membership` field. +/// For procedural NPCs: assigned by the 30/50/20 ratio via SimRng. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum EntanglementTag { + Flat, + Mundane, + Intrigue, +} + // --------------------------------------------------------------------------- // Axis 1: Want (D-024) // --------------------------------------------------------------------------- diff --git a/server/tests/content_loading.rs b/server/tests/content_loading.rs index bedfbd9bf..b93416764 100644 --- a/server/tests/content_loading.rs +++ b/server/tests/content_loading.rs @@ -421,5 +421,197 @@ fn spawn_real_content_with_relationships_and_secrets() { assert_eq!(nils_want.primary, npc::WantKind::Power); } +// ----------------------------------------------------------------------- +// Test: EntanglementTag assignment from authored content (#176, D-029) +// ----------------------------------------------------------------------- + +#[test] +fn entanglement_tags_assigned_from_triangle_membership() { + let root = content_root(); + if !root.join("content.yaml").exists() { + return; + } + + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + + let store = load_content(&root).expect("content loading should succeed"); + let result = spawn_content(&mut world, &store); + + // Acceptance: 17+ NPCs spawn (we have 23 authored profiles) + assert!( + result.npcs_spawned >= 17, + "Expected 17+ NPCs, got {}", + result.npcs_spawned + ); + + let registry = world.resource::(); + + // Every NPC must have an EntanglementTag + let mut intrigue_count = 0u32; + let mut flat_count = 0u32; + for (canonical_id, stable_id) in &result.npc_ids { + let entity = registry + .to_entity(stable_id) + .unwrap_or_else(|| panic!("{} should have an entity", canonical_id)); + let tag = world + .get::(entity) + .unwrap_or_else(|| panic!("{} must have EntanglementTag", canonical_id)); + match tag { + npc::EntanglementTag::Intrigue => intrigue_count += 1, + npc::EntanglementTag::Flat => flat_count += 1, + npc::EntanglementTag::Mundane => {} // reserved for procedural NPCs + } + } + + // Acceptance: at least one EntanglementTag::Intrigue entity + assert!( + intrigue_count >= 1, + "At least one NPC must be EntanglementTag::Intrigue, got 0" + ); + + // Stronger assertion: we know 13 authored NPCs have non-empty triangle_membership + assert!( + intrigue_count >= 10, + "Expected 10+ Intrigue NPCs (authored triangle members), got {}", + intrigue_count + ); + + // Some NPCs should be Flat (no triangle membership) + assert!( + flat_count >= 1, + "At least one NPC should be EntanglementTag::Flat, got 0" + ); + + // Spot-check: Kael (triangle member) must be Intrigue + let kael_entity = registry + .to_entity(&result.npc_ids["npc:kael-davan"]) + .unwrap(); + assert_eq!( + *world.get::(kael_entity).unwrap(), + npc::EntanglementTag::Intrigue, + "Kael (triangle member) must be Intrigue" + ); + + // Spot-check: Devra (empty triangle_membership) must be Flat + let devra_entity = registry + .to_entity(&result.npc_ids["npc:devra"]) + .unwrap(); + assert_eq!( + *world.get::(devra_entity).unwrap(), + npc::EntanglementTag::Flat, + "Devra (no triangle membership) must be Flat" + ); +} + +// ----------------------------------------------------------------------- +// Test: Authored triangle instantiation (#188, D-087) +// ----------------------------------------------------------------------- + +#[test] +fn authored_triangles_instantiated_from_content() { + use settled_reach_server::content::template::{TriangleClassification, TriangleState}; + use settled_reach_server::simulation::tier::ActiveSim; + + let root = content_root(); + if !root.join("content.yaml").exists() { + return; + } + + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + + let store = load_content(&root).expect("content loading should succeed"); + let result = spawn_content(&mut world, &store); + + // Query all TriangleState entities (clone to release world borrow) + let triangles: Vec = { + let mut q = world.query::<&TriangleState>(); + q.iter(&world).cloned().collect() + }; + + // Acceptance: exactly 5 authored triangles + assert_eq!( + triangles.len(), + 5, + "Expected 5 authored triangles, got {}", + triangles.len() + ); + + // Count by classification (D-087) + let active_count = triangles + .iter() + .filter(|t| t.classification == TriangleClassification::ActiveFork) + .count(); + let passive_count = triangles + .iter() + .filter(|t| t.classification == TriangleClassification::PassiveTension) + .count(); + + assert_eq!( + active_count, 3, + "Expected 3 ActiveFork triangles, got {}", + active_count + ); + assert_eq!( + passive_count, 2, + "Expected 2 PassiveTension triangles, got {}", + passive_count + ); + + // All 5 must have exactly 3 role assignments (triangle = 3 NPCs) + for triangle in &triangles { + assert_eq!( + triangle.role_assignments.len(), + 3, + "Triangle {:?} should have 3 role assignments, got {}", + triangle.triangle_id, + triangle.role_assignments.len() + ); + } + + // All role assignments must point to valid NPC entities in the registry + let registry = world.resource::(); + for triangle in &triangles { + for (role, stable_id) in &triangle.role_assignments { + assert!( + registry.to_entity(stable_id).is_some(), + "Triangle {:?} role '{}' points to StableId {:?} with no entity", + triangle.triangle_id, + role.0, + stable_id, + ); + } + } + + // All triangle entities must have ActiveSim marker + let mut active_query = world.query::<(&TriangleState, &ActiveSim)>(); + let active_triangles: Vec<_> = active_query.iter(&world).collect(); + assert_eq!( + active_triangles.len(), + 5, + "All 5 triangles must have ActiveSim marker" + ); + + // Verify StableIds in role assignments correspond to spawned NPC canonical_ids + let all_npc_stable_ids: std::collections::BTreeSet<_> = + result.npc_ids.values().copied().collect(); + for triangle in &triangles { + for (role, stable_id) in &triangle.role_assignments { + assert!( + all_npc_stable_ids.contains(stable_id), + "Triangle {:?} role '{}' StableId {:?} not in spawned NPC set", + triangle.triangle_id, + role.0, + stable_id, + ); + } + } +} + // Runtime validation test (boot + tick 10 + snapshot) moved to // server/tests/content_runtime.rs per architectural review. diff --git a/server/tests/triangle_escalation.rs b/server/tests/triangle_escalation.rs index 78007c317..71c4987f3 100644 --- a/server/tests/triangle_escalation.rs +++ b/server/tests/triangle_escalation.rs @@ -15,8 +15,8 @@ use bevy_ecs::{schedule::Schedule, world::World}; use settled_reach_server::{ content::template::{ apply_resolve_triangle, tick_triangle_escalation, ResolveTriangleCommand, - ResolveTriangleQueue, TemplateId, TriangleCrisisEventQueue, TriangleDef, TriangleId, - TrianglePhase, TriangleState, + ResolveTriangleQueue, TemplateId, TriangleClassification, TriangleCrisisEventQueue, + TriangleDef, TriangleId, TrianglePhase, TriangleState, }, knowledge::{registry::EntityRegistry, types::StableId, StableEntityId}, npc::ToleranceThreshold, @@ -68,6 +68,7 @@ fn spawn_triangle( phase, tension_rate, template_id: TemplateId(1), + classification: TriangleClassification::default(), }, )) .id() @@ -329,6 +330,7 @@ fn d026_non_active_tier_triangle_not_escalated() { phase: TrianglePhase::Simmering, tension_rate: 5, template_id: TemplateId(1), + classification: TriangleClassification::default(), }) .id(); @@ -395,6 +397,7 @@ fn resolve_command_sets_phase_to_resolved() { phase: TrianglePhase::Active, tension_rate: 3, template_id: TemplateId(1), + classification: TriangleClassification::default(), }) .id(); @@ -423,6 +426,7 @@ fn d089_resolve_does_not_cascade() { phase: TrianglePhase::Active, tension_rate: 3, template_id: TemplateId(1), + classification: TriangleClassification::default(), }) .id(); @@ -434,6 +438,7 @@ fn d089_resolve_does_not_cascade() { phase: TrianglePhase::Simmering, tension_rate: 2, template_id: TemplateId(1), + classification: TriangleClassification::default(), }) .id(); @@ -445,6 +450,7 @@ fn d089_resolve_does_not_cascade() { phase: TrianglePhase::Active, tension_rate: 4, template_id: TemplateId(1), + classification: TriangleClassification::default(), }) .id(); @@ -485,6 +491,7 @@ fn resolve_twice_is_idempotent() { phase: TrianglePhase::Active, tension_rate: 1, template_id: TemplateId(1), + classification: TriangleClassification::default(), }) .id(); @@ -602,6 +609,7 @@ fn d087_all_v01_conflict_types_produce_escalatable_states() { phase: TrianglePhase::Simmering, tension_rate: 3, template_id: TemplateId(1), + classification: TriangleClassification::default(), }; assert_eq!( state.phase,