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>
1154 lines
44 KiB
Rust
1154 lines
44 KiB
Rust
//! 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 2–3 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 0–3 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);
|
||
}
|