Complete minimax algorithm and relevant classes

This commit is contained in:
nichujie 2022-03-04 17:18:37 +01:00
parent 33695ce766
commit a41a2747ce
6 changed files with 231 additions and 67 deletions

View File

@ -0,0 +1,141 @@
export const playerMark = 'x';
export const opponentMark = 'o';
class GameNode {
/**
*
* @param {Array.<Array.<string>>} 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.<Array.<string>>} 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.<GameNode>} - 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;

View File

@ -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
*/

View File

@ -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');
}
}

View File

@ -1,9 +0,0 @@
import Minimax from '../Minimax';
describe('Minimax', () => {
if('exists', () => {
expect(true);
});
});

View File

@ -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;

View File

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