Files
code_of_conquest_dnd/docs/superpowers/plans/2026-07-13-character-creation-ui.md
Phillip Tarrant 7a74a8de94 docs(plan): M4-b — the creation screen, seven tasks
TDD task-by-task: NewGame's public seeded roll -> the eleven fragments ->
CreationCopy -> CreationDraft -> the theme -> the scene -> the docs.

Both of traps.md's bugs get a named guard rather than a hope: the pipeline test
that asserts what the screen SHOWED is what construct BUILT (and a step that
re-breaks the code to prove the test can fail), and a no-default-parameters rule
on the two new public functions.

Also corrects the spec: PrimaryCTA already has a disabled stylebox.
2026-07-13 07:07:36 -05:00

79 KiB
Raw Blame History

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
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
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:

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
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 3135, the comment block plus the for stat in Attributes.IDS: loop) with a call:

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

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
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:

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
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:

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
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:

{
  "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
cd client && ./run_tests.sh
cd .. && PYTHONPATH=tools python3 -m content_build --check

Expected: client PASS (255). Content build PASS.

  • Step 5: Commit
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:

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
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:

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
cd client && ./run_tests.sh

Expected: PASS, 261 tests.

  • Step 5: Commit
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:

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
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:

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
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
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:

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
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):

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:

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):

const SECTION_LABEL := &"SectionLabel"

Add to ALL (stylebox variations + their base type):

	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)):

	_section_label(theme)
	_select_card(theme)
	_skill_chip(theme)
	_tags_and_primary(theme)

And the four functions (place them after _chip):

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
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
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
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:

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
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 Calling1Calling6. 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 Pool1Pool5 (6 total) and Bonus0 for Bonus1Bonus8 (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:

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
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
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

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