440 lines
17 KiB
Rust
440 lines
17 KiB
Rust
//! NPC blueprint structs for the generator spike (#611).
|
||
//!
|
||
//! Three input/output structs for the generator proof-of-life:
|
||
//! - `ZoneSpec` — zone identity specification (deserializes from zone-identity-spec RON)
|
||
//! - `CultureProfile` — culture profile (deserializes from culture RON)
|
||
//! - `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 identity spec: ticket #609
|
||
//! - Culture profile (Krenn): ticket #610
|
||
//!
|
||
//! ## Format
|
||
//!
|
||
//! RON (Rusty Object Notation) — struct-aware, supports enums and comments.
|
||
//! Validate with: `tooling/validate-ron <file.ron>`
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::npc::PersonalityTrait;
|
||
use crate::npc::tell_state::TellCategory;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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).
|
||
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).
|
||
pub typical_behaviors: Vec<String>,
|
||
}
|
||
|
||
/// 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,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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. "krenn").
|
||
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>,
|
||
}
|
||
|
||
/// 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()],
|
||
}],
|
||
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: "krenn".into(),
|
||
name: "Krenn System 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![],
|
||
};
|
||
|
||
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, "krenn");
|
||
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: "krenn".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);
|
||
}
|
||
}
|