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); + }); +});