Skip to content

Commit

Permalink
Merge 4ce031d into 298ecaf
Browse files Browse the repository at this point in the history
  • Loading branch information
liorp committed Oct 1, 2021
2 parents 298ecaf + 4ce031d commit 762c890
Show file tree
Hide file tree
Showing 6 changed files with 113 additions and 19 deletions.
3 changes: 2 additions & 1 deletion docs/documentation/api/Lobby.md
Expand Up @@ -147,7 +147,8 @@ 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'`...).
- `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.

- `playerName` (required): the display name of the player joining the match.

Expand Down
2 changes: 1 addition & 1 deletion src/lobby/client.test.ts
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 Down
27 changes: 21 additions & 6 deletions src/lobby/client.ts
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 @@ -220,7 +232,7 @@ export class LobbyClient {
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
54 changes: 50 additions & 4 deletions src/server/api.test.ts
Expand Up @@ -449,14 +449,60 @@ 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('updates the player name', async () => {
expect(db.mocks.setMetadata).toHaveBeenCalledWith(
'1',
expect.objectContaining({
players: expect.objectContaining({
'0': expect.objectContaining({
name: 'alice',
}),
}),
})
);
});
});

Expand Down
24 changes: 17 additions & 7 deletions src/server/api.ts
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.
*/
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 Down
22 changes: 22 additions & 0 deletions src/server/util.ts
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);
}
}
};

0 comments on commit 762c890

Please sign in to comment.