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:
2026-02-27 17:46:37 +01:00
co-authored by Claude Opus 4.6
parent d31ac1cc70
commit 6f39df884c
2 changed files with 468 additions and 5 deletions
+196
View File
@@ -0,0 +1,196 @@
# Sova Station Logistics Hub — Tier 2 social site template (#159).
#
# Spec refs: D-023 (three-tier content model), D-024 (10-axis NPC model),
# D-025 (social site as atomic template unit), D-028 (dialogue pools),
# D-087 (v0.1 triangle configuration), D-089 (self-contained forks)
#
# Tile scale (D-066): tile_count_* are SIM tiles (0.5m each).
# 1540 visual tiles (D-025) = 3080 sim tiles.
#
# triangle_id values are authoring placeholders (0).
# The instantiation engine overwrites them with
# TriangleId::from_seed_and_roles(world_seed, &roles) at runtime.
slug: "logistics-hub"
display_name: "Sova Station Logistics Hub"
description: >
The terminal's operational core — cargo processing, documentation,
and the informal relationships that keep goods moving off-manifest.
A functional cluster of four NPCs controlling access to the station's
primary freight throughput.
roles:
- role_id: "logistics-manager"
required_traits:
- Bold
- Cautious
skill_focus:
- Persuasion
- Observation
relationship_constraints:
- with_role: "dock-worker"
kind: Superior
required_trust:
min: 0
max: 5
- with_role: "ring-contact"
kind: Colleague
required_trust:
min: -5
max: 2
- with_role: "security-guard"
kind: Superior
required_trust:
min: 1
max: 5
routine_template:
- phase: morning
location: "terminal-office"
activity: "documentation-review"
- phase: afternoon
location: "terminal-cargo-bay"
activity: "cargo-inspection"
- phase: evening
location: "terminal-office"
activity: "end-of-day-reports"
- role_id: "dock-worker"
required_traits:
- Honest
- Social
skill_focus:
- Technical
- Observation
relationship_constraints:
- with_role: "logistics-manager"
kind: Subordinate
required_trust:
min: -2
max: 4
- with_role: "ring-contact"
kind: Colleague
required_trust:
min: -4
max: 0
routine_template:
- phase: morning
location: "terminal-cargo-bay"
activity: "freight-handling"
- phase: afternoon
location: "terminal-cargo-bay"
activity: "freight-handling"
- phase: evening
location: "bar-last-shift"
- role_id: "ring-contact"
required_traits:
- Deceptive
- Social
skill_focus:
- Stealth
- Persuasion
relationship_constraints:
- with_role: "logistics-manager"
kind: Colleague
required_trust:
min: -5
max: 2
- with_role: "dock-worker"
kind: Colleague
required_trust:
min: -4
max: 0
routine_template:
- phase: morning
location: "terminal-cargo-bay"
activity: "oversight"
- phase: afternoon
location: "maintenance-corridor"
- phase: evening
location: "bar-last-shift"
- role_id: "security-guard"
required_traits:
- Cautious
- Honest
skill_focus:
- Combat
- Observation
relationship_constraints:
- with_role: "logistics-manager"
kind: Subordinate
required_trust:
min: 1
max: 5
routine_template:
- phase: morning
location: "terminal-entrance"
activity: "access-control"
- phase: afternoon
location: "terminal-cargo-bay"
activity: "patrol"
- phase: evening
location: "terminal-entrance"
activity: "access-control"
space:
tile_count_min: 30
tile_count_max: 80
sightline_zones:
- name: "loading-floor"
radius: 8 # 4m clear sightline across the loading area
- name: "reception-desk"
radius: 4 # 2m clear sightline at the desk
- name: "cargo-staging"
radius: 6 # 3m clear sightline in staging area
privacy_level: SemiPrivate
traffic_pattern: Destination
triangles:
- triangle_id: 0
roles:
- "ring-contact"
- "dock-worker"
- "logistics-manager"
conflict_type: ResourceCompetition
interest_axes:
- Want
- Secret
- Relationships
relationship_constraints:
- with_role: "dock-worker"
kind: Subordinate
required_trust:
min: -2
max: 2
- triangle_id: 0
roles:
- "logistics-manager"
- "security-guard"
- "ring-contact"
conflict_type: AuthorityChallenge
interest_axes:
- Relationships
- Tolerance
- Secret
dialogue_pools:
- location: "the-terminal"
roles:
- "logistics-manager"
- "dock-worker"
- "ring-contact"
- "security-guard"
- location: "terminal-cargo-bay"
roles:
- "dock-worker"
- "ring-contact"
cross_template_links:
- from_role: "dock-worker"
to_template_slug: "last-shift-bar"
relationship: Colleague
- from_role: "ring-contact"
to_template_slug: "last-shift-bar"
relationship: Colleague
+272 -5
View File
@@ -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: 3080 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());
}