Skip to content

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’s bh-dev-chunk ritual (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.

  • /sim purity: sim/sim.gd, sim/weapon_thresholds.gd are RefCounted/pure — NO Node/Engine/Input/Time/OS/File/JSON. upgrade_effects/rules_met/rank_upgrades MUST be read-only (never mutate crystals, weapons, _thresholds_done, or any sim state).
  • Determinism: these previews are read-only and the panel/resume-grace are set only by main in crystals mode — the determinism test never enters crystals/level-up. The survival reactions baseline (pinned in tests/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 existing LevelUpPanel untouched.
  • DRY: rules_met is 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 (NOT assert_le/assert_ge — a typo silently drops the file). An un-consumed push_error fails the test.
  • New class_name file → run godot --headless --path . --import before tests.
  • Trust the COUNT: bash scripts/check-test-count.sh must report N/N.
  • Concurrency: the other agent commits to main in a tight loop. Build on a CLEAN tree in an isolated worktree (per using-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.
  • 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.
  • Modify sim/weapon_thresholds.gd — add pure rules_met(weapon_id, count_map) -> Array[int].
  • Modify sim/sim.gd — refactor _eval_thresholds to use rules_met; add upgrade_effects(id) -> Dictionary + rank_upgrades(ids) -> Array[String] + a pure _parse_crystal_spec(spec) -> Dictionary helper.
  • Create ui/crystals_levelup_panel.gd — full-screen panel (mirrors LevelUpPanel nav).
  • Modify main.gd — show the crystals panel in crystals mode; resume-grace + i-frames.
  • Create tests/test_upgrade_effects.gd; extend tests/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 into rules_for(weapon_id) whose count_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_met undefined).
  • Step 3: Add rules_met to sim/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_thresholds to use it (DRY). Read the current _eval_thresholds; replace its per-rule crystals.count(...) >= amount check with iterating WeaponThresholds.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.gd cases (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

Terminal window
git add sim/weapon_thresholds.gd sim/sim.gd tests/test_weapon_thresholds.gd
git 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}. Also Sim._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, existing upgrade_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 testtests/test_upgrade_effects.gd

extends GutTest
func _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_spec undefined). (Adjust SimMods.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
Terminal window
git add sim/sim.gd tests/test_upgrade_effects.gd
git 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. Mirror ui/level_up_panel.gd’s focus-nav (_input, debounced stick/d-pad, JOY_BUTTON_A/ui_accept).

  • Step 1: Write the failing testtests/test_crystals_levelup_panel.gd

extends GutTest
func 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 (CrystalsLevelUpPanel undefined). godot --headless --path . --import (new class_name) then re-run.
  • Step 3: Create ui/crystals_levelup_panel.gd. Build: a full-rect dim ColorRect; an HBoxContainer with LEFT VBoxContainer (title “LEVEL UP — CRYSTALS”, then one focusable card per rank_upgrades(ids) order — each card set_meta("id", id), shows effects.headline + a rank badge “BEST/GOOD/OK/WEAK” by position, accent-coloured) and RIGHT VBoxContainer (build readout: for each sim.active_weapon_ids a line with weapon id + evolved flag; the player stat block from sim.player; the crystal wallet from sim.crystals.counts). A detail area under/beside the left list renders the FOCUSED card’s sim.upgrade_effects(id).changes (one line each; evolve lines highlighted; “dead” shown muted). Wire focus_entered on each card to re-render the detail area. _input mirrors LevelUpPanel (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
Terminal window
git add ui/crystals_levelup_panel.gd tests/test_crystals_levelup_panel.gd
git 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: CrystalsLevelUpPanel alongside level_up; add it as a child in _new_run (connect chosen_on_upgrade_chosen). Add var _resume_grace: float = 0.0.
  • Step 2: Branch _open_levelup(). Read the current function. In crystals mode (sim.ruleset == Sim.RULESET_CRYSTALS) call crystals_level_up.show_for(sim, ids) instead of level_up.show_choices(...); keep _paused_for_levelup = true and _current_choice_ids = ids. Other modes unchanged.
  • Step 3: Branch _on_upgrade_chosen(id). After sim.apply_upgrade(id) and hiding whichever panel is showing: if more levels are pending, re-open; else instead of clearing _paused_for_levelup immediately, 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 _process guards. Add: if _resume_grace > 0.0, decrement by real delta, keep the sim FROZEN (do not call sim.tick), and when it reaches 0 clear _paused_for_levelup/the READY label so normal ticking + live input_router.poll() resume. (No input replay — the freeze lets the player re-grip.)
  • Step 5: Boot-check + verify wiringgodot --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
Terminal window
git add main.gd
git commit -m "feat(crystals): wire full-screen level-up panel + 0.5s resume grace with i-frames"

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.