From 7a74a8de94af6e6b778c44010a084b528612760d Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Mon, 13 Jul 2026 07:07:36 -0500 Subject: [PATCH] =?UTF-8?q?docs(plan):=20M4-b=20=E2=80=94=20the=20creation?= =?UTF-8?q?=20screen,=20seven=20tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../plans/2026-07-13-character-creation-ui.md | 2173 +++++++++++++++++ ...2026-07-13-character-creation-ui-design.md | 9 +- 2 files changed, 2179 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-13-character-creation-ui.md diff --git a/docs/superpowers/plans/2026-07-13-character-creation-ui.md b/docs/superpowers/plans/2026-07-13-character-creation-ui.md new file mode 100644 index 0000000..25e356c --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-character-creation-ui.md @@ -0,0 +1,2173 @@ +# M4-b — Character Creation Screen 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:** Build the mock's character creation screen over M4-a's proven model — race, calling, rolled attributes, a +3 spend pool, proficiency picks, a name — and emit a validated creation `Dictionary`. + +**Architecture:** A pure `CreationDraft` (`RefCounted`, no nodes) holds every rule; a pure static `CreationCopy` formats mechanics into display strings by reading the rules tables; the `.tscn` owns the layout (ADR 0001) and `character_creation.gd` does nothing but translate clicks into draft mutations and bind the draft into authored nodes. The screen displays the rolled attributes by calling the **same** `NewGame.roll_attributes(seed)` that `construct` calls — so it shows real numbers while only ever handing over a seed (§2). + +**Tech Stack:** Godot 4.7 / GDScript. GUT headless (`client/run_tests.sh`). Hand-written JSON content. No network, no AI call, no API change — this milestone touches `client/` and `content/` only. + +**Spec:** `docs/superpowers/specs/2026-07-13-character-creation-ui-design.md` — read it before starting. Everything below implements it. + +--- + +## Global Constraints + +- **Charter §2 — code owns state.** The screen never passes an attribute value to anything. It passes a **seed** and four choices. `construct` re-rolls from the seed itself. +- **Charter §7 — LCK is invisible.** No LCK value, descriptor, tooltip, or accessor anywhere in the creation scene. The ability row has **five** cards, never six. +- **Charter §10 — seeded.** `roll_attributes(seed)` is the single implementation of the roll. `construct` calls it. There must never be a second one. +- **Charter §13 — authored text is content.** A missing `fragment` degrades to the `blurb`. Nothing on this screen ever shows an error dialog. +- **Charter §16 / ADR 0001 — editor-first UI.** The node tree lives in the `.tscn`. The script does `@onready` refs, wiring, and binding state into **authored** nodes. **Never build nodes in `_ready()`.** Data-driven repeats are a fixed set of authored nodes; spares hide. +- **No default parameters on the new public `NewGame` functions.** `traps.md` #2: every param of `LogPlayer._init` had a default, so a wrong-arity call compiled clean and bound the wrong values into the wrong slots. Required args only. +- **No hex literals in a script or a `.tscn`.** Colours live in `palette.gd`; the theme is generated by `build_game_theme.gd` and the `.tres` is a committed artifact. Never hand-edit the `.tres`. +- **The client suite must be green at the end of every task.** From `client/`: `./run_tests.sh` (**250 tests before this plan**; the `-gtest=` flag is ignored — `.gutconfig.json` always runs the whole suite). +- **The content build must stay green.** From the repo root: `PYTHONPATH=tools python3 -m content_build --check`. +- **Charter §18 — git.** Code, so it lands on `feat/creation-screen` off `dev`. **Never `master`.** Merge only after the human confirms. +- Godot writes `.uid` sidecars for new scripts and this repo tracks them — **stage any that appear.** +- **Ollama is not installed on this machine.** Nothing in this plan needs it. If a task seems to need a model call, you have misread the plan. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `client/scripts/newgame/new_game.gd` | **modify.** `roll_attributes()` + `validate()` become public; `construct` calls `roll_attributes` instead of its own loop. | +| `content/world/races/*.json` (4) | **modify.** Each gains a `fragment`. | +| `content/world/callings/*.json` (7) | **modify.** Each gains a `fragment`. | +| `client/scripts/ui/creation/creation_copy.gd` | **new.** `CreationCopy` — pure static. Formats mechanics into display strings **by reading the rules tables**. | +| `client/scripts/ui/creation/creation_draft.gd` | **new.** `CreationDraft` — pure, node-free. Every rule the screen enforces. | +| `client/scripts/theme/palette.gd` | **modify.** One colour: the muted section-label ink. | +| `client/scripts/theme/theme_keys.gd` | **modify.** Six new variations. | +| `client/scripts/theme/build_game_theme.gd` | **modify.** Builds them. | +| `client/assets/theme/game_theme.tres` | **regenerate.** Committed artifact. | +| `client/scenes/creation/CharacterCreation.tscn` | **new.** The screen. | +| `client/scripts/ui/creation/character_creation.gd` | **new.** The binder. No rules. | +| `client/tests/unit/test_creation_copy.gd` | **new.** | +| `client/tests/unit/test_creation_draft.gd` | **new.** | +| `client/tests/unit/test_character_creation_screen.gd` | **new.** | +| `client/tests/unit/test_new_game.gd` | **modify.** The pipeline trap test (§9, trap 1). | +| `client/tests/unit/test_content_db.gd` | **modify.** Fragment parity. | +| `docs/roadmap.md` | **modify.** M4-b → ✅; and the Title screen (M3) is already built but still reads `○`. | + +**Task order and why.** `NewGame` first (T1) because both the draft and the screen depend on `roll_attributes` existing. Content (T2) next because it is authoring, is independent, and unblocks nothing else. `CreationCopy` (T3) and `CreationDraft` (T4) are pure and testable with no scene. The theme (T5) must land before the scene, because the scene sets variations by name and the `.tres` must already contain them. The scene (T6) is last because it consumes all of the above. T7 closes the loop: the trap-1 pipeline test at the draft level, the docs, and the human's F6 gate. + +--- + +## Task 0: Branch + +- [ ] **Step 1: Cut the branch off `dev`** + +```bash +cd /home/ptarrant/repos/ptarrant/code_of_conquest_dnd +git checkout dev +git status --porcelain # expect: clean. If client/project.godot shows dirty, ask the human — it is an editor rewrite, not yours. +git pull --ff-only 2>/dev/null || true +git checkout -b feat/creation-screen +``` + +- [ ] **Step 2: Confirm the suite is green before you touch anything** + +```bash +cd client && ./run_tests.sh +``` + +Expected: `250` passing, 0 failing. **If it is not green now, stop** — you cannot tell your breakage from a pre-existing one. + +--- + +## Task 1: `NewGame` — one public roll, one public validate + +The screen must *display* the rolled attributes without *owning* them. The only honest way is a public pure function both the screen and `construct` call. + +**Files:** +- Modify: `client/scripts/newgame/new_game.gd` +- Test: `client/tests/unit/test_new_game.gd` + +- [ ] **Step 1: Write the failing tests** + +Append to `client/tests/unit/test_new_game.gd`: + +```gdscript +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_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") +``` + +- [ ] **Step 2: Run the tests and watch them fail** + +```bash +cd client && ./run_tests.sh +``` + +Expected: FAIL — `Invalid call. Nonexistent function 'roll_attributes' in base 'GDScript'` (and the same for `validate`). + +- [ ] **Step 3: Make the two functions public and delete the second roll** + +In `client/scripts/newgame/new_game.gd`: + +Replace the attribute-roll loop inside `construct` (currently lines 31–35, the comment block plus the `for stat in Attributes.IDS:` loop) with a call: + +```gdscript + # 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) +``` + +Add these three functions (replacing the private `_validate` header and the private `_roll_attribute`): + +```gdscript +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) +``` + +Keep `_validate` and `_roll_attribute` exactly as they are — `validate` and `roll_attributes` are the public doors onto them. + +> **Why `roll_attributes` takes `character_seed`, not `seed`:** `seed` is a global GDScript function (`seed(int)`). Shadowing it in a static function is legal but reads as a bug. Do not rename it later. + +- [ ] **Step 4: Run the tests and watch them pass** + +```bash +cd client && ./run_tests.sh +``` + +Expected: PASS, 254 tests. **The pre-existing `test_golden_vector_pins_the_rng_stream` must still pass** — if it went red, you changed the RNG order and every seed in the world now builds a different character. Revert and re-read. + +- [ ] **Step 5: Prove the trap-1 test can actually fail** + +`traps.md` #1: *a regression test that cannot fail against the bug is not a regression test.* Prove it. + +Temporarily break `roll_attributes` — change `rng.seed = character_seed` to `rng.seed = character_seed + 1` — and run: + +```bash +cd client && ./run_tests.sh +``` + +Expected: `test_construct_builds_exactly_what_roll_attributes_showed` **FAILS**. If it passes, your test is decoration; fix it before continuing. **Then revert the break** and confirm green again. + +- [ ] **Step 6: Commit** + +```bash +git add client/scripts/newgame/new_game.gd client/tests/unit/test_new_game.gd +git commit -m "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)." +``` + +--- + +## Task 2: The eleven fragments + +The DM origin panel composes `{race.fragment} Now you carry a {calling.name}'s work — {calling.fragment}`. + +**Files:** +- Modify: `content/world/races/{human,elf,dwarf,beastfolk}.json` +- Modify: `content/world/callings/{sellsword,reaver,cutpurse,trapper,hedge_mage,bonesetter,bloodsworn}.json` +- Test: `client/tests/unit/test_content_db.gd` + +- [ ] **Step 1: Write the failing parity test** + +Append to `client/tests/unit/test_content_db.gd`: + +```gdscript +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. + for id in Races.IDS: + assert_true(db.race(id).has("fragment"), "race %s has no fragment" % id) + assert_ne(str(db.race(id).get("fragment", "")).strip_edges(), "", + "race %s has an empty fragment" % id) + for id in Callings.IDS: + assert_true(db.calling(id).has("fragment"), "calling %s has no fragment" % id) + assert_ne(str(db.calling(id).get("fragment", "")).strip_edges(), "", + "calling %s has an empty fragment" % id) +``` + +- [ ] **Step 2: Run it and watch it fail** + +```bash +cd client && ./run_tests.sh +``` + +Expected: FAIL — `race human has no fragment`. + +- [ ] **Step 3: Author the eleven fragments** + +**REQUIRED SUB-SKILL: use the `world-building` skill for this step.** These are eleven pieces of real prose that form the first sentence the player ever reads. They must carry the Margreave's voice — hard, uncaring, warm only when warmth is earned (charter §3: gritty, not grim; the narrator never winks). + +Constraints the skill must be told: + +- A **race fragment** is a complete sentence about where you come from. It ends with a period. The panel puts it first. +- A **calling fragment** is a *clause* that completes `Now you carry a {Calling}'s work — {fragment}`. It is lowercase at the start and ends with a period. +- Second person. No proper nouns that are not already canon. Nothing "hilariously" anything. +- Do not touch `blurb`. Do not add any mechanic (`hit_die`, `saves`, `armor`, …) — `test_blurb_content_carries_no_mechanics` will fail you, and it is right to. + +The resulting shape, for every one of the eleven files: + +```json +{ + "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": "quick fingers, quicker exits, and a knife for the rest." +} +``` + +- [ ] **Step 4: Run the tests and the content build** + +```bash +cd client && ./run_tests.sh +cd .. && PYTHONPATH=tools python3 -m content_build --check +``` + +Expected: client PASS (255). Content build PASS. + +- [ ] **Step 5: Commit** + +```bash +git add content/world/races content/world/callings client/tests/unit/test_content_db.gd +git commit -m "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." +``` + +--- + +## Task 3: `CreationCopy` — mechanics are read, never retyped + +**Files:** +- Create: `client/scripts/ui/creation/creation_copy.gd` +- Test: `client/tests/unit/test_creation_copy.gd` + +- [ ] **Step 1: Write the failing test** + +Create `client/tests/unit/test_creation_copy.gd`: + +```gdscript +extends "res://addons/gut/test.gd" + + +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. + 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, Callings.armor(id)) + assert_string_contains(line, Callings.talent(id)) + assert_string_contains(line, str(Callings.skill_count(id))) + for s in Callings.saves(id): + assert_string_contains(line, s.to_upper()) + + +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")) +``` + +- [ ] **Step 2: Run it and watch it fail** + +```bash +cd client && ./run_tests.sh +``` + +Expected: FAIL — `Identifier "CreationCopy" not declared in the current scope`. + +- [ ] **Step 3: Write `CreationCopy`** + +Create `client/scripts/ui/creation/creation_copy.gd`: + +```gdscript +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", +} + + +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.get(Callings.armor(id), Callings.armor(id)), + resource_word(id), + " + ".join(saves), + Callings.skill_count(id), + Callings.talent(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("_", " ") +``` + +- [ ] **Step 4: Run the tests and watch them pass** + +```bash +cd client && ./run_tests.sh +``` + +Expected: PASS, 261 tests. + +- [ ] **Step 5: Commit** + +```bash +git add client/scripts/ui/creation/creation_copy.gd client/scripts/ui/creation/creation_copy.gd.uid client/tests/unit/test_creation_copy.gd +git commit -m "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." +``` + +--- + +## Task 4: `CreationDraft` — every rule the screen enforces + +**Files:** +- Create: `client/scripts/ui/creation/creation_draft.gd` +- Test: `client/tests/unit/test_creation_draft.gd` + +- [ ] **Step 1: Write the failing test** + +Create `client/tests/unit/test_creation_draft.gd`: + +```gdscript +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_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")) +``` + +- [ ] **Step 2: Run it and watch it fail** + +```bash +cd client && ./run_tests.sh +``` + +Expected: FAIL — `Identifier "CreationDraft" not declared in the current scope`. + +- [ ] **Step 3: Write `CreationDraft`** + +Create `client/scripts/ui/creation/creation_draft.gd`: + +```gdscript +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. + +const NewGame = preload("res://scripts/newgame/new_game.gd") +const POOL := 3 # mirrors NewGame.SPEND_POOL; validate() is the authority + +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: + return 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 + 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() +``` + +- [ ] **Step 4: Run the tests and watch them pass** + +```bash +cd client && ./run_tests.sh +``` + +Expected: PASS, 277 tests. + +- [ ] **Step 5: Prove the trap-1 test can fail here too** + +Temporarily break `final()` — change `out[stat] = int(out[stat]) + int(spend[stat])` to `+ 0` — and run the suite. + +Expected: `test_to_creation_round_trips_through_construct` **FAILS** (and `test_final_is_rolled_plus_spend` with it). If the round-trip test stays green, it is not asserting across the pipeline and must be fixed. **Revert the break**, re-run, confirm green. + +- [ ] **Step 6: Commit** + +```bash +git add client/scripts/ui/creation/creation_draft.gd client/scripts/ui/creation/creation_draft.gd.uid client/tests/unit/test_creation_draft.gd +git commit -m "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." +``` + +--- + +## Task 5: The theme — five new variations, one new colour + +The scene sets variations **by name**, so the `.tres` must contain them before the scene exists. + +**Files:** +- Modify: `client/scripts/theme/palette.gd` +- Modify: `client/scripts/theme/theme_keys.gd` +- Modify: `client/scripts/theme/build_game_theme.gd` +- Regenerate: `client/assets/theme/game_theme.tres` +- Test: `client/tests/unit/test_theme_resource.gd` + +- [ ] **Step 1: Write the failing test** + +Append to `client/tests/unit/test_theme_resource.gd`: + +```gdscript +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) +``` + +- [ ] **Step 2: Run it and watch it fail** + +```bash +cd client && ./run_tests.sh +``` + +Expected: FAIL — `Invalid access to property or key 'SELECT_CARD' on a base object of type 'GDScript'`. + +- [ ] **Step 3: Add the colour** + +In `client/scripts/theme/palette.gd`, in the **ink** block (after `INK_GOLD`): + +```gdscript +const INK_LABEL_MUTED := Color("8a7748") # section labels on parchment (creation sheet) +``` + +- [ ] **Step 4: Add the variation names** + +In `client/scripts/theme/theme_keys.gd`, after `DARK_PANEL`: + +```gdscript +const SELECT_CARD := &"SelectCard" # race/calling card: a Button whose `pressed` is CHOSEN +const SKILL_CHIP := &"SkillChip" # toggle: unpicked / picked / granted-so-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 +``` + +Add to the **font roles** block, next to `MONO` (a font role, so it stays **out** of `ALL`, exactly as `HEADING`/`ACCENT`/`MONO` do): + +```gdscript +const SECTION_LABEL := &"SectionLabel" +``` + +Add to `ALL` (stylebox variations + their base type): + +```gdscript + SELECT_CARD: "Button", + SKILL_CHIP: "Button", + CHOSEN_TAG: "Label", + PRIMARY_TAG: "Label", + PRIMARY_CARD: "PanelContainer", +``` + +- [ ] **Step 5: Build them** + +In `client/scripts/theme/build_game_theme.gd`, add three calls to `build_theme()` (after `_chip(theme)`): + +```gdscript + _section_label(theme) + _select_card(theme) + _skill_chip(theme) + _tags_and_primary(theme) +``` + +And the four functions (place them after `_chip`): + +```gdscript +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, 13) + 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, 12) + 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 _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, _flat(Palette.BLOOD, Palette.BLOOD, 1, 5)) + theme.set_font(&"font", chosen, load(MONO)) + theme.set_font_size(&"font_size", chosen, 10) + 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, _flat(Palette.INK_GOLD, Palette.INK_GOLD, 1, 5)) + theme.set_font(&"font", primary_tag, load(MONO)) + theme.set_font_size(&"font_size", primary_tag, 9) + theme.set_color(&"font_color", primary_tag, Palette.CREAM_BRIGHT) + + theme.set_type_variation(ThemeKeys.PRIMARY_CARD, "PanelContainer") + theme.set_stylebox(&"panel", ThemeKeys.PRIMARY_CARD, + _flat(Palette.PARCHMENT_CARD, Palette.INK_GOLD, 2, 12)) +``` + +- [ ] **Step 6: Regenerate the `.tres` — never hand-edit it** + +```bash +cd client && godot --headless -s res://scripts/theme/build_game_theme.gd +``` + +Expected: `wrote res://assets/theme/game_theme.tres` + +- [ ] **Step 7: Run the tests and watch them pass** + +```bash +cd client && ./run_tests.sh +``` + +Expected: PASS, 279 tests. `test_every_variation_resolves` and `test_committed_tres_matches_builder` (both pre-existing) now cover the five new variations for free, because they iterate `ThemeKeys.ALL`. + +- [ ] **Step 8: Commit** + +```bash +git add client/scripts/theme/ client/assets/theme/game_theme.tres client/tests/unit/test_theme_resource.gd +git commit -m "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." +``` + +--- + +## Task 6: The screen + +**Files:** +- Create: `client/scenes/creation/CharacterCreation.tscn` +- Create: `client/scripts/ui/creation/character_creation.gd` +- Test: `client/tests/unit/test_character_creation_screen.gd` + +> **ADR 0001.** The node tree lives in the `.tscn`. The script does `@onready` refs, signal wiring, and **binding the draft into authored nodes**. It creates **no nodes**. The repeats (4 race cards, 7 calling cards, 6 pool chips, 9 bonus chips, 5 ability cards) are a **fixed set of authored nodes**; spares hide. The node **paths below are load-bearing** — the tests read them. + +- [ ] **Step 1: Write the failing test** + +Create `client/tests/unit/test_character_creation_screen.gd`: + +```gdscript +extends "res://addons/gut/test.gd" + +const SCENE := "res://scenes/creation/CharacterCreation.tscn" +const ContentDB = preload("res://scripts/content/content_db.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 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") + assert_gt(s.size.x, 0.0, "viewport-fit sized the root (the M3-a collapse guard)") + + +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. + var s := _screen() + assert_eq(s._ability_cards.size(), 5) + for card in s._ability_cards: + assert_false(card.name.to_lower().contains("lck")) + + +func test_no_node_on_the_screen_says_luck(): + var s := _screen() + for node in s.find_children("*", "Label", true, false): + assert_false(node.text.to_lower().contains("luck"), "%s leaks Luck (§7)" % node.name) + assert_false(node.text.to_lower().contains("lck"), "%s leaks Luck (§7)" % node.name) + + +func test_only_the_origin_s_allowed_callings_are_shown(): + var s := _screen() + var shown := 0 + for card in s._calling_cards: + if card.visible: + shown += 1 + assert_eq(shown, s.origin["build_constraints"]["allowed_callings"].size()) + + +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(), "") + + s._complete_a_valid_draft_for_test() + 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_pressing_enter_emits_exactly_the_draft_s_creation_dict(): + var s := _screen() + s._complete_a_valid_draft_for_test() + watch_signals(s) + s._on_enter_pressed() + 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() + s._complete_a_valid_draft_for_test() + for i in range(Attributes.IDS.size()): + var stat: String = Attributes.IDS[i] + var value_label: Label = s._ability_cards[i].get_node("Box/Value") + assert_eq(value_label.text, str(int(s.draft.final()[stat])), + "%s shows what the draft holds" % stat) + + +func test_reroll_changes_what_is_on_screen(): + var s := _screen() + var before := s.draft.character_seed + s._on_reroll_pressed() + assert_ne(s.draft.character_seed, before) + assert_eq(s._points.text, "points left: 3", "a re-roll returns the pool") +``` + +- [ ] **Step 2: Run it and watch it fail** + +```bash +cd client && ./run_tests.sh +``` + +Expected: FAIL — the scene does not exist (`Cannot load resource at path res://scenes/creation/CharacterCreation.tscn`). + +- [ ] **Step 3: Author the scene** + +Create `client/scenes/creation/CharacterCreation.tscn`. Containers do the layout — do **not** hand-place absolute offsets except on the root (which needs a fixed size and **no full-rect anchors**, per the M3-a collapse lesson). + +``` +[gd_scene load_steps=5 format=3] + +[ext_resource type="Script" path="res://scripts/ui/creation/character_creation.gd" id="1"] +[ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="2"] +[ext_resource type="PackedScene" path="res://scenes/theme/surfaces/DarkBay.tscn" id="3"] +[ext_resource type="PackedScene" path="res://scenes/theme/surfaces/ParchmentPanel.tscn" id="4"] + +[node name="CharacterCreation" type="Control"] +layout_mode = 3 +anchors_preset = 0 +offset_right = 1920.0 +offset_bottom = 1080.0 +theme = ExtResource("2") +script = ExtResource("1") + +[node name="Split" type="HBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +theme_override_constants/separation = 0 + +[node name="Bay" type="Control" parent="Split"] +custom_minimum_size = Vector2(660, 1080) +layout_mode = 2 + +[node name="Backdrop" parent="Split/Bay" instance=ExtResource("3")] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Col" type="VBoxContainer" parent="Split/Bay"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 34.0 +offset_top = 30.0 +offset_right = -34.0 +offset_bottom = -34.0 +theme_override_constants/separation = 16 + +[node name="Kicker" type="Label" parent="Split/Bay/Col"] +layout_mode = 2 +theme_type_variation = &"Mono" +text = "WHO WILL YOU BE?" + +[node name="ModelSlot" type="PanelContainer" parent="Split/Bay/Col"] +custom_minimum_size = Vector2(0, 540) +layout_mode = 2 +size_flags_vertical = 3 +theme_type_variation = &"ItemTileEmpty" + +[node name="Label" type="Label" parent="Split/Bay/Col/ModelSlot"] +layout_mode = 2 +horizontal_alignment = 1 +vertical_alignment = 1 +theme_type_variation = &"Mono" +text = "CHARACTER +MODEL" + +[node name="Nameplate" type="VBoxContainer" parent="Split/Bay/Col"] +layout_mode = 2 +theme_override_constants/separation = 4 + +[node name="Name" type="Label" parent="Split/Bay/Col/Nameplate"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"Accent" +theme_override_font_sizes/font_size = 34 +text = "Nameless" + +[node name="Sub" type="Label" parent="Split/Bay/Col/Nameplate"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"TitleKicker" +text = "Human · Sellsword" + +[node name="OriginCard" type="PanelContainer" parent="Split/Bay/Col"] +layout_mode = 2 +theme_type_variation = &"ParchmentCard" + +[node name="Box" type="VBoxContainer" parent="Split/Bay/Col/OriginCard"] +layout_mode = 2 +theme_override_constants/separation = 7 + +[node name="Kicker" type="Label" parent="Split/Bay/Col/OriginCard/Box"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "THE DM SIZES YOU UP" + +[node name="Text" type="RichTextLabel" parent="Split/Bay/Col/OriginCard/Box"] +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +bbcode_enabled = true +fit_content = true +text = "[i]The DM has not yet decided what you are.[/i]" + +[node name="Sheet" type="Control" parent="Split"] +custom_minimum_size = Vector2(1260, 1080) +layout_mode = 2 + +[node name="Backdrop" parent="Split/Sheet" instance=ExtResource("4")] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Body" type="VBoxContainer" parent="Split/Sheet"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 44.0 +offset_top = 30.0 +offset_right = -44.0 +offset_bottom = -30.0 +theme_override_constants/separation = 16 + +[node name="Header" type="VBoxContainer" parent="Split/Sheet/Body"] +layout_mode = 2 + +[node name="Title" type="Label" parent="Split/Sheet/Body/Header"] +layout_mode = 2 +theme_type_variation = &"Heading" +text = "Create Your Character" + +[node name="Kicker" type="Label" parent="Split/Sheet/Body/Header"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "THE WORLD HAS REAL PROBLEMS AND DOES NOT CARE ABOUT YOU" + +[node name="RaceSection" type="VBoxContainer" parent="Split/Sheet/Body"] +layout_mode = 2 +theme_override_constants/separation = 9 + +[node name="Label" type="Label" parent="Split/Sheet/Body/RaceSection"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "RACE" + +[node name="Cards" type="HBoxContainer" parent="Split/Sheet/Body/RaceSection"] +layout_mode = 2 +theme_override_constants/separation = 14 +``` + +**Then, still in the same file**, author these repeated blocks. Every one of them is a real authored node — the script fills them, it never creates them. + +**Four race cards.** For `i` in `0..3`, under `Split/Sheet/Body/RaceSection/Cards`: + +``` +[node name="Race0" type="Button" parent="Split/Sheet/Body/RaceSection/Cards"] +custom_minimum_size = Vector2(0, 108) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +theme_type_variation = &"SelectCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/RaceSection/Cards/Race0"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 15.0 +offset_top = 13.0 +offset_right = -15.0 +offset_bottom = -13.0 +mouse_filter = 2 +theme_override_constants/separation = 5 + +[node name="Name" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race0/Box"] +layout_mode = 2 +mouse_filter = 2 +text = "Human" + +[node name="Blurb" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race0/Box"] +layout_mode = 2 +size_flags_vertical = 3 +mouse_filter = 2 +autowrap_mode = 3 +theme_override_font_sizes/font_size = 14 +text = "…" + +[node name="Trait" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race0/Box"] +layout_mode = 2 +mouse_filter = 2 +autowrap_mode = 3 +theme_type_variation = &"SectionLabel" +text = "…" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race0"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -78.0 +offset_top = -11.0 +offset_right = -12.0 +offset_bottom = 9.0 +grow_horizontal = 0 +mouse_filter = 2 +theme_type_variation = &"ChosenTag" +text = "CHOSEN" +``` + +Repeat verbatim for `Race1`, `Race2`, `Race3` (change only the node name and every `parent=` path). **`mouse_filter = 2` on every child is load-bearing** — without it the labels swallow the click and the card never toggles. + +**Seven calling cards** under a `CallingSection`, same pattern but with **no blurb** (variant C — the blurb lives in the detail panel): + +``` +[node name="CallingSection" type="VBoxContainer" parent="Split/Sheet/Body"] +layout_mode = 2 +theme_override_constants/separation = 9 + +[node name="Label" type="Label" parent="Split/Sheet/Body/CallingSection"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "CALLING" + +[node name="Cards" type="HBoxContainer" parent="Split/Sheet/Body/CallingSection"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Calling0" type="Button" parent="Split/Sheet/Body/CallingSection/Cards"] +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +theme_type_variation = &"SelectCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/CallingSection/Cards/Calling0"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Name" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling0/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_override_font_sizes/font_size = 16 +text = "Sellsword" + +[node name="Chip" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling0/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "STR" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling0"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -33.0 +offset_top = -11.0 +offset_right = 33.0 +offset_bottom = 9.0 +grow_horizontal = 2 +mouse_filter = 2 +theme_type_variation = &"ChosenTag" +text = "CHOSEN" +``` + +Repeat for `Calling1` … `Calling6`. Then the detail panel, a sibling of `Cards` under `CallingSection`: + +``` +[node name="Detail" type="PanelContainer" parent="Split/Sheet/Body/CallingSection"] +layout_mode = 2 +theme_type_variation = &"ParchmentCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/CallingSection/Detail"] +layout_mode = 2 +theme_override_constants/separation = 7 + +[node name="Blurb" type="RichTextLabel" parent="Split/Sheet/Body/CallingSection/Detail/Box"] +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +bbcode_enabled = true +fit_content = true +text = "[i]…[/i]" + +[node name="Mechanics" type="Label" parent="Split/Sheet/Body/CallingSection/Detail/Box"] +layout_mode = 2 +autowrap_mode = 3 +theme_type_variation = &"SectionLabel" +text = "…" +``` + +**The proficiencies section.** Six pool chips (the Cutpurse's pool of 6 is the widest in the game) and nine bonus chips (all nine skills): + +``` +[node name="SkillSection" type="VBoxContainer" parent="Split/Sheet/Body"] +layout_mode = 2 +theme_override_constants/separation = 9 + +[node name="Label" type="Label" parent="Split/Sheet/Body/SkillSection"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "PROFICIENCIES" + +[node name="PoolRow" type="HBoxContainer" parent="Split/Sheet/Body/SkillSection"] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="Count" type="Label" parent="Split/Sheet/Body/SkillSection/PoolRow"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "0 of 2 chosen" + +[node name="Pool0" type="Button" parent="Split/Sheet/Body/SkillSection/PoolRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "stealth" + +[node name="BonusRow" type="HBoxContainer" parent="Split/Sheet/Body/SkillSection"] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="Label" type="Label" parent="Split/Sheet/Body/SkillSection/BonusRow"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "HUMAN — ONE MORE, ANY SKILL" + +[node name="Bonus0" type="Button" parent="Split/Sheet/Body/SkillSection/BonusRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "athletics" +``` + +Repeat `Pool0` for `Pool1` … `Pool5` (6 total) and `Bonus0` for `Bonus1` … `Bonus8` (9 total), same properties, placeholder `text` differing only so the scene previews. + +**The ability row.** Five cards, one per `Attributes.IDS` entry, in that order: + +``` +[node name="AbilitySection" type="VBoxContainer" parent="Split/Sheet/Body"] +layout_mode = 2 +theme_override_constants/separation = 9 + +[node name="Head" type="HBoxContainer" parent="Split/Sheet/Body/AbilitySection"] +layout_mode = 2 +theme_override_constants/separation = 14 + +[node name="Label" type="Label" parent="Split/Sheet/Body/AbilitySection/Head"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "ABILITY SCORES" + +[node name="Points" type="Label" parent="Split/Sheet/Body/AbilitySection/Head"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "points left: 3" + +[node name="Reroll" type="Button" parent="Split/Sheet/Body/AbilitySection/Head"] +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "⟳ re-roll" + +[node name="Hint" type="Label" parent="Split/Sheet/Body/AbilitySection/Head"] +layout_mode = 2 +theme_type_variation = &"Accent" +theme_override_font_sizes/font_size = 15 +text = "rolled 3d6 — that roll is your floor, spend up only" + +[node name="Cards" type="HBoxContainer" parent="Split/Sheet/Body/AbilitySection"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Ab0" type="PanelContainer" parent="Split/Sheet/Body/AbilitySection/Cards"] +custom_minimum_size = Vector2(0, 132) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"ParchmentCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0"] +layout_mode = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Key" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "STR" + +[node name="Value" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"Accent" +theme_override_font_sizes/font_size = 36 +text = "10" + +[node name="Rolled" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "rolled 10" + +[node name="Buttons" type="HBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0/Box"] +layout_mode = 2 +alignment = 1 +theme_override_constants/separation = 6 + +[node name="Minus" type="Button" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0/Box/Buttons"] +custom_minimum_size = Vector2(36, 28) +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "−" + +[node name="Plus" type="Button" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0/Box/Buttons"] +custom_minimum_size = Vector2(36, 28) +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "+" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -32.0 +offset_top = -10.0 +offset_right = 32.0 +offset_bottom = 8.0 +grow_horizontal = 2 +mouse_filter = 2 +theme_type_variation = &"PrimaryTag" +text = "PRIMARY" +``` + +Repeat for `Ab1` (DEX), `Ab2` (CON), `Ab3` (FTH), `Ab4` (MAG). **There is no `Ab5`. There is never an `Ab5`.** (§7) + +**The footer:** + +``` +[node name="Footer" type="HBoxContainer" parent="Split/Sheet/Body"] +layout_mode = 2 +size_flags_vertical = 10 +theme_override_constants/separation = 20 + +[node name="NameBox" type="VBoxContainer" parent="Split/Sheet/Body/Footer"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 9 + +[node name="Label" type="Label" parent="Split/Sheet/Body/Footer/NameBox"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "NAME" + +[node name="NameEdit" type="LineEdit" parent="Split/Sheet/Body/Footer/NameBox"] +layout_mode = 2 +placeholder_text = "What do they call you?" + +[node name="CtaBox" type="VBoxContainer" parent="Split/Sheet/Body/Footer"] +layout_mode = 2 +theme_override_constants/separation = 6 + +[node name="Enter" type="Button" parent="Split/Sheet/Body/Footer/CtaBox"] +custom_minimum_size = Vector2(280, 56) +layout_mode = 2 +disabled = true +theme_type_variation = &"PrimaryCTA" +text = "ENTER THE WORLD" + +[node name="Error" type="Label" parent="Split/Sheet/Body/Footer/CtaBox"] +layout_mode = 2 +horizontal_alignment = 1 +autowrap_mode = 3 +theme_type_variation = &"SectionLabel" +text = "no second chances out there" +``` + +- [ ] **Step 4: Write the binder** + +Create `client/scripts/ui/creation/character_creation.gd`: + +```gdscript +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) + +const ContentDB = preload("res://scripts/content/content_db.gd") + +## 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: HBoxContainer = $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]) + draft.set_calling(_allowed_callings()[0]) + _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] + chip.visible = true + chip.text = 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 = draft.is_granted(skill) 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) + card.get_node("Tag").visible = (stat == primary) + card.theme_type_variation = ThemeKeys.PRIMARY_CARD if stat == primary else ThemeKeys.PARCHMENT_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: + var errors := draft.errors(origin, world) + _enter.disabled = not errors.is_empty() + _error.text = str(errors[0]) if not errors.is_empty() else CTA_HINT + + +# ---------------------------------------------------------------- test seam + +func _complete_a_valid_draft_for_test() -> void: + ## Drives the draft to a legal state the way a player would. Test-only, and it + ## touches nothing the player's own clicks do not. + draft.name = "Aldric" + draft.set_race("human") + draft.set_calling("sellsword") + for skill in draft.skill_pool(): + if draft.can_pick(skill): + draft.toggle_skill(skill) + for skill in Skills.IDS: + if draft.can_take_bonus(skill): + draft.set_bonus_skill(skill) + break + _bind() +``` + +- [ ] **Step 5: Run the tests and watch them pass** + +```bash +cd client && ./run_tests.sh +``` + +Expected: PASS, 289 tests. + +If `test_no_node_on_the_screen_says_luck` fails, you have leaked LCK onto the screen and §7 is broken — that is not a test to loosen. + +If the race/calling cards do not toggle, you dropped `mouse_filter = 2` from a child Label and it is eating the click. + +- [ ] **Step 6: Commit** + +```bash +git add client/scenes/creation client/scripts/ui/creation/character_creation.gd client/scripts/ui/creation/character_creation.gd.uid client/tests/unit/test_character_creation_screen.gd +git commit -m "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)." +``` + +--- + +## Task 7: Close the loop + +**Files:** +- Modify: `docs/roadmap.md` +- Modify: `client/docs/README.md` + +- [ ] **Step 1: Run the whole suite and the content build one more time** + +```bash +cd client && ./run_tests.sh +cd .. && PYTHONPATH=tools python3 -m content_build --check +``` + +Expected: client 289 passing / 0 failing. Content build green. + +- [ ] **Step 2: The human's F6 gate — this is a REQUIRED step, not a formality** + +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 person. + +Ask the human to open `res://scenes/creation/CharacterCreation.tscn` in the Godot editor and run it (F6), then confirm: + +1. Seven calling cards in one row; clicking one shows its blurb + mechanics below. +2. Clicking a race card updates the DM origin panel in the left bay, live. +3. Choosing Human reveals the bonus-skill row; choosing Dwarf hides it. +4. Elf + Cutpurse ⇒ the `perception` chip is **dim and unclickable** (the race already granted it). +5. `+` stops at three points; `−` greys out at the rolled base. +6. `⟳ re-roll` changes the numbers and returns the pool to 3. +7. ENTER THE WORLD is dead until the draft is legal, and the reason underneath is legible. +8. **There is no sixth ability card, and the word "luck" appears nowhere.** + +Note: `DarkBay` and `ParchmentPanel` set their `ShaderMaterial` in `_ready()`, so their texture appears **only on an F6 run**, not in the editor — the editor shows layout on a plain panel. That is expected (ADR 0001). + +**Do not proceed until the human confirms.** Fix what they find, then re-run the suite. + +- [ ] **Step 3: Update the roadmap** + +In `docs/roadmap.md`: + +- Flip **M4-b** from `○` to `✅` with a one-paragraph entry in the house style: what landed, what was reconciled against the mock (the five decisions in the spec's §4), the test count, and the honest seams. +- **The Title screen under M3 still reads `○` but it is built and committed** (`a77bf03`, `f071392`). Flip it to `✅`. M4-c depends on it and the roadmap currently claims it does not exist. +- Leave **M4-c** as `○` — it is next. + +- [ ] **Step 4: Add the creation screen to the client docs** + +In `client/docs/README.md`, under "UI scene conventions," add the creation screen alongside the shell as a reference implementation, and note the one convention it establishes that the shell did not: + +> **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. + +- [ ] **Step 5: Commit** + +```bash +git add docs/roadmap.md client/docs/README.md +git commit -m "docs(roadmap): M4-b lands; the Title screen was already built + +The creation screen is done — 289 client tests, human F6 confirmed. 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." +``` + +- [ ] **Step 6: Hand the branch to the human** + +**Do not merge.** Charter §18: the human does the `--no-ff` merge to `dev` after confirming. Report: + +- the branch name (`feat/creation-screen`), +- the test count before (250) and after (289), +- the F6 findings and what you changed, +- **and anything you had to decide that the plan did not decide for you** — that is the part worth reading. + +--- + +## Notes for the implementer + +**The one thing that must not break.** The screen shows five numbers. Those five numbers must be *exactly* what `NewGame.construct` puts on the sheet. The only reason that is true is that both call `NewGame.roll_attributes(seed)` and the screen never passes a number anywhere. If you find yourself adding an `attributes` key to the creation Dictionary, or caching the roll in the draft, or "just passing the rolled values through so we don't roll twice" — **stop.** That is the §2 breach this whole architecture exists to prevent, and it will pass every test you have except the one in Task 1 Step 5. + +**Rolling twice is not a bug.** It is the design. It costs microseconds. + +**What the player is never told.** The seed drives the five visible attributes *and* the hidden LCK roll, which comes off the same RNG immediately after MAG. Every re-roll silently re-rolls his Luck. Do not add a tooltip. Do not add a hint. Do not "helpfully" surface it in a debug label that ships. The player becoming superstitious *is the feature* (§7). 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 index c111346..8c33790 100644 --- a/docs/superpowers/specs/2026-07-13-character-creation-ui-design.md +++ b/docs/superpowers/specs/2026-07-13-character-creation-ui-design.md @@ -171,9 +171,12 @@ 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). +`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). ---