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]
|
## [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
|
### 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)
|
- 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)
|
- ~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)
|
- 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
|
## [v0.1.24] — 2026-03-06
|
||||||
|
|
||||||
|
|||||||
@@ -21,15 +21,18 @@
|
|||||||
//! axes (traits, relationships, behaviors, cultural markers) as standalone
|
//! axes (traits, relationships, behaviors, cultural markers) as standalone
|
||||||
//! functions that work without ECS. Full ECS integration is deferred.
|
//! functions that work without ECS. Full ECS integration is deferred.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, VecDeque};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process;
|
use std::process;
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
|
use rand::SeedableRng;
|
||||||
|
use rand_chacha::ChaCha20Rng;
|
||||||
|
|
||||||
use settled_reach_server::npc::blueprint::{
|
use settled_reach_server::npc::blueprint::{
|
||||||
BlueprintRelationship, CultureProfile, CulturalMarkers, CulturalValues, NamingConventions,
|
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::npc::PersonalityTrait;
|
||||||
use settled_reach_server::simulation::rng::SimRng;
|
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.
|
/// 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`.
|
/// 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.
|
/// Callers must use the returned `Vec`'s length as the actual NPC count.
|
||||||
///
|
///
|
||||||
/// Exits if `given_names` is empty (validator should catch this upstream,
|
/// Exits if `given_names` is empty (validator should catch this upstream).
|
||||||
/// but we guard here defensively — #5 fix).
|
fn build_name_pool(
|
||||||
fn build_name_pool(rng: &mut SimRng, culture: &CultureProfile, count: usize) -> Vec<String> {
|
seed: u64,
|
||||||
|
zone_type: &str,
|
||||||
|
culture: &CultureProfile,
|
||||||
|
count: usize,
|
||||||
|
) -> Vec<String> {
|
||||||
if culture.naming.given_names.is_empty() {
|
if culture.naming.given_names.is_empty() {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Error: culture '{}' has no given_names — cannot generate NPCs.",
|
"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 pool_size = culture.naming.given_names.len();
|
||||||
let actual_count = count.min(pool_size);
|
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.
|
// Partial Fisher-Yates — produces `actual_count` unique given-name indices.
|
||||||
let mut indices: Vec<usize> = (0..pool_size).collect();
|
let mut indices: Vec<usize> = (0..pool_size).collect();
|
||||||
for i in 0..actual_count {
|
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);
|
indices.swap(i, swap);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,7 +426,7 @@ fn build_name_pool(rng: &mut SimRng, culture: &CultureProfile, count: usize) ->
|
|||||||
.map(|&i| {
|
.map(|&i| {
|
||||||
let given = &culture.naming.given_names[i];
|
let given = &culture.naming.given_names[i];
|
||||||
if culture.naming.family_name_used_socially && !culture.naming.family_names.is_empty() {
|
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];
|
let family = &culture.naming.family_names[family_idx];
|
||||||
format!("{} {}", given, family)
|
format!("{} {}", given, family)
|
||||||
} else {
|
} 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();
|
let mut behaviors: Vec<String> = Vec::new();
|
||||||
|
|
||||||
// Pick 1 role-specific behavior
|
// Draw from pre-shuffled pool without replacement (#629 fix).
|
||||||
if !role.typical_behaviors.is_empty() {
|
// 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());
|
let idx = rng.rng.random_range(0..role.typical_behaviors.len());
|
||||||
behaviors.push(role.typical_behaviors[idx].clone());
|
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
|
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
|
// Cultural markers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -568,17 +615,23 @@ fn generate_npc_blueprint(
|
|||||||
zone: &ZoneSpec,
|
zone: &ZoneSpec,
|
||||||
culture: &CultureProfile,
|
culture: &CultureProfile,
|
||||||
name: String,
|
name: String,
|
||||||
|
behavior_pools: &mut BTreeMap<String, VecDeque<String>>,
|
||||||
) -> NpcBlueprint {
|
) -> NpcBlueprint {
|
||||||
let role = pick_role(rng, zone);
|
let role = pick_role(rng, zone);
|
||||||
let traits = gen_traits(rng, culture);
|
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);
|
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 {
|
NpcBlueprint {
|
||||||
name,
|
name,
|
||||||
role: role.id.clone(),
|
role: role.id.clone(),
|
||||||
traits,
|
traits,
|
||||||
|
want,
|
||||||
observable_behaviors,
|
observable_behaviors,
|
||||||
cultural_markers,
|
cultural_markers,
|
||||||
relationships: vec![],
|
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
|
// 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) {
|
fn print_output(output: &SpikeOutput) {
|
||||||
println!("=== {} ===", output.zone_type.to_uppercase());
|
println!("=== {} ===", output.zone_type.to_uppercase());
|
||||||
println!("Zone: {}", output.zone_type);
|
println!("Zone: {}", output.zone_type);
|
||||||
@@ -630,10 +920,15 @@ fn print_output(output: &SpikeOutput) {
|
|||||||
println!(" Role: {}", npc.role);
|
println!(" Role: {}", npc.role);
|
||||||
let trait_labels: Vec<&str> = npc.traits.iter().map(|&t| trait_label(t)).collect();
|
let trait_labels: Vec<&str> = npc.traits.iter().map(|&t| trait_label(t)).collect();
|
||||||
println!(" Traits: [{}]", trait_labels.join(", "));
|
println!(" Traits: [{}]", trait_labels.join(", "));
|
||||||
|
println!(" State: [{}]", want_label(&npc.want));
|
||||||
|
|
||||||
if let Some(behavior) = npc.observable_behaviors.first() {
|
if let Some(behavior) = npc.observable_behaviors.first() {
|
||||||
println!(" Behavior: {}", behavior);
|
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!(
|
println!(
|
||||||
" Speech: {} | filler: [{}]",
|
" Speech: {} | filler: [{}]",
|
||||||
@@ -709,7 +1004,8 @@ fn main() {
|
|||||||
process::exit(1);
|
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);
|
let mut rng = SimRng::new(args.seed);
|
||||||
|
|
||||||
// Determine NPC count from zone density.
|
// 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 = rng.rng.random_range(density..=(density * 2));
|
||||||
let npc_count = npc_count.max(2); // at least 2 for relationship output
|
let npc_count = npc_count.max(2); // at least 2 for relationship output
|
||||||
|
|
||||||
// Pre-generate unique names without replacement (#3 fix).
|
// Pre-generate unique names using a derived RNG (#628 fix: first-pick bias).
|
||||||
// build_name_pool caps at pool size — actual count may be less than requested.
|
// Name ordering is a pure function of (seed, zone_type, culture) — independent
|
||||||
let name_pool = build_name_pool(&mut rng, &culture, npc_count);
|
// 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)
|
// First pass: generate all NPCs (no relationships yet)
|
||||||
let mut npcs: Vec<NpcBlueprint> = name_pool
|
let mut npcs: Vec<NpcBlueprint> = name_pool
|
||||||
.into_iter()
|
.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();
|
||||||
|
|
||||||
// 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();
|
let all_names: Vec<String> = npcs.iter().map(|n| n.name.clone()).collect();
|
||||||
|
|
||||||
// Second pass: assign relationships
|
// Second pass: assign relationships.
|
||||||
// Use a deterministic sub-RNG offset per NPC (advance from current state)
|
|
||||||
for npc in &mut npcs {
|
for npc in &mut npcs {
|
||||||
let rels = gen_relationships(&mut rng, &npc.name, &all_names);
|
let rels = gen_relationships(&mut rng, &npc.name, &all_names);
|
||||||
npc.relationships = rels;
|
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 {
|
let output = SpikeOutput {
|
||||||
zone_type: zone.zone_type.clone(),
|
zone_type: zone.zone_type.clone(),
|
||||||
seed: args.seed,
|
seed: args.seed,
|
||||||
|
|||||||
@@ -202,6 +202,29 @@ pub struct OccasionalInjection {
|
|||||||
// NPC blueprint (output — generator produces these)
|
// 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.
|
/// Generator output for a single NPC.
|
||||||
///
|
///
|
||||||
/// Maps to the 10-axis model (D-024) but as a serializable data record,
|
/// Maps to the 10-axis model (D-024) but as a serializable data record,
|
||||||
@@ -215,7 +238,12 @@ pub struct NpcBlueprint {
|
|||||||
pub role: String,
|
pub role: String,
|
||||||
/// Personality traits (2-3, no contradictory pairs).
|
/// Personality traits (2-3, no contradictory pairs).
|
||||||
pub traits: Vec<PersonalityTrait>,
|
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.
|
/// 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>,
|
pub observable_behaviors: Vec<String>,
|
||||||
/// Cultural markers derived from the culture profile.
|
/// Cultural markers derived from the culture profile.
|
||||||
pub cultural_markers: CulturalMarkers,
|
pub cultural_markers: CulturalMarkers,
|
||||||
@@ -370,6 +398,7 @@ mod tests {
|
|||||||
name: "Kael".into(),
|
name: "Kael".into(),
|
||||||
role: "dock_worker".into(),
|
role: "dock_worker".into(),
|
||||||
traits: vec![PersonalityTrait::Bold, PersonalityTrait::Honest],
|
traits: vec![PersonalityTrait::Bold, PersonalityTrait::Honest],
|
||||||
|
want: NpcWant::Alert,
|
||||||
observable_behaviors: vec!["works efficiently".into()],
|
observable_behaviors: vec!["works efficiently".into()],
|
||||||
cultural_markers: CulturalMarkers {
|
cultural_markers: CulturalMarkers {
|
||||||
speech_register: "direct".into(),
|
speech_register: "direct".into(),
|
||||||
|
|||||||
Reference in New Issue
Block a user