diff --git a/client/scripts/ui/creation/creation_draft.gd b/client/scripts/ui/creation/creation_draft.gd new file mode 100644 index 0000000..181918e --- /dev/null +++ b/client/scripts/ui/creation/creation_draft.gd @@ -0,0 +1,214 @@ +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() diff --git a/client/scripts/ui/creation/creation_draft.gd.uid b/client/scripts/ui/creation/creation_draft.gd.uid new file mode 100644 index 0000000..4129624 --- /dev/null +++ b/client/scripts/ui/creation/creation_draft.gd.uid @@ -0,0 +1 @@ +uid://dkxuw5c4r6tld diff --git a/client/tests/unit/test_creation_draft.gd b/client/tests/unit/test_creation_draft.gd new file mode 100644 index 0000000..8d498c3 --- /dev/null +++ b/client/tests/unit/test_creation_draft.gd @@ -0,0 +1,182 @@ +extends "res://addons/gut/test.gd" + +const ContentDB = preload("res://scripts/content/content_db.gd") +const NewGame = preload("res://scripts/newgame/new_game.gd") + +var world + + +func before_each(): + world = ContentDB.new() + world.load_from(ContentDB.default_content_root()) + + +func _deserter() -> Dictionary: + return ContentDB.load_json(ContentDB.origin_path("deserter")) + + +func _draft() -> CreationDraft: + # A fixed seed, so every assertion below is reproducible. + var d := CreationDraft.new() + d.character_seed = 8675309 + d.set_race("human") + d.set_calling("sellsword") + d.name = "Aldric" + return d + + +func test_a_fresh_draft_has_a_seed_and_no_choices(): + var d := CreationDraft.fresh() + assert_ne(d.character_seed, 0, "randomize() gave us the one bit of non-determinism there is") + assert_eq(d.spend, {}) + assert_eq(d.skills, []) + assert_eq(d.bonus_skill, "") + + +func test_rolled_is_exactly_what_new_game_will_roll(): + var d := _draft() + assert_eq(d.rolled(), NewGame.roll_attributes(d.character_seed), + "the screen and construct must reach the same five numbers") + + +func test_final_is_rolled_plus_spend(): + var d := _draft() + d.increment("str") + d.increment("str") + assert_eq(int(d.final()["str"]), int(d.rolled()["str"]) + 2) + assert_eq(int(d.final()["dex"]), int(d.rolled()["dex"]), "an unspent stat is untouched") + + +func test_the_pool_is_three_and_the_fourth_point_does_not_exist(): + var d := _draft() + assert_eq(d.points_left(), 3) + d.increment("str"); d.increment("dex"); d.increment("con") + assert_eq(d.points_left(), 0) + assert_false(d.can_increment("fth")) + d.increment("fth") + assert_eq(int(d.final()["fth"]), int(d.rolled()["fth"]), "the fourth point bought nothing") + assert_eq(d.spent_total(), 3) + + +func test_minus_removes_only_what_you_spent_and_floors_at_the_roll(): + var d := _draft() + d.increment("str") + assert_true(d.can_decrement("str")) + d.decrement("str") + assert_eq(d.points_left(), 3) + assert_false(d.can_decrement("str"), "you cannot go below the roll — additive only") + d.decrement("str") + assert_eq(int(d.final()["str"]), int(d.rolled()["str"])) + + +func test_reroll_changes_the_numbers_and_clears_the_spend_and_nothing_else(): + var d := _draft() + d.set_calling("cutpurse") + d.toggle_skill("stealth") + d.increment("dex") + var before := d.character_seed + + d.reroll() + + assert_ne(d.character_seed, before, "a re-roll IS a new seed — nothing else") + assert_eq(d.spend, {}, "points spent against a roll that no longer exists are meaningless") + assert_eq(d.calling_id, "cutpurse", "the calling survives") + assert_eq(d.skills, ["stealth"], "the picks survive") + assert_eq(d.name, "Aldric", "the name survives") + + +func test_changing_calling_clears_the_picks(): + var d := _draft() + d.toggle_skill("athletics") + assert_eq(d.skills, ["athletics"]) + d.set_calling("hedge_mage") + assert_eq(d.skills, [], "the pool changed underneath them — stale picks are an invalid draft the player did not cause") + + +func test_changing_race_drops_a_pick_the_new_race_now_grants(): + # The elf/cutpurse case. An Elf is GRANTED perception; a Cutpurse who had + # picked it now holds a pick that "buys nothing" and construct rejects by name. + var d := _draft() + d.set_calling("cutpurse") + d.toggle_skill("perception") + assert_eq(d.skills, ["perception"]) + d.set_race("elf") + assert_eq(d.skills, [], "the elf already has perception — the pick is dropped, not left to 422") + + +func test_changing_race_off_human_clears_the_bonus_skill(): + var d := _draft() + d.set_bonus_skill("stealth") + assert_eq(d.bonus_skill, "stealth") + d.set_race("dwarf") + assert_eq(d.bonus_skill, "", "only a human gets a bonus skill") + + +func test_a_granted_skill_cannot_be_picked(): + var d := _draft() + d.set_race("elf") + d.set_calling("cutpurse") + assert_true(d.is_granted("perception")) + assert_false(d.can_pick("perception")) + d.toggle_skill("perception") + assert_eq(d.skills, [], "the chip is inert, not merely styled to look inert") + + +func test_picks_stop_at_the_calling_s_count(): + var d := _draft() + d.set_calling("sellsword") # picks 2 + d.toggle_skill("athletics") + d.toggle_skill("endurance") + assert_eq(d.picks_left(), 0) + d.toggle_skill("perception") + assert_eq(d.skills, ["athletics", "endurance"], "the third pick does not exist") + + +func test_toggling_an_already_picked_skill_removes_it(): + var d := _draft() + d.toggle_skill("athletics") + d.toggle_skill("athletics") + assert_eq(d.skills, []) + + +func test_to_creation_round_trips_through_construct(): + var d := _draft() + d.toggle_skill("athletics") + d.toggle_skill("endurance") + d.set_bonus_skill("perception") + d.increment("str") + + var res := NewGame.construct(_deserter(), world, d.to_creation()) + assert_true(res["ok"], str(res["errors"])) + assert_eq(res["state"].sheet.attributes, d.final(), + "TRAP 1: what the screen SHOWED is what the player GOT — asserted across the pipeline") + + +func test_errors_are_construct_s_errors_not_a_second_copy_of_the_rules(): + var d := _draft() # no skills picked yet + assert_gt(d.errors(_deserter(), world).size(), 0) + assert_false(d.is_complete(_deserter(), world)) + + d.toggle_skill("athletics") + d.toggle_skill("endurance") + d.set_bonus_skill("perception") + assert_eq(d.errors(_deserter(), world), [], "a legal draft has nothing wrong with it") + assert_true(d.is_complete(_deserter(), world)) + + +func test_an_unnamed_draft_is_not_complete(): + var d := _draft() + d.toggle_skill("athletics") + d.toggle_skill("endurance") + d.set_bonus_skill("perception") + d.name = " " + assert_false(d.is_complete(_deserter(), world), "a nameless character cannot enter the world") + + +func test_the_draft_never_exposes_luck(): + # §7 — no accessor, no field, no leak. The screen cannot show what it cannot reach. + var d := _draft() + assert_false("lck" in d.to_creation().get("spend", {})) + assert_false(d.to_creation().has("luck")) + assert_false(d.final().has("lck")) + assert_false(d.rolled().has("lck")) diff --git a/client/tests/unit/test_creation_draft.gd.uid b/client/tests/unit/test_creation_draft.gd.uid new file mode 100644 index 0000000..d154ce7 --- /dev/null +++ b/client/tests/unit/test_creation_draft.gd.uid @@ -0,0 +1 @@ +uid://dqllxamt2tcgu