diff --git a/server/src/bin/generator_spike.rs b/server/src/bin/generator_spike.rs new file mode 100644 index 000000000..3bf7db449 --- /dev/null +++ b/server/src/bin/generator_spike.rs @@ -0,0 +1,678 @@ +//! 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 rural --seed 42 +//! cargo run --bin generator_spike -- --zone 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::path::PathBuf; +use std::process; + +use clap::Parser; +use rand::Rng; + +use settled_reach_server::npc::blueprint::{ + BlueprintRelationship, CultureProfile, CulturalMarkers, CulturalValues, NamingConventions, + NpcBlueprint, RoleSpec, SocialSiteSpec, SpeechPatterns, SpikeOutput, ZoneSpec, +}; +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 ("rural" or "industrial"). + #[arg(long)] + zone: String, + + /// Deterministic seed — same seed produces identical output. + #[arg(long)] + seed: u64, + + /// Culture to use (default: "krenn"). + #[arg(long, default_value = "krenn")] + culture: String, + + /// Phase 2: load zone spec and culture from RON files on disk. + /// Requires content/global/-zone-spec.ron and 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(), + ], + }, + 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(), + ], + }, + 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(), + ], + }, + 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(), + ], + }, + ], + 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(), + ], + }, + 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(), + ], + }, + 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(), + ], + }, + 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(), + ], + }, + ], + 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_krenn_culture() -> CultureProfile { + CultureProfile { + id: "krenn".into(), + name: "Krenn System 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], + }, + } +} + +// --------------------------------------------------------------------------- +// Phase 2: file loading +// --------------------------------------------------------------------------- + +fn load_zone_from_file(content_root: &PathBuf, zone_type: &str) -> ZoneSpec { + let path = content_root.join("global").join(format!("{}-zone-spec.ron", zone_type)); + let content = std::fs::read_to_string(&path).unwrap_or_else(|e| { + eprintln!("Error loading zone spec from {:?}: {}", path, e); + eprintln!("Tip: copy team fills this file (ticket #609)."); + process::exit(1); + }); + ron::from_str(&content).unwrap_or_else(|e| { + eprintln!("Invalid ZoneSpec in {:?}: {}", path, e); + process::exit(1); + }) +} + +fn load_culture_from_file(content_root: &PathBuf, 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, +]; + +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 +// --------------------------------------------------------------------------- + +fn gen_name(rng: &mut SimRng, culture: &CultureProfile) -> String { + let given_idx = rng.rng.random_range(0..culture.naming.given_names.len()); + let given = &culture.naming.given_names[given_idx]; + + if culture.naming.family_name_used_socially && !culture.naming.family_names.is_empty() { + let family_idx = rng.rng.random_range(0..culture.naming.family_names.len()); + let family = &culture.naming.family_names[family_idx]; + format!("{} {}", given, family) + } else { + given.clone() + } +} + +// --------------------------------------------------------------------------- +// Role selection (weighted) +// --------------------------------------------------------------------------- + +fn pick_role<'a>(rng: &mut SimRng, zone: &'a ZoneSpec) -> &'a RoleSpec { + 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 +// --------------------------------------------------------------------------- + +fn gen_behaviors(rng: &mut SimRng, role: &RoleSpec, culture: &CultureProfile) -> Vec { + let mut behaviors: Vec = Vec::new(); + + // Pick 1 role-specific behavior + if !role.typical_behaviors.is_empty() { + let idx = rng.rng.random_range(0..role.typical_behaviors.len()); + behaviors.push(role.typical_behaviors[idx].clone()); + } + + // Pick 1 culture-specific behavior (50% chance to add a second entry) + if !culture.values.description.is_empty() && rng.rng.random_range(0..2_u32) == 0 { + if !culture.naming.given_names.is_empty() { + // Use a behavioral tendency derived from cultural values description + let cultural_behavior = cultural_tendency(rng, culture); + behaviors.push(cultural_behavior); + } + } + + behaviors +} + +fn cultural_tendency(rng: &mut SimRng, culture: &CultureProfile) -> String { + // Derive a behavioral tendency from cultural speech patterns + let greetings_len = culture.speech.greetings.len(); + if greetings_len > 0 { + let idx = rng.rng.random_range(0..greetings_len); + let greeting = &culture.speech.greetings[idx]; + return format!("greets passersby with a brief \"{}\"", greeting); + } + "keeps to themselves unless spoken to".into() +} + +// --------------------------------------------------------------------------- +// Cultural markers +// --------------------------------------------------------------------------- + +fn gen_cultural_markers(rng: &mut SimRng, culture: &CultureProfile) -> CulturalMarkers { + // Pick 1-2 filler words + let filler_count = if culture.speech.filler_words.len() > 1 { + rng.rng.random_range(1_usize..=2.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) +// --------------------------------------------------------------------------- + +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); + } + + 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, + all_names: &[String], +) -> NpcBlueprint { + let name = gen_name(rng, culture); + let role = pick_role(rng, zone); + let traits = gen_traits(rng, culture); + let observable_behaviors = gen_behaviors(rng, role, culture); + let cultural_markers = gen_cultural_markers(rng, culture); + // Relationships assigned in a second pass once all names are known + let _ = all_names; // populated after all NPCs are named + + NpcBlueprint { + name, + role: role.id.clone(), + traits, + observable_behaviors, + cultural_markers, + relationships: vec![], + } +} + +// --------------------------------------------------------------------------- +// Output formatting +// --------------------------------------------------------------------------- + +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 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(", ")); + + if let Some(behavior) = npc.observable_behaviors.first() { + println!(" Behavior: {}", behavior); + } + + 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); + let culture = load_culture_from_file(&args.content_root, &args.culture); + (zone, culture) + } else { + // Phase 1: hardcoded stubs + let zone = match args.zone.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 additional zone types from disk)"); + process::exit(1); + } + }; + let culture = match args.culture.as_str() { + "krenn" => hardcoded_krenn_culture(), + other => { + eprintln!("Unknown culture: '{}'. Use 'krenn'.", other); + eprintln!("(Phase 2 with --from-files loads culture RON from disk)"); + process::exit(1); + } + }; + (zone, culture) + }; + + // Seed the RNG + let mut rng = SimRng::new(args.seed); + + // Determine NPC count from zone density + let npc_count = rng.rng.random_range( + zone.population_density as usize..=(zone.population_density as usize * 2).max(1) + ); + let npc_count = npc_count.max(2); // at least 2 for relationship output + + // First pass: generate all NPCs (no relationships yet) + let mut npcs: Vec = (0..npc_count) + .map(|_| generate_npc_blueprint(&mut rng, &zone, &culture, &[])) + .collect(); + + // Collect all names for relationship pass + let all_names: Vec = npcs.iter().map(|n| n.name.clone()).collect(); + + // Second pass: assign relationships + // Use a deterministic sub-RNG offset per NPC (advance from current state) + for npc in &mut npcs { + let rels = gen_relationships(&mut rng, &npc.name, &all_names); + npc.relationships = rels; + } + + let output = SpikeOutput { + zone_type: zone.zone_type.clone(), + seed: args.seed, + culture: culture.id.clone(), + npcs, + }; + + print_output(&output); +}