Skip to content

Commit

Permalink
add ability for Local multiplayer mode to run bots
Browse files Browse the repository at this point in the history
  • Loading branch information
nicolodavis committed Feb 11, 2020
1 parent b0a5175 commit ef8df65
Show file tree
Hide file tree
Showing 9 changed files with 204 additions and 52 deletions.
12 changes: 4 additions & 8 deletions docs/documentation/tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions examples/react-web/src/tic-tac-toe/game.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
16 changes: 5 additions & 11 deletions examples/react-web/src/tic-tac-toe/singleplayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => (
Expand Down
25 changes: 13 additions & 12 deletions src/ai/ai.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,27 +97,28 @@ 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 });
});

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

Expand All @@ -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 });
Expand Down
2 changes: 0 additions & 2 deletions src/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ export const createMoveDispatchers = createDispatchers.bind(null, 'makeMove');
class _ClientImpl {
constructor({
game,
ai,
debug,
numPlayers,
multiplayer,
Expand All @@ -90,7 +89,6 @@ class _ClientImpl {
enhancer,
}) {
this.game = Game(game);
this.ai = ai;
this.playerID = playerID;
this.gameID = gameID;
this.credentials = credentials;
Expand Down
6 changes: 3 additions & 3 deletions src/client/debug/ai/AI.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
12 changes: 1 addition & 11 deletions src/client/react.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -85,7 +76,6 @@ export function Client(opts) {

this.client = RawClient({
game,
ai,
debug,
numPlayers,
multiplayer,
Expand Down
73 changes: 69 additions & 4 deletions src/client/transport/local.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -58,27 +76,70 @@ 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,
});
}
}
}

/**
* Called when another player makes a move and the
* 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.
Expand Down Expand Up @@ -149,7 +210,7 @@ export class LocalTransport extends Transport {
}

const localMasters = new Map();
export function Local() {
export function Local(opts) {
return transportOpts => {
let master;

Expand All @@ -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,
});
};
}
Loading

0 comments on commit ef8df65

Please sign in to comment.