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>
931 lines
37 KiB
Rust
931 lines
37 KiB
Rust
//! 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.0–1.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"
|
||
);
|
||
}
|
||
}
|