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:
+198
-23
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user