Final whole-branch review of M4-b. Six findings.
1. The player could press his way into an invalid draft. can_take_bonus()
refused an already-picked skill; can_pick() did not consider the bonus, so
the guard was one-directional. Take athletics as the human's bonus, pick it
again from the Sellsword's pool, and the draft is one NewGame.validate
rejects — the invalid draft "he did not cause and cannot see" that
set_calling's own comment forbids. Fixed in the DRAFT (the screen owns no
rules): can_pick() now mirrors can_take_bonus(), so the chip goes inert, the
idiom the pool row already uses for a race-granted skill. Recoverable: hand
the bonus back and the chip is live again. Both directions tested.
2. The theme drift guard STILL could not fail. ThemeKeys.ALL is stylebox
variations only, by its own docstring — so the six FONT roles the builder
sets fonts, sizes and colours for were entirely unguarded. Changing
Palette.CREAM and skipping the rebuild shipped a stale game_theme.tres with
the suite green (proven, on the committed test). Adds ThemeKeys.FONT_ROLES
beside ALL (never inside it — ALL's contract is relied on), compares fonts,
font sizes and font colours as well as styleboxes, and names the variation
AND the property that drifted. Plus a meta-guard that walks the builder's own
output and fails if a variation it styles is in neither collection — this is
the second time this guard has been fixed by "enumerate the right set", and
nothing was checking the set.
3. §13: the CTA label piped NewGame.validate's diagnostics straight to the
player. On the game's SECOND SCREEN he read "hedge_mage picks 2 skills, got
0". Now CreationCopy.error_line maps them to authored copy — "name yourself",
"choose two more proficiencies" (the number DERIVED from Callings.skill_count,
never written down), "one more proficiency, any of them — take it" — with an
authored fallback for anything unmapped, because §13 means the unmapped case
still speaks in voice. validate's strings are untouched: they are a contract,
so they are translated in the presentation layer, not rewritten.
4. §2 had no test that fails if the boundary is breached. The emit test compared
the signal to draft.to_creation() — both sides move together. Pins the seven
contract keys exactly, and feeds construct a hostile creation dict carrying an
18 in every stat, asserting the sheet is still roll_attributes(seed) + spend.
Every other test in test_new_game passed with that breach in place.
5. Three holes: the orphan sweep skipped PoolRow (a stray Pool6 rendered unbound
and visible, suite green — the exact bug the test exists to catch, in the one
row it forgot); the §7 sweep forbade "luck" but not Luck.BANDS' descriptors,
which are worse than the number (the player would re-roll until his Luck read
well — calculable Luck); and the DM panel's composition hardcodes the article
"a", so a vowel-initial calling would break it silently — guarded in the
content parity test, which names the template as the reason.
6. The calling card printed "talent second_wind". Four of seven talents carry an
underscore. Humanised, the way skill_label() already does. The test that
pinned the RAW form is moved to the full derived phrase ("talent second wind")
so it still fails if someone hardcodes a talent into the format string.
Every guard was proven by re-breaking the code and watching the named test go
red (docs/traps.md). Suite 302 -> 315, content build green, no new warnings.
Report: .superpowers/sdd/final-review-fix-report.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
377 lines
14 KiB
GDScript
377 lines
14 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)
|
|
|
|
|
|
func test_roll_attributes_is_pure_and_seeded():
|
|
# Same seed, same five. The screen shows these; construct builds from them.
|
|
var a := NewGame.roll_attributes(8675309)
|
|
var b := NewGame.roll_attributes(8675309)
|
|
assert_eq(a, b)
|
|
assert_eq(a.keys(), Attributes.IDS, "the five rows, in the contract's order")
|
|
assert_false(a.has("lck"), "LCK is not an attribute and never leaves construct (§7)")
|
|
|
|
|
|
func test_roll_attributes_holds_the_floor_of_eight():
|
|
# Sweep enough seeds that a straight 3d6 would certainly have rolled below 8
|
|
# somewhere. (GUT has no assert_gte — assert_true on the comparison.)
|
|
var lowest := 999
|
|
for s in range(400):
|
|
for stat in Attributes.IDS:
|
|
lowest = mini(lowest, int(NewGame.roll_attributes(s)[stat]))
|
|
assert_true(lowest >= 8, "the floor broke: something rolled %d" % lowest)
|
|
assert_eq(lowest, 8, "the floor should actually BITE across 2000 rolls, not sit unused")
|
|
|
|
|
|
func test_construct_builds_exactly_what_roll_attributes_showed():
|
|
# TRAP 1 (traps.md): this is the load-bearing claim of the creation SCREEN —
|
|
# "the numbers you saw are the numbers you got". Asserting that two fresh
|
|
# rolls agree would pass even if construct kept its own second implementation
|
|
# of the die. This asserts across the PIPELINE: what roll_attributes returns,
|
|
# plus the spend, IS what construct puts on the sheet.
|
|
var spend := {"str": 2, "con": 1}
|
|
var res := _build(_creation({"seed": 4242, "spend": spend}))
|
|
assert_true(res["ok"], str(res["errors"]))
|
|
|
|
var shown := NewGame.roll_attributes(4242)
|
|
var expected := {}
|
|
for stat in Attributes.IDS:
|
|
expected[stat] = int(shown[stat]) + int(spend.get(stat, 0))
|
|
|
|
assert_eq(res["state"].sheet.attributes, expected)
|
|
|
|
|
|
func test_construct_ignores_an_attribute_block_handed_to_it():
|
|
# §2 — "code owns state" — stated as a TEST, not as a comment. Every other test
|
|
# here feeds construct a well-behaved creation dict, so nothing in the suite ever
|
|
# proves construct REFUSES a number: a construct that trusted creation["attributes"]
|
|
# would pass all of them. Hand it a hostile one — an 18 in every stat — and assert
|
|
# the sheet is still, exactly, roll_attributes(seed) + spend.
|
|
var spend := {"str": 2, "con": 1}
|
|
var hostile := _creation({
|
|
"seed": 4242,
|
|
"spend": spend,
|
|
"attributes": {"str": 18, "dex": 18, "con": 18, "fth": 18, "mag": 18},
|
|
})
|
|
var res := _build(hostile)
|
|
assert_true(res["ok"], str(res["errors"]))
|
|
|
|
var shown := NewGame.roll_attributes(4242)
|
|
var expected := {}
|
|
for stat in Attributes.IDS:
|
|
expected[stat] = int(shown[stat]) + int(spend.get(stat, 0))
|
|
assert_eq(res["state"].sheet.attributes, expected,
|
|
"construct re-rolls from the SEED — a stat block handed to it is not state, it is noise (§2)")
|
|
|
|
# And the hostile block is not merely ignored on the sheet — it never becomes state
|
|
# anywhere. (A `sheet.attributes` the caller supplied would be the exact §2 failure.)
|
|
assert_false(res["log"].to_dict().has("attributes"), "no attribute ever reaches the canon log")
|
|
|
|
|
|
func test_validate_is_public_and_agrees_with_construct():
|
|
var bad := _creation({"calling_id": "paladin"})
|
|
var errors := NewGame.validate(_deserter(), world, bad)
|
|
assert_gt(errors.size(), 0)
|
|
var res := _build(bad)
|
|
assert_false(res["ok"])
|
|
assert_eq(res["errors"], errors, "the screen must be told exactly what construct would reject")
|