minesweeper/src/main/js/Field.ts

639 lines
22 KiB
TypeScript

const {MemoryStorage} = (window as any).fwdekker.storage; // eslint-disable-line
import {Action, ActionHistory} from "./Action";
import {chunkifyArray, req} from "./Common";
import {findDifficulty} from "./Difficulty";
import {HighScores} from "./HighScores";
import {Random} from "./Random";
import {Solver} from "./Solver";
import {Statistics} from "./Statistics";
import {Timer} from "./Timer";
/**
* 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 random: Random;
private timer: Timer = new Timer();
readonly width: number;
readonly height: number;
readonly mineCount: number;
readonly squareList: Square[] = [];
readonly squares: Square[][] = [];
readonly isSolvable: boolean;
get maxMines(): number {
return Field.maxMines(this.width, this.height);
}
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;
}
get isOver(): boolean {
return this.hasWon || this.hasLost;
}
private _deathCount: number = 0;
get deathCount(): number {
return this._deathCount;
}
isAutoSolving: boolean = false;
private _changeListeners: (() => void)[] = [];
/**
* 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) {
this.statistics = statistics;
this.highScores = highScores;
this.width = width;
this.height = height;
this.mineCount = mineCount;
this.random = new Random(seed);
this.isSolvable = solvable;
req(mineCount <= this.maxMines, () => `Mine count must be at most ${this.maxMines}, but was ${mineCount}.`);
this.squareList = Array(this.size).fill(0)
.map((_, i) => new Square(this, {x: i % this.width, y: Math.floor(i / this.width)}, true));
this.squares = chunkifyArray(this.squareList, this.width);
this._coveredNonMineCount = this.size - this.mineCount;
this.shuffle();
}
/**
* 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.random = this.random.copy();
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;
}
/**
* Shuffles all mines in the field by changing the `#hasMine` property of its squares.
*
* @private
*/
private shuffle(): void {
req(!this.hasStarted, () => "Cannot shuffle mines after field has started");
const newMineMask = Array<boolean>(this.size).fill(true, 0, this.mineCount).fill(false, this.mineCount);
this.random.shuffleInPlace(newMineMask);
newMineMask.forEach((hasMine, i) => this.squareList[i].hasMine = hasMine);
}
/**
* Moves mines from the given coords and its neighbors to other coordinates in the field, if possible.
*
* @param coords the coordinate to move mines away from
* @private
*/
private removeMinesAround(coords: Coords): void {
const swapAction = (source: Square, target: Square) => new Action(
() => {
[source.hasMine, target.hasMine] = [target.hasMine, source.hasMine];
},
() => {
[source.hasMine, target.hasMine] = [target.hasMine, source.hasMine];
return true;
},
() => true
);
this.runUndoably(() => {
const square = this.getSquare(coords);
const from = [square].concat(square.neighbors).filter(it => it.hasMine);
const to = this.squareList.filter(it => !it.hasMine && !from.includes(it)).slice(0, from.length);
from.slice(0, to.length).forEach(it => this.runAction(swapAction(it, this.random.pop(to)!)));
});
}
/**
* Generates new contents for this field, starting at `start`.
*
* @param start the initial square from which the field should be generated
* @private
*/
private generate(start: Coords): void {
req(!this.hasStarted, () => "Cannot generate new field after field has started.");
this.statistics.gamesStarted++;
if (this.isSolvable) {
let attempts = 1;
const time = Timer.time(() => {
while (!Solver.canSolve(this, start)) {
this.shuffle();
attempts++;
}
});
console.log(`Found solvable field in ${time}ms in ${attempts} attempt(s).`);
}
this.removeMinesAround(start);
this._hasStarted = true;
this.timer.start();
this.runAction(Action.barrier);
}
/**
* Returns the square at the given coordinates, and throws an error if no such square exists.
*
* @param coords the coordinates of the square to look up
* @returns the square at the given coordinates
*/
getSquare(coords: Coords): Square {
req(coords.x >= 0 && coords.x < this.width, () => `Expected 0 <= x < ${this.width}, actual ${coords.x}.`);
req(coords.y >= 0 && coords.y < this.height, () => `Expected 0 <= y < ${this.height}, actual ${coords.y}.`);
return this.squares[coords.y][coords.x];
}
/**
* Returns the square at the given coordinates, `orElse` if there is no square there, or `undefined` if there is no
* square there and `orElse` is not given.
*
* @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, `orElse` if there is no square there, or `undefined` if there is no
* square there and `orElse` is not given
*/
getSquareOrElse<T = void>(coords: Coords, orElse?: T): Square | T {
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: Coords): 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;
}
/**
* Chords the square at the given position, i.e. if the square is uncovered 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: Coords): void {
const square = this.getSquare(coords);
if (square.isCovered || this.isOver) 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 => {
if (it.hasMark) this.toggleMark(it.coords);
this.uncover(it.coords);
});
});
this.invokeEventListeners();
if (!this.isAutoSolving && this.hasLost) this.statistics.squaresChordedLeadingToLoss++;
}
/**
* Anti-chords the square at the given position, i.e. if the square is uncovered and the number of covered
* neighbours equals the number of neighbouring mines, then all covered neighbours are flagged.
*
* @param coords the coordinates of the square to anti-chord
*/
antiChord(coords: Coords): void {
const square = this.getSquare(coords);
if (square.isCovered || this.isOver) return;
if (square.getNeighborCount(it => it.isCovered) !== square.getNeighborCount(it => it.hasMine)) return;
this.runUndoably(() => {
square.neighbors
.filter(it => it.isCovered && !it.hasFlag)
.forEach(it => {
if (it.hasMark) this.toggleMark(it.coords);
this.toggleFlag(it.coords)
});
});
this.invokeEventListeners();
}
/**
* Uncovers this square, revealing the contents beneath.
*
* @param coords the coordinates of the square to uncover
*/
uncover(coords: Coords): void {
const square = this.getSquare(coords);
if (this.isOver) return;
this.runUndoably(() => {
if (!this.hasStarted) this.generate(coords);
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.runAction(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));
}
});
this.invokeEventListeners();
}
/**
* Toggles the flag at the given square.
*
* @param coords the coordinates of the square to toggle the flag at
*/
toggleFlag(coords: Coords): void {
const square = this.getSquare(coords);
if (!square.isCovered || square.hasMark || this.isOver) return;
this.runAction(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
));
this.invokeEventListeners();
}
/**
* Toggles the question mark at the given square.
*
* @param coords the coordinates of the square to toggle the question mark at
*/
toggleMark(coords: Coords): void {
const square = this.getSquare(coords);
if (!square.isCovered || square.hasFlag || this.isOver) return;
this.runAction(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
));
this.invokeEventListeners();
}
/**
* Runs the given callback such that all calls to `#runAction` inside the callback are stored as a single
* `ActionSequence`.
*
* This function is re-entrant. That is, calling this function inside the callback will create only a single
* sequence of actions.
*
* @param callback a function such that all its calls to `#runAction` should be undoable with a single invocation of
* `#undo`
*/
runUndoably(callback: () => void): void {
this.history.startSequence();
callback();
if (this.history.commitSequence())
this.invokeEventListeners();
}
/**
* Runs `#runUndoably` asynchronously.
*/
async runUndoablyAsync(callback: () => Promise<void>): Promise<void> {
this.history.startSequence();
await callback();
if (this.history.commitSequence())
this.invokeEventListeners();
}
/**
* Runs and 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 runAction(action: Action): void {
this.runUndoably(() => this.history.addAction(action));
action.run();
}
/**
* Returns `true` if and only if there is an action that can be undone.
*
* @returns `true` if and only if there is an action that can be undone
*/
canUndo(): boolean {
return this.history.canUndo();
}
/**
* 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++;
this.invokeEventListeners();
}
/**
* Returns `true` if and only if there is an action that can be redone.
*
* @returns `true` if and only if there is an action that can be redone
*/
canRedo(): boolean {
return this.history.canRedo();
}
/**
* 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++;
this.invokeEventListeners();
}
/**
* The time the player has played on this field so far.
*/
get elapsedTime(): number {
return this.timer.elapsedTime;
}
/**
* Set up `changeListener` to be invoked each time the state of this field changes.
*
* @param changeListener
*/
addEventListener(changeListener: () => void): void {
this._changeListeners.push(changeListener);
}
/**
* Invokes each registered event listener.
*
* @private
*/
private invokeEventListeners(): void {
this._changeListeners.forEach(it => it());
}
/**
* 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 coords: Coords;
isCovered: boolean;
hasMine: boolean;
hasFlag: boolean;
hasMark: boolean;
/**
* Constructs a new square.
*
* @param field the field in which this square is located
* @param coords the coordinates of this square in the field
* @param hasMine `true` if and only if this square contains a mine
*/
constructor(field: Field, coords: Coords, hasMine: boolean) {
this.field = field;
this.coords = coords;
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.coords, this.hasMine);
copy.isCovered = this.isCovered;
copy.hasFlag = this.hasFlag;
copy.hasMark = this.hasMark;
return copy;
}
/**
* The `Square`s that are adjacent to this square.
*/
get neighbors(): Square[] {
if (this._neighbors === undefined) {
this._neighbors = [
this.field.getSquareOrElse({x: this.coords.x - 1, y: this.coords.y - 1}, null),
this.field.getSquareOrElse({x: this.coords.x, y: this.coords.y - 1}, null),
this.field.getSquareOrElse({x: this.coords.x + 1, y: this.coords.y - 1}, null),
this.field.getSquareOrElse({x: this.coords.x - 1, y: this.coords.y}, null),
this.field.getSquareOrElse({x: this.coords.x + 1, y: this.coords.y}, null),
this.field.getSquareOrElse({x: this.coords.x - 1, y: this.coords.y + 1}, null),
this.field.getSquareOrElse({x: this.coords.x, y: this.coords.y + 1}, null),
this.field.getSquareOrElse({x: this.coords.x + 1, y: this.coords.y + 1}, null),
].filter((it): it is Square => 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: (_: Square) => boolean): number {
return this.neighbors.filter(property).length;
}
}
/**
* A pair of coordinates, typically those of a `Square`.
*/
export interface Coords {
readonly x: number;
readonly y: number;
}