Files
code_of_conquest_dnd/.claude/skills/world-building/references/schema.md
Phillip Tarrant a9357fb787 fix(creation-model): migrate stray class_id/allowed_classes copies
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
2026-07-12 20:58:49 -05:00

11 KiB
Raw Blame History

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

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).
  • body is public description.
  • The special entry rule.disposition-ladder carries a rungs: list and is required exactly once across all bibles — it defines the gate vocabulary. Don't duplicate or remove it; it lives in mechanics.md.

Knowledge — the atoms NPCs reveal

Namespaces rumor, fact, secret (these three id-prefixes; type matches).

  • secrecy 14 (author-facing sensitivity — see scale).
  • body is 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 entitycontent/world/canon/<slug>.json (id, type, related, public body). Nothing server-side.
  • Knowledge → client content/world/topics/<slug>.json (id, type, related — no body); server content/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); server content/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.

Questcontent/world/quests/<id>.json

{ "id": "find_the_ledger", "name": "The Missing Ledger",
  "objective": "Find who took Fenn's ledger" }

Itemcontent/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" }

Locationcontent/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." }

Origincontent/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 }
}

Fallbackcontent/fallback/. Degraded-DM prose (charter §13), written as in-voice content, not error text. Every AI surface needs one before it ships.