- 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
|
||||
// ===========================================================================
|
||||
|
||||
Reference in New Issue
Block a user