minimojs 1.0.0-alpha.35 → 1.0.0-alpha.37

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.
@@ -0,0 +1,1310 @@
1
+ /**
2
+ * What a room promise is refused with. `reason` is one of:
3
+ * `unavailable` (not on Minimo Games, or the game's creator is not on Pro),
4
+ * `cancelled` (the player closed the lobby), `room_not_found`, `full`,
5
+ * `in_progress`, `removed`, `not_your_turn`, `stale` (another change came
6
+ * first; read the room again and retry), `not_host`, `wrong_phase`,
7
+ * `not_enough_players`, `state_too_large`, `closed`, or another the room
8
+ * names.
9
+ */
10
+ export class MultiplayerError extends Error {
11
+ constructor(message, reason) {
12
+ super(message);
13
+ this.name = "MultiplayerError";
14
+ this.reason = reason;
15
+ }
16
+ }
17
+ const HOST_TIMEOUT_MS = 1500;
18
+ const REQUEST_TIMEOUT_MS = 20000;
19
+ const pendingRequests = new Map();
20
+ const eventListeners = new Set();
21
+ let nextRequestId = 1;
22
+ let listening = false;
23
+ let hostPromise = null;
24
+ function parentWindow() {
25
+ try {
26
+ return typeof window !== "undefined" && window.parent && window.parent !== window ? window.parent : null;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ function listen() {
33
+ if (listening || typeof window === "undefined")
34
+ return;
35
+ listening = true;
36
+ window.addEventListener("message", (event) => {
37
+ if (event.source !== parentWindow())
38
+ return;
39
+ const message = event.data;
40
+ if (!message || typeof message !== "object")
41
+ return;
42
+ if (message.type === "minimo:multiplayer-event") {
43
+ for (const listener of [...eventListeners])
44
+ listener(message);
45
+ return;
46
+ }
47
+ if (message.type !== "minimo:multiplayer-response" || typeof message.id !== "string")
48
+ return;
49
+ const pending = pendingRequests.get(message.id);
50
+ if (!pending)
51
+ return;
52
+ pendingRequests.delete(message.id);
53
+ if (message.ok)
54
+ pending.resolve(message.result);
55
+ else
56
+ pending.reject(new MultiplayerError(message.error || "The room did not answer", message.reason || "error"));
57
+ });
58
+ }
59
+ function request(action, payload, timeoutMs = REQUEST_TIMEOUT_MS) {
60
+ const parent = parentWindow();
61
+ if (!parent)
62
+ return Promise.reject(new MultiplayerError("Multiplayer works only on Minimo Games", "unavailable"));
63
+ listen();
64
+ const id = `multiplayer-${nextRequestId++}`;
65
+ return new Promise((resolve, reject) => {
66
+ const timer = setTimeout(() => {
67
+ pendingRequests.delete(id);
68
+ reject(new MultiplayerError("The room did not answer", "timeout"));
69
+ }, timeoutMs);
70
+ pendingRequests.set(id, {
71
+ resolve: (value) => { clearTimeout(timer); resolve(value); },
72
+ reject: (error) => { clearTimeout(timer); reject(error); },
73
+ });
74
+ parent.postMessage({ type: "minimo:multiplayer-request", id, action, ...payload }, "*");
75
+ });
76
+ }
77
+ function connectHost() {
78
+ hostPromise ?? (hostPromise = request("hello", {}, HOST_TIMEOUT_MS)
79
+ .then((info) => (info && info.host === "minimo-games" ? info : null))
80
+ .catch(() => null));
81
+ return hostPromise;
82
+ }
83
+ // ---------------------------------------------------------------------------
84
+ // Seeded randomness, the same on every player's device for the same seed.
85
+ /** @internal mulberry32: small, fast, and good enough for shuffles and levels. */
86
+ export function seededRandom(seed) {
87
+ let state = seed >>> 0;
88
+ return () => {
89
+ state = (state + 0x6d2b79f5) >>> 0;
90
+ let value = state;
91
+ value = Math.imul(value ^ (value >>> 15), value | 1);
92
+ value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
93
+ return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
94
+ };
95
+ }
96
+ // ---------------------------------------------------------------------------
97
+ // The lobby, the strip, and the results, drawn over the game in HTML.
98
+ const STYLE_ID = "minimo-multiplayer-style";
99
+ const STYLE = `
100
+ .minimo-mp{position:fixed;z-index:2147483000;display:flex;align-items:center;justify-content:center;box-sizing:border-box;
101
+ background:rgba(0,0,0,.55);-webkit-user-select:none;user-select:none;touch-action:manipulation}
102
+ .minimo-mp *{box-sizing:border-box}
103
+ .minimo-mp-panel{display:flex;flex-direction:column;gap:.7em;width:min(92%,26em);max-height:94%;overflow-y:auto;padding:1.1em;border-radius:.9em;
104
+ background:var(--mmp-bg);color:var(--mmp-fg);font-family:var(--mmp-font);box-shadow:0 .6em 2em rgba(0,0,0,.5);text-align:center}
105
+ .minimo-mp h2{margin:0;color:var(--mmp-accent);font-size:1.35em;font-weight:700}
106
+ .minimo-mp p{margin:0}
107
+ .minimo-mp-muted{opacity:.7;font-size:.85em}
108
+ .minimo-mp-error{color:#ff8f8f;font-size:.85em}
109
+ .minimo-mp button{padding:.55em 1em;border:.08em solid currentColor;border-radius:999px;background:none;color:inherit;font:inherit;
110
+ font-size:.9em;cursor:pointer}
111
+ .minimo-mp button:disabled{opacity:.45;cursor:default}
112
+ .minimo-mp .minimo-mp-primary{border-color:var(--mmp-accent);background:var(--mmp-accent);color:var(--mmp-bg);font-weight:700}
113
+ .minimo-mp-row{display:flex;gap:.5em;justify-content:center;flex-wrap:wrap}
114
+ .minimo-mp input{flex:1 1 6em;min-width:0;padding:.5em .6em;border:.08em solid currentColor;border-radius:.4em;background:rgba(255,255,255,.08);
115
+ color:inherit;font:inherit;font-size:max(16px,.95em);text-align:center}
116
+ .minimo-mp-code-input{text-transform:uppercase;letter-spacing:.2em}
117
+ .minimo-mp-code{margin:0;color:var(--mmp-accent);font-size:2.2em;font-weight:800;letter-spacing:.18em;font-variant-numeric:tabular-nums}
118
+ .minimo-mp-qr{align-self:center;width:8.5em;height:8.5em;border-radius:.4em;background:#fff}
119
+ .minimo-mp-link{word-break:break-all;font-size:.75em;opacity:.75}
120
+ .minimo-mp-players{margin:0;padding:0;list-style:none;text-align:left}
121
+ .minimo-mp-player{display:flex;align-items:center;gap:.55em;padding:.35em .5em;border-radius:.4em}
122
+ .minimo-mp-player:nth-child(odd){background:rgba(255,255,255,.06)}
123
+ .minimo-mp-player.is-me{color:var(--mmp-accent);font-weight:700}
124
+ .minimo-mp-dot{flex:0 0 .55em;height:.55em;border-radius:50%;background:#59d38c}
125
+ .minimo-mp-dot.is-away{background:#888}
126
+ .minimo-mp-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
127
+ .minimo-mp-tag{flex:0 0 auto;font-size:.75em;opacity:.75}
128
+ .minimo-mp-score{flex:0 0 auto;font-variant-numeric:tabular-nums}
129
+ .minimo-mp-player button{padding:.15em .55em;font-size:.75em}
130
+ .minimo-mp-strip{position:fixed;z-index:2147482999;display:flex;align-items:flex-start;justify-content:center;box-sizing:border-box;
131
+ pointer-events:none;font-family:var(--mmp-font)}
132
+ .minimo-mp-strip-bar{display:flex;align-items:center;gap:.6em;margin-top:.4em;padding:.3em .8em;border-radius:999px;
133
+ background:rgba(0,0,0,.6);color:#fff;font-size:.8em;pointer-events:auto}
134
+ .minimo-mp-strip-bar.is-mine{background:var(--mmp-accent);color:var(--mmp-bg);font-weight:700}
135
+ .minimo-mp-strip-bar button{padding:.1em .6em;border:.08em solid currentColor;border-radius:999px;background:none;color:inherit;font:inherit;cursor:pointer}
136
+ `;
137
+ function escapeHtml(value) {
138
+ return value.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
139
+ }
140
+ function defaultFormat(score) {
141
+ return Number.isInteger(score) ? score.toLocaleString() : score.toLocaleString(undefined, { maximumFractionDigits: 3 });
142
+ }
143
+ function ensureStyle() {
144
+ if (document.getElementById(STYLE_ID))
145
+ return;
146
+ const style = document.createElement("style");
147
+ style.id = STYLE_ID;
148
+ style.textContent = STYLE;
149
+ document.head.appendChild(style);
150
+ }
151
+ function hoursLeft(until) {
152
+ if (!until)
153
+ return "";
154
+ const ms = Date.parse(until) - Date.now();
155
+ if (!(ms > 0))
156
+ return "";
157
+ const minutes = Math.ceil(ms / 60000);
158
+ return minutes >= 120 ? `${Math.round(minutes / 60)} h` : `${minutes} min`;
159
+ }
160
+ /** @internal */
161
+ export function renderLobbyHtml(screen, view) {
162
+ const parts = [];
163
+ const title = (text) => parts.push(`<h2>${escapeHtml(text)}</h2>`);
164
+ const error = (text) => {
165
+ if (text)
166
+ parts.push(`<p class="minimo-mp-error" role="alert">${escapeHtml(text)}</p>`);
167
+ };
168
+ const players = (room, showScores) => {
169
+ parts.push('<ul class="minimo-mp-players">');
170
+ const list = showScores ? [...room.players].sort((a, b) => rankOf(room, a) - rankOf(room, b)) : room.players;
171
+ for (const player of list) {
172
+ const tags = [];
173
+ if (player.host)
174
+ tags.push("host");
175
+ if (!player.connected)
176
+ tags.push("away");
177
+ parts.push(`<li class="minimo-mp-player${player.me ? " is-me" : ""}">`
178
+ + `<span class="minimo-mp-dot${player.connected ? "" : " is-away"}" aria-hidden="true"></span>`
179
+ + `<span class="minimo-mp-name">${escapeHtml(player.name)}${player.me ? " (you)" : ""}</span>`
180
+ + (tags.length ? `<span class="minimo-mp-tag">${tags.join(" · ")}</span>` : "")
181
+ + (showScores ? `<span class="minimo-mp-score">${player.score === null ? (room.mode === "competition" && room.phase === "playing" ? "playing…" : "—") : escapeHtml(view.format(player.score))}</span>` : "")
182
+ + (!showScores && room.isHost && !player.me ? `<button type="button" data-remove="${escapeHtml(player.id)}" aria-label="Remove ${escapeHtml(player.name)}">Remove</button>` : "")
183
+ + "</li>");
184
+ }
185
+ parts.push("</ul>");
186
+ };
187
+ const share = (room) => {
188
+ parts.push(`<p class="minimo-mp-muted">Room code</p><p class="minimo-mp-code">${escapeHtml(room.code)}</p>`);
189
+ // Hidden if it does not load, rather than shown as a broken image.
190
+ if (room.qrUrl)
191
+ parts.push(`<img class="minimo-mp-qr" src="${escapeHtml(room.qrUrl)}" alt="QR code of the room's link" onerror="this.hidden=true">`);
192
+ parts.push(`<p class="minimo-mp-link">${escapeHtml(room.link)}</p>`);
193
+ parts.push('<div class="minimo-mp-row"><button type="button" data-copy>Copy link</button></div>');
194
+ };
195
+ parts.push(`<div class="minimo-mp-panel" role="dialog" aria-modal="true" aria-label="${escapeHtml(view.title)}">`);
196
+ switch (screen.kind) {
197
+ case "unavailable":
198
+ case "closed":
199
+ title(screen.kind === "closed" ? "Room closed" : view.title);
200
+ parts.push(`<p>${escapeHtml(screen.message)}</p>`);
201
+ parts.push('<div class="minimo-mp-row"><button type="button" class="minimo-mp-primary" data-close>OK</button></div>');
202
+ break;
203
+ case "name":
204
+ title(view.title);
205
+ parts.push("<p>Your name, shown to the other players and in leaderboards.</p>");
206
+ // Not a <form>: a game's frame on Minimo Games is sandboxed without forms.
207
+ parts.push(`<div class="minimo-mp-row" data-name-form><input name="name" maxlength="24" placeholder="Your name" aria-label="Your name" autocomplete="nickname" value="${escapeHtml(screen.name)}"></div>`);
208
+ error(screen.error);
209
+ parts.push('<div class="minimo-mp-row"><button type="button" data-close>Cancel</button><button type="button" class="minimo-mp-primary" data-save-name>Continue</button></div>');
210
+ break;
211
+ case "menu":
212
+ title(view.title);
213
+ if (screen.name) {
214
+ parts.push(`<p class="minimo-mp-muted">Playing as ${escapeHtml(screen.name)}${screen.account ? "" : ' · <button type="button" data-rename>Change</button>'}</p>`);
215
+ }
216
+ parts.push(`<div class="minimo-mp-row"><button type="button" class="minimo-mp-primary" data-create${screen.busy ? " disabled" : ""}>Create a room</button></div>`);
217
+ parts.push('<p class="minimo-mp-muted">or join a friend\'s room</p>');
218
+ parts.push(`<div class="minimo-mp-row" data-join-form><input class="minimo-mp-code-input" name="code" maxlength="5" placeholder="CODE" aria-label="Room code" autocomplete="off" autocapitalize="characters" spellcheck="false" value="${escapeHtml(screen.code)}">`
219
+ + `<button type="button" data-join${screen.busy ? " disabled" : ""}>Join</button></div>`);
220
+ error(screen.error);
221
+ parts.push('<div class="minimo-mp-row"><button type="button" data-close>Close</button></div>');
222
+ break;
223
+ case "waiting": {
224
+ const room = screen.room;
225
+ title(view.title);
226
+ share(room);
227
+ players(room, false);
228
+ error(screen.error);
229
+ if (room.phase === "playing") {
230
+ // A challenge runs from the start; a player joins and plays at once.
231
+ const left = hoursLeft(room.deadline ? room.deadline.toISOString() : null);
232
+ if (left)
233
+ parts.push(`<p class="minimo-mp-muted">The challenge ends in ${left}.</p>`);
234
+ parts.push('<div class="minimo-mp-row"><button type="button" data-leave>Leave</button><button type="button" class="minimo-mp-primary" data-play>Play</button></div>');
235
+ }
236
+ else if (room.isHost) {
237
+ const enough = room.players.length >= room.minPlayers;
238
+ if (!enough)
239
+ parts.push(`<p class="minimo-mp-muted">Waiting for players: at least ${room.minPlayers}.</p>`);
240
+ parts.push(`<div class="minimo-mp-row"><button type="button" data-leave>Leave</button><button type="button" class="minimo-mp-primary" data-start${enough ? "" : " disabled"}>Start</button></div>`);
241
+ }
242
+ else {
243
+ parts.push('<p class="minimo-mp-muted">Waiting for the host to start.</p>');
244
+ parts.push('<div class="minimo-mp-row"><button type="button" data-leave>Leave</button></div>');
245
+ }
246
+ break;
247
+ }
248
+ case "results": {
249
+ const room = screen.room;
250
+ title(view.resultsTitle);
251
+ if (room.mode === "competition" && room.phase === "playing") {
252
+ const left = hoursLeft(room.deadline ? room.deadline.toISOString() : null);
253
+ parts.push(`<p class="minimo-mp-muted">${room.challengeHours !== null ? `Results so far${left ? ` · the challenge ends in ${left}` : ""}` : "Waiting for the others to finish…"}</p>`);
254
+ }
255
+ players(room, true);
256
+ error(screen.error);
257
+ const buttons = ['<button type="button" data-leave>Leave</button>'];
258
+ if (room.challengeHours !== null && room.phase === "playing") {
259
+ buttons.push('<button type="button" data-copy>Copy link</button>');
260
+ }
261
+ else if (room.isHost && room.phase === "playing") {
262
+ buttons.push('<button type="button" data-end-round>End round</button>');
263
+ }
264
+ else if (room.isHost && room.phase === "finished") {
265
+ buttons.push('<button type="button" class="minimo-mp-primary" data-rematch>Play again</button>');
266
+ }
267
+ else if (room.phase === "finished") {
268
+ parts.push('<p class="minimo-mp-muted">Waiting for the host to play again.</p>');
269
+ }
270
+ parts.push(`<div class="minimo-mp-row">${buttons.join("")}</div>`);
271
+ break;
272
+ }
273
+ }
274
+ parts.push("</div>");
275
+ return parts.join("");
276
+ }
277
+ function rankOf(room, player) {
278
+ const result = room.results?.find((entry) => entry.id === player.id);
279
+ return result ? result.rank : 1000 + player.seat;
280
+ }
281
+ /** @internal The strip's text, apart from the page. */
282
+ export function renderStripHtml(room) {
283
+ if (room.phase !== "playing" || room.mode !== "turns")
284
+ return "";
285
+ const turn = room.players.find((player) => player.id === room.turn) ?? null;
286
+ const away = room.players.filter((player) => !player.connected);
287
+ const parts = [];
288
+ if (!room.connected) {
289
+ parts.push("Reconnecting…");
290
+ }
291
+ else if (turn) {
292
+ parts.push(turn.me ? "Your turn" : `${escapeHtml(turn.name)}'s turn`);
293
+ }
294
+ if (away.length > 0 && room.connected) {
295
+ parts.push(`${away.map((player) => escapeHtml(player.name)).join(", ")} dropped out`);
296
+ }
297
+ const skip = room.isHost && turn && !turn.me && !turn.connected ? ' <button type="button" data-skip>Skip</button>' : "";
298
+ return `<div class="minimo-mp-strip-bar${turn?.me && room.connected ? " is-mine" : ""}" role="status">${parts.join(" · ")}${skip}</div>`;
299
+ }
300
+ /** An element laid over the game's canvas, following it as the page resizes. */
301
+ function overGame(game, className, style) {
302
+ ensureStyle();
303
+ const root = document.createElement("div");
304
+ root.className = className;
305
+ root.style.setProperty("--mmp-bg", style.background);
306
+ root.style.setProperty("--mmp-fg", style.color);
307
+ root.style.setProperty("--mmp-accent", style.accent);
308
+ root.style.setProperty("--mmp-font", style.font);
309
+ const canvas = game.getCanvasElement();
310
+ const place = () => {
311
+ const rect = canvas.getBoundingClientRect();
312
+ root.style.left = `${rect.left}px`;
313
+ root.style.top = `${rect.top}px`;
314
+ root.style.width = `${rect.width}px`;
315
+ root.style.height = `${rect.height}px`;
316
+ root.style.fontSize = `${Math.max(12, Math.min(22, rect.width / 22))}px`;
317
+ };
318
+ place();
319
+ // Keys typed into the lobby, such as a name or a code, must not reach the game.
320
+ const stopKeys = (event) => event.stopPropagation();
321
+ root.addEventListener("keydown", stopKeys);
322
+ root.addEventListener("keyup", stopKeys);
323
+ window.addEventListener("resize", place);
324
+ document.body.appendChild(root);
325
+ return {
326
+ root,
327
+ remove: () => {
328
+ window.removeEventListener("resize", place);
329
+ root.remove();
330
+ },
331
+ };
332
+ }
333
+ async function copyText(text) {
334
+ try {
335
+ await navigator.clipboard.writeText(text);
336
+ return true;
337
+ }
338
+ catch {
339
+ return false;
340
+ }
341
+ }
342
+ // ---------------------------------------------------------------------------
343
+ // A room.
344
+ /**
345
+ * A room the player is in: its players, its phase, its shared state, the
346
+ * player's private state, and whose turn it is. Every field reads the room as
347
+ * the player last received it.
348
+ *
349
+ * Changes made by the other players arrive between frames, and the listeners
350
+ * given to {@link Room.onChange} and {@link Room.onMessage} are called inside
351
+ * the game's loop, before the scene's `onUpdate`, so a game never sees the room
352
+ * change halfway through a frame. A change the player makes themselves is in
353
+ * the room when its promise resolves.
354
+ *
355
+ * Get a room from {@link Multiplayer.lobby}, {@link Multiplayer.create}, or
356
+ * {@link Multiplayer.join}.
357
+ */
358
+ export class Room {
359
+ /** @internal */
360
+ constructor(game, owner, snapshot, linkBase) {
361
+ /** @internal */ this._connected = true;
362
+ /** @internal */ this._closed = null;
363
+ /** @internal */ this._random = null;
364
+ /** @internal */ this._randomSeed = null;
365
+ /** @internal */ this._changeListeners = new Set();
366
+ /** @internal */ this._messageListeners = new Set();
367
+ /** @internal */ this._closeListeners = new Set();
368
+ /** @internal */ this._watchers = new Set();
369
+ /** @internal */ this._queue = [];
370
+ /** @internal */ this._flushScheduledAt = 0;
371
+ this._game = game;
372
+ this._owner = owner;
373
+ this._snapshot = snapshot;
374
+ this._link = linkBase;
375
+ }
376
+ /** The room's code, such as `KQ7XA`, which friends type to join. */
377
+ get code() {
378
+ return this._snapshot.code;
379
+ }
380
+ /** The address that opens the game straight into this room. */
381
+ get link() {
382
+ try {
383
+ const url = new URL(this._link);
384
+ url.searchParams.set("room", this.code);
385
+ return url.href;
386
+ }
387
+ catch {
388
+ return `${this._link}?room=${this.code}`;
389
+ }
390
+ }
391
+ /** @internal An image of the link's QR code, drawn by Minimo Games. */
392
+ get qrUrl() {
393
+ try {
394
+ const url = new URL(this._link);
395
+ return `${url.origin}/multiplayer/qr.svg?link=${encodeURIComponent(this.link)}`;
396
+ }
397
+ catch {
398
+ return null;
399
+ }
400
+ }
401
+ get mode() {
402
+ return this._snapshot.mode;
403
+ }
404
+ get phase() {
405
+ return this._snapshot.phase;
406
+ }
407
+ /** Grows each time the room starts a new round: 1 for the first. */
408
+ get round() {
409
+ return this._snapshot.round;
410
+ }
411
+ /** The players in the room, by seat. */
412
+ get players() {
413
+ return this._snapshot.players.map((player) => ({ ...player, me: player.id === this._snapshot.me }));
414
+ }
415
+ /** The player playing on this device. */
416
+ get me() {
417
+ return this.players.find((player) => player.me) ?? {
418
+ id: this._snapshot.me, name: "", seat: -1, host: false, connected: false, finished: false, score: null, me: true,
419
+ };
420
+ }
421
+ /** Whether the player on this device is the room's host. */
422
+ get isHost() {
423
+ return this._snapshot.host === this._snapshot.me;
424
+ }
425
+ /** The id of the player whose turn it is, or `null`. */
426
+ get turn() {
427
+ return this._snapshot.turn;
428
+ }
429
+ /** Whether it is the turn of the player on this device. */
430
+ get isMyTurn() {
431
+ return this._snapshot.turn !== null && this._snapshot.turn === this._snapshot.me;
432
+ }
433
+ /**
434
+ * Whether the player on this device can change the state now: on their
435
+ * turn, or, when no one has the turn, if they are the host.
436
+ */
437
+ get canUpdate() {
438
+ return this.phase === "playing" && (this._snapshot.turn ?? this._snapshot.host) === this._snapshot.me;
439
+ }
440
+ /** The shared state every player receives, or `null` before anyone writes one. */
441
+ get state() {
442
+ return this._snapshot.state;
443
+ }
444
+ /** This player's private state, which no other player receives, or `null`. */
445
+ get mine() {
446
+ return this._snapshot.mine;
447
+ }
448
+ /** The state's version, which grows with every change. */
449
+ get version() {
450
+ return this._snapshot.version;
451
+ }
452
+ /** The round's seed, the same for every player, or `null` before the first round. */
453
+ get seed() {
454
+ return this._snapshot.seed;
455
+ }
456
+ /** When a competition's round, or a challenge, ends, or `null`. */
457
+ get deadline() {
458
+ return this._snapshot.deadlineAt ? new Date(this._snapshot.deadlineAt) : null;
459
+ }
460
+ /** The fewest players who can start. */
461
+ get minPlayers() {
462
+ return this._snapshot.options.minPlayers;
463
+ }
464
+ /** The most players the room seats. */
465
+ get maxPlayers() {
466
+ return this._snapshot.options.maxPlayers;
467
+ }
468
+ /** For an asynchronous challenge, how many hours it runs; otherwise `null`. */
469
+ get challengeHours() {
470
+ return this._snapshot.options.challengeHours;
471
+ }
472
+ /**
473
+ * The results, best first: in a competition, the players who have finished
474
+ * so far; in a game by turns, the scores given to {@link Room.end}. `null`
475
+ * when there are none.
476
+ */
477
+ get results() {
478
+ return this._snapshot.results?.map((result) => ({ ...result, me: result.id === this._snapshot.me })) ?? null;
479
+ }
480
+ /** Whether the player's connection to the room is up. It reconnects on its own. */
481
+ get connected() {
482
+ return this._connected && this._closed === null;
483
+ }
484
+ /** Whether the room has closed, or the player left it. */
485
+ get closed() {
486
+ return this._closed !== null;
487
+ }
488
+ /** @internal Why the room closed: `"closed"`, `"removed"`, or `"left"`. */
489
+ get _closedReason() {
490
+ return this._closed;
491
+ }
492
+ /**
493
+ * A random number from 0 up to 1, from the round's seed: every player who
494
+ * calls it in the same order gets the same numbers, so they can deal the
495
+ * same deck or build the same level. It starts again at each round.
496
+ */
497
+ random() {
498
+ const seed = this._snapshot.seed ?? 0;
499
+ if (this._random === null || this._randomSeed !== seed) {
500
+ this._random = seededRandom(seed);
501
+ this._randomSeed = seed;
502
+ }
503
+ return this._random();
504
+ }
505
+ /** A shuffled copy of `items`, from the round's seed, as {@link Room.random} is. */
506
+ shuffle(items) {
507
+ const copy = [...items];
508
+ for (let index = copy.length - 1; index > 0; index -= 1) {
509
+ const other = Math.floor(this.random() * (index + 1));
510
+ [copy[index], copy[other]] = [copy[other], copy[index]];
511
+ }
512
+ return copy;
513
+ }
514
+ /** The player with this id, or `null`. */
515
+ player(id) {
516
+ return this.players.find((player) => player.id === id) ?? null;
517
+ }
518
+ /**
519
+ * Calls `listener` whenever the room changes: a player joins or leaves, the
520
+ * state or the turn changes, the phase moves on. It runs inside the game's
521
+ * loop. Returns a function that stops it.
522
+ */
523
+ onChange(listener) {
524
+ this._changeListeners.add(listener);
525
+ return () => this._changeListeners.delete(listener);
526
+ }
527
+ /**
528
+ * Calls `listener` for each message another player sends with
529
+ * {@link Room.send}. It runs inside the game's loop. Returns a function that
530
+ * stops it.
531
+ */
532
+ onMessage(listener) {
533
+ this._messageListeners.add(listener);
534
+ return () => this._messageListeners.delete(listener);
535
+ }
536
+ /**
537
+ * Calls `listener` once when the room closes, the player is removed, or the
538
+ * player leaves. `reason` is `"closed"`, `"removed"`, or `"left"`.
539
+ */
540
+ onClose(listener) {
541
+ this._closeListeners.add(listener);
542
+ return () => this._closeListeners.delete(listener);
543
+ }
544
+ /** @internal Watchers redraw the lobby and the strip at once, outside the loop. */
545
+ _watch(watcher) {
546
+ this._watchers.add(watcher);
547
+ return () => this._watchers.delete(watcher);
548
+ }
549
+ /** @internal */
550
+ _apply(snapshot) {
551
+ if (snapshot.code !== this._snapshot.code || snapshot.revision < this._snapshot.revision)
552
+ return false;
553
+ const changed = snapshot.revision !== this._snapshot.revision || JSON.stringify(snapshot.players) !== JSON.stringify(this._snapshot.players);
554
+ this._snapshot = snapshot;
555
+ if (changed) {
556
+ this._enqueue(() => {
557
+ for (const listener of [...this._changeListeners])
558
+ listener(this);
559
+ });
560
+ }
561
+ this._notifyWatchers();
562
+ return changed;
563
+ }
564
+ /** @internal */
565
+ _setConnected(connected) {
566
+ if (this._connected === connected)
567
+ return;
568
+ this._connected = connected;
569
+ this._notifyWatchers();
570
+ }
571
+ /** @internal */
572
+ _message(kind, data, from) {
573
+ const player = this.player(from);
574
+ this._enqueue(() => {
575
+ for (const listener of [...this._messageListeners])
576
+ listener(kind, data, player);
577
+ });
578
+ }
579
+ /** @internal */
580
+ _close(reason) {
581
+ if (this._closed !== null)
582
+ return;
583
+ this._closed = reason;
584
+ this._notifyWatchers();
585
+ this._enqueue(() => {
586
+ for (const listener of [...this._closeListeners])
587
+ listener(reason);
588
+ });
589
+ }
590
+ /** @internal */
591
+ _notifyWatchers() {
592
+ for (const watcher of [...this._watchers])
593
+ watcher();
594
+ }
595
+ /**
596
+ * Runs `work` inside the game's loop: a timer of the game fires at the start
597
+ * of the next frame. `game.reset()` clears timers, so a flush that has not
598
+ * run for a while is scheduled again.
599
+ *
600
+ * @internal
601
+ */
602
+ _enqueue(work) {
603
+ this._queue.push(work);
604
+ const now = Date.now();
605
+ if (this._flushScheduledAt !== 0 && now - this._flushScheduledAt < 500)
606
+ return;
607
+ this._flushScheduledAt = now;
608
+ this._game.addTimer(0, false, () => {
609
+ this._flushScheduledAt = 0;
610
+ const queue = this._queue;
611
+ this._queue = [];
612
+ for (const item of queue) {
613
+ try {
614
+ item();
615
+ }
616
+ catch (error) {
617
+ console.error("[minimo-multiplayer] a room listener failed", error);
618
+ }
619
+ }
620
+ });
621
+ }
622
+ /** @internal */
623
+ async _act(payload) {
624
+ if (this._closed !== null)
625
+ throw new MultiplayerError("The room has closed", "closed");
626
+ const result = await request("act", { code: this.code, payload });
627
+ if (result.room)
628
+ this._apply(result.room);
629
+ }
630
+ /** Starts the game, as the host, once enough players are in. Every player's lobby closes. */
631
+ start() {
632
+ return this._act({ action: "start" });
633
+ }
634
+ /**
635
+ * Changes the room, on the player's turn or, when no one has the turn, as
636
+ * the host: the shared state, players' private states, and whose turn it
637
+ * is. It names the version it started from, and is refused with `stale` if
638
+ * another change came first.
639
+ *
640
+ * ```js
641
+ * await room.update({ state: { board }, turn: "next" });
642
+ * ```
643
+ */
644
+ update(changes) {
645
+ const payload = { action: "update", base: this.version };
646
+ if (Object.prototype.hasOwnProperty.call(changes, "state"))
647
+ payload.state = changes.state ?? null;
648
+ if (changes.private)
649
+ payload.private = changes.private;
650
+ if (changes.turn)
651
+ payload.turn = changes.turn;
652
+ return this._act(payload);
653
+ }
654
+ /**
655
+ * Ends a game by turns, on the player's turn or as the host. `scores`, by
656
+ * player id, become the results, ranked by the room's order; `state` is a
657
+ * last state to show, such as the final board.
658
+ */
659
+ end(scores, state) {
660
+ const payload = { action: "end" };
661
+ if (scores)
662
+ payload.results = scores;
663
+ if (state !== undefined)
664
+ payload.state = state;
665
+ return this._act(payload);
666
+ }
667
+ /**
668
+ * Reports the player's result in a competition's round. The round finishes
669
+ * when every player has reported, when its time limit passes, or when the
670
+ * host ends it.
671
+ */
672
+ finish(score) {
673
+ return this._act({ action: "finish", score: Number(score) });
674
+ }
675
+ /** Ends a competition's round now, as the host, with the results reported so far. */
676
+ endRound() {
677
+ return this._act({ action: "endRound" });
678
+ }
679
+ /** Passes the turn to the next player, as the host, such as when its player dropped out. */
680
+ skipTurn() {
681
+ return this._act({ action: "skip" });
682
+ }
683
+ /** Removes a player from the room, as the host. They cannot join it again. */
684
+ remove(playerId) {
685
+ return this._act({ action: "remove", player: playerId });
686
+ }
687
+ /**
688
+ * Plays again, as the host, once the room has finished: the room goes back
689
+ * to its lobby with the same players, or, for a challenge, starts a new
690
+ * round at once.
691
+ */
692
+ rematch() {
693
+ return this._act({ action: "rematch" });
694
+ }
695
+ /**
696
+ * Sends a message to the other players, which the room passes on and does
697
+ * not keep, such as a reaction. `kind` is a short name; `data` is plain
698
+ * JSON, at most 1 KB.
699
+ */
700
+ async send(kind, data = null) {
701
+ if (this._closed !== null)
702
+ throw new MultiplayerError("The room has closed", "closed");
703
+ await request("act", { code: this.code, payload: { action: "message", kind, data } });
704
+ }
705
+ /** Leaves the room. The others are told, and the turn and the host pass on. */
706
+ async leave() {
707
+ if (this._closed !== null)
708
+ return;
709
+ try {
710
+ await request("act", { code: this.code, payload: { action: "leave" } });
711
+ }
712
+ finally {
713
+ this._close("left");
714
+ this._owner._forget(this);
715
+ }
716
+ }
717
+ /** Resolves when the room's game starts, or at once if it is playing. */
718
+ waitForStart() {
719
+ if (this.phase === "playing")
720
+ return Promise.resolve();
721
+ return new Promise((resolve, reject) => {
722
+ const stop = this._watch(() => {
723
+ if (this.phase === "playing") {
724
+ stop();
725
+ resolve();
726
+ }
727
+ else if (this._closed !== null) {
728
+ stop();
729
+ reject(new MultiplayerError("The room has closed", this._closed));
730
+ }
731
+ });
732
+ });
733
+ }
734
+ /**
735
+ * Shows the results over the game, in HTML, and keeps them up to date as
736
+ * players finish. The host can play again from it, or end a round still
737
+ * running. Resolves `"rematch"` when the next game starts, with the lobby
738
+ * shown while the players wait, or `"leave"` when the player leaves or the
739
+ * room closes.
740
+ */
741
+ showResults(options = {}) {
742
+ return this._owner._showResults(this, options);
743
+ }
744
+ }
745
+ // ---------------------------------------------------------------------------
746
+ // The module's entry point.
747
+ /**
748
+ * Multiplayer rooms for a game on Minimo Games: two to eight players, each on
749
+ * their own device, meet in a room by its code or its link, and play by turns
750
+ * on a small state the room keeps, or each play the same round of a
751
+ * competition and compare their scores. Nothing is synchronized frame by
752
+ * frame: it suits card games, board games, word games, and races against the
753
+ * clock, not action games.
754
+ *
755
+ * It works only inside Minimo Games, and only while the game's creator is on
756
+ * Minimo Games Pro. Players need no account: a guest types a name once.
757
+ *
758
+ * {@link Multiplayer.lobby} shows the lobby over the game, in HTML: creating a
759
+ * room, joining one by its code, sharing its link, and waiting for the others
760
+ * until the host starts. A player who opens the room's link lands in it.
761
+ *
762
+ * ```js
763
+ * import { Multiplayer } from "https://cdn.jsdelivr.net/npm/minimojs@<version>/dist/minimo-multiplayer.js";
764
+ *
765
+ * const mp = new Multiplayer(game, { mode: "turns", minPlayers: 2, maxPlayers: 4 });
766
+ * const room = await mp.lobby(); // resolves when the host starts
767
+ * if (room.isHost) await room.update({ state: deal(room.shuffle(deck)), turn: "next" });
768
+ * room.onChange(() => redraw(room.state, room.mine));
769
+ * // On the player's turn:
770
+ * if (room.isMyTurn) await room.update({ state: next, turn: "next" });
771
+ * // When someone wins:
772
+ * await room.end({ [winnerId]: 1 });
773
+ * const choice = await room.showResults(); // "rematch" or "leave"
774
+ * ```
775
+ *
776
+ * A game with its own welcome screen asks {@link Multiplayer.pendingRoom}
777
+ * first, so a player who opened a room's link, or reloaded the page in a
778
+ * room, goes straight to it instead of to the welcome screen:
779
+ *
780
+ * ```js
781
+ * const pending = await mp.pendingRoom();
782
+ * if (pending) room = await mp.lobby(); // that room's lobby, or its game in progress
783
+ * else showWelcome(); // Play online, then mp.lobby() to create or join
784
+ * ```
785
+ */
786
+ export class Multiplayer {
787
+ /**
788
+ * @param game - The game.
789
+ * @param options - How its rooms work, and how the lobby looks.
790
+ */
791
+ constructor(game, options = {}) {
792
+ /** @internal */ this._room = null;
793
+ /** @internal */ this._info = null;
794
+ /** @internal */ this._listening = false;
795
+ /** @internal */ this._overlay = null;
796
+ /** @internal */ this._closeOverlay = null;
797
+ /** @internal */ this._strip = null;
798
+ /** @internal */ this._stopStrip = null;
799
+ this._game = game;
800
+ this._options = { ...options };
801
+ }
802
+ /** The room the player is in, or `null`. */
803
+ get room() {
804
+ return this._room;
805
+ }
806
+ /**
807
+ * Whether multiplayer can be played here: inside Minimo Games, with the
808
+ * game's creator on Pro.
809
+ */
810
+ async available() {
811
+ const info = await connectHost();
812
+ return Boolean(info && info.available);
813
+ }
814
+ /** @internal */
815
+ _style() {
816
+ return {
817
+ font: this._options.font ?? 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
818
+ background: this._options.background ?? "#15151d",
819
+ color: this._options.color ?? "#ffffff",
820
+ accent: this._options.accent ?? "#ffcc33",
821
+ };
822
+ }
823
+ /** @internal */
824
+ _roomOptions() {
825
+ const { mode, minPlayers, maxPlayers, order, timeLimit, challengeHours, turnTimeout } = this._options;
826
+ const options = { mode: mode ?? "turns" };
827
+ if (minPlayers !== undefined)
828
+ options.minPlayers = minPlayers;
829
+ if (maxPlayers !== undefined)
830
+ options.maxPlayers = maxPlayers;
831
+ if (order !== undefined)
832
+ options.order = order;
833
+ if (timeLimit !== undefined)
834
+ options.timeLimit = timeLimit;
835
+ if (challengeHours !== undefined)
836
+ options.challengeHours = challengeHours;
837
+ if (turnTimeout !== undefined)
838
+ options.turnTimeout = turnTimeout;
839
+ return options;
840
+ }
841
+ /** @internal */
842
+ async _host() {
843
+ if (!(await connectHost()))
844
+ throw new MultiplayerError("Multiplayer works only on Minimo Games", "unavailable");
845
+ // Asked again each time: the room the player is in, and their name, change while the game runs.
846
+ const info = await request("hello", {}, HOST_TIMEOUT_MS * 2);
847
+ this._info = info;
848
+ if (!info.available) {
849
+ throw new MultiplayerError(info.preview ? "Multiplayer is part of Minimo Games Pro: this preview opens rooms once the game's creator is on Pro" : "Multiplayer is not available for this game", "unavailable");
850
+ }
851
+ if (!this._listening) {
852
+ this._listening = true;
853
+ listen();
854
+ eventListeners.add((event) => this._onEvent(event));
855
+ }
856
+ return info;
857
+ }
858
+ /** @internal */
859
+ _onEvent(event) {
860
+ const room = this._room;
861
+ if (!room)
862
+ return;
863
+ switch (event.event) {
864
+ case "room":
865
+ if (event.room.code === room.code)
866
+ room._apply(event.room);
867
+ break;
868
+ case "message":
869
+ room._message(event.kind, event.data, event.from);
870
+ break;
871
+ case "connection":
872
+ room._setConnected(event.connected);
873
+ break;
874
+ case "closed":
875
+ room._close(event.reason === "removed" ? "removed" : "closed");
876
+ this._forget(room);
877
+ break;
878
+ }
879
+ }
880
+ /** @internal */
881
+ _forget(room) {
882
+ if (this._room === room) {
883
+ this._room = null;
884
+ this._removeStrip();
885
+ }
886
+ }
887
+ /** @internal */
888
+ _adopt(snapshot) {
889
+ if (this._room && this._room.code === snapshot.code && !this._room.closed) {
890
+ this._room._apply(snapshot);
891
+ return this._room;
892
+ }
893
+ const room = new Room(this._game, this, snapshot, this._info?.link ?? "");
894
+ this._room = room;
895
+ this._showStrip(room);
896
+ return room;
897
+ }
898
+ /** Creates a room, without the lobby. The player is its host. */
899
+ async create() {
900
+ await this._host();
901
+ const result = await request("create", { options: this._roomOptions() });
902
+ return this._adopt(result.room);
903
+ }
904
+ /** Joins a room by its code, without the lobby, or returns to one the player is in. */
905
+ async join(code) {
906
+ await this._host();
907
+ const result = await request("join", { code: String(code).trim().toUpperCase() });
908
+ return this._adopt(result.room);
909
+ }
910
+ /**
911
+ * The room this player is expected in, before any screen is shown: the room
912
+ * whose link they opened, an invitation, or the room they were in on this
913
+ * device before the page reloaded. `null` on a normal visit, outside
914
+ * Minimo Games, and when the game's creator is not on Pro.
915
+ *
916
+ * It opens nothing, joins nothing, and can be asked again: the room stays
917
+ * pending until the player is in it. {@link Multiplayer.lobby} then takes
918
+ * them straight into it. A room that has closed since, as an old link's,
919
+ * is still returned, and the lobby then says there is no room with that
920
+ * code.
921
+ */
922
+ async pendingRoom() {
923
+ if (this._room && !this._room.closed)
924
+ return { code: this._room.code, invited: false };
925
+ if (!(await connectHost()))
926
+ return null;
927
+ const info = await request("hello", {}, HOST_TIMEOUT_MS * 2).catch(() => null);
928
+ if (!info || !info.available || typeof info.roomCode !== "string")
929
+ return null;
930
+ const code = info.roomCode.trim().toUpperCase();
931
+ return /^[A-Z0-9]{5}$/.test(code) ? { code, invited: Boolean(info.fromLink) } : null;
932
+ }
933
+ /**
934
+ * The player's name on Minimo Games, the one leaderboards show, or `null`
935
+ * before they choose one.
936
+ */
937
+ async playerName() {
938
+ const info = await connectHost();
939
+ return info?.name ?? null;
940
+ }
941
+ /** @internal */
942
+ _closeLobby() {
943
+ this._closeOverlay?.();
944
+ }
945
+ /** @internal Shows `screen` in the overlay, creating it if needed. */
946
+ _render(screen, resultsTitle = "Results", format = defaultFormat) {
947
+ if (!this._overlay)
948
+ return;
949
+ this._overlay.root.innerHTML = renderLobbyHtml(screen, {
950
+ title: this._options.title ?? "Play with friends",
951
+ format,
952
+ resultsTitle,
953
+ });
954
+ const input = this._overlay.root.querySelector("input");
955
+ if (input && document.activeElement?.tagName !== "INPUT")
956
+ input.focus();
957
+ }
958
+ /**
959
+ * Shows the lobby over the game, in HTML, and resolves with the room when
960
+ * its game starts. The player chooses a name if they have none, creates a
961
+ * room or joins one by its code, shares its code and link with friends, and
962
+ * waits; the host starts once enough players are in. A player who opened the
963
+ * game from a room's link goes straight into that room, and one who reloads
964
+ * the page returns to theirs.
965
+ *
966
+ * Refused with `unavailable` outside Minimo Games or when the game's creator
967
+ * is not on Pro, after the lobby says so, and with `cancelled` when the
968
+ * player closes it.
969
+ */
970
+ async lobby() {
971
+ if (typeof document === "undefined" || !document.body) {
972
+ throw new MultiplayerError("Multiplayer needs a page to show its lobby", "unavailable");
973
+ }
974
+ this._closeLobby();
975
+ const overlay = overGame(this._game, "minimo-mp", this._style());
976
+ this._overlay = overlay;
977
+ return new Promise((resolve, reject) => {
978
+ let screen = { kind: "menu", name: null, account: false, error: null, busy: true, code: "" };
979
+ let stopWatching = null;
980
+ let settled = false;
981
+ let pendingError = null;
982
+ // A room's link opened by a player with no name yet: they choose one, then go in.
983
+ let pendingCode = null;
984
+ const finish = (room, error) => {
985
+ if (settled)
986
+ return;
987
+ settled = true;
988
+ stopWatching?.();
989
+ overlay.remove();
990
+ if (this._overlay === overlay) {
991
+ this._overlay = null;
992
+ this._closeOverlay = null;
993
+ }
994
+ if (room)
995
+ resolve(room);
996
+ else
997
+ reject(error ?? new MultiplayerError("The lobby was closed", "cancelled"));
998
+ };
999
+ this._closeOverlay = () => finish(null);
1000
+ const show = (next) => {
1001
+ screen = next;
1002
+ this._render(screen);
1003
+ };
1004
+ const menu = (error = null, code = "") => {
1005
+ show({ kind: "menu", name: this._info?.name ?? null, account: Boolean(this._info?.account), error, busy: false, code });
1006
+ };
1007
+ const wait = (room) => {
1008
+ stopWatching?.();
1009
+ const redraw = () => {
1010
+ if (settled)
1011
+ return;
1012
+ if (room.closed) {
1013
+ stopWatching?.();
1014
+ show({ kind: "closed", message: room._closedReason === "removed" ? "You were removed from this room." : "The room has closed." });
1015
+ return;
1016
+ }
1017
+ // A game by turns starts for everyone at once; a challenge waits for the player's Play.
1018
+ if (room.phase === "playing" && room.challengeHours === null) {
1019
+ finish(room);
1020
+ return;
1021
+ }
1022
+ show({ kind: "waiting", room, error: screen.kind === "waiting" ? screen.error : null });
1023
+ };
1024
+ stopWatching = room._watch(redraw);
1025
+ redraw();
1026
+ };
1027
+ const needsName = () => !this._info?.account && !this._info?.name;
1028
+ const askAccountName = async () => {
1029
+ if (this._info?.account && !this._info.name) {
1030
+ const answer = await request("setName", {}, 10 * 60 * 1000).catch(() => ({ name: null }));
1031
+ if (this._info)
1032
+ this._info.name = answer.name;
1033
+ }
1034
+ };
1035
+ const enter = async (work) => {
1036
+ if (needsName()) {
1037
+ show({ kind: "name", name: "", error: null });
1038
+ return;
1039
+ }
1040
+ if (screen.kind === "menu")
1041
+ show({ ...screen, busy: true, error: null });
1042
+ try {
1043
+ await askAccountName();
1044
+ wait(await work());
1045
+ }
1046
+ catch (error) {
1047
+ const reason = error instanceof MultiplayerError ? error.reason : "error";
1048
+ menu(error instanceof Error ? friendlyError(reason, error.message) : "Something went wrong.");
1049
+ }
1050
+ };
1051
+ overlay.root.addEventListener("click", (event) => {
1052
+ const target = event.target;
1053
+ if (!target)
1054
+ return;
1055
+ const room = screen.kind === "waiting" ? screen.room : null;
1056
+ if (target.closest("[data-close]")) {
1057
+ // After a room closes, the player is back at the menu, to make or join another.
1058
+ if (screen.kind === "closed")
1059
+ menu();
1060
+ else
1061
+ finish(null, pendingError ?? undefined);
1062
+ }
1063
+ else if (target.closest("[data-save-name]") || target.closest("[data-rename]")) {
1064
+ if (target.closest("[data-rename]")) {
1065
+ show({ kind: "name", name: this._info?.name ?? "", error: null });
1066
+ return;
1067
+ }
1068
+ const input = overlay.root.querySelector("[data-name-form] input");
1069
+ void request("setName", { name: input?.value ?? "" }).then((answer) => {
1070
+ if (!answer.name) {
1071
+ show({ kind: "name", name: input?.value ?? "", error: "Write a name of up to 24 characters." });
1072
+ return;
1073
+ }
1074
+ if (this._info)
1075
+ this._info.name = answer.name;
1076
+ menu();
1077
+ if (pendingCode) {
1078
+ const code = pendingCode;
1079
+ pendingCode = null;
1080
+ void enter(() => this.join(code));
1081
+ }
1082
+ }, () => show({ kind: "name", name: input?.value ?? "", error: "The name could not be saved." }));
1083
+ }
1084
+ else if (target.closest("[data-create]")) {
1085
+ void enter(() => this.create());
1086
+ }
1087
+ else if (target.closest("[data-join]")) {
1088
+ const input = overlay.root.querySelector("[data-join-form] input");
1089
+ const code = (input?.value ?? "").trim().toUpperCase();
1090
+ if (!/^[A-Z0-9]{5}$/.test(code)) {
1091
+ menu("Type the 5 letters and digits of the room's code.", code);
1092
+ return;
1093
+ }
1094
+ void enter(() => this.join(code));
1095
+ }
1096
+ else if (room && target.closest("[data-start]")) {
1097
+ room.start().catch((error) => show({ kind: "waiting", room, error: friendlyError(error.reason, error.message) }));
1098
+ }
1099
+ else if (room && target.closest("[data-play]")) {
1100
+ finish(room);
1101
+ }
1102
+ else if (room && target.closest("[data-leave]")) {
1103
+ stopWatching?.();
1104
+ void room.leave().catch(() => undefined).then(() => menu());
1105
+ }
1106
+ else if (room && target.closest("[data-remove]")) {
1107
+ const id = target.closest("[data-remove]")?.getAttribute("data-remove");
1108
+ if (id)
1109
+ room.remove(id).catch(() => undefined);
1110
+ }
1111
+ else if (room && target.closest("[data-copy]")) {
1112
+ void copyText(room.link).then((copied) => {
1113
+ const button = overlay.root.querySelector("[data-copy]");
1114
+ if (button)
1115
+ button.textContent = copied ? "Copied" : "Copy failed";
1116
+ });
1117
+ }
1118
+ });
1119
+ overlay.root.addEventListener("keydown", (event) => {
1120
+ if (event.key === "Escape" && screen.kind !== "waiting")
1121
+ finish(null, pendingError ?? undefined);
1122
+ else if (event.key === "Enter") {
1123
+ const target = event.target;
1124
+ if (target?.closest("[data-name-form]"))
1125
+ overlay.root.querySelector("[data-save-name]")?.click();
1126
+ else if (target?.closest("[data-join-form]"))
1127
+ overlay.root.querySelector("[data-join]")?.click();
1128
+ }
1129
+ });
1130
+ this._render(screen);
1131
+ void (async () => {
1132
+ let info;
1133
+ try {
1134
+ info = await this._host();
1135
+ }
1136
+ catch (error) {
1137
+ pendingError = error instanceof MultiplayerError ? error : new MultiplayerError("Multiplayer is not available", "unavailable");
1138
+ show({ kind: "unavailable", message: pendingError.message });
1139
+ return;
1140
+ }
1141
+ // Already in a room on this device, as after a reload, or arriving by a room's link.
1142
+ if (this._room && !this._room.closed) {
1143
+ wait(this._room);
1144
+ return;
1145
+ }
1146
+ if (info.roomCode) {
1147
+ if (needsName()) {
1148
+ pendingCode = info.roomCode;
1149
+ show({ kind: "name", name: "", error: null });
1150
+ return;
1151
+ }
1152
+ try {
1153
+ await askAccountName();
1154
+ wait(await this.join(info.roomCode));
1155
+ return;
1156
+ }
1157
+ catch (error) {
1158
+ const reason = error instanceof MultiplayerError ? error.reason : "error";
1159
+ menu(info.fromLink ? friendlyError(reason, error.message) : null);
1160
+ // A room that is gone is not tried again.
1161
+ void request("forget", {}).catch(() => undefined);
1162
+ return;
1163
+ }
1164
+ }
1165
+ if (needsName()) {
1166
+ show({ kind: "name", name: "", error: null });
1167
+ return;
1168
+ }
1169
+ menu();
1170
+ })();
1171
+ });
1172
+ }
1173
+ /** @internal */
1174
+ _showResults(room, options) {
1175
+ if (typeof document === "undefined" || !document.body)
1176
+ return Promise.resolve("leave");
1177
+ this._closeLobby();
1178
+ const overlay = overGame(this._game, "minimo-mp", this._style());
1179
+ this._overlay = overlay;
1180
+ const format = options.format ?? defaultFormat;
1181
+ const title = options.title ?? "Results";
1182
+ const startRound = room.round;
1183
+ return new Promise((resolve) => {
1184
+ let settled = false;
1185
+ let error = null;
1186
+ const finish = (choice) => {
1187
+ if (settled)
1188
+ return;
1189
+ settled = true;
1190
+ stop();
1191
+ overlay.remove();
1192
+ if (this._overlay === overlay) {
1193
+ this._overlay = null;
1194
+ this._closeOverlay = null;
1195
+ }
1196
+ resolve(choice);
1197
+ };
1198
+ this._closeOverlay = () => finish("leave");
1199
+ const redraw = () => {
1200
+ if (settled)
1201
+ return;
1202
+ if (room.closed) {
1203
+ this._render({ kind: "closed", message: "The room has closed." });
1204
+ return;
1205
+ }
1206
+ if (room.round > startRound && room.phase === "playing" && room.challengeHours === null) {
1207
+ finish("rematch");
1208
+ return;
1209
+ }
1210
+ if (room.phase === "lobby" || (room.round > startRound && room.phase === "playing")) {
1211
+ this._render({ kind: "waiting", room, error });
1212
+ return;
1213
+ }
1214
+ this._render({ kind: "results", room, error }, title, format);
1215
+ };
1216
+ const stop = room._watch(redraw);
1217
+ overlay.root.addEventListener("click", (event) => {
1218
+ const target = event.target;
1219
+ if (!target)
1220
+ return;
1221
+ const fail = (reason, message) => {
1222
+ error = friendlyError(reason, message);
1223
+ redraw();
1224
+ };
1225
+ if (target.closest("[data-close]"))
1226
+ finish("leave");
1227
+ else if (target.closest("[data-leave]"))
1228
+ void room.leave().catch(() => undefined).then(() => finish("leave"));
1229
+ else if (target.closest("[data-rematch]"))
1230
+ room.rematch().catch((e) => fail(e.reason, e.message));
1231
+ else if (target.closest("[data-start]"))
1232
+ room.start().catch((e) => fail(e.reason, e.message));
1233
+ else if (target.closest("[data-play]"))
1234
+ finish("rematch");
1235
+ else if (target.closest("[data-end-round]"))
1236
+ room.endRound().catch((e) => fail(e.reason, e.message));
1237
+ else if (target.closest("[data-remove]")) {
1238
+ const id = target.closest("[data-remove]")?.getAttribute("data-remove");
1239
+ if (id)
1240
+ room.remove(id).catch(() => undefined);
1241
+ }
1242
+ else if (target.closest("[data-copy]")) {
1243
+ void copyText(room.link).then((copied) => {
1244
+ const button = overlay.root.querySelector("[data-copy]");
1245
+ if (button)
1246
+ button.textContent = copied ? "Copied" : "Copy failed";
1247
+ });
1248
+ }
1249
+ });
1250
+ redraw();
1251
+ });
1252
+ }
1253
+ /** @internal */
1254
+ _showStrip(room) {
1255
+ this._removeStrip();
1256
+ if (this._options.strip === false || typeof document === "undefined" || !document.body)
1257
+ return;
1258
+ const strip = overGame(this._game, "minimo-mp-strip", this._style());
1259
+ this._strip = strip;
1260
+ const redraw = () => {
1261
+ const html = renderStripHtml(room);
1262
+ if (strip.root.innerHTML !== html)
1263
+ strip.root.innerHTML = html;
1264
+ };
1265
+ strip.root.addEventListener("click", (event) => {
1266
+ if (event.target?.closest("[data-skip]"))
1267
+ room.skipTurn().catch(() => undefined);
1268
+ });
1269
+ this._stopStrip = room._watch(redraw);
1270
+ redraw();
1271
+ }
1272
+ /** @internal */
1273
+ _removeStrip() {
1274
+ this._stopStrip?.();
1275
+ this._stopStrip = null;
1276
+ this._strip?.remove();
1277
+ this._strip = null;
1278
+ }
1279
+ /** Closes the lobby or the results, if showing. The room is kept. */
1280
+ close() {
1281
+ this._closeLobby();
1282
+ }
1283
+ /** Closes the lobby and the strip. The player stays in their room; call {@link Room.leave} to leave it. */
1284
+ destroy() {
1285
+ this._closeLobby();
1286
+ this._removeStrip();
1287
+ }
1288
+ }
1289
+ function friendlyError(reason, message) {
1290
+ switch (reason) {
1291
+ case "room_not_found":
1292
+ return "There is no room with this code.";
1293
+ case "full":
1294
+ return "This room is full.";
1295
+ case "in_progress":
1296
+ return "This room's game has already started.";
1297
+ case "removed":
1298
+ return "You were removed from this room.";
1299
+ case "not_enough_players":
1300
+ return message;
1301
+ case "too_many_rooms":
1302
+ case "room_limit":
1303
+ case "rate_limited":
1304
+ return message;
1305
+ case "unavailable":
1306
+ return message;
1307
+ default:
1308
+ return message || "Something went wrong.";
1309
+ }
1310
+ }