Compare commits

...

22 Commits

Author SHA1 Message Date
a106be673f Merge M4-b: character creation screen
The mock's Character Creation screen over the M4-a model: four race cards, seven
nameplate calling cards (blurb + mechanics on the chosen one), the +3 additive
spend pool, calling-gated proficiency chips (+ the Human bonus-skill row), five
ability cards — never six, and 'luck' nowhere (§7) — and the DM origin panel.
Editor-first per ADR 0001: layout in the .tscn, script only binds.

F6 human gate: screenshot-verified all 8 checklist items. Granted skills read as
owned (gold + ✓), the small text was enlarged for sub-1920 laptops, four load-time
script warnings cleared. 319 client tests green.

Emits creation_confirmed(creation); the title→creation→shell wiring is M4-c.
2026-07-14 20:35:42 -05:00
1ae62fe0db feat(creation): distinguish granted skills as owned, and enlarge the small text
Two F6-review follow-ups from the human's read of the running screen.

1. GRANTED skill chips now read as 'you already have this', not 'unavailable'.
   The elf's granted `perception` shared SkillChip's dim `disabled` look with the
   'picks are spent' chips, so it read as blocked. Split out a fourth state,
   SkillChipGranted — a solid gold fill, cream text, and a ✓ prefix (owned) — while
   the pool-is-spent chips keep the plain dim disabled. Applied per-chip in _bind_skills.

2. Enlarged the small mono/body/prose roles for readability on sub-1920 laptops
   (the 1920 design canvas scales DOWN on a smaller screen, so 11-13px text got
   tiny): DM prose 20->22, MONO 13->14, section labels 13->14, card title 19->20,
   card body 13->14, chips 11->12, skill chips 12->14, the two flag pills +1. Big
   serif headings unchanged.

   Two layout consequences the guards caught and I fixed, not silenced:
   - Race cards overflowed the taller blurb -> card min-height 140->180 (the overflow
     guard, which measures against the card the engine does NOT derive from the text,
     went red first; traps.md #17).
   - The 9-chip Human bonus row, wider at the bigger font, blew the whole sheet past
     the viewport -> BonusRow HBoxContainer -> HFlowContainer so it wraps instead of
     forcing the sheet wide. Nothing depends on the row's type (tests check child
     count + visibility only).

319 client tests green (theme drift guard + overflow guard included); screenshot-
verified at 1280x720: granted chip owned, no clipping, bonus row wraps cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:33:08 -05:00
8f3aa75fa9 chore(warnings): silence the four pre-existing script warnings the creation screen surfaced
Not errors and not regressions — Godot printed them on every script reload
and they predate M4-b. Cleared so a real warning is not lost in the noise:

- Redundant `const ContentDB`/`const NewGame` preloads that shadowed their
  own `class_name` globals (character_creation.gd, creation_draft.gd) — the
  class is already global, so the const bought nothing.
- Two `log` locals shadowing the built-in `log()` — renamed to `out`
  (canon_log.from_dict) and `canon` (NewGame.construct); the "log" dict KEY
  is the public contract and is unchanged.
- currency.format's copper->denomination division is integer BY DESIGN (the
  remainder is carried, not lost) — annotated `@warning_ignore` to say so.

319 client tests green; headless parse reports none of the four sites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:09:29 -05:00
63cbc8c16e fix(creation-screen): the white text, the badge on top of the roll, and a window too big for the laptop
The human's first F6 run found four defects headless GUT could not see, all
now fixed and break-proven.

1. Fifteen Labels shipped invisible. _fonts() sets a default font and size but
   never a default font_color for the base Label type, so a Label with no
   type-variation inherited Godot's built-in default — WHITE — and rendered
   white-on-parchment: the four race names, four race blurbs, and seven calling
   names, every unreadable string on the sheet. New CardTitle / CardBody roles
   (mock #3a2f1c / #6a5a3a) fix it. NOT a global default Label colour: the Title
   screen has 12 bare Labels on a dark background that rely on white.

2. The PRIMARY badge covered the rolled value. The ability card is a
   PanelContainer, and a Container force-fits its children, so the badge's
   authored anchors were dead letters — it stretched to the card's full width
   and centred over the value. Nesting it under a plain Control (not a
   Container) restores absolute positioning. The badges also carried _flat()'s
   12/6 card padding; a new tight pill stylebox matches the mock's 2px 9px.

3. Race cards overflowed their own border (108px card, 114px of content). Bumped
   to 140. The CHOSEN badge on the calling card moved from centre to the mock's
   top-right, and the ability card got its own AbilityCard variation with 16px
   top headroom for the badge (ParchmentCard is shared with the shell).

4. The default window (1600x900) was larger than a 1600x900 laptop's usable
   area, so the WM clamped it to 1589x752 and the run came up pillarboxed.
   Default is now 1280x720, resizable; the 1920x1080 design canvas is unchanged
   (canvas_items + keep scales it, so no layout number moved).

Also closes M4-b's two open copy items: the cutpurse origin fragment (it
contradicted its own blurb) and the Hint label (it omitted the roll's floor of
8, now pinned to NewGame.roll_attributes).

Guards for 1-4 are new and each was re-broken and watched go red. The overflow
guard in 3 was FIRST WRITTEN AS A TAUTOLOGY — Godot clamps Control.size up to
its combined minimum, so `min <= size` is `x <= x` — and passed against the bug,
29/29, until it was rewritten to measure against the card that clips. That is
traps.md #17 and the thirteenth cannot-fail assertion this branch has caught.

319 client tests green, content build green, theme drift guard satisfied.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 19:56:09 -05:00
7471d2e37f docs(traps): the drift guard took three fixes, and the third one whitelisted the gap
Trap 12 told only the first act. The full story is the lesson: ThemeKeys.ALL excludes
the font roles by its own docstring, and the builder styles some base types directly
(RichTextLabel — which renders every line of DM prose on the creation screen). The
meta-guard written specifically to prevent a third miss contained a `continue` that
exempted exactly the gap it existed to catch.

A guard-of-a-guard with an exemption in it is not a guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:48:27 -05:00
6291f21a00 fix(theme-guard): close the drift guard's third miss — the meta-guard's own continue whitelisted the gap
test_committed_tres_matches_builder has now been "fixed" three times (traps.md #12).
v1 hardcoded 3 checks; v2 iterated ThemeKeys.ALL but missed the six font roles; v3
added a meta-guard specifically to catch "the builder styles a thing guarded by
nothing" — but that meta-guard contained `if fresh.get_type_variation_base(variation)
== &"": continue`, which whitelisted exactly the case it was built to catch. The
builder styles RichTextLabel directly (build_game_theme.gd's _rich_text()), with no
set_type_variation, so it reports base "" and was skipped unconditionally — and
RichTextLabel is what renders every line of DM prose the creation screen shows
(_origin_text, _detail_blurb). Nothing compared its fonts, font size, or colour, nor
the theme-level default_font/default_font_size, against the committed .tres.

Adds ThemeKeys.BASE_TYPES for base Control types the builder styles directly, folds
it into the drift guard's coverage, replaces the meta-guard's continue with an
assertion that names the offending type, and adds the missing theme-level default
font/size comparison. Proved with three reverted breaks: a RichTextLabel font-size
edit, a theme-level default_font_size edit, and an unregistered "GhostRole" variation
— all three now go red and name the drifted property.

Also: strengthens test_creation_copy's error-copy sweep to assert a mapped error
actually reaches its authored line rather than silently falling through to the
generic UNSPOKEN fallback (CreationCopy.UNSPOKEN satisfied all four prior checks,
so a renamed validator string could regress silently); and removes two assertions
that were true by construction / already true before their test's action ran.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:47:24 -05:00
34bf064904 fix(creation-screen): the bonus-skill hole, the theme guard that could not fail, and the strings the player was reading
Final whole-branch review of M4-b. Six findings.

1. The player could press his way into an invalid draft. can_take_bonus()
   refused an already-picked skill; can_pick() did not consider the bonus, so
   the guard was one-directional. Take athletics as the human's bonus, pick it
   again from the Sellsword's pool, and the draft is one NewGame.validate
   rejects — the invalid draft "he did not cause and cannot see" that
   set_calling's own comment forbids. Fixed in the DRAFT (the screen owns no
   rules): can_pick() now mirrors can_take_bonus(), so the chip goes inert, the
   idiom the pool row already uses for a race-granted skill. Recoverable: hand
   the bonus back and the chip is live again. Both directions tested.

2. The theme drift guard STILL could not fail. ThemeKeys.ALL is stylebox
   variations only, by its own docstring — so the six FONT roles the builder
   sets fonts, sizes and colours for were entirely unguarded. Changing
   Palette.CREAM and skipping the rebuild shipped a stale game_theme.tres with
   the suite green (proven, on the committed test). Adds ThemeKeys.FONT_ROLES
   beside ALL (never inside it — ALL's contract is relied on), compares fonts,
   font sizes and font colours as well as styleboxes, and names the variation
   AND the property that drifted. Plus a meta-guard that walks the builder's own
   output and fails if a variation it styles is in neither collection — this is
   the second time this guard has been fixed by "enumerate the right set", and
   nothing was checking the set.

3. §13: the CTA label piped NewGame.validate's diagnostics straight to the
   player. On the game's SECOND SCREEN he read "hedge_mage picks 2 skills, got
   0". Now CreationCopy.error_line maps them to authored copy — "name yourself",
   "choose two more proficiencies" (the number DERIVED from Callings.skill_count,
   never written down), "one more proficiency, any of them — take it" — with an
   authored fallback for anything unmapped, because §13 means the unmapped case
   still speaks in voice. validate's strings are untouched: they are a contract,
   so they are translated in the presentation layer, not rewritten.

4. §2 had no test that fails if the boundary is breached. The emit test compared
   the signal to draft.to_creation() — both sides move together. Pins the seven
   contract keys exactly, and feeds construct a hostile creation dict carrying an
   18 in every stat, asserting the sheet is still roll_attributes(seed) + spend.
   Every other test in test_new_game passed with that breach in place.

5. Three holes: the orphan sweep skipped PoolRow (a stray Pool6 rendered unbound
   and visible, suite green — the exact bug the test exists to catch, in the one
   row it forgot); the §7 sweep forbade "luck" but not Luck.BANDS' descriptors,
   which are worse than the number (the player would re-roll until his Luck read
   well — calculable Luck); and the DM panel's composition hardcodes the article
   "a", so a vowel-initial calling would break it silently — guarded in the
   content parity test, which names the template as the reason.

6. The calling card printed "talent second_wind". Four of seven talents carry an
   underscore. Humanised, the way skill_label() already does. The test that
   pinned the RAW form is moved to the full derived phrase ("talent second wind")
   so it still fails if someone hardcodes a talent into the format string.

Every guard was proven by re-breaking the code and watching the named test go
red (docs/traps.md). Suite 302 -> 315, content build green, no new warnings.

Report: .superpowers/sdd/final-review-fix-report.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:25:12 -05:00
31759ef848 docs(roadmap): M4-b lands; the Title screen was already built
The creation screen is done — 302 client tests (up from the 250 baseline),
content build green. Also flips the M3 Title screen from planned to done: it
shipped in a77bf03/f071392 and the roadmap never caught up, which would have
misled M4-c into thinking it must build one.

Adds six new trap species to docs/traps.md from this milestone's review
passes: substring-collision assertions, a BBCode wrapper masking an empty
ContentDB, a test asserting the script's own loop bound, a trivially-true
gating fixture, assertions already true before the action under test ran, an
"iterates everything" guard that checked three hardcoded pairs, a node-type
sweep that missed RichTextLabel, tests that called handlers instead of
pressing nodes, GUT silently skipping an unparseable class_name test file at
the old count, and a test that can hang instead of fail. Also documents the
new client-docs convention: clickable cards are Buttons with mouse_filter = 2
on every child, and pressed on a toggle_mode Button is the chosen state.

Human F6 confirmation of the rendered screen against the mock is still
outstanding and is not claimed here.
2026-07-13 16:00:17 -05:00
4494856a44 test(creation-screen): close the §7 sweep's own emptiness guard, and the M3-a viewport-fit guard
The Luck sweep's non-empty-label assertions could not fail: _bind_bay and
_bind_callings always wrap prose in BBCode italics ("[i]%s[/i]"), and
_origin_prose() always emits the literal connective " Now you carry a %s's
work — " even when every authored fragment/blurb is blank. So
"[i][/i]".strip_edges().is_empty() reads false regardless of what ContentDB
served — the 28-combination sweep was proving the wrapper is non-empty, not
that §7's own guard against a blank ContentDB actually holds. Replaced the
emptiness checks with assertions that the DM panel and detail panel render
the WORLD's own fragment/blurb strings, and that those strings are
themselves non-blank, for every race x calling combination.

The M3-a collapse guard (`assert_gt(s.size.x, 0.0, ...)`) also could not
fail: the .tscn root is authored at exactly 1920x1080, matching the
project's viewport size, so the assertion passed on the authored offsets
alone with _fit_to_viewport() and its size_changed connection deleted
entirely. Replaced it with an actual resize proof: setting
Window.content_scale_size (the property this project's canvas_items stretch
mode actually keys Control.get_viewport_rect() off of — get_viewport().size
does not move it headless) to a non-1920x1080 value and asserting the root
followed, then restoring it.

Also covers three previously-unasserted binder behaviours: a race-granted
skill chip going inert (and a chip going inert once the pool's picks are
spent), the bonus-skill row's visibility gate per race, the ability card's
primary-stat Tag/theme_type_variation, and _on_enter_pressed's early-out on
an illegal draft.

Every new/changed guard was proven red against the bug it names (blanked a
calling's blurb; hardcoded a placeholder origin string; deleted
_fit_to_viewport's wiring; dropped the chip.disabled line; forced the bonus
row visible) and reverted. Suite: 298 -> 302, all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:53:47 -05:00
c5f127e0f1 test(creation-screen): close 3 vacuous guards — point-buy display, signal wiring, Luck sweep
Adversarial re-review of 6589d7c found three assertions in the same species as
the seven it fixed: green against the bug they name.

- test_the_displayed_scores_are_the_draft_s_scores spent zero points, so
  final() == rolled() and a Box/Value bound to `rolled[stat]` instead of
  `final[stat]` would pass unnoticed. Now presses the Plus node to force a
  divergence, and checks the Box/Rolled sub-label too (previously unchecked).
- No test ever emitted a node's `pressed` signal — every interaction went
  through the _on_*_pressed handler directly, so _wire() itself, and every
  card-to-index binding, had zero coverage. Minus had no coverage of any
  kind. Helper and interaction tests now press the authored nodes; added
  dedicated press tests for race/calling/pool/bonus/minus, plus the
  Plus/Minus disabled-boundary bindings.
- The Luck sweep only ever checked the default binding (human + sellsword),
  covering 1 of the 28 race x calling combinations whose authored prose
  (fragment/blurb) actually renders on screen. Now sweeps all 28.

Plus three cheap adds: a degenerate-origin (§13) guard, orphan-authored-card
counts for the race/calling/bonus rows, and a non-empty assertion on the
origin-card RichTextLabel.

Each fix verified by deliberately reintroducing its named bug, confirming the
guard test goes red, then reverting — see
.superpowers/sdd/task-6-report-fix2.md. 291 -> 298 tests, all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:38:31 -05:00
6589d7c4ed fix(creation-screen): the guards did not guard
Review found three tests in test_character_creation_screen.gd could not fail
against the bug they named — including both tests claiming to enforce §7
(Luck is invisible) and the only test covering origin->calling gating. The
five-ability-cards test asserted the script's own range(5) loop bound, not
the scene. The Luck sweep filtered on `Label` and missed both RichTextLabels
(the only nodes rendering authored prose) plus every Button and the
LineEdit. The reroll test's pool assertion already held true before the
reroll ran. The origin-gating test ran only against the deserter origin,
which allows all 7 callings, so shown==allowed was a trivial 7==7.

Rewrote all four to assert against the scene, the rules tables, or a
restrictive injected origin instead of values the code already guaranteed.
Added a node-count guard tying authored cards/chips to Races/Callings/
Skills/Attributes table sizes. Deleted the test-only
_complete_a_valid_draft_for_test() from the shipped Control and replaced it
with a test-file helper that drives the real click handlers, closing the
gap where no test touched _on_race_pressed/_on_calling_pressed/
_on_pool_pressed/_on_bonus_pressed at all. Guarded _ready() against a
degenerate origin (empty/missing allowed_callings) indexing an empty array.

Each fixed guard was deliberately re-broken and confirmed red before being
reverted (see .superpowers/sdd/task-6-report-fix.md, gitignored). 291 tests
green, up from 289.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:19:10 -05:00
2227d703f6 feat(creation): the character creation screen
Variant C of the mock: seven nameplate calling cards in one row, with the chosen
calling's blurb AND mechanics in a detail panel below — the one card you
committed to is the only one that talks.

Editor-first (ADR 0001): the tree is in the .tscn, the script binds. No rules
here — CreationDraft holds them, CreationCopy derives the display strings, the
prose comes from ContentDB. Emits creation_confirmed(Dictionary); M4-c
constructs. Five ability cards. Never six (§7).
2026-07-13 15:03:24 -05:00
fe8e24cf39 fix(tests): make theme drift guard iterate ThemeKeys.ALL, not 3 hardcoded pairs
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>
2026-07-13 14:57:28 -05:00
0bbf94289a feat(theme): the creation screen's five variations
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.
2026-07-13 14:48:17 -05:00
1621a38b29 fix(creation-draft): delete the duplicate SPEND_POOL const
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>
2026-07-13 14:44:50 -05:00
866158cc0d feat(creation): CreationDraft — the screen's brain, with no screen
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.
2026-07-13 14:38:18 -05:00
1c4d19492d fix(test): skill-count assertion collided with hit-die digit for Reaver
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>
2026-07-13 14:34:12 -05:00
537b0e1d18 feat(creation): CreationCopy — the card reads the table
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>
2026-07-13 14:28:30 -05:00
057b83679b fix(content): bonesetter fragment restated the blurb's central non-claim
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.
2026-07-13 14:18:46 -05:00
c6b8a651aa fix(creation-content): four fragments were reading the blurb back to the player
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>
2026-07-13 14:11:16 -05:00
e4db022c4b content(creation): the eleven origin fragments
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.
2026-07-13 14:02:47 -05:00
17ef142689 feat(newgame): one public, seeded roll the screen can show
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).
2026-07-13 13:54:02 -05:00
39 changed files with 4382 additions and 193 deletions

6
.gitignore vendored
View File

@@ -1,3 +1,9 @@
# ─────────────────────────────────────────────
# Local scratch — never versioned
# ─────────────────────────────────────────────
# Ad-hoc F6 / smoke-test captures. Kept for the working session, not the repo.
/screenshots/
# ─────────────────────────────────────────────
# Godot 4.7 (client/)
# ─────────────────────────────────────────────

View File

@@ -1,13 +1,29 @@
[gd_resource type="Theme" format=3]
[ext_resource type="FontFile" path="res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf" id="1_605w4"]
[ext_resource type="FontFile" path="res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf" id="2_4lwr2"]
[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf" id="3_eckwq"]
[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf" id="2_4lwr2"]
[ext_resource type="FontFile" path="res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf" id="3_eckwq"]
[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-Italic-VariableFont_wght.ttf" id="4_i0b23"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_cqbvn"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_cqbvn"]
content_margin_left = 10.0
content_margin_top = 16.0
content_margin_right = 10.0
content_margin_bottom = 14.0
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.7490196, 0.65882355, 0.47058824, 1)
corner_radius_top_left = 12
corner_radius_top_right = 12
corner_radius_bottom_right = 12
corner_radius_bottom_left = 12
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_q5gpk"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_q5gpk"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bbpmc"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -23,7 +39,7 @@ corner_radius_top_right = 3
corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bbpmc"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_73lme"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -39,7 +55,7 @@ corner_radius_top_right = 3
corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_73lme"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_js3tr"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -55,7 +71,19 @@ corner_radius_top_right = 3
corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_js3tr"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ube3r"]
content_margin_left = 8.0
content_margin_top = 3.0
content_margin_right = 8.0
content_margin_bottom = 3.0
bg_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
border_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
corner_radius_top_left = 5
corner_radius_top_right = 5
corner_radius_bottom_right = 5
corner_radius_bottom_left = 5
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2tooq"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -71,9 +99,9 @@ corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_ube3r"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_4kten"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2tooq"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p6wtm"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -89,7 +117,7 @@ corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4kten"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0fjdt"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -105,7 +133,7 @@ corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p6wtm"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ns3kg"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -121,7 +149,7 @@ corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0fjdt"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jly1r"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -137,7 +165,7 @@ corner_radius_top_right = 6
corner_radius_bottom_right = 6
corner_radius_bottom_left = 6
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ns3kg"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lw8lk"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -154,7 +182,7 @@ corner_radius_top_right = 6
corner_radius_bottom_right = 6
corner_radius_bottom_left = 6
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jly1r"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nh0e7"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -170,46 +198,14 @@ corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_lw8lk"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nh0e7"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.3529412, 0.2627451, 0.14901961, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dtr07"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_dtr07"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nmowx"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1)
bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
@@ -230,13 +226,45 @@ border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukblj"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.3529412, 0.2627451, 0.14901961, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_utt73"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.7490196, 0.65882355, 0.47058824, 1)
corner_radius_top_left = 6
corner_radius_top_right = 6
corner_radius_bottom_right = 6
corner_radius_bottom_left = 6
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukblj"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_5u8fh"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -252,7 +280,7 @@ corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_utt73"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p0qbh"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -268,7 +296,7 @@ corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_5u8fh"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_6y6ak"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -284,7 +312,7 @@ corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p0qbh"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_txxuc"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -300,7 +328,7 @@ corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_6y6ak"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_7d6ug"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -316,41 +344,53 @@ corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_txxuc"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_7d6ug"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.79607844, 0.7137255, 0.5176471, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_najyk"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.9372549, 0.9019608, 0.8117647, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.79607844, 0.7137255, 0.5176471, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
content_margin_left = 10.0
content_margin_top = 16.0
content_margin_right = 10.0
content_margin_bottom = 14.0
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
corner_radius_top_left = 12
corner_radius_top_right = 12
corner_radius_bottom_right = 12
corner_radius_bottom_left = 12
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jtb1k"]
content_margin_left = 9.0
content_margin_top = 2.0
content_margin_right = 9.0
content_margin_bottom = 2.0
bg_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
border_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
corner_radius_top_left = 5
corner_radius_top_right = 5
corner_radius_bottom_right = 5
corner_radius_bottom_left = 5
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_wmbj4"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
corner_radius_top_left = 12
corner_radius_top_right = 12
corner_radius_bottom_right = 12
corner_radius_bottom_left = 12
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_xdylb"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pjnll"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -360,31 +400,95 @@ border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.7490196, 0.65882355, 0.47058824, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
corner_radius_top_left = 12
corner_radius_top_right = 12
corner_radius_bottom_right = 12
corner_radius_bottom_left = 12
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_wmbj4"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_xdylb"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_klelq"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.72156864, 0.32156864, 0.2901961, 1)
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.72156864, 0.32156864, 0.2901961, 1)
border_color = Color(0.7490196, 0.65882355, 0.47058824, 1)
corner_radius_top_left = 12
corner_radius_top_right = 12
corner_radius_bottom_right = 12
corner_radius_bottom_left = 12
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_i2sl5"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
border_width_left = 3
border_width_top = 3
border_width_right = 3
border_width_bottom = 3
border_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
corner_radius_top_left = 12
corner_radius_top_right = 12
corner_radius_bottom_right = 12
corner_radius_bottom_left = 12
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_onsig"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pjnll"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_gw0t7"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_88l5g"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.3529412, 0.2627451, 0.14901961, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_f8qac"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_u6gcs"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -400,7 +504,109 @@ corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_klelq"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nufy3"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_0d7vs"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_3fnfk"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2txdw"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.79607844, 0.7137255, 0.5176471, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_w7qkp"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.9372549, 0.9019608, 0.8117647, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.79607844, 0.7137255, 0.5176471, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_rthnr"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.7490196, 0.65882355, 0.47058824, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_ta3oh"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ohwwq"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.72156864, 0.32156864, 0.2901961, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.72156864, 0.32156864, 0.2901961, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ismtf"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_gs634"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -417,80 +623,136 @@ corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[resource]
default_font = ExtResource("3_eckwq")
default_font = ExtResource("2_4lwr2")
default_font_size = 18
AbilityCard/base_type = &"PanelContainer"
AbilityCard/styles/panel = SubResource("StyleBoxFlat_cqbvn")
Accent/base_type = &"Label"
Accent/colors/font_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
Accent/font_sizes/font_size = 24
Accent/fonts/font = ExtResource("1_605w4")
CardBody/base_type = &"Label"
CardBody/colors/font_color = Color(0.41568628, 0.3529412, 0.22745098, 1)
CardBody/font_sizes/font_size = 14
CardBody/fonts/font = ExtResource("2_4lwr2")
CardTitle/base_type = &"Label"
CardTitle/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1)
CardTitle/font_sizes/font_size = 20
CardTitle/fonts/font = ExtResource("2_4lwr2")
Chip/base_type = &"Button"
Chip/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
Chip/font_sizes/font_size = 11
Chip/fonts/font = ExtResource("2_4lwr2")
Chip/styles/focus = SubResource("StyleBoxEmpty_cqbvn")
Chip/styles/hover = SubResource("StyleBoxFlat_q5gpk")
Chip/styles/normal = SubResource("StyleBoxFlat_bbpmc")
Chip/styles/pressed = SubResource("StyleBoxFlat_73lme")
Chip/font_sizes/font_size = 12
Chip/fonts/font = ExtResource("3_eckwq")
Chip/styles/focus = SubResource("StyleBoxEmpty_q5gpk")
Chip/styles/hover = SubResource("StyleBoxFlat_bbpmc")
Chip/styles/normal = SubResource("StyleBoxFlat_73lme")
Chip/styles/pressed = SubResource("StyleBoxFlat_js3tr")
ChosenTag/base_type = &"Label"
ChosenTag/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
ChosenTag/font_sizes/font_size = 11
ChosenTag/fonts/font = ExtResource("3_eckwq")
ChosenTag/styles/normal = SubResource("StyleBoxFlat_ube3r")
DarkPanel/base_type = &"PanelContainer"
DarkPanel/styles/panel = SubResource("StyleBoxFlat_js3tr")
DarkPanel/styles/panel = SubResource("StyleBoxFlat_2tooq")
DockButton/base_type = &"Button"
DockButton/colors/font_color = Color(0.7882353, 0.7411765, 0.6392157, 1)
DockButton/styles/focus = SubResource("StyleBoxEmpty_ube3r")
DockButton/styles/hover = SubResource("StyleBoxFlat_2tooq")
DockButton/styles/normal = SubResource("StyleBoxFlat_4kten")
DockButton/styles/pressed = SubResource("StyleBoxFlat_p6wtm")
DockButton/styles/focus = SubResource("StyleBoxEmpty_4kten")
DockButton/styles/hover = SubResource("StyleBoxFlat_p6wtm")
DockButton/styles/normal = SubResource("StyleBoxFlat_0fjdt")
DockButton/styles/pressed = SubResource("StyleBoxFlat_ns3kg")
Heading/base_type = &"Label"
Heading/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1)
Heading/font_sizes/font_size = 34
Heading/fonts/font = ExtResource("3_eckwq")
Heading/fonts/font = ExtResource("2_4lwr2")
ItemTile/base_type = &"PanelContainer"
ItemTile/styles/panel = SubResource("StyleBoxFlat_0fjdt")
ItemTile/styles/panel = SubResource("StyleBoxFlat_jly1r")
ItemTileEmpty/base_type = &"PanelContainer"
ItemTileEmpty/styles/panel = SubResource("StyleBoxFlat_ns3kg")
ItemTileEmpty/styles/panel = SubResource("StyleBoxFlat_lw8lk")
Mono/base_type = &"Label"
Mono/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
Mono/font_sizes/font_size = 13
Mono/fonts/font = ExtResource("2_4lwr2")
Mono/font_sizes/font_size = 14
Mono/fonts/font = ExtResource("3_eckwq")
ParchmentButton/base_type = &"Button"
ParchmentButton/colors/font_color = Color(0.2901961, 0.24705882, 0.17254902, 1)
ParchmentButton/colors/font_disabled_color = Color(0.41568628, 0.3529412, 0.22745098, 1)
ParchmentButton/styles/disabled = SubResource("StyleBoxFlat_jly1r")
ParchmentButton/styles/focus = SubResource("StyleBoxEmpty_lw8lk")
ParchmentButton/styles/hover = SubResource("StyleBoxFlat_nh0e7")
ParchmentButton/styles/normal = SubResource("StyleBoxFlat_dtr07")
ParchmentButton/styles/pressed = SubResource("StyleBoxFlat_nmowx")
ParchmentButton/styles/disabled = SubResource("StyleBoxFlat_nh0e7")
ParchmentButton/styles/focus = SubResource("StyleBoxEmpty_dtr07")
ParchmentButton/styles/hover = SubResource("StyleBoxFlat_nmowx")
ParchmentButton/styles/normal = SubResource("StyleBoxFlat_oi067")
ParchmentButton/styles/pressed = SubResource("StyleBoxFlat_ukblj")
ParchmentCard/base_type = &"PanelContainer"
ParchmentCard/styles/panel = SubResource("StyleBoxFlat_oi067")
ParchmentCard/styles/panel = SubResource("StyleBoxFlat_utt73")
ParchmentInset/base_type = &"PanelContainer"
ParchmentInset/styles/panel = SubResource("StyleBoxFlat_ukblj")
ParchmentInset/styles/panel = SubResource("StyleBoxFlat_5u8fh")
PrimaryCTA/base_type = &"Button"
PrimaryCTA/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
PrimaryCTA/colors/font_disabled_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
PrimaryCTA/styles/disabled = SubResource("StyleBoxFlat_utt73")
PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_5u8fh")
PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_p0qbh")
PrimaryCTA/styles/pressed = SubResource("StyleBoxFlat_6y6ak")
PrimaryCTA/styles/disabled = SubResource("StyleBoxFlat_p0qbh")
PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_6y6ak")
PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_txxuc")
PrimaryCTA/styles/pressed = SubResource("StyleBoxFlat_7d6ug")
PrimaryCard/base_type = &"PanelContainer"
PrimaryCard/styles/panel = SubResource("StyleBoxFlat_najyk")
PrimaryTag/base_type = &"Label"
PrimaryTag/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
PrimaryTag/font_sizes/font_size = 10
PrimaryTag/fonts/font = ExtResource("3_eckwq")
PrimaryTag/styles/normal = SubResource("StyleBoxFlat_jtb1k")
RichTextLabel/colors/default_color = Color(0.2901961, 0.24705882, 0.17254902, 1)
RichTextLabel/font_sizes/normal_font_size = 20
RichTextLabel/font_sizes/normal_font_size = 22
RichTextLabel/fonts/italics_font = ExtResource("4_i0b23")
RichTextLabel/fonts/normal_font = ExtResource("3_eckwq")
RichTextLabel/fonts/normal_font = ExtResource("2_4lwr2")
SectionLabel/base_type = &"Label"
SectionLabel/colors/font_color = Color(0.5411765, 0.46666667, 0.28235295, 1)
SectionLabel/font_sizes/font_size = 14
SectionLabel/fonts/font = ExtResource("3_eckwq")
SelectCard/base_type = &"Button"
SelectCard/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1)
SelectCard/colors/font_disabled_color = Color(0.41568628, 0.3529412, 0.22745098, 1)
SelectCard/styles/disabled = SubResource("StyleBoxFlat_wmbj4")
SelectCard/styles/focus = SubResource("StyleBoxEmpty_xdylb")
SelectCard/styles/hover = SubResource("StyleBoxFlat_pjnll")
SelectCard/styles/normal = SubResource("StyleBoxFlat_klelq")
SelectCard/styles/pressed = SubResource("StyleBoxFlat_i2sl5")
SkillChip/base_type = &"Button"
SkillChip/colors/font_color = Color(0.3529412, 0.2627451, 0.14901961, 1)
SkillChip/colors/font_disabled_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
SkillChip/colors/font_hover_color = Color(0.3529412, 0.2627451, 0.14901961, 1)
SkillChip/colors/font_pressed_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
SkillChip/font_sizes/font_size = 14
SkillChip/fonts/font = ExtResource("3_eckwq")
SkillChip/styles/disabled = SubResource("StyleBoxFlat_onsig")
SkillChip/styles/focus = SubResource("StyleBoxEmpty_gw0t7")
SkillChip/styles/hover = SubResource("StyleBoxFlat_88l5g")
SkillChip/styles/normal = SubResource("StyleBoxFlat_f8qac")
SkillChip/styles/pressed = SubResource("StyleBoxFlat_u6gcs")
SkillChipGranted/base_type = &"Button"
SkillChipGranted/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
SkillChipGranted/colors/font_disabled_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
SkillChipGranted/font_sizes/font_size = 14
SkillChipGranted/fonts/font = ExtResource("3_eckwq")
SkillChipGranted/styles/disabled = SubResource("StyleBoxFlat_nufy3")
SkillChipGranted/styles/focus = SubResource("StyleBoxEmpty_0d7vs")
SkillChipGranted/styles/hover = SubResource("StyleBoxFlat_nufy3")
SkillChipGranted/styles/normal = SubResource("StyleBoxFlat_nufy3")
SkillChipGranted/styles/pressed = SubResource("StyleBoxFlat_nufy3")
Tab/base_type = &"Button"
Tab/colors/font_color = Color(0.41568628, 0.3529412, 0.22745098, 1)
Tab/styles/focus = SubResource("StyleBoxEmpty_txxuc")
Tab/styles/hover = SubResource("StyleBoxFlat_7d6ug")
Tab/styles/normal = SubResource("StyleBoxFlat_najyk")
Tab/styles/pressed = SubResource("StyleBoxFlat_jtb1k")
Tab/styles/focus = SubResource("StyleBoxEmpty_3fnfk")
Tab/styles/hover = SubResource("StyleBoxFlat_2txdw")
Tab/styles/normal = SubResource("StyleBoxFlat_w7qkp")
Tab/styles/pressed = SubResource("StyleBoxFlat_rthnr")
TabActive/base_type = &"Button"
TabActive/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
TabActive/styles/focus = SubResource("StyleBoxEmpty_wmbj4")
TabActive/styles/hover = SubResource("StyleBoxFlat_xdylb")
TabActive/styles/normal = SubResource("StyleBoxFlat_pjnll")
TabActive/styles/pressed = SubResource("StyleBoxFlat_klelq")
TabActive/styles/focus = SubResource("StyleBoxEmpty_ta3oh")
TabActive/styles/hover = SubResource("StyleBoxFlat_ohwwq")
TabActive/styles/normal = SubResource("StyleBoxFlat_ismtf")
TabActive/styles/pressed = SubResource("StyleBoxFlat_gs634")
TitleKicker/base_type = &"Label"
TitleKicker/colors/font_color = Color(0.6627451, 0.5176471, 0.24705882, 1)
TitleKicker/font_sizes/font_size = 13
TitleKicker/fonts/font = ExtResource("2_4lwr2")
TitleKicker/fonts/font = ExtResource("3_eckwq")
TitleLogo/base_type = &"Label"
TitleLogo/colors/font_color = Color(0.9098039, 0.8666667, 0.78431374, 1)
TitleLogo/font_sizes/font_size = 116
TitleLogo/fonts/font = ExtResource("3_eckwq")
TitleLogo/fonts/font = ExtResource("2_4lwr2")

View File

@@ -17,7 +17,15 @@ Cross-cutting design (anything touching both client and api) goes in the root [`
Screens are **authored editor-first** — the layout lives in the `.tscn`, not in
code. See ADR [0001](../../docs/adr/0001-editor-first-ui-scenes.md) for the why;
this is the how. Reference: `scenes/shell/{MainWindowShell,SystemDock,NarrationBook}.tscn`
+ `scripts/ui/shell/*.gd`.
+ `scripts/ui/shell/*.gd`, and `scenes/creation/CharacterCreation.tscn` +
`scripts/ui/creation/{character_creation,creation_draft,creation_copy}.gd` (M4-b).
- **Clickable cards are Buttons with `mouse_filter = 2` on every child.** A card that
holds a name, a blurb and a trait line is a `Button` (Button-base variation, so
`normal`/`hover`/`pressed` actually skin it) with a `VBoxContainer` of Labels inside.
Every child needs `mouse_filter = 2` (IGNORE) or the Label swallows the click and the
card never toggles. `pressed` on a `toggle_mode` Button *is* the "chosen" state — no
script-side stylebox swapping.
- **The `.tscn` owns the tree.** Author every node in the editor: containers,
panels, labels, buttons, art-slot placeholders. Assign the shared theme on the

View File

@@ -11,19 +11,28 @@ config_version=5
[application]
config/name="coc-rpg"
config/version="0.01-alpha"
config/description="AI-driven single-player party RPG. Code owns state. AI owns text."
config/version="0.01-alpha"
run/main_scene="res://scenes/title/TitleScreen.tscn"
config/features=PackedStringArray("4.7")
[display]
; Design/logical canvas stays 1920x1080 (charter §16, mockups authored here).
; The DESIGN CANVAS stays 1920x1080 — the mockups (§16) are authored there, and every
; .tscn's offsets are in those coordinates. It is a logical canvas, not a demand on the
; monitor: canvas_items + keep scales it uniformly into whatever window it is given, so
; the UI never reflows, it only gets smaller. Nothing below changes a layout number.
window/size/viewport_width=1920
window/size/viewport_height=1080
; Open a smaller 1600x900 window that scales the 1920x1080 canvas down, so it
; doesn't fill the whole screen. canvas_items + keep aspect does the scaling.
window/size/window_width_override=1600
window/size/window_height_override=900
; The WINDOW is what the laptop has to fit, and it is a different question. 1600x900 was
; larger than a 1600x900 laptop's *usable* area once the WM took its panel and titlebar,
; so the window manager clamped it to 1589x752 and the run came up pillarboxed. 1280x720
; is 16:9 and fits any laptop this ships to; the window is resizable (Godot's default),
; and canvas_items rescales the canvas live on every resize.
window/size/window_width_override=1280
window/size/window_height_override=720
window/size/resizable=true
window/stretch/mode="canvas_items"
; keep = letterbox rather than distort or reflow. This IS Godot 4's default, so the editor
; prunes the line whenever it rewrites this file — that is cosmetic, not a behaviour change.
window/stretch/aspect="keep"

File diff suppressed because it is too large Load Diff

View File

@@ -99,17 +99,17 @@ static func from_dict(d: Dictionary) -> CanonLog:
if v != SCHEMA_VERSION:
push_error("unsupported canon_log schema_version: %s" % v)
return null
var log := CanonLog.new()
log.player = LogPlayer.from_dict(d.get("player", {}))
log.location = LogLocation.from_dict(d.get("location", {}))
var out := CanonLog.new()
out.player = LogPlayer.from_dict(d.get("player", {}))
out.location = LogLocation.from_dict(d.get("location", {}))
for m in d.get("party", []):
log.party.append(PartyMember.from_dict(m))
out.party.append(PartyMember.from_dict(m))
for e in d.get("recent_events", []):
log.push_event(e)
out.push_event(e)
for f in d.get("established_facts", []):
log.add_fact(f)
out.add_fact(f)
for q in d.get("active_quests", []):
log.active_quests.append(Quest.from_dict(q))
out.active_quests.append(Quest.from_dict(q))
for h in d.get("humiliations", []):
log.humiliations.append(Humiliation.from_dict(h))
return log
out.humiliations.append(Humiliation.from_dict(h))
return out

View File

@@ -30,9 +30,9 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary
# The RNG ORDER is part of the contract: five attributes in a fixed order, then
# Luck. Change the order and every existing seed produces a different character.
var attrs: Dictionary = {}
for stat in Attributes.IDS:
attrs[stat] = _roll_attribute(rng)
# roll_attributes() is the ONE implementation — the creation screen calls the same
# function to DISPLAY the roll, so what the player saw is what he gets (§2/§10).
var attrs: Dictionary = _roll_five(rng)
var bc: Dictionary = origin.get("build_constraints", {})
state.luck_base = Luck.roll_base(rng, int(bc.get("luck_modifier", 0)))
@@ -58,31 +58,31 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary
sheet.mp = sheet.max_mp()
state.sheet = sheet
var log := CanonLog.new()
log.player = LogPlayer.new(str(creation.get("name", "")), sheet.race_id,
var canon := CanonLog.new()
canon.player = LogPlayer.new(str(creation.get("name", "")), sheet.race_id,
sheet.calling_id, state.luck_descriptor())
var loc: Dictionary = world.location(origin["start_location_id"])
log.set_location(loc.get("id", ""), loc.get("name", ""))
canon.set_location(loc.get("id", ""), loc.get("name", ""))
var overrides: Dictionary = origin.get("disposition_overrides", {})
var companion_ids := {}
for c in world.companions():
companion_ids[c["id"]] = true
log.party.append(PartyMember.new(c["id"], c.get("name", ""), int(overrides.get(c["id"], 0))))
canon.party.append(PartyMember.new(c["id"], c.get("name", ""), int(overrides.get(c["id"], 0))))
for npc_id in overrides:
if not companion_ids.has(npc_id):
state.set_npc_disposition(npc_id, int(overrides[npc_id]))
for line in origin.get("situation", []):
log.push_event(line)
canon.push_event(line)
for fact in origin.get("opening_facts", []):
log.add_fact(fact)
canon.add_fact(fact)
var quest_id: Variant = origin.get("start_quest_id", null)
if quest_id != null:
var qd: Dictionary = world.quest(quest_id)
log.active_quests.append(Quest.new(qd.get("id", ""), qd.get("name", ""), "active",
canon.active_quests.append(Quest.new(qd.get("id", ""), qd.get("name", ""), "active",
qd.get("objective", "")))
# inventory grants -> game state (NOT the log). §2. grant() routes currency to the
@@ -90,7 +90,31 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary
for g in origin.get("inventory_grants", []):
state.grant(g.get("item_id", ""), int(g.get("qty", 0)))
return {"ok": true, "errors": [], "log": log, "state": state}
return {"ok": true, "errors": [], "log": canon, "state": state}
static func roll_attributes(character_seed: int) -> Dictionary:
## PUBLIC and PURE. The creation screen calls this to SHOW the roll; construct
## calls it to BUILD it. One implementation, so the two can never disagree.
## NO DEFAULT PARAMETERS (traps.md #2) — a defaulted arg turns a wrong-arity
## call into a silent miscompile that binds the wrong value into the wrong slot.
var rng := RandomNumberGenerator.new()
rng.seed = character_seed
return _roll_five(rng)
static func _roll_five(rng: RandomNumberGenerator) -> Dictionary:
var attrs: Dictionary = {}
for stat in Attributes.IDS:
attrs[stat] = _roll_attribute(rng)
return attrs
static func validate(origin: Dictionary, world: ContentDB, creation: Dictionary) -> Array:
## PUBLIC. The creation screen gates its CTA on this, so the player cannot press
## his way into a rejection. construct() still calls it and still does not trust
## its caller — the screen is a caller like any other. NO DEFAULT PARAMETERS.
return _validate(origin, world, creation)
static func _roll_attribute(rng: RandomNumberGenerator) -> int:

View File

@@ -31,10 +31,12 @@ static func format(copper: int) -> String:
return "0c"
var parts: Array[String] = []
var left := copper
var gold := left / GOLD # int division
@warning_ignore("integer_division") # denomination math — the remainder is carried, not lost
var gold := left / GOLD
if gold > 0:
parts.append("%dg" % gold)
left -= gold * GOLD
@warning_ignore("integer_division")
var silver := left / SILVER
if silver > 0:
parts.append("%ds" % silver)

View File

@@ -41,11 +41,33 @@ static func build_theme() -> Theme:
_dock_button(theme)
_parchment_button(theme)
_chip(theme)
_section_label(theme)
_select_card(theme)
_skill_chip(theme)
_skill_chip_granted(theme)
_tags_and_primary(theme)
_tiles(theme)
_cards_and_panels(theme)
return theme
static func _pill(bg: Color, radius: int, margin_x: int, margin_y: int) -> StyleBoxFlat:
# A BADGE, not a card. _flat() bakes in the 12/6 content margin every panel and
# button on the sheet wants — on a 9px "PRIMARY" flag that padding is most of the
# control, which is what made the badge swallow the ability card's rolled value.
# The mock's flags are padding:2px 9px (PRIMARY) and 3px 8px (CHOSEN).
var s := StyleBoxFlat.new()
s.bg_color = bg
s.border_color = bg
s.set_border_width_all(0)
s.set_corner_radius_all(radius)
s.content_margin_left = margin_x
s.content_margin_right = margin_x
s.content_margin_top = margin_y
s.content_margin_bottom = margin_y
return s
static func _flat(bg: Color, border: Color, width := 1, radius := 4) -> StyleBoxFlat:
var s := StyleBoxFlat.new()
s.bg_color = bg
@@ -77,8 +99,22 @@ static func _fonts(theme: Theme) -> void:
# Mono chrome role.
theme.set_type_variation(ThemeKeys.MONO, "Label")
theme.set_font(&"font", ThemeKeys.MONO, load(MONO))
theme.set_font_size(&"font_size", ThemeKeys.MONO, 13)
theme.set_font_size(&"font_size", ThemeKeys.MONO, 14)
theme.set_color(&"font_color", ThemeKeys.MONO, Palette.MUTED_MONO)
# Card type — the name + blurb inside a select-card. These sit ON PARCHMENT, so
# they take INK, not CREAM. Godot's built-in default Label colour is WHITE, and a
# Label left bare inherits it: the race and calling names shipped invisible on the
# parchment sheet for exactly that reason. There is no such thing as an unstyled
# Label on this sheet — every one of them names a role. (mock: 19px/17px #3a2f1c
# title, 13px italic-serif #6a5a3a blurb.)
theme.set_type_variation(ThemeKeys.CARD_TITLE, "Label")
theme.set_font(&"font", ThemeKeys.CARD_TITLE, serif)
theme.set_font_size(&"font_size", ThemeKeys.CARD_TITLE, 20)
theme.set_color(&"font_color", ThemeKeys.CARD_TITLE, Palette.INK_HEADING)
theme.set_type_variation(ThemeKeys.CARD_BODY, "Label")
theme.set_font(&"font", ThemeKeys.CARD_BODY, serif)
theme.set_font_size(&"font_size", ThemeKeys.CARD_BODY, 14)
theme.set_color(&"font_color", ThemeKeys.CARD_BODY, Palette.INK_MUTED)
static func _title_type(theme: Theme) -> void:
@@ -103,7 +139,7 @@ static func _rich_text(theme: Theme) -> void:
# (the narration book, later quest briefs/dialogue) reads dark serif ink from here.
theme.set_font(&"normal_font", "RichTextLabel", load(SERIF))
theme.set_font(&"italics_font", "RichTextLabel", load(SERIF_ITALIC))
theme.set_font_size(&"normal_font_size", "RichTextLabel", 20)
theme.set_font_size(&"normal_font_size", "RichTextLabel", 22)
theme.set_color(&"default_color", "RichTextLabel", Palette.INK_BODY)
@@ -175,10 +211,112 @@ static func _chip(theme: Theme) -> void:
theme.set_stylebox(&"pressed", k, _flat(Color(Palette.MUTED_MONO, 0.32), Palette.MUTED_MONO, 1, 3))
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
theme.set_font(&"font", k, load(MONO))
theme.set_font_size(&"font_size", k, 11)
theme.set_font_size(&"font_size", k, 12)
theme.set_color(&"font_color", k, Palette.MUTED_MONO)
static func _section_label(theme: Theme) -> void:
# The mono section headers on the parchment sheet (RACE / CALLING / NAME …).
# Mono's own colour is for dark surfaces; on parchment it needs the muted ink.
theme.set_type_variation(ThemeKeys.SECTION_LABEL, "Label")
theme.set_font(&"font", ThemeKeys.SECTION_LABEL, load(MONO))
theme.set_font_size(&"font_size", ThemeKeys.SECTION_LABEL, 14)
theme.set_color(&"font_color", ThemeKeys.SECTION_LABEL, Palette.INK_LABEL_MUTED)
static func _select_card(theme: Theme) -> void:
# Race + calling cards. Button-base (a PanelContainer variation's lone `panel`
# stylebox does not skin a Button — ADR 0001). `pressed` IS the CHOSEN state:
# the card is a toggle in a ButtonGroup, so `pressed` persists after the click.
var k := ThemeKeys.SELECT_CARD
theme.set_type_variation(k, "Button")
theme.set_stylebox(&"normal", k, _flat(Palette.PARCHMENT_CARD, Palette.PARCHMENT_BORDER, 1, 12))
theme.set_stylebox(&"hover", k, _flat(Palette.PARCHMENT_INSET, Palette.PARCHMENT_BORDER_3, 1, 12))
theme.set_stylebox(&"pressed", k, _flat(Palette.PARCHMENT_CARD, Palette.BLOOD, 3, 12))
theme.set_stylebox(&"disabled", k, _flat(Palette.PARCHMENT_INSET_2, Palette.PARCHMENT_BORDER_3, 1, 12))
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
theme.set_color(&"font_color", k, Palette.INK_HEADING)
theme.set_color(&"font_disabled_color", k, Palette.INK_MUTED)
static func _skill_chip(theme: Theme) -> void:
# A proficiency chip is a TOGGLE with three states the player must be able to
# read at a glance: unpicked (parchment), picked (crimson), and granted-by-race
# (dim + inert — the elf already has perception, so the chip cannot be pressed).
var k := ThemeKeys.SKILL_CHIP
theme.set_type_variation(k, "Button")
theme.set_stylebox(&"normal", k, _flat(Palette.PARCHMENT_CARD, Palette.PARCHMENT_BORDER_3, 1, 14))
theme.set_stylebox(&"hover", k, _flat(Palette.PARCHMENT_INSET, Palette.INK_LABEL, 1, 14))
theme.set_stylebox(&"pressed", k, _flat(Palette.BLOOD, Palette.BLOOD, 1, 14))
theme.set_stylebox(&"disabled", k, _flat(Palette.PARCHMENT_INSET_2, Palette.PARCHMENT_BORDER_3, 1, 14))
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
theme.set_font(&"font", k, load(MONO))
theme.set_font_size(&"font_size", k, 14)
theme.set_color(&"font_color", k, Palette.INK_LABEL)
theme.set_color(&"font_pressed_color", k, Palette.CREAM_BRIGHT)
theme.set_color(&"font_hover_color", k, Palette.INK_LABEL)
theme.set_color(&"font_disabled_color", k, Palette.PARCHMENT_BORDER_3)
static func _skill_chip_granted(theme: Theme) -> void:
# The FOURTH skill-chip state, split out of SkillChip's dim `disabled` look. A
# race-GRANTED skill (the elf already has perception) must read "you already have
# this" — OWNED — not "unavailable". So: a solid gold fill with cream text (the
# binding prefixes a ✓), where the pool-is-spent chips keep the plain dim disabled.
# It is inert like a disabled chip, so every interactive state paints the owned box.
var k := ThemeKeys.SKILL_CHIP_GRANTED
theme.set_type_variation(k, "Button")
var owned := _flat(Palette.INK_GOLD, Palette.INK_GOLD, 1, 14)
theme.set_stylebox(&"normal", k, owned)
theme.set_stylebox(&"hover", k, owned)
theme.set_stylebox(&"pressed", k, owned)
theme.set_stylebox(&"disabled", k, owned)
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
theme.set_font(&"font", k, load(MONO))
theme.set_font_size(&"font_size", k, 14)
theme.set_color(&"font_color", k, Palette.CREAM_BRIGHT)
theme.set_color(&"font_disabled_color", k, Palette.CREAM_BRIGHT)
static func _tags_and_primary(theme: Theme) -> void:
# The two little flags that ride on a card's top edge, and the gold ring the
# calling's primary attribute wears. Label takes a `normal` stylebox in Godot 4.
var chosen := ThemeKeys.CHOSEN_TAG
theme.set_type_variation(chosen, "Label")
theme.set_stylebox(&"normal", chosen, _pill(Palette.BLOOD, 5, 8, 3))
theme.set_font(&"font", chosen, load(MONO))
theme.set_font_size(&"font_size", chosen, 11)
theme.set_color(&"font_color", chosen, Palette.CREAM_BRIGHT)
var primary_tag := ThemeKeys.PRIMARY_TAG
theme.set_type_variation(primary_tag, "Label")
theme.set_stylebox(&"normal", primary_tag, _pill(Palette.INK_GOLD, 5, 9, 2))
theme.set_font(&"font", primary_tag, load(MONO))
theme.set_font_size(&"font_size", primary_tag, 10)
theme.set_color(&"font_color", primary_tag, Palette.CREAM_BRIGHT)
# The ability card in both states. It is NOT a plain ParchmentCard: it wears the PRIMARY
# flag on its top edge, and _flat()'s 6px top margin puts the "STR" key label directly
# under the badge — they collided. The mock's card is padding:16px 10px 14px, and that
# 16px IS the headroom the badge needs. ParchmentCard cannot simply be retuned: the
# shell shares it, and the shell has no badge.
theme.set_stylebox(&"panel", ThemeKeys.ABILITY_CARD,
_ability_card(Palette.PARCHMENT_BORDER, 1))
theme.set_type_variation(ThemeKeys.ABILITY_CARD, "PanelContainer")
theme.set_type_variation(ThemeKeys.PRIMARY_CARD, "PanelContainer")
theme.set_stylebox(&"panel", ThemeKeys.PRIMARY_CARD,
_ability_card(Palette.INK_GOLD, 2))
static func _ability_card(border: Color, width: int) -> StyleBoxFlat:
var s := _flat(Palette.PARCHMENT_CARD, border, width, 12)
s.content_margin_top = 16
s.content_margin_bottom = 14
s.content_margin_left = 10
s.content_margin_right = 10
return s
static func _tiles(theme: Theme) -> void:
var filled := _flat(Palette.PARCHMENT_INSET, Palette.PARCHMENT_BORDER, 2, 6)
theme.set_type_variation(ThemeKeys.ITEM_TILE, "PanelContainer")

View File

@@ -43,6 +43,7 @@ const INK_BODY := Color("4a3f2c")
const INK_MUTED := Color("6a5a3a")
const INK_LABEL := Color("5a4326")
const INK_GOLD := Color("8f6a2a")
const INK_LABEL_MUTED := Color("8a7748") # section labels on parchment (creation sheet)
# --- Text on dark ---
const CREAM := Color("e8ddc8")

View File

@@ -16,6 +16,17 @@ const ITEM_TILE_EMPTY := &"ItemTileEmpty"
const PARCHMENT_CARD := &"ParchmentCard"
const PARCHMENT_INSET := &"ParchmentInset"
const DARK_PANEL := &"DarkPanel"
const SELECT_CARD := &"SelectCard" # race/calling card: a Button whose `pressed` is CHOSEN
const SKILL_CHIP := &"SkillChip" # toggle: unpicked / picked / can't-pick-now (dim)
const SKILL_CHIP_GRANTED := &"SkillChipGranted" # a race-GRANTED skill: owned (gold + ✓), inert
const CHOSEN_TAG := &"ChosenTag" # the little crimson "CHOSEN" flag
const PRIMARY_TAG := &"PrimaryTag" # the little gold "PRIMARY" flag
const PRIMARY_CARD := &"PrimaryCard" # the ability card ringed as the calling's primary
const ABILITY_CARD := &"AbilityCard" # ...and its UNringed state. Not ParchmentCard: the
# ability card carries a badge on its top edge and
# needs the headroom for it (mock padding-top 16 vs
# the generic card's 6), and ParchmentCard is shared
# with the shell.
## Font roles (Label type-variations carrying a font, not a stylebox). Kept
## separate from ALL below, which is stylebox variations + their base type only.
@@ -24,6 +35,40 @@ const ACCENT := &"Accent"
const MONO := &"Mono"
const TITLE_LOGO := &"TitleLogo"
const TITLE_KICKER := &"TitleKicker"
const SECTION_LABEL := &"SectionLabel"
const CARD_TITLE := &"CardTitle" # a select-card's name, dark ink ON PARCHMENT
const CARD_BODY := &"CardBody" # a select-card's blurb, muted ink ON PARCHMENT
## Every FONT role + the base Control type it decorates. Deliberately NOT folded
## into ALL — ALL's "stylebox variations only" contract is relied on elsewhere
## (test_every_variation_resolves, and the styleboxes the screens set). Kept as a
## second enumerable set so a guard that means "every variation the builder
## touches" can iterate BOTH and never silently cover only half of them: the
## drift guard used to iterate ALL alone, so a font, a font size or a font colour
## could change in the builder and the committed .tres stay stale, suite green.
const FONT_ROLES := {
HEADING: "Label",
ACCENT: "Label",
MONO: "Label",
TITLE_LOGO: "Label",
TITLE_KICKER: "Label",
SECTION_LABEL: "Label",
CARD_TITLE: "Label",
CARD_BODY: "Label",
}
## Base Control types the builder styles DIRECTLY — no set_type_variation is
## ever called for these, so a fresh Theme reports get_type_variation_base("")
## for them. Kept as its own set (not folded into ALL or FONT_ROLES) because a
## base type is not a variation: nothing sets `theme_type_variation` to
## "RichTextLabel" anywhere, the node's own class name is the lookup key.
## Today: RichTextLabel, the builder's prose voice (mock README Typography:
## serif body/italic emphasis) — the creation screen's origin panel and
## calling detail panel are RichTextLabels rendering this styling directly,
## i.e. every line of DM prose the screen shows.
const BASE_TYPES := {
&"RichTextLabel": true,
}
## Every variation name + the base Control type it decorates. The builder and the
## test both iterate this so they can never drift apart.
@@ -39,4 +84,11 @@ const ALL := {
PARCHMENT_CARD: "PanelContainer",
PARCHMENT_INSET: "PanelContainer",
DARK_PANEL: "PanelContainer",
SELECT_CARD: "Button",
SKILL_CHIP: "Button",
SKILL_CHIP_GRANTED: "Button",
CHOSEN_TAG: "Label",
PRIMARY_TAG: "Label",
PRIMARY_CARD: "PanelContainer",
ABILITY_CARD: "PanelContainer",
}

View File

@@ -0,0 +1,304 @@
class_name CharacterCreation
extends Control
## The creation screen (mock: Character Creation). Layout is authored in
## CharacterCreation.tscn — open it in the editor to arrange it (ADR 0001). This
## script does on-load work only: @onready refs, signal wiring, and binding the
## draft into AUTHORED nodes. It creates no nodes and it owns no rules.
##
## Every rule lives in CreationDraft; every display string derived from a rules
## table lives in CreationCopy; the prose lives in ContentDB. This file is glue.
##
## §2: the screen never hands an attribute value to anything. It emits a seed and
## four choices, and NewGame.construct re-rolls from that seed itself.
## §7: LCK appears nowhere here. Five ability cards. Never six.
signal creation_confirmed(creation: Dictionary)
## Shown under the CTA when the draft is legal. When it is NOT legal, the first
## validation error takes this slot instead — the player is told what is wrong, in
## the same place, in the same dry voice. §13: even the nag is content.
const CTA_HINT := "no second chances out there"
## Injection seams — set BETWEEN instantiate() and add_child(), as the shell does
## for DmService. _ready builds the real ones when nothing was supplied.
var world: ContentDB = null
var origin: Dictionary = {}
var draft: CreationDraft
var _race_cards: Array = []
var _calling_cards: Array = []
var _pool_chips: Array = []
var _bonus_chips: Array = []
var _ability_cards: Array = []
@onready var _plate_name: Label = $Split/Bay/Col/Nameplate/Name
@onready var _plate_sub: Label = $Split/Bay/Col/Nameplate/Sub
@onready var _origin_text: RichTextLabel = $Split/Bay/Col/OriginCard/Box/Text
@onready var _race_row: HBoxContainer = $Split/Sheet/Body/RaceSection/Cards
@onready var _calling_row: HBoxContainer = $Split/Sheet/Body/CallingSection/Cards
@onready var _detail_blurb: RichTextLabel = $Split/Sheet/Body/CallingSection/Detail/Box/Blurb
@onready var _detail_mech: Label = $Split/Sheet/Body/CallingSection/Detail/Box/Mechanics
@onready var _pool_row: HBoxContainer = $Split/Sheet/Body/SkillSection/PoolRow
@onready var _pool_count: Label = $Split/Sheet/Body/SkillSection/PoolRow/Count
@onready var _bonus_row: HFlowContainer = $Split/Sheet/Body/SkillSection/BonusRow
@onready var _ability_row: HBoxContainer = $Split/Sheet/Body/AbilitySection/Cards
@onready var _points: Label = $Split/Sheet/Body/AbilitySection/Head/Points
@onready var _reroll: Button = $Split/Sheet/Body/AbilitySection/Head/Reroll
@onready var _name_edit: LineEdit = $Split/Sheet/Body/Footer/NameBox/NameEdit
@onready var _enter: Button = $Split/Sheet/Body/Footer/CtaBox/Enter
@onready var _error: Label = $Split/Sheet/Body/Footer/CtaBox/Error
func _ready() -> void:
# The .tscn root has a fixed size and NO full-rect anchors — a root Control run
# via F6 is not sized by full-rect anchors and collapses to (0,0) (the M3-a bug).
_fit_to_viewport()
get_viewport().size_changed.connect(_fit_to_viewport)
if world == null:
world = ContentDB.new()
world.load_from(ContentDB.default_content_root())
if origin.is_empty():
origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
_collect_authored_nodes()
_wire()
draft = CreationDraft.fresh()
draft.set_race(Races.IDS[0])
var allowed := _allowed_callings()
if not allowed.is_empty():
draft.set_calling(allowed[0])
# A degenerate origin (zero allowed callings, or no build_constraints at all)
# leaves calling_id unset rather than crashing — §13, never an error thrown
# at the player. The CTA simply stays disabled; there is no calling to invent.
_bind()
func _fit_to_viewport() -> void:
size = get_viewport_rect().size
func _allowed_callings() -> Array:
## The ORIGIN gates the callings. The screen does not hardcode seven — a later
## origin may allow fewer, and construct would reject a calling it disallows.
var allowed: Array = origin.get("build_constraints", {}).get("allowed_callings", [])
var out: Array = []
for id in Callings.IDS: # Callings.IDS fixes the ORDER; the origin fixes the SET
if id in allowed:
out.append(id)
return out
func _collect_authored_nodes() -> void:
# Fixed authored nodes, gathered — never created (ADR 0001). Spares hide.
for i in range(4):
_race_cards.append(_race_row.get_node("Race%d" % i))
for i in range(7):
_calling_cards.append(_calling_row.get_node("Calling%d" % i))
for i in range(6):
_pool_chips.append(_pool_row.get_node("Pool%d" % i))
for i in range(9):
_bonus_chips.append(_bonus_row.get_node("Bonus%d" % i))
for i in range(5):
_ability_cards.append(_ability_row.get_node("Ab%d" % i))
func _wire() -> void:
for i in range(_race_cards.size()):
_race_cards[i].pressed.connect(_on_race_pressed.bind(i))
for i in range(_calling_cards.size()):
_calling_cards[i].pressed.connect(_on_calling_pressed.bind(i))
for i in range(_pool_chips.size()):
_pool_chips[i].pressed.connect(_on_pool_pressed.bind(i))
for i in range(_bonus_chips.size()):
_bonus_chips[i].pressed.connect(_on_bonus_pressed.bind(i))
for i in range(_ability_cards.size()):
var card: PanelContainer = _ability_cards[i]
card.get_node("Box/Buttons/Minus").pressed.connect(_on_minus_pressed.bind(i))
card.get_node("Box/Buttons/Plus").pressed.connect(_on_plus_pressed.bind(i))
_reroll.pressed.connect(_on_reroll_pressed)
_name_edit.text_changed.connect(_on_name_changed)
_enter.pressed.connect(_on_enter_pressed)
# ---------------------------------------------------------------- input -> draft
func _on_race_pressed(i: int) -> void:
draft.set_race(Races.IDS[i])
_bind()
func _on_calling_pressed(i: int) -> void:
var allowed := _allowed_callings()
if i >= allowed.size():
return
draft.set_calling(allowed[i])
_bind()
func _on_pool_pressed(i: int) -> void:
var pool := draft.skill_pool()
if i < pool.size():
draft.toggle_skill(pool[i])
_bind()
func _on_bonus_pressed(i: int) -> void:
draft.set_bonus_skill(Skills.IDS[i])
_bind()
func _on_plus_pressed(i: int) -> void:
draft.increment(Attributes.IDS[i])
_bind()
func _on_minus_pressed(i: int) -> void:
draft.decrement(Attributes.IDS[i])
_bind()
func _on_reroll_pressed() -> void:
draft.reroll()
_bind()
func _on_name_changed(text: String) -> void:
draft.name = text
_bind()
func _on_enter_pressed() -> void:
if not draft.is_complete(origin, world):
return
creation_confirmed.emit(draft.to_creation())
# ---------------------------------------------------------------- draft -> nodes
func _bind() -> void:
_bind_races()
_bind_callings()
_bind_skills()
_bind_abilities()
_bind_bay()
_bind_cta()
func _bind_races() -> void:
for i in range(_race_cards.size()):
var id: String = Races.IDS[i]
var card: Button = _race_cards[i]
card.get_node("Box/Name").text = str(world.race(id).get("name", id.capitalize()))
card.get_node("Box/Blurb").text = str(world.race(id).get("blurb", ""))
card.get_node("Box/Trait").text = CreationCopy.race_trait(id)
card.button_pressed = (id == draft.race_id)
card.get_node("Tag").visible = card.button_pressed
func _bind_callings() -> void:
var allowed := _allowed_callings()
for i in range(_calling_cards.size()):
var card: Button = _calling_cards[i]
if i >= allowed.size():
card.visible = false # a spare authored slot: hide it (ADR 0001)
continue
var id: String = allowed[i]
card.visible = true
card.get_node("Box/Name").text = str(world.calling(id).get("name", id.capitalize()))
card.get_node("Box/Chip").text = Callings.primary(id).to_upper()
card.button_pressed = (id == draft.calling_id)
card.get_node("Tag").visible = card.button_pressed
_detail_blurb.text = "[i]%s[/i]" % str(world.calling(draft.calling_id).get("blurb", ""))
_detail_mech.text = CreationCopy.calling_detail(draft.calling_id)
func _bind_skills() -> void:
var pool := draft.skill_pool()
_pool_count.text = "%d of %d chosen" % [draft.skills.size(), draft.picks_needed()]
for i in range(_pool_chips.size()):
var chip: Button = _pool_chips[i]
if i >= pool.size():
chip.visible = false
continue
var skill: String = pool[i]
var granted := draft.is_granted(skill)
chip.visible = true
# A granted skill reads as OWNED (gold + ✓ via SkillChipGranted), not merely
# disabled — otherwise it looks identical to a "picks are spent" chip and reads
# as "unavailable" rather than "you already have this".
chip.theme_type_variation = ThemeKeys.SKILL_CHIP_GRANTED if granted else ThemeKeys.SKILL_CHIP
chip.text = ("" + CreationCopy.skill_label(skill)) if granted else CreationCopy.skill_label(skill)
chip.button_pressed = draft.is_picked(skill)
# Inert when the race already granted it, or when the picks are spent.
chip.disabled = granted or (not draft.is_picked(skill) and not draft.can_pick(skill))
_bonus_row.visible = draft.wants_bonus_skill()
for i in range(_bonus_chips.size()):
var skill: String = Skills.IDS[i]
var chip: Button = _bonus_chips[i]
chip.text = CreationCopy.skill_label(skill)
chip.button_pressed = (draft.bonus_skill == skill)
chip.disabled = not draft.can_take_bonus(skill) and draft.bonus_skill != skill
func _bind_abilities() -> void:
var rolled := draft.rolled()
var final := draft.final()
var primary := Callings.primary(draft.calling_id)
_points.text = "points left: %d" % draft.points_left()
for i in range(_ability_cards.size()):
var stat: String = Attributes.IDS[i]
var card: PanelContainer = _ability_cards[i]
var spent := int(draft.spend.get(stat, 0))
card.get_node("Box/Key").text = stat.to_upper()
card.get_node("Box/Value").text = str(int(final[stat]))
card.get_node("Box/Rolled").text = "rolled %d%s" % [
int(rolled[stat]), (" +%d" % spent) if spent > 0 else ""]
card.get_node("Box/Buttons/Plus").disabled = not draft.can_increment(stat)
card.get_node("Box/Buttons/Minus").disabled = not draft.can_decrement(stat)
# Overlay/Tag, not Tag: the ability card is a PanelContainer, and a Container
# force-fits every child it has — which stretched the badge across the card and
# parked it on top of the rolled value. The Control overlay is not a Container,
# so the badge's anchors survive and it rides the card's top edge (ADR 0001).
card.get_node("Overlay/Tag").visible = (stat == primary)
card.theme_type_variation = ThemeKeys.PRIMARY_CARD if stat == primary else ThemeKeys.ABILITY_CARD
func _bind_bay() -> void:
var shown_name := draft.name.strip_edges()
_plate_name.text = shown_name if shown_name != "" else "Nameless"
_plate_sub.text = "%s · %s" % [
str(world.race(draft.race_id).get("name", "")),
str(world.calling(draft.calling_id).get("name", "")),
]
_origin_text.text = "[i]%s[/i]" % _origin_prose()
func _origin_prose() -> String:
## The DM sizes you up — authored, not an AI call. The DM's real entrance is the
## shell's opening narration, one screen later, at the first moment where speaking
## means something (§13: authored text is CONTENT, not error handling).
var race: Dictionary = world.race(draft.race_id)
var calling: Dictionary = world.calling(draft.calling_id)
# A missing fragment degrades to the blurb rather than crashing. The content
# parity test stops that from ever shipping; this stops it from ever exploding.
var race_frag := str(race.get("fragment", race.get("blurb", "")))
var calling_frag := str(calling.get("fragment", calling.get("blurb", "")))
return "%s Now you carry a %s's work — %s" % [
race_frag, str(calling.get("name", "")).to_lower(), calling_frag]
func _bind_cta() -> void:
## The errors array is the VALIDATOR's, written for code. What lands on the
## label is CreationCopy's, written for the player — "choose two more
## proficiencies", not "hedge_mage picks 2 skills, got 0" (§13). The mapping is
## in the presentation layer on purpose: NewGame.validate's strings are a
## contract, and this screen is not the only thing that reads them.
var errors := draft.errors(origin, world)
_enter.disabled = not errors.is_empty()
_error.text = CreationCopy.error_line(str(errors[0]), draft) if not errors.is_empty() else CTA_HINT

View File

@@ -0,0 +1 @@
uid://3cxsq82gjlfk

View File

@@ -0,0 +1,149 @@
class_name CreationCopy
extends RefCounted
## Display strings for the creation screen, DERIVED from the rules tables.
##
## Prose lives in content (blurbs, fragments). NUMBERS LIVE IN CODE. The card reads
## both and repeats neither. A hit die written into a JSON — or typed into a string
## literal here — is a second source of truth, and it will eventually disagree with
## the table. Every number below is read from Callings/Races/Skills at call time, so
## retuning a calling retunes its card.
##
## §7: nothing here may mention Luck, in any form. §2: presentation only.
const ARMOR_WORD := {
"none": "no armour",
"light": "light armour",
"medium": "medium armour",
"heavy": "heavy armour",
}
## The one line the player reads when the draft will not hold. §13: a fallback is
## CONTENT, not error handling — so even an error nobody anticipated speaks in the
## DM's voice rather than showing him a validator's grammar.
const UNSPOKEN := "something in this does not hold — look again"
const NUMBER_WORD := {
1: "one", 2: "two", 3: "three", 4: "four", 5: "five",
6: "six", 7: "seven", 8: "eight", 9: "nine",
}
static func calling_detail(id: String) -> String:
## e.g. "d8 · light armour · MP (FTH) · saves DEX + MAG · picks 4 skills · talent backstab"
if not Callings.exists(id):
return ""
var saves: Array = []
for s in Callings.saves(id):
saves.append(s.to_upper())
return "d%d · %s · %s · saves %s · picks %d skills · talent %s" % [
Callings.hit_die(id),
armor_word(id),
resource_word(id),
" + ".join(saves),
Callings.skill_count(id),
talent_word(id),
]
static func talent_word(id: String) -> String:
## second_wind -> "second wind". Four of the seven talent IDS carry an
## underscore, and the card printed the raw id: the player read "talent
## second_wind" off the calling card. Ids are snake_case; humans are not — the
## same reason skill_label() exists. Read from the table at call time, so
## retuning a talent retunes the card.
return Callings.talent(id).replace("_", " ")
static func armor_word(id: String) -> String:
## The card says "no armour", not "none". Public so the test can assert against
## the DERIVED word rather than the raw table value — "none" is not a substring
## of "no armour", and asserting on the raw value only ever passed by accident.
return ARMOR_WORD.get(Callings.armor(id), Callings.armor(id))
static func resource_word(id: String) -> String:
## Martials live on cooldowns; casters on a pool off their casting stat.
if not Callings.is_caster(id):
return "cooldowns"
return "MP (%s)" % Callings.casting_stat(id).to_upper()
static func race_trait(id: String) -> String:
## e.g. "+1 to every save · +1 skill of choice" / "Poison resist · Nightsight"
if not Races.exists(id):
return ""
var parts: Array = []
if Races.save_bonus(id) > 0:
parts.append("+%d to every save" % Races.save_bonus(id))
for s in Races.granted_skills(id):
parts.append(skill_label(s).capitalize())
if Races.poison_save_bonus(id) > 0:
parts.append("Poison resist")
if Races.flag(id, "nightsight"):
parts.append("Nightsight")
if Races.flag(id, "claws"):
parts.append("Claws")
if Races.flag(id, "keen_scent"):
parts.append("Keen scent")
if Races.wants_bonus_skill(id):
parts.append("+1 skill of choice")
return " · ".join(parts)
static func skill_label(skill: String) -> String:
## sleight_of_hand -> "sleight of hand". Ids are snake_case; humans are not.
return skill.replace("_", " ")
# ------------------------------------------------------------------- §13: the nag
static func error_line(error: String, draft: CreationDraft) -> String:
## The validator's diagnostics are written for whoever is reading the errors
## array — "hedge_mage picks 2 skills, got 0", "unresolved ref: item:x". They are
## a CONTRACT (other code reads those strings), so they are not rewritten; they
## are TRANSLATED, here, in the presentation layer, where translating them
## belongs. What the player reads on the second screen of the game is authored
## copy: dry, second person, flat. Never an id, never a compiler's grammar (§13).
##
## Anything unmapped degrades to UNSPOKEN — never the raw string, never blank.
if error.begins_with("player name is required"):
return "name yourself"
if error.begins_with("unknown race"):
return "decide what blood you carry"
if error.begins_with("unknown calling"):
return "decide what you do for coin"
if error.begins_with("calling not allowed by origin"):
return "that trade is closed to you — you left it behind"
if error.contains(" picks ") and error.contains(" skills, got "):
return _proficiency_line(draft)
if error.begins_with("duplicate skill pick"):
return "you have taken the same proficiency twice"
if error.contains(" is not in the ") and error.ends_with(" pool"):
return "your calling never taught you that"
if error.contains(" is already granted by "):
return "you have that one already — it buys you nothing"
if error.contains(" must choose a bonus skill"):
return "one more proficiency, any of them — take it"
if error.begins_with("unknown bonus skill"):
return "no one here would call that a skill"
if error.begins_with("bonus skill ") and error.ends_with(" is already proficient"):
return "you have that one already — take another"
if error.contains(" does not get a bonus skill"):
return "your blood grants no proficiency of its own"
return UNSPOKEN
static func _proficiency_line(draft: CreationDraft) -> String:
## "choose two more proficiencies". The NUMBER is derived — Callings.skill_count
## is the rules table's business and the copy asks it, every time, rather than
## carrying a second copy of it that would one day disagree.
var left: int = draft.picks_left()
if left <= 0:
return "you have taken more proficiencies than your calling can carry"
if left == 1:
return "choose one more proficiency"
return "choose %s more proficiencies" % number_word(left)
static func number_word(n: int) -> String:
return str(NUMBER_WORD.get(n, n))

View File

@@ -0,0 +1 @@
uid://rm6bkgstenf1

View File

@@ -0,0 +1,222 @@
class_name CreationDraft
extends RefCounted
## The creation screen's whole brain. Pure, node-free, headlessly testable.
##
## Stores exactly the seven things a creation Dictionary is made of, and nothing
## derived. It CARRIES A SEED, NOT A STAT BLOCK — the screen shows the roll by
## calling NewGame.roll_attributes(seed), the same function construct calls, so
## the numbers on screen are the numbers the player gets and the screen still
## never hands a number to anything (§2).
##
## It owns no rules of its own: errors() delegates to NewGame.validate. A second
## copy of the rules is a second thing to keep in sync, and it would lose.
##
## §7: LCK is not here. Not a field, not an accessor, not a key. It is rolled
## inside construct off the same seed, immediately after MAG, and never surfaces.
var name: String = ""
var race_id: String = ""
var calling_id: String = ""
var spend: Dictionary = {} # {stat: int} — additive only
var skills: Array = [] # the player's picks (NOT the race's grants)
var bonus_skill: String = "" # human only
## NOT named `seed` — `seed()` is a global GDScript function and a member that
## shadows it reads as a bug (and warns). The creation Dictionary's KEY is still
## "seed"; only the GDScript identifier differs.
var character_seed: int = 0
static func fresh() -> CreationDraft:
## The ONE place non-determinism enters the entire pipeline — and what it
## produces is a seed, which is thereafter the character's anchor forever.
var d := CreationDraft.new()
d.character_seed = new_seed()
return d
static func new_seed() -> int:
var rng := RandomNumberGenerator.new()
rng.randomize()
return int(rng.randi())
# ---------------------------------------------------------------- the numbers
func rolled() -> Dictionary:
return NewGame.roll_attributes(character_seed)
func final() -> Dictionary:
var out := rolled()
for stat in spend:
out[stat] = int(out[stat]) + int(spend[stat])
return out
func spent_total() -> int:
var total := 0
for stat in spend:
total += int(spend[stat])
return total
func points_left() -> int:
## NewGame.SPEND_POOL is the ONE authority on the pool's size. A local copy of the
## number would be a second thing to keep in sync, and this class's whole contract
## is that it keeps none.
return NewGame.SPEND_POOL - spent_total()
func can_increment(stat: String) -> bool:
return Attributes.exists(stat) and points_left() > 0
func increment(stat: String) -> void:
if not can_increment(stat):
return
spend[stat] = int(spend.get(stat, 0)) + 1
func can_decrement(stat: String) -> bool:
## Removes only what YOU spent. "Additive only" is a rule about where the number
## can END UP — never below the roll — not about whether a mis-click is
## recoverable. Those are different things.
return int(spend.get(stat, 0)) > 0
func decrement(stat: String) -> void:
if not can_decrement(stat):
return
var left := int(spend[stat]) - 1
if left <= 0:
spend.erase(stat)
else:
spend[stat] = left
func reroll() -> void:
## A re-roll IS a new seed. Nothing else. The spend clears because points spent
## against a roll that no longer exists are meaningless; race, calling, skills
## and name survive because none of them came from the die.
##
## Note what the player is NOT told: the seed drives the five visible attributes
## AND the hidden Luck roll. Chase a 16 and you re-roll your Luck, blind, every
## time. That is §7 working exactly as designed. Say nothing.
character_seed = new_seed()
spend = {}
# ---------------------------------------------------------------- the choices
func set_race(id: String) -> void:
race_id = id
# A pick the new race GRANTS now buys nothing, and construct rejects it by name.
# Drop it here rather than let the player press a button into a 422.
var granted: Array = Races.granted_skills(id)
var kept: Array = []
for s in skills:
if s not in granted:
kept.append(s)
skills = kept
if not Races.wants_bonus_skill(id):
bonus_skill = ""
func set_calling(id: String) -> void:
# The pool changed underneath the picks. Keeping them leaves the player holding
# an invalid draft he did not cause and cannot see.
if id != calling_id:
skills = []
calling_id = id
func skill_pool() -> Array:
return Callings.skill_pool(calling_id)
func picks_needed() -> int:
return Callings.skill_count(calling_id)
func picks_left() -> int:
return picks_needed() - skills.size()
func is_granted(skill: String) -> bool:
return skill in Races.granted_skills(race_id)
func is_picked(skill: String) -> bool:
return skill in skills
func can_pick(skill: String) -> bool:
if is_granted(skill):
return false
# The mirror of can_take_bonus's `not is_picked(skill)`. Without it the guard is
# one-directional: take athletics as the human's bonus, then pick it AGAIN from
# the calling's pool, and the draft is invalid ("bonus skill athletics is already
# proficient") — the player holding an invalid draft he did not cause and cannot
# see, which is the exact thing set_calling's comment forbids. The chip goes inert
# instead; the bonus row is right there and the player can hand it back.
if skill == bonus_skill:
return false
if skill not in skill_pool():
return false
return picks_left() > 0
func toggle_skill(skill: String) -> void:
if is_picked(skill):
skills.erase(skill)
return
if can_pick(skill):
skills.append(skill)
func wants_bonus_skill() -> bool:
return Races.wants_bonus_skill(race_id)
func can_take_bonus(skill: String) -> bool:
if not wants_bonus_skill():
return false
if not Skills.exists(skill):
return false
return not is_granted(skill) and not is_picked(skill)
func set_bonus_skill(skill: String) -> void:
if bonus_skill == skill:
bonus_skill = ""
return
if can_take_bonus(skill):
bonus_skill = skill
# ---------------------------------------------------------------- the contract
func to_creation() -> Dictionary:
## The plain Dictionary NewGame.construct takes. A saga (later) synthesizes one
## of these and skips this screen entirely — so it stays a plain Dictionary, and
## this scene must never become the only thing that can produce it.
return {
"name": name.strip_edges(),
"race_id": race_id,
"calling_id": calling_id,
"seed": character_seed, # the DICT key stays "seed" — the contract is unchanged
"spend": spend.duplicate(),
"skills": skills.duplicate(),
"bonus_skill": bonus_skill,
}
func errors(origin: Dictionary, world: ContentDB) -> Array:
## Delegates. The screen owns no second copy of the rules — it asks construct's
## validator what is wrong, and shows the answer.
return NewGame.validate(origin, world, to_creation())
func is_complete(origin: Dictionary, world: ContentDB) -> bool:
return errors(origin, world).is_empty()

View File

@@ -0,0 +1 @@
uid://dkxuw5c4r6tld

View File

@@ -0,0 +1,761 @@
extends "res://addons/gut/test.gd"
const SCENE := "res://scenes/creation/CharacterCreation.tscn"
const ContentDB = preload("res://scripts/content/content_db.gd")
const NewGame = preload("res://scripts/newgame/new_game.gd")
func _world() -> ContentDB:
var w := ContentDB.new()
w.load_from(ContentDB.default_content_root())
return w
func _screen() -> CharacterCreation:
# Inject origin + world BEFORE _ready (add_child) — instantiate() does not run
# _ready(), add_child does. Same seam the shell uses for DmService.
var node = load(SCENE).instantiate()
node.world = _world()
node.origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
add_child_autofree(node)
return node
func _screen_with_origin(origin: Dictionary) -> CharacterCreation:
# Same seam as _screen(), but with an origin the CALLER controls — used to
# prove the calling-gate actually gates, which the real "deserter" origin
# (all 7 callings allowed) cannot do: 7 shown == 7 allowed is trivially true
# whether or not the hide branch in _bind_callings exists at all.
var node = load(SCENE).instantiate()
node.world = _world()
node.origin = origin
add_child_autofree(node)
return node
func _complete_a_valid_draft(s: CharacterCreation) -> void:
## Drives the draft to a legal state by pressing the same AUTHORED NODES a
## player's clicks fire — not by calling the _on_*_pressed handlers directly.
## Calling the handler proves the handler works; it proves nothing about
## _wire() actually connecting the node's `pressed` signal to it. Pressing the
## node is the only path that exercises both at once.
s._name_edit.text = "Aldric"
s._on_name_changed("Aldric") # LineEdit.text_changed is not raised by setting .text in a test.
s._race_cards[Races.IDS.find("human")].pressed.emit()
s._calling_cards[s._allowed_callings().find("sellsword")].pressed.emit()
var pool: Array = s.draft.skill_pool()
for i in range(pool.size()):
s._pool_chips[i].pressed.emit()
for i in range(Skills.IDS.size()):
if s.draft.can_take_bonus(Skills.IDS[i]):
s._bonus_chips[i].pressed.emit()
break
func test_screen_applies_theme_and_fits_viewport():
var packed = load(SCENE)
assert_true(packed is PackedScene)
var s := _screen()
assert_true(s is Control)
assert_true(s.theme is Theme, "the screen pulls the shared theme")
# The .tscn root is authored at exactly 1920x1080 (project.godot's viewport
# size too) — assert_gt(s.size.x, 0.0) passes on the AUTHORED offsets alone,
# even with _fit_to_viewport() and its size_changed connection deleted
# outright. Prove the fit is actually live: change the window's reported
# resolution to something that is NOT 1920x1080 and assert the root followed.
#
# get_viewport().size / get_window().size do not move get_viewport_rect() —
# this project runs with window/stretch/mode="canvas_items", so a Control's
# visible rect tracks content_scale_size, not the raw window pixel size.
# content_scale_size is the property that actually reaches Viewport.size_changed
# here, headless included.
var win := s.get_window()
var original_scale_size: Vector2i = win.content_scale_size
win.content_scale_size = Vector2i(1600, 900)
assert_eq(s.size, Vector2(1600, 900),
"the root must follow a resize through _fit_to_viewport()'s size_changed hook (the M3-a collapse guard)")
win.content_scale_size = original_scale_size
assert_eq(s.size, Vector2(1920, 1080), "restoring the viewport must restore the root, or later tests see a stale size")
func test_a_draft_exists_with_a_seed_on_load():
var s := _screen()
assert_not_null(s.draft)
assert_ne(s.draft.character_seed, 0)
func test_five_ability_cards_never_six():
# §7 — LCK has no card, no row, no tooltip. The player must never be able to
# CALCULATE that he is cursed.
#
# _ability_cards is populated by _collect_authored_nodes() looping
# `range(5)` — that loop is 5 by construction and would stay 5 even if the
# .tscn grew a 6th card. Assert against the SCENE's actual children instead.
var s := _screen()
assert_eq(s._ability_cards.size(), 5)
assert_eq(s._ability_row.get_child_count(), 5,
"five authored ability cards — the scene, not the script's range()")
assert_null(s._ability_row.get_node_or_null("Ab5"),
"there is no sixth ability card, and there never is (§7)")
for card in s._ability_cards:
assert_false(card.name.to_lower().contains("lck"))
func test_no_node_on_the_screen_says_luck():
# Sweep every Control, not just Label — RichTextLabel (_origin_text,
# _detail_blurb, the only two nodes rendering authored ContentDB prose)
# extends Control, not Label, and a `find_children(..., "Label", ...)` filter
# never looks at it. Buttons (`text`) and the LineEdit (`placeholder_text`)
# are swept too.
#
# The two RichTextLabels render race.fragment / calling.fragment /
# calling.blurb, which DIFFER per race and per calling. Checking only the
# default binding (human + sellsword) means a "luck" in, say, the Reaver's
# blurb or the Beastfolk's fragment would reach the screen and never be seen.
# Rebind across every race x every allowed calling (the deserter origin used
# by _screen() allows all 7, so this is a full 4 x 7 = 28-combination sweep).
var s := _screen()
var checked_a_rich_text_label := false
var combos_checked := 0
# The word "luck" is not the only thing that leaks Luck. Luck.BANDS holds the
# DESCRIPTORS — "the dice are kind today" — and a descriptor on this screen is
# strictly worse than the number: the player would re-roll until his Luck read
# well, which is Luck made CALCULABLE, the precise §7 failure. It is structurally
# unreachable today (the screen holds no GameState and the draft has no LCK), and
# it costs three lines to keep it that way.
var forbidden: Array = ["luck", "lck"]
for band in Luck.BANDS:
forbidden.append(str(band["text"]).to_lower())
for race_i in range(Races.IDS.size()):
s._race_cards[race_i].pressed.emit()
var allowed: Array = s._allowed_callings()
for calling_i in range(allowed.size()):
s._calling_cards[calling_i].pressed.emit()
combos_checked += 1
var combo := "%s + %s" % [Races.IDS[race_i], allowed[calling_i]]
for node in s.find_children("*", "Control", true, false):
if node is RichTextLabel:
checked_a_rich_text_label = true
for prop in ["text", "placeholder_text", "tooltip_text"]:
var value: Variant = node.get(prop)
if typeof(value) != TYPE_STRING:
continue
var lowered: String = value.to_lower()
for word in forbidden:
assert_false(lowered.contains(word),
"%s.%s leaks Luck (§7) at %s: '%s'" % [node.name, prop, combo, word])
# `checked_a_rich_text_label` alone only proves the sweep REACHED a
# RichTextLabel, not that it had anything in it — an empty label would
# pass the Luck check for a reason that has nothing to do with §7.
#
# But _bind_bay() and _bind_callings() both wrap their prose in BBCode
# italics ("[i]%s[/i]"), and _origin_prose() always emits the literal
# connective " Now you carry a %s's work — " even when every fragment is
# blank. So `"[i][/i]".strip_edges().is_empty()` is FALSE — the wrapper
# alone would pass this guard even if ContentDB served nothing at all.
# Assert the labels render the WORLD's own strings, not just non-blankness.
var race_id: String = Races.IDS[race_i]
var calling_id: String = allowed[calling_i]
var race_frag: String = str(s.world.race(race_id).get("fragment", s.world.race(race_id).get("blurb", "")))
var calling_frag: String = str(s.world.calling(calling_id).get("fragment", s.world.calling(calling_id).get("blurb", "")))
var calling_blurb: String = str(s.world.calling(calling_id).get("blurb", ""))
assert_false(race_frag.strip_edges().is_empty(),
"race %s has no fragment (or blurb) to render — the content itself must not be blank" % race_id)
assert_false(calling_frag.strip_edges().is_empty(),
"calling %s has no fragment (or blurb) to render — the content itself must not be blank" % calling_id)
assert_false(calling_blurb.strip_edges().is_empty(),
"calling %s has no blurb to render — the content itself must not be blank" % calling_id)
assert_true(s._origin_text.text.contains(race_frag),
"the DM panel must render %s's own fragment, not just non-empty BBCode, at %s" % [race_id, combo])
assert_true(s._origin_text.text.contains(calling_frag),
"the DM panel must render %s's own fragment, not just non-empty BBCode, at %s" % [calling_id, combo])
assert_true(s._detail_blurb.text.contains(calling_blurb),
"the detail panel must render %s's own blurb, not just non-empty BBCode, at %s" % [calling_id, combo])
assert_true(checked_a_rich_text_label,
"the sweep must actually reach the RichTextLabels or it is checking nothing")
assert_eq(combos_checked, Races.IDS.size() * Callings.IDS.size(),
"every race must be checked against every allowed calling — 4 x 7, not just the default binding")
func test_only_the_origin_s_allowed_callings_are_shown():
# The deserter origin allows all 7 callings, so against it "shown == allowed"
# is a trivial 7 == 7 that would stay green even with the hide branch in
# _bind_callings deleted outright. Use a RESTRICTIVE origin, deliberately out
# of Callings.IDS order, so the gate is doing real work.
var allowed := ["bonesetter", "sellsword", "cutpurse"]
var s := _screen_with_origin({"build_constraints": {"allowed_callings": allowed}})
var expected_order: Array = []
for id in Callings.IDS: # Callings.IDS fixes the ORDER
if id in allowed: # the origin fixes the SET
expected_order.append(id)
assert_eq(expected_order.size(), 3)
var shown := 0
for i in range(s._calling_cards.size()):
var card: Button = s._calling_cards[i]
if i < expected_order.size():
assert_true(card.visible, "calling %s must be shown" % expected_order[i])
var expected_name: String = str(
s.world.calling(expected_order[i]).get("name", expected_order[i].capitalize()))
assert_eq(card.get_node("Box/Name").text, expected_name,
"visible cards must appear in Callings.IDS order, not the origin's list order")
shown += 1
else:
assert_false(card.visible,
"a calling the origin disallows stays hidden, not deleted")
assert_eq(shown, 3)
func test_the_deserter_origin_shows_all_seven_callings():
var s := _screen()
var shown := 0
for card in s._calling_cards:
if card.visible:
shown += 1
assert_eq(shown, 7)
func test_a_degenerate_origin_disables_entry_without_throwing():
# §13 — a degrade, never a crash. An origin with no build_constraints at all
# (or an empty allowed_callings list) must leave the draft with no calling to
# invent, every calling card hidden, and the CTA disabled — quietly.
#
# NOT {} — CharacterCreation._ready() treats an EMPTY origin Dictionary as
# "none supplied" and substitutes the deserter origin (`origin.is_empty()`),
# which allows all 7 callings and is not degenerate at all. A non-empty
# Dictionary that simply carries no build_constraints is the real degenerate
# case §13's comment in the source describes.
var s := _screen_with_origin({"id": "test-degenerate-origin"})
assert_eq(s.draft.calling_id, "",
"no allowed callings means no calling to default into")
for card in s._calling_cards:
assert_false(card.visible, "every calling card hides when none are allowed")
assert_true(s._enter.disabled, "a calling-less draft can never be legal")
func test_authored_node_counts_match_the_rules_tables():
# _collect_authored_nodes() hardcodes 4/7/6/9/5. A table growing (a 5th race,
# an 8th calling, a 10th skill) must go red HERE, before a binder silently
# under-renders or indexes a table out of range at runtime.
var s := _screen()
assert_eq(s._race_cards.size(), Races.IDS.size())
assert_eq(s._calling_cards.size(), Callings.IDS.size())
assert_eq(s._bonus_chips.size(), Skills.IDS.size())
assert_eq(s._ability_cards.size(), Attributes.IDS.size())
# The pool row's 6 is the WIDEST calling's skill pool, not a table length —
# assert it covers every calling, so a 7-wide pool added later goes red
# instead of silently dropping a chip.
for id in Callings.IDS:
assert_true(s._pool_chips.size() >= Callings.skill_pool(id).size(),
"the pool row must fit %s's skill pool" % id)
# The above ties the SCRIPT's arrays to the tables. It says nothing about the
# SCENE — an orphan authored card (a stray "Race4" nobody wired or hid) would
# render, unbound and visible, with every assertion above still green. Tie the
# rows' actual child counts to the same tables.
assert_eq(s._race_row.get_child_count(), Races.IDS.size(),
"an orphan authored race card would sit here, unbound and visible")
assert_eq(s._calling_row.get_child_count(), Callings.IDS.size(),
"an orphan authored calling card would sit here, unbound and visible")
# BonusRow's first child is its own section Label ("HUMAN — ONE MORE, ANY
# SKILL") — not a chip — so its total is one MORE than Skills.IDS.size().
assert_eq(s._bonus_row.get_child_count(), Skills.IDS.size() + 1,
"an orphan authored bonus chip would sit here, unbound and visible")
# PoolRow was the one row the sweep above forgot — a stray authored "Pool6" would
# render unbound and visible with every assertion so far still green, which is
# exactly the bug this test exists to catch. Its count is not a table LENGTH: it
# is the widest calling's pool (plus its own "Count" label, which is not a chip),
# so derive that from the rules table rather than writing 6 down again.
var widest := 0
for id in Callings.IDS:
widest = maxi(widest, Callings.skill_pool(id).size())
assert_eq(widest, s._pool_chips.size(), "the pool row must hold exactly the widest calling's pool")
assert_eq(s._pool_row.get_child_count(), widest + 1,
"an orphan authored pool chip would sit here, unbound and visible (PoolRow also holds its Count label)")
assert_null(s._pool_row.get_node_or_null("Pool%d" % widest),
"there is no chip past the widest calling's pool — a stray Pool%d renders unbound" % widest)
assert_eq(s._pool_count.get_parent(), s._pool_row, "the +1 above is the Count label, and nothing else")
func test_cta_is_disabled_until_the_draft_is_legal():
var s := _screen()
assert_true(s._enter.disabled, "an empty draft cannot enter the world")
assert_ne(s._error.text, CharacterCreation.CTA_HINT, "the screen says WHY, dryly")
assert_ne(s._error.text.strip_edges(), "")
_complete_a_valid_draft(s)
assert_false(s._enter.disabled)
assert_eq(s._error.text, CharacterCreation.CTA_HINT,
"a legal draft gets the mock's line back, not a blank")
func test_the_nag_under_the_cta_is_authored_copy_and_never_a_validator_string():
# §13. The label used to read `str(errors[0])` — so the second screen of the game
# showed the player "sellsword picks 2 skills, got 0". Drive the screen through
# every invalid state it can actually reach, by pressing the nodes, and pin what
# the label says. Anchored to the authored phrases themselves: "contains no
# underscore" alone would pass on a blank label, or on the wrong copy.
var s := _screen() # human + sellsword, nameless, nothing picked
var seen: Array = []
assert_eq(s._error.text, "name yourself", "the first thing missing is a name")
seen.append(s._error.text)
s._name_edit.text = "Aldric"
s._on_name_changed("Aldric") # LineEdit.text_changed is not raised by setting .text
assert_eq(s._error.text, "choose two more proficiencies",
"the sellsword picks 2 and has none — and the two is DERIVED from Callings.skill_count")
seen.append(s._error.text)
s._pool_chips[0].pressed.emit()
assert_eq(s.draft.skills.size(), 1, "the pick landed, or the line below is about nothing")
assert_eq(s._error.text, "choose one more proficiency", "one is singular")
seen.append(s._error.text)
s._pool_chips[1].pressed.emit()
assert_eq(s.draft.skills.size(), 2)
assert_eq(s._error.text, "one more proficiency, any of them — take it",
"the human's bonus skill, in the Margreave's voice, not 'human must choose a bonus skill'")
seen.append(s._error.text)
for i in range(Skills.IDS.size()):
if s.draft.can_take_bonus(Skills.IDS[i]):
s._bonus_chips[i].pressed.emit()
break
assert_eq(s._error.text, CharacterCreation.CTA_HINT, "a legal draft gets the mock's line back")
# Nothing above may be an engineering string, and none of them may repeat.
assert_eq(seen.size(), 4)
for line in seen:
assert_false(line.contains("_"), "a snake_case id reached the player: %s" % line)
assert_false(line.contains("got "), "the validator's grammar reached the player: %s" % line)
var unique := {}
for line in seen:
unique[line] = true
assert_eq(unique.size(), 4, "each invalid state says a DIFFERENT thing — the nag is telling him what is missing")
func test_a_skill_taken_as_the_bonus_goes_inert_in_the_calling_s_pool():
# The one-directional guard: can_take_bonus() refused an already-PICKED skill, but
# can_pick() ignored the bonus. Human + Sellsword, take athletics as the bonus,
# then press athletics in the pool — the chip was live and the pick LANDED, leaving
# a draft NewGame.validate rejects ("bonus skill athletics is already proficient").
var s := _screen() # human + sellsword
var pool: Array = s.draft.skill_pool()
var athletics_i: int = pool.find("athletics")
var bonus_i: int = Skills.IDS.find("athletics")
assert_ne(athletics_i, -1, "athletics must be in the sellsword's pool or this proves nothing")
assert_false(s._pool_chips[athletics_i].disabled, "the chip starts live, or the assertion below is trivially true")
s._bonus_chips[bonus_i].pressed.emit()
assert_eq(s.draft.bonus_skill, "athletics")
assert_true(s._pool_chips[athletics_i].disabled,
"a skill the bonus already bought must render INERT in the pool")
# Button.pressed fires even on a disabled Button — the rule lives in the draft, not
# in the styling, so press it and prove the pick does not land.
s._pool_chips[athletics_i].pressed.emit()
assert_false(s.draft.is_picked("athletics"), "the pick must not land")
assert_false("bonus skill athletics is already proficient" in s.draft.errors(s.origin, s.world),
"the player must not be able to press his way into an invalid draft")
# And it is recoverable: hand the bonus back and the chip is live again.
s._bonus_chips[bonus_i].pressed.emit()
assert_eq(s.draft.bonus_skill, "")
assert_false(s._pool_chips[athletics_i].disabled, "handing the bonus back makes the pool chip live")
s._pool_chips[athletics_i].pressed.emit()
assert_true(s.draft.is_picked("athletics"))
func test_pressing_enter_emits_exactly_the_draft_s_creation_dict():
var s := _screen()
_complete_a_valid_draft(s)
watch_signals(s)
s._enter.pressed.emit()
assert_signal_emitted_with_parameters(s, "creation_confirmed", [s.draft.to_creation()])
func test_the_screen_never_constructs_the_character():
# The flow is M4-c's job, and a saga later synthesizes the same Dictionary and
# skips this scene entirely. The screen PRODUCES the dict; it does not spend it.
var s := _screen()
assert_false(s.has_method("construct"))
func test_the_displayed_scores_are_the_draft_s_scores():
var s := _screen()
_complete_a_valid_draft(s)
# A fresh, unspent draft has final() == rolled() for EVERY stat, so comparing
# the Value label against final() alone cannot tell a `final`/`rolled` swap in
# the ability binder apart from the real thing. Spend a point — by pressing
# the actual Plus BUTTON, not _on_plus_pressed, so a broken _wire() would also
# be caught here — so final() and rolled() diverge and the assertion below has
# to actually choose one.
var spent_stat: String = Attributes.IDS[0]
s._ability_cards[0].get_node("Box/Buttons/Plus").pressed.emit()
assert_eq(int(s.draft.spend.get(spent_stat, 0)), 1, "the spend actually landed")
var rolled: Dictionary = s.draft.rolled()
var final: Dictionary = s.draft.final()
assert_eq(int(final[spent_stat]), int(rolled[spent_stat]) + 1,
"spending a point must move final away from rolled, or nothing below distinguishes them")
for i in range(Attributes.IDS.size()):
var stat: String = Attributes.IDS[i]
var card = s._ability_cards[i]
var value_label: Label = card.get_node("Box/Value")
var rolled_label: Label = card.get_node("Box/Rolled")
assert_eq(value_label.text, str(int(final[stat])),
"%s's Value must show final(), not rolled()" % stat)
var spent: int = int(s.draft.spend.get(stat, 0))
var expected_rolled_text: String = "rolled %d%s" % [
int(rolled[stat]), (" +%d" % spent) if spent > 0 else ""]
assert_eq(rolled_label.text, expected_rolled_text,
"%s's Rolled sub-label must show the roll and the spend, nothing else covers it" % stat)
func test_reroll_changes_what_is_on_screen():
var s := _screen()
# Spend a point BEFORE the re-roll, by pressing the actual Plus button — a
# fresh draft's pool already reads "points left: 3" with nothing spent, so
# asserting that value after reroll alone proves nothing about whether
# reroll() actually clears the spend.
s._ability_cards[0].get_node("Box/Buttons/Plus").pressed.emit()
assert_eq(s._points.text, "points left: 2", "the spend landed before the reroll")
var before := s.draft.character_seed
s._reroll.pressed.emit()
assert_ne(s.draft.character_seed, before)
assert_eq(s._points.text, "points left: 3", "a re-roll returns the pool")
func test_pressing_a_race_card_sets_the_draft_s_race():
# Nothing in the suite ever emits a card's `pressed` signal — every existing
# test called _on_race_pressed directly. A mis-wired card (bound to the wrong
# index, or not connected at all — see _wire()) would leave every other test
# green. Press the elf card itself.
var s := _screen()
var elf_i: int = Races.IDS.find("elf")
s._race_cards[elf_i].pressed.emit()
assert_eq(s.draft.race_id, "elf",
"the CARD's pressed signal must reach the draft through _wire()")
assert_true(s._race_cards[elf_i].get_node("Tag").visible, "the chosen card shows its tag")
func test_pressing_a_calling_card_sets_the_draft_s_calling():
var s := _screen()
var idx: int = s._allowed_callings().find("cutpurse")
s._calling_cards[idx].pressed.emit()
assert_eq(s.draft.calling_id, "cutpurse",
"the CARD's pressed signal must reach the draft through _wire()")
func test_pressing_a_pool_chip_toggles_the_skill():
var s := _screen()
var pool: Array = s.draft.skill_pool()
var skill: String = pool[0]
s._pool_chips[0].pressed.emit()
assert_true(s.draft.is_picked(skill),
"the CHIP's pressed signal must reach toggle_skill through _wire()")
s._pool_chips[0].pressed.emit()
assert_false(s.draft.is_picked(skill), "pressing it again toggles it back off")
func test_pressing_a_bonus_chip_sets_the_bonus_skill():
var s := _screen() # human is the default race, and wants a bonus skill
var idx := -1
for i in range(Skills.IDS.size()):
if s.draft.can_take_bonus(Skills.IDS[i]):
idx = i
break
assert_ne(idx, -1, "the default draft (human) must have a takeable bonus skill")
s._bonus_chips[idx].pressed.emit()
assert_eq(s.draft.bonus_skill, Skills.IDS[idx],
"the CHIP's pressed signal must reach set_bonus_skill through _wire()")
func test_pressing_the_minus_button_returns_a_spent_point():
# Nothing else in the suite ever presses Minus — every prior test only ever
# spent points, never gave one back.
var s := _screen()
var stat: String = Attributes.IDS[0]
var card = s._ability_cards[0]
card.get_node("Box/Buttons/Plus").pressed.emit()
assert_eq(int(s.draft.spend.get(stat, 0)), 1)
card.get_node("Box/Buttons/Minus").pressed.emit()
assert_eq(int(s.draft.spend.get(stat, 0)), 0,
"the MINUS button's pressed signal must reach the draft through _wire()")
assert_eq(s._points.text, "points left: 3")
func test_minus_is_disabled_at_the_rolled_floor_plus_at_the_spent_pool():
var s := _screen()
var card = s._ability_cards[0]
var plus: Button = card.get_node("Box/Buttons/Plus")
var minus: Button = card.get_node("Box/Buttons/Minus")
assert_true(minus.disabled, "nothing has been spent yet — minus must be inert at the rolled floor")
plus.pressed.emit()
assert_false(minus.disabled, "a spent point makes minus live again")
# Drain the whole pool (SPEND_POOL == 3) into this one stat. points_left() is
# POOL-wide, not per-card, so every Plus on the screen must go inert together.
# A BOUNDED loop, deliberately — `while points_left() > 0` would hang forever
# if _wire() were ever broken (the press would never reach the draft), turning
# a guard meant to catch that exact bug into a stuck test run instead of a red one.
var pool_size: int = s.draft.points_left()
for i in range(pool_size + 1):
plus.pressed.emit()
assert_eq(s.draft.points_left(), 0)
assert_true(plus.disabled, "the pool is spent — this card's Plus must go inert")
var other_plus: Button = s._ability_cards[1].get_node("Box/Buttons/Plus")
assert_true(other_plus.disabled, "points_left() is pool-wide — every card's Plus disables together")
func test_a_race_granted_skill_chip_is_inert_and_so_is_a_chip_once_picks_are_spent():
# Two real binder behaviours in one chip's line
# (chip.disabled = draft.is_granted(skill) or (not is_picked(skill) and not can_pick(skill)))
# that nothing in the suite observed. Elf grants "perception"; Cutpurse's pool
# has 6 skills (one of them "perception") and needs 4 picks — the only
# race/calling pairing where both halves of that OR are exercised at once.
var s := _screen()
s._race_cards[Races.IDS.find("elf")].pressed.emit()
var cutpurse_i: int = s._allowed_callings().find("cutpurse")
s._calling_cards[cutpurse_i].pressed.emit()
var pool: Array = s.draft.skill_pool()
var perception_i: int = pool.find("perception")
assert_ne(perception_i, -1, "cutpurse's pool must include perception for this test to mean anything")
assert_true(s._pool_chips[perception_i].disabled,
"a race-granted skill must be inert — the player already has it for free")
# Spend every pick on the OTHER skills in the pool, leaving exactly one behind.
var picked := 0
var left_behind_i := -1
for i in range(pool.size()):
if i == perception_i:
continue
if picked < s.draft.picks_needed():
s._pool_chips[i].pressed.emit()
picked += 1
else:
left_behind_i = i
assert_eq(picked, s.draft.picks_needed(), "the pool must actually be spent for this test to mean anything")
assert_ne(left_behind_i, -1, "cutpurse's pool must be wider than its pick count for this test to mean anything")
assert_true(s._pool_chips[left_behind_i].disabled,
"once the picks are spent, the remaining UNPICKED chip must go inert too")
assert_false(s._pool_chips[perception_i].button_pressed,
"the granted chip was never toggled — it was inert from the start")
func test_bonus_row_shows_only_for_the_race_that_wants_one():
# _bonus_row.visible = draft.wants_bonus_skill() — nothing in the suite checks
# this. Human wants a bonus skill; the other three races do not.
var s := _screen() # default race is human
assert_true(s._bonus_row.visible, "human wants a bonus skill choice")
for race_id in ["elf", "dwarf", "beastfolk"]:
s._race_cards[Races.IDS.find(race_id)].pressed.emit()
assert_false(s._bonus_row.visible, "%s does not want a bonus skill choice" % race_id)
s._race_cards[Races.IDS.find("human")].pressed.emit()
assert_true(s._bonus_row.visible, "switching back to a bonus-wanting race must show the row again")
func test_ability_card_surfaces_the_calling_s_primary_stat():
# The RACE card's Tag is checked (test_pressing_a_race_card_sets_the_draft_s_race);
# the ability card's equivalent — Tag.visible and theme_type_variation marking
# which of the five stats is the calling's primary — is not.
var s := _screen()
var idx: int = s._allowed_callings().find("cutpurse")
s._calling_cards[idx].pressed.emit()
var primary: String = Callings.primary("cutpurse")
assert_eq(primary, "dex", "cutpurse's primary must be dex for this test to mean anything")
for i in range(Attributes.IDS.size()):
var stat: String = Attributes.IDS[i]
var card: PanelContainer = s._ability_cards[i]
var is_primary := (stat == primary)
assert_eq(card.get_node("Overlay/Tag").visible, is_primary,
"%s's Tag must show only when it is the calling's primary stat" % stat)
var expected_variation: StringName = ThemeKeys.PRIMARY_CARD if is_primary else ThemeKeys.ABILITY_CARD
assert_eq(card.theme_type_variation, expected_variation,
"%s's card style must reflect whether it is the primary stat" % stat)
func test_pressing_enter_on_an_illegal_draft_emits_nothing():
# _on_enter_pressed's early-out (`if not draft.is_complete(): return`) is the
# one branch in the file nothing else in the suite enters — every other Enter
# test first drives the draft to a legal state. Button.pressed still fires even
# on a disabled Button, so the early-out must be proven explicitly.
var s := _screen()
assert_true(s._enter.disabled, "an empty draft must start illegal, or this test proves nothing")
watch_signals(s)
s._enter.pressed.emit()
assert_signal_not_emitted(s, "creation_confirmed",
"an illegal draft must never reach creation_confirmed, even when Enter fires anyway")
# ------------------------------------------- the screen a human actually has to read
## Luminance gap a Label must clear against the surface it is drawn on. Every styled
## Label on the sheet measures >= 0.419 (the dimmest is SectionLabel's ink); an UNSTYLED
## Label — Godot's built-in Label colour is WHITE — measures 0.112 on parchment. The
## threshold sits in that gap, nearer the failure so it cannot be squeaked past.
const MIN_CONTRAST := 0.30
func _labels_under(n: Node, out: Array = []) -> Array:
for c in n.get_children():
if c is Label:
out.append(c)
_labels_under(c, out)
return out
func _laid_out() -> void:
## Geometry assertions are meaningless until the container tree has actually sorted.
## On the frame add_child() runs, a race card reports 102px wide and its autowrapped
## blurb reports a 2508px minimum height (one unwrapped line) — numbers that would
## make a size guard fail for the wrong reason, or pass for one.
await get_tree().process_frame
await get_tree().process_frame
func test_no_label_on_the_parchment_sheet_is_unreadable():
# The race and calling names, and the race blurbs, SHIPPED INVISIBLE: 15 Labels were
# left with no type-variation, so they inherited Godot's default Label colour — white
# — and rendered white-on-parchment. The trait line beneath them was the only readable
# string on the card. Nothing in the suite could see it, because every assertion was
# about text CONTENT, and the content was correct. It was the colour that was wrong.
#
# This is TOTAL: no Label is skipped. A Label that paints its own background (the
# CHOSEN / PRIMARY pills) is judged against THAT background rather than the sheet, so
# it is checked, not exempted — a whitelist here would be the same hole traps.md #16
# describes, where the guard's own `continue` covered the gap it was hunting.
var s := _screen()
var sheet: Control = s.get_node("Split/Sheet")
var parchment := Palette.SHEET_TOP.get_luminance()
var checked := 0
for lab in _labels_under(sheet):
var col: Color = lab.get_theme_color(&"font_color")
var bg := parchment
var surface := "the parchment sheet"
var sb: StyleBox = lab.get_theme_stylebox(&"normal")
if sb is StyleBoxFlat and (sb as StyleBoxFlat).bg_color.a > 0.0:
bg = (sb as StyleBoxFlat).bg_color.get_luminance()
surface = "its own pill"
checked += 1
assert_gt(absf(col.get_luminance() - bg), MIN_CONTRAST,
"'%s' (variation '%s') is unreadable on %s: text luminance %.3f vs surface %.3f" % [
lab.name, str(lab.theme_type_variation), surface, col.get_luminance(), bg])
# 15 Labels carried the bug. If this sweep ever walks fewer than that it has stopped
# reaching the cards, and a green result would mean nothing.
assert_gt(checked, 15, "the sweep must actually reach the sheet's labels, not an empty subtree")
func test_the_primary_badge_rides_the_card_edge_instead_of_covering_the_roll():
# The ability card is a PanelContainer. A Container FORCE-FITS its children, so the
# badge's authored anchors were dead letters: it stretched to the card's full content
# width (200 of 224px) and centred itself vertically, straight across the rolled value
# — measured, not guessed. Nesting it under a plain Control (NOT a Container) restores
# absolute positioning. These three assertions are the three ways that regressed.
var s := _screen()
_complete_a_valid_draft(s)
await _laid_out()
var primary: String = Callings.primary("sellsword")
var i: int = Attributes.IDS.find(primary)
assert_gt(i, -1, "the sellsword's primary must be a real attribute, or this test proves nothing")
var card: PanelContainer = s._ability_cards[i]
var tag: Label = card.get_node("Overlay/Tag")
var value: Label = card.get_node("Box/Value")
assert_true(tag.visible, "the primary card must show its badge, or the geometry below is untested")
var tag_r := tag.get_global_rect()
var card_r := card.get_global_rect()
var value_r := value.get_global_rect()
assert_lt(tag_r.position.y, card_r.position.y,
"the badge must RIDE the card's top edge, not sit inside the card")
assert_lt(tag_r.size.x, card_r.size.x * 0.6,
"the badge is a flag, not a banner — it stretched to the card's full width (%.0f of %.0f)" % [
tag_r.size.x, card_r.size.x])
assert_false(tag_r.intersects(value_r),
"the badge is covering the rolled value: badge %s vs value %s" % [tag_r, value_r])
func test_no_card_overflows_its_own_border():
# "+1 skill of choice" and "Claws · Keen scent" rendered BELOW the race card's bottom
# border: the card's height is a fixed custom_minimum_size, and its Box is anchored
# inside it rather than being a container child — so content CANNOT push the card
# taller, it just spills out. Every card must be authored tall enough for its own text.
var s := _screen()
await _laid_out()
var cards: Array = []
cards.append_array(s._race_cards)
cards.append_array(s._calling_cards)
assert_gt(cards.size(), 4, "no cards reached the sweep")
# Measure against the CARD, never against the Box's own size. Godot clamps a Control's
# size UP to its combined minimum, so box.size.y is forced equal to the very number it
# would be compared against — `needed <= box.size.y` is a tautology that stays green
# with the card at its original, overflowing 108px. It was written that way first and
# it passed against the bug it names (traps.md: a green suite is evidence only if the
# test can fail). The card's height is the thing the text actually spills out of.
for card in cards:
var box: VBoxContainer = card.get_node("Box")
var needed := box.get_combined_minimum_size().y
# The Box is anchored inside the card; offset_bottom is negative, so this is the
# card's height less its top and bottom insets.
var available: float = card.size.y - box.offset_top + box.offset_bottom
assert_lte(needed, available,
"'%s' overflows its card: content needs %.0fpx, the card leaves %.0fpx" % [
box.get_node("Name").text, needed, available])
func test_the_hint_label_advertises_the_floor_the_roll_actually_enforces():
# The label used to say "rolled 3d6" and stop there, but the roll is maxi(8, 3d6)
# (new_game.gd) — so a player who rolled low read a label that looked like it was
# lying to him. The label now names the floor, and this pins it TO THE CODE.
#
# The floor is OBSERVED, never hardcoded: roll a deterministic spread of seeds and
# take the minimum the player can actually be dealt. Retune maxi(8, ..) to 7 and the
# observed floor becomes 7, the label still claims 8, and this goes red. Delete the
# clamp and the floor falls to 3 — red. Reword the label past the claim — red.
#
# It costs the phrase "never below N": reword that and update this string, do not
# delete the guard. The number must keep coming from the roll.
var observed_floor := 99
for seed_value in range(1, 201): # deterministic — no RNG in the test itself
for stat in Attributes.IDS:
observed_floor = mini(observed_floor, int(NewGame.roll_attributes(seed_value)[stat]))
# ~16% of 3d6 lands at or under 7, so across 1000 rolls the minimum is the CLAMP,
# not the luck of the draw. Assert the clamp is actually binding rather than trusting
# that: an unclamped 3d6 reaches 3, and a floor at or below 3 would make the pin vacuous.
assert_true(observed_floor > 3,
"the roll's minimum is %d — the clamp is not binding, so the label's floor claim is a lie" % observed_floor)
var hint: Label = _screen().get_node("Split/Sheet/Body/AbilitySection/Head/Hint")
assert_string_contains(hint.text, "never below %d" % observed_floor)
assert_string_contains(hint.text, "3d6")

View File

@@ -0,0 +1 @@
uid://dmliumgn42tv8

View File

@@ -130,3 +130,51 @@ func test_blurb_content_carries_no_mechanics():
"nightsight", "claws", "keen_scent"]:
assert_false(db.races[id].has(banned),
"%s.json leaks the mechanic '%s' — that lives in Races" % [id, banned])
func test_every_race_and_calling_carries_a_fragment():
# The DM origin panel (M4-b) composes race.fragment + calling.fragment. A
# missing one degrades to the blurb at runtime rather than crashing — but it
# must never SHIP missing, so the suite is where it fails.
#
# The composition is "{race.fragment} Now you carry a {Calling}'s work —
# {calling.fragment}", so three shape rules make all 28 pairings legal
# sentences: a race fragment opens a sentence (uppercase) and closes it (.);
# a calling fragment continues one after the em dash (lowercase) and closes
# it (.). Guard them here — a future author adding a calling gets a signal,
# not a subtly ungrammatical DM.
for id in Races.IDS:
assert_true(db.race(id).has("fragment"), "race %s has no fragment" % id)
var race_fragment := str(db.race(id).get("fragment", "")).strip_edges()
assert_ne(race_fragment, "", "race %s has an empty fragment" % id)
assert_eq(race_fragment.substr(0, 1), race_fragment.substr(0, 1).to_upper(),
"race %s: fragment opens the composition, so it must start uppercase" % id)
assert_true(race_fragment.ends_with("."),
"race %s: fragment must end with '.' — a sentence precedes 'Now you carry…'" % id)
for id in Callings.IDS:
assert_true(db.calling(id).has("fragment"), "calling %s has no fragment" % id)
var calling_fragment := str(db.calling(id).get("fragment", "")).strip_edges()
assert_ne(calling_fragment, "", "calling %s has an empty fragment" % id)
assert_eq(calling_fragment.substr(0, 1), calling_fragment.substr(0, 1).to_lower(),
"calling %s: fragment follows an em dash, so it must start lowercase" % id)
assert_true(calling_fragment.ends_with("."),
"calling %s: fragment must end with '.' — it closes the composition" % id)
func test_no_calling_is_named_with_a_vowel():
# The fourth shape rule of that same composition, and the one that breaks SILENTLY.
# CharacterCreation._origin_prose() hardcodes the article:
# "%s Now you carry a %s's work — %s"
# All seven callings are consonant-initial, so all 28 compositions read correctly
# today and nothing anywhere would notice an eighth. Add "an Auger" and every
# Auger in the game gets "Now you carry a auger's work" on the DM panel — no
# error, no test, just a DM who cannot speak. Fail HERE, where the author is.
var vowels := ["a", "e", "i", "o", "u"]
for id in Callings.IDS:
var calling_name := str(db.calling(id).get("name", "")).strip_edges()
assert_ne(calling_name, "", "calling %s has no name" % id)
assert_false(calling_name.substr(0, 1).to_lower() in vowels,
("calling %s is named '%s'. The DM origin panel composes "
+ "\"Now you carry a %s's work\" with the article HARDCODED as \"a\" "
+ "(CharacterCreation._origin_prose) — a vowel-initial calling needs \"an\". "
+ "Change the template first, then this guard.") % [id, calling_name, calling_name.to_lower()])

View File

@@ -0,0 +1,214 @@
extends "res://addons/gut/test.gd"
const ContentDB = preload("res://scripts/content/content_db.gd")
const NewGame = preload("res://scripts/newgame/new_game.gd")
var world
func before_each():
world = ContentDB.new()
world.load_from(ContentDB.default_content_root())
func _deserter() -> Dictionary:
return ContentDB.load_json(ContentDB.origin_path("deserter"))
func _draft(race := "human", calling := "sellsword") -> CreationDraft:
var d := CreationDraft.new()
d.character_seed = 8675309
d.set_race(race)
d.set_calling(calling)
d.name = "Aldric"
return d
func test_calling_detail_reads_the_table_rather_than_repeating_it():
# The guard that fails if anyone types "d8" into a string literal. If someone
# retunes the Cutpurse's hit die, this test keeps passing and the CARD FOLLOWS —
# which is the whole point. A hardcoded line would go red here.
#
# The talent is asserted through the DERIVED phrase ("talent second wind"), not
# the raw id ("second_wind") the card used to print — but it is still read from
# Callings.talent() at assert time, so hardcoding a talent into the format string
# still goes red (traps.md #7: anchor to the full derived phrase).
for id in Callings.IDS:
var line := CreationCopy.calling_detail(id)
assert_string_contains(line, "d%d" % Callings.hit_die(id))
assert_string_contains(line, CreationCopy.armor_word(id))
assert_string_contains(line, "talent %s" % CreationCopy.talent_word(id))
assert_string_contains(line, "picks %d skills" % Callings.skill_count(id))
for s in Callings.saves(id):
assert_string_contains(line, s.to_upper())
func test_the_talent_is_humanised_and_no_card_line_shows_a_raw_id():
# The player read "talent second_wind" off the Sellsword's card. Four of the seven
# talents carry an underscore — the card printed the ID.
assert_eq(CreationCopy.talent_word("sellsword"), "second wind")
assert_eq(CreationCopy.talent_word("cutpurse"), "backstab", "an underscore-free talent is untouched")
var underscored := 0
for id in Callings.IDS:
if Callings.talent(id).contains("_"):
underscored += 1
assert_false(CreationCopy.calling_detail(id).contains("_"),
"%s's card line shows a raw snake_case id: %s" % [id, CreationCopy.calling_detail(id)])
assert_eq(underscored, 4,
"four talents carry an underscore — if that ever hits zero this guard stops guarding")
func test_casters_show_a_pool_and_martials_show_cooldowns():
assert_string_contains(CreationCopy.calling_detail("hedge_mage"), "MP (MAG)")
assert_string_contains(CreationCopy.calling_detail("bonesetter"), "MP (FTH)")
assert_string_contains(CreationCopy.calling_detail("sellsword"), "cooldowns")
assert_false(CreationCopy.calling_detail("sellsword").contains("MP"))
func test_race_trait_lines_are_derived_from_the_race_table():
assert_string_contains(CreationCopy.race_trait("human"), "every save")
assert_string_contains(CreationCopy.race_trait("human"), "skill of choice")
assert_string_contains(CreationCopy.race_trait("elf"), "Perception")
assert_string_contains(CreationCopy.race_trait("elf"), "Nightsight")
assert_string_contains(CreationCopy.race_trait("dwarf"), "Poison")
assert_string_contains(CreationCopy.race_trait("dwarf"), "Nightsight")
assert_string_contains(CreationCopy.race_trait("beastfolk"), "Claws")
assert_string_contains(CreationCopy.race_trait("beastfolk"), "Keen scent")
func test_every_race_has_a_non_empty_trait_line():
for id in Races.IDS:
assert_ne(CreationCopy.race_trait(id).strip_edges(), "", "%s has no trait line" % id)
func test_skill_labels_are_human_readable():
assert_eq(CreationCopy.skill_label("sleight_of_hand"), "sleight of hand")
assert_eq(CreationCopy.skill_label("faith_lore"), "faith lore")
assert_eq(CreationCopy.skill_label("stealth"), "stealth")
func test_nothing_in_the_copy_mentions_luck():
# §7 — the player must never be able to CALCULATE that he is cursed.
for id in Callings.IDS:
assert_false(CreationCopy.calling_detail(id).to_lower().contains("luck"))
assert_false(CreationCopy.calling_detail(id).to_lower().contains("lck"))
for id in Races.IDS:
assert_false(CreationCopy.race_trait(id).to_lower().contains("luck"))
assert_false(CreationCopy.race_trait(id).to_lower().contains("lck"))
# ------------------------------------------------------------------- §13: the nag
func test_the_nag_is_authored_copy_not_the_validator_s_grammar():
# §13. The exact strings a human must be able to judge, pinned. Anchored to the
# authored phrase itself — asserting only "no underscore" would pass on any
# string that happens to have none, including a blank one.
var human := _draft("human", "sellsword")
human.name = ""
assert_eq(CreationCopy.error_line("player name is required", human), "name yourself")
# "sellsword picks 2 skills, got 0" -> the count is DERIVED from the rules table.
assert_eq(Callings.skill_count("sellsword"), 2, "the sellsword picks 2, or the line below means nothing")
assert_eq(CreationCopy.error_line("sellsword picks 2 skills, got 0", human),
"choose two more proficiencies")
human.toggle_skill("athletics")
assert_eq(CreationCopy.error_line("sellsword picks 2 skills, got 1", human),
"choose one more proficiency", "one is singular, and the number is never hardcoded")
# The same error against a calling that picks FOUR must say four — the proof the
# number comes from Callings.skill_count and not from a literal "two".
var thief := _draft("dwarf", "cutpurse")
assert_eq(Callings.skill_count("cutpurse"), 4, "the cutpurse picks 4, or the line below means nothing")
assert_eq(CreationCopy.error_line("cutpurse picks 4 skills, got 0", thief),
"choose four more proficiencies")
assert_eq(CreationCopy.error_line("human must choose a bonus skill", human),
"one more proficiency, any of them — take it")
func test_an_unmapped_error_still_speaks_in_voice():
# §13: "write fallbacks as content, not as error handling." An error nobody
# anticipated (broken content; a spend the screen cannot produce) must never put
# the raw string on the label, and must never blank it either.
var d := _draft()
for raw in ["unresolved ref: item:ghost_blade", "seed is required and must be an integer",
"spend exceeds the pool of 3 (got 5)", "cannot spend on 'lck' — not an attribute"]:
var line := CreationCopy.error_line(raw, d)
assert_eq(line, CreationCopy.UNSPOKEN, "an unmapped error degrades to the authored fallback: %s" % raw)
assert_ne(line, raw)
assert_ne(CreationCopy.UNSPOKEN.strip_edges(), "", "the fallback is CONTENT — it is never blank")
assert_false(CreationCopy.UNSPOKEN.contains("_"))
func _is_deliberately_unmapped(raw: String) -> bool:
# The shapes error_line has NO authored line for, on purpose, because the
# screen structurally cannot produce them (CreationDraft always emits an
# int seed and a spend built only from increment/decrement, which can
# never exceed the pool or name a non-attribute) — a malformed seed or
# spend, or a broken content reference, is not a player mistake to voice
# in character; test_an_unmapped_error_still_speaks_in_voice already pins
# the fallback for exactly this set. Named by the validator's own prefixes
# so this stays independent of error_line's internals.
return (raw.begins_with("unresolved ref:")
or raw.begins_with("seed is required")
or raw.begins_with("spend")
or raw.begins_with("cannot spend on")
or raw == "skills must be an array")
func test_no_error_the_validator_can_raise_reaches_the_player_raw():
# Sweep EVERY error NewGame.validate actually produces for a draft the screen can
# be driven into (plus the hostile shapes only a saga could hand it), and assert
# the player never sees the id, the underscore, or the validator's grammar — AND,
# for every shape error_line actually has an authored line for, that the line
# fired rather than silently falling through to the generic UNSPOKEN fallback.
#
# CreationCopy.UNSPOKEN ("something in this does not hold — look again") is
# itself non-blank, isn't the raw string, has no underscore, and has no "got " —
# so the four checks below cannot tell "correctly mapped to its authored line"
# from "silently fell through." Without the UNSPOKEN check, renaming a validator
# string in new_game.gd so an error_line branch stops matching would leave every
# error line reading the generic fallback, suite green.
var d := _draft()
var hostiles: Array = [
{"name": "", "skills": [], "bonus_skill": ""}, # nameless, unpicked
{"skills": ["athletics"]}, # too few
{"skills": ["athletics", "athletics"]}, # duplicate
{"skills": ["athletics", "sorcery"]}, # not in the pool
{"race_id": "elf", "skills": ["athletics", "perception"], "bonus_skill": ""}, # granted already
{"bonus_skill": ""}, # human, no bonus
{"bonus_skill": "athletics", "skills": ["athletics", "endurance"]}, # bonus == a pick
{"bonus_skill": "swimming"}, # no such skill
{"race_id": "dwarf", "bonus_skill": "stealth"}, # dwarf gets none
{"race_id": "orc"}, {"calling_id": "paladin"}, # off the tables
{"seed": "8675309"}, {"spend": {"lck": 3}}, {"spend": {"str": 9}},
]
var base := {
"name": "Aldric", "race_id": "human", "calling_id": "sellsword", "seed": 8675309,
"spend": {}, "skills": ["athletics", "endurance"], "bonus_skill": "perception",
}
var seen := 0
var mapped_seen := 0
for h in hostiles:
var creation := base.duplicate(true)
for k in h:
creation[k] = h[k]
var errors: Array = NewGame.validate(_deserter(), world, creation)
assert_gt(errors.size(), 0, "hostile creation %s must actually be invalid, or it guards nothing" % str(h))
for e in errors:
seen += 1
var raw := str(e)
var line: String = CreationCopy.error_line(raw, d)
assert_ne(line.strip_edges(), "", "'%s' put a BLANK line on the label" % raw)
assert_ne(line, raw, "'%s' reached the player raw" % raw)
assert_false(line.contains("_"), "'%s' put a snake_case id on the label: %s" % [raw, line])
assert_false(line.contains("got "), "'%s' put the validator's grammar on the label: %s" % [raw, line])
if not _is_deliberately_unmapped(raw):
mapped_seen += 1
assert_ne(line, CreationCopy.UNSPOKEN,
"'%s' has an authored error_line branch but silently fell through to the generic fallback" % raw)
assert_gt(seen, 12, "the sweep must actually reach a spread of errors, not one or two")
assert_gt(mapped_seen, 8, "the sweep must actually exercise error_line's mapped branches, not just the deliberately-unmapped ones")

View File

@@ -0,0 +1 @@
uid://bvb87ime573x4

View File

@@ -0,0 +1,241 @@
extends "res://addons/gut/test.gd"
const ContentDB = preload("res://scripts/content/content_db.gd")
const NewGame = preload("res://scripts/newgame/new_game.gd")
var world
func before_each():
world = ContentDB.new()
world.load_from(ContentDB.default_content_root())
func _deserter() -> Dictionary:
return ContentDB.load_json(ContentDB.origin_path("deserter"))
func _draft() -> CreationDraft:
# A fixed seed, so every assertion below is reproducible.
var d := CreationDraft.new()
d.character_seed = 8675309
d.set_race("human")
d.set_calling("sellsword")
d.name = "Aldric"
return d
func test_a_fresh_draft_has_a_seed_and_no_choices():
var d := CreationDraft.fresh()
assert_ne(d.character_seed, 0, "randomize() gave us the one bit of non-determinism there is")
assert_eq(d.spend, {})
assert_eq(d.skills, [])
assert_eq(d.bonus_skill, "")
func test_rolled_is_exactly_what_new_game_will_roll():
var d := _draft()
assert_eq(d.rolled(), NewGame.roll_attributes(d.character_seed),
"the screen and construct must reach the same five numbers")
func test_final_is_rolled_plus_spend():
var d := _draft()
d.increment("str")
d.increment("str")
assert_eq(int(d.final()["str"]), int(d.rolled()["str"]) + 2)
assert_eq(int(d.final()["dex"]), int(d.rolled()["dex"]), "an unspent stat is untouched")
func test_the_pool_is_three_and_the_fourth_point_does_not_exist():
var d := _draft()
assert_eq(d.points_left(), 3)
d.increment("str"); d.increment("dex"); d.increment("con")
assert_eq(d.points_left(), 0)
assert_false(d.can_increment("fth"))
d.increment("fth")
assert_eq(int(d.final()["fth"]), int(d.rolled()["fth"]), "the fourth point bought nothing")
assert_eq(d.spent_total(), 3)
func test_minus_removes_only_what_you_spent_and_floors_at_the_roll():
var d := _draft()
d.increment("str")
assert_true(d.can_decrement("str"))
d.decrement("str")
assert_eq(d.points_left(), 3)
assert_false(d.can_decrement("str"), "you cannot go below the roll — additive only")
d.decrement("str")
assert_eq(int(d.final()["str"]), int(d.rolled()["str"]))
func test_reroll_changes_the_numbers_and_clears_the_spend_and_nothing_else():
var d := _draft()
d.set_calling("cutpurse")
d.toggle_skill("stealth")
d.increment("dex")
var before := d.character_seed
d.reroll()
assert_ne(d.character_seed, before, "a re-roll IS a new seed — nothing else")
assert_eq(d.spend, {}, "points spent against a roll that no longer exists are meaningless")
assert_eq(d.calling_id, "cutpurse", "the calling survives")
assert_eq(d.skills, ["stealth"], "the picks survive")
assert_eq(d.name, "Aldric", "the name survives")
func test_changing_calling_clears_the_picks():
var d := _draft()
d.toggle_skill("athletics")
assert_eq(d.skills, ["athletics"])
d.set_calling("hedge_mage")
assert_eq(d.skills, [], "the pool changed underneath them — stale picks are an invalid draft the player did not cause")
func test_changing_race_drops_a_pick_the_new_race_now_grants():
# The elf/cutpurse case. An Elf is GRANTED perception; a Cutpurse who had
# picked it now holds a pick that "buys nothing" and construct rejects by name.
var d := _draft()
d.set_calling("cutpurse")
d.toggle_skill("perception")
assert_eq(d.skills, ["perception"])
d.set_race("elf")
assert_eq(d.skills, [], "the elf already has perception — the pick is dropped, not left to 422")
func test_changing_race_off_human_clears_the_bonus_skill():
var d := _draft()
d.set_bonus_skill("stealth")
assert_eq(d.bonus_skill, "stealth")
d.set_race("dwarf")
assert_eq(d.bonus_skill, "", "only a human gets a bonus skill")
func test_a_granted_skill_cannot_be_picked():
var d := _draft()
d.set_race("elf")
d.set_calling("cutpurse")
assert_true(d.is_granted("perception"))
assert_false(d.can_pick("perception"))
d.toggle_skill("perception")
assert_eq(d.skills, [], "the chip is inert, not merely styled to look inert")
func test_picks_stop_at_the_calling_s_count():
var d := _draft()
d.set_calling("sellsword") # picks 2
d.toggle_skill("athletics")
d.toggle_skill("endurance")
assert_eq(d.picks_left(), 0)
d.toggle_skill("perception")
assert_eq(d.skills, ["athletics", "endurance"], "the third pick does not exist")
func test_toggling_an_already_picked_skill_removes_it():
var d := _draft()
d.toggle_skill("athletics")
d.toggle_skill("athletics")
assert_eq(d.skills, [])
func test_the_bonus_skill_cannot_also_be_picked_from_the_pool():
# The guard was one-directional: can_take_bonus() refused a skill already PICKED,
# but can_pick() did not consider the bonus. Human + Sellsword, take athletics as
# the bonus, then press athletics in the Sellsword's pool — the pick landed and
# the draft went invalid ("bonus skill athletics is already proficient"), which is
# precisely the invalid draft "he did not cause and cannot see" that set_calling's
# comment forbids.
var d := _draft() # human + sellsword; athletics is in the pool
d.set_bonus_skill("athletics")
assert_eq(d.bonus_skill, "athletics")
assert_true("athletics" in d.skill_pool(), "athletics must be in the sellsword's pool or this proves nothing")
assert_false(d.can_pick("athletics"), "the chip must be INERT, not merely styled to look inert")
d.toggle_skill("athletics")
assert_eq(d.skills, [], "the pick must not land while athletics is the bonus skill")
# ...and the draft the press would have produced is exactly the one construct rejects.
d.skills = ["athletics", "endurance"] # what the bug let the player build
assert_true("bonus skill athletics is already proficient" in d.errors(_deserter(), world),
"this IS the invalid draft the guard exists to prevent")
func test_the_other_direction_still_holds_a_picked_skill_cannot_become_the_bonus():
# The half of the guard that already worked. Kept beside its mirror so the two
# can never drift apart again.
var d := _draft()
d.toggle_skill("athletics")
assert_eq(d.skills, ["athletics"])
assert_false(d.can_take_bonus("athletics"))
d.set_bonus_skill("athletics")
assert_eq(d.bonus_skill, "", "a picked skill must not also be taken as the bonus")
func test_handing_back_the_bonus_skill_makes_the_pool_chip_live_again():
# The inert chip is recoverable — the bonus row is right there. Without this the
# fix above would be a dead end the player could walk into and not walk out of.
var d := _draft()
d.set_bonus_skill("athletics")
assert_false(d.can_pick("athletics"))
d.set_bonus_skill("athletics") # pressing the chosen bonus chip clears it
assert_eq(d.bonus_skill, "")
assert_true(d.can_pick("athletics"), "handing the bonus back must make the pool chip live")
d.toggle_skill("athletics")
assert_eq(d.skills, ["athletics"])
func test_to_creation_carries_exactly_the_seven_contract_keys():
# §2, stated as a test rather than as a comment. The screen emits a seed and four
# choices; it never hands construct a NUMBER. test_pressing_enter_emits_exactly_
# the_draft_s_creation_dict compares the emitted dict to to_creation() — both
# sides move together, so adding an "attributes" key there breaks nothing. This
# pins the contract itself: seven keys, no more, no fewer.
var d := _draft()
var keys: Array = d.to_creation().keys()
keys.sort()
assert_eq(keys, ["bonus_skill", "calling_id", "name", "race_id", "seed", "skills", "spend"],
"the creation contract is exactly seven keys — an eighth (an 'attributes' block, say) is the §2 breach")
func test_to_creation_round_trips_through_construct():
var d := _draft()
d.toggle_skill("athletics")
d.toggle_skill("endurance")
d.set_bonus_skill("perception")
d.increment("str")
var res := NewGame.construct(_deserter(), world, d.to_creation())
assert_true(res["ok"], str(res["errors"]))
assert_eq(res["state"].sheet.attributes, d.final(),
"TRAP 1: what the screen SHOWED is what the player GOT — asserted across the pipeline")
func test_errors_are_construct_s_errors_not_a_second_copy_of_the_rules():
var d := _draft() # no skills picked yet
assert_gt(d.errors(_deserter(), world).size(), 0)
assert_false(d.is_complete(_deserter(), world))
d.toggle_skill("athletics")
d.toggle_skill("endurance")
d.set_bonus_skill("perception")
assert_eq(d.errors(_deserter(), world), [], "a legal draft has nothing wrong with it")
assert_true(d.is_complete(_deserter(), world))
func test_an_unnamed_draft_is_not_complete():
var d := _draft()
d.toggle_skill("athletics")
d.toggle_skill("endurance")
d.set_bonus_skill("perception")
d.name = " "
assert_false(d.is_complete(_deserter(), world), "a nameless character cannot enter the world")
func test_the_draft_never_exposes_luck():
# §7 — no accessor, no field, no leak. The screen cannot show what it cannot reach.
var d := _draft()
assert_false("lck" in d.to_creation().get("spend", {}))
assert_false(d.to_creation().has("luck"))
assert_false(d.final().has("lck"))
assert_false(d.rolled().has("lck"))

View File

@@ -0,0 +1 @@
uid://dqllxamt2tcgu

View File

@@ -300,3 +300,86 @@ func test_non_companion_override_goes_to_state_not_log():
for m in res["log"].to_dict()["party"]:
assert_ne(m["id"], "oda_fenn")
assert_eq(res["log"].party_member("brannoc_thane").disposition, 40)
func test_roll_attributes_is_pure_and_seeded():
# Same seed, same five. The screen shows these; construct builds from them.
var a := NewGame.roll_attributes(8675309)
var b := NewGame.roll_attributes(8675309)
assert_eq(a, b)
assert_eq(a.keys(), Attributes.IDS, "the five rows, in the contract's order")
assert_false(a.has("lck"), "LCK is not an attribute and never leaves construct (§7)")
func test_roll_attributes_holds_the_floor_of_eight():
# Sweep enough seeds that a straight 3d6 would certainly have rolled below 8
# somewhere. (GUT has no assert_gte — assert_true on the comparison.)
var lowest := 999
for s in range(400):
for stat in Attributes.IDS:
lowest = mini(lowest, int(NewGame.roll_attributes(s)[stat]))
assert_true(lowest >= 8, "the floor broke: something rolled %d" % lowest)
assert_eq(lowest, 8, "the floor should actually BITE across 2000 rolls, not sit unused")
func test_construct_builds_exactly_what_roll_attributes_showed():
# TRAP 1 (traps.md): this is the load-bearing claim of the creation SCREEN —
# "the numbers you saw are the numbers you got". Asserting that two fresh
# rolls agree would pass even if construct kept its own second implementation
# of the die. This asserts across the PIPELINE: what roll_attributes returns,
# plus the spend, IS what construct puts on the sheet.
var spend := {"str": 2, "con": 1}
var res := _build(_creation({"seed": 4242, "spend": spend}))
assert_true(res["ok"], str(res["errors"]))
var shown := NewGame.roll_attributes(4242)
var expected := {}
for stat in Attributes.IDS:
expected[stat] = int(shown[stat]) + int(spend.get(stat, 0))
assert_eq(res["state"].sheet.attributes, expected)
func test_construct_ignores_an_attribute_block_handed_to_it():
# §2 — "code owns state" — stated as a TEST, not as a comment. Every other test
# here feeds construct a well-behaved creation dict, so nothing in the suite ever
# proves construct REFUSES a number: a construct that trusted creation["attributes"]
# would pass all of them. Hand it a hostile one — an 18 in every stat — and assert
# the sheet is still, exactly, roll_attributes(seed) + spend.
var spend := {"str": 2, "con": 1}
var hostile := _creation({
"seed": 4242,
"spend": spend,
"attributes": {"str": 18, "dex": 18, "con": 18, "fth": 18, "mag": 18},
})
var res := _build(hostile)
assert_true(res["ok"], str(res["errors"]))
var shown := NewGame.roll_attributes(4242)
var expected := {}
for stat in Attributes.IDS:
expected[stat] = int(shown[stat]) + int(spend.get(stat, 0))
assert_eq(res["state"].sheet.attributes, expected,
"construct re-rolls from the SEED — a stat block handed to it is not state, it is noise (§2)")
# And the hostile block is not merely ignored on the sheet — it never becomes state
# anywhere else either. `res["log"].to_dict().has("attributes")` (the old check here)
# was already false before this test's action ran: LogPlayer.to_dict() has no such
# TOP-LEVEL key on any code path, hostile block or not, so it could never fail — delete
# construct's whole §2 guard and this stayed green. Assert the player row's exact key
# SET instead (sorted — insertion order in LogPlayer.to_dict() isn't the contract):
# it feeds the AI (§2/§7), so a stray "attributes", "str", or anything else reaching it
# is a real leak, and this is what actually fails if one does.
var player_keys: Array = res["log"].to_dict()["player"].keys()
player_keys.sort()
assert_eq(player_keys, ["calling_id", "luck_descriptor", "name", "race_id"],
"the canon-log player row carries a key beyond name/race_id/calling_id/luck_descriptor — numeric state reached the AI-facing log (§2/§7)")
func test_validate_is_public_and_agrees_with_construct():
var bad := _creation({"calling_id": "paladin"})
var errors := NewGame.validate(_deserter(), world, bad)
assert_gt(errors.size(), 0)
var res := _build(bad)
assert_false(res["ok"])
assert_eq(res["errors"], errors, "the screen must be told exactly what construct would reject")

View File

@@ -29,22 +29,166 @@ func test_default_font_is_set():
assert_true(t.default_font is FontFile)
func _guarded_variations() -> Array:
# EVERY variation OR BASE TYPE the builder touches — the stylebox variations
# (ThemeKeys.ALL), the six font roles (ThemeKeys.FONT_ROLES), AND the base
# types the builder styles directly with no variation on top
# (ThemeKeys.BASE_TYPES — today just RichTextLabel). ALL and FONT_ROLES
# deliberately exclude each other's half by their own docstrings; missing
# BASE_TYPES here left RichTextLabel's two fonts, its font size and its
# font colour — the ONLY things rendering DM prose on the creation screen —
# unguarded against a stale .tres. traps.md #12, the third miss: iterating
# ALL alone, then ALL+FONT_ROLES, both still left a gap "covers everything"
# did not actually cover.
var out: Array = ThemeKeys.ALL.keys()
out.append_array(ThemeKeys.FONT_ROLES.keys())
out.append_array(ThemeKeys.BASE_TYPES.keys())
return out
func test_the_drift_guard_covers_every_variation_the_builder_touches():
# The guard below is only as good as the set it walks. Pin that set to the
# builder's own output: every variation the fresh Theme carries a stylebox, a
# font, a font size or a font colour for MUST be in _guarded_variations(), or
# it can drift unwatched. A new variation added to the builder and forgotten in
# ThemeKeys goes red here rather than sitting unguarded forever.
var fresh: Theme = Builder.build_theme()
var guarded := _guarded_variations()
for variation in fresh.get_type_list():
var touched := (not fresh.get_stylebox_list(variation).is_empty()
or not fresh.get_font_list(variation).is_empty()
or not fresh.get_font_size_list(variation).is_empty()
or not fresh.get_color_list(variation).is_empty())
if not touched:
continue
if fresh.get_type_variation_base(variation) == &"":
# "" means one of two things, and they must be told apart, not both
# waved through: (a) a KNOWN base type the builder styles directly
# with no set_type_variation (RichTextLabel today — registered in
# ThemeKeys.BASE_TYPES), or (b) a type nobody ever registered at
# all — styled with set_stylebox/set_font/set_color but never
# given a set_type_variation, which reports the same "" and used
# to slip through this `continue` forever. The old guard could not
# tell these apart because it treated "" as "definitely (a)" and
# skipped it unconditionally — the exact whitelist that let
# RichTextLabel through in the first place (traps.md #12, third
# miss). Assert (a); anything else IS (b) and must be named.
assert_true(variation in ThemeKeys.BASE_TYPES,
("the builder styles %s directly (no set_type_variation) and it is not in " +
"ThemeKeys.BASE_TYPES — add it there, or if it should be a real variation, " +
"give it a set_type_variation(...) base so ALL/FONT_ROLES can guard it") % variation)
continue
assert_true(variation in guarded,
"the builder styles the variation %s but no drift guard walks it — add it to ThemeKeys.ALL or ThemeKeys.FONT_ROLES" % variation)
func test_committed_tres_matches_builder():
# Catches "palette/builder changed but nobody regenerated game_theme.tres."
# Preloading the builder script and calling its static build_theme() does
# NOT run _init() (that only fires on .new()), so this is side-effect-free.
#
# Drives its checks off ThemeKeys.ALL *and* ThemeKeys.FONT_ROLES so every
# variation is guarded, not just whichever three someone remembered to hardcode
# here — and not just the stylebox half. For each variation: every stylebox
# state, every font, every font size and every font colour the builder actually
# set is compared. Nothing the builder never touches is asserted on.
var fresh: Theme = Builder.build_theme()
var committed: Theme = load(THEME_PATH)
var checks := [
[ThemeKeys.PRIMARY_CTA, &"normal"],
[ThemeKeys.PARCHMENT_CARD, &"panel"],
[ThemeKeys.DARK_PANEL, &"panel"],
]
for pair in checks:
var variation: StringName = pair[0]
var state: StringName = pair[1]
var fresh_box: StyleBoxFlat = fresh.get_stylebox(state, variation)
var committed_box: StyleBoxFlat = committed.get_stylebox(state, variation)
assert_eq(fresh_box.bg_color, committed_box.bg_color,
"stale game_theme.tres — %s/%s bg_color drifted from the builder; re-run build_game_theme.gd" % [variation, state])
# Theme-LEVEL defaults (build_game_theme.gd:69-70) live on the Theme object
# itself, not under any type or variation, so no per-variation loop below
# ever reaches them — they need their own comparison or a changed
# default_font_size (18 -> anything) ships silently. Font by resource_path,
# not the Resource: load() caches, so two references to the same .ttf are
# `==` even when the builder swapped fonts, unless the .tres happens to
# still point at the same file.
assert_eq(committed.default_font.resource_path, fresh.default_font.resource_path,
"stale game_theme.tres — theme.default_font drifted from the builder; re-run build_game_theme.gd")
assert_eq(committed.default_font_size, fresh.default_font_size,
"stale game_theme.tres — theme.default_font_size drifted from the builder; re-run build_game_theme.gd")
for variation in _guarded_variations():
for state in fresh.get_stylebox_list(variation):
assert_true(committed.has_stylebox(state, variation),
"stale game_theme.tres — %s/%s stylebox is missing entirely; re-run build_game_theme.gd" % [variation, state])
var fresh_box: StyleBox = fresh.get_stylebox(state, variation)
var committed_box: StyleBox = committed.get_stylebox(state, variation)
assert_eq(committed_box.get_class(), fresh_box.get_class(),
"stale game_theme.tres — %s/%s stylebox type drifted from the builder; re-run build_game_theme.gd" % [variation, state])
if fresh_box is StyleBoxFlat:
var f: StyleBoxFlat = fresh_box
var c: StyleBoxFlat = committed_box
assert_eq(c.bg_color, f.bg_color,
"stale game_theme.tres — %s/%s bg_color drifted from the builder; re-run build_game_theme.gd" % [variation, state])
assert_eq(c.border_color, f.border_color,
"stale game_theme.tres — %s/%s border_color drifted from the builder; re-run build_game_theme.gd" % [variation, state])
assert_eq(c.border_width_left, f.border_width_left,
"stale game_theme.tres — %s/%s border_width_left drifted from the builder; re-run build_game_theme.gd" % [variation, state])
assert_eq(c.border_width_top, f.border_width_top,
"stale game_theme.tres — %s/%s border_width_top drifted from the builder; re-run build_game_theme.gd" % [variation, state])
assert_eq(c.border_width_right, f.border_width_right,
"stale game_theme.tres — %s/%s border_width_right drifted from the builder; re-run build_game_theme.gd" % [variation, state])
assert_eq(c.border_width_bottom, f.border_width_bottom,
"stale game_theme.tres — %s/%s border_width_bottom drifted from the builder; re-run build_game_theme.gd" % [variation, state])
assert_eq(c.corner_radius_top_left, f.corner_radius_top_left,
"stale game_theme.tres — %s/%s corner_radius_top_left drifted from the builder; re-run build_game_theme.gd" % [variation, state])
assert_eq(c.corner_radius_top_right, f.corner_radius_top_right,
"stale game_theme.tres — %s/%s corner_radius_top_right drifted from the builder; re-run build_game_theme.gd" % [variation, state])
assert_eq(c.corner_radius_bottom_right, f.corner_radius_bottom_right,
"stale game_theme.tres — %s/%s corner_radius_bottom_right drifted from the builder; re-run build_game_theme.gd" % [variation, state])
assert_eq(c.corner_radius_bottom_left, f.corner_radius_bottom_left,
"stale game_theme.tres — %s/%s corner_radius_bottom_left drifted from the builder; re-run build_game_theme.gd" % [variation, state])
# The FONT half — the whole reason this guard could not fail against a font
# role. Compare the font's resource_path, not the Resource: `load()` caches,
# so two references to the same .ttf are the same instance and `==` would be
# true even when the builder swapped SERIF for MONO... only if the .tres
# happened to point at the same file anyway. The path is the thing that drifts.
for font_name in fresh.get_font_list(variation):
assert_true(committed.has_font(font_name, variation),
"stale game_theme.tres — %s/%s font is missing entirely; re-run build_game_theme.gd" % [variation, font_name])
var fresh_font: Font = fresh.get_font(font_name, variation)
var committed_font: Font = committed.get_font(font_name, variation)
assert_eq(committed_font.resource_path, fresh_font.resource_path,
"stale game_theme.tres — %s/%s font drifted from the builder; re-run build_game_theme.gd" % [variation, font_name])
for size_name in fresh.get_font_size_list(variation):
assert_true(committed.has_font_size(size_name, variation),
"stale game_theme.tres — %s/%s font size is missing entirely; re-run build_game_theme.gd" % [variation, size_name])
assert_eq(committed.get_font_size(size_name, variation), fresh.get_font_size(size_name, variation),
"stale game_theme.tres — %s/%s font size drifted from the builder; re-run build_game_theme.gd" % [variation, size_name])
for color_name in fresh.get_color_list(variation):
assert_true(committed.has_color(color_name, variation),
"stale game_theme.tres — %s/%s colour is missing entirely; re-run build_game_theme.gd" % [variation, color_name])
var fresh_color: Color = fresh.get_color(color_name, variation)
var committed_color: Color = committed.get_color(color_name, variation)
assert_eq(committed_color, fresh_color,
"stale game_theme.tres — %s/%s drifted from the builder; re-run build_game_theme.gd" % [variation, color_name])
func test_creation_variations_exist_with_the_states_the_screen_needs():
var t: Theme = load(THEME_PATH)
# A race/calling card is a Button that must show a CHOSEN state.
assert_true(t.has_stylebox(&"normal", ThemeKeys.SELECT_CARD))
assert_true(t.has_stylebox(&"hover", ThemeKeys.SELECT_CARD))
assert_true(t.has_stylebox(&"pressed", ThemeKeys.SELECT_CARD), "pressed IS the chosen ring")
assert_true(t.has_stylebox(&"disabled", ThemeKeys.SELECT_CARD), "an origin may disallow a calling")
# A skill chip is a toggle with three states: unpicked, picked, and granted-so-inert.
assert_true(t.has_stylebox(&"normal", ThemeKeys.SKILL_CHIP))
assert_true(t.has_stylebox(&"pressed", ThemeKeys.SKILL_CHIP))
assert_true(t.has_stylebox(&"disabled", ThemeKeys.SKILL_CHIP))
# The tags and the primary ring.
assert_true(t.has_stylebox(&"normal", ThemeKeys.CHOSEN_TAG))
assert_true(t.has_stylebox(&"normal", ThemeKeys.PRIMARY_TAG))
assert_true(t.has_stylebox(&"panel", ThemeKeys.PRIMARY_CARD))
func test_section_label_is_a_font_role_on_parchment():
var t: Theme = load(THEME_PATH)
assert_true(t.has_font(&"font", ThemeKeys.SECTION_LABEL))
assert_eq(t.get_color(&"font_color", ThemeKeys.SECTION_LABEL), Palette.INK_LABEL_MUTED)

View File

@@ -1 +1 @@
{ "id": "bloodsworn", "name": "Bloodsworn", "blurb": "You made a bargain with one of the Seven and it was accepted. The power is real. So is the ledger, and it is not settled." }
{ "id": "bloodsworn", "name": "Bloodsworn", "blurb": "You made a bargain with one of the Seven and it was accepted. The power is real. So is the ledger, and it is not settled.", "fragment": "something answered when you called it, and it has been keeping accounts ever since." }

View File

@@ -1 +1 @@
{ "id": "bonesetter", "name": "Bonesetter", "blurb": "The Warden is distant and does not explain himself. Still — you set the bone, you say the words, and often enough the rot does not take. Often enough." }
{ "id": "bonesetter", "name": "Bonesetter", "blurb": "The Warden is distant and does not explain himself. Still — you set the bone, you say the words, and often enough the rot does not take. Often enough.", "fragment": "you are the one they wake at the third hour, and when it goes the other way you wash the body yourself and answer the door again the next night." }

View File

@@ -1 +1 @@
{ "id": "cutpurse", "name": "Cutpurse", "blurb": "Purses, locks, confidences — you have taken all three. The trick was never the hands. It was knowing which pocket was worth it." }
{ "id": "cutpurse", "name": "Cutpurse", "blurb": "Purses, locks, confidences — you have taken all three. The trick was never the hands. It was knowing which pocket was worth it.", "fragment": "you pick your man and take what he will not miss until morning, and the towns where you picked wrong still have your description at the gate." }

View File

@@ -1 +1 @@
{ "id": "hedge_mage", "name": "Hedge-Mage", "blurb": "No tower took you. What you know, you got from a book you should not have had and a teacher who is not alive to confirm it." }
{ "id": "hedge_mage", "name": "Hedge-Mage", "blurb": "No tower took you. What you know, you got from a book you should not have had and a teacher who is not alive to confirm it.", "fragment": "you taught yourself the rest where nobody would come looking, and the scars are from the parts the book got wrong." }

View File

@@ -1 +1 @@
{ "id": "reaver", "name": "Reaver", "blurb": "They hire you when they want a door opened and do not care about the door. You do not wear plate. You have never needed it." }
{ "id": "reaver", "name": "Reaver", "blurb": "They hire you when they want a door opened and do not care about the door. You do not wear plate. You have never needed it.", "fragment": "they send for you when the talking is finished, and they make a point of being somewhere else when you arrive." }

View File

@@ -1 +1 @@
{ "id": "sellsword", "name": "Sellsword", "blurb": "You fight for money. You are good at it, and the work has never once run out." }
{ "id": "sellsword", "name": "Sellsword", "blurb": "You fight for money. You are good at it, and the work has never once run out.", "fragment": "you sell the swing and let the man paying keep the reasons." }

View File

@@ -1 +1 @@
{ "id": "trapper", "name": "Trapper", "blurb": "You worked the treelines until the treelines ran out of anything worth taking. A snare does the waiting for you, and you have learned to wait." }
{ "id": "trapper", "name": "Trapper", "blurb": "You worked the treelines until the treelines ran out of anything worth taking. A snare does the waiting for you, and you have learned to wait.", "fragment": "you learned to sit still in the wet dark until something else made the mistake." }

View File

@@ -1 +1 @@
{ "id": "beastfolk", "name": "Beastfolk", "blurb": "Claws you did not ask for and a nose you cannot switch off. Every gate you pass, someone decides what you are before you speak." }
{ "id": "beastfolk", "name": "Beastfolk", "blurb": "Claws you did not ask for and a nose you cannot switch off. Every gate you pass, someone decides what you are before you speak.", "fragment": "Most towns will take your coin and still want you outside the walls by dark. You stopped taking that personally a long time ago." }

View File

@@ -1 +1 @@
{ "id": "dwarf", "name": "Dwarf", "blurb": "You have drunk worse and survived worse. The rot that takes other men tends to think better of it." }
{ "id": "dwarf", "name": "Dwarf", "blurb": "You have drunk worse and survived worse. The rot that takes other men tends to think better of it.", "fragment": "You came up in stone and cold water, among folk who bury their dead deep and do not speak of them again." }

View File

@@ -1 +1 @@
{ "id": "elf", "name": "Elf", "blurb": "You see in the dark and you notice things. Neither has made you popular in a town that would rather not be noticed." }
{ "id": "elf", "name": "Elf", "blurb": "You see in the dark and you notice things. Neither has made you popular in a town that would rather not be noticed.", "fragment": "You come from a people the Margreave tolerates from a distance, and you have never been in a room where that was not clear." }

View File

@@ -1 +1 @@
{ "id": "human", "name": "Human", "blurb": "Short-lived, adaptable, everywhere. The Margreave is mostly yours, for whatever that has been worth." }
{ "id": "human", "name": "Human", "blurb": "Short-lived, adaptable, everywhere. The Margreave is mostly yours, for whatever that has been worth.", "fragment": "You were born in one of the Margreave's thousand small towns, and it let you go without asking where." }

View File

@@ -71,13 +71,13 @@ Each screen is recreated as a Godot 4.7 Control-node scene against the mockups (
### M3 — Visual foundation & shell ✅
-**Shared `Theme`** — palette tokens, the three font families, and reusable styleboxes (parchment card, dark panel, primary CTA, tab, item tile, tag/chip) from the mock README as a Godot `Theme` resource. Every screen pulls from it; build it first so the look is one system. Merged to `dev`. *§2: n/a (presentation) · depends on nothing · goal: one consistent visual identity.*
-**Main Window shell (2a)** — the exploration HUD: isometric world-view slot + the permanent DM "narration book" (wired to the proven `/dm/narrate` loop + considering-state), the turn-order rail, minimap slot, the slide-in system dock (Inventory/Character/Quest/Map/Party/Spellbook toggles), and the bottom command bar (HP/MP/gold, consumables, End Turn). The frame everything else lives in. **Established the editor-first UI-scene convention** (ADR [0001](adr/0001-editor-first-ui-scenes.md); how-to in `client/docs/README.md`) and added Button-base `DockButton`/`ParchmentButton` + a base `RichTextLabel` prose style to the theme. *§2: state (client owns the HUD, consumes DM text) · depends on the DM loop (M2) · goal: the game's main screen, on screen.*
- **Title screen** — new game / continue / load / settings; the entry point. Author it editor-first (ADR 0001). **⛨ saga:** a fourth entry path (*begin the next campaign of an existing saga*) lands later — don't build it, just don't hardcode the flow as `new game → creation → world` so it can't be added. *§2: n/a · depends on the Theme · goal: first impression, into the world.*
- **Title screen** — new game / continue / load / settings; the entry point. Author it editor-first (ADR 0001). Built and committed (`a77bf03`, `f071392`); this entry was stale — the roadmap had not caught up and M4-c would have been misled into thinking it still had to build one. **⛨ saga:** a fourth entry path (*begin the next campaign of an existing saga*) lands later — don't build it, just don't hardcode the flow as `new game → creation → world` so it can't be added. *§2: n/a · depends on the Theme · goal: first impression, into the world.*
### M4 — Character creation
*Content it consumes → [BOM](../content/roadmap.md#2-bill-of-materials): calling blurbs + the Bloodsworn's patron choice (fixed); origins (variable). **~4 pieces.***
-**Creation model (M4-a)** — the rules a character is made of, no UI ([spec](superpowers/specs/2026-07-12-creation-model-design.md)). Race/calling/skill **mechanics** are static tables in code (`client/scripts/rules/`); their **blurbs** are hand-written content (`content/world/{callings,races}/`, loaded by `ContentDB`, parity-tested), so the content BOM does not block the engine. `CharacterSheet` **stores only what was rolled or chosen** (attributes, race, calling, skills, current hp/mp) and **derives** the rest (`max_hp`, `ac`, `save`, `skill_bonus`, `spell_dc`) — so no derived number can drift from its inputs, and M5's level curve is a function change rather than a stored-field migration. **`NewGame.construct` carries 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) — pinned by a golden-vector test. The roll is **3d6 with a hard floor of 8** (straight 3d6 puts ~9% on a primary the +3 pool cannot rescue; a character bad at the one thing he is *for* is the "fine" §7 forbids). **LCK is unspendable, not merely hidden.** Open seams marked honestly: `ac()` is `10 + DEX` until armor exists (M7), `max_mp()` is a `TUNABLE` placeholder (M5 owns the curve), L1 talents are id stubs (M5). **Live-proven end-to-end** (2026-07-12): a beastfolk cutpurse built by the pipeline → canon log → schema validation → `Player: Vexcca, a beastfolk cutpurse` in the digest (no numeric Luck, no stats, no snake_case id) → real narration from qwen3.5. 250 client tests, 76 api (live layer included). *§2: state · goal: the character exists in code.*
-**Contract migration** — the two contract migrations this milestone owed have **landed**: the canon log's `player` block is now `{name, race_id, calling_id, luck_descriptor}` (the Narrator/NPCs describe the player as e.g. "a beastfolk cutpurse"), and an origin's build-constraints key is now `allowed_callings` (`content/origins/deserter.json` lists all seven real calling ids; the pre-migration key and its two dead class ids are gone from the codebase). Schemas, docs, fixtures, and the world-building skill's content template all agree.
- **Character creation UI (M4-b)** — the mock's screen over our proven model: race + calling cards, six stats with **LCK hidden entirely** (§7), 3d6 floors, the **+3 additive-only spend-up** pool (§8, matches the `character-creation-stats` model), live origin blurb + nameplate. **The class-name reconcile is settled** — all seven callings are named (Sellsword · **Reaver** · Cutpurse · Trapper · Hedge-Mage · Bonesetter · **Bloodsworn**; [races/classes spec](superpowers/specs/2026-07-11-races-and-classes-design.md) §4). Feeds new-game canon-log construction (already built, and already emits `race_id` + `calling_id` per the migration above). **⛨ saga:** `NewGame.construct` takes `creation` as a plain `Dictionary` — keep it that way. The creation *scene* produces that dict but must not become the only thing that can (a saga synthesizes it and skips the UI). *§2: state · depends on the creation model (M0) + Theme · goal: build a character.*
- **Character creation UI (M4-b)** — the mock's screen over M4-a's model: four race cards and seven nameplate calling cards (blurb + mechanics shown only for the *chosen* calling), the **+3 additive spend pool** with `` flooring at the rolled base, proficiency chips gated by the chosen calling's pool (plus the Human bonus-skill row), five ability cards — **never six, and the word "luck" appears nowhere on the screen** (§7) — and the DM origin panel composing race + calling fragments. Five mock/model reconciles settled in the spec's §4 (skill picking added, seven callings not five, blurb-on-chosen-only, `+`/`` both present, the portrait ` ` arrows cut for lacking art). **Architecture:** a pure, node-free `CreationDraft` (`RefCounted`) holds every rule — changing calling clears stale skill picks, changing race drops a now-granted pick, re-roll clears the spend and only the spend, `errors()` delegates to `NewGame.validate` rather than keeping a second copy of the rules; `CreationCopy` is a pure static formatter that derives every display string (hit die, armour word, saves, skill count) from the `Races`/`Callings`/`Skills` tables, so retuning a calling retunes its card without touching a string literal; the `.tscn` owns the layout per ADR 0001 and `character_creation.gd` only binds. **The §2 property holds without the screen having to be trustworthy:** the screen shows its five numbers by calling the same newly-public `NewGame.roll_attributes(seed)` that `construct` calls internally — the screen never hands a number to anything, only a seed and four choices; `construct` rolls the identical attributes again, itself, off the same seed. Re-roll is unlimited (the floor of 8 already prevents a dead primary), and every re-roll silently re-rolls the hidden LCK draw riding the same RNG stream — the screen says nothing about this, ever. Emits `creation_confirmed(creation: Dictionary)`; does not call `construct` itself, so a saga can synthesize the same Dictionary later and skip the scene entirely. **Test count: 250 → 302.** **The honest seams:** three rounds of adversarial review found ten guard-shaped assertions across this milestone that could not fail against the bug they named (substring collisions, a BBCode wrapper masking an empty ContentDB, a test asserting the script's own loop bound, a trivial origin fixture, assertions already true before the action under test ran, a theme-drift guard checking three hardcoded variations instead of all of them, a `Label` sweep that missed `RichTextLabel`, and tests that called handlers directly instead of pressing nodes) — all rewritten against the actual artifact and reverted-and-reconfirmed-red per `docs/traps.md`'s standard; the new species are folded into that file. *§2: state · depends on the creation model (M0) + Theme · goal: build a character.*
-**Title → creation → shell flow (M4-c)** — wire the title screen's *new game* path into character creation and on into the M3 shell, without hardcoding a `new game → creation → world` linear flow (the saga's fourth entry path, §M3, must still be addable later). *§2: n/a (flow) · depends on M4-b + the Title screen (M3) · goal: a playable start-to-shell loop.*
### M5 — Tactical combat (gridless)

View File

@@ -119,6 +119,251 @@ nothing changed that you did not intend.
---
## 7. An assertion can pass because one string happens to sit inside another
**M4-b.** `assert_string_contains(line, Callings.armor(id))` checked that a calling's
detail line mentioned its armour — using the **raw** table value (`"light"`, `"none"`),
not the word the card actually prints (`"light armour"`, `"no armour"`). For
light/medium/heavy this passed **by accident**: `"light"` is a substring of `"light
armour"`. It broke only on the Hedge-Mage, the one calling with `armor: "none"`, because
`"none"` is not a substring of `"no armour"`. Delete the Hedge-Mage and the bug goes
dormant and silent — nothing else in the roster would ever expose it.
The same species, same milestone: `assert_string_contains(line, str(Callings.skill_count(id)))`
could not fail for the Reaver — its skill count is 2, and `"2"` is already sitting inside
`"d12"` (its hit die). Hardcoding the wrong skill count in the card would still pass, satisfied
by a digit in an unrelated number.
**The guard:** anchor the assertion to the **full derived phrase** the code actually
produces (`CreationCopy.armor_word(id)`, `"picks %d skills" % Callings.skill_count(id)`),
never to a bare fragment that might coincidentally be present for the wrong reason. If an
assertion would still pass with the code deleted and a different, unrelated number
substituted, it is checking overlap, not correctness.
---
## 8. A wrapper can hide the void it was supposed to catch
**M4-b.** A §7 sweep asserted every race×calling combination's DM-panel prose was
non-empty — the guard meant to catch a blank `ContentDB` leaking onto the one screen that
must never show a raw error. But the binder wraps prose in BBCode (`"[i]%s[/i]"`) and the
origin panel always emits its own literal connective (`" Now you carry a %s's work — "`)
even when every authored fragment and blurb is blank. `"[i][/i]".strip_edges().is_empty()`
reads `false` regardless of what content actually rendered. The 28-combination sweep was
proving the *wrapper* is non-empty, not that the guard against a blank `ContentDB` held.
**The guard:** when a value is always wrapped in a fixed template before display, assert
that the render **contains the payload's own string** (and that the payload's own string
is itself non-blank) — never just that the wrapped result is non-empty. A non-empty
assertion downstream of a non-empty literal proves nothing.
---
## 9. A test that asserts the script's own loop bound, not the artifact
**M4-b.** `test_five_ability_cards_never_six` asserted `_ability_cards.size() == 5` — but
that array is filled by the binder's own `for i in range(5)`, so it is exactly 5 **by
construction**, independent of what the scene actually contains. An `Ab5` node could be
added to the `.tscn` and this test stayed green while a sixth ability card — the exact
regression §7 exists to prevent — rendered on screen.
**The guard:** interrogate the artifact, not the code that reads it.
`get_node_or_null("Ab5")` must be null, or `get_child_count()` on the authored container
must equal the expected count. A test whose only source of truth is a literal in the same
file it is meant to be guarding is not a test of that file.
---
## 10. A guard whose only fixture makes it trivially true
**M4-b.** `test_only_the_origin_s_allowed_callings_are_shown` checked that shown calling
cards equal the origin's `allowed_callings`. The only origin in the game (the deserter)
allows all seven callings, and cards default to visible — so the assertion was `7 == 7`
no matter what the hiding logic did. **Deleting the entire hide branch left the test
green.**
**The guard:** a gating rule needs a fixture that actually gates. Inject a restrictive
origin (the injection seam for `origin`/`ContentDB` already existed for exactly this) that
allows a strict subset, and assert the excluded cards are absent. A test with only the
permissive case in play is not testing the restrictive path at all.
---
## 11. An assertion that was already true before the action under test ran
**M4-b.** A re-roll test asserted `"points left: 3"` **after** calling re-roll — but a
freshly-constructed draft already reads 3 points left. Removing `spend = {}` from
`reroll()` entirely left the test green, because the assertion never depended on the
reset actually happening.
The same species bit a displayed-scores test harder: it asserted the ability cards show
`draft.final()` values, but spent zero points before checking — so `final() == rolled()`
and a binder bug that read `rolled` instead of `final` (arguably the single most obvious
possible bug in a point-buy panel) passed clean.
**The guard:** drive the state away from its default before asserting the reset or the
derivation. Spend a point, *then* re-roll and check the pool refilled. Spend a point,
*then* check the card shows the spent value, not the rolled one. If the assertion would
already hold on a brand-new object with no action taken, the test has not exercised
anything.
---
## 12. A "covers everything" claim that covers three things
**M4-b.** `test_committed_tres_matches_builder` is the drift guard between the theme
builder and the committed `.tres` artifact — the thing that stops "changed the palette,
forgot to regenerate" from shipping silently. The plan described it as iterating
`ThemeKeys.ALL`. It did not: it checked a hardcoded list of exactly three
variation/state pairs, so every variation added since — including all five this
milestone added for the creation screen — could drift from its generator with the test
still green.
**It took three fixes, and that is the actual lesson.**
- **Fix 1** replaced the hardcoded three with `ThemeKeys.ALL`. Still could not fail: `ALL`,
*by its own docstring*, holds stylebox variations only — it deliberately excludes the six
**font roles**. Change a palette colour used only by a font role, skip the regen, ship a
stale theme, green.
- **Fix 2** added `FONT_ROLES` and iterated both. Still could not fail: the builder also
styles some **base types** directly (`RichTextLabel`'s font, italics font, size and
colour), and those are in neither set. `RichTextLabel` is what renders every line of DM
prose on the creation screen.
- **Fix 2 also added a meta-guard** — a test that walks the builder's own output and fails
if it styles anything that no set covers. That guard was written *specifically* to make a
third miss impossible. It contained this:
```gdscript
if fresh.get_type_variation_base(variation) == &"":
continue # a BASE type, not a variation
```
**The meta-guard's one exemption was exactly the gap it existed to catch.**
**The guard:** when a guard is described as covering "every X," check that it enumerates
`X.ALL` (or equivalent) rather than a literal list someone wrote down once — and then check
what `X.ALL` actually *contains*, because a set's name is not its contents. Make the failure
message name the specific variation and property that drifted; a guard that only says "theme
mismatch" does not tell the next person which of forty checks failed.
**And the harder lesson: a guard-of-a-guard with an exemption in it is not a guard.** If you
write a meta-test to prove a set is complete, every `continue` and every `if … : return` in
it is a hole you are cutting on purpose. Assert on the exempted case instead of skipping it,
or you have built the very thing you were trying to prevent, one level up, where nobody will
look for it.
---
## 13. A node-type sweep that misses the types that matter most
**M4-b.** The §7 Luck-invisibility guard swept `find_children("*", "Label", ...)` across
the creation screen looking for the word "luck." `RichTextLabel` does **not** extend
`Label` in Godot's class hierarchy — and the two `RichTextLabel` nodes on the screen were
the only nodes rendering authored prose (the DM origin panel and the calling detail
panel), i.e. the single likeliest place for a stray "luck" to leak onto the screen. The
sweep also missed every `Button` (whose text is a property, not a child Label) and
`LineEdit.placeholder_text`.
**The guard:** a sweep for "does this string appear anywhere on screen" must walk
`Control` and check every text-bearing property that type can hold (`text`, `.text` on
buttons, `placeholder_text`, `bbcode_text`), not one Label subclass. Naming the node type
you filtered on is not the same as covering the node types that carry the content you care
about.
---
## 14. A test that calls the handler instead of pressing the button
**M4-b.** Every interaction test for the creation screen invoked `_on_race_pressed(i)` /
`_on_calling_pressed(i)` / etc. directly. **Commenting out `_wire()` entirely — the method
that connects every button's `pressed` signal to its handler — left the whole suite
green**, because no test ever went through the signal. A mis-bound `bind(i)` (wrong card
wired to the wrong index) would have been equally invisible.
**The guard:** drive the interaction through the actual node —
`button.pressed.emit()` (or, for a real click, `button.pressing`/`gui_input`) — not the
handler function. This covers the wiring *and* the index binding for free, and it is the
only way a test can tell you a button that looks correct in the editor is inert at
runtime.
---
## 15. GUT can skip a test file with a warning, not a failure
**M4-b.** A new `class_name`-declaring script (`CreationDraft`, `CreationCopy`) needs
Godot's `.godot/` import cache rebuilt before GUT can resolve the global class name from a
sibling test file. Until that happens, the test file fails to parse and GUT **silently
skips it with a `WARNING`**, not a failure — and the run still prints `All tests passed!`,
at the **old** test count. A TDD RED phase against a brand-new global class can therefore
be **fake**: you believe you watched the new test fail, but it never ran at all.
**The guard:** check the test **count**, every time, not the green banner text. If a
change was supposed to add N tests and the total didn't move, the suite lied by omission.
`rm -rf .godot && ./run_tests.sh` forces the cache rebuild if a new file seems to be
missing.
---
## 16. A test can hang instead of fail
**M4-b.** The natural way to write "drain the point-buy pool" in a test is
`while draft.points_left() > 0: plus.pressed.emit()`. Run that against a broken `_wire()`
(trap 14) and `points_left()` never changes — the loop **never terminates**, and the test
runner hangs instead of reporting a failure.
**The guard:** bounded `for` loops only, with an explicit iteration cap well above the
expected count, and an assertion after the loop that the expected end-state was actually
reached. A test that can hang is worse than a test that can silently pass — it costs a
human a wall-clock timeout to even learn something is wrong.
---
## 17. Asserting against a value the engine derives from the thing under test
**M4-b, the F6 pass.** The race cards were authored 108px tall, and their text — the
trait line — visibly rendered *below the card's bottom border* in the running game. The
guard written for it read:
```gdscript
var needed := box.get_combined_minimum_size().y # 114
assert_lte(needed, box.size.y) # box.size.y is ALSO 114
```
It passed. Not against the fix — **against the bug.** The card was put back to its
overflowing 108px and the suite stayed green, 29/29.
The reason is a Godot rule that is easy to forget: **`Control.size` is clamped UP to
`get_combined_minimum_size()`.** The `Box` is anchored inside the card, so a 108px card
leaves it 82px of room — but the Box does not *become* 82px tall. It reports 114, its own
minimum, and quietly overflows the card's border. So the assertion compared 114 against
114: `needed <= box.size.y` is not a size check at all, it is `x <= x`. It could never
have failed, for any card, at any height.
**The guard:** assert against the container that actually *clips* — the card — not
against the child, whose size the engine has already reconciled with the very number you
are testing:
```gdscript
var available: float = card.size.y - box.offset_top + box.offset_bottom # 82
assert_lte(needed, available) # 114 <= 82 -> RED
```
The general shape: **if the engine derives B from A, then `assert(A <= B)` is a
tautology, not a test.** Layout, min-size, and any auto-fitted value are all A-and-B.
Reach for a number the engine computed from a *different* source — the parent, the
authored constant, the viewport — or the assertion is checking that arithmetic works.
Related: this is trap 11's family (an assertion already true before the action ran), but
it is worse — this one is true *by construction*, so no amount of driving state can
redeem it.
**Corollary, same fix:** geometry assertions are meaningless before the container tree has
sorted. On the frame `add_child()` runs, a race card reports **102px** wide and its
autowrapped blurb reports a **2508px** minimum height. `await get_tree().process_frame`
twice, or the guard fails for a reason that has nothing to do with the bug.
---
## The checklist this all reduces to
- Can the new test **fail**? Re-break the code and watch it. If it can't fail, it isn't a test.
@@ -127,3 +372,24 @@ nothing changed that you did not intend.
- Anything the **model can emit**? Ask what happens if it emits it *twice*.
- Any fact written down **twice**? Guard every copy — including docs and tooling.
- Touched **content**? Check the generated tree and the build.
- Does an assertion string appear **only as a fragment** of another string? Anchor to the
full derived phrase.
- Is the value under test **always wrapped** in a fixed template first? Assert on the
payload, not the wrapper.
- Could the assertion be reading a bound the **test's own setup** guarantees, rather than
the artifact under test?
- Does the fixture make the guard's condition **trivially true** regardless of the logic
it claims to check?
- Would the assertion **already hold before the action under test runs**? Drive state away
from its default first.
- Does "covers everything" actually **iterate the full set**, or a hardcoded sample of it?
- Does a node-type sweep account for **every type** that can carry the content, not just
the common one?
- Does the test **press the node** (emit the real signal), or call the handler directly?
- Did the test **count** move by the expected amount — not just the green banner?
- Can the test **hang** on a broken precondition? Bound every loop.
- Is the assertion's right-hand side **derived by the engine from its left-hand side**
(`Control.size` vs `get_combined_minimum_size()`)? Then it is `x <= x`. Measure against
the parent that clips, not the child that reports.
- Is it a **geometry** assertion? Let the container tree sort first, or it measures an
unlaid-out node.