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
+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)
// ---------------------------------------------------------------------------