UNPKG

boardgame.io

Version:
434 lines (375 loc) 10.8 kB
/* * 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 ); }); }); });