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