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
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
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
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>
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
The hex-removal correction set _glow.color = Palette.BLOOD (opaque), which
with the 0.5-0.8 modulate pulse read as a near-solid red slab. Restore the
intended low alpha via Color(Palette.BLOOD, 0.22) — RGB still from Palette,
alpha a numeric param (same idiom as the ember ramp). Whole-branch review
catch. Title tests 5/5.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add scripts/sync-version to sync root /VERSION into client/project.godot
- Add Version GDScript helper (reads application/config/version)
- Test coverage: 2 tests for Version.string() and footer()
- Synced config/version="0.01-alpha" to project.godot
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Capture the direction change from M3-b: UI screens are authored editor-first
as .tscn node trees (previewable/arrangeable in the Godot editor), scripts do
on-load work only — never build the tree in _ready(). Add ADR 0001, a
"UI scene conventions" how-to in client/docs, a §16 mandate in the charter,
and mark M3-a/M3-b done in the roadmap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wider consumable tiles overflowed the command bar, shoving END TURN under
the dock column. Trim the vitals min-width (230->120) and END TURN
(150->140) and separation so the row fits — END TURN lands cleanly at the
bottom-right, below the dock.
Move the dock handle to the outer (right) side per mock 2a: the menu now
sits left of the handle and slides right off-screen to hide, the handle
staying at the world's right edge. Dock sits flush to that edge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 62px tiles clipped longer names (Draught, Antidote). Widen to 93px
(~1.5x) — there is room in the command bar — so labels fit while tiles stay
uniform.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The narration RichTextLabel had no theme styling, so it rendered near-white
and was unreadable on parchment. Add a base RichTextLabel style to the theme
(dark INK_BODY serif at 20px, italic face wired) — systemic and previews in
the editor; every parchment prose surface now reads as dark ink. Also clip
the consumable slot labels so a long name ("Antidote") can't stretch its tile
wider than the empty ones — all 8 stay a uniform 62px.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the three shell scenes (SystemDock, NarrationBook, MainWindowShell)
from build-everything-in-_ready() to authored .tscn node trees with the
theme + type-variations set in the editor, so the layout previews and can
be arranged in the Godot editor. Scripts keep only on-load work: @onready
node refs, DM-loop wiring, the dock slide, and binding seed ShellState into
the fixed authored turn-order tokens + consumable slots.
Fixes the dock offset + over-long buttons (now fixed-width DockButtons in a
right-anchored menu). Headless probe: root 1920x1080, world/book split
1180/740, dock at the world's right edge — no collapse. Suite 138/138.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ThemeKeys.PARCHMENT_CARD is a PanelContainer-base variation (only a
`panel` stylebox) — a Button looks up normal/hover/pressed/disabled,
which don't exist there, so a Button styled with it renders unskinned.
Add PARCHMENT_BUTTON, a Button-base variation with those four
styleboxes plus an empty focus box, built from Palette colours only.
Same fix pattern as DOCK_BUTTON for the system dock. Regenerated
game_theme.tres from the builder.
DarkPanel is a PanelContainer-base variation (only a `panel`
stylebox); a Button looks up normal/hover/pressed, which don't exist
on it, so SystemDock's menu buttons rendered as default gray Godot
buttons instead of mock-faithful dark nav buttons.
Add a dedicated Button-base DockButton variation (Palette-only
colours, dock-dark normal/hover/pressed styleboxes), point
system_dock.gd's menu buttons at it, and regenerate the committed
game_theme.tres artifact from the builder.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keep the design/logical canvas at 1920x1080 (charter §16 / mockups) but add a
window-size override so the window opens at 1600x900 and doesn't fill the whole
screen; canvas_items + keep-aspect scales the canvas to fit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two root causes behind the blank F6 run:
1. project.godot had no display/stretch config (README mandates canvas_items
@ 1920x1080). Added [display] window size + stretch mode/aspect — the
project's canvas foundation, needed by every screen.
2. A root Control run directly (F6) is not sized to the viewport by full-rect
anchors — it collapses to (0,0), clipping the ScrollContainer and hiding all
content. Size the root to the viewport explicitly and track size_changed
(dropping the full-rect anchors, which conflict with setting size directly).
Faithful main-scene run now measures root = scroll = viewport = 1920x1080.
Suite 125/125.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
palette.gd / theme_keys.gd already declare `class_name`, so `const Palette =
preload(...)` in every consumer shadowed the global class and emitted 4
editor errors ("has the same name as a global class"). Reference the global
class_names directly. Editor reload now clean; suite 125/125; runtime probe
still builds all 75 showcase nodes with the theme applied.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- build_game_theme.gd: extract build_theme() as a static func returning the
fully-built Theme; _init() is now a thin save+quit wrapper. Enables a test
to build a fresh Theme in memory without running the SceneTree main loop.
- test_theme_resource.gd: add test_committed_tres_matches_builder() — builds
a fresh Theme via the preloaded builder and compares bg_color against the
committed game_theme.tres for PrimaryCTA/normal, ParchmentCard/panel, and
DarkPanel/panel. Catches "palette/builder changed, .tres not regenerated."
- build_game_theme.gd: give Tab, TabActive, and Chip explicit hover/pressed/
focus styleboxes (focus is StyleBoxEmpty to suppress the default ring) so
they no longer fall back to Godot's default gray button on interaction.
Add a disabled stylebox + font_disabled_color to PrimaryCTA for the
unaffordable-CTA state the 2a shell will need.
- theme_keys.gd: add HEADING/ACCENT/MONO font-role consts (kept out of ALL,
which is stylebox-variation-only); build_game_theme.gd and
theme_showcase.gd now reference them instead of raw StringName literals.
- theme_showcase.gd: add an italic DM-voice RichTextLabel (EB Garamond
italic face, spec §6) and two semantic-coloured chips (BLOOD for
aggressive actions, GOLD for pay/faith) alongside the existing neutral
chip, demonstrating the script-applied colouring from spec §5.3.
- dark_bay.gdshader: comment noting the stripe uses FRAGCOORD (screen-space)
while the vignette uses UV (node-local) — intended for full-bleed use.
- surfaces/*.gd: ## comments documenting the accepted deviation from spec
§5.4 (uniforms set at runtime from Palette rather than baked into the
.tscn) — no behavior change.
- Regenerated game_theme.tres via build_game_theme.gd.
Full suite: 125/125 (was 124, +1 drift-guard test).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFDm1ku9WDUacK2S78m2be
- _surfaces(): add VignetteOverlay.tscn alongside ParchmentPanel/DarkBay,
backed by a Palette.STEEL ColorRect so the dim is visibly rendering.
- _variations(): add ItemTileEmpty (next to filled ItemTile), ParchmentInset,
and DarkPanel stylebox nodes.
- _fonts(): new section rendering default serif body, Accent
(Architects Daughter), and Mono (JetBrains Mono) labels at real sizes.
- _swatches(): wrap each ColorRect in a VBoxContainer with a Mono caption
Label naming its Palette const, per spec's "labelled swatches."
No raw hex / Color(...) literals introduced; all colour via Palette.*, all
styleboxes via ThemeKeys.* variation names.
By-hand cold-start test showed 3 phrases before the response; 1.5x the
cadence so each line stays on screen a touch longer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The throwaway harness hand-built a minimal canon log and left luck_descriptor
as the default "", which fails the canon-log schema (minLength 1) → /npc/speak
422s and the harness silently degrades to fallback. Build the player through
LogPlayer with a non-empty §7 fortune line. Verified: request now 200s and Fenn
answers in-voice.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>