Files
code_of_conquest_dnd/client/tests/unit/test_new_game.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

303 lines
11 KiB
GDScript

extends "res://addons/gut/test.gd"
const NewGame = preload("res://scripts/newgame/new_game.gd")
const ContentDB = preload("res://scripts/content/content_db.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 _creation(overrides := {}) -> Dictionary:
var c := {
"name": "Aldric",
"race_id": "human",
"calling_id": "sellsword",
"seed": 8675309,
"spend": {},
"skills": ["athletics", "endurance"],
"bonus_skill": "perception",
}
for k in overrides:
c[k] = overrides[k]
return c
func _build(creation: Dictionary) -> Dictionary:
return NewGame.construct(_deserter(), world, creation)
func test_same_seed_yields_an_identical_character():
# The determinism guarantee (§10). Two independent constructs, one seed.
var a := _build(_creation())
var b := _build(_creation())
assert_true(a["ok"] and b["ok"])
assert_eq(a["state"].sheet.attributes, b["state"].sheet.attributes)
assert_eq(a["state"].luck, b["state"].luck, "even the hidden Luck is reproducible")
func test_golden_vector_pins_the_rng_stream():
# The determinism test above (`test_same_seed_yields_an_identical_character`)
# compares two `construct` runs against EACH OTHER. Both would drift together
# if the roll order changed, so it proves nothing about the ORDER itself.
# Swap two entries in `Attributes.IDS` and that test still passes while every
# already-persisted seed silently becomes a different character.
#
# This test pins one seed to its EXACT rolled values — all five attributes and
# the hidden Luck, drawn from the same stream immediately after — so reordering
# `Attributes.IDS` or moving the Luck roll fails LOUDLY, here, instead of
# silently rewriting every saved character.
var res := _build(_creation({"seed": 8675309}))
assert_true(res["ok"], str(res["errors"]))
var attrs: Dictionary = res["state"].sheet.attributes
assert_eq(int(attrs["str"]), 8)
assert_eq(int(attrs["dex"]), 13)
assert_eq(int(attrs["con"]), 8)
assert_eq(int(attrs["fth"]), 14)
assert_eq(int(attrs["mag"]), 8)
assert_eq(int(res["state"].luck), 6)
func test_a_missing_seed_is_rejected():
# A seed silently defaulting to 0 builds a perfectly valid, deterministic
# character — which is exactly the problem: a saga that forgets the key
# gets no signal at all. Require it explicitly.
var creation := _creation()
creation.erase("seed")
var res := NewGame.construct(_deserter(), world, creation)
assert_false(res["ok"])
assert_true("seed" in str(res["errors"]).to_lower())
func test_a_non_integer_seed_is_rejected():
var res := _build(_creation({"seed": "8675309"}))
assert_false(res["ok"])
assert_true("seed" in str(res["errors"]).to_lower())
func test_a_different_seed_yields_a_different_character():
var a := _build(_creation())
var b := _build(_creation({"seed": 1234567}))
assert_ne(a["state"].sheet.attributes, b["state"].sheet.attributes)
func test_every_attribute_respects_the_floor_of_eight():
# 3d6 can roll 3. The floor is a design guarantee, not a dice accident.
for s in range(40):
var res := _build(_creation({"seed": s}))
assert_true(res["ok"], str(res["errors"]))
for stat in Attributes.IDS:
assert_true(int(res["state"].sheet.attributes[stat]) >= 8,
"seed %d rolled %s below the floor" % [s, stat])
func test_spend_is_additive_on_top_of_the_roll():
var base := _build(_creation())
var spent := _build(_creation({"spend": {"str": 2, "con": 1}}))
assert_eq(int(spent["state"].sheet.attributes["str"]),
int(base["state"].sheet.attributes["str"]) + 2)
assert_eq(int(spent["state"].sheet.attributes["con"]),
int(base["state"].sheet.attributes["con"]) + 1)
assert_eq(int(spent["state"].sheet.attributes["dex"]),
int(base["state"].sheet.attributes["dex"]), "unspent stats are untouched")
func test_spend_over_the_pool_is_rejected():
var res := _build(_creation({"spend": {"str": 2, "con": 2}}))
assert_false(res["ok"])
assert_true("spend" in str(res["errors"]).to_lower())
func test_spend_cannot_be_negative():
var res := _build(_creation({"spend": {"str": -2, "con": 1}}))
assert_false(res["ok"], "additive only there is no [-]")
func test_a_negative_spend_does_not_mask_an_over_pool_spend():
# {"str": -1, "dex": 4} sums to 3 — at the pool limit — so without a
# `continue`/floor after the negative-value error, the over-pool check
# never independently fires; only the negative-value error does.
var res := _build(_creation({"spend": {"str": -1, "dex": 4}}))
assert_false(res["ok"])
var joined := str(res["errors"]).to_lower()
assert_true("negative" in joined, "the negative-value error should fire")
assert_true("exceeds the pool" in joined, "the over-pool error should ALSO fire")
func test_spend_rejects_non_integer_values():
var floats := _build(_creation({"spend": {"str": 2.9}}))
assert_false(floats["ok"], "a float must be rejected, not truncated to +2")
var bools := _build(_creation({"spend": {"str": true}}))
assert_false(bools["ok"], "a bool must be rejected, not coerced to +1")
var strings := _build(_creation({"spend": {"str": "abc"}}))
assert_false(strings["ok"], "a string must be rejected, not coerced to +0")
func test_luck_can_never_be_spent():
# §7: LCK is untouchable at creation. Not hidden — impossible.
var res := _build(_creation({"spend": {"lck": 3}}))
assert_false(res["ok"])
func test_skills_must_come_from_the_callings_pool():
var res := _build(_creation({"skills": ["athletics", "sorcery"]}))
assert_false(res["ok"], "sorcery is not in the sellsword's pool")
func test_skills_must_be_the_right_count():
var res := _build(_creation({"skills": ["athletics"]}))
assert_false(res["ok"], "a sellsword picks 2")
func test_skills_reject_a_duplicate():
var res := _build(_creation({"skills": ["athletics", "athletics"]}))
assert_false(res["ok"])
func test_an_elf_cannot_spend_a_pick_on_perception():
# He already has it. A pick that buys nothing is a trap, not a choice.
var res := _build(_creation({
"race_id": "elf", "bonus_skill": "",
"skills": ["athletics", "perception"]}))
assert_false(res["ok"])
func test_the_elf_is_granted_perception_anyway():
var res := _build(_creation({
"race_id": "elf", "bonus_skill": "", "skills": ["athletics", "endurance"]}))
assert_true(res["ok"], str(res["errors"]))
assert_true(res["state"].sheet.is_proficient("perception"))
func test_a_human_must_choose_a_bonus_skill():
var res := _build(_creation({"bonus_skill": ""}))
assert_false(res["ok"])
func test_a_non_human_must_not_have_one():
var res := _build(_creation({"race_id": "dwarf", "bonus_skill": "stealth"}))
assert_false(res["ok"])
func test_the_humans_bonus_skill_confers_proficiency():
var res := _build(_creation()) # human, bonus_skill perception
assert_true(res["ok"], str(res["errors"]))
assert_true(res["state"].sheet.is_proficient("perception"))
func test_the_character_starts_whole():
var res := _build(_creation())
var sheet = res["state"].sheet
assert_eq(sheet.hp, sheet.max_hp())
assert_eq(sheet.mp, sheet.max_mp())
func test_a_calling_the_origin_forbids_is_rejected():
var o := _deserter()
o["build_constraints"] = o["build_constraints"].duplicate(true)
o["build_constraints"]["allowed_callings"] = ["bonesetter"]
var res := NewGame.construct(o, world, _creation())
assert_false(res["ok"])
func test_no_luck_number_and_no_attributes_reach_the_log():
# §7 + §11: the log carries only what the AI narrates from.
var res := _build(_creation())
var pd: Dictionary = res["log"].player.to_dict()
assert_eq(pd.keys().size(), 4)
assert_false("luck" in pd)
assert_false("attributes" in pd)
assert_true(res["state"].luck >= Luck.MIN and res["state"].luck <= Luck.MAX)
func test_inventory_and_dispo_land_in_state_not_log():
var res := _build(_creation())
assert_eq(res["state"].inventory.get("worn_shortsword", 0), 1)
assert_eq(res["state"].purse_copper, 47, "the deserter is down to his last coin")
assert_false("copper" in res["state"].inventory, "money is never an inventory row")
assert_false("inventory" in res["log"].to_dict())
func test_companion_dispositions_from_overrides():
var res := _build(_creation())
assert_eq(res["log"].party_member("brannoc_thane").disposition, 40)
assert_eq(res["log"].party_member("cadwyn_vell").disposition, 15)
func test_situation_and_facts_seeded():
var res := _build(_creation())
assert_eq(res["log"].recent_events.size(), 2)
assert_true("the player deserted the Iron Kettle mercenary company" in res["log"].established_facts)
assert_eq(res["log"].humiliations.size(), 0)
func test_active_quest_resolved_from_world():
var res := _build(_creation())
assert_eq(res["log"].active_quests.size(), 1)
assert_eq(res["log"].active_quests[0].id, "find_the_ledger")
assert_eq(res["log"].active_quests[0].status, "active")
func test_null_start_quest_yields_no_quest():
var o := _deserter()
o["start_quest_id"] = null
var res := NewGame.construct(o, world, _creation())
assert_eq(res["log"].active_quests.size(), 0)
func test_unresolved_origin_is_rejected():
var o := _deserter()
o["start_location_id"] = "nowhere"
var res := NewGame.construct(o, world, _creation())
assert_false(res["ok"])
assert_true("unresolved ref: location:nowhere" in res["errors"])
func test_empty_name_is_rejected():
var missing_name := _build(_creation({"name": ""}))
assert_false(missing_name["ok"])
assert_true(missing_name["errors"].size() > 0)
assert_null(missing_name["log"])
var whitespace_name := _build(_creation({"name": " "}))
assert_false(whitespace_name["ok"])
assert_true(whitespace_name["errors"].size() > 0)
assert_null(whitespace_name["log"])
func test_a_null_spend_is_rejected_not_crashed():
# A JSON round-trip can hand back {"spend": null}. Without a container-type
# guard, assigning null into a statically-typed Dictionary parameter throws
# a GDScript runtime error INSIDE construct() instead of a front-loaded error.
var res := _build(_creation({"spend": null}))
assert_false(res["ok"])
assert_true("spend" in str(res["errors"]).to_lower())
func test_non_array_skills_is_rejected_not_crashed():
var res := _build(_creation({"skills": "athletics"}))
assert_false(res["ok"])
assert_true("skills" in str(res["errors"]).to_lower())
func test_non_companion_override_goes_to_state_not_log():
world.npcs["oda_fenn"] = {"id": "oda_fenn", "name": "Oda Fenn", "role": "npc"}
var o := _deserter()
o["disposition_overrides"] = {"brannoc_thane": 40, "oda_fenn": -25}
var res := NewGame.construct(o, world, _creation())
assert_true(res["ok"], str(res["errors"]))
assert_eq(res["state"].npc_dispositions["oda_fenn"], -25)
assert_null(res["log"].party_member("oda_fenn"))
for m in res["log"].to_dict()["party"]:
assert_ne(m["id"], "oda_fenn")
assert_eq(res["log"].party_member("brannoc_thane").disposition, 40)