Files
code_of_conquest_dnd/client/scripts/ui/creation/character_creation.gd
Phillip Tarrant 34bf064904 fix(creation-screen): the bonus-skill hole, the theme guard that could not fail, and the strings the player was reading
Final whole-branch review of M4-b. Six findings.

1. The player could press his way into an invalid draft. can_take_bonus()
   refused an already-picked skill; can_pick() did not consider the bonus, so
   the guard was one-directional. Take athletics as the human's bonus, pick it
   again from the Sellsword's pool, and the draft is one NewGame.validate
   rejects — the invalid draft "he did not cause and cannot see" that
   set_calling's own comment forbids. Fixed in the DRAFT (the screen owns no
   rules): can_pick() now mirrors can_take_bonus(), so the chip goes inert, the
   idiom the pool row already uses for a race-granted skill. Recoverable: hand
   the bonus back and the chip is live again. Both directions tested.

2. The theme drift guard STILL could not fail. ThemeKeys.ALL is stylebox
   variations only, by its own docstring — so the six FONT roles the builder
   sets fonts, sizes and colours for were entirely unguarded. Changing
   Palette.CREAM and skipping the rebuild shipped a stale game_theme.tres with
   the suite green (proven, on the committed test). Adds ThemeKeys.FONT_ROLES
   beside ALL (never inside it — ALL's contract is relied on), compares fonts,
   font sizes and font colours as well as styleboxes, and names the variation
   AND the property that drifted. Plus a meta-guard that walks the builder's own
   output and fails if a variation it styles is in neither collection — this is
   the second time this guard has been fixed by "enumerate the right set", and
   nothing was checking the set.

3. §13: the CTA label piped NewGame.validate's diagnostics straight to the
   player. On the game's SECOND SCREEN he read "hedge_mage picks 2 skills, got
   0". Now CreationCopy.error_line maps them to authored copy — "name yourself",
   "choose two more proficiencies" (the number DERIVED from Callings.skill_count,
   never written down), "one more proficiency, any of them — take it" — with an
   authored fallback for anything unmapped, because §13 means the unmapped case
   still speaks in voice. validate's strings are untouched: they are a contract,
   so they are translated in the presentation layer, not rewritten.

4. §2 had no test that fails if the boundary is breached. The emit test compared
   the signal to draft.to_creation() — both sides move together. Pins the seven
   contract keys exactly, and feeds construct a hostile creation dict carrying an
   18 in every stat, asserting the sheet is still roll_attributes(seed) + spend.
   Every other test in test_new_game passed with that breach in place.

5. Three holes: the orphan sweep skipped PoolRow (a stray Pool6 rendered unbound
   and visible, suite green — the exact bug the test exists to catch, in the one
   row it forgot); the §7 sweep forbade "luck" but not Luck.BANDS' descriptors,
   which are worse than the number (the player would re-roll until his Luck read
   well — calculable Luck); and the DM panel's composition hardcodes the article
   "a", so a vowel-initial calling would break it silently — guarded in the
   content parity test, which names the template as the reason.

6. The calling card printed "talent second_wind". Four of seven talents carry an
   underscore. Humanised, the way skill_label() already does. The test that
   pinned the RAW form is moved to the full derived phrase ("talent second wind")
   so it still fails if someone hardcodes a talent into the format string.

Every guard was proven by re-breaking the code and watching the named test go
red (docs/traps.md). Suite 302 -> 315, content build green, no new warnings.

Report: .superpowers/sdd/final-review-fix-report.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:25:12 -05:00

298 lines
11 KiB
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])
var allowed := _allowed_callings()
if not allowed.is_empty():
draft.set_calling(allowed[0])
# A degenerate origin (zero allowed callings, or no build_constraints at all)
# leaves calling_id unset rather than crashing — §13, never an error thrown
# at the player. The CTA simply stays disabled; there is no calling to invent.
_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:
## The errors array is the VALIDATOR's, written for code. What lands on the
## label is CreationCopy's, written for the player — "choose two more
## proficiencies", not "hedge_mage picks 2 skills, got 0" (§13). The mapping is
## in the presentation layer on purpose: NewGame.validate's strings are a
## contract, and this screen is not the only thing that reads them.
var errors := draft.errors(origin, world)
_enter.disabled = not errors.is_empty()
_error.text = CreationCopy.error_line(str(errors[0]), draft) if not errors.is_empty() else CTA_HINT