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]]
|
[[package]]
|
||||||
name = "settled-reach-server"
|
name = "settled-reach-server"
|
||||||
version = "0.1.19"
|
version = "0.1.20"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bevy_app",
|
"bevy_app",
|
||||||
"bevy_ecs",
|
"bevy_ecs",
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ use crate::simulation::movement::TilePosition;
|
|||||||
use crate::simulation::tier::ActiveSim;
|
use crate::simulation::tier::ActiveSim;
|
||||||
use crate::simulation::time::DayPhase;
|
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").
|
/// Stable content identifier from YAML (e.g., "kael-davan", "sera-venn").
|
||||||
///
|
///
|
||||||
/// Bridges authoring identity to ECS entities. Independent of StableId —
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1180,4 +1335,224 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(world.get::<npc::Relationships>(entity).is_none());
|
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