Files
code_of_conquest_dnd/client/scripts/newgame/new_game.gd
Phillip Tarrant 62b8113ffa 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
2026-07-12 20:50:06 -05:00

189 lines
7.0 KiB
GDScript

class_name NewGame
extends RefCounted
## 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.
const SPEND_POOL := 3
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}
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
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(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", ""))
var overrides: Dictionary = origin.get("disposition_overrides", {})
var companion_ids := {}
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))))
for npc_id in overrides:
if not companion_ids.has(npc_id):
state.set_npc_disposition(npc_id, int(overrides[npc_id]))
for line in origin.get("situation", []):
log.push_event(line)
for fact in origin.get("opening_facts", []):
log.add_fact(fact)
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", "")))
# 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_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")
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)
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
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])
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