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:
2026-07-13 14:38:18 -05:00
parent 1c4d19492d
commit 866158cc0d
4 changed files with 398 additions and 0 deletions

View 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()

View File

@@ -0,0 +1 @@
uid://dkxuw5c4r6tld

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

View File

@@ -0,0 +1 @@
uid://dqllxamt2tcgu