From a1742e3a587320f19d9bc74be23370ed007aaf0b Mon Sep 17 00:00:00 2001 From: Ayo Date: Thu, 2 Jul 2026 21:17:29 +0200 Subject: [PATCH] perf: win check now tracks safe cells revealed instead of scanning the whole grid --- lib/mnswpr.js | 17 +++++++++-------- test/minesweeper.test.js | 41 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/lib/mnswpr.js b/lib/mnswpr.js index 276c348..bdea70b 100644 --- a/lib/mnswpr.js +++ b/lib/mnswpr.js @@ -86,6 +86,8 @@ const Minesweeper = function(appId, version, hooks = undefined) { let mines = new Set() // Cells currently highlighted, so removeHighlights only resets these (<=9) instead of the whole grid 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() { const headingElement = document.createElement('h1') @@ -194,6 +196,7 @@ const Minesweeper = function(appId, version, hooks = undefined) { flagsCount = setting.mines mines.clear() highlightedCells = [] + revealedSafeCount = 0 for (let i = 0; i < setting.rows; i++) { let row = grid.insertRow(i) @@ -560,14 +563,8 @@ const Minesweeper = function(appId, version, hooks = undefined) { } function checkLevelCompletion() { - let levelComplete = true - for (let i=0; i= safeCells && grid.getAttribute('game-status') == 'active') { grid.setAttribute('game-status', 'win') revealMines() } @@ -817,6 +814,9 @@ const Minesweeper = function(appId, version, hooks = undefined) { function openCell(cell) { 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' setStatus(cell, 'clicked') firstClick = false @@ -826,6 +826,7 @@ const Minesweeper = function(appId, version, hooks = undefined) { flagsDisplay.innerHTML = '😱' grid.setAttribute('game-status', 'over') } else { + if (!wasOpen) revealedSafeCount++ const mineCount = countMinesAround(cell) if (mineCount==0) { handleEmpty(cell) diff --git a/test/minesweeper.test.js b/test/minesweeper.test.js index a2d7e6c..4220c77 100644 --- a/test/minesweeper.test.js +++ b/test/minesweeper.test.js @@ -29,14 +29,24 @@ function everyCell(grid, fn) { describe('Minesweeper board', () => { beforeEach(() => { 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() }) afterEach(() => { 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 = '
' + const game = new Minesweeper('app', 'dev', hooks) + game.initialize() + return document.getElementById('grid') + } + it('renders the beginner grid (9x9) by default', () => { const grid = mountGame() expect(grid.rows.length).toBe(9) @@ -116,4 +126,33 @@ describe('Minesweeper board', () => { }) 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') + }) })