cozy-games/packages/mnswpr/test/core.test.js

559 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect } from 'vitest'
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import {
Grid, eightWay, orthogonal,
GameSession, replay, mulberry32,
MinesweeperRules, generateBoard, validateLayout, levels
} from '../core/index.js'
import { placeMines, excludeAround } from '../core/minesweeper/board.js'
const beginner = levels.beginner
// Reveal every non-mine cell of a session's board, reading the (already-placed)
// state — a deterministic way to drive a game to a win without hardcoding the
// layout. Returns the events for assertions.
function playToWin(session, clock) {
// First reveal seeds the board (first-click safe); pick a corner.
session.applyMove({ type: 'reveal', r: 0, c: 0 })
const { grid, config } = session.state
for (let r = 0; r < config.rows; r++) {
for (let c = 0; c < config.cols; c++) {
if (!grid.at(r, c).mine && grid.at(r, c).status !== 'revealed') {
if (clock) clock.tick()
session.applyMove({ type: 'reveal', r, c })
}
}
}
}
describe('grid + neighbors (Layer 0)', () => {
it('addresses cells and reports bounds', () => {
const g = new Grid(3, 4, (r, c) => `${r},${c}`)
expect(g.rows).toBe(3)
expect(g.cols).toBe(4)
expect(g.at(2, 3)).toBe('2,3')
expect(g.inBounds(2, 3)).toBe(true)
expect(g.inBounds(3, 0)).toBe(false)
expect(g.inBounds(-1, 0)).toBe(false)
})
it('eightWay yields 8 neighbors interior, 3 in a corner', () => {
const g = new Grid(5, 5)
expect(eightWay(g, 2, 2)).toHaveLength(8)
expect(eightWay(g, 0, 0)).toHaveLength(3)
expect(orthogonal(g, 2, 2)).toHaveLength(4)
expect(orthogonal(g, 0, 0)).toHaveLength(2)
})
})
describe('rng (Layer 1)', () => {
it('is deterministic for a given seed', () => {
const a = mulberry32(12345)
const b = mulberry32(12345)
const seqA = [a(), a(), a()]
const seqB = [b(), b(), b()]
expect(seqA).toEqual(seqB)
expect(mulberry32(1)()).not.toBe(mulberry32(2)())
})
})
describe('board generation (Layer 2)', () => {
it('places exactly `mines` mines, deterministically from the seed', () => {
const mk = () => {
const g = new Grid(beginner.rows, beginner.cols, () => ({ mine: false, adjacent: 0, status: 'hidden' }))
const placed = placeMines(42, beginner, excludeAround(beginner, 0, 0), g)
return { g, placed }
}
const one = mk()
const two = mk()
expect(one.placed.size).toBe(beginner.mines)
expect([...one.placed].sort()).toEqual([...two.placed].sort())
})
it('keeps the first-click 3x3 neighborhood mine-free', () => {
const g = new Grid(beginner.rows, beginner.cols, () => ({ mine: false, adjacent: 0, status: 'hidden' }))
placeMines(7, beginner, excludeAround(beginner, 4, 4), g)
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
expect(g.at(4 + dr, 4 + dc).mine).toBe(false)
}
}
})
it('computes adjacency as the 8-way mine count', () => {
const g = new Grid(beginner.rows, beginner.cols, () => ({ mine: false, adjacent: 0, status: 'hidden' }))
placeMines(99, beginner, excludeAround(beginner, 0, 0), g)
g.forEach((cell, r, c) => {
if (cell.mine) return
let n = 0
for (const [nr, nc] of eightWay(g, r, c)) if (g.at(nr, nc).mine) n++
expect(cell.adjacent).toBe(n)
})
})
})
describe('generateBoard (Layer 2, pure)', () => {
it('is callable in plain Node and returns a plain layout object', () => {
// Non-square (rows ≠ cols) pins the rows-first orientation.
const layout = generateBoard(4, 7, 5, { seed: 42 })
expect(layout.rows).toBe(4)
expect(layout.cols).toBe(7)
expect(layout.mines).toBe(5)
expect(layout.cells).toHaveLength(4)
expect(layout.cells.every(row => row.length === 7)).toBe(true)
// plain data only — no Grid instance, no class methods leaking out
expect(layout.cells[0][0]).toEqual({ mine: expect.any(Boolean), adjacent: expect.any(Number) })
expect(layout).toEqual(JSON.parse(JSON.stringify(layout)))
// places exactly `mines` mines
expect(layout.mineLocations).toHaveLength(5)
let mineCount = 0
for (const row of layout.cells) for (const cell of row) if (cell.mine) mineCount++
expect(mineCount).toBe(5)
})
it('same injected RNG (seed) → identical layout', () => {
const a = generateBoard(16, 16, 40, { rng: mulberry32(123) })
const b = generateBoard(16, 16, 40, { rng: mulberry32(123) })
expect(a).toEqual(b)
// and the seed convenience wrapper matches an explicitly injected mulberry32
expect(generateBoard(16, 16, 40, { seed: 123 })).toEqual(a)
// a different seed diverges
expect(generateBoard(16, 16, 40, { seed: 124 })).not.toEqual(a)
})
it('computes adjacency as the 8-way mine count', () => {
const layout = generateBoard(9, 9, 10, { seed: 99 })
const g = new Grid(9, 9, (r, c) => layout.cells[r][c])
layout.cells.forEach((row, r) => row.forEach((cell, c) => {
if (cell.mine) return
let n = 0
for (const [nr, nc] of eightWay(g, r, c)) if (g.at(nr, nc).mine) n++
expect(cell.adjacent).toBe(n)
}))
})
it('honors an exclude set (e.g. first-click safety)', () => {
const exclude = new Set([0]) // key 0 == cell (0,0)
const layout = generateBoard(9, 9, 10, { seed: 7, exclude })
expect(layout.cells[0][0].mine).toBe(false)
})
it('rejects impossible dimensions and mine counts', () => {
expect(() => generateBoard(0, 9, 1)).toThrow(RangeError)
expect(() => generateBoard(3, 3, 10)).toThrow(RangeError) // more mines than cells
expect(() => generateBoard(3, 3, -1)).toThrow(RangeError)
})
})
describe('generateBoard first-move-safe (safeCell)', () => {
it('never mines the safe cell across randomized runs (property-style)', () => {
// Dense board so the safe cell is a demanding constraint, swept over many
// injected-RNG streams — the guarantee must hold for every seed, not one.
for (let seed = 0; seed < 200; seed++) {
const layout = generateBoard(9, 9, 70, { rng: mulberry32(seed), safeCell: { r: 4, c: 5 } })
expect(layout.cells[4][5].mine).toBe(false)
// and it's a genuine constraint on top of a correct board: exact mine count
expect(layout.mineLocations).toHaveLength(70)
}
})
it('holds at edge coordinates — every corner and a border cell', () => {
const corners = [{ r: 0, c: 0 }, { r: 0, c: 8 }, { r: 8, c: 0 }, { r: 8, c: 8 }]
const borders = [{ r: 0, c: 4 }, { r: 4, c: 0 }, { r: 8, c: 4 }, { r: 4, c: 8 }]
for (const safeCell of [...corners, ...borders]) {
const layout = generateBoard(9, 9, 70, { seed: 123, safeCell })
expect(layout.cells[safeCell.r][safeCell.c].mine).toBe(false)
}
})
it('generates a max-density board (mines = cells 1) with the one safe cell blank', () => {
// 3x3 with 8 mines: every cell except the safe one must be a mine.
const layout = generateBoard(3, 3, 8, { seed: 5, safeCell: { r: 1, c: 1 } })
expect(layout.mineLocations).toHaveLength(8)
expect(layout.cells[1][1].mine).toBe(false)
let mines = 0
for (const row of layout.cells) for (const cell of row) if (cell.mine) mines++
expect(mines).toBe(8)
})
it('merges with an existing exclude set rather than replacing it', () => {
const exclude = new Set([0]) // key 0 == cell (0,0)
const layout = generateBoard(5, 5, 23, { seed: 9, exclude, safeCell: { r: 4, c: 4 } })
expect(layout.cells[0][0].mine).toBe(false) // from exclude
expect(layout.cells[4][4].mine).toBe(false) // from safeCell
expect(layout.mineLocations).toHaveLength(23) // 25 2 excluded
})
it('rejects configurations where the mines cannot fit with the safe cell excluded', () => {
// 3x3 = 9 cells, one reserved for safety ⇒ capacity 8; 9 mines can't fit.
expect(() => generateBoard(3, 3, 9, { safeCell: { r: 0, c: 0 } })).toThrow(RangeError)
})
it('rejects an out-of-bounds or non-integer safe cell', () => {
expect(() => generateBoard(9, 9, 10, { safeCell: { r: 9, c: 0 } })).toThrow(RangeError)
expect(() => generateBoard(9, 9, 10, { safeCell: { r: -1, c: 0 } })).toThrow(RangeError)
expect(() => generateBoard(9, 9, 10, { safeCell: { r: 0, c: 1.5 } })).toThrow(RangeError)
})
})
describe('rules (Layer 2)', () => {
it('first reveal is never a mine and opens a region', () => {
const s = MinesweeperRules.init(3, beginner)
const { state, events } = MinesweeperRules.apply(s, { type: 'reveal', r: 0, c: 0 })
expect(state.phase).toBe('active')
expect(state.grid.at(0, 0).mine).toBe(false)
expect(events[0].type).toBe('reveal')
// first click is inside a mine-free 3x3, so it's blank and floods
expect(events[0].cells.length).toBeGreaterThan(1)
})
it('revealing a mine loses and emits explode with all mines', () => {
const s = MinesweeperRules.init(5, beginner)
MinesweeperRules.apply(s, { type: 'reveal', r: 0, c: 0 })
const mine = firstMine(s.grid)
const { state, events } = MinesweeperRules.apply(s, { type: 'reveal', r: mine.r, c: mine.c })
expect(state.phase).toBe('lost')
expect(events[0].type).toBe('explode')
expect(events[0].mines.length).toBe(beginner.mines)
})
it('toggles flags and blocks revealing a flagged cell', () => {
let s = MinesweeperRules.init(1, beginner)
s = MinesweeperRules.apply(s, { type: 'reveal', r: 0, c: 0 }).state
const hidden = firstHidden(s.grid)
let r = MinesweeperRules.apply(s, { type: 'flag', r: hidden.r, c: hidden.c })
expect(r.events[0]).toMatchObject({ type: 'flag', flagged: true })
// revealing a flagged cell is a no-op
r = MinesweeperRules.apply(r.state, { type: 'reveal', r: hidden.r, c: hidden.c })
expect(r.events).toHaveLength(0)
expect(r.state.grid.at(hidden.r, hidden.c).status).toBe('flagged')
})
it('project never leaks an unrevealed mine mid-game, but reveals mines when over', () => {
const s = MinesweeperRules.init(8, beginner)
const active = MinesweeperRules.apply(s, { type: 'reveal', r: 0, c: 0 }).state
const view = MinesweeperRules.project(active)
for (const cell of view.cells) {
if (cell.status !== 'revealed') expect(cell.mine).not.toBe(true)
}
// now lose and re-project
const mine = firstMine(active.grid)
const lost = MinesweeperRules.apply(active, { type: 'reveal', r: mine.r, c: mine.c }).state
const overView = MinesweeperRules.project(lost)
expect(overView.cells.some(c => c.status === 'hidden' && c.mine === true)).toBe(true)
})
})
describe('board injection (fromLayout, Layer 2)', () => {
// A hand-built 3x3 with a single mine at (0,0). Adjacency: the mine's three
// neighbors (0,1),(1,0),(1,1) are 1; everything else is 0.
const knownLayout = () => ({
rows: 3,
cols: 3,
mines: 1,
cells: [
[{ mine: true, adjacent: 0 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
[{ mine: false, adjacent: 1 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
[{ mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }]
],
mineLocations: [[0, 0]]
})
it('creates a game instance from an injected layout and scripts moves to known states', () => {
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(knownLayout()) })
expect(session.status()).toBe('fresh')
// Reveal a numbered (non-zero) cell: it activates the game and reveals just
// itself, carrying the layout's adjacency — proof the injected board is live.
const first = session.applyMove({ type: 'reveal', r: 0, c: 1 })
expect(session.status()).toBe('active')
expect(first.events[0].cells).toEqual([{ r: 0, c: 1, adjacent: 1 }])
// Stepping on the injected mine loses and reports it (before any flood wins).
const boom = session.applyMove({ type: 'reveal', r: 0, c: 0 })
expect(session.status()).toBe('lost')
expect(boom.events[0]).toMatchObject({ type: 'explode', r: 0, c: 0 })
expect(boom.events[0].mines).toEqual([{ r: 0, c: 0 }])
})
it('a zero cell floods the connected safe region on an injected board', () => {
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(knownLayout()) })
// (2,2) is a zero far from the mine; flood opens all 8 safe cells → win.
const flood = session.applyMove({ type: 'reveal', r: 2, c: 2 })
expect(flood.events[0].cells.length).toBe(8)
expect(session.status()).toBe('won')
})
it('an injected board can be played to a win, every safe cell revealed', () => {
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(knownLayout()) })
// Reveal all 8 non-mine cells; avoid (0,0).
for (let r = 0; r < 3; r++) {
for (let c = 0; c < 3; c++) {
if (r === 0 && c === 0) continue
session.applyMove({ type: 'reveal', r, c })
}
}
expect(session.status()).toBe('won')
})
it('plays identically to a generated board: injecting a generated layout reproduces its play', () => {
// Generate a board, then inject that exact layout. Revealing any cell must
// report the layout's own adjacency, and mines must sit where the layout says.
const layout = generateBoard(9, 9, 10, { seed: 42, safeCell: { r: 4, c: 4 } })
const state = MinesweeperRules.fromLayout(layout)
const { events } = MinesweeperRules.apply(state, { type: 'reveal', r: 4, c: 4 })
expect(state.phase).toBe('active')
// (4,4) was forced safe; its revealed adjacency matches the layout.
const seen = events[0].cells.find(cell => cell.r === 4 && cell.c === 4)
expect(seen.adjacent).toBe(layout.cells[4][4].adjacent)
// The mines are exactly the layout's mines.
const mines = []
state.grid.forEach((cell, r, c) => { if (cell.mine) mines.push([r, c]) })
expect(mines.sort()).toEqual([...layout.mineLocations].sort())
})
it('two sessions from the same layout with the same script transition identically', () => {
const script = [{ type: 'reveal', r: 2, c: 2 }, { type: 'flag', r: 0, c: 0 }, { type: 'reveal', r: 0, c: 1 }]
const run = () => {
const s = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(knownLayout()) })
return script.map(m => s.applyMove(m).view)
}
expect(run()).toEqual(run())
})
it('rejects malformed layouts with a clear error', () => {
expect(() => MinesweeperRules.fromLayout(null)).toThrow(TypeError)
expect(() => MinesweeperRules.fromLayout('nope')).toThrow(TypeError)
// dimensions don't match the cells grid
expect(() => MinesweeperRules.fromLayout({ ...knownLayout(), rows: 4 })).toThrow(RangeError)
// mine count disagrees with the actual mined cells
expect(() => MinesweeperRules.fromLayout({ ...knownLayout(), mines: 2 })).toThrow(RangeError)
// a cell missing its shape
const badCells = knownLayout()
badCells.cells[1][1] = { mine: false } // no `adjacent`
expect(() => MinesweeperRules.fromLayout(badCells)).toThrow(TypeError)
// mineLocations pointing at a non-mined cell
const badLocs = knownLayout()
badLocs.mineLocations = [[2, 2]]
expect(() => MinesweeperRules.fromLayout(badLocs)).toThrow(RangeError)
})
it('validateLayout accepts a freshly generated board', () => {
expect(() => validateLayout(generateBoard(16, 16, 40, { seed: 3 }))).not.toThrow()
})
it('leaves existing seed-based construction unaffected', () => {
// No `state` supplied ⇒ the session still generates from seed/config as before.
const session = new GameSession(MinesweeperRules, { seed: 3, config: beginner })
const { events } = session.applyMove({ type: 'reveal', r: 0, c: 0 })
expect(session.status()).toBe('active')
expect(session.state.grid.at(0, 0).mine).toBe(false) // first-click safety intact
expect(events[0].cells.length).toBeGreaterThan(1)
})
})
describe('state serialization (serialize / deserialize)', () => {
// Drive a fresh session to a genuine MID-GAME state: opening flood + a flag on
// a mine (which stays flagged and never needs revealing to win). Returns the
// session, its clock, and the flagged mine's coordinates.
function midGame(seed = 21) {
const clock = makeClock()
const session = new GameSession(MinesweeperRules, { seed, config: beginner, clock })
clock.tick()
session.applyMove({ type: 'reveal', r: 0, c: 0 }) // opening flood → active
const mine = firstMine(session.state.grid)
clock.tick()
session.applyMove({ type: 'flag', r: mine.r, c: mine.c })
return { session, clock, mine }
}
it('round-trips a mid-game snapshot through JSON without loss', () => {
const { session } = midGame()
expect(session.status()).toBe('active') // genuinely mid-game, not fresh/finished
const snap = session.serialize()
const roundTripped = JSON.parse(JSON.stringify(snap))
expect(roundTripped).toEqual(snap) // JSON-safe: stringify → parse is lossless
// deserialize → re-serialize reproduces the exact snapshot
const revived = GameSession.deserialize(MinesweeperRules, roundTripped, { clock: makeClock() })
expect(revived.serialize()).toEqual(snap)
})
it('snapshot covers layout, revealed + flagged cells, and session/clock state', () => {
const { session, mine } = midGame()
const snap = session.serialize()
// board layout
expect(snap.state.grid.rows).toBe(beginner.rows)
expect(snap.state.grid.cols).toBe(beginner.cols)
expect(snap.state.grid.cells).toHaveLength(beginner.rows * beginner.cols)
expect(snap.state.grid.cells[0]).toEqual({ mine: expect.any(Boolean), adjacent: expect.any(Number), status: expect.any(String) })
// per-cell state: the flood left revealed cells, and our flagged mine is flagged
expect(snap.state.grid.cells.some(c => c.status === 'revealed')).toBe(true)
expect(snap.state.grid.cells[mine.r * beginner.cols + mine.c].status).toBe('flagged')
// session / clock state: timer started (t0 set), still running (tEnd null), log intact
expect(snap.t0).not.toBeNull()
expect(snap.tEnd).toBeNull()
expect(snap.log).toHaveLength(2)
expect(snap.log[0]).toEqual({ move: { type: 'reveal', r: 0, c: 0 }, t: expect.any(Number) })
})
it('a deserialized session resumes and plays identically to the original', () => {
const playToWin = (session, clock) => {
const { grid, config } = session.state
for (let r = 0; r < config.rows; r++) {
for (let c = 0; c < config.cols; c++) {
if (!grid.at(r, c).mine && grid.at(r, c).status !== 'revealed') {
clock.tick()
session.applyMove({ type: 'reveal', r, c })
}
}
}
}
const { session, clock } = midGame(33)
const snap = session.serialize()
// Revive on a clock continuing from the same instant, so timing stays aligned.
const reviveClock = makeClock(clock())
const revived = GameSession.deserialize(MinesweeperRules, JSON.parse(JSON.stringify(snap)), { clock: reviveClock })
playToWin(session, clock)
playToWin(revived, reviveClock)
expect(revived.status()).toBe(session.status())
expect(revived.status()).toBe('won')
expect(revived.view()).toEqual(session.view())
expect(revived.elapsed()).toBe(session.elapsed())
expect(revived.result()).toEqual(session.result())
})
it('rules.serialize / deserialize round-trips a state and revives a real Grid', () => {
const state = MinesweeperRules.init(7, beginner)
MinesweeperRules.apply(state, { type: 'reveal', r: 0, c: 0 })
const snap = MinesweeperRules.serialize(state)
const revived = MinesweeperRules.deserialize(JSON.parse(JSON.stringify(snap)))
expect(revived.grid).toBeInstanceOf(Grid) // not a plain object — a live Grid
expect(revived.grid.at(0, 0)).toEqual(state.grid.at(0, 0))
expect(MinesweeperRules.serialize(revived)).toEqual(snap)
})
it('serialize throws clearly when the rules lack a serializer', () => {
const bareRules = { init: () => ({}), apply: s => ({ state: s, events: [] }), status: () => 'fresh', project: s => s }
const session = new GameSession(bareRules, { state: {} })
expect(() => session.serialize()).toThrow(TypeError)
})
})
describe('session + timing (Layer 1)', () => {
it('reports authoritative elapsed time from the injected clock', () => {
const clock = makeClock()
const session = new GameSession(MinesweeperRules, { seed: 21, config: beginner, clock })
expect(session.elapsed()).toBe(0) // no move yet
playToWin(session, clock)
expect(session.status()).toBe('won')
// time is (terminal tick first tick), owned by the clock, not the client
expect(session.elapsed()).toBeGreaterThan(0)
const result = session.result()
expect(result.status).toBe('won')
expect(result.seed).toBe(21)
})
})
describe('replay verification (Layer 1)', () => {
it('accepts a genuine winning log and recomputes the same time', () => {
const clock = makeClock()
const session = new GameSession(MinesweeperRules, { seed: 33, config: beginner, clock })
playToWin(session, clock)
const submission = session.result()
const verdict = replay(MinesweeperRules, submission)
expect(verdict.valid).toBe(true)
expect(verdict.status).toBe('won')
expect(verdict.time).toBe(submission.time)
})
it('rejects a truncated (non-terminal) log', () => {
const clock = makeClock()
const session = new GameSession(MinesweeperRules, { seed: 33, config: beginner, clock })
playToWin(session, clock)
const submission = session.result()
const truncated = { ...submission, log: submission.log.slice(0, 2) }
const verdict = replay(MinesweeperRules, truncated)
expect(verdict.valid).toBe(false)
expect(verdict.reason).toMatch(/terminal/)
})
it('rejects non-monotonic timestamps', () => {
const clock = makeClock()
const session = new GameSession(MinesweeperRules, { seed: 33, config: beginner, clock })
playToWin(session, clock)
const submission = session.result()
const tampered = { ...submission, log: submission.log.map((e, i) => ({ ...e, t: i === 1 ? -5 : e.t })) }
const verdict = replay(MinesweeperRules, tampered)
expect(verdict.valid).toBe(false)
expect(verdict.reason).toMatch(/monotonic/)
})
it('is deterministic: same seed + same log ⇒ same board', () => {
const a = MinesweeperRules.init(777, beginner)
const b = MinesweeperRules.init(777, beginner)
MinesweeperRules.apply(a, { type: 'reveal', r: 2, c: 2 })
MinesweeperRules.apply(b, { type: 'reveal', r: 2, c: 2 })
const minesA = []
const minesB = []
a.grid.forEach((cell, r, c) => { if (cell.mine) minesA.push(r * beginner.cols + c) })
b.grid.forEach((cell, r, c) => { if (cell.mine) minesB.push(r * beginner.cols + c) })
expect(minesA).toEqual(minesB)
})
})
describe('determinism guard (invariant #4)', () => {
it('no Date/Math.random in core/ outside the rng seam', () => {
const coreDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'core')
const offenders = []
walk(coreDir, file => {
if (!file.endsWith('.js')) return
// Scan code only — strip comments so prose that *names* these APIs
// (e.g. "uses Math.imul, not Math.random") isn't a false positive.
const code = readFileSync(file, 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/.*$/gm, '')
if (/\bMath\.random\b/.test(code) || /\bDate\.now\b/.test(code) || /\bnew Date\b/.test(code)) {
offenders.push(file)
}
})
expect(offenders).toEqual([])
})
})
// ---- helpers ----
function makeClock(start = 1000) {
let t = start
return Object.assign(() => t, { tick() { t += 50 } })
}
function firstMine(grid) {
let found = null
grid.forEach((cell, r, c) => { if (!found && cell.mine) found = { r, c } })
return found
}
function firstHidden(grid) {
let found = null
grid.forEach((cell, r, c) => { if (!found && cell.status === 'hidden' && !cell.mine) found = { r, c } })
return found
}
function walk(dir, fn) {
for (const name of readdirSync(dir)) {
const p = join(dir, name)
if (statSync(p).isDirectory()) walk(p, fn)
else fn(p)
}
}