class_name CreationDraft extends RefCounted ## The creation screen's whole brain. Pure, node-free, headlessly testable. ## ## Stores exactly the seven things a creation Dictionary is made of, and nothing ## derived. It CARRIES A SEED, NOT A STAT BLOCK — the screen shows the roll by ## calling NewGame.roll_attributes(seed), the same function construct calls, so ## the numbers on screen are the numbers the player gets and the screen still ## never hands a number to anything (§2). ## ## It owns no rules of its own: errors() delegates to NewGame.validate. A second ## copy of the rules is a second thing to keep in sync, and it would lose. ## ## §7: LCK is not here. Not a field, not an accessor, not a key. It is rolled ## inside construct off the same seed, immediately after MAG, and never surfaces. const NewGame = preload("res://scripts/newgame/new_game.gd") const POOL := 3 # mirrors NewGame.SPEND_POOL; validate() is the authority var name: String = "" var race_id: String = "" var calling_id: String = "" var spend: Dictionary = {} # {stat: int} — additive only var skills: Array = [] # the player's picks (NOT the race's grants) var bonus_skill: String = "" # human only ## NOT named `seed` — `seed()` is a global GDScript function and a member that ## shadows it reads as a bug (and warns). The creation Dictionary's KEY is still ## "seed"; only the GDScript identifier differs. var character_seed: int = 0 static func fresh() -> CreationDraft: ## The ONE place non-determinism enters the entire pipeline — and what it ## produces is a seed, which is thereafter the character's anchor forever. var d := CreationDraft.new() d.character_seed = new_seed() return d static func new_seed() -> int: var rng := RandomNumberGenerator.new() rng.randomize() return int(rng.randi()) # ---------------------------------------------------------------- the numbers func rolled() -> Dictionary: return NewGame.roll_attributes(character_seed) func final() -> Dictionary: var out := rolled() for stat in spend: out[stat] = int(out[stat]) + int(spend[stat]) return out func spent_total() -> int: var total := 0 for stat in spend: total += int(spend[stat]) return total func points_left() -> int: return POOL - spent_total() func can_increment(stat: String) -> bool: return Attributes.exists(stat) and points_left() > 0 func increment(stat: String) -> void: if not can_increment(stat): return spend[stat] = int(spend.get(stat, 0)) + 1 func can_decrement(stat: String) -> bool: ## Removes only what YOU spent. "Additive only" is a rule about where the number ## can END UP — never below the roll — not about whether a mis-click is ## recoverable. Those are different things. return int(spend.get(stat, 0)) > 0 func decrement(stat: String) -> void: if not can_decrement(stat): return var left := int(spend[stat]) - 1 if left <= 0: spend.erase(stat) else: spend[stat] = left func reroll() -> void: ## A re-roll IS a new seed. Nothing else. The spend clears because points spent ## against a roll that no longer exists are meaningless; race, calling, skills ## and name survive because none of them came from the die. ## ## Note what the player is NOT told: the seed drives the five visible attributes ## AND the hidden Luck roll. Chase a 16 and you re-roll your Luck, blind, every ## time. That is §7 working exactly as designed. Say nothing. character_seed = new_seed() spend = {} # ---------------------------------------------------------------- the choices func set_race(id: String) -> void: race_id = id # A pick the new race GRANTS now buys nothing, and construct rejects it by name. # Drop it here rather than let the player press a button into a 422. var granted: Array = Races.granted_skills(id) var kept: Array = [] for s in skills: if s not in granted: kept.append(s) skills = kept if not Races.wants_bonus_skill(id): bonus_skill = "" func set_calling(id: String) -> void: # The pool changed underneath the picks. Keeping them leaves the player holding # an invalid draft he did not cause and cannot see. if id != calling_id: skills = [] calling_id = id func skill_pool() -> Array: return Callings.skill_pool(calling_id) func picks_needed() -> int: return Callings.skill_count(calling_id) func picks_left() -> int: return picks_needed() - skills.size() func is_granted(skill: String) -> bool: return skill in Races.granted_skills(race_id) func is_picked(skill: String) -> bool: return skill in skills func can_pick(skill: String) -> bool: if is_granted(skill): return false if skill not in skill_pool(): return false return picks_left() > 0 func toggle_skill(skill: String) -> void: if is_picked(skill): skills.erase(skill) return if can_pick(skill): skills.append(skill) func wants_bonus_skill() -> bool: return Races.wants_bonus_skill(race_id) func can_take_bonus(skill: String) -> bool: if not wants_bonus_skill(): return false if not Skills.exists(skill): return false return not is_granted(skill) and not is_picked(skill) func set_bonus_skill(skill: String) -> void: if bonus_skill == skill: bonus_skill = "" return if can_take_bonus(skill): bonus_skill = skill # ---------------------------------------------------------------- the contract func to_creation() -> Dictionary: ## The plain Dictionary NewGame.construct takes. A saga (later) synthesizes one ## of these and skips this screen entirely — so it stays a plain Dictionary, and ## this scene must never become the only thing that can produce it. return { "name": name.strip_edges(), "race_id": race_id, "calling_id": calling_id, "seed": character_seed, # the DICT key stays "seed" — the contract is unchanged "spend": spend.duplicate(), "skills": skills.duplicate(), "bonus_skill": bonus_skill, } func errors(origin: Dictionary, world: ContentDB) -> Array: ## Delegates. The screen owns no second copy of the rules — it asks construct's ## validator what is wrong, and shows the answer. return NewGame.validate(origin, world, to_creation()) func is_complete(origin: Dictionary, world: ContentDB) -> bool: return errors(origin, world).is_empty()