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

11 KiB
Raw Blame History

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 and the canon-log contract from 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)

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 entitiestown/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 entriesrumor/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.

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/*.mdcontent/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-shrinerumor.travelers-go-missingfact.militia-never-investigatesfact.crells-guard-acts-alonesecret.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.