diff --git a/client/scripts/newgame/new_game.gd b/client/scripts/newgame/new_game.gd index bc6f765..0deba42 100644 --- a/client/scripts/newgame/new_game.gd +++ b/client/scripts/newgame/new_game.gd @@ -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 diff --git a/client/tests/unit/test_new_game.gd b/client/tests/unit/test_new_game.gd index c0d0f98..eec9222 100644 --- a/client/tests/unit/test_new_game.gd +++ b/client/tests/unit/test_new_game.gd @@ -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")