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. # roll_attributes() is the ONE implementation — the creation screen calls the same # function to DISPLAY the roll, so what the player saw is what he gets (§2/§10). var attrs: Dictionary = _roll_five(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_attributes(character_seed: int) -> Dictionary: ## PUBLIC and PURE. The creation screen calls this to SHOW the roll; construct ## calls it to BUILD it. One implementation, so the two can never disagree. ## NO DEFAULT PARAMETERS (traps.md #2) — a defaulted arg turns a wrong-arity ## call into a silent miscompile that binds the wrong value into the wrong slot. var rng := RandomNumberGenerator.new() rng.seed = character_seed return _roll_five(rng) static func _roll_five(rng: RandomNumberGenerator) -> Dictionary: var attrs: Dictionary = {} for stat in Attributes.IDS: attrs[stat] = _roll_attribute(rng) return attrs static func validate(origin: Dictionary, world: ContentDB, creation: Dictionary) -> Array: ## PUBLIC. The creation screen gates its CTA on this, so the player cannot press ## his way into a rejection. construct() still calls it and still does not trust ## its caller — the screen is a caller like any other. NO DEFAULT PARAMETERS. return _validate(origin, world, creation) 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