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] 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); }); });