feat(simulation): template instantiation engine — load, spawn, lifecycle (#161)

Add content::instantiation module with:
- instantiate_template(): validates FullTemplateDef, calls
  spawn_template_npcs, generates TriangleState entities with ActiveSim,
  registers in ActiveTemplateInstances resource
- unload_template(): despawns all NPC + triangle entities, removes
  from tracking
- load_template_from_file(): YAML → FullTemplateDef deserialization
- ActiveTemplateInstances: BTreeMap-backed resource (D-010 determinism)

Integration tests: end-to-end logistics-hub.yaml instantiation (4 NPCs,
2 TriangleStates), lifecycle (instantiate → unload → clean), determinism
(same seed = same layout), error path (missing file).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 17:59:49 +01:00
co-authored by Claude Opus 4.6
parent b6340f9663
commit 88fc6c30dc
3 changed files with 419 additions and 0 deletions
+197
View File
@@ -0,0 +1,197 @@
//! Template instantiation engine (#161).
//!
//! Wires the full pipeline: `FullTemplateDef` → NPC spawn (via spawn.rs) →
//! triangle generation (via template.rs) → instance tracking.
//!
//! **Pipeline:**
//! 1. Validate the `FullTemplateDef` (schema-level checks).
//! 2. Call `spawn_template_npcs` to create NPC entities and wire relationships.
//! 3. Call `generate_intra_template_triangles` to generate `TriangleState` values.
//! 4. Spawn each `TriangleState` as an ECS entity with the `ActiveSim` marker.
//! 5. Register the live instance in `ActiveTemplateInstances`.
//!
//! **Instance lifecycle:**
//! Instances are tracked by `TemplateId` in `ActiveTemplateInstances`.
//! `unload_template` despawns all NPC and triangle entities and removes the
//! entry from `ActiveTemplateInstances`.
//!
//! **Determinism (D-010):** given the same `FullTemplateDef`, `TemplateId`,
//! `world_seed`, and `SimRng` state, the spawned NPC and triangle layout is
//! identical.
use std::collections::BTreeMap;
use bevy_ecs::prelude::*;
use crate::content::spawn::spawn_template_npcs;
use crate::content::template::{
generate_intra_template_triangles, FullTemplateDef, TemplateId,
};
use crate::simulation::rng::SimRng;
use crate::simulation::tier::ActiveSim;
// ===========================================================================
// Public types
// ===========================================================================
/// A live template instance — the result of `instantiate_template`.
///
/// Holds entity handles for all NPCs and triangle entities spawned from a
/// single `FullTemplateDef`. Required by `unload_template` to despawn them.
#[derive(Debug, Clone)]
pub struct TemplateInstance {
/// Template this instance was created from.
pub template_id: TemplateId,
/// ECS entities for the NPC role slots (one per `RoleSchema`).
pub npc_entities: Vec<Entity>,
/// ECS entities for the generated `TriangleState` components.
pub triangle_entities: Vec<Entity>,
/// Non-fatal warnings from triangle generation (e.g., fallback assignments).
pub warnings: Vec<String>,
}
/// Resource tracking all currently active template instances.
///
/// Key = `TemplateId.0` (deterministic u64). Initialized on demand by
/// `instantiate_template`; may also be initialized explicitly with
/// `world.init_resource::<ActiveTemplateInstances>()`.
///
/// **Determinism (D-010):** `BTreeMap` for consistent iteration order.
#[derive(Resource, Default, Debug)]
pub struct ActiveTemplateInstances {
instances: BTreeMap<u64, TemplateInstance>,
}
impl ActiveTemplateInstances {
/// Register a new instance. Overwrites any existing entry for the same ID.
pub fn insert(&mut self, instance: TemplateInstance) {
self.instances.insert(instance.template_id.0, instance);
}
/// Look up a live instance by template ID.
pub fn get(&self, template_id: TemplateId) -> Option<&TemplateInstance> {
self.instances.get(&template_id.0)
}
/// Remove and return an instance (used by `unload_template`).
pub fn remove(&mut self, template_id: TemplateId) -> Option<TemplateInstance> {
self.instances.remove(&template_id.0)
}
/// Number of active instances.
pub fn len(&self) -> usize {
self.instances.len()
}
/// `true` if no instances are active.
pub fn is_empty(&self) -> bool {
self.instances.is_empty()
}
}
// ===========================================================================
// Instantiation
// ===========================================================================
/// Instantiate a template: validate, spawn NPCs, generate triangles, register.
///
/// **Preconditions:**
/// - `EntityRegistry` must be initialized as a world resource (done by
/// `SimulationPlugin` at startup).
/// - `ActiveTemplateInstances` is initialized on demand inside this function.
///
/// **Returns** the created `TemplateInstance` (also stored in
/// `ActiveTemplateInstances`).
///
/// **Errors:** returns `Err(String)` if `template_def.validate()` fails.
pub fn instantiate_template(
world: &mut World,
template_def: &FullTemplateDef,
template_id: TemplateId,
world_seed: u64,
rng: &mut SimRng,
) -> Result<TemplateInstance, String> {
// Schema validation before any ECS mutations.
template_def.validate()?;
// Phases 13: NPC spawn + relationship wiring + cross-template ref map.
let spawn_result = spawn_template_npcs(world, template_def, template_id, world_seed, rng);
// Phase 4: Generate intra-template triangle state values.
let tri_result =
generate_intra_template_triangles(world, template_id, &template_def.triangles, rng);
let warnings = tri_result.warnings;
// Spawn each TriangleState as a dedicated ECS entity with ActiveSim so
// the escalation system can pick it up (D-087).
let triangle_entities: Vec<Entity> = tri_result
.triangles
.into_iter()
.map(|state| world.spawn((ActiveSim, state)).id())
.collect();
let instance = TemplateInstance {
template_id,
npc_entities: spawn_result.entities,
triangle_entities,
warnings,
};
// Register in ActiveTemplateInstances (init if absent).
world.init_resource::<ActiveTemplateInstances>();
world
.resource_mut::<ActiveTemplateInstances>()
.insert(instance.clone());
Ok(instance)
}
// ===========================================================================
// Lifecycle: unload
// ===========================================================================
/// Unload a template instance: despawn all entities and remove from tracking.
///
/// No-op (with a warning log) if the given `template_id` is not active.
pub fn unload_template(world: &mut World, template_id: TemplateId) {
let instance = world
.resource_mut::<ActiveTemplateInstances>()
.remove(template_id);
let Some(instance) = instance else {
tracing::warn!(
"unload_template: TemplateId({}) not active — no-op",
template_id.0
);
return;
};
let mut despawned = 0usize;
for entity in instance.npc_entities.iter().chain(instance.triangle_entities.iter()) {
if world.get_entity(*entity).is_ok() {
world.despawn(*entity);
despawned += 1;
}
}
tracing::info!(
"unload_template: TemplateId({}) unloaded — {} entities despawned",
template_id.0,
despawned,
);
}
// ===========================================================================
// YAML loader
// ===========================================================================
/// Load a `FullTemplateDef` from a YAML file on disk.
///
/// Returns `Err(String)` if the file cannot be read or fails YAML parsing.
pub fn load_template_from_file(path: &std::path::Path) -> Result<FullTemplateDef, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read {:?}: {}", path, e))?;
serde_yaml::from_str::<FullTemplateDef>(&content)
.map_err(|e| format!("failed to parse {:?}: {}", path, e))
}
+1
View File
@@ -11,6 +11,7 @@
//! handles the mapping between the two representations.
pub mod hot_reload;
pub mod instantiation;
pub mod line_pool;
pub mod loader;
pub mod spawn;
+221
View File
@@ -0,0 +1,221 @@
//! End-to-end tests for the template instantiation engine (#161).
//!
//! Verifies the full pipeline:
//! load YAML → validate → spawn NPCs → generate triangles → lifecycle
//!
//! Spec refs:
//! - D-023: three-tier content model
//! - D-024: 10-axis NPC model, minimum 2 triangles per social site
//! - D-025: social site as atomic template unit, single-ownership
//! - D-010: determinism (same seed → same layout)
use std::path::PathBuf;
use bevy_ecs::prelude::*;
use settled_reach_server::{
content::{
instantiation::{
instantiate_template, load_template_from_file, unload_template,
ActiveTemplateInstances,
},
template::{TemplateId, TemplateOwnership, TriangleState},
},
knowledge::{registry::EntityRegistry, StableEntityId},
simulation::rng::SimRng,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn templates_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data/templates")
}
fn make_test_world() -> World {
let mut world = World::new();
world.init_resource::<EntityRegistry>();
world
}
// ---------------------------------------------------------------------------
// End-to-end: load logistics-hub YAML, instantiate, assert structure (#161)
// ---------------------------------------------------------------------------
#[test]
fn template_instantiation_end_to_end_logistics_hub() {
let path = templates_dir().join("logistics-hub.yaml");
let template_def =
load_template_from_file(&path).expect("logistics-hub.yaml must load and parse");
let mut world = make_test_world();
let template_id = TemplateId::from_seed_and_slug(42, "logistics-hub");
let mut rng = SimRng::new(42);
let instance = instantiate_template(&mut world, &template_def, template_id, 42, &mut rng)
.expect("logistics-hub must instantiate without validation errors");
// --- NPCs: all 4 role slots filled ---
assert_eq!(
instance.npc_entities.len(),
4,
"logistics-hub has 4 role slots — 4 NPC entities expected"
);
// --- TemplateOwnership on every NPC ---
let expected_roles = [
"logistics-manager",
"dock-worker",
"ring-contact",
"security-guard",
];
let mut seen_roles: Vec<String> = Vec::new();
for &entity in &instance.npc_entities {
let ownership = world
.get::<TemplateOwnership>(entity)
.expect("every spawned NPC must have TemplateOwnership");
assert_eq!(
ownership.template_id, template_id,
"TemplateOwnership.template_id must match the instantiated template"
);
let role = &ownership.role_id.0;
assert!(
expected_roles.contains(&role.as_str()),
"unexpected role '{}' — not in logistics-hub role list",
role,
);
seen_roles.push(role.clone());
}
// Every role slot must appear exactly once.
for role in &expected_roles {
assert_eq!(
seen_roles.iter().filter(|r| r.as_str() == *role).count(),
1,
"role '{}' must appear exactly once",
role,
);
}
// --- 2+ TriangleState entities (D-024 minimum) ---
assert!(
instance.triangle_entities.len() >= 2,
"logistics-hub must produce at least 2 TriangleState entities (D-024), got {}",
instance.triangle_entities.len(),
);
for &entity in &instance.triangle_entities {
assert!(
world.get::<TriangleState>(entity).is_some(),
"triangle entity {:?} must carry a TriangleState component",
entity,
);
}
// --- Instance registered in ActiveTemplateInstances ---
let active = world.resource::<ActiveTemplateInstances>();
assert!(
active.get(template_id).is_some(),
"instantiated template must be tracked in ActiveTemplateInstances",
);
}
// ---------------------------------------------------------------------------
// Lifecycle: unload despawns all entities
// ---------------------------------------------------------------------------
#[test]
fn template_instantiation_unload_despawns_entities() {
let path = templates_dir().join("logistics-hub.yaml");
let template_def = load_template_from_file(&path).expect("must parse");
let mut world = make_test_world();
let template_id = TemplateId::from_seed_and_slug(99, "logistics-hub");
let mut rng = SimRng::new(99);
let instance =
instantiate_template(&mut world, &template_def, template_id, 99, &mut rng)
.expect("must instantiate");
let all_entities: Vec<Entity> = instance
.npc_entities
.iter()
.chain(instance.triangle_entities.iter())
.cloned()
.collect();
assert!(!all_entities.is_empty(), "sanity: some entities were spawned");
unload_template(&mut world, template_id);
// All spawned entities must be gone.
for entity in &all_entities {
assert!(
world.get_entity(*entity).is_err(),
"entity {:?} must be despawned after unload_template",
entity,
);
}
// Instance removed from tracking.
let active = world.resource::<ActiveTemplateInstances>();
assert!(
active.get(template_id).is_none(),
"unloaded template must be removed from ActiveTemplateInstances",
);
}
// ---------------------------------------------------------------------------
// Determinism: same seed → same NPC StableId assignment (D-010)
// ---------------------------------------------------------------------------
#[test]
fn template_instantiation_is_deterministic() {
let path = templates_dir().join("logistics-hub.yaml");
let template_def = load_template_from_file(&path).expect("must parse");
let template_id = TemplateId::from_seed_and_slug(42, "logistics-hub");
let mut world1 = make_test_world();
let instance1 =
instantiate_template(&mut world1, &template_def, template_id, 42, &mut SimRng::new(42))
.expect("must instantiate");
let mut world2 = make_test_world();
let instance2 =
instantiate_template(&mut world2, &template_def, template_id, 42, &mut SimRng::new(42))
.expect("must instantiate");
// Collect (role → StableId) pairs from each world and compare.
let role_stable_ids = |world: &World, entities: &[Entity]| {
let mut pairs: Vec<(String, u64)> = entities
.iter()
.map(|&e| {
let role = world.get::<TemplateOwnership>(e).unwrap().role_id.0.clone();
let sid = world.get::<StableEntityId>(e).unwrap().0 .0;
(role, sid)
})
.collect();
pairs.sort();
pairs
};
let pairs1 = role_stable_ids(&world1, &instance1.npc_entities);
let pairs2 = role_stable_ids(&world2, &instance2.npc_entities);
assert_eq!(
pairs1, pairs2,
"instantiate_template must be deterministic (D-010): same seed → same layout"
);
}
// ---------------------------------------------------------------------------
// YAML loading: invalid path returns Err
// ---------------------------------------------------------------------------
#[test]
fn load_template_from_file_nonexistent_path_returns_err() {
let path = templates_dir().join("nonexistent-template-xyzzy.yaml");
let result = load_template_from_file(&path);
assert!(
result.is_err(),
"loading a nonexistent file must return Err"
);
}