The canon-log schema and NewGame code already migrated to race_id/ calling_id + allowed_callings, but two copies of the roster outside that schema were missed, and no parity test guards them: - docs/canon-log.md (the cross-boundary contract doc) still documented the OLD player shape and allowed_classes. Anyone building a player block from the doc would get a 422. - .claude/skills/world-building's schema reference still emitted allowed_classes with two dead calling ids (assassin, priest). Author a new origin with that skill and it fails origin.schema.json (additionalProperties: false, allowed_callings required) AND, if that ever loosened, silently allows zero callings at runtime. Also: - add schema tests rejecting a dead class_id field and an unknown calling_id (priest) - guard NewGame._validate's container types (spend/skills) so a malformed JSON round-trip (null spend, string skills) produces a front-loaded error list instead of a GDScript runtime crash - extend the no-mechanics-in-content test to db.races, not just db.callings - rewrite the roadmap's M4 bullet: the two contract migrations landed; only the creation screen and title->creation->shell flow remain client: 250/250. api: 74 passed, 2 skipped. content_build --check: clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
11 KiB
Content schema — the exact contract
Everything the build tool (tools/content_build/) enforces, plus the loose JSON
formats it doesn't build. When in doubt, the validator in resolve.py is the
source of truth; this file is its plain-language mirror.
Table of contents
- The bible file (
content/lore/*.md) - Common fields (every bible entry)
- Entry kinds
- The secrecy scale
- The knowledge-gate model
- Client vs server routing
- Worked example
- Hand-authored JSON (not built)
The bible file
A bible file is Markdown. Prose between blocks is for human authors — the build
tool ignores it. Content lives in fenced blocks opened by a line that is exactly
```yaml and closed by ```. Each block is one YAML mapping = one entry.
Organize a file with ## headings and > notes like content/lore/specimen.md.
Every *.md in content/lore/ is parsed; a new region/storyline can be its own
file (e.g. content/lore/the-tallow-reach.md).
Common fields
Every entry requires these five (missing any → build fails):
| Field | Rule |
|---|---|
id |
namespace.slug, lowercase-kebab. Namespace is one of the legal set below. Globally unique — duplicate id fails the build. |
type |
Must equal the id's namespace, EXCEPT npc.* whose type is always person. |
status |
candidate or canon. Only canon entries emit to world/server; candidate is validated but not shipped — use it for work-in-progress. |
secrecy |
Integer (not bool). See the scale below. |
related |
List of ids (may be empty []). Every id must resolve to another entry, or the build fails. This is the web that keeps the world coherent — wire new entries into it. |
body: > — the prose. A YAML folded scalar. This is what the DM/NPC pipeline
reads. For canon entities it ships to the client; for knowledge it's server-only.
Entry kinds
Two families, distinguished by id namespace (not by any kind field):
Canon entities — the world's furniture
Namespaces town, place, region, faction, person, rule.
type== namespace.- Must be
secrecy: 0(they're public; their bodies ship to the client). bodyis public description.- The special entry
rule.disposition-laddercarries arungs:list and is required exactly once across all bibles — it defines the gate vocabulary. Don't duplicate or remove it; it lives inmechanics.md.
Knowledge — the atoms NPCs reveal
Namespaces rumor, fact, secret (these three id-prefixes; type matches).
secrecy1–4 (author-facing sensitivity — see scale).bodyis the thing known. Routes to the server, never the client.- One atomic idea per entry, so different NPCs can hold the same fact at
different gates. Chain them by
related:(a secret relates to the facts that circle it).
NPC dialogue layer — npc.*
The interactive layer of a person. id: npc.<slug>, type: person. This is a
SEPARATE entry from the person.<slug> world-record; the namespace prefix is the
only thing distinguishing them (a person may have both — see the mayor). Extra
required/optional fields:
| Field | Rule |
|---|---|
start_disposition |
Required. One of the ladder rungs (hostile/cold/neutral/warm/trusted). Where this NPC meets a stranger. |
knows |
Required, non-empty. List of {fact: <knowledge-id>, gate: <rung-or-never>}. Each fact must resolve to a rumor/fact/secret. Each gate is a rung or never. |
body |
Here it's the NPC's persona — voice, manner, what they want, why they withhold. Server-only. |
disposition_notes |
Optional. Author guidance on how they move up/down the ladder and what each pole means. Server-only. |
A non-npc entry must NOT carry knows or start_disposition. An npc.* entry
MUST carry both.
The secrecy scale
secrecy is author-facing and static — a mis-authoring guard, not a runtime
lever. It answers "how sensitive is this text, where may it live?"
| secrecy | Meaning | Routing |
|---|---|---|
| 0 | Public. Canon entities only. | Body ships client-side. |
| 1 | Observable rumor; freely-ish known. | Body server-only. |
| 2 | Known locally, not discussed. | Body server-only. |
| 3 | Protected; a real tell. | Body must never reach the client (build hard-fails if it does). |
| 4 | The core secret. | Server-only. |
Keep the secrecy of a knowledge chain ascending from freely-observed to the
never-gated core — a chain climbs by secrecy, e.g. 1→2→2→3→4 from an
observable rumor to the core secret.
The knowledge-gate model
Two independent axes — never conflate them:
- secrecy (above) lives on the fact — static, author-facing.
- gate lives on the knowledge link (
knows[].gate) — runtime, character-facing. It's the disposition rung at which THIS NPC will reveal THIS fact.
Rungs, low → high: hostile < cold < neutral < warm < trusted. Plus the special
gate never: the NPC knows the fact (the Improviser can reason with it) but
no disposition ever unlocks it in dialogue. never is how a locked-door NPC (the
mayor) holds the secret without leaking it.
Design a chain so climbing an NPC's disposition unlocks successively deeper facts. The reachable ceiling is a design choice: an NPC can witness enough to imply a secret without holding the secret entry itself: its top rung names what it witnessed, not the whole truth, and the player infers the rest. Understanding earned beats understanding handed over.
Client vs server routing
The build splits each canon entry (emit.py) — you don't do this, but author with
it in mind:
- Canon entity →
content/world/canon/<slug>.json(id, type, related, public body). Nothing server-side. - Knowledge → client
content/world/topics/<slug>.json(id, type, related — no body); servercontent/server/topics/<slug>.json(id, body, secrecy). - npc.* → client
content/world/npcs/<slug>.json(id, type, start_disposition, related, knows+gates — the gate logic the client needs); servercontent/server/npcs/<slug>.json(id, persona, disposition_notes — the voice, hidden).
The guarantee: a player who reads the client bundle sees NPC gates but never NPC personas or secret bodies. That's why secrecy≥3 bodies client-side is a hard build failure.
Worked example
A guarded fisherman who warms up and names the coin — and who holds one thing no disposition ever buys.
id: person.specimen-teague
type: person
status: canon
secrecy: 0
related: [place.specimen-wharf]
body: >
Teague, oldest hand on the Greywater wharf. Mends nets he no longer sails with.
Watches everything off the water and says almost none of it.
id: fact.specimen-foreign-coin
type: fact
status: canon
secrecy: 2
related: [place.specimen-wharf, person.specimen-teague]
body: >
The dock hands are paid in pass-country coin, not town mint. Someone outside the
charter is buying the wharf's silence, a little at a time.
id: secret.specimen-drowned-clerk
type: secret
status: canon
secrecy: 4
related: [place.specimen-wharf, person.specimen-teague, fact.specimen-foreign-coin]
body: >
The charter clerk who came asking after the coin went into the river in the
spring. Teague held his legs. He has not been out on the water since and tells
anyone who asks that it is his knees.
id: npc.specimen-teague
type: person
status: canon
start_disposition: cold
related:
[person.specimen-teague, fact.specimen-foreign-coin,
secret.specimen-drowned-clerk]
body: >
Same man as person.specimen-teague; his interactive layer. Curt with strangers, not
from malice but from a lifetime of watching talkers end up face-down in the
river. Warms slowly, and when he does the dryness turns to something almost
fond.
knows:
- {fact: fact.specimen-foreign-coin, gate: warm}
- {fact: secret.specimen-drowned-clerk, gate: never}
disposition_notes: >
Starts cold. Buying his catch, not his story, is what moves him. At warm he
names the coin; he never speculates aloud about who mints it. The clerk is the
one thing no rung buys — saying it aloud would hang him, and he knows it. He
reasons around it, steers off it, and takes it to the ground.
Note: person.specimen-teague (secrecy 0, public) and npc.specimen-teague (the layer,
persona server-only) are two entries. The chain ascends 2 → 4: the coin is gated at
warm and the player earns it; the clerk is gated never and no disposition unlocks
it. That is what never is for. Teague holds it — the Improviser may reason with
it, and the player may infer it from what he will say — but he cannot be talked into
saying it, because saying it would hang him. A never gate needs a reason like that:
if any rung should buy the secret, gate it at trusted instead.
Hand-authored JSON
Not built from the bible — edit these files directly. Loosely schema'd POC seeds; mirror the existing file in the folder. Keep ids stable, lowercase-snake.
Quest — content/world/quests/<id>.json
{ "id": "find_the_ledger", "name": "The Missing Ledger",
"objective": "Find who took Fenn's ledger" }
Item — content/world/items/<id>.json. Cursed/blessed items move Luck (§7):
the STR/LCK split is the point. Numeric effects live in game state; keep the JSON
to identity/slot and let narrative-worthy items surface as canon-log facts.
{ "id": "worn_shortsword", "name": "a worn shortsword", "slot": "weapon" }
Location — content/world/locations/<id>.json. Map nodes; an origin's
start_location_id resolves here.
{ "id": "greywater_docks", "name": "the Greywater docks",
"description": "A rot-black wharf where the river meets the sea trade." }
Origin — content/origins/<id>.json. Thin starting seed — where the player
begins and the situation. This is the ONLY place initial state (seeded
dispositions, granted items) belongs. Every referenced id
(start_location_id, inventory_grants[].item_id, start_quest_id,
disposition_overrides keys) must resolve to real content, or new-game
construction fails loudly.
{
"schema_version": 1,
"id": "deserter",
"display_name": "The Deserter",
"description": "You walked away from a company that doesn't allow walking away.",
"start_location_id": "greywater_docks",
"situation": ["Arrived by barge before dawn, hood up"],
"opening_facts": ["the player deserted the Iron Kettle mercenary company"],
"disposition_overrides": { "brannoc_thane": 40, "cadwyn_vell": 15 },
"inventory_grants": [{ "item_id": "worn_shortsword", "qty": 1 }],
"start_quest_id": "find_the_ledger",
"build_constraints": { "allowed_callings": ["sellsword","reaver","cutpurse","trapper","hedge_mage","bonesetter","bloodsworn"], "luck_modifier": 0 }
}
Fallback — content/fallback/. Degraded-DM prose (charter §13), written as
in-voice content, not error text. Every AI surface needs one before it ships.