Files
code_of_conquest_dnd/client/tests/unit/test_creation_draft.gd
Phillip Tarrant 866158cc0d 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.
2026-07-13 14:38:18 -05:00

183 lines
5.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_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"))