diff --git a/client/scripts/theme/theme_keys.gd b/client/scripts/theme/theme_keys.gd index f7f6800..552df23 100644 --- a/client/scripts/theme/theme_keys.gd +++ b/client/scripts/theme/theme_keys.gd @@ -31,6 +31,22 @@ const TITLE_LOGO := &"TitleLogo" const TITLE_KICKER := &"TitleKicker" const SECTION_LABEL := &"SectionLabel" +## Every FONT role + the base Control type it decorates. Deliberately NOT folded +## into ALL — ALL's "stylebox variations only" contract is relied on elsewhere +## (test_every_variation_resolves, and the styleboxes the screens set). Kept as a +## second enumerable set so a guard that means "every variation the builder +## touches" can iterate BOTH and never silently cover only half of them: the +## drift guard used to iterate ALL alone, so a font, a font size or a font colour +## could change in the builder and the committed .tres stay stale, suite green. +const FONT_ROLES := { + HEADING: "Label", + ACCENT: "Label", + MONO: "Label", + TITLE_LOGO: "Label", + TITLE_KICKER: "Label", + SECTION_LABEL: "Label", +} + ## Every variation name + the base Control type it decorates. The builder and the ## test both iterate this so they can never drift apart. const ALL := { diff --git a/client/scripts/ui/creation/character_creation.gd b/client/scripts/ui/creation/character_creation.gd index ce717b3..e25c363 100644 --- a/client/scripts/ui/creation/character_creation.gd +++ b/client/scripts/ui/creation/character_creation.gd @@ -287,6 +287,11 @@ func _origin_prose() -> String: func _bind_cta() -> void: + ## The errors array is the VALIDATOR's, written for code. What lands on the + ## label is CreationCopy's, written for the player — "choose two more + ## proficiencies", not "hedge_mage picks 2 skills, got 0" (§13). The mapping is + ## in the presentation layer on purpose: NewGame.validate's strings are a + ## contract, and this screen is not the only thing that reads them. var errors := draft.errors(origin, world) _enter.disabled = not errors.is_empty() - _error.text = str(errors[0]) if not errors.is_empty() else CTA_HINT + _error.text = CreationCopy.error_line(str(errors[0]), draft) if not errors.is_empty() else CTA_HINT diff --git a/client/scripts/ui/creation/creation_copy.gd b/client/scripts/ui/creation/creation_copy.gd index 6a7fea6..6dc4a4a 100644 --- a/client/scripts/ui/creation/creation_copy.gd +++ b/client/scripts/ui/creation/creation_copy.gd @@ -17,6 +17,16 @@ const ARMOR_WORD := { "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" @@ -31,10 +41,19 @@ static func calling_detail(id: String) -> String: resource_word(id), " + ".join(saves), Callings.skill_count(id), - Callings.talent(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 @@ -74,3 +93,57 @@ static func race_trait(id: String) -> String: 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)) diff --git a/client/scripts/ui/creation/creation_draft.gd b/client/scripts/ui/creation/creation_draft.gd index ac6c589..7bb55d5 100644 --- a/client/scripts/ui/creation/creation_draft.gd +++ b/client/scripts/ui/creation/creation_draft.gd @@ -156,6 +156,14 @@ func is_picked(skill: String) -> bool: 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 diff --git a/client/tests/unit/test_character_creation_screen.gd b/client/tests/unit/test_character_creation_screen.gd index 5eb8aab..88c3c3e 100644 --- a/client/tests/unit/test_character_creation_screen.gd +++ b/client/tests/unit/test_character_creation_screen.gd @@ -121,6 +121,17 @@ func test_no_node_on_the_screen_says_luck(): var checked_a_rich_text_label := false var combos_checked := 0 + # The word "luck" is not the only thing that leaks Luck. Luck.BANDS holds the + # DESCRIPTORS — "the dice are kind today" — and a descriptor on this screen is + # strictly worse than the number: the player would re-roll until his Luck read + # well, which is Luck made CALCULABLE, the precise §7 failure. It is structurally + # unreachable today (the screen holds no GameState and the draft has no LCK), and + # it costs three lines to keep it that way. + var forbidden: Array = ["luck", "lck"] + for band in Luck.BANDS: + forbidden.append(str(band["text"]).to_lower()) + assert_eq(forbidden.size(), Luck.BANDS.size() + 2, "every band's text must be swept, not just the word") + for race_i in range(Races.IDS.size()): s._race_cards[race_i].pressed.emit() var allowed: Array = s._allowed_callings() @@ -137,10 +148,9 @@ func test_no_node_on_the_screen_says_luck(): if typeof(value) != TYPE_STRING: continue var lowered: String = value.to_lower() - assert_false(lowered.contains("luck"), - "%s.%s leaks Luck (§7) at %s" % [node.name, prop, combo]) - assert_false(lowered.contains("lck"), - "%s.%s leaks Luck (§7) at %s" % [node.name, prop, combo]) + for word in forbidden: + assert_false(lowered.contains(word), + "%s.%s leaks Luck (§7) at %s: '%s'" % [node.name, prop, combo, word]) # `checked_a_rich_text_label` alone only proves the sweep REACHED a # RichTextLabel, not that it had anything in it — an empty label would @@ -265,6 +275,21 @@ func test_authored_node_counts_match_the_rules_tables(): assert_eq(s._bonus_row.get_child_count(), Skills.IDS.size() + 1, "an orphan authored bonus chip would sit here, unbound and visible") + # PoolRow was the one row the sweep above forgot — a stray authored "Pool6" would + # render unbound and visible with every assertion so far still green, which is + # exactly the bug this test exists to catch. Its count is not a table LENGTH: it + # is the widest calling's pool (plus its own "Count" label, which is not a chip), + # so derive that from the rules table rather than writing 6 down again. + var widest := 0 + for id in Callings.IDS: + widest = maxi(widest, Callings.skill_pool(id).size()) + assert_eq(widest, s._pool_chips.size(), "the pool row must hold exactly the widest calling's pool") + assert_eq(s._pool_row.get_child_count(), widest + 1, + "an orphan authored pool chip would sit here, unbound and visible (PoolRow also holds its Count label)") + assert_null(s._pool_row.get_node_or_null("Pool%d" % widest), + "there is no chip past the widest calling's pool — a stray Pool%d renders unbound" % widest) + assert_eq(s._pool_count.get_parent(), s._pool_row, "the +1 above is the Count label, and nothing else") + func test_cta_is_disabled_until_the_draft_is_legal(): var s := _screen() @@ -278,6 +303,84 @@ func test_cta_is_disabled_until_the_draft_is_legal(): "a legal draft gets the mock's line back, not a blank") +func test_the_nag_under_the_cta_is_authored_copy_and_never_a_validator_string(): + # §13. The label used to read `str(errors[0])` — so the second screen of the game + # showed the player "sellsword picks 2 skills, got 0". Drive the screen through + # every invalid state it can actually reach, by pressing the nodes, and pin what + # the label says. Anchored to the authored phrases themselves: "contains no + # underscore" alone would pass on a blank label, or on the wrong copy. + var s := _screen() # human + sellsword, nameless, nothing picked + var seen: Array = [] + + assert_eq(s._error.text, "name yourself", "the first thing missing is a name") + seen.append(s._error.text) + + s._name_edit.text = "Aldric" + s._on_name_changed("Aldric") # LineEdit.text_changed is not raised by setting .text + assert_eq(s._error.text, "choose two more proficiencies", + "the sellsword picks 2 and has none — and the two is DERIVED from Callings.skill_count") + seen.append(s._error.text) + + s._pool_chips[0].pressed.emit() + assert_eq(s.draft.skills.size(), 1, "the pick landed, or the line below is about nothing") + assert_eq(s._error.text, "choose one more proficiency", "one is singular") + seen.append(s._error.text) + + s._pool_chips[1].pressed.emit() + assert_eq(s.draft.skills.size(), 2) + assert_eq(s._error.text, "one more proficiency, any of them — take it", + "the human's bonus skill, in the Margreave's voice, not 'human must choose a bonus skill'") + seen.append(s._error.text) + + for i in range(Skills.IDS.size()): + if s.draft.can_take_bonus(Skills.IDS[i]): + s._bonus_chips[i].pressed.emit() + break + assert_eq(s._error.text, CharacterCreation.CTA_HINT, "a legal draft gets the mock's line back") + + # Nothing above may be an engineering string, and none of them may repeat. + assert_eq(seen.size(), 4) + for line in seen: + assert_false(line.contains("_"), "a snake_case id reached the player: %s" % line) + assert_false(line.contains("got "), "the validator's grammar reached the player: %s" % line) + var unique := {} + for line in seen: + unique[line] = true + assert_eq(unique.size(), 4, "each invalid state says a DIFFERENT thing — the nag is telling him what is missing") + + +func test_a_skill_taken_as_the_bonus_goes_inert_in_the_calling_s_pool(): + # The one-directional guard: can_take_bonus() refused an already-PICKED skill, but + # can_pick() ignored the bonus. Human + Sellsword, take athletics as the bonus, + # then press athletics in the pool — the chip was live and the pick LANDED, leaving + # a draft NewGame.validate rejects ("bonus skill athletics is already proficient"). + var s := _screen() # human + sellsword + var pool: Array = s.draft.skill_pool() + var athletics_i: int = pool.find("athletics") + var bonus_i: int = Skills.IDS.find("athletics") + assert_ne(athletics_i, -1, "athletics must be in the sellsword's pool or this proves nothing") + assert_false(s._pool_chips[athletics_i].disabled, "the chip starts live, or the assertion below is trivially true") + + s._bonus_chips[bonus_i].pressed.emit() + assert_eq(s.draft.bonus_skill, "athletics") + assert_true(s._pool_chips[athletics_i].disabled, + "a skill the bonus already bought must render INERT in the pool") + + # Button.pressed fires even on a disabled Button — the rule lives in the draft, not + # in the styling, so press it and prove the pick does not land. + s._pool_chips[athletics_i].pressed.emit() + assert_false(s.draft.is_picked("athletics"), "the pick must not land") + assert_false("bonus skill athletics is already proficient" in s.draft.errors(s.origin, s.world), + "the player must not be able to press his way into an invalid draft") + + # And it is recoverable: hand the bonus back and the chip is live again. + s._bonus_chips[bonus_i].pressed.emit() + assert_eq(s.draft.bonus_skill, "") + assert_false(s._pool_chips[athletics_i].disabled, "handing the bonus back makes the pool chip live") + s._pool_chips[athletics_i].pressed.emit() + assert_true(s.draft.is_picked("athletics")) + + func test_pressing_enter_emits_exactly_the_draft_s_creation_dict(): var s := _screen() _complete_a_valid_draft(s) diff --git a/client/tests/unit/test_content_db.gd b/client/tests/unit/test_content_db.gd index dee944a..5325a88 100644 --- a/client/tests/unit/test_content_db.gd +++ b/client/tests/unit/test_content_db.gd @@ -159,3 +159,22 @@ func test_every_race_and_calling_carries_a_fragment(): "calling %s: fragment follows an em dash, so it must start lowercase" % id) assert_true(calling_fragment.ends_with("."), "calling %s: fragment must end with '.' — it closes the composition" % id) + + +func test_no_calling_is_named_with_a_vowel(): + # The fourth shape rule of that same composition, and the one that breaks SILENTLY. + # CharacterCreation._origin_prose() hardcodes the article: + # "%s Now you carry a %s's work — %s" + # All seven callings are consonant-initial, so all 28 compositions read correctly + # today and nothing anywhere would notice an eighth. Add "an Auger" and every + # Auger in the game gets "Now you carry a auger's work" on the DM panel — no + # error, no test, just a DM who cannot speak. Fail HERE, where the author is. + var vowels := ["a", "e", "i", "o", "u"] + for id in Callings.IDS: + var calling_name := str(db.calling(id).get("name", "")).strip_edges() + assert_ne(calling_name, "", "calling %s has no name" % id) + assert_false(calling_name.substr(0, 1).to_lower() in vowels, + ("calling %s is named '%s'. The DM origin panel composes " + + "\"Now you carry a %s's work\" with the article HARDCODED as \"a\" " + + "(CharacterCreation._origin_prose) — a vowel-initial calling needs \"an\". " + + "Change the template first, then this guard.") % [id, calling_name, calling_name.to_lower()]) diff --git a/client/tests/unit/test_creation_copy.gd b/client/tests/unit/test_creation_copy.gd index bada308..16b6e76 100644 --- a/client/tests/unit/test_creation_copy.gd +++ b/client/tests/unit/test_creation_copy.gd @@ -1,20 +1,64 @@ extends "res://addons/gut/test.gd" +const ContentDB = preload("res://scripts/content/content_db.gd") +const NewGame = preload("res://scripts/newgame/new_game.gd") + +var world + + +func before_each(): + world = ContentDB.new() + world.load_from(ContentDB.default_content_root()) + + +func _deserter() -> Dictionary: + return ContentDB.load_json(ContentDB.origin_path("deserter")) + + +func _draft(race := "human", calling := "sellsword") -> CreationDraft: + var d := CreationDraft.new() + d.character_seed = 8675309 + d.set_race(race) + d.set_calling(calling) + d.name = "Aldric" + return d + 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. + # + # The talent is asserted through the DERIVED phrase ("talent second wind"), not + # the raw id ("second_wind") the card used to print — but it is still read from + # Callings.talent() at assert time, so hardcoding a talent into the format string + # still goes red (traps.md #7: anchor to the full derived phrase). 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, "talent %s" % CreationCopy.talent_word(id)) assert_string_contains(line, "picks %d skills" % Callings.skill_count(id)) for s in Callings.saves(id): assert_string_contains(line, s.to_upper()) +func test_the_talent_is_humanised_and_no_card_line_shows_a_raw_id(): + # The player read "talent second_wind" off the Sellsword's card. Four of the seven + # talents carry an underscore — the card printed the ID. + assert_eq(CreationCopy.talent_word("sellsword"), "second wind") + assert_eq(CreationCopy.talent_word("cutpurse"), "backstab", "an underscore-free talent is untouched") + + var underscored := 0 + for id in Callings.IDS: + if Callings.talent(id).contains("_"): + underscored += 1 + assert_false(CreationCopy.calling_detail(id).contains("_"), + "%s's card line shows a raw snake_case id: %s" % [id, CreationCopy.calling_detail(id)]) + assert_eq(underscored, 4, + "four talents carry an underscore — if that ever hits zero this guard stops guarding") + + 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)") @@ -52,3 +96,86 @@ func test_nothing_in_the_copy_mentions_luck(): 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")) + + +# ------------------------------------------------------------------- §13: the nag + +func test_the_nag_is_authored_copy_not_the_validator_s_grammar(): + # §13. The exact strings a human must be able to judge, pinned. Anchored to the + # authored phrase itself — asserting only "no underscore" would pass on any + # string that happens to have none, including a blank one. + var human := _draft("human", "sellsword") + human.name = "" + assert_eq(CreationCopy.error_line("player name is required", human), "name yourself") + + # "sellsword picks 2 skills, got 0" -> the count is DERIVED from the rules table. + assert_eq(Callings.skill_count("sellsword"), 2, "the sellsword picks 2, or the line below means nothing") + assert_eq(CreationCopy.error_line("sellsword picks 2 skills, got 0", human), + "choose two more proficiencies") + human.toggle_skill("athletics") + assert_eq(CreationCopy.error_line("sellsword picks 2 skills, got 1", human), + "choose one more proficiency", "one is singular, and the number is never hardcoded") + + # The same error against a calling that picks FOUR must say four — the proof the + # number comes from Callings.skill_count and not from a literal "two". + var thief := _draft("dwarf", "cutpurse") + assert_eq(Callings.skill_count("cutpurse"), 4, "the cutpurse picks 4, or the line below means nothing") + assert_eq(CreationCopy.error_line("cutpurse picks 4 skills, got 0", thief), + "choose four more proficiencies") + + assert_eq(CreationCopy.error_line("human must choose a bonus skill", human), + "one more proficiency, any of them — take it") + + +func test_an_unmapped_error_still_speaks_in_voice(): + # §13: "write fallbacks as content, not as error handling." An error nobody + # anticipated (broken content; a spend the screen cannot produce) must never put + # the raw string on the label, and must never blank it either. + var d := _draft() + for raw in ["unresolved ref: item:ghost_blade", "seed is required and must be an integer", + "spend exceeds the pool of 3 (got 5)", "cannot spend on 'lck' — not an attribute"]: + var line := CreationCopy.error_line(raw, d) + assert_eq(line, CreationCopy.UNSPOKEN, "an unmapped error degrades to the authored fallback: %s" % raw) + assert_ne(line, raw) + assert_ne(CreationCopy.UNSPOKEN.strip_edges(), "", "the fallback is CONTENT — it is never blank") + assert_false(CreationCopy.UNSPOKEN.contains("_")) + + +func test_no_error_the_validator_can_raise_reaches_the_player_raw(): + # Sweep EVERY error NewGame.validate actually produces for a draft the screen can + # be driven into (plus the hostile shapes only a saga could hand it), and assert + # the player never sees the id, the underscore, or the validator's grammar. + var d := _draft() + var hostiles: Array = [ + {"name": "", "skills": [], "bonus_skill": ""}, # nameless, unpicked + {"skills": ["athletics"]}, # too few + {"skills": ["athletics", "athletics"]}, # duplicate + {"skills": ["athletics", "sorcery"]}, # not in the pool + {"race_id": "elf", "skills": ["athletics", "perception"], "bonus_skill": ""}, # granted already + {"bonus_skill": ""}, # human, no bonus + {"bonus_skill": "athletics", "skills": ["athletics", "endurance"]}, # bonus == a pick + {"bonus_skill": "swimming"}, # no such skill + {"race_id": "dwarf", "bonus_skill": "stealth"}, # dwarf gets none + {"race_id": "orc"}, {"calling_id": "paladin"}, # off the tables + {"seed": "8675309"}, {"spend": {"lck": 3}}, {"spend": {"str": 9}}, + ] + var base := { + "name": "Aldric", "race_id": "human", "calling_id": "sellsword", "seed": 8675309, + "spend": {}, "skills": ["athletics", "endurance"], "bonus_skill": "perception", + } + + var seen := 0 + for h in hostiles: + var creation := base.duplicate(true) + for k in h: + creation[k] = h[k] + var errors: Array = NewGame.validate(_deserter(), world, creation) + assert_gt(errors.size(), 0, "hostile creation %s must actually be invalid, or it guards nothing" % str(h)) + for e in errors: + seen += 1 + var line: String = CreationCopy.error_line(str(e), d) + assert_ne(line.strip_edges(), "", "'%s' put a BLANK line on the label" % e) + assert_ne(line, str(e), "'%s' reached the player raw" % e) + assert_false(line.contains("_"), "'%s' put a snake_case id on the label: %s" % [e, line]) + assert_false(line.contains("got "), "'%s' put the validator's grammar on the label: %s" % [e, line]) + assert_gt(seen, 12, "the sweep must actually reach a spread of errors, not one or two") diff --git a/client/tests/unit/test_creation_draft.gd b/client/tests/unit/test_creation_draft.gd index 8d498c3..9c79151 100644 --- a/client/tests/unit/test_creation_draft.gd +++ b/client/tests/unit/test_creation_draft.gd @@ -139,6 +139,65 @@ func test_toggling_an_already_picked_skill_removes_it(): assert_eq(d.skills, []) +func test_the_bonus_skill_cannot_also_be_picked_from_the_pool(): + # The guard was one-directional: can_take_bonus() refused a skill already PICKED, + # but can_pick() did not consider the bonus. Human + Sellsword, take athletics as + # the bonus, then press athletics in the Sellsword's pool — the pick landed and + # the draft went invalid ("bonus skill athletics is already proficient"), which is + # precisely the invalid draft "he did not cause and cannot see" that set_calling's + # comment forbids. + var d := _draft() # human + sellsword; athletics is in the pool + d.set_bonus_skill("athletics") + assert_eq(d.bonus_skill, "athletics") + assert_true("athletics" in d.skill_pool(), "athletics must be in the sellsword's pool or this proves nothing") + + assert_false(d.can_pick("athletics"), "the chip must be INERT, not merely styled to look inert") + d.toggle_skill("athletics") + assert_eq(d.skills, [], "the pick must not land while athletics is the bonus skill") + + # ...and the draft the press would have produced is exactly the one construct rejects. + d.skills = ["athletics", "endurance"] # what the bug let the player build + assert_true("bonus skill athletics is already proficient" in d.errors(_deserter(), world), + "this IS the invalid draft the guard exists to prevent") + + +func test_the_other_direction_still_holds_a_picked_skill_cannot_become_the_bonus(): + # The half of the guard that already worked. Kept beside its mirror so the two + # can never drift apart again. + var d := _draft() + d.toggle_skill("athletics") + assert_eq(d.skills, ["athletics"]) + assert_false(d.can_take_bonus("athletics")) + d.set_bonus_skill("athletics") + assert_eq(d.bonus_skill, "", "a picked skill must not also be taken as the bonus") + + +func test_handing_back_the_bonus_skill_makes_the_pool_chip_live_again(): + # The inert chip is recoverable — the bonus row is right there. Without this the + # fix above would be a dead end the player could walk into and not walk out of. + var d := _draft() + d.set_bonus_skill("athletics") + assert_false(d.can_pick("athletics")) + d.set_bonus_skill("athletics") # pressing the chosen bonus chip clears it + assert_eq(d.bonus_skill, "") + assert_true(d.can_pick("athletics"), "handing the bonus back must make the pool chip live") + d.toggle_skill("athletics") + assert_eq(d.skills, ["athletics"]) + + +func test_to_creation_carries_exactly_the_seven_contract_keys(): + # §2, stated as a test rather than as a comment. The screen emits a seed and four + # choices; it never hands construct a NUMBER. test_pressing_enter_emits_exactly_ + # the_draft_s_creation_dict compares the emitted dict to to_creation() — both + # sides move together, so adding an "attributes" key there breaks nothing. This + # pins the contract itself: seven keys, no more, no fewer. + var d := _draft() + var keys: Array = d.to_creation().keys() + keys.sort() + assert_eq(keys, ["bonus_skill", "calling_id", "name", "race_id", "seed", "skills", "spend"], + "the creation contract is exactly seven keys — an eighth (an 'attributes' block, say) is the §2 breach") + + func test_to_creation_round_trips_through_construct(): var d := _draft() d.toggle_skill("athletics") diff --git a/client/tests/unit/test_new_game.gd b/client/tests/unit/test_new_game.gd index eec9222..daf32fd 100644 --- a/client/tests/unit/test_new_game.gd +++ b/client/tests/unit/test_new_game.gd @@ -340,6 +340,33 @@ func test_construct_builds_exactly_what_roll_attributes_showed(): assert_eq(res["state"].sheet.attributes, expected) +func test_construct_ignores_an_attribute_block_handed_to_it(): + # §2 — "code owns state" — stated as a TEST, not as a comment. Every other test + # here feeds construct a well-behaved creation dict, so nothing in the suite ever + # proves construct REFUSES a number: a construct that trusted creation["attributes"] + # would pass all of them. Hand it a hostile one — an 18 in every stat — and assert + # the sheet is still, exactly, roll_attributes(seed) + spend. + var spend := {"str": 2, "con": 1} + var hostile := _creation({ + "seed": 4242, + "spend": spend, + "attributes": {"str": 18, "dex": 18, "con": 18, "fth": 18, "mag": 18}, + }) + var res := _build(hostile) + assert_true(res["ok"], str(res["errors"])) + + var shown := NewGame.roll_attributes(4242) + var expected := {} + for stat in Attributes.IDS: + expected[stat] = int(shown[stat]) + int(spend.get(stat, 0)) + assert_eq(res["state"].sheet.attributes, expected, + "construct re-rolls from the SEED — a stat block handed to it is not state, it is noise (§2)") + + # And the hostile block is not merely ignored on the sheet — it never becomes state + # anywhere. (A `sheet.attributes` the caller supplied would be the exact §2 failure.) + assert_false(res["log"].to_dict().has("attributes"), "no attribute ever reaches the canon log") + + func test_validate_is_public_and_agrees_with_construct(): var bad := _creation({"calling_id": "paladin"}) var errors := NewGame.validate(_deserter(), world, bad) diff --git a/client/tests/unit/test_theme_resource.gd b/client/tests/unit/test_theme_resource.gd index 7b8eedf..2dc9b8c 100644 --- a/client/tests/unit/test_theme_resource.gd +++ b/client/tests/unit/test_theme_resource.gd @@ -29,22 +29,56 @@ func test_default_font_is_set(): assert_true(t.default_font is FontFile) +func _guarded_variations() -> Array: + # EVERY variation the builder touches — the stylebox variations (ThemeKeys.ALL) + # AND the six font roles (ThemeKeys.FONT_ROLES), which ALL deliberately excludes + # by its own docstring. Iterating ALL alone left every font, font size and font + # colour in the theme unguarded: change Palette.CREAM (TitleLogo's font colour, + # and nothing else's), skip the rebuild, and the suite stayed green with a stale + # .tres shipped. traps.md #12 — "covers everything" must iterate the full set. + var out: Array = ThemeKeys.ALL.keys() + out.append_array(ThemeKeys.FONT_ROLES.keys()) + return out + + +func test_the_drift_guard_covers_every_variation_the_builder_touches(): + # The guard below is only as good as the set it walks. Pin that set to the + # builder's own output: every variation the fresh Theme carries a stylebox, a + # font, a font size or a font colour for MUST be in _guarded_variations(), or + # it can drift unwatched. A new variation added to the builder and forgotten in + # ThemeKeys goes red here rather than sitting unguarded forever. + var fresh: Theme = Builder.build_theme() + var guarded := _guarded_variations() + for variation in fresh.get_type_list(): + var touched := (not fresh.get_stylebox_list(variation).is_empty() + or not fresh.get_font_list(variation).is_empty() + or not fresh.get_font_size_list(variation).is_empty() + or not fresh.get_color_list(variation).is_empty()) + if not touched: + continue + if fresh.get_type_variation_base(variation) == &"": + continue # a BASE type (the builder styles RichTextLabel directly), not a variation + assert_true(variation in guarded, + "the builder styles the variation %s but no drift guard walks it — add it to ThemeKeys.ALL or ThemeKeys.FONT_ROLES" % variation) + + func test_committed_tres_matches_builder(): # Catches "palette/builder changed but nobody regenerated game_theme.tres." # Preloading the builder script and calling its static build_theme() does # NOT run _init() (that only fires on .new()), so this is side-effect-free. # - # Drives its checks off ThemeKeys.ALL so every variation is guarded, not just - # whichever three someone remembered to hardcode here. For each variation: - # every stylebox state the builder actually set (get_stylebox_list) is - # compared property-by-property when it's a StyleBoxFlat, and every font - # colour role the builder actually set (get_color_list) is compared too. - # Nothing the builder never touches is asserted on. + # Drives its checks off ThemeKeys.ALL *and* ThemeKeys.FONT_ROLES so every + # variation is guarded, not just whichever three someone remembered to hardcode + # here — and not just the stylebox half. For each variation: every stylebox + # state, every font, every font size and every font colour the builder actually + # set is compared. Nothing the builder never touches is asserted on. var fresh: Theme = Builder.build_theme() var committed: Theme = load(THEME_PATH) - for variation in ThemeKeys.ALL: + for variation in _guarded_variations(): for state in fresh.get_stylebox_list(variation): + assert_true(committed.has_stylebox(state, variation), + "stale game_theme.tres — %s/%s stylebox is missing entirely; re-run build_game_theme.gd" % [variation, state]) var fresh_box: StyleBox = fresh.get_stylebox(state, variation) var committed_box: StyleBox = committed.get_stylebox(state, variation) assert_eq(committed_box.get_class(), fresh_box.get_class(), @@ -74,7 +108,28 @@ func test_committed_tres_matches_builder(): assert_eq(c.corner_radius_bottom_left, f.corner_radius_bottom_left, "stale game_theme.tres — %s/%s corner_radius_bottom_left drifted from the builder; re-run build_game_theme.gd" % [variation, state]) + # The FONT half — the whole reason this guard could not fail against a font + # role. Compare the font's resource_path, not the Resource: `load()` caches, + # so two references to the same .ttf are the same instance and `==` would be + # true even when the builder swapped SERIF for MONO... only if the .tres + # happened to point at the same file anyway. The path is the thing that drifts. + for font_name in fresh.get_font_list(variation): + assert_true(committed.has_font(font_name, variation), + "stale game_theme.tres — %s/%s font is missing entirely; re-run build_game_theme.gd" % [variation, font_name]) + var fresh_font: Font = fresh.get_font(font_name, variation) + var committed_font: Font = committed.get_font(font_name, variation) + assert_eq(committed_font.resource_path, fresh_font.resource_path, + "stale game_theme.tres — %s/%s font drifted from the builder; re-run build_game_theme.gd" % [variation, font_name]) + + for size_name in fresh.get_font_size_list(variation): + assert_true(committed.has_font_size(size_name, variation), + "stale game_theme.tres — %s/%s font size is missing entirely; re-run build_game_theme.gd" % [variation, size_name]) + assert_eq(committed.get_font_size(size_name, variation), fresh.get_font_size(size_name, variation), + "stale game_theme.tres — %s/%s font size drifted from the builder; re-run build_game_theme.gd" % [variation, size_name]) + for color_name in fresh.get_color_list(variation): + assert_true(committed.has_color(color_name, variation), + "stale game_theme.tres — %s/%s colour is missing entirely; re-run build_game_theme.gd" % [variation, color_name]) var fresh_color: Color = fresh.get_color(color_name, variation) var committed_color: Color = committed.get_color(color_name, variation) assert_eq(committed_color, fresh_color,