feat(core): serializeable game state

This commit is contained in:
ayo 2026-07-04 11:48:08 +02:00
parent 8099820e79
commit d482b17976
3 changed files with 184 additions and 5 deletions

View file

@ -1,5 +1,6 @@
// @ts-check // @ts-check
import { Grid } from '../grid/grid.js' import { Grid } from '../grid/grid.js'
import { toJSON as gridToJSON, fromJSON as gridFromJSON } from '../grid/serialize.js'
import { eightWay } from '../grid/neighbors.js' import { eightWay } from '../grid/neighbors.js'
import { placeMines, excludeAround, validateLayout } from './board.js' import { placeMines, excludeAround, validateLayout } from './board.js'
import { floodReveal, countFlagsAround, allMines } from './reveal.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 / * 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 = { 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<typeof MinesweeperRules.serialize>} 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 }))
}
}
} }

View file

@ -7,7 +7,7 @@
* server's clock (authoritative). The session never calls a wall clock itself. * server's clock (authoritative). The session never calls a wall clock itself.
* Future home: @cozy-games/game-session. * 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 { export class GameSession {
/** /**
@ -74,4 +74,47 @@ export class GameSession {
if (status !== 'won' && status !== 'lost') return null if (status !== 'won' && status !== 'lost') return null
return { status, time: this.elapsed(), seed: this.state.seed, config: this.state.config, log: this.log() } 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
}
} }

View file

@ -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)', () => { describe('session + timing (Layer 1)', () => {
it('reports authoritative elapsed time from the injected clock', () => { it('reports authoritative elapsed time from the injected clock', () => {
const clock = makeClock() const clock = makeClock()
@ -438,8 +533,8 @@ describe('determinism guard (invariant #4)', () => {
// ---- helpers ---- // ---- helpers ----
function makeClock() { function makeClock(start = 1000) {
let t = 1000 let t = start
return Object.assign(() => t, { tick() { t += 50 } }) return Object.assign(() => t, { tick() { t += 50 } })
} }