Not errors and not regressions — Godot printed them on every script reload and they predate M4-b. Cleared so a real warning is not lost in the noise: - Redundant `const ContentDB`/`const NewGame` preloads that shadowed their own `class_name` globals (character_creation.gd, creation_draft.gd) — the class is already global, so the const bought nothing. - Two `log` locals shadowing the built-in `log()` — renamed to `out` (canon_log.from_dict) and `canon` (NewGame.construct); the "log" dict KEY is the public contract and is unchanged. - currency.format's copper->denomination division is integer BY DESIGN (the remainder is carried, not lost) — annotated `@warning_ignore` to say so. 319 client tests green; headless parse reports none of the four sites. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
224 lines
8.7 KiB
GDScript
224 lines
8.7 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.
|
|
# 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)))
|
|
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 canon := CanonLog.new()
|
|
canon.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"])
|
|
canon.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
|
|
canon.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", []):
|
|
canon.push_event(line)
|
|
for fact in origin.get("opening_facts", []):
|
|
canon.add_fact(fact)
|
|
|
|
var quest_id: Variant = origin.get("start_quest_id", null)
|
|
if quest_id != null:
|
|
var qd: Dictionary = world.quest(quest_id)
|
|
canon.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": canon, "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
|
|
# 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)
|
|
|
|
# Guard container SHAPES before any statically-typed call below touches them.
|
|
# A JSON round-trip can hand back {"spend": null} or {"skills": "stealth"} —
|
|
# without this check, assigning that into a typed Dictionary/Array parameter
|
|
# throws a GDScript runtime error INSIDE construct() instead of landing in the
|
|
# front-loaded {"ok": false, "errors": [...]} the spec promises.
|
|
if typeof(creation.get("spend", {})) != TYPE_DICTIONARY:
|
|
errors.append("spend must be an object")
|
|
else:
|
|
errors.append_array(_validate_spend(creation.get("spend", {})))
|
|
|
|
if typeof(creation.get("skills", [])) != TYPE_ARRAY:
|
|
errors.append("skills must be an array")
|
|
elif 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
|