Compare commits

...

2 Commits

Author SHA1 Message Date
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
2 changed files with 2513 additions and 0 deletions

File diff suppressed because it is too large Load Diff

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.