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>
222 lines
7.5 KiB
Rust
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"
|
|
);
|
|
}
|