Compare commits

...

49 Commits

Author SHA1 Message Date
e5fb173008 Merge M4-c: Title → Creation → Shell flow (the Main coordinator)
New Game now runs character creation and lands in the exploration shell as the
CREATED character — HP/MP/purse/location/opening-narration all real. A script-first
`Main` coordinator (scenes/Main.tscn, the main_scene) owns Title → Creation → Shell
via instantiate → inject → add_child; the title is emit-only; the shell consumes an
injected character (seed stays the F6 fallback). ⛨ saga fourth-path seam kept addable.

Completes M4. Full suite 327/327; whole-branch review clean; human F5 smoke passed.
Also adds the roadmap's next item (Ollama Cloud proxy provider + backend switch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 05:35:05 -05:00
f5e6c59f2a docs(roadmap): add Ollama Cloud provider + backend switch as the next item
A laptop-dev enabler (planned, not specced): extend the proxy's M1 model-call
pipeline with an Ollama Cloud backend reachable via API key, plus a switch to
flip local-ollama <-> ollama-cloud, so AI works off the homelab.

Reconciles against §4: the API key and provider choice stay server-side in the
proxy (the client never holds a key, never picks the backend). The title
Settings-button "config file" splits — the client may hold only the proxy
base URL / profile (folds into M9); the provider/key switch is proxy config.

Filed under a renamed "Landed / planned out of band" section and pointed to
from the sequencing table as the next task before M5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 05:27:04 -05:00
7f155b8de2 docs(roadmap): M4-c lands — Title -> Creation -> Shell flow; M4 complete
Marks M4-c done (the Main coordinator wires new game -> creation -> shell,
the shell consumes the real created character, the saga fourth-path seam is
kept addable) and flips M4 -> done / M5 -> next in the milestone table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 05:20:45 -05:00
d553213563 refactor(m4c): rename GameFlow coordinator to Main, move to scenes/Main.tscn
The entry-point coordinator read as an internal name ("GameFlow" buried in
scenes/flow/), so it wasn't obvious THIS is the scene that boots first — the
title screen looked like the entry point. Renamed to the Godot-conventional
Main (scenes/Main.tscn + scripts/main.gd) so the boot scene is self-evident.
TitleScreen is unchanged and still shown BY Main as its first screen.

Also drops the hand-written gd_scene uid (now matches sibling scenes) —
closes the Minor review finding. Full suite 327/327 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 05:16:55 -05:00
5a9f39ea48 feat(m4c): GameFlow is the main scene — title->creation->shell is the boot path
Flips run/main_scene from TitleScreen to the GameFlow coordinator, so the
shipped boot path runs the real Title -> Creation -> Shell flow. Full suite
327/327 green headless; the human F5 smoke (ADR 0001 visual gate) is the
remaining acceptance step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 04:20:23 -05:00
583d32456a feat(m4c): GameFlow coordinator wires title -> creation -> shell 2026-07-15 04:15:28 -05:00
6b10b6ea6c feat(m4c): title screen is emit-only (routing moves to GameFlow) 2026-07-15 04:10:10 -05:00
8d963e8030 feat(m4c): shell consumes an injected character (seed stays the F6 fallback) 2026-07-15 04:06:44 -05:00
e169885d90 feat(m4c): ShellState.for_character derives vitals+location from the real character 2026-07-15 04:02:37 -05:00
f763aa0c31 docs(m4c): implementation plan — title → creation → shell flow
Five TDD tasks: ShellState.for_character, the shell's injected-character
seams, the emit-only title, the GameFlow coordinator (+ its flow and
construct-failure tests), and the main_scene flip with a human smoke gate.
Each task carries full test code, exact commands, and a revert-and-watch-red
step per docs/traps.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 03:54:18 -05:00
10f89baef9 Adding helper script to clean up stale superpowers sdd files 2026-07-15 03:47:30 -05:00
aaff2c8554 docs(m4c): spec — title → creation → shell flow
A GameFlow coordinator node (new main_scene) owns the Title → Creation →
Shell sequence in one file: instantiate → inject → add_child, reusing the
existing injection seam that change_scene_to_file cannot. Title becomes
emit-only; the shell gains injected_log/injected_state seams and stops
self-seeding on the real path (seed stays as the F6 fallback), so the
created character's vitals, location, and opening narration are real.

The ⛨ saga constraint is honored structurally: _show_creation and
_start_campaign are separate, so the fourth entry path is an added arm,
not surgery on existing edges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 03:43:57 -05:00
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
7a74a8de94 docs(plan): M4-b — the creation screen, seven tasks
TDD task-by-task: NewGame's public seeded roll -> the eleven fragments ->
CreationCopy -> CreationDraft -> the theme -> the scene -> the docs.

Both of traps.md's bugs get a named guard rather than a hope: the pipeline test
that asserts what the screen SHOWED is what construct BUILT (and a step that
re-breaks the code to prove the test can fail), and a no-default-parameters rule
on the two new public functions.

Also corrects the spec: PrimaryCTA already has a disabled stylebox.
2026-07-13 07:07:36 -05:00
333d901931 docs(spec): M4-b — the character creation screen
The mock's screen over M4-a's proven model. Five mock/model reconciles
recorded: the skills section the mock never drew, seven callings not five,
nameplate cards with prose on selection, a minus button, no portrait arrows.

No AI call anywhere — the DM's entrance is the shell, one screen later.
The screen shows the roll by calling the same pure roll_attributes(seed)
construct does, so it displays numbers without ever handing one over.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 06:56:31 -05:00
b4b72e472e docs: traps.md — the bugs that passed a green suite
Six bugs this project shipped into a branch, what let each through, and the
guard that now catches it. They share a shape: the test passed, and the test
was wrong.

- a regression test that constructed its own correct object, so reverting the
  bug left the suite green
- GDScript default params turning an arity change into a silent miscompile,
  invisible to a grep for the renamed field (grep cannot see positional args)
- a determinism test comparing two runs that would change together
- a closed move vocabulary that was not bounded, because the model could repeat
  a tag and choose an amount in unary
- a roster written down in four places with only one pair guarded — including a
  shipped authoring skill that would have emitted origins allowing zero callings
- generated content that has a client consumer

Linked from CLAUDE.md §18 and docs/README.md. Also records M4-a on the roadmap
with its live end-to-end proof.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 21:07:39 -05:00
b663703da7 Merge feat/creation-model into dev (M4-a)
The character-creation model: race/calling/skill mechanics as static tables in
code, their blurbs as content, a CharacterSheet that stores only what was rolled
or chosen and derives the rest, and a seeded NewGame.construct.

Creation carries a seed, not a stat block — construct rebuilds the RNG and rolls
the attributes itself, so it never trusts a number handed to it (§2) and the same
seed yields an identical character, hidden Luck included (§10). A golden-vector
test pins the RNG stream. The roll is 3d6 with a hard floor of 8: straight 3d6
puts ~9% on a primary the +3 pool cannot rescue, and a character bad at the one
thing he is FOR is the 'fine' §7 forbids.

Migrates the cross-boundary contract: class_id -> calling_id (the contract should
say what the world says) and a required race_id, so the AI narrates 'a beastfolk
cutpurse' instead of 'a sellsword'.

No UI — the creation screen is M4-b, the title->creation->shell flow is M4-c.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:59:36 -05:00
a9357fb787 fix(creation-model): migrate stray class_id/allowed_classes copies
The canon-log schema and NewGame code already migrated to race_id/
calling_id + allowed_callings, but two copies of the roster outside
that schema were missed, and no parity test guards them:

- docs/canon-log.md (the cross-boundary contract doc) still documented
  the OLD player shape and allowed_classes. Anyone building a player
  block from the doc would get a 422.
- .claude/skills/world-building's schema reference still emitted
  allowed_classes with two dead calling ids (assassin, priest). Author
  a new origin with that skill and it fails origin.schema.json
  (additionalProperties: false, allowed_callings required) AND, if that
  ever loosened, silently allows zero callings at runtime.

Also:
- add schema tests rejecting a dead class_id field and an unknown
  calling_id (priest)
- guard NewGame._validate's container types (spend/skills) so a
  malformed JSON round-trip (null spend, string skills) produces a
  front-loaded error list instead of a GDScript runtime crash
- extend the no-mechanics-in-content test to db.races, not just
  db.callings
- rewrite the roadmap's M4 bullet: the two contract migrations landed;
  only the creation screen and title->creation->shell flow remain

client: 250/250. api: 74 passed, 2 skipped. content_build --check: clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:58:49 -05:00
62b8113ffa fix(creation): pin RNG stream, tighten spend/seed validation
Finding 1 (important): test_same_seed_yields_an_identical_character only
compared two construct() runs against each other, so swapping dex/con in
Attributes.IDS still passed the whole suite while every persisted seed
would silently become a different character. Added a golden-vector test
that pins one fixed seed to its exact rolled attributes and hidden Luck,
so reordering Attributes.IDS or moving the Luck roll fails loudly instead
of silently rewriting every saved character. Proved it: swapped IDS,
watched the golden vector fail, restored, confirmed green.

Also: _validate_spend no longer lets a negative value mask the over-pool
check (continue after each error instead of falling through to `total +=
v`); non-integer spend values (float/bool/string) are now rejected instead
of coerced; a missing or non-integer seed is now a validation error instead
of silently defaulting to 0. GameState's header comment no longer
advertises "stats" it no longer holds.

243 -> 248 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:50:06 -05:00
bd49e118d5 feat(newgame): the seeded creation pipeline
construct() takes a seed, not a stat block. It rebuilds the RNG and rolls the
attributes itself, so it never trusts a number handed to it (§2) and the same seed
yields an identical character, hidden Luck included (§10). The whole character is
reproducible from four primitives: seed, race_id, calling_id, spend. The rng
parameter is dropped — the seed is now the only source of randomness.

The roll is 3d6 with a hard floor of 8. Straight 3d6 puts ~9% on a primary the +3
pool cannot rescue, and a character bad at the one thing he is FOR is not grit —
it is the fine §7 forbids, paid for twenty hours.

LCK is unspendable, not merely hidden: spending on it is a validation error.
GameState.stats becomes GameState.sheet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:43:51 -05:00
eba706b906 test: assert seed-log fields, not a fresh LogPlayer
The previous regression tests in test_entities.gd only constructed their
own LogPlayer and never touched the seed logs the real call sites build
— reverting main_window_shell.gd:124 to the broken 3-arg call still left
the whole suite green. Not a real regression guard.

Now assert against node._log.player in the shell test and against the
two dev-harness scenes' seed logs (narrate_harness, npc_harness — both
cheaply testable via instantiate + add_child_autofree, no scaffolding
needed). Proved the new shell assertion fails when the call site is
reverted to the 3-arg shape (226/227, one failure), then passes restored
(227/227). Removed a redundant ctor test from test_entities.gd; kept the
one that exercises the exact broken-shape call.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:39:48 -05:00
5149e817f6 fix(contract): repair 3-arg LogPlayer callers + digest article bug
Three callers (main_window_shell.gd, narrate_harness.gd, npc_harness.gd)
still built LogPlayer with the OLD 3-arg shape (name, class_id,
luck_descriptor) after LogPlayer.new() was migrated to the 4-arg
(name, race_id, calling_id, luck_descriptor) shape. This was invisible
to both grep and the test suite: every ctor param carries a default, so
GDScript compiles a 3-arg call against a 4-arg signature without error —
it just silently mis-binds positionally. "sellsword" landed in the
race_id slot (rejected — not a race), the luck descriptor landed in the
calling_id slot (rejected — not a calling), and luck_descriptor itself
fell back to its default "". Every field in the emitted dict came out
empty, which 422s against the canon-log schema's minLength/enum
constraints. Grepping for class_id can't see this, because there is no
class_id token left anywhere — the bug is purely positional.

Also fixes prompts.py's _article(""), which returned "an" because
Python's "" in "aeiou" is True, producing a doubled-space/wrong-article
digest line when race_id is empty. _describe_player now builds its
descriptor from whichever of race/calling are present and omits the
clause entirely when both are absent.

Adds a schema-parity guard for origin.schema.json's inlined
allowed_callings enum, which duplicated the calling roster with no
runtime check tying it to Callings.IDS. Renames leftover "class"
vocabulary in test names/comments to "calling".

Regression coverage:
- client/tests/unit/test_entities.gd: ctor populates both race_id and
  calling_id; a calling passed into the race_id slot (the exact shape
  of the three broken call sites) yields an all-empty row.
- api/tests/test_prompts.py: empty-race and empty-race-and-calling
  digest rendering, asserting no doubled space and no dangling article.
- client/tests/unit/test_schema_parity.gd: origin.schema.json's
  allowed_callings enum matches Callings.IDS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:34:05 -05:00
a668cf98eb refactor(contract)!: class_id -> calling_id, and the log gains race_id
class_id is the rulebook's word; the world has callings — that was the whole §17
reconcile. The contract the Narrator reads should say what the world says. There
are no saves (M9) and no deployed clients, so this is as cheap as it will ever be
and strictly more expensive every milestone after.

race_id reaches the log because the AI must be able to describe the player:
api/app/prompts.py rendered 'a sellsword' and now renders 'a beastfolk cutpurse'.
It humanizes the id (the model must never read 'hedge_mage') and picks the right
article ('an elf', not 'a elf').

LogPlayer's hardcoded CLASSES const is deleted — the roster is Callings.IDS. A
parity test reads the JSON Schema from the client and asserts its enum equals the
code, so the two sides of the HTTP boundary cannot drift.

The deserter allowed three callings that no longer exist. He now allows all seven;
a thematic gate is a content decision for the Greywater authoring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:27:56 -05:00
bb5d176262 feat(state): CharacterSheet — store the inputs, derive the rest
Stores only what was rolled or chosen, plus current hp/mp (the only two numbers
that genuinely mutate). Everything else is a function, so no derived number can
drift from its inputs and M5's level curve is a function change, not a migration.

LCK has no row and no accessor here — §7 holds by construction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:21:45 -05:00
a7e153884f feat(content): calling + race blurbs, loaded by ContentDB
Hand-written like items/ — no build-tool namespace needed. Blurbs are content;
mechanics are code, and a test asserts the blurb files carry no hit dice. The
prose is placeholder; swapping it later touches no code, which is what stops the
content BOM from blocking the engine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:18:19 -05:00
78e18107b2 feat(rules): the race, calling, skill and attribute tables
Mechanics are rules and live in code, beside Luck and Currency. The skill pools
are new — the races/classes spec said only 'the stat-appropriate slice' and never
enumerated them; each pool is larger than its pick count so the choice is real.

The dwarf's +2 vs poison is exposed separately from save() rather than folded
into it: baking a conditional into a flat number is how a sheet starts lying.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:14:44 -05:00
89d4c65a7a docs(plan): M4-a — the creation model, six tasks
TDD, green suite at every boundary. Task 4 is deliberately one cross-cutting
commit: the origin's key and the log's player field are read on both sides of
the HTTP boundary, so renaming them in separate tasks would leave a suite red
at the seam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:08:21 -05:00
9de8ee40b3 docs(spec): M4-a — the character creation model
The rules a character is made of, with no screen attached. Races, callings and
skills become static tables in code; blurbs stay content (hand-written, like
items/), so the content BOM stops blocking the engine. The sheet stores only
what was rolled or chosen and derives the rest — one source of truth, and the
M5 level curve becomes a function change rather than a migration.

Creation is seeded. The creation Dictionary carries a seed, not a stat block:
construct reconstructs the RNG and rolls the attributes itself, so the same
seed yields an identical character and the UI cannot inject a number. §10's
seeding argument, applied where the races spec says it first matters.

3d6 with a hard floor of 8. Straight 3d6 puts a ~9% chance on a primary the +3
pool cannot rescue — a Hedge-Mage with a -1 MAG modifier is not grit, it is the
"fine" §7 forbids, paid for twenty hours.

Lands the two migrations the races spec said to do at M4 and not silently:
class_id -> calling_id (the contract should say what the world says) and a
required race_id, so api/app/prompts.py can render "a beastfolk cutpurse"
instead of "a sellsword".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 19:48:46 -05:00
97 changed files with 11461 additions and 318 deletions

View File

@@ -252,7 +252,7 @@ construction fails loudly.
"disposition_overrides": { "brannoc_thane": 40, "cadwyn_vell": 15 },
"inventory_grants": [{ "item_id": "worn_shortsword", "qty": 1 }],
"start_quest_id": "find_the_ledger",
"build_constraints": { "allowed_classes": ["sellsword","assassin","priest"], "luck_modifier": 0 }
"build_constraints": { "allowed_callings": ["sellsword","reaver","cutpurse","trapper","hedge_mage","bonesetter","bloodsworn"], "luck_modifier": 0 }
}
```

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

@@ -559,6 +559,12 @@ contracts (§12), and Failure UX (§13).
- **When a request conflicts with this document, say so.** Do not silently comply. This file is the argument; if it is wrong, change the file first.
- **When adding a system, state which side of §2 it falls on.** State or text. If it is both, it is two systems.
- **Prefer deleting scope.** The POC's value is answering one question fast.
- **A green suite is evidence only if the test can fail.** Before accepting a fix that
claims to guard a regression, re-break the code and *watch the new test fail*. This
project has shipped a regression test that passed against its own bug, a determinism
test that compared two runs that changed together, and an arity change that
miscompiled silently past a clean grep. Read **[docs/traps.md](docs/traps.md)** before
writing tests — it is six ways this codebase has already fooled a green suite.
### Git workflow

View File

@@ -12,6 +12,29 @@ from pathlib import Path
_PROMPT_DIR = Path(__file__).resolve().parent.parent / "prompts"
def _humanize(id_: str) -> str:
"""Ids are snake_case; the model should read prose. hedge_mage -> hedge-mage."""
return id_.replace("_", "-")
def _article(word: str) -> str:
if not word:
return "a"
return "an" if word[:1].lower() in "aeiou" else "a"
def _describe_player(player: dict) -> str:
"""'Aldric, a beastfolk cutpurse' — what the AI narrates the player as."""
race = _humanize(player.get("race_id", ""))
calling = _humanize(player.get("calling_id", ""))
name = player.get("name", "")
parts = [p for p in (race, calling) if p]
if not parts:
return name
descriptor = " ".join(parts)
return f"{name}, {_article(parts[0])} {descriptor}".strip()
@lru_cache(maxsize=None)
def system_prompt(role: str) -> str:
text = (_PROMPT_DIR / f"{role}.md").read_text(encoding="utf-8")
@@ -50,7 +73,7 @@ def render_digest(canon_log: dict) -> str:
descriptor = player.get("luck_descriptor")
if descriptor:
lines.append(f"Fortune: {descriptor}")
lines.append(f"Player: {player.get('name', '')}, a {player.get('class_id', '')}")
lines.append(f"Player: {_describe_player(player)}")
lines.append("")
lines.append("Describe the scene as it is now.")
@@ -104,7 +127,7 @@ def render_npc_digest(
lines.append(f"Active quest: {quest.get('name', '')}{quest.get('objective', '')}")
player = canon_log.get("player", {})
lines.append(f"The player is {player.get('name', '')}, a {player.get('class_id', '')}.")
lines.append(f"The player is {_describe_player(player)}.")
lines.append("")
lines.append(f'The player says to you: "{utterance}"')

View File

@@ -2,7 +2,8 @@
"schema_version": 1,
"player": {
"name": "Aldric",
"class_id": "sellsword",
"race_id": "human",
"calling_id": "sellsword",
"luck_descriptor": "Fortune spits on you"
},
"location": { "id": "greywater_docks", "name": "the Greywater docks" },

View File

@@ -68,3 +68,20 @@ def test_negative_turn_is_rejected():
doc = _valid()
doc["humiliations"][0]["turn"] = -1
assert validate_canon_log(doc) != []
def test_dead_class_id_field_is_rejected():
# The player block migrated from {name, class_id, luck_descriptor} to
# {name, race_id, calling_id, luck_descriptor}. class_id must not slip
# back in via additionalProperties: false.
doc = _valid()
doc["player"]["class_id"] = "sellsword"
assert validate_canon_log(doc) != []
def test_unknown_calling_id_is_rejected():
# assassin/priest are dead ids from the pre-migration class roster and
# must not validate as calling_id values.
doc = _valid()
doc["player"]["calling_id"] = "priest"
assert validate_canon_log(doc) != []

View File

@@ -30,7 +30,8 @@ def test_live_npc_speak_fenn():
log = {
"player": {
"name": "Aldric",
"class_id": "sellsword",
"race_id": "human",
"calling_id": "sellsword",
"luck_descriptor": "the dice are kind",
},
"location": {"id": "greywater_docks", "name": "Greywater Docks"},

View File

@@ -4,7 +4,7 @@ import app.npc as npc
from app.ollama_client import ModelError
LOG = {
"player": {"name": "Aldric", "class_id": "sellsword", "luck_descriptor": "kind"},
"player": {"name": "Aldric", "race_id": "human", "calling_id": "sellsword", "luck_descriptor": "kind"},
"location": {"id": "greywater_docks", "name": "Greywater Docks"},
"party": [], "recent_events": [], "established_facts": [],
"active_quests": [], "humiliations": [],

View File

@@ -14,7 +14,7 @@ VALID_ORIGIN = {
"inventory_grants": [{"item_id": "worn_shortsword", "qty": 1}],
"start_quest_id": "find_the_ledger",
"build_constraints": {
"allowed_classes": ["sellsword", "assassin", "priest"],
"allowed_callings": ["sellsword", "cutpurse", "bonesetter"],
"luck_modifier": 0,
},
}
@@ -30,9 +30,9 @@ def test_null_start_quest_is_allowed():
assert validate_origin(doc) == []
def test_unknown_class_is_rejected():
def test_unknown_calling_is_rejected():
doc = copy.deepcopy(VALID_ORIGIN)
doc["build_constraints"]["allowed_classes"] = ["bard"]
doc["build_constraints"]["allowed_callings"] = ["bard"]
assert validate_origin(doc) != []

View File

@@ -22,7 +22,7 @@ def test_digest_has_the_narrative_context():
assert "Companions present: Brannoc Thane, Cadwyn Vell" in d
assert "Active quest: The Missing Ledger — Find who took Fenn's ledger" in d
assert "Fortune: Fortune spits on you" in d
assert "Player: Aldric, a sellsword" in d
assert "Player: Aldric, a human sellsword" in d
assert d.rstrip().endswith("Describe the scene as it is now.")
@@ -42,10 +42,47 @@ def test_narrator_body_is_authored():
assert "second person" in body # the core task is present
def _with_player(**player) -> dict:
log = json.loads(json.dumps(VALID)) # deep copy — VALID is module-level and shared
log["player"] = {"luck_descriptor": "the dice are kind", **player}
return log
def test_digest_names_the_race_and_the_calling():
out = render_digest(_with_player(name="Aldric", race_id="beastfolk", calling_id="cutpurse"))
assert "Aldric, a beastfolk cutpurse" in out
def test_digest_says_an_elf_not_a_elf():
out = render_digest(_with_player(name="Aldric", race_id="elf", calling_id="sellsword"))
assert "an elf sellsword" in out
def test_digest_does_not_leak_a_snake_case_id():
out = render_digest(_with_player(name="Aldric", race_id="human", calling_id="hedge_mage"))
assert "hedge_mage" not in out, "the model must never read a raw snake_case id"
assert "hedge-mage" in out
def test_digest_omits_race_clause_when_race_is_empty():
# Regression: _article("") used to return "an" (Python's "" in "aeiou" is
# True), rendering "Player: Aldric, an " — wrong article, doubled space.
out = render_digest(_with_player(name="Aldric", race_id="", calling_id="sellsword"))
assert "Player: Aldric, a sellsword" in out
assert " " not in out
assert ", an " not in out
def test_digest_omits_both_clauses_when_race_and_calling_are_empty():
out = render_digest(_with_player(name="Aldric", race_id="", calling_id=""))
assert "\nPlayer: Aldric\n" in out
assert " " not in out
def test_render_npc_digest_includes_all_sections():
from app import prompts
log = {
"player": {"name": "Aldric", "class_id": "sellsword", "luck_descriptor": "the dice are kind"},
"player": {"name": "Aldric", "race_id": "human", "calling_id": "sellsword", "luck_descriptor": "the dice are kind"},
"location": {"id": "greywater_docks", "name": "Greywater Docks"},
"party": [], "recent_events": ["a gull screamed"],
"established_facts": ["the eastern bridge is out"],

3
clean_superpowers.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/env bash
rm .superpowers/sdd/*.diff
rm .superpowers/sdd/*.md

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,15 @@ 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."
run/main_scene="res://scenes/title/TitleScreen.tscn"
config/version="0.01-alpha"
run/main_scene="res://scenes/Main.tscn"
config/features=PackedStringArray("4.7")
[display]
; Design/logical canvas stays 1920x1080 (charter §16, mockups authored here).
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
window/size/window_width_override=1280
window/size/window_height_override=720
window/stretch/mode="canvas_items"
window/stretch/aspect="keep"

6
client/scenes/Main.tscn Normal file
View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/main.gd" id="1"]
[node name="Main" type="Node"]
script = ExtResource("1")

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

@@ -1,33 +1,46 @@
class_name LogPlayer
extends RefCounted
## A canon-log player row. Carries ONLY name, class_id, luck_descriptor (§7):
## no numeric Luck, no stats — those live in GameState and never reach the AI.
const CLASSES := ["sellsword", "assassin", "priest"]
## A canon-log player row. Carries ONLY name, race_id, calling_id, luck_descriptor
## (§7): no numeric Luck, no attributes — those live in GameState and never reach the
## AI. race_id is here because the Narrator and NPCs must be able to describe the
## player ("a beastfolk cutpurse"); the mechanical sheet must not.
var name: String
var class_id: String
var race_id: String
var calling_id: String
var luck_descriptor: String
func _init(p_name := "", p_class_id := "", p_luck_descriptor := "") -> void:
func _init(p_name := "", p_race_id := "", p_calling_id := "", p_luck_descriptor := "") -> void:
name = p_name
luck_descriptor = p_luck_descriptor
if p_class_id != "":
set_class_id(p_class_id)
if p_race_id != "":
set_race_id(p_race_id)
if p_calling_id != "":
set_calling_id(p_calling_id)
func set_class_id(v: String) -> bool:
if v not in CLASSES:
push_error("invalid class_id: %s" % v)
func set_race_id(v: String) -> bool:
if not Races.exists(v):
push_error("invalid race_id: %s" % v)
return false
class_id = v
race_id = v
return true
func set_calling_id(v: String) -> bool:
if not Callings.exists(v):
push_error("invalid calling_id: %s" % v)
return false
calling_id = v
return true
func to_dict() -> Dictionary:
return {"name": name, "class_id": class_id, "luck_descriptor": luck_descriptor}
return {"name": name, "race_id": race_id, "calling_id": calling_id,
"luck_descriptor": luck_descriptor}
static func from_dict(d: Dictionary) -> LogPlayer:
return LogPlayer.new(d.get("name", ""), d.get("class_id", ""), d.get("luck_descriptor", ""))
return LogPlayer.new(d.get("name", ""), d.get("race_id", ""),
d.get("calling_id", ""), d.get("luck_descriptor", ""))

View File

@@ -8,6 +8,8 @@ var locations: Dictionary = {}
var npcs: Dictionary = {}
var quests: Dictionary = {}
var items: Dictionary = {}
var callings: Dictionary = {}
var races: Dictionary = {}
var canon_entities: Dictionary = {}
var topics: Dictionary = {}
@@ -31,6 +33,8 @@ func load_from(content_root: String) -> void:
npcs = _load_dir(world.path_join("npcs"))
quests = _load_dir(world.path_join("quests"))
items = _load_dir(world.path_join("items"))
callings = _load_dir(world.path_join("callings"))
races = _load_dir(world.path_join("races"))
canon_entities = _load_dir(world.path_join("canon"))
topics = _load_dir(world.path_join("topics"))
@@ -61,6 +65,10 @@ func has_location(id: String) -> bool: return locations.has(id)
func has_npc(id: String) -> bool: return npcs.has(id)
func has_quest(id: String) -> bool: return quests.has(id)
func has_item(id: String) -> bool: return items.has(id)
func calling(id: String) -> Dictionary: return callings.get(id, {})
func race(id: String) -> Dictionary: return races.get(id, {})
func has_calling(id: String) -> bool: return callings.has(id)
func has_race(id: String) -> bool: return races.has(id)
func canon(id: String) -> Dictionary: return canon_entities.get(id, {})
func topic(id: String) -> Dictionary: return topics.get(id, {})
func has_canon(id: String) -> bool: return canon_entities.has(id)

View File

@@ -59,7 +59,7 @@ func _on_narrate_pressed() -> void:
func _build_scene_log() -> CanonLog:
var log := CanonLog.new()
log.player = LogPlayer.new("Aldric", "sellsword", "Fortune spits on you")
log.player = LogPlayer.new("Aldric", "human", "sellsword", "Fortune spits on you")
log.set_location("greywater_docks", "the Greywater docks")
log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40))
log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15))

View File

@@ -39,7 +39,7 @@ func _ready() -> void:
# Build the player through LogPlayer so luck_descriptor is non-empty — the
# canon-log schema requires it (minLength 1), and it's the §7 fortune line
# the digest surfaces to the NPC. An empty string here 422s the request.
_canon_log.player = LogPlayer.new("Aldric", "sellsword", "the dice are kind today")
_canon_log.player = LogPlayer.new("Aldric", "human", "sellsword", "the dice are kind today")
# Deliberately no find_the_ledger quest here — leave it un-started so Fenn
# can offer_quest it during the conversation.

93
client/scripts/main.gd Normal file
View File

@@ -0,0 +1,93 @@
class_name Main
extends Node
## The single home for the game's screen sequence: Title → Creation → Shell.
## Script-first by design — a pure coordinator with no Control layout, so ADR 0001's
## editor-first rule does not apply (that rule governs layout scenes). It owns one
## ContentDB + one DmService and swaps screens via instantiate → inject → add_child,
## the injection seam change_scene_to_file cannot provide.
##
## §2: the flow carries only plain data; NewGame.construct re-rolls the character
## from the creation seed itself. ⛨ saga: _show_creation and _start_campaign are
## separate, so a fourth entry path is an added arm, not surgery on existing edges.
const HttpTransport = preload("res://scripts/net/http_transport.gd")
const TITLE := preload("res://scenes/title/TitleScreen.tscn")
const CREATION := preload("res://scenes/creation/CharacterCreation.tscn")
const SHELL := preload("res://scenes/shell/MainWindowShell.tscn")
# Injection seams — set BETWEEN instantiate() and add_child(), as the shell/creation
# do for their own deps. _ready builds the real ones when nothing was supplied.
var world: ContentDB = null
var service: DmService = null
var origin: Dictionary = {}
var _current: Node = null
var _http: HTTPRequest
func _ready() -> void:
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"))
if service == null:
_http = HTTPRequest.new()
add_child(_http)
service = DmService.new(HttpTransport.new(_http), FallbackLibrary.new())
_show_title()
func _show(next: PackedScene, inject: Callable) -> void:
## The one swap primitive. inject runs after instantiate() (which does NOT run
## _ready) and before add_child (which does) — the injection window. The outgoing
## screen is queue_free'd (deferred): a signal handler must never free the node
## still executing up its own call stack.
var old := _current
var screen := next.instantiate()
inject.call(screen)
_current = screen
add_child(screen)
if old != null:
old.queue_free()
func _show_title() -> void:
_show(TITLE, func(s): s.menu_activated.connect(_on_menu))
func _show_creation() -> void:
_show(CREATION, func(s):
s.world = world
s.origin = origin
s.creation_confirmed.connect(_start_campaign))
func _show_shell(log: CanonLog, state: GameState) -> void:
_show(SHELL, func(s):
s.service = service
s.injected_log = log
s.injected_state = state)
func _on_menu(id: StringName) -> void:
match id:
&"new_game":
_show_creation()
&"quit":
get_tree().quit()
_:
pass # continue / load land in later milestones (M9); inert for now
func _start_campaign(creation: Dictionary) -> void:
## Takes a plain Dictionary — does NOT assume it came from the creation screen, so
## a saga can synthesize one and call this directly (skipping creation).
var result := NewGame.construct(origin, world, creation)
if not result.get("ok", false):
# Unreachable via the UI (the CTA gates on validate), but §2 forbids trusting
# the caller and §13 forbids surfacing an error. Log and stay put.
push_error("Main: construct rejected a creation: %s" % str(result.get("errors", [])))
return
_show_shell(result["log"], result["state"])

View File

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

View File

@@ -1,74 +1,223 @@
class_name NewGame
extends RefCounted
## New-game construction (spec "New-game construction"). Reads three immutable
## inputs (origin, world, creation) and writes two products (CanonLog, GameState).
## Nothing flows back up — charter §2 on the client. Validation is front-loaded:
## a broken origin or illegal class fails here, loudly, not three scenes later.
## New-game construction. Reads three immutable inputs (origin, world, creation) and
## writes two products (CanonLog, GameState). Nothing flows back up — charter §2.
##
## Creation carries a SEED, not a stat block. construct() rebuilds the RNG and rolls
## the attributes ITSELF, so it never trusts a number handed to it (§2) and the same
## seed always yields the same character (§10 — "the difference between a bug you can
## reproduce and a bug you cannot"). The whole character is reproducible from four
## primitives: seed, race_id, calling_id, spend.
##
## `creation` stays a plain Dictionary: a saga (later) synthesizes one and skips the
## UI entirely, so the creation SCENE must never become the only thing that can make it.
static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary, rng: RandomNumberGenerator) -> Dictionary:
var errors: Array = []
const SPEND_POOL := 3
# 1. content integrity — every referenced id must resolve in world content.
for ref in world.unresolved_refs(origin):
errors.append("unresolved ref: %s" % ref)
# 2. build constraints — chosen class must be allowed by the origin.
var bc: Dictionary = origin.get("build_constraints", {})
var allowed: Array = bc.get("allowed_classes", [])
var class_id: String = creation.get("class_id", "")
if class_id not in allowed:
errors.append("class not allowed by origin: %s" % class_id)
if creation.get("name", "").strip_edges() == "":
errors.append("player name is required")
static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary) -> Dictionary:
var errors := _validate(origin, world, creation)
if not errors.is_empty():
return {"ok": false, "errors": errors, "log": null, "state": null}
# 3. roll Luck (numeric -> state only) and 4. the stat block (data only).
var rng := RandomNumberGenerator.new()
rng.seed = int(creation.get("seed", 0))
var state := GameState.new()
var sheet := CharacterSheet.new()
sheet.race_id = str(creation.get("race_id", ""))
sheet.calling_id = str(creation.get("calling_id", ""))
# 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.
# 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)))
state.luck = state.luck_base
state.stats = {
"str": _roll_stat(rng), "dex": _roll_stat(rng), "con": _roll_stat(rng),
"fth": _roll_stat(rng), "mag": _roll_stat(rng),
}
# 5. assemble the canon log.
var log := CanonLog.new()
log.player = LogPlayer.new(creation.get("name", ""), class_id, state.luck_descriptor())
var spend: Dictionary = creation.get("spend", {})
for stat in spend:
attrs[stat] = int(attrs[stat]) + int(spend[stat])
sheet.attributes = attrs
# Proficiencies: what the race grants + what the player picked + the human's bonus.
# All land in one list, so is_proficient() has exactly one thing to read.
var skills: Array = []
skills.append_array(Races.granted_skills(sheet.race_id))
skills.append_array(creation.get("skills", []))
var bonus := str(creation.get("bonus_skill", ""))
if bonus != "":
skills.append(bonus)
sheet.bonus_skill = bonus
sheet.skills = skills
sheet.hp = sheet.max_hp() # the character starts whole
sheet.mp = sheet.max_mp()
state.sheet = sheet
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))))
# non-companion overrides go to game state, never the log (spec §5 / charter §6).
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", qd.get("objective", "")))
canon.active_quests.append(Quest.new(qd.get("id", ""), qd.get("name", ""), "active",
qd.get("objective", "")))
# inventory grants -> game state (NOT the log). Charter §2. grant() routes
# currency to the purse; everything else to the inventory dict.
for grant in origin.get("inventory_grants", []):
state.grant(grant.get("item_id", ""), int(grant.get("qty", 0)))
# inventory grants -> game state (NOT the log). §2. grant() routes currency to the
# purse; everything else to the inventory dict.
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_stat(rng: RandomNumberGenerator) -> int:
# 3d6 placeholder; the stat block is data-only in this plan. TUNABLE.
return rng.randi_range(1, 6) + rng.randi_range(1, 6) + rng.randi_range(1, 6)
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:
# 3d6 with a HARD FLOOR of 8. The floor clamps the die, not the player: straight
# 3d6 puts ~9% on a primary the +3 pool cannot rescue, and a character who is bad
# at the one thing he is FOR is not grit — it is the "fine" §7 forbids, paid for
# twenty hours.
var roll := rng.randi_range(1, 6) + rng.randi_range(1, 6) + rng.randi_range(1, 6)
return maxi(8, roll)
static func _validate(origin: Dictionary, world: ContentDB, creation: Dictionary) -> Array:
var errors: Array = []
for ref in world.unresolved_refs(origin):
errors.append("unresolved ref: %s" % ref)
if str(creation.get("name", "")).strip_edges() == "":
errors.append("player name is required")
if not creation.has("seed") or typeof(creation["seed"]) != TYPE_INT:
errors.append("seed is required and must be an integer")
var race_id: String = str(creation.get("race_id", ""))
if not Races.exists(race_id):
errors.append("unknown race: %s" % race_id)
var calling_id: String = str(creation.get("calling_id", ""))
if not Callings.exists(calling_id):
errors.append("unknown calling: %s" % calling_id)
else:
var allowed: Array = origin.get("build_constraints", {}).get("allowed_callings", [])
if calling_id not in allowed:
errors.append("calling not allowed by origin: %s" % calling_id)
# Guard container SHAPES before any statically-typed call below touches them.
# A JSON round-trip can hand back {"spend": null} or {"skills": "stealth"} —
# without this check, assigning that into a typed Dictionary/Array parameter
# throws a GDScript runtime error INSIDE construct() instead of landing in the
# front-loaded {"ok": false, "errors": [...]} the spec promises.
if typeof(creation.get("spend", {})) != TYPE_DICTIONARY:
errors.append("spend must be an object")
else:
errors.append_array(_validate_spend(creation.get("spend", {})))
if typeof(creation.get("skills", [])) != TYPE_ARRAY:
errors.append("skills must be an array")
elif Races.exists(race_id) and Callings.exists(calling_id):
errors.append_array(_validate_skills(creation, race_id, calling_id))
return errors
static func _validate_spend(spend: Dictionary) -> Array:
var errors: Array = []
var total := 0
for stat in spend:
# LCK is not merely hidden at creation — it is unspendable (§7).
if not Attributes.exists(str(stat)):
errors.append("cannot spend on '%s' — not an attribute" % stat)
continue
if typeof(spend[stat]) != TYPE_INT:
errors.append("spend value for '%s' must be an integer" % stat)
continue
var v := int(spend[stat])
if v < 0:
errors.append("spend is additive only: %s is negative" % stat)
continue
total += v
if total > SPEND_POOL:
errors.append("spend exceeds the pool of %d (got %d)" % [SPEND_POOL, total])
return errors
static func _validate_skills(creation: Dictionary, race_id: String, calling_id: String) -> Array:
var errors: Array = []
var picks: Array = creation.get("skills", [])
var granted: Array = Races.granted_skills(race_id)
var pool: Array = Callings.skill_pool(calling_id)
var want := Callings.skill_count(calling_id)
if picks.size() != want:
errors.append("%s picks %d skills, got %d" % [calling_id, want, picks.size()])
var seen := {}
for s in picks:
if s in seen:
errors.append("duplicate skill pick: %s" % s)
seen[s] = true
if s not in pool:
errors.append("%s is not in the %s pool" % [s, calling_id])
if s in granted:
errors.append("%s is already granted by %s — the pick buys nothing" % [s, race_id])
var bonus := str(creation.get("bonus_skill", ""))
if Races.wants_bonus_skill(race_id):
if bonus == "":
errors.append("%s must choose a bonus skill" % race_id)
elif not Skills.exists(bonus):
errors.append("unknown bonus skill: %s" % bonus)
elif bonus in picks or bonus in granted:
errors.append("bonus skill %s is already proficient" % bonus)
elif bonus != "":
errors.append("%s does not get a bonus skill" % race_id)
return errors

View File

@@ -0,0 +1,15 @@
class_name Attributes
extends RefCounted
## The five attribute rows and the one modifier formula. LCK is deliberately NOT
## here — it is never an attribute row, never a save, never a skill (§7). It lives
## in GameState.luck.
const IDS := ["str", "dex", "con", "fth", "mag"]
static func exists(id: String) -> bool:
return id in IDS
static func modifier(score: int) -> int:
return floori((score - 10) / 2.0)

View File

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

View File

@@ -0,0 +1,93 @@
class_name Callings
extends RefCounted
## The 7 callings (races-and-classes spec §4). A calling is what a VILLAGE calls you,
## not what a rulebook calls you — Cutpurse, not Assassin; Bonesetter, not Priest.
##
## The skill POOLS are this spec's addition: the races/classes spec said only "drawn
## from the class's stat-appropriate slice" and never enumerated them. Each pool is the
## calling's primary-stat skills plus a small adjacent slice, always larger than the
## pick count so the choice is real.
##
## L1 talents are ID STUBS. M5 implements them. armor is a proficiency CATEGORY; real
## armor is an item (M7).
const IDS := ["sellsword", "reaver", "cutpurse", "trapper", "hedge_mage", "bonesetter", "bloodsworn"]
const TABLE := {
"sellsword": {
"primary": "str", "hit_die": 10, "saves": ["str", "con"],
"skill_pool": ["athletics", "intimidation", "endurance", "perception"], "skill_count": 2,
"casting_stat": "", "armor": "heavy", "talent": "second_wind",
},
"reaver": {
"primary": "str", "hit_die": 12, "saves": ["str", "con"],
"skill_pool": ["athletics", "intimidation", "endurance", "acrobatics"], "skill_count": 2,
"casting_stat": "", "armor": "medium", "talent": "blood_fury",
},
"cutpurse": {
"primary": "dex", "hit_die": 8, "saves": ["dex", "mag"],
"skill_pool": ["stealth", "sleight_of_hand", "acrobatics", "perception", "athletics", "intimidation"],
"skill_count": 4,
"casting_stat": "", "armor": "light", "talent": "backstab",
},
"trapper": {
"primary": "dex", "hit_die": 10, "saves": ["dex", "con"],
"skill_pool": ["stealth", "acrobatics", "perception", "endurance", "athletics"], "skill_count": 3,
"casting_stat": "", "armor": "light", "talent": "set_snare",
},
"hedge_mage": {
"primary": "mag", "hit_die": 6, "saves": ["mag", "fth"],
"skill_pool": ["sorcery", "perception", "sleight_of_hand", "faith_lore"], "skill_count": 2,
"casting_stat": "mag", "armor": "none", "talent": "hexbolt",
},
"bonesetter": {
"primary": "fth", "hit_die": 8, "saves": ["fth", "con"],
"skill_pool": ["faith_lore", "perception", "endurance", "intimidation"], "skill_count": 2,
"casting_stat": "fth", "armor": "medium", "talent": "mend",
},
"bloodsworn": {
"primary": "fth", "hit_die": 8, "saves": ["fth", "mag"],
"skill_pool": ["faith_lore", "sorcery", "intimidation", "perception"], "skill_count": 2,
"casting_stat": "fth", "armor": "light", "talent": "pact_mark",
},
}
static func exists(id: String) -> bool:
return TABLE.has(id)
static func primary(id: String) -> String:
return str(TABLE.get(id, {}).get("primary", ""))
static func hit_die(id: String) -> int:
return int(TABLE.get(id, {}).get("hit_die", 0))
static func saves(id: String) -> Array:
return TABLE.get(id, {}).get("saves", [])
static func skill_pool(id: String) -> Array:
return TABLE.get(id, {}).get("skill_pool", [])
static func skill_count(id: String) -> int:
return int(TABLE.get(id, {}).get("skill_count", 0))
static func casting_stat(id: String) -> String:
return str(TABLE.get(id, {}).get("casting_stat", ""))
static func is_caster(id: String) -> bool:
return casting_stat(id) != ""
static func armor(id: String) -> String:
return str(TABLE.get(id, {}).get("armor", ""))
static func talent(id: String) -> String:
return str(TABLE.get(id, {}).get("talent", ""))

View File

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

View File

@@ -0,0 +1,60 @@
class_name Races
extends RefCounted
## The 4 races (races-and-classes spec §3). FEATURES ONLY — no race touches an
## attribute score, which is what keeps the roll + spend pure.
##
## nightsight / claws / keen_scent are FLAGS, not numbers. Combat (M5) and the DM's
## exploration checks consume them; creation only stores them.
##
## The dwarf's +2 is SITUATIONAL (vs poison and the affliction track), so it is NOT
## a flat save bonus — baking a conditional into a flat number is how a sheet starts
## lying. It is exposed separately as poison_save_bonus().
const IDS := ["human", "elf", "dwarf", "beastfolk"]
const TABLE := {
"human": {
"save_bonus": 1, "granted_skills": [], "bonus_skill_choice": true,
"poison_save_bonus": 0,
"flags": {"nightsight": false, "claws": false, "keen_scent": false},
},
"elf": {
"save_bonus": 0, "granted_skills": ["perception"], "bonus_skill_choice": false,
"poison_save_bonus": 0,
"flags": {"nightsight": true, "claws": false, "keen_scent": false},
},
"dwarf": {
"save_bonus": 0, "granted_skills": [], "bonus_skill_choice": false,
"poison_save_bonus": 2,
"flags": {"nightsight": true, "claws": false, "keen_scent": false},
},
"beastfolk": {
"save_bonus": 0, "granted_skills": [], "bonus_skill_choice": false,
"poison_save_bonus": 0,
"flags": {"nightsight": false, "claws": true, "keen_scent": true},
},
}
static func exists(id: String) -> bool:
return TABLE.has(id)
static func save_bonus(id: String) -> int:
return int(TABLE.get(id, {}).get("save_bonus", 0))
static func granted_skills(id: String) -> Array:
return TABLE.get(id, {}).get("granted_skills", [])
static func wants_bonus_skill(id: String) -> bool:
return bool(TABLE.get(id, {}).get("bonus_skill_choice", false))
static func poison_save_bonus(id: String) -> int:
return int(TABLE.get(id, {}).get("poison_save_bonus", 0))
static func flag(id: String, name: String) -> bool:
return bool(TABLE.get(id, {}).get("flags", {}).get(name, false))

View File

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

View File

@@ -0,0 +1,28 @@
class_name Skills
extends RefCounted
## The 9 skills and the attribute each is governed by (races-and-classes spec §2).
## Perception sits under FTH, not a standalone WIS — §8's FTH/MAG replace WIS/INT,
## so a Bonesetter is also the party's best lookout. LCK owns no skill (§7).
const BY_ATTRIBUTE := {
"athletics": "str",
"intimidation": "str",
"stealth": "dex",
"sleight_of_hand": "dex",
"acrobatics": "dex",
"endurance": "con",
"perception": "fth",
"faith_lore": "fth",
"sorcery": "mag",
}
const IDS := ["athletics", "intimidation", "stealth", "sleight_of_hand", "acrobatics",
"endurance", "perception", "faith_lore", "sorcery"]
static func exists(skill: String) -> bool:
return BY_ATTRIBUTE.has(skill)
static func attribute(skill: String) -> String:
return str(BY_ATTRIBUTE.get(skill, ""))

View File

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

View File

@@ -0,0 +1,94 @@
class_name CharacterSheet
extends RefCounted
## The character, as state (§2). STORES only what was rolled or chosen, plus the two
## numbers that genuinely mutate in play (current hp/mp). Everything else is DERIVED —
## so a derived number can never drift from the inputs that produced it, and M5's level
## curve becomes a function change rather than a stored-field migration.
##
## LCK IS NOT HERE, and there is no accessor for it. It lives in GameState.luck: no
## save, no skill, no row (§7). That holds by construction, not by discipline.
const PROF_BONUS_L1 := 2
# --- stored: rolled, chosen, or genuinely mutable -------------------------------
var attributes: Dictionary = {} # {str,dex,con,fth,mag} — final, post-spend
var race_id: String = ""
var calling_id: String = ""
var level: int = 1
var skills: Array = [] # chosen + race-granted + the human's bonus pick
var bonus_skill: String = "" # human only; also present in `skills`
var hp: int = 0 # CURRENT hp — combat writes this
var mp: int = 0 # CURRENT mp
# --- derived: functions, never stored -------------------------------------------
func modifier(stat: String) -> int:
return Attributes.modifier(int(attributes.get(stat, 10)))
func prof_bonus() -> int:
return PROF_BONUS_L1 # the level curve is M5's; creation is L1
func max_hp() -> int:
return Callings.hit_die(calling_id) + modifier("con")
func max_mp() -> int:
# TUNABLE — M5 owns the real MP curve (races-and-classes spec §6 defers it).
# The SHAPE is what M4-b needs to draw a bar: martials have no pool at all; a
# caster's scales off the casting stat, with a floor so a bad roll still casts.
if not Callings.is_caster(calling_id):
return 0
return maxi(4, 4 + 4 * modifier(Callings.casting_stat(calling_id)))
func ac() -> int:
return 10 + modifier("dex") # armor is an item -> M7
func initiative() -> int:
return modifier("dex")
func is_proficient(skill: String) -> bool:
return skill in skills
func save(stat: String) -> int:
var v := modifier(stat)
if stat in Callings.saves(calling_id):
v += prof_bonus()
v += Races.save_bonus(race_id) # human's +1 to all five
return v
func poison_save_bonus() -> int:
# Situational (vs poison + the affliction track) — deliberately NOT folded into
# save("con"), because a conditional inside a flat number is a sheet that lies.
return Races.poison_save_bonus(race_id)
func skill_bonus(skill: String) -> int:
var v := modifier(Skills.attribute(skill))
if is_proficient(skill):
v += prof_bonus()
return v
func spell_dc() -> int:
if not Callings.is_caster(calling_id):
return 0
return 8 + prof_bonus() + modifier(Callings.casting_stat(calling_id))
func has_nightsight() -> bool:
return Races.flag(race_id, "nightsight")
func has_claws() -> bool:
return Races.flag(race_id, "claws")
func has_keen_scent() -> bool:
return Races.flag(race_id, "keen_scent")

View File

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

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

@@ -1,12 +1,13 @@
class_name GameState
extends RefCounted
## Luck-centric game state (spec decision 5). The SOLE home of numeric Luck,
## stats, world-NPC dispositions, and inventory — none of which enter the canon
## log (charter §7/§2). The log reads only luck_descriptor() from here.
## the character sheet, world-NPC dispositions, and inventory — none of which
## enter the canon log (charter §7/§2). The log reads only luck_descriptor()
## from here.
var luck: int = 0
var luck_base: int = 0
var stats: Dictionary = {} # {str, dex, con, fth, mag}
var sheet: CharacterSheet = null # the whole character (§2). Built by NewGame.
var npc_dispositions: Dictionary = {} # world-npc id -> int (100..100)
var inventory: Dictionary = {} # item_id -> qty (NEVER money — see purse_copper)
var purse_copper: int = 0 # ALL money, in copper. One integer (§4.1 of the spec).

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

@@ -19,6 +19,8 @@ extends Control
const HttpTransport = preload("res://scripts/net/http_transport.gd")
var service: DmService = null # injection seam; real HttpTransport built if null
var injected_log: CanonLog = null # injection seam; _build_seed_log() if null (F6/standalone)
var injected_state: GameState = null # injection seam; ShellState.seed() if null
var _state: ShellState
var _log: CanonLog
@@ -40,8 +42,10 @@ func _ready() -> void:
_fit_to_viewport()
get_viewport().size_changed.connect(_fit_to_viewport)
_state = ShellState.seed()
_log = _build_seed_log()
# The injected character wins; the seed calls are the F6/standalone fallback —
# same shape as `service == null` building a real transport below.
_state = ShellState.for_character(injected_state, injected_log) if injected_state != null else ShellState.seed()
_log = injected_log if injected_log != null else _build_seed_log()
_bind_state()
if service == null:
@@ -121,7 +125,7 @@ func _build_seed_log() -> CanonLog:
# save/load (M9) construct the real one — mirrors narrate_harness. No Luck
# number is rendered anywhere (§7); the descriptor is prose-only.
var log := CanonLog.new()
log.player = LogPlayer.new("Vexcca", "sellsword", "Fortune spits on you")
log.player = LogPlayer.new("Vexcca", "human", "sellsword", "Fortune spits on you")
log.set_location("lower_ward", "the Lower Ward")
log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40))
log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15))

View File

@@ -34,3 +34,19 @@ static func seed() -> ShellState:
s.location_label = "THE LOWER WARD"
s.dock_open = true
return s
static func for_character(state: GameState, log: CanonLog) -> ShellState:
## The real HUD state for a constructed character. Vitals + location come from
## the character (§2 — code owns them); turn_order/consumables/round_label are
## still seed() placeholders because combat (M5) and inventory (M7) own those.
var s := ShellState.seed() # start from the placeholders, then overwrite what is real
s.vitals = {
"hp": state.sheet.hp,
"hp_max": state.sheet.max_hp(),
"mp": state.sheet.mp,
"mp_max": state.sheet.max_mp(),
"purse_copper": state.purse_copper,
}
s.location_label = log.location.name
return s

View File

@@ -9,8 +9,6 @@ extends Control
signal menu_activated(id: StringName)
const SHELL := "res://scenes/shell/MainWindowShell.tscn"
var _rows: Array = []
var _sel: int = 0
@@ -44,7 +42,6 @@ func _ready() -> void:
_wire_row_mouse()
_footer_left.text = Version.new().footer()
menu_activated.connect(_on_menu_activated)
_update_selection()
@@ -128,16 +125,6 @@ func _activate(i: int) -> void:
menu_activated.emit(_rows[i].get_meta("action"))
func _on_menu_activated(id: StringName) -> void:
match id:
&"new_game":
get_tree().change_scene_to_file(SHELL)
&"quit":
get_tree().quit()
_:
pass # inert placeholder — the screen lands in a later milestone
func _update_selection() -> void:
for i in range(_rows.size()):
var row: Control = _rows[i]

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

@@ -0,0 +1,91 @@
extends "res://addons/gut/test.gd"
const CharacterSheet = preload("res://scripts/state/character_sheet.gd")
func _sheet(race := "human", calling := "sellsword", attrs := {}) -> CharacterSheet:
var s = CharacterSheet.new()
s.race_id = race
s.calling_id = calling
s.attributes = {"str": 14, "dex": 12, "con": 14, "fth": 10, "mag": 8}
for k in attrs:
s.attributes[k] = attrs[k]
return s
func test_max_hp_is_hit_die_plus_con_mod():
var s = _sheet("human", "reaver") # d12, CON 14 -> +2
assert_eq(s.max_hp(), 14)
var frail = _sheet("human", "hedge_mage", {"con": 8}) # d6, CON 8 -> -1
assert_eq(frail.max_hp(), 5)
func test_ac_is_ten_plus_dex():
assert_eq(_sheet().ac(), 11) # DEX 12 -> +1. Armor is an item (M7).
func test_initiative_is_dex():
assert_eq(_sheet().initiative(), 1)
func test_save_picks_up_calling_proficiency():
var s = _sheet("elf", "sellsword") # saves STR, CON; prof +2
assert_eq(s.save("str"), 4) # STR 14 -> +2, +2 prof
assert_eq(s.save("dex"), 1) # DEX 12 -> +1, not proficient
func test_human_plus_one_lands_on_all_five_saves():
var human = _sheet("human", "sellsword")
var elf = _sheet("elf", "sellsword")
for stat in ["str", "dex", "con", "fth", "mag"]:
assert_eq(human.save(stat), elf.save(stat) + 1, "human +1 on %s" % stat)
func test_dwarf_poison_bonus_is_not_in_the_con_save():
var dwarf = _sheet("dwarf", "sellsword")
var elf = _sheet("elf", "sellsword")
assert_eq(dwarf.save("con"), elf.save("con"), "the +2 is situational, not a flat CON save")
assert_eq(dwarf.poison_save_bonus(), 2)
assert_eq(elf.poison_save_bonus(), 0)
func test_skill_bonus_uses_the_governing_attribute():
var s = _sheet() # STR 14 -> +2
assert_eq(s.skill_bonus("athletics"), 2, "not proficient: attribute only")
s.skills = ["athletics"]
assert_eq(s.skill_bonus("athletics"), 4, "proficient: +2 prof")
func test_bonus_skill_confers_proficiency_via_the_skills_list():
var s = _sheet()
s.skills = ["athletics", "endurance"] # Task 5 appends the human's pick here
s.bonus_skill = "endurance"
assert_true(s.is_proficient("endurance"))
assert_false(s.is_proficient("stealth"))
func test_martials_have_no_mp_and_no_spell_dc():
var s = _sheet("human", "sellsword")
assert_eq(s.max_mp(), 0)
assert_eq(s.spell_dc(), 0)
func test_casters_have_mp_and_a_spell_dc():
var s = _sheet("human", "bonesetter", {"fth": 16}) # FTH 16 -> +3
assert_true(s.max_mp() > 0, "a caster has a pool")
assert_eq(s.spell_dc(), 13) # 8 + 2 prof + 3
func test_race_flags_are_exposed():
assert_true(_sheet("elf").has_nightsight())
assert_false(_sheet("human").has_nightsight())
assert_true(_sheet("beastfolk").has_claws())
assert_true(_sheet("beastfolk").has_keen_scent())
assert_false(_sheet("dwarf").has_claws())
func test_luck_is_nowhere_on_the_sheet():
# §7: LCK has no save, no skill, no row, no accessor. It lives in GameState.
var s = _sheet()
assert_false("lck" in s.attributes, "LCK is never an attribute row")
assert_false(s.has_method("luck"), "the sheet must not expose Luck")

View File

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

View File

@@ -91,3 +91,90 @@ func test_no_loaded_topic_has_a_body():
func test_legacy_npcs_still_load():
assert_true(db.has_npc("fenn"))
func test_loads_calling_and_race_content():
assert_true(db.has_calling("reaver"))
assert_true(db.has_calling("bloodsworn"))
assert_true(db.has_race("beastfolk"))
assert_eq(db.calling("reaver")["name"], "Reaver")
assert_eq(db.race("beastfolk")["name"], "Beastfolk")
func test_calling_content_matches_the_code_table():
# The roster exists in code (Callings.IDS — mechanics are rules) and in content
# (the blurbs). Nothing at runtime reconciles them, so guard against drift.
var content_ids: Array = db.callings.keys()
content_ids.sort()
var code_ids: Array = Callings.IDS.duplicate()
code_ids.sort()
assert_eq(content_ids, code_ids)
func test_race_content_matches_the_code_table():
var content_ids: Array = db.races.keys()
content_ids.sort()
var code_ids: Array = Races.IDS.duplicate()
code_ids.sort()
assert_eq(content_ids, code_ids)
func test_blurb_content_carries_no_mechanics():
# Mechanics are code. A hit_die in a content file is a second source of truth.
for id in db.callings:
for banned in ["hit_die", "saves", "skill_pool", "skill_count", "armor", "talent"]:
assert_false(db.callings[id].has(banned),
"%s.json leaks the mechanic '%s' — that lives in Callings" % [id, banned])
for id in db.races:
for banned in ["save_bonus", "poison_save_bonus", "granted_skills", "flags",
"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

@@ -0,0 +1,25 @@
extends "res://addons/gut/test.gd"
## Guards the two dev-only proving-scene seed logs (narrate_harness,
## npc_harness) against the same LogPlayer arg-slot regression the shell test
## guards (see test_main_window_shell.gd). Both scenes build their seed log's
## LogPlayer in _ready() with no network call until a button is pressed, so
## instantiate + add_child_autofree is cheap and touches no network.
const NARRATE_SCENE := "res://scenes/narrate_harness.tscn"
const NPC_SCENE := "res://scenes/npc_harness.tscn"
func test_narrate_harness_seed_log_has_real_race_and_calling():
var node = load(NARRATE_SCENE).instantiate()
add_child_autofree(node)
assert_true(Races.exists(node._log.player.race_id), "seed race must be a real race")
assert_true(Callings.exists(node._log.player.calling_id), "seed calling must be a real calling")
assert_ne(node._log.player.luck_descriptor, "", "the descriptor must not be lost to an arg-slot shift")
func test_npc_harness_seed_log_has_real_race_and_calling():
var node = load(NPC_SCENE).instantiate()
add_child_autofree(node)
assert_true(Races.exists(node._canon_log.player.race_id), "seed race must be a real race")
assert_true(Callings.exists(node._canon_log.player.calling_id), "seed calling must be a real calling")
assert_ne(node._canon_log.player.luck_descriptor, "", "the descriptor must not be lost to an arg-slot shift")

View File

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

View File

@@ -14,18 +14,36 @@ func test_ids_regex():
assert_false(Ids.is_valid("has-hyphen"))
func test_player_dict_has_only_three_keys():
var p = LogPlayer.new("Aldric", "sellsword", "Fortune spits on you")
var d = p.to_dict()
assert_eq(d.keys().size(), 3)
assert_false("luck" in d)
assert_eq(d["class_id"], "sellsword")
func test_log_player_carries_race_and_calling_and_no_luck_number():
var p := LogPlayer.new("Aldric", "beastfolk", "cutpurse", "Fortune spits on you")
var d := p.to_dict()
assert_eq(d["race_id"], "beastfolk")
assert_eq(d["calling_id"], "cutpurse")
assert_false("luck" in d, "numeric Luck never reaches the log (§7)")
assert_false("class_id" in d)
assert_eq(d.keys().size(), 4)
func test_player_rejects_unknown_class():
var p = LogPlayer.new("Aldric", "sellsword", "x")
assert_false(p.set_class_id("bard"))
assert_eq(p.class_id, "sellsword")
func test_log_player_rejects_a_dead_calling():
var p := LogPlayer.new()
assert_false(p.set_calling_id("priest"), "priest is not a calling")
assert_true(p.set_calling_id("bonesetter"))
assert_false(p.set_race_id("orc"))
assert_true(p.set_race_id("dwarf"))
func test_log_player_ctor_rejects_calling_passed_as_race():
# The exact shape of the bug: calling three broken call sites used to pass —
# LogPlayer.new(name, class_id, luck_descriptor) against the NEW ctor
# (name, race_id, calling_id, luck_descriptor). "sellsword" lands in the
# race_id slot (rejected — not a race), the descriptor lands in the
# calling_id slot (rejected — not a calling), and luck_descriptor is left
# at its default. Every field ends up "", which 422s against the schema.
var p := LogPlayer.new("Aldric", "sellsword", "Fortune spits on you")
var d := p.to_dict()
assert_eq(d["race_id"], "", "a calling id is not a legal race_id")
assert_eq(d["calling_id"], "", "a luck descriptor is not a legal calling_id")
assert_eq(d["luck_descriptor"], "", "old 3-arg call shape must not produce a usable player row")
func test_party_member_clamps_disposition():

View File

@@ -3,6 +3,12 @@ extends "res://addons/gut/test.gd"
const GameState = preload("res://scripts/state/game_state.gd")
func test_state_holds_a_sheet_not_a_stat_bag():
var gs = GameState.new()
assert_null(gs.sheet, "the sheet is built by NewGame.construct")
assert_false("stats" in gs, "the flat stats Dictionary is gone")
func test_add_item_accumulates():
var s = GameState.new()
s.add_item("worn_shortsword", 3)

View File

@@ -0,0 +1,71 @@
extends "res://addons/gut/test.gd"
const SCENE := "res://scenes/Main.tscn"
const ContentDB = preload("res://scripts/content/content_db.gd")
const FakeTransport = preload("res://tests/doubles/fake_transport.gd")
const DmResponse = preload("res://scripts/net/dm_response.gd")
func _flow() -> Node:
# Inject fakes BEFORE _ready (add_child): a real world + a network-free service,
# so the shell's initial narrate never touches HTTP.
var node = load(SCENE).instantiate()
var world = ContentDB.new()
world.load_from(ContentDB.default_content_root())
node.world = world
node.origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
var transport = FakeTransport.new()
transport.set_response(DmResponse.ok(200, {"prose": "Rain on the cobbles."}))
node.service = DmService.new(transport, FallbackLibrary.new())
add_child_autofree(node)
return node
func _creation() -> Dictionary:
return {
"name": "Dagnet", "race_id": "human", "calling_id": "sellsword",
"seed": 8675309, "spend": {},
"skills": ["athletics", "endurance"], "bonus_skill": "perception",
}
func test_boots_into_the_title():
var flow := _flow()
assert_true(flow._current is TitleScreen, "Main opens on the title")
func test_new_game_shows_creation():
var flow := _flow()
flow._current.emit_signal("menu_activated", &"new_game")
assert_true(flow._current is CharacterCreation, "new game routes to creation")
func test_confirming_creation_reaches_the_shell_as_the_created_character():
var flow := _flow()
flow._current.emit_signal("menu_activated", &"new_game")
flow._current.emit_signal("creation_confirmed", _creation())
assert_true(flow._current is MainWindowShell, "creation routes to the shell")
assert_eq(flow._current._log.player.name, "Dagnet",
"the CREATED character flowed through — not a seed, not a swap alone")
func _assert_stays_on_creation(bad: Dictionary, why: String) -> void:
var flow := _flow()
flow._current.emit_signal("menu_activated", &"new_game")
# Drive the REAL signal with an invalid dict. The CTA gates this out via the UI,
# but §2 forbids Main trusting the caller and §13 forbids surfacing an error.
flow._current.emit_signal("creation_confirmed", bad)
assert_false(flow._current is MainWindowShell, "a failed construct never reaches the shell (§13): %s" % why)
assert_true(flow._current is CharacterCreation, "the player stays on the valid creation screen: %s" % why)
func test_construct_failure_empty_name_stays_on_creation():
var bad := _creation()
bad["name"] = "" # empty name fails NewGame.validate
_assert_stays_on_creation(bad, "empty name")
func test_construct_failure_bad_calling_stays_on_creation():
var bad := _creation()
bad["calling_id"] = "bard" # NPC-only / not a real calling — fails NewGame.validate
_assert_stays_on_creation(bad, "unknown calling")

View File

@@ -0,0 +1 @@
uid://17abdh3almry

View File

@@ -3,6 +3,8 @@ extends "res://addons/gut/test.gd"
const SCENE := "res://scenes/shell/MainWindowShell.tscn"
const FakeTransport = preload("res://tests/doubles/fake_transport.gd")
const DmResponse = preload("res://scripts/net/dm_response.gd")
const NewGame = preload("res://scripts/newgame/new_game.gd")
const ContentDB = preload("res://scripts/content/content_db.gd")
func _shell() -> MainWindowShell:
@@ -35,3 +37,48 @@ func test_shell_builds_a_code_owned_seed_log():
var node := _shell()
assert_not_null(node._log, "a canon log exists to post (§11)")
assert_gt(node._log.established_facts.size(), 0, "seed scene facts are code-owned")
func test_shell_seed_player_has_a_real_race_and_calling():
# Regression: the shell's _build_seed_log() once called LogPlayer.new() with
# the OLD 3-arg (name, class_id, luck_descriptor) shape against the NEW
# 4-arg ctor. Every param defaults, so it compiled silently and both
# race_id/calling_id came out "" — which 422s against the canon-log schema.
# Assert against the log the shell actually builds, not a fresh LogPlayer.
var node := _shell()
assert_true(Races.exists(node._log.player.race_id), "seed race must be a real race")
assert_true(Callings.exists(node._log.player.calling_id), "seed calling must be a real calling")
assert_ne(node._log.player.luck_descriptor, "", "the descriptor must not be lost to an arg-slot shift")
func _built_character():
var world = ContentDB.new()
world.load_from(ContentDB.default_content_root())
var origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
var creation := {
"name": "Aldric", "race_id": "human", "calling_id": "sellsword",
"seed": 8675309, "spend": {},
"skills": ["athletics", "endurance"], "bonus_skill": "perception",
}
return NewGame.construct(origin, world, creation)
func _shell_with_character(res) -> MainWindowShell:
var node = load(SCENE).instantiate()
var transport = FakeTransport.new()
transport.set_response(DmResponse.ok(200, {"prose": "Rain on the cobbles."}))
node.service = DmService.new(transport, FallbackLibrary.new())
node.injected_log = res["log"]
node.injected_state = res["state"]
add_child_autofree(node)
return node
func test_shell_shows_the_injected_character_not_the_seed():
var res = _built_character()
assert_true(res["ok"], str(res["errors"]))
var node := _shell_with_character(res)
assert_same(node._log, res["log"], "the injected canon log drives narration")
var expected := "HP %d / %d" % [res["state"].sheet.hp, res["state"].sheet.max_hp()]
assert_eq(node._hp.text, expected, "HP label derives from the real sheet")
assert_ne(node._hp.text, "HP 42 / 60", "NOT the seed's Vexcca vitals")

View File

@@ -15,31 +15,212 @@ func _deserter() -> Dictionary:
return ContentDB.load_json(ContentDB.origin_path("deserter"))
func _rng() -> RandomNumberGenerator:
var r := RandomNumberGenerator.new()
r.seed = 7
return r
func _creation(overrides := {}) -> Dictionary:
var c := {
"name": "Aldric",
"race_id": "human",
"calling_id": "sellsword",
"seed": 8675309,
"spend": {},
"skills": ["athletics", "endurance"],
"bonus_skill": "perception",
}
for k in overrides:
c[k] = overrides[k]
return c
func _build(creation: Dictionary) -> Dictionary:
return NewGame.construct(_deserter(), world, creation, _rng())
return NewGame.construct(_deserter(), world, creation)
func test_construct_succeeds():
var res := _build({"name": "Aldric", "class_id": "sellsword"})
func test_same_seed_yields_an_identical_character():
# The determinism guarantee (§10). Two independent constructs, one seed.
var a := _build(_creation())
var b := _build(_creation())
assert_true(a["ok"] and b["ok"])
assert_eq(a["state"].sheet.attributes, b["state"].sheet.attributes)
assert_eq(a["state"].luck, b["state"].luck, "even the hidden Luck is reproducible")
func test_golden_vector_pins_the_rng_stream():
# The determinism test above (`test_same_seed_yields_an_identical_character`)
# compares two `construct` runs against EACH OTHER. Both would drift together
# if the roll order changed, so it proves nothing about the ORDER itself.
# Swap two entries in `Attributes.IDS` and that test still passes while every
# already-persisted seed silently becomes a different character.
#
# This test pins one seed to its EXACT rolled values — all five attributes and
# the hidden Luck, drawn from the same stream immediately after — so reordering
# `Attributes.IDS` or moving the Luck roll fails LOUDLY, here, instead of
# silently rewriting every saved character.
var res := _build(_creation({"seed": 8675309}))
assert_true(res["ok"], str(res["errors"]))
var attrs: Dictionary = res["state"].sheet.attributes
assert_eq(int(attrs["str"]), 8)
assert_eq(int(attrs["dex"]), 13)
assert_eq(int(attrs["con"]), 8)
assert_eq(int(attrs["fth"]), 14)
assert_eq(int(attrs["mag"]), 8)
assert_eq(int(res["state"].luck), 6)
func test_numeric_luck_absent_from_log():
var res := _build({"name": "Aldric", "class_id": "sellsword"})
func test_a_missing_seed_is_rejected():
# A seed silently defaulting to 0 builds a perfectly valid, deterministic
# character — which is exactly the problem: a saga that forgets the key
# gets no signal at all. Require it explicitly.
var creation := _creation()
creation.erase("seed")
var res := NewGame.construct(_deserter(), world, creation)
assert_false(res["ok"])
assert_true("seed" in str(res["errors"]).to_lower())
func test_a_non_integer_seed_is_rejected():
var res := _build(_creation({"seed": "8675309"}))
assert_false(res["ok"])
assert_true("seed" in str(res["errors"]).to_lower())
func test_a_different_seed_yields_a_different_character():
var a := _build(_creation())
var b := _build(_creation({"seed": 1234567}))
assert_ne(a["state"].sheet.attributes, b["state"].sheet.attributes)
func test_every_attribute_respects_the_floor_of_eight():
# 3d6 can roll 3. The floor is a design guarantee, not a dice accident.
for s in range(40):
var res := _build(_creation({"seed": s}))
assert_true(res["ok"], str(res["errors"]))
for stat in Attributes.IDS:
assert_true(int(res["state"].sheet.attributes[stat]) >= 8,
"seed %d rolled %s below the floor" % [s, stat])
func test_spend_is_additive_on_top_of_the_roll():
var base := _build(_creation())
var spent := _build(_creation({"spend": {"str": 2, "con": 1}}))
assert_eq(int(spent["state"].sheet.attributes["str"]),
int(base["state"].sheet.attributes["str"]) + 2)
assert_eq(int(spent["state"].sheet.attributes["con"]),
int(base["state"].sheet.attributes["con"]) + 1)
assert_eq(int(spent["state"].sheet.attributes["dex"]),
int(base["state"].sheet.attributes["dex"]), "unspent stats are untouched")
func test_spend_over_the_pool_is_rejected():
var res := _build(_creation({"spend": {"str": 2, "con": 2}}))
assert_false(res["ok"])
assert_true("spend" in str(res["errors"]).to_lower())
func test_spend_cannot_be_negative():
var res := _build(_creation({"spend": {"str": -2, "con": 1}}))
assert_false(res["ok"], "additive only — there is no [-]")
func test_a_negative_spend_does_not_mask_an_over_pool_spend():
# {"str": -1, "dex": 4} sums to 3 — at the pool limit — so without a
# `continue`/floor after the negative-value error, the over-pool check
# never independently fires; only the negative-value error does.
var res := _build(_creation({"spend": {"str": -1, "dex": 4}}))
assert_false(res["ok"])
var joined := str(res["errors"]).to_lower()
assert_true("negative" in joined, "the negative-value error should fire")
assert_true("exceeds the pool" in joined, "the over-pool error should ALSO fire")
func test_spend_rejects_non_integer_values():
var floats := _build(_creation({"spend": {"str": 2.9}}))
assert_false(floats["ok"], "a float must be rejected, not truncated to +2")
var bools := _build(_creation({"spend": {"str": true}}))
assert_false(bools["ok"], "a bool must be rejected, not coerced to +1")
var strings := _build(_creation({"spend": {"str": "abc"}}))
assert_false(strings["ok"], "a string must be rejected, not coerced to +0")
func test_luck_can_never_be_spent():
# §7: LCK is untouchable at creation. Not hidden — impossible.
var res := _build(_creation({"spend": {"lck": 3}}))
assert_false(res["ok"])
func test_skills_must_come_from_the_callings_pool():
var res := _build(_creation({"skills": ["athletics", "sorcery"]}))
assert_false(res["ok"], "sorcery is not in the sellsword's pool")
func test_skills_must_be_the_right_count():
var res := _build(_creation({"skills": ["athletics"]}))
assert_false(res["ok"], "a sellsword picks 2")
func test_skills_reject_a_duplicate():
var res := _build(_creation({"skills": ["athletics", "athletics"]}))
assert_false(res["ok"])
func test_an_elf_cannot_spend_a_pick_on_perception():
# He already has it. A pick that buys nothing is a trap, not a choice.
var res := _build(_creation({
"race_id": "elf", "bonus_skill": "",
"skills": ["athletics", "perception"]}))
assert_false(res["ok"])
func test_the_elf_is_granted_perception_anyway():
var res := _build(_creation({
"race_id": "elf", "bonus_skill": "", "skills": ["athletics", "endurance"]}))
assert_true(res["ok"], str(res["errors"]))
assert_true(res["state"].sheet.is_proficient("perception"))
func test_a_human_must_choose_a_bonus_skill():
var res := _build(_creation({"bonus_skill": ""}))
assert_false(res["ok"])
func test_a_non_human_must_not_have_one():
var res := _build(_creation({"race_id": "dwarf", "bonus_skill": "stealth"}))
assert_false(res["ok"])
func test_the_humans_bonus_skill_confers_proficiency():
var res := _build(_creation()) # human, bonus_skill perception
assert_true(res["ok"], str(res["errors"]))
assert_true(res["state"].sheet.is_proficient("perception"))
func test_the_character_starts_whole():
var res := _build(_creation())
var sheet = res["state"].sheet
assert_eq(sheet.hp, sheet.max_hp())
assert_eq(sheet.mp, sheet.max_mp())
func test_a_calling_the_origin_forbids_is_rejected():
var o := _deserter()
o["build_constraints"] = o["build_constraints"].duplicate(true)
o["build_constraints"]["allowed_callings"] = ["bonesetter"]
var res := NewGame.construct(o, world, _creation())
assert_false(res["ok"])
func test_no_luck_number_and_no_attributes_reach_the_log():
# §7 + §11: the log carries only what the AI narrates from.
var res := _build(_creation())
var pd: Dictionary = res["log"].player.to_dict()
assert_eq(pd.keys().size(), 4)
assert_false("luck" in pd)
assert_eq(pd.keys().size(), 3)
assert_false("attributes" in pd)
assert_true(res["state"].luck >= Luck.MIN and res["state"].luck <= Luck.MAX)
func test_inventory_and_dispo_land_in_state_not_log():
var res := _build({"name": "Aldric", "class_id": "sellsword"})
var res := _build(_creation())
assert_eq(res["state"].inventory.get("worn_shortsword", 0), 1)
assert_eq(res["state"].purse_copper, 47, "the deserter is down to his last coin")
assert_false("copper" in res["state"].inventory, "money is never an inventory row")
@@ -47,20 +228,20 @@ func test_inventory_and_dispo_land_in_state_not_log():
func test_companion_dispositions_from_overrides():
var res := _build({"name": "Aldric", "class_id": "sellsword"})
var res := _build(_creation())
assert_eq(res["log"].party_member("brannoc_thane").disposition, 40)
assert_eq(res["log"].party_member("cadwyn_vell").disposition, 15)
func test_situation_and_facts_seeded():
var res := _build({"name": "Aldric", "class_id": "sellsword"})
var res := _build(_creation())
assert_eq(res["log"].recent_events.size(), 2)
assert_true("the player deserted the Iron Kettle mercenary company" in res["log"].established_facts)
assert_eq(res["log"].humiliations.size(), 0)
func test_active_quest_resolved_from_world():
var res := _build({"name": "Aldric", "class_id": "sellsword"})
var res := _build(_creation())
assert_eq(res["log"].active_quests.size(), 1)
assert_eq(res["log"].active_quests[0].id, "find_the_ledger")
assert_eq(res["log"].active_quests[0].status, "active")
@@ -69,45 +250,136 @@ func test_active_quest_resolved_from_world():
func test_null_start_quest_yields_no_quest():
var o := _deserter()
o["start_quest_id"] = null
var res := NewGame.construct(o, world, {"name": "X", "class_id": "sellsword"}, _rng())
var res := NewGame.construct(o, world, _creation())
assert_eq(res["log"].active_quests.size(), 0)
func test_class_not_allowed_is_rejected():
var res := _build({"name": "X", "class_id": "berserker"})
assert_false(res["ok"])
assert_true(res["errors"].size() > 0)
assert_null(res["log"])
func test_unresolved_origin_is_rejected():
var o := _deserter()
o["start_location_id"] = "nowhere"
var res := NewGame.construct(o, world, {"name": "X", "class_id": "sellsword"}, _rng())
var res := NewGame.construct(o, world, _creation())
assert_false(res["ok"])
assert_true("unresolved ref: location:nowhere" in res["errors"])
func test_empty_name_is_rejected():
var missing_name := _build({"class_id": "sellsword"})
var missing_name := _build(_creation({"name": ""}))
assert_false(missing_name["ok"])
assert_true(missing_name["errors"].size() > 0)
assert_null(missing_name["log"])
var whitespace_name := _build({"name": " ", "class_id": "sellsword"})
var whitespace_name := _build(_creation({"name": " "}))
assert_false(whitespace_name["ok"])
assert_true(whitespace_name["errors"].size() > 0)
assert_null(whitespace_name["log"])
func test_a_null_spend_is_rejected_not_crashed():
# A JSON round-trip can hand back {"spend": null}. Without a container-type
# guard, assigning null into a statically-typed Dictionary parameter throws
# a GDScript runtime error INSIDE construct() instead of a front-loaded error.
var res := _build(_creation({"spend": null}))
assert_false(res["ok"])
assert_true("spend" in str(res["errors"]).to_lower())
func test_non_array_skills_is_rejected_not_crashed():
var res := _build(_creation({"skills": "athletics"}))
assert_false(res["ok"])
assert_true("skills" in str(res["errors"]).to_lower())
func test_non_companion_override_goes_to_state_not_log():
world.npcs["oda_fenn"] = {"id": "oda_fenn", "name": "Oda Fenn", "role": "npc"}
var o := _deserter()
o["disposition_overrides"] = {"brannoc_thane": 40, "oda_fenn": -25}
var res := NewGame.construct(o, world, {"name": "X", "class_id": "sellsword"}, _rng())
var res := NewGame.construct(o, world, _creation())
assert_true(res["ok"], str(res["errors"]))
assert_eq(res["state"].npc_dispositions["oda_fenn"], -25)
assert_null(res["log"].party_member("oda_fenn"))
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

@@ -8,10 +8,17 @@ const CanonLog = preload("res://scripts/canon_log/canon_log.gd")
func _built_log():
var world = ContentDB.new()
world.load_from(ContentDB.default_content_root())
var rng := RandomNumberGenerator.new()
rng.seed = 3
var origin: Dictionary = ContentDB.load_json(ContentDB.origin_path("deserter"))
var res := NewGame.construct(origin, world, {"name": "Aldric", "class_id": "priest"}, rng)
var creation := {
"name": "Aldric",
"race_id": "human",
"calling_id": "bonesetter",
"seed": 3,
"spend": {},
"skills": ["faith_lore", "perception"],
"bonus_skill": "endurance",
}
var res := NewGame.construct(origin, world, creation)
assert_true(res["ok"], str(res["errors"]))
return res["log"]
@@ -28,9 +35,10 @@ func test_constructed_log_satisfies_contract_invariants():
var d: Dictionary = _built_log().to_dict()
assert_eq(d["schema_version"], 1)
assert_true(d["recent_events"].size() <= 5)
assert_eq(d["player"].keys().size(), 3) # exactly name/class_id/luck_descriptor
assert_eq(d["player"].keys().size(), 4) # exactly name/race_id/calling_id/luck_descriptor
assert_false("luck" in d["player"]) # §7: no numeric luck
assert_true(d["player"]["class_id"] in ["sellsword", "assassin", "priest"])
assert_true(d["player"]["calling_id"] in Callings.IDS)
assert_true(d["player"]["race_id"] in Races.IDS)
for m in d["party"]:
assert_true(m["disposition"] >= -100 and m["disposition"] <= 100)
for q in d["active_quests"]:

View File

@@ -0,0 +1,97 @@
extends "res://addons/gut/test.gd"
func test_modifier_at_the_boundaries():
assert_eq(Attributes.modifier(7), -2)
assert_eq(Attributes.modifier(8), -1)
assert_eq(Attributes.modifier(9), -1)
assert_eq(Attributes.modifier(10), 0)
assert_eq(Attributes.modifier(11), 0)
assert_eq(Attributes.modifier(12), 1)
assert_eq(Attributes.modifier(18), 4)
func test_the_five_attributes_exclude_luck():
assert_eq(Attributes.IDS, ["str", "dex", "con", "fth", "mag"])
assert_false("lck" in Attributes.IDS, "LCK is never an attribute row (§7)")
func test_nine_skills_each_governed_by_an_attribute():
assert_eq(Skills.IDS.size(), 9)
for s in Skills.IDS:
assert_true(Skills.attribute(s) in Attributes.IDS, "%s has no governing attribute" % s)
func test_perception_is_faith_and_sorcery_is_magic():
assert_eq(Skills.attribute("perception"), "fth")
assert_eq(Skills.attribute("sorcery"), "mag")
assert_false(Skills.exists("lockpicking"))
func test_four_races():
assert_eq(Races.IDS, ["human", "elf", "dwarf", "beastfolk"])
func test_human_gets_a_save_bonus_and_a_skill_choice():
assert_eq(Races.save_bonus("human"), 1)
assert_true(Races.wants_bonus_skill("human"))
assert_eq(Races.granted_skills("human"), [])
func test_elf_is_granted_perception_and_nightsight():
assert_eq(Races.granted_skills("elf"), ["perception"])
assert_true(Races.flag("elf", "nightsight"))
assert_false(Races.wants_bonus_skill("elf"))
func test_dwarf_poison_bonus_is_situational_not_a_save_bonus():
assert_eq(Races.poison_save_bonus("dwarf"), 2)
assert_eq(Races.save_bonus("dwarf"), 0, "the +2 is vs poison only — it is NOT a flat save bonus")
func test_beastfolk_has_claws_and_scent():
assert_true(Races.flag("beastfolk", "claws"))
assert_true(Races.flag("beastfolk", "keen_scent"))
func test_seven_callings():
assert_eq(Callings.IDS,
["sellsword", "reaver", "cutpurse", "trapper", "hedge_mage", "bonesetter", "bloodsworn"])
func test_hit_dice():
assert_eq(Callings.hit_die("reaver"), 12)
assert_eq(Callings.hit_die("sellsword"), 10)
assert_eq(Callings.hit_die("hedge_mage"), 6)
func test_casters_have_a_casting_stat_and_martials_do_not():
for id in ["hedge_mage", "bonesetter", "bloodsworn"]:
assert_true(Callings.is_caster(id), "%s is a caster" % id)
assert_true(Callings.casting_stat(id) in Attributes.IDS)
for id in ["sellsword", "reaver", "cutpurse", "trapper"]:
assert_false(Callings.is_caster(id), "%s lives on cooldowns" % id)
assert_eq(Callings.casting_stat(id), "")
func test_cutpurse_picks_four_skills_trapper_three_rest_two():
assert_eq(Callings.skill_count("cutpurse"), 4)
assert_eq(Callings.skill_count("trapper"), 3)
for id in ["sellsword", "reaver", "hedge_mage", "bonesetter", "bloodsworn"]:
assert_eq(Callings.skill_count(id), 2, "%s picks 2" % id)
func test_every_skill_pool_is_real_skills_and_big_enough_to_choose_from():
for id in Callings.IDS:
var pool: Array = Callings.skill_pool(id)
for s in pool:
assert_true(Skills.exists(s), "%s pool has a bogus skill: %s" % [id, s])
assert_true(pool.size() > Callings.skill_count(id),
"%s must have more pool than picks or the choice is fake" % id)
func test_every_calling_saves_are_real_attributes():
for id in Callings.IDS:
assert_eq(Callings.saves(id).size(), 2)
for s in Callings.saves(id):
assert_true(s in Attributes.IDS)

View File

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

View File

@@ -0,0 +1,53 @@
extends "res://addons/gut/test.gd"
## The canon log crosses the HTTP boundary, so its roster exists twice: in code
## (Callings.IDS / Races.IDS) and in the JSON Schema the API validates against.
## Nothing at runtime reconciles them. This does.
const ContentDB = preload("res://scripts/content/content_db.gd")
func _player_schema() -> Dictionary:
var path := ProjectSettings.globalize_path("res://") \
.path_join("../docs/schemas/canon-log.schema.json").simplify_path()
var doc: Variant = ContentDB.load_json(path)
assert_eq(typeof(doc), TYPE_DICTIONARY, "canon-log schema did not parse")
return doc["properties"]["player"]
func test_schema_calling_enum_matches_the_code():
var enum_ids: Array = _player_schema()["properties"]["calling_id"]["enum"]
enum_ids.sort()
var code_ids: Array = Callings.IDS.duplicate()
code_ids.sort()
assert_eq(enum_ids, code_ids)
func test_schema_race_enum_matches_the_code():
var enum_ids: Array = _player_schema()["properties"]["race_id"]["enum"]
enum_ids.sort()
var code_ids: Array = Races.IDS.duplicate()
code_ids.sort()
assert_eq(enum_ids, code_ids)
func test_schema_requires_race_and_calling():
var required: Array = _player_schema()["required"]
assert_true("race_id" in required)
assert_true("calling_id" in required)
assert_false("class_id" in required, "class_id is the rulebook's word — it is gone")
func test_origin_schema_allowed_callings_matches_the_code():
# origin.schema.json's build_constraints.allowed_callings duplicates the
# same seven calling ids inline (a JSON Schema "enum" cannot $ref another
# file's enum), so nothing at runtime reconciles it with Callings.IDS
# either. This guards it the same way _player_schema() guards canon-log.
var path := ProjectSettings.globalize_path("res://") \
.path_join("../docs/schemas/origin.schema.json").simplify_path()
var doc: Variant = ContentDB.load_json(path)
assert_eq(typeof(doc), TYPE_DICTIONARY, "origin schema did not parse")
var enum_ids: Array = doc["properties"]["build_constraints"]["properties"]["allowed_callings"]["items"]["enum"]
enum_ids.sort()
var code_ids: Array = Callings.IDS.duplicate()
code_ids.sort()
assert_eq(enum_ids, code_ids)

View File

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

View File

@@ -1,5 +1,46 @@
extends "res://addons/gut/test.gd"
const NewGame = preload("res://scripts/newgame/new_game.gd")
const ContentDB = preload("res://scripts/content/content_db.gd")
func _built():
var world = ContentDB.new()
world.load_from(ContentDB.default_content_root())
var origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
var creation := {
"name": "Aldric", "race_id": "human", "calling_id": "sellsword",
"seed": 8675309, "spend": {},
"skills": ["athletics", "endurance"], "bonus_skill": "perception",
}
return NewGame.construct(origin, world, creation)
func test_for_character_derives_vitals_and_location_from_the_real_character():
var res = _built()
assert_true(res["ok"], str(res["errors"]))
var state = res["state"]
var log = res["log"]
var s := ShellState.for_character(state, log)
assert_eq(s.vitals["hp"], state.sheet.hp, "hp from the sheet")
assert_eq(s.vitals["hp_max"], state.sheet.max_hp(), "hp_max from the sheet")
assert_eq(s.vitals["mp"], state.sheet.mp, "mp from the sheet")
assert_eq(s.vitals["mp_max"], state.sheet.max_mp(), "mp_max from the sheet")
assert_eq(s.vitals["purse_copper"], state.purse_copper, "purse from origin grants (47c)")
assert_eq(s.location_label, log.location.name, "location from the canon log")
assert_ne(s.vitals["hp_max"], 60, "NOT the seed's hardcoded 60 — this is a real L1 sheet")
func test_for_character_keeps_the_seed_placeholders_for_combat_and_inventory():
# turn_order/consumables/round_label have no real source yet (M5/M7 own them);
# for_character must reuse seed()'s placeholders, not invent its own.
var res = _built()
var s := ShellState.for_character(res["state"], res["log"])
var seed := ShellState.seed()
assert_eq(s.turn_order.size(), seed.turn_order.size(), "turn rail is still the placeholder")
assert_eq(s.consumables, seed.consumables, "consumables are still the placeholder")
assert_eq(s.round_label, seed.round_label, "round label is still the placeholder")
func test_turn_entry_holds_fields():
var e := TurnEntry.new("DW", 12, &"ally")

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

@@ -38,8 +38,7 @@ func test_up_from_first_wraps_to_last():
func test_new_game_and_quit_emit_their_action():
var t := _title()
# Isolate the signal seam so the test does not actually change scene / quit.
t.menu_activated.disconnect(t._on_menu_activated)
# Title is emit-only now: no internal handler to isolate. Just watch the emit.
watch_signals(t)
t._activate(_index_of(t, &"new_game"))
assert_signal_emitted_with_parameters(t, "menu_activated", [&"new_game"])

View File

@@ -19,7 +19,8 @@
],
"start_quest_id": "find_the_ledger",
"build_constraints": {
"allowed_classes": ["sellsword", "assassin", "priest"],
"allowed_callings": ["sellsword", "reaver", "cutpurse", "trapper",
"hedge_mage", "bonesetter", "bloodsworn"],
"luck_modifier": 0
}
}

View File

@@ -0,0 +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.", "fragment": "something answered when you called it, and it has been keeping accounts ever since." }

View File

@@ -0,0 +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.", "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

@@ -0,0 +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.", "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

@@ -0,0 +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.", "fragment": "you taught yourself the rest where nobody would come looking, and the scars are from the parts the book got wrong." }

View File

@@ -0,0 +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.", "fragment": "they send for you when the talking is finished, and they make a point of being somewhere else when you arrive." }

View File

@@ -0,0 +1 @@
{ "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

@@ -0,0 +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.", "fragment": "you learned to sit still in the wet dark until something else made the mistake." }

View File

@@ -0,0 +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.", "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

@@ -0,0 +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.", "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

@@ -0,0 +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.", "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

@@ -0,0 +1 @@
{ "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

@@ -5,7 +5,9 @@ Anything affecting **both** client and api, plus planning and roadmap. Side-spec
Put here:
- [`roadmap.md`](roadmap.md) — phases, POC scope, what's next
- [`traps.md`](traps.md) — **bugs we have already shipped, and the guards that catch them.** Read before writing tests: every one of them passed a green suite.
- [`adr/`](adr/) — Architecture Decision Records
- [`canon-log.md`](canon-log.md) — the cross-boundary contract (§11)
- Shared contracts — canon log schema, tag syntax, retry policy (§11, §12)
- Anything spanning the HTTP boundary

View File

@@ -27,7 +27,7 @@ Full field rules live in `canon-log.schema.json`. Summary:
```json
{
"schema_version": 1,
"player": { "name": "…", "class_id": "sellsword|assassin|priest", "luck_descriptor": "…" },
"player": { "name": "…", "race_id": "human|elf|dwarf|beastfolk", "calling_id": "sellsword|reaver|cutpurse|trapper|hedge_mage|bonesetter|bloodsworn", "luck_descriptor": "…" },
"location": { "id": "…", "name": "…" },
"party": [ { "id": "…", "name": "…", "disposition": -100..100 } ],
"recent_events": ["… (≤5, rolling)"],
@@ -39,9 +39,13 @@ Full field rules live in `canon-log.schema.json`. Summary:
**The §7 boundary (enforced structurally).** The schema sets
`additionalProperties: false` on the root and on `player`, and `player` admits
only `name`, `class_id`, `luck_descriptor`. **Numeric Luck, stats, HP/MP, and
inventory cannot be represented in the canon log** — the AI never sees a number
it could use to calculate Luck. Only `luck_descriptor` (e.g. "Fortune spits on
only `name`, `race_id`, `calling_id`, `luck_descriptor`. `race_id` and
`calling_id` are there so the Narrator and NPCs can *describe* the player (a
"beastfolk cutpurse" reads different scene text than a "dwarf bonesetter") —
they are identity for prose, not mechanics. **Numeric Luck, stats, HP/MP, and
inventory cannot be represented in the canon log** — the mechanical sheet stays
in `GameState` and never reaches the log, and the AI never sees a number it
could use to calculate Luck. Only `luck_descriptor` (e.g. "Fortune spits on
you") crosses the boundary.
All string ids match `^[a-z0-9_]+$`.
@@ -59,7 +63,7 @@ Full rules in `origin.schema.json`. An origin seeds the initial log:
"disposition_overrides": { "<npc_id>": -100..100 },
"inventory_grants": [ { "item_id": "…", "qty": 1.. } ],
"start_quest_id": "…|null",
"build_constraints": { "allowed_classes": ["sellsword",], "luck_modifier": 0 }
"build_constraints": { "allowed_callings": ["sellsword",], "luck_modifier": 0 }
}
```

View File

@@ -36,18 +36,25 @@ M0M2 built the **engine** (the contract, the client, the server loop, bounded
| **M1 — Prove the server loop** ✅ | A real model sits behind `/dm/narrate` and the pipeline is reusable. |
| **M2 — Prove aliveness** ✅ | **Does bounded AI dialogue feel alive?** — answered; bounded NPC dialogue + DM loop live-proven. |
| **M3 — Visual foundation & shell** ✅ | The `Theme` + the exploration HUD; every screen inherits the look and lives in the shell. |
| **M4 — Character creation** *here* | The player enters the world; the proven stat/Luck model gets its UI. |
| **M4 — Character creation** | The player enters the world; the proven stat/Luck model gets its UI. |
| **M5 — Tactical combat (gridless)** | Real stakes: the Combat HUD turn manager. |
| **M6 — Dialogue screen** | The proven NPC free-text loop inside the mock's conversation UI. |
| **M7 — Inventory & character sheet** | State the player can see and manage. |
| **M8 — World systems** | World-map travel, a shop economy, the quest log. |
| **M9 — Framing, AI-flavor & persistence** | Title/pause, save/load, and the Improviser/Banter/Luck-drift roles + fallback sweep. |
### Landed out of band
**▶ Next (before M5):** *Provider flexibility — Ollama Cloud + a backend switch* (in **Landed / planned out of band**, below). A laptop-dev enabler: it lets the proxy serve the same Ollama-hosted models over an API key from any machine, so development continues away from the homelab. Not a game-screen milestone — a cross-cutting infra item taken now because the alternative is a laptop with no AI.
Not every item is a milestone. These shipped on their own because a later milestone
would have been more expensive to build on top of the old shape.
### Landed / planned out of band
Not every item is a milestone. Some shipped on their own because a later milestone
would have been more expensive to build on top of the old shape; some are planned
cross-cutting work that does not belong to a single screen.
-**Provider flexibility — Ollama Cloud + a backend switch** *(next; not yet specced — a future session builds it).* Development spans two machines: a desktop with the homelab Ollama, and a laptop with no homelab reach. Today the proxy's model-call pipeline (M1) only knows a **local** Ollama, so the laptop has no AI at all. This adds **Ollama Cloud** as a backend so the same Ollama-hosted models are reachable over an **API key** from anywhere, and a **switch** to flip between `local-ollama` (desktop, free/offline) and `ollama-cloud` (laptop) — and the door stays open for prod's Replicate (§4) as a third backend.
- **§4 is load-bearing — the key and the provider choice live server-side, in the proxy.** The client never holds an API key and never learns which backend serves a role (§4). Concretely: extend the proxy's Ollama httpx client (M1) to target an **Ollama-compatible cloud endpoint** with an **`Authorization: Bearer <key>`** header, the key read from the proxy's environment / `.env`, never a client binary. The "flip a switch" is a **proxy config/env** value selecting the backend (endpoint + key + per-role model ids) — this is the M1 role→model routing config growing a *backend* axis, a deploy/config change, not a client patch, exactly as §4 intends.
- **The client's Settings-button angle (reconcile, don't silently adopt):** the "config file surfaced by the title **Settings** button" is a real feature, but under §4 it splits in two. **Legit client setting:** the **proxy base URL / profile** (§4 lets the client know *one base URL*) — a persisted client config choosing "laptop proxy" vs "desktop proxy," editable via the M9 Settings screen. **NOT a client setting:** the AI **provider and API key** — those stay in the proxy; the Settings screen must never hold the key or pick the backend, or the §4 boundary leaks into a shippable binary. So the honest "flip a switch" is *two* switches — a proxy-side provider/key config (this item, the immediate laptop-dev enabler) and a client-side base-URL/profile that **folds into M9's settings + persistence**. For laptop work right now, only the proxy switch is needed.
- *§2: text (the provider serves text; the client stays provider-agnostic) · depends on the M1 pipeline · goal: AI on any machine, switchable — and a proxy that is already multi-backend before Replicate (§4) arrives.*
-**Currency — copper with real denominations** ([spec](superpowers/specs/2026-07-12-currency-copper-design.md)). `1g = 100s = 10,000c`, **stored entirely in copper** as one `GameState.purse_copper` int, formatted only at the display edge (`◈ 3s 47c`). `coin.json``copper`/`silver`/`gold`. Money is **not** an inventory item: `give_item`/`accept_item` carry no quantity and `gifts_given` is a global one-shot, so a lone `copper` id would have been givable once, for one copper, per campaign — the denominations are **move tokens** instead (`give_item(gold)` = +10,000c), routed to the purse and never into `inventory`, so money cannot surface in an inventory grid by construction. Taken before M7 while `content/world/items/` held two files. **Prices remain M8's content BOM — nothing is priced yet.** *§2: state · goal: money that means something.*
-**A move applies at most once per reply** — found by the review of the above. `TagExtractor` returns *every* tag occurrence and `MoveValidator` is a membership test, so a model repeating a tag N times picked an amount **in unary** (§2 breach: the AI choosing a number). Now deduped by (name, id) across a reply, and `adjust_disposition`'s `MAX_DELTA` caps the reply's **net** swing rather than each tag — it was per-tag, so three `+15`s moved standing by 45, the exact "wholesale swing" the constant forbids. *§2: state · goal: the bounded vocabulary stays bounded.*
@@ -71,11 +78,14 @@ 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.***
- **Character creation UI** — 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). **Two contract migrations land here, not silently:** the canon log gains a `race` field (the Narrator needs it to describe the player), and `content/origins/deserter.json` still carries the dead `allowed_classes: ["sellsword","assassin","priest"]`. **⛨ 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). Emit `race_id` + `calling_id` into it. *§2: state · depends on the creation model (M0) + Theme · goal: build a character.*
- **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 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)** — the *new game* path is wired end-to-end ([spec](superpowers/specs/2026-07-15-m4c-title-creation-shell-flow-design.md)). A script-first **`Main`** coordinator node (`scenes/Main.tscn`, now the `main_scene`) owns the Title → Creation → Shell sequence in one file, swapping screens via **instantiate → inject → add_child** — the injection seam `change_scene_to_file` cannot provide, and the reason it, not a linear chain, holds the flow. The **Title became emit-only** (it announces `menu_activated`; the coordinator routes it — it no longer knows what comes next), and the **shell stopped faking a character**: new `injected_log`/`injected_state` seams + a `ShellState.for_character(state, log)` factory mean the created character's **HP/MP/purse, location, and opening narration are real** (`seed()`/`_build_seed_log()` stay as the F6/standalone fallback, exactly like `service == null`). The flow carries only a plain `creation` Dictionary; `NewGame.construct` re-rolls the character from its seed itself (§2). **⛨ saga honored structurally:** `_show_creation()` and `_start_campaign(creation)` are separate methods, so the fourth entry path ("begin the next campaign of an existing saga") is an *added `menu_activated` arm* that synthesizes a Dictionary and calls `_start_campaign` directly — no existing edge changes. **§13:** a `construct` that returns `{ok:false}` (unreachable via the CTA gate, but the coordinator distrusts its caller anyway) logs and stays on creation — never a crash, never a half-built shell. Built subagent-per-task with a per-task + final whole-branch review; every new guard reverted-and-reconfirmed-red per `docs/traps.md`. **Test count: 302 → 327.** The coordinator was named `Main` (not `GameFlow`) so the boot scene is self-evident; `TitleScreen` is unchanged and shown *by* `Main`. *§2: n/a (flow) · depends on M4-b + the Title screen (M3) · goal: a playable start-to-shell loop.*
### M5 — Tactical combat (gridless)
*Content it consumes → [BOM](../content/roadmap.md#2-bill-of-materials): abilities for the 3 POC classes (fixed); 1 enemy family (4 units) + boss, **tier 1** (variable per tier). **~15 pieces.***

View File

@@ -13,10 +13,14 @@
"player": {
"type": "object",
"additionalProperties": false,
"required": ["name", "class_id", "luck_descriptor"],
"required": ["name", "race_id", "calling_id", "luck_descriptor"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"class_id": { "enum": ["sellsword", "assassin", "priest"] },
"race_id": { "enum": ["human", "elf", "dwarf", "beastfolk"] },
"calling_id": {
"enum": ["sellsword", "reaver", "cutpurse", "trapper",
"hedge_mage", "bonesetter", "bloodsworn"]
},
"luck_descriptor": { "type": "string", "minLength": 1 }
}
},

View File

@@ -39,12 +39,15 @@
"build_constraints": {
"type": "object",
"additionalProperties": false,
"required": ["allowed_classes", "luck_modifier"],
"required": ["allowed_callings", "luck_modifier"],
"properties": {
"allowed_classes": {
"allowed_callings": {
"type": "array",
"minItems": 1,
"items": { "enum": ["sellsword", "assassin", "priest"] }
"items": {
"enum": ["sellsword", "reaver", "cutpurse", "trapper",
"hedge_mage", "bonesetter", "bloodsworn"]
}
},
"luck_modifier": { "type": "integer" }
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,615 @@
# M4-c — Title → Creation → Shell Flow Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Wire the title screen's *new game* path through character creation and into the exploration shell, with the created character flowing all the way through — HP/MP/purse/location/opening-narration all real.
**Architecture:** A new script-first `GameFlow` root node becomes the `main_scene` and owns the Title → Creation → Shell sequence in one file, swapping screens via `instantiate()` → inject → `add_child()` (the injection seam that `change_scene_to_file` cannot provide). The title becomes emit-only; the shell gains `injected_log`/`injected_state` seams and stops self-seeding on the real path (its `seed()`/`_build_seed_log()` stay as the F6/standalone fallback).
**Tech Stack:** Godot 4.7, GDScript, GUT 9.7 for tests.
**Spec:** [`docs/superpowers/specs/2026-07-15-m4c-title-creation-shell-flow-design.md`](../specs/2026-07-15-m4c-title-creation-shell-flow-design.md)
## Global Constraints
- **§2 (code owns state):** the flow carries only plain data; `NewGame.construct` re-rolls the character from the creation seed itself. No task hands a computed stat between screens.
- **⛨ saga:** do NOT weld `new game → creation → shell` into one unit. `_show_creation()` and `_start_campaign(creation)` stay separate methods so a fourth entry path is an added `menu_activated` arm, not surgery on existing edges.
- **§13:** a failure never reaches the player as an error. A `construct` that returns `{ok:false}` logs and aborts the transition; it never crashes or shows a half-built shell.
- **No autoloads / no new global state.** Use the existing injection-seam pattern (set a property between `instantiate()` and `add_child()`).
- **ADR 0001 (editor-first UI):** applies to *Control layout* scenes. `GameFlow` is a pure coordinator with no layout, so it is script-first by design — this is not a violation.
- **traps.md discipline:** every new guard must be reverted-and-watched-red before acceptance. Drive screens by emitting their real signals, never by calling handlers directly.
- **Test runner:** whole suite `./run_tests.sh` (from `client/`, ~4s). Single file: `./run_tests.sh -gselect=test_NAME.gd`. Single method: add `-gunit_test_name=test_method`.
- All paths below are relative to `client/` unless noted.
---
## File Structure
**New:**
- `scripts/ui/flow/game_flow.gd` — the coordinator: owns `world`/`service`/`origin`, holds the Title→Creation→Shell sequence, one `_show` swap primitive.
- `scenes/flow/GameFlow.tscn` — one `Node` with `game_flow.gd` attached. The new `main_scene`.
- `tests/unit/test_game_flow.gd` — flow tests (new game reaches shell as the created character; construct-failure stays put).
**Modified:**
- `scripts/ui/shell/shell_state.gd` — add `for_character(state, log)` factory.
- `scripts/ui/shell/main_window_shell.gd` — add `injected_log`/`injected_state` seams; seed calls become fallbacks.
- `scripts/ui/title/title_screen.gd` — emit-only (drop `const SHELL`, `_on_menu_activated`, the self-connect).
- `project.godot``run/main_scene``GameFlow.tscn`.
- `tests/unit/test_shell_state.gd``for_character` test.
- `tests/unit/test_main_window_shell.gd` — injected-path test.
- `tests/unit/test_title_screen.gd` — drop the `_on_menu_activated` disconnect (it no longer exists).
---
## Task 1: `ShellState.for_character` factory
Derive real HUD vitals + location from a constructed character, keeping the turn-order/consumables/round placeholders honest (combat=M5, inventory=M7 own those).
**Files:**
- Modify: `scripts/ui/shell/shell_state.gd`
- Test: `tests/unit/test_shell_state.gd`
**Interfaces:**
- Consumes: `GameState` (`.sheet.hp`, `.sheet.max_hp()`, `.sheet.mp`, `.sheet.max_mp()`, `.purse_copper`), `CanonLog` (`.location.name`), `NewGame.construct` (test only).
- Produces: `static func ShellState.for_character(state: GameState, log: CanonLog) -> ShellState`.
- [ ] **Step 1: Write the failing test**
Add to `tests/unit/test_shell_state.gd`:
```gdscript
const NewGame = preload("res://scripts/newgame/new_game.gd")
const ContentDB = preload("res://scripts/content/content_db.gd")
func _built():
var world = ContentDB.new()
world.load_from(ContentDB.default_content_root())
var origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
var creation := {
"name": "Aldric", "race_id": "human", "calling_id": "sellsword",
"seed": 8675309, "spend": {},
"skills": ["athletics", "endurance"], "bonus_skill": "perception",
}
return NewGame.construct(origin, world, creation)
func test_for_character_derives_vitals_and_location_from_the_real_character():
var res = _built()
assert_true(res["ok"], str(res["errors"]))
var state = res["state"]
var log = res["log"]
var s := ShellState.for_character(state, log)
assert_eq(s.vitals["hp"], state.sheet.hp, "hp from the sheet")
assert_eq(s.vitals["hp_max"], state.sheet.max_hp(), "hp_max from the sheet")
assert_eq(s.vitals["mp"], state.sheet.mp, "mp from the sheet")
assert_eq(s.vitals["mp_max"], state.sheet.max_mp(), "mp_max from the sheet")
assert_eq(s.vitals["purse_copper"], state.purse_copper, "purse from origin grants (47c)")
assert_eq(s.location_label, log.location.name, "location from the canon log")
assert_ne(s.vitals["hp_max"], 60, "NOT the seed's hardcoded 60 — this is a real L1 sheet")
func test_for_character_keeps_the_seed_placeholders_for_combat_and_inventory():
# turn_order/consumables/round_label have no real source yet (M5/M7 own them);
# for_character must reuse seed()'s placeholders, not invent its own.
var res = _built()
var s := ShellState.for_character(res["state"], res["log"])
var seed := ShellState.seed()
assert_eq(s.turn_order.size(), seed.turn_order.size(), "turn rail is still the placeholder")
assert_eq(s.consumables, seed.consumables, "consumables are still the placeholder")
assert_eq(s.round_label, seed.round_label, "round label is still the placeholder")
```
- [ ] **Step 2: Run test to verify it fails**
Run: `./run_tests.sh -gselect=test_shell_state.gd`
Expected: FAIL — `Invalid call. Nonexistent function 'for_character' in base 'RefCounted (ShellState)'`.
- [ ] **Step 3: Write minimal implementation**
Add to `scripts/ui/shell/shell_state.gd` (after `seed()`):
```gdscript
static func for_character(state: GameState, log: CanonLog) -> ShellState:
## The real HUD state for a constructed character. Vitals + location come from
## the character (§2 — code owns them); turn_order/consumables/round_label are
## still seed() placeholders because combat (M5) and inventory (M7) own those.
var s := seed() # start from the placeholders, then overwrite what is real
s.vitals = {
"hp": state.sheet.hp,
"hp_max": state.sheet.max_hp(),
"mp": state.sheet.mp,
"mp_max": state.sheet.max_mp(),
"purse_copper": state.purse_copper,
}
s.location_label = log.location.name
return s
```
- [ ] **Step 4: Run test to verify it passes**
Run: `./run_tests.sh -gselect=test_shell_state.gd`
Expected: PASS (existing seed tests + 2 new tests).
- [ ] **Step 5: Re-break to prove the guard (traps.md)**
Temporarily change `"hp_max": state.sheet.max_hp()` to `"hp_max": 60`. Run the test.
Expected: FAIL on `test_for_character_derives_vitals_and_location_from_the_real_character` (both the `==` and the `assert_ne 60`). Revert the change; re-run; PASS.
- [ ] **Step 6: Commit**
```bash
git add client/scripts/ui/shell/shell_state.gd client/tests/unit/test_shell_state.gd
git commit -m "feat(m4c): ShellState.for_character derives vitals+location from the real character"
```
---
## Task 2: Shell consumes an injected character
Add the `injected_log`/`injected_state` seams; make the seed calls fallbacks. The seed path stays fully working for F6/standalone.
**Files:**
- Modify: `scripts/ui/shell/main_window_shell.gd:21-23` (seams) and `:43-44` (fallback wiring)
- Test: `tests/unit/test_main_window_shell.gd`
**Interfaces:**
- Consumes: `ShellState.for_character(state, log)` (Task 1).
- Produces: shell public seams `injected_log: CanonLog`, `injected_state: GameState`, set before `add_child` (mirrors the existing `service` seam).
- [ ] **Step 1: Write the failing test**
Add to `tests/unit/test_main_window_shell.gd`:
```gdscript
const NewGame = preload("res://scripts/newgame/new_game.gd")
const ContentDB = preload("res://scripts/content/content_db.gd")
func _built_character():
var world = ContentDB.new()
world.load_from(ContentDB.default_content_root())
var origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
var creation := {
"name": "Aldric", "race_id": "human", "calling_id": "sellsword",
"seed": 8675309, "spend": {},
"skills": ["athletics", "endurance"], "bonus_skill": "perception",
}
return NewGame.construct(origin, world, creation)
func _shell_with_character(res) -> MainWindowShell:
var node = load(SCENE).instantiate()
var transport = FakeTransport.new()
transport.set_response(DmResponse.ok(200, {"prose": "Rain on the cobbles."}))
node.service = DmService.new(transport, FallbackLibrary.new())
node.injected_log = res["log"]
node.injected_state = res["state"]
add_child_autofree(node)
return node
func test_shell_shows_the_injected_character_not_the_seed():
var res = _built_character()
assert_true(res["ok"], str(res["errors"]))
var node := _shell_with_character(res)
assert_same(node._log, res["log"], "the injected canon log drives narration")
var expected := "HP %d / %d" % [res["state"].sheet.hp, res["state"].sheet.max_hp()]
assert_eq(node._hp.text, expected, "HP label derives from the real sheet")
assert_ne(node._hp.text, "HP 42 / 60", "NOT the seed's Vexcca vitals")
```
- [ ] **Step 2: Run test to verify it fails**
Run: `./run_tests.sh -gselect=test_main_window_shell.gd`
Expected: FAIL — `Invalid set index 'injected_log'` (the property does not exist yet).
- [ ] **Step 3: Write minimal implementation**
In `scripts/ui/shell/main_window_shell.gd`, add the seams next to `service` (currently line 21):
```gdscript
var service: DmService = null # injection seam; real HttpTransport built if null
var injected_log: CanonLog = null # injection seam; _build_seed_log() if null (F6/standalone)
var injected_state: GameState = null # injection seam; ShellState.seed() if null
```
Then in `_ready()` replace the two seed lines (currently lines 43-44):
```gdscript
_state = ShellState.seed()
_log = _build_seed_log()
```
with:
```gdscript
# The injected character wins; the seed calls are the F6/standalone fallback —
# same shape as `service == null` building a real transport below.
_state = ShellState.for_character(injected_state, injected_log) if injected_state != null else ShellState.seed()
_log = injected_log if injected_log != null else _build_seed_log()
```
- [ ] **Step 4: Run test to verify it passes**
Run: `./run_tests.sh -gselect=test_main_window_shell.gd`
Expected: PASS (existing seed/fallback tests still green — they inject only `service`, so both seams stay null and the seed path runs — plus the new injected test).
- [ ] **Step 5: Re-break to prove the guard (traps.md)**
Temporarily force the seed path: change the `_state`/`_log` lines to always use `ShellState.seed()` / `_build_seed_log()`. Run the test.
Expected: FAIL on `test_shell_shows_the_injected_character_not_the_seed` (`_log` mismatch + `HP 42 / 60`). Revert; re-run; PASS.
- [ ] **Step 6: Commit**
```bash
git add client/scripts/ui/shell/main_window_shell.gd client/tests/unit/test_main_window_shell.gd
git commit -m "feat(m4c): shell consumes an injected character (seed stays the F6 fallback)"
```
---
## Task 3: Title becomes emit-only
Strip the title's knowledge of what comes next. It already emits `menu_activated(id)`; the coordinator will route it.
**Files:**
- Modify: `scripts/ui/title/title_screen.gd` (remove `const SHELL`, `_on_menu_activated`, the self-connect)
- Test: `tests/unit/test_title_screen.gd:39-47`
**Interfaces:**
- Consumes: nothing new.
- Produces: `TitleScreen` emits `menu_activated(id: StringName)` and does not self-route (no scene changes, no quit from within the screen).
- [ ] **Step 1: Update the test first (it currently disconnects a method we are deleting)**
In `tests/unit/test_title_screen.gd`, replace `test_new_game_and_quit_emit_their_action` (lines 39-47) with:
```gdscript
func test_new_game_and_quit_emit_their_action():
var t := _title()
# Title is emit-only now: no internal handler to isolate. Just watch the emit.
watch_signals(t)
t._activate(_index_of(t, &"new_game"))
assert_signal_emitted_with_parameters(t, "menu_activated", [&"new_game"])
t._activate(_index_of(t, &"quit"))
assert_signal_emitted_with_parameters(t, "menu_activated", [&"quit"])
```
- [ ] **Step 2: Run test to verify it fails**
Run: `./run_tests.sh -gselect=test_title_screen.gd`
Expected: PASS still (the removed `disconnect` line was the only thing that would break — but `_on_menu_activated` still exists right now, so this test passes both before and after the edit). To get a real red first, temporarily rename `_activate`'s emit target — SKIP; instead this task's red/green is driven by Step 4 after the source change. Proceed to Step 3.
- [ ] **Step 3: Make the title emit-only**
In `scripts/ui/title/title_screen.gd`:
1. Delete `const SHELL := "res://scenes/shell/MainWindowShell.tscn"` (line 12).
2. In `_ready()`, delete the line `menu_activated.connect(_on_menu_activated)` (line 47).
3. Delete the entire `_on_menu_activated` function (lines 131-138):
```gdscript
func _on_menu_activated(id: StringName) -> void:
match id:
&"new_game":
get_tree().change_scene_to_file(SHELL)
&"quit":
get_tree().quit()
_:
pass # inert placeholder — the screen lands in a later milestone
```
- [ ] **Step 4: Run the full suite**
Run: `./run_tests.sh -gselect=test_title_screen.gd`
Expected: PASS. `test_new_game_and_quit_emit_their_action` still asserts the emit; nothing self-routes.
- [ ] **Step 5: Verify the title no longer references downstream scenes**
Run: `grep -n "SHELL\|change_scene\|_on_menu_activated" client/scripts/ui/title/title_screen.gd`
Expected: no matches. (This is the proof the screen is emit-only; it cannot be a runtime assert.)
- [ ] **Step 6: Commit**
```bash
git add client/scripts/ui/title/title_screen.gd client/tests/unit/test_title_screen.gd
git commit -m "feat(m4c): title screen is emit-only (routing moves to GameFlow)"
```
---
## Task 4: `GameFlow` coordinator
The single home for the Title → Creation → Shell sequence. Injection seams on `world`/`service`/`origin` keep it headless-testable.
**Files:**
- Create: `scripts/ui/flow/game_flow.gd`
- Create: `scenes/flow/GameFlow.tscn`
- Test: `tests/unit/test_game_flow.gd`
**Interfaces:**
- Consumes: `TitleScreen.menu_activated(id)`, `CharacterCreation` seams (`world`, `origin`) + signal `creation_confirmed(creation)`, `NewGame.construct(origin, world, creation) -> {ok, errors, log, state}`, `MainWindowShell` seams (`service`, `injected_log`, `injected_state`).
- Produces: `GameFlow` node with public seams `world: ContentDB`, `service: DmService`, `origin: Dictionary`, and `_current: Node` (the active screen, read by tests).
- [ ] **Step 1: Write the failing test**
Create `tests/unit/test_game_flow.gd`:
```gdscript
extends "res://addons/gut/test.gd"
const SCENE := "res://scenes/flow/GameFlow.tscn"
const ContentDB = preload("res://scripts/content/content_db.gd")
const FakeTransport = preload("res://tests/doubles/fake_transport.gd")
const DmResponse = preload("res://scripts/net/dm_response.gd")
func _flow() -> Node:
# Inject fakes BEFORE _ready (add_child): a real world + a network-free service,
# so the shell's initial narrate never touches HTTP.
var node = load(SCENE).instantiate()
var world = ContentDB.new()
world.load_from(ContentDB.default_content_root())
node.world = world
node.origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
var transport = FakeTransport.new()
transport.set_response(DmResponse.ok(200, {"prose": "Rain on the cobbles."}))
node.service = DmService.new(transport, FallbackLibrary.new())
add_child_autofree(node)
return node
func _creation() -> Dictionary:
return {
"name": "Dagnet", "race_id": "human", "calling_id": "sellsword",
"seed": 8675309, "spend": {},
"skills": ["athletics", "endurance"], "bonus_skill": "perception",
}
func test_boots_into_the_title():
var flow := _flow()
assert_true(flow._current is TitleScreen, "GameFlow opens on the title")
func test_new_game_shows_creation():
var flow := _flow()
flow._current.emit_signal("menu_activated", &"new_game")
assert_true(flow._current is CharacterCreation, "new game routes to creation")
func test_confirming_creation_reaches_the_shell_as_the_created_character():
var flow := _flow()
flow._current.emit_signal("menu_activated", &"new_game")
flow._current.emit_signal("creation_confirmed", _creation())
assert_true(flow._current is MainWindowShell, "creation routes to the shell")
assert_eq(flow._current._log.player.name, "Dagnet",
"the CREATED character flowed through not a seed, not a swap alone")
func _assert_stays_on_creation(bad: Dictionary, why: String) -> void:
var flow := _flow()
flow._current.emit_signal("menu_activated", &"new_game")
# Drive the REAL signal with an invalid dict. The CTA gates this out via the UI,
# but §2 forbids GameFlow trusting the caller and §13 forbids surfacing an error.
flow._current.emit_signal("creation_confirmed", bad)
assert_false(flow._current is MainWindowShell, "a failed construct never reaches the shell (§13): %s" % why)
assert_true(flow._current is CharacterCreation, "the player stays on the valid creation screen: %s" % why)
func test_construct_failure_empty_name_stays_on_creation():
var bad := _creation()
bad["name"] = "" # empty name fails NewGame.validate
_assert_stays_on_creation(bad, "empty name")
func test_construct_failure_bad_calling_stays_on_creation():
var bad := _creation()
bad["calling_id"] = "bard" # NPC-only / not a real calling — fails NewGame.validate
_assert_stays_on_creation(bad, "unknown calling")
```
- [ ] **Step 2: Run test to verify it fails**
Run: `./run_tests.sh -gselect=test_game_flow.gd`
Expected: FAIL — the scene `res://scenes/flow/GameFlow.tscn` does not exist yet (load returns null).
- [ ] **Step 3: Write the coordinator script**
Create `scripts/ui/flow/game_flow.gd`:
```gdscript
class_name GameFlow
extends Node
## The single home for the game's screen sequence: Title → Creation → Shell.
## Script-first by design — a pure coordinator with no Control layout, so ADR 0001's
## editor-first rule does not apply (that rule governs layout scenes). It owns one
## ContentDB + one DmService and swaps screens via instantiate → inject → add_child,
## the injection seam change_scene_to_file cannot provide.
##
## §2: the flow carries only plain data; NewGame.construct re-rolls the character
## from the creation seed itself. ⛨ saga: _show_creation and _start_campaign are
## separate, so a fourth entry path is an added arm, not surgery on existing edges.
const HttpTransport = preload("res://scripts/net/http_transport.gd")
const TITLE := preload("res://scenes/title/TitleScreen.tscn")
const CREATION := preload("res://scenes/creation/CharacterCreation.tscn")
const SHELL := preload("res://scenes/shell/MainWindowShell.tscn")
# Injection seams — set BETWEEN instantiate() and add_child(), as the shell/creation
# do for their own deps. _ready builds the real ones when nothing was supplied.
var world: ContentDB = null
var service: DmService = null
var origin: Dictionary = {}
var _current: Node = null
var _http: HTTPRequest
func _ready() -> void:
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"))
if service == null:
_http = HTTPRequest.new()
add_child(_http)
service = DmService.new(HttpTransport.new(_http), FallbackLibrary.new())
_show_title()
func _show(next: PackedScene, inject: Callable) -> void:
## The one swap primitive. inject runs after instantiate() (which does NOT run
## _ready) and before add_child (which does) — the injection window. The outgoing
## screen is queue_free'd (deferred): a signal handler must never free the node
## still executing up its own call stack.
var old := _current
var screen := next.instantiate()
inject.call(screen)
_current = screen
add_child(screen)
if old != null:
old.queue_free()
func _show_title() -> void:
_show(TITLE, func(s): s.menu_activated.connect(_on_menu))
func _show_creation() -> void:
_show(CREATION, func(s):
s.world = world
s.origin = origin
s.creation_confirmed.connect(_start_campaign))
func _show_shell(log: CanonLog, state: GameState) -> void:
_show(SHELL, func(s):
s.service = service
s.injected_log = log
s.injected_state = state)
func _on_menu(id: StringName) -> void:
match id:
&"new_game":
_show_creation()
&"quit":
get_tree().quit()
_:
pass # continue / load land in later milestones (M9); inert for now
func _start_campaign(creation: Dictionary) -> void:
## Takes a plain Dictionary — does NOT assume it came from the creation screen, so
## a saga can synthesize one and call this directly (skipping creation).
var result := NewGame.construct(origin, world, creation)
if not result.get("ok", false):
# Unreachable via the UI (the CTA gates on validate), but §2 forbids trusting
# the caller and §13 forbids surfacing an error. Log and stay put.
push_error("GameFlow: construct rejected a creation: %s" % str(result.get("errors", [])))
return
_show_shell(result["log"], result["state"])
```
- [ ] **Step 4: Create the scene**
Create `scenes/flow/GameFlow.tscn` — a single `Node` with the script attached. Author it in the editor (New Scene → Node → attach `res://scripts/ui/flow/game_flow.gd` → save as `res://scenes/flow/GameFlow.tscn`), or write the file directly:
```
[gd_scene load_steps=2 format=3 uid="uid://coc_gameflow"]
[ext_resource type="Script" path="res://scripts/ui/flow/game_flow.gd" id="1"]
[node name="GameFlow" type="Node"]
script = ExtResource("1")
```
- [ ] **Step 5: Run test to verify it passes**
Run: `./run_tests.sh -gselect=test_game_flow.gd`
Expected: PASS — boots into title; new_game → creation; creation_confirmed → shell with `player.name == "Dagnet"`; both construct-failure parameterizations stay on creation.
- [ ] **Step 6: Re-break to prove the guards (traps.md)**
1. In `_start_campaign`, temporarily delete the `if not result.get("ok"...)` early return. Run `test_game_flow.gd``test_construct_failure_empty_name_stays_on_creation` and `test_construct_failure_bad_calling_stays_on_creation` must FAIL (a rejected construct reaches the shell). Revert.
2. In `test_confirming_creation...`, temporarily change the created name to `"Dagnet"` → assert against `"Aldric"`. Run → FAIL (proves the name assertion reads the real flowed character, not a constant). Revert.
- [ ] **Step 7: Commit**
```bash
git add client/scripts/ui/flow/game_flow.gd client/scenes/flow/GameFlow.tscn client/tests/unit/test_game_flow.gd
git commit -m "feat(m4c): GameFlow coordinator wires title -> creation -> shell"
```
---
## Task 5: Make `GameFlow` the entry point + smoke the whole loop
Flip `main_scene` and confirm the real, non-injected boot path start-to-shell (ADR 0001: the final visual gate is a person).
**Files:**
- Modify: `project.godot` (`run/main_scene`)
**Interfaces:**
- Consumes: everything above.
- Produces: the shipped boot path — launching the client opens the title; *new game* leads through creation into a shell showing the created character.
- [ ] **Step 1: Flip the main scene**
In `project.godot`, change:
```
run/main_scene="res://scenes/title/TitleScreen.tscn"
```
to:
```
run/main_scene="res://scenes/flow/GameFlow.tscn"
```
- [ ] **Step 2: Run the full suite**
Run: `./run_tests.sh`
Expected: PASS. All prior tests + the new Task 1/2/4 tests. Note the new total (was 319; +~7 new tests).
- [ ] **Step 3: Manual smoke (human/visual gate — ADR 0001)**
Launch the client (F5 in the editor, or `godot --path client`). Verify the *real* path with no injected fakes:
1. The **title** appears (embers, gold rule).
2. Select **New Game** → the **character creation** screen appears (four race cards, seven calling cards, five ability cards, no "luck").
3. Build a legal character, give a name, press **Enter the World**.
4. The **shell** appears; the DM narration book opens on *this* character's scene (grounded in the deserter origin — Greywater, the desertion), and the command bar HP/MP/purse reflect the built character (not Vexcca's 42/60, ◈3s 47c).
Record the result. If narration shows the degraded fallback (API down), that is acceptable — the character/vitals must still be the created one.
- [ ] **Step 4: Commit**
```bash
git add client/project.godot
git commit -m "feat(m4c): GameFlow is the main scene — title->creation->shell is the boot path"
```
---
## Self-Review
**Spec coverage:**
- §1 problem (title skips creation; shell fakes) → Tasks 3, 2, 5. ✓
- §3.1 GameFlow (owns world/service/origin, `_show`, sequence table, saga seam) → Task 4. ✓
- §3.2 title emit-only → Task 3. ✓
- §3.3 creation unchanged → no task (correct — it already emits). ✓
- §3.4 shell seams + `ShellState.for_character` → Tasks 1, 2. ✓
- §4 data flow → exercised by Task 4's shell-reached-as-created-character test. ✓
- §5 error handling → Task 4's `test_construct_failure_{empty_name,bad_calling}_stays_on_creation` + re-break. ✓
- §6 testing (real signals, name-proves-flow, injected vitals, for_character unit, construct-failure) → Tasks 1,2,4. ✓
- §3.1 main_scene flip → Task 5. ✓
- ⛨ saga seam (separate `_show_creation`/`_start_campaign`) → Task 4 script. ✓
**Placeholder scan:** none — every step has concrete code/commands.
**Type consistency:** `for_character(state, log)` defined Task 1, consumed Task 2 (via shell `_ready`) and Task 4 (via shell). `injected_log`/`injected_state` defined Task 2, set in Task 4's `_show_shell`. `_current`, `_start_campaign(creation)`, `_show_creation()` consistent across Task 4 script + test. `NewGame.construct` return keys (`ok`/`errors`/`log`/`state`) match the real signature. ✓

View File

@@ -0,0 +1,361 @@
# M4-a — the character creation model
**Date:** 2026-07-12
**Status:** approved, ready for planning
**Charter:** §2 (code owns state), §7 (Luck), §8 (stats), §10 (seeding), §11 (canon log), §18 (git)
**Implements:** the M4 half of [races & classes](2026-07-11-races-and-classes-design.md) — its §5 *Character-creation integration*, and the two contract migrations it flagged.
**Roadmap:** M4, split into **M4-a (this: the model, no UI) → M4-b (the screen) → M4-c (title→creation→shell flow)**.
---
## 1. What this is
The rules a character is made of, in code, with no screen attached.
Races, callings, and skills become **static tables**. The character sheet becomes a
**typed model that stores what was rolled or chosen and derives everything else**.
`NewGame.construct` grows from "roll five stats" into the real creation pipeline — and
becomes **seeded and reproducible**.
It also lands the two contract migrations the races/classes spec said to do **at M4, not
silently**: the canon log gains `race_id`, and `class_id` becomes `calling_id`.
**No UI.** The creation screen is M4-b; it consumes what this builds. Everything here is a
pure function or a dumb store, and the whole thing is testable headlessly.
---
## 2. What is actually there today (verified at `198ea27`)
- `NewGame.construct(origin, world, creation, rng)` exists and is tested. It validates the
origin's refs, checks `class_id` against `build_constraints.allowed_classes`, rolls Luck,
rolls five stats with a placeholder `_roll_stat` (plain 3d6, marked `TUNABLE`), and
assembles the `CanonLog` + `GameState`.
- **`GameState.stats`** is a flat `Dictionary` of five ints. There is no sheet, no HP, no
AC, no skills, no race.
- **`class_id` is hardcoded as `["sellsword", "assassin", "priest"]` in three places** —
all three name callings that no longer exist:
- `docs/schemas/canon-log.schema.json` (an `enum`, `additionalProperties: false`)
- `client/scripts/canon_log/log_player.gd` (`LogPlayer.CLASSES`)
- `content/origins/deserter.json` (`build_constraints.allowed_classes`)
- `docs/schemas/origin.schema.json` enumerates the same dead three under
`build_constraints.allowed_classes` (`additionalProperties: false`, `minItems: 1`).
- **The server renders the class into the prompt.** `api/app/prompts.py:53` emits
`Player: {name}, a {class_id}` and `:107` emits `The player is {name}, a {class_id}.`
This is *why* `race_id` must reach the log — so the AI can say "a beastfolk cutpurse".
- `content/world/items/` is **hand-written** JSON loaded by `ContentDB._load_dir`, not
emitted by `content_build`. `callings/` and `races/` will work the same way.
---
## 3. Where the rules live
**Mechanics in code; blurbs in content.** The same split the currency work settled: a rule
is a rule and belongs in code, and one fact gets one source of truth.
### Code — three static tables
New directory `client/scripts/rules/`. These are *rules*, not state; `Luck` and `Currency`
sit in `state/` because they are read *by* state, but a table of hit dice is neither.
- **`Skills`** (`rules/skills.gd`) — the 9 skills and the attribute each is governed by.
- **`Races`** (`rules/races.gd`) — the 4 races as feature flags.
- **`Callings`** (`rules/callings.gd`) — the 7 callings.
All static, all pure, shaped exactly like `Luck` / `Currency` (`class_name`, `extends
RefCounted`, consts at the top, no autoload).
### Content — the words
Hand-written JSON, loaded by `ContentDB` exactly like `items/`:
```
content/world/callings/reaver.json { "id": "reaver", "name": "Reaver", "blurb": "..." }
content/world/races/beastfolk.json { "id": "beastfolk", "name": "Beastfolk", "blurb": "..." }
```
`ContentDB` gains `callings` and `races` dicts (two more `_load_dir` calls). **No
build-tool change** — these are hand-written directories, so M4-a does not need the
`calling` namespace the roadmap books for M7.
Blurbs ship as **placeholders** and are swapped for authored prose later without touching
code. That is the whole point of the split: the content BOM's calling blurbs stop blocking
the engine.
**Parity test:** `Callings.IDS` ≡ the ids in `content/world/callings/`, and likewise for
races. Drift fails the suite. (Same guard as the currency parity test.)
---
## 4. The tables
### Skills (9)
| Skill | Attribute |
|---|---|
| `athletics` · `intimidation` | STR |
| `stealth` · `sleight_of_hand` · `acrobatics` | DEX |
| `endurance` | CON |
| `perception` · `faith_lore` | FTH |
| `sorcery` | MAG |
LCK owns no skill, no save, no row (§7).
### Races (4)
Features only — **no race touches an attribute score**, which keeps the roll + spend pure.
| Race | Headline | Minor |
|---|---|---|
| `human` | **+1 to all 5 saves** | **+1 skill proficiency of choice** (any of the 9) at creation |
| `elf` | **`perception` proficiency** | **Nightsight** |
| `dwarf` | **+2 on CON saves vs poison / the affliction track** | **Nightsight** |
| `beastfolk` | **Claws** — innate unarmed attack, always available | **Keen scent** — situational bonus to detect/track |
**Nightsight**, **claws**, and **keen scent** are **flags on the sheet**, not numbers on it.
They are consumed by combat (M5) and by the DM's exploration checks. M4-a stores and
exposes them; it does not act on them.
The **dwarf's +2** is *situational* (vs poison and the affliction track), so it is **not**
folded into `save("con")` — it is a separate query, `poison_save_bonus()`. Baking a
conditional into a flat number is how a sheet starts lying.
### Callings (7)
| Calling | Primary | Hit die | Save profs | Skill pool | Picks | Resource | Armor | L1 talent |
|---|---|---|---|---|---|---|---|---|
| `sellsword` | STR | d10 | STR, CON | athletics · intimidation · endurance · perception | 2 | cooldowns | heavy | `second_wind` |
| `reaver` | STR | d12 | STR, CON | athletics · intimidation · endurance · acrobatics | 2 | cooldowns | medium | `blood_fury` |
| `cutpurse` | DEX | d8 | DEX, MAG | stealth · sleight_of_hand · acrobatics · perception · athletics · intimidation | **4** | cooldowns | light | `backstab` |
| `trapper` | DEX | d10 | DEX, CON | stealth · acrobatics · perception · endurance · athletics | **3** | cooldowns | light | `set_snare` |
| `hedge_mage` | MAG | d6 | MAG, FTH | sorcery · perception · sleight_of_hand · faith_lore | 2 | MP (MAG) | none | `hexbolt` |
| `bonesetter` | FTH | d8 | FTH, CON | faith_lore · perception · endurance · intimidation | 2 | MP (FTH) | medium | `mend` |
| `bloodsworn` | FTH | d8 | FTH, MAG | faith_lore · sorcery · intimidation · perception | 2 | MP (FTH) | light | `pact_mark` |
The **skill pools are new here** — the races/classes spec said only "drawn from the class's
stat-appropriate slice of the 9-skill list" and never enumerated them. Each pool is the
calling's primary-stat skills plus a small adjacent slice, sized so the choice is real but
not wide open. Cutpurse gets 4 picks from 6 (breadth is the rogue's identity); Trapper 3;
everyone else 2.
**L1 talents are id stubs.** They name a thing M5 will implement. Nothing here resolves them.
---
## 5. The sheet
`CharacterSheet` (`client/scripts/state/character_sheet.gd`) — state, so it lives with state.
**Stored** (rolled, chosen, or genuinely mutable):
```gdscript
var attributes: Dictionary # {str, dex, con, fth, mag} — final, post-spend
var race_id: String
var calling_id: String
var level: int = 1
var skills: Array # chosen + race-granted proficiencies
var bonus_skill: String # human only, "" otherwise
var hp: int # CURRENT — combat writes this
var mp: int # CURRENT
```
**Derived** (functions, never stored):
```gdscript
func modifier(stat) -> int # floor((score - 10) / 2)
func prof_bonus() -> int # +2 at L1
func max_hp() -> int # hit_die_max + CON_mod
func max_mp() -> int # TUNABLE — see below
func ac() -> int # 10 + DEX_mod (armor is M7)
func save(stat) -> int # mod + prof_if_proficient + 1_if_human
func poison_save_bonus() -> int # +2 for dwarf, else 0
func skill_bonus(skill) -> int # mod(governing attr) + prof_if_proficient
func spell_dc() -> int # 8 + prof + casting_mod (casters only)
func initiative() -> int # DEX_mod
func has_nightsight() -> bool # etc. — the race flags
```
**LCK is not on the sheet.** It stays in `GameState.luck` / `luck_base`, exactly where it is
today — no save, no skill, no row, no accessor. §7 holds by construction, not by discipline.
`GameState.stats` (the flat five-int Dictionary) is **replaced** by `GameState.sheet`.
### Two honest seams
- **`ac()` is `10 + DEX_mod`.** Armor categories are proficiency categories; actual armor is
an item, and items are M7. The armor category is on the calling and unused until then.
- **`max_mp()` is a marked `TUNABLE` placeholder.** The races/classes spec **explicitly
defers the MP formula to M5**, so this must not pretend to be the answer:
```gdscript
# TUNABLE — M5 owns the real MP curve (races-and-classes spec §6).
# Martials have no pool at all; a caster gets a small one off the casting stat.
func max_mp() -> int:
if not Callings.is_caster(calling_id):
return 0
return maxi(4, 4 + 4 * modifier(Callings.casting_stat(calling_id)))
```
Flagging it beats quietly inventing it — the same courtesy `_roll_stat` was given. The
**shape** (martials 0, casters scale off the casting modifier, floor of 4) is what M4-b's
screen needs to render a bar; the **numbers** are M5's to replace, and no test asserts them
beyond "a martial has 0 and a caster has some."
---
## 6. Creation is seeded, and `construct` re-derives
Charter §10: *"Seed the RNG per encounter from save state… the difference between a bug you
can reproduce and a bug you cannot."* The races/classes spec says **"character creation is
the first place seeding matters."** So it is seeded.
### The creation Dictionary
`NewGame.construct` keeps taking a **plain `Dictionary`** — the saga guardrail: a later saga
synthesizes one and skips the UI entirely, so the creation *scene* must never become the
only thing that can produce this.
```gdscript
{
"name": "Aldric",
"race_id": "beastfolk",
"calling_id": "cutpurse",
"seed": 8675309, # the whole character, reproducibly
"spend": {"dex": 2, "con": 1}, # additive-only, ≤ 3 total
"skills": ["stealth", "sleight_of_hand", "acrobatics", "perception"],
"bonus_skill": "" # human only
}
```
**It carries a seed, not a stat block.** `construct` reconstructs the RNG from `seed`,
**rolls the base attributes itself**, and applies `spend`. It never trusts numbers handed to
it. Consequences:
- **Same seed → identical character.** Every time. Directly asserted in test.
- `spend` is **validated**, not assumed — and it is the *only* thing the player adds.
- The UI cannot inject an attribute value, because it never passes one. **Code owns state.**
- The entire character is reproducible from four primitives: `seed`, `race_id`,
`calling_id`, `spend`.
### Order of operations (fixed, because the RNG order is the determinism)
1. Seed the RNG from `creation.seed`.
2. **Roll five attributes** in the fixed order `str · dex · con · fth · mag` —
**3d6, hard floor of 8** (`maxi(8, 3d6)`).
3. **Roll LCK hidden** — `Luck.roll_base(rng, origin.build_constraints.luck_modifier)`. Same
RNG, after the stats, so the order is part of the contract. Never shown, never on the
sheet (§7).
4. **Apply `spend`** — additive only.
5. **Apply race** — granted skills, save bonus, flags.
6. **Apply calling** — hit die, save profs, chosen skills, resource, armor category, talent.
7. `hp = max_hp()`, `mp = max_mp()` — the character starts whole.
### The die: 3d6, floor 8
The pool caps at +3, and straight 3d6 rolls 36 on a given stat about 9% of the time. A
Hedge-Mage who rolls MAG 5, spends the entire pool, and lands at 8 has a **1 modifier on
his primary** — bad at the one thing he is *for*, before the game starts.
That is not grit. §3's grit is *the world does not care about you*; §7 is explicit that bad
luck costs **dignity, not progress**, and that punishing a player for a build choice "is not
comedy. It is a fine." A dead-on-arrival primary is a fine, and it is paid for twenty hours.
Floor 8 clamps the **die**, not the player: the mean stays low and unheroic (~10.9), the
party is still ordinary people, and the worst case on a primary is 8 → 11 with the full pool
→ a **+0 modifier**, which with the +2 proficiency bonus is a character who is unremarkable
rather than broken. It also makes the +3 pool do what it was designed for — *recovering a bad
roll on the primary* — instead of failing to.
### Validation (all of it, in `construct`, front-loaded)
Rejects with `{"ok": false, "errors": [...]}` — never a half-built character:
- `name` non-empty after trimming.
- `race_id` ∈ `Races.IDS`; `calling_id` ∈ `Callings.IDS`.
- `calling_id` ∈ the origin's `build_constraints.allowed_callings`.
- `spend`: keys ⊆ the five attributes (**`lck` is never spendable**), values non-negative
integers, **sum ≤ 3**.
- `skills`: exactly `Callings.skill_count(calling_id)` of them, all drawn from that calling's
pool, no duplicates, and **none already granted by the race** (an Elf cannot spend a pick
on `perception` — he has it).
- `bonus_skill`: required **iff** race is `human`, empty otherwise; one of the 9; not already
proficient.
- The origin's refs resolve (existing check, unchanged).
---
## 7. The migrations
`class_id` is the rulebook's word. The world has **callings** — that was the whole §17
reconcile ("Priest is what a rulebook calls him; Bonesetter is what a village calls him").
The contract the *Narrator* reads should say what the world says.
There are no saves (persistence is M9) and no deployed clients, so this rename is as cheap
now as it will ever be, and strictly more expensive every milestone after.
| Where | Change |
|---|---|
| `docs/schemas/canon-log.schema.json` | `class_id` → **`calling_id`** (enum of the 7); **new required `race_id`** (enum of the 4) |
| `docs/schemas/origin.schema.json` | `build_constraints.allowed_classes` → **`allowed_callings`** (enum of the 7) |
| `client/scripts/canon_log/log_player.gd` | `class_id` → `calling_id`; add `race_id`; **`CLASSES` const deleted** — validate against `Callings.IDS` |
| `client/scripts/newgame/new_game.gd` | the pipeline of §6 |
| `client/scripts/state/game_state.gd` | `stats` Dictionary → `sheet: CharacterSheet` |
| `client/scripts/content/content_db.gd` | load `callings/` + `races/` |
| `api/app/prompts.py:53,107` | `a {class_id}` → **`a {race_id} {calling_id}`** — the AI can finally describe the player's race |
| `content/origins/deserter.json` | `allowed_classes` → `allowed_callings`, with live ids |
| api + client tests, `api/tests/fixtures/canon_log_valid.json` | the renamed fields |
**The deserter allows all seven callings.** He is a mercenary-company deserter; a thematic
gate is a *content* decision that belongs with the Greywater authoring, not an engine change
invented here. `minItems: 1` is satisfied.
**Schema/code parity test.** The roster now lives in two places — `Callings.IDS` and the
schema's `enum`. A client test reads `../docs/schemas/canon-log.schema.json` (the same reach
`ContentDB` already uses for `../content`) and asserts the enum **equals** `Callings.IDS`,
and likewise `race_id` ≡ `Races.IDS`. They cannot drift.
---
## 8. Testing
All headless — the model is pure functions over a dumb store.
**Tables:** every calling's skill pool is a subset of the 9; every id in `Callings.IDS` has a
content blurb and vice versa (parity); same for races.
**Derivation:** `modifier()` at the boundaries (7→2, 8→1, 10→0, 11→0, 12→+1);
`max_hp()` per calling (a d12 Reaver with CON 14 → 12 + 2 = 14); `save()` picks up calling
proficiency; a **human's +1 lands on all five saves**; a **dwarf's +2 does NOT appear in
`save("con")`** but does in `poison_save_bonus()`; `skill_bonus()` uses the governing
attribute; `spell_dc()` for each caster.
**The roll:** the floor holds at the bottom of the die (a seed that would roll 3 yields **8**);
the distribution is still 3d6-shaped above the floor.
**Determinism — the headline test:** the same `seed` + same choices produces a byte-identical
sheet **and** the same hidden LCK. Twice, from two fresh `construct` calls.
**Validation:** `spend` rejects a negative, a >3 total, and **any attempt to spend on `lck`**;
skills reject a pick outside the pool, a duplicate, and an Elf re-picking `perception`;
`bonus_skill` is rejected when present on a non-human and when missing on a human;
`construct` rejects a calling the origin disallows.
**§7 guard (extend the existing one):** no numeric Luck and no attribute block appears in
`log.to_dict()` — the log carries only `{name, race_id, calling_id, luck_descriptor}`.
**Server:** the prompt digest renders `a beastfolk cutpurse`; the canon-log schema rejects a
dead `class_id` and an unknown `calling_id`.
---
## 9. Out of scope
- **The creation screen** — M4-b. This spec builds only what it will consume.
- **Title → creation → shell** — M4-c. `MainWindowShell._build_seed_log()` still hand-seeds a
`CanonLog`; retiring that placeholder is M4-c's job.
- **The MP formula and the level curve** — M5 (deferred by the races/classes spec).
- **Armor / AC beyond `10 + DEX`** — M7 (armor is an item).
- **The L1 talents' actual effects** — M5. They are id stubs here.
- **Calling and race blurb prose** — the content BOM. Placeholders ship; the split is what
stops that from blocking this.
- **The beastfolk social tax** — deferred to the canon session + M8's shop.

View File

@@ -0,0 +1,340 @@
# M4-b — the character creation screen
**Date:** 2026-07-13
**Status:** approved, ready for planning
**Charter:** §2 (code owns state), §7 (Luck), §8 (stats), §10 (seeding), §13 (authored
fallback is content), §15 (button-driven), §16 (editor-first UI), §18 (git)
**Consumes:** [M4-a — the creation model](2026-07-12-creation-model-design.md). Everything
here is a screen over rules that already exist and are already tested.
**Mock:** `mockups/Character Creation.dc.html` — the UI bible, and where it and the model
disagree, §4 below records the decision.
**Roadmap:** M4, split **M4-a (the model, done) → M4-b (this: the screen) → M4-c (the
title→creation→shell flow)**.
---
## 1. What this is
The mock's creation screen, over M4-a's proven model. The player picks a race and a
calling, sees five rolled attributes (LCK never), spends a pool of three points, chooses
proficiencies, types a name, and presses ENTER THE WORLD.
**It makes no AI calls.** Not one. The DM's real entrance is the shell's opening narration
one screen later (M4-c). This screen is authored text and local rules end to end, and runs
with no proxy and no model reachable.
**It does not construct the character.** It emits a creation `Dictionary` and M4-c calls
`NewGame.construct`. The scene is a producer of that Dictionary, never the only possible
producer — the saga guardrail M4-a wrote down.
---
## 2. What is actually there today (verified at `b4b72e4`)
- `NewGame.construct(origin, world, creation)` is built, seeded and tested. It takes a
**seed, not a stat block**, rolls the five attributes itself in a fixed order, then rolls
LCK off the same RNG, applies `spend`, race grants, calling picks, and returns
`{ok, errors, log, state}`.
- `_validate` is **private**. `_roll_attribute` is **private** and called in a loop inside
`construct`.
- `Races` (4), `Callings` (7), `Skills` (9), `Attributes` (5) are static tables in
`client/scripts/rules/`. `CharacterSheet` stores inputs and derives the rest.
- `ContentDB` loads `content/world/races/` and `callings/`. Each JSON carries
`{id, name, blurb}` and **no mechanics** — the M4-a split.
- The theme has a `Chip` variation (`ThemeKeys.CHIP`) and a `PrimaryCTA`. Both were built
for a single visual state.
- `TitleScreen` exists and `MainWindowShell` exists. Nothing connects them to creation —
that is M4-c.
---
## 3. Architecture
Three units, one of which is a scene.
| File | Responsibility |
|---|---|
| `client/scenes/creation/CharacterCreation.tscn` | **new.** The screen. Editor-authored tree, theme on the root (ADR 0001). |
| `client/scripts/ui/creation/character_creation.gd` | **new.** The binder. `@onready` refs, click → draft, draft → nodes. **No rules.** |
| `client/scripts/ui/creation/creation_draft.gd` | **new.** `CreationDraft` — pure, node-free. Every rule the screen enforces. |
| `client/scripts/ui/creation/creation_copy.gd` | **new.** `CreationCopy` — pure, static. Formats *mechanics* into display strings by reading the rules tables. |
| `client/scripts/newgame/new_game.gd` | `roll_attributes()` and `validate()` become **public**; `construct` calls `roll_attributes` instead of its own loop. |
| `content/world/{races,callings}/*.json` | Each gains a `fragment` (§6). |
| `client/scripts/theme/build_game_theme.gd` + `theme_keys.gd` | Chip `pressed`/`disabled`, CTA `disabled` (§7). |
**Why a `CreationDraft` and not logic in the scene script.** ADR 0001 already decided this:
the script binds, it does not reason. A draft that is a plain `RefCounted` makes every rule
("can a Human re-pick a skill his race already granted?") answerable headlessly, with no
scene tree, which is the difference between a rule that is tested and a rule that is
merely written. `ShellState` is the same pattern; this follows it.
---
## 4. Where the mock and the model disagree — and what wins
The mock predates the seven callings and the skill system. It is a **UI bible, not a rules
bible.** Five reconciles, all decided:
| # | The mock says | We do | Why |
|---|---|---|---|
| 1 | No skill picking at all | **A skills section** — chips, N of a pool | `construct` *rejects* a creation dict without exactly `Callings.skill_count(id)` legal picks. Without the UI the pools do nothing, every Cutpurse is identical, and we ship a validation rule no player exercises. |
| 2 | Five callings | **Seven**, nameplate cards | The seven are named and settled (races/classes spec §4). |
| 3 | Blurb on every calling card | **Blurb on the *chosen* calling only** | Seven blurbs shouting at once is a menu you skim, not a choice. See §5. |
| 4 | `+` only on the spend | **`+` and ``** | "Additive only" constrains where the number lands, not whether a mis-click is recoverable. A `` that floors at the rolled base produces a dict `construct` already accepts. The mock did not think about mis-clicks; this is a bug in it, not a position it took. |
| 5 | ` ` arrows on the portrait | **Cut** | They cycle model variants. We have neither art nor a portrait field on `CharacterSheet`. Two buttons that do nothing are worse than none. The dashed art slot stays (ADR 0001 — art slots are placeholders). |
The **"THE DM SIZES YOU UP" panel is authored, not an AI call** (§6). The **re-roll is
unlimited**, as mocked (§8).
---
## 5. Layout — variant C
1920×1080. The mock's split holds: a 660px dark portrait bay on the left, a 1260px
parchment sheet on the right.
**Left bay:** kicker (`WHO WILL YOU BE?`), the dashed character-model art slot, the
nameplate (`{name or "Nameless"}` · `{Race} · {Calling}`), and the DM origin card.
**Right sheet, top to bottom:**
- Header — *Create Your Character* / `THE WORLD HAS REAL PROBLEMS AND DOES NOT CARE ABOUT YOU`
- **RACE** — four cards (name, blurb, derived trait line). Selected card gets the crimson
border + `CHOSEN` tag.
- **CALLING** — **seven nameplate cards in one row: name + primary-stat chip, no blurb.**
Beneath the row, a detail panel for the *chosen* calling carrying **its blurb and its
mechanics together**:
`d8 · light armour · cooldowns · saves DEX + MAG · picks 4 skills · talent backstab`
- **PROFICIENCIES** — chips. The chosen calling's pool, `N of N chosen`, plus (Human only) a
second row over all nine skills for the bonus pick.
- **ABILITY SCORES** — five cards (STR DEX CON FTH MAG — **never LCK**), each with the final
value, `rolled X +Y`, and ``/`+`. `points left: N`, the `⟳ re-roll` button, and the dry
line *rolled 3d6 — that roll is your floor, spend up only*. The calling's primary stat
gets the `PRIMARY` ring.
- **Footer** — the name field and ENTER THE WORLD.
**Why C.** The CALLING block becomes the visual mirror of the PROFICIENCIES block below it:
*pick, and the consequence appears.* One blurb, for the thing you chose, in a panel that
exists because you chose it. It also buys ~70px and kills the cramped-italic problem that
seven 157px cards create. Worst case for vertical space is **Human + Cutpurse** (the bonus
row appears *and* the pool is the widest in the game, 4 of 6) — it fits inside 1080 with
room.
**LCK appears nowhere on this screen.** Not as a number, not as a descriptor, not as a
hidden tooltip. §7.
---
## 6. The DM origin panel is authored
The panel composes, exactly as the mock does:
> `{race.fragment}` Now you carry a `{calling.name}`'s work — `{calling.fragment}`
Each race and calling JSON gains a **`fragment`**:
```json
{ "id": "cutpurse", "name": "Cutpurse",
"blurb": "Purses, locks, confidences — you have taken all three. …",
"fragment": "quick fingers, quicker exits, and a knife for the rest." }
```
Eleven fragments — four races, seven callings. **Real prose, not stubs**, authored through
the **world-building skill** so they carry the world's voice. A fragment a shade too warm
poisons the first screen the player ever sees.
**Why not a live Narrator call.** The player clicks race and calling cards a dozen times
while fiddling; that is a dozen calls with a spinner between them, against §14's "cache
aggressively, pre-generate." Worse, it asks the AI to narrate a character *nothing has
happened to yet* — there is no scene, no event, nothing to narrate. §13 is explicit that
authored text is **content**, not error handling; the DM's voice does not require a model
behind it to be the DM's voice. The DM's real entrance is the shell's opening narration,
one screen later, at the first moment where speaking means something.
**Degradation:** a missing `fragment` falls back to the `blurb`. The parity test (§9)
prevents that from ever shipping, but the screen does not crash on it.
---
## 7. Mechanics are derived, never written twice
`CreationCopy` is a pure static formatter that reads `Races` / `Callings` / `Skills` and
returns display strings:
- **Race trait line** — `+1 to every save · +1 skill of choice` (human),
`Poison resist · nightsight` (dwarf), …
- **Calling detail line** — `d8 · light armour · cooldowns · saves DEX + MAG · picks 4 skills · talent backstab`
**Prose lives in content; numbers live in code; the card reads both.** A hit die written
into a JSON is a hit die that will eventually disagree with the table. If someone retunes
the Cutpurse's d8, the card follows — because the card never knew the number.
**Theme additions.** A skill chip has three states — unpicked, picked, and *granted by your
race so it cannot be picked* — which is a `Button` in `toggle_mode` needing
`normal`/`pressed`/`disabled` styleboxes. The existing `Chip` is a display badge with no
`disabled` and no selected look, and the race/calling cards need a "chosen" crimson-ring
state that nothing in the theme has. (`PrimaryCTA` **already** carries `disabled` +
`font_disabled_color` — the CTA needs nothing new.) The new variations are added to
**`build_game_theme.gd`** + `theme_keys.gd` and the `.tres` **regenerated** — never
hand-edited, no hex in a scene or script (the M3-a rule).
---
## 8. The draft, the seed, and the data flow
### `CreationDraft`
Stores exactly what a creation Dictionary is made of, and nothing derived:
```gdscript
var name: String
var race_id: String
var calling_id: String
var seed: int
var spend: Dictionary # {stat: int} — additive only
var skills: Array # the player's picks
var bonus_skill: String # human only
```
**The interaction rules** — where a creation screen actually gets buggy, so they are stated
rather than discovered:
- **Changing calling clears the skill picks.** The pool changed underneath them; keeping
stale picks leaves the player holding an invalid draft he did not cause and cannot see.
- **Changing race drops a now-granted pick.** An Elf is granted `perception`; a Cutpurse who
had picked it now holds a pick that "buys nothing" and `construct` rejects by name.
`set_race` drops it, and clears `bonus_skill` if the new race is not Human.
- **Re-roll clears the spend, and only the spend.** Race, calling, skills and name survive.
Points spent against a roll that no longer exists are meaningless.
- **`` floors at the rolled base.** It removes only what you added.
- **`errors()` delegates to `NewGame.validate`.** The screen owns no second copy of the
rules. It asks, and shows the answer.
### The seed — and why the screen can show numbers without owning them
```
randomize() ──> seed ──┬──> draft.rolled() ────────> the five numbers ON SCREEN
│ (NewGame.roll_attributes)
└──> creation Dictionary ──> NewGame.construct
└──> rolls them AGAIN, itself
──> CharacterSheet
```
The screen and `construct` reach the same five numbers by calling **the same pure function
on the same seed** — but the screen never *hands* a number to anything. It hands over a seed
and four choices. **§2 holds without the screen having to be trustworthy**, which is the only
kind of holding that counts.
`randomize()` is the one place non-determinism enters the entire pipeline, and what it
produces is the seed — which is thereafter the character's determinism anchor.
**This forces two small M4-a changes:**
- **`roll_attributes(seed: int) -> Dictionary`** — public, pure. `construct` **calls it**
rather than keeping its own loop, so there is exactly one implementation of the roll.
- **`validate(origin, world, creation) -> Array`** — today's `_validate`, made public, so
the screen can gate the button on the real rules.
**Neither takes a default parameter.** See §9, trap 2.
### Re-roll is unlimited
As mocked. The floor of 8 already prevents the dead-primary "fine" §7 forbids, so limiting
re-rolls buys no protection — it only adds friction and calls it grit.
And it earns something. The seed drives the five visible attributes **and the hidden LCK
roll**, which comes off the same RNG immediately after MAG. A player who re-rolls twenty
times chasing a 16 STR is, on every press, blindly re-rolling his Luck. He cannot see it, he
cannot count it, and he will never know what he traded away. That is the superstition §7 is
built to produce, and the seeding decision M4-a already made hands it to us for free.
**The screen says nothing about this, ever.** The `⟳ re-roll` button and the dry subtitle are
all the player is told.
### Exit
The screen emits **`creation_confirmed(creation: Dictionary)`**. It does **not** call
`construct` — the flow is M4-c's job, and a saga later synthesizes the same Dictionary and
skips the scene entirely.
`origin` and `world` are **injected** the way the shell injects `DmService`: settable between
`instantiate()` and `add_child()`, with `_ready` loading the deserter origin and a real
`ContentDB` when nothing was supplied. Tests stay hermetic, and the origin's
`allowed_callings` gates which calling cards appear rather than the screen hardcoding seven.
**ENTER THE WORLD is disabled while `errors()` is non-empty**, with the first error printed
dry beneath it — *choose two more proficiencies*, *name your character*. The player cannot
press his way into a rejection.
---
## 9. Testing
All headless (GUT, `client/run_tests.sh`) except the visual gate. `traps.md` records two bugs
M4-a shipped **past a green suite**; both have a direct analogue here, so the guard is named,
not hoped for.
### Trap 1 — a regression test that cannot fail is not a regression test
The load-bearing claim of this entire screen is **the numbers you saw are the numbers you
got.** The lazy test builds a `CreationDraft`, builds a `CharacterSheet`, and asserts both
roll the same — which passes even if the screen and `construct` hold two separate roll
implementations that merely agree today.
**The real test asserts across the pipeline:**
```gdscript
var draft := CreationDraft.new() # seed S, calling, spend
var result := NewGame.construct(origin, world, draft.to_creation())
assert_eq(result.state.sheet.attributes, draft.final())
```
Re-implement the roll in either place and it goes red. Per `traps.md`: before accepting the
work, **re-break it and watch the test fail.**
### Trap 2 — GDScript defaults turn an arity change into a silent miscompile
`validate` and `roll_attributes` become public. **Neither gets a default parameter on any
argument.** A wrong-arity call to a defaulted function compiles clean and binds the wrong
values — exactly how M4-a shipped a player block of four empty strings into the live shell,
past a grep that came back clean. Required args only.
### The rest
- **`CreationDraft`** (no scene tree): re-roll clears the spend and only the spend; changing
calling clears the picks; changing race drops a now-granted pick and clears a non-Human's
bonus skill; `` floors at the rolled base; the fourth point cannot be spent; a granted
skill cannot be picked; `to_creation()` round-trips into `construct` cleanly.
- **`CreationCopy`**: for **all seven** callings the detail line *contains*
`"d%d" % Callings.hit_die(id)` and that calling's actual saves — the guard that fails if
anyone types `d8` into a string literal instead of reading the table. Same for the four
race trait lines.
- **`NewGame`**: same seed → same five attributes; the floor of 8 holds at the bottom of the
die; `validate` returns the same errors `construct` would reject with.
- **The scene**: mounts with an injected origin + `ContentDB`; nodes are non-zero; the CTA is
disabled on a fresh draft and enabled on a complete one; `creation_confirmed` emits exactly
`draft.to_creation()`.
- **Content parity** (extends the existing test): every id in `Races.IDS` / `Callings.IDS` has
a content file with a **non-empty `fragment`**.
- **§7 guard**: no LCK value, descriptor, or accessor is reachable from the creation scene.
### The visual gate
Headless GUT proves the nodes mount and the bindings run. **It cannot prove the screen looks
like the mock.** ADR 0001 is explicit that this gate is a **human F6 eyeball**, and this is
the screen where that matters most.
---
## 10. Out of scope
- **Title → creation → shell wiring** — M4-c, next.
- **Portrait art and the ` ` arrows** — no art, no portrait field.
- **Origin *selection*** — the deserter is the only origin. The screen is *handed* one; it
does not choose.
- **Persisting the creation seed past creation** — M9 saves the sheet, and the sheet *is* the
character.
- **Any AI call.** The DM's entrance is the shell, one screen later.
- **The MP curve and the level curve** — M5, deferred by the races/classes spec.

View File

@@ -0,0 +1,281 @@
# M4-c — Title → creation → shell flow
**Milestone:** M4-c (roadmap §M4). **Date:** 2026-07-15.
**§2 side:** n/a (flow orchestration — code owns it, no AI).
**Depends on:** the Title screen (M3), the creation screen (M4-b), the creation
model `NewGame.construct` (M4-a), the shell (M3-a).
**Goal it advances:** a playable start-to-shell loop — the player builds a
character and lands in the exploration shell as *that* character.
---
## 1. Problem
The flow from the title screen into the game is fictional in two places:
1. **Title skips creation.** `title_screen.gd`'s `new_game` item does
`get_tree().change_scene_to_file(SHELL)` — it jumps straight to the shell and
never touches the creation screen.
2. **The shell fakes a character.** `main_window_shell.gd._build_seed_log()`
hardcodes "Vexcca, a human sellsword" and `ShellState.seed()` hardcodes her
vitals. The shell has never consumed a real, created character.
Meanwhile the creation screen already emits `creation_confirmed(creation)` and
`NewGame.construct(origin, world, creation)` already returns
`{ok, log: CanonLog, state: GameState}` — the two objects the shell needs. Nothing
connects the emit to the construct to the shell.
M4-c wires those three edges: **Title → Creation → Shell**, with the created
character flowing all the way through.
### Constraints
- **⛨ saga (roadmap §M3/§M4):** do NOT hardcode a linear
`new game → creation → world` flow as one welded unit. The saga's fourth entry
path — "begin the next campaign of an existing saga" — must be addable later
without rewriting existing edges.
- **§2:** the screen never hands game state upward. `construct` re-rolls the
character from the creation seed itself; the flow only carries plain data.
- **§13:** a failure never surfaces to the player as an error.
- **No new global state.** The codebase uses an injection-seam pattern
(`instantiate()` → set property → `add_child()`), not autoloads. There are zero
autoloads today; this milestone adds none.
---
## 2. Approach (chosen)
**A flow coordinator node.** A new script-first root `Node`, `GameFlow`, becomes
the `main_scene`. It owns one `ContentDB` and the current screen (its only child),
and it holds the entire Title → Creation → Shell sequence in one file.
Rejected alternatives:
- **Autoload handoff singleton** — keep `change_scene_to_file` everywhere, carry
the character in a global. Smallest screen diff, but smears the flow across three
handlers + global mutable state, fighting the no-hardcoded-flow constraint and
cutting against the injection-seam pattern.
- **Screens instantiate their own successor** — hardcodes the flow *into* the
screens and couples them; worst for the saga constraint.
The coordinator is the only option where the sequence is a single editable object
(saga-friendly) and it reuses the existing injection seam (no global state).
---
## 3. Components
### 3.1 `GameFlow` (new)
- **Files:** `client/scripts/ui/flow/game_flow.gd`,
`client/scenes/flow/GameFlow.tscn` (a trivial one-node scene).
- **Type:** script-first `Node`. It has no Control layout, so ADR 0001's
editor-first rule does not apply — that rule governs *Control layout* scenes;
this is a pure coordinator. (Noted explicitly so a reviewer does not read the
code-built structure as an ADR 0001 violation.)
- **Owns:**
- `world: ContentDB` — loaded once in `_ready` (`ContentDB.default_content_root()`).
- `service: DmService` — built once and injected into the shell, so the shell's
own `service == null` fallback is not needed on the real path (it stays for
F6/standalone).
- `origin: Dictionary` — the deserter origin (the single origin that exists).
- the current child screen.
- **`main_scene`:** `project.godot` `run/main_scene` changes from
`res://scenes/title/TitleScreen.tscn` to `res://scenes/flow/GameFlow.tscn`.
**The one swap primitive:**
```gdscript
func _show(next: PackedScene, inject: Callable) -> void:
# free current child, instantiate next, run the injection seam, then add.
for c in get_children():
c.queue_free()
var screen := next.instantiate()
inject.call(screen) # set injected properties BEFORE add_child (_ready)
add_child(screen)
```
`inject` is the seam: it runs after `instantiate()` (which does NOT run `_ready`)
and before `add_child()` (which does) — the same window the tests use. This is
precisely what `change_scene_to_file` cannot provide.
**The sequence (the whole table lives here):**
| Step | Trigger | GameFlow method |
|---|---|---|
| boot | `_ready()` | `_show_title()` |
| title | `menu_activated(&"new_game")` | `_show_creation()` |
| title | `menu_activated(&"quit")` | `get_tree().quit()` |
| creation | `creation_confirmed(creation)` | `_start_campaign(creation)` |
- `_show_title()``_show(TITLE, func(s): s.menu_activated.connect(_on_menu))`.
- `_show_creation()` → `_show(CREATION, func(s): s.world = world; s.origin = origin;
s.creation_confirmed.connect(_start_campaign))`.
- `_start_campaign(creation: Dictionary)` → call `NewGame.construct`; on success
`_show_shell(result.log, result.state)`; on failure see §5.
- `_show_shell(log, state)` → `_show(SHELL, func(s): s.service = service;
s.injected_log = log; s.injected_state = state)`.
**Saga seam:** `_show_creation()` and `_start_campaign(creation)` are deliberately
separate. `_start_campaign` takes a plain Dictionary and does not assume the
creation came from the screen. The saga's fourth path becomes a new
`menu_activated` arm that synthesizes a `creation` Dict and calls
`_start_campaign` directly, skipping the creation screen — no existing edge
changes.
### 3.2 `title_screen.gd` (modified)
Becomes **emit-only**. It already emits `menu_activated(id)`. Remove:
- `const SHELL`,
- the `new_game` and `quit` bodies of `_on_menu_activated` (and the now-dead
`_on_menu_activated` connection if it holds nothing else).
The screen no longer knows what comes after it. Its atmosphere/selection/input
behavior is untouched.
### 3.3 `character_creation.gd` (unchanged)
No change. It already accepts injected `world`/`origin` and already emits
`creation_confirmed(draft.to_creation())`. M4-b left the signal dangling for
exactly this milestone to connect.
### 3.4 `main_window_shell.gd` + `ShellState` (modified)
Two new injection seams on the shell, mirroring `service`:
```gdscript
var injected_log: CanonLog = null
var injected_state: GameState = null
```
In `_ready()`, the seed calls become fallbacks (same shape as `service == null`):
```gdscript
_state = ShellState.for_character(injected_state, injected_log) if injected_state \
else ShellState.seed()
_log = injected_log if injected_log else _build_seed_log()
```
New factory **`ShellState.for_character(state: GameState, log: CanonLog)`**:
- `vitals.hp / hp_max` ← `state.sheet.hp / state.sheet.max_hp()`
- `vitals.mp / mp_max` ← `state.sheet.mp / state.sheet.max_mp()`
- `vitals.purse_copper` ← `state.purse_copper`
- `location_label` ← `log.location.name`
- `turn_order`, `consumables`, `round_label` ← **the same placeholders `seed()`
uses** (combat = M5, inventory = M7 own these; not M4-c's to invent).
`_build_seed_log()` and `ShellState.seed()` **stay** — they are the F6/standalone
fallback, unchanged. On the real path the injected objects win and the created
character's vitals, location, and opening narration (via the injected log) are all
real.
---
## 4. Data flow
```
project.godot main_scene = GameFlow.tscn
GameFlow._ready → loads ContentDB (world), origin (deserter), builds DmService
│ _show_title
TitleScreen ──menu_activated(&"new_game")──▶ GameFlow._show_creation
│ inject world + origin
CharacterCreation ──creation_confirmed(creation)──▶ GameFlow._start_campaign
│ NewGame.construct(origin, world, creation) → {ok, log, state}
│ inject log + state + service
MainWindowShell (vitals/location/narration all from the real character)
```
`creation` is a plain Dictionary carrying `{name, race_id, calling_id, seed,
spend, skills, bonus_skill}`. `construct` re-rolls the attributes and Luck from
`seed` itself (§2/§10) — the flow never carries a computed stat.
---
## 5. Error handling (§13)
`NewGame.construct` returns `{ok: false, errors}` on invalid input. Through the UI
this is **unreachable** — `character_creation._bind_cta` disables the CTA on any
`NewGame.validate` error, so `creation_confirmed` only fires on a legal draft. But
§2 requires `construct` to distrust its caller, and so does `GameFlow`:
- On `result.ok == false`, `_start_campaign` **logs the errors and returns** —
it does not swap to the shell. The player remains on the valid creation screen.
- No crash, no half-built shell reaches the player.
This path exists to be handled, not hit; a test drives it deliberately (§6).
---
## 6. Testing
All new guards get the revert-and-watch-it-go-red treatment per
[`docs/traps.md`](../../traps.md). Drive screens by emitting their **real
signals**, never by calling handlers (traps.md: tests that called handlers
instead of pressing nodes).
1. **GameFlow flow — new game reaches the shell as the created character.**
Instantiate GameFlow with an injected fake `world`/`origin`/`service`; let it
show the title; `emit_signal("menu_activated", &"new_game")` → assert the child
is a `CharacterCreation`. Build a valid `creation` Dict for a known character
(e.g. name "Dagnet"), `emit_signal("creation_confirmed", creation)` → assert
the child is a `MainWindowShell` **and its canon log player name == "Dagnet"**.
The name assertion proves the character flowed through, not merely that a scene
swapped. *Re-break:* feed a Dict whose name differs from the asserted one and
watch it go red.
2. **Shell injected path — vitals derive from the real sheet.** Inject a
`log`+`state` for a character with a known HP (distinct from the seed's 42/60);
`add_child`; assert the `HP` label reads the injected HP. *Re-break:* force the
`seed()` branch and watch it fail.
3. **`ShellState.for_character` unit.** Vitals + location derive from the passed
`state`/`log`; `turn_order`/`consumables`/`round_label` equal `seed()`'s
placeholders. *Re-break:* change one derived field's source and watch the
assertion fail.
4. **Construct-failure stays on creation (§5).** Drive `_start_campaign` with a
`creation` Dict that fails `validate`; assert no `MainWindowShell` is added and
the flow is unchanged. *Re-break:* make `_start_campaign` transition
unconditionally and watch this go red.
5. **Title is emit-only.** Assert `title_screen.gd` no longer references a shell
scene path and emits `menu_activated` (the existing title test may already
cover the emit; extend it to assert the routing bodies are gone).
---
## 7. Scope boundary
**In:** the three flow edges; the character flowing through them; the shell's
vitals + location + narration deriving from the real character; the emit-only
title; error handling for the unreachable construct failure.
**Out (owned elsewhere):**
- Origin selection UI — deserter is the single origin; the seam accepts one origin
now and a choice later.
- Save/load, continue, load — M9.
- Quit-confirmation dialog.
- Any HUD field beyond vitals + location (turn order = M5, consumables = M7).
- The saga's fourth entry path itself — this milestone only keeps it *addable*.
---
## 8. Files
**New:**
- `client/scripts/ui/flow/game_flow.gd`
- `client/scenes/flow/GameFlow.tscn`
- `client/tests/unit/test_game_flow.gd`
**Modified:**
- `client/project.godot` — `run/main_scene` → `GameFlow.tscn`
- `client/scripts/ui/title/title_screen.gd` — emit-only
- `client/scripts/ui/shell/main_window_shell.gd` — `injected_log`/`injected_state`
seams + fallback wiring
- `client/scripts/ui/shell/shell_state.gd` — `for_character(state, log)` factory
- `client/tests/unit/test_main_window_shell.gd` — injected-path test
- `client/tests/unit/test_shell_state.gd` — `for_character` test
- `client/tests/unit/test_title_screen.gd` — emit-only assertions

395
docs/traps.md Normal file
View File

@@ -0,0 +1,395 @@
# Traps
Bugs this project has **already** shipped into a branch, what let them through, and the
guard that now catches them. Read before writing tests; each entry is a class of mistake,
not a one-off.
The pattern in almost all of them: **the test passed, and the test was wrong.** A green
suite is evidence only if the test can fail.
---
## 1. A regression test that cannot fail against the bug is not a regression test
**M4-a.** `LogPlayer.new()` changed from `(name, class_id, luck_descriptor)` to
`(name, race_id, calling_id, luck_descriptor)`. Three callers kept the old 3-argument
shape. The fix added a test — which constructed its *own* correct `LogPlayer` and asserted
the constructor worked. You could revert the broken call site and the suite stayed green.
**The guard:** a regression test must assert on **the thing that was broken**, not on a
fresh correct instance of the same type. Here, that meant asserting on the seed log the
*call site* builds (`test_main_window_shell.gd`), not on a `LogPlayer` the test made.
**Prove it.** Before accepting a fix, re-break the code, run the suite, and *watch the new
test fail*. If it doesn't, the test is decoration. This is now the standard for any fix
that claims to guard a regression.
---
## 2. GDScript default parameters turn an arity change into a silent miscompile
**M4-a.** Every parameter of `LogPlayer._init` has a default, so
`LogPlayer.new("Vexcca", "sellsword", "Fortune spits on you")` — the *old* 3-arg call —
compiled without a warning against the *new* 4-arg signature. `"sellsword"` bound to
`race_id` (rejected → `""`), the Luck descriptor bound to `calling_id` (rejected → `""`),
and `luck_descriptor` defaulted to `""`. The live shell and both proving scenes emitted a
player block of four empty strings — which fails the very JSON Schema that migration had
just tightened. Two `push_error`s fired into a log nobody was reading.
**Why it survived review:** the migration was verified with
`grep -rn "class_id" client api docs content`, which came back clean. **Grep cannot see
positional arguments.** The field name was gone; the *call sites* were not.
**The guard:** when changing a function's arity or parameter order, grep for the
**callers** (`grep -rn "LogPlayer.new("`), never for the field name. And if the type is
constructed in a scene or harness that no unit test touches, that is exactly where the
bug will live — write the test that loads it.
---
## 3. A determinism test that compares two runs tests nothing
**M4-a.** Character creation is seeded: the same seed must always produce the same
character (charter §10). The test built the character twice and asserted the two were
equal. Both runs change *together* — so reordering `Attributes.IDS` from
`str,dex,con,fth,mag` to anything else would have silently rewritten **every seeded
character in every future save**, with the suite fully green.
**The guard:** pin the **stream**, not the symmetry. `test_golden_vector_pins_the_rng_stream`
hardcodes the exact five attribute values *and the exact hidden Luck* for a fixed seed. It
was proven to fail against a reordered `Attributes.IDS`.
Any RNG-order contract (creation now; combat seeding at M5; saves at M9) needs a golden
vector. Two-runs-agree is not a determinism test.
---
## 4. A closed vocabulary is not bounded if the model can repeat a tag
**Currency.** Charter §6 gives NPCs eight moves and *no quantity argument*, deliberately:
the model picks from a closed vocabulary and never invents a number (§2). But
`TagExtractor` returns **every** occurrence of a tag, `MoveValidator` is a pure membership
test, and `MoveApplier` looped and applied each one. So a model emitting
`[MOVE: accept_item(copper)]` forty-seven times drained a 47-copper purse, and ten
`[MOVE: give_item(gold)]` tags minted 100,000c.
**The model chose the amount, in unary.** Rejecting a quantity argument bought nothing;
repetition smuggled the integer back in.
**The guard:** a move applies **at most once per reply**, keyed on `name(id)`. And
`adjust_disposition`'s `MAX_DELTA` now caps the reply's **net** swing — it used to clamp
per *tag*, so three `+15`s moved standing by 45, the exact "wholesale swing" its own
comment forbids.
**The general lesson:** when the defence is "the model can only pick from a list," ask what
happens if it picks the *same item N times*. Validation that is a membership test is not a
rate limit.
---
## 5. One fact, two homes — and only one of them is guarded
**M4-a.** The calling roster (`sellsword`, `reaver`, …) needs to be known by the client
(`Callings.IDS`), the JSON Schema the API validates against, the *origin* schema, and the
content blurb files. A parity test guarded code ↔ canon-log schema. It did **not** guard:
- `docs/canon-log.md`*the* cross-boundary contract doc, still documenting `class_id`.
- `.claude/skills/world-building/references/schema.md` — a **shipped authoring tool** whose
job is to emit content in the exact format the build consumes. It still told authors to
write `allowed_classes`. Any origin authored with it would have been rejected by the
schema, and at runtime `NewGame` would have read `allowed_callings``[]` → **every
calling forbidden.**
**The guard:** when a roster or enum exists in more than one place, enumerate **every**
copy — including prose docs and tooling — and add a parity test per machine-readable copy.
Ask specifically: *is there a doc, a skill, or a template that also writes this down?*
---
## 6. Generated content has a client consumer
**Duncarrow purge.** `content/world/**` is emitted by `content_build`, but it is also
**read by the client** (`content_db.gd` via the harnesses, plus `test_content_db.gd`).
Deleting generated content is therefore a *client* change, not a content change, and will
break client tests.
**The guard:** after any content change, run `PYTHONPATH=tools python3 -m content_build
--check`, then `git diff --stat dev...HEAD -- content/world content/server` and confirm
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.
- Changed an **arity or parameter order**? Grep the *callers*, not the field name.
- Anything **seeded**? Pin the stream with a golden vector.
- 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.