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()])