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>
150 lines
5.9 KiB
GDScript
150 lines
5.9 KiB
GDScript
class_name CreationCopy
|
|
extends RefCounted
|
|
## Display strings for the creation screen, DERIVED from the rules tables.
|
|
##
|
|
## Prose lives in content (blurbs, fragments). NUMBERS LIVE IN CODE. The card reads
|
|
## both and repeats neither. A hit die written into a JSON — or typed into a string
|
|
## literal here — is a second source of truth, and it will eventually disagree with
|
|
## the table. Every number below is read from Callings/Races/Skills at call time, so
|
|
## retuning a calling retunes its card.
|
|
##
|
|
## §7: nothing here may mention Luck, in any form. §2: presentation only.
|
|
|
|
const ARMOR_WORD := {
|
|
"none": "no armour",
|
|
"light": "light armour",
|
|
"medium": "medium armour",
|
|
"heavy": "heavy armour",
|
|
}
|
|
|
|
## The one line the player reads when the draft will not hold. §13: a fallback is
|
|
## CONTENT, not error handling — so even an error nobody anticipated speaks in the
|
|
## DM's voice rather than showing him a validator's grammar.
|
|
const UNSPOKEN := "something in this does not hold — look again"
|
|
|
|
const NUMBER_WORD := {
|
|
1: "one", 2: "two", 3: "three", 4: "four", 5: "five",
|
|
6: "six", 7: "seven", 8: "eight", 9: "nine",
|
|
}
|
|
|
|
|
|
static func calling_detail(id: String) -> String:
|
|
## e.g. "d8 · light armour · MP (FTH) · saves DEX + MAG · picks 4 skills · talent backstab"
|
|
if not Callings.exists(id):
|
|
return ""
|
|
var saves: Array = []
|
|
for s in Callings.saves(id):
|
|
saves.append(s.to_upper())
|
|
return "d%d · %s · %s · saves %s · picks %d skills · talent %s" % [
|
|
Callings.hit_die(id),
|
|
armor_word(id),
|
|
resource_word(id),
|
|
" + ".join(saves),
|
|
Callings.skill_count(id),
|
|
talent_word(id),
|
|
]
|
|
|
|
|
|
static func talent_word(id: String) -> String:
|
|
## second_wind -> "second wind". Four of the seven talent IDS carry an
|
|
## underscore, and the card printed the raw id: the player read "talent
|
|
## second_wind" off the calling card. Ids are snake_case; humans are not — the
|
|
## same reason skill_label() exists. Read from the table at call time, so
|
|
## retuning a talent retunes the card.
|
|
return Callings.talent(id).replace("_", " ")
|
|
|
|
|
|
static func armor_word(id: String) -> String:
|
|
## The card says "no armour", not "none". Public so the test can assert against
|
|
## the DERIVED word rather than the raw table value — "none" is not a substring
|
|
## of "no armour", and asserting on the raw value only ever passed by accident.
|
|
return ARMOR_WORD.get(Callings.armor(id), Callings.armor(id))
|
|
|
|
|
|
static func resource_word(id: String) -> String:
|
|
## Martials live on cooldowns; casters on a pool off their casting stat.
|
|
if not Callings.is_caster(id):
|
|
return "cooldowns"
|
|
return "MP (%s)" % Callings.casting_stat(id).to_upper()
|
|
|
|
|
|
static func race_trait(id: String) -> String:
|
|
## e.g. "+1 to every save · +1 skill of choice" / "Poison resist · Nightsight"
|
|
if not Races.exists(id):
|
|
return ""
|
|
var parts: Array = []
|
|
if Races.save_bonus(id) > 0:
|
|
parts.append("+%d to every save" % Races.save_bonus(id))
|
|
for s in Races.granted_skills(id):
|
|
parts.append(skill_label(s).capitalize())
|
|
if Races.poison_save_bonus(id) > 0:
|
|
parts.append("Poison resist")
|
|
if Races.flag(id, "nightsight"):
|
|
parts.append("Nightsight")
|
|
if Races.flag(id, "claws"):
|
|
parts.append("Claws")
|
|
if Races.flag(id, "keen_scent"):
|
|
parts.append("Keen scent")
|
|
if Races.wants_bonus_skill(id):
|
|
parts.append("+1 skill of choice")
|
|
return " · ".join(parts)
|
|
|
|
|
|
static func skill_label(skill: String) -> String:
|
|
## sleight_of_hand -> "sleight of hand". Ids are snake_case; humans are not.
|
|
return skill.replace("_", " ")
|
|
|
|
|
|
# ------------------------------------------------------------------- §13: the nag
|
|
|
|
static func error_line(error: String, draft: CreationDraft) -> String:
|
|
## The validator's diagnostics are written for whoever is reading the errors
|
|
## array — "hedge_mage picks 2 skills, got 0", "unresolved ref: item:x". They are
|
|
## a CONTRACT (other code reads those strings), so they are not rewritten; they
|
|
## are TRANSLATED, here, in the presentation layer, where translating them
|
|
## belongs. What the player reads on the second screen of the game is authored
|
|
## copy: dry, second person, flat. Never an id, never a compiler's grammar (§13).
|
|
##
|
|
## Anything unmapped degrades to UNSPOKEN — never the raw string, never blank.
|
|
if error.begins_with("player name is required"):
|
|
return "name yourself"
|
|
if error.begins_with("unknown race"):
|
|
return "decide what blood you carry"
|
|
if error.begins_with("unknown calling"):
|
|
return "decide what you do for coin"
|
|
if error.begins_with("calling not allowed by origin"):
|
|
return "that trade is closed to you — you left it behind"
|
|
if error.contains(" picks ") and error.contains(" skills, got "):
|
|
return _proficiency_line(draft)
|
|
if error.begins_with("duplicate skill pick"):
|
|
return "you have taken the same proficiency twice"
|
|
if error.contains(" is not in the ") and error.ends_with(" pool"):
|
|
return "your calling never taught you that"
|
|
if error.contains(" is already granted by "):
|
|
return "you have that one already — it buys you nothing"
|
|
if error.contains(" must choose a bonus skill"):
|
|
return "one more proficiency, any of them — take it"
|
|
if error.begins_with("unknown bonus skill"):
|
|
return "no one here would call that a skill"
|
|
if error.begins_with("bonus skill ") and error.ends_with(" is already proficient"):
|
|
return "you have that one already — take another"
|
|
if error.contains(" does not get a bonus skill"):
|
|
return "your blood grants no proficiency of its own"
|
|
return UNSPOKEN
|
|
|
|
|
|
static func _proficiency_line(draft: CreationDraft) -> String:
|
|
## "choose two more proficiencies". The NUMBER is derived — Callings.skill_count
|
|
## is the rules table's business and the copy asks it, every time, rather than
|
|
## carrying a second copy of it that would one day disagree.
|
|
var left: int = draft.picks_left()
|
|
if left <= 0:
|
|
return "you have taken more proficiencies than your calling can carry"
|
|
if left == 1:
|
|
return "choose one more proficiency"
|
|
return "choose %s more proficiencies" % number_word(left)
|
|
|
|
|
|
static func number_word(n: int) -> String:
|
|
return str(NUMBER_WORD.get(n, n))
|