//! 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/.ron and server/content/global/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 { let count = rng.rng.random_range(2_usize..=3); let mut chosen: Vec = Vec::with_capacity(count); // Build a weighted candidate pool: favored traits appear twice, disfavored once. let mut pool: Vec = 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 { 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 = (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> { let mut pools: BTreeMap> = BTreeMap::new(); for role in &zone.roles { let mut behaviors: Vec = 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) -> Vec { let mut behaviors: Vec = 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 = Vec::with_capacity(filler_count); let mut filler_indices: Vec = (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 { 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 = (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>, ) -> 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 { 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 = 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 = 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); }