feat: mnswpr client
This commit is contained in:
parent
042d6b26e9
commit
062b912549
5 changed files with 340 additions and 401 deletions
114
packages/mnswpr/client/renderer.js
Normal file
114
packages/mnswpr/client/renderer.js
Normal file
|
|
@ -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<string>} "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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
packages/mnswpr/client/transport.js
Normal file
48
packages/mnswpr/client/transport.js
Normal file
|
|
@ -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() }
|
||||||
|
}
|
||||||
|
|
@ -45,11 +45,14 @@ packages/mnswpr/ # @ayo-run/mnswpr — ONE published package
|
||||||
rules.js # GameRules impl: init/apply/status/project
|
rules.js # GameRules impl: init/apply/status/project
|
||||||
board.js # deterministic board gen + first-click safety
|
board.js # deterministic board gen + first-click safety
|
||||||
reveal.js # flood-fill, chording
|
reveal.js # flood-fill, chording
|
||||||
client/ # DOM client internals (consume ./core) — added in migration
|
client/ # DOM client internals (consume ./core)
|
||||||
renderer.js # events → DOM (the ONLY place document is touched)
|
renderer.js # events → DOM (the ONLY place document is touched) — EXTRACTED
|
||||||
input-adapter.js # gestures → Move intents
|
transport.js # LocalTransport (RemoteTransport added later) — EXTRACTED
|
||||||
transport.js # LocalTransport (RemoteTransport added later)
|
# Decision: the input state machine (mouse/touch/chording/long-press) and the
|
||||||
timer-view.js
|
# 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):
|
`package.json` `exports` (sub-path is the only surface change consumers see):
|
||||||
|
|
|
||||||
|
|
@ -6,30 +6,39 @@
|
||||||
import './mnswpr.css'
|
import './mnswpr.css'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
LoggerService,
|
|
||||||
StorageService,
|
StorageService,
|
||||||
TimerService
|
TimerService
|
||||||
} from '@cozy-games/utils'
|
} from '@cozy-games/utils'
|
||||||
import { levels } from './levels.js'
|
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 TEST_MODE = false // set to true if you want to test the game with visual hints
|
||||||
const MOBILE_BUSY_DELAY = 250
|
const MOBILE_BUSY_DELAY = 250
|
||||||
const PC_BUSY_DELAY = 500
|
const PC_BUSY_DELAY = 500
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create Minesweeper game board
|
* Create Minesweeper game board.
|
||||||
* @param {String} appId
|
*
|
||||||
|
* 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 {String} version
|
||||||
* @param {{
|
* @param {{
|
||||||
* levelChanged: (setting: any) => void,
|
* levelChanged: (setting: any) => void,
|
||||||
* gameDone: (game: any) => void
|
* gameDone: (game: any) => void
|
||||||
* } | undefined } hooks
|
* } | 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 _this = this
|
||||||
const storageService = new StorageService()
|
const storageService = new StorageService()
|
||||||
const timerService = new TimerService()
|
const timerService = new TimerService()
|
||||||
const loggerService = new LoggerService()
|
|
||||||
|
|
||||||
if (!hooks) {
|
if (!hooks) {
|
||||||
hooks = {
|
hooks = {
|
||||||
|
|
@ -38,6 +47,8 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const configuredSeed = options.seed
|
||||||
|
|
||||||
let grid = document.createElement('table')
|
let grid = document.createElement('table')
|
||||||
grid.setAttribute('id', 'grid')
|
grid.setAttribute('id', 'grid')
|
||||||
let flagsDisplay = document.createElement('span')
|
let flagsDisplay = document.createElement('span')
|
||||||
|
|
@ -66,7 +77,6 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
highlightSurroundingCell, // middle-click down
|
highlightSurroundingCell, // middle-click down
|
||||||
rightClickCell // right-click down
|
rightClickCell // right-click down
|
||||||
]
|
]
|
||||||
let firstClick = true
|
|
||||||
let isBusy = false
|
let isBusy = false
|
||||||
let clickedCell
|
let clickedCell
|
||||||
let cachedSetting = storageService.getFromLocal('setting')
|
let cachedSetting = storageService.getFromLocal('setting')
|
||||||
|
|
@ -82,12 +92,10 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
}
|
}
|
||||||
storageService.saveToLocal('setting', setting)
|
storageService.saveToLocal('setting', setting)
|
||||||
let flagsCount = setting.mines
|
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
|
// 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
|
// The headless game for the current board; recreated on every generateGrid.
|
||||||
let revealedSafeCount = 0
|
let transport
|
||||||
|
|
||||||
this.initialize = function() {
|
this.initialize = function() {
|
||||||
const headingElement = document.createElement('h1')
|
const headingElement = document.createElement('h1')
|
||||||
|
|
@ -129,8 +137,6 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
levelsDropdown.add(levelOption, null)
|
levelsDropdown.add(levelOption, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (TEST_MODE) {
|
if (TEST_MODE) {
|
||||||
const testLevel = document.createElement('span')
|
const testLevel = document.createElement('span')
|
||||||
testLevel.innerText = 'Test Mode'
|
testLevel.innerText = 'Test Mode'
|
||||||
|
|
@ -144,24 +150,20 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
|
|
||||||
function initializeToolbar() {
|
function initializeToolbar() {
|
||||||
const toolbar = document.createElement('div')
|
const toolbar = document.createElement('div')
|
||||||
const toolbarItems = []
|
|
||||||
|
|
||||||
const flagsWrapper = document.createElement('div')
|
const flagsWrapper = document.createElement('div')
|
||||||
flagsWrapper.append(flagsDisplay)
|
flagsWrapper.append(flagsDisplay)
|
||||||
flagsWrapper.style.height = '20px'
|
flagsWrapper.style.height = '20px'
|
||||||
toolbar.append(flagsWrapper)
|
toolbar.append(flagsWrapper)
|
||||||
toolbarItems.push(flagsWrapper)
|
|
||||||
|
|
||||||
const smileyWrapper = document.createElement('div')
|
const smileyWrapper = document.createElement('div')
|
||||||
smileyWrapper.append(smileyDisplay)
|
smileyWrapper.append(smileyDisplay)
|
||||||
// toolbar.append(smileyWrapper);
|
// toolbar.append(smileyWrapper);
|
||||||
// toolbarItems.push(smileyWrapper);
|
|
||||||
|
|
||||||
const timerWrapper = document.createElement('div')
|
const timerWrapper = document.createElement('div')
|
||||||
timerWrapper.append(timerDisplay)
|
timerWrapper.append(timerDisplay)
|
||||||
timerWrapper.style.height = '20px'
|
timerWrapper.style.height = '20px'
|
||||||
toolbar.append(timerWrapper)
|
toolbar.append(timerWrapper)
|
||||||
toolbarItems.push(timerWrapper)
|
|
||||||
|
|
||||||
toolbar.style.cursor = 'pointer'
|
toolbar.style.cursor = 'pointer'
|
||||||
toolbar.style.padding = '10px 35px'
|
toolbar.style.padding = '10px 35px'
|
||||||
|
|
@ -174,7 +176,7 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the game level
|
* Updates the game level
|
||||||
* @param {String} key
|
* @param {String} key
|
||||||
*/
|
*/
|
||||||
function updateSetting(key) {
|
function updateSetting(key) {
|
||||||
setting = levels[key]
|
setting = levels[key]
|
||||||
|
|
@ -182,7 +184,6 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
generateGrid({ initial: true })
|
generateGrid({ initial: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate the Game Board
|
* Generate the Game Board
|
||||||
* @param {{
|
* @param {{
|
||||||
|
|
@ -190,13 +191,10 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
* }} options - Game Board Options
|
* }} options - Game Board Options
|
||||||
*/
|
*/
|
||||||
function generateGrid(options = { initial: false }) {
|
function generateGrid(options = { initial: false }) {
|
||||||
firstClick = true
|
|
||||||
grid.innerHTML = ''
|
grid.innerHTML = ''
|
||||||
grid.oncontextmenu = () => false
|
grid.oncontextmenu = () => false
|
||||||
flagsCount = setting.mines
|
flagsCount = setting.mines
|
||||||
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)
|
||||||
|
|
@ -210,8 +208,8 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
initializeTouchEventHandlers(cell)
|
initializeTouchEventHandlers(cell)
|
||||||
}
|
}
|
||||||
|
|
||||||
let status = document.createAttribute('data-status')
|
let status = document.createAttribute('data-status')
|
||||||
status.value = 'default'
|
status.value = 'default'
|
||||||
cell.setAttributeNode(status)
|
cell.setAttributeNode(status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -224,6 +222,12 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
appElement.style.margin = '0 auto'
|
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
|
* TODO: add hook afterGridGenerated
|
||||||
* - for initializing the leaderboard
|
* - for initializing the leaderboard
|
||||||
|
|
@ -233,8 +237,51 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
|
|
||||||
timerService.initialize(timerDisplay)
|
timerService.initialize(timerDisplay)
|
||||||
updateFlagsCountDisplay()
|
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() {
|
function setBusy() {
|
||||||
|
|
@ -255,8 +302,8 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {HTMLTableCellElement} cell
|
* @param {HTMLTableCellElement} cell
|
||||||
*/
|
*/
|
||||||
function initializeTouchEventHandlers(cell) {
|
function initializeTouchEventHandlers(cell) {
|
||||||
let ontouchleave = function() {
|
let ontouchleave = function() {
|
||||||
|
|
@ -332,7 +379,7 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
resetMouseEventFlags()
|
resetMouseEventFlags()
|
||||||
|
|
||||||
// Set grid status to active on first click
|
// Set grid status to active on first click
|
||||||
cell.onmouseup = function(e) {
|
cell.onmouseup = function(e) {
|
||||||
pressed = undefined
|
pressed = undefined
|
||||||
let dont = false
|
let dont = false
|
||||||
|
|
||||||
|
|
@ -403,11 +450,6 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
cell.onmousemove = function(e) {
|
cell.onmousemove = function(e) {
|
||||||
if ((pressed || bothPressed) && typeof e === 'object') {
|
if ((pressed || bothPressed) && typeof e === 'object') {
|
||||||
removeHighlights()
|
removeHighlights()
|
||||||
/*
|
|
||||||
if (!isEqual(clickedCell, cell)) {
|
|
||||||
clickedCell = undefined;
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
if (pressed == 'middle' || (isLeft && isRight)) {
|
if (pressed == 'middle' || (isLeft && isRight)) {
|
||||||
highlightSurroundingCell(this)
|
highlightSurroundingCell(this)
|
||||||
} else if (pressed == 'left') {
|
} else if (pressed == 'left') {
|
||||||
|
|
@ -456,187 +498,6 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
skip = true
|
skip = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function addMines(minesCount) {
|
|
||||||
//Add mines randomly
|
|
||||||
for (let i=0; i<minesCount; i++) {
|
|
||||||
let row = Math.floor(Math.random() * setting.rows)
|
|
||||||
let col = Math.floor(Math.random() * setting.cols)
|
|
||||||
let cell = grid.rows[row].cells[col]
|
|
||||||
if (isMine(cell)) {
|
|
||||||
transferMine()
|
|
||||||
} else {
|
|
||||||
mines.add(mineKey(row, col))
|
|
||||||
}
|
|
||||||
if (TEST_MODE){
|
|
||||||
cell.innerHTML = 'X'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (TEST_MODE) {
|
|
||||||
printMines()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function revealMines() {
|
|
||||||
if (grid.getAttribute('game-status') == 'done') return
|
|
||||||
//Highlight all mines in red
|
|
||||||
const win = grid.getAttribute('game-status') == 'win'
|
|
||||||
for (let i=0; i<setting.rows; i++) {
|
|
||||||
for(let j=0; j<setting.cols; j++) {
|
|
||||||
let cell = grid.rows[i].cells[j]
|
|
||||||
if (win) {
|
|
||||||
handleWinRevelation(cell)
|
|
||||||
} else {
|
|
||||||
handleLostRevelation(cell)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
grid.setAttribute('game-status', 'done')
|
|
||||||
|
|
||||||
const time = timerService.stop()
|
|
||||||
const game = {
|
|
||||||
time,
|
|
||||||
status: win ? '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 handleWinRevelation(cell) {
|
|
||||||
updateFlagsCountDisplay(0)
|
|
||||||
if (isMine(cell)) {
|
|
||||||
cell.innerHTML = ':)'
|
|
||||||
cell.className = 'correct'
|
|
||||||
setStatus(cell, 'clicked')
|
|
||||||
let correct = document.createAttribute('title')
|
|
||||||
correct.value = 'Correct'
|
|
||||||
cell.setAttributeNode(correct)
|
|
||||||
setStatus(cell, 'clicked')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLostRevelation(cell) {
|
|
||||||
if (isFlagged(cell)) {
|
|
||||||
cell.className = 'flag'
|
|
||||||
if (!isMine(cell)) {
|
|
||||||
cell.innerHTML = 'X'
|
|
||||||
cell.className = 'wrong'
|
|
||||||
let wrong = document.createAttribute('title')
|
|
||||||
wrong.value = 'Wrong'
|
|
||||||
cell.setAttributeNode(wrong)
|
|
||||||
} else {
|
|
||||||
cell.innerHTML = ':)'
|
|
||||||
cell.className = 'correct'
|
|
||||||
let correct = document.createAttribute('title')
|
|
||||||
correct.value = 'Correct'
|
|
||||||
cell.setAttributeNode(correct)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (isMine(cell)) {
|
|
||||||
cell.className = 'mine'
|
|
||||||
setStatus(cell, 'clicked')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isOpen(cell) {
|
|
||||||
return cell.innerHTML !== '' && !isFlagged(cell)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isFlagged(cell) {
|
|
||||||
return getStatus(cell) == 'flagged'
|
|
||||||
}
|
|
||||||
|
|
||||||
function mineKey(row, col) {
|
|
||||||
return row * setting.cols + col
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMine(cell) {
|
|
||||||
return mines.has(mineKey(getRow(cell), getCol(cell)))
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkLevelCompletion() {
|
|
||||||
const safeCells = setting.rows * setting.cols - setting.mines
|
|
||||||
if (revealedSafeCount >= 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() {
|
function activateGame() {
|
||||||
grid.setAttribute('game-status', 'active')
|
grid.setAttribute('game-status', 'active')
|
||||||
// start timer
|
// start timer
|
||||||
|
|
@ -659,7 +520,7 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
function highlightCell(cell) {
|
function highlightCell(cell) {
|
||||||
if (isFlagged(cell)) return
|
if (isFlagged(cell)) return
|
||||||
if (!gameIsDone() && getStatus(cell) == 'default') {
|
if (!gameIsDone() && getStatus(cell) == 'default') {
|
||||||
setStatus(cell, 'highlighted') // currentCell.classList.add('highlight');
|
setStatus(cell, 'highlighted')
|
||||||
highlightedCells.push(cell)
|
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) {
|
function rightClickCell(cell) {
|
||||||
if (isFlagged(cell)) setBusy()
|
if (isFlagged(cell)) setBusy()
|
||||||
if (grid.getAttribute('game-status') == 'inactive') {
|
if (grid.getAttribute('game-status') == 'inactive') {
|
||||||
activateGame()
|
activateGame()
|
||||||
}
|
}
|
||||||
if (grid.getAttribute('game-status') != 'active') return
|
if (grid.getAttribute('game-status') != 'active') return
|
||||||
if (getStatus(cell) != 'clicked' && getStatus(cell) != 'empty') {
|
|
||||||
if (getStatus(cell) == 'default' || getStatus(cell) == 'highlighted') {
|
const status = getStatus(cell)
|
||||||
if (flagsCount <= 0) return
|
if (status == 'clicked' || status == 'empty') return
|
||||||
cell.className = 'flag'
|
|
||||||
decreaseFlagsCount()
|
const move = { type: 'flag', r: getRow(cell), c: getCol(cell) }
|
||||||
setStatus(cell, 'flagged')
|
if (status == 'default' || status == 'highlighted') {
|
||||||
} else {
|
if (flagsCount <= 0) return
|
||||||
cell.className = ''
|
transport.send(move) // renderer paints the flag
|
||||||
increaseFlagsCount()
|
decreaseFlagsCount()
|
||||||
setStatus(cell, 'default')
|
} else {
|
||||||
}
|
transport.send(move) // toggles the flag off
|
||||||
if ('vibrate' in navigator) {
|
increaseFlagsCount()
|
||||||
navigator.vibrate(100)
|
}
|
||||||
}
|
if ('vibrate' in navigator) {
|
||||||
if (TEST_MODE) loggerService.debug('right click', cell)
|
navigator.vibrate(100)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -707,164 +601,26 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
activateGame()
|
activateGame()
|
||||||
}
|
}
|
||||||
if (grid.getAttribute('game-status') != 'active') return
|
if (grid.getAttribute('game-status') != 'active') return
|
||||||
//Check if the end-user clicked on a mine
|
if (isFlagged(cell) || grid.getAttribute('game-status') == 'over') {
|
||||||
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')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (revealSafeCell(cell)) handleEmpty(cell)
|
const r = getRow(cell)
|
||||||
checkLevelCompletion()
|
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) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,20 @@
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
import Minesweeper from '../mnswpr.js'
|
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 <table>.
|
// Build a fresh board mounted on #app and return its grid <table>.
|
||||||
function mountGame() {
|
function mountGame() {
|
||||||
|
|
@ -38,11 +53,12 @@ describe('Minesweeper board', () => {
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mount a game on a custom board injected via the cached 'setting' localStorage key.
|
// Mount a game on a custom board injected via the cached 'setting' localStorage
|
||||||
function mountCustomGame(setting, hooks) {
|
// key. `options.seed` pins the (deterministic) mine layout.
|
||||||
|
function mountCustomGame(setting, hooks, options) {
|
||||||
localStorage.setItem('setting', JSON.stringify(setting))
|
localStorage.setItem('setting', JSON.stringify(setting))
|
||||||
document.body.innerHTML = '<div id="app"></div>'
|
document.body.innerHTML = '<div id="app"></div>'
|
||||||
const game = new Minesweeper('app', 'dev', hooks)
|
const game = new Minesweeper('app', 'dev', hooks, options)
|
||||||
game.initialize()
|
game.initialize()
|
||||||
return document.getElementById('grid')
|
return document.getElementById('grid')
|
||||||
}
|
}
|
||||||
|
|
@ -143,25 +159,27 @@ describe('Minesweeper board', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not declare a win until the last safe cell is revealed', () => {
|
it('does not declare a win until the last safe cell is revealed', () => {
|
||||||
// 1x3 board with the single mine pinned to the middle column.
|
// 1x3 board; pin the single mine to the middle column so neither end cascades.
|
||||||
vi.spyOn(Math, 'random').mockReturnValue(0.5)
|
const setting = { rows: 1, cols: 3, mines: 1, id: 'test', name: 'test' }
|
||||||
const grid = mountCustomGame({ 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])
|
leftClick(grid.rows[0].cells[0])
|
||||||
expect(grid.getAttribute('game-status')).toBe('active')
|
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])
|
leftClick(grid.rows[0].cells[2])
|
||||||
expect(grid.getAttribute('game-status')).toBe('done')
|
expect(grid.getAttribute('game-status')).toBe('done')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('flood fill stops at a flagged cell and never reveals it', () => {
|
it('flood fill stops at a flagged cell and never reveals it', () => {
|
||||||
// 1x4 board with the mine pinned to the last column.
|
// 1x4 board; pin the mine to the last column so cols 0-2 are a safe run.
|
||||||
vi.spyOn(Math, 'random').mockReturnValue(0.99)
|
const setting = { rows: 1, cols: 4, mines: 1, id: 'test', name: 'test' }
|
||||||
const grid = mountCustomGame({ 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)
|
// (full press + release so the internal right-button flag resets)
|
||||||
const flag = grid.rows[0].cells[2]
|
const flag = grid.rows[0].cells[2]
|
||||||
flag.dispatchEvent(new MouseEvent('mousedown', { button: 2, bubbles: true }))
|
flag.dispatchEvent(new MouseEvent('mousedown', { button: 2, bubbles: true }))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue