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
+35 -5
View File
@@ -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