From ace5cb811fc5adbf24e15652efa7fe3653fc1cce Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 27 Feb 2026 17:46:47 +0100 Subject: [PATCH] =?UTF-8?q?wip(simulation):=20template-to-instance=20mappi?= =?UTF-8?q?ng=20=E2=80=94=20spawn=5Ftemplate=5Fnpcs=20(#166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/Cargo.lock | 2 +- server/src/content/spawn.rs | 375 ++++++++++++++++++++++++++++++++++++ 2 files changed, 376 insertions(+), 1 deletion(-) diff --git a/server/Cargo.lock b/server/Cargo.lock index 9b58cf422..d469ae4e2 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1092,7 +1092,7 @@ dependencies = [ [[package]] name = "settled-reach-server" -version = "0.1.19" +version = "0.1.20" dependencies = [ "bevy_app", "bevy_ecs", diff --git a/server/src/content/spawn.rs b/server/src/content/spawn.rs index b23e056b4..3368111f6 100644 --- a/server/src/content/spawn.rs +++ b/server/src/content/spawn.rs @@ -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 { } } +// =========================================================================== +// #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, + /// ECS entity handles in the same order as `FullTemplateDef::roles`. + pub entities: Vec, +} + +/// 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 = BTreeMap::new(); + let mut role_entities: BTreeMap = BTreeMap::new(); + let mut entities: Vec = 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::().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::(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::(); + 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::() + .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::(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::(*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::(); + 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 1–4). + 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::() + .to_entity(&manager_stable_id) + .unwrap(); + + let rels = world.get::(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)" + ); + } }