Files
code_of_conquest_dnd/.claude/skills/world-building/references/schema.md
Phillip Tarrant b15bd2bb1e feat(skills): add project-local world-building skill
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
2026-07-12 11:15:34 -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/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).
  • 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 already lives in duncarrow.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 — 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 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.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.

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.