//! NPC blueprint structs for the generator spike and D-142 zone-type architecture. //! //! ## 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. //! These are spike-specific and intentionally decoupled from `DistrictSkeleton`. //! //! ## Content contract //! //! 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 ` use rand::Rng; use serde::{Deserialize, Serialize}; use crate::npc::tell_state::TellCategory; use crate::npc::PersonalityTrait; // --------------------------------------------------------------------------- // Zone identity specification (input — filled by copy team, ticket #609) // --------------------------------------------------------------------------- /// Top-level zone identity spec. One file per zone type. /// /// Describes what a zone IS — its purpose, population shape, and social sites. /// The generator reads this to decide how many NPCs to create, what roles they /// fill, and what social sites exist. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ZoneSpec { /// Zone type identifier (e.g. "rural", "industrial", "commercial"). pub zone_type: String, /// Human-readable label for output headers. pub label: String, /// Short description of this zone type's character. pub description: String, /// Economic activity level (1-10). Affects NPC count and role distribution. pub economic_level: u8, /// Population density hint (1-10). Generator scales NPC count from this. pub population_density: u8, /// Role definitions available in this zone type. /// Each role has a weight (relative frequency) and properties. pub roles: Vec, /// Social site templates that can appear in this zone type. pub social_sites: Vec, } /// A role that NPCs can fill in a zone. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RoleSpec { /// Role identifier (e.g. "dock_worker", "merchant", "guard"). pub id: String, /// 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 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 /// modifiers at NpcBlueprint instantiation time. When present, the assembly /// function uses these instead of `typical_behaviors`. #[serde(default)] pub behavior_primitives: Vec, } /// A social site template within a zone. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SocialSiteSpec { /// Site type identifier (e.g. "bar", "workshop", "market_stall"). pub site_type: String, /// Human-readable label. pub label: String, /// Roles that staff or frequent this site (references RoleSpec.id). pub roles: Vec, /// Minimum NPCs associated with this site. pub min_npcs: u8, /// Maximum NPCs associated with this site. 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) // --------------------------------------------------------------------------- /// A composable behavior primitive — a role-generic stage direction. /// /// The assembly function composes: `action_text` + optional culture modifier /// → final observable behavior string. Context tags gate when the behavior /// is eligible for selection. /// /// Example: /// action_text: "moves freight containers" /// context: OnShift /// → assembled with Van Maanen's Star modifier "with mechanical efficiency" /// → "moves freight containers with mechanical efficiency" #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BehaviorPrimitive { /// The action text — a role-generic physical stage direction. /// Must be a complete sentence fragment that can stand alone or /// accept a trailing modifier clause. pub action: String, /// Context tag gating when this behavior is eligible. pub context: BehaviorContext, /// Optional modifier slot hint. If set, the assembly function /// prefers modifiers matching this category. If empty, any /// compatible modifier may be selected. #[serde(default)] pub modifier_hint: Option, } /// Context tags that gate when a behavior primitive is eligible. /// /// 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, Default)] pub enum BehaviorContext { /// Available during work shifts at the NPC's assigned zone. OnShift, /// Available when the NPC is off duty (break rooms, bars, quarters). OffDuty, /// Available at social sites (taverns, break rooms, cantinas). Social, /// Available anywhere — no context restriction. #[default] Any, } /// A culture-specific behavior modifier clause. /// /// Appended to a `BehaviorPrimitive.action` during assembly to add /// cultural flavor. The composition engine selects modifiers from the /// culture profile and concatenates them with the action text. /// /// Example: /// category: "work_style" /// clause: "with mechanical efficiency" /// → "moves freight containers with mechanical efficiency" #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BehaviorModifier { /// Modifier category for matching against `BehaviorPrimitive.modifier_hint`. pub category: String, /// The modifier clause text. Concatenated to action text with a space. pub clause: String, } /// Assemble observable behavior strings from primitives + culture modifiers. /// /// This is the core composition function (#633). It replaces the flat /// `typical_behaviors` selection with a composable pipeline: /// /// 1. Filter primitives by context (pass `None` for no filtering) /// 2. For each eligible primitive, select a culture modifier /// 3. Concatenate: `action + " " + modifier.clause` /// 4. If no modifier matches, use the action text as-is /// /// Returns the assembled strings in the same order as the input primitives. pub fn assemble_behaviors( primitives: &[BehaviorPrimitive], modifiers: &[BehaviorModifier], context_filter: Option, rng: &mut crate::simulation::rng::SimRng, ) -> Vec { let eligible: Vec<&BehaviorPrimitive> = primitives .iter() .filter(|p| match context_filter { Some(ctx) => p.context == ctx || p.context == BehaviorContext::Any, None => true, }) .collect(); let mut results = Vec::with_capacity(eligible.len()); for prim in &eligible { // Find matching modifiers: prefer hint match, fall back to any let matching: Vec<&BehaviorModifier> = if let Some(ref hint) = prim.modifier_hint { let hinted: Vec<&BehaviorModifier> = modifiers.iter().filter(|m| &m.category == hint).collect(); if hinted.is_empty() { tracing::trace!( "modifier_hint '{}' matched no category; falling back to all modifiers", hint ); modifiers.iter().collect() } else { hinted } } else { modifiers.iter().collect() }; if matching.is_empty() { // No modifiers available — use action text as-is results.push(prim.action.clone()); } else { let idx = rng.rng.random_range(0..matching.len()); let modifier = matching[idx]; results.push(format!("{} {}", prim.action, modifier.clause)); } } results } // --------------------------------------------------------------------------- // Culture profile (input — filled by copy team, ticket #610) // --------------------------------------------------------------------------- /// Culture profile for a star system or region. /// /// Drives the cultural texture of generated NPCs: how they speak, what names /// they have, what values shape their personality distribution. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CultureProfile { /// Culture identifier (e.g. "van-maanens-star"). pub id: String, /// Human-readable culture name. pub name: String, /// Short cultural description for generator context. pub description: String, /// Naming conventions for this culture. pub naming: NamingConventions, /// Speech patterns — how NPCs from this culture talk. pub speech: SpeechPatterns, /// Cultural values that bias personality trait selection. pub values: CulturalValues, /// Full persona block for voice pipeline LLM prompts (may be absent). #[serde(default)] pub voice_persona: Option, /// Example input/output pairs for voice pipeline prompts. #[serde(default)] pub voice_examples: Vec, /// Occasional prompt injections rolled per-prompt by the composition engine. /// Some cultures have none, others several. No cap on count. #[serde(default)] pub occasional_injections: Vec, /// Culture-specific behavior modifier clauses (#633, Q-057). /// Composed with `BehaviorPrimitive` actions during NPC generation. /// Empty means no cultural modifier — action text used as-is. #[serde(default)] pub behavior_modifiers: Vec, } /// Naming conventions for NPC name generation. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NamingConventions { /// Style description (e.g. "compact, consonant-heavy, first-name-primary"). pub style: String, /// Pool of given names the generator draws from. pub given_names: Vec, /// Pool of family names (may be empty if culture is first-name-primary). pub family_names: Vec, /// Whether family names are commonly used in social contexts. pub family_name_used_socially: bool, } /// Speech patterns that color NPC dialogue and observable behaviors. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpeechPatterns { /// Speech register description (e.g. "direct, minimal pleasantries"). pub register: String, /// Filler words and verbal tics drawn from this culture. pub filler_words: Vec, /// Common greetings. pub greetings: Vec, /// Common farewells. pub farewells: Vec, /// Oath or exclamation phrases. pub exclamations: Vec, } /// Cultural values that influence personality trait distribution. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CulturalValues { /// Brief description of the culture's value system. pub description: String, /// Traits that are more common in this culture (biased toward). pub favored_traits: Vec, /// Traits that are less common in this culture (biased against). pub disfavored_traits: Vec, } // --------------------------------------------------------------------------- // Voice pipeline types (D-138, Spike 2) // --------------------------------------------------------------------------- /// Example input/output pair for voice pipeline prompts. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VoiceExample { /// The input scenario description. pub input: String, /// The expected voiced output. pub output: String, } /// Occasional prompt injection rolled per-prompt by the composition engine. /// /// The model never decides injection frequency — the composition engine rolls /// a random check per prompt and either includes the clause or doesn't. /// This solves the fundamental problem that small LLMs can't self-gate /// vocabulary frequency across independent inference calls. /// /// Different `kind` values represent different categories of injection: /// oaths ("void take it"), faith expressions ("God help us"), /// verbal hesitancy, greetings ("hey"), etc. The composition engine /// treats them uniformly — `kind` exists for human readability and /// future filtering. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OccasionalInjection { /// Injection category (e.g. "oath", "faith", "hesitancy", "greeting"). pub kind: String, /// LLM instruction text to inject into the prompt. pub clause: String, /// Optional example pair demonstrating the injection in use. #[serde(default)] pub example: Option, /// Probability of inclusion per prompt (0.0–1.0). pub frequency: f32, /// Tell categories that suppress this injection to avoid conflicting instructions. #[serde(default)] pub suppress_on_tells: Vec, } // --------------------------------------------------------------------------- // NPC blueprint (output — generator produces these) // --------------------------------------------------------------------------- /// Internal motive/state driving an NPC's micro-behavior (#632). /// /// The player never sees this directly — it leaks through the observable /// behavior as a tell. The gap between what an NPC is doing and WHY is /// the asymmetric information mechanic (D-010 principle 2). /// /// `Neutral` means no notable motive — the NPC is simply doing their job. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum NpcWant { /// Nothing stands out; routine work mode. Neutral, /// Disengaged from current task; attention elsewhere. Bored, /// Heightened attention to the environment; scanning, tracking. Alert, /// Something is wrong or off; watching without being obvious about it. Suspicious, /// Steering clear of a specific person in the zone. AvoidingSomeone, /// Actively trying to pick up information by proximity or conversation. LookingForInfo, } /// Generator output for a single NPC. /// /// Maps to the 10-axis model (D-024) but as a serializable data record, /// not ECS components. The spike binary prints these; the full pipeline /// will convert `NpcBlueprint` → `RoleDefinition` → ECS entity. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NpcBlueprint { /// Generated NPC name. pub name: String, /// Role identifier (matches RoleSpec.id from the zone spec). pub role: String, /// Personality traits (2-3, no contradictory pairs). pub traits: Vec, /// Internal motive that biases observable behavior (#632). /// The player never sees this label — they infer it from the behavior. pub want: NpcWant, /// Observable behaviors the player can witness. /// Index 0 is the primary role/relationship-driven action. /// Index 1 (if present) is the Want tell — the leak of the internal state. pub observable_behaviors: Vec, /// Cultural markers derived from the culture profile. pub cultural_markers: CulturalMarkers, /// Relationship slots (0-3 per D-024). pub relationships: Vec, /// Tell-specific behaviors — always passed through verbatim, never re-voiced. #[serde(default)] pub tell_behaviors: Vec, } /// A tell-specific behavior string that is passed through verbatim. /// Tell behaviors are never re-voiced by the voice pipeline — they are /// authored text that plays exactly as written. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TellBehavior { /// Which tell category triggers this behavior. pub category: TellCategory, /// The behavior text shown to the player. pub base_text: String, } /// Cultural markers attached to a generated NPC. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CulturalMarkers { /// Speech register inherited from culture. pub speech_register: String, /// Filler words this NPC uses (subset of culture's pool). pub filler_words: Vec, /// Greeting this NPC tends to use. pub greeting: String, } /// A relationship in the blueprint (pre-ECS, uses string IDs). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BlueprintRelationship { /// Target NPC name (resolved to StableId at spawn time). pub target_name: String, /// Relationship type. pub relationship_type: String, /// Valence: positive, negative, or neutral. pub valence: RelationshipValence, } /// Relationship valence for blueprint output. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum RelationshipValence { Positive, Negative, Neutral, } // --------------------------------------------------------------------------- // Spike output container // --------------------------------------------------------------------------- /// Top-level output for the generator spike binary. /// /// Intentionally decoupled from `DistrictSkeleton` — this is a proof-of-life /// container, not production architecture. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpikeOutput { /// Zone type that was generated. pub zone_type: String, /// Seed used for generation. pub seed: u64, /// Culture profile used. pub culture: String, /// Generated NPCs. pub npcs: Vec, } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn zone_spec_round_trips_through_ron() { let spec = ZoneSpec { zone_type: "rural".into(), label: "Rural Settlement".into(), description: "Scattered homesteads and small workshops".into(), economic_level: 3, population_density: 2, roles: vec![RoleSpec { id: "farmer".into(), label: "Farmer".into(), weight: 5, skill_focus: vec!["technical".into()], combat_eligible: false, typical_behaviors: vec!["tends crops".into()], behavior_primitives: vec![], }], social_sites: vec![SocialSiteSpec { site_type: "tavern".into(), label: "Local Tavern".into(), roles: vec!["farmer".into()], min_npcs: 2, max_npcs: 5, }], }; let ron_str = ron::ser::to_string_pretty(&spec, ron::ser::PrettyConfig::default()).unwrap(); let deserialized: ZoneSpec = ron::from_str(&ron_str).unwrap(); assert_eq!(deserialized.zone_type, "rural"); assert_eq!(deserialized.roles.len(), 1); assert_eq!(deserialized.social_sites.len(), 1); } #[test] fn culture_profile_round_trips_through_ron() { let profile = CultureProfile { id: "van-maanens-star".into(), name: "Van Maanen's Star Culture".into(), description: "Working-class pragmatic culture".into(), naming: NamingConventions { style: "compact, consonant-heavy".into(), given_names: vec!["Kael".into(), "Voss".into()], family_names: vec!["Davan".into()], family_name_used_socially: false, }, speech: SpeechPatterns { register: "direct, minimal pleasantries".into(), filler_words: vec!["look".into(), "right".into()], greetings: vec!["hey".into()], farewells: vec!["shift's calling".into()], exclamations: vec!["void take it".into()], }, values: CulturalValues { description: "Pragmatic, community-oriented, suspicious of authority".into(), favored_traits: vec![PersonalityTrait::Bold, PersonalityTrait::Honest], disfavored_traits: vec![PersonalityTrait::Reclusive], }, voice_persona: None, voice_examples: vec![], occasional_injections: vec![], behavior_modifiers: vec![], }; let ron_str = ron::ser::to_string_pretty(&profile, ron::ser::PrettyConfig::default()).unwrap(); let deserialized: CultureProfile = ron::from_str(&ron_str).unwrap(); assert_eq!(deserialized.id, "van-maanens-star"); assert_eq!(deserialized.naming.given_names.len(), 2); assert_eq!(deserialized.values.favored_traits.len(), 2); } #[test] fn npc_blueprint_round_trips_through_ron() { let blueprint = NpcBlueprint { name: "Kael".into(), role: "dock_worker".into(), traits: vec![PersonalityTrait::Bold, PersonalityTrait::Honest], want: NpcWant::Alert, observable_behaviors: vec!["works efficiently".into()], cultural_markers: CulturalMarkers { speech_register: "direct".into(), filler_words: vec!["look".into()], greeting: "hey".into(), }, relationships: vec![BlueprintRelationship { target_name: "Voss".into(), relationship_type: "colleague".into(), valence: RelationshipValence::Positive, }], tell_behaviors: vec![], }; let ron_str = ron::ser::to_string_pretty(&blueprint, ron::ser::PrettyConfig::default()).unwrap(); let deserialized: NpcBlueprint = ron::from_str(&ron_str).unwrap(); assert_eq!(deserialized.name, "Kael"); assert_eq!(deserialized.traits.len(), 2); assert_eq!(deserialized.relationships.len(), 1); } #[test] fn spike_output_round_trips_through_ron() { let output = SpikeOutput { zone_type: "rural".into(), seed: 42, culture: "van-maanens-star".into(), npcs: vec![], }; let ron_str = ron::ser::to_string_pretty(&output, ron::ser::PrettyConfig::default()).unwrap(); let deserialized: SpikeOutput = ron::from_str(&ron_str).unwrap(); assert_eq!(deserialized.zone_type, "rural"); assert_eq!(deserialized.seed, 42); } // ----------------------------------------------------------------------- // Composable behavior assembly tests (#633) // ----------------------------------------------------------------------- #[test] fn assemble_behaviors_with_modifiers() { use crate::simulation::rng::SimRng; let mut rng = SimRng::new(42); let primitives = vec![ BehaviorPrimitive { action: "moves freight containers".into(), context: BehaviorContext::OnShift, modifier_hint: Some("work_style".into()), }, BehaviorPrimitive { action: "patrols the perimeter".into(), context: BehaviorContext::OnShift, modifier_hint: None, }, ]; let modifiers = vec![ BehaviorModifier { category: "work_style".into(), clause: "with mechanical efficiency".into(), }, BehaviorModifier { category: "demeanor".into(), clause: "with a watchful eye".into(), }, ]; 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" ); // Second primitive has no hint → either modifier is valid assert!(result[1].starts_with("patrols the perimeter")); } #[test] fn assemble_behaviors_no_modifiers_returns_action_text() { use crate::simulation::rng::SimRng; let mut rng = SimRng::new(42); let primitives = vec![BehaviorPrimitive { action: "tends crops in the field".into(), context: BehaviorContext::Any, modifier_hint: None, }]; let result = assemble_behaviors(&primitives, &[], None, &mut rng); assert_eq!(result.len(), 1); assert_eq!(result[0], "tends crops in the field"); } #[test] fn assemble_behaviors_context_filter() { use crate::simulation::rng::SimRng; let mut rng = SimRng::new(42); let primitives = vec![ BehaviorPrimitive { action: "works shift".into(), context: BehaviorContext::OnShift, modifier_hint: None, }, BehaviorPrimitive { action: "drinks at bar".into(), context: BehaviorContext::Social, modifier_hint: None, }, BehaviorPrimitive { action: "stretches".into(), context: BehaviorContext::Any, modifier_hint: None, }, ]; // OnShift filter: should return OnShift + Any 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"); // Social filter: should return Social + Any let social = assemble_behaviors(&primitives, &[], Some(BehaviorContext::Social), &mut rng); assert_eq!(social.len(), 2); assert_eq!(social[0], "drinks at bar"); assert_eq!(social[1], "stretches"); // No filter: all three let all = assemble_behaviors(&primitives, &[], None, &mut rng); assert_eq!(all.len(), 3); } #[test] fn assemble_behaviors_off_duty_context_filter() { use crate::simulation::rng::SimRng; let mut rng = SimRng::new(42); let primitives = vec![ BehaviorPrimitive { action: "operates crane".into(), context: BehaviorContext::OnShift, modifier_hint: None, }, BehaviorPrimitive { action: "sleeps in bunk".into(), context: BehaviorContext::OffDuty, modifier_hint: None, }, BehaviorPrimitive { action: "stretches".into(), context: BehaviorContext::Any, modifier_hint: None, }, ]; // OffDuty filter: should return OffDuty + Any, exclude OnShift 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"); } #[test] fn assemble_behaviors_hint_fallback_to_any_modifier() { use crate::simulation::rng::SimRng; let mut rng = SimRng::new(42); let primitives = vec![BehaviorPrimitive { action: "runs diagnostics".into(), context: BehaviorContext::Any, modifier_hint: Some("nonexistent_category".into()), }]; let modifiers = vec![BehaviorModifier { category: "demeanor".into(), clause: "with focused intensity".into(), }]; // Hint doesn't match any modifier category → falls back to any modifier let result = assemble_behaviors(&primitives, &modifiers, None, &mut rng); assert_eq!(result[0], "runs diagnostics with focused intensity"); } #[test] fn behavior_primitive_round_trips_through_ron() { let prim = BehaviorPrimitive { action: "moves freight containers".into(), context: BehaviorContext::OnShift, modifier_hint: Some("work_style".into()), }; let ron_str = ron::ser::to_string_pretty(&prim, ron::ser::PrettyConfig::default()).unwrap(); let deserialized: BehaviorPrimitive = ron::from_str(&ron_str).unwrap(); assert_eq!(deserialized.action, "moves freight containers"); assert_eq!(deserialized.context, BehaviorContext::OnShift); assert_eq!(deserialized.modifier_hint, Some("work_style".into())); } #[test] fn behavior_modifier_round_trips_through_ron() { let modifier = BehaviorModifier { category: "work_style".into(), clause: "with mechanical efficiency".into(), }; 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" ); } }