@@ -104,24 +104,24 @@ class LobbyRoomInstance extends React.Component {
};
render() {
- const room = this.props.room;
+ const match = this.props.match;
let status = 'OPEN';
- if (!room.players.find(player => !player.name)) {
+ if (!match.players.find(player => !player.name)) {
status = 'RUNNING';
}
return (
-
);
}
}
-export default LobbyRoomInstance;
+export default LobbyMatchInstance;
diff --git a/src/lobby/react.js b/src/lobby/react.js
index 4e4c86a44..1a2ca34c3 100644
--- a/src/lobby/react.js
+++ b/src/lobby/react.js
@@ -15,8 +15,8 @@ import { Local } from '../client/transport/local';
import { SocketIO } from '../client/transport/socketio';
import { LobbyConnection } from './connection';
import LobbyLoginForm from './login-form';
-import LobbyRoomInstance from './room-instance';
-import LobbyCreateRoomForm from './create-room-form';
+import LobbyMatchInstance from './match-instance';
+import LobbyCreateMatchForm from './create-match-form';
const LobbyPhases = {
ENTER: 'enter',
@@ -39,7 +39,8 @@ const LobbyPhases = {
* @param {bool} debug - Enable debug information (default: false).
*
* Returns:
- * A React component that provides a UI to create, list, join, leave, play or spectate game instances.
+ * A React component that provides a UI to create, list, join, leave, play or
+ * spectate matches (game instances).
*/
class Lobby extends React.Component {
static propTypes = {
@@ -60,7 +61,7 @@ class Lobby extends React.Component {
state = {
phase: LobbyPhases.ENTER,
playerName: 'Visitor',
- runningGame: null,
+ runningMatch: null,
errorMsg: '',
credentialStore: {},
};
@@ -135,7 +136,7 @@ class Lobby extends React.Component {
this.setState({ phase: LobbyPhases.ENTER, errorMsg: '' });
};
- _createRoom = async (gameName, numPlayers) => {
+ _createMatch = async (gameName, numPlayers) => {
try {
await this.connection.create(gameName, numPlayers);
await this.connection.refresh();
@@ -146,9 +147,9 @@ class Lobby extends React.Component {
}
};
- _joinRoom = async (gameName, gameID, playerID) => {
+ _joinMatch = async (gameName, matchID, playerID) => {
try {
- await this.connection.join(gameName, gameID, playerID);
+ await this.connection.join(gameName, matchID, playerID);
await this.connection.refresh();
this._updateCredentials(
this.connection.playerName,
@@ -159,9 +160,9 @@ class Lobby extends React.Component {
}
};
- _leaveRoom = async (gameName, gameID) => {
+ _leaveMatch = async (gameName, matchID) => {
try {
- await this.connection.leave(gameName, gameID);
+ await this.connection.leave(gameName, matchID);
await this.connection.refresh();
this._updateCredentials(
this.connection.playerName,
@@ -172,7 +173,7 @@ class Lobby extends React.Component {
}
};
- _startGame = (gameName, gameOpts) => {
+ _startMatch = (gameName, matchOpts) => {
const gameCode = this.connection._getGameComponents(gameName);
if (!gameCode) {
this.setState({
@@ -182,7 +183,7 @@ class Lobby extends React.Component {
}
let multiplayer = undefined;
- if (gameOpts.numPlayers > 1) {
+ if (matchOpts.numPlayers > 1) {
if (this.props.gameServer) {
multiplayer = SocketIO({ server: this.props.gameServer });
} else {
@@ -190,7 +191,7 @@ class Lobby extends React.Component {
}
}
- if (gameOpts.numPlayers == 1) {
+ if (matchOpts.numPlayers == 1) {
const maxPlayers = gameCode.game.maxPlayers;
let bots = {};
for (let i = 1; i < maxPlayers; i++) {
@@ -206,35 +207,35 @@ class Lobby extends React.Component {
multiplayer,
});
- const game = {
+ const match = {
app: app,
- gameID: gameOpts.gameID,
- playerID: gameOpts.numPlayers > 1 ? gameOpts.playerID : '0',
+ matchID: matchOpts.matchID,
+ playerID: matchOpts.numPlayers > 1 ? matchOpts.playerID : '0',
credentials: this.connection.playerCredentials,
};
- this.setState({ phase: LobbyPhases.PLAY, runningGame: game });
+ this.setState({ phase: LobbyPhases.PLAY, runningMatch: match });
};
- _exitRoom = () => {
- this.setState({ phase: LobbyPhases.LIST, runningGame: null });
+ _exitMatch = () => {
+ this.setState({ phase: LobbyPhases.LIST, runningMatch: null });
};
_getPhaseVisibility = phase => {
return this.state.phase !== phase ? 'hidden' : 'phase';
};
- renderRooms = (rooms, playerName) => {
- return rooms.map(room => {
- const { gameID, gameName, players } = room;
+ renderMatches = (matches, playerName) => {
+ return matches.map(match => {
+ const { matchID, gameName, players } = match;
return (
-
);
});
@@ -242,24 +243,24 @@ class Lobby extends React.Component {
render() {
const { gameComponents, renderer } = this.props;
- const { errorMsg, playerName, phase, runningGame } = this.state;
+ const { errorMsg, playerName, phase, runningMatch } = this.state;
if (renderer) {
return renderer({
errorMsg,
gameComponents,
- rooms: this.connection.rooms,
+ matches: this.connection.matches,
phase,
playerName,
- runningGame,
+ runningMatch,
handleEnterLobby: this._enterLobby,
handleExitLobby: this._exitLobby,
- handleCreateRoom: this._createRoom,
- handleJoinRoom: this._joinRoom,
- handleLeaveRoom: this._leaveRoom,
- handleExitRoom: this._exitRoom,
- handleRefreshRooms: this._updateConnection,
- handleStartGame: this._startGame,
+ handleCreateMatch: this._createMatch,
+ handleJoinMatch: this._joinMatch,
+ handleLeaveMatch: this._leaveMatch,
+ handleExitMatch: this._exitMatch,
+ handleRefreshMatches: this._updateConnection,
+ handleStartMatch: this._startMatch,
});
}
@@ -276,18 +277,18 @@ class Lobby extends React.Component {
- {runningGame && (
-
)}
-
-
Exit game
+
+ Exit match
diff --git a/src/lobby/react.ssr.test.js b/src/lobby/react.ssr.test.js
index 5dff7edff..9d7e279c7 100644
--- a/src/lobby/react.ssr.test.js
+++ b/src/lobby/react.ssr.test.js
@@ -7,7 +7,9 @@ import Lobby from './react';
import ReactDOMServer from 'react-dom/server';
/* mock server requests */
-global.fetch = jest.fn().mockReturnValue({ status: 200, json: () => [] });
+global.fetch = jest
+ .fn()
+ .mockReturnValue({ ok: true, status: 200, json: () => [] });
describe('lobby', () => {
test('is rendered', () => {
diff --git a/src/lobby/react.test.js b/src/lobby/react.test.js
index b7cd9e39d..f6b1f310e 100644
--- a/src/lobby/react.test.js
+++ b/src/lobby/react.test.js
@@ -13,7 +13,9 @@ import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
/* mock server requests */
-global.fetch = jest.fn().mockReturnValue({ status: 200, json: () => [] });
+global.fetch = jest
+ .fn()
+ .mockReturnValue({ ok: true, status: 200, json: () => [] });
/* mock 'Client' component */
function NullComponent() {
@@ -55,7 +57,7 @@ describe('lobby', () => {
gameServer="localhost:9000"
/>
);
- lobby.instance()._startGame('GameName1', { numPlayers: 2 });
+ lobby.instance()._startMatch('GameName1', { numPlayers: 2 });
expect(spy).toBeCalledWith(
expect.objectContaining({
multiplayer: expect.anything(),
@@ -129,9 +131,9 @@ describe('lobby', () => {
describe('exiting lobby', () => {
beforeEach(async () => {
- lobby.instance().connection.rooms = [
+ lobby.instance().connection.matches = [
{
- gameID: 'gameID1',
+ matchID: 'matchID1',
players: {
'0': { id: 0, name: 'Bob' },
'1': { id: 1 },
@@ -171,7 +173,7 @@ describe('lobby', () => {
});
});
- describe('rooms list', () => {
+ describe('matches list', () => {
let spyClient = jest.fn();
beforeEach(async () => {
// initial state = logged-in as 'Bob'
@@ -196,11 +198,11 @@ describe('lobby', () => {
spyClient.mockReset();
});
- describe('creating a room', () => {
+ describe('creating a match', () => {
beforeEach(async () => {
- lobby.instance().connection.rooms = [
+ lobby.instance().connection.matches = [
{
- gameID: 'gameID1',
+ matchID: 'matchID1',
players: { '0': { id: 0 } },
gameName: 'GameName1',
},
@@ -209,30 +211,30 @@ describe('lobby', () => {
lobby.update();
});
- test('room with default number of players', () => {
+ test('match with default number of players', () => {
lobby.instance().connection.create = spy;
lobby
- .find('LobbyCreateRoomForm')
+ .find('LobbyCreateMatchForm')
.find('button')
.simulate('click');
expect(spy).toHaveBeenCalledWith('GameName1', 3);
});
- test('room with 2 players', () => {
+ test('match with 2 players', () => {
lobby.instance().connection.create = spy;
lobby
- .find('LobbyCreateRoomForm')
+ .find('LobbyCreateMatchForm')
.find('select')
.first()
.props()
.onChange({ target: { value: '1' } });
lobby
- .find('LobbyCreateRoomForm')
+ .find('LobbyCreateMatchForm')
.find('select')
.at(1)
.props()
.onChange({ target: { value: '2' } });
lobby
- .find('LobbyCreateRoomForm')
+ .find('LobbyCreateMatchForm')
.find('button')
.simulate('click');
expect(spy).toHaveBeenCalledWith('GameName2', 2);
@@ -242,7 +244,7 @@ describe('lobby', () => {
throw new Error('fail');
});
await lobby
- .find('LobbyCreateRoomForm')
+ .find('LobbyCreateMatchForm')
.find('button')
.simulate('click');
expect(
@@ -255,14 +257,14 @@ describe('lobby', () => {
test('when game has no boundaries on the number of players', async () => {
// select 2nd game
lobby
- .find('LobbyCreateRoomForm')
+ .find('LobbyCreateMatchForm')
.find('select')
.first()
.props()
.onChange({ target: { value: '1' } });
expect(
lobby
- .find('LobbyCreateRoomForm')
+ .find('LobbyCreateMatchForm')
.find('select')
.at(1)
.text()
@@ -271,7 +273,7 @@ describe('lobby', () => {
test('when game has boundaries on the number of players', async () => {
expect(
lobby
- .find('LobbyCreateRoomForm')
+ .find('LobbyCreateMatchForm')
.find('select')
.at(1)
.text()
@@ -279,16 +281,16 @@ describe('lobby', () => {
});
});
- describe('joining a room', () => {
+ describe('joining a match', () => {
beforeEach(async () => {
- lobby.instance().connection.rooms = [
+ lobby.instance().connection.matches = [
{
- gameID: 'gameID1',
+ matchID: 'matchID1',
players: { '0': { id: 0 } },
gameName: 'GameName1',
},
{
- gameID: 'gameID2',
+ matchID: 'matchID2',
players: { '0': { id: 0, name: 'Bob' } },
gameName: 'GameName1',
},
@@ -296,21 +298,21 @@ describe('lobby', () => {
lobby.instance().forceUpdate();
lobby.update();
});
- test('when room is empty', () => {
- // join 1st room
+ test('when match is empty', () => {
+ // join 1st match
lobby.instance().connection.join = spy;
lobby
- .find('LobbyRoomInstance')
+ .find('LobbyMatchInstance')
.first()
.find('button')
.simulate('click');
- expect(spy).toHaveBeenCalledWith('GameName1', 'gameID1', '0');
+ expect(spy).toHaveBeenCalledWith('GameName1', 'matchID1', '0');
});
- test('when room is full', () => {
- // try 2nd room
+ test('when match is full', () => {
+ // try 2nd match
expect(
lobby
- .find('LobbyRoomInstance')
+ .find('LobbyMatchInstance')
.at(1)
.text()
).toContain('RUNNING');
@@ -319,9 +321,9 @@ describe('lobby', () => {
lobby.instance().connection.join = spy.mockImplementation(() => {
throw new Error('fail');
});
- // join 1st room
+ // join 1st match
await lobby
- .find('LobbyRoomInstance')
+ .find('LobbyMatchInstance')
.first()
.find('button')
.simulate('click');
@@ -334,11 +336,11 @@ describe('lobby', () => {
});
});
- describe('leaving a room', () => {
+ describe('leaving a match', () => {
beforeEach(async () => {
- lobby.instance().connection.rooms = [
+ lobby.instance().connection.matches = [
{
- gameID: 'gameID1',
+ matchID: 'matchID1',
players: {
'0': { id: 0, name: 'Bob' },
'1': { id: 1 },
@@ -350,26 +352,26 @@ describe('lobby', () => {
lobby.update();
expect(
lobby
- .find('LobbyRoomInstance')
+ .find('LobbyMatchInstance')
.find('button')
.text()
).toBe('Leave');
});
- test('shall leave a room', () => {
- // leave room
+ test('shall leave a match', () => {
+ // leave match
lobby.instance().connection.leave = spy;
lobby
- .find('LobbyRoomInstance')
+ .find('LobbyMatchInstance')
.find('button')
.simulate('click');
- expect(spy).toHaveBeenCalledWith('GameName1', 'gameID1');
+ expect(spy).toHaveBeenCalledWith('GameName1', 'matchID1');
});
test('when server request fails', async () => {
lobby.instance().connection.leave = spy.mockImplementation(() => {
throw new Error('fail');
});
await lobby
- .find('LobbyRoomInstance')
+ .find('LobbyMatchInstance')
.find('button')
.simulate('click');
expect(
@@ -383,9 +385,9 @@ describe('lobby', () => {
describe('starting a game', () => {
beforeEach(async () => {
- lobby.instance().connection.rooms = [
+ lobby.instance().connection.matches = [
{
- gameID: 'gameID1',
+ matchID: 'matchID1',
players: {
'0': { id: 0, name: 'Bob', credentials: 'SECRET1' },
'1': { id: 1, name: 'Charly', credentials: 'SECRET2' },
@@ -393,17 +395,17 @@ describe('lobby', () => {
gameName: 'GameName1',
},
{
- gameID: 'gameID2',
+ matchID: 'matchID2',
players: { '0': { id: 0, name: 'Alice' } },
gameName: 'GameName2',
},
{
- gameID: 'gameID3',
+ matchID: 'matchID3',
players: { '0': { id: 0, name: 'Bob' } },
gameName: 'GameName3',
},
{
- gameID: 'gameID4',
+ matchID: 'matchID4',
players: { '0': { id: 0, name: 'Zoe' } },
gameName: 'GameNameUnknown',
},
@@ -415,15 +417,15 @@ describe('lobby', () => {
test('if player has joined the game', () => {
lobby.instance().connection.playerCredentials = 'SECRET1';
lobby
- .find('LobbyRoomInstance')
+ .find('LobbyMatchInstance')
.first()
.find('button')
.first()
.simulate('click');
- expect(lobby.instance().state.runningGame).toEqual({
+ expect(lobby.instance().state.runningMatch).toEqual({
app: NullComponent,
- gameID: 'gameID1',
+ matchID: 'matchID1',
playerID: '0',
credentials: 'SECRET1',
});
@@ -437,21 +439,21 @@ describe('lobby', () => {
test('if player is spectator', () => {
lobby
- .find('LobbyRoomInstance')
+ .find('LobbyMatchInstance')
.at(1)
.find('button')
.simulate('click');
- expect(lobby.instance().state.runningGame).toEqual({
+ expect(lobby.instance().state.runningMatch).toEqual({
app: NullComponent,
credentials: undefined,
- gameID: 'gameID2',
+ matchID: 'matchID2',
playerID: '0',
});
});
test('if game is not supported', () => {
lobby
- .find('LobbyRoomInstance')
+ .find('LobbyMatchInstance')
.at(3)
.find('button')
.simulate('click');
@@ -466,23 +468,23 @@ describe('lobby', () => {
test('if game is monoplayer', () => {
lobby
- .find('LobbyRoomInstance')
+ .find('LobbyMatchInstance')
.at(2)
.find('button')
.first()
.simulate('click');
expect(spy).not.toHaveBeenCalledWith(expect.anything(), {
- gameID: 'gameID3',
+ matchID: 'matchID3',
});
});
});
describe('exiting during game', () => {
beforeEach(async () => {
- lobby.instance().connection.rooms = [
+ lobby.instance().connection.matches = [
{
- gameID: 'gameID1',
+ matchID: 'matchID1',
players: {
'0': { id: 0, name: 'Bob', credentials: 'SECRET1' },
'1': { id: 1, name: 'Charly', credentials: 'SECRET2' },
@@ -497,17 +499,17 @@ describe('lobby', () => {
lobby.instance().connection.playerCredentials = 'SECRET1';
// start game
lobby
- .find('LobbyRoomInstance')
+ .find('LobbyMatchInstance')
.first()
.find('button')
.first()
.simulate('click');
// exit game
lobby
- .find('#game-exit')
+ .find('#match-exit')
.find('button')
.simulate('click');
- expect(lobby.instance().state.runningGame).toEqual(null);
+ expect(lobby.instance().state.runningMatch).toEqual(null);
expect(lobby.instance().state.phase).toEqual('list');
});
});
diff --git a/src/master/master.test.ts b/src/master/master.test.ts
index 6affa93a9..5bb3bd123 100644
--- a/src/master/master.test.ts
+++ b/src/master/master.test.ts
@@ -12,18 +12,23 @@ import {
Master,
redactLog,
getPlayerMetadata,
- doesGameRequireAuthentication,
+ doesMatchRequireAuthentication,
isActionFromAuthenticPlayer,
} from './master';
import { error } from '../core/logger';
import { Server } from '../types';
import * as StorageAPI from '../server/db/base';
+import * as dateMock from 'jest-date-mock';
jest.mock('../core/logger', () => ({
info: jest.fn(),
error: jest.fn(),
}));
+beforeEach(() => {
+ dateMock.clear();
+});
+
class InMemoryAsync extends InMemory {
type() {
return StorageAPI.Type.ASYNC;
@@ -81,6 +86,8 @@ describe('sync', () => {
name: 'Bob',
},
},
+ createdAt: 0,
+ updatedAt: 0,
};
db.setMetadata('gameID', dbMetadata);
const masterWithMetadata = new Master(game, db, TransportAPI(send));
@@ -155,11 +162,11 @@ describe('update', () => {
]);
});
- test('invalid gameID', async () => {
+ test('invalid matchID', async () => {
await master.onUpdate(action, 1, 'default:unknown', '1');
expect(sendAll).not.toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(
- `game not found, gameID=[default:unknown]`
+ `game not found, matchID=[default:unknown]`
);
});
@@ -186,7 +193,7 @@ describe('update', () => {
);
});
- test('valid gameID / stateID / playerID', async () => {
+ test('valid matchID / stateID / playerID', async () => {
await master.onUpdate(action, 1, 'gameID', '1');
expect(sendAll).toHaveBeenCalled();
});
@@ -206,7 +213,7 @@ describe('update', () => {
await master.onUpdate(event, 2, 'gameID', '0');
event = ActionCreators.gameEvent('endTurn');
await master.onUpdate(event, 3, 'gameID', '0');
- expect(error).toHaveBeenCalledWith(`game over - gameID=[gameID]`);
+ expect(error).toHaveBeenCalledWith(`game over - matchID=[gameID]`);
});
test('writes gameover to metadata', async () => {
@@ -216,6 +223,8 @@ describe('update', () => {
gameName: 'tic-tac-toe',
setupData: {},
players: { '0': { id: 0 }, '1': { id: 1 } },
+ createdAt: 0,
+ updatedAt: 0,
};
db.setMetadata(id, dbMetadata);
const masterWithMetadata = new Master(game, db, TransportAPI(send));
@@ -235,6 +244,8 @@ describe('update', () => {
gameName: 'tic-tac-toe',
setupData: {},
players: { '0': { id: 0 }, '1': { id: 1 } },
+ createdAt: 0,
+ updatedAt: 0,
};
db.setMetadata(id, dbMetadata);
const masterWithMetadata = new Master(game, db, TransportAPI(send));
@@ -246,6 +257,28 @@ describe('update', () => {
const { metadata } = db.fetch(id, { metadata: true });
expect(metadata.gameover).toEqual(gameOverArg);
});
+
+ test('writes updatedAt to metadata with async storage API', async () => {
+ const id = 'gameWithMetadata';
+ const db = new InMemoryAsync();
+ const dbMetadata = {
+ gameName: 'tic-tac-toe',
+ setupData: {},
+ players: { '0': { id: 0 }, '1': { id: 1 } },
+ createdAt: 0,
+ updatedAt: 0,
+ };
+ db.setMetadata(id, dbMetadata);
+ const masterWithMetadata = new Master(game, db, TransportAPI(send));
+ await masterWithMetadata.onSync(id, '0', 2);
+
+ const updatedAt = new Date(2020, 3, 4, 5, 6, 7);
+ dateMock.advanceTo(updatedAt);
+ const event = ActionCreators.gameEvent('endTurn', null, '0');
+ await masterWithMetadata.onUpdate(event, 0, id, '0');
+ const { metadata } = db.fetch(id, { metadata: true });
+ expect(metadata.updatedAt).toEqual(updatedAt.getTime());
+ });
});
describe('playerView', () => {
@@ -307,7 +340,7 @@ describe('subscribe', () => {
test('sync', async () => {
master.onSync('gameID', '0');
expect(callback).toBeCalledWith({
- gameID: 'gameID',
+ matchID: 'gameID',
state: expect.objectContaining({ _stateID: 0 }),
});
});
@@ -316,7 +349,7 @@ describe('subscribe', () => {
const action = ActionCreators.gameEvent('endTurn');
master.onUpdate(action, 0, 'gameID', '0');
expect(callback).toBeCalledWith({
- gameID: 'gameID',
+ matchID: 'gameID',
action,
state: expect.objectContaining({ _stateID: 1 }),
});
@@ -590,7 +623,7 @@ describe('getPlayerMetadata', () => {
describe('when metadata does not contain players field', () => {
test('then playerMetadata is undefined', () => {
- expect(getPlayerMetadata({} as Server.GameMetadata, '0')).toBeUndefined();
+ expect(getPlayerMetadata({} as Server.MatchData, '0')).toBeUndefined();
});
});
@@ -598,7 +631,13 @@ describe('getPlayerMetadata', () => {
test('then playerMetadata is undefined', () => {
expect(
getPlayerMetadata(
- { gameName: '', setupData: {}, players: { '1': { id: 1 } } },
+ {
+ gameName: '',
+ setupData: {},
+ players: { '1': { id: 1 } },
+ createdAt: 0,
+ updatedAt: 0,
+ },
'0'
)
).toBeUndefined();
@@ -609,7 +648,13 @@ describe('getPlayerMetadata', () => {
test('then playerMetadata is returned', () => {
const playerMetadata = { id: 0, credentials: 'SECRET' };
const result = getPlayerMetadata(
- { gameName: '', setupData: {}, players: { '0': playerMetadata } },
+ {
+ gameName: '',
+ setupData: {},
+ players: { '0': playerMetadata },
+ createdAt: 0,
+ updatedAt: 0,
+ },
'0'
);
expect(result).toBe(playerMetadata);
@@ -617,31 +662,33 @@ describe('getPlayerMetadata', () => {
});
});
-describe('doesGameRequireAuthentication', () => {
+describe('doesMatchRequireAuthentication', () => {
describe('when game metadata is not found', () => {
test('then authentication is not required', () => {
- const result = doesGameRequireAuthentication();
+ const result = doesMatchRequireAuthentication();
expect(result).toBe(false);
});
});
- describe('when game has no credentials', () => {
+ describe('when match has no credentials', () => {
test('then authentication is not required', () => {
- const gameMetadata = {
+ const matchData = {
gameName: '',
setupData: {},
players: {
'0': { id: 1 },
},
+ createdAt: 0,
+ updatedAt: 0,
};
- const result = doesGameRequireAuthentication(gameMetadata);
+ const result = doesMatchRequireAuthentication(matchData);
expect(result).toBe(false);
});
});
- describe('when game has credentials', () => {
+ describe('when match has credentials', () => {
test('then authentication is required', () => {
- const gameMetadata = {
+ const matchData = {
gameName: '',
setupData: {},
players: {
@@ -650,8 +697,10 @@ describe('doesGameRequireAuthentication', () => {
credentials: 'SECRET',
},
},
+ createdAt: 0,
+ updatedAt: 0,
};
- const result = doesGameRequireAuthentication(gameMetadata);
+ const result = doesMatchRequireAuthentication(matchData);
expect(result).toBe(true);
});
});
@@ -660,7 +709,7 @@ describe('doesGameRequireAuthentication', () => {
describe('isActionFromAuthenticPlayer', () => {
let action;
let playerID;
- let gameMetadata;
+ let matchData;
let credentials;
let playerMetadata;
@@ -671,13 +720,13 @@ describe('isActionFromAuthenticPlayer', () => {
payload: { credentials: 'SECRET' },
};
- gameMetadata = {
+ matchData = {
players: {
'0': { credentials: 'SECRET' },
},
};
- playerMetadata = gameMetadata.players[playerID];
+ playerMetadata = matchData.players[playerID];
({ credentials } = action.payload || {});
});
diff --git a/src/master/master.ts b/src/master/master.ts
index 2336903a0..912b85ffb 100644
--- a/src/master/master.ts
+++ b/src/master/master.ts
@@ -26,11 +26,11 @@ import {
import * as StorageAPI from '../server/db/base';
export const getPlayerMetadata = (
- gameMetadata: Server.GameMetadata,
+ matchData: Server.MatchData,
playerID: PlayerID
) => {
- if (gameMetadata && gameMetadata.players) {
- return gameMetadata.players[playerID];
+ if (matchData && matchData.players) {
+ return matchData.players[playerID];
}
};
@@ -78,13 +78,13 @@ export function redactLog(log: LogEntry[], playerID: PlayerID) {
}
/**
- * Verifies that the game has metadata and is using credentials.
+ * Verifies that the match has metadata and is using credentials.
*/
-export const doesGameRequireAuthentication = (
- gameMetadata?: Server.GameMetadata
+export const doesMatchRequireAuthentication = (
+ matchData?: Server.MatchData
) => {
- if (!gameMetadata) return false;
- const { players } = gameMetadata as Server.GameMetadata;
+ if (!matchData) return false;
+ const { players } = matchData as Server.MatchData;
const hasCredentials = Object.keys(players).some(key => {
return !!(players[key] && players[key].credentials);
});
@@ -119,7 +119,7 @@ export type AuthFn = (
type CallbackFn = (arg: {
state: State;
- gameID: string;
+ matchID: string;
action?: ActionShape.Any | CredentialedActionShape.Any;
}) => void;
@@ -151,7 +151,7 @@ export class Master {
transportAPI: TransportAPI;
subscribeCallback: CallbackFn;
auth: null | AuthFn;
- shouldAuth: typeof doesGameRequireAuthentication;
+ shouldAuth: typeof doesMatchRequireAuthentication;
constructor(
game: Game,
@@ -168,7 +168,7 @@ export class Master {
if (auth === true) {
this.auth = isActionFromAuthenticPlayer;
- this.shouldAuth = doesGameRequireAuthentication;
+ this.shouldAuth = doesMatchRequireAuthentication;
} else if (typeof auth === 'function') {
this.auth = auth;
this.shouldAuth = () => true;
@@ -187,20 +187,20 @@ export class Master {
async onUpdate(
credAction: CredentialedActionShape.Any,
stateID: number,
- gameID: string,
+ matchID: string,
playerID: string
) {
let isActionAuthentic;
- let metadata: Server.GameMetadata | undefined;
+ let metadata: Server.MatchData | undefined;
const credentials = credAction.payload.credentials;
if (IsSynchronous(this.storageAPI)) {
- ({ metadata } = this.storageAPI.fetch(gameID, { metadata: true }));
+ ({ metadata } = this.storageAPI.fetch(matchID, { metadata: true }));
const playerMetadata = getPlayerMetadata(metadata, playerID);
isActionAuthentic = this.shouldAuth(metadata)
? this.auth(credentials, playerMetadata)
: true;
} else {
- ({ metadata } = await this.storageAPI.fetch(gameID, {
+ ({ metadata } = await this.storageAPI.fetch(matchID, {
metadata: true,
}));
const playerMetadata = getPlayerMetadata(metadata, playerID);
@@ -213,7 +213,7 @@ export class Master {
}
let action = stripCredentialsFromAction(credAction);
- const key = gameID;
+ const key = matchID;
let state: State;
let result: StorageAPI.FetchResult<{ state: true }>;
@@ -225,12 +225,12 @@ export class Master {
state = result.state;
if (state === undefined) {
- logging.error(`game not found, gameID=[${key}]`);
+ logging.error(`game not found, matchID=[${key}]`);
return { error: 'game not found' };
}
if (state.ctx.gameover !== undefined) {
- logging.error(`game over - gameID=[${key}]`);
+ logging.error(`game over - matchID=[${key}]`);
return;
}
@@ -283,7 +283,7 @@ export class Master {
this.subscribeCallback({
state,
action,
- gameID,
+ matchID,
});
this.transportAPI.sendAll((playerID: string) => {
@@ -299,22 +299,21 @@ export class Master {
return {
type: 'update',
- args: [gameID, filteredState, log],
+ args: [matchID, filteredState, log],
};
});
const { deltalog, ...stateWithoutDeltalog } = state;
- let newMetadata: Server.GameMetadata | undefined;
- if (
- metadata &&
- !('gameover' in metadata) &&
- state.ctx.gameover !== undefined
- ) {
+ let newMetadata: Server.MatchData | undefined;
+ if (metadata && !('gameover' in metadata)) {
newMetadata = {
...metadata,
- gameover: state.ctx.gameover,
+ updatedAt: Date.now(),
};
+ if (state.ctx.gameover !== undefined) {
+ newMetadata.gameover = state.ctx.gameover;
+ }
}
if (IsSynchronous(this.storageAPI)) {
@@ -335,13 +334,13 @@ export class Master {
* Called when the client connects / reconnects.
* Returns the latest game state and the entire log.
*/
- async onSync(gameID: string, playerID: string, numPlayers: number) {
- const key = gameID;
+ async onSync(matchID: string, playerID: string, numPlayers: number) {
+ const key = matchID;
let state: State;
let initialState: State;
let log: LogEntry[];
- let gameMetadata: Server.GameMetadata;
+ let matchData: Server.MatchData;
let filteredMetadata: FilteredMetadata;
let result: StorageAPI.FetchResult<{
state: true;
@@ -369,10 +368,10 @@ export class Master {
state = result.state;
initialState = result.initialState;
log = result.log;
- gameMetadata = result.metadata;
+ matchData = result.metadata;
- if (gameMetadata) {
- filteredMetadata = Object.values(gameMetadata.players).map(player => {
+ if (matchData) {
+ filteredMetadata = Object.values(matchData.players).map(player => {
const { credentials, ...filteredData } = player;
return filteredData;
});
@@ -385,7 +384,7 @@ export class Master {
this.subscribeCallback({
state,
- gameID,
+ matchID,
});
if (IsSynchronous(this.storageAPI)) {
@@ -415,7 +414,7 @@ export class Master {
this.transportAPI.send({
playerID,
type: 'sync',
- args: [gameID, syncInfo],
+ args: [matchID, syncInfo],
});
return;
diff --git a/src/server/api.test.ts b/src/server/api.test.ts
index 5054009a4..07f058c27 100644
--- a/src/server/api.test.ts
+++ b/src/server/api.test.ts
@@ -8,14 +8,19 @@
import request from 'supertest';
import Koa from 'koa';
+import * as dateMock from 'jest-date-mock';
-import { addApiToServer, createApiServer } from './api';
+import { createRouter, configureApp } from './api';
import { ProcessGameConfig } from '../core/game';
import * as StorageAPI from './db/base';
import { Game } from '../types';
jest.setTimeout(2000000000);
+beforeEach(() => {
+ dateMock.clear();
+});
+
type StorageMocks = Record<
'createGame' | 'setState' | 'fetch' | 'setMetadata' | 'listGames' | 'wipe',
jest.Mock | ((...args: any[]) => any)
@@ -63,12 +68,27 @@ class AsyncStorage extends StorageAPI.Async {
}
}
-describe('.createApiServer', () => {
+describe('.createRouter', () => {
+ function addApiToServer({
+ app,
+ ...args
+ }: { app: Koa } & Parameters
[0]) {
+ const router = createRouter(args);
+ configureApp(app, router);
+ }
+
+ function createApiServer(args: Parameters[0]) {
+ const app = new Koa();
+ addApiToServer({ app, ...args });
+ return app;
+ }
+
describe('creating a game', () => {
let response;
let app: Koa;
let db: AsyncStorage;
let games: Game[];
+ const updatedAt = new Date(2020, 3, 4, 5, 6, 7);
beforeEach(async () => {
db = new AsyncStorage();
@@ -87,11 +107,12 @@ describe('.createApiServer', () => {
describe('for an unprotected lobby server', () => {
beforeEach(async () => {
+ dateMock.advanceTo(updatedAt);
+
delete process.env.API_SECRET;
- const uuid = () => 'gameID';
- const lobbyConfig = { uuid };
- app = createApiServer({ db, games, lobbyConfig });
+ const uuid = () => 'matchID';
+ app = createApiServer({ db, games, uuid });
response = await request(app.callback())
.post('/games/foo/create')
@@ -104,7 +125,7 @@ describe('.createApiServer', () => {
test('creates game state and metadata', () => {
expect(db.mocks.createGame).toHaveBeenCalledWith(
- 'gameID',
+ 'matchID',
expect.objectContaining({
initialState: expect.objectContaining({
ctx: expect.objectContaining({
@@ -118,13 +139,15 @@ describe('.createApiServer', () => {
'1': expect.objectContaining({}),
}),
unlisted: false,
+ createdAt: updatedAt.getTime(),
+ updatedAt: updatedAt.getTime(),
}),
})
);
});
- test('returns game id', () => {
- expect(response.body.gameID).not.toBeNull();
+ test('returns match id', () => {
+ expect(response.body.matchID).not.toBeNull();
});
describe('without numPlayers', () => {
@@ -134,7 +157,7 @@ describe('.createApiServer', () => {
test('uses default numPlayers', () => {
expect(db.mocks.createGame).toHaveBeenCalledWith(
- 'gameID',
+ 'matchID',
expect.objectContaining({
initialState: expect.objectContaining({
ctx: expect.objectContaining({
@@ -172,7 +195,7 @@ describe('.createApiServer', () => {
test('includes setupData in metadata', () => {
expect(db.mocks.createGame).toHaveBeenCalledWith(
- 'gameID',
+ 'matchID',
expect.objectContaining({
metadata: expect.objectContaining({
setupData: expect.objectContaining({
@@ -188,7 +211,7 @@ describe('.createApiServer', () => {
test('passes setupData to game setup function', () => {
expect(db.mocks.createGame).toHaveBeenCalledWith(
- 'gameID',
+ 'matchID',
expect.objectContaining({
initialState: expect.objectContaining({
G: expect.objectContaining({
@@ -212,7 +235,7 @@ describe('.createApiServer', () => {
test('sets unlisted in metadata', () => {
expect(db.mocks.createGame).toHaveBeenCalledWith(
- 'gameID',
+ 'matchID',
expect.objectContaining({
metadata: expect.objectContaining({
unlisted: true,
@@ -306,9 +329,7 @@ describe('.createApiServer', () => {
const app = createApiServer({
db,
games,
- lobbyConfig: {
- uuid: () => 'gameID',
- },
+ uuid: () => 'matchID',
generateCredentials: () => credentials,
});
response = await request(app.callback())
@@ -597,7 +618,7 @@ describe('.createApiServer', () => {
response = await request(app.callback())
.post('/games/foo/1/update')
.send('playerID=0&playerName=alice&newName=ali');
- expect(response.text).toEqual('Game 1 not found');
+ expect(response.text).toEqual('Match 1 not found');
});
});
@@ -728,7 +749,7 @@ describe('.createApiServer', () => {
response = await request(app.callback())
.post('/games/foo/1/update')
.send({ playerID: 0, data: { subdata: 'text' } });
- expect(response.text).toEqual('Game 1 not found');
+ expect(response.text).toEqual('Match 1 not found');
});
});
@@ -1037,10 +1058,8 @@ describe('.createApiServer', () => {
});
test('creates new game data', async () => {
- const lobbyConfig = {
- uuid: () => 'newGameID',
- };
- const app = createApiServer({ db, games, lobbyConfig });
+ const uuid = () => 'newGameID';
+ const app = createApiServer({ db, games, uuid });
response = await request(app.callback())
.post('/games/foo/1/playAgain')
@@ -1071,14 +1090,12 @@ describe('.createApiServer', () => {
}),
})
);
- expect(response.body.nextRoomID).toBe('newGameID');
+ expect(response.body.nextMatchID).toBe('newGameID');
});
test('when game configuration not supplied, uses previous game config', async () => {
- const lobbyConfig = {
- uuid: () => 'newGameID',
- };
- const app = createApiServer({ db, games, lobbyConfig });
+ const uuid = () => 'newGameID';
+ const app = createApiServer({ db, games, uuid });
response = await request(app.callback())
.post('/games/foo/1/playAgain')
.send('playerID=0&credentials=SECRET1');
@@ -1100,7 +1117,7 @@ describe('.createApiServer', () => {
}),
})
);
- expect(response.body.nextRoomID).toBe('newGameID');
+ expect(response.body.nextMatchID).toBe('newGameID');
});
test('fetches next id', async () => {
@@ -1118,7 +1135,7 @@ describe('.createApiServer', () => {
credentials: 'SECRET2',
},
},
- nextRoomID: '12345',
+ nextMatchID: '12345',
},
};
},
@@ -1127,10 +1144,10 @@ describe('.createApiServer', () => {
response = await request(app.callback())
.post('/games/foo/1/playAgain')
.send('playerID=0&credentials=SECRET1');
- expect(response.body.nextRoomID).toBe('12345');
+ expect(response.body.nextMatchID).toBe('12345');
});
- test('when the game does not exist throws a "not found" error', async () => {
+ test('when the match does not exist throws a "not found" error', async () => {
db = new AsyncStorage({
fetch: async () => ({ metadata: null }),
});
@@ -1176,64 +1193,170 @@ describe('.createApiServer', () => {
describe('requesting room list', () => {
let db: AsyncStorage;
+ const dbFetch = jest.fn(async matchID => {
+ return {
+ metadata: {
+ players: {
+ '0': {
+ id: 0,
+ credentials: 'SECRET1',
+ },
+ '1': {
+ id: 1,
+ credentials: 'SECRET2',
+ },
+ },
+ unlisted: matchID === 'bar-4',
+ gameover: matchID === 'bar-3' ? { winner: 0 } : undefined,
+ },
+ };
+ });
+ const dbListGames = jest.fn(async opts => {
+ const metadata = {
+ 'foo-0': { gameName: 'foo' },
+ 'foo-1': { gameName: 'foo' },
+ 'bar-2': { gameName: 'bar' },
+ 'bar-3': { gameName: 'bar' },
+ 'bar-4': { gameName: 'bar' },
+ };
+ const keys = Object.keys(metadata);
+ if (opts && opts.gameName) {
+ return keys.filter(key => metadata[key].gameName === opts.gameName);
+ }
+ return [...keys];
+ });
beforeEach(() => {
delete process.env.API_SECRET;
db = new AsyncStorage({
- fetch: async gameID => {
- return {
- metadata: {
- players: {
- '0': {
- id: 0,
- credentials: 'SECRET1',
- },
- '1': {
- id: 1,
- credentials: 'SECRET2',
- },
- },
- unlisted: gameID === 'bar-4',
- },
- };
- },
- listGames: async opts => {
- const metadata = {
- 'foo-0': { gameName: 'foo' },
- 'foo-1': { gameName: 'foo' },
- 'bar-2': { gameName: 'bar' },
- 'bar-3': { gameName: 'bar' },
- 'bar-4': { gameName: 'bar' },
- };
- const keys = Object.keys(metadata);
- if (opts && opts.gameName) {
- return keys.filter(key => metadata[key].gameName === opts.gameName);
- }
- return [...keys];
- },
+ fetch: dbFetch,
+ listGames: dbListGames,
});
});
- describe('when given 2 rooms', () => {
+
+ describe('when given 2 matches', () => {
let response;
- let rooms;
+ let matches;
beforeEach(async () => {
let games = [ProcessGameConfig({ name: 'foo' }), { name: 'bar' }];
let app = createApiServer({ db, games });
response = await request(app.callback()).get('/games/bar');
- rooms = JSON.parse(response.text).rooms;
+ matches = JSON.parse(response.text).matches;
});
- test('returns rooms for the selected game', async () => {
- expect(rooms).toHaveLength(2);
+ test('returns matches for the selected game', async () => {
+ expect(matches).toHaveLength(2);
});
- test('returns room ids', async () => {
- expect(rooms[0].gameID).toEqual('bar-2');
- expect(rooms[1].gameID).toEqual('bar-3');
+ test('returns match ids', async () => {
+ expect(matches[0].matchID).toEqual('bar-2');
+ expect(matches[1].matchID).toEqual('bar-3');
});
test('returns player names', async () => {
- expect(rooms[0].players).toEqual([{ id: 0 }, { id: 1 }]);
- expect(rooms[1].players).toEqual([{ id: 0 }, { id: 1 }]);
+ expect(matches[0].players).toEqual([{ id: 0 }, { id: 1 }]);
+ expect(matches[1].players).toEqual([{ id: 0 }, { id: 1 }]);
+ });
+
+ test('returns gameover data for ended match', async () => {
+ expect(matches[0].gameover).toBeUndefined();
+ expect(matches[1].gameover).toEqual({ winner: 0 });
+ });
+ });
+
+ describe('when given filter options', () => {
+ const games = [ProcessGameConfig({ name: 'foo' }), { name: 'bar' }];
+ let app;
+
+ beforeEach(() => {
+ app = createApiServer({ db, games });
+ dbListGames.mockClear();
+ });
+
+ describe('isGameover query param', () => {
+ test('is undefined if not specified in request', async () => {
+ await request(app.callback()).get('/games/bar');
+ expect(dbListGames).toBeCalledWith(
+ expect.objectContaining({ where: { isGameover: undefined } })
+ );
+ });
+ test('is true', async () => {
+ await request(app.callback()).get('/games/bar?isGameover=true');
+ expect(dbListGames).toBeCalledWith(
+ expect.objectContaining({ where: { isGameover: true } })
+ );
+ });
+ test('is false', async () => {
+ await request(app.callback()).get('/games/bar?isGameover=false');
+ expect(dbListGames).toBeCalledWith(
+ expect.objectContaining({ where: { isGameover: false } })
+ );
+ });
+ test('invalid value is ignored', async () => {
+ await request(app.callback()).get('/games/bar?isGameover=5');
+ expect(dbListGames).toBeCalledWith(
+ expect.objectContaining({ where: { isGameover: undefined } })
+ );
+ });
+ });
+
+ describe('updatedBefore query param', () => {
+ test('is undefined if not specified in request', async () => {
+ await request(app.callback()).get('/games/bar');
+ expect(dbListGames).toBeCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({ updatedBefore: undefined }),
+ })
+ );
+ });
+ test('is specified', async () => {
+ const timestamp = new Date(2020, 3, 4, 5, 6, 7);
+ await request(app.callback()).get(
+ `/games/bar?updatedBefore=${timestamp.getTime()}`
+ );
+ expect(dbListGames).toBeCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ updatedBefore: timestamp.getTime(),
+ }),
+ })
+ );
+ });
+ test('invalid value is ignored', async () => {
+ await request(app.callback()).get('/games/bar?updatedBefore=-5');
+ expect(dbListGames).toBeCalledWith(
+ expect.objectContaining({ where: { updatedBefore: undefined } })
+ );
+ });
+ });
+
+ describe('updatedAfter query param', () => {
+ test('is undefined if not specified in request', async () => {
+ await request(app.callback()).get('/games/bar');
+ expect(dbListGames).toBeCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({ updatedAfter: undefined }),
+ })
+ );
+ });
+ test('is specified', async () => {
+ const timestamp = new Date(2020, 3, 4, 5, 6, 7);
+ await request(app.callback()).get(
+ `/games/bar?updatedAfter=${timestamp.getTime()}`
+ );
+ expect(dbListGames).toBeCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ updatedAfter: timestamp.getTime(),
+ }),
+ })
+ );
+ });
+ test('invalid value is ignored', async () => {
+ await request(app.callback()).get('/games/bar?updatedAfter=-5');
+ expect(dbListGames).toBeCalledWith(
+ expect.objectContaining({ where: { updatedAfter: undefined } })
+ );
+ });
});
});
});
@@ -1256,6 +1379,7 @@ describe('.createApiServer', () => {
credentials: 'SECRET2',
},
},
+ gameover: { winner: 1 },
},
};
},
@@ -1276,12 +1400,16 @@ describe('.createApiServer', () => {
});
test('returns game ids', async () => {
- expect(room.roomID).toEqual('bar-0');
+ expect(room.matchID).toEqual('bar-0');
});
test('returns player names', async () => {
expect(room.players).toEqual([{ id: 0 }, { id: 1 }]);
});
+
+ test('returns gameover data for ended game', async () => {
+ expect(room.gameover).toEqual({ winner: 1 });
+ });
});
describe('when given a non-existent room ID', () => {
@@ -1300,9 +1428,7 @@ describe('.createApiServer', () => {
});
});
});
-});
-describe('.addApiToServer', () => {
describe('when server app is provided', () => {
let db: AsyncStorage;
let server;
@@ -1328,7 +1454,7 @@ describe('.addApiToServer', () => {
test('call .use method several times with uuid', async () => {
const uuid = () => 'foo';
- addApiToServer({ app: server, db, games, lobbyConfig: { uuid } });
+ addApiToServer({ app: server, db, games, uuid });
expect(server.use.mock.calls.length).toBeGreaterThan(1);
});
});
diff --git a/src/server/api.ts b/src/server/api.ts
index 304bd822f..d6820e3fe 100644
--- a/src/server/api.ts
+++ b/src/server/api.ts
@@ -9,15 +9,15 @@
import Koa from 'koa';
import Router from 'koa-router';
import koaBody from 'koa-body';
-import { generate as uuid } from 'shortid';
+import { generate as shortid } from 'shortid';
import cors from '@koa/cors';
import { InitializeGame } from '../core/initialize';
import * as StorageAPI from './db/base';
-import { Server, Game } from '../types';
+import { Server, LobbyAPI, Game } from '../types';
/**
- * Creates a new game.
+ * Creates a new match.
*
* @param {object} db - The storage API.
* @param {object} game - The game config object.
@@ -25,76 +25,102 @@ import { Server, Game } from '../types';
* @param {object} setupData - User-defined object that's available
* during game setup.
* @param {object } lobbyConfig - Configuration options for the lobby.
- * @param {boolean} unlisted - Whether the game should be excluded from public listing.
+ * @param {boolean} unlisted - Whether the match should be excluded from public listing.
*/
-export const CreateGame = async (
- db: StorageAPI.Sync | StorageAPI.Async,
- game: Game,
- numPlayers: number,
- setupData: any,
- lobbyConfig: Server.LobbyConfig,
- unlisted: boolean
-) => {
+export const CreateMatch = async ({
+ db,
+ game,
+ numPlayers,
+ setupData,
+ uuid,
+ unlisted,
+}: {
+ db: StorageAPI.Sync | StorageAPI.Async;
+ game: Game;
+ numPlayers: number;
+ setupData: any;
+ uuid: () => string;
+ unlisted: boolean;
+}) => {
if (!numPlayers || typeof numPlayers !== 'number') numPlayers = 2;
- const metadata: Server.GameMetadata = {
+ const metadata: Server.MatchData = {
gameName: game.name,
unlisted: !!unlisted,
players: {},
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
};
if (setupData !== undefined) metadata.setupData = setupData;
for (let playerIndex = 0; playerIndex < numPlayers; playerIndex++) {
metadata.players[playerIndex] = { id: playerIndex };
}
- const gameID = lobbyConfig.uuid();
+ const matchID = uuid();
const initialState = InitializeGame({ game, numPlayers, setupData });
- await db.createGame(gameID, { metadata, initialState });
+ await db.createGame(matchID, { metadata, initialState });
- return gameID;
+ return matchID;
};
-export const createApiServer = ({
- db,
- games,
- lobbyConfig,
- generateCredentials,
-}: {
- db: StorageAPI.Sync | StorageAPI.Async;
- games: Game[];
- lobbyConfig?: Server.LobbyConfig;
- generateCredentials?: Server.GenerateCredentials;
-}) => {
- const app = new Koa();
- return addApiToServer({ app, db, games, lobbyConfig, generateCredentials });
+/**
+ * Create a metadata object without secret credentials to return to the client.
+ *
+ * @param {string} matchID - The identifier of the match the metadata belongs to.
+ * @param {object} metadata - The match metadata object to strip credentials from.
+ * @return - A metadata object without player credentials.
+ */
+const createClientMatchData = (
+ matchID: string,
+ metadata: Server.MatchData
+): LobbyAPI.Match => {
+ return {
+ ...metadata,
+ matchID,
+ players: Object.values(metadata.players).map(player => {
+ // strip away credentials
+ const { credentials, ...strippedInfo } = player;
+ return strippedInfo;
+ }),
+ };
};
-export const addApiToServer = ({
- app,
+export const createRouter = ({
db,
games,
- lobbyConfig,
+ uuid,
generateCredentials,
}: {
- app: Koa;
games: Game[];
- lobbyConfig?: Server.LobbyConfig;
+ uuid?: () => string;
generateCredentials?: Server.GenerateCredentials;
db: StorageAPI.Sync | StorageAPI.Async;
-}) => {
- if (!lobbyConfig) lobbyConfig = {};
- lobbyConfig = {
- ...lobbyConfig,
- uuid: lobbyConfig.uuid || uuid,
- generateCredentials: generateCredentials || lobbyConfig.uuid || uuid,
- };
+}): Router => {
+ uuid = uuid || shortid;
+ generateCredentials = generateCredentials || uuid;
const router = new Router();
+ /**
+ * List available games.
+ *
+ * @return - Array of game names as string.
+ */
router.get('/games', async ctx => {
- ctx.body = games.map(game => game.name);
+ const body: LobbyAPI.GameList = games.map(game => game.name);
+ ctx.body = body;
});
+ /**
+ * Create a new match of a given game.
+ *
+ * @param {string} name - The name of the game of the new match.
+ * @param {number} numPlayers - The number of players.
+ * @param {object} setupData - User-defined object that's available
+ * during game setup.
+ * @param {boolean} unlisted - Whether the match should be excluded from public listing.
+ * @return - The ID of the created match.
+ */
router.post('/games/:name/create', koaBody(), async ctx => {
// The name of the game (for example: tic-tac-toe).
const gameName = ctx.params.name;
@@ -108,64 +134,107 @@ export const addApiToServer = ({
const game = games.find(g => g.name === gameName);
if (!game) ctx.throw(404, 'Game ' + gameName + ' not found');
- const gameID = await CreateGame(
+ const matchID = await CreateMatch({
db,
game,
numPlayers,
setupData,
- lobbyConfig,
- unlisted
- );
+ uuid,
+ unlisted,
+ });
- ctx.body = {
- gameID,
- };
+ const body: LobbyAPI.CreatedMatch = { matchID };
+ ctx.body = body;
});
+ /**
+ * List matches for a given game.
+ *
+ * This does not return matches that are marked as unlisted.
+ *
+ * @param {string} name - The name of the game.
+ * @return - Array of match objects.
+ */
router.get('/games/:name', async ctx => {
const gameName = ctx.params.name;
- const gameList = await db.listGames({ gameName });
- let rooms = [];
- for (let gameID of gameList) {
- const { metadata } = await (db as StorageAPI.Async).fetch(gameID, {
+ const {
+ isGameover: isGameoverString,
+ updatedBefore: updatedBeforeString,
+ updatedAfter: updatedAfterString,
+ } = ctx.query;
+
+ let isGameover: boolean | undefined;
+ if (isGameoverString) {
+ if (isGameoverString.toLowerCase() === 'true') {
+ isGameover = true;
+ } else if (isGameoverString.toLowerCase() === 'false') {
+ isGameover = false;
+ }
+ }
+ let updatedBefore: number | undefined;
+ if (updatedBeforeString) {
+ const parsedNumber = Number.parseInt(updatedBeforeString, 10);
+ if (parsedNumber > 0) {
+ updatedBefore = parsedNumber;
+ }
+ }
+ let updatedAfter: number | undefined;
+ if (updatedAfterString) {
+ const parsedNumber = Number.parseInt(updatedAfterString, 10);
+ if (parsedNumber > 0) {
+ updatedAfter = parsedNumber;
+ }
+ }
+ const matchList = await db.listGames({
+ gameName,
+ where: {
+ isGameover,
+ updatedAfter,
+ updatedBefore,
+ },
+ });
+ let matches = [];
+ for (let matchID of matchList) {
+ const { metadata } = await (db as StorageAPI.Async).fetch(matchID, {
metadata: true,
});
if (!metadata.unlisted) {
- rooms.push({
- gameID,
- players: Object.values(metadata.players).map(player => {
- // strip away credentials
- const { credentials, ...strippedInfo } = player;
- return strippedInfo;
- }),
- setupData: metadata.setupData,
- });
+ matches.push(createClientMatchData(matchID, metadata));
}
}
- ctx.body = {
- rooms: rooms,
- };
+ const body: LobbyAPI.MatchList = { matches };
+ ctx.body = body;
});
+ /**
+ * Get data about a specific match.
+ *
+ * @param {string} name - The name of the game.
+ * @param {string} id - The ID of the match.
+ * @return - A match object.
+ */
router.get('/games/:name/:id', async ctx => {
- const gameID = ctx.params.id;
- const { metadata } = await (db as StorageAPI.Async).fetch(gameID, {
+ const matchID = ctx.params.id;
+ const { metadata } = await (db as StorageAPI.Async).fetch(matchID, {
metadata: true,
});
if (!metadata) {
- ctx.throw(404, 'Room ' + gameID + ' not found');
+ ctx.throw(404, 'Match ' + matchID + ' not found');
}
- const strippedRoom = {
- roomID: gameID,
- players: Object.values(metadata.players).map(player => {
- const { credentials, ...strippedInfo } = player;
- return strippedInfo;
- }),
- setupData: metadata.setupData,
- };
- ctx.body = strippedRoom;
+ const body: LobbyAPI.Match = createClientMatchData(matchID, metadata);
+ ctx.body = body;
});
+ /**
+ * Join a given match.
+ *
+ * @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} 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;
const playerName = ctx.request.body.playerName;
@@ -176,12 +245,12 @@ export const addApiToServer = ({
if (!playerName) {
ctx.throw(403, 'playerName is required');
}
- const gameID = ctx.params.id;
- const { metadata } = await (db as StorageAPI.Async).fetch(gameID, {
+ const matchID = ctx.params.id;
+ const { metadata } = await (db as StorageAPI.Async).fetch(matchID, {
metadata: true,
});
if (!metadata) {
- ctx.throw(404, 'Game ' + gameID + ' not found');
+ ctx.throw(404, 'Match ' + matchID + ' not found');
}
if (!metadata.players[playerID]) {
ctx.throw(404, 'Player ' + playerID + ' not found');
@@ -194,21 +263,29 @@ export const addApiToServer = ({
metadata.players[playerID].data = data;
}
metadata.players[playerID].name = playerName;
- const playerCredentials = await lobbyConfig.generateCredentials(ctx);
+ const playerCredentials = await generateCredentials(ctx);
metadata.players[playerID].credentials = playerCredentials;
- await db.setMetadata(gameID, metadata);
+ await db.setMetadata(matchID, metadata);
- ctx.body = {
- playerCredentials,
- };
+ const body: LobbyAPI.JoinedMatch = { playerCredentials };
+ ctx.body = body;
});
+ /**
+ * Leave a given match.
+ *
+ * @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 leaves.
+ * @param {string} credentials - The credentials of the player who leaves.
+ * @return - Nothing.
+ */
router.post('/games/:name/:id/leave', koaBody(), async ctx => {
- const gameID = ctx.params.id;
+ const matchID = ctx.params.id;
const playerID = ctx.request.body.playerID;
const credentials = ctx.request.body.credentials;
- const { metadata } = await (db as StorageAPI.Async).fetch(gameID, {
+ const { metadata } = await (db as StorageAPI.Async).fetch(matchID, {
metadata: true,
});
if (typeof playerID === 'undefined' || playerID === null) {
@@ -216,7 +293,7 @@ export const addApiToServer = ({
}
if (!metadata) {
- ctx.throw(404, 'Game ' + gameID + ' not found');
+ ctx.throw(404, 'Match ' + matchID + ' not found');
}
if (!metadata.players[playerID]) {
ctx.throw(404, 'Player ' + playerID + ' not found');
@@ -228,21 +305,31 @@ export const addApiToServer = ({
delete metadata.players[playerID].name;
delete metadata.players[playerID].credentials;
if (Object.values(metadata.players).some(player => player.name)) {
- await db.setMetadata(gameID, metadata);
+ await db.setMetadata(matchID, metadata);
} else {
// remove room
- await db.wipe(gameID);
+ await db.wipe(matchID);
}
ctx.body = {};
});
+ /**
+ * Start a new match based on another existing match.
+ *
+ * @param {string} name - The name of the game.
+ * @param {string} id - The ID of the match.
+ * @param {string} playerID - The ID of the player creating the match.
+ * @param {string} credentials - The credentials of the player creating the match.
+ * @param {boolean} unlisted - Whether the match should be excluded from public listing.
+ * @return - The ID of the new match.
+ */
router.post('/games/:name/:id/playAgain', koaBody(), async ctx => {
const gameName = ctx.params.name;
- const gameID = ctx.params.id;
+ const matchID = ctx.params.id;
const playerID = ctx.request.body.playerID;
const credentials = ctx.request.body.credentials;
const unlisted = ctx.request.body.unlisted;
- const { metadata } = await (db as StorageAPI.Async).fetch(gameID, {
+ const { metadata } = await (db as StorageAPI.Async).fetch(matchID, {
metadata: true,
});
@@ -251,7 +338,7 @@ export const addApiToServer = ({
}
if (!metadata) {
- ctx.throw(404, 'Game ' + gameID + ' not found');
+ ctx.throw(404, 'Match ' + matchID + ' not found');
}
if (!metadata.players[playerID]) {
ctx.throw(404, 'Player ' + playerID + ' not found');
@@ -260,9 +347,9 @@ export const addApiToServer = ({
ctx.throw(403, 'Invalid credentials ' + credentials);
}
- // Check if nextRoom is already set, if so, return that id.
- if (metadata.nextRoomID) {
- ctx.body = { nextRoomID: metadata.nextRoomID };
+ // Check if nextMatch is already set, if so, return that id.
+ if (metadata.nextMatchID) {
+ ctx.body = { nextMatchID: metadata.nextMatchID };
return;
}
@@ -274,30 +361,29 @@ export const addApiToServer = ({
Object.keys(metadata.players).length;
const game = games.find(g => g.name === gameName);
- const nextRoomID = await CreateGame(
+ const nextMatchID = await CreateMatch({
db,
game,
numPlayers,
setupData,
- lobbyConfig,
- unlisted
- );
- metadata.nextRoomID = nextRoomID;
+ uuid,
+ unlisted,
+ });
+ metadata.nextMatchID = nextMatchID;
- await db.setMetadata(gameID, metadata);
+ await db.setMetadata(matchID, metadata);
- ctx.body = {
- nextRoomID,
- };
+ const body: LobbyAPI.NextMatch = { nextMatchID };
+ ctx.body = body;
});
const updatePlayerMetadata = async (ctx: Koa.Context) => {
- const gameID = ctx.params.id;
+ const matchID = ctx.params.id;
const playerID = ctx.request.body.playerID;
const credentials = ctx.request.body.credentials;
const newName = ctx.request.body.newName;
const data = ctx.request.body.data;
- const { metadata } = await (db as StorageAPI.Async).fetch(gameID, {
+ const { metadata } = await (db as StorageAPI.Async).fetch(matchID, {
metadata: true,
});
if (typeof playerID === 'undefined') {
@@ -310,7 +396,7 @@ export const addApiToServer = ({
ctx.throw(403, `newName must be a string, got ${typeof newName}`);
}
if (!metadata) {
- ctx.throw(404, 'Game ' + gameID + ' not found');
+ ctx.throw(404, 'Match ' + matchID + ' not found');
}
if (!metadata.players[playerID]) {
ctx.throw(404, 'Player ' + playerID + ' not found');
@@ -325,10 +411,20 @@ export const addApiToServer = ({
if (data) {
metadata.players[playerID].data = data;
}
- await db.setMetadata(gameID, metadata);
+ await db.setMetadata(matchID, metadata);
ctx.body = {};
};
+ /**
+ * Change the name of a player in a given match.
+ *
+ * @param {string} name - The name of the game.
+ * @param {string} id - The ID of the match.
+ * @param {string} playerID - The ID of the player.
+ * @param {string} credentials - The credentials of the player.
+ * @param {object} newName - The new name of the player in the match.
+ * @return - Nothing.
+ */
router.post('/games/:name/:id/rename', koaBody(), async ctx => {
console.warn(
'This endpoint /rename is deprecated. Please use /update instead.'
@@ -336,8 +432,23 @@ export const addApiToServer = ({
await updatePlayerMetadata(ctx);
});
+ /**
+ * Update the player's data for a given match.
+ *
+ * @param {string} name - The name of the game.
+ * @param {string} id - The ID of the match.
+ * @param {string} playerID - The ID of the player.
+ * @param {string} credentials - The credentials of the player.
+ * @param {object} newName - The new name of the player in the match.
+ * @param {object} data - The new data of the player in the match.
+ * @return - Nothing.
+ */
router.post('/games/:name/:id/update', koaBody(), updatePlayerMetadata);
+ return router;
+};
+
+export const configureApp = (app: Koa, router: Router): void => {
app.use(cors());
// If API_SECRET is set, then require that requests set an
@@ -354,6 +465,4 @@ export const addApiToServer = ({
});
app.use(router.routes()).use(router.allowedMethods());
-
- return app;
};
diff --git a/src/server/db/base.ts b/src/server/db/base.ts
index 7a60cb1ce..f9a91adda 100644
--- a/src/server/db/base.ts
+++ b/src/server/db/base.ts
@@ -22,7 +22,7 @@ export interface FetchOpts {
export interface FetchFields {
state: State;
log: LogEntry[];
- metadata: Server.GameMetadata;
+ metadata: Server.MatchData;
initialState: State;
}
@@ -36,6 +36,11 @@ export type FetchResult = Object.Pick<
export interface ListGamesOpts {
gameName?: string;
+ where?: {
+ isGameover?: boolean;
+ updatedBefore?: number;
+ updatedAfter?: number;
+ };
}
/**
@@ -43,7 +48,7 @@ export interface ListGamesOpts {
*/
export interface CreateGameOpts {
initialState: State;
- metadata: Server.GameMetadata;
+ metadata: Server.MatchData;
}
export abstract class Async {
@@ -69,7 +74,7 @@ export abstract class Async {
* a game is created. For example, it might stow away the
* initial game state in a separate field for easier retrieval.
*/
- abstract createGame(gameID: string, opts: CreateGameOpts): Promise;
+ abstract createGame(matchID: string, opts: CreateGameOpts): Promise;
/**
* Update the game state.
@@ -78,7 +83,7 @@ export abstract class Async {
* existing log for this game.
*/
abstract setState(
- gameID: string,
+ matchID: string,
state: State,
deltalog?: LogEntry[]
): Promise;
@@ -87,22 +92,22 @@ export abstract class Async {
* Update the game metadata.
*/
abstract setMetadata(
- gameID: string,
- metadata: Server.GameMetadata
+ matchID: string,
+ metadata: Server.MatchData
): Promise;
/**
* Fetch the game state.
*/
abstract fetch(
- gameID: string,
+ matchID: string,
opts: O
): Promise>;
/**
* Remove the game state.
*/
- abstract wipe(gameID: string): Promise;
+ abstract wipe(matchID: string): Promise;
/**
* Return all games.
@@ -133,7 +138,7 @@ export abstract class Sync {
* a game is created. For example, it might stow away the
* initial game state in a separate field for easier retrieval.
*/
- abstract createGame(gameID: string, opts: CreateGameOpts): void;
+ abstract createGame(matchID: string, opts: CreateGameOpts): void;
/**
* Update the game state.
@@ -141,22 +146,22 @@ export abstract class Sync {
* If passed a deltalog array, setState should append its contents to the
* existing log for this game.
*/
- abstract setState(gameID: string, state: State, deltalog?: LogEntry[]): void;
+ abstract setState(matchID: string, state: State, deltalog?: LogEntry[]): void;
/**
- * Update the game metadata.
+ * Update the match metadata.
*/
- abstract setMetadata(gameID: string, metadata: Server.GameMetadata): void;
+ abstract setMetadata(matchID: string, metadata: Server.MatchData): void;
/**
* Fetch the game state.
*/
- abstract fetch(gameID: string, opts: O): FetchResult;
+ abstract fetch(matchID: string, opts: O): FetchResult;
/**
* Remove the game state.
*/
- abstract wipe(gameID: string): void;
+ abstract wipe(matchID: string): void;
/**
* Return all games.
diff --git a/src/server/db/flatfile.test.ts b/src/server/db/flatfile.test.ts
index 7ef1de34b..a5aa2ca39 100644
--- a/src/server/db/flatfile.test.ts
+++ b/src/server/db/flatfile.test.ts
@@ -32,7 +32,7 @@ describe('FlatFile', () => {
await db.createGame('gameID', {
initialState: state as State,
- metadata: metadata as Server.GameMetadata,
+ metadata: metadata as Server.MatchData,
});
// Must return created game.
diff --git a/src/server/db/flatfile.ts b/src/server/db/flatfile.ts
index 1eb982e70..222e3b957 100644
--- a/src/server/db/flatfile.ts
+++ b/src/server/db/flatfile.ts
@@ -16,7 +16,7 @@ export class FlatFile extends StorageAPI.Async {
private games: {
init: (opts: object) => Promise;
setItem: (id: string, value: any) => Promise;
- getItem: (id: string) => Promise;
+ getItem: (id: string) => Promise;
removeItem: (id: string) => Promise;
clear: () => {};
keys: () => Promise;
@@ -78,39 +78,39 @@ export class FlatFile extends StorageAPI.Async {
}
async createGame(
- gameID: string,
+ matchID: string,
opts: StorageAPI.CreateGameOpts
): Promise {
// Store initial state separately for easy retrieval later.
- const key = InitialStateKey(gameID);
+ const key = InitialStateKey(matchID);
await this.setItem(key, opts.initialState);
- await this.setState(gameID, opts.initialState);
- await this.setMetadata(gameID, opts.metadata);
+ await this.setState(matchID, opts.initialState);
+ await this.setMetadata(matchID, opts.metadata);
}
async fetch(
- gameID: string,
+ matchID: string,
opts: O
): Promise> {
let result = {} as StorageAPI.FetchFields;
if (opts.state) {
- result.state = (await this.getItem(gameID)) as State;
+ result.state = (await this.getItem(matchID)) as State;
}
if (opts.metadata) {
- const key = MetadataKey(gameID);
- result.metadata = (await this.getItem(key)) as Server.GameMetadata;
+ const key = MetadataKey(matchID);
+ result.metadata = (await this.getItem(key)) as Server.MatchData;
}
if (opts.log) {
- const key = LogKey(gameID);
+ const key = LogKey(matchID);
result.log = (await this.getItem(key)) as LogEntry[];
}
if (opts.initialState) {
- const key = InitialStateKey(gameID);
+ const key = InitialStateKey(matchID);
result.initialState = (await this.getItem(key)) as State;
}
@@ -132,7 +132,7 @@ export class FlatFile extends StorageAPI.Async {
return await this.setItem(id, state);
}
- async setMetadata(id: string, metadata: Server.GameMetadata): Promise {
+ async setMetadata(id: string, metadata: Server.MatchData): Promise {
const key = MetadataKey(id);
return await this.setItem(key, metadata);
@@ -157,14 +157,14 @@ export class FlatFile extends StorageAPI.Async {
}
}
-function InitialStateKey(gameID: string) {
- return `${gameID}:initial`;
+function InitialStateKey(matchID: string) {
+ return `${matchID}:initial`;
}
-function MetadataKey(gameID: string) {
- return `${gameID}:metadata`;
+function MetadataKey(matchID: string) {
+ return `${matchID}:metadata`;
}
-function LogKey(gameID: string) {
- return `${gameID}:log`;
+function LogKey(matchID: string) {
+ return `${matchID}:log`;
}
diff --git a/src/server/db/inmemory.test.ts b/src/server/db/inmemory.test.ts
index d40d49bb4..a6dd50271 100644
--- a/src/server/db/inmemory.test.ts
+++ b/src/server/db/inmemory.test.ts
@@ -30,7 +30,8 @@ describe('InMemory', () => {
db.createGame('gameID', {
metadata: {
gameName: 'tic-tac-toe',
- } as Server.GameMetadata,
+ updatedAt: new Date(2020, 1).getTime(),
+ } as Server.MatchData,
initialState: stateEntry as State,
});
@@ -43,13 +44,93 @@ describe('InMemory', () => {
expect(initialState).toEqual(stateEntry);
});
- test('listGames', () => {
- let keys = db.listGames({});
- expect(keys).toEqual(['gameID']);
- keys = db.listGames({ gameName: 'tic-tac-toe' });
- expect(keys).toEqual(['gameID']);
- keys = db.listGames({ gameName: 'chess' });
- expect(keys).toEqual([]);
+ describe('listGames', () => {
+ test('filter by gameName', () => {
+ let keys = db.listGames();
+ expect(keys).toEqual(['gameID']);
+ keys = db.listGames({ gameName: 'tic-tac-toe' });
+ expect(keys).toEqual(['gameID']);
+ keys = db.listGames({ gameName: 'chess' });
+ expect(keys).toEqual([]);
+ });
+
+ test('filter by isGameover', () => {
+ const stateEntry: unknown = { a: 1 };
+ db.createGame('gameID2', {
+ metadata: {
+ gameName: 'tic-tac-toe',
+ gameover: 'gameover',
+ updatedAt: new Date(2020, 3).getTime(),
+ } as Server.MatchData,
+ initialState: stateEntry as State,
+ });
+
+ let keys = db.listGames({});
+ expect(keys).toEqual(['gameID', 'gameID2']);
+ keys = db.listGames({ where: { isGameover: true } });
+ expect(keys).toEqual(['gameID2']);
+ keys = db.listGames({ where: { isGameover: false } });
+ expect(keys).toEqual(['gameID']);
+ });
+
+ test('filter by updatedBefore', () => {
+ const stateEntry: unknown = { a: 1 };
+ db.createGame('gameID3', {
+ metadata: {
+ gameName: 'tic-tac-toe',
+ updatedAt: new Date(2020, 5).getTime(),
+ } as Server.MatchData,
+ initialState: stateEntry as State,
+ });
+ const timestamp = new Date(2020, 4);
+
+ let keys = db.listGames({});
+ expect(keys).toEqual(['gameID', 'gameID2', 'gameID3']);
+ keys = db.listGames({ where: { updatedBefore: timestamp.getTime() } });
+ expect(keys).toEqual(['gameID', 'gameID2']);
+ });
+
+ test('filter by updatedAfter', () => {
+ const timestamp = new Date(2020, 4);
+
+ let keys = db.listGames({});
+ expect(keys).toEqual(['gameID', 'gameID2', 'gameID3']);
+ keys = db.listGames({ where: { updatedAfter: timestamp.getTime() } });
+ expect(keys).toEqual(['gameID3']);
+ });
+
+ test('filter combined', () => {
+ const timestamp = new Date(2020, 4);
+ const timestamp2 = new Date(2020, 2, 15);
+ let keys = db.listGames({
+ gameName: 'chess',
+ where: { isGameover: true },
+ });
+ expect(keys).toEqual([]);
+ keys = db.listGames({
+ where: { isGameover: true, updatedBefore: timestamp.getTime() },
+ });
+ expect(keys).toEqual(['gameID2']);
+ keys = db.listGames({
+ where: { isGameover: false, updatedBefore: timestamp.getTime() },
+ });
+ expect(keys).toEqual(['gameID']);
+ keys = db.listGames({
+ where: { isGameover: true, updatedAfter: timestamp.getTime() },
+ });
+ expect(keys).toEqual([]);
+ keys = db.listGames({
+ where: { isGameover: false, updatedAfter: timestamp.getTime() },
+ });
+ expect(keys).toEqual(['gameID3']);
+ keys = db.listGames({
+ where: {
+ updatedBefore: timestamp.getTime(),
+ updatedAfter: timestamp2.getTime(),
+ },
+ });
+ expect(keys).toEqual(['gameID2']);
+ });
});
test('remove', () => {
diff --git a/src/server/db/inmemory.ts b/src/server/db/inmemory.ts
index d0485f919..a2ab73d7c 100644
--- a/src/server/db/inmemory.ts
+++ b/src/server/db/inmemory.ts
@@ -15,7 +15,7 @@ import * as StorageAPI from './base';
export class InMemory extends StorageAPI.Sync {
private state: Map;
private initial: Map;
- private metadata: Map;
+ private metadata: Map;
private log: Map;
/**
@@ -32,53 +32,53 @@ export class InMemory extends StorageAPI.Sync {
/**
* Create a new game.
*/
- createGame(gameID: string, opts: StorageAPI.CreateGameOpts) {
- this.initial.set(gameID, opts.initialState);
- this.setState(gameID, opts.initialState);
- this.setMetadata(gameID, opts.metadata);
+ createGame(matchID: string, opts: StorageAPI.CreateGameOpts) {
+ this.initial.set(matchID, opts.initialState);
+ this.setState(matchID, opts.initialState);
+ this.setMetadata(matchID, opts.metadata);
}
/**
* Write the game metadata to the in-memory object.
*/
- setMetadata(gameID: string, metadata: Server.GameMetadata) {
- this.metadata.set(gameID, metadata);
+ setMetadata(matchID: string, metadata: Server.MatchData) {
+ this.metadata.set(matchID, metadata);
}
/**
* Write the game state to the in-memory object.
*/
- setState(gameID: string, state: State, deltalog?: LogEntry[]): void {
+ setState(matchID: string, state: State, deltalog?: LogEntry[]): void {
if (deltalog && deltalog.length > 0) {
- const log = this.log.get(gameID) || [];
- this.log.set(gameID, log.concat(deltalog));
+ const log = this.log.get(matchID) || [];
+ this.log.set(matchID, log.concat(deltalog));
}
- this.state.set(gameID, state);
+ this.state.set(matchID, state);
}
/**
- * Fetches state for a particular gameID.
+ * Fetches state for a particular matchID.
*/
fetch(
- gameID: string,
+ matchID: string,
opts: O
): StorageAPI.FetchResult {
let result = {} as StorageAPI.FetchFields;
if (opts.state) {
- result.state = this.state.get(gameID);
+ result.state = this.state.get(matchID);
}
if (opts.metadata) {
- result.metadata = this.metadata.get(gameID);
+ result.metadata = this.metadata.get(matchID);
}
if (opts.log) {
- result.log = this.log.get(gameID) || [];
+ result.log = this.log.get(matchID) || [];
}
if (opts.initialState) {
- result.initialState = this.initial.get(gameID);
+ result.initialState = this.initial.get(matchID);
}
return result as StorageAPI.FetchResult;
@@ -87,24 +87,53 @@ export class InMemory extends StorageAPI.Sync {
/**
* Remove the game state from the in-memory object.
*/
- wipe(gameID: string) {
- this.state.delete(gameID);
- this.metadata.delete(gameID);
+ wipe(matchID: string) {
+ this.state.delete(matchID);
+ this.metadata.delete(matchID);
}
/**
* Return all keys.
*/
listGames(opts?: StorageAPI.ListGamesOpts): string[] {
- if (opts && opts.gameName !== undefined) {
- let gameIDs = [];
- this.metadata.forEach((metadata, gameID) => {
- if (metadata.gameName === opts.gameName) {
- gameIDs.push(gameID);
+ return [...this.metadata.entries()]
+ .filter(([key, metadata]) => {
+ if (!opts) {
+ return true;
}
- });
- return gameIDs;
- }
- return [...this.metadata.keys()];
+
+ if (
+ opts.gameName !== undefined &&
+ metadata.gameName !== opts.gameName
+ ) {
+ return false;
+ }
+
+ if (opts.where !== undefined) {
+ if (opts.where.isGameover !== undefined) {
+ const isGameover = metadata.gameover !== undefined;
+ if (isGameover !== opts.where.isGameover) {
+ return false;
+ }
+ }
+
+ if (
+ opts.where.updatedBefore !== undefined &&
+ metadata.updatedAt >= opts.where.updatedBefore
+ ) {
+ return false;
+ }
+
+ if (
+ opts.where.updatedAfter !== undefined &&
+ metadata.updatedAt <= opts.where.updatedAfter
+ ) {
+ return false;
+ }
+ }
+
+ return true;
+ })
+ .map(([key]) => key);
}
}
diff --git a/src/server/index.test.ts b/src/server/index.test.ts
index bbdc0c5a1..cca5fb2a5 100644
--- a/src/server/index.test.ts
+++ b/src/server/index.test.ts
@@ -7,7 +7,6 @@
*/
import { Server, createServerRunConfig, KoaServer } from '.';
-import * as api from './api';
import { SocketIO } from './transport/socketio';
import { StorageAPI } from '../types';
@@ -18,20 +17,6 @@ jest.mock('../core/logger', () => ({
error: () => {},
}));
-const mockApiServerListen = jest.fn((port, listeningCallback?: () => void) => {
- if (listeningCallback) listeningCallback();
- return {
- address: () => ({ port: 'mock-api-port' }),
- close: () => {},
- };
-});
-jest.mock('./api', () => ({
- createApiServer: jest.fn(() => ({
- listen: mockApiServerListen,
- })),
- addApiToServer: jest.fn(),
-}));
-
jest.mock('koa-socket-2', () => {
class MockSocket {
on() {}
@@ -51,7 +36,7 @@ jest.mock('koa-socket-2', () => {
callback((this as any).socket);
}
adapter(adapter) {
- return this
+ return this;
}
};
});
@@ -60,6 +45,7 @@ jest.mock('koa', () => {
return class {
constructor() {
(this as any).context = {};
+ (this as any).use = () => this;
(this as any).callback = () => {};
(this as any).listen = (port, listeningCallback?: () => void) => {
if (listeningCallback) listeningCallback();
@@ -102,9 +88,6 @@ describe('run', () => {
beforeEach(() => {
server = null;
runningServer = null;
- (api.createApiServer as jest.Mock).mockClear();
- (api.addApiToServer as jest.Mock).mockClear();
- (mockApiServerListen as jest.Mock).mockClear();
});
afterEach(() => {
@@ -119,9 +102,8 @@ describe('run', () => {
runningServer = await server.run(undefined);
expect(server).not.toBeUndefined();
- expect(api.addApiToServer).toBeCalled();
- expect(api.createApiServer).not.toBeCalled();
- expect(mockApiServerListen).not.toBeCalled();
+ expect(runningServer.appServer).not.toBeUndefined();
+ expect(runningServer.apiServer).toBeUndefined();
});
test('multiple servers running', async () => {
@@ -132,9 +114,8 @@ describe('run', () => {
});
expect(server).not.toBeUndefined();
- expect(api.addApiToServer).not.toBeCalled();
- expect(api.createApiServer).toBeCalled();
- expect(mockApiServerListen).toBeCalled();
+ expect(runningServer.appServer).not.toBeUndefined();
+ expect(runningServer.apiServer).not.toBeUndefined();
});
});
diff --git a/src/server/index.ts b/src/server/index.ts
index 05dba850f..62879ef43 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -8,7 +8,7 @@
import Koa from 'koa';
-import { addApiToServer, createApiServer } from './api';
+import { createRouter, configureApp } from './api';
import { DBFromEnv } from './db';
import { ProcessGameConfig } from '../core/game';
import * as logger from '../core/logger';
@@ -62,6 +62,7 @@ interface ServerOpts {
games: Game[];
db?: StorageAPI.Async | StorageAPI.Sync;
transport?: SocketIO;
+ uuid?: () => string;
authenticateCredentials?: ServerTypes.AuthenticateCredentials;
generateCredentials?: ServerTypes.GenerateCredentials;
https?: HttpsOptions;
@@ -76,6 +77,7 @@ interface ServerOpts {
* @param authenticateCredentials - Function to test player credentials.
* @param generateCredentials - Method for API to generate player credentials.
* @param https - HTTPS configuration options passed through to the TLS module.
+ * @param lobbyConfig - Configuration options for the Lobby API server.
*/
export function Server({
games,
@@ -84,6 +86,7 @@ export function Server({
authenticateCredentials,
generateCredentials,
https,
+ uuid,
}: ServerOpts) {
const app = new Koa();
@@ -106,30 +109,29 @@ export function Server({
}
transport.init(app, games);
+ const router = createRouter({ db, games, uuid, generateCredentials });
+
return {
app,
db,
+ router,
transport,
- run: async (portOrConfig: number | object, callback?: () => void) => {
+ run: async (portOrConfig: number | ServerConfig, callback?: () => void) => {
const serverRunConfig = createServerRunConfig(portOrConfig, callback);
// DB
await db.connect();
// Lobby API
- const lobbyConfig: ServerTypes.LobbyConfig = serverRunConfig.lobbyConfig;
+ const lobbyConfig = serverRunConfig.lobbyConfig;
let apiServer: KoaServer | undefined;
if (!lobbyConfig || !lobbyConfig.apiPort) {
- addApiToServer({ app, db, games, lobbyConfig, generateCredentials });
+ configureApp(app, router);
} else {
// Run API in a separate Koa app.
- const api = createApiServer({
- db,
- games,
- lobbyConfig,
- generateCredentials,
- });
+ const api = new Koa();
+ configureApp(api, router);
await new Promise(resolve => {
apiServer = api.listen(lobbyConfig.apiPort, resolve);
});
diff --git a/src/server/transport/socketio.test.ts b/src/server/transport/socketio.test.ts
index afed39669..d8f0f4764 100644
--- a/src/server/transport/socketio.test.ts
+++ b/src/server/transport/socketio.test.ts
@@ -131,9 +131,9 @@ describe('socketAdapter', () => {
});
test('socketAdapter is passed', () => {
- expect(app.io.socketAdapter).toBe(socketAdapter)
- })
-})
+ expect(app.io.socketAdapter).toBe(socketAdapter);
+ });
+});
describe('TransportAPI', () => {
let io;
@@ -223,11 +223,11 @@ describe('connect / disconnect', () => {
await io.socket.receive('sync', 'gameID', '1', 2);
expect(toObj(clientInfo)['0']).toMatchObject({
- gameID: 'gameID',
+ matchID: 'gameID',
playerID: '0',
});
expect(toObj(clientInfo)['1']).toMatchObject({
- gameID: 'gameID',
+ matchID: 'gameID',
playerID: '1',
});
});
@@ -238,7 +238,7 @@ describe('connect / disconnect', () => {
expect(toObj(clientInfo)['0']).toBeUndefined();
expect(toObj(clientInfo)['1']).toMatchObject({
- gameID: 'gameID',
+ matchID: 'gameID',
playerID: '1',
});
expect(toObj(roomInfo.get('gameID'))).toEqual({ '1': '1' });
@@ -250,7 +250,7 @@ describe('connect / disconnect', () => {
expect(toObj(clientInfo)['0']).toBeUndefined();
expect(toObj(clientInfo)['1']).toMatchObject({
- gameID: 'gameID',
+ matchID: 'gameID',
playerID: '1',
});
expect(toObj(roomInfo.get('gameID'))).toEqual({ '1': '1' });
diff --git a/src/server/transport/socketio.ts b/src/server/transport/socketio.ts
index 1ef02458a..1ed4c98fc 100644
--- a/src/server/transport/socketio.ts
+++ b/src/server/transport/socketio.ts
@@ -24,7 +24,7 @@ const PING_INTERVAL = 10 * 1e3;
* information to the clients.
*/
export function TransportAPI(
- gameID: string,
+ matchID: string,
socket,
clientInfo: Map,
roomInfo: Map
@@ -33,7 +33,7 @@ export function TransportAPI(
* Send a message to a specific client.
*/
const send: MasterTransport['send'] = ({ type, playerID, args }) => {
- const clients = roomInfo.get(gameID).values();
+ const clients = roomInfo.get(matchID).values();
for (const client of clients) {
const info = clientInfo.get(client);
if (info.playerID == playerID) {
@@ -50,7 +50,7 @@ export function TransportAPI(
* Send a message to all clients.
*/
const sendAll: MasterTransport['sendAll'] = makePlayerData => {
- roomInfo.get(gameID).forEach(c => {
+ roomInfo.get(matchID).forEach(c => {
const playerID: PlayerID = clientInfo.get(c).playerID;
const data = makePlayerData(playerID);
send({ playerID, ...data });
@@ -78,7 +78,12 @@ export class SocketIO {
private socketAdapter: any;
private socketOpts: SocketOptions;
- constructor({ auth = true, https, socketAdapter, socketOpts }: SocketOpts = {}) {
+ constructor({
+ auth = true,
+ https,
+ socketAdapter,
+ socketOpts,
+ }: SocketOpts = {}) {
this.clientInfo = new Map();
this.roomInfo = new Map();
this.auth = auth;
@@ -107,47 +112,47 @@ export class SocketIO {
const nsp = app._io.of(game.name);
nsp.on('connection', socket => {
- socket.on('update', async (action, stateID, gameID, playerID) => {
+ socket.on('update', async (action, stateID, matchID, playerID) => {
const master = new Master(
game,
app.context.db,
- TransportAPI(gameID, socket, this.clientInfo, this.roomInfo),
+ TransportAPI(matchID, socket, this.clientInfo, this.roomInfo),
this.auth
);
- await master.onUpdate(action, stateID, gameID, playerID);
+ await master.onUpdate(action, stateID, matchID, playerID);
});
- socket.on('sync', async (gameID, playerID, numPlayers) => {
- socket.join(gameID);
+ socket.on('sync', async (matchID, playerID, numPlayers) => {
+ socket.join(matchID);
// Remove client from any previous game that it was a part of.
if (this.clientInfo.has(socket.id)) {
- const { gameID: oldGameID } = this.clientInfo.get(socket.id);
- this.roomInfo.get(oldGameID).delete(socket.id);
+ const { matchID: oldMatchID } = this.clientInfo.get(socket.id);
+ this.roomInfo.get(oldMatchID).delete(socket.id);
}
- let roomClients = this.roomInfo.get(gameID);
+ let roomClients = this.roomInfo.get(matchID);
if (roomClients === undefined) {
roomClients = new Set();
- this.roomInfo.set(gameID, roomClients);
+ this.roomInfo.set(matchID, roomClients);
}
roomClients.add(socket.id);
- this.clientInfo.set(socket.id, { gameID, playerID, socket });
+ this.clientInfo.set(socket.id, { matchID, playerID, socket });
const master = new Master(
game,
app.context.db,
- TransportAPI(gameID, socket, this.clientInfo, this.roomInfo),
+ TransportAPI(matchID, socket, this.clientInfo, this.roomInfo),
this.auth
);
- await master.onSync(gameID, playerID, numPlayers);
+ await master.onSync(matchID, playerID, numPlayers);
});
socket.on('disconnect', () => {
if (this.clientInfo.has(socket.id)) {
- const { gameID } = this.clientInfo.get(socket.id);
- this.roomInfo.get(gameID).delete(socket.id);
+ const { matchID } = this.clientInfo.get(socket.id);
+ this.roomInfo.get(matchID).delete(socket.id);
this.clientInfo.delete(socket.id);
}
});
diff --git a/src/types.ts b/src/types.ts
index a2ca2a917..48e5b9003 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -229,6 +229,8 @@ interface PhaseMap {
export interface Game {
name?: string;
+ minPlayers?: number;
+ maxPlayers?: number;
disableUndo?: boolean;
seed?: string | number;
setup?: (ctx: CtxWithPlugins, setupData?: any) => any;
@@ -291,20 +293,36 @@ export namespace Server {
data?: any;
};
- export interface GameMetadata {
+ export interface MatchData {
gameName: string;
players: { [id: number]: PlayerMetadata };
setupData?: any;
gameover?: any;
- nextRoomID?: string;
+ nextMatchID?: string;
unlisted?: boolean;
+ createdAt: number;
+ updatedAt: number;
}
+}
- export interface LobbyConfig {
- uuid?: () => string;
- generateCredentials?: GenerateCredentials;
- apiPort?: number;
- apiCallback?: () => void;
+export namespace LobbyAPI {
+ export type GameList = string[];
+ type PublicPlayerMetadata = Omit;
+ export type Match = Omit & {
+ matchID: string;
+ players: PublicPlayerMetadata[];
+ };
+ export interface MatchList {
+ matches: Match[];
+ }
+ export interface CreatedMatch {
+ matchID: string;
+ }
+ export interface JoinedMatch {
+ playerCredentials: string;
+ }
+ export interface NextMatch {
+ nextMatchID: string;
}
}