Skip to content

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.

  • sim/meta_state.gd is pure logic — no Node/Engine/File APIs (MetaState extends RefCounted). reset_shop() must not touch persistence directly; the render side calls MetaStore.save_state after it, exactly like every other shop action.
  • tests/test_meta_shop_panel.gd has a documented, deliberate constraint (its own header comment): it never exercises a code path that calls MetaStore.save_state, because doing so writes to the REAL user://meta.json in 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 — MetaState lives entirely outside Sim.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.

  • Modify: sim/meta_state.gd — add reset_shop(defs: Array) -> void.
  • Modify: tests/test_meta_state.gd — tests for reset_shop().
  • Modify: ui/meta_shop_panel.gd — add the confirm/cancel dialog (built once in _ready()), a RESET_ID root-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).

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 across defs (each a Dictionary with id/base_cost/cost_growth, the same shape cost() already reads), adds it to banked_gold, clears levels to {}, and resets selected_ship to ShipBonuses.DEFAULT_SHIP, selected_decoy to "basic", and drone_loadout to ["sentinel"]. Does not touch seen_enemies or tutorial_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
Terminal window
godot --headless --path . --import
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_state.gd -gexit

Expected: 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
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_state.gd -gexit

Expected: all tests in the file pass (existing + 6 new).

  • Step 5: Boot-check and full suite
Terminal window
godot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"

Expected: no output.

Terminal window
bash scripts/check-test-count.sh

Expected: 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
Terminal window
git add sim/meta_state.gd tests/test_meta_state.gd
git 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) -> StyleBoxFlat and MetaShopPanel._label(txt: String, font: Font, size: int, col: Color) -> Label (both already exist in this file, near the bottom — _card_box at line 962, _label at line 953).
  • Produces (for Task 3 to wire up): _reset_dialog: Control (null until _ready() runs, then always non-null, visible toggled), _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
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_shop_panel.gd -gexit

Expected: 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 accent

Near 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 once
var _reset_dim: ColorRect # full-screen dim behind the dialog panel
var _reset_confirm_btn: Button
var _reset_cancel_btn: Button
var _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
Terminal window
godot --headless --path . --import
godot --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 -gexit

Expected: no SCRIPT ERROR output from the boot-check; all tests in the file pass.

  • Step 6: Full suite + count guard
Terminal window
bash scripts/check-test-count.sh

Expected: 0 failing.

  • Step 7: Commit
Terminal window
git add ui/meta_shop_panel.gd tests/test_meta_shop_panel.gd
git 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>"

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) -> Button and _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 _items on 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
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_shop_panel.gd -gexit

Expected: the 5 new tests fail (RESET_ID doesn’t exist yet — parse error on MetaShopPanel.RESET_ID).

  • Step 3: Add the RESET_ID constant

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
Terminal window
godot --headless --path . --import
godot --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 -gexit

Expected: 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
Terminal window
bash scripts/check-test-count.sh
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_crystals.gd -gexit

Expected: 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

Terminal window
git add ui/meta_shop_panel.gd tests/test_meta_shop_panel.gd
git commit -m "feat(shop): wire RESET SHOP into the root carousel, confirm triggers the respec
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>"

  • 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_btn is focus_mode = FOCUS_NONE and reachable only by touch/mouse click or the hardcoded ui_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.