diff --git a/server/src/npc/blueprint.rs b/server/src/npc/blueprint.rs index 5add5f245..f1494ae5d 100644 --- a/server/src/npc/blueprint.rs +++ b/server/src/npc/blueprint.rs @@ -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 ` +//! Validate with: `tooling/validate-ron ` 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, /// 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, /// 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/.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, + /// 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, + /// Valid social site types for this zone type. + /// Location specs declare which appear as instances with label/min/max. + pub social_site_types: Vec, +} + +/// 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, +} + +/// 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, + /// Override for `ZoneTypeTemplate.economic_level`. `None` uses the template default. + #[serde(default)] + pub economic_level: Option, + /// Override for `ZoneTypeTemplate.population_density`. `None` uses the template default. + #[serde(default)] + pub population_density: Option, + /// 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, + /// Concrete social site instances for this location. + #[serde(default)] + pub social_sites: Vec, +} + +/// 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" + ); + } }