|
|
|
/** |
|
* ############################################################### |
|
* Tic Tac Toe Game code |
|
* ############################################################### |
|
*/ |
|
|
|
enum Player { |
|
X = "X", |
|
O = "O", |
|
} |
|
|
|
enum TicTacToeGameMode { |
|
Random = "random", |
|
Strategic = "strategic", |
|
} |
|
|
|
type NullablePlayer = Player | null; |
|
|
|
type BoardRow = NullablePlayer[]; |
|
type BoardCellGroup = NullablePlayer[]; |
|
type BoardCellGroupType = "row" | "column" | "diagonal"; |
|
|
|
type Result = "X Wins!" | "O Wins!" | "Draw"; |
|
|
|
class Config { |
|
readonly minBoardSize = 2; |
|
readonly maxBoardSize = 10; |
|
|
|
roundsOfGamesToRun = 5; |
|
|
|
// Board size is square: boardSize x boardSize |
|
boardSize = 3; |
|
|
|
// Choose between random and strategic modes for the computer to operate with: |
|
defaultGameMode: TicTacToeGameMode = TicTacToeGameMode.Random; |
|
|
|
// X is always first, expect during state aware games where the loser of the last game goes first (if Config.loserPlaysFirst is true): |
|
firstPlayer: Player = Player.X; |
|
|
|
// For state aware games: |
|
lastPlayerToWin?: Player; |
|
loserPlaysFirst: boolean | null = true; |
|
|
|
// For strategic mode: |
|
oneInXOddOfMissingWinningMove = 10; |
|
} |
|
|
|
const CONFIG = new Config(); |
|
|
|
interface IGameRecord { |
|
firstPlayer: Player; |
|
mode: TicTacToeGameMode; |
|
boardSize: number; |
|
winner: null | Player; |
|
board: BoardRow[]; |
|
winningCellGroupName: null | string; |
|
winningCellGroupType: null | BoardCellGroupType; |
|
gameWinningMistakesMade: number; |
|
} |
|
|
|
class GameRecords extends Array<IGameRecord> { |
|
getLastGameRecord = () => { |
|
const lastGameRecord = [...this].reverse()[0]; |
|
|
|
return lastGameRecord; |
|
}; |
|
|
|
getLastGameRecordWon = () => { |
|
const lastGameRecord = [ |
|
...this.filter((gameRecord) => gameRecord.winner !== null), |
|
].reverse()[0]; |
|
|
|
return lastGameRecord; |
|
}; |
|
|
|
getLastWinner = () => { |
|
const lastGameRecord = this.getLastGameRecordWon(); |
|
|
|
if (!lastGameRecord) { |
|
return null; |
|
} |
|
|
|
return lastGameRecord.winner; |
|
}; |
|
|
|
getScores = () => { |
|
return { |
|
[Player.X]: this.filter((game) => game.winner === Player.X).length, |
|
[Player.O]: this.filter((game) => game.winner === Player.O).length, |
|
draws: this.filter((game) => game.winner === null).length, |
|
}; |
|
}; |
|
|
|
getStats = () => { |
|
const gamesWithoutWinners = this.filter((game) => !game.winner); |
|
const gamesWithWinners = this.filter((game) => game.winner); |
|
const strategicGamesWithWinners = gamesWithWinners.filter( |
|
(game) => game.mode === TicTacToeGameMode.Strategic, |
|
); |
|
const randomGamesWithWinners = gamesWithWinners.filter( |
|
(game) => game.mode === TicTacToeGameMode.Random, |
|
); |
|
|
|
const wonByFirstPlayers = (games: IGameRecord[]) => |
|
games.filter((game) => game.firstPlayer === game.winner); |
|
|
|
const wonBySecondPlayers = (games: IGameRecord[]) => |
|
games.filter((game) => game.firstPlayer !== game.winner); |
|
|
|
const stats = { |
|
games: this.length, |
|
gamesWithMistakesMade: this.reduce( |
|
(acc, game) => acc + (game.gameWinningMistakesMade ? 1 : 0), |
|
0, |
|
), |
|
gamesWithMistakesMadeThatDrawed: this.reduce( |
|
(acc, game) => |
|
acc + (game.gameWinningMistakesMade && !game.winner ? 1 : 0), |
|
0, |
|
), |
|
gamesWithMistakesMadeThatWereWon: this.reduce( |
|
(acc, game) => |
|
acc + (game.gameWinningMistakesMade && !!game.winner ? 1 : 0), |
|
0, |
|
), |
|
gameWinningMistakesMade: this.reduce( |
|
(acc, game) => acc + game.gameWinningMistakesMade, |
|
0, |
|
), |
|
gamesInStrategicMode: this.filter( |
|
(game) => game.mode === TicTacToeGameMode.Strategic, |
|
).length, |
|
drawsInStrategicMode: gamesWithoutWinners.filter( |
|
(game) => game.mode === TicTacToeGameMode.Strategic, |
|
).length, |
|
gamesInRandomMode: this.filter( |
|
(game) => game.mode === TicTacToeGameMode.Random, |
|
).length, |
|
drawsInRandomMode: gamesWithoutWinners.filter( |
|
(game) => game.mode === TicTacToeGameMode.Random, |
|
).length, |
|
firstPlayer: wonByFirstPlayers(gamesWithWinners).length, |
|
secondPlayer: wonBySecondPlayers(gamesWithWinners).length, |
|
firstPlayerInStrategicMode: wonByFirstPlayers(strategicGamesWithWinners) |
|
.length, |
|
secondPlayerInStrategicMode: wonBySecondPlayers(strategicGamesWithWinners) |
|
.length, |
|
firstPlayerInRandomMode: wonByFirstPlayers(randomGamesWithWinners).length, |
|
secondPlayerInRandomMode: wonBySecondPlayers(randomGamesWithWinners) |
|
.length, |
|
}; |
|
|
|
return { |
|
...stats, |
|
draws: gamesWithoutWinners.length, |
|
firstPlayerPercent: stats.firstPlayer / gamesWithWinners.length, |
|
secondPlayerPercent: stats.secondPlayer / gamesWithWinners.length, |
|
firstPlayerInStrategicPercent: |
|
stats.firstPlayerInStrategicMode / strategicGamesWithWinners.length, |
|
secondPlayerInStrategicPercent: |
|
stats.secondPlayerInStrategicMode / strategicGamesWithWinners.length, |
|
firstPlayerInRandomPercent: |
|
stats.firstPlayerInRandomMode / randomGamesWithWinners.length, |
|
secondPlayerInRandomPercent: |
|
stats.secondPlayerInRandomMode / randomGamesWithWinners.length, |
|
}; |
|
}; |
|
} |
|
|
|
const GAME_RECORDS = new GameRecords(); |
|
|
|
const addGameRecord = (gameRecord: IGameRecord) => |
|
GAME_RECORDS.push(gameRecord); |
|
|
|
const shuffleArray = (array: Array<unknown>) => { |
|
for (let i = array.length - 1; i > 0; i--) { |
|
const j = Math.floor(Math.random() * (i + 1)); |
|
[array[i], array[j]] = [array[j], array[i]]; |
|
} |
|
}; |
|
|
|
const formatPercent = (value: number) => { |
|
return `${(value * 100).toFixed(2).replace(/\.0+$/, "")}%`; |
|
}; |
|
|
|
const createBoardRowColumns = (boardSize: number): BoardRow => { |
|
return Array.from({ |
|
length: boardSize, |
|
}).map(() => null); |
|
}; |
|
|
|
const createBoardRows = (boardSize: number): BoardRow[] => { |
|
return Array.from({ length: boardSize }).map( |
|
(): BoardRow => createBoardRowColumns(boardSize), |
|
); |
|
}; |
|
|
|
const hasPlayerWonCellGroup = (player: Player, cellGroup: BoardCellGroup) => { |
|
return cellGroup.every((cell) => cell === player); |
|
}; |
|
|
|
const hasPlayerWonAnyCellGroup = ( |
|
player: Player, |
|
cellGroups: BoardCellGroup[], |
|
) => { |
|
return cellGroups.some((cellGroup) => |
|
hasPlayerWonCellGroup(player, cellGroup), |
|
); |
|
}; |
|
|
|
const formatCellGroupOutput = (cellGroup: BoardCellGroup) => { |
|
return `|${cellGroup.map((cell) => cell ?? " ").join("|")}|`; |
|
}; |
|
|
|
interface ITicTacToeBoardOptions { |
|
boardSize?: number; |
|
rows?: BoardRow[]; |
|
} |
|
|
|
class TicTacToeBoard extends Array<BoardRow> { |
|
constructor(private options: ITicTacToeBoardOptions) { |
|
super( |
|
...(options.rows |
|
? options.rows |
|
: createBoardRows(options.boardSize ?? CONFIG.boardSize)), |
|
); |
|
|
|
if (options.rows && options.rows.length !== this.boardSize) { |
|
throw new Error( |
|
`Mismatch in provided rows (${options.rows.length}) for board of size ${this.boardSize}`, |
|
); |
|
} |
|
|
|
const invalidRowIndex = this.findIndex( |
|
(row) => row.length !== this.boardSize, |
|
); |
|
|
|
if (invalidRowIndex >= 0) { |
|
const invalidRow = this[invalidRowIndex]; |
|
throw new Error( |
|
`Mismatch in provided columns (${invalidRow.length}) in row #${invalidRowIndex} (zero-indexed) for board of size ${this.boardSize}`, |
|
); |
|
} |
|
|
|
if (this.boardSize > CONFIG.maxBoardSize) { |
|
throw new Error( |
|
`A maximum board size of ${CONFIG.maxBoardSize} is supported, but the game was started with ${options.boardSize}.`, |
|
); |
|
} |
|
|
|
if (this.boardSize < CONFIG.minBoardSize) { |
|
throw new Error( |
|
`A minimum board size of ${CONFIG.minBoardSize} is supported, but the game was started with ${options.boardSize}.`, |
|
); |
|
} |
|
} |
|
|
|
readonly boardSize = this.options.boardSize ?? CONFIG.boardSize; |
|
|
|
get numberOfMovesMade() { |
|
let moves = 0; |
|
|
|
for (const row of this) { |
|
moves += row.filter((player) => player !== null).length; |
|
} |
|
|
|
return moves; |
|
} |
|
|
|
get numberOfAvailableMovesLeft() { |
|
return this.reduce<number>((acc, row) => { |
|
return acc + row.filter((player) => player === null).length; |
|
}, 0); |
|
} |
|
|
|
doesPlayerHaveAWinningRow(player: Player) { |
|
return hasPlayerWonAnyCellGroup(player, this); |
|
} |
|
|
|
getColumn(columnIndex: number): BoardCellGroup { |
|
return this.reduce((acc, row) => { |
|
acc.push(row[columnIndex]); |
|
return acc; |
|
}, []); |
|
} |
|
|
|
doesPlayerHaveAWinningColumn(player: Player) { |
|
for (let columnIndex = 0; columnIndex < this.boardSize; columnIndex++) { |
|
const column = this.getColumn(columnIndex); |
|
|
|
if (hasPlayerWonCellGroup(player, column)) { |
|
return true; |
|
} |
|
} |
|
|
|
return false; |
|
} |
|
|
|
doesPlayerHaveAWinningDiagonal(player: Player) { |
|
const cellGroups = this.getDiagonals(); |
|
|
|
return hasPlayerWonAnyCellGroup(player, Object.values(cellGroups)); |
|
} |
|
|
|
doesPlayerHaveAWinningCellGroup(player: Player) { |
|
if (this.doesPlayerHaveAWinningRow(player)) { |
|
return true; |
|
} |
|
|
|
if (this.doesPlayerHaveAWinningColumn(player)) { |
|
return true; |
|
} |
|
|
|
if (this.doesPlayerHaveAWinningDiagonal(player)) { |
|
return true; |
|
} |
|
|
|
return false; |
|
} |
|
|
|
getDiagonals(): { |
|
topLeftToBottomRight: BoardCellGroup; |
|
topRightToBottomLeft: BoardCellGroup; |
|
} { |
|
const topLeftToBottomRight: BoardCellGroup = []; |
|
const topRightToBottomLeft: BoardCellGroup = []; |
|
|
|
for (let index = 0; index < this.boardSize; index++) { |
|
topLeftToBottomRight.push(this[index][index]); |
|
|
|
topRightToBottomLeft.push(this[this.boardSize - 1 - index][index]); |
|
} |
|
|
|
return { |
|
topLeftToBottomRight, |
|
topRightToBottomLeft, |
|
}; |
|
} |
|
|
|
get asConsoleOutput(): string { |
|
const output = [...this] |
|
.map((row) => { |
|
return formatCellGroupOutput(row); |
|
}) |
|
.join("\n"); |
|
return output; |
|
} |
|
|
|
getColumns() { |
|
return Array.from({ |
|
length: this.boardSize, |
|
}).map((_, index) => this.getColumn(index)); |
|
} |
|
|
|
getRows() { |
|
return [...this]; |
|
} |
|
|
|
getCellGroups(): Record<string, BoardCellGroup> { |
|
const diagonals = this.getDiagonals(); |
|
return { |
|
...this.getColumns().reduce((acc, cellGroup, index) => { |
|
return { |
|
...acc, |
|
[`Column #${index}`]: cellGroup, |
|
}; |
|
}, {}), |
|
...this.getRows().reduce((acc, cellGroup, index) => { |
|
return { |
|
...acc, |
|
[`Row #${index}`]: cellGroup, |
|
}; |
|
}, {}), |
|
"Top left to bottom right diagonal": diagonals.topLeftToBottomRight, |
|
"Top right to bottom left diagonal": diagonals.topRightToBottomLeft, |
|
}; |
|
} |
|
|
|
getWinningCellGroup( |
|
player: Player, |
|
): null | { |
|
name: string; |
|
type: BoardCellGroupType; |
|
cellGroup: BoardCellGroup; |
|
} { |
|
const winningCellGroup = Object.entries(this.getCellGroups()).find( |
|
([name, cellGroup]) => { |
|
return hasPlayerWonCellGroup(player, cellGroup); |
|
}, |
|
); |
|
|
|
if (winningCellGroup) { |
|
const type = this.doesPlayerHaveAWinningColumn(player) |
|
? "column" |
|
: this.doesPlayerHaveAWinningRow(player) |
|
? "row" |
|
: this.doesPlayerHaveAWinningDiagonal(player) |
|
? "diagonal" |
|
: null; |
|
|
|
if (!type) { |
|
throw new Error( |
|
`Unable to find winning cell group type for ${winningCellGroup[0]}`, |
|
); |
|
} |
|
|
|
return { |
|
name: winningCellGroup[0], |
|
cellGroup: winningCellGroup[1], |
|
type, |
|
}; |
|
} |
|
|
|
return null; |
|
} |
|
} |
|
|
|
const getNextPlayer = (player: Player) => { |
|
switch (player) { |
|
case Player.X: |
|
return Player.O; |
|
case Player.O: |
|
return Player.X; |
|
default: |
|
throw new Error( |
|
`"${player}" is not a valid player. Choices are: ${Object.values( |
|
Player, |
|
).join(", ")}`, |
|
); |
|
} |
|
}; |
|
|
|
const getRandomNumber = (max: number) => { |
|
return Math.floor(Math.random() * max); |
|
}; |
|
|
|
const getDefaultFirstPlayer = () => { |
|
const lastPlayerToWin = GAME_RECORDS.getLastWinner(); |
|
|
|
if (!lastPlayerToWin) { |
|
return CONFIG.firstPlayer; |
|
} |
|
|
|
if (CONFIG.loserPlaysFirst) { |
|
return getNextPlayer(lastPlayerToWin); |
|
} |
|
|
|
return lastPlayerToWin; |
|
}; |
|
|
|
interface ITicTacToeGameOptions extends ITicTacToeBoardOptions { |
|
firstPlayer?: Player; |
|
enableState?: boolean; |
|
mode?: TicTacToeGameMode; |
|
printEveryMove?: boolean; |
|
} |
|
|
|
class TicTacToeGame { |
|
constructor( |
|
private options: ITicTacToeGameOptions = { |
|
boardSize: CONFIG.boardSize, |
|
firstPlayer: CONFIG.firstPlayer, |
|
mode: CONFIG.defaultGameMode, |
|
printEveryMove: false, |
|
}, |
|
private board: TicTacToeBoard = new TicTacToeBoard(options), |
|
private currentPlayer: Player = options.firstPlayer ?? options.enableState |
|
? getDefaultFirstPlayer() |
|
: CONFIG.firstPlayer, |
|
) { |
|
if (!this.options.firstPlayer) { |
|
this.options.firstPlayer = this.currentPlayer ?? CONFIG.firstPlayer; |
|
} |
|
|
|
if (!this.options.boardSize) { |
|
this.options.boardSize = CONFIG.boardSize; |
|
} |
|
|
|
if (!this.options.mode) { |
|
this.options.mode = CONFIG.defaultGameMode; |
|
} |
|
|
|
if (this.boardSize !== board.boardSize) { |
|
throw new Error( |
|
`The Tic Tac Toe game was started with a board size of ${options.boardSize}, but a custom board was used that has a size of ${board.boardSize}. Please ensure the game and the board are of the same size.`, |
|
); |
|
} |
|
} |
|
|
|
get firstPlayer() { |
|
return this.options.firstPlayer ?? CONFIG.firstPlayer; |
|
} |
|
|
|
get mode() { |
|
return this.options.mode ?? CONFIG.defaultGameMode; |
|
} |
|
|
|
get boardSize() { |
|
return this.options.boardSize ?? CONFIG.boardSize; |
|
} |
|
|
|
public getCellGroups(): Record<string, BoardCellGroup> { |
|
return this.board.getCellGroups(); |
|
} |
|
|
|
private switchPlayer() { |
|
this.setPlayer(getNextPlayer(this.currentPlayer)); |
|
} |
|
|
|
public setPlayer(player: Player) { |
|
this.currentPlayer = player; |
|
} |
|
|
|
private printBoard() { |
|
console.log(this.asConsoleOutput); |
|
} |
|
|
|
private isValidMove(row: number, column: number) { |
|
return this.board[row][column] === null; |
|
} |
|
|
|
private updateBoardWithMove(row: number, column: number, player: Player) { |
|
if (!this.isValidMove(row, column)) { |
|
throw new Error( |
|
`Board move of ${player} to row, column location of ${row}:${column} is invalid as ${this.board[row][column]} is already there.`, |
|
); |
|
} |
|
return (this.board[row][column] = player); |
|
} |
|
|
|
private undoBoardMove(row: number, column: number) { |
|
return (this.board[row][column] = null); |
|
} |
|
|
|
private findWinningMove(player: Player): [number, number] | null { |
|
for (let row = 0; row < this.boardSize; row++) { |
|
for (let column = 0; column < this.boardSize; column++) { |
|
if (this.isValidMove(row, column)) { |
|
this.updateBoardWithMove(row, column, player); |
|
const isWinning = this.board.doesPlayerHaveAWinningCellGroup(player); |
|
this.undoBoardMove(row, column); |
|
if (isWinning) { |
|
return [row, column]; |
|
} |
|
} |
|
} |
|
} |
|
return null; |
|
} |
|
|
|
private getStrategicMoveSuggestions(): [number, number][] { |
|
const moves: [number, number][] = []; |
|
|
|
const isBoardSizeEven = this.boardSize % 2 === 1; |
|
|
|
// Center |
|
if (isBoardSizeEven) { |
|
const center = Math.floor(this.boardSize / 2); |
|
moves.push([center, center]); |
|
} |
|
|
|
// Corners |
|
if (isBoardSizeEven) { |
|
moves.push( |
|
[0, 0], |
|
[0, this.boardSize - 1], |
|
[this.boardSize - 1, 0], |
|
[this.boardSize - 1, this.boardSize - 1], |
|
); |
|
} |
|
|
|
// Remaining cells |
|
const remainingCells: [number, number][] = []; |
|
for (let i = 0; i < this.boardSize; i++) { |
|
for (let j = 0; j < this.boardSize; j++) { |
|
if (!remainingCells.some(([x, y]) => x === i && y === j)) { |
|
remainingCells.push([i, j]); |
|
} |
|
} |
|
} |
|
|
|
shuffleArray(remainingCells); |
|
|
|
moves.push(...remainingCells); |
|
|
|
return moves; |
|
} |
|
|
|
public gameWinningMistakesMade = { |
|
[Player.X]: 0, |
|
[Player.O]: 0, |
|
}; |
|
|
|
private makeStrategicMove(): void { |
|
// Introduce a 1 in X possibility that the computer overlooks the winning move: |
|
const willTheComputerMakeAMistake = |
|
getRandomNumber(CONFIG.oneInXOddOfMissingWinningMove) === 1; |
|
|
|
// Check for a winning move |
|
let move = this.findWinningMove(this.currentPlayer); |
|
if (move) { |
|
if (willTheComputerMakeAMistake) { |
|
this.gameWinningMistakesMade[this.currentPlayer]++; |
|
} else { |
|
this.updateBoardWithMove(move[0], move[1], this.currentPlayer); |
|
} |
|
return; |
|
} |
|
|
|
// Check if opponent is about to win and block |
|
const opponent = getNextPlayer(this.currentPlayer); |
|
move = this.findWinningMove(opponent); |
|
if (move) { |
|
if (willTheComputerMakeAMistake) { |
|
this.gameWinningMistakesMade[this.currentPlayer]++; |
|
} else { |
|
this.updateBoardWithMove(move[0], move[1], this.currentPlayer); |
|
} |
|
return; |
|
} |
|
|
|
// Pick center, corners, then sides |
|
for (const [row, column] of this.getStrategicMoveSuggestions()) { |
|
if (this.isValidMove(row, column)) { |
|
this.updateBoardWithMove(row, column, this.currentPlayer); |
|
return; |
|
} |
|
} |
|
} |
|
|
|
private makeRandomMove() { |
|
let row: number; |
|
let column: number; |
|
|
|
// Continually generate a random number until there is a valid move |
|
do { |
|
(row = getRandomNumber(this.boardSize)), |
|
(column = getRandomNumber(this.boardSize)); |
|
} while (!this.isValidMove(row, column)); |
|
|
|
// Now that a valid move has been found, update the board |
|
this.updateBoardWithMove(row, column, this.currentPlayer); |
|
} |
|
|
|
private makeMove() { |
|
switch (this.mode) { |
|
case TicTacToeGameMode.Random: |
|
return this.makeRandomMove(); |
|
case TicTacToeGameMode.Strategic: |
|
return this.makeStrategicMove(); |
|
default: |
|
throw new Error( |
|
`"${ |
|
this.mode |
|
}" is not a valid game mode. Choices are: ${Object.values( |
|
TicTacToeGameMode, |
|
).join(", ")}`, |
|
); |
|
} |
|
} |
|
|
|
public getGameResult(): Result | null { |
|
// Winning states are: |
|
// - If a row is fully owned by a player |
|
// - If a column is fully owned by a Player |
|
// - If a diagonal is fully owned by a player |
|
|
|
if (this.board.doesPlayerHaveAWinningCellGroup(this.currentPlayer)) { |
|
return `${this.currentPlayer} Wins!` as const; |
|
} |
|
|
|
// Draw state if no available moves: |
|
if (this.board.numberOfAvailableMovesLeft === 0) { |
|
return "Draw"; |
|
} |
|
|
|
return null; |
|
} |
|
|
|
public get asConsoleOutput() { |
|
return this.board.asConsoleOutput; |
|
} |
|
|
|
public playGame() { |
|
console.log(" "); |
|
console.log( |
|
`${this.boardSize}x${this.boardSize} game started by ${this.currentPlayer} in ${this.mode} mode.`, |
|
); |
|
if (this.options.enableState) { |
|
const lastWinner = GAME_RECORDS.getLastWinner(); |
|
console.log( |
|
lastWinner |
|
? ` ${lastWinner} won the last winning game.` |
|
: GAME_RECORDS.getLastGameRecord() |
|
? " No games have been won yet." |
|
: " This is the first game on the record.", |
|
); |
|
} |
|
while (true) { |
|
this.makeMove(); |
|
|
|
const result = this.getGameResult(); |
|
|
|
if (this.options.printEveryMove || result) { |
|
console.log(" "); |
|
this.printBoard(); |
|
} |
|
|
|
// Winner winner, chicken dinner: |
|
if (result !== null) { |
|
console.log(" "); |
|
console.log(result); |
|
if (this.options.enableState) { |
|
if (result !== "Draw") { |
|
CONFIG.lastPlayerToWin = this.currentPlayer; |
|
} |
|
const winningCellGroup = |
|
result === "Draw" |
|
? null |
|
: this.board.getWinningCellGroup(this.currentPlayer); |
|
if (!winningCellGroup && result !== "Draw") { |
|
throw new Error(`Cannot find the winning cell group for the game.`); |
|
} |
|
addGameRecord({ |
|
board: [...this.board], |
|
boardSize: this.boardSize, |
|
mode: this.mode, |
|
firstPlayer: this.firstPlayer, |
|
winner: result === "Draw" ? null : this.currentPlayer, |
|
winningCellGroupName: winningCellGroup |
|
? winningCellGroup.name |
|
: null, |
|
winningCellGroupType: winningCellGroup |
|
? winningCellGroup.type |
|
: null, |
|
gameWinningMistakesMade: |
|
this.gameWinningMistakesMade[Player.O] + |
|
this.gameWinningMistakesMade[Player.X], |
|
}); |
|
} |
|
break; |
|
} |
|
|
|
// Switch the player |
|
this.switchPlayer(); |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* Run the game 10 times, for X number of rounds at different board sizes: |
|
* - in the default random mode |
|
* - in strategic mode |
|
*/ |
|
|
|
for (const i of Array.from({ length: CONFIG.roundsOfGamesToRun })) { |
|
for (const mode of [TicTacToeGameMode.Random, TicTacToeGameMode.Strategic]) { |
|
new TicTacToeGame({ |
|
enableState: true, |
|
mode, |
|
}).playGame(); |
|
|
|
new TicTacToeGame({ |
|
enableState: true, |
|
mode, |
|
}).playGame(); |
|
|
|
new TicTacToeGame({ |
|
enableState: true, |
|
mode, |
|
}).playGame(); |
|
|
|
new TicTacToeGame({ |
|
enableState: true, |
|
mode, |
|
}).playGame(); |
|
|
|
new TicTacToeGame({ |
|
enableState: true, |
|
mode, |
|
}).playGame(); |
|
|
|
new TicTacToeGame({ |
|
enableState: true, |
|
boardSize: 2, |
|
mode, |
|
}).playGame(); |
|
|
|
new TicTacToeGame({ |
|
boardSize: 2, |
|
enableState: true, |
|
mode, |
|
}).playGame(); |
|
|
|
new TicTacToeGame({ |
|
boardSize: 4, |
|
enableState: true, |
|
mode, |
|
}).playGame(); |
|
|
|
new TicTacToeGame({ |
|
boardSize: 5, |
|
enableState: true, |
|
mode, |
|
}).playGame(); |
|
|
|
new TicTacToeGame({ |
|
boardSize: 6, |
|
enableState: true, |
|
mode, |
|
}).playGame(); |
|
} |
|
} |
|
|
|
// Run the standard example last to show the exercise working: |
|
new TicTacToeGame({ |
|
enableState: false, |
|
printEveryMove: true, |
|
}).playGame(); |
|
|
|
const scores = GAME_RECORDS.getScores(); |
|
console.log( |
|
`\nScores:\n Draws: ${scores.draws}\n X: ${scores[Player.X]}\n O: ${ |
|
scores[Player.O] |
|
}`, |
|
); |
|
|
|
const stats = GAME_RECORDS.getStats(); |
|
console.log(`\nStats: |
|
Games: ${stats.games} |
|
Draws: ${stats.draws} (${formatPercent(stats.draws / stats.games)} of games) |
|
First player wins: ${stats.firstPlayer} (${formatPercent( |
|
stats.firstPlayerPercent, |
|
)} of wins) |
|
Second player wins: ${stats.secondPlayer} (${formatPercent( |
|
stats.secondPlayerPercent, |
|
)} of wins) |
|
|
|
Games played in strategic mode: ${stats.gamesInStrategicMode} |
|
Draws in strategic mode: ${stats.drawsInStrategicMode} (${formatPercent( |
|
stats.drawsInStrategicMode / stats.gamesInStrategicMode, |
|
)} of games) |
|
First player wins in strategic mode: ${ |
|
stats.firstPlayerInStrategicMode |
|
} (${formatPercent(stats.firstPlayerInStrategicPercent)} of wins) |
|
Second player wins in strategic mode: ${ |
|
stats.secondPlayerInStrategicMode |
|
} (${formatPercent(stats.secondPlayerInStrategicPercent)} of wins) |
|
Important mistakes made: ${stats.gameWinningMistakesMade} in ${ |
|
stats.gamesWithMistakesMade |
|
} game${stats.gamesWithMistakesMade !== 1 ? "s" : ""} (${formatPercent( |
|
stats.gamesWithMistakesMade / stats.gamesInStrategicMode, |
|
)} of strategic games had mistakes. The odds of making a mistake at any point during a game are ${formatPercent( |
|
1 / CONFIG.oneInXOddOfMissingWinningMove, |
|
)} or 1 in ${CONFIG.oneInXOddOfMissingWinningMove}. Mistakes led to ${ |
|
stats.gamesWithMistakesMadeThatWereWon |
|
} win${stats.gamesWithMistakesMadeThatWereWon !== 1 ? "s" : ""} and ${ |
|
stats.gamesWithMistakesMadeThatDrawed |
|
} draw${stats.gamesWithMistakesMadeThatDrawed !== 1 ? "s" : ""}.) |
|
|
|
Games played in random mode: ${stats.gamesInRandomMode} |
|
Draws in random mode: ${stats.drawsInRandomMode} (${formatPercent( |
|
stats.drawsInRandomMode / stats.gamesInRandomMode, |
|
)} of games) |
|
First player wins in random mode: ${ |
|
stats.firstPlayerInRandomMode |
|
} (${formatPercent(stats.firstPlayerInRandomPercent)} of wins) |
|
Second player wins in random mode: ${ |
|
stats.secondPlayerInRandomMode |
|
} (${formatPercent(stats.secondPlayerInRandomPercent)}) of wins`); |
|
|
|
/** |
|
* ############################################################### |
|
* Testing |
|
* ############################################################### |
|
*/ |
|
|
|
interface ITestCaseContext { |
|
assertCellGroup: (name: string, expectedCells: NullablePlayer[]) => void; |
|
} |
|
|
|
interface ITestCase { |
|
name: string; |
|
boardSize?: number; |
|
board: TicTacToeBoard; |
|
player?: Player; |
|
|
|
// One or two of these must be provided for the test case to run: |
|
result?: string | null; |
|
test?: (game: TicTacToeGame, context: ITestCaseContext) => void; |
|
onGameFailToStart?: (cause: Error | unknown) => void; |
|
} |
|
|
|
const TEST_CASES: ITestCase[] = [ |
|
{ |
|
name: "X wins middle column", |
|
board: new TicTacToeBoard({ |
|
rows: [ |
|
[null, Player.X, null], |
|
[null, Player.X, null], |
|
[null, Player.X, null], |
|
], |
|
}), |
|
result: "X Wins!", |
|
test(game) { |
|
assert.equal(game.asConsoleOutput, "| |X| |\n| |X| |\n| |X| |"); |
|
}, |
|
}, |
|
{ |
|
name: "X wins right column", |
|
board: new TicTacToeBoard({ |
|
rows: [ |
|
[null, null, Player.X], |
|
[null, null, Player.X], |
|
[null, null, Player.X], |
|
], |
|
}), |
|
result: "X Wins!", |
|
}, |
|
{ |
|
name: "X wins left column", |
|
board: new TicTacToeBoard({ |
|
rows: [ |
|
[Player.X, null, null], |
|
[Player.X, null, null], |
|
[Player.X, null, null], |
|
], |
|
}), |
|
result: "X Wins!", |
|
}, |
|
{ |
|
name: "X wins left column, but there is no result because it is not X's turn", |
|
board: new TicTacToeBoard({ |
|
rows: [ |
|
[Player.X, null, null], |
|
[Player.X, null, null], |
|
[Player.X, null, null], |
|
], |
|
}), |
|
result: null, |
|
player: Player.O, |
|
}, |
|
{ |
|
name: "X wins diagonal top left to bottom right", |
|
board: new TicTacToeBoard({ |
|
rows: [ |
|
[Player.X, null, null], |
|
[null, Player.X, null], |
|
[null, null, Player.X], |
|
], |
|
}), |
|
result: "X Wins!", |
|
}, |
|
{ |
|
name: "X wins diagonal top right to bottom left", |
|
board: new TicTacToeBoard({ |
|
rows: [ |
|
[null, null, Player.X], |
|
[null, Player.X, null], |
|
[Player.X, null, null], |
|
], |
|
}), |
|
result: "X Wins!", |
|
}, |
|
{ |
|
name: "O wins middle column", |
|
board: new TicTacToeBoard({ |
|
rows: [ |
|
[null, Player.O, null], |
|
[null, Player.O, null], |
|
[null, Player.O, null], |
|
], |
|
}), |
|
result: "O Wins!", |
|
player: Player.O, |
|
}, |
|
{ |
|
name: "O wins right column", |
|
board: new TicTacToeBoard({ |
|
rows: [ |
|
[null, null, Player.O], |
|
[null, null, Player.O], |
|
[null, null, Player.O], |
|
], |
|
}), |
|
result: "O Wins!", |
|
player: Player.O, |
|
}, |
|
{ |
|
name: "O wins left column", |
|
board: new TicTacToeBoard({ |
|
rows: [ |
|
[Player.O, null, null], |
|
[Player.O, null, null], |
|
[Player.O, null, null], |
|
], |
|
}), |
|
result: "O Wins!", |
|
player: Player.O, |
|
}, |
|
{ |
|
name: "O wins diagonal top left to bottom right", |
|
board: new TicTacToeBoard({ |
|
rows: [ |
|
[Player.O, null, null], |
|
[null, Player.O, null], |
|
[null, null, Player.O], |
|
], |
|
}), |
|
result: "O Wins!", |
|
player: Player.O, |
|
}, |
|
{ |
|
name: "O wins diagonal top right to bottom left", |
|
board: new TicTacToeBoard({ |
|
rows: [ |
|
[null, null, Player.O], |
|
[null, Player.O, null], |
|
[Player.O, null, null], |
|
], |
|
}), |
|
result: "O Wins!", |
|
player: Player.O, |
|
test(game, { assertCellGroup }) { |
|
assertCellGroup("Column #0", [null, null, Player.O]); |
|
assertCellGroup("Column #1", [null, Player.O, null]); |
|
assertCellGroup("Column #2", [Player.O, null, null]); |
|
assertCellGroup("Row #0", [null, null, Player.O]); |
|
assertCellGroup("Row #1", [null, Player.O, null]); |
|
assertCellGroup("Row #2", [Player.O, null, null]); |
|
assertCellGroup("Top left to bottom right diagonal", [ |
|
null, |
|
Player.O, |
|
null, |
|
]); |
|
assertCellGroup("Top right to bottom left diagonal", [ |
|
Player.O, |
|
Player.O, |
|
Player.O, |
|
]); |
|
|
|
assert.equal(game.getGameResult(), "O Wins!"); |
|
assert.equal(game.asConsoleOutput, "| | |O|\n| |O| |\n|O| | |"); |
|
}, |
|
}, |
|
{ |
|
name: "O wins diagonal top right to bottom left, but there is no result because it is not O's turn", |
|
board: new TicTacToeBoard({ |
|
rows: [ |
|
[null, null, Player.O], |
|
[null, Player.O, null], |
|
[Player.O, null, null], |
|
], |
|
}), |
|
result: null, |
|
player: Player.X, |
|
}, |
|
{ |
|
name: "No result yet for player X (1)", |
|
board: new TicTacToeBoard({ |
|
rows: [ |
|
[Player.X, null, Player.O], |
|
[null, Player.O, null], |
|
[Player.X, null, null], |
|
], |
|
}), |
|
result: null, |
|
player: Player.X, |
|
}, |
|
{ |
|
name: "No result yet for player X (2)", |
|
board: new TicTacToeBoard({ |
|
rows: [ |
|
[Player.O, Player.X, Player.O], |
|
[Player.X, Player.O, null], |
|
[Player.X, null, Player.X], |
|
], |
|
}), |
|
result: null, |
|
player: Player.X, |
|
}, |
|
{ |
|
name: "No result yet for player X (3)", |
|
board: new TicTacToeBoard({ |
|
rows: [ |
|
[null, null, null], |
|
[null, null, null], |
|
[null, null, null], |
|
], |
|
}), |
|
result: null, |
|
player: Player.X, |
|
}, |
|
{ |
|
name: "No result yet for player O (1)", |
|
board: new TicTacToeBoard({ |
|
rows: [ |
|
[Player.X, null, Player.O], |
|
[null, Player.O, null], |
|
[Player.X, null, null], |
|
], |
|
}), |
|
result: null, |
|
player: Player.O, |
|
}, |
|
{ |
|
name: "No result yet for player O (2)", |
|
board: new TicTacToeBoard({ |
|
rows: [ |
|
[Player.X, Player.O, Player.X], |
|
[null, Player.X, Player.O], |
|
[Player.X, Player.O, Player.O], |
|
], |
|
}), |
|
result: null, |
|
player: Player.O, |
|
}, |
|
{ |
|
name: "No result yet for player O (3)", |
|
board: new TicTacToeBoard({ |
|
rows: [ |
|
[null, null, null], |
|
[null, null, null], |
|
[null, null, null], |
|
], |
|
}), |
|
result: null, |
|
player: Player.O, |
|
}, |
|
{ |
|
name: "There is a draw (1)", |
|
board: new TicTacToeBoard({ |
|
rows: [ |
|
[Player.O, Player.X, Player.O], |
|
[Player.X, Player.O, Player.X], |
|
[Player.X, Player.O, Player.X], |
|
], |
|
}), |
|
result: "Draw", |
|
player: Player.X, |
|
}, |
|
{ |
|
name: "There is a draw (2)", |
|
board: new TicTacToeBoard({ |
|
rows: [ |
|
[Player.O, Player.X, Player.O], |
|
[Player.X, Player.O, Player.X], |
|
[Player.X, Player.O, Player.X], |
|
], |
|
}), |
|
result: "Draw", |
|
player: Player.O, |
|
}, |
|
{ |
|
name: "X wins", |
|
board: new TicTacToeBoard({ |
|
rows: [ |
|
[Player.X, Player.X, Player.O], |
|
[Player.X, Player.O, Player.X], |
|
[Player.X, Player.O, Player.X], |
|
], |
|
}), |
|
result: "X Wins!", |
|
player: Player.X, |
|
}, |
|
{ |
|
name: "O wins", |
|
board: new TicTacToeBoard({ |
|
rows: [ |
|
[Player.O, Player.X, Player.O], |
|
[Player.X, Player.O, Player.X], |
|
[Player.X, Player.O, Player.O], |
|
], |
|
}), |
|
result: "O Wins!", |
|
player: Player.O, |
|
}, |
|
{ |
|
name: "Throws error if there a board size mismatch", |
|
boardSize: 5, |
|
board: new TicTacToeBoard({ |
|
rows: [ |
|
[Player.O, Player.X, Player.O], |
|
[Player.X, Player.O, Player.X], |
|
[Player.X, Player.O, Player.O], |
|
], |
|
}), |
|
result: "O Wins!", |
|
player: Player.O, |
|
onGameFailToStart(cause) { |
|
assert.equal( |
|
cause instanceof Error ? cause.message : `${cause}`, |
|
"The Tic Tac Toe game was started with a board size of 5, but a custom board was used that has a size of 3. Please ensure the game and the board are of the same size.", |
|
); |
|
}, |
|
}, |
|
{ |
|
name: "2x2 has right output", |
|
boardSize: 2, |
|
board: new TicTacToeBoard({ |
|
boardSize: 2, |
|
rows: [ |
|
[Player.O, Player.X], |
|
[Player.X, null], |
|
], |
|
}), |
|
player: Player.O, |
|
test(game) { |
|
assert.equal(game.asConsoleOutput, "|O|X|\n|X| |"); |
|
}, |
|
}, |
|
{ |
|
name: "4x4 has right output", |
|
boardSize: 4, |
|
board: new TicTacToeBoard({ |
|
boardSize: 4, |
|
rows: [ |
|
[Player.X, null, Player.X, Player.X], |
|
[Player.O, Player.X, Player.X, null], |
|
[Player.O, Player.O, Player.X, Player.X], |
|
[Player.O, Player.X, Player.O, Player.X], |
|
], |
|
}), |
|
player: Player.X, |
|
result: "X Wins!", |
|
test(game, { assertCellGroup }) { |
|
assertCellGroup("Column #0", [Player.X, Player.O, Player.O, Player.O]); |
|
assertCellGroup("Column #1", [null, Player.X, Player.O, Player.X]); |
|
assertCellGroup("Column #2", [Player.X, Player.X, Player.X, Player.O]); |
|
assertCellGroup("Column #3", [Player.X, null, Player.X, Player.X]); |
|
assertCellGroup("Row #0", [Player.X, null, Player.X, Player.X]); |
|
assertCellGroup("Row #1", [Player.O, Player.X, Player.X, null]); |
|
assertCellGroup("Row #3", [Player.O, Player.X, Player.O, Player.X]); |
|
assertCellGroup("Top left to bottom right diagonal", [ |
|
Player.X, |
|
Player.X, |
|
Player.X, |
|
Player.X, |
|
]); |
|
assertCellGroup("Top right to bottom left diagonal", [ |
|
Player.O, |
|
Player.O, |
|
Player.X, |
|
Player.X, |
|
]); |
|
|
|
assert.equal( |
|
game.asConsoleOutput, |
|
"|X| |X|X|\n|O|X|X| |\n|O|O|X|X|\n|O|X|O|X|", |
|
); |
|
}, |
|
}, |
|
]; |
|
|
|
console.debug(" "); |
|
console.debug("Running tests..."); |
|
|
|
let testCaseIndex = 0; |
|
for (const testCase of TEST_CASES) { |
|
testCaseIndex++; |
|
|
|
let testRan = false; |
|
let game: TicTacToeGame | undefined; |
|
|
|
const testCaseId = `Test case ${testCaseIndex}/${TEST_CASES.length} "${testCase.name}"`; |
|
|
|
try { |
|
game = new TicTacToeGame( |
|
{ |
|
boardSize: testCase.boardSize ?? CONFIG.boardSize, |
|
}, |
|
testCase.board, |
|
); |
|
if ("player" in testCase && testCase.player) { |
|
game.setPlayer(testCase.player); |
|
} |
|
} catch (cause) { |
|
if ( |
|
"onGameFailToStart" in testCase && |
|
typeof testCase.onGameFailToStart === "function" |
|
) { |
|
testRan = true; |
|
try { |
|
testCase.onGameFailToStart(cause); |
|
} catch (cause) { |
|
console.error(`[${testCaseId}] failed to start game`); |
|
throw cause; |
|
} |
|
} else { |
|
console.error(`[${testCaseId}] failed to start game`); |
|
throw cause; |
|
} |
|
} |
|
|
|
if (!testRan) { |
|
if (!game) { |
|
throw new Error(`No game started for ${testCaseId}`); |
|
} |
|
|
|
// Custom test runner function provided: |
|
if ("test" in testCase && typeof testCase.test === "function") { |
|
testRan = true; |
|
|
|
const cellGroups = game.getCellGroups(); |
|
|
|
const assertCellGroup = ( |
|
name: string, |
|
expectedCells: NullablePlayer[], |
|
) => { |
|
const cells = cellGroups[name]; |
|
if (!cells) { |
|
throw new Error(`Cannot find cell group for ${name}`); |
|
} |
|
assert.deepEqual( |
|
cells, |
|
expectedCells, |
|
`${name} should have ${formatCellGroupOutput( |
|
expectedCells, |
|
)} but instead has ${formatCellGroupOutput(cells)}`, |
|
); |
|
}; |
|
|
|
try { |
|
testCase.test(game, { assertCellGroup }); |
|
} catch (cause) { |
|
console.error(`[${testCaseId}] failed`); |
|
throw cause; |
|
} |
|
} |
|
|
|
// Standard test of comparing the game result: |
|
if ( |
|
"result" in testCase && |
|
(typeof testCase.result === "string" || testCase.result === null) |
|
) { |
|
testRan = true; |
|
|
|
const result = game.getGameResult(); |
|
assert.strictEqual( |
|
result, |
|
testCase.result, |
|
`[${testCaseId}] "${ |
|
testCase.result |
|
}" was expected, but instead the result of the game is "${result}". Game:\n${ |
|
game.asConsoleOutput |
|
}\n\nCell groups:\n${JSON.stringify(game.getCellGroups(), null, 2)}`, |
|
); |
|
} |
|
|
|
// Test case is invalid, because either a test() function or result string should be provided: |
|
if (!testRan) { |
|
console.error( |
|
`[${testCaseId}] is invalid because it requires a test() function or result string.`, |
|
); |
|
throw new Error( |
|
`${testCaseId} is invalid: ${JSON.stringify(testCase, null, 2)}`, |
|
); |
|
} |
|
} |
|
|
|
console.debug(`[${testCaseId}] passed`); |
|
} |
|
console.debug("Tests finished."); |