perf: win check now tracks safe cells revealed instead of scanning the whole grid

This commit is contained in:
ayo 2026-07-02 21:17:29 +02:00
parent a2d8d975ec
commit a1742e3a58
2 changed files with 49 additions and 9 deletions

View file

@ -86,6 +86,8 @@ const Minesweeper = function(appId, version, hooks = undefined) {
let mines = new Set() let mines = new Set()
// Cells currently highlighted, so removeHighlights only resets these (<=9) instead of the whole grid // Cells currently highlighted, so removeHighlights only resets these (<=9) instead of the whole grid
let highlightedCells = [] let highlightedCells = []
// Count of safe (non-mine) cells revealed, so the win check is O(1) instead of scanning the whole grid
let revealedSafeCount = 0
this.initialize = function() { this.initialize = function() {
const headingElement = document.createElement('h1') const headingElement = document.createElement('h1')
@ -194,6 +196,7 @@ const Minesweeper = function(appId, version, hooks = undefined) {
flagsCount = setting.mines flagsCount = setting.mines
mines.clear() mines.clear()
highlightedCells = [] highlightedCells = []
revealedSafeCount = 0
for (let i = 0; i < setting.rows; i++) { for (let i = 0; i < setting.rows; i++) {
let row = grid.insertRow(i) let row = grid.insertRow(i)
@ -560,14 +563,8 @@ const Minesweeper = function(appId, version, hooks = undefined) {
} }
function checkLevelCompletion() { function checkLevelCompletion() {
let levelComplete = true const safeCells = setting.rows * setting.cols - setting.mines
for (let i=0; i<setting.rows; i++) { if (revealedSafeCount >= safeCells && grid.getAttribute('game-status') == 'active') {
for(let j=0; j<setting.cols; j++) {
const cell = grid.rows[i].cells[j]
if (!isMine(cell) && cell.innerHTML=='') levelComplete=false
}
}
if (levelComplete && grid.getAttribute('game-status') == 'active') {
grid.setAttribute('game-status', 'win') grid.setAttribute('game-status', 'win')
revealMines() revealMines()
} }
@ -817,6 +814,9 @@ const Minesweeper = function(appId, version, hooks = undefined) {
function openCell(cell) { function openCell(cell) {
if (grid.getAttribute('game-status') != 'active') return if (grid.getAttribute('game-status') != 'active') return
// A cell can be re-opened via chording, so only count the first reveal
const wasOpen = getStatus(cell) == 'clicked' || getStatus(cell) == 'empty'
cell.className='clicked' cell.className='clicked'
setStatus(cell, 'clicked') setStatus(cell, 'clicked')
firstClick = false firstClick = false
@ -826,6 +826,7 @@ const Minesweeper = function(appId, version, hooks = undefined) {
flagsDisplay.innerHTML = '&#128561;' flagsDisplay.innerHTML = '&#128561;'
grid.setAttribute('game-status', 'over') grid.setAttribute('game-status', 'over')
} else { } else {
if (!wasOpen) revealedSafeCount++
const mineCount = countMinesAround(cell) const mineCount = countMinesAround(cell)
if (mineCount==0) { if (mineCount==0) {
handleEmpty(cell) handleEmpty(cell)

View file

@ -29,14 +29,24 @@ function everyCell(grid, fn) {
describe('Minesweeper board', () => { describe('Minesweeper board', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear() localStorage.clear()
// Fake timers stop the 1ms game clock interval from running during tests. // Fake timers stop the requestAnimationFrame game clock from running during tests.
vi.useFakeTimers() vi.useFakeTimers()
}) })
afterEach(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers()
vi.restoreAllMocks()
}) })
// Mount a game on a custom board injected via the cached 'setting' localStorage key.
function mountCustomGame(setting, hooks) {
localStorage.setItem('setting', JSON.stringify(setting))
document.body.innerHTML = '<div id="app"></div>'
const game = new Minesweeper('app', 'dev', hooks)
game.initialize()
return document.getElementById('grid')
}
it('renders the beginner grid (9x9) by default', () => { it('renders the beginner grid (9x9) by default', () => {
const grid = mountGame() const grid = mountGame()
expect(grid.rows.length).toBe(9) expect(grid.rows.length).toBe(9)
@ -116,4 +126,33 @@ describe('Minesweeper board', () => {
}) })
expect(highlighted).toBe(1) expect(highlighted).toBe(1)
}) })
it('declares a win once every safe cell is revealed', () => {
// A mine-free 3x3 board: one click cascades to reveal all 9 safe cells.
let finished = null
const grid = mountCustomGame(
{ rows: 3, cols: 3, mines: 0, id: 'test', name: 'test' },
{ levelChanged: () => {}, gameDone: (g) => { finished = g.status } }
)
leftClick(grid.rows[1].cells[1])
expect(finished).toBe('win')
expect(grid.getAttribute('game-status')).toBe('done')
everyCell(grid, cell => expect(cell.getAttribute('data-status')).not.toBe('default'))
})
it('does not declare a win until the last safe cell is revealed', () => {
// 1x3 board with the single mine pinned to the middle column.
vi.spyOn(Math, 'random').mockReturnValue(0.5)
const grid = mountCustomGame({ rows: 1, cols: 3, mines: 1, id: 'test', name: 'test' })
// First safe cell: adjacent to the mine, shows "1", no cascade -> not yet won.
leftClick(grid.rows[0].cells[0])
expect(grid.getAttribute('game-status')).toBe('active')
// Revealing the remaining safe cell completes the board.
leftClick(grid.rows[0].cells[2])
expect(grid.getAttribute('game-status')).toBe('done')
})
}) })