feat(newgame): one public, seeded roll the screen can show

roll_attributes(seed) is now the single implementation of the die — construct
calls it, and the creation screen (M4-b) will call it to DISPLAY the roll. So
the screen shows real numbers while still only ever handing over a seed (§2).
validate() goes public so the screen can gate its CTA on the real rules.

Neither takes a default parameter (traps.md #2).
This commit is contained in:
2026-07-13 13:54:02 -05:00
parent 7a74a8de94
commit 17ef142689
2 changed files with 74 additions and 3 deletions

View File

@@ -300,3 +300,50 @@ func test_non_companion_override_goes_to_state_not_log():
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_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")