Skip to content

Commit

Permalink
feat(lobby): use first available playerID when joining a match (#1013)
Browse files Browse the repository at this point in the history
Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>
  • Loading branch information
liorp and delucis authored Oct 2, 2021
1 parent 298ecaf commit 604d12e
Show file tree
Hide file tree
Showing 7 changed files with 128 additions and 24 deletions.
7 changes: 4 additions & 3 deletions docs/documentation/api/Lobby.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion src/lobby/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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();
});
});

Expand Down
29 changes: 22 additions & 7 deletions src/lobby/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}”.`
);
}
}
Expand Down Expand Up @@ -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;
Expand All @@ -229,7 +241,10 @@ export class LobbyClient {
): Promise<LobbyAPI.JoinedMatch> {
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 });
}

Expand Down
58 changes: 54 additions & 4 deletions src/server/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}),
}),
})
);
});
});

Expand Down
28 changes: 19 additions & 9 deletions src/server/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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');
}
Expand All @@ -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;
});

Expand Down
22 changes: 22 additions & 0 deletions src/server/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
};
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ export namespace LobbyAPI {
matchID: string;
}
export interface JoinedMatch {
playerID: string;
playerCredentials: string;
}
export interface NextMatch {
Expand Down

0 comments on commit 604d12e

Please sign in to comment.