From d482b179769a20812a4d08cd800ed39e755f1dcd Mon Sep 17 00:00:00 2001 From: Ayo Date: Sat, 4 Jul 2026 11:48:08 +0200 Subject: [PATCH] feat(core): serializeable game state --- packages/mnswpr/core/minesweeper/rules.js | 45 ++++++++++- packages/mnswpr/core/session/session.js | 45 ++++++++++- packages/mnswpr/test/core.test.js | 99 ++++++++++++++++++++++- 3 files changed, 184 insertions(+), 5 deletions(-) diff --git a/packages/mnswpr/core/minesweeper/rules.js b/packages/mnswpr/core/minesweeper/rules.js index e68c3f5..f1aa903 100644 --- a/packages/mnswpr/core/minesweeper/rules.js +++ b/packages/mnswpr/core/minesweeper/rules.js @@ -1,5 +1,6 @@ // @ts-check import { Grid } from '../grid/grid.js' +import { toJSON as gridToJSON, fromJSON as gridFromJSON } from '../grid/serialize.js' import { eightWay } from '../grid/neighbors.js' import { placeMines, excludeAround, validateLayout } from './board.js' import { floodReveal, countFlagsAround, allMines } from './reveal.js' @@ -126,7 +127,8 @@ function project(state) { /** * The GameRules contract consumed by GameSession/replay: init / apply / status / - * project. Deterministic and DOM-free. + * project, plus serialize / deserialize for snapshotting. Deterministic and + * DOM-free. */ export const MinesweeperRules = { /** @@ -196,5 +198,44 @@ export const MinesweeperRules = { } }, - project + project, + + /** + * Snapshot a game state as a plain, JSON-safe object: the whole board (every + * cell's mine/adjacent/status, via the Layer-0 grid serializer) plus phase and + * progress. Inverse of {@link deserialize}. The `grid` instance is the only + * non-JSON-safe field of `State`; everything else (seed, config, flags) is + * already plain data. + * + * @param {State} state + * @returns {{ seed: number, config: Config, phase: Phase, minesPlaced: boolean, revealedSafe: number, grid: { rows: number, cols: number, cells: Cell[] } }} + */ + serialize(state) { + return { + seed: state.seed, + config: state.config, + phase: state.phase, + minesPlaced: state.minesPlaced, + revealedSafe: state.revealedSafe, + grid: gridToJSON(state.grid) + } + }, + + /** + * Rebuild a game state from {@link serialize} output (or its JSON round-trip). + * Cells are cloned so the revived state shares no references with the snapshot. + * + * @param {ReturnType} snap + * @returns {State} + */ + deserialize(snap) { + return { + seed: snap.seed, + config: snap.config, + phase: snap.phase, + minesPlaced: snap.minesPlaced, + revealedSafe: snap.revealedSafe, + grid: gridFromJSON(snap.grid, cell => ({ ...cell })) + } + } } diff --git a/packages/mnswpr/core/session/session.js b/packages/mnswpr/core/session/session.js index 5b0c41d..aa58b59 100644 --- a/packages/mnswpr/core/session/session.js +++ b/packages/mnswpr/core/session/session.js @@ -7,7 +7,7 @@ * server's clock (authoritative). The session never calls a wall clock itself. * Future home: @cozy-games/game-session. * - * @typedef {{ init: Function, apply: Function, status: Function, project: Function }} Rules + * @typedef {{ init: Function, apply: Function, status: Function, project: Function, serialize?: Function, deserialize?: Function }} Rules */ export class GameSession { /** @@ -74,4 +74,47 @@ export class GameSession { if (status !== 'won' && status !== 'lost') return null return { status, time: this.elapsed(), seed: this.state.seed, config: this.state.config, log: this.log() } } + + /** + * Full, JSON-safe snapshot of the whole session — game state (board + per-cell + * status, via the rules' own serializer) plus the move log and timing anchors + * (`t0`/`tEnd`) that `elapsed()` derives from. Everything needed to later resume + * (core-05); the live `clock` is deliberately excluded (it's re-injected on + * {@link GameSession.deserialize}). Requires the rules to implement `serialize`. + * + * @returns {{ state: object, log: Array<{ move: object, t: number }>, t0: number | null, tEnd: number | null }} + */ + serialize() { + if (typeof this.rules.serialize !== 'function') { + throw new TypeError('GameSession.serialize: rules must implement serialize(state)') + } + return { + state: this.rules.serialize(this.state), + log: this._log.map(({ move, t }) => ({ move, t })), + t0: this._t0, + tEnd: this._tEnd + } + } + + /** + * Rebuild a session from a {@link serialize} snapshot (or its JSON round-trip). + * The clock is re-injected — it's a live function, not serializable — while the + * log and timing anchors are restored so `elapsed()` resumes correctly. + * Requires the rules to implement `deserialize`. + * + * @param {Rules} rules + * @param {{ state: object, log: Array<{ move: object, t: number }>, t0: number | null, tEnd: number | null }} snapshot + * @param {{ clock?: () => number }} [opts] + * @returns {GameSession} + */ + static deserialize(rules, snapshot, { clock = () => 0 } = {}) { + 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 + return session + } } diff --git a/packages/mnswpr/test/core.test.js b/packages/mnswpr/test/core.test.js index 3187c26..2dfb7e9 100644 --- a/packages/mnswpr/test/core.test.js +++ b/packages/mnswpr/test/core.test.js @@ -355,6 +355,101 @@ describe('board injection (fromLayout, Layer 2)', () => { }) }) +describe('state serialization (serialize / deserialize)', () => { + // Drive a fresh session to a genuine MID-GAME state: opening flood + a flag on + // a mine (which stays flagged and never needs revealing to win). Returns the + // session, its clock, and the flagged mine's coordinates. + function midGame(seed = 21) { + const clock = makeClock() + const session = new GameSession(MinesweeperRules, { seed, config: beginner, clock }) + clock.tick() + session.applyMove({ type: 'reveal', r: 0, c: 0 }) // opening flood → active + const mine = firstMine(session.state.grid) + clock.tick() + session.applyMove({ type: 'flag', r: mine.r, c: mine.c }) + return { session, clock, mine } + } + + it('round-trips a mid-game snapshot through JSON without loss', () => { + const { session } = midGame() + expect(session.status()).toBe('active') // genuinely mid-game, not fresh/finished + + const snap = session.serialize() + const roundTripped = JSON.parse(JSON.stringify(snap)) + expect(roundTripped).toEqual(snap) // JSON-safe: stringify → parse is lossless + + // deserialize → re-serialize reproduces the exact snapshot + const revived = GameSession.deserialize(MinesweeperRules, roundTripped, { clock: makeClock() }) + expect(revived.serialize()).toEqual(snap) + }) + + it('snapshot covers layout, revealed + flagged cells, and session/clock state', () => { + const { session, mine } = midGame() + const snap = session.serialize() + + // board layout + expect(snap.state.grid.rows).toBe(beginner.rows) + expect(snap.state.grid.cols).toBe(beginner.cols) + expect(snap.state.grid.cells).toHaveLength(beginner.rows * beginner.cols) + expect(snap.state.grid.cells[0]).toEqual({ mine: expect.any(Boolean), adjacent: expect.any(Number), status: expect.any(String) }) + + // per-cell state: the flood left revealed cells, and our flagged mine is flagged + expect(snap.state.grid.cells.some(c => c.status === 'revealed')).toBe(true) + expect(snap.state.grid.cells[mine.r * beginner.cols + mine.c].status).toBe('flagged') + + // session / clock state: timer started (t0 set), still running (tEnd null), log intact + expect(snap.t0).not.toBeNull() + expect(snap.tEnd).toBeNull() + expect(snap.log).toHaveLength(2) + expect(snap.log[0]).toEqual({ move: { type: 'reveal', r: 0, c: 0 }, t: expect.any(Number) }) + }) + + it('a deserialized session resumes and plays identically to the original', () => { + const playToWin = (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 { session, clock } = midGame(33) + const snap = session.serialize() + // Revive on a clock continuing from the same instant, so timing stays aligned. + const reviveClock = makeClock(clock()) + const revived = GameSession.deserialize(MinesweeperRules, JSON.parse(JSON.stringify(snap)), { clock: reviveClock }) + + playToWin(session, clock) + playToWin(revived, reviveClock) + + expect(revived.status()).toBe(session.status()) + expect(revived.status()).toBe('won') + expect(revived.view()).toEqual(session.view()) + expect(revived.elapsed()).toBe(session.elapsed()) + expect(revived.result()).toEqual(session.result()) + }) + + it('rules.serialize / deserialize round-trips a state and revives a real Grid', () => { + const state = MinesweeperRules.init(7, beginner) + MinesweeperRules.apply(state, { type: 'reveal', r: 0, c: 0 }) + const snap = MinesweeperRules.serialize(state) + const revived = MinesweeperRules.deserialize(JSON.parse(JSON.stringify(snap))) + expect(revived.grid).toBeInstanceOf(Grid) // not a plain object — a live Grid + expect(revived.grid.at(0, 0)).toEqual(state.grid.at(0, 0)) + expect(MinesweeperRules.serialize(revived)).toEqual(snap) + }) + + it('serialize throws clearly when the rules lack a serializer', () => { + const bareRules = { init: () => ({}), apply: s => ({ state: s, events: [] }), status: () => 'fresh', project: s => s } + const session = new GameSession(bareRules, { state: {} }) + expect(() => session.serialize()).toThrow(TypeError) + }) +}) + describe('session + timing (Layer 1)', () => { it('reports authoritative elapsed time from the injected clock', () => { const clock = makeClock() @@ -438,8 +533,8 @@ describe('determinism guard (invariant #4)', () => { // ---- helpers ---- -function makeClock() { - let t = 1000 +function makeClock(start = 1000) { + let t = start return Object.assign(() => t, { tick() { t += 50 } }) }