Compare commits
9
Commits
a54b9d24e0
...
a7b7d3e31a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7b7d3e31a | ||
|
|
c3a8dcc481 | ||
|
|
c12005843b | ||
|
|
10b1a4252e | ||
|
|
4b75ec3495 | ||
|
|
a6c857b174 | ||
|
|
bdad91d3d0 | ||
|
|
95c0da9cf5 | ||
|
|
4b57006a5c |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
),
|
||||
)
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -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)*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user