From 17ef1426890ad69a2a8a574e3b345dd2ec4ac30e Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Mon, 13 Jul 2026 13:54:02 -0500 Subject: [PATCH 01/21] feat(newgame): one public, seeded roll the screen can show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit roll_attributes(seed) is now the single implementation of the die — construct calls it, and the creation screen (M4-b) will call it to DISPLAY the roll. So the screen shows real numbers while still only ever handing over a seed (§2). validate() goes public so the screen can gate its CTA on the real rules. Neither takes a default parameter (traps.md #2). --- client/scripts/newgame/new_game.gd | 30 +++++++++++++++++-- client/tests/unit/test_new_game.gd | 47 ++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/client/scripts/newgame/new_game.gd b/client/scripts/newgame/new_game.gd index bc6f765..0deba42 100644 --- a/client/scripts/newgame/new_game.gd +++ b/client/scripts/newgame/new_game.gd @@ -30,9 +30,9 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary # 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) + # 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))) @@ -93,6 +93,30 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary 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 diff --git a/client/tests/unit/test_new_game.gd b/client/tests/unit/test_new_game.gd index c0d0f98..eec9222 100644 --- a/client/tests/unit/test_new_game.gd +++ b/client/tests/unit/test_new_game.gd @@ -300,3 +300,50 @@ func test_non_companion_override_goes_to_state_not_log(): for m in res["log"].to_dict()["party"]: assert_ne(m["id"], "oda_fenn") assert_eq(res["log"].party_member("brannoc_thane").disposition, 40) + + +func test_roll_attributes_is_pure_and_seeded(): + # Same seed, same five. The screen shows these; construct builds from them. + var a := NewGame.roll_attributes(8675309) + var b := NewGame.roll_attributes(8675309) + assert_eq(a, b) + assert_eq(a.keys(), Attributes.IDS, "the five rows, in the contract's order") + assert_false(a.has("lck"), "LCK is not an attribute and never leaves construct (§7)") + + +func test_roll_attributes_holds_the_floor_of_eight(): + # Sweep enough seeds that a straight 3d6 would certainly have rolled below 8 + # somewhere. (GUT has no assert_gte — assert_true on the comparison.) + var lowest := 999 + for s in range(400): + for stat in Attributes.IDS: + lowest = mini(lowest, int(NewGame.roll_attributes(s)[stat])) + assert_true(lowest >= 8, "the floor broke: something rolled %d" % lowest) + assert_eq(lowest, 8, "the floor should actually BITE across 2000 rolls, not sit unused") + + +func test_construct_builds_exactly_what_roll_attributes_showed(): + # TRAP 1 (traps.md): this is the load-bearing claim of the creation SCREEN — + # "the numbers you saw are the numbers you got". Asserting that two fresh + # rolls agree would pass even if construct kept its own second implementation + # of the die. This asserts across the PIPELINE: what roll_attributes returns, + # plus the spend, IS what construct puts on the sheet. + var spend := {"str": 2, "con": 1} + var res := _build(_creation({"seed": 4242, "spend": spend})) + assert_true(res["ok"], str(res["errors"])) + + var shown := NewGame.roll_attributes(4242) + var expected := {} + for stat in Attributes.IDS: + expected[stat] = int(shown[stat]) + int(spend.get(stat, 0)) + + assert_eq(res["state"].sheet.attributes, expected) + + +func test_validate_is_public_and_agrees_with_construct(): + var bad := _creation({"calling_id": "paladin"}) + var errors := NewGame.validate(_deserter(), world, bad) + assert_gt(errors.size(), 0) + var res := _build(bad) + assert_false(res["ok"]) + assert_eq(res["errors"], errors, "the screen must be told exactly what construct would reject") From e4db022c4b4a14e3c3eb2b73c9aefcdb2656a98a Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Mon, 13 Jul 2026 14:02:47 -0500 Subject: [PATCH 02/21] content(creation): the eleven origin fragments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit race.fragment + calling.fragment compose the DM origin panel on the creation screen. Authored prose, not stubs — this is the first sentence the player reads. Parity-tested: a missing or empty fragment fails the suite. --- client/tests/unit/test_content_db.gd | 14 ++++++++++++++ content/world/callings/bloodsworn.json | 2 +- content/world/callings/bonesetter.json | 2 +- content/world/callings/cutpurse.json | 2 +- content/world/callings/hedge_mage.json | 2 +- content/world/callings/reaver.json | 2 +- content/world/callings/sellsword.json | 2 +- content/world/callings/trapper.json | 2 +- content/world/races/beastfolk.json | 2 +- content/world/races/dwarf.json | 2 +- content/world/races/elf.json | 2 +- content/world/races/human.json | 2 +- 12 files changed, 25 insertions(+), 11 deletions(-) diff --git a/client/tests/unit/test_content_db.gd b/client/tests/unit/test_content_db.gd index f321786..0c11ea1 100644 --- a/client/tests/unit/test_content_db.gd +++ b/client/tests/unit/test_content_db.gd @@ -130,3 +130,17 @@ func test_blurb_content_carries_no_mechanics(): "nightsight", "claws", "keen_scent"]: assert_false(db.races[id].has(banned), "%s.json leaks the mechanic '%s' — that lives in Races" % [id, banned]) + + +func test_every_race_and_calling_carries_a_fragment(): + # The DM origin panel (M4-b) composes race.fragment + calling.fragment. A + # missing one degrades to the blurb at runtime rather than crashing — but it + # must never SHIP missing, so the suite is where it fails. + for id in Races.IDS: + assert_true(db.race(id).has("fragment"), "race %s has no fragment" % id) + assert_ne(str(db.race(id).get("fragment", "")).strip_edges(), "", + "race %s has an empty fragment" % id) + for id in Callings.IDS: + assert_true(db.calling(id).has("fragment"), "calling %s has no fragment" % id) + assert_ne(str(db.calling(id).get("fragment", "")).strip_edges(), "", + "calling %s has an empty fragment" % id) diff --git a/content/world/callings/bloodsworn.json b/content/world/callings/bloodsworn.json index ff30160..093a493 100644 --- a/content/world/callings/bloodsworn.json +++ b/content/world/callings/bloodsworn.json @@ -1 +1 @@ -{ "id": "bloodsworn", "name": "Bloodsworn", "blurb": "You made a bargain with one of the Seven and it was accepted. The power is real. So is the ledger, and it is not settled." } +{ "id": "bloodsworn", "name": "Bloodsworn", "blurb": "You made a bargain with one of the Seven and it was accepted. The power is real. So is the ledger, and it is not settled.", "fragment": "something answered when you called it, and it has been keeping accounts ever since." } diff --git a/content/world/callings/bonesetter.json b/content/world/callings/bonesetter.json index 0ad877f..a58459a 100644 --- a/content/world/callings/bonesetter.json +++ b/content/world/callings/bonesetter.json @@ -1 +1 @@ -{ "id": "bonesetter", "name": "Bonesetter", "blurb": "The Warden is distant and does not explain himself. Still — you set the bone, you say the words, and often enough the rot does not take. Often enough." } +{ "id": "bonesetter", "name": "Bonesetter", "blurb": "The Warden is distant and does not explain himself. Still — you set the bone, you say the words, and often enough the rot does not take. Often enough.", "fragment": "you set the bone, you say the words over it, and you have stopped claiming to know which one matters." } diff --git a/content/world/callings/cutpurse.json b/content/world/callings/cutpurse.json index 697a3f6..296da5e 100644 --- a/content/world/callings/cutpurse.json +++ b/content/world/callings/cutpurse.json @@ -1 +1 @@ -{ "id": "cutpurse", "name": "Cutpurse", "blurb": "Purses, locks, confidences — you have taken all three. The trick was never the hands. It was knowing which pocket was worth it." } +{ "id": "cutpurse", "name": "Cutpurse", "blurb": "Purses, locks, confidences — you have taken all three. The trick was never the hands. It was knowing which pocket was worth it.", "fragment": "quick fingers, quicker exits, and a knife for the rest." } diff --git a/content/world/callings/hedge_mage.json b/content/world/callings/hedge_mage.json index 26ff24b..b884056 100644 --- a/content/world/callings/hedge_mage.json +++ b/content/world/callings/hedge_mage.json @@ -1 +1 @@ -{ "id": "hedge_mage", "name": "Hedge-Mage", "blurb": "No tower took you. What you know, you got from a book you should not have had and a teacher who is not alive to confirm it." } +{ "id": "hedge_mage", "name": "Hedge-Mage", "blurb": "No tower took you. What you know, you got from a book you should not have had and a teacher who is not alive to confirm it.", "fragment": "what you know came out of a stolen book, and there is no one left to tell you what you got wrong." } diff --git a/content/world/callings/reaver.json b/content/world/callings/reaver.json index 24b3fc2..07c0a10 100644 --- a/content/world/callings/reaver.json +++ b/content/world/callings/reaver.json @@ -1 +1 @@ -{ "id": "reaver", "name": "Reaver", "blurb": "They hire you when they want a door opened and do not care about the door. You do not wear plate. You have never needed it." } +{ "id": "reaver", "name": "Reaver", "blurb": "They hire you when they want a door opened and do not care about the door. You do not wear plate. You have never needed it.", "fragment": "they point you at a door and pay you not to care what is behind it." } diff --git a/content/world/callings/sellsword.json b/content/world/callings/sellsword.json index 2aff2e5..ed41905 100644 --- a/content/world/callings/sellsword.json +++ b/content/world/callings/sellsword.json @@ -1 +1 @@ -{ "id": "sellsword", "name": "Sellsword", "blurb": "You fight for money. You are good at it, and the work has never once run out." } +{ "id": "sellsword", "name": "Sellsword", "blurb": "You fight for money. You are good at it, and the work has never once run out.", "fragment": "you sell the swing and let the man paying keep the reasons." } diff --git a/content/world/callings/trapper.json b/content/world/callings/trapper.json index 3031d0a..9cc8690 100644 --- a/content/world/callings/trapper.json +++ b/content/world/callings/trapper.json @@ -1 +1 @@ -{ "id": "trapper", "name": "Trapper", "blurb": "You worked the treelines until the treelines ran out of anything worth taking. A snare does the waiting for you, and you have learned to wait." } +{ "id": "trapper", "name": "Trapper", "blurb": "You worked the treelines until the treelines ran out of anything worth taking. A snare does the waiting for you, and you have learned to wait.", "fragment": "you learned to sit still in the wet dark until something else made the mistake." } diff --git a/content/world/races/beastfolk.json b/content/world/races/beastfolk.json index 4dde59a..dde61e4 100644 --- a/content/world/races/beastfolk.json +++ b/content/world/races/beastfolk.json @@ -1 +1 @@ -{ "id": "beastfolk", "name": "Beastfolk", "blurb": "Claws you did not ask for and a nose you cannot switch off. Every gate you pass, someone decides what you are before you speak." } +{ "id": "beastfolk", "name": "Beastfolk", "blurb": "Claws you did not ask for and a nose you cannot switch off. Every gate you pass, someone decides what you are before you speak.", "fragment": "You were born with claws the Margreave never learned to ignore, and you have spent your life being looked at a beat too long." } diff --git a/content/world/races/dwarf.json b/content/world/races/dwarf.json index 1a5bc4b..7de1a3e 100644 --- a/content/world/races/dwarf.json +++ b/content/world/races/dwarf.json @@ -1 +1 @@ -{ "id": "dwarf", "name": "Dwarf", "blurb": "You have drunk worse and survived worse. The rot that takes other men tends to think better of it." } +{ "id": "dwarf", "name": "Dwarf", "blurb": "You have drunk worse and survived worse. The rot that takes other men tends to think better of it.", "fragment": "You came up in stone and cold water, among folk who bury their dead deep and do not speak of them again." } diff --git a/content/world/races/elf.json b/content/world/races/elf.json index 6755cb9..719f958 100644 --- a/content/world/races/elf.json +++ b/content/world/races/elf.json @@ -1 +1 @@ -{ "id": "elf", "name": "Elf", "blurb": "You see in the dark and you notice things. Neither has made you popular in a town that would rather not be noticed." } +{ "id": "elf", "name": "Elf", "blurb": "You see in the dark and you notice things. Neither has made you popular in a town that would rather not be noticed.", "fragment": "You come from a people the Margreave tolerates from a distance, and you have never been in a room where that was not clear." } diff --git a/content/world/races/human.json b/content/world/races/human.json index 373054e..d483ad0 100644 --- a/content/world/races/human.json +++ b/content/world/races/human.json @@ -1 +1 @@ -{ "id": "human", "name": "Human", "blurb": "Short-lived, adaptable, everywhere. The Margreave is mostly yours, for whatever that has been worth." } +{ "id": "human", "name": "Human", "blurb": "Short-lived, adaptable, everywhere. The Margreave is mostly yours, for whatever that has been worth.", "fragment": "You were born in one of the Margreave's thousand small towns, and it let you go without asking where." } From c6b8a651aa8c741169c5f6231f82820d11684878 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Mon, 13 Jul 2026 14:11:16 -0500 Subject: [PATCH 03/21] fix(creation-content): four fragments were reading the blurb back to the player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The creation screen shows a race's blurb on its card and the chosen calling's blurb in the detail panel directly above the DM panel. Blurb and fragment are on screen at the same moment, inches apart — so a fragment that paraphrases its own blurb makes the DM sound like it is reciting the card. Four did: - bonesetter lifted two clauses verbatim ("you set the bone, you say the words") - reaver re-ran the blurb's door image with the same sentence shape - hedge_mage re-opened on "what you know" and re-landed the dead teacher - beastfolk repeated both the claws and the being-judged beat Each now says the NEXT thing instead: the hour they wake you and which of the two trades you will not claim credit for; the employer who arranges to be elsewhere; where you went to fail safely and what the book's gaps cost your skin; the town that takes your coin and still wants you outside the walls. beastfolk's rewrite also breaks the race-set monotony — it no longer opens "You were born" (now unique to human) and drops the shared "X, and you have…" spine, so clicking the four cards in a row stops reading as one voice. The parity test guarded presence but could not fail on the three properties that actually make all 28 race×calling concatenations grammatical. It now asserts race fragments start uppercase and end with '.', and calling fragments start lowercase and end with '.' — verified by breaking both and watching all four new assertions fire. 255 client tests green; content build --check green. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/tests/unit/test_content_db.gd | 23 +++++++++++++++++++---- content/world/callings/bonesetter.json | 2 +- content/world/callings/hedge_mage.json | 2 +- content/world/callings/reaver.json | 2 +- content/world/races/beastfolk.json | 2 +- 5 files changed, 23 insertions(+), 8 deletions(-) diff --git a/client/tests/unit/test_content_db.gd b/client/tests/unit/test_content_db.gd index 0c11ea1..dee944a 100644 --- a/client/tests/unit/test_content_db.gd +++ b/client/tests/unit/test_content_db.gd @@ -136,11 +136,26 @@ func test_every_race_and_calling_carries_a_fragment(): # The DM origin panel (M4-b) composes race.fragment + calling.fragment. A # missing one degrades to the blurb at runtime rather than crashing — but it # must never SHIP missing, so the suite is where it fails. + # + # The composition is "{race.fragment} Now you carry a {Calling}'s work — + # {calling.fragment}", so three shape rules make all 28 pairings legal + # sentences: a race fragment opens a sentence (uppercase) and closes it (.); + # a calling fragment continues one after the em dash (lowercase) and closes + # it (.). Guard them here — a future author adding a calling gets a signal, + # not a subtly ungrammatical DM. for id in Races.IDS: assert_true(db.race(id).has("fragment"), "race %s has no fragment" % id) - assert_ne(str(db.race(id).get("fragment", "")).strip_edges(), "", - "race %s has an empty fragment" % id) + var race_fragment := str(db.race(id).get("fragment", "")).strip_edges() + assert_ne(race_fragment, "", "race %s has an empty fragment" % id) + assert_eq(race_fragment.substr(0, 1), race_fragment.substr(0, 1).to_upper(), + "race %s: fragment opens the composition, so it must start uppercase" % id) + assert_true(race_fragment.ends_with("."), + "race %s: fragment must end with '.' — a sentence precedes 'Now you carry…'" % id) for id in Callings.IDS: assert_true(db.calling(id).has("fragment"), "calling %s has no fragment" % id) - assert_ne(str(db.calling(id).get("fragment", "")).strip_edges(), "", - "calling %s has an empty fragment" % id) + var calling_fragment := str(db.calling(id).get("fragment", "")).strip_edges() + assert_ne(calling_fragment, "", "calling %s has an empty fragment" % id) + assert_eq(calling_fragment.substr(0, 1), calling_fragment.substr(0, 1).to_lower(), + "calling %s: fragment follows an em dash, so it must start lowercase" % id) + assert_true(calling_fragment.ends_with("."), + "calling %s: fragment must end with '.' — it closes the composition" % id) diff --git a/content/world/callings/bonesetter.json b/content/world/callings/bonesetter.json index a58459a..c42cf37 100644 --- a/content/world/callings/bonesetter.json +++ b/content/world/callings/bonesetter.json @@ -1 +1 @@ -{ "id": "bonesetter", "name": "Bonesetter", "blurb": "The Warden is distant and does not explain himself. Still — you set the bone, you say the words, and often enough the rot does not take. Often enough.", "fragment": "you set the bone, you say the words over it, and you have stopped claiming to know which one matters." } +{ "id": "bonesetter", "name": "Bonesetter", "blurb": "The Warden is distant and does not explain himself. Still — you set the bone, you say the words, and often enough the rot does not take. Often enough.", "fragment": "you are the one they wake at the third hour, and you have stopped claiming to know whether it is your hands or the Warden that does the saving." } diff --git a/content/world/callings/hedge_mage.json b/content/world/callings/hedge_mage.json index b884056..ad3fde1 100644 --- a/content/world/callings/hedge_mage.json +++ b/content/world/callings/hedge_mage.json @@ -1 +1 @@ -{ "id": "hedge_mage", "name": "Hedge-Mage", "blurb": "No tower took you. What you know, you got from a book you should not have had and a teacher who is not alive to confirm it.", "fragment": "what you know came out of a stolen book, and there is no one left to tell you what you got wrong." } +{ "id": "hedge_mage", "name": "Hedge-Mage", "blurb": "No tower took you. What you know, you got from a book you should not have had and a teacher who is not alive to confirm it.", "fragment": "you taught yourself the rest where nobody would come looking, and the scars are from the parts the book got wrong." } diff --git a/content/world/callings/reaver.json b/content/world/callings/reaver.json index 07c0a10..6a27575 100644 --- a/content/world/callings/reaver.json +++ b/content/world/callings/reaver.json @@ -1 +1 @@ -{ "id": "reaver", "name": "Reaver", "blurb": "They hire you when they want a door opened and do not care about the door. You do not wear plate. You have never needed it.", "fragment": "they point you at a door and pay you not to care what is behind it." } +{ "id": "reaver", "name": "Reaver", "blurb": "They hire you when they want a door opened and do not care about the door. You do not wear plate. You have never needed it.", "fragment": "they send for you when the talking is finished, and they make a point of being somewhere else when you arrive." } diff --git a/content/world/races/beastfolk.json b/content/world/races/beastfolk.json index dde61e4..5f1d6b3 100644 --- a/content/world/races/beastfolk.json +++ b/content/world/races/beastfolk.json @@ -1 +1 @@ -{ "id": "beastfolk", "name": "Beastfolk", "blurb": "Claws you did not ask for and a nose you cannot switch off. Every gate you pass, someone decides what you are before you speak.", "fragment": "You were born with claws the Margreave never learned to ignore, and you have spent your life being looked at a beat too long." } +{ "id": "beastfolk", "name": "Beastfolk", "blurb": "Claws you did not ask for and a nose you cannot switch off. Every gate you pass, someone decides what you are before you speak.", "fragment": "Most towns will take your coin and still want you outside the walls by dark. You stopped taking that personally a long time ago." } From 057b83679b2594b6126849cc6ad47beb11ecf2f7 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Mon, 13 Jul 2026 14:18:46 -0500 Subject: [PATCH 04/21] fix(content): bonesetter fragment restated the blurb's central non-claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fragment's closing clause — "you have stopped claiming to know whether it is your hands or the Warden that does the saving" — restated the thesis of the blurb the player is reading at the same moment, inches above it in the calling detail panel. The blurb's whole argument already IS the unresolved-Warden question: it states it outright ("distant and does not explain himself") and dramatises it with its doubled "Often enough." The fragment took that artful implicit doubt, said it flat, and put it in the emphatic final position of the composed DM line. Different words, same beat. It also collided lexically with the beastfolk race fragment, which the composition can place directly before it: "You stopped taking that personally" / "you have stopped claiming". Keep the front clause — "you are the one they wake at the third hour" is the best image in the set and gives the player a position in a village that nothing else on the screen supplies. Rewrite the back clause to say the next thing instead of the same thing: what the failed nights cost, and that the job does not release you for failing. The fragment no longer mentions the Warden at all, so it cannot re-argue him; it now makes a claim about the player's life rather than about a god. Also avoids "carry", which the composition template itself supplies ("Now you carry a Bonesetter's work"), so the rendered sentence no longer risks repeating its own verb. Client suite 255/255. content_build --check exit 0. --- content/world/callings/bonesetter.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/world/callings/bonesetter.json b/content/world/callings/bonesetter.json index c42cf37..c714a1b 100644 --- a/content/world/callings/bonesetter.json +++ b/content/world/callings/bonesetter.json @@ -1 +1 @@ -{ "id": "bonesetter", "name": "Bonesetter", "blurb": "The Warden is distant and does not explain himself. Still — you set the bone, you say the words, and often enough the rot does not take. Often enough.", "fragment": "you are the one they wake at the third hour, and you have stopped claiming to know whether it is your hands or the Warden that does the saving." } +{ "id": "bonesetter", "name": "Bonesetter", "blurb": "The Warden is distant and does not explain himself. Still — you set the bone, you say the words, and often enough the rot does not take. Often enough.", "fragment": "you are the one they wake at the third hour, and when it goes the other way you wash the body yourself and answer the door again the next night." } From 537b0e1d18d55e3f3ba395a59e9f64024e14ebb2 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Mon, 13 Jul 2026 14:28:30 -0500 Subject: [PATCH 05/21] =?UTF-8?q?feat(creation):=20CreationCopy=20?= =?UTF-8?q?=E2=80=94=20the=20card=20reads=20the=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display strings derived from Callings/Races/Skills at call time. Retune a hit die and the card follows, because the card never knew the number. Prose stays in content; numbers stay in code; nothing is written twice. Deviation from the plan, approved by the human: the plan's test asserted the rendered line contains Callings.armor(id) — the RAW table value — while the plan's own ARMOR_WORD maps "none" to "no armour". "none" is not a substring of "no armour", so the Hedge-Mage failed. The card's copy is what the plan's docstring example shows, so the copy stands and the test was fixed: armor_word(id) is now public and the test asserts against the derived word. The guard is unchanged — hardcode an armour string and retune the table, and it still goes red. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/scripts/ui/creation/creation_copy.gd | 76 +++++++++++++++++++ .../scripts/ui/creation/creation_copy.gd.uid | 1 + client/tests/unit/test_creation_copy.gd | 54 +++++++++++++ client/tests/unit/test_creation_copy.gd.uid | 1 + 4 files changed, 132 insertions(+) create mode 100644 client/scripts/ui/creation/creation_copy.gd create mode 100644 client/scripts/ui/creation/creation_copy.gd.uid create mode 100644 client/tests/unit/test_creation_copy.gd create mode 100644 client/tests/unit/test_creation_copy.gd.uid diff --git a/client/scripts/ui/creation/creation_copy.gd b/client/scripts/ui/creation/creation_copy.gd new file mode 100644 index 0000000..6a7fea6 --- /dev/null +++ b/client/scripts/ui/creation/creation_copy.gd @@ -0,0 +1,76 @@ +class_name CreationCopy +extends RefCounted +## Display strings for the creation screen, DERIVED from the rules tables. +## +## Prose lives in content (blurbs, fragments). NUMBERS LIVE IN CODE. The card reads +## both and repeats neither. A hit die written into a JSON — or typed into a string +## literal here — is a second source of truth, and it will eventually disagree with +## the table. Every number below is read from Callings/Races/Skills at call time, so +## retuning a calling retunes its card. +## +## §7: nothing here may mention Luck, in any form. §2: presentation only. + +const ARMOR_WORD := { + "none": "no armour", + "light": "light armour", + "medium": "medium armour", + "heavy": "heavy armour", +} + + +static func calling_detail(id: String) -> String: + ## e.g. "d8 · light armour · MP (FTH) · saves DEX + MAG · picks 4 skills · talent backstab" + if not Callings.exists(id): + return "" + var saves: Array = [] + for s in Callings.saves(id): + saves.append(s.to_upper()) + return "d%d · %s · %s · saves %s · picks %d skills · talent %s" % [ + Callings.hit_die(id), + armor_word(id), + resource_word(id), + " + ".join(saves), + Callings.skill_count(id), + Callings.talent(id), + ] + + +static func armor_word(id: String) -> String: + ## The card says "no armour", not "none". Public so the test can assert against + ## the DERIVED word rather than the raw table value — "none" is not a substring + ## of "no armour", and asserting on the raw value only ever passed by accident. + return ARMOR_WORD.get(Callings.armor(id), Callings.armor(id)) + + +static func resource_word(id: String) -> String: + ## Martials live on cooldowns; casters on a pool off their casting stat. + if not Callings.is_caster(id): + return "cooldowns" + return "MP (%s)" % Callings.casting_stat(id).to_upper() + + +static func race_trait(id: String) -> String: + ## e.g. "+1 to every save · +1 skill of choice" / "Poison resist · Nightsight" + if not Races.exists(id): + return "" + var parts: Array = [] + if Races.save_bonus(id) > 0: + parts.append("+%d to every save" % Races.save_bonus(id)) + for s in Races.granted_skills(id): + parts.append(skill_label(s).capitalize()) + if Races.poison_save_bonus(id) > 0: + parts.append("Poison resist") + if Races.flag(id, "nightsight"): + parts.append("Nightsight") + if Races.flag(id, "claws"): + parts.append("Claws") + if Races.flag(id, "keen_scent"): + parts.append("Keen scent") + if Races.wants_bonus_skill(id): + parts.append("+1 skill of choice") + return " · ".join(parts) + + +static func skill_label(skill: String) -> String: + ## sleight_of_hand -> "sleight of hand". Ids are snake_case; humans are not. + return skill.replace("_", " ") diff --git a/client/scripts/ui/creation/creation_copy.gd.uid b/client/scripts/ui/creation/creation_copy.gd.uid new file mode 100644 index 0000000..75e02e7 --- /dev/null +++ b/client/scripts/ui/creation/creation_copy.gd.uid @@ -0,0 +1 @@ +uid://rm6bkgstenf1 diff --git a/client/tests/unit/test_creation_copy.gd b/client/tests/unit/test_creation_copy.gd new file mode 100644 index 0000000..79b7809 --- /dev/null +++ b/client/tests/unit/test_creation_copy.gd @@ -0,0 +1,54 @@ +extends "res://addons/gut/test.gd" + + +func test_calling_detail_reads_the_table_rather_than_repeating_it(): + # The guard that fails if anyone types "d8" into a string literal. If someone + # retunes the Cutpurse's hit die, this test keeps passing and the CARD FOLLOWS — + # which is the whole point. A hardcoded line would go red here. + for id in Callings.IDS: + var line := CreationCopy.calling_detail(id) + assert_string_contains(line, "d%d" % Callings.hit_die(id)) + assert_string_contains(line, CreationCopy.armor_word(id)) + assert_string_contains(line, Callings.talent(id)) + assert_string_contains(line, str(Callings.skill_count(id))) + for s in Callings.saves(id): + assert_string_contains(line, s.to_upper()) + + +func test_casters_show_a_pool_and_martials_show_cooldowns(): + assert_string_contains(CreationCopy.calling_detail("hedge_mage"), "MP (MAG)") + assert_string_contains(CreationCopy.calling_detail("bonesetter"), "MP (FTH)") + assert_string_contains(CreationCopy.calling_detail("sellsword"), "cooldowns") + assert_false(CreationCopy.calling_detail("sellsword").contains("MP")) + + +func test_race_trait_lines_are_derived_from_the_race_table(): + assert_string_contains(CreationCopy.race_trait("human"), "every save") + assert_string_contains(CreationCopy.race_trait("human"), "skill of choice") + assert_string_contains(CreationCopy.race_trait("elf"), "Perception") + assert_string_contains(CreationCopy.race_trait("elf"), "Nightsight") + assert_string_contains(CreationCopy.race_trait("dwarf"), "Poison") + assert_string_contains(CreationCopy.race_trait("dwarf"), "Nightsight") + assert_string_contains(CreationCopy.race_trait("beastfolk"), "Claws") + assert_string_contains(CreationCopy.race_trait("beastfolk"), "Keen scent") + + +func test_every_race_has_a_non_empty_trait_line(): + for id in Races.IDS: + assert_ne(CreationCopy.race_trait(id).strip_edges(), "", "%s has no trait line" % id) + + +func test_skill_labels_are_human_readable(): + assert_eq(CreationCopy.skill_label("sleight_of_hand"), "sleight of hand") + assert_eq(CreationCopy.skill_label("faith_lore"), "faith lore") + assert_eq(CreationCopy.skill_label("stealth"), "stealth") + + +func test_nothing_in_the_copy_mentions_luck(): + # §7 — the player must never be able to CALCULATE that he is cursed. + for id in Callings.IDS: + assert_false(CreationCopy.calling_detail(id).to_lower().contains("luck")) + assert_false(CreationCopy.calling_detail(id).to_lower().contains("lck")) + for id in Races.IDS: + assert_false(CreationCopy.race_trait(id).to_lower().contains("luck")) + assert_false(CreationCopy.race_trait(id).to_lower().contains("lck")) diff --git a/client/tests/unit/test_creation_copy.gd.uid b/client/tests/unit/test_creation_copy.gd.uid new file mode 100644 index 0000000..280de2d --- /dev/null +++ b/client/tests/unit/test_creation_copy.gd.uid @@ -0,0 +1 @@ +uid://bvb87ime573x4 From 1c4d19492df9877d0f7e965c388805c098915321 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Mon, 13 Jul 2026 14:34:12 -0500 Subject: [PATCH 06/21] fix(test): skill-count assertion collided with hit-die digit for Reaver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_calling_detail_reads_the_table_rather_than_repeating_it checked for str(Callings.skill_count(id)) — bare digit "2" — which appeared in d12 for the Reaver (hit_die=12, skill_count=2). Hardcoding "picks 3 skills" in the card would pass the test, satisfied by the "2" in "d12". Now anchors to "picks %d skills" format string, so the guard catches when the card's hardcoded count diverges from the table. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/tests/unit/test_creation_copy.gd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/tests/unit/test_creation_copy.gd b/client/tests/unit/test_creation_copy.gd index 79b7809..bada308 100644 --- a/client/tests/unit/test_creation_copy.gd +++ b/client/tests/unit/test_creation_copy.gd @@ -10,7 +10,7 @@ func test_calling_detail_reads_the_table_rather_than_repeating_it(): assert_string_contains(line, "d%d" % Callings.hit_die(id)) assert_string_contains(line, CreationCopy.armor_word(id)) assert_string_contains(line, Callings.talent(id)) - assert_string_contains(line, str(Callings.skill_count(id))) + assert_string_contains(line, "picks %d skills" % Callings.skill_count(id)) for s in Callings.saves(id): assert_string_contains(line, s.to_upper()) From 866158cc0dd3b72bc315b8cc2d85ff4b881844e6 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Mon, 13 Jul 2026 14:38:18 -0500 Subject: [PATCH 07/21] =?UTF-8?q?feat(creation):=20CreationDraft=20?= =?UTF-8?q?=E2=80=94=20the=20screen's=20brain,=20with=20no=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure, node-free, headlessly testable. Carries a seed, not a stat block. Owns no rules of its own: errors() delegates to NewGame.validate. The interaction rules that make creation screens buggy are pinned by test: changing calling clears the picks; changing race drops a pick the new race now grants (the elf/cutpurse case); re-roll clears the spend and only the spend; minus floors at the roll; the fourth point does not exist. --- client/scripts/ui/creation/creation_draft.gd | 214 ++++++++++++++++++ .../scripts/ui/creation/creation_draft.gd.uid | 1 + client/tests/unit/test_creation_draft.gd | 182 +++++++++++++++ client/tests/unit/test_creation_draft.gd.uid | 1 + 4 files changed, 398 insertions(+) create mode 100644 client/scripts/ui/creation/creation_draft.gd create mode 100644 client/scripts/ui/creation/creation_draft.gd.uid create mode 100644 client/tests/unit/test_creation_draft.gd create mode 100644 client/tests/unit/test_creation_draft.gd.uid 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 From 1621a38b2921c94042fedd368112a96b49f1c7e8 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Mon, 13 Jul 2026 14:44:50 -0500 Subject: [PATCH 08/21] fix(creation-draft): delete the duplicate SPEND_POOL const MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CreationDraft's docstring promised it owns no rules—it delegates to NewGame.validate. But it kept a local const POOL := 3 that mirrored NewGame.SPEND_POOL, inviting silent disagreement if the source was changed and the copy forgotten. Delete POOL. Use NewGame.SPEND_POOL directly in points_left(), with a comment making it clear this is the single authority. The coupling is now enforced by code structure, not by hope. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/scripts/ui/creation/creation_draft.gd | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/scripts/ui/creation/creation_draft.gd b/client/scripts/ui/creation/creation_draft.gd index 181918e..ac6c589 100644 --- a/client/scripts/ui/creation/creation_draft.gd +++ b/client/scripts/ui/creation/creation_draft.gd @@ -15,7 +15,6 @@ extends RefCounted ## 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 = "" @@ -65,7 +64,10 @@ func spent_total() -> int: func points_left() -> int: - return POOL - spent_total() + ## NewGame.SPEND_POOL is the ONE authority on the pool's size. A local copy of the + ## number would be a second thing to keep in sync, and this class's whole contract + ## is that it keeps none. + return NewGame.SPEND_POOL - spent_total() func can_increment(stat: String) -> bool: From 0bbf94289a7f1fa7c66d042fe26da9cdae78d75b Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Mon, 13 Jul 2026 14:48:17 -0500 Subject: [PATCH 09/21] feat(theme): the creation screen's five variations SelectCard (a Button whose `pressed` IS the chosen ring), SkillChip (a toggle with unpicked/picked/granted-so-inert), the CHOSEN and PRIMARY tags, and the gold PrimaryCard ring. Plus INK_LABEL_MUTED for the sheet's section headers. Built by build_game_theme.gd and regenerated; the .tres is never hand-edited. --- client/assets/theme/game_theme.tres | 388 ++++++++++++++++++----- client/scripts/theme/build_game_theme.gd | 69 ++++ client/scripts/theme/palette.gd | 1 + client/scripts/theme/theme_keys.gd | 11 + client/tests/unit/test_theme_resource.gd | 26 ++ 5 files changed, 409 insertions(+), 86 deletions(-) diff --git a/client/assets/theme/game_theme.tres b/client/assets/theme/game_theme.tres index c6c3265..2f94a07 100644 --- a/client/assets/theme/game_theme.tres +++ b/client/assets/theme/game_theme.tres @@ -60,6 +60,22 @@ content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 content_margin_bottom = 6.0 +bg_color = Color(0.56078434, 0.22745098, 0.20392157, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.56078434, 0.22745098, 0.20392157, 1) +corner_radius_top_left = 5 +corner_radius_top_right = 5 +corner_radius_bottom_right = 5 +corner_radius_bottom_left = 5 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ube3r"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 bg_color = Color(0.07058824, 0.05882353, 0.043137256, 0.7) border_width_left = 1 border_width_top = 1 @@ -71,9 +87,9 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_ube3r"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_2tooq"] -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2tooq"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4kten"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -89,7 +105,7 @@ corner_radius_top_right = 8 corner_radius_bottom_right = 8 corner_radius_bottom_left = 8 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4kten"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p6wtm"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -105,7 +121,7 @@ corner_radius_top_right = 8 corner_radius_bottom_right = 8 corner_radius_bottom_left = 8 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p6wtm"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0fjdt"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -121,7 +137,7 @@ corner_radius_top_right = 8 corner_radius_bottom_right = 8 corner_radius_bottom_left = 8 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0fjdt"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ns3kg"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -137,7 +153,7 @@ corner_radius_top_right = 6 corner_radius_bottom_right = 6 corner_radius_bottom_left = 6 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ns3kg"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jly1r"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -154,7 +170,7 @@ corner_radius_top_right = 6 corner_radius_bottom_right = 6 corner_radius_bottom_left = 6 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jly1r"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lw8lk"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -170,9 +186,9 @@ corner_radius_top_right = 8 corner_radius_bottom_right = 8 corner_radius_bottom_left = 8 -[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_lw8lk"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_nh0e7"] -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nh0e7"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dtr07"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -188,7 +204,7 @@ corner_radius_top_right = 8 corner_radius_bottom_right = 8 corner_radius_bottom_left = 8 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dtr07"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nmowx"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -204,7 +220,7 @@ corner_radius_top_right = 8 corner_radius_bottom_right = 8 corner_radius_bottom_left = 8 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nmowx"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_oi067"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -220,7 +236,7 @@ corner_radius_top_right = 8 corner_radius_bottom_right = 8 corner_radius_bottom_left = 8 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_oi067"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukblj"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -236,7 +252,7 @@ corner_radius_top_right = 6 corner_radius_bottom_right = 6 corner_radius_bottom_left = 6 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukblj"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_utt73"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -252,7 +268,7 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_utt73"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_5u8fh"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -268,28 +284,12 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_5u8fh"] -content_margin_left = 12.0 -content_margin_top = 6.0 -content_margin_right = 12.0 -content_margin_bottom = 6.0 -bg_color = Color(0.72156864, 0.32156864, 0.2901961, 1) -border_width_left = 1 -border_width_top = 1 -border_width_right = 1 -border_width_bottom = 1 -border_color = Color(0.56078434, 0.22745098, 0.20392157, 1) -corner_radius_top_left = 4 -corner_radius_top_right = 4 -corner_radius_bottom_right = 4 -corner_radius_bottom_left = 4 - [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p0qbh"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 content_margin_bottom = 6.0 -bg_color = Color(0.56078434, 0.22745098, 0.20392157, 1) +bg_color = Color(0.72156864, 0.32156864, 0.2901961, 1) border_width_left = 1 border_width_top = 1 border_width_right = 1 @@ -305,6 +305,22 @@ content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 content_margin_bottom = 6.0 +bg_color = Color(0.56078434, 0.22745098, 0.20392157, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.56078434, 0.22745098, 0.20392157, 1) +corner_radius_top_left = 4 +corner_radius_top_right = 4 +corner_radius_bottom_right = 4 +corner_radius_bottom_left = 4 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_txxuc"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 bg_color = Color(0.47843137, 0.18431373, 0.16078432, 1) border_width_left = 1 border_width_top = 1 @@ -316,75 +332,155 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_txxuc"] - [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_7d6ug"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 content_margin_bottom = 6.0 bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1) -border_width_left = 1 -border_width_top = 1 -border_width_right = 1 -border_width_bottom = 1 -border_color = Color(0.79607844, 0.7137255, 0.5176471, 1) -corner_radius_top_left = 14 -corner_radius_top_right = 14 -corner_radius_bottom_right = 14 -corner_radius_bottom_left = 14 +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +border_color = Color(0.56078434, 0.41568628, 0.16470589, 1) +corner_radius_top_left = 12 +corner_radius_top_right = 12 +corner_radius_bottom_right = 12 +corner_radius_bottom_left = 12 [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_najyk"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 content_margin_bottom = 6.0 -bg_color = Color(0.9372549, 0.9019608, 0.8117647, 1) +bg_color = Color(0.56078434, 0.41568628, 0.16470589, 1) border_width_left = 1 border_width_top = 1 border_width_right = 1 border_width_bottom = 1 -border_color = Color(0.79607844, 0.7137255, 0.5176471, 1) -corner_radius_top_left = 14 -corner_radius_top_right = 14 -corner_radius_bottom_right = 14 -corner_radius_bottom_left = 14 +border_color = Color(0.56078434, 0.41568628, 0.16470589, 1) +corner_radius_top_left = 5 +corner_radius_top_right = 5 +corner_radius_bottom_right = 5 +corner_radius_bottom_left = 5 [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jtb1k"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 content_margin_bottom = 6.0 +bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.6627451, 0.5803922, 0.39215687, 1) +corner_radius_top_left = 12 +corner_radius_top_right = 12 +corner_radius_bottom_right = 12 +corner_radius_bottom_left = 12 + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_wmbj4"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_xdylb"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1) border_width_left = 1 border_width_top = 1 border_width_right = 1 border_width_bottom = 1 -border_color = Color(0.7490196, 0.65882355, 0.47058824, 1) -corner_radius_top_left = 14 -corner_radius_top_right = 14 -corner_radius_bottom_right = 14 -corner_radius_bottom_left = 14 +border_color = Color(0.6627451, 0.5803922, 0.39215687, 1) +corner_radius_top_left = 12 +corner_radius_top_right = 12 +corner_radius_bottom_right = 12 +corner_radius_bottom_left = 12 -[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_wmbj4"] - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_xdylb"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pjnll"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 content_margin_bottom = 6.0 -bg_color = Color(0.72156864, 0.32156864, 0.2901961, 1) +bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1) border_width_left = 1 border_width_top = 1 border_width_right = 1 border_width_bottom = 1 -border_color = Color(0.72156864, 0.32156864, 0.2901961, 1) +border_color = Color(0.7490196, 0.65882355, 0.47058824, 1) +corner_radius_top_left = 12 +corner_radius_top_right = 12 +corner_radius_bottom_right = 12 +corner_radius_bottom_left = 12 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_klelq"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 +bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1) +border_width_left = 3 +border_width_top = 3 +border_width_right = 3 +border_width_bottom = 3 +border_color = Color(0.56078434, 0.22745098, 0.20392157, 1) +corner_radius_top_left = 12 +corner_radius_top_right = 12 +corner_radius_bottom_right = 12 +corner_radius_bottom_left = 12 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_i2sl5"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 +bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.6627451, 0.5803922, 0.39215687, 1) corner_radius_top_left = 14 corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pjnll"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_onsig"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_gw0t7"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 +bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.3529412, 0.2627451, 0.14901961, 1) +corner_radius_top_left = 14 +corner_radius_top_right = 14 +corner_radius_bottom_right = 14 +corner_radius_bottom_left = 14 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_88l5g"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 +bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.6627451, 0.5803922, 0.39215687, 1) +corner_radius_top_left = 14 +corner_radius_top_right = 14 +corner_radius_bottom_right = 14 +corner_radius_bottom_left = 14 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_f8qac"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -400,7 +496,91 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_klelq"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_u6gcs"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nufy3"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 +bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.79607844, 0.7137255, 0.5176471, 1) +corner_radius_top_left = 14 +corner_radius_top_right = 14 +corner_radius_bottom_right = 14 +corner_radius_bottom_left = 14 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0d7vs"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 +bg_color = Color(0.9372549, 0.9019608, 0.8117647, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.79607844, 0.7137255, 0.5176471, 1) +corner_radius_top_left = 14 +corner_radius_top_right = 14 +corner_radius_bottom_right = 14 +corner_radius_bottom_left = 14 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_3fnfk"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 +bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.7490196, 0.65882355, 0.47058824, 1) +corner_radius_top_left = 14 +corner_radius_top_right = 14 +corner_radius_bottom_right = 14 +corner_radius_bottom_left = 14 + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_2txdw"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_w7qkp"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 +bg_color = Color(0.72156864, 0.32156864, 0.2901961, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.72156864, 0.32156864, 0.2901961, 1) +corner_radius_top_left = 14 +corner_radius_top_right = 14 +corner_radius_bottom_right = 14 +corner_radius_bottom_left = 14 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_rthnr"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 +bg_color = Color(0.56078434, 0.22745098, 0.20392157, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.56078434, 0.22745098, 0.20392157, 1) +corner_radius_top_left = 14 +corner_radius_top_right = 14 +corner_radius_bottom_right = 14 +corner_radius_bottom_left = 14 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ta3oh"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -431,22 +611,27 @@ Chip/styles/focus = SubResource("StyleBoxEmpty_cqbvn") Chip/styles/hover = SubResource("StyleBoxFlat_q5gpk") Chip/styles/normal = SubResource("StyleBoxFlat_bbpmc") Chip/styles/pressed = SubResource("StyleBoxFlat_73lme") +ChosenTag/base_type = &"Label" +ChosenTag/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1) +ChosenTag/font_sizes/font_size = 10 +ChosenTag/fonts/font = ExtResource("2_4lwr2") +ChosenTag/styles/normal = SubResource("StyleBoxFlat_js3tr") DarkPanel/base_type = &"PanelContainer" -DarkPanel/styles/panel = SubResource("StyleBoxFlat_js3tr") +DarkPanel/styles/panel = SubResource("StyleBoxFlat_ube3r") DockButton/base_type = &"Button" DockButton/colors/font_color = Color(0.7882353, 0.7411765, 0.6392157, 1) -DockButton/styles/focus = SubResource("StyleBoxEmpty_ube3r") -DockButton/styles/hover = SubResource("StyleBoxFlat_2tooq") -DockButton/styles/normal = SubResource("StyleBoxFlat_4kten") -DockButton/styles/pressed = SubResource("StyleBoxFlat_p6wtm") +DockButton/styles/focus = SubResource("StyleBoxEmpty_2tooq") +DockButton/styles/hover = SubResource("StyleBoxFlat_4kten") +DockButton/styles/normal = SubResource("StyleBoxFlat_p6wtm") +DockButton/styles/pressed = SubResource("StyleBoxFlat_0fjdt") Heading/base_type = &"Label" Heading/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1) Heading/font_sizes/font_size = 34 Heading/fonts/font = ExtResource("3_eckwq") ItemTile/base_type = &"PanelContainer" -ItemTile/styles/panel = SubResource("StyleBoxFlat_0fjdt") +ItemTile/styles/panel = SubResource("StyleBoxFlat_ns3kg") ItemTileEmpty/base_type = &"PanelContainer" -ItemTileEmpty/styles/panel = SubResource("StyleBoxFlat_ns3kg") +ItemTileEmpty/styles/panel = SubResource("StyleBoxFlat_jly1r") Mono/base_type = &"Label" Mono/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1) Mono/font_sizes/font_size = 13 @@ -454,38 +639,69 @@ Mono/fonts/font = ExtResource("2_4lwr2") ParchmentButton/base_type = &"Button" ParchmentButton/colors/font_color = Color(0.2901961, 0.24705882, 0.17254902, 1) ParchmentButton/colors/font_disabled_color = Color(0.41568628, 0.3529412, 0.22745098, 1) -ParchmentButton/styles/disabled = SubResource("StyleBoxFlat_jly1r") -ParchmentButton/styles/focus = SubResource("StyleBoxEmpty_lw8lk") -ParchmentButton/styles/hover = SubResource("StyleBoxFlat_nh0e7") -ParchmentButton/styles/normal = SubResource("StyleBoxFlat_dtr07") -ParchmentButton/styles/pressed = SubResource("StyleBoxFlat_nmowx") +ParchmentButton/styles/disabled = SubResource("StyleBoxFlat_lw8lk") +ParchmentButton/styles/focus = SubResource("StyleBoxEmpty_nh0e7") +ParchmentButton/styles/hover = SubResource("StyleBoxFlat_dtr07") +ParchmentButton/styles/normal = SubResource("StyleBoxFlat_nmowx") +ParchmentButton/styles/pressed = SubResource("StyleBoxFlat_oi067") ParchmentCard/base_type = &"PanelContainer" -ParchmentCard/styles/panel = SubResource("StyleBoxFlat_oi067") +ParchmentCard/styles/panel = SubResource("StyleBoxFlat_ukblj") ParchmentInset/base_type = &"PanelContainer" -ParchmentInset/styles/panel = SubResource("StyleBoxFlat_ukblj") +ParchmentInset/styles/panel = SubResource("StyleBoxFlat_utt73") PrimaryCTA/base_type = &"Button" PrimaryCTA/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1) PrimaryCTA/colors/font_disabled_color = Color(0.6039216, 0.56078434, 0.47058824, 1) -PrimaryCTA/styles/disabled = SubResource("StyleBoxFlat_utt73") -PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_5u8fh") -PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_p0qbh") -PrimaryCTA/styles/pressed = SubResource("StyleBoxFlat_6y6ak") +PrimaryCTA/styles/disabled = SubResource("StyleBoxFlat_5u8fh") +PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_p0qbh") +PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_6y6ak") +PrimaryCTA/styles/pressed = SubResource("StyleBoxFlat_txxuc") +PrimaryCard/base_type = &"PanelContainer" +PrimaryCard/styles/panel = SubResource("StyleBoxFlat_7d6ug") +PrimaryTag/base_type = &"Label" +PrimaryTag/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1) +PrimaryTag/font_sizes/font_size = 9 +PrimaryTag/fonts/font = ExtResource("2_4lwr2") +PrimaryTag/styles/normal = SubResource("StyleBoxFlat_najyk") RichTextLabel/colors/default_color = Color(0.2901961, 0.24705882, 0.17254902, 1) RichTextLabel/font_sizes/normal_font_size = 20 RichTextLabel/fonts/italics_font = ExtResource("4_i0b23") RichTextLabel/fonts/normal_font = ExtResource("3_eckwq") +SectionLabel/base_type = &"Label" +SectionLabel/colors/font_color = Color(0.5411765, 0.46666667, 0.28235295, 1) +SectionLabel/font_sizes/font_size = 13 +SectionLabel/fonts/font = ExtResource("2_4lwr2") +SelectCard/base_type = &"Button" +SelectCard/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1) +SelectCard/colors/font_disabled_color = Color(0.41568628, 0.3529412, 0.22745098, 1) +SelectCard/styles/disabled = SubResource("StyleBoxFlat_jtb1k") +SelectCard/styles/focus = SubResource("StyleBoxEmpty_wmbj4") +SelectCard/styles/hover = SubResource("StyleBoxFlat_xdylb") +SelectCard/styles/normal = SubResource("StyleBoxFlat_pjnll") +SelectCard/styles/pressed = SubResource("StyleBoxFlat_klelq") +SkillChip/base_type = &"Button" +SkillChip/colors/font_color = Color(0.3529412, 0.2627451, 0.14901961, 1) +SkillChip/colors/font_disabled_color = Color(0.6627451, 0.5803922, 0.39215687, 1) +SkillChip/colors/font_hover_color = Color(0.3529412, 0.2627451, 0.14901961, 1) +SkillChip/colors/font_pressed_color = Color(0.9411765, 0.9019608, 0.8156863, 1) +SkillChip/font_sizes/font_size = 12 +SkillChip/fonts/font = ExtResource("2_4lwr2") +SkillChip/styles/disabled = SubResource("StyleBoxFlat_i2sl5") +SkillChip/styles/focus = SubResource("StyleBoxEmpty_onsig") +SkillChip/styles/hover = SubResource("StyleBoxFlat_gw0t7") +SkillChip/styles/normal = SubResource("StyleBoxFlat_88l5g") +SkillChip/styles/pressed = SubResource("StyleBoxFlat_f8qac") Tab/base_type = &"Button" Tab/colors/font_color = Color(0.41568628, 0.3529412, 0.22745098, 1) -Tab/styles/focus = SubResource("StyleBoxEmpty_txxuc") -Tab/styles/hover = SubResource("StyleBoxFlat_7d6ug") -Tab/styles/normal = SubResource("StyleBoxFlat_najyk") -Tab/styles/pressed = SubResource("StyleBoxFlat_jtb1k") +Tab/styles/focus = SubResource("StyleBoxEmpty_u6gcs") +Tab/styles/hover = SubResource("StyleBoxFlat_nufy3") +Tab/styles/normal = SubResource("StyleBoxFlat_0d7vs") +Tab/styles/pressed = SubResource("StyleBoxFlat_3fnfk") TabActive/base_type = &"Button" TabActive/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1) -TabActive/styles/focus = SubResource("StyleBoxEmpty_wmbj4") -TabActive/styles/hover = SubResource("StyleBoxFlat_xdylb") -TabActive/styles/normal = SubResource("StyleBoxFlat_pjnll") -TabActive/styles/pressed = SubResource("StyleBoxFlat_klelq") +TabActive/styles/focus = SubResource("StyleBoxEmpty_2txdw") +TabActive/styles/hover = SubResource("StyleBoxFlat_w7qkp") +TabActive/styles/normal = SubResource("StyleBoxFlat_rthnr") +TabActive/styles/pressed = SubResource("StyleBoxFlat_ta3oh") TitleKicker/base_type = &"Label" TitleKicker/colors/font_color = Color(0.6627451, 0.5176471, 0.24705882, 1) TitleKicker/font_sizes/font_size = 13 diff --git a/client/scripts/theme/build_game_theme.gd b/client/scripts/theme/build_game_theme.gd index 3154db1..60e027e 100644 --- a/client/scripts/theme/build_game_theme.gd +++ b/client/scripts/theme/build_game_theme.gd @@ -41,6 +41,10 @@ static func build_theme() -> Theme: _dock_button(theme) _parchment_button(theme) _chip(theme) + _section_label(theme) + _select_card(theme) + _skill_chip(theme) + _tags_and_primary(theme) _tiles(theme) _cards_and_panels(theme) return theme @@ -179,6 +183,71 @@ static func _chip(theme: Theme) -> void: theme.set_color(&"font_color", k, Palette.MUTED_MONO) +static func _section_label(theme: Theme) -> void: + # The mono section headers on the parchment sheet (RACE / CALLING / NAME …). + # Mono's own colour is for dark surfaces; on parchment it needs the muted ink. + theme.set_type_variation(ThemeKeys.SECTION_LABEL, "Label") + theme.set_font(&"font", ThemeKeys.SECTION_LABEL, load(MONO)) + theme.set_font_size(&"font_size", ThemeKeys.SECTION_LABEL, 13) + theme.set_color(&"font_color", ThemeKeys.SECTION_LABEL, Palette.INK_LABEL_MUTED) + + +static func _select_card(theme: Theme) -> void: + # Race + calling cards. Button-base (a PanelContainer variation's lone `panel` + # stylebox does not skin a Button — ADR 0001). `pressed` IS the CHOSEN state: + # the card is a toggle in a ButtonGroup, so `pressed` persists after the click. + var k := ThemeKeys.SELECT_CARD + theme.set_type_variation(k, "Button") + theme.set_stylebox(&"normal", k, _flat(Palette.PARCHMENT_CARD, Palette.PARCHMENT_BORDER, 1, 12)) + theme.set_stylebox(&"hover", k, _flat(Palette.PARCHMENT_INSET, Palette.PARCHMENT_BORDER_3, 1, 12)) + theme.set_stylebox(&"pressed", k, _flat(Palette.PARCHMENT_CARD, Palette.BLOOD, 3, 12)) + theme.set_stylebox(&"disabled", k, _flat(Palette.PARCHMENT_INSET_2, Palette.PARCHMENT_BORDER_3, 1, 12)) + theme.set_stylebox(&"focus", k, StyleBoxEmpty.new()) + theme.set_color(&"font_color", k, Palette.INK_HEADING) + theme.set_color(&"font_disabled_color", k, Palette.INK_MUTED) + + +static func _skill_chip(theme: Theme) -> void: + # A proficiency chip is a TOGGLE with three states the player must be able to + # read at a glance: unpicked (parchment), picked (crimson), and granted-by-race + # (dim + inert — the elf already has perception, so the chip cannot be pressed). + var k := ThemeKeys.SKILL_CHIP + theme.set_type_variation(k, "Button") + theme.set_stylebox(&"normal", k, _flat(Palette.PARCHMENT_CARD, Palette.PARCHMENT_BORDER_3, 1, 14)) + theme.set_stylebox(&"hover", k, _flat(Palette.PARCHMENT_INSET, Palette.INK_LABEL, 1, 14)) + theme.set_stylebox(&"pressed", k, _flat(Palette.BLOOD, Palette.BLOOD, 1, 14)) + theme.set_stylebox(&"disabled", k, _flat(Palette.PARCHMENT_INSET_2, Palette.PARCHMENT_BORDER_3, 1, 14)) + theme.set_stylebox(&"focus", k, StyleBoxEmpty.new()) + theme.set_font(&"font", k, load(MONO)) + theme.set_font_size(&"font_size", k, 12) + theme.set_color(&"font_color", k, Palette.INK_LABEL) + theme.set_color(&"font_pressed_color", k, Palette.CREAM_BRIGHT) + theme.set_color(&"font_hover_color", k, Palette.INK_LABEL) + theme.set_color(&"font_disabled_color", k, Palette.PARCHMENT_BORDER_3) + + +static func _tags_and_primary(theme: Theme) -> void: + # The two little flags that ride on a card's top edge, and the gold ring the + # calling's primary attribute wears. Label takes a `normal` stylebox in Godot 4. + var chosen := ThemeKeys.CHOSEN_TAG + theme.set_type_variation(chosen, "Label") + theme.set_stylebox(&"normal", chosen, _flat(Palette.BLOOD, Palette.BLOOD, 1, 5)) + theme.set_font(&"font", chosen, load(MONO)) + theme.set_font_size(&"font_size", chosen, 10) + theme.set_color(&"font_color", chosen, Palette.CREAM_BRIGHT) + + var primary_tag := ThemeKeys.PRIMARY_TAG + theme.set_type_variation(primary_tag, "Label") + theme.set_stylebox(&"normal", primary_tag, _flat(Palette.INK_GOLD, Palette.INK_GOLD, 1, 5)) + theme.set_font(&"font", primary_tag, load(MONO)) + theme.set_font_size(&"font_size", primary_tag, 9) + theme.set_color(&"font_color", primary_tag, Palette.CREAM_BRIGHT) + + theme.set_type_variation(ThemeKeys.PRIMARY_CARD, "PanelContainer") + theme.set_stylebox(&"panel", ThemeKeys.PRIMARY_CARD, + _flat(Palette.PARCHMENT_CARD, Palette.INK_GOLD, 2, 12)) + + static func _tiles(theme: Theme) -> void: var filled := _flat(Palette.PARCHMENT_INSET, Palette.PARCHMENT_BORDER, 2, 6) theme.set_type_variation(ThemeKeys.ITEM_TILE, "PanelContainer") diff --git a/client/scripts/theme/palette.gd b/client/scripts/theme/palette.gd index 9151289..7475397 100644 --- a/client/scripts/theme/palette.gd +++ b/client/scripts/theme/palette.gd @@ -43,6 +43,7 @@ const INK_BODY := Color("4a3f2c") const INK_MUTED := Color("6a5a3a") const INK_LABEL := Color("5a4326") const INK_GOLD := Color("8f6a2a") +const INK_LABEL_MUTED := Color("8a7748") # section labels on parchment (creation sheet) # --- Text on dark --- const CREAM := Color("e8ddc8") diff --git a/client/scripts/theme/theme_keys.gd b/client/scripts/theme/theme_keys.gd index 6f8578f..f7f6800 100644 --- a/client/scripts/theme/theme_keys.gd +++ b/client/scripts/theme/theme_keys.gd @@ -16,6 +16,11 @@ const ITEM_TILE_EMPTY := &"ItemTileEmpty" const PARCHMENT_CARD := &"ParchmentCard" const PARCHMENT_INSET := &"ParchmentInset" const DARK_PANEL := &"DarkPanel" +const SELECT_CARD := &"SelectCard" # race/calling card: a Button whose `pressed` is CHOSEN +const SKILL_CHIP := &"SkillChip" # toggle: unpicked / picked / granted-so-inert +const CHOSEN_TAG := &"ChosenTag" # the little crimson "CHOSEN" flag +const PRIMARY_TAG := &"PrimaryTag" # the little gold "PRIMARY" flag +const PRIMARY_CARD := &"PrimaryCard" # the ability card ringed as the calling's primary ## Font roles (Label type-variations carrying a font, not a stylebox). Kept ## separate from ALL below, which is stylebox variations + their base type only. @@ -24,6 +29,7 @@ const ACCENT := &"Accent" const MONO := &"Mono" const TITLE_LOGO := &"TitleLogo" const TITLE_KICKER := &"TitleKicker" +const SECTION_LABEL := &"SectionLabel" ## Every variation name + the base Control type it decorates. The builder and the ## test both iterate this so they can never drift apart. @@ -39,4 +45,9 @@ const ALL := { PARCHMENT_CARD: "PanelContainer", PARCHMENT_INSET: "PanelContainer", DARK_PANEL: "PanelContainer", + SELECT_CARD: "Button", + SKILL_CHIP: "Button", + CHOSEN_TAG: "Label", + PRIMARY_TAG: "Label", + PRIMARY_CARD: "PanelContainer", } diff --git a/client/tests/unit/test_theme_resource.gd b/client/tests/unit/test_theme_resource.gd index 212c651..9c0e53f 100644 --- a/client/tests/unit/test_theme_resource.gd +++ b/client/tests/unit/test_theme_resource.gd @@ -48,3 +48,29 @@ func test_committed_tres_matches_builder(): var committed_box: StyleBoxFlat = committed.get_stylebox(state, variation) assert_eq(fresh_box.bg_color, committed_box.bg_color, "stale game_theme.tres — %s/%s bg_color drifted from the builder; re-run build_game_theme.gd" % [variation, state]) + + +func test_creation_variations_exist_with_the_states_the_screen_needs(): + var t: Theme = load(THEME_PATH) + + # A race/calling card is a Button that must show a CHOSEN state. + assert_true(t.has_stylebox(&"normal", ThemeKeys.SELECT_CARD)) + assert_true(t.has_stylebox(&"hover", ThemeKeys.SELECT_CARD)) + assert_true(t.has_stylebox(&"pressed", ThemeKeys.SELECT_CARD), "pressed IS the chosen ring") + assert_true(t.has_stylebox(&"disabled", ThemeKeys.SELECT_CARD), "an origin may disallow a calling") + + # A skill chip is a toggle with three states: unpicked, picked, and granted-so-inert. + assert_true(t.has_stylebox(&"normal", ThemeKeys.SKILL_CHIP)) + assert_true(t.has_stylebox(&"pressed", ThemeKeys.SKILL_CHIP)) + assert_true(t.has_stylebox(&"disabled", ThemeKeys.SKILL_CHIP)) + + # The tags and the primary ring. + assert_true(t.has_stylebox(&"normal", ThemeKeys.CHOSEN_TAG)) + assert_true(t.has_stylebox(&"normal", ThemeKeys.PRIMARY_TAG)) + assert_true(t.has_stylebox(&"panel", ThemeKeys.PRIMARY_CARD)) + + +func test_section_label_is_a_font_role_on_parchment(): + var t: Theme = load(THEME_PATH) + assert_true(t.has_font(&"font", ThemeKeys.SECTION_LABEL)) + assert_eq(t.get_color(&"font_color", ThemeKeys.SECTION_LABEL), Palette.INK_LABEL_MUTED) From fe8e24cf39a7b5870f2e804e0b0909bd466e9b49 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Mon, 13 Jul 2026 14:57:28 -0500 Subject: [PATCH 10/21] fix(tests): make theme drift guard iterate ThemeKeys.ALL, not 3 hardcoded pairs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_committed_tres_matches_builder claimed to catch a builder/artifact drift in game_theme.tres, but its checks array hardcoded exactly three variation/state pairs (PrimaryCTA, ParchmentCard, DarkPanel) checking only bg_color. Every other variation — including all five added for the creation screen (SelectCard, SkillChip, ChosenTag, PrimaryTag, PrimaryCard) — could drift from the builder silently, and this test would keep passing on stale data. Textbook docs/traps.md failure: a test that cannot fail against the bug it names. Rewrote it to derive checks from ThemeKeys.ALL: for every variation, every stylebox state the builder actually set is compared (bg_color, border_color, border widths, corner radii for StyleBoxFlat), and every font colour role the builder actually set is compared too. Failures name the variation and the drifted property. Verified by deliberately drifting SkillChip's pressed stylebox in build_game_theme.gd without regenerating the .tres: the new test failed and named SkillChip/pressed/bg_color+border_color; the old three-pair version would not have caught it (SkillChip was never in its list). Reverted the drift; suite is green at 279 again. No pre-existing drift found in the committed artifact. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/tests/unit/test_theme_resource.gd | 55 ++++++++++++++++++------ 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/client/tests/unit/test_theme_resource.gd b/client/tests/unit/test_theme_resource.gd index 9c0e53f..7b8eedf 100644 --- a/client/tests/unit/test_theme_resource.gd +++ b/client/tests/unit/test_theme_resource.gd @@ -33,21 +33,52 @@ func test_committed_tres_matches_builder(): # Catches "palette/builder changed but nobody regenerated game_theme.tres." # Preloading the builder script and calling its static build_theme() does # NOT run _init() (that only fires on .new()), so this is side-effect-free. + # + # Drives its checks off ThemeKeys.ALL so every variation is guarded, not just + # whichever three someone remembered to hardcode here. For each variation: + # every stylebox state the builder actually set (get_stylebox_list) is + # compared property-by-property when it's a StyleBoxFlat, and every font + # colour role the builder actually set (get_color_list) is compared too. + # Nothing the builder never touches is asserted on. var fresh: Theme = Builder.build_theme() var committed: Theme = load(THEME_PATH) - var checks := [ - [ThemeKeys.PRIMARY_CTA, &"normal"], - [ThemeKeys.PARCHMENT_CARD, &"panel"], - [ThemeKeys.DARK_PANEL, &"panel"], - ] - for pair in checks: - var variation: StringName = pair[0] - var state: StringName = pair[1] - var fresh_box: StyleBoxFlat = fresh.get_stylebox(state, variation) - var committed_box: StyleBoxFlat = committed.get_stylebox(state, variation) - assert_eq(fresh_box.bg_color, committed_box.bg_color, - "stale game_theme.tres — %s/%s bg_color drifted from the builder; re-run build_game_theme.gd" % [variation, state]) + for variation in ThemeKeys.ALL: + for state in fresh.get_stylebox_list(variation): + var fresh_box: StyleBox = fresh.get_stylebox(state, variation) + var committed_box: StyleBox = committed.get_stylebox(state, variation) + assert_eq(committed_box.get_class(), fresh_box.get_class(), + "stale game_theme.tres — %s/%s stylebox type drifted from the builder; re-run build_game_theme.gd" % [variation, state]) + + if fresh_box is StyleBoxFlat: + var f: StyleBoxFlat = fresh_box + var c: StyleBoxFlat = committed_box + assert_eq(c.bg_color, f.bg_color, + "stale game_theme.tres — %s/%s bg_color drifted from the builder; re-run build_game_theme.gd" % [variation, state]) + assert_eq(c.border_color, f.border_color, + "stale game_theme.tres — %s/%s border_color drifted from the builder; re-run build_game_theme.gd" % [variation, state]) + assert_eq(c.border_width_left, f.border_width_left, + "stale game_theme.tres — %s/%s border_width_left drifted from the builder; re-run build_game_theme.gd" % [variation, state]) + assert_eq(c.border_width_top, f.border_width_top, + "stale game_theme.tres — %s/%s border_width_top drifted from the builder; re-run build_game_theme.gd" % [variation, state]) + assert_eq(c.border_width_right, f.border_width_right, + "stale game_theme.tres — %s/%s border_width_right drifted from the builder; re-run build_game_theme.gd" % [variation, state]) + assert_eq(c.border_width_bottom, f.border_width_bottom, + "stale game_theme.tres — %s/%s border_width_bottom drifted from the builder; re-run build_game_theme.gd" % [variation, state]) + assert_eq(c.corner_radius_top_left, f.corner_radius_top_left, + "stale game_theme.tres — %s/%s corner_radius_top_left drifted from the builder; re-run build_game_theme.gd" % [variation, state]) + assert_eq(c.corner_radius_top_right, f.corner_radius_top_right, + "stale game_theme.tres — %s/%s corner_radius_top_right drifted from the builder; re-run build_game_theme.gd" % [variation, state]) + assert_eq(c.corner_radius_bottom_right, f.corner_radius_bottom_right, + "stale game_theme.tres — %s/%s corner_radius_bottom_right drifted from the builder; re-run build_game_theme.gd" % [variation, state]) + assert_eq(c.corner_radius_bottom_left, f.corner_radius_bottom_left, + "stale game_theme.tres — %s/%s corner_radius_bottom_left drifted from the builder; re-run build_game_theme.gd" % [variation, state]) + + for color_name in fresh.get_color_list(variation): + var fresh_color: Color = fresh.get_color(color_name, variation) + var committed_color: Color = committed.get_color(color_name, variation) + assert_eq(committed_color, fresh_color, + "stale game_theme.tres — %s/%s drifted from the builder; re-run build_game_theme.gd" % [variation, color_name]) func test_creation_variations_exist_with_the_states_the_screen_needs(): From 2227d703f60e683124e8b860c185a676f04fc66d Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Mon, 13 Jul 2026 15:03:24 -0500 Subject: [PATCH 11/21] feat(creation): the character creation screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Variant C of the mock: seven nameplate calling cards in one row, with the chosen calling's blurb AND mechanics in a detail panel below — the one card you committed to is the only one that talks. Editor-first (ADR 0001): the tree is in the .tscn, the script binds. No rules here — CreationDraft holds them, CreationCopy derives the display strings, the prose comes from ContentDB. Emits creation_confirmed(Dictionary); M4-c constructs. Five ability cards. Never six (§7). --- client/scenes/creation/CharacterCreation.tscn | 1207 +++++++++++++++++ .../scripts/ui/creation/character_creation.gd | 305 +++++ .../ui/creation/character_creation.gd.uid | 1 + .../unit/test_character_creation_screen.gd | 105 ++ .../test_character_creation_screen.gd.uid | 1 + 5 files changed, 1619 insertions(+) create mode 100644 client/scenes/creation/CharacterCreation.tscn create mode 100644 client/scripts/ui/creation/character_creation.gd create mode 100644 client/scripts/ui/creation/character_creation.gd.uid create mode 100644 client/tests/unit/test_character_creation_screen.gd create mode 100644 client/tests/unit/test_character_creation_screen.gd.uid diff --git a/client/scenes/creation/CharacterCreation.tscn b/client/scenes/creation/CharacterCreation.tscn new file mode 100644 index 0000000..0636818 --- /dev/null +++ b/client/scenes/creation/CharacterCreation.tscn @@ -0,0 +1,1207 @@ +[gd_scene load_steps=5 format=3] + +[ext_resource type="Script" path="res://scripts/ui/creation/character_creation.gd" id="1"] +[ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="2"] +[ext_resource type="PackedScene" path="res://scenes/theme/surfaces/DarkBay.tscn" id="3"] +[ext_resource type="PackedScene" path="res://scenes/theme/surfaces/ParchmentPanel.tscn" id="4"] + +[node name="CharacterCreation" type="Control"] +layout_mode = 3 +anchors_preset = 0 +offset_right = 1920.0 +offset_bottom = 1080.0 +theme = ExtResource("2") +script = ExtResource("1") + +[node name="Split" type="HBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +theme_override_constants/separation = 0 + +[node name="Bay" type="Control" parent="Split"] +custom_minimum_size = Vector2(660, 1080) +layout_mode = 2 + +[node name="Backdrop" parent="Split/Bay" instance=ExtResource("3")] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Col" type="VBoxContainer" parent="Split/Bay"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 34.0 +offset_top = 30.0 +offset_right = -34.0 +offset_bottom = -34.0 +theme_override_constants/separation = 16 + +[node name="Kicker" type="Label" parent="Split/Bay/Col"] +layout_mode = 2 +theme_type_variation = &"Mono" +text = "WHO WILL YOU BE?" + +[node name="ModelSlot" type="PanelContainer" parent="Split/Bay/Col"] +custom_minimum_size = Vector2(0, 540) +layout_mode = 2 +size_flags_vertical = 3 +theme_type_variation = &"ItemTileEmpty" + +[node name="Label" type="Label" parent="Split/Bay/Col/ModelSlot"] +layout_mode = 2 +horizontal_alignment = 1 +vertical_alignment = 1 +theme_type_variation = &"Mono" +text = "CHARACTER +MODEL" + +[node name="Nameplate" type="VBoxContainer" parent="Split/Bay/Col"] +layout_mode = 2 +theme_override_constants/separation = 4 + +[node name="Name" type="Label" parent="Split/Bay/Col/Nameplate"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"Accent" +theme_override_font_sizes/font_size = 34 +text = "Nameless" + +[node name="Sub" type="Label" parent="Split/Bay/Col/Nameplate"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"TitleKicker" +text = "Human · Sellsword" + +[node name="OriginCard" type="PanelContainer" parent="Split/Bay/Col"] +layout_mode = 2 +theme_type_variation = &"ParchmentCard" + +[node name="Box" type="VBoxContainer" parent="Split/Bay/Col/OriginCard"] +layout_mode = 2 +theme_override_constants/separation = 7 + +[node name="Kicker" type="Label" parent="Split/Bay/Col/OriginCard/Box"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "THE DM SIZES YOU UP" + +[node name="Text" type="RichTextLabel" parent="Split/Bay/Col/OriginCard/Box"] +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +bbcode_enabled = true +fit_content = true +text = "[i]The DM has not yet decided what you are.[/i]" + +[node name="Sheet" type="Control" parent="Split"] +custom_minimum_size = Vector2(1260, 1080) +layout_mode = 2 + +[node name="Backdrop" parent="Split/Sheet" instance=ExtResource("4")] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Body" type="VBoxContainer" parent="Split/Sheet"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 44.0 +offset_top = 30.0 +offset_right = -44.0 +offset_bottom = -30.0 +theme_override_constants/separation = 16 + +[node name="Header" type="VBoxContainer" parent="Split/Sheet/Body"] +layout_mode = 2 + +[node name="Title" type="Label" parent="Split/Sheet/Body/Header"] +layout_mode = 2 +theme_type_variation = &"Heading" +text = "Create Your Character" + +[node name="Kicker" type="Label" parent="Split/Sheet/Body/Header"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "THE WORLD HAS REAL PROBLEMS AND DOES NOT CARE ABOUT YOU" + +[node name="RaceSection" type="VBoxContainer" parent="Split/Sheet/Body"] +layout_mode = 2 +theme_override_constants/separation = 9 + +[node name="Label" type="Label" parent="Split/Sheet/Body/RaceSection"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "RACE" + +[node name="Cards" type="HBoxContainer" parent="Split/Sheet/Body/RaceSection"] +layout_mode = 2 +theme_override_constants/separation = 14 + +[node name="Race0" type="Button" parent="Split/Sheet/Body/RaceSection/Cards"] +custom_minimum_size = Vector2(0, 108) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +theme_type_variation = &"SelectCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/RaceSection/Cards/Race0"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 15.0 +offset_top = 13.0 +offset_right = -15.0 +offset_bottom = -13.0 +mouse_filter = 2 +theme_override_constants/separation = 5 + +[node name="Name" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race0/Box"] +layout_mode = 2 +mouse_filter = 2 +text = "Human" + +[node name="Blurb" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race0/Box"] +layout_mode = 2 +size_flags_vertical = 3 +mouse_filter = 2 +autowrap_mode = 3 +theme_override_font_sizes/font_size = 14 +text = "…" + +[node name="Trait" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race0/Box"] +layout_mode = 2 +mouse_filter = 2 +autowrap_mode = 3 +theme_type_variation = &"SectionLabel" +text = "…" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race0"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -78.0 +offset_top = -11.0 +offset_right = -12.0 +offset_bottom = 9.0 +grow_horizontal = 0 +mouse_filter = 2 +theme_type_variation = &"ChosenTag" +text = "CHOSEN" + +[node name="Race1" type="Button" parent="Split/Sheet/Body/RaceSection/Cards"] +custom_minimum_size = Vector2(0, 108) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +theme_type_variation = &"SelectCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/RaceSection/Cards/Race1"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 15.0 +offset_top = 13.0 +offset_right = -15.0 +offset_bottom = -13.0 +mouse_filter = 2 +theme_override_constants/separation = 5 + +[node name="Name" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race1/Box"] +layout_mode = 2 +mouse_filter = 2 +text = "Elf" + +[node name="Blurb" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race1/Box"] +layout_mode = 2 +size_flags_vertical = 3 +mouse_filter = 2 +autowrap_mode = 3 +theme_override_font_sizes/font_size = 14 +text = "…" + +[node name="Trait" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race1/Box"] +layout_mode = 2 +mouse_filter = 2 +autowrap_mode = 3 +theme_type_variation = &"SectionLabel" +text = "…" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race1"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -78.0 +offset_top = -11.0 +offset_right = -12.0 +offset_bottom = 9.0 +grow_horizontal = 0 +mouse_filter = 2 +theme_type_variation = &"ChosenTag" +text = "CHOSEN" + +[node name="Race2" type="Button" parent="Split/Sheet/Body/RaceSection/Cards"] +custom_minimum_size = Vector2(0, 108) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +theme_type_variation = &"SelectCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/RaceSection/Cards/Race2"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 15.0 +offset_top = 13.0 +offset_right = -15.0 +offset_bottom = -13.0 +mouse_filter = 2 +theme_override_constants/separation = 5 + +[node name="Name" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race2/Box"] +layout_mode = 2 +mouse_filter = 2 +text = "Dwarf" + +[node name="Blurb" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race2/Box"] +layout_mode = 2 +size_flags_vertical = 3 +mouse_filter = 2 +autowrap_mode = 3 +theme_override_font_sizes/font_size = 14 +text = "…" + +[node name="Trait" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race2/Box"] +layout_mode = 2 +mouse_filter = 2 +autowrap_mode = 3 +theme_type_variation = &"SectionLabel" +text = "…" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race2"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -78.0 +offset_top = -11.0 +offset_right = -12.0 +offset_bottom = 9.0 +grow_horizontal = 0 +mouse_filter = 2 +theme_type_variation = &"ChosenTag" +text = "CHOSEN" + +[node name="Race3" type="Button" parent="Split/Sheet/Body/RaceSection/Cards"] +custom_minimum_size = Vector2(0, 108) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +theme_type_variation = &"SelectCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/RaceSection/Cards/Race3"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 15.0 +offset_top = 13.0 +offset_right = -15.0 +offset_bottom = -13.0 +mouse_filter = 2 +theme_override_constants/separation = 5 + +[node name="Name" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race3/Box"] +layout_mode = 2 +mouse_filter = 2 +text = "Beastfolk" + +[node name="Blurb" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race3/Box"] +layout_mode = 2 +size_flags_vertical = 3 +mouse_filter = 2 +autowrap_mode = 3 +theme_override_font_sizes/font_size = 14 +text = "…" + +[node name="Trait" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race3/Box"] +layout_mode = 2 +mouse_filter = 2 +autowrap_mode = 3 +theme_type_variation = &"SectionLabel" +text = "…" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race3"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -78.0 +offset_top = -11.0 +offset_right = -12.0 +offset_bottom = 9.0 +grow_horizontal = 0 +mouse_filter = 2 +theme_type_variation = &"ChosenTag" +text = "CHOSEN" + +[node name="CallingSection" type="VBoxContainer" parent="Split/Sheet/Body"] +layout_mode = 2 +theme_override_constants/separation = 9 + +[node name="Label" type="Label" parent="Split/Sheet/Body/CallingSection"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "CALLING" + +[node name="Cards" type="HBoxContainer" parent="Split/Sheet/Body/CallingSection"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Calling0" type="Button" parent="Split/Sheet/Body/CallingSection/Cards"] +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +theme_type_variation = &"SelectCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/CallingSection/Cards/Calling0"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Name" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling0/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_override_font_sizes/font_size = 16 +text = "Sellsword" + +[node name="Chip" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling0/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "STR" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling0"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -33.0 +offset_top = -11.0 +offset_right = 33.0 +offset_bottom = 9.0 +grow_horizontal = 2 +mouse_filter = 2 +theme_type_variation = &"ChosenTag" +text = "CHOSEN" + +[node name="Calling1" type="Button" parent="Split/Sheet/Body/CallingSection/Cards"] +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +theme_type_variation = &"SelectCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/CallingSection/Cards/Calling1"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Name" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling1/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_override_font_sizes/font_size = 16 +text = "Reaver" + +[node name="Chip" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling1/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "STR" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling1"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -33.0 +offset_top = -11.0 +offset_right = 33.0 +offset_bottom = 9.0 +grow_horizontal = 2 +mouse_filter = 2 +theme_type_variation = &"ChosenTag" +text = "CHOSEN" + +[node name="Calling2" type="Button" parent="Split/Sheet/Body/CallingSection/Cards"] +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +theme_type_variation = &"SelectCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/CallingSection/Cards/Calling2"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Name" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling2/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_override_font_sizes/font_size = 16 +text = "Cutpurse" + +[node name="Chip" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling2/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "DEX" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling2"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -33.0 +offset_top = -11.0 +offset_right = 33.0 +offset_bottom = 9.0 +grow_horizontal = 2 +mouse_filter = 2 +theme_type_variation = &"ChosenTag" +text = "CHOSEN" + +[node name="Calling3" type="Button" parent="Split/Sheet/Body/CallingSection/Cards"] +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +theme_type_variation = &"SelectCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/CallingSection/Cards/Calling3"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Name" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling3/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_override_font_sizes/font_size = 16 +text = "Trapper" + +[node name="Chip" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling3/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "DEX" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling3"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -33.0 +offset_top = -11.0 +offset_right = 33.0 +offset_bottom = 9.0 +grow_horizontal = 2 +mouse_filter = 2 +theme_type_variation = &"ChosenTag" +text = "CHOSEN" + +[node name="Calling4" type="Button" parent="Split/Sheet/Body/CallingSection/Cards"] +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +theme_type_variation = &"SelectCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/CallingSection/Cards/Calling4"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Name" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling4/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_override_font_sizes/font_size = 16 +text = "Hedge-Mage" + +[node name="Chip" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling4/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "MAG" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling4"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -33.0 +offset_top = -11.0 +offset_right = 33.0 +offset_bottom = 9.0 +grow_horizontal = 2 +mouse_filter = 2 +theme_type_variation = &"ChosenTag" +text = "CHOSEN" + +[node name="Calling5" type="Button" parent="Split/Sheet/Body/CallingSection/Cards"] +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +theme_type_variation = &"SelectCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/CallingSection/Cards/Calling5"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Name" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling5/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_override_font_sizes/font_size = 16 +text = "Bonesetter" + +[node name="Chip" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling5/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "FTH" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling5"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -33.0 +offset_top = -11.0 +offset_right = 33.0 +offset_bottom = 9.0 +grow_horizontal = 2 +mouse_filter = 2 +theme_type_variation = &"ChosenTag" +text = "CHOSEN" + +[node name="Calling6" type="Button" parent="Split/Sheet/Body/CallingSection/Cards"] +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +theme_type_variation = &"SelectCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/CallingSection/Cards/Calling6"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Name" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling6/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_override_font_sizes/font_size = 16 +text = "Bloodsworn" + +[node name="Chip" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling6/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "FTH" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling6"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -33.0 +offset_top = -11.0 +offset_right = 33.0 +offset_bottom = 9.0 +grow_horizontal = 2 +mouse_filter = 2 +theme_type_variation = &"ChosenTag" +text = "CHOSEN" + +[node name="Detail" type="PanelContainer" parent="Split/Sheet/Body/CallingSection"] +layout_mode = 2 +theme_type_variation = &"ParchmentCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/CallingSection/Detail"] +layout_mode = 2 +theme_override_constants/separation = 7 + +[node name="Blurb" type="RichTextLabel" parent="Split/Sheet/Body/CallingSection/Detail/Box"] +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +bbcode_enabled = true +fit_content = true +text = "[i]…[/i]" + +[node name="Mechanics" type="Label" parent="Split/Sheet/Body/CallingSection/Detail/Box"] +layout_mode = 2 +autowrap_mode = 3 +theme_type_variation = &"SectionLabel" +text = "…" + +[node name="SkillSection" type="VBoxContainer" parent="Split/Sheet/Body"] +layout_mode = 2 +theme_override_constants/separation = 9 + +[node name="Label" type="Label" parent="Split/Sheet/Body/SkillSection"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "PROFICIENCIES" + +[node name="PoolRow" type="HBoxContainer" parent="Split/Sheet/Body/SkillSection"] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="Count" type="Label" parent="Split/Sheet/Body/SkillSection/PoolRow"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "0 of 2 chosen" + +[node name="Pool0" type="Button" parent="Split/Sheet/Body/SkillSection/PoolRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "stealth" + +[node name="Pool1" type="Button" parent="Split/Sheet/Body/SkillSection/PoolRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "sleight of hand" + +[node name="Pool2" type="Button" parent="Split/Sheet/Body/SkillSection/PoolRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "acrobatics" + +[node name="Pool3" type="Button" parent="Split/Sheet/Body/SkillSection/PoolRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "athletics" + +[node name="Pool4" type="Button" parent="Split/Sheet/Body/SkillSection/PoolRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "intimidation" + +[node name="Pool5" type="Button" parent="Split/Sheet/Body/SkillSection/PoolRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "perception" + +[node name="BonusRow" type="HBoxContainer" parent="Split/Sheet/Body/SkillSection"] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="Label" type="Label" parent="Split/Sheet/Body/SkillSection/BonusRow"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "HUMAN — ONE MORE, ANY SKILL" + +[node name="Bonus0" type="Button" parent="Split/Sheet/Body/SkillSection/BonusRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "athletics" + +[node name="Bonus1" type="Button" parent="Split/Sheet/Body/SkillSection/BonusRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "intimidation" + +[node name="Bonus2" type="Button" parent="Split/Sheet/Body/SkillSection/BonusRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "stealth" + +[node name="Bonus3" type="Button" parent="Split/Sheet/Body/SkillSection/BonusRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "sleight of hand" + +[node name="Bonus4" type="Button" parent="Split/Sheet/Body/SkillSection/BonusRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "acrobatics" + +[node name="Bonus5" type="Button" parent="Split/Sheet/Body/SkillSection/BonusRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "endurance" + +[node name="Bonus6" type="Button" parent="Split/Sheet/Body/SkillSection/BonusRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "perception" + +[node name="Bonus7" type="Button" parent="Split/Sheet/Body/SkillSection/BonusRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "faith lore" + +[node name="Bonus8" type="Button" parent="Split/Sheet/Body/SkillSection/BonusRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "sorcery" + +[node name="AbilitySection" type="VBoxContainer" parent="Split/Sheet/Body"] +layout_mode = 2 +theme_override_constants/separation = 9 + +[node name="Head" type="HBoxContainer" parent="Split/Sheet/Body/AbilitySection"] +layout_mode = 2 +theme_override_constants/separation = 14 + +[node name="Label" type="Label" parent="Split/Sheet/Body/AbilitySection/Head"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "ABILITY SCORES" + +[node name="Points" type="Label" parent="Split/Sheet/Body/AbilitySection/Head"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "points left: 3" + +[node name="Reroll" type="Button" parent="Split/Sheet/Body/AbilitySection/Head"] +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "⟳ re-roll" + +[node name="Hint" type="Label" parent="Split/Sheet/Body/AbilitySection/Head"] +layout_mode = 2 +theme_type_variation = &"Accent" +theme_override_font_sizes/font_size = 15 +text = "rolled 3d6 — that roll is your floor, spend up only" + +[node name="Cards" type="HBoxContainer" parent="Split/Sheet/Body/AbilitySection"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Ab0" type="PanelContainer" parent="Split/Sheet/Body/AbilitySection/Cards"] +custom_minimum_size = Vector2(0, 132) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"ParchmentCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0"] +layout_mode = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Key" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "STR" + +[node name="Value" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"Accent" +theme_override_font_sizes/font_size = 36 +text = "10" + +[node name="Rolled" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "rolled 10" + +[node name="Buttons" type="HBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0/Box"] +layout_mode = 2 +alignment = 1 +theme_override_constants/separation = 6 + +[node name="Minus" type="Button" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0/Box/Buttons"] +custom_minimum_size = Vector2(36, 28) +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "−" + +[node name="Plus" type="Button" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0/Box/Buttons"] +custom_minimum_size = Vector2(36, 28) +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "+" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -32.0 +offset_top = -10.0 +offset_right = 32.0 +offset_bottom = 8.0 +grow_horizontal = 2 +mouse_filter = 2 +theme_type_variation = &"PrimaryTag" +text = "PRIMARY" + +[node name="Ab1" type="PanelContainer" parent="Split/Sheet/Body/AbilitySection/Cards"] +custom_minimum_size = Vector2(0, 132) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"ParchmentCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab1"] +layout_mode = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Key" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab1/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "DEX" + +[node name="Value" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab1/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"Accent" +theme_override_font_sizes/font_size = 36 +text = "10" + +[node name="Rolled" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab1/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "rolled 10" + +[node name="Buttons" type="HBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab1/Box"] +layout_mode = 2 +alignment = 1 +theme_override_constants/separation = 6 + +[node name="Minus" type="Button" parent="Split/Sheet/Body/AbilitySection/Cards/Ab1/Box/Buttons"] +custom_minimum_size = Vector2(36, 28) +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "−" + +[node name="Plus" type="Button" parent="Split/Sheet/Body/AbilitySection/Cards/Ab1/Box/Buttons"] +custom_minimum_size = Vector2(36, 28) +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "+" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab1"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -32.0 +offset_top = -10.0 +offset_right = 32.0 +offset_bottom = 8.0 +grow_horizontal = 2 +mouse_filter = 2 +theme_type_variation = &"PrimaryTag" +text = "PRIMARY" + +[node name="Ab2" type="PanelContainer" parent="Split/Sheet/Body/AbilitySection/Cards"] +custom_minimum_size = Vector2(0, 132) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"ParchmentCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab2"] +layout_mode = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Key" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab2/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "CON" + +[node name="Value" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab2/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"Accent" +theme_override_font_sizes/font_size = 36 +text = "10" + +[node name="Rolled" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab2/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "rolled 10" + +[node name="Buttons" type="HBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab2/Box"] +layout_mode = 2 +alignment = 1 +theme_override_constants/separation = 6 + +[node name="Minus" type="Button" parent="Split/Sheet/Body/AbilitySection/Cards/Ab2/Box/Buttons"] +custom_minimum_size = Vector2(36, 28) +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "−" + +[node name="Plus" type="Button" parent="Split/Sheet/Body/AbilitySection/Cards/Ab2/Box/Buttons"] +custom_minimum_size = Vector2(36, 28) +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "+" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab2"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -32.0 +offset_top = -10.0 +offset_right = 32.0 +offset_bottom = 8.0 +grow_horizontal = 2 +mouse_filter = 2 +theme_type_variation = &"PrimaryTag" +text = "PRIMARY" + +[node name="Ab3" type="PanelContainer" parent="Split/Sheet/Body/AbilitySection/Cards"] +custom_minimum_size = Vector2(0, 132) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"ParchmentCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab3"] +layout_mode = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Key" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab3/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "FTH" + +[node name="Value" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab3/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"Accent" +theme_override_font_sizes/font_size = 36 +text = "10" + +[node name="Rolled" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab3/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "rolled 10" + +[node name="Buttons" type="HBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab3/Box"] +layout_mode = 2 +alignment = 1 +theme_override_constants/separation = 6 + +[node name="Minus" type="Button" parent="Split/Sheet/Body/AbilitySection/Cards/Ab3/Box/Buttons"] +custom_minimum_size = Vector2(36, 28) +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "−" + +[node name="Plus" type="Button" parent="Split/Sheet/Body/AbilitySection/Cards/Ab3/Box/Buttons"] +custom_minimum_size = Vector2(36, 28) +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "+" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab3"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -32.0 +offset_top = -10.0 +offset_right = 32.0 +offset_bottom = 8.0 +grow_horizontal = 2 +mouse_filter = 2 +theme_type_variation = &"PrimaryTag" +text = "PRIMARY" + +[node name="Ab4" type="PanelContainer" parent="Split/Sheet/Body/AbilitySection/Cards"] +custom_minimum_size = Vector2(0, 132) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"ParchmentCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab4"] +layout_mode = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Key" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab4/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "MAG" + +[node name="Value" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab4/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"Accent" +theme_override_font_sizes/font_size = 36 +text = "10" + +[node name="Rolled" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab4/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "rolled 10" + +[node name="Buttons" type="HBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab4/Box"] +layout_mode = 2 +alignment = 1 +theme_override_constants/separation = 6 + +[node name="Minus" type="Button" parent="Split/Sheet/Body/AbilitySection/Cards/Ab4/Box/Buttons"] +custom_minimum_size = Vector2(36, 28) +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "−" + +[node name="Plus" type="Button" parent="Split/Sheet/Body/AbilitySection/Cards/Ab4/Box/Buttons"] +custom_minimum_size = Vector2(36, 28) +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "+" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab4"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -32.0 +offset_top = -10.0 +offset_right = 32.0 +offset_bottom = 8.0 +grow_horizontal = 2 +mouse_filter = 2 +theme_type_variation = &"PrimaryTag" +text = "PRIMARY" + +[node name="Footer" type="HBoxContainer" parent="Split/Sheet/Body"] +layout_mode = 2 +size_flags_vertical = 10 +theme_override_constants/separation = 20 + +[node name="NameBox" type="VBoxContainer" parent="Split/Sheet/Body/Footer"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 9 + +[node name="Label" type="Label" parent="Split/Sheet/Body/Footer/NameBox"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "NAME" + +[node name="NameEdit" type="LineEdit" parent="Split/Sheet/Body/Footer/NameBox"] +layout_mode = 2 +placeholder_text = "What do they call you?" + +[node name="CtaBox" type="VBoxContainer" parent="Split/Sheet/Body/Footer"] +layout_mode = 2 +theme_override_constants/separation = 6 + +[node name="Enter" type="Button" parent="Split/Sheet/Body/Footer/CtaBox"] +custom_minimum_size = Vector2(280, 56) +layout_mode = 2 +disabled = true +theme_type_variation = &"PrimaryCTA" +text = "ENTER THE WORLD" + +[node name="Error" type="Label" parent="Split/Sheet/Body/Footer/CtaBox"] +layout_mode = 2 +horizontal_alignment = 1 +autowrap_mode = 3 +theme_type_variation = &"SectionLabel" +text = "no second chances out there" diff --git a/client/scripts/ui/creation/character_creation.gd b/client/scripts/ui/creation/character_creation.gd new file mode 100644 index 0000000..7ba6308 --- /dev/null +++ b/client/scripts/ui/creation/character_creation.gd @@ -0,0 +1,305 @@ +class_name CharacterCreation +extends Control +## The creation screen (mock: Character Creation). Layout is authored in +## CharacterCreation.tscn — open it in the editor to arrange it (ADR 0001). This +## script does on-load work only: @onready refs, signal wiring, and binding the +## draft into AUTHORED nodes. It creates no nodes and it owns no rules. +## +## Every rule lives in CreationDraft; every display string derived from a rules +## table lives in CreationCopy; the prose lives in ContentDB. This file is glue. +## +## §2: the screen never hands an attribute value to anything. It emits a seed and +## four choices, and NewGame.construct re-rolls from that seed itself. +## §7: LCK appears nowhere here. Five ability cards. Never six. + +signal creation_confirmed(creation: Dictionary) + +const ContentDB = preload("res://scripts/content/content_db.gd") + +## Shown under the CTA when the draft is legal. When it is NOT legal, the first +## validation error takes this slot instead — the player is told what is wrong, in +## the same place, in the same dry voice. §13: even the nag is content. +const CTA_HINT := "no second chances out there" + +## Injection seams — set BETWEEN instantiate() and add_child(), as the shell does +## for DmService. _ready builds the real ones when nothing was supplied. +var world: ContentDB = null +var origin: Dictionary = {} + +var draft: CreationDraft + +var _race_cards: Array = [] +var _calling_cards: Array = [] +var _pool_chips: Array = [] +var _bonus_chips: Array = [] +var _ability_cards: Array = [] + +@onready var _plate_name: Label = $Split/Bay/Col/Nameplate/Name +@onready var _plate_sub: Label = $Split/Bay/Col/Nameplate/Sub +@onready var _origin_text: RichTextLabel = $Split/Bay/Col/OriginCard/Box/Text +@onready var _race_row: HBoxContainer = $Split/Sheet/Body/RaceSection/Cards +@onready var _calling_row: HBoxContainer = $Split/Sheet/Body/CallingSection/Cards +@onready var _detail_blurb: RichTextLabel = $Split/Sheet/Body/CallingSection/Detail/Box/Blurb +@onready var _detail_mech: Label = $Split/Sheet/Body/CallingSection/Detail/Box/Mechanics +@onready var _pool_row: HBoxContainer = $Split/Sheet/Body/SkillSection/PoolRow +@onready var _pool_count: Label = $Split/Sheet/Body/SkillSection/PoolRow/Count +@onready var _bonus_row: HBoxContainer = $Split/Sheet/Body/SkillSection/BonusRow +@onready var _ability_row: HBoxContainer = $Split/Sheet/Body/AbilitySection/Cards +@onready var _points: Label = $Split/Sheet/Body/AbilitySection/Head/Points +@onready var _reroll: Button = $Split/Sheet/Body/AbilitySection/Head/Reroll +@onready var _name_edit: LineEdit = $Split/Sheet/Body/Footer/NameBox/NameEdit +@onready var _enter: Button = $Split/Sheet/Body/Footer/CtaBox/Enter +@onready var _error: Label = $Split/Sheet/Body/Footer/CtaBox/Error + + +func _ready() -> void: + # The .tscn root has a fixed size and NO full-rect anchors — a root Control run + # via F6 is not sized by full-rect anchors and collapses to (0,0) (the M3-a bug). + _fit_to_viewport() + get_viewport().size_changed.connect(_fit_to_viewport) + + if world == null: + world = ContentDB.new() + world.load_from(ContentDB.default_content_root()) + if origin.is_empty(): + origin = ContentDB.load_json(ContentDB.origin_path("deserter")) + + _collect_authored_nodes() + _wire() + + draft = CreationDraft.fresh() + draft.set_race(Races.IDS[0]) + draft.set_calling(_allowed_callings()[0]) + _bind() + + +func _fit_to_viewport() -> void: + size = get_viewport_rect().size + + +func _allowed_callings() -> Array: + ## The ORIGIN gates the callings. The screen does not hardcode seven — a later + ## origin may allow fewer, and construct would reject a calling it disallows. + var allowed: Array = origin.get("build_constraints", {}).get("allowed_callings", []) + var out: Array = [] + for id in Callings.IDS: # Callings.IDS fixes the ORDER; the origin fixes the SET + if id in allowed: + out.append(id) + return out + + +func _collect_authored_nodes() -> void: + # Fixed authored nodes, gathered — never created (ADR 0001). Spares hide. + for i in range(4): + _race_cards.append(_race_row.get_node("Race%d" % i)) + for i in range(7): + _calling_cards.append(_calling_row.get_node("Calling%d" % i)) + for i in range(6): + _pool_chips.append(_pool_row.get_node("Pool%d" % i)) + for i in range(9): + _bonus_chips.append(_bonus_row.get_node("Bonus%d" % i)) + for i in range(5): + _ability_cards.append(_ability_row.get_node("Ab%d" % i)) + + +func _wire() -> void: + for i in range(_race_cards.size()): + _race_cards[i].pressed.connect(_on_race_pressed.bind(i)) + for i in range(_calling_cards.size()): + _calling_cards[i].pressed.connect(_on_calling_pressed.bind(i)) + for i in range(_pool_chips.size()): + _pool_chips[i].pressed.connect(_on_pool_pressed.bind(i)) + for i in range(_bonus_chips.size()): + _bonus_chips[i].pressed.connect(_on_bonus_pressed.bind(i)) + for i in range(_ability_cards.size()): + var card: PanelContainer = _ability_cards[i] + card.get_node("Box/Buttons/Minus").pressed.connect(_on_minus_pressed.bind(i)) + card.get_node("Box/Buttons/Plus").pressed.connect(_on_plus_pressed.bind(i)) + _reroll.pressed.connect(_on_reroll_pressed) + _name_edit.text_changed.connect(_on_name_changed) + _enter.pressed.connect(_on_enter_pressed) + + +# ---------------------------------------------------------------- input -> draft + +func _on_race_pressed(i: int) -> void: + draft.set_race(Races.IDS[i]) + _bind() + + +func _on_calling_pressed(i: int) -> void: + var allowed := _allowed_callings() + if i >= allowed.size(): + return + draft.set_calling(allowed[i]) + _bind() + + +func _on_pool_pressed(i: int) -> void: + var pool := draft.skill_pool() + if i < pool.size(): + draft.toggle_skill(pool[i]) + _bind() + + +func _on_bonus_pressed(i: int) -> void: + draft.set_bonus_skill(Skills.IDS[i]) + _bind() + + +func _on_plus_pressed(i: int) -> void: + draft.increment(Attributes.IDS[i]) + _bind() + + +func _on_minus_pressed(i: int) -> void: + draft.decrement(Attributes.IDS[i]) + _bind() + + +func _on_reroll_pressed() -> void: + draft.reroll() + _bind() + + +func _on_name_changed(text: String) -> void: + draft.name = text + _bind() + + +func _on_enter_pressed() -> void: + if not draft.is_complete(origin, world): + return + creation_confirmed.emit(draft.to_creation()) + + +# ---------------------------------------------------------------- draft -> nodes + +func _bind() -> void: + _bind_races() + _bind_callings() + _bind_skills() + _bind_abilities() + _bind_bay() + _bind_cta() + + +func _bind_races() -> void: + for i in range(_race_cards.size()): + var id: String = Races.IDS[i] + var card: Button = _race_cards[i] + card.get_node("Box/Name").text = str(world.race(id).get("name", id.capitalize())) + card.get_node("Box/Blurb").text = str(world.race(id).get("blurb", "")) + card.get_node("Box/Trait").text = CreationCopy.race_trait(id) + card.button_pressed = (id == draft.race_id) + card.get_node("Tag").visible = card.button_pressed + + +func _bind_callings() -> void: + var allowed := _allowed_callings() + for i in range(_calling_cards.size()): + var card: Button = _calling_cards[i] + if i >= allowed.size(): + card.visible = false # a spare authored slot: hide it (ADR 0001) + continue + var id: String = allowed[i] + card.visible = true + card.get_node("Box/Name").text = str(world.calling(id).get("name", id.capitalize())) + card.get_node("Box/Chip").text = Callings.primary(id).to_upper() + card.button_pressed = (id == draft.calling_id) + card.get_node("Tag").visible = card.button_pressed + + _detail_blurb.text = "[i]%s[/i]" % str(world.calling(draft.calling_id).get("blurb", "")) + _detail_mech.text = CreationCopy.calling_detail(draft.calling_id) + + +func _bind_skills() -> void: + var pool := draft.skill_pool() + _pool_count.text = "%d of %d chosen" % [draft.skills.size(), draft.picks_needed()] + for i in range(_pool_chips.size()): + var chip: Button = _pool_chips[i] + if i >= pool.size(): + chip.visible = false + continue + var skill: String = pool[i] + chip.visible = true + chip.text = CreationCopy.skill_label(skill) + chip.button_pressed = draft.is_picked(skill) + # Inert when the race already granted it, or when the picks are spent. + chip.disabled = draft.is_granted(skill) or (not draft.is_picked(skill) and not draft.can_pick(skill)) + + _bonus_row.visible = draft.wants_bonus_skill() + for i in range(_bonus_chips.size()): + var skill: String = Skills.IDS[i] + var chip: Button = _bonus_chips[i] + chip.text = CreationCopy.skill_label(skill) + chip.button_pressed = (draft.bonus_skill == skill) + chip.disabled = not draft.can_take_bonus(skill) and draft.bonus_skill != skill + + +func _bind_abilities() -> void: + var rolled := draft.rolled() + var final := draft.final() + var primary := Callings.primary(draft.calling_id) + _points.text = "points left: %d" % draft.points_left() + + for i in range(_ability_cards.size()): + var stat: String = Attributes.IDS[i] + var card: PanelContainer = _ability_cards[i] + var spent := int(draft.spend.get(stat, 0)) + card.get_node("Box/Key").text = stat.to_upper() + card.get_node("Box/Value").text = str(int(final[stat])) + card.get_node("Box/Rolled").text = "rolled %d%s" % [ + int(rolled[stat]), (" +%d" % spent) if spent > 0 else ""] + card.get_node("Box/Buttons/Plus").disabled = not draft.can_increment(stat) + card.get_node("Box/Buttons/Minus").disabled = not draft.can_decrement(stat) + card.get_node("Tag").visible = (stat == primary) + card.theme_type_variation = ThemeKeys.PRIMARY_CARD if stat == primary else ThemeKeys.PARCHMENT_CARD + + +func _bind_bay() -> void: + var shown_name := draft.name.strip_edges() + _plate_name.text = shown_name if shown_name != "" else "Nameless" + _plate_sub.text = "%s · %s" % [ + str(world.race(draft.race_id).get("name", "")), + str(world.calling(draft.calling_id).get("name", "")), + ] + _origin_text.text = "[i]%s[/i]" % _origin_prose() + + +func _origin_prose() -> String: + ## The DM sizes you up — authored, not an AI call. The DM's real entrance is the + ## shell's opening narration, one screen later, at the first moment where speaking + ## means something (§13: authored text is CONTENT, not error handling). + var race: Dictionary = world.race(draft.race_id) + var calling: Dictionary = world.calling(draft.calling_id) + # A missing fragment degrades to the blurb rather than crashing. The content + # parity test stops that from ever shipping; this stops it from ever exploding. + var race_frag := str(race.get("fragment", race.get("blurb", ""))) + var calling_frag := str(calling.get("fragment", calling.get("blurb", ""))) + return "%s Now you carry a %s's work — %s" % [ + race_frag, str(calling.get("name", "")).to_lower(), calling_frag] + + +func _bind_cta() -> void: + var errors := draft.errors(origin, world) + _enter.disabled = not errors.is_empty() + _error.text = str(errors[0]) if not errors.is_empty() else CTA_HINT + + +# ---------------------------------------------------------------- test seam + +func _complete_a_valid_draft_for_test() -> void: + ## Drives the draft to a legal state the way a player would. Test-only, and it + ## touches nothing the player's own clicks do not. + draft.name = "Aldric" + draft.set_race("human") + draft.set_calling("sellsword") + for skill in draft.skill_pool(): + if draft.can_pick(skill): + draft.toggle_skill(skill) + for skill in Skills.IDS: + if draft.can_take_bonus(skill): + draft.set_bonus_skill(skill) + break + _bind() diff --git a/client/scripts/ui/creation/character_creation.gd.uid b/client/scripts/ui/creation/character_creation.gd.uid new file mode 100644 index 0000000..4c2ff05 --- /dev/null +++ b/client/scripts/ui/creation/character_creation.gd.uid @@ -0,0 +1 @@ +uid://3cxsq82gjlfk diff --git a/client/tests/unit/test_character_creation_screen.gd b/client/tests/unit/test_character_creation_screen.gd new file mode 100644 index 0000000..2304b3d --- /dev/null +++ b/client/tests/unit/test_character_creation_screen.gd @@ -0,0 +1,105 @@ +extends "res://addons/gut/test.gd" + +const SCENE := "res://scenes/creation/CharacterCreation.tscn" +const ContentDB = preload("res://scripts/content/content_db.gd") + + +func _world() -> ContentDB: + var w := ContentDB.new() + w.load_from(ContentDB.default_content_root()) + return w + + +func _screen() -> CharacterCreation: + # Inject origin + world BEFORE _ready (add_child) — instantiate() does not run + # _ready(), add_child does. Same seam the shell uses for DmService. + var node = load(SCENE).instantiate() + node.world = _world() + node.origin = ContentDB.load_json(ContentDB.origin_path("deserter")) + add_child_autofree(node) + return node + + +func test_screen_applies_theme_and_fits_viewport(): + var packed = load(SCENE) + assert_true(packed is PackedScene) + var s := _screen() + assert_true(s is Control) + assert_true(s.theme is Theme, "the screen pulls the shared theme") + assert_gt(s.size.x, 0.0, "viewport-fit sized the root (the M3-a collapse guard)") + + +func test_a_draft_exists_with_a_seed_on_load(): + var s := _screen() + assert_not_null(s.draft) + assert_ne(s.draft.character_seed, 0) + + +func test_five_ability_cards_never_six(): + # §7 — LCK has no card, no row, no tooltip. The player must never be able to + # CALCULATE that he is cursed. + var s := _screen() + assert_eq(s._ability_cards.size(), 5) + for card in s._ability_cards: + assert_false(card.name.to_lower().contains("lck")) + + +func test_no_node_on_the_screen_says_luck(): + var s := _screen() + for node in s.find_children("*", "Label", true, false): + assert_false(node.text.to_lower().contains("luck"), "%s leaks Luck (§7)" % node.name) + assert_false(node.text.to_lower().contains("lck"), "%s leaks Luck (§7)" % node.name) + + +func test_only_the_origin_s_allowed_callings_are_shown(): + var s := _screen() + var shown := 0 + for card in s._calling_cards: + if card.visible: + shown += 1 + assert_eq(shown, s.origin["build_constraints"]["allowed_callings"].size()) + + +func test_cta_is_disabled_until_the_draft_is_legal(): + var s := _screen() + assert_true(s._enter.disabled, "an empty draft cannot enter the world") + assert_ne(s._error.text, CharacterCreation.CTA_HINT, "the screen says WHY, dryly") + assert_ne(s._error.text.strip_edges(), "") + + s._complete_a_valid_draft_for_test() + assert_false(s._enter.disabled) + assert_eq(s._error.text, CharacterCreation.CTA_HINT, + "a legal draft gets the mock's line back, not a blank") + + +func test_pressing_enter_emits_exactly_the_draft_s_creation_dict(): + var s := _screen() + s._complete_a_valid_draft_for_test() + watch_signals(s) + s._on_enter_pressed() + assert_signal_emitted_with_parameters(s, "creation_confirmed", [s.draft.to_creation()]) + + +func test_the_screen_never_constructs_the_character(): + # The flow is M4-c's job, and a saga later synthesizes the same Dictionary and + # skips this scene entirely. The screen PRODUCES the dict; it does not spend it. + var s := _screen() + assert_false(s.has_method("construct")) + + +func test_the_displayed_scores_are_the_draft_s_scores(): + var s := _screen() + s._complete_a_valid_draft_for_test() + for i in range(Attributes.IDS.size()): + var stat: String = Attributes.IDS[i] + var value_label: Label = s._ability_cards[i].get_node("Box/Value") + assert_eq(value_label.text, str(int(s.draft.final()[stat])), + "%s shows what the draft holds" % stat) + + +func test_reroll_changes_what_is_on_screen(): + var s := _screen() + var before := s.draft.character_seed + s._on_reroll_pressed() + assert_ne(s.draft.character_seed, before) + assert_eq(s._points.text, "points left: 3", "a re-roll returns the pool") diff --git a/client/tests/unit/test_character_creation_screen.gd.uid b/client/tests/unit/test_character_creation_screen.gd.uid new file mode 100644 index 0000000..f63d415 --- /dev/null +++ b/client/tests/unit/test_character_creation_screen.gd.uid @@ -0,0 +1 @@ +uid://dmliumgn42tv8 From 6589d7c4ed59170408cceaa2471b9b3339388911 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Mon, 13 Jul 2026 15:19:10 -0500 Subject: [PATCH 12/21] fix(creation-screen): the guards did not guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found three tests in test_character_creation_screen.gd could not fail against the bug they named — including both tests claiming to enforce §7 (Luck is invisible) and the only test covering origin->calling gating. The five-ability-cards test asserted the script's own range(5) loop bound, not the scene. The Luck sweep filtered on `Label` and missed both RichTextLabels (the only nodes rendering authored prose) plus every Button and the LineEdit. The reroll test's pool assertion already held true before the reroll ran. The origin-gating test ran only against the deserter origin, which allows all 7 callings, so shown==allowed was a trivial 7==7. Rewrote all four to assert against the scene, the rules tables, or a restrictive injected origin instead of values the code already guaranteed. Added a node-count guard tying authored cards/chips to Races/Callings/ Skills/Attributes table sizes. Deleted the test-only _complete_a_valid_draft_for_test() from the shipped Control and replaced it with a test-file helper that drives the real click handlers, closing the gap where no test touched _on_race_pressed/_on_calling_pressed/ _on_pool_pressed/_on_bonus_pressed at all. Guarded _ready() against a degenerate origin (empty/missing allowed_callings) indexing an empty array. Each fixed guard was deliberately re-broken and confirmed red before being reverted (see .superpowers/sdd/task-6-report-fix.md, gitignored). 291 tests green, up from 289. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../scripts/ui/creation/character_creation.gd | 25 +--- .../unit/test_character_creation_screen.gd | 124 +++++++++++++++++- 2 files changed, 123 insertions(+), 26 deletions(-) diff --git a/client/scripts/ui/creation/character_creation.gd b/client/scripts/ui/creation/character_creation.gd index 7ba6308..ce717b3 100644 --- a/client/scripts/ui/creation/character_creation.gd +++ b/client/scripts/ui/creation/character_creation.gd @@ -69,7 +69,12 @@ func _ready() -> void: draft = CreationDraft.fresh() draft.set_race(Races.IDS[0]) - draft.set_calling(_allowed_callings()[0]) + var allowed := _allowed_callings() + if not allowed.is_empty(): + draft.set_calling(allowed[0]) + # A degenerate origin (zero allowed callings, or no build_constraints at all) + # leaves calling_id unset rather than crashing — §13, never an error thrown + # at the player. The CTA simply stays disabled; there is no calling to invent. _bind() @@ -285,21 +290,3 @@ func _bind_cta() -> void: var errors := draft.errors(origin, world) _enter.disabled = not errors.is_empty() _error.text = str(errors[0]) if not errors.is_empty() else CTA_HINT - - -# ---------------------------------------------------------------- test seam - -func _complete_a_valid_draft_for_test() -> void: - ## Drives the draft to a legal state the way a player would. Test-only, and it - ## touches nothing the player's own clicks do not. - draft.name = "Aldric" - draft.set_race("human") - draft.set_calling("sellsword") - for skill in draft.skill_pool(): - if draft.can_pick(skill): - draft.toggle_skill(skill) - for skill in Skills.IDS: - if draft.can_take_bonus(skill): - draft.set_bonus_skill(skill) - break - _bind() diff --git a/client/tests/unit/test_character_creation_screen.gd b/client/tests/unit/test_character_creation_screen.gd index 2304b3d..03e9235 100644 --- a/client/tests/unit/test_character_creation_screen.gd +++ b/client/tests/unit/test_character_creation_screen.gd @@ -20,6 +20,39 @@ func _screen() -> CharacterCreation: return node +func _screen_with_origin(origin: Dictionary) -> CharacterCreation: + # Same seam as _screen(), but with an origin the CALLER controls — used to + # prove the calling-gate actually gates, which the real "deserter" origin + # (all 7 callings allowed) cannot do: 7 shown == 7 allowed is trivially true + # whether or not the hide branch in _bind_callings exists at all. + var node = load(SCENE).instantiate() + node.world = _world() + node.origin = origin + add_child_autofree(node) + return node + + +func _complete_a_valid_draft(s: CharacterCreation) -> void: + ## Drives the draft to a legal state through the SAME public handlers a + ## player's clicks call — _on_race_pressed / _on_calling_pressed / + ## _on_pool_pressed / _on_bonus_pressed / _on_name_changed. Lives here, not in + ## the shipped Control: character_creation.gd carries no test-only method. + s._name_edit.text = "Aldric" + s._on_name_changed("Aldric") + + s._on_race_pressed(Races.IDS.find("human")) + s._on_calling_pressed(s._allowed_callings().find("sellsword")) + + var pool: Array = s.draft.skill_pool() + for i in range(pool.size()): + s._on_pool_pressed(i) + + for i in range(Skills.IDS.size()): + if s.draft.can_take_bonus(Skills.IDS[i]): + s._on_bonus_pressed(i) + break + + func test_screen_applies_theme_and_fits_viewport(): var packed = load(SCENE) assert_true(packed is PackedScene) @@ -38,26 +71,97 @@ func test_a_draft_exists_with_a_seed_on_load(): func test_five_ability_cards_never_six(): # §7 — LCK has no card, no row, no tooltip. The player must never be able to # CALCULATE that he is cursed. + # + # _ability_cards is populated by _collect_authored_nodes() looping + # `range(5)` — that loop is 5 by construction and would stay 5 even if the + # .tscn grew a 6th card. Assert against the SCENE's actual children instead. var s := _screen() assert_eq(s._ability_cards.size(), 5) + assert_eq(s._ability_row.get_child_count(), 5, + "five authored ability cards — the scene, not the script's range()") + assert_null(s._ability_row.get_node_or_null("Ab5"), + "there is no sixth ability card, and there never is (§7)") for card in s._ability_cards: assert_false(card.name.to_lower().contains("lck")) func test_no_node_on_the_screen_says_luck(): + # Sweep every Control, not just Label — RichTextLabel (_origin_text, + # _detail_blurb, the only two nodes rendering authored ContentDB prose) + # extends Control, not Label, and a `find_children(..., "Label", ...)` filter + # never looks at it. Buttons (`text`) and the LineEdit (`placeholder_text`) + # are swept too. var s := _screen() - for node in s.find_children("*", "Label", true, false): - assert_false(node.text.to_lower().contains("luck"), "%s leaks Luck (§7)" % node.name) - assert_false(node.text.to_lower().contains("lck"), "%s leaks Luck (§7)" % node.name) + var checked_a_rich_text_label := false + for node in s.find_children("*", "Control", true, false): + if node is RichTextLabel: + checked_a_rich_text_label = true + for prop in ["text", "placeholder_text", "tooltip_text"]: + var value: Variant = node.get(prop) + if typeof(value) != TYPE_STRING: + continue + var lowered: String = value.to_lower() + assert_false(lowered.contains("luck"), "%s.%s leaks Luck (§7)" % [node.name, prop]) + assert_false(lowered.contains("lck"), "%s.%s leaks Luck (§7)" % [node.name, prop]) + assert_true(checked_a_rich_text_label, + "the sweep must actually reach the RichTextLabels or it is checking nothing") func test_only_the_origin_s_allowed_callings_are_shown(): + # The deserter origin allows all 7 callings, so against it "shown == allowed" + # is a trivial 7 == 7 that would stay green even with the hide branch in + # _bind_callings deleted outright. Use a RESTRICTIVE origin, deliberately out + # of Callings.IDS order, so the gate is doing real work. + var allowed := ["bonesetter", "sellsword", "cutpurse"] + var s := _screen_with_origin({"build_constraints": {"allowed_callings": allowed}}) + + var expected_order: Array = [] + for id in Callings.IDS: # Callings.IDS fixes the ORDER + if id in allowed: # the origin fixes the SET + expected_order.append(id) + assert_eq(expected_order.size(), 3) + + var shown := 0 + for i in range(s._calling_cards.size()): + var card: Button = s._calling_cards[i] + if i < expected_order.size(): + assert_true(card.visible, "calling %s must be shown" % expected_order[i]) + var expected_name: String = str( + s.world.calling(expected_order[i]).get("name", expected_order[i].capitalize())) + assert_eq(card.get_node("Box/Name").text, expected_name, + "visible cards must appear in Callings.IDS order, not the origin's list order") + shown += 1 + else: + assert_false(card.visible, + "a calling the origin disallows stays hidden, not deleted") + + assert_eq(shown, 3) + + +func test_the_deserter_origin_shows_all_seven_callings(): var s := _screen() var shown := 0 for card in s._calling_cards: if card.visible: shown += 1 - assert_eq(shown, s.origin["build_constraints"]["allowed_callings"].size()) + assert_eq(shown, 7) + + +func test_authored_node_counts_match_the_rules_tables(): + # _collect_authored_nodes() hardcodes 4/7/6/9/5. A table growing (a 5th race, + # an 8th calling, a 10th skill) must go red HERE, before a binder silently + # under-renders or indexes a table out of range at runtime. + var s := _screen() + assert_eq(s._race_cards.size(), Races.IDS.size()) + assert_eq(s._calling_cards.size(), Callings.IDS.size()) + assert_eq(s._bonus_chips.size(), Skills.IDS.size()) + assert_eq(s._ability_cards.size(), Attributes.IDS.size()) + # The pool row's 6 is the WIDEST calling's skill pool, not a table length — + # assert it covers every calling, so a 7-wide pool added later goes red + # instead of silently dropping a chip. + for id in Callings.IDS: + assert_true(s._pool_chips.size() >= Callings.skill_pool(id).size(), + "the pool row must fit %s's skill pool" % id) func test_cta_is_disabled_until_the_draft_is_legal(): @@ -66,7 +170,7 @@ func test_cta_is_disabled_until_the_draft_is_legal(): assert_ne(s._error.text, CharacterCreation.CTA_HINT, "the screen says WHY, dryly") assert_ne(s._error.text.strip_edges(), "") - s._complete_a_valid_draft_for_test() + _complete_a_valid_draft(s) assert_false(s._enter.disabled) assert_eq(s._error.text, CharacterCreation.CTA_HINT, "a legal draft gets the mock's line back, not a blank") @@ -74,7 +178,7 @@ func test_cta_is_disabled_until_the_draft_is_legal(): func test_pressing_enter_emits_exactly_the_draft_s_creation_dict(): var s := _screen() - s._complete_a_valid_draft_for_test() + _complete_a_valid_draft(s) watch_signals(s) s._on_enter_pressed() assert_signal_emitted_with_parameters(s, "creation_confirmed", [s.draft.to_creation()]) @@ -89,7 +193,7 @@ func test_the_screen_never_constructs_the_character(): func test_the_displayed_scores_are_the_draft_s_scores(): var s := _screen() - s._complete_a_valid_draft_for_test() + _complete_a_valid_draft(s) for i in range(Attributes.IDS.size()): var stat: String = Attributes.IDS[i] var value_label: Label = s._ability_cards[i].get_node("Box/Value") @@ -99,6 +203,12 @@ func test_the_displayed_scores_are_the_draft_s_scores(): func test_reroll_changes_what_is_on_screen(): var s := _screen() + # Spend a point BEFORE the re-roll — a fresh draft's pool already reads + # "points left: 3" with nothing spent, so asserting that value after reroll + # alone proves nothing about whether reroll() actually clears the spend. + s._on_plus_pressed(0) + assert_eq(s._points.text, "points left: 2", "the spend landed before the reroll") + var before := s.draft.character_seed s._on_reroll_pressed() assert_ne(s.draft.character_seed, before) From c5f127e0f1d43920a8e6bca6981428493ee82b55 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Mon, 13 Jul 2026 15:38:31 -0500 Subject: [PATCH 13/21] =?UTF-8?q?test(creation-screen):=20close=203=20vacu?= =?UTF-8?q?ous=20guards=20=E2=80=94=20point-buy=20display,=20signal=20wiri?= =?UTF-8?q?ng,=20Luck=20sweep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial re-review of 6589d7c found three assertions in the same species as the seven it fixed: green against the bug they name. - test_the_displayed_scores_are_the_draft_s_scores spent zero points, so final() == rolled() and a Box/Value bound to `rolled[stat]` instead of `final[stat]` would pass unnoticed. Now presses the Plus node to force a divergence, and checks the Box/Rolled sub-label too (previously unchecked). - No test ever emitted a node's `pressed` signal — every interaction went through the _on_*_pressed handler directly, so _wire() itself, and every card-to-index binding, had zero coverage. Minus had no coverage of any kind. Helper and interaction tests now press the authored nodes; added dedicated press tests for race/calling/pool/bonus/minus, plus the Plus/Minus disabled-boundary bindings. - The Luck sweep only ever checked the default binding (human + sellsword), covering 1 of the 28 race x calling combinations whose authored prose (fragment/blurb) actually renders on screen. Now sweeps all 28. Plus three cheap adds: a degenerate-origin (§13) guard, orphan-authored-card counts for the race/calling/bonus rows, and a non-empty assertion on the origin-card RichTextLabel. Each fix verified by deliberately reintroducing its named bug, confirming the guard test goes red, then reverting — see .superpowers/sdd/task-6-report-fix2.md. 291 -> 298 tests, all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../unit/test_character_creation_screen.gd | 227 +++++++++++++++--- 1 file changed, 199 insertions(+), 28 deletions(-) diff --git a/client/tests/unit/test_character_creation_screen.gd b/client/tests/unit/test_character_creation_screen.gd index 03e9235..55fe060 100644 --- a/client/tests/unit/test_character_creation_screen.gd +++ b/client/tests/unit/test_character_creation_screen.gd @@ -33,23 +33,24 @@ func _screen_with_origin(origin: Dictionary) -> CharacterCreation: func _complete_a_valid_draft(s: CharacterCreation) -> void: - ## Drives the draft to a legal state through the SAME public handlers a - ## player's clicks call — _on_race_pressed / _on_calling_pressed / - ## _on_pool_pressed / _on_bonus_pressed / _on_name_changed. Lives here, not in - ## the shipped Control: character_creation.gd carries no test-only method. + ## Drives the draft to a legal state by pressing the same AUTHORED NODES a + ## player's clicks fire — not by calling the _on_*_pressed handlers directly. + ## Calling the handler proves the handler works; it proves nothing about + ## _wire() actually connecting the node's `pressed` signal to it. Pressing the + ## node is the only path that exercises both at once. s._name_edit.text = "Aldric" - s._on_name_changed("Aldric") + s._on_name_changed("Aldric") # LineEdit.text_changed is not raised by setting .text in a test. - s._on_race_pressed(Races.IDS.find("human")) - s._on_calling_pressed(s._allowed_callings().find("sellsword")) + s._race_cards[Races.IDS.find("human")].pressed.emit() + s._calling_cards[s._allowed_callings().find("sellsword")].pressed.emit() var pool: Array = s.draft.skill_pool() for i in range(pool.size()): - s._on_pool_pressed(i) + s._pool_chips[i].pressed.emit() for i in range(Skills.IDS.size()): if s.draft.can_take_bonus(Skills.IDS[i]): - s._on_bonus_pressed(i) + s._bonus_chips[i].pressed.emit() break @@ -91,20 +92,50 @@ func test_no_node_on_the_screen_says_luck(): # extends Control, not Label, and a `find_children(..., "Label", ...)` filter # never looks at it. Buttons (`text`) and the LineEdit (`placeholder_text`) # are swept too. + # + # The two RichTextLabels render race.fragment / calling.fragment / + # calling.blurb, which DIFFER per race and per calling. Checking only the + # default binding (human + sellsword) means a "luck" in, say, the Reaver's + # blurb or the Beastfolk's fragment would reach the screen and never be seen. + # Rebind across every race x every allowed calling (the deserter origin used + # by _screen() allows all 7, so this is a full 4 x 7 = 28-combination sweep). var s := _screen() var checked_a_rich_text_label := false - for node in s.find_children("*", "Control", true, false): - if node is RichTextLabel: - checked_a_rich_text_label = true - for prop in ["text", "placeholder_text", "tooltip_text"]: - var value: Variant = node.get(prop) - if typeof(value) != TYPE_STRING: - continue - var lowered: String = value.to_lower() - assert_false(lowered.contains("luck"), "%s.%s leaks Luck (§7)" % [node.name, prop]) - assert_false(lowered.contains("lck"), "%s.%s leaks Luck (§7)" % [node.name, prop]) + var combos_checked := 0 + + for race_i in range(Races.IDS.size()): + s._race_cards[race_i].pressed.emit() + var allowed: Array = s._allowed_callings() + for calling_i in range(allowed.size()): + s._calling_cards[calling_i].pressed.emit() + combos_checked += 1 + var combo := "%s + %s" % [Races.IDS[race_i], allowed[calling_i]] + + for node in s.find_children("*", "Control", true, false): + if node is RichTextLabel: + checked_a_rich_text_label = true + for prop in ["text", "placeholder_text", "tooltip_text"]: + var value: Variant = node.get(prop) + if typeof(value) != TYPE_STRING: + continue + var lowered: String = value.to_lower() + assert_false(lowered.contains("luck"), + "%s.%s leaks Luck (§7) at %s" % [node.name, prop, combo]) + assert_false(lowered.contains("lck"), + "%s.%s leaks Luck (§7) at %s" % [node.name, prop, combo]) + + # `checked_a_rich_text_label` alone only proves the sweep REACHED a + # RichTextLabel, not that it had anything in it — an empty label would + # pass the Luck check for a reason that has nothing to do with §7. + assert_false(s._origin_text.text.strip_edges().is_empty(), + "the origin card must actually render prose at %s" % combo) + assert_false(s._detail_blurb.text.strip_edges().is_empty(), + "the calling detail must actually render prose at %s" % combo) + assert_true(checked_a_rich_text_label, "the sweep must actually reach the RichTextLabels or it is checking nothing") + assert_eq(combos_checked, Races.IDS.size() * Callings.IDS.size(), + "every race must be checked against every allowed calling — 4 x 7, not just the default binding") func test_only_the_origin_s_allowed_callings_are_shown(): @@ -147,6 +178,24 @@ func test_the_deserter_origin_shows_all_seven_callings(): assert_eq(shown, 7) +func test_a_degenerate_origin_disables_entry_without_throwing(): + # §13 — a degrade, never a crash. An origin with no build_constraints at all + # (or an empty allowed_callings list) must leave the draft with no calling to + # invent, every calling card hidden, and the CTA disabled — quietly. + # + # NOT {} — CharacterCreation._ready() treats an EMPTY origin Dictionary as + # "none supplied" and substitutes the deserter origin (`origin.is_empty()`), + # which allows all 7 callings and is not degenerate at all. A non-empty + # Dictionary that simply carries no build_constraints is the real degenerate + # case §13's comment in the source describes. + var s := _screen_with_origin({"id": "test-degenerate-origin"}) + assert_eq(s.draft.calling_id, "", + "no allowed callings means no calling to default into") + for card in s._calling_cards: + assert_false(card.visible, "every calling card hides when none are allowed") + assert_true(s._enter.disabled, "a calling-less draft can never be legal") + + func test_authored_node_counts_match_the_rules_tables(): # _collect_authored_nodes() hardcodes 4/7/6/9/5. A table growing (a 5th race, # an 8th calling, a 10th skill) must go red HERE, before a binder silently @@ -163,6 +212,19 @@ func test_authored_node_counts_match_the_rules_tables(): assert_true(s._pool_chips.size() >= Callings.skill_pool(id).size(), "the pool row must fit %s's skill pool" % id) + # The above ties the SCRIPT's arrays to the tables. It says nothing about the + # SCENE — an orphan authored card (a stray "Race4" nobody wired or hid) would + # render, unbound and visible, with every assertion above still green. Tie the + # rows' actual child counts to the same tables. + assert_eq(s._race_row.get_child_count(), Races.IDS.size(), + "an orphan authored race card would sit here, unbound and visible") + assert_eq(s._calling_row.get_child_count(), Callings.IDS.size(), + "an orphan authored calling card would sit here, unbound and visible") + # BonusRow's first child is its own section Label ("HUMAN — ONE MORE, ANY + # SKILL") — not a chip — so its total is one MORE than Skills.IDS.size(). + assert_eq(s._bonus_row.get_child_count(), Skills.IDS.size() + 1, + "an orphan authored bonus chip would sit here, unbound and visible") + func test_cta_is_disabled_until_the_draft_is_legal(): var s := _screen() @@ -180,7 +242,7 @@ func test_pressing_enter_emits_exactly_the_draft_s_creation_dict(): var s := _screen() _complete_a_valid_draft(s) watch_signals(s) - s._on_enter_pressed() + s._enter.pressed.emit() assert_signal_emitted_with_parameters(s, "creation_confirmed", [s.draft.to_creation()]) @@ -194,22 +256,131 @@ func test_the_screen_never_constructs_the_character(): func test_the_displayed_scores_are_the_draft_s_scores(): var s := _screen() _complete_a_valid_draft(s) + + # A fresh, unspent draft has final() == rolled() for EVERY stat, so comparing + # the Value label against final() alone cannot tell a `final`/`rolled` swap in + # the ability binder apart from the real thing. Spend a point — by pressing + # the actual Plus BUTTON, not _on_plus_pressed, so a broken _wire() would also + # be caught here — so final() and rolled() diverge and the assertion below has + # to actually choose one. + var spent_stat: String = Attributes.IDS[0] + s._ability_cards[0].get_node("Box/Buttons/Plus").pressed.emit() + assert_eq(int(s.draft.spend.get(spent_stat, 0)), 1, "the spend actually landed") + + var rolled: Dictionary = s.draft.rolled() + var final: Dictionary = s.draft.final() + assert_eq(int(final[spent_stat]), int(rolled[spent_stat]) + 1, + "spending a point must move final away from rolled, or nothing below distinguishes them") + for i in range(Attributes.IDS.size()): var stat: String = Attributes.IDS[i] - var value_label: Label = s._ability_cards[i].get_node("Box/Value") - assert_eq(value_label.text, str(int(s.draft.final()[stat])), - "%s shows what the draft holds" % stat) + var card = s._ability_cards[i] + var value_label: Label = card.get_node("Box/Value") + var rolled_label: Label = card.get_node("Box/Rolled") + assert_eq(value_label.text, str(int(final[stat])), + "%s's Value must show final(), not rolled()" % stat) + + var spent: int = int(s.draft.spend.get(stat, 0)) + var expected_rolled_text: String = "rolled %d%s" % [ + int(rolled[stat]), (" +%d" % spent) if spent > 0 else ""] + assert_eq(rolled_label.text, expected_rolled_text, + "%s's Rolled sub-label must show the roll and the spend, nothing else covers it" % stat) func test_reroll_changes_what_is_on_screen(): var s := _screen() - # Spend a point BEFORE the re-roll — a fresh draft's pool already reads - # "points left: 3" with nothing spent, so asserting that value after reroll - # alone proves nothing about whether reroll() actually clears the spend. - s._on_plus_pressed(0) + # Spend a point BEFORE the re-roll, by pressing the actual Plus button — a + # fresh draft's pool already reads "points left: 3" with nothing spent, so + # asserting that value after reroll alone proves nothing about whether + # reroll() actually clears the spend. + s._ability_cards[0].get_node("Box/Buttons/Plus").pressed.emit() assert_eq(s._points.text, "points left: 2", "the spend landed before the reroll") var before := s.draft.character_seed - s._on_reroll_pressed() + s._reroll.pressed.emit() assert_ne(s.draft.character_seed, before) assert_eq(s._points.text, "points left: 3", "a re-roll returns the pool") + + +func test_pressing_a_race_card_sets_the_draft_s_race(): + # Nothing in the suite ever emits a card's `pressed` signal — every existing + # test called _on_race_pressed directly. A mis-wired card (bound to the wrong + # index, or not connected at all — see _wire()) would leave every other test + # green. Press the elf card itself. + var s := _screen() + var elf_i: int = Races.IDS.find("elf") + s._race_cards[elf_i].pressed.emit() + assert_eq(s.draft.race_id, "elf", + "the CARD's pressed signal must reach the draft through _wire()") + assert_true(s._race_cards[elf_i].get_node("Tag").visible, "the chosen card shows its tag") + + +func test_pressing_a_calling_card_sets_the_draft_s_calling(): + var s := _screen() + var idx: int = s._allowed_callings().find("cutpurse") + s._calling_cards[idx].pressed.emit() + assert_eq(s.draft.calling_id, "cutpurse", + "the CARD's pressed signal must reach the draft through _wire()") + + +func test_pressing_a_pool_chip_toggles_the_skill(): + var s := _screen() + var pool: Array = s.draft.skill_pool() + var skill: String = pool[0] + s._pool_chips[0].pressed.emit() + assert_true(s.draft.is_picked(skill), + "the CHIP's pressed signal must reach toggle_skill through _wire()") + s._pool_chips[0].pressed.emit() + assert_false(s.draft.is_picked(skill), "pressing it again toggles it back off") + + +func test_pressing_a_bonus_chip_sets_the_bonus_skill(): + var s := _screen() # human is the default race, and wants a bonus skill + var idx := -1 + for i in range(Skills.IDS.size()): + if s.draft.can_take_bonus(Skills.IDS[i]): + idx = i + break + assert_ne(idx, -1, "the default draft (human) must have a takeable bonus skill") + s._bonus_chips[idx].pressed.emit() + assert_eq(s.draft.bonus_skill, Skills.IDS[idx], + "the CHIP's pressed signal must reach set_bonus_skill through _wire()") + + +func test_pressing_the_minus_button_returns_a_spent_point(): + # Nothing else in the suite ever presses Minus — every prior test only ever + # spent points, never gave one back. + var s := _screen() + var stat: String = Attributes.IDS[0] + var card = s._ability_cards[0] + card.get_node("Box/Buttons/Plus").pressed.emit() + assert_eq(int(s.draft.spend.get(stat, 0)), 1) + + card.get_node("Box/Buttons/Minus").pressed.emit() + assert_eq(int(s.draft.spend.get(stat, 0)), 0, + "the MINUS button's pressed signal must reach the draft through _wire()") + assert_eq(s._points.text, "points left: 3") + + +func test_minus_is_disabled_at_the_rolled_floor_plus_at_the_spent_pool(): + var s := _screen() + var card = s._ability_cards[0] + var plus: Button = card.get_node("Box/Buttons/Plus") + var minus: Button = card.get_node("Box/Buttons/Minus") + + assert_true(minus.disabled, "nothing has been spent yet — minus must be inert at the rolled floor") + plus.pressed.emit() + assert_false(minus.disabled, "a spent point makes minus live again") + + # Drain the whole pool (SPEND_POOL == 3) into this one stat. points_left() is + # POOL-wide, not per-card, so every Plus on the screen must go inert together. + # A BOUNDED loop, deliberately — `while points_left() > 0` would hang forever + # if _wire() were ever broken (the press would never reach the draft), turning + # a guard meant to catch that exact bug into a stuck test run instead of a red one. + var pool_size: int = s.draft.points_left() + for i in range(pool_size + 1): + plus.pressed.emit() + assert_eq(s.draft.points_left(), 0) + assert_true(plus.disabled, "the pool is spent — this card's Plus must go inert") + var other_plus: Button = s._ability_cards[1].get_node("Box/Buttons/Plus") + assert_true(other_plus.disabled, "points_left() is pool-wide — every card's Plus disables together") From 4494856a440645aba384593efed8c1c209c0e91b Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Mon, 13 Jul 2026 15:53:47 -0500 Subject: [PATCH 14/21] =?UTF-8?q?test(creation-screen):=20close=20the=20?= =?UTF-8?q?=C2=A77=20sweep's=20own=20emptiness=20guard,=20and=20the=20M3-a?= =?UTF-8?q?=20viewport-fit=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Luck sweep's non-empty-label assertions could not fail: _bind_bay and _bind_callings always wrap prose in BBCode italics ("[i]%s[/i]"), and _origin_prose() always emits the literal connective " Now you carry a %s's work — " even when every authored fragment/blurb is blank. So "[i][/i]".strip_edges().is_empty() reads false regardless of what ContentDB served — the 28-combination sweep was proving the wrapper is non-empty, not that §7's own guard against a blank ContentDB actually holds. Replaced the emptiness checks with assertions that the DM panel and detail panel render the WORLD's own fragment/blurb strings, and that those strings are themselves non-blank, for every race x calling combination. The M3-a collapse guard (`assert_gt(s.size.x, 0.0, ...)`) also could not fail: the .tscn root is authored at exactly 1920x1080, matching the project's viewport size, so the assertion passed on the authored offsets alone with _fit_to_viewport() and its size_changed connection deleted entirely. Replaced it with an actual resize proof: setting Window.content_scale_size (the property this project's canvas_items stretch mode actually keys Control.get_viewport_rect() off of — get_viewport().size does not move it headless) to a non-1920x1080 value and asserting the root followed, then restoring it. Also covers three previously-unasserted binder behaviours: a race-granted skill chip going inert (and a chip going inert once the pool's picks are spent), the bonus-skill row's visibility gate per race, the ability card's primary-stat Tag/theme_type_variation, and _on_enter_pressed's early-out on an illegal draft. Every new/changed guard was proven red against the bug it names (blanked a calling's blurb; hardcoded a placeholder origin string; deleted _fit_to_viewport's wiring; dropped the chip.disabled line; forced the bonus row visible) and reverted. Suite: 298 -> 302, all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../unit/test_character_creation_screen.gd | 134 +++++++++++++++++- 1 file changed, 129 insertions(+), 5 deletions(-) diff --git a/client/tests/unit/test_character_creation_screen.gd b/client/tests/unit/test_character_creation_screen.gd index 55fe060..5eb8aab 100644 --- a/client/tests/unit/test_character_creation_screen.gd +++ b/client/tests/unit/test_character_creation_screen.gd @@ -60,7 +60,25 @@ func test_screen_applies_theme_and_fits_viewport(): var s := _screen() assert_true(s is Control) assert_true(s.theme is Theme, "the screen pulls the shared theme") - assert_gt(s.size.x, 0.0, "viewport-fit sized the root (the M3-a collapse guard)") + + # The .tscn root is authored at exactly 1920x1080 (project.godot's viewport + # size too) — assert_gt(s.size.x, 0.0) passes on the AUTHORED offsets alone, + # even with _fit_to_viewport() and its size_changed connection deleted + # outright. Prove the fit is actually live: change the window's reported + # resolution to something that is NOT 1920x1080 and assert the root followed. + # + # get_viewport().size / get_window().size do not move get_viewport_rect() — + # this project runs with window/stretch/mode="canvas_items", so a Control's + # visible rect tracks content_scale_size, not the raw window pixel size. + # content_scale_size is the property that actually reaches Viewport.size_changed + # here, headless included. + var win := s.get_window() + var original_scale_size: Vector2i = win.content_scale_size + win.content_scale_size = Vector2i(1600, 900) + assert_eq(s.size, Vector2(1600, 900), + "the root must follow a resize through _fit_to_viewport()'s size_changed hook (the M3-a collapse guard)") + win.content_scale_size = original_scale_size + assert_eq(s.size, Vector2(1920, 1080), "restoring the viewport must restore the root, or later tests see a stale size") func test_a_draft_exists_with_a_seed_on_load(): @@ -127,10 +145,32 @@ func test_no_node_on_the_screen_says_luck(): # `checked_a_rich_text_label` alone only proves the sweep REACHED a # RichTextLabel, not that it had anything in it — an empty label would # pass the Luck check for a reason that has nothing to do with §7. - assert_false(s._origin_text.text.strip_edges().is_empty(), - "the origin card must actually render prose at %s" % combo) - assert_false(s._detail_blurb.text.strip_edges().is_empty(), - "the calling detail must actually render prose at %s" % combo) + # + # But _bind_bay() and _bind_callings() both wrap their prose in BBCode + # italics ("[i]%s[/i]"), and _origin_prose() always emits the literal + # connective " Now you carry a %s's work — " even when every fragment is + # blank. So `"[i][/i]".strip_edges().is_empty()` is FALSE — the wrapper + # alone would pass this guard even if ContentDB served nothing at all. + # Assert the labels render the WORLD's own strings, not just non-blankness. + var race_id: String = Races.IDS[race_i] + var calling_id: String = allowed[calling_i] + var race_frag: String = str(s.world.race(race_id).get("fragment", s.world.race(race_id).get("blurb", ""))) + var calling_frag: String = str(s.world.calling(calling_id).get("fragment", s.world.calling(calling_id).get("blurb", ""))) + var calling_blurb: String = str(s.world.calling(calling_id).get("blurb", "")) + + assert_false(race_frag.strip_edges().is_empty(), + "race %s has no fragment (or blurb) to render — the content itself must not be blank" % race_id) + assert_false(calling_frag.strip_edges().is_empty(), + "calling %s has no fragment (or blurb) to render — the content itself must not be blank" % calling_id) + assert_false(calling_blurb.strip_edges().is_empty(), + "calling %s has no blurb to render — the content itself must not be blank" % calling_id) + + assert_true(s._origin_text.text.contains(race_frag), + "the DM panel must render %s's own fragment, not just non-empty BBCode, at %s" % [race_id, combo]) + assert_true(s._origin_text.text.contains(calling_frag), + "the DM panel must render %s's own fragment, not just non-empty BBCode, at %s" % [calling_id, combo]) + assert_true(s._detail_blurb.text.contains(calling_blurb), + "the detail panel must render %s's own blurb, not just non-empty BBCode, at %s" % [calling_id, combo]) assert_true(checked_a_rich_text_label, "the sweep must actually reach the RichTextLabels or it is checking nothing") @@ -384,3 +424,87 @@ func test_minus_is_disabled_at_the_rolled_floor_plus_at_the_spent_pool(): assert_true(plus.disabled, "the pool is spent — this card's Plus must go inert") var other_plus: Button = s._ability_cards[1].get_node("Box/Buttons/Plus") assert_true(other_plus.disabled, "points_left() is pool-wide — every card's Plus disables together") + + +func test_a_race_granted_skill_chip_is_inert_and_so_is_a_chip_once_picks_are_spent(): + # Two real binder behaviours in one chip's line + # (chip.disabled = draft.is_granted(skill) or (not is_picked(skill) and not can_pick(skill))) + # that nothing in the suite observed. Elf grants "perception"; Cutpurse's pool + # has 6 skills (one of them "perception") and needs 4 picks — the only + # race/calling pairing where both halves of that OR are exercised at once. + var s := _screen() + s._race_cards[Races.IDS.find("elf")].pressed.emit() + var cutpurse_i: int = s._allowed_callings().find("cutpurse") + s._calling_cards[cutpurse_i].pressed.emit() + + var pool: Array = s.draft.skill_pool() + var perception_i: int = pool.find("perception") + assert_ne(perception_i, -1, "cutpurse's pool must include perception for this test to mean anything") + assert_true(s._pool_chips[perception_i].disabled, + "a race-granted skill must be inert — the player already has it for free") + + # Spend every pick on the OTHER skills in the pool, leaving exactly one behind. + var picked := 0 + var left_behind_i := -1 + for i in range(pool.size()): + if i == perception_i: + continue + if picked < s.draft.picks_needed(): + s._pool_chips[i].pressed.emit() + picked += 1 + else: + left_behind_i = i + assert_eq(picked, s.draft.picks_needed(), "the pool must actually be spent for this test to mean anything") + assert_ne(left_behind_i, -1, "cutpurse's pool must be wider than its pick count for this test to mean anything") + assert_true(s._pool_chips[left_behind_i].disabled, + "once the picks are spent, the remaining UNPICKED chip must go inert too") + assert_false(s._pool_chips[perception_i].button_pressed, + "the granted chip was never toggled — it was inert from the start") + + +func test_bonus_row_shows_only_for_the_race_that_wants_one(): + # _bonus_row.visible = draft.wants_bonus_skill() — nothing in the suite checks + # this. Human wants a bonus skill; the other three races do not. + var s := _screen() # default race is human + assert_true(s._bonus_row.visible, "human wants a bonus skill choice") + + for race_id in ["elf", "dwarf", "beastfolk"]: + s._race_cards[Races.IDS.find(race_id)].pressed.emit() + assert_false(s._bonus_row.visible, "%s does not want a bonus skill choice" % race_id) + + s._race_cards[Races.IDS.find("human")].pressed.emit() + assert_true(s._bonus_row.visible, "switching back to a bonus-wanting race must show the row again") + + +func test_ability_card_surfaces_the_calling_s_primary_stat(): + # The RACE card's Tag is checked (test_pressing_a_race_card_sets_the_draft_s_race); + # the ability card's equivalent — Tag.visible and theme_type_variation marking + # which of the five stats is the calling's primary — is not. + var s := _screen() + var idx: int = s._allowed_callings().find("cutpurse") + s._calling_cards[idx].pressed.emit() + var primary: String = Callings.primary("cutpurse") + assert_eq(primary, "dex", "cutpurse's primary must be dex for this test to mean anything") + + for i in range(Attributes.IDS.size()): + var stat: String = Attributes.IDS[i] + var card: PanelContainer = s._ability_cards[i] + var is_primary := (stat == primary) + assert_eq(card.get_node("Tag").visible, is_primary, + "%s's Tag must show only when it is the calling's primary stat" % stat) + var expected_variation: StringName = ThemeKeys.PRIMARY_CARD if is_primary else ThemeKeys.PARCHMENT_CARD + assert_eq(card.theme_type_variation, expected_variation, + "%s's card style must reflect whether it is the primary stat" % stat) + + +func test_pressing_enter_on_an_illegal_draft_emits_nothing(): + # _on_enter_pressed's early-out (`if not draft.is_complete(): return`) is the + # one branch in the file nothing else in the suite enters — every other Enter + # test first drives the draft to a legal state. Button.pressed still fires even + # on a disabled Button, so the early-out must be proven explicitly. + var s := _screen() + assert_true(s._enter.disabled, "an empty draft must start illegal, or this test proves nothing") + watch_signals(s) + s._enter.pressed.emit() + assert_signal_not_emitted(s, "creation_confirmed", + "an illegal draft must never reach creation_confirmed, even when Enter fires anyway") From 31759ef84894aeb56bbc5626c36f4cd0d6b6533c Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Mon, 13 Jul 2026 16:00:17 -0500 Subject: [PATCH 15/21] docs(roadmap): M4-b lands; the Title screen was already built MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The creation screen is done — 302 client tests (up from the 250 baseline), content build green. Also flips the M3 Title screen from planned to done: it shipped in a77bf03/f071392 and the roadmap never caught up, which would have misled M4-c into thinking it must build one. Adds six new trap species to docs/traps.md from this milestone's review passes: substring-collision assertions, a BBCode wrapper masking an empty ContentDB, a test asserting the script's own loop bound, a trivially-true gating fixture, assertions already true before the action under test ran, an "iterates everything" guard that checked three hardcoded pairs, a node-type sweep that missed RichTextLabel, tests that called handlers instead of pressing nodes, GUT silently skipping an unparseable class_name test file at the old count, and a test that can hang instead of fail. Also documents the new client-docs convention: clickable cards are Buttons with mouse_filter = 2 on every child, and pressed on a toggle_mode Button is the chosen state. Human F6 confirmation of the rendered screen against the mock is still outstanding and is not claimed here. --- client/docs/README.md | 10 ++- docs/roadmap.md | 4 +- docs/traps.md | 188 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+), 3 deletions(-) diff --git a/client/docs/README.md b/client/docs/README.md index feb472f..6e258ed 100644 --- a/client/docs/README.md +++ b/client/docs/README.md @@ -17,7 +17,15 @@ Cross-cutting design (anything touching both client and api) goes in the root [` Screens are **authored editor-first** — the layout lives in the `.tscn`, not in code. See ADR [0001](../../docs/adr/0001-editor-first-ui-scenes.md) for the why; this is the how. Reference: `scenes/shell/{MainWindowShell,SystemDock,NarrationBook}.tscn` -+ `scripts/ui/shell/*.gd`. ++ `scripts/ui/shell/*.gd`, and `scenes/creation/CharacterCreation.tscn` + +`scripts/ui/creation/{character_creation,creation_draft,creation_copy}.gd` (M4-b). + +- **Clickable cards are Buttons with `mouse_filter = 2` on every child.** A card that + holds a name, a blurb and a trait line is a `Button` (Button-base variation, so + `normal`/`hover`/`pressed` actually skin it) with a `VBoxContainer` of Labels inside. + Every child needs `mouse_filter = 2` (IGNORE) or the Label swallows the click and the + card never toggles. `pressed` on a `toggle_mode` Button *is* the "chosen" state — no + script-side stylebox swapping. - **The `.tscn` owns the tree.** Author every node in the editor: containers, panels, labels, buttons, art-slot placeholders. Assign the shared theme on the diff --git a/docs/roadmap.md b/docs/roadmap.md index dd9df3a..626e5f5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -71,13 +71,13 @@ Each screen is recreated as a Godot 4.7 Control-node scene against the mockups ( ### M3 — Visual foundation & shell ✅ - ✅ **Shared `Theme`** — palette tokens, the three font families, and reusable styleboxes (parchment card, dark panel, primary CTA, tab, item tile, tag/chip) from the mock README as a Godot `Theme` resource. Every screen pulls from it; build it first so the look is one system. Merged to `dev`. *§2: n/a (presentation) · depends on nothing · goal: one consistent visual identity.* - ✅ **Main Window shell (2a)** — the exploration HUD: isometric world-view slot + the permanent DM "narration book" (wired to the proven `/dm/narrate` loop + considering-state), the turn-order rail, minimap slot, the slide-in system dock (Inventory/Character/Quest/Map/Party/Spellbook toggles), and the bottom command bar (HP/MP/gold, consumables, End Turn). The frame everything else lives in. **Established the editor-first UI-scene convention** (ADR [0001](adr/0001-editor-first-ui-scenes.md); how-to in `client/docs/README.md`) and added Button-base `DockButton`/`ParchmentButton` + a base `RichTextLabel` prose style to the theme. *§2: state (client owns the HUD, consumes DM text) · depends on the DM loop (M2) · goal: the game's main screen, on screen.* -- ○ **Title screen** — new game / continue / load / settings; the entry point. Author it editor-first (ADR 0001). **⛨ saga:** a fourth entry path (*begin the next campaign of an existing saga*) lands later — don't build it, just don't hardcode the flow as `new game → creation → world` so it can't be added. *§2: n/a · depends on the Theme · goal: first impression, into the world.* +- ✅ **Title screen** — new game / continue / load / settings; the entry point. Author it editor-first (ADR 0001). Built and committed (`a77bf03`, `f071392`); this entry was stale — the roadmap had not caught up and M4-c would have been misled into thinking it still had to build one. **⛨ saga:** a fourth entry path (*begin the next campaign of an existing saga*) lands later — don't build it, just don't hardcode the flow as `new game → creation → world` so it can't be added. *§2: n/a · depends on the Theme · goal: first impression, into the world.* ### M4 — Character creation *Content it consumes → [BOM](../content/roadmap.md#2-bill-of-materials): calling blurbs + the Bloodsworn's patron choice (fixed); origins (variable). **~4 pieces.*** - ✅ **Creation model (M4-a)** — the rules a character is made of, no UI ([spec](superpowers/specs/2026-07-12-creation-model-design.md)). Race/calling/skill **mechanics** are static tables in code (`client/scripts/rules/`); their **blurbs** are hand-written content (`content/world/{callings,races}/`, loaded by `ContentDB`, parity-tested), so the content BOM does not block the engine. `CharacterSheet` **stores only what was rolled or chosen** (attributes, race, calling, skills, current hp/mp) and **derives** the rest (`max_hp`, `ac`, `save`, `skill_bonus`, `spell_dc`) — so no derived number can drift from its inputs, and M5's level curve is a function change rather than a stored-field migration. **`NewGame.construct` carries 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) — pinned by a golden-vector test. The roll is **3d6 with a hard floor of 8** (straight 3d6 puts ~9% on a primary the +3 pool cannot rescue; a character bad at the one thing he is *for* is the "fine" §7 forbids). **LCK is unspendable, not merely hidden.** Open seams marked honestly: `ac()` is `10 + DEX` until armor exists (M7), `max_mp()` is a `TUNABLE` placeholder (M5 owns the curve), L1 talents are id stubs (M5). **Live-proven end-to-end** (2026-07-12): a beastfolk cutpurse built by the pipeline → canon log → schema validation → `Player: Vexcca, a beastfolk cutpurse` in the digest (no numeric Luck, no stats, no snake_case id) → real narration from qwen3.5. 250 client tests, 76 api (live layer included). *§2: state · goal: the character exists in code.* - ✅ **Contract migration** — the two contract migrations this milestone owed have **landed**: the canon log's `player` block is now `{name, race_id, calling_id, luck_descriptor}` (the Narrator/NPCs describe the player as e.g. "a beastfolk cutpurse"), and an origin's build-constraints key is now `allowed_callings` (`content/origins/deserter.json` lists all seven real calling ids; the pre-migration key and its two dead class ids are gone from the codebase). Schemas, docs, fixtures, and the world-building skill's content template all agree. -- ○ **Character creation UI (M4-b)** — the mock's screen over our proven model: race + calling cards, six stats with **LCK hidden entirely** (§7), 3d6 floors, the **+3 additive-only spend-up** pool (§8, matches the `character-creation-stats` model), live origin blurb + nameplate. **The class-name reconcile is settled** — all seven callings are named (Sellsword · **Reaver** · Cutpurse · Trapper · Hedge-Mage · Bonesetter · **Bloodsworn**; [races/classes spec](superpowers/specs/2026-07-11-races-and-classes-design.md) §4). Feeds new-game canon-log construction (already built, and already emits `race_id` + `calling_id` per the migration above). **⛨ saga:** `NewGame.construct` takes `creation` as a plain `Dictionary` — keep it that way. The creation *scene* produces that dict but must not become the only thing that can (a saga synthesizes it and skips the UI). *§2: state · depends on the creation model (M0) + Theme · goal: build a character.* +- ✅ **Character creation UI (M4-b)** — the mock's screen over M4-a's model: four race cards and seven nameplate calling cards (blurb + mechanics shown only for the *chosen* calling), the **+3 additive spend pool** with `−` flooring at the rolled base, proficiency chips gated by the chosen calling's pool (plus the Human bonus-skill row), five ability cards — **never six, and the word "luck" appears nowhere on the screen** (§7) — and the DM origin panel composing race + calling fragments. Five mock/model reconciles settled in the spec's §4 (skill picking added, seven callings not five, blurb-on-chosen-only, `+`/`−` both present, the portrait `‹ ›` arrows cut for lacking art). **Architecture:** a pure, node-free `CreationDraft` (`RefCounted`) holds every rule — changing calling clears stale skill picks, changing race drops a now-granted pick, re-roll clears the spend and only the spend, `errors()` delegates to `NewGame.validate` rather than keeping a second copy of the rules; `CreationCopy` is a pure static formatter that derives every display string (hit die, armour word, saves, skill count) from the `Races`/`Callings`/`Skills` tables, so retuning a calling retunes its card without touching a string literal; the `.tscn` owns the layout per ADR 0001 and `character_creation.gd` only binds. **The §2 property holds without the screen having to be trustworthy:** the screen shows its five numbers by calling the same newly-public `NewGame.roll_attributes(seed)` that `construct` calls internally — the screen never hands a number to anything, only a seed and four choices; `construct` rolls the identical attributes again, itself, off the same seed. Re-roll is unlimited (the floor of 8 already prevents a dead primary), and every re-roll silently re-rolls the hidden LCK draw riding the same RNG stream — the screen says nothing about this, ever. Emits `creation_confirmed(creation: Dictionary)`; does not call `construct` itself, so a saga can synthesize the same Dictionary later and skip the scene entirely. **Test count: 250 → 302.** **The honest seams:** three rounds of adversarial review found ten guard-shaped assertions across this milestone that could not fail against the bug they named (substring collisions, a BBCode wrapper masking an empty ContentDB, a test asserting the script's own loop bound, a trivial origin fixture, assertions already true before the action under test ran, a theme-drift guard checking three hardcoded variations instead of all of them, a `Label` sweep that missed `RichTextLabel`, and tests that called handlers directly instead of pressing nodes) — all rewritten against the actual artifact and reverted-and-reconfirmed-red per `docs/traps.md`'s standard; the new species are folded into that file. *§2: state · depends on the creation model (M0) + Theme · goal: build a character.* - ○ **Title → creation → shell flow (M4-c)** — wire the title screen's *new game* path into character creation and on into the M3 shell, without hardcoding a `new game → creation → world` linear flow (the saga's fourth entry path, §M3, must still be addable later). *§2: n/a (flow) · depends on M4-b + the Title screen (M3) · goal: a playable start-to-shell loop.* ### M5 — Tactical combat (gridless) diff --git a/docs/traps.md b/docs/traps.md index 9948052..c71b09f 100644 --- a/docs/traps.md +++ b/docs/traps.md @@ -119,6 +119,178 @@ nothing changed that you did not intend. --- +## 7. An assertion can pass because one string happens to sit inside another + +**M4-b.** `assert_string_contains(line, Callings.armor(id))` checked that a calling's +detail line mentioned its armour — using the **raw** table value (`"light"`, `"none"`), +not the word the card actually prints (`"light armour"`, `"no armour"`). For +light/medium/heavy this passed **by accident**: `"light"` is a substring of `"light +armour"`. It broke only on the Hedge-Mage, the one calling with `armor: "none"`, because +`"none"` is not a substring of `"no armour"`. Delete the Hedge-Mage and the bug goes +dormant and silent — nothing else in the roster would ever expose it. + +The same species, same milestone: `assert_string_contains(line, str(Callings.skill_count(id)))` +could not fail for the Reaver — its skill count is 2, and `"2"` is already sitting inside +`"d12"` (its hit die). Hardcoding the wrong skill count in the card would still pass, satisfied +by a digit in an unrelated number. + +**The guard:** anchor the assertion to the **full derived phrase** the code actually +produces (`CreationCopy.armor_word(id)`, `"picks %d skills" % Callings.skill_count(id)`), +never to a bare fragment that might coincidentally be present for the wrong reason. If an +assertion would still pass with the code deleted and a different, unrelated number +substituted, it is checking overlap, not correctness. + +--- + +## 8. A wrapper can hide the void it was supposed to catch + +**M4-b.** A §7 sweep asserted every race×calling combination's DM-panel prose was +non-empty — the guard meant to catch a blank `ContentDB` leaking onto the one screen that +must never show a raw error. But the binder wraps prose in BBCode (`"[i]%s[/i]"`) and the +origin panel always emits its own literal connective (`" Now you carry a %s's work — "`) +even when every authored fragment and blurb is blank. `"[i][/i]".strip_edges().is_empty()` +reads `false` regardless of what content actually rendered. The 28-combination sweep was +proving the *wrapper* is non-empty, not that the guard against a blank `ContentDB` held. + +**The guard:** when a value is always wrapped in a fixed template before display, assert +that the render **contains the payload's own string** (and that the payload's own string +is itself non-blank) — never just that the wrapped result is non-empty. A non-empty +assertion downstream of a non-empty literal proves nothing. + +--- + +## 9. A test that asserts the script's own loop bound, not the artifact + +**M4-b.** `test_five_ability_cards_never_six` asserted `_ability_cards.size() == 5` — but +that array is filled by the binder's own `for i in range(5)`, so it is exactly 5 **by +construction**, independent of what the scene actually contains. An `Ab5` node could be +added to the `.tscn` and this test stayed green while a sixth ability card — the exact +regression §7 exists to prevent — rendered on screen. + +**The guard:** interrogate the artifact, not the code that reads it. +`get_node_or_null("Ab5")` must be null, or `get_child_count()` on the authored container +must equal the expected count. A test whose only source of truth is a literal in the same +file it is meant to be guarding is not a test of that file. + +--- + +## 10. A guard whose only fixture makes it trivially true + +**M4-b.** `test_only_the_origin_s_allowed_callings_are_shown` checked that shown calling +cards equal the origin's `allowed_callings`. The only origin in the game (the deserter) +allows all seven callings, and cards default to visible — so the assertion was `7 == 7` +no matter what the hiding logic did. **Deleting the entire hide branch left the test +green.** + +**The guard:** a gating rule needs a fixture that actually gates. Inject a restrictive +origin (the injection seam for `origin`/`ContentDB` already existed for exactly this) that +allows a strict subset, and assert the excluded cards are absent. A test with only the +permissive case in play is not testing the restrictive path at all. + +--- + +## 11. An assertion that was already true before the action under test ran + +**M4-b.** A re-roll test asserted `"points left: 3"` **after** calling re-roll — but a +freshly-constructed draft already reads 3 points left. Removing `spend = {}` from +`reroll()` entirely left the test green, because the assertion never depended on the +reset actually happening. + +The same species bit a displayed-scores test harder: it asserted the ability cards show +`draft.final()` values, but spent zero points before checking — so `final() == rolled()` +and a binder bug that read `rolled` instead of `final` (arguably the single most obvious +possible bug in a point-buy panel) passed clean. + +**The guard:** drive the state away from its default before asserting the reset or the +derivation. Spend a point, *then* re-roll and check the pool refilled. Spend a point, +*then* check the card shows the spent value, not the rolled one. If the assertion would +already hold on a brand-new object with no action taken, the test has not exercised +anything. + +--- + +## 12. A "covers everything" claim that covers three things + +**M4-b.** `test_committed_tres_matches_builder` is the drift guard between the theme +builder and the committed `.tres` artifact — the thing that stops "changed the palette, +forgot to regenerate" from shipping silently. The plan described it as iterating +`ThemeKeys.ALL`. It did not: it checked a hardcoded list of exactly three +variation/state pairs, so every variation added since — including all five this +milestone added for the creation screen — could drift from its generator with the test +still green. + +**The guard:** when a guard is described as covering "every X," check that it actually +enumerates `X.ALL` (or equivalent) rather than a literal list someone wrote down once. And +make the failure message name the specific variation and property that drifted — a guard +that only says "theme mismatch" does not tell the next person which of forty checks +failed. + +--- + +## 13. A node-type sweep that misses the types that matter most + +**M4-b.** The §7 Luck-invisibility guard swept `find_children("*", "Label", ...)` across +the creation screen looking for the word "luck." `RichTextLabel` does **not** extend +`Label` in Godot's class hierarchy — and the two `RichTextLabel` nodes on the screen were +the only nodes rendering authored prose (the DM origin panel and the calling detail +panel), i.e. the single likeliest place for a stray "luck" to leak onto the screen. The +sweep also missed every `Button` (whose text is a property, not a child Label) and +`LineEdit.placeholder_text`. + +**The guard:** a sweep for "does this string appear anywhere on screen" must walk +`Control` and check every text-bearing property that type can hold (`text`, `.text` on +buttons, `placeholder_text`, `bbcode_text`), not one Label subclass. Naming the node type +you filtered on is not the same as covering the node types that carry the content you care +about. + +--- + +## 14. A test that calls the handler instead of pressing the button + +**M4-b.** Every interaction test for the creation screen invoked `_on_race_pressed(i)` / +`_on_calling_pressed(i)` / etc. directly. **Commenting out `_wire()` entirely — the method +that connects every button's `pressed` signal to its handler — left the whole suite +green**, because no test ever went through the signal. A mis-bound `bind(i)` (wrong card +wired to the wrong index) would have been equally invisible. + +**The guard:** drive the interaction through the actual node — +`button.pressed.emit()` (or, for a real click, `button.pressing`/`gui_input`) — not the +handler function. This covers the wiring *and* the index binding for free, and it is the +only way a test can tell you a button that looks correct in the editor is inert at +runtime. + +--- + +## 15. GUT can skip a test file with a warning, not a failure + +**M4-b.** A new `class_name`-declaring script (`CreationDraft`, `CreationCopy`) needs +Godot's `.godot/` import cache rebuilt before GUT can resolve the global class name from a +sibling test file. Until that happens, the test file fails to parse and GUT **silently +skips it with a `WARNING`**, not a failure — and the run still prints `All tests passed!`, +at the **old** test count. A TDD RED phase against a brand-new global class can therefore +be **fake**: you believe you watched the new test fail, but it never ran at all. + +**The guard:** check the test **count**, every time, not the green banner text. If a +change was supposed to add N tests and the total didn't move, the suite lied by omission. +`rm -rf .godot && ./run_tests.sh` forces the cache rebuild if a new file seems to be +missing. + +--- + +## 16. A test can hang instead of fail + +**M4-b.** The natural way to write "drain the point-buy pool" in a test is +`while draft.points_left() > 0: plus.pressed.emit()`. Run that against a broken `_wire()` +(trap 14) and `points_left()` never changes — the loop **never terminates**, and the test +runner hangs instead of reporting a failure. + +**The guard:** bounded `for` loops only, with an explicit iteration cap well above the +expected count, and an assertion after the loop that the expected end-state was actually +reached. A test that can hang is worse than a test that can silently pass — it costs a +human a wall-clock timeout to even learn something is wrong. + +--- + ## The checklist this all reduces to - Can the new test **fail**? Re-break the code and watch it. If it can't fail, it isn't a test. @@ -127,3 +299,19 @@ nothing changed that you did not intend. - Anything the **model can emit**? Ask what happens if it emits it *twice*. - Any fact written down **twice**? Guard every copy — including docs and tooling. - Touched **content**? Check the generated tree and the build. +- Does an assertion string appear **only as a fragment** of another string? Anchor to the + full derived phrase. +- Is the value under test **always wrapped** in a fixed template first? Assert on the + payload, not the wrapper. +- Could the assertion be reading a bound the **test's own setup** guarantees, rather than + the artifact under test? +- Does the fixture make the guard's condition **trivially true** regardless of the logic + it claims to check? +- Would the assertion **already hold before the action under test runs**? Drive state away + from its default first. +- Does "covers everything" actually **iterate the full set**, or a hardcoded sample of it? +- Does a node-type sweep account for **every type** that can carry the content, not just + the common one? +- Does the test **press the node** (emit the real signal), or call the handler directly? +- Did the test **count** move by the expected amount — not just the green banner? +- Can the test **hang** on a broken precondition? Bound every loop. From 34bf064904f407f8cad02aba3b74c63c2e78ca90 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Mon, 13 Jul 2026 16:25:12 -0500 Subject: [PATCH 16/21] fix(creation-screen): the bonus-skill hole, the theme guard that could not fail, and the strings the player was reading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final whole-branch review of M4-b. Six findings. 1. The player could press his way into an invalid draft. can_take_bonus() refused an already-picked skill; can_pick() did not consider the bonus, so the guard was one-directional. Take athletics as the human's bonus, pick it again from the Sellsword's pool, and the draft is one NewGame.validate rejects — the invalid draft "he did not cause and cannot see" that set_calling's own comment forbids. Fixed in the DRAFT (the screen owns no rules): can_pick() now mirrors can_take_bonus(), so the chip goes inert, the idiom the pool row already uses for a race-granted skill. Recoverable: hand the bonus back and the chip is live again. Both directions tested. 2. The theme drift guard STILL could not fail. ThemeKeys.ALL is stylebox variations only, by its own docstring — so the six FONT roles the builder sets fonts, sizes and colours for were entirely unguarded. Changing Palette.CREAM and skipping the rebuild shipped a stale game_theme.tres with the suite green (proven, on the committed test). Adds ThemeKeys.FONT_ROLES beside ALL (never inside it — ALL's contract is relied on), compares fonts, font sizes and font colours as well as styleboxes, and names the variation AND the property that drifted. Plus a meta-guard that walks the builder's own output and fails if a variation it styles is in neither collection — this is the second time this guard has been fixed by "enumerate the right set", and nothing was checking the set. 3. §13: the CTA label piped NewGame.validate's diagnostics straight to the player. On the game's SECOND SCREEN he read "hedge_mage picks 2 skills, got 0". Now CreationCopy.error_line maps them to authored copy — "name yourself", "choose two more proficiencies" (the number DERIVED from Callings.skill_count, never written down), "one more proficiency, any of them — take it" — with an authored fallback for anything unmapped, because §13 means the unmapped case still speaks in voice. validate's strings are untouched: they are a contract, so they are translated in the presentation layer, not rewritten. 4. §2 had no test that fails if the boundary is breached. The emit test compared the signal to draft.to_creation() — both sides move together. Pins the seven contract keys exactly, and feeds construct a hostile creation dict carrying an 18 in every stat, asserting the sheet is still roll_attributes(seed) + spend. Every other test in test_new_game passed with that breach in place. 5. Three holes: the orphan sweep skipped PoolRow (a stray Pool6 rendered unbound and visible, suite green — the exact bug the test exists to catch, in the one row it forgot); the §7 sweep forbade "luck" but not Luck.BANDS' descriptors, which are worse than the number (the player would re-roll until his Luck read well — calculable Luck); and the DM panel's composition hardcodes the article "a", so a vowel-initial calling would break it silently — guarded in the content parity test, which names the template as the reason. 6. The calling card printed "talent second_wind". Four of seven talents carry an underscore. Humanised, the way skill_label() already does. The test that pinned the RAW form is moved to the full derived phrase ("talent second wind") so it still fails if someone hardcodes a talent into the format string. Every guard was proven by re-breaking the code and watching the named test go red (docs/traps.md). Suite 302 -> 315, content build green, no new warnings. Report: .superpowers/sdd/final-review-fix-report.md Co-Authored-By: Claude Opus 4.8 (1M context) --- client/scripts/theme/theme_keys.gd | 16 +++ .../scripts/ui/creation/character_creation.gd | 7 +- client/scripts/ui/creation/creation_copy.gd | 75 +++++++++- client/scripts/ui/creation/creation_draft.gd | 8 ++ .../unit/test_character_creation_screen.gd | 111 ++++++++++++++- client/tests/unit/test_content_db.gd | 19 +++ client/tests/unit/test_creation_copy.gd | 129 +++++++++++++++++- client/tests/unit/test_creation_draft.gd | 59 ++++++++ client/tests/unit/test_new_game.gd | 27 ++++ client/tests/unit/test_theme_resource.gd | 69 +++++++++- 10 files changed, 506 insertions(+), 14 deletions(-) diff --git a/client/scripts/theme/theme_keys.gd b/client/scripts/theme/theme_keys.gd index f7f6800..552df23 100644 --- a/client/scripts/theme/theme_keys.gd +++ b/client/scripts/theme/theme_keys.gd @@ -31,6 +31,22 @@ const TITLE_LOGO := &"TitleLogo" const TITLE_KICKER := &"TitleKicker" const SECTION_LABEL := &"SectionLabel" +## Every FONT role + the base Control type it decorates. Deliberately NOT folded +## into ALL — ALL's "stylebox variations only" contract is relied on elsewhere +## (test_every_variation_resolves, and the styleboxes the screens set). Kept as a +## second enumerable set so a guard that means "every variation the builder +## touches" can iterate BOTH and never silently cover only half of them: the +## drift guard used to iterate ALL alone, so a font, a font size or a font colour +## could change in the builder and the committed .tres stay stale, suite green. +const FONT_ROLES := { + HEADING: "Label", + ACCENT: "Label", + MONO: "Label", + TITLE_LOGO: "Label", + TITLE_KICKER: "Label", + SECTION_LABEL: "Label", +} + ## Every variation name + the base Control type it decorates. The builder and the ## test both iterate this so they can never drift apart. const ALL := { diff --git a/client/scripts/ui/creation/character_creation.gd b/client/scripts/ui/creation/character_creation.gd index ce717b3..e25c363 100644 --- a/client/scripts/ui/creation/character_creation.gd +++ b/client/scripts/ui/creation/character_creation.gd @@ -287,6 +287,11 @@ func _origin_prose() -> String: func _bind_cta() -> void: + ## The errors array is the VALIDATOR's, written for code. What lands on the + ## label is CreationCopy's, written for the player — "choose two more + ## proficiencies", not "hedge_mage picks 2 skills, got 0" (§13). The mapping is + ## in the presentation layer on purpose: NewGame.validate's strings are a + ## contract, and this screen is not the only thing that reads them. var errors := draft.errors(origin, world) _enter.disabled = not errors.is_empty() - _error.text = str(errors[0]) if not errors.is_empty() else CTA_HINT + _error.text = CreationCopy.error_line(str(errors[0]), draft) if not errors.is_empty() else CTA_HINT diff --git a/client/scripts/ui/creation/creation_copy.gd b/client/scripts/ui/creation/creation_copy.gd index 6a7fea6..6dc4a4a 100644 --- a/client/scripts/ui/creation/creation_copy.gd +++ b/client/scripts/ui/creation/creation_copy.gd @@ -17,6 +17,16 @@ const ARMOR_WORD := { "heavy": "heavy armour", } +## The one line the player reads when the draft will not hold. §13: a fallback is +## CONTENT, not error handling — so even an error nobody anticipated speaks in the +## DM's voice rather than showing him a validator's grammar. +const UNSPOKEN := "something in this does not hold — look again" + +const NUMBER_WORD := { + 1: "one", 2: "two", 3: "three", 4: "four", 5: "five", + 6: "six", 7: "seven", 8: "eight", 9: "nine", +} + static func calling_detail(id: String) -> String: ## e.g. "d8 · light armour · MP (FTH) · saves DEX + MAG · picks 4 skills · talent backstab" @@ -31,10 +41,19 @@ static func calling_detail(id: String) -> String: resource_word(id), " + ".join(saves), Callings.skill_count(id), - Callings.talent(id), + talent_word(id), ] +static func talent_word(id: String) -> String: + ## second_wind -> "second wind". Four of the seven talent IDS carry an + ## underscore, and the card printed the raw id: the player read "talent + ## second_wind" off the calling card. Ids are snake_case; humans are not — the + ## same reason skill_label() exists. Read from the table at call time, so + ## retuning a talent retunes the card. + return Callings.talent(id).replace("_", " ") + + static func armor_word(id: String) -> String: ## The card says "no armour", not "none". Public so the test can assert against ## the DERIVED word rather than the raw table value — "none" is not a substring @@ -74,3 +93,57 @@ static func race_trait(id: String) -> String: static func skill_label(skill: String) -> String: ## sleight_of_hand -> "sleight of hand". Ids are snake_case; humans are not. return skill.replace("_", " ") + + +# ------------------------------------------------------------------- §13: the nag + +static func error_line(error: String, draft: CreationDraft) -> String: + ## The validator's diagnostics are written for whoever is reading the errors + ## array — "hedge_mage picks 2 skills, got 0", "unresolved ref: item:x". They are + ## a CONTRACT (other code reads those strings), so they are not rewritten; they + ## are TRANSLATED, here, in the presentation layer, where translating them + ## belongs. What the player reads on the second screen of the game is authored + ## copy: dry, second person, flat. Never an id, never a compiler's grammar (§13). + ## + ## Anything unmapped degrades to UNSPOKEN — never the raw string, never blank. + if error.begins_with("player name is required"): + return "name yourself" + if error.begins_with("unknown race"): + return "decide what blood you carry" + if error.begins_with("unknown calling"): + return "decide what you do for coin" + if error.begins_with("calling not allowed by origin"): + return "that trade is closed to you — you left it behind" + if error.contains(" picks ") and error.contains(" skills, got "): + return _proficiency_line(draft) + if error.begins_with("duplicate skill pick"): + return "you have taken the same proficiency twice" + if error.contains(" is not in the ") and error.ends_with(" pool"): + return "your calling never taught you that" + if error.contains(" is already granted by "): + return "you have that one already — it buys you nothing" + if error.contains(" must choose a bonus skill"): + return "one more proficiency, any of them — take it" + if error.begins_with("unknown bonus skill"): + return "no one here would call that a skill" + if error.begins_with("bonus skill ") and error.ends_with(" is already proficient"): + return "you have that one already — take another" + if error.contains(" does not get a bonus skill"): + return "your blood grants no proficiency of its own" + return UNSPOKEN + + +static func _proficiency_line(draft: CreationDraft) -> String: + ## "choose two more proficiencies". The NUMBER is derived — Callings.skill_count + ## is the rules table's business and the copy asks it, every time, rather than + ## carrying a second copy of it that would one day disagree. + var left: int = draft.picks_left() + if left <= 0: + return "you have taken more proficiencies than your calling can carry" + if left == 1: + return "choose one more proficiency" + return "choose %s more proficiencies" % number_word(left) + + +static func number_word(n: int) -> String: + return str(NUMBER_WORD.get(n, n)) diff --git a/client/scripts/ui/creation/creation_draft.gd b/client/scripts/ui/creation/creation_draft.gd index ac6c589..7bb55d5 100644 --- a/client/scripts/ui/creation/creation_draft.gd +++ b/client/scripts/ui/creation/creation_draft.gd @@ -156,6 +156,14 @@ func is_picked(skill: String) -> bool: func can_pick(skill: String) -> bool: if is_granted(skill): return false + # The mirror of can_take_bonus's `not is_picked(skill)`. Without it the guard is + # one-directional: take athletics as the human's bonus, then pick it AGAIN from + # the calling's pool, and the draft is invalid ("bonus skill athletics is already + # proficient") — the player holding an invalid draft he did not cause and cannot + # see, which is the exact thing set_calling's comment forbids. The chip goes inert + # instead; the bonus row is right there and the player can hand it back. + if skill == bonus_skill: + return false if skill not in skill_pool(): return false return picks_left() > 0 diff --git a/client/tests/unit/test_character_creation_screen.gd b/client/tests/unit/test_character_creation_screen.gd index 5eb8aab..88c3c3e 100644 --- a/client/tests/unit/test_character_creation_screen.gd +++ b/client/tests/unit/test_character_creation_screen.gd @@ -121,6 +121,17 @@ func test_no_node_on_the_screen_says_luck(): var checked_a_rich_text_label := false var combos_checked := 0 + # The word "luck" is not the only thing that leaks Luck. Luck.BANDS holds the + # DESCRIPTORS — "the dice are kind today" — and a descriptor on this screen is + # strictly worse than the number: the player would re-roll until his Luck read + # well, which is Luck made CALCULABLE, the precise §7 failure. It is structurally + # unreachable today (the screen holds no GameState and the draft has no LCK), and + # it costs three lines to keep it that way. + var forbidden: Array = ["luck", "lck"] + for band in Luck.BANDS: + forbidden.append(str(band["text"]).to_lower()) + assert_eq(forbidden.size(), Luck.BANDS.size() + 2, "every band's text must be swept, not just the word") + for race_i in range(Races.IDS.size()): s._race_cards[race_i].pressed.emit() var allowed: Array = s._allowed_callings() @@ -137,10 +148,9 @@ func test_no_node_on_the_screen_says_luck(): if typeof(value) != TYPE_STRING: continue var lowered: String = value.to_lower() - assert_false(lowered.contains("luck"), - "%s.%s leaks Luck (§7) at %s" % [node.name, prop, combo]) - assert_false(lowered.contains("lck"), - "%s.%s leaks Luck (§7) at %s" % [node.name, prop, combo]) + for word in forbidden: + assert_false(lowered.contains(word), + "%s.%s leaks Luck (§7) at %s: '%s'" % [node.name, prop, combo, word]) # `checked_a_rich_text_label` alone only proves the sweep REACHED a # RichTextLabel, not that it had anything in it — an empty label would @@ -265,6 +275,21 @@ func test_authored_node_counts_match_the_rules_tables(): assert_eq(s._bonus_row.get_child_count(), Skills.IDS.size() + 1, "an orphan authored bonus chip would sit here, unbound and visible") + # PoolRow was the one row the sweep above forgot — a stray authored "Pool6" would + # render unbound and visible with every assertion so far still green, which is + # exactly the bug this test exists to catch. Its count is not a table LENGTH: it + # is the widest calling's pool (plus its own "Count" label, which is not a chip), + # so derive that from the rules table rather than writing 6 down again. + var widest := 0 + for id in Callings.IDS: + widest = maxi(widest, Callings.skill_pool(id).size()) + assert_eq(widest, s._pool_chips.size(), "the pool row must hold exactly the widest calling's pool") + assert_eq(s._pool_row.get_child_count(), widest + 1, + "an orphan authored pool chip would sit here, unbound and visible (PoolRow also holds its Count label)") + assert_null(s._pool_row.get_node_or_null("Pool%d" % widest), + "there is no chip past the widest calling's pool — a stray Pool%d renders unbound" % widest) + assert_eq(s._pool_count.get_parent(), s._pool_row, "the +1 above is the Count label, and nothing else") + func test_cta_is_disabled_until_the_draft_is_legal(): var s := _screen() @@ -278,6 +303,84 @@ func test_cta_is_disabled_until_the_draft_is_legal(): "a legal draft gets the mock's line back, not a blank") +func test_the_nag_under_the_cta_is_authored_copy_and_never_a_validator_string(): + # §13. The label used to read `str(errors[0])` — so the second screen of the game + # showed the player "sellsword picks 2 skills, got 0". Drive the screen through + # every invalid state it can actually reach, by pressing the nodes, and pin what + # the label says. Anchored to the authored phrases themselves: "contains no + # underscore" alone would pass on a blank label, or on the wrong copy. + var s := _screen() # human + sellsword, nameless, nothing picked + var seen: Array = [] + + assert_eq(s._error.text, "name yourself", "the first thing missing is a name") + seen.append(s._error.text) + + s._name_edit.text = "Aldric" + s._on_name_changed("Aldric") # LineEdit.text_changed is not raised by setting .text + assert_eq(s._error.text, "choose two more proficiencies", + "the sellsword picks 2 and has none — and the two is DERIVED from Callings.skill_count") + seen.append(s._error.text) + + s._pool_chips[0].pressed.emit() + assert_eq(s.draft.skills.size(), 1, "the pick landed, or the line below is about nothing") + assert_eq(s._error.text, "choose one more proficiency", "one is singular") + seen.append(s._error.text) + + s._pool_chips[1].pressed.emit() + assert_eq(s.draft.skills.size(), 2) + assert_eq(s._error.text, "one more proficiency, any of them — take it", + "the human's bonus skill, in the Margreave's voice, not 'human must choose a bonus skill'") + seen.append(s._error.text) + + for i in range(Skills.IDS.size()): + if s.draft.can_take_bonus(Skills.IDS[i]): + s._bonus_chips[i].pressed.emit() + break + assert_eq(s._error.text, CharacterCreation.CTA_HINT, "a legal draft gets the mock's line back") + + # Nothing above may be an engineering string, and none of them may repeat. + assert_eq(seen.size(), 4) + for line in seen: + assert_false(line.contains("_"), "a snake_case id reached the player: %s" % line) + assert_false(line.contains("got "), "the validator's grammar reached the player: %s" % line) + var unique := {} + for line in seen: + unique[line] = true + assert_eq(unique.size(), 4, "each invalid state says a DIFFERENT thing — the nag is telling him what is missing") + + +func test_a_skill_taken_as_the_bonus_goes_inert_in_the_calling_s_pool(): + # The one-directional guard: can_take_bonus() refused an already-PICKED skill, but + # can_pick() ignored the bonus. Human + Sellsword, take athletics as the bonus, + # then press athletics in the pool — the chip was live and the pick LANDED, leaving + # a draft NewGame.validate rejects ("bonus skill athletics is already proficient"). + var s := _screen() # human + sellsword + var pool: Array = s.draft.skill_pool() + var athletics_i: int = pool.find("athletics") + var bonus_i: int = Skills.IDS.find("athletics") + assert_ne(athletics_i, -1, "athletics must be in the sellsword's pool or this proves nothing") + assert_false(s._pool_chips[athletics_i].disabled, "the chip starts live, or the assertion below is trivially true") + + s._bonus_chips[bonus_i].pressed.emit() + assert_eq(s.draft.bonus_skill, "athletics") + assert_true(s._pool_chips[athletics_i].disabled, + "a skill the bonus already bought must render INERT in the pool") + + # Button.pressed fires even on a disabled Button — the rule lives in the draft, not + # in the styling, so press it and prove the pick does not land. + s._pool_chips[athletics_i].pressed.emit() + assert_false(s.draft.is_picked("athletics"), "the pick must not land") + assert_false("bonus skill athletics is already proficient" in s.draft.errors(s.origin, s.world), + "the player must not be able to press his way into an invalid draft") + + # And it is recoverable: hand the bonus back and the chip is live again. + s._bonus_chips[bonus_i].pressed.emit() + assert_eq(s.draft.bonus_skill, "") + assert_false(s._pool_chips[athletics_i].disabled, "handing the bonus back makes the pool chip live") + s._pool_chips[athletics_i].pressed.emit() + assert_true(s.draft.is_picked("athletics")) + + func test_pressing_enter_emits_exactly_the_draft_s_creation_dict(): var s := _screen() _complete_a_valid_draft(s) diff --git a/client/tests/unit/test_content_db.gd b/client/tests/unit/test_content_db.gd index dee944a..5325a88 100644 --- a/client/tests/unit/test_content_db.gd +++ b/client/tests/unit/test_content_db.gd @@ -159,3 +159,22 @@ func test_every_race_and_calling_carries_a_fragment(): "calling %s: fragment follows an em dash, so it must start lowercase" % id) assert_true(calling_fragment.ends_with("."), "calling %s: fragment must end with '.' — it closes the composition" % id) + + +func test_no_calling_is_named_with_a_vowel(): + # The fourth shape rule of that same composition, and the one that breaks SILENTLY. + # CharacterCreation._origin_prose() hardcodes the article: + # "%s Now you carry a %s's work — %s" + # All seven callings are consonant-initial, so all 28 compositions read correctly + # today and nothing anywhere would notice an eighth. Add "an Auger" and every + # Auger in the game gets "Now you carry a auger's work" on the DM panel — no + # error, no test, just a DM who cannot speak. Fail HERE, where the author is. + var vowels := ["a", "e", "i", "o", "u"] + for id in Callings.IDS: + var calling_name := str(db.calling(id).get("name", "")).strip_edges() + assert_ne(calling_name, "", "calling %s has no name" % id) + assert_false(calling_name.substr(0, 1).to_lower() in vowels, + ("calling %s is named '%s'. The DM origin panel composes " + + "\"Now you carry a %s's work\" with the article HARDCODED as \"a\" " + + "(CharacterCreation._origin_prose) — a vowel-initial calling needs \"an\". " + + "Change the template first, then this guard.") % [id, calling_name, calling_name.to_lower()]) diff --git a/client/tests/unit/test_creation_copy.gd b/client/tests/unit/test_creation_copy.gd index bada308..16b6e76 100644 --- a/client/tests/unit/test_creation_copy.gd +++ b/client/tests/unit/test_creation_copy.gd @@ -1,20 +1,64 @@ 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(race := "human", calling := "sellsword") -> CreationDraft: + var d := CreationDraft.new() + d.character_seed = 8675309 + d.set_race(race) + d.set_calling(calling) + d.name = "Aldric" + return d + func test_calling_detail_reads_the_table_rather_than_repeating_it(): # The guard that fails if anyone types "d8" into a string literal. If someone # retunes the Cutpurse's hit die, this test keeps passing and the CARD FOLLOWS — # which is the whole point. A hardcoded line would go red here. + # + # The talent is asserted through the DERIVED phrase ("talent second wind"), not + # the raw id ("second_wind") the card used to print — but it is still read from + # Callings.talent() at assert time, so hardcoding a talent into the format string + # still goes red (traps.md #7: anchor to the full derived phrase). for id in Callings.IDS: var line := CreationCopy.calling_detail(id) assert_string_contains(line, "d%d" % Callings.hit_die(id)) assert_string_contains(line, CreationCopy.armor_word(id)) - assert_string_contains(line, Callings.talent(id)) + assert_string_contains(line, "talent %s" % CreationCopy.talent_word(id)) assert_string_contains(line, "picks %d skills" % Callings.skill_count(id)) for s in Callings.saves(id): assert_string_contains(line, s.to_upper()) +func test_the_talent_is_humanised_and_no_card_line_shows_a_raw_id(): + # The player read "talent second_wind" off the Sellsword's card. Four of the seven + # talents carry an underscore — the card printed the ID. + assert_eq(CreationCopy.talent_word("sellsword"), "second wind") + assert_eq(CreationCopy.talent_word("cutpurse"), "backstab", "an underscore-free talent is untouched") + + var underscored := 0 + for id in Callings.IDS: + if Callings.talent(id).contains("_"): + underscored += 1 + assert_false(CreationCopy.calling_detail(id).contains("_"), + "%s's card line shows a raw snake_case id: %s" % [id, CreationCopy.calling_detail(id)]) + assert_eq(underscored, 4, + "four talents carry an underscore — if that ever hits zero this guard stops guarding") + + func test_casters_show_a_pool_and_martials_show_cooldowns(): assert_string_contains(CreationCopy.calling_detail("hedge_mage"), "MP (MAG)") assert_string_contains(CreationCopy.calling_detail("bonesetter"), "MP (FTH)") @@ -52,3 +96,86 @@ func test_nothing_in_the_copy_mentions_luck(): for id in Races.IDS: assert_false(CreationCopy.race_trait(id).to_lower().contains("luck")) assert_false(CreationCopy.race_trait(id).to_lower().contains("lck")) + + +# ------------------------------------------------------------------- §13: the nag + +func test_the_nag_is_authored_copy_not_the_validator_s_grammar(): + # §13. The exact strings a human must be able to judge, pinned. Anchored to the + # authored phrase itself — asserting only "no underscore" would pass on any + # string that happens to have none, including a blank one. + var human := _draft("human", "sellsword") + human.name = "" + assert_eq(CreationCopy.error_line("player name is required", human), "name yourself") + + # "sellsword picks 2 skills, got 0" -> the count is DERIVED from the rules table. + assert_eq(Callings.skill_count("sellsword"), 2, "the sellsword picks 2, or the line below means nothing") + assert_eq(CreationCopy.error_line("sellsword picks 2 skills, got 0", human), + "choose two more proficiencies") + human.toggle_skill("athletics") + assert_eq(CreationCopy.error_line("sellsword picks 2 skills, got 1", human), + "choose one more proficiency", "one is singular, and the number is never hardcoded") + + # The same error against a calling that picks FOUR must say four — the proof the + # number comes from Callings.skill_count and not from a literal "two". + var thief := _draft("dwarf", "cutpurse") + assert_eq(Callings.skill_count("cutpurse"), 4, "the cutpurse picks 4, or the line below means nothing") + assert_eq(CreationCopy.error_line("cutpurse picks 4 skills, got 0", thief), + "choose four more proficiencies") + + assert_eq(CreationCopy.error_line("human must choose a bonus skill", human), + "one more proficiency, any of them — take it") + + +func test_an_unmapped_error_still_speaks_in_voice(): + # §13: "write fallbacks as content, not as error handling." An error nobody + # anticipated (broken content; a spend the screen cannot produce) must never put + # the raw string on the label, and must never blank it either. + var d := _draft() + for raw in ["unresolved ref: item:ghost_blade", "seed is required and must be an integer", + "spend exceeds the pool of 3 (got 5)", "cannot spend on 'lck' — not an attribute"]: + var line := CreationCopy.error_line(raw, d) + assert_eq(line, CreationCopy.UNSPOKEN, "an unmapped error degrades to the authored fallback: %s" % raw) + assert_ne(line, raw) + assert_ne(CreationCopy.UNSPOKEN.strip_edges(), "", "the fallback is CONTENT — it is never blank") + assert_false(CreationCopy.UNSPOKEN.contains("_")) + + +func test_no_error_the_validator_can_raise_reaches_the_player_raw(): + # Sweep EVERY error NewGame.validate actually produces for a draft the screen can + # be driven into (plus the hostile shapes only a saga could hand it), and assert + # the player never sees the id, the underscore, or the validator's grammar. + var d := _draft() + var hostiles: Array = [ + {"name": "", "skills": [], "bonus_skill": ""}, # nameless, unpicked + {"skills": ["athletics"]}, # too few + {"skills": ["athletics", "athletics"]}, # duplicate + {"skills": ["athletics", "sorcery"]}, # not in the pool + {"race_id": "elf", "skills": ["athletics", "perception"], "bonus_skill": ""}, # granted already + {"bonus_skill": ""}, # human, no bonus + {"bonus_skill": "athletics", "skills": ["athletics", "endurance"]}, # bonus == a pick + {"bonus_skill": "swimming"}, # no such skill + {"race_id": "dwarf", "bonus_skill": "stealth"}, # dwarf gets none + {"race_id": "orc"}, {"calling_id": "paladin"}, # off the tables + {"seed": "8675309"}, {"spend": {"lck": 3}}, {"spend": {"str": 9}}, + ] + var base := { + "name": "Aldric", "race_id": "human", "calling_id": "sellsword", "seed": 8675309, + "spend": {}, "skills": ["athletics", "endurance"], "bonus_skill": "perception", + } + + var seen := 0 + for h in hostiles: + var creation := base.duplicate(true) + for k in h: + creation[k] = h[k] + var errors: Array = NewGame.validate(_deserter(), world, creation) + assert_gt(errors.size(), 0, "hostile creation %s must actually be invalid, or it guards nothing" % str(h)) + for e in errors: + seen += 1 + var line: String = CreationCopy.error_line(str(e), d) + assert_ne(line.strip_edges(), "", "'%s' put a BLANK line on the label" % e) + assert_ne(line, str(e), "'%s' reached the player raw" % e) + assert_false(line.contains("_"), "'%s' put a snake_case id on the label: %s" % [e, line]) + assert_false(line.contains("got "), "'%s' put the validator's grammar on the label: %s" % [e, line]) + assert_gt(seen, 12, "the sweep must actually reach a spread of errors, not one or two") diff --git a/client/tests/unit/test_creation_draft.gd b/client/tests/unit/test_creation_draft.gd index 8d498c3..9c79151 100644 --- a/client/tests/unit/test_creation_draft.gd +++ b/client/tests/unit/test_creation_draft.gd @@ -139,6 +139,65 @@ func test_toggling_an_already_picked_skill_removes_it(): assert_eq(d.skills, []) +func test_the_bonus_skill_cannot_also_be_picked_from_the_pool(): + # The guard was one-directional: can_take_bonus() refused a skill already PICKED, + # but can_pick() did not consider the bonus. Human + Sellsword, take athletics as + # the bonus, then press athletics in the Sellsword's pool — the pick landed and + # the draft went invalid ("bonus skill athletics is already proficient"), which is + # precisely the invalid draft "he did not cause and cannot see" that set_calling's + # comment forbids. + var d := _draft() # human + sellsword; athletics is in the pool + d.set_bonus_skill("athletics") + assert_eq(d.bonus_skill, "athletics") + assert_true("athletics" in d.skill_pool(), "athletics must be in the sellsword's pool or this proves nothing") + + assert_false(d.can_pick("athletics"), "the chip must be INERT, not merely styled to look inert") + d.toggle_skill("athletics") + assert_eq(d.skills, [], "the pick must not land while athletics is the bonus skill") + + # ...and the draft the press would have produced is exactly the one construct rejects. + d.skills = ["athletics", "endurance"] # what the bug let the player build + assert_true("bonus skill athletics is already proficient" in d.errors(_deserter(), world), + "this IS the invalid draft the guard exists to prevent") + + +func test_the_other_direction_still_holds_a_picked_skill_cannot_become_the_bonus(): + # The half of the guard that already worked. Kept beside its mirror so the two + # can never drift apart again. + var d := _draft() + d.toggle_skill("athletics") + assert_eq(d.skills, ["athletics"]) + assert_false(d.can_take_bonus("athletics")) + d.set_bonus_skill("athletics") + assert_eq(d.bonus_skill, "", "a picked skill must not also be taken as the bonus") + + +func test_handing_back_the_bonus_skill_makes_the_pool_chip_live_again(): + # The inert chip is recoverable — the bonus row is right there. Without this the + # fix above would be a dead end the player could walk into and not walk out of. + var d := _draft() + d.set_bonus_skill("athletics") + assert_false(d.can_pick("athletics")) + d.set_bonus_skill("athletics") # pressing the chosen bonus chip clears it + assert_eq(d.bonus_skill, "") + assert_true(d.can_pick("athletics"), "handing the bonus back must make the pool chip live") + d.toggle_skill("athletics") + assert_eq(d.skills, ["athletics"]) + + +func test_to_creation_carries_exactly_the_seven_contract_keys(): + # §2, stated as a test rather than as a comment. The screen emits a seed and four + # choices; it never hands construct a NUMBER. test_pressing_enter_emits_exactly_ + # the_draft_s_creation_dict compares the emitted dict to to_creation() — both + # sides move together, so adding an "attributes" key there breaks nothing. This + # pins the contract itself: seven keys, no more, no fewer. + var d := _draft() + var keys: Array = d.to_creation().keys() + keys.sort() + assert_eq(keys, ["bonus_skill", "calling_id", "name", "race_id", "seed", "skills", "spend"], + "the creation contract is exactly seven keys — an eighth (an 'attributes' block, say) is the §2 breach") + + func test_to_creation_round_trips_through_construct(): var d := _draft() d.toggle_skill("athletics") diff --git a/client/tests/unit/test_new_game.gd b/client/tests/unit/test_new_game.gd index eec9222..daf32fd 100644 --- a/client/tests/unit/test_new_game.gd +++ b/client/tests/unit/test_new_game.gd @@ -340,6 +340,33 @@ func test_construct_builds_exactly_what_roll_attributes_showed(): assert_eq(res["state"].sheet.attributes, expected) +func test_construct_ignores_an_attribute_block_handed_to_it(): + # §2 — "code owns state" — stated as a TEST, not as a comment. Every other test + # here feeds construct a well-behaved creation dict, so nothing in the suite ever + # proves construct REFUSES a number: a construct that trusted creation["attributes"] + # would pass all of them. Hand it a hostile one — an 18 in every stat — and assert + # the sheet is still, exactly, roll_attributes(seed) + spend. + var spend := {"str": 2, "con": 1} + var hostile := _creation({ + "seed": 4242, + "spend": spend, + "attributes": {"str": 18, "dex": 18, "con": 18, "fth": 18, "mag": 18}, + }) + var res := _build(hostile) + assert_true(res["ok"], str(res["errors"])) + + var shown := NewGame.roll_attributes(4242) + var expected := {} + for stat in Attributes.IDS: + expected[stat] = int(shown[stat]) + int(spend.get(stat, 0)) + assert_eq(res["state"].sheet.attributes, expected, + "construct re-rolls from the SEED — a stat block handed to it is not state, it is noise (§2)") + + # And the hostile block is not merely ignored on the sheet — it never becomes state + # anywhere. (A `sheet.attributes` the caller supplied would be the exact §2 failure.) + assert_false(res["log"].to_dict().has("attributes"), "no attribute ever reaches the canon log") + + func test_validate_is_public_and_agrees_with_construct(): var bad := _creation({"calling_id": "paladin"}) var errors := NewGame.validate(_deserter(), world, bad) diff --git a/client/tests/unit/test_theme_resource.gd b/client/tests/unit/test_theme_resource.gd index 7b8eedf..2dc9b8c 100644 --- a/client/tests/unit/test_theme_resource.gd +++ b/client/tests/unit/test_theme_resource.gd @@ -29,22 +29,56 @@ func test_default_font_is_set(): assert_true(t.default_font is FontFile) +func _guarded_variations() -> Array: + # EVERY variation the builder touches — the stylebox variations (ThemeKeys.ALL) + # AND the six font roles (ThemeKeys.FONT_ROLES), which ALL deliberately excludes + # by its own docstring. Iterating ALL alone left every font, font size and font + # colour in the theme unguarded: change Palette.CREAM (TitleLogo's font colour, + # and nothing else's), skip the rebuild, and the suite stayed green with a stale + # .tres shipped. traps.md #12 — "covers everything" must iterate the full set. + var out: Array = ThemeKeys.ALL.keys() + out.append_array(ThemeKeys.FONT_ROLES.keys()) + return out + + +func test_the_drift_guard_covers_every_variation_the_builder_touches(): + # The guard below is only as good as the set it walks. Pin that set to the + # builder's own output: every variation the fresh Theme carries a stylebox, a + # font, a font size or a font colour for MUST be in _guarded_variations(), or + # it can drift unwatched. A new variation added to the builder and forgotten in + # ThemeKeys goes red here rather than sitting unguarded forever. + var fresh: Theme = Builder.build_theme() + var guarded := _guarded_variations() + for variation in fresh.get_type_list(): + var touched := (not fresh.get_stylebox_list(variation).is_empty() + or not fresh.get_font_list(variation).is_empty() + or not fresh.get_font_size_list(variation).is_empty() + or not fresh.get_color_list(variation).is_empty()) + if not touched: + continue + if fresh.get_type_variation_base(variation) == &"": + continue # a BASE type (the builder styles RichTextLabel directly), not a variation + assert_true(variation in guarded, + "the builder styles the variation %s but no drift guard walks it — add it to ThemeKeys.ALL or ThemeKeys.FONT_ROLES" % variation) + + func test_committed_tres_matches_builder(): # Catches "palette/builder changed but nobody regenerated game_theme.tres." # Preloading the builder script and calling its static build_theme() does # NOT run _init() (that only fires on .new()), so this is side-effect-free. # - # Drives its checks off ThemeKeys.ALL so every variation is guarded, not just - # whichever three someone remembered to hardcode here. For each variation: - # every stylebox state the builder actually set (get_stylebox_list) is - # compared property-by-property when it's a StyleBoxFlat, and every font - # colour role the builder actually set (get_color_list) is compared too. - # Nothing the builder never touches is asserted on. + # Drives its checks off ThemeKeys.ALL *and* ThemeKeys.FONT_ROLES so every + # variation is guarded, not just whichever three someone remembered to hardcode + # here — and not just the stylebox half. For each variation: every stylebox + # state, every font, every font size and every font colour the builder actually + # set is compared. Nothing the builder never touches is asserted on. var fresh: Theme = Builder.build_theme() var committed: Theme = load(THEME_PATH) - for variation in ThemeKeys.ALL: + for variation in _guarded_variations(): for state in fresh.get_stylebox_list(variation): + assert_true(committed.has_stylebox(state, variation), + "stale game_theme.tres — %s/%s stylebox is missing entirely; re-run build_game_theme.gd" % [variation, state]) var fresh_box: StyleBox = fresh.get_stylebox(state, variation) var committed_box: StyleBox = committed.get_stylebox(state, variation) assert_eq(committed_box.get_class(), fresh_box.get_class(), @@ -74,7 +108,28 @@ func test_committed_tres_matches_builder(): assert_eq(c.corner_radius_bottom_left, f.corner_radius_bottom_left, "stale game_theme.tres — %s/%s corner_radius_bottom_left drifted from the builder; re-run build_game_theme.gd" % [variation, state]) + # The FONT half — the whole reason this guard could not fail against a font + # role. Compare the font's resource_path, not the Resource: `load()` caches, + # so two references to the same .ttf are the same instance and `==` would be + # true even when the builder swapped SERIF for MONO... only if the .tres + # happened to point at the same file anyway. The path is the thing that drifts. + for font_name in fresh.get_font_list(variation): + assert_true(committed.has_font(font_name, variation), + "stale game_theme.tres — %s/%s font is missing entirely; re-run build_game_theme.gd" % [variation, font_name]) + var fresh_font: Font = fresh.get_font(font_name, variation) + var committed_font: Font = committed.get_font(font_name, variation) + assert_eq(committed_font.resource_path, fresh_font.resource_path, + "stale game_theme.tres — %s/%s font drifted from the builder; re-run build_game_theme.gd" % [variation, font_name]) + + for size_name in fresh.get_font_size_list(variation): + assert_true(committed.has_font_size(size_name, variation), + "stale game_theme.tres — %s/%s font size is missing entirely; re-run build_game_theme.gd" % [variation, size_name]) + assert_eq(committed.get_font_size(size_name, variation), fresh.get_font_size(size_name, variation), + "stale game_theme.tres — %s/%s font size drifted from the builder; re-run build_game_theme.gd" % [variation, size_name]) + for color_name in fresh.get_color_list(variation): + assert_true(committed.has_color(color_name, variation), + "stale game_theme.tres — %s/%s colour is missing entirely; re-run build_game_theme.gd" % [variation, color_name]) var fresh_color: Color = fresh.get_color(color_name, variation) var committed_color: Color = committed.get_color(color_name, variation) assert_eq(committed_color, fresh_color, From 6291f21a0036f2b4d249d67856454d6a487e7486 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Mon, 13 Jul 2026 16:47:24 -0500 Subject: [PATCH 17/21] =?UTF-8?q?fix(theme-guard):=20close=20the=20drift?= =?UTF-8?q?=20guard's=20third=20miss=20=E2=80=94=20the=20meta-guard's=20ow?= =?UTF-8?q?n=20continue=20whitelisted=20the=20gap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_committed_tres_matches_builder has now been "fixed" three times (traps.md #12). v1 hardcoded 3 checks; v2 iterated ThemeKeys.ALL but missed the six font roles; v3 added a meta-guard specifically to catch "the builder styles a thing guarded by nothing" — but that meta-guard contained `if fresh.get_type_variation_base(variation) == &"": continue`, which whitelisted exactly the case it was built to catch. The builder styles RichTextLabel directly (build_game_theme.gd's _rich_text()), with no set_type_variation, so it reports base "" and was skipped unconditionally — and RichTextLabel is what renders every line of DM prose the creation screen shows (_origin_text, _detail_blurb). Nothing compared its fonts, font size, or colour, nor the theme-level default_font/default_font_size, against the committed .tres. Adds ThemeKeys.BASE_TYPES for base Control types the builder styles directly, folds it into the drift guard's coverage, replaces the meta-guard's continue with an assertion that names the offending type, and adds the missing theme-level default font/size comparison. Proved with three reverted breaks: a RichTextLabel font-size edit, a theme-level default_font_size edit, and an unregistered "GhostRole" variation — all three now go red and name the drifted property. Also: strengthens test_creation_copy's error-copy sweep to assert a mapped error actually reaches its authored line rather than silently falling through to the generic UNSPOKEN fallback (CreationCopy.UNSPOKEN satisfied all four prior checks, so a renamed validator string could regress silently); and removes two assertions that were true by construction / already true before their test's action ran. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/scripts/theme/theme_keys.gd | 13 ++++++ .../unit/test_character_creation_screen.gd | 1 - client/tests/unit/test_creation_copy.gd | 45 +++++++++++++++--- client/tests/unit/test_new_game.gd | 13 +++++- client/tests/unit/test_theme_resource.gd | 46 ++++++++++++++++--- 5 files changed, 102 insertions(+), 16 deletions(-) diff --git a/client/scripts/theme/theme_keys.gd b/client/scripts/theme/theme_keys.gd index 552df23..8bffbf6 100644 --- a/client/scripts/theme/theme_keys.gd +++ b/client/scripts/theme/theme_keys.gd @@ -47,6 +47,19 @@ const FONT_ROLES := { SECTION_LABEL: "Label", } +## Base Control types the builder styles DIRECTLY — no set_type_variation is +## ever called for these, so a fresh Theme reports get_type_variation_base("") +## for them. Kept as its own set (not folded into ALL or FONT_ROLES) because a +## base type is not a variation: nothing sets `theme_type_variation` to +## "RichTextLabel" anywhere, the node's own class name is the lookup key. +## Today: RichTextLabel, the builder's prose voice (mock README Typography: +## serif body/italic emphasis) — the creation screen's origin panel and +## calling detail panel are RichTextLabels rendering this styling directly, +## i.e. every line of DM prose the screen shows. +const BASE_TYPES := { + &"RichTextLabel": true, +} + ## Every variation name + the base Control type it decorates. The builder and the ## test both iterate this so they can never drift apart. const ALL := { diff --git a/client/tests/unit/test_character_creation_screen.gd b/client/tests/unit/test_character_creation_screen.gd index 88c3c3e..f5eb251 100644 --- a/client/tests/unit/test_character_creation_screen.gd +++ b/client/tests/unit/test_character_creation_screen.gd @@ -130,7 +130,6 @@ func test_no_node_on_the_screen_says_luck(): var forbidden: Array = ["luck", "lck"] for band in Luck.BANDS: forbidden.append(str(band["text"]).to_lower()) - assert_eq(forbidden.size(), Luck.BANDS.size() + 2, "every band's text must be swept, not just the word") for race_i in range(Races.IDS.size()): s._race_cards[race_i].pressed.emit() diff --git a/client/tests/unit/test_creation_copy.gd b/client/tests/unit/test_creation_copy.gd index 16b6e76..d9c8662 100644 --- a/client/tests/unit/test_creation_copy.gd +++ b/client/tests/unit/test_creation_copy.gd @@ -141,10 +141,35 @@ func test_an_unmapped_error_still_speaks_in_voice(): assert_false(CreationCopy.UNSPOKEN.contains("_")) +func _is_deliberately_unmapped(raw: String) -> bool: + # The shapes error_line has NO authored line for, on purpose, because the + # screen structurally cannot produce them (CreationDraft always emits an + # int seed and a spend built only from increment/decrement, which can + # never exceed the pool or name a non-attribute) — a malformed seed or + # spend, or a broken content reference, is not a player mistake to voice + # in character; test_an_unmapped_error_still_speaks_in_voice already pins + # the fallback for exactly this set. Named by the validator's own prefixes + # so this stays independent of error_line's internals. + return (raw.begins_with("unresolved ref:") + or raw.begins_with("seed is required") + or raw.begins_with("spend") + or raw.begins_with("cannot spend on") + or raw == "skills must be an array") + + func test_no_error_the_validator_can_raise_reaches_the_player_raw(): # Sweep EVERY error NewGame.validate actually produces for a draft the screen can # be driven into (plus the hostile shapes only a saga could hand it), and assert - # the player never sees the id, the underscore, or the validator's grammar. + # the player never sees the id, the underscore, or the validator's grammar — AND, + # for every shape error_line actually has an authored line for, that the line + # fired rather than silently falling through to the generic UNSPOKEN fallback. + # + # CreationCopy.UNSPOKEN ("something in this does not hold — look again") is + # itself non-blank, isn't the raw string, has no underscore, and has no "got " — + # so the four checks below cannot tell "correctly mapped to its authored line" + # from "silently fell through." Without the UNSPOKEN check, renaming a validator + # string in new_game.gd so an error_line branch stops matching would leave every + # error line reading the generic fallback, suite green. var d := _draft() var hostiles: Array = [ {"name": "", "skills": [], "bonus_skill": ""}, # nameless, unpicked @@ -165,6 +190,7 @@ func test_no_error_the_validator_can_raise_reaches_the_player_raw(): } var seen := 0 + var mapped_seen := 0 for h in hostiles: var creation := base.duplicate(true) for k in h: @@ -173,9 +199,16 @@ func test_no_error_the_validator_can_raise_reaches_the_player_raw(): assert_gt(errors.size(), 0, "hostile creation %s must actually be invalid, or it guards nothing" % str(h)) for e in errors: seen += 1 - var line: String = CreationCopy.error_line(str(e), d) - assert_ne(line.strip_edges(), "", "'%s' put a BLANK line on the label" % e) - assert_ne(line, str(e), "'%s' reached the player raw" % e) - assert_false(line.contains("_"), "'%s' put a snake_case id on the label: %s" % [e, line]) - assert_false(line.contains("got "), "'%s' put the validator's grammar on the label: %s" % [e, line]) + var raw := str(e) + var line: String = CreationCopy.error_line(raw, d) + assert_ne(line.strip_edges(), "", "'%s' put a BLANK line on the label" % raw) + assert_ne(line, raw, "'%s' reached the player raw" % raw) + assert_false(line.contains("_"), "'%s' put a snake_case id on the label: %s" % [raw, line]) + assert_false(line.contains("got "), "'%s' put the validator's grammar on the label: %s" % [raw, line]) + + if not _is_deliberately_unmapped(raw): + mapped_seen += 1 + assert_ne(line, CreationCopy.UNSPOKEN, + "'%s' has an authored error_line branch but silently fell through to the generic fallback" % raw) assert_gt(seen, 12, "the sweep must actually reach a spread of errors, not one or two") + assert_gt(mapped_seen, 8, "the sweep must actually exercise error_line's mapped branches, not just the deliberately-unmapped ones") diff --git a/client/tests/unit/test_new_game.gd b/client/tests/unit/test_new_game.gd index daf32fd..ef3dd9c 100644 --- a/client/tests/unit/test_new_game.gd +++ b/client/tests/unit/test_new_game.gd @@ -363,8 +363,17 @@ func test_construct_ignores_an_attribute_block_handed_to_it(): "construct re-rolls from the SEED — a stat block handed to it is not state, it is noise (§2)") # And the hostile block is not merely ignored on the sheet — it never becomes state - # anywhere. (A `sheet.attributes` the caller supplied would be the exact §2 failure.) - assert_false(res["log"].to_dict().has("attributes"), "no attribute ever reaches the canon log") + # anywhere else either. `res["log"].to_dict().has("attributes")` (the old check here) + # was already false before this test's action ran: LogPlayer.to_dict() has no such + # TOP-LEVEL key on any code path, hostile block or not, so it could never fail — delete + # construct's whole §2 guard and this stayed green. Assert the player row's exact key + # SET instead (sorted — insertion order in LogPlayer.to_dict() isn't the contract): + # it feeds the AI (§2/§7), so a stray "attributes", "str", or anything else reaching it + # is a real leak, and this is what actually fails if one does. + var player_keys: Array = res["log"].to_dict()["player"].keys() + player_keys.sort() + assert_eq(player_keys, ["calling_id", "luck_descriptor", "name", "race_id"], + "the canon-log player row carries a key beyond name/race_id/calling_id/luck_descriptor — numeric state reached the AI-facing log (§2/§7)") func test_validate_is_public_and_agrees_with_construct(): diff --git a/client/tests/unit/test_theme_resource.gd b/client/tests/unit/test_theme_resource.gd index 2dc9b8c..a9af29f 100644 --- a/client/tests/unit/test_theme_resource.gd +++ b/client/tests/unit/test_theme_resource.gd @@ -30,14 +30,19 @@ func test_default_font_is_set(): func _guarded_variations() -> Array: - # EVERY variation the builder touches — the stylebox variations (ThemeKeys.ALL) - # AND the six font roles (ThemeKeys.FONT_ROLES), which ALL deliberately excludes - # by its own docstring. Iterating ALL alone left every font, font size and font - # colour in the theme unguarded: change Palette.CREAM (TitleLogo's font colour, - # and nothing else's), skip the rebuild, and the suite stayed green with a stale - # .tres shipped. traps.md #12 — "covers everything" must iterate the full set. + # EVERY variation OR BASE TYPE the builder touches — the stylebox variations + # (ThemeKeys.ALL), the six font roles (ThemeKeys.FONT_ROLES), AND the base + # types the builder styles directly with no variation on top + # (ThemeKeys.BASE_TYPES — today just RichTextLabel). ALL and FONT_ROLES + # deliberately exclude each other's half by their own docstrings; missing + # BASE_TYPES here left RichTextLabel's two fonts, its font size and its + # font colour — the ONLY things rendering DM prose on the creation screen — + # unguarded against a stale .tres. traps.md #12, the third miss: iterating + # ALL alone, then ALL+FONT_ROLES, both still left a gap "covers everything" + # did not actually cover. var out: Array = ThemeKeys.ALL.keys() out.append_array(ThemeKeys.FONT_ROLES.keys()) + out.append_array(ThemeKeys.BASE_TYPES.keys()) return out @@ -57,7 +62,22 @@ func test_the_drift_guard_covers_every_variation_the_builder_touches(): if not touched: continue if fresh.get_type_variation_base(variation) == &"": - continue # a BASE type (the builder styles RichTextLabel directly), not a variation + # "" means one of two things, and they must be told apart, not both + # waved through: (a) a KNOWN base type the builder styles directly + # with no set_type_variation (RichTextLabel today — registered in + # ThemeKeys.BASE_TYPES), or (b) a type nobody ever registered at + # all — styled with set_stylebox/set_font/set_color but never + # given a set_type_variation, which reports the same "" and used + # to slip through this `continue` forever. The old guard could not + # tell these apart because it treated "" as "definitely (a)" and + # skipped it unconditionally — the exact whitelist that let + # RichTextLabel through in the first place (traps.md #12, third + # miss). Assert (a); anything else IS (b) and must be named. + assert_true(variation in ThemeKeys.BASE_TYPES, + ("the builder styles %s directly (no set_type_variation) and it is not in " + + "ThemeKeys.BASE_TYPES — add it there, or if it should be a real variation, " + + "give it a set_type_variation(...) base so ALL/FONT_ROLES can guard it") % variation) + continue assert_true(variation in guarded, "the builder styles the variation %s but no drift guard walks it — add it to ThemeKeys.ALL or ThemeKeys.FONT_ROLES" % variation) @@ -75,6 +95,18 @@ func test_committed_tres_matches_builder(): var fresh: Theme = Builder.build_theme() var committed: Theme = load(THEME_PATH) + # Theme-LEVEL defaults (build_game_theme.gd:69-70) live on the Theme object + # itself, not under any type or variation, so no per-variation loop below + # ever reaches them — they need their own comparison or a changed + # default_font_size (18 -> anything) ships silently. Font by resource_path, + # not the Resource: load() caches, so two references to the same .ttf are + # `==` even when the builder swapped fonts, unless the .tres happens to + # still point at the same file. + assert_eq(committed.default_font.resource_path, fresh.default_font.resource_path, + "stale game_theme.tres — theme.default_font drifted from the builder; re-run build_game_theme.gd") + assert_eq(committed.default_font_size, fresh.default_font_size, + "stale game_theme.tres — theme.default_font_size drifted from the builder; re-run build_game_theme.gd") + for variation in _guarded_variations(): for state in fresh.get_stylebox_list(variation): assert_true(committed.has_stylebox(state, variation), From 7471d2e37f61abac3600478d395106755a7f75f8 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Mon, 13 Jul 2026 16:48:27 -0500 Subject: [PATCH 18/21] docs(traps): the drift guard took three fixes, and the third one whitelisted the gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trap 12 told only the first act. The full story is the lesson: ThemeKeys.ALL excludes the font roles by its own docstring, and the builder styles some base types directly (RichTextLabel — which renders every line of DM prose on the creation screen). The meta-guard written specifically to prevent a third miss contained a `continue` that exempted exactly the gap it existed to catch. A guard-of-a-guard with an exemption in it is not a guard. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/traps.md | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/docs/traps.md b/docs/traps.md index c71b09f..835835f 100644 --- a/docs/traps.md +++ b/docs/traps.md @@ -219,11 +219,38 @@ variation/state pairs, so every variation added since — including all five thi milestone added for the creation screen — could drift from its generator with the test still green. -**The guard:** when a guard is described as covering "every X," check that it actually -enumerates `X.ALL` (or equivalent) rather than a literal list someone wrote down once. And -make the failure message name the specific variation and property that drifted — a guard -that only says "theme mismatch" does not tell the next person which of forty checks -failed. +**It took three fixes, and that is the actual lesson.** + +- **Fix 1** replaced the hardcoded three with `ThemeKeys.ALL`. Still could not fail: `ALL`, + *by its own docstring*, holds stylebox variations only — it deliberately excludes the six + **font roles**. Change a palette colour used only by a font role, skip the regen, ship a + stale theme, green. +- **Fix 2** added `FONT_ROLES` and iterated both. Still could not fail: the builder also + styles some **base types** directly (`RichTextLabel`'s font, italics font, size and + colour), and those are in neither set. `RichTextLabel` is what renders every line of DM + prose on the creation screen. +- **Fix 2 also added a meta-guard** — a test that walks the builder's own output and fails + if it styles anything that no set covers. That guard was written *specifically* to make a + third miss impossible. It contained this: + + ```gdscript + if fresh.get_type_variation_base(variation) == &"": + continue # a BASE type, not a variation + ``` + + **The meta-guard's one exemption was exactly the gap it existed to catch.** + +**The guard:** when a guard is described as covering "every X," check that it enumerates +`X.ALL` (or equivalent) rather than a literal list someone wrote down once — and then check +what `X.ALL` actually *contains*, because a set's name is not its contents. Make the failure +message name the specific variation and property that drifted; a guard that only says "theme +mismatch" does not tell the next person which of forty checks failed. + +**And the harder lesson: a guard-of-a-guard with an exemption in it is not a guard.** If you +write a meta-test to prove a set is complete, every `continue` and every `if … : return` in +it is a hole you are cutting on purpose. Assert on the exempted case instead of skipping it, +or you have built the very thing you were trying to prevent, one level up, where nobody will +look for it. --- From 63cbc8c16eabd6585ea6080549808caba64914f7 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Tue, 14 Jul 2026 19:56:09 -0500 Subject: [PATCH 19/21] fix(creation-screen): the white text, the badge on top of the roll, and a window too big for the laptop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The human's first F6 run found four defects headless GUT could not see, all now fixed and break-proven. 1. Fifteen Labels shipped invisible. _fonts() sets a default font and size but never a default font_color for the base Label type, so a Label with no type-variation inherited Godot's built-in default — WHITE — and rendered white-on-parchment: the four race names, four race blurbs, and seven calling names, every unreadable string on the sheet. New CardTitle / CardBody roles (mock #3a2f1c / #6a5a3a) fix it. NOT a global default Label colour: the Title screen has 12 bare Labels on a dark background that rely on white. 2. The PRIMARY badge covered the rolled value. The ability card is a PanelContainer, and a Container force-fits its children, so the badge's authored anchors were dead letters — it stretched to the card's full width and centred over the value. Nesting it under a plain Control (not a Container) restores absolute positioning. The badges also carried _flat()'s 12/6 card padding; a new tight pill stylebox matches the mock's 2px 9px. 3. Race cards overflowed their own border (108px card, 114px of content). Bumped to 140. The CHOSEN badge on the calling card moved from centre to the mock's top-right, and the ability card got its own AbilityCard variation with 16px top headroom for the badge (ParchmentCard is shared with the shell). 4. The default window (1600x900) was larger than a 1600x900 laptop's usable area, so the WM clamped it to 1589x752 and the run came up pillarboxed. Default is now 1280x720, resizable; the 1920x1080 design canvas is unchanged (canvas_items + keep scales it, so no layout number moved). Also closes M4-b's two open copy items: the cutpurse origin fragment (it contradicted its own blurb) and the Hint label (it omitted the roll's floor of 8, now pinned to NewGame.roll_attributes). Guards for 1-4 are new and each was re-broken and watched go red. The overflow guard in 3 was FIRST WRITTEN AS A TAUTOLOGY — Godot clamps Control.size up to its combined minimum, so `min <= size` is `x <= x` — and passed against the bug, 29/29, until it was rewritten to measure against the card that clips. That is traps.md #17 and the thirteenth cannot-fail assertion this branch has caught. 319 client tests green, content build green, theme drift guard satisfied. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 6 + client/assets/theme/game_theme.tres | 276 ++++++++++-------- client/project.godot | 21 +- client/scenes/creation/CharacterCreation.tscn | 213 ++++++++------ client/scripts/theme/build_game_theme.gd | 54 +++- client/scripts/theme/theme_keys.gd | 10 + .../scripts/ui/creation/character_creation.gd | 8 +- .../unit/test_character_creation_screen.gd | 153 +++++++++- content/world/callings/cutpurse.json | 2 +- docs/traps.md | 51 ++++ 10 files changed, 565 insertions(+), 229 deletions(-) diff --git a/.gitignore b/.gitignore index 22b6020..57fd54c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ +# ───────────────────────────────────────────── +# Local scratch — never versioned +# ───────────────────────────────────────────── +# Ad-hoc F6 / smoke-test captures. Kept for the working session, not the repo. +/screenshots/ + # ───────────────────────────────────────────── # Godot 4.7 (client/) # ───────────────────────────────────────────── diff --git a/client/assets/theme/game_theme.tres b/client/assets/theme/game_theme.tres index 2f94a07..889c6fe 100644 --- a/client/assets/theme/game_theme.tres +++ b/client/assets/theme/game_theme.tres @@ -1,13 +1,29 @@ [gd_resource type="Theme" format=3] [ext_resource type="FontFile" path="res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf" id="1_605w4"] -[ext_resource type="FontFile" path="res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf" id="2_4lwr2"] -[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf" id="3_eckwq"] +[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf" id="2_4lwr2"] +[ext_resource type="FontFile" path="res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf" id="3_eckwq"] [ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-Italic-VariableFont_wght.ttf" id="4_i0b23"] -[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_cqbvn"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_cqbvn"] +content_margin_left = 10.0 +content_margin_top = 16.0 +content_margin_right = 10.0 +content_margin_bottom = 14.0 +bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.7490196, 0.65882355, 0.47058824, 1) +corner_radius_top_left = 12 +corner_radius_top_right = 12 +corner_radius_bottom_right = 12 +corner_radius_bottom_left = 12 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_q5gpk"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_q5gpk"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bbpmc"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -23,7 +39,7 @@ corner_radius_top_right = 3 corner_radius_bottom_right = 3 corner_radius_bottom_left = 3 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bbpmc"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_73lme"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -39,7 +55,7 @@ corner_radius_top_right = 3 corner_radius_bottom_right = 3 corner_radius_bottom_left = 3 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_73lme"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_js3tr"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -55,23 +71,19 @@ corner_radius_top_right = 3 corner_radius_bottom_right = 3 corner_radius_bottom_left = 3 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_js3tr"] -content_margin_left = 12.0 -content_margin_top = 6.0 -content_margin_right = 12.0 -content_margin_bottom = 6.0 +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ube3r"] +content_margin_left = 8.0 +content_margin_top = 3.0 +content_margin_right = 8.0 +content_margin_bottom = 3.0 bg_color = Color(0.56078434, 0.22745098, 0.20392157, 1) -border_width_left = 1 -border_width_top = 1 -border_width_right = 1 -border_width_bottom = 1 border_color = Color(0.56078434, 0.22745098, 0.20392157, 1) corner_radius_top_left = 5 corner_radius_top_right = 5 corner_radius_bottom_right = 5 corner_radius_bottom_left = 5 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ube3r"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2tooq"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -87,9 +99,9 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_2tooq"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_4kten"] -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4kten"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p6wtm"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -105,7 +117,7 @@ corner_radius_top_right = 8 corner_radius_bottom_right = 8 corner_radius_bottom_left = 8 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p6wtm"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0fjdt"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -121,7 +133,7 @@ corner_radius_top_right = 8 corner_radius_bottom_right = 8 corner_radius_bottom_left = 8 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0fjdt"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ns3kg"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -137,7 +149,7 @@ corner_radius_top_right = 8 corner_radius_bottom_right = 8 corner_radius_bottom_left = 8 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ns3kg"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jly1r"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -153,7 +165,7 @@ corner_radius_top_right = 6 corner_radius_bottom_right = 6 corner_radius_bottom_left = 6 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jly1r"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lw8lk"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -170,7 +182,7 @@ corner_radius_top_right = 6 corner_radius_bottom_right = 6 corner_radius_bottom_left = 6 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lw8lk"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nh0e7"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -186,9 +198,9 @@ corner_radius_top_right = 8 corner_radius_bottom_right = 8 corner_radius_bottom_left = 8 -[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_nh0e7"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_dtr07"] -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dtr07"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nmowx"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -204,7 +216,7 @@ corner_radius_top_right = 8 corner_radius_bottom_right = 8 corner_radius_bottom_left = 8 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nmowx"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_oi067"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -220,7 +232,7 @@ corner_radius_top_right = 8 corner_radius_bottom_right = 8 corner_radius_bottom_left = 8 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_oi067"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukblj"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -236,7 +248,7 @@ corner_radius_top_right = 8 corner_radius_bottom_right = 8 corner_radius_bottom_left = 8 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukblj"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_utt73"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -252,7 +264,7 @@ corner_radius_top_right = 6 corner_radius_bottom_right = 6 corner_radius_bottom_left = 6 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_utt73"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_5u8fh"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -268,7 +280,7 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_5u8fh"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p0qbh"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -284,28 +296,12 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p0qbh"] -content_margin_left = 12.0 -content_margin_top = 6.0 -content_margin_right = 12.0 -content_margin_bottom = 6.0 -bg_color = Color(0.72156864, 0.32156864, 0.2901961, 1) -border_width_left = 1 -border_width_top = 1 -border_width_right = 1 -border_width_bottom = 1 -border_color = Color(0.56078434, 0.22745098, 0.20392157, 1) -corner_radius_top_left = 4 -corner_radius_top_right = 4 -corner_radius_bottom_right = 4 -corner_radius_bottom_left = 4 - [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_6y6ak"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 content_margin_bottom = 6.0 -bg_color = Color(0.56078434, 0.22745098, 0.20392157, 1) +bg_color = Color(0.72156864, 0.32156864, 0.2901961, 1) border_width_left = 1 border_width_top = 1 border_width_right = 1 @@ -321,7 +317,7 @@ content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 content_margin_bottom = 6.0 -bg_color = Color(0.47843137, 0.18431373, 0.16078432, 1) +bg_color = Color(0.56078434, 0.22745098, 0.20392157, 1) border_width_left = 1 border_width_top = 1 border_width_right = 1 @@ -337,6 +333,22 @@ content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 content_margin_bottom = 6.0 +bg_color = Color(0.47843137, 0.18431373, 0.16078432, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.56078434, 0.22745098, 0.20392157, 1) +corner_radius_top_left = 4 +corner_radius_top_right = 4 +corner_radius_bottom_right = 4 +corner_radius_bottom_left = 4 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_najyk"] +content_margin_left = 10.0 +content_margin_top = 16.0 +content_margin_right = 10.0 +content_margin_bottom = 14.0 bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1) border_width_left = 2 border_width_top = 2 @@ -348,23 +360,19 @@ corner_radius_top_right = 12 corner_radius_bottom_right = 12 corner_radius_bottom_left = 12 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_najyk"] -content_margin_left = 12.0 -content_margin_top = 6.0 -content_margin_right = 12.0 -content_margin_bottom = 6.0 +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jtb1k"] +content_margin_left = 9.0 +content_margin_top = 2.0 +content_margin_right = 9.0 +content_margin_bottom = 2.0 bg_color = Color(0.56078434, 0.41568628, 0.16470589, 1) -border_width_left = 1 -border_width_top = 1 -border_width_right = 1 -border_width_bottom = 1 border_color = Color(0.56078434, 0.41568628, 0.16470589, 1) corner_radius_top_left = 5 corner_radius_top_right = 5 corner_radius_bottom_right = 5 corner_radius_bottom_left = 5 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jtb1k"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_wmbj4"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -380,9 +388,9 @@ corner_radius_top_right = 12 corner_radius_bottom_right = 12 corner_radius_bottom_left = 12 -[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_wmbj4"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_xdylb"] -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_xdylb"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pjnll"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -398,7 +406,7 @@ corner_radius_top_right = 12 corner_radius_bottom_right = 12 corner_radius_bottom_left = 12 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pjnll"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_klelq"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -414,7 +422,7 @@ corner_radius_top_right = 12 corner_radius_bottom_right = 12 corner_radius_bottom_left = 12 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_klelq"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_i2sl5"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -430,7 +438,7 @@ corner_radius_top_right = 12 corner_radius_bottom_right = 12 corner_radius_bottom_left = 12 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_i2sl5"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_onsig"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -446,9 +454,9 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_onsig"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_gw0t7"] -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_gw0t7"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_88l5g"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -464,7 +472,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_88l5g"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_f8qac"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -480,7 +488,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_f8qac"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_u6gcs"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -496,9 +504,9 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_u6gcs"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_nufy3"] -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nufy3"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0d7vs"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -514,7 +522,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0d7vs"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_3fnfk"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -530,7 +538,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_3fnfk"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2txdw"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -546,9 +554,9 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_2txdw"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_w7qkp"] -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_w7qkp"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_rthnr"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -564,7 +572,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_rthnr"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ta3oh"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -580,7 +588,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ta3oh"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ohwwq"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -597,116 +605,126 @@ corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 [resource] -default_font = ExtResource("3_eckwq") +default_font = ExtResource("2_4lwr2") default_font_size = 18 +AbilityCard/base_type = &"PanelContainer" +AbilityCard/styles/panel = SubResource("StyleBoxFlat_cqbvn") Accent/base_type = &"Label" Accent/colors/font_color = Color(0.56078434, 0.41568628, 0.16470589, 1) Accent/font_sizes/font_size = 24 Accent/fonts/font = ExtResource("1_605w4") +CardBody/base_type = &"Label" +CardBody/colors/font_color = Color(0.41568628, 0.3529412, 0.22745098, 1) +CardBody/font_sizes/font_size = 13 +CardBody/fonts/font = ExtResource("2_4lwr2") +CardTitle/base_type = &"Label" +CardTitle/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1) +CardTitle/font_sizes/font_size = 19 +CardTitle/fonts/font = ExtResource("2_4lwr2") Chip/base_type = &"Button" Chip/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1) Chip/font_sizes/font_size = 11 -Chip/fonts/font = ExtResource("2_4lwr2") -Chip/styles/focus = SubResource("StyleBoxEmpty_cqbvn") -Chip/styles/hover = SubResource("StyleBoxFlat_q5gpk") -Chip/styles/normal = SubResource("StyleBoxFlat_bbpmc") -Chip/styles/pressed = SubResource("StyleBoxFlat_73lme") +Chip/fonts/font = ExtResource("3_eckwq") +Chip/styles/focus = SubResource("StyleBoxEmpty_q5gpk") +Chip/styles/hover = SubResource("StyleBoxFlat_bbpmc") +Chip/styles/normal = SubResource("StyleBoxFlat_73lme") +Chip/styles/pressed = SubResource("StyleBoxFlat_js3tr") ChosenTag/base_type = &"Label" ChosenTag/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1) ChosenTag/font_sizes/font_size = 10 -ChosenTag/fonts/font = ExtResource("2_4lwr2") -ChosenTag/styles/normal = SubResource("StyleBoxFlat_js3tr") +ChosenTag/fonts/font = ExtResource("3_eckwq") +ChosenTag/styles/normal = SubResource("StyleBoxFlat_ube3r") DarkPanel/base_type = &"PanelContainer" -DarkPanel/styles/panel = SubResource("StyleBoxFlat_ube3r") +DarkPanel/styles/panel = SubResource("StyleBoxFlat_2tooq") DockButton/base_type = &"Button" DockButton/colors/font_color = Color(0.7882353, 0.7411765, 0.6392157, 1) -DockButton/styles/focus = SubResource("StyleBoxEmpty_2tooq") -DockButton/styles/hover = SubResource("StyleBoxFlat_4kten") -DockButton/styles/normal = SubResource("StyleBoxFlat_p6wtm") -DockButton/styles/pressed = SubResource("StyleBoxFlat_0fjdt") +DockButton/styles/focus = SubResource("StyleBoxEmpty_4kten") +DockButton/styles/hover = SubResource("StyleBoxFlat_p6wtm") +DockButton/styles/normal = SubResource("StyleBoxFlat_0fjdt") +DockButton/styles/pressed = SubResource("StyleBoxFlat_ns3kg") Heading/base_type = &"Label" Heading/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1) Heading/font_sizes/font_size = 34 -Heading/fonts/font = ExtResource("3_eckwq") +Heading/fonts/font = ExtResource("2_4lwr2") ItemTile/base_type = &"PanelContainer" -ItemTile/styles/panel = SubResource("StyleBoxFlat_ns3kg") +ItemTile/styles/panel = SubResource("StyleBoxFlat_jly1r") ItemTileEmpty/base_type = &"PanelContainer" -ItemTileEmpty/styles/panel = SubResource("StyleBoxFlat_jly1r") +ItemTileEmpty/styles/panel = SubResource("StyleBoxFlat_lw8lk") Mono/base_type = &"Label" Mono/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1) Mono/font_sizes/font_size = 13 -Mono/fonts/font = ExtResource("2_4lwr2") +Mono/fonts/font = ExtResource("3_eckwq") ParchmentButton/base_type = &"Button" ParchmentButton/colors/font_color = Color(0.2901961, 0.24705882, 0.17254902, 1) ParchmentButton/colors/font_disabled_color = Color(0.41568628, 0.3529412, 0.22745098, 1) -ParchmentButton/styles/disabled = SubResource("StyleBoxFlat_lw8lk") -ParchmentButton/styles/focus = SubResource("StyleBoxEmpty_nh0e7") -ParchmentButton/styles/hover = SubResource("StyleBoxFlat_dtr07") -ParchmentButton/styles/normal = SubResource("StyleBoxFlat_nmowx") -ParchmentButton/styles/pressed = SubResource("StyleBoxFlat_oi067") +ParchmentButton/styles/disabled = SubResource("StyleBoxFlat_nh0e7") +ParchmentButton/styles/focus = SubResource("StyleBoxEmpty_dtr07") +ParchmentButton/styles/hover = SubResource("StyleBoxFlat_nmowx") +ParchmentButton/styles/normal = SubResource("StyleBoxFlat_oi067") +ParchmentButton/styles/pressed = SubResource("StyleBoxFlat_ukblj") ParchmentCard/base_type = &"PanelContainer" -ParchmentCard/styles/panel = SubResource("StyleBoxFlat_ukblj") +ParchmentCard/styles/panel = SubResource("StyleBoxFlat_utt73") ParchmentInset/base_type = &"PanelContainer" -ParchmentInset/styles/panel = SubResource("StyleBoxFlat_utt73") +ParchmentInset/styles/panel = SubResource("StyleBoxFlat_5u8fh") PrimaryCTA/base_type = &"Button" PrimaryCTA/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1) PrimaryCTA/colors/font_disabled_color = Color(0.6039216, 0.56078434, 0.47058824, 1) -PrimaryCTA/styles/disabled = SubResource("StyleBoxFlat_5u8fh") -PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_p0qbh") -PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_6y6ak") -PrimaryCTA/styles/pressed = SubResource("StyleBoxFlat_txxuc") +PrimaryCTA/styles/disabled = SubResource("StyleBoxFlat_p0qbh") +PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_6y6ak") +PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_txxuc") +PrimaryCTA/styles/pressed = SubResource("StyleBoxFlat_7d6ug") PrimaryCard/base_type = &"PanelContainer" -PrimaryCard/styles/panel = SubResource("StyleBoxFlat_7d6ug") +PrimaryCard/styles/panel = SubResource("StyleBoxFlat_najyk") PrimaryTag/base_type = &"Label" PrimaryTag/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1) PrimaryTag/font_sizes/font_size = 9 -PrimaryTag/fonts/font = ExtResource("2_4lwr2") -PrimaryTag/styles/normal = SubResource("StyleBoxFlat_najyk") +PrimaryTag/fonts/font = ExtResource("3_eckwq") +PrimaryTag/styles/normal = SubResource("StyleBoxFlat_jtb1k") RichTextLabel/colors/default_color = Color(0.2901961, 0.24705882, 0.17254902, 1) RichTextLabel/font_sizes/normal_font_size = 20 RichTextLabel/fonts/italics_font = ExtResource("4_i0b23") -RichTextLabel/fonts/normal_font = ExtResource("3_eckwq") +RichTextLabel/fonts/normal_font = ExtResource("2_4lwr2") SectionLabel/base_type = &"Label" SectionLabel/colors/font_color = Color(0.5411765, 0.46666667, 0.28235295, 1) SectionLabel/font_sizes/font_size = 13 -SectionLabel/fonts/font = ExtResource("2_4lwr2") +SectionLabel/fonts/font = ExtResource("3_eckwq") SelectCard/base_type = &"Button" SelectCard/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1) SelectCard/colors/font_disabled_color = Color(0.41568628, 0.3529412, 0.22745098, 1) -SelectCard/styles/disabled = SubResource("StyleBoxFlat_jtb1k") -SelectCard/styles/focus = SubResource("StyleBoxEmpty_wmbj4") -SelectCard/styles/hover = SubResource("StyleBoxFlat_xdylb") -SelectCard/styles/normal = SubResource("StyleBoxFlat_pjnll") -SelectCard/styles/pressed = SubResource("StyleBoxFlat_klelq") +SelectCard/styles/disabled = SubResource("StyleBoxFlat_wmbj4") +SelectCard/styles/focus = SubResource("StyleBoxEmpty_xdylb") +SelectCard/styles/hover = SubResource("StyleBoxFlat_pjnll") +SelectCard/styles/normal = SubResource("StyleBoxFlat_klelq") +SelectCard/styles/pressed = SubResource("StyleBoxFlat_i2sl5") SkillChip/base_type = &"Button" SkillChip/colors/font_color = Color(0.3529412, 0.2627451, 0.14901961, 1) SkillChip/colors/font_disabled_color = Color(0.6627451, 0.5803922, 0.39215687, 1) SkillChip/colors/font_hover_color = Color(0.3529412, 0.2627451, 0.14901961, 1) SkillChip/colors/font_pressed_color = Color(0.9411765, 0.9019608, 0.8156863, 1) SkillChip/font_sizes/font_size = 12 -SkillChip/fonts/font = ExtResource("2_4lwr2") -SkillChip/styles/disabled = SubResource("StyleBoxFlat_i2sl5") -SkillChip/styles/focus = SubResource("StyleBoxEmpty_onsig") -SkillChip/styles/hover = SubResource("StyleBoxFlat_gw0t7") -SkillChip/styles/normal = SubResource("StyleBoxFlat_88l5g") -SkillChip/styles/pressed = SubResource("StyleBoxFlat_f8qac") +SkillChip/fonts/font = ExtResource("3_eckwq") +SkillChip/styles/disabled = SubResource("StyleBoxFlat_onsig") +SkillChip/styles/focus = SubResource("StyleBoxEmpty_gw0t7") +SkillChip/styles/hover = SubResource("StyleBoxFlat_88l5g") +SkillChip/styles/normal = SubResource("StyleBoxFlat_f8qac") +SkillChip/styles/pressed = SubResource("StyleBoxFlat_u6gcs") Tab/base_type = &"Button" Tab/colors/font_color = Color(0.41568628, 0.3529412, 0.22745098, 1) -Tab/styles/focus = SubResource("StyleBoxEmpty_u6gcs") -Tab/styles/hover = SubResource("StyleBoxFlat_nufy3") -Tab/styles/normal = SubResource("StyleBoxFlat_0d7vs") -Tab/styles/pressed = SubResource("StyleBoxFlat_3fnfk") +Tab/styles/focus = SubResource("StyleBoxEmpty_nufy3") +Tab/styles/hover = SubResource("StyleBoxFlat_0d7vs") +Tab/styles/normal = SubResource("StyleBoxFlat_3fnfk") +Tab/styles/pressed = SubResource("StyleBoxFlat_2txdw") TabActive/base_type = &"Button" TabActive/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1) -TabActive/styles/focus = SubResource("StyleBoxEmpty_2txdw") -TabActive/styles/hover = SubResource("StyleBoxFlat_w7qkp") -TabActive/styles/normal = SubResource("StyleBoxFlat_rthnr") -TabActive/styles/pressed = SubResource("StyleBoxFlat_ta3oh") +TabActive/styles/focus = SubResource("StyleBoxEmpty_w7qkp") +TabActive/styles/hover = SubResource("StyleBoxFlat_rthnr") +TabActive/styles/normal = SubResource("StyleBoxFlat_ta3oh") +TabActive/styles/pressed = SubResource("StyleBoxFlat_ohwwq") TitleKicker/base_type = &"Label" TitleKicker/colors/font_color = Color(0.6627451, 0.5176471, 0.24705882, 1) TitleKicker/font_sizes/font_size = 13 -TitleKicker/fonts/font = ExtResource("2_4lwr2") +TitleKicker/fonts/font = ExtResource("3_eckwq") TitleLogo/base_type = &"Label" TitleLogo/colors/font_color = Color(0.9098039, 0.8666667, 0.78431374, 1) TitleLogo/font_sizes/font_size = 116 -TitleLogo/fonts/font = ExtResource("3_eckwq") +TitleLogo/fonts/font = ExtResource("2_4lwr2") diff --git a/client/project.godot b/client/project.godot index 0ea126e..29567d0 100644 --- a/client/project.godot +++ b/client/project.godot @@ -11,19 +11,28 @@ config_version=5 [application] config/name="coc-rpg" -config/version="0.01-alpha" config/description="AI-driven single-player party RPG. Code owns state. AI owns text." +config/version="0.01-alpha" run/main_scene="res://scenes/title/TitleScreen.tscn" config/features=PackedStringArray("4.7") [display] -; Design/logical canvas stays 1920x1080 (charter §16, mockups authored here). +; The DESIGN CANVAS stays 1920x1080 — the mockups (§16) are authored there, and every +; .tscn's offsets are in those coordinates. It is a logical canvas, not a demand on the +; monitor: canvas_items + keep scales it uniformly into whatever window it is given, so +; the UI never reflows, it only gets smaller. Nothing below changes a layout number. window/size/viewport_width=1920 window/size/viewport_height=1080 -; Open a smaller 1600x900 window that scales the 1920x1080 canvas down, so it -; doesn't fill the whole screen. canvas_items + keep aspect does the scaling. -window/size/window_width_override=1600 -window/size/window_height_override=900 +; The WINDOW is what the laptop has to fit, and it is a different question. 1600x900 was +; larger than a 1600x900 laptop's *usable* area once the WM took its panel and titlebar, +; so the window manager clamped it to 1589x752 and the run came up pillarboxed. 1280x720 +; is 16:9 and fits any laptop this ships to; the window is resizable (Godot's default), +; and canvas_items rescales the canvas live on every resize. +window/size/window_width_override=1280 +window/size/window_height_override=720 +window/size/resizable=true window/stretch/mode="canvas_items" +; keep = letterbox rather than distort or reflow. This IS Godot 4's default, so the editor +; prunes the line whenever it rewrites this file — that is cosmetic, not a behaviour change. window/stretch/aspect="keep" diff --git a/client/scenes/creation/CharacterCreation.tscn b/client/scenes/creation/CharacterCreation.tscn index 0636818..403f675 100644 --- a/client/scenes/creation/CharacterCreation.tscn +++ b/client/scenes/creation/CharacterCreation.tscn @@ -145,7 +145,7 @@ layout_mode = 2 theme_override_constants/separation = 14 [node name="Race0" type="Button" parent="Split/Sheet/Body/RaceSection/Cards"] -custom_minimum_size = Vector2(0, 108) +custom_minimum_size = Vector2(0, 140) layout_mode = 2 size_flags_horizontal = 3 toggle_mode = true @@ -166,6 +166,7 @@ theme_override_constants/separation = 5 [node name="Name" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race0/Box"] layout_mode = 2 mouse_filter = 2 +theme_type_variation = &"CardTitle" text = "Human" [node name="Blurb" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race0/Box"] @@ -173,7 +174,7 @@ layout_mode = 2 size_flags_vertical = 3 mouse_filter = 2 autowrap_mode = 3 -theme_override_font_sizes/font_size = 14 +theme_type_variation = &"CardBody" text = "…" [node name="Trait" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race0/Box"] @@ -199,7 +200,7 @@ theme_type_variation = &"ChosenTag" text = "CHOSEN" [node name="Race1" type="Button" parent="Split/Sheet/Body/RaceSection/Cards"] -custom_minimum_size = Vector2(0, 108) +custom_minimum_size = Vector2(0, 140) layout_mode = 2 size_flags_horizontal = 3 toggle_mode = true @@ -220,6 +221,7 @@ theme_override_constants/separation = 5 [node name="Name" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race1/Box"] layout_mode = 2 mouse_filter = 2 +theme_type_variation = &"CardTitle" text = "Elf" [node name="Blurb" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race1/Box"] @@ -227,7 +229,7 @@ layout_mode = 2 size_flags_vertical = 3 mouse_filter = 2 autowrap_mode = 3 -theme_override_font_sizes/font_size = 14 +theme_type_variation = &"CardBody" text = "…" [node name="Trait" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race1/Box"] @@ -253,7 +255,7 @@ theme_type_variation = &"ChosenTag" text = "CHOSEN" [node name="Race2" type="Button" parent="Split/Sheet/Body/RaceSection/Cards"] -custom_minimum_size = Vector2(0, 108) +custom_minimum_size = Vector2(0, 140) layout_mode = 2 size_flags_horizontal = 3 toggle_mode = true @@ -274,6 +276,7 @@ theme_override_constants/separation = 5 [node name="Name" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race2/Box"] layout_mode = 2 mouse_filter = 2 +theme_type_variation = &"CardTitle" text = "Dwarf" [node name="Blurb" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race2/Box"] @@ -281,7 +284,7 @@ layout_mode = 2 size_flags_vertical = 3 mouse_filter = 2 autowrap_mode = 3 -theme_override_font_sizes/font_size = 14 +theme_type_variation = &"CardBody" text = "…" [node name="Trait" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race2/Box"] @@ -307,7 +310,7 @@ theme_type_variation = &"ChosenTag" text = "CHOSEN" [node name="Race3" type="Button" parent="Split/Sheet/Body/RaceSection/Cards"] -custom_minimum_size = Vector2(0, 108) +custom_minimum_size = Vector2(0, 140) layout_mode = 2 size_flags_horizontal = 3 toggle_mode = true @@ -328,6 +331,7 @@ theme_override_constants/separation = 5 [node name="Name" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race3/Box"] layout_mode = 2 mouse_filter = 2 +theme_type_variation = &"CardTitle" text = "Beastfolk" [node name="Blurb" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race3/Box"] @@ -335,7 +339,7 @@ layout_mode = 2 size_flags_vertical = 3 mouse_filter = 2 autowrap_mode = 3 -theme_override_font_sizes/font_size = 14 +theme_type_variation = &"CardBody" text = "…" [node name="Trait" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race3/Box"] @@ -393,7 +397,8 @@ theme_override_constants/separation = 5 layout_mode = 2 mouse_filter = 2 horizontal_alignment = 1 -theme_override_font_sizes/font_size = 16 +theme_type_variation = &"CardTitle" +theme_override_font_sizes/font_size = 17 text = "Sellsword" [node name="Chip" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling0/Box"] @@ -407,13 +412,13 @@ text = "STR" visible = false layout_mode = 1 anchors_preset = 1 -anchor_left = 0.5 -anchor_right = 0.5 -offset_left = -33.0 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -74.0 offset_top = -11.0 -offset_right = 33.0 +offset_right = -10.0 offset_bottom = 9.0 -grow_horizontal = 2 +grow_horizontal = 0 mouse_filter = 2 theme_type_variation = &"ChosenTag" text = "CHOSEN" @@ -438,7 +443,8 @@ theme_override_constants/separation = 5 layout_mode = 2 mouse_filter = 2 horizontal_alignment = 1 -theme_override_font_sizes/font_size = 16 +theme_type_variation = &"CardTitle" +theme_override_font_sizes/font_size = 17 text = "Reaver" [node name="Chip" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling1/Box"] @@ -452,13 +458,13 @@ text = "STR" visible = false layout_mode = 1 anchors_preset = 1 -anchor_left = 0.5 -anchor_right = 0.5 -offset_left = -33.0 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -74.0 offset_top = -11.0 -offset_right = 33.0 +offset_right = -10.0 offset_bottom = 9.0 -grow_horizontal = 2 +grow_horizontal = 0 mouse_filter = 2 theme_type_variation = &"ChosenTag" text = "CHOSEN" @@ -483,7 +489,8 @@ theme_override_constants/separation = 5 layout_mode = 2 mouse_filter = 2 horizontal_alignment = 1 -theme_override_font_sizes/font_size = 16 +theme_type_variation = &"CardTitle" +theme_override_font_sizes/font_size = 17 text = "Cutpurse" [node name="Chip" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling2/Box"] @@ -497,13 +504,13 @@ text = "DEX" visible = false layout_mode = 1 anchors_preset = 1 -anchor_left = 0.5 -anchor_right = 0.5 -offset_left = -33.0 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -74.0 offset_top = -11.0 -offset_right = 33.0 +offset_right = -10.0 offset_bottom = 9.0 -grow_horizontal = 2 +grow_horizontal = 0 mouse_filter = 2 theme_type_variation = &"ChosenTag" text = "CHOSEN" @@ -528,7 +535,8 @@ theme_override_constants/separation = 5 layout_mode = 2 mouse_filter = 2 horizontal_alignment = 1 -theme_override_font_sizes/font_size = 16 +theme_type_variation = &"CardTitle" +theme_override_font_sizes/font_size = 17 text = "Trapper" [node name="Chip" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling3/Box"] @@ -542,13 +550,13 @@ text = "DEX" visible = false layout_mode = 1 anchors_preset = 1 -anchor_left = 0.5 -anchor_right = 0.5 -offset_left = -33.0 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -74.0 offset_top = -11.0 -offset_right = 33.0 +offset_right = -10.0 offset_bottom = 9.0 -grow_horizontal = 2 +grow_horizontal = 0 mouse_filter = 2 theme_type_variation = &"ChosenTag" text = "CHOSEN" @@ -573,7 +581,8 @@ theme_override_constants/separation = 5 layout_mode = 2 mouse_filter = 2 horizontal_alignment = 1 -theme_override_font_sizes/font_size = 16 +theme_type_variation = &"CardTitle" +theme_override_font_sizes/font_size = 17 text = "Hedge-Mage" [node name="Chip" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling4/Box"] @@ -587,13 +596,13 @@ text = "MAG" visible = false layout_mode = 1 anchors_preset = 1 -anchor_left = 0.5 -anchor_right = 0.5 -offset_left = -33.0 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -74.0 offset_top = -11.0 -offset_right = 33.0 +offset_right = -10.0 offset_bottom = 9.0 -grow_horizontal = 2 +grow_horizontal = 0 mouse_filter = 2 theme_type_variation = &"ChosenTag" text = "CHOSEN" @@ -618,7 +627,8 @@ theme_override_constants/separation = 5 layout_mode = 2 mouse_filter = 2 horizontal_alignment = 1 -theme_override_font_sizes/font_size = 16 +theme_type_variation = &"CardTitle" +theme_override_font_sizes/font_size = 17 text = "Bonesetter" [node name="Chip" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling5/Box"] @@ -632,13 +642,13 @@ text = "FTH" visible = false layout_mode = 1 anchors_preset = 1 -anchor_left = 0.5 -anchor_right = 0.5 -offset_left = -33.0 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -74.0 offset_top = -11.0 -offset_right = 33.0 +offset_right = -10.0 offset_bottom = 9.0 -grow_horizontal = 2 +grow_horizontal = 0 mouse_filter = 2 theme_type_variation = &"ChosenTag" text = "CHOSEN" @@ -663,7 +673,8 @@ theme_override_constants/separation = 5 layout_mode = 2 mouse_filter = 2 horizontal_alignment = 1 -theme_override_font_sizes/font_size = 16 +theme_type_variation = &"CardTitle" +theme_override_font_sizes/font_size = 17 text = "Bloodsworn" [node name="Chip" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling6/Box"] @@ -677,13 +688,13 @@ text = "FTH" visible = false layout_mode = 1 anchors_preset = 1 -anchor_left = 0.5 -anchor_right = 0.5 -offset_left = -33.0 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -74.0 offset_top = -11.0 -offset_right = 33.0 +offset_right = -10.0 offset_bottom = 9.0 -grow_horizontal = 2 +grow_horizontal = 0 mouse_filter = 2 theme_type_variation = &"ChosenTag" text = "CHOSEN" @@ -853,7 +864,7 @@ text = "⟳ re-roll" layout_mode = 2 theme_type_variation = &"Accent" theme_override_font_sizes/font_size = 15 -text = "rolled 3d6 — that roll is your floor, spend up only" +text = "rolled 3d6, never below 8 — that roll is your floor, spend up only" [node name="Cards" type="HBoxContainer" parent="Split/Sheet/Body/AbilitySection"] layout_mode = 2 @@ -863,7 +874,7 @@ theme_override_constants/separation = 12 custom_minimum_size = Vector2(0, 132) layout_mode = 2 size_flags_horizontal = 3 -theme_type_variation = &"ParchmentCard" +theme_type_variation = &"AbilityCard" [node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0"] layout_mode = 2 @@ -906,18 +917,24 @@ layout_mode = 2 theme_type_variation = &"ParchmentButton" text = "+" -[node name="Tag" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0"] +[node name="Overlay" type="Control" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0"] +layout_mode = 2 +mouse_filter = 2 + +[node name="Tag" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0/Overlay"] visible = false layout_mode = 1 -anchors_preset = 1 +anchors_preset = 5 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -32.0 -offset_top = -10.0 -offset_right = 32.0 -offset_bottom = 8.0 +offset_left = -42.0 +offset_top = -26.0 +offset_right = 42.0 +offset_bottom = -6.0 grow_horizontal = 2 mouse_filter = 2 +horizontal_alignment = 1 +vertical_alignment = 1 theme_type_variation = &"PrimaryTag" text = "PRIMARY" @@ -925,7 +942,7 @@ text = "PRIMARY" custom_minimum_size = Vector2(0, 132) layout_mode = 2 size_flags_horizontal = 3 -theme_type_variation = &"ParchmentCard" +theme_type_variation = &"AbilityCard" [node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab1"] layout_mode = 2 @@ -968,18 +985,24 @@ layout_mode = 2 theme_type_variation = &"ParchmentButton" text = "+" -[node name="Tag" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab1"] +[node name="Overlay" type="Control" parent="Split/Sheet/Body/AbilitySection/Cards/Ab1"] +layout_mode = 2 +mouse_filter = 2 + +[node name="Tag" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab1/Overlay"] visible = false layout_mode = 1 -anchors_preset = 1 +anchors_preset = 5 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -32.0 -offset_top = -10.0 -offset_right = 32.0 -offset_bottom = 8.0 +offset_left = -42.0 +offset_top = -26.0 +offset_right = 42.0 +offset_bottom = -6.0 grow_horizontal = 2 mouse_filter = 2 +horizontal_alignment = 1 +vertical_alignment = 1 theme_type_variation = &"PrimaryTag" text = "PRIMARY" @@ -987,7 +1010,7 @@ text = "PRIMARY" custom_minimum_size = Vector2(0, 132) layout_mode = 2 size_flags_horizontal = 3 -theme_type_variation = &"ParchmentCard" +theme_type_variation = &"AbilityCard" [node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab2"] layout_mode = 2 @@ -1030,18 +1053,24 @@ layout_mode = 2 theme_type_variation = &"ParchmentButton" text = "+" -[node name="Tag" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab2"] +[node name="Overlay" type="Control" parent="Split/Sheet/Body/AbilitySection/Cards/Ab2"] +layout_mode = 2 +mouse_filter = 2 + +[node name="Tag" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab2/Overlay"] visible = false layout_mode = 1 -anchors_preset = 1 +anchors_preset = 5 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -32.0 -offset_top = -10.0 -offset_right = 32.0 -offset_bottom = 8.0 +offset_left = -42.0 +offset_top = -26.0 +offset_right = 42.0 +offset_bottom = -6.0 grow_horizontal = 2 mouse_filter = 2 +horizontal_alignment = 1 +vertical_alignment = 1 theme_type_variation = &"PrimaryTag" text = "PRIMARY" @@ -1049,7 +1078,7 @@ text = "PRIMARY" custom_minimum_size = Vector2(0, 132) layout_mode = 2 size_flags_horizontal = 3 -theme_type_variation = &"ParchmentCard" +theme_type_variation = &"AbilityCard" [node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab3"] layout_mode = 2 @@ -1092,18 +1121,24 @@ layout_mode = 2 theme_type_variation = &"ParchmentButton" text = "+" -[node name="Tag" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab3"] +[node name="Overlay" type="Control" parent="Split/Sheet/Body/AbilitySection/Cards/Ab3"] +layout_mode = 2 +mouse_filter = 2 + +[node name="Tag" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab3/Overlay"] visible = false layout_mode = 1 -anchors_preset = 1 +anchors_preset = 5 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -32.0 -offset_top = -10.0 -offset_right = 32.0 -offset_bottom = 8.0 +offset_left = -42.0 +offset_top = -26.0 +offset_right = 42.0 +offset_bottom = -6.0 grow_horizontal = 2 mouse_filter = 2 +horizontal_alignment = 1 +vertical_alignment = 1 theme_type_variation = &"PrimaryTag" text = "PRIMARY" @@ -1111,7 +1146,7 @@ text = "PRIMARY" custom_minimum_size = Vector2(0, 132) layout_mode = 2 size_flags_horizontal = 3 -theme_type_variation = &"ParchmentCard" +theme_type_variation = &"AbilityCard" [node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab4"] layout_mode = 2 @@ -1154,18 +1189,24 @@ layout_mode = 2 theme_type_variation = &"ParchmentButton" text = "+" -[node name="Tag" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab4"] +[node name="Overlay" type="Control" parent="Split/Sheet/Body/AbilitySection/Cards/Ab4"] +layout_mode = 2 +mouse_filter = 2 + +[node name="Tag" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab4/Overlay"] visible = false layout_mode = 1 -anchors_preset = 1 +anchors_preset = 5 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -32.0 -offset_top = -10.0 -offset_right = 32.0 -offset_bottom = 8.0 +offset_left = -42.0 +offset_top = -26.0 +offset_right = 42.0 +offset_bottom = -6.0 grow_horizontal = 2 mouse_filter = 2 +horizontal_alignment = 1 +vertical_alignment = 1 theme_type_variation = &"PrimaryTag" text = "PRIMARY" diff --git a/client/scripts/theme/build_game_theme.gd b/client/scripts/theme/build_game_theme.gd index 60e027e..9dd5664 100644 --- a/client/scripts/theme/build_game_theme.gd +++ b/client/scripts/theme/build_game_theme.gd @@ -50,6 +50,23 @@ static func build_theme() -> Theme: return theme +static func _pill(bg: Color, radius: int, margin_x: int, margin_y: int) -> StyleBoxFlat: + # A BADGE, not a card. _flat() bakes in the 12/6 content margin every panel and + # button on the sheet wants — on a 9px "PRIMARY" flag that padding is most of the + # control, which is what made the badge swallow the ability card's rolled value. + # The mock's flags are padding:2px 9px (PRIMARY) and 3px 8px (CHOSEN). + var s := StyleBoxFlat.new() + s.bg_color = bg + s.border_color = bg + s.set_border_width_all(0) + s.set_corner_radius_all(radius) + s.content_margin_left = margin_x + s.content_margin_right = margin_x + s.content_margin_top = margin_y + s.content_margin_bottom = margin_y + return s + + static func _flat(bg: Color, border: Color, width := 1, radius := 4) -> StyleBoxFlat: var s := StyleBoxFlat.new() s.bg_color = bg @@ -83,6 +100,20 @@ static func _fonts(theme: Theme) -> void: theme.set_font(&"font", ThemeKeys.MONO, load(MONO)) theme.set_font_size(&"font_size", ThemeKeys.MONO, 13) theme.set_color(&"font_color", ThemeKeys.MONO, Palette.MUTED_MONO) + # Card type — the name + blurb inside a select-card. These sit ON PARCHMENT, so + # they take INK, not CREAM. Godot's built-in default Label colour is WHITE, and a + # Label left bare inherits it: the race and calling names shipped invisible on the + # parchment sheet for exactly that reason. There is no such thing as an unstyled + # Label on this sheet — every one of them names a role. (mock: 19px/17px #3a2f1c + # title, 13px italic-serif #6a5a3a blurb.) + theme.set_type_variation(ThemeKeys.CARD_TITLE, "Label") + theme.set_font(&"font", ThemeKeys.CARD_TITLE, serif) + theme.set_font_size(&"font_size", ThemeKeys.CARD_TITLE, 19) + theme.set_color(&"font_color", ThemeKeys.CARD_TITLE, Palette.INK_HEADING) + theme.set_type_variation(ThemeKeys.CARD_BODY, "Label") + theme.set_font(&"font", ThemeKeys.CARD_BODY, serif) + theme.set_font_size(&"font_size", ThemeKeys.CARD_BODY, 13) + theme.set_color(&"font_color", ThemeKeys.CARD_BODY, Palette.INK_MUTED) static func _title_type(theme: Theme) -> void: @@ -231,21 +262,38 @@ static func _tags_and_primary(theme: Theme) -> void: # calling's primary attribute wears. Label takes a `normal` stylebox in Godot 4. var chosen := ThemeKeys.CHOSEN_TAG theme.set_type_variation(chosen, "Label") - theme.set_stylebox(&"normal", chosen, _flat(Palette.BLOOD, Palette.BLOOD, 1, 5)) + theme.set_stylebox(&"normal", chosen, _pill(Palette.BLOOD, 5, 8, 3)) theme.set_font(&"font", chosen, load(MONO)) theme.set_font_size(&"font_size", chosen, 10) theme.set_color(&"font_color", chosen, Palette.CREAM_BRIGHT) var primary_tag := ThemeKeys.PRIMARY_TAG theme.set_type_variation(primary_tag, "Label") - theme.set_stylebox(&"normal", primary_tag, _flat(Palette.INK_GOLD, Palette.INK_GOLD, 1, 5)) + theme.set_stylebox(&"normal", primary_tag, _pill(Palette.INK_GOLD, 5, 9, 2)) theme.set_font(&"font", primary_tag, load(MONO)) theme.set_font_size(&"font_size", primary_tag, 9) theme.set_color(&"font_color", primary_tag, Palette.CREAM_BRIGHT) + # The ability card in both states. It is NOT a plain ParchmentCard: it wears the PRIMARY + # flag on its top edge, and _flat()'s 6px top margin puts the "STR" key label directly + # under the badge — they collided. The mock's card is padding:16px 10px 14px, and that + # 16px IS the headroom the badge needs. ParchmentCard cannot simply be retuned: the + # shell shares it, and the shell has no badge. + theme.set_stylebox(&"panel", ThemeKeys.ABILITY_CARD, + _ability_card(Palette.PARCHMENT_BORDER, 1)) + theme.set_type_variation(ThemeKeys.ABILITY_CARD, "PanelContainer") theme.set_type_variation(ThemeKeys.PRIMARY_CARD, "PanelContainer") theme.set_stylebox(&"panel", ThemeKeys.PRIMARY_CARD, - _flat(Palette.PARCHMENT_CARD, Palette.INK_GOLD, 2, 12)) + _ability_card(Palette.INK_GOLD, 2)) + + +static func _ability_card(border: Color, width: int) -> StyleBoxFlat: + var s := _flat(Palette.PARCHMENT_CARD, border, width, 12) + s.content_margin_top = 16 + s.content_margin_bottom = 14 + s.content_margin_left = 10 + s.content_margin_right = 10 + return s static func _tiles(theme: Theme) -> void: diff --git a/client/scripts/theme/theme_keys.gd b/client/scripts/theme/theme_keys.gd index 8bffbf6..5358793 100644 --- a/client/scripts/theme/theme_keys.gd +++ b/client/scripts/theme/theme_keys.gd @@ -21,6 +21,11 @@ const SKILL_CHIP := &"SkillChip" # toggle: unpicked / picked / granted-so- const CHOSEN_TAG := &"ChosenTag" # the little crimson "CHOSEN" flag const PRIMARY_TAG := &"PrimaryTag" # the little gold "PRIMARY" flag const PRIMARY_CARD := &"PrimaryCard" # the ability card ringed as the calling's primary +const ABILITY_CARD := &"AbilityCard" # ...and its UNringed state. Not ParchmentCard: the + # ability card carries a badge on its top edge and + # needs the headroom for it (mock padding-top 16 vs + # the generic card's 6), and ParchmentCard is shared + # with the shell. ## Font roles (Label type-variations carrying a font, not a stylebox). Kept ## separate from ALL below, which is stylebox variations + their base type only. @@ -30,6 +35,8 @@ const MONO := &"Mono" const TITLE_LOGO := &"TitleLogo" const TITLE_KICKER := &"TitleKicker" const SECTION_LABEL := &"SectionLabel" +const CARD_TITLE := &"CardTitle" # a select-card's name, dark ink ON PARCHMENT +const CARD_BODY := &"CardBody" # a select-card's blurb, muted ink ON PARCHMENT ## Every FONT role + the base Control type it decorates. Deliberately NOT folded ## into ALL — ALL's "stylebox variations only" contract is relied on elsewhere @@ -45,6 +52,8 @@ const FONT_ROLES := { TITLE_LOGO: "Label", TITLE_KICKER: "Label", SECTION_LABEL: "Label", + CARD_TITLE: "Label", + CARD_BODY: "Label", } ## Base Control types the builder styles DIRECTLY — no set_type_variation is @@ -79,4 +88,5 @@ const ALL := { CHOSEN_TAG: "Label", PRIMARY_TAG: "Label", PRIMARY_CARD: "PanelContainer", + ABILITY_CARD: "PanelContainer", } diff --git a/client/scripts/ui/creation/character_creation.gd b/client/scripts/ui/creation/character_creation.gd index e25c363..29c0b53 100644 --- a/client/scripts/ui/creation/character_creation.gd +++ b/client/scripts/ui/creation/character_creation.gd @@ -258,8 +258,12 @@ func _bind_abilities() -> void: int(rolled[stat]), (" +%d" % spent) if spent > 0 else ""] card.get_node("Box/Buttons/Plus").disabled = not draft.can_increment(stat) card.get_node("Box/Buttons/Minus").disabled = not draft.can_decrement(stat) - card.get_node("Tag").visible = (stat == primary) - card.theme_type_variation = ThemeKeys.PRIMARY_CARD if stat == primary else ThemeKeys.PARCHMENT_CARD + # Overlay/Tag, not Tag: the ability card is a PanelContainer, and a Container + # force-fits every child it has — which stretched the badge across the card and + # parked it on top of the rolled value. The Control overlay is not a Container, + # so the badge's anchors survive and it rides the card's top edge (ADR 0001). + card.get_node("Overlay/Tag").visible = (stat == primary) + card.theme_type_variation = ThemeKeys.PRIMARY_CARD if stat == primary else ThemeKeys.ABILITY_CARD func _bind_bay() -> void: diff --git a/client/tests/unit/test_character_creation_screen.gd b/client/tests/unit/test_character_creation_screen.gd index f5eb251..c18266a 100644 --- a/client/tests/unit/test_character_creation_screen.gd +++ b/client/tests/unit/test_character_creation_screen.gd @@ -2,6 +2,7 @@ extends "res://addons/gut/test.gd" const SCENE := "res://scenes/creation/CharacterCreation.tscn" const ContentDB = preload("res://scripts/content/content_db.gd") +const NewGame = preload("res://scripts/newgame/new_game.gd") func _world() -> ContentDB: @@ -592,9 +593,9 @@ func test_ability_card_surfaces_the_calling_s_primary_stat(): var stat: String = Attributes.IDS[i] var card: PanelContainer = s._ability_cards[i] var is_primary := (stat == primary) - assert_eq(card.get_node("Tag").visible, is_primary, + assert_eq(card.get_node("Overlay/Tag").visible, is_primary, "%s's Tag must show only when it is the calling's primary stat" % stat) - var expected_variation: StringName = ThemeKeys.PRIMARY_CARD if is_primary else ThemeKeys.PARCHMENT_CARD + var expected_variation: StringName = ThemeKeys.PRIMARY_CARD if is_primary else ThemeKeys.ABILITY_CARD assert_eq(card.theme_type_variation, expected_variation, "%s's card style must reflect whether it is the primary stat" % stat) @@ -610,3 +611,151 @@ func test_pressing_enter_on_an_illegal_draft_emits_nothing(): s._enter.pressed.emit() assert_signal_not_emitted(s, "creation_confirmed", "an illegal draft must never reach creation_confirmed, even when Enter fires anyway") + + +# ------------------------------------------- the screen a human actually has to read + +## Luminance gap a Label must clear against the surface it is drawn on. Every styled +## Label on the sheet measures >= 0.419 (the dimmest is SectionLabel's ink); an UNSTYLED +## Label — Godot's built-in Label colour is WHITE — measures 0.112 on parchment. The +## threshold sits in that gap, nearer the failure so it cannot be squeaked past. +const MIN_CONTRAST := 0.30 + + +func _labels_under(n: Node, out: Array = []) -> Array: + for c in n.get_children(): + if c is Label: + out.append(c) + _labels_under(c, out) + return out + + +func _laid_out() -> void: + ## Geometry assertions are meaningless until the container tree has actually sorted. + ## On the frame add_child() runs, a race card reports 102px wide and its autowrapped + ## blurb reports a 2508px minimum height (one unwrapped line) — numbers that would + ## make a size guard fail for the wrong reason, or pass for one. + await get_tree().process_frame + await get_tree().process_frame + + +func test_no_label_on_the_parchment_sheet_is_unreadable(): + # The race and calling names, and the race blurbs, SHIPPED INVISIBLE: 15 Labels were + # left with no type-variation, so they inherited Godot's default Label colour — white + # — and rendered white-on-parchment. The trait line beneath them was the only readable + # string on the card. Nothing in the suite could see it, because every assertion was + # about text CONTENT, and the content was correct. It was the colour that was wrong. + # + # This is TOTAL: no Label is skipped. A Label that paints its own background (the + # CHOSEN / PRIMARY pills) is judged against THAT background rather than the sheet, so + # it is checked, not exempted — a whitelist here would be the same hole traps.md #16 + # describes, where the guard's own `continue` covered the gap it was hunting. + var s := _screen() + var sheet: Control = s.get_node("Split/Sheet") + var parchment := Palette.SHEET_TOP.get_luminance() + + var checked := 0 + for lab in _labels_under(sheet): + var col: Color = lab.get_theme_color(&"font_color") + var bg := parchment + var surface := "the parchment sheet" + var sb: StyleBox = lab.get_theme_stylebox(&"normal") + if sb is StyleBoxFlat and (sb as StyleBoxFlat).bg_color.a > 0.0: + bg = (sb as StyleBoxFlat).bg_color.get_luminance() + surface = "its own pill" + checked += 1 + assert_gt(absf(col.get_luminance() - bg), MIN_CONTRAST, + "'%s' (variation '%s') is unreadable on %s: text luminance %.3f vs surface %.3f" % [ + lab.name, str(lab.theme_type_variation), surface, col.get_luminance(), bg]) + + # 15 Labels carried the bug. If this sweep ever walks fewer than that it has stopped + # reaching the cards, and a green result would mean nothing. + assert_gt(checked, 15, "the sweep must actually reach the sheet's labels, not an empty subtree") + + +func test_the_primary_badge_rides_the_card_edge_instead_of_covering_the_roll(): + # The ability card is a PanelContainer. A Container FORCE-FITS its children, so the + # badge's authored anchors were dead letters: it stretched to the card's full content + # width (200 of 224px) and centred itself vertically, straight across the rolled value + # — measured, not guessed. Nesting it under a plain Control (NOT a Container) restores + # absolute positioning. These three assertions are the three ways that regressed. + var s := _screen() + _complete_a_valid_draft(s) + await _laid_out() + var primary: String = Callings.primary("sellsword") + var i: int = Attributes.IDS.find(primary) + assert_gt(i, -1, "the sellsword's primary must be a real attribute, or this test proves nothing") + + var card: PanelContainer = s._ability_cards[i] + var tag: Label = card.get_node("Overlay/Tag") + var value: Label = card.get_node("Box/Value") + assert_true(tag.visible, "the primary card must show its badge, or the geometry below is untested") + + var tag_r := tag.get_global_rect() + var card_r := card.get_global_rect() + var value_r := value.get_global_rect() + + assert_lt(tag_r.position.y, card_r.position.y, + "the badge must RIDE the card's top edge, not sit inside the card") + assert_lt(tag_r.size.x, card_r.size.x * 0.6, + "the badge is a flag, not a banner — it stretched to the card's full width (%.0f of %.0f)" % [ + tag_r.size.x, card_r.size.x]) + assert_false(tag_r.intersects(value_r), + "the badge is covering the rolled value: badge %s vs value %s" % [tag_r, value_r]) + + +func test_no_card_overflows_its_own_border(): + # "+1 skill of choice" and "Claws · Keen scent" rendered BELOW the race card's bottom + # border: the card's height is a fixed custom_minimum_size, and its Box is anchored + # inside it rather than being a container child — so content CANNOT push the card + # taller, it just spills out. Every card must be authored tall enough for its own text. + var s := _screen() + await _laid_out() + var cards: Array = [] + cards.append_array(s._race_cards) + cards.append_array(s._calling_cards) + assert_gt(cards.size(), 4, "no cards reached the sweep") + + # Measure against the CARD, never against the Box's own size. Godot clamps a Control's + # size UP to its combined minimum, so box.size.y is forced equal to the very number it + # would be compared against — `needed <= box.size.y` is a tautology that stays green + # with the card at its original, overflowing 108px. It was written that way first and + # it passed against the bug it names (traps.md: a green suite is evidence only if the + # test can fail). The card's height is the thing the text actually spills out of. + for card in cards: + var box: VBoxContainer = card.get_node("Box") + var needed := box.get_combined_minimum_size().y + # The Box is anchored inside the card; offset_bottom is negative, so this is the + # card's height less its top and bottom insets. + var available: float = card.size.y - box.offset_top + box.offset_bottom + assert_lte(needed, available, + "'%s' overflows its card: content needs %.0fpx, the card leaves %.0fpx" % [ + box.get_node("Name").text, needed, available]) + + +func test_the_hint_label_advertises_the_floor_the_roll_actually_enforces(): + # The label used to say "rolled 3d6" and stop there, but the roll is maxi(8, 3d6) + # (new_game.gd) — so a player who rolled low read a label that looked like it was + # lying to him. The label now names the floor, and this pins it TO THE CODE. + # + # The floor is OBSERVED, never hardcoded: roll a deterministic spread of seeds and + # take the minimum the player can actually be dealt. Retune maxi(8, ..) to 7 and the + # observed floor becomes 7, the label still claims 8, and this goes red. Delete the + # clamp and the floor falls to 3 — red. Reword the label past the claim — red. + # + # It costs the phrase "never below N": reword that and update this string, do not + # delete the guard. The number must keep coming from the roll. + var observed_floor := 99 + for seed_value in range(1, 201): # deterministic — no RNG in the test itself + for stat in Attributes.IDS: + observed_floor = mini(observed_floor, int(NewGame.roll_attributes(seed_value)[stat])) + + # ~16% of 3d6 lands at or under 7, so across 1000 rolls the minimum is the CLAMP, + # not the luck of the draw. Assert the clamp is actually binding rather than trusting + # that: an unclamped 3d6 reaches 3, and a floor at or below 3 would make the pin vacuous. + assert_true(observed_floor > 3, + "the roll's minimum is %d — the clamp is not binding, so the label's floor claim is a lie" % observed_floor) + + var hint: Label = _screen().get_node("Split/Sheet/Body/AbilitySection/Head/Hint") + assert_string_contains(hint.text, "never below %d" % observed_floor) + assert_string_contains(hint.text, "3d6") diff --git a/content/world/callings/cutpurse.json b/content/world/callings/cutpurse.json index 296da5e..23013d3 100644 --- a/content/world/callings/cutpurse.json +++ b/content/world/callings/cutpurse.json @@ -1 +1 @@ -{ "id": "cutpurse", "name": "Cutpurse", "blurb": "Purses, locks, confidences — you have taken all three. The trick was never the hands. It was knowing which pocket was worth it.", "fragment": "quick fingers, quicker exits, and a knife for the rest." } +{ "id": "cutpurse", "name": "Cutpurse", "blurb": "Purses, locks, confidences — you have taken all three. The trick was never the hands. It was knowing which pocket was worth it.", "fragment": "you pick your man and take what he will not miss until morning, and the towns where you picked wrong still have your description at the gate." } diff --git a/docs/traps.md b/docs/traps.md index 835835f..6c1f6e5 100644 --- a/docs/traps.md +++ b/docs/traps.md @@ -318,6 +318,52 @@ human a wall-clock timeout to even learn something is wrong. --- +## 17. Asserting against a value the engine derives from the thing under test + +**M4-b, the F6 pass.** The race cards were authored 108px tall, and their text — the +trait line — visibly rendered *below the card's bottom border* in the running game. The +guard written for it read: + +```gdscript +var needed := box.get_combined_minimum_size().y # 114 +assert_lte(needed, box.size.y) # box.size.y is ALSO 114 +``` + +It passed. Not against the fix — **against the bug.** The card was put back to its +overflowing 108px and the suite stayed green, 29/29. + +The reason is a Godot rule that is easy to forget: **`Control.size` is clamped UP to +`get_combined_minimum_size()`.** The `Box` is anchored inside the card, so a 108px card +leaves it 82px of room — but the Box does not *become* 82px tall. It reports 114, its own +minimum, and quietly overflows the card's border. So the assertion compared 114 against +114: `needed <= box.size.y` is not a size check at all, it is `x <= x`. It could never +have failed, for any card, at any height. + +**The guard:** assert against the container that actually *clips* — the card — not +against the child, whose size the engine has already reconciled with the very number you +are testing: + +```gdscript +var available: float = card.size.y - box.offset_top + box.offset_bottom # 82 +assert_lte(needed, available) # 114 <= 82 -> RED +``` + +The general shape: **if the engine derives B from A, then `assert(A <= B)` is a +tautology, not a test.** Layout, min-size, and any auto-fitted value are all A-and-B. +Reach for a number the engine computed from a *different* source — the parent, the +authored constant, the viewport — or the assertion is checking that arithmetic works. + +Related: this is trap 11's family (an assertion already true before the action ran), but +it is worse — this one is true *by construction*, so no amount of driving state can +redeem it. + +**Corollary, same fix:** geometry assertions are meaningless before the container tree has +sorted. On the frame `add_child()` runs, a race card reports **102px** wide and its +autowrapped blurb reports a **2508px** minimum height. `await get_tree().process_frame` +twice, or the guard fails for a reason that has nothing to do with the bug. + +--- + ## The checklist this all reduces to - Can the new test **fail**? Re-break the code and watch it. If it can't fail, it isn't a test. @@ -342,3 +388,8 @@ human a wall-clock timeout to even learn something is wrong. - Does the test **press the node** (emit the real signal), or call the handler directly? - Did the test **count** move by the expected amount — not just the green banner? - Can the test **hang** on a broken precondition? Bound every loop. +- Is the assertion's right-hand side **derived by the engine from its left-hand side** + (`Control.size` vs `get_combined_minimum_size()`)? Then it is `x <= x`. Measure against + the parent that clips, not the child that reports. +- Is it a **geometry** assertion? Let the container tree sort first, or it measures an + unlaid-out node. From 8f3aa75fa93e10aaec213bc7cb2da3ab6c275829 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Tue, 14 Jul 2026 20:09:29 -0500 Subject: [PATCH 20/21] chore(warnings): silence the four pre-existing script warnings the creation screen surfaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not errors and not regressions — Godot printed them on every script reload and they predate M4-b. Cleared so a real warning is not lost in the noise: - Redundant `const ContentDB`/`const NewGame` preloads that shadowed their own `class_name` globals (character_creation.gd, creation_draft.gd) — the class is already global, so the const bought nothing. - Two `log` locals shadowing the built-in `log()` — renamed to `out` (canon_log.from_dict) and `canon` (NewGame.construct); the "log" dict KEY is the public contract and is unchanged. - currency.format's copper->denomination division is integer BY DESIGN (the remainder is carried, not lost) — annotated `@warning_ignore` to say so. 319 client tests green; headless parse reports none of the four sites. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/scripts/canon_log/canon_log.gd | 18 +++++++++--------- client/scripts/newgame/new_game.gd | 16 ++++++++-------- client/scripts/state/currency.gd | 4 +++- .../scripts/ui/creation/character_creation.gd | 2 -- client/scripts/ui/creation/creation_draft.gd | 2 -- 5 files changed, 20 insertions(+), 22 deletions(-) diff --git a/client/scripts/canon_log/canon_log.gd b/client/scripts/canon_log/canon_log.gd index 01ca212..661de7a 100644 --- a/client/scripts/canon_log/canon_log.gd +++ b/client/scripts/canon_log/canon_log.gd @@ -99,17 +99,17 @@ static func from_dict(d: Dictionary) -> CanonLog: if v != SCHEMA_VERSION: push_error("unsupported canon_log schema_version: %s" % v) return null - var log := CanonLog.new() - log.player = LogPlayer.from_dict(d.get("player", {})) - log.location = LogLocation.from_dict(d.get("location", {})) + var out := CanonLog.new() + out.player = LogPlayer.from_dict(d.get("player", {})) + out.location = LogLocation.from_dict(d.get("location", {})) for m in d.get("party", []): - log.party.append(PartyMember.from_dict(m)) + out.party.append(PartyMember.from_dict(m)) for e in d.get("recent_events", []): - log.push_event(e) + out.push_event(e) for f in d.get("established_facts", []): - log.add_fact(f) + out.add_fact(f) for q in d.get("active_quests", []): - log.active_quests.append(Quest.from_dict(q)) + out.active_quests.append(Quest.from_dict(q)) for h in d.get("humiliations", []): - log.humiliations.append(Humiliation.from_dict(h)) - return log + out.humiliations.append(Humiliation.from_dict(h)) + return out diff --git a/client/scripts/newgame/new_game.gd b/client/scripts/newgame/new_game.gd index 0deba42..a55a7bd 100644 --- a/client/scripts/newgame/new_game.gd +++ b/client/scripts/newgame/new_game.gd @@ -58,31 +58,31 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary sheet.mp = sheet.max_mp() state.sheet = sheet - var log := CanonLog.new() - log.player = LogPlayer.new(str(creation.get("name", "")), sheet.race_id, + var canon := CanonLog.new() + canon.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", "")) + canon.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)))) + canon.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) + canon.push_event(line) for fact in origin.get("opening_facts", []): - log.add_fact(fact) + canon.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", + canon.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 @@ -90,7 +90,7 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary 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} + return {"ok": true, "errors": [], "log": canon, "state": state} static func roll_attributes(character_seed: int) -> Dictionary: diff --git a/client/scripts/state/currency.gd b/client/scripts/state/currency.gd index 4d19d0b..18834c4 100644 --- a/client/scripts/state/currency.gd +++ b/client/scripts/state/currency.gd @@ -31,10 +31,12 @@ static func format(copper: int) -> String: return "0c" var parts: Array[String] = [] var left := copper - var gold := left / GOLD # int division + @warning_ignore("integer_division") # denomination math — the remainder is carried, not lost + var gold := left / GOLD if gold > 0: parts.append("%dg" % gold) left -= gold * GOLD + @warning_ignore("integer_division") var silver := left / SILVER if silver > 0: parts.append("%ds" % silver) diff --git a/client/scripts/ui/creation/character_creation.gd b/client/scripts/ui/creation/character_creation.gd index 29c0b53..10bd2bd 100644 --- a/client/scripts/ui/creation/character_creation.gd +++ b/client/scripts/ui/creation/character_creation.gd @@ -14,8 +14,6 @@ extends Control signal creation_confirmed(creation: Dictionary) -const ContentDB = preload("res://scripts/content/content_db.gd") - ## Shown under the CTA when the draft is legal. When it is NOT legal, the first ## validation error takes this slot instead — the player is told what is wrong, in ## the same place, in the same dry voice. §13: even the nag is content. diff --git a/client/scripts/ui/creation/creation_draft.gd b/client/scripts/ui/creation/creation_draft.gd index 7bb55d5..b0c32c8 100644 --- a/client/scripts/ui/creation/creation_draft.gd +++ b/client/scripts/ui/creation/creation_draft.gd @@ -14,8 +14,6 @@ extends RefCounted ## §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") - var name: String = "" var race_id: String = "" var calling_id: String = "" From 1ae62fe0db3d1d4a23400313b6831cbbf414f076 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Tue, 14 Jul 2026 20:33:08 -0500 Subject: [PATCH 21/21] feat(creation): distinguish granted skills as owned, and enlarge the small text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two F6-review follow-ups from the human's read of the running screen. 1. GRANTED skill chips now read as 'you already have this', not 'unavailable'. The elf's granted `perception` shared SkillChip's dim `disabled` look with the 'picks are spent' chips, so it read as blocked. Split out a fourth state, SkillChipGranted — a solid gold fill, cream text, and a ✓ prefix (owned) — while the pool-is-spent chips keep the plain dim disabled. Applied per-chip in _bind_skills. 2. Enlarged the small mono/body/prose roles for readability on sub-1920 laptops (the 1920 design canvas scales DOWN on a smaller screen, so 11-13px text got tiny): DM prose 20->22, MONO 13->14, section labels 13->14, card title 19->20, card body 13->14, chips 11->12, skill chips 12->14, the two flag pills +1. Big serif headings unchanged. Two layout consequences the guards caught and I fixed, not silenced: - Race cards overflowed the taller blurb -> card min-height 140->180 (the overflow guard, which measures against the card the engine does NOT derive from the text, went red first; traps.md #17). - The 9-chip Human bonus row, wider at the bigger font, blew the whole sheet past the viewport -> BonusRow HBoxContainer -> HFlowContainer so it wraps instead of forcing the sheet wide. Nothing depends on the row's type (tests check child count + visibility only). 319 client tests green (theme drift guard + overflow guard included); screenshot- verified at 1280x720: granted chip owned, no clipping, bonus row wraps cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/assets/theme/game_theme.tres | 78 +++++++++++++------ client/scenes/creation/CharacterCreation.tscn | 13 ++-- client/scripts/theme/build_game_theme.gd | 39 +++++++--- client/scripts/theme/theme_keys.gd | 4 +- .../scripts/ui/creation/character_creation.gd | 11 ++- 5 files changed, 101 insertions(+), 44 deletions(-) diff --git a/client/assets/theme/game_theme.tres b/client/assets/theme/game_theme.tres index 889c6fe..c4ad4de 100644 --- a/client/assets/theme/game_theme.tres +++ b/client/assets/theme/game_theme.tres @@ -504,9 +504,27 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_nufy3"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nufy3"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 +bg_color = Color(0.56078434, 0.41568628, 0.16470589, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.56078434, 0.41568628, 0.16470589, 1) +corner_radius_top_left = 14 +corner_radius_top_right = 14 +corner_radius_bottom_right = 14 +corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0d7vs"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_0d7vs"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_3fnfk"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2txdw"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -522,7 +540,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_3fnfk"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_w7qkp"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -538,7 +556,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2txdw"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_rthnr"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -554,9 +572,9 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_w7qkp"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_ta3oh"] -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_rthnr"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ohwwq"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -572,7 +590,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ta3oh"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ismtf"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -588,7 +606,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ohwwq"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_gs634"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -615,15 +633,15 @@ Accent/font_sizes/font_size = 24 Accent/fonts/font = ExtResource("1_605w4") CardBody/base_type = &"Label" CardBody/colors/font_color = Color(0.41568628, 0.3529412, 0.22745098, 1) -CardBody/font_sizes/font_size = 13 +CardBody/font_sizes/font_size = 14 CardBody/fonts/font = ExtResource("2_4lwr2") CardTitle/base_type = &"Label" CardTitle/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1) -CardTitle/font_sizes/font_size = 19 +CardTitle/font_sizes/font_size = 20 CardTitle/fonts/font = ExtResource("2_4lwr2") Chip/base_type = &"Button" Chip/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1) -Chip/font_sizes/font_size = 11 +Chip/font_sizes/font_size = 12 Chip/fonts/font = ExtResource("3_eckwq") Chip/styles/focus = SubResource("StyleBoxEmpty_q5gpk") Chip/styles/hover = SubResource("StyleBoxFlat_bbpmc") @@ -631,7 +649,7 @@ Chip/styles/normal = SubResource("StyleBoxFlat_73lme") Chip/styles/pressed = SubResource("StyleBoxFlat_js3tr") ChosenTag/base_type = &"Label" ChosenTag/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1) -ChosenTag/font_sizes/font_size = 10 +ChosenTag/font_sizes/font_size = 11 ChosenTag/fonts/font = ExtResource("3_eckwq") ChosenTag/styles/normal = SubResource("StyleBoxFlat_ube3r") DarkPanel/base_type = &"PanelContainer" @@ -652,7 +670,7 @@ ItemTileEmpty/base_type = &"PanelContainer" ItemTileEmpty/styles/panel = SubResource("StyleBoxFlat_lw8lk") Mono/base_type = &"Label" Mono/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1) -Mono/font_sizes/font_size = 13 +Mono/font_sizes/font_size = 14 Mono/fonts/font = ExtResource("3_eckwq") ParchmentButton/base_type = &"Button" ParchmentButton/colors/font_color = Color(0.2901961, 0.24705882, 0.17254902, 1) @@ -677,16 +695,16 @@ PrimaryCard/base_type = &"PanelContainer" PrimaryCard/styles/panel = SubResource("StyleBoxFlat_najyk") PrimaryTag/base_type = &"Label" PrimaryTag/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1) -PrimaryTag/font_sizes/font_size = 9 +PrimaryTag/font_sizes/font_size = 10 PrimaryTag/fonts/font = ExtResource("3_eckwq") PrimaryTag/styles/normal = SubResource("StyleBoxFlat_jtb1k") RichTextLabel/colors/default_color = Color(0.2901961, 0.24705882, 0.17254902, 1) -RichTextLabel/font_sizes/normal_font_size = 20 +RichTextLabel/font_sizes/normal_font_size = 22 RichTextLabel/fonts/italics_font = ExtResource("4_i0b23") RichTextLabel/fonts/normal_font = ExtResource("2_4lwr2") SectionLabel/base_type = &"Label" SectionLabel/colors/font_color = Color(0.5411765, 0.46666667, 0.28235295, 1) -SectionLabel/font_sizes/font_size = 13 +SectionLabel/font_sizes/font_size = 14 SectionLabel/fonts/font = ExtResource("3_eckwq") SelectCard/base_type = &"Button" SelectCard/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1) @@ -701,25 +719,35 @@ SkillChip/colors/font_color = Color(0.3529412, 0.2627451, 0.14901961, 1) SkillChip/colors/font_disabled_color = Color(0.6627451, 0.5803922, 0.39215687, 1) SkillChip/colors/font_hover_color = Color(0.3529412, 0.2627451, 0.14901961, 1) SkillChip/colors/font_pressed_color = Color(0.9411765, 0.9019608, 0.8156863, 1) -SkillChip/font_sizes/font_size = 12 +SkillChip/font_sizes/font_size = 14 SkillChip/fonts/font = ExtResource("3_eckwq") SkillChip/styles/disabled = SubResource("StyleBoxFlat_onsig") SkillChip/styles/focus = SubResource("StyleBoxEmpty_gw0t7") SkillChip/styles/hover = SubResource("StyleBoxFlat_88l5g") SkillChip/styles/normal = SubResource("StyleBoxFlat_f8qac") SkillChip/styles/pressed = SubResource("StyleBoxFlat_u6gcs") +SkillChipGranted/base_type = &"Button" +SkillChipGranted/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1) +SkillChipGranted/colors/font_disabled_color = Color(0.9411765, 0.9019608, 0.8156863, 1) +SkillChipGranted/font_sizes/font_size = 14 +SkillChipGranted/fonts/font = ExtResource("3_eckwq") +SkillChipGranted/styles/disabled = SubResource("StyleBoxFlat_nufy3") +SkillChipGranted/styles/focus = SubResource("StyleBoxEmpty_0d7vs") +SkillChipGranted/styles/hover = SubResource("StyleBoxFlat_nufy3") +SkillChipGranted/styles/normal = SubResource("StyleBoxFlat_nufy3") +SkillChipGranted/styles/pressed = SubResource("StyleBoxFlat_nufy3") Tab/base_type = &"Button" Tab/colors/font_color = Color(0.41568628, 0.3529412, 0.22745098, 1) -Tab/styles/focus = SubResource("StyleBoxEmpty_nufy3") -Tab/styles/hover = SubResource("StyleBoxFlat_0d7vs") -Tab/styles/normal = SubResource("StyleBoxFlat_3fnfk") -Tab/styles/pressed = SubResource("StyleBoxFlat_2txdw") +Tab/styles/focus = SubResource("StyleBoxEmpty_3fnfk") +Tab/styles/hover = SubResource("StyleBoxFlat_2txdw") +Tab/styles/normal = SubResource("StyleBoxFlat_w7qkp") +Tab/styles/pressed = SubResource("StyleBoxFlat_rthnr") TabActive/base_type = &"Button" TabActive/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1) -TabActive/styles/focus = SubResource("StyleBoxEmpty_w7qkp") -TabActive/styles/hover = SubResource("StyleBoxFlat_rthnr") -TabActive/styles/normal = SubResource("StyleBoxFlat_ta3oh") -TabActive/styles/pressed = SubResource("StyleBoxFlat_ohwwq") +TabActive/styles/focus = SubResource("StyleBoxEmpty_ta3oh") +TabActive/styles/hover = SubResource("StyleBoxFlat_ohwwq") +TabActive/styles/normal = SubResource("StyleBoxFlat_ismtf") +TabActive/styles/pressed = SubResource("StyleBoxFlat_gs634") TitleKicker/base_type = &"Label" TitleKicker/colors/font_color = Color(0.6627451, 0.5176471, 0.24705882, 1) TitleKicker/font_sizes/font_size = 13 diff --git a/client/scenes/creation/CharacterCreation.tscn b/client/scenes/creation/CharacterCreation.tscn index 403f675..3dc65bb 100644 --- a/client/scenes/creation/CharacterCreation.tscn +++ b/client/scenes/creation/CharacterCreation.tscn @@ -145,7 +145,7 @@ layout_mode = 2 theme_override_constants/separation = 14 [node name="Race0" type="Button" parent="Split/Sheet/Body/RaceSection/Cards"] -custom_minimum_size = Vector2(0, 140) +custom_minimum_size = Vector2(0, 180) layout_mode = 2 size_flags_horizontal = 3 toggle_mode = true @@ -200,7 +200,7 @@ theme_type_variation = &"ChosenTag" text = "CHOSEN" [node name="Race1" type="Button" parent="Split/Sheet/Body/RaceSection/Cards"] -custom_minimum_size = Vector2(0, 140) +custom_minimum_size = Vector2(0, 180) layout_mode = 2 size_flags_horizontal = 3 toggle_mode = true @@ -255,7 +255,7 @@ theme_type_variation = &"ChosenTag" text = "CHOSEN" [node name="Race2" type="Button" parent="Split/Sheet/Body/RaceSection/Cards"] -custom_minimum_size = Vector2(0, 140) +custom_minimum_size = Vector2(0, 180) layout_mode = 2 size_flags_horizontal = 3 toggle_mode = true @@ -310,7 +310,7 @@ theme_type_variation = &"ChosenTag" text = "CHOSEN" [node name="Race3" type="Button" parent="Split/Sheet/Body/RaceSection/Cards"] -custom_minimum_size = Vector2(0, 140) +custom_minimum_size = Vector2(0, 180) layout_mode = 2 size_flags_horizontal = 3 toggle_mode = true @@ -774,9 +774,10 @@ toggle_mode = true theme_type_variation = &"SkillChip" text = "perception" -[node name="BonusRow" type="HBoxContainer" parent="Split/Sheet/Body/SkillSection"] +[node name="BonusRow" type="HFlowContainer" parent="Split/Sheet/Body/SkillSection"] layout_mode = 2 -theme_override_constants/separation = 10 +theme_override_constants/h_separation = 10 +theme_override_constants/v_separation = 8 [node name="Label" type="Label" parent="Split/Sheet/Body/SkillSection/BonusRow"] layout_mode = 2 diff --git a/client/scripts/theme/build_game_theme.gd b/client/scripts/theme/build_game_theme.gd index 9dd5664..94a4eeb 100644 --- a/client/scripts/theme/build_game_theme.gd +++ b/client/scripts/theme/build_game_theme.gd @@ -44,6 +44,7 @@ static func build_theme() -> Theme: _section_label(theme) _select_card(theme) _skill_chip(theme) + _skill_chip_granted(theme) _tags_and_primary(theme) _tiles(theme) _cards_and_panels(theme) @@ -98,7 +99,7 @@ static func _fonts(theme: Theme) -> void: # Mono chrome role. theme.set_type_variation(ThemeKeys.MONO, "Label") theme.set_font(&"font", ThemeKeys.MONO, load(MONO)) - theme.set_font_size(&"font_size", ThemeKeys.MONO, 13) + theme.set_font_size(&"font_size", ThemeKeys.MONO, 14) theme.set_color(&"font_color", ThemeKeys.MONO, Palette.MUTED_MONO) # Card type — the name + blurb inside a select-card. These sit ON PARCHMENT, so # they take INK, not CREAM. Godot's built-in default Label colour is WHITE, and a @@ -108,11 +109,11 @@ static func _fonts(theme: Theme) -> void: # title, 13px italic-serif #6a5a3a blurb.) theme.set_type_variation(ThemeKeys.CARD_TITLE, "Label") theme.set_font(&"font", ThemeKeys.CARD_TITLE, serif) - theme.set_font_size(&"font_size", ThemeKeys.CARD_TITLE, 19) + theme.set_font_size(&"font_size", ThemeKeys.CARD_TITLE, 20) theme.set_color(&"font_color", ThemeKeys.CARD_TITLE, Palette.INK_HEADING) theme.set_type_variation(ThemeKeys.CARD_BODY, "Label") theme.set_font(&"font", ThemeKeys.CARD_BODY, serif) - theme.set_font_size(&"font_size", ThemeKeys.CARD_BODY, 13) + theme.set_font_size(&"font_size", ThemeKeys.CARD_BODY, 14) theme.set_color(&"font_color", ThemeKeys.CARD_BODY, Palette.INK_MUTED) @@ -138,7 +139,7 @@ static func _rich_text(theme: Theme) -> void: # (the narration book, later quest briefs/dialogue) reads dark serif ink from here. theme.set_font(&"normal_font", "RichTextLabel", load(SERIF)) theme.set_font(&"italics_font", "RichTextLabel", load(SERIF_ITALIC)) - theme.set_font_size(&"normal_font_size", "RichTextLabel", 20) + theme.set_font_size(&"normal_font_size", "RichTextLabel", 22) theme.set_color(&"default_color", "RichTextLabel", Palette.INK_BODY) @@ -210,7 +211,7 @@ static func _chip(theme: Theme) -> void: theme.set_stylebox(&"pressed", k, _flat(Color(Palette.MUTED_MONO, 0.32), Palette.MUTED_MONO, 1, 3)) theme.set_stylebox(&"focus", k, StyleBoxEmpty.new()) theme.set_font(&"font", k, load(MONO)) - theme.set_font_size(&"font_size", k, 11) + theme.set_font_size(&"font_size", k, 12) theme.set_color(&"font_color", k, Palette.MUTED_MONO) @@ -219,7 +220,7 @@ static func _section_label(theme: Theme) -> void: # Mono's own colour is for dark surfaces; on parchment it needs the muted ink. theme.set_type_variation(ThemeKeys.SECTION_LABEL, "Label") theme.set_font(&"font", ThemeKeys.SECTION_LABEL, load(MONO)) - theme.set_font_size(&"font_size", ThemeKeys.SECTION_LABEL, 13) + theme.set_font_size(&"font_size", ThemeKeys.SECTION_LABEL, 14) theme.set_color(&"font_color", ThemeKeys.SECTION_LABEL, Palette.INK_LABEL_MUTED) @@ -250,13 +251,33 @@ static func _skill_chip(theme: Theme) -> void: theme.set_stylebox(&"disabled", k, _flat(Palette.PARCHMENT_INSET_2, Palette.PARCHMENT_BORDER_3, 1, 14)) theme.set_stylebox(&"focus", k, StyleBoxEmpty.new()) theme.set_font(&"font", k, load(MONO)) - theme.set_font_size(&"font_size", k, 12) + theme.set_font_size(&"font_size", k, 14) theme.set_color(&"font_color", k, Palette.INK_LABEL) theme.set_color(&"font_pressed_color", k, Palette.CREAM_BRIGHT) theme.set_color(&"font_hover_color", k, Palette.INK_LABEL) theme.set_color(&"font_disabled_color", k, Palette.PARCHMENT_BORDER_3) +static func _skill_chip_granted(theme: Theme) -> void: + # The FOURTH skill-chip state, split out of SkillChip's dim `disabled` look. A + # race-GRANTED skill (the elf already has perception) must read "you already have + # this" — OWNED — not "unavailable". So: a solid gold fill with cream text (the + # binding prefixes a ✓), where the pool-is-spent chips keep the plain dim disabled. + # It is inert like a disabled chip, so every interactive state paints the owned box. + var k := ThemeKeys.SKILL_CHIP_GRANTED + theme.set_type_variation(k, "Button") + var owned := _flat(Palette.INK_GOLD, Palette.INK_GOLD, 1, 14) + theme.set_stylebox(&"normal", k, owned) + theme.set_stylebox(&"hover", k, owned) + theme.set_stylebox(&"pressed", k, owned) + theme.set_stylebox(&"disabled", k, owned) + theme.set_stylebox(&"focus", k, StyleBoxEmpty.new()) + theme.set_font(&"font", k, load(MONO)) + theme.set_font_size(&"font_size", k, 14) + theme.set_color(&"font_color", k, Palette.CREAM_BRIGHT) + theme.set_color(&"font_disabled_color", k, Palette.CREAM_BRIGHT) + + static func _tags_and_primary(theme: Theme) -> void: # The two little flags that ride on a card's top edge, and the gold ring the # calling's primary attribute wears. Label takes a `normal` stylebox in Godot 4. @@ -264,14 +285,14 @@ static func _tags_and_primary(theme: Theme) -> void: theme.set_type_variation(chosen, "Label") theme.set_stylebox(&"normal", chosen, _pill(Palette.BLOOD, 5, 8, 3)) theme.set_font(&"font", chosen, load(MONO)) - theme.set_font_size(&"font_size", chosen, 10) + theme.set_font_size(&"font_size", chosen, 11) theme.set_color(&"font_color", chosen, Palette.CREAM_BRIGHT) var primary_tag := ThemeKeys.PRIMARY_TAG theme.set_type_variation(primary_tag, "Label") theme.set_stylebox(&"normal", primary_tag, _pill(Palette.INK_GOLD, 5, 9, 2)) theme.set_font(&"font", primary_tag, load(MONO)) - theme.set_font_size(&"font_size", primary_tag, 9) + theme.set_font_size(&"font_size", primary_tag, 10) theme.set_color(&"font_color", primary_tag, Palette.CREAM_BRIGHT) # The ability card in both states. It is NOT a plain ParchmentCard: it wears the PRIMARY diff --git a/client/scripts/theme/theme_keys.gd b/client/scripts/theme/theme_keys.gd index 5358793..0728c86 100644 --- a/client/scripts/theme/theme_keys.gd +++ b/client/scripts/theme/theme_keys.gd @@ -17,7 +17,8 @@ const PARCHMENT_CARD := &"ParchmentCard" const PARCHMENT_INSET := &"ParchmentInset" const DARK_PANEL := &"DarkPanel" const SELECT_CARD := &"SelectCard" # race/calling card: a Button whose `pressed` is CHOSEN -const SKILL_CHIP := &"SkillChip" # toggle: unpicked / picked / granted-so-inert +const SKILL_CHIP := &"SkillChip" # toggle: unpicked / picked / can't-pick-now (dim) +const SKILL_CHIP_GRANTED := &"SkillChipGranted" # a race-GRANTED skill: owned (gold + ✓), inert const CHOSEN_TAG := &"ChosenTag" # the little crimson "CHOSEN" flag const PRIMARY_TAG := &"PrimaryTag" # the little gold "PRIMARY" flag const PRIMARY_CARD := &"PrimaryCard" # the ability card ringed as the calling's primary @@ -85,6 +86,7 @@ const ALL := { DARK_PANEL: "PanelContainer", SELECT_CARD: "Button", SKILL_CHIP: "Button", + SKILL_CHIP_GRANTED: "Button", CHOSEN_TAG: "Label", PRIMARY_TAG: "Label", PRIMARY_CARD: "PanelContainer", diff --git a/client/scripts/ui/creation/character_creation.gd b/client/scripts/ui/creation/character_creation.gd index 10bd2bd..4739302 100644 --- a/client/scripts/ui/creation/character_creation.gd +++ b/client/scripts/ui/creation/character_creation.gd @@ -41,7 +41,7 @@ var _ability_cards: Array = [] @onready var _detail_mech: Label = $Split/Sheet/Body/CallingSection/Detail/Box/Mechanics @onready var _pool_row: HBoxContainer = $Split/Sheet/Body/SkillSection/PoolRow @onready var _pool_count: Label = $Split/Sheet/Body/SkillSection/PoolRow/Count -@onready var _bonus_row: HBoxContainer = $Split/Sheet/Body/SkillSection/BonusRow +@onready var _bonus_row: HFlowContainer = $Split/Sheet/Body/SkillSection/BonusRow @onready var _ability_row: HBoxContainer = $Split/Sheet/Body/AbilitySection/Cards @onready var _points: Label = $Split/Sheet/Body/AbilitySection/Head/Points @onready var _reroll: Button = $Split/Sheet/Body/AbilitySection/Head/Reroll @@ -225,11 +225,16 @@ func _bind_skills() -> void: chip.visible = false continue var skill: String = pool[i] + var granted := draft.is_granted(skill) chip.visible = true - chip.text = CreationCopy.skill_label(skill) + # A granted skill reads as OWNED (gold + ✓ via SkillChipGranted), not merely + # disabled — otherwise it looks identical to a "picks are spent" chip and reads + # as "unavailable" rather than "you already have this". + chip.theme_type_variation = ThemeKeys.SKILL_CHIP_GRANTED if granted else ThemeKeys.SKILL_CHIP + chip.text = ("✓ " + CreationCopy.skill_label(skill)) if granted else CreationCopy.skill_label(skill) chip.button_pressed = draft.is_picked(skill) # Inert when the race already granted it, or when the picks are spent. - chip.disabled = draft.is_granted(skill) or (not draft.is_picked(skill) and not draft.can_pick(skill)) + chip.disabled = granted or (not draft.is_picked(skill) and not draft.can_pick(skill)) _bonus_row.visible = draft.wants_bonus_skill() for i in range(_bonus_chips.size()):