minesweeper/src/main/js/Field.ts

550 lines
19 KiB
TypeScript

// @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;
}
}