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:
236
.claude/skills/world-building/references/schema.md
Normal file
236
.claude/skills/world-building/references/schema.md
Normal 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` 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.<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.
|
||||
Reference in New Issue
Block a user