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:
@@ -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 1–3: 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))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user