feat(core): resume games

This commit is contained in:
ayo 2026-07-04 11:52:38 +02:00
parent d482b17976
commit d3d84aa9b8
3 changed files with 171 additions and 4 deletions

View file

@ -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,

View file

@ -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
}
}

View file

@ -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()