Files
settled-reach/server/tests/template_instantiation.rs
T
jpmschweitzerandClaude Opus 4.6 88fc6c30dc 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>
2026-02-27 17:59:49 +01:00

222 lines
7.5 KiB
Rust

//! 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"
);
}