feat(core): move events
This commit is contained in:
parent
d3d84aa9b8
commit
0823d43c75
4 changed files with 206 additions and 6 deletions
|
|
@ -18,7 +18,7 @@ export { replay } from './session/replay.js'
|
||||||
export { mulberry32, randInt } from './session/rng.js'
|
export { mulberry32, randInt } from './session/rng.js'
|
||||||
|
|
||||||
// Layer 2 — Minesweeper rules & pure board generation
|
// Layer 2 — Minesweeper rules & pure board generation
|
||||||
export { MinesweeperRules } from './minesweeper/rules.js'
|
export { MinesweeperRules, MOVE_EVENT_TYPES } from './minesweeper/rules.js'
|
||||||
export { generateBoard, validateLayout } from './minesweeper/board.js'
|
export { generateBoard, validateLayout } from './minesweeper/board.js'
|
||||||
|
|
||||||
// Shared level presets (also consumed by the DOM client)
|
// Shared level presets (also consumed by the DOM client)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,22 @@ import { floodReveal, countFlagsAround, allMines } from './reveal.js'
|
||||||
* @typedef {object} Event
|
* @typedef {object} Event
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The typed move-event vocabulary emitted by the session (one per effective
|
||||||
|
* move). This is the game's public event language — consumed later by the shared
|
||||||
|
* envelope. `type` + `r`/`c` are game meaning (classified here); `t` (injected
|
||||||
|
* clock) and `seq` (monotonic) are stamped by `GameSession` when it emits.
|
||||||
|
*
|
||||||
|
* Note `flag` vs `unflag`: both come from a `flag` move — the distinction is the
|
||||||
|
* outcome (did the toggle set or clear the flag), which only the rules can tell.
|
||||||
|
*
|
||||||
|
* @typedef {'reveal' | 'flag' | 'unflag' | 'chord'} MoveEventType
|
||||||
|
* @typedef {{ type: MoveEventType, r: number, c: number, t: number, seq: number }} MoveEvent
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The move-event vocabulary as runtime data (the `MoveEvent` `type` domain). */
|
||||||
|
export const MOVE_EVENT_TYPES = /** @type {const} */ (['reveal', 'flag', 'unflag', 'chord'])
|
||||||
|
|
||||||
const freshCell = () => ({ mine: false, adjacent: 0, status: 'hidden' })
|
const freshCell = () => ({ mine: false, adjacent: 0, status: 'hidden' })
|
||||||
|
|
||||||
/** Valid game phases and per-cell statuses — the closed sets a snapshot may name. */
|
/** Valid game phases and per-cell statuses — the closed sets a snapshot may name. */
|
||||||
|
|
@ -242,6 +258,31 @@ export const MinesweeperRules = {
|
||||||
|
|
||||||
project,
|
project,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify an applied move into a typed move-event kind, or `null` if the move
|
||||||
|
* was a no-op (the rules produced no events — e.g. clicking a revealed cell).
|
||||||
|
* The session stamps the returned `{ type, r, c }` with `t` and `seq`. This is
|
||||||
|
* the seam that turns a raw `Move` into the {@link MoveEvent} vocabulary and,
|
||||||
|
* critically, splits a `flag` move into `flag`/`unflag` by its outcome.
|
||||||
|
*
|
||||||
|
* @param {Move} move
|
||||||
|
* @param {Event[]} events - the rules events this move produced
|
||||||
|
* @returns {{ type: MoveEventType, r: number, c: number } | null}
|
||||||
|
*/
|
||||||
|
toMoveEvent(move, events) {
|
||||||
|
if (!events || events.length === 0) return null
|
||||||
|
switch (move.type) {
|
||||||
|
case 'reveal': return { type: 'reveal', r: move.r, c: move.c }
|
||||||
|
case 'chord': return { type: 'chord', r: move.r, c: move.c }
|
||||||
|
case 'flag': {
|
||||||
|
const flagged = events.find(e => e.type === 'flag')
|
||||||
|
if (!flagged) return null
|
||||||
|
return { type: flagged.flagged ? 'flag' : 'unflag', r: move.r, c: move.c }
|
||||||
|
}
|
||||||
|
default: return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snapshot a game state as a plain, JSON-safe object: the whole board (every
|
* 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
|
* cell's mine/adjacent/status, via the Layer-0 grid serializer) plus phase and
|
||||||
|
|
|
||||||
|
|
@ -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, serialize?: Function, deserialize?: Function }} Rules
|
* @typedef {{ init: Function, apply: Function, status: Function, project: Function, serialize?: Function, deserialize?: Function, toMoveEvent?: Function }} Rules
|
||||||
*/
|
*/
|
||||||
export class GameSession {
|
export class GameSession {
|
||||||
/**
|
/**
|
||||||
|
|
@ -27,6 +27,26 @@ export class GameSession {
|
||||||
this._log = []
|
this._log = []
|
||||||
this._t0 = null
|
this._t0 = null
|
||||||
this._tEnd = null
|
this._tEnd = null
|
||||||
|
// Move-event emission: subscribers + a monotonic sequence counter (last seq
|
||||||
|
// assigned; 0 = none yet). Seq is part of the snapshot so it keeps rising
|
||||||
|
// across a resume rather than restarting.
|
||||||
|
/** @type {Set<(event: object) => void>} */
|
||||||
|
this._moveHandlers = new Set()
|
||||||
|
this._seq = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to typed move-events — one per effective move (reveal / flag /
|
||||||
|
* unflag / chord), each carrying `{ type, r, c, t, seq }`. Returns an
|
||||||
|
* unsubscribe function. Pure in-process pub/sub: no DOM, no rendering. Requires
|
||||||
|
* the rules to implement `toMoveEvent`.
|
||||||
|
*
|
||||||
|
* @param {(event: object) => void} handler
|
||||||
|
* @returns {() => void} unsubscribe
|
||||||
|
*/
|
||||||
|
onMove(handler) {
|
||||||
|
this._moveHandlers.add(handler)
|
||||||
|
return () => this._moveHandlers.delete(handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -46,9 +66,20 @@ export class GameSession {
|
||||||
if (before === 'fresh' && after !== 'fresh' && this._t0 === null) this._t0 = t
|
if (before === 'fresh' && after !== 'fresh' && this._t0 === null) this._t0 = t
|
||||||
if ((after === 'won' || after === 'lost') && this._tEnd === null) this._tEnd = t
|
if ((after === 'won' || after === 'lost') && this._tEnd === null) this._tEnd = t
|
||||||
|
|
||||||
|
// Emit a typed move-event for effective moves (rules classify; no-ops → null).
|
||||||
|
if (typeof this.rules.toMoveEvent === 'function') {
|
||||||
|
const kind = this.rules.toMoveEvent(move, events)
|
||||||
|
if (kind) this._emitMove({ ...kind, t, seq: ++this._seq })
|
||||||
|
}
|
||||||
|
|
||||||
return { events, view: this.rules.project(state), time: this.elapsed() }
|
return { events, view: this.rules.project(state), time: this.elapsed() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param {object} event */
|
||||||
|
_emitMove(event) {
|
||||||
|
for (const handler of this._moveHandlers) handler(event)
|
||||||
|
}
|
||||||
|
|
||||||
status() {
|
status() {
|
||||||
return this.rules.status(this.state)
|
return this.rules.status(this.state)
|
||||||
}
|
}
|
||||||
|
|
@ -82,7 +113,7 @@ export class GameSession {
|
||||||
* (core-05); the live `clock` is deliberately excluded (it's re-injected on
|
* (core-05); the live `clock` is deliberately excluded (it's re-injected on
|
||||||
* {@link GameSession.deserialize}). Requires the rules to implement `serialize`.
|
* {@link GameSession.deserialize}). Requires the rules to implement `serialize`.
|
||||||
*
|
*
|
||||||
* @returns {{ state: object, log: Array<{ move: object, t: number }>, t0: number | null, tEnd: number | null }}
|
* @returns {{ state: object, log: Array<{ move: object, t: number }>, t0: number | null, tEnd: number | null, seq: number }}
|
||||||
*/
|
*/
|
||||||
serialize() {
|
serialize() {
|
||||||
if (typeof this.rules.serialize !== 'function') {
|
if (typeof this.rules.serialize !== 'function') {
|
||||||
|
|
@ -92,7 +123,8 @@ export class GameSession {
|
||||||
state: this.rules.serialize(this.state),
|
state: this.rules.serialize(this.state),
|
||||||
log: this._log.map(({ move, t }) => ({ move, t })),
|
log: this._log.map(({ move, t }) => ({ move, t })),
|
||||||
t0: this._t0,
|
t0: this._t0,
|
||||||
tEnd: this._tEnd
|
tEnd: this._tEnd,
|
||||||
|
seq: this._seq
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,7 +146,7 @@ export class GameSession {
|
||||||
if (snapshot === null || typeof snapshot !== 'object') {
|
if (snapshot === null || typeof snapshot !== 'object') {
|
||||||
throw new TypeError(`GameSession.deserialize: expected a snapshot object (got ${snapshot === null ? 'null' : typeof snapshot})`)
|
throw new TypeError(`GameSession.deserialize: expected a snapshot object (got ${snapshot === null ? 'null' : typeof snapshot})`)
|
||||||
}
|
}
|
||||||
const { state, log, t0, tEnd } = snapshot
|
const { state, log, t0, tEnd, seq } = snapshot
|
||||||
if (!Array.isArray(log)) {
|
if (!Array.isArray(log)) {
|
||||||
throw new TypeError('GameSession.deserialize: snapshot.log must be an array')
|
throw new TypeError('GameSession.deserialize: snapshot.log must be an array')
|
||||||
}
|
}
|
||||||
|
|
@ -126,11 +158,17 @@ export class GameSession {
|
||||||
if (!(t0 === null || typeof t0 === 'number') || !(tEnd === null || typeof tEnd === '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')
|
throw new TypeError('GameSession.deserialize: snapshot.t0/tEnd must each be a number or null')
|
||||||
}
|
}
|
||||||
|
if (!(seq === undefined || (Number.isInteger(seq) && seq >= 0))) {
|
||||||
|
throw new TypeError('GameSession.deserialize: snapshot.seq must be a non-negative integer')
|
||||||
|
}
|
||||||
// rules.deserialize validates and revives the game-state half.
|
// rules.deserialize validates and revives the game-state half.
|
||||||
const session = new GameSession(rules, { state: rules.deserialize(state), clock })
|
const session = new GameSession(rules, { state: rules.deserialize(state), clock })
|
||||||
session._log = log.map(({ move, t }) => ({ move, t }))
|
session._log = log.map(({ move, t }) => ({ move, t }))
|
||||||
session._t0 = t0
|
session._t0 = t0
|
||||||
session._tEnd = tEnd
|
session._tEnd = tEnd
|
||||||
|
// Continue the sequence where it left off so move-event seq keeps rising
|
||||||
|
// across a resume (absent in a pre-move-event snapshot ⇒ start at 0).
|
||||||
|
session._seq = seq ?? 0
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { dirname, join } from 'node:path'
|
||||||
import {
|
import {
|
||||||
Grid, eightWay, orthogonal,
|
Grid, eightWay, orthogonal,
|
||||||
GameSession, replay, mulberry32,
|
GameSession, replay, mulberry32,
|
||||||
MinesweeperRules, generateBoard, validateLayout, levels
|
MinesweeperRules, generateBoard, validateLayout, MOVE_EVENT_TYPES, levels
|
||||||
} from '../core/index.js'
|
} from '../core/index.js'
|
||||||
import { placeMines, excludeAround } from '../core/minesweeper/board.js'
|
import { placeMines, excludeAround } from '../core/minesweeper/board.js'
|
||||||
|
|
||||||
|
|
@ -558,6 +558,112 @@ describe('resume from serialized state (Layer 1)', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('typed move-events (onMove)', () => {
|
||||||
|
// A 3x3 with a single mine at (0,0); adjacency computed for it. Lets us script
|
||||||
|
// exact coordinates and outcomes for a fully-asserted event stream.
|
||||||
|
const knownLayout = () => ({
|
||||||
|
rows: 3,
|
||||||
|
cols: 3,
|
||||||
|
mines: 1,
|
||||||
|
cells: [
|
||||||
|
[{ mine: true, adjacent: 0 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
|
||||||
|
[{ mine: false, adjacent: 1 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
|
||||||
|
[{ mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }]
|
||||||
|
],
|
||||||
|
mineLocations: [[0, 0]]
|
||||||
|
})
|
||||||
|
|
||||||
|
const injected = clock =>
|
||||||
|
new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(knownLayout()), clock })
|
||||||
|
|
||||||
|
it('exports the move-event vocabulary', () => {
|
||||||
|
expect(MOVE_EVENT_TYPES).toEqual(['reveal', 'flag', 'unflag', 'chord'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits the full typed event stream for a scripted game (all four types + no-op)', () => {
|
||||||
|
const clock = makeClock()
|
||||||
|
const session = injected(clock)
|
||||||
|
const events = []
|
||||||
|
session.onMove(e => events.push(e))
|
||||||
|
|
||||||
|
clock.tick() // 1050
|
||||||
|
session.applyMove({ type: 'reveal', r: 0, c: 1 }) // number cell → reveals itself
|
||||||
|
clock.tick() // 1100
|
||||||
|
session.applyMove({ type: 'flag', r: 0, c: 0 }) // flag the mine
|
||||||
|
clock.tick() // 1150
|
||||||
|
session.applyMove({ type: 'flag', r: 0, c: 0 }) // toggle off → unflag
|
||||||
|
clock.tick() // 1200
|
||||||
|
session.applyMove({ type: 'flag', r: 0, c: 0 }) // flag again
|
||||||
|
clock.tick() // 1250
|
||||||
|
session.applyMove({ type: 'chord', r: 0, c: 1 }) // 1 adjacent flag == value → chord
|
||||||
|
clock.tick() // 1300
|
||||||
|
session.applyMove({ type: 'reveal', r: 0, c: 1 }) // already revealed → no-op, no event
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ type: 'reveal', r: 0, c: 1, t: 1050, seq: 1 },
|
||||||
|
{ type: 'flag', r: 0, c: 0, t: 1100, seq: 2 },
|
||||||
|
{ type: 'unflag', r: 0, c: 0, t: 1150, seq: 3 },
|
||||||
|
{ type: 'flag', r: 0, c: 0, t: 1200, seq: 4 },
|
||||||
|
{ type: 'chord', r: 0, c: 1, t: 1250, seq: 5 }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('timestamps come from the injected clock', () => {
|
||||||
|
const clock = makeClock(5000)
|
||||||
|
const session = injected(clock)
|
||||||
|
let event
|
||||||
|
session.onMove(e => { event = e })
|
||||||
|
clock.tick() // 5050
|
||||||
|
session.applyMove({ type: 'reveal', r: 2, c: 2 })
|
||||||
|
expect(event.t).toBe(5050)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('onMove returns an unsubscribe that stops delivery', () => {
|
||||||
|
const session = injected(makeClock())
|
||||||
|
const events = []
|
||||||
|
const off = session.onMove(e => events.push(e))
|
||||||
|
session.applyMove({ type: 'flag', r: 0, c: 0 })
|
||||||
|
off()
|
||||||
|
session.applyMove({ type: 'flag', r: 0, c: 0 }) // unflag — not delivered
|
||||||
|
expect(events).toHaveLength(1)
|
||||||
|
expect(events[0].type).toBe('flag')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sequence numbers strictly increase across a resume (core-05)', () => {
|
||||||
|
const clock = makeClock()
|
||||||
|
const session = injected(clock)
|
||||||
|
const seqs = []
|
||||||
|
session.onMove(e => seqs.push(e.seq))
|
||||||
|
clock.tick(); session.applyMove({ type: 'reveal', r: 0, c: 1 }) // seq 1
|
||||||
|
clock.tick(); session.applyMove({ type: 'flag', r: 0, c: 0 }) // seq 2
|
||||||
|
|
||||||
|
// Serialize → resume; the counter must continue, not restart.
|
||||||
|
const snap = JSON.parse(JSON.stringify(session.serialize()))
|
||||||
|
expect(snap.seq).toBe(2)
|
||||||
|
const resumed = GameSession.deserialize(MinesweeperRules, snap, { clock: makeClock(clock()) })
|
||||||
|
const resumedSeqs = []
|
||||||
|
resumed.onMove(e => resumedSeqs.push(e.seq))
|
||||||
|
resumed.applyMove({ type: 'flag', r: 0, c: 0 }) // unflag → seq 3, not 1
|
||||||
|
|
||||||
|
expect(resumedSeqs).toEqual([3])
|
||||||
|
// strictly increasing over the whole session
|
||||||
|
const all = [...seqs, ...resumedSeqs]
|
||||||
|
expect(all).toEqual([...all].sort((a, b) => a - b))
|
||||||
|
expect(new Set(all).size).toBe(all.length) // no duplicates
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a losing reveal still emits a reveal move-event', () => {
|
||||||
|
const clock = makeClock()
|
||||||
|
const session = injected(clock)
|
||||||
|
let event
|
||||||
|
session.onMove(e => { event = e })
|
||||||
|
clock.tick()
|
||||||
|
session.applyMove({ type: 'reveal', r: 0, c: 0 }) // steps on the mine
|
||||||
|
expect(session.status()).toBe('lost')
|
||||||
|
expect(event).toEqual({ type: 'reveal', r: 0, c: 0, t: 1050, seq: 1 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
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()
|
||||||
|
|
@ -637,6 +743,21 @@ describe('determinism guard (invariant #4)', () => {
|
||||||
})
|
})
|
||||||
expect(offenders).toEqual([])
|
expect(offenders).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('no DOM/rendering coupling in core/ (headless invariant)', () => {
|
||||||
|
const coreDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'core')
|
||||||
|
const offenders = []
|
||||||
|
walk(coreDir, file => {
|
||||||
|
if (!file.endsWith('.js')) return
|
||||||
|
const code = readFileSync(file, 'utf8')
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||||
|
.replace(/\/\/.*$/gm, '')
|
||||||
|
if (/\b(document|window|navigator|localStorage|requestAnimationFrame)\b/.test(code) || /from ['"][^'"]*(client|renderer)/.test(code)) {
|
||||||
|
offenders.push(file)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(offenders).toEqual([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- helpers ----
|
// ---- helpers ----
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue