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

@@ -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")