feat(simulation): composable behavior engine — action+modifier+context primitives (#633)
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 <noreply@anthropic.com>
This commit is contained in:
+16
-1
@@ -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<String>` 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<BehaviorPrimitive>` (serde default, backward-compatible). `CultureProfile.behavior_modifiers: Vec<BehaviorModifier>` (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)*
|
||||
|
||||
@@ -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)*
|
||||
|
||||
@@ -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<String, VecDeque<String>> {
|
||||
///
|
||||
/// 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<String, VecDeque<String>> {
|
||||
let mut pools: BTreeMap<String, VecDeque<String>> = BTreeMap::new();
|
||||
for role in &zone.roles {
|
||||
let mut behaviors: Vec<String> = role.typical_behaviors.clone();
|
||||
let mut behaviors: Vec<String> = 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<NpcBlueprint> = name_pool
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
//! RON (Rusty Object Notation) — struct-aware, supports enums and comments.
|
||||
//! Validate with: `tooling/validate-ron <file.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<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.
|
||||
@@ -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<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)]
|
||||
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<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() {
|
||||
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<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.
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user