# 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).