From 604d12e6cedfed2643d70a44ace7a00113abcc98 Mon Sep 17 00:00:00 2001 From: Lior Pollak <4294489+liorp@users.noreply.github.com> Date: Sat, 2 Oct 2021 03:09:23 +0300 Subject: [PATCH] feat(lobby): use first available `playerID` when joining a match (#1013) Co-authored-by: Chris Swithinbank --- docs/documentation/api/Lobby.md | 7 ++-- src/lobby/client.test.ts | 7 +++- src/lobby/client.ts | 29 +++++++++++++---- src/server/api.test.ts | 58 ++++++++++++++++++++++++++++++--- src/server/api.ts | 28 +++++++++++----- src/server/util.ts | 22 +++++++++++++ src/types.ts | 1 + 7 files changed, 128 insertions(+), 24 deletions(-) diff --git a/docs/documentation/api/Lobby.md b/docs/documentation/api/Lobby.md index a4983ea53..b2ec5f152 100644 --- a/docs/documentation/api/Lobby.md +++ b/docs/documentation/api/Lobby.md @@ -147,13 +147,14 @@ Allows a player to join a particular match instance `id` of a game named `name`. Accepts three JSON body parameters: -- `playerID` (required): the ordinal player in the match that is being joined (`'0'`, `'1'`...). - - `playerName` (required): the display name of the player joining the match. +- `playerID` (optional): the ordinal player in the match that is being joined (`'0'`, `'1'`...). +If not sent, will be automatically assigned to the first available ordinal. + - `data` (optional): additional metadata to associate with the player. -Returns `playerCredentials` which is the token this player will require to authenticate their actions in the future. +Returns `playerCredentials` which is the token this player will require to authenticate their actions in the future and `playerID`, which can be useful if you didn’t specify a `playerID` when making the request. #### Using a LobbyClient instance diff --git a/src/lobby/client.test.ts b/src/lobby/client.test.ts index 838d8fa5c..aab5fb415 100644 --- a/src/lobby/client.test.ts +++ b/src/lobby/client.test.ts @@ -291,7 +291,7 @@ describe('LobbyClient', () => { playerName: 'Bob', }) ).rejects.toThrow( - 'Expected body.playerID to be of type string, got “0”.' + 'Expected body.playerID to be of type string|undefined, got “0”.' ); await expect( @@ -302,6 +302,11 @@ describe('LobbyClient', () => { ).rejects.toThrow( 'Expected body.playerName to be of type string, got “undefined”.' ); + + // Allows requests that don’t specify `playerID`. + await expect( + client.joinMatch('tic-tac-toe', 'xyz', { playerName: 'Bob' }) + ).resolves.not.toThrow(); }); }); diff --git a/src/lobby/client.ts b/src/lobby/client.ts index 66e994eec..50d7b6861 100644 --- a/src/lobby/client.ts +++ b/src/lobby/client.ts @@ -8,17 +8,29 @@ const assertString = (str: unknown, label: string) => { const assertGameName = (name?: string) => assertString(name, 'game name'); const assertMatchID = (id?: string) => assertString(id, 'match ID'); +type JSType = + | 'string' + | 'number' + | 'bigint' + | 'object' + | 'boolean' + | 'symbol' + | 'function' + | 'undefined'; + const validateBody = ( body: { [key: string]: any } | undefined, - schema: { [key: string]: 'string' | 'number' | 'object' | 'boolean' } + schema: { [key: string]: JSType | JSType[] } ) => { if (!body) throw new Error(`Expected body, got “${body}”.`); for (const key in schema) { - const type = schema[key]; + const propSchema = schema[key]; + const types = Array.isArray(propSchema) ? propSchema : [propSchema]; const received = body[key]; - if (typeof received !== type) { + if (!types.includes(typeof received)) { + const union = types.join('|'); throw new TypeError( - `Expected body.${key} to be of type ${type}, got “${received}”.` + `Expected body.${key} to be of type ${union}, got “${received}”.` ); } } @@ -214,13 +226,13 @@ export class LobbyClient { * playerID: '1', * playerName: 'Bob', * }).then(console.log); - * // => { playerCredentials: 'random-string' } + * // => { playerID: '1', playerCredentials: 'random-string' } */ async joinMatch( gameName: string, matchID: string, body: { - playerID: string; + playerID?: string; playerName: string; data?: any; [key: string]: any; @@ -229,7 +241,10 @@ export class LobbyClient { ): Promise { assertGameName(gameName); assertMatchID(matchID); - validateBody(body, { playerID: 'string', playerName: 'string' }); + validateBody(body, { + playerID: ['string', 'undefined'], + playerName: 'string', + }); return this.post(`/games/${gameName}/${matchID}/join`, { body, init }); } diff --git a/src/server/api.test.ts b/src/server/api.test.ts index a8172bfb6..bca35ec54 100644 --- a/src/server/api.test.ts +++ b/src/server/api.test.ts @@ -449,14 +449,64 @@ describe('.configureRouter', () => { describe('when playerID is omitted', () => { beforeEach(async () => { - const app = createApiServer({ db, auth, games }); + const app = createApiServer({ + db, + auth: new Auth({ generateCredentials: () => credentials }), + games, + uuid: () => 'matchID', + }); response = await request(app.callback()) .post('/games/foo/1/join') - .send('playerName=1'); + .send('playerName=alice'); }); - test('throws error 403', async () => { - expect(response.status).toEqual(403); + describe('numPlayers is reached in match', () => { + beforeEach(async () => { + db = new AsyncStorage({ + fetch: async () => { + return { + metadata: { + players: { + '0': { name: 'alice' }, + }, + }, + }; + }, + }); + const app = createApiServer({ db, auth, games }); + response = await request(app.callback()) + .post('/games/foo/1/join') + .send('playerName=bob'); + }); + + test('throws error 409', async () => { + expect(response.status).toEqual(409); + }); + }); + + test('is successful', async () => { + expect(response.status).toEqual(200); + }); + + test('returns the player credentials', async () => { + expect(response.body.playerCredentials).toEqual(credentials); + }); + + test('returns the playerID', async () => { + expect(response.body.playerID).toEqual('0'); + }); + + test('updates the player name', async () => { + expect(db.mocks.setMetadata).toHaveBeenCalledWith( + '1', + expect.objectContaining({ + players: expect.objectContaining({ + '0': expect.objectContaining({ + name: 'alice', + }), + }), + }) + ); }); }); diff --git a/src/server/api.ts b/src/server/api.ts index d39b82889..c9dcc510d 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -12,7 +12,7 @@ import koaBody from 'koa-body'; import { nanoid } from 'nanoid'; import cors from '@koa/cors'; import type IOTypes from 'socket.io'; -import { createMatch } from './util'; +import { createMatch, getFirstAvailablePlayerID, getNumPlayers } from './util'; import type { Auth } from './auth'; import type { Server, LobbyAPI, Game, StorageAPI } from '../types'; @@ -213,28 +213,38 @@ export const configureRouter = ({ * * @param {string} name - The name of the game. * @param {string} id - The ID of the match. - * @param {string} playerID - The ID of the player who joins. + * @param {string} playerID - The ID of the player who joins. If not sent, will be assigned to the first index available. * @param {string} playerName - The name of the player who joins. * @param {object} data - The default data of the player in the match. - * @return - Player credentials to use when interacting in the joined match. + * @return - Player ID and credentials to use when interacting in the joined match. */ router.post('/games/:name/:id/join', koaBody(), async (ctx) => { - const playerID = ctx.request.body.playerID; + let playerID = ctx.request.body.playerID; const playerName = ctx.request.body.playerName; const data = ctx.request.body.data; - if (typeof playerID === 'undefined' || playerID === null) { - ctx.throw(403, 'playerID is required'); - } + const matchID = ctx.params.id; if (!playerName) { ctx.throw(403, 'playerName is required'); } - const matchID = ctx.params.id; + const { 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'); } @@ -251,7 +261,7 @@ export const configureRouter = ({ await db.setMetadata(matchID, metadata); - const body: LobbyAPI.JoinedMatch = { playerCredentials }; + const body: LobbyAPI.JoinedMatch = { playerID, playerCredentials }; ctx.body = body; }); diff --git a/src/server/util.ts b/src/server/util.ts index 227944dc1..d12ae4345 100644 --- a/src/server/util.ts +++ b/src/server/util.ts @@ -61,3 +61,25 @@ export const createMatch = ({ return { metadata, initialState }; }; + +/** + * Given players, returns the count of players. + */ +export const getNumPlayers = (players: Server.MatchData['players']): number => + Object.keys(players).length; + +/** + * Given players, tries to find the ID of the first player that can be joined. + * Returns `undefined` if there’s no available ID. + */ +export const getFirstAvailablePlayerID = ( + players: Server.MatchData['players'] +): string | undefined => { + const numPlayers = getNumPlayers(players); + // Try to get the first index available + for (let i = 0; i < numPlayers; i++) { + if (typeof players[i].name === 'undefined' || players[i].name === null) { + return String(i); + } + } +}; diff --git a/src/types.ts b/src/types.ts index 9fe321d0f..75e2c4f71 100644 --- a/src/types.ts +++ b/src/types.ts @@ -409,6 +409,7 @@ export namespace LobbyAPI { matchID: string; } export interface JoinedMatch { + playerID: string; playerCredentials: string; } export interface NextMatch {