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:
+114
-4
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user