Finding 1 (important): test_same_seed_yields_an_identical_character only compared two construct() runs against each other, so swapping dex/con in Attributes.IDS still passed the whole suite while every persisted seed would silently become a different character. Added a golden-vector test that pins one fixed seed to its exact rolled attributes and hidden Luck, so reordering Attributes.IDS or moving the Luck roll fails loudly instead of silently rewriting every saved character. Proved it: swapped IDS, watched the golden vector fail, restored, confirmed green. Also: _validate_spend no longer lets a negative value mask the over-pool check (continue after each error instead of falling through to `total += v`); non-integer spend values (float/bool/string) are now rejected instead of coerced; a missing or non-integer seed is now a validation error instead of silently defaulting to 0. GameState's header comment no longer advertises "stats" it no longer holds. 243 -> 248 tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
288 lines
10 KiB
GDScript
288 lines
10 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_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)
|