feat(simulation): tier 2 template definition format — FullTemplateDef with tests (#159)
Add FullTemplateDef integration tests: round-trip YAML serialization, space spec validation, routine schedule, sightline zones, dialogue pool refs, cross-template link specs, and logistics-hub template fixture. Tests cover the full social site template document structure per D-023, D-024, D-025, D-028. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,21 @@
|
||||
//! Integration tests for the template schema system (tickets #163, #164, #165, #106).
|
||||
//! Integration tests for the template schema system (tickets #163, #164, #165, #106, #159).
|
||||
//!
|
||||
//! Tests YAML round-trips, validation logic, and ECS component interactions
|
||||
//! against the spec decisions:
|
||||
//! - D-023: three-tier content model
|
||||
//! - D-024: 10-axis NPC model, triangles as atomic unit
|
||||
//! - D-025: social site / single-ownership model
|
||||
//! - D-028: dialogue tagged pools
|
||||
//! - D-087: v0.1 triangle configuration
|
||||
//! - D-089: self-contained triangle forks, no cross-triangle cascade
|
||||
//! - D-010: determinism (no HashMap, FNV-1a IDs)
|
||||
|
||||
use settled_reach_server::content::template::{
|
||||
validate_role_schemas_no_duplicate_ids, ConflictType, NpcAxis, PrivacyLevel,
|
||||
RelationshipConstraint, RoleId, RoleSchema, SpaceSpec, TemplateId,
|
||||
TemplateOwnership, TemplateReference, TemplateReferenceMap, TemplateRoutineEntry,
|
||||
TrafficPattern, TriangleDef, TriangleId, TrustRange,
|
||||
validate_role_schemas_no_duplicate_ids, ConflictType, CrossTemplateLinkSpec, FullTemplateDef,
|
||||
NpcAxis, PrivacyLevel, RelationshipConstraint, RoleId, RoleSchema, SightlineZone, SpaceSpec,
|
||||
TemplateDialoguePoolRef, TemplateId, TemplateOwnership, TemplateReference,
|
||||
TemplateReferenceMap, TemplateRoutineEntry, TrafficPattern, TriangleDef, TriangleId,
|
||||
TrustRange,
|
||||
};
|
||||
use settled_reach_server::npc::{PersonalityTrait, RelationshipKind, Skill};
|
||||
|
||||
@@ -637,3 +639,268 @@ fn template_ownership_survives_clone_for_save_state() {
|
||||
let saved = ownership.clone();
|
||||
assert_eq!(saved, ownership, "TemplateOwnership must survive clone (save path)");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #159: Full Tier 2 template document — FullTemplateDef
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a minimal valid FullTemplateDef with two roles and two triangles.
|
||||
fn minimal_full_template() -> FullTemplateDef {
|
||||
FullTemplateDef {
|
||||
slug: "test-site".to_string(),
|
||||
display_name: "Test Social Site".to_string(),
|
||||
description: None,
|
||||
roles: vec![
|
||||
RoleSchema {
|
||||
role_id: RoleId::new("manager"),
|
||||
required_traits: vec![PersonalityTrait::Cautious],
|
||||
skill_focus: vec![Skill::Persuasion],
|
||||
relationship_constraints: vec![RelationshipConstraint {
|
||||
with_role: RoleId::new("worker"),
|
||||
kind: RelationshipKind::Superior,
|
||||
required_trust: TrustRange { min: 0, max: 5 },
|
||||
}],
|
||||
routine_template: vec![],
|
||||
},
|
||||
RoleSchema {
|
||||
role_id: RoleId::new("worker"),
|
||||
required_traits: vec![PersonalityTrait::Honest],
|
||||
skill_focus: vec![Skill::Technical],
|
||||
relationship_constraints: vec![],
|
||||
routine_template: vec![],
|
||||
},
|
||||
RoleSchema {
|
||||
role_id: RoleId::new("informant"),
|
||||
required_traits: vec![PersonalityTrait::Deceptive],
|
||||
skill_focus: vec![Skill::Stealth],
|
||||
relationship_constraints: vec![],
|
||||
routine_template: vec![],
|
||||
},
|
||||
],
|
||||
space: SpaceSpec {
|
||||
tile_count_min: 30,
|
||||
tile_count_max: 80,
|
||||
sightline_zones: vec![SightlineZone {
|
||||
name: "main-floor".to_string(),
|
||||
radius: 6,
|
||||
}],
|
||||
privacy_level: PrivacyLevel::SemiPrivate,
|
||||
traffic_pattern: TrafficPattern::Destination,
|
||||
},
|
||||
triangles: vec![
|
||||
TriangleDef {
|
||||
triangle_id: TriangleId(0),
|
||||
roles: [
|
||||
RoleId::new("manager"),
|
||||
RoleId::new("worker"),
|
||||
RoleId::new("informant"),
|
||||
],
|
||||
conflict_type: ConflictType::ResourceCompetition,
|
||||
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
|
||||
relationship_constraints: vec![],
|
||||
},
|
||||
TriangleDef {
|
||||
triangle_id: TriangleId(0),
|
||||
roles: [
|
||||
RoleId::new("manager"),
|
||||
RoleId::new("informant"),
|
||||
RoleId::new("worker"),
|
||||
],
|
||||
conflict_type: ConflictType::LatentTension,
|
||||
interest_axes: [NpcAxis::Tolerance, NpcAxis::Contentment, NpcAxis::Routine],
|
||||
relationship_constraints: vec![],
|
||||
},
|
||||
],
|
||||
dialogue_pools: vec![TemplateDialoguePoolRef {
|
||||
location: "the-hub".to_string(),
|
||||
roles: vec!["manager".to_string(), "worker".to_string()],
|
||||
}],
|
||||
cross_template_links: vec![CrossTemplateLinkSpec {
|
||||
from_role: RoleId::new("worker"),
|
||||
to_template_slug: "other-site".to_string(),
|
||||
relationship: RelationshipKind::Colleague,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_template_def_yaml_roundtrip() {
|
||||
let template = minimal_full_template();
|
||||
let yaml = serde_yaml::to_string(&template).expect("serialize FullTemplateDef");
|
||||
let restored: FullTemplateDef =
|
||||
serde_yaml::from_str(&yaml).expect("deserialize FullTemplateDef");
|
||||
|
||||
assert_eq!(restored.slug, template.slug);
|
||||
assert_eq!(restored.display_name, template.display_name);
|
||||
assert_eq!(restored.roles.len(), template.roles.len());
|
||||
assert_eq!(restored.space.tile_count_min, template.space.tile_count_min);
|
||||
assert_eq!(restored.triangles.len(), template.triangles.len());
|
||||
assert_eq!(restored.dialogue_pools.len(), template.dialogue_pools.len());
|
||||
assert_eq!(restored.cross_template_links.len(), template.cross_template_links.len());
|
||||
|
||||
// Role round-trip: traits, constraints, routine entries
|
||||
let role = &restored.roles[0];
|
||||
assert_eq!(role.role_id, RoleId::new("manager"));
|
||||
assert_eq!(role.required_traits[0], PersonalityTrait::Cautious);
|
||||
assert_eq!(role.relationship_constraints[0].with_role, RoleId::new("worker"));
|
||||
|
||||
// Triangle round-trip: roles, conflict type, axes
|
||||
let tri = &restored.triangles[0];
|
||||
assert_eq!(tri.conflict_type, ConflictType::ResourceCompetition);
|
||||
assert_eq!(tri.roles[0], RoleId::new("manager"));
|
||||
assert_eq!(tri.interest_axes[1], NpcAxis::Secret);
|
||||
|
||||
// Dialogue pool round-trip
|
||||
assert_eq!(restored.dialogue_pools[0].location, "the-hub");
|
||||
assert_eq!(restored.dialogue_pools[0].roles.len(), 2);
|
||||
|
||||
// Cross-template link round-trip
|
||||
assert_eq!(
|
||||
restored.cross_template_links[0].from_role,
|
||||
RoleId::new("worker")
|
||||
);
|
||||
assert_eq!(
|
||||
restored.cross_template_links[0].to_template_slug,
|
||||
"other-site"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_template_def_validation_passes_for_valid_template() {
|
||||
let template = minimal_full_template();
|
||||
assert!(
|
||||
template.validate().is_ok(),
|
||||
"minimal valid template must pass: {:?}",
|
||||
template.validate()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_template_def_validation_rejects_fewer_than_2_triangles() {
|
||||
let mut template = minimal_full_template();
|
||||
template.triangles.truncate(1);
|
||||
let result = template.validate();
|
||||
assert!(result.is_err(), "fewer than 2 triangles must fail");
|
||||
assert!(
|
||||
result.unwrap_err().contains("fewer than 2 triangles"),
|
||||
"error must mention triangle count"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_template_def_validation_rejects_undefined_triangle_role() {
|
||||
let mut template = minimal_full_template();
|
||||
// Replace a triangle role with one not in the roles list
|
||||
template.triangles[0].roles[2] = RoleId::new("ghost-role");
|
||||
let result = template.validate();
|
||||
assert!(result.is_err(), "undefined triangle role must fail validation");
|
||||
assert!(
|
||||
result.unwrap_err().contains("ghost-role"),
|
||||
"error must name the undefined role"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_template_def_validation_rejects_duplicate_role_ids() {
|
||||
let mut template = minimal_full_template();
|
||||
template.roles.push(RoleSchema {
|
||||
role_id: RoleId::new("manager"), // duplicate
|
||||
required_traits: vec![],
|
||||
skill_focus: vec![],
|
||||
relationship_constraints: vec![],
|
||||
routine_template: vec![],
|
||||
});
|
||||
let result = template.validate();
|
||||
assert!(result.is_err(), "duplicate role_id must fail validation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_template_def_optional_fields_default_on_minimal_yaml() {
|
||||
// description, dialogue_pools, cross_template_links are all optional.
|
||||
let yaml = r#"
|
||||
slug: "bare-minimum"
|
||||
display_name: "Bare Minimum Site"
|
||||
roles:
|
||||
- role_id: "alpha"
|
||||
- role_id: "beta"
|
||||
- role_id: "gamma"
|
||||
space:
|
||||
tile_count_min: 30
|
||||
tile_count_max: 80
|
||||
privacy_level: Public
|
||||
traffic_pattern: Thoroughfare
|
||||
triangles:
|
||||
- triangle_id: 0
|
||||
roles:
|
||||
- "alpha"
|
||||
- "beta"
|
||||
- "gamma"
|
||||
conflict_type: LatentTension
|
||||
interest_axes:
|
||||
- Contentment
|
||||
- Tolerance
|
||||
- Routine
|
||||
- triangle_id: 0
|
||||
roles:
|
||||
- "alpha"
|
||||
- "gamma"
|
||||
- "beta"
|
||||
conflict_type: ResourceCompetition
|
||||
interest_axes:
|
||||
- Want
|
||||
- Secret
|
||||
- Relationships
|
||||
"#;
|
||||
let def: FullTemplateDef = serde_yaml::from_str(yaml).expect("minimal YAML must parse");
|
||||
assert_eq!(def.slug, "bare-minimum");
|
||||
assert!(def.description.is_none());
|
||||
assert!(def.dialogue_pools.is_empty());
|
||||
assert!(def.cross_template_links.is_empty());
|
||||
assert!(def.validate().is_ok(), "minimal template must validate: {:?}", def.validate());
|
||||
}
|
||||
|
||||
/// Acceptance test: the authored logistics-hub.yaml round-trips through serde_yaml.
|
||||
///
|
||||
/// The file lives at `server/data/templates/logistics-hub.yaml`.
|
||||
/// This test is the canonical acceptance criterion for ticket #159.
|
||||
#[test]
|
||||
fn logistics_hub_yaml_roundtrips_cleanly() {
|
||||
let path = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/data/templates/logistics-hub.yaml"
|
||||
);
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.unwrap_or_else(|e| panic!("could not read logistics-hub.yaml: {}", e));
|
||||
|
||||
let def: FullTemplateDef = serde_yaml::from_str(&raw)
|
||||
.unwrap_or_else(|e| panic!("logistics-hub.yaml failed to deserialize: {}", e));
|
||||
|
||||
// Structural assertions
|
||||
assert_eq!(def.slug, "logistics-hub");
|
||||
assert_eq!(def.roles.len(), 4, "logistics hub must define 4 roles");
|
||||
assert_eq!(def.triangles.len(), 2, "logistics hub must define 2 triangles");
|
||||
assert!(!def.dialogue_pools.is_empty(), "dialogue_pools must be present");
|
||||
assert!(!def.cross_template_links.is_empty(), "cross_template_links must be present");
|
||||
|
||||
// Spatial spec assertions (D-025: 30–80 sim tiles)
|
||||
assert!(def.space.validate().is_ok(), "space spec must validate");
|
||||
assert_eq!(def.space.tile_count_min, 30);
|
||||
assert_eq!(def.space.tile_count_max, 80);
|
||||
|
||||
// Validation must pass
|
||||
assert!(
|
||||
def.validate().is_ok(),
|
||||
"logistics-hub.yaml must pass full validation: {:?}",
|
||||
def.validate()
|
||||
);
|
||||
|
||||
// Round-trip: serialize back to YAML then deserialize again
|
||||
let reserialized = serde_yaml::to_string(&def).expect("re-serialize");
|
||||
let restored: FullTemplateDef =
|
||||
serde_yaml::from_str(&reserialized).expect("re-deserialize after round-trip");
|
||||
assert_eq!(def.slug, restored.slug);
|
||||
assert_eq!(def.roles.len(), restored.roles.len());
|
||||
assert_eq!(def.triangles.len(), restored.triangles.len());
|
||||
assert_eq!(def.dialogue_pools.len(), restored.dialogue_pools.len());
|
||||
assert_eq!(def.cross_template_links.len(), restored.cross_template_links.len());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user