Shop Overhaul — Implementation Plan
Shop Overhaul — Implementation Plan
Section titled “Shop Overhaul — Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: use superpowers:subagent-driven-development or superpowers:executing-plans to implement this task-by-task. Each task ends green + committed via the
bh-dev-chunkritual (TDD →--import→ boot-check → full GUT suite →scripts/check-test-count.sh→ determinism). Steps use checkbox (- [ ]) syntax.
Goal: Decouple the shop from the death screen — make it a standalone, categorised two-level shop reachable from the start menu, the in-run pause menu, and a new Results death screen.
Architecture: MetaShopPanel becomes a reusable two-level shop (open_shop(meta, defs, return_to)):
a root view of category tiles → a per-category card view. ResultsPanel becomes the survival/crystal
death screen (Redeploy / Shop / Main Menu + run summary). StartMenu and PauseMenu each gain a Shop
entry. main.gd routes all three entry points through one _open_shop(return_to) helper. Categories are
a new data-driven category field on each meta_upgrade in bible.json.
Tech Stack: Godot 4.6 typed GDScript; CanvasLayer UI; GUT 9.6 headless tests.
Global Constraints
Section titled “Global Constraints”- No
/simchanges. Pure UI + meta-progression (meta upgrades already apply outside the sim). The determinism baselines MUST NOT move — survival1405185210/3122397125, crystals91572468/1173256610. Re-run the determinism test each task and confirm it’s unchanged (no re-pin). - tvOS focus nav on every screen: debounced joypad/d-pad nav (
NAV_DEBOUNCE_MS = 200), confirm onui_acceptORJOY_BUTTON_A, centered/overscan-safe layout, reuseui/shop_icons.gdglyphs. - Never clobber
user://meta.jsonin tests — exerciseMetaState.buy(pure) and the unaffordable (no-save) path only; never the buy-with-save path in a headless test (it writes the real save). - Categories (fixed order): Pilot / Drones / Arsenal / Utility. The root view lists only categories that have ≥1 upgrade (empty ones — e.g. Drones today has decoy entries; a truly empty category — auto-hide).
- Each task: commit specific files only (the other agent commits to
main; nevergit add -A; never touchCLAUDE.md).
File Structure
Section titled “File Structure”data/bible.json— addcategoryto all 13meta_upgrades(Task 1).ui/shop_categories.gd(new, pure) —ShopCategories: the ordered category list + grouping helpers (Task 2). Pure (no DOM/Node) so it’s unit-testable.ui/meta_shop_panel.gd— rewrite to the two-level standalone shop (Task 3).ui/results_panel.gd—show_resultsbecomes the 3-button death screen (Task 4).ui/start_menu.gd— add a SHOP entry (Task 5).ui/pause_menu.gd— add a Shop button (Task 6).main.gd—_open_shop(return_to)+ rewire game-over/start/pause (Task 7).- Tests:
tests/test_shop_categories.gd(new), rewritetests/test_meta_shop.gd+tests/test_meta_shop_panel.gd, extendtests/test_results_panel.gd(new if absent),tests/test_start_menu.gd+tests/test_pause_menu.gd(new if absent).
Task 1: category field on every meta_upgrade
Section titled “Task 1: category field on every meta_upgrade”Files:
- Modify:
data/bible.json(data.meta_upgrades[*]) - Test:
tests/test_shop_categories.gd(create; expanded in Task 2)
Interfaces — Produces: every meta_upgrade dict has a category ∈ {Pilot, Drones, Arsenal, Utility}.
Category assignment (by id):
| Category | ids |
|---|---|
| Pilot | vitality, bulwark, haste, swiftness, thrusters, field-medic |
| Utility | greed |
| Arsenal | unlock-scatter |
| Drones | decoy-power, decoy-duration, unlock-decoy-damage, unlock-decoy-tank, unlock-decoy-healer, unlock-decoy-ultimate |
- Step 1: Write the failing test (
tests/test_shop_categories.gd)
extends GutTest
const ALLOWED := ["Pilot", "Drones", "Arsenal", "Utility"]
func test_every_meta_upgrade_has_a_valid_category() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") for m in content.meta_upgrades(): assert_true(m is Dictionary, "meta upgrade is a dict") var cat := String((m as Dictionary).get("category", "")) assert_true(cat in ALLOWED, "%s has a valid category (got '%s')" % [m.get("id", "?"), cat])- Step 2: Run it — expect FAIL (
-gtest=res://tests/test_shop_categories.gd): every upgrade missingcategory. - Step 3: Add the field via a tab-indented python round-trip (clean one-key diff):
import jsonp = "data/bible.json"doc = json.load(open(p)); mu = doc["data"]["meta_upgrades"]CAT = { "vitality":"Pilot","bulwark":"Pilot","haste":"Pilot","swiftness":"Pilot", "thrusters":"Pilot","field-medic":"Pilot","greed":"Utility","unlock-scatter":"Arsenal", "decoy-power":"Drones","decoy-duration":"Drones","unlock-decoy-damage":"Drones", "unlock-decoy-tank":"Drones","unlock-decoy-healer":"Drones","unlock-decoy-ultimate":"Drones",}for m in mu: m["category"] = CAT[m["id"]] # KeyError = a new upgrade needs a category herejson.dump(doc, open(p,"w"), indent="\t", ensure_ascii=False); open(p,"a").write("\n")- Step 4: Run the test — expect PASS. Then full suite + count + determinism (unchanged). Commit
data/bible.json tests/test_shop_categories.gd.
Task 2: ShopCategories pure grouping helper
Section titled “Task 2: ShopCategories pure grouping helper”Files:
- Create:
ui/shop_categories.gd - Test:
tests/test_shop_categories.gd(extend)
Interfaces — Produces:
-
ShopCategories.ORDER: Array[String]=["Pilot", "Drones", "Arsenal", "Utility"] -
ShopCategories.present(defs: Array) -> Array[String]— categories (in ORDER) with ≥1 upgrade. -
ShopCategories.in_category(defs: Array, cat: String) -> Array— defs whosecategory == cat, preserving document order. -
Step 1: Write failing tests (append to
tests/test_shop_categories.gd)
func test_present_returns_categories_in_fixed_order_only_if_nonempty() -> void: var defs := [ {"id":"a","category":"Arsenal"}, {"id":"b","category":"Pilot"}, {"id":"c","category":"Pilot"}, ] assert_eq(ShopCategories.present(defs), ["Pilot", "Arsenal"], "ordered + only non-empty")
func test_in_category_filters_and_preserves_order() -> void: var defs := [{"id":"a","category":"Pilot"}, {"id":"b","category":"Drones"}, {"id":"c","category":"Pilot"}] var pilots := ShopCategories.in_category(defs, "Pilot") assert_eq(pilots.size(), 2) assert_eq(String(pilots[0]["id"]), "a") assert_eq(String(pilots[1]["id"]), "c")
func test_unknown_category_is_ignored_not_crash() -> void: var defs := [{"id":"x","category":"Bogus"}] assert_eq(ShopCategories.present(defs), [], "unknown categories don't appear")- Step 2: Run — expect FAIL (
ShopCategoriesundefined). - Step 3: Implement
ui/shop_categories.gd
class_name ShopCategories
# Pure grouping helpers for the categorised shop. No DOM/Node — unit-testable.const ORDER: Array[String] = ["Pilot", "Drones", "Arsenal", "Utility"]
static func in_category(defs: Array, cat: String) -> Array: var out: Array = [] for d in defs: if d is Dictionary and String((d as Dictionary).get("category", "")) == cat: out.append(d) return out
static func present(defs: Array) -> Array[String]: var out: Array[String] = [] for cat in ORDER: if not in_category(defs, cat).is_empty(): out.append(cat) return out- Step 4:
--import(newclass_namein a new file → refresh the class cache). Run — expect PASS. Full suite + count + determinism (unchanged). Commitui/shop_categories.gd tests/test_shop_categories.gd.
Task 3: MetaShopPanel → two-level standalone shop
Section titled “Task 3: MetaShopPanel → two-level standalone shop”Files:
- Modify:
ui/meta_shop_panel.gd - Test: rewrite
tests/test_meta_shop.gd+tests/test_meta_shop_panel.gd
Interfaces — Consumes: ShopCategories (Task 2), MetaState (buy/cost/can_afford/is_maxed/
level_of/selected_decoy), MetaStore.save_state, ShopIcons.make, NeonTheme.
Produces:
MetaShopPanel.open_shop(meta: MetaState, defs: Array, return_to: Callable) -> void— shows the ROOT category view (layer 80, on top of any menu). Storesreturn_to.signal closed— emitted when the player backs out of the root.- Internal views:
_show_root()(category tiles + banked-gold header),_show_category(cat)(that category’s cards + Back). Back: category→root→(close:hide_panel()+return_to.call()+closed). - Keeps: buy/persist (
_activate/MetaState.buy/MetaStore.save_state), card UI (_make_card/_set_card_text/_card_box/_label), grid layout (_columns_for/CARD_*), debounced 2D nav. - Removes: the run-summary header + the
PLAY_ID“Play Again” card +restart_requested(the Results screen owns restart now) + the oldshow_shop(sim, meta, defs)entry point.
Behaviour detail:
-
layer = 80(above pause 70 / start 60) so the shop covers whatever opened it. -
Root header: title “SHOP” + “BANKED N gold”. Tiles =
ShopCategories.present(defs); each tile is a_make_card(cat, accent, true, w)with_set_card_text(card, cat, "<n> upgrades", accent, "", ""). Activating a tile →_show_category(cat). -
Category view: cards =
ShopCategories.in_category(defs, cat)(reuse_make_upgrade_card) + a Back card at the end (__back__). Back →_show_root(). -
A buy stays in the category view and
_rebuilds it (so cost/level/OWNED refresh) — same flash + save. -
_inputBack button (ui_cancel/JOY_BUTTON_B/ Esc): in category →_show_root(); in root → close. (Add this alongside the existing confirm/nav handling.) -
Step 1: Rewrite the tests (
tests/test_meta_shop.gd,tests/test_meta_shop_panel.gd) to the new API. Drop the Play-Again/restart_requested/run-summary assertions. New assertions:
# tests/test_meta_shop.gd (rewrite)extends GutTest
func _open(p: MetaShopPanel, meta: MetaState, defs: Array) -> void: p.open_shop(meta, defs, func() -> void: pass)
func test_root_shows_one_tile_per_present_category() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var meta := MetaState.new() # fresh, zero gold → no save path touched var p := MetaShopPanel.new(); add_child_autofree(p); await get_tree().process_frame _open(p, meta, content.meta_upgrades()) # one card per present category (no buy summary, no play card) assert_eq(p._cards.size(), ShopCategories.present(content.meta_upgrades()).size(), "root view shows one tile per non-empty category") p.hide_panel()
func test_drilling_into_category_shows_its_cards_plus_back() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var meta := MetaState.new() var p := MetaShopPanel.new(); add_child_autofree(p); await get_tree().process_frame _open(p, meta, content.meta_upgrades()) p._show_category("Pilot") var n := ShopCategories.in_category(content.meta_upgrades(), "Pilot").size() assert_eq(p._cards.size(), n + 1, "category view shows its cards + a Back card") p.hide_panel()
func test_back_from_root_calls_return_to_and_emits_closed() -> void: var meta := MetaState.new() var p := MetaShopPanel.new(); add_child_autofree(p); await get_tree().process_frame var returned := [false] p.open_shop(meta, [], func() -> void: returned[0] = true) watch_signals(p) p._close() # the back-from-root path assert_true(returned[0], "return_to callback fired") assert_signal_emitted(p, "closed")
func test_activating_unaffordable_upgrade_is_noop() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var meta := MetaState.new() # zero gold var p := MetaShopPanel.new(); add_child_autofree(p); await get_tree().process_frame _open(p, meta, content.meta_upgrades()) p._show_category("Pilot") var first_stat := ShopCategories.in_category(content.meta_upgrades(), "Pilot")[0] p._activate(String(first_stat["id"])) # unaffordable → buy() false → no save, no crash assert_eq(meta.banked_gold, 0, "no gold spent on an unaffordable buy") p.hide_panel()(test_meta_shop_panel.gd: keep test_columns_scale_with_count; replace test_grid_built_for_defs
with the root-tile-count assertion above — or delete it as redundant.)
- Step 2: Run the tests — expect FAIL (old API gone / new methods missing).
- Step 3: Rewrite
ui/meta_shop_panel.gd. Keep the helpers listed above; replace the entry/header/_rebuildflow withopen_shop+_show_root+_show_category+_close. Key new code:
signal closed
var _return_to: Callable = Callable()var _view: String = "root" # "root" | "<category>"
func open_shop(meta: MetaState, defs: Array, return_to: Callable) -> void: _meta = meta _defs = defs _return_to = return_to visible = true _show_root()
func _show_root() -> void: _view = "root" _build_header("SHOP") _clear_grid() var cats := ShopCategories.present(_defs) _columns = _columns_for(cats.size()) var card_w := _card_width(_columns) _grid.columns = _columns for cat in cats: var n := ShopCategories.in_category(_defs, cat).size() var card := _make_card(cat, NeonTheme.CYAN, true, card_w) _set_card_text(card, cat, "%d upgrades" % n, NeonTheme.CYAN, "", "") _cards.append(card); _grid.add_child(card) _finish_view()
func _show_category(cat: String) -> void: _view = cat _build_header(cat.to_upper()) _clear_grid() var defs := ShopCategories.in_category(_defs, cat) _columns = _columns_for(defs.size() + 1) var card_w := _card_width(_columns) _grid.columns = _columns for def in defs: _cards.append(_make_upgrade_card(def, card_w)); _grid.add_child(_cards[-1]) var back := _make_card("__back__", STEEL, true, card_w) _set_card_text(back, "◀ BACK", "", STEEL, "", "") _cards.append(back); _grid.add_child(back) _finish_view()
func _close() -> void: hide_panel() if _return_to.is_valid(): _return_to.call() closed.emit()-
Factor
_card_width(cols),_build_header(title)(title + “BANKED N gold”, no run summary),_clear_grid(),_finish_view()(the_sel/focus/entrance-tween block from the old_rebuild). -
_activate(id): ifid == "__back__"→_show_root(); else the existing decoy-equip /buylogic, but on success_rebuild_current()(re-render the active category, not the old_rebuild). A tile id (a category name) →_show_category(id). -
_input: add the Back action —ui_cancel/JOY_BUTTON_B/KEY_ESCAPE→ if_view != "root":_show_root()else_close();get_viewport().set_input_as_handled(). -
Step 4:
--import+ boot-check. Run the rewritten shop tests — expect PASS. Full suite + count- determinism (unchanged). Commit
ui/meta_shop_panel.gd tests/test_meta_shop.gd tests/test_meta_shop_panel.gd.
- determinism (unchanged). Commit
Task 4: ResultsPanel → Redeploy / Shop / Main Menu death screen
Section titled “Task 4: ResultsPanel → Redeploy / Shop / Main Menu death screen”Files:
- Modify:
ui/results_panel.gd - Test:
tests/test_results_panel.gd(create)
Interfaces — Produces:
-
signal redeploy_requested,signal shop_requested,signal menu_requested(keeprestart_requestedfor the existingshow_victory“Climb again” button — unchanged). -
show_results(sim, banked_gold)builds the run summary + a 3-button column (Redeploy / Shop / Main Menu) with debounced joypad/JOY_BUTTON_Afocus nav (mirrorPauseMenu). Each button emits its signal. -
refocus()— re-grab focus on the current selection (used when returning from the shop). -
Step 1: Write failing tests (
tests/test_results_panel.gd)
extends GutTest
func _panel() -> ResultsPanel: var p := ResultsPanel.new(); add_child_autofree(p); return p
func test_results_has_three_buttons() -> void: var p := _panel(); await get_tree().process_frame var sim := Sim.new(1, SimContentFixture.db()) p.show_results(sim, 0) assert_eq(p._buttons.size(), 3, "Redeploy / Shop / Main Menu")
func test_buttons_emit_their_signals() -> void: var p := _panel(); await get_tree().process_frame var sim := Sim.new(1, SimContentFixture.db()) p.show_results(sim, 0) watch_signals(p) p._buttons[0].emit_signal("pressed") # Redeploy p._buttons[1].emit_signal("pressed") # Shop p._buttons[2].emit_signal("pressed") # Main Menu assert_signal_emitted(p, "redeploy_requested") assert_signal_emitted(p, "shop_requested") assert_signal_emitted(p, "menu_requested")- Step 2: Run — expect FAIL.
- Step 3: Rewrite
show_resultsto build the summary label + three_make_btn(text, signal)buttons into a_buttons: Array[Button], with thePauseMenu-style_move/_focus_sel/_input(debounced d-pad nav +JOY_BUTTON_A/Enter confirm emitting the focused button’spressed). Addrefocus(). Leaveshow_victory(and itsrestart_requestedbutton) intact. - Step 4:
--import+ boot-check. Run — expect PASS. Full suite + count + determinism. Commitui/results_panel.gd tests/test_results_panel.gd.
Task 5: StartMenu SHOP entry
Section titled “Task 5: StartMenu SHOP entry”Files:
- Modify:
ui/start_menu.gd - Test:
tests/test_start_menu.gd(create)
Interfaces — Produces: signal shop_requested. A SHOP footer button (styled like the existing
“Remote Control” footer) added to _cards; activating it emits shop_requested (route it in _input’s
confirm + its pressed handler via a set_meta("mode", "shop") sentinel, like “remote”).
- Step 1: Write failing test (
tests/test_start_menu.gd)
extends GutTest
func test_shop_entry_emits_shop_requested() -> void: var m := StartMenu.new(); add_child_autofree(m); await get_tree().process_frame watch_signals(m) # find the shop card by its mode meta and fire it var fired := false for c in m._cards: if String(c.get_meta("mode", "")) == "shop": c.emit_signal("pressed"); fired = true; break assert_true(fired, "a SHOP entry exists") assert_signal_emitted(m, "shop_requested")- Step 2: Run — expect FAIL.
- Step 3: Add the
shop_requestedsignal + a SHOP footer button (set_meta("mode","shop"),pressed→shop_requested.emit()); in_inputconfirm, routesel_mode == "shop"→ emit. - Step 4:
--import+ boot-check. Run — expect PASS. Full suite + count + determinism. Commitui/start_menu.gd tests/test_start_menu.gd.
Task 6: PauseMenu Shop button
Section titled “Task 6: PauseMenu Shop button”Files:
- Modify:
ui/pause_menu.gd - Test:
tests/test_pause_menu.gd(create)
Interfaces — Produces: signal shop_requested. A “Shop” button added to _buttons (between Resume
and Back to Start Menu), pressed → shop_requested.emit().
- Step 1: Write failing test (
tests/test_pause_menu.gd)
extends GutTest
func test_shop_button_emits_shop_requested() -> void: var p := PauseMenu.new(); add_child_autofree(p); await get_tree().process_frame watch_signals(p) var found := false for b in p._buttons: if b.text == "Shop": b.emit_signal("pressed"); found = true; break assert_true(found, "a Shop button exists") assert_signal_emitted(p, "shop_requested")- Step 2: Run — expect FAIL.
- Step 3: Add the signal +
_make_btn("Shop", func(): shop_requested.emit())(keep default focus on Resume — re-check the_selindex if button order shifts). - Step 4:
--import+ boot-check. Run — expect PASS. Full suite + count + determinism. Commitui/pause_menu.gd tests/test_pause_menu.gd.
Task 7: main.gd wiring — one _open_shop(return_to), three entry points
Section titled “Task 7: main.gd wiring — one _open_shop(return_to), three entry points”Files:
- Modify:
main.gd - Verify: boot smoke + playtest (UI wiring; no unit test for main.gd’s scene assembly).
Interfaces — Consumes: all the signals from Tasks 3–6. Produces: the routing.
- Step 1: Game-over → Results (not the shop). Replace the
meta_shop.show_shop(...)branch:
if sim.game_over: _bank_run() audio.game_over() results.show_results(sim, meta.banked_gold if meta != null else -1)- Step 2: Add
_open_shop+ wire Results signals in the panel-setup block (replace themeta_shop.restart_requested.connect(_new_run)line; ResultsPanel is created atmain.gd:432):
func _open_shop(return_to: Callable) -> void: meta_shop.open_shop(meta, sim.content.meta_upgrades() if sim != null else meta_defs(), return_to)
# meta upgrades when no run is live (start-menu shop): load from the committed bible once.func meta_defs() -> Array: return _menu_content().meta_upgrades() # _menu_content(): cache a ContentLoader.load_from_pathWire:
-
results.redeploy_requested.connect(_new_run) -
results.shop_requested.connect(func() -> void: results.visible = false; _open_shop(func() -> void: results.visible = true; results.refocus())) -
results.menu_requested.connect(_return_to_menu) -
meta_shopno longer connectsrestart_requested(removed). -
Step 3: Start-menu + pause-menu entry.
- In
_show_start_menu:start_menu.shop_requested.connect(func() -> void: start_menu.visible = false; _open_shop(func() -> void: start_menu.visible = true)). (Recreate-on-return is fine too; whichever restores focus.) - In
_toggle_pause(where the pause menu is built):pause_menu.shop_requested.connect(func() -> void: pause_menu.visible = false; _open_shop(func() -> void: if pause_menu != null: pause_menu.visible = true)). The run stays paused (_paused_for_menuunchanged).
- In
-
Step 4: Teardown safety.
_return_to_menu/_new_runalreadyqueue_freenon-persistent children incl.meta_shop+results; confirmmeta_shopis recreated in the setup block so a reopened run has a fresh panel. The shop’s_return_tocallables captureresults/start_menu/pause_menu— ensure they’re still valid when invoked (they are: the shop closes before the run tears down). -
Step 5: Boot-check (
--quit-after 90 | grep "SCRIPT ERROR"empty) + full suite + count + determinism (unchanged). Manual playtest on desktop: start-menu SHOP, pause→Shop mid-run, die→Results→ Shop→back, Redeploy, Main Menu. Commitmain.gd.
Task 8: Deploy
Section titled “Task 8: Deploy”- Run the
bh-deployritual (bumpSim_Const.BUILD, sync main→tvOS gameplay + wholetests/, verify tvOS suite/count, export.pck, xcodebuild, devicectl install/launch). Update the roadmap memory with the new BUILD + “shop overhaul shipped”.
Sequencing notes
Section titled “Sequencing notes”- Tasks 1–2 are pure data/logic (trivially green). Task 3 is the big rewrite; 4–6 are small per-panel
additions; 7 is wiring. Every task is determinism-neutral — the determinism test must stay
1405185210/3122397125(survival) and91572468/1173256610(crystals) throughout; if it ever moves, something leaked into/sim— stop and investigate. - After this lands, the Drones category is ready to absorb the drone shop trees (roadmap #3).