diff --git a/src/botserver/botserver.ts b/src/botserver/botserver.ts new file mode 100644 index 000000000..ade8a39b9 --- /dev/null +++ b/src/botserver/botserver.ts @@ -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; +} + +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); +} diff --git a/src/botserver/manager.test.ts b/src/botserver/manager.test.ts new file mode 100644 index 000000000..74e5c638b --- /dev/null +++ b/src/botserver/manager.test.ts @@ -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 = >Client; +const mockGetBotPlayer = >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); + }); +}); diff --git a/src/botserver/manager.ts b/src/botserver/manager.ts new file mode 100644 index 000000000..9364fdcce --- /dev/null +++ b/src/botserver/manager.ts @@ -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; +export type GameMonitorCallback = (state: State) => Promise; + +export class BotManager { + private clients: Map>; + 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 => { + 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); + } + }; + } +} diff --git a/src/client/transport/local.test.ts b/src/client/transport/local.test.ts index 0fccf2aaf..207661b1d 100644 --- a/src/client/transport/local.test.ts +++ b/src/client/transport/local.test.ts @@ -79,10 +79,7 @@ describe('GetBotPlayer', () => { }, }, } as unknown as State, - { - '0': {}, - '1': {}, - } + ['0', '1'] ); expect(result).toEqual('1'); }); @@ -94,7 +91,7 @@ describe('GetBotPlayer', () => { currentPlayer: '0', }, } as unknown as State, - { '0': {} } + ['0'] ); expect(result).toEqual('0'); }); @@ -106,7 +103,7 @@ describe('GetBotPlayer', () => { currentPlayer: '1', }, } as unknown as State, - { '0': {} } + ['0'] ); expect(result).toEqual(null); }); @@ -119,7 +116,7 @@ describe('GetBotPlayer', () => { gameover: true, }, } as unknown as State, - { '0': {} } + ['0'] ); expect(result).toEqual(null); }); diff --git a/src/client/transport/local.ts b/src/client/transport/local.ts index 9a64de74f..83cf13ca5 100644 --- a/src/client/transport/local.ts +++ b/src/client/transport/local.ts @@ -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) { +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; } @@ -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( diff --git a/src/server/api.test.ts b/src/server/api.test.ts index a5e1b4539..91a2fde7c 100644 --- a/src/server/api.test.ts +++ b/src/server/api.test.ts @@ -17,6 +17,9 @@ import { Auth } from './auth'; import * as StorageAPI from './db/base'; import { Origins } from './cors'; import type { Game, Server } from '../types'; +import type { GenericPubSub } from '../../packages/server'; +import type { BotCreationRequest } from '../botserver/botserver'; +import { BOT_SERVER_CHANNEL } from '../botserver/botserver'; jest.setTimeout(2000000000); @@ -29,6 +32,7 @@ type StorageMocks = Record< jest.Mock | ((...args: any[]) => any) >; +type PubSubMocks = Record<'publish' | 'subscribe' | 'unsuscribeAll', jest.Mock>; class AsyncStorage extends StorageAPI.Async { public mocks: StorageMocks; @@ -70,6 +74,29 @@ class AsyncStorage extends StorageAPI.Async { return this.mocks.listMatches(...args); } } +class MockBotServerPubSub implements GenericPubSub { + public mocks: PubSubMocks; + + constructor(args: Partial = {}) { + this.mocks = { + publish: args.publish || jest.fn(), + subscribe: args.subscribe || jest.fn(), + unsuscribeAll: args.unsuscribeAll || jest.fn(() => ({})), + }; + } + + async publish(...args) { + this.mocks.publish(...args); + } + + async subscribe(...args) { + return this.mocks.subscribe(...args); + } + + async unsubscribeAll(...args) { + this.mocks.unsuscribeAll(...args); + } +} describe('.configureRouter', () => { function addApiToServer({ @@ -581,6 +608,102 @@ describe('.configureRouter', () => { }); }); }); + describe('requesting bots to join a room', () => { + let response; + let db: AsyncStorage; + let botServerPubSub: MockBotServerPubSub; + const auth = new Auth(); + let games: Game[]; + let credentials: string; + + beforeEach(() => { + credentials = 'SECRET'; + games = [ProcessGameConfig({ name: 'foo' })]; + }); + describe('for an unprotected lobby', () => { + beforeEach(() => { + delete process.env.API_SECRET; + }); + describe('when the game does exist', () => { + beforeEach(async () => { + db = new AsyncStorage({ + fetch: async () => { + return { + metadata: { + players: { + '0': {}, + '1': {}, + '2': {}, + }, + }, + }; + }, + }); + botServerPubSub = new MockBotServerPubSub(); + }); + + describe('when the playerIDs are available', () => { + beforeEach(async () => { + const app = createApiServer({ + db, + auth: new Auth({ generateCredentials: () => credentials }), + games, + uuid: () => 'matchID', + botServerPubSub, + }); + response = await request(app.callback()) + .post('/games/foo/1/add-bots') + .send({ numBots: 3 }); + }); + + test('is successful', async () => { + expect(response.status).toEqual(200); + }); + + test('returns botNameList', async () => { + expect(response.body.botNameList).toHaveLength(3); + }); + + test('publishes to BOT_SERVER_CHANNEL', async () => { + expect(botServerPubSub.mocks.publish).toHaveBeenCalledWith( + BOT_SERVER_CHANNEL, + { + gameName: 'foo', + matchID: '1', + botOptsList: [ + { + playerID: '0', + playerCredentials: credentials, + }, + { + playerID: '1', + playerCredentials: credentials, + }, + { + playerID: '2', + playerCredentials: credentials, + }, + ], + } + ); + }); + + describe('when no botServerPubSub was provided in server config', () => { + beforeEach(async () => { + const app = createApiServer({ db, auth, games }); + response = await request(app.callback()) + .post('/games/foo/1/add-bots') + .send({ numBots: 3 }); + }); + + test('it fails', async () => { + expect(response.status).toEqual(409); + }); + }); + }); + }); + }); + }); describe('rename with deprecated endpoint', () => { let response; diff --git a/src/server/api.ts b/src/server/api.ts index 1bc6a609e..2bf2fc111 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -12,9 +12,12 @@ import type Router from '@koa/router'; import koaBody from 'koa-body'; import { nanoid } from 'nanoid'; import cors from '@koa/cors'; -import { createMatch, getFirstAvailablePlayerID, getNumPlayers } from './util'; +import { addPlayerToGame, createMatch } from './util'; import type { Auth } from './auth'; import type { Server, LobbyAPI, Game, StorageAPI } from '../types'; +import type { GenericPubSub } from './transport/pubsub/generic-pub-sub'; +import type { BotCreationRequest } from '../botserver/botserver'; +import { BOT_SERVER_CHANNEL } from '../botserver/botserver'; /** * Creates a new match. @@ -81,12 +84,14 @@ export const configureRouter = ({ auth, games, uuid = () => nanoid(11), + botServerPubSub, }: { router: Router; auth: Auth; games: Game[]; uuid?: () => string; db: StorageAPI.Sync | StorageAPI.Async; + botServerPubSub?: GenericPubSub; }) => { /** * List available games. @@ -220,6 +225,68 @@ export const configureRouter = ({ ctx.body = body; }); + /** + * Request that bots join a match + * + * @param {string} name - The name of the game. + * @param {string} id - The ID of the match. + * @param {number} numBots - amount of bots to add + * + */ + router.post('/games/:name/:id/add-bots', koaBody(), async (ctx) => { + if (!botServerPubSub) { + ctx.throw(409, 'BotServer config not provided at server start up'); + } + + const data = ctx.request.body.data; + const matchID = ctx.params.id; + const gameName = ctx.params.name; + let numBots = ctx.request.body.numBots; + if (!numBots) { + numBots = 1; + } + + let { metadata } = await (db as StorageAPI.Async).fetch(matchID, { + metadata: true, + }); + if (!metadata) { + ctx.throw(404, 'Match ' + matchID + ' not found'); + } + + const botNameList = []; + const botOptsList: { playerID: string; playerCredentials: string }[] = []; + for (let i = 0; i < numBots; i++) { + const playerData = await addPlayerToGame( + ctx, + null, + metadata, + matchID, + data, + auth + ); + const { playerCredentials, playerID } = playerData; + metadata = playerData.metadata; + + const botName = `bot${playerID}`; + metadata.players[playerID].name = botName; + await db.setMetadata(matchID, metadata); + botNameList.push(botName); + + botOptsList.push({ + playerID, + playerCredentials, + }); + } + + botServerPubSub.publish(BOT_SERVER_CHANNEL, { + gameName, + matchID, + botOptsList, + }); + const body: LobbyAPI.BotsJoinedMatch = { botNameList }; + ctx.body = body; + }); + /** * Join a given match. * @@ -239,37 +306,23 @@ export const configureRouter = ({ ctx.throw(403, 'playerName is required'); } - const { metadata } = await (db as StorageAPI.Async).fetch(matchID, { + let { metadata } = await (db as StorageAPI.Async).fetch(matchID, { metadata: true, }); if (!metadata) { ctx.throw(404, 'Match ' + matchID + ' not found'); } - if (typeof playerID === 'undefined' || playerID === null) { - playerID = getFirstAvailablePlayerID(metadata.players); - if (playerID === undefined) { - const numPlayers = getNumPlayers(metadata.players); - ctx.throw( - 409, - `Match ${matchID} reached maximum number of players (${numPlayers})` - ); - } - } - - if (!metadata.players[playerID]) { - ctx.throw(404, 'Player ' + playerID + ' not found'); - } - if (metadata.players[playerID].name) { - ctx.throw(409, 'Player ' + playerID + ' not available'); - } - - if (data) { - metadata.players[playerID].data = data; - } - metadata.players[playerID].name = playerName; - const playerCredentials = await auth.generateCredentials(ctx); - metadata.players[playerID].credentials = playerCredentials; + let playerCredentials; + ({ metadata, playerCredentials, playerID } = await addPlayerToGame( + ctx, + playerID, + metadata, + matchID, + data, + auth, + playerName + )); await db.setMetadata(matchID, metadata); diff --git a/src/server/index.ts b/src/server/index.ts index 811cc11cf..1b1ad5ca2 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -17,6 +17,11 @@ import * as logger from '../core/logger'; import { Auth } from './auth'; import { SocketIO } from './transport/socketio'; import type { Server as ServerTypes, Game, StorageAPI } from '../types'; +import type { BotCallback } from '../botserver/manager'; +import type { GenericPubSub } from './transport/pubsub/generic-pub-sub'; +import type { BotCreationRequest } from '../botserver/botserver'; +import { runBotServer } from '../botserver/botserver'; +import { InMemoryPubSub } from './transport/pubsub/in-memory-pub-sub'; export type KoaServer = ReturnType; @@ -67,6 +72,10 @@ interface ServerOpts { authenticateCredentials?: ServerTypes.AuthenticateCredentials; generateCredentials?: ServerTypes.GenerateCredentials; https?: HttpsOptions; + botServerConfig?: { + runBot?: BotCallback; + pubSub?: GenericPubSub; + }; } /** @@ -92,6 +101,7 @@ export function Server({ apiOrigins = origins, generateCredentials = uuid, authenticateCredentials, + botServerConfig, }: ServerOpts) { const app: ServerTypes.App = new Koa(); @@ -118,6 +128,10 @@ export function Server({ } transport.init(app, games, origins); + if (botServerConfig?.runBot && !botServerConfig.pubSub) { + botServerConfig.pubSub = new InMemoryPubSub(); + } + const router = new Router(); return { @@ -129,7 +143,25 @@ export function Server({ run: async (portOrConfig: number | ServerConfig, callback?: () => void) => { const serverRunConfig = createServerRunConfig(portOrConfig, callback); - configureRouter({ router, db, games, uuid, auth }); + configureRouter({ + router, + db, + games, + uuid, + auth, + botServerPubSub: botServerConfig?.pubSub, + }); + + //BotServer + if (botServerConfig?.runBot) { + const { runBot, pubSub } = botServerConfig; + runBotServer({ + games, + runBot, + masterServerHost: `localhost:${serverRunConfig.port}`, + pubSub, + }); + } // DB await db.connect(); diff --git a/src/server/util.ts b/src/server/util.ts index b6fc16396..84246c182 100644 --- a/src/server/util.ts +++ b/src/server/util.ts @@ -1,5 +1,6 @@ import { InitializeGame } from '../core/initialize'; import type { Server, State, Game } from '../types'; +import type { Auth } from './auth'; /** * Creates a new match metadata object. @@ -83,3 +84,52 @@ export const getFirstAvailablePlayerID = ( } } }; + +/** + * Add player to a game + * + */ +export const addPlayerToGame = async ( + ctx, + playerID: string, + metadata: Server.MatchData, + matchID: string, + data: any, + auth: Auth, + playerName?: string +): Promise<{ + metadata: Server.MatchData; + playerCredentials: string; + playerID: string; +}> => { + if (typeof playerID === 'undefined' || playerID === null) { + playerID = getFirstAvailablePlayerID(metadata.players); + if (playerID === undefined) { + const numPlayers = getNumPlayers(metadata.players); + ctx.throw( + 409, + `Match ${matchID} reached maximum number of players (${numPlayers})` + ); + } + } + + if (!metadata.players[playerID]) { + ctx.throw(404, 'Player ' + playerID + ' not found'); + } + if (metadata.players[playerID].name) { + ctx.throw(409, 'Player ' + playerID + ' not available'); + } + + if (data) { + metadata.players[playerID].data = data; + } + + if (playerName) { + metadata.players[playerID].name = playerName; + } + + const playerCredentials = await auth.generateCredentials(ctx); + metadata.players[playerID].credentials = playerCredentials; + + return { metadata, playerCredentials, playerID }; +}; diff --git a/src/types.ts b/src/types.ts index 0c07e6b95..1537c0786 100644 --- a/src/types.ts +++ b/src/types.ts @@ -414,6 +414,9 @@ export namespace LobbyAPI { playerID: string; playerCredentials: string; } + export interface BotsJoinedMatch { + botNameList: string[]; + } export interface NextMatch { nextMatchID: string; }