refactor: flood-fill logic

This commit is contained in:
ayo 2026-07-02 21:54:09 +02:00
parent a1742e3a58
commit 76e95d20da
2 changed files with 80 additions and 33 deletions

View file

@ -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) {
/**
* 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, ' ')
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]
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 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)
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,32 +852,20 @@ 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 = '&#128561;'
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)
return
}
//Count and display the number of adjacent mines
if (revealSafeCell(cell)) handleEmpty(cell)
checkLevelCompletion()
}
}
}
export default Minesweeper

View file

@ -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')
})
})