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 else either. `res["log"].to_dict().has("attributes")` (the old check here) # was already false before this test's action ran: LogPlayer.to_dict() has no such # TOP-LEVEL key on any code path, hostile block or not, so it could never fail — delete # construct's whole §2 guard and this stayed green. Assert the player row's exact key # SET instead (sorted — insertion order in LogPlayer.to_dict() isn't the contract): # it feeds the AI (§2/§7), so a stray "attributes", "str", or anything else reaching it # is a real leak, and this is what actually fails if one does. var player_keys: Array = res["log"].to_dict()["player"].keys() player_keys.sort() assert_eq(player_keys, ["calling_id", "luck_descriptor", "name", "race_id"], "the canon-log player row carries a key beyond name/race_id/calling_id/luck_descriptor — numeric state reached the AI-facing log (§2/§7)") 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")