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
+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.