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>
This commit is contained in:
2026-07-13 16:25:12 -05:00
parent 31759ef848
commit 34bf064904
10 changed files with 506 additions and 14 deletions

View File

@@ -31,6 +31,22 @@ const TITLE_LOGO := &"TitleLogo"
const TITLE_KICKER := &"TitleKicker"
const SECTION_LABEL := &"SectionLabel"
## Every FONT role + the base Control type it decorates. Deliberately NOT folded
## into ALL — ALL's "stylebox variations only" contract is relied on elsewhere
## (test_every_variation_resolves, and the styleboxes the screens set). Kept as a
## second enumerable set so a guard that means "every variation the builder
## touches" can iterate BOTH and never silently cover only half of them: the
## drift guard used to iterate ALL alone, so a font, a font size or a font colour
## could change in the builder and the committed .tres stay stale, suite green.
const FONT_ROLES := {
HEADING: "Label",
ACCENT: "Label",
MONO: "Label",
TITLE_LOGO: "Label",
TITLE_KICKER: "Label",
SECTION_LABEL: "Label",
}
## Every variation name + the base Control type it decorates. The builder and the
## test both iterate this so they can never drift apart.
const ALL := {

View File

@@ -287,6 +287,11 @@ func _origin_prose() -> String:
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 = str(errors[0]) if not errors.is_empty() else CTA_HINT
_error.text = CreationCopy.error_line(str(errors[0]), draft) if not errors.is_empty() else CTA_HINT

View File

@@ -17,6 +17,16 @@ const ARMOR_WORD := {
"heavy": "heavy armour",
}
## The one line the player reads when the draft will not hold. §13: a fallback is
## CONTENT, not error handling — so even an error nobody anticipated speaks in the
## DM's voice rather than showing him a validator's grammar.
const UNSPOKEN := "something in this does not hold — look again"
const NUMBER_WORD := {
1: "one", 2: "two", 3: "three", 4: "four", 5: "five",
6: "six", 7: "seven", 8: "eight", 9: "nine",
}
static func calling_detail(id: String) -> String:
## e.g. "d8 · light armour · MP (FTH) · saves DEX + MAG · picks 4 skills · talent backstab"
@@ -31,10 +41,19 @@ static func calling_detail(id: String) -> String:
resource_word(id),
" + ".join(saves),
Callings.skill_count(id),
Callings.talent(id),
talent_word(id),
]
static func talent_word(id: String) -> String:
## second_wind -> "second wind". Four of the seven talent IDS carry an
## underscore, and the card printed the raw id: the player read "talent
## second_wind" off the calling card. Ids are snake_case; humans are not — the
## same reason skill_label() exists. Read from the table at call time, so
## retuning a talent retunes the card.
return Callings.talent(id).replace("_", " ")
static func armor_word(id: String) -> String:
## The card says "no armour", not "none". Public so the test can assert against
## the DERIVED word rather than the raw table value — "none" is not a substring
@@ -74,3 +93,57 @@ static func race_trait(id: String) -> String:
static func skill_label(skill: String) -> String:
## sleight_of_hand -> "sleight of hand". Ids are snake_case; humans are not.
return skill.replace("_", " ")
# ------------------------------------------------------------------- §13: the nag
static func error_line(error: String, draft: CreationDraft) -> String:
## The validator's diagnostics are written for whoever is reading the errors
## array — "hedge_mage picks 2 skills, got 0", "unresolved ref: item:x". They are
## a CONTRACT (other code reads those strings), so they are not rewritten; they
## are TRANSLATED, here, in the presentation layer, where translating them
## belongs. What the player reads on the second screen of the game is authored
## copy: dry, second person, flat. Never an id, never a compiler's grammar (§13).
##
## Anything unmapped degrades to UNSPOKEN — never the raw string, never blank.
if error.begins_with("player name is required"):
return "name yourself"
if error.begins_with("unknown race"):
return "decide what blood you carry"
if error.begins_with("unknown calling"):
return "decide what you do for coin"
if error.begins_with("calling not allowed by origin"):
return "that trade is closed to you — you left it behind"
if error.contains(" picks ") and error.contains(" skills, got "):
return _proficiency_line(draft)
if error.begins_with("duplicate skill pick"):
return "you have taken the same proficiency twice"
if error.contains(" is not in the ") and error.ends_with(" pool"):
return "your calling never taught you that"
if error.contains(" is already granted by "):
return "you have that one already — it buys you nothing"
if error.contains(" must choose a bonus skill"):
return "one more proficiency, any of them — take it"
if error.begins_with("unknown bonus skill"):
return "no one here would call that a skill"
if error.begins_with("bonus skill ") and error.ends_with(" is already proficient"):
return "you have that one already — take another"
if error.contains(" does not get a bonus skill"):
return "your blood grants no proficiency of its own"
return UNSPOKEN
static func _proficiency_line(draft: CreationDraft) -> String:
## "choose two more proficiencies". The NUMBER is derived — Callings.skill_count
## is the rules table's business and the copy asks it, every time, rather than
## carrying a second copy of it that would one day disagree.
var left: int = draft.picks_left()
if left <= 0:
return "you have taken more proficiencies than your calling can carry"
if left == 1:
return "choose one more proficiency"
return "choose %s more proficiencies" % number_word(left)
static func number_word(n: int) -> String:
return str(NUMBER_WORD.get(n, n))

View File

@@ -156,6 +156,14 @@ func is_picked(skill: String) -> bool:
func can_pick(skill: String) -> bool:
if is_granted(skill):
return false
# The mirror of can_take_bonus's `not is_picked(skill)`. Without it the guard is
# one-directional: take athletics as the human's bonus, then pick it AGAIN from
# the calling's pool, and the draft is invalid ("bonus skill athletics is already
# proficient") — the player holding an invalid draft he did not cause and cannot
# see, which is the exact thing set_calling's comment forbids. The chip goes inert
# instead; the bonus row is right there and the player can hand it back.
if skill == bonus_skill:
return false
if skill not in skill_pool():
return false
return picks_left() > 0

View File

@@ -121,6 +121,17 @@ func test_no_node_on_the_screen_says_luck():
var checked_a_rich_text_label := false
var combos_checked := 0
# The word "luck" is not the only thing that leaks Luck. Luck.BANDS holds the
# DESCRIPTORS — "the dice are kind today" — and a descriptor on this screen is
# strictly worse than the number: the player would re-roll until his Luck read
# well, which is Luck made CALCULABLE, the precise §7 failure. It is structurally
# unreachable today (the screen holds no GameState and the draft has no LCK), and
# it costs three lines to keep it that way.
var forbidden: Array = ["luck", "lck"]
for band in Luck.BANDS:
forbidden.append(str(band["text"]).to_lower())
assert_eq(forbidden.size(), Luck.BANDS.size() + 2, "every band's text must be swept, not just the word")
for race_i in range(Races.IDS.size()):
s._race_cards[race_i].pressed.emit()
var allowed: Array = s._allowed_callings()
@@ -137,10 +148,9 @@ func test_no_node_on_the_screen_says_luck():
if typeof(value) != TYPE_STRING:
continue
var lowered: String = value.to_lower()
assert_false(lowered.contains("luck"),
"%s.%s leaks Luck (§7) at %s" % [node.name, prop, combo])
assert_false(lowered.contains("lck"),
"%s.%s leaks Luck (§7) at %s" % [node.name, prop, combo])
for word in forbidden:
assert_false(lowered.contains(word),
"%s.%s leaks Luck (§7) at %s: '%s'" % [node.name, prop, combo, word])
# `checked_a_rich_text_label` alone only proves the sweep REACHED a
# RichTextLabel, not that it had anything in it — an empty label would
@@ -265,6 +275,21 @@ func test_authored_node_counts_match_the_rules_tables():
assert_eq(s._bonus_row.get_child_count(), Skills.IDS.size() + 1,
"an orphan authored bonus chip would sit here, unbound and visible")
# PoolRow was the one row the sweep above forgot — a stray authored "Pool6" would
# render unbound and visible with every assertion so far still green, which is
# exactly the bug this test exists to catch. Its count is not a table LENGTH: it
# is the widest calling's pool (plus its own "Count" label, which is not a chip),
# so derive that from the rules table rather than writing 6 down again.
var widest := 0
for id in Callings.IDS:
widest = maxi(widest, Callings.skill_pool(id).size())
assert_eq(widest, s._pool_chips.size(), "the pool row must hold exactly the widest calling's pool")
assert_eq(s._pool_row.get_child_count(), widest + 1,
"an orphan authored pool chip would sit here, unbound and visible (PoolRow also holds its Count label)")
assert_null(s._pool_row.get_node_or_null("Pool%d" % widest),
"there is no chip past the widest calling's pool — a stray Pool%d renders unbound" % widest)
assert_eq(s._pool_count.get_parent(), s._pool_row, "the +1 above is the Count label, and nothing else")
func test_cta_is_disabled_until_the_draft_is_legal():
var s := _screen()
@@ -278,6 +303,84 @@ func test_cta_is_disabled_until_the_draft_is_legal():
"a legal draft gets the mock's line back, not a blank")
func test_the_nag_under_the_cta_is_authored_copy_and_never_a_validator_string():
# §13. The label used to read `str(errors[0])` — so the second screen of the game
# showed the player "sellsword picks 2 skills, got 0". Drive the screen through
# every invalid state it can actually reach, by pressing the nodes, and pin what
# the label says. Anchored to the authored phrases themselves: "contains no
# underscore" alone would pass on a blank label, or on the wrong copy.
var s := _screen() # human + sellsword, nameless, nothing picked
var seen: Array = []
assert_eq(s._error.text, "name yourself", "the first thing missing is a name")
seen.append(s._error.text)
s._name_edit.text = "Aldric"
s._on_name_changed("Aldric") # LineEdit.text_changed is not raised by setting .text
assert_eq(s._error.text, "choose two more proficiencies",
"the sellsword picks 2 and has none — and the two is DERIVED from Callings.skill_count")
seen.append(s._error.text)
s._pool_chips[0].pressed.emit()
assert_eq(s.draft.skills.size(), 1, "the pick landed, or the line below is about nothing")
assert_eq(s._error.text, "choose one more proficiency", "one is singular")
seen.append(s._error.text)
s._pool_chips[1].pressed.emit()
assert_eq(s.draft.skills.size(), 2)
assert_eq(s._error.text, "one more proficiency, any of them — take it",
"the human's bonus skill, in the Margreave's voice, not 'human must choose a bonus skill'")
seen.append(s._error.text)
for i in range(Skills.IDS.size()):
if s.draft.can_take_bonus(Skills.IDS[i]):
s._bonus_chips[i].pressed.emit()
break
assert_eq(s._error.text, CharacterCreation.CTA_HINT, "a legal draft gets the mock's line back")
# Nothing above may be an engineering string, and none of them may repeat.
assert_eq(seen.size(), 4)
for line in seen:
assert_false(line.contains("_"), "a snake_case id reached the player: %s" % line)
assert_false(line.contains("got "), "the validator's grammar reached the player: %s" % line)
var unique := {}
for line in seen:
unique[line] = true
assert_eq(unique.size(), 4, "each invalid state says a DIFFERENT thing — the nag is telling him what is missing")
func test_a_skill_taken_as_the_bonus_goes_inert_in_the_calling_s_pool():
# The one-directional guard: can_take_bonus() refused an already-PICKED skill, but
# can_pick() ignored the bonus. Human + Sellsword, take athletics as the bonus,
# then press athletics in the pool — the chip was live and the pick LANDED, leaving
# a draft NewGame.validate rejects ("bonus skill athletics is already proficient").
var s := _screen() # human + sellsword
var pool: Array = s.draft.skill_pool()
var athletics_i: int = pool.find("athletics")
var bonus_i: int = Skills.IDS.find("athletics")
assert_ne(athletics_i, -1, "athletics must be in the sellsword's pool or this proves nothing")
assert_false(s._pool_chips[athletics_i].disabled, "the chip starts live, or the assertion below is trivially true")
s._bonus_chips[bonus_i].pressed.emit()
assert_eq(s.draft.bonus_skill, "athletics")
assert_true(s._pool_chips[athletics_i].disabled,
"a skill the bonus already bought must render INERT in the pool")
# Button.pressed fires even on a disabled Button — the rule lives in the draft, not
# in the styling, so press it and prove the pick does not land.
s._pool_chips[athletics_i].pressed.emit()
assert_false(s.draft.is_picked("athletics"), "the pick must not land")
assert_false("bonus skill athletics is already proficient" in s.draft.errors(s.origin, s.world),
"the player must not be able to press his way into an invalid draft")
# And it is recoverable: hand the bonus back and the chip is live again.
s._bonus_chips[bonus_i].pressed.emit()
assert_eq(s.draft.bonus_skill, "")
assert_false(s._pool_chips[athletics_i].disabled, "handing the bonus back makes the pool chip live")
s._pool_chips[athletics_i].pressed.emit()
assert_true(s.draft.is_picked("athletics"))
func test_pressing_enter_emits_exactly_the_draft_s_creation_dict():
var s := _screen()
_complete_a_valid_draft(s)

View File

@@ -159,3 +159,22 @@ func test_every_race_and_calling_carries_a_fragment():
"calling %s: fragment follows an em dash, so it must start lowercase" % id)
assert_true(calling_fragment.ends_with("."),
"calling %s: fragment must end with '.' — it closes the composition" % id)
func test_no_calling_is_named_with_a_vowel():
# The fourth shape rule of that same composition, and the one that breaks SILENTLY.
# CharacterCreation._origin_prose() hardcodes the article:
# "%s Now you carry a %s's work — %s"
# All seven callings are consonant-initial, so all 28 compositions read correctly
# today and nothing anywhere would notice an eighth. Add "an Auger" and every
# Auger in the game gets "Now you carry a auger's work" on the DM panel — no
# error, no test, just a DM who cannot speak. Fail HERE, where the author is.
var vowels := ["a", "e", "i", "o", "u"]
for id in Callings.IDS:
var calling_name := str(db.calling(id).get("name", "")).strip_edges()
assert_ne(calling_name, "", "calling %s has no name" % id)
assert_false(calling_name.substr(0, 1).to_lower() in vowels,
("calling %s is named '%s'. The DM origin panel composes "
+ "\"Now you carry a %s's work\" with the article HARDCODED as \"a\" "
+ "(CharacterCreation._origin_prose) — a vowel-initial calling needs \"an\". "
+ "Change the template first, then this guard.") % [id, calling_name, calling_name.to_lower()])

View File

@@ -1,20 +1,64 @@
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(race := "human", calling := "sellsword") -> CreationDraft:
var d := CreationDraft.new()
d.character_seed = 8675309
d.set_race(race)
d.set_calling(calling)
d.name = "Aldric"
return d
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.
#
# The talent is asserted through the DERIVED phrase ("talent second wind"), not
# the raw id ("second_wind") the card used to print — but it is still read from
# Callings.talent() at assert time, so hardcoding a talent into the format string
# still goes red (traps.md #7: anchor to the full derived phrase).
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, CreationCopy.armor_word(id))
assert_string_contains(line, Callings.talent(id))
assert_string_contains(line, "talent %s" % CreationCopy.talent_word(id))
assert_string_contains(line, "picks %d skills" % Callings.skill_count(id))
for s in Callings.saves(id):
assert_string_contains(line, s.to_upper())
func test_the_talent_is_humanised_and_no_card_line_shows_a_raw_id():
# The player read "talent second_wind" off the Sellsword's card. Four of the seven
# talents carry an underscore — the card printed the ID.
assert_eq(CreationCopy.talent_word("sellsword"), "second wind")
assert_eq(CreationCopy.talent_word("cutpurse"), "backstab", "an underscore-free talent is untouched")
var underscored := 0
for id in Callings.IDS:
if Callings.talent(id).contains("_"):
underscored += 1
assert_false(CreationCopy.calling_detail(id).contains("_"),
"%s's card line shows a raw snake_case id: %s" % [id, CreationCopy.calling_detail(id)])
assert_eq(underscored, 4,
"four talents carry an underscore — if that ever hits zero this guard stops guarding")
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)")
@@ -52,3 +96,86 @@ func test_nothing_in_the_copy_mentions_luck():
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"))
# ------------------------------------------------------------------- §13: the nag
func test_the_nag_is_authored_copy_not_the_validator_s_grammar():
# §13. The exact strings a human must be able to judge, pinned. Anchored to the
# authored phrase itself — asserting only "no underscore" would pass on any
# string that happens to have none, including a blank one.
var human := _draft("human", "sellsword")
human.name = ""
assert_eq(CreationCopy.error_line("player name is required", human), "name yourself")
# "sellsword picks 2 skills, got 0" -> the count is DERIVED from the rules table.
assert_eq(Callings.skill_count("sellsword"), 2, "the sellsword picks 2, or the line below means nothing")
assert_eq(CreationCopy.error_line("sellsword picks 2 skills, got 0", human),
"choose two more proficiencies")
human.toggle_skill("athletics")
assert_eq(CreationCopy.error_line("sellsword picks 2 skills, got 1", human),
"choose one more proficiency", "one is singular, and the number is never hardcoded")
# The same error against a calling that picks FOUR must say four — the proof the
# number comes from Callings.skill_count and not from a literal "two".
var thief := _draft("dwarf", "cutpurse")
assert_eq(Callings.skill_count("cutpurse"), 4, "the cutpurse picks 4, or the line below means nothing")
assert_eq(CreationCopy.error_line("cutpurse picks 4 skills, got 0", thief),
"choose four more proficiencies")
assert_eq(CreationCopy.error_line("human must choose a bonus skill", human),
"one more proficiency, any of them — take it")
func test_an_unmapped_error_still_speaks_in_voice():
# §13: "write fallbacks as content, not as error handling." An error nobody
# anticipated (broken content; a spend the screen cannot produce) must never put
# the raw string on the label, and must never blank it either.
var d := _draft()
for raw in ["unresolved ref: item:ghost_blade", "seed is required and must be an integer",
"spend exceeds the pool of 3 (got 5)", "cannot spend on 'lck' — not an attribute"]:
var line := CreationCopy.error_line(raw, d)
assert_eq(line, CreationCopy.UNSPOKEN, "an unmapped error degrades to the authored fallback: %s" % raw)
assert_ne(line, raw)
assert_ne(CreationCopy.UNSPOKEN.strip_edges(), "", "the fallback is CONTENT — it is never blank")
assert_false(CreationCopy.UNSPOKEN.contains("_"))
func test_no_error_the_validator_can_raise_reaches_the_player_raw():
# Sweep EVERY error NewGame.validate actually produces for a draft the screen can
# be driven into (plus the hostile shapes only a saga could hand it), and assert
# the player never sees the id, the underscore, or the validator's grammar.
var d := _draft()
var hostiles: Array = [
{"name": "", "skills": [], "bonus_skill": ""}, # nameless, unpicked
{"skills": ["athletics"]}, # too few
{"skills": ["athletics", "athletics"]}, # duplicate
{"skills": ["athletics", "sorcery"]}, # not in the pool
{"race_id": "elf", "skills": ["athletics", "perception"], "bonus_skill": ""}, # granted already
{"bonus_skill": ""}, # human, no bonus
{"bonus_skill": "athletics", "skills": ["athletics", "endurance"]}, # bonus == a pick
{"bonus_skill": "swimming"}, # no such skill
{"race_id": "dwarf", "bonus_skill": "stealth"}, # dwarf gets none
{"race_id": "orc"}, {"calling_id": "paladin"}, # off the tables
{"seed": "8675309"}, {"spend": {"lck": 3}}, {"spend": {"str": 9}},
]
var base := {
"name": "Aldric", "race_id": "human", "calling_id": "sellsword", "seed": 8675309,
"spend": {}, "skills": ["athletics", "endurance"], "bonus_skill": "perception",
}
var seen := 0
for h in hostiles:
var creation := base.duplicate(true)
for k in h:
creation[k] = h[k]
var errors: Array = NewGame.validate(_deserter(), world, creation)
assert_gt(errors.size(), 0, "hostile creation %s must actually be invalid, or it guards nothing" % str(h))
for e in errors:
seen += 1
var line: String = CreationCopy.error_line(str(e), d)
assert_ne(line.strip_edges(), "", "'%s' put a BLANK line on the label" % e)
assert_ne(line, str(e), "'%s' reached the player raw" % e)
assert_false(line.contains("_"), "'%s' put a snake_case id on the label: %s" % [e, line])
assert_false(line.contains("got "), "'%s' put the validator's grammar on the label: %s" % [e, line])
assert_gt(seen, 12, "the sweep must actually reach a spread of errors, not one or two")

View File

@@ -139,6 +139,65 @@ func test_toggling_an_already_picked_skill_removes_it():
assert_eq(d.skills, [])
func test_the_bonus_skill_cannot_also_be_picked_from_the_pool():
# The guard was one-directional: can_take_bonus() refused a skill already PICKED,
# but can_pick() did not consider the bonus. Human + Sellsword, take athletics as
# the bonus, then press athletics in the Sellsword's pool — the pick landed and
# the draft went invalid ("bonus skill athletics is already proficient"), which is
# precisely the invalid draft "he did not cause and cannot see" that set_calling's
# comment forbids.
var d := _draft() # human + sellsword; athletics is in the pool
d.set_bonus_skill("athletics")
assert_eq(d.bonus_skill, "athletics")
assert_true("athletics" in d.skill_pool(), "athletics must be in the sellsword's pool or this proves nothing")
assert_false(d.can_pick("athletics"), "the chip must be INERT, not merely styled to look inert")
d.toggle_skill("athletics")
assert_eq(d.skills, [], "the pick must not land while athletics is the bonus skill")
# ...and the draft the press would have produced is exactly the one construct rejects.
d.skills = ["athletics", "endurance"] # what the bug let the player build
assert_true("bonus skill athletics is already proficient" in d.errors(_deserter(), world),
"this IS the invalid draft the guard exists to prevent")
func test_the_other_direction_still_holds_a_picked_skill_cannot_become_the_bonus():
# The half of the guard that already worked. Kept beside its mirror so the two
# can never drift apart again.
var d := _draft()
d.toggle_skill("athletics")
assert_eq(d.skills, ["athletics"])
assert_false(d.can_take_bonus("athletics"))
d.set_bonus_skill("athletics")
assert_eq(d.bonus_skill, "", "a picked skill must not also be taken as the bonus")
func test_handing_back_the_bonus_skill_makes_the_pool_chip_live_again():
# The inert chip is recoverable — the bonus row is right there. Without this the
# fix above would be a dead end the player could walk into and not walk out of.
var d := _draft()
d.set_bonus_skill("athletics")
assert_false(d.can_pick("athletics"))
d.set_bonus_skill("athletics") # pressing the chosen bonus chip clears it
assert_eq(d.bonus_skill, "")
assert_true(d.can_pick("athletics"), "handing the bonus back must make the pool chip live")
d.toggle_skill("athletics")
assert_eq(d.skills, ["athletics"])
func test_to_creation_carries_exactly_the_seven_contract_keys():
# §2, stated as a test rather than as a comment. The screen emits a seed and four
# choices; it never hands construct a NUMBER. test_pressing_enter_emits_exactly_
# the_draft_s_creation_dict compares the emitted dict to to_creation() — both
# sides move together, so adding an "attributes" key there breaks nothing. This
# pins the contract itself: seven keys, no more, no fewer.
var d := _draft()
var keys: Array = d.to_creation().keys()
keys.sort()
assert_eq(keys, ["bonus_skill", "calling_id", "name", "race_id", "seed", "skills", "spend"],
"the creation contract is exactly seven keys — an eighth (an 'attributes' block, say) is the §2 breach")
func test_to_creation_round_trips_through_construct():
var d := _draft()
d.toggle_skill("athletics")

View File

@@ -340,6 +340,33 @@ func test_construct_builds_exactly_what_roll_attributes_showed():
assert_eq(res["state"].sheet.attributes, expected)
func test_construct_ignores_an_attribute_block_handed_to_it():
# §2 — "code owns state" — stated as a TEST, not as a comment. Every other test
# here feeds construct a well-behaved creation dict, so nothing in the suite ever
# proves construct REFUSES a number: a construct that trusted creation["attributes"]
# would pass all of them. Hand it a hostile one — an 18 in every stat — and assert
# the sheet is still, exactly, roll_attributes(seed) + spend.
var spend := {"str": 2, "con": 1}
var hostile := _creation({
"seed": 4242,
"spend": spend,
"attributes": {"str": 18, "dex": 18, "con": 18, "fth": 18, "mag": 18},
})
var res := _build(hostile)
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,
"construct re-rolls from the SEED — a stat block handed to it is not state, it is noise (§2)")
# And the hostile block is not merely ignored on the sheet — it never becomes state
# anywhere. (A `sheet.attributes` the caller supplied would be the exact §2 failure.)
assert_false(res["log"].to_dict().has("attributes"), "no attribute ever reaches the canon log")
func test_validate_is_public_and_agrees_with_construct():
var bad := _creation({"calling_id": "paladin"})
var errors := NewGame.validate(_deserter(), world, bad)

View File

@@ -29,22 +29,56 @@ func test_default_font_is_set():
assert_true(t.default_font is FontFile)
func _guarded_variations() -> Array:
# EVERY variation the builder touches — the stylebox variations (ThemeKeys.ALL)
# AND the six font roles (ThemeKeys.FONT_ROLES), which ALL deliberately excludes
# by its own docstring. Iterating ALL alone left every font, font size and font
# colour in the theme unguarded: change Palette.CREAM (TitleLogo's font colour,
# and nothing else's), skip the rebuild, and the suite stayed green with a stale
# .tres shipped. traps.md #12 — "covers everything" must iterate the full set.
var out: Array = ThemeKeys.ALL.keys()
out.append_array(ThemeKeys.FONT_ROLES.keys())
return out
func test_the_drift_guard_covers_every_variation_the_builder_touches():
# The guard below is only as good as the set it walks. Pin that set to the
# builder's own output: every variation the fresh Theme carries a stylebox, a
# font, a font size or a font colour for MUST be in _guarded_variations(), or
# it can drift unwatched. A new variation added to the builder and forgotten in
# ThemeKeys goes red here rather than sitting unguarded forever.
var fresh: Theme = Builder.build_theme()
var guarded := _guarded_variations()
for variation in fresh.get_type_list():
var touched := (not fresh.get_stylebox_list(variation).is_empty()
or not fresh.get_font_list(variation).is_empty()
or not fresh.get_font_size_list(variation).is_empty()
or not fresh.get_color_list(variation).is_empty())
if not touched:
continue
if fresh.get_type_variation_base(variation) == &"":
continue # a BASE type (the builder styles RichTextLabel directly), not a variation
assert_true(variation in guarded,
"the builder styles the variation %s but no drift guard walks it — add it to ThemeKeys.ALL or ThemeKeys.FONT_ROLES" % variation)
func test_committed_tres_matches_builder():
# Catches "palette/builder changed but nobody regenerated game_theme.tres."
# Preloading the builder script and calling its static build_theme() does
# NOT run _init() (that only fires on .new()), so this is side-effect-free.
#
# Drives its checks off ThemeKeys.ALL so every variation is guarded, not just
# whichever three someone remembered to hardcode here. For each variation:
# every stylebox state the builder actually set (get_stylebox_list) is
# compared property-by-property when it's a StyleBoxFlat, and every font
# colour role the builder actually set (get_color_list) is compared too.
# Nothing the builder never touches is asserted on.
# Drives its checks off ThemeKeys.ALL *and* ThemeKeys.FONT_ROLES so every
# variation is guarded, not just whichever three someone remembered to hardcode
# here — and not just the stylebox half. For each variation: every stylebox
# state, every font, every font size and every font colour the builder actually
# set is compared. Nothing the builder never touches is asserted on.
var fresh: Theme = Builder.build_theme()
var committed: Theme = load(THEME_PATH)
for variation in ThemeKeys.ALL:
for variation in _guarded_variations():
for state in fresh.get_stylebox_list(variation):
assert_true(committed.has_stylebox(state, variation),
"stale game_theme.tres — %s/%s stylebox is missing entirely; re-run build_game_theme.gd" % [variation, state])
var fresh_box: StyleBox = fresh.get_stylebox(state, variation)
var committed_box: StyleBox = committed.get_stylebox(state, variation)
assert_eq(committed_box.get_class(), fresh_box.get_class(),
@@ -74,7 +108,28 @@ func test_committed_tres_matches_builder():
assert_eq(c.corner_radius_bottom_left, f.corner_radius_bottom_left,
"stale game_theme.tres — %s/%s corner_radius_bottom_left drifted from the builder; re-run build_game_theme.gd" % [variation, state])
# The FONT half — the whole reason this guard could not fail against a font
# role. Compare the font's resource_path, not the Resource: `load()` caches,
# so two references to the same .ttf are the same instance and `==` would be
# true even when the builder swapped SERIF for MONO... only if the .tres
# happened to point at the same file anyway. The path is the thing that drifts.
for font_name in fresh.get_font_list(variation):
assert_true(committed.has_font(font_name, variation),
"stale game_theme.tres — %s/%s font is missing entirely; re-run build_game_theme.gd" % [variation, font_name])
var fresh_font: Font = fresh.get_font(font_name, variation)
var committed_font: Font = committed.get_font(font_name, variation)
assert_eq(committed_font.resource_path, fresh_font.resource_path,
"stale game_theme.tres — %s/%s font drifted from the builder; re-run build_game_theme.gd" % [variation, font_name])
for size_name in fresh.get_font_size_list(variation):
assert_true(committed.has_font_size(size_name, variation),
"stale game_theme.tres — %s/%s font size is missing entirely; re-run build_game_theme.gd" % [variation, size_name])
assert_eq(committed.get_font_size(size_name, variation), fresh.get_font_size(size_name, variation),
"stale game_theme.tres — %s/%s font size drifted from the builder; re-run build_game_theme.gd" % [variation, size_name])
for color_name in fresh.get_color_list(variation):
assert_true(committed.has_color(color_name, variation),
"stale game_theme.tres — %s/%s colour is missing entirely; re-run build_game_theme.gd" % [variation, color_name])
var fresh_color: Color = fresh.get_color(color_name, variation)
var committed_color: Color = committed.get_color(color_name, variation)
assert_eq(committed_color, fresh_color,