diff --git a/docs/documentation/api/Client.md b/docs/documentation/api/Client.md index 6d661e02a..6183d12ec 100644 --- a/docs/documentation/api/Client.md +++ b/docs/documentation/api/Client.md @@ -53,8 +53,8 @@ const client = Client({ // See `src/client/client.js` for details. multiplayer: false, - // Game to connect to (multiplayer). - gameID: 'gameID', + // Match to connect to (multiplayer). + matchID: 'matchID', // Associate the client with a player (multiplayer). playerID: 'playerID', @@ -91,7 +91,7 @@ The following properties are available on a client instance: - `log`: The game log. -- `gameID`: The game ID associated with the client. +- `matchID`: The match ID associated with the client. - `playerID`: The player ID associated with the client. @@ -100,8 +100,8 @@ The following properties are available on a client instance: - `credentials`: Multiplayer authentication credentials for this player. -- `gameMetadata`: An array containing the players that have joined - the game from a [room](/api/Lobby.md). +- `matchData`: An array containing the players that have joined + the current match via the [Lobby API](/api/Lobby.md). Example: @@ -175,7 +175,7 @@ The following methods are available on a client instance: - `redo()`: Function that redoes the previously undone move. -- `updateGameID(id)`: Function to update the client’s game ID. +- `updateMatchID(id)`: Function to update the client’s match ID. - `updatePlayerID(id)`: Function to update the client’s player ID. @@ -202,8 +202,8 @@ A React component that runs the client. The component supports the following `props`: -1. `gameID` (_string_): - Connect to a particular game (multiplayer). +1. `matchID` (_string_): + Connect to a particular match (multiplayer). 2. `playerID` (_string_): Associate the client with a player (multiplayer). @@ -308,14 +308,14 @@ following as `props`: - `log`: The game log. -- `gameID`: The game ID associated with the client. +- `matchID`: The match ID associated with the client. - `playerID`: The player ID associated with the client. -- `gameMetadata`: An array containing the players that have joined - the game from a [room](/api/Lobby.md). +- `matchData`: An array containing the players that have joined + the current match via the [Lobby API](/api/Lobby.md). Example: diff --git a/docs/documentation/api/Lobby.md b/docs/documentation/api/Lobby.md index 04455d3af..a4983ea53 100644 --- a/docs/documentation/api/Lobby.md +++ b/docs/documentation/api/Lobby.md @@ -1,161 +1,247 @@ # Lobby -### React components +The [Server](/api/Server) hosts the Lobby REST API that can be used to create +and join matches. It is particularly useful when you want to +authenticate clients to prove that they have the right to send +actions on behalf of a player. + +Authenticated matches are created with server-side tokens for each player. +You can create a match with the `create` API call, and join a player to a +match with the `join` API call. + +A match that is authenticated will not accept moves from a client on behalf +of a player without the appropriate credential token. + +Use the `create` API call to create a match that requires credential tokens. +When you call the `join` API, you can retrieve the credential token for a +particular player. + +## Clients + + +### **Plain JS** + +boardgame.io provides a lightweight wrapper around the Fetch API to simplify +using a Lobby API server from the client. -You can use the lobby component with the code below: + +```js +import { LobbyClient } from 'boardgame.io/client'; + +const lobbyClient = new LobbyClient({ server: 'http://localhost:8000' }); + +lobbyClient.listGames() + .then(console.log) // => ['chess', 'tic-tac-toe'] + .catch(console.error); +``` + +### **React** + +The React lobby component provides a more high-level client, including UI +for listing, joining, and creating matches. ```js import { Lobby } from 'boardgame.io/react'; +import { TicTacToe } from './Game'; +import { TicTacToeBoard } from './Board'; ; ``` -`importedGames` is an array of objects with these fields: +`gameComponents` expects an array of objects with these fields: -- `game`: The boardgame.io `Game` definition. +- `game`: A boardgame.io `Game` definition. - `board`: The React component that will render the board. -### Server-side API - -The [Server](/api/Server) hosts the Lobby REST API that can be used to create and join rooms. It is particularly useful when you want to -authenticate clients to prove that they have the right to send -actions on behalf of a player. + -Authenticated games are created with server-side tokens for each player. You can create a room with the `create` API call, and join a player to a room with the `join` API call. +## REST API -A game that is authenticated will not accept moves from a client on behalf of a player without the appropriate credential token. +### Listing available game types -Use the `create` API call to create a room that requires credential tokens. When you call the `join` API, you can retrieve the credential token for a particular player. +#### GET `/games` -#### Configuration +Returns an array of names for the games this server is running. -You can pass `lobbyConfig` to configure the Lobby API -during server startup: +#### Using a LobbyClient instance ```js -server.run({ port: 8000, lobbyConfig }); +const games = await lobbyClient.listGames(); ``` -Options are: +### Listing all matches for a given game -- `apiPort`: If specified, it runs the Lobby API in a separate Koa server on this port. Otherwise, it shares the same Koa server runnning on the default boardgame.io `port`. -- `apiCallback`: Called when the Koa server is ready. Only applicable if `apiPort` is specified. -- `uuid`: Function that returns an unique identifier, needed for creating new game ID codes. If not specified, uses [shortid](https://www.npmjs.com/package/shortid). +#### GET `/games/{name}` -#### Creating a room +Returns all match instances of the game named `name`. -##### POST `/games/{name}/create` +Returns an array of `matches`. Each instance has fields: -Creates a new authenticated room for a game named `name`. +- `matchID`: the ID of the match instance. -Accepts three parameters: +- `players`: the list of seats and players that have joined the game, if any. -- `numPlayers` (required): the number of players. +- `setupData` (optional): custom object that was passed to the game `setup` function. -- `setupData` (optional): custom object that is passed to the game `setup` function. +#### Using a LobbyClient instance -- `unlisted` (optional): if set to `true`, the room will be excluded from the public list of room instances. +```js +const { matches } = await lobbyClient.listMatches('tic-tac-toe'); +``` -Returns `roomID`, which is the ID of the newly created game instance. +### Getting a specific match by its ID -#### Joining a game +#### GET `/games/{name}/{id}` -##### POST `/games/{name}/{id}/join` +Returns a match instance given its matchID. -Allows a player to join a particular room instance `id` of a game named `name`. +Returns a match instance. Each instance has fields: -Accepts three JSON body parameters: +- `matchID`: the ID of the match instance. -- `playerID` (required): the ordinal player in the game that is being joined (0, 1...). +- `players`: the list of seats and players that have joined the match, if any. -- `playerName` (required): the display name of the player joining the game. +- `setupData` (optional): custom object that was passed to the game `setup` function. -- `data` (optional): additional information associated to the player. +#### Using a LobbyClient instance -Returns `playerCredentials` which is the token this player will require to authenticate their actions in the future. +```js +const match = await lobbyClient.getMatch('tic-tac-toe', 'matchID'); +``` -#### Update a player's information +### Creating a match -##### POST `/games/{name}/{id}/update` +#### POST `/games/{name}/create` -Rename and/or update additional information of a user in the room instance `id` of a game named `name` previously joined by the player. +Creates a new authenticated match for a game named `name`. -Accepts four JSON body parameters, requires at least one of the two optional parameters: +Accepts three parameters: -- `playerID` (required): the ID used by the player in the game (0,1...). +- `numPlayers` (required): the number of players. -- `crendentials` (required): the authentication token of the player. +- `setupData` (optional): custom object that is passed to the game `setup` function. -- `newName` (optional): the new name of the player. +- `unlisted` (optional): if set to `true`, the match will be excluded from the public list of match instances. -- `data` (optional): additional information associated to the player. +Returns `matchID`, which is the ID of the newly created game instance. -#### Leaving a room +#### Using a LobbyClient instance -##### POST `/games/{name}/{id}/leave` +```js +const { matchID } = await lobbyClient.createMatch('tic-tac-toe', { + numPlayers: 2 +}); +``` -Leave the room instance `id` of a game named `name` previously joined by the player. +### Joining a match -Accepts two JSON body parameters, all required: +#### POST `/games/{name}/{id}/join` -- `playerID`: the ID used by the player in the game (0, 1...). +Allows a player to join a particular match instance `id` of a game named `name`. -- `credentials`: the authentication token of the player. +Accepts three JSON body parameters: -#### Listing all room instances of a given game +- `playerID` (required): the ordinal player in the match that is being joined (`'0'`, `'1'`...). -##### GET `/games/{name}` +- `playerName` (required): the display name of the player joining the match. -Returns all room instances of the game named `name`. +- `data` (optional): additional metadata to associate with the player. -Returns an array of `rooms`. Each instance has fields: +Returns `playerCredentials` which is the token this player will require to authenticate their actions in the future. -- `roomID`: the ID of the room instance. +#### Using a LobbyClient instance -- `players`: the list of seats and players that have joined the game, if any. +```js +const { playerCredentials } = await lobbyClient.joinMatch( + 'tic-tac-toe', + 'matchID', + { + playerID: '0', + playerName: 'Alice', + } +); +``` -- `setupData` (optional): custom object that was passed to the game `setup` function. +### Updating a player’s metadata -#### Getting specific instance of a room by its ID +#### POST `/games/{name}/{id}/update` -##### GET `/games/{name}/{id}` +Rename and/or update additional metadata for a player in the match instance `id` of a game named `name` previously joined by the player. -Returns a room instance given its roomID. +Accepts four JSON body parameters, requires at least one of the two optional parameters: -Returns a room instance. Each instance has fields: +- `playerID` (required): the ID used by the player in the match (0,1...). -- `roomID`: the ID of the room instance. +- `credentials` (required): the authentication token of the player. -- `players`: the list of seats and players that have joined the game, if any. +- `newName` (optional): the new name of the player. -- `setupData` (optional): custom object that was passed to the game `setup` function. +- `data` (optional): additional metadata to associate with the player. -#### Client Authentication +#### Using a LobbyClient instance -All actions for an authenticated game require an additional payload field `credentials`, which must be the given secret associated with the player. +```js +await lobbyClient.updatePlayer('tic-tac-toe', 'matchID', { + playerID: '0', + credentials: 'playerCredentials', + newName: 'Al', +}); +``` -#### Playing again +### Leaving a match -##### POST `/games/{name}/{id}/playAgain` +#### POST `/games/{name}/{id}/leave` + +Leave the match instance `id` of a game named `name` previously joined by the player. + +Accepts two JSON body parameters, all required: + +- `playerID`: the ID used by the player in the match (0, 1...). + +- `credentials`: the authentication token of the player. + +#### Using a LobbyClient instance + +```js +await lobbyClient.leaveMatch('tic-tac-toe', 'matchID', { + playerID: '0', + credentials: 'playerCredentials', +}); +``` + +### Playing again + +#### POST `/games/{name}/{id}/playAgain` - `{name}` (required): the name of the game being played again. -- `{id}` (required): the ID of the previous finished room. +- `{id}` (required): the ID of the previous finished match. -Given a previous room, generates a room ID where users should go if they want to play again. Creates this new room if it didn't exist before. +Given a previous match, generates a match ID where users should go if they want to play again. Creates this new match if it didn't exist before. Accepts these parameters: -- `playerID` (required): the player ID of the player on the previous game. +- `playerID` (required): the player ID of the player in the previous match. - `credentials` (required): player's credentials. -`numPlayers` (optional): the number of players. Defaults to the `numPlayers` value of the previous room. +- `numPlayers` (optional): the number of players. Defaults to the `numPlayers` value of the previous match. -`setupData` (optional): custom object that was passed to the game `setup` function. Defaults to the `setupData` object of the previous room. +- `setupData` (optional): custom object that was passed to the game `setup` function. Defaults to the `setupData` object of the previous room. -Returns `nextRoomID`, which is the ID of the newly created room that the user should go to play again. +Returns `nextMatchID`, which is the ID of the newly created match that the user should go to play again. + +#### Using a LobbyClient instance + +```js +const { nextMatchID } = await lobbyClient.playAgain('tic-tac-toe', 'matchID', { + playerID: '0', + credentials: 'playerCredentials', +}); +``` diff --git a/docs/documentation/api/Server.md b/docs/documentation/api/Server.md index cd434e563..eba55b9b7 100644 --- a/docs/documentation/api/Server.md +++ b/docs/documentation/api/Server.md @@ -25,9 +25,11 @@ A config object with the following options: 3. `transport` (_object_): the transport implementation. If not provided, socket.io is used. -4. `generateCredentials` (_function_): an optional function that returns player credentials to store in the game metadata and validate against. If not specified, the Lobby’s `uuid` implementation will be used. +4. `uuid` (_function_): an optional function that returns a unique identifier, used to create new game IDs and — if `generateCredentials` is not specified — player credentials. Defaults to [shortid](https://www.npmjs.com/package/shortid). -5. `authenticateCredentials` (_function_): an optional function that tests if a player’s move is made with the correct credentials when using the default socket.io transport implementation. +5. `generateCredentials` (_function_): an optional function that returns player credentials to store in the game metadata and validate against. If not specified, the `uuid` function will be used. + +6. `authenticateCredentials` (_function_): an optional function that tests if a player’s move is made with the correct credentials when using the default socket.io transport implementation. #### Returns @@ -43,6 +45,8 @@ An object that contains: 4. `db` (_object_): The database implementation. +5. router (_object_): The Koa Router for the server API. + ### Usage #### Basic @@ -65,6 +69,27 @@ server.run(8000); server.run(8000, () => console.log("server running...")); ``` +#### With custom Lobby settings + +You can pass `lobbyConfig` to configure the Lobby API during server startup: + +```js +const lobbyConfig = { + apiPort: 8080, + apiCallback: () => console.log('Running Lobby API on port 8080...'), +}; + +server.run({ port: 8000, lobbyConfig }); +``` + +Options are: + +- `apiPort`: If specified, it runs the Lobby API in a separate Koa server on +this port. Otherwise, it shares the same Koa server running on the default +boardgame.io `port`. +- `apiCallback`: Called when the Koa server is ready. Only applicable if +`apiPort` is specified. + #### With HTTPS ```js diff --git a/docs/documentation/multiplayer.md b/docs/documentation/multiplayer.md index 414c0a778..35fb84280 100644 --- a/docs/documentation/multiplayer.md +++ b/docs/documentation/multiplayer.md @@ -299,35 +299,35 @@ const TicTacToe = { By default all client instances connect to a game with an ID `'default'`. To play a new game instance, you can pass -`gameID` to your client. All clients that use +`matchID` to your client. All clients that use this ID will now see the same game state. #### **Plain JS** -Pass `gameID` when creating your boardgame.io client: +Pass `matchID` when creating your boardgame.io client: ```js const client = Client({ game: TicTacToe, - gameID: 'gameid', + matchID: 'matchID', // ... }); ``` -You an also update a `gameID` on an already instantiated client: +You an also update a `matchID` on an already instantiated client: ```js -client.updateGameID('newGameID'); +client.updateMatchID('newID'); ``` #### **React** ``` - + ``` -The `gameID`, similar to the `playerID` can again be determined +The `matchID`, similar to the `playerID` can again be determined either by a URL path or a lobby implementation. ### Storage diff --git a/examples/react-native/App.js b/examples/react-native/App.js index f70076b8e..3b2c40033 100644 --- a/examples/react-native/App.js +++ b/examples/react-native/App.js @@ -22,7 +22,7 @@ const App = Client({ const Singleplayer = () => ( - + ); diff --git a/examples/react-web/src/chess/multiplayer.js b/examples/react-web/src/chess/multiplayer.js index 9fe54f135..0563ee3d6 100644 --- a/examples/react-web/src/chess/multiplayer.js +++ b/examples/react-web/src/chess/multiplayer.js @@ -22,7 +22,7 @@ const App = Client({ const Multiplayer = playerID => () => (
- + PlayerID: {playerID}
); diff --git a/examples/react-web/src/chess/singleplayer.js b/examples/react-web/src/chess/singleplayer.js index 39c6fc9e4..ea2a2d38a 100644 --- a/examples/react-web/src/chess/singleplayer.js +++ b/examples/react-web/src/chess/singleplayer.js @@ -18,7 +18,7 @@ const App = Client({ const Singleplayer = () => (
- +
); diff --git a/examples/react-web/src/random/index.js b/examples/react-web/src/random/index.js index 0d06f83f6..cc7b55a03 100644 --- a/examples/react-web/src/random/index.js +++ b/examples/react-web/src/random/index.js @@ -19,7 +19,7 @@ const App = Client({ const SingleView = () => (
- +
); diff --git a/examples/react-web/src/redacted-move/multiview.js b/examples/react-web/src/redacted-move/multiview.js index 871fffe75..87ee85121 100644 --- a/examples/react-web/src/redacted-move/multiview.js +++ b/examples/react-web/src/redacted-move/multiview.js @@ -34,15 +34,15 @@ const Multiview = () => (

- + <App playerID="0"/>
- + <App playerID="1"/>
- + <App/>
diff --git a/examples/react-web/src/secret-state/multiview.js b/examples/react-web/src/secret-state/multiview.js index af648f126..ffaa847f9 100644 --- a/examples/react-web/src/secret-state/multiview.js +++ b/examples/react-web/src/secret-state/multiview.js @@ -25,19 +25,19 @@ const Multiview = () => (

Secret Info

- + <App playerID="0"/>
- + <App playerID="1"/>
- + <App playerID="2"/>
- + <App/>
diff --git a/examples/react-web/src/simulator/simulator.js b/examples/react-web/src/simulator/simulator.js index d74235585..dcdd0e03c 100644 --- a/examples/react-web/src/simulator/simulator.js +++ b/examples/react-web/src/simulator/simulator.js @@ -118,7 +118,7 @@ class App extends React.Component { let players = []; for (let i = 0; i < 6; i++) { - players.push(); + players.push(); } return ( @@ -152,7 +152,7 @@ class App extends React.Component {
- + {players}
diff --git a/examples/react-web/src/tic-tac-toe/authenticated.js b/examples/react-web/src/tic-tac-toe/authenticated.js index 18adad2f3..301c3d549 100644 --- a/examples/react-web/src/tic-tac-toe/authenticated.js +++ b/examples/react-web/src/tic-tac-toe/authenticated.js @@ -26,7 +26,7 @@ class AuthenticatedClient extends React.Component { constructor(props) { super(props); this.state = { - gameID: 'gameID', + matchID: 'matchID', players: { '0': { credentials: 'credentials', @@ -46,13 +46,13 @@ class AuthenticatedClient extends React.Component { .post(`http://${hostname}:${PORT}/games/${gameName}/create`) .send({ numPlayers: 2 }); - const gameID = newGame.body.gameID; + const matchID = newGame.body.matchID; let playerCredentials = []; for (let playerID of [0, 1]) { const player = await request - .post(`http://${hostname}:${PORT}/games/${gameName}/${gameID}/join`) + .post(`http://${hostname}:${PORT}/games/${gameName}/${matchID}/join`) .send({ gameName, playerID, @@ -63,7 +63,7 @@ class AuthenticatedClient extends React.Component { } this.setState({ - gameID, + matchID, players: { '0': { credentials: playerCredentials[0], @@ -77,7 +77,7 @@ class AuthenticatedClient extends React.Component { onPlayerCredentialsChange(playerID, credentials) { this.setState({ - gameID: this.state.gameID, + matchID: this.state.matchID, players: { ...this.state.players, [playerID]: { @@ -90,7 +90,7 @@ class AuthenticatedClient extends React.Component { render() { return ( @@ -100,7 +100,7 @@ class AuthenticatedClient extends React.Component { class AuthenticatedExample extends React.Component { static propTypes = { - gameID: PropTypes.string, + matchID: PropTypes.string, players: PropTypes.any, onPlayerCredentialsChange: PropTypes.func, }; @@ -118,7 +118,7 @@ class AuthenticatedExample extends React.Component {
@@ -132,7 +132,7 @@ class AuthenticatedExample extends React.Component {
diff --git a/examples/react-web/src/tic-tac-toe/multiplayer.js b/examples/react-web/src/tic-tac-toe/multiplayer.js index a3f069667..e7c5a2071 100644 --- a/examples/react-web/src/tic-tac-toe/multiplayer.js +++ b/examples/react-web/src/tic-tac-toe/multiplayer.js @@ -24,11 +24,11 @@ const Multiplayer = () => (

Multiplayer

- + <App playerID="0"/>
- + <App playerID="1"/>
diff --git a/examples/react-web/src/tic-tac-toe/singleplayer.js b/examples/react-web/src/tic-tac-toe/singleplayer.js index 188984f4a..58782558e 100644 --- a/examples/react-web/src/tic-tac-toe/singleplayer.js +++ b/examples/react-web/src/tic-tac-toe/singleplayer.js @@ -21,7 +21,7 @@ const App = Client({ const Singleplayer = () => (

Singleplayer

- +
); diff --git a/examples/react-web/src/tic-tac-toe/spectator.js b/examples/react-web/src/tic-tac-toe/spectator.js index 20e24c27c..f67266977 100644 --- a/examples/react-web/src/tic-tac-toe/spectator.js +++ b/examples/react-web/src/tic-tac-toe/spectator.js @@ -25,15 +25,15 @@ const Spectator = () => (

Spectator

- + <App playerID="0"/>
- + <App playerID="1"/>
- + Spectator
diff --git a/examples/snippets/src/phases-1/Player.svelte b/examples/snippets/src/phases-1/Player.svelte index acadc2831..2a18efcf1 100644 --- a/examples/snippets/src/phases-1/Player.svelte +++ b/examples/snippets/src/phases-1/Player.svelte @@ -7,7 +7,7 @@ const client = Client({ game: Game, - gameID: 'default', + matchID: 'default', playerID, debug: false, numPlayers: 3, diff --git a/examples/snippets/src/phases-2/Player.svelte b/examples/snippets/src/phases-2/Player.svelte index a572c1c64..e358e60c3 100644 --- a/examples/snippets/src/phases-2/Player.svelte +++ b/examples/snippets/src/phases-2/Player.svelte @@ -7,7 +7,7 @@ const client = Client({ game: Game, - gameID: 'default', + matchID: 'default', playerID, debug: false, numPlayers: 3, diff --git a/examples/snippets/src/stages-1/Player.svelte b/examples/snippets/src/stages-1/Player.svelte index 0f5c15b23..1adeda566 100644 --- a/examples/snippets/src/stages-1/Player.svelte +++ b/examples/snippets/src/stages-1/Player.svelte @@ -7,7 +7,7 @@ const client = Client({ game: Game, - gameID: 'default', + matchID: 'default', playerID, debug: false, numPlayers: 3, diff --git a/package-lock.json b/package-lock.json index 17e6fe874..a9207669b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9509,6 +9509,12 @@ "realpath-native": "^1.1.0" } }, + "jest-date-mock": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/jest-date-mock/-/jest-date-mock-1.0.8.tgz", + "integrity": "sha512-0Lyp+z9xvuNmLbK+5N6FOhSiBeux05Lp5bbveFBmYo40Aggl2wwxFoIrZ+rOWC8nDNcLeBoDd2miQdEDSf3iQw==", + "dev": true + }, "jest-diff": { "version": "24.9.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-24.9.0.tgz", diff --git a/package.json b/package.json index 40780dc38..bb3bccbd4 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "husky": "^1.3.1", "identity-obj-proxy": "^3.0.0", "jest": "^24.0.0", + "jest-date-mock": "^1.0.8", "jest-transform-svelte": "^2.1.0", "lint-staged": "^8.1.0", "node-persist": "^3.0.4", @@ -183,7 +184,8 @@ "src/types.ts" ], "setupFiles": [ - "raf/polyfill" + "raf/polyfill", + "jest-date-mock" ], "setupFilesAfterEnv": [ "@testing-library/jest-dom/extend-expect" diff --git a/packages/client.ts b/packages/client.ts index f0fe563b1..def965405 100644 --- a/packages/client.ts +++ b/packages/client.ts @@ -7,5 +7,6 @@ */ import { Client } from '../src/client/client'; +import { LobbyClient } from '../src/lobby/client'; -export { Client }; +export { Client, LobbyClient }; diff --git a/src/client/client.test.ts b/src/client/client.test.ts index 23c9cc68b..e799a4125 100644 --- a/src/client/client.test.ts +++ b/src/client/client.test.ts @@ -233,7 +233,7 @@ describe('multiplayer', () => { this.callback = null; } - subscribeGameMetadata(fn) { + subscribeMatchData(fn) { this.callback = fn; } } @@ -256,7 +256,7 @@ describe('multiplayer', () => { test('metadata callback', () => { const metadata = { m: true }; client.transport.callback(metadata); - expect(client.gameMetadata).toEqual(metadata); + expect(client.matchData).toEqual(metadata); }); }); }); diff --git a/src/client/client.ts b/src/client/client.ts index e821e8996..c82108afb 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -91,7 +91,7 @@ export interface ClientOpts { debug?: DebugOpt | boolean; numPlayers?: number; multiplayer?: (opts: TransportOpts) => Transport; - gameID?: string; + matchID?: string; playerID?: PlayerID; credentials?: string; enhancer?: StoreEnhancer; @@ -113,10 +113,10 @@ export class _ClientImpl { game: ReturnType; store: Store; log: State['deltalog']; - gameID: string; + matchID: string; playerID: PlayerID | null; credentials: string; - gameMetadata?: FilteredMetadata; + matchData?: FilteredMetadata; moves: Record void>; events: { endGame?: (gameover?: any) => void; @@ -137,14 +137,14 @@ export class _ClientImpl { debug, numPlayers, multiplayer, - gameID, + matchID: matchID, playerID, credentials, enhancer, }: ClientOpts) { this.game = ProcessGameConfig(game); this.playerID = playerID; - this.gameID = gameID; + this.matchID = matchID; this.credentials = credentials; this.multiplayer = multiplayer; this.debug = debug; @@ -282,10 +282,10 @@ export class _ClientImpl { isConnected: true, onAction: () => {}, subscribe: () => {}, - subscribeGameMetadata: () => {}, + subscribeMatchData: () => {}, connect: () => {}, disconnect: () => {}, - updateGameID: () => {}, + updateMatchID: () => {}, updatePlayerID: () => {}, } as unknown) as Transport; @@ -295,7 +295,7 @@ export class _ClientImpl { gameKey: game, game: this.game, store: this.store, - gameID, + matchID, playerID, gameName: this.game.name, numPlayers, @@ -304,8 +304,8 @@ export class _ClientImpl { this.createDispatchers(); - this.transport.subscribeGameMetadata(metadata => { - this.gameMetadata = metadata; + this.transport.subscribeMatchData(metadata => { + this.matchData = metadata; }); this._debugPanel = null; @@ -476,10 +476,10 @@ export class _ClientImpl { this.notifySubscribers(); } - updateGameID(gameID: string) { - this.gameID = gameID; + updateMatchID(matchID: string) { + this.matchID = matchID; this.createDispatchers(); - this.transport.updateGameID(gameID); + this.transport.updateMatchID(matchID); this.notifySubscribers(); } @@ -498,7 +498,7 @@ export class _ClientImpl { * @param {...object} game - The return value of `Game`. * @param {...object} numPlayers - The number of players. * @param {...object} multiplayer - Set to a falsy value or a transportFactory, e.g., SocketIO() - * @param {...object} gameID - The gameID that you want to connect to. + * @param {...object} matchID - The matchID that you want to connect to. * @param {...object} playerID - The playerID associated with this client. * @param {...string} credentials - The authentication credentials associated with this client. * diff --git a/src/client/debug/info/Info.svelte b/src/client/debug/info/Info.svelte index ec7ecf965..ad5e18bd1 100644 --- a/src/client/debug/info/Info.svelte +++ b/src/client/debug/info/Info.svelte @@ -11,7 +11,7 @@
- + {#if client.multiplayer} diff --git a/src/client/debug/tests/debug.test.js b/src/client/debug/tests/debug.test.js index 5fa41429d..f863be484 100644 --- a/src/client/debug/tests/debug.test.js +++ b/src/client/debug/tests/debug.test.js @@ -29,7 +29,7 @@ test('switching panels', async () => { // switch to info tab const InfoTab = getByText('Info'); await fireEvent.click(InfoTab); - expect(getByText('gameID')).toBeInTheDocument(); + expect(getByText('matchID')).toBeInTheDocument(); expect(getByText('playerID')).toBeInTheDocument(); expect(getByText('isActive')).toBeInTheDocument(); diff --git a/src/client/react-native.js b/src/client/react-native.js index 1af7f225c..ebebe57d4 100644 --- a/src/client/react-native.js +++ b/src/client/react-native.js @@ -40,7 +40,7 @@ export function Client(opts) { static propTypes = { // The ID of a game to connect to. // Only relevant in multiplayer. - gameID: PropTypes.string, + matchID: PropTypes.string, // The ID of the player associated with this client. // Only relevant in multiplayer. playerID: PropTypes.string, @@ -50,7 +50,7 @@ export function Client(opts) { }; static defaultProps = { - gameID: 'default', + matchID: 'default', playerID: null, credentials: null, }; @@ -62,7 +62,7 @@ export function Client(opts) { game, numPlayers, multiplayer, - gameID: props.gameID, + matchID: props.matchID, playerID: props.playerID, credentials: props.credentials, debug: false, @@ -81,8 +81,8 @@ export function Client(opts) { } componentDidUpdate(prevProps) { - if (prevProps.gameID != this.props.gameID) { - this.client.updateGameID(this.props.gameID); + if (prevProps.matchID != this.props.matchID) { + this.client.updateMatchID(this.props.matchID); } if (prevProps.playerID != this.props.playerID) { this.client.updatePlayerID(this.props.playerID); @@ -96,13 +96,13 @@ export function Client(opts) { let _board = null; const state = this.client.getState(); - const { gameID, playerID, ...rest } = this.props; + const { matchID, playerID, ...rest } = this.props; if (board) { _board = React.createElement(board, { ...state, ...rest, - gameID, + matchID, playerID, isMultiplayer: !!multiplayer, moves: this.client.moves, @@ -111,7 +111,7 @@ export function Client(opts) { reset: this.client.reset, undo: this.client.undo, redo: this.client.redo, - gameMetadata: this.client.gameMetadata, + matchData: this.client.matchData, }); } diff --git a/src/client/react-native.test.js b/src/client/react-native.test.js index 175919c97..54375dab1 100644 --- a/src/client/react-native.test.js +++ b/src/client/react-native.test.js @@ -84,7 +84,7 @@ test('move api', () => { expect(board.props.G).toEqual({ arg: 42 }); }); -test('update gameID / playerID', () => { +test('update matchID / playerID', () => { let Board = null; let game = null; @@ -99,7 +99,7 @@ test('update gameID / playerID', () => { board: TestBoard, }); game = Enzyme.mount(); - game.setProps({ gameID: 'a' }); + game.setProps({ matchID: 'a' }); game.setProps({ playerID: '3' }); expect(game.instance().transport).toBe(undefined); @@ -114,32 +114,32 @@ test('update gameID / playerID', () => { board: TestBoard, multiplayer: Local(), }); - game = Enzyme.mount(); + game = Enzyme.mount(); const m = game.instance().client.transport; const g = game.instance().client; - const spy1 = jest.spyOn(m, 'updateGameID'); + const spy1 = jest.spyOn(m, 'updateMatchID'); const spy2 = jest.spyOn(m, 'updatePlayerID'); const spy3 = jest.spyOn(g, 'updateCredentials'); - expect(m.gameID).toBe('a'); + expect(m.matchID).toBe('a'); expect(m.playerID).toBe('1'); - game.setProps({ gameID: 'a' }); + game.setProps({ matchID: 'a' }); game.setProps({ playerID: '1' }); game.setProps({ credentials: 'foo' }); - expect(m.gameID).toBe('a'); + expect(m.matchID).toBe('a'); expect(m.playerID).toBe('1'); expect(spy1).not.toHaveBeenCalled(); expect(spy2).not.toHaveBeenCalled(); expect(spy3).not.toHaveBeenCalled(); - game.setProps({ gameID: 'next' }); + game.setProps({ matchID: 'next' }); game.setProps({ playerID: 'next' }); game.setProps({ credentials: 'bar' }); - expect(m.gameID).toBe('next'); + expect(m.matchID).toBe('next'); expect(m.playerID).toBe('next'); expect(spy1).toHaveBeenCalled(); expect(spy2).toHaveBeenCalled(); diff --git a/src/client/react.test.tsx b/src/client/react.test.tsx index fd0d10f56..e32c4d376 100644 --- a/src/client/react.test.tsx +++ b/src/client/react.test.tsx @@ -110,7 +110,7 @@ test('move api', () => { expect(board.props.G).toEqual({ arg: 42 }); }); -test('update gameID / playerID', () => { +test('update matchID / playerID', () => { let Board = null; let game = null; @@ -125,7 +125,7 @@ test('update gameID / playerID', () => { board: TestBoard, }); game = Enzyme.mount(); - game.setProps({ gameID: 'a' }); + game.setProps({ matchID: 'a' }); game.setProps({ playerID: '3' }); expect(game.instance().transport).toBe(undefined); @@ -140,32 +140,32 @@ test('update gameID / playerID', () => { board: TestBoard, multiplayer: Local(), }); - game = Enzyme.mount(); + game = Enzyme.mount(); const m = game.instance().client.transport; const g = game.instance().client; - const spy1 = jest.spyOn(m, 'updateGameID'); + const spy1 = jest.spyOn(m, 'updateMatchID'); const spy2 = jest.spyOn(m, 'updatePlayerID'); const spy3 = jest.spyOn(g, 'updateCredentials'); - expect(m.gameID).toBe('a'); + expect(m.matchID).toBe('a'); expect(m.playerID).toBe('1'); - game.setProps({ gameID: 'a' }); + game.setProps({ matchID: 'a' }); game.setProps({ playerID: '1' }); game.setProps({ credentials: 'foo' }); - expect(m.gameID).toBe('a'); + expect(m.matchID).toBe('a'); expect(m.playerID).toBe('1'); expect(spy1).not.toHaveBeenCalled(); expect(spy2).not.toHaveBeenCalled(); expect(spy3).not.toHaveBeenCalled(); - game.setProps({ gameID: 'next' }); + game.setProps({ matchID: 'next' }); game.setProps({ playerID: 'next' }); game.setProps({ credentials: 'bar' }); - expect(m.gameID).toBe('next'); + expect(m.matchID).toBe('next'); expect(m.playerID).toBe('next'); expect(spy1).toHaveBeenCalled(); expect(spy2).toHaveBeenCalled(); diff --git a/src/client/react.tsx b/src/client/react.tsx index 0108b5918..ed4aa7fa7 100644 --- a/src/client/react.tsx +++ b/src/client/react.tsx @@ -11,7 +11,7 @@ import PropTypes from 'prop-types'; import { Client as RawClient, ClientOpts, _ClientImpl } from './client'; import { State } from '../types'; -type WrappedBoardDelegates = 'gameID' | 'playerID' | 'credentials'; +type WrappedBoardDelegates = 'matchID' | 'playerID' | 'credentials'; export type WrappedBoardProps = Pick< ClientOpts, @@ -28,8 +28,8 @@ export type BoardProps = State & | 'undo' | 'redo' | 'playerID' - | 'gameID' - | 'gameMetadata' + | 'matchID' + | 'matchData' > & { isActive: boolean; isMultiplayer: boolean; @@ -92,7 +92,7 @@ export function Client< static propTypes = { // The ID of a game to connect to. // Only relevant in multiplayer. - gameID: PropTypes.string, + matchID: PropTypes.string, // The ID of the player associated with this client. // Only relevant in multiplayer. playerID: PropTypes.string, @@ -104,7 +104,7 @@ export function Client< }; static defaultProps = { - gameID: 'default', + matchID: 'default', playerID: null, credentials: null, debug: true, @@ -122,7 +122,7 @@ export function Client< debug, numPlayers, multiplayer, - gameID: props.gameID, + matchID: props.matchID, playerID: props.playerID, credentials: props.credentials, enhancer, @@ -140,8 +140,8 @@ export function Client< } componentDidUpdate(prevProps: WrappedBoardProps & AdditionalProps) { - if (this.props.gameID != prevProps.gameID) { - this.client.updateGameID(this.props.gameID); + if (this.props.matchID != prevProps.matchID) { + this.client.updateMatchID(this.props.matchID); } if (this.props.playerID != prevProps.playerID) { this.client.updatePlayerID(this.props.playerID); @@ -167,13 +167,13 @@ export function Client< isMultiplayer: !!multiplayer, moves: this.client.moves, events: this.client.events, - gameID: this.client.gameID, + matchID: this.client.matchID, playerID: this.client.playerID, reset: this.client.reset, undo: this.client.undo, redo: this.client.redo, log: this.client.log, - gameMetadata: this.client.gameMetadata, + matchData: this.client.matchData, }); } diff --git a/src/client/transport/local.test.js b/src/client/transport/local.test.js index 554460723..54b184641 100644 --- a/src/client/transport/local.test.js +++ b/src/client/transport/local.test.js @@ -169,8 +169,8 @@ describe('LocalMaster', () => { }); test('connect without callback', () => { - master.connect('gameID', '0', undefined); - master.onSync('gameID', '0'); + master.connect('matchID', '0', undefined); + master.onSync('matchID', '0'); }); test('disconnect', () => { @@ -180,7 +180,7 @@ describe('LocalMaster', () => { }); describe('LocalTransport', () => { - describe('update gameID / playerID', () => { + describe('update matchID / playerID', () => { const master = { connect: jest.fn(), onSync: jest.fn() }; const store = { dispatch: () => {} }; const m = new LocalTransport({ master, store }); @@ -189,9 +189,9 @@ describe('LocalTransport', () => { jest.resetAllMocks(); }); - test('gameID', () => { - m.updateGameID('test'); - expect(m.gameID).toBe('test'); + test('matchID', () => { + m.updateMatchID('test'); + expect(m.matchID).toBe('test'); expect(master.connect).toBeCalled(); }); @@ -221,7 +221,7 @@ describe('LocalTransport', () => { test('receive update', () => { const restored = { restore: true }; expect(store.getState()).not.toMatchObject(restored); - m.onUpdate('unknown gameID', restored); + m.onUpdate('unknown matchID', restored); expect(store.getState()).not.toMatchObject(restored); m.onUpdate('default', restored); expect(store.getState()).not.toMatchObject(restored); @@ -235,7 +235,7 @@ describe('LocalTransport', () => { test('receive sync', () => { const restored = { restore: true }; expect(store.getState()).not.toMatchObject(restored); - m.onSync('unknown gameID', { state: restored }); + m.onSync('unknown matchID', { state: restored }); expect(store.getState()).not.toMatchObject(restored); m.onSync('default', { state: restored }); expect(store.getState()).toMatchObject(restored); diff --git a/src/client/transport/local.ts b/src/client/transport/local.ts index e8126dea9..7cceb78cd 100644 --- a/src/client/transport/local.ts +++ b/src/client/transport/local.ts @@ -52,7 +52,7 @@ interface LocalMasterOpts { */ export class LocalMaster extends Master { connect: ( - gameID: string, + matchID: string, playerID: PlayerID, callback: (...args: any[]) => void ) => void; @@ -91,11 +91,11 @@ export class LocalMaster extends Master { super(game, new InMemory(), transportAPI, false); - this.connect = (gameID, playerID, callback) => { + this.connect = (matchID, playerID, callback) => { clientCallbacks[playerID] = callback; }; - this.subscribe(({ state, gameID }) => { + this.subscribe(({ state, matchID }) => { if (!bots) { return; } @@ -109,7 +109,7 @@ export class LocalMaster extends Master { await this.onUpdate( botAction.action, state._stateID, - gameID, + matchID, botAction.action.payload.playerID ); }, 100); @@ -133,7 +133,7 @@ export class LocalTransport extends Transport { /** * Creates a new Mutiplayer instance. - * @param {string} gameID - The game ID to connect to. + * @param {string} matchID - The game ID to connect to. * @param {string} playerID - The player ID associated with this client. * @param {string} gameName - The game type (the `name` field in `Game`). * @param {string} numPlayers - The number of players. @@ -141,12 +141,12 @@ export class LocalTransport extends Transport { constructor({ master, store, - gameID, + matchID, playerID, gameName, numPlayers, }: LocalTransportOpts) { - super({ store, gameName, playerID, gameID, numPlayers }); + super({ store, gameName, playerID, matchID, numPlayers }); this.master = master; this.isConnected = true; } @@ -156,10 +156,10 @@ export class LocalTransport extends Transport { * master broadcasts the update to other clients (including * this one). */ - async onUpdate(gameID: string, state: State, deltalog: LogEntry[]) { + async onUpdate(matchID: string, state: State, deltalog: LogEntry[]) { const currentState = this.store.getState(); - if (gameID == this.gameID && state._stateID >= currentState._stateID) { + if (matchID == this.matchID && state._stateID >= currentState._stateID) { const action = ActionCreators.update(state, deltalog); this.store.dispatch(action); } @@ -169,8 +169,8 @@ export class LocalTransport extends Transport { * Called when the client first connects to the master * and requests the current game state. */ - onSync(gameID: string, syncInfo: SyncInfo) { - if (gameID == this.gameID) { + onSync(matchID: string, syncInfo: SyncInfo) { + if (matchID == this.matchID) { const action = ActionCreators.sync(syncInfo); this.store.dispatch(action); } @@ -181,14 +181,14 @@ export class LocalTransport extends Transport { * game master is made. */ onAction(state: State, action: CredentialedActionShape.Any) { - this.master.onUpdate(action, state._stateID, this.gameID, this.playerID); + this.master.onUpdate(action, state._stateID, this.matchID, this.playerID); } /** * Connect to the master. */ connect() { - this.master.connect(this.gameID, this.playerID, (type, ...args) => { + this.master.connect(this.matchID, this.playerID, (type, ...args) => { if (type == 'sync') { this.onSync.apply(this, args); } @@ -196,7 +196,7 @@ export class LocalTransport extends Transport { this.onUpdate.apply(this, args); } }); - this.master.onSync(this.gameID, this.playerID, this.numPlayers); + this.master.onSync(this.matchID, this.playerID, this.numPlayers); } /** @@ -209,14 +209,14 @@ export class LocalTransport extends Transport { */ subscribe() {} - subscribeGameMetadata() {} + subscribeMatchData() {} /** * Updates the game id. * @param {string} id - The new game id. */ - updateGameID(id: string) { - this.gameID = id; + updateMatchID(id: string) { + this.matchID = id; const action = ActionCreators.reset(null); this.store.dispatch(action); this.connect(); diff --git a/src/client/transport/socketio.test.js b/src/client/transport/socketio.test.js index 6edc20cc7..a8e411670 100644 --- a/src/client/transport/socketio.test.js +++ b/src/client/transport/socketio.test.js @@ -36,16 +36,16 @@ test('defaults', () => { m.callback(); }); -describe('update gameID / playerID', () => { +describe('update matchID / playerID', () => { const socket = new MockSocket(); const m = new SocketIOTransport({ socket }); m.store = { dispatch: () => {} }; beforeEach(() => (socket.emit = jest.fn())); - test('gameID', () => { - m.updateGameID('test'); - expect(m.gameID).toBe('test'); + test('matchID', () => { + m.updateMatchID('test'); + expect(m.matchID).toBe('test'); expect(socket.emit).lastCalledWith('sync', 'test', null, 2); }); @@ -66,7 +66,7 @@ describe('connection status', () => { mockSocket = new MockSocket(); m = new SocketIOTransport({ socket: mockSocket, - gameID: 0, + matchID: 0, playerID: 0, gameName: 'foo', numPlayers: 2, @@ -115,7 +115,7 @@ describe('multiplayer', () => { test('receive update', () => { const restored = { restore: true }; expect(store.getState()).not.toMatchObject(restored); - mockSocket.receive('update', 'unknown gameID', restored); + mockSocket.receive('update', 'unknown matchID', restored); expect(store.getState()).not.toMatchObject(restored); mockSocket.receive('update', 'default', restored); expect(store.getState()).not.toMatchObject(restored); @@ -129,7 +129,7 @@ describe('multiplayer', () => { test('receive sync', () => { const restored = { restore: true }; expect(store.getState()).not.toMatchObject(restored); - mockSocket.receive('sync', 'unknown gameID', { state: restored }); + mockSocket.receive('sync', 'unknown matchID', { state: restored }); expect(store.getState()).not.toMatchObject(restored); mockSocket.receive('sync', 'default', { state: restored }); expect(store.getState()).toMatchObject(restored); @@ -195,14 +195,14 @@ describe('server option', () => { }); }); -test('changing a gameID resets the state before resync', () => { +test('changing a matchID resets the state before resync', () => { const m = new SocketIOTransport(); const game = {}; const store = createStore(CreateGameReducer({ game })); m.store = store; const dispatchSpy = jest.spyOn(store, 'dispatch'); - m.updateGameID('foo'); + m.updateMatchID('foo'); expect(dispatchSpy).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/client/transport/socketio.ts b/src/client/transport/socketio.ts index 8e86654ab..74026cbc6 100644 --- a/src/client/transport/socketio.ts +++ b/src/client/transport/socketio.ts @@ -39,13 +39,13 @@ export class SocketIOTransport extends Transport { socket; socketOpts; callback: () => void; - gameMetadataCallback: MetadataCallback; + matchDataCallback: MetadataCallback; /** * Creates a new Mutiplayer instance. * @param {object} socket - Override for unit tests. * @param {object} socketOpts - Options to pass to socket.io. - * @param {string} gameID - The game ID to connect to. + * @param {string} matchID - The game ID to connect to. * @param {string} playerID - The player ID associated with this client. * @param {string} gameName - The game type (the `name` field in `Game`). * @param {string} numPlayers - The number of players. @@ -55,20 +55,20 @@ export class SocketIOTransport extends Transport { socket, socketOpts, store, - gameID, + matchID, playerID, gameName, numPlayers, server, }: SocketIOTransportOpts = {}) { - super({ store, gameName, playerID, gameID, numPlayers }); + super({ store, gameName, playerID, matchID, numPlayers }); this.server = server; this.socket = socket; this.socketOpts = socketOpts; this.isConnected = false; this.callback = () => {}; - this.gameMetadataCallback = () => {}; + this.matchDataCallback = () => {}; } /** @@ -80,7 +80,7 @@ export class SocketIOTransport extends Transport { 'update', action, state._stateID, - this.gameID, + this.matchID, this.playerID ); } @@ -110,10 +110,13 @@ export class SocketIOTransport extends Transport { // this one). this.socket.on( 'update', - (gameID: string, state: State, deltalog: LogEntry[]) => { + (matchID: string, state: State, deltalog: LogEntry[]) => { const currentState = this.store.getState(); - if (gameID == this.gameID && state._stateID >= currentState._stateID) { + if ( + matchID == this.matchID && + state._stateID >= currentState._stateID + ) { const action = ActionCreators.update(state, deltalog); this.store.dispatch(action); } @@ -122,10 +125,10 @@ export class SocketIOTransport extends Transport { // Called when the client first connects to the master // and requests the current game state. - this.socket.on('sync', (gameID: string, syncInfo: SyncInfo) => { - if (gameID == this.gameID) { + this.socket.on('sync', (matchID: string, syncInfo: SyncInfo) => { + if (matchID == this.matchID) { const action = ActionCreators.sync(syncInfo); - this.gameMetadataCallback(syncInfo.filteredMetadata); + this.matchDataCallback(syncInfo.filteredMetadata); this.store.dispatch(action); } }); @@ -133,7 +136,7 @@ export class SocketIOTransport extends Transport { // Keep track of connection status. this.socket.on('connect', () => { // Initial sync to get game state. - this.socket.emit('sync', this.gameID, this.playerID, this.numPlayers); + this.socket.emit('sync', this.matchID, this.playerID, this.numPlayers); this.isConnected = true; this.callback(); }); @@ -160,22 +163,22 @@ export class SocketIOTransport extends Transport { this.callback = fn; } - subscribeGameMetadata(fn: MetadataCallback) { - this.gameMetadataCallback = fn; + subscribeMatchData(fn: MetadataCallback) { + this.matchDataCallback = fn; } /** * Updates the game id. * @param {string} id - The new game id. */ - updateGameID(id: string) { - this.gameID = id; + updateMatchID(id: string) { + this.matchID = id; const action = ActionCreators.reset(null); this.store.dispatch(action); if (this.socket) { - this.socket.emit('sync', this.gameID, this.playerID, this.numPlayers); + this.socket.emit('sync', this.matchID, this.playerID, this.numPlayers); } } @@ -190,7 +193,7 @@ export class SocketIOTransport extends Transport { this.store.dispatch(action); if (this.socket) { - this.socket.emit('sync', this.gameID, this.playerID, this.numPlayers); + this.socket.emit('sync', this.matchID, this.playerID, this.numPlayers); } } } diff --git a/src/client/transport/transport.ts b/src/client/transport/transport.ts index e273654d7..642a9a48d 100644 --- a/src/client/transport/transport.ts +++ b/src/client/transport/transport.ts @@ -24,7 +24,7 @@ export interface TransportOpts { gameKey?: Game; game?: ReturnType; playerID?: PlayerID; - gameID?: string; + matchID?: string; numPlayers?: number; } @@ -32,7 +32,7 @@ export abstract class Transport { protected store: Store; protected gameName: string; protected playerID: PlayerID | null; - protected gameID: string; + protected matchID: string; protected numPlayers: number; isConnected: boolean; @@ -40,13 +40,13 @@ export abstract class Transport { store, gameName, playerID, - gameID, + matchID, numPlayers, }: TransportOpts) { this.store = store; this.gameName = gameName || 'default'; this.playerID = playerID || null; - this.gameID = gameID || 'default'; + this.matchID = matchID || 'default'; this.numPlayers = numPlayers || 2; } @@ -54,7 +54,7 @@ export abstract class Transport { abstract connect(): void; abstract disconnect(): void; abstract subscribe(fn: () => void): void; - abstract subscribeGameMetadata(fn: MetadataCallback): void; - abstract updateGameID(id: string): void; + abstract subscribeMatchData(fn: MetadataCallback): void; + abstract updateMatchID(id: string): void; abstract updatePlayerID(id: PlayerID): void; } diff --git a/src/lobby/client.test.ts b/src/lobby/client.test.ts new file mode 100644 index 000000000..1e99d0c1c --- /dev/null +++ b/src/lobby/client.test.ts @@ -0,0 +1,315 @@ +import { LobbyClient } from './client'; + +const throwsWithoutBody = (fn: (...args: any) => Promise) => async () => { + await expect(fn('tic-tac-toe')).rejects.toThrow( + `Expected body, got “undefined”.` + ); +}; + +const testStringValidation = ( + fn: (arg: any) => Promise, + label: string +) => async () => { + await expect(fn(undefined)).rejects.toThrow( + `Expected ${label} string, got "undefined".` + ); + await expect(fn(2)).rejects.toThrow(`Expected ${label} string, got "2".`); + await expect(fn('')).rejects.toThrow(`Expected ${label} string, got "".`); +}; + +const throwsWithInvalidGameName = (fn: (...args: any) => Promise) => + testStringValidation(fn, 'game name'); + +const throwsWithInvalidMatchID = (fn: (...args: any) => Promise) => + testStringValidation((matchID: string) => fn('chess', matchID), 'match ID'); + +const testBasicBody = (fn: (...args: any) => Promise) => async () => { + await expect( + fn('chess', '1', { playerID: undefined, credentials: 'pwd' }) + ).rejects.toThrow( + 'Expected body.playerID to be of type string, got “undefined”.' + ); + + await expect( + fn('chess', '2', { playerID: '0', credentials: (0 as unknown) as string }) + ).rejects.toThrow('Expected body.credentials to be of type string, got “0”.'); +}; + +describe('LobbyClient', () => { + let client = new LobbyClient(); + + beforeEach(async () => { + (global as any).fetch = jest.fn(async () => ({ + ok: true, + status: 200, + json: async () => {}, + })); + }); + + describe('construction', () => { + test('basic', async () => { + await client.listGames(); + expect(fetch).toBeCalledWith(`/games`, undefined); + }); + + test('with server address', async () => { + const client = new LobbyClient({ server: 'http://api.io' }); + await client.listGames(); + expect(fetch).toBeCalledWith(`http://api.io/games`, undefined); + }); + + test('with server address with trailing slash', async () => { + const client = new LobbyClient({ server: 'http://api.io/' }); + await client.listGames(); + expect(fetch).toBeCalledWith(`http://api.io/games`, undefined); + }); + }); + + describe('status errors', () => { + beforeEach(async () => { + (global as any).fetch = jest.fn(async () => ({ + ok: false, + status: 404, + json: async () => {}, + })); + client = new LobbyClient(); + }); + + test('404 throws an error', async () => { + await expect(client.listGames()).rejects.toThrow('HTTP status 404'); + }); + }); + + describe('listGames', () => { + test('calls `/games`', async () => { + await client.listGames(); + expect(fetch).toBeCalledWith('/games', undefined); + }); + + test('init can be customized', async () => { + await client.listGames({ headers: { Authorization: 'pwd' } }); + expect(fetch).toBeCalledWith('/games', { + headers: { Authorization: 'pwd' }, + }); + }); + }); + + describe('listMatches', () => { + test('calls `/games/:name`', async () => { + await client.listMatches('tic-tac-toe'); + expect(fetch).toBeCalledWith(`/games/tic-tac-toe`, undefined); + }); + + test('validates gameName', throwsWithInvalidGameName(client.listMatches)); + + describe('builds filter queries', () => { + test('kitchen sink', async () => { + await client.listMatches('chess', { + isGameover: false, + updatedBefore: 3000, + updatedAfter: 1000, + }); + expect(fetch).toBeCalledWith( + '/games/chess?isGameover=false&updatedBefore=3000&updatedAfter=1000', + undefined + ); + }); + + test('isGameover', async () => { + await client.listMatches('chess', { isGameover: undefined }); + expect(fetch).toBeCalledWith('/games/chess', undefined); + await client.listMatches('chess', { isGameover: false }); + expect(fetch).toBeCalledWith( + '/games/chess?isGameover=false', + undefined + ); + await client.listMatches('chess', { isGameover: true }); + expect(fetch).toBeCalledWith('/games/chess?isGameover=true', undefined); + }); + + test('updatedBefore', async () => { + const updatedBefore = 1989; + await client.listMatches('chess', { updatedBefore }); + expect(fetch).toBeCalledWith( + '/games/chess?updatedBefore=1989', + undefined + ); + }); + + test('updatedAfter', async () => { + const updatedAfter = 1970; + await client.listMatches('chess', { updatedAfter }); + expect(fetch).toBeCalledWith( + '/games/chess?updatedAfter=1970', + undefined + ); + }); + }); + }); + + describe('getMatch', () => { + test('calls `/games/:name/:id`', async () => { + await client.getMatch('tic-tac-toe', 'xyz'); + expect(fetch).toBeCalledWith(`/games/tic-tac-toe/xyz`, undefined); + }); + + test('validates gameName', throwsWithInvalidGameName(client.getMatch)); + test('validates matchID', throwsWithInvalidMatchID(client.getMatch)); + }); + + describe('createMatch', () => { + test('calls `/games/:name/create`', async () => { + await client.createMatch('tic-tac-toe', { numPlayers: 2 }); + expect(fetch).toBeCalledWith(`/games/tic-tac-toe/create`, { + method: 'post', + body: '{"numPlayers":2}', + headers: { 'Content-Type': 'application/json' }, + }); + }); + + test('validates gameName', throwsWithInvalidGameName(client.createMatch)); + + test('throws without body', throwsWithoutBody(client.createMatch)); + + test('validates body', async () => { + await expect( + client.createMatch('tic-tac-toe', { + numPlayers: ('12' as unknown) as number, + }) + ).rejects.toThrow( + 'Expected body.numPlayers to be of type number, got “12”.' + ); + }); + + test('init can be customized', async () => { + await client.createMatch( + 'chess', + { numPlayers: 2 }, + { headers: { Authorization: 'pwd' } } + ); + expect(fetch).toBeCalledWith(`/games/chess/create`, { + method: 'post', + body: '{"numPlayers":2}', + headers: { + 'Content-Type': 'application/json', + Authorization: 'pwd', + }, + }); + }); + }); + + describe('joinMatch', () => { + test('calls `/games/:name/:id/join`', async () => { + await client.joinMatch('tic-tac-toe', 'xyz', { + playerID: '0', + playerName: 'Alice', + }); + expect(fetch).toBeCalledWith(`/games/tic-tac-toe/xyz/join`, { + method: 'post', + body: '{"playerID":"0","playerName":"Alice"}', + headers: { 'Content-Type': 'application/json' }, + }); + }); + + test('validates gameName', throwsWithInvalidGameName(client.joinMatch)); + test('validates matchID', throwsWithInvalidMatchID(client.joinMatch)); + + test( + 'throws without body', + throwsWithoutBody(() => client.joinMatch('chess', 'id', undefined)) + ); + + test('validates body', async () => { + await expect( + client.joinMatch('tic-tac-toe', 'xyz', { + playerID: (0 as unknown) as string, + playerName: 'Bob', + }) + ).rejects.toThrow( + 'Expected body.playerID to be of type string, got “0”.' + ); + + await expect( + client.joinMatch('tic-tac-toe', 'xyz', { + playerID: '0', + playerName: undefined, + }) + ).rejects.toThrow( + 'Expected body.playerName to be of type string, got “undefined”.' + ); + }); + }); + + describe('leaveMatch', () => { + test('calls `/games/:name/:id/leave`', async () => { + await client.leaveMatch('tic-tac-toe', 'xyz', { + playerID: '0', + credentials: 'pwd', + }); + expect(fetch).toBeCalledWith(`/games/tic-tac-toe/xyz/leave`, { + method: 'post', + body: '{"playerID":"0","credentials":"pwd"}', + headers: { 'Content-Type': 'application/json' }, + }); + }); + + test('validates gameName', throwsWithInvalidGameName(client.leaveMatch)); + test('validates matchID', throwsWithInvalidMatchID(client.leaveMatch)); + + test( + 'throws without body', + throwsWithoutBody(() => client.leaveMatch('chess', 'id', undefined)) + ); + + test('validates body', testBasicBody(client.leaveMatch)); + }); + + describe('updatePlayer', () => { + test('calls `/games/:name/:id/update`', async () => { + await client.updatePlayer('tic-tac-toe', 'xyz', { + playerID: '0', + credentials: 'pwd', + newName: 'Al', + }); + expect(fetch).toBeCalledWith(`/games/tic-tac-toe/xyz/update`, { + method: 'post', + body: '{"playerID":"0","credentials":"pwd","newName":"Al"}', + headers: { 'Content-Type': 'application/json' }, + }); + }); + + test('validates gameName', throwsWithInvalidGameName(client.updatePlayer)); + test('validates matchID', throwsWithInvalidMatchID(client.updatePlayer)); + + test( + 'throws without body', + throwsWithoutBody(() => client.updatePlayer('chess', 'id', undefined)) + ); + + test('validates body', testBasicBody(client.updatePlayer)); + }); + + describe('playAgain', () => { + test('calls `/games/:name/:id/playAgain`', async () => { + await client.playAgain('tic-tac-toe', 'xyz', { + playerID: '0', + credentials: 'pwd', + }); + expect(fetch).toBeCalledWith(`/games/tic-tac-toe/xyz/playAgain`, { + method: 'post', + body: '{"playerID":"0","credentials":"pwd"}', + headers: { 'Content-Type': 'application/json' }, + }); + }); + + test('validates gameName', throwsWithInvalidGameName(client.playAgain)); + test('validates matchID', throwsWithInvalidMatchID(client.playAgain)); + + test( + 'throws without body', + throwsWithoutBody(() => client.playAgain('chess', 'id', undefined)) + ); + + test('validates body', testBasicBody(client.playAgain)); + }); +}); diff --git a/src/lobby/client.ts b/src/lobby/client.ts new file mode 100644 index 000000000..0a1812ea1 --- /dev/null +++ b/src/lobby/client.ts @@ -0,0 +1,321 @@ +import { LobbyAPI } from '../types'; + +const assertString = (str: unknown, label: string) => { + if (!str || typeof str !== 'string') { + throw new Error(`Expected ${label} string, got "${str}".`); + } +}; +const assertGameName = (name?: string) => assertString(name, 'game name'); +const assertMatchID = (id?: string) => assertString(id, 'match ID'); + +const validateBody = ( + body: { [key: string]: any } | undefined, + schema: { [key: string]: 'string' | 'number' | 'object' | 'boolean' } +) => { + if (!body) throw new Error(`Expected body, got “${body}”.`); + for (const key in schema) { + const type = schema[key]; + const received = body[key]; + if (typeof received !== type) { + throw new Error( + `Expected body.${key} to be of type ${type}, got “${received}”.` + ); + } + } +}; + +/** + * Create a boardgame.io Lobby API client. + * @param server The API’s base URL, e.g. `http://localhost:8000`. + */ +export class LobbyClient { + private server: string; + + constructor({ server = '' }: { server?: string } = {}) { + // strip trailing slash if passed + this.server = server.replace(/\/$/, ''); + } + + private async request(route: string, init?: RequestInit) { + const response = await fetch(this.server + route, init); + if (!response.ok) throw new Error(`HTTP status ${response.status}`); + return response.json(); + } + + private async post( + route: string, + opts: { body?: object; init?: RequestInit } + ) { + let init: RequestInit = { + method: 'post', + body: JSON.stringify(opts.body), + headers: { 'Content-Type': 'application/json' }, + }; + if (opts.init) + init = { + ...init, + ...opts.init, + headers: { ...init.headers, ...opts.init.headers }, + }; + return this.request(route, init); + } + + /** + * Get a list of the game names available on this server. + * @param init Optional RequestInit interface to override defaults. + * @return Array of game names. + * + * @example + * lobbyClient.listGames() + * .then(console.log); // => ['chess', 'tic-tac-toe'] + */ + async listGames(init?: RequestInit): Promise { + return this.request('/games', init); + } + + /** + * Get a list of the matches for a specific game type on the server. + * @param gameName The game to list for, e.g. 'tic-tac-toe'. + * @param where Options to filter matches by update time or gameover state + * @param init Optional RequestInit interface to override defaults. + * @return Array of match metadata objects. + * + * @example + * lobbyClient.listMatches('tic-tac-toe', where: { isGameover: false }) + * .then(data => console.log(data.matches)); + * // => [ + * // { + * // matchID: 'xyz', + * // gameName: 'tic-tac-toe', + * // players: [{ id: 0, name: 'Alice' }, { id: 1 }] + * // }, + * // ... + * // ] + */ + async listMatches( + gameName: string, + where?: { + /** + * If true, only games that have ended will be returned. + * If false, only games that have not yet ended will be returned. + * Leave undefined to receive both finished and unfinished games. + */ + isGameover?: boolean; + /** + * List matches last updated before a specific time. + * Value should be a timestamp in milliseconds after January 1, 1970. + */ + updatedBefore?: number; + /** + * List matches last updated after a specific time. + * Value should be a timestamp in milliseconds after January 1, 1970. + */ + updatedAfter?: number; + }, + init?: RequestInit + ): Promise { + assertGameName(gameName); + let query = ''; + if (where) { + const queries = []; + const { isGameover, updatedBefore, updatedAfter } = where; + if (isGameover !== undefined) queries.push(`isGameover=${isGameover}`); + if (updatedBefore) queries.push(`updatedBefore=${updatedBefore}`); + if (updatedAfter) queries.push(`updatedAfter=${updatedAfter}`); + if (queries.length) query = '?' + queries.join('&'); + } + return this.request(`/games/${gameName}${query}`, init); + } + + /** + * Get metadata for a specific match. + * @param gameName The match’s game type, e.g. 'tic-tac-toe'. + * @param matchID Match ID for the match to fetch. + * @param init Optional RequestInit interface to override defaults. + * @return A match metadata object. + * + * @example + * lobbyClient.getMatch('tic-tac-toe', 'xyz').then(console.log); + * // => { + * // matchID: 'xyz', + * // gameName: 'tic-tac-toe', + * // players: [{ id: 0, name: 'Alice' }, { id: 1 }] + * // } + */ + async getMatch( + gameName: string, + matchID: string, + init?: RequestInit + ): Promise { + assertGameName(gameName); + assertMatchID(matchID); + return this.request(`/games/${gameName}/${matchID}`, init); + } + + /** + * Create a new match for a specific game type. + * @param gameName The game to create a match for, e.g. 'tic-tac-toe'. + * @param body Options required to configure match creation. + * @param init Optional RequestInit interface to override defaults. + * @return An object containing the created `matchID`. + * + * @example + * lobbyClient.createMatch('tic-tac-toe', { numPlayers: 2 }) + * .then(console.log); + * // => { matchID: 'xyz' } + */ + async createMatch( + gameName: string, + body: { + numPlayers: number; + setupData?: any; + unlisted?: boolean; + [key: string]: any; + }, + init?: RequestInit + ): Promise { + assertGameName(gameName); + validateBody(body, { numPlayers: 'number' }); + return this.post(`/games/${gameName}/create`, { body, init }); + } + + /** + * Join a match using its matchID. + * @param gameName The match’s game type, e.g. 'tic-tac-toe'. + * @param matchID Match ID for the match to join. + * @param body Options required to join match. + * @param init Optional RequestInit interface to override defaults. + * @return Object containing `playerCredentials` for the player who joined. + * + * @example + * lobbyClient.joinMatch('tic-tac-toe', 'xyz', { + * playerID: '1', + * playerName: 'Bob', + * }).then(console.log); + * // => { playerCredentials: 'random-string' } + */ + async joinMatch( + gameName: string, + matchID: string, + body: { + playerID: string; + playerName: string; + data?: any; + [key: string]: any; + }, + init?: RequestInit + ): Promise { + assertGameName(gameName); + assertMatchID(matchID); + validateBody(body, { playerID: 'string', playerName: 'string' }); + return this.post(`/games/${gameName}/${matchID}/join`, { body, init }); + } + + /** + * Leave a previously joined match. + * @param gameName The match’s game type, e.g. 'tic-tac-toe'. + * @param matchID Match ID for the match to leave. + * @param body Options required to leave match. + * @param init Optional RequestInit interface to override defaults. + * @return Promise resolves if successful. + * + * @example + * lobbyClient.leaveMatch('tic-tac-toe', 'xyz', { + * playerID: '1', + * credentials: 'credentials-returned-when-joining', + * }) + * .then(() => console.log('Left match.')) + * .catch(error => console.error('Error leaving match', error)); + */ + async leaveMatch( + gameName: string, + matchID: string, + body: { + playerID: string; + credentials: string; + [key: string]: any; + }, + init?: RequestInit + ): Promise { + assertGameName(gameName); + assertMatchID(matchID); + validateBody(body, { playerID: 'string', credentials: 'string' }); + await this.post(`/games/${gameName}/${matchID}/leave`, { body, init }); + } + + /** + * Update a player’s name or custom metadata. + * @param gameName The match’s game type, e.g. 'tic-tac-toe'. + * @param matchID Match ID for the match to update. + * @param body Options required to update player. + * @param init Optional RequestInit interface to override defaults. + * @return Promise resolves if successful. + * + * @example + * lobbyClient.updatePlayer('tic-tac-toe', 'xyz', { + * playerID: '0', + * credentials: 'credentials-returned-when-joining', + * newName: 'Al', + * }) + * .then(() => console.log('Updated player data.')) + * .catch(error => console.error('Error updating data', error)); + */ + async updatePlayer( + gameName: string, + matchID: string, + body: { + playerID: string; + credentials: string; + newName?: string; + data?: any; + [key: string]: any; + }, + init?: RequestInit + ): Promise { + assertGameName(gameName); + assertMatchID(matchID); + validateBody(body, { playerID: 'string', credentials: 'string' }); + await this.post(`/games/${gameName}/${matchID}/update`, { body, init }); + } + + /** + * Create a new match based on the configuration of the current match. + * @param gameName The match’s game type, e.g. 'tic-tac-toe'. + * @param matchID Match ID for the match to play again. + * @param body Options required to configure match. + * @param init Optional RequestInit interface to override defaults. + * @return Object containing `nextMatchID`. + * + * @example + * lobbyClient.playAgain('tic-tac-toe', 'xyz', { + * playerID: '0', + * credentials: 'credentials-returned-when-joining', + * }) + * .then(({ nextMatchID }) => { + * return lobbyClient.joinMatch('tic-tac-toe', nextMatchID, { + * playerID: '0', + * playerName: 'Al', + * }) + * }) + * .then({ playerCredentials } => { + * console.log(playerCredentials); + * }) + * .catch(console.error); + */ + async playAgain( + gameName: string, + matchID: string, + body: { + playerID: string; + credentials: string; + unlisted?: boolean; + [key: string]: any; + }, + init?: RequestInit + ): Promise { + assertGameName(gameName); + assertMatchID(matchID); + validateBody(body, { playerID: 'string', credentials: 'string' }); + return this.post(`/games/${gameName}/${matchID}/playAgain`, { body, init }); + } +} diff --git a/src/lobby/connection.js b/src/lobby/connection.js deleted file mode 100644 index febce4210..000000000 --- a/src/lobby/connection.js +++ /dev/null @@ -1,172 +0,0 @@ -/* - * Copyright 2018 The boardgame.io Authors - * - * Use of this source code is governed by a MIT-style - * license that can be found in the LICENSE file or at - * https://opensource.org/licenses/MIT. - */ - -class _LobbyConnectionImpl { - constructor({ server, gameComponents, playerName, playerCredentials }) { - this.gameComponents = gameComponents; - this.playerName = playerName || 'Visitor'; - this.playerCredentials = playerCredentials; - this.server = server; - this.rooms = []; - } - - _baseUrl() { - return `${this.server || ''}/games`; - } - - async refresh() { - try { - this.rooms.length = 0; - const resp = await fetch(this._baseUrl()); - if (resp.status !== 200) { - throw new Error('HTTP status ' + resp.status); - } - const json = await resp.json(); - for (let gameName of json) { - if (!this._getGameComponents(gameName)) continue; - const gameResp = await fetch(this._baseUrl() + '/' + gameName); - const gameJson = await gameResp.json(); - for (let inst of gameJson.rooms) { - inst.gameName = gameName; - } - this.rooms = this.rooms.concat(gameJson.rooms); - } - } catch (error) { - throw new Error('failed to retrieve list of games (' + error + ')'); - } - } - - _getGameInstance(gameID) { - for (let inst of this.rooms) { - if (inst['gameID'] === gameID) return inst; - } - } - - _getGameComponents(gameName) { - for (let comp of this.gameComponents) { - if (comp.game.name === gameName) return comp; - } - } - - _findPlayer(playerName) { - for (let inst of this.rooms) { - if (inst.players.some(player => player.name === playerName)) return inst; - } - } - - async join(gameName, gameID, playerID) { - try { - let inst = this._findPlayer(this.playerName); - if (inst) { - throw new Error('player has already joined ' + inst.gameID); - } - inst = this._getGameInstance(gameID); - if (!inst) { - throw new Error('game instance ' + gameID + ' not found'); - } - const resp = await fetch( - this._baseUrl() + '/' + gameName + '/' + gameID + '/join', - { - method: 'POST', - body: JSON.stringify({ - playerID: playerID, - playerName: this.playerName, - }), - headers: { 'Content-Type': 'application/json' }, - } - ); - if (resp.status !== 200) throw new Error('HTTP status ' + resp.status); - const json = await resp.json(); - inst.players[Number.parseInt(playerID)].name = this.playerName; - this.playerCredentials = json.playerCredentials; - } catch (error) { - throw new Error('failed to join room ' + gameID + ' (' + error + ')'); - } - } - - async leave(gameName, gameID) { - try { - let inst = this._getGameInstance(gameID); - if (!inst) throw new Error('game instance not found'); - for (let player of inst.players) { - if (player.name === this.playerName) { - const resp = await fetch( - this._baseUrl() + '/' + gameName + '/' + gameID + '/leave', - { - method: 'POST', - body: JSON.stringify({ - playerID: player.id, - credentials: this.playerCredentials, - }), - headers: { 'Content-Type': 'application/json' }, - } - ); - if (resp.status !== 200) { - throw new Error('HTTP status ' + resp.status); - } - delete player.name; - delete this.playerCredentials; - return; - } - } - throw new Error('player not found in room'); - } catch (error) { - throw new Error('failed to leave room ' + gameID + ' (' + error + ')'); - } - } - - async disconnect() { - let inst = this._findPlayer(this.playerName); - if (inst) { - await this.leave(inst.gameName, inst.gameID); - } - this.rooms = []; - this.playerName = 'Visitor'; - } - - async create(gameName, numPlayers) { - try { - const comp = this._getGameComponents(gameName); - if (!comp) throw new Error('game not found'); - if ( - numPlayers < comp.game.minPlayers || - numPlayers > comp.game.maxPlayers - ) - throw new Error('invalid number of players ' + numPlayers); - const resp = await fetch(this._baseUrl() + '/' + gameName + '/create', { - method: 'POST', - body: JSON.stringify({ - numPlayers: numPlayers, - }), - headers: { 'Content-Type': 'application/json' }, - }); - if (resp.status !== 200) throw new Error('HTTP status ' + resp.status); - } catch (error) { - throw new Error( - 'failed to create room for ' + gameName + ' (' + error + ')' - ); - } - } -} - -/** - * LobbyConnection - * - * Lobby model. - * - * @param {string} server - ':' of the server. - * @param {Array} gameComponents - A map of Board and Game objects for the supported games. - * @param {string} playerName - The name of the player. - * @param {string} playerCredentials - The credentials currently used by the player, if any. - * - * Returns: - * A JS object that synchronizes the list of running game instances with the server and provides an API to create/join/start instances. - */ -export function LobbyConnection(opts) { - return new _LobbyConnectionImpl(opts); -} diff --git a/src/lobby/connection.test.js b/src/lobby/connection.test.ts similarity index 62% rename from src/lobby/connection.test.js rename to src/lobby/connection.test.ts index 649c19057..e4ca21fb5 100644 --- a/src/lobby/connection.test.js +++ b/src/lobby/connection.test.ts @@ -6,49 +6,60 @@ * https://opensource.org/licenses/MIT. */ -import { LobbyConnection } from './connection.js'; +import { LobbyConnection } from './connection'; +import { LobbyAPI } from '../types'; describe('lobby', () => { - let lobby; - let room1, room2; + let lobby: ReturnType; + let match1: LobbyAPI.Match, match2: LobbyAPI.Match; let jsonResult = []; let nextStatus = 200; beforeEach(async () => { - room1 = { gameID: 'gameID_1', players: [{ id: '0' }] }; - room2 = { gameID: 'gameID_2', players: [{ id: '1' }] }; + match1 = { + gameName: 'game1', + matchID: 'matchID_1', + players: [{ id: 0 }], + createdAt: 1, + updatedAt: 4, + }; + match2 = { + gameName: 'game2', + matchID: 'matchID_2', + players: [{ id: 1 }], + createdAt: 2, + updatedAt: 3, + }; // result of connection requests jsonResult = [ () => ['game1', 'game2'], () => { - return { rooms: [room1] }; + return { matches: [match1] }; }, () => { - return { rooms: [room2] }; + return { matches: [match2] }; }, ]; let nextResult = jsonResult.shift.bind(jsonResult); nextStatus = 200; - global.fetch = jest.fn().mockImplementation(() => - Promise.resolve({ - ok: true, - status: nextStatus, - json: nextResult(), - }) - ); + (global as any).fetch = jest.fn(async () => ({ + ok: true, + status: nextStatus, + json: nextResult(), + })); }); describe('handling all games', () => { beforeEach(async () => { - lobby = new LobbyConnection({ + lobby = LobbyConnection({ server: 'localhost', gameComponents: [ { - board: 'Board1', + board: () => null, game: { name: 'game1', minPlayers: 2, maxPlayers: 4 }, }, { - board: 'Board2', + board: () => null, game: { name: 'game2' }, }, ], @@ -57,10 +68,10 @@ describe('lobby', () => { await lobby.refresh(); }); - describe('get list of rooms', () => { + describe('get list of matches', () => { test('when the server requests succeed', async () => { expect(fetch).toHaveBeenCalledTimes(3); - expect(lobby.rooms).toEqual([room1, room2]); + expect(lobby.matches).toEqual([match1, match2]); }); test('when the server request fails', async () => { nextStatus = 404; @@ -69,38 +80,38 @@ describe('lobby', () => { } catch (error) { expect(error).toBeInstanceOf(Error); } - expect(lobby.rooms).toEqual([]); + expect(lobby.matches).toEqual([]); }); }); - describe('join a room', () => { + describe('join a match', () => { beforeEach(async () => { // result of request 'join' jsonResult.push(() => { return { playerCredentials: 'SECRET' }; }); }); - test('when the room exists', async () => { - await lobby.join('game1', 'gameID_1', '0'); + test('when the match exists', async () => { + await lobby.join('game1', 'matchID_1', '0'); expect(fetch).toHaveBeenCalledTimes(4); - expect(lobby.rooms[0].players[0]).toEqual({ - id: '0', + expect(lobby.matches[0].players[0]).toEqual({ + id: 0, name: 'Bob', }); expect(lobby.playerCredentials).toEqual('SECRET'); }); - test('when the room does not exist', async () => { + test('when the match does not exist', async () => { try { - await lobby.join('game1', 'gameID_3', '0'); + await lobby.join('game1', 'matchID_3', '0'); } catch (error) { expect(error).toBeInstanceOf(Error); } - expect(lobby.rooms).toEqual([room1, room2]); + expect(lobby.matches).toEqual([match1, match2]); }); test('when the seat is not available', async () => { - room1.players[0].name = 'Bob'; + match1.players[0].name = 'Bob'; try { - await lobby.join('game1', 'gameID_3', '0'); + await lobby.join('game1', 'matchID_3', '0'); } catch (error) { expect(error).toBeInstanceOf(Error); } @@ -108,52 +119,52 @@ describe('lobby', () => { test('when the server request fails', async () => { nextStatus = 404; try { - await lobby.join('game1', 'gameID_1', '0'); + await lobby.join('game1', 'matchID_1', '0'); } catch (error) { expect(error).toBeInstanceOf(Error); } }); - test('when the player has already joined another game', async () => { - room2.players[0].name = 'Bob'; + test('when the player has already joined another match', async () => { + match2.players[0].name = 'Bob'; try { - await lobby.join('game1', 'gameID_1', '0'); + await lobby.join('game1', 'matchID_1', '0'); } catch (error) { expect(error).toBeInstanceOf(Error); } }); }); - describe('leave a room', () => { + describe('leave a match', () => { beforeEach(async () => { // result of request 'join' jsonResult.push(() => { return { playerCredentials: 'SECRET' }; }); - await lobby.join('game1', 'gameID_1', '0'); + await lobby.join('game1', 'matchID_1', '0'); // result of request 'leave' jsonResult.push(() => { return {}; }); }); - test('when the room exists', async () => { - await lobby.leave('game1', 'gameID_1'); + test('when the match exists', async () => { + await lobby.leave('game1', 'matchID_1'); expect(fetch).toHaveBeenCalledTimes(5); - expect(lobby.rooms).toEqual([room1, room2]); + expect(lobby.matches).toEqual([match1, match2]); }); - test('when the room does not exist', async () => { + test('when the match does not exist', async () => { try { - await lobby.leave('game1', 'gameID_3'); + await lobby.leave('game1', 'matchID_3'); } catch (error) { expect(error).toBeInstanceOf(Error); } expect(fetch).toHaveBeenCalledTimes(4); - expect(lobby.rooms).toEqual([room1, room2]); + expect(lobby.matches).toEqual([match1, match2]); }); - test('when the player is not in the room', async () => { - await lobby.leave('game1', 'gameID_1'); + test('when the player is not in the match', async () => { + await lobby.leave('game1', 'matchID_1'); expect(fetch).toHaveBeenCalledTimes(5); try { - await lobby.leave('game1', 'gameID_1'); + await lobby.leave('game1', 'matchID_1'); } catch (error) { expect(error).toBeInstanceOf(Error); } @@ -161,7 +172,7 @@ describe('lobby', () => { test('when the server request fails', async () => { nextStatus = 404; try { - await lobby.leave('game1', 'gameID_1'); + await lobby.leave('game1', 'matchID_1'); } catch (error) { expect(error).toBeInstanceOf(Error); } @@ -172,25 +183,26 @@ describe('lobby', () => { beforeEach(async () => {}); test('when the player leaves the lobby', async () => { await lobby.disconnect(); - expect(lobby.rooms).toEqual([]); + expect(lobby.matches).toEqual([]); }); - test('when the player had joined a room', async () => { + test('when the player had joined a match', async () => { // result of request 'join' jsonResult.push(() => { return { playerCredentials: 'SECRET' }; }); - await lobby.join('game1', 'gameID_1', '0'); + await lobby.join('game1', 'matchID_1', '0'); // result of request 'leave' jsonResult.push(() => { return {}; }); await lobby.disconnect(); - expect(lobby.rooms).toEqual([]); + expect(lobby.matches).toEqual([]); }); }); - describe('create a room', () => { + describe('create a match', () => { test('when the server request succeeds', async () => { + jsonResult.push(() => ({ matchID: 'abc' })); await lobby.create('game1', 2); expect(fetch).toHaveBeenCalledTimes(4); }); @@ -202,6 +214,7 @@ describe('lobby', () => { } }); test('when the number of players has no boundaries', async () => { + jsonResult.push(() => ({ matchID: 'def' })); await lobby.create('game2', 1); }); test('when the game is unknown', async () => { @@ -224,15 +237,15 @@ describe('lobby', () => { describe('handling some games', () => { beforeEach(async () => { - lobby = new LobbyConnection({ + lobby = LobbyConnection({ server: 'localhost', - gameComponents: [{ board: 'Board1', game: { name: 'game1' } }], + gameComponents: [{ board: () => null, game: { name: 'game1' } }], }); await lobby.refresh(); }); - test('get list of rooms for supported games', async () => { + test('get list of matches for supported games', async () => { expect(fetch).toHaveBeenCalledTimes(2); - expect(lobby.rooms).toEqual([room1]); + expect(lobby.matches).toEqual([match1]); }); }); }); diff --git a/src/lobby/connection.ts b/src/lobby/connection.ts new file mode 100644 index 000000000..2c405e57b --- /dev/null +++ b/src/lobby/connection.ts @@ -0,0 +1,161 @@ +/* + * Copyright 2018 The boardgame.io Authors + * + * Use of this source code is governed by a MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ + +import React from 'react'; +import { LobbyClient } from './client'; +import { Game, LobbyAPI } from '../types'; + +interface GameComponent { + game: Game; + board: React.ComponentType; +} + +interface LobbyConnectionOpts { + server: string; + playerName?: string; + playerCredentials?: string; + gameComponents: GameComponent[]; +} + +class _LobbyConnectionImpl { + client: LobbyClient; + gameComponents: GameComponent[]; + playerName: string; + playerCredentials?: string; + matches: LobbyAPI.MatchList['matches']; + + constructor({ + server, + gameComponents, + playerName, + playerCredentials, + }: LobbyConnectionOpts) { + this.client = new LobbyClient({ server }); + this.gameComponents = gameComponents; + this.playerName = playerName || 'Visitor'; + this.playerCredentials = playerCredentials; + this.matches = []; + } + + async refresh() { + try { + this.matches = []; + const games = await this.client.listGames(); + for (const game of games) { + if (!this._getGameComponents(game)) continue; + const { matches } = await this.client.listMatches(game); + this.matches = this.matches.concat(matches); + } + } catch (error) { + throw new Error('failed to retrieve list of matches (' + error + ')'); + } + } + + _getMatchInstance(matchID: string) { + for (let inst of this.matches) { + if (inst['matchID'] === matchID) return inst; + } + } + + _getGameComponents(gameName: string) { + for (let comp of this.gameComponents) { + if (comp.game.name === gameName) return comp; + } + } + + _findPlayer(playerName: string) { + for (let inst of this.matches) { + if (inst.players.some(player => player.name === playerName)) return inst; + } + } + + async join(gameName: string, matchID: string, playerID: string) { + try { + let inst = this._findPlayer(this.playerName); + if (inst) { + throw new Error('player has already joined ' + inst.matchID); + } + inst = this._getMatchInstance(matchID); + if (!inst) { + throw new Error('game instance ' + matchID + ' not found'); + } + const json = await this.client.joinMatch(gameName, matchID, { + playerID, + playerName: this.playerName, + }); + inst.players[Number.parseInt(playerID)].name = this.playerName; + this.playerCredentials = json.playerCredentials; + } catch (error) { + throw new Error('failed to join match ' + matchID + ' (' + error + ')'); + } + } + + async leave(gameName: string, matchID: string) { + try { + let inst = this._getMatchInstance(matchID); + if (!inst) throw new Error('match instance not found'); + for (const player of inst.players) { + if (player.name === this.playerName) { + await this.client.leaveMatch(gameName, matchID, { + playerID: player.id.toString(), + credentials: this.playerCredentials, + }); + delete player.name; + delete this.playerCredentials; + return; + } + } + throw new Error('player not found in match'); + } catch (error) { + throw new Error('failed to leave match ' + matchID + ' (' + error + ')'); + } + } + + async disconnect() { + let inst = this._findPlayer(this.playerName); + if (inst) { + await this.leave(inst.gameName, inst.matchID); + } + this.matches = []; + this.playerName = 'Visitor'; + } + + async create(gameName: string, numPlayers: number) { + try { + const comp = this._getGameComponents(gameName); + if (!comp) throw new Error('game not found'); + if ( + numPlayers < comp.game.minPlayers || + numPlayers > comp.game.maxPlayers + ) + throw new Error('invalid number of players ' + numPlayers); + await this.client.createMatch(gameName, { numPlayers }); + } catch (error) { + throw new Error( + 'failed to create match for ' + gameName + ' (' + error + ')' + ); + } + } +} + +/** + * LobbyConnection + * + * Lobby model. + * + * @param {string} server - ':' of the server. + * @param {Array} gameComponents - A map of Board and Game objects for the supported games. + * @param {string} playerName - The name of the player. + * @param {string} playerCredentials - The credentials currently used by the player, if any. + * + * Returns: + * A JS object that synchronizes the list of running game instances with the server and provides an API to create/join/start instances. + */ +export function LobbyConnection(opts: LobbyConnectionOpts) { + return new _LobbyConnectionImpl(opts); +} diff --git a/src/lobby/create-room-form.js b/src/lobby/create-match-form.js similarity index 84% rename from src/lobby/create-room-form.js rename to src/lobby/create-match-form.js index b8dbaade2..f8e72a185 100644 --- a/src/lobby/create-room-form.js +++ b/src/lobby/create-match-form.js @@ -9,10 +9,10 @@ import React from 'react'; import PropTypes from 'prop-types'; -class LobbyCreateRoomForm extends React.Component { +class LobbyCreateMatchForm extends React.Component { static propTypes = { games: PropTypes.array.isRequired, - createGame: PropTypes.func.isRequired, + createMatch: PropTypes.func.isRequired, }; state = { @@ -24,14 +24,14 @@ class LobbyCreateRoomForm extends React.Component { super(props); /* fix min and max number of players */ for (let game of props.games) { - let game_details = game.game; - if (!game_details.minPlayers) { - game_details.minPlayers = 1; + let matchDetails = game.game; + if (!matchDetails.minPlayers) { + matchDetails.minPlayers = 1; } - if (!game_details.maxPlayers) { - game_details.maxPlayers = 4; + if (!matchDetails.maxPlayers) { + matchDetails.maxPlayers = 4; } - console.assert(game_details.maxPlayers >= game_details.minPlayers); + console.assert(matchDetails.maxPlayers >= matchDetails.minPlayers); } this.state = { selectedGame: 0, @@ -99,11 +99,11 @@ class LobbyCreateRoomForm extends React.Component { }; onClickCreate = () => { - this.props.createGame( + this.props.createMatch( this.props.games[this.state.selectedGame].game.name, this.state.numPlayers ); }; } -export default LobbyCreateRoomForm; +export default LobbyCreateMatchForm; diff --git a/src/lobby/room-instance.js b/src/lobby/match-instance.js similarity index 68% rename from src/lobby/room-instance.js rename to src/lobby/match-instance.js index 2dc4a6380..bec612bc8 100644 --- a/src/lobby/room-instance.js +++ b/src/lobby/match-instance.js @@ -9,11 +9,11 @@ import React from 'react'; import PropTypes from 'prop-types'; -class LobbyRoomInstance extends React.Component { +class LobbyMatchInstance extends React.Component { static propTypes = { - room: PropTypes.shape({ + match: PropTypes.shape({ gameName: PropTypes.string.isRequired, - gameID: PropTypes.string.isRequired, + matchID: PropTypes.string.isRequired, players: PropTypes.array.isRequired, }), playerName: PropTypes.string.isRequired, @@ -28,9 +28,9 @@ class LobbyRoomInstance extends React.Component { _createButtonJoin = (inst, seatId) => ( @@ -48,10 +48,10 @@ class LobbyRoomInstance extends React.Component { _createButtonPlay = (inst, seatId) => ( +
+
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; } }