Files
code_of_conquest_dnd/client/scripts/newgame/new_game.gd
Phillip Tarrant a9d9eedb7e feat(currency): copper/silver/gold replace the coin
The three denominations are move tokens, not stored buckets — there is one
integer, so change-making never arises. The deserter is down to 47c: under a
silver, so he cannot cover a bed and the player sees it on turn one.

A parity test guards the code-side ratios against the content-side ids.

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

75 lines
3.1 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 class must be allowed by the origin.
var bc: Dictionary = origin.get("build_constraints", {})
var allowed: Array = bc.get("allowed_classes", [])
var class_id: String = creation.get("class_id", "")
if class_id not in allowed:
errors.append("class not allowed by origin: %s" % class_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", ""), class_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)