feat(creation): CreationCopy — the card reads the table

Display strings derived from Callings/Races/Skills at call time. Retune a hit
die and the card follows, because the card never knew the number. Prose stays in
content; numbers stay in code; nothing is written twice.

Deviation from the plan, approved by the human: the plan's test asserted the
rendered line contains Callings.armor(id) — the RAW table value — while the plan's
own ARMOR_WORD maps "none" to "no armour". "none" is not a substring of "no armour",
so the Hedge-Mage failed. The card's copy is what the plan's docstring example shows,
so the copy stands and the test was fixed: armor_word(id) is now public and the test
asserts against the derived word. The guard is unchanged — hardcode an armour string
and retune the table, and it still goes red.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 14:28:30 -05:00
parent 057b83679b
commit 537b0e1d18
4 changed files with 132 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
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",
}
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),
Callings.talent(id),
]
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("_", " ")

View File

@@ -0,0 +1 @@
uid://rm6bkgstenf1

View File

@@ -0,0 +1,54 @@
extends "res://addons/gut/test.gd"
func test_calling_detail_reads_the_table_rather_than_repeating_it():
# The guard that fails if anyone types "d8" into a string literal. If someone
# retunes the Cutpurse's hit die, this test keeps passing and the CARD FOLLOWS —
# which is the whole point. A hardcoded line would go red here.
for id in Callings.IDS:
var line := CreationCopy.calling_detail(id)
assert_string_contains(line, "d%d" % Callings.hit_die(id))
assert_string_contains(line, CreationCopy.armor_word(id))
assert_string_contains(line, Callings.talent(id))
assert_string_contains(line, str(Callings.skill_count(id)))
for s in Callings.saves(id):
assert_string_contains(line, s.to_upper())
func test_casters_show_a_pool_and_martials_show_cooldowns():
assert_string_contains(CreationCopy.calling_detail("hedge_mage"), "MP (MAG)")
assert_string_contains(CreationCopy.calling_detail("bonesetter"), "MP (FTH)")
assert_string_contains(CreationCopy.calling_detail("sellsword"), "cooldowns")
assert_false(CreationCopy.calling_detail("sellsword").contains("MP"))
func test_race_trait_lines_are_derived_from_the_race_table():
assert_string_contains(CreationCopy.race_trait("human"), "every save")
assert_string_contains(CreationCopy.race_trait("human"), "skill of choice")
assert_string_contains(CreationCopy.race_trait("elf"), "Perception")
assert_string_contains(CreationCopy.race_trait("elf"), "Nightsight")
assert_string_contains(CreationCopy.race_trait("dwarf"), "Poison")
assert_string_contains(CreationCopy.race_trait("dwarf"), "Nightsight")
assert_string_contains(CreationCopy.race_trait("beastfolk"), "Claws")
assert_string_contains(CreationCopy.race_trait("beastfolk"), "Keen scent")
func test_every_race_has_a_non_empty_trait_line():
for id in Races.IDS:
assert_ne(CreationCopy.race_trait(id).strip_edges(), "", "%s has no trait line" % id)
func test_skill_labels_are_human_readable():
assert_eq(CreationCopy.skill_label("sleight_of_hand"), "sleight of hand")
assert_eq(CreationCopy.skill_label("faith_lore"), "faith lore")
assert_eq(CreationCopy.skill_label("stealth"), "stealth")
func test_nothing_in_the_copy_mentions_luck():
# §7 — the player must never be able to CALCULATE that he is cursed.
for id in Callings.IDS:
assert_false(CreationCopy.calling_detail(id).to_lower().contains("luck"))
assert_false(CreationCopy.calling_detail(id).to_lower().contains("lck"))
for id in Races.IDS:
assert_false(CreationCopy.race_trait(id).to_lower().contains("luck"))
assert_false(CreationCopy.race_trait(id).to_lower().contains("lck"))

View File

@@ -0,0 +1 @@
uid://bvb87ime573x4