Files
settled-reach/server/src/npc/blueprint.rs
T
jpmschweitzerandClaude Opus 4.6 7f7706442a 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>
2026-03-17 10:32:42 +01:00

931 lines
37 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 <file.ron> <zone_type|culture>`
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<RoleSpec>,
/// Social site templates that can appear in this zone type.
pub social_sites: Vec<SocialSiteSpec>,
}
/// 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<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 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
/// modifiers at NpcBlueprint instantiation time. When present, the assembly
/// function uses these instead of `typical_behaviors`.
#[serde(default)]
pub behavior_primitives: Vec<BehaviorPrimitive>,
}
/// 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<String>,
/// 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/<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)
// ---------------------------------------------------------------------------
/// 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<String>,
}
/// 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<BehaviorContext>,
rng: &mut crate::simulation::rng::SimRng,
) -> Vec<String> {
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<String>,
/// Example input/output pairs for voice pipeline prompts.
#[serde(default)]
pub voice_examples: Vec<VoiceExample>,
/// 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<OccasionalInjection>,
/// 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<BehaviorModifier>,
}
/// 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<String>,
/// Pool of family names (may be empty if culture is first-name-primary).
pub family_names: Vec<String>,
/// 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<String>,
/// Common greetings.
pub greetings: Vec<String>,
/// Common farewells.
pub farewells: Vec<String>,
/// Oath or exclamation phrases.
pub exclamations: Vec<String>,
}
/// 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<PersonalityTrait>,
/// Traits that are less common in this culture (biased against).
pub disfavored_traits: Vec<PersonalityTrait>,
}
// ---------------------------------------------------------------------------
// 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<VoiceExample>,
/// Probability of inclusion per prompt (0.01.0).
pub frequency: f32,
/// Tell categories that suppress this injection to avoid conflicting instructions.
#[serde(default)]
pub suppress_on_tells: Vec<TellCategory>,
}
// ---------------------------------------------------------------------------
// 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<PersonalityTrait>,
/// 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<String>,
/// Cultural markers derived from the culture profile.
pub cultural_markers: CulturalMarkers,
/// Relationship slots (0-3 per D-024).
pub relationships: Vec<BlueprintRelationship>,
/// Tell-specific behaviors — always passed through verbatim, never re-voiced.
#[serde(default)]
pub tell_behaviors: Vec<TellBehavior>,
}
/// 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<String>,
/// 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<NpcBlueprint>,
}
// ---------------------------------------------------------------------------
// 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"
);
}
}