Progress reset — implementation plan
Progress Reset Implementation Plan
Section titled “Progress Reset 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. In this repo also consultbh-sdd-concurrentbefore Task 1, and dispatch implementers asbh-implementerin their own worktree offorigin/main.
Goal: Real, announced, recoverable progress resets: a required reset (schema bump), a recommended reset (epoch bump, player chooses), and a manual “Reset progress” in the pause menu. All three back up the old save first, and the game can no longer silently overwrite a save it couldn’t read. A manual or recommended reset can be undone once from the pause menu (spec §5b).
Architecture: MetaState gains RESET_EPOCH / reset_epoch. A new pure helper SaveReset (meta/save_reset.gd) decides which notice to show, builds the post-wipe state, and holds the copy. MetaStore classifies each load (last_load_status), keeps the raw parsed dict, blocks save_state() while the load is unacknowledged, and exposes backup_file() / wipe_progress(). A new SaveResetNotice panel shows at boot before the start menu. The same panel’s manage mode is the progress dialog (Reset / Restore previous / Cancel, each action confirmed twice), opened from a pause-menu row paired with “Quit to menu”. A manual/recommended wipe copies the save to the single undo slot meta.undo.json; a restore loads it back.
Tech Stack: Godot 4.7, typed GDScript (warnings as errors), GUT.
Spec: docs/superpowers/specs/2026-10-04-progress-reset-design.md
Global Constraints
Section titled “Global Constraints”- Wipe clears:
banked_gold,gold_spent,levels,selected_ship,drone_loadout,seen_enemies. Wipe keeps:tutorial_done,seen_survival_intro,shop_tip_views,install_id,share_diagnostics. MetaState.new().reset_epoch == MetaState.RESET_EPOCH; a save dict missingreset_epochloads as0.- Reset offers are bundled in the build. Never add a remote-config path to them.
/simstays pure:sim/meta_state.gdgets constants/fields only, no FileAccess. The sim is untouched otherwise, so determinism baselines must not move.- Tests never touch the real save: GUT runs with
MetaStore.disk_io_enabled = false(tests/gut_pre_run.gd). File tests useuser://_test_*.jsontemp paths and delete them inafter_each. - Every test that mutates
MetaStorestatics (last_load_compatible,last_load_status,last_load_raw) restores them inafter_each(true,"none",{}). - Player copy: no em-dashes. “Decoy”, never “Sentinel”.
- Run the suite only via
scripts/check-test-count.sh, never a raw GUT run into context. Full gate:scripts/gate.sh. - After any windowed
godot --path .run:git checkout -- project.godot audio/bus_layout.tres. - Commit messages via
git commit --file=<file>, ending with the two attribution lines from the session.
Deviation from spec §5 (decided while planning): no simulate_reset_notice dev command or console control. That would mean hand-written Remote Console JS plus a Worker redeploy, just for screenshots. Visual QA instead captures the notice through two new tools/ui_sweep screens (Task 5), which construct the panel directly and never touch the save. (The spec is already updated.)
Added after the first draft (Chris, mid-planning): undo a reset. This is spec §5b, and it replaces §5’s two-press pause row with the progress dialog.
Task 1: MetaState.reset_epoch + SaveReset pure helper
Section titled “Task 1: MetaState.reset_epoch + SaveReset pure helper”Files:
- Modify:
sim/meta_state.gd(consts near line 14-18; var block;to_dict()~line 335;from_dict()~line 348) - Create:
meta/save_reset.gd - Test:
tests/test_save_reset.gd(create)
Interfaces:
-
Produces:
MetaState.RESET_EPOCH: int(const,0),MetaState.reset_epoch: intstatic func MetaState.schema_of(d: Dictionary) -> int: the save’s schema number, or-1when non-numeric/non-finite; a missing key reads asSCHEMA_VERSIONSaveReset.RESET_NOTICE: Stringstatic func SaveReset.notice_mode(load_status: String, save_epoch: int, build_epoch: int = MetaState.RESET_EPOCH) -> String: one of"required","corrupt","newer","unreadable","recommended",""static func SaveReset.fresh_from(old: Dictionary, fallback_install_id: String = "") -> MetaStatestatic func SaveReset.event_payload(kind: String, old: Dictionary) -> Dictionarystatic func SaveReset.copy(mode: String) -> Dictionary:{"title": String, "body": String}
-
Step 1: Write the failing tests (
tests/test_save_reset.gd)
extends GutTest# Progress reset (spec docs/superpowers/specs/2026-10-04-progress-reset-design.md): the pure half.
func test_fresh_meta_state_is_already_at_the_build_epoch() -> void: assert_eq(MetaState.new().reset_epoch, MetaState.RESET_EPOCH, "a fresh install is never offered a reset")
func test_a_save_without_reset_epoch_loads_as_zero() -> void: var m := MetaState.new() m.from_dict({"schema": MetaState.SCHEMA_VERSION, "banked_gold": 5}) assert_eq(m.reset_epoch, 0)
func test_reset_epoch_round_trips() -> void: var a := MetaState.new() a.reset_epoch = 3 var b := MetaState.new() b.from_dict(a.to_dict()) assert_eq(b.reset_epoch, 3)
func test_corrupt_reset_epoch_falls_back_to_zero() -> void: for bad: Variant in [null, "2", -1, [1], 1e30]: var m := MetaState.new() m.from_dict({"schema": MetaState.SCHEMA_VERSION, "reset_epoch": bad}) assert_eq(m.reset_epoch, 0, "bad value %s" % str(bad))
func test_schema_of() -> void: assert_eq(MetaState.schema_of({}), MetaState.SCHEMA_VERSION, "missing key = current (pre-versioning save)") assert_eq(MetaState.schema_of({"schema": 7}), 7) assert_eq(MetaState.schema_of({"schema": 0}), 0) assert_eq(MetaState.schema_of({"schema": "1"}), -1) assert_eq(MetaState.schema_of({"schema": null}), -1)
func test_notice_mode_by_load_status() -> void: assert_eq(SaveReset.notice_mode("old_schema", 0, 0), "required") assert_eq(SaveReset.notice_mode("corrupt", 0, 0), "corrupt") assert_eq(SaveReset.notice_mode("newer_schema", 0, 0), "newer") assert_eq(SaveReset.notice_mode("unreadable", 0, 0), "unreadable") assert_eq(SaveReset.notice_mode("ok", 0, 0), "") assert_eq(SaveReset.notice_mode("none", 0, 0), "")
func test_recommended_only_when_the_save_is_behind_the_build_epoch() -> void: assert_eq(SaveReset.notice_mode("ok", 0, 1), "recommended") assert_eq(SaveReset.notice_mode("ok", 1, 1), "") assert_eq(SaveReset.notice_mode("ok", 2, 1), "", "a newer epoch (downgrade) is never offered") assert_eq(SaveReset.notice_mode("none", 0, 1), "", "no save on disk: nothing to reset")
func test_unreadable_states_outrank_recommended() -> void: assert_eq(SaveReset.notice_mode("old_schema", 0, 5), "required", "one notice per boot, required wins") assert_eq(SaveReset.notice_mode("newer_schema", 0, 5), "newer")
func _rich_old() -> Dictionary: return {"schema": MetaState.SCHEMA_VERSION, "banked_gold": 900, "gold_spent": 400, "levels": {"vitality": 3}, "selected_ship": "obsidian", "drone_loadout": ["sentinel", "sentinel"], "seen_enemies": ["swarmer"], "tutorial_done": true, "seen_survival_intro": true, "shop_tip_views": 2, "install_id": "abc123", "share_diagnostics": false, "reset_epoch": 0}
func test_fresh_from_clears_progress_and_keeps_the_rest() -> void: var m := SaveReset.fresh_from(_rich_old()) assert_eq(m.banked_gold, 0) assert_eq(m.gold_spent, 0) assert_true(m.levels.is_empty()) assert_eq(m.selected_ship, ShipBonuses.DEFAULT_SHIP) assert_eq(m.drone_loadout, MetaState.new().drone_loadout) assert_true(m.seen_enemies.is_empty()) assert_true(m.tutorial_done) assert_true(m.seen_survival_intro) assert_eq(m.shop_tip_views, 2) assert_eq(m.install_id, "abc123") assert_false(m.share_diagnostics) assert_eq(m.reset_epoch, MetaState.RESET_EPOCH)
func test_fresh_from_reads_an_incompatible_schema_dict_leniently() -> void: var old := _rich_old() old["schema"] = -5 # below MIN_COMPAT: from_dict() would ignore every field var m := SaveReset.fresh_from(old) assert_true(m.tutorial_done, "kept fields come from the raw dict, not from_dict()") assert_eq(m.install_id, "abc123")
func test_fresh_from_hostile_kept_fields_fall_back_to_defaults() -> void: var m := SaveReset.fresh_from({"tutorial_done": "yes", "shop_tip_views": [1], "install_id": 5, "share_diagnostics": null, "seen_survival_intro": 1}) var d := MetaState.new() assert_eq(m.tutorial_done, d.tutorial_done) assert_eq(m.shop_tip_views, d.shop_tip_views) assert_eq(m.install_id, "") assert_eq(m.share_diagnostics, d.share_diagnostics) assert_eq(m.seen_survival_intro, d.seen_survival_intro)
func test_fresh_from_uses_the_fallback_install_id_only_when_none_was_kept_and_sharing_is_on() -> void: assert_eq(SaveReset.fresh_from({}, "session-id").install_id, "session-id") assert_eq(SaveReset.fresh_from({"install_id": "kept"}, "session-id").install_id, "kept") assert_eq(SaveReset.fresh_from({"share_diagnostics": false}, "session-id").install_id, "", "Diagnostics off: no id at all")
func test_event_payload() -> void: assert_eq(SaveReset.event_payload("manual", {"schema": 1, "reset_epoch": 2}), {"kind": "manual", "from_schema": 1, "from_epoch": 2}) assert_eq(SaveReset.event_payload("corrupt", {}), {"kind": "corrupt", "from_schema": MetaState.SCHEMA_VERSION, "from_epoch": 0})
func test_copy_exists_for_every_mode_without_em_dashes() -> void: for mode: String in ["required", "corrupt", "newer", "unreadable", "recommended", "manage", "manage_undo"]: var c := SaveReset.copy(mode) assert_ne(String(c.get("title", "")), "", mode) assert_ne(String(c.get("body", "")), "", mode) assert_false(String(c["body"]).contains("—"), "%s: no em-dash" % mode)- Step 2: Run, confirm FAIL
Run: godot --headless --path . --import (new class_name), then scripts/check-test-count.sh
Expected: FAIL, with parse errors naming SaveReset / reset_epoch / schema_of.
- Step 3: Implement
sim/meta_state.gd, after MIN_COMPAT_SCHEMA:
# Recommended-reset epoch (spec 2026-10-04 progress reset). A release that RECOMMENDS a fresh start# (old saves still load) bumps this; a save whose reset_epoch is lower gets the one-time "Reset now /# Keep my progress" offer at boot (meta/save_reset.gd). A REQUIRED reset bumps the schema consts# above instead. A fresh MetaState starts AT this epoch, so new installs are never offered one.const RESET_EPOCH := 0In the var block, beside seen_survival_intro:
var reset_epoch: int = RESET_EPOCH # last reset epoch this save answered (missing in a save = 0)to_dict(): add "reset_epoch": reset_epoch, to the returned dict.
from_dict(), after the shop_tip_views line:
reset_epoch = _sane_int(d.get("reset_epoch", 0), 0, 0, GOLD_SPENT_MAX)Below schema_compatible():
# The save's raw schema number: -1 when non-numeric (corrupt/hand-edited); a missing key reads as# the current schema (pre-versioning saves match today's additive shape). MetaStore uses it to tell# a too-old save (required reset) from a newer one (downgrade: preserve, never write).static func schema_of(d: Dictionary) -> int: return _sane_int(d.get("schema", SCHEMA_VERSION), -1, -1000000, 1000000)meta/save_reset.gd:
class_name SaveResetextends RefCounted
# Progress reset, the pure half (spec docs/superpowers/specs/2026-10-04-progress-reset-design.md).# Which boot notice to show, the post-wipe state, the telemetry payload and the player copy.# MetaStore owns the file side (backup, save guard, wipe_progress); main owns the flow.## RELEASE PROCEDURE (docs/architecture/meta-and-story.md § Progress reset):# recommended reset: bump MetaState.RESET_EPOCH, write RESET_NOTICE, mention it in What's New.# required reset: bump MetaState.SCHEMA_VERSION + MIN_COMPAT_SCHEMA, mention it in What's New.
# Why THIS build recommends a fresh start, shown in the offer. Empty = the generic body.const RESET_NOTICE := ""
static func notice_mode(load_status: String, save_epoch: int, build_epoch: int = MetaState.RESET_EPOCH) -> String: match load_status: "old_schema": return "required" "corrupt": return "corrupt" "newer_schema": return "newer" "unreadable": return "unreadable" "ok": return "recommended" if save_epoch < build_epoch else "" return ""
# A fresh save carrying only the kept fields, read leniently from a raw dict (a too-old schema can't# go through from_dict()). A hostile value falls back to the fresh default, never aborts.static func fresh_from(old: Dictionary, fallback_install_id: String = "") -> MetaState: var m := MetaState.new() var td: Variant = old.get("tutorial_done") if td is bool: m.tutorial_done = td var ssi: Variant = old.get("seen_survival_intro") if ssi is bool: m.seen_survival_intro = ssi m.shop_tip_views = MetaState._sane_int(old.get("shop_tip_views", 0), 0, 0, MetaState.SHOP_TIP_VIEWS) var sd: Variant = old.get("share_diagnostics") if sd is bool: m.share_diagnostics = sd var iid: Variant = old.get("install_id") if iid is String: m.install_id = iid if m.install_id == "" and m.share_diagnostics: m.install_id = fallback_install_id if not m.share_diagnostics: m.install_id = "" m.reset_epoch = MetaState.RESET_EPOCH return m
static func event_payload(kind: String, old: Dictionary) -> Dictionary: return {"kind": kind, "from_schema": MetaState.schema_of(old), "from_epoch": MetaState._sane_int(old.get("reset_epoch", 0), 0, 0, MetaState.GOLD_SPENT_MAX)}
static func copy(mode: String) -> Dictionary: match mode: "required": return {"title": "PROGRESS RESET", "body": "This update changes how progress is stored, so your progress has been reset. Your settings are kept. Thanks for playing while Dark Cosmos takes shape."} "corrupt": return {"title": "SAVE COULDN'T BE READ", "body": "Your save file couldn't be read, so your progress has been reset."} "newer": return {"title": "NEWER SAVE FOUND", "body": "This save is from a newer version of Dark Cosmos. Update the game to keep it. Progress this session won't be saved."} "unreadable": return {"title": "SAVE UNAVAILABLE", "body": "Your save couldn't be opened. Progress this session won't be saved. Restart the game to try again."} "manage": return {"title": "PROGRESS", "body": "Start again from scratch. Your settings and tutorial progress are kept."} "manage_undo": return {"title": "PROGRESS", "body": "Start again from scratch, or bring back the progress you had before your last reset."} "recommended": var body := RESET_NOTICE if RESET_NOTICE != "" else "This update works best from a fresh start. Reset your progress now, or keep it and carry on." return {"title": "FRESH START RECOMMENDED", "body": body} return {"title": "", "body": ""}(Check that _sane_int’s -1000000 lower bound doesn’t make {"schema": -5} read as -5 and get treated as corrupt elsewhere. schema_of returns -5 there, which Task 2 classifies as old_schema. Only a non-numeric schema yields -1. Note that a literal -1 schema also reads as -1, and Task 2 classifies -1 as corrupt. Both paths wipe after a backup, so that’s acceptable.)
- Step 4: Run, confirm PASS
Run: godot --headless --path . --import && scripts/check-test-count.sh
Expected: all pass, count up by the new tests. Also confirm tests/test_meta_state.gd and tests/test_meta_corrupt_save.gd still pass. They round-trip to_dict(), so an exact-dict assertion there may need reset_epoch added. Fix the assertion, not the field.
- Step 5: Commit
feat(meta): reset epoch + SaveReset pure helper (progress reset 1/5)
Task 2: MetaStore load status, save guard, backup, wipe_progress
Section titled “Task 2: MetaStore load status, save guard, backup, wipe_progress”Files:
- Modify:
meta/meta_store.gd(statics ~line 33-41;load_state()~95;load_from()~108-135;save_state()~220; new undo-slot functions) - Test:
tests/test_meta_store_reset.gd(create)
Interfaces:
-
Consumes:
MetaState.schema_of,SaveReset.fresh_from(Task 1) -
Produces:
static var MetaStore.last_load_status: String:"none" | "ok" | "unreadable" | "corrupt" | "old_schema" | "newer_schema"static var MetaStore.last_load_raw: Dictionary: the parsed save (also when incompatible),{}otherwisestatic func MetaStore.backup_file(src: String, build: int) -> String: dst path, or""static func MetaStore.wipe_progress(old: Dictionary, kind: String, fallback_install_id: String = "") -> MetaState(kind:"required" | "corrupt" | "recommended" | "manual")const MetaStore.UNDO_NAME := "meta.undo.json"static func MetaStore.copy_file(src: String, dst: String) -> boolstatic func MetaStore.read_undo_at(path: String) -> Dictionary: the parsed slot when it exists AND is schema-compatible, else{}static func MetaStore.restore_from_dict(d: Dictionary, current: MetaState) -> MetaState: pure; progress fromd, settings (share_diagnostics,install_id) fromcurrent,reset_epoch = maxi(d's, RESET_EPOCH)static func MetaStore.has_undo() -> bool,static func MetaStore.restore_undo(current: MetaState) -> MetaState(nullwhen there’s no usable slot)save_state()is a no-op whilelast_load_compatible == false, withlast_save_error == "blocked: unreadable save not acknowledged"
-
Step 1: Write the failing tests (
tests/test_meta_store_reset.gd)
extends GutTest# Progress reset, the file half. GUT runs with MetaStore.disk_io_enabled = false; load_from() and# backup_file() take explicit temp paths, so they still exercise real files.
const TMP := "user://_test_meta_store_reset.json"const BUILD := 999
func after_each() -> void: for p: String in [TMP, "user://meta.backup-%d.json" % BUILD]: if FileAccess.file_exists(p): DirAccess.remove_absolute(ProjectSettings.globalize_path(p)) MetaStore.last_load_compatible = true MetaStore.last_load_status = "none" MetaStore.last_load_raw = {}
func _write(txt: String) -> void: var f := FileAccess.open(TMP, FileAccess.WRITE) f.store_string(txt) f.close()
func test_status_none_when_no_file() -> void: MetaStore.load_from([TMP]) assert_eq(MetaStore.last_load_status, "none") assert_true(MetaStore.last_load_compatible)
func test_status_ok_and_raw_kept() -> void: _write(JSON.stringify({"schema": MetaState.SCHEMA_VERSION, "banked_gold": 12})) MetaStore.load_from([TMP]) assert_eq(MetaStore.last_load_status, "ok") assert_eq(int(MetaStore.last_load_raw.get("banked_gold", 0)), 12)
func test_status_old_schema() -> void: _write(JSON.stringify({"schema": MetaState.MIN_COMPAT_SCHEMA - 1, "tutorial_done": true})) MetaStore.load_from([TMP]) assert_eq(MetaStore.last_load_status, "old_schema") assert_false(MetaStore.last_load_compatible) assert_true(bool(MetaStore.last_load_raw.get("tutorial_done", false)), "raw kept for the wipe's kept fields")
func test_status_newer_schema() -> void: _write(JSON.stringify({"schema": MetaState.SCHEMA_VERSION + 1})) MetaStore.load_from([TMP]) assert_eq(MetaStore.last_load_status, "newer_schema") assert_false(MetaStore.last_load_compatible)
func test_status_corrupt_json_and_corrupt_schema() -> void: _write("{not json") MetaStore.load_from([TMP]) assert_eq(MetaStore.last_load_status, "corrupt") assert_eq(MetaStore.last_load_raw, {}) _write(JSON.stringify({"schema": "x"})) MetaStore.load_from([TMP]) assert_eq(MetaStore.last_load_status, "corrupt")
func test_save_state_blocked_while_the_load_is_unacknowledged() -> void: MetaStore.last_load_compatible = false var before := MetaStore.save_count MetaStore.save_state(MetaState.new()) assert_eq(MetaStore.save_count, before + 1, "still counted") assert_eq(MetaStore.last_save_error, "blocked: unreadable save not acknowledged")
func test_backup_file_is_byte_identical() -> void: var payload := JSON.stringify({"schema": 1, "banked_gold": 77, "levels": {"vitality": 2}}) _write(payload) var dst := MetaStore.backup_file(TMP, BUILD) assert_eq(dst, "user://meta.backup-%d.json" % BUILD) assert_eq(FileAccess.get_file_as_string(dst), payload)
func test_backup_file_missing_source_returns_empty() -> void: assert_eq(MetaStore.backup_file("user://_no_such_save.json", BUILD), "")
func test_wipe_progress_unblocks_saving_and_keeps_fields() -> void: MetaStore.last_load_compatible = false MetaStore.last_load_status = "old_schema" var m := MetaStore.wipe_progress({"tutorial_done": true, "banked_gold": 500, "install_id": "k"}, "required") assert_true(MetaStore.last_load_compatible) assert_eq(MetaStore.last_load_status, "ok") assert_eq(m.banked_gold, 0) assert_true(m.tutorial_done) assert_eq(m.install_id, "k") assert_ne(MetaStore.last_save_error, "blocked: unreadable save not acknowledged", "the wipe's own save went through the guard (disk IO is off in GUT, so it reports that instead)")
# ── Undo slot (spec §5b) ──const UNDO_TMP := "user://_test_meta_undo.json"const COPY_TMP := "user://_test_meta_copy.json"# (add UNDO_TMP and COPY_TMP to after_each's removal list)
func _write_at(path: String, txt: String) -> void: var f := FileAccess.open(path, FileAccess.WRITE) f.store_string(txt) f.close()
func test_copy_file_is_byte_identical() -> void: _write(JSON.stringify({"schema": 1, "banked_gold": 3})) assert_true(MetaStore.copy_file(TMP, COPY_TMP)) assert_eq(FileAccess.get_file_as_string(COPY_TMP), FileAccess.get_file_as_string(TMP)) assert_false(MetaStore.copy_file("user://_no_such.json", COPY_TMP))
func test_read_undo_at_only_returns_a_compatible_slot() -> void: assert_eq(MetaStore.read_undo_at(UNDO_TMP), {}, "missing") _write_at(UNDO_TMP, "{nope") assert_eq(MetaStore.read_undo_at(UNDO_TMP), {}, "corrupt") _write_at(UNDO_TMP, JSON.stringify({"schema": MetaState.SCHEMA_VERSION + 1})) assert_eq(MetaStore.read_undo_at(UNDO_TMP), {}, "a schema this build can't read is never offered") _write_at(UNDO_TMP, JSON.stringify({"schema": MetaState.SCHEMA_VERSION, "banked_gold": 40})) assert_eq(int(MetaStore.read_undo_at(UNDO_TMP).get("banked_gold", 0)), 40)
func test_restore_from_dict_takes_progress_from_the_slot_and_settings_from_now() -> void: var cur := MetaState.new() cur.share_diagnostics = false cur.install_id = "" cur.banked_gold = 5 # earned since the reset: replaced var slot := {"schema": MetaState.SCHEMA_VERSION, "banked_gold": 800, "levels": {"vitality": 2}, "share_diagnostics": true, "install_id": "old-id", "reset_epoch": 0} var m := MetaStore.restore_from_dict(slot, cur) assert_eq(m.banked_gold, 800) assert_eq(int(m.levels.get("vitality", 0)), 2) assert_false(m.share_diagnostics, "the current Diagnostics choice wins") assert_eq(m.install_id, "") assert_eq(m.reset_epoch, maxi(0, MetaState.RESET_EPOCH), "never re-offers a recommended reset")
func test_undo_is_unavailable_with_disk_io_off() -> void: assert_false(MetaStore.has_undo()) assert_null(MetaStore.restore_undo(MetaState.new()))- Step 2: Run, confirm FAIL
scripts/check-test-count.sh. Expected: FAIL (unknown last_load_status / backup_file / wipe_progress).
- Step 3: Implement in
meta/meta_store.gd
Statics, beside last_load_compatible:
# How the last load went (progress reset, spec 2026-10-04): "none" (no save yet), "ok", "unreadable"# (exists, can't open), "corrupt" (not a JSON object, or a non-numeric schema), "old_schema" (below# MIN_COMPAT: the REQUIRED reset), "newer_schema" (a downgrade: preserve, never write).static var last_load_status: String = "none"# The parsed save even when this build can't read it, so a wipe can carry the kept fields over.static var last_load_raw: Dictionary = {}load_state()’s disk-IO-off branch: also set last_load_status = "none" and last_load_raw = {}.
load_from(): reset both at the top ("none", {}). In the loop:
- the
f == nullbranch setslast_load_status = "unreadable" - replace the parse block with:
var parsed: Variant = JSON.parse_string(txt) if not (parsed is Dictionary): last_load_compatible = false last_load_status = "corrupt" return m last_load_raw = parsed var schema := MetaState.schema_of(parsed) if schema == -1: last_load_compatible = false last_load_status = "corrupt" elif schema < MetaState.MIN_COMPAT_SCHEMA: last_load_compatible = false last_load_status = "old_schema" elif schema > MetaState.SCHEMA_VERSION: last_load_compatible = false last_load_status = "newer_schema" else: m.from_dict(parsed) _migrate_gold_spent(m) last_load_status = "ok" return m(_migrate_gold_spent runs before last_load_status = "ok", and its own save_state call is gated on last_load_compatible, which is still true there. Leave that order as it is.)
save_state(): right after save_count += 1:
# Never write over a save this build couldn't read until the player has seen the reset notice # (wipe_progress clears this). Before 2026-10-04 only the boot-time writers checked, so the first # run end silently overwrote an old-schema / corrupt / newer save with no notice or backup. if not last_load_compatible: last_save_ok = false last_save_error = "blocked: unreadable save not acknowledged" last_save_path = "" returnNew functions after save_state():
# Byte-for-byte copy of the save at `src` to meta.backup-<build>.json beside it (one per build,# overwritten). For manual support recovery; nothing in the game reads it. "" when there is nothing# to copy or the write fails.static func backup_file(src: String, build: int) -> String: if not FileAccess.file_exists(src): return "" var bytes := FileAccess.get_file_as_bytes(src) var dst := src.get_base_dir().path_join("meta.backup-%d.json" % build) var f := FileAccess.open(dst, FileAccess.WRITE) if f == null: return "" f.store_buffer(bytes) f.close() return dst
# ── Undo slot (spec §5b): one meta.undo.json beside the save, written by a manual/recommended wipe ──const UNDO_NAME := "meta.undo.json"
static func _undo_paths() -> Array[String]: return [PATH.get_base_dir().path_join(UNDO_NAME), OS.get_cache_dir().path_join(UNDO_NAME)]
static func copy_file(src: String, dst: String) -> bool: if not FileAccess.file_exists(src): return false var bytes := FileAccess.get_file_as_bytes(src) var f := FileAccess.open(dst, FileAccess.WRITE) if f == null: return false f.store_buffer(bytes) f.close() return true
static func read_undo_at(path: String) -> Dictionary: if not FileAccess.file_exists(path): return {} var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(path)) if parsed is Dictionary and MetaState.schema_compatible(parsed): return parsed return {}
static func restore_from_dict(d: Dictionary, current: MetaState) -> MetaState: var m := MetaState.new() m.from_dict(d) m.share_diagnostics = current.share_diagnostics # settings, not progress: the current choice wins m.install_id = current.install_id m.reset_epoch = maxi(m.reset_epoch, MetaState.RESET_EPOCH) return m
static func has_undo() -> bool: if not disk_io_enabled: return false for p in _undo_paths(): if not read_undo_at(p).is_empty(): return true return false
# Bring the slot back (replacing anything earned since the reset), then delete it: one step only.static func restore_undo(current: MetaState) -> MetaState: if not disk_io_enabled: return null for p in _undo_paths(): var d := read_undo_at(p) if d.is_empty(): continue var m := restore_from_dict(d, current) _clear_undo() last_load_compatible = true save_state(m) return m return null
static func _clear_undo() -> void: for p in _undo_paths(): if FileAccess.file_exists(p): DirAccess.remove_absolute(ProjectSettings.globalize_path(p) if p.begins_with("user://") else p)
# The one wipe (required / recommended / manual / corrupt): back up whatever save is on disk, build# the fresh state with the kept fields, clear the save guard and persist it. Returns the new state;# main swaps it in and sends the save_reset event.static func wipe_progress(old: Dictionary, kind: String, fallback_install_id: String = "") -> MetaState: if disk_io_enabled: for p: String in [PATH, cache_path()]: if FileAccess.file_exists(p): backup_file(p, Sim_Const.BUILD) if kind == "manual" or kind == "recommended": copy_file(p, p.get_base_dir().path_join(UNDO_NAME)) # the one undo slot (spec §5b) break if kind == "required" or kind == "corrupt": _clear_undo() # this build can't read what an older slot holds: never offer it var m := SaveReset.fresh_from(old, fallback_install_id) apply_diag_off_marker(m) if not m.share_diagnostics: m.install_id = "" last_load_compatible = true last_load_status = "ok" last_load_raw = {} save_state(m) return m- Step 4: Run, confirm PASS
scripts/check-test-count.sh. (restore_from_dict skips the refund-ledger migration on purpose: the slot was written by a post-ledger build.) Pay particular attention to test_meta_corrupt_save.gd, test_refund_ledger.gd, test_diagnostics_toggle.gd and test_meta_install_id_boot.gd. They set or read last_load_compatible, and one may now see the new "blocked: …" error where it expected the disk-IO-off one. If an assertion was pinning the old silent-overwrite behaviour, update it and say so in the commit message.
- Step 5: Commit
feat(meta): load status, save guard, backup + wipe_progress (progress reset 2/5)
Task 3: SaveResetNotice panel (boot notice + progress dialog)
Section titled “Task 3: SaveResetNotice panel (boot notice + progress dialog)”Files:
- Create:
ui/save_reset_notice.gd - Test:
tests/test_save_reset_notice.gd(create)
Interfaces:
- Consumes:
SaveReset.copy(mode)(Task 1) - Produces:
class_name SaveResetNotice extends CanvasLayervar mode: String,var can_restore: bool(both set beforeadd_child;can_restoreonly matters inmanage)signal acknowledged(OK in required/corrupt/newer/unreadable)signal keep_chosen(recommended: Keep my progress),signal cancelled(manage: Cancel / Back)signal reset_chosen(recommended + manage, after the second press),signal restore_chosen(manage, after the second press)var _buttons: Array[Button](nav order),var _sel: int,var _confirming: String("","reset"or"restore"),var _body: Label,var _reset_btn: Button,var _restore_btn: Button- Every signal is followed by
queue_free()
Button sets:
- required / corrupt / newer / unreadable:
["OK"] - recommended:
["Keep my progress", "Reset now"], focus index 0 - manage:
["Reset progress", "Restore previous progress", "Cancel"]whencan_restore, else["Reset progress", "Cancel"]; focus on Cancel (last). Body:SaveReset.copy("manage_undo" if can_restore else "manage").
Two-press rule: the first press of Reset (or Restore) sets _confirming, swaps the body to CONFIRM_RESET_BODY / CONFIRM_RESTORE_BODY and relabels that button “Yes, reset” / “Yes, restore”. Pressing the other action instead switches the confirmation over and restores the first button’s label. The second press of the same button emits.
Style: copy ui/feedback_panel.gd’s _ready() construction (layer 80, dim ColorRect with surface.void 0.82, CenterContainer + NeonTheme.get_theme(), a PanelContainer with NeonTheme.nova_box(Tokens.c(ACCENT), 0.2), a token-bound MarginContainer/VBoxContainer, a title using NeonTheme.title_font() + font.3xl + accent.main, a body label using font.base, word-smart autowrap and custom_minimum_size.x = 440). Buttons go via a local _btn(txt, w) like the feedback panel’s (read feedback_panel.gd:154-175 and mirror it). In manage mode the three buttons stack vertically (a 440 px row can’t fit “Restore previous progress” beside two others). Use ACCENT := &"warn.main" for required/corrupt and &"accent.soft" otherwise. Check that warn.main exists in ui/tokens/defaults.json. Input mirrors feedback_panel._input, without the text box: left/right/up/down move between buttons with the NAV_DEBOUNCE_MS := 180 debounce; ui_accept / pad A presses the focused button; pad B / Backspace = Keep (recommended), Cancel (manage), OK (others); finish with PauseMenu.consumes_unmatched_event(event) → handled. It sits on layer 80 so it draws over the pause menu (the feedback panel uses the same layer from the same place).
- Step 1: Write the failing tests
extends GutTest
func _make(mode: String, can_restore := false) -> SaveResetNotice: var n := SaveResetNotice.new() n.mode = mode n.can_restore = can_restore add_child_autofree(n) return n
func _labels(n: SaveResetNotice) -> Array: return n._buttons.map(func(b: Button) -> String: return b.text)
func test_required_has_one_ok_that_acknowledges() -> void: var n := _make("required"); await get_tree().process_frame assert_eq(_labels(n), ["OK"]) watch_signals(n) n._buttons[0].emit_signal("pressed") assert_signal_emitted(n, "acknowledged")
func test_every_ok_mode_shows_its_copy() -> void: for m: String in ["required", "corrupt", "newer", "unreadable"]: var n := _make(m); await get_tree().process_frame assert_eq(n._body.text, SaveReset.copy(m)["body"], m) assert_eq(_labels(n), ["OK"], m)
func test_recommended_defaults_to_keep() -> void: var n := _make("recommended"); await get_tree().process_frame assert_eq(_labels(n), ["Keep my progress", "Reset now"]) assert_eq(n._sel, 0, "the safe choice has focus")
func test_keep_emits_keep_chosen() -> void: var n := _make("recommended"); await get_tree().process_frame watch_signals(n) n._buttons[0].emit_signal("pressed") assert_signal_emitted(n, "keep_chosen") assert_signal_not_emitted(n, "reset_chosen")
func test_reset_needs_a_second_press() -> void: var n := _make("recommended"); await get_tree().process_frame watch_signals(n) n._reset_btn.emit_signal("pressed") assert_signal_not_emitted(n, "reset_chosen", "first press only asks") assert_eq(n._confirming, "reset") assert_eq(n._body.text, SaveResetNotice.CONFIRM_RESET_BODY) assert_eq(n._reset_btn.text, "Yes, reset") n._reset_btn.emit_signal("pressed") assert_signal_emitted(n, "reset_chosen")
func test_keep_after_arming_still_keeps() -> void: var n := _make("recommended"); await get_tree().process_frame watch_signals(n) n._reset_btn.emit_signal("pressed") n._buttons[0].emit_signal("pressed") assert_signal_emitted(n, "keep_chosen") assert_signal_not_emitted(n, "reset_chosen")
func test_manage_without_undo() -> void: var n := _make("manage", false); await get_tree().process_frame assert_eq(_labels(n), ["Reset progress", "Cancel"]) assert_eq(n._sel, 1, "Cancel has focus") assert_eq(n._body.text, SaveReset.copy("manage")["body"])
func test_manage_with_undo_offers_restore() -> void: var n := _make("manage", true); await get_tree().process_frame assert_eq(_labels(n), ["Reset progress", "Restore previous progress", "Cancel"]) assert_eq(n._sel, 2) assert_eq(n._body.text, SaveReset.copy("manage_undo")["body"])
func test_restore_needs_a_second_press() -> void: var n := _make("manage", true); await get_tree().process_frame watch_signals(n) n._restore_btn.emit_signal("pressed") assert_signal_not_emitted(n, "restore_chosen") assert_eq(n._body.text, SaveResetNotice.CONFIRM_RESTORE_BODY) assert_eq(n._restore_btn.text, "Yes, restore") n._restore_btn.emit_signal("pressed") assert_signal_emitted(n, "restore_chosen") assert_signal_not_emitted(n, "reset_chosen")
func test_switching_confirmation_restores_the_other_label() -> void: var n := _make("manage", true); await get_tree().process_frame n._reset_btn.emit_signal("pressed") n._restore_btn.emit_signal("pressed") assert_eq(n._confirming, "restore") assert_eq(n._reset_btn.text, "Reset progress")
func test_manage_cancel() -> void: var n := _make("manage", true); await get_tree().process_frame watch_signals(n) n._buttons[2].emit_signal("pressed") assert_signal_emitted(n, "cancelled")-
Step 2: Run, confirm FAIL (
godot --headless --path . --import && scripts/check-test-count.sh) -
Step 3: Implement
ui/save_reset_notice.gd. Core logic (the layout follows the style paragraph above):
class_name SaveResetNoticeextends CanvasLayer
# Progress-reset panel (spec docs/superpowers/specs/2026-10-04-progress-reset-design.md §4 + §5b).# Pure UI: emits a choice and frees itself; main does the wipe/restore/save. Modes:# required / corrupt / newer / unreadable -- boot notice, one OK (from SaveReset.notice_mode)# recommended -- boot offer: Keep my progress (default) / Reset now (two presses)# manage -- the pause-menu progress dialog: Reset / Restore previous (if can_restore) / Cancel (default)
signal acknowledgedsignal keep_chosensignal cancelledsignal reset_chosensignal restore_chosen
const NAV_DEBOUNCE_MS := 180const CONFIRM_RESET_BODY := "Reset all progress? You can undo this from the pause menu."const CONFIRM_RESTORE_BODY := "Restore your previous progress? Anything earned since the reset will be replaced."
var mode := "required"var can_restore := falsevar audio: AudioManagervar _buttons: Array[Button] = []var _sel := 0var _confirming := ""var _body: Labelvar _base_body := ""var _reset_btn: Buttonvar _restore_btn: Buttonvar _reset_label := ""var _last_nav_ms := 0
# ... _ready(): build per the style paragraph; title/_base_body from# SaveReset.copy("manage_undo" if can_restore else "manage") in manage mode, else SaveReset.copy(mode); then:# match mode:# "recommended":# _add_button("Keep my progress", _on_keep)# _reset_btn = _add_button("Reset now", _on_reset)# _sel = 0# "manage":# _reset_btn = _add_button("Reset progress", _on_reset)# if can_restore:# _restore_btn = _add_button("Restore previous progress", _on_restore)# _add_button("Cancel", _on_cancel)# _sel = _buttons.size() - 1# _:# _add_button("OK", _on_ok)# _sel = 0# _reset_label = _reset_btn.text if _reset_btn != null else ""# _focus()
func _on_ok() -> void: acknowledged.emit() queue_free()
func _on_keep() -> void: keep_chosen.emit() queue_free()
func _on_cancel() -> void: cancelled.emit() queue_free()
func _on_reset() -> void: if _confirming != "reset": _arm("reset") return reset_chosen.emit() queue_free()
func _on_restore() -> void: if _confirming != "restore": _arm("restore") return restore_chosen.emit() queue_free()
func _arm(which: String) -> void: _confirming = which if _reset_btn != null: _reset_btn.text = "Yes, reset" if which == "reset" else _reset_label if _restore_btn != null: _restore_btn.text = "Yes, restore" if which == "restore" else "Restore previous progress" _body.text = CONFIRM_RESET_BODY if which == "reset" else CONFIRM_RESTORE_BODY_add_button(txt, cb) -> Button appends to _buttons and wires pressed to set _sel to its own index and then call cb (same idiom as pause_menu _make_btn).
- Step 4: Run, confirm PASS
- Step 5: Commit
feat(ui): SaveResetNotice panel + progress dialog (progress reset 3/5)
Task 4: Wire into main (boot flow, progress dialog, telemetry) + pause-menu row
Section titled “Task 4: Wire into main (boot flow, progress dialog, telemetry) + pause-menu row”Files:
- Modify:
main.gd(_ready~line 320 and the boot branch ~442-447;_toggle_pause~549-569;tutorial_pause_hidden~2589; new funcs near_on_diagnostics_toggled~1713) - Modify:
ui/pause_menu.gd(signals ~11-22; vars ~26-31; button build ~183-187;_pair_step~276) - Test:
tests/test_pause_menu.gd(add),tests/test_main_save_reset.gd(create),tests/test_main.gd:1278(extend the tutorial-hidden assertion)
Interfaces:
-
Consumes:
SaveReset.notice_mode,SaveReset.event_payload,MetaStore.last_load_status,MetaStore.last_load_raw,MetaStore.wipe_progress(old, kind, fallback_id),MetaStore.has_undo(),MetaStore.restore_undo(current),SaveResetNotice+ its signals -
Produces:
PauseMenu.progress_requestedsignal;var PauseMenu.can_restore := false(set beforeadd_child);PauseMenu.RESET_LABEL := "Reset progress",PauseMenu.RESET_RESTORE_LABEL := "Reset / restore";PauseMenu._reset_btn,PauseMenu._quit_btnmain._boot_to_menu(),main._show_reset_notice(mode: String),main._apply_wipe(kind: String),main._open_progress_dialog(),main._on_progress_reset_requested(),main._on_progress_restore_requested()tutorial_pause_hidden("tutorial", …)includes"reset"
-
Step 1: Write failing pause-menu tests (append to
tests/test_pause_menu.gd)
# ── Progress row (spec 2026-10-04 progress reset §5b) ──────────────────────────────────────
func _progress_btn(p: PauseMenu) -> Button: for b in p._buttons: if b.text == PauseMenu.RESET_LABEL or b.text == PauseMenu.RESET_RESTORE_LABEL: return b return null
func test_progress_row_opens_the_dialog_in_one_press() -> void: var p := PauseMenu.new(); add_child_autofree(p); await get_tree().process_frame var b := _progress_btn(p) assert_not_null(b) assert_eq(b.text, PauseMenu.RESET_LABEL) watch_signals(p) b.emit_signal("pressed") assert_signal_emitted(p, "progress_requested", "the dialog does the confirming")
func test_progress_row_label_when_an_undo_exists() -> void: var p := PauseMenu.new() p.can_restore = true add_child_autofree(p); await get_tree().process_frame assert_eq(_progress_btn(p).text, PauseMenu.RESET_RESTORE_LABEL)
func test_progress_row_hidden_when_listed() -> void: var p := PauseMenu.new() p.hidden_entries = ["reset"] add_child_autofree(p); await get_tree().process_frame assert_null(_progress_btn(p))
func test_progress_row_shares_a_row_with_quit() -> void: var p := PauseMenu.new(); add_child_autofree(p); await get_tree().process_frame assert_eq(_progress_btn(p).get_parent(), p._quit_btn.get_parent(), "paired on one row (keeps the 720p fit)") assert_eq(p._quit_btn.text, "Quit to menu")Also extend tests/test_main.gd near line 1278: assert_true(hidden.has("reset"), "no progress reset mid-tutorial").
- Step 2: Write failing main tests (
tests/test_main_save_reset.gd). Follow howtests/test_main.gdboots main (read itsbefore_each/helpers first and reuse the same instantiate pattern; headlessmain._readyauto-starts a run).
extends GutTest# main's progress-reset wiring. Headless main auto-starts a run, so these call the flow functions# directly. Disk IO is off in GUT: wipe_progress only counts its save, has_undo() is false.
var _main: Node
func before_each() -> void: _main = load("res://main.tscn").instantiate() add_child_autofree(_main) await get_tree().process_frame
func after_each() -> void: MetaStore.last_load_compatible = true MetaStore.last_load_status = "none" MetaStore.last_load_raw = {}
func test_apply_wipe_manual_clears_progress_keeps_tutorial() -> void: _main.meta.banked_gold = 400 _main.meta.tutorial_done = true _main._apply_wipe("manual") assert_eq(_main.meta.banked_gold, 0) assert_true(_main.meta.tutorial_done)
func test_apply_wipe_required_reads_the_raw_save() -> void: MetaStore.last_load_raw = {"schema": -3, "tutorial_done": true, "banked_gold": 50} MetaStore.last_load_compatible = false _main.meta = MetaState.new() # what load_state returned for the unreadable save _main._apply_wipe("required") assert_true(_main.meta.tutorial_done, "kept field came from the raw dict") assert_true(MetaStore.last_load_compatible, "saving unblocked")
func test_show_reset_notice_adds_the_panel() -> void: _main._show_reset_notice("required") assert_true(_main.get_children().any(func(c: Node) -> bool: return c is SaveResetNotice))
func test_open_progress_dialog_hides_pause_and_cancel_brings_it_back() -> void: _main._toggle_pause() assert_not_null(_main.pause_menu) _main._open_progress_dialog() assert_false(_main.pause_menu.visible) var dlg: SaveResetNotice = null for c in _main.get_children(): if c is SaveResetNotice: dlg = c assert_eq(dlg.mode, "manage") assert_false(dlg.can_restore, "no undo slot with disk IO off") dlg._on_cancel() assert_true(_main.pause_menu.visible)
func test_manual_reset_returns_to_menu() -> void: assert_not_null(_main.sim, "headless boot started a run") _main.meta.banked_gold = 99 _main._on_progress_reset_requested() assert_null(_main.sim, "run torn down") assert_eq(_main.meta.banked_gold, 0, "nothing from the abandoned run was banked")
func test_restore_without_a_slot_changes_nothing() -> void: _main.meta.banked_gold = 42 _main._on_progress_restore_requested() assert_eq(_main.meta.banked_gold, 42)If _return_to_menu() → _show_start_menu() misbehaves headless (audio, PrewarmPass), read how test_main.gd tests _return_to_menu and copy its setup. Don’t skip the assertion. If _toggle_pause() refuses headless (e.g. the survival intro dialogue is up), clear it first the way tools/ui_sweep/sweep.gd:_stage_pause does.
-
Step 3: Run, confirm FAIL
-
Step 4: Implement the pause menu
Signal: signal progress_requested # "Reset progress" row; main opens the progress dialog (SaveResetNotice "manage").
Consts/vars:
const RESET_LABEL := "Reset progress"const RESET_RESTORE_LABEL := "Reset / restore" # an undo slot exists; fits the 187 px glass half-rowvar can_restore := false # set before add_child: MetaStore.has_undo()var _reset_btn: Buttonvar _quit_btn: ButtonReplace the single "Quit to menu" if _glass else "Back to Start Menu" line (~187) with a pair row built the same way as the feedback/diagnostics pair (same sep, col_w, SIZE_EXPAND_FILL, TokenBind.theme_font_size(b, &"font_size", GLASS_DEV_BTN_FONT) after _make_btn):
_quit_btn = _make_btn("Quit to menu", func() -> void: menu_requested.emit(), &"accent.main") if hidden_entries.has("reset"): box.add_child(_quit_btn) else: _reset_btn = _make_btn(RESET_RESTORE_LABEL if can_restore else RESET_LABEL, func() -> void: progress_requested.emit(), &"warn.main") # build an HBoxContainer pair [_quit_btn, _reset_btn] exactly like the fb/diag pair above _pairs.append([_quit_btn, _reset_btn])The quit label is “Quit to menu” in both looks. The legacy look’s “Back to Start Menu” doesn’t fit a half-row. test_legacy_column_sizes_are_unchanged must still pass: a pair row is one row tall. Generalise _pair_step to a list of pairs:
var _pairs: Array = [] # [[left: Button, right: Button], ...] sharing one row; left/right moves within
func _pair_step(dir: int) -> bool: var cur := _buttons[_sel] if _sel >= 0 and _sel < _buttons.size() else null for pr: Array in _pairs: var l: Button = pr[0] var r: Button = pr[1] if cur != l and cur != r: continue var target := r if dir > 0 else l if target != cur and not target.disabled: _sel = _buttons.find(target) _ui_nav() _focus_sel() return true return falseRegister _pairs.append([fb, _diag_btn]) where that pair is built. _buttons order must stay visual: _make_btn appends on creation, and _quit_btn then _reset_btn are created after _diag_btn is re-appended (line ~185), so they land in the right order.
- Step 5: Implement main
In _ready, replace the final boot branch (lines ~442-447) with:
if DisplayServer.get_name() == "headless": _new_run() return var notice := SaveReset.notice_mode(MetaStore.last_load_status, meta.reset_epoch) if notice != "": _show_reset_notice(notice) # continues to _boot_to_menu() when closed else: _boot_to_menu()
# The normal boot destination: a fresh save (never finished the tutorial) goes straight into it,# everyone else to the picker.func _boot_to_menu() -> void: if _should_force_tutorial(meta): _on_mode_chosen("tutorial") else: _show_start_menu()(Keep the existing comment block above the branch, updated to mention the notice.)
Flow functions, near _on_diagnostics_toggled:
# Progress reset (spec docs/superpowers/specs/2026-10-04-progress-reset-design.md). The boot notice# for a save this build can't read (required / corrupt / newer / unreadable) or a recommended fresh# start. One per boot; the start menu waits for it.func _show_reset_notice(mode: String) -> void: var n := SaveResetNotice.new() n.mode = mode n.audio = audio add_child(n) n.acknowledged.connect(func() -> void: if mode == "required" or mode == "corrupt": _apply_wipe(mode) _boot_to_menu()) n.keep_chosen.connect(func() -> void: meta.reset_epoch = MetaState.RESET_EPOCH MetaStore.save_state(meta) _boot_to_menu()) n.reset_chosen.connect(func() -> void: _apply_wipe("recommended") _boot_to_menu())
# The one wipe path: backup (+ undo slot for manual/recommended) + fresh state, then one save_reset# event. required/corrupt read the kept fields from the raw save (meta is a blank stand-in for it).func _apply_wipe(kind: String) -> void: var old: Dictionary = MetaStore.last_load_raw if kind == "required" or kind == "corrupt" else meta.to_dict() var payload := SaveReset.event_payload(kind, old) meta = MetaStore.wipe_progress(old, kind, meta.install_id) if event_queue != null: event_queue.push("save_reset", payload)
# Pause-menu progress row → the progress dialog over a hidden pause menu (§5b).func _open_progress_dialog() -> void: if pause_menu != null: pause_menu.visible = false var n := SaveResetNotice.new() n.mode = "manage" n.can_restore = MetaStore.has_undo() n.audio = audio add_child(n) n.cancelled.connect(func() -> void: if pause_menu != null: pause_menu.visible = true pause_menu.refocus()) n.reset_chosen.connect(_on_progress_reset_requested) n.restore_chosen.connect(_on_progress_restore_requested)
# Confirmed reset: wipe, then leave the run with nothing banked.func _on_progress_reset_requested() -> void: _apply_wipe("manual") _return_to_menu()
# Confirmed restore: the undo slot replaces the current save, then back to the menu (so the next run# starts from the restored shop). No slot (raced away / disk IO off): nothing changes.func _on_progress_restore_requested() -> void: var payload := SaveReset.event_payload("restore", meta.to_dict()) var restored := MetaStore.restore_undo(meta) if restored == null: if pause_menu != null: pause_menu.visible = true pause_menu.refocus() return meta = restored if event_queue != null: event_queue.push("save_reset", payload) _return_to_menu()_toggle_pause: set pause_menu.can_restore = MetaStore.has_undo() before add_child (beside share_diagnostics), and pause_menu.progress_requested.connect(_open_progress_dialog) beside the menu_requested connect. tutorial_pause_hidden: out.append("reset") beside "bestiary".
Check that _return_to_menu() doesn’t save meta or bank sim.run_gold (verified while planning: it calls _send_run_end("quit_menu") and no _bank_run). If either changes, wipe after the return instead. Also check that _close_pause() (called by _return_to_menu) copes with the pause menu being hidden rather than freed.
Check that the install id and event queue survive a wipe: event_queue.setup() ran with the pre-wipe id. When wipe_progress keeps the same id (the usual case) nothing changes. If the id differs (corrupt save, Diagnostics on), call event_queue.set_enabled(meta.share_diagnostics, meta.install_id) and remote_config.set_install_id(meta.install_id) after the wipe, as _on_diagnostics_toggled does. A restore keeps the current id by design (restore_from_dict), so nothing changes there.
-
Step 6: Run, confirm PASS (
godot --headless --path . --import && scripts/check-test-count.sh), includingtest_glass_column_with_dev_rows_fits_720pandtest_legacy_column_sizes_are_unchanged. -
Step 7: Commit
feat: progress reset boot notice + pause-menu progress dialog with undo (progress reset 4/5)
Task 5: Screenshot coverage, docs, copy, console What’s New, gate
Section titled “Task 5: Screenshot coverage, docs, copy, console What’s New, gate”Files:
-
Modify:
tools/ui_sweep/sweep.gd(thescreenslist ~line 75;_reset_between~198) -
Modify:
docs/architecture/meta-and-story.md(new section## Progress reset (2026-10-04)) -
Modify:
marketing/appstore/copy.md,marketing/appstore/compliance.md(Product Interaction row),docs/RELEASE-1.0.1.md,control/src/whats_new.js -
Separate repo, own commit:
~/Claude/bullet-heaven-site/privacy/index.html(line ~72 “How a session went” list) -
Step 1: ui_sweep screens. Add to
screens:
["reset_notice_required", func() -> void: _stage_reset_notice("required")], ["reset_notice_recommended", func() -> void: _stage_reset_notice("recommended")], ["progress_dialog", func() -> void: _stage_pause(); _stage_reset_notice("manage", true)],# The boot reset notice, constructed DIRECTLY (not via main._show_reset_notice): its signals are left# unconnected, so no wipe or save can happen here. Over the start menu, which hides it on a device.func _stage_reset_notice(mode: String, can_restore := false) -> void: main.start_menu.visible = false var n := SaveResetNotice.new() n.mode = mode n.can_restore = can_restore n.name = "SweepResetNotice" main.add_child(n)In _reset_between: for both notice ids, free the SweepResetNotice child and set main.start_menu.visible = true. For progress_dialog, free the SweepResetNotice and then do whatever the "pause" case does. Run scripts/ui-sweep.sh (read docs/reference/running-and-testing.md § ui-sweep for the bless step), check the new PNGs for clipping, bless them, then git checkout -- project.godot audio/bus_layout.tres.
-
Step 2: Architecture doc section in
docs/architecture/meta-and-story.md: the three kinds;MetaStore.last_load_statusvalues; the save guard; the backup filenamemeta.backup-<BUILD>.jsonbeside the save (tvOS: cache dir); the single undo slotmeta.undo.json(written by manual/recommended wipes, deleted by required/corrupt wipes and by a restore) and the pause-menu progress dialog; kept vs cleared fields; the release procedure (copy the block frommeta/save_reset.gd’s header); thesave_resetevent fields{kind, from_schema, from_epoch}; and that resets are never remote-triggered. -
Step 3: Copy + listings.
marketing/appstore/copy.md: insert after the “It is fast, it is bright…” paragraph, under Description:Dark Cosmos is in active development. Expect big updates as we build it out: new weapons, enemies and systems, plus balance changes. While the game is still taking shape, some updates may need to reset your progress. We’ll always tell you in the update notes first.
docs/RELEASE-1.0.1.md: add a 1.0.2-listing item: “iOS + tvOS description: paste the active-development paragraph frommarketing/appstore/copy.md(description is version-locked, so it can only go in with a new version).”marketing/appstore/compliance.mdProduct Interaction row: add “, progress resets (whether required, recommended or chosen by the player)” after “shop purchases (item + price)”. The ASC category is unchanged.- Privacy page (separate repo
~/Claude/bullet-heaven-site): add “progress resets” after “shop purchases” on line ~72. Commit there, and deploy via that repo’sscripts/deploy-site.shonly, after Chris says yes (outward-facing). control/src/whats_new.js: add to thebuild: nullentry’s items:{ text: 'Progress reset: the pause menu has Reset progress, which opens a dialog to reset (or restore the progress from before your last reset). Updates can now show a one-time notice when they need or recommend a fresh start; the old save is backed up first.', where: 'Pause menu: Reset progress, next to Quit to menu; reset, then reopen it and Restore' }. Runnode --test control/test/*.test.mjs. The console Worker redeploys after merge (CLAUDE.md rule).
-
Step 4: Gate.
scripts/gate.shmust be green (includes the determinism baselines, which must be unchanged, plus the docs-site frontmatter test, which this plan file and the spec satisfy). -
Step 5: Commit
docs+tools: progress reset sweep screens, architecture, store copy (progress reset 5/5), thengit push origin mainafter the merge. -
Step 6: Device check (Chris).
bh-visual-qaon the three new sweep screens (reset_notice_required,reset_notice_recommended,progress_dialog) at iPhone/iPad/ATV sizes. A real on-device required-reset trial needs a throwaway branch withMIN_COMPAT_SCHEMAbumped, installed on the Dev app. Offer it to Chris; don’t do it unasked.
Execution handoff
Section titled “Execution handoff”For a fresh session with no conversation context. The spec + this plan are the source of truth; this section records what they don’t.
Decisions Chris made in conversation (2026-10-04):
- Store wording: avoid “beta” (App Review Guideline 2.2 risk). Use the “active development” paragraph (Task 5 Step 3). It goes into the 1.0.2 listing: 1.0.1 (store build 255) is in review and its description is version-locked.
- Reset kinds are set per release (required vs recommended), not “always optional” or “always forced”.
- Wipe scope: progress only (keep tutorial_done, intro/tip flags, Diagnostics choice and install id).
- Manual reset: yes, in the pause menu.
- Undo: Chris, mid-planning: “we need to have a way that allows a player to restore if they decide after they wished they hadnt wiped”. He picked the progress dialog option (Reset / Restore previous / Cancel, one undo slot, kept until the next reset). Rejected: a row that swaps to “Undo reset” (blocks re-reset, expires quietly), and restoring after required resets (needs a migration per breaking change).
- Planning ruling: no
simulate_reset_noticedev command / console control. ui_sweep screens cover the screenshots instead.
Authorised: implementing on worktree branches, merging to main after review + green scripts/gate.sh, pushing main (repo rule: push after every merge).
Needs Chris’s same-day OK: deploying the privacy-page change (~/Claude/bullet-heaven-site, scripts/deploy-site.sh), redeploying the Remote Console Worker after the control/ What’s New edit, any Dev-app device install, and any App Store Connect metadata edit.
Already done: spec committed + pushed (96e8636d). The spec’s §5b undo section and this plan are committed together with this handoff. Nothing else touched.
Environment notes from planning:
- The main checkout has UNRELATED uncommitted economy-sim edits (
tests/test_economy_career.gd,tools/economy_sim/*). Don’t stage or stash-drop them. Work in a worktree. MetaStore.save_state()is currently unguarded (meta/meta_store.gd:220); main.gd calls it at 5 sites. Task 2’s guard is the structural fix.- The pause menu’s glass column is height-pinned (
test_glass_column_with_dev_rows_fits_720p), hence the paired row. _return_to_menu()doesn’t bank run gold (only_bank_rundoes), so wipe-then-return leaks nothing.
Open questions for Chris: none blocking. After merge, offer him an on-device trial of a required reset (a throwaway branch with MIN_COMPAT_SCHEMA bumped, Dev app).
Next action: execute this plan subagent-driven (superpowers:subagent-driven-development, consult bh-sdd-concurrent first, bh-implementer per task in its own worktree off origin/main, bh-reviewer before merge).