feat(mnswpr): progress reducer adapter

This commit is contained in:
ayo 2026-07-04 13:14:25 +02:00
parent 6f9a8031e9
commit 6b38088d4b
2 changed files with 180 additions and 0 deletions

View file

@ -0,0 +1,57 @@
// @ts-check
import { MinesweeperRules } from '../core/minesweeper/rules.js'
/**
* @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent
* @typedef {import('../core/minesweeper/board.js').Layout} Layout
*/
/**
* The percent-cleared progress reducer for Minesweeper mnswpr's first concrete
* implementation of the replay engine's `ProgressReducer<MnswprMoveEvent>` seam
* (see `@cozy-games/replay` / replay-02). Given the ordered slice of move-events
* played so far, it returns completion as
* `revealed safe cells / total safe cells * 100`.
*
* Why it needs the board: a single `reveal` or `chord` event floods MANY cells,
* but the recorded move-event only carries `{ type, r, c }` not how many cells
* opened. So the reducer takes the board as closure input (consistent with the
* interface design) and replays the moves through the pure core rules. That makes
* reveals flood, chords reveal via their (non-flagged) neighbors, and
* flags/unflags only gate chords never advancing progress themselves with no
* cell double-counted. The engine stays game-blind; all interpretation is here.
*
* @param {Layout} layout - the recorded board (as produced by `generateBoard`)
* @returns {(events: { event: MnswprMoveEvent }[]) => number} a reducer to `[0, 100]`
*/
export function createProgressReducer(layout) {
const totalSafe = layout.rows * layout.cols - layout.mines
return function progress(events) {
// A board with no safe cells is vacuously fully cleared (and avoids /0).
if (totalSafe === 0) return 100
let state = MinesweeperRules.fromLayout(layout)
for (const record of events) {
const move = toMove(record.event)
if (move) state = MinesweeperRules.apply(state, move).state
}
return (state.revealedSafe / totalSafe) * 100
}
}
/**
* Map a recorded move-event back to the rules move that produced it: `flag` and
* `unflag` are both the toggle move `flag`; `reveal` and `chord` pass through.
* Unknown kinds are ignored.
*
* @param {MnswprMoveEvent} e
* @returns {{ type: 'reveal' | 'flag' | 'chord', r: number, c: number } | null}
*/
function toMove(e) {
switch (e && e.type) {
case 'reveal': return { type: 'reveal', r: e.r, c: e.c }
case 'chord': return { type: 'chord', r: e.r, c: e.c }
case 'flag':
case 'unflag': return { type: 'flag', r: e.r, c: e.c }
default: return null
}
}

View file

@ -0,0 +1,123 @@
import { describe, it, expect } from 'vitest'
import { GameSession, MinesweeperRules, generateBoard, levels } from '../core/index.js'
import { createProgressReducer } from '../adapters/replay-progress.js'
// Drive a session over an injected layout, capturing the real core-06 move-event
// stream (onMove). Returns the emitted events and the session.
function record(layout, moves) {
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(layout) })
const events = []
session.onMove(e => events.push(e))
for (const m of moves) session.applyMove(m)
return { events: events.map(event => ({ seq: event.seq, t: event.t, event })), session }
}
// 3x3, single mine at (0,0); adjacency computed. Total safe = 8.
const smallBoard = () => ({
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]]
})
describe('mnswpr progress reducer (percent-cleared)', () => {
it('progresses a scripted full game from 0% to 100%, monotonically', () => {
// A real generated board; reveal every safe cell in row-major order to win.
const layout = generateBoard(9, 9, 10, { seed: 7, safeCell: { r: 0, c: 0 } })
const progress = createProgressReducer(layout)
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(layout) })
const events = []
session.onMove(e => events.push({ seq: e.seq, t: e.t, event: e }))
for (let r = 0; r < 9; r++) {
for (let c = 0; c < 9; c++) {
if (!session.state.grid.at(r, c).mine) session.applyMove({ type: 'reveal', r, c })
}
}
expect(session.status()).toBe('won')
expect(progress([])).toBe(0) // nothing revealed yet
expect(progress(events)).toBe(100) // full clear
expect(progress(events.slice(0, 1))).toBeGreaterThan(0) // first reveal opens a region
// monotonic non-decreasing across every prefix
let prev = -1
for (let k = 0; k <= events.length; k++) {
const p = progress(events.slice(0, k))
expect(p).toBeGreaterThanOrEqual(prev)
prev = p
}
})
it('counts flooded reveals by cells opened, not by event count', () => {
const layout = smallBoard()
const progress = createProgressReducer(layout)
// Revealing the far corner (2,2) floods every one of the 8 safe cells.
const { events } = record(layout, [{ type: 'reveal', r: 2, c: 2 }])
expect(events).toHaveLength(1) // one event...
expect(progress(events)).toBe(100) // ...but all 8 safe cells cleared
})
it('flag and unflag events leave progress unchanged', () => {
const layout = smallBoard()
const progress = createProgressReducer(layout)
// reveal one numbered cell (1/8), then flag + unflag the mine.
const { events } = record(layout, [
{ type: 'reveal', r: 0, c: 1 }, // reveals just itself (adjacent 1)
{ type: 'flag', r: 0, c: 0 }, // flag the mine
{ type: 'flag', r: 0, c: 0 } // unflag it
])
expect(events.map(e => e.event.type)).toEqual(['reveal', 'flag', 'unflag'])
const afterReveal = progress(events.slice(0, 1))
expect(afterReveal).toBeCloseTo(12.5, 5) // 1 / 8
expect(progress(events.slice(0, 2))).toBe(afterReveal) // + flag: unchanged
expect(progress(events)).toBe(afterReveal) // + unflag: unchanged
})
it('counts the cells a chord reveals', () => {
const layout = smallBoard()
const progress = createProgressReducer(layout)
// reveal (0,1)=1, flag the mine (0,0), then chord (0,1): its 1 flag matches
// its value, so it reveals the rest of the board.
const { events } = record(layout, [
{ type: 'reveal', r: 0, c: 1 },
{ type: 'flag', r: 0, c: 0 },
{ type: 'chord', r: 0, c: 1 }
])
expect(events.map(e => e.event.type)).toEqual(['reveal', 'flag', 'chord'])
expect(progress(events.slice(0, 1))).toBeCloseTo(12.5, 5) // 1/8 after reveal
expect(progress(events.slice(0, 2))).toBeCloseTo(12.5, 5) // flag doesn't advance
expect(progress(events)).toBe(100) // chord clears the rest
})
it('reflects a partial game (lost run stops where it stopped)', () => {
// Reveal a couple of cells then step on the mine — progress reflects only the
// safe cells cleared before the loss.
const layout = smallBoard()
const progress = createProgressReducer(layout)
const { events, session } = record(layout, [
{ type: 'reveal', r: 0, c: 1 },
{ type: 'reveal', r: 1, c: 0 },
{ type: 'reveal', r: 0, c: 0 } // mine → lost
])
expect(session.status()).toBe('lost')
expect(progress(events)).toBeCloseTo(25, 5) // 2 of 8 safe cells cleared
})
it('is usable as the replay engine progress adapter (shape check)', () => {
// The reducer matches ProgressReducer<T>: (events) => number in [0,100].
const { rows, cols, mines } = levels.beginner
const progress = createProgressReducer(generateBoard(rows, cols, mines, { seed: 1 }))
const v = progress([])
expect(typeof v).toBe('number')
expect(v).toBeGreaterThanOrEqual(0)
expect(v).toBeLessThanOrEqual(100)
})
})