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;