feat(core): resume games
This commit is contained in:
parent
d482b17976
commit
d3d84aa9b8
3 changed files with 171 additions and 4 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue