diff --git a/CHANGELOG.md b/CHANGELOG.md index 8131a8906..980b55221 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/server/src/bin/generator_spike.rs b/server/src/bin/generator_spike.rs index e5eea6d20..0d4bc15f7 100644 --- a/server/src/bin/generator_spike.rs +++ b/server/src/bin/generator_spike.rs @@ -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 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 { +/// 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 { 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 = (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 { +/// 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> { + let mut pools: BTreeMap> = BTreeMap::new(); + for role in &zone.roles { + let mut behaviors: Vec = 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) -> Vec { let mut behaviors: Vec = 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>, ) -> 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 { + 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 = 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 = 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, diff --git a/server/src/npc/blueprint.rs b/server/src/npc/blueprint.rs index f301eb077..c929cc1c3 100644 --- a/server/src/npc/blueprint.rs +++ b/server/src/npc/blueprint.rs @@ -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, + /// 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, /// 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(),