From fd7610b2fa696e2574c0d0727004d502e11ef679 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 13 Apr 2026 22:44:56 +0700 Subject: [PATCH] feat: add game state persistence with localStorage (Story 2.2) Game state saved after every move, restored on page load. Best score persisted separately. New Game clears game state but preserves best score. Schema validation prevents crashes from corrupted localStorage. 49 tests passing (11 new storage tests). --- .../2-2-game-state-persistence.md | 150 ++++++++++++++++++ .../sprint-status.yaml | 2 +- bmad/src/App.svelte | 12 +- bmad/src/lib/storage.js | 50 ++++++ bmad/src/lib/storage.test.js | 81 ++++++++++ 5 files changed, 292 insertions(+), 3 deletions(-) create mode 100644 bmad/_bmad-output/implementation-artifacts/2-2-game-state-persistence.md create mode 100644 bmad/src/lib/storage.js create mode 100644 bmad/src/lib/storage.test.js diff --git a/bmad/_bmad-output/implementation-artifacts/2-2-game-state-persistence.md b/bmad/_bmad-output/implementation-artifacts/2-2-game-state-persistence.md new file mode 100644 index 0000000..06e60f4 --- /dev/null +++ b/bmad/_bmad-output/implementation-artifacts/2-2-game-state-persistence.md @@ -0,0 +1,150 @@ +# Story 2.2: Game State Persistence + +Status: done + +## Story + +As a player, +I want my in-progress game to survive a page refresh, +so that I never lose my progress unexpectedly. + +## Acceptance Criteria + +1. After a valid move, the full game state is saved to localStorage under the `gameState` key as JSON: `{ grid, score, won, keepPlaying }` +2. The best score is saved separately under the `bestScore` key as a single number +3. On app load with a saved `gameState` in localStorage, the game restores the saved grid, score, won, and keepPlaying values, and the best score is restored from the `bestScore` key (silent restore, no loading indicator) +4. On app load with no saved game or corrupted localStorage data, a fresh game starts with 2 random tiles and score 0 (silent recovery, no error shown) +5. The `storage.js` module catches `JSON.parse` errors silently and returns null (triggering fresh game start) +6. When the player clicks New Game, the `gameState` key in localStorage is cleared and the `bestScore` key is preserved + +## Tasks / Subtasks + +- [x] Task 1: Create storage.js module (AC: #1, #2, #3, #4, #5) + - [x] Create `src/lib/storage.js` with functions: `saveGameState(state)`, `loadGameState()`, `saveBestScore(score)`, `loadBestScore()` + - [x] `saveGameState` serializes `{ grid, score, won, keepPlaying }` to localStorage key `gameState` + - [x] `loadGameState` parses localStorage `gameState` — returns parsed object or null on failure/missing + - [x] `saveBestScore` saves a number to localStorage key `bestScore` + - [x] `loadBestScore` parses localStorage `bestScore` — returns number or 0 on failure/missing + - [x] Wrap all `JSON.parse` calls in try/catch, return null/0 on error + +- [x] Task 2: Integrate storage into App.svelte initialization (AC: #3, #4) + - [x] Import storage functions into App.svelte + - [x] On init: attempt `loadGameState()` — if non-null, use as initial gameState; otherwise `initGame()` + - [x] On init: attempt `loadBestScore()` — use as initial bestScore value + - [x] Ensure bestScore from storage is at least as high as restored game score + +- [x] Task 3: Save state after every move and score change (AC: #1, #2) + - [x] Add `$effect` to save gameState to localStorage whenever gameState changes + - [x] Update existing bestScore `$effect` to also call `saveBestScore(bestScore)` when bestScore updates + +- [x] Task 4: Handle New Game clearing gameState (AC: #6) + - [x] In `handleNewGame()`, call storage function to clear gameState from localStorage + - [x] Verify bestScore key is NOT cleared on New Game + +- [x] Task 5: Write tests for storage.js (AC: #5) + - [x] Test saveGameState/loadGameState round-trip + - [x] Test saveBestScore/loadBestScore round-trip + - [x] Test loadGameState returns null for corrupted data + - [x] Test loadBestScore returns 0 for missing/corrupted data + - [x] Test loadGameState returns null when key is missing + +- [x] Task 6: Run full test suite + - [x] All 48 tests pass (38 existing + 10 new storage tests) + +## Dev Notes + +### Architecture Compliance + +- **File location:** `src/lib/storage.js` — pure JS module, ZERO Svelte imports, ZERO DOM access beyond localStorage +- **Exports:** `saveGameState`, `loadGameState`, `saveBestScore`, `loadBestScore`, `clearGameState` +- **Error handling:** Wrap `JSON.parse` in try/catch — on failure, return null (gameState) or 0 (bestScore). No user-facing errors. +- **localStorage keys:** `gameState` (JSON object), `bestScore` (JSON number) +- **No new dependencies** — uses built-in localStorage API only + +### Critical Anti-Patterns (DO NOT) + +- DO NOT store bestScore inside the gameState localStorage key — they are separate keys +- DO NOT show error messages to the user on corrupted localStorage — silent recovery +- DO NOT import Svelte in storage.js — it's a pure JS module +- DO NOT use sessionStorage — use localStorage for cross-session persistence +- DO NOT save the entire App component state — only save the canonical game state shape + +### localStorage Schema + +```javascript +// Key: "gameState" +{ grid: number[][], score: number, won: boolean, keepPlaying: boolean } + +// Key: "bestScore" +number +``` + +### Save/Load Pattern in App.svelte + +```javascript +import { saveGameState, loadGameState, saveBestScore, loadBestScore, clearGameState } from './lib/storage.js'; + +// Initialize from localStorage or fresh +const savedState = loadGameState(); +let gameState = $state(savedState || initGame()); +let bestScore = $state(Math.max(loadBestScore(), savedState?.score || 0)); + +// Save on every state change +$effect(() => { saveGameState(gameState); }); +$effect(() => { + if (gameState.score > bestScore) bestScore = gameState.score; + saveBestScore(bestScore); +}); + +// New Game: clear saved state, keep bestScore +function handleNewGame() { + clearGameState(); + gameState = initGame(); +} +``` + +### Previous Story Intelligence + +From Story 2.1: +- `bestScore` is a separate `$state(0)` in App.svelte — NOT part of gameState +- `$effect` watches `gameState.score` and updates `bestScore` when exceeded +- `handleNewGame()` only resets `gameState = initGame()` — bestScore survives +- ScoreBoard receives `score` and `bestScore` props — no changes needed +- 38 tests passing in `src/lib/game-logic.test.js` + +### Testing Strategy + +- Mock localStorage using Vitest's built-in support or a simple mock object +- Test storage.js in isolation — no Svelte needed +- Test file: `src/lib/storage.test.js` (co-located with module) + +### References + +- [Source: _bmad-output/planning-artifacts/epics.md#Story 2.2] +- [Source: _bmad-output/planning-artifacts/architecture.md#Data Architecture - localStorage Schema] +- [Source: _bmad-output/planning-artifacts/architecture.md#Implementation Patterns - Process Patterns] + +## Dev Agent Record + +### Agent Model Used + +Claude Opus 4.6 (1M context) + +### Debug Log References + +### Completion Notes List + +- Created `src/lib/storage.js` with 5 exports: saveGameState, loadGameState, clearGameState, saveBestScore, loadBestScore +- All JSON.parse wrapped in try/catch — corrupted data returns null/0 silently +- saveGameState destructures only canonical fields (grid, score, won, keepPlaying) — no extra data leaks +- App.svelte initializes from localStorage on load, falls back to initGame() if missing/corrupted +- bestScore initialized as max of stored bestScore and restored game score +- Two $effects: one saves gameState on every change, one tracks+saves bestScore +- handleNewGame calls clearGameState() — removes gameState key but preserves bestScore +- 10 new tests in storage.test.js with localStorage mock, 48 total tests passing + +### File List + +- src/lib/storage.js (new — localStorage persistence module) +- src/lib/storage.test.js (new — 10 tests for storage module) +- src/App.svelte (modified — integrated storage save/load/clear) diff --git a/bmad/_bmad-output/implementation-artifacts/sprint-status.yaml b/bmad/_bmad-output/implementation-artifacts/sprint-status.yaml index c0e2589..a7900eb 100644 --- a/bmad/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/bmad/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -55,7 +55,7 @@ development_status: # Epic 2: Save Progress & Keep Playing epic-2: in-progress 2-1-best-score-tracking: done - 2-2-game-state-persistence: backlog + 2-2-game-state-persistence: done 2-3-keep-playing-mode: backlog epic-2-retrospective: optional diff --git a/bmad/src/App.svelte b/bmad/src/App.svelte index 1356aaa..aa44dc4 100644 --- a/bmad/src/App.svelte +++ b/bmad/src/App.svelte @@ -5,14 +5,21 @@ import { initGame, move, isGameOver } from './lib/game-logic.js'; import { GRID_SIZE } from './lib/constants.js'; import { getDirectionFromKey } from './lib/input-handler.js'; + import { saveGameState, loadGameState, saveBestScore, loadBestScore, clearGameState } from './lib/storage.js'; - let gameState = $state(initGame()); - let bestScore = $state(0); + const savedState = loadGameState(); + let gameState = $state(savedState || initGame()); + let bestScore = $state(Math.max(loadBestScore(), savedState?.score || 0)); + + $effect(() => { + saveGameState(gameState); + }); $effect(() => { if (gameState.score > bestScore) { bestScore = gameState.score; } + saveBestScore(bestScore); }); let tiles = $derived.by(() => { @@ -35,6 +42,7 @@ }); function handleNewGame() { + clearGameState(); gameState = initGame(); } diff --git a/bmad/src/lib/storage.js b/bmad/src/lib/storage.js new file mode 100644 index 0000000..d566554 --- /dev/null +++ b/bmad/src/lib/storage.js @@ -0,0 +1,50 @@ +const GAME_STATE_KEY = 'gameState'; +const BEST_SCORE_KEY = 'bestScore'; + +export function saveGameState(state) { + try { + const { grid, score, won, keepPlaying } = state; + localStorage.setItem(GAME_STATE_KEY, JSON.stringify({ grid, score, won, keepPlaying })); + } catch { + // Silent failure — localStorage may be full or unavailable + } +} + +export function loadGameState() { + try { + const raw = localStorage.getItem(GAME_STATE_KEY); + if (raw === null) return null; + const parsed = JSON.parse(raw); + if (!parsed || !Array.isArray(parsed.grid) || typeof parsed.score !== 'number') return null; + return parsed; + } catch { + return null; + } +} + +export function clearGameState() { + try { + localStorage.removeItem(GAME_STATE_KEY); + } catch { + // Silent failure + } +} + +export function saveBestScore(score) { + try { + localStorage.setItem(BEST_SCORE_KEY, JSON.stringify(score)); + } catch { + // Silent failure + } +} + +export function loadBestScore() { + try { + const raw = localStorage.getItem(BEST_SCORE_KEY); + if (raw === null) return 0; + const parsed = JSON.parse(raw); + return typeof parsed === 'number' ? parsed : 0; + } catch { + return 0; + } +} diff --git a/bmad/src/lib/storage.test.js b/bmad/src/lib/storage.test.js new file mode 100644 index 0000000..a0c200e --- /dev/null +++ b/bmad/src/lib/storage.test.js @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { saveGameState, loadGameState, clearGameState, saveBestScore, loadBestScore } from './storage.js'; + +// Mock localStorage +const store = {}; +const localStorageMock = { + getItem: (key) => store[key] ?? null, + setItem: (key, value) => { store[key] = String(value); }, + removeItem: (key) => { delete store[key]; }, +}; +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }); + +beforeEach(() => { + Object.keys(store).forEach((key) => delete store[key]); +}); + +describe('saveGameState / loadGameState', () => { + it('round-trips game state', () => { + const state = { grid: [[2, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 4]], score: 8, won: false, keepPlaying: false }; + saveGameState(state); + const loaded = loadGameState(); + expect(loaded).toEqual(state); + }); + + it('returns null when no saved state', () => { + expect(loadGameState()).toBeNull(); + }); + + it('returns null for corrupted data', () => { + store.gameState = 'not-valid-json{{{'; + expect(loadGameState()).toBeNull(); + }); + + it('returns null for valid JSON with wrong shape', () => { + store.gameState = '{"foo": 1}'; + expect(loadGameState()).toBeNull(); + }); + + it('only saves canonical fields', () => { + const state = { grid: [[0]], score: 5, won: true, keepPlaying: false, extraField: 'nope' }; + saveGameState(state); + const loaded = loadGameState(); + expect(loaded).not.toHaveProperty('extraField'); + }); +}); + +describe('clearGameState', () => { + it('removes gameState key', () => { + saveGameState({ grid: [[0]], score: 0, won: false, keepPlaying: false }); + clearGameState(); + expect(loadGameState()).toBeNull(); + }); + + it('does not affect bestScore key', () => { + saveBestScore(100); + saveGameState({ grid: [[0]], score: 0, won: false, keepPlaying: false }); + clearGameState(); + expect(loadBestScore()).toBe(100); + }); +}); + +describe('saveBestScore / loadBestScore', () => { + it('round-trips best score', () => { + saveBestScore(2048); + expect(loadBestScore()).toBe(2048); + }); + + it('returns 0 when no saved score', () => { + expect(loadBestScore()).toBe(0); + }); + + it('returns 0 for corrupted data', () => { + store.bestScore = 'not-a-number{'; + expect(loadBestScore()).toBe(0); + }); + + it('returns 0 for non-number JSON', () => { + store.bestScore = '"hello"'; + expect(loadBestScore()).toBe(0); + }); +});