feat(core): move events

This commit is contained in:
ayo 2026-07-04 12:02:10 +02:00
parent d3d84aa9b8
commit 0823d43c75
4 changed files with 206 additions and 6 deletions

View file

@ -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)

View file

@ -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

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, 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
} }
} }

View file

@ -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 ----