Crystals Full-Screen Level-Up Panel Implementation Plan
Crystals Full-Screen Level-Up Panel Implementation Plan
Section titled “Crystals Full-Screen Level-Up Panel Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax. Also follow the project’sbh-dev-chunkritual (import → boot-check → full suite → count guard → determinism) on every sim-touching task.
Goal: In CRYSTALS mode only, replace the compact level-up card row with a full-screen decision screen — choices + best→worst ranking on the left, full build/inventory on the right, complete change-preview for the focused choice — and a 0.5s “get ready” freeze (with i-frames) on resume.
Architecture: One new pure read-only /sim method Sim.upgrade_effects(id) computes each choice’s full change-set + an impact score (sharing a WeaponThresholds.rules_met helper with the live threshold logic). A new render/UI CrystalsLevelUpPanel displays it. main shows it in crystals mode and runs the resume grace. The reactions baseline stays byte-identical (pure preview; crystals-only wiring).
Tech Stack: Godot 4.6.3 typed GDScript, GUT 9.6 headless tests.
Design spec: docs/superpowers/specs/2026-06-26-crystals-levelup-panel-design.md.
Global Constraints
Section titled “Global Constraints”/simpurity:sim/sim.gd,sim/weapon_thresholds.gdareRefCounted/pure — NO Node/Engine/Input/Time/OS/File/JSON.upgrade_effects/rules_met/rank_upgradesMUST be read-only (never mutatecrystals, weapons,_thresholds_done, or any sim state).- Determinism: these previews are read-only and the panel/resume-grace are set only by
mainin crystals mode — the determinism test never enters crystals/level-up. The survival reactions baseline (pinned intests/test_determinism_checksum.gd— READ the current value, don’t hardcode a remembered one; the other agent re-pins it often) MUST stay byte-identical. Re-run the determinism test after every sim task. - Crystals-only: all new behaviour gates on
ruleset == RULESET_CRYSTALS. Reactions/story/survival keep the existingLevelUpPaneluntouched. - DRY:
rules_metis the single definition of “which threshold rules a count-map satisfies”; the live_eval_thresholds(Task 1) and the preview (Task 2) both use it. - GUT: methods are
assert_lte/assert_gte(NOTassert_le/assert_ge— a typo silently drops the file). An un-consumedpush_errorfails the test. - New
class_namefile → rungodot --headless --path . --importbefore tests. - Trust the COUNT:
bash scripts/check-test-count.shmust report N/N. - Concurrency: the other agent commits to
mainin a tight loop. Build on a CLEAN tree in an isolated worktree (perusing-git-worktrees); extract task briefs straight from the worktree plan (NOT the shared.superpowers/sdd/— briefs there collide with the other agent’s). Merge to main at the end.
Run/test quick reference
Section titled “Run/test quick reference”- Single test:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/<file>.gd -gexit - Full suite:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit - Count guard:
bash scripts/check-test-count.sh; Import:godot --headless --path . --import - Boot:
godot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR"(empty = good) - Tests build
Sim.new(seed, SimContentFixture.db());sim.enable_crystals()for crystals mode.
File Structure
Section titled “File Structure”- Modify
sim/weapon_thresholds.gd— add purerules_met(weapon_id, count_map) -> Array[int]. - Modify
sim/sim.gd— refactor_eval_thresholdsto userules_met; addupgrade_effects(id) -> Dictionary+rank_upgrades(ids) -> Array[String]+ a pure_parse_crystal_spec(spec) -> Dictionaryhelper. - Create
ui/crystals_levelup_panel.gd— full-screen panel (mirrorsLevelUpPanelnav). - Modify
main.gd— show the crystals panel in crystals mode; resume-grace + i-frames. - Create
tests/test_upgrade_effects.gd; extendtests/test_weapon_thresholds.gd.
Task 1: WeaponThresholds.rules_met + DRY refactor of _eval_thresholds
Section titled “Task 1: WeaponThresholds.rules_met + DRY refactor of _eval_thresholds”Files: Modify sim/weapon_thresholds.gd, sim/sim.gd; Test: tests/test_weapon_thresholds.gd (extend)
Interfaces:
-
Produces:
WeaponThresholds.rules_met(weapon_id: String, count_map: Dictionary) -> Array[int](indices intorules_for(weapon_id)whosecount_map.get(element,0) >= amount). -
Consumes: existing
WeaponThresholds.rules_for,Sim.crystals.counts,_thresholds_done. -
Step 1: Write the failing test (append to
tests/test_weapon_thresholds.gd)
func test_rules_met_boundaries() -> void: # orbit has a cold "shard" rule (amount 5) — verify the boundary. var below := WeaponThresholds.rules_met("orbit", {"cold": 4}) var at := WeaponThresholds.rules_met("orbit", {"cold": 5}) assert_eq(below.size(), 0, "4 cold meets no orbit rule") assert_true(at.size() >= 1, "5 cold meets at least the shard rule") # empty map meets nothing; unknown weapon -> empty assert_eq(WeaponThresholds.rules_met("orbit", {}).size(), 0) assert_eq(WeaponThresholds.rules_met("nonesuch", {"fire": 99}).size(), 0)- Step 2: Run it — Expected FAIL (
rules_metundefined). - Step 3: Add
rules_mettosim/weapon_thresholds.gd
static func rules_met(weapon_id: String, count_map: Dictionary) -> Array[int]: var out: Array[int] = [] var rules := rules_for(weapon_id) for i in range(rules.size()): var rule: Dictionary = rules[i] if int(count_map.get(rule["element"], 0)) >= int(rule["amount"]): out.append(i) return out-
Step 4: Refactor
Sim._eval_thresholdsto use it (DRY). Read the current_eval_thresholds; replace its per-rulecrystals.count(...) >= amountcheck with iteratingWeaponThresholds.rules_met(wid, crystals.counts)and applying any index not in_thresholds_done. Behaviour MUST be identical (same fires, idempotent, non-consuming). -
Step 5: Run tests — Expected PASS. The existing
test_weapon_thresholds.gdcases (threshold fires once, idempotent, evolve branch, late-weapon inherit) must STILL pass — that proves the refactor preserved behaviour. -
Step 6: bh-dev-chunk gates — import, boot empty, full suite, count guard, determinism baseline unchanged.
-
Step 7: Commit
git add sim/weapon_thresholds.gd sim/sim.gd tests/test_weapon_thresholds.gdgit commit -m "refactor(crystals): WeaponThresholds.rules_met (DRY: _eval_thresholds + preview share it)"Task 2: Sim.upgrade_effects(id) — change-set + score (pure, read-only)
Section titled “Task 2: Sim.upgrade_effects(id) — change-set + score (pure, read-only)”Files: Modify sim/sim.gd; Test: tests/test_upgrade_effects.gd (create)
Interfaces:
-
Produces:
Sim.upgrade_effects(id: String) -> Dictionary={kind:String, headline:String, changes:Array[Dictionary], dead:bool, score:float}where each change is{target:String, detail:String, evolve:bool}. AlsoSim._parse_crystal_spec(spec: String) -> Dictionary(e.g."fire=4,cold=2"→{"fire":4,"cold":2}). -
Consumes:
WeaponThresholds.rules_met/rules_for,crystals.counts,active_weapon_ids, existingupgrade_preview(id),_crystal_spec_label(added in the level-up card fix),SimMods.TABLE(to detect transformative/reaction mods),StatEffects.TABLE. -
Step 1: Write the failing test —
tests/test_upgrade_effects.gd
extends GutTestfunc _content() -> ContentDB: return SimContentFixture.db()
# A crystal grant lists the weapon thresholds it would NEWLY cross, and scores them.func test_crystal_grant_lists_new_thresholds() -> void: var sim := Sim.new(1, _content()); sim.enable_crystals() sim.grant_weapon("orbit") # owns orbit (cold shard @5, void evolve @18) var e := sim.upgrade_effects("crystals:cold=5") assert_eq(e["kind"], "crystal") assert_false(e["dead"], "a crystal grant that crosses a threshold is not dead") var hit_orbit := false for c in e["changes"]: if c["target"] == "orbit": hit_orbit = true assert_true(hit_orbit, "crossing 5 cold shows an orbit change") assert_gt(e["score"], 0.0, "scored") # pure: the wallet was NOT mutated by previewing assert_eq(sim.crystals.count("cold"), 0, "upgrade_effects must not mutate crystals")
# An evolve-crossing grant outscores a single-mod grant.func test_evolve_outscores_plain_threshold() -> void: var sim := Sim.new(1, _content()); sim.enable_crystals() sim.grant_weapon("orbit") var evolve := sim.upgrade_effects("crystals:void=18") # crosses orbit evolve var plain := sim.upgrade_effects("crystals:cold=5") # crosses orbit shard assert_gt(evolve["score"], plain["score"], "evolution is weighted highest")
# A weapon grant lists thresholds it instantly inherits from the existing wallet.func test_weapon_grant_lists_inherited() -> void: var sim := Sim.new(1, _content()); sim.enable_crystals() sim.crystals.add("cold", 5) var e := sim.upgrade_effects("weapon:orbit") assert_eq(e["kind"], "weapon") var inherited := false for c in e["changes"]: if c["detail"] != "new weapon": inherited = true assert_true(inherited, "a weapon inherits already-met thresholds on grant")
# A reaction-buff transformative mod is dead in crystals mode (reactions are off).func test_reaction_mod_is_dead_in_crystals() -> void: var sim := Sim.new(1, _content()); sim.enable_crystals() # pick a transformative-mod id from the content (effect in SimMods.TABLE) var mod_id := _first_transformative_id(sim) if mod_id == "": pass_test("no transformative mod in content"); return var e := sim.upgrade_effects(mod_id) assert_true(e["dead"], "a reaction-buff mod does nothing in crystals mode") assert_eq(e["score"], 0.0, "dead picks score 0")
func _first_transformative_id(sim: Sim) -> String: for u in sim.content.upgrades(): if SimMods.TABLE.has(u.get("effect", "")): return String(u.get("id", "")) return ""
# rank_upgrades sorts best->worst by score.func test_rank_orders_best_first() -> void: var sim := Sim.new(1, _content()); sim.enable_crystals() sim.grant_weapon("orbit") var ids := ["crystals:cold=1", "crystals:void=18"] # weak vs evolve-crossing var ranked := sim.rank_upgrades(ids) assert_eq(ranked[0], "crystals:void=18", "the evolve-crossing grant ranks first")-
Step 2: Run it — Expected FAIL (
upgrade_effects/rank_upgrades/_parse_crystal_specundefined). (AdjustSimMods.TABLE/StatEffects.TABLE/content.upgrades()accessor names to the REAL source — verify before implementing.) -
Step 3: Implement in
sim/sim.gd(verify helper/accessor names against source first)
func _parse_crystal_spec(spec: String) -> Dictionary: var out := {} for pair in spec.split(",", false): var kv := pair.split("=") if kv.size() == 2: out[kv[0]] = int(kv[1]) return out
func upgrade_effects(id: String) -> Dictionary: if id.begins_with("crystals:"): var bundle := _parse_crystal_spec(id.substr("crystals:".length())) var after := crystals.counts.duplicate() for el in bundle: after[el] = int(after.get(el, 0)) + int(bundle[el]) var changes: Array = [] var score := 0.0 for wid in active_weapon_ids: var before_idx := WeaponThresholds.rules_met(wid, crystals.counts) for i in WeaponThresholds.rules_met(wid, after): if not before_idx.has(i): var rule: Dictionary = WeaponThresholds.rules_for(wid)[i] var ev: bool = rule["kind"] == "evolve" changes.append({"target": wid, "detail": ("EVOLVE" if ev else String(rule["kind"])), "evolve": ev}) score += (100.0 if ev else 20.0) return {"kind": "crystal", "headline": _crystal_spec_label(id.substr("crystals:".length())), "changes": changes, "dead": changes.is_empty(), "score": score} if id.begins_with("weapon:"): var wid := id.substr("weapon:".length()) var changes: Array = [{"target": wid, "detail": "new weapon", "evolve": false}] var score := 15.0 for i in WeaponThresholds.rules_met(wid, crystals.counts): var rule: Dictionary = WeaponThresholds.rules_for(wid)[i] var ev: bool = rule["kind"] == "evolve" changes.append({"target": wid, "detail": ("EVOLVE" if ev else String(rule["kind"])), "evolve": ev}) score += (100.0 if ev else 20.0) return {"kind": "weapon", "headline": "Add " + wid, "changes": changes, "dead": false, "score": score} # stat / transformative mod — reuse the existing preview; reaction mods are dead in crystals var prev := upgrade_preview(id) var eff := String(_upgrade_effect_of(id)) # the upgrade's "effect" field (helper or inline) var is_mod := SimMods.TABLE.has(eff) var dead := is_mod and ruleset == RULESET_CRYSTALS var score := 0.0 if dead else 8.0 return {"kind": ("mod" if is_mod else "stat"), "headline": String(prev.get("name", id)), "changes": [{"target": String(prev.get("name", id)), "detail": ("no effect (reactions off)" if dead else "%s → %s" % [prev.get("now",""), prev.get("after","")]), "evolve": false}], "dead": dead, "score": score}
func rank_upgrades(ids: Array) -> Array[String]: var scored: Array = [] for id in ids: scored.append({"id": String(id), "s": float(upgrade_effects(String(id)).get("score", 0.0))}) scored.sort_custom(func(a, b): return a["s"] > b["s"]) var out: Array[String] = [] for e in scored: out.append(e["id"]) return out(If there’s no _upgrade_effect_of helper, look up the upgrade’s effect from content.upgrades() by id inline. Verify _crystal_spec_label exists — it was added in the level-up card fix.)
- Step 4: Run tests — Expected PASS. Fix accessor names if any assertion errors on a real-source mismatch.
- Step 5: gates — boot, suite, count, determinism unchanged (all new funcs are read-only + crystals-gated for
dead). - Step 6: Commit
git add sim/sim.gd tests/test_upgrade_effects.gdgit commit -m "feat(crystals): Sim.upgrade_effects + rank_upgrades (pure change-preview + scoring)"Task 3: CrystalsLevelUpPanel — full-screen panel
Section titled “Task 3: CrystalsLevelUpPanel — full-screen panel”Files: Create ui/crystals_levelup_panel.gd; Test: tests/test_crystals_levelup_panel.gd (create, minimal)
Interfaces:
-
Produces:
CrystalsLevelUpPanel(extends CanvasLayer),signal chosen(id: String),func show_for(sim: Sim, ids: Array) -> void,func hide_panel() -> void. Holds_cards: Array[Button]. -
Consumes:
Sim.upgrade_effects,Sim.rank_upgrades,sim.crystals.counts,sim.active_weapon_ids,sim.player,sim.upgrade_preview;NeonTheme. Mirrorui/level_up_panel.gd’s focus-nav (_input, debounced stick/d-pad,JOY_BUTTON_A/ui_accept). -
Step 1: Write the failing test —
tests/test_crystals_levelup_panel.gd
extends GutTestfunc test_panel_builds_a_card_per_choice_with_ranking() -> void: var sim := Sim.new(1, SimContentFixture.db()); sim.enable_crystals() sim.grant_weapon("orbit") var panel := CrystalsLevelUpPanel.new() add_child_autofree(panel) await get_tree().process_frame var ids := ["crystals:cold=1", "crystals:void=18", "weapon:nova"] panel.show_for(sim, ids) await get_tree().process_frame assert_eq(panel._cards.size(), ids.size(), "one card per choice") # cards are ordered best->worst (the evolve-crossing grant first) assert_eq(String(panel._cards[0].get_meta("id", "")), "crystals:void=18", "best choice is first")- Step 2: Run it — Expected FAIL (
CrystalsLevelUpPanelundefined).godot --headless --path . --import(new class_name) then re-run. - Step 3: Create
ui/crystals_levelup_panel.gd. Build: a full-rect dim ColorRect; anHBoxContainerwith LEFTVBoxContainer(title “LEVEL UP — CRYSTALS”, then one focusable card perrank_upgrades(ids)order — each cardset_meta("id", id), showseffects.headline+ a rank badge “BEST/GOOD/OK/WEAK” by position, accent-coloured) and RIGHTVBoxContainer(build readout: for eachsim.active_weapon_idsa line with weapon id + evolved flag; the player stat block fromsim.player; the crystal wallet fromsim.crystals.counts). A detail area under/beside the left list renders the FOCUSED card’ssim.upgrade_effects(id).changes(one line each; evolve lines highlighted; “dead” shown muted). Wirefocus_enteredon each card to re-render the detail area._inputmirrorsLevelUpPanel(debounced nav + confirm →chosen.emit(meta id)). Build the readout helpers small + focused. - Step 4: Run test — Expected PASS. Boot-check empty (
--quit-after 120). - Step 5: gates — full suite, count guard (panel adds 1 script), determinism unchanged (pure UI). Visual correctness is playtest-verified.
- Step 6: Commit
git add ui/crystals_levelup_panel.gd tests/test_crystals_levelup_panel.gdgit commit -m "feat(crystals): full-screen level-up panel (choices+ranking left, inventory right, change preview)"Task 4: main.gd wiring — show panel in crystals mode + 0.5s resume grace + i-frames
Section titled “Task 4: main.gd wiring — show panel in crystals mode + 0.5s resume grace + i-frames”Files: Modify main.gd; verified by boot + playtest (render/UI — no GUT seam).
Interfaces: Consumes CrystalsLevelUpPanel, Sim.upgrade_effects, the player i-frame mechanism (verify the real field/method, e.g. sim.player.dash_iframes / sim.is_invulnerable).
- Step 1: Add the panel + a resume-grace field. Create
var crystals_level_up: CrystalsLevelUpPanelalongsidelevel_up; add it as a child in_new_run(connectchosen→_on_upgrade_chosen). Addvar _resume_grace: float = 0.0. - Step 2: Branch
_open_levelup(). Read the current function. In crystals mode (sim.ruleset == Sim.RULESET_CRYSTALS) callcrystals_level_up.show_for(sim, ids)instead oflevel_up.show_choices(...); keep_paused_for_levelup = trueand_current_choice_ids = ids. Other modes unchanged. - Step 3: Branch
_on_upgrade_chosen(id). Aftersim.apply_upgrade(id)and hiding whichever panel is showing: if more levels are pending, re-open; else instead of clearing_paused_for_levelupimmediately, in crystals mode set_resume_grace = 0.5, grant the player a ~0.5s invuln window (via the verified i-frame mechanism), and show a brief “READY…” label; in other modes keep the existing immediate resume. - Step 4: Handle the grace in
_process. Read the current_processguards. Add: if_resume_grace > 0.0, decrement by realdelta, keep the sim FROZEN (do not callsim.tick), and when it reaches 0 clear_paused_for_levelup/the READY label so normal ticking + liveinput_router.poll()resume. (No input replay — the freeze lets the player re-grip.) - Step 5: Boot-check + verify wiring —
godot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR"empty; full suite still all-pass + count guard N/N (no test changes); determinism baseline unchanged (the grace/i-frames only run in crystals mode, which the determinism test never enters). - Step 6: Playtest check (editor): crystals mode → on level-up the full-screen panel appears (choices+ranks left, inventory right), focusing a choice shows its changes, picking it freezes ~0.5s (“READY”), then live control resumes with brief invuln.
- Step 7: Commit
git add main.gdgit commit -m "feat(crystals): wire full-screen level-up panel + 0.5s resume grace with i-frames"Self-Review
Section titled “Self-Review”Spec coverage: full-screen crystals panel (T3); choices+ranking left / inventory right (T3); focused-card full change preview incl. crystal cascade (T2 upgrade_effects + T3 render); best→worst ranking (T2 rank_upgrades + T3 badges); 0.5s freeze + i-frames resume (T4); crystals-only (T3/T4 gates); pure read-only preview + DRY rules_met (T1/T2); determinism untouched (every sim task re-checks). All spec sections map to a task.
Placeholder scan: the “verify accessor/helper names against source” notes are honest flags (the other agent actively changes sim.gd/main.gd), not placeholders — each task names the concrete thing to verify (_crystal_spec_label, SimMods.TABLE, the i-frame field). Score weights are explicit starting values per the spec’s “Open/tuning”.
Type consistency: upgrade_effects returns {kind,headline,changes:[{target,detail,evolve}],dead,score} consistently across T2 (producer) and T3 (consumer); rank_upgrades(ids)->Array[String] consistent T2→T3; WeaponThresholds.rules_met(weapon_id,count_map)->Array[int] consistent T1→T2; show_for(sim,ids)/chosen(id) consistent T3→T4; the RULESET_CRYSTALS gate repeated in T2(dead)/T3/T4.