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:
@@ -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 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user