123 lines
5.3 KiB
JavaScript
123 lines
5.3 KiB
JavaScript
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)
|
|
})
|
|
})
|