Compare commits

..
13 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.6 a7b7d3e31a chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:27:12 +01:00
jpmschweitzerandClaude Opus 4.6 c3a8dcc481 feat(simulation): name dedup, behavior dedup, relationship pipeline, Want/State layer (#628 #629 #631 #632)
Four generator spike improvements in one pass:

- #628: Fix name pool first-pick bias. build_name_pool now derives a
  zone+culture-specific ChaCha20 RNG via FNV-1a mixing of (seed,
  zone_type, culture_id), isolating name ordering from main RNG
  consumption. Different zone types with the same seed now produce
  different first names.

- #629: Behavior dedup within a zone run. build_behavior_pools
  pre-shuffles each role's behavior list; gen_behaviors draws without
  replacement. Falls back to random repeat with warning when pool
  exhausts.

- #631: Relationship-to-behavior pipeline. Third generation pass
  (~50% chance) replaces primary behavior with relationship-revealing
  action — rivals talk past each other, friends drift together,
  subordinates defer.

- #632: Want/State layer. NpcWant enum (Neutral/Bored/Alert/Suspicious/
  AvoidingSomeone/LookingForInfo) biased by traits and role. Fourth
  generation pass produces observable tells that leak internal state
  through behavior. AvoidingSomeone resolves against negative-valence
  relationships for named targets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:26:48 +01:00
jpmschweitzer c12005843b Merge remote-tracking branch 'origin/copy' 2026-03-07 09:59:10 +01:00
jpmschweitzerandClaude Opus 4.6 10b1a4252e fix(copy): address PR #88 re-review comments
Name pool fixes:
- Remove "Korr" from given_names (duplicate with family_names), replace with "Tork"
- Replace "Narek" with "Sorek" (real-world Armenian name, IP concern)
- Replace soft "-ael" endings (Vael→Vrek, Rael→Rask) to match naming rules
- Update comment: "no soft endings" → "hard endings preferred"
- Add 2 family names (Tollek, Dass) to balance pool ratio (now 40/18)

Voice fixes:
- Replace "same drill" (not an exclamation) with "cold vacuum"
- Rewrite 3 trader behaviors as observable stage directions
- Add 2 foreman off-duty behaviors (person beneath the role)
- Add break room behaviors to dock_worker, technician, foreman

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:58:24 +01:00
jpmschweitzerandClaude Opus 4.6 4b75ec3495 fix(copy): address PR #88 review comments
- Remove "Narek" from family_names (duplicate with given_names), replace with "Morek"
- Rewrite rural farmer behaviors to be location-agnostic (no sky/weather assumptions)
- Add heritage root overlay comments (D-104/D-105) to both zone specs
- Add insert/lattice tech behavior to industrial technician role
- Rewrite 2 security behaviors with Krenn cultural texture (D-121)
- Replace generic exclamations with Krenn-specific oaths ("void's sake", "same drill")
- Replace soft greeting "you okay?" with "all in one piece?"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:45:25 +01:00
jpmschweitzerandClaude Opus 4.6 a6c857b174 chore(skills): add sprint retrospective to sprint-start lifecycle
Insert A1b retrospective step between sprint close and version bump.
Covers: what shipped, what didn't, what we learned, process notes.
Process improvements are optional — only proposed when something was
actually broken.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:45:21 +01:00
jpmschweitzerandClaude Opus 4.6 bdad91d3d0 docs(decisions): add Q-056 zone spec location_context field
Zone specs need a location_context field (surface/station/vessel) so
the generator can filter environment-specific behaviors. Raised during
PR #88 review — rural zone had sky/weather references that only make
sense on a planet surface.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:45:14 +01:00
jpmschweitzerandClaude Opus 4.6 95c0da9cf5 feat(copy): add zone identity specs and Krenn culture profile (#609, #610)
Zone identity specs for the generator spike proof-of-life:
- rural-zone-spec.ron: 4 roles, 3 social sites, density 2, economic 3
- industrial-zone-spec.ron: 4 roles, 3 social sites, density 6, economic 7

Krenn culture profile:
- culture-krenn.ron: 40 given names, 16 family names, speech patterns,
  cultural values (Bold/Honest/Curious/Social favored)

All three files validate against the Rust structs from #611 and produce
visibly differentiated output from the generator spike.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:27:16 +01:00
jpmschweitzerandClaude Opus 4.6 4b57006a5c chore(simulation): remove duplicate TODO and dead binding in generator spike
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 08:48:20 +01:00
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
18 changed files with 2198 additions and 25 deletions
+42
View File
@@ -87,6 +87,48 @@ tooling/db/sprint stop
This marks the active sprint as completed and lists carry-over candidates.
Note the sprint number (N) from the output.
#### A1b. Sprint retrospective and review
Before bumping the version, run a brief retro. Present the following to
the user:
1. **What shipped** — list completed tickets with one-line summaries
2. **What didn't ship** — carry-overs and why (blocked, cut, deprioritized)
3. **What we learned** — open questions raised during the sprint (new Q-NNN
items), review findings that surfaced design gaps, and any assumptions
that turned out to be wrong
4. **Process notes** — what worked well, what was friction (e.g. dependency
chains that blocked teams, specs that were over/under-specified,
review cycles that caught real issues vs busywork)
5. **Process improvements** — this is the most important section. Do NOT
skip it. Look for:
- Dependency chains that blocked teams — could the sprint have been
structured differently to avoid the bottleneck?
- Specs that were over-specified (wasted planning) or under-specified
(wasted iteration) — what's the right level of detail for this
project's current stage?
- Review cycles — did they catch real issues or create busywork?
- Agent coordination — were agents stuck, duplicating work, or idle?
- **Dig into the deeper why.** Don't stop at "the dependency chain
blocked the copy team." Ask: why was there a dependency chain? Was
the sprint structured wrong, or was the work inherently sequential?
Could Phase 0 have been done pre-sprint? Should we change how we
plan sprints going forward?
- If something went rough, understand the root cause — not just what
happened, but why the process allowed it to happen.
- If a concrete process change follows naturally, propose it. But do
NOT force improvements. If nothing was broken, say so and move on.
Unnecessary process changes are worse than no changes.
Keep each section concise — a few bullet points, not a document. The
retro is a conversation checkpoint, not a report. Use `AskUserQuestion`
to let the user add their own observations and push back before proceeding.
If the user raises items that should be tracked, create Q-NNN entries
or backlog tickets on the spot. If process changes are agreed, update
the relevant skill files or CLAUDE.md immediately — don't defer them.
#### A2. Bump the version
The project version scheme is `v0.1.{sprint_number}`. After closing
+8
View File
@@ -6,6 +6,14 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Fixed
- Name pool first-pick bias — generator spike produced "Dav" as NPC 1 across all seeds; now uses derived RNG per zone+culture (#628)
- Behavior dedup — same behavior string no longer assigned to multiple NPCs in one zone run (#629)
### Added
- Relationship-to-behavior pipeline — NPC behavior lines now reflect social connections (rivals ignore each other, friends gravitate, subordinates defer) (#631)
- Want/State layer — NPCs have internal motives (Bored, Alert, Suspicious, AvoidingSomeone, LookingForInfo) that leak through observable micro-tells (#632)
## [v0.1.24] — 2026-03-06
### Changed
+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],
),
)
+103
View File
@@ -0,0 +1,103 @@
// Krenn Culture Profile
//
// Schema: server/src/npc/blueprint.rs :: CultureProfile
// Ticket: #610 (copy team)
// Validate: tooling/validate-ron content/global/culture-krenn.ron culture
//
// Sources: D-036 (Sova Transit District / Krenn System setting, amended post-workshop),
// D-121 (voice is culture-driven, job as modifier),
// D-128 (culture implicit in starting location).
//
// Krenn: ~180 years settled. Mid-Reach G3V system. Working-class pragmatic.
// Community-oriented, suspicious of distant authority. Competence earns respect.
// Showing up and doing the work matters more than rank or credentials.
// First-name-primary in social contexts — family names are institutional.
(
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. Atmosphere: quotidian-with-undertow — comfortable enough to be complacent, tight enough that extra income is tempting.",
naming: (
style: "compact, consonant-heavy, first-name-primary in social contexts",
// Pool the generator draws from. More names = more variety across runs.
// Source: D-036 canonical examples + extended set following same phoneme rules.
// Rule: compact (1-2 syllables), consonant clusters welcome, hard endings preferred.
given_names: [
// D-036 canonical set
"Kael", "Voss", "Lera", "Torek", "Drin",
"Maret", "Naia", "Sera", "Nils", "Pael",
"Tev", "Ren", "Sess", "Renn", "Olin",
"Tav", "Resha", "Harek", "Sabel", "Pell",
// Extended — same phoneme pattern
"Dav", "Tork", "Ness", "Pren", "Vel",
"Orin", "Mael", "Torra", "Sorek", "Sev",
"Bren", "Linn", "Vrek", "Darek", "Pess",
"Nell", "Kren", "Sorel", "Tavek", "Rask",
],
family_names: [
// D-036 canonical + extended
"Davan", "Sessik", "Korr", "Tamm", "Venn",
"Lintar", "Darvo", "Kosse",
// Extended
"Pellan", "Sorren", "Tessik", "Morek", "Brav",
"Ossel", "Rennick", "Harven", "Tollek", "Dass",
],
// Krenn culture is first-name-primary.
// Family names exist but belong to institutional contexts: contracts, registrations, arrest records.
family_name_used_socially: false,
),
speech: (
// Direct. Minimal pleasantries. Gets to the point — not because they're rude,
// but because time is real and everyone's short of it.
register: "direct, minimal pleasantries, gets to the point",
filler_words: [
"look",
"right",
"yeah",
"so",
"listen",
"well",
],
greetings: [
"hey",
"morning",
"shift treating you alright?",
"all good?",
"what's the word?",
"all in one piece?",
],
farewells: [
"shift's calling",
"gotta move",
"catch you later",
"take it easy",
"see you around",
"stay out of trouble",
],
// Krenn oaths are void-adjacent — space is real here, and hostile.
// They don't swear by gods or governments. They swear by what kills you.
exclamations: [
"void take it",
"stars",
"blood and void",
"void's sake",
"damn all",
"cold vacuum",
],
),
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. Loyalty runs narrow and deep — to your crew, your shift, your street.",
// Traits more common in Krenn culture — generator biases toward these.
// Bold: Krenn people speak their mind. Honest: community trust is load-bearing.
// Curious: 180 years of problem-solving breeds intellectual appetite.
// Social: community-oriented culture; isolation is a warning sign.
favored_traits: [Bold, Honest, Curious, Social],
// Traits less common — generator biases away from these.
// Reclusive: red flag in a community that depends on showing up.
// Deceptive: betrayal of trust is the worst thing you can do here.
disfavored_traits: [Reclusive, Deceptive],
),
)
+124
View File
@@ -0,0 +1,124 @@
// Zone Identity Spec — Industrial
//
// Schema: server/src/npc/blueprint.rs :: ZoneSpec
// Ticket: #609 (copy team)
// Validate: tooling/validate-ron content/global/industrial-zone-spec.ron zone
//
// Character: freight handling, manufacturing, maintenance. High throughput.
// Shift rhythms. Functional over comfortable. Nobody lingers — unless they're on break.
//
// Generator contrast target: industrial vs rural (Sprint 25 proof).
// If these produce NPCs that look the same, something is wrong.
//
// Heritage roots (D-104/D-105) overlay these base specs at generation time,
// modifying role weights, social site composition, and behavioral flavoring.
(
zone_type: "industrial",
label: "Industrial Zone",
description: "Freight handling, manufacturing, and maintenance. High throughput, shift-based work rhythms, functional over comfortable. Faces are known by role and bay number more than name. The work doesn't stop between shifts — people do.",
// 1-10. Industrial Krenn: steady credit flow, but it all goes somewhere.
economic_level: 7,
// 1-10. Dense. Multiple shifts overlap. Crowds at handover.
population_density: 6,
roles: [
(
id: "dock_worker",
label: "Dock Worker",
// Most common. The zone runs on their backs.
weight: 5,
skill_focus: ["technical"],
combat_eligible: false,
typical_behaviors: [
"guides a freight container into position with hand signals",
"checks a manifest against a handheld scanner, lips moving",
"waits at the loading bay apron with arms crossed, watching the clock",
"calls bay numbers to a colleague across the noise of the floor",
"hooks a cargo sling and steps clear before signaling the lift",
"stacks empty pallets against a wall with mechanical efficiency",
"wipes sweat from her face with a forearm and keeps moving",
"slumps into a break room chair and stares at nothing for a full minute before reaching for a drink",
],
),
(
id: "technician",
label: "Systems Technician",
// Keeps the infrastructure running. Never enough of them.
weight: 4,
skill_focus: ["technical"],
combat_eligible: false,
typical_behaviors: [
"runs a diagnostic routine on a floor console, eyes on the readout",
"traces a conduit run along the ceiling with a handheld light",
"swaps a panel module with practiced speed and no wasted motion",
"logs a fault code into a datapad before moving on",
"presses an ear to a vibrating duct and listens",
"argues quietly with a display that isn't giving the right numbers",
"queries a fault log through her insert without touching the terminal, eyes briefly unfocused",
"stretches both arms overhead in the break room doorway, blocking it without noticing",
],
),
(
id: "foreman",
label: "Shift Foreman",
// Fewer of them. High visibility. Everyone knows where they are.
weight: 2,
skill_focus: ["observation", "persuasion"],
combat_eligible: false,
typical_behaviors: [
"walks the floor with a datapad under one arm and says nothing",
"pulls a worker aside for a quiet word near the far wall",
"marks a line off a production board and moves to the next one",
"stands at the mezzanine rail watching throughput without expression",
"reviews shift handover notes and circles something with a stylus",
"speaks to a dock crew in a low voice — they listen without nodding",
"sits alone in the break room rubbing the back of her neck, datapad face-down on the table",
"laughs at something a technician says, then catches herself and goes quiet",
],
),
(
id: "security",
label: "Facility Security",
// Present, watchful. Not looking for trouble — cataloguing it.
weight: 2,
skill_focus: ["combat", "observation"],
combat_eligible: true,
typical_behaviors: [
"sweeps the access corridor on a timed circuit, same path each time",
"waves a regular through the checkpoint on sight — scans the one behind them out of procedure",
"leans at the restricted bay entrance, arms folded — been watching this corridor since the shift started",
"notes something in a shift log without reacting to it outwardly",
"watches a handover between crews from across the loading floor",
],
),
],
social_sites: [
(
site_type: "break_room",
label: "Worker Break Room",
// Between shifts. Decompression. People stop performing for a moment.
roles: ["dock_worker", "technician", "foreman"],
min_npcs: 2,
max_npcs: 5,
),
(
site_type: "maintenance_bay",
label: "Maintenance Bay",
// Active work site. Technicians and dock workers cross paths here.
roles: ["technician", "dock_worker"],
min_npcs: 2,
max_npcs: 4,
),
(
site_type: "loading_platform",
label: "Loading Platform",
// The operational center. Busy, loud, coordinated.
roles: ["dock_worker", "foreman", "security"],
min_npcs: 3,
max_npcs: 8,
),
],
)
+119
View File
@@ -0,0 +1,119 @@
// Zone Identity Spec — Rural
//
// Schema: server/src/npc/blueprint.rs :: ZoneSpec
// Ticket: #609 (copy team)
// Validate: tooling/validate-ron content/global/rural-zone-spec.ron zone
//
// Character: scattered homesteads and small workshops. Unhurried. Community-bound.
// Low throughput, high familiarity. Everyone knows who belongs here.
//
// Generator contrast target: rural vs industrial (Sprint 25 proof).
// If these produce NPCs that look the same, something is wrong.
//
// Heritage roots (D-104/D-105) overlay these base specs at generation time,
// modifying role weights, social site composition, and behavioral flavoring.
(
zone_type: "rural",
label: "Rural Settlement",
description: "Scattered homesteads, small workshops, and communal gathering points. Low population density, strong community bonds, subsistence-plus economy. Work is seasonal and visible — everyone knows what everyone else is doing.",
// 1-10. Rural Krenn: self-sufficient, low cash flow, barter supplements credits.
economic_level: 3,
// 1-10. Sparse. Faces are familiar. Strangers stand out.
population_density: 2,
roles: [
(
id: "farmer",
label: "Farmer",
// Most common. The settlement runs on food production.
weight: 5,
skill_focus: ["technical"],
combat_eligible: false,
typical_behaviors: [
"tends rows of low-growing crops with a long-handled hoe",
"lifts a crate of produce onto a flatbed with practiced ease",
"checks the section's light cycle timer before deciding whether to water",
"patches a cracked irrigation pipe with strips of bonding tape",
"calls across a field to a neighbor without looking up from work",
"hauls produce to the market stall before the morning exchange opens",
"checks seedling trays in a low prefab greenhouse",
],
),
(
id: "mechanic",
label: "Settlement Mechanic",
// One or two per settlement. Everyone knows who to call.
weight: 3,
skill_focus: ["technical"],
combat_eligible: false,
typical_behaviors: [
"pulls a drive unit from a tiller and examines the worn housing",
"wipes grease on the thigh of her coveralls between jobs",
"explains a repair in clipped shorthand without looking up",
"lines up salvaged parts on a workbench and assesses them",
"welds a seam on a cracked water tank with slow, careful strokes",
"borrows a tool from a neighbor and returns it without being asked",
],
),
(
id: "trader",
label: "Itinerant Trader",
// Passes through. Outsider-familiar — not local, not stranger.
weight: 2,
skill_focus: ["persuasion", "observation"],
combat_eligible: false,
typical_behaviors: [
"squares goods on a fold-out portable display with deliberate care",
"leans back on a stool and watches the foot traffic",
"flicks credit chits across the counter with one thumb, barely glancing down",
"holds eye contact through a long pause, waiting for the price to land",
"watches a regular browse the same shelf as last time and says nothing",
"packs unsold goods with no visible frustration",
],
),
(
id: "militia",
label: "Settlement Militia",
// Rare. Part-time. Knows everyone, trusted because of it.
weight: 1,
skill_focus: ["combat", "observation"],
combat_eligible: true,
typical_behaviors: [
"walks the fence line at a measured, unhurried pace",
"leans on the gate post with rifle slung, watching the road",
"waves a familiar face through without checking credentials",
"sits in the shade of the gatehouse with a local newsline",
"stops to talk with a passing farmer, eyes still scanning the perimeter",
],
),
],
social_sites: [
(
site_type: "tavern",
label: "Local Tavern",
// The Last Shift equivalent for rural Krenn: functional, familiar, slow.
roles: ["farmer", "mechanic", "trader", "militia"],
min_npcs: 3,
max_npcs: 6,
),
(
site_type: "workshop",
label: "Community Workshop",
// Shared space. People fix things together.
roles: ["mechanic", "farmer"],
min_npcs: 2,
max_npcs: 4,
),
(
site_type: "market_stall",
label: "Settlement Market",
// Weekly or daily trading post. Commerce and gossip combined.
roles: ["trader", "farmer"],
min_npcs: 1,
max_npcs: 3,
),
],
)
@@ -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,
),
],
)
+15 -1
View File
@@ -176,4 +176,18 @@ Narrative, NPCs, dialogue, templates, setting, worldbuilding, and storyteller me
---
*19 questions (5 resolved, 2 partially resolved, 12 open). Last updated: 2026-03-05 (Q-033 partially resolved/reframed — Where's the Fun? Workshop)*
---
### Q-056: Zone spec needs location_context field (surface/station/vessel)
- **Status:** Open
- **Raised:** Sprint 25 PR #88 review (Miri)
- **Context:** Rural zone spec behaviors reference sky, weather, and diurnal heat — only valid on a planetary surface, not inside a station. The current `ZoneSpec` struct has no field for environment context. Without it, the generator can't distinguish surface-rural from station-rural, and behavior strings may be incoherent for the location.
- **Question:** Should `ZoneSpec` include a `location_context` enum (Surface/Station/Vessel) that the generator uses to filter or modify environment-specific behaviors? Or should zone specs be authored per-context (e.g. `rural-surface.ron`, `rural-station.ron`)?
- **Implications:** Affects all zone spec authoring going forward. The generator's ability to extrapolate from minimal input depends on knowing whether "rural" means open sky or sealed corridors.
- **Cross-reference:** D-012 (chunk-based map), D-036 (Krenn/Sova setting), D-104/D-105 (heritage roots), #609 (zone identity spec)
- **Assigned to:** Tyre, Miri
---
*20 questions (5 resolved, 2 partially resolved, 13 open). Last updated: 2026-03-07 (Q-056 added — Sprint 25 review)*
+1 -1
View File
@@ -8,7 +8,7 @@ Tracked questions awaiting discussion or resolution. Split by domain, mirroring
|------|--------|-----------|
| [questions-architecture.md](questions-architecture.md) | Technical foundation | Q-001, Q-006, Q-009, Q-018, Q-019, Q-020, Q-021, Q-022, Q-023, Q-029, Q-030, Q-046 |
| [questions-perception.md](questions-perception.md) | Player observation | Q-003, Q-014, Q-016, Q-024, Q-025, Q-026, Q-051, Q-053, Q-054 |
| [questions-content.md](questions-content.md) | Narrative, NPCs, setting | Q-010, Q-012, Q-013, Q-015, Q-017, Q-028, Q-031, Q-033, Q-040, Q-041, Q-042, Q-043, Q-044, Q-045, Q-047, Q-048, Q-049, Q-050, Q-052 |
| [questions-content.md](questions-content.md) | Narrative, NPCs, setting | Q-010, Q-012, Q-013, Q-015, Q-017, Q-028, Q-031, Q-033, Q-040, Q-041, Q-042, Q-043, Q-044, Q-045, Q-047, Q-048, Q-049, Q-050, Q-052, Q-056 |
| [questions-scope.md](questions-scope.md) | Game concept, prototype | Q-002, Q-004, Q-005, Q-007, Q-008, Q-011, Q-027, Q-032, Q-034, Q-035, Q-036, Q-037, Q-038, Q-039 |
## Status Summary
+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"
File diff suppressed because it is too large Load Diff
+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);
}
}
}
+369
View File
@@ -0,0 +1,369 @@
//! 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)
// ---------------------------------------------------------------------------
/// Internal motive/state driving an NPC's micro-behavior (#632).
///
/// The player never sees this directly — it leaks through the observable
/// behavior as a tell. The gap between what an NPC is doing and WHY is
/// the asymmetric information mechanic (D-010 principle 2).
///
/// `Neutral` means no notable motive — the NPC is simply doing their job.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum NpcWant {
/// Nothing stands out; routine work mode.
Neutral,
/// Disengaged from current task; attention elsewhere.
Bored,
/// Heightened attention to the environment; scanning, tracking.
Alert,
/// Something is wrong or off; watching without being obvious about it.
Suspicious,
/// Steering clear of a specific person in the zone.
AvoidingSomeone,
/// Actively trying to pick up information by proximity or conversation.
LookingForInfo,
}
/// 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>,
/// Internal motive that biases observable behavior (#632).
/// The player never sees this label — they infer it from the behavior.
pub want: NpcWant,
/// Observable behaviors the player can witness.
/// Index 0 is the primary role/relationship-driven action.
/// Index 1 (if present) is the Want tell — the leak of the internal state.
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],
want: NpcWant::Alert,
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"