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.
This commit is contained in:
spelgubbe 2022-03-05 18:23:40 +01:00 committed by Cheney Ni
parent d0cc1029ec
commit b8a5d0d7fc
4 changed files with 81 additions and 20 deletions

View File

@ -13,6 +13,13 @@ class GameNode {
throw new Error('GameNode::isTerminalState must be implemented'); 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.<GameNode>} - Possible next states of the game tree * @returns {Array.<GameNode>} - Possible next states of the game tree
*/ */

View File

@ -68,6 +68,13 @@ class TicTacToeGameNode extends GameNode {
return false; 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. * @returns {string} - If the game is end with three consecutive mark on the board.

View File

@ -5,22 +5,17 @@ class MinimaxPlayer extends Player {
* Find the best move to perform in the current game state. * Find the best move to perform in the current game state.
* @param {GameNode} node - Description of game state * @param {GameNode} node - Description of game state
*/ */
findBestMove(node) { findBestMove(node, depth = 5) {
let bestScore = Number.NEGATIVE_INFINITY; // Default depth set low
let bestMove = null;
const nextNodes = node.computeNextStates(); let [_, best_node] = this.minimax(node, depth);
for (let i = 0; i < nextNodes.length; i += 1) {
const nextNode = nextNodes[i];
const score = this.minimax(nextNode, 10);
if (score > bestScore) { // If current state is terminal or depth == 0, then no move is possible
bestScore = score; if (best_node === null) {
bestMove = nextNode.move; 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 * @param {Number} depth - Limit to the search depth
* @returns {Number} - Best score the player can reach under this state * @returns {Number} - Best score the player can reach under this state
*/ */
minimax(node, depth) { minimax(node, depth) {
if (depth === 0 || node.isTerminalState()) { 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.isOpponentPlaying() ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY;
let optimal_node = null
const nextNodes = node.computeNextStates(); const nextNodes = node.computeNextStates();
for (let i = 0; i < nextNodes.length; i += 1) { for (let i = 0; i < nextNodes.length; i += 1) {
const nextNode = nextNodes[i]; const nextNode = nextNodes[i];
const score = this.minimax(nextNode, depth - 1); const [score, _] = this.minimax(nextNode, depth - 1);
if (node.isOpponentPlaying()) { if (node.isOpponentPlaying()) {
if (score < optimal) { if (score < optimal) {
optimal = score; optimal = score;
optimal_node = nextNode;
} }
} else { } else {
if (score > optimal) { if (score > optimal) {
optimal = score; optimal = score;
optimal_node = nextNode;
} }
} }
} }
return optimal; return [optimal, optimal_node];
} }
} }

View File

@ -1,16 +1,64 @@
import TicTacToeGameNode, { playerMark } from '../../TicTacToeGameNode'; import TicTacToeGameNode, { opponentMark, playerMark } from '../../TicTacToeGameNode';
import Minimax from '../Minimax'; import Minimax from '../Minimax';
describe('Minimax', () => { describe('Minimax', () => {
it('make decision under a normal state', () => { it('player makes right decision under a normal state', () => {
const board = [ const board = [
['x', 'o', 'x'], ['x', 'o', 'x'],
['o', 'o', 'x'], ['o', 'o', 'x'],
['_', '_', '_'], ['_', '_', '_'],
]; ];
const initialNode = new TicTacToeGameNode(board, playerMark); 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);
}); });
}); });