Repository navigation
fix(web-backup): prune candidate dictionary before writing BACKUP_STORAGE_KEY (issue #421) - #429
Conversation
…RAGE_KEY (issue #421) The web backup writer stored the full MAX_BACKUPS+1 candidate first and pruned after, so once the backup cap filled every rotation needed storage for the temporary sixth snapshot. If the browser's remaining localStorage capacity only fit the final five-snapshot set, the first write failed and _prune_old_backups() never ran, leaving New Game callers with no recoverable backup. Prune the candidate dictionary in memory using the existing newest-first numeric ordering BEFORE calling _store_web_backups, so the aggregated BACKUP_STORAGE_KEY value never carries more than MAX_BACKUPS entries. If the trimmed payload itself cannot be written, return empty and leave the previously stored backups byte-identical. Adds _prune_backups_in_memory() helper used by both the write path and _prune_old_backups(), plus deterministic hook-based regression tests for the quota-blocked rotation and the preservation of existing backups under a persistent write failure.
There was a problem hiding this comment.
AI Automated Review
Full PR review.
Analysis engine: MiniMax-M3.1-Flash-Preview (anthropic) — primary route · pr-reviewer-action v3.4.1
Findings (1 info)
| Severity | Location | Finding |
|---|---|---|
| Info | scripts/game_state.gd:853 |
Since `_prune_backups_in_memory()` mutates and returns the same Dictionary reference, `pruned.size() == backups.size()` remains true after pruning and this web `_prune_old_backups()` path returns without persisting the trimmed set; no current repository caller reaches it, so the defect is non-blocki |
Recommendation: approve. No blocking or major issues were found. The rotation path now persists at most MAX_BACKUPS snapshots, and the linked acceptance criteria are directly exercised. There is one non-blocking, currently unreachable aliasing defect in the refactored _prune_old_backups() web branch at scripts/game_state.gd:853.
Change-by-change findings
-
scripts/game_state.gd- The complete cross-step path is consistent:
- Producer: reads the current live state from
SAVE_KEY. - Persisted representation: loads the parsed
BACKUP_STORAGE_KEYdictionary, inserts the new backup, and trims it with the existing newest-first numeric ordering. - Transport/environment:
_store_web_backups()serializes only that pruned candidate before writing through the existing localStorage writer. - Consumers:
list_backups()andrestore_backup()read the same aggregated key and use_sorted_backup_names(). - Decision: a failed writer result returns
""without invoking any remover; a successful write is followed by the existing read-back check for the new key.
- Producer: reads the current live state from
- Exact call-site searches found only two
_store_web_backups()callers, and both supply an already-pruned dictionary, so no sibling caller was missed. - Desktop backup behavior remains on its existing copy-and-prune path; localStorage key conventions and save schema are unchanged.
- The complete cross-step path is consistent:
-
tests/test_web_backup.gd- The quota-rotation regression seeds five backups, rejects any aggregated candidate larger than
MAX_BACKUPS, verifies that no oversized write is attempted, and then verifies the retained key, five-entry payload, newest-first result, restore key, and restored tick. - The final-write-failure regression verifies the empty return, exactly one backup-key write attempt, byte-identical previous payload, unchanged backup count/list, and unchanged live save.
- All new assertion calls include the required assertion name, and the reported exact-head CI execution succeeds.
- The quota-rotation regression seeds five backups, rejects any aggregated candidate larger than
Claims to falsify
- Claim 1 — held: 2/2 persistence call sites. Both
_store_web_backups()callers receive dictionaries capped atMAX_BACKUPS; no additional call sites were found. - Claim 2 — held: 3/3 historical stages. The removed implementation inserted the new snapshot, attempted the six-entry payload, and only then called the post-write pruning path.
- Claim 3 — held: 3/3 failure behaviors. The candidate is a freshly parsed dictionary, a failed write returns immediately, and there is no deletion or fallback write that could alter the previously stored payload.
- Claim 4 — held: 11/11 assertions. The deterministic quota test covers the retained return value, no oversized write, five retained entries, newest ordering, restoration of the new key, and restoration of tick 99.
- Claim 5 — held: 7/7 assertions. The failure test covers the empty return, one write attempt, byte-identical backup payload, unchanged count/list, and unchanged live save.
Sources
scripts/game_state.gd:748-907tests/test_web_backup.gd:174-264scripts/main.gd:269-284tests/test_case.gd.github/workflows/test.yml.github/actions/load-godot-toolchain/action.yml- Linked issue PR 421 and the reported CI results for commit
2ec217efbf8bf17a8c798534f4e128fea9975a7a
Standards Compliance
- The change is focused on the two files named by the repository issue convention.
- It does not alter the save version or migration behavior, storage-key convention, desktop retention logic, release process, or UI direction.
- The tests avoid the documented two-argument
assert_eqparse-error trap. - The repository's headless test workflow enumerates
tests/test_*.gd, and the exact-head script, macOS, smoke, and export checks all pass. - No repository-standard violation was found.
Linked Issue Fit
- Ask: met. The candidate dictionary is pruned in memory before
BACKUP_STORAGE_KEYis written. - Deterministic regression test: met. Five snapshots are seeded and a hook writer accepts five but rejects six.
- Successful rotation behavior: met.
backup_saveretains the new key, listing returns five newest entries, and restore recovers tick 99. - Final-write failure behavior: met. The function returns an empty string, performs no delete-to-make-space fallback, and preserves the previous payload exactly.
- Regression/scope constraints: met. Existing web, ordering, clear-game, and desktop backup coverage remains green, and neither desktop retention nor storage-key conventions changed.
Tool Harness Findings
Native reads and searches confirmed the save/list/restore implementation, the JavaScriptBridge writer's success handling, every aggregated-write caller, the desktop-only reachability of _prune_old_backups() under current repository callers, the New Game consumer, assertion signatures, and CI suite enumeration. They also exposed the non-blocking Dictionary-aliasing behavior documented in the info finding.
Closes #421
Prunes the candidate backup dictionary in memory before writing
BACKUP_STORAGE_KEYso the aggregated web-backup payload never carries more thanMAX_BACKUPSentries.The old
_backup_save_local_storage()order inserted the new snapshot, attempted to store the fullMAX_BACKUPS + 1set, and only then pruned. Once five snapshots were already retained, every rotation first required storage for six. When the browser's remaininglocalStoragecapacity held the intended five-snapshot set but not the temporary six-snapshot set, the first_store_web_backups()call failed, the function returned empty, andmain.gd::_on_startup_new_game()proceeded to reset the colony without a recoverable backup.Fix:
_prune_backups_in_memory()helper uses the existing newest-first numeric ordering (same as_sorted_backup_names()/_prune_old_backups())._backup_save_local_storage()calls it before_store_web_backups().backup_save()returns""and leaves the previously stored backups byte-identical (no delete-some-entries-to-make-space fallback)._prune_old_backups()web branch reuses the helper for a single source of truth.Tests added in
tests/test_web_backup.gd:flow_backup_pruning_avoids_quota_blocked_rotation(): seeds five existing snapshots, swaps in a writer that rejects only the six-snapshot payload, assertsbackup_savereturns a retained key, the writer is never asked to store more thanMAX_BACKUPSentries,list_backupscontains exactly the newest five, andrestore_backuprecovers the new snapshot.flow_backup_save_preserves_existing_when_trimmed_payload_fails(): writer rejects every aggregated write; asserts the previousBACKUP_STORAGE_KEYpayload is byte-identical, the snapshot count is unchanged,list_backupsstill enumerates the original five, and the liveSAVE_KEYis untouched.Verified locally with the headless matrix used in CI: all 32
tests/test_*.gdsuites pass (44 assertions intest_web_backup.gd, including the 18 new ones). Reverting the in-memory prune before re-running the suite reproduces the original write-before-prune failures, confirming regression coverage.