From 50800cbf81958e000e5f3b0bdb7f0bb70e8fa58c Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Mar 2026 09:12:07 +0100 Subject: [PATCH] =?UTF-8?q?feat(simulation):=20composable=20behavior=20eng?= =?UTF-8?q?ine=20=E2=80=94=20action+modifier+context=20primitives=20(#633)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces flat culture×zone×role behavior strings with three-layer composition: BehaviorAction (role-generic), BehaviorModifier (culture coloring), BehaviorContext (situation gating). Assembly at NpcBlueprint instantiation. Backward compatible — falls back to legacy behaviors when primitives are empty. D-139 filed, Q-057 resolved. Co-Authored-By: Claude Opus 4.6 --- decisions/content.md | 17 +- decisions/questions-content.md | 8 +- server/src/bin/generator_spike.rs | 40 ++++- server/src/npc/blueprint.rs | 283 ++++++++++++++++++++++++++++++ server/src/npc/mod.rs | 10 ++ 5 files changed, 348 insertions(+), 10 deletions(-) diff --git a/decisions/content.md b/decisions/content.md index 5db90decc..e676afe82 100644 --- a/decisions/content.md +++ b/decisions/content.md @@ -433,6 +433,21 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio - **Resolves:** Q-057 (composable behavior generation), Q-012 (generation expansion method) - **Cross-reference:** [D-010](architecture.md#d-010) (information boundaries), [D-121](#d-121-voice-is-culture-driven--job-as-modifier) (culture-primary voice), [D-122](#d-122-all-npcs-generated--named-npcs-deferred) (all NPCs generated), [D-128](#d-128-culture-implicit-in-starting-location--krenn-system-equals-krenn-culture) (culture as generator input), [D-029](#d-029-population-entanglement-ratio--305020) (NPC tier model), [D-092](perception.md#d-092) (anchor lines) +### D-139: Composable behavior primitives — three-layer assembly model +- **Date:** 2026-03-13 +- **Decision:** Observable NPC behaviors are composed from three layers rather than authored as flat strings per culture×zone×role combination. The three layers are: + 1. **BehaviorPrimitive** (on `RoleSpec`): role-generic physical stage directions (e.g., "moves freight containers", "runs diagnostics on a terminal"). Tagged with a `BehaviorContext` (OnShift/OffDuty/Social/Any) that gates when the behavior is eligible. Optional `modifier_hint` for category matching. + 2. **BehaviorModifier** (on `CultureProfile`): culture-specific clauses appended to actions (e.g., "with mechanical efficiency", "with a watchful eye"). Categorized for matching (work_style, demeanor, social, etc.). + 3. **Assembly function** (`assemble_behaviors`): at NpcBlueprint instantiation time, filters primitives by context, selects a culture modifier per primitive (preferring hint matches, falling back to any), concatenates `action + " " + modifier.clause`. No modifier → action text used as-is. +- **Rationale:** The current model has `typical_behaviors: Vec` on RoleSpec — flat strings per culture×zone×role. At ~50 behaviors × 4 roles × N zones × M cultures, this is O(roles × zones × cultures) custom content. Decomposition to primitives + modifiers reduces to O(roles + cultures) authored content. Assembly is deterministic via SimRng, so behavior output is reproducible for a given seed. The three-layer model maps directly to the existing CultureProfile/RoleSpec/NpcBlueprint data flow — no new serialization formats or pipeline stages. +- **Implementation:** `server/src/npc/blueprint.rs` — `BehaviorPrimitive`, `BehaviorContext`, `BehaviorModifier` structs + `assemble_behaviors()` function. `RoleSpec.behavior_primitives: Vec` (serde default, backward-compatible). `CultureProfile.behavior_modifiers: Vec` (serde default). Legacy `typical_behaviors` field preserved until content migration complete. +- **Migration path:** Copy team populates `behavior_primitives` on zone spec RON files and `behavior_modifiers` on culture RON files. Generator switches from `typical_behaviors` to `assemble_behaviors()` when primitives are present. Once verified equivalent for seed 42, legacy field can be removed. +- **Source:** Sprint 26, ticket #633 (implements Q-057) +- **Raised by:** Tyre (Technical Architect) +- **Dissent:** None (design resolves the scaling problem identified in #630 sprint review) +- **Resolves:** Q-057 (composable behavior generation — data structure definition) +- **Cross-reference:** [D-138](#d-138-llm-re-voicing-pipeline-for-npc-voice) (resolved pipeline, this resolves data format), [D-121](#d-121-voice-is-culture-driven--job-as-modifier) (culture-primary voice), [D-122](#d-122-all-npcs-generated--named-npcs-deferred) (all NPCs generated) + --- -*38 decisions. Last updated: 2026-03-07 (D-138 amended with Spike 2 findings: stdio IPC, tell differentiation results, double-prompt technique, ContentType::Factual, negative injectors moved to per-culture; D-123 amended; D-124 superseded — LLM Voice Pipeline Workshop)* +*39 decisions. Last updated: 2026-03-13 (D-139 composable behavior primitives — Sprint 26 #633)* diff --git a/decisions/questions-content.md b/decisions/questions-content.md index 84e7b7dd0..da8583d08 100644 --- a/decisions/questions-content.md +++ b/decisions/questions-content.md @@ -193,15 +193,15 @@ Narrative, NPCs, dialogue, templates, setting, worldbuilding, and storyteller me ### Q-057: Composable behavior generation — decompose culture × role × context into assembled behaviors -- **Status:** Open +- **Status:** Resolved — D-139 (Sprint 26, #633) - **Raised:** Sprint 25, ticket #630 review discussion - **Priority:** High (blocks scaling beyond hand-authored content) - **Context:** Current behavior pools are hand-authored per culture×zone×role combination (`typical_behaviors` arrays in zone spec RON files). At ~50 behaviors per role × 4 roles × N zone types × M cultures, this is O(roles × zones × cultures) custom content. Each cell is effectively a unique location — "rural zone spec" is really "Krenn rural settlement content" with the name filed off. This doesn't scale to multiple cultures or zone types. - **Question:** Should the generator compose observable behaviors from smaller primitives instead of drawing from pre-written complete sentences? Proposed decomposition: (1) **role action templates** — generic observable stage directions per role, culture-neutral, (2) **culture modifier sets** — culture-specific flavoring (Krenn mannerisms, speech patterns, social norms) that overlay role actions, (3) **context tags** — on-shift, off-duty, break-room, social-site-type that filter/weight which behaviors are available. The generator assembles these at runtime. -- **Implications:** Changes the content authoring model from "write 50 sentences per role per zone per culture" to "write role actions once, write culture modifiers once, compose at runtime." Server needs a composition engine (#633); copy needs to author the decomposed format (#634). Part of the Sprint 25 PoC spike. -- **Cross-reference:** #630 (behavior pool expansion), #633 (server: composition engine), #634 (copy: decomposed content format), D-121 (voice is culture-driven), D-122 (all NPCs generated) +- **Resolution:** Yes. D-139 defines the three-layer composable behavior model: `BehaviorPrimitive` (role actions with context tags), `BehaviorModifier` (culture overlays), and `BehaviorContext` (on-shift/off-duty/social/any filtering). The `assemble_behaviors()` function composes at runtime. Generator spike updated to use assembly when `behavior_primitives` are present, falling back to `typical_behaviors` for backward compatibility. Copy team (#634) authors the decomposed format. +- **Cross-reference:** #630 (behavior pool expansion), #633 (server: composition engine), #634 (copy: decomposed content format), D-121 (voice is culture-driven), D-122 (all NPCs generated), D-139 (composable behavior assembly) - **Assigned to:** Tyre, Mellanie, Miri --- -*21 questions (5 resolved, 2 partially resolved, 14 open). Last updated: 2026-03-07 (Q-057 added — composable behaviors)* +*21 questions (6 resolved, 2 partially resolved, 13 open). Last updated: 2026-03-13 (Q-057 resolved by D-139)* diff --git a/server/src/bin/generator_spike.rs b/server/src/bin/generator_spike.rs index 0d4bc15f7..9007084f2 100644 --- a/server/src/bin/generator_spike.rs +++ b/server/src/bin/generator_spike.rs @@ -31,8 +31,9 @@ use rand::SeedableRng; use rand_chacha::ChaCha20Rng; use settled_reach_server::npc::blueprint::{ - BlueprintRelationship, CultureProfile, CulturalMarkers, CulturalValues, NamingConventions, - NpcBlueprint, NpcWant, RoleSpec, SocialSiteSpec, SpeechPatterns, SpikeOutput, ZoneSpec, + assemble_behaviors, BlueprintRelationship, CultureProfile, CulturalMarkers, CulturalValues, + NamingConventions, NpcBlueprint, NpcWant, RoleSpec, SocialSiteSpec, SpeechPatterns, + SpikeOutput, ZoneSpec, }; use settled_reach_server::npc::PersonalityTrait; use settled_reach_server::simulation::rng::SimRng; @@ -93,6 +94,7 @@ fn hardcoded_rural_zone() -> ZoneSpec { "repairs equipment by hand".into(), "watches the horizon with a practiced eye".into(), ], + behavior_primitives: vec![], }, RoleSpec { id: "mechanic".into(), @@ -105,6 +107,7 @@ fn hardcoded_rural_zone() -> ZoneSpec { "wipes grease on coveralls between tasks".into(), "explains repairs in terse technical shorthand".into(), ], + behavior_primitives: vec![], }, RoleSpec { id: "trader".into(), @@ -117,6 +120,7 @@ fn hardcoded_rural_zone() -> ZoneSpec { "haggles with quiet persistence".into(), "watches foot traffic from market stall".into(), ], + behavior_primitives: vec![], }, RoleSpec { id: "militia".into(), @@ -129,6 +133,7 @@ fn hardcoded_rural_zone() -> ZoneSpec { "checks credentials at the gate".into(), "leans on rifle while scanning the horizon".into(), ], + behavior_primitives: vec![], }, ], social_sites: vec![ @@ -163,6 +168,7 @@ fn hardcoded_industrial_zone() -> ZoneSpec { "waits at a loading bay with arms crossed".into(), "calls out bay numbers to a colleague".into(), ], + behavior_primitives: vec![], }, RoleSpec { id: "technician".into(), @@ -175,6 +181,7 @@ fn hardcoded_industrial_zone() -> ZoneSpec { "traces conduit runs along a ceiling with a flashlight".into(), "replaces a component panel with practiced speed".into(), ], + behavior_primitives: vec![], }, RoleSpec { id: "foreman".into(), @@ -187,6 +194,7 @@ fn hardcoded_industrial_zone() -> ZoneSpec { "walks the floor with a datapad under one arm".into(), "pulls aside a worker for a quiet word".into(), ], + behavior_primitives: vec![], }, RoleSpec { id: "security".into(), @@ -199,6 +207,7 @@ fn hardcoded_industrial_zone() -> ZoneSpec { "checks IDs at the freight elevator".into(), "stands at post near restricted equipment bays".into(), ], + behavior_primitives: vec![], }, ], social_sites: vec![ @@ -251,6 +260,7 @@ fn hardcoded_krenn_culture() -> CultureProfile { voice_persona: None, voice_examples: vec![], occasional_injections: vec![], + behavior_modifiers: vec![], } } @@ -470,10 +480,29 @@ fn pick_role<'a>(rng: &mut SimRng, zone: &'a ZoneSpec) -> &'a RoleSpec { /// The pools are consumed as NPCs are generated: each NPC pops the front. /// When a pool is exhausted (more NPCs of that role than behaviors), `gen_behaviors` /// falls back to a random repeat with an eprintln warning — not a crash. -fn build_behavior_pools(rng: &mut SimRng, zone: &ZoneSpec) -> BTreeMap> { +/// +/// When a role has non-empty `behavior_primitives`, uses `assemble_behaviors()` +/// (D-139) to compose culture×role behaviors instead of flat `typical_behaviors`. +/// Falls back to `typical_behaviors` when primitives are empty — backward compatible. +fn build_behavior_pools( + rng: &mut SimRng, + zone: &ZoneSpec, + culture: &CultureProfile, +) -> BTreeMap> { let mut pools: BTreeMap> = BTreeMap::new(); for role in &zone.roles { - let mut behaviors: Vec = role.typical_behaviors.clone(); + let mut behaviors: Vec = if !role.behavior_primitives.is_empty() { + // D-139: composable assembly — primitives + culture modifiers + assemble_behaviors( + &role.behavior_primitives, + &culture.behavior_modifiers, + None, // no context filter during pool building + rng, + ) + } else { + // Legacy path: flat typical_behaviors strings + role.typical_behaviors.clone() + }; let n = behaviors.len(); // Fisher-Yates shuffle using the main RNG (deterministic order is zone-seeded). for i in 0..n { @@ -1020,7 +1049,8 @@ fn main() { let name_pool = build_name_pool(args.seed, &args.zone, &culture, npc_count); // Pre-shuffle behavior pools per role (#629 fix: dedup within a zone run). - let mut behavior_pools = build_behavior_pools(&mut rng, &zone); + // D-139: uses assemble_behaviors() when behavior_primitives are present. + let mut behavior_pools = build_behavior_pools(&mut rng, &zone, &culture); // First pass: generate all NPCs (no relationships yet) let mut npcs: Vec = name_pool diff --git a/server/src/npc/blueprint.rs b/server/src/npc/blueprint.rs index c929cc1c3..f58ba78db 100644 --- a/server/src/npc/blueprint.rs +++ b/server/src/npc/blueprint.rs @@ -19,6 +19,7 @@ //! RON (Rusty Object Notation) — struct-aware, supports enums and comments. //! Validate with: `tooling/validate-ron ` +use rand::Rng; use serde::{Deserialize, Serialize}; use crate::npc::PersonalityTrait; @@ -66,7 +67,15 @@ pub struct RoleSpec { /// 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 until composable + /// assembly produces verified-equivalent output. pub typical_behaviors: Vec, + /// Composable behavior primitives (#633, Q-057). + /// Role-generic physical stage directions that get composed with culture + /// modifiers at NpcBlueprint instantiation time. When present, the assembly + /// function uses these instead of `typical_behaviors`. + #[serde(default)] + pub behavior_primitives: Vec, } /// A social site template within a zone. @@ -84,6 +93,131 @@ pub struct SocialSiteSpec { 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 Krenn modifier "with mechanical efficiency" +/// → "moves freight containers with mechanical efficiency" +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BehaviorPrimitive { + /// The action text — a role-generic physical stage direction. + /// Must be a complete sentence fragment that can stand alone or + /// accept a trailing modifier clause. + pub action: String, + /// Context tag gating when this behavior is eligible. + pub context: BehaviorContext, + /// Optional modifier slot hint. If set, the assembly function + /// prefers modifiers matching this category. If empty, any + /// compatible modifier may be selected. + #[serde(default)] + pub modifier_hint: Option, +} + +/// Context tags that gate when a behavior primitive is eligible. +/// +/// The generator checks the NPC's current assignment against these tags +/// to filter the behavior pool before selection. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +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. + Any, +} + +impl Default for BehaviorContext { + fn default() -> Self { + Self::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`. + /// Also used for dedup — at most one modifier per category per NPC. + pub category: String, + /// The modifier clause text. Concatenated to action text with a space. + pub clause: String, +} + +/// Assemble observable behavior strings from primitives + culture modifiers. +/// +/// This is the core composition function (#633). It replaces the flat +/// `typical_behaviors` selection with a composable pipeline: +/// +/// 1. Filter primitives by context (pass `None` for no filtering) +/// 2. For each eligible primitive, select a culture modifier +/// 3. Concatenate: `action + " " + modifier.clause` +/// 4. If no modifier matches, use the action text as-is +/// +/// Returns the assembled strings in the same order as the input primitives. +pub fn assemble_behaviors( + primitives: &[BehaviorPrimitive], + modifiers: &[BehaviorModifier], + context_filter: Option, + rng: &mut crate::simulation::rng::SimRng, +) -> Vec { + let eligible: Vec<&BehaviorPrimitive> = primitives + .iter() + .filter(|p| match context_filter { + Some(ctx) => p.context == ctx || p.context == BehaviorContext::Any, + None => true, + }) + .collect(); + + let mut results = Vec::with_capacity(eligible.len()); + + for prim in &eligible { + // Find matching modifiers: prefer hint match, fall back to any + let matching: Vec<&BehaviorModifier> = if let Some(ref hint) = prim.modifier_hint { + let hinted: Vec<&BehaviorModifier> = + modifiers.iter().filter(|m| &m.category == hint).collect(); + if hinted.is_empty() { + 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) // --------------------------------------------------------------------------- @@ -116,6 +250,11 @@ pub struct CultureProfile { /// Some cultures have none, others several. No cap on count. #[serde(default)] pub occasional_injections: Vec, + /// Culture-specific behavior modifier clauses (#633, Q-057). + /// Composed with `BehaviorPrimitive` actions during NPC generation. + /// Empty means no cultural modifier — action text used as-is. + #[serde(default)] + pub behavior_modifiers: Vec, } /// Naming conventions for NPC name generation. @@ -338,6 +477,7 @@ mod tests { 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(), @@ -382,6 +522,7 @@ mod tests { voice_persona: None, voice_examples: vec![], occasional_injections: vec![], + behavior_modifiers: vec![], }; let ron_str = @@ -436,4 +577,146 @@ mod tests { 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_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"); + } } diff --git a/server/src/npc/mod.rs b/server/src/npc/mod.rs index 5428dbcac..2ea3189c0 100644 --- a/server/src/npc/mod.rs +++ b/server/src/npc/mod.rs @@ -117,6 +117,16 @@ impl Plugin for NpcPlugin { #[derive(Component, Debug)] pub struct Npc; +/// Voice culture identifier for an NPC entity (D-138). +/// +/// Stores the culture_id used to key the voice cache. Attached at spawn time +/// by the content system or generator. Absent NPCs fall back to base text. +#[derive(Component, Debug, Clone)] +pub struct NpcVoiceProfile { + pub culture_id: String, +} + + /// NPC animation tier (D-047). /// /// Tier 1 (clear): public daily activities — instantly readable.