- 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:
@@ -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 = 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<String>,
|
||||
/// Role definitions (D-024 10-axis NPC constraints per role, 4–8 roles).
|
||||
pub roles: Vec<RoleSchema>,
|
||||
/// 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<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
|
||||
// ===========================================================================
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user