From 062b9125495e17915e831b0cecef3ca5627f5659 Mon Sep 17 00:00:00 2001 From: Ayo Date: Sat, 4 Jul 2026 08:17:54 +0200 Subject: [PATCH] feat: mnswpr client --- packages/mnswpr/client/renderer.js | 114 ++++ packages/mnswpr/client/transport.js | 48 ++ .../docs/headless-core-and-client-design.md | 13 +- packages/mnswpr/mnswpr.js | 524 +++++------------- packages/mnswpr/test/minesweeper.test.js | 42 +- 5 files changed, 340 insertions(+), 401 deletions(-) create mode 100644 packages/mnswpr/client/renderer.js create mode 100644 packages/mnswpr/client/transport.js diff --git a/packages/mnswpr/client/renderer.js b/packages/mnswpr/client/renderer.js new file mode 100644 index 0000000..83999f5 --- /dev/null +++ b/packages/mnswpr/client/renderer.js @@ -0,0 +1,114 @@ +// @ts-check + +/** + * The renderer — the ONLY place the client touches game DOM. It consumes core + * events + the projected view and reproduces the exact attributes/classes the + * game has always used (`data-status`, `data-value`, class names), so existing + * CSS and the jsdom tests are unaffected. It reads nothing from the core beyond + * the projected view, so board secrecy stays a drop-in later (invariant #3). + */ + +function cellAt(grid, r, c) { + return grid.rows[r].cells[c] +} + +/** Reveal a safe cell — matches the old revealSafeCell() DOM exactly. */ +function renderRevealed(grid, r, c, adjacent) { + const cell = cellAt(grid, r, c) + cell.className = 'clicked' + cell.setAttribute('data-status', 'clicked') + const span = document.createElement('span') + if (adjacent === 0) { + span.innerHTML = ' ' + cell.innerHTML = '' + cell.appendChild(span) + cell.setAttribute('data-status', 'empty') + } else { + span.innerHTML = String(adjacent) + cell.innerHTML = '' + cell.appendChild(span) + cell.setAttribute('data-value', String(adjacent)) + } +} + +/** Toggle a flag — matches the old rightClickCell() DOM. */ +function renderFlag(grid, r, c, flagged) { + const cell = cellAt(grid, r, c) + if (flagged) { + cell.className = 'flag' + cell.setAttribute('data-status', 'flagged') + } else { + cell.className = '' + cell.setAttribute('data-status', 'default') + } +} + +/** @returns {Set} "r,c" keys of every mine in the terminal view */ +function mineSet(view) { + const set = new Set() + for (const cell of view.cells) { + if (cell.mine) set.add(`${cell.r},${cell.c}`) + } + return set +} + +/** + * Apply per-move events to the grid (reveal / flag / the clicked mine on + * explode). Terminal board reveal is handled separately by revealBoard(). + * @param {HTMLTableElement} grid + * @param {object[]} events + */ +export function renderEvents(grid, events) { + for (const ev of events) { + if (ev.type === 'reveal') { + for (const c of ev.cells) renderRevealed(grid, c.r, c.c, c.adjacent) + } else if (ev.type === 'flag') { + renderFlag(grid, ev.r, ev.c, ev.flagged) + } else if (ev.type === 'explode') { + const cell = cellAt(grid, ev.r, ev.c) + cell.className = 'clicked' + cell.setAttribute('data-status', 'clicked') + } + } +} + +/** + * Reveal the whole board at game end, reproducing the old handleWinRevelation / + * handleLostRevelation output. On a win, mines are marked correct; on a loss, + * unflagged mines detonate and wrong flags are marked. + * @param {HTMLTableElement} grid + * @param {{ phase: string, cells: object[] }} view + * @param {{ rows: number, cols: number }} setting + */ +export function revealBoard(grid, view, setting) { + const won = view.phase === 'won' + const mines = mineSet(view) + for (let r = 0; r < setting.rows; r++) { + for (let c = 0; c < setting.cols; c++) { + const cell = cellAt(grid, r, c) + const isMine = mines.has(`${r},${c}`) + const isFlagged = cell.getAttribute('data-status') === 'flagged' + if (won) { + if (isMine) { + cell.innerHTML = ':)' + cell.className = 'correct' + cell.setAttribute('data-status', 'clicked') + cell.setAttribute('title', 'Correct') + } + } else if (isFlagged) { + if (isMine) { + cell.innerHTML = ':)' + cell.className = 'correct' + cell.setAttribute('title', 'Correct') + } else { + cell.innerHTML = 'X' + cell.className = 'wrong' + cell.setAttribute('title', 'Wrong') + } + } else if (isMine) { + cell.className = 'mine' + cell.setAttribute('data-status', 'clicked') + } + } + } +} diff --git a/packages/mnswpr/client/transport.js b/packages/mnswpr/client/transport.js new file mode 100644 index 0000000..29e30ef --- /dev/null +++ b/packages/mnswpr/client/transport.js @@ -0,0 +1,48 @@ +// @ts-check +import { GameSession } from '../core/session/session.js' + +/** + * In-process transport: runs a `GameSession` locally and emits its events. + * + * The Transport interface is intentionally async — `send()` returns a Promise — + * so a future `RemoteTransport` (server-authoritative) is a drop-in with no + * change to the client (server-readiness invariant #1). Rendering is driven by + * `onEvent`, which Local fires synchronously inside `send()` and Remote would + * fire when the server replies; the client never depends on `send()`'s timing. + * + * Only serializable messages cross this boundary (moves in; events + a projected + * view out) — never the live session/state (invariant #2). + */ +export class LocalTransport { + /** + * @param {object} rules - a GameRules implementation (e.g. MinesweeperRules) + * @param {{ seed: number, config: object, clock?: () => number }} opts + */ + constructor(rules, opts) { + this.session = new GameSession(rules, opts) + this._subs = [] + } + + /** @param {(payload: { events: object[], view: object, time: number }) => void} cb */ + onEvent(cb) { + this._subs.push(cb) + return () => { this._subs = this._subs.filter(s => s !== cb) } + } + + /** + * Apply a move and emit the resulting events/view. Returns a Promise for + * interface parity with a remote transport. + * @param {object} move + * @returns {Promise<{ events: object[], view: object, time: number }>} + */ + send(move) { + const payload = this.session.applyMove(move) + for (const cb of this._subs) cb(payload) + return Promise.resolve(payload) + } + + status() { return this.session.status() } + view() { return this.session.view() } + result() { return this.session.result() } + elapsed() { return this.session.elapsed() } +} diff --git a/packages/mnswpr/docs/headless-core-and-client-design.md b/packages/mnswpr/docs/headless-core-and-client-design.md index 2912c5d..7b92763 100644 --- a/packages/mnswpr/docs/headless-core-and-client-design.md +++ b/packages/mnswpr/docs/headless-core-and-client-design.md @@ -45,11 +45,14 @@ packages/mnswpr/ # @ayo-run/mnswpr — ONE published package rules.js # GameRules impl: init/apply/status/project board.js # deterministic board gen + first-click safety reveal.js # flood-fill, chording - client/ # DOM client internals (consume ./core) — added in migration - renderer.js # events → DOM (the ONLY place document is touched) - input-adapter.js # gestures → Move intents - transport.js # LocalTransport (RemoteTransport added later) - timer-view.js + client/ # DOM client internals (consume ./core) + renderer.js # events → DOM (the ONLY place document is touched) — EXTRACTED + transport.js # LocalTransport (RemoteTransport added later) — EXTRACTED + # Decision: the input state machine (mouse/touch/chording/long-press) and the + # timer stay INLINE in mnswpr.js for now — not extracted to input-adapter.js / + # timer-view.js. The input code is intricate and under-tested (no chord/touch/ + # middle-click coverage yet), so extraction is deferred until those + # characterization tests exist or a concrete need arises. See §9. ``` `package.json` `exports` (sub-path is the only surface change consumers see): diff --git a/packages/mnswpr/mnswpr.js b/packages/mnswpr/mnswpr.js index 2706665..60e9591 100644 --- a/packages/mnswpr/mnswpr.js +++ b/packages/mnswpr/mnswpr.js @@ -6,30 +6,39 @@ import './mnswpr.css' import { - LoggerService, StorageService, TimerService } from '@cozy-games/utils' import { levels } from './levels.js' +import { MinesweeperRules } from './core/index.js' +import { LocalTransport } from './client/transport.js' +import { renderEvents, revealBoard } from './client/renderer.js' const TEST_MODE = false // set to true if you want to test the game with visual hints const MOBILE_BUSY_DELAY = 250 const PC_BUSY_DELAY = 500 /** - * Create Minesweeper game board - * @param {String} appId + * Create Minesweeper game board. + * + * This is the DOM CLIENT: it builds the board, handles input, and renders. All + * game state and rules live in the headless core (`./core`); the client drives + * it through a Transport and paints the events it emits (see + * docs/headless-core-and-client-design.md). + * + * @param {String} appId * @param {String} version * @param {{ * levelChanged: (setting: any) => void, * gameDone: (game: any) => void * } | undefined } hooks + * @param {{ seed?: number }} [options] - `seed` pins the (deterministic) board, + * mainly for tests/replay; omit for a fresh random game each time. */ -const Minesweeper = function(appId, version, hooks = undefined) { +const Minesweeper = function(appId, version, hooks = undefined, options = {}) { const _this = this const storageService = new StorageService() const timerService = new TimerService() - const loggerService = new LoggerService() if (!hooks) { hooks = { @@ -38,6 +47,8 @@ const Minesweeper = function(appId, version, hooks = undefined) { } } + const configuredSeed = options.seed + let grid = document.createElement('table') grid.setAttribute('id', 'grid') let flagsDisplay = document.createElement('span') @@ -66,7 +77,6 @@ const Minesweeper = function(appId, version, hooks = undefined) { highlightSurroundingCell, // middle-click down rightClickCell // right-click down ] - let firstClick = true let isBusy = false let clickedCell let cachedSetting = storageService.getFromLocal('setting') @@ -82,12 +92,10 @@ const Minesweeper = function(appId, version, hooks = undefined) { } storageService.saveToLocal('setting', setting) let flagsCount = setting.mines - // Mine positions stored as a Set of numeric keys (row * cols + col) for O(1) lookup - 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 + // The headless game for the current board; recreated on every generateGrid. + let transport this.initialize = function() { const headingElement = document.createElement('h1') @@ -129,8 +137,6 @@ const Minesweeper = function(appId, version, hooks = undefined) { levelsDropdown.add(levelOption, null) }) - - if (TEST_MODE) { const testLevel = document.createElement('span') testLevel.innerText = 'Test Mode' @@ -144,24 +150,20 @@ const Minesweeper = function(appId, version, hooks = undefined) { function initializeToolbar() { const toolbar = document.createElement('div') - const toolbarItems = [] const flagsWrapper = document.createElement('div') flagsWrapper.append(flagsDisplay) flagsWrapper.style.height = '20px' toolbar.append(flagsWrapper) - toolbarItems.push(flagsWrapper) const smileyWrapper = document.createElement('div') smileyWrapper.append(smileyDisplay) // toolbar.append(smileyWrapper); - // toolbarItems.push(smileyWrapper); const timerWrapper = document.createElement('div') timerWrapper.append(timerDisplay) timerWrapper.style.height = '20px' toolbar.append(timerWrapper) - toolbarItems.push(timerWrapper) toolbar.style.cursor = 'pointer' toolbar.style.padding = '10px 35px' @@ -174,7 +176,7 @@ const Minesweeper = function(appId, version, hooks = undefined) { /** * Updates the game level - * @param {String} key + * @param {String} key */ function updateSetting(key) { setting = levels[key] @@ -182,7 +184,6 @@ const Minesweeper = function(appId, version, hooks = undefined) { generateGrid({ initial: true }) } - /** * Generate the Game Board * @param {{ @@ -190,13 +191,10 @@ const Minesweeper = function(appId, version, hooks = undefined) { * }} options - Game Board Options */ function generateGrid(options = { initial: false }) { - firstClick = true grid.innerHTML = '' grid.oncontextmenu = () => false flagsCount = setting.mines - mines.clear() highlightedCells = [] - revealedSafeCount = 0 for (let i = 0; i < setting.rows; i++) { let row = grid.insertRow(i) @@ -210,8 +208,8 @@ const Minesweeper = function(appId, version, hooks = undefined) { initializeTouchEventHandlers(cell) } - let status = document.createAttribute('data-status') - status.value = 'default' + let status = document.createAttribute('data-status') + status.value = 'default' cell.setAttributeNode(status) } } @@ -224,6 +222,12 @@ const Minesweeper = function(appId, version, hooks = undefined) { appElement.style.margin = '0 auto' } + // A fresh headless game. Mines are placed by the core on the first reveal + // (first-click safe), so there is nothing to seed into the DOM here. + const gameSeed = configuredSeed !== undefined ? configuredSeed : Math.floor(Math.random() * 0x7fffffff) + transport = new LocalTransport(MinesweeperRules, { seed: gameSeed, config: setting, clock: () => Date.now() }) + transport.onEvent(onCoreEvent) + /** * TODO: add hook afterGridGenerated * - for initializing the leaderboard @@ -233,8 +237,51 @@ const Minesweeper = function(appId, version, hooks = undefined) { timerService.initialize(timerDisplay) updateFlagsCountDisplay() - addMines(setting.mines) + } + /** + * Paint the events from a core move, and drive the win/loss transition. Fired + * synchronously by the LocalTransport; a RemoteTransport would fire it on the + * server's reply with no change here. + * @param {{ events: object[], view: object }} payload + */ + function onCoreEvent(payload) { + renderEvents(grid, payload.events) + for (const ev of payload.events) { + if (ev.type === 'explode' || ev.type === 'win') { + finishGame(payload.view) + break + } + } + } + + /** Terminal transition: reveal the board, update displays, fire gameDone. */ + function finishGame(view) { + const won = view.phase === 'won' + if (won) { + grid.setAttribute('game-status', 'win') + updateFlagsCountDisplay(0) + } else { + flagsDisplay.innerHTML = '😱' + grid.setAttribute('game-status', 'over') + } + revealBoard(grid, view, setting) + grid.setAttribute('game-status', 'done') + + const time = timerService.stop() + const game = { + time, + status: won ? 'win' : 'loss', + level: setting.id, + time_stamp: new Date(), + isMobile + } + + /** + * TODO: add hook after gameSession send back `game` + * - for sending the game score to the db + */ + hooks.gameDone(game) } function setBusy() { @@ -255,8 +302,8 @@ const Minesweeper = function(appId, version, hooks = undefined) { } /** - * - * @param {HTMLTableCellElement} cell + * + * @param {HTMLTableCellElement} cell */ function initializeTouchEventHandlers(cell) { let ontouchleave = function() { @@ -332,7 +379,7 @@ const Minesweeper = function(appId, version, hooks = undefined) { resetMouseEventFlags() // Set grid status to active on first click - cell.onmouseup = function(e) { + cell.onmouseup = function(e) { pressed = undefined let dont = false @@ -403,11 +450,6 @@ const Minesweeper = function(appId, version, hooks = undefined) { cell.onmousemove = function(e) { if ((pressed || bothPressed) && typeof e === 'object') { removeHighlights() - /* - if (!isEqual(clickedCell, cell)) { - clickedCell = undefined; - } - */ if (pressed == 'middle' || (isLeft && isRight)) { highlightSurroundingCell(this) } else if (pressed == 'left') { @@ -456,187 +498,6 @@ const Minesweeper = function(appId, version, hooks = undefined) { skip = true } - function addMines(minesCount) { - //Add mines randomly - for (let i=0; i= safeCells && grid.getAttribute('game-status') == 'active') { - grid.setAttribute('game-status', 'win') - revealMines() - } - } - - function setStatus(cell, status) { - cell.setAttribute('data-status', status) - } - - function getCol(cell) { - return cell.cellIndex - } - - function getRow(cell) { - return cell.parentNode.rowIndex - } - - function getStatus(cell) { - if (!cell) return undefined - return cell.getAttribute('data-status') - } - - function middleClickCell(cell) { - if (grid.getAttribute('game-status') != 'active' || getStatus(cell) !== 'clicked') { - return - } - // check for number of surrounding flags - const valueString = cell.getAttribute('data-value') - let cellValue = parseInt(valueString, 10) - let flagCount = countFlagsAround(cell) - - if (flagCount === cellValue) { - clickSurrounding(cell) - if (TEST_MODE) loggerService.debug('middle click', cell) - } - } - - function countFlagsAround(cell) { - let flagCount = 0 - let cellRow = cell.parentNode.rowIndex - let cellCol = cell.cellIndex - for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1, setting.rows - 1); i++) { - for(let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1, setting.cols - 1); j++) { - if (isFlagged(grid.rows[i].cells[j])) flagCount++ - } - } - return flagCount - } - - function clickSurrounding(cell) { - if (grid.getAttribute('game-status') != 'active') return - let cellRow = cell.parentNode.rowIndex - let cellCol = cell.cellIndex - for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1, setting.rows - 1); i++) { - for(let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1, setting.cols - 1); j++) { - let currentCell = grid.rows[i].cells[j] - if (getStatus(currentCell) == 'flagged') continue - openCell(currentCell) - } - } - } - - function increaseFlagsCount() { - flagsCount++ - updateFlagsCountDisplay() - } - - function decreaseFlagsCount() { - flagsCount-- - updateFlagsCountDisplay() - } - function activateGame() { grid.setAttribute('game-status', 'active') // start timer @@ -659,7 +520,7 @@ const Minesweeper = function(appId, version, hooks = undefined) { function highlightCell(cell) { if (isFlagged(cell)) return if (!gameIsDone() && getStatus(cell) == 'default') { - setStatus(cell, 'highlighted') // currentCell.classList.add('highlight'); + setStatus(cell, 'highlighted') highlightedCells.push(cell) } } @@ -677,27 +538,60 @@ const Minesweeper = function(appId, version, hooks = undefined) { } } + function increaseFlagsCount() { + flagsCount++ + updateFlagsCountDisplay() + } + + function decreaseFlagsCount() { + flagsCount-- + updateFlagsCountDisplay() + } + + function isFlagged(cell) { + return getStatus(cell) == 'flagged' + } + + function setStatus(cell, status) { + cell.setAttribute('data-status', status) + } + + function getCol(cell) { + return cell.cellIndex + } + + function getRow(cell) { + return cell.parentNode.rowIndex + } + + function getStatus(cell) { + if (!cell) return undefined + return cell.getAttribute('data-status') + } + + // ---- input → core move adapters ---- + function rightClickCell(cell) { if (isFlagged(cell)) setBusy() if (grid.getAttribute('game-status') == 'inactive') { activateGame() } if (grid.getAttribute('game-status') != 'active') return - if (getStatus(cell) != 'clicked' && getStatus(cell) != 'empty') { - if (getStatus(cell) == 'default' || getStatus(cell) == 'highlighted') { - if (flagsCount <= 0) return - cell.className = 'flag' - decreaseFlagsCount() - setStatus(cell, 'flagged') - } else { - cell.className = '' - increaseFlagsCount() - setStatus(cell, 'default') - } - if ('vibrate' in navigator) { - navigator.vibrate(100) - } - if (TEST_MODE) loggerService.debug('right click', cell) + + const status = getStatus(cell) + if (status == 'clicked' || status == 'empty') return + + const move = { type: 'flag', r: getRow(cell), c: getCol(cell) } + if (status == 'default' || status == 'highlighted') { + if (flagsCount <= 0) return + transport.send(move) // renderer paints the flag + decreaseFlagsCount() + } else { + transport.send(move) // toggles the flag off + increaseFlagsCount() + } + if ('vibrate' in navigator) { + navigator.vibrate(100) } } @@ -707,164 +601,26 @@ const Minesweeper = function(appId, version, hooks = undefined) { activateGame() } if (grid.getAttribute('game-status') != 'active') return - //Check if the end-user clicked on a mine - if (TEST_MODE) loggerService.debug('click', cell) - if (getStatus(cell) == 'flagged' || grid.getAttribute('game-status') == 'over') { - return - } else if (getStatus(cell) == 'clicked') { - middleClickCell(cell) - return - } else if (isMine(cell) && firstClick) { - // cell.setAttribute('data-mine', 'false'); - mines.delete(mineKey(getRow(cell), getCol(cell))) - transferMine(cell) - if (TEST_MODE) printMines() - } - - openCell(cell) - } - - function printMines() { - let count = 0 - for (let i = 0; i < setting.rows; i++) { - for (let j = 0; j < setting.cols; j++) { - if (isMine(grid.rows[i].cells[j])) { - loggerService.debug(count++ + ' - mine: [' + i + ',' + j + ']') - } - } - } - } - - function transferMine(cell = undefined) { - let found = false - do { - 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) || transferMineToCell === cell || isNeighbor(cell, transferMineToCell)) { - continue - } else { - mines.add(mineKey(row, col)) - if (TEST_MODE){ - transferMineToCell.innerHTML = 'X' - if (TEST_MODE) loggerService.debug('transferred mine to: ' + row + ', ' + col) - } - // TODO: refactor maybe - // eslint-disable-next-line no-useless-assignment - found = true - return - } - } while(!found) - } - - function isNeighbor(cell, nextCell) { - if (cell === undefined) { - return - } - const rowDifference = Math.abs(getRow(cell) - getRow(nextCell)) - const colDifference = Math.abs(getCol(cell) - getCol(nextCell)) - - return (rowDifference === 1) && (colDifference === 1) - } - - function countMinesAround(cell) { - let mineCount=0 - let cellRow = cell.parentNode.rowIndex - let 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 cell = rows.cells[j] - const mine = isMine(cell) - if (cell && mine) { - mineCount++ - } - } - } - return mineCount - } - - function updateCellValue(cell, value) { - const spanElement = document.createElement('span') - spanElement.innerHTML = value - cell.innerHTML = '' - cell.appendChild(spanElement) - } - - /** - * 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) - } - } - } - } - - function openCell(cell) { - if (grid.getAttribute('game-status') != 'active') return - - firstClick = false - - if (isMine(cell)) { - cell.className = 'clicked' - setStatus(cell, 'clicked') - revealMines() - flagsDisplay.innerHTML = '😱' - grid.setAttribute('game-status', 'over') + if (isFlagged(cell) || grid.getAttribute('game-status') == 'over') { return } - if (revealSafeCell(cell)) handleEmpty(cell) - checkLevelCompletion() + const r = getRow(cell) + const c = getCol(cell) + // An already-open number chords; anything else is a reveal. The core places + // mines on the first reveal (first-click safe), so no transfer is needed. + if (getStatus(cell) == 'clicked') { + transport.send({ type: 'chord', r, c }) + return + } + transport.send({ type: 'reveal', r, c }) + } + + function middleClickCell(cell) { + if (grid.getAttribute('game-status') != 'active' || getStatus(cell) !== 'clicked') { + return + } + transport.send({ type: 'chord', r: getRow(cell), c: getCol(cell) }) } } diff --git a/packages/mnswpr/test/minesweeper.test.js b/packages/mnswpr/test/minesweeper.test.js index 8ec51de..cafc226 100644 --- a/packages/mnswpr/test/minesweeper.test.js +++ b/packages/mnswpr/test/minesweeper.test.js @@ -1,5 +1,20 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import Minesweeper from '../mnswpr.js' +import { MinesweeperRules } from '../core/index.js' + +// Ask the (deterministic) core for a seed whose first-click-safe board places +// the single mine at `targetCol`. Lets the DOM tests below pin a layout honestly +// via the client's `seed` option, instead of hoping a Math.random value lands it. +function findSeedForMine(setting, firstClick, targetCol) { + for (let seed = 1; seed < 100000; seed++) { + const state = MinesweeperRules.init(seed, setting) + MinesweeperRules.apply(state, { type: 'reveal', r: firstClick.r, c: firstClick.c }) + let mineCol = -1 + state.grid.forEach((cell, r, c) => { if (cell.mine) mineCol = c }) + if (mineCol === targetCol) return seed + } + throw new Error('no seed found for target mine column') +} // Build a fresh board mounted on #app and return its grid . function mountGame() { @@ -38,11 +53,12 @@ describe('Minesweeper board', () => { vi.restoreAllMocks() }) - // Mount a game on a custom board injected via the cached 'setting' localStorage key. - function mountCustomGame(setting, hooks) { + // Mount a game on a custom board injected via the cached 'setting' localStorage + // key. `options.seed` pins the (deterministic) mine layout. + function mountCustomGame(setting, hooks, options) { localStorage.setItem('setting', JSON.stringify(setting)) document.body.innerHTML = '
' - const game = new Minesweeper('app', 'dev', hooks) + const game = new Minesweeper('app', 'dev', hooks, options) game.initialize() return document.getElementById('grid') } @@ -143,25 +159,27 @@ describe('Minesweeper board', () => { }) 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' }) + // 1x3 board; pin the single mine to the middle column so neither end cascades. + const setting = { rows: 1, cols: 3, mines: 1, id: 'test', name: 'test' } + const seed = findSeedForMine(setting, { r: 0, c: 0 }, 1) + const grid = mountCustomGame(setting, undefined, { seed }) - // First safe cell: adjacent to the mine, shows "1", no cascade -> not yet won. + // First safe cell (col 0): 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. + // Revealing the remaining safe cell (col 2) completes the 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' }) + // 1x4 board; pin the mine to the last column so cols 0-2 are a safe run. + const setting = { rows: 1, cols: 4, mines: 1, id: 'test', name: 'test' } + const seed = findSeedForMine(setting, { r: 0, c: 0 }, 3) + const grid = mountCustomGame(setting, undefined, { seed }) - // Flag a safe cell sitting between the blank region and the mine. + // Flag a safe cell (col 2) 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 }))