Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Andrscyv/botserver #1081

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
51 changes: 51 additions & 0 deletions src/botserver/botserver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { GenericPubSub } from '../server/transport/pubsub/generic-pub-sub';
import type { Game } from '../types';
import type { BotCallback } from './manager';
import { BotManager } from './manager';
export interface BotCreationRequest {
gameName: string;
matchID: string;
botOptsList: { playerID: string; playerCredentials: string }[];
}

interface BotServerConfig {
games: Game[];
runBot: BotCallback;
masterServerHost: string;
pubSub: GenericPubSub<BotCreationRequest>;
}

function validateConfig(config: BotServerConfig): BotServerConfig {
if (!config) {
throw new Error('BotServer config required');
}

const { games, runBot, masterServerHost, pubSub } = config;

if (!games?.length) {
throw new Error('At least one game required');
}

if (!runBot || typeof runBot !== 'function') {
throw new Error('runBot callback function required');
}

if (!masterServerHost) {
throw new Error('masterServerHost string required');
}

if (!pubSub) {
throw new Error('pubSub required');
}

return config;
}

export const BOT_SERVER_CHANNEL = 'botServer';

export function runBotServer(botServerConfig: BotServerConfig): void {
const { games, runBot, masterServerHost, pubSub } =
validateConfig(botServerConfig);
const manager = new BotManager(games, runBot, masterServerHost);
pubSub.subscribe(BOT_SERVER_CHANNEL, manager.addBotsToGame);
}
123 changes: 123 additions & 0 deletions src/botserver/manager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { BotExecutionResult, GameMonitorCallback } from './manager';
import { BotManager } from './manager';
import { Client } from '../client/client';
import { GetBotPlayer } from '../client/transport/local';

jest.mock('../core/logger', () => ({
info: () => {},
error: () => {},
}));

jest.mock('../client/client', () => ({
Client: jest.fn(),
}));

jest.mock('../client/transport/local', () => ({
GetBotPlayer: jest.fn(),
}));

const mockClient = <jest.Mock<any, any>>Client;
const mockGetBotPlayer = <jest.Mock<any, any>>GetBotPlayer;
const masterServerHost = 'localhost:3000';
describe('Bot manager', () => {
const gameName = 'testGame';
const game = {
moves: { A: (G, ctx) => ({ A: ctx.playerID }) },
name: gameName,
};
const mockClientImpl = {
start: jest.fn(),
subscribe: jest.fn(),
};
const runBot = jest.fn();
afterEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
it('creates new BotManager instance', () => {
const botManager = new BotManager([game], runBot, masterServerHost);
expect(botManager).toBeDefined();
});
it('adds single bot to game', () => {
const botManager = new BotManager([game], runBot, masterServerHost);
mockClient.mockReturnValue(mockClientImpl);
expect(mockClient).not.toBeCalled();
expect(mockClientImpl.start).not.toBeCalled();
expect(mockClientImpl.subscribe).not.toBeCalled();
botManager.addBotsToGame({
gameName,
matchID: 'testMatchId',
botOptsList: [{ playerID: 'p1', playerCredentials: 'pc1' }],
});
expect(mockClient).toBeCalled();
expect(mockClientImpl.start).toBeCalled();
expect(mockClientImpl.subscribe).toBeCalled();
});
it('adds multiple bots to game', () => {
const botManager = new BotManager([game], runBot, masterServerHost);
mockClient.mockReturnValue(mockClientImpl);
expect(mockClient).not.toBeCalled();
expect(mockClientImpl.start).not.toBeCalled();
expect(mockClientImpl.subscribe).not.toBeCalled();
botManager.addBotsToGame({
gameName,
matchID: 'testMatchId',
botOptsList: [
{ playerID: 'p1', playerCredentials: 'pc1' },
{ playerID: 'p2', playerCredentials: 'pc2' },
{ playerID: 'p3', playerCredentials: 'pc3' },
],
});
expect(mockClient).toBeCalled();
expect(mockClientImpl.start).toBeCalledTimes(3);
expect(mockClientImpl.subscribe).toBeCalledTimes(3);
});
it('calls runBot when its bots turn', async () => {
const moveName = 'testMove';
const moveArgs = { arg1: 1 };
const executionResult: BotExecutionResult = { moveName, moveArgs };
runBot.mockResolvedValueOnce(executionResult);
const botManager = new BotManager([game], runBot, masterServerHost);
const clientP1 = {
start: jest.fn(),
subscribe: jest.fn(),
};
const clientP2 = {
name: 'client2',
start: jest.fn(),
subscribe: jest.fn(),
moves: {
[moveName]: jest.fn(),
},
};
const clientP3 = {
start: jest.fn(),
subscribe: jest.fn(),
};
mockClient
.mockReturnValueOnce(clientP1)
.mockReturnValueOnce(clientP2)
.mockReturnValueOnce(clientP3);

botManager.addBotsToGame({
gameName,
matchID: 'testMatchId',
botOptsList: [
{ playerID: 'p1', playerCredentials: 'pc1' },
{ playerID: 'p2', playerCredentials: 'pc2' },
{ playerID: 'p3', playerCredentials: 'pc3' },
],
});
const gameMonitor: GameMonitorCallback =
clientP2.subscribe.mock.calls[0][0];

mockGetBotPlayer.mockReturnValue('p2');

// fake a game state update
const state = { data: 'testData' };
await gameMonitor(state as any);

expect(runBot).toBeCalledWith(state);
expect(clientP2.moves[moveName]).toBeCalledWith(moveArgs);
});
});
80 changes: 80 additions & 0 deletions src/botserver/manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { ClientState, _ClientImpl } from '../client/client';
import { Client } from '../client/client';
import { GetBotPlayer } from '../client/transport/local';
import { SocketIO } from '../client/transport/socketio';
import type { Game } from '../types';
import type { State } from '../types';
import type { BotCreationRequest } from './botserver';

export interface BotExecutionResult {
moveName: string;
moveArgs: any;
}

export type BotCallback = (state: State) => Promise<BotExecutionResult>;
export type GameMonitorCallback = (state: State) => Promise<void>;

export class BotManager {
private clients: Map<string, Map<string, _ClientImpl>>;
constructor(
private games: Game[],
private runBot: BotCallback,
private masterServerHost: string
) {
this.clients = new Map();
}

private saveClient(
matchID: string,
playerID: string,
client: _ClientImpl
): void {
if (!this.clients.has(matchID)) {
this.clients.set(matchID, new Map());
}

this.clients.get(matchID).set(playerID, client);
}

private getClient(matchID: string, playerID: string): _ClientImpl {
if (this.clients.has(matchID)) {
return this.clients.get(matchID).get(playerID);
}
}

addBotsToGame(params: BotCreationRequest): void {
const { gameName, matchID, botOptsList } = params;
const game = this.games.find((game) => game.name === gameName);
for (const botOpts of botOptsList) {
const { playerID, playerCredentials } = botOpts;

const client = Client({
game,
multiplayer: SocketIO({
server: this.masterServerHost,
}),
playerID,
matchID,
credentials: playerCredentials,
debug: false,
});

client.start();
client.subscribe(this.buildGameMonitor(matchID, playerID));
this.saveClient(matchID, playerID, client);
}
return;
}

buildGameMonitor(matchID: string, playerID: string): GameMonitorCallback {
return async (state: ClientState): Promise<void> => {
const botIDs = [...this.clients.get(matchID).keys()];
const botPlayerID = GetBotPlayer(state, botIDs);
if (botPlayerID) {
const { moveName, moveArgs } = await this.runBot(state);
const client = this.getClient(matchID, playerID);
client.moves[moveName](moveArgs);
}
};
}
}
11 changes: 4 additions & 7 deletions src/client/transport/local.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,7 @@ describe('GetBotPlayer', () => {
},
},
} as unknown as State,
{
'0': {},
'1': {},
}
['0', '1']
);
expect(result).toEqual('1');
});
Expand All @@ -94,7 +91,7 @@ describe('GetBotPlayer', () => {
currentPlayer: '0',
},
} as unknown as State,
{ '0': {} }
['0']
);
expect(result).toEqual('0');
});
Expand All @@ -106,7 +103,7 @@ describe('GetBotPlayer', () => {
currentPlayer: '1',
},
} as unknown as State,
{ '0': {} }
['0']
);
expect(result).toEqual(null);
});
Expand All @@ -119,7 +116,7 @@ describe('GetBotPlayer', () => {
gameover: true,
},
} as unknown as State,
{ '0': {} }
['0']
);
expect(result).toEqual(null);
});
Expand Down
12 changes: 6 additions & 6 deletions src/client/transport/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,18 @@ import { getFilterPlayerView } from '../../master/filter-player-view';
* 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: State, bots: Record<PlayerID, any>) {
export function GetBotPlayer(state: State, botIDs: PlayerID[]) {
if (state.ctx.gameover !== undefined) {
return null;
}

if (state.ctx.activePlayers) {
for (const key of Object.keys(bots)) {
if (key in state.ctx.activePlayers) {
return key;
for (const botID of botIDs) {
if (botID in state.ctx.activePlayers) {
return botID;
}
}
} else if (state.ctx.currentPlayer in bots) {
} else if (botIDs.includes(state.ctx.currentPlayer)) {
return state.ctx.currentPlayer;
}

Expand Down Expand Up @@ -105,7 +105,7 @@ export class LocalMaster extends Master {
if (!bots) {
return;
}
const botPlayer = GetBotPlayer(state, initializedBots);
const botPlayer = GetBotPlayer(state, Object.keys(initializedBots));
if (botPlayer !== null) {
setTimeout(async () => {
const botAction = await initializedBots[botPlayer].play(
Expand Down