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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user