diff --git a/eslint.config.js b/eslint.config.js index b64a17a..aa267e0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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", }, }, diff --git a/src/lib/board.spec.ts b/src/lib/board.spec.ts index 6084b3d..1afe1d7 100644 --- a/src/lib/board.spec.ts +++ b/src/lib/board.spec.ts @@ -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", }); diff --git a/src/lib/board.ts b/src/lib/board.ts index da17a9c..9077599 100644 --- a/src/lib/board.ts +++ b/src/lib/board.ts @@ -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) => diff --git a/src/lib/datatypes.spec.ts b/src/lib/datatypes.spec.ts new file mode 100644 index 0000000..5f5e28b --- /dev/null +++ b/src/lib/datatypes.spec.ts @@ -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", + }); +}); diff --git a/src/lib/datatypes.ts b/src/lib/datatypes.ts new file mode 100644 index 0000000..765f08d --- /dev/null +++ b/src/lib/datatypes.ts @@ -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, +): 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}`); + } +}; diff --git a/src/lib/solver.spec.ts b/src/lib/solver.spec.ts index 853e46a..b4c679b 100644 --- a/src/lib/solver.spec.ts +++ b/src/lib/solver.spec.ts @@ -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, + ) => { + t.matchOnlyStrict( + Object.fromEntries(computeAllSolutions(rows, columns).entries()), + expectedSolutions, + ); + }; + + const checkSolutionsIncomplete = ( + rows: number, + columns: number, + expectedSolutionsCount: number, + expectedSolutionsIncomplete: Record, + ) => { + 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 }, + }); }); diff --git a/src/lib/solver.ts b/src/lib/solver.ts index f5e3c47..5d1f90b 100644 --- a/src/lib/solver.ts +++ b/src/lib/solver.ts @@ -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(); + + 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; +}; diff --git a/src/lib/types.ts b/src/lib/types.ts deleted file mode 100644 index a4f63bd..0000000 --- a/src/lib/types.ts +++ /dev/null @@ -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, -}