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:
Generated
+1
-1
@@ -1092,7 +1092,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.19"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_ecs",
|
||||
|
||||
@@ -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 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::<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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user