Merge remote-tracking branch 'origin/server'

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
2026-03-07 19:52:08 +01:00
3 changed files with 391 additions and 41 deletions
+6
View File
@@ -6,10 +6,16 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Fixed
- Name pool first-pick bias — generator spike produced "Dav" as NPC 1 across all seeds; now uses derived RNG per zone+culture (#628)
- Behavior dedup — same behavior string no longer assigned to multiple NPCs in one zone run (#629)
### Added
- Zone identity specs renamed to location-specific: krenn-rural-zone.ron and krenn-industrial-zone.ron — acknowledges these are culture×zone content, not reusable templates (#630, Q-057)
- ~108 new NPC behavior pool entries across all roles in both zone files — trader stage directions, foreman humanity behaviors, dock_worker/technician off-shift/break room behaviors (#630)
- Q-057 open question: composable behavior generation — decompose hand-authored pools into role actions + culture modifiers + context tags (#633, #634)
- Relationship-to-behavior pipeline — NPC behavior lines now reflect social connections (rivals ignore each other, friends gravitate, subordinates defer) (#631)
- Want/State layer — NPCs have internal motives (Bored, Alert, Suspicious, AvoidingSomeone, LookingForInfo) that leak through observable micro-tells (#632)
## [v0.1.24] — 2026-03-06
+356 -41
View File
@@ -21,15 +21,18 @@
//! axes (traits, relationships, behaviors, cultural markers) as standalone
//! functions that work without ECS. Full ECS integration is deferred.
use std::collections::{BTreeMap, VecDeque};
use std::path::PathBuf;
use std::process;
use clap::Parser;
use rand::Rng;
use rand::SeedableRng;
use rand_chacha::ChaCha20Rng;
use settled_reach_server::npc::blueprint::{
BlueprintRelationship, CultureProfile, CulturalMarkers, CulturalValues, NamingConventions,
NpcBlueprint, RoleSpec, SocialSiteSpec, SpeechPatterns, SpikeOutput, ZoneSpec,
NpcBlueprint, NpcWant, RoleSpec, SocialSiteSpec, SpeechPatterns, SpikeOutput, ZoneSpec,
};
use settled_reach_server::npc::PersonalityTrait;
use settled_reach_server::simulation::rng::SimRng;
@@ -360,18 +363,42 @@ fn gen_traits(rng: &mut SimRng, culture: &CultureProfile) -> Vec<PersonalityTrai
}
// ---------------------------------------------------------------------------
// Name generation (without replacement — #3 fix)
// Name generation (without replacement — #628 fix: derived RNG)
// ---------------------------------------------------------------------------
/// Mix `seed`, `zone_type`, and `culture_id` into a name-pool-specific u64
/// using inline FNV-1a (same pattern as TemplateId/TriangleId in D-010).
///
/// This ensures name ordering is a pure function of (seed, zone_type, culture),
/// not of what other operations consumed from the main RNG before the shuffle.
/// Without this, different zone types with the same seed (e.g. rural/42 and
/// industrial/42) produce the same first name because both consume exactly one
/// main-RNG word before reaching the Fisher-Yates step.
fn name_pool_seed(seed: u64, zone_type: &str, culture_id: &str) -> u64 {
let mut h: u64 = seed ^ 0xcbf29ce484222325; // FNV-1a offset basis, XOR'd with seed
for b in zone_type.bytes().chain(std::iter::once(b':')).chain(culture_id.bytes()) {
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3); // FNV-1a prime
}
h
}
/// Build a shuffled name pool of up to `count` unique names.
///
/// Uses Fisher-Yates on given_names indices so each name appears at most once.
/// Uses a derived RNG (seed × zone_type × culture_id) for the Fisher-Yates
/// shuffle so name ordering doesn't depend on how many words the main RNG
/// has consumed for other purposes (#628 fix).
///
/// Returns fewer names than requested if the pool is smaller than `count`.
/// Callers must use the returned `Vec`'s length as the actual NPC count.
///
/// Exits if `given_names` is empty (validator should catch this upstream,
/// but we guard here defensively — #5 fix).
fn build_name_pool(rng: &mut SimRng, culture: &CultureProfile, count: usize) -> Vec<String> {
/// Exits if `given_names` is empty (validator should catch this upstream).
fn build_name_pool(
seed: u64,
zone_type: &str,
culture: &CultureProfile,
count: usize,
) -> Vec<String> {
if culture.naming.given_names.is_empty() {
eprintln!(
"Error: culture '{}' has no given_names — cannot generate NPCs.",
@@ -383,10 +410,14 @@ fn build_name_pool(rng: &mut SimRng, culture: &CultureProfile, count: usize) ->
let pool_size = culture.naming.given_names.len();
let actual_count = count.min(pool_size);
// Derived RNG — isolated from the main SimRng stream (#628 fix).
let derived_seed = name_pool_seed(seed, zone_type, &culture.id);
let mut name_rng = ChaCha20Rng::seed_from_u64(derived_seed);
// Partial Fisher-Yates — produces `actual_count` unique given-name indices.
let mut indices: Vec<usize> = (0..pool_size).collect();
for i in 0..actual_count {
let swap = rng.rng.random_range(i..pool_size);
let swap = name_rng.random_range(i..pool_size);
indices.swap(i, swap);
}
@@ -395,7 +426,7 @@ fn build_name_pool(rng: &mut SimRng, culture: &CultureProfile, count: usize) ->
.map(|&i| {
let given = &culture.naming.given_names[i];
if culture.naming.family_name_used_socially && !culture.naming.family_names.is_empty() {
let family_idx = rng.rng.random_range(0..culture.naming.family_names.len());
let family_idx = name_rng.random_range(0..culture.naming.family_names.len());
let family = &culture.naming.family_names[family_idx];
format!("{} {}", given, family)
} else {
@@ -430,40 +461,56 @@ fn pick_role<'a>(rng: &mut SimRng, zone: &'a ZoneSpec) -> &'a RoleSpec {
}
// ---------------------------------------------------------------------------
// Observable behaviors
// Observable behaviors (with zone-level dedup — #629 fix)
// ---------------------------------------------------------------------------
fn gen_behaviors(rng: &mut SimRng, role: &RoleSpec, culture: &CultureProfile) -> Vec<String> {
/// Pre-shuffle behavior pools per role so NPCs within a zone run draw
/// without replacement (#629 fix).
///
/// 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>> {
let mut pools: BTreeMap<String, VecDeque<String>> = BTreeMap::new();
for role in &zone.roles {
let mut behaviors: Vec<String> = 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 {
let j = rng.rng.random_range(i..n);
behaviors.swap(i, j);
}
pools.insert(role.id.clone(), VecDeque::from(behaviors));
}
pools
}
/// Pick 1 role-specific behavior from the pre-shuffled pool (without replacement).
///
/// If the pool is exhausted, falls back to a random pick from typical_behaviors
/// and logs a warning — this is a content signal that the role needs more behavior
/// strings in the zone spec.
fn gen_behaviors(rng: &mut SimRng, role: &RoleSpec, pool: &mut VecDeque<String>) -> Vec<String> {
let mut behaviors: Vec<String> = Vec::new();
// Pick 1 role-specific behavior
if !role.typical_behaviors.is_empty() {
// Draw from pre-shuffled pool without replacement (#629 fix).
// Index 0 is always the primary role action — relationship overrides and
// Want tells may replace or append to this in later passes.
if let Some(behavior) = pool.pop_front() {
behaviors.push(behavior);
} else if !role.typical_behaviors.is_empty() {
// Pool exhausted — allow repeat, warn content authors.
eprintln!(
"Warning: behavior pool for role '{}' exhausted — add more typical_behaviors to avoid repeats.",
role.id
);
let idx = rng.rng.random_range(0..role.typical_behaviors.len());
behaviors.push(role.typical_behaviors[idx].clone());
}
// 50% chance to add a cultural behavior from the speech greeting pool.
// Guard on greetings being non-empty — cultural_tendency draws from it.
// (#7 fix: was incorrectly checking given_names, which is unrelated here)
if !culture.speech.greetings.is_empty() && rng.rng.random_range(0..2_u32) == 0 {
let cultural_behavior = cultural_tendency(rng, culture);
behaviors.push(cultural_behavior);
}
behaviors
}
fn cultural_tendency(rng: &mut SimRng, culture: &CultureProfile) -> String {
// Derive a behavioral tendency from cultural speech patterns
let greetings_len = culture.speech.greetings.len();
if greetings_len > 0 {
let idx = rng.rng.random_range(0..greetings_len);
let greeting = &culture.speech.greetings[idx];
return format!("greets passersby with a brief \"{}\"", greeting);
}
"keeps to themselves unless spoken to".into()
}
// ---------------------------------------------------------------------------
// Cultural markers
// ---------------------------------------------------------------------------
@@ -568,17 +615,23 @@ fn generate_npc_blueprint(
zone: &ZoneSpec,
culture: &CultureProfile,
name: String,
behavior_pools: &mut BTreeMap<String, VecDeque<String>>,
) -> NpcBlueprint {
let role = pick_role(rng, zone);
let traits = gen_traits(rng, culture);
let observable_behaviors = gen_behaviors(rng, role, culture);
let want = gen_want(rng, &traits, role);
let pool = behavior_pools
.entry(role.id.clone())
.or_insert_with(VecDeque::new);
let observable_behaviors = gen_behaviors(rng, role, pool);
let cultural_markers = gen_cultural_markers(rng, culture);
// Relationships assigned in a second pass once all names are known.
// Relationships and Want tells assigned in later passes.
NpcBlueprint {
name,
role: role.id.clone(),
traits,
want,
observable_behaviors,
cultural_markers,
relationships: vec![],
@@ -586,6 +639,232 @@ fn generate_npc_blueprint(
}
}
// ---------------------------------------------------------------------------
// Want/State generation (#632)
// ---------------------------------------------------------------------------
/// Generate an internal motive (Want) for an NPC based on traits and role.
///
/// The Want is the NPC's current internal state — not visible to the player,
/// but it leaks through the observable behavior as a tell. Trait combinations
/// bias the distribution: cautious NPCs trend Alert/Suspicious, curious ones
/// trend LookingForInfo, bold ones resist Bored, social ones lean neutral.
/// Combat-eligible roles start with Alert bias.
fn gen_want(rng: &mut SimRng, traits: &[PersonalityTrait], role: &RoleSpec) -> NpcWant {
// Build a weighted pool: each Want gets a base weight, modified by traits.
// Weights: [Neutral, Bored, Alert, Suspicious, AvoidingSomeone, LookingForInfo]
let mut weights: [u32; 6] = [10, 4, 4, 3, 2, 3];
for &t in traits {
use PersonalityTrait::*;
match t {
Cautious => {
weights[2] += 4; // Alert
weights[3] += 3; // Suspicious
}
Bold => {
weights[1] = weights[1].saturating_sub(2); // less Bored
weights[2] += 1; // Alert
}
Curious => {
weights[5] += 4; // LookingForInfo
weights[1] = weights[1].saturating_sub(1); // less Bored
}
Incurious => {
weights[1] += 3; // Bored
}
Social => {
weights[5] += 2; // LookingForInfo
}
Reclusive => {
weights[4] += 3; // AvoidingSomeone
}
Deceptive => {
weights[3] += 2; // Suspicious
weights[5] += 2; // LookingForInfo
}
Honest => {
weights[3] = weights[3].saturating_sub(2); // less Suspicious
}
_ => {}
}
}
// Combat-eligible roles start with higher Alert bias.
if role.combat_eligible {
weights[2] += 3; // Alert
weights[0] = weights[0].saturating_sub(3); // less Neutral
}
let total: u32 = weights.iter().sum();
let roll = rng.rng.random_range(0..total);
let mut cumulative = 0u32;
for (i, &w) in weights.iter().enumerate() {
cumulative += w;
if roll < cumulative {
return match i {
0 => NpcWant::Neutral,
1 => NpcWant::Bored,
2 => NpcWant::Alert,
3 => NpcWant::Suspicious,
4 => NpcWant::AvoidingSomeone,
_ => NpcWant::LookingForInfo,
};
}
}
NpcWant::Neutral
}
/// Generate the observable tell for an NPC's Want — the behavior that leaks
/// the internal state to a perceptive observer.
///
/// Returns `None` for `Neutral` (no tell needed — NPC is just doing their job).
/// For `AvoidingSomeone`, uses the NPC's relationships to name a target if one
/// has Negative valence; falls back to generic phrasing if no such relationship.
///
/// ~70% of non-Neutral NPCs get a tell (seeded-random). The other 30% have a
/// Want that hasn't surfaced yet — invisible until the right moment.
fn gen_want_tell(rng: &mut SimRng, npc: &NpcBlueprint) -> Option<String> {
use NpcWant::*;
if npc.want == Neutral {
return None;
}
// 70% chance to surface the tell in observable behavior.
if rng.rng.random_range(0..10_u32) >= 7 {
return None; // Want is present but hasn't surfaced yet
}
// Find a named target for AvoidingSomeone from the relationship list.
let avoid_target: Option<&str> = npc
.relationships
.iter()
.find(|r| {
use settled_reach_server::npc::blueprint::RelationshipValence;
r.valence == RelationshipValence::Negative
})
.map(|r| r.target_name.as_str());
let tell = match &npc.want {
Neutral => return None,
Bored => {
let options = [
"shifts weight and checks the time without reason",
"drums fingers absently on a nearby surface",
"glances toward the exit more than the task warrants",
"half-watches passing foot traffic instead of working",
];
options[rng.rng.random_range(0..options.len())].to_string()
}
Alert => {
let options = [
"pauses to scan the room before continuing",
"tracks movement near the entrance without turning",
"positions with back to the wall during a natural pause",
"clocks where each person in the space is standing",
];
options[rng.rng.random_range(0..options.len())].to_string()
}
Suspicious => {
let options = [
"watches the room in the glass of a nearby surface",
"lingers near a conversation just long enough to catch a word",
"double-checks something that didn't need checking",
"slows their pace near a group without joining it",
];
options[rng.rng.random_range(0..options.len())].to_string()
}
AvoidingSomeone => match avoid_target {
Some(name) => {
let options = [
format!("routes around {}'s usual area without looking toward it", name),
format!("takes the long way past {}'s position", name),
format!("keeps a surface or cluster of people between them and {}", name),
];
options[rng.rng.random_range(0..options.len())].clone()
}
None => "takes routes that keep them out of direct lines of sight".to_string(),
},
LookingForInfo => {
let options = [
"scans faces as people move through the space",
"drifts near conversations without joining, listening",
"makes eye contact with newcomers longer than social norms allow",
"asks a small question that's really a probe for something larger",
];
options[rng.rng.random_range(0..options.len())].to_string()
}
};
Some(tell)
}
// ---------------------------------------------------------------------------
// Relationship-driven behavior (#631)
// ---------------------------------------------------------------------------
/// Override an NPC's primary behavior with a relationship-revealing action
/// when the NPC has an active relationship (#631).
///
/// This is the "bridge between populated and inhabited" — the behavior line
/// the player reads reflects a real social connection, not just the role's
/// generic action. Rivals won't acknowledge each other; friends drift together;
/// subordinates defer.
///
/// Applied after both the generation pass and the relationship pass so the
/// full relationship list is available. ~50% chance per NPC (seeded), ensuring
/// a mix of relationship-driven and role-driven behaviors in any given run.
fn apply_relationship_behaviors(rng: &mut SimRng, npc: &mut NpcBlueprint) {
use settled_reach_server::npc::blueprint::RelationshipValence;
if npc.relationships.is_empty() {
return;
}
// ~50% chance to express a relationship through behavior.
if rng.rng.random_range(0..2_u32) == 0 {
return;
}
// Pick the first relationship (deterministic ordering from gen_relationships).
let rel = &npc.relationships[0];
let target = &rel.target_name;
let behavior = match (rel.relationship_type.as_str(), rel.valence) {
("rival", RelationshipValence::Negative) => {
format!("talks past {} without making eye contact", target)
}
("rival", _) => format!("keeps {} in peripheral view without approaching", target),
("friend", RelationshipValence::Positive) => {
format!("catches {}'s eye and nods across the room", target)
}
("friend", _) => format!("drifts toward {} between tasks", target),
("subordinate", RelationshipValence::Negative) => {
format!("complies when {} speaks, but doesn't volunteer anything", target)
}
("subordinate", _) => format!("defers to {} before moving on", target),
("superior", RelationshipValence::Negative) => {
format!("checks whether {} is watching before moving on", target)
}
("superior", _) => format!("glances at {} to see if anything is needed", target),
("colleague", RelationshipValence::Positive) => {
format!("exchanges a few quiet words with {}", target)
}
("colleague", RelationshipValence::Negative) => {
format!("works efficiently, keeping clear of {}", target)
}
_ => format!("glances toward {} briefly before continuing", target),
};
// Replace the primary behavior.
if npc.observable_behaviors.is_empty() {
npc.observable_behaviors.push(behavior);
} else {
npc.observable_behaviors[0] = behavior;
}
}
// ---------------------------------------------------------------------------
// Output formatting
// ---------------------------------------------------------------------------
@@ -616,6 +895,17 @@ fn valence_label(v: &settled_reach_server::npc::blueprint::RelationshipValence)
}
}
fn want_label(want: &NpcWant) -> &'static str {
match want {
NpcWant::Neutral => "Neutral",
NpcWant::Bored => "Bored",
NpcWant::Alert => "Alert",
NpcWant::Suspicious => "Suspicious",
NpcWant::AvoidingSomeone => "AvoidingSomeone",
NpcWant::LookingForInfo => "LookingForInfo",
}
}
fn print_output(output: &SpikeOutput) {
println!("=== {} ===", output.zone_type.to_uppercase());
println!("Zone: {}", output.zone_type);
@@ -630,10 +920,15 @@ fn print_output(output: &SpikeOutput) {
println!(" Role: {}", npc.role);
let trait_labels: Vec<&str> = npc.traits.iter().map(|&t| trait_label(t)).collect();
println!(" Traits: [{}]", trait_labels.join(", "));
println!(" State: [{}]", want_label(&npc.want));
if let Some(behavior) = npc.observable_behaviors.first() {
println!(" Behavior: {}", behavior);
}
// Index 1 is the Want tell — the observable leak of the internal state.
if let Some(tell) = npc.observable_behaviors.get(1) {
println!(" Tell: {}", tell);
}
println!(
" Speech: {} | filler: [{}]",
@@ -709,7 +1004,8 @@ fn main() {
process::exit(1);
}
// Seed the RNG
// Seed the main RNG from args.seed directly.
// Name generation uses a separately derived seed (#628 fix) — see build_name_pool.
let mut rng = SimRng::new(args.seed);
// Determine NPC count from zone density.
@@ -718,26 +1014,45 @@ fn main() {
let npc_count = rng.rng.random_range(density..=(density * 2));
let npc_count = npc_count.max(2); // at least 2 for relationship output
// Pre-generate unique names without replacement (#3 fix).
// build_name_pool caps at pool size — actual count may be less than requested.
let name_pool = build_name_pool(&mut rng, &culture, npc_count);
// Pre-generate unique names using a derived RNG (#628 fix: first-pick bias).
// Name ordering is a pure function of (seed, zone_type, culture) — independent
// of how many words the main RNG has consumed for NPC count or other purposes.
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);
// First pass: generate all NPCs (no relationships yet)
let mut npcs: Vec<NpcBlueprint> = name_pool
.into_iter()
.map(|name| generate_npc_blueprint(&mut rng, &zone, &culture, name))
.map(|name| {
generate_npc_blueprint(&mut rng, &zone, &culture, name, &mut behavior_pools)
})
.collect();
// Collect all names for relationship pass
// Collect all names for relationship pass.
let all_names: Vec<String> = npcs.iter().map(|n| n.name.clone()).collect();
// Second pass: assign relationships
// Use a deterministic sub-RNG offset per NPC (advance from current state)
// Second pass: assign relationships.
for npc in &mut npcs {
let rels = gen_relationships(&mut rng, &npc.name, &all_names);
npc.relationships = rels;
}
// Third pass: apply relationship-driven behavior overrides (#631).
// Must run after relationships are assigned.
for npc in &mut npcs {
apply_relationship_behaviors(&mut rng, npc);
}
// Fourth pass: generate Want tells (#632).
// Must run after relationships are assigned (AvoidingSomeone resolves from relationships).
for npc in &mut npcs {
if let Some(tell) = gen_want_tell(&mut rng, npc) {
npc.observable_behaviors.push(tell);
}
}
let output = SpikeOutput {
zone_type: zone.zone_type.clone(),
seed: args.seed,
+29
View File
@@ -202,6 +202,29 @@ pub struct OccasionalInjection {
// NPC blueprint (output — generator produces these)
// ---------------------------------------------------------------------------
/// Internal motive/state driving an NPC's micro-behavior (#632).
///
/// The player never sees this directly — it leaks through the observable
/// behavior as a tell. The gap between what an NPC is doing and WHY is
/// the asymmetric information mechanic (D-010 principle 2).
///
/// `Neutral` means no notable motive — the NPC is simply doing their job.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum NpcWant {
/// Nothing stands out; routine work mode.
Neutral,
/// Disengaged from current task; attention elsewhere.
Bored,
/// Heightened attention to the environment; scanning, tracking.
Alert,
/// Something is wrong or off; watching without being obvious about it.
Suspicious,
/// Steering clear of a specific person in the zone.
AvoidingSomeone,
/// Actively trying to pick up information by proximity or conversation.
LookingForInfo,
}
/// Generator output for a single NPC.
///
/// Maps to the 10-axis model (D-024) but as a serializable data record,
@@ -215,7 +238,12 @@ pub struct NpcBlueprint {
pub role: String,
/// Personality traits (2-3, no contradictory pairs).
pub traits: Vec<PersonalityTrait>,
/// Internal motive that biases observable behavior (#632).
/// The player never sees this label — they infer it from the behavior.
pub want: NpcWant,
/// Observable behaviors the player can witness.
/// Index 0 is the primary role/relationship-driven action.
/// Index 1 (if present) is the Want tell — the leak of the internal state.
pub observable_behaviors: Vec<String>,
/// Cultural markers derived from the culture profile.
pub cultural_markers: CulturalMarkers,
@@ -370,6 +398,7 @@ mod tests {
name: "Kael".into(),
role: "dock_worker".into(),
traits: vec![PersonalityTrait::Bold, PersonalityTrait::Honest],
want: NpcWant::Alert,
observable_behaviors: vec!["works efficiently".into()],
cultural_markers: CulturalMarkers {
speech_register: "direct".into(),