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:
@@ -30,9 +30,9 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary
|
||||
|
||||
# The RNG ORDER is part of the contract: five attributes in a fixed order, then
|
||||
# Luck. Change the order and every existing seed produces a different character.
|
||||
var attrs: Dictionary = {}
|
||||
for stat in Attributes.IDS:
|
||||
attrs[stat] = _roll_attribute(rng)
|
||||
# roll_attributes() is the ONE implementation — the creation screen calls the same
|
||||
# function to DISPLAY the roll, so what the player saw is what he gets (§2/§10).
|
||||
var attrs: Dictionary = _roll_five(rng)
|
||||
|
||||
var bc: Dictionary = origin.get("build_constraints", {})
|
||||
state.luck_base = Luck.roll_base(rng, int(bc.get("luck_modifier", 0)))
|
||||
@@ -93,6 +93,30 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary
|
||||
return {"ok": true, "errors": [], "log": log, "state": state}
|
||||
|
||||
|
||||
static func roll_attributes(character_seed: int) -> Dictionary:
|
||||
## PUBLIC and PURE. The creation screen calls this to SHOW the roll; construct
|
||||
## calls it to BUILD it. One implementation, so the two can never disagree.
|
||||
## NO DEFAULT PARAMETERS (traps.md #2) — a defaulted arg turns a wrong-arity
|
||||
## call into a silent miscompile that binds the wrong value into the wrong slot.
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = character_seed
|
||||
return _roll_five(rng)
|
||||
|
||||
|
||||
static func _roll_five(rng: RandomNumberGenerator) -> Dictionary:
|
||||
var attrs: Dictionary = {}
|
||||
for stat in Attributes.IDS:
|
||||
attrs[stat] = _roll_attribute(rng)
|
||||
return attrs
|
||||
|
||||
|
||||
static func validate(origin: Dictionary, world: ContentDB, creation: Dictionary) -> Array:
|
||||
## PUBLIC. The creation screen gates its CTA on this, so the player cannot press
|
||||
## his way into a rejection. construct() still calls it and still does not trust
|
||||
## its caller — the screen is a caller like any other. NO DEFAULT PARAMETERS.
|
||||
return _validate(origin, world, creation)
|
||||
|
||||
|
||||
static func _roll_attribute(rng: RandomNumberGenerator) -> int:
|
||||
# 3d6 with a HARD FLOOR of 8. The floor clamps the die, not the player: straight
|
||||
# 3d6 puts ~9% on a primary the +3 pool cannot rescue, and a character who is bad
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user