diff --git a/client/scripts/newgame/new_game.gd b/client/scripts/newgame/new_game.gd index 19c3921..8dbffd3 100644 --- a/client/scripts/newgame/new_game.gd +++ b/client/scripts/newgame/new_game.gd @@ -111,6 +111,9 @@ static func _validate(origin: Dictionary, world: ContentDB, creation: Dictionary if str(creation.get("name", "")).strip_edges() == "": errors.append("player name is required") + if not creation.has("seed") or typeof(creation["seed"]) != TYPE_INT: + errors.append("seed is required and must be an integer") + var race_id: String = str(creation.get("race_id", "")) if not Races.exists(race_id): errors.append("unknown race: %s" % race_id) @@ -138,9 +141,13 @@ static func _validate_spend(spend: Dictionary) -> Array: if not Attributes.exists(str(stat)): errors.append("cannot spend on '%s' — not an attribute" % stat) continue + if typeof(spend[stat]) != TYPE_INT: + errors.append("spend value for '%s' must be an integer" % stat) + continue var v := int(spend[stat]) if v < 0: errors.append("spend is additive only: %s is negative" % stat) + continue total += v if total > SPEND_POOL: errors.append("spend exceeds the pool of %d (got %d)" % [SPEND_POOL, total]) diff --git a/client/scripts/state/game_state.gd b/client/scripts/state/game_state.gd index d7077ce..ce6df82 100644 --- a/client/scripts/state/game_state.gd +++ b/client/scripts/state/game_state.gd @@ -1,8 +1,9 @@ class_name GameState extends RefCounted ## Luck-centric game state (spec decision 5). The SOLE home of numeric Luck, -## stats, world-NPC dispositions, and inventory — none of which enter the canon -## log (charter §7/§2). The log reads only luck_descriptor() from here. +## the character sheet, world-NPC dispositions, and inventory — none of which +## enter the canon log (charter §7/§2). The log reads only luck_descriptor() +## from here. var luck: int = 0 var luck_base: int = 0 diff --git a/client/tests/unit/test_new_game.gd b/client/tests/unit/test_new_game.gd index 3a21e0f..8fe8d83 100644 --- a/client/tests/unit/test_new_game.gd +++ b/client/tests/unit/test_new_game.gd @@ -43,6 +43,45 @@ func test_same_seed_yields_an_identical_character(): 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})) @@ -81,6 +120,28 @@ func test_spend_cannot_be_negative(): 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}}))