Skip to content

Commit 604d12e

Browse files
liorpdelucis
andauthored
feat(lobby): use first available playerID when joining a match (#1013)
Co-authored-by: Chris Swithinbank <[email protected]>
1 parent 298ecaf commit 604d12e

7 files changed

Lines changed: 128 additions & 24 deletions

File tree

docs/documentation/api/Lobby.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,13 +147,14 @@ Allows a player to join a particular match instance `id` of a game named `name`.
147147

148148
Accepts three JSON body parameters:
149149

150-
- `playerID` (required): the ordinal player in the match that is being joined (`'0'`, `'1'`...).
151-
152150
- `playerName` (required): the display name of the player joining the match.
153151

152+
- `playerID` (optional): the ordinal player in the match that is being joined (`'0'`, `'1'`...).
153+
If not sent, will be automatically assigned to the first available ordinal.
154+
154155
- `data` (optional): additional metadata to associate with the player.
155156

156-
Returns `playerCredentials` which is the token this player will require to authenticate their actions in the future.
157+
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.
157158

158159
#### Using a LobbyClient instance
159160

src/lobby/client.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ describe('LobbyClient', () => {
291291
playerName: 'Bob',
292292
})
293293
).rejects.toThrow(
294-
'Expected body.playerID to be of type string, got “0”.'
294+
'Expected body.playerID to be of type string|undefined, got “0”.'
295295
);
296296

297297
await expect(
@@ -302,6 +302,11 @@ describe('LobbyClient', () => {
302302
).rejects.toThrow(
303303
'Expected body.playerName to be of type string, got “undefined”.'
304304
);
305+
306+
// Allows requests that don’t specify `playerID`.
307+
await expect(
308+
client.joinMatch('tic-tac-toe', 'xyz', { playerName: 'Bob' })
309+
).resolves.not.toThrow();
305310
});
306311
});
307312

src/lobby/client.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,29 @@ const assertString = (str: unknown, label: string) => {
88
const assertGameName = (name?: string) => assertString(name, 'game name');
99
const assertMatchID = (id?: string) => assertString(id, 'match ID');
1010

11+
type JSType =
12+
| 'string'
13+
| 'number'
14+
| 'bigint'
15+
| 'object'
16+
| 'boolean'
17+
| 'symbol'
18+
| 'function'
19+
| 'undefined';
20+
1121
const validateBody = (
1222
body: { [key: string]: any } | undefined,
13-
schema: { [key: string]: 'string' | 'number' | 'object' | 'boolean' }
23+
schema: { [key: string]: JSType | JSType[] }
1424
) => {
1525
if (!body) throw new Error(`Expected body, got “${body}”.`);
1626
for (const key in schema) {
17-
const type = schema[key];
27+
const propSchema = schema[key];
28+
const types = Array.isArray(propSchema) ? propSchema : [propSchema];
1829
const received = body[key];
19-
if (typeof received !== type) {
30+
if (!types.includes(typeof received)) {
31+
const union = types.join('|');
2032
throw new TypeError(
21-
`Expected body.${key} to be of type ${type}, got “${received}”.`
33+
`Expected body.${key} to be of type ${union}, got “${received}”.`
2234
);
2335
}
2436
}
@@ -214,13 +226,13 @@ export class LobbyClient {
214226
* playerID: '1',
215227
* playerName: 'Bob',
216228
* }).then(console.log);
217-
* // => { playerCredentials: 'random-string' }
229+
* // => { playerID: '1', playerCredentials: 'random-string' }
218230
*/
219231
async joinMatch(
220232
gameName: string,
221233
matchID: string,
222234
body: {
223-
playerID: string;
235+
playerID?: string;
224236
playerName: string;
225237
data?: any;
226238
[key: string]: any;
@@ -229,7 +241,10 @@ export class LobbyClient {
229241
): Promise<LobbyAPI.JoinedMatch> {
230242
assertGameName(gameName);
231243
assertMatchID(matchID);
232-
validateBody(body, { playerID: 'string', playerName: 'string' });
244+
validateBody(body, {
245+
playerID: ['string', 'undefined'],
246+
playerName: 'string',
247+
});
233248
return this.post(`/games/${gameName}/${matchID}/join`, { body, init });
234249
}
235250

src/server/api.test.ts

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -449,14 +449,64 @@ describe('.configureRouter', () => {
449449

450450
describe('when playerID is omitted', () => {
451451
beforeEach(async () => {
452-
const app = createApiServer({ db, auth, games });
452+
const app = createApiServer({
453+
db,
454+
auth: new Auth({ generateCredentials: () => credentials }),
455+
games,
456+
uuid: () => 'matchID',
457+
});
453458
response = await request(app.callback())
454459
.post('/games/foo/1/join')
455-
.send('playerName=1');
460+
.send('playerName=alice');
456461
});
457462

458-
test('throws error 403', async () => {
459-
expect(response.status).toEqual(403);
463+
describe('numPlayers is reached in match', () => {
464+
beforeEach(async () => {
465+
db = new AsyncStorage({
466+
fetch: async () => {
467+
return {
468+
metadata: {
469+
players: {
470+
'0': { name: 'alice' },
471+
},
472+
},
473+
};
474+
},
475+
});
476+
const app = createApiServer({ db, auth, games });
477+
response = await request(app.callback())
478+
.post('/games/foo/1/join')
479+
.send('playerName=bob');
480+
});
481+
482+
test('throws error 409', async () => {
483+
expect(response.status).toEqual(409);
484+
});
485+
});
486+
487+
test('is successful', async () => {
488+
expect(response.status).toEqual(200);
489+
});
490+
491+
test('returns the player credentials', async () => {
492+
expect(response.body.playerCredentials).toEqual(credentials);
493+
});
494+
495+
test('returns the playerID', async () => {
496+
expect(response.body.playerID).toEqual('0');
497+
});
498+
499+
test('updates the player name', async () => {
500+
expect(db.mocks.setMetadata).toHaveBeenCalledWith(
501+
'1',
502+
expect.objectContaining({
503+
players: expect.objectContaining({
504+
'0': expect.objectContaining({
505+
name: 'alice',
506+
}),
507+
}),
508+
})
509+
);
460510
});
461511
});
462512

src/server/api.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import koaBody from 'koa-body';
1212
import { nanoid } from 'nanoid';
1313
import cors from '@koa/cors';
1414
import type IOTypes from 'socket.io';
15-
import { createMatch } from './util';
15+
import { createMatch, getFirstAvailablePlayerID, getNumPlayers } from './util';
1616
import type { Auth } from './auth';
1717
import type { Server, LobbyAPI, Game, StorageAPI } from '../types';
1818

@@ -213,28 +213,38 @@ export const configureRouter = ({
213213
*
214214
* @param {string} name - The name of the game.
215215
* @param {string} id - The ID of the match.
216-
* @param {string} playerID - The ID of the player who joins.
216+
* @param {string} playerID - The ID of the player who joins. If not sent, will be assigned to the first index available.
217217
* @param {string} playerName - The name of the player who joins.
218218
* @param {object} data - The default data of the player in the match.
219-
* @return - Player credentials to use when interacting in the joined match.
219+
* @return - Player ID and credentials to use when interacting in the joined match.
220220
*/
221221
router.post('/games/:name/:id/join', koaBody(), async (ctx) => {
222-
const playerID = ctx.request.body.playerID;
222+
let playerID = ctx.request.body.playerID;
223223
const playerName = ctx.request.body.playerName;
224224
const data = ctx.request.body.data;
225-
if (typeof playerID === 'undefined' || playerID === null) {
226-
ctx.throw(403, 'playerID is required');
227-
}
225+
const matchID = ctx.params.id;
228226
if (!playerName) {
229227
ctx.throw(403, 'playerName is required');
230228
}
231-
const matchID = ctx.params.id;
229+
232230
const { metadata } = await (db as StorageAPI.Async).fetch(matchID, {
233231
metadata: true,
234232
});
235233
if (!metadata) {
236234
ctx.throw(404, 'Match ' + matchID + ' not found');
237235
}
236+
237+
if (typeof playerID === 'undefined' || playerID === null) {
238+
playerID = getFirstAvailablePlayerID(metadata.players);
239+
if (playerID === undefined) {
240+
const numPlayers = getNumPlayers(metadata.players);
241+
ctx.throw(
242+
409,
243+
`Match ${matchID} reached maximum number of players (${numPlayers})`
244+
);
245+
}
246+
}
247+
238248
if (!metadata.players[playerID]) {
239249
ctx.throw(404, 'Player ' + playerID + ' not found');
240250
}
@@ -251,7 +261,7 @@ export const configureRouter = ({
251261

252262
await db.setMetadata(matchID, metadata);
253263

254-
const body: LobbyAPI.JoinedMatch = { playerCredentials };
264+
const body: LobbyAPI.JoinedMatch = { playerID, playerCredentials };
255265
ctx.body = body;
256266
});
257267

src/server/util.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,25 @@ export const createMatch = ({
6161

6262
return { metadata, initialState };
6363
};
64+
65+
/**
66+
* Given players, returns the count of players.
67+
*/
68+
export const getNumPlayers = (players: Server.MatchData['players']): number =>
69+
Object.keys(players).length;
70+
71+
/**
72+
* Given players, tries to find the ID of the first player that can be joined.
73+
* Returns `undefined` if there’s no available ID.
74+
*/
75+
export const getFirstAvailablePlayerID = (
76+
players: Server.MatchData['players']
77+
): string | undefined => {
78+
const numPlayers = getNumPlayers(players);
79+
// Try to get the first index available
80+
for (let i = 0; i < numPlayers; i++) {
81+
if (typeof players[i].name === 'undefined' || players[i].name === null) {
82+
return String(i);
83+
}
84+
}
85+
};

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,7 @@ export namespace LobbyAPI {
409409
matchID: string;
410410
}
411411
export interface JoinedMatch {
412+
playerID: string;
412413
playerCredentials: string;
413414
}
414415
export interface NextMatch {

0 commit comments

Comments
 (0)