class_id is the rulebook's word; the world has callings — that was the whole §17
reconcile. The contract the Narrator reads should say what the world says. There
are no saves (M9) and no deployed clients, so this is as cheap as it will ever be
and strictly more expensive every milestone after.
race_id reaches the log because the AI must be able to describe the player:
api/app/prompts.py rendered 'a sellsword' and now renders 'a beastfolk cutpurse'.
It humanizes the id (the model must never read 'hedge_mage') and picks the right
article ('an elf', not 'a elf').
LogPlayer's hardcoded CLASSES const is deleted — the roster is Callings.IDS. A
parity test reads the JSON Schema from the client and asserts its enum equals the
code, so the two sides of the HTTP boundary cannot drift.
The deserter allowed three callings that no longer exist. He now allows all seven;
a thematic gate is a content decision for the Greywater authoring.
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 class 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)
|