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
// ===========================================================================