diff --git a/docs/documentation/tutorial.md b/docs/documentation/tutorial.md index 5e7feb41e..818fea1c9 100644 --- a/docs/documentation/tutorial.md +++ b/docs/documentation/tutorial.md @@ -266,13 +266,11 @@ const TicTacToe = { } ``` -After that, add an AI section to our `Client` call that returns a list -of moves (one per empty cell). +After that, add an `ai` section to the game config: ```js -const App = Client({ - game: TicTacToe, - board: TicTacToeBoard, +const TicTacToe = { + ... ai: { enumerate: (G, ctx) => { @@ -285,9 +283,7 @@ const App = Client({ return moves; }, }, -}); - -export default App; +}; ``` That's it! Now that you can visit the AI section of the Debug Panel: diff --git a/examples/react-web/src/tic-tac-toe/game.js b/examples/react-web/src/tic-tac-toe/game.js index 0b36bcc18..e425ed8a7 100644 --- a/examples/react-web/src/tic-tac-toe/game.js +++ b/examples/react-web/src/tic-tac-toe/game.js @@ -63,6 +63,18 @@ const TicTacToe = { return { draw: true }; } }, + + ai: { + enumerate: G => { + let r = []; + for (let i = 0; i < 9; i++) { + if (G.cells[i] === null) { + r.push({ move: 'clickCell', args: [i] }); + } + } + return r; + }, + }, }; export default TicTacToe; diff --git a/examples/react-web/src/tic-tac-toe/singleplayer.js b/examples/react-web/src/tic-tac-toe/singleplayer.js index 255b91e89..ae7f8620e 100644 --- a/examples/react-web/src/tic-tac-toe/singleplayer.js +++ b/examples/react-web/src/tic-tac-toe/singleplayer.js @@ -11,22 +11,16 @@ import { Client } from 'boardgame.io/react'; import { Debug } from 'boardgame.io/debug'; import TicTacToe from './game'; import Board from './board'; +import { Local } from 'boardgame.io/multiplayer'; +import { MCTSBot } from 'boardgame.io/ai'; const App = Client({ game: TicTacToe, board: Board, debug: { impl: Debug }, - ai: { - enumerate: G => { - let r = []; - for (let i = 0; i < 9; i++) { - if (G.cells[i] === null) { - r.push({ move: 'clickCell', args: [i] }); - } - } - return r; - }, - }, + multiplayer: Local({ + bots: { '1': MCTSBot }, + }), }); const Singleplayer = () => ( diff --git a/src/ai/ai.test.js b/src/ai/ai.test.js index 7c104df3d..ca1947837 100644 --- a/src/ai/ai.test.js +++ b/src/ai/ai.test.js @@ -97,14 +97,14 @@ describe('Step', () => { endIf(G) { if (G.moved) return true; }, - }, - ai: { - enumerate: () => [{ move: 'clickCell' }], + ai: { + enumerate: () => [{ move: 'clickCell' }], + }, }, }); - const bot = new RandomBot({ enumerate: client.ai.enumerate }); + 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 }); @@ -112,12 +112,13 @@ describe('Step', () => { test('does not crash on empty action', async () => { const client = Client({ - game: {}, - ai: { - enumerate: () => [], + game: { + ai: { + enumerate: () => [], + }, }, }); - const bot = new RandomBot({ enumerate: client.ai.enumerate }); + const bot = new RandomBot({ enumerate: client.game.ai.enumerate }); await Step(client, bot); }); @@ -133,14 +134,14 @@ describe('Step', () => { turn: { activePlayers: { currentPlayer: 'stage' }, }, - }, - ai: { - enumerate: () => [{ move: 'A' }], + ai: { + enumerate: () => [{ move: 'A' }], + }, }, }); - const bot = new RandomBot({ enumerate: client.ai.enumerate }); + 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 }); diff --git a/src/client/client.js b/src/client/client.js index c8eb15c9a..d16a59447 100644 --- a/src/client/client.js +++ b/src/client/client.js @@ -80,7 +80,6 @@ export const createMoveDispatchers = createDispatchers.bind(null, 'makeMove'); class _ClientImpl { constructor({ game, - ai, debug, numPlayers, multiplayer, @@ -90,7 +89,6 @@ class _ClientImpl { enhancer, }) { this.game = Game(game); - this.ai = ai; this.playerID = playerID; this.gameID = gameID; this.credentials = credentials; diff --git a/src/client/debug/ai/AI.svelte b/src/client/debug/ai/AI.svelte index c2457bfa5..85a4bf3d8 100644 --- a/src/client/debug/ai/AI.svelte +++ b/src/client/debug/ai/AI.svelte @@ -40,10 +40,10 @@ } let bot; - if (client.ai) { + if (client.game.ai) { bot = new MCTSBot({ game: client.game, - enumerate: client.ai.enumerate, + enumerate: client.game.ai.enumerate, iterationCallback, }); bot.setOpt('async', true); @@ -56,7 +56,7 @@ const botConstructor = bots[selectedBot]; bot = new botConstructor({ game: client.game, - enumerate: client.ai.enumerate, + enumerate: client.game.ai.enumerate, iterationCallback, }); bot.setOpt('async', true); diff --git a/src/client/react.js b/src/client/react.js index 2c57b4264..6d94eaf7d 100644 --- a/src/client/react.js +++ b/src/client/react.js @@ -30,16 +30,7 @@ import { Client as RawClient } from './client'; * UNDO and REDO. */ export function Client(opts) { - let { - game, - numPlayers, - loading, - board, - multiplayer, - ai, - enhancer, - debug, - } = opts; + let { game, numPlayers, loading, board, multiplayer, enhancer, debug } = opts; // Component that is displayed before the client has synced // with the game master. @@ -85,7 +76,6 @@ export function Client(opts) { this.client = RawClient({ game, - ai, debug, numPlayers, multiplayer, diff --git a/src/client/transport/local.js b/src/client/transport/local.js index 754854d52..758aded96 100644 --- a/src/client/transport/local.js +++ b/src/client/transport/local.js @@ -41,6 +41,24 @@ export function LocalMaster(game) { return master; } +/** + * Returns null if it is not a bot's turn. + * Otherwise, returns a playerID of a bot that may play now. + */ +export function GetBotPlayer(state, bots) { + if (state.ctx.stage) { + for (const key of Object.keys(bots)) { + if (key in state.ctx.stage) { + return key; + } + } + } else if (state.ctx.currentPlayer in bots) { + return state.ctx.currentPlayer; + } + + return null; +} + /** * Local * @@ -58,11 +76,34 @@ export class LocalTransport extends Transport { * @param {string} numPlayers - The number of players. * @param {string} server - The game server in the form of 'hostname:port'. Defaults to the server serving the client if not provided. */ - constructor({ master, store, gameID, playerID, gameName, numPlayers }) { + constructor({ + master, + bots, + game, + store, + gameID, + playerID, + gameName, + numPlayers, + }) { super({ store, gameName, playerID, gameID, numPlayers }); this.master = master; + this.game = game; this.isConnected = true; + + if (game && game.ai && bots) { + this.bots = {}; + + for (const playerID in bots) { + const bot = bots[playerID]; + this.bots[playerID] = new bot({ + game, + enumerate: game.ai.enumerate, + seed: game.seed, + }); + } + } } /** @@ -70,15 +111,35 @@ export class LocalTransport extends Transport { * master broadcasts the update to other clients (including * this one). */ - onUpdate(gameID, state, deltalog) { + async onUpdate(gameID, state, deltalog) { const currentState = this.store.getState(); if (gameID == this.gameID && state._stateID >= currentState._stateID) { const action = ActionCreators.update(state, deltalog); this.store.dispatch(action); + + if (this.bots) { + const newState = this.store.getState(); + const botPlayer = GetBotPlayer(newState, this.bots); + if (botPlayer !== null) { + setTimeout(async () => { + await this.makeBotMove(newState, botPlayer); + }, 100); + } + } } } + async makeBotMove(state, playerID) { + const botAction = await this.bots[playerID].play(state, playerID); + await this.master.onUpdate( + botAction.action, + state._stateID, + this.gameID, + botAction.action.payload.playerID + ); + } + /** * Called when the client first connects to the master * and requests the current game state. @@ -149,7 +210,7 @@ export class LocalTransport extends Transport { } const localMasters = new Map(); -export function Local() { +export function Local(opts) { return transportOpts => { let master; @@ -160,6 +221,10 @@ export function Local() { localMasters.set(transportOpts.gameKey, master); } - return new LocalTransport({ master, ...transportOpts }); + return new LocalTransport({ + master, + bots: opts && opts.bots, + ...transportOpts, + }); }; } diff --git a/src/client/transport/local.test.js b/src/client/transport/local.test.js index 5ea5fe19c..b52e3356f 100644 --- a/src/client/transport/local.test.js +++ b/src/client/transport/local.test.js @@ -7,10 +7,106 @@ */ import { createStore } from 'redux'; -import { LocalTransport, LocalMaster } from './local'; +import { LocalTransport, LocalMaster, Local, GetBotPlayer } from './local'; import { makeMove, gameEvent } from '../../core/action-creators'; import { CreateGameReducer } from '../../core/reducer'; import { InitializeGame } from '../../core/initialize'; +import { Client } from '../client'; +import { RandomBot } from '../../ai/random-bot'; +import { Stage } from '../../core/turn-order'; + +jest.useFakeTimers(); + +describe('bots', () => { + const game = { + moves: { + A: G => G, + }, + ai: { + enumerate: () => [{ move: 'A' }], + }, + }; + + test('make bot move', async () => { + const client = Client({ + game, + playerID: '0', + multiplayer: Local({ bots: { '1': RandomBot } }), + }); + + client.start(); + + // Make it Player 1's turn and make the bot move. + // There isn't a good way to test the result of this + // due to the setTimeout and async calls. These are + // run primarily to cover the lines in the test and + // ensure that there are no exceptions. + client.events.endTurn(); + jest.runAllTimers(); + }); + + test('no bot move', async () => { + const client = Client({ + numPlayers: 3, + game, + playerID: '0', + multiplayer: Local({ bots: { '2': RandomBot } }), + }); + + client.start(); + + // Make it Player 1's turn. No bot move. + // There isn't a good way to test the result of this + // due to the setTimeout and async calls. These are + // run primarily to cover the lines in the test and + // ensure that there are no exceptions. + client.events.endTurn(); + jest.runAllTimers(); + }); +}); + +describe('GetBotPlayer', () => { + test('stages', () => { + const result = GetBotPlayer( + { + ctx: { + stage: { + '1': Stage.NULL, + }, + }, + }, + { + '0': {}, + '1': {}, + } + ); + expect(result).toEqual('1'); + }); + + test('no stages', () => { + const result = GetBotPlayer( + { + ctx: { + currentPlayer: '0', + }, + }, + { '0': {} } + ); + expect(result).toEqual('0'); + }); + + test('null', () => { + const result = GetBotPlayer( + { + ctx: { + currentPlayer: '1', + }, + }, + { '0': {} } + ); + expect(result).toEqual(null); + }); +}); describe('LocalMaster', () => { const game = {};