Files
settled-reach/docs/workshops/wiki-review/round1-tyre.md
T
jpmschweitzerandClaude Opus 4.6 b672b9c269 feat(docs): restructure wiki taxonomy with templates and Krenn System entry (#301, #319)
Move wiki from docs/wiki/ to root wiki/. Add 14 category templates with
YAML frontmatter, governance model, hierarchical spatial structure
(system > station > district > location). Populate Krenn System entry
as first concrete star system. Update cross-references in workshop and
sprint docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 00:58:45 +01:00

584 lines
23 KiB
Markdown

# Wiki Review Workshop — Round 1: Tyre (Technical Architect)
**Topics:** 4 (Taxonomy Nesting) and 5 (Content Directory Structure + Mod Overlay)
---
## Topic 4: Wiki Taxonomy Hardening
### The Problem, Stated Precisely
We have 45 wiki files today. The v0.1 vertical slice has 1 system, 1 station, 1 district, 3 social sites, 17 NPCs. When we add a second system — or a second station in Krenn — every generic name collides. "The Terminal" exists on every logistics station. "Maintenance Corridors" is universal. "Drin" is a common enough name to recur.
The question isn't *whether* to namespace. It's *how deep to nest* before the directory structure becomes a tax on contributors instead of an aid.
### Proposal: Two Rules, One Principle
**Principle: The filesystem is navigation, not identity.** Unique identity lives in content frontmatter (YAML/markdown metadata). The directory path is for humans browsing the tree. It should be shallow enough to tab-complete and deep enough to disambiguate.
**Rule 1: Maximum 2 levels of spatial nesting for locations.**
```
locations/
{system}/
{location-slug}.md
```
Current state (`locations/krenn-system/the-terminal.md`) already follows this. Good. Don't go deeper.
Why not 3 levels (`locations/{system}/{station}/{slug}.md`)? Because:
- Tab-completing a 4-segment path is annoying. Contributors will get the nesting wrong.
- Station context belongs in the file's frontmatter, not the path. A location is *in* a station, but it's *addressed by* system + slug.
- Most systems have 1-3 stations. Nesting buys almost nothing for the navigation cost.
If two locations in the same system share a slug (e.g., two stations both have "The Terminal"), disambiguate the slug: `the-terminal-sova.md` vs `the-terminal-orbital.md`. The frontmatter carries the canonical name.
**Example — v0.2 adds a second system (Tavros):**
```
locations/
krenn-system/
the-terminal.md # Station Sova's logistics hub
the-last-shift.md
maintenance-corridors.md
tavros-system/
the-terminal-central.md # Tavros Central Station's hub
the-strip.md # Bar district
dry-dock-corridors.md
```
No restructuring of existing files. New system = new directory. Done.
**Rule 2: NPCs, factions, technology, contraband, and knowledge are FLAT until collision.**
```
npcs/
kael-davan.md # unique name → flat
sera-venn.md
drin.md # unique in v0.1
# If v0.2 introduces a second "Drin":
npcs/
drin.md # rename to drin-sova.md
drin-sova.md # Krenn System Drin (retroactive rename)
drin-tavros.md # Tavros System Drin
```
Factions are inherently global — `factions/lattice-commission.md` never collides. Technology and contraband are likewise global concepts.
**Wait — do we need the retroactive rename?** Yes. But only on actual collision, which is rare. 17 NPCs fit flat. 50 NPCs probably fit flat. At 100+ NPCs across 3+ systems, you'll get collisions, but they'll be individual renames, not restructures.
If the team wants to avoid *any* future renames, we could namespace NPCs by district from the start:
```
npcs/
sova-transit/
kael-davan.md
drin.md
tavros-central/
drin.md
```
I don't recommend this for v0.1 (17 NPCs, one district, unnecessary nesting). But I acknowledge it's a valid choice if the team prefers "never rename" over "shallow paths now."
**Rule 3: World hierarchy is flat with cross-references, not nesting.**
```
world/
krenn-system.md # links to station-sova.md
station-sova.md # links to sova-transit-district.md
sova-transit-district.md # links to location pages
```
The spatial hierarchy lives in the *content* (wiki cross-links), not the *directory*. This is the same principle as Rule 1 — identity in content, navigation in paths. If we nested `world/krenn-system/station-sova/sova-transit-district.md`, contributors would drown in path segments. Three flat files with hyperlinks do the job better.
### Scaling Analysis
| Content growth | Current structure works? | Change needed? |
|---|---|---|
| v0.1 (1 system, 17 NPCs) | Yes | None |
| v0.2 (2 systems, ~40 NPCs) | Yes | New `locations/{system}/` dir only |
| v1.0 (5+ systems, 100+ NPCs) | Mostly | Occasional NPC slug disambiguation |
| DLC/mod content | Yes (see Topic 5) | Content packs are separate trees |
The structure survives because the nesting is shallow and disambiguation is on-demand. The only painful moment is if two NPCs share a first name across systems and we rename files — but that's a single `git mv`, not a restructure.
### Summary: Taxonomy Rules
1. **Locations**: `locations/{system}/{slug}.md` — max 2 levels. Disambiguate slug on collision.
2. **NPCs**: `npcs/{slug}.md` — flat. Disambiguate with `{slug}-{district}.md` on collision only.
3. **Factions, technology, contraband, knowledge**: flat. No foreseeable collision risk.
4. **World**: flat with cross-references. Hierarchy is content, not structure.
5. **Authoring guides**: `authoring/{slug}.md` — flat. Few files, no collision risk.
---
## Topic 5: Content Directory Structure & Mod Overlay
*cracks knuckles*
This is the architectural meat. The wiki (`wiki/`) is the authoring source of truth — human-readable, team-navigable. The content directory (`content/`) is the engine-readable delivery format — YAML, schema-validated, loaded at runtime. They mirror each other structurally but serve different consumers.
### Architecture: Three-Layer Content Stack
```
Layer 3 (highest priority): mods/ # User mods (override/extend)
Layer 2: content/dlc/ # Official DLC packs
Layer 1 (base): content/ # Base game
```
Later layers override or extend earlier layers. The engine walks the stack top-down, resolving each content address to the highest-priority file.
### Proposed `content/` Directory Structure
```
content/
_meta/
manifest.yaml # Pack metadata: version, id, dependencies
load-order.yaml # Explicit load ordering for multi-pack scenarios
_schema/
npc.schema.yaml # Validation schema per content type
location.schema.yaml
faction.schema.yaml
template.schema.yaml
fact.schema.yaml
dialogue.schema.yaml
monologue.schema.yaml
district.schema.yaml
global/
factions/
concord-assembly.yaml
lattice-commission.yaml
syndics.yaml
the-ring.yaml
guardians-of-autonomy.yaml
veil-institute.yaml
the-unbound.yaml
technology/
neural-lattice.yaml
meridian.yaml
span-gates.yaml
founder-gates.yaml
clone-transfer.yaml
severance-tech.yaml
contraband/
lattice-components.yaml
medical-grade-replacements.yaml
severance-equipment.yaml
knowledge/
facts.yaml # All FactId definitions (24 for v0.1)
entity-attributes.yaml # Canonical EntityKnowledge keys
relationship-states.yaml # RelationshipState enum reference
districts/
sova-transit/
district.yaml # District metadata, spatial refs, ambient config
npcs/
kael-davan.yaml # 10-axis NPC definition
sera-venn.yaml
voss.yaml
lera-sessik.yaml
torek-lintar.yaml
devra.yaml
maret-korr.yaml
resha.yaml
hael.yaml
renn.yaml
pell.yaml
harek.yaml
drin.yaml
sess.yaml
olin.yaml
sabel.yaml
tav.yaml
locations/
the-terminal.yaml # Spatial definition, sightlines, Meridian coverage
the-last-shift.yaml
maintenance-corridors.yaml
templates/
logistics-hub.yaml # Social site: roles, triangles, NPC assignments
bar.yaml
smuggling-ring.yaml
lines/
terminal/
dialogue.yaml
monologue-smuggler.yaml
monologue-detective.yaml
bar/
dialogue.yaml
monologue-smuggler.yaml
monologue-detective.yaml
corridor/
dialogue.yaml
monologue-smuggler.yaml
monologue-detective.yaml
```
### Key Design Decisions
**Why `districts/` as the atomic content pack?**
A district maps to D-025 (social site / functional cluster as atomic template unit). It's the natural unit of content authoring, the natural unit of mod creation, and the natural unit of engine loading. "Add a district" = drop a new directory under `districts/`. The engine discovers it.
The monologue guide already organizes content by location within a district (terminal, bar, corridor). The `lines/` subdirectory matches this exactly.
**Why `global/` vs `districts/`?**
Some content is inherently Reach-wide: factions, technology, contraband definitions, FactId vocabulary. An NPC references `faction: "lattice-commission"` — that faction definition lives in `global/`, not per-district. If a mod changes how the Commission works, it changes `global/factions/lattice-commission.yaml` once, and every district that references it picks up the change.
District-scoped content is everything that's spatially local: NPCs, locations, social site templates, dialogue/monologue pools.
**Why `lines/` not `content/` or `dialogue/`?**
Three reasons: (1) avoids recursive `content/content/` path confusion, (2) "lines" is what authors call it — monologue lines, dialogue lines, (3) distinguishes textual content from structural definitions (NPC YAML is structural, monologue YAML is lines).
**Why `_schema/` and `_meta/` with underscore prefix?**
Infrastructure files sort to top of directory listings and are visually distinct from game content. Modders know not to touch underscore directories. Same convention as `_config.yml` in Jekyll, `__init__.py` in Python.
### Mod Overlay: How It Works
A mod is a content pack with the same directory structure as `content/`. Mods live under `mods/{mod-id}/`.
**Example: "Add Jax the bartender to The Last Shift"**
```
mods/
extra-bartender/
_meta/
manifest.yaml # mod-id: extra-bartender, requires: base >= 0.1
districts/
sova-transit/
npcs/
jax.yaml # New NPC definition (10-axis)
templates/
bar.yaml # EXTENDS bar template (adds Jax to role list)
lines/
bar/
dialogue.yaml # EXTENDS dialogue pool (adds Jax's lines)
```
**Three overlay operations:**
| Operation | When | Mechanic |
|---|---|---|
| **ADD** | New file at a path that doesn't exist in base | File is loaded as-is |
| **REPLACE** | File at same path as base, entity-type content | Entire file replaces base version |
| **MERGE** | File at same path as base, pool-type content | Lists are concatenated, deduplicated by `id` |
The critical distinction: **entity definitions REPLACE, line pools MERGE.**
If a mod provides `districts/sova-transit/npcs/kael-davan.yaml`, it *replaces* Kael's entire 10-axis definition. You can't partially patch an NPC — that's a recipe for broken state.
If a mod provides `districts/sova-transit/lines/bar/dialogue.yaml`, its lines are *appended* to the base pool. Each line has a unique `id` — if a mod provides a line with an existing `id`, the mod's version wins (override by ID). New IDs are simply added. This means a modder can:
- Add 5 new dialogue lines (new IDs) without touching existing content
- Override a specific line (same ID, different text) without replacing the whole pool
- Both in the same file
**Merge semantics in YAML:**
```yaml
# Base: content/districts/sova-transit/lines/bar/dialogue.yaml
lines:
- id: bar_d_001
text: "What'll you have?"
role: bartender
access: [public]
trust: surface
situation: [bar_evening]
# Mod: mods/extra-bartender/districts/sova-transit/lines/bar/dialogue.yaml
lines:
- id: bar_d_001 # OVERRIDE: same id, different text
text: "Evening. The usual?"
role: bartender
access: [public]
trust: surface
situation: [bar_evening]
- id: bar_d_200 # ADD: new id
text: "Jax says the grain spirit's from a new batch. Smoother."
role: bartender
access: [peer]
trust: surface
situation: [social]
```
**Load order resolution:**
```
1. Discover all content packs: content/ (base), content/dlc/* (DLC), mods/* (user)
2. Read each _meta/manifest.yaml for dependencies and load-order hints
3. Topological sort by dependencies (base → DLC → mods, respecting inter-mod deps)
4. For each content address (e.g., districts/sova-transit/npcs/kael-davan.yaml):
a. Entity types: last writer wins (highest priority pack)
b. Pool types: merge all, deduplicate by line id (highest priority wins on collision)
5. Validate merged content against _schema/
```
### Content Validation Pipeline
**Proposal: Schema per content type, not one schema.**
Each content type has its own YAML schema in `_schema/`. This is the right granularity because:
- Different types have completely different shapes (NPC ≠ dialogue ≠ faction)
- Schema changes are localized (adding a new NPC axis doesn't touch dialogue schema)
- Mod validation checks only the types the mod provides
**Validation runs at three points:**
| When | What | Speed |
|---|---|---|
| **Author-time** | CLI validator: `make validate-content` | <2 seconds for full base |
| **CI** | Same validator, blocks merge on failure | Same |
| **Load-time** | Engine validates on startup, panics on schema violation | ~50ms for v0.1 content |
**What the validator checks:**
1. **Structural validity**: YAML parses, required fields present, enums match allowed values.
2. **Cross-reference integrity**:
- NPC definitions reference valid FactIds from `global/knowledge/facts.yaml`
- Dialogue prerequisites reference valid FactIds and entity slugs
- Template role lists reference valid NPC slugs in the same district
- Monologue `character` values are valid enum members
3. **Pool consistency**:
- No duplicate line IDs within a merged pool
- All `access` and `trust` values are valid enums
- All `trigger` types match the 9 canonical types
- All `situation`, `topic`, `mood` values match canonical enums
4. **Completeness** (warning, not error):
- Every NPC in a template has at least N dialogue lines
- Every location has at least one monologue line per character
- Tier 1 NPCs meet minimum line counts from D-034
**Schema format**: YAML schemas in a subset of JSON Schema (YAML-formatted for readability, functionally equivalent). The Rust loader and the Python/CLI validator both consume the same schema files.
Example NPC schema (abbreviated):
```yaml
# _schema/npc.schema.yaml
type: object
required: [id, name, tier, axes]
properties:
id:
type: string
pattern: "^[a-z][a-z0-9_-]+$"
name:
type: string
tier:
type: integer
enum: [1, 2, 3]
district:
type: string
axes:
type: object
required: [want, routine, personality]
properties:
want: { type: string }
secret: { type: string }
relationships:
type: array
items: { type: object }
tolerance: { type: string }
routine: { type: string }
information: { type: string }
contentment: { type: string }
personality: { type: string }
tell: { type: string }
skills: { type: string }
voice_sample:
type: array
items: { type: string }
dual_lens:
type: object
properties:
smuggler: { type: string }
detective: { type: string }
```
### Hot-Reloading During Development
**Feasibility tier: straightforward for line pools, moderate for entity definitions.**
The Rust server watches `content/` via `notify` crate (cross-platform filesystem events). On file change:
| Content type | Reload strategy | Latency |
|---|---|---|
| Dialogue/monologue pools | Full pool reload for the affected location | <5ms |
| NPC definitions | Update ECS component fields in-place | <10ms |
| Location definitions | Update spatial metadata | <10ms |
| Templates | Requires re-linking NPC-to-role assignments | ~50ms |
| Global definitions (factions, facts) | Full reload of global registry | ~20ms |
| Schema changes | Requires restart (schema drives parser) | N/A |
**Line pool hot-reload is the money feature.** Writers edit `monologue-smuggler.yaml`, save, and the next monologue trigger in-game uses the updated pool. No restart, no reload command. This is critical for the authoring workflow — Mellanie and Paula need to iterate on lines without leaving the game running.
**Implementation sketch:**
```rust
// Simplified hot-reload system
fn content_watcher_system(
mut events: EventReader<FileChangedEvent>,
mut pools: ResMut<DialoguePoolRegistry>,
mut npcs: Query<&mut NpcDefinition>,
schemas: Res<ContentSchemas>,
) {
for event in events.read() {
match categorize_path(&event.path) {
ContentType::DialoguePool(location, pool_type) => {
// Re-parse, re-validate, swap pool
if let Ok(new_pool) = parse_and_validate(&event.path, &schemas) {
pools.replace(location, pool_type, new_pool);
info!("Hot-reloaded {} pool for {}", pool_type, location);
}
}
ContentType::NpcDefinition(npc_id) => {
// Re-parse, update matching ECS component
if let Ok(new_def) = parse_and_validate(&event.path, &schemas) {
for mut npc in npcs.iter_mut() {
if npc.id == npc_id {
*npc = new_def;
break;
}
}
}
}
// ... other types
}
}
}
```
Hot-reload is **development-only** (behind `#[cfg(feature = "dev")]`). Release builds load content once at startup and never watch the filesystem. This avoids the performance cost of file watching and the security surface of runtime file loading in shipped builds.
### How Wiki and Content Relate
```
wiki/ → Human-authored source of truth (markdown)
↓ (authoring pipeline: wiki → YAML)
content/ → Engine-readable delivery format (YAML)
↓ (engine loads at startup)
Runtime ECS → In-memory game state
```
The wiki is where *decisions* are made and *content is authored*. The content directory is where *the engine looks*. The pipeline between them can be:
1. **Manual** (v0.1): Authors write YAML by hand, referencing wiki pages. The wiki and YAML coexist as parallel representations. The validator ensures they don't drift.
2. **Semi-automated** (v0.2+): A conversion tool extracts structured data from wiki markdown (NPC axes, FactId definitions) into YAML. Authors maintain the wiki; the tool generates YAML.
3. **Full pipeline** (v1.0): Wiki entries have embedded YAML frontmatter that IS the content definition. The wiki page is simultaneously the human-readable doc and the machine-readable source.
For v0.1, option 1 is correct. The content volume (17 NPCs, 3 locations, ~200 lines) doesn't justify pipeline tooling. Authors write both wiki markdown and content YAML. The schema validator catches inconsistencies.
### Mod Structure: Complete Example
A modder who wants to "add a new NPC to the bar with custom dialogue" creates:
```
mods/
jax-the-veteran/
_meta/
manifest.yaml
districts/
sova-transit/
npcs/
jax-korrenson.yaml
templates/
bar.yaml # Extends: adds jax_korrenson to regular role
lines/
bar/
dialogue.yaml # Jax's dialogue lines (new IDs)
monologue-smuggler.yaml # Smuggler's observations of Jax
monologue-detective.yaml # Detective's observations of Jax
```
**`manifest.yaml`:**
```yaml
id: jax-the-veteran
name: "Jax the Veteran"
version: "1.0.0"
author: "ModderName"
requires:
- id: base
version: ">=0.1.0"
description: "Adds Jax Korrenson, a retired military veteran, to The Last Shift."
content_types: [npc, template, dialogue, monologue]
```
**`jax-korrenson.yaml`:**
```yaml
id: jax-korrenson
name: "Jax Korrenson"
tier: 3
district: sova-transit
axes:
want: "Quiet drink, nobody asking questions about the war."
routine: "Arrives at bar 18:00, corner booth, leaves 22:00."
personality: "Gruff, observant, protective of regulars."
voice_sample:
- "I've seen enough to know when someone's lying. You're not good at it."
```
**`dialogue.yaml` (merge extension):**
```yaml
lines:
- id: bar_d_300
text: "You don't look like logistics. What's your story?"
role: bar_regular
access: [public]
trust: surface
situation: [bar_evening, social]
topic: [personal]
mood: [suspicious]
```
The engine loads this, validates it against `_schema/`, merges the line pool, assigns the NPC to the template, and Jax appears at the bar.
### Feasibility Assessment
| Component | Difficulty | Sprint estimate |
|---|---|---|
| Directory structure + schema definitions | Easy | 0.5 sprint |
| YAML content parser (Rust, `serde_yaml`) | Easy | 0.5 sprint |
| Schema validator CLI (`make validate-content`) | Moderate | 1 sprint |
| Mod discovery + load-order resolution | Moderate | 1 sprint |
| Merge semantics for line pools | Moderate | 0.5 sprint |
| Hot-reload (dev-only) | Moderate | 1 sprint |
| Cross-reference validation | Hard | 1 sprint |
Total for full content pipeline: ~4.5 sprints. But we don't need all of it for v0.1:
**v0.1 scope (1 sprint):** Directory structure + YAML parser + basic schema validation. No mod overlay, no hot-reload. Content is loaded once at startup from `content/`.
**v0.2 scope (2 sprints):** Mod overlay (ADD + REPLACE), schema validator CLI, hot-reload for line pools.
**v0.3+ scope (1.5 sprints):** MERGE semantics, cross-reference validation, full mod pipeline.
### Open Questions for the Team
1. **Gestalt/Nigel**: The mod MERGE mechanic for line pools needs game design input. If a mod adds 50 dialogue lines to a pool of 20, the NPC talks like a mod 71% of the time. Should there be weighting? A max-lines-per-mod cap? Or is this the modder's problem?
2. **Paula/Mellanie**: The `lines/` directory uses location shortnames (terminal, bar, corridor). These come from the monologue guide. Are these stable identifiers you're comfortable with, or should they match the wiki slugs (the-terminal, the-last-shift, maintenance-corridors)?
3. **Gestalt**: Is `district` the right atomic content pack unit? Or should it be `template` (social site)? A district contains multiple templates — packing at the district level means you can't add a single social site without the whole district. Packing at the template level is more granular but more complex.
---
## Cross-Topic: How Wiki Taxonomy Maps to Content Structure
| Wiki path | Content path | Notes |
|---|---|---|
| `wiki/npcs/kael-davan.md` | `content/districts/sova-transit/npcs/kael-davan.yaml` | NPC is district-scoped |
| `wiki/locations/krenn-system/the-terminal.md` | `content/districts/sova-transit/locations/the-terminal.yaml` | Location is district-scoped |
| `wiki/factions/lattice-commission.md` | `content/global/factions/lattice-commission.yaml` | Faction is global |
| `wiki/knowledge/fact-catalog.md` | `content/global/knowledge/facts.yaml` | All FactIds in one file |
| `wiki/authoring/monologue-guide.md` | N/A (authoring reference, not engine content) | Wiki-only |
| N/A | `content/districts/sova-transit/lines/terminal/dialogue.yaml` | Engine-only (authored YAML) |
The mapping is consistent but not 1:1. Some wiki pages are references that don't produce engine content. Some engine content (line pools) doesn't have a wiki counterpart — it IS the content.
---
*Tyre out. Ready for cross-pollination in Round 2.*