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:
2026-03-13 09:12:07 +01:00
co-authored by Claude Opus 4.6
parent accbe579c9
commit 50800cbf81
5 changed files with 348 additions and 10 deletions
+283
View File
@@ -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");
}
}
+10
View File
@@ -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.