Files
code_of_conquest_dnd/client/tests/unit/test_content_db.gd
Phillip Tarrant a9357fb787 fix(creation-model): migrate stray class_id/allowed_classes copies
The canon-log schema and NewGame code already migrated to race_id/
calling_id + allowed_callings, but two copies of the roster outside
that schema were missed, and no parity test guards them:

- docs/canon-log.md (the cross-boundary contract doc) still documented
  the OLD player shape and allowed_classes. Anyone building a player
  block from the doc would get a 422.
- .claude/skills/world-building's schema reference still emitted
  allowed_classes with two dead calling ids (assassin, priest). Author
  a new origin with that skill and it fails origin.schema.json
  (additionalProperties: false, allowed_callings required) AND, if that
  ever loosened, silently allows zero callings at runtime.

Also:
- add schema tests rejecting a dead class_id field and an unknown
  calling_id (priest)
- guard NewGame._validate's container types (spend/skills) so a
  malformed JSON round-trip (null spend, string skills) produces a
  front-loaded error list instead of a GDScript runtime crash
- extend the no-mechanics-in-content test to db.races, not just
  db.callings
- rewrite the roadmap's M4 bullet: the two contract migrations landed;
  only the creation screen and title->creation->shell flow remain

client: 250/250. api: 74 passed, 2 skipped. content_build --check: clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:58:49 -05:00

133 lines
4.2 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])