diff --git a/server/data/templates/logistics-hub.yaml b/server/data/templates/logistics-hub.yaml new file mode 100644 index 000000000..70cd30408 --- /dev/null +++ b/server/data/templates/logistics-hub.yaml @@ -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). +# 15–40 visual tiles (D-025) = 30–80 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 diff --git a/server/tests/template_schema.rs b/server/tests/template_schema.rs index fa4b91552..c570eaa20 100644 --- a/server/tests/template_schema.rs +++ b/server/tests/template_schema.rs @@ -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()); +}