Three callers (main_window_shell.gd, narrate_harness.gd, npc_harness.gd)
still built LogPlayer with the OLD 3-arg shape (name, class_id,
luck_descriptor) after LogPlayer.new() was migrated to the 4-arg
(name, race_id, calling_id, luck_descriptor) shape. This was invisible
to both grep and the test suite: every ctor param carries a default, so
GDScript compiles a 3-arg call against a 4-arg signature without error —
it just silently mis-binds positionally. "sellsword" landed in the
race_id slot (rejected — not a race), the luck descriptor landed in the
calling_id slot (rejected — not a calling), and luck_descriptor itself
fell back to its default "". Every field in the emitted dict came out
empty, which 422s against the canon-log schema's minLength/enum
constraints. Grepping for class_id can't see this, because there is no
class_id token left anywhere — the bug is purely positional.
Also fixes prompts.py's _article(""), which returned "an" because
Python's "" in "aeiou" is True, producing a doubled-space/wrong-article
digest line when race_id is empty. _describe_player now builds its
descriptor from whichever of race/calling are present and omits the
clause entirely when both are absent.
Adds a schema-parity guard for origin.schema.json's inlined
allowed_callings enum, which duplicated the calling roster with no
runtime check tying it to Callings.IDS. Renames leftover "class"
vocabulary in test names/comments to "calling".
Regression coverage:
- client/tests/unit/test_entities.gd: ctor populates both race_id and
calling_id; a calling passed into the race_id slot (the exact shape
of the three broken call sites) yields an all-empty row.
- api/tests/test_prompts.py: empty-race and empty-race-and-calling
digest rendering, asserting no doubled space and no dangling article.
- client/tests/unit/test_schema_parity.gd: origin.schema.json's
allowed_callings enum matches Callings.IDS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
80 lines
3.3 KiB
GDScript
80 lines
3.3 KiB
GDScript
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.
|
|
|
|
static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary, rng: RandomNumberGenerator) -> Dictionary:
|
|
var errors: Array = []
|
|
|
|
# 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")
|
|
|
|
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 state := GameState.new()
|
|
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 log := CanonLog.new()
|
|
log.player = LogPlayer.new(creation.get("name", ""), race_id, 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))))
|
|
# 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]))
|
|
|
|
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). 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)))
|
|
|
|
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)
|