Files
code_of_conquest_dnd/.claude/skills/world-building/references/schema.md
Phillip Tarrant dc4b14d3e5 fix(content): repair client consumers broken by the Duncarrow purge
The purge plan assumed content/world/** was pure build output. The client
reads it: content_db.gd loads content/world/topics/ (now absent, since every
topic was Duncarrow-generated) and its tests asserted on deleted ids.

- content_db: a missing content dir is an empty dir, not an error (§13)
- test_content_db: repoint at cosmology canon; drop deleted npc.mera-fenn
- test_content_db: assert the no-body-on-client invariant structurally, so it
  cannot pass vacuously when the topic set is empty
- schema.md: finish repointing the worked example onto specimen.md's ids
- restore a local real-bible --check smoke test (was only in CI)
- roadmap: record that generated content has client consumers

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:00:06 -05:00

9.9 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, if trusted, names who really owns the boats.

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: npc.specimen-teague
type: person
status: canon
start_disposition: cold
related: [person.specimen-teague, fact.specimen-foreign-coin]
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}

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.

Note: person.specimen-teague (secrecy 0, public) and npc.specimen-teague (the layer, persona server-only) are two entries. The fact is secrecy 2, gated at warm.

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_classes": ["sellsword","assassin","priest"], "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.