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
|
// @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 }))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue