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
This commit is contained in:
2026-07-12 11:15:34 -05:00
parent a3506f7f02
commit b15bd2bb1e
6 changed files with 643 additions and 1 deletions

View File

@@ -0,0 +1,236 @@
# 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/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 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**); 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.
```yaml
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.
```
```yaml
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.
```
```yaml
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`
```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.
```json
{ "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.
```json
{ "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.
```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.