From a41a2747ce440a05f2f105354bb19888b0108ff2 Mon Sep 17 00:00:00 2001 From: nichujie Date: Fri, 4 Mar 2022 17:18:37 +0100 Subject: [PATCH] Complete minimax algorithm and relevant classes --- src/algorithms/ai/GameNode.js | 141 ++++++++++++++++++ src/algorithms/ai/Minimax.js | 58 ------- src/algorithms/ai/Player.js | 8 + src/algorithms/ai/__test__/Minimax.test.js | 9 -- src/algorithms/ai/minimax/Minimax.js | 66 ++++++++ .../ai/minimax/__test__/Minimax.test.js | 16 ++ 6 files changed, 231 insertions(+), 67 deletions(-) create mode 100644 src/algorithms/ai/GameNode.js delete mode 100644 src/algorithms/ai/Minimax.js create mode 100644 src/algorithms/ai/Player.js delete mode 100644 src/algorithms/ai/__test__/Minimax.test.js create mode 100644 src/algorithms/ai/minimax/Minimax.js create mode 100644 src/algorithms/ai/minimax/__test__/Minimax.test.js diff --git a/src/algorithms/ai/GameNode.js b/src/algorithms/ai/GameNode.js new file mode 100644 index 00000000..70630eaa --- /dev/null +++ b/src/algorithms/ai/GameNode.js @@ -0,0 +1,141 @@ +export const playerMark = 'x'; +export const opponentMark = 'o'; +class GameNode { + /** + * + * @param {Array.>} board Board of a Tic-Tac-Toe game. + * @param {string} currentPlayer Current player mark: 'x' or 'o' + */ + constructor(board, currentPlayer) { + if (!GameNode.isBoardValid(board)) throw new Error('Invalid board object!'); + if (currentPlayer !== 'x' && currentPlayer !== 'o') throw new Error('Invalid player: \'x\' or \'o\''); + + this.board = board; + this.currentPlayer = currentPlayer; + this.move = null; + } + + /** + * Check whether a board object is valid: 3*3 matrix of 'x', 'o' or '_' + * @param {Array.>} board Board of a Tic-Tac-Toe game. + * @returns {boolean} - If the board is valid. + */ + static isBoardValid(board) { + let validCount = 0; + if (Array.isArray(board) && board.length === 3) { + for (let i = 0; i < 3; i += 1) { + const row = board[i]; + if (Array.isArray(row) && row.length === 3) { + for (let j = 0; j < 3; j += 1) { + if (row[j] === 'x' || row[j] === 'o' || row[j] === '_') { + validCount += 1; + } + } + } + } + } + return validCount === 9; + } + + /** + * @returns {boolean} - If it is the opponent's turn + */ + isOpponentPlaying() { + return this.currentPlayer === opponentMark; + } + + /** + * @returns {boolean} - If the game is terminated + */ + isTerminated() { + if (!this.hasMoreMoves() || this.checkWin() !== null) return true; + return false; + } + + /** + * @returns {boolean} - If there are empty grids on the board + */ + hasMoreMoves() { + for (let i = 0; i < 3; i += 1) { + for (let j = 0; j < 3; j += 1) { + if (this.board[i][j] === '_') return true; + } + } + return false; + } + + /** + * + * @returns {string} - If the game is end with three consecutive mark on the board. + * Return the player mark of winning ('x' or 'o'), otherwise return null. + */ + checkWin() { + const { board } = this; + // Checking for rows + for (let row = 0; row < 3; row += 1) { + if (board[row][0] === board[row][1] && board[row][1] === board[row][2]) { + if (board[row][0] === playerMark) return playerMark; + if (board[row][0] === opponentMark) return opponentMark; + } + } + + // Checking for columns + for (let col = 0; col < 3; col += 1) { + if (board[0][col] === board[1][col] && board[1][col] === board[2][col]) { + if (board[0][col] === playerMark) return playerMark; + if (board[0][col] === opponentMark) return opponentMark; + } + } + + // Checking for diagonals + if (board[0][0] === board[1][1] && board[1][1] === board[2][2]) { + if (board[0][0] === playerMark) return playerMark; + if (board[0][0] === opponentMark) return opponentMark; + } + + if (board[0][2] === board[1][1] && board[1][1] === board[2][0]) { + if (board[0][2] === playerMark) return playerMark; + if (board[0][2] === opponentMark) return opponentMark; + } + + // Otherwise none of the players have won, return null + return null; + } + + /** + * @returns {Number} - Score of current state + */ + evaluate() { + const win = this.checkWin(); + if (win === playerMark) return 10; + if (win === opponentMark) return -10; + return 0; + } + + /** + * @returns {Array.} - Possible next states of the game tree + */ + computeNextStates() { + if (this.isTerminated()) return []; + + const nextPlayerMark = this.currentPlayer === playerMark ? opponentMark : playerMark; + const nextStates = []; + + for (let i = 0; i < 3; i += 1) { + for (let j = 0; j < 3; j += 1) { + if (this.board[i][j] === '_') { + const newBoard = JSON.parse(JSON.stringify(this.board)); // Deep clone the board array + newBoard[i][j] = this.currentPlayer; // Make the move + + const newNode = new GameNode(newBoard, nextPlayerMark); + newNode.move = [i, j]; // Record the move + nextStates.push(newNode); // Add the new state to result + } + } + } + + return nextStates; + } +} + +export default GameNode; diff --git a/src/algorithms/ai/Minimax.js b/src/algorithms/ai/Minimax.js deleted file mode 100644 index ba7e5600..00000000 --- a/src/algorithms/ai/Minimax.js +++ /dev/null @@ -1,58 +0,0 @@ -/** - * - * @param {*} node description of game state - * @param {Number} depth limit to the search depth - * @param {Number} player player id, either 0 or 1 - * @returns game state resulting from the best found move - */ -function minimax(node, depth, player) { - if (depth === 0 || terminal_state(node)) { - return this.heuristic(node, player), node; // pretend there is a heuristic function supplies - } - opt = player == 0 ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY; - - opt_node = undefined; - - for (const child in node.possibleStates()) { // pretend there is node.possibleStates - nextPlayer = player === 1 ? 0 : 1; - - value, _ = minimax(move, depth - 1, nextPlayer); - - if (player === 0) { - if (value < optimal) { - optimal = value; - opt_node = child; - } - } else { - if (value > optimal) { - optimal = value; - opt_node = child; - } - } - } - return optimal, opt_node; -} - -/* inspired by Python... - - def minimax(self, node: Node, depth: int, player: int): - if depth == 0 or self.is_terminal_state(node): - return self.h(node, player), node - - opt = math.inf if (player != 0) else -math.inf - opt_node = None - for child in node.compute_and_get_children(): - # test all possible moves - h, _ = self.minimax(child, depth-1, player ^ 1) - if player != 0: - if h > opt: - opt = h - opt_node = child - else: - if h < opt: - opt = h - opt_node = child - - return opt, opt_node - -*/ diff --git a/src/algorithms/ai/Player.js b/src/algorithms/ai/Player.js new file mode 100644 index 00000000..0bc695eb --- /dev/null +++ b/src/algorithms/ai/Player.js @@ -0,0 +1,8 @@ +export default class Player { + /** + * @param {GameNode} node One node of the game tree. Description of a game state + */ + findBestMove() { + throw new Error('Player::findBestMove must be implemented'); + } +} diff --git a/src/algorithms/ai/__test__/Minimax.test.js b/src/algorithms/ai/__test__/Minimax.test.js deleted file mode 100644 index 591e5084..00000000 --- a/src/algorithms/ai/__test__/Minimax.test.js +++ /dev/null @@ -1,9 +0,0 @@ -import Minimax from '../Minimax'; - -describe('Minimax', () => { - - if('exists', () => { - expect(true); - }); - -}); diff --git a/src/algorithms/ai/minimax/Minimax.js b/src/algorithms/ai/minimax/Minimax.js new file mode 100644 index 00000000..bd249846 --- /dev/null +++ b/src/algorithms/ai/minimax/Minimax.js @@ -0,0 +1,66 @@ +import Player from '../Player'; + +class MinimaxPlayer extends Player { + /** + * Find the best move to perform in the current game state. + * @param {GameNode} node - Description of game state + */ + findBestMove(node) { + let bestScore = Number.NEGATIVE_INFINITY; + let bestMove = null; + + const nextNodes = node.computeNextStates(); + for (let i = 0; i < nextNodes.length; i += 1) { + const nextNode = nextNodes[i]; + const score = this.minimax(nextNode, 10); + + if (score > bestScore) { + bestScore = score; + bestMove = nextNode.move; + } + } + + return bestMove; + } + + /** + * Calculate the heuristic value of current state. + * @param {GameNode} node - Description of game state + * @returns + */ + heuristic(node) { + return node.evaluate(); + } + + /** + * + * @param {*} node - Description of game state + * @param {Number} depth - Limit to the search depth + * @returns {Number} - Best score the player can reach under this state + */ + minimax(node, depth) { + if (depth === 0 || node.isTerminated()) { + return this.heuristic(node); + } + + let optimal = node.isOpponentPlaying() ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY; + const nextNodes = node.computeNextStates(); + for (let i = 0; i < nextNodes.length; i += 1) { + const nextNode = nextNodes[i]; + const score = this.minimax(nextNode, depth - 1); + + if (node.isOpponentPlaying()) { + if (score < optimal) { + optimal = score; + } + } else { + if (score > optimal) { + optimal = score; + } + } + } + return optimal; + } +} + +export default MinimaxPlayer; diff --git a/src/algorithms/ai/minimax/__test__/Minimax.test.js b/src/algorithms/ai/minimax/__test__/Minimax.test.js new file mode 100644 index 00000000..46bbd1c7 --- /dev/null +++ b/src/algorithms/ai/minimax/__test__/Minimax.test.js @@ -0,0 +1,16 @@ +import GameNode, { playerMark } from '../../GameNode'; +import Minimax from '../Minimax'; + +describe('Minimax', () => { + it('make decision under a normal state', () => { + const board = [ + ['x', 'o', 'x'], + ['o', 'o', 'x'], + ['_', '_', '_'], + ]; + const initialNode = new GameNode(board, playerMark); + const player = new Minimax(); + + expect(player.findBestMove(initialNode)).toStrictEqual([2, 2]); + }); +});