# 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`)](#the-bible-file) - [Common fields (every bible entry)](#common-fields) - [Entry kinds](#entry-kinds) - [The secrecy scale](#the-secrecy-scale) - [The knowledge-gate model](#the-knowledge-gate-model) - [Client vs server routing](#client-vs-server-routing) - [Worked example](#worked-example) - [Hand-authored JSON (not built)](#hand-authored-json) --- ## 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` 1–4 (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.`, `type: person`. This is a SEPARATE entry from the `person.` 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: , gate: }`. 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/.json` (id, type, related, public body). Nothing server-side. - **Knowledge** → client `content/world/topics/.json` (id, type, related — **no body**); server `content/server/topics/.json` (id, body, secrecy). - **npc.\*** → client `content/world/npcs/.json` (id, type, start_disposition, related, knows+gates — the gate logic the client needs); server `content/server/npcs/.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. ```yaml 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. ``` ```yaml 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. ``` ```yaml 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/.json` ```json { "id": "find_the_ledger", "name": "The Missing Ledger", "objective": "Find who took Fenn's ledger" } ``` **Item** — `content/world/items/.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. ```json { "id": "worn_shortsword", "name": "a worn shortsword", "slot": "weapon" } ``` **Location** — `content/world/locations/.json`. Map nodes; an origin's `start_location_id` resolves here. ```json { "id": "greywater_docks", "name": "the Greywater docks", "description": "A rot-black wharf where the river meets the sea trade." } ``` **Origin** — `content/origins/.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. ```json { "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.