feat(core): serializeable game state
This commit is contained in:
parent
8099820e79
commit
d482b17976
3 changed files with 184 additions and 5 deletions
|
|
@ -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<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 }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue