Not errors and not regressions — Godot printed them on every script reload and they predate M4-b. Cleared so a real warning is not lost in the noise: - Redundant `const ContentDB`/`const NewGame` preloads that shadowed their own `class_name` globals (character_creation.gd, creation_draft.gd) — the class is already global, so the const bought nothing. - Two `log` locals shadowing the built-in `log()` — renamed to `out` (canon_log.from_dict) and `canon` (NewGame.construct); the "log" dict KEY is the public contract and is unchanged. - currency.format's copper->denomination division is integer BY DESIGN (the remainder is carried, not lost) — annotated `@warning_ignore` to say so. 319 client tests green; headless parse reports none of the four sites. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
223 lines
6.7 KiB
GDScript
223 lines
6.7 KiB
GDScript
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.
|
|
|
|
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:
|
|
## NewGame.SPEND_POOL is the ONE authority on the pool's size. A local copy of the
|
|
## number would be a second thing to keep in sync, and this class's whole contract
|
|
## is that it keeps none.
|
|
return NewGame.SPEND_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
|
|
# The mirror of can_take_bonus's `not is_picked(skill)`. Without it the guard is
|
|
# one-directional: take athletics as the human's bonus, then pick it AGAIN from
|
|
# the calling's pool, and the draft is invalid ("bonus skill athletics is already
|
|
# proficient") — the player holding an invalid draft he did not cause and cannot
|
|
# see, which is the exact thing set_calling's comment forbids. The chip goes inert
|
|
# instead; the bonus row is right there and the player can hand it back.
|
|
if skill == bonus_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()
|