// @ts-ignore import alea from "alea"; import {Action, ActionHistory} from "./Action"; import {chunkifyArray, shuffleArrayInPlace} from "./Common"; import {findDifficulty} from "./Difficulty"; import {HighScores} from "./HighScores"; import {Solver} from "./Solver"; import {Statistics} from "./Statistics"; import {Timer} from "./Timer"; // @ts-ignore const {MemoryStorage} = window.fwdekker.storage; /** * A playing field for a game of Minesweeper. */ export class Field { private readonly statistics: Statistics; private readonly highScores: HighScores; private readonly history = new ActionHistory(); private readonly rng: any; private timer = new Timer(); readonly width: number; readonly height: number; readonly mineCount: number; readonly squareList: Square[] = []; readonly squares: Square[][] = []; readonly isSolvable: boolean; private _coveredNonMineCount: number; get coveredNonMineCount(): number { return this._coveredNonMineCount; } private _flagCount: number = 0; get flagCount(): number { return this._flagCount; } private _hasStarted: boolean = false; get hasStarted(): boolean { return this._hasStarted; } private _hasWonBefore: boolean = false; private _hasWon: boolean = false; get hasWon(): boolean { return this._hasWon; } private _hasLost: boolean = false; get hasLost(): boolean { return this._hasLost; } private _deathCount: number = 0; get deathCount(): number { return this._deathCount; } isAutoSolving: boolean = false; /** * Constructs a new playing field for a game of Minesweeper. * * @param width the number of squares per row in the field * @param height the number of rows in the field * @param mineCount the initial number of mines to place in the field * @param solvable whether the field must be solvable * @param seed the seed to generate the field with * @param statistics the statistics tracker, or `null` if statistics should not be tracked * @param highScores the tracker to store new scores in */ constructor(width: number, height: number, mineCount: number, solvable: boolean, seed: number, statistics: Statistics, highScores: HighScores) { if (mineCount > Field.maxMines(width, height)) throw new Error(`Mine count must be at most ${Field.maxMines(width, height)}, but was ${mineCount}.`); this.statistics = statistics; this.highScores = highScores; this.width = width; this.height = height; this.mineCount = mineCount; this.rng = alea("" + seed); this.isSolvable = solvable; this.squareList = Array(this.size).fill(0) .map((_, i) => new Square(this, i % this.width, Math.floor(i / this.width), true)); this.squares = chunkifyArray(this.squareList, this.width); this._coveredNonMineCount = this.size - this.mineCount; this.shuffle(this.rng.uint32()); } /** * Returns a deep copy of this field. * * Note that the copy cannot be guaranteed to be solvable. * * @returns a deep copy of this field */ copy(): Field { const copy = new Field( this.width, this.height, this.mineCount, false, 0, new Statistics(new MemoryStorage()), new HighScores(new MemoryStorage()) ); copy.squareList.length = 0; copy.squareList.push(...this.squareList.map(it => it.copy(copy))); copy.squares.length = 0; copy.squares.push(...chunkifyArray(copy.squareList, copy.width)); copy.timer = this.timer.copy(); copy._coveredNonMineCount = this.coveredNonMineCount; copy._flagCount = this.flagCount; copy._hasStarted = this.hasStarted; copy._hasWon = this.hasWon; copy._hasLost = this.hasLost; copy._deathCount = this.deathCount; return copy; } /** * Returns the square at the given coordinates, or `orElse` if there is no square there. * * @param coords the coordinates of the square to look up * @param orElse the value to return if there is no square at the given coordinates * @returns the square at the given coordinates, or `orElse` if there is no square there */ getSquareOrElse(coords: { x: number, y: number }, orElse: any = null): Square | any { return this.squares[coords.y]?.[coords.x] ?? orElse; } /** * Returns `true` if and only if this field contains a square at the given coordinates. * * @param coords the coordinates to check * @returns `true` if and only if this field contains a square at the given coordinates */ hasSquareAt(coords: { x: number, y: number }): boolean { return coords.x >= 0 && coords.x < this.width && coords.y >= 0 && coords.y < this.height; } /** * The number of squares in this field. */ get size(): number { return this.width * this.height; } /** * Moves mines from the given square and its neighbors to other squares in the field, if possible. * * @param square the square from to move mines away from * @private */ private clearMines(square: Square): void { const swapAction = (source: Square, target: Square) => new Action( () => { source.hasMine = false; target.hasMine = true; }, () => { source.hasMine = true; target.hasMine = false; return true; }, () => true ); this.runUndoably(() => { if (square.hasMine) { const target = this.squareList.find(it => !it.hasMine && it !== square)!; this.addAction(swapAction(square, target)); } square.neighbors .filter(it => it.hasMine) .forEach(it => { const target = this.squareList .find(it => !it.hasMine && it !== square && square.neighbors.indexOf(it) < 0); if (target !== undefined) this.addAction(swapAction(it, target)); }); }); } /** * Shuffles the mines in the field by changing the `#hasMine` property of its squares. * * @param seed the seed to determine the shuffling by * @private */ private shuffle(seed: number): void { if (this.hasStarted) throw new Error("Cannot shuffle mines after field has started"); const mines = Array(this.size).fill(true, 0, this.mineCount).fill(false, this.mineCount); shuffleArrayInPlace(mines, seed); mines.forEach((hasMine, i) => this.squareList[i].hasMine = hasMine); } /** * Chords the square at the given position, i.e. if the square is covered and the number of neighboring flags equals * the number in the square, then all unflagged neighbors are uncovered. * * @param coords the coordinates of the square to chord */ chord(coords: { x: number, y: number }): void { const square = this.squares[coords.y][coords.x]; if (square === undefined) throw new Error(`Cannot chord undefined square at (${coords}).`); if (square.isCovered || this.hasWon || this.hasLost) return; if (square.getNeighborCount(it => it.hasMark) > 0) return; if (square.getNeighborCount(it => it.hasFlag) !== square.getNeighborCount(it => it.hasMine)) return; if (!this.isAutoSolving) this.statistics.squaresChorded++; this.runUndoably(() => { square.neighbors .filter(it => it.isCovered && !it.hasFlag) .forEach(it => this.uncover(it.coords)); }); if (!this.isAutoSolving && this.hasLost) this.statistics.squaresChordedLeadingToLoss++; } /** * Uncovers this square, revealing the contents beneath. * * @param coords the coordinates of the square to uncover */ uncover(coords: { x: number, y: number }): void { const square = this.squares[coords.y][coords.x]; if (square === undefined) throw new Error(`Cannot uncover undefined square at (${coords}).`); if (this.hasWon || this.hasLost) return; this.runUndoably(() => { if (!this.hasStarted) { this.statistics.gamesStarted++; if (this.isSolvable) { let i = 1; const time = Timer.time(() => { while (!Solver.canSolve(this, coords)) { this.shuffle(this.rng.uint32()); i++; } }); console.log(`Found solvable field in ${time}ms in ${i} attempts.`); } this.clearMines(square); this._hasStarted = true; this.timer.start(); // @formatter:off this.addAction(new Action(() => {}, () => false, () => false)); // @formatter:on } const uncoverQueue: Square[] = [square]; while (uncoverQueue.length > 0) { const next = uncoverQueue.pop()!; if (!next.isCovered || next.hasFlag || next.hasMark) continue; let remainingFlags: Square[] | undefined; this.addAction(new Action( () => { next.isCovered = false; if (!this.isAutoSolving) this.statistics.squaresUncovered++; if (next.hasMine) { this.timer.stop(); this._hasLost = true; this._deathCount++; if (!this.isAutoSolving) this.statistics.minesUncovered++; } else { this._coveredNonMineCount--; if (this.coveredNonMineCount === 0) { this.timer.stop(); remainingFlags = this.squareList.filter(it => it.isCovered && !it.hasFlag); remainingFlags.forEach(it => it.hasFlag = true); this._flagCount = this.mineCount; this._hasWon = true; if (!this._hasWonBefore) { this._hasWonBefore = true; if (!this.isAutoSolving) { this.statistics.gamesWon++; if (this.deathCount === 0) this.statistics.gamesWonWithoutLosing++; const difficulty = findDifficulty(this.width, this.height, this.mineCount); const score = {time: this.elapsedTime, deaths: this.deathCount}; this.highScores.addScore(difficulty, score); } } } } }, () => { next.isCovered = true; if (next.hasMine) { this._hasLost = false; this.timer.start(); } else { this._coveredNonMineCount++; this._hasWon = false; if (remainingFlags !== undefined) { remainingFlags.forEach(it => it.hasFlag = false); this._flagCount = this.mineCount - remainingFlags.length; } this.timer.start(); } return true; }, () => true )); if (next.hasMine) break; if (next.getNeighborCount(it => it.hasMine) === 0) uncoverQueue.push(...next.neighbors.filter(it => it.isCovered)); } }); } /** * Toggles the flag at the given square. * * @param coords the coordinates of the square to toggle the flag at */ toggleFlag(coords: { x: number, y: number }): void { const square = this.squares[coords.y][coords.x]; if (square === undefined) throw new Error(`Cannot toggle flag of undefined square at (${coords}).`); if (!square.isCovered || square.hasMark || this.hasWon || this.hasLost) return; this.addAction(new Action( () => { square.hasFlag = !square.hasFlag; if (!this.isAutoSolving && square.hasFlag) this.statistics.squaresFlagged++; this._flagCount += (square.hasFlag ? 1 : -1); }, () => { square.hasFlag = !square.hasFlag; this._flagCount += (square.hasFlag ? 1 : -1); return true; }, () => true )); } /** * Toggles the question mark at the given square. * * @param coords the coordinates of the square to toggle the question mark at */ toggleMark(coords: { x: number, y: number }): void { const square = this.squares[coords.y][coords.x]; if (square === undefined) throw new Error(`Cannot toggle flag of undefined square at (${coords}).`); if (!square.isCovered || square.hasFlag || this.hasWon || this.hasLost) return; this.addAction(new Action( () => { square.hasMark = !square.hasMark; if (!this.isAutoSolving && square.hasMark) this.statistics.squaresMarked++; }, () => { square.hasMark = !square.hasMark; if (!this.isAutoSolving && square.hasMark) this.statistics.squaresMarked++; return true; }, () => true )); } /** * Runs the given callback such that all calls to `#addAction` can be undone with a single invocation of `#undo`. * * Calling this function again inside the callback adds all actions inside the inner callback does not create a new * undoable unit of actions. * * @param callback a function such that all its calls to `#addAction` should be undoable with a single invocation of * `#undo` */ runUndoably(callback: () => void): void { this.history.startSequence(); callback(); this.history.commitSequence(); } /** * Stores the given action such that it can be undone. * * If this method is not called in `#runUndoably`, the given action will be added to its own undoable unit. * * @param action the action that can be undone * @private */ private addAction(action: Action): void { if (this.history.hasUncommittedSequence) this.history.addAction(action); else this.runUndoably(() => this.history.addAction(action)); action.run(); } /** * Redoes the last `amount` actions in the field's history, if possible. * * Redoing an action removes all subsequent actions from its history. * * @param amount the maximum amount of actions to redo, or all future actions if `undefined` */ redo(amount: number | undefined = undefined): void { const wasLost = this.hasLost; const redone = this.history.redo(amount); if (!this.isAutoSolving && redone > 0) this.statistics.actionsRedone++; if (!this.isAutoSolving && !wasLost && this.hasLost) this.statistics.lossesRedone++; } /** * Undoes the last `amount` actions in the field's history, if any. * * @param amount the maximum amount of actions to undo, or all past actions if `undefined` */ undo(amount: number | undefined = undefined): void { const wasLost = this.hasLost; const undone = this.history.undo(amount); if (!this.isAutoSolving && undone > 0) this.statistics.actionsUndone++; if (!this.isAutoSolving && wasLost && !this.hasLost) this.statistics.lossesUndone++; } /** * The time the player has played on this field so far. */ get elapsedTime(): number { return this.timer.elapsedTime; } /** * Returns the maximum number of mines that can be placed in a `width` x `height` field. * * @param width the width of the field * @param height the height of the field */ static maxMines(width: number, height: number): number { return width * height - 1; } } /** * A square in a Minesweeper `Field`. */ export class Square { private readonly field: Field; private _neighbors: Square[] | undefined = undefined; readonly x: number; readonly y: number; isCovered: boolean; hasMine: boolean; hasFlag: boolean; hasMark: boolean; /** * Constructs a new square. * * @param field the field in which this square is located * @param x the horizontal coordinate of this square in the field * @param y the vertical coordinate of this square in the field * @param hasMine `true` if and only if this square contains a mine */ constructor(field: Field, x: number, y: number, hasMine: boolean) { this.field = field; this.x = x; this.y = y; this.isCovered = true; this.hasMine = hasMine; this.hasFlag = false; this.hasMark = false; } /** * Returns a deep copy of this square. * * @param field the field in which this square is present * @returns a deep copy of this square */ copy(field: Field): Square { const copy = new Square(field, this.x, this.y, this.hasMine); copy.isCovered = this.isCovered; copy.hasFlag = this.hasFlag; copy.hasMark = this.hasMark; return copy; } /** * The coordinates of this square in the field. */ get coords(): { x: number, y: number } { return {x: this.x, y: this.y}; } /** * The `Square`s that are adjacent to this square. */ get neighbors(): Square[] { if (this._neighbors === undefined) { this._neighbors = [ this.field.getSquareOrElse({x: this.x - 1, y: this.y - 1}), this.field.getSquareOrElse({x: this.x, y: this.y - 1}), this.field.getSquareOrElse({x: this.x + 1, y: this.y - 1}), this.field.getSquareOrElse({x: this.x - 1, y: this.y}), this.field.getSquareOrElse({x: this.x + 1, y: this.y}), this.field.getSquareOrElse({x: this.x - 1, y: this.y + 1}), this.field.getSquareOrElse({x: this.x, y: this.y + 1}), this.field.getSquareOrElse({x: this.x + 1, y: this.y + 1}), ].filter(it => it !== null); } return this._neighbors!; } /** * Returns the number of neighbors that satisfy the given property. * * @param property the property to check on each neighbor * @returns the number of neighbors that satisfy the given property */ getNeighborCount(property: (neighbor: Square) => boolean): number { return this.neighbors.filter(property).length; } }