From 33695ce766392ac35f37c401f9b1f9b2eb74c7bb Mon Sep 17 00:00:00 2001 From: Jakob Kratz <4411767+spelgubbe@users.noreply.github.com> Date: Thu, 3 Mar 2022 18:57:58 +0100 Subject: [PATCH 1/6] Add minimax algorithm logic Heuristic and game state definition still missing. No tests yet. --- src/algorithms/ai/Minimax.js | 58 ++++++++++++++++++++++ src/algorithms/ai/__test__/Minimax.test.js | 9 ++++ 2 files changed, 67 insertions(+) create mode 100644 src/algorithms/ai/Minimax.js create mode 100644 src/algorithms/ai/__test__/Minimax.test.js diff --git a/src/algorithms/ai/Minimax.js b/src/algorithms/ai/Minimax.js new file mode 100644 index 00000000..ba7e5600 --- /dev/null +++ b/src/algorithms/ai/Minimax.js @@ -0,0 +1,58 @@ +/** + * + * @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/__test__/Minimax.test.js b/src/algorithms/ai/__test__/Minimax.test.js new file mode 100644 index 00000000..591e5084 --- /dev/null +++ b/src/algorithms/ai/__test__/Minimax.test.js @@ -0,0 +1,9 @@ +import Minimax from '../Minimax'; + +describe('Minimax', () => { + + if('exists', () => { + expect(true); + }); + +}); From a41a2747ce440a05f2f105354bb19888b0108ff2 Mon Sep 17 00:00:00 2001 From: nichujie Date: Fri, 4 Mar 2022 17:18:37 +0100 Subject: [PATCH 2/6] 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]); + }); +}); From d0cc1029eca380de2ebea7f55cfcfcce6d98fd9a Mon Sep 17 00:00:00 2001 From: spelgubbe <4411767+spelgubbe@users.noreply.github.com> Date: Sat, 5 Mar 2022 16:33:57 +0100 Subject: [PATCH 3/6] Make GameNode abstract (#1) Old GameNode is called TicTacToeGameNode as it is used to represent tic tac toe game states. The idea is that any game implementing the GameNode interface should be able to use the Minimax algorithm. --- src/algorithms/ai/GameNode.js | 132 ++-------------- src/algorithms/ai/TicTacToeGameNode.js | 145 ++++++++++++++++++ src/algorithms/ai/minimax/Minimax.js | 2 +- .../ai/minimax/__test__/Minimax.test.js | 4 +- 4 files changed, 159 insertions(+), 124 deletions(-) create mode 100644 src/algorithms/ai/TicTacToeGameNode.js diff --git a/src/algorithms/ai/GameNode.js b/src/algorithms/ai/GameNode.js index 70630eaa..c0c50524 100644 --- a/src/algorithms/ai/GameNode.js +++ b/src/algorithms/ai/GameNode.js @@ -1,140 +1,30 @@ -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; + throw new Error('GameNode::isOpponentPlaying must be implemented'); } /** - * @returns {boolean} - If the game is terminated + * @returns {boolean} - If the game is terminated in the current state */ - 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; + isTerminalState() { + throw new Error('GameNode::isTerminalState must be implemented'); } /** * @returns {Array.} - Possible next states of the game tree */ computeNextStates() { - if (this.isTerminated()) return []; + throw new Error('GameNode::computeNextStates must be implemented'); + } - 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; + /** + * @returns {Number} - Score of current state + */ + evaluateState() { + throw new Error('GameNode::evaluateState must be implemented'); } } diff --git a/src/algorithms/ai/TicTacToeGameNode.js b/src/algorithms/ai/TicTacToeGameNode.js new file mode 100644 index 00000000..a830f273 --- /dev/null +++ b/src/algorithms/ai/TicTacToeGameNode.js @@ -0,0 +1,145 @@ +import GameNode from './GameNode'; + +export const playerMark = 'x'; +export const opponentMark = 'o'; +class TicTacToeGameNode extends GameNode { + /** + * + * @param {Array.>} board Board of a Tic-Tac-Toe game. + * @param {string} currentPlayer Current player mark: 'x' or 'o' + */ + constructor(board, currentPlayer) { + super(); + + if (!TicTacToeGameNode.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 + */ + isTerminalState() { + 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.isTerminalState()) 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 TicTacToeGameNode(newBoard, nextPlayerMark); + newNode.move = [i, j]; // Record the move + nextStates.push(newNode); // Add the new state to result + } + } + } + + return nextStates; + } +} + +export default TicTacToeGameNode; diff --git a/src/algorithms/ai/minimax/Minimax.js b/src/algorithms/ai/minimax/Minimax.js index bd249846..80753a34 100644 --- a/src/algorithms/ai/minimax/Minimax.js +++ b/src/algorithms/ai/minimax/Minimax.js @@ -39,7 +39,7 @@ class MinimaxPlayer extends Player { * @returns {Number} - Best score the player can reach under this state */ minimax(node, depth) { - if (depth === 0 || node.isTerminated()) { + if (depth === 0 || node.isTerminalState()) { return this.heuristic(node); } diff --git a/src/algorithms/ai/minimax/__test__/Minimax.test.js b/src/algorithms/ai/minimax/__test__/Minimax.test.js index 46bbd1c7..918a49fc 100644 --- a/src/algorithms/ai/minimax/__test__/Minimax.test.js +++ b/src/algorithms/ai/minimax/__test__/Minimax.test.js @@ -1,4 +1,4 @@ -import GameNode, { playerMark } from '../../GameNode'; +import TicTacToeGameNode, { playerMark } from '../../TicTacToeGameNode'; import Minimax from '../Minimax'; describe('Minimax', () => { @@ -8,7 +8,7 @@ describe('Minimax', () => { ['o', 'o', 'x'], ['_', '_', '_'], ]; - const initialNode = new GameNode(board, playerMark); + const initialNode = new TicTacToeGameNode(board, playerMark); const player = new Minimax(); expect(player.findBestMove(initialNode)).toStrictEqual([2, 2]); From b8a5d0d7fc2f2e783810ea6fb9e3d98ed686c438 Mon Sep 17 00:00:00 2001 From: spelgubbe <4411767+spelgubbe@users.noreply.github.com> Date: Sat, 5 Mar 2022 18:23:40 +0100 Subject: [PATCH 4/6] Fix minimax bug (#3) Changes findBestMove and minimax to be correct regardless of player associated with a game state. Adds to the GameNode interface a required method getMove, which should return the move which caused the game state defined by the GameNode. Adds a test which exemplifies the previous issue and a few tests checking the functionality of minimax when used on a Tic Tac Toe game. --- src/algorithms/ai/GameNode.js | 7 +++ src/algorithms/ai/TicTacToeGameNode.js | 7 +++ src/algorithms/ai/minimax/Minimax.js | 31 +++++----- .../ai/minimax/__test__/Minimax.test.js | 56 +++++++++++++++++-- 4 files changed, 81 insertions(+), 20 deletions(-) diff --git a/src/algorithms/ai/GameNode.js b/src/algorithms/ai/GameNode.js index c0c50524..b692cff5 100644 --- a/src/algorithms/ai/GameNode.js +++ b/src/algorithms/ai/GameNode.js @@ -13,6 +13,13 @@ class GameNode { throw new Error('GameNode::isTerminalState must be implemented'); } + /** + * @returns {Object} - Get the move which caused this game state + */ + getMove() { + throw new Error('GameNode::getMove must be implemented'); + } + /** * @returns {Array.} - Possible next states of the game tree */ diff --git a/src/algorithms/ai/TicTacToeGameNode.js b/src/algorithms/ai/TicTacToeGameNode.js index a830f273..ad7d6b35 100644 --- a/src/algorithms/ai/TicTacToeGameNode.js +++ b/src/algorithms/ai/TicTacToeGameNode.js @@ -68,6 +68,13 @@ class TicTacToeGameNode extends GameNode { return false; } + /** + * @returns {Object} - Get the move which caused this game state + */ + getMove() { + return this.move; + } + /** * * @returns {string} - If the game is end with three consecutive mark on the board. diff --git a/src/algorithms/ai/minimax/Minimax.js b/src/algorithms/ai/minimax/Minimax.js index 80753a34..46792801 100644 --- a/src/algorithms/ai/minimax/Minimax.js +++ b/src/algorithms/ai/minimax/Minimax.js @@ -5,22 +5,17 @@ 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; + findBestMove(node, depth = 5) { + // Default depth set low - const nextNodes = node.computeNextStates(); - for (let i = 0; i < nextNodes.length; i += 1) { - const nextNode = nextNodes[i]; - const score = this.minimax(nextNode, 10); + let [_, best_node] = this.minimax(node, depth); - if (score > bestScore) { - bestScore = score; - bestMove = nextNode.move; - } + // If current state is terminal or depth == 0, then no move is possible + if (best_node === null) { + return null; } - return bestMove; + return best_node.getMove(); } /** @@ -34,32 +29,36 @@ class MinimaxPlayer extends Player { /** * - * @param {*} node - Description of game state + * @param {GameNode} 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.isTerminalState()) { - return this.heuristic(node); + return [this.heuristic(node), null]; } let optimal = node.isOpponentPlaying() ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY; + let optimal_node = null const nextNodes = node.computeNextStates(); + for (let i = 0; i < nextNodes.length; i += 1) { const nextNode = nextNodes[i]; - const score = this.minimax(nextNode, depth - 1); + const [score, _] = this.minimax(nextNode, depth - 1); if (node.isOpponentPlaying()) { if (score < optimal) { optimal = score; + optimal_node = nextNode; } } else { if (score > optimal) { optimal = score; + optimal_node = nextNode; } } } - return optimal; + return [optimal, optimal_node]; } } diff --git a/src/algorithms/ai/minimax/__test__/Minimax.test.js b/src/algorithms/ai/minimax/__test__/Minimax.test.js index 918a49fc..f5a01b3a 100644 --- a/src/algorithms/ai/minimax/__test__/Minimax.test.js +++ b/src/algorithms/ai/minimax/__test__/Minimax.test.js @@ -1,16 +1,64 @@ -import TicTacToeGameNode, { playerMark } from '../../TicTacToeGameNode'; +import TicTacToeGameNode, { opponentMark, playerMark } from '../../TicTacToeGameNode'; import Minimax from '../Minimax'; describe('Minimax', () => { - it('make decision under a normal state', () => { + it('player makes right decision under a normal state', () => { const board = [ ['x', 'o', 'x'], ['o', 'o', 'x'], ['_', '_', '_'], ]; const initialNode = new TicTacToeGameNode(board, playerMark); - const player = new Minimax(); + const minimax = new Minimax(); - expect(player.findBestMove(initialNode)).toStrictEqual([2, 2]); + expect(minimax.findBestMove(initialNode)).toStrictEqual([2, 2]); + }); + + it('opponent makes right decision under a normal state', () => { + const board = [ + ['x', 'o', 'x'], + ['o', 'o', 'x'], + ['_', '_', '_'], + ]; + const initialNode = new TicTacToeGameNode(board, opponentMark); + const minimax = new Minimax(); + + expect(minimax.findBestMove(initialNode)).toStrictEqual([2, 1]); + }); + + it('a finished game finds no moves', () => { + const board = [ + ['x', 'o', 'x'], + ['o', 'o', 'x'], + ['_', 'o', '_'], + ]; + const initialNode = new TicTacToeGameNode(board, playerMark); + const minimax = new Minimax(); + + expect(minimax.findBestMove(initialNode)).toStrictEqual(null); + }); + + it('a game far from a terminating state finds moves', () => { + const board = [ + ['_', '_', '_'], + ['_', '_', '_'], + ['_', '_', '_'], + ]; + const initialNode = new TicTacToeGameNode(board, opponentMark); + const minimax = new Minimax(); + + expect(minimax.findBestMove(initialNode)).not.toStrictEqual(null); + }); + + it('a full game board finds no moves', () => { + const board = [ + ['x', 'o', 'o'], + ['o', 'x', 'x'], + ['x', 'x', 'o'], + ]; + const initialNode = new TicTacToeGameNode(board, opponentMark); + const minimax = new Minimax(); + + expect(minimax.findBestMove(initialNode)).toStrictEqual(null); }); }); From 7dccbaa440263eb9ecd549fa59736c6bae5b7083 Mon Sep 17 00:00:00 2001 From: spelgubbe <4411767+spelgubbe@users.noreply.github.com> Date: Sat, 5 Mar 2022 20:13:35 +0100 Subject: [PATCH 5/6] Add basic minimax documentation (#5) --- src/algorithms/ai/minimax/README.md | 43 +++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/algorithms/ai/minimax/README.md diff --git a/src/algorithms/ai/minimax/README.md b/src/algorithms/ai/minimax/README.md new file mode 100644 index 00000000..d7e92bd3 --- /dev/null +++ b/src/algorithms/ai/minimax/README.md @@ -0,0 +1,43 @@ +## The Minimax Algorithm + +The minimax algorithm is a recursive algorithm used to make intelligent decisions in a zero-sum game. In this case it is implemented for a two-player game, but is possible to implement for a game with more players. The algorithm looks ahead into possible future states of the game, and assuming that each of the two players seek to maximize their own score. The algorithm attempts to maximize the guaranteed gain of a move. In other words it tries to maximize the minimum gain of a move or minimize the maximum loss of a move. + +The algorithm simulates a decision tree of different moves by the two players. At the bottom of the decision tree the state of the game is evaluated and assigned a score reflecting how good the game state is for some player. The algorithm works depth first, which in practice means that any game that takes many turns to finish, needs to use a depth limit in order for the minimax to terminate reasonably fast or at all. + +A small example below shows a value associated with each game state in a decision tree. In the example, there are two moves possible in each state. As the topmost player seeks to maximize the score, the value 10 is chosen at the bottom of the tree. + +``` +maximizing: 7 + / \ +minimizing: 3 7 + / \ / \ +maximizing: 3 5 7 10 +``` + + + +### Pseudo code for the algorithm +``` +function minimax(node, depth, maximizingPlayer) is + if depth = 0 or node is a terminal node then + return the heuristic value of node + if maximizingPlayer then + value := −∞ + for each child of node do + value := max(value, minimax(child, depth − 1, FALSE)) + return value + else (* minimizing player *) + value := +∞ + for each child of node do + value := min(value, minimax(child, depth − 1, TRUE)) + return value +``` + +The initial call to the method will be +``` +minimax(origin, depth, TRUE) +``` + +## References + +- [Wikipedia](https://en.wikipedia.org/wiki/Minimax) \ No newline at end of file From b2edea0ce3cd25bea97ba78d9454bfb4349ed0f3 Mon Sep 17 00:00:00 2001 From: spelgubbe <4411767+spelgubbe@users.noreply.github.com> Date: Sun, 6 Mar 2022 18:45:52 +0100 Subject: [PATCH 6/6] Add comments to minimax (#7) --- src/algorithms/ai/minimax/Minimax.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/algorithms/ai/minimax/Minimax.js b/src/algorithms/ai/minimax/Minimax.js index 46792801..1239e0fd 100644 --- a/src/algorithms/ai/minimax/Minimax.js +++ b/src/algorithms/ai/minimax/Minimax.js @@ -31,33 +31,50 @@ class MinimaxPlayer extends Player { * * @param {GameNode} node - Description of game state * @param {Number} depth - Limit to the search depth - * @returns {Number} - Best score the player can reach under this state + * @returns {[Number, GameNode]} - Best score the player can reach under this + * state, and the resulting game state */ minimax(node, depth) { + // Check whether search depth is reached + // or if the state is terminal if (depth === 0 || node.isTerminalState()) { return [this.heuristic(node), null]; } + // One player is maximizing and the other play is minimizing + // Their optimal values are initialized to numbers which will be replaced + // as soon as any valid node is visited let optimal = node.isOpponentPlaying() ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY; let optimal_node = null + + // Make a list of possible next states const nextNodes = node.computeNextStates(); + // Iterate over all possible next states + // Choose the optimal score, from the perspective of current player for (let i = 0; i < nextNodes.length; i += 1) { const nextNode = nextNodes[i]; + + // Recursively call minimax to find score of the next state const [score, _] = this.minimax(nextNode, depth - 1); + // In this code, the "opponent" is the minimizing player + // And the "player" is the maximizing player if (node.isOpponentPlaying()) { + // optimal is set to min(score, optimal) if (score < optimal) { optimal = score; optimal_node = nextNode; } } else { + // optimal is set to max(score, optimal) if (score > optimal) { optimal = score; optimal_node = nextNode; } } } + // Return optimal score and resulting game state return [optimal, optimal_node]; } }