Final whole-branch review of M4-b. Six findings.
1. The player could press his way into an invalid draft. can_take_bonus()
refused an already-picked skill; can_pick() did not consider the bonus, so
the guard was one-directional. Take athletics as the human's bonus, pick it
again from the Sellsword's pool, and the draft is one NewGame.validate
rejects — the invalid draft "he did not cause and cannot see" that
set_calling's own comment forbids. Fixed in the DRAFT (the screen owns no
rules): can_pick() now mirrors can_take_bonus(), so the chip goes inert, the
idiom the pool row already uses for a race-granted skill. Recoverable: hand
the bonus back and the chip is live again. Both directions tested.
2. The theme drift guard STILL could not fail. ThemeKeys.ALL is stylebox
variations only, by its own docstring — so the six FONT roles the builder
sets fonts, sizes and colours for were entirely unguarded. Changing
Palette.CREAM and skipping the rebuild shipped a stale game_theme.tres with
the suite green (proven, on the committed test). Adds ThemeKeys.FONT_ROLES
beside ALL (never inside it — ALL's contract is relied on), compares fonts,
font sizes and font colours as well as styleboxes, and names the variation
AND the property that drifted. Plus a meta-guard that walks the builder's own
output and fails if a variation it styles is in neither collection — this is
the second time this guard has been fixed by "enumerate the right set", and
nothing was checking the set.
3. §13: the CTA label piped NewGame.validate's diagnostics straight to the
player. On the game's SECOND SCREEN he read "hedge_mage picks 2 skills, got
0". Now CreationCopy.error_line maps them to authored copy — "name yourself",
"choose two more proficiencies" (the number DERIVED from Callings.skill_count,
never written down), "one more proficiency, any of them — take it" — with an
authored fallback for anything unmapped, because §13 means the unmapped case
still speaks in voice. validate's strings are untouched: they are a contract,
so they are translated in the presentation layer, not rewritten.
4. §2 had no test that fails if the boundary is breached. The emit test compared
the signal to draft.to_creation() — both sides move together. Pins the seven
contract keys exactly, and feeds construct a hostile creation dict carrying an
18 in every stat, asserting the sheet is still roll_attributes(seed) + spend.
Every other test in test_new_game passed with that breach in place.
5. Three holes: the orphan sweep skipped PoolRow (a stray Pool6 rendered unbound
and visible, suite green — the exact bug the test exists to catch, in the one
row it forgot); the §7 sweep forbade "luck" but not Luck.BANDS' descriptors,
which are worse than the number (the player would re-roll until his Luck read
well — calculable Luck); and the DM panel's composition hardcodes the article
"a", so a vowel-initial calling would break it silently — guarded in the
content parity test, which names the template as the reason.
6. The calling card printed "talent second_wind". Four of seven talents carry an
underscore. Humanised, the way skill_label() already does. The test that
pinned the RAW form is moved to the full derived phrase ("talent second wind")
so it still fails if someone hardcodes a talent into the format string.
Every guard was proven by re-breaking the code and watching the named test go
red (docs/traps.md). Suite 302 -> 315, content build green, no new warnings.
Report: .superpowers/sdd/final-review-fix-report.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
225 lines
6.7 KiB
GDScript
225 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.
|
|
|
|
const NewGame = preload("res://scripts/newgame/new_game.gd")
|
|
|
|
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()
|