From 0823d43c759c3d1b40daa4db0e4f4524ee18095b Mon Sep 17 00:00:00 2001 From: Ayo Date: Sat, 4 Jul 2026 12:02:10 +0200 Subject: [PATCH] feat(core): move events --- packages/mnswpr/core/index.js | 2 +- packages/mnswpr/core/minesweeper/rules.js | 41 ++++++++ packages/mnswpr/core/session/session.js | 46 +++++++- packages/mnswpr/test/core.test.js | 123 +++++++++++++++++++++- 4 files changed, 206 insertions(+), 6 deletions(-) diff --git a/packages/mnswpr/core/index.js b/packages/mnswpr/core/index.js index d99384a..967d306 100644 --- a/packages/mnswpr/core/index.js +++ b/packages/mnswpr/core/index.js @@ -18,7 +18,7 @@ export { replay } from './session/replay.js' export { mulberry32, randInt } from './session/rng.js' // 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' // Shared level presets (also consumed by the DOM client) diff --git a/packages/mnswpr/core/minesweeper/rules.js b/packages/mnswpr/core/minesweeper/rules.js index dbb012b..d473d6c 100644 --- a/packages/mnswpr/core/minesweeper/rules.js +++ b/packages/mnswpr/core/minesweeper/rules.js @@ -17,6 +17,22 @@ import { floodReveal, countFlagsAround, allMines } from './reveal.js' * @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' }) /** Valid game phases and per-cell statuses — the closed sets a snapshot may name. */ @@ -242,6 +258,31 @@ export const MinesweeperRules = { 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 * cell's mine/adjacent/status, via the Layer-0 grid serializer) plus phase and diff --git a/packages/mnswpr/core/session/session.js b/packages/mnswpr/core/session/session.js index 60b289a..265f42e 100644 --- a/packages/mnswpr/core/session/session.js +++ b/packages/mnswpr/core/session/session.js @@ -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, serialize?: Function, deserialize?: Function }} Rules + * @typedef {{ init: Function, apply: Function, status: Function, project: Function, serialize?: Function, deserialize?: Function, toMoveEvent?: Function }} Rules */ export class GameSession { /** @@ -27,6 +27,26 @@ export class GameSession { this._log = [] this._t0 = 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 ((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() } } + /** @param {object} event */ + _emitMove(event) { + for (const handler of this._moveHandlers) handler(event) + } + status() { 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 * {@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() { if (typeof this.rules.serialize !== 'function') { @@ -92,7 +123,8 @@ export class GameSession { state: this.rules.serialize(this.state), log: this._log.map(({ move, t }) => ({ move, t })), 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') { 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)) { 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')) { 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. const session = new GameSession(rules, { state: rules.deserialize(state), clock }) session._log = log.map(({ move, t }) => ({ move, t })) session._t0 = t0 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 } } diff --git a/packages/mnswpr/test/core.test.js b/packages/mnswpr/test/core.test.js index bd088fa..bd9d513 100644 --- a/packages/mnswpr/test/core.test.js +++ b/packages/mnswpr/test/core.test.js @@ -6,7 +6,7 @@ import { dirname, join } from 'node:path' import { Grid, eightWay, orthogonal, GameSession, replay, mulberry32, - MinesweeperRules, generateBoard, validateLayout, levels + MinesweeperRules, generateBoard, validateLayout, MOVE_EVENT_TYPES, levels } from '../core/index.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)', () => { it('reports authoritative elapsed time from the injected clock', () => { const clock = makeClock() @@ -637,6 +743,21 @@ describe('determinism guard (invariant #4)', () => { }) 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 ----