Compare commits

...
4 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.6 a54b9d24e0 fix(simulation): address PR #87 review — name collision, validation, and polish
- Fix critical name collision: shuffle+pop for unique NPC names (#3)
- Validate population_density >= 1 in generator and validator (#4)
- Guard against empty given_names/roles with validator warnings (#5)
- Extract filler word cap to MAX_FILLER_WORDS constant (#6)
- Fix cultural behavior gate checking wrong field (#7)
- Document intentional one-directional relationships (#8)
- Validate min_npcs <= max_npcs in validator (#9)
- Fix validate-ron script realpath error handling (#10)
- Add TODO comments for spike-specific code duplication (#11, #12, #13)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:50:33 +01:00
jpmschweitzerandClaude Opus 4.6 0f83c64e8f 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>
2026-03-06 21:39:36 +01:00
jpmschweitzerandClaude Opus 4.6 fb3ebf4313 feat(simulation): NpcBlueprint struct design, RON schema, and validator CLI (#611)
Define ZoneSpec, CultureProfile, and NpcBlueprint structs with serde/RON
deserialization. Ship example RON files as schema contract for the copy
team (#609, #610). Add validate-ron CLI for copy team to lint their files
without compiling the server.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:39:27 +01:00
jpmschweitzerandClaude Opus 4.6 4cbaf0cb57 chore(meta): switch Sprint 25 content format from YAML to RON
RON is Rust-native and struct-aware — the Rust structs ARE the schema.
Includes RON validator CLI for the copy team to lint their files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:18:12 +01:00
11 changed files with 1445 additions and 23 deletions
+67
View File
@@ -0,0 +1,67 @@
// Krenn Culture Profile — example file
//
// Schema: server/src/npc/blueprint.rs :: CultureProfile
// Real content: ticket #610 (copy team fills this)
// Validate: tooling/validate-ron content/global/culture-krenn.example.ron culture
//
// One file per culture. The generator reads this to give NPCs culturally
// appropriate names, speech patterns, and personality bias.
//
// Source: D-036 (Krenn System canonical setting), D-128 (culture implicit in location),
// D-121 (voice culture-driven), D-123 (AI content templating via culture vectors).
(
id: "krenn",
name: "Krenn System Culture",
description: "Working-class pragmatic culture. ~180 years settled, mid-Reach G3V system. Community-oriented, suspicious of distant authority, values competence and reliability over credentials. First-name-primary in social contexts.",
naming: (
style: "compact, consonant-heavy, first-name-primary in social contexts",
given_names: [
"Kael", "Voss", "Lera", "Torek", "Drin",
"Maret", "Naia", "Sera", "Nils", "Pael",
"Tev", "Ren", "Sess", "Renn", "Olin",
"Tav", "Resha", "Harek", "Sabel", "Pell",
],
family_names: [
"Davan", "Sessik", "Korr", "Tamm", "Venn",
"Lintar", "Darvo", "Kosse",
],
// Krenn culture is first-name-primary. Family names exist but are
// used mainly in formal/institutional contexts.
family_name_used_socially: false,
),
speech: (
register: "direct, minimal pleasantries, gets to the point",
filler_words: [
"look",
"right",
"yeah",
"so",
],
greetings: [
"hey",
"morning",
"shift treating you alright?",
],
farewells: [
"shift's calling",
"gotta move",
"catch you later",
],
exclamations: [
"void take it",
"stars",
"unbelievable",
],
),
values: (
description: "Pragmatic, community-oriented, suspicious of authority. Competence earns respect. Showing up and doing the work matters more than rank or credentials. Outsiders are tolerated but watched.",
// Traits more common in Krenn culture — generator biases toward these.
favored_traits: [Bold, Honest, Curious],
// Traits less common — generator biases away from these.
disfavored_traits: [Reclusive, Deceptive],
),
)
@@ -0,0 +1,97 @@
// Zone Identity Spec — example file
//
// Schema: server/src/npc/blueprint.rs :: ZoneSpec
// Real content: ticket #609 (copy team fills this)
// Validate: tooling/validate-ron content/global/zone-identity-spec.example.ron zone
//
// One file per zone type. The generator reads this to decide NPC count,
// role distribution, and social site placement.
//
// Format: RON (Rusty Object Notation) — the Rust structs ARE the schema.
// Comments are allowed. Trailing commas are allowed.
(
zone_type: "rural",
label: "Rural Settlement",
description: "Scattered homesteads, small workshops, and communal gathering spots. Low population density, strong community bonds, subsistence-plus economy.",
// 1-10 scale. Rural = low economic activity, mostly self-sufficient.
economic_level: 3,
// 1-10 scale. Rural = sparse, everyone knows everyone.
population_density: 2,
roles: [
(
id: "farmer",
label: "Farmer",
weight: 5,
skill_focus: ["technical"],
combat_eligible: false,
typical_behaviors: [
"tends crops in the field",
"hauls produce to the market stall",
"repairs equipment by hand",
],
),
(
id: "mechanic",
label: "Settlement Mechanic",
weight: 3,
skill_focus: ["technical"],
combat_eligible: false,
typical_behaviors: [
"works on machinery with focused intensity",
"wipes grease on coveralls between tasks",
"explains repairs in terse technical shorthand",
],
),
(
id: "trader",
label: "Itinerant Trader",
weight: 2,
skill_focus: ["persuasion", "observation"],
combat_eligible: false,
typical_behaviors: [
"arranges goods on a portable display",
"haggles with quiet persistence",
"watches foot traffic from market stall",
],
),
(
id: "militia",
label: "Settlement Militia",
weight: 1,
skill_focus: ["combat", "observation"],
combat_eligible: true,
typical_behaviors: [
"patrols the settlement perimeter",
"checks credentials at the gate",
"leans on rifle while scanning the horizon",
],
),
],
social_sites: [
(
site_type: "tavern",
label: "Local Tavern",
roles: ["farmer", "mechanic", "trader", "militia"],
min_npcs: 3,
max_npcs: 6,
),
(
site_type: "workshop",
label: "Community Workshop",
roles: ["mechanic", "farmer"],
min_npcs: 2,
max_npcs: 4,
),
(
site_type: "market_stall",
label: "Market Stall",
roles: ["trader", "farmer"],
min_npcs: 1,
max_npcs: 3,
),
],
)
+12 -10
View File
@@ -24,36 +24,38 @@ Use `tooling/db/ticket show <id>` for full details.
**How this sprint works for copy**
Server starts first. Tyre (#611) defines `ZoneSpec`, `CultureProfile`, and `NpcBlueprint` as Rust structs and writes example YAML showing the expected format. That YAML is the schema contract. Copy fills real content into that schema — not the other way around.
Server starts first. Tyre (#611) defines `ZoneSpec`, `CultureProfile`, and `NpcBlueprint` as Rust structs and writes example RON files showing the expected format. The Rust structs ARE the schema — no separate schema file to maintain. Copy fills real content into that format.
Wait for #611 to deliver its example YAML before writing the real files. Coordinate with Tyre at sprint start to agree on file locations (`content/global/zone-identity-spec.yaml` and `content/global/culture-krenn.yaml` are the expected paths, but Tyre's struct design is authoritative).
Wait for #611 to deliver its example RON before writing the real files. Tyre also ships a **RON validator CLI** (`tooling/validate-content <file.ron>`) that deserializes into the actual Rust structs and prints errors. Use it to lint your files before submitting.
Coordinate with Tyre at sprint start to agree on file locations (`content/global/zone-identity-spec.ron` and `content/global/culture-krenn.ron` are the expected paths, but Tyre's struct design is authoritative).
**#609 — Zone identity spec**
- Output: YAML file the generator deserializes at runtime. Schema defined by Tyre's `ZoneSpec` struct from #611.
- Output: RON file the generator deserializes at runtime. Schema defined by Tyre's `ZoneSpec` struct from #611.
- Minimum two zone types with real content: **rural** and **industrial**. These are the two the sprint proof runs. Remaining types can be stubs with plausible values.
- The taxonomy must make the generator produce visibly different output per zone type — if rural and industrial look the same, it has failed.
- Content scope: what varies between zone types (density, pace, social site mix, NPC role distribution). Not prose worldbuilding — structured parameters that the Rust generator can read.
- Do not invent the schema. Read #611's example YAML first.
- Do not invent the schema. Read #611's example RON first.
**#610 — Krenn culture profile**
- Output: YAML file the generator deserializes at runtime. Schema defined by Tyre's `CultureProfile` struct from #611.
- Output: RON file the generator deserializes at runtime. Schema defined by Tyre's `CultureProfile` struct from #611.
- Must provide enough cultural signal that generated NPCs feel Krenn, not generic-space-village.
- Sprint scope: **name lists** (not phoneme generation rules — Tyre's generator picks from lists), speech markers, economic values, social norms.
- Phoneme-based name generation is explicitly out of scope for this sprint. A curated list of Krenn-sounding names is sufficient.
- Existing Krenn atmosphere and naming examples in `decisions/content.md` D-036 (amended post-workshop) are a starting point. Go deeper on concrete values (specific speech markers, actual name examples) not broader on atmospheric description.
- Do not invent the schema. Read #611's example YAML first.
- Do not invent the schema. Read #611's example RON first.
## Dependency Chain
```
server #611 (schema contract) ──> #609 (zone spec YAML) ──┐
├──> server #612 (generator)
──> #610 (culture YAML) ───────┘
server #611 (schema + validator) ──> #609 (zone spec RON) ──┐
├──> server #612 (generator)
──> #610 (culture RON) ──────┘
```
Server defines the shape. Copy fills it. Both #609 and #610 can be written in parallel once #611 delivers its example YAML.
Server defines the shape. Copy fills it. Both #609 and #610 can be written in parallel once #611 delivers its example RON.
## PR Workflow
+13 -12
View File
@@ -33,9 +33,9 @@ Use `tooling/db/ticket show <id>` for full details.
This sprint discovers the right spec — it does not implement a known one. Three phases:
- **Phase 0 (#611):** Define the Rust structs (`ZoneSpec`, `CultureProfile`, `NpcBlueprint`) and write example YAML. Share with copy team immediately — this unblocks #609 and #610.
- **Phase 1 (#612, early):** Build the generator binary with hardcoded test data. Do not wait for copy to finish their YAML. Hardcode two zone profiles (rural, industrial stub) and a Krenn culture stub in Rust. Get the generation pipeline and stdout output working end-to-end.
- **Phase 2 (#612, late):** Swap hardcoded stubs for real YAML loading from disk. Wire in copy's actual #609 and #610 files. Run the two-zone proof.
- **Phase 0 (#611):** Define the Rust structs (`ZoneSpec`, `CultureProfile`, `NpcBlueprint`) and write example RON files. Build the RON validator CLI. Share with copy team immediately — this unblocks #609 and #610.
- **Phase 1 (#612, early):** Build the generator binary with hardcoded test data. Do not wait for copy to finish their RON files. Hardcode two zone profiles (rural, industrial stub) and a Krenn culture stub in Rust. Get the generation pipeline and stdout output working end-to-end.
- **Phase 2 (#612, late):** Swap hardcoded stubs for real RON loading from disk. Wire in copy's actual #609 and #610 files. Run the two-zone proof.
This phasing means the copy team's blocking relationship is on the final integration, not the generator build. Server can move through Phase 0 and Phase 1 in parallel with copy writing #609/#610.
@@ -43,21 +43,22 @@ This phasing means the copy team's blocking relationship is on the final integra
- Starts immediately. No blockers.
- Define three structs in `server/src/npc/blueprint.rs` (new file):
- `ZoneSpec` — deserializes from zone-identity-spec.yaml
- `CultureProfile` — deserializes from culture-krenn.yaml
- `ZoneSpec` — deserializes from zone-identity-spec.ron
- `CultureProfile` — deserializes from culture-krenn.ron
- `NpcBlueprint` — generator output for a single NPC
- All three derive `Serialize`, `Deserialize` (serde + serde_yaml).
- All three derive `Serialize`, `Deserialize` (serde + `ron`). **Use RON format, not YAML/JSON.** RON is Rust-native, struct-aware, supports enums and comments. The Rust structs ARE the schema — no separate schema file to maintain.
- `NpcBlueprint` fields: name (String), role (occupation), traits (Vec of trait enum), observable_behaviors (Vec<String>), cultural_markers (speech register, filler words from culture profile), relationships (Vec of (npc_id, relationship_type, valence)).
- Use a spike-specific `SpikeOutput` struct for the binary's top-level output — do NOT couple to `DistrictSkeleton` for the proof. Keep the spike isolated.
- Key deliverable: write `content/global/zone-identity-spec.example.yaml` and `content/global/culture-krenn.example.yaml` showing the schema copy must fill. Share these with Miri before copy starts writing real content.
- Key deliverable: write `content/global/zone-identity-spec.example.ron` and `content/global/culture-krenn.example.ron` showing the schema copy must fill. Share these with Miri before copy starts writing real content.
- Add a note in the file header pointing to the tickets (#609, #610) that fill these schemas with real content.
- **Build a RON validator CLI** (`tooling/validate-content <file.ron>`) that deserializes into the actual Rust structs and prints errors. This is the copy team's lint tool — they run it to check their files without needing to compile the server. ~20 lines of Rust, ship it as part of #611.
**#612 — Template assembly generator (absorbs #613)**
- Blocked by #611. Build Phase 1 before #609/#610 arrive; integrate in Phase 2.
- Binary: `cargo run --bin generator-spike -- --zone <type> --seed <n>` (new binary in `server/src/bin/`).
- Phase 1: hardcoded `ZoneSpec` and `CultureProfile` stubs in Rust. Focus on the generation logic and output formatting.
- Phase 2: load zone spec and culture YAML from disk at runtime. Zone taxonomy is the file — adding a new zone type requires zero Rust changes.
- Phase 2: load zone spec and culture RON from disk at runtime. Zone taxonomy is the file — adding a new zone type requires zero Rust changes.
- Determinism: `SimRng` seeded from the `--seed` flag. Same inputs = same output.
- NPC generation: use the existing `npc/generate.rs` pipeline. `NpcBlueprint` maps to `RoleDefinition` via a conversion method. The blueprint's cultural markers bias trait selection.
- Stdout output per invocation: zone type header, NPC list (name, role, traits, one observable behavior), relationship pairs ("A knows B as colleague (positive)").
@@ -66,9 +67,9 @@ This phasing means the copy team's blocking relationship is on the final integra
## Dependency Chain
```
#611 (structs + example YAML) ──> copy #609 (zone spec) ──┐
──> copy #610 (culture) ──┴──> #612 (generator, Phase 2)
#611 ──────────────────────────────────────────────────────────> #612 (generator, Phase 1 — no YAML needed)
#611 (structs + example RON + validator) ──> copy #609 (zone spec) ──┐
──> copy #610 (culture) ──┴──> #612 (generator, Phase 2)
#611 ──────────────────────────────────────────────────────────────────> #612 (generator, Phase 1 — no RON needed)
```
#611 first. Phase 1 of #612 runs in parallel with copy writing #609/#610. Phase 2 of #612 waits for both.
@@ -83,7 +84,7 @@ From Troblum's pre-sprint review. Read before starting.
3. **`DayPhase` name collision.** `server/src/simulation/generator.rs` defines `DayPhase = String` as a stub type alias, shadowing the real `DayPhase` enum in `server/src/simulation/time.rs`. Use the real enum explicitly or alias the stub out of scope before the spike binary sees both. Do not let the collision silently compile to the wrong type.
4. **Schema negotiation takes rounds.** The first YAML draft from copy will not deserialize cleanly. Build in slack between Phase 1 and Phase 2 — expect at least one round of struct adjustments after seeing real content.
4. **Schema negotiation takes rounds.** The first RON draft from copy will not deserialize cleanly — the validator will catch this early. Build in slack between Phase 1 and Phase 2 — expect at least one round of struct adjustments after seeing real content.
## PR Workflow
+20 -1
View File
@@ -128,6 +128,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "bevy_app"
version = "0.18.0"
@@ -1001,6 +1007,18 @@ dependencies = [
"serde",
]
[[package]]
name = "ron"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94"
dependencies = [
"base64",
"bitflags",
"serde",
"serde_derive",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
@@ -1092,7 +1110,7 @@ dependencies = [
[[package]]
name = "settled-reach-server"
version = "0.1.23"
version = "0.1.24"
dependencies = [
"bevy_app",
"bevy_ecs",
@@ -1102,6 +1120,7 @@ dependencies = [
"rand",
"rand_chacha",
"rmp-serde",
"ron",
"serde",
"serde_json",
"serde_yaml",
+1
View File
@@ -8,6 +8,7 @@ bevy_ecs = "0.18"
bevy_app = "0.18"
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
ron = "0.8"
rmp-serde = "1"
bincode = "1"
rand = "0.9"
+748
View File
@@ -0,0 +1,748 @@
//! 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,
];
// TODO(integration): Duplicates `traits_contradict` from npc/generate.rs.
// Extract to a shared helper in npc/mod.rs when wiring the spike into the main pipeline.
// TODO(#612 integration): duplicates the private `traits_contradict` in npc/generate.rs.
// When wiring the spike into the main pipeline, expose that function as pub(crate)
// and remove this copy. Kept separate for now to avoid spike coupling to ECS internals.
fn traits_contradict(a: PersonalityTrait, b: PersonalityTrait) -> bool {
use PersonalityTrait::*;
matches!(
(a, b),
(Cautious, Bold)
| (Bold, Cautious)
| (Honest, Deceptive)
| (Deceptive, Honest)
| (Compassionate, Ruthless)
| (Ruthless, Compassionate)
| (Curious, Incurious)
| (Incurious, Curious)
| (Social, Reclusive)
| (Reclusive, Social)
)
}
/// Generate 2–3 personality traits, culturally biased, no contradictory pairs.
///
/// Cultural bias: favored traits are picked first if any remain valid;
/// disfavored traits are rejected on first encounter (replaced by reroll).
fn gen_traits(rng: &mut SimRng, culture: &CultureProfile) -> Vec<PersonalityTrait> {
let count = rng.rng.random_range(2_usize..=3);
let mut chosen: Vec<PersonalityTrait> = Vec::with_capacity(count);
// Build a weighted candidate pool: favored traits appear twice, disfavored once.
let mut pool: Vec<PersonalityTrait> = Vec::with_capacity(20);
for &t in &ALL_TRAITS {
if culture.values.favored_traits.contains(&t) {
pool.push(t);
pool.push(t); // double weight
} else if !culture.values.disfavored_traits.contains(&t) {
pool.push(t);
}
// disfavored: excluded from pool entirely
}
// Fallback: if pool is empty (extreme culture config), use all traits
if pool.is_empty() {
pool.extend_from_slice(&ALL_TRAITS);
}
let mut attempts = 0_usize;
while chosen.len() < count && attempts < 100 {
attempts += 1;
let idx = rng.rng.random_range(0..pool.len());
let candidate = pool[idx];
if chosen.contains(&candidate) {
continue;
}
if chosen.iter().any(|&t| traits_contradict(t, candidate)) {
continue;
}
chosen.push(candidate);
}
chosen
}
// ---------------------------------------------------------------------------
// Name generation (without replacement — #3 fix)
// ---------------------------------------------------------------------------
/// Build a shuffled name pool of up to `count` unique names.
///
/// Uses Fisher-Yates on given_names indices so each name appears at most once.
/// 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,
/// but we guard here defensively — #5 fix).
fn build_name_pool(rng: &mut SimRng, culture: &CultureProfile, count: usize) -> Vec<String> {
if culture.naming.given_names.is_empty() {
eprintln!(
"Error: culture '{}' has no given_names — cannot generate NPCs.",
culture.id
);
process::exit(1);
}
let pool_size = culture.naming.given_names.len();
let actual_count = count.min(pool_size);
// Partial Fisher-Yates — produces `actual_count` unique given-name indices.
let mut indices: Vec<usize> = (0..pool_size).collect();
for i in 0..actual_count {
let swap = rng.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 = rng.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
// ---------------------------------------------------------------------------
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());
}
// 50% chance to add a cultural behavior from the speech greeting pool.
// Guard on greetings being non-empty — cultural_tendency draws from it.
// (#7 fix: was incorrectly checking given_names, which is unrelated here)
if !culture.speech.greetings.is_empty() && rng.rng.random_range(0..2_u32) == 0 {
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
// ---------------------------------------------------------------------------
/// Maximum filler words assigned to a single NPC from the culture pool.
const MAX_FILLER_WORDS: usize = 2;
fn gen_cultural_markers(rng: &mut SimRng, culture: &CultureProfile) -> CulturalMarkers {
let filler_count = if culture.speech.filler_words.len() > 1 {
rng.rng.random_range(1_usize..=MAX_FILLER_WORDS.min(culture.speech.filler_words.len()))
} else {
culture.speech.filler_words.len()
};
let mut filler_words: Vec<String> = Vec::with_capacity(filler_count);
let mut filler_indices: Vec<usize> = (0..culture.speech.filler_words.len()).collect();
for i in 0..filler_count {
let swap = rng.rng.random_range(i..filler_indices.len());
filler_indices.swap(i, swap);
}
for &idx in &filler_indices[..filler_count] {
filler_words.push(culture.speech.filler_words[idx].clone());
}
let greeting = if !culture.speech.greetings.is_empty() {
let idx = rng.rng.random_range(0..culture.speech.greetings.len());
culture.speech.greetings[idx].clone()
} else {
String::new()
};
CulturalMarkers {
speech_register: culture.speech.register.clone(),
filler_words,
greeting,
}
}
// ---------------------------------------------------------------------------
// Relationship generation (name-based for spike — no StableId)
// ---------------------------------------------------------------------------
/// Generate 0–3 relationships for an NPC.
///
/// **One-directional by design:** each NPC independently picks their own
/// relationships. If Tev knows Renn as a "colleague (positive)" but Renn
/// has no entry for Tev, that is intentional — asymmetric awareness is
/// a core mechanic (D-034). The output may look like bugs but is correct.
fn gen_relationships(
rng: &mut SimRng,
this_name: &str,
all_names: &[String],
) -> Vec<BlueprintRelationship> {
use settled_reach_server::npc::blueprint::RelationshipValence;
let other_names: Vec<&String> = all_names.iter().filter(|n| n.as_str() != this_name).collect();
if other_names.is_empty() {
return vec![];
}
let max_rels = 3_usize.min(other_names.len());
let count = rng.rng.random_range(0..=max_rels);
if count == 0 {
return vec![];
}
// Shuffle prefix to pick unique targets
let mut indices: Vec<usize> = (0..other_names.len()).collect();
for i in 0..count {
let swap = rng.rng.random_range(i..other_names.len());
indices.swap(i, swap);
}
// TODO(integration): Use RelationshipKind enum from npc/mod.rs instead of strings.
let rel_kinds = ["colleague", "friend", "rival", "superior", "subordinate"];
let valences = [
RelationshipValence::Positive,
RelationshipValence::Neutral,
RelationshipValence::Negative,
];
indices[..count]
.iter()
.map(|&target_idx| {
let kind_idx = rng.rng.random_range(0..rel_kinds.len());
let valence_idx = rng.rng.random_range(0..valences.len());
BlueprintRelationship {
target_name: other_names[target_idx].clone(),
relationship_type: rel_kinds[kind_idx].into(),
valence: valences[valence_idx],
}
})
.collect()
}
// ---------------------------------------------------------------------------
// NPC generation
// ---------------------------------------------------------------------------
fn generate_npc_blueprint(
rng: &mut SimRng,
zone: &ZoneSpec,
culture: &CultureProfile,
name: String,
) -> NpcBlueprint {
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.
NpcBlueprint {
name,
role: role.id.clone(),
traits,
observable_behaviors,
cultural_markers,
relationships: vec![],
}
}
// ---------------------------------------------------------------------------
// 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 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)
};
// 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 RNG
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 without replacement (#3 fix).
// build_name_pool caps at pool size — actual count may be less than requested.
let name_pool = build_name_pool(&mut rng, &culture, npc_count);
let _npc_count = name_pool.len(); // use capped count (for documentation)
// First pass: generate all NPCs (no relationships yet)
let mut npcs: Vec<NpcBlueprint> = name_pool
.into_iter()
.map(|name| generate_npc_blueprint(&mut rng, &zone, &culture, name))
.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);
}
+113
View File
@@ -0,0 +1,113 @@
//! RON content validator CLI (#611).
//!
//! Deserializes a RON file into the actual Rust structs and prints errors.
//! This is the copy team's lint tool — run it to check RON files without
//! needing to compile the full server.
//!
//! # Usage
//!
//! ```sh
//! # Via wrapper script (recommended):
//! tooling/validate-ron content/global/zone-identity-spec.example.ron zone
//! tooling/validate-ron content/global/culture-krenn.example.ron culture
//!
//! # Direct:
//! cargo run --bin validate_ron -- <file.ron> <zone|culture>
//! ```
use std::process;
use clap::Parser;
use settled_reach_server::npc::blueprint::{CultureProfile, ZoneSpec};
#[derive(Parser)]
#[command(
name = "validate_ron",
about = "Validate RON content files against Rust struct schemas"
)]
struct Args {
/// Path to the RON file to validate.
file: String,
/// Schema type: "zone" (ZoneSpec) or "culture" (CultureProfile).
schema: String,
}
fn main() {
let args = Args::parse();
let content = match std::fs::read_to_string(&args.file) {
Ok(c) => c,
Err(e) => {
eprintln!("Error reading {}: {}", args.file, e);
process::exit(1);
}
};
match args.schema.as_str() {
"zone" => match ron::from_str::<ZoneSpec>(&content) {
Ok(spec) => {
println!("Valid ZoneSpec: {} ({})", spec.label, spec.zone_type);
println!(" {} roles, {} social sites", spec.roles.len(), spec.social_sites.len());
let mut warnings = 0;
if spec.roles.is_empty() {
eprintln!(" WARNING: no roles defined — generator will reject this");
warnings += 1;
}
if spec.population_density < 1 {
eprintln!(" WARNING: population_density is 0 — generator requires >= 1");
warnings += 1;
}
for site in &spec.social_sites {
if site.min_npcs > site.max_npcs {
eprintln!(
" WARNING: social site '{}' has min_npcs ({}) > max_npcs ({})",
site.site_type, site.min_npcs, site.max_npcs
);
warnings += 1;
}
}
if warnings > 0 {
process::exit(1);
}
}
Err(e) => {
eprintln!("Invalid ZoneSpec in {}:", args.file);
eprintln!(" {}", e);
process::exit(1);
}
},
"culture" => match ron::from_str::<CultureProfile>(&content) {
Ok(profile) => {
println!("Valid CultureProfile: {} ({})", profile.name, profile.id);
println!(
" {} given names, {} family names",
profile.naming.given_names.len(),
profile.naming.family_names.len()
);
println!(
" {} filler words, {} favored traits",
profile.speech.filler_words.len(),
profile.values.favored_traits.len()
);
let mut warnings = 0;
if profile.naming.given_names.is_empty() {
eprintln!(" WARNING: no given_names — generator will reject this");
warnings += 1;
}
if warnings > 0 {
process::exit(1);
}
}
Err(e) => {
eprintln!("Invalid CultureProfile in {}:", args.file);
eprintln!(" {}", e);
process::exit(1);
}
},
other => {
eprintln!("Unknown schema type: '{}'. Use 'zone' or 'culture'.", other);
process::exit(1);
}
}
}
+340
View File
@@ -0,0 +1,340 @@
//! NPC blueprint structs for the generator spike (#611).
//!
//! Three input/output structs for the generator proof-of-life:
//! - `ZoneSpec` — zone identity specification (deserializes from zone-identity-spec RON)
//! - `CultureProfile` — culture profile (deserializes from culture RON)
//! - `NpcBlueprint` — generator output for a single NPC
//!
//! Plus `SpikeOutput` as the top-level binary output container.
//! These are spike-specific and intentionally decoupled from `DistrictSkeleton`.
//!
//! ## Content contract
//!
//! The Rust structs ARE the schema. Copy team fills RON files to match these structs:
//! - Zone identity spec: ticket #609
//! - Culture profile (Krenn): ticket #610
//!
//! ## Format
//!
//! RON (Rusty Object Notation) — struct-aware, supports enums and comments.
//! Validate with: `tooling/validate-ron <file.ron>`
use serde::{Deserialize, Serialize};
use crate::npc::PersonalityTrait;
// ---------------------------------------------------------------------------
// Zone identity specification (input — filled by copy team, ticket #609)
// ---------------------------------------------------------------------------
/// Top-level zone identity spec. One file per zone type.
///
/// Describes what a zone IS — its purpose, population shape, and social sites.
/// The generator reads this to decide how many NPCs to create, what roles they
/// fill, and what social sites exist.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZoneSpec {
/// Zone type identifier (e.g. "rural", "industrial", "commercial").
pub zone_type: String,
/// Human-readable label for output headers.
pub label: String,
/// Short description of this zone type's character.
pub description: String,
/// Economic activity level (1-10). Affects NPC count and role distribution.
pub economic_level: u8,
/// Population density hint (1-10). Generator scales NPC count from this.
pub population_density: u8,
/// Role definitions available in this zone type.
/// Each role has a weight (relative frequency) and properties.
pub roles: Vec<RoleSpec>,
/// Social site templates that can appear in this zone type.
pub social_sites: Vec<SocialSiteSpec>,
}
/// A role that NPCs can fill in a zone.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoleSpec {
/// Role identifier (e.g. "dock_worker", "merchant", "guard").
pub id: String,
/// Human-readable label.
pub label: String,
/// Relative weight for role selection (higher = more common).
pub weight: u8,
/// Skills biased toward for this role.
pub skill_focus: Vec<String>,
/// Whether this role may have combat capability.
pub combat_eligible: bool,
/// Observable behaviors typical for this role (generator picks from these).
pub typical_behaviors: Vec<String>,
}
/// A social site template within a zone.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SocialSiteSpec {
/// Site type identifier (e.g. "bar", "workshop", "market_stall").
pub site_type: String,
/// Human-readable label.
pub label: String,
/// Roles that staff or frequent this site (references RoleSpec.id).
pub roles: Vec<String>,
/// Minimum NPCs associated with this site.
pub min_npcs: u8,
/// Maximum NPCs associated with this site.
pub max_npcs: u8,
}
// ---------------------------------------------------------------------------
// Culture profile (input — filled by copy team, ticket #610)
// ---------------------------------------------------------------------------
/// Culture profile for a star system or region.
///
/// Drives the cultural texture of generated NPCs: how they speak, what names
/// they have, what values shape their personality distribution.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CultureProfile {
/// Culture identifier (e.g. "krenn").
pub id: String,
/// Human-readable culture name.
pub name: String,
/// Short cultural description for generator context.
pub description: String,
/// Naming conventions for this culture.
pub naming: NamingConventions,
/// Speech patterns — how NPCs from this culture talk.
pub speech: SpeechPatterns,
/// Cultural values that bias personality trait selection.
pub values: CulturalValues,
}
/// Naming conventions for NPC name generation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NamingConventions {
/// Style description (e.g. "compact, consonant-heavy, first-name-primary").
pub style: String,
/// Pool of given names the generator draws from.
pub given_names: Vec<String>,
/// Pool of family names (may be empty if culture is first-name-primary).
pub family_names: Vec<String>,
/// Whether family names are commonly used in social contexts.
pub family_name_used_socially: bool,
}
/// Speech patterns that color NPC dialogue and observable behaviors.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpeechPatterns {
/// Speech register description (e.g. "direct, minimal pleasantries").
pub register: String,
/// Filler words and verbal tics drawn from this culture.
pub filler_words: Vec<String>,
/// Common greetings.
pub greetings: Vec<String>,
/// Common farewells.
pub farewells: Vec<String>,
/// Oath or exclamation phrases.
pub exclamations: Vec<String>,
}
/// Cultural values that influence personality trait distribution.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CulturalValues {
/// Brief description of the culture's value system.
pub description: String,
/// Traits that are more common in this culture (biased toward).
pub favored_traits: Vec<PersonalityTrait>,
/// Traits that are less common in this culture (biased against).
pub disfavored_traits: Vec<PersonalityTrait>,
}
// ---------------------------------------------------------------------------
// NPC blueprint (output — generator produces these)
// ---------------------------------------------------------------------------
/// Generator output for a single NPC.
///
/// Maps to the 10-axis model (D-024) but as a serializable data record,
/// not ECS components. The spike binary prints these; the full pipeline
/// will convert `NpcBlueprint` → `RoleDefinition` → ECS entity.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NpcBlueprint {
/// Generated NPC name.
pub name: String,
/// Role identifier (matches RoleSpec.id from the zone spec).
pub role: String,
/// Personality traits (2-3, no contradictory pairs).
pub traits: Vec<PersonalityTrait>,
/// Observable behaviors the player can witness.
pub observable_behaviors: Vec<String>,
/// Cultural markers derived from the culture profile.
pub cultural_markers: CulturalMarkers,
/// Relationship slots (0-3 per D-024).
pub relationships: Vec<BlueprintRelationship>,
}
/// Cultural markers attached to a generated NPC.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CulturalMarkers {
/// Speech register inherited from culture.
pub speech_register: String,
/// Filler words this NPC uses (subset of culture's pool).
pub filler_words: Vec<String>,
/// Greeting this NPC tends to use.
pub greeting: String,
}
/// A relationship in the blueprint (pre-ECS, uses string IDs).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlueprintRelationship {
/// Target NPC name (resolved to StableId at spawn time).
pub target_name: String,
/// Relationship type.
pub relationship_type: String,
/// Valence: positive, negative, or neutral.
pub valence: RelationshipValence,
}
/// Relationship valence for blueprint output.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RelationshipValence {
Positive,
Negative,
Neutral,
}
// ---------------------------------------------------------------------------
// Spike output container
// ---------------------------------------------------------------------------
/// Top-level output for the generator spike binary.
///
/// Intentionally decoupled from `DistrictSkeleton` — this is a proof-of-life
/// container, not production architecture.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpikeOutput {
/// Zone type that was generated.
pub zone_type: String,
/// Seed used for generation.
pub seed: u64,
/// Culture profile used.
pub culture: String,
/// Generated NPCs.
pub npcs: Vec<NpcBlueprint>,
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zone_spec_round_trips_through_ron() {
let spec = ZoneSpec {
zone_type: "rural".into(),
label: "Rural Settlement".into(),
description: "Scattered homesteads and small workshops".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".into()],
}],
social_sites: vec![SocialSiteSpec {
site_type: "tavern".into(),
label: "Local Tavern".into(),
roles: vec!["farmer".into()],
min_npcs: 2,
max_npcs: 5,
}],
};
let ron_str = ron::ser::to_string_pretty(&spec, ron::ser::PrettyConfig::default()).unwrap();
let deserialized: ZoneSpec = ron::from_str(&ron_str).unwrap();
assert_eq!(deserialized.zone_type, "rural");
assert_eq!(deserialized.roles.len(), 1);
assert_eq!(deserialized.social_sites.len(), 1);
}
#[test]
fn culture_profile_round_trips_through_ron() {
let profile = CultureProfile {
id: "krenn".into(),
name: "Krenn System Culture".into(),
description: "Working-class pragmatic culture".into(),
naming: NamingConventions {
style: "compact, consonant-heavy".into(),
given_names: vec!["Kael".into(), "Voss".into()],
family_names: vec!["Davan".into()],
family_name_used_socially: false,
},
speech: SpeechPatterns {
register: "direct, minimal pleasantries".into(),
filler_words: vec!["look".into(), "right".into()],
greetings: vec!["hey".into()],
farewells: vec!["shift's calling".into()],
exclamations: vec!["void take it".into()],
},
values: CulturalValues {
description: "Pragmatic, community-oriented, suspicious of authority".into(),
favored_traits: vec![PersonalityTrait::Bold, PersonalityTrait::Honest],
disfavored_traits: vec![PersonalityTrait::Reclusive],
},
};
let ron_str =
ron::ser::to_string_pretty(&profile, ron::ser::PrettyConfig::default()).unwrap();
let deserialized: CultureProfile = ron::from_str(&ron_str).unwrap();
assert_eq!(deserialized.id, "krenn");
assert_eq!(deserialized.naming.given_names.len(), 2);
assert_eq!(deserialized.values.favored_traits.len(), 2);
}
#[test]
fn npc_blueprint_round_trips_through_ron() {
let blueprint = NpcBlueprint {
name: "Kael".into(),
role: "dock_worker".into(),
traits: vec![PersonalityTrait::Bold, PersonalityTrait::Honest],
observable_behaviors: vec!["works efficiently".into()],
cultural_markers: CulturalMarkers {
speech_register: "direct".into(),
filler_words: vec!["look".into()],
greeting: "hey".into(),
},
relationships: vec![BlueprintRelationship {
target_name: "Voss".into(),
relationship_type: "colleague".into(),
valence: RelationshipValence::Positive,
}],
};
let ron_str =
ron::ser::to_string_pretty(&blueprint, ron::ser::PrettyConfig::default()).unwrap();
let deserialized: NpcBlueprint = ron::from_str(&ron_str).unwrap();
assert_eq!(deserialized.name, "Kael");
assert_eq!(deserialized.traits.len(), 2);
assert_eq!(deserialized.relationships.len(), 1);
}
#[test]
fn spike_output_round_trips_through_ron() {
let output = SpikeOutput {
zone_type: "rural".into(),
seed: 42,
culture: "krenn".into(),
npcs: vec![],
};
let ron_str =
ron::ser::to_string_pretty(&output, ron::ser::PrettyConfig::default()).unwrap();
let deserialized: SpikeOutput = ron::from_str(&ron_str).unwrap();
assert_eq!(deserialized.zone_type, "rural");
assert_eq!(deserialized.seed, 42);
}
}
+1
View File
@@ -4,6 +4,7 @@
pub mod awareness;
pub mod background;
pub mod blueprint;
pub mod disclosure;
pub mod generate;
pub mod interaction;
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# RON content validator — wrapper for the Rust validate_ron binary (#611).
#
# Usage:
# tooling/validate-ron <file.ron> <zone|culture>
#
# Examples:
# tooling/validate-ron content/global/zone-identity-spec.example.ron zone
# tooling/validate-ron content/global/culture-krenn.example.ron culture
set -euo pipefail
if [ $# -lt 2 ]; then
echo "Usage: tooling/validate-ron <file.ron> <zone|culture>"
echo ""
echo "Validates a RON file against the Rust struct schema."
echo "Schema types:"
echo " zone — ZoneSpec (zone identity spec)"
echo " culture — CultureProfile (culture profile)"
exit 1
fi
# Check file exists before resolving — realpath gives unhelpful errors otherwise
if [ ! -f "$1" ]; then
echo "Error: file not found: $1"
exit 1
fi
FILE="$(realpath "$1")"
SCHEMA="$2"
cd "$(dirname "$0")/../server"
exec cargo run --quiet --bin validate_ron -- "$FILE" "$SCHEMA"