Files
settled-reach/server/src/bin/generator_spike.rs
T
jpmschweitzerandClaude Opus 4.6 5fc9c03683 chore(server): auto-format Rust code (cargo fmt)
Pre-existing formatting issues in generator_spike.rs and
validate_ron.rs caught by the new pre-push lint hook.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 18:26:06 +01:00

1154 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Generator spike binary — NPC generation proof-of-life (Sprint 25, ticket #612).
//!
//! Produces NPCs from zone + culture inputs using deterministic SimRng.
//! Phase 1: hardcoded zone and culture stubs (no file I/O needed).
//! Phase 2: `--from-files` loads real RON content written by copy team (#609, #610).
//!
//! # Sprint proof
//!
//! Run twice, compare side-by-side:
//! ```sh
//! cargo run --bin generator_spike -- --zone-type rural --seed 42
//! cargo run --bin generator_spike -- --zone-type industrial --seed 42
//! ```
//!
//! The test: can you tell which is which from the output alone?
//!
//! # Feasibility note (Troblum's review)
//!
//! `generate_npc()` in `npc/generate.rs` requires a live bevy `World`.
//! This binary does NOT use that function. Instead it reimplements the relevant
//! 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::{Path, PathBuf};
use std::process;
use clap::Parser;
use rand::Rng;
use rand::SeedableRng;
use rand_chacha::ChaCha20Rng;
use settled_reach_server::npc::blueprint::{
assemble_behaviors, BlueprintRelationship, CulturalMarkers, CulturalValues, CultureProfile,
NamingConventions, NpcBlueprint, NpcWant, RoleSpec, SocialSiteSpec, SpeechPatterns,
SpikeOutput, ZoneSpec, ZoneTypeTemplate,
};
use settled_reach_server::npc::PersonalityTrait;
use settled_reach_server::simulation::rng::SimRng;
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
#[derive(Parser)]
#[command(
name = "generator-spike",
about = "NPC generator proof-of-life — Sprint 25 (#612)"
)]
struct Args {
/// Zone type to generate.
/// Phase 1 (hardcoded): "rural" or "industrial".
/// Phase 2 (--from-files): a zone-type ID like "rural_agricultural" or "industrial_freight".
#[arg(long)]
zone_type: String,
/// Deterministic seed — same seed produces identical output.
#[arg(long)]
seed: u64,
/// Culture to use (default: "van-maanens-star").
#[arg(long, default_value = "van-maanens-star")]
culture: String,
/// Phase 2: load zone-type template and culture from RON files on disk.
/// Requires server/content/global/zone-types/<zone_type>.ron and server/content/global/culture-<culture>.ron.
#[arg(long)]
from_files: bool,
/// Content root for --from-files mode.
#[arg(long, default_value = "content")]
content_root: PathBuf,
}
// ---------------------------------------------------------------------------
// Phase 1: hardcoded zone stubs
// ---------------------------------------------------------------------------
fn hardcoded_rural_zone() -> ZoneSpec {
ZoneSpec {
zone_type: "rural".into(),
label: "Rural Settlement".into(),
description: "Scattered homesteads, small workshops, communal gathering spots. Low density, strong community bonds, subsistence-plus economy.".into(),
economic_level: 3,
population_density: 2,
roles: vec![
RoleSpec {
id: "farmer".into(),
label: "Farmer".into(),
weight: 5,
skill_focus: vec!["technical".into()],
combat_eligible: false,
typical_behaviors: vec![
"tends crops in the field".into(),
"hauls produce to the market stall".into(),
"repairs equipment by hand".into(),
"watches the horizon with a practiced eye".into(),
],
behavior_primitives: vec![],
},
RoleSpec {
id: "mechanic".into(),
label: "Settlement Mechanic".into(),
weight: 3,
skill_focus: vec!["technical".into()],
combat_eligible: false,
typical_behaviors: vec![
"works on machinery with focused intensity".into(),
"wipes grease on coveralls between tasks".into(),
"explains repairs in terse technical shorthand".into(),
],
behavior_primitives: vec![],
},
RoleSpec {
id: "trader".into(),
label: "Itinerant Trader".into(),
weight: 2,
skill_focus: vec!["persuasion".into(), "observation".into()],
combat_eligible: false,
typical_behaviors: vec![
"arranges goods on a portable display".into(),
"haggles with quiet persistence".into(),
"watches foot traffic from market stall".into(),
],
behavior_primitives: vec![],
},
RoleSpec {
id: "militia".into(),
label: "Settlement Militia".into(),
weight: 1,
skill_focus: vec!["combat".into(), "observation".into()],
combat_eligible: true,
typical_behaviors: vec![
"patrols the settlement perimeter".into(),
"checks credentials at the gate".into(),
"leans on rifle while scanning the horizon".into(),
],
behavior_primitives: vec![],
},
],
social_sites: vec![
SocialSiteSpec {
site_type: "tavern".into(),
label: "Local Tavern".into(),
roles: vec!["farmer".into(), "mechanic".into(), "trader".into()],
min_npcs: 3,
max_npcs: 6,
},
],
}
}
fn hardcoded_industrial_zone() -> ZoneSpec {
ZoneSpec {
zone_type: "industrial".into(),
label: "Industrial Zone".into(),
description: "Freight handling, manufacturing, and maintenance. High throughput, shift-based work rhythms, functional over comfortable.".into(),
economic_level: 7,
population_density: 6,
roles: vec![
RoleSpec {
id: "dock_worker".into(),
label: "Dock Worker".into(),
weight: 5,
skill_focus: vec!["technical".into()],
combat_eligible: false,
typical_behaviors: vec![
"moves freight containers with mechanical efficiency".into(),
"checks a manifest against a handheld scanner".into(),
"waits at a loading bay with arms crossed".into(),
"calls out bay numbers to a colleague".into(),
],
behavior_primitives: vec![],
},
RoleSpec {
id: "technician".into(),
label: "Systems Technician".into(),
weight: 4,
skill_focus: vec!["technical".into()],
combat_eligible: false,
typical_behaviors: vec![
"runs diagnostics on a control terminal".into(),
"traces conduit runs along a ceiling with a flashlight".into(),
"replaces a component panel with practiced speed".into(),
],
behavior_primitives: vec![],
},
RoleSpec {
id: "foreman".into(),
label: "Shift Foreman".into(),
weight: 2,
skill_focus: vec!["observation".into(), "persuasion".into()],
combat_eligible: false,
typical_behaviors: vec![
"reviews production targets on a wall-mounted display".into(),
"walks the floor with a datapad under one arm".into(),
"pulls aside a worker for a quiet word".into(),
],
behavior_primitives: vec![],
},
RoleSpec {
id: "security".into(),
label: "Facility Security".into(),
weight: 2,
skill_focus: vec!["combat".into(), "observation".into()],
combat_eligible: true,
typical_behaviors: vec![
"sweeps access corridors on a timed rotation".into(),
"checks IDs at the freight elevator".into(),
"stands at post near restricted equipment bays".into(),
],
behavior_primitives: vec![],
},
],
social_sites: vec![
SocialSiteSpec {
site_type: "break_room".into(),
label: "Worker Break Room".into(),
roles: vec!["dock_worker".into(), "technician".into(), "foreman".into()],
min_npcs: 2,
max_npcs: 5,
},
],
}
}
// ---------------------------------------------------------------------------
// Phase 1: hardcoded culture stub
// ---------------------------------------------------------------------------
fn hardcoded_van_maanens_star_culture() -> CultureProfile {
CultureProfile {
id: "van-maanens-star".into(),
name: "Van Maanen's Star Culture".into(),
description: "Working-class pragmatic culture. ~180 years settled. Community-oriented, suspicious of distant authority, values competence and reliability.".into(),
naming: NamingConventions {
style: "compact, consonant-heavy, first-name-primary".into(),
given_names: vec![
"Kael".into(), "Voss".into(), "Lera".into(), "Torek".into(), "Drin".into(),
"Maret".into(), "Naia".into(), "Sera".into(), "Nils".into(), "Pael".into(),
"Tev".into(), "Ren".into(), "Sess".into(), "Renn".into(), "Olin".into(),
"Tav".into(), "Resha".into(), "Harek".into(), "Sabel".into(), "Pell".into(),
],
family_names: vec![
"Davan".into(), "Sessik".into(), "Korr".into(), "Tamm".into(),
"Venn".into(), "Lintar".into(), "Darvo".into(), "Kosse".into(),
],
family_name_used_socially: false,
},
speech: SpeechPatterns {
register: "direct, minimal pleasantries, gets to the point".into(),
filler_words: vec!["look".into(), "right".into(), "yeah".into(), "so".into()],
greetings: vec!["hey".into(), "morning".into(), "shift treating you alright?".into()],
farewells: vec!["shift's calling".into(), "gotta move".into(), "catch you later".into()],
exclamations: vec!["void take it".into(), "stars".into(), "unbelievable".into()],
},
values: CulturalValues {
description: "Pragmatic, community-oriented, suspicious of authority. Competence earns respect. Showing up and doing the work matters more than rank.".into(),
favored_traits: vec![PersonalityTrait::Bold, PersonalityTrait::Honest, PersonalityTrait::Curious],
disfavored_traits: vec![PersonalityTrait::Reclusive, PersonalityTrait::Deceptive],
},
voice_persona: None,
voice_examples: vec![],
occasional_injections: vec![],
behavior_modifiers: vec![],
}
}
// ---------------------------------------------------------------------------
// Phase 2: file loading
// ---------------------------------------------------------------------------
fn load_zone_from_file(content_root: &Path, zone_type: &str) -> ZoneSpec {
let path = content_root
.join("global")
.join("zone-types")
.join(format!("{}.ron", zone_type));
let content = std::fs::read_to_string(&path).unwrap_or_else(|e| {
eprintln!("Error loading zone-type template from {:?}: {}", path, e);
eprintln!("Tip: zone-type RON files live in content/global/zone-types/.");
process::exit(1);
});
let template: ZoneTypeTemplate = ron::from_str(&content).unwrap_or_else(|e| {
eprintln!("Invalid ZoneTypeTemplate in {:?}: {}", path, e);
process::exit(1);
});
zone_type_template_to_zone_spec(template)
}
/// Convert a `ZoneTypeTemplate` to a `ZoneSpec` compatible with the spike generation pipeline.
///
/// `ZoneTypeTemplate` roles have no weight (weights live on `LocationSpec`).
/// The spike assigns weight=1 uniformly — all roles equally likely.
fn zone_type_template_to_zone_spec(template: ZoneTypeTemplate) -> ZoneSpec {
ZoneSpec {
zone_type: template.id,
label: template.label,
description: template.description,
economic_level: template.economic_level,
population_density: template.population_density,
roles: template
.roles
.into_iter()
.map(|mut r| {
r.weight = 1; // uniform weight — location specs provide real weights
r
})
.collect(),
social_sites: template
.social_site_types
.into_iter()
.map(|s| SocialSiteSpec {
site_type: s.site_type,
label: String::new(),
roles: s.eligible_roles,
min_npcs: 1,
max_npcs: 6,
})
.collect(),
}
}
fn load_culture_from_file(content_root: &Path, culture_id: &str) -> CultureProfile {
let path = content_root
.join("global")
.join(format!("culture-{}.ron", culture_id));
let content = std::fs::read_to_string(&path).unwrap_or_else(|e| {
eprintln!("Error loading culture from {:?}: {}", path, e);
eprintln!("Tip: copy team fills this file (ticket #610).");
process::exit(1);
});
ron::from_str(&content).unwrap_or_else(|e| {
eprintln!("Invalid CultureProfile in {:?}: {}", path, e);
process::exit(1);
})
}
// ---------------------------------------------------------------------------
// Personality trait generation (standalone — no World required)
// ---------------------------------------------------------------------------
const ALL_TRAITS: [PersonalityTrait; 10] = [
PersonalityTrait::Cautious,
PersonalityTrait::Bold,
PersonalityTrait::Honest,
PersonalityTrait::Deceptive,
PersonalityTrait::Compassionate,
PersonalityTrait::Ruthless,
PersonalityTrait::Curious,
PersonalityTrait::Incurious,
PersonalityTrait::Social,
PersonalityTrait::Reclusive,
];
// TODO(integration): duplicates the private `traits_contradict` in npc/generate.rs.
// When wiring the spike into the main pipeline, expose that function as pub(crate)
// and remove this copy. Kept separate for now to avoid spike coupling to ECS internals.
fn traits_contradict(a: PersonalityTrait, b: PersonalityTrait) -> bool {
use PersonalityTrait::*;
matches!(
(a, b),
(Cautious, Bold)
| (Bold, Cautious)
| (Honest, Deceptive)
| (Deceptive, Honest)
| (Compassionate, Ruthless)
| (Ruthless, Compassionate)
| (Curious, Incurious)
| (Incurious, Curious)
| (Social, Reclusive)
| (Reclusive, Social)
)
}
/// Generate 23 personality traits, culturally biased, no contradictory pairs.
///
/// Cultural bias: favored traits are picked first if any remain valid;
/// disfavored traits are rejected on first encounter (replaced by reroll).
fn gen_traits(rng: &mut SimRng, culture: &CultureProfile) -> Vec<PersonalityTrait> {
let count = rng.rng.random_range(2_usize..=3);
let mut chosen: Vec<PersonalityTrait> = Vec::with_capacity(count);
// Build a weighted candidate pool: favored traits appear twice, disfavored once.
let mut pool: Vec<PersonalityTrait> = Vec::with_capacity(20);
for &t in &ALL_TRAITS {
if culture.values.favored_traits.contains(&t) {
pool.push(t);
pool.push(t); // double weight
} else if !culture.values.disfavored_traits.contains(&t) {
pool.push(t);
}
// disfavored: excluded from pool entirely
}
// Fallback: if pool is empty (extreme culture config), use all traits
if pool.is_empty() {
pool.extend_from_slice(&ALL_TRAITS);
}
let mut attempts = 0_usize;
while chosen.len() < count && attempts < 100 {
attempts += 1;
let idx = rng.rng.random_range(0..pool.len());
let candidate = pool[idx];
if chosen.contains(&candidate) {
continue;
}
if chosen.iter().any(|&t| traits_contradict(t, candidate)) {
continue;
}
chosen.push(candidate);
}
chosen
}
// ---------------------------------------------------------------------------
// 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 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).
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.",
culture.id
);
process::exit(1);
}
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 = name_rng.random_range(i..pool_size);
indices.swap(i, swap);
}
indices[..actual_count]
.iter()
.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 = name_rng.random_range(0..culture.naming.family_names.len());
let family = &culture.naming.family_names[family_idx];
format!("{} {}", given, family)
} else {
given.clone()
}
})
.collect()
}
// ---------------------------------------------------------------------------
// Role selection (weighted)
// ---------------------------------------------------------------------------
fn pick_role<'a>(rng: &mut SimRng, zone: &'a ZoneSpec) -> &'a RoleSpec {
if zone.roles.is_empty() {
eprintln!(
"Error: zone '{}' has no roles — cannot generate NPCs.",
zone.zone_type
);
process::exit(1);
}
let total_weight: u32 = zone.roles.iter().map(|r| r.weight as u32).sum();
let roll = rng.rng.random_range(0..total_weight);
let mut cumulative = 0u32;
for role in &zone.roles {
cumulative += role.weight as u32;
if roll < cumulative {
return role;
}
}
&zone.roles[0]
}
// ---------------------------------------------------------------------------
// Observable behaviors (with zone-level dedup — #629 fix)
// ---------------------------------------------------------------------------
/// 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.
///
/// When a role has non-empty `behavior_primitives`, uses `assemble_behaviors()`
/// (D-139) to compose culture×role behaviors instead of flat `typical_behaviors`.
/// Falls back to `typical_behaviors` when primitives are empty — backward compatible.
fn build_behavior_pools(
rng: &mut SimRng,
zone: &ZoneSpec,
culture: &CultureProfile,
) -> BTreeMap<String, VecDeque<String>> {
let mut pools: BTreeMap<String, VecDeque<String>> = BTreeMap::new();
for role in &zone.roles {
let mut behaviors: Vec<String> = if !role.behavior_primitives.is_empty() {
// D-139: composable assembly — primitives + culture modifiers
assemble_behaviors(
&role.behavior_primitives,
&culture.behavior_modifiers,
None, // no context filter during pool building
rng,
)
} else {
// Legacy path: flat typical_behaviors strings
role.typical_behaviors.clone()
};
let n = behaviors.len();
// Fisher-Yates shuffle using the main RNG (deterministic order is zone-seeded).
for i in 0..n {
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();
// 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());
}
behaviors
}
// ---------------------------------------------------------------------------
// Cultural markers
// ---------------------------------------------------------------------------
/// Maximum filler words assigned to a single NPC from the culture pool.
const MAX_FILLER_WORDS: usize = 2;
fn gen_cultural_markers(rng: &mut SimRng, culture: &CultureProfile) -> CulturalMarkers {
let filler_count = if culture.speech.filler_words.len() > 1 {
rng.rng
.random_range(1_usize..=MAX_FILLER_WORDS.min(culture.speech.filler_words.len()))
} else {
culture.speech.filler_words.len()
};
let mut filler_words: Vec<String> = Vec::with_capacity(filler_count);
let mut filler_indices: Vec<usize> = (0..culture.speech.filler_words.len()).collect();
for i in 0..filler_count {
let swap = rng.rng.random_range(i..filler_indices.len());
filler_indices.swap(i, swap);
}
for &idx in &filler_indices[..filler_count] {
filler_words.push(culture.speech.filler_words[idx].clone());
}
let greeting = if !culture.speech.greetings.is_empty() {
let idx = rng.rng.random_range(0..culture.speech.greetings.len());
culture.speech.greetings[idx].clone()
} else {
String::new()
};
CulturalMarkers {
speech_register: culture.speech.register.clone(),
filler_words,
greeting,
}
}
// ---------------------------------------------------------------------------
// Relationship generation (name-based for spike — no StableId)
// ---------------------------------------------------------------------------
/// Generate 03 relationships for an NPC.
///
/// **One-directional by design:** each NPC independently picks their own
/// relationships. If Tev knows Renn as a "colleague (positive)" but Renn
/// has no entry for Tev, that is intentional — asymmetric awareness is
/// a core mechanic (D-034). The output may look like bugs but is correct.
fn gen_relationships(
rng: &mut SimRng,
this_name: &str,
all_names: &[String],
) -> Vec<BlueprintRelationship> {
use settled_reach_server::npc::blueprint::RelationshipValence;
let other_names: Vec<&String> = all_names
.iter()
.filter(|n| n.as_str() != this_name)
.collect();
if other_names.is_empty() {
return vec![];
}
let max_rels = 3_usize.min(other_names.len());
let count = rng.rng.random_range(0..=max_rels);
if count == 0 {
return vec![];
}
// Shuffle prefix to pick unique targets
let mut indices: Vec<usize> = (0..other_names.len()).collect();
for i in 0..count {
let swap = rng.rng.random_range(i..other_names.len());
indices.swap(i, swap);
}
// TODO(integration): Use RelationshipKind enum from npc/mod.rs instead of strings.
let rel_kinds = ["colleague", "friend", "rival", "superior", "subordinate"];
let valences = [
RelationshipValence::Positive,
RelationshipValence::Neutral,
RelationshipValence::Negative,
];
indices[..count]
.iter()
.map(|&target_idx| {
let kind_idx = rng.rng.random_range(0..rel_kinds.len());
let valence_idx = rng.rng.random_range(0..valences.len());
BlueprintRelationship {
target_name: other_names[target_idx].clone(),
relationship_type: rel_kinds[kind_idx].into(),
valence: valences[valence_idx],
}
})
.collect()
}
// ---------------------------------------------------------------------------
// NPC generation
// ---------------------------------------------------------------------------
fn generate_npc_blueprint(
rng: &mut SimRng,
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 want = gen_want(rng, &traits, role);
let pool = behavior_pools.entry(role.id.clone()).or_default();
let observable_behaviors = gen_behaviors(rng, role, pool);
let cultural_markers = gen_cultural_markers(rng, culture);
// Relationships and Want tells assigned in later passes.
NpcBlueprint {
name,
role: role.id.clone(),
traits,
want,
observable_behaviors,
cultural_markers,
relationships: vec![],
tell_behaviors: vec![],
}
}
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
// TODO(integration): Derive Display on PersonalityTrait and drop this function.
fn trait_label(t: PersonalityTrait) -> &'static str {
use PersonalityTrait::*;
match t {
Cautious => "Cautious",
Bold => "Bold",
Honest => "Honest",
Deceptive => "Deceptive",
Compassionate => "Compassionate",
Ruthless => "Ruthless",
Curious => "Curious",
Incurious => "Incurious",
Social => "Social",
Reclusive => "Reclusive",
}
}
fn valence_label(v: &settled_reach_server::npc::blueprint::RelationshipValence) -> &'static str {
use settled_reach_server::npc::blueprint::RelationshipValence::*;
match v {
Positive => "positive",
Neutral => "neutral",
Negative => "negative",
}
}
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);
println!("Culture: {}", output.culture);
println!("Seed: {}", output.seed);
println!("NPCs: {}", output.npcs.len());
println!();
for (i, npc) in output.npcs.iter().enumerate() {
println!("--- NPC {} ---", i + 1);
println!(" Name: {}", npc.name);
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: [{}]",
npc.cultural_markers.speech_register,
npc.cultural_markers.filler_words.join(", ")
);
if npc.relationships.is_empty() {
println!(" Relationships: none");
} else {
for rel in &npc.relationships {
println!(
" {} knows {} as {} ({})",
npc.name,
rel.target_name,
rel.relationship_type,
valence_label(&rel.valence)
);
}
}
println!();
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
fn main() {
let args = Args::parse();
// Load zone spec and culture profile
let (zone, culture) = if args.from_files {
eprintln!("Phase 2: loading from files...");
let zone = load_zone_from_file(&args.content_root, &args.zone_type);
let culture = load_culture_from_file(&args.content_root, &args.culture);
(zone, culture)
} else {
// Phase 1: hardcoded stubs
let zone = match args.zone_type.as_str() {
"rural" => hardcoded_rural_zone(),
"industrial" => hardcoded_industrial_zone(),
other => {
eprintln!(
"Unknown zone type: '{}'. Use 'rural' or 'industrial'.",
other
);
eprintln!(
"(Phase 2 with --from-files supports zone-type IDs like 'rural_agricultural')"
);
process::exit(1);
}
};
let culture = match args.culture.as_str() {
"van-maanens-star" => hardcoded_van_maanens_star_culture(),
other => {
eprintln!("Unknown culture: '{}'. Use 'van-maanens-star'.", other);
eprintln!("(Phase 2 with --from-files loads culture RON from disk)");
process::exit(1);
}
};
(zone, culture)
};
// Validate zone spec
if zone.population_density < 1 {
eprintln!(
"Error: population_density must be >= 1 (got {} in zone '{}')",
zone.population_density, zone.zone_type
);
process::exit(1);
}
if zone.roles.is_empty() {
eprintln!(
"Error: zone '{}' has no roles defined — cannot generate NPCs.",
zone.zone_type
);
process::exit(1);
}
// 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.
// population_density already validated >= 1 above.
let density = zone.population_density as usize;
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 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_type, &culture, npc_count);
// Pre-shuffle behavior pools per role (#629 fix: dedup within a zone run).
// D-139: uses assemble_behaviors() when behavior_primitives are present.
let mut behavior_pools = build_behavior_pools(&mut rng, &zone, &culture);
// First pass: generate all NPCs (no relationships yet)
let mut npcs: Vec<NpcBlueprint> = name_pool
.into_iter()
.map(|name| generate_npc_blueprint(&mut rng, &zone, &culture, name, &mut behavior_pools))
.collect();
// Collect all names for relationship pass.
let all_names: Vec<String> = npcs.iter().map(|n| n.name.clone()).collect();
// 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,
culture: culture.id.clone(),
npcs,
};
print_output(&output);
}