boardgame.io
Version:
library for turn-based games
434 lines (375 loc) • 10.8 kB
text/typescript
/*
* Copyright 2018 The boardgame.io Authors
*
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
import { InitializeGame } from '../core/initialize';
import { Client } from '../client/client';
import { MAKE_MOVE, GAME_EVENT } from '../core/action-types';
import { makeMove } from '../core/action-creators';
import { Step, Simulate } from './ai';
import { RandomBot } from './random-bot';
import { MCTSBot, Node } from './mcts-bot';
import { ProcessGameConfig } from '../core/game';
import { Stage } from '../core/turn-order';
import { Game, Ctx } from '../types';
function IsVictory(cells) {
const positions = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6],
];
const isRowComplete = row => {
const symbols = row.map(i => cells[i]);
return symbols.every(i => i !== null && i === symbols[0]);
};
return positions.map(isRowComplete).some(i => i === true);
}
const TicTacToe = ProcessGameConfig({
setup: () => ({
cells: new Array(9).fill(null),
}),
moves: {
clickCell(G, ctx, id) {
const cells = [...G.cells];
if (cells[id] === null) {
cells[id] = ctx.currentPlayer;
}
return { ...G, cells };
},
},
turn: { moveLimit: 1 },
endIf: (G, ctx) => {
if (IsVictory(G.cells)) {
return { winner: ctx.currentPlayer };
}
if (G.cells.filter(t => t == null).length == 0) {
return { draw: true };
}
},
});
const enumerate = (G, ctx, playerID) => {
let r = [];
for (let i = 0; i < 9; i++) {
if (G.cells[i] === null) {
r.push(makeMove('clickCell', [i], playerID));
}
}
return r;
};
describe('Step', () => {
test('advances game state', async () => {
const client = Client({
game: {
setup: () => ({ moved: false }),
moves: {
clickCell(G) {
return { moved: !G.moved };
},
},
endIf(G) {
if (G.moved) return true;
},
ai: {
enumerate: () => [{ move: 'clickCell' }],
},
},
});
const bot = new RandomBot({ enumerate: client.game.ai.enumerate });
expect(client.getState().G).toEqual({ moved: false });
await Step(client, bot);
expect(client.getState().G).toEqual({ moved: true });
});
test('does not crash on empty action', async () => {
const client = Client({
game: {
ai: {
enumerate: () => [],
},
},
});
const bot = new RandomBot({ enumerate: client.game.ai.enumerate });
await Step(client, bot);
});
test('works with stages', async () => {
const client = Client({
game: {
moves: {
A: G => {
G.moved = true;
},
},
turn: {
activePlayers: { currentPlayer: 'stage' },
},
ai: {
enumerate: () => [{ move: 'A' }],
},
},
});
const bot = new RandomBot({ enumerate: client.game.ai.enumerate });
expect(client.getState().G).not.toEqual({ moved: true });
await Step(client, bot);
expect(client.getState().G).toEqual({ moved: true });
});
});
describe('Simulate', () => {
const bots = {
'0': new RandomBot({ seed: 'test', enumerate }),
'1': new RandomBot({ seed: 'test', enumerate }),
};
test('multiple bots', async () => {
const state = InitializeGame({ game: TicTacToe });
const { state: endState } = await Simulate({
game: TicTacToe,
bots,
state,
});
expect(endState.ctx.gameover).not.toBe(undefined);
});
test('single bot', async () => {
const bot = new RandomBot({ seed: 'test', enumerate });
const state = InitializeGame({ game: TicTacToe });
const { state: endState } = await Simulate({
game: TicTacToe,
bots: bot,
state,
depth: 10,
});
expect(endState.ctx.gameover).not.toBe(undefined);
});
test('with activePlayers', async () => {
const game = ProcessGameConfig({
moves: {
A: G => {
G.moved = true;
},
},
turn: {
activePlayers: { currentPlayer: Stage.NULL },
},
endIf: G => G.moved,
});
const bot = new RandomBot({
seed: 'test',
enumerate: () => [makeMove('A')],
});
const state = InitializeGame({ game });
const { state: endState } = await Simulate({
game,
bots: bot,
state,
depth: 1,
});
expect(endState.ctx.gameover).not.toBe(undefined);
});
});
describe('Bot', () => {
test('random', () => {
const b = new RandomBot({ enumerate: () => [] });
expect(b.random()).toBeGreaterThanOrEqual(0);
expect(b.random()).toBeLessThan(1);
});
test('enumerate - makeMove', () => {
const enumerate = () => [makeMove('move')];
const b = new RandomBot({ enumerate });
expect(b.enumerate(undefined, undefined, undefined)[0].type).toBe(
MAKE_MOVE
);
});
test('enumerate - translate to makeMove', () => {
const enumerate = () => [{ move: 'move' }];
const b = new RandomBot({ enumerate });
expect(b.enumerate(undefined, undefined, undefined)[0].type).toBe(
MAKE_MOVE
);
});
test('enumerate - translate to gameEvent', () => {
const enumerate = () => [{ event: 'endTurn' }];
const b = new RandomBot({ enumerate });
expect(b.enumerate(undefined, undefined, undefined)[0].type).toBe(
GAME_EVENT
);
});
test('enumerate - unrecognized', () => {
const enumerate = (() =>
[{ unknown: true }] as unknown) as Game['ai']['enumerate'];
const b = new RandomBot({ enumerate });
expect(b.enumerate(undefined, undefined, undefined)).toEqual([undefined]);
});
});
describe('MCTSBot', () => {
test('game that never ends', async () => {
const game = {};
const state = InitializeGame({ game });
const bot = new MCTSBot({ seed: 'test', game, enumerate: () => [] });
const { state: endState } = await Simulate({ game, bots: bot, state });
expect(endState.ctx.turn).toBe(1);
});
test('RandomBot vs. MCTSBot', async () => {
const bots = {
'0': new RandomBot({ seed: 'test', enumerate }),
'1': new MCTSBot({
iterations: 200,
seed: 'test',
game: TicTacToe,
enumerate,
}),
};
const initialState = InitializeGame({ game: TicTacToe });
for (let i = 0; i < 5; i++) {
const state = initialState;
const { state: endState } = await Simulate({
game: TicTacToe,
bots,
state,
});
expect(endState.ctx.gameover).not.toEqual({ winner: '0' });
}
});
test('MCTSBot vs. MCTSBot', async () => {
const initialState = InitializeGame({ game: TicTacToe });
const iterations = 400;
for (let i = 0; i < 5; i++) {
const bots = {
'0': new MCTSBot({
seed: i,
game: TicTacToe,
enumerate,
iterations,
playoutDepth: 50,
}),
'1': new MCTSBot({
seed: i,
game: TicTacToe,
enumerate,
iterations,
}),
};
const state = initialState;
const { state: endState } = await Simulate({
game: TicTacToe,
bots,
state,
});
expect(endState.ctx.gameover).toEqual({ draw: true });
}
});
test('with activePlayers', async () => {
const game = ProcessGameConfig({
setup: () => ({ moves: 0 }),
moves: {
A: G => {
G.moves++;
},
},
turn: {
activePlayers: { currentPlayer: Stage.NULL },
},
endIf: G => G.moves > 5,
});
const bot = new MCTSBot({
seed: 'test',
game,
enumerate: () => [makeMove('A')],
});
const state = InitializeGame({ game });
const { state: endState } = await Simulate({
game,
bots: bot,
state,
depth: 10,
});
expect(endState.ctx.gameover).not.toBe(undefined);
});
test('objectives', async () => {
const objectives = () => ({
'play-on-square-0': {
checker: G => G.cells[0] !== null,
weight: 10.0,
},
});
const state = InitializeGame({ game: TicTacToe });
for (let i = 0; i < 10; i++) {
const bot = new MCTSBot({
iterations: 200,
seed: i,
game: TicTacToe,
enumerate,
objectives,
});
const { action } = await bot.play(state, '0');
expect(action.payload.args).toEqual([0]);
}
});
test('async mode', async () => {
const initialState = InitializeGame({ game: TicTacToe });
const bot = new MCTSBot({
seed: '0',
game: TicTacToe,
enumerate,
iterations: 10,
playoutDepth: 10,
});
bot.setOpt('async', true);
const action = await bot.play(initialState, '0');
expect(action).not.toBeUndefined();
});
describe('iterations & playout depth', () => {
test('set opts', () => {
const bot = new MCTSBot({ game: TicTacToe, enumerate: jest.fn() });
bot.setOpt('iterations', 1);
expect(bot.opts()['iterations'].value).toBe(1);
});
test('setOpt works on invalid key', () => {
const bot = new RandomBot({ enumerate: jest.fn() });
bot.setOpt('unknown', 1);
});
test('functions', () => {
const state = InitializeGame({ game: TicTacToe });
// jump ahead in the game because the example iterations
// and playoutDepth functions are based on the turn
state.ctx.turn = 8;
const { turn, currentPlayer } = state.ctx;
const enumerateSpy = jest.fn(enumerate);
const bot = new MCTSBot({
game: TicTacToe,
enumerate: enumerateSpy,
iterations: (G, ctx) => ctx.turn * 100,
playoutDepth: (G, ctx) => ctx.turn * 10,
});
if (typeof bot.iterations === 'function') {
expect(bot.iterations(null, { turn } as Ctx, currentPlayer)).toBe(
turn * 100
);
}
if (typeof bot.playoutDepth === 'function') {
expect(bot.playoutDepth(null, { turn } as Ctx, currentPlayer)).toBe(
turn * 10
);
}
// try the playout() function which requests the playoutDepth value
bot.playout({ state } as Node);
expect(enumerateSpy).toHaveBeenCalledWith(
state.G,
state.ctx,
currentPlayer
);
// then try the play() function which requests the iterations value
enumerateSpy.mockClear();
bot.play(state, currentPlayer);
expect(enumerateSpy).toHaveBeenCalledWith(
state.G,
state.ctx,
currentPlayer
);
});
});
});