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
11 KiB
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-directionmemory (the storage architecture agreed in discussion; this doc is the concrete schema built from the specimen). Consumes the disposition-integer model from2026-07-10-bounded-npc-conversation-design.mdand the canon-log contract from2026-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_dispositionmath is unchanged. - Code owns the numbers (one shared constant, single source — same pattern as
palette.gdfor colour). The bible owns the rung names/order viarule.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.
neveris a real gate that no band satisfies — the fact is held butrevealnever 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
ContentDBand 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'sknows: [{fact_id, gate}]. Enough to computeavailable_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).
- The body of every knowledge entry (rumor/fact/secret) — it is an NPC
knowledge payload, spoken through
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 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.
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/speakprompt as the knowledge list).
Gate logic (the whole of it)
reveal(fact_id) enters an NPC's available_moves iff:
- the NPC
knowsfact_id, and gate ≠ never, andband(current_disposition) ≥ band(gate), andfact_idis not already inrevealed_topics.
This is §6's existing available_moves ("reveal if not revealed") enriched with the
band-gate check (2–3). 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):
- Parse — extract fenced YAML blocks from each bible; ignore prose commentary.
- Resolve & validate (promotion gate) — refuse to emit unless:
- every
knowsfact_idresolves to a real knowledge entry; - every
gateis a legal rung (hostile/cold/neutral/warm/trusted) ornever; - every
relatedid resolves; - no id collision;
- secrecy safety check — no
secrecy:≥3body appears in a game artifact.
- every
- Emit — only
status: canonentries build into shipping artifacts;candidateentries are dev-only (surfaced, not shipped). - 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 isfact.crells-guard-acts-aloneattrusted— she never holds thesecretitself. The player infers the trade; the game never hands it over as a line. - The control (
npc.mayor-oswin-crell) holding the secret atgate: 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.jsonmigration — 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
racefield / class-id enum widening — tracked in the races & classes spec, not here.