Merge remote-tracking branch 'origin/server'
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user