diff --git a/server/src/content/instantiation.rs b/server/src/content/instantiation.rs new file mode 100644 index 000000000..4749b48ca --- /dev/null +++ b/server/src/content/instantiation.rs @@ -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, + /// ECS entities for the generated `TriangleState` components. + pub triangle_entities: Vec, + /// Non-fatal warnings from triangle generation (e.g., fallback assignments). + pub warnings: Vec, +} + +/// 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::()`. +/// +/// **Determinism (D-010):** `BTreeMap` for consistent iteration order. +#[derive(Resource, Default, Debug)] +pub struct ActiveTemplateInstances { + instances: BTreeMap, +} + +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 { + 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 { + // 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 = 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::(); + world + .resource_mut::() + .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::() + .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 { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("failed to read {:?}: {}", path, e))?; + serde_yaml::from_str::(&content) + .map_err(|e| format!("failed to parse {:?}: {}", path, e)) +} diff --git a/server/src/content/mod.rs b/server/src/content/mod.rs index 34e71eeb0..aefd49acf 100644 --- a/server/src/content/mod.rs +++ b/server/src/content/mod.rs @@ -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; diff --git a/server/tests/template_instantiation.rs b/server/tests/template_instantiation.rs new file mode 100644 index 000000000..86ae6bc93 --- /dev/null +++ b/server/tests/template_instantiation.rs @@ -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::(); + 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 = Vec::new(); + for &entity in &instance.npc_entities { + let ownership = world + .get::(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::(entity).is_some(), + "triangle entity {:?} must carry a TriangleState component", + entity, + ); + } + + // --- Instance registered in ActiveTemplateInstances --- + let active = world.resource::(); + 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 = 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::(); + 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::(e).unwrap().role_id.0.clone(); + let sid = world.get::(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" + ); +}