Files
code_of_conquest_dnd/client/scripts/ui/creation/creation_draft.gd
Phillip Tarrant 8f3aa75fa9 chore(warnings): silence the four pre-existing script warnings the creation screen surfaced
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>
2026-07-14 20:09:29 -05:00

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