Author gritty, disposition-gated Margreave content (lore bible + hand-authored quest/item/location/origin JSON) that reconciles against existing canon before writing, holds the world's hard-by-default / warm-when-earned NPC tone, and keeps the content_build --check gate green. - SKILL.md: read-before-write workflow, read-only on existing canon (propose diffs, require approval), build+check+pytest as the done bar. - references/schema.md: full bible + JSON contract, secrecy scale, gate model. - references/tone.md: tone contract + NPC disposition-warmth model. - scripts/canon_index.py: dumps existing ids for reconciliation. - evals/: 3 test cases (all green in isolated-worktree runs). Un-ignore /.claude/skills/ so project skills are versioned like code; all other .claude/ dirs (root + nested api/client) stay ignored. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
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/duncarrow.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 already lives induncarrow.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 — mirror the Crell chain in duncarrow.md (1→2→2→3→4).
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 (Mera Fenn tops out at
crells-guard-acts-alone, never crell-runs-slave-trade — 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.old-teague
type: person
status: canon
secrecy: 0
related: [town.duncarrow]
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.foreign-coin-on-the-docks
type: fact
status: canon
secrecy: 2
related: [town.duncarrow, person.old-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.old-teague
type: person
status: canon
start_disposition: cold
related: [person.old-teague, fact.foreign-coin-on-the-docks]
body: >
Same man as person.old-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.foreign-coin-on-the-docks, 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.old-teague (secrecy 0, public) and npc.old-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.