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
|
||||
Reference in New Issue
Block a user