Reset Shop (respec) Implementation Plan
Reset Shop (respec) Implementation Plan
Section titled “Reset Shop (respec) 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 for tracking.
Goal: Add a player-facing “Reset Shop” respec — refunds every gold coin ever spent (stat levels and one-time unlocks), clears all of it, and reverts ship/decoy/drone-loadout selection to their starter defaults — reachable from the meta shop’s root view, gated behind a confirm/cancel dialog.
Architecture: MetaState.reset_shop(defs) (pure logic, sim/meta_state.gd) does the actual
refund-and-clear math. MetaShopPanel (ui/meta_shop_panel.gd) adds a “RESET SHOP” tile to the
existing root-view carousel (so it’s reachable via touch, mouse, keyboard, and controller — the
same mechanics as every category tile) and a new confirm/cancel dialog, built once in _ready()
and shown/hidden rather than rebuilt per-view.
Tech Stack: Godot 4.6 / GDScript, GUT 9.6.0 test framework.
Global Constraints
Section titled “Global Constraints”sim/meta_state.gdis pure logic — no Node/Engine/File APIs (MetaState extends RefCounted).reset_shop()must not touch persistence directly; the render side callsMetaStore.save_stateafter it, exactly like every other shop action.tests/test_meta_shop_panel.gdhas a documented, deliberate constraint (its own header comment): it never exercises a code path that callsMetaStore.save_state, because doing so writes to the REALuser://meta.jsonin a headless test run and would clobber Chris’s actual save file. Every new panel-level test in this plan must respect that — test the confirm dialog’s open/cancel wiring, but never call the real confirm-and-save path from a test.- Determinism is not affected by anything in this plan —
MetaStatelives entirely outsideSim.tick(). No re-pin needed. - Follow the existing neon-panel visual convention (
Panel/PanelContainer+StyleBoxFlat,NeonTheme.mono_font()/title_font()) — this file already has_card_box()/_label()helpers for this; reuse them rather than hand-rolling new styling.
File structure
Section titled “File structure”- Modify:
sim/meta_state.gd— addreset_shop(defs: Array) -> void. - Modify:
tests/test_meta_state.gd— tests forreset_shop(). - Modify:
ui/meta_shop_panel.gd— add the confirm/cancel dialog (built once in_ready()), aRESET_IDroot-view carousel tile, and the_activate()/_input()wiring between them. - Modify:
tests/test_meta_shop_panel.gd— tests for the dialog and the tile’s wiring (excluding the save-triggering confirm path, per the Global Constraints note above).
Task 1: MetaState.reset_shop()
Section titled “Task 1: MetaState.reset_shop()”Files:
- Modify:
sim/meta_state.gd - Test:
tests/test_meta_state.gd
Interfaces:
-
Produces:
MetaState.reset_shop(defs: Array) -> void— refunds gold for every currently-owned level acrossdefs(each aDictionarywithid/base_cost/cost_growth, the same shapecost()already reads), adds it tobanked_gold, clearslevelsto{}, and resetsselected_shiptoShipBonuses.DEFAULT_SHIP,selected_decoyto"basic", anddrone_loadoutto["sentinel"]. Does not touchseen_enemiesortutorial_done. -
Step 1: Write the failing tests
Add to tests/test_meta_state.gd, after test_cruiser_gates_on_the_unlock_purchase (the file’s
last test) — reuses the existing _def(over: Dictionary = {}) helper already at the top of this
file (base_cost=40, cost_growth=1.5, max_level=3):
# ── Reset shop (respec) ─────────────────────────────────────────────────────
func test_reset_shop_refunds_exact_amount_spent() -> void: var m := MetaState.new() m.banked_gold = 1000 var d := _def() m.buy(d); m.buy(d) # level 2: paid 40 (level 0->1) + 60 (level 1->2, 40*1.5) = 100 var gold_before_reset := m.banked_gold # 900 m.reset_shop([d]) assert_eq(m.banked_gold, gold_before_reset + 100, "refunds exactly what was spent (40 + 60)")
func test_reset_shop_clears_levels() -> void: var m := MetaState.new() m.banked_gold = 1000 var d := _def() m.buy(d) m.reset_shop([d]) assert_eq(m.level_of("vitality"), 0, "level cleared back to 0")
func test_reset_shop_refunds_across_multiple_defs() -> void: var m := MetaState.new() m.banked_gold = 1000 var d1 := _def() # id "vitality", base_cost 40 var d2 := _def({"id": "haste", "base_cost": 20, "cost_growth": 2.0, "max_level": 5}) m.buy(d1) # -40 m.buy(d2); m.buy(d2) # -20, -40 (20*2^0, 20*2^1) var gold_before_reset := m.banked_gold # 1000 - 40 - 20 - 40 = 900 m.reset_shop([d1, d2]) assert_eq(m.banked_gold, gold_before_reset + 100, "refunds every def's spend, summed") assert_eq(m.level_of("vitality"), 0) assert_eq(m.level_of("haste"), 0)
func test_reset_shop_resets_ship_decoy_and_drone_loadout_to_defaults() -> void: var m := MetaState.new() m.selected_ship = "aurum" m.selected_decoy = "tank" m.drone_loadout = ["bomber", "interceptor"] m.reset_shop([]) assert_eq(m.selected_ship, ShipBonuses.DEFAULT_SHIP, "ship reverts to the default") assert_eq(m.selected_decoy, "basic", "decoy reverts to the default") assert_eq(m.drone_loadout, ["sentinel"], "drone loadout reverts to the default")
func test_reset_shop_does_not_touch_bestiary_or_tutorial() -> void: var m := MetaState.new() m.mark_seen("ghost") m.tutorial_done = true m.reset_shop([]) assert_true(m.has_seen("ghost"), "bestiary codex is untouched by a shop reset") assert_true(m.tutorial_done, "tutorial-seen flag is untouched by a shop reset")
func test_reset_shop_with_nothing_bought_is_a_gold_noop() -> void: var m := MetaState.new() m.banked_gold = 250 m.reset_shop([_def()]) assert_eq(m.banked_gold, 250, "nothing owned means nothing to refund")- Step 2: Run tests to verify they fail
godot --headless --path . --importgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_state.gd -gexitExpected: SCRIPT ERROR: Invalid call. Nonexistent function 'reset_shop' in base 'RefCounted (MetaState)'
(or similar), all 6 new tests failing.
- Step 3: Implement
reset_shop()
In sim/meta_state.gd, add after buy() (which ends right before the apply_to doc comment —
search for func apply_to(player: PlayerState, defs: Array) -> void: and insert immediately
above it):
# Full respec: refunds every gold coin ever spent (stats AND unlocks) and clears all of it back# to zero. Reverts ship/decoy/drone-loadout selection to starter defaults, since whatever was# selected may no longer be owned after the wipe. Does NOT touch seen_enemies/tutorial_done --# those aren't shop purchases. Reuses cost()'s exact geometric formula (base_cost * cost_growth^i)# evaluated at each already-owned level, so the refund is always exactly what was spent -- no# separate "total spent" ledger to keep in sync if costs are rebalanced later.func reset_shop(defs: Array) -> void: var refund := 0 for def in defs: if not (def is Dictionary): continue var lvl := level_of(def["id"]) var growth: float = float(def.get("cost_growth", 1.5)) var base: float = float(def["base_cost"]) for i in range(lvl): refund += int(round(base * pow(growth, i))) banked_gold += refund levels = {} selected_ship = ShipBonuses.DEFAULT_SHIP selected_decoy = "basic" drone_loadout = ["sentinel"]- Step 4: Run tests to verify they pass
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_state.gd -gexitExpected: all tests in the file pass (existing + 6 new).
- Step 5: Boot-check and full suite
godot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"Expected: no output.
bash scripts/check-test-count.shExpected: test-count guard OK: 208/208 test scripts ran, 0 failing (count may differ slightly if
other work has landed since this plan was written — 0 failing is what matters).
- Step 6: Commit
git add sim/meta_state.gd tests/test_meta_state.gdgit commit -m "feat(meta): add MetaState.reset_shop() -- full respec, refund + clear
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>"Task 2: Confirm/cancel dialog in MetaShopPanel
Section titled “Task 2: Confirm/cancel dialog in MetaShopPanel”Files:
- Modify:
ui/meta_shop_panel.gd - Test:
tests/test_meta_shop_panel.gd
Interfaces:
- Consumes:
MetaShopPanel._card_box(accent: Color, fill: float) -> StyleBoxFlatandMetaShopPanel._label(txt: String, font: Font, size: int, col: Color) -> Label(both already exist in this file, near the bottom —_card_boxat line 962,_labelat line 953). - Produces (for Task 3 to wire up):
_reset_dialog: Control(null until_ready()runs, then always non-null,visibletoggled),_open_reset_confirm() -> void,_close_reset_dialog() -> void,_reset_selected: int(0 = CONFIRM, 1 = CANCEL).
This task builds the dialog and makes it independently operable (open/close/toggle-selection) —
nothing calls _open_reset_confirm() yet from real gameplay; that’s Task 3.
- Step 1: Write the failing tests
Add to tests/test_meta_shop_panel.gd, at the end of the file:
# ── Reset-shop confirm dialog (Task 2: dialog mechanics only, not yet wired to a button) ────
func test_reset_dialog_starts_hidden() -> 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 p.open_shop(meta, content.meta_upgrades(), func() -> void: pass) assert_false(p._reset_dialog.visible, "dialog is hidden until explicitly opened") p.hide_panel()
func test_open_reset_confirm_shows_the_dialog_and_defaults_to_cancel() -> 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 p.open_shop(meta, content.meta_upgrades(), func() -> void: pass) p._open_reset_confirm() assert_true(p._reset_dialog.visible, "opening shows the dialog") assert_eq(p._reset_selected, 1, "defaults to CANCEL, the safe option") p.hide_panel()
func test_close_reset_dialog_hides_it() -> 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 p.open_shop(meta, content.meta_upgrades(), func() -> void: pass) p._open_reset_confirm() p._close_reset_dialog() assert_false(p._reset_dialog.visible, "closing hides the dialog") p.hide_panel()- Step 2: Run tests to verify they fail
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_shop_panel.gd -gexitExpected: Invalid get index '_reset_dialog' or similar — the field doesn’t exist yet.
- Step 3: Add the dialog fields and constants
In ui/meta_shop_panel.gd, near the other color consts (after const STEEL := Color(0.55, 0.8, 1.0)
at line 37):
const DANGER := Color(1.0, 0.32, 0.3) # reset-shop warning accentNear the other var fields (after var _last_nav_ms: int = 0 at line 65):
var _reset_dialog: Control # confirm/cancel overlay for the RESET SHOP action; built oncevar _reset_dim: ColorRect # full-screen dim behind the dialog panelvar _reset_confirm_btn: Buttonvar _reset_cancel_btn: Buttonvar _reset_selected: int = 1 # 0 = CONFIRM, 1 = CANCEL -- defaults to the safe option- Step 4: Build the dialog in
_ready()
In ui/meta_shop_panel.gd, in _ready(), right after add_child(_close_btn) (line 135) and
before visible = false (line 137):
_build_reset_dialog()Then add the builder function near the bottom of the file, right before func _label(...) (line
953) — it uses _card_box/_label, both defined just below it, which is fine since GDScript
doesn’t require forward-declaration order within a class:
# Confirm/cancel overlay for RESET SHOP. Built once here (not per-view like the carousel cards)# and just shown/hidden -- matches _close_btn's "always exists, toggle visibility" pattern rather# than the carousel's rebuild-every-render one, since this has no per-item content to vary.func _build_reset_dialog() -> void: _reset_dialog = Control.new() _reset_dialog.set_anchors_preset(Control.PRESET_FULL_RECT) _reset_dialog.mouse_filter = Control.MOUSE_FILTER_STOP # blocks clicks reaching the carousel underneath _reset_dialog.visible = false add_child(_reset_dialog)
_reset_dim = ColorRect.new() _reset_dim.set_anchors_preset(Control.PRESET_FULL_RECT) _reset_dim.color = Color(0.0, 0.0, 0.0, 0.72) _reset_dim.mouse_filter = Control.MOUSE_FILTER_STOP _reset_dialog.add_child(_reset_dim)
# CenterContainer + PRESET_FULL_RECT, matching ui/control_hint.gd's proven centering pattern # (a manual position-minus-half-size calc is the exact class of bug documented in # docs/godot-gotchas.md -- NeonBackdrop's zero-rect collapse. Don't repeat it here). var center := CenterContainer.new() center.set_anchors_preset(Control.PRESET_FULL_RECT) center.mouse_filter = Control.MOUSE_FILTER_IGNORE _reset_dialog.add_child(center)
var panel := PanelContainer.new() panel.custom_minimum_size = Vector2(460, 200) panel.add_theme_stylebox_override("panel", _card_box(DANGER, 0.1)) center.add_child(panel)
var vbox := VBoxContainer.new() vbox.add_theme_constant_override("separation", 16) vbox.custom_minimum_size = Vector2(420, 0) panel.add_child(vbox)
var title := _label("RESET SHOP?", NeonTheme.title_font(), 26, DANGER) title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER title.size_flags_horizontal = Control.SIZE_EXPAND_FILL vbox.add_child(title)
var body := _label( "Refunds all gold spent and clears every stat level and unlock — including owned ships, weapons, and drones. This cannot be undone.", NeonTheme.mono_font(), 15, STEEL) body.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART body.size_flags_horizontal = Control.SIZE_EXPAND_FILL vbox.add_child(body)
var row := HBoxContainer.new() row.alignment = BoxContainer.ALIGNMENT_CENTER row.add_theme_constant_override("separation", 20) vbox.add_child(row)
_reset_confirm_btn = Button.new() _reset_confirm_btn.text = "CONFIRM" _reset_confirm_btn.custom_minimum_size = Vector2(140, 44) _reset_confirm_btn.focus_mode = Control.FOCUS_NONE # this panel drives selection manually, like every carousel card _reset_confirm_btn.add_theme_font_override("font", NeonTheme.mono_font()) _reset_confirm_btn.add_theme_font_size_override("font_size", 16) _reset_confirm_btn.add_theme_color_override("font_color", DANGER) _reset_confirm_btn.pressed.connect(_confirm_reset_shop) row.add_child(_reset_confirm_btn)
_reset_cancel_btn = Button.new() _reset_cancel_btn.text = "CANCEL" _reset_cancel_btn.custom_minimum_size = Vector2(140, 44) _reset_cancel_btn.focus_mode = Control.FOCUS_NONE _reset_cancel_btn.add_theme_font_override("font", NeonTheme.mono_font()) _reset_cancel_btn.add_theme_font_size_override("font_size", 16) _reset_cancel_btn.add_theme_color_override("font_color", STEEL) _reset_cancel_btn.pressed.connect(_close_reset_dialog) row.add_child(_reset_cancel_btn)
_update_reset_dialog_highlight()
# Highlights whichever of CONFIRM/CANCEL is currently selected (controller/keyboard nav) with a# brighter fill; the other stays dim. Mouse/touch clicks work independently via the .pressed# signals above, same as every carousel card.func _update_reset_dialog_highlight() -> void: _reset_confirm_btn.add_theme_stylebox_override("normal", _card_box(DANGER, 0.35 if _reset_selected == 0 else 0.08)) _reset_cancel_btn.add_theme_stylebox_override("normal", _card_box(STEEL, 0.35 if _reset_selected == 1 else 0.08))
func _open_reset_confirm() -> void: _reset_selected = 1 # always default to the safe option _update_reset_dialog_highlight() _reset_dialog.visible = true
func _close_reset_dialog() -> void: _reset_dialog.visible = false
# Task 3 replaces this stub with the real reset+save+refresh call.func _confirm_reset_shop() -> void: _close_reset_dialog()- Step 5: Run tests to verify they pass
godot --headless --path . --importgodot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_shop_panel.gd -gexitExpected: no SCRIPT ERROR output from the boot-check; all tests in the file pass.
- Step 6: Full suite + count guard
bash scripts/check-test-count.shExpected: 0 failing.
- Step 7: Commit
git add ui/meta_shop_panel.gd tests/test_meta_shop_panel.gdgit commit -m "feat(shop): add the RESET SHOP confirm/cancel dialog (not yet wired to a button)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>"Task 3: Wire a RESET SHOP tile into the root carousel
Section titled “Task 3: Wire a RESET SHOP tile into the root carousel”Files:
- Modify:
ui/meta_shop_panel.gd - Test:
tests/test_meta_shop_panel.gd
Interfaces:
- Consumes:
_open_reset_confirm(),_close_reset_dialog(),_reset_selected,_reset_dialog(all from Task 2);MetaState.reset_shop(defs: Array) -> void(from Task 1);_make_card(id, accent, bright, card_w, card_h) -> Buttonand_set_card_text(card, name_text, desc_text, accent, right_text="", icon_key="", ...) -> void(both pre-existing in this file, at lines 484 and 504). - Produces:
MetaShopPanel.RESET_ID(a public const, so tests and any future caller can reference it) appearing in_itemson the root view;_activate(RESET_ID)opens the confirm dialog.
Placed as a carousel tile (not a fixed corner button like _close_btn) specifically so it’s
reachable via the SAME touch/mouse/keyboard/controller path as every category tile — a
controller-only platform (Apple TV) has no way to reach a focus_mode = FOCUS_NONE corner button
the way _close_btn is reached (its controller equivalent is the separate, hardcoded
ui_cancel/B action, not reusable for a different action).
- Step 1: Write the failing tests
Add to tests/test_meta_shop_panel.gd, after the Task 2 tests:
# ── Reset-shop tile wiring (Task 3) ──────────────────────────────────────────────────────────# Deliberately does NOT test pressing CONFIRM through to completion -- that calls# MetaStore.save_state(_meta), which this file's own header comment already flags as unsafe to# exercise in a headless test run (it would clobber the real user://meta.json). reset_shop()'s# own correctness is fully covered at the MetaState level in tests/test_meta_state.gd; these# tests cover only the panel-level WIRING (the tile exists, activating it opens the dialog,# cancel is a true no-op).
func test_reset_tile_appears_on_root_view() -> 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 p.open_shop(meta, content.meta_upgrades(), func() -> void: pass) assert_true(p._items.has(MetaShopPanel.RESET_ID), "RESET SHOP tile is present on the root view") p.hide_panel()
func test_reset_tile_absent_from_category_view() -> 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 p.open_shop(meta, content.meta_upgrades(), func() -> void: pass) p._show_category("Pilot") assert_false(p._items.has(MetaShopPanel.RESET_ID), "RESET SHOP tile only appears on the root view") p.hide_panel()
func test_activating_reset_tile_opens_the_dialog_without_changing_state() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var meta := MetaState.new() meta.banked_gold = 500 meta.levels["vitality"] = 2 var p := MetaShopPanel.new() add_child_autofree(p) await get_tree().process_frame p.open_shop(meta, content.meta_upgrades(), func() -> void: pass) p._activate(MetaShopPanel.RESET_ID) assert_true(p._reset_dialog.visible, "activating the tile opens the confirm dialog") assert_eq(meta.banked_gold, 500, "opening the dialog alone must not touch gold") assert_eq(meta.level_of("vitality"), 2, "opening the dialog alone must not touch levels") p.hide_panel()
func test_cancel_after_activating_reset_tile_leaves_state_untouched() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var meta := MetaState.new() meta.banked_gold = 500 meta.levels["vitality"] = 2 meta.selected_ship = "aurum" var p := MetaShopPanel.new() add_child_autofree(p) await get_tree().process_frame p.open_shop(meta, content.meta_upgrades(), func() -> void: pass) p._activate(MetaShopPanel.RESET_ID) p._close_reset_dialog() assert_false(p._reset_dialog.visible, "dialog closes") assert_eq(meta.banked_gold, 500, "cancel must not touch gold") assert_eq(meta.level_of("vitality"), 2, "cancel must not touch levels") assert_eq(meta.selected_ship, "aurum", "cancel must not touch ship selection") p.hide_panel()
func test_reset_tile_card_is_built_with_the_danger_accent() -> 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 p.open_shop(meta, content.meta_upgrades(), func() -> void: pass) var card: Button = p._build_item_card(MetaShopPanel.RESET_ID) assert_not_null(card, "a card is built for the RESET SHOP tile") p.hide_panel()- Step 2: Run tests to verify they fail
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_shop_panel.gd -gexitExpected: the 5 new tests fail (RESET_ID doesn’t exist yet — parse error on
MetaShopPanel.RESET_ID).
- Step 3: Add the
RESET_IDconstant
In ui/meta_shop_panel.gd, next to const BACK_ID := "__back__" (line 22):
const RESET_ID := "__reset__"- Step 4: Add the tile to the root view’s item list
In func _render(keep: int) (line 221), the root-view branch currently reads:
if _view == "root": _build_header("SHOP") _items = ShopCategories.present(_defs)Change to:
if _view == "root": _build_header("SHOP") _items = ShopCategories.present(_defs) _items.append(RESET_ID)- Step 5: Build the tile’s card
In func _build_item_card(item) (line 244), right after the existing BACK_ID branch (which
ends at return back / line 252) and before if _view == "root": (line 253):
if item is String and item == RESET_ID: var tile := _make_card(RESET_ID, DANGER, true, ROOT_CARD_SIZE, ROOT_CARD_SIZE) _set_card_text(tile, "RESET SHOP", "Refund everything", DANGER) return tile- Step 6: Wire activation to open the dialog
In func _activate(id: String) (line 604), right after the existing BACK_ID branch:
func _activate(id: String) -> void: if id == BACK_ID: _go_back() return if id == RESET_ID: _ui_nav() _open_reset_confirm() return- Step 7: Wire the real confirm handler (replaces Task 2’s stub)
Replace the stub from Task 2:
# Task 3 replaces this stub with the real reset+save+refresh call.func _confirm_reset_shop() -> void: _close_reset_dialog()with:
func _confirm_reset_shop() -> void: _meta.reset_shop(_defs) MetaStore.save_state(_meta) _close_reset_dialog() _rebuild_current()- Step 8: Intercept input while the dialog is open
In func _input(event: InputEvent) (line 660), right after the top guard
(if not visible or _cards.is_empty(): return) and before the touch/mouse drag handling:
func _input(event: InputEvent) -> void: if not visible or _cards.is_empty(): return
if _reset_dialog.visible: _handle_reset_dialog_input(event) return
# ── Touch/mouse live-follow drag (Task 4) ────────────────────────────── ...Add the handler near _carousel_step (after line 730, func _carousel_step), reusing the exact
same action/button conventions as the carousel’s own confirm/back handling just above it in this
same file:
# Routes input to the CONFIRM/CANCEL dialog instead of the carousel while it's open. Mirrors the# carousel's own ui_accept/ui_cancel/MenuNav handling just above in _input() for consistency --# ui_cancel/B/Esc always means CANCEL (the safe out), regardless of which button is highlighted;# left/right toggles the highlight since there are only ever two options.func _handle_reset_dialog_input(event: InputEvent) -> void: var confirm: bool = event.is_action_pressed("ui_accept") if not confirm and event is InputEventJoypadButton: var je: InputEventJoypadButton = event confirm = je.pressed and je.button_index == JOY_BUTTON_A if confirm: if _reset_selected == 0: _confirm_reset_shop() else: _close_reset_dialog() get_viewport().set_input_as_handled() return
var back: bool = event.is_action_pressed("ui_cancel") if not back and event is InputEventJoypadButton: var jb: InputEventJoypadButton = event back = jb.pressed and jb.button_index == JOY_BUTTON_B if not back and event is InputEventKey: var ke: InputEventKey = event back = ke.pressed and not ke.echo and ke.keycode == KEY_ESCAPE if back: _close_reset_dialog() get_viewport().set_input_as_handled() return
if MenuNav.is_right(event) or MenuNav.is_left(event): _reset_selected = 1 - _reset_selected _update_reset_dialog_highlight() get_viewport().set_input_as_handled()- Step 9: Run tests to verify they pass
godot --headless --path . --importgodot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_shop_panel.gd -gexitExpected: no SCRIPT ERROR; all tests pass, including the pre-existing ones (the root view’s item
count grew by one everywhere ShopCategories.present(_defs).size() is used as an expectation —
check the existing test_root_cards_have_a_category_icon style tests still pass; if any hardcode
an exact root item count instead of deriving it from ShopCategories.present, that assertion needs
updating to + 1 for the new tile).
- Step 10: Full suite, count guard, determinism
bash scripts/check-test-count.shgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_crystals.gd -gexitExpected: 0 failing; both determinism tests pass unchanged (this plan never touches /sim’s
deterministic tick path).
- Step 11: Manual verification (not automatable — GPUParticles/visual layout)
Open the shop in a real run (or via the start menu’s shop entry) and confirm:
-
The RESET SHOP tile appears on the root view, in the danger-red accent, reachable by swiping/ clicking/pressing left-right to it and pressing confirm (controller A / Enter / click).
-
The confirm dialog is legible, centered, and dims the shop behind it.
-
CANCEL (default-highlighted) returns to the shop with nothing changed.
-
Buying an upgrade first, then RESET SHOP → CONFIRM refunds the gold and the shop’s displayed levels/costs immediately reflect the reset.
-
Step 12: Commit
git add ui/meta_shop_panel.gd tests/test_meta_shop_panel.gdgit commit -m "feat(shop): wire RESET SHOP into the root carousel, confirm triggers the respec
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>"Plan self-review notes
Section titled “Plan self-review notes”- Spec coverage: §2 (data model) → Task 1. §3 (UI: button, confirm dialog, wiring, focus/input
behavior) → Tasks 2-3. §4 (testing) → covered in each task’s own test step, with the
save-triggering-path exclusion explicitly documented (a deliberate, precedented deviation from
the spec’s original “ordinary focusable Buttons… Godot’s built-in focus system” framing — this
panel does not use Godot’s default Control focus system anywhere; it drives all nav through a
manual
_input()override, so the confirm dialog matches that established convention instead). - Placement correction from the spec: the spec described a fixed corner button like
_close_btn. Deeper investigation while writing this plan found_close_btnisfocus_mode = FOCUS_NONEand reachable only by touch/mouse click or the hardcodedui_cancel/B action — there is no way to reach a same-shaped new button from a controller alone. Since Apple TV (controller-only) is a primary platform for this game, Task 3 makes RESET SHOP a root-view carousel tile instead, reusing the exact same touch/mouse/keyboard/controller path every category tile already has. This preserves the spec’s product intent (a real, player-facing, reachable button) while fixing a platform-reachability gap the spec’s originally-assumed mechanism would have had.