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 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 16:34:36 +01:00
co-authored by Claude Opus 4.6
parent daae3dd6ab
commit 42febf4059
8 changed files with 381 additions and 10 deletions
@@ -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
@@ -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
+114 -4
View File
@@ -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
// ---------------------------------------------------------------------------
+37
View File
@@ -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();
+3
View File
@@ -196,6 +196,9 @@ pub struct Triangle {
pub forks: Vec<Fork>,
#[serde(default)]
pub resolution_states: Vec<Resolution>,
/// D-087 classification: "active_fork" (default) or "passive_tension".
#[serde(default)]
pub classification: Option<String>,
}
#[derive(Debug, Deserialize)]
+19
View File
@@ -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)
// ---------------------------------------------------------------------------
+192
View File
@@ -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::<EntityRegistry>();
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
world.init_resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
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::<EntityRegistry>();
// 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::<npc::EntanglementTag>(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::<npc::EntanglementTag>(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::<npc::EntanglementTag>(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::<EntityRegistry>();
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
world.init_resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
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<TriangleState> = {
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::<EntityRegistry>();
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.
+10 -2
View File
@@ -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,