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:
214
client/scripts/ui/creation/creation_draft.gd
Normal file
214
client/scripts/ui/creation/creation_draft.gd
Normal file
@@ -0,0 +1,214 @@
|
||||
class_name CreationDraft
|
||||
extends RefCounted
|
||||
## The creation screen's whole brain. Pure, node-free, headlessly testable.
|
||||
##
|
||||
## Stores exactly the seven things a creation Dictionary is made of, and nothing
|
||||
## derived. It CARRIES A SEED, NOT A STAT BLOCK — the screen shows the roll by
|
||||
## calling NewGame.roll_attributes(seed), the same function construct calls, so
|
||||
## the numbers on screen are the numbers the player gets and the screen still
|
||||
## never hands a number to anything (§2).
|
||||
##
|
||||
## It owns no rules of its own: errors() delegates to NewGame.validate. A second
|
||||
## copy of the rules is a second thing to keep in sync, and it would lose.
|
||||
##
|
||||
## §7: LCK is not here. Not a field, not an accessor, not a key. It is rolled
|
||||
## inside construct off the same seed, immediately after MAG, and never surfaces.
|
||||
|
||||
const NewGame = preload("res://scripts/newgame/new_game.gd")
|
||||
const POOL := 3 # mirrors NewGame.SPEND_POOL; validate() is the authority
|
||||
|
||||
var name: String = ""
|
||||
var race_id: String = ""
|
||||
var calling_id: String = ""
|
||||
var spend: Dictionary = {} # {stat: int} — additive only
|
||||
var skills: Array = [] # the player's picks (NOT the race's grants)
|
||||
var bonus_skill: String = "" # human only
|
||||
|
||||
## NOT named `seed` — `seed()` is a global GDScript function and a member that
|
||||
## shadows it reads as a bug (and warns). The creation Dictionary's KEY is still
|
||||
## "seed"; only the GDScript identifier differs.
|
||||
var character_seed: int = 0
|
||||
|
||||
|
||||
static func fresh() -> CreationDraft:
|
||||
## The ONE place non-determinism enters the entire pipeline — and what it
|
||||
## produces is a seed, which is thereafter the character's anchor forever.
|
||||
var d := CreationDraft.new()
|
||||
d.character_seed = new_seed()
|
||||
return d
|
||||
|
||||
|
||||
static func new_seed() -> int:
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.randomize()
|
||||
return int(rng.randi())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the numbers
|
||||
|
||||
func rolled() -> Dictionary:
|
||||
return NewGame.roll_attributes(character_seed)
|
||||
|
||||
|
||||
func final() -> Dictionary:
|
||||
var out := rolled()
|
||||
for stat in spend:
|
||||
out[stat] = int(out[stat]) + int(spend[stat])
|
||||
return out
|
||||
|
||||
|
||||
func spent_total() -> int:
|
||||
var total := 0
|
||||
for stat in spend:
|
||||
total += int(spend[stat])
|
||||
return total
|
||||
|
||||
|
||||
func points_left() -> int:
|
||||
return POOL - spent_total()
|
||||
|
||||
|
||||
func can_increment(stat: String) -> bool:
|
||||
return Attributes.exists(stat) and points_left() > 0
|
||||
|
||||
|
||||
func increment(stat: String) -> void:
|
||||
if not can_increment(stat):
|
||||
return
|
||||
spend[stat] = int(spend.get(stat, 0)) + 1
|
||||
|
||||
|
||||
func can_decrement(stat: String) -> bool:
|
||||
## Removes only what YOU spent. "Additive only" is a rule about where the number
|
||||
## can END UP — never below the roll — not about whether a mis-click is
|
||||
## recoverable. Those are different things.
|
||||
return int(spend.get(stat, 0)) > 0
|
||||
|
||||
|
||||
func decrement(stat: String) -> void:
|
||||
if not can_decrement(stat):
|
||||
return
|
||||
var left := int(spend[stat]) - 1
|
||||
if left <= 0:
|
||||
spend.erase(stat)
|
||||
else:
|
||||
spend[stat] = left
|
||||
|
||||
|
||||
func reroll() -> void:
|
||||
## A re-roll IS a new seed. Nothing else. The spend clears because points spent
|
||||
## against a roll that no longer exists are meaningless; race, calling, skills
|
||||
## and name survive because none of them came from the die.
|
||||
##
|
||||
## Note what the player is NOT told: the seed drives the five visible attributes
|
||||
## AND the hidden Luck roll. Chase a 16 and you re-roll your Luck, blind, every
|
||||
## time. That is §7 working exactly as designed. Say nothing.
|
||||
character_seed = new_seed()
|
||||
spend = {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the choices
|
||||
|
||||
func set_race(id: String) -> void:
|
||||
race_id = id
|
||||
# A pick the new race GRANTS now buys nothing, and construct rejects it by name.
|
||||
# Drop it here rather than let the player press a button into a 422.
|
||||
var granted: Array = Races.granted_skills(id)
|
||||
var kept: Array = []
|
||||
for s in skills:
|
||||
if s not in granted:
|
||||
kept.append(s)
|
||||
skills = kept
|
||||
if not Races.wants_bonus_skill(id):
|
||||
bonus_skill = ""
|
||||
|
||||
|
||||
func set_calling(id: String) -> void:
|
||||
# The pool changed underneath the picks. Keeping them leaves the player holding
|
||||
# an invalid draft he did not cause and cannot see.
|
||||
if id != calling_id:
|
||||
skills = []
|
||||
calling_id = id
|
||||
|
||||
|
||||
func skill_pool() -> Array:
|
||||
return Callings.skill_pool(calling_id)
|
||||
|
||||
|
||||
func picks_needed() -> int:
|
||||
return Callings.skill_count(calling_id)
|
||||
|
||||
|
||||
func picks_left() -> int:
|
||||
return picks_needed() - skills.size()
|
||||
|
||||
|
||||
func is_granted(skill: String) -> bool:
|
||||
return skill in Races.granted_skills(race_id)
|
||||
|
||||
|
||||
func is_picked(skill: String) -> bool:
|
||||
return skill in skills
|
||||
|
||||
|
||||
func can_pick(skill: String) -> bool:
|
||||
if is_granted(skill):
|
||||
return false
|
||||
if skill not in skill_pool():
|
||||
return false
|
||||
return picks_left() > 0
|
||||
|
||||
|
||||
func toggle_skill(skill: String) -> void:
|
||||
if is_picked(skill):
|
||||
skills.erase(skill)
|
||||
return
|
||||
if can_pick(skill):
|
||||
skills.append(skill)
|
||||
|
||||
|
||||
func wants_bonus_skill() -> bool:
|
||||
return Races.wants_bonus_skill(race_id)
|
||||
|
||||
|
||||
func can_take_bonus(skill: String) -> bool:
|
||||
if not wants_bonus_skill():
|
||||
return false
|
||||
if not Skills.exists(skill):
|
||||
return false
|
||||
return not is_granted(skill) and not is_picked(skill)
|
||||
|
||||
|
||||
func set_bonus_skill(skill: String) -> void:
|
||||
if bonus_skill == skill:
|
||||
bonus_skill = ""
|
||||
return
|
||||
if can_take_bonus(skill):
|
||||
bonus_skill = skill
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the contract
|
||||
|
||||
func to_creation() -> Dictionary:
|
||||
## The plain Dictionary NewGame.construct takes. A saga (later) synthesizes one
|
||||
## of these and skips this screen entirely — so it stays a plain Dictionary, and
|
||||
## this scene must never become the only thing that can produce it.
|
||||
return {
|
||||
"name": name.strip_edges(),
|
||||
"race_id": race_id,
|
||||
"calling_id": calling_id,
|
||||
"seed": character_seed, # the DICT key stays "seed" — the contract is unchanged
|
||||
"spend": spend.duplicate(),
|
||||
"skills": skills.duplicate(),
|
||||
"bonus_skill": bonus_skill,
|
||||
}
|
||||
|
||||
|
||||
func errors(origin: Dictionary, world: ContentDB) -> Array:
|
||||
## Delegates. The screen owns no second copy of the rules — it asks construct's
|
||||
## validator what is wrong, and shows the answer.
|
||||
return NewGame.validate(origin, world, to_creation())
|
||||
|
||||
|
||||
func is_complete(origin: Dictionary, world: ContentDB) -> bool:
|
||||
return errors(origin, world).is_empty()
|
||||
1
client/scripts/ui/creation/creation_draft.gd.uid
Normal file
1
client/scripts/ui/creation/creation_draft.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dkxuw5c4r6tld
|
||||
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