Compare commits

...

7 Commits

Author SHA1 Message Date
Cheney Ni
8b7617019e
Merge b2edea0ce3 into 2c67b48c21 2024-04-25 08:31:16 +08:00
spelgubbe
b2edea0ce3 Add comments to minimax (#7) 2022-03-07 02:01:10 +08:00
spelgubbe
7dccbaa440 Add basic minimax documentation (#5) 2022-03-06 20:20:25 +08:00
spelgubbe
b8a5d0d7fc 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.
2022-03-06 05:32:17 +08:00
spelgubbe
d0cc1029ec 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.
2022-03-06 05:28:54 +08:00
nichujie
a41a2747ce Complete minimax algorithm and relevant classes 2022-03-04 17:18:37 +01:00
Jakob Kratz
33695ce766 Add minimax algorithm logic
Heuristic and game state definition still missing. No tests yet.
2022-03-03 19:35:37 +01:00
6 changed files with 387 additions and 0 deletions

View File

@ -0,0 +1,38 @@
class GameNode {
/**
* @returns {boolean} - If it is the opponent's turn
*/
isOpponentPlaying() {
throw new Error('GameNode::isOpponentPlaying must be implemented');
}
/**
* @returns {boolean} - If the game is terminated in the current state
*/
isTerminalState() {
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
*/
computeNextStates() {
throw new Error('GameNode::computeNextStates must be implemented');
}
/**
* @returns {Number} - Score of current state
*/
evaluateState() {
throw new Error('GameNode::evaluateState must be implemented');
}
}
export default GameNode;

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

@ -0,0 +1,152 @@
import GameNode from './GameNode';
export const playerMark = 'x';
export const opponentMark = 'o';
class TicTacToeGameNode extends 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) {
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.<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
*/
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 {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.
* 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.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;

View File

@ -0,0 +1,82 @@
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, depth = 5) {
// Default depth set low
let [_, best_node] = this.minimax(node, depth);
// If current state is terminal or depth == 0, then no move is possible
if (best_node === null) {
return null;
}
return best_node.getMove();
}
/**
* Calculate the heuristic value of current state.
* @param {GameNode} node - Description of game state
* @returns
*/
heuristic(node) {
return node.evaluate();
}
/**
*
* @param {GameNode} node - Description of game state
* @param {Number} depth - Limit to the search depth
* @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];
}
}
export default MinimaxPlayer;

View File

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

View File

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