test_committed_tres_matches_builder claimed to catch a builder/artifact
drift in game_theme.tres, but its checks array hardcoded exactly three
variation/state pairs (PrimaryCTA, ParchmentCard, DarkPanel) checking only
bg_color. Every other variation — including all five added for the
creation screen (SelectCard, SkillChip, ChosenTag, PrimaryTag,
PrimaryCard) — could drift from the builder silently, and this test would
keep passing on stale data. Textbook docs/traps.md failure: a test that
cannot fail against the bug it names.
Rewrote it to derive checks from ThemeKeys.ALL: for every variation,
every stylebox state the builder actually set is compared (bg_color,
border_color, border widths, corner radii for StyleBoxFlat), and every
font colour role the builder actually set is compared too. Failures name
the variation and the drifted property.
Verified by deliberately drifting SkillChip's pressed stylebox in
build_game_theme.gd without regenerating the .tres: the new test failed
and named SkillChip/pressed/bg_color+border_color; the old three-pair
version would not have caught it (SkillChip was never in its list).
Reverted the drift; suite is green at 279 again. No pre-existing drift
found in the committed artifact.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SelectCard (a Button whose `pressed` IS the chosen ring), SkillChip (a toggle
with unpicked/picked/granted-so-inert), the CHOSEN and PRIMARY tags, and the
gold PrimaryCard ring. Plus INK_LABEL_MUTED for the sheet's section headers.
Built by build_game_theme.gd and regenerated; the .tres is never hand-edited.
CreationDraft's docstring promised it owns no rules—it delegates to NewGame.validate.
But it kept a local const POOL := 3 that mirrored NewGame.SPEND_POOL, inviting
silent disagreement if the source was changed and the copy forgotten.
Delete POOL. Use NewGame.SPEND_POOL directly in points_left(), with a comment
making it clear this is the single authority. The coupling is now enforced by
code structure, not by hope.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pure, node-free, headlessly testable. Carries a seed, not a stat block. Owns no
rules of its own: errors() delegates to NewGame.validate.
The interaction rules that make creation screens buggy are pinned by test:
changing calling clears the picks; changing race drops a pick the new race now
grants (the elf/cutpurse case); re-roll clears the spend and only the spend;
minus floors at the roll; the fourth point does not exist.
test_calling_detail_reads_the_table_rather_than_repeating_it checked for
str(Callings.skill_count(id)) — bare digit "2" — which appeared in d12 for
the Reaver (hit_die=12, skill_count=2). Hardcoding "picks 3 skills" in the
card would pass the test, satisfied by the "2" in "d12".
Now anchors to "picks %d skills" format string, so the guard catches when
the card's hardcoded count diverges from the table.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Display strings derived from Callings/Races/Skills at call time. Retune a hit
die and the card follows, because the card never knew the number. Prose stays in
content; numbers stay in code; nothing is written twice.
Deviation from the plan, approved by the human: the plan's test asserted the
rendered line contains Callings.armor(id) — the RAW table value — while the plan's
own ARMOR_WORD maps "none" to "no armour". "none" is not a substring of "no armour",
so the Hedge-Mage failed. The card's copy is what the plan's docstring example shows,
so the copy stands and the test was fixed: armor_word(id) is now public and the test
asserts against the derived word. The guard is unchanged — hardcode an armour string
and retune the table, and it still goes red.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fragment's closing clause — "you have stopped claiming to know whether
it is your hands or the Warden that does the saving" — restated the thesis
of the blurb the player is reading at the same moment, inches above it in
the calling detail panel. The blurb's whole argument already IS the
unresolved-Warden question: it states it outright ("distant and does not
explain himself") and dramatises it with its doubled "Often enough." The
fragment took that artful implicit doubt, said it flat, and put it in the
emphatic final position of the composed DM line. Different words, same beat.
It also collided lexically with the beastfolk race fragment, which the
composition can place directly before it: "You stopped taking that
personally" / "you have stopped claiming".
Keep the front clause — "you are the one they wake at the third hour" is the
best image in the set and gives the player a position in a village that
nothing else on the screen supplies. Rewrite the back clause to say the next
thing instead of the same thing: what the failed nights cost, and that the
job does not release you for failing. The fragment no longer mentions the
Warden at all, so it cannot re-argue him; it now makes a claim about the
player's life rather than about a god.
Also avoids "carry", which the composition template itself supplies ("Now
you carry a Bonesetter's work"), so the rendered sentence no longer risks
repeating its own verb.
Client suite 255/255. content_build --check exit 0.
The creation screen shows a race's blurb on its card and the chosen calling's
blurb in the detail panel directly above the DM panel. Blurb and fragment are on
screen at the same moment, inches apart — so a fragment that paraphrases its own
blurb makes the DM sound like it is reciting the card. Four did:
- bonesetter lifted two clauses verbatim ("you set the bone, you say the words")
- reaver re-ran the blurb's door image with the same sentence shape
- hedge_mage re-opened on "what you know" and re-landed the dead teacher
- beastfolk repeated both the claws and the being-judged beat
Each now says the NEXT thing instead: the hour they wake you and which of the
two trades you will not claim credit for; the employer who arranges to be
elsewhere; where you went to fail safely and what the book's gaps cost your
skin; the town that takes your coin and still wants you outside the walls.
beastfolk's rewrite also breaks the race-set monotony — it no longer opens "You
were born" (now unique to human) and drops the shared "X, and you have…" spine,
so clicking the four cards in a row stops reading as one voice.
The parity test guarded presence but could not fail on the three properties that
actually make all 28 race×calling concatenations grammatical. It now asserts
race fragments start uppercase and end with '.', and calling fragments start
lowercase and end with '.' — verified by breaking both and watching all four new
assertions fire.
255 client tests green; content build --check green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
race.fragment + calling.fragment compose the DM origin panel on the creation
screen. Authored prose, not stubs — this is the first sentence the player reads.
Parity-tested: a missing or empty fragment fails the suite.
roll_attributes(seed) is now the single implementation of the die — construct
calls it, and the creation screen (M4-b) will call it to DISPLAY the roll. So
the screen shows real numbers while still only ever handing over a seed (§2).
validate() goes public so the screen can gate its CTA on the real rules.
Neither takes a default parameter (traps.md #2).
TDD task-by-task: NewGame's public seeded roll -> the eleven fragments ->
CreationCopy -> CreationDraft -> the theme -> the scene -> the docs.
Both of traps.md's bugs get a named guard rather than a hope: the pipeline test
that asserts what the screen SHOWED is what construct BUILT (and a step that
re-breaks the code to prove the test can fail), and a no-default-parameters rule
on the two new public functions.
Also corrects the spec: PrimaryCTA already has a disabled stylebox.
The mock's screen over M4-a's proven model. Five mock/model reconciles
recorded: the skills section the mock never drew, seven callings not five,
nameplate cards with prose on selection, a minus button, no portrait arrows.
No AI call anywhere — the DM's entrance is the shell, one screen later.
The screen shows the roll by calling the same pure roll_attributes(seed)
construct does, so it displays numbers without ever handing one over.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Six bugs this project shipped into a branch, what let each through, and the
guard that now catches it. They share a shape: the test passed, and the test
was wrong.
- a regression test that constructed its own correct object, so reverting the
bug left the suite green
- GDScript default params turning an arity change into a silent miscompile,
invisible to a grep for the renamed field (grep cannot see positional args)
- a determinism test comparing two runs that would change together
- a closed move vocabulary that was not bounded, because the model could repeat
a tag and choose an amount in unary
- a roster written down in four places with only one pair guarded — including a
shipped authoring skill that would have emitted origins allowing zero callings
- generated content that has a client consumer
Linked from CLAUDE.md §18 and docs/README.md. Also records M4-a on the roadmap
with its live end-to-end proof.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
The character-creation model: race/calling/skill mechanics as static tables in
code, their blurbs as content, a CharacterSheet that stores only what was rolled
or chosen and derives the rest, and a seeded NewGame.construct.
Creation carries a seed, not a stat block — construct rebuilds the RNG and rolls
the attributes itself, so it never trusts a number handed to it (§2) and the same
seed yields an identical character, hidden Luck included (§10). A golden-vector
test pins the RNG stream. The roll is 3d6 with a hard floor of 8: straight 3d6
puts ~9% on a primary the +3 pool cannot rescue, and a character bad at the one
thing he is FOR is the 'fine' §7 forbids.
Migrates the cross-boundary contract: class_id -> calling_id (the contract should
say what the world says) and a required race_id, so the AI narrates 'a beastfolk
cutpurse' instead of 'a sellsword'.
No UI — the creation screen is M4-b, the title->creation->shell flow is M4-c.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
The canon-log schema and NewGame code already migrated to race_id/
calling_id + allowed_callings, but two copies of the roster outside
that schema were missed, and no parity test guards them:
- docs/canon-log.md (the cross-boundary contract doc) still documented
the OLD player shape and allowed_classes. Anyone building a player
block from the doc would get a 422.
- .claude/skills/world-building's schema reference still emitted
allowed_classes with two dead calling ids (assassin, priest). Author
a new origin with that skill and it fails origin.schema.json
(additionalProperties: false, allowed_callings required) AND, if that
ever loosened, silently allows zero callings at runtime.
Also:
- add schema tests rejecting a dead class_id field and an unknown
calling_id (priest)
- guard NewGame._validate's container types (spend/skills) so a
malformed JSON round-trip (null spend, string skills) produces a
front-loaded error list instead of a GDScript runtime crash
- extend the no-mechanics-in-content test to db.races, not just
db.callings
- rewrite the roadmap's M4 bullet: the two contract migrations landed;
only the creation screen and title->creation->shell flow remain
client: 250/250. api: 74 passed, 2 skipped. content_build --check: clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
Finding 1 (important): test_same_seed_yields_an_identical_character only
compared two construct() runs against each other, so swapping dex/con in
Attributes.IDS still passed the whole suite while every persisted seed
would silently become a different character. Added a golden-vector test
that pins one fixed seed to its exact rolled attributes and hidden Luck,
so reordering Attributes.IDS or moving the Luck roll fails loudly instead
of silently rewriting every saved character. Proved it: swapped IDS,
watched the golden vector fail, restored, confirmed green.
Also: _validate_spend no longer lets a negative value mask the over-pool
check (continue after each error instead of falling through to `total +=
v`); non-integer spend values (float/bool/string) are now rejected instead
of coerced; a missing or non-integer seed is now a validation error instead
of silently defaulting to 0. GameState's header comment no longer
advertises "stats" it no longer holds.
243 -> 248 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
construct() takes a seed, not a stat block. It rebuilds the RNG and rolls the
attributes itself, so it never trusts a number handed to it (§2) and the same seed
yields an identical character, hidden Luck included (§10). The whole character is
reproducible from four primitives: seed, race_id, calling_id, spend. The rng
parameter is dropped — the seed is now the only source of randomness.
The roll is 3d6 with a hard floor of 8. Straight 3d6 puts ~9% on a primary the +3
pool cannot rescue, and a character bad at the one thing he is FOR is not grit —
it is the fine §7 forbids, paid for twenty hours.
LCK is unspendable, not merely hidden: spending on it is a validation error.
GameState.stats becomes GameState.sheet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
The previous regression tests in test_entities.gd only constructed their
own LogPlayer and never touched the seed logs the real call sites build
— reverting main_window_shell.gd:124 to the broken 3-arg call still left
the whole suite green. Not a real regression guard.
Now assert against node._log.player in the shell test and against the
two dev-harness scenes' seed logs (narrate_harness, npc_harness — both
cheaply testable via instantiate + add_child_autofree, no scaffolding
needed). Proved the new shell assertion fails when the call site is
reverted to the 3-arg shape (226/227, one failure), then passes restored
(227/227). Removed a redundant ctor test from test_entities.gd; kept the
one that exercises the exact broken-shape call.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
Three callers (main_window_shell.gd, narrate_harness.gd, npc_harness.gd)
still built LogPlayer with the OLD 3-arg shape (name, class_id,
luck_descriptor) after LogPlayer.new() was migrated to the 4-arg
(name, race_id, calling_id, luck_descriptor) shape. This was invisible
to both grep and the test suite: every ctor param carries a default, so
GDScript compiles a 3-arg call against a 4-arg signature without error —
it just silently mis-binds positionally. "sellsword" landed in the
race_id slot (rejected — not a race), the luck descriptor landed in the
calling_id slot (rejected — not a calling), and luck_descriptor itself
fell back to its default "". Every field in the emitted dict came out
empty, which 422s against the canon-log schema's minLength/enum
constraints. Grepping for class_id can't see this, because there is no
class_id token left anywhere — the bug is purely positional.
Also fixes prompts.py's _article(""), which returned "an" because
Python's "" in "aeiou" is True, producing a doubled-space/wrong-article
digest line when race_id is empty. _describe_player now builds its
descriptor from whichever of race/calling are present and omits the
clause entirely when both are absent.
Adds a schema-parity guard for origin.schema.json's inlined
allowed_callings enum, which duplicated the calling roster with no
runtime check tying it to Callings.IDS. Renames leftover "class"
vocabulary in test names/comments to "calling".
Regression coverage:
- client/tests/unit/test_entities.gd: ctor populates both race_id and
calling_id; a calling passed into the race_id slot (the exact shape
of the three broken call sites) yields an all-empty row.
- api/tests/test_prompts.py: empty-race and empty-race-and-calling
digest rendering, asserting no doubled space and no dangling article.
- client/tests/unit/test_schema_parity.gd: origin.schema.json's
allowed_callings enum matches Callings.IDS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
class_id is the rulebook's word; the world has callings — that was the whole §17
reconcile. The contract the Narrator reads should say what the world says. There
are no saves (M9) and no deployed clients, so this is as cheap as it will ever be
and strictly more expensive every milestone after.
race_id reaches the log because the AI must be able to describe the player:
api/app/prompts.py rendered 'a sellsword' and now renders 'a beastfolk cutpurse'.
It humanizes the id (the model must never read 'hedge_mage') and picks the right
article ('an elf', not 'a elf').
LogPlayer's hardcoded CLASSES const is deleted — the roster is Callings.IDS. A
parity test reads the JSON Schema from the client and asserts its enum equals the
code, so the two sides of the HTTP boundary cannot drift.
The deserter allowed three callings that no longer exist. He now allows all seven;
a thematic gate is a content decision for the Greywater authoring.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
Stores only what was rolled or chosen, plus current hp/mp (the only two numbers
that genuinely mutate). Everything else is a function, so no derived number can
drift from its inputs and M5's level curve is a function change, not a migration.
LCK has no row and no accessor here — §7 holds by construction.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
Hand-written like items/ — no build-tool namespace needed. Blurbs are content;
mechanics are code, and a test asserts the blurb files carry no hit dice. The
prose is placeholder; swapping it later touches no code, which is what stops the
content BOM from blocking the engine.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
Mechanics are rules and live in code, beside Luck and Currency. The skill pools
are new — the races/classes spec said only 'the stat-appropriate slice' and never
enumerated them; each pool is larger than its pick count so the choice is real.
The dwarf's +2 vs poison is exposed separately from save() rather than folded
into it: baking a conditional into a flat number is how a sheet starts lying.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
TDD, green suite at every boundary. Task 4 is deliberately one cross-cutting
commit: the origin's key and the log's player field are read on both sides of
the HTTP boundary, so renaming them in separate tasks would leave a suite red
at the seam.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
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>