diff --git a/lib/mnswpr.js b/lib/mnswpr.js index bdea70b..49c0539 100644 --- a/lib/mnswpr.js +++ b/lib/mnswpr.js @@ -741,7 +741,7 @@ const Minesweeper = function(appId, version, hooks = undefined) { let row = Math.floor(Math.random() * setting.rows) let col = Math.floor(Math.random() * setting.cols) const transferMineToCell = grid.rows[row].cells[col] - if (isMine(transferMineToCell) || isNeighbor(cell, transferMineToCell)) { + if (isMine(transferMineToCell) || transferMineToCell === cell || isNeighbor(cell, transferMineToCell)) { continue } else { mines.add(mineKey(row, col)) @@ -792,20 +792,58 @@ const Minesweeper = function(appId, version, hooks = undefined) { cell.appendChild(spanElement) } - function handleEmpty(cell) { - updateCellValue(cell, ' ') - let cellRow = cell.parentNode.rowIndex - let cellCol = cell.cellIndex - setStatus(cell, 'empty') - //Reveal all adjacent cells as they do not have a mine - for (let y = Math.max(cellRow-1,0); y <= Math.min(cellRow+1, setting.rows - 1); y++) { - const rows = grid.rows[y] - if (!rows) continue - for(let x = Math.max(cellCol-1,0); x <= Math.min(cellCol+1, setting.cols - 1); x++) { - //Recursive Call - const cell = rows.cells[x] - if (cell && !isOpen(cell)) { - clickCell(cell) + /** + * Reveal a single known-non-mine cell. Returns true when the cell is blank + * (no adjacent mines), which is the signal to keep flooding from it. + * @param {HTMLTableCellElement} cell + */ + function revealSafeCell(cell) { + // 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') + if (!wasOpen) revealedSafeCount++ + + const mineCount = countMinesAround(cell) + if (mineCount == 0) { + updateCellValue(cell, ' ') + setStatus(cell, 'empty') + return true + } + + updateCellValue(cell, mineCount.toString()) + const dataValue = document.createAttribute('data-value') + dataValue.value = mineCount.toString() + cell.setAttributeNode(dataValue) + return false + } + + /** + * Iteratively reveal the connected region of blank cells, starting from a + * cell that has already been revealed as blank. Uses an explicit queue with + * isOpen() as the visited guard instead of recursing back through clickCell, + * so there are no redundant per-cell checks and no deep call stack. + * @param {HTMLTableCellElement} startCell + */ + function handleEmpty(startCell) { + const queue = [startCell] + while (queue.length) { + const cell = queue.shift() + const cellRow = cell.parentNode.rowIndex + const cellCol = cell.cellIndex + for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1, setting.rows - 1); i++) { + const rows = grid.rows[i] + if (!rows) continue + for (let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1, setting.cols - 1); j++) { + const neighbor = rows.cells[j] + if (!neighbor || neighbor === cell) continue + if (isFlagged(neighbor)) { + setBusy() + continue + } + if (isOpen(neighbor)) continue + // blank cells have no adjacent mines, so every neighbor here is safe + if (revealSafeCell(neighbor)) queue.push(neighbor) } } } @@ -814,31 +852,19 @@ 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 if (isMine(cell)) { + cell.className = 'clicked' + setStatus(cell, 'clicked') revealMines() flagsDisplay.innerHTML = '😱' grid.setAttribute('game-status', 'over') - } else { - if (!wasOpen) revealedSafeCount++ - const mineCount = countMinesAround(cell) - if (mineCount==0) { - handleEmpty(cell) - } else { - updateCellValue(cell, mineCount.toString()) - const dataValue = document.createAttribute('data-value') - dataValue.value = mineCount.toString() - cell.setAttributeNode(dataValue) - } - //Count and display the number of adjacent mines - checkLevelCompletion() + return } + + if (revealSafeCell(cell)) handleEmpty(cell) + checkLevelCompletion() } } diff --git a/test/minesweeper.test.js b/test/minesweeper.test.js index 4220c77..fc46a67 100644 --- a/test/minesweeper.test.js +++ b/test/minesweeper.test.js @@ -155,4 +155,25 @@ describe('Minesweeper board', () => { leftClick(grid.rows[0].cells[2]) expect(grid.getAttribute('game-status')).toBe('done') }) + + it('flood fill stops at a flagged cell and never reveals it', () => { + // 1x4 board with the mine pinned to the last column. + vi.spyOn(Math, 'random').mockReturnValue(0.99) + const grid = mountCustomGame({ rows: 1, cols: 4, mines: 1, id: 'test', name: 'test' }) + + // Flag a safe cell sitting between the blank region and the mine. + // (full press + release so the internal right-button flag resets) + const flag = grid.rows[0].cells[2] + flag.dispatchEvent(new MouseEvent('mousedown', { button: 2, bubbles: true })) + flag.dispatchEvent(new MouseEvent('mouseup', { button: 2, bubbles: true })) + expect(flag.getAttribute('data-status')).toBe('flagged') + + // Click the far blank cell; the cascade must stop at the flag. + leftClick(grid.rows[0].cells[0]) + + expect(grid.rows[0].cells[0].getAttribute('data-status')).toBe('empty') + expect(flag.getAttribute('data-status')).toBe('flagged') + // A safe cell is still hidden (behind the flag), so it is not a win. + expect(grid.getAttribute('game-status')).toBe('active') + }) })