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.
This commit is contained in:
182
client/tests/unit/test_creation_draft.gd
Normal file
182
client/tests/unit/test_creation_draft.gd
Normal file
@@ -0,0 +1,182 @@
|
||||
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"))
|
||||
1
client/tests/unit/test_creation_draft.gd.uid
Normal file
1
client/tests/unit/test_creation_draft.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dqllxamt2tcgu
|
||||
Reference in New Issue
Block a user