diff --git a/docs/superpowers/specs/2026-07-13-character-creation-ui-design.md b/docs/superpowers/specs/2026-07-13-character-creation-ui-design.md new file mode 100644 index 0000000..c111346 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-character-creation-ui-design.md @@ -0,0 +1,337 @@ +# 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. `Chip` was built for one state, and `PrimaryCTA` +has no `disabled`. Both are extended in **`build_game_theme.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.