Files
code_of_conquest_dnd/client/scripts/newgame/new_game.gd
Phillip Tarrant a9357fb787 fix(creation-model): migrate stray class_id/allowed_classes copies
The canon-log schema and NewGame code already migrated to race_id/
calling_id + allowed_callings, but two copies of the roster outside
that schema were missed, and no parity test guards them:

- docs/canon-log.md (the cross-boundary contract doc) still documented
  the OLD player shape and allowed_classes. Anyone building a player
  block from the doc would get a 422.
- .claude/skills/world-building's schema reference still emitted
  allowed_classes with two dead calling ids (assassin, priest). Author
  a new origin with that skill and it fails origin.schema.json
  (additionalProperties: false, allowed_callings required) AND, if that
  ever loosened, silently allows zero callings at runtime.

Also:
- add schema tests rejecting a dead class_id field and an unknown
  calling_id (priest)
- guard NewGame._validate's container types (spend/skills) so a
  malformed JSON round-trip (null spend, string skills) produces a
  front-loaded error list instead of a GDScript runtime crash
- extend the no-mechanics-in-content test to db.races, not just
  db.callings
- rewrite the roadmap's M4 bullet: the two contract migrations landed;
  only the creation screen and title->creation->shell flow remain

client: 250/250. api: 74 passed, 2 skipped. content_build --check: clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:58:49 -05:00

200 lines
7.6 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)
# 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