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.

View File

@@ -0,0 +1,104 @@
# Tone — the Margreave
The world's voice. Distilled from charter §3 (tone), §7 (Luck), §9 (companions).
Read before writing any `body`, persona, or narrative prose. Getting this right is
most of the job — the schema is easy; the voice is the product.
## The core stance
**Gritty, not grim.** The reference is Batman in the DC comics — not Warhammer,
not Care Bears. The world has real problems and does not care about the player.
People get hangovers, vomit, curse, get infected wounds. But it is not a misery
engine. Grimdark is as wrong as cozy. The world is *indifferent*, and indifference
is harder and more interesting than cruelty.
**The world is played straight.** Comedy emerges from situation, never from the
narrator. Nothing is ever "hilariously" anything. The narrator is dry and does not
wink. When you're tempted to make prose funny, make the *situation* absurd and
describe it flatly instead.
## Register
- **Profanity** is permitted and should feel *earned*, not decorative.
- **Violence has weight.** Don't gloss it and don't wallow.
- **Sex exists** and is discussed the way adults discuss it — bluntly, and mostly
as a source of trouble.
- **Bodies are real.** Fatigue, hunger, cold, drink, injury. The world touches the
characters physically.
## The narrator voice
Dry. Economical. Observational. States what is, lets the reader feel the weight.
It never editorializes, never reassures, never jokes. A good body reads like
someone who has seen a lot and is not impressed, telling you the truth plainly.
Look at the `duncarrow.md` bodies: "prosperous and orderly place, which is exactly
the reputation its mayor has spent fifteen years building and depends on." The
menace is in the plain statement, not in adjectives.
## NPCs: hard by default, warm when earned
This is the load-bearing pattern for the user's world. **The world doesn't care
about you — but a person who comes to trust you does.** That arc is what the
disposition ladder exists to deliver, and it's what makes the coldness bearable.
### Default posture: guarded
A Margreave NPC meets a stranger with wariness, not hostility — they have their
own troubles and no reason to spend them on you. Mechanically:
- `start_disposition` is usually `cold` or `neutral`, rarely `warm`.
- A frightened or burned NPC withholds **more** than a stranger would expect, not
less (fear reads as prickliness — Mera Fenn is the model). `hostile` is a
reachable floor: push them, threaten, side against them, and they close entirely.
- What they reveal is gated. Low rungs get you surface; the personal and the
dangerous sit higher up.
### The warm turn: real, specific, earned
When the player climbs the ladder, two things change together:
1. **They reveal more** — deeper `knows` gates unlock.
2. **Their manner softens** — write the persona and `disposition_notes` so that at
`warm`/`trusted` the dryness turns to something human: fondness, relief at being
heard, blunt loyalty. Old Teague's "dryness turns to something almost fond."
The warmth is never sycophantic and never a personality transplant — a hard person
stays recognizably themselves, just no longer guarded against *you*. That's more
affecting than a switch from mean to nice.
**Don't** write an NPC who is friendly from hello (breaks the world) or one who
stays cold no matter what (makes the ladder pointless and the world hopeless). The
range between those poles is the whole design.
## The companions set the tone (charter §9)
If you write companion content, hold these exactly — they carry the register:
- **Cadwyn Vell (Bard, NPC-only):** genuinely talented, reflexively lies about
small things and truthfully about large ones, does not know he is the problem and
never becomes self-aware. Florid performing, clipped when scared. The source of
chaos.
- **Brannoc Thane (Sellsword):** dry, warm, economical, twenty years past his
prime and at peace with it. Says devastating things in the tone of a man
discussing weather. Holds the humiliation log — the callback engine. Not a grump;
*fond* of you, which is what makes it land.
- The pair dislike each other mildly and permanently, and neither will leave the
other. Cadwyn generates, Brannoc annotates. Don't add a third to the loop.
## Luck's fingerprint (charter §7)
If content touches Luck: it is **visible in prose, never in numbers.** Never state
a Luck value; render a descriptor ("Fortune spits on you"). Bad luck costs
**dignity, not progress** — embarrassment, inconvenience, property damage, social
catastrophe, minor injury. Never quest failure, permanent loss, or death. A cursed
item that grants power at a Luck cost (+STR / LCK) is the most interesting kind of
item — lean into that.
## Quick self-check before you ship a body
- Did the narrator stay dry and out of the joke?
- Is the hardness *indifference*, not sadism?
- Does the NPC start guarded, with a real warm turn available up the ladder?
- Would profanity/violence/sex here feel earned, not decorative?
- Does anything contradict an existing canon body? (If yes → reconcile, don't ship.)