implemented solver

main
Inga 🏳‍🌈 5 days ago
parent 1c9175e4a9
commit aa47a7812b
  1. 9
      eslint.config.js
  2. 79
      src/lib/board.spec.ts
  3. 13
      src/lib/board.ts
  4. 76
      src/lib/datatypes.spec.ts
  5. 87
      src/lib/datatypes.ts
  6. 411
      src/lib/solver.spec.ts
  7. 137
      src/lib/solver.ts
  8. 12
      src/lib/types.ts

@ -17,6 +17,7 @@ export default tsEslint.config(
},
{
rules: {
"@typescript-eslint/consistent-type-definitions": ["error", "type"],
"@typescript-eslint/restrict-template-expressions": [
"error",
{ allowNumber: true, allowNever: true },
@ -26,6 +27,14 @@ export default tsEslint.config(
{
files: ["**/*.spec.*"],
rules: {
// To make it easier to pass incorrect data to functions under test
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
// With async test suites in TAP, we don't have to explicitly call t.end
// (because TAP automatically determines that all assertions are done when the promise is settled),
// so using async functions even when they don't have any awaits in them
"@typescript-eslint/require-await": "off",
},
},

@ -1,7 +1,7 @@
import t, { type Test } from "tap";
import { Board } from "./board.ts";
import { SquareState } from "./types.ts";
import { SquareState } from "./datatypes.ts";
void t.test("Serialize / deserialize", async (t) => {
const createAndCheckBoard = (t: Test, serialized: string) => {
@ -55,7 +55,7 @@ void t.test("Serialize / deserialize", async (t) => {
t.equal(board.hasSquare(1, 1), false);
});
void t.test("Half-full 3x4 board", async (t) => {
void t.test("Half-full 4x3 board", async (t) => {
const board = createAndCheckBoard(t, "XO_|O_X|OX_|_XO");
t.equal(board.get(0, 0), SquareState.X);
t.equal(board.get(0, 1), SquareState.O);
@ -91,14 +91,14 @@ void t.test("Serialize / deserialize", async (t) => {
});
{
const board = new Board([[undefined as unknown as SquareState]]);
const board = new Board([[undefined as any]]);
t.throws(() => board.serialize(), {
message: "Unsupported square state: undefined",
});
}
{
const board = new Board([["test" as unknown as SquareState]]);
const board = new Board([["test" as any]]);
t.throws(() => board.serialize(), {
message: "Unsupported square state: test",
});
@ -106,6 +106,75 @@ void t.test("Serialize / deserialize", async (t) => {
});
});
void t.test("Empty board creation", async (t) => {
void t.test("0x0 board", async (t) => {
const board = Board.createEmpty(0, 0);
t.equal(board.serialize(), "");
t.throws(() => board.get(0, 0), { message: "Out of bounds: 0:0" });
t.throws(() => board.get(0, 1), { message: "Out of bounds: 0:1" });
t.throws(() => board.get(1, 0), { message: "Out of bounds: 1:0" });
t.throws(() => board.get(1, 1), { message: "Out of bounds: 1:1" });
t.equal(board.hasRow(0), false);
t.equal(board.hasRow(1), false);
t.equal(board.hasSquare(0, 0), false);
t.equal(board.hasSquare(0, 1), false);
t.equal(board.hasSquare(1, 0), false);
t.equal(board.hasSquare(1, 1), false);
});
void t.test("1x0 board", async (t) => {
const board = Board.createEmpty(1, 0);
t.equal(board.serialize(), "");
t.throws(() => board.get(0, 0), { message: "Out of bounds: 0:0" });
t.throws(() => board.get(0, 1), { message: "Out of bounds: 0:1" });
t.throws(() => board.get(1, 0), { message: "Out of bounds: 1:0" });
t.throws(() => board.get(1, 1), { message: "Out of bounds: 1:1" });
t.equal(board.hasRow(0), true);
t.equal(board.hasRow(1), false);
t.equal(board.hasSquare(0, 0), false);
t.equal(board.hasSquare(0, 1), false);
t.equal(board.hasSquare(1, 0), false);
t.equal(board.hasSquare(1, 1), false);
});
void t.test("Empty 1x1 board", async (t) => {
const board = Board.createEmpty(1, 1);
t.equal(board.serialize(), "_");
t.equal(board.get(0, 0), SquareState.Unoccupied);
t.throws(() => board.get(0, 1), { message: "Out of bounds: 0:1" });
t.throws(() => board.get(1, 0), { message: "Out of bounds: 1:0" });
t.throws(() => board.get(1, 1), { message: "Out of bounds: 1:1" });
t.equal(board.hasRow(0), true);
t.equal(board.hasRow(1), false);
t.equal(board.hasSquare(0, 0), true);
t.equal(board.hasSquare(0, 1), false);
t.equal(board.hasSquare(1, 0), false);
t.equal(board.hasSquare(1, 1), false);
});
void t.test("Empty 4x3 board", async (t) => {
const board = Board.createEmpty(4, 3);
t.equal(board.serialize(), "___|___|___|___");
t.equal(board.get(0, 0), SquareState.Unoccupied);
t.equal(board.get(3, 2), SquareState.Unoccupied);
t.throws(() => board.get(3, 3), { message: "Out of bounds: 3:3" });
t.throws(() => board.get(4, 0), { message: "Out of bounds: 4:0" });
t.equal(board.hasRow(3), true);
t.equal(board.hasRow(4), false);
t.equal(board.hasSquare(3, 2), true);
t.equal(board.hasSquare(3, 3), false);
t.equal(board.hasSquare(4, 0), false);
});
});
void t.test("Derived boards creation", async (t) => {
const board = Board.fromSerialized("_X|O_|_O");
@ -126,7 +195,7 @@ void t.test("Derived boards creation", async (t) => {
t.throws(() => board.with(1, 2, SquareState.X), {
message: "Out of bounds: 1:2",
});
t.equal(board.with(2, 0, SquareState.X).serialize(), "_X|O_|XX");
t.equal(board.with(2, 0, SquareState.X).serialize(), "_X|O_|XO");
t.throws(() => board.with(2, 1, SquareState.X), {
message: "Cannot update occupied square: 2:1",
});

@ -1,7 +1,8 @@
import { SquareState } from "./types.ts";
import { SquareState } from "./datatypes.ts";
import { unreachable } from "./utils.ts";
export class Board {
// State should be immutable
constructor(private readonly state: SquareState[][]) {}
hasRow(row: number) {
@ -27,13 +28,21 @@ export class Board {
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guaranteed to exist at this stage because otherwise `get` would throw an error
const newRowState = [...this.state[column]!];
const newRowState = [...this.state[row]!];
newRowState[column] = newSquareState;
const newState = [...this.state];
newState[row] = newRowState;
return new Board(newState);
}
static createEmpty(rows: number, columns: number) {
const row = [...(new Array(columns) as unknown[])].map(
() => SquareState.Unoccupied,
);
const boardState = [...(new Array(rows) as unknown[])].map(() => row);
return new Board(boardState);
}
static fromSerialized(serialized: string) {
return new Board(
serialized.split("|").map((line) =>

@ -0,0 +1,76 @@
import t from "tap";
import {
CurrentOutcome,
FinalOutcome,
Player,
SquareState,
getDesiredFinalOutcomeByPlayer,
getExpectedOutcomeByCurrentOutcome,
getNextPlayer,
getOccupiedStateByPlayer,
getUndesiredFinalOutcomeByPlayer,
} from "./datatypes.ts";
// These unit tests are mostly useless because they're basically a tautology,
// a rephrasing of implementation in slightly different words.
// Still it might be useful to have them here to set expectations.
void t.test("getExpectedOutcomeByCurrentOutcome", async (t) => {
t.matchOnlyStrict(getExpectedOutcomeByCurrentOutcome(CurrentOutcome.Draw), {
finalOutcome: FinalOutcome.Draw,
movesLeft: 0,
});
t.matchOnlyStrict(getExpectedOutcomeByCurrentOutcome(CurrentOutcome.WinX), {
finalOutcome: FinalOutcome.WinX,
movesLeft: 0,
});
t.matchOnlyStrict(getExpectedOutcomeByCurrentOutcome(CurrentOutcome.WinO), {
finalOutcome: FinalOutcome.WinO,
movesLeft: 0,
});
t.throws(
() => getExpectedOutcomeByCurrentOutcome(CurrentOutcome.Undecided as any),
{
message: "Unsupported current outcome: 201",
},
);
t.throws(() => getExpectedOutcomeByCurrentOutcome("test" as any), {
message: "Unsupported current outcome: test",
});
});
void t.test("getOccupiedStateByPlayer", async (t) => {
t.equal(getOccupiedStateByPlayer(Player.X), SquareState.X);
t.equal(getOccupiedStateByPlayer(Player.O), SquareState.O);
t.throws(() => getOccupiedStateByPlayer("test" as any), {
message: "Unsupported player: test",
});
});
void t.test("getNextPlayer", async (t) => {
t.equal(getNextPlayer(Player.X), Player.O);
t.equal(getNextPlayer(Player.O), Player.X);
t.throws(() => getNextPlayer("test" as any), {
message: "Unsupported player: test",
});
});
void t.test("getDesiredFinalOutcomeByPlayer", async (t) => {
t.equal(getDesiredFinalOutcomeByPlayer(Player.X), FinalOutcome.WinX);
t.equal(getDesiredFinalOutcomeByPlayer(Player.O), FinalOutcome.WinO);
t.throws(() => getDesiredFinalOutcomeByPlayer("test" as any), {
message: "Unsupported player: test",
});
});
void t.test("getUndesiredFinalOutcomeByPlayer", async (t) => {
t.equal(getUndesiredFinalOutcomeByPlayer(Player.X), FinalOutcome.WinO);
t.equal(getUndesiredFinalOutcomeByPlayer(Player.O), FinalOutcome.WinX);
t.throws(() => getUndesiredFinalOutcomeByPlayer("test" as any), {
message: "Unsupported player: test",
});
});

@ -0,0 +1,87 @@
export enum SquareState {
Unoccupied = 1, // so that all SquareState values are truthy
X,
O,
}
export enum Player {
X = 101, // so that values of different enums never overlap
O,
}
export enum CurrentOutcome {
Undecided = 201,
Draw,
WinX,
WinO,
}
export enum FinalOutcome {
Draw = 301,
WinX,
WinO,
}
export type ExpectedOutcome = {
finalOutcome: FinalOutcome;
movesLeft: number;
};
export const getExpectedOutcomeByCurrentOutcome = (
currentOutcome: Exclude<CurrentOutcome, CurrentOutcome.Undecided>,
): ExpectedOutcome => {
switch (currentOutcome) {
case CurrentOutcome.Draw:
return { finalOutcome: FinalOutcome.Draw, movesLeft: 0 };
case CurrentOutcome.WinX:
return { finalOutcome: FinalOutcome.WinX, movesLeft: 0 };
case CurrentOutcome.WinO:
return { finalOutcome: FinalOutcome.WinO, movesLeft: 0 };
default:
throw new Error(`Unsupported current outcome: ${currentOutcome}`);
}
};
export const getOccupiedStateByPlayer = (player: Player) => {
switch (player) {
case Player.X:
return SquareState.X;
case Player.O:
return SquareState.O;
default:
throw new Error(`Unsupported player: ${player}`);
}
};
export const getNextPlayer = (player: Player) => {
switch (player) {
case Player.X:
return Player.O;
case Player.O:
return Player.X;
default:
throw new Error(`Unsupported player: ${player}`);
}
};
export const getDesiredFinalOutcomeByPlayer = (player: Player) => {
switch (player) {
case Player.X:
return FinalOutcome.WinX;
case Player.O:
return FinalOutcome.WinO;
default:
throw new Error(`Unsupported player: ${player}`);
}
};
export const getUndesiredFinalOutcomeByPlayer = (player: Player) => {
switch (player) {
case Player.X:
return FinalOutcome.WinO;
case Player.O:
return FinalOutcome.WinX;
default:
throw new Error(`Unsupported player: ${player}`);
}
};

@ -1,7 +1,18 @@
import t from "tap";
import { getBoardOutcome, getSequenceOutcome } from "./solver.ts";
import { CurrentOutcome, SquareState } from "./types.ts";
import {
computeAllSolutions,
getBoardOutcome,
getPreferredNextOutcome,
getSequenceOutcome,
} from "./solver.ts";
import {
CurrentOutcome,
ExpectedOutcome,
FinalOutcome,
Player,
SquareState,
} from "./datatypes.ts";
import { Board } from "./board.ts";
void t.test("getSequenceOutcome", async (t) => {
@ -134,6 +145,15 @@ void t.test("getSequenceOutcome", async (t) => {
});
void t.test("getBoardOutcome", async (t) => {
void t.test("1x1 boards", async (t) => {
t.equal(
getBoardOutcome(Board.fromSerialized("_")),
CurrentOutcome.Undecided,
);
t.equal(getBoardOutcome(Board.fromSerialized("X")), CurrentOutcome.Draw);
t.equal(getBoardOutcome(Board.fromSerialized("O")), CurrentOutcome.Draw);
});
void t.test("2x2 boards", async (t) => {
t.equal(
getBoardOutcome(Board.fromSerialized("__|__")),
@ -279,4 +299,391 @@ void t.test("getBoardOutcome", async (t) => {
CurrentOutcome.WinX,
);
});
void t.test("6x5 boards", async (t) => {
t.equal(
getBoardOutcome(
Board.fromSerialized("_____|_____|_____|_____|_____|__XXX"),
),
CurrentOutcome.WinX,
);
t.equal(
getBoardOutcome(
Board.fromSerialized("_____|_____|_____|____X|____X|____X"),
),
CurrentOutcome.WinX,
);
t.equal(
getBoardOutcome(
Board.fromSerialized("_____|_____|_____|__X__|___X_|____X"),
),
CurrentOutcome.WinX,
);
t.equal(
getBoardOutcome(
Board.fromSerialized("_____|_____|_____|____X|___X_|__X__"),
),
CurrentOutcome.WinX,
);
});
void t.test("5x6 boards", async (t) => {
t.equal(
getBoardOutcome(
Board.fromSerialized("______|______|______|______|___XXX"),
),
CurrentOutcome.WinX,
);
t.equal(
getBoardOutcome(
Board.fromSerialized("______|______|_____X|_____X|_____X"),
),
CurrentOutcome.WinX,
);
t.equal(
getBoardOutcome(
Board.fromSerialized("______|______|___X__|____X_|_____X"),
),
CurrentOutcome.WinX,
);
t.equal(
getBoardOutcome(
Board.fromSerialized("______|______|_____X|____X_|___X__"),
),
CurrentOutcome.WinX,
);
});
});
void t.test("getPreferredNextOutcome", async (t) => {
const nextOutcomes: ExpectedOutcome[] = [
{ finalOutcome: FinalOutcome.Draw, movesLeft: 100 },
{ finalOutcome: FinalOutcome.Draw, movesLeft: 150 },
{ finalOutcome: FinalOutcome.Draw, movesLeft: 50 },
{ finalOutcome: FinalOutcome.WinX, movesLeft: 60 },
{ finalOutcome: FinalOutcome.WinX, movesLeft: 160 },
{ finalOutcome: FinalOutcome.WinO, movesLeft: 40 },
{ finalOutcome: FinalOutcome.WinO, movesLeft: 140 },
];
t.matchOnlyStrict(getPreferredNextOutcome(nextOutcomes, Player.X), {
finalOutcome: FinalOutcome.WinX,
movesLeft: 60,
});
t.matchOnlyStrict(getPreferredNextOutcome(nextOutcomes, Player.O), {
finalOutcome: FinalOutcome.WinO,
movesLeft: 40,
});
t.matchOnlyStrict(getPreferredNextOutcome(nextOutcomes.slice(3), Player.X), {
finalOutcome: FinalOutcome.WinX,
movesLeft: 60,
});
t.matchOnlyStrict(getPreferredNextOutcome(nextOutcomes.slice(3), Player.O), {
finalOutcome: FinalOutcome.WinO,
movesLeft: 40,
});
t.matchOnlyStrict(getPreferredNextOutcome(nextOutcomes.slice(4), Player.X), {
finalOutcome: FinalOutcome.WinX,
movesLeft: 160,
});
t.matchOnlyStrict(getPreferredNextOutcome(nextOutcomes.slice(4), Player.O), {
finalOutcome: FinalOutcome.WinO,
movesLeft: 40,
});
t.matchOnlyStrict(getPreferredNextOutcome(nextOutcomes.slice(5), Player.X), {
finalOutcome: FinalOutcome.WinO,
movesLeft: 140,
});
t.matchOnlyStrict(getPreferredNextOutcome(nextOutcomes.slice(5), Player.O), {
finalOutcome: FinalOutcome.WinO,
movesLeft: 40,
});
t.matchOnlyStrict(getPreferredNextOutcome(nextOutcomes.slice(6), Player.X), {
finalOutcome: FinalOutcome.WinO,
movesLeft: 140,
});
t.matchOnlyStrict(getPreferredNextOutcome(nextOutcomes.slice(6), Player.O), {
finalOutcome: FinalOutcome.WinO,
movesLeft: 140,
});
t.matchOnlyStrict(
getPreferredNextOutcome(nextOutcomes.slice(0, 5), Player.X),
{
finalOutcome: FinalOutcome.WinX,
movesLeft: 60,
},
);
t.matchOnlyStrict(
getPreferredNextOutcome(nextOutcomes.slice(0, 5), Player.O),
{
finalOutcome: FinalOutcome.Draw,
movesLeft: 150,
},
);
t.matchOnlyStrict(
getPreferredNextOutcome(nextOutcomes.slice(0, 3), Player.X),
{
finalOutcome: FinalOutcome.Draw,
movesLeft: 150,
},
);
t.matchOnlyStrict(
getPreferredNextOutcome(nextOutcomes.slice(0, 3), Player.O),
{
finalOutcome: FinalOutcome.Draw,
movesLeft: 150,
},
);
t.throws(() => getPreferredNextOutcome([], Player.X), {
message: "Could not find the best outcome out of 0 possible next outcomes",
});
});
void t.test("computeAllSolutions", async (t) => {
const checkSolutionsComplete = (
rows: number,
columns: number,
expectedSolutions: Record<string, ExpectedOutcome>,
) => {
t.matchOnlyStrict(
Object.fromEntries(computeAllSolutions(rows, columns).entries()),
expectedSolutions,
);
};
const checkSolutionsIncomplete = (
rows: number,
columns: number,
expectedSolutionsCount: number,
expectedSolutionsIncomplete: Record<string, ExpectedOutcome>,
) => {
const allSolutions = computeAllSolutions(rows, columns);
t.equal(allSolutions.size, expectedSolutionsCount);
t.matchStrict(
Object.fromEntries(allSolutions.entries()),
expectedSolutionsIncomplete,
);
};
checkSolutionsComplete(0, 0, {
"": { finalOutcome: FinalOutcome.Draw, movesLeft: 0 },
});
checkSolutionsComplete(1, 0, {
"": { finalOutcome: FinalOutcome.Draw, movesLeft: 0 },
});
checkSolutionsComplete(1, 1, {
_: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
X: { finalOutcome: FinalOutcome.Draw, movesLeft: 0 },
});
// making sure that we don't have any bugs that would manifest when width > height
checkSolutionsComplete(1, 3, {
___: { finalOutcome: FinalOutcome.Draw, movesLeft: 3 },
X__: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
_X_: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
__X: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
XO_: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
X_O: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
OX_: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
_XO: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
O_X: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
_OX: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
XOX: { finalOutcome: FinalOutcome.Draw, movesLeft: 0 },
XXO: { finalOutcome: FinalOutcome.Draw, movesLeft: 0 },
OXX: { finalOutcome: FinalOutcome.Draw, movesLeft: 0 },
});
// making sure that we don't have any bugs that would manifest when height > width
checkSolutionsComplete(3, 1, {
"_|_|_": { finalOutcome: FinalOutcome.Draw, movesLeft: 3 },
"X|_|_": { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
"_|X|_": { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
"_|_|X": { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
"X|O|_": { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
"X|_|O": { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
"O|X|_": { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
"_|X|O": { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
"O|_|X": { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
"_|O|X": { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
"X|O|X": { finalOutcome: FinalOutcome.Draw, movesLeft: 0 },
"X|X|O": { finalOutcome: FinalOutcome.Draw, movesLeft: 0 },
"O|X|X": { finalOutcome: FinalOutcome.Draw, movesLeft: 0 },
});
// smallest possible board where X can win
checkSolutionsComplete(1, 5, {
_____: { finalOutcome: FinalOutcome.Draw, movesLeft: 5 },
X____: { finalOutcome: FinalOutcome.Draw, movesLeft: 4 },
XO___: { finalOutcome: FinalOutcome.Draw, movesLeft: 3 },
X_O__: { finalOutcome: FinalOutcome.Draw, movesLeft: 3 },
X__O_: { finalOutcome: FinalOutcome.Draw, movesLeft: 3 },
X___O: { finalOutcome: FinalOutcome.Draw, movesLeft: 3 },
_X___: { finalOutcome: FinalOutcome.Draw, movesLeft: 4 },
OX___: { finalOutcome: FinalOutcome.Draw, movesLeft: 3 },
_XO__: { finalOutcome: FinalOutcome.Draw, movesLeft: 3 },
_X_O_: { finalOutcome: FinalOutcome.Draw, movesLeft: 3 },
_X__O: { finalOutcome: FinalOutcome.WinX, movesLeft: 3 },
__X__: { finalOutcome: FinalOutcome.Draw, movesLeft: 4 },
O_X__: { finalOutcome: FinalOutcome.WinX, movesLeft: 3 },
_OX__: { finalOutcome: FinalOutcome.Draw, movesLeft: 3 },
__XO_: { finalOutcome: FinalOutcome.Draw, movesLeft: 3 },
__X_O: { finalOutcome: FinalOutcome.WinX, movesLeft: 3 },
___X_: { finalOutcome: FinalOutcome.Draw, movesLeft: 4 },
O__X_: { finalOutcome: FinalOutcome.WinX, movesLeft: 3 },
_O_X_: { finalOutcome: FinalOutcome.Draw, movesLeft: 3 },
__OX_: { finalOutcome: FinalOutcome.Draw, movesLeft: 3 },
___XO: { finalOutcome: FinalOutcome.Draw, movesLeft: 3 },
____X: { finalOutcome: FinalOutcome.Draw, movesLeft: 4 },
O___X: { finalOutcome: FinalOutcome.Draw, movesLeft: 3 },
_O__X: { finalOutcome: FinalOutcome.Draw, movesLeft: 3 },
__O_X: { finalOutcome: FinalOutcome.Draw, movesLeft: 3 },
___OX: { finalOutcome: FinalOutcome.Draw, movesLeft: 3 },
XOX__: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
XO_X_: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
XO__X: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
XXO__: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
X_OX_: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
X_O_X: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
XX_O_: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
X_XO_: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
X__OX: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
XX__O: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
X_X_O: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
X__XO: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
OXX__: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
OX_X_: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
OX__X: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
_XOX_: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
_XO_X: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
_XXO_: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
_X_OX: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
_XX_O: { finalOutcome: FinalOutcome.WinX, movesLeft: 2 },
_X_XO: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
O_XX_: { finalOutcome: FinalOutcome.WinX, movesLeft: 2 },
O_X_X: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
_OXX_: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
_OX_X: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
__XOX: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
__XXO: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
O__XX: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
_O_XX: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
__OXX: { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
XOXO_: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
XOX_O: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
XOOX_: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
XO_XO: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
XOO_X: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
XO_OX: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
XXOO_: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
XXO_O: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
X_OXO: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
X_OOX: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
XX_OO: { finalOutcome: FinalOutcome.WinX, movesLeft: 1 },
X_XOO: { finalOutcome: FinalOutcome.WinX, movesLeft: 1 },
OXXO_: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
OXX_O: { finalOutcome: FinalOutcome.WinX, movesLeft: 1 },
OXOX_: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
OX_XO: { finalOutcome: FinalOutcome.WinX, movesLeft: 1 },
OXO_X: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
OX_OX: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
_XOXO: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
_XOOX: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
_XXOO: { finalOutcome: FinalOutcome.WinX, movesLeft: 1 },
OOXX_: { finalOutcome: FinalOutcome.WinX, movesLeft: 1 },
O_XXO: { finalOutcome: FinalOutcome.WinX, movesLeft: 1 },
OOX_X: { finalOutcome: FinalOutcome.WinX, movesLeft: 1 },
O_XOX: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
_OXXO: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
_OXOX: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
OO_XX: { finalOutcome: FinalOutcome.WinX, movesLeft: 1 },
O_OXX: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
_OOXX: { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
XOXOX: { finalOutcome: FinalOutcome.Draw, movesLeft: 0 },
XOXXO: { finalOutcome: FinalOutcome.Draw, movesLeft: 0 },
XOOXX: { finalOutcome: FinalOutcome.Draw, movesLeft: 0 },
XXOOX: { finalOutcome: FinalOutcome.Draw, movesLeft: 0 },
XXOXO: { finalOutcome: FinalOutcome.Draw, movesLeft: 0 },
XXXOO: { finalOutcome: FinalOutcome.WinX, movesLeft: 0 },
OXXOX: { finalOutcome: FinalOutcome.Draw, movesLeft: 0 },
OXXXO: { finalOutcome: FinalOutcome.WinX, movesLeft: 0 },
OXOXX: { finalOutcome: FinalOutcome.Draw, movesLeft: 0 },
OOXXX: { finalOutcome: FinalOutcome.WinX, movesLeft: 0 },
});
checkSolutionsComplete(2, 2, {
"__|__": { finalOutcome: FinalOutcome.Draw, movesLeft: 4 },
"X_|__": { finalOutcome: FinalOutcome.Draw, movesLeft: 3 },
"XO|__": { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
"X_|O_": { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
"X_|_O": { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
"_X|__": { finalOutcome: FinalOutcome.Draw, movesLeft: 3 },
"OX|__": { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
"_X|O_": { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
"_X|_O": { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
"__|X_": { finalOutcome: FinalOutcome.Draw, movesLeft: 3 },
"O_|X_": { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
"_O|X_": { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
"__|XO": { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
"__|_X": { finalOutcome: FinalOutcome.Draw, movesLeft: 3 },
"O_|_X": { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
"_O|_X": { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
"__|OX": { finalOutcome: FinalOutcome.Draw, movesLeft: 2 },
"XO|X_": { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
"XO|_X": { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
"XX|O_": { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
"X_|OX": { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
"XX|_O": { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
"X_|XO": { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
"OX|X_": { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
"OX|_X": { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
"_X|OX": { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
"_X|XO": { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
"O_|XX": { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
"_O|XX": { finalOutcome: FinalOutcome.Draw, movesLeft: 1 },
"XO|XO": { finalOutcome: FinalOutcome.Draw, movesLeft: 0 },
"XO|OX": { finalOutcome: FinalOutcome.Draw, movesLeft: 0 },
"XX|OO": { finalOutcome: FinalOutcome.Draw, movesLeft: 0 },
"OX|XO": { finalOutcome: FinalOutcome.Draw, movesLeft: 0 },
"OX|OX": { finalOutcome: FinalOutcome.Draw, movesLeft: 0 },
"OO|XX": { finalOutcome: FinalOutcome.Draw, movesLeft: 0 },
});
// number 5478 taken from https://math.stackexchange.com/a/613505
checkSolutionsIncomplete(3, 3, 5478, {
"___|___|___": { finalOutcome: FinalOutcome.Draw, movesLeft: 9 },
"X__|___|___": { finalOutcome: FinalOutcome.Draw, movesLeft: 8 },
"_X_|___|___": { finalOutcome: FinalOutcome.Draw, movesLeft: 8 },
"___|_X_|___": { finalOutcome: FinalOutcome.Draw, movesLeft: 8 },
"XO_|___|___": { finalOutcome: FinalOutcome.WinX, movesLeft: 5 },
"X__|___|_O_": { finalOutcome: FinalOutcome.WinX, movesLeft: 5 },
"X_O|___|___": { finalOutcome: FinalOutcome.WinX, movesLeft: 5 },
"X__|___|__O": { finalOutcome: FinalOutcome.WinX, movesLeft: 5 },
"OO_|___|_XX": { finalOutcome: FinalOutcome.WinX, movesLeft: 1 },
"OO_|__X|_XX": { finalOutcome: FinalOutcome.WinO, movesLeft: 1 },
});
});

@ -1,5 +1,16 @@
import { Board } from "./board.ts";
import { CurrentOutcome, SquareState } from "./types.ts";
import {
CurrentOutcome,
ExpectedOutcome,
FinalOutcome,
Player,
SquareState,
getDesiredFinalOutcomeByPlayer,
getExpectedOutcomeByCurrentOutcome,
getNextPlayer,
getOccupiedStateByPlayer,
getUndesiredFinalOutcomeByPlayer,
} from "./datatypes.ts";
export const getSequenceOutcome = (sequence: SquareState[]) => {
for (let i = 1; i < sequence.length; i++) {
@ -88,3 +99,127 @@ export const getBoardOutcome = (board: Board) => {
return CurrentOutcome.Draw;
};
export const getPreferredNextOutcome = (
possibleNextOutcomes: ExpectedOutcome[],
currentPlayer: Player,
): ExpectedOutcome => {
{
const desiredFinalOutcome = getDesiredFinalOutcomeByPlayer(currentPlayer);
let bestDesiredNextOutcome: ExpectedOutcome | null = null;
for (const nextOutcome of possibleNextOutcomes) {
if (nextOutcome.finalOutcome === desiredFinalOutcome) {
// If there are multiple winning options, give preference to the one that wins in less moves
if (
!bestDesiredNextOutcome ||
bestDesiredNextOutcome.movesLeft > nextOutcome.movesLeft
) {
bestDesiredNextOutcome = nextOutcome;
}
}
}
if (bestDesiredNextOutcome) {
return bestDesiredNextOutcome;
}
}
{
const leastDesiredFinalOutcome =
getUndesiredFinalOutcomeByPlayer(currentPlayer);
for (const undesiredFinalOutcome of [
FinalOutcome.Draw,
leastDesiredFinalOutcome,
]) {
let leastBadUndesiredNextOutcome: ExpectedOutcome | null = null;
for (const nextOutcome of possibleNextOutcomes) {
if (nextOutcome.finalOutcome === undesiredFinalOutcome) {
// If there are multiple draw options, or multiple losing options,
// give preference to the one that protracts for most moves
if (
!leastBadUndesiredNextOutcome ||
leastBadUndesiredNextOutcome.movesLeft < nextOutcome.movesLeft
) {
leastBadUndesiredNextOutcome = nextOutcome;
}
}
}
if (leastBadUndesiredNextOutcome) {
return leastBadUndesiredNextOutcome;
}
}
}
throw new Error(
`Could not find the best outcome out of ${possibleNextOutcomes.length} possible next outcomes`,
);
};
export const computeAllSolutions = (rows: number, columns: number) => {
const expectedOutcomesByBoard = new Map<string, ExpectedOutcome>();
const getExpectedOutcomeForBoard = (
board: Board,
currentPlayer: Player,
): ExpectedOutcome => {
// assuming that currentPlayer is always the same for the same board
// (which is true, because for correct board states, currentPlayer is determined by
// parity of the number of occupied cells)
const key = board.serialize();
// Short-circuit if this board is already cached
{
if (expectedOutcomesByBoard.has(key)) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we have just checked that it exists.
return expectedOutcomesByBoard.get(key)!;
}
}
// Short-circuit if this board is full
{
const currentOutcome = getBoardOutcome(board);
if (currentOutcome !== CurrentOutcome.Undecided) {
const expectedOutcome =
getExpectedOutcomeByCurrentOutcome(currentOutcome);
expectedOutcomesByBoard.set(key, expectedOutcome);
return expectedOutcome;
}
}
const possibleNextOutcomes: ExpectedOutcome[] = [];
{
const nextPlayer = getNextPlayer(currentPlayer);
const occupiedState = getOccupiedStateByPlayer(currentPlayer);
for (let row = 0; board.hasRow(row); row++) {
for (let column = 0; board.hasSquare(row, column); column++) {
if (board.get(row, column) === SquareState.Unoccupied) {
// Recursion is not a problem here, because every time the number of occupied squares increases,
// therefore the depth of recursion is limited by the number of originally free squares
const nextOutcome = getExpectedOutcomeForBoard(
board.with(row, column, occupiedState),
nextPlayer,
);
possibleNextOutcomes.push(nextOutcome);
}
}
}
}
const preferredNextOutcome = getPreferredNextOutcome(
possibleNextOutcomes,
currentPlayer,
);
const expectedOutcome: ExpectedOutcome = {
finalOutcome: preferredNextOutcome.finalOutcome,
movesLeft: preferredNextOutcome.movesLeft + 1,
};
expectedOutcomesByBoard.set(key, expectedOutcome);
return expectedOutcome;
};
void getExpectedOutcomeForBoard(Board.createEmpty(rows, columns), Player.X);
return expectedOutcomesByBoard;
};

@ -1,12 +0,0 @@
export enum SquareState {
Unoccupied = 1, // so that all SquareState values are truthy
X,
O,
}
export enum CurrentOutcome {
Undecided = 1,
Draw,
WinX,
WinO,
}
Loading…
Cancel
Save