Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/documentation/api/Lobby.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,12 @@ const { matchID } = await lobbyClient.createMatch('tic-tac-toe', {

Allows a player to join a particular match instance `id` of a game named `name`.

The first player to join becomes the match’s `creator`, the only player allowed
to start it. Once every seat is taken the match’s `status` becomes `running` on
its own. Freeing a seat again — see [leaving a lobby slot](#leaving-a-lobby-slot)
— puts it back to `open`, and hands `creator` to a player who is still seated if
the creator was the one who left.

Accepts three JSON body parameters:

- `playerName` (required): the display name of the player joining the match.
Expand All @@ -169,6 +175,37 @@ const { playerCredentials } = await lobbyClient.joinMatch(
);
```

### Starting a match

#### POST `/games/{name}/{id}/start`

Settles the seats of match `id` so play can begin, moving its `status` from
`open` to `running`.

A match with a fixed number of seats does this by itself the moment the last
seat is taken, and never needs this endpoint. It is for matches that can begin
before every seat is filled, where only the player who created the match — the
first one to sit down, reported as `creator` — decides when that is.

Accepts two JSON body parameters, both required:

- `playerID`: the ID of the player starting the match, which must be the match’s
`creator`.

- `credentials`: that player’s authentication token.

Responds `403` if the player is not the creator or the credentials do not match,
and `409` if the match is already running.

#### Using a LobbyClient instance

```js
await lobbyClient.startMatch('tic-tac-toe', 'matchID', {
playerID: '0',
credentials: 'playerCredentials',
});
```

### Updating a player’s metadata

#### POST `/games/{name}/{id}/update`
Expand Down
12 changes: 9 additions & 3 deletions docs/documentation/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,15 @@ players: {
The initial values of these states are determined by the `setup` function in its options object, which creates the state for a particular `playerID`.

The record associated with the current player can be accessed
via `ctx.player.get()`. If this is a 2 player game,
then the opponent's record is available using `ctx.player.opponent.get()`. These fields can be modified using their corresponding
`set()` versions.
via `ctx.player.get()`. If the game declares itself to be for exactly two
players — `minPlayers: 2` and `maxPlayers: 2` — then the opponent's record is
available using `ctx.player.opponent.get()`. These fields can be modified using
their corresponding `set()` versions.

?> `opponent` follows what the game declares rather than how many players are in
the match, because the declaration holds for the life of the match and the live
count does not. A two-player game that declares neither bound does not get
`opponent`.

```js
ctx.player.get() // Get the current player's record.
Expand Down
24 changes: 24 additions & 0 deletions src/lobby/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,30 @@ describe('LobbyClient', () => {
test('validates body', testBasicBody(client.leaveMatch));
});

describe('startMatch', () => {
test('calls `/games/:name/:id/start`', async () => {
await client.startMatch('tic-tac-toe', 'xyz', {
playerID: '0',
credentials: 'pwd',
});
expect(fetch).toHaveBeenCalledWith(`/games/tic-tac-toe/xyz/start`, {
method: 'post',
body: '{"playerID":"0","credentials":"pwd"}',
headers: { 'Content-Type': 'application/json' },
});
});

test('validates gameName', throwsWithInvalidGameName(client.startMatch));
test('validates matchID', throwsWithInvalidMatchID(client.startMatch));

test(
'throws without body',
throwsWithoutBody(() => client.startMatch('chess', 'id', undefined)),
);

test('validates body', testBasicBody(client.startMatch));
});

describe('leaveSlot', () => {
test('calls `/games/:name/:id/leaveSlot`', async () => {
await client.leaveSlot('tic-tac-toe', 'xyz', {
Expand Down
36 changes: 36 additions & 0 deletions src/lobby/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,42 @@ export class LobbyClient {
await this.post(`/games/${gameName}/${matchID}/leave`, { body, init });
}

/**
* Start a match, settling its seats so play can begin.
*
* A match with a fixed number of seats starts itself once the last one is
* taken. This is for matches that can begin before every seat is filled,
* where only the player who created the match decides when that is.
* @param gameName The match’s game type, e.g. 'tic-tac-toe'.
* @param matchID Match ID for the match to start.
* @param body Options required to start the match.
* @param init Optional RequestInit interface to override defaults.
* @return Promise resolves if successful.
*
* @example
* lobbyClient.startMatch('tic-tac-toe', 'xyz', {
* playerID: '0',
* credentials: 'credentials-returned-when-joining',
* })
* .then(() => console.log('Match started.'))
* .catch(error => console.error('Error starting match', error));
*/
async startMatch(
gameName: string,
matchID: string,
body: {
playerID: string;
credentials: string;
[key: string]: any;
},
init?: RequestInit,
): Promise<void> {
assertGameName(gameName);
assertMatchID(matchID);
validateBody(body, { playerID: 'string', credentials: 'string' });
await this.post(`/games/${gameName}/${matchID}/start`, { body, init });
}

/**
* Leave a previously joined lobby slot.
* @param gameName The match’s game type, e.g. 'tic-tac-toe'.
Expand Down
2 changes: 2 additions & 0 deletions src/lobby/connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ describe('lobby', () => {
match1 = {
gameName: 'game1',
matchID: 'matchID_1',
status: 'open',
players: [{ id: 0 }],
createdAt: 1,
updatedAt: 4,
};
match2 = {
gameName: 'game2',
matchID: 'matchID_2',
status: 'open',
players: [{ id: 1 }],
createdAt: 2,
updatedAt: 3,
Expand Down
23 changes: 10 additions & 13 deletions src/lobby/match-instance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type Match = {
gameName: string;
matchID: string;
players: LobbyAPI.Match['players'];
status: LobbyAPI.Match['status'];
};

type MatchInstanceProps = {
Expand Down Expand Up @@ -88,16 +89,11 @@ class LobbyMatchInstance extends React.Component<MatchInstanceProps> {
(player) => player.name === this.props.playerName,
);
const freeSeat = inst.players.find((player) => !player.name);
if (playerSeat && freeSeat) {
// already seated: waiting for match to start
return this._createButtonLeave(inst);
}
if (freeSeat) {
// at least 1 seat is available
return this._createButtonJoin(inst, freeSeat.id);
}
// match is full
// Already seated: wait while the match is open, play once it is running.
if (playerSeat) {
if (inst.status === 'open') {
return this._createButtonLeave(inst);
}
return (
<div>
{[
Expand All @@ -107,16 +103,17 @@ class LobbyMatchInstance extends React.Component<MatchInstanceProps> {
</div>
);
}
// at least 1 seat is available
if (freeSeat) {
return this._createButtonJoin(inst, freeSeat.id);
}
// allow spectating
return this._createButtonSpectate(inst);
};

render() {
const match = this.props.match;
let status = 'OPEN';
if (!match.players.some((player) => !player.name)) {
status = 'RUNNING';
}
const status = match.status === 'running' ? 'RUNNING' : 'OPEN';
return (
<tr key={'line-' + match.matchID}>
<td key={'cell-name-' + match.matchID}>{match.gameName}</td>
Expand Down
10 changes: 10 additions & 0 deletions src/lobby/react.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ describe('lobby', () => {
'1': { id: 1 },
},
gameName: 'GameName1',
status: 'open' as const,
},
];
act(() => {
Expand Down Expand Up @@ -395,6 +396,7 @@ describe('lobby', () => {
matchID: 'matchID1',
players: { '0': { id: 0 } },
gameName: 'GameName1',
status: 'open' as const,
},
];
forceLobbyUpdate();
Expand Down Expand Up @@ -459,11 +461,13 @@ describe('lobby', () => {
matchID: 'matchID1',
players: { '0': { id: 0 } },
gameName: 'GameName1',
status: 'open' as const,
},
{
matchID: 'matchID2',
players: { '0': { id: 0, name: 'Bob' } },
gameName: 'GameName1',
status: 'running' as const,
},
];
forceLobbyUpdate();
Expand Down Expand Up @@ -521,6 +525,7 @@ describe('lobby', () => {
'1': { id: 1 },
},
gameName: 'GameName1',
status: 'open' as const,
},
];
forceLobbyUpdate();
Expand Down Expand Up @@ -560,21 +565,25 @@ describe('lobby', () => {
'1': { id: 1, name: 'Charly', credentials: 'SECRET2' },
},
gameName: 'GameName1',
status: 'running' as const,
},
{
matchID: 'matchID2',
players: { '0': { id: 0, name: 'Alice' } },
gameName: 'GameName2',
status: 'running' as const,
},
{
matchID: 'matchID3',
players: { '0': { id: 0, name: 'Bob' } },
gameName: 'GameName3',
status: 'running' as const,
},
{
matchID: 'matchID4',
players: { '0': { id: 0, name: 'Zoe' } },
gameName: 'GameNameUnknown',
status: 'running' as const,
},
];
forceLobbyUpdate();
Expand Down Expand Up @@ -643,6 +652,7 @@ describe('lobby', () => {
'1': { id: 1, name: 'Charly', credentials: 'SECRET2' },
},
gameName: 'GameName1',
status: 'running' as const,
},
];
forceLobbyUpdate();
Expand Down
4 changes: 2 additions & 2 deletions src/lobby/react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -312,11 +312,11 @@ class Lobby extends React.Component<LobbyProps, LobbyState> {
playerName: string,
) => {
return matches.map((match) => {
const { matchID, gameName, players } = match;
const { matchID, gameName, players, status } = match;
return (
<LobbyMatchInstance
key={'instance-' + matchID}
match={{ matchID, gameName, players: Object.values(players) }}
match={{ matchID, gameName, status, players: Object.values(players) }}
playerName={playerName}
onClickJoin={this._joinMatch}
onClickLeave={this._leaveMatch}
Expand Down
5 changes: 5 additions & 0 deletions src/plugins/plugin-player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ describe('2 player game', () => {

beforeAll(() => {
const game: Game<any, { player: PlayerAPI }> = {
// `player.opponent` follows what the game declares, so a game that uses
// it has to say it is for exactly two players.
minPlayers: 2,
maxPlayers: 2,

moves: {
A: ({ player }) => {
player.set({ field: 'A1' });
Expand Down
6 changes: 4 additions & 2 deletions src/plugins/plugin-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const PlayerPlugin = <PlayerState extends any = any>({
return { players: api.state };
},

api: ({ ctx, data }): PlayerAPI => {
api: ({ ctx, game, data }): PlayerAPI => {
const state = data.players;

const get = () => {
Expand All @@ -67,7 +67,9 @@ const PlayerPlugin = <PlayerState extends any = any>({

const result: PlayerAPI = { state, get, set };

if (ctx.numPlayers === 2) {
// What the game declared holds for the life of the match; the number of
// players in it right now does not.
if (game.minPlayers === 2 && game.maxPlayers === 2) {
const other = ctx.currentPlayer === '0' ? '1' : '0';
const get = () => {
return data.players[other];
Expand Down
Loading