The rules a character is made of, with no screen attached. Races, callings and
skills become static tables in code; blurbs stay content (hand-written, like
items/), so the content BOM stops blocking the engine. The sheet stores only
what was rolled or chosen and derives the rest — one source of truth, and the
M5 level curve becomes a function change rather than a migration.
Creation is seeded. The creation Dictionary carries a seed, not a stat block:
construct reconstructs the RNG and rolls the attributes itself, so the same
seed yields an identical character and the UI cannot inject a number. §10's
seeding argument, applied where the races spec says it first matters.
3d6 with a hard floor of 8. Straight 3d6 puts a ~9% chance on a primary the +3
pool cannot rescue — a Hedge-Mage with a -1 MAG modifier is not grit, it is the
"fine" §7 forbids, paid for twenty hours.
Lands the two migrations the races spec said to do at M4 and not silently:
class_id -> calling_id (the contract should say what the world says) and a
required race_id, so api/app/prompts.py can render "a beastfolk cutpurse"
instead of "a sellsword".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
All seven callings are now named; no [CANON-TBD] remains in the specs.
Reaver (Barbarian). To reave is to raid — occupational, like Sellsword and
Trapper, naming what he does for money rather than what he is inside. That is
the rule the five mock names already obeyed and that §8's own list broke: a
calling is what a village calls you, not what a rulebook calls you. Bearsark
and Wildman were rejected for naming a condition; Breaker for being generic.
Bloodsworn (Warlock) was not a new coinage — the cosmology minted it a day
after the races spec was written ("what the Margreave calls bloodsworn",
rule.blood-price), and the cosmology spec had already applied it. It is the
exact FTH-axis mirror of Bonesetter: one channels the Warden, one pacts a king
of the Seven. Accepted cost, recorded in the spec: alone among the callings it
names a condition rather than a trade — the villain who pacts Ghaul is
bloodsworn too. A second, occupational word (Cursewright, Hexman) was
considered and rejected as a distinction the player will never feel.
Also retires the stale "reconcile class names" note on M4 and records the two
contract migrations that milestone owes: the canon log's `race` field, and the
deserter origin's dead allowed_classes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
Both roadmaps still described the copper currency change as pending. It is
merged. The marker moves to M4, M8's line now says what it still owes (the
price table — nothing is priced yet), and a "Landed out of band" section
records the two items that shipped outside a milestone: the currency value
type, and the once-per-reply move cap its review turned up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
A move applies at most once per reply. Repeating a tag let the model pick an
amount in unary (§2 breach); the currency-only guard is generalized and deleted.
adjust_disposition's MAX_DELTA now caps the reply's net swing, not each tag.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
The tag extractor returns every occurrence of a move tag and the validator is
a pure membership test, so MoveApplier applied duplicates. A model repeating a
tag N times therefore picked an amount in unary — the AI choosing a number,
which is the §2 breach the bounded vocabulary exists to prevent.
The currency fix (8e1238c) closed this for denominations only. Generalizing it
to every move that names a thing covers real items too — three give_item(amulet)
tags are one amulet, not three — and lets the currency special case be deleted
rather than kept alongside a second mechanism. ID_MOVES is reused from
MoveValidator instead of restated.
Also caps adjust_disposition on the reply's NET swing. MAX_DELTA was clamped
per tag, so three +15s moved standing by 45 — precisely the wholesale swing its
own comment says it forbids. Applied in emission order, so a later
become_hostile still floors what came before it.
offer_quest and reveal needed no change: add_quest self-guards on has_quest and
mark_revealed is idempotent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
Money becomes copper-based with real denominations (1g = 100s = 10,000c),
stored as one integer and formatted only at the display edge.
Money is not an inventory item: give_item/accept_item carry no quantity and
gifts_given is a global one-shot, so a single 'copper' id would be givable
once, for one copper, per campaign. The denominations are move tokens instead
— give_item(gold) is +10,000c — and currency routes to the purse, never the
inventory dict, so it cannot surface in an inventory grid by construction.
Prices remain content and remain M8.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
The existing test varied the move name and the denomination together, so a
name-only or denom-only key would have passed it too. Two assertions nail it:
give_item(gold)+give_item(silver) kills a name-only key, and
give_item(gold)+accept_item(gold) kills a denom-only key.
The spec now records the per-reply ceiling as a decision rather than an
accident, and flags the pre-existing non-currency duplicate-tag path as open.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
TagExtractor collects every [MOVE:] tag occurrence with no dedupe, and
MoveValidator is a pure membership test against available_moves, so N
copies of one legal move all pass validation. MoveApplier then looped
and applied every copy. A model could therefore pick an arbitrary
amount of money in unary by repeating one tag: [MOVE: accept_item(copper)]
x47 against a 47c purse drains it to 0; [MOVE: give_item(gold)] x10
mints +100,000c. The eight-move vocabulary (§6) takes no quantity
argument precisely so the AI cannot choose a number — this closed that
gap by another door and was a §2 breach (AI owns text, never state).
MoveApplier.apply() now tracks currency moves (give_item/accept_item on
a denomination) already applied within one call, keyed on name+denom,
and skips repeats. Non-currency moves are untouched — repeated
give_item(amulet) still yields multiple copies, which stays bounded by
the pre-existing cross-reply gifts_given gate and is a separate,
already-reported issue. move_validator.gd and tag_extractor.gd are
untouched.
Also adds the Finding-2 test the currency spec (§7) named but never
delivered: after GameState.grant("gold", 1), inventory stays empty, so
accept_item(gold) in available_moves comes only from the affordability
loop, never double-offered via the inventory.keys() loop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
The seed placeholder was a flat 1240 gold — 12.4 million copper under the real
model, which is 1,240 plot points in the command bar. It reseeds to 347c, a
party carrying silver and no gold: '◈ 3s 47c'.
This is the only call site of Currency.format() — the display edge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
The three denominations are move tokens, not stored buckets — there is one
integer, so change-making never arises. The deserter is down to 47c: under a
silver, so he cannot cover a bed and the player sees it on turn one.
A parity test guards the code-side ratios against the content-side ids.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
give_item(gold) is +10,000c and does NOT mark gifts_given — that gate is a
global one-shot keyed by item id, so marking money would let an NPC pay you
exactly once per campaign. accept_item(<denom>) is offered only for the
denominations the purse can actually pay.
The eight moves do not grow. MoveValidator is untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
One int holds all money. grant/take are the sole routing point — currency ids
go to the purse, everything else to inventory. Money is never an inventory row.
The 'coin' stand-in in the two unrelated inventory tests becomes
worn_shortsword; they were never about money.
One integer, stored in copper. The ratios are rules and live in code; prices
are content and land in M8. format() suppresses empty denominations so being
broke reads as '47c', not '0g 0s 47c'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
Money stops being a flat `coin` and becomes copper, stored as a single
int, formatted only at the display edge.
The approved model (1g = 100s = 10,000c, store everything in copper) said
money should stay an inventory item under one `copper` id. It can't. §6 is
what kills it: `give_item(x)` adds exactly 1 and carries no quantity, and
`gifts_given` is a global one-shot keyed by item id — so a single `copper`
id would be givable once, for one copper, for the whole campaign.
So the purse is one int on GameState, and the three denominations are
content items that exist only to let a bounded move name a unit —
give_item(gold) is +10,000c. Three ids, not three buckets: the
change-making problem never arises because there is only ever one number.
A quantity arg on the move was rejected — it hands the model an unbounded
integer to invent, which is the §2 line.
Currency routes to the purse and never enters `inventory`, so it can't
appear in an inventory grid by construction. No filter needed.
Prices remain content and remain M8.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
Implements the docs restructure half of the content-track design (the
currency item is code and stays unbuilt). Content stops being unbounded:
the work is now a countable checklist with an end.
content/roadmap.md — new. Three sections, only one of which is ever the
work:
Spine Greywater Docks + one dungeon, patron Ghaul the Yoke; the
cosmology touching ground. Plus what is already canon.
BOM ~57 discrete pieces for a complete, playable game, per
milestone. Numbers, not vibes.
Backlog Everything the slice does not need. Not a debt — where ideas
go to be safe.
Its rule: never author anything that is not in the Bill of Materials.
The BOM carries a Fixed/Variable column — the seven-campaign saga spec's
only ask of this table. ~37 fixed / ~20 variable: a second town costs the
20, not the 57. That is also the exact split v2 generation would need.
content/lore/canon-roadmap.md becomes a pointer (older specs and plans
reference the old path). An items-and-prices table does not belong under
lore/.
docs/roadmap.md — M4..M9 each gain a "Content it consumes →" line into
the BOM.
world-building skill — SKILL.md and evals.json now point at
content/roadmap.md, and the skill is told to respect the BOM rule: ideas
that arrive during authoring go to the Backlog, not into the world.
Build + --check green; generated content/world and content/server are
byte-identical. No Python touched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Applies the seven-campaign saga direction spec (021c248) to the docs it
was written to protect. No new milestone, no new content, no BOM line.
docs/roadmap.md — a "Guarded direction" section, and a ⛨ saga note on the
four milestones that would otherwise foreclose the shape:
M3 don't hardcode the title flow so a saga entry path can't be added
M4 keep construct()'s `creation` a plain Dictionary; the scene must not
become its only producer
M5 the level curve is no longer open — L20 across 7 tiers; enemy stat
blocks carry tier; no scaling multiplier, ever
M9 the save is two objects (Saga + Campaign); humiliations move to the
Saga or they die with the campaign
races-and-classes spec — the deferred "XP curve → M5" line now inherits the
ceiling rather than choosing one.
canon-roadmap — what the saga asks of content: a tier field when the
item/quest namespaces land, and a fixed-vs-variable mark on each BOM line
when the BOM is written. Tiers 2-7 and the other six kings go to the
Backlog.
Content build --check green; no generated content touched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A direction spec: one character, seven campaigns (one per demon king),
one saga — then it ends. The character, companions, deeds and level carry
between campaigns; the world does not.
Deliberately adds no milestone and no BOM line. Its real payload is a set
of guardrails on milestones that are about to be built blank and would
otherwise foreclose the shape:
- M5 owes a level curve — design it to a ceiling of 20 across 7 tiers,
with tiered enemy stat blocks and never a scaling multiplier.
- M9 owes a save format — two objects (Saga + Campaign), not one blob.
Humiliations move to the Saga or they die with the campaign.
- M3/M4 — don't foreclose a saga entry path; don't couple construct() to
the creation scene.
- Build tool — add tier:1..7 when the item/quest namespaces land.
Difficulty is fixed at world-construction time from the campaign index,
never during play. That prohibition is the whole design; a world that
re-levels with the player is a treadmill.
The canon-log contract does not change: party[].disposition,
humiliations[] and established_facts[] already exist, and player still
admits no number. The Saga becomes the source; the canon log a projection
of it. The seam is one optional fourth arg to NewGame.construct().
Greywater at tier 1 under Ghaul is, unintentionally, already campaign 1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Content (lore, economy, items, quests, map) was untracked and unbounded.
Bound it: a second roadmap (content/roadmap.md) with Spine / Bill of
Materials / Backlog, demand-driven by the milestone that consumes it.
- Vertical slice = one town + one dungeon (adopt + deepen Greywater).
- Ghaul the Yoke is the ledger chain's patron, so the quest chain and
the cosmology are one object, not two.
- Economy splits per §18: the loop is code (M8), the numbers are content.
Currency = 1g/100s/10,000c, copper as the stored atom; lands before M7.
- Build tool needs `item` + `quest` namespaces; locations already exist.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the GitHub Actions workflow (repo is on Gitea; it never ran). The
--check gate now lives in pytest (test_real_content.py), and the world-building
skill's verify step states there is no CI and adds the client suite — since
content/world/ is a client input, not just build output.
The repo lives on a Gitea server and CI/CD is a long way off, so
.github/workflows/content-build.yml never ran. A workflow nobody executes is
worse than none: it read as a safety net that wasn't there.
Removing it moves the `--check` gate somewhere real. It is now enforced by
tools/content_build/tests/test_real_content.py, which runs check() against the
live bible under pytest — so the gate is a test you actually run, not a job
that never fired.
The world-building skill's verify step now says plainly that nothing runs these
for you, and adds the client suite to the checklist: content/world/ is a CLIENT
INPUT (content_db.gd loads it, test_content_db.gd asserts on it), so a content
change can break the client. That is exactly what the Duncarrow purge did, and
with no CI there is nothing else to catch it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Delete the Duncarrow scaffolding; preserve the infrastructure it carried.
- rule.disposition-ladder reparented into content/lore/mechanics.md (required
exactly once by the build; moved atomically with the deletion)
- content/lore/specimen.md: synthetic status=candidate disposition-gate specimen
(validated by --check, emitted nowhere), teaching both an earned warm reveal
and a never-gated core secret
- duncarrow.md + 23 generated JSON + test_duncarrow.py removed
- world-building skill repointed: structure -> specimen.md, voice -> cosmology.md;
3 evals rewritten cosmology-aware
- repaired the client consumers the purge broke: content_db no longer errors on
an absent content dir, and the no-body-on-client secrecy guard is asserted
structurally so it cannot pass vacuously
--check exit 0, pytest 45, client GUT 149/149.
The purge left `gate: never` — the rung no disposition unlocks — demonstrated
nowhere. Duncarrow's Crell chain had taught it; specimen.md only taught `warm`.
Adds secret.specimen-drowned-clerk (secrecy 4, gate: never), so the specimen
now shows an ascending 2 -> 4 chain and a reason `never` exists: the NPC holds
it, but saying it aloud would hang him. Mirrored in the skill's worked example,
which now states the rule — if any rung *should* buy the secret, gate it at
`trusted` instead.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The purge plan assumed content/world/** was pure build output. The client
reads it: content_db.gd loads content/world/topics/ (now absent, since every
topic was Duncarrow-generated) and its tests asserted on deleted ids.
- content_db: a missing content dir is an empty dir, not an error (§13)
- test_content_db: repoint at cosmology canon; drop deleted npc.mera-fenn
- test_content_db: assert the no-body-on-client invariant structurally, so it
cannot pass vacuously when the topic set is empty
- schema.md: finish repointing the worked example onto specimen.md's ids
- restore a local real-bible --check smoke test (was only in CI)
- roadmap: record that generated content has client consumers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move rule.disposition-ladder into content/lore/mechanics.md and delete the
Duncarrow scaffolding (source + 23 generated JSON). Remove the redundant
test_duncarrow.py (routing/secrecy already covered by test_emit.py synthetic
fixtures); repoint a cosmetic slug-test id.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
Delete the Duncarrow scaffolding while preserving the schema/disposition-gate
infrastructure it carries: reparent rule.disposition-ladder into a new
mechanics.md, stand up a synthetic status:candidate specimen (validated but
never emitted), repoint the skill/tests/evals. Zero real game content; the
in-flight NPC redesign and the hand-authored POC seed JSON stay out of scope.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
Overarching cosmology: the Warden (distant good god), the Sent, and the
Seven demon kings, authored as canon; world-building skill updated to
reconcile all future content against the backdrop (incl. FTH-axis
Bonesetter/Bloodsworn calling mapping). Duncarrow decoupled as
scaffolding, not removed. Build --check exit 0, pytest 47/47.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
- faction.the-seven now relates down to all seven lords (spec §4 intent;
bidirectional discoverability), regenerated the-seven.json.
- canon-roadmap: clarify Bloodsworn resolves the warlock's former [CANON-TBD] name.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
Adds the FTH-axis calling mapping to the cosmology spec: Bonesetter
channels the Warden, the Bloodsworn (names the [CANON-TBD] warlock
calling) pacts one of the Seven, Hedge-Mage is a pointed outsider,
martials unaligned. Luck-immunity intent kept as flavor and flagged for
the M5/Improviser reconcile (§7-sensitive), not built here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
Establishes the good-god/seven-demon-lords cosmology as the game's
overarching content theme, mapped onto the content schema as canon
entities, with the world-building skill updated to reconcile all future
authoring against it. Duncarrow scaffolding decoupled now, purged in a
follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
The scripts were tracked but their .gd.uid siblings weren't. Godot 4.4+ .uid
files pin each script's uid://, so leaving them out lets each clone regenerate
different uids and break uid:// references in scenes/resources. Matches the 159
.uid files already tracked in the repo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
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
Lock the NPC identifier: full namespaced id (npc.mera-fenn) is canonical
everywhere — stored data, client dict key, and the /npc/speak wire. The API
load_npc changes from a filename lookup to an id-indexed load (matching the
client's ContentDB), reads persona from content/server, and resolves the
client's reveal-set fact_ids to topic bodies. Legacy Fenn keeps working; no
migration required. First task of the runtime-consumption cycle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
Content & canon build tool: compiles content/lore/*.md (Markdown bible +
fenced YAML) into validated client (content/world) + server-only
(content/server) JSON, split by role so spoilers/personas never ship in the
client. Validation gate aborts on schema violations; --check is the CI
freshness/validity gate. Promotes Duncarrow as the acceptance test (secret
body proven server-only). ContentDB loads the new canon/ + topics/ dirs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
- --check now detects orphaned/stale build artifacts in build-owned dirs
(client canon/ + topics/ fully owned; client npcs/ shared with legacy
hand-authored files, only npc.* ids are treated as build-owned)
- resolve() enforces the schema rule that canon entities must be secrecy 0
- resolve() and ladder_rungs() guard against malformed value TYPES (non-int
secrecy, non-list related/rungs) raising BuildError instead of crashing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ