feat(simulation): implement triangle validation and cross-template generation (#108, #109)

- Add ValidationError enum with three failure modes: ConflictViability
  (missing Want axis), RelationshipCoherence (empty constraints),
  InterestDivergence (duplicate interest axes)
- Add validate_triangle_def() pure function enforcing all three checks
  in priority order (per D-087)
- Add generate_cross_template_triangles() function that combines NPC
  pools from two templates, validates each TriangleDef before processing,
  and assigns ownership to template_a
- 10 integration tests in tests/triangle_validation.rs covering all
  validation failure modes, ordering guarantees, cross-template span,
  invalid def skipping, determinism, and intra-template isolation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 11:11:31 +01:00
co-authored by Claude Sonnet 4.6
parent 93e050f3bf
commit c4e09bff77
2 changed files with 792 additions and 0 deletions
+350
View File
@@ -462,6 +462,131 @@ impl TriangleDef {
}
}
// ===========================================================================
// #159 — Full Tier 2 template document
// ===========================================================================
/// Dialogue pool reference within a template (D-028).
///
/// Refers to an existing authored dialogue pool by (location, roles).
/// The pool content lives in the campaign dialogue files; this reference
/// wires the pool to the social site for runtime line selection.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TemplateDialoguePoolRef {
/// The location identifier the pool is authored under (e.g., "the-terminal").
pub location: String,
/// Role slugs from this template that draw from the pool.
/// Empty = all roles may draw from this pool.
#[serde(default)]
pub roles: Vec<String>,
}
/// Specification of a cross-template link authored in the template file (D-025).
///
/// At instantiation time the engine resolves these into `TemplateReference`
/// entries in `TemplateReferenceMap`. The actual target template is identified
/// by slug, not a pre-computed `TemplateId`, because IDs are seed-dependent.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CrossTemplateLinkSpec {
/// The role in THIS template that holds the cross-template relationship.
pub from_role: RoleId,
/// Slug of the external template being referenced.
pub to_template_slug: String,
/// Relationship kind for the `TemplateReference` link.
pub relationship: RelationshipKind,
}
/// Full Tier 2 social site template definition (#159).
///
/// The composite YAML document combining all sub-schemas into one canonical
/// template file. Authored in `server/data/templates/*.yaml` and loaded by
/// the template instantiation engine (#161).
///
/// Per D-023 (three-tier content model) and D-025 (social site as atomic unit):
/// one file = one social site = 48 roles + spatial spec + 2+ triangles.
///
/// **YAML authoring note:** `triangle_id` fields in authored `TriangleDef`s
/// should be set to 0 as a placeholder — the instantiation engine overwrites
/// them with `TriangleId::from_seed_and_roles(world_seed, &roles)` at runtime.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FullTemplateDef {
/// Stable slug used to derive `TemplateId` via `TemplateId::from_seed_and_slug`.
pub slug: String,
/// Human-readable display name.
pub display_name: String,
/// Optional authoring description (not surfaced to players).
#[serde(default)]
pub description: Option<String>,
/// Role definitions (D-024 10-axis NPC constraints per role, 48 roles).
pub roles: Vec<RoleSchema>,
/// Spatial requirements (D-025: 3080 sim tiles).
pub space: SpaceSpec,
/// Triangle definitions (D-024 minimum 2 per template, D-087 configuration).
pub triangles: Vec<TriangleDef>,
/// Dialogue pool references for runtime line selection (D-028).
#[serde(default)]
pub dialogue_pools: Vec<TemplateDialoguePoolRef>,
/// Cross-template link specs resolved to `TemplateReferenceMap` at instantiation.
#[serde(default)]
pub cross_template_links: Vec<CrossTemplateLinkSpec>,
}
impl FullTemplateDef {
/// Validate the full template definition.
///
/// Checks (in order):
/// 1. No duplicate `role_id`s in the roles list.
/// 2. Each role validates individually.
/// 3. Space spec validates.
/// 4. At least 2 triangles (D-024).
/// 5. Each triangle validates individually.
/// 6. All role references in triangles are defined in the roles list.
///
/// Returns `Err` with the first failure description found.
pub fn validate(&self) -> Result<(), String> {
// 1 — no duplicate role IDs
validate_role_schemas_no_duplicate_ids(&self.roles)?;
// 2 — each role validates
for role in &self.roles {
role.validate().map_err(|e| format!("role '{}': {}", role.role_id.0, e))?;
}
// 3 — space spec
self.space.validate()?;
// 4 — minimum 2 triangles
if self.triangles.len() < 2 {
return Err(format!(
"template '{}': fewer than 2 triangles ({}) — D-024 requires minimum 2",
self.slug,
self.triangles.len()
));
}
// 5 — each triangle validates
for tri in &self.triangles {
tri.validate()
.map_err(|e| format!("triangle {:?}: {}", tri.triangle_id, e))?;
}
// 6 — triangle role references must exist in roles list
let role_ids: BTreeSet<&RoleId> = self.roles.iter().map(|r| &r.role_id).collect();
for tri in &self.triangles {
for role_id in &tri.roles {
if !role_ids.contains(role_id) {
return Err(format!(
"triangle {:?}: role '{}' is not defined in template roles",
tri.triangle_id, role_id.0
));
}
}
}
Ok(())
}
}
// ===========================================================================
// #107 — Intra-template triangle generation
// ===========================================================================
@@ -616,6 +741,231 @@ pub fn generate_intra_template_triangles(
result
}
// ===========================================================================
// #109 — Triangle validation
// ===========================================================================
/// Errors returned when a `TriangleDef` fails instantiation-time validation.
///
/// These checks run before role assignment to catch degenerate triangle
/// definitions that cannot produce meaningful drama.
///
/// Spec: #109, D-024, D-087
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationError {
/// None of the three role interest_axes is `NpcAxis::Want`.
///
/// A viable conflict requires at least one role whose primary tension is
/// their Want axis — without it there is no active driver of conflict.
ConflictViability { triangle_id: TriangleId },
/// `relationship_constraints` is empty — no authored relationship links
/// the three roles together.
///
/// A coherent triangle requires at least one explicit relationship
/// constraint documenting how the roles are socially connected.
RelationshipCoherence { triangle_id: TriangleId },
/// Two or more roles share the same `interest_axes` value.
///
/// Each role must have a distinct tension axis so their interests genuinely
/// diverge. Duplicate axes indicate the triangle is underspecified.
InterestDivergence {
triangle_id: TriangleId,
duplicate_axis: NpcAxis,
},
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ValidationError::ConflictViability { triangle_id } => write!(
f,
"Triangle {:?}: conflict viability — no NpcAxis::Want among the three interest_axes",
triangle_id
),
ValidationError::RelationshipCoherence { triangle_id } => write!(
f,
"Triangle {:?}: relationship coherence — relationship_constraints is empty",
triangle_id
),
ValidationError::InterestDivergence { triangle_id, duplicate_axis } => write!(
f,
"Triangle {:?}: interest divergence — duplicate interest_axes value {:?}",
triangle_id, duplicate_axis
),
}
}
}
/// Validate a `TriangleDef` for instantiation quality (#109).
///
/// Three checks:
/// 1. **Conflict viability** — at least one of the three `interest_axes` is
/// `NpcAxis::Want`, ensuring an active want-driven tension.
/// 2. **Relationship coherence** — `relationship_constraints` is non-empty,
/// documenting at least one social link among the three roles.
/// 3. **Interest divergence** — all three `interest_axes` are distinct, so
/// each role brings a genuinely different tension to the triangle.
///
/// Returns `Ok(())` if all checks pass, or `Err(ValidationError)` on the
/// first failure (conflict viability is checked first, then coherence, then
/// divergence).
pub fn validate_triangle_def(def: &TriangleDef) -> Result<(), ValidationError> {
// 1. Conflict viability: at least one Want axis
if !def.interest_axes.iter().any(|a| *a == NpcAxis::Want) {
return Err(ValidationError::ConflictViability {
triangle_id: def.triangle_id,
});
}
// 2. Relationship coherence: at least one relationship constraint
if def.relationship_constraints.is_empty() {
return Err(ValidationError::RelationshipCoherence {
triangle_id: def.triangle_id,
});
}
// 3. Interest divergence: all three axes must be distinct
let [a0, a1, a2] = def.interest_axes;
if a0 == a1 {
return Err(ValidationError::InterestDivergence {
triangle_id: def.triangle_id,
duplicate_axis: a0,
});
}
if a0 == a2 {
return Err(ValidationError::InterestDivergence {
triangle_id: def.triangle_id,
duplicate_axis: a0,
});
}
if a1 == a2 {
return Err(ValidationError::InterestDivergence {
triangle_id: def.triangle_id,
duplicate_axis: a1,
});
}
Ok(())
}
// ===========================================================================
// #108 — Cross-template triangle generation
// ===========================================================================
/// Generate triangle instances that span two social site templates (#108).
///
/// Implements the 1 cross-template triangle required by D-024 ("2 per template
/// minimum, 1 cross-template"). Role assignments draw from NPCs owned by
/// *either* `template_a_id` or `template_b_id` — the combined pool is used
/// for role lookup.
///
/// The generated `TriangleState` is owned by `template_a_id`. D-025 ownership
/// model: NPCs are owned by one template but can hold reference roles in
/// another; the cross-template triangle represents this social link.
///
/// **Validation:** each `TriangleDef` is validated via `validate_triangle_def`
/// before role assignment. Invalid defs are skipped with a warning added to
/// the result.
///
/// **Fallback behavior:** same as `generate_intra_template_triangles` — if no
/// NPC satisfies a role constraint, the closest available NPC is used and a
/// warning is logged. Generation never panics.
///
/// All randomness flows through `rng` for determinism (D-010).
pub fn generate_cross_template_triangles(
world: &mut World,
template_a_id: TemplateId,
template_b_id: TemplateId,
defs: &[TriangleDef],
rng: &mut SimRng,
) -> TriangleGenerationResult {
let mut result = TriangleGenerationResult {
triangles: Vec::new(),
warnings: Vec::new(),
};
// Collect NPCs from both templates into a single role → StableId lookup.
// BTreeMap for determinism (D-010). If both templates define the same
// role slug, template_a wins (insertion order: a first, b second via
// entry().or_insert).
let role_to_npc: BTreeMap<RoleId, StableId> = {
let mut q = world.query::<(&TemplateOwnership, &StableEntityId)>();
let mut map = BTreeMap::new();
for (own, sid) in q.iter(world) {
if own.template_id == template_a_id || own.template_id == template_b_id {
map.entry(own.role_id.clone()).or_insert(sid.0);
}
}
map
};
for def in defs {
// Validate the def before attempting role assignment.
if let Err(e) = validate_triangle_def(def) {
result.warnings.push(format!(
"Cross-template triangle {:?}: skipped — validation failed: {}",
def.triangle_id, e
));
continue;
}
let mut role_assignments = BTreeMap::new();
let mut assigned_npcs = BTreeSet::new();
let mut assignment_ok = true;
for role_id in &def.roles {
if let Some(&stable_id) = role_to_npc.get(role_id) {
if !assigned_npcs.contains(&stable_id) {
role_assignments.insert(role_id.clone(), stable_id);
assigned_npcs.insert(stable_id);
continue;
}
}
// Fallback: first available NPC not already in this triangle.
let fallback = role_to_npc
.values()
.find(|sid| !assigned_npcs.contains(sid));
if let Some(&fallback_sid) = fallback {
result.warnings.push(format!(
"Cross-template triangle {:?}: no NPC for role '{}' — assigned fallback StableId({})",
def.triangle_id, role_id.0, fallback_sid.0
));
role_assignments.insert(role_id.clone(), fallback_sid);
assigned_npcs.insert(fallback_sid);
} else {
result.warnings.push(format!(
"Cross-template triangle {:?}: no NPC available for role '{}' — skipping",
def.triangle_id, role_id.0
));
assignment_ok = false;
break;
}
}
if !assignment_ok {
continue;
}
let initial_tension: u8 = rng.rng.random_range(5_u8..=25);
let tension_rate: u8 = rng.rng.random_range(1_u8..=5);
result.triangles.push(TriangleState {
triangle_id: def.triangle_id,
role_assignments,
tension: initial_tension,
phase: TrianglePhase::Simmering,
tension_rate,
template_id: template_a_id, // cross-template triangle owned by template_a
});
}
result
}
// ===========================================================================
// #250 — Triangle escalation system
// ===========================================================================
+442
View File
@@ -0,0 +1,442 @@
//! Integration tests for triangle validation and cross-template generation (#108, #109).
//!
//! Spec references:
//! - D-024: NPC generation model — minimum 2 triangles per template, 1 cross-template
//! - D-087: v0.1 triangle configuration — 3 active forks, 2 passive tensions
//! - D-025: social site as atomic template unit — cross-template reference links
//!
//! Test naming follows the cargo test filter target:
//! `cargo test -p settled-reach-server -- triangle_validation`
use settled_reach_server::{
content::template::{
generate_cross_template_triangles, generate_intra_template_triangles,
validate_triangle_def, ConflictType, NpcAxis, RelationshipConstraint, RoleId,
TemplateId, TemplateOwnership, TriangleDef, TriangleId, TrianglePhase, TrustRange,
ValidationError,
},
knowledge::{registry::StableEntityId, types::StableId},
npc::{Npc, RelationshipKind},
simulation::rng::SimRng,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Minimal valid TriangleDef that passes all three validation checks.
///
/// - interest_axes: [Want, Secret, Relationships] → all distinct, has Want
/// - relationship_constraints: non-empty
fn valid_triangle_def(id: u64) -> TriangleDef {
TriangleDef {
triangle_id: TriangleId(id),
roles: [
RoleId::new("ops-manager"),
RoleId::new("freight-handler"),
RoleId::new("inspector"),
],
conflict_type: ConflictType::LoyaltyConflict,
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("freight-handler"),
kind: RelationshipKind::Colleague,
required_trust: TrustRange { min: 1, max: 5 },
}],
}
}
/// Spawn an NPC entity with TemplateOwnership in the given world.
fn spawn_template_npc(
world: &mut bevy_ecs::world::World,
template_id: TemplateId,
role: &str,
stable_id: u64,
) {
world.spawn((
Npc,
TemplateOwnership {
template_id,
role_id: RoleId::new(role),
},
StableEntityId(StableId(stable_id)),
));
}
// ---------------------------------------------------------------------------
// #109 — Triangle validation: unit tests for each failure mode
// ---------------------------------------------------------------------------
/// A valid triangle passes all three validation checks.
#[test]
fn triangle_validation_valid_triangle_passes_all_checks() {
let def = valid_triangle_def(1);
assert!(
validate_triangle_def(&def).is_ok(),
"valid triangle must pass all checks: {:?}",
validate_triangle_def(&def)
);
}
/// Conflict viability fails when no interest_axes entry is NpcAxis::Want.
///
/// D-024: active conflict requires at least one role whose tension is Want-driven.
#[test]
fn triangle_validation_conflict_viability_fails_without_want_axis() {
let def = TriangleDef {
triangle_id: TriangleId(10),
roles: [
RoleId::new("worker-a"),
RoleId::new("worker-b"),
RoleId::new("supervisor"),
],
conflict_type: ConflictType::LatentTension,
// No Want axis — all passive tensions
interest_axes: [NpcAxis::Contentment, NpcAxis::Tolerance, NpcAxis::Routine],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("worker-b"),
kind: RelationshipKind::Colleague,
required_trust: TrustRange { min: 0, max: 3 },
}],
};
let result = validate_triangle_def(&def);
assert!(
matches!(result, Err(ValidationError::ConflictViability { triangle_id: TriangleId(10) })),
"expected ConflictViability error, got: {:?}",
result
);
}
/// Relationship coherence fails when relationship_constraints is empty.
///
/// A coherent triangle must document at least one social link among the three roles.
#[test]
fn triangle_validation_relationship_coherence_fails_without_constraints() {
let def = TriangleDef {
triangle_id: TriangleId(20),
roles: [
RoleId::new("smuggler"),
RoleId::new("detective"),
RoleId::new("informant"),
],
conflict_type: ConflictType::SecretExposure,
// Has Want axis (passes conflict viability)
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
// Empty constraints — fails coherence
relationship_constraints: vec![],
};
let result = validate_triangle_def(&def);
assert!(
matches!(result, Err(ValidationError::RelationshipCoherence { triangle_id: TriangleId(20) })),
"expected RelationshipCoherence error, got: {:?}",
result
);
}
/// Interest divergence fails when two roles share the same interest_axis.
///
/// All three axes must be distinct so each role brings a different tension.
#[test]
fn triangle_validation_interest_divergence_fails_with_duplicate_axes() {
let def = TriangleDef {
triangle_id: TriangleId(30),
roles: [
RoleId::new("dock-worker"),
RoleId::new("cargo-lead"),
RoleId::new("port-officer"),
],
conflict_type: ConflictType::ResourceCompetition,
// Two roles both have Want — duplicate axis
interest_axes: [NpcAxis::Want, NpcAxis::Want, NpcAxis::Secret],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("cargo-lead"),
kind: RelationshipKind::Colleague,
required_trust: TrustRange { min: 0, max: 5 },
}],
};
let result = validate_triangle_def(&def);
assert!(
matches!(
result,
Err(ValidationError::InterestDivergence {
triangle_id: TriangleId(30),
duplicate_axis: NpcAxis::Want,
})
),
"expected InterestDivergence(Want) error, got: {:?}",
result
);
}
/// Divergence check catches the third axis duplicating the first.
///
/// Edge case: axes[0] == axes[2], but axes[1] is different.
#[test]
fn triangle_validation_interest_divergence_first_last_duplicate() {
let def = TriangleDef {
triangle_id: TriangleId(31),
roles: [
RoleId::new("role-a"),
RoleId::new("role-b"),
RoleId::new("role-c"),
],
conflict_type: ConflictType::AuthorityChallenge,
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Want], // 0 == 2
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("role-b"),
kind: RelationshipKind::Colleague,
required_trust: TrustRange { min: 0, max: 5 },
}],
};
let result = validate_triangle_def(&def);
assert!(
matches!(
result,
Err(ValidationError::InterestDivergence {
triangle_id: TriangleId(31),
duplicate_axis: NpcAxis::Want,
})
),
"expected InterestDivergence(Want) for axes[0]==axes[2], got: {:?}",
result
);
}
/// Validation checks are ordered: ConflictViability fires before Coherence.
///
/// A def with no Want axis AND empty constraints should fail with
/// ConflictViability, not RelationshipCoherence.
#[test]
fn triangle_validation_conflict_viability_checked_before_coherence() {
let def = TriangleDef {
triangle_id: TriangleId(40),
roles: [
RoleId::new("a"),
RoleId::new("b"),
RoleId::new("c"),
],
conflict_type: ConflictType::LatentTension,
interest_axes: [NpcAxis::Contentment, NpcAxis::Tolerance, NpcAxis::Routine],
relationship_constraints: vec![], // also fails coherence
};
let result = validate_triangle_def(&def);
assert!(
matches!(result, Err(ValidationError::ConflictViability { .. })),
"ConflictViability must be checked before RelationshipCoherence, got: {:?}",
result
);
}
// ---------------------------------------------------------------------------
// #108 — Cross-template triangle generation
// ---------------------------------------------------------------------------
/// Two instantiated templates produce 1 cross-template TriangleState
/// with role assignments spanning both templates.
///
/// Spec: D-024 ("1 cross-template" requirement), D-025 (ownership model)
#[test]
fn triangle_validation_cross_template_spans_two_templates() {
let mut world = bevy_ecs::world::World::new();
let mut rng = SimRng::new(42);
let hub_id = TemplateId::from_seed_and_slug(42, "logistics-hub");
let bar_id = TemplateId::from_seed_and_slug(42, "last-shift-bar");
// Logistics hub roles
spawn_template_npc(&mut world, hub_id, "ops-manager", 1);
spawn_template_npc(&mut world, hub_id, "freight-handler", 2);
// Bar roles
spawn_template_npc(&mut world, bar_id, "bartender", 3);
// Cross-template triangle: ops-manager (hub) + freight-handler (hub) + bartender (bar)
let def = valid_triangle_def(999);
let overridden = TriangleDef {
roles: [
RoleId::new("ops-manager"),
RoleId::new("freight-handler"),
RoleId::new("bartender"),
],
..def
};
let result = generate_cross_template_triangles(
&mut world,
hub_id,
bar_id,
&[overridden],
&mut rng,
);
assert!(
result.warnings.is_empty(),
"no warnings expected for valid cross-template triangle: {:?}",
result.warnings
);
assert_eq!(
result.triangles.len(),
1,
"should generate exactly 1 cross-template triangle"
);
let state = &result.triangles[0];
assert_eq!(
state.template_id, hub_id,
"cross-template triangle must be owned by template_a (hub)"
);
assert_eq!(state.role_assignments.len(), 3);
// Verify role assignments span both templates
let ops = state.role_assignments[&RoleId::new("ops-manager")];
let freight = state.role_assignments[&RoleId::new("freight-handler")];
let bartender = state.role_assignments[&RoleId::new("bartender")];
assert_eq!(ops, StableId(1), "ops-manager must map to hub NPC 1");
assert_eq!(freight, StableId(2), "freight-handler must map to hub NPC 2");
assert_eq!(bartender, StableId(3), "bartender must map to bar NPC 3");
assert_eq!(state.phase, TrianglePhase::Simmering);
assert!(state.tension >= 5 && state.tension <= 25, "tension in seeded range");
assert!(state.tension_rate >= 1 && state.tension_rate <= 5, "rate in seeded range");
}
/// Cross-template generation skips defs that fail validation, adding a warning.
#[test]
fn triangle_validation_cross_template_skips_invalid_defs() {
let mut world = bevy_ecs::world::World::new();
let mut rng = SimRng::new(42);
let hub_id = TemplateId::from_seed_and_slug(1, "hub");
let bar_id = TemplateId::from_seed_and_slug(1, "bar");
spawn_template_npc(&mut world, hub_id, "ops-manager", 1);
spawn_template_npc(&mut world, hub_id, "freight-handler", 2);
spawn_template_npc(&mut world, bar_id, "bartender", 3);
// Invalid def: no Want axis (fails conflict viability)
let invalid = TriangleDef {
triangle_id: TriangleId(50),
roles: [
RoleId::new("ops-manager"),
RoleId::new("freight-handler"),
RoleId::new("bartender"),
],
conflict_type: ConflictType::LatentTension,
interest_axes: [NpcAxis::Contentment, NpcAxis::Tolerance, NpcAxis::Routine],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("freight-handler"),
kind: RelationshipKind::Colleague,
required_trust: TrustRange { min: 0, max: 3 },
}],
};
let result = generate_cross_template_triangles(&mut world, hub_id, bar_id, &[invalid], &mut rng);
assert_eq!(result.triangles.len(), 0, "invalid def must be skipped");
assert_eq!(result.warnings.len(), 1, "exactly one warning for the skipped def");
assert!(
result.warnings[0].contains("validation failed"),
"warning must mention validation failure: {}",
result.warnings[0]
);
}
/// Cross-template generation is deterministic for the same seed (D-010).
#[test]
fn triangle_validation_cross_template_deterministic() {
let hub_id = TemplateId::from_seed_and_slug(42, "hub");
let bar_id = TemplateId::from_seed_and_slug(42, "bar");
let def = valid_triangle_def(1);
let make_world = || {
let mut world = bevy_ecs::world::World::new();
spawn_template_npc(&mut world, hub_id, "ops-manager", 1);
spawn_template_npc(&mut world, hub_id, "freight-handler", 2);
spawn_template_npc(&mut world, bar_id, "inspector", 3);
world
};
let overridden = TriangleDef {
roles: [
RoleId::new("ops-manager"),
RoleId::new("freight-handler"),
RoleId::new("inspector"),
],
..def.clone()
};
let mut world1 = make_world();
let result1 = generate_cross_template_triangles(
&mut world1,
hub_id,
bar_id,
&[overridden.clone()],
&mut SimRng::new(42),
);
let mut world2 = make_world();
let result2 = generate_cross_template_triangles(
&mut world2,
hub_id,
bar_id,
&[overridden],
&mut SimRng::new(42),
);
assert_eq!(result1.triangles.len(), 1);
assert_eq!(result2.triangles.len(), 1);
assert_eq!(
result1.triangles[0].tension,
result2.triangles[0].tension,
"cross-template generation must be deterministic (D-010)"
);
assert_eq!(
result1.triangles[0].tension_rate,
result2.triangles[0].tension_rate
);
}
/// D-024: cross-template generation is separate from intra-template generation.
/// Intra-template only sees its own template's NPCs.
#[test]
fn triangle_validation_intra_template_does_not_see_other_template_npcs() {
let mut world = bevy_ecs::world::World::new();
let mut rng = SimRng::new(42);
let hub_id = TemplateId::from_seed_and_slug(1, "hub");
let bar_id = TemplateId::from_seed_and_slug(1, "bar");
// Only hub NPCs for roles ops-manager, freight-handler
spawn_template_npc(&mut world, hub_id, "ops-manager", 1);
spawn_template_npc(&mut world, hub_id, "freight-handler", 2);
// Bar NPC exists but should NOT be used by intra-template hub generation
spawn_template_npc(&mut world, bar_id, "inspector", 3);
// Triangle requiring ops-manager + freight-handler + inspector
// intra-template hub generation cannot find "inspector" in hub NPCs
let def = TriangleDef {
triangle_id: TriangleId(5),
roles: [
RoleId::new("ops-manager"),
RoleId::new("freight-handler"),
RoleId::new("inspector"),
],
conflict_type: ConflictType::LoyaltyConflict,
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
relationship_constraints: vec![],
};
let result = generate_intra_template_triangles(&mut world, hub_id, &[def, valid_triangle_def(6)], &mut rng);
// The def needing "inspector" should fall back (inspector is in bar, not hub)
// At least one warning about the missing role
assert!(
!result.warnings.is_empty(),
"intra-template must not find bar NPCs — warning expected for missing 'inspector' role"
);
}