feat(simulation): generator spike binary — template assembly Phase 1 (#612)

Add generator-spike binary producing NPC rosters from hardcoded zone and
culture stubs. Deterministic via SimRng, supports rural and industrial
zone types with Krenn culture. Phase 2 (RON file loading) wired via
--from-files flag, awaiting copy team deliverables (#609, #610).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 21:39:36 +01:00
co-authored by Claude Opus 4.6
parent fb3ebf4313
commit 0f83c64e8f
+678
View File
@@ -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>-zone-spec.ron and 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(),
],
},
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 23 personality traits, culturally biased, no contradictory pairs.
///
/// Cultural bias: favored traits are picked first if any remain valid;
/// disfavored traits are rejected on first encounter (replaced by reroll).
fn gen_traits(rng: &mut SimRng, culture: &CultureProfile) -> Vec<PersonalityTrait> {
let count = rng.rng.random_range(2_usize..=3);
let mut chosen: Vec<PersonalityTrait> = Vec::with_capacity(count);
// Build a weighted candidate pool: favored traits appear twice, disfavored once.
let mut pool: Vec<PersonalityTrait> = Vec::with_capacity(20);
for &t in &ALL_TRAITS {
if culture.values.favored_traits.contains(&t) {
pool.push(t);
pool.push(t); // double weight
} else if !culture.values.disfavored_traits.contains(&t) {
pool.push(t);
}
// disfavored: excluded from pool entirely
}
// Fallback: if pool is empty (extreme culture config), use all traits
if pool.is_empty() {
pool.extend_from_slice(&ALL_TRAITS);
}
let mut attempts = 0_usize;
while chosen.len() < count && attempts < 100 {
attempts += 1;
let idx = rng.rng.random_range(0..pool.len());
let candidate = pool[idx];
if chosen.contains(&candidate) {
continue;
}
if chosen.iter().any(|&t| traits_contradict(t, candidate)) {
continue;
}
chosen.push(candidate);
}
chosen
}
// ---------------------------------------------------------------------------
// Name generation
// ---------------------------------------------------------------------------
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<String> {
let mut behaviors: Vec<String> = 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<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)
// ---------------------------------------------------------------------------
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);
}
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<NpcBlueprint> = (0..npc_count)
.map(|_| generate_npc_blueprint(&mut rng, &zone, &culture, &[]))
.collect();
// Collect all names for relationship pass
let all_names: Vec<String> = 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);
}