Files
settled-reach/server/tests/triangle_validation.rs
T
jpmschweitzerandClaude Sonnet 4.6 c4e09bff77 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>
2026-02-27 11:11:31 +01:00

443 lines
16 KiB
Rust

//! 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"
);
}