Files
code_of_conquest_dnd/client/tests/unit/test_creation_draft.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

242 lines
8.7 KiB
GDScript

extends "res://addons/gut/test.gd"
const ContentDB = preload("res://scripts/content/content_db.gd")
const NewGame = preload("res://scripts/newgame/new_game.gd")
var world
func before_each():
world = ContentDB.new()
world.load_from(ContentDB.default_content_root())
func _deserter() -> Dictionary:
return ContentDB.load_json(ContentDB.origin_path("deserter"))
func _draft() -> CreationDraft:
# A fixed seed, so every assertion below is reproducible.
var d := CreationDraft.new()
d.character_seed = 8675309
d.set_race("human")
d.set_calling("sellsword")
d.name = "Aldric"
return d
func test_a_fresh_draft_has_a_seed_and_no_choices():
var d := CreationDraft.fresh()
assert_ne(d.character_seed, 0, "randomize() gave us the one bit of non-determinism there is")
assert_eq(d.spend, {})
assert_eq(d.skills, [])
assert_eq(d.bonus_skill, "")
func test_rolled_is_exactly_what_new_game_will_roll():
var d := _draft()
assert_eq(d.rolled(), NewGame.roll_attributes(d.character_seed),
"the screen and construct must reach the same five numbers")
func test_final_is_rolled_plus_spend():
var d := _draft()
d.increment("str")
d.increment("str")
assert_eq(int(d.final()["str"]), int(d.rolled()["str"]) + 2)
assert_eq(int(d.final()["dex"]), int(d.rolled()["dex"]), "an unspent stat is untouched")
func test_the_pool_is_three_and_the_fourth_point_does_not_exist():
var d := _draft()
assert_eq(d.points_left(), 3)
d.increment("str"); d.increment("dex"); d.increment("con")
assert_eq(d.points_left(), 0)
assert_false(d.can_increment("fth"))
d.increment("fth")
assert_eq(int(d.final()["fth"]), int(d.rolled()["fth"]), "the fourth point bought nothing")
assert_eq(d.spent_total(), 3)
func test_minus_removes_only_what_you_spent_and_floors_at_the_roll():
var d := _draft()
d.increment("str")
assert_true(d.can_decrement("str"))
d.decrement("str")
assert_eq(d.points_left(), 3)
assert_false(d.can_decrement("str"), "you cannot go below the roll additive only")
d.decrement("str")
assert_eq(int(d.final()["str"]), int(d.rolled()["str"]))
func test_reroll_changes_the_numbers_and_clears_the_spend_and_nothing_else():
var d := _draft()
d.set_calling("cutpurse")
d.toggle_skill("stealth")
d.increment("dex")
var before := d.character_seed
d.reroll()
assert_ne(d.character_seed, before, "a re-roll IS a new seed nothing else")
assert_eq(d.spend, {}, "points spent against a roll that no longer exists are meaningless")
assert_eq(d.calling_id, "cutpurse", "the calling survives")
assert_eq(d.skills, ["stealth"], "the picks survive")
assert_eq(d.name, "Aldric", "the name survives")
func test_changing_calling_clears_the_picks():
var d := _draft()
d.toggle_skill("athletics")
assert_eq(d.skills, ["athletics"])
d.set_calling("hedge_mage")
assert_eq(d.skills, [], "the pool changed underneath them stale picks are an invalid draft the player did not cause")
func test_changing_race_drops_a_pick_the_new_race_now_grants():
# The elf/cutpurse case. An Elf is GRANTED perception; a Cutpurse who had
# picked it now holds a pick that "buys nothing" and construct rejects by name.
var d := _draft()
d.set_calling("cutpurse")
d.toggle_skill("perception")
assert_eq(d.skills, ["perception"])
d.set_race("elf")
assert_eq(d.skills, [], "the elf already has perception the pick is dropped, not left to 422")
func test_changing_race_off_human_clears_the_bonus_skill():
var d := _draft()
d.set_bonus_skill("stealth")
assert_eq(d.bonus_skill, "stealth")
d.set_race("dwarf")
assert_eq(d.bonus_skill, "", "only a human gets a bonus skill")
func test_a_granted_skill_cannot_be_picked():
var d := _draft()
d.set_race("elf")
d.set_calling("cutpurse")
assert_true(d.is_granted("perception"))
assert_false(d.can_pick("perception"))
d.toggle_skill("perception")
assert_eq(d.skills, [], "the chip is inert, not merely styled to look inert")
func test_picks_stop_at_the_calling_s_count():
var d := _draft()
d.set_calling("sellsword") # picks 2
d.toggle_skill("athletics")
d.toggle_skill("endurance")
assert_eq(d.picks_left(), 0)
d.toggle_skill("perception")
assert_eq(d.skills, ["athletics", "endurance"], "the third pick does not exist")
func test_toggling_an_already_picked_skill_removes_it():
var d := _draft()
d.toggle_skill("athletics")
d.toggle_skill("athletics")
assert_eq(d.skills, [])
func test_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")
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"))