From bd49e118d5c15bdb13bc248272342bdef904cd49 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 12 Jul 2026 20:43:51 -0500 Subject: [PATCH] feat(newgame): the seeded creation pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit construct() takes a seed, not a stat block. It rebuilds the RNG and rolls the attributes itself, so it never trusts a number handed to it (§2) and the same seed yields an identical character, hidden Luck included (§10). The whole character is reproducible from four primitives: seed, race_id, calling_id, spend. The rng parameter is dropped — the seed is now the only source of randomness. The roll is 3d6 with a hard floor of 8. Straight 3d6 puts ~9% on a primary the +3 pool cannot rescue, and a character bad at the one thing he is FOR is not grit — it is the fine §7 forbids, paid for twenty hours. LCK is unspendable, not merely hidden: spending on it is a validation error. GameState.stats becomes GameState.sheet. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8 --- client/scripts/newgame/new_game.gd | 182 +++++++++++++++++++++------ client/scripts/state/game_state.gd | 2 +- client/tests/unit/test_game_state.gd | 6 + client/tests/unit/test_new_game.gd | 165 ++++++++++++++++++++---- client/tests/unit/test_round_trip.gd | 13 +- 5 files changed, 298 insertions(+), 70 deletions(-) diff --git a/client/scripts/newgame/new_game.gd b/client/scripts/newgame/new_game.gd index 7c40014..19c3921 100644 --- a/client/scripts/newgame/new_game.gd +++ b/client/scripts/newgame/new_game.gd @@ -1,47 +1,66 @@ class_name NewGame extends RefCounted -## New-game construction (spec "New-game construction"). Reads three immutable -## inputs (origin, world, creation) and writes two products (CanonLog, GameState). -## Nothing flows back up — charter §2 on the client. Validation is front-loaded: -## a broken origin or illegal calling fails here, loudly, not three scenes later. +## New-game construction. Reads three immutable inputs (origin, world, creation) and +## writes two products (CanonLog, GameState). Nothing flows back up — charter §2. +## +## Creation carries a SEED, not a stat block. construct() rebuilds the RNG and rolls +## the attributes ITSELF, so it never trusts a number handed to it (§2) and the same +## seed always yields the same character (§10 — "the difference between a bug you can +## reproduce and a bug you cannot"). The whole character is reproducible from four +## primitives: seed, race_id, calling_id, spend. +## +## `creation` stays a plain Dictionary: a saga (later) synthesizes one and skips the +## UI entirely, so the creation SCENE must never become the only thing that can make it. -static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary, rng: RandomNumberGenerator) -> Dictionary: - var errors: Array = [] +const SPEND_POOL := 3 - # 1. content integrity — every referenced id must resolve in world content. - for ref in world.unresolved_refs(origin): - errors.append("unresolved ref: %s" % ref) - - # 2. build constraints — chosen calling must be allowed by the origin. - var bc: Dictionary = origin.get("build_constraints", {}) - var allowed: Array = bc.get("allowed_callings", []) - var calling_id: String = creation.get("calling_id", "") - if calling_id not in allowed: - errors.append("calling not allowed by origin: %s" % calling_id) - - var race_id: String = creation.get("race_id", "") - if not Races.exists(race_id): - errors.append("unknown race: %s" % race_id) - - if creation.get("name", "").strip_edges() == "": - errors.append("player name is required") +static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary) -> Dictionary: + var errors := _validate(origin, world, creation) if not errors.is_empty(): return {"ok": false, "errors": errors, "log": null, "state": null} - # 3. roll Luck (numeric -> state only) and 4. the stat block (data only). + var rng := RandomNumberGenerator.new() + rng.seed = int(creation.get("seed", 0)) + var state := GameState.new() + var sheet := CharacterSheet.new() + sheet.race_id = str(creation.get("race_id", "")) + sheet.calling_id = str(creation.get("calling_id", "")) + + # 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) + + var bc: Dictionary = origin.get("build_constraints", {}) state.luck_base = Luck.roll_base(rng, int(bc.get("luck_modifier", 0))) state.luck = state.luck_base - state.stats = { - "str": _roll_stat(rng), "dex": _roll_stat(rng), "con": _roll_stat(rng), - "fth": _roll_stat(rng), "mag": _roll_stat(rng), - } - # 5. assemble the canon log. + var spend: Dictionary = creation.get("spend", {}) + for stat in spend: + attrs[stat] = int(attrs[stat]) + int(spend[stat]) + sheet.attributes = attrs + + # Proficiencies: what the race grants + what the player picked + the human's bonus. + # All land in one list, so is_proficient() has exactly one thing to read. + var skills: Array = [] + skills.append_array(Races.granted_skills(sheet.race_id)) + skills.append_array(creation.get("skills", [])) + var bonus := str(creation.get("bonus_skill", "")) + if bonus != "": + skills.append(bonus) + sheet.bonus_skill = bonus + sheet.skills = skills + + sheet.hp = sheet.max_hp() # the character starts whole + sheet.mp = sheet.max_mp() + state.sheet = sheet + var log := CanonLog.new() - log.player = LogPlayer.new(creation.get("name", ""), race_id, calling_id, - state.luck_descriptor()) + log.player = LogPlayer.new(str(creation.get("name", "")), sheet.race_id, + sheet.calling_id, state.luck_descriptor()) var loc: Dictionary = world.location(origin["start_location_id"]) log.set_location(loc.get("id", ""), loc.get("name", "")) @@ -51,7 +70,6 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary for c in world.companions(): companion_ids[c["id"]] = true log.party.append(PartyMember.new(c["id"], c.get("name", ""), int(overrides.get(c["id"], 0)))) - # non-companion overrides go to game state, never the log (spec §5 / charter §6). for npc_id in overrides: if not companion_ids.has(npc_id): state.set_npc_disposition(npc_id, int(overrides[npc_id])) @@ -64,16 +82,100 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary var quest_id: Variant = origin.get("start_quest_id", null) if quest_id != null: var qd: Dictionary = world.quest(quest_id) - log.active_quests.append(Quest.new(qd.get("id", ""), qd.get("name", ""), "active", qd.get("objective", ""))) + log.active_quests.append(Quest.new(qd.get("id", ""), qd.get("name", ""), "active", + qd.get("objective", ""))) - # inventory grants -> game state (NOT the log). Charter §2. grant() routes - # currency to the purse; everything else to the inventory dict. - for grant in origin.get("inventory_grants", []): - state.grant(grant.get("item_id", ""), int(grant.get("qty", 0))) + # inventory grants -> game state (NOT the log). §2. grant() routes currency to the + # purse; everything else to the inventory dict. + for g in origin.get("inventory_grants", []): + state.grant(g.get("item_id", ""), int(g.get("qty", 0))) return {"ok": true, "errors": [], "log": log, "state": state} -static func _roll_stat(rng: RandomNumberGenerator) -> int: - # 3d6 placeholder; the stat block is data-only in this plan. TUNABLE. - return rng.randi_range(1, 6) + rng.randi_range(1, 6) + rng.randi_range(1, 6) +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 + # at the one thing he is FOR is not grit — it is the "fine" §7 forbids, paid for + # twenty hours. + var roll := rng.randi_range(1, 6) + rng.randi_range(1, 6) + rng.randi_range(1, 6) + return maxi(8, roll) + + +static func _validate(origin: Dictionary, world: ContentDB, creation: Dictionary) -> Array: + var errors: Array = [] + + for ref in world.unresolved_refs(origin): + errors.append("unresolved ref: %s" % ref) + + if str(creation.get("name", "")).strip_edges() == "": + errors.append("player name is required") + + var race_id: String = str(creation.get("race_id", "")) + if not Races.exists(race_id): + errors.append("unknown race: %s" % race_id) + + var calling_id: String = str(creation.get("calling_id", "")) + if not Callings.exists(calling_id): + errors.append("unknown calling: %s" % calling_id) + else: + var allowed: Array = origin.get("build_constraints", {}).get("allowed_callings", []) + if calling_id not in allowed: + errors.append("calling not allowed by origin: %s" % calling_id) + + errors.append_array(_validate_spend(creation.get("spend", {}))) + if Races.exists(race_id) and Callings.exists(calling_id): + errors.append_array(_validate_skills(creation, race_id, calling_id)) + + return errors + + +static func _validate_spend(spend: Dictionary) -> Array: + var errors: Array = [] + var total := 0 + for stat in spend: + # LCK is not merely hidden at creation — it is unspendable (§7). + if not Attributes.exists(str(stat)): + errors.append("cannot spend on '%s' — not an attribute" % stat) + continue + var v := int(spend[stat]) + if v < 0: + errors.append("spend is additive only: %s is negative" % stat) + total += v + if total > SPEND_POOL: + errors.append("spend exceeds the pool of %d (got %d)" % [SPEND_POOL, total]) + return errors + + +static func _validate_skills(creation: Dictionary, race_id: String, calling_id: String) -> Array: + var errors: Array = [] + var picks: Array = creation.get("skills", []) + var granted: Array = Races.granted_skills(race_id) + var pool: Array = Callings.skill_pool(calling_id) + var want := Callings.skill_count(calling_id) + + if picks.size() != want: + errors.append("%s picks %d skills, got %d" % [calling_id, want, picks.size()]) + + var seen := {} + for s in picks: + if s in seen: + errors.append("duplicate skill pick: %s" % s) + seen[s] = true + if s not in pool: + errors.append("%s is not in the %s pool" % [s, calling_id]) + if s in granted: + errors.append("%s is already granted by %s — the pick buys nothing" % [s, race_id]) + + var bonus := str(creation.get("bonus_skill", "")) + if Races.wants_bonus_skill(race_id): + if bonus == "": + errors.append("%s must choose a bonus skill" % race_id) + elif not Skills.exists(bonus): + errors.append("unknown bonus skill: %s" % bonus) + elif bonus in picks or bonus in granted: + errors.append("bonus skill %s is already proficient" % bonus) + elif bonus != "": + errors.append("%s does not get a bonus skill" % race_id) + + return errors diff --git a/client/scripts/state/game_state.gd b/client/scripts/state/game_state.gd index e7e820d..d7077ce 100644 --- a/client/scripts/state/game_state.gd +++ b/client/scripts/state/game_state.gd @@ -6,7 +6,7 @@ extends RefCounted var luck: int = 0 var luck_base: int = 0 -var stats: Dictionary = {} # {str, dex, con, fth, mag} +var sheet: CharacterSheet = null # the whole character (§2). Built by NewGame. var npc_dispositions: Dictionary = {} # world-npc id -> int (−100..100) var inventory: Dictionary = {} # item_id -> qty (NEVER money — see purse_copper) var purse_copper: int = 0 # ALL money, in copper. One integer (§4.1 of the spec). diff --git a/client/tests/unit/test_game_state.gd b/client/tests/unit/test_game_state.gd index d3bd06f..d385df1 100644 --- a/client/tests/unit/test_game_state.gd +++ b/client/tests/unit/test_game_state.gd @@ -3,6 +3,12 @@ extends "res://addons/gut/test.gd" const GameState = preload("res://scripts/state/game_state.gd") +func test_state_holds_a_sheet_not_a_stat_bag(): + var gs = GameState.new() + assert_null(gs.sheet, "the sheet is built by NewGame.construct") + assert_false("stats" in gs, "the flat stats Dictionary is gone") + + func test_add_item_accumulates(): var s = GameState.new() s.add_item("worn_shortsword", 3) diff --git a/client/tests/unit/test_new_game.gd b/client/tests/unit/test_new_game.gd index 27bd201..3a21e0f 100644 --- a/client/tests/unit/test_new_game.gd +++ b/client/tests/unit/test_new_game.gd @@ -15,31 +15,151 @@ func _deserter() -> Dictionary: return ContentDB.load_json(ContentDB.origin_path("deserter")) -func _rng() -> RandomNumberGenerator: - var r := RandomNumberGenerator.new() - r.seed = 7 - return r +func _creation(overrides := {}) -> Dictionary: + var c := { + "name": "Aldric", + "race_id": "human", + "calling_id": "sellsword", + "seed": 8675309, + "spend": {}, + "skills": ["athletics", "endurance"], + "bonus_skill": "perception", + } + for k in overrides: + c[k] = overrides[k] + return c func _build(creation: Dictionary) -> Dictionary: - return NewGame.construct(_deserter(), world, creation, _rng()) + return NewGame.construct(_deserter(), world, creation) -func test_construct_succeeds(): - var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"}) +func test_same_seed_yields_an_identical_character(): + # The determinism guarantee (§10). Two independent constructs, one seed. + var a := _build(_creation()) + var b := _build(_creation()) + assert_true(a["ok"] and b["ok"]) + assert_eq(a["state"].sheet.attributes, b["state"].sheet.attributes) + assert_eq(a["state"].luck, b["state"].luck, "even the hidden Luck is reproducible") + + +func test_a_different_seed_yields_a_different_character(): + var a := _build(_creation()) + var b := _build(_creation({"seed": 1234567})) + assert_ne(a["state"].sheet.attributes, b["state"].sheet.attributes) + + +func test_every_attribute_respects_the_floor_of_eight(): + # 3d6 can roll 3. The floor is a design guarantee, not a dice accident. + for s in range(40): + var res := _build(_creation({"seed": s})) + assert_true(res["ok"], str(res["errors"])) + for stat in Attributes.IDS: + assert_true(int(res["state"].sheet.attributes[stat]) >= 8, + "seed %d rolled %s below the floor" % [s, stat]) + + +func test_spend_is_additive_on_top_of_the_roll(): + var base := _build(_creation()) + var spent := _build(_creation({"spend": {"str": 2, "con": 1}})) + assert_eq(int(spent["state"].sheet.attributes["str"]), + int(base["state"].sheet.attributes["str"]) + 2) + assert_eq(int(spent["state"].sheet.attributes["con"]), + int(base["state"].sheet.attributes["con"]) + 1) + assert_eq(int(spent["state"].sheet.attributes["dex"]), + int(base["state"].sheet.attributes["dex"]), "unspent stats are untouched") + + +func test_spend_over_the_pool_is_rejected(): + var res := _build(_creation({"spend": {"str": 2, "con": 2}})) + assert_false(res["ok"]) + assert_true("spend" in str(res["errors"]).to_lower()) + + +func test_spend_cannot_be_negative(): + var res := _build(_creation({"spend": {"str": -2, "con": 1}})) + assert_false(res["ok"], "additive only — there is no [-]") + + +func test_luck_can_never_be_spent(): + # §7: LCK is untouchable at creation. Not hidden — impossible. + var res := _build(_creation({"spend": {"lck": 3}})) + assert_false(res["ok"]) + + +func test_skills_must_come_from_the_callings_pool(): + var res := _build(_creation({"skills": ["athletics", "sorcery"]})) + assert_false(res["ok"], "sorcery is not in the sellsword's pool") + + +func test_skills_must_be_the_right_count(): + var res := _build(_creation({"skills": ["athletics"]})) + assert_false(res["ok"], "a sellsword picks 2") + + +func test_skills_reject_a_duplicate(): + var res := _build(_creation({"skills": ["athletics", "athletics"]})) + assert_false(res["ok"]) + + +func test_an_elf_cannot_spend_a_pick_on_perception(): + # He already has it. A pick that buys nothing is a trap, not a choice. + var res := _build(_creation({ + "race_id": "elf", "bonus_skill": "", + "skills": ["athletics", "perception"]})) + assert_false(res["ok"]) + + +func test_the_elf_is_granted_perception_anyway(): + var res := _build(_creation({ + "race_id": "elf", "bonus_skill": "", "skills": ["athletics", "endurance"]})) assert_true(res["ok"], str(res["errors"])) + assert_true(res["state"].sheet.is_proficient("perception")) -func test_numeric_luck_absent_from_log(): - var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"}) +func test_a_human_must_choose_a_bonus_skill(): + var res := _build(_creation({"bonus_skill": ""})) + assert_false(res["ok"]) + + +func test_a_non_human_must_not_have_one(): + var res := _build(_creation({"race_id": "dwarf", "bonus_skill": "stealth"})) + assert_false(res["ok"]) + + +func test_the_humans_bonus_skill_confers_proficiency(): + var res := _build(_creation()) # human, bonus_skill perception + assert_true(res["ok"], str(res["errors"])) + assert_true(res["state"].sheet.is_proficient("perception")) + + +func test_the_character_starts_whole(): + var res := _build(_creation()) + var sheet = res["state"].sheet + assert_eq(sheet.hp, sheet.max_hp()) + assert_eq(sheet.mp, sheet.max_mp()) + + +func test_a_calling_the_origin_forbids_is_rejected(): + var o := _deserter() + o["build_constraints"] = o["build_constraints"].duplicate(true) + o["build_constraints"]["allowed_callings"] = ["bonesetter"] + var res := NewGame.construct(o, world, _creation()) + assert_false(res["ok"]) + + +func test_no_luck_number_and_no_attributes_reach_the_log(): + # §7 + §11: the log carries only what the AI narrates from. + var res := _build(_creation()) var pd: Dictionary = res["log"].player.to_dict() - assert_false("luck" in pd) assert_eq(pd.keys().size(), 4) + assert_false("luck" in pd) + assert_false("attributes" in pd) assert_true(res["state"].luck >= Luck.MIN and res["state"].luck <= Luck.MAX) func test_inventory_and_dispo_land_in_state_not_log(): - var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"}) + var res := _build(_creation()) assert_eq(res["state"].inventory.get("worn_shortsword", 0), 1) assert_eq(res["state"].purse_copper, 47, "the deserter is down to his last coin") assert_false("copper" in res["state"].inventory, "money is never an inventory row") @@ -47,20 +167,20 @@ func test_inventory_and_dispo_land_in_state_not_log(): func test_companion_dispositions_from_overrides(): - var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"}) + var res := _build(_creation()) assert_eq(res["log"].party_member("brannoc_thane").disposition, 40) assert_eq(res["log"].party_member("cadwyn_vell").disposition, 15) func test_situation_and_facts_seeded(): - var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"}) + var res := _build(_creation()) assert_eq(res["log"].recent_events.size(), 2) assert_true("the player deserted the Iron Kettle mercenary company" in res["log"].established_facts) assert_eq(res["log"].humiliations.size(), 0) func test_active_quest_resolved_from_world(): - var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"}) + var res := _build(_creation()) assert_eq(res["log"].active_quests.size(), 1) assert_eq(res["log"].active_quests[0].id, "find_the_ledger") assert_eq(res["log"].active_quests[0].status, "active") @@ -69,32 +189,25 @@ func test_active_quest_resolved_from_world(): func test_null_start_quest_yields_no_quest(): var o := _deserter() o["start_quest_id"] = null - var res := NewGame.construct(o, world, {"name": "X", "race_id": "human", "calling_id": "sellsword"}, _rng()) + var res := NewGame.construct(o, world, _creation()) assert_eq(res["log"].active_quests.size(), 0) -func test_calling_not_allowed_is_rejected(): - var res := _build({"name": "X", "race_id": "human", "calling_id": "berserker"}) - assert_false(res["ok"]) - assert_true(res["errors"].size() > 0) - assert_null(res["log"]) - - func test_unresolved_origin_is_rejected(): var o := _deserter() o["start_location_id"] = "nowhere" - var res := NewGame.construct(o, world, {"name": "X", "race_id": "human", "calling_id": "sellsword"}, _rng()) + var res := NewGame.construct(o, world, _creation()) assert_false(res["ok"]) assert_true("unresolved ref: location:nowhere" in res["errors"]) func test_empty_name_is_rejected(): - var missing_name := _build({"race_id": "human", "calling_id": "sellsword"}) + var missing_name := _build(_creation({"name": ""})) assert_false(missing_name["ok"]) assert_true(missing_name["errors"].size() > 0) assert_null(missing_name["log"]) - var whitespace_name := _build({"name": " ", "race_id": "human", "calling_id": "sellsword"}) + var whitespace_name := _build(_creation({"name": " "})) assert_false(whitespace_name["ok"]) assert_true(whitespace_name["errors"].size() > 0) assert_null(whitespace_name["log"]) @@ -104,7 +217,7 @@ func test_non_companion_override_goes_to_state_not_log(): world.npcs["oda_fenn"] = {"id": "oda_fenn", "name": "Oda Fenn", "role": "npc"} var o := _deserter() o["disposition_overrides"] = {"brannoc_thane": 40, "oda_fenn": -25} - var res := NewGame.construct(o, world, {"name": "X", "race_id": "human", "calling_id": "sellsword"}, _rng()) + var res := NewGame.construct(o, world, _creation()) assert_true(res["ok"], str(res["errors"])) assert_eq(res["state"].npc_dispositions["oda_fenn"], -25) assert_null(res["log"].party_member("oda_fenn")) diff --git a/client/tests/unit/test_round_trip.gd b/client/tests/unit/test_round_trip.gd index 5108147..502653a 100644 --- a/client/tests/unit/test_round_trip.gd +++ b/client/tests/unit/test_round_trip.gd @@ -8,10 +8,17 @@ const CanonLog = preload("res://scripts/canon_log/canon_log.gd") func _built_log(): var world = ContentDB.new() world.load_from(ContentDB.default_content_root()) - var rng := RandomNumberGenerator.new() - rng.seed = 3 var origin: Dictionary = ContentDB.load_json(ContentDB.origin_path("deserter")) - var res := NewGame.construct(origin, world, {"name": "Aldric", "race_id": "human", "calling_id": "bonesetter"}, rng) + var creation := { + "name": "Aldric", + "race_id": "human", + "calling_id": "bonesetter", + "seed": 3, + "spend": {}, + "skills": ["faith_lore", "perception"], + "bonus_skill": "endurance", + } + var res := NewGame.construct(origin, world, creation) assert_true(res["ok"], str(res["errors"])) return res["log"]