feat(mnswpr): progress reducer adapter
This commit is contained in:
parent
6f9a8031e9
commit
6b38088d4b
2 changed files with 180 additions and 0 deletions
57
packages/mnswpr/adapters/replay-progress.js
Normal file
57
packages/mnswpr/adapters/replay-progress.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
123
packages/mnswpr/test/replay-progress.test.js
Normal file
123
packages/mnswpr/test/replay-progress.test.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue