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,137 @@
---
name: world-building
description: >
Author gritty, dark, disposition-gated world content for Code of Conquest (the
Margreave). Use this WHENEVER the user wants to write, expand, or add lore,
storylines, plot threads, secrets, factions, regions, towns, dungeons, NPCs and
their knowledge/dialogue, quests, items, locations, or starting origins — even
if they don't say "world-building" or name the content/ folder. Triggers on
"write an NPC", "add a town/region/faction", "new quest", "a secret the party
can uncover", "expand the Tallow Reach", "flesh out Duncarrow", "someone the
player meets on the road", "cursed item", "give me a storyline", and the like.
The skill reconciles new content against existing canon (never contradicting or
silently changing it), writes it in the exact source format the content build
tool consumes, keeps NPCs true to the world's hard, uncaring tone (warm only
when disposition is earned), and verifies the build stays green. Do NOT use it
for engine/UI code, prompts (/api/prompts), or combat rules.
---
# World-building — the Margreave
Author new content for **Code of Conquest**, a gritty single-player CRPG. Read
`CLAUDE.md` (repo root) once per session for the charter — this skill assumes it.
## The one rule that governs everything you write
> **Code owns state. AI owns text.** (charter §2)
You are authoring *static content*, not runtime state. Facts, personas, places,
and knowledge chains are text the engine references by stable **id**. You never
write initial disposition numbers into world content, never gameplay logic. A
starting *value* (where the player begins, a seeded disposition) belongs to an
**origin**, never to a town or NPC.
## The two things that make this hard
1. **New content must not contradict old content.** The build tool checks
structure (ids resolve, secrecy routes correctly) but it CANNOT tell that you
just described the north road as untraveled when canon says elves walk it. A
contradiction ships silently and the DM narrates a broken world. So:
**read the existing canon before you write a word**, and treat every existing
file as read-only unless the user explicitly approves an edit (see below).
2. **The tone is specific and easy to get wrong.** Gritty, not grim. The world
is hard and does not care about the player — but an NPC who has come to trust
them becomes genuinely warm. Getting either pole wrong (grimdark misery, or
friendly-NPC-from-hello) breaks the world. See `references/tone.md`.
## Workflow — follow in order
Make a todo per step.
### 1. Understand what's being asked, and which layer it lives in
| Content kind | Format | Lives in |
|---|---|---|
| People, places, regions, factions, world rules, knowledge (rumor/fact/secret), NPC dialogue layers | **Markdown bible**` ```yaml ` blocks | `content/lore/*.md` (source) |
| Quests / story skeletons | Hand-authored JSON | `content/world/quests/` |
| Items (incl. cursed/blessed) | Hand-authored JSON | `content/world/items/` |
| Locations (map nodes) | Hand-authored JSON | `content/world/locations/` |
| Origins (starting seeds) | Hand-authored JSON | `content/origins/` |
| Degraded-DM fallback prose | Markdown/text | `content/fallback/` |
The **lore bible is the heart** — it produces the disposition-gated dialogue that
makes NPCs feel alive. `content/world/` and `content/server/` under `canon/`,
`topics/`, and `npcs/` are **BUILT from the bible — never hand-edit them.** The
JSON under `quests/`, `items/`, `locations/`, and `origins/` is hand-authored and
loosely schema'd (POC seeds); edit those directly.
If a storyline you're writing implies a quest, item, or location that doesn't
exist yet, say so and author the stub, or flag it — don't leave a dangling id.
### 2. Load and reconcile against existing canon — before writing
Run the canon index to see every id, type, secrecy, and one-line gist already
authored:
```
.venv/bin/python .claude/skills/world-building/scripts/canon_index.py
```
Read the entries your new content touches (people it references, the region it
sits in, secrets it brushes against). New content must slot into this without
contradiction. Reuse existing ids in `related:` rather than inventing parallel
ones. If your idea only works by *changing* something already canon — a fact's
body, a disposition, who knows what — **STOP.** Do not edit. Present the exact
before/after change and the reason, and get explicit approval first. New files
and new entries need no approval; edits to existing canon always do.
### 3. Draft in the correct format
- **Bible entries:** see `references/schema.md` for the full field-by-field
contract, the id/namespace/type rules, the secrecy scale, and the knowledge-gate
model. Match the voice and structure of `content/lore/duncarrow.md` — it is the
reference specimen.
- **Hand-authored JSON:** mirror the shape of the existing file in that folder
(`references/schema.md` documents each). Keep ids stable and lowercase-snake.
Write NPCs to the tone and disposition-warmth model in `references/tone.md`. A
hard NPC starts guarded (`cold`/`neutral`, sometimes reachable-`hostile`),
withholds more than a stranger would expect, and both *reveals more* and *softens
in manner* as the player climbs their ladder. That warmth is earned, never
default — it's what the five-rung gate system exists to deliver.
### 4. Build and verify — not done until green
The build regenerates `content/world/` + `content/server/` from the bible and the
`--check` gate is what CI enforces (`.github/workflows/content-build.yml`). Run,
from repo root, with the repo venv active (`source .venv/bin/activate`) — the
`content_build` package lives under `tools/`, so `PYTHONPATH=tools` is required:
```
PYTHONPATH=tools python -m content_build # regenerate world/ + server/ from lore/
PYTHONPATH=tools python -m content_build --check # must exit 0: fresh, valid, no orphans, no secrecy leaks
PYTHONPATH=tools python -m pytest tools/content_build/tests -q # schema + secrecy + cli coverage
```
(If `python` isn't found, the venv isn't active — use `.venv/bin/python`.)
If any step fails, read the `BUILD FAILED` / `STALE:` message — it names the
source file and line — fix the bible, rebuild, recheck. Never hand-edit the
generated JSON to make `--check` pass; fix the source. (Hand-authored JSON under
quests/items/locations/origins isn't built — the build tool ignores it. If you
touched those, just confirm they're valid JSON.)
### 5. Report
Summarize: new ids added (by layer), any existing id you reused in `related`,
anything you flagged for approval, and confirmation the build + check + tests are
green. Update `content/lore/canon-roadmap.md`'s Authored/Pending lists if you
added a new bible file or closed out a pending thread.
## Reference files
- `references/schema.md` — the complete bible + JSON schema, all validation rules,
the secrecy scale, the knowledge-gate model, worked examples. Read before
authoring bible entries.
- `references/tone.md` — the Margreave's tone contract and the NPC
disposition-warmth model. Read before writing any NPC or narrative body.

View File

@@ -0,0 +1,46 @@
{
"skill_name": "world-building",
"evals": [
{
"id": 1,
"name": "new-npc-reconciled",
"prompt": "Add a new NPC to Duncarrow: a tavern keeper on the north road who's cagey with strangers but opens up once you've earned it. Give them something personal they only share when they trust you, and tie them into what's already going on in town.",
"expected_output": "New npc.* dialogue-layer entry (+ any new person/knowledge entries) authored in content/lore; start_disposition cold or neutral; at least one fact gated at warm/trusted; related wired to existing Duncarrow ids; no existing lore file edited; PYTHONPATH=tools content_build --check exits 0; tone dry and guarded-then-warm.",
"assertions": [
"Created at least one new npc.* entry in content/lore with start_disposition of cold or neutral",
"The NPC's knows list gates a personal fact at warm or trusted (earned reveal)",
"New entry's related list references at least one pre-existing Duncarrow id",
"No pre-existing content/lore/*.md file was modified (or any edit was surfaced for approval, not silent)",
"python -m content_build --check exits 0 after the change",
"Persona/body prose is dry and in-tone; no narrator winking or 'hilarious' register"
],
"files": []
},
{
"id": 2,
"name": "contradiction-approval-gate",
"prompt": "I want the town militia to actually be honest and investigate the disappearances near the shrine. Rewrite it so the militia does ride out.",
"expected_output": "Skill recognizes this contradicts existing canon (fact.militia-never-investigates, which is load-bearing for the Crell secret chain), STOPS, and presents the exact before/after change with the reason, asking for approval instead of silently editing the existing fact.",
"assertions": [
"Did NOT silently edit fact.militia-never-investigates or any existing lore entry",
"Surfaced the contradiction explicitly, naming the affected canon and why it matters (Crell chain)",
"Presented a concrete proposed change and asked for approval before writing"
],
"files": []
},
{
"id": 3,
"name": "new-storyline-file",
"prompt": "Start a new storyline about the elven road-peoples beyond Duncarrow: who they are, a faction or two, and a rumor the player might hear about why they've stopped using the pass. Make it its own thing.",
"expected_output": "A new content/lore/*.md bible file with canon entities (faction/region/etc.) and at least one knowledge entry; related wired to the existing faction.elves stub; build + --check green; canon-roadmap Pending/Authored updated.",
"assertions": [
"Authored a new content/lore/*.md file (did not cram into duncarrow.md)",
"Wired at least one new entry's related to the existing faction.elves id",
"Includes at least one knowledge entry (rumor/fact/secret) with correct ascending secrecy",
"python -m content_build --check exits 0 after the change",
"content/lore/canon-roadmap.md updated to reflect the new content"
],
"files": []
}
]
}

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.)

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Print an index of every authored entry across the lore bible + hand-authored
JSON, so new content can be reconciled against existing canon BEFORE writing.
Reads the same ```yaml blocks the content build tool parses (no dependency on the
build package — pure stdlib + PyYAML, which the repo already needs). Run from the
repo root:
python .claude/skills/world-building/scripts/canon_index.py
Output groups bible entries by kind (canon entity / knowledge / npc layer) with
id, status, secrecy, and a one-line gist, then lists hand-authored quest/item/
location/origin ids. Use it to spot id collisions and facts your new content must
not contradict."""
import json
import sys
from pathlib import Path
try:
import yaml
except ImportError:
sys.exit("PyYAML not installed; run: pip install pyyaml")
REPO = Path(__file__).resolve().parents[4]
LORE = REPO / "content" / "lore"
WORLD = REPO / "content" / "world"
ORIGINS = REPO / "content" / "origins"
CANON_TYPES = {"town", "place", "region", "faction", "person", "rule"}
KNOWLEDGE_TYPES = {"rumor", "fact", "secret"}
def parse_bible(path: Path):
"""Yield yaml-block mappings, mirroring tools/content_build/parse.py."""
lines = path.read_text().splitlines()
i, n = 0, len(lines)
while i < n:
if lines[i].strip() == "```yaml":
i += 1
block = []
while i < n and lines[i].strip() != "```":
block.append(lines[i])
i += 1
data = yaml.safe_load("\n".join(block))
if isinstance(data, dict):
yield data, path.name
i += 1
def gist(text) -> str:
if not text:
return ""
one = " ".join(str(text).split())
return one[:100] + ("" if len(one) > 100 else "")
def main() -> int:
if not LORE.is_dir():
return print(f"no lore dir at {LORE}") or 1
entities, knowledge, npcs = [], [], []
for md in sorted(LORE.glob("*.md")):
for d, src in parse_bible(md):
eid = d.get("id", "?")
ns = str(eid).split(".", 1)[0]
row = (eid, d.get("status", "?"), d.get("secrecy", "?"),
gist(d.get("body")), src)
if ns == "npc":
gates = ", ".join(f"{k.get('fact')}@{k.get('gate')}"
for k in d.get("knows", []))
npcs.append(row + (d.get("start_disposition", "?"), gates))
elif ns in KNOWLEDGE_TYPES:
knowledge.append(row)
else:
entities.append(row)
print("=== CANON ENTITIES (public; body ships client) ===")
for eid, st, sec, g, src in sorted(entities):
print(f" {eid:34} [{st} sec{sec}] {src}\n {g}")
print("\n=== KNOWLEDGE (rumor/fact/secret; body server-only) ===")
for eid, st, sec, g, src in sorted(knowledge):
print(f" {eid:34} [{st} sec{sec}] {src}\n {g}")
print("\n=== NPC DIALOGUE LAYERS ===")
for eid, st, sec, g, src, start, gates in sorted(npcs):
print(f" {eid:34} [{st} start:{start}] {src}\n persona: {g}\n"
f" knows: {gates}")
def list_json(label, d):
print(f"\n=== {label} (hand-authored JSON) ===")
if not d.is_dir():
print(" (none)")
return
for f in sorted(d.glob("*.json")):
try:
o = json.loads(f.read_text())
name = o.get("name") or o.get("display_name") or ""
except (OSError, ValueError):
name = "(unreadable)"
print(f" {o.get('id', f.stem):28} {name}")
list_json("QUESTS", WORLD / "quests")
list_json("ITEMS", WORLD / "items")
list_json("LOCATIONS", WORLD / "locations")
list_json("ORIGINS", ORIGINS)
return 0
if __name__ == "__main__":
raise SystemExit(main())

9
.gitignore vendored
View File

@@ -60,4 +60,11 @@ Thumbs.db
# ─────────────────────────────────────────────
# Claude Code harness session/state files
# ─────────────────────────────────────────────
.claude/
# Ignore every .claude/ dir anywhere in the tree...
**/.claude/
# ...but keep the repo-root .claude/skills/ dir: project-local skills are
# checked-in tooling, versioned like code. (Re-include the root dir, drop its
# other contents, then re-include skills/.)
!/.claude/
/.claude/*
!/.claude/skills/