Files
code_of_conquest_dnd/docs/superpowers/specs/2026-07-11-content-canon-schema-design.md
Phillip Tarrant db5ee247e7 docs(spec): content & canon schema design
Schema lock built bottom-up from the Duncarrow specimen (candidate-town.md):
- disposition rungs as bands over the engine integer (code owns numbers)
- town fact = engine reveal-topic (authoring layer over §6, no new runtime)
- MD bible -> build -> JSON; split by role (game vs server), secrecy as lint
- canon home: content/lore (source) -> content/world + content/server (built)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-11 17:00:40 -05:00

256 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Content & Canon Schema — Design
- **Date:** 2026-07-11
- **Status:** Design approved; not yet planned/implemented.
- **Specimen:** `candidate-town.md` (Duncarrow) at repo root — the example town that
drives this schema bottom-up.
- **Supersedes/realizes:** the `canon-architecture-direction` memory (the storage
architecture agreed in discussion; this doc is the concrete schema built from the
specimen). Consumes the disposition-integer model from
[`2026-07-10-bounded-npc-conversation-design.md`](2026-07-10-bounded-npc-conversation-design.md)
and the canon-log contract from
[`2026-07-09-canon-log-schema-design.md`](2026-07-09-canon-log-schema-design.md).
---
## Purpose
Lock the **content schema** for authored world canon, using Duncarrow as the
specimen. Decide the authoring format, the physical client/server (spoiler) split,
and the canon home in the repo. Reconcile the town's named-rung disposition gates
with the engine's integer disposition.
This is a **schema lock**, not a content-authoring pass. Duncarrow's content
correctness is secondary; its *shape* is the deliverable.
**Charter alignment:** §2 (code owns state, AI owns text), §4 (proxy hides
spoilers/prompts), §6 (bounded NPC dialogue, `available_moves`), §11 (canon log),
§16 (repo layout, `/content`). Nothing here introduces a new runtime system — the
schema is an **authoring layer over §6**.
---
## Decisions (the lock)
### D1 — Disposition: named rungs are bands over the integer
The engine stores disposition as an integer (100..100); `adjust_disposition` does
integer math (clamped ±15; `become_hostile` = 100 floor). The town authors gates as
**named rungs**. These meet by defining threshold **bands** over the integer:
| Rung | Integer band |
|---|---|
| hostile | ≤ 45 |
| cold | 44 .. 15 |
| neutral | 14 .. +14 |
| warm | +15 .. +44 |
| trusted | ≥ +45 |
- The **integer is the runtime truth.** Bands are an evaluation/authoring layer.
`adjust_disposition` math is unchanged.
- **Code owns the numbers** (one shared constant, single source — same pattern as
`palette.gd` for colour). The **bible owns the rung names/order** via
`rule.disposition-ladder`.
- The band numbers above are a **tunable placeholder** — the model is fixed, the
exact cut points get playtested. Everything else in this doc is the lock.
- `never` is a real gate that **no band satisfies** — the fact is held but `reveal`
never admits it at any disposition.
### D2 — A town "fact" is an engine "topic" (authoring layer over §6)
A gated knowledge entry **is** a §6 reveal-topic. `fact_id = topic_id`. The `gate`
is the disposition-band condition that admits `reveal(fact_id)` into that NPC's
`available_moves`. `never` = held-but-never-admitted. **No new runtime system** — the
schema feeds gate logic the client already runs.
### D3 — Authoring format: Markdown bible → build → JSON
- **Source of truth:** a readable Markdown doc per town/topic — section headers,
prose commentary between blocks — with **fenced YAML blocks** for the structured
entries (Duncarrow's exact form, with the blocks formalized as real YAML so
parsing is trivial).
- **Runtime:** JSON (the client `ContentDB` and the API content resolver already
read JSON). A **build step** extracts the YAML blocks and emits JSON.
- One source, reads as a bible, machine-read as data.
### D4 — Client/server split: by role, with `secrecy` as a safety check
The build sorts each piece by **what it is for**, not by a number:
- **Ships in the game** (`content/world`, bundled in the client export):
- Public **canon entities** — whole entry (town/place/region/faction/person-record/rule).
- The **gate skeleton** of knowledge entries — `id`, `type`, `related`, and each
NPC's `knows: [{fact_id, gate}]`. Enough to compute `available_moves`. **No body.**
- **Stays on the server** (`content/server`, API-only, excluded from the client export):
- The **body** of every knowledge entry (rumor/fact/secret) — it is an NPC
knowledge payload, spoken through `/npc/speak`, never rendered by the client.
- NPC **persona/voice** bodies and `disposition_notes` (§6: personas are server-only).
`secrecy` (0..4) is the author's spoiler rating and becomes a **build-time safety
check**, not the router: if a `secrecy:≥3` body lands in a game artifact, **the build
fails**. Defense in depth — structural routing does the sort, secrecy lints it.
### D5 — Canon home
```
content/
lore/ # authored Markdown bibles (SOURCE OF TRUTH)
canon-roadmap.md # index: authored-vs-pending, sequences the world
duncarrow.md # <- candidate-town.md, promoted here
world/ # BUILT, ships in the game (client-bundled)
duncarrow.client.json
server/ # BUILT, api-only (excluded from Godot export)
duncarrow.server.json
```
- Authored bibles: `content/lore/*.md`.
- Built game artifact: `content/world/` — where the client already reads.
- Built server artifact: `content/server/` — the Godot export must **exclude** this
subtree (same class of concern as the deferred "Fallback-JSON export packaging"
and "spoilers-in-client-bundle" notes; this schema is the trigger to make the split
real).
---
## Schema reference
### Common envelope (every entry)
```yaml
id: <type>.<slug> # namespaced, unique. e.g. town.duncarrow, npc.mera-fenn
type: town | place | region | faction | person | rule # canon entities
| rumor | fact | secret # knowledge entries
status: candidate | canon # only `canon` builds into shipping artifacts
secrecy: 0..4 # author's spoiler rating; drives the D4 safety check
related: [<id>, ...] # graph links; every id must resolve
body: > # prose (routing per D4)
```
### Two categories
**A. Canon entities**`town/place/region/faction/person/rule`. Public world
objects. `secrecy:0`. Whole entry → game (`content/world`). Never appear in a
`knows` list; referenced via `related`.
**B. Knowledge entries**`rumor/fact/secret`. Gated, speakable. Atomic, so
different NPCs can hold the same entry at different gates. Skeleton → game; **body →
server**. `secrecy` free to be 0..4.
### NPC layer
An NPC is a `person`-type entry with a knowledge layer, kept **separate** from its
world-record `person.*` (a being can have both — e.g. `person.mayor-oswin-crell`
the record and `npc.mayor-oswin-crell` the interactive layer). The build
distinguishes the two by **id prefix**: `npc.*` is an interactive layer (carries
`knows` + `start_disposition`); `person.*` is a public world-record (canon entity,
category A). A `person.*` record never carries a `knows` list.
```yaml
id: npc.mera-fenn
type: person
status: candidate
secrecy: 0
start_disposition: cold # rung a stranger meets them at (mapped to an integer)
related: [town.duncarrow]
body: > # persona / voice — SERVER ONLY (§6)
knows:
- fact: rumor.elves-avoid-the-shrine gate: neutral
- fact: rumor.travelers-go-missing gate: warm
- fact: fact.crells-guard-acts-alone gate: trusted # reachable ceiling
- fact: secret.crell-runs-slave-trade gate: never # not held / never told
disposition_notes: > # author-facing — SERVER ONLY
```
- **Client receives per NPC:** `{ id, start_disposition, knows: [{fact_id, gate}] }`.
- **Server receives per NPC:** persona `body`, `disposition_notes`, and the resolved
fact **bodies** (fed to the `/npc/speak` prompt as the knowledge list).
### Gate logic (the whole of it)
`reveal(fact_id)` enters an NPC's `available_moves` **iff**:
1. the NPC `knows` `fact_id`, **and**
2. `gate ≠ never`, **and**
3. `band(current_disposition) ≥ band(gate)`, **and**
4. `fact_id` is not already in `revealed_topics`.
This is §6's existing `available_moves` ("reveal if not revealed") enriched with the
band-gate check (23). No other runtime change.
### The disposition rule entry
`rule.disposition-ladder` (type `rule`, canon entity) states the five rung
names and their order plus the `never` gate — the **semantic** ladder. The integer
band numbers live in **code** (D1), not the bible.
---
## Build & validation
The build step (`content/lore/*.md``content/world` + `content/server`):
1. **Parse** — extract fenced YAML blocks from each bible; ignore prose commentary.
2. **Resolve & validate (promotion gate)** — refuse to emit unless:
- every `knows` `fact_id` resolves to a real knowledge entry;
- every `gate` is a legal rung (`hostile/cold/neutral/warm/trusted`) or `never`;
- every `related` id resolves;
- no id collision;
- **secrecy safety check** — no `secrecy:≥3` body appears in a game artifact.
3. **Emit** — only `status: canon` entries build into shipping artifacts;
`candidate` entries are dev-only (surfaced, not shipped).
4. **Split** — route each field per D4 into `*.client.json` (game) and
`*.server.json` (api-only).
Contradiction-with-existing-canon is **author-asserted** (not auto-checkable) — the
promotion from `candidate` to `canon` is where a human confirms it.
---
## The Duncarrow specimen (worked example)
`candidate-town.md` exercises every part of the schema:
- **Canon entities:** `town.duncarrow`, `shrine.the-white-antlers` (place),
`person.mayor-oswin-crell` (record), `rule.disposition-ladder`.
- **A secret chain** of atomic knowledge entries at rising secrecy (1→4):
`rumor.elves-avoid-the-shrine``rumor.travelers-go-missing`
`fact.militia-never-investigates``fact.crells-guard-acts-alone`
`secret.crell-runs-slave-trade`.
- **The witness** (`npc.mera-fenn`, `start_disposition: cold`) whose reachable
ceiling is `fact.crells-guard-acts-alone` at `trusted` — she never holds the
`secret` itself. The player **infers** the trade; the game never hands it over as
a line.
- **The control** (`npc.mayor-oswin-crell`) holding the secret at `gate: never` — a
live test that the bounded-move vocabulary cannot leak it at any band.
- **The texture case** (`npc.harn-blackwood`) with a low-stakes personal fact
(`fact.harn-daughter-left`, secrecy 1, `gate: warm`) — tests disposition-gating on
something that spoils nothing.
Promoting Duncarrow = moving `candidate-town.md` to `content/lore/duncarrow.md`,
flipping `status: candidate → canon` per entry as each passes validation.
---
## Out of scope (this schema)
- **Acting on** the Crell secret — no quest/faction/positional combat to stop the
trade (the specimen's own POC constraint). The witness's ceiling delivers
understanding, not a quest.
- **Content authoring** beyond the specimen — new towns/NPCs are their own passes.
- **The build tool implementation** — this doc specifies its contract; the code is a
separate spec → plan → implementation cycle.
---
## Open / deferred
- **Band numbers** (D1) — tunable placeholder, settle by playtest.
- **`start_disposition` → integer** — a rung maps to a representative integer for
the runtime start (e.g. the band's midpoint, or its lower edge). Pick at
implementation.
- **Existing `fenn.json` migration** — today it crams server persona + client
capabilities in one hand-authored file (the flagged debt). The build supersedes it
with generated `*.client.json` / `*.server.json`; migrate Fenn when the build lands.
- **Canon-log `race` field / class-id enum widening** — tracked in the races &
classes spec, not here.