@magicworks/client-party-game 0.1.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -0
- package/dist/client.d.ts +210 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +659 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol.d.ts +407 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +232 -0
- package/dist/protocol.js.map +1 -0
- package/dist/socket.d.ts +30 -0
- package/dist/socket.d.ts.map +1 -0
- package/dist/socket.js +3 -0
- package/dist/socket.js.map +1 -0
- package/package.json +26 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PartyGameClient = void 0;
|
|
4
|
+
const protocol_1 = require("./protocol");
|
|
5
|
+
const defaultAckTimeoutMs = 8000;
|
|
6
|
+
const defaultClockSyncIntervalMs = 60 * 1000;
|
|
7
|
+
const clockSyncSamples = 5;
|
|
8
|
+
/** Servers that can be on their way out at once, during a deploy. */
|
|
9
|
+
const maxDrainingRetries = 3;
|
|
10
|
+
/** Hops a room can have made between servers since we last knew its code. */
|
|
11
|
+
const maxRedirects = 3;
|
|
12
|
+
/** What this client can do beyond the first protocol: see Feature. */
|
|
13
|
+
const features = [protocol_1.Feature.Move];
|
|
14
|
+
const processHostId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
15
|
+
let rememberedCode = null;
|
|
16
|
+
const moduleRoomMemory = {
|
|
17
|
+
get: () => rememberedCode,
|
|
18
|
+
set: code => {
|
|
19
|
+
rememberedCode = code;
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
function withTimeout(promise, ms, what) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const timer = setTimeout(() => reject(new Error(`party server did not answer ${what} in ${ms}ms`)), ms);
|
|
25
|
+
promise.then(value => {
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
resolve(value);
|
|
28
|
+
}, error => {
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
reject(error);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function routesAlike(a, b) {
|
|
35
|
+
return (a ? (0, protocol_1.codePrefixOf)(a) : null) === (b ? (0, protocol_1.codePrefixOf)(b) : null);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Host side of the party games protocol. The TV owns the video and therefore
|
|
39
|
+
* owns round timing: it tells the server when a round starts and when it ends,
|
|
40
|
+
* and the server answers with who scored what.
|
|
41
|
+
*
|
|
42
|
+
* One instance is meant to be shared by the lobby screen and the game itself,
|
|
43
|
+
* so navigating from the lobby into the first round keeps the same room and
|
|
44
|
+
* the same players.
|
|
45
|
+
*
|
|
46
|
+
* Rooms live in the memory of one server of several, and the load balancer
|
|
47
|
+
* finds it by the room code: every connection made for a room carries its
|
|
48
|
+
* code (ROUTING_QUERY_KEY). A connection made before there is a room goes to
|
|
49
|
+
* any server, so once that server has opened one the client connects again
|
|
50
|
+
* with its code - in the lobby, where a moment's reconnect costs nothing,
|
|
51
|
+
* rather than leaving the game on a route the load balancer can take away.
|
|
52
|
+
*/
|
|
53
|
+
class PartyGameClient {
|
|
54
|
+
url;
|
|
55
|
+
regionUrls;
|
|
56
|
+
io;
|
|
57
|
+
hostId;
|
|
58
|
+
roomMemory;
|
|
59
|
+
logger;
|
|
60
|
+
ackTimeoutMs;
|
|
61
|
+
clockSyncIntervalMs;
|
|
62
|
+
socket = null;
|
|
63
|
+
/** The room code the current socket was routed by, if any. */
|
|
64
|
+
routedCode = null;
|
|
65
|
+
/**
|
|
66
|
+
* Whether the current connection is attached to the session's room. Host
|
|
67
|
+
* calls wait for it: sent while a reconnect is still re-attaching, they
|
|
68
|
+
* would reach the server from a socket it does not know the room of.
|
|
69
|
+
*/
|
|
70
|
+
attached = true;
|
|
71
|
+
attachedWaiters = [];
|
|
72
|
+
session = null;
|
|
73
|
+
/** Where the session's room is now, when a server has said it moved. */
|
|
74
|
+
redirectCode = null;
|
|
75
|
+
/** What the last lobby was opened for, to open another like it. */
|
|
76
|
+
lastHost = null;
|
|
77
|
+
players = [];
|
|
78
|
+
playersListeners = new Set();
|
|
79
|
+
allAnsweredListeners = new Set();
|
|
80
|
+
sessionListeners = new Set();
|
|
81
|
+
/** Start times the server has given rounds of the current game. */
|
|
82
|
+
roundGos = new Map();
|
|
83
|
+
roundGoListeners = new Set();
|
|
84
|
+
/** Whether the server has started the current game's countdown. */
|
|
85
|
+
countdownStarted = false;
|
|
86
|
+
countdownListeners = new Set();
|
|
87
|
+
/** Server clock minus this TV's clock, in ms. */
|
|
88
|
+
clockOffset = 0;
|
|
89
|
+
clockSyncTimer = null;
|
|
90
|
+
constructor(options) {
|
|
91
|
+
this.url = options.url.replace(/\/+$/, '');
|
|
92
|
+
/* No Object.entries or fromEntries: older TV browsers lack them, and the
|
|
93
|
+
app has no polyfill. */
|
|
94
|
+
const regionUrls = options.regionUrls || {};
|
|
95
|
+
this.regionUrls = {};
|
|
96
|
+
for (const region of Object.keys(regionUrls)) {
|
|
97
|
+
this.regionUrls[region] = regionUrls[region].replace(/\/+$/, '');
|
|
98
|
+
}
|
|
99
|
+
this.io = options.io;
|
|
100
|
+
this.hostId = options.hostId || processHostId;
|
|
101
|
+
this.roomMemory = options.roomMemory || moduleRoomMemory;
|
|
102
|
+
this.logger = options.logger || console;
|
|
103
|
+
this.ackTimeoutMs = options.ackTimeoutMs ?? defaultAckTimeoutMs;
|
|
104
|
+
this.clockSyncIntervalMs =
|
|
105
|
+
options.clockSyncIntervalMs ?? defaultClockSyncIntervalMs;
|
|
106
|
+
}
|
|
107
|
+
getSession() {
|
|
108
|
+
return this.session;
|
|
109
|
+
}
|
|
110
|
+
getPlayers() {
|
|
111
|
+
return this.players;
|
|
112
|
+
}
|
|
113
|
+
isConnected() {
|
|
114
|
+
return this.socket ? this.socket.connected : false;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* A download of at least `bytes` random bytes for the TV to time in the
|
|
118
|
+
* lobby, and its real size, which the bandwidth has to be worked out from.
|
|
119
|
+
* It is a file on the CDN the phone page is on - the origin of the
|
|
120
|
+
* session's join URL - so null until there is a session.
|
|
121
|
+
*/
|
|
122
|
+
speedtest(bytes) {
|
|
123
|
+
if (!this.session) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
const origin = new URL(this.session.joinUrl).origin;
|
|
127
|
+
const size = protocol_1.SPEEDTEST_SIZES.find(candidate => candidate >= bytes) ??
|
|
128
|
+
protocol_1.SPEEDTEST_SIZES[protocol_1.SPEEDTEST_SIZES.length - 1];
|
|
129
|
+
return { url: `${origin}${(0, protocol_1.speedtestPath)(size)}`, bytes: size };
|
|
130
|
+
}
|
|
131
|
+
/* ---------------------------------------------------------- connection */
|
|
132
|
+
/** The socket for `code`'s room (or for no room), connecting a new one if need be. */
|
|
133
|
+
socketFor(code, { fresh = false } = {}) {
|
|
134
|
+
if (this.socket && !fresh && routesAlike(this.routedCode, code)) {
|
|
135
|
+
return this.socket;
|
|
136
|
+
}
|
|
137
|
+
if (this.socket) {
|
|
138
|
+
this.logger.debug('PartyGames', 'reconnecting to route by', code);
|
|
139
|
+
this.socket.close();
|
|
140
|
+
}
|
|
141
|
+
this.routedCode = code;
|
|
142
|
+
this.socket = this.openSocket(code);
|
|
143
|
+
return this.socket;
|
|
144
|
+
}
|
|
145
|
+
currentSocket() {
|
|
146
|
+
return this.socket || this.socketFor(this.session?.code || null);
|
|
147
|
+
}
|
|
148
|
+
setAttached(attached) {
|
|
149
|
+
this.attached = attached;
|
|
150
|
+
if (attached) {
|
|
151
|
+
const waiters = this.attachedWaiters;
|
|
152
|
+
this.attachedWaiters = [];
|
|
153
|
+
for (const waiter of waiters) {
|
|
154
|
+
waiter();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
whenAttached(what) {
|
|
159
|
+
if (this.attached) {
|
|
160
|
+
return Promise.resolve();
|
|
161
|
+
}
|
|
162
|
+
return withTimeout(new Promise(resolve => this.attachedWaiters.push(resolve)), this.ackTimeoutMs, `${what} (reconnecting)`);
|
|
163
|
+
}
|
|
164
|
+
/** Where `code`'s room is: its region's servers, or for no room, the nearest. */
|
|
165
|
+
urlFor(code) {
|
|
166
|
+
return code
|
|
167
|
+
? (0, protocol_1.socketUrlForCode)(code, {
|
|
168
|
+
socketUrl: this.url,
|
|
169
|
+
regionSocketUrls: this.regionUrls,
|
|
170
|
+
})
|
|
171
|
+
: this.url;
|
|
172
|
+
}
|
|
173
|
+
/** A socket routed by `code`, to `url` (by default, where `code`'s room is). */
|
|
174
|
+
connectTo(code, url = this.urlFor(code)) {
|
|
175
|
+
return this.io(url, {
|
|
176
|
+
transports: ['websocket'],
|
|
177
|
+
jsonp: false,
|
|
178
|
+
query: code ? { [protocol_1.ROUTING_QUERY_KEY]: code } : {},
|
|
179
|
+
forceNew: true,
|
|
180
|
+
reconnection: true,
|
|
181
|
+
reconnectionDelay: 1000,
|
|
182
|
+
reconnectionDelayMax: 5000,
|
|
183
|
+
timeout: this.ackTimeoutMs,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
openSocket(code) {
|
|
187
|
+
this.setAttached(false);
|
|
188
|
+
const socket = this.connectTo(code);
|
|
189
|
+
socket.on('connect', () => {
|
|
190
|
+
if (socket !== this.socket) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
this.logger.debug('PartyGames', 'connected to', this.url);
|
|
194
|
+
this.syncClock();
|
|
195
|
+
this.reattach(socket);
|
|
196
|
+
});
|
|
197
|
+
socket.on('disconnect', () => {
|
|
198
|
+
if (socket !== this.socket) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
this.logger.debug('PartyGames', 'disconnected');
|
|
202
|
+
this.setAttached(false);
|
|
203
|
+
});
|
|
204
|
+
socket.on('connect_error', (error) => {
|
|
205
|
+
this.logger.warn('PartyGames', 'connect error', error?.message);
|
|
206
|
+
});
|
|
207
|
+
socket.on(protocol_1.HostMessage.Players, (payload) => {
|
|
208
|
+
this.players = payload?.players || [];
|
|
209
|
+
for (const listener of this.playersListeners) {
|
|
210
|
+
listener(this.players);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
socket.on(protocol_1.HostMessage.AllAnswered, (payload) => {
|
|
214
|
+
for (const listener of this.allAnsweredListeners) {
|
|
215
|
+
listener(payload.round);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
socket.on(protocol_1.GameMessage.Countdown, () => {
|
|
219
|
+
this.countdownStarted = true;
|
|
220
|
+
for (const listener of [...this.countdownListeners]) {
|
|
221
|
+
listener();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
socket.on(protocol_1.HostMessage.Move, (message) => {
|
|
225
|
+
if (socket === this.socket && message?.token) {
|
|
226
|
+
this.moveRoom(socket, message.token);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
socket.on(protocol_1.HostMessage.RoundGo, (go) => {
|
|
230
|
+
if (go) {
|
|
231
|
+
this.roundGos.set(go.round, go);
|
|
232
|
+
for (const listener of this.roundGoListeners) {
|
|
233
|
+
listener(go);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
if (!this.clockSyncTimer) {
|
|
238
|
+
/* clocks drift, and so does the route to the server */
|
|
239
|
+
this.clockSyncTimer = setInterval(() => this.syncClock(), this.clockSyncIntervalMs);
|
|
240
|
+
}
|
|
241
|
+
return socket;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Back on the room we were already hosting, so a dropped connection
|
|
245
|
+
* mid-party does not lose the players or their scores. If the room is gone
|
|
246
|
+
* - its server is - the server opens a new one, and the session follows.
|
|
247
|
+
*/
|
|
248
|
+
reattach(socket) {
|
|
249
|
+
const session = this.session;
|
|
250
|
+
if (!session) {
|
|
251
|
+
this.setAttached(true);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
this.send(socket, protocol_1.HostEvent.Create, {
|
|
255
|
+
code: this.redirectCode || session.code,
|
|
256
|
+
hostId: this.hostId,
|
|
257
|
+
features,
|
|
258
|
+
})
|
|
259
|
+
.then(response => {
|
|
260
|
+
if (response?.ok) {
|
|
261
|
+
// A re-attach is not a reopen, so it says `reused: false`; the
|
|
262
|
+
// lobby still is the one it was.
|
|
263
|
+
const same = response.code === this.session?.code;
|
|
264
|
+
this.adopt({
|
|
265
|
+
...response,
|
|
266
|
+
reused: same ? Boolean(this.session?.reused) : response.reused,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
else if (response?.reason === protocol_1.HostRefusal.Moved && response.movedTo) {
|
|
270
|
+
/* it left that server while we were away: follow it */
|
|
271
|
+
this.redirectCode = response.movedTo;
|
|
272
|
+
this.socketFor(response.movedTo);
|
|
273
|
+
}
|
|
274
|
+
else if (response?.reason === protocol_1.HostRefusal.Draining) {
|
|
275
|
+
/* the room is gone and its server opens no new ones: another will,
|
|
276
|
+
over a new connection the load balancer can send elsewhere */
|
|
277
|
+
this.socketFor(null, { fresh: true });
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
this.logger.warn('PartyGames', 'failed to re-attach', response);
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
.catch(error => this.logger.warn('PartyGames', 'failed to re-attach', error))
|
|
284
|
+
.finally(() => {
|
|
285
|
+
if (socket === this.socket) {
|
|
286
|
+
this.setAttached(true);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
/** Takes on a session the server has handed back, rerouting by its code if need be. */
|
|
291
|
+
adopt(response) {
|
|
292
|
+
const previous = this.session;
|
|
293
|
+
this.redirectCode = null;
|
|
294
|
+
this.session = {
|
|
295
|
+
code: response.code,
|
|
296
|
+
joinUrl: response.joinUrl,
|
|
297
|
+
reused: response.reused,
|
|
298
|
+
maxPlayers: response.maxPlayers,
|
|
299
|
+
totalRounds: response.totalRounds,
|
|
300
|
+
status: response.status,
|
|
301
|
+
players: response.players || [],
|
|
302
|
+
};
|
|
303
|
+
this.players = this.session.players;
|
|
304
|
+
this.roomMemory.set(response.code);
|
|
305
|
+
if (previous && previous.code !== response.code) {
|
|
306
|
+
this.logger.warn('PartyGames', 'room', previous.code, 'is gone, now', response.code);
|
|
307
|
+
for (const listener of this.sessionListeners) {
|
|
308
|
+
listener(this.session);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (!routesAlike(this.routedCode, response.code)) {
|
|
312
|
+
this.socketFor(response.code);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
/* same server: from now on this socket stands for the room */
|
|
316
|
+
this.routedCode = response.code;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
send(socket, event, payload = {}) {
|
|
320
|
+
return withTimeout(new Promise(resolve => socket.emit(event, payload, resolve)), this.ackTimeoutMs, event);
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* A host call: once the connection is attached to the room. A call that
|
|
324
|
+
* crossed the room's move to another server is sent again from there.
|
|
325
|
+
*/
|
|
326
|
+
async emit(event, payload = {}) {
|
|
327
|
+
for (let attempt = 0;; ++attempt) {
|
|
328
|
+
const socket = this.currentSocket();
|
|
329
|
+
await this.whenAttached(event);
|
|
330
|
+
const response = await this.send(this.socket || socket, event, payload);
|
|
331
|
+
const reason = response?.reason;
|
|
332
|
+
if (reason !== protocol_1.HostRefusal.Moving || attempt >= maxRedirects) {
|
|
333
|
+
return response;
|
|
334
|
+
}
|
|
335
|
+
/* the room:move that came before this answer has us reconnecting */
|
|
336
|
+
await this.whenAttached(event);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Takes the room to another server, which the one it is on asked for: it
|
|
341
|
+
* is on its way out, and no game is being played. The room is adopted over
|
|
342
|
+
* a connection with no room in its query - so the load balancer hands it
|
|
343
|
+
* to a server still taking parties - the old server is told where it went,
|
|
344
|
+
* for the phones, and the client then connects for the new code as usual.
|
|
345
|
+
* Host calls wait meanwhile. If it cannot be done the room stays where it
|
|
346
|
+
* was, and the server asks again later.
|
|
347
|
+
*/
|
|
348
|
+
async moveRoom(oldSocket, token) {
|
|
349
|
+
this.setAttached(false);
|
|
350
|
+
// The handover is between the servers of one region, whose tables hold
|
|
351
|
+
// it, so it goes to the room's region rather than the nearest.
|
|
352
|
+
const regionUrl = this.urlFor(this.session?.code || null);
|
|
353
|
+
let adopted = null;
|
|
354
|
+
for (let attempt = 0; attempt <= maxDrainingRetries && !adopted; ++attempt) {
|
|
355
|
+
const socket = this.connectTo(null, regionUrl);
|
|
356
|
+
try {
|
|
357
|
+
await withTimeout(new Promise(resolve => socket.on('connect', () => resolve())), this.ackTimeoutMs, 'connecting to move the room');
|
|
358
|
+
const response = await this.send(socket, protocol_1.HostEvent.Adopt, {
|
|
359
|
+
token,
|
|
360
|
+
hostId: this.hostId,
|
|
361
|
+
features,
|
|
362
|
+
});
|
|
363
|
+
if (response?.ok) {
|
|
364
|
+
adopted = response;
|
|
365
|
+
}
|
|
366
|
+
else if (response?.reason !== protocol_1.HostRefusal.Draining) {
|
|
367
|
+
this.logger.warn('PartyGames', 'could not move the room', response);
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
this.logger.warn('PartyGames', 'could not move the room', error);
|
|
373
|
+
}
|
|
374
|
+
finally {
|
|
375
|
+
socket.close();
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (oldSocket !== this.socket) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (!adopted) {
|
|
382
|
+
this.setAttached(true);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
this.logger.debug('PartyGames', 'room moved to', adopted.code);
|
|
386
|
+
await this.send(oldSocket, protocol_1.HostEvent.Moved, { code: adopted.code }).catch(error => this.logger.warn('PartyGames', 'could not say where the room went', error));
|
|
387
|
+
if (oldSocket === this.socket) {
|
|
388
|
+
this.adopt(adopted);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/* --------------------------------------------------------------- clock */
|
|
392
|
+
/**
|
|
393
|
+
* Measures the offset from this TV's clock to the server's, NTP style: a few
|
|
394
|
+
* round trips, trusting the quickest - the one whose reply spent the least
|
|
395
|
+
* time in flight, and so says most exactly what the server's clock read.
|
|
396
|
+
*/
|
|
397
|
+
async syncClock() {
|
|
398
|
+
let best = null;
|
|
399
|
+
for (let sample = 0; sample < clockSyncSamples; ++sample) {
|
|
400
|
+
try {
|
|
401
|
+
const sentAt = Date.now();
|
|
402
|
+
const response = await this.send(this.currentSocket(), protocol_1.TimeEvent.Ping);
|
|
403
|
+
const receivedAt = Date.now();
|
|
404
|
+
const rtt = receivedAt - sentAt;
|
|
405
|
+
if (typeof response?.now === 'number' && (!best || rtt < best.rtt)) {
|
|
406
|
+
best = { rtt, offset: response.now + rtt / 2 - receivedAt };
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (best) {
|
|
414
|
+
this.clockOffset = best.offset;
|
|
415
|
+
this.logger.debug('PartyGames', 'clock offset', Math.round(best.offset), 'ms, rtt', best.rtt, 'ms');
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/** The time now on the server's clock. */
|
|
419
|
+
serverNow() {
|
|
420
|
+
return Date.now() + this.clockOffset;
|
|
421
|
+
}
|
|
422
|
+
/** A time by this TV's `Date.now()`, as the server's clock would read it. */
|
|
423
|
+
toServerTime(localTime) {
|
|
424
|
+
return localTime + this.clockOffset;
|
|
425
|
+
}
|
|
426
|
+
/** A time on the server's clock, as this TV's `Date.now()` would read it. */
|
|
427
|
+
toLocalTime(serverTime) {
|
|
428
|
+
return serverTime - this.clockOffset;
|
|
429
|
+
}
|
|
430
|
+
/* ---------------------------------------------------------------- game */
|
|
431
|
+
/**
|
|
432
|
+
* Opens a room and returns the code / join URL to put in the QR code.
|
|
433
|
+
* Passing the code of a room we hosted recently reopens that one instead,
|
|
434
|
+
* which is what a second game does: same phones, same names, and a
|
|
435
|
+
* `reused` session so the lobby can tell people to tap Ready.
|
|
436
|
+
*
|
|
437
|
+
* `fresh` is the way out of that: a brand new room, and the one we were
|
|
438
|
+
* holding is closed behind us so its phones are not left waiting on it.
|
|
439
|
+
*/
|
|
440
|
+
async host(gameType, genre, genreName, fresh = false) {
|
|
441
|
+
this.lastHost = { gameType, genre, genreName };
|
|
442
|
+
const known = this.session?.code || this.roomMemory.get();
|
|
443
|
+
const code = fresh ? undefined : known || undefined;
|
|
444
|
+
const request = {
|
|
445
|
+
code,
|
|
446
|
+
features,
|
|
447
|
+
/* Opening a lobby, not re-attaching: the room comes back cleared. */
|
|
448
|
+
reopen: !fresh,
|
|
449
|
+
hostId: this.hostId,
|
|
450
|
+
gameType,
|
|
451
|
+
genre,
|
|
452
|
+
/* for the phones, which only show it */
|
|
453
|
+
genreName,
|
|
454
|
+
totalRounds: protocol_1.TOTAL_ROUNDS,
|
|
455
|
+
};
|
|
456
|
+
/* A fresh room is opened over the connection we have, so the server
|
|
457
|
+
holding the old one closes it; any other ask goes where its room is. */
|
|
458
|
+
if (!fresh) {
|
|
459
|
+
this.socketFor(code || null);
|
|
460
|
+
}
|
|
461
|
+
for (let attempt = 0;; ++attempt) {
|
|
462
|
+
if (!fresh && attempt > 0) {
|
|
463
|
+
/* wherever the room is by now */
|
|
464
|
+
request.code = this.redirectCode || this.session?.code || request.code;
|
|
465
|
+
}
|
|
466
|
+
const response = await this.emit(protocol_1.HostEvent.Create, request);
|
|
467
|
+
if (response?.ok) {
|
|
468
|
+
this.adopt(response);
|
|
469
|
+
await this.whenAttached('the room');
|
|
470
|
+
return this.session;
|
|
471
|
+
}
|
|
472
|
+
if (response?.reason === protocol_1.HostRefusal.Moved &&
|
|
473
|
+
response.movedTo &&
|
|
474
|
+
attempt < maxRedirects) {
|
|
475
|
+
/* opened again on another server since we last knew it */
|
|
476
|
+
this.redirectCode = response.movedTo;
|
|
477
|
+
this.socketFor(response.movedTo);
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (response?.reason === protocol_1.HostRefusal.Draining &&
|
|
481
|
+
attempt < maxDrainingRetries) {
|
|
482
|
+
/* asked a server on its way out: any other server will do */
|
|
483
|
+
this.logger.debug('PartyGames', 'server draining, asking another');
|
|
484
|
+
if (fresh) {
|
|
485
|
+
/* walking away from the old room, so not re-attaching to it */
|
|
486
|
+
this.session = null;
|
|
487
|
+
}
|
|
488
|
+
// A new connection even if this one had no room either: over the
|
|
489
|
+
// same socket the same draining server would answer again.
|
|
490
|
+
this.socketFor(null, { fresh: true });
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
throw new Error(response?.error || 'failed to open a party game room');
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Closes the lobby - no more players can join after this. The round count is
|
|
498
|
+
* chosen in the lobby, so it is locked in here.
|
|
499
|
+
*/
|
|
500
|
+
async start(totalRounds = protocol_1.TOTAL_ROUNDS) {
|
|
501
|
+
const response = await this.emit(protocol_1.HostEvent.Start, {
|
|
502
|
+
totalRounds,
|
|
503
|
+
});
|
|
504
|
+
if (!response?.ok) {
|
|
505
|
+
throw new Error(response?.error || 'failed to start the game');
|
|
506
|
+
}
|
|
507
|
+
this.players = response.players || [];
|
|
508
|
+
if (this.session) {
|
|
509
|
+
this.session = {
|
|
510
|
+
...this.session,
|
|
511
|
+
totalRounds: response.totalRounds || totalRounds,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
/* round numbers start again at 1 */
|
|
515
|
+
this.roundGos.clear();
|
|
516
|
+
this.countdownStarted = false;
|
|
517
|
+
return this.players;
|
|
518
|
+
}
|
|
519
|
+
/** How many rounds this party is playing; the default until a game starts. */
|
|
520
|
+
getTotalRounds() {
|
|
521
|
+
return this.session?.totalRounds || protocol_1.TOTAL_ROUNDS;
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* This TV has the game on screen and its player set up: the countdown to
|
|
525
|
+
* round 1 can start as far as it is concerned. It starts on the TV and the
|
|
526
|
+
* phones at once, when they have all said so - see waitForCountdown.
|
|
527
|
+
*/
|
|
528
|
+
readyForGame() {
|
|
529
|
+
this.emit(protocol_1.GameEvent.Ready).catch(error => this.logger.warn('PartyGames', 'failed to report ready for the game', error));
|
|
530
|
+
}
|
|
531
|
+
/** Resolves when the server starts the countdown to round 1 of this game. */
|
|
532
|
+
waitForCountdown() {
|
|
533
|
+
if (this.countdownStarted) {
|
|
534
|
+
return Promise.resolve();
|
|
535
|
+
}
|
|
536
|
+
return new Promise(resolve => {
|
|
537
|
+
const listener = () => {
|
|
538
|
+
this.countdownListeners.delete(listener);
|
|
539
|
+
resolve();
|
|
540
|
+
};
|
|
541
|
+
this.countdownListeners.add(listener);
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Tells the server the host's video for the round is preloaded, with the
|
|
546
|
+
* options for the phones; the correct index stays between us and the server.
|
|
547
|
+
* The round starts at the time `waitForRoundGo` resolves with.
|
|
548
|
+
*/
|
|
549
|
+
async startRound(config) {
|
|
550
|
+
await this.emit(protocol_1.HostEvent.RoundStart, config);
|
|
551
|
+
}
|
|
552
|
+
/** When the server has said a round starts: at once if it already has. */
|
|
553
|
+
waitForRoundGo(round) {
|
|
554
|
+
const known = this.roundGos.get(round);
|
|
555
|
+
if (known) {
|
|
556
|
+
return Promise.resolve(known);
|
|
557
|
+
}
|
|
558
|
+
return new Promise(resolve => {
|
|
559
|
+
const unsubscribe = this.onRoundGo(go => {
|
|
560
|
+
if (go.round === round) {
|
|
561
|
+
unsubscribe();
|
|
562
|
+
resolve(go);
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
/** Scores the round. Players who did not answer are scored as wrong. */
|
|
568
|
+
async endRound() {
|
|
569
|
+
const response = await this.emit(protocol_1.HostEvent.RoundEnd);
|
|
570
|
+
if (!response?.ok) {
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
this.players = response.players || [];
|
|
574
|
+
return response;
|
|
575
|
+
}
|
|
576
|
+
async endGame() {
|
|
577
|
+
const response = await this.emit(protocol_1.HostEvent.GameEnd);
|
|
578
|
+
if (!response?.ok) {
|
|
579
|
+
return this.players;
|
|
580
|
+
}
|
|
581
|
+
this.players = response.standings || [];
|
|
582
|
+
return this.players;
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Same players, scores back to zero, room back to the lobby. A server past
|
|
586
|
+
* its drain deadline plays no more games, and closes the room instead: the
|
|
587
|
+
* lobby then opens fresh on another server, with a code to scan.
|
|
588
|
+
*/
|
|
589
|
+
async restart() {
|
|
590
|
+
const response = await this.emit(protocol_1.HostEvent.Restart);
|
|
591
|
+
if (response?.reason ===
|
|
592
|
+
protocol_1.HostRefusal.Draining &&
|
|
593
|
+
this.lastHost) {
|
|
594
|
+
const { gameType, genre, genreName } = this.lastHost;
|
|
595
|
+
this.session = null;
|
|
596
|
+
this.socketFor(null, { fresh: true });
|
|
597
|
+
return (await this.host(gameType, genre, genreName, true)).players;
|
|
598
|
+
}
|
|
599
|
+
if (response?.ok) {
|
|
600
|
+
this.players = response.players || [];
|
|
601
|
+
if (this.session) {
|
|
602
|
+
this.session = {
|
|
603
|
+
...this.session,
|
|
604
|
+
status: 'lobby',
|
|
605
|
+
players: this.players,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return this.players;
|
|
610
|
+
}
|
|
611
|
+
/* ----------------------------------------------------------- listeners */
|
|
612
|
+
onPlayers(listener) {
|
|
613
|
+
this.playersListeners.add(listener);
|
|
614
|
+
return () => this.playersListeners.delete(listener);
|
|
615
|
+
}
|
|
616
|
+
onAllAnswered(listener) {
|
|
617
|
+
this.allAnsweredListeners.add(listener);
|
|
618
|
+
return () => this.allAnsweredListeners.delete(listener);
|
|
619
|
+
}
|
|
620
|
+
onRoundGo(listener) {
|
|
621
|
+
this.roundGoListeners.add(listener);
|
|
622
|
+
return () => this.roundGoListeners.delete(listener);
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* The room changed under us: its server went away and a reconnect was
|
|
626
|
+
* handed a new one. The lobby shows the new code; a game in progress has
|
|
627
|
+
* lost its players.
|
|
628
|
+
*/
|
|
629
|
+
onSession(listener) {
|
|
630
|
+
this.sessionListeners.add(listener);
|
|
631
|
+
return () => this.sessionListeners.delete(listener);
|
|
632
|
+
}
|
|
633
|
+
/** Leaves the room entirely - the next `host()` opens a fresh one. */
|
|
634
|
+
close() {
|
|
635
|
+
this.playersListeners.clear();
|
|
636
|
+
this.allAnsweredListeners.clear();
|
|
637
|
+
this.roundGoListeners.clear();
|
|
638
|
+
this.sessionListeners.clear();
|
|
639
|
+
this.roundGos.clear();
|
|
640
|
+
this.countdownListeners.clear();
|
|
641
|
+
this.countdownStarted = false;
|
|
642
|
+
if (this.clockSyncTimer) {
|
|
643
|
+
clearInterval(this.clockSyncTimer);
|
|
644
|
+
this.clockSyncTimer = null;
|
|
645
|
+
}
|
|
646
|
+
this.session = null;
|
|
647
|
+
this.redirectCode = null;
|
|
648
|
+
this.players = [];
|
|
649
|
+
if (this.socket) {
|
|
650
|
+
const socket = this.socket;
|
|
651
|
+
this.socket = null;
|
|
652
|
+
this.routedCode = null;
|
|
653
|
+
socket.close();
|
|
654
|
+
}
|
|
655
|
+
this.setAttached(true);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
exports.PartyGameClient = PartyGameClient;
|
|
659
|
+
//# sourceMappingURL=client.js.map
|