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>
618 lines
21 KiB
Rust
618 lines
21 KiB
Rust
//! Integration test: content loading pipeline.
|
|
//!
|
|
//! Tests the full pipeline: discover content → deserialize YAML → spawn ECS entities.
|
|
//! Uses real content files from content/ directory for structural content,
|
|
//! and a test fixture for isolated NPC profile spawning.
|
|
//!
|
|
//! Runtime validation (TCP boot + tick) is in content_runtime.rs.
|
|
|
|
use bevy_app::prelude::*;
|
|
use bevy_ecs::prelude::*;
|
|
use std::collections::BTreeMap;
|
|
use std::path::PathBuf;
|
|
|
|
use settled_reach_server::content::loader::{load_content, ContentStore};
|
|
use settled_reach_server::content::spawn::spawn_content;
|
|
use settled_reach_server::content::types::*;
|
|
use settled_reach_server::content::{ContentConfig, ContentPlugin, ContentStoreResource};
|
|
use settled_reach_server::knowledge::registry::EntityRegistry;
|
|
use settled_reach_server::npc;
|
|
use settled_reach_server::simulation::SimulationPlugin;
|
|
|
|
/// Find the content root relative to the test binary location.
|
|
fn content_root() -> PathBuf {
|
|
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
|
PathBuf::from(manifest_dir).join("../content")
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test: real content discovery and structural loading
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn discover_real_content_structure() {
|
|
let root = content_root();
|
|
if !root.join("content.yaml").exists() {
|
|
// Skip if content directory is not present (e.g. CI without content)
|
|
eprintln!("Skipping: content directory not found at {:?}", root);
|
|
return;
|
|
}
|
|
|
|
let store = load_content(&root).expect("content loading should succeed");
|
|
|
|
// Manifest should be present
|
|
assert!(store.manifest.is_some());
|
|
let manifest = store.manifest.as_ref().unwrap();
|
|
assert_eq!(manifest.version, "0.1.0");
|
|
assert!(!manifest.campaigns.is_empty());
|
|
|
|
// At least one district should be discovered
|
|
assert!(
|
|
!store.districts.is_empty(),
|
|
"should discover at least one district"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn load_real_transit_district() {
|
|
let root = content_root();
|
|
if !root.join("content.yaml").exists() {
|
|
return;
|
|
}
|
|
|
|
let store = load_content(&root).expect("content loading should succeed");
|
|
|
|
// The transit district should be discovered
|
|
let transit = store
|
|
.districts
|
|
.get("krenn.sova.transit")
|
|
.expect("transit district should be discovered");
|
|
|
|
// District metadata
|
|
assert!(transit.meta.is_some());
|
|
let meta = transit.meta.as_ref().unwrap();
|
|
assert_eq!(meta.display_name, "Sova Transit District");
|
|
assert_eq!(meta.npc_count, 17);
|
|
|
|
// 5 triangles from ticket #391
|
|
assert_eq!(transit.triangles.len(), 5);
|
|
let triangle_ids: Vec<&str> = transit
|
|
.triangles
|
|
.iter()
|
|
.map(|t| t.canonical_id.as_str())
|
|
.collect();
|
|
assert!(triangle_ids.contains(&"hub-power"));
|
|
assert!(triangle_ids.contains(&"worried-knowledge"));
|
|
assert!(triangle_ids.contains(&"bar-tensions"));
|
|
assert!(triangle_ids.contains(&"worried-partner"));
|
|
assert!(triangle_ids.contains(&"informant-question"));
|
|
|
|
// Each triangle should have exactly 3 members
|
|
for triangle in &transit.triangles {
|
|
assert_eq!(
|
|
triangle.members.len(),
|
|
3,
|
|
"Triangle {} should have 3 members",
|
|
triangle.canonical_id
|
|
);
|
|
}
|
|
|
|
// 5 pools from ticket #389
|
|
assert_eq!(transit.pools.len(), 5);
|
|
let pool_ids: Vec<&str> = transit.pools.iter().map(|p| p.pool_id.as_str()).collect();
|
|
assert!(pool_ids.contains(&"transit:friend_smuggler"));
|
|
assert!(pool_ids.contains(&"transit:friend_detective"));
|
|
assert!(pool_ids.contains(&"transit:bar_regulars"));
|
|
assert!(pool_ids.contains(&"transit:compromised_inspector"));
|
|
assert!(pool_ids.contains(&"transit:primary_contraband"));
|
|
|
|
// 3 templates from ticket #390
|
|
assert_eq!(transit.templates.len(), 3);
|
|
let template_ids: Vec<&str> = transit
|
|
.templates
|
|
.iter()
|
|
.map(|t| t.template_id.as_str())
|
|
.collect();
|
|
assert!(template_ids.contains(&"logistics-hub"));
|
|
assert!(template_ids.contains(&"bar"));
|
|
assert!(template_ids.contains(&"smuggling-ring"));
|
|
|
|
// 23 NPC profiles: 17 NPCs + 2 PC-as-NPC + 1 extended NPC (nils-davan) + 3 Sprint 14 additions
|
|
// Populated by #398 (wiki→YAML NPC conversion) and Sprint 14 content expansion
|
|
assert_eq!(
|
|
transit.npc_profiles.len(),
|
|
23,
|
|
"Expected 23 parseable NPC profiles"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn verify_triangle_fork_structure() {
|
|
let root = content_root();
|
|
if !root.join("content.yaml").exists() {
|
|
return;
|
|
}
|
|
|
|
let store = load_content(&root).expect("content loading should succeed");
|
|
let transit = store.districts.get("krenn.sova.transit").unwrap();
|
|
|
|
// Hub power triangle: should have 1 fork with 3 outcomes
|
|
let hub_power = transit
|
|
.triangles
|
|
.iter()
|
|
.find(|t| t.canonical_id == "hub-power")
|
|
.expect("hub-power triangle should exist");
|
|
|
|
assert_eq!(hub_power.forks.len(), 1);
|
|
assert_eq!(hub_power.forks[0].id, "volume-escalation");
|
|
assert_eq!(hub_power.forks[0].outcomes.len(), 3);
|
|
|
|
let outcome_ids: Vec<&str> = hub_power.forks[0]
|
|
.outcomes
|
|
.iter()
|
|
.filter_map(|o| o.id.as_deref())
|
|
.collect();
|
|
assert!(outcome_ids.contains(&"escalate"));
|
|
assert!(outcome_ids.contains(&"stabilize"));
|
|
assert!(outcome_ids.contains(&"mediate"));
|
|
|
|
// Resolution states
|
|
assert_eq!(hub_power.resolution_states.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn verify_pool_candidates() {
|
|
let root = content_root();
|
|
if !root.join("content.yaml").exists() {
|
|
return;
|
|
}
|
|
|
|
let store = load_content(&root).expect("content loading should succeed");
|
|
let transit = store.districts.get("krenn.sova.transit").unwrap();
|
|
|
|
// bar_regulars pool should have 5 candidates
|
|
let bar_regulars = transit
|
|
.pools
|
|
.iter()
|
|
.find(|p| p.pool_id == "transit:bar_regulars")
|
|
.expect("bar_regulars pool should exist");
|
|
|
|
assert_eq!(bar_regulars.candidates.len(), 5);
|
|
assert_eq!(bar_regulars.category, "npc_group");
|
|
}
|
|
|
|
#[test]
|
|
fn verify_template_role_slots() {
|
|
let root = content_root();
|
|
if !root.join("content.yaml").exists() {
|
|
return;
|
|
}
|
|
|
|
let store = load_content(&root).expect("content loading should succeed");
|
|
let transit = store.districts.get("krenn.sova.transit").unwrap();
|
|
|
|
// Logistics hub should have 5 role slots
|
|
let hub = transit
|
|
.templates
|
|
.iter()
|
|
.find(|t| t.template_id == "logistics-hub")
|
|
.expect("logistics-hub template should exist");
|
|
|
|
assert_eq!(hub.role_slots.len(), 5);
|
|
|
|
// Should have v01_assignments
|
|
assert!(hub.v01_assignments.is_some());
|
|
let assignments = hub.v01_assignments.as_ref().unwrap();
|
|
assert!(assignments.contains_key("shift-supervisor"));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test: NPC spawning pipeline with test fixture data
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn spawn_npc_from_content_store() {
|
|
let mut world = World::new();
|
|
world.init_resource::<EntityRegistry>();
|
|
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
|
|
|
|
// Create a minimal content store with one test NPC
|
|
let mut store = ContentStore::default();
|
|
let mut district = settled_reach_server::content::loader::DistrictContent::default();
|
|
district.npc_profiles.push(NpcProfile {
|
|
canonical_id: "test-worker".to_string(),
|
|
display_name: "Test Worker".to_string(),
|
|
tier: 2,
|
|
pattern: Some("ANCHOR".to_string()),
|
|
motivation: Some("CIVILIAN".to_string()),
|
|
description: Some("A test dock worker".to_string()),
|
|
want: Some(NpcWant {
|
|
primary: "Safety".to_string(),
|
|
intensity: Some(5),
|
|
description: Some("Wants a quiet life".to_string()),
|
|
}),
|
|
secret: None,
|
|
relationships: vec![],
|
|
tolerance: Some(NpcTolerance {
|
|
threshold: Some(70),
|
|
description: None,
|
|
}),
|
|
routine: None,
|
|
information: None,
|
|
contentment: Some(NpcContentment {
|
|
level: Some(30),
|
|
description: None,
|
|
}),
|
|
personality: None,
|
|
tells: vec![],
|
|
skills: Some(NpcSkills {
|
|
combat_trained: Some(false),
|
|
skills: Some({
|
|
let mut m = BTreeMap::new();
|
|
m.insert("technical".to_string(), 5);
|
|
m
|
|
}),
|
|
}),
|
|
triangle_membership: vec![],
|
|
trust_levels: None,
|
|
friend_arc: None,
|
|
dual_lens: None,
|
|
});
|
|
store
|
|
.districts
|
|
.insert("test.district".to_string(), district);
|
|
|
|
let result = spawn_content(&mut world, &store);
|
|
|
|
// Verify entity was spawned
|
|
assert_eq!(result.npcs_spawned, 1);
|
|
assert!(result.npc_ids.contains_key("test-worker"));
|
|
|
|
// Verify ECS components
|
|
let stable_id = result.npc_ids["test-worker"];
|
|
let entity = world
|
|
.resource::<EntityRegistry>()
|
|
.to_entity(&stable_id)
|
|
.unwrap();
|
|
|
|
assert!(world.get::<npc::Npc>(entity).is_some());
|
|
|
|
let want = world.get::<npc::Want>(entity).unwrap();
|
|
assert_eq!(want.primary, npc::WantKind::Safety);
|
|
assert_eq!(want.intensity, 5);
|
|
|
|
let tolerance = world.get::<npc::ToleranceThreshold>(entity).unwrap();
|
|
assert_eq!(tolerance.threshold, 70);
|
|
|
|
let skills = world.get::<npc::SkillSet>(entity).unwrap();
|
|
assert_eq!(skills.skills[&npc::Skill::Technical], 5);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test: ContentPlugin integration with bevy App
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn content_plugin_loads_via_app() {
|
|
let root = content_root();
|
|
if !root.join("content.yaml").exists() {
|
|
return;
|
|
}
|
|
|
|
let mut app = App::new();
|
|
app.add_plugins(SimulationPlugin);
|
|
app.add_plugins(settled_reach_server::npc::NpcPlugin);
|
|
app.insert_resource(ContentConfig {
|
|
content_root: root,
|
|
..Default::default()
|
|
});
|
|
app.add_plugins(ContentPlugin);
|
|
|
|
// Run startup systems
|
|
app.update();
|
|
|
|
// ContentStoreResource should be inserted
|
|
assert!(
|
|
app.world().contains_resource::<ContentStoreResource>(),
|
|
"ContentStoreResource should be present after startup"
|
|
);
|
|
|
|
let store = &app.world().resource::<ContentStoreResource>().0;
|
|
assert!(!store.districts.is_empty());
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test: Full spawn pipeline with real content — 10-axis gap closure
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn spawn_real_content_with_relationships_and_secrets() {
|
|
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);
|
|
|
|
// All 23 profiles should spawn
|
|
assert_eq!(result.npcs_spawned, 23);
|
|
assert!(result.npc_ids.contains_key("npc:kael-davan"));
|
|
assert!(result.npc_ids.contains_key("npc:voss"));
|
|
assert!(result.npc_ids.contains_key("npc:pc-smuggler"));
|
|
assert!(result.npc_ids.contains_key("npc:nils-davan"));
|
|
|
|
// Verify ALL 20 NPCs have Want components (Option C: exact enum keywords in YAML)
|
|
let registry = world.resource::<EntityRegistry>();
|
|
let mut npcs_with_want = 0;
|
|
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));
|
|
assert!(
|
|
world.get::<npc::Want>(entity).is_some(),
|
|
"NPC {} should have a Want component",
|
|
canonical_id
|
|
);
|
|
npcs_with_want += 1;
|
|
}
|
|
assert_eq!(
|
|
npcs_with_want, 23,
|
|
"All 23 NPCs should have Want components"
|
|
);
|
|
|
|
// Spot-check specific Want values
|
|
let kael_entity = registry
|
|
.to_entity(&result.npc_ids["npc:kael-davan"])
|
|
.unwrap();
|
|
let kael_want = world
|
|
.get::<npc::Want>(kael_entity)
|
|
.expect("Kael should have Want");
|
|
assert_eq!(kael_want.primary, npc::WantKind::Safety);
|
|
|
|
// Verify Kael has a Secret component
|
|
let kael_secret = world
|
|
.get::<npc::Secret>(kael_entity)
|
|
.expect("Kael should have Secret");
|
|
assert!(kael_secret.description.contains("ring"));
|
|
assert_eq!(kael_secret.severity, npc::SecretSeverity::Major);
|
|
|
|
// Verify Kael has Relationships (7 defined in YAML)
|
|
let kael_rels = world
|
|
.get::<npc::Relationships>(kael_entity)
|
|
.expect("Kael should have Relationships");
|
|
assert!(
|
|
kael_rels.entries.len() >= 5,
|
|
"Kael should have at least 5 resolved relationships, got {}",
|
|
kael_rels.entries.len()
|
|
);
|
|
|
|
// Verify Kael has KnowledgeGraph (background facts from information.knows)
|
|
let kael_kg = world
|
|
.get::<settled_reach_server::knowledge::graph::KnowledgeGraph>(kael_entity)
|
|
.expect("Kael should have KnowledgeGraph");
|
|
assert!(
|
|
kael_kg.knows_fact(&settled_reach_server::knowledge::types::FactId(
|
|
"contraband.ring_exists".to_string()
|
|
))
|
|
);
|
|
|
|
// Verify global RelationshipGraph was populated
|
|
let graph = world.resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
|
|
assert!(
|
|
graph.edge_count() >= 20,
|
|
"Expected at least 20 relationship edges, got {}",
|
|
graph.edge_count()
|
|
);
|
|
|
|
// Verify Nils (off-stage) also has correct data
|
|
let nils_entity = world
|
|
.resource::<EntityRegistry>()
|
|
.to_entity(&result.npc_ids["npc:nils-davan"])
|
|
.unwrap();
|
|
let nils_want = world
|
|
.get::<npc::Want>(nils_entity)
|
|
.expect("Nils should have Want");
|
|
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.
|