fix(creation): pin RNG stream, tighten spend/seed validation

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
This commit is contained in:
2026-07-12 20:50:06 -05:00
parent bd49e118d5
commit 62b8113ffa
3 changed files with 71 additions and 2 deletions

View File

@@ -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])

View File

@@ -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

View File

@@ -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}}))