The creation screen shows a race's blurb on its card and the chosen calling's
blurb in the detail panel directly above the DM panel. Blurb and fragment are on
screen at the same moment, inches apart — so a fragment that paraphrases its own
blurb makes the DM sound like it is reciting the card. Four did:
- bonesetter lifted two clauses verbatim ("you set the bone, you say the words")
- reaver re-ran the blurb's door image with the same sentence shape
- hedge_mage re-opened on "what you know" and re-landed the dead teacher
- beastfolk repeated both the claws and the being-judged beat
Each now says the NEXT thing instead: the hour they wake you and which of the
two trades you will not claim credit for; the employer who arranges to be
elsewhere; where you went to fail safely and what the book's gaps cost your
skin; the town that takes your coin and still wants you outside the walls.
beastfolk's rewrite also breaks the race-set monotony — it no longer opens "You
were born" (now unique to human) and drops the shared "X, and you have…" spine,
so clicking the four cards in a row stops reading as one voice.
The parity test guarded presence but could not fail on the three properties that
actually make all 28 race×calling concatenations grammatical. It now asserts
race fragments start uppercase and end with '.', and calling fragments start
lowercase and end with '.' — verified by breaking both and watching all four new
assertions fire.
255 client tests green; content build --check green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
162 lines
6.0 KiB
GDScript
162 lines
6.0 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)
|