From d3d84aa9b8f4610917b500862be0de450da9e405 Mon Sep 17 00:00:00 2001 From: Ayo Date: Sat, 4 Jul 2026 11:52:38 +0200 Subject: [PATCH] feat(core): resume games --- packages/mnswpr/core/minesweeper/rules.js | 43 +++++++++ packages/mnswpr/core/session/session.js | 24 ++++- packages/mnswpr/test/core.test.js | 108 ++++++++++++++++++++++ 3 files changed, 171 insertions(+), 4 deletions(-) diff --git a/packages/mnswpr/core/minesweeper/rules.js b/packages/mnswpr/core/minesweeper/rules.js index f1aa903..dbb012b 100644 --- a/packages/mnswpr/core/minesweeper/rules.js +++ b/packages/mnswpr/core/minesweeper/rules.js @@ -19,6 +19,48 @@ import { floodReveal, countFlagsAround, allMines } from './reveal.js' const freshCell = () => ({ mine: false, adjacent: 0, status: 'hidden' }) +/** Valid game phases and per-cell statuses — the closed sets a snapshot may name. */ +const PHASES = new Set(['fresh', 'active', 'won', 'lost']) +const CELL_STATUSES = new Set(['hidden', 'flagged', 'revealed']) + +/** + * Assert a serialized game state (from {@link MinesweeperRules.serialize}, or its + * JSON round-trip) is well-formed before reviving it, so a corrupt or truncated + * snapshot fails with a clear error instead of quietly producing a broken game. + * + * @param {unknown} snap + */ +function assertStateSnapshot(snap) { + if (snap === null || typeof snap !== 'object') { + throw new TypeError(`deserialize: expected a state snapshot object (got ${snap === null ? 'null' : typeof snap})`) + } + const s = /** @type {any} */ (snap) + if (typeof s.seed !== 'number') throw new TypeError('deserialize: snapshot.seed must be a number') + const cfg = s.config + if (cfg === null || typeof cfg !== 'object' || !Number.isInteger(cfg.rows) || !Number.isInteger(cfg.cols) || !Number.isInteger(cfg.mines)) { + throw new TypeError('deserialize: snapshot.config must be { rows, cols, mines } integers') + } + if (!PHASES.has(s.phase)) throw new RangeError(`deserialize: snapshot.phase must be one of ${[...PHASES].join('/')} (got ${JSON.stringify(s.phase)})`) + if (typeof s.minesPlaced !== 'boolean') throw new TypeError('deserialize: snapshot.minesPlaced must be a boolean') + if (!Number.isInteger(s.revealedSafe) || s.revealedSafe < 0) throw new RangeError('deserialize: snapshot.revealedSafe must be a non-negative integer') + const g = s.grid + if (g === null || typeof g !== 'object' || !Number.isInteger(g.rows) || !Number.isInteger(g.cols) || !Array.isArray(g.cells)) { + throw new TypeError('deserialize: snapshot.grid must be { rows, cols, cells[] }') + } + if (g.rows !== cfg.rows || g.cols !== cfg.cols) { + throw new RangeError(`deserialize: grid ${g.rows}x${g.cols} disagrees with config ${cfg.rows}x${cfg.cols}`) + } + if (g.cells.length !== g.rows * g.cols) { + throw new RangeError(`deserialize: grid.cells must have ${g.rows * g.cols} entries (got ${g.cells.length})`) + } + for (let i = 0; i < g.cells.length; i++) { + const cell = g.cells[i] + if (cell === null || typeof cell !== 'object' || typeof cell.mine !== 'boolean' || !Number.isInteger(cell.adjacent) || !CELL_STATUSES.has(cell.status)) { + throw new TypeError(`deserialize: grid.cells[${i}] must be { mine: boolean, adjacent: integer, status: hidden/flagged/revealed }`) + } + } +} + /** @param {State} state */ function isWin(state) { const { rows, cols, mines } = state.config @@ -229,6 +271,7 @@ export const MinesweeperRules = { * @returns {State} */ deserialize(snap) { + assertStateSnapshot(snap) return { seed: snap.seed, config: snap.config, diff --git a/packages/mnswpr/core/session/session.js b/packages/mnswpr/core/session/session.js index aa58b59..60b289a 100644 --- a/packages/mnswpr/core/session/session.js +++ b/packages/mnswpr/core/session/session.js @@ -111,10 +111,26 @@ export class GameSession { if (typeof rules.deserialize !== 'function') { throw new TypeError('GameSession.deserialize: rules must implement deserialize(snapshot)') } - const session = new GameSession(rules, { state: rules.deserialize(snapshot.state), clock }) - session._log = snapshot.log.map(({ move, t }) => ({ move, t })) - session._t0 = snapshot.t0 - session._tEnd = snapshot.tEnd + if (snapshot === null || typeof snapshot !== 'object') { + throw new TypeError(`GameSession.deserialize: expected a snapshot object (got ${snapshot === null ? 'null' : typeof snapshot})`) + } + const { state, log, t0, tEnd } = snapshot + if (!Array.isArray(log)) { + throw new TypeError('GameSession.deserialize: snapshot.log must be an array') + } + for (const entry of log) { + if (entry === null || typeof entry !== 'object' || typeof entry.t !== 'number' || entry.move === null || typeof entry.move !== 'object') { + throw new TypeError('GameSession.deserialize: each log entry must be { move: object, t: number }') + } + } + if (!(t0 === null || typeof t0 === 'number') || !(tEnd === null || typeof tEnd === 'number')) { + throw new TypeError('GameSession.deserialize: snapshot.t0/tEnd must each be a number or null') + } + // rules.deserialize validates and revives the game-state half. + const session = new GameSession(rules, { state: rules.deserialize(state), clock }) + session._log = log.map(({ move, t }) => ({ move, t })) + session._t0 = t0 + session._tEnd = tEnd return session } } diff --git a/packages/mnswpr/test/core.test.js b/packages/mnswpr/test/core.test.js index 2dfb7e9..bd088fa 100644 --- a/packages/mnswpr/test/core.test.js +++ b/packages/mnswpr/test/core.test.js @@ -450,6 +450,114 @@ describe('state serialization (serialize / deserialize)', () => { }) }) +describe('resume from serialized state (Layer 1)', () => { + // Reveal every remaining non-mine cell in row-major order, ticking the injected + // clock before each move — a deterministic way to finish an in-progress board. + const finish = (session, clock) => { + const { grid, config } = session.state + for (let r = 0; r < config.rows; r++) { + for (let c = 0; c < config.cols; c++) { + if (!grid.at(r, c).mine && grid.at(r, c).status !== 'revealed') { + clock.tick() + session.applyMove({ type: 'reveal', r, c }) + } + } + } + } + + const validSnapshot = () => { + const s = new GameSession(MinesweeperRules, { seed: 1, config: beginner, clock: makeClock() }) + s.applyMove({ type: 'reveal', r: 0, c: 0 }) + return JSON.parse(JSON.stringify(s.serialize())) + } + + it('play N moves → serialize → restore → finish: final state identical to an uninterrupted run', () => { + // Uninterrupted reference run. + const clockU = makeClock() + const uninterrupted = new GameSession(MinesweeperRules, { seed: 77, config: beginner, clock: clockU }) + clockU.tick() + uninterrupted.applyMove({ type: 'reveal', r: 0, c: 0 }) + finish(uninterrupted, clockU) + + // Interrupted: identical opening move, snapshot, restore on the SAME clock + // instant, then finish with the identical remaining moves. + const clockI = makeClock() + const before = new GameSession(MinesweeperRules, { seed: 77, config: beginner, clock: clockI }) + clockI.tick() + before.applyMove({ type: 'reveal', r: 0, c: 0 }) + const snap = JSON.parse(JSON.stringify(before.serialize())) + const resumed = GameSession.deserialize(MinesweeperRules, snap, { clock: clockI }) + finish(resumed, clockI) + + expect(resumed.status()).toBe('won') + expect(resumed.view()).toEqual(uninterrupted.view()) // identical board + progress + expect(resumed.result()).toEqual(uninterrupted.result()) // identical log + time + }) + + it('elapsed time continues (not reset) after restore, on the injected clock', () => { + const clock = makeClock() // 1000 + const s = new GameSession(MinesweeperRules, { seed: 21, config: beginner, clock }) + clock.tick() // 1050 + s.applyMove({ type: 'reveal', r: 0, c: 0 }) // t0 = 1050 + clock.tick(); clock.tick() // 1150 + expect(s.elapsed()).toBe(100) // 1150 − 1050 + + const snap = JSON.parse(JSON.stringify(s.serialize())) + // Resume on a fresh clock that continues from the same instant (1150). + const reviveClock = makeClock(clock()) + const resumed = GameSession.deserialize(MinesweeperRules, snap, { clock: reviveClock }) + + // Preserved, not reset to zero. + expect(resumed.elapsed()).toBe(100) + + // And it keeps advancing on the injected clock as play continues. + reviveClock.tick() // 1200 + const mine = firstMine(resumed.state.grid) + resumed.applyMove({ type: 'flag', r: mine.r, c: mine.c }) + expect(resumed.elapsed()).toBe(150) // 1200 − 1050 + expect(resumed.elapsed()).toBeGreaterThan(100) + }) + + it('a finished (won/lost) session round-trips with its final elapsed frozen', () => { + const clock = makeClock() + const s = new GameSession(MinesweeperRules, { seed: 5, config: beginner, clock }) + clock.tick() + s.applyMove({ type: 'reveal', r: 0, c: 0 }) + finish(s, clock) + expect(s.status()).toBe('won') + const finalTime = s.elapsed() + + const resumed = GameSession.deserialize( + MinesweeperRules, + JSON.parse(JSON.stringify(s.serialize())), + { clock: makeClock(999999) } // a wildly different clock must not change a frozen time + ) + expect(resumed.status()).toBe('won') + expect(resumed.elapsed()).toBe(finalTime) + }) + + it('rejects invalid or corrupt snapshots with a clear error', () => { + const good = validSnapshot() + // envelope corruption (caught by GameSession.deserialize) + expect(() => GameSession.deserialize(MinesweeperRules, null)).toThrow(TypeError) + expect(() => GameSession.deserialize(MinesweeperRules, { ...good, log: 'nope' })).toThrow(TypeError) + expect(() => GameSession.deserialize(MinesweeperRules, { ...good, log: [{ move: {}, t: 'soon' }] })).toThrow(TypeError) + expect(() => GameSession.deserialize(MinesweeperRules, { ...good, t0: 'later' })).toThrow(TypeError) + // game-state corruption (delegated to rules.deserialize) + expect(() => GameSession.deserialize(MinesweeperRules, { ...good, state: null })).toThrow(TypeError) + expect(() => GameSession.deserialize(MinesweeperRules, { ...good, state: { ...good.state, phase: 'bogus' } })).toThrow(RangeError) + const truncated = { ...good, state: { ...good.state, grid: { ...good.state.grid, cells: good.state.grid.cells.slice(1) } } } + expect(() => GameSession.deserialize(MinesweeperRules, truncated)).toThrow(RangeError) + }) + + it('rules.deserialize rejects a malformed state snapshot directly', () => { + expect(() => MinesweeperRules.deserialize(null)).toThrow(TypeError) + expect(() => MinesweeperRules.deserialize({})).toThrow(TypeError) // missing config/grid + const good = validSnapshot().state + expect(() => MinesweeperRules.deserialize({ ...good, grid: { ...good.grid, rows: good.grid.rows + 1 } })).toThrow(RangeError) + }) +}) + describe('session + timing (Layer 1)', () => { it('reports authoritative elapsed time from the injected clock', () => { const clock = makeClock()