Compare commits
2 Commits
b4b72e472e
...
7a74a8de94
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a74a8de94 | |||
| 333d901931 |
2173
docs/superpowers/plans/2026-07-13-character-creation-ui.md
Normal file
2173
docs/superpowers/plans/2026-07-13-character-creation-ui.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
Reference in New Issue
Block a user