feat(simulation): add ZoneTypeTemplate and LocationSpec structs for D-142 (#663)

Implement the zone-type template architecture: ZoneTypeTemplate (behavior
pools per zone category), LocationSpec (instance metadata with role weights),
SocialSiteTypeSpec, RoleWeight, and LocationSocialSite. Add deserialization
tests for rural_agricultural.ron and industrial_freight.ron. Existing ZoneSpec
kept for backward compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-17 10:32:42 +01:00
co-authored by Claude Opus 4.6
parent b2ef56440f
commit 7f7706442a
+198 -23
View File
@@ -1,8 +1,11 @@
//! NPC blueprint structs for the generator spike (#611).
//! NPC blueprint structs for the generator spike and D-142 zone-type architecture.
//!
//! Three input/output structs for the generator proof-of-life:
//! - `ZoneSpec` — zone identity specification (deserializes from zone-identity-spec RON)
//! - `CultureProfile` — culture profile (deserializes from culture RON)
//! ## Structs
//!
//! - `ZoneTypeTemplate` — D-142 zone-type template (deserializes from `content/global/zone-types/*.ron`)
//! - `LocationSpec` — D-142 location instance spec (references a ZoneTypeTemplate)
//! - `ZoneSpec` — legacy zone identity spec (spike-era, kept for backward compat)
//! - `CultureProfile` — culture profile (deserializes from `content/global/culture-*.ron`)
//! - `NpcBlueprint` — generator output for a single NPC
//!
//! Plus `SpikeOutput` as the top-level binary output container.
@@ -10,20 +13,20 @@
//!
//! ## Content contract
//!
//! The Rust structs ARE the schema. Copy team fills RON files to match these structs:
//! - Zone identity spec: ticket #609
//! - Culture profile (Van Maanen's Star): ticket #610
//! The Rust structs ARE the schema. Copy team fills RON files to match these structs.
//! - Zone-type templates: `content/global/zone-types/*.ron` → `ZoneTypeTemplate`
//! - Culture profiles: `content/global/culture-*.ron` → `CultureProfile`
//!
//! ## Format
//!
//! RON (Rusty Object Notation) — struct-aware, supports enums and comments.
//! Validate with: `tooling/validate-ron <file.ron>`
//! Validate with: `tooling/validate-ron <file.ron> <zone_type|culture>`
use rand::Rng;
use serde::{Deserialize, Serialize};
use crate::npc::PersonalityTrait;
use crate::npc::tell_state::TellCategory;
use crate::npc::PersonalityTrait;
// ---------------------------------------------------------------------------
// Zone identity specification (input — filled by copy team, ticket #609)
@@ -61,14 +64,18 @@ pub struct RoleSpec {
/// Human-readable label.
pub label: String,
/// Relative weight for role selection (higher = more common).
/// Declared on location specs, not zone-type templates — defaults to 0
/// when deserializing zone-type template RON files.
#[serde(default)]
pub weight: u8,
/// Skills biased toward for this role.
pub skill_focus: Vec<String>,
/// Whether this role may have combat capability.
pub combat_eligible: bool,
/// Observable behaviors typical for this role (generator picks from these).
/// Legacy field — preserved for backward compatibility until composable
/// assembly produces verified-equivalent output.
/// Legacy field — preserved for backward compatibility with zone-identity-spec
/// RON files. Zone-type templates use `behavior_primitives` exclusively.
#[serde(default)]
pub typical_behaviors: Vec<String>,
/// Composable behavior primitives (#633, Q-057).
/// Role-generic physical stage directions that get composed with culture
@@ -93,6 +100,108 @@ pub struct SocialSiteSpec {
pub max_npcs: u8,
}
// ---------------------------------------------------------------------------
// D-142: Zone-type template architecture
// ---------------------------------------------------------------------------
/// Zone-type template — the authoritative behavior pool for a zone category.
///
/// One file per zone type (e.g. `content/global/zone-types/rural_agricultural.ron`).
/// Defines what roles exist and what behaviors they produce. Does NOT declare
/// weights — those live on `LocationSpec`. Does NOT carry instance data (min/max
/// NPCs per site) — that lives on `LocationSpec.social_sites`.
///
/// The Rust struct IS the schema. RON files must match this struct exactly.
/// Validate with: `tooling/validate-ron server/content/global/zone-types/<id>.ron zone_type`
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZoneTypeTemplate {
/// Canonical zone-type identifier (e.g. `"rural_agricultural"`).
pub id: String,
/// Optional subtype discriminator — `None` in all current files.
pub subtype: Option<String>,
/// Human-readable label for the zone type.
pub label: String,
/// Short description of this zone type's character.
pub description: String,
/// Default economic activity level (1-10). Location specs may override.
pub economic_level: u8,
/// Default population density hint (1-10). Location specs may override.
pub population_density: u8,
/// Role definitions available in this zone type.
/// Roles carry no weight — weights are declared on `LocationSpec.role_weights`.
pub roles: Vec<RoleSpec>,
/// Valid social site types for this zone type.
/// Location specs declare which appear as instances with label/min/max.
pub social_site_types: Vec<SocialSiteTypeSpec>,
}
/// A social site type entry in a `ZoneTypeTemplate`.
///
/// Declares that a given site type exists in this zone category and which
/// roles are eligible to appear there. Location specs instantiate these
/// with a label and NPC count bounds.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SocialSiteTypeSpec {
/// Site type identifier (e.g. `"tavern"`, `"break_room"`).
pub site_type: String,
/// Role IDs eligible to appear at this site type (references `RoleSpec.id`).
pub eligible_roles: Vec<String>,
}
/// Location specification — one concrete location instance within a zone type.
///
/// References a `ZoneTypeTemplate` by ID and a culture profile by ID.
/// Overrides defaults and declares which roles/sites appear and at what frequency.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocationSpec {
/// Zone-type template this location is based on (references `ZoneTypeTemplate.id`).
pub zone_type: String,
/// Culture profile for this location (references a `CultureProfile.id`).
pub culture: String,
/// Role frequency weights for this specific location.
pub role_weights: Vec<RoleWeight>,
/// Override for `ZoneTypeTemplate.economic_level`. `None` uses the template default.
#[serde(default)]
pub economic_level: Option<u8>,
/// Override for `ZoneTypeTemplate.population_density`. `None` uses the template default.
#[serde(default)]
pub population_density: Option<u8>,
/// Whether this location is abandoned (no NPCs generated when true).
#[serde(default)]
pub abandoned: bool,
/// POI overlays applied to this location (e.g. `"smuggling_cache"`).
#[serde(default)]
pub poi_overlay: Vec<String>,
/// Concrete social site instances for this location.
#[serde(default)]
pub social_sites: Vec<LocationSocialSite>,
}
/// Role weight entry in a `LocationSpec`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoleWeight {
/// Role ID (references `RoleSpec.id` in the zone-type template).
pub role_id: String,
/// Relative frequency weight (higher = more common at this location).
pub weight: u8,
}
/// A concrete social site instance in a `LocationSpec`.
///
/// Instantiates one entry from `ZoneTypeTemplate.social_site_types` with
/// location-specific label and NPC count bounds.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocationSocialSite {
/// Site type identifier (references `SocialSiteTypeSpec.site_type`).
pub site_type: String,
/// Human-readable label for this specific site instance.
pub label: String,
/// Minimum NPCs associated with this site.
pub min_npcs: u8,
/// Maximum NPCs associated with this site.
pub max_npcs: u8,
}
// ---------------------------------------------------------------------------
// Composable behavior primitives (#633, Q-057)
// ---------------------------------------------------------------------------
@@ -127,7 +236,7 @@ pub struct BehaviorPrimitive {
///
/// The generator checks the NPC's current assignment against these tags
/// to filter the behavior pool before selection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum BehaviorContext {
/// Available during work shifts at the NPC's assigned zone.
OnShift,
@@ -136,15 +245,10 @@ pub enum BehaviorContext {
/// Available at social sites (taverns, break rooms, cantinas).
Social,
/// Available anywhere — no context restriction.
#[default]
Any,
}
impl Default for BehaviorContext {
fn default() -> Self {
Self::Any
}
}
/// A culture-specific behavior modifier clause.
///
/// Appended to a `BehaviorPrimitive.action` during assembly to add
@@ -613,10 +717,18 @@ mod tests {
},
];
let result = assemble_behaviors(&primitives, &modifiers, Some(BehaviorContext::OnShift), &mut rng);
let result = assemble_behaviors(
&primitives,
&modifiers,
Some(BehaviorContext::OnShift),
&mut rng,
);
assert_eq!(result.len(), 2);
// First primitive has hint "work_style" → should match the work_style modifier
assert_eq!(result[0], "moves freight containers with mechanical efficiency");
assert_eq!(
result[0],
"moves freight containers with mechanical efficiency"
);
// Second primitive has no hint → either modifier is valid
assert!(result[1].starts_with("patrols the perimeter"));
}
@@ -661,7 +773,8 @@ mod tests {
];
// OnShift filter: should return OnShift + Any
let on_shift = assemble_behaviors(&primitives, &[], Some(BehaviorContext::OnShift), &mut rng);
let on_shift =
assemble_behaviors(&primitives, &[], Some(BehaviorContext::OnShift), &mut rng);
assert_eq!(on_shift.len(), 2);
assert_eq!(on_shift[0], "works shift");
assert_eq!(on_shift[1], "stretches");
@@ -701,7 +814,8 @@ mod tests {
];
// OffDuty filter: should return OffDuty + Any, exclude OnShift
let off_duty = assemble_behaviors(&primitives, &[], Some(BehaviorContext::OffDuty), &mut rng);
let off_duty =
assemble_behaviors(&primitives, &[], Some(BehaviorContext::OffDuty), &mut rng);
assert_eq!(off_duty.len(), 2);
assert_eq!(off_duty[0], "sleeps in bunk");
assert_eq!(off_duty[1], "stretches");
@@ -747,9 +861,70 @@ mod tests {
category: "work_style".into(),
clause: "with mechanical efficiency".into(),
};
let ron_str = ron::ser::to_string_pretty(&modifier, ron::ser::PrettyConfig::default()).unwrap();
let ron_str =
ron::ser::to_string_pretty(&modifier, ron::ser::PrettyConfig::default()).unwrap();
let deserialized: BehaviorModifier = ron::from_str(&ron_str).unwrap();
assert_eq!(deserialized.category, "work_style");
assert_eq!(deserialized.clause, "with mechanical efficiency");
}
// -----------------------------------------------------------------------
// D-142 ZoneTypeTemplate deserialization (#663)
// -----------------------------------------------------------------------
#[test]
fn rural_agricultural_ron_deserializes_correctly() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let path = std::path::PathBuf::from(manifest_dir)
.join("content/global/zone-types/rural_agricultural.ron");
let content = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read rural_agricultural.ron: {}", e));
let template: ZoneTypeTemplate = ron::from_str(&content)
.unwrap_or_else(|e| panic!("Failed to deserialize rural_agricultural.ron: {}", e));
assert_eq!(template.id, "rural_agricultural");
assert!(
!template.roles.is_empty(),
"ZoneTypeTemplate must have at least one role"
);
for role in &template.roles {
assert!(
!role.behavior_primitives.is_empty(),
"Role '{}' must have behavior_primitives in zone-type template",
role.id
);
}
assert!(
!template.social_site_types.is_empty(),
"ZoneTypeTemplate must have at least one social_site_type"
);
}
#[test]
fn industrial_freight_ron_deserializes_correctly() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let path = std::path::PathBuf::from(manifest_dir)
.join("content/global/zone-types/industrial_freight.ron");
let content = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read industrial_freight.ron: {}", e));
let template: ZoneTypeTemplate = ron::from_str(&content)
.unwrap_or_else(|e| panic!("Failed to deserialize industrial_freight.ron: {}", e));
assert_eq!(template.id, "industrial_freight");
assert!(
!template.roles.is_empty(),
"ZoneTypeTemplate must have at least one role"
);
for role in &template.roles {
assert!(
!role.behavior_primitives.is_empty(),
"Role '{}' must have behavior_primitives in zone-type template",
role.id
);
}
assert!(
!template.social_site_types.is_empty(),
"ZoneTypeTemplate must have at least one social_site_type"
);
}
}