minesweeper/src/main/js/Solver.ts

465 lines
17 KiB
TypeScript

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.hasWon || field.hasLost) 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, undefined)!;
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.hasWon || field.hasLost) 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 in one step through 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.hasWon || field.hasLost)
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 {
const knowns = Solver.getKnowns(field);
knowns.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 cells: number[][];
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.cells = cells;
this.rowCount = this.cells.length;
this.colCount = this.cells[0].length;
}
/**
* Returns the `row`th row of numbers.
*
* @param row the index of the row to return
* @returns the `row`th row of numbers
*/
getRow(row: number): number[] {
if (row < 0 || row >= this.rowCount)
throw new Error(`Row must be in range [0, ${this.rowCount}) but was ${row}.`);
return this.cells[row];
}
/**
* Returns the `col`th column of numbers.
*
* @param col the index of the column to return
* @returns the `col`th column of numbers
*/
getCol(col: number): number[] {
if (col < 0 || col >= this.colCount)
throw new Error(`Col must be in range [0, ${this.colCount}) but was ${col}.`);
return this.cells.map(row => row[col]);
}
/**
* Returns the `col`th number in the `row`th row.
*
* @param row the index of the row to find the number in
* @param col the index of the column to find the number in
* @returns the `col`th number in the `row`th row
*/
getCell(row: number, col: number): number {
if (row < 0 || row >= this.rowCount)
throw new Error(`Row must be in range [0, ${this.rowCount}) but was ${row}.`);
if (col < 0 || col >= this.colCount)
throw new Error(`Row must be in range [0, ${this.colCount}) but was ${col}.`);
return this.cells[row][col];
}
/**
* Transforms this matrix into its row-reduced echelon form using Gauss-Jordan elimination.
*/
rref(): void {
let pivot = 0;
for (let row = 0; row < this.rowCount; row++) {
// Find pivot
while (pivot < this.colCount && this.getCol(pivot).slice(row).every(it => it === 0)) pivot++;
if (pivot >= this.colCount) return;
// Set pivot to non-zero
if (this.getCell(row, pivot) === 0)
this.swap(row, this.getCol(pivot).slice(row + 1).findIndex(it => it !== 0) + row + 1);
// Set pivot to 1
this.multiply(row, 1 / this.getCell(row, pivot));
// Set all other cells in this column to 0
for (let row2 = 0; row2 < this.rowCount; row2++) {
if (row2 === row) continue;
this.add(row2, row, -this.getCell(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
*/
solve(): (number | undefined)[] {
this.rref();
return range(this.colCount - 1)
.map(it => {
const rowPivotIndex = this.getCol(it).findIndex(it => it === 1);
if (rowPivotIndex < 0) return undefined;
const row = this.getRow(rowPivotIndex);
if (row.slice(0, it).every(it => it === 0) && row.slice(it + 1, -1).every(it => it === 0))
return row.slice(-1)[0];
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);
this.cells.forEach(row => {
// ax = b
const a = row.slice(0, -1);
const b = row.slice(-1)[0];
const negSum = a.filter(it => it < 0).reduce((sum, cell) => sum + cell, 0);
const posSum = a.filter(it => it > 0).reduce((sum, cell) => sum + cell, 0);
if (b === negSum) {
a.forEach((it, i) => {
if (it < 0) results[i] = 1;
if (it > 0) results[i] = 0;
});
} else if (b === posSum) {
a.forEach((it, i) => {
if (it < 0) results[i] = 0;
if (it > 0) results[i] = 1;
});
}
});
return results;
}
/**
* Swaps the rows at the given indices.
*
* @param rowA the index of the row to swap
* @param rowB the index of the other row to swap
*/
swap(rowA: number, rowB: number) {
[this.cells[rowA], this.cells[rowB]] = [this.cells[rowB], this.cells[rowA]];
}
/**
* Multiplies all numbers in the `row`th number by `factor`.
*
* @param row the index of the row to multiply
* @param factor the factory to multiply each number with
*/
multiply(row: number, factor: number) {
this.cells[row] = this.cells[row].map(it => it * factor);
}
/**
* Adds `factor` multiples of the `rowB`th row to the `rowA`th row.
*
* Effectively, sets `A = A + B * factor`.
*
* @param rowA the index of the row to add to
* @param rowB the index of the row to add a multiple of
* @param factor the factor to multiply each added number with
*/
add(rowA: number, rowB: number, factor: number) {
this.cells[rowA] =
this.cells[rowA].map((it, i) => this.cells[rowA][i] + this.cells[rowB][i] * factor);
}
}
/**
* 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)[]