diff --git a/server/src/content/template.rs b/server/src/content/template.rs index 93527b008..55e844d34 100644 --- a/server/src/content/template.rs +++ b/server/src/content/template.rs @@ -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, +} + +/// 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 = 4–8 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, + /// Role definitions (D-024 10-axis NPC constraints per role, 4–8 roles). + pub roles: Vec, + /// Spatial requirements (D-025: 30–80 sim tiles). + pub space: SpaceSpec, + /// Triangle definitions (D-024 minimum 2 per template, D-087 configuration). + pub triangles: Vec, + /// Dialogue pool references for runtime line selection (D-028). + #[serde(default)] + pub dialogue_pools: Vec, + /// Cross-template link specs resolved to `TemplateReferenceMap` at instantiation. + #[serde(default)] + pub cross_template_links: Vec, +} + +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 = { + 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 // =========================================================================== diff --git a/server/tests/triangle_validation.rs b/server/tests/triangle_validation.rs new file mode 100644 index 000000000..0a5355e97 --- /dev/null +++ b/server/tests/triangle_validation.rs @@ -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" + ); +}