feat(newgame): the seeded creation pipeline
construct() takes a seed, not a stat block. It rebuilds the RNG and rolls the attributes itself, so it never trusts a number handed to it (§2) and the same seed yields an identical character, hidden Luck included (§10). The whole character is reproducible from four primitives: seed, race_id, calling_id, spend. The rng parameter is dropped — the seed is now the only source of randomness. The roll is 3d6 with a hard floor of 8. Straight 3d6 puts ~9% on a primary the +3 pool cannot rescue, and a character bad at the one thing he is FOR is not grit — it is the fine §7 forbids, paid for twenty hours. LCK is unspendable, not merely hidden: spending on it is a validation error. GameState.stats becomes GameState.sheet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
This commit is contained in:
@@ -1,47 +1,66 @@
|
||||
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.
|
||||
## 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.
|
||||
|
||||
static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary, rng: RandomNumberGenerator) -> Dictionary:
|
||||
var errors: Array = []
|
||||
const SPEND_POOL := 3
|
||||
|
||||
# 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")
|
||||
|
||||
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}
|
||||
|
||||
# 3. roll Luck (numeric -> state only) and 4. the stat block (data only).
|
||||
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
|
||||
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 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(creation.get("name", ""), race_id, calling_id,
|
||||
state.luck_descriptor())
|
||||
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", ""))
|
||||
@@ -51,7 +70,6 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary
|
||||
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]))
|
||||
@@ -64,16 +82,100 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary
|
||||
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", "")))
|
||||
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)))
|
||||
# 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_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)
|
||||
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")
|
||||
|
||||
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)
|
||||
|
||||
errors.append_array(_validate_spend(creation.get("spend", {})))
|
||||
if 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
|
||||
var v := int(spend[stat])
|
||||
if v < 0:
|
||||
errors.append("spend is additive only: %s is negative" % stat)
|
||||
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
|
||||
|
||||
@@ -6,7 +6,7 @@ extends RefCounted
|
||||
|
||||
var luck: int = 0
|
||||
var luck_base: int = 0
|
||||
var stats: Dictionary = {} # {str, dex, con, fth, mag}
|
||||
var sheet: CharacterSheet = null # the whole character (§2). Built by NewGame.
|
||||
var npc_dispositions: Dictionary = {} # world-npc id -> int (−100..100)
|
||||
var inventory: Dictionary = {} # item_id -> qty (NEVER money — see purse_copper)
|
||||
var purse_copper: int = 0 # ALL money, in copper. One integer (§4.1 of the spec).
|
||||
|
||||
Reference in New Issue
Block a user