Compare commits

...
9 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
9 changed files with 798 additions and 47 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
+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,
),
],
)
+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
+357 -45
View File
@@ -21,15 +21,18 @@
//! axes (traits, relationships, behaviors, cultural markers) as standalone
//! functions that work without ECS. Full ECS integration is deferred.
use std::collections::{BTreeMap, VecDeque};
use std::path::PathBuf;
use std::process;
use clap::Parser;
use rand::Rng;
use rand::SeedableRng;
use rand_chacha::ChaCha20Rng;
use settled_reach_server::npc::blueprint::{
BlueprintRelationship, CultureProfile, CulturalMarkers, CulturalValues, NamingConventions,
NpcBlueprint, RoleSpec, SocialSiteSpec, SpeechPatterns, SpikeOutput, ZoneSpec,
NpcBlueprint, NpcWant, RoleSpec, SocialSiteSpec, SpeechPatterns, SpikeOutput, ZoneSpec,
};
use settled_reach_server::npc::PersonalityTrait;
use settled_reach_server::simulation::rng::SimRng;
@@ -295,9 +298,7 @@ const ALL_TRAITS: [PersonalityTrait; 10] = [
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.
// TODO(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 {
@@ -359,18 +360,42 @@ fn gen_traits(rng: &mut SimRng, culture: &CultureProfile) -> Vec<PersonalityTrai
}
// ---------------------------------------------------------------------------
// Name generation (without replacement — #3 fix)
// Name generation (without replacement — #628 fix: derived RNG)
// ---------------------------------------------------------------------------
/// Mix `seed`, `zone_type`, and `culture_id` into a name-pool-specific u64
/// using inline FNV-1a (same pattern as TemplateId/TriangleId in D-010).
///
/// This ensures name ordering is a pure function of (seed, zone_type, culture),
/// not of what other operations consumed from the main RNG before the shuffle.
/// Without this, different zone types with the same seed (e.g. rural/42 and
/// industrial/42) produce the same first name because both consume exactly one
/// main-RNG word before reaching the Fisher-Yates step.
fn name_pool_seed(seed: u64, zone_type: &str, culture_id: &str) -> u64 {
let mut h: u64 = seed ^ 0xcbf29ce484222325; // FNV-1a offset basis, XOR'd with seed
for b in zone_type.bytes().chain(std::iter::once(b':')).chain(culture_id.bytes()) {
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3); // FNV-1a prime
}
h
}
/// 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.
/// Uses a derived RNG (seed × zone_type × culture_id) for the Fisher-Yates
/// shuffle so name ordering doesn't depend on how many words the main RNG
/// has consumed for other purposes (#628 fix).
///
/// 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> {
/// Exits if `given_names` is empty (validator should catch this upstream).
fn build_name_pool(
seed: u64,
zone_type: &str,
culture: &CultureProfile,
count: usize,
) -> Vec<String> {
if culture.naming.given_names.is_empty() {
eprintln!(
"Error: culture '{}' has no given_names — cannot generate NPCs.",
@@ -382,10 +407,14 @@ fn build_name_pool(rng: &mut SimRng, culture: &CultureProfile, count: usize) ->
let pool_size = culture.naming.given_names.len();
let actual_count = count.min(pool_size);
// Derived RNG — isolated from the main SimRng stream (#628 fix).
let derived_seed = name_pool_seed(seed, zone_type, &culture.id);
let mut name_rng = ChaCha20Rng::seed_from_u64(derived_seed);
// 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);
let swap = name_rng.random_range(i..pool_size);
indices.swap(i, swap);
}
@@ -394,7 +423,7 @@ fn build_name_pool(rng: &mut SimRng, culture: &CultureProfile, count: usize) ->
.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_idx = name_rng.random_range(0..culture.naming.family_names.len());
let family = &culture.naming.family_names[family_idx];
format!("{} {}", given, family)
} else {
@@ -429,40 +458,56 @@ fn pick_role<'a>(rng: &mut SimRng, zone: &'a ZoneSpec) -> &'a RoleSpec {
}
// ---------------------------------------------------------------------------
// Observable behaviors
// Observable behaviors (with zone-level dedup — #629 fix)
// ---------------------------------------------------------------------------
fn gen_behaviors(rng: &mut SimRng, role: &RoleSpec, culture: &CultureProfile) -> Vec<String> {
/// Pre-shuffle behavior pools per role so NPCs within a zone run draw
/// without replacement (#629 fix).
///
/// The pools are consumed as NPCs are generated: each NPC pops the front.
/// When a pool is exhausted (more NPCs of that role than behaviors), `gen_behaviors`
/// falls back to a random repeat with an eprintln warning — not a crash.
fn build_behavior_pools(rng: &mut SimRng, zone: &ZoneSpec) -> BTreeMap<String, VecDeque<String>> {
let mut pools: BTreeMap<String, VecDeque<String>> = BTreeMap::new();
for role in &zone.roles {
let mut behaviors: Vec<String> = role.typical_behaviors.clone();
let n = behaviors.len();
// Fisher-Yates shuffle using the main RNG (deterministic order is zone-seeded).
for i in 0..n {
let j = rng.rng.random_range(i..n);
behaviors.swap(i, j);
}
pools.insert(role.id.clone(), VecDeque::from(behaviors));
}
pools
}
/// Pick 1 role-specific behavior from the pre-shuffled pool (without replacement).
///
/// If the pool is exhausted, falls back to a random pick from typical_behaviors
/// and logs a warning — this is a content signal that the role needs more behavior
/// strings in the zone spec.
fn gen_behaviors(rng: &mut SimRng, role: &RoleSpec, pool: &mut VecDeque<String>) -> Vec<String> {
let mut behaviors: Vec<String> = Vec::new();
// Pick 1 role-specific behavior
if !role.typical_behaviors.is_empty() {
// Draw from pre-shuffled pool without replacement (#629 fix).
// Index 0 is always the primary role action — relationship overrides and
// Want tells may replace or append to this in later passes.
if let Some(behavior) = pool.pop_front() {
behaviors.push(behavior);
} else if !role.typical_behaviors.is_empty() {
// Pool exhausted — allow repeat, warn content authors.
eprintln!(
"Warning: behavior pool for role '{}' exhausted — add more typical_behaviors to avoid repeats.",
role.id
);
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
// ---------------------------------------------------------------------------
@@ -567,23 +612,255 @@ fn generate_npc_blueprint(
zone: &ZoneSpec,
culture: &CultureProfile,
name: String,
behavior_pools: &mut BTreeMap<String, VecDeque<String>>,
) -> NpcBlueprint {
let role = pick_role(rng, zone);
let traits = gen_traits(rng, culture);
let observable_behaviors = gen_behaviors(rng, role, culture);
let want = gen_want(rng, &traits, role);
let pool = behavior_pools
.entry(role.id.clone())
.or_insert_with(VecDeque::new);
let observable_behaviors = gen_behaviors(rng, role, pool);
let cultural_markers = gen_cultural_markers(rng, culture);
// Relationships assigned in a second pass once all names are known.
// Relationships and Want tells assigned in later passes.
NpcBlueprint {
name,
role: role.id.clone(),
traits,
want,
observable_behaviors,
cultural_markers,
relationships: vec![],
}
}
// ---------------------------------------------------------------------------
// Want/State generation (#632)
// ---------------------------------------------------------------------------
/// Generate an internal motive (Want) for an NPC based on traits and role.
///
/// The Want is the NPC's current internal state — not visible to the player,
/// but it leaks through the observable behavior as a tell. Trait combinations
/// bias the distribution: cautious NPCs trend Alert/Suspicious, curious ones
/// trend LookingForInfo, bold ones resist Bored, social ones lean neutral.
/// Combat-eligible roles start with Alert bias.
fn gen_want(rng: &mut SimRng, traits: &[PersonalityTrait], role: &RoleSpec) -> NpcWant {
// Build a weighted pool: each Want gets a base weight, modified by traits.
// Weights: [Neutral, Bored, Alert, Suspicious, AvoidingSomeone, LookingForInfo]
let mut weights: [u32; 6] = [10, 4, 4, 3, 2, 3];
for &t in traits {
use PersonalityTrait::*;
match t {
Cautious => {
weights[2] += 4; // Alert
weights[3] += 3; // Suspicious
}
Bold => {
weights[1] = weights[1].saturating_sub(2); // less Bored
weights[2] += 1; // Alert
}
Curious => {
weights[5] += 4; // LookingForInfo
weights[1] = weights[1].saturating_sub(1); // less Bored
}
Incurious => {
weights[1] += 3; // Bored
}
Social => {
weights[5] += 2; // LookingForInfo
}
Reclusive => {
weights[4] += 3; // AvoidingSomeone
}
Deceptive => {
weights[3] += 2; // Suspicious
weights[5] += 2; // LookingForInfo
}
Honest => {
weights[3] = weights[3].saturating_sub(2); // less Suspicious
}
_ => {}
}
}
// Combat-eligible roles start with higher Alert bias.
if role.combat_eligible {
weights[2] += 3; // Alert
weights[0] = weights[0].saturating_sub(3); // less Neutral
}
let total: u32 = weights.iter().sum();
let roll = rng.rng.random_range(0..total);
let mut cumulative = 0u32;
for (i, &w) in weights.iter().enumerate() {
cumulative += w;
if roll < cumulative {
return match i {
0 => NpcWant::Neutral,
1 => NpcWant::Bored,
2 => NpcWant::Alert,
3 => NpcWant::Suspicious,
4 => NpcWant::AvoidingSomeone,
_ => NpcWant::LookingForInfo,
};
}
}
NpcWant::Neutral
}
/// Generate the observable tell for an NPC's Want — the behavior that leaks
/// the internal state to a perceptive observer.
///
/// Returns `None` for `Neutral` (no tell needed — NPC is just doing their job).
/// For `AvoidingSomeone`, uses the NPC's relationships to name a target if one
/// has Negative valence; falls back to generic phrasing if no such relationship.
///
/// ~70% of non-Neutral NPCs get a tell (seeded-random). The other 30% have a
/// Want that hasn't surfaced yet — invisible until the right moment.
fn gen_want_tell(rng: &mut SimRng, npc: &NpcBlueprint) -> Option<String> {
use NpcWant::*;
if npc.want == Neutral {
return None;
}
// 70% chance to surface the tell in observable behavior.
if rng.rng.random_range(0..10_u32) >= 7 {
return None; // Want is present but hasn't surfaced yet
}
// Find a named target for AvoidingSomeone from the relationship list.
let avoid_target: Option<&str> = npc
.relationships
.iter()
.find(|r| {
use settled_reach_server::npc::blueprint::RelationshipValence;
r.valence == RelationshipValence::Negative
})
.map(|r| r.target_name.as_str());
let tell = match &npc.want {
Neutral => return None,
Bored => {
let options = [
"shifts weight and checks the time without reason",
"drums fingers absently on a nearby surface",
"glances toward the exit more than the task warrants",
"half-watches passing foot traffic instead of working",
];
options[rng.rng.random_range(0..options.len())].to_string()
}
Alert => {
let options = [
"pauses to scan the room before continuing",
"tracks movement near the entrance without turning",
"positions with back to the wall during a natural pause",
"clocks where each person in the space is standing",
];
options[rng.rng.random_range(0..options.len())].to_string()
}
Suspicious => {
let options = [
"watches the room in the glass of a nearby surface",
"lingers near a conversation just long enough to catch a word",
"double-checks something that didn't need checking",
"slows their pace near a group without joining it",
];
options[rng.rng.random_range(0..options.len())].to_string()
}
AvoidingSomeone => match avoid_target {
Some(name) => {
let options = [
format!("routes around {}'s usual area without looking toward it", name),
format!("takes the long way past {}'s position", name),
format!("keeps a surface or cluster of people between them and {}", name),
];
options[rng.rng.random_range(0..options.len())].clone()
}
None => "takes routes that keep them out of direct lines of sight".to_string(),
},
LookingForInfo => {
let options = [
"scans faces as people move through the space",
"drifts near conversations without joining, listening",
"makes eye contact with newcomers longer than social norms allow",
"asks a small question that's really a probe for something larger",
];
options[rng.rng.random_range(0..options.len())].to_string()
}
};
Some(tell)
}
// ---------------------------------------------------------------------------
// Relationship-driven behavior (#631)
// ---------------------------------------------------------------------------
/// Override an NPC's primary behavior with a relationship-revealing action
/// when the NPC has an active relationship (#631).
///
/// This is the "bridge between populated and inhabited" — the behavior line
/// the player reads reflects a real social connection, not just the role's
/// generic action. Rivals won't acknowledge each other; friends drift together;
/// subordinates defer.
///
/// Applied after both the generation pass and the relationship pass so the
/// full relationship list is available. ~50% chance per NPC (seeded), ensuring
/// a mix of relationship-driven and role-driven behaviors in any given run.
fn apply_relationship_behaviors(rng: &mut SimRng, npc: &mut NpcBlueprint) {
use settled_reach_server::npc::blueprint::RelationshipValence;
if npc.relationships.is_empty() {
return;
}
// ~50% chance to express a relationship through behavior.
if rng.rng.random_range(0..2_u32) == 0 {
return;
}
// Pick the first relationship (deterministic ordering from gen_relationships).
let rel = &npc.relationships[0];
let target = &rel.target_name;
let behavior = match (rel.relationship_type.as_str(), rel.valence) {
("rival", RelationshipValence::Negative) => {
format!("talks past {} without making eye contact", target)
}
("rival", _) => format!("keeps {} in peripheral view without approaching", target),
("friend", RelationshipValence::Positive) => {
format!("catches {}'s eye and nods across the room", target)
}
("friend", _) => format!("drifts toward {} between tasks", target),
("subordinate", RelationshipValence::Negative) => {
format!("complies when {} speaks, but doesn't volunteer anything", target)
}
("subordinate", _) => format!("defers to {} before moving on", target),
("superior", RelationshipValence::Negative) => {
format!("checks whether {} is watching before moving on", target)
}
("superior", _) => format!("glances at {} to see if anything is needed", target),
("colleague", RelationshipValence::Positive) => {
format!("exchanges a few quiet words with {}", target)
}
("colleague", RelationshipValence::Negative) => {
format!("works efficiently, keeping clear of {}", target)
}
_ => format!("glances toward {} briefly before continuing", target),
};
// Replace the primary behavior.
if npc.observable_behaviors.is_empty() {
npc.observable_behaviors.push(behavior);
} else {
npc.observable_behaviors[0] = behavior;
}
}
// ---------------------------------------------------------------------------
// Output formatting
// ---------------------------------------------------------------------------
@@ -614,6 +891,17 @@ fn valence_label(v: &settled_reach_server::npc::blueprint::RelationshipValence)
}
}
fn want_label(want: &NpcWant) -> &'static str {
match want {
NpcWant::Neutral => "Neutral",
NpcWant::Bored => "Bored",
NpcWant::Alert => "Alert",
NpcWant::Suspicious => "Suspicious",
NpcWant::AvoidingSomeone => "AvoidingSomeone",
NpcWant::LookingForInfo => "LookingForInfo",
}
}
fn print_output(output: &SpikeOutput) {
println!("=== {} ===", output.zone_type.to_uppercase());
println!("Zone: {}", output.zone_type);
@@ -628,10 +916,15 @@ fn print_output(output: &SpikeOutput) {
println!(" Role: {}", npc.role);
let trait_labels: Vec<&str> = npc.traits.iter().map(|&t| trait_label(t)).collect();
println!(" Traits: [{}]", trait_labels.join(", "));
println!(" State: [{}]", want_label(&npc.want));
if let Some(behavior) = npc.observable_behaviors.first() {
println!(" Behavior: {}", behavior);
}
// Index 1 is the Want tell — the observable leak of the internal state.
if let Some(tell) = npc.observable_behaviors.get(1) {
println!(" Tell: {}", tell);
}
println!(
" Speech: {} | filler: [{}]",
@@ -707,7 +1000,8 @@ fn main() {
process::exit(1);
}
// Seed the RNG
// Seed the main RNG from args.seed directly.
// Name generation uses a separately derived seed (#628 fix) — see build_name_pool.
let mut rng = SimRng::new(args.seed);
// Determine NPC count from zone density.
@@ -716,27 +1010,45 @@ fn main() {
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)
// Pre-generate unique names using a derived RNG (#628 fix: first-pick bias).
// Name ordering is a pure function of (seed, zone_type, culture) — independent
// of how many words the main RNG has consumed for NPC count or other purposes.
let name_pool = build_name_pool(args.seed, &args.zone, &culture, npc_count);
// Pre-shuffle behavior pools per role (#629 fix: dedup within a zone run).
let mut behavior_pools = build_behavior_pools(&mut rng, &zone);
// 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))
.map(|name| {
generate_npc_blueprint(&mut rng, &zone, &culture, name, &mut behavior_pools)
})
.collect();
// Collect all names for relationship pass
// 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)
// Second pass: assign relationships.
for npc in &mut npcs {
let rels = gen_relationships(&mut rng, &npc.name, &all_names);
npc.relationships = rels;
}
// Third pass: apply relationship-driven behavior overrides (#631).
// Must run after relationships are assigned.
for npc in &mut npcs {
apply_relationship_behaviors(&mut rng, npc);
}
// Fourth pass: generate Want tells (#632).
// Must run after relationships are assigned (AvoidingSomeone resolves from relationships).
for npc in &mut npcs {
if let Some(tell) = gen_want_tell(&mut rng, npc) {
npc.observable_behaviors.push(tell);
}
}
let output = SpikeOutput {
zone_type: zone.zone_type.clone(),
seed: args.seed,
+29
View File
@@ -150,6 +150,29 @@ pub struct CulturalValues {
// 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,
@@ -163,7 +186,12 @@ pub struct NpcBlueprint {
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,
@@ -301,6 +329,7 @@ mod tests {
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(),