403 lines
15 KiB
TypeScript
403 lines
15 KiB
TypeScript
// @ts-ignore
|
|
import {array} from "vectorious";
|
|
|
|
import {range} from "./Common";
|
|
import {Field, Square} from "./Field";
|
|
|
|
|
|
/**
|
|
* A solver for a game of Minesweeper.
|
|
*/
|
|
export class Solver {
|
|
/**
|
|
* Solves the given field as far as the algorithm is able to.
|
|
*
|
|
* The `#startSequence` function on the field must NOT be called before invoking this function.
|
|
*
|
|
* @param field the field to solve
|
|
*/
|
|
static solve(field: Field): void {
|
|
if (field.isOver) return;
|
|
if (field.hasStarted && !this.step(field.copy())) return;
|
|
|
|
if (!field.hasStarted) {
|
|
field.isAutoSolving = true;
|
|
field.runUndoably(() => {
|
|
const target = {x: Math.floor(field.width / 2), y: Math.floor(field.height / 2)};
|
|
const targetSquare = field.getSquareOrElse(target)!;
|
|
if (targetSquare.hasFlag) field.toggleFlag(target);
|
|
if (targetSquare.hasMark) field.toggleMark(target);
|
|
field.uncover(target);
|
|
});
|
|
field.isAutoSolving = false;
|
|
}
|
|
|
|
field.isAutoSolving = true;
|
|
field.runUndoably(() => {
|
|
field.squareList.filter(it => it.hasFlag).forEach(it => field.toggleFlag(it.coords));
|
|
field.squareList.filter(it => it.hasMark).forEach(it => field.toggleMark(it.coords));
|
|
|
|
while (this.step(field)) {
|
|
// Repeat until `step` returns false
|
|
}
|
|
});
|
|
field.isAutoSolving = false;
|
|
}
|
|
|
|
/**
|
|
* Returns `true` if and only if this solver can solve the given field.
|
|
*
|
|
* This function does not change anything in the given field; solvability is checked on a copy of the field.
|
|
*
|
|
* If the given field has not started and no initial square is given, the solver will start solving at an arbitrary
|
|
* position.
|
|
*
|
|
* @param field the field to check for solvability
|
|
* @param initialSquare the initial coordinates to click at
|
|
*/
|
|
static canSolve(field: Field, initialSquare: { x: number, y: number } | undefined = undefined): boolean {
|
|
const copy = field.copy();
|
|
if (initialSquare !== undefined) copy.runUndoably(() => copy.uncover(initialSquare));
|
|
this.solve(copy);
|
|
return copy.hasWon;
|
|
}
|
|
|
|
/**
|
|
* Returns a suggestion for a next move based on the current state of the field.
|
|
*
|
|
* @param field the field to suggest a move for
|
|
* @returns a suggestion for a next move based on the current state of the field
|
|
*/
|
|
static getHint(field: Field): Square | null {
|
|
if (!field.hasStarted || field.isOver) return null;
|
|
const knowns = Solver.getKnowns(field);
|
|
|
|
const candidate = knowns.find(square =>
|
|
// Can chord
|
|
square.getNeighborCount(it => it.hasFlag) === square.getNeighborCount(it => it.hasMine) ||
|
|
// Can flag
|
|
square.getNeighborCount(it => it.isCovered && !it.hasFlag) ===
|
|
(square.getNeighborCount(it => it.hasMine) - square.getNeighborCount(it => it.hasFlag))
|
|
);
|
|
if (candidate !== undefined) return candidate;
|
|
|
|
for (let i = 0; i < knowns.length; i++) {
|
|
const square = knowns[i];
|
|
const solution = this.matrixSolve(field, square.neighbors.filter(it => !it.isCovered).concat(square), true);
|
|
const candidate = solution.find(it => it !== undefined);
|
|
if (candidate !== undefined)
|
|
return candidate[1];
|
|
}
|
|
|
|
const solution = this.matrixSolve(field, knowns, false);
|
|
const candidate2 = solution.find(it => it !== undefined);
|
|
if (candidate2 !== undefined)
|
|
return candidate2[1];
|
|
|
|
return null;
|
|
}
|
|
|
|
|
|
/**
|
|
* Solves one step of the field.
|
|
*
|
|
* @param field the field to solve one step in
|
|
* @returns `true` if a step could be solved
|
|
* @private
|
|
*/
|
|
private static step(field: Field): boolean {
|
|
let flagCount = field.flagCount;
|
|
let coveredCount = field.coveredNonMineCount;
|
|
|
|
if (field.isOver)
|
|
return false;
|
|
|
|
this.stepSingleSquares(field);
|
|
if (field.hasWon || field.flagCount !== flagCount || field.coveredNonMineCount !== coveredCount)
|
|
return true;
|
|
|
|
this.stepNeighboringSquares(field);
|
|
if (field.hasWon || field.flagCount !== flagCount || field.coveredNonMineCount !== coveredCount)
|
|
return true;
|
|
|
|
this.stepAllSquares(field);
|
|
// noinspection RedundantIfStatementJS // Makes it easier to add more steps
|
|
if (field.hasWon || field.flagCount !== flagCount || field.coveredNonMineCount !== coveredCount)
|
|
return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Solves the field as much as by considering just one square at a time and looking for trivial solutions.
|
|
*
|
|
* This function is very fast but only finds trivial moves such as a square that can be chorded or a square of which
|
|
* all neighbors can be flagged.
|
|
*
|
|
* @param field the field to solve
|
|
* @private
|
|
*/
|
|
private static stepSingleSquares(field: Field): void {
|
|
Solver.getKnowns(field)
|
|
.forEach(square => {
|
|
field.chord(square);
|
|
if (square.getNeighborCount(it => it.isCovered) === square.getNeighborCount(it => it.hasMine))
|
|
square.neighbors.filter(it => !it.hasFlag).forEach(it => field.toggleFlag(it));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Solves the field as much as possible by considering only one uncovered square and its uncovered neighbors at a
|
|
* time.
|
|
*
|
|
* This function is slower than `#stepSingleSquares` but finds some more advanced moves by effectively considering
|
|
* two squares at time. Meanwhile, this function does not look at the bigger picture, so it cannot infer some more
|
|
* complicated moves. On the other hand, for some reason this function finds some edge cases that `#stepAllSquares`
|
|
* overlooks.
|
|
*
|
|
* @param field the field to solve
|
|
* @private
|
|
*/
|
|
private static stepNeighboringSquares(field: Field): void {
|
|
Solver.getKnowns(field).forEach(known => {
|
|
Solver.applySolution(
|
|
field,
|
|
this.matrixSolve(field, known.neighbors.filter(it => !it.isCovered).concat(known), true)
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Solves the field as much as possible by looking at all uncovered squares and the remaining number of mines.
|
|
*
|
|
* Because this function considers all squares in the field, it is very slow. Then again, it finds a lot of steps
|
|
* as well.
|
|
*
|
|
* @param field the field to solve
|
|
* @private
|
|
*/
|
|
private static stepAllSquares(field: Field): void {
|
|
if (!field.hasStarted || field.hasWon || field.hasLost) return;
|
|
|
|
const knowns = Solver.getKnowns(field);
|
|
Solver.applySolution(
|
|
field,
|
|
this.matrixSolve(field, knowns, false)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Solves as much as possible from the field assuming knowledge of the uncovered squares in `known`.
|
|
*
|
|
* @param field the field to solve in
|
|
* @param knowns the uncovered squares that the solver should consider
|
|
* @param adjacentSquaresOnly `true` if the solver should only look at the squares adjacent to `known` and not at
|
|
* all squares in the field. Enabling this option increases complexity, but may uncover some edge cases
|
|
* @returns the solution that has been found
|
|
* @private
|
|
*/
|
|
private static matrixSolve(field: Field, knowns: Square[], adjacentSquaresOnly: boolean): Solution {
|
|
if (knowns.length === 0) return [];
|
|
|
|
let unknowns: Square[];
|
|
if (adjacentSquaresOnly)
|
|
unknowns = Array
|
|
.from(new Set(knowns.reduce((acc, it) => acc.concat(it.neighbors), <Square[]>[])))
|
|
.filter(it => it.isCovered && !it.hasFlag && knowns.indexOf(it) < 0);
|
|
else
|
|
unknowns = field.squareList
|
|
.filter(it => it.isCovered && !it.hasFlag && knowns.indexOf(it) < 0);
|
|
if (unknowns.length === 0) return [];
|
|
|
|
const matrix: number[][] = [];
|
|
knowns.forEach(square => {
|
|
const row = Array(unknowns.length).fill(0);
|
|
square.neighbors
|
|
.filter(it => it.isCovered && !it.hasFlag)
|
|
.forEach(it => row[unknowns.indexOf(it)] = 1);
|
|
|
|
row.push(square.getNeighborCount(it => it.hasMine) - square.getNeighborCount(it => it.hasFlag));
|
|
matrix.push(row);
|
|
});
|
|
if (!adjacentSquaresOnly)
|
|
matrix.push(Array(unknowns.length).fill(1).concat(field.mineCount - field.flagCount));
|
|
|
|
return (new Matrix(matrix))
|
|
.solveBinary()
|
|
.map((it, i) => it === undefined ? undefined : [it, unknowns[i]]);
|
|
}
|
|
|
|
|
|
/**
|
|
* Returns all uncovered squares that have at least one covered unflagged neighbor.
|
|
*
|
|
* @param field the field to find the known squares in
|
|
* @returns all uncovered squares that have at least one covered unflagged neighbor
|
|
* @private
|
|
*/
|
|
private static getKnowns(field: Field): Square[] {
|
|
return field.squareList
|
|
.filter(it => !it.isCovered)
|
|
.filter(it => it.getNeighborCount(it => it.isCovered && !it.hasFlag) > 0);
|
|
}
|
|
|
|
/**
|
|
* Applies the given solution to the field.
|
|
*
|
|
* @param field the field to apply the solution to
|
|
* @param solution the solution to apply
|
|
* @private
|
|
*/
|
|
private static applySolution(field: Field, solution: Solution): void {
|
|
solution.forEach(target => {
|
|
if (target === undefined) return;
|
|
|
|
const [solution, square] = target;
|
|
if (solution === 0) field.uncover(square.coords);
|
|
else if (solution === 1) field.toggleFlag(square.coords);
|
|
});
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* A matrix of numbers.
|
|
*/
|
|
export class Matrix {
|
|
private readonly matrix: array;
|
|
private readonly rowCount: number;
|
|
private readonly colCount: number;
|
|
|
|
|
|
/**
|
|
* Constructs a new matrix from the given numbers.
|
|
*
|
|
* @param cells an array of rows of numbers
|
|
*/
|
|
constructor(cells: number[][]) {
|
|
if (cells.length === 0) throw new Error("Matrix must have at least 1 row.");
|
|
if (cells[0].length === 0) throw new Error("Matrix must have at least 1 column.");
|
|
|
|
this.matrix = array(cells);
|
|
this.rowCount = this.matrix.shape[0];
|
|
this.colCount = this.matrix.shape[1];
|
|
}
|
|
|
|
|
|
/**
|
|
* Transforms this matrix into its row-reduced echelon form using Gauss-Jordan elimination.
|
|
*/
|
|
private rref(): void {
|
|
const shape = this.matrix.shape;
|
|
|
|
let pivot = 0;
|
|
for (let row = 0; row < this.matrix.shape[1]; row++) {
|
|
// Find pivot
|
|
while (pivot < this.colCount && this.rowWhereColSatisfies(row, pivot, it => it !== 0) == null) pivot++;
|
|
if (pivot >= this.colCount) return;
|
|
|
|
// Swap with any lower row with non-zero in pivot column
|
|
if (this.matrix.get(row, pivot) === 0) {
|
|
const row2 = this.rowWhereColSatisfies(row + 1, pivot, it => it !== 0)!;
|
|
this.matrix.swap(row, row2);
|
|
}
|
|
// Scale row so pivot equals 1
|
|
this.matrix.slice(row, row + 1).scale(1 / this.matrix.get(row, pivot));
|
|
|
|
// Set all other cells in this column to 0
|
|
for (let row2 = 0; row2 < this.rowCount; row2++) {
|
|
if (row2 === row) continue;
|
|
this.matrix.row_add(row2, row, -this.matrix.get(row2, pivot));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Interprets this matrix as an augmented matrix and returns for each variable the value or `undefined` if its value
|
|
* could not be determined.
|
|
*
|
|
* This function invokes `#rref`, so this matrix will change as a result.
|
|
*
|
|
* @returns the value of each variable, and `undefined` for each variable that could not be determined uniquely
|
|
*/
|
|
private solve(): (number | undefined)[] {
|
|
this.rref();
|
|
|
|
return range(this.colCount - 1)
|
|
.map(column => {
|
|
const rowPivotIndex = this.rowWhereColSatisfies(0, column, it => it === 1);
|
|
if (rowPivotIndex == null) return undefined;
|
|
|
|
const row = this.matrix.slice(rowPivotIndex, rowPivotIndex + 1);
|
|
if (row.map((it: number) => it === 0).sum() >= this.colCount - 1)
|
|
return row.get(row.length - 1);
|
|
|
|
return undefined;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Same as `#solve`, except that it assumes that every variable is an integer in the range [0, 1].
|
|
*
|
|
* @returns the value of each variable, and `undefined` for each variable that could not be determined uniquely
|
|
*/
|
|
solveBinary(): (number | undefined)[] {
|
|
const resultsA = this.solve();
|
|
const resultsB = this.solveBinarySub();
|
|
return resultsA.map((it, i) => it ?? resultsB[i]);
|
|
}
|
|
|
|
/**
|
|
* Helper function for `#solveBinary` that tries to solve for variables in the range [0, 1] in the current matrix
|
|
* without applying transformations.
|
|
*
|
|
* @returns the value of each variable, and `undefined` for each variable that could not be determined uniquely
|
|
* @private
|
|
*/
|
|
private solveBinarySub(): (number | undefined)[] {
|
|
const results = Array(this.colCount - 1).fill(undefined);
|
|
for (let row = 0; row < this.rowCount; row++) {
|
|
// ax = b
|
|
const a = this.matrix.slice(0, this.colCount - 1);
|
|
const b = this.matrix.get(row, this.colCount - 1);
|
|
|
|
const sign = a.copy().sign();
|
|
const negSum = -sign.copy().map((it: number) => it === -1).product(a).sum();
|
|
const posSum = sign.copy().map((it: number) => it === 1).product(a).sum();
|
|
|
|
if (b === negSum) {
|
|
a.forEach((it: number, i: number) => {
|
|
if (it < 0) results[i] = 1;
|
|
if (it > 0) results[i] = 0;
|
|
});
|
|
} else if (b === posSum) {
|
|
a.forEach((it: number, i: number) => {
|
|
if (it < 0) results[i] = 0;
|
|
if (it > 0) results[i] = 1;
|
|
});
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
|
|
private rowWhereColSatisfies(rowStart: number = 0, column: number, criterion: (cell: number) => boolean): number | null {
|
|
for (let row = rowStart; row < this.rowCount; row++)
|
|
if (criterion(this.matrix.get(row, column)))
|
|
return row;
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* A partial solution to a field.
|
|
*
|
|
* Each element of the array describes an instruction to apply to the field. If the instruction is `undefined`, then
|
|
* nothing should be done. Otherwise, the tuple describes the instruction: If the number is 0, the associated square
|
|
* should be uncovered because it definitely does not contain a mine. Otherwise, the number is 1 and the associated
|
|
* square should be flagged because it definitely contains a mine.
|
|
*/
|
|
type Solution = ([number, Square] | undefined)[]
|