wip(simulation): template-to-instance mapping — spawn_template_npcs (#166)

Add spawn_template_npcs: three-phase template instantiation (spawn NPCs
per role slot, wire intra-template relationships, record cross-template
references in TemplateReferenceMap). Partially complete — needs
validation pass, error handling, and integration with content loading
pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 17:46:47 +01:00
co-authored by Claude Opus 4.6
parent 6f39df884c
commit ace5cb811f
2 changed files with 376 additions and 1 deletions
+1 -1
View File
@@ -1092,7 +1092,7 @@ dependencies = [
[[package]]
name = "settled-reach-server"
version = "0.1.19"
version = "0.1.20"
dependencies = [
"bevy_app",
"bevy_ecs",
+375
View File
@@ -34,6 +34,15 @@ use crate::simulation::movement::TilePosition;
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::DayPhase;
// #166 — Template-to-instance mapping
use rand::Rng as _;
use crate::content::template::{
FullTemplateDef, RoleId, TemplateId, TemplateOwnership, TemplateReference, TemplateReferenceMap,
};
use crate::npc::generate::{generate_npc, RoleDefinition};
use crate::npc::{Relationship, Relationships};
use crate::simulation::rng::SimRng;
/// Stable content identifier from YAML (e.g., "kael-davan", "sera-venn").
///
/// Bridges authoring identity to ECS entities. Independent of StableId —
@@ -656,6 +665,152 @@ pub fn parse_day_phase(s: &str) -> Option<DayPhase> {
}
}
// ===========================================================================
// #166 — Template-to-instance mapping
// ===========================================================================
/// Result of spawning all NPC role slots for a template.
#[derive(Debug)]
pub struct TemplateSpawnResult {
/// Stable ID assigned to each role slot. BTreeMap for deterministic ordering (D-010).
pub role_assignments: BTreeMap<RoleId, StableId>,
/// ECS entity handles in the same order as `FullTemplateDef::roles`.
pub entities: Vec<Entity>,
}
/// Spawn NPC entities for all role slots in a `FullTemplateDef` (#166).
///
/// Three-phase process:
///
/// **Phase 1 — Spawn:** For each `RoleSchema`, build a `RoleDefinition` and
/// call `generate_npc()`. Register the entity in `EntityRegistry`, then
/// insert `StableEntityId` + `TemplateOwnership`.
///
/// **Phase 2 — Relationships:** Wire intra-template `RelationshipConstraint`s.
/// Each constraint becomes a `Relationship` entry on the NPC, with trust
/// sampled within `[min, max]` via `rng`.
///
/// **Phase 3 — Reference map:** Record cross-template links in
/// `TemplateReferenceMap`, resolving `to_template_slug` to `TemplateId`
/// via `TemplateId::from_seed_and_slug(world_seed, slug)`.
///
/// **Caller precondition:** `EntityRegistry` must be initialized as a world
/// resource (done by `SimulationPlugin`). `TemplateReferenceMap` is
/// initialized inside this function if absent.
///
/// **Determinism (D-010):** All randomness flows through `rng`. Same seed
/// and `FullTemplateDef` → same NPC layout every time.
pub fn spawn_template_npcs(
world: &mut World,
template_def: &FullTemplateDef,
template_id: TemplateId,
world_seed: u64,
rng: &mut SimRng,
) -> TemplateSpawnResult {
let mut role_assignments: BTreeMap<RoleId, StableId> = BTreeMap::new();
let mut role_entities: BTreeMap<RoleId, Entity> = BTreeMap::new();
let mut entities: Vec<Entity> = Vec::new();
// -----------------------------------------------------------------------
// Phase 1: Spawn one NPC per role slot
// -----------------------------------------------------------------------
for role_schema in &template_def.roles {
// Enable combat capability for roles whose skill focus includes Combat.
let combat_enabled = role_schema.skill_focus.contains(&crate::npc::Skill::Combat);
let role_def = RoleDefinition {
name: role_schema.role_id.0.clone(),
// Location pool is empty at template-def time — positions are resolved
// when the template is placed in the world (#161).
location_pool: vec![],
// Relationship targets are empty — Phase 2 wires them from constraints.
relationship_targets: vec![],
known_facts: vec![],
skill_focus: role_schema.skill_focus.clone(),
combat_enabled,
};
let entity = generate_npc(&role_def, world, rng);
// Register the entity in EntityRegistry and attach stable identity.
let stable_id = world.resource_mut::<EntityRegistry>().register(entity);
world.entity_mut(entity).insert((
StableEntityId(stable_id),
TemplateOwnership {
template_id,
role_id: role_schema.role_id.clone(),
},
));
role_assignments.insert(role_schema.role_id.clone(), stable_id);
role_entities.insert(role_schema.role_id.clone(), entity);
entities.push(entity);
}
// -----------------------------------------------------------------------
// Phase 2: Wire intra-template relationship constraints
// -----------------------------------------------------------------------
for role_schema in &template_def.roles {
let Some(&from_entity) = role_entities.get(&role_schema.role_id) else {
continue;
};
for constraint in &role_schema.relationship_constraints {
let Some(&with_stable_id) = role_assignments.get(&constraint.with_role) else {
// Referenced role is not in this template — cross-template links
// are handled via TemplateReferenceMap (Phase 3), not Relationships.
tracing::debug!(
"spawn_template_npcs: constraint references role '{}' not in template '{}', skipping",
constraint.with_role.0,
template_def.slug,
);
continue;
};
// Sample trust within the authored range. If range is degenerate, use min.
let trust: i8 = if constraint.required_trust.min >= constraint.required_trust.max {
constraint.required_trust.min
} else {
rng.rng.random_range(
constraint.required_trust.min..=constraint.required_trust.max,
)
};
// Append the relationship — generate_npc starts with empty relationship_targets
// so there are no pre-existing duplicates to guard against.
if let Some(mut rels) = world.get_mut::<Relationships>(from_entity) {
rels.entries.push(Relationship {
target_id: with_stable_id,
kind: constraint.kind.clone(),
trust_level: trust,
history: vec![],
});
}
}
}
// -----------------------------------------------------------------------
// Phase 3: Record cross-template reference links in TemplateReferenceMap
// -----------------------------------------------------------------------
world.init_resource::<TemplateReferenceMap>();
for link in &template_def.cross_template_links {
let to_template_id = TemplateId::from_seed_and_slug(world_seed, &link.to_template_slug);
world
.resource_mut::<TemplateReferenceMap>()
.add(TemplateReference {
from_template: template_id,
to_template: to_template_id,
via_role: link.from_role.clone(),
relationship_metadata: link.relationship.clone(),
});
}
TemplateSpawnResult {
role_assignments,
entities,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1180,4 +1335,224 @@ mod tests {
.unwrap();
assert!(world.get::<npc::Relationships>(entity).is_none());
}
// -----------------------------------------------------------------------
// #166 — Template-to-instance mapping tests
// -----------------------------------------------------------------------
fn minimal_template_def_4_roles() -> crate::content::template::FullTemplateDef {
use crate::content::template::{
ConflictType, CrossTemplateLinkSpec, FullTemplateDef, NpcAxis, PrivacyLevel,
RelationshipConstraint, RoleId, RoleSchema, SpaceSpec, TemplateId, TrafficPattern,
TriangleDef, TriangleId, TrustRange,
};
use crate::npc::{RelationshipKind, Skill};
FullTemplateDef {
slug: "test-hub".to_string(),
display_name: "Test Hub".to_string(),
description: None,
roles: vec![
RoleSchema {
role_id: RoleId::new("manager"),
required_traits: vec![],
skill_focus: vec![Skill::Persuasion, Skill::Observation],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("worker"),
kind: RelationshipKind::Superior,
required_trust: TrustRange { min: 1, max: 4 },
}],
routine_template: vec![],
},
RoleSchema {
role_id: RoleId::new("worker"),
required_traits: vec![],
skill_focus: vec![Skill::Technical],
relationship_constraints: vec![],
routine_template: vec![],
},
RoleSchema {
role_id: RoleId::new("guard"),
required_traits: vec![],
skill_focus: vec![Skill::Combat, Skill::Observation],
relationship_constraints: vec![],
routine_template: vec![],
},
RoleSchema {
role_id: RoleId::new("contact"),
required_traits: vec![],
skill_focus: vec![Skill::Stealth, Skill::Persuasion],
relationship_constraints: vec![],
routine_template: vec![],
},
],
space: SpaceSpec {
tile_count_min: 30,
tile_count_max: 80,
sightline_zones: vec![],
privacy_level: PrivacyLevel::SemiPrivate,
traffic_pattern: TrafficPattern::Destination,
},
triangles: vec![
TriangleDef {
triangle_id: TriangleId(0),
roles: [
RoleId::new("manager"),
RoleId::new("worker"),
RoleId::new("guard"),
],
conflict_type: ConflictType::ResourceCompetition,
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
relationship_constraints: vec![],
},
TriangleDef {
triangle_id: TriangleId(0),
roles: [
RoleId::new("manager"),
RoleId::new("contact"),
RoleId::new("guard"),
],
conflict_type: ConflictType::AuthorityChallenge,
interest_axes: [NpcAxis::Relationships, NpcAxis::Tolerance, NpcAxis::Secret],
relationship_constraints: vec![],
},
],
dialogue_pools: vec![],
cross_template_links: vec![CrossTemplateLinkSpec {
from_role: RoleId::new("worker"),
to_template_slug: "bar".to_string(),
relationship: crate::npc::RelationshipKind::Colleague,
}],
}
}
#[test]
fn spawn_template_npcs_fills_all_role_slots() {
use crate::content::template::{RoleId, TemplateId};
use crate::simulation::rng::SimRng;
let mut world = create_test_world();
let template_def = minimal_template_def_4_roles();
let template_id = TemplateId::from_seed_and_slug(42, "test-hub");
let mut rng = SimRng::new(42);
let result = spawn_template_npcs(&mut world, &template_def, template_id, 42, &mut rng);
assert_eq!(result.role_assignments.len(), 4, "all 4 role slots must be filled");
assert_eq!(result.entities.len(), 4, "4 entities expected");
assert!(result.role_assignments.contains_key(&RoleId::new("manager")));
assert!(result.role_assignments.contains_key(&RoleId::new("worker")));
assert!(result.role_assignments.contains_key(&RoleId::new("guard")));
assert!(result.role_assignments.contains_key(&RoleId::new("contact")));
}
#[test]
fn spawn_template_npcs_sets_template_ownership() {
use crate::content::template::{RoleId, TemplateId, TemplateOwnership};
use crate::simulation::rng::SimRng;
let mut world = create_test_world();
let template_def = minimal_template_def_4_roles();
let template_id = TemplateId::from_seed_and_slug(42, "test-hub");
let mut rng = SimRng::new(42);
let result = spawn_template_npcs(&mut world, &template_def, template_id, 42, &mut rng);
let valid_roles = [
RoleId::new("manager"),
RoleId::new("worker"),
RoleId::new("guard"),
RoleId::new("contact"),
];
for entity in &result.entities {
let ownership = world
.get::<TemplateOwnership>(*entity)
.expect("entity must have TemplateOwnership");
assert_eq!(ownership.template_id, template_id, "template_id must match");
assert!(
valid_roles.contains(&ownership.role_id),
"role_id {:?} not in expected roles",
ownership.role_id,
);
}
}
#[test]
fn spawn_template_npcs_records_cross_template_references() {
use crate::content::template::{TemplateId, TemplateReferenceMap};
use crate::simulation::rng::SimRng;
let mut world = create_test_world();
let template_def = minimal_template_def_4_roles();
let template_id = TemplateId::from_seed_and_slug(42, "test-hub");
let mut rng = SimRng::new(42);
spawn_template_npcs(&mut world, &template_def, template_id, 42, &mut rng);
let ref_map = world.resource::<TemplateReferenceMap>();
let outgoing = ref_map.outgoing(template_id);
assert_eq!(outgoing.len(), 1, "one cross-template link expected");
assert_eq!(outgoing[0].from_template, template_id);
let expected_target = TemplateId::from_seed_and_slug(42, "bar");
assert_eq!(
outgoing[0].to_template, expected_target,
"target template_id must match seed+slug derivation"
);
}
#[test]
fn spawn_template_npcs_wires_relationship_constraints() {
use crate::content::template::{RoleId, TemplateId};
use crate::simulation::rng::SimRng;
let mut world = create_test_world();
let template_def = minimal_template_def_4_roles();
let template_id = TemplateId::from_seed_and_slug(42, "test-hub");
let mut rng = SimRng::new(42);
let result = spawn_template_npcs(&mut world, &template_def, template_id, 42, &mut rng);
// The "manager" role has a RelationshipConstraint toward "worker" (trust 14).
let manager_stable_id = result.role_assignments[&RoleId::new("manager")];
let worker_stable_id = result.role_assignments[&RoleId::new("worker")];
let manager_entity = world
.resource::<EntityRegistry>()
.to_entity(&manager_stable_id)
.unwrap();
let rels = world.get::<npc::Relationships>(manager_entity).unwrap();
let worker_rel = rels.entries.iter().find(|r| r.target_id == worker_stable_id);
assert!(
worker_rel.is_some(),
"manager must have a relationship toward worker (from RelationshipConstraint)"
);
let trust = worker_rel.unwrap().trust_level;
assert!(
trust >= 1 && trust <= 4,
"trust {} not in authored range [1, 4]",
trust
);
}
#[test]
fn spawn_template_npcs_is_deterministic() {
use crate::content::template::TemplateId;
use crate::simulation::rng::SimRng;
let template_def = minimal_template_def_4_roles();
let template_id = TemplateId::from_seed_and_slug(42, "test-hub");
let mut world1 = create_test_world();
let result1 =
spawn_template_npcs(&mut world1, &template_def, template_id, 42, &mut SimRng::new(42));
let mut world2 = create_test_world();
let result2 =
spawn_template_npcs(&mut world2, &template_def, template_id, 42, &mut SimRng::new(42));
assert_eq!(
result1.role_assignments, result2.role_assignments,
"spawn_template_npcs must be deterministic (D-010)"
);
}
}