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
189 lines
7.0 KiB
GDScript
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
|