docs(architecture): add seed configuration schema design (#394)
Design document defining what the randomizer produces at game-start: FRIEND selections, pool draws, template assignments, triangle config, entanglement config, contraband selection, starting knowledge. Includes ChaCha20 RNG protocol, validation rules, and implementation roadmap. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,815 @@
|
||||
# Seed Configuration Schema — Design Document
|
||||
|
||||
**Ticket:** #394
|
||||
**Author:** Tyre (Technical Architect)
|
||||
**Status:** Draft
|
||||
**Date:** 2026-02-13
|
||||
**Decisions referenced:** D-010, D-024, D-025, D-027, D-029, D-034, D-035, D-036, D-037, D-041
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
The seed configuration is the **handoff contract** between authored content (pools, templates, triangles in `content/`) and a running game instance (ECS entities in memory). It answers one question: *given this content and this seed value, what specific world do we instantiate?*
|
||||
|
||||
At game start, the seeder reads content definitions and a seed value, then produces a `SeedConfig` — a deterministic, serializable record of every randomized selection. This record is saved with the game state and replayed identically on reload.
|
||||
|
||||
**What the seed config is NOT:**
|
||||
- Not a content authoring format (content authors write pools/templates/triangles — the seed config *consumes* them)
|
||||
- Not a runtime state snapshot (that's the ECS world — the seed config is the *recipe* that built it)
|
||||
- Not a save file (the save file *contains* the seed config alongside mutable game state)
|
||||
|
||||
## 2. Design Constraints
|
||||
|
||||
| Constraint | Source | Impact |
|
||||
|-----------|--------|--------|
|
||||
| Deterministic reproduction | D-010 principle 4, D-030 #7 | Same seed + same content version = identical `SeedConfig`. No HashMap iteration, no platform-dependent RNG. |
|
||||
| BTreeMap for ordered collections | D-041 | All maps in the seed config use BTreeMap, not HashMap. |
|
||||
| 30/50/20 entanglement ratio (variable) | D-029 | ~30% flat, ~50% mundane triangles, ~20% intrigue-entangled. Ratios vary per seed to prevent metagaming. |
|
||||
| Single-candidate pools in v0.1 | Sprint briefing | Architecture supports N candidates; v0.1 pools contain exactly 1 candidate each. |
|
||||
| Template instantiation via role slots | D-025 | Social sites define roles; the seed assigns NPCs to roles. Single ownership with reference links. |
|
||||
| Two playable characters | D-027 | Smuggler + detective. Seed config records which character the player selected. |
|
||||
| Saved with game state | Ticket #394 | Serialized into save files. Must be self-contained (no external content references that could drift). |
|
||||
| Content version pinning | Implicit | Seed config records content version to detect content/save incompatibility. |
|
||||
|
||||
## 3. Schema Overview
|
||||
|
||||
```
|
||||
SeedConfig
|
||||
├── meta
|
||||
│ ├── seed: u64
|
||||
│ ├── content_version: String
|
||||
│ ├── schema_version: u32
|
||||
│ └── generated_at: String (ISO 8601)
|
||||
├── character_selection: CharacterSelection
|
||||
├── pool_draws: BTreeMap<PoolId, PoolDraw>
|
||||
├── template_assignments: BTreeMap<TemplateId, TemplateAssignment>
|
||||
├── triangle_config: TriangleConfig
|
||||
├── entanglement: EntanglementConfig
|
||||
├── contraband: ContrabandSelection
|
||||
└── starting_knowledge: BTreeMap<CharacterId, Vec<KnowledgeEntry>>
|
||||
```
|
||||
|
||||
## 4. Schema Detail
|
||||
|
||||
### 4.1 Meta
|
||||
|
||||
```rust
|
||||
struct SeedMeta {
|
||||
/// The seed value. u64 for sufficient randomness space.
|
||||
/// v0.1: displayed nowhere; v0.2+: player can enter a seed for shared runs.
|
||||
seed: u64,
|
||||
|
||||
/// Content version string from content/content.yaml.
|
||||
/// If the save's content_version doesn't match the loaded content,
|
||||
/// the loader warns or refuses to load (prevents desync).
|
||||
content_version: String,
|
||||
|
||||
/// Schema version for forward compatibility. Increment on breaking changes.
|
||||
schema_version: u32,
|
||||
|
||||
/// ISO 8601 timestamp of generation (informational, not used in logic).
|
||||
generated_at: String,
|
||||
}
|
||||
```
|
||||
|
||||
**Rationale:** `seed` is the root of determinism — every randomized decision traces back to this value through a deterministic RNG (see section 6). `content_version` pins the content snapshot to prevent save/content drift.
|
||||
|
||||
### 4.2 Character Selection
|
||||
|
||||
```rust
|
||||
struct CharacterSelection {
|
||||
/// Which character the player chose. Determines starting knowledge,
|
||||
/// access tiers, monologue pools, perception modes.
|
||||
player_character: CharacterId,
|
||||
|
||||
/// All available characters for this campaign (for reference/validation).
|
||||
available_characters: Vec<CharacterDefinition>,
|
||||
}
|
||||
|
||||
/// CharacterId is a string enum matching content definitions.
|
||||
/// v0.1: "smuggler" | "detective"
|
||||
type CharacterId = String;
|
||||
|
||||
struct CharacterDefinition {
|
||||
id: CharacterId,
|
||||
display_name: String,
|
||||
/// Starting social site (determines spawn location)
|
||||
home_template: TemplateId,
|
||||
/// Starting access tiers for NPC interactions
|
||||
default_access: Vec<AccessTier>,
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Character selection is the one non-deterministic input — the player chooses. Everything else flows from `seed` + `player_character`.
|
||||
|
||||
### 4.3 Pool Draws
|
||||
|
||||
Pools are the core randomization mechanism. Each pool defines N candidates for a role; the seeder draws one.
|
||||
|
||||
```rust
|
||||
/// Pool identifier matching content pool definitions.
|
||||
/// Format: "{scope}:{pool_name}" — e.g., "transit:friend_smuggler"
|
||||
type PoolId = String;
|
||||
|
||||
struct PoolDraw {
|
||||
/// Which pool this draw came from
|
||||
pool_id: PoolId,
|
||||
|
||||
/// The selected candidate's NPC canonical ID
|
||||
selected: NpcId,
|
||||
|
||||
/// All candidates that were available (for debugging/replay verification)
|
||||
candidates: Vec<NpcId>,
|
||||
|
||||
/// Index into candidates that was selected (for replay verification)
|
||||
selected_index: usize,
|
||||
}
|
||||
```
|
||||
|
||||
**v0.1 pools (single-candidate each):**
|
||||
|
||||
| Pool ID | Selected | Purpose |
|
||||
|---------|----------|---------|
|
||||
| `transit:friend_smuggler` | `npc:kael-davan` | Smuggler's FRIEND (D-034) |
|
||||
| `transit:friend_detective` | `npc:sera-venn` | Detective's FRIEND (D-034) |
|
||||
| `transit:bar_regulars` | (set of NPCs) | Bar regular population |
|
||||
| `transit:compromised_inspector` | `npc:torek-lintar` | The compromised Commission inspector |
|
||||
| `transit:primary_contraband` | `contraband:lattice-components` | What's being smuggled |
|
||||
|
||||
**v0.2+ expansion:** Pools grow to N candidates. `friend_smuggler` might offer 3 dock workers who could each be the FRIEND, with different contradiction arcs. The seeder draws one. Same schema, more candidates.
|
||||
|
||||
**Pool categories:**
|
||||
|
||||
```rust
|
||||
enum PoolCategory {
|
||||
/// Selects one NPC for a named narrative role
|
||||
NpcRole,
|
||||
/// Selects a set of NPCs for a group (bar regulars, shift workers)
|
||||
NpcGroup,
|
||||
/// Selects a contraband type
|
||||
Contraband,
|
||||
/// Selects an entanglement pattern (which NPCs are intrigue-connected)
|
||||
EntanglementPattern,
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 Template Assignments
|
||||
|
||||
Templates (D-025 social sites) define role slots; the seeder fills them with NPCs.
|
||||
|
||||
```rust
|
||||
/// Template identifier matching content template definitions.
|
||||
/// Format: location slug — e.g., "logistics-hub", "bar", "smuggling-ring"
|
||||
type TemplateId = String;
|
||||
|
||||
struct TemplateAssignment {
|
||||
template_id: TemplateId,
|
||||
|
||||
/// Which location(s) this template is instantiated in
|
||||
locations: Vec<LocationId>,
|
||||
|
||||
/// Role slot → NPC assignments
|
||||
role_assignments: BTreeMap<RoleSlotId, RoleAssignment>,
|
||||
}
|
||||
|
||||
/// Role slot identifier from template definition.
|
||||
/// Format: "{template}:{role}" — e.g., "logistics-hub:shift-supervisor"
|
||||
type RoleSlotId = String;
|
||||
|
||||
struct RoleAssignment {
|
||||
/// The NPC assigned to this role slot
|
||||
npc_id: NpcId,
|
||||
|
||||
/// Whether this NPC is the primary owner of this template (D-025 single ownership)
|
||||
is_owner: bool,
|
||||
|
||||
/// If not owner, this is a reference link with relationship metadata
|
||||
reference_metadata: Option<ReferenceLink>,
|
||||
}
|
||||
|
||||
struct ReferenceLink {
|
||||
/// The template that owns this NPC
|
||||
owning_template: TemplateId,
|
||||
|
||||
/// Why this NPC appears in this template (relationship context)
|
||||
relationship: String,
|
||||
|
||||
/// How many time phases this NPC spends at this template's location
|
||||
presence_phases: Vec<DayPhase>,
|
||||
}
|
||||
```
|
||||
|
||||
**Example — v0.1 Sova Transit District:**
|
||||
|
||||
```yaml
|
||||
# Logistics Hub template
|
||||
template: logistics-hub
|
||||
locations: [the-terminal]
|
||||
roles:
|
||||
shift-supervisor:
|
||||
npc: npc:voss
|
||||
is_owner: true
|
||||
dock-worker-1:
|
||||
npc: npc:kael-davan
|
||||
is_owner: true
|
||||
dock-worker-2:
|
||||
npc: npc:drin
|
||||
is_owner: true
|
||||
new-hire:
|
||||
npc: npc:renn
|
||||
is_owner: true
|
||||
scheduler:
|
||||
npc: npc:maret-korr
|
||||
is_owner: true
|
||||
courier:
|
||||
npc: npc:harek
|
||||
is_owner: true
|
||||
|
||||
# Bar template
|
||||
template: bar
|
||||
locations: [the-last-shift]
|
||||
roles:
|
||||
bar-owner:
|
||||
npc: npc:lera-sessik
|
||||
is_owner: true
|
||||
bartender:
|
||||
npc: npc:pell
|
||||
is_owner: true
|
||||
bar-regular-1:
|
||||
npc: npc:sera-venn
|
||||
is_owner: false
|
||||
reference:
|
||||
owning_template: null # Sera owns herself (Commission field tech, not bar staff)
|
||||
relationship: "social anchor — evening regular"
|
||||
presence_phases: [evening]
|
||||
bar-regular-2:
|
||||
npc: npc:resha
|
||||
is_owner: true
|
||||
|
||||
# Smuggling ring template
|
||||
template: smuggling-ring
|
||||
locations: [maintenance-corridors]
|
||||
roles:
|
||||
ring-operative:
|
||||
npc: npc:devra
|
||||
is_owner: true
|
||||
contact:
|
||||
npc: npc:kael-davan
|
||||
is_owner: false
|
||||
reference:
|
||||
owning_template: logistics-hub
|
||||
relationship: "ring member — dual role"
|
||||
presence_phases: [night]
|
||||
```
|
||||
|
||||
### 4.5 Triangle Configuration
|
||||
|
||||
```rust
|
||||
struct TriangleConfig {
|
||||
/// All triangles instantiated in this seed
|
||||
triangles: BTreeMap<TriangleId, TriangleInstance>,
|
||||
|
||||
/// Which triangles are initially active (storyteller can activate others later)
|
||||
initially_active: Vec<TriangleId>,
|
||||
}
|
||||
|
||||
/// Triangle identifier matching content triangle definitions.
|
||||
type TriangleId = String;
|
||||
|
||||
struct TriangleInstance {
|
||||
triangle_id: TriangleId,
|
||||
|
||||
/// The 3 NPCs assigned to this triangle's member slots.
|
||||
/// Maps triangle role → NPC ID.
|
||||
members: BTreeMap<String, NpcId>,
|
||||
|
||||
/// Initial fork state. In v0.1: all triangles start at their default state.
|
||||
/// In v0.2+: seed can randomize starting fork positions for variety.
|
||||
initial_fork_state: Option<String>,
|
||||
|
||||
/// Whether this triangle is "active" (storyteller managing) or "passive" (running on its own)
|
||||
activation_mode: TriangleActivationMode,
|
||||
}
|
||||
|
||||
enum TriangleActivationMode {
|
||||
/// Storyteller actively manages fork progression based on player proximity
|
||||
Active,
|
||||
/// Triangle runs on background simulation, forks resolve without storyteller intervention
|
||||
Passive,
|
||||
}
|
||||
```
|
||||
|
||||
**v0.1 triangle instances:**
|
||||
|
||||
| Triangle | Members | Mode | Notes |
|
||||
|----------|---------|------|-------|
|
||||
| `hub-power` | Voss (authority), Kael (subordinate), Maret (caught-between) | Active | Workplace hierarchy tension |
|
||||
| `worried-knowledge` | Sera (holder), Torek (subject), Naia (protected) | Active | Sera's unreported evidence |
|
||||
| `bar-tensions` | Lera (owner), Resha (regular), Pell (bartender) | Passive | Mundane social friction |
|
||||
| `worried-partner` | Kael (partner), Naia (worried), Devra (cause) | Active | Ring pressure on relationship |
|
||||
| `informant-question` | Torek (inspector), Drin (worker), Olin (bystander) | Passive | Mundane workplace gossip |
|
||||
|
||||
### 4.6 Entanglement Configuration
|
||||
|
||||
```rust
|
||||
struct EntanglementConfig {
|
||||
/// The target ratio for this seed (varies around 30/50/20 per D-029)
|
||||
target_ratio: EntanglementRatio,
|
||||
|
||||
/// The actual ratio achieved after assignment (may differ slightly due to rounding)
|
||||
actual_ratio: EntanglementRatio,
|
||||
|
||||
/// Per-NPC entanglement tier assignment
|
||||
npc_tiers: BTreeMap<NpcId, EntanglementTier>,
|
||||
|
||||
/// Module attachment ratio: known vs stranger NPCs for intrigue connections
|
||||
module_attachment_ratio: ModuleAttachmentRatio,
|
||||
}
|
||||
|
||||
struct EntanglementRatio {
|
||||
/// Percentage of NPCs that are truly flat (routine + greeting only)
|
||||
flat_pct: u8,
|
||||
/// Percentage of NPCs in mundane triangles (no conspiracy connection)
|
||||
mundane_pct: u8,
|
||||
/// Percentage of NPCs entangled with intrigue content
|
||||
entangled_pct: u8,
|
||||
}
|
||||
|
||||
struct ModuleAttachmentRatio {
|
||||
/// Percentage of intrigue-connected NPCs that are known to the player character
|
||||
known_pct: u8,
|
||||
/// Percentage that are strangers
|
||||
stranger_pct: u8,
|
||||
}
|
||||
|
||||
enum EntanglementTier {
|
||||
/// Routine + greeting, no triangle membership, social wallpaper
|
||||
Flat,
|
||||
/// Member of mundane triangle(s), no conspiracy connection
|
||||
Mundane,
|
||||
/// Connected to intrigue content (ring member, compromised, witness, etc.)
|
||||
Entangled,
|
||||
}
|
||||
```
|
||||
|
||||
**v0.1 entanglement breakdown (17 NPCs):**
|
||||
|
||||
| Tier | Count | Pct | NPCs |
|
||||
|------|-------|-----|------|
|
||||
| Flat | 5 | 29% | Renn, Harek, Sess, Sabel, Tav |
|
||||
| Mundane | 9 | 53% | Voss, Lera, Pell, Resha, Maret, Drin, Olin, Naia, Torek* |
|
||||
| Entangled | 3 | 18% | Kael (ring), Devra (ring), Sera (witness) |
|
||||
|
||||
*Torek straddles mundane/entangled — he's compromised (entangled) but his triangle surface reads as mundane institutional friction. The seed config records him as entangled; the player discovers this through gameplay.*
|
||||
|
||||
**Revised breakdown with Torek entangled:**
|
||||
|
||||
| Tier | Count | Pct | NPCs |
|
||||
|------|-------|-----|------|
|
||||
| Flat | 5 | 29% | Renn, Harek, Sess, Sabel, Tav |
|
||||
| Mundane | 8 | 47% | Voss, Lera, Pell, Resha, Maret, Drin, Olin, Naia |
|
||||
| Entangled | 4 | 24% | Kael, Devra, Sera, Torek |
|
||||
|
||||
This lands at 29/47/24 — within D-029's variable range around 30/50/20.
|
||||
|
||||
### 4.7 Contraband Selection
|
||||
|
||||
```rust
|
||||
struct ContrabandSelection {
|
||||
/// Primary contraband type for this seed
|
||||
primary: ContrabandType,
|
||||
|
||||
/// Secondary contraband types available (for variety in future seeds)
|
||||
secondary: Vec<ContrabandType>,
|
||||
}
|
||||
|
||||
struct ContrabandType {
|
||||
/// Identifier matching content/global/knowledge/contraband.yaml
|
||||
id: String,
|
||||
|
||||
/// Display name for content systems (dialogue lines reference this)
|
||||
display_name: String,
|
||||
|
||||
/// What the ring calls it internally (used in insider-access dialogue)
|
||||
ring_codename: String,
|
||||
|
||||
/// Moral valence — affects monologue tone when player discovers it
|
||||
moral_ambiguity: MoralAmbiguity,
|
||||
}
|
||||
|
||||
enum MoralAmbiguity {
|
||||
/// Clearly wrong (weapons, poisons)
|
||||
Clear,
|
||||
/// Morally complex (medical supplies, access tech)
|
||||
Ambiguous,
|
||||
/// Arguably justified (survival supplies, freedom tech)
|
||||
Sympathetic,
|
||||
}
|
||||
```
|
||||
|
||||
**v0.1:** Single contraband type — unlicensed lattice components (D-037). `moral_ambiguity: Ambiguous`. The ring is smuggling *access*, not weapons.
|
||||
|
||||
### 4.8 Starting Knowledge
|
||||
|
||||
```rust
|
||||
/// Per-character starting knowledge state.
|
||||
/// Loaded into KnowledgeGraph components at entity creation time (D-041).
|
||||
struct StartingKnowledge {
|
||||
/// Facts this character knows at game start
|
||||
facts: Vec<StartingFact>,
|
||||
|
||||
/// Entity knowledge at game start (NPCs the character already knows about)
|
||||
entities: Vec<StartingEntityKnowledge>,
|
||||
}
|
||||
|
||||
struct StartingFact {
|
||||
/// Fact ID from content/global/knowledge/*.yaml
|
||||
fact_id: String,
|
||||
/// Starting confidence level
|
||||
confidence: ConfidenceLevel,
|
||||
/// Source of this knowledge
|
||||
source: KnowledgeSource,
|
||||
}
|
||||
|
||||
struct StartingEntityKnowledge {
|
||||
/// NPC stable ID
|
||||
entity_id: NpcId,
|
||||
/// What attributes the character knows about this NPC at start
|
||||
known_attributes: BTreeMap<String, AttributeKnowledge>,
|
||||
/// Starting confidence
|
||||
confidence: ConfidenceLevel,
|
||||
/// Source
|
||||
source: KnowledgeSource,
|
||||
}
|
||||
|
||||
/// Maps to D-041's 4-level hierarchy
|
||||
enum ConfidenceLevel {
|
||||
Suspects,
|
||||
KnowsOf,
|
||||
KnowsDetails,
|
||||
Direct,
|
||||
}
|
||||
|
||||
enum KnowledgeSource {
|
||||
/// Character background — they knew this before game start
|
||||
Background,
|
||||
/// Institutional knowledge — comes with the job
|
||||
Institutional,
|
||||
}
|
||||
```
|
||||
|
||||
**Smuggler starting knowledge:**
|
||||
- Knows colleagues at logistics hub (KnowsOf: Voss, Drin, Renn, Maret, Harek)
|
||||
- Knows FRIEND deeply (KnowsDetails: Kael)
|
||||
- Knows bar regulars casually (Suspects: Lera, Pell)
|
||||
- Knows ring exists, knows Devra (KnowsDetails: Devra, ring operations)
|
||||
- Does NOT know Sera, Torek, or Commission personnel (detective's world)
|
||||
- Knows contraband type (KnowsDetails: lattice components)
|
||||
|
||||
**Detective starting knowledge:**
|
||||
- Knows Commission chain of command (KnowsOf: Torek)
|
||||
- Knows FRIEND (KnowsDetails: Sera)
|
||||
- Knows bar casually (Suspects: Lera — goes there off-duty)
|
||||
- Knows assignment briefing (Suspects: smuggling activity on Sova)
|
||||
- Does NOT know ring members, specific smugglers, or insider logistics operations
|
||||
- Does NOT know contraband type (investigation target)
|
||||
|
||||
## 5. File Format and Location
|
||||
|
||||
### 5.1 Authored content (input to seeder)
|
||||
|
||||
Lives in `content/` under the campaign hierarchy. Relevant files:
|
||||
|
||||
```
|
||||
content/
|
||||
├── global/
|
||||
│ └── knowledge/
|
||||
│ └── contraband.yaml # Contraband type definitions
|
||||
├── campaigns/main/systems/krenn/stations/sova/districts/transit/
|
||||
│ ├── npcs/*.yaml # NPC profiles (candidates for pool draws)
|
||||
│ ├── triangles/*.yaml # Triangle definitions (instantiated by seed)
|
||||
│ ├── locations/*.yaml # Location definitions
|
||||
│ ├── routines/schedules.yaml # NPC daily schedules
|
||||
│ └── pools.yaml # Pool definitions (NEW — ticket #389)
|
||||
```
|
||||
|
||||
### 5.2 Pool definition format (content/...pools.yaml)
|
||||
|
||||
```yaml
|
||||
# Pool definitions for Sova Transit District
|
||||
# Each pool defines N candidates for a named role.
|
||||
# The seeder draws from these pools using the seed value.
|
||||
|
||||
pools:
|
||||
- pool_id: "transit:friend_smuggler"
|
||||
category: npc_role
|
||||
description: "Smuggler's FRIEND — closest colleague, emotional anchor"
|
||||
constraints:
|
||||
- must_be_in_template: "logistics-hub"
|
||||
- must_have_pattern: "FRIEND"
|
||||
- bonded_character: "smuggler"
|
||||
candidates:
|
||||
- npc_id: "npc:kael-davan"
|
||||
weight: 1 # v0.1: only candidate
|
||||
# v0.2+: additional candidates with different contradiction arcs
|
||||
|
||||
- pool_id: "transit:friend_detective"
|
||||
category: npc_role
|
||||
description: "Detective's FRIEND — social anchor, information holder"
|
||||
constraints:
|
||||
- bonded_character: "detective"
|
||||
candidates:
|
||||
- npc_id: "npc:sera-venn"
|
||||
weight: 1
|
||||
|
||||
- pool_id: "transit:compromised_inspector"
|
||||
category: npc_role
|
||||
description: "The Commission inspector compromised by the ring"
|
||||
constraints:
|
||||
- must_have_access: "authority"
|
||||
candidates:
|
||||
- npc_id: "npc:torek-lintar"
|
||||
weight: 1
|
||||
|
||||
- pool_id: "transit:primary_contraband"
|
||||
category: contraband
|
||||
description: "What the ring is smuggling"
|
||||
candidates:
|
||||
- id: "contraband:lattice-components"
|
||||
weight: 1
|
||||
```
|
||||
|
||||
### 5.3 Generated seed config (runtime output)
|
||||
|
||||
**Format:** RON (Rusty Object Notation) for Rust-native deserialization. Mirrors the Rust structs from section 4.
|
||||
|
||||
**Location:** Embedded in save files. Not a standalone file during gameplay — the seeder generates it in memory, the ECS consumes it, and the save system serializes it alongside game state.
|
||||
|
||||
**Debug dump location:** `runtime/debug/seed-config-{seed}.ron` — written only in debug builds or when `--dump-seed` flag is passed. Useful for content authors testing pool behavior.
|
||||
|
||||
### 5.4 Schema file
|
||||
|
||||
A JSON Schema for validating pool definition files goes into `content/_schema/pools.schema.json`. The seed config itself is validated at the Rust type level (serde deserialization), not via JSON Schema.
|
||||
|
||||
## 6. Generation Algorithm
|
||||
|
||||
### 6.1 Seeder pipeline
|
||||
|
||||
```
|
||||
Input: seed: u64, player_character: CharacterId, content: LoadedContent
|
||||
Output: SeedConfig
|
||||
|
||||
1. Initialize deterministic RNG from seed
|
||||
└── Use `rand_chacha::ChaCha20Rng::seed_from_u64(seed)`
|
||||
└── ChaCha20 is platform-independent, deterministic, cryptographically strong
|
||||
|
||||
2. Draw pool selections (order: alphabetical by pool_id for determinism)
|
||||
├── For each pool in sorted order:
|
||||
│ ├── Compute weighted random selection from candidates
|
||||
│ ├── Record PoolDraw { selected, candidates, selected_index }
|
||||
│ └── Advance RNG state (consumed regardless of pool size)
|
||||
└── Validate: no NPC selected for conflicting roles
|
||||
|
||||
3. Assign templates
|
||||
├── For each template in sorted order:
|
||||
│ ├── Fill mandatory role slots from pool draws
|
||||
│ ├── Fill remaining slots from available NPCs (weighted by fit)
|
||||
│ ├── Record ownership (first template assigned = owner)
|
||||
│ └── Create reference links for cross-template NPCs
|
||||
└── Validate: every NPC has exactly one owning template
|
||||
|
||||
4. Configure triangles
|
||||
├── Map pool-drawn NPCs into triangle member slots
|
||||
├── Determine activation mode per triangle
|
||||
│ └── Active if any member is entangled; passive otherwise
|
||||
└── Set initial fork states (v0.1: all default)
|
||||
|
||||
5. Compute entanglement
|
||||
├── Generate target ratio (vary around 30/50/20 using seed RNG)
|
||||
│ └── flat_pct: 25-35 (uniform draw)
|
||||
│ └── entangled_pct: 15-25 (uniform draw)
|
||||
│ └── mundane_pct: 100 - flat - entangled
|
||||
├── Classify NPCs: pool draws determine entangled set, triangle
|
||||
│ membership determines mundane, remainder is flat
|
||||
└── Record actual ratio achieved
|
||||
|
||||
6. Select contraband
|
||||
└── Draw from contraband pool (v0.1: single candidate)
|
||||
|
||||
7. Generate starting knowledge
|
||||
├── For each character:
|
||||
│ ├── Background knowledge from character definition
|
||||
│ ├── Institutional knowledge from character role
|
||||
│ ├── Social knowledge from template assignments
|
||||
│ │ └── Character knows NPCs in their home template (KnowsOf)
|
||||
│ │ └── Character knows FRIEND deeply (KnowsDetails)
|
||||
│ └── Investigation knowledge (detective only: assignment briefing)
|
||||
└── Validate: no character knows things they shouldn't
|
||||
|
||||
8. Assemble SeedConfig and return
|
||||
```
|
||||
|
||||
### 6.2 Determinism guarantees
|
||||
|
||||
The seeder MUST produce identical output given identical inputs. This requires:
|
||||
|
||||
1. **Platform-independent RNG:** ChaCha20 (not platform `thread_rng`). Same byte stream on Linux, macOS, Windows.
|
||||
2. **Sorted iteration:** All collections iterated in sorted order (BTreeMap handles this; Vec collections must be pre-sorted or iteration order must be specified).
|
||||
3. **No floating-point in selection logic:** Weights are integers. Selection uses integer arithmetic only.
|
||||
4. **Content version pinning:** The `content_version` field detects if content changed between save and load.
|
||||
5. **RNG consumption order:** The RNG advances in a fixed order regardless of pool sizes or skip conditions. This prevents "butterfly effect" where adding a candidate to one pool shifts all subsequent draws.
|
||||
|
||||
### 6.3 RNG consumption protocol
|
||||
|
||||
To prevent butterfly effects when pools change size between content versions:
|
||||
|
||||
```
|
||||
For each pool (sorted alphabetically):
|
||||
1. Consume exactly `max_candidates` RNG values (configurable per pool, default 8)
|
||||
2. Use the first consumed value to select from actual candidates
|
||||
3. Remaining consumed values are discarded
|
||||
|
||||
This means adding a candidate to pool A doesn't shift the RNG
|
||||
sequence for pool B.
|
||||
```
|
||||
|
||||
**v0.1 simplification:** With single-candidate pools, all draws are deterministic regardless. The protocol matters for v0.2+ when pools have real variation.
|
||||
|
||||
## 7. Integration Points
|
||||
|
||||
### 7.1 Who writes seed configs
|
||||
|
||||
| Component | Responsibility |
|
||||
|-----------|---------------|
|
||||
| **Seeder system** (Rust, `server/src/simulation/seeder.rs`) | Generates `SeedConfig` from seed + content |
|
||||
| **Save system** (Rust) | Serializes `SeedConfig` into save files |
|
||||
| **Content loader** (Rust, ticket #408) | Reads pool definitions from content YAML |
|
||||
| **Debug CLI** (Rust, `--dump-seed` flag) | Writes debug RON dump |
|
||||
|
||||
### 7.2 Who reads seed configs
|
||||
|
||||
| Component | What it reads | Why |
|
||||
|-----------|--------------|-----|
|
||||
| **Entity spawner** | Pool draws, template assignments | Creates ECS entities with correct components |
|
||||
| **Knowledge initializer** | Starting knowledge | Populates KnowledgeGraph components (D-041) |
|
||||
| **Storyteller** | Triangle config, entanglement | Knows which triangles to manage, which NPCs are intrigue-relevant |
|
||||
| **Save/load** | Full SeedConfig | Restores game state from save |
|
||||
| **Replay system** | SeedMeta | Verifies deterministic reproduction |
|
||||
| **Line previewer** (ticket #407) | Full SeedConfig | Simulates line selection for a given seed |
|
||||
|
||||
### 7.3 Content author workflow
|
||||
|
||||
1. Author writes NPC profiles, templates, triangles, pools in `content/`
|
||||
2. Author runs `make validate-content` to check schemas
|
||||
3. Author runs line previewer (`tooling/line-previewer`) with a test seed to verify line selection
|
||||
4. Author can inspect seed config via `--dump-seed` to verify NPC assignments match expectations
|
||||
|
||||
## 8. v0.1 vs v0.2+ Scope
|
||||
|
||||
| Aspect | v0.1 | v0.2+ |
|
||||
|--------|------|-------|
|
||||
| Pool candidates | 1 per pool (deterministic) | N per pool (randomized) |
|
||||
| Entanglement ratio | Fixed at ~29/47/24 | Variable per seed (25-35 / 40-55 / 15-25) |
|
||||
| Contraband types | 1 (lattice components) | 3+ with different moral valences |
|
||||
| Triangle initial states | All default | Seed-randomized starting positions |
|
||||
| Module attachment | Fixed known/stranger split | Variable per seed (D-029: 60-70/30-40) |
|
||||
| Starting knowledge | Hardcoded per character | Generated from character definition + pool draws |
|
||||
| Cross-district pools | N/A (one district) | NPCs can be drawn across district boundaries |
|
||||
| Template variants | Fixed templates | Template variants (same social site, different layouts) |
|
||||
|
||||
## 9. Validation Rules
|
||||
|
||||
The seeder validates invariants after generation:
|
||||
|
||||
```rust
|
||||
fn validate(config: &SeedConfig, content: &LoadedContent) -> Result<(), SeedError> {
|
||||
// 1. Every NPC has exactly one owning template
|
||||
assert_single_ownership(&config.template_assignments)?;
|
||||
|
||||
// 2. FRIEND NPCs are assigned to the correct character
|
||||
assert_friend_bonds(&config.pool_draws)?;
|
||||
|
||||
// 3. Triangle members match NPC assignments
|
||||
assert_triangle_consistency(&config.triangle_config, &config.template_assignments)?;
|
||||
|
||||
// 4. Entanglement ratio is within acceptable range
|
||||
assert_entanglement_bounds(&config.entanglement)?;
|
||||
|
||||
// 5. No circular ownership in reference links
|
||||
assert_no_circular_refs(&config.template_assignments)?;
|
||||
|
||||
// 6. Starting knowledge respects information boundaries
|
||||
// (smuggler doesn't know detective-only facts, etc.)
|
||||
assert_knowledge_boundaries(&config.starting_knowledge, content)?;
|
||||
|
||||
// 7. All NPC IDs reference valid content profiles
|
||||
assert_npc_ids_valid(&config, content)?;
|
||||
|
||||
// 8. Content version matches
|
||||
assert_content_version(&config.meta, content)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## 10. Serialization Format
|
||||
|
||||
The `SeedConfig` serializes to RON for save files and debug dumps:
|
||||
|
||||
```ron
|
||||
SeedConfig(
|
||||
meta: SeedMeta(
|
||||
seed: 42,
|
||||
content_version: "0.1.0",
|
||||
schema_version: 1,
|
||||
generated_at: "2026-02-13T10:00:00Z",
|
||||
),
|
||||
character_selection: CharacterSelection(
|
||||
player_character: "smuggler",
|
||||
available_characters: [
|
||||
CharacterDefinition(
|
||||
id: "smuggler",
|
||||
display_name: "Dock Worker",
|
||||
home_template: "logistics-hub",
|
||||
default_access: [Public, Insider],
|
||||
),
|
||||
CharacterDefinition(
|
||||
id: "detective",
|
||||
display_name: "Commission Investigator",
|
||||
home_template: "logistics-hub",
|
||||
default_access: [Public, Authority],
|
||||
),
|
||||
],
|
||||
),
|
||||
pool_draws: {
|
||||
"transit:compromised_inspector": PoolDraw(
|
||||
pool_id: "transit:compromised_inspector",
|
||||
selected: "npc:torek-lintar",
|
||||
candidates: ["npc:torek-lintar"],
|
||||
selected_index: 0,
|
||||
),
|
||||
"transit:friend_detective": PoolDraw(
|
||||
pool_id: "transit:friend_detective",
|
||||
selected: "npc:sera-venn",
|
||||
candidates: ["npc:sera-venn"],
|
||||
selected_index: 0,
|
||||
),
|
||||
"transit:friend_smuggler": PoolDraw(
|
||||
pool_id: "transit:friend_smuggler",
|
||||
selected: "npc:kael-davan",
|
||||
candidates: ["npc:kael-davan"],
|
||||
selected_index: 0,
|
||||
),
|
||||
"transit:primary_contraband": PoolDraw(
|
||||
pool_id: "transit:primary_contraband",
|
||||
selected: "contraband:lattice-components",
|
||||
candidates: ["contraband:lattice-components"],
|
||||
selected_index: 0,
|
||||
),
|
||||
},
|
||||
// ... (template_assignments, triangle_config, etc.)
|
||||
)
|
||||
```
|
||||
|
||||
## 11. Crate Dependencies
|
||||
|
||||
```toml
|
||||
# In server/Cargo.toml or wherever the seeder lives
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
ron = "0.8" # RON serialization
|
||||
rand = "0.8" # RNG traits
|
||||
rand_chacha = "0.3" # Platform-independent deterministic RNG
|
||||
```
|
||||
|
||||
No new dependencies beyond what the server already uses. `rand` and `serde` are existing deps. `ron` and `rand_chacha` are standard Rust ecosystem crates with no transitive bloat.
|
||||
|
||||
## 12. Open Questions
|
||||
|
||||
| # | Question | Impact | Suggested Resolution |
|
||||
|---|----------|--------|---------------------|
|
||||
| 1 | Should the seed config include routine schedule overrides, or should routines be purely content-driven? | If the seeder can modify routines (e.g., FRIEND's deviation schedule depends on which FRIEND was drawn), routines become partially generated. If not, content must pre-author all variants. | Content-driven for v0.1 (one candidate = one routine). v0.2+: seeder generates routine deviations based on drawn contradiction arcs. |
|
||||
| 2 | Should `max_candidates` (RNG consumption budget per pool) be configurable per pool or global? | Per-pool allows fine-grained control but complicates the protocol. Global is simpler but wastes RNG state for small pools. | Global default of 8, with per-pool override in pool definition YAML. 8 handles up to 8 candidates without waste, which covers v0.2 comfortably. |
|
||||
| 3 | How does the seed config interact with the storyteller's module activation system (D-023 Tier 1)? | The storyteller needs to know which Tier 1 modules are *available* (not yet activated) vs *activated* vs *completed*. Does the seed config pre-select available modules, or does the storyteller draw from its own pool at runtime? | Seed config pre-selects *available* modules from a module pool. Storyteller activates them based on player proximity. This keeps all randomization in the seeder for determinism. |
|
||||
| 4 | Should the debug dump include a human-readable narrative summary (e.g., "Kael Davan is the smuggler's FRIEND, smuggling lattice components...")? | Useful for content authors, trivial to generate, but adds code surface. | Yes. Add a `summary: String` field to `SeedMeta` generated at dump time. Not serialized into save files. |
|
||||
|
||||
## 13. Implementation Sequence
|
||||
|
||||
This is a design document. Implementation is Sprint 5+. Suggested build order:
|
||||
|
||||
1. **Rust types** — Define `SeedConfig` and all sub-structs with serde derives. ~1 day.
|
||||
2. **Pool definition schema** — `pools.schema.json` in `content/_schema/`. ~0.5 day.
|
||||
3. **Pool loader** — Extend content loader to parse pools.yaml. ~1 day.
|
||||
4. **Seeder system** — `server/src/simulation/seeder.rs`. Core generation logic. ~2 days.
|
||||
5. **Validation** — Invariant checks from section 9. ~1 day.
|
||||
6. **Save integration** — Serialize/deserialize SeedConfig in save system. ~0.5 day.
|
||||
7. **Debug dump** — `--dump-seed` CLI flag. ~0.5 day.
|
||||
8. **Starting knowledge generation** — Section 4.8 logic. ~1 day.
|
||||
|
||||
**Total estimate:** ~7-8 developer-days. Parallelizable with content authoring work.
|
||||
|
||||
---
|
||||
|
||||
*Design document for ticket #394. Implementation deferred to Sprint 5+.*
|
||||
*Cross-references: D-010, D-024, D-025, D-027, D-029, D-034, D-035, D-036, D-037, D-041.*
|
||||
Reference in New Issue
Block a user