Files
settled-reach/server/src/npc/blueprint.rs
T
2026-03-07 19:52:08 +01:00

440 lines
17 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 (#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.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()],
}],
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);
}
}