Files
code_of_conquest_dnd/client/tests/unit/test_content_db.gd
Phillip Tarrant 34bf064904 fix(creation-screen): the bonus-skill hole, the theme guard that could not fail, and the strings the player was reading
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>
2026-07-13 16:25:12 -05:00

181 lines
7.1 KiB
GDScript

extends "res://addons/gut/test.gd"
const ContentDB = preload("res://scripts/content/content_db.gd")
var db
func before_each():
db = ContentDB.new()
db.load_from(ContentDB.default_content_root())
func _deserter() -> Dictionary:
return ContentDB.load_json(ContentDB.origin_path("deserter"))
func test_loads_world_ids():
assert_true(db.has_location("greywater_docks"))
assert_true(db.has_quest("find_the_ledger"))
assert_true(db.has_item("worn_shortsword"))
assert_true(db.has_item("copper"))
assert_true(db.has_item("silver"))
assert_true(db.has_item("gold"))
assert_false(db.has_item("coin"), "the undifferentiated coin is gone")
func test_currency_items_agree_with_the_code():
# The denomination ids exist twice — in code (Currency.VALUES, because the
# ratios are rules) and in content (slot: "currency"). Guard the two against
# drifting apart, since nothing at runtime reconciles them.
var content_ids: Array = []
for id in db.items:
if db.items[id].get("slot", "") == "currency":
content_ids.append(id)
content_ids.sort()
var code_ids: Array = Currency.VALUES.keys()
code_ids.sort()
assert_eq(content_ids, code_ids)
func test_companions_are_brannoc_and_cadwyn():
var ids := []
for n in db.companions():
ids.append(n["id"])
ids.sort()
assert_eq(ids, ["brannoc_thane","cadwyn_vell"])
func test_good_origin_has_no_unresolved_refs():
assert_eq(db.unresolved_refs(_deserter()), [])
func test_broken_location_ref_detected():
var o := _deserter()
o["start_location_id"] = "nowhere"
assert_true("location:nowhere" in db.unresolved_refs(o))
func test_broken_item_ref_detected():
var o := _deserter()
o["inventory_grants"] = o["inventory_grants"].duplicate(true)
o["inventory_grants"].append({"item_id": "ghost_blade", "qty": 1})
assert_true("item:ghost_blade" in db.unresolved_refs(o))
func test_null_quest_ref_resolves():
var o := _deserter()
o["start_quest_id"] = null
assert_eq(db.unresolved_refs(o), [])
func test_loads_canon():
assert_true(db.has_canon("faction.the-seven"))
assert_true(db.has_canon("person.the-warden"))
assert_true(db.has_canon("rule.disposition-ladder"))
func test_no_loaded_topic_has_a_body():
# Bodies are server-only (secrecy≥1 knowledge routes to content/server/topics,
# never the client). Structural, so it stays a real guard even when the
# topic set is empty (as it is now — the last authored topics were purged)
# and automatically starts testing something the moment a topic exists.
# One assertion, always executed (not per-loop-item), so an empty topic
# set still exercises the check instead of asserting zero times.
var leaked: Array = []
for id in db.topics:
if db.topics[id].has("body"):
leaked.append(id)
assert_eq(leaked, [], "topic(s) leaked a body to the client")
func test_legacy_npcs_still_load():
assert_true(db.has_npc("fenn"))
func test_loads_calling_and_race_content():
assert_true(db.has_calling("reaver"))
assert_true(db.has_calling("bloodsworn"))
assert_true(db.has_race("beastfolk"))
assert_eq(db.calling("reaver")["name"], "Reaver")
assert_eq(db.race("beastfolk")["name"], "Beastfolk")
func test_calling_content_matches_the_code_table():
# The roster exists in code (Callings.IDS — mechanics are rules) and in content
# (the blurbs). Nothing at runtime reconciles them, so guard against drift.
var content_ids: Array = db.callings.keys()
content_ids.sort()
var code_ids: Array = Callings.IDS.duplicate()
code_ids.sort()
assert_eq(content_ids, code_ids)
func test_race_content_matches_the_code_table():
var content_ids: Array = db.races.keys()
content_ids.sort()
var code_ids: Array = Races.IDS.duplicate()
code_ids.sort()
assert_eq(content_ids, code_ids)
func test_blurb_content_carries_no_mechanics():
# Mechanics are code. A hit_die in a content file is a second source of truth.
for id in db.callings:
for banned in ["hit_die", "saves", "skill_pool", "skill_count", "armor", "talent"]:
assert_false(db.callings[id].has(banned),
"%s.json leaks the mechanic '%s' that lives in Callings" % [id, banned])
for id in db.races:
for banned in ["save_bonus", "poison_save_bonus", "granted_skills", "flags",
"nightsight", "claws", "keen_scent"]:
assert_false(db.races[id].has(banned),
"%s.json leaks the mechanic '%s' that lives in Races" % [id, banned])
func test_every_race_and_calling_carries_a_fragment():
# The DM origin panel (M4-b) composes race.fragment + calling.fragment. A
# missing one degrades to the blurb at runtime rather than crashing — but it
# must never SHIP missing, so the suite is where it fails.
#
# The composition is "{race.fragment} Now you carry a {Calling}'s work —
# {calling.fragment}", so three shape rules make all 28 pairings legal
# sentences: a race fragment opens a sentence (uppercase) and closes it (.);
# a calling fragment continues one after the em dash (lowercase) and closes
# it (.). Guard them here — a future author adding a calling gets a signal,
# not a subtly ungrammatical DM.
for id in Races.IDS:
assert_true(db.race(id).has("fragment"), "race %s has no fragment" % id)
var race_fragment := str(db.race(id).get("fragment", "")).strip_edges()
assert_ne(race_fragment, "", "race %s has an empty fragment" % id)
assert_eq(race_fragment.substr(0, 1), race_fragment.substr(0, 1).to_upper(),
"race %s: fragment opens the composition, so it must start uppercase" % id)
assert_true(race_fragment.ends_with("."),
"race %s: fragment must end with '.' — a sentence precedes 'Now you carry'" % id)
for id in Callings.IDS:
assert_true(db.calling(id).has("fragment"), "calling %s has no fragment" % id)
var calling_fragment := str(db.calling(id).get("fragment", "")).strip_edges()
assert_ne(calling_fragment, "", "calling %s has an empty fragment" % id)
assert_eq(calling_fragment.substr(0, 1), calling_fragment.substr(0, 1).to_lower(),
"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()])