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.
2174 lines
79 KiB
Markdown
2174 lines
79 KiB
Markdown
# 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).
|