@caisual/cli 0.4.0 → 0.9.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/dist/caisual.mjs +1327 -251
- package/package.json +3 -1
package/dist/caisual.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { createHash as createHash3 } from "node:crypto";
|
|
5
5
|
import { createReadStream, promises as fs3 } from "node:fs";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
|
-
import { basename, extname as extname2, join as join3, resolve as resolve2 } from "node:path";
|
|
7
|
+
import { basename as basename2, extname as extname2, join as join3, resolve as resolve2 } from "node:path";
|
|
8
8
|
|
|
9
9
|
// ../contracts/src/slug.ts
|
|
10
10
|
var NOMI_RISERVATI = [
|
|
@@ -50,7 +50,22 @@ function isReservedSlug(value) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
// ../contracts/src/manifest.ts
|
|
53
|
+
function risolviModalita(manifest, mode) {
|
|
54
|
+
const scelta = mode === null ? void 0 : manifest.modes.find((voce) => voce.id === mode);
|
|
55
|
+
if (mode !== null && scelta === void 0) throw new Error("The selected game mode does not exist.");
|
|
56
|
+
return { players: { ...scelta?.players ?? manifest.players }, lobby: scelta?.lobby ?? manifest.lobby };
|
|
57
|
+
}
|
|
58
|
+
function richiedeServer(manifest) {
|
|
59
|
+
return manifest.modes.some((mode) => mode.execution === "room");
|
|
60
|
+
}
|
|
61
|
+
function modalitaLocale(manifest, mode) {
|
|
62
|
+
return mode !== null && manifest.modes.some((voce) => voce.id === mode && voce.execution === "local");
|
|
63
|
+
}
|
|
64
|
+
var TETTO_GIOCATORI = 24;
|
|
65
|
+
var RITARDO_SPETTATORI_MS = 3e3;
|
|
66
|
+
var MASSIMO_CLASSIFICHE = 32;
|
|
53
67
|
var CAMPI = /* @__PURE__ */ new Set([
|
|
68
|
+
"overlay",
|
|
54
69
|
"manifest",
|
|
55
70
|
"id",
|
|
56
71
|
"name",
|
|
@@ -69,6 +84,8 @@ var CAMPI = /* @__PURE__ */ new Set([
|
|
|
69
84
|
"players",
|
|
70
85
|
"lobby",
|
|
71
86
|
"persistent",
|
|
87
|
+
"spectators",
|
|
88
|
+
"boards",
|
|
72
89
|
"roles",
|
|
73
90
|
"teams",
|
|
74
91
|
"voice",
|
|
@@ -83,6 +100,7 @@ var PERFORMANCE = /* @__PURE__ */ new Set(["light", "medium", "heavy"]);
|
|
|
83
100
|
var TAG = /^[a-z0-9-]+$/;
|
|
84
101
|
var ID_INTERNO = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
85
102
|
var CAMPO_MATCHMAKING = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
103
|
+
var ID_CLASSIFICA = /^[a-z0-9][a-z0-9_-]{0,31}$/;
|
|
86
104
|
function oggetto(value) {
|
|
87
105
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
88
106
|
return value;
|
|
@@ -119,6 +137,15 @@ function stringaDefault(dati, campo, valoreDefault, errori) {
|
|
|
119
137
|
}
|
|
120
138
|
return value;
|
|
121
139
|
}
|
|
140
|
+
function testoFacoltativo(value, key, max, path, errors) {
|
|
141
|
+
if (value[key] === void 0) return void 0;
|
|
142
|
+
const text = value[key];
|
|
143
|
+
if (typeof text !== "string" || text.trim().length === 0 || text.trim().length > max || /[\r\n\u0000-\u001f]/.test(text)) {
|
|
144
|
+
errors.push(`${path}.${key}: must contain 1-${max} characters on one line.`);
|
|
145
|
+
return void 0;
|
|
146
|
+
}
|
|
147
|
+
return text.trim();
|
|
148
|
+
}
|
|
122
149
|
function validaManifest(valore) {
|
|
123
150
|
const errori = [];
|
|
124
151
|
const dati = oggetto(valore);
|
|
@@ -261,9 +288,9 @@ function validaManifest(valore) {
|
|
|
261
288
|
for (const campo of Object.keys(value)) {
|
|
262
289
|
if (campo !== "min" && campo !== "max") errori.push(`players.${campo}: unknown field.`);
|
|
263
290
|
}
|
|
264
|
-
if (!interoTra(value.min, 1,
|
|
265
|
-
if (!interoTra(value.max, 1,
|
|
266
|
-
if (interoTra(value.min, 1,
|
|
291
|
+
if (!interoTra(value.min, 1, TETTO_GIOCATORI)) errori.push(`players.min: must be an integer from 1 to ${TETTO_GIOCATORI}.`);
|
|
292
|
+
if (!interoTra(value.max, 1, TETTO_GIOCATORI)) errori.push(`players.max: must be an integer from 1 to ${TETTO_GIOCATORI} in manifest version 1.`);
|
|
293
|
+
if (interoTra(value.min, 1, TETTO_GIOCATORI) && interoTra(value.max, 1, TETTO_GIOCATORI)) {
|
|
267
294
|
if (value.min > value.max) errori.push("players.max: must be greater than or equal to players.min.");
|
|
268
295
|
else players = { min: value.min, max: value.max };
|
|
269
296
|
}
|
|
@@ -279,6 +306,75 @@ function validaManifest(valore) {
|
|
|
279
306
|
if (typeof dati.persistent !== "boolean") errori.push("persistent: must be a boolean.");
|
|
280
307
|
else persistent = dati.persistent;
|
|
281
308
|
}
|
|
309
|
+
let spectators = { delayMs: RITARDO_SPETTATORI_MS };
|
|
310
|
+
if (dati.spectators === false || dati.spectators === null) spectators = null;
|
|
311
|
+
else if (dati.spectators !== void 0 && dati.spectators !== true) {
|
|
312
|
+
const value = oggetto(dati.spectators);
|
|
313
|
+
if (value === null) {
|
|
314
|
+
errori.push("spectators: must be a boolean or an object with delayMs.");
|
|
315
|
+
} else {
|
|
316
|
+
for (const campo of Object.keys(value)) {
|
|
317
|
+
if (campo !== "delayMs") errori.push(`spectators.${campo}: unknown field.`);
|
|
318
|
+
}
|
|
319
|
+
if (!interoTra(value.delayMs, 0, 3e4)) {
|
|
320
|
+
errori.push("spectators.delayMs: must be an integer from 0 to 30000.");
|
|
321
|
+
} else spectators = { delayMs: value.delayMs };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
let overlay = null;
|
|
325
|
+
if (dati.overlay !== void 0 && dati.overlay !== null) {
|
|
326
|
+
const value = oggetto(dati.overlay);
|
|
327
|
+
if (value === null) errori.push("overlay: must be an object or null.");
|
|
328
|
+
else {
|
|
329
|
+
for (const key of Object.keys(value)) if (!["version", "accent"].includes(key)) errori.push(`overlay.${key}: unknown field.`);
|
|
330
|
+
if (value.version !== 1) errori.push("overlay.version: must be exactly 1.");
|
|
331
|
+
if (value.accent !== void 0 && (typeof value.accent !== "string" || !/^#[0-9a-fA-F]{6}$/.test(value.accent))) {
|
|
332
|
+
errori.push("overlay.accent: must be a six-digit hexadecimal color, such as #336699.");
|
|
333
|
+
}
|
|
334
|
+
overlay = { version: 1, ...typeof value.accent === "string" ? { accent: value.accent } : {} };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const boards = {};
|
|
338
|
+
if (dati.boards !== void 0) {
|
|
339
|
+
const value = oggetto(dati.boards);
|
|
340
|
+
if (value === null) errori.push("boards: must be an object of board ids.");
|
|
341
|
+
else {
|
|
342
|
+
if (Object.keys(value).length > MASSIMO_CLASSIFICHE) {
|
|
343
|
+
errori.push(`boards: at most ${MASSIMO_CLASSIFICHE} boards.`);
|
|
344
|
+
}
|
|
345
|
+
for (const [id2, raw] of Object.entries(value)) {
|
|
346
|
+
let valido = true;
|
|
347
|
+
if (!ID_CLASSIFICA.test(id2)) {
|
|
348
|
+
errori.push(`boards.${id2}: invalid board id.`);
|
|
349
|
+
valido = false;
|
|
350
|
+
}
|
|
351
|
+
const board = oggetto(raw);
|
|
352
|
+
if (board === null) {
|
|
353
|
+
errori.push(`boards.${id2}.source: must be "client" or "server".`);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
for (const campo of Object.keys(board)) {
|
|
357
|
+
if (!["source", "label", "periods"].includes(campo)) errori.push(`boards.${id2}.${campo}: unknown field.`);
|
|
358
|
+
}
|
|
359
|
+
if (board.source !== "client" && board.source !== "server") {
|
|
360
|
+
errori.push(`boards.${id2}.source: must be "client" or "server".`);
|
|
361
|
+
valido = false;
|
|
362
|
+
}
|
|
363
|
+
const label = testoFacoltativo(board, "label", 48, `boards.${id2}`, errori);
|
|
364
|
+
let periods = ["all-time"];
|
|
365
|
+
if (board.periods !== void 0) {
|
|
366
|
+
if (!Array.isArray(board.periods) || board.periods.length < 1 || board.periods.length > 2 || board.periods.some((period) => period !== "daily" && period !== "all-time") || new Set(board.periods).size !== board.periods.length) {
|
|
367
|
+
errori.push(`boards.${id2}.periods: must contain daily, all-time, or both without duplicates.`);
|
|
368
|
+
} else periods = [...board.periods];
|
|
369
|
+
}
|
|
370
|
+
if (valido) Object.defineProperty(boards, id2, { value: {
|
|
371
|
+
source: board.source,
|
|
372
|
+
periods,
|
|
373
|
+
...label === void 0 ? {} : { label }
|
|
374
|
+
}, enumerable: true, configurable: true, writable: true });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
282
378
|
const roles = [];
|
|
283
379
|
if (dati.roles !== void 0) {
|
|
284
380
|
if (!Array.isArray(dati.roles)) errori.push("roles: must be an array.");
|
|
@@ -291,7 +387,7 @@ function validaManifest(valore) {
|
|
|
291
387
|
continue;
|
|
292
388
|
}
|
|
293
389
|
for (const campo of Object.keys(value)) {
|
|
294
|
-
if (!["id", "min", "max"].includes(campo)) errori.push(`roles[${indice}].${campo}: unknown field.`);
|
|
390
|
+
if (!["id", "min", "max", "label"].includes(campo)) errori.push(`roles[${indice}].${campo}: unknown field.`);
|
|
295
391
|
}
|
|
296
392
|
const idRuolo = value.id;
|
|
297
393
|
const min = value.min;
|
|
@@ -304,19 +400,25 @@ function validaManifest(valore) {
|
|
|
304
400
|
errori.push(`roles[${indice}].id: duplicate role ${idRuolo}.`);
|
|
305
401
|
valido = false;
|
|
306
402
|
} else ids.add(idRuolo);
|
|
307
|
-
if (!interoTra(min, 0,
|
|
308
|
-
errori.push(`roles[${indice}].min: must be an integer from 0 to
|
|
403
|
+
if (!interoTra(min, 0, TETTO_GIOCATORI)) {
|
|
404
|
+
errori.push(`roles[${indice}].min: must be an integer from 0 to ${TETTO_GIOCATORI}.`);
|
|
309
405
|
valido = false;
|
|
310
406
|
}
|
|
311
|
-
if (max !== void 0 && !interoTra(max, 0,
|
|
312
|
-
errori.push(`roles[${indice}].max: must be an integer from 0 to
|
|
407
|
+
if (max !== void 0 && !interoTra(max, 0, TETTO_GIOCATORI)) {
|
|
408
|
+
errori.push(`roles[${indice}].max: must be an integer from 0 to ${TETTO_GIOCATORI} when present.`);
|
|
313
409
|
valido = false;
|
|
314
410
|
}
|
|
315
411
|
if (typeof min === "number" && typeof max === "number" && min > max) {
|
|
316
412
|
errori.push(`roles[${indice}].max: must be greater than or equal to min.`);
|
|
317
413
|
valido = false;
|
|
318
414
|
}
|
|
319
|
-
|
|
415
|
+
const label = testoFacoltativo(value, "label", 32, `roles[${indice}]`, errori);
|
|
416
|
+
if (valido) roles.push({
|
|
417
|
+
id: idRuolo,
|
|
418
|
+
min,
|
|
419
|
+
...max === void 0 ? {} : { max },
|
|
420
|
+
...label === void 0 ? {} : { label }
|
|
421
|
+
});
|
|
320
422
|
}
|
|
321
423
|
}
|
|
322
424
|
}
|
|
@@ -328,9 +430,9 @@ function validaManifest(valore) {
|
|
|
328
430
|
for (const campo of Object.keys(value)) {
|
|
329
431
|
if (campo !== "min" && campo !== "max") errori.push(`teams.${campo}: unknown field.`);
|
|
330
432
|
}
|
|
331
|
-
if (!interoTra(value.min, 2,
|
|
332
|
-
if (!interoTra(value.max, 2,
|
|
333
|
-
if (interoTra(value.min, 2,
|
|
433
|
+
if (!interoTra(value.min, 2, TETTO_GIOCATORI)) errori.push(`teams.min: must be an integer from 2 to ${TETTO_GIOCATORI}.`);
|
|
434
|
+
if (!interoTra(value.max, 2, TETTO_GIOCATORI)) errori.push(`teams.max: must be an integer from 2 to ${TETTO_GIOCATORI}.`);
|
|
435
|
+
if (interoTra(value.min, 2, TETTO_GIOCATORI) && interoTra(value.max, 2, TETTO_GIOCATORI)) {
|
|
334
436
|
if (value.min > value.max) errori.push("teams.max: must be greater than or equal to teams.min.");
|
|
335
437
|
else teams = { min: value.min, max: value.max };
|
|
336
438
|
}
|
|
@@ -354,7 +456,7 @@ function validaManifest(valore) {
|
|
|
354
456
|
continue;
|
|
355
457
|
}
|
|
356
458
|
for (const campo of Object.keys(value)) {
|
|
357
|
-
if (
|
|
459
|
+
if (!["id", "players", "lobby", "matchmaking", "execution", "label", "instructions"].includes(campo)) errori.push(`modes[${indice}].${campo}: unknown field.`);
|
|
358
460
|
}
|
|
359
461
|
if (typeof value.id !== "string" || value.id.length > 32 || !ID_INTERNO.test(value.id)) {
|
|
360
462
|
errori.push(`modes[${indice}].id: must be 1-32 lowercase letters, digits, or internal hyphens.`);
|
|
@@ -365,8 +467,44 @@ function validaManifest(valore) {
|
|
|
365
467
|
continue;
|
|
366
468
|
}
|
|
367
469
|
ids.add(value.id);
|
|
470
|
+
const modo = { id: value.id };
|
|
471
|
+
for (const [key2, max] of [["label", 48], ["instructions", 160]]) {
|
|
472
|
+
const text = testoFacoltativo(value, key2, max, `modes[${indice}]`, errori);
|
|
473
|
+
if (text !== void 0) modo[key2] = text;
|
|
474
|
+
}
|
|
475
|
+
if (value.execution !== void 0) {
|
|
476
|
+
if (value.execution !== "local" && value.execution !== "room") errori.push(`modes[${indice}].execution: must be local or room.`);
|
|
477
|
+
else modo.execution = value.execution;
|
|
478
|
+
}
|
|
479
|
+
if (overlay !== null && modo.execution === void 0) errori.push(`modes[${indice}].execution: is required with the standard overlay.`);
|
|
480
|
+
if (value.players !== void 0) {
|
|
481
|
+
const campo = `modes[${indice}].players`;
|
|
482
|
+
const range = oggetto(value.players);
|
|
483
|
+
if (range === null) errori.push(`${campo}: must be an object with min and max.`);
|
|
484
|
+
else {
|
|
485
|
+
for (const key2 of Object.keys(range)) {
|
|
486
|
+
if (key2 !== "min" && key2 !== "max") errori.push(`${campo}.${key2}: unknown field.`);
|
|
487
|
+
}
|
|
488
|
+
if (!interoTra(range.min, 1, TETTO_GIOCATORI)) errori.push(`${campo}.min: must be an integer from 1 to ${TETTO_GIOCATORI}.`);
|
|
489
|
+
if (!interoTra(range.max, 1, TETTO_GIOCATORI)) errori.push(`${campo}.max: must be an integer from 1 to ${TETTO_GIOCATORI}.`);
|
|
490
|
+
if (interoTra(range.min, 1, TETTO_GIOCATORI) && interoTra(range.max, 1, TETTO_GIOCATORI)) {
|
|
491
|
+
if (range.min > range.max) errori.push(`${campo}.max: must be greater than or equal to min.`);
|
|
492
|
+
else modo.players = { min: range.min, max: range.max };
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (value.lobby !== void 0) {
|
|
497
|
+
if (typeof value.lobby !== "boolean") errori.push(`modes[${indice}].lobby: must be a boolean.`);
|
|
498
|
+
else modo.lobby = value.lobby;
|
|
499
|
+
}
|
|
500
|
+
if (modo.execution === "local") {
|
|
501
|
+
const range = modo.players ?? players;
|
|
502
|
+
if (range.min !== 1 || range.max !== 1) errori.push(`modes[${indice}].players: local execution requires min and max to be 1.`);
|
|
503
|
+
if (modo.lobby ?? lobby) errori.push(`modes[${indice}].lobby: local execution requires false.`);
|
|
504
|
+
if (value.matchmaking !== void 0) errori.push(`modes[${indice}].matchmaking: local execution cannot use matchmaking.`);
|
|
505
|
+
}
|
|
368
506
|
if (value.matchmaking === void 0) {
|
|
369
|
-
modes.push(
|
|
507
|
+
modes.push(modo);
|
|
370
508
|
continue;
|
|
371
509
|
}
|
|
372
510
|
const matchmaking = oggetto(value.matchmaking);
|
|
@@ -375,7 +513,7 @@ function validaManifest(valore) {
|
|
|
375
513
|
continue;
|
|
376
514
|
}
|
|
377
515
|
for (const campo of Object.keys(matchmaking)) {
|
|
378
|
-
if (
|
|
516
|
+
if (!["key", "timeoutMs", "defaults"].includes(campo)) {
|
|
379
517
|
errori.push(`modes[${indice}].matchmaking.${campo}: unknown field.`);
|
|
380
518
|
}
|
|
381
519
|
}
|
|
@@ -397,16 +535,33 @@ function validaManifest(valore) {
|
|
|
397
535
|
errori.push(`modes[${indice}].matchmaking.timeoutMs: must be an integer from 1000 to 300000.`);
|
|
398
536
|
valido = false;
|
|
399
537
|
}
|
|
400
|
-
|
|
538
|
+
let defaults;
|
|
539
|
+
if (matchmaking.defaults !== void 0) {
|
|
540
|
+
const values = oggetto(matchmaking.defaults);
|
|
541
|
+
if (values === null || Object.keys(values).length !== key.length || key.some((field) => !Object.hasOwn(values, field))) {
|
|
542
|
+
errori.push(`modes[${indice}].matchmaking.defaults: must contain exactly the declared key fields.`);
|
|
543
|
+
} else {
|
|
544
|
+
defaults = {};
|
|
545
|
+
for (const [field, value2] of Object.entries(values)) {
|
|
546
|
+
if (!(typeof value2 === "string" && value2.length >= 1 && value2.length <= 64 && /^[A-Za-z0-9_.:-]+$/.test(value2)) && !Number.isSafeInteger(value2)) {
|
|
547
|
+
errori.push(`modes[${indice}].matchmaking.defaults.${field}: must be a string of 1-64 characters or a safe integer.`);
|
|
548
|
+
} else Object.defineProperty(defaults, field, { value: value2, enumerable: true });
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (valido) modes.push({ ...modo, matchmaking: {
|
|
553
|
+
...defaults === void 0 ? {} : { defaults },
|
|
401
554
|
key,
|
|
402
555
|
timeoutMs: matchmaking.timeoutMs
|
|
403
556
|
} });
|
|
404
557
|
}
|
|
405
558
|
}
|
|
406
559
|
}
|
|
560
|
+
if (overlay !== null && modes.length === 0) errori.push("modes: at least one explicit mode is required with the standard overlay.");
|
|
407
561
|
if (errori.length > 0) return { ok: false, errori };
|
|
408
562
|
return { ok: true, manifest: {
|
|
409
563
|
manifest: 1,
|
|
564
|
+
overlay,
|
|
410
565
|
id,
|
|
411
566
|
name,
|
|
412
567
|
description,
|
|
@@ -424,6 +579,8 @@ function validaManifest(valore) {
|
|
|
424
579
|
players,
|
|
425
580
|
lobby,
|
|
426
581
|
persistent,
|
|
582
|
+
spectators,
|
|
583
|
+
boards,
|
|
427
584
|
roles,
|
|
428
585
|
teams,
|
|
429
586
|
voice,
|
|
@@ -525,11 +682,37 @@ function validaServerJs(sorgente) {
|
|
|
525
682
|
return errori.length === 0 ? { ok: true } : { ok: false, errori };
|
|
526
683
|
}
|
|
527
684
|
|
|
685
|
+
// ../contracts/src/overlay.ts
|
|
686
|
+
function validBoardDay(value) {
|
|
687
|
+
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
|
688
|
+
const at = Date.parse(`${value}T00:00:00Z`);
|
|
689
|
+
return Number.isFinite(at) && new Date(at).toISOString().slice(0, 10) === value;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ../contracts/src/overlay-boards.ts
|
|
693
|
+
function overlayBoardError(manifest, board, params) {
|
|
694
|
+
const configuration = manifest.boards[board];
|
|
695
|
+
if (!manifest.overlay || !configuration || !/^[a-z0-9][a-z0-9_-]{0,31}$/.test(board)) return "The board is not available.";
|
|
696
|
+
const allowed = ["day", "daily", "guests", "limit"];
|
|
697
|
+
for (const key of params.keys()) if (!allowed.includes(key) || params.getAll(key).length !== 1) return "The board query is invalid.";
|
|
698
|
+
if (["daily", "guests"].some((key) => params.has(key) && params.get(key) !== "1")) return "daily and guests must be 1 when present.";
|
|
699
|
+
if (params.has("day") && !validBoardDay(params.get("day"))) return "day must be a real UTC date in YYYY-MM-DD format.";
|
|
700
|
+
const limit = params.get("limit");
|
|
701
|
+
if (limit !== null && (!/^\d+$/.test(limit) || Number(limit) < 1 || Number(limit) > 100)) return "limit must be an integer from 1 to 100.";
|
|
702
|
+
const period = params.has("day") || params.has("daily") ? "daily" : "all-time";
|
|
703
|
+
if (!(configuration.periods ?? ["all-time"]).includes(period)) return "This board does not offer that period.";
|
|
704
|
+
return null;
|
|
705
|
+
}
|
|
706
|
+
function overlayReadOrigin(origin, site, expected) {
|
|
707
|
+
if (origin !== null && origin !== expected) return false;
|
|
708
|
+
return site === null || site === "same-origin" || site === "none";
|
|
709
|
+
}
|
|
710
|
+
|
|
528
711
|
// ../../docs/publish.md
|
|
529
|
-
var publish_default = '# Publish a game on Caisual\n\nCaisual hosts browser games supplied as folders. Each publish creates an immutable version and moves the game\'s stable link to that version.\nThe publishing flow supports both single-player and multiplayer games and does not require changes in the Caisual dashboard. Player identity, rooms, cloud saves, leaderboards, and the daily challenge come from the game kit, documented in [kit.md](./kit.md).\n\n## Game folder\n\nUse this structure:\n\n```text\nmy-game/\n caisual.json\n server.js # optional, required only for multiplayer rooms\n client/\n index.html\n ...\n```\n\n`caisual.json` and `client/index.html` are required. Put every file used by the game under `client/`.\n\nRun `npx @caisual/cli init my-game` to create a minimal single-player folder. Run `npx @caisual/cli init --multiplayer my-game` to include a four-player lobby, a relay server, and a room client example.\n\n## caisual.json\n\nThe file must contain one JSON object. Unknown fields are rejected. This is a complete single-player example:\n\n```json\n{\n "manifest": 1,\n "id": "my-game",\n "name": "My Game",\n "description": "A short description of the game.",\n "cover": "cover.png",\n "screenshots": ["screenshots/level-one.png"],\n "tags": ["puzzle"],\n "language": "en",\n "platform": "both",\n "orientation": "landscape",\n "input": ["keyboard", "mouse", "touch"],\n "visibility": "public",\n "network": [],\n "isolated": false,\n "requires": { "webgl2": false, "webgpu": false, "wasm": false, "threads": false, "memoryMb": null, "performance": "light" },\n "players": { "min": 1, "max": 1 },\n "lobby": false,\n "persistent": false,\n "roles": [],\n "teams": null,\n "voice": "none",\n "modes": []\n}\n```\n\n- `manifest` is required and must be `1`.\n- `id` is required. Use 3 to 32 lowercase ASCII letters or digits, with single hyphens only between groups. The ID becomes the URL slug. Choose it carefully because it cannot be renamed or reused after deletion.\n- `name` is required and must contain 1 to 60 characters.\n- `description` is optional, defaults to an empty string, and can contain at most 500 characters.\n- `cover` is optional. Use a relative path inside `client/`, or `null`. Do not include a query, fragment, empty segment, or parent segment.\n- `screenshots` is optional and defaults to `[]`. It accepts up to 8 relative paths inside `client/`.\n- `tags` is optional and defaults to `[]`. It accepts up to 10 values. Each value uses 1 to 24 lowercase letters, digits, or hyphens.\n- `language` is optional and defaults to `en`. Use a BCP 47 language tag such as `en`, `it`, or `pt-BR`.\n- `platform` is required. Use `desktop` when the game needs a keyboard, mouse, large display, or desktop performance. Use `mobile` when it is designed only for touch and small screens. Use `both` only after checking that layout, performance, and controls work on both.\n- `orientation` is optional and defaults to `landscape`. Use `landscape` or `portrait` to describe the intended mobile layout. The device may not honor an orientation request.\n- `input` is optional and defaults to `[]`. Include every supported input from `keyboard`, `mouse`, `touch`, and `gamepad`. Do not claim an input until the game is usable with it.\n- `visibility` is optional and defaults to `public`. Use `public` for catalog eligibility or `unlisted` for access by direct link only.\n- `network` is optional and defaults to `[]`. List every external host contacted or loaded by the game, without scheme, port, path, query, or fragment, for example `api.example.com`. If an external host is missing, the browser blocks the request. Keep the array empty when the game uses only its own files and Caisual services.\n- `isolated` is optional and defaults to `false`. Use `true` only when the game requires shared memory or threaded WebAssembly. Every external host in `network` must then send headers compatible with cross-origin isolation.\n- `requires` is optional and defaults to `{ "webgl2": false, "webgpu": false, "wasm": false, "threads": false, "memoryMb": null, "performance": "light" }`. Declare the minimum capabilities the game truly needs to run. For example, a game with a WebGPU renderer and a WebGL2 fallback declares only `webgl2`. Set `threads` together with `isolated: true`. `memoryMb` accepts `null` or a multiple of 256 from 512 to 32768. Use `light`, `medium`, or `heavy` for the expected performance load.\n- `players` is optional and defaults to `{ "min": 1, "max": 1 }`. Both values are integers from 1 to 16 and `max` must be at least `min`. Set the range that a room needs before play can start.\n- `lobby` is optional and defaults to `false`. Use `true` when players must choose roles or teams, mark themselves ready, and wait for the host to start. With `false`, play starts when the first player enters and later players may join in progress.\n- `persistent` is optional and defaults to `false`. Use `true` when room members must be able to return with the same code after disconnecting, including while the game is already playing. Persistent rooms expire after 30 days without activity.\n- `roles` is optional and defaults to `[]`. Each entry has an `id` of 1 to 32 lowercase letters, digits, or internal hyphens, a `min` integer from 0 to 16, and an optional `max` in the same range. Rooms enforce these capacities in the lobby.\n- `teams` is optional and defaults to `null`. An object has `min` and `max` integers from 2 to 16, with `max` at least `min`. Rooms balance players who do not choose a team.\n- `voice` is optional and defaults to `none`. Use `room` so everyone in the room can hear each other, `team` to restrict voice to teammates, or `proximity` when `server.js` sets the gain between player pairs. Use `none` to disable voice.\n- `modes` is optional and defaults to `[]`. A mode has a unique `id` using 1 to 32 lowercase letters, digits, or internal hyphens. It may have `matchmaking` with `key`, an array of 1 to 8 unique field names, and `timeoutMs`, an integer from 1,000 to 300,000. Each field name uses 1 to 32 lowercase letters, digits, or hyphens and starts with a letter or digit.\n\nThe CLI prints every manifest error in one run. Fix every listed field and rule before retrying.\n\nWhen `requires` is not at its default, the game page checks the player\'s browser and device. It reports that the game is compatible, may run slowly, or is missing a required capability. The Play button always remains active so the player can still try the game.\n\n## client/index.html\n\n`index.html` must be at the root of `client/`. Use relative URLs such as `./game.js` or `assets/sprite.png`. Do not use root-relative URLs such as `/game.js`, and do not use parent paths that leave the published `client/` tree.\n\nTo use player identity, saves, and leaderboards, import the kit from `/__caisual/kit/v1.js` as shown in [kit.md](./kit.md). The path `/__caisual/` is reserved: do not put game files under it.\n\nDo not register a service worker. The game runs in an iframe on its own origin inside `caisual.com`. Test it without assuming access to the parent page, parent cookies, or files outside `client/`.\n\nWhen `voice` is not `none`, the portal grants microphone access to the game iframe. The browser still asks the player for permission when the game calls `room.voice.join()`. Call it from a button click or another user gesture, not automatically when the page loads.\n\n## Multiplayer server\n\nAdd `server.js` beside `caisual.json` when the game uses rooms. It is the ESM entry point and must have an `export default`. It may import local files such as `./logic/ships.js`, including `.js`, `.ts`, and `.json` files, and npm packages installed in the game folder. The CLI bundles these imports into one file both when publishing and when starting `caisual dev`.\n\nA minimal relay server looks like this:\n\n```js\nimport { defineGame } from \'@caisual/kit/server\';\n\nexport default defineGame({\n onMessage(room, player, message) {\n room.broadcast(message);\n },\n});\n```\n\nThe file may define the optional room callbacks documented in [kit.md](./kit.md). Server code runs without Node.js APIs or network access. Dynamic `import()`, `require()`, and CommonJS exports are not supported. Use only pure JavaScript packages, such as a noise or vector library. A package that needs an HTTP client is not suitable. The `network` field in `caisual.json` controls only requests made by the browser client.\n\nThe bundled `server.js` may be at most 1,000,000 bytes. Room state must remain plain JSON and may be at most 256 KB when serialized. Each incoming player message may be at most 16 KB, and each connection may send at most 20 messages per second. Room save values may be at most 128 KB.\n\nPublish a multiplayer game with the same `npx @caisual/cli publish` command. When imports need bundling, the CLI prints `Bundling server.js (N KB).` The uploaded file is the bundle: the CLI validates it, declares its size and SHA-256 digest, and uploads it separately from browser files. The portal validates the stored bundle again before making the new game version current.\n\nIf the portal finds an invalid `server.js`, the command prints `The multiplayer server could not be published.` followed by diagnostic hints. The failed version is kept for diagnosis but never becomes current. If the game already has a working version, players continue to receive that version. Fix the reported problem and publish again to create a new version.\n\n## Test locally\n\nRun the local preview from the game folder before publishing:\n\n```sh\nnpx @caisual/cli dev\n```\n\nYou can pass a game folder and choose another port:\n\n```sh\nnpx @caisual/cli dev ./my-game --port 8790\n```\n\nThe command prints a portal URL and a game URL. Open the portal URL. It loads the game in an iframe with the same handshake used after publishing, so `c.connected` is `true`. Player identity, saves, leaderboards, daily data, invitations, and rooms all use local data. Each new browser tab gets a different guest identity, while reloading one tab keeps that tab\'s identity.\n\nWhen `server.js` exists, room data is stored as JSON under `.caisual-dev/` in the game folder. Without `server.js`, the game remains single player and attempts to create a room return `no_server`.\n\nPress Ctrl+C in the terminal to stop the preview. No account or publish key is required.\n\n## Limits\n\n- At most 2,000 files per version.\n- At most 50,000,000 bytes per file.\n- At most 200,000,000 bytes for all files in one version.\n- At most 1,000,000 bytes for `server.js`.\n- Dotfiles, dot-directories, and directories named `node_modules` are ignored.\n- Symbolic links and other non-regular files are rejected.\n\nReduce or split files that exceed the per-file limit. Remove generated files that the browser does not need.\n\n## Publish\n\nUse the key supplied by the creator. Set it in the environment so it does not enter shell history as a command-line flag:\n\n```sh\nexport CAISUAL_KEY=\'ck_...\'\nnpx @caisual/cli publish\n```\n\nRun the command from the game folder, or pass the folder path after `publish`. For local portal development only, set `CAISUAL_ORIGIN` to the local HTTP origin.\n\nThe CLI validates the folder, computes every file size and SHA-256 digest, creates a new version, uploads the files, completes the version, and prints the game URL. The stable URL is `https://caisual.com/g/<id>`.\n\nBefore contacting the portal, the CLI scans browser files for common WebGL2, WebGPU, WebAssembly, and shared-memory signatures. A possible mismatch is printed to stderr with a `Warning:` prefix and never blocks publishing. Correct an accurate warning by declaring the minimum matching `requires` field, and set `isolated: true` for shared memory. If the signature belongs to unused code, remove that code from the published client bundle.\n\nThe first games from a new creator are reviewed before they can appear in the public catalog. Their stable links still work while review is pending.\n\n## Update, unlist, or delete\n\nTo update a game, change its files without changing `id`, then run `npx @caisual/cli publish` again. This creates a new version and keeps the same stable game URL.\n\nTo remove a game from the catalog, set `visibility` to `unlisted` and publish, or change visibility from the dashboard. To delete a game, use the dashboard. Deletion is permanent and its ID cannot be reused.\n\n## Common errors\n\n- `CAISUAL_KEY is required`: export the creator\'s key in the same shell before publishing.\n- `The publish API key is not valid`: create a new key in the account dashboard if the old key expired or was revoked.\n- `caisual.json is not valid`: read every reported field and rule, fix all of them, then retry.\n- `client/index.html: file not found`: place `index.html` directly under `client/`, not in a nested build folder.\n- `referenced file not found`: make sure `cover` and every screenshot path match a file under `client/`, including letter case.\n- `file is larger than 50 MB`: compress, reduce, or split the asset and update its references.\n- `upload failed` or a temporary portal error: keep the files unchanged and retry the same publish command. The CLI retries temporary upload failures automatically.\n- `The multiplayer server could not be published`: read every diagnostic hint, fix `server.js`, and publish again. The failed version does not replace the current one.\n- An external browser request works locally but fails after publishing: add its host to `network` and publish a new version. Server code cannot make outbound network requests.\n- A threaded WebAssembly game fails to start: set `isolated` to `true` and verify that every declared external host supports cross-origin isolation.\n';
|
|
712
|
+
var publish_default = '# Publish a game on Caisual\n\nCaisual hosts browser games supplied as folders. Each publish creates an immutable version and moves the game\'s stable link to that version.\nThe publishing flow supports both single-player and multiplayer games and does not require changes in the Caisual dashboard. Player identity, rooms, cloud saves, leaderboards, and the daily challenge come from the game kit, documented in [kit.md](./kit.md).\n\nA game published with `"overlay": { "version": 1 }` is a standard game: it runs full screen and Caisual draws the menu, the lobby, invitations, friends, matchmaking, spectators, leaderboards, voice, the end of a match and Play again on top of it. Write the field, the HUD and the settings; declare the rest in the manifest. See [Sessions and the standard overlay](./kit.md#sessions-and-the-standard-overlay).\n\n## Game folder\n\nUse this structure:\n\n```text\nmy-game/\n caisual.json\n server.js # optional, required only for multiplayer rooms\n client/\n index.html\n ...\n```\n\n`caisual.json` and `client/index.html` are required. Put every file used by the game under `client/`.\n\nRun `npx @caisual/cli init my-game` to create a minimal single-player folder with the standard overlay and one local mode. Run `npx @caisual/cli init --multiplayer my-game` to add a room mode with matchmaking and a `server.js`. Both templates are full screen and use `c.session` and `c.overlay`; neither draws a menu or a lobby of its own.\n\n## caisual.json\n\nThe file must contain one JSON object. Unknown fields are rejected. This is a complete single-player example:\n\n```json\n{\n "manifest": 1,\n "id": "my-game",\n "name": "My Game",\n "description": "A short description of the game.",\n "cover": "cover.png",\n "screenshots": ["screenshots/level-one.png"],\n "tags": ["puzzle"],\n "language": "en",\n "platform": "both",\n "overlay": { "version": 1, "accent": "#397e83" },\n "orientation": "landscape",\n "input": ["keyboard", "mouse", "touch"],\n "visibility": "public",\n "network": [],\n "isolated": false,\n "requires": { "webgl2": false, "webgpu": false, "wasm": false, "threads": false, "memoryMb": null, "performance": "light" },\n "players": { "min": 1, "max": 1 },\n "lobby": false,\n "persistent": false,\n "spectators": true,\n "boards": { "main": { "source": "server", "label": "Best run", "periods": ["daily", "all-time"] } },\n "roles": [],\n "teams": null,\n "voice": "none",\n "modes": [\n { "id": "solo", "execution": "local", "label": "Solo", "instructions": "One run against the clock." }\n ]\n}\n```\n\n- `manifest` is required and must be `1`.\n- `overlay` is optional and defaults to absent. Set `{ "version": 1 }` to publish a standard game and get the whole overlay. `accent` is optional and must be a six-digit `#RRGGBB` colour; no other CSS is accepted. A game without `overlay` keeps its historical flow and draws its own menus, and nothing in this guide changes for it.\n- `id` is required. Use 3 to 32 lowercase ASCII letters or digits, with single hyphens only between groups. The ID becomes the URL slug. Choose it carefully because it cannot be renamed or reused after deletion.\n- `name` is required and must contain 1 to 60 characters.\n- `description` is optional, defaults to an empty string, and can contain at most 500 characters.\n- `cover` is optional. Use a relative path inside `client/`, or `null`. Do not include a query, fragment, empty segment, or parent segment.\n- `screenshots` is optional and defaults to `[]`. It accepts up to 8 relative paths inside `client/`.\n- `tags` is optional and defaults to `[]`. It accepts up to 10 values. Each value uses 1 to 24 lowercase letters, digits, or hyphens.\n- `language` is optional and defaults to `en`. Use a BCP 47 language tag such as `en`, `it`, or `pt-BR`.\n- `platform` is required. Use `desktop` when the game needs a keyboard, mouse, large display, or desktop performance. Use `mobile` when it is designed only for touch and small screens. Use `both` only after checking that layout, performance, and controls work on both.\n- `orientation` is optional and defaults to `landscape`. Use `landscape` or `portrait` to describe the intended mobile layout. The device may not honor an orientation request.\n- `input` is optional and defaults to `[]`. Include every supported input from `keyboard`, `mouse`, `touch`, and `gamepad`. Do not claim an input until the game is usable with it.\n- `visibility` is optional and defaults to `public`. Use `public` for catalog eligibility or `unlisted` for access by direct link only.\n- `network` is optional and defaults to `[]`. List every external host contacted or loaded by the game, without scheme, port, path, query, or fragment, for example `api.example.com`. If an external host is missing, the browser blocks the request. Keep the array empty when the game uses only its own files and Caisual services.\n- `isolated` is optional and defaults to `false`. Use `true` only when the game requires shared memory or threaded WebAssembly. Every external host in `network` must then send headers compatible with cross-origin isolation.\n- `requires` is optional and defaults to `{ "webgl2": false, "webgpu": false, "wasm": false, "threads": false, "memoryMb": null, "performance": "light" }`. Declare the minimum capabilities the game truly needs to run. For example, a game with a WebGPU renderer and a WebGL2 fallback declares only `webgl2`. Set `threads` together with `isolated: true`. `memoryMb` accepts `null` or a multiple of 256 from 512 to 32768. Use `light`, `medium`, or `heavy` for the expected performance load.\n- `players` is optional and defaults to `{ "min": 1, "max": 1 }`. Both values are integers from 1 to 24 and `max` must be at least `min`. Set the range that a room needs before play can start.\n- `lobby` is optional and defaults to `false`. Use `true` when players must choose roles or teams, mark themselves ready, and wait for the host to start. With `false`, play starts when the first player enters and later players may join in progress.\n- `persistent` is optional and defaults to `false`. Use `true` when room members must be able to return with the same code after disconnecting, including while the game is already playing. Persistent rooms expire after 30 days without activity.\n- `spectators` is optional and defaults to `{ "delayMs": 3000 }`. Use `false` to disable watching, `true` for the default three-second delay, or `{ "delayMs": N }` to choose an integer delay from 0 to 30000 milliseconds.\n- `boards` is optional and defaults to `{}`. Each key is a leaderboard id. Use `{ "source": "server" }` to accept only `room.board.submit`, or `{ "source": "client" }` to allow browser submissions. Boards not listed use `client`. A manifest may list up to 32 boards. `label` is optional plain text, 1 to 48 characters on one line, and names the board in the overlay; without it the overlay shows the id. `periods` is optional and defaults to `["all-time"]`: list `daily`, `all-time` or both, without duplicates. `all-time` means the best score with no day attached, not a sum of days. `periods` only chooses what the overlay offers; it does not change what the score APIs accept.\n- `roles` is optional and defaults to `[]`. Each entry has an `id` of 1 to 32 lowercase letters, digits, or internal hyphens, a `min` integer from 0 to 24, and an optional `max` in the same range. Rooms enforce these capacities in the lobby. `label` is optional plain text, 1 to 32 characters on one line, and names the role in the overlay lobby; without it the overlay shows the id.\n- `teams` is optional and defaults to `null`. An object has `min` and `max` integers from 2 to 24, with `max` at least `min`. Rooms balance players who do not choose a team.\n- `voice` is optional and defaults to `none`. Use `room` so everyone in the room can hear each other, `team` to restrict voice to teammates, or `proximity` when `server.js` sets the gain between player pairs. Use `none` to disable voice.\n- `modes` is optional and defaults to `[]`. A mode has a unique `id` using 1 to 32 lowercase letters, digits, or internal hyphens. It may have `matchmaking` with `key`, an array of 1 to 8 unique field names, and `timeoutMs`, an integer from 1,000 to 300,000. Each field name uses 1 to 32 lowercase letters, digits, or hyphens and starts with a letter or digit. A mode may also define `players: { min, max }` (both integers from 1 to 24, max at least min) and `lobby` (boolean). Each supplied field replaces its root counterpart for creation, joining and matchmaking, including filling an open room; omitted fields inherit the root value. `players` is replaced as a whole, not merged. `mode: null` uses the root configuration. Roles, teams, voice and persistence remain game-wide. Catalog labels consider the resolved modes, or the root range when there are no modes: Single player, Multiplayer, or Solo + Multiplayer.\n- A standard game declares at least one mode, and every mode of a standard game needs `execution`: `local` for a run inside the browser, `room` for a room. A `local` mode resolves to exactly one player with `lobby` false and no matchmaking; it is not a room of one, and the create and match APIs refuse it. A `room` mode requires `server.js`, checked by the CLI and again when the version is published.\n- `label` is optional plain text, 1 to 48 characters on one line, and names the mode in the standard menu; without it the menu shows the id. `instructions` is optional plain text, 1 to 160 characters on one line, and adds a line under the label. Both are text, never HTML, and stay in the author\'s own language.\n- `matchmaking.defaults` is required when the overlay is expected to start a search on its own. It holds exactly the fields listed in `key`, with safe integers or strings of 1 to 64 characters from letters, digits, `_ . : -`. Without it a search must come from the game\'s own `c.room.match()` call.\n\nThe CLI prints every manifest error in one run. Fix every listed field and rule before retrying.\n\n`requires` is also available to the game itself through `c.device` in the kit, so the game can show its own warning or pick a lighter renderer. The portal does not gate the Play link on it.\n\n## client/index.html\n\n`index.html` must be at the root of `client/`. Use relative URLs such as `./game.js` or `assets/sprite.png`. Do not use root-relative URLs such as `/game.js`, and do not use parent paths that leave the published `client/` tree.\n\nTo use player identity, saves, and leaderboards, import the kit from `/__caisual/kit/v1.js` as shown in [kit.md](./kit.md). The path `/__caisual/` is reserved: do not put game files under it.\n\nA standard game fills the window: `html`, `body` and the game surface are 100% of the viewport, with no maximum width, no header, no footer and no editorial frame, and the document must not scroll at 1366x768 or at 390x844 with safe areas applied. Aim for the playable field to cover at least 70% of the visible area on desktop and 60% on mobile; a board with a fixed aspect ratio uses the geometric exception described in [kit.md](./kit.md#full-screen).\n\nCaisual draws its own controls on top: a pill in the top-right corner, about 44 pixels tall and wider when it carries an invitation, and a compact bar at the end of a match. Exit lives in that pill. The exact positions arrive in the game as `reservedRects` on `c.overlay.onChange`, in CSS pixels of the game viewport, so place the game\'s own HUD outside them rather than guessing a corner. While a panel is open `inputBlocked` is `true`: release held keys and stop reading input, but keep simulating, because a panel never pauses a room.\n\nA game published without `overlay` keeps the historical control instead: a small round Exit button over the top-right corner, 36 pixels, inside the safe area. Keep that corner free of controls.\n\nDo not register a service worker. The game runs in an iframe on its own origin inside `caisual.com`. Test it without assuming access to the parent page, parent cookies, or files outside `client/`.\n\nWhen `voice` is not `none`, the portal grants microphone access to the game iframe. The browser still asks the player for permission when the game calls `room.voice.join()`. Call it from a button click or another user gesture, not automatically when the page loads.\n\n## Multiplayer server\n\nAdd `server.js` beside `caisual.json` when the game uses rooms. It is the ESM entry point and must have an `export default`. It may import local files such as `./logic/ships.js`, including `.js`, `.ts`, and `.json` files, and npm packages installed in the game folder. The CLI bundles these imports into one file both when publishing and when starting `caisual dev`.\n\nA minimal relay server looks like this:\n\n```js\nimport { defineGame } from \'@caisual/kit/server\';\n\nexport default defineGame({\n onMessage(room, player, message) {\n room.broadcast(message);\n },\n});\n```\n\nThe file may define the optional room callbacks documented in [kit.md](./kit.md). Server code runs without Node.js APIs or network access. Dynamic `import()`, `require()`, and CommonJS exports are not supported. Use only pure JavaScript packages, such as a noise or vector library. A package that needs an HTTP client is not suitable. The `network` field in `caisual.json` controls only requests made by the browser client.\n\nThe bundled `server.js` may be at most 1,000,000 bytes. Room state must remain plain JSON and may be at most 256 KB when serialized. Each incoming player message may be at most 16 KB, and each connection may send at most 20 messages per second. Room save values may be at most 128 KB.\n\nPublish a multiplayer game with the same `npx @caisual/cli publish` command. When imports need bundling, the CLI prints `Bundling server.js (N KB).` The uploaded file is the bundle: the CLI validates it, declares its size and SHA-256 digest, and uploads it separately from browser files. The portal validates the stored bundle again before making the new game version current.\n\nIf the portal finds an invalid `server.js`, the command prints `The multiplayer server could not be published.` followed by diagnostic hints. The failed version is kept for diagnosis but never becomes current. If the game already has a working version, players continue to receive that version. Fix the reported problem and publish again to create a new version.\n\n## Test locally\n\nRun the local preview from the game folder before publishing:\n\n```sh\nnpx @caisual/cli dev\n```\n\nYou can pass a game folder and choose another port:\n\n```sh\nnpx @caisual/cli dev ./my-game --port 8790\n```\n\nThe command prints a portal URL and a game URL. Open the portal URL. It loads the game in an iframe with the same handshake used after publishing, so `c.connected` is `true`, and it mounts the same standard overlay when the manifest declares one. Player identity, saves, leaderboards, daily data, invitations, and rooms all use local data. Add `?lang=` with `en`, `it`, `es`, `fr`, `de` or `pt` to see the overlay in another language; friends and parties are marked unavailable locally. Each new browser tab gets a different guest identity, while reloading one tab keeps that tab\'s identity.\n\nWhen `server.js` exists, room data is stored as JSON under `.caisual-dev/` in the game folder. Without `server.js`, the game remains single player and attempts to create a room return `no_server`.\n\nPress Ctrl+C in the terminal to stop the preview. No account or publish key is required.\n\n## Limits\n\n- At most 2,000 files per version.\n- At most 50,000,000 bytes per file.\n- At most 200,000,000 bytes for all files in one version.\n- At most 1,000,000 bytes for `server.js`.\n- Dotfiles, dot-directories, and directories named `node_modules` are ignored.\n- Symbolic links and other non-regular files are rejected.\n\nReduce or split files that exceed the per-file limit. Remove generated files that the browser does not need.\n\n## Publish\n\nUse the key supplied by the creator. Set it in the environment so it does not enter shell history as a command-line flag:\n\n```sh\nexport CAISUAL_KEY=\'ck_...\'\nnpx @caisual/cli publish\n```\n\nRun the command from the game folder, or pass the folder path after `publish`. For local portal development only, set `CAISUAL_ORIGIN` to the local HTTP origin.\n\nThe CLI validates the folder, computes every file size and SHA-256 digest, creates a new version, uploads the files, completes the version, and prints the game URL. The stable URL is `https://caisual.com/g/<id>`.\n\nBefore contacting the portal, the CLI scans browser files for common WebGL2, WebGPU, WebAssembly, and shared-memory signatures. A possible mismatch is printed to stderr with a `Warning:` prefix and never blocks publishing. Correct an accurate warning by declaring the minimum matching `requires` field, and set `isolated: true` for shared memory. If the signature belongs to unused code, remove that code from the published client bundle.\n\nThe first games from a new creator are reviewed before they can appear in the public catalog. Their stable links still work while review is pending.\n\n## Update, unlist, or delete\n\nTo update a game, change its files without changing `id`, then run `npx @caisual/cli publish` again. This creates a new version and keeps the same stable game URL.\n\nTo remove the current game from the catalog without publishing a new version, run:\n\n```sh\nCAISUAL_KEY=\'ck_...\' npx @caisual/cli unlist\n```\n\nRestore its public visibility with:\n\n```sh\nCAISUAL_KEY=\'ck_...\' npx @caisual/cli relist\n```\n\nDelete it permanently only when you are certain:\n\n```sh\nCAISUAL_KEY=\'ck_...\' npx @caisual/cli delete --yes\n```\n\nEach command reads the `id` from `caisual.json` in the current folder. You may instead pass a game folder or an ID directly, for example `npx @caisual/cli unlist ./my-game` or `npx @caisual/cli relist my-game`. The publishing key always comes from `CAISUAL_KEY`, never from a flag. Deletion has no interactive prompt, is permanent, removes the stored game files, and never frees the ID for reuse.\n\n## Common errors\n\n- `CAISUAL_KEY is required`: export the creator\'s key in the same shell before publishing or managing a game.\n- `The publish API key is not valid`: create a new key in the account dashboard if the old key expired or was revoked.\n- `game_not_found`: check that the game ID is correct and belongs to the creator represented by `CAISUAL_KEY`; deleted games return the same error.\n- `caisual.json is not valid`: read every reported field and rule, fix all of them, then retry.\n- `client/index.html: file not found`: place `index.html` directly under `client/`, not in a nested build folder.\n- `referenced file not found`: make sure `cover` and every screenshot path match a file under `client/`, including letter case.\n- `file is larger than 50 MB`: compress, reduce, or split the asset and update its references.\n- `upload failed` or a temporary portal error: keep the files unchanged and retry the same publish command. The CLI retries temporary upload failures automatically.\n- `The multiplayer server could not be published`: read every diagnostic hint, fix `server.js`, and publish again. The failed version does not replace the current one.\n- An external browser request works locally but fails after publishing: add its host to `network` and publish a new version. Server code cannot make outbound network requests.\n- A threaded WebAssembly game fails to start: set `isolated` to `true` and verify that every declared external host supports cross-origin isolation.\n';
|
|
530
713
|
|
|
531
714
|
// ../../docs/kit.md
|
|
532
|
-
var kit_default = "# Caisual game kit\n\nThe kit gives a published game a stable player identity, cloud saves, leaderboards, a daily challenge seed, and multiplayer rooms with server-owned state.\n\nThe kit is a single JavaScript module with no dependencies. It never touches the page: the game keeps its own rendering, input, and UI.\n\n## Load the kit\n\nEvery published game can import the kit from its own origin, without a bundler and without an npm install:\n\n```html\n<script type=\"module\">\n import { caisual } from '/__caisual/kit/v1.js';\n\n const c = await caisual.connect();\n console.log(c.player.name);\n</script>\n```\n\nGames built with a bundler can install the same module from npm:\n\n```sh\nnpm install @caisual/kit\n```\n\n```js\nimport { caisual } from '@caisual/kit';\n```\n\nBoth forms expose the same API. The module also sets `globalThis.caisual` for classic scripts that load it first.\n\nThe path `/__caisual/` is reserved on every game origin. Do not put game files under it.\n\n## Connect\n\n```js\nconst c = await caisual.connect();\n```\n\n`connect()` completes when the game is running inside caisual.com and has received its player identity, or after a short timeout when it is not. Calling it again returns the same promise.\n\n- `c.connected` is `true` inside caisual.com and `false` when the game runs on its own, for example from a local folder during development or when its files are copied elsewhere.\n- `c.player` is `{ id, name, guest }`. `id` is stable for the player across sessions and across every version of the game. `name` is the account username, or `\"Guest\"`. `guest` is `true` for players without an account. When a guest later signs in, saves and scores stay attached to the same `id`.\n- When not connected, `c.player` is `{ id: \"local\", name: \"Guest\", guest: true }`.\n\nDo not store the ticket or reimplement the handshake. The kit handles identity, renewal, and retries.\n\n## Daily challenge\n\n```js\nc.daily.day; // \"2026-09-04\", the current UTC day\nc.daily.seed; // unsigned 32-bit integer, identical for every player on that day\nconst r = c.daily.random(); // number in [0, 1), deterministic from the seed\n```\n\n`c.daily.random()` is a deterministic generator initialized from `c.daily.seed`. Every `connect()` starts the sequence from the beginning, so two players who call it the same number of times get the same values. Use it to build the level of the day.\n\n`c.time.now()` returns milliseconds aligned with the portal clock. Prefer it to `Date.now()` for anything that must agree with the current day.\n\nWhen not connected, `day` comes from the local clock and `seed` from the local hostname, so a game copied elsewhere still runs.\n\n## Saves\n\nEach player has up to 32 saves per game. A save is any JSON value up to 256 KB when serialized.\n\n```js\nawait c.save.set('slot1', { level: 3, coins: 120 }); // -> { key, bytes, updatedAt }\nconst data = await c.save.get('slot1'); // -> the value, or null\nawait c.save.remove('slot1');\nconst saves = await c.save.list(); // -> [{ key, bytes, updatedAt }]\n```\n\n- Keys use 1 to 32 characters: lowercase letters, digits, `_` or `-`, starting with a letter or digit.\n- `updatedAt` is a millisecond timestamp.\n- Saves are per player and per game. Another game cannot read them.\n- When not connected, saves go to the browser's local storage on the game origin.\n\nErrors reject the promise with an `Error` whose `code` is one of `invalid_request`, `not_found`, `save_limit`, `payload_too_large`, `rate_limited`, `invalid_ticket`, `internal_error`, or `offline`.\n\n## Leaderboards\n\nA leaderboard is identified by a board id chosen by the game. Scores are non-negative integers and higher is better. Each player keeps one entry per board, and one per board per day for daily boards: the best score is kept.\n\n```js\nconst result = await c.board.submit('main', 1234);\n// -> { accepted: true, best: 1234, rank: 7, day: null }\n\nconst daily = await c.board.submit('main', 1234, { daily: true });\n// -> { accepted: true, best: 1234, rank: 7, day: \"2026-09-04\" }\n\nconst top = await c.board.top('main', { daily: true, limit: 10 });\n// -> { day: \"2026-09-04\", entries: [{ rank, name, score, guest, me }], me: { rank, score } | null }\n```\n\n- Board ids use the same format as save keys.\n- `submit` never rejects because of connectivity. When the game is not connected it resolves `{ accepted: false, reason: \"offline\" }`.\n- `best` is the score kept for this player after the submission, which can be higher than the submitted one.\n- `rank` counts players with a strictly higher score. Ties are ordered by who reached the score first.\n- Accounts and guests are ranked separately. `top()` returns account players by default; pass `guests: true` to list guests instead. `me` always refers to the current player within their own category, even beyond `limit`.\n- `limit` is 1 to 100 and defaults to 10.\n- Scores submitted from the browser are recorded as unverified. A room server can submit verified scores.\n\n## Device\n\n`c.device` contains the browser and device report collected while `connect()` runs:\n\n```ts\ninterface DeviceReport {\n webgl2: boolean;\n webgpu: boolean;\n wasm: boolean;\n threads: boolean;\n isolated: boolean;\n gpu: 'hardware' | 'software' | 'none';\n memoryMb: number | null;\n cores: number | null;\n mobile: boolean;\n tier: 'low' | 'mid' | 'high';\n}\n```\n\nUse capability fields to choose a renderer, then use `tier` to reduce pixel ratio and quality on smaller devices:\n\n```js\nconst renderer = c.device.webgpu\n ? createWebGpuRenderer()\n : createWebGl2Renderer();\n\nconst pixelRatio = c.device.tier === 'high' ? devicePixelRatio : 1;\nconst quality = c.device.tier === 'low' ? 'low' : 'high';\nrenderer.configure({ pixelRatio, quality });\n```\n\nThe probe takes at most 1.5 seconds. `memoryMb` and `cores` are `null` when the browser does not expose them. The report stays in the browser and is not saved or sent to Caisual.\n\n## Rooms\n\nA room brings players into the same running game. Creating and joining require a published `server.js`; single-player games can ignore `c.room`.\n\n```js\nconst c = await caisual.connect();\n\nc.room.invited; // invitation code from the game page, or null\n\nconst room = await c.room.create({ mode: null });\n// Or join the invitation that opened the game:\nconst invitedRoom = await c.room.join();\n// Or enter a code supplied by the player:\nconst codedRoom = await c.room.join('ABC234');\n\nroom.code;\nroom.seed; // unsigned 32-bit integer fixed for this room\nroom.invite(); // { code: \"ABC234\", url: \"https://caisual.com/r/ABC234\" }\n```\n\nPass a mode id from the manifest to `create({ mode })`, or `null` when the game has no modes. `join()` uses `c.room.invited`; without an invitation, pass the six-character code explicitly. Show the URL returned by `invite()` in a share button or copy action.\n\n### Crew\n\nThe kit automatically reports the player's current room to the Caisual portal, so the player's friends can join with one click. The game does not need to send or handle anything for this. There is no `c.crew` API in this version.\n\n### Matchmaking\n\nUse `c.room.match()` to find players who requested the same mode and key. The key must contain exactly the fields declared by that mode's `matchmaking.key` in `caisual.json`.\n\n```js\nconst room = await c.room.match({\n mode: 'daily',\n key: { day: c.daily.day, stage: 3 },\n onWaiting({ players, min, max }) {\n showQueue(`${players}/${max} players, ${min} required`);\n },\n});\n```\n\nA room opens as soon as the queue reaches `players.max`. When `matchmaking.timeoutMs` expires, it also opens if at least `players.min` players are waiting. Otherwise the promise rejects with `no_match`, and the game should offer the player another option. A new search first tries to fill a matching room that is already open and can still accept players.\n\nPass an `AbortSignal` as `signal` to let the player cancel a search. Cancellation rejects with `cancelled`. For lobbies with players who may not know each other, have the game start automatically after everyone is ready:\n\n```js\nroom.onPlayers((players) => {\n if (players.every((player) => player.ready) && room.you === room.host) room.start();\n});\n```\n\nRoom status is one of:\n\n- `lobby`: players are joining and choosing their setup.\n- `countdown`: the lobby has accepted `start()` and play begins at the announced server time.\n- `playing`: the game server is running the match.\n- `ended`: the match or connection has ended. `room.result` contains the result last reported by the room. A definitive connection closure uses `{ closed: 4003 }`, `{ closed: 4004 }`, `{ closed: 4005 }`, or `{ closed: 4006 }`.\n\nThe current lobby data is available directly:\n\n```js\nroom.players; // [{ id, name, guest, role, team, ready, connected }]\nroom.you; // this player's id\nroom.host; // the current host's id, or null\n\nroom.ready(true);\nroom.setRole('captain');\nroom.setTeam(1);\n\nif (room.you === room.host) room.start();\n```\n\n`ready`, role, team, and `start()` are lobby actions. Starting requires the host, every connected player to be ready, and the player, role, and team minimums from the manifest. Calling `start()` begins a three-second countdown. A role or team change clears that player's ready state. The built-in `spectator` role receives state but cannot send game input.\n\nThe server owns room state. Read it and react to updates, but do not assign to it or mutate nested values from the browser:\n\n```js\ndraw(room.state);\n\nconst stopState = room.onState((state, tick, serverTime) => {\n draw(state);\n});\n\nconst stopPlayers = room.onPlayers((players) => updateLobby(players));\nconst stopStatus = room.onStatus((status, result, at) => showStatus(status, result, at));\n\nstopState();\nstopPlayers();\nstopStatus();\n```\n\n`room.tick` identifies the latest state. The kit applies structural updates in order and requests a full state automatically if an update does not match the current tick. `room.serverTime()` returns milliseconds aligned with the room clock and is kept current by a ping every five seconds.\n\nSend JSON input to `onMessage` in the server definition, and receive JSON sent or broadcast by the server:\n\n```js\nroom.send({ type: 'fire', target: 3 });\n\nconst stopMessages = room.onMessage((message) => {\n showEvent(message);\n});\n```\n\nThe kit numbers outgoing inputs in increasing order. It automatically reconnects temporary failures with delays of 1, 2, 4, then 8 seconds, for at most the room's 60-second grace period. Each attempt gets a fresh room token. A successful reconnect replaces local state with a full server state. Inputs sent while reconnecting throw an error with `code: \"offline\"`.\n\nCall `room.leave()` for an intentional departure. The kit does not reconnect after leaving, being kicked, the room ending, the published version closing, or the same player opening the room in another tab.\n\nRoom creation, joining, and matchmaking reject with an `Error` carrying a stable `code`. Common codes are `invalid_request`, `no_server`, `no_match`, `cancelled`, `room_not_found`, `room_full`, `room_playing`, `room_ended`, `rate_limited`, `invalid_ticket`, `internal_error`, and `offline`. `no_server` means the published game has no multiplayer server. When `c.connected` is `false`, `create`, `join`, and `match` reject with `offline`.\n\nEvery `onState`, `onPlayers`, `onStatus`, and `onMessage` call returns a function that removes that listener.\n\n## Voice\n\nEvery room has a `room.voice` object. Voice is disabled by default and is enabled with the manifest's `voice` field. A game should offer an explicit control because `join()` must be called from a click or another user gesture so the browser can start audio and, when publishing, request microphone permission.\n\n```js\nconst micButton = document.querySelector('#mic');\nconst voiceList = document.querySelector('#voice-list');\n\nfunction renderVoice(peers = room.voice.peers) {\n voiceList.replaceChildren(...peers.map((peer) => {\n const item = document.createElement('li');\n const player = room.players.find((entry) => entry.id === peer.id);\n item.textContent = `${player?.name ?? peer.id}: ${\n peer.speaking ? 'speaking' : peer.muted ? 'muted' : 'quiet'\n }`;\n return item;\n }));\n micButton.textContent = room.voice.state === 'off'\n ? 'Join voice'\n : !room.voice.mic ? 'Listening' : room.voice.muted ? 'Unmute' : 'Mute';\n}\n\nmicButton.addEventListener('click', async () => {\n if (room.voice.state === 'off') await room.voice.join();\n else if (room.voice.mic) room.voice.mute(!room.voice.muted);\n renderVoice();\n});\n\nroom.voice.onPeers(renderVoice);\nroom.voice.onState(() => renderVoice());\nrenderVoice();\n```\n\n`room.voice.mode` is `none`, `room`, `team`, or `proximity`. In `room` mode, every participant in voice can hear every other participant. In `team` mode, players hear only their team. In `proximity` mode, the room server controls the gain between participants. Call `room.voice.join({ mic: false })` to listen without opening or publishing a microphone. Spectators join in listening mode when they call `join()` without options. A spectator that calls `join({ mic: true })` receives the `spectator` error.\n\n`room.voice.state` is `off`, `joining`, `on`, or `reconnecting`. `room.voice.mic` is `true` while the local player is publishing. `room.voice.muted` and `room.voice.speaking` describe the local microphone. `room.voice.peers` contains the other voice participants as `{ id, mic, muted, speaking, volume, gain }`. A listening participant has `mic: false`, `muted: true`, and `speaking: false`. `volume` is the local setting and `gain` is the value from the room server. Use `room.voice.setVolume(playerId, volume)` with a value from 0 to 1 to change only local playback.\n\n`room.voice.onPeers(listener)` runs when participants, microphone state, mute state, speaking state, volume, or gain changes. `room.voice.onState(listener)` reports connection state changes. Both return a function that removes the listener.\n\nCall `room.voice.leave()` to stop publishing or listening without leaving the room. `room.voice.mute()` requires an active published microphone and otherwise throws `not_publishing`. `room.leave()` and the end of the room stop voice automatically.\n\n`join()` rejects with an `Error` carrying one of these stable codes: `voice_disabled`, `permission_denied`, `unsupported`, `spectator`, `offline`, or `voice_error`. Voice can reconnect after a temporary room or media connection failure. The state becomes `reconnecting` while the kit retries.\n\n## Server\n\nPut `server.js` next to `caisual.json` and publish it with the game. See [publish.md](./publish.md#multiplayer-server) for the file rules, validation, and publishing flow.\n\n```js\nimport { defineGame } from '@caisual/kit/server';\n\nexport default defineGame({\n tickRate: 20, // 0 runs only in response to events\n onCreate(room) {},\n onStart(room) {},\n onJoin(room, player) {},\n onLeave(room, player, reason) {}, // \"left\", \"timeout\", or \"kicked\"\n onMessage(room, player, message) {},\n onTick(room, deltaSeconds) {},\n onEnd(room) {},\n});\n```\n\nAll callbacks are optional. A player is `{ id, name, guest, role, team, connected }`. The room object provides:\n\n```js\nroom.id;\nroom.seed;\nroom.mode;\nroom.status;\nroom.tick;\nroom.tickRate;\nroom.result;\nroom.state;\nroom.players;\nroom.host;\n\nroom.broadcast(message);\nroom.send(playerOrId, message);\nroom.kick(playerOrId);\nroom.setRole(playerOrId, role);\nroom.setTeam(playerOrId, team);\nroom.end(result);\n\nawait room.save('round', value);\nawait room.load('round');\nawait room.shared.get('ship_abc');\nawait room.shared.set('ship_abc', value);\nawait room.shared.delete('ship_abc');\nawait room.shared.list('ship_');\nawait room.shared.increment('visits', 1);\nroom.schedule(milliseconds, 'methodName', payload);\nroom.board.submit(playerOrId, 'main', score, { daily: true });\n\nroom.daily.day;\nroom.daily.seed;\nroom.time.now();\n\nroom.voice.mode;\nroom.voice.setGain(listener, speaker, 0.25);\nroom.voice.setProximity(playerA, playerB, 0.5);\n```\n\nSet `room.state` in `onCreate`, then mutate it only in server callbacks. It must remain plain JSON and may be at most 256 KB when serialized. `broadcast` sends a JSON message to everyone; `send` targets one player. `end` records a JSON result and ends the room. Room saves use keys with the same format as player save keys and values up to 128 KB. `schedule` names a method on the definition so it can run even after a quiet room resumes. Scores submitted through `room.board` are verified.\n\nThe browser can change its role or team only while the room is in `lobby`. During a match, the server decides when a player changes role or team with `room.setRole` and `room.setTeam`. Both methods accept a player object or id and immediately update `room.players` for every client.\n\n```js\nonMessage(room, player, message) {\n if (message?.swap === 'captain') {\n room.setRole(player, 'captain');\n }\n},\n```\n\n`room.daily.seed` is shared by every room for the game on the current UTC day. `room.seed` is fixed for one room and is identical on the server and clients, so rooms created on the same day can generate different maps.\n\n### Shared game store\n\n`room.shared` is a server-only JSON key/value store shared by every room of the same game. It is useful when one room must leave data for another room, while `room.save` remains private to one room.\n\nThe following server leaves a ship when a room ends, then loads every previously left ship when another room is created. The room id suffix is used because shared-store keys follow the save-key format.\n\n```js\nexport default defineGame({\n tickRate: 0,\n\n async onCreate(room) {\n const keys = await room.shared.list('ship_');\n room.state = {\n ships: await Promise.all(keys.map((key) => room.shared.get(key))),\n };\n },\n\n async onEnd(room) {\n const roomSuffix = room.id.split('.')[1];\n await room.shared.set('ship_' + roomSuffix, {\n position: room.state.position,\n cargo: room.state.cargo,\n });\n },\n});\n```\n\nThe five methods are asynchronous:\n\n```js\nconst value = await room.shared.get(key); // JSON value, or null\nawait room.shared.set(key, value); // last writer wins\nawait room.shared.delete(key);\nconst keys = await room.shared.list(prefix); // sorted, up to 1024\nconst total = await room.shared.increment(key, 1); // atomic, defaults to 1\n```\n\nKeys contain 1 to 32 lowercase letters, numbers, underscores, or hyphens. Values may be up to 64 KB when serialized, and each game may keep up to 1024 keys. Each room may perform up to 120 shared-store operations per minute. `increment` treats a missing key as zero and rejects unless the existing value, amount, and result are safe integers.\n\nUse the store in `onCreate`, `onStart`, `onEnd`, `onMessage`, or a `schedule` handler. Do not call it on every tick: each call waits for a remote operation, and the CPU budget uses elapsed wall-clock time. Browser clients cannot access this store. Send only the data they need with `room.broadcast` or `room.send`.\n\nFailures reject with an `Error` carrying `store_invalid_key`, `store_too_large`, `store_full`, `store_not_integer`, `store_unavailable`, or `store_rate_limited` in `code`.\n\n`room.voice.setGain(listener, speaker, gain)` controls how much one listener hears one speaker. It is directional, limited to the range from 0 to 1, and rounded to two decimal places. For example, the following setup lets the captain hear everyone while each crew member hears only the captain:\n\n```js\nconst captain = room.players.find((player) => player.role === 'captain');\nconst crew = room.players.filter((player) => player.id !== captain.id);\n\nfor (const speaker of room.players) {\n room.voice.setGain(captain, speaker, 1);\n}\nfor (const listener of crew) {\n for (const speaker of room.players) {\n room.voice.setGain(listener, speaker, speaker.id === captain.id ? 1 : 0);\n }\n}\n```\n\n`room.voice.setProximity(a, b, gain)` is the symmetric shortcut for setting both directions. Both methods work in `room`, `team`, and `proximity` modes, and do nothing in `none`. In `team` mode, gains remain inside the team and cannot make a player hear another team.\n\nFor position-based audio, update the symmetric gain between players from server-owned positions:\n\n```js\nexport default defineGame({\n tickRate: 20,\n onTick(room) {\n for (const a of room.players) {\n for (const b of room.players) {\n if (a.id >= b.id) continue;\n const pa = room.state.positions[a.id];\n const pb = room.state.positions[b.id];\n const distance = Math.hypot(pa.x - pb.x, pa.y - pb.y);\n room.voice.setProximity(a, b, Math.max(0, 1 - distance / 20));\n }\n }\n },\n});\n```\n\n### Sleeping and cost\n\nPrefer `tickRate: 0` for turn based and party games. A room with a tick loop sleeps automatically after 30 seconds without player input or state changes and wakes on the next game message or player joining. Automatic ping and resync messages do not count as player input. A match with no player input for 10 minutes ends with `{ error: 'idle' }`. Timers set with `schedule` and the countdown keep working while the room sleeps.\n\n### CPU budget\n\nEvery `onTick` and `onMessage` call is measured. Twenty consecutive calls above 100 ms end the room with `{ error: 'cpu_budget' }`. If the average over 50 ticks is above 20 ms, the effective `tickRate` is halved, down to a minimum of 5, and clients receive an `error` message with code `tick_rate_reduced`.\n\n`room.tickRate` starts at the definition's `tickRate` and always reports the current effective frequency. `deltaSeconds` follows that frequency, so a fixed-step simulation must accumulate `deltaSeconds` instead of counting ticks. Measurement uses elapsed wall-clock time, so a slow `await` inside a callback also counts. `room.result` is `null` until the room ends, then contains the final game or automatic error result and is available inside `onEnd`.\n\n### Persistent rooms\n\nSet `\"persistent\": true` in `caisual.json` for a room that must survive long breaks. It does not use the normal inactivity ending rule and does not end when every player disconnects. Players remain members until they call `room.leave()` or the server removes them with `room.kick()`. They can use the same room code to return while the game is already playing. The code remains valid while the room lives, and absent members remain in `room.players` with `connected: false`.\n\nA persistent room ends when the server calls `room.end()`, after 30 days without player input, entry, or a state change with `{ error: 'expired' }`, or after five minutes without any members. Consider storing `room.code` with `c.save.set()` and offering a Resume action. A persistent room incurs cost only while it is awake.\n\n## Limits\n\n- 120 requests per minute per player. Beyond that the kit rejects with `rate_limited`; wait and retry.\n- Saves: 32 keys per player per game, 256 KB per value.\n- Scores: safe integers from 0 upward.\n- Room state: 256 KB of plain JSON.\n- Room messages: 16 KB each and 20 messages per second per connection.\n- Voice supports audio only and one voice channel per room.\n- Voice control messages: 64 KB each and 30 operations per 10 seconds per connection. Voice traffic is not counted against the room's message limits.\n- Room save values: 128 KB each.\n- Shared game store: 64 KB per JSON value, 1024 keys per game, and 120 operations per minute per room.\n\n## Development\n\nRun `npx @caisual/cli dev` from the game folder, then open the printed portal URL. The preview supplies the normal handshake, so `c.connected` is `true` and the game receives a local guest identity. Saves, leaderboards, daily data, invitations, and rooms work locally. Opening the portal URL in more browser tabs creates more local players, which makes multiplayer testing possible without publishing.\n\nWhen building the client with a bundler, remember that `client/` is served as-is. Configure Vite, esbuild, or another bundler to write into that folder, for example `vite build --outDir client`, and use relative paths such as `base: './'`.\n\nKeep loading the kit from the `<script type=\"module\">` shown at the beginning of this guide, using `/__caisual/kit/v1.js`.\n\nIf the game has `server.js`, room state is handled locally and stored under `.caisual-dev/` in the game folder. If it has no `server.js`, room creation rejects with `no_server` and the single-player APIs still work.\n\nOpening `client/index.html` from a plain static server still uses standalone mode: `c.connected` is `false`, saves use local storage, `submit` returns `accepted: false`, leaderboards are empty, the daily seed is local, and room creation and joining reject with `offline`. The rest of the game logic does not need a different code path.\n\nAfter publishing with `npx @caisual/cli publish`, open the game from its caisual.com page: `c.connected` becomes `true` and every call goes to the portal.\n\n## Manifest\n\nNo manifest field is required for identity, saves, leaderboards, or the daily challenge. For rooms, set `players` to the supported range and use `lobby`, `persistent`, `roles`, `teams`, and `modes` to describe the setup and lifetime. Set `voice` to `room`, `team`, or `proximity` to enable the corresponding voice mode, or omit it for `none`. A single-player game can keep `players` at `{ \"min\": 1, \"max\": 1 }`, `lobby` at `false`, and omit `server.js`. See [publish.md](./publish.md#caisualjson) for every field and the publishing steps.\n";
|
|
715
|
+
var kit_default = "# Caisual game kit\n\nThe kit gives a published game a stable player identity, cloud saves, leaderboards, a daily challenge seed, and multiplayer rooms with server-owned state.\n\nThe kit is a single JavaScript module with no dependencies. It never touches the page: the game keeps its own rendering, input, and UI.\n\n## Load the kit\n\nEvery published game can import the kit from its own origin, without a bundler and without an npm install:\n\n```html\n<script type=\"module\">\n import { caisual } from '/__caisual/kit/v1.js';\n\n const c = await caisual.connect();\n console.log(c.player.name);\n</script>\n```\n\nGames built with a bundler can install the same module from npm:\n\n```sh\nnpm install @caisual/kit\n```\n\n```js\nimport { caisual } from '@caisual/kit';\n```\n\nBoth forms expose the same API. The module also sets `globalThis.caisual` for classic scripts that load it first.\n\nThe path `/__caisual/` is reserved on every game origin. Do not put game files under it.\n\n## Connect\n\n```js\nconst c = await caisual.connect();\n```\n\n`connect()` completes when the game is running inside caisual.com and has received its player identity, or after a short timeout when it is not. Calling it again returns the same promise.\n\n- `c.connected` is `true` inside caisual.com and `false` when the game runs on its own, for example from a local folder during development or when its files are copied elsewhere.\n- `c.player` is `{ id, name, guest }`. `id` is stable for the player across sessions and across every version of the game. `name` is the account username, or `\"Guest\"`. `guest` is `true` for players without an account. When a guest later signs in, saves and scores stay attached to the same `id`.\n- When not connected, `c.player` is `{ id: \"local\", name: \"Guest\", guest: true }`.\n\nDo not store the ticket or reimplement the handshake. The kit handles identity, renewal, and retries.\n\n## Sessions and the standard overlay\n\nA game that declares `overlay` in `caisual.json` is a standard game: it runs full screen and Caisual draws everything around it. The opening menu, the mode choice, the lobby with roles, teams and ready, invitations, friends and parties, matchmaking, spectators, leaderboards, voice, the end of a match and Play again belong to the platform. The game keeps the field, its own HUD and its own settings.\n\n```json\n{ \"overlay\": { \"version\": 1, \"accent\": \"#397e83\" } }\n```\n\nTwo objects appear on the connection. `c.session` says which session the game is in, `c.overlay` says when the platform is on top of it.\n\n```js\nconst c = await caisual.connect();\n\nconst stopSession = c.session.onChange((session) => {\n detachGameListeners();\n if (session.kind === 'idle') return showAttractScene();\n if (session.kind === 'local') return showLocalRun(session.mode, session.status);\n attachGameListeners(session.room, session.kind === 'watch');\n draw(session.room.state);\n});\n\nconst stopOverlay = c.overlay.onChange(({ inputBlocked, reservedRects }) => {\n clearHeldKeys();\n setInputEnabled(!inputBlocked);\n placeHudOutside(reservedRects);\n});\n\nawait loadAssetsAndChosenView();\nc.session.ready();\n```\n\n### The session\n\n`c.session.current` reads the session at once. `onChange` repeats the current value immediately to every new listener, returns a function that removes it, and then reports attaches, detaches and the end of a local run. It never fires for a move or a roster change: those stay on the room listeners.\n\n- `{ kind: 'idle' }`: no session. Show an attract scene, not a menu.\n- `{ kind: 'local', id, mode, status }`: a run of a mode declared with `\"execution\": \"local\"`. `status` is `playing` or `ended`.\n- `{ kind: 'room', id, room }`: `room` is the `Room` documented below, already attached.\n- `{ kind: 'watch', id, room }`: `room` is a `Spectate`. Draw it read only.\n\n`id` changes on every attach, so a second local run is distinguishable from the first.\n\n`c.session.ready()` says the game has loaded its assets and installed its listeners. Call it once, at the end of setup: until then the overlay waits instead of starting a session under a game that is still downloading. It is idempotent.\n\n`c.session.finish()` ends a local run and returns to the standard menu with a Play again action. It applies only to `kind: 'local'`: on a room it fails with `not_local` and on idle it does nothing. The result of an online match comes from the server, never from `finish()`.\n\n`c.session.capabilities` is `{ local, rooms, overlay, requestRole }`. Outside caisual.com `overlay` and `rooms` are `false` while `local` stays `true`, which is the signal to run the game's own offline fallback. No fake room is created.\n\nTwo front ends of the same game share one session. Switching view does not call `ready()` again and does not detach the room: the new renderer reads `c.session.current` and draws.\n\n### The overlay on top\n\n`c.overlay.onChange` repeats the current geometry immediately, then on every change:\n\n- `inputBlocked` is `true` while a panel is open. Release held keys and stop reading input, but keep simulating: opening a panel never pauses a room.\n- `reservedRects` is up to eight rectangles, in CSS pixels of the game viewport. Keep the game's own HUD out of them. The field under a closed overlay stays visible and clickable.\n\n`c.overlay.open(panel)` asks the platform to open one of `home`, `room`, `invite`, `friends`, `voice`, `boards`. It is a request, not a permission: it creates no room and grants nothing. Outside caisual.com it does nothing.\n\nShift+Tab from the field opens the menu and Escape closes it. Text fields inside the game keep their own shortcut.\n\n### What a standard game no longer builds\n\nRemove these and let the overlay do them:\n\n- a start menu with Create, Join or a code field;\n- invitation links, copy buttons and share sheets;\n- the lobby: roster, ready, role and team pickers, the Start button;\n- a matchmaking screen with its cancel button;\n- a friends or party list;\n- voice buttons;\n- a leaderboard screen;\n- an Exit or Back to Caisual button;\n- a Play again button after a match.\n\nThe game still draws its own result inside the field. `c.room.create`, `join`, `match` and `watch` stay available for a game that wants its own entry point: under a standard overlay the room they return becomes the current session and the standard controls follow it. Do not attach a second room controller from a second renderer.\n\n### Full screen\n\nA standard game fills the window. `html`, `body` and the game surface are 100% of the viewport: no maximum width, no header, no footer, no editorial frame, and no document scrolling at 1366x768 or 390x844, safe areas included. Only the HUD and compact controls sit over the scene.\n\nAim for the playable field to cover at least 70% of the visible area on desktop and 60% on mobile, counting only what shows or controls the game. A board with a fixed aspect ratio cannot always reach that: a square board on a 1366x768 window tops out near 56% before any HUD. That is the declared geometric exception: the board must then fill at least 90% of the largest rectangle that fits the area left free, and the HUD must have a stated ceiling, typically 48 to 64 pixels on desktop and about 160 pixels of controls on a phone.\n\n### Resume\n\nCaisual keeps one resume reference per game and per player, in a save key it owns. Leaving through the overlay with Leave for now stores the room code and detaches without giving up the seat; the standard menu then offers Resume, which rejoins from that code. Leave room removes the reference and gives up the seat, and so does a normal end of match. A network drop keeps it.\n\nA game does not read or write that key, and does not build its own Resume button. A reference that is no longer valid returns the service error and the overlay explains it: it does not retry forever. Resume carries the room code, not a promise to reopen the same published version of the game.\n\n### Voice and leaderboards\n\nVoice belongs to the overlay panel, together with the microphone gesture the browser requires. The panel is already in place; while its controls are still arriving, a standard game simply does not draw voice buttons. `room.voice` in this guide keeps working for games that already have their own controls.\n\nLeaderboards are read by the overlay from the published manifest, using the boards and periods declared there. The overlay reads the official verified scores after a submission and offers Refresh: a game does not need a board screen. Scores are still submitted by the game or, better, by `server.js`.\n\n### Known gaps\n\nFour things are deliberately not in this version, and a game should not work around them:\n\n- continuing with the same company: Play again opens a new room and copies the invitation, without moving the other players;\n- resolving the original game version behind a persistent Resume;\n- inviting one friend straight into a room, as opposed to a party;\n- matchmaking for a whole group at once.\n\n## Daily challenge\n\n```js\nc.daily.day; // \"2026-09-04\", the current UTC day\nc.daily.seed; // unsigned 32-bit integer, identical for every player on that day\nconst r = c.daily.random(); // number in [0, 1), deterministic from the seed\n```\n\n`c.daily.random()` is a deterministic generator initialized from `c.daily.seed`. Every `connect()` starts the sequence from the beginning, so two players who call it the same number of times get the same values. Use it to build the level of the day.\n\n`c.time.now()` returns milliseconds aligned with the portal clock. Prefer it to `Date.now()` for anything that must agree with the current day.\n\nWhen not connected, `day` comes from the local clock and `seed` from the local hostname, so a game copied elsewhere still runs.\n\n## Saves\n\nEach player has up to 32 saves per game. A save is any JSON value up to 256 KB when serialized.\n\n```js\nawait c.save.set('slot1', { level: 3, coins: 120 }); // -> { key, bytes, updatedAt }\nconst data = await c.save.get('slot1'); // -> the value, or null\nawait c.save.remove('slot1');\nconst saves = await c.save.list(); // -> [{ key, bytes, updatedAt }]\n```\n\n- Keys use 1 to 32 characters: lowercase letters, digits, `_` or `-`, starting with a letter or digit.\n- `updatedAt` is a millisecond timestamp.\n- Saves are per player and per game. Another game cannot read them.\n- When not connected, saves go to the browser's local storage on the game origin.\n\nErrors reject the promise with an `Error` whose `code` is one of `invalid_request`, `not_found`, `save_limit`, `payload_too_large`, `rate_limited`, `invalid_ticket`, `internal_error`, or `offline`.\n\n## Leaderboards\n\nA leaderboard is identified by a board id chosen by the game. Scores are non-negative integers and higher is better. Each player keeps one entry per board, and one per board per day for daily boards: the best score is kept.\n\n```js\nconst result = await c.board.submit('main', 1234);\n// -> { accepted: true, best: 1234, rank: 7, day: null, verified: false }\n\nconst daily = await c.board.submit('main', 1234, { daily: true });\n// -> { accepted: true, best: 1234, rank: 7, day: \"2026-09-04\", verified: false }\n\nconst top = await c.board.top('main', { daily: true, limit: 10 });\n// -> { day: \"2026-09-04\", entries: [{ rank, name, score, guest, me, verified }], me: { rank, score, verified } | null }\n```\n\n- Board ids use the same format as save keys.\n- `submit` never rejects because of connectivity. When the game is not connected it resolves `{ accepted: false, reason: \"offline\" }`.\n- `best` is the score kept for this player after the submission, which can be higher than the submitted one.\n- `rank` counts players with a strictly higher score. Ties are ordered by who reached the score first.\n- Accounts and guests are ranked separately. `top()` returns account players by default; pass `guests: true` to list guests instead. `me` always refers to the current player within their own category, even beyond `limit`.\n- `limit` is 1 to 100 and defaults to 10.\n- Pass `day: \"2026-09-06\"` to `top()` to read exactly that UTC day, even after midnight. A day implies the daily filter. A date that is not a real `YYYY-MM-DD` is rejected.\n- `verified` is `true` when the kept score came from the room server. Browser scores cannot replace a verified score.\n- Add `\"boards\": { \"main\": { \"source\": \"server\" } }` to `caisual.json` for a server-only board. It accepts scores only from `room.board.submit` in `server.js`.\n- An omitted board has `source: \"client\"`. Browser submissions keep working for existing games.\n- A browser submission to a server-only board rejects with `board_server_only`.\n\n## Device\n\n`c.device` contains the browser and device report collected while `connect()` runs:\n\n```ts\ninterface DeviceReport {\n webgl2: boolean;\n webgpu: boolean;\n wasm: boolean;\n threads: boolean;\n isolated: boolean;\n gpu: 'hardware' | 'software' | 'none';\n memoryMb: number | null;\n cores: number | null;\n mobile: boolean;\n tier: 'low' | 'mid' | 'high';\n}\n```\n\nUse capability fields to choose a renderer, then use `tier` to reduce pixel ratio and quality on smaller devices:\n\n```js\nconst renderer = c.device.webgpu\n ? createWebGpuRenderer()\n : createWebGl2Renderer();\n\nconst pixelRatio = c.device.tier === 'high' ? devicePixelRatio : 1;\nconst quality = c.device.tier === 'low' ? 'low' : 'high';\nrenderer.configure({ pixelRatio, quality });\n```\n\nThe probe takes at most 1.5 seconds. `memoryMb` and `cores` are `null` when the browser does not expose them. The report stays in the browser and is not saved or sent to Caisual.\n\n### Two front ends, one game\n\nKeep one `client/index.html`, one game ID and one server. With `platform: \"both\"`, choose separate front ends in that entry without navigating or adding another iframe:\n```js\nimport { caisual } from '/__caisual/kit/v1.js';\nconst c = await caisual.connect();\nlet preference = null;\ntry { preference = localStorage.getItem('layout'); } catch {}\nconst touch = preference === 'touch' || (preference !== 'desktop' && (c.device.mobile || matchMedia('(pointer: coarse)').matches));\nconst screen = touch ? await import('./touch/main.js') : await import('./desktop/main.js');\nscreen.mount({ c, root: document.querySelector('#app') });\n```\nOffer a manual layout choice, persist it when storage is available, and keep rules and room connections shared. Both front ends use relative asset paths inside `client/`.\n\n## Rooms\n\nA room brings players into the same running game. Creating and joining require a published `server.js`; single-player games can ignore `c.room`.\n\nIn a standard game the overlay creates, joins, matches and watches on the player's behalf, and hands the game the room through `c.session`. The calls below stay available, and their result becomes the current session. Read them for what the room object offers; do not rebuild the entry screens around them.\n\n```js\nconst c = await caisual.connect();\n\nc.room.invited; // invitation code from the game page, or null\n\nconst room = await c.room.create({ mode: null });\n// Or join the invitation that opened the game:\nconst invitedRoom = await c.room.join();\n// Or enter a code supplied by the player:\nconst codedRoom = await c.room.join('ABC234');\n\nroom.code;\nroom.seed; // unsigned 32-bit integer fixed for this room\nroom.invite(); // { code: \"ABC234\", url: \"https://caisual.com/r/ABC234\" }\n```\n\nPass a mode id from the manifest to `create({ mode })`, or `null` to use the root configuration. Optional `players` and `lobby` on that mode replace the root values; joining keeps the configuration of the room being joined. `join()` uses `c.room.invited`; without an invitation, pass the six-character code explicitly. A standard game does not need `invite()`: the overlay owns the invitation panel and the copy action.\n\n### Crew\n\nThe kit automatically reports the player's current room to the Caisual portal, so the player's friends can join with one click. The game does not need to send or handle anything for this. There is no `c.crew` API in this version. In a standard game the friends and party list is a panel of the overlay, so there is nothing to draw either.\n\n### Matchmaking\n\nUse `c.room.match()` to find players who requested the same mode and key. The key must contain exactly the fields declared by that mode's `matchmaking.key` in `caisual.json`.\n\n```js\nconst room = await c.room.match({\n mode: 'daily',\n key: { day: c.daily.day, stage: 3 },\n onWaiting({ players, min, max }) {\n showQueue(`${players}/${max} players, ${min} required`);\n },\n});\n```\n\nMatchmaking uses the selected mode's resolved `players` and `lobby`, and that mode's `matchmaking.timeoutMs`. A room opens as soon as the queue reaches the resolved `players.max`. When `matchmaking.timeoutMs` expires, it also opens if at least `players.min` players are waiting. Otherwise the promise rejects with `no_match`, and the game should offer the player another option. A new search first tries to fill a matching room that is already open and can still accept players.\n\nPass an `AbortSignal` as `signal` to let the player cancel a search. Cancellation rejects with `cancelled`. In a standard game the search screen, its Cancel button and the lobby that follows are the overlay's: declare `matchmaking.defaults` in the mode and the player can start a search from the standard menu without the game passing a key.\n\nRoom status is one of:\n\n- `lobby`: players are joining and choosing their setup.\n- `countdown`: the lobby has accepted `start()` and play begins at the announced server time.\n- `playing`: the game server is running the match.\n- `ended`: the match or connection has ended. `room.result` contains the result last reported by the room. A definitive connection closure uses `{ closed: 4003 }`, `{ closed: 4004 }`, `{ closed: 4005 }`, or `{ closed: 4006 }`.\n\nThe current lobby data is available directly:\n\n```js\nroom.players; // [{ id, name, guest, role, team, ready, connected }]\nroom.you; // this player's id\nroom.host; // the current host's id, or null\n\nroom.ready(true);\nroom.setRole('captain');\nroom.setTeam(1);\n\nif (room.you === room.host) room.start();\n```\n\nIn a standard game the overlay calls these four for the player: read `room.players` to draw the field, not to build a roster panel. `ready`, role, team, and `start()` are lobby actions. Starting requires the host, every connected player to be ready, and the player, role, and team minimums from the manifest. Calling `start()` begins a three-second countdown. A role or team change clears that player's ready state. The built-in `spectator` role is still a player slot for setups such as a shared screen with phone controllers. Use `watch()` for someone who only observes and does not occupy a player slot.\n\nThe server owns room state. Read it and react to updates, but do not assign to it or mutate nested values from the browser:\n\n```js\ndraw(room.state);\n\nconst stopState = room.onState((state, tick, serverTime) => {\n draw(state);\n});\n\nconst stopPlayers = room.onPlayers((players) => updateLobby(players));\nconst stopStatus = room.onStatus((status, result, at) => showStatus(status, result, at));\n\nstopState();\nstopPlayers();\nstopStatus();\n```\n\n`room.tick` identifies the latest state. The kit applies structural updates in order for both tick-based and event-only servers. Full state is sent on entry, reconnection or resync when an update does not match the current tick; normal updates, including every hundredth tick, remain diffs. `room.serverTime()` returns milliseconds aligned with the room clock and is kept current by a ping every five seconds.\n\nSend JSON input to `onMessage` in the server definition, and receive JSON sent or broadcast by the server:\n\n```js\nroom.send({ type: 'fire', target: 3 });\n\nconst stopMessages = room.onMessage((message) => {\n showEvent(message);\n});\n```\n\nThe kit numbers outgoing inputs in increasing order. It automatically reconnects temporary failures with delays of 1, 2, 4, then 8 seconds, for at most the room's 60-second grace period. Each attempt gets a fresh room token. A successful reconnect replaces local state with a full server state. Inputs sent while reconnecting throw an error with `code: \"offline\"`.\n\nCall `room.leave()` for an intentional departure. The kit does not reconnect after leaving, being kicked, the room ending, the published version closing, or the same player opening the room in another tab.\n\n`room.disconnect()` is the other departure: it stops the transport, the retries and voice without sending a leave, so the server keeps the seat under its own persistence and grace rules. It is not reversible on the same object; returning means entering again from the code. The overlay uses it for Leave for now, together with the resume reference.\n\nA room also exposes `room.mode`, `room.countdownAt`, `room.connection`, `room.metadata`, and the `onMetadata` and `onConnection` listeners. `connection` is one of `connecting`, `connected`, `reconnecting`, `disconnected`, `ended`, `closed`, or `replaced`, where `replaced` means the same player opened the room in another tab. Unlike `session.onChange` and `overlay.onChange`, these listeners do not repeat the current value: read the getter first.\n\n`await room.requestRole('scout')` asks the server for a role change during a match. It works only while the room is playing, only for a role declared in the manifest, and only when `server.js` defines `onRoleRequest(room, player, role)`; the server approves by calling `room.setRole`. Without that callback nothing changes, and the capability shows as `false` in `c.session.capabilities`. It is not a shortcut for changing roles from the browser.\n\nRoom creation, joining, and matchmaking reject with an `Error` carrying a stable `code`. Common codes are `invalid_request`, `no_server`, `no_match`, `cancelled`, `room_not_found`, `room_full`, `room_playing`, `room_ended`, `rate_limited`, `invalid_ticket`, `internal_error`, and `offline`. `no_server` means the published game has no multiplayer server. When `c.connected` is `false`, `create`, `join`, and `match` reject with `offline`.\n\nEvery `onState`, `onPlayers`, `onStatus`, and `onMessage` call returns a function that removes that listener.\n\n### Spectators\n\nIn a standard game the overlay offers watching from the menu and the session arrives as `{ kind: 'watch' }`. Use `c.room.watch(code)` to observe a room without joining it as a player:\n\n```js\nconst view = await c.room.watch('ABC234');\n\ndraw(view.state);\nview.onState((state) => draw(state));\nview.onPlayers((players) => updateRoster(players));\nview.onStatus((status, result) => showStatus(status, result));\nview.onMessage((message) => showEvent(message));\n\nview.leave();\n```\n\nThe returned `Spectate` object exposes `state`, `tick`, `seed`, `status`, `players`, `host`, `code`, `result`, `delayMs`, the four listeners shown above, `serverTime()`, and `leave()`. It receives the room's public state, snapshots and updates, player list, status, and messages broadcast by `server.js`. The kit repairs a missed update automatically and reconnects temporary failures for the same 60-second grace period used by players.\n\nPublic room events are delayed by `delayMs`, which defaults to 3000 milliseconds. A game can set `\"spectators\": { \"delayMs\": N }` in `caisual.json`, where `N` is from 0 to 30000, or set `\"spectators\": false` to disable watching.\n\nA spectator has no `you`, `invite()`, `send()`, or voice API. Watching does not add anyone to `room.players`, does not affect roles, teams, player minimums, the host, or room lifetime, and is not visible to `server.js`. `watch()` can reject with `room_not_found`, `room_ended`, `spectators_disabled`, `spectators_full`, `rate_limited`, `offline`, or `invalid_request`.\n\n## Voice\n\nEvery room has a `room.voice` object. Voice is disabled by default and is enabled with the manifest's `voice` field.\n\nIn a standard game voice belongs to the overlay panel, with the click the browser requires. The panel is in place and its controls are arriving; until then a standard game simply draws no voice buttons. The rest of this section is the API used by games that keep their own controls, and it stays supported.\n\nA game with its own controls must offer an explicit one, because `join()` must be called from a click or another user gesture so the browser can start audio and, when publishing, request microphone permission.\n\n```js\nconst micButton = document.querySelector('#mic');\nconst voiceList = document.querySelector('#voice-list');\n\nfunction renderVoice(peers = room.voice.peers) {\n voiceList.replaceChildren(...peers.map((peer) => {\n const item = document.createElement('li');\n const player = room.players.find((entry) => entry.id === peer.id);\n item.textContent = `${player?.name ?? peer.id}: ${\n peer.speaking ? 'speaking' : peer.muted ? 'muted' : 'quiet'\n }`;\n return item;\n }));\n micButton.textContent = room.voice.state === 'off'\n ? 'Join voice'\n : !room.voice.mic ? 'Listening' : room.voice.muted ? 'Unmute' : 'Mute';\n}\n\nmicButton.addEventListener('click', async () => {\n if (room.voice.state === 'off') await room.voice.join();\n else if (room.voice.mic) room.voice.mute(!room.voice.muted);\n renderVoice();\n});\n\nroom.voice.onPeers(renderVoice);\nroom.voice.onState(() => renderVoice());\nrenderVoice();\n```\n\n`room.voice.mode` is `none`, `room`, `team`, or `proximity`. In `room` mode, every participant in voice can hear every other participant. In `team` mode, players hear only their team. In `proximity` mode, the room server controls the gain between participants. Call `room.voice.join({ mic: false })` to listen without opening or publishing a microphone. Spectators join in listening mode when they call `join()` without options. A spectator that calls `join({ mic: true })` receives the `spectator` error.\n\nThe room server authorizes every voice track by team and gain. Listening that is no longer allowed is refused or closed.\n\n`room.voice.state` is `off`, `joining`, `on`, or `reconnecting`. `room.voice.mic` is `true` while the local player is publishing. `room.voice.muted` and `room.voice.speaking` describe the local microphone. `room.voice.peers` contains the other voice participants as `{ id, mic, muted, speaking, volume, gain }`. A listening participant has `mic: false`, `muted: true`, and `speaking: false`. `volume` is the local setting and `gain` is the value from the room server. Use `room.voice.setVolume(playerId, volume)` with a value from 0 to 1 to change only local playback.\n\n`room.voice.onPeers(listener)` runs when participants, microphone state, mute state, speaking state, volume, or gain changes. `room.voice.onState(listener)` reports connection state changes. Both return a function that removes the listener.\n\nCall `room.voice.leave()` to stop publishing or listening without leaving the room. `room.voice.mute()` requires an active published microphone and otherwise throws `not_publishing`. `room.leave()` and the end of the room stop voice automatically.\n\n`join()` rejects with an `Error` carrying one of these stable codes: `voice_disabled`, `permission_denied`, `unsupported`, `spectator`, `offline`, or `voice_error`. Voice can reconnect after a temporary room or media connection failure. The state becomes `reconnecting` while the kit retries.\n\n## Server\n\nPut `server.js` next to `caisual.json` and publish it with the game. See [publish.md](./publish.md#multiplayer-server) for the file rules, validation, and publishing flow.\n\n```js\nimport { defineGame } from '@caisual/kit/server';\n\nexport default defineGame({\n tickRate: 20, // 0 runs only in response to events\n onCreate(room) {},\n onStart(room) {},\n onJoin(room, player) {},\n onLeave(room, player, reason) {}, // \"left\", \"timeout\", or \"kicked\"\n onMessage(room, player, message) {},\n onTick(room, deltaSeconds) {},\n onEnd(room) {},\n});\n```\n\nAll callbacks are optional. A player is `{ id, name, guest, role, team, connected }`. The room object provides:\n\n```js\nroom.id;\nroom.seed;\nroom.mode;\nroom.status;\nroom.tick;\nroom.tickRate;\nroom.result;\nroom.state;\nroom.players;\nroom.host;\n\nroom.broadcast(message);\nroom.send(playerOrId, message);\nroom.kick(playerOrId);\nroom.setRole(playerOrId, role);\nroom.setTeam(playerOrId, team);\nroom.end(result);\n\nawait room.save('round', value);\nawait room.load('round');\nawait room.shared.get('ship_abc');\nawait room.shared.set('ship_abc', value);\nawait room.shared.delete('ship_abc');\nawait room.shared.list('ship_');\nawait room.shared.increment('visits', 1);\nroom.schedule(milliseconds, 'methodName', payload);\nroom.board.submit(playerOrId, 'main', score, { daily: true });\n\nroom.daily.day;\nroom.daily.seed;\nroom.time.now();\n\nroom.voice.mode;\nroom.voice.setGain(listener, speaker, 0.25);\nroom.voice.setProximity(playerA, playerB, 0.5);\n```\n\nSet `room.state` in `onCreate`, then mutate it only in server callbacks. It must remain plain JSON and may be at most 256 KB when serialized. `broadcast` sends a JSON message to everyone; `send` targets one player. `end` records a JSON result and ends the room. Room saves use keys with the same format as player save keys and values up to 128 KB. `schedule` names a method on the definition so it can run even after a quiet room resumes. Scores submitted through `room.board` are verified. The room fixes their UTC `day` and millisecond `submittedAt` when `submit` is called, so delayed writes and retries do not move them to another day. Older queued scores without these fields retain the write-time day. A daily run crossing midnight is scored on its submission day; games should define a deadline if they require the starting day.\n\nThe browser can change its role or team only while the room is in `lobby`. During a match, the server decides when a player changes role or team with `room.setRole` and `room.setTeam`. Both methods accept a player object or id and immediately update `room.players` for every client.\n\n```js\nonMessage(room, player, message) {\n if (message?.swap === 'captain') {\n room.setRole(player, 'captain');\n }\n},\n```\n\n`room.daily.seed` is shared by every room for the game on the current UTC day. `room.seed` is fixed for one room and is identical on the server and clients, so rooms created on the same day can generate different maps.\n\n### Shared game store\n\n`room.shared` is a server-only JSON key/value store shared by every room of the same game. It is useful when one room must leave data for another room, while `room.save` remains private to one room.\n\nThe following server leaves a ship when a room ends, then loads every previously left ship when another room is created. The room id suffix is used because shared-store keys follow the save-key format.\n\n```js\nexport default defineGame({\n tickRate: 0,\n\n async onCreate(room) {\n const keys = await room.shared.list('ship_');\n room.state = {\n ships: await Promise.all(keys.map((key) => room.shared.get(key))),\n };\n },\n\n async onEnd(room) {\n const roomSuffix = room.id.split('.')[1];\n await room.shared.set('ship_' + roomSuffix, {\n position: room.state.position,\n cargo: room.state.cargo,\n });\n },\n});\n```\n\nThe five methods are asynchronous:\n\n```js\nconst value = await room.shared.get(key); // JSON value, or null\nawait room.shared.set(key, value); // last writer wins\nawait room.shared.delete(key);\nconst keys = await room.shared.list(prefix); // sorted, up to 1024\nconst total = await room.shared.increment(key, 1); // atomic, defaults to 1\n```\n\nKeys contain 1 to 32 lowercase letters, numbers, underscores, or hyphens. Values may be up to 64 KB when serialized, and each game may keep up to 1024 keys. Each room may perform up to 120 shared-store operations per minute. `increment` treats a missing key as zero and rejects unless the existing value, amount, and result are safe integers.\n\nUse the store in `onCreate`, `onStart`, `onEnd`, `onMessage`, or a `schedule` handler. Do not call it on every tick: each call waits for a remote operation, and the CPU budget uses elapsed wall-clock time. Browser clients cannot access this store. Send only the data they need with `room.broadcast` or `room.send`.\n\nFailures reject with an `Error` carrying `store_invalid_key`, `store_too_large`, `store_full`, `store_not_integer`, `store_unavailable`, or `store_rate_limited` in `code`.\n\n`room.voice.setGain(listener, speaker, gain)` controls how much one listener hears one speaker. It is directional, limited to the range from 0 to 1, and rounded to two decimal places. For example, the following setup lets the captain hear everyone while each crew member hears only the captain:\n\n```js\nconst captain = room.players.find((player) => player.role === 'captain');\nconst crew = room.players.filter((player) => player.id !== captain.id);\n\nfor (const speaker of room.players) {\n room.voice.setGain(captain, speaker, 1);\n}\nfor (const listener of crew) {\n for (const speaker of room.players) {\n room.voice.setGain(listener, speaker, speaker.id === captain.id ? 1 : 0);\n }\n}\n```\n\n`room.voice.setProximity(a, b, gain)` is the symmetric shortcut for setting both directions. Both methods work in `room`, `team`, and `proximity` modes, and do nothing in `none`. In `team` mode, gains remain inside the team and cannot make a player hear another team.\n\nFor position-based audio, update the symmetric gain between players from server-owned positions:\n\n```js\nexport default defineGame({\n tickRate: 20,\n onTick(room) {\n for (const a of room.players) {\n for (const b of room.players) {\n if (a.id >= b.id) continue;\n const pa = room.state.positions[a.id];\n const pb = room.state.positions[b.id];\n const distance = Math.hypot(pa.x - pb.x, pa.y - pb.y);\n room.voice.setProximity(a, b, Math.max(0, 1 - distance / 20));\n }\n }\n },\n});\n```\n\n### Sleeping and cost\n\nPrefer `tickRate: 0` for turn based and party games. A room with a tick loop sleeps automatically after 30 seconds without player input or state changes and wakes on the next game message or player joining. Automatic ping and resync messages do not count as player input. A match with no player input for 10 minutes ends with `{ error: 'idle' }`. Timers set with `schedule` and the countdown keep working while the room sleeps.\n\n### CPU budget\n\nEvery `onTick` and `onMessage` call is measured. Twenty consecutive calls above 100 ms end the room with `{ error: 'cpu_budget' }`. If the average over 50 ticks is above 20 ms, the effective `tickRate` is halved, down to a minimum of 5, and clients receive an `error` message with code `tick_rate_reduced`.\n\n`room.tickRate` starts at the definition's `tickRate` and always reports the current effective frequency. `deltaSeconds` follows that frequency, so a fixed-step simulation must accumulate `deltaSeconds` instead of counting ticks. Measurement uses elapsed wall-clock time, so a slow `await` inside a callback also counts. `room.result` is `null` until the room ends, then contains the final game or automatic error result and is available inside `onEnd`.\n\n### Persistent rooms\n\nSet `\"persistent\": true` in `caisual.json` for a room that must survive long breaks. It does not use the normal inactivity ending rule and does not end when every player disconnects. Players remain members until they call `room.leave()` or the server removes them with `room.kick()`. They can use the same room code to return while the game is already playing. The code remains valid while the room lives, and absent members remain in `room.players` with `connected: false`.\n\nA persistent room ends when the server calls `room.end()`, after 30 days without player input, entry, or a state change with `{ error: 'expired' }`, or after five minutes without any members. Consider storing `room.code` with `c.save.set()` and offering a Resume action. A persistent room incurs cost only while it is awake.\n\n## Limits\n\n- 120 requests per minute per player. Beyond that the kit rejects with `rate_limited`; wait and retry.\n- Saves: 32 keys per player per game, 256 KB per value.\n- Scores: safe integers from 0 upward.\n- Room state: 256 KB of plain JSON.\n- Room messages: 16 KB each and 20 messages per second per connection.\n- Spectators: 100 per room, with a configured delay from 0 to 30 seconds.\n- Voice supports audio only and one voice channel per room.\n- Voice control messages: 64 KB each and 30 operations per 10 seconds per connection. Voice traffic is not counted against the room's message limits.\n- Room save values: 128 KB each.\n- Shared game store: 64 KB per JSON value, 1024 keys per game, and 120 operations per minute per room.\n\n## Development\n\nRun `npx @caisual/cli dev` from the game folder, then open the printed portal URL. The preview supplies the normal handshake, so `c.connected` is `true` and the game receives a local guest identity. It also mounts the same standard overlay as the portal, in the language chosen with `?lang=` among `en`, `it`, `es`, `fr`, `de` and `pt`. Friends and parties are marked unavailable locally; everything else, including saves, leaderboards, daily data, invitations, and rooms, works on local data. Opening the portal URL in more browser tabs creates more local players, which makes multiplayer testing possible without publishing.\n\nWhen building the client with a bundler, remember that `client/` is served as-is. Configure Vite, esbuild, or another bundler to write into that folder, for example `vite build --outDir client`, and use relative paths such as `base: './'`.\n\nKeep loading the kit from the `<script type=\"module\">` shown at the beginning of this guide, using `/__caisual/kit/v1.js`.\n\nIf the game has `server.js`, room state is handled locally and stored under `.caisual-dev/` in the game folder. If it has no `server.js`, room creation rejects with `no_server` and the single-player APIs still work.\n\nOpening `client/index.html` from a plain static server still uses standalone mode: `c.connected` is `false`, saves use local storage, `submit` returns `accepted: false`, leaderboards are empty, the daily seed is local, and room creation and joining reject with `offline`. The rest of the game logic does not need a different code path.\n\nAfter publishing with `npx @caisual/cli publish`, open the game from its caisual.com page: `c.connected` becomes `true` and every call goes to the portal.\n\n## Manifest\n\nDeclare `\"overlay\": { \"version\": 1 }` to get the standard overlay, with an optional `accent` colour. A standard game must declare at least one mode, and every mode needs `execution`, either `local` for a single-player run of exactly one player or `room` for a room backed by `server.js`. `label` names the mode in the standard menu and `instructions` adds one line under it. `roles[].label` and `boards[<id>].label` name roles and boards in the same UI, and `boards[<id>].periods` lists `daily`, `all-time` or both. A mode with matchmaking adds `matchmaking.defaults`, one value for every field of its `key`, so the overlay can start a search on its own.\n\n```json\n{\n \"overlay\": { \"version\": 1, \"accent\": \"#397e83\" },\n \"players\": { \"min\": 2, \"max\": 4 },\n \"lobby\": true,\n \"boards\": { \"solo\": { \"source\": \"server\", \"label\": \"Best run\", \"periods\": [\"daily\", \"all-time\"] } },\n \"modes\": [\n { \"id\": \"practice\", \"execution\": \"local\", \"label\": \"Practice\",\n \"instructions\": \"One run against the clock.\",\n \"players\": { \"min\": 1, \"max\": 1 }, \"lobby\": false },\n { \"id\": \"duel\", \"execution\": \"room\", \"label\": \"Online\",\n \"matchmaking\": { \"key\": [\"pool\"], \"defaults\": { \"pool\": \"v1\" }, \"timeoutMs\": 12000 } }\n ]\n}\n```\n\nA game without `overlay` keeps its historical flow and draws its own menus. Nothing else changes for it.\n\nNo manifest field is required for identity, saves, leaderboards, or the daily challenge. Use `boards` to make selected leaderboards server-only. A mode may override only `players: { min, max }` and `lobby`; omitted fields inherit the root configuration, and `mode: null` uses the root values. Matchmaking thresholds and room admission use this same resolution. For rooms, set `players` to the supported range and use `lobby`, `persistent`, `spectators`, `roles`, `teams`, and `modes` to describe the setup and lifetime. Set `voice` to `room`, `team`, or `proximity` to enable the corresponding voice mode, or omit it for `none`. A single-player game can keep `players` at `{ \"min\": 1, \"max\": 1 }`, `lobby` at `false`, and omit `server.js`. See [publish.md](./publish.md#caisualjson) for every field and the publishing steps.\n";
|
|
533
716
|
|
|
534
717
|
// src/bundle.ts
|
|
535
718
|
import { promises as fs } from "node:fs";
|
|
@@ -643,10 +826,10 @@ ${bundledValidation.errori.map((error) => `- ${error}`).join("\n")}`
|
|
|
643
826
|
}
|
|
644
827
|
|
|
645
828
|
// src/dev.ts
|
|
646
|
-
import { createHash as createHash2, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
829
|
+
import { createHash as createHash2, createHmac, randomBytes, randomUUID as randomUUID2, timingSafeEqual } from "node:crypto";
|
|
647
830
|
import { promises as fs2 } from "node:fs";
|
|
648
831
|
import { createServer } from "node:http";
|
|
649
|
-
import { extname, join as join2, relative as relative2, resolve, sep } from "node:path";
|
|
832
|
+
import { basename, dirname as dirname2, extname, join as join2, relative as relative2, resolve, sep } from "node:path";
|
|
650
833
|
|
|
651
834
|
// ../kit/dist/node.js
|
|
652
835
|
import { randomUUID } from "node:crypto";
|
|
@@ -655,6 +838,49 @@ import { dirname } from "node:path";
|
|
|
655
838
|
import { performance } from "node:perf_hooks";
|
|
656
839
|
import { createHash } from "node:crypto";
|
|
657
840
|
import { EventEmitter } from "node:events";
|
|
841
|
+
var NOMI_RISERVATI2 = [
|
|
842
|
+
"www",
|
|
843
|
+
"api",
|
|
844
|
+
"app",
|
|
845
|
+
"play",
|
|
846
|
+
"live",
|
|
847
|
+
"multi",
|
|
848
|
+
"cdn",
|
|
849
|
+
"assets",
|
|
850
|
+
"static",
|
|
851
|
+
"mail",
|
|
852
|
+
"mx",
|
|
853
|
+
"ns1",
|
|
854
|
+
"ns2",
|
|
855
|
+
"autodiscover",
|
|
856
|
+
"_dmarc",
|
|
857
|
+
"admin",
|
|
858
|
+
"login",
|
|
859
|
+
"account",
|
|
860
|
+
"auth",
|
|
861
|
+
"pay",
|
|
862
|
+
"secure",
|
|
863
|
+
"support",
|
|
864
|
+
"help",
|
|
865
|
+
"blog",
|
|
866
|
+
"status",
|
|
867
|
+
"dev",
|
|
868
|
+
"staging",
|
|
869
|
+
"test",
|
|
870
|
+
"caisual",
|
|
871
|
+
"shipz"
|
|
872
|
+
];
|
|
873
|
+
var RISERVATI2 = new Set(NOMI_RISERVATI2);
|
|
874
|
+
function risolviModalita2(manifest, mode) {
|
|
875
|
+
const scelta = mode === null ? void 0 : manifest.modes.find((voce) => voce.id === mode);
|
|
876
|
+
if (mode !== null && scelta === void 0) throw new Error("The selected game mode does not exist.");
|
|
877
|
+
return { players: { ...scelta?.players ?? manifest.players }, lobby: scelta?.lobby ?? manifest.lobby };
|
|
878
|
+
}
|
|
879
|
+
function modalitaLocale2(manifest, mode) {
|
|
880
|
+
return mode !== null && manifest.modes.some((voce) => voce.id === mode && voce.execution === "local");
|
|
881
|
+
}
|
|
882
|
+
var MASSIMO_SPETTATORI = 100;
|
|
883
|
+
var RITARDO_SPETTATORI_MS2 = 3e3;
|
|
658
884
|
function isPlainObject(value) {
|
|
659
885
|
const prototype = Object.getPrototypeOf(value);
|
|
660
886
|
return prototype === Object.prototype || prototype === null;
|
|
@@ -1094,9 +1320,8 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1094
1320
|
}
|
|
1095
1321
|
async crea(id, mode, _creator) {
|
|
1096
1322
|
if (this.dati !== null) return false;
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
}
|
|
1323
|
+
risolviModalita2(this.manifest, mode);
|
|
1324
|
+
if (modalitaLocale2(this.manifest, mode)) throw new Error("Local modes cannot create rooms.");
|
|
1100
1325
|
const ora = this.adattatore.ora();
|
|
1101
1326
|
this.dati = {
|
|
1102
1327
|
versione: 1,
|
|
@@ -1141,6 +1366,9 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1141
1366
|
await this.persistiEProgramma();
|
|
1142
1367
|
return true;
|
|
1143
1368
|
}
|
|
1369
|
+
get configurazione() {
|
|
1370
|
+
return risolviModalita2(this.manifest, this.dati?.mode ?? null);
|
|
1371
|
+
}
|
|
1144
1372
|
info(playerId) {
|
|
1145
1373
|
if (this.dati === null) return null;
|
|
1146
1374
|
return {
|
|
@@ -1149,26 +1377,53 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1149
1377
|
players: this.manifest.persistent === true ? this.dati.giocatori.length : this.dati.giocatori.filter(
|
|
1150
1378
|
(player) => player.connected || player.graziaFinoA !== null
|
|
1151
1379
|
).length,
|
|
1152
|
-
max: this.
|
|
1380
|
+
max: this.configurazione.players.max,
|
|
1153
1381
|
mode: this.dati.mode,
|
|
1154
1382
|
member: playerId === void 0 ? false : this.dati.giocatori.some((player) => player.id === playerId)
|
|
1155
1383
|
};
|
|
1156
1384
|
}
|
|
1385
|
+
fotografia() {
|
|
1386
|
+
if (this.dati === null) return null;
|
|
1387
|
+
return {
|
|
1388
|
+
room: this.stanzaProtocollo(),
|
|
1389
|
+
players: this.giocatoriProtocollo(),
|
|
1390
|
+
state: this.dati.state
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1157
1393
|
giocatoreConnesso(connessione) {
|
|
1158
1394
|
const player = this.dati?.giocatori.find(
|
|
1159
1395
|
(item) => item.connected && item.connessione === connessione
|
|
1160
1396
|
);
|
|
1161
1397
|
return player === void 0 ? null : copiaGiocatore(player);
|
|
1162
1398
|
}
|
|
1399
|
+
puoAscoltare(listener, speakers) {
|
|
1400
|
+
const mode = this.manifest.voice ?? "none";
|
|
1401
|
+
if (mode === "none") return speakers.map(() => ({ ok: false, reason: "mode" }));
|
|
1402
|
+
const dati = this.richiediDati();
|
|
1403
|
+
const ascoltatore = dati.giocatori.find((player) => player.id === listener);
|
|
1404
|
+
return speakers.map((speakerId) => {
|
|
1405
|
+
const speaker = dati.giocatori.find((player) => player.id === speakerId);
|
|
1406
|
+
if (ascoltatore === void 0 || speaker === void 0 || listener === speakerId) {
|
|
1407
|
+
return { ok: false, reason: "unknown" };
|
|
1408
|
+
}
|
|
1409
|
+
if (mode === "team" && ascoltatore.role !== "spectator" && ascoltatore.team !== speaker.team) {
|
|
1410
|
+
return { ok: false, reason: "team" };
|
|
1411
|
+
}
|
|
1412
|
+
if ((dati.voceGuadagni?.[listener]?.[speakerId] ?? 1) <= 0) {
|
|
1413
|
+
return { ok: false, reason: "gain" };
|
|
1414
|
+
}
|
|
1415
|
+
return { ok: true };
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1163
1418
|
puoEntrare(identity) {
|
|
1164
1419
|
if (this.dati === null) return { ok: false, code: "room_not_found" };
|
|
1165
1420
|
if (this.dati.status === "ended") return { ok: false, code: "room_ended" };
|
|
1166
1421
|
const esistente = this.dati.giocatori.find((player) => player.id === identity.id);
|
|
1167
1422
|
if (esistente !== void 0) return { ok: true };
|
|
1168
|
-
if (this.
|
|
1423
|
+
if (this.configurazione.lobby && this.dati.status !== "lobby") {
|
|
1169
1424
|
return { ok: false, code: "room_playing" };
|
|
1170
1425
|
}
|
|
1171
|
-
if (this.dati.giocatori.length >= this.
|
|
1426
|
+
if (this.dati.giocatori.length >= this.configurazione.players.max) {
|
|
1172
1427
|
return { ok: false, code: "room_full" };
|
|
1173
1428
|
}
|
|
1174
1429
|
return { ok: true };
|
|
@@ -1211,7 +1466,7 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1211
1466
|
dati.hostId ??= player.id;
|
|
1212
1467
|
dati.vuotaDa = null;
|
|
1213
1468
|
dati.ultimoInputAt = ora;
|
|
1214
|
-
const primaConnessione = !this.
|
|
1469
|
+
const primaConnessione = !this.configurazione.lobby && dati.status === "lobby";
|
|
1215
1470
|
if (primaConnessione) dati.status = "playing";
|
|
1216
1471
|
if (nuovo) {
|
|
1217
1472
|
await this.chiama(
|
|
@@ -1317,6 +1572,26 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1317
1572
|
await this.persistiEProgramma();
|
|
1318
1573
|
return;
|
|
1319
1574
|
}
|
|
1575
|
+
if (message.t === "request-role") {
|
|
1576
|
+
if (!Number.isSafeInteger(message.r) || message.r < 1 || typeof message.role !== "string") return this.messaggioErrato(player);
|
|
1577
|
+
let code = null;
|
|
1578
|
+
if (this.dati.status !== "playing" || this.definizione.onRoleRequest === void 0) code = "role_change_unavailable";
|
|
1579
|
+
else if (!this.manifest.roles.some((role) => role.id === message.role)) code = "invalid_role";
|
|
1580
|
+
else {
|
|
1581
|
+
this.dati.ultimoInputAt = ora;
|
|
1582
|
+
await this.chiama(this.definizione.onRoleRequest, this.room, copiaGiocatore(player), message.role);
|
|
1583
|
+
await this.concludiEvento();
|
|
1584
|
+
if (player.role !== message.role) code = "role_change_refused";
|
|
1585
|
+
}
|
|
1586
|
+
if (player.connected && player.connessione !== null) this.adattatore.invia(player.connessione, {
|
|
1587
|
+
t: "role-result",
|
|
1588
|
+
r: message.r,
|
|
1589
|
+
ok: code === null,
|
|
1590
|
+
code
|
|
1591
|
+
});
|
|
1592
|
+
await this.persistiEProgramma();
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1320
1595
|
if (message.t === "team") {
|
|
1321
1596
|
if (!Number.isInteger(message.team)) return this.messaggioErrato(player);
|
|
1322
1597
|
if (!this.inLobby(player)) return;
|
|
@@ -1385,23 +1660,8 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1385
1660
|
this.inviaGuadagniCambiati();
|
|
1386
1661
|
const stato = await this.verificaStato();
|
|
1387
1662
|
if (stato === null || this.stanzaTerminata()) return;
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
dati.state = stato.valore;
|
|
1391
|
-
this.inviaSnapshotTutti();
|
|
1392
|
-
} else if (cambiato) {
|
|
1393
|
-
const base = dati.tickSincronizzato;
|
|
1394
|
-
this.broadcast({
|
|
1395
|
-
t: "state",
|
|
1396
|
-
tick: dati.tick,
|
|
1397
|
-
serverTime: this.adattatore.ora(),
|
|
1398
|
-
base,
|
|
1399
|
-
patch: creaDiff(dati.statoSincronizzato, stato.valore)
|
|
1400
|
-
});
|
|
1401
|
-
dati.state = stato.valore;
|
|
1402
|
-
dati.statoSincronizzato = copiaJson(stato.valore);
|
|
1403
|
-
dati.tickSincronizzato = dati.tick;
|
|
1404
|
-
}
|
|
1663
|
+
dati.state = stato.valore;
|
|
1664
|
+
this.inviaDiff();
|
|
1405
1665
|
if (dati.tick % 100 === 0 || this.voceDaPersistire) await this.persisti();
|
|
1406
1666
|
await this.aggiornaProgrammazione();
|
|
1407
1667
|
}
|
|
@@ -1530,10 +1790,15 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1530
1790
|
if (ruolo?.max !== void 0 && occupati >= ruolo.max) {
|
|
1531
1791
|
return { code: "role_full", message: "This role is full." };
|
|
1532
1792
|
}
|
|
1793
|
+
const ruoloPrecedente = player.role;
|
|
1794
|
+
const squadraPrecedente = player.team;
|
|
1533
1795
|
player.role = roleId;
|
|
1534
1796
|
if (roleId === "spectator") player.team = null;
|
|
1535
1797
|
else if (player.team === null) player.team = this.squadraAutomatica();
|
|
1536
1798
|
if (this.richiediDati().status === "lobby") player.ready = false;
|
|
1799
|
+
if (player.role !== ruoloPrecedente || player.team !== squadraPrecedente) {
|
|
1800
|
+
this.rivediVoce();
|
|
1801
|
+
}
|
|
1537
1802
|
this.inviaGiocatori();
|
|
1538
1803
|
return null;
|
|
1539
1804
|
}
|
|
@@ -1544,8 +1809,10 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1544
1809
|
if (player.role === "spectator") {
|
|
1545
1810
|
return { code: "spectator", message: "Spectators cannot join a team." };
|
|
1546
1811
|
}
|
|
1812
|
+
const squadraPrecedente = player.team;
|
|
1547
1813
|
player.team = team;
|
|
1548
1814
|
if (this.richiediDati().status === "lobby") player.ready = false;
|
|
1815
|
+
if (player.team !== squadraPrecedente) this.rivediVoce();
|
|
1549
1816
|
this.inviaGiocatori();
|
|
1550
1817
|
return null;
|
|
1551
1818
|
}
|
|
@@ -1569,7 +1836,7 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1569
1836
|
erroreMinimi() {
|
|
1570
1837
|
const connessi = this.richiediDati().giocatori.filter((player) => player.connected);
|
|
1571
1838
|
const attivi = connessi.filter((player) => player.role !== "spectator");
|
|
1572
|
-
if (attivi.length < this.
|
|
1839
|
+
if (attivi.length < this.configurazione.players.min) {
|
|
1573
1840
|
return { code: "not_enough_players", message: "The room does not have enough players." };
|
|
1574
1841
|
}
|
|
1575
1842
|
if (connessi.some((player) => !player.ready)) {
|
|
@@ -1624,6 +1891,7 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1624
1891
|
const indice = dati.giocatori.findIndex((item) => item.id === player.id);
|
|
1625
1892
|
if (indice < 0) return;
|
|
1626
1893
|
dati.giocatori.splice(indice, 1);
|
|
1894
|
+
this.rivediVoce();
|
|
1627
1895
|
this.pulisciGuadagni(player.id);
|
|
1628
1896
|
if (dati.hostId === player.id) this.assegnaHost();
|
|
1629
1897
|
if (dati.giocatori.length === 0) dati.vuotaDa = this.adattatore.ora();
|
|
@@ -1670,7 +1938,7 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1670
1938
|
});
|
|
1671
1939
|
}
|
|
1672
1940
|
accodaPunteggio(playerId, board, score, daily) {
|
|
1673
|
-
if (!this.richiediDati().giocatori.some((
|
|
1941
|
+
if (!this.richiediDati().giocatori.some((player2) => player2.id === playerId)) {
|
|
1674
1942
|
throw new Error("Player not found.");
|
|
1675
1943
|
}
|
|
1676
1944
|
if (!CHIAVE.test(board)) {
|
|
@@ -1679,7 +1947,20 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1679
1947
|
if (!Number.isSafeInteger(score) || score < 0) {
|
|
1680
1948
|
throw new TypeError("Score must be a non-negative safe integer.");
|
|
1681
1949
|
}
|
|
1682
|
-
this.
|
|
1950
|
+
const submittedAt = this.adattatore.ora();
|
|
1951
|
+
this.richiediDati().punteggi.push({
|
|
1952
|
+
playerId,
|
|
1953
|
+
board,
|
|
1954
|
+
score,
|
|
1955
|
+
daily,
|
|
1956
|
+
submittedAt,
|
|
1957
|
+
day: daily ? giornoUtc(submittedAt) : null
|
|
1958
|
+
});
|
|
1959
|
+
const player = this.richiediDati().giocatori.find((item) => item.id === playerId);
|
|
1960
|
+
if (player.connected && player.connessione !== null) this.adattatore.invia(player.connessione, {
|
|
1961
|
+
t: "score-queued",
|
|
1962
|
+
score: { player: playerId, board, score, day: daily ? giornoUtc(submittedAt) : null, submittedAt }
|
|
1963
|
+
});
|
|
1683
1964
|
this.broadcast({ t: "flush" });
|
|
1684
1965
|
}
|
|
1685
1966
|
richiediFine(result) {
|
|
@@ -1813,6 +2094,7 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1813
2094
|
this.adattatore.invia(player.connessione, message);
|
|
1814
2095
|
}
|
|
1815
2096
|
}
|
|
2097
|
+
if (message.t !== "flush") this.adattatore.pubblica(message);
|
|
1816
2098
|
}
|
|
1817
2099
|
inviaErrore(player, code, message) {
|
|
1818
2100
|
if (player.connected && player.connessione !== null) {
|
|
@@ -1836,16 +2118,7 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1836
2118
|
this.adattatore.invia(player.connessione, {
|
|
1837
2119
|
t: "welcome",
|
|
1838
2120
|
you: player.id,
|
|
1839
|
-
room:
|
|
1840
|
-
id: dati.id,
|
|
1841
|
-
seed: seedStanza(dati.id),
|
|
1842
|
-
status: dati.status,
|
|
1843
|
-
mode: dati.mode,
|
|
1844
|
-
tick: dati.tick,
|
|
1845
|
-
tickRate: dati.tickRate,
|
|
1846
|
-
serverTime: this.adattatore.ora(),
|
|
1847
|
-
host: dati.hostId
|
|
1848
|
-
},
|
|
2121
|
+
room: this.stanzaProtocollo(),
|
|
1849
2122
|
players: this.giocatoriProtocollo(),
|
|
1850
2123
|
state: dati.state
|
|
1851
2124
|
});
|
|
@@ -1854,18 +2127,54 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1854
2127
|
this.adattatore.invia(player.connessione, { t: "voice", op: "gain", gains: { ...gains } });
|
|
1855
2128
|
}
|
|
1856
2129
|
}
|
|
2130
|
+
stanzaProtocollo() {
|
|
2131
|
+
const dati = this.richiediDati();
|
|
2132
|
+
return {
|
|
2133
|
+
id: dati.id,
|
|
2134
|
+
seed: seedStanza(dati.id),
|
|
2135
|
+
status: dati.status,
|
|
2136
|
+
mode: dati.mode,
|
|
2137
|
+
tick: dati.tick,
|
|
2138
|
+
tickRate: dati.tickRate,
|
|
2139
|
+
serverTime: this.adattatore.ora(),
|
|
2140
|
+
host: dati.hostId,
|
|
2141
|
+
countdownAt: dati.countdownAt,
|
|
2142
|
+
configuration: {
|
|
2143
|
+
players: { ...this.configurazione.players },
|
|
2144
|
+
lobby: this.configurazione.lobby,
|
|
2145
|
+
persistent: this.manifest.persistent === true,
|
|
2146
|
+
requestRole: this.definizione.onRoleRequest !== void 0
|
|
2147
|
+
}
|
|
2148
|
+
};
|
|
2149
|
+
}
|
|
1857
2150
|
inviaGiocatori() {
|
|
1858
|
-
this.broadcast({ t: "players", players: this.giocatoriProtocollo() });
|
|
2151
|
+
this.broadcast({ t: "players", players: this.giocatoriProtocollo(), host: this.richiediDati().hostId });
|
|
1859
2152
|
}
|
|
1860
2153
|
inviaStatus(at) {
|
|
1861
2154
|
const dati = this.richiediDati();
|
|
1862
2155
|
this.broadcast({
|
|
1863
2156
|
t: "status",
|
|
1864
2157
|
status: dati.status,
|
|
2158
|
+
host: dati.hostId,
|
|
2159
|
+
countdownAt: dati.countdownAt,
|
|
1865
2160
|
at,
|
|
1866
2161
|
result: dati.status === "ended" ? dati.result : null
|
|
1867
2162
|
});
|
|
1868
2163
|
}
|
|
2164
|
+
inviaDiff() {
|
|
2165
|
+
const dati = this.richiediDati();
|
|
2166
|
+
const patch = creaDiff(dati.statoSincronizzato, dati.state);
|
|
2167
|
+
if (patch.length === 0) return;
|
|
2168
|
+
this.broadcast({
|
|
2169
|
+
t: "state",
|
|
2170
|
+
tick: dati.tick,
|
|
2171
|
+
base: dati.tickSincronizzato,
|
|
2172
|
+
serverTime: this.adattatore.ora(),
|
|
2173
|
+
patch
|
|
2174
|
+
});
|
|
2175
|
+
dati.statoSincronizzato = copiaJson(dati.state);
|
|
2176
|
+
dati.tickSincronizzato = dati.tick;
|
|
2177
|
+
}
|
|
1869
2178
|
inviaSnapshotTutti() {
|
|
1870
2179
|
const dati = this.richiediDati();
|
|
1871
2180
|
const stato = analizzaJson(dati.state);
|
|
@@ -1920,7 +2229,7 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1920
2229
|
this.dati.state = stato.valore;
|
|
1921
2230
|
if ((this.dati.tickRate === 0 || this.dati.status !== "playing") && stato.testo !== precedente) {
|
|
1922
2231
|
this.dati.tick++;
|
|
1923
|
-
this.
|
|
2232
|
+
this.inviaDiff();
|
|
1924
2233
|
}
|
|
1925
2234
|
}
|
|
1926
2235
|
async verificaStato() {
|
|
@@ -1954,6 +2263,7 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1954
2263
|
dati.voceGuadagni ??= {};
|
|
1955
2264
|
const precedente = dati.voceGuadagni[playerId]?.[altroId] ?? 1;
|
|
1956
2265
|
if (precedente === valore) return;
|
|
2266
|
+
if (precedente > 0 && valore === 0) this.rivediVoce();
|
|
1957
2267
|
this.voceDaPersistire = true;
|
|
1958
2268
|
if (valore === 1) {
|
|
1959
2269
|
const riga = dati.voceGuadagni[playerId];
|
|
@@ -1971,6 +2281,9 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1971
2281
|
this.voceGuadagniCambiati.set(playerId, cambi);
|
|
1972
2282
|
return cambi;
|
|
1973
2283
|
}
|
|
2284
|
+
rivediVoce() {
|
|
2285
|
+
if ((this.manifest.voice ?? "none") !== "none") this.adattatore.rivediVoce();
|
|
2286
|
+
}
|
|
1974
2287
|
inviaGuadagniCambiati() {
|
|
1975
2288
|
if (this.dati === null || this.voceGuadagniCambiati.size === 0) return;
|
|
1976
2289
|
for (const [playerId, gains] of this.voceGuadagniCambiati) {
|
|
@@ -2018,7 +2331,7 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
2018
2331
|
} else if (stato.testo !== JSON.stringify(dati.statoSincronizzato)) {
|
|
2019
2332
|
dati.tick++;
|
|
2020
2333
|
dati.state = stato.valore;
|
|
2021
|
-
this.
|
|
2334
|
+
this.inviaDiff();
|
|
2022
2335
|
}
|
|
2023
2336
|
const fine = { result: dati.result, at: dati.resultAt };
|
|
2024
2337
|
dati.fineInCoda = fine;
|
|
@@ -2371,10 +2684,13 @@ var ArchivioNode = class _ArchivioNode {
|
|
|
2371
2684
|
}
|
|
2372
2685
|
};
|
|
2373
2686
|
var AdattatoreNode = class {
|
|
2374
|
-
constructor(storage, deposito) {
|
|
2687
|
+
constructor(storage, deposito, ritardoSpettatori) {
|
|
2375
2688
|
this.storage = storage;
|
|
2376
2689
|
this.deposito = deposito;
|
|
2690
|
+
this.ritardoSpettatori = ritardoSpettatori;
|
|
2377
2691
|
this.connessioni = /* @__PURE__ */ new Map();
|
|
2692
|
+
this.spettatori = /* @__PURE__ */ new Map();
|
|
2693
|
+
this.timerSpettatori = /* @__PURE__ */ new Set();
|
|
2378
2694
|
this.tickTimer = null;
|
|
2379
2695
|
this.tickIntervallo = null;
|
|
2380
2696
|
this.tickGenerazione = 0;
|
|
@@ -2393,12 +2709,66 @@ var AdattatoreNode = class {
|
|
|
2393
2709
|
rimuovi(id) {
|
|
2394
2710
|
this.connessioni.delete(id);
|
|
2395
2711
|
}
|
|
2712
|
+
aggiungiSpettatore(id, socket) {
|
|
2713
|
+
this.spettatori.set(id, socket);
|
|
2714
|
+
}
|
|
2715
|
+
rimuoviSpettatore(id) {
|
|
2716
|
+
this.spettatori.delete(id);
|
|
2717
|
+
}
|
|
2718
|
+
numeroSpettatori() {
|
|
2719
|
+
return this.spettatori.size;
|
|
2720
|
+
}
|
|
2721
|
+
elencoSpettatori() {
|
|
2722
|
+
return [...this.spettatori];
|
|
2723
|
+
}
|
|
2396
2724
|
elencoConnessioni() {
|
|
2397
2725
|
return [...this.connessioni];
|
|
2398
2726
|
}
|
|
2399
2727
|
invia(connessione, messaggio) {
|
|
2400
2728
|
this.connessioni.get(connessione)?.send(JSON.stringify(messaggio));
|
|
2401
2729
|
}
|
|
2730
|
+
pubblica(messaggio) {
|
|
2731
|
+
const testo = JSON.stringify(messaggio);
|
|
2732
|
+
for (const [connessione] of this.spettatori) {
|
|
2733
|
+
this.inviaSpettatore(connessione, testo);
|
|
2734
|
+
if (messaggio.t === "status" && messaggio.status === "ended") {
|
|
2735
|
+
this.chiudiSpettatoreRitardato(connessione);
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
inviaSpettatore(connessione, testo) {
|
|
2740
|
+
this.accodaSpettatore(connessione, (socket) => {
|
|
2741
|
+
socket.send(testo);
|
|
2742
|
+
});
|
|
2743
|
+
}
|
|
2744
|
+
inviaSpettatoreSubito(connessione, testo) {
|
|
2745
|
+
this.spettatori.get(connessione)?.send(testo);
|
|
2746
|
+
}
|
|
2747
|
+
chiudiSpettatore(connessione, codice, motivo) {
|
|
2748
|
+
const socket = this.spettatori.get(connessione);
|
|
2749
|
+
this.spettatori.delete(connessione);
|
|
2750
|
+
socket?.close(codice, motivo);
|
|
2751
|
+
}
|
|
2752
|
+
chiudiSpettatoreRitardato(connessione) {
|
|
2753
|
+
this.accodaSpettatore(connessione, (socket) => {
|
|
2754
|
+
this.spettatori.delete(connessione);
|
|
2755
|
+
socket.close(4004, "room_ended");
|
|
2756
|
+
});
|
|
2757
|
+
}
|
|
2758
|
+
accodaSpettatore(connessione, azione) {
|
|
2759
|
+
if (this.ritardoSpettatori === 0) {
|
|
2760
|
+
const socket = this.spettatori.get(connessione);
|
|
2761
|
+
if (socket !== void 0) azione(socket);
|
|
2762
|
+
return;
|
|
2763
|
+
}
|
|
2764
|
+
const timer = setTimeout(() => {
|
|
2765
|
+
this.timerSpettatori.delete(timer);
|
|
2766
|
+
const socket = this.spettatori.get(connessione);
|
|
2767
|
+
if (socket !== void 0) azione(socket);
|
|
2768
|
+
}, this.ritardoSpettatori ?? RITARDO_SPETTATORI_MS2);
|
|
2769
|
+
this.timerSpettatori.add(timer);
|
|
2770
|
+
timer.unref();
|
|
2771
|
+
}
|
|
2402
2772
|
chiudi(connessione, codice, motivo) {
|
|
2403
2773
|
this.connessioni.get(connessione)?.close(codice, motivo);
|
|
2404
2774
|
}
|
|
@@ -2411,6 +2781,8 @@ var AdattatoreNode = class {
|
|
|
2411
2781
|
misuraCpu() {
|
|
2412
2782
|
return performance.now();
|
|
2413
2783
|
}
|
|
2784
|
+
rivediVoce() {
|
|
2785
|
+
}
|
|
2414
2786
|
programmaTick(intervalloMs) {
|
|
2415
2787
|
if (this.tickIntervallo === intervalloMs) return;
|
|
2416
2788
|
this.tickIntervallo = intervalloMs;
|
|
@@ -2431,6 +2803,8 @@ var AdattatoreNode = class {
|
|
|
2431
2803
|
this.tickGenerazione += 1;
|
|
2432
2804
|
if (this.tickTimer !== null) clearTimeout(this.tickTimer);
|
|
2433
2805
|
if (this.svegliaTimer !== null) clearTimeout(this.svegliaTimer);
|
|
2806
|
+
for (const timer of this.timerSpettatori) clearTimeout(timer);
|
|
2807
|
+
this.timerSpettatori.clear();
|
|
2434
2808
|
this.tickTimer = null;
|
|
2435
2809
|
this.svegliaTimer = null;
|
|
2436
2810
|
}
|
|
@@ -2489,6 +2863,9 @@ var StanzaNode = class {
|
|
|
2489
2863
|
canJoin(identity) {
|
|
2490
2864
|
return this.serializza(() => Promise.resolve(this.nucleo.puoEntrare(identity)));
|
|
2491
2865
|
}
|
|
2866
|
+
canWatch() {
|
|
2867
|
+
return this.serializza(() => Promise.resolve(this.permessoSpettatore()));
|
|
2868
|
+
}
|
|
2492
2869
|
async connect(socket, identity) {
|
|
2493
2870
|
const connessione = randomUUID();
|
|
2494
2871
|
this.adattatore.aggiungi(connessione, socket);
|
|
@@ -2519,6 +2896,28 @@ var StanzaNode = class {
|
|
|
2519
2896
|
throw cause;
|
|
2520
2897
|
}
|
|
2521
2898
|
}
|
|
2899
|
+
watch(socket, _identity) {
|
|
2900
|
+
return this.serializza(async () => {
|
|
2901
|
+
const permesso = this.permessoSpettatore();
|
|
2902
|
+
if (!permesso.ok) return permesso;
|
|
2903
|
+
const connessione = randomUUID();
|
|
2904
|
+
this.adattatore.aggiungiSpettatore(connessione, socket);
|
|
2905
|
+
socket.on("message", (message) => {
|
|
2906
|
+
void this.serializza(() => this.riceviSpettatore(connessione, message));
|
|
2907
|
+
});
|
|
2908
|
+
socket.on("close", () => {
|
|
2909
|
+
this.adattatore.rimuoviSpettatore(connessione);
|
|
2910
|
+
this.frameFrequenza.delete(connessione);
|
|
2911
|
+
});
|
|
2912
|
+
const fotografia = this.nucleo.fotografia();
|
|
2913
|
+
if (fotografia === null) {
|
|
2914
|
+
this.adattatore.rimuoviSpettatore(connessione);
|
|
2915
|
+
return { ok: false, code: "room_not_found" };
|
|
2916
|
+
}
|
|
2917
|
+
this.inviaWatching(connessione, fotografia);
|
|
2918
|
+
return { ok: true };
|
|
2919
|
+
});
|
|
2920
|
+
}
|
|
2522
2921
|
flush() {
|
|
2523
2922
|
return this.serializza(() => this.nucleo.flush());
|
|
2524
2923
|
}
|
|
@@ -2533,6 +2932,11 @@ var StanzaNode = class {
|
|
|
2533
2932
|
socket.close(1001, "server_shutdown");
|
|
2534
2933
|
await this.serializza(() => this.nucleo.disconnetti(connessione));
|
|
2535
2934
|
}
|
|
2935
|
+
for (const [connessione, socket] of this.adattatore.elencoSpettatori()) {
|
|
2936
|
+
this.adattatore.rimuoviSpettatore(connessione);
|
|
2937
|
+
this.frameFrequenza.delete(connessione);
|
|
2938
|
+
socket.close(1001, "server_shutdown");
|
|
2939
|
+
}
|
|
2536
2940
|
}
|
|
2537
2941
|
serializza(operazione) {
|
|
2538
2942
|
const risultato = this.coda.then(operazione);
|
|
@@ -2573,6 +2977,66 @@ var StanzaNode = class {
|
|
|
2573
2977
|
}
|
|
2574
2978
|
await this.riceviVoce(connessione, player.id, player.role, message);
|
|
2575
2979
|
}
|
|
2980
|
+
permessoSpettatore() {
|
|
2981
|
+
const info = this.nucleo.info();
|
|
2982
|
+
if (info === null) return { ok: false, code: "room_not_found" };
|
|
2983
|
+
if (info.status === "ended") return { ok: false, code: "room_ended" };
|
|
2984
|
+
if (this.manifest.spectators === null) {
|
|
2985
|
+
return { ok: false, code: "spectators_disabled" };
|
|
2986
|
+
}
|
|
2987
|
+
if (this.adattatore.numeroSpettatori() >= MASSIMO_SPETTATORI) {
|
|
2988
|
+
return { ok: false, code: "spectators_full" };
|
|
2989
|
+
}
|
|
2990
|
+
return { ok: true };
|
|
2991
|
+
}
|
|
2992
|
+
inviaWatching(connessione, fotografia) {
|
|
2993
|
+
this.adattatore.inviaSpettatore(connessione, JSON.stringify({
|
|
2994
|
+
t: "watching",
|
|
2995
|
+
...fotografia,
|
|
2996
|
+
delayMs: this.manifest.spectators?.delayMs ?? RITARDO_SPETTATORI_MS2
|
|
2997
|
+
}));
|
|
2998
|
+
}
|
|
2999
|
+
async riceviSpettatore(connessione, frame) {
|
|
3000
|
+
if (Buffer.byteLength(frame, "utf8") > 16 * 1024) {
|
|
3001
|
+
this.adattatore.chiudiSpettatore(connessione, 4008, "rate_limited");
|
|
3002
|
+
this.frameFrequenza.delete(connessione);
|
|
3003
|
+
return;
|
|
3004
|
+
}
|
|
3005
|
+
const ora = Date.now();
|
|
3006
|
+
const frames = (this.frameFrequenza.get(connessione) ?? []).filter((at) => ora - at < 1e3);
|
|
3007
|
+
if (frames.length >= 20) {
|
|
3008
|
+
this.adattatore.chiudiSpettatore(connessione, 4008, "rate_limited");
|
|
3009
|
+
this.frameFrequenza.delete(connessione);
|
|
3010
|
+
return;
|
|
3011
|
+
}
|
|
3012
|
+
frames.push(ora);
|
|
3013
|
+
this.frameFrequenza.set(connessione, frames);
|
|
3014
|
+
let message = null;
|
|
3015
|
+
try {
|
|
3016
|
+
const value = JSON.parse(frame);
|
|
3017
|
+
message = typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
3018
|
+
} catch {
|
|
3019
|
+
}
|
|
3020
|
+
if (message?.t === "ping" && typeof message.c === "number" && Number.isFinite(message.c)) {
|
|
3021
|
+
this.adattatore.inviaSpettatoreSubito(
|
|
3022
|
+
connessione,
|
|
3023
|
+
JSON.stringify({ t: "pong", c: message.c, s: ora })
|
|
3024
|
+
);
|
|
3025
|
+
return;
|
|
3026
|
+
}
|
|
3027
|
+
if (message?.t === "resync") {
|
|
3028
|
+
const fotografia = this.nucleo.fotografia();
|
|
3029
|
+
if (fotografia !== null) this.inviaWatching(connessione, fotografia);
|
|
3030
|
+
return;
|
|
3031
|
+
}
|
|
3032
|
+
if (message?.t === "leave") {
|
|
3033
|
+
this.adattatore.chiudiSpettatore(connessione, 1e3, "left");
|
|
3034
|
+
this.frameFrequenza.delete(connessione);
|
|
3035
|
+
return;
|
|
3036
|
+
}
|
|
3037
|
+
this.adattatore.chiudiSpettatore(connessione, 4009, "bad_message");
|
|
3038
|
+
this.frameFrequenza.delete(connessione);
|
|
3039
|
+
}
|
|
2576
3040
|
async riceviVoce(connessione, playerId, role, value) {
|
|
2577
3041
|
const richiesta = this.richiestaVoce(value);
|
|
2578
3042
|
if (richiesta === null) {
|
|
@@ -2644,6 +3108,19 @@ var StanzaNode = class {
|
|
|
2644
3108
|
}
|
|
2645
3109
|
if (richiesta.op === "signal") {
|
|
2646
3110
|
const destinazione = this.giocatoriConnessioni.get(richiesta.to);
|
|
3111
|
+
const autorizzazioni = [
|
|
3112
|
+
...this.nucleo.puoAscoltare(richiesta.to, [playerId]),
|
|
3113
|
+
...this.nucleo.puoAscoltare(playerId, [richiesta.to])
|
|
3114
|
+
];
|
|
3115
|
+
if (autorizzazioni.some((esito) => !esito.ok)) {
|
|
3116
|
+
this.inviaErroreVoce(
|
|
3117
|
+
connessione,
|
|
3118
|
+
richiesta,
|
|
3119
|
+
"not_allowed",
|
|
3120
|
+
"The player is not allowed to hear this participant."
|
|
3121
|
+
);
|
|
3122
|
+
return;
|
|
3123
|
+
}
|
|
2647
3124
|
if (destinazione !== void 0) {
|
|
2648
3125
|
this.adattatore.invia(destinazione, {
|
|
2649
3126
|
t: "voice",
|
|
@@ -2763,11 +3240,169 @@ var StanzaNode = class {
|
|
|
2763
3240
|
};
|
|
2764
3241
|
async function createNodeRoom(definition, manifest, options = {}) {
|
|
2765
3242
|
const storage = await ArchivioNode.apri(options.storageFile ?? null);
|
|
2766
|
-
const
|
|
3243
|
+
const ritardoSpettatori = manifest.spectators === null ? null : manifest.spectators?.delayMs ?? RITARDO_SPETTATORI_MS2;
|
|
3244
|
+
const adattatore = new AdattatoreNode(storage, options.deposito ?? null, ritardoSpettatori);
|
|
2767
3245
|
const nucleo = await NucleoStanza.apri(definition, manifest, adattatore);
|
|
2768
3246
|
return new StanzaNode(nucleo, adattatore, manifest);
|
|
2769
3247
|
}
|
|
2770
3248
|
|
|
3249
|
+
// ../kit/dist/overlay.js
|
|
3250
|
+
var NOMI_RISERVATI3 = [
|
|
3251
|
+
"www",
|
|
3252
|
+
"api",
|
|
3253
|
+
"app",
|
|
3254
|
+
"play",
|
|
3255
|
+
"live",
|
|
3256
|
+
"multi",
|
|
3257
|
+
"cdn",
|
|
3258
|
+
"assets",
|
|
3259
|
+
"static",
|
|
3260
|
+
"mail",
|
|
3261
|
+
"mx",
|
|
3262
|
+
"ns1",
|
|
3263
|
+
"ns2",
|
|
3264
|
+
"autodiscover",
|
|
3265
|
+
"_dmarc",
|
|
3266
|
+
"admin",
|
|
3267
|
+
"login",
|
|
3268
|
+
"account",
|
|
3269
|
+
"auth",
|
|
3270
|
+
"pay",
|
|
3271
|
+
"secure",
|
|
3272
|
+
"support",
|
|
3273
|
+
"help",
|
|
3274
|
+
"blog",
|
|
3275
|
+
"status",
|
|
3276
|
+
"dev",
|
|
3277
|
+
"staging",
|
|
3278
|
+
"test",
|
|
3279
|
+
"caisual",
|
|
3280
|
+
"shipz"
|
|
3281
|
+
];
|
|
3282
|
+
var RISERVATI3 = new Set(NOMI_RISERVATI3);
|
|
3283
|
+
var words = {
|
|
3284
|
+
loading: ["Loading game...", "Caricamento...", "Cargando...", "Chargement...", "Spiel wird geladen...", "Carregando..."],
|
|
3285
|
+
home: ["Play", "Gioca", "Jugar", "Jouer", "Spielen", "Jogar"],
|
|
3286
|
+
mode: ["Mode", "Modalit\xE0", "Modo", "Mode", "Modus", "Modo"],
|
|
3287
|
+
play: ["Play", "Gioca", "Jugar", "Jouer", "Spielen", "Jogar"],
|
|
3288
|
+
friendsPlay: ["Play with friends", "Gioca con amici", "Jugar con amigos", "Jouer entre amis", "Mit Freunden spielen", "Jogar com amigos"],
|
|
3289
|
+
find: ["Find players", "Trova giocatori", "Buscar jugadores", "Trouver des joueurs", "Spieler finden", "Buscar jogadores"],
|
|
3290
|
+
join: ["Join with code", "Entra con codice", "Entrar con c\xF3digo", "Rejoindre avec un code", "Mit Code beitreten", "Entrar com c\xF3digo"],
|
|
3291
|
+
joinInvite: ["Join this room", "Entra in questa stanza", "Entrar en est\xE1 sala", "Rejoindre cette salle", "Diesem Raum beitreten", "Entrar nest\xE1 sala"],
|
|
3292
|
+
watch: ["Watch a room", "Guarda una stanza", "Observar una sala", "Regarder une salle", "Raum ansehen", "Assistir a uma sala"],
|
|
3293
|
+
resume: ["Resume", "Riprendi", "Continuar", "Reprendre", "Fortsetzen", "Continuar"],
|
|
3294
|
+
room: ["Room", "Stanza", "Sala", "Salle", "Raum", "Sala"],
|
|
3295
|
+
code: ["Room code", "Codice stanza", "C\xF3digo de sala", "Code de salle", "Raumcode", "C\xF3digo da sala"],
|
|
3296
|
+
copy: ["Copy invite", "Copia invito", "Copiar invitaci\xF3n", "Copier le lien", "Einladung kopieren", "Copiar convite"],
|
|
3297
|
+
copied: ["Invite copied", "Invito copiato", "Invitacion copiada", "Lien copi\xE9", "Einladung kopiert", "Convite copiado"],
|
|
3298
|
+
copyFailed: ["Copy this link:", "Copia questo link:", "Copia este enlace:", "Copiez ce lien :", "Diesen Link kopieren:", "Copie este link:"],
|
|
3299
|
+
joining: ["Joining room...", "Ingresso nella stanza...", "Entrando en la sala...", "Connexion \xE0 la salle...", "Raum wird betreten...", "Entrando na sala..."],
|
|
3300
|
+
matching: ["Finding your people...", "Ricerca giocatori...", "Buscando jugadores...", "Recherche de joueurs...", "Spieler werden gesucht...", "Buscando jogadores..."],
|
|
3301
|
+
queue: ["{n} / {max} players", "{n} / {max} giocatori", "{n} / {max} jugadores", "{n} / {max} joueurs", "{n} / {max} Spieler", "{n} / {max} jogadores"],
|
|
3302
|
+
cancel: ["Cancel", "Annulla", "Cancelar", "Annuler", "Abbrechen", "Cancelar"],
|
|
3303
|
+
close: ["Close", "Chiudi", "Cerrar", "Fermer", "Schlie\xDFen", "Fechar"],
|
|
3304
|
+
back: ["Back", "Indietro", "Volver", "Retour", "Zur\xFCck", "Voltar"],
|
|
3305
|
+
ready: ["Ready", "Pronto", "Listo", "Pr\xEAt", "Bereit", "Pronto"],
|
|
3306
|
+
unready: ["Not ready", "Non pronto", "No listo", "Pas pr\xEAt", "Nicht bereit", "N\xE3o pronto"],
|
|
3307
|
+
start: ["Start", "Inizia", "Empezar", "Commencer", "Starten", "Come\xE7ar"],
|
|
3308
|
+
role: ["Role", "Ruolo", "Rol", "R\xF4le", "Rolle", "Fun\xE7\xE3o"],
|
|
3309
|
+
team: ["Team", "Squadra", "Equipo", "\xC9quipe", "Team", "Equipe"],
|
|
3310
|
+
host: ["Host", "Host", "Anfitrion", "H\xF4te", "Host", "Anfitri\xE3o"],
|
|
3311
|
+
you: ["You", "Tu", "T\xFA", "Vous", "Du", "Voc\xEA"],
|
|
3312
|
+
away: ["Away", "Assente", "Ausente", "Absent", "Abwesend", "Ausente"],
|
|
3313
|
+
needPlayers: ["Waiting for more players", "In attesa di giocatori", "Esperando m\xE1s jugadores", "En attente de joueurs", "Weitere Spieler fehlen", "Esperando mais jogadores"],
|
|
3314
|
+
needReady: ["Everyone needs to be ready", "Tutti devono essere pronti", "Todos deben estar listos", "Tout le monde doit \xEAtre pr\xEAt", "Alle m\xFCssen bereit sein", "Todos precisam estar prontos"],
|
|
3315
|
+
needRoles: ["Fill the required roles", "Completa i ruoli richiesti", "Completa los roles", "Compl\xE9tez les r\xF4les", "Ben\xF6tigte Rollen besetzen", "Complete as fun\xE7\xF5es"],
|
|
3316
|
+
needTeams: ["Choose the required teams", "Scegli le squadre richieste", "Elige los equipos", "Choisissez les \xE9quipes", "Teams auswahlen", "Escolha as equipes"],
|
|
3317
|
+
waitHost: ["Waiting for the host", "In attesa dell'host", "Esperando al anfitrion", "En attente de l\u2019h\xF4te", "Warten auf den Host", "Esperando o anfitri\xE3o"],
|
|
3318
|
+
starting: ["Starting in", "Si inizia tra", "Empieza en", "D\xE9but dans", "Start in", "Come\xE7a em"],
|
|
3319
|
+
playing: ["Playing", "In partita", "Jugando", "En jeu", "Im Spiel", "Jogando"],
|
|
3320
|
+
ended: ["Game finished", "Partita conclusa", "Partida terminada", "Partie termin\xE9e", "Spiel beendet", "Partida encerrada"],
|
|
3321
|
+
again: ["Play again", "Gioca ancora", "Jugar de nuevo", "Rejouer", "Erneut spielen", "Jogar novamente"],
|
|
3322
|
+
newRoom: ["New room. Share the new invite.", "Nuova stanza. Condividi il nuovo invito.", "Nueva sala. Comparte la invitaci\xF3n.", "Nouvelle salle. Partagez le lien.", "Neuer Raum. Neue Einladung teilen.", "Nova sala. Compartilhe o convite."],
|
|
3323
|
+
watching: ["Watching", "In osservazione", "Observando", "Spectateur", "Zuschauen", "Assistindo"],
|
|
3324
|
+
delay: ["{n}s delay", "Ritardo {n}s", "Retraso de {n}s", "Retard de {n}s", "{n}s Verz\xF6gerung", "Atraso de {n}s"],
|
|
3325
|
+
exit: ["Exit", "Esci", "Salir", "Quitter", "Verlassen", "Sair"],
|
|
3326
|
+
leaveNow: ["Leave for now", "Esci per ora", "Salir por ahora", "Quitter pour le moment", "Vor\xFCbergehend verlassen", "Sair por enquanto"],
|
|
3327
|
+
leaveRoom: ["Leave room", "Lascia la stanza", "Abandonar sala", "Abandonner la salle", "Raum verlassen", "Deixar a sala"],
|
|
3328
|
+
leaveHint: ["Your room stays available for Resume.", "La stanza resta disponibile con Riprendi.", "Podr\xE1s volver a est\xE1 sala.", "Vous pourrez reprendre cette salle.", "Du kannst den Raum fortsetzen.", "Voc\xEA pode voltar a est\xE1 sala."],
|
|
3329
|
+
temporaryHint: ["The game continues. Rejoining may only be possible briefly.", "La partita continua. Il rientro pu\xF2 essere disponibile solo per poco.", "La partida continua. Volver puede ser posible solo por poco tiempo.", "La partie continue. Le retour peut \xEAtre limit\xE9.", "Das Spiel l\xE4uft weiter. R\xFCckkehr nur kurz m\xF6glich.", "A partida continua. O retorno pode ser limitado."],
|
|
3330
|
+
abandonHint: ["Leave room gives up your place.", "Lascia la stanza libera il tuo posto.", "Abandonar libera tu plaza.", "Abandonner lib\xE8re votre place.", "Raum verlassen gibt deinen Platz frei.", "Deixar a sala libera sua vaga."],
|
|
3331
|
+
reconnecting: ["Reconnecting...", "Riconnessione...", "Reconectando...", "Reconnexion...", "Verbindung wird erneuert...", "Reconectando..."],
|
|
3332
|
+
replaced: ["Opened in another tab", "Aperta in un\u2019altra scheda", "Abierta en otra pest\xE1na", "Ouverte dans un autre onglet", "In anderem Tab ge\xF6ffnet", "Aberta em outra aba"],
|
|
3333
|
+
error: ["Something went wrong. Try again.", "Qualcosa non va. Riprova.", "Algo sali\xF3 mal. Reintenta.", "Une erreur est survenue. R\xE9essayez.", "Etwas ist schiefgelaufen. Erneut versuchen.", "Algo deu errado. Tente novamente."],
|
|
3334
|
+
noRoom: ["This room is no longer available.", "Questa stanza non \xE8 pi\xF9 disponibile.", "Esta sala ya no est\xE1 disponible.", "Cette salle n'est plus disponible.", "Dieser Raum ist nicht mehr verf\xFCgbar.", "Esta sala n\xE3o est\xE1 mais disponivel."],
|
|
3335
|
+
full: ["This room is full.", "La stanza \xE8 piena.", "La sala est\xE1 llena.", "Cette salle est pleine.", "Dieser Raum ist voll.", "Esta sala est\xE1 cheia."],
|
|
3336
|
+
noMatch: ["No match this time. Try again.", "Nessun gruppo trovato. Riprova.", "No hay grupo. Reintenta.", "Aucun groupe trouv\xE9. R\xE9essayez.", "Keine Gruppe gefunden. Erneut versuchen.", "Nenhum grupo encontrado. Tente novamente."],
|
|
3337
|
+
invalidCode: ["Enter a six-character room code.", "Inserisci un codice di sei caratteri.", "Escribe un c\xF3digo de seis caracteres.", "Entrez un code de six caracteres.", "Sechsstelligen Raumcode eingeben.", "Digite um c\xF3digo de seis caracteres."],
|
|
3338
|
+
refused: ["The room did not accept that change.", "La stanza ha rifiutato la modifica.", "La sala rechaz\xF3 el cambio.", "La salle a refus\xE9 ce changement.", "Der Raum hat die \xC4nderung abgelehnt.", "A sala recusou a altera\xE7\xE3o."],
|
|
3339
|
+
unavailable: ["Unavailable right now", "Non disponibile ora", "No disponible ahora", "Indisponible pour le moment", "Derzeit nicht verf\xFCgbar", "Indisponivel agora"],
|
|
3340
|
+
offline: ["Connection unavailable. Try again.", "Connessione non disponibile. Riprova.", "Sin conexi\xF3n. Reintenta.", "Connexion indisponible. R\xE9essayez.", "Keine Verbindung. Erneut versuchen.", "Sem conex\xE3o. Tente novamente."],
|
|
3341
|
+
saveFailed: ["Keep the room code. Resume could not be saved.", "Conserva il codice. Riprendi non \xE8 stato salvato.", "Guarda el c\xF3digo. No se pudo guardar el regreso.", "Gardez le code. La reprise ne peut pas \xEAtre enregistr\xE9e.", "Raumcode aufbewahren. Fortsetzen nicht gespeichert.", "Guarde o c\xF3digo. O retorno n\xE3o foi salvo."],
|
|
3342
|
+
boards: ["Leaderboard", "Classifica", "Clasificaci\xF3n", "Classement", "Bestenliste", "Classifica\xE7\xE3o"],
|
|
3343
|
+
board: ["Board", "Classifica", "Tabla", "Classement", "Bestenliste", "Tabela"],
|
|
3344
|
+
daily: ["Daily", "Giornaliera", "Diaria", "Du jour", "T\xE4glich", "Di\xE1ria"],
|
|
3345
|
+
allTime: ["All time", "Di sempre", "Hist\xF3rica", "Tous les temps", "Gesamt", "Geral"],
|
|
3346
|
+
accounts: ["Accounts", "Account", "Cuentas", "Comptes", "Konten", "Contas"],
|
|
3347
|
+
guests: ["Guests", "Ospiti", "Invitados", "Invit\xE9s", "G\xE4ste", "Visitantes"],
|
|
3348
|
+
category: ["Category", "Categoria", "Categoria", "Cat\xE9gorie", "Kategorie", "Categoria"],
|
|
3349
|
+
period: ["Period", "Periodo", "Per\xEDodo", "P\xE9riode", "Zeitraum", "Per\xEDodo"],
|
|
3350
|
+
rank: ["Rank", "Posizione", "Puesto", "Rang", "Platz", "Posicao"],
|
|
3351
|
+
score: ["Score", "Punteggio", "Puntos", "Score", "Punkte", "Pontos"],
|
|
3352
|
+
verified: ["Verified", "Verificato", "Verificado", "V\xE9rifi\xE9", "Verifiziert", "Verificado"],
|
|
3353
|
+
own: ["Your best", "Il tuo record", "Tu record", "Votre record", "Dein Rekord", "Seu recorde"],
|
|
3354
|
+
empty: ["No scores yet", "Nessun punteggio", "A\xFAn no hay puntos", "Aucun score", "Noch keine Punkte", "Ainda sem pontos"],
|
|
3355
|
+
saving: ["Saving score...", "Salvataggio punteggio...", "Guardando puntos...", "Enregistrement du score...", "Punkte werden gespeichert...", "Salvando pontos..."],
|
|
3356
|
+
saved: ["Your best is on the board", "Il tuo record \xE8 in classifica", "Tu record est\xE1 en la tabla", "Votre record est au classement", "Dein Rekord ist eingetragen", "Seu recorde est\xE1 na tabela"],
|
|
3357
|
+
bestAlready: ["Your best is already on the board", "Il tuo record era gi\xE0 in classifica", "Tu record ya estaba en la tabla", "Votre record est d\xE9j\xE0 au classement", "Dein Rekord ist bereits eingetragen", "Seu recorde j\xE1 est\xE1 na tabela"],
|
|
3358
|
+
refresh: ["Refresh", "Aggiorna", "Actualizar", "Actualiser", "Aktualisieren", "Atualizar"],
|
|
3359
|
+
refreshHint: ["Score not visible yet. Refresh to check.", "Punteggio non ancora visibile. Aggiorna per controllare.", "Puntos a\xFAn no visibles. Actualiza.", "Score pas encore visible. Actualisez.", "Punkte noch nicht sichtbar. Aktualisieren.", "Pontos ainda n\xE3o visiveis. Atualize."],
|
|
3360
|
+
friends: ["Friends & party", "Amici e gruppo", "Amigos y grupo", "Amis et groupe", "Freunde & Gruppe", "Amigos e grupo"],
|
|
3361
|
+
localCrew: ["Friends and party are unavailable in local preview.", "Amici e gruppo non disponibili in anteprima locale.", "Amigos y grupo no disponibles en la vista local.", "Amis et groupe indisponibles en aper\xE7u local.", "Freunde und Gruppe in lokaler Vorschau nicht verf\xFCgbar.", "Amigos e grupo indispon\xEDveis na pr\xE9via local."],
|
|
3362
|
+
loginCrew: ["Sign in on Caisual to use friends and party.", "Accedi a Caisual per amici e gruppo.", "Inicia sesion para amigos y grupo.", "Connectez-vous pour utiliser amis et groupe.", "F\xFCr Freunde und Gruppe bei Caisual anmelden.", "Entre no Caisual para amigos e grupo."],
|
|
3363
|
+
online: ["Online", "Online", "En linea", "En ligne", "Online", "Online"],
|
|
3364
|
+
noFriends: ["No friends online", "Nessun amico online", "Sin amigos en linea", "Aucun ami en ligne", "Keine Freunde online", "Nenhum amigo online"],
|
|
3365
|
+
createParty: ["Create party", "Crea gruppo", "Crear grupo", "Cr\xE9er un groupe", "Gruppe erstellen", "Criar grupo"],
|
|
3366
|
+
inviteParty: ["Invite to party", "Invita nel gruppo", "Invitar al grupo", "Inviter au groupe", "In Gruppe einladen", "Convidar para o grupo"],
|
|
3367
|
+
leaveParty: ["Leave party", "Lascia gruppo", "Salir del grupo", "Quitter le groupe", "Gruppe verlassen", "Sair do grupo"],
|
|
3368
|
+
accept: ["Accept", "Accetta", "Aceptar", "Accepter", "Annehmen", "Aceitar"],
|
|
3369
|
+
decline: ["Decline", "Rifiuta", "Rechazar", "Refuser", "Ablehnen", "Recusar"],
|
|
3370
|
+
follow: ["Join them", "Raggiungi", "Unirse", "Rejoindre", "Beitreten", "Juntar-se"],
|
|
3371
|
+
voice: ["Voice", "Voce", "Voz", "Voix", "Sprache", "Voz"],
|
|
3372
|
+
voiceJoin: ["Join voice", "Entra in voce", "Unirse a voz", "Activer la voix", "Sprachchat beitreten", "Entrar na voz"],
|
|
3373
|
+
voiceLeave: ["Leave voice", "Esci dalla voce", "Salir de voz", "Quitter la voix", "Sprachchat verlassen", "Sair da voz"],
|
|
3374
|
+
voiceMute: ["Mute", "Disattiva microfono", "Silenciar", "Couper le micro", "Stummschalten", "Silenciar"],
|
|
3375
|
+
voiceUnmute: ["Unmute", "Attiva microfono", "Activar micr\xF3fono", "Activer le micro", "Mikrofon aktivieren", "Ativar microfone"],
|
|
3376
|
+
voiceOff: ["Voice off", "Voce disattivata", "Voz desactivada", "Voix d\xE9sactiv\xE9e", "Sprachchat aus", "Voz desativada"],
|
|
3377
|
+
voiceJoining: ["Joining voice...", "Connessione voce...", "Conectando voz...", "Connexion vocale...", "Sprachchat verbindet...", "Conectando voz..."],
|
|
3378
|
+
voiceOn: ["Voice connected", "Voce connessa", "Voz conectada", "Voix connect\xE9e", "Sprachchat verbunden", "Voz conectada"],
|
|
3379
|
+
voiceMuted: ["Muted", "Microfono disattivato", "Silenciado", "Micro coup\xE9", "Stumm", "Silenciado"],
|
|
3380
|
+
voiceMic: ["Mic on", "Microfono attivo", "Micr\xF3fono activo", "Micro actif", "Mikrofon an", "Microfone ativo"],
|
|
3381
|
+
voiceListening: ["Listening only", "Solo ascolto", "Solo escucha", "\xC9coute seule", "Nur zuh\xF6ren", "Somente ouvindo"],
|
|
3382
|
+
voiceSpeaking: ["Speaking", "Sta parlando", "Hablando", "Parle", "Spricht", "Falando"],
|
|
3383
|
+
voicePeers: ["Voice participants", "Partecipanti in voce", "Participantes de voz", "Participants vocaux", "Sprachteilnehmer", "Participantes de voz"],
|
|
3384
|
+
voiceEmpty: ["No one else in voice yet.", "Nessun altro in voce per ora.", "A\xFAn no hay nadie m\xE1s en voz.", "Personne d\u2019autre en voix pour le moment.", "Noch niemand im Sprachchat.", "Ningu\xE9m mais na voz ainda."],
|
|
3385
|
+
voiceVolume: ["Volume for {name}", "Volume di {name}", "Volumen de {name}", "Volume de {name}", "Lautst\xE4rke f\xFCr {name}", "Volume de {name}"],
|
|
3386
|
+
voiceUnavailable: ["Join a room with voice to use these controls.", "Entra in una stanza con voce per usare questi controlli.", "Entra en una sala con voz para usar estos controles.", "Rejoignez une salle vocale pour utiliser ces commandes.", "Diese Steuerung braucht einen Raum mit Sprachchat.", "Entre em uma sala com voz para usar estes controles."],
|
|
3387
|
+
voiceWatch: ["Voice is unavailable while watching.", "La voce non e' disponibile in osservazione.", "La voz no est\xE1 disponible al observar.", "La voix est indisponible en observation.", "Beim Zuschauen ist kein Sprachchat verf\xFCgbar.", "A voz n\xE3o est\xE1 dispon\xEDvel ao assistir."],
|
|
3388
|
+
voiceDenied: ["Microphone permission denied. Allow it in your browser, then try again.", "Permesso microfono negato. Consenti l'accesso nel browser e riprova.", "Permiso de micr\xF3fono denegado. Act\xEDvalo en el navegador e int\xE9ntalo de nuevo.", "Acc\xE8s au micro refus\xE9. Autorisez-le dans le navigateur, puis r\xE9essayez.", "Mikrofonzugriff verweigert. Im Browser erlauben und erneut versuchen.", "Permiss\xE3o do microfone negada. Permita no navegador e tente novamente."],
|
|
3389
|
+
voiceUnsupported: ["Voice is not supported in this browser.", "Questo browser non supporta la voce.", "Este navegador no admite voz.", "Ce navigateur ne prend pas en charge la voix.", "Dieser Browser unterst\xFCtzt keinen Sprachchat.", "Este navegador n\xE3o oferece suporte a voz."],
|
|
3390
|
+
voiceFailed: ["Voice could not connect. Try again.", "Connessione voce non riuscita. Riprova.", "No se pudo conectar la voz. Int\xE9ntalo de nuevo.", "Connexion vocale impossible. R\xE9essayez.", "Sprachverbindung fehlgeschlagen. Erneut versuchen.", "N\xE3o foi poss\xEDvel conectar a voz. Tente novamente."],
|
|
3391
|
+
voicePeerGone: ["This participant has left voice.", "Questo partecipante e' uscito dalla voce.", "Este participante sali\xF3 de voz.", "Ce participant a quitt\xE9 la voix.", "Diese Person hat den Sprachchat verlassen.", "Este participante saiu da voz."],
|
|
3392
|
+
shortcut: ["Shift+Tab shortcut", "Scorciatoia Shift+Tab", "Atajo Shift+Tab", "Raccourci Maj+Tab", "Umschalt+Tab-Kurzbefehl", "Atalho Shift+Tab"],
|
|
3393
|
+
menu: ["Caisual menu", "Menu Caisual", "Menu Caisual", "Menu Caisual", "Caisual-Menu", "Menu Caisual"],
|
|
3394
|
+
retry: ["Retry", "Riprova", "Reintentar", "R\xE9essayer", "Erneut versuchen", "Tentar novamente"]
|
|
3395
|
+
};
|
|
3396
|
+
var column = (index) => Object.fromEntries(Object.entries(words).map(([key, row]) => [key, row[index]]));
|
|
3397
|
+
var dictionaries = { en: column(0), it: column(1), es: column(2), fr: column(3), de: column(4), pt: column(5) };
|
|
3398
|
+
var styles = `
|
|
3399
|
+
:host{all:initial;position:fixed;inset:0;z-index:10000;pointer-events:none;font:15px/1.45 system-ui,sans-serif;color:#f4f4f1;color-scheme:dark;--accent:#a8efc5}
|
|
3400
|
+
[data-layout],[data-surface],.sr{pointer-events:none}*{box-sizing:border-box}button,input,select{font:inherit}button,a,input,select{touch-action:manipulation}button,select,input{border:1px solid #ffffff30;background:#25292b;color:inherit;border-radius:12px;min-height:44px;padding:10px 14px}button{cursor:pointer}button:disabled{opacity:.45;cursor:default}button:hover:not(:disabled){background:#343b3a}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid var(--accent);outline-offset:3px}a{color:var(--accent)}.primary{background:var(--accent);color:var(--accent-ink,#11221b);border-color:transparent;font-weight:700}.primary:hover:not(:disabled){filter:brightness(1.1);background:var(--accent)}.quiet{background:transparent}label{display:grid;gap:6px;text-align:left}select,input{width:100%;min-width:0}h1,h2,p{margin:0}h1{font-size:clamp(26px,5vw,42px);line-height:1.1;letter-spacing:-.035em}h2{font-size:20px}small,.muted{color:#bdc5c1}.stack{display:grid;gap:16px}.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.row>*{flex:0 1 auto}.row .grow,.grow{flex:1}.split{display:grid;grid-template-columns:1fr 1fr;gap:10px}.pill{position:absolute;top:max(10px,env(safe-area-inset-top));right:max(10px,env(safe-area-inset-right));display:flex;height:44px;border:1px solid #ffffff35;border-radius:24px;background:#171e20eF;box-shadow:0 4px 20px #0004;pointer-events:auto;overflow:hidden}.pill button{border:0;border-radius:0;padding:8px 13px;background:transparent}.pill .exit{width:44px;border-left:1px solid #ffffff30}.pill button:focus-visible{outline-offset:-4px}.pill small{margin-left:8px}.backdrop{position:absolute;inset:0;background:#0b151ce8;backdrop-filter:blur(10px);pointer-events:auto;display:grid;place-items:center;padding:max(16px,env(safe-area-inset-top)) max(16px,env(safe-area-inset-right)) max(16px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left));overflow:auto}.backdrop.home{background-color:#142127;background-size:cover;background-position:center}.dialog{position:relative;width:min(100%,540px);max-height:100%;overflow:auto;overscroll-behavior:contain;background:#141b1df5;border:1px solid #ffffff25;border-radius:22px;padding:24px;box-shadow:0 20px 80px #0005}.dialog.wide{width:min(100%,700px)}.top{display:flex;align-items:center;gap:12px;margin-bottom:18px}.top h2{flex:1}.tabs{display:flex;gap:6px;flex-wrap:wrap;border-bottom:1px solid #ffffff25;padding-bottom:12px}.tabs button{min-height:36px;padding:6px 10px}.tabs [aria-current=true]{border-color:var(--accent)}.roster{list-style:none;padding:0;margin:0;display:grid;gap:8px;max-height:32dvh;overflow:auto}.roster li{display:flex;align-items:center;gap:8px;padding:10px;background:#ffffff08;border-radius:10px}.roster .name{flex:1;overflow-wrap:anywhere}.badge{border:1px solid #ffffff30;border-radius:6px;padding:2px 6px;font-size:12px}.code{font-size:24px;letter-spacing:.13em;font-variant-numeric:tabular-nums}.notice,.error{border-radius:10px;padding:10px;background:#a8efc514;overflow-wrap:anywhere}.error{background:#ff8b7720;color:#ffd2c9}.countdown{font-size:88px;line-height:1;text-align:center;font-variant-numeric:tabular-nums}.ended{position:absolute;bottom:max(12px,env(safe-area-inset-bottom));left:50%;transform:translateX(-50%);max-width:calc(100% - 24px);width:max-content;background:#171e20f5;pointer-events:auto;border:1px solid #ffffff30;border-radius:16px;padding:10px;display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap}.ended strong{padding:0 8px}.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.table-wrap{overflow:auto;max-height:38dvh}table{width:100%;border-collapse:collapse;text-align:left}th,td{padding:9px 6px;border-bottom:1px solid #ffffff20}td:nth-child(3){text-align:right}td small{display:block}.self{background:#ffffff0a}.checkbox{display:flex;align-items:center;gap:8px;font-size:13px}.checkbox input{width:18px;min-height:18px}.full{width:100%}
|
|
3401
|
+
[hidden]{display:none!important}.voice-peers{list-style:none;margin:0;padding:0;display:grid;gap:10px}.voice-peers li{border:1px solid #ffffff25;border-radius:12px;padding:12px;display:grid;gap:8px}.voice-peers [data-speaking=true]{border-color:var(--accent)}.voice-peers input{width:100%;accent-color:var(--accent);padding:0}.voice-peers label{font-size:13px}.pill .voice-toggle{width:44px;padding:8px}.voice-toggle[data-voice-state=on][data-muted=false]{color:var(--accent)}
|
|
3402
|
+
@media(max-width:480px){.dialog{padding:18px;border-radius:18px}.split{grid-template-columns:1fr 1fr;gap:8px}.tabs{gap:4px}.tabs button{font-size:13px;padding:6px 8px}.pill button:focus-visible{outline-offset:-4px}.pill small{display:none}.ended{gap:6px}.ended strong{font-size:13px}.ended button{padding:8px 10px;font-size:13px}.roster{max-height:28dvh}}
|
|
3403
|
+
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto}}
|
|
3404
|
+
`;
|
|
3405
|
+
|
|
2771
3406
|
// src/dev.ts
|
|
2772
3407
|
var DURATA_BIGLIETTO = 120;
|
|
2773
3408
|
var DURATA_INGRESSO = 60;
|
|
@@ -2783,11 +3418,23 @@ var VALORE_CHIAVE_MATCH = /^[A-Za-z0-9_.:-]+$/;
|
|
|
2783
3418
|
var PREFISSO_DEPOSITO = /^[a-z0-9_-]{0,32}$/;
|
|
2784
3419
|
var LIMITE_DEPOSITO2 = 64 * 1024;
|
|
2785
3420
|
var MASSIMO_CHIAVI_DEPOSITO = 1024;
|
|
3421
|
+
var VERSIONE_STATO_DEV = 1;
|
|
2786
3422
|
function erroreDeposito(code, message) {
|
|
2787
3423
|
return Object.assign(new Error(message), { code });
|
|
2788
3424
|
}
|
|
2789
3425
|
var DepositoDev = class {
|
|
3426
|
+
constructor(salva) {
|
|
3427
|
+
this.salva = salva;
|
|
3428
|
+
}
|
|
3429
|
+
salva;
|
|
2790
3430
|
valori = /* @__PURE__ */ new Map();
|
|
3431
|
+
carica(valori) {
|
|
3432
|
+
for (const [key, value] of valori) this.valori.set(key, structuredClone(value));
|
|
3433
|
+
}
|
|
3434
|
+
persisti() {
|
|
3435
|
+
const valori = [...this.valori.entries()].map(([key, value]) => ({ key, value: structuredClone(value) })).sort((left, right) => left.key.localeCompare(right.key));
|
|
3436
|
+
return this.salva(valori);
|
|
3437
|
+
}
|
|
2791
3438
|
verificaChiave(key) {
|
|
2792
3439
|
if (typeof key !== "string" || !CHIAVE_SAVE.test(key)) {
|
|
2793
3440
|
throw erroreDeposito("store_invalid_key", "The shared store key is invalid.");
|
|
@@ -2813,10 +3460,12 @@ var DepositoDev = class {
|
|
|
2813
3460
|
throw erroreDeposito("store_full", "The shared store is full.");
|
|
2814
3461
|
}
|
|
2815
3462
|
this.valori.set(key, JSON.parse(testo));
|
|
3463
|
+
await this.persisti();
|
|
2816
3464
|
}
|
|
2817
3465
|
async delete(key) {
|
|
2818
3466
|
this.verificaChiave(key);
|
|
2819
3467
|
this.valori.delete(key);
|
|
3468
|
+
await this.persisti();
|
|
2820
3469
|
}
|
|
2821
3470
|
async list(prefix = "") {
|
|
2822
3471
|
if (typeof prefix !== "string" || !PREFISSO_DEPOSITO.test(prefix)) {
|
|
@@ -2838,6 +3487,7 @@ var DepositoDev = class {
|
|
|
2838
3487
|
throw erroreDeposito("store_full", "The shared store is full.");
|
|
2839
3488
|
}
|
|
2840
3489
|
this.valori.set(key, result);
|
|
3490
|
+
await this.persisti();
|
|
2841
3491
|
return result;
|
|
2842
3492
|
}
|
|
2843
3493
|
};
|
|
@@ -2856,6 +3506,34 @@ var DevHttpError = class extends Error {
|
|
|
2856
3506
|
function object(value) {
|
|
2857
3507
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
2858
3508
|
}
|
|
3509
|
+
async function leggiJsonFacoltativo(path) {
|
|
3510
|
+
let testo;
|
|
3511
|
+
try {
|
|
3512
|
+
testo = await fs2.readFile(path, "utf8");
|
|
3513
|
+
} catch (cause) {
|
|
3514
|
+
if (cause.code === "ENOENT") return null;
|
|
3515
|
+
throw cause;
|
|
3516
|
+
}
|
|
3517
|
+
return JSON.parse(testo);
|
|
3518
|
+
}
|
|
3519
|
+
async function scriviFileAtomico(path, contenuto, mode) {
|
|
3520
|
+
await fs2.mkdir(dirname2(path), { recursive: true });
|
|
3521
|
+
const temporaneo = join2(
|
|
3522
|
+
dirname2(path),
|
|
3523
|
+
`.${basename(path)}.${process.pid}.${randomUUID2()}.tmp`
|
|
3524
|
+
);
|
|
3525
|
+
try {
|
|
3526
|
+
await fs2.writeFile(temporaneo, contenuto, { encoding: "utf8", mode });
|
|
3527
|
+
await fs2.rename(temporaneo, path);
|
|
3528
|
+
} catch (cause) {
|
|
3529
|
+
await fs2.rm(temporaneo, { force: true }).catch(() => void 0);
|
|
3530
|
+
throw cause;
|
|
3531
|
+
}
|
|
3532
|
+
}
|
|
3533
|
+
function scriviJsonAtomico(path, value) {
|
|
3534
|
+
return scriviFileAtomico(path, `${JSON.stringify(value, null, 2)}
|
|
3535
|
+
`);
|
|
3536
|
+
}
|
|
2859
3537
|
function base64Url(value) {
|
|
2860
3538
|
return Buffer.from(value).toString("base64url");
|
|
2861
3539
|
}
|
|
@@ -2902,14 +3580,14 @@ function serviceTicket(player, game, aud, secret) {
|
|
|
2902
3580
|
exp: iat + DURATA_BIGLIETTO
|
|
2903
3581
|
}, secret);
|
|
2904
3582
|
}
|
|
2905
|
-
function joinTicket(player, room, secret) {
|
|
3583
|
+
function joinTicket(player, room, secret, aud = "room") {
|
|
2906
3584
|
const iat = currentSeconds();
|
|
2907
3585
|
return signJwt({
|
|
2908
3586
|
sub: player.id,
|
|
2909
3587
|
name: player.name,
|
|
2910
3588
|
guest: player.guest,
|
|
2911
3589
|
room,
|
|
2912
|
-
aud
|
|
3590
|
+
aud,
|
|
2913
3591
|
iat,
|
|
2914
3592
|
exp: iat + DURATA_INGRESSO
|
|
2915
3593
|
}, secret);
|
|
@@ -2946,9 +3624,9 @@ function readServiceTicket(request, game, aud, secret) {
|
|
|
2946
3624
|
}
|
|
2947
3625
|
return payload;
|
|
2948
3626
|
}
|
|
2949
|
-
function readJoinTicket(token, room, secret) {
|
|
3627
|
+
function readJoinTicket(token, room, aud, secret) {
|
|
2950
3628
|
const payload = verifyJwt(token, secret);
|
|
2951
|
-
if (payload === null || payload.aud !==
|
|
3629
|
+
if (payload === null || payload.aud !== aud || payload.room !== room || typeof payload.sub !== "string" || payload.sub === "" || typeof payload.name !== "string" || payload.name === "" || typeof payload.guest !== "boolean" || !validTimes(payload, DURATA_INGRESSO)) return null;
|
|
2952
3630
|
return payload;
|
|
2953
3631
|
}
|
|
2954
3632
|
function readMatchTicket(token, game, secret) {
|
|
@@ -3108,12 +3786,16 @@ function parentPage(input) {
|
|
|
3108
3786
|
<title>Local preview: ${input.slug}</title>
|
|
3109
3787
|
<style>
|
|
3110
3788
|
html, body, iframe { width: 100%; height: 100%; margin: 0; border: 0; }
|
|
3789
|
+
html, body { overflow: hidden; }
|
|
3790
|
+
iframe { display: block; }
|
|
3111
3791
|
body { background: #111; }
|
|
3112
3792
|
</style>
|
|
3113
3793
|
</head>
|
|
3114
3794
|
<body>
|
|
3115
|
-
<iframe id="game" title="${input.slug}" src="${input.gameOrigin}/" allow="${input.allow}"></iframe>
|
|
3795
|
+
<iframe id="game" title="${input.slug}" data-src="${input.gameOrigin}/" allow="${input.allow}"></iframe>
|
|
3116
3796
|
<script type="module">
|
|
3797
|
+
import { creaPonteOspite, overlayConfiguration, mountOverlay } from '/__caisual/overlay/v1.js';
|
|
3798
|
+
const manifest = ${JSON.stringify(input.manifest).replaceAll("<", "\\u003c")};
|
|
3117
3799
|
const gameOrigin = ${JSON.stringify(input.gameOrigin)};
|
|
3118
3800
|
const portalOrigin = ${JSON.stringify(input.portalOrigin)};
|
|
3119
3801
|
const frame = document.getElementById('game');
|
|
@@ -3134,35 +3816,36 @@ function parentPage(input) {
|
|
|
3134
3816
|
const invite = normalizedInvite && /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/.test(normalizedInvite)
|
|
3135
3817
|
? normalizedInvite
|
|
3136
3818
|
: null;
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3819
|
+
const configuration = overlayConfiguration(manifest, manifest.cover ? gameOrigin + '/' + manifest.cover : null, invite);
|
|
3820
|
+
const bridge = creaPonteOspite({
|
|
3821
|
+
finestra: window, frame, origineGioco: gameOrigin, origineLive: portalOrigin,
|
|
3822
|
+
invite, ticket: session.portal,
|
|
3823
|
+
configuration,
|
|
3824
|
+
rinnova: async (aud) => { session = await getSession(); return session[aud]; },
|
|
3825
|
+
onRoom() {},
|
|
3826
|
+
});
|
|
3827
|
+
const overlay = mountOverlay({
|
|
3828
|
+
container: document.body, frame, bridge, configuration, player: session.player,
|
|
3829
|
+
language: new URL(location.href).searchParams.get('lang') || navigator.language,
|
|
3830
|
+
exit: () => { location.href = '/?lang=' + encodeURIComponent(new URL(location.href).searchParams.get('lang') || navigator.language); },
|
|
3831
|
+
inviteUrl: (code) => portalOrigin + '/?invite=' + code,
|
|
3832
|
+
boards: async (query) => {
|
|
3151
3833
|
session = await getSession();
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
},
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3834
|
+
const params = new URLSearchParams({ limit: '25' });
|
|
3835
|
+
if (query.period === 'daily') params.set('daily', '1');
|
|
3836
|
+
if (query.day) params.set('day', query.day);
|
|
3837
|
+
if (query.guests) params.set('guests', '1');
|
|
3838
|
+
const response = await fetch('/api/overlay/' + manifest.id + '/boards/' + encodeURIComponent(query.board) + '?' + params,
|
|
3839
|
+
{ headers: { Authorization: 'Bearer ' + session.portal }, cache: 'no-store' });
|
|
3840
|
+
if (!response.ok) throw new Error('The leaderboard is unavailable.');
|
|
3841
|
+
return response.json();
|
|
3842
|
+
},
|
|
3843
|
+
crew: { unavailable: 'local', getSnapshot: () => ({ connected: false, you: null, friends: [], party: null, invites: [], follow: null }),
|
|
3844
|
+
subscribe: () => () => {}, party: { create() {}, invite() {}, accept() {}, decline() {}, leave() {} }, follow() {} },
|
|
3845
|
+
});
|
|
3846
|
+
addEventListener('pagehide', () => { overlay?.dispose(); bridge.dispose(); }, { once: true });
|
|
3847
|
+
// Il gioco ha un'attesa limitata per il saluto: parte quando l'ospite puo' gia' rispondere.
|
|
3848
|
+
frame.src = frame.dataset.src;
|
|
3166
3849
|
</script>
|
|
3167
3850
|
</body>
|
|
3168
3851
|
</html>
|
|
@@ -3200,7 +3883,7 @@ async function loadDefinition(root) {
|
|
|
3200
3883
|
}
|
|
3201
3884
|
if (!stat.isFile()) throw new Error("server.js: file not readable.");
|
|
3202
3885
|
const { source } = await bundleServer(root);
|
|
3203
|
-
const kitUrl = `data:text/javascript;base64,${Buffer.from('// src/server/index.ts\nvar GAME_DEFINITION = /* @__PURE__ */ Symbol.for("@caisual/kit/game-definition");\nvar CALLBACKS = [\n "onCreate",\n "onStart",\n "onJoin",\n "onLeave",\n "onMessage",\n "onTick",\n "onEnd"\n];\nfunction isRecord(value) {\n return typeof value === "object" && value !== null && !Array.isArray(value);\n}\nfunction defineGame(definition) {\n if (!isRecord(definition)) {\n throw new TypeError("Game definition must be an object.");\n }\n if (typeof definition.tickRate !== "number" || !Number.isInteger(definition.tickRate) || definition.tickRate < 0 || definition.tickRate > 60) {\n throw new TypeError("Game definition tickRate must be an integer from 0 to 60.");\n }\n for (const callback of CALLBACKS) {\n const value = definition[callback];\n if (value !== void 0 && typeof value !== "function") {\n throw new TypeError(`Game definition ${callback} must be a function.`);\n }\n }\n Object.defineProperty(definition, GAME_DEFINITION, {\n configurable: false,\n enumerable: false,\n value: true,\n writable: false\n });\n return definition;\n}\nexport {\n defineGame\n};\n').toString("base64")}`;
|
|
3886
|
+
const kitUrl = `data:text/javascript;base64,${Buffer.from('// src/server/index.ts\nvar GAME_DEFINITION = /* @__PURE__ */ Symbol.for("@caisual/kit/game-definition");\nvar CALLBACKS = [\n "onCreate",\n "onStart",\n "onJoin",\n "onLeave",\n "onMessage",\n "onRoleRequest",\n "onTick",\n "onEnd"\n];\nfunction isRecord(value) {\n return typeof value === "object" && value !== null && !Array.isArray(value);\n}\nfunction defineGame(definition) {\n if (!isRecord(definition)) {\n throw new TypeError("Game definition must be an object.");\n }\n if (typeof definition.tickRate !== "number" || !Number.isInteger(definition.tickRate) || definition.tickRate < 0 || definition.tickRate > 60) {\n throw new TypeError("Game definition tickRate must be an integer from 0 to 60.");\n }\n for (const callback of CALLBACKS) {\n const value = definition[callback];\n if (value !== void 0 && typeof value !== "function") {\n throw new TypeError(`Game definition ${callback} must be a function.`);\n }\n }\n Object.defineProperty(definition, GAME_DEFINITION, {\n configurable: false,\n enumerable: false,\n value: true,\n writable: false\n });\n return definition;\n}\nexport {\n defineGame\n};\n').toString("base64")}`;
|
|
3204
3887
|
const rewritten = source.replace(
|
|
3205
3888
|
/(\bfrom\s*)(['"])@caisual\/kit\/server\2/g,
|
|
3206
3889
|
(_match, prefix) => `${prefix}${JSON.stringify(kitUrl)}`
|
|
@@ -3217,25 +3900,169 @@ var DevService = class {
|
|
|
3217
3900
|
this.manifest = manifest;
|
|
3218
3901
|
this.definition = definition;
|
|
3219
3902
|
this.port = port;
|
|
3903
|
+
this.deposito = new DepositoDev((valori) => this.persistShared(valori));
|
|
3220
3904
|
}
|
|
3221
3905
|
root;
|
|
3222
3906
|
clientRoot;
|
|
3223
3907
|
manifest;
|
|
3224
3908
|
definition;
|
|
3225
3909
|
port;
|
|
3226
|
-
secret =
|
|
3910
|
+
secret = Buffer.alloc(0);
|
|
3227
3911
|
playersBySession = /* @__PURE__ */ new Map();
|
|
3228
3912
|
playersById = /* @__PURE__ */ new Map();
|
|
3229
3913
|
saves = /* @__PURE__ */ new Map();
|
|
3230
3914
|
scores = /* @__PURE__ */ new Map();
|
|
3231
3915
|
rooms = /* @__PURE__ */ new Map();
|
|
3232
|
-
deposito
|
|
3916
|
+
deposito;
|
|
3917
|
+
roomIndex = /* @__PURE__ */ new Map();
|
|
3233
3918
|
roomByCode = /* @__PURE__ */ new Map();
|
|
3919
|
+
roomLoads = /* @__PURE__ */ new Map();
|
|
3234
3920
|
matchQueues = /* @__PURE__ */ new Map();
|
|
3235
3921
|
kitRequests = /* @__PURE__ */ new Map();
|
|
3236
3922
|
liveRequests = /* @__PURE__ */ new Map();
|
|
3237
3923
|
matchOperations = Promise.resolve();
|
|
3924
|
+
persistenceOperations = Promise.resolve();
|
|
3238
3925
|
playerNumber = 0;
|
|
3926
|
+
async initialize() {
|
|
3927
|
+
await Promise.all([
|
|
3928
|
+
this.loadSecret(),
|
|
3929
|
+
this.loadRoomIndex(),
|
|
3930
|
+
this.loadSaves(),
|
|
3931
|
+
this.loadShared()
|
|
3932
|
+
]);
|
|
3933
|
+
}
|
|
3934
|
+
statePath(name) {
|
|
3935
|
+
return join2(this.root, ".caisual-dev", name);
|
|
3936
|
+
}
|
|
3937
|
+
async loadSecret() {
|
|
3938
|
+
const path = this.statePath("secret");
|
|
3939
|
+
let encoded;
|
|
3940
|
+
try {
|
|
3941
|
+
encoded = (await fs2.readFile(path, "utf8")).trim();
|
|
3942
|
+
} catch (cause) {
|
|
3943
|
+
if (cause.code !== "ENOENT") throw cause;
|
|
3944
|
+
const secret2 = randomBytes(32);
|
|
3945
|
+
await scriviFileAtomico(path, `${secret2.toString("base64url")}
|
|
3946
|
+
`, 384);
|
|
3947
|
+
this.secret = secret2;
|
|
3948
|
+
return;
|
|
3949
|
+
}
|
|
3950
|
+
const secret = Buffer.from(encoded, "base64url");
|
|
3951
|
+
if (secret.byteLength !== 32 || secret.toString("base64url") !== encoded) {
|
|
3952
|
+
throw new Error("The local development secret is invalid.");
|
|
3953
|
+
}
|
|
3954
|
+
this.secret = secret;
|
|
3955
|
+
}
|
|
3956
|
+
async loadRoomIndex() {
|
|
3957
|
+
const value = await leggiJsonFacoltativo(this.statePath("rooms.json"));
|
|
3958
|
+
if (value === null) return;
|
|
3959
|
+
const file = object(value);
|
|
3960
|
+
if (file?.version !== VERSIONE_STATO_DEV || !Array.isArray(file.rooms)) {
|
|
3961
|
+
throw new Error("The local room index is invalid.");
|
|
3962
|
+
}
|
|
3963
|
+
for (const valueRoom of file.rooms) {
|
|
3964
|
+
const room = object(valueRoom);
|
|
3965
|
+
if (room === null || typeof room.roomId !== "string" || !/^g1-1\.[a-z0-9]{16}$/.test(room.roomId) || typeof room.code !== "string" || !FORMA_CODICE.test(room.code) || typeof room.game !== "string" || room.mode !== null && typeof room.mode !== "string" || this.roomIndex.has(room.roomId) || this.roomByCode.has(room.code)) {
|
|
3966
|
+
throw new Error("The local room index is invalid.");
|
|
3967
|
+
}
|
|
3968
|
+
const record2 = {
|
|
3969
|
+
roomId: room.roomId,
|
|
3970
|
+
code: room.code,
|
|
3971
|
+
game: room.game,
|
|
3972
|
+
mode: room.mode
|
|
3973
|
+
};
|
|
3974
|
+
this.roomIndex.set(record2.roomId, record2);
|
|
3975
|
+
this.roomByCode.set(record2.code, record2.roomId);
|
|
3976
|
+
}
|
|
3977
|
+
}
|
|
3978
|
+
async loadSaves() {
|
|
3979
|
+
const value = await leggiJsonFacoltativo(this.statePath("saves.json"));
|
|
3980
|
+
if (value === null) return;
|
|
3981
|
+
const file = object(value);
|
|
3982
|
+
if (file?.version !== VERSIONE_STATO_DEV || !Array.isArray(file.players)) {
|
|
3983
|
+
throw new Error("The local player saves are invalid.");
|
|
3984
|
+
}
|
|
3985
|
+
for (const valuePlayer of file.players) {
|
|
3986
|
+
const player = object(valuePlayer);
|
|
3987
|
+
if (player === null || typeof player.game !== "string" || typeof player.playerId !== "string" || !Array.isArray(player.saves)) {
|
|
3988
|
+
throw new Error("The local player saves are invalid.");
|
|
3989
|
+
}
|
|
3990
|
+
const id = `${player.game}\0${player.playerId}`;
|
|
3991
|
+
if (this.saves.has(id)) throw new Error("The local player saves are invalid.");
|
|
3992
|
+
const records = /* @__PURE__ */ new Map();
|
|
3993
|
+
for (const valueSave of player.saves) {
|
|
3994
|
+
const save = object(valueSave);
|
|
3995
|
+
if (save === null || typeof save.key !== "string" || !CHIAVE_SAVE.test(save.key) || !Number.isSafeInteger(save.bytes) || save.bytes < 0 || !Number.isSafeInteger(save.updatedAt) || save.updatedAt < 0 || !Object.hasOwn(save, "value") || records.has(save.key)) {
|
|
3996
|
+
throw new Error("The local player saves are invalid.");
|
|
3997
|
+
}
|
|
3998
|
+
records.set(save.key, {
|
|
3999
|
+
value: structuredClone(save.value),
|
|
4000
|
+
bytes: save.bytes,
|
|
4001
|
+
updatedAt: save.updatedAt
|
|
4002
|
+
});
|
|
4003
|
+
}
|
|
4004
|
+
this.saves.set(id, records);
|
|
4005
|
+
}
|
|
4006
|
+
}
|
|
4007
|
+
async loadShared() {
|
|
4008
|
+
const value = await leggiJsonFacoltativo(this.statePath("shared.json"));
|
|
4009
|
+
if (value === null) return;
|
|
4010
|
+
const file = object(value);
|
|
4011
|
+
if (file?.version !== VERSIONE_STATO_DEV || !Array.isArray(file.values) || file.values.length > MASSIMO_CHIAVI_DEPOSITO) {
|
|
4012
|
+
throw new Error("The local shared store is invalid.");
|
|
4013
|
+
}
|
|
4014
|
+
const valori = /* @__PURE__ */ new Map();
|
|
4015
|
+
for (const valueEntry of file.values) {
|
|
4016
|
+
const entry = object(valueEntry);
|
|
4017
|
+
if (entry === null || typeof entry.key !== "string" || !CHIAVE_SAVE.test(entry.key) || !Object.hasOwn(entry, "value") || valori.has(entry.key)) {
|
|
4018
|
+
throw new Error("The local shared store is invalid.");
|
|
4019
|
+
}
|
|
4020
|
+
const serialized = JSON.stringify(entry.value);
|
|
4021
|
+
if (serialized === void 0 || Buffer.byteLength(serialized, "utf8") > LIMITE_DEPOSITO2) {
|
|
4022
|
+
throw new Error("The local shared store is invalid.");
|
|
4023
|
+
}
|
|
4024
|
+
valori.set(entry.key, JSON.parse(serialized));
|
|
4025
|
+
}
|
|
4026
|
+
this.deposito.carica([...valori.entries()]);
|
|
4027
|
+
}
|
|
4028
|
+
serializePersistence(operation) {
|
|
4029
|
+
const result = this.persistenceOperations.then(operation);
|
|
4030
|
+
this.persistenceOperations = result.then(() => void 0, () => void 0);
|
|
4031
|
+
return result;
|
|
4032
|
+
}
|
|
4033
|
+
persistRoomIndex() {
|
|
4034
|
+
return this.serializePersistence(async () => {
|
|
4035
|
+
const rooms = [...this.roomIndex.values()].sort((left, right) => left.roomId.localeCompare(right.roomId));
|
|
4036
|
+
await scriviJsonAtomico(this.statePath("rooms.json"), {
|
|
4037
|
+
version: VERSIONE_STATO_DEV,
|
|
4038
|
+
rooms
|
|
4039
|
+
});
|
|
4040
|
+
});
|
|
4041
|
+
}
|
|
4042
|
+
persistSaves() {
|
|
4043
|
+
return this.serializePersistence(async () => {
|
|
4044
|
+
const players = [...this.saves.entries()].map(([id, records]) => {
|
|
4045
|
+
const separator = id.indexOf("\0");
|
|
4046
|
+
return {
|
|
4047
|
+
game: id.slice(0, separator),
|
|
4048
|
+
playerId: id.slice(separator + 1),
|
|
4049
|
+
saves: [...records.entries()].map(([key, record2]) => ({ key, ...record2 })).sort((left, right) => left.key.localeCompare(right.key))
|
|
4050
|
+
};
|
|
4051
|
+
}).sort(
|
|
4052
|
+
(left, right) => left.game.localeCompare(right.game) || left.playerId.localeCompare(right.playerId)
|
|
4053
|
+
);
|
|
4054
|
+
await scriviJsonAtomico(this.statePath("saves.json"), {
|
|
4055
|
+
version: VERSIONE_STATO_DEV,
|
|
4056
|
+
players
|
|
4057
|
+
});
|
|
4058
|
+
});
|
|
4059
|
+
}
|
|
4060
|
+
persistShared(valori) {
|
|
4061
|
+
return this.serializePersistence(() => scriviJsonAtomico(this.statePath("shared.json"), {
|
|
4062
|
+
version: VERSIONE_STATO_DEV,
|
|
4063
|
+
values: valori
|
|
4064
|
+
}));
|
|
4065
|
+
}
|
|
3239
4066
|
get portalOrigin() {
|
|
3240
4067
|
return `http://localhost:${this.port}`;
|
|
3241
4068
|
}
|
|
@@ -3266,13 +4093,20 @@ var DevService = class {
|
|
|
3266
4093
|
this.rejectUpgrade(socket, 404, "room_not_found", "The room was not found.");
|
|
3267
4094
|
return;
|
|
3268
4095
|
}
|
|
3269
|
-
const localRoom = this.
|
|
4096
|
+
const localRoom = await this.loadLocalRoom(match[1]);
|
|
3270
4097
|
if (localRoom === void 0) {
|
|
3271
4098
|
this.rejectUpgrade(socket, 404, "room_not_found", "The room was not found.");
|
|
3272
4099
|
return;
|
|
3273
4100
|
}
|
|
3274
|
-
const
|
|
3275
|
-
const
|
|
4101
|
+
const tokenRoom = url.searchParams.get("j");
|
|
4102
|
+
const tokenWatch = url.searchParams.get("w");
|
|
4103
|
+
if (tokenRoom !== null && tokenWatch !== null) {
|
|
4104
|
+
this.rejectUpgrade(socket, 400, "invalid_request", "Provide either a room token or a watch token.");
|
|
4105
|
+
return;
|
|
4106
|
+
}
|
|
4107
|
+
const aud = tokenWatch === null ? "room" : "watch";
|
|
4108
|
+
const token = tokenWatch ?? tokenRoom;
|
|
4109
|
+
const joined = token === null ? null : readJoinTicket(token, match[1], aud, this.secret);
|
|
3276
4110
|
const origin = request.headers.origin;
|
|
3277
4111
|
const nodeClient = origin === void 0 && request.headers["user-agent"] === "node";
|
|
3278
4112
|
if (joined === null || origin !== this.gameOrigin && !nodeClient) {
|
|
@@ -3280,6 +4114,30 @@ var DevService = class {
|
|
|
3280
4114
|
return;
|
|
3281
4115
|
}
|
|
3282
4116
|
const identity = playerFromTicket(joined);
|
|
4117
|
+
try {
|
|
4118
|
+
this.checkRate(this.liveRequests, identity.id);
|
|
4119
|
+
} catch (cause) {
|
|
4120
|
+
const error = cause instanceof DevHttpError ? cause : new DevHttpError(429, "rate_limited", "Too many game API requests were sent.");
|
|
4121
|
+
this.rejectUpgrade(socket, error.status, error.code, error.message);
|
|
4122
|
+
return;
|
|
4123
|
+
}
|
|
4124
|
+
if (aud === "watch") {
|
|
4125
|
+
const permission2 = await localRoom.room.canWatch();
|
|
4126
|
+
if (!permission2.ok) {
|
|
4127
|
+
const status = permission2.code === "room_not_found" ? 404 : 409;
|
|
4128
|
+
this.rejectUpgrade(socket, status, permission2.code, this.roomErrorMessage(permission2.code));
|
|
4129
|
+
return;
|
|
4130
|
+
}
|
|
4131
|
+
try {
|
|
4132
|
+
const websocket = acceptNodeWebSocket2(request, socket, head);
|
|
4133
|
+
await localRoom.room.watch(websocket, identity);
|
|
4134
|
+
} catch {
|
|
4135
|
+
if (!socket.destroyed) {
|
|
4136
|
+
this.rejectUpgrade(socket, 400, "invalid_request", "The WebSocket request is invalid.");
|
|
4137
|
+
}
|
|
4138
|
+
}
|
|
4139
|
+
return;
|
|
4140
|
+
}
|
|
3283
4141
|
const permission = await localRoom.room.canJoin(identity);
|
|
3284
4142
|
if (!permission.ok) {
|
|
3285
4143
|
const status = permission.code === "room_not_found" ? 404 : 409;
|
|
@@ -3397,8 +4255,10 @@ var DevService = class {
|
|
|
3397
4255
|
for (const [playerId, expiresAt] of localRoom.pendingMatch) {
|
|
3398
4256
|
if (expiresAt <= now) localRoom.pendingMatch.delete(playerId);
|
|
3399
4257
|
}
|
|
3400
|
-
|
|
3401
|
-
|
|
4258
|
+
if (info.mode !== ticket.mode) continue;
|
|
4259
|
+
const risolta = risolviModalita(this.manifest, info.mode);
|
|
4260
|
+
const canEnter = info.status === "lobby" || info.status === "playing" && !risolta.lobby;
|
|
4261
|
+
if (!canEnter || info.players + localRoom.pendingMatch.size >= risolta.players.max) continue;
|
|
3402
4262
|
const permission = await localRoom.room.canJoin(playerFromTicket(ticket));
|
|
3403
4263
|
if (!permission.ok) {
|
|
3404
4264
|
if (permission.code === "room_not_found" || permission.code === "room_ended") {
|
|
@@ -3516,6 +4376,7 @@ var DevService = class {
|
|
|
3516
4376
|
for (const waiting of queue.waiting) waiting.socket.close(1001, "server_shutdown");
|
|
3517
4377
|
}
|
|
3518
4378
|
this.matchQueues.clear();
|
|
4379
|
+
await this.persistenceOperations;
|
|
3519
4380
|
await Promise.all([...this.rooms.values()].map((entry) => entry.room.close()));
|
|
3520
4381
|
}
|
|
3521
4382
|
async handleGame(request, response, url) {
|
|
@@ -3528,7 +4389,7 @@ var DevService = class {
|
|
|
3528
4389
|
response.setHeader("Content-Type", "text/javascript; charset=utf-8");
|
|
3529
4390
|
response.setHeader("Cache-Control", "no-store");
|
|
3530
4391
|
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
3531
|
-
response.end(request.method === "HEAD" ? void 0 : '// Caisual game kit v0.4.0\n\n// ../contracts/src/slug.ts\nvar NOMI_RISERVATI = [\n "www",\n "api",\n "app",\n "play",\n "live",\n "multi",\n "cdn",\n "assets",\n "static",\n "mail",\n "mx",\n "ns1",\n "ns2",\n "autodiscover",\n "_dmarc",\n "admin",\n "login",\n "account",\n "auth",\n "pay",\n "secure",\n "support",\n "help",\n "blog",\n "status",\n "dev",\n "staging",\n "test",\n "caisual",\n "shipz"\n];\nvar RISERVATI = new Set(NOMI_RISERVATI);\n\n// ../contracts/src/device.ts\nfunction deviceTier(report) {\n if (report.gpu !== "hardware" || report.memoryMb !== null && report.memoryMb <= 2048) return "low";\n if (report.mobile || report.memoryMb !== null && report.memoryMb <= 4096 || report.cores !== null && report.cores <= 4) return "mid";\n return "high";\n}\nfunction perdiContesto(context) {\n try {\n context?.getExtension("WEBGL_lose_context")?.loseContext();\n } catch {\n }\n}\nfunction valoriSincroni(ambiente) {\n let navigator2;\n try {\n navigator2 = ambiente.navigator;\n } catch {\n navigator2 = void 0;\n }\n let memoryMb = null;\n try {\n const memory = navigator2?.deviceMemory;\n const converted = typeof memory === "number" ? memory * 1024 : NaN;\n if (Number.isFinite(converted)) memoryMb = converted;\n } catch {\n memoryMb = null;\n }\n let cores = null;\n try {\n const value = navigator2?.hardwareConcurrency;\n if (typeof value === "number" && Number.isFinite(value)) cores = value;\n } catch {\n cores = null;\n }\n let mobile = false;\n try {\n mobile = typeof navigator2?.userAgentData?.mobile === "boolean" ? navigator2.userAgentData.mobile : /Android|iPhone|iPad|iPod|Mobile/i.test(navigator2?.userAgent ?? "");\n } catch {\n mobile = false;\n }\n let isolated = false;\n try {\n isolated = ambiente.crossOriginIsolated === true;\n } catch {\n isolated = false;\n }\n return {\n webgl2: false,\n webgpu: false,\n wasm: false,\n threads: false,\n isolated,\n gpu: "none",\n memoryMb,\n cores,\n mobile\n };\n}\nasync function probeDevice(globals, timeoutMs = 1500) {\n const ambiente = globals ?? globalThis;\n const report = valoriSincroni(ambiente);\n const webgl = Promise.resolve().then(() => {\n try {\n const canvas = ambiente.document?.createElement("canvas");\n if (canvas === void 0) return;\n const hardware = canvas.getContext("webgl2", { failIfMajorPerformanceCaveat: true });\n if (hardware !== null) {\n report.webgl2 = true;\n report.gpu = "hardware";\n perdiContesto(hardware);\n return;\n }\n const software = canvas.getContext("webgl2");\n if (software !== null) {\n report.webgl2 = true;\n report.gpu = "software";\n perdiContesto(software);\n }\n } catch {\n report.webgl2 = false;\n report.gpu = "none";\n }\n });\n const webgpu = Promise.resolve().then(async () => {\n let device;\n try {\n const gpu = ambiente.navigator?.gpu;\n if (gpu === void 0) return;\n const adapter = await gpu.requestAdapter();\n if (adapter === null) return;\n device = await adapter.requestDevice();\n report.webgpu = true;\n } catch {\n report.webgpu = false;\n } finally {\n try {\n device?.destroy?.();\n } catch {\n }\n }\n });\n const wasm = Promise.resolve().then(() => {\n try {\n report.wasm = ambiente.WebAssembly?.validate(\n new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0])\n ) === true;\n } catch {\n report.wasm = false;\n }\n });\n const threads = Promise.resolve().then(() => {\n try {\n if (ambiente.WebAssembly === void 0) return;\n new ambiente.WebAssembly.Memory({ initial: 1, maximum: 1, shared: true });\n report.threads = true;\n } catch {\n report.threads = false;\n }\n });\n let timer;\n await Promise.race([\n Promise.all([webgl, webgpu, wasm, threads]),\n new Promise((resolve) => {\n timer = setTimeout(resolve, Math.max(0, timeoutMs));\n })\n ]);\n if (timer !== void 0) clearTimeout(timer);\n return { ...report, tier: deviceTier(report) };\n}\n\n// src/errors.ts\nfunction creaErrore(code, message) {\n return Object.assign(new Error(message), { name: "CaisualError", code });\n}\nfunction erroreOffline() {\n return creaErrore("offline", "Caisual services are unavailable.");\n}\nfunction codiceErrore(valore) {\n return typeof valore === "object" && valore !== null && "code" in valore ? valore.code : null;\n}\n\n// src/http.ts\nasync function leggiErrore(response) {\n let corpo = {};\n try {\n corpo = await response.json();\n } catch {\n }\n return creaErrore(\n typeof corpo.error?.code === "string" ? corpo.error.code : response.status === 401 ? "invalid_ticket" : "internal_error",\n typeof corpo.error?.message === "string" ? corpo.error.message : `The request failed with status ${response.status}.`\n );\n}\nfunction creaRichiedente(origin, prefix, fetcher, biglietto) {\n async function manda(path, metodo, ticket, corpo) {\n const headers = new Headers({ Authorization: `Bearer ${ticket}` });\n let body;\n if (corpo !== void 0) {\n headers.set("Content-Type", "application/json");\n try {\n body = JSON.stringify(corpo);\n } catch {\n throw creaErrore("invalid_request", "The value must be valid JSON.");\n }\n }\n try {\n return await fetcher(new URL(prefix + path, origin), {\n method: metodo,\n headers,\n body,\n credentials: "omit"\n });\n } catch {\n throw erroreOffline();\n }\n }\n return async function richiesta(path, metodo, corpo, forzaRinnovo = false) {\n let ticket;\n try {\n ticket = forzaRinnovo ? await biglietto.rinnova() : await biglietto.ottieni();\n } catch {\n throw erroreOffline();\n }\n let response = await manda(path, metodo, ticket, corpo);\n if (response.status === 401) {\n try {\n ticket = await biglietto.rinnova();\n } catch {\n throw erroreOffline();\n }\n response = await manda(path, metodo, ticket, corpo);\n }\n if (!response.ok) throw await leggiErrore(response);\n try {\n return await response.json();\n } catch {\n throw creaErrore("internal_error", "The service returned an invalid response.");\n }\n };\n}\n\n// src/api.ts\nfunction creaClienteApi(appOrigin, fetcher, biglietto) {\n const richiesta = creaRichiedente(appOrigin, "/api/kit", fetcher, biglietto);\n return {\n me: () => richiesta("/me", "GET"),\n saveSet: (key, value) => richiesta(`/saves/${encodeURIComponent(key)}`, "PUT", { value }),\n async saveGet(key) {\n try {\n return (await richiesta(`/saves/${encodeURIComponent(key)}`, "GET")).value;\n } catch (errore) {\n if (codiceErrore(errore) === "not_found") return null;\n throw errore;\n }\n },\n async saveRemove(key) {\n await richiesta(`/saves/${encodeURIComponent(key)}`, "DELETE");\n },\n async saveList() {\n return (await richiesta("/saves", "GET")).saves;\n },\n async boardSubmit(board, score, daily) {\n const risultato = await richiesta("/scores", "POST", { board, score, daily });\n return { accepted: true, best: risultato.best, rank: risultato.rank, day: risultato.day };\n },\n async boardTop(board, opzioni) {\n const query = new URLSearchParams();\n if (opzioni.daily) query.set("daily", "1");\n if (opzioni.limit !== void 0) query.set("limit", String(opzioni.limit));\n if (opzioni.guests) query.set("guests", "1");\n const suffisso = query.size === 0 ? "" : `?${query.toString()}`;\n const { day, entries, me } = await richiesta(\n `/scores/${encodeURIComponent(board)}${suffisso}`,\n "GET"\n );\n return { day, entries, me };\n }\n };\n}\n\n// src/daily.ts\nvar DIVISORE_UINT32 = 4294967296;\nfunction giornoUtc(ora) {\n return new Date(ora).toISOString().slice(0, 10);\n}\nasync function calcolaSeed(gioco, giorno, subtle) {\n const dati = new TextEncoder().encode(`caisual:${gioco}:${giorno}`);\n const digest = new Uint8Array(await subtle.digest("SHA-256", dati));\n return (digest[0] ?? 0) * 16777216 + ((digest[1] ?? 0) << 16) + ((digest[2] ?? 0) << 8) + (digest[3] ?? 0) >>> 0;\n}\nfunction creaMulberry32(seed) {\n let stato = seed >>> 0;\n return () => {\n stato = stato + 1831565813 >>> 0;\n let valore = stato;\n valore = Math.imul(valore ^ valore >>> 15, valore | 1);\n valore ^= valore + Math.imul(valore ^ valore >>> 7, valore | 61);\n return ((valore ^ valore >>> 14) >>> 0) / DIVISORE_UINT32;\n };\n}\n\n// src/handshake.ts\nfunction record(valore) {\n return typeof valore === "object" && valore !== null && !Array.isArray(valore) ? valore : null;\n}\nfunction eTipo(valore, tipo) {\n return record(valore)?.type === tipo;\n}\nfunction leggiOrigine(valore) {\n if (typeof valore !== "string") return null;\n try {\n const url = new URL(valore);\n return url.origin === valore && (url.protocol === "https:" || url.protocol === "http:") ? valore : null;\n } catch {\n return null;\n }\n}\nfunction attendiHandshake(finestra, appOrigin, timeoutMs = 3e3) {\n return new Promise((resolve) => {\n let concluso = false;\n const termina = (esito) => {\n if (concluso) return;\n concluso = true;\n finestra.removeEventListener("message", ascolta);\n finestra.clearTimeout(scadenza);\n resolve(esito);\n };\n const segnalaPronto = () => {\n finestra.parent.postMessage({ type: "caisual:ready" }, appOrigin);\n };\n const ascolta = (evento) => {\n if (evento.origin !== appOrigin || evento.source !== finestra.parent) return;\n if (eTipo(evento.data, "caisual:ready?")) {\n segnalaPronto();\n return;\n }\n if (!eTipo(evento.data, "caisual:hello")) return;\n const dati = record(evento.data);\n const porta = evento.ports[0];\n if (typeof dati?.ticket !== "string" || porta === void 0) return;\n porta.start();\n termina({\n ticket: dati.ticket,\n live: leggiOrigine(dati.live),\n invite: typeof dati.invite === "string" ? dati.invite : null,\n porta\n });\n };\n finestra.addEventListener("message", ascolta);\n const scadenza = finestra.setTimeout(() => termina(null), timeoutMs);\n segnalaPronto();\n });\n}\nfunction scadenzaJwt(ticket) {\n const parte = ticket.split(".")[1];\n if (parte === void 0) return null;\n const base64 = parte.replace(/-/g, "+").replace(/_/g, "/").padEnd(\n Math.ceil(parte.length / 4) * 4,\n "="\n );\n try {\n const payload = record(JSON.parse(globalThis.atob(base64)));\n return typeof payload?.exp === "number" && Number.isFinite(payload.exp) ? payload.exp * 1e3 : null;\n } catch {\n return null;\n }\n}\nfunction chiediBiglietto(porta, finestra, timeoutMs, aud) {\n return new Promise((resolve, reject) => {\n let concluso = false;\n const termina = (ticket) => {\n if (concluso) return;\n concluso = true;\n porta.removeEventListener("message", ascolta);\n finestra.clearTimeout(scadenza);\n if (ticket === null) reject(new Error("Ticket refresh timed out."));\n else resolve(ticket);\n };\n const ascolta = (evento) => {\n const dati = record(evento.data);\n const destinatario = dati?.aud === void 0 ? "portal" : dati.aud;\n if (dati?.type === "caisual:ticket" && destinatario === aud && typeof dati.ticket === "string") {\n termina(dati.ticket);\n }\n };\n porta.addEventListener("message", ascolta);\n const scadenza = finestra.setTimeout(() => termina(null), timeoutMs);\n try {\n porta.postMessage(aud === "live" ? { type: "caisual:ticket", aud: "live" } : { type: "caisual:ticket" });\n } catch {\n termina(null);\n }\n });\n}\nfunction creaGestoreBiglietto(ticketIniziale, porta, finestra, ora, timeoutMs = 3e3, aud = "portal") {\n let ticket = ticketIniziale;\n let rinnovo = null;\n const rinnova = () => {\n if (rinnovo !== null) return rinnovo;\n const richiesta = chiediBiglietto(porta, finestra, timeoutMs, aud).then((nuovo) => {\n ticket = nuovo;\n return nuovo;\n });\n const completa = richiesta.finally(() => {\n if (rinnovo === completa) rinnovo = null;\n });\n rinnovo = completa;\n return completa;\n };\n return {\n async ottieni() {\n if (ticket === null) return rinnova();\n const scadenza = scadenzaJwt(ticket);\n return scadenza !== null && scadenza - ora() < 3e4 ? rinnova() : ticket;\n },\n rinnova\n };\n}\n\n// src/voce/index.ts\nvar SOGLIA_AUDIO = 0.02;\nvar DURATA_PARLANTE = 300;\nvar INTERVALLO_AUDIO = 200;\nvar DURATA_ZERO = 3e3;\nvar TIMEOUT_CONNESSIONE = 1e4;\nvar RITARDI_RICONNESSIONE = [1e3, 2e3, 4e3];\nfunction limita(value) {\n return Number.isNaN(value) ? 1 : Math.min(1, Math.max(0, value));\n}\nfunction dipendenzeReali(input) {\n const globali = globalThis;\n const AudioContextClass = globali.AudioContext ?? globali.webkitAudioContext;\n if (typeof RTCPeerConnection === "undefined" || typeof MediaStream === "undefined" || AudioContextClass === void 0 || typeof navigator === "undefined" || navigator.mediaDevices?.getUserMedia === void 0 || typeof document === "undefined") return null;\n return {\n ...input,\n creaPeerConnection: (configuration) => new RTCPeerConnection(configuration),\n getUserMedia: (constraints) => navigator.mediaDevices.getUserMedia(constraints),\n creaAudioContext: () => new AudioContextClass(),\n creaAudioElement: () => document.createElement("audio"),\n creaMediaStream: (tracks) => new MediaStream(tracks)\n };\n}\nvar VoceClient = class {\n constructor(contesto, timer, dipendenze) {\n this.contesto = contesto;\n this.modeCorrente = "none";\n this.stateCorrente = "off";\n this.mutedCorrente = false;\n this.speakingCorrente = false;\n this.roster = [];\n this.gains = /* @__PURE__ */ new Map();\n this.volumi = /* @__PURE__ */ new Map();\n this.speakingPeers = /* @__PURE__ */ new Map();\n this.ultimoAudio = /* @__PURE__ */ new Map();\n this.zeroDa = /* @__PURE__ */ new Map();\n this.timerZero = /* @__PURE__ */ new Map();\n this.ascoltatoriPeers = /* @__PURE__ */ new Set();\n this.ascoltatoriState = /* @__PURE__ */ new Set();\n this.richieste = /* @__PURE__ */ new Map();\n this.riproduzioni = /* @__PURE__ */ new Map();\n this.sfuAttive = /* @__PURE__ */ new Map();\n this.midGiocatori = /* @__PURE__ */ new Map();\n this.mesh = /* @__PURE__ */ new Map();\n this.stream = null;\n this.tracciaMic = null;\n this.audioContext = null;\n this.analyser = null;\n this.peerSfu = null;\n this.sessioneSfu = null;\n this.connessioneSfuAttesa = false;\n this.trasporto = null;\n this.intervalloAudio = null;\n this.timerConnessione = null;\n this.cancellaAttesaConnessione = null;\n this.timerRiconnessione = null;\n this.ultimoAudioMic = Number.NEGATIVE_INFINITY;\n this.sequenzaRichieste = 0;\n this.generazione = 0;\n this.tentativoRiconnessione = 0;\n this.desiderata = false;\n this.micDesiderato = true;\n this.promessaIngresso = null;\n this.negoziazione = Promise.resolve();\n this.dipendenze = dipendenze ?? dipendenzeReali(timer);\n }\n get mode() {\n return this.modeCorrente;\n }\n get state() {\n return this.stateCorrente;\n }\n get mic() {\n return this.stateCorrente === "on" && this.tracciaMic !== null;\n }\n get muted() {\n return this.mutedCorrente;\n }\n get speaking() {\n return this.speakingCorrente;\n }\n get peers() {\n return this.copiaPeers();\n }\n async join(options = {}) {\n if (this.stateCorrente === "on") return;\n if (this.stateCorrente === "joining") {\n if (this.promessaIngresso !== null) await this.promessaIngresso;\n return;\n }\n if (this.stateCorrente === "reconnecting" && this.desiderata) return;\n const mic = this.scegliMic(options);\n this.verificaIngresso(mic);\n this.micDesiderato = mic;\n this.desiderata = true;\n this.tentativoRiconnessione = 0;\n this.aggiornaState("joining");\n const generazione = ++this.generazione;\n const promessa = this.completaIngresso(generazione);\n this.promessaIngresso = promessa;\n try {\n await promessa;\n } finally {\n if (this.promessaIngresso === promessa) this.promessaIngresso = null;\n }\n }\n async completaIngresso(generazione) {\n try {\n await this.entra(generazione);\n } catch (cause) {\n if (generazione !== this.generazione) return;\n this.desiderata = false;\n this.chiudiRisorse();\n this.aggiornaState("off");\n throw this.mappaErrore(cause);\n }\n }\n leave() {\n const deveFermare = this.desiderata || this.stateCorrente !== "off";\n this.desiderata = false;\n this.generazione++;\n this.fermaRiconnessione();\n if (deveFermare && this.contesto.connessa()) {\n void this.richiedi({ t: "voice", op: "stop" }).catch(() => void 0);\n }\n this.rifiutaRichieste(creaErrore("offline", "Voice has stopped."));\n this.chiudiRisorse();\n this.aggiornaState("off");\n }\n mute(muted = true) {\n if (this.stateCorrente !== "on" || this.tracciaMic === null) {\n throw creaErrore("not_publishing", "Join voice before changing mute.");\n }\n this.mutedCorrente = muted;\n this.tracciaMic.enabled = !muted;\n void this.richiedi({ t: "voice", op: "mute", muted }).catch(() => void 0);\n }\n setVolume(playerId, volume) {\n const valore = limita(volume);\n this.volumi.set(playerId, valore);\n this.aggiornaGuadagno(playerId);\n this.notificaPeers();\n }\n onPeers(listener) {\n this.ascoltatoriPeers.add(listener);\n return () => {\n this.ascoltatoriPeers.delete(listener);\n };\n }\n onState(listener) {\n this.ascoltatoriState.add(listener);\n return () => {\n this.ascoltatoriState.delete(listener);\n };\n }\n ricevi(message) {\n if ("r" in message) {\n const pending = this.richieste.get(message.r);\n if (pending !== void 0) {\n this.richieste.delete(message.r);\n if ("error" in message) {\n pending.reject(creaErrore(message.error.code, message.error.message));\n } else pending.resolve(message);\n }\n return;\n }\n if (message.op === "roster") {\n this.modeCorrente = message.mode;\n const publisher = new Set(message.peers.map((peer) => peer.id));\n this.roster = [\n ...message.peers.map((peer) => ({ ...peer, mic: true })),\n ...message.listeners.flatMap((id) => publisher.has(id) ? [] : [{ id, mic: false, muted: true }])\n ];\n for (const peer of this.roster) {\n if (peer.muted) this.speakingPeers.set(peer.id, false);\n }\n this.pulisciPeerAssenti();\n this.contesto.rosterPronto();\n this.notificaPeers();\n this.accodaRiconciliazione();\n return;\n }\n if (message.op === "gain") {\n for (const [playerId, gain] of Object.entries(message.gains)) {\n this.gains.set(playerId, limita(gain));\n this.aggiornaZero(playerId);\n this.aggiornaGuadagno(playerId);\n }\n this.notificaPeers();\n this.accodaRiconciliazione();\n return;\n }\n if (message.op === "signal") void this.riceviSegnale(message.from, message.data);\n }\n giocatoriCambiati() {\n const presenti = new Set(this.contesto.giocatori().map((player) => player.id));\n for (const playerId of this.gains.keys()) {\n if (presenti.has(playerId)) continue;\n this.gains.delete(playerId);\n this.zeroDa.delete(playerId);\n const timer = this.timerZero.get(playerId);\n if (timer !== void 0) this.dipendenze?.clearTimeout(timer);\n this.timerZero.delete(playerId);\n this.aggiornaGuadagno(playerId);\n }\n this.notificaPeers();\n this.accodaRiconciliazione();\n }\n socketDisconnesso() {\n this.sequenzaRichieste = 0;\n this.rifiutaRichieste(creaErrore("offline", "The room is reconnecting."));\n if (!this.desiderata) return;\n this.generazione++;\n this.chiudiRisorse();\n this.tentativoRiconnessione = 0;\n this.aggiornaState("reconnecting");\n }\n socketRiconnesso() {\n this.sequenzaRichieste = 0;\n if (this.desiderata && this.stateCorrente === "reconnecting") this.programmaRiconnessione();\n }\n termina() {\n this.desiderata = false;\n this.generazione++;\n this.fermaRiconnessione();\n this.rifiutaRichieste(creaErrore("offline", "The room connection ended."));\n this.chiudiRisorse();\n this.aggiornaState("off");\n }\n scegliMic(options) {\n if (options.mic !== void 0) return options.mic;\n const you = this.contesto.giocatori().find((player) => player.id === this.contesto.you());\n return you?.role !== "spectator";\n }\n verificaIngresso(mic = this.micDesiderato) {\n if (!this.contesto.connessa()) throw creaErrore("offline", "The room is not connected.");\n if (this.modeCorrente === "none") {\n throw creaErrore("voice_disabled", "Voice is disabled for this room.");\n }\n const you = this.contesto.giocatori().find((player) => player.id === this.contesto.you());\n if (you?.role === "spectator" && mic) {\n throw creaErrore("spectator", "Spectators cannot publish voice.");\n }\n if (this.dipendenze === null) {\n throw creaErrore("unsupported", "Voice is not supported in this browser.");\n }\n }\n async entra(generazione) {\n this.verificaIngresso();\n const dipendenze = this.richiediDipendenze();\n const audioContext = dipendenze.creaAudioContext();\n this.audioContext = audioContext;\n try {\n await audioContext.resume();\n } catch {\n }\n if (this.micDesiderato) {\n let stream;\n try {\n stream = await dipendenze.getUserMedia({ audio: true });\n } catch (cause) {\n if (this.permessoNegato(cause)) {\n throw creaErrore("permission_denied", "Microphone permission was denied.");\n }\n throw creaErrore("voice_error", "The microphone could not be opened.");\n }\n try {\n this.controllaGenerazione(generazione);\n } catch (cause) {\n for (const track of stream.getTracks()) track.stop();\n throw cause;\n }\n const mic = stream.getAudioTracks()[0];\n if (mic === void 0) throw creaErrore("voice_error", "The microphone has no audio track.");\n this.stream = stream;\n this.tracciaMic = mic;\n mic.enabled = !this.mutedCorrente;\n this.preparaAnalizzatore(stream);\n }\n const risposta = await this.richiedi({ t: "voice", op: "ice" });\n this.controllaGenerazione(generazione);\n if (risposta.op !== "ice") throw creaErrore("voice_error", "The voice service returned an invalid response.");\n this.modeCorrente = risposta.mode;\n if (risposta.mode === "none") throw creaErrore("voice_disabled", "Voice is disabled for this room.");\n this.trasporto = risposta.transport;\n if (risposta.transport === "sfu") {\n await this.entraSfu(risposta.iceServers, generazione);\n } else {\n await this.richiedi({ t: "voice", op: "publish", mic: this.micDesiderato });\n }\n if (this.micDesiderato && this.mutedCorrente) {\n await this.richiedi({ t: "voice", op: "mute", muted: true });\n }\n this.controllaGenerazione(generazione);\n this.tentativoRiconnessione = 0;\n this.aggiornaState("on");\n this.avviaMisuraAudio();\n for (const playerId of this.gains.keys()) this.aggiornaZero(playerId);\n this.accodaRiconciliazione();\n }\n async entraSfu(iceServers, generazione) {\n const pc = this.richiediDipendenze().creaPeerConnection({\n iceServers,\n bundlePolicy: "max-bundle"\n });\n this.peerSfu = pc;\n pc.ontrack = (event) => {\n const mid = event.transceiver.mid;\n const playerId = mid === null ? void 0 : this.midGiocatori.get(mid);\n if (playerId !== void 0) this.collegaTraccia(playerId, event.track, event.receiver);\n };\n this.osservaCaduta(pc);\n let risposta;\n if (this.micDesiderato) {\n const transceiver = pc.addTransceiver(this.richiediMic(), { direction: "sendonly" });\n const offer = await pc.createOffer();\n await pc.setLocalDescription(offer);\n this.controllaGenerazione(generazione);\n const mid = transceiver.mid;\n const sdp = pc.localDescription?.sdp;\n if (mid === null || sdp === void 0) {\n throw creaErrore("voice_error", "The voice connection could not create an offer.");\n }\n risposta = await this.richiedi({ t: "voice", op: "session", sdp, mid });\n } else {\n risposta = await this.richiedi({ t: "voice", op: "session" });\n }\n if (risposta.op !== "session") {\n throw creaErrore("voice_error", "The voice service returned an invalid response.");\n }\n this.sessioneSfu = risposta.session;\n if (this.micDesiderato) {\n if (risposta.sdp === null) {\n throw creaErrore("voice_error", "The voice service returned an invalid response.");\n }\n await pc.setRemoteDescription({ type: "answer", sdp: risposta.sdp });\n await this.attendiConnessione(pc, generazione);\n this.connessioneSfuAttesa = true;\n return;\n }\n if (risposta.sdp !== null) {\n throw creaErrore("voice_error", "The voice service returned an invalid response.");\n }\n if (this.publisherDesiderati().length > 0) {\n await this.riconciliaSfu();\n }\n }\n attendiConnessione(pc, generazione) {\n if (pc.connectionState === "connected") return Promise.resolve();\n const dipendenze = this.richiediDipendenze();\n return new Promise((resolve, reject) => {\n const pulisci = () => {\n pc.removeEventListener("connectionstatechange", cambiata);\n if (this.timerConnessione !== null) dipendenze.clearTimeout(this.timerConnessione);\n this.timerConnessione = null;\n this.cancellaAttesaConnessione = null;\n };\n const cambiata = () => {\n if (generazione !== this.generazione) {\n pulisci();\n reject(creaErrore("offline", "Voice was stopped."));\n } else if (pc.connectionState === "connected") {\n pulisci();\n resolve();\n } else if (pc.connectionState === "failed" || pc.connectionState === "closed") {\n pulisci();\n reject(creaErrore("voice_error", "The voice connection failed."));\n }\n };\n pc.addEventListener("connectionstatechange", cambiata);\n this.cancellaAttesaConnessione = () => {\n pulisci();\n reject(creaErrore("offline", "Voice was stopped."));\n };\n this.timerConnessione = dipendenze.setTimeout(() => {\n pulisci();\n reject(creaErrore("voice_error", "The voice connection timed out."));\n }, TIMEOUT_CONNESSIONE);\n });\n }\n accodaRiconciliazione() {\n if (this.stateCorrente !== "on") return;\n this.negoziazione = this.negoziazione.then(async () => {\n if (this.stateCorrente !== "on") return;\n if (this.trasporto === "sfu") await this.riconciliaSfu();\n else if (this.trasporto === "mesh") this.riconciliaMesh();\n }).catch(() => this.avviaRiconnessione());\n }\n async riconciliaSfu() {\n const sessione = this.sessioneSfu;\n const pc = this.peerSfu;\n if (sessione === null || pc === null) return;\n const desiderati = new Map(this.publisherDesiderati().map((peer) => [peer.id, peer]));\n const daChiudere = [];\n for (const [playerId, attiva] of this.sfuAttive) {\n const peer = desiderati.get(playerId);\n if (peer !== void 0 && peer.session === attiva.session && peer.track === attiva.track) continue;\n daChiudere.push(attiva);\n if (!this.riproduzioni.has(playerId)) attiva.receiver?.track.stop();\n this.sfuAttive.delete(playerId);\n this.midGiocatori.delete(attiva.mid);\n this.scollegaTraccia(playerId);\n }\n if (daChiudere.length > 0) {\n await this.richiedi({\n t: "voice",\n op: "close",\n session: sessione,\n mids: daChiudere.map((item) => item.mid)\n });\n }\n const nuove = [...desiderati.values()].filter((peer) => !this.sfuAttive.has(peer.id));\n if (nuove.length === 0) return;\n const risposta = await this.richiedi({\n t: "voice",\n op: "subscribe",\n session: sessione,\n tracks: nuove.map((peer) => ({ session: peer.session, track: peer.track }))\n });\n if (risposta.op !== "subscribe") {\n throw creaErrore("voice_error", "The voice service returned an invalid response.");\n }\n for (const risultato of risposta.tracks) {\n const peer = nuove.find(\n (item) => item.session === risultato.session && item.track === risultato.track\n );\n if (risultato?.mid === null || risultato?.mid === void 0 || risultato.error !== null || peer === void 0) continue;\n this.midGiocatori.set(risultato.mid, peer.id);\n this.sfuAttive.set(peer.id, {\n session: peer.session,\n track: peer.track,\n mid: risultato.mid,\n receiver: null\n });\n }\n await pc.setRemoteDescription({ type: "offer", sdp: risposta.sdp });\n const answer = await pc.createAnswer();\n await pc.setLocalDescription(answer);\n const sdp = pc.localDescription?.sdp;\n if (sdp === void 0) throw creaErrore("voice_error", "The voice answer is missing.");\n await this.richiedi({ t: "voice", op: "answer", session: sessione, sdp });\n if (!this.connessioneSfuAttesa) {\n await this.attendiConnessione(pc, this.generazione);\n this.connessioneSfuAttesa = true;\n }\n }\n riconciliaMesh() {\n const desiderati = new Map(this.peerDesiderati().map((peer) => [peer.id, peer]));\n for (const [playerId, item] of this.mesh) {\n if (desiderati.has(playerId)) continue;\n item.pc.close();\n this.mesh.delete(playerId);\n this.scollegaTraccia(playerId);\n }\n for (const peer of desiderati.values()) {\n if (!this.mesh.has(peer.id)) this.creaMesh(peer);\n }\n }\n creaMesh(peer) {\n const playerId = peer.id;\n const pc = this.richiediDipendenze().creaPeerConnection();\n const item = {\n pc,\n makingOffer: false,\n ignoreOffer: false,\n settingRemoteAnswer: false,\n polite: this.contesto.you() > playerId,\n receiver: null\n };\n this.mesh.set(playerId, item);\n pc.onicecandidate = (event) => {\n if (event.candidate === null) return;\n void this.inviaSegnale(playerId, { kind: "candidate", candidate: event.candidate.toJSON() });\n };\n if (!item.polite) pc.onnegotiationneeded = () => {\n void this.offriMesh(playerId, item);\n };\n pc.ontrack = (event) => {\n item.receiver = event.receiver;\n this.collegaTraccia(playerId, event.track, event.receiver);\n };\n this.osservaCaduta(pc);\n if (this.micDesiderato) {\n pc.addTransceiver(this.richiediMic(), {\n direction: peer.mic ? "sendrecv" : "sendonly"\n });\n } else {\n pc.addTransceiver("audio", { direction: "recvonly" });\n }\n }\n async offriMesh(playerId, item) {\n try {\n item.makingOffer = true;\n const offer = await item.pc.createOffer();\n await item.pc.setLocalDescription(offer);\n const sdp = item.pc.localDescription?.sdp;\n if (sdp !== void 0) await this.inviaSegnale(playerId, { kind: "offer", sdp });\n } finally {\n item.makingOffer = false;\n }\n }\n async riceviSegnale(from, data) {\n if (this.trasporto !== "mesh" || this.stateCorrente !== "on") return;\n const peer = this.peerDesiderati().find((item2) => item2.id === from);\n if (peer === void 0) return;\n if (!this.mesh.has(from)) this.creaMesh(peer);\n const item = this.mesh.get(from);\n if (item === void 0 || typeof data !== "object" || data === null || Array.isArray(data)) return;\n const segnale = data;\n try {\n if (segnale.kind === "candidate") {\n if (!item.ignoreOffer) await item.pc.addIceCandidate(segnale.candidate);\n return;\n }\n if (segnale.kind !== "offer" && segnale.kind !== "answer" || typeof segnale.sdp !== "string") return;\n const pronta = !item.makingOffer && (item.pc.signalingState === "stable" || item.settingRemoteAnswer);\n const collisione = segnale.kind === "offer" && !pronta;\n item.ignoreOffer = !item.polite && collisione;\n if (item.ignoreOffer) return;\n item.settingRemoteAnswer = segnale.kind === "answer";\n await item.pc.setRemoteDescription({ type: segnale.kind, sdp: segnale.sdp });\n item.settingRemoteAnswer = false;\n if (segnale.kind === "offer") {\n const answer = await item.pc.createAnswer();\n await item.pc.setLocalDescription(answer);\n const sdp = item.pc.localDescription?.sdp;\n if (sdp !== void 0) await this.inviaSegnale(from, { kind: "answer", sdp });\n }\n } catch {\n this.avviaRiconnessione();\n }\n }\n inviaSegnale(to, data) {\n return this.richiedi({ t: "voice", op: "signal", to, data });\n }\n peerDesiderati() {\n const you = this.contesto.you();\n const giocatori = this.contesto.giocatori();\n const player = giocatori.find((item) => item.id === you);\n return this.roster.filter((peer) => {\n if (peer.id === you) return false;\n if (!this.micDesiderato && !peer.mic) return false;\n if (this.modeCorrente === "team") {\n const altro = giocatori.find((item) => item.id === peer.id);\n if (player?.role !== "spectator" && altro?.team !== player?.team) return false;\n }\n return true;\n });\n }\n publisherDesiderati() {\n return this.peerDesiderati().filter(\n (peer) => {\n if (!peer.mic) return false;\n const zeroAt = this.zeroDa.get(peer.id);\n return zeroAt === void 0 || this.richiediDipendenze().ora() - zeroAt < DURATA_ZERO;\n }\n );\n }\n aggiornaZero(playerId) {\n const dipendenze = this.dipendenze;\n if (dipendenze === null) return;\n const precedente = this.timerZero.get(playerId);\n if (precedente !== void 0) dipendenze.clearTimeout(precedente);\n this.timerZero.delete(playerId);\n if ((this.gains.get(playerId) ?? 1) > 0) {\n this.zeroDa.delete(playerId);\n return;\n }\n if (!this.zeroDa.has(playerId)) this.zeroDa.set(playerId, dipendenze.ora());\n const trascorso = dipendenze.ora() - (this.zeroDa.get(playerId) ?? dipendenze.ora());\n const timer = dipendenze.setTimeout(() => {\n this.timerZero.delete(playerId);\n this.accodaRiconciliazione();\n }, Math.max(0, DURATA_ZERO - trascorso));\n this.timerZero.set(playerId, timer);\n }\n collegaTraccia(playerId, track, receiver) {\n this.scollegaTraccia(playerId);\n const dipendenze = this.richiediDipendenze();\n const media = dipendenze.creaMediaStream([track]);\n const source = this.richiediAudioContext().createMediaStreamSource(media);\n const gain = this.richiediAudioContext().createGain();\n source.connect(gain);\n gain.connect(this.richiediAudioContext().destination);\n let analyser = null;\n try {\n analyser = this.richiediAudioContext().createAnalyser();\n analyser.fftSize = 256;\n source.connect(analyser);\n } catch {\n analyser = null;\n }\n const audio = dipendenze.creaAudioElement();\n audio.srcObject = media;\n audio.muted = true;\n audio.playsInline = true;\n void audio.play().catch(() => void 0);\n this.riproduzioni.set(playerId, { source, gain, analyser, audio, track, receiver });\n const attiva = this.sfuAttive.get(playerId);\n if (attiva !== void 0) attiva.receiver = receiver;\n this.aggiornaGuadagno(playerId);\n }\n scollegaTraccia(playerId) {\n const riproduzione = this.riproduzioni.get(playerId);\n if (riproduzione === void 0) return;\n riproduzione.source.disconnect();\n riproduzione.gain.disconnect();\n riproduzione.analyser?.disconnect();\n riproduzione.track.stop();\n riproduzione.audio.pause();\n riproduzione.audio.srcObject = null;\n this.riproduzioni.delete(playerId);\n this.speakingPeers.delete(playerId);\n this.ultimoAudio.delete(playerId);\n }\n aggiornaGuadagno(playerId) {\n const riproduzione = this.riproduzioni.get(playerId);\n if (riproduzione !== void 0) {\n riproduzione.gain.gain.value = (this.volumi.get(playerId) ?? 1) * (this.gains.get(playerId) ?? 1);\n }\n }\n preparaAnalizzatore(stream) {\n const context = this.richiediAudioContext();\n const analyser = context.createAnalyser();\n analyser.fftSize = 256;\n context.createMediaStreamSource(stream).connect(analyser);\n this.analyser = analyser;\n }\n avviaMisuraAudio() {\n const dipendenze = this.richiediDipendenze();\n if (this.intervalloAudio !== null) dipendenze.clearInterval(this.intervalloAudio);\n this.intervalloAudio = dipendenze.setInterval(() => this.misuraAudio(), INTERVALLO_AUDIO);\n }\n misuraAudio() {\n const dipendenze = this.dipendenze;\n if (dipendenze === null) return;\n let sopraSoglia = false;\n if (this.analyser !== null) sopraSoglia = this.livelloAnalizzatore(this.analyser) > SOGLIA_AUDIO;\n if (sopraSoglia) this.ultimoAudioMic = dipendenze.ora();\n const parlando = !this.mutedCorrente && dipendenze.ora() - this.ultimoAudioMic <= DURATA_PARLANTE;\n if (parlando !== this.speakingCorrente) {\n this.speakingCorrente = parlando;\n this.notificaPeers();\n }\n let cambiato = false;\n for (const peer of this.copiaPeers()) {\n const riproduzione = this.riproduzioni.get(peer.id);\n if (this.livelloAnalizzatore(riproduzione?.analyser ?? null) > SOGLIA_AUDIO) {\n this.ultimoAudio.set(peer.id, dipendenze.ora());\n } else if (riproduzione?.analyser === null || riproduzione?.analyser === void 0) {\n const sources = riproduzione?.receiver?.getSynchronizationSources?.() ?? [];\n if (sources.some((source) => (source.audioLevel ?? 0) > SOGLIA_AUDIO)) {\n this.ultimoAudio.set(peer.id, dipendenze.ora());\n }\n }\n const speaking = !peer.muted && dipendenze.ora() - (this.ultimoAudio.get(peer.id) ?? 0) <= DURATA_PARLANTE;\n if ((this.speakingPeers.get(peer.id) ?? false) !== speaking) {\n this.speakingPeers.set(peer.id, speaking);\n cambiato = true;\n }\n }\n if (cambiato) this.notificaPeers();\n }\n livelloAnalizzatore(analyser) {\n const nodo = analyser;\n if (nodo?.getFloatTimeDomainData === void 0) return 0;\n const campioni = new Float32Array(nodo.fftSize);\n nodo.getFloatTimeDomainData(campioni);\n return Math.sqrt(campioni.reduce((somma, valore) => somma + valore * valore, 0) / Math.max(1, campioni.length));\n }\n copiaPeers() {\n const you = this.contesto.you();\n const giocatori = this.contesto.giocatori();\n const player = giocatori.find((item) => item.id === you);\n return this.roster.flatMap((peer) => {\n if (peer.id === you) return [];\n if (this.modeCorrente === "team") {\n const altro = giocatori.find((item) => item.id === peer.id);\n if (player?.role !== "spectator" && altro?.team !== player?.team) return [];\n }\n return [{\n id: peer.id,\n mic: peer.mic,\n muted: peer.muted,\n speaking: peer.mic && !peer.muted && (this.speakingPeers.get(peer.id) ?? false),\n volume: this.volumi.get(peer.id) ?? 1,\n gain: this.gains.get(peer.id) ?? 1\n }];\n });\n }\n pulisciPeerAssenti() {\n const presenti = new Set(this.roster.map((peer) => peer.id));\n for (const playerId of this.speakingPeers.keys()) {\n if (!presenti.has(playerId)) this.speakingPeers.delete(playerId);\n }\n for (const playerId of this.zeroDa.keys()) {\n if (presenti.has(playerId)) continue;\n this.zeroDa.delete(playerId);\n const timer = this.timerZero.get(playerId);\n if (timer !== void 0) this.dipendenze?.clearTimeout(timer);\n this.timerZero.delete(playerId);\n }\n }\n osservaCaduta(pc) {\n pc.addEventListener("connectionstatechange", () => {\n if (this.stateCorrente === "on" && (pc.connectionState === "failed" || pc.connectionState === "disconnected")) this.avviaRiconnessione();\n });\n }\n avviaRiconnessione() {\n if (!this.desiderata || this.stateCorrente === "reconnecting") return;\n this.generazione++;\n this.rifiutaRichieste(creaErrore("voice_error", "The voice connection was restarted."));\n this.chiudiRisorse();\n this.tentativoRiconnessione = 0;\n this.aggiornaState("reconnecting");\n this.programmaRiconnessione();\n }\n programmaRiconnessione() {\n if (!this.desiderata || !this.contesto.connessa() || this.timerRiconnessione !== null || this.stateCorrente !== "reconnecting") return;\n const ritardo = RITARDI_RICONNESSIONE[this.tentativoRiconnessione];\n if (ritardo === void 0) {\n this.desiderata = false;\n this.aggiornaState("off");\n return;\n }\n this.tentativoRiconnessione++;\n this.timerRiconnessione = this.richiediDipendenze().setTimeout(() => {\n this.timerRiconnessione = null;\n const generazione = ++this.generazione;\n void this.entra(generazione).catch(() => {\n if (generazione !== this.generazione || !this.desiderata) return;\n this.chiudiRisorse();\n this.aggiornaState("reconnecting");\n this.programmaRiconnessione();\n });\n }, ritardo);\n }\n fermaRiconnessione() {\n if (this.timerRiconnessione === null || this.dipendenze === null) return;\n this.dipendenze.clearTimeout(this.timerRiconnessione);\n this.timerRiconnessione = null;\n }\n chiudiRisorse() {\n const dipendenze = this.dipendenze;\n this.cancellaAttesaConnessione?.();\n this.cancellaAttesaConnessione = null;\n if (dipendenze !== null) {\n if (this.intervalloAudio !== null) dipendenze.clearInterval(this.intervalloAudio);\n if (this.timerConnessione !== null) dipendenze.clearTimeout(this.timerConnessione);\n for (const timer of this.timerZero.values()) dipendenze.clearTimeout(timer);\n }\n this.intervalloAudio = null;\n this.timerConnessione = null;\n this.timerZero.clear();\n for (const playerId of [...this.riproduzioni.keys()]) this.scollegaTraccia(playerId);\n this.peerSfu?.close();\n this.peerSfu = null;\n for (const item of this.mesh.values()) item.pc.close();\n this.mesh.clear();\n this.sfuAttive.clear();\n this.midGiocatori.clear();\n for (const track of this.stream?.getTracks() ?? []) track.stop();\n this.stream = null;\n this.tracciaMic = null;\n this.analyser = null;\n void this.audioContext?.close().catch(() => void 0);\n this.audioContext = null;\n this.sessioneSfu = null;\n this.connessioneSfuAttesa = false;\n this.trasporto = null;\n this.speakingCorrente = false;\n this.ultimoAudioMic = Number.NEGATIVE_INFINITY;\n this.speakingPeers.clear();\n this.ultimoAudio.clear();\n this.negoziazione = Promise.resolve();\n }\n richiedi(message) {\n if (!this.contesto.connessa()) return Promise.reject(creaErrore("offline", "The room is reconnecting."));\n const r = ++this.sequenzaRichieste;\n return new Promise((resolve, reject) => {\n this.richieste.set(r, { resolve, reject });\n try {\n this.contesto.invia({ ...message, r });\n } catch (cause) {\n this.richieste.delete(r);\n reject(cause);\n }\n });\n }\n rifiutaRichieste(reason) {\n for (const richiesta of this.richieste.values()) richiesta.reject(reason);\n this.richieste.clear();\n }\n aggiornaState(state) {\n if (state === this.stateCorrente) return;\n this.stateCorrente = state;\n for (const listener of this.ascoltatoriState) {\n try {\n listener(state);\n } catch {\n }\n }\n }\n notificaPeers() {\n const peers = this.copiaPeers();\n for (const listener of this.ascoltatoriPeers) {\n try {\n listener(peers);\n } catch {\n }\n }\n }\n controllaGenerazione(generazione) {\n if (generazione !== this.generazione || !this.desiderata) {\n throw creaErrore("offline", "Voice was stopped.");\n }\n }\n richiediDipendenze() {\n if (this.dipendenze === null) throw creaErrore("unsupported", "Voice is not supported.");\n return this.dipendenze;\n }\n richiediMic() {\n if (this.tracciaMic === null) throw creaErrore("voice_error", "The microphone is not ready.");\n return this.tracciaMic;\n }\n richiediAudioContext() {\n if (this.audioContext === null) throw creaErrore("voice_error", "Audio is not ready.");\n return this.audioContext;\n }\n permessoNegato(cause) {\n return typeof cause === "object" && cause !== null && "name" in cause && (cause.name === "NotAllowedError" || cause.name === "SecurityError");\n }\n mappaErrore(cause) {\n if (typeof cause === "object" && cause !== null && "code" in cause) {\n const code = cause.code;\n if (code === "voice_disabled" || code === "permission_denied" || code === "unsupported" || code === "spectator" || code === "offline" || code === "voice_error") return cause;\n return creaErrore("voice_error", "Voice could not be started.");\n }\n return creaErrore("voice_error", "Voice could not be started.");\n }\n};\n\n// src/stanza-client/index.ts\nvar APERTO = 1;\nvar RITARDI_RICONNESSIONE2 = [1e3, 2e3, 4e3, 8e3];\nvar GRAZIA_RICONNESSIONE = 6e4;\nvar INTERVALLO_PING = 5e3;\nvar RITARDO_FLUSH = 500;\nvar ATTESA_ROSTER = 2e3;\nvar CHIUSURE_DEFINITIVE = /* @__PURE__ */ new Set([4003, 4004, 4005, 4006]);\nfunction record2(value) {\n return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;\n}\nfunction ingressoValido(value) {\n const dati = record2(value);\n return dati !== null && typeof dati.roomId === "string" && typeof dati.code === "string" && typeof dati.join === "string" && typeof dati.url === "string";\n}\nfunction rispostaMatchValida(value) {\n const dati = record2(value);\n const players = record2(dati?.players);\n return dati !== null && typeof dati.url === "string" && Number.isInteger(dati.timeoutMs) && dati.timeoutMs >= 1e3 && dati.timeoutMs <= 3e5 && players !== null && Number.isInteger(players.min) && Number.isInteger(players.max) && players.min >= 1 && players.max >= players.min;\n}\nfunction copiaJson(value) {\n return JSON.parse(JSON.stringify(value));\n}\nfunction applicaPatch(state, value) {\n let risultato = copiaJson(state);\n for (const operazione of value) {\n if (operazione.path.length === 0) {\n if (operazione.op !== "set") return { ok: false };\n risultato = copiaJson(operazione.value);\n continue;\n }\n let contenitore = risultato;\n const percorso = operazione.path;\n for (let indice = 0; indice < percorso.length - 1; indice++) {\n const parte = percorso[indice];\n if (Array.isArray(contenitore)) {\n if (typeof parte !== "number" || parte >= contenitore.length) return { ok: false };\n contenitore = contenitore[parte];\n } else {\n const oggetto = record2(contenitore);\n if (oggetto === null || typeof parte !== "string" || !Object.hasOwn(oggetto, parte)) {\n return { ok: false };\n }\n contenitore = oggetto[parte];\n }\n }\n const ultima = percorso.at(-1);\n if (Array.isArray(contenitore)) {\n if (operazione.op !== "set" || typeof ultima !== "number" || ultima >= contenitore.length) return { ok: false };\n contenitore[ultima] = copiaJson(operazione.value);\n } else {\n const oggetto = record2(contenitore);\n if (oggetto === null || typeof ultima !== "string") return { ok: false };\n if (operazione.op === "del") {\n if (!Object.hasOwn(oggetto, ultima)) return { ok: false };\n delete oggetto[ultima];\n } else {\n Object.defineProperty(oggetto, ultima, {\n configurable: true,\n enumerable: true,\n value: copiaJson(operazione.value),\n writable: true\n });\n }\n }\n }\n return { ok: true, state: risultato };\n}\nfunction creaApiLive(input) {\n const richiesta = creaRichiedente(input.liveOrigin, "", input.fetcher, input.biglietto);\n async function ingresso(path, body, rinnova = false) {\n const value = await richiesta(path, "POST", body, rinnova);\n if (!ingressoValido(value)) {\n throw creaErrore("internal_error", "The room service returned an invalid response.");\n }\n return value;\n }\n async function match(options) {\n const value = await richiesta("/match", "POST", {\n mode: options.mode,\n key: options.key\n });\n if (!rispostaMatchValida(value)) {\n throw creaErrore("internal_error", "The matchmaking service returned an invalid response.");\n }\n return value;\n }\n return {\n create: (mode) => ingresso("/rooms", { mode }),\n joinCode: (code) => ingresso("/rooms/join", { code }),\n joinRoom: (roomId) => ingresso("/rooms/join", { roomId }, true),\n match,\n flush: (roomId) => richiesta(\n `/rooms/${encodeURIComponent(roomId)}/flush`,\n "POST"\n )\n };\n}\nvar StanzaClient = class {\n constructor(roomId, codice, url, input, api, segnalaStanza) {\n this.roomId = roomId;\n this.codice = codice;\n this.input = input;\n this.api = api;\n this.segnalaStanza = segnalaStanza;\n this.statoPubblico = null;\n this.statoSincronizzato = null;\n this.tickCorrente = 0;\n this.seedCorrente = 0;\n this.statusCorrente = "lobby";\n this.giocatoriCorrenti = [];\n this.youCorrente = "";\n this.hostCorrente = null;\n this.resultCorrente = null;\n this.socket = null;\n this.seq = 0;\n this.scartoOrario = 0;\n this.timerPing = null;\n this.timerRiconnessione = null;\n this.timerFlush = null;\n this.flushInCorso = false;\n this.flushRichiesto = false;\n this.ritardoIndice = 0;\n this.tempoRiconnessione = 0;\n this.resyncRichiesto = false;\n this.terminata = false;\n this.lasciata = false;\n this.prontaRisolta = false;\n this.welcomeRicevuto = false;\n this.rosterRicevuto = false;\n this.timerRoster = null;\n this.risolviPronta = () => void 0;\n this.rifiutaPronta = () => void 0;\n this.ascoltatoriStato = /* @__PURE__ */ new Set();\n this.ascoltatoriGiocatori = /* @__PURE__ */ new Set();\n this.ascoltatoriStatus = /* @__PURE__ */ new Set();\n this.ascoltatoriMessaggi = /* @__PURE__ */ new Set();\n this.promessaPronta = new Promise((resolve, reject) => {\n this.risolviPronta = resolve;\n this.rifiutaPronta = reject;\n });\n this.voice = new VoceClient({\n invia: (message) => this.invia(message),\n connessa: () => this.socket?.readyState === APERTO && this.welcomeRicevuto && !this.terminata && !this.lasciata,\n you: () => this.youCorrente,\n giocatori: () => this.copiaGiocatori(),\n rosterPronto: () => {\n this.rosterRicevuto = true;\n this.risolviProntaSePossibile();\n }\n }, input, input.voce);\n this.apri(url);\n }\n get state() {\n return this.statoPubblico;\n }\n get tick() {\n return this.tickCorrente;\n }\n get seed() {\n return this.seedCorrente;\n }\n get status() {\n return this.statusCorrente;\n }\n get players() {\n return this.copiaGiocatori();\n }\n get you() {\n return this.youCorrente;\n }\n get host() {\n return this.hostCorrente;\n }\n get code() {\n return this.codice;\n }\n get result() {\n return this.resultCorrente;\n }\n pronta() {\n return this.promessaPronta;\n }\n invite() {\n return { code: this.codice, url: new URL(`/r/${this.codice}`, this.input.appOrigin).href };\n }\n onState(listener) {\n this.ascoltatoriStato.add(listener);\n return () => {\n this.ascoltatoriStato.delete(listener);\n };\n }\n onPlayers(listener) {\n this.ascoltatoriGiocatori.add(listener);\n return () => {\n this.ascoltatoriGiocatori.delete(listener);\n };\n }\n onStatus(listener) {\n this.ascoltatoriStatus.add(listener);\n return () => {\n this.ascoltatoriStatus.delete(listener);\n };\n }\n onMessage(listener) {\n this.ascoltatoriMessaggi.add(listener);\n return () => {\n this.ascoltatoriMessaggi.delete(listener);\n };\n }\n send(message) {\n const prossimo = this.seq + 1;\n this.invia({ t: "msg", seq: prossimo, m: message });\n this.seq = prossimo;\n }\n ready(ready) {\n this.invia({ t: "ready", ready });\n }\n setRole(role) {\n this.invia({ t: "role", role });\n }\n setTeam(team) {\n this.invia({ t: "team", team });\n }\n start() {\n this.invia({ t: "start" });\n }\n leave() {\n if (this.lasciata) return;\n this.voice.leave();\n this.lasciata = true;\n this.segnalaStanza(null);\n if (this.socket?.readyState === APERTO) this.invia({ t: "leave" });\n this.termina(1e3);\n }\n serverTime() {\n return this.input.ora() + this.scartoOrario;\n }\n copiaGiocatori() {\n return this.giocatoriCorrenti.map((player) => ({ ...player }));\n }\n notifica(listeners, ...args) {\n for (const listener of listeners) {\n try {\n listener(...args);\n } catch {\n }\n }\n }\n invia(message) {\n if (this.socket?.readyState !== APERTO) {\n throw creaErrore("offline", "The room is reconnecting.");\n }\n let frame;\n try {\n frame = JSON.stringify(message);\n } catch {\n throw creaErrore("invalid_request", "Room messages must be valid JSON.");\n }\n this.socket.send(frame);\n }\n apri(url) {\n let socket;\n try {\n socket = this.input.apriSocket(url);\n } catch {\n this.programmaRiconnessione();\n return;\n }\n this.socket = socket;\n socket.addEventListener("open", () => {\n if (this.socket === socket) this.avviaPing();\n });\n socket.addEventListener("message", (evento) => {\n if (this.socket === socket && typeof evento.data === "string") this.ricevi(evento.data);\n });\n socket.addEventListener("close", (evento) => {\n if (this.socket === socket) this.chiuso(evento.code);\n });\n }\n avviaPing() {\n if (this.timerPing !== null) this.input.clearInterval(this.timerPing);\n this.timerPing = this.input.setInterval(() => {\n if (this.socket?.readyState !== APERTO) return;\n try {\n this.invia({ t: "ping", c: this.input.ora() });\n } catch {\n }\n }, INTERVALLO_PING);\n }\n fermaPing() {\n if (this.timerPing === null) return;\n this.input.clearInterval(this.timerPing);\n this.timerPing = null;\n }\n ricevi(frame) {\n let dati;\n try {\n const value = JSON.parse(frame);\n const oggetto = record2(value);\n if (oggetto === null || typeof oggetto.t !== "string") return;\n dati = oggetto;\n } catch {\n return;\n }\n try {\n if (dati.t === "welcome") this.riceviWelcome(dati);\n else if (dati.t === "players") this.riceviGiocatori(dati.players);\n else if (dati.t === "status") this.riceviStatus(dati);\n else if (dati.t === "state") this.riceviDiff(dati);\n else if (dati.t === "snapshot") this.riceviSnapshot(dati);\n else if (dati.t === "msg") this.notifica(this.ascoltatoriMessaggi, copiaJson(dati.m));\n else if (dati.t === "pong") this.riceviPong(dati);\n else if (dati.t === "flush") this.richiediFlush();\n else if (dati.t === "voice") this.voice.ricevi(dati);\n } catch {\n if (dati.t === "state" || dati.t === "snapshot") this.chiediResync();\n }\n }\n riceviWelcome(dati) {\n const room = dati.room;\n if (room.id !== this.roomId) return;\n this.youCorrente = dati.you;\n this.seedCorrente = room.seed;\n this.hostCorrente = room.host;\n this.statusCorrente = room.status;\n this.giocatoriCorrenti = dati.players.map((player) => ({ ...player }));\n this.aggiornaStato(dati.state, room.tick, room.serverTime);\n this.scartoOrario = room.serverTime - this.input.ora();\n this.resyncRichiesto = false;\n this.welcomeRicevuto = true;\n if (!this.rosterRicevuto && this.timerRoster === null) {\n this.timerRoster = this.input.setTimeout(() => {\n this.timerRoster = null;\n this.rosterRicevuto = true;\n this.risolviProntaSePossibile();\n }, ATTESA_ROSTER);\n }\n this.ritardoIndice = 0;\n this.tempoRiconnessione = 0;\n this.notifica(this.ascoltatoriGiocatori, this.copiaGiocatori());\n this.voice.giocatoriCambiati();\n this.voice.socketRiconnesso();\n this.notifica(\n this.ascoltatoriStatus,\n this.statusCorrente,\n this.resultCorrente,\n room.serverTime\n );\n this.risolviProntaSePossibile();\n }\n riceviGiocatori(value) {\n this.giocatoriCorrenti = value.map((player) => ({ ...player }));\n if (!this.giocatoriCorrenti.some(\n (player) => player.id === this.hostCorrente && player.connected\n )) {\n this.hostCorrente = this.giocatoriCorrenti.find((player) => player.connected)?.id ?? null;\n }\n this.notifica(this.ascoltatoriGiocatori, this.copiaGiocatori());\n this.voice.giocatoriCambiati();\n }\n riceviStatus(dati) {\n this.statusCorrente = dati.status;\n this.resultCorrente = copiaJson(dati.result);\n if (dati.status === "ended") {\n this.terminata = true;\n this.segnalaStanza(null);\n this.voice.termina();\n this.fermaPing();\n this.fermaRiconnessione();\n }\n this.notifica(this.ascoltatoriStatus, this.statusCorrente, this.resultCorrente, dati.at);\n }\n riceviDiff(dati) {\n if (dati.base !== this.tickCorrente) {\n this.chiediResync();\n return;\n }\n const risultato = applicaPatch(this.statoSincronizzato, dati.patch);\n if (!risultato.ok) {\n this.chiediResync();\n return;\n }\n this.resyncRichiesto = false;\n this.aggiornaStato(risultato.state, dati.tick, dati.serverTime);\n }\n riceviSnapshot(dati) {\n if (dati.tick < this.tickCorrente) return;\n this.resyncRichiesto = false;\n this.aggiornaStato(dati.state, dati.tick, dati.serverTime);\n }\n aggiornaStato(state, tick, serverTime) {\n this.statoSincronizzato = copiaJson(state);\n this.statoPubblico = copiaJson(state);\n this.tickCorrente = tick;\n this.notifica(this.ascoltatoriStato, this.statoPubblico, tick, serverTime);\n }\n chiediResync() {\n if (this.resyncRichiesto || this.socket?.readyState !== APERTO) return;\n this.resyncRichiesto = true;\n try {\n this.invia({ t: "resync" });\n } catch {\n this.resyncRichiesto = false;\n }\n }\n riceviPong(dati) {\n this.scartoOrario = dati.s - (dati.c + this.input.ora()) / 2;\n }\n chiuso(code) {\n this.socket = null;\n this.welcomeRicevuto = false;\n this.fermaPing();\n if (CHIUSURE_DEFINITIVE.has(code)) {\n this.termina(code);\n return;\n }\n if (this.lasciata || this.terminata) return;\n this.voice.socketDisconnesso();\n this.programmaRiconnessione();\n }\n programmaRiconnessione() {\n if (this.terminata || this.lasciata || this.timerRiconnessione !== null) return;\n const indice = Math.min(this.ritardoIndice, RITARDI_RICONNESSIONE2.length - 1);\n const ritardo = RITARDI_RICONNESSIONE2[indice];\n if (this.tempoRiconnessione + ritardo > GRAZIA_RICONNESSIONE) {\n this.termina("timeout");\n return;\n }\n this.ritardoIndice++;\n this.tempoRiconnessione += ritardo;\n this.timerRiconnessione = this.input.setTimeout(() => {\n this.timerRiconnessione = null;\n void this.riconnetti();\n }, ritardo);\n }\n async riconnetti() {\n if (this.terminata || this.lasciata) return;\n try {\n const ingresso = await this.api.joinRoom(this.roomId);\n const codiceCambiato = this.codice !== ingresso.code;\n this.codice = ingresso.code;\n if (codiceCambiato && this.prontaRisolta && !this.terminata && !this.lasciata) {\n this.segnalaStanza({ code: this.codice });\n }\n this.apri(ingresso.url);\n } catch {\n this.programmaRiconnessione();\n }\n }\n fermaRiconnessione() {\n if (this.timerRiconnessione === null) return;\n this.input.clearTimeout(this.timerRiconnessione);\n this.timerRiconnessione = null;\n }\n termina(code) {\n const risultato = { closed: code };\n const cambiato = this.statusCorrente !== "ended" || JSON.stringify(this.resultCorrente) !== JSON.stringify(risultato);\n this.terminata = true;\n this.segnalaStanza(null);\n this.statusCorrente = "ended";\n this.resultCorrente = risultato;\n this.voice.termina();\n this.fermaPing();\n this.fermaRiconnessione();\n if (cambiato) this.notifica(this.ascoltatoriStatus, "ended", risultato, this.serverTime());\n if (!this.prontaRisolta) {\n this.prontaRisolta = true;\n const codici = {\n 4003: "kicked",\n 4004: "room_ended",\n 4005: "version_closed",\n 4006: "replaced"\n };\n const erroreCode = typeof code === "number" ? codici[code] ?? "offline" : "offline";\n this.rifiutaPronta(creaErrore(erroreCode, "The room connection ended."));\n }\n }\n risolviProntaSePossibile() {\n if (this.prontaRisolta || !this.welcomeRicevuto || !this.rosterRicevuto) return;\n if (this.timerRoster !== null) {\n this.input.clearTimeout(this.timerRoster);\n this.timerRoster = null;\n }\n this.prontaRisolta = true;\n if (!this.terminata && !this.lasciata) this.segnalaStanza({ code: this.codice });\n this.risolviPronta();\n }\n richiediFlush() {\n this.flushRichiesto = true;\n if (this.flushInCorso || this.timerFlush !== null) return;\n this.timerFlush = this.input.setTimeout(() => {\n this.timerFlush = null;\n void this.eseguiFlush();\n }, RITARDO_FLUSH);\n }\n async eseguiFlush() {\n if (this.flushInCorso || !this.flushRichiesto) return;\n this.flushInCorso = true;\n this.flushRichiesto = false;\n try {\n await this.api.flush(this.roomId);\n } catch {\n } finally {\n this.flushInCorso = false;\n if (this.flushRichiesto) this.richiediFlush();\n }\n }\n};\nfunction creaStanzeOffline(invited = null) {\n return {\n invited,\n async create() {\n throw erroreOffline();\n },\n async join() {\n throw erroreOffline();\n },\n async match() {\n throw erroreOffline();\n }\n };\n}\nfunction creaGestoreStanze(input, invited) {\n const api = creaApiLive(input);\n let haSegnalato = false;\n let ultimoCodice = null;\n const segnalaStanza = (room) => {\n const codice = room?.code ?? null;\n if (haSegnalato && codice === ultimoCodice) return;\n haSegnalato = true;\n ultimoCodice = codice;\n input.segnalaStanza?.(room);\n };\n const collega = async (ingresso) => {\n const stanza = new StanzaClient(\n ingresso.roomId,\n ingresso.code,\n ingresso.url,\n input,\n api,\n segnalaStanza\n );\n await stanza.pronta();\n return stanza;\n };\n const attendiMatch = (url, options) => new Promise((resolve, reject) => {\n let socket;\n let conclusa = false;\n const pulisci = () => {\n socket.removeEventListener("message", ricevi);\n socket.removeEventListener("close", chiuso);\n socket.removeEventListener("error", caduto);\n options.signal?.removeEventListener("abort", annulla);\n };\n const chiudi = () => {\n try {\n socket.close(1e3);\n } catch {\n }\n };\n const fallisci = (errore, chiudiSocket) => {\n if (conclusa) return;\n conclusa = true;\n pulisci();\n if (chiudiSocket) chiudi();\n reject(errore);\n };\n function annulla() {\n fallisci(\n creaErrore("cancelled", "The matchmaking search was cancelled."),\n true\n );\n }\n function chiuso() {\n fallisci(erroreOffline(), false);\n }\n function caduto() {\n fallisci(erroreOffline(), true);\n }\n function ricevi(evento) {\n let dati = null;\n try {\n dati = typeof evento.data === "string" ? record2(JSON.parse(evento.data)) : null;\n } catch {\n }\n if (dati === null || typeof dati.t !== "string") {\n fallisci(creaErrore("internal_error", "The matchmaking service sent an invalid message."), true);\n return;\n }\n if (dati.t === "waiting") {\n if (!Number.isInteger(dati.players) || !Number.isInteger(dati.min) || !Number.isInteger(dati.max)) {\n fallisci(creaErrore("internal_error", "The matchmaking service sent an invalid message."), true);\n return;\n }\n try {\n options.onWaiting?.({\n players: dati.players,\n min: dati.min,\n max: dati.max\n });\n } catch {\n }\n return;\n }\n if (dati.t === "matched") {\n if (!ingressoValido(dati)) {\n fallisci(creaErrore("internal_error", "The matchmaking service sent an invalid message."), true);\n return;\n }\n conclusa = true;\n pulisci();\n chiudi();\n resolve(dati);\n return;\n }\n if (dati.t === "no_match") {\n fallisci(creaErrore("no_match", "No match was found before the timeout."), true);\n return;\n }\n if (dati.t === "error") {\n fallisci(creaErrore(\n typeof dati.code === "string" ? dati.code : "internal_error",\n typeof dati.message === "string" ? dati.message : "The matchmaking service could not complete the search."\n ), true);\n return;\n }\n if (dati.t !== "pong") {\n fallisci(creaErrore("internal_error", "The matchmaking service sent an invalid message."), true);\n }\n }\n try {\n socket = input.apriSocket(url);\n } catch {\n reject(erroreOffline());\n return;\n }\n socket.addEventListener("message", ricevi);\n socket.addEventListener("close", chiuso);\n socket.addEventListener("error", caduto);\n options.signal?.addEventListener("abort", annulla, { once: true });\n if (options.signal?.aborted === true) annulla();\n });\n return {\n invited,\n async create(options) {\n return collega(await api.create(options.mode));\n },\n async join(code) {\n const scelto = code ?? invited;\n if (scelto === null || scelto === void 0 || scelto.length === 0) {\n throw creaErrore("invalid_request", "A room invitation code is required.");\n }\n return collega(await api.joinCode(scelto));\n },\n async match(options) {\n const annullata = () => options.signal?.aborted === true;\n if (annullata()) {\n throw creaErrore("cancelled", "The matchmaking search was cancelled.");\n }\n const risposta = await api.match(options);\n if (annullata()) {\n throw creaErrore("cancelled", "The matchmaking search was cancelled.");\n }\n return collega(await attendiMatch(risposta.url, options));\n }\n };\n}\n\n// src/standalone.ts\nvar PREFISSO = "caisual:save:";\nvar CHIAVE_VALIDA = /^[a-z0-9][a-z0-9_-]{0,31}$/;\nfunction verificaChiave(key) {\n if (!CHIAVE_VALIDA.test(key)) {\n throw creaErrore("invalid_request", "Save keys must use lowercase letters, numbers, underscores, or hyphens.");\n }\n}\nfunction leggiSalvataggio(testo) {\n if (testo === null) return null;\n try {\n return JSON.parse(testo);\n } catch {\n return null;\n }\n}\nfunction chiavi(archivio) {\n const risultato = [];\n for (let indice = 0; indice < archivio.length; indice++) {\n const key = archivio.key(indice);\n if (key?.startsWith(PREFISSO)) risultato.push(key.slice(PREFISSO.length));\n }\n return risultato;\n}\nfunction creaSave(archivio, ora) {\n const disponibile = () => {\n if (archivio === null) throw erroreOffline();\n return archivio;\n };\n return {\n async set(key, value) {\n verificaChiave(key);\n const locale = disponibile();\n const corpo = JSON.stringify({ value });\n const bytes = new TextEncoder().encode(corpo).byteLength;\n if (bytes > 262144) {\n throw creaErrore("payload_too_large", "The save is larger than 262144 bytes.");\n }\n if (locale.getItem(PREFISSO + key) === null && chiavi(locale).length >= 32) {\n throw creaErrore("save_limit", "A game can store at most 32 save keys.");\n }\n const voce = { value, bytes, updatedAt: ora() };\n locale.setItem(PREFISSO + key, JSON.stringify(voce));\n return { key, bytes, updatedAt: voce.updatedAt };\n },\n async get(key) {\n verificaChiave(key);\n return leggiSalvataggio(disponibile().getItem(PREFISSO + key))?.value ?? null;\n },\n async remove(key) {\n verificaChiave(key);\n disponibile().removeItem(PREFISSO + key);\n },\n async list() {\n const locale = disponibile();\n return chiavi(locale).flatMap((key) => {\n const voce = leggiSalvataggio(locale.getItem(PREFISSO + key));\n return voce === null ? [] : [{ key, bytes: voce.bytes, updatedAt: voce.updatedAt }];\n }).sort((a, b) => a.key.localeCompare(b.key));\n }\n };\n}\nasync function creaStandalone(input, invited = null) {\n const day = giornoUtc(input.ora());\n const seed = await calcolaSeed(input.hostname, day, input.subtle);\n return {\n connected: false,\n player: { id: "local", name: "Guest", guest: true },\n daily: { day, seed, random: creaMulberry32(seed) },\n time: { now: input.ora },\n save: creaSave(input.archivio, input.ora),\n board: {\n async submit() {\n return { accepted: false, reason: "offline" };\n },\n async top(_board, opzioni = {}) {\n return { day: opzioni.daily ? day : null, entries: [], me: null };\n }\n },\n room: creaStanzeOffline(invited)\n };\n}\n\n// src/kit.ts\nfunction leggiAppOrigin(documento) {\n const valore = documento?.querySelector(\'meta[name="caisual-app"]\')?.getAttribute("content");\n if (valore === null || valore === void 0) return null;\n try {\n const url = new URL(valore);\n return url.origin === valore && (url.protocol === "https:" || url.protocol === "http:") ? valore : null;\n } catch {\n return null;\n }\n}\nfunction archivioReale() {\n try {\n return typeof localStorage === "undefined" ? null : localStorage;\n } catch {\n return null;\n }\n}\nfunction dipendenzeReali2() {\n return {\n finestra: typeof window === "undefined" ? null : window,\n documento: typeof document === "undefined" ? null : document,\n fetcher: (input, init) => globalThis.fetch(input, init),\n archivio: archivioReale(),\n hostname: typeof location === "undefined" ? "" : location.hostname,\n subtle: globalThis.crypto.subtle,\n ora: Date.now,\n sonda: () => probeDevice()\n };\n}\nasync function connetti(input) {\n const appOrigin = leggiAppOrigin(input.documento);\n const senzaPadre = input.finestra === null || input.finestra.parent === input.finestra;\n if (appOrigin === null || senzaPadre) {\n return creaStandalone(input);\n }\n const handshake = await attendiHandshake(\n input.finestra,\n appOrigin,\n input.timeoutHandshake\n );\n if (handshake === null) return creaStandalone(input);\n const biglietto = creaGestoreBiglietto(\n handshake.ticket,\n handshake.porta,\n input.finestra,\n input.ora,\n input.timeoutRinnovo,\n "portal"\n );\n const api = creaClienteApi(appOrigin, input.fetcher, biglietto);\n const prima = input.ora();\n let me;\n try {\n me = await api.me();\n } catch {\n return creaStandalone(input, handshake.invite);\n }\n const dopo = input.ora();\n const scartoOrario = me.serverTime - (prima + dopo) / 2;\n const room = handshake.live === null ? creaStanzeOffline(handshake.invite) : creaGestoreStanze({\n appOrigin,\n liveOrigin: handshake.live,\n fetcher: input.fetcher,\n biglietto: creaGestoreBiglietto(\n null,\n handshake.porta,\n input.finestra,\n input.ora,\n input.timeoutRinnovo,\n "live"\n ),\n apriSocket(url) {\n if (input.apriSocket !== void 0) return input.apriSocket(url);\n if (typeof WebSocket === "undefined") throw erroreOffline();\n return new WebSocket(url);\n },\n ora: input.ora,\n setTimeout: (handler, timeout) => globalThis.setTimeout(handler, timeout),\n clearTimeout: (id) => globalThis.clearTimeout(id),\n setInterval: (handler, timeout) => globalThis.setInterval(handler, timeout),\n clearInterval: (id) => globalThis.clearInterval(id),\n voce: input.voce,\n segnalaStanza(room2) {\n try {\n handshake.porta.postMessage({ type: "caisual:room", room: room2 });\n } catch {\n }\n }\n }, handshake.invite);\n return {\n connected: true,\n player: me.player,\n daily: { day: me.day, seed: me.seed, random: creaMulberry32(me.seed) },\n time: { now: () => input.ora() + scartoOrario },\n save: {\n set: (key, value) => api.saveSet(key, value),\n get: (key) => api.saveGet(key),\n remove: (key) => api.saveRemove(key),\n list: () => api.saveList()\n },\n board: {\n async submit(board, score, opzioni = {}) {\n try {\n return await api.boardSubmit(board, score, opzioni.daily === true);\n } catch (errore) {\n if (typeof errore === "object" && errore !== null && "code" in errore && errore.code === "offline") return { accepted: false, reason: "offline" };\n throw errore;\n }\n },\n top: (board, opzioni = {}) => api.boardTop(board, opzioni)\n },\n room\n };\n}\nfunction dispositivoSconosciuto() {\n return {\n webgl2: false,\n webgpu: false,\n wasm: false,\n threads: false,\n isolated: false,\n gpu: "none",\n memoryMb: null,\n cores: null,\n mobile: false,\n tier: "low"\n };\n}\nasync function attendiSonda(sonda) {\n let timer;\n try {\n return await Promise.race([\n Promise.resolve().then(sonda).catch(() => dispositivoSconosciuto()),\n new Promise((resolve) => {\n timer = globalThis.setTimeout(() => resolve(dispositivoSconosciuto()), 1500);\n })\n ]);\n } finally {\n if (timer !== void 0) globalThis.clearTimeout(timer);\n }\n}\nfunction creaKit(input = dipendenzeReali2()) {\n let promessa = null;\n return {\n connect() {\n promessa ?? (promessa = Promise.all([connetti(input), attendiSonda(input.sonda)]).then(([connessione, device]) => ({ ...connessione, device })));\n return promessa;\n }\n };\n}\n\n// src/index.ts\nvar caisual = creaKit();\nglobalThis.caisual = caisual;\nvar index_default = caisual;\nexport {\n caisual,\n index_default as default\n};\n');
|
|
4392
|
+
response.end(request.method === "HEAD" ? void 0 : '// Caisual game kit v0.8.0\n\n// ../contracts/src/slug.ts\nvar NOMI_RISERVATI = [\n "www",\n "api",\n "app",\n "play",\n "live",\n "multi",\n "cdn",\n "assets",\n "static",\n "mail",\n "mx",\n "ns1",\n "ns2",\n "autodiscover",\n "_dmarc",\n "admin",\n "login",\n "account",\n "auth",\n "pay",\n "secure",\n "support",\n "help",\n "blog",\n "status",\n "dev",\n "staging",\n "test",\n "caisual",\n "shipz"\n];\nvar RISERVATI = new Set(NOMI_RISERVATI);\nvar SLUG_NUOVO = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;\nvar SLUG_STORICO = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;\nfunction isValidSlug(value) {\n return value.length >= 3 && value.length <= 32 && SLUG_NUOVO.test(value) || SLUG_STORICO.test(value);\n}\nfunction isReservedSlug(value) {\n return RISERVATI.has(value);\n}\n\n// ../contracts/src/manifest.ts\nfunction risolviModalita(manifest, mode) {\n const scelta = mode === null ? void 0 : manifest.modes.find((voce) => voce.id === mode);\n if (mode !== null && scelta === void 0) throw new Error("The selected game mode does not exist.");\n return { players: { ...scelta?.players ?? manifest.players }, lobby: scelta?.lobby ?? manifest.lobby };\n}\nfunction modalitaLocale(manifest, mode) {\n return mode !== null && manifest.modes.some((voce) => voce.id === mode && voce.execution === "local");\n}\nvar TETTO_GIOCATORI = 24;\nvar RITARDO_SPETTATORI_MS = 3e3;\nvar MASSIMO_CLASSIFICHE = 32;\nvar CAMPI = /* @__PURE__ */ new Set([\n "overlay",\n "manifest",\n "id",\n "name",\n "description",\n "cover",\n "screenshots",\n "tags",\n "language",\n "platform",\n "orientation",\n "input",\n "visibility",\n "network",\n "isolated",\n "requires",\n "players",\n "lobby",\n "persistent",\n "spectators",\n "boards",\n "roles",\n "teams",\n "voice",\n "modes"\n]);\nvar INPUT = /* @__PURE__ */ new Set(["keyboard", "mouse", "touch", "gamepad"]);\nvar PLATFORM = /* @__PURE__ */ new Set(["desktop", "mobile", "both"]);\nvar ORIENTATION = /* @__PURE__ */ new Set(["landscape", "portrait"]);\nvar VISIBILITY = /* @__PURE__ */ new Set(["public", "unlisted"]);\nvar VOICE = /* @__PURE__ */ new Set(["none", "room", "team", "proximity"]);\nvar PERFORMANCE = /* @__PURE__ */ new Set(["light", "medium", "heavy"]);\nvar TAG = /^[a-z0-9-]+$/;\nvar ID_INTERNO = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;\nvar CAMPO_MATCHMAKING = /^[a-z0-9][a-z0-9-]{0,31}$/;\nvar ID_CLASSIFICA = /^[a-z0-9][a-z0-9_-]{0,31}$/;\nfunction oggetto(value) {\n if (typeof value !== "object" || value === null || Array.isArray(value)) return null;\n return value;\n}\nfunction percorsoRelativo(value) {\n if (value === "" || value.startsWith("/") || value.includes("\\\\") || value.includes("\\0")) return false;\n if (value.includes("?") || value.includes("#")) return false;\n const parti = value.split("/");\n if (parti.some((parte) => parte === "" || parte === "." || parte === "..")) return false;\n try {\n const decoded = parti.map((parte) => decodeURIComponent(parte));\n return !decoded.some((parte) => parte === "" || parte === "." || parte === ".." || parte.includes("/"));\n } catch {\n return false;\n }\n}\nfunction hostValido(value) {\n if (value.length === 0 || value.length > 253) return false;\n if (value.includes("://") || /[/:?#@]/.test(value)) return false;\n const parti = value.split(".");\n return parti.every(\n (parte) => parte.length >= 1 && parte.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(parte)\n );\n}\nfunction interoTra(value, min, max) {\n return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;\n}\nfunction stringaDefault(dati, campo, valoreDefault, errori) {\n const value = dati[campo];\n if (value === void 0) return valoreDefault;\n if (typeof value !== "string") {\n errori.push(`${campo}: must be a string.`);\n return valoreDefault;\n }\n return value;\n}\nfunction testoFacoltativo(value, key, max, path, errors) {\n if (value[key] === void 0) return void 0;\n const text = value[key];\n if (typeof text !== "string" || text.trim().length === 0 || text.trim().length > max || /[\\r\\n\\u0000-\\u001f]/.test(text)) {\n errors.push(`${path}.${key}: must contain 1-${max} characters on one line.`);\n return void 0;\n }\n return text.trim();\n}\nfunction validaManifest(valore) {\n const errori = [];\n const dati = oggetto(valore);\n if (dati === null) return { ok: false, errori: ["manifest: must be a JSON object."] };\n for (const campo of Object.keys(dati)) {\n if (!CAMPI.has(campo)) errori.push(`${campo}: unknown field.`);\n }\n if (dati.manifest === void 0) errori.push("manifest: is required and must be 1.");\n else if (dati.manifest !== 1) errori.push("manifest: must be exactly 1.");\n const id = stringaDefault(dati, "id", "", errori);\n if (dati.id === void 0) errori.push("id: is required.");\n else if (typeof dati.id === "string") {\n if (!isValidSlug(id)) {\n errori.push("id: must be 3-32 lowercase ASCII letters or digits with internal hyphens; historical UUID v4 slugs may be 36 characters.");\n } else if (isReservedSlug(id)) errori.push("id: this slug is reserved.");\n }\n const name = stringaDefault(dati, "name", "", errori);\n if (dati.name === void 0) errori.push("name: is required.");\n else if (typeof dati.name === "string" && (name.trim() === "" || name.length > 60)) {\n errori.push("name: must contain 1-60 characters.");\n }\n const description = stringaDefault(dati, "description", "", errori);\n if (description.length > 500) errori.push("description: must be at most 500 characters.");\n let cover = null;\n if (dati.cover !== void 0 && dati.cover !== null) {\n if (typeof dati.cover !== "string") errori.push("cover: must be a relative file path or null.");\n else if (!percorsoRelativo(dati.cover)) errori.push("cover: must be a relative file path without query, fragment, or parent segments.");\n else cover = dati.cover;\n }\n const screenshots = [];\n if (dati.screenshots !== void 0) {\n if (!Array.isArray(dati.screenshots)) errori.push("screenshots: must be an array of relative file paths.");\n else {\n if (dati.screenshots.length > 8) errori.push("screenshots: must contain at most 8 paths.");\n for (const [indice, value] of dati.screenshots.entries()) {\n if (typeof value !== "string" || !percorsoRelativo(value)) {\n errori.push(`screenshots[${indice}]: must be a relative file path without query, fragment, or parent segments.`);\n } else screenshots.push(value);\n }\n }\n }\n const tags = [];\n if (dati.tags !== void 0) {\n if (!Array.isArray(dati.tags)) errori.push("tags: must be an array.");\n else {\n if (dati.tags.length > 10) errori.push("tags: must contain at most 10 tags.");\n for (const [indice, value] of dati.tags.entries()) {\n if (typeof value !== "string" || value.length > 24 || !TAG.test(value)) {\n errori.push(`tags[${indice}]: must be 1-24 lowercase letters, digits, or hyphens.`);\n } else tags.push(value);\n }\n }\n }\n const language = stringaDefault(dati, "language", "en", errori);\n if (!/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/.test(language)) {\n errori.push("language: must be a BCP 47 language tag such as en, it, or pt-BR.");\n }\n let platform = "both";\n if (dati.platform === void 0) errori.push("platform: is required.");\n else if (typeof dati.platform !== "string" || !PLATFORM.has(dati.platform)) {\n errori.push("platform: must be desktop, mobile, or both.");\n } else platform = dati.platform;\n let orientation = "landscape";\n if (dati.orientation !== void 0) {\n if (typeof dati.orientation !== "string" || !ORIENTATION.has(dati.orientation)) {\n errori.push("orientation: must be landscape or portrait.");\n } else orientation = dati.orientation;\n }\n const input = [];\n if (dati.input !== void 0) {\n if (!Array.isArray(dati.input)) errori.push("input: must be an array.");\n else for (const [indice, value] of dati.input.entries()) {\n if (typeof value !== "string" || !INPUT.has(value)) {\n errori.push(`input[${indice}]: must be keyboard, mouse, touch, or gamepad.`);\n } else if (input.includes(value)) errori.push(`input[${indice}]: duplicate value ${value}.`);\n else input.push(value);\n }\n }\n let visibility = "public";\n if (dati.visibility !== void 0) {\n if (typeof dati.visibility !== "string" || !VISIBILITY.has(dati.visibility)) {\n errori.push("visibility: must be public or unlisted.");\n } else visibility = dati.visibility;\n }\n const network = [];\n if (dati.network !== void 0) {\n if (!Array.isArray(dati.network)) errori.push("network: must be an array of host names.");\n else for (const [indice, value] of dati.network.entries()) {\n if (typeof value !== "string" || !hostValido(value)) {\n errori.push(`network[${indice}]: must be a host name without scheme, port, path, query, or fragment.`);\n } else if (network.includes(value)) errori.push(`network[${indice}]: duplicate host ${value}.`);\n else network.push(value);\n }\n }\n let isolated = false;\n if (dati.isolated !== void 0) {\n if (typeof dati.isolated !== "boolean") errori.push("isolated: must be a boolean.");\n else isolated = dati.isolated;\n }\n const requires = {\n webgl2: false,\n webgpu: false,\n wasm: false,\n threads: false,\n memoryMb: null,\n performance: "light"\n };\n if (dati.requires !== void 0) {\n const value = oggetto(dati.requires);\n if (value === null) errori.push("requires: must be an object.");\n else {\n for (const campo of Object.keys(value)) {\n if (!["webgl2", "webgpu", "wasm", "threads", "memoryMb", "performance"].includes(campo)) {\n errori.push(`requires.${campo}: unknown field.`);\n }\n }\n for (const campo of ["webgl2", "webgpu", "wasm", "threads"]) {\n if (value[campo] === void 0) continue;\n if (typeof value[campo] !== "boolean") errori.push(`requires.${campo}: must be a boolean.`);\n else requires[campo] = value[campo];\n }\n if (value.memoryMb !== void 0) {\n if (value.memoryMb !== null && (!interoTra(value.memoryMb, 512, 32768) || value.memoryMb % 256 !== 0)) {\n errori.push("requires.memoryMb: must be an integer from 512 to 32768 in steps of 256, or null.");\n } else requires.memoryMb = value.memoryMb;\n }\n if (value.performance !== void 0) {\n if (typeof value.performance !== "string" || !PERFORMANCE.has(value.performance)) {\n errori.push("requires.performance: must be light, medium, or heavy.");\n } else requires.performance = value.performance;\n }\n if (requires.threads && !isolated) errori.push("requires.threads: needs isolated: true.");\n }\n }\n let players = { min: 1, max: 1 };\n if (dati.players !== void 0) {\n const value = oggetto(dati.players);\n if (value === null) errori.push("players: must be an object with min and max.");\n else {\n for (const campo of Object.keys(value)) {\n if (campo !== "min" && campo !== "max") errori.push(`players.${campo}: unknown field.`);\n }\n if (!interoTra(value.min, 1, TETTO_GIOCATORI)) errori.push(`players.min: must be an integer from 1 to ${TETTO_GIOCATORI}.`);\n if (!interoTra(value.max, 1, TETTO_GIOCATORI)) errori.push(`players.max: must be an integer from 1 to ${TETTO_GIOCATORI} in manifest version 1.`);\n if (interoTra(value.min, 1, TETTO_GIOCATORI) && interoTra(value.max, 1, TETTO_GIOCATORI)) {\n if (value.min > value.max) errori.push("players.max: must be greater than or equal to players.min.");\n else players = { min: value.min, max: value.max };\n }\n }\n }\n let lobby = false;\n if (dati.lobby !== void 0) {\n if (typeof dati.lobby !== "boolean") errori.push("lobby: must be a boolean.");\n else lobby = dati.lobby;\n }\n let persistent = false;\n if (dati.persistent !== void 0) {\n if (typeof dati.persistent !== "boolean") errori.push("persistent: must be a boolean.");\n else persistent = dati.persistent;\n }\n let spectators = { delayMs: RITARDO_SPETTATORI_MS };\n if (dati.spectators === false || dati.spectators === null) spectators = null;\n else if (dati.spectators !== void 0 && dati.spectators !== true) {\n const value = oggetto(dati.spectators);\n if (value === null) {\n errori.push("spectators: must be a boolean or an object with delayMs.");\n } else {\n for (const campo of Object.keys(value)) {\n if (campo !== "delayMs") errori.push(`spectators.${campo}: unknown field.`);\n }\n if (!interoTra(value.delayMs, 0, 3e4)) {\n errori.push("spectators.delayMs: must be an integer from 0 to 30000.");\n } else spectators = { delayMs: value.delayMs };\n }\n }\n let overlay = null;\n if (dati.overlay !== void 0 && dati.overlay !== null) {\n const value = oggetto(dati.overlay);\n if (value === null) errori.push("overlay: must be an object or null.");\n else {\n for (const key of Object.keys(value)) if (!["version", "accent"].includes(key)) errori.push(`overlay.${key}: unknown field.`);\n if (value.version !== 1) errori.push("overlay.version: must be exactly 1.");\n if (value.accent !== void 0 && (typeof value.accent !== "string" || !/^#[0-9a-fA-F]{6}$/.test(value.accent))) {\n errori.push("overlay.accent: must be a six-digit hexadecimal color, such as #336699.");\n }\n overlay = { version: 1, ...typeof value.accent === "string" ? { accent: value.accent } : {} };\n }\n }\n const boards = {};\n if (dati.boards !== void 0) {\n const value = oggetto(dati.boards);\n if (value === null) errori.push("boards: must be an object of board ids.");\n else {\n if (Object.keys(value).length > MASSIMO_CLASSIFICHE) {\n errori.push(`boards: at most ${MASSIMO_CLASSIFICHE} boards.`);\n }\n for (const [id2, raw] of Object.entries(value)) {\n let valido = true;\n if (!ID_CLASSIFICA.test(id2)) {\n errori.push(`boards.${id2}: invalid board id.`);\n valido = false;\n }\n const board = oggetto(raw);\n if (board === null) {\n errori.push(`boards.${id2}.source: must be "client" or "server".`);\n continue;\n }\n for (const campo of Object.keys(board)) {\n if (!["source", "label", "periods"].includes(campo)) errori.push(`boards.${id2}.${campo}: unknown field.`);\n }\n if (board.source !== "client" && board.source !== "server") {\n errori.push(`boards.${id2}.source: must be "client" or "server".`);\n valido = false;\n }\n const label = testoFacoltativo(board, "label", 48, `boards.${id2}`, errori);\n let periods = ["all-time"];\n if (board.periods !== void 0) {\n if (!Array.isArray(board.periods) || board.periods.length < 1 || board.periods.length > 2 || board.periods.some((period) => period !== "daily" && period !== "all-time") || new Set(board.periods).size !== board.periods.length) {\n errori.push(`boards.${id2}.periods: must contain daily, all-time, or both without duplicates.`);\n } else periods = [...board.periods];\n }\n if (valido) Object.defineProperty(boards, id2, { value: {\n source: board.source,\n periods,\n ...label === void 0 ? {} : { label }\n }, enumerable: true, configurable: true, writable: true });\n }\n }\n }\n const roles = [];\n if (dati.roles !== void 0) {\n if (!Array.isArray(dati.roles)) errori.push("roles: must be an array.");\n else {\n const ids = /* @__PURE__ */ new Set();\n for (const [indice, raw] of dati.roles.entries()) {\n const value = oggetto(raw);\n if (value === null) {\n errori.push(`roles[${indice}]: must be an object.`);\n continue;\n }\n for (const campo of Object.keys(value)) {\n if (!["id", "min", "max", "label"].includes(campo)) errori.push(`roles[${indice}].${campo}: unknown field.`);\n }\n const idRuolo = value.id;\n const min = value.min;\n const max = value.max;\n let valido = true;\n if (typeof idRuolo !== "string" || idRuolo.length > 32 || !ID_INTERNO.test(idRuolo)) {\n errori.push(`roles[${indice}].id: must be 1-32 lowercase letters, digits, or internal hyphens.`);\n valido = false;\n } else if (ids.has(idRuolo)) {\n errori.push(`roles[${indice}].id: duplicate role ${idRuolo}.`);\n valido = false;\n } else ids.add(idRuolo);\n if (!interoTra(min, 0, TETTO_GIOCATORI)) {\n errori.push(`roles[${indice}].min: must be an integer from 0 to ${TETTO_GIOCATORI}.`);\n valido = false;\n }\n if (max !== void 0 && !interoTra(max, 0, TETTO_GIOCATORI)) {\n errori.push(`roles[${indice}].max: must be an integer from 0 to ${TETTO_GIOCATORI} when present.`);\n valido = false;\n }\n if (typeof min === "number" && typeof max === "number" && min > max) {\n errori.push(`roles[${indice}].max: must be greater than or equal to min.`);\n valido = false;\n }\n const label = testoFacoltativo(value, "label", 32, `roles[${indice}]`, errori);\n if (valido) roles.push({\n id: idRuolo,\n min,\n ...max === void 0 ? {} : { max },\n ...label === void 0 ? {} : { label }\n });\n }\n }\n }\n let teams = null;\n if (dati.teams !== void 0 && dati.teams !== null) {\n const value = oggetto(dati.teams);\n if (value === null) errori.push("teams: must be null or an object with min and max.");\n else {\n for (const campo of Object.keys(value)) {\n if (campo !== "min" && campo !== "max") errori.push(`teams.${campo}: unknown field.`);\n }\n if (!interoTra(value.min, 2, TETTO_GIOCATORI)) errori.push(`teams.min: must be an integer from 2 to ${TETTO_GIOCATORI}.`);\n if (!interoTra(value.max, 2, TETTO_GIOCATORI)) errori.push(`teams.max: must be an integer from 2 to ${TETTO_GIOCATORI}.`);\n if (interoTra(value.min, 2, TETTO_GIOCATORI) && interoTra(value.max, 2, TETTO_GIOCATORI)) {\n if (value.min > value.max) errori.push("teams.max: must be greater than or equal to teams.min.");\n else teams = { min: value.min, max: value.max };\n }\n }\n }\n let voice = "none";\n if (dati.voice !== void 0) {\n if (typeof dati.voice !== "string" || !VOICE.has(dati.voice)) {\n errori.push("voice: must be none, room, team, or proximity.");\n } else voice = dati.voice;\n }\n const modes = [];\n if (dati.modes !== void 0) {\n if (!Array.isArray(dati.modes)) errori.push("modes: must be an array.");\n else {\n const ids = /* @__PURE__ */ new Set();\n for (const [indice, raw] of dati.modes.entries()) {\n const value = oggetto(raw);\n if (value === null) {\n errori.push(`modes[${indice}]: must be an object.`);\n continue;\n }\n for (const campo of Object.keys(value)) {\n if (!["id", "players", "lobby", "matchmaking", "execution", "label", "instructions"].includes(campo)) errori.push(`modes[${indice}].${campo}: unknown field.`);\n }\n if (typeof value.id !== "string" || value.id.length > 32 || !ID_INTERNO.test(value.id)) {\n errori.push(`modes[${indice}].id: must be 1-32 lowercase letters, digits, or internal hyphens.`);\n continue;\n }\n if (ids.has(value.id)) {\n errori.push(`modes[${indice}].id: duplicate mode ${value.id}.`);\n continue;\n }\n ids.add(value.id);\n const modo = { id: value.id };\n for (const [key2, max] of [["label", 48], ["instructions", 160]]) {\n const text = testoFacoltativo(value, key2, max, `modes[${indice}]`, errori);\n if (text !== void 0) modo[key2] = text;\n }\n if (value.execution !== void 0) {\n if (value.execution !== "local" && value.execution !== "room") errori.push(`modes[${indice}].execution: must be local or room.`);\n else modo.execution = value.execution;\n }\n if (overlay !== null && modo.execution === void 0) errori.push(`modes[${indice}].execution: is required with the standard overlay.`);\n if (value.players !== void 0) {\n const campo = `modes[${indice}].players`;\n const range = oggetto(value.players);\n if (range === null) errori.push(`${campo}: must be an object with min and max.`);\n else {\n for (const key2 of Object.keys(range)) {\n if (key2 !== "min" && key2 !== "max") errori.push(`${campo}.${key2}: unknown field.`);\n }\n if (!interoTra(range.min, 1, TETTO_GIOCATORI)) errori.push(`${campo}.min: must be an integer from 1 to ${TETTO_GIOCATORI}.`);\n if (!interoTra(range.max, 1, TETTO_GIOCATORI)) errori.push(`${campo}.max: must be an integer from 1 to ${TETTO_GIOCATORI}.`);\n if (interoTra(range.min, 1, TETTO_GIOCATORI) && interoTra(range.max, 1, TETTO_GIOCATORI)) {\n if (range.min > range.max) errori.push(`${campo}.max: must be greater than or equal to min.`);\n else modo.players = { min: range.min, max: range.max };\n }\n }\n }\n if (value.lobby !== void 0) {\n if (typeof value.lobby !== "boolean") errori.push(`modes[${indice}].lobby: must be a boolean.`);\n else modo.lobby = value.lobby;\n }\n if (modo.execution === "local") {\n const range = modo.players ?? players;\n if (range.min !== 1 || range.max !== 1) errori.push(`modes[${indice}].players: local execution requires min and max to be 1.`);\n if (modo.lobby ?? lobby) errori.push(`modes[${indice}].lobby: local execution requires false.`);\n if (value.matchmaking !== void 0) errori.push(`modes[${indice}].matchmaking: local execution cannot use matchmaking.`);\n }\n if (value.matchmaking === void 0) {\n modes.push(modo);\n continue;\n }\n const matchmaking = oggetto(value.matchmaking);\n if (matchmaking === null) {\n errori.push(`modes[${indice}].matchmaking: must be an object.`);\n continue;\n }\n for (const campo of Object.keys(matchmaking)) {\n if (!["key", "timeoutMs", "defaults"].includes(campo)) {\n errori.push(`modes[${indice}].matchmaking.${campo}: unknown field.`);\n }\n }\n let valido = true;\n const key = [];\n if (!Array.isArray(matchmaking.key) || matchmaking.key.length < 1 || matchmaking.key.length > 8) {\n errori.push(`modes[${indice}].matchmaking.key: must contain from 1 to 8 fields.`);\n valido = false;\n } else for (const [keyIndice, item] of matchmaking.key.entries()) {\n if (typeof item !== "string" || !CAMPO_MATCHMAKING.test(item)) {\n errori.push(`modes[${indice}].matchmaking.key[${keyIndice}]: must be 1-32 lowercase letters, digits, or hyphens and start with a letter or digit.`);\n valido = false;\n } else if (key.includes(item)) {\n errori.push(`modes[${indice}].matchmaking.key[${keyIndice}]: duplicate field ${item}.`);\n valido = false;\n } else key.push(item);\n }\n if (!interoTra(matchmaking.timeoutMs, 1e3, 3e5)) {\n errori.push(`modes[${indice}].matchmaking.timeoutMs: must be an integer from 1000 to 300000.`);\n valido = false;\n }\n let defaults;\n if (matchmaking.defaults !== void 0) {\n const values = oggetto(matchmaking.defaults);\n if (values === null || Object.keys(values).length !== key.length || key.some((field) => !Object.hasOwn(values, field))) {\n errori.push(`modes[${indice}].matchmaking.defaults: must contain exactly the declared key fields.`);\n } else {\n defaults = {};\n for (const [field, value2] of Object.entries(values)) {\n if (!(typeof value2 === "string" && value2.length >= 1 && value2.length <= 64 && /^[A-Za-z0-9_.:-]+$/.test(value2)) && !Number.isSafeInteger(value2)) {\n errori.push(`modes[${indice}].matchmaking.defaults.${field}: must be a string of 1-64 characters or a safe integer.`);\n } else Object.defineProperty(defaults, field, { value: value2, enumerable: true });\n }\n }\n }\n if (valido) modes.push({ ...modo, matchmaking: {\n ...defaults === void 0 ? {} : { defaults },\n key,\n timeoutMs: matchmaking.timeoutMs\n } });\n }\n }\n }\n if (overlay !== null && modes.length === 0) errori.push("modes: at least one explicit mode is required with the standard overlay.");\n if (errori.length > 0) return { ok: false, errori };\n return { ok: true, manifest: {\n manifest: 1,\n overlay,\n id,\n name,\n description,\n cover,\n screenshots,\n tags,\n language,\n platform,\n orientation,\n input,\n visibility,\n network,\n isolated,\n requires,\n players,\n lobby,\n persistent,\n spectators,\n boards,\n roles,\n teams,\n voice,\n modes\n } };\n}\n\n// ../contracts/src/device.ts\nfunction deviceTier(report) {\n if (report.gpu !== "hardware" || report.memoryMb !== null && report.memoryMb <= 2048) return "low";\n if (report.mobile || report.memoryMb !== null && report.memoryMb <= 4096 || report.cores !== null && report.cores <= 4) return "mid";\n return "high";\n}\nfunction perdiContesto(context) {\n try {\n context?.getExtension("WEBGL_lose_context")?.loseContext();\n } catch {\n }\n}\nfunction valoriSincroni(ambiente) {\n let navigator2;\n try {\n navigator2 = ambiente.navigator;\n } catch {\n navigator2 = void 0;\n }\n let memoryMb = null;\n try {\n const memory = navigator2?.deviceMemory;\n const converted = typeof memory === "number" ? memory * 1024 : NaN;\n if (Number.isFinite(converted)) memoryMb = converted;\n } catch {\n memoryMb = null;\n }\n let cores = null;\n try {\n const value = navigator2?.hardwareConcurrency;\n if (typeof value === "number" && Number.isFinite(value)) cores = value;\n } catch {\n cores = null;\n }\n let mobile = false;\n try {\n mobile = typeof navigator2?.userAgentData?.mobile === "boolean" ? navigator2.userAgentData.mobile : /Android|iPhone|iPad|iPod|Mobile/i.test(navigator2?.userAgent ?? "");\n } catch {\n mobile = false;\n }\n let isolated = false;\n try {\n isolated = ambiente.crossOriginIsolated === true;\n } catch {\n isolated = false;\n }\n return {\n webgl2: false,\n webgpu: false,\n wasm: false,\n threads: false,\n isolated,\n gpu: "none",\n memoryMb,\n cores,\n mobile\n };\n}\nasync function probeDevice(globals, timeoutMs = 1500) {\n const ambiente = globals ?? globalThis;\n const report = valoriSincroni(ambiente);\n const webgl = Promise.resolve().then(() => {\n try {\n const canvas = ambiente.document?.createElement("canvas");\n if (canvas === void 0) return;\n const hardware = canvas.getContext("webgl2", { failIfMajorPerformanceCaveat: true });\n if (hardware !== null) {\n report.webgl2 = true;\n report.gpu = "hardware";\n perdiContesto(hardware);\n return;\n }\n const software = canvas.getContext("webgl2");\n if (software !== null) {\n report.webgl2 = true;\n report.gpu = "software";\n perdiContesto(software);\n }\n } catch {\n report.webgl2 = false;\n report.gpu = "none";\n }\n });\n const webgpu = Promise.resolve().then(async () => {\n let device;\n try {\n const gpu = ambiente.navigator?.gpu;\n if (gpu === void 0) return;\n const adapter = await gpu.requestAdapter();\n if (adapter === null) return;\n device = await adapter.requestDevice();\n report.webgpu = true;\n } catch {\n report.webgpu = false;\n } finally {\n try {\n device?.destroy?.();\n } catch {\n }\n }\n });\n const wasm = Promise.resolve().then(() => {\n try {\n report.wasm = ambiente.WebAssembly?.validate(\n new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0])\n ) === true;\n } catch {\n report.wasm = false;\n }\n });\n const threads = Promise.resolve().then(() => {\n try {\n if (ambiente.WebAssembly === void 0) return;\n new ambiente.WebAssembly.Memory({ initial: 1, maximum: 1, shared: true });\n report.threads = true;\n } catch {\n report.threads = false;\n }\n });\n let timer;\n await Promise.race([\n Promise.all([webgl, webgpu, wasm, threads]),\n new Promise((resolve) => {\n timer = setTimeout(resolve, Math.max(0, timeoutMs));\n })\n ]);\n if (timer !== void 0) clearTimeout(timer);\n return { ...report, tier: deviceTier(report) };\n}\n\n// ../contracts/src/overlay.ts\nfunction overlayConfiguration(manifest, coverUrl = null, invite = null) {\n const validated = validaManifest(manifest);\n if (!validated.ok) throw new Error("The overlay manifest is invalid.");\n return { manifest: validated.manifest, coverUrl, invite };\n}\nfunction record(value) {\n return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;\n}\nfunction validOverlayHello(value) {\n const hello = record(value), config = record(hello?.configuration);\n return hello?.v === 1 && typeof hello.epoch === "string" && hello.epoch.length > 0 && hello.epoch.length <= 128 && config !== null && (config.coverUrl === null || typeof config.coverUrl === "string") && (config.invite === null || typeof config.invite === "string" && /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/.test(config.invite)) && validaManifest(config.manifest).ok;\n}\nfunction normalizeOverlayHello(value) {\n if (!validOverlayHello(value)) return null;\n return { v: 1, epoch: value.epoch, configuration: overlayConfiguration(value.configuration.manifest, value.configuration.coverUrl, value.configuration.invite) };\n}\nfunction validOverlayView(value) {\n const data = record(value);\n return data !== null && Object.keys(data).every((key) => ["inputBlocked", "reservedRects", "shortcutEnabled"].includes(key)) && (data.shortcutEnabled === void 0 || typeof data.shortcutEnabled === "boolean") && typeof data.inputBlocked === "boolean" && Array.isArray(data.reservedRects) && data.reservedRects.length <= 8 && data.reservedRects.every((value2) => {\n const rect = record(value2);\n return rect !== null && Object.keys(rect).length === 4 && ["x", "y", "width", "height"].every((key) => typeof rect[key] === "number" && Number.isFinite(rect[key]) && rect[key] >= 0 && rect[key] <= 1e5);\n });\n}\nfunction validOverlayRequest(value) {\n const message = record(value), args = record(message?.args);\n if (message?.type !== "caisual:overlay" || message.v !== 1 || typeof message.epoch !== "string" || message.epoch.length < 1 || message.epoch.length > 128 || typeof message.requestId !== "string" || !(/^[1-9][0-9]{0,15}$/.test(message.requestId) && Number.isSafeInteger(Number(message.requestId))) || args === null) return false;\n if (Object.keys(message).some((key) => !["type", "v", "epoch", "requestId", "sessionId", "op", "args"].includes(key)) || !(message.sessionId === void 0 || message.sessionId === null || typeof message.sessionId === "string" && /^[1-9][0-9]{0,15}$/.test(message.sessionId))) return false;\n const keys = (...allowed) => Object.keys(args).every((key) => allowed.includes(key));\n const text = (key) => typeof args[key] === "string" && args[key].length >= 1 && args[key].length <= 64;\n switch (message.op) {\n case "local.start":\n return keys("mode") && text("mode");\n case "room.create":\n return keys("mode") && (args.mode === null || text("mode"));\n case "room.join":\n return keys("code") && (args.code === void 0 || text("code"));\n case "room.watch":\n return keys("code") && text("code");\n case "room.match": {\n const key = record(args.key);\n return keys("mode", "key") && text("mode") && (args.key === void 0 || key !== null && Object.keys(key).length <= 8 && Object.values(key).every((v) => typeof v === "string" && v.length >= 1 && v.length <= 64 || typeof v === "number" && Number.isSafeInteger(v)));\n }\n case "room.ready":\n return keys("ready") && typeof args.ready === "boolean";\n case "room.role":\n case "room.requestRole":\n return keys("role") && text("role");\n case "room.team":\n return keys("team") && Number.isInteger(args.team) && args.team >= 1 && args.team <= 24;\n case "room.start":\n case "session.cancel":\n case "session.leave":\n case "session.disconnect":\n case "session.resume":\n return keys();\n case "voice.join":\n case "voice.leave":\n return keys() && typeof message.sessionId === "string";\n case "voice.mute":\n return keys("muted") && typeof args.muted === "boolean" && typeof message.sessionId === "string";\n case "voice.setVolume":\n return keys("playerId", "volume") && typeof message.sessionId === "string" && typeof args.playerId === "string" && args.playerId.length > 0 && args.playerId.length <= 128 && typeof args.volume === "number" && Number.isFinite(args.volume) && args.volume >= 0 && args.volume <= 1;\n case "overlay.view":\n return validOverlayView(args);\n default:\n return false;\n }\n}\nfunction validBoardDay(value) {\n if (typeof value !== "string" || !/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) return false;\n const at = Date.parse(`${value}T00:00:00Z`);\n return Number.isFinite(at) && new Date(at).toISOString().slice(0, 10) === value;\n}\n\n// src/errors.ts\nfunction creaErrore(code, message) {\n return Object.assign(new Error(message), { name: "CaisualError", code });\n}\nfunction erroreOffline() {\n return creaErrore("offline", "Caisual services are unavailable.");\n}\nfunction codiceErrore(valore) {\n return typeof valore === "object" && valore !== null && "code" in valore ? valore.code : null;\n}\n\n// src/session/resume.ts\nvar KEY = "caisual-session-v1";\nfunction resume(value) {\n const data = record(value);\n if (!data || typeof data.code !== "string" || !/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/.test(data.code) || !(data.mode === void 0 || data.mode === null || typeof data.mode === "string")) return null;\n return {\n version: 1,\n code: data.code,\n mode: typeof data.mode === "string" ? data.mode : null,\n updatedAt: typeof data.updatedAt === "number" && Number.isFinite(data.updatedAt) ? data.updatedAt : 0\n };\n}\nfunction createResume(save, changed) {\n let current = null, error = false, work = Promise.resolve();\n const write = async () => {\n const value = { version: 1, imported: true, resume: current };\n work = work.catch(() => void 0).then(async () => {\n try {\n await save.set(KEY, value);\n error = false;\n } catch (cause) {\n error = true;\n throw cause;\n } finally {\n changed();\n }\n });\n return work;\n };\n const loaded = (async () => {\n try {\n const data = record(await save.get(KEY));\n if (data?.version === 1 && data.imported === true) current = resume(data.resume);\n else {\n current = resume(await save.get("resume"));\n await write();\n }\n } catch {\n error = true;\n }\n changed();\n })();\n return {\n loaded,\n get value() {\n return current === null ? null : { ...current };\n },\n get error() {\n return error;\n },\n async set(value) {\n await loaded;\n current = value;\n changed();\n await write();\n }\n };\n}\n\n// src/session/index.ts\nfunction notify(listeners, value) {\n for (const listener of listeners) {\n try {\n listener(value);\n } catch {\n }\n }\n}\nfunction createSession(base, configuration = null, roomsAvailable = base.connected) {\n const standard = configuration?.manifest.overlay?.version === 1;\n const manifest = configuration?.manifest;\n let current = { kind: "idle" }, ready = false, operation = 0, identifier = 0;\n let pending = null, pendingMode = null;\n let waiting = null, controller = null;\n let stops = [], disposed = false, lastState = "";\n let view = { inputBlocked: false, reservedRects: [] };\n const listeners = /* @__PURE__ */ new Set();\n const viewListeners = /* @__PURE__ */ new Set();\n const stateListeners = /* @__PURE__ */ new Set();\n const openListeners = /* @__PURE__ */ new Set();\n const errorListeners = /* @__PURE__ */ new Set();\n const scoreListeners = /* @__PURE__ */ new Set();\n let resumeStore = null;\n const capabilities = () => ({\n local: true,\n rooms: roomsAvailable,\n overlay: standard,\n requestRole: current.kind === "room" && current.room.metadata.configuration?.requestRole === true\n });\n function voiceSnapshot() {\n if (current.kind !== "room" || !manifest || manifest.voice === "none") return null;\n const room = current.room, voice = room.voice;\n if (!voice || voice.mode === "none" || room.players.find((p) => p.id === room.you)?.role === "spectator") return null;\n return {\n mode: voice.mode,\n state: voice.state,\n mic: voice.mic,\n muted: voice.muted,\n speaking: voice.speaking,\n peers: voice.peers.map(({ id, mic, muted, speaking, volume }) => ({ id, mic, muted, speaking, volume }))\n };\n }\n function snapshot() {\n const attached = current.kind === "room" || current.kind === "watch" ? current.room : null;\n const configured = attached?.metadata.configuration;\n const fallback = manifest && attached && (attached.mode === null || manifest.modes.some((m) => m.id === attached.mode)) ? risolviModalita(manifest, attached.mode) : { players: { min: 1, max: 1 }, lobby: false };\n return {\n kind: pending ?? (current.kind === "idle" ? ready ? "home" : "boot" : current.kind),\n id: current.kind === "idle" ? null : current.id,\n mode: pending ? pendingMode : current.kind === "local" ? current.mode : attached?.mode ?? null,\n localStatus: current.kind === "local" ? current.status : null,\n ready,\n capabilities: capabilities(),\n room: attached ? {\n code: attached.code,\n mode: attached.mode,\n status: attached.status,\n host: attached.host,\n you: current.kind === "room" ? current.room.you : null,\n players: attached.players.map((p) => ({ id: p.id, name: p.name, guest: p.guest, role: p.role, team: p.team, ready: p.ready, connected: p.connected })),\n countdownAt: attached.countdownAt,\n connection: attached.connection,\n closedCode: attached.metadata.closedCode,\n limits: { ...configured?.players ?? fallback.players },\n lobby: configured?.lobby ?? fallback.lobby,\n persistent: configured?.persistent ?? manifest?.persistent ?? false,\n delayMs: current.kind === "watch" ? current.room.delayMs : null,\n requestRole: configured?.requestRole ?? false\n } : null,\n voice: pending ? null : voiceSnapshot(),\n waiting: waiting ? { ...waiting } : null,\n resume: resumeStore?.value ?? null,\n resumeError: resumeStore?.error ?? false\n };\n }\n function emit() {\n if (disposed) return;\n const state = snapshot(), serialized = JSON.stringify(state);\n if (serialized === lastState) return;\n lastState = serialized;\n notify(stateListeners, state);\n }\n function changed() {\n notify(listeners, { ...current });\n emit();\n }\n function active() {\n if (current.kind !== "room") throw creaErrore("no_room", "There is no active player room.");\n return current.room;\n }\n function activeVoice() {\n const room = active();\n if (room.players.find((p) => p.id === room.you)?.role === "spectator") throw creaErrore("spectator", "Spectators cannot use voice controls.");\n if (!manifest || manifest.voice === "none" || room.voice.mode === "none") throw creaErrore("voice_disabled", "Voice is disabled for this room.");\n return room.voice;\n }\n function cancel() {\n operation++;\n controller?.abort();\n controller = null;\n pending = null;\n waiting = null;\n emit();\n }\n function detach(preserve) {\n stops.splice(0).forEach((stop) => stop());\n if (current.kind === "room" || current.kind === "watch") {\n if (preserve) current.room.disconnect();\n else current.room.leave();\n }\n current = { kind: "idle" };\n changed();\n }\n async function clearResume(code) {\n if (resumeStore?.value?.code === code) await resumeStore.set(null).catch(() => void 0);\n }\n async function adopt(next, watch, token) {\n if (token !== operation || disposed) {\n next.leave();\n throw creaErrore("cancelled", "The operation was cancelled.");\n }\n detach(false);\n current = watch ? { kind: "watch", room: next, id: String(++identifier) } : { kind: "room", room: next, id: String(++identifier) };\n const room = next;\n stops = [room.onPlayers(emit), room.onMetadata(() => {\n if (room.connection === "disconnected" && (current.kind === "room" || current.kind === "watch") && current.room === room) {\n stops.splice(0).forEach((stop) => stop());\n if (!watch && room.metadata.closedCode === 1e3) void clearResume(room.code);\n current = { kind: "idle" };\n changed();\n } else emit();\n }), room.onStatus(() => {\n emit();\n if (!watch && room.connection === "ended") void clearResume(room.code);\n })];\n if (!watch) {\n const playerRoom = next;\n const sessionId = current.id;\n if (playerRoom.voice) stops.push(playerRoom.voice.onState(emit), playerRoom.voice.onPeers(emit));\n stops.push(playerRoom.onError((error) => notify(errorListeners, { sessionId, error: { ...error } })));\n stops.push(playerRoom.onScoreQueued((score) => notify(scoreListeners, { ...score })));\n for (const score of playerRoom.queuedScores) notify(scoreListeners, { ...score });\n }\n pending = null;\n waiting = null;\n changed();\n if (!watch && resumeStore && room.connection !== "ended") {\n await resumeStore.set({ version: 1, code: room.code, mode: room.mode, updatedAt: base.time.now() }).catch(() => void 0);\n }\n return next;\n }\n async function run(kind, mode, work, watch = false) {\n cancel();\n const token = operation;\n controller = new AbortController();\n pending = kind;\n pendingMode = mode;\n emit();\n try {\n const next = await work(controller.signal, token);\n await adopt(next, watch, token);\n if (token !== operation || disposed) throw creaErrore("cancelled", "The operation was cancelled.");\n return next;\n } finally {\n if (token === operation) {\n pending = null;\n waiting = null;\n controller = null;\n emit();\n }\n }\n }\n const direct = base.room;\n const rooms = !standard ? direct : {\n invited: direct.invited,\n create(options) {\n if (manifest && modalitaLocale(manifest, options.mode)) return Promise.reject(creaErrore("invalid_request", "Local modes cannot create rooms."));\n return run("attaching", options.mode, () => direct.create(options));\n },\n join(code) {\n return run("attaching", null, () => direct.join(code));\n },\n watch(code) {\n return run("attaching", null, () => direct.watch(code), true);\n },\n match(options) {\n if (manifest && modalitaLocale(manifest, options.mode)) return Promise.reject(creaErrore("invalid_request", "Local modes cannot use matchmaking."));\n return run("matching", options.mode, (signal, token) => {\n const abort = () => {\n if (operation === token) cancel();\n };\n options.signal?.addEventListener("abort", abort, { once: true });\n if (options.signal?.aborted) abort();\n return direct.match({ ...options, signal, onWaiting(value) {\n if (token !== operation) return;\n waiting = { ...value };\n emit();\n options.onWaiting?.(value);\n } }).finally(() => options.signal?.removeEventListener("abort", abort));\n });\n }\n };\n if (standard) resumeStore = createResume(base.save, emit);\n const session = {\n get current() {\n return { ...current };\n },\n get capabilities() {\n return capabilities();\n },\n onChange(listener) {\n listeners.add(listener);\n notify(/* @__PURE__ */ new Set([listener]), { ...current });\n return () => {\n listeners.delete(listener);\n };\n },\n ready() {\n if (disposed || ready) return;\n ready = true;\n emit();\n },\n finish() {\n if (current.kind === "room" || current.kind === "watch") throw creaErrore("not_local", "Only a local session can be finished by the client.");\n if (current.kind === "local") {\n current = { ...current, status: "ended" };\n changed();\n }\n }\n };\n const overlay = {\n open(panel) {\n if (!["home", "room", "invite", "friends", "voice", "boards"].includes(panel)) throw creaErrore("invalid_request", "Unknown overlay panel.");\n if (standard) notify(openListeners, panel);\n },\n onChange(listener) {\n viewListeners.add(listener);\n notify(/* @__PURE__ */ new Set([listener]), structuredClone(view));\n return () => {\n viewListeners.delete(listener);\n };\n }\n };\n return {\n session,\n overlay,\n rooms,\n snapshot,\n serverTime: () => current.kind === "room" || current.kind === "watch" ? current.room.serverTime() : base.time.now(),\n onState(listener) {\n stateListeners.add(listener);\n listener(snapshot());\n return () => {\n stateListeners.delete(listener);\n };\n },\n onOpen(listener) {\n openListeners.add(listener);\n return () => {\n openListeners.delete(listener);\n };\n },\n onError(listener) {\n errorListeners.add(listener);\n return () => {\n errorListeners.delete(listener);\n };\n },\n onScore(listener) {\n scoreListeners.add(listener);\n return () => {\n scoreListeners.delete(listener);\n };\n },\n async execute(request) {\n if (!standard) throw creaErrore("overlay_disabled", "This game uses its own room flow.");\n if (request.op === "overlay.view") {\n if (!validOverlayView(request.args)) throw creaErrore("invalid_request", "The overlay geometry is invalid.");\n view = structuredClone(request.args);\n notify(viewListeners, structuredClone(view));\n return;\n }\n if (request.sessionId !== void 0 && request.sessionId !== (current.kind === "idle" ? null : current.id)) throw creaErrore("session_replaced", "The active session changed.");\n if (request.op.startsWith("voice.") && request.sessionId !== (current.kind === "idle" ? null : current.id)) throw creaErrore("session_replaced", "The active session changed.");\n if (!ready) throw creaErrore("game_not_ready", "The game is still loading.");\n switch (request.op) {\n case "local.start": {\n if (!manifest || !modalitaLocale(manifest, request.args.mode)) throw creaErrore("invalid_mode", "This is not a local mode.");\n cancel();\n const token = operation;\n if (current.kind === "room") await clearResume(current.room.code);\n if (token !== operation || disposed) throw creaErrore("cancelled", "The operation was cancelled.");\n detach(false);\n current = { kind: "local", id: String(++identifier), mode: request.args.mode, status: "playing" };\n changed();\n return;\n }\n case "room.create":\n await rooms.create(request.args);\n return;\n case "room.join":\n await rooms.join(request.args.code);\n return;\n case "room.watch":\n await rooms.watch(request.args.code);\n return;\n case "room.match": {\n const mode = manifest?.modes.find((m) => m.id === request.args.mode);\n const key = request.args.key ?? mode?.matchmaking?.defaults;\n if (!key) throw creaErrore("invalid_request", "Matchmaking needs a complete key.");\n await rooms.match({ mode: request.args.mode, key });\n return;\n }\n case "voice.join": {\n const room = active(), voice = activeVoice();\n await voice.join();\n if (current.kind !== "room" || current.room !== room) throw creaErrore("session_replaced", "The active session changed.");\n emit();\n return;\n }\n case "voice.mute":\n activeVoice().mute(request.args.muted);\n emit();\n return;\n case "voice.leave":\n activeVoice().leave();\n emit();\n return;\n case "voice.setVolume": {\n const voice = activeVoice();\n if (!voice.peers.some((peer) => peer.id === request.args.playerId)) throw creaErrore("voice_peer_missing", "This voice participant is no longer available.");\n voice.setVolume(request.args.playerId, request.args.volume);\n emit();\n return;\n }\n case "room.ready":\n active().ready(request.args.ready);\n return;\n case "room.role":\n active().setRole(request.args.role);\n return;\n case "room.requestRole":\n await active().requestRole(request.args.role);\n return;\n case "room.team":\n active().setTeam(request.args.team);\n return;\n case "room.start":\n active().start();\n return;\n case "session.cancel":\n cancel();\n return;\n case "session.resume": {\n await run("attaching", null, async (signal) => {\n await resumeStore?.loaded;\n if (signal.aborted) throw creaErrore("cancelled", "The operation was cancelled.");\n if (!resumeStore?.value) throw creaErrore("no_resume", "There is no saved room.");\n return direct.join(resumeStore.value.code);\n });\n return;\n }\n case "session.disconnect": {\n cancel();\n const token = operation;\n if (current.kind === "room" && current.room.connection !== "ended" && resumeStore) await resumeStore.set({ version: 1, code: current.room.code, mode: current.room.mode, updatedAt: base.time.now() });\n if (token !== operation || disposed) throw creaErrore("cancelled", "The operation was cancelled.");\n detach(true);\n return;\n }\n case "session.leave": {\n cancel();\n const token = operation;\n if (current.kind === "room") await clearResume(current.room.code);\n if (token !== operation || disposed) throw creaErrore("cancelled", "The operation was cancelled.");\n detach(false);\n return;\n }\n }\n },\n dispose() {\n cancel();\n detach(true);\n disposed = true;\n listeners.clear();\n viewListeners.clear();\n stateListeners.clear();\n openListeners.clear();\n scoreListeners.clear();\n errorListeners.clear();\n }\n };\n}\n\n// src/overlay/shortcut.ts\nfunction bindOverlayShortcut(target, overlay, open) {\n let enabled = true, blocked = false;\n const stop = overlay.onChange((view) => {\n enabled = view.shortcutEnabled !== false;\n blocked = view.inputBlocked;\n });\n const listener = (event) => {\n const element = event.target;\n if (!enabled || blocked || event.repeat || event.key !== "Tab" || !event.shiftKey || event.ctrlKey || event.altKey || event.metaKey || element?.closest?.(\'input,textarea,select,[contenteditable="true"]\')) return;\n event.preventDefault();\n event.stopImmediatePropagation();\n open();\n };\n target.addEventListener("keydown", listener, true);\n return () => {\n stop();\n target.removeEventListener("keydown", listener, true);\n };\n}\n\n// src/overlay/bridge.ts\nfunction attachKitBridge(port, hello, coordinator) {\n let disposed = false, seq = 0, highestRequest = 0, activeRequests = 0;\n const replies = /* @__PURE__ */ new Map();\n const send = (message) => {\n if (!disposed) try {\n port.postMessage(message);\n } catch {\n }\n };\n const stops = [\n ...hello.configuration.manifest.overlay && typeof window !== "undefined" ? [bindOverlayShortcut(window, coordinator.overlay, () => send({ type: "caisual:overlay-shortcut", v: 1, epoch: hello.epoch }))] : [],\n coordinator.onState((state) => send({ type: "caisual:overlay-state", v: 1, epoch: hello.epoch, seq: ++seq, serverTime: coordinator.serverTime(), state })),\n coordinator.onOpen((panel) => send({ type: "caisual:overlay-open", v: 1, epoch: hello.epoch, panel })),\n coordinator.onError(({ sessionId, error }) => send({ type: "caisual:overlay-error", v: 1, epoch: hello.epoch, sessionId, error })),\n coordinator.onScore((score) => send({ type: "caisual:overlay-score", v: 1, epoch: hello.epoch, score }))\n ];\n const listener = (event) => {\n const raw = record(event.data);\n if (raw?.type !== "caisual:overlay" || raw.epoch !== hello.epoch || disposed) return;\n const reply = { type: "caisual:overlay-response", v: 1, epoch: hello.epoch, requestId: typeof raw.requestId === "string" ? raw.requestId : "" };\n if (!validOverlayRequest(raw)) {\n send({ ...reply, ok: false, error: { code: "invalid_request", message: "The overlay request is invalid." } });\n return;\n }\n const fingerprint = JSON.stringify([raw.op, raw.args, raw.sessionId]);\n const previous = replies.get(raw.requestId);\n if (previous) {\n if (previous.fingerprint !== fingerprint) send({ ...reply, ok: false, error: { code: "duplicate_request", message: "The request id was already used." } });\n else void previous.response.then(send);\n return;\n }\n if (Number(raw.requestId) <= highestRequest || activeRequests >= 32) {\n send({ ...reply, ok: false, error: { code: "stale_request", message: "The request is stale or too many requests are pending." } });\n return;\n }\n highestRequest = Number(raw.requestId);\n activeRequests++;\n const response = Promise.resolve().then(() => coordinator.execute(raw)).then(\n () => ({ ...reply, ok: true }),\n (error) => ({ ...reply, ok: false, error: {\n code: typeof record(error)?.code === "string" ? record(error).code : "internal_error",\n message: error instanceof Error ? error.message : "The operation could not be completed."\n } })\n );\n replies.set(raw.requestId, { fingerprint, response });\n void response.then((value) => {\n activeRequests--;\n send(value);\n if (replies.size > 64) for (const id of replies.keys()) {\n if (Number(id) < highestRequest - 64) replies.delete(id);\n }\n });\n };\n port.addEventListener("message", listener);\n port.start();\n return () => {\n disposed = true;\n port.removeEventListener("message", listener);\n stops.forEach((stop) => stop());\n coordinator.dispose();\n replies.clear();\n };\n}\n\n// src/http.ts\nasync function leggiErrore(response) {\n let corpo = {};\n try {\n corpo = await response.json();\n } catch {\n }\n return creaErrore(\n typeof corpo.error?.code === "string" ? corpo.error.code : response.status === 401 ? "invalid_ticket" : "internal_error",\n typeof corpo.error?.message === "string" ? corpo.error.message : `The request failed with status ${response.status}.`\n );\n}\nfunction creaRichiedente(origin, prefix, fetcher, biglietto) {\n async function manda(path, metodo, ticket, corpo) {\n const headers = new Headers({ Authorization: `Bearer ${ticket}` });\n let body;\n if (corpo !== void 0) {\n headers.set("Content-Type", "application/json");\n try {\n body = JSON.stringify(corpo);\n } catch {\n throw creaErrore("invalid_request", "The value must be valid JSON.");\n }\n }\n try {\n return await fetcher(new URL(prefix + path, origin), {\n method: metodo,\n headers,\n body,\n credentials: "omit"\n });\n } catch {\n throw erroreOffline();\n }\n }\n return async function richiesta(path, metodo, corpo, forzaRinnovo = false) {\n let ticket;\n try {\n ticket = forzaRinnovo ? await biglietto.rinnova() : await biglietto.ottieni();\n } catch {\n throw erroreOffline();\n }\n let response = await manda(path, metodo, ticket, corpo);\n if (response.status === 401) {\n try {\n ticket = await biglietto.rinnova();\n } catch {\n throw erroreOffline();\n }\n response = await manda(path, metodo, ticket, corpo);\n }\n if (!response.ok) throw await leggiErrore(response);\n try {\n return await response.json();\n } catch {\n throw creaErrore("internal_error", "The service returned an invalid response.");\n }\n };\n}\n\n// src/api.ts\nfunction creaClienteApi(appOrigin, fetcher, biglietto) {\n const richiesta = creaRichiedente(appOrigin, "/api/kit", fetcher, biglietto);\n return {\n me: () => richiesta("/me", "GET"),\n saveSet: (key, value) => richiesta(`/saves/${encodeURIComponent(key)}`, "PUT", { value }),\n async saveGet(key) {\n try {\n return (await richiesta(`/saves/${encodeURIComponent(key)}`, "GET")).value;\n } catch (errore) {\n if (codiceErrore(errore) === "not_found") return null;\n throw errore;\n }\n },\n async saveRemove(key) {\n await richiesta(`/saves/${encodeURIComponent(key)}`, "DELETE");\n },\n async saveList() {\n return (await richiesta("/saves", "GET")).saves;\n },\n async boardSubmit(board, score, daily) {\n const risultato = await richiesta("/scores", "POST", { board, score, daily });\n return {\n accepted: true,\n best: risultato.best,\n rank: risultato.rank,\n day: risultato.day,\n verified: risultato.verified\n };\n },\n async boardTop(board, opzioni) {\n if (opzioni.day !== void 0 && (!validBoardDay(opzioni.day) || opzioni.daily === false)) throw creaErrore("invalid_request", "day must be a real UTC date and cannot be combined with daily: false.");\n const query = new URLSearchParams();\n if (opzioni.day !== void 0) query.set("day", opzioni.day);\n if (opzioni.daily) query.set("daily", "1");\n if (opzioni.limit !== void 0) query.set("limit", String(opzioni.limit));\n if (opzioni.guests) query.set("guests", "1");\n const suffisso = query.size === 0 ? "" : `?${query.toString()}`;\n const { day, entries, me } = await richiesta(\n `/scores/${encodeURIComponent(board)}${suffisso}`,\n "GET"\n );\n return { day, entries, me };\n }\n };\n}\n\n// src/daily.ts\nvar DIVISORE_UINT32 = 4294967296;\nfunction giornoUtc(ora) {\n return new Date(ora).toISOString().slice(0, 10);\n}\nasync function calcolaSeed(gioco, giorno, subtle) {\n const dati = new TextEncoder().encode(`caisual:${gioco}:${giorno}`);\n const digest = new Uint8Array(await subtle.digest("SHA-256", dati));\n return (digest[0] ?? 0) * 16777216 + ((digest[1] ?? 0) << 16) + ((digest[2] ?? 0) << 8) + (digest[3] ?? 0) >>> 0;\n}\nfunction creaMulberry32(seed) {\n let stato = seed >>> 0;\n return () => {\n stato = stato + 1831565813 >>> 0;\n let valore = stato;\n valore = Math.imul(valore ^ valore >>> 15, valore | 1);\n valore ^= valore + Math.imul(valore ^ valore >>> 7, valore | 61);\n return ((valore ^ valore >>> 14) >>> 0) / DIVISORE_UINT32;\n };\n}\n\n// src/handshake.ts\nfunction record2(valore) {\n return typeof valore === "object" && valore !== null && !Array.isArray(valore) ? valore : null;\n}\nfunction eTipo(valore, tipo) {\n return record2(valore)?.type === tipo;\n}\nfunction leggiOrigine(valore) {\n if (typeof valore !== "string") return null;\n try {\n const url = new URL(valore);\n return url.origin === valore && (url.protocol === "https:" || url.protocol === "http:") ? valore : null;\n } catch {\n return null;\n }\n}\nfunction attendiHandshake(finestra, appOrigin, timeoutMs = 3e3) {\n return new Promise((resolve) => {\n let concluso = false;\n const instance = globalThis.crypto.randomUUID();\n const termina = (esito) => {\n if (concluso) return;\n concluso = true;\n finestra.removeEventListener("message", ascolta);\n finestra.clearTimeout(scadenza);\n resolve(esito);\n };\n const segnalaPronto = () => {\n finestra.parent.postMessage({ type: "caisual:ready", instance, overlayVersion: 1 }, appOrigin);\n };\n const ascolta = (evento) => {\n if (evento.origin !== appOrigin || evento.source !== finestra.parent) return;\n if (eTipo(evento.data, "caisual:ready?")) {\n segnalaPronto();\n return;\n }\n if (!eTipo(evento.data, "caisual:hello")) return;\n const dati = record2(evento.data);\n const porta = evento.ports[0];\n if (typeof dati?.ticket !== "string" || porta === void 0) return;\n porta.start();\n const overlay = normalizeOverlayHello(dati.overlay);\n termina({\n ...overlay ? { overlay } : {},\n ticket: dati.ticket,\n live: leggiOrigine(dati.live),\n invite: typeof dati.invite === "string" ? dati.invite : null,\n porta\n });\n };\n finestra.addEventListener("message", ascolta);\n const scadenza = finestra.setTimeout(() => termina(null), timeoutMs);\n segnalaPronto();\n });\n}\nfunction scadenzaJwt(ticket) {\n const parte = ticket.split(".")[1];\n if (parte === void 0) return null;\n const base64 = parte.replace(/-/g, "+").replace(/_/g, "/").padEnd(\n Math.ceil(parte.length / 4) * 4,\n "="\n );\n try {\n const payload = record2(JSON.parse(globalThis.atob(base64)));\n return typeof payload?.exp === "number" && Number.isFinite(payload.exp) ? payload.exp * 1e3 : null;\n } catch {\n return null;\n }\n}\nfunction chiediBiglietto(porta, finestra, timeoutMs, aud) {\n return new Promise((resolve, reject) => {\n let concluso = false;\n const termina = (ticket) => {\n if (concluso) return;\n concluso = true;\n porta.removeEventListener("message", ascolta);\n finestra.clearTimeout(scadenza);\n if (ticket === null) reject(new Error("Ticket refresh timed out."));\n else resolve(ticket);\n };\n const ascolta = (evento) => {\n const dati = record2(evento.data);\n const destinatario = dati?.aud === void 0 ? "portal" : dati.aud;\n if (dati?.type === "caisual:ticket" && destinatario === aud && typeof dati.ticket === "string") {\n termina(dati.ticket);\n }\n };\n porta.addEventListener("message", ascolta);\n const scadenza = finestra.setTimeout(() => termina(null), timeoutMs);\n try {\n porta.postMessage(aud === "live" ? { type: "caisual:ticket", aud: "live" } : { type: "caisual:ticket" });\n } catch {\n termina(null);\n }\n });\n}\nfunction creaGestoreBiglietto(ticketIniziale, porta, finestra, ora, timeoutMs = 3e3, aud = "portal") {\n let ticket = ticketIniziale;\n let rinnovo = null;\n const rinnova = () => {\n if (rinnovo !== null) return rinnovo;\n const richiesta = chiediBiglietto(porta, finestra, timeoutMs, aud).then((nuovo) => {\n ticket = nuovo;\n return nuovo;\n });\n const completa = richiesta.finally(() => {\n if (rinnovo === completa) rinnovo = null;\n });\n rinnovo = completa;\n return completa;\n };\n return {\n async ottieni() {\n if (ticket === null) return rinnova();\n const scadenza = scadenzaJwt(ticket);\n return scadenza !== null && scadenza - ora() < 3e4 ? rinnova() : ticket;\n },\n rinnova\n };\n}\n\n// src/voce/index.ts\nvar SOGLIA_AUDIO = 0.02;\nvar DURATA_PARLANTE = 300;\nvar INTERVALLO_AUDIO = 200;\nvar DURATA_ZERO = 3e3;\nvar TIMEOUT_CONNESSIONE = 1e4;\nvar RITARDI_RICONNESSIONE = [1e3, 2e3, 4e3];\nfunction limita(value) {\n return Number.isNaN(value) ? 1 : Math.min(1, Math.max(0, value));\n}\nfunction dipendenzeReali(input) {\n const globali = globalThis;\n const AudioContextClass = globali.AudioContext ?? globali.webkitAudioContext;\n if (typeof RTCPeerConnection === "undefined" || typeof MediaStream === "undefined" || AudioContextClass === void 0 || typeof navigator === "undefined" || navigator.mediaDevices?.getUserMedia === void 0 || typeof document === "undefined") return null;\n return {\n ...input,\n creaPeerConnection: (configuration) => new RTCPeerConnection(configuration),\n getUserMedia: (constraints) => navigator.mediaDevices.getUserMedia(constraints),\n creaAudioContext: () => new AudioContextClass(),\n creaAudioElement: () => document.createElement("audio"),\n creaMediaStream: (tracks) => new MediaStream(tracks)\n };\n}\nvar VoceClient = class {\n constructor(contesto, timer, dipendenze) {\n this.contesto = contesto;\n this.modeCorrente = "none";\n this.stateCorrente = "off";\n this.mutedCorrente = false;\n this.speakingCorrente = false;\n this.roster = [];\n this.gains = /* @__PURE__ */ new Map();\n this.volumi = /* @__PURE__ */ new Map();\n this.speakingPeers = /* @__PURE__ */ new Map();\n this.ultimoAudio = /* @__PURE__ */ new Map();\n this.zeroDa = /* @__PURE__ */ new Map();\n this.timerZero = /* @__PURE__ */ new Map();\n this.ascoltatoriPeers = /* @__PURE__ */ new Set();\n this.ascoltatoriState = /* @__PURE__ */ new Set();\n this.richieste = /* @__PURE__ */ new Map();\n this.riproduzioni = /* @__PURE__ */ new Map();\n this.sfuAttive = /* @__PURE__ */ new Map();\n this.midGiocatori = /* @__PURE__ */ new Map();\n this.negati = /* @__PURE__ */ new Set();\n this.mesh = /* @__PURE__ */ new Map();\n this.stream = null;\n this.tracciaMic = null;\n this.audioContext = null;\n this.analyser = null;\n this.peerSfu = null;\n this.sessioneSfu = null;\n this.connessioneSfuAttesa = false;\n this.trasporto = null;\n this.intervalloAudio = null;\n this.timerConnessione = null;\n this.cancellaAttesaConnessione = null;\n this.timerRiconnessione = null;\n this.ultimoAudioMic = Number.NEGATIVE_INFINITY;\n this.sequenzaRichieste = 0;\n this.generazione = 0;\n this.tentativoRiconnessione = 0;\n this.desiderata = false;\n this.micDesiderato = true;\n this.promessaIngresso = null;\n this.negoziazione = Promise.resolve();\n this.dipendenze = dipendenze ?? dipendenzeReali(timer);\n }\n get mode() {\n return this.modeCorrente;\n }\n get state() {\n return this.stateCorrente;\n }\n get mic() {\n return this.stateCorrente === "on" && this.tracciaMic !== null;\n }\n get muted() {\n return this.mutedCorrente;\n }\n get speaking() {\n return this.speakingCorrente;\n }\n get peers() {\n return this.copiaPeers();\n }\n async join(options = {}) {\n if (this.stateCorrente === "on") return;\n if (this.stateCorrente === "joining") {\n if (this.promessaIngresso !== null) await this.promessaIngresso;\n return;\n }\n if (this.stateCorrente === "reconnecting" && this.desiderata) return;\n const mic = this.scegliMic(options);\n this.verificaIngresso(mic);\n this.micDesiderato = mic;\n this.desiderata = true;\n this.tentativoRiconnessione = 0;\n this.aggiornaState("joining");\n const generazione = ++this.generazione;\n const promessa = this.completaIngresso(generazione);\n this.promessaIngresso = promessa;\n try {\n await promessa;\n } finally {\n if (this.promessaIngresso === promessa) this.promessaIngresso = null;\n }\n }\n async completaIngresso(generazione) {\n try {\n await this.entra(generazione);\n } catch (cause) {\n if (generazione !== this.generazione) return;\n this.desiderata = false;\n this.chiudiRisorse();\n this.aggiornaState("off");\n throw this.mappaErrore(cause);\n }\n }\n leave() {\n const deveFermare = this.desiderata || this.stateCorrente !== "off";\n this.desiderata = false;\n this.generazione++;\n this.fermaRiconnessione();\n if (deveFermare && this.contesto.connessa()) {\n void this.richiedi({ t: "voice", op: "stop" }).catch(() => void 0);\n }\n this.rifiutaRichieste(creaErrore("offline", "Voice has stopped."));\n this.chiudiRisorse();\n this.aggiornaState("off");\n }\n mute(muted = true) {\n if (this.stateCorrente !== "on" || this.tracciaMic === null) {\n throw creaErrore("not_publishing", "Join voice before changing mute.");\n }\n this.mutedCorrente = muted;\n this.tracciaMic.enabled = !muted;\n if (muted) this.speakingCorrente = false;\n this.notificaPeers();\n void this.richiedi({ t: "voice", op: "mute", muted }).catch(() => void 0);\n }\n setVolume(playerId, volume) {\n const valore = limita(volume);\n this.volumi.set(playerId, valore);\n this.aggiornaGuadagno(playerId);\n this.notificaPeers();\n }\n onPeers(listener) {\n this.ascoltatoriPeers.add(listener);\n return () => {\n this.ascoltatoriPeers.delete(listener);\n };\n }\n onState(listener) {\n this.ascoltatoriState.add(listener);\n return () => {\n this.ascoltatoriState.delete(listener);\n };\n }\n ricevi(message) {\n if ("r" in message) {\n const pending = this.richieste.get(message.r);\n if (pending !== void 0) {\n this.richieste.delete(message.r);\n if ("error" in message) {\n pending.reject(creaErrore(message.error.code, message.error.message));\n } else pending.resolve(message);\n }\n return;\n }\n if (message.op === "roster") {\n this.negati.clear();\n this.modeCorrente = message.mode;\n const publisher = new Set(message.peers.map((peer) => peer.id));\n this.roster = [\n ...message.peers.map((peer) => ({ ...peer, mic: true })),\n ...message.listeners.flatMap((id) => publisher.has(id) ? [] : [{ id, mic: false, muted: true }])\n ];\n for (const peer of this.roster) {\n if (peer.muted) this.speakingPeers.set(peer.id, false);\n }\n this.pulisciPeerAssenti();\n this.contesto.rosterPronto();\n this.notificaPeers();\n this.accodaRiconciliazione();\n return;\n }\n if (message.op === "gain") {\n this.negati.clear();\n for (const [playerId, gain] of Object.entries(message.gains)) {\n this.gains.set(playerId, limita(gain));\n this.aggiornaZero(playerId);\n this.aggiornaGuadagno(playerId);\n }\n this.notificaPeers();\n this.accodaRiconciliazione();\n return;\n }\n if (message.op === "closed") {\n for (const mid of message.mids) {\n const playerId = this.midGiocatori.get(mid);\n if (playerId === void 0) continue;\n const attiva = this.sfuAttive.get(playerId);\n if (attiva?.mid === mid && !this.riproduzioni.has(playerId)) attiva.receiver?.track.stop();\n if (attiva?.mid === mid) this.sfuAttive.delete(playerId);\n this.midGiocatori.delete(mid);\n this.scollegaTraccia(playerId);\n this.negati.add(playerId);\n }\n this.notificaPeers();\n return;\n }\n if (message.op === "signal") void this.riceviSegnale(message.from, message.data);\n }\n giocatoriCambiati() {\n this.negati.clear();\n const presenti = new Set(this.contesto.giocatori().map((player) => player.id));\n for (const playerId of this.gains.keys()) {\n if (presenti.has(playerId)) continue;\n this.gains.delete(playerId);\n this.zeroDa.delete(playerId);\n const timer = this.timerZero.get(playerId);\n if (timer !== void 0) this.dipendenze?.clearTimeout(timer);\n this.timerZero.delete(playerId);\n this.aggiornaGuadagno(playerId);\n }\n this.notificaPeers();\n this.accodaRiconciliazione();\n }\n socketDisconnesso() {\n this.sequenzaRichieste = 0;\n this.rifiutaRichieste(creaErrore("offline", "The room is reconnecting."));\n if (!this.desiderata) return;\n this.generazione++;\n this.chiudiRisorse();\n this.tentativoRiconnessione = 0;\n this.aggiornaState("reconnecting");\n }\n socketRiconnesso() {\n this.sequenzaRichieste = 0;\n if (this.desiderata && this.stateCorrente === "reconnecting") this.programmaRiconnessione();\n }\n termina() {\n this.desiderata = false;\n this.generazione++;\n this.fermaRiconnessione();\n this.rifiutaRichieste(creaErrore("offline", "The room connection ended."));\n this.chiudiRisorse();\n this.aggiornaState("off");\n }\n scegliMic(options) {\n if (options.mic !== void 0) return options.mic;\n const you = this.contesto.giocatori().find((player) => player.id === this.contesto.you());\n return you?.role !== "spectator";\n }\n verificaIngresso(mic = this.micDesiderato) {\n if (!this.contesto.connessa()) throw creaErrore("offline", "The room is not connected.");\n if (this.modeCorrente === "none") {\n throw creaErrore("voice_disabled", "Voice is disabled for this room.");\n }\n const you = this.contesto.giocatori().find((player) => player.id === this.contesto.you());\n if (you?.role === "spectator" && mic) {\n throw creaErrore("spectator", "Spectators cannot publish voice.");\n }\n if (this.dipendenze === null) {\n throw creaErrore("unsupported", "Voice is not supported in this browser.");\n }\n }\n async entra(generazione) {\n this.verificaIngresso();\n const dipendenze = this.richiediDipendenze();\n const audioContext = dipendenze.creaAudioContext();\n this.audioContext = audioContext;\n if (this.micDesiderato) {\n let stream;\n try {\n stream = await dipendenze.getUserMedia({ audio: true });\n } catch (cause) {\n if (this.permessoNegato(cause)) {\n throw creaErrore("permission_denied", "Microphone permission was denied.");\n }\n throw creaErrore("voice_error", "The microphone could not be opened.");\n }\n try {\n this.controllaGenerazione(generazione);\n } catch (cause) {\n for (const track of stream.getTracks()) track.stop();\n throw cause;\n }\n const mic = stream.getAudioTracks()[0];\n if (mic === void 0) throw creaErrore("voice_error", "The microphone has no audio track.");\n this.stream = stream;\n this.tracciaMic = mic;\n mic.enabled = !this.mutedCorrente;\n this.preparaAnalizzatore(stream);\n }\n try {\n await audioContext.resume();\n } catch {\n }\n this.controllaGenerazione(generazione);\n const risposta = await this.richiedi({ t: "voice", op: "ice" });\n this.controllaGenerazione(generazione);\n if (risposta.op !== "ice") throw creaErrore("voice_error", "The voice service returned an invalid response.");\n this.modeCorrente = risposta.mode;\n if (risposta.mode === "none") throw creaErrore("voice_disabled", "Voice is disabled for this room.");\n this.trasporto = risposta.transport;\n if (risposta.transport === "sfu") {\n await this.entraSfu(risposta.iceServers, generazione);\n } else {\n await this.richiedi({ t: "voice", op: "publish", mic: this.micDesiderato });\n }\n if (this.micDesiderato && this.mutedCorrente) {\n await this.richiedi({ t: "voice", op: "mute", muted: true });\n }\n this.controllaGenerazione(generazione);\n this.tentativoRiconnessione = 0;\n this.aggiornaState("on");\n this.avviaMisuraAudio();\n for (const playerId of this.gains.keys()) this.aggiornaZero(playerId);\n this.accodaRiconciliazione();\n }\n async entraSfu(iceServers, generazione) {\n const pc = this.richiediDipendenze().creaPeerConnection({\n iceServers,\n bundlePolicy: "max-bundle"\n });\n this.peerSfu = pc;\n pc.ontrack = (event) => {\n const mid = event.transceiver.mid;\n const playerId = mid === null ? void 0 : this.midGiocatori.get(mid);\n if (playerId !== void 0) this.collegaTraccia(playerId, event.track, event.receiver);\n };\n this.osservaCaduta(pc);\n let risposta;\n if (this.micDesiderato) {\n const transceiver = pc.addTransceiver(this.richiediMic(), { direction: "sendonly" });\n const offer = await pc.createOffer();\n await pc.setLocalDescription(offer);\n this.controllaGenerazione(generazione);\n const mid = transceiver.mid;\n const sdp = pc.localDescription?.sdp;\n if (mid === null || sdp === void 0) {\n throw creaErrore("voice_error", "The voice connection could not create an offer.");\n }\n risposta = await this.richiedi({ t: "voice", op: "session", sdp, mid });\n } else {\n risposta = await this.richiedi({ t: "voice", op: "session" });\n }\n if (risposta.op !== "session") {\n throw creaErrore("voice_error", "The voice service returned an invalid response.");\n }\n this.sessioneSfu = risposta.session;\n if (this.micDesiderato) {\n if (risposta.sdp === null) {\n throw creaErrore("voice_error", "The voice service returned an invalid response.");\n }\n await pc.setRemoteDescription({ type: "answer", sdp: risposta.sdp });\n await this.attendiConnessione(pc, generazione);\n this.connessioneSfuAttesa = true;\n return;\n }\n if (risposta.sdp !== null) {\n throw creaErrore("voice_error", "The voice service returned an invalid response.");\n }\n if (this.publisherDesiderati().length > 0) {\n await this.riconciliaSfu();\n }\n }\n attendiConnessione(pc, generazione) {\n if (pc.connectionState === "connected") return Promise.resolve();\n const dipendenze = this.richiediDipendenze();\n return new Promise((resolve, reject) => {\n const pulisci = () => {\n pc.removeEventListener("connectionstatechange", cambiata);\n if (this.timerConnessione !== null) dipendenze.clearTimeout(this.timerConnessione);\n this.timerConnessione = null;\n this.cancellaAttesaConnessione = null;\n };\n const cambiata = () => {\n if (generazione !== this.generazione) {\n pulisci();\n reject(creaErrore("offline", "Voice was stopped."));\n } else if (pc.connectionState === "connected") {\n pulisci();\n resolve();\n } else if (pc.connectionState === "failed" || pc.connectionState === "closed") {\n pulisci();\n reject(creaErrore("voice_error", "The voice connection failed."));\n }\n };\n pc.addEventListener("connectionstatechange", cambiata);\n this.cancellaAttesaConnessione = () => {\n pulisci();\n reject(creaErrore("offline", "Voice was stopped."));\n };\n this.timerConnessione = dipendenze.setTimeout(() => {\n pulisci();\n reject(creaErrore("voice_error", "The voice connection timed out."));\n }, TIMEOUT_CONNESSIONE);\n });\n }\n accodaRiconciliazione() {\n if (this.stateCorrente !== "on") return;\n this.negoziazione = this.negoziazione.then(async () => {\n if (this.stateCorrente !== "on") return;\n if (this.trasporto === "sfu") await this.riconciliaSfu();\n else if (this.trasporto === "mesh") this.riconciliaMesh();\n }).catch(() => this.avviaRiconnessione());\n }\n async riconciliaSfu() {\n const sessione = this.sessioneSfu;\n const pc = this.peerSfu;\n if (sessione === null || pc === null) return;\n const desiderati = new Map(this.publisherDesiderati().map((peer) => [peer.id, peer]));\n const daChiudere = [];\n for (const [playerId, attiva] of this.sfuAttive) {\n const peer = desiderati.get(playerId);\n if (peer !== void 0 && peer.session === attiva.session && peer.track === attiva.track) continue;\n daChiudere.push(attiva);\n if (!this.riproduzioni.has(playerId)) attiva.receiver?.track.stop();\n this.sfuAttive.delete(playerId);\n this.midGiocatori.delete(attiva.mid);\n this.scollegaTraccia(playerId);\n }\n if (daChiudere.length > 0) {\n await this.richiedi({\n t: "voice",\n op: "close",\n session: sessione,\n mids: daChiudere.map((item) => item.mid)\n });\n }\n const nuove = [...desiderati.values()].filter((peer) => !this.sfuAttive.has(peer.id));\n if (nuove.length === 0) return;\n let risposta;\n try {\n risposta = await this.richiedi({\n t: "voice",\n op: "subscribe",\n session: sessione,\n tracks: nuove.map((peer) => ({ session: peer.session, track: peer.track }))\n });\n } catch (cause) {\n if (codiceErrore(cause) !== "not_allowed") throw cause;\n for (const peer of nuove) this.negati.add(peer.id);\n return;\n }\n if (risposta.op !== "subscribe") {\n throw creaErrore("voice_error", "The voice service returned an invalid response.");\n }\n for (const risultato of risposta.tracks) {\n const peer = nuove.find(\n (item) => item.session === risultato.session && item.track === risultato.track\n );\n if (risultato.error === "not_allowed" && peer !== void 0) this.negati.add(peer.id);\n if (risultato?.mid === null || risultato?.mid === void 0 || risultato.error !== null || peer === void 0) continue;\n this.midGiocatori.set(risultato.mid, peer.id);\n this.sfuAttive.set(peer.id, {\n session: peer.session,\n track: peer.track,\n mid: risultato.mid,\n receiver: null\n });\n }\n await pc.setRemoteDescription({ type: "offer", sdp: risposta.sdp });\n const answer = await pc.createAnswer();\n await pc.setLocalDescription(answer);\n const sdp = pc.localDescription?.sdp;\n if (sdp === void 0) throw creaErrore("voice_error", "The voice answer is missing.");\n await this.richiedi({ t: "voice", op: "answer", session: sessione, sdp });\n if (!this.connessioneSfuAttesa) {\n await this.attendiConnessione(pc, this.generazione);\n this.connessioneSfuAttesa = true;\n }\n }\n riconciliaMesh() {\n const desiderati = new Map(this.peerDesiderati().map((peer) => [peer.id, peer]));\n for (const [playerId, item] of this.mesh) {\n if (desiderati.has(playerId)) continue;\n item.pc.close();\n this.mesh.delete(playerId);\n this.scollegaTraccia(playerId);\n }\n for (const peer of desiderati.values()) {\n if (!this.mesh.has(peer.id)) this.creaMesh(peer);\n }\n }\n creaMesh(peer) {\n const playerId = peer.id;\n const pc = this.richiediDipendenze().creaPeerConnection();\n const item = {\n pc,\n makingOffer: false,\n ignoreOffer: false,\n settingRemoteAnswer: false,\n polite: this.contesto.you() > playerId,\n receiver: null\n };\n this.mesh.set(playerId, item);\n pc.onicecandidate = (event) => {\n if (event.candidate === null) return;\n void this.inviaSegnale(playerId, { kind: "candidate", candidate: event.candidate.toJSON() });\n };\n if (!item.polite) pc.onnegotiationneeded = () => {\n void this.offriMesh(playerId, item);\n };\n pc.ontrack = (event) => {\n item.receiver = event.receiver;\n this.collegaTraccia(playerId, event.track, event.receiver);\n };\n this.osservaCaduta(pc);\n if (this.micDesiderato) {\n pc.addTransceiver(this.richiediMic(), {\n direction: peer.mic ? "sendrecv" : "sendonly"\n });\n } else {\n pc.addTransceiver("audio", { direction: "recvonly" });\n }\n }\n async offriMesh(playerId, item) {\n try {\n item.makingOffer = true;\n const offer = await item.pc.createOffer();\n await item.pc.setLocalDescription(offer);\n const sdp = item.pc.localDescription?.sdp;\n if (sdp !== void 0) await this.inviaSegnale(playerId, { kind: "offer", sdp });\n } finally {\n item.makingOffer = false;\n }\n }\n async riceviSegnale(from, data) {\n if (this.trasporto !== "mesh" || this.stateCorrente !== "on") return;\n const peer = this.peerDesiderati().find((item2) => item2.id === from);\n if (peer === void 0) return;\n if (!this.mesh.has(from)) this.creaMesh(peer);\n const item = this.mesh.get(from);\n if (item === void 0 || typeof data !== "object" || data === null || Array.isArray(data)) return;\n const segnale = data;\n try {\n if (segnale.kind === "candidate") {\n if (!item.ignoreOffer) await item.pc.addIceCandidate(segnale.candidate);\n return;\n }\n if (segnale.kind !== "offer" && segnale.kind !== "answer" || typeof segnale.sdp !== "string") return;\n const pronta = !item.makingOffer && (item.pc.signalingState === "stable" || item.settingRemoteAnswer);\n const collisione = segnale.kind === "offer" && !pronta;\n item.ignoreOffer = !item.polite && collisione;\n if (item.ignoreOffer) return;\n item.settingRemoteAnswer = segnale.kind === "answer";\n await item.pc.setRemoteDescription({ type: segnale.kind, sdp: segnale.sdp });\n item.settingRemoteAnswer = false;\n if (segnale.kind === "offer") {\n const answer = await item.pc.createAnswer();\n await item.pc.setLocalDescription(answer);\n const sdp = item.pc.localDescription?.sdp;\n if (sdp !== void 0) await this.inviaSegnale(from, { kind: "answer", sdp });\n }\n } catch {\n this.avviaRiconnessione();\n }\n }\n async inviaSegnale(to, data) {\n try {\n await this.richiedi({ t: "voice", op: "signal", to, data });\n } catch (cause) {\n if (codiceErrore(cause) !== "not_allowed") throw cause;\n const item = this.mesh.get(to);\n item?.pc.close();\n this.mesh.delete(to);\n this.scollegaTraccia(to);\n this.negati.add(to);\n }\n }\n peerDesiderati() {\n const you = this.contesto.you();\n const giocatori = this.contesto.giocatori();\n const player = giocatori.find((item) => item.id === you);\n return this.roster.filter((peer) => {\n if (peer.id === you) return false;\n if (this.negati.has(peer.id)) return false;\n if (!this.micDesiderato && !peer.mic) return false;\n if (this.modeCorrente === "team") {\n const altro = giocatori.find((item) => item.id === peer.id);\n if (player?.role !== "spectator" && altro?.team !== player?.team) return false;\n }\n return true;\n });\n }\n publisherDesiderati() {\n return this.peerDesiderati().filter(\n (peer) => {\n if (!peer.mic) return false;\n const zeroAt = this.zeroDa.get(peer.id);\n return zeroAt === void 0 || this.richiediDipendenze().ora() - zeroAt < DURATA_ZERO;\n }\n );\n }\n aggiornaZero(playerId) {\n const dipendenze = this.dipendenze;\n if (dipendenze === null) return;\n const precedente = this.timerZero.get(playerId);\n if (precedente !== void 0) dipendenze.clearTimeout(precedente);\n this.timerZero.delete(playerId);\n if ((this.gains.get(playerId) ?? 1) > 0) {\n this.zeroDa.delete(playerId);\n return;\n }\n if (!this.zeroDa.has(playerId)) this.zeroDa.set(playerId, dipendenze.ora());\n const trascorso = dipendenze.ora() - (this.zeroDa.get(playerId) ?? dipendenze.ora());\n const timer = dipendenze.setTimeout(() => {\n this.timerZero.delete(playerId);\n this.accodaRiconciliazione();\n }, Math.max(0, DURATA_ZERO - trascorso));\n this.timerZero.set(playerId, timer);\n }\n collegaTraccia(playerId, track, receiver) {\n this.scollegaTraccia(playerId);\n const dipendenze = this.richiediDipendenze();\n const media = dipendenze.creaMediaStream([track]);\n const source = this.richiediAudioContext().createMediaStreamSource(media);\n const gain = this.richiediAudioContext().createGain();\n source.connect(gain);\n gain.connect(this.richiediAudioContext().destination);\n let analyser = null;\n try {\n analyser = this.richiediAudioContext().createAnalyser();\n analyser.fftSize = 256;\n source.connect(analyser);\n } catch {\n analyser = null;\n }\n const audio = dipendenze.creaAudioElement();\n audio.srcObject = media;\n audio.muted = true;\n audio.playsInline = true;\n void audio.play().catch(() => void 0);\n this.riproduzioni.set(playerId, { source, gain, analyser, audio, track, receiver });\n const attiva = this.sfuAttive.get(playerId);\n if (attiva !== void 0) attiva.receiver = receiver;\n this.aggiornaGuadagno(playerId);\n }\n scollegaTraccia(playerId) {\n const riproduzione = this.riproduzioni.get(playerId);\n if (riproduzione === void 0) return;\n riproduzione.source.disconnect();\n riproduzione.gain.disconnect();\n riproduzione.analyser?.disconnect();\n riproduzione.track.stop();\n riproduzione.audio.pause();\n riproduzione.audio.srcObject = null;\n this.riproduzioni.delete(playerId);\n this.speakingPeers.delete(playerId);\n this.ultimoAudio.delete(playerId);\n }\n aggiornaGuadagno(playerId) {\n const riproduzione = this.riproduzioni.get(playerId);\n if (riproduzione !== void 0) {\n riproduzione.gain.gain.value = (this.volumi.get(playerId) ?? 1) * (this.gains.get(playerId) ?? 1);\n }\n }\n preparaAnalizzatore(stream) {\n const context = this.richiediAudioContext();\n const analyser = context.createAnalyser();\n analyser.fftSize = 256;\n context.createMediaStreamSource(stream).connect(analyser);\n this.analyser = analyser;\n }\n avviaMisuraAudio() {\n const dipendenze = this.richiediDipendenze();\n if (this.intervalloAudio !== null) dipendenze.clearInterval(this.intervalloAudio);\n this.intervalloAudio = dipendenze.setInterval(() => this.misuraAudio(), INTERVALLO_AUDIO);\n }\n misuraAudio() {\n const dipendenze = this.dipendenze;\n if (dipendenze === null) return;\n let sopraSoglia = false;\n if (this.analyser !== null) sopraSoglia = this.livelloAnalizzatore(this.analyser) > SOGLIA_AUDIO;\n if (sopraSoglia) this.ultimoAudioMic = dipendenze.ora();\n const parlando = !this.mutedCorrente && dipendenze.ora() - this.ultimoAudioMic <= DURATA_PARLANTE;\n if (parlando !== this.speakingCorrente) {\n this.speakingCorrente = parlando;\n this.notificaPeers();\n }\n let cambiato = false;\n for (const peer of this.copiaPeers()) {\n const riproduzione = this.riproduzioni.get(peer.id);\n if (this.livelloAnalizzatore(riproduzione?.analyser ?? null) > SOGLIA_AUDIO) {\n this.ultimoAudio.set(peer.id, dipendenze.ora());\n } else if (riproduzione?.analyser === null || riproduzione?.analyser === void 0) {\n const sources = riproduzione?.receiver?.getSynchronizationSources?.() ?? [];\n if (sources.some((source) => (source.audioLevel ?? 0) > SOGLIA_AUDIO)) {\n this.ultimoAudio.set(peer.id, dipendenze.ora());\n }\n }\n const speaking = !peer.muted && dipendenze.ora() - (this.ultimoAudio.get(peer.id) ?? 0) <= DURATA_PARLANTE;\n if ((this.speakingPeers.get(peer.id) ?? false) !== speaking) {\n this.speakingPeers.set(peer.id, speaking);\n cambiato = true;\n }\n }\n if (cambiato) this.notificaPeers();\n }\n livelloAnalizzatore(analyser) {\n const nodo = analyser;\n if (nodo?.getFloatTimeDomainData === void 0) return 0;\n const campioni = new Float32Array(nodo.fftSize);\n nodo.getFloatTimeDomainData(campioni);\n return Math.sqrt(campioni.reduce((somma, valore) => somma + valore * valore, 0) / Math.max(1, campioni.length));\n }\n copiaPeers() {\n const you = this.contesto.you();\n const giocatori = this.contesto.giocatori();\n const player = giocatori.find((item) => item.id === you);\n return this.roster.flatMap((peer) => {\n if (peer.id === you) return [];\n if (this.modeCorrente === "team") {\n const altro = giocatori.find((item) => item.id === peer.id);\n if (player?.role !== "spectator" && altro?.team !== player?.team) return [];\n }\n return [{\n id: peer.id,\n mic: peer.mic,\n muted: peer.muted,\n speaking: peer.mic && !peer.muted && (this.speakingPeers.get(peer.id) ?? false),\n volume: this.volumi.get(peer.id) ?? 1,\n gain: this.gains.get(peer.id) ?? 1\n }];\n });\n }\n pulisciPeerAssenti() {\n const presenti = new Set(this.roster.map((peer) => peer.id));\n for (const playerId of this.speakingPeers.keys()) {\n if (!presenti.has(playerId)) this.speakingPeers.delete(playerId);\n }\n for (const playerId of this.zeroDa.keys()) {\n if (presenti.has(playerId)) continue;\n this.zeroDa.delete(playerId);\n const timer = this.timerZero.get(playerId);\n if (timer !== void 0) this.dipendenze?.clearTimeout(timer);\n this.timerZero.delete(playerId);\n }\n }\n osservaCaduta(pc) {\n pc.addEventListener("connectionstatechange", () => {\n if (this.stateCorrente === "on" && (pc.connectionState === "failed" || pc.connectionState === "disconnected")) this.avviaRiconnessione();\n });\n }\n avviaRiconnessione() {\n if (!this.desiderata || this.stateCorrente === "reconnecting") return;\n this.generazione++;\n this.rifiutaRichieste(creaErrore("voice_error", "The voice connection was restarted."));\n this.chiudiRisorse();\n this.tentativoRiconnessione = 0;\n this.aggiornaState("reconnecting");\n this.programmaRiconnessione();\n }\n programmaRiconnessione() {\n if (!this.desiderata || !this.contesto.connessa() || this.timerRiconnessione !== null || this.stateCorrente !== "reconnecting") return;\n const ritardo = RITARDI_RICONNESSIONE[this.tentativoRiconnessione];\n if (ritardo === void 0) {\n this.desiderata = false;\n this.aggiornaState("off");\n return;\n }\n this.tentativoRiconnessione++;\n this.timerRiconnessione = this.richiediDipendenze().setTimeout(() => {\n this.timerRiconnessione = null;\n const generazione = ++this.generazione;\n void this.entra(generazione).catch(() => {\n if (generazione !== this.generazione || !this.desiderata) return;\n this.chiudiRisorse();\n this.aggiornaState("reconnecting");\n this.programmaRiconnessione();\n });\n }, ritardo);\n }\n fermaRiconnessione() {\n if (this.timerRiconnessione === null || this.dipendenze === null) return;\n this.dipendenze.clearTimeout(this.timerRiconnessione);\n this.timerRiconnessione = null;\n }\n chiudiRisorse() {\n const dipendenze = this.dipendenze;\n this.cancellaAttesaConnessione?.();\n this.cancellaAttesaConnessione = null;\n if (dipendenze !== null) {\n if (this.intervalloAudio !== null) dipendenze.clearInterval(this.intervalloAudio);\n if (this.timerConnessione !== null) dipendenze.clearTimeout(this.timerConnessione);\n for (const timer of this.timerZero.values()) dipendenze.clearTimeout(timer);\n }\n this.intervalloAudio = null;\n this.timerConnessione = null;\n this.timerZero.clear();\n for (const playerId of [...this.riproduzioni.keys()]) this.scollegaTraccia(playerId);\n this.peerSfu?.close();\n this.peerSfu = null;\n for (const item of this.mesh.values()) item.pc.close();\n this.mesh.clear();\n this.sfuAttive.clear();\n this.midGiocatori.clear();\n this.negati.clear();\n for (const track of this.stream?.getTracks() ?? []) track.stop();\n this.stream = null;\n this.tracciaMic = null;\n this.analyser = null;\n void this.audioContext?.close().catch(() => void 0);\n this.audioContext = null;\n this.sessioneSfu = null;\n this.connessioneSfuAttesa = false;\n this.trasporto = null;\n this.speakingCorrente = false;\n this.ultimoAudioMic = Number.NEGATIVE_INFINITY;\n this.speakingPeers.clear();\n this.ultimoAudio.clear();\n this.negoziazione = Promise.resolve();\n }\n richiedi(message) {\n if (!this.contesto.connessa()) return Promise.reject(creaErrore("offline", "The room is reconnecting."));\n const r = ++this.sequenzaRichieste;\n return new Promise((resolve, reject) => {\n this.richieste.set(r, { resolve, reject });\n try {\n this.contesto.invia({ ...message, r });\n } catch (cause) {\n this.richieste.delete(r);\n reject(cause);\n }\n });\n }\n rifiutaRichieste(reason) {\n for (const richiesta of this.richieste.values()) richiesta.reject(reason);\n this.richieste.clear();\n }\n aggiornaState(state) {\n if (state === this.stateCorrente) return;\n this.stateCorrente = state;\n for (const listener of this.ascoltatoriState) {\n try {\n listener(state);\n } catch {\n }\n }\n }\n notificaPeers() {\n const peers = this.copiaPeers();\n for (const listener of this.ascoltatoriPeers) {\n try {\n listener(peers);\n } catch {\n }\n }\n }\n controllaGenerazione(generazione) {\n if (generazione !== this.generazione || !this.desiderata) {\n throw creaErrore("offline", "Voice was stopped.");\n }\n }\n richiediDipendenze() {\n if (this.dipendenze === null) throw creaErrore("unsupported", "Voice is not supported.");\n return this.dipendenze;\n }\n richiediMic() {\n if (this.tracciaMic === null) throw creaErrore("voice_error", "The microphone is not ready.");\n return this.tracciaMic;\n }\n richiediAudioContext() {\n if (this.audioContext === null) throw creaErrore("voice_error", "Audio is not ready.");\n return this.audioContext;\n }\n permessoNegato(cause) {\n return typeof cause === "object" && cause !== null && "name" in cause && (cause.name === "NotAllowedError" || cause.name === "SecurityError");\n }\n mappaErrore(cause) {\n if (typeof cause === "object" && cause !== null && "code" in cause) {\n const code = cause.code;\n if (code === "voice_disabled" || code === "permission_denied" || code === "unsupported" || code === "spectator" || code === "offline" || code === "voice_error") return cause;\n return creaErrore("voice_error", "Voice could not be started.");\n }\n return creaErrore("voice_error", "Voice could not be started.");\n }\n};\n\n// src/stanza-client/index.ts\nvar APERTO = 1;\nvar RITARDI_RICONNESSIONE2 = [1e3, 2e3, 4e3, 8e3];\nvar GRAZIA_RICONNESSIONE = 6e4;\nvar INTERVALLO_PING = 5e3;\nvar RITARDO_FLUSH = 500;\nvar ATTESA_ROSTER = 2e3;\nvar CHIUSURE_DEFINITIVE = /* @__PURE__ */ new Set([4003, 4004, 4005, 4006]);\nvar CHIUSURE_DEFINITIVE_SPETTATORE = /* @__PURE__ */ new Set([4008, 4009]);\nfunction record3(value) {\n return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;\n}\nfunction ingressoValido(value) {\n const dati = record3(value);\n return dati !== null && typeof dati.roomId === "string" && typeof dati.code === "string" && typeof dati.join === "string" && typeof dati.url === "string";\n}\nfunction visioneValida(value) {\n const dati = record3(value);\n return dati !== null && typeof dati.roomId === "string" && typeof dati.code === "string" && typeof dati.watch === "string" && typeof dati.url === "string";\n}\nfunction rispostaMatchValida(value) {\n const dati = record3(value);\n const players = record3(dati?.players);\n return dati !== null && typeof dati.url === "string" && Number.isInteger(dati.timeoutMs) && dati.timeoutMs >= 1e3 && dati.timeoutMs <= 3e5 && players !== null && Number.isInteger(players.min) && Number.isInteger(players.max) && players.min >= 1 && players.max >= players.min;\n}\nfunction copiaJson(value) {\n return JSON.parse(JSON.stringify(value));\n}\nfunction applicaPatch(state, value) {\n let risultato = copiaJson(state);\n for (const operazione of value) {\n if (operazione.path.length === 0) {\n if (operazione.op !== "set") return { ok: false };\n risultato = copiaJson(operazione.value);\n continue;\n }\n let contenitore = risultato;\n const percorso = operazione.path;\n for (let indice = 0; indice < percorso.length - 1; indice++) {\n const parte = percorso[indice];\n if (Array.isArray(contenitore)) {\n if (typeof parte !== "number" || parte >= contenitore.length) return { ok: false };\n contenitore = contenitore[parte];\n } else {\n const oggetto2 = record3(contenitore);\n if (oggetto2 === null || typeof parte !== "string" || !Object.hasOwn(oggetto2, parte)) {\n return { ok: false };\n }\n contenitore = oggetto2[parte];\n }\n }\n const ultima = percorso.at(-1);\n if (Array.isArray(contenitore)) {\n if (operazione.op !== "set" || typeof ultima !== "number" || ultima >= contenitore.length) return { ok: false };\n contenitore[ultima] = copiaJson(operazione.value);\n } else {\n const oggetto2 = record3(contenitore);\n if (oggetto2 === null || typeof ultima !== "string") return { ok: false };\n if (operazione.op === "del") {\n if (!Object.hasOwn(oggetto2, ultima)) return { ok: false };\n delete oggetto2[ultima];\n } else {\n Object.defineProperty(oggetto2, ultima, {\n configurable: true,\n enumerable: true,\n value: copiaJson(operazione.value),\n writable: true\n });\n }\n }\n }\n return { ok: true, state: risultato };\n}\nfunction creaApiLive(input) {\n const richiesta = creaRichiedente(input.liveOrigin, "", input.fetcher, input.biglietto);\n async function ingresso(path, body, rinnova = false) {\n const value = await richiesta(path, "POST", body, rinnova);\n if (!ingressoValido(value)) {\n throw creaErrore("internal_error", "The room service returned an invalid response.");\n }\n return value;\n }\n async function match(options) {\n const value = await richiesta("/match", "POST", {\n mode: options.mode,\n key: options.key\n });\n if (!rispostaMatchValida(value)) {\n throw creaErrore("internal_error", "The matchmaking service returned an invalid response.");\n }\n return value;\n }\n async function visione(body, rinnova = false) {\n const value = await richiesta("/rooms/watch", "POST", body, rinnova);\n if (!visioneValida(value)) {\n throw creaErrore("internal_error", "The room service returned an invalid response.");\n }\n return value;\n }\n return {\n create: (mode) => ingresso("/rooms", { mode }),\n joinCode: (code) => ingresso("/rooms/join", { code }),\n joinRoom: (roomId) => ingresso("/rooms/join", { roomId }, true),\n watchCode: (code) => visione({ code }),\n watchRoom: (roomId) => visione({ roomId }, true),\n match,\n flush: (roomId) => richiesta(\n `/rooms/${encodeURIComponent(roomId)}/flush`,\n "POST"\n )\n };\n}\nvar StanzaClient = class {\n constructor(roomId, codice, url, input, api, segnalaStanza, spettatore = false) {\n this.roomId = roomId;\n this.codice = codice;\n this.input = input;\n this.api = api;\n this.segnalaStanza = segnalaStanza;\n this.spettatore = spettatore;\n this.meta = { host: null, mode: null, countdownAt: null, configuration: null, connection: "connecting", closedCode: null };\n this.metaListeners = /* @__PURE__ */ new Set();\n this.connectionListeners = /* @__PURE__ */ new Set();\n this.scoreListeners = /* @__PURE__ */ new Set();\n this.scores = [];\n this.errorListeners = /* @__PURE__ */ new Set();\n this.roleId = 0;\n this.roleRequests = /* @__PURE__ */ new Map();\n this.statoPubblico = null;\n this.statoSincronizzato = null;\n this.tickCorrente = 0;\n this.seedCorrente = 0;\n this.statusCorrente = "lobby";\n this.giocatoriCorrenti = [];\n this.youCorrente = "";\n this.hostCorrente = null;\n this.resultCorrente = null;\n this.delaySpettatore = 0;\n this.socket = null;\n this.seq = 0;\n this.scartoOrario = 0;\n this.timerPing = null;\n this.timerRiconnessione = null;\n this.timerFlush = null;\n this.flushInCorso = false;\n this.flushRichiesto = false;\n this.ritardoIndice = 0;\n this.tempoRiconnessione = 0;\n this.resyncRichiesto = false;\n this.terminata = false;\n this.lasciata = false;\n this.prontaRisolta = false;\n this.welcomeRicevuto = false;\n this.rosterRicevuto = false;\n this.timerRoster = null;\n this.risolviPronta = () => void 0;\n this.rifiutaPronta = () => void 0;\n this.ascoltatoriStato = /* @__PURE__ */ new Set();\n this.ascoltatoriGiocatori = /* @__PURE__ */ new Set();\n this.ascoltatoriStatus = /* @__PURE__ */ new Set();\n this.ascoltatoriMessaggi = /* @__PURE__ */ new Set();\n this.promessaPronta = new Promise((resolve, reject) => {\n this.risolviPronta = resolve;\n this.rifiutaPronta = reject;\n });\n this.voice = new VoceClient({\n invia: (message) => this.invia(message),\n connessa: () => this.socket?.readyState === APERTO && this.welcomeRicevuto && !this.terminata && !this.lasciata,\n you: () => this.youCorrente,\n giocatori: () => this.copiaGiocatori(),\n rosterPronto: () => {\n this.rosterRicevuto = true;\n this.risolviProntaSePossibile();\n }\n }, input, input.voce);\n if (spettatore) this.rosterRicevuto = true;\n this.apri(url);\n }\n get mode() {\n return this.meta.mode;\n }\n get countdownAt() {\n return this.meta.countdownAt;\n }\n get connection() {\n return this.meta.connection;\n }\n get metadata() {\n return structuredClone(this.meta);\n }\n get queuedScores() {\n return structuredClone(this.scores);\n }\n onMetadata(listener) {\n this.metaListeners.add(listener);\n return () => this.metaListeners.delete(listener);\n }\n onConnection(listener) {\n this.connectionListeners.add(listener);\n return () => this.connectionListeners.delete(listener);\n }\n onError(listener) {\n this.errorListeners.add(listener);\n return () => this.errorListeners.delete(listener);\n }\n onScoreQueued(listener) {\n this.scoreListeners.add(listener);\n return () => this.scoreListeners.delete(listener);\n }\n metadataChanged(change) {\n const old = this.meta.connection;\n this.meta = { ...this.meta, ...change };\n this.notifica(this.metaListeners, this.metadata);\n if (old !== this.meta.connection) this.notifica(this.connectionListeners, this.meta.connection);\n }\n initialMetadata(room) {\n this.metadataChanged({\n host: room.host,\n mode: room.mode,\n countdownAt: room.countdownAt ?? null,\n configuration: room.configuration ?? null,\n connection: "connected",\n closedCode: null\n });\n }\n requestRole(role) {\n if (typeof role !== "string" || role.length < 1 || role.length > 32) return Promise.reject(creaErrore("invalid_role", "The role is not valid."));\n if (this.connection !== "connected" || this.status !== "playing" || !this.meta.configuration?.requestRole) {\n return Promise.reject(creaErrore("role_change_unavailable", "Roles cannot be requested right now."));\n }\n if (this.roleRequests.size >= 8) return Promise.reject(creaErrore("rate_limited", "Too many role requests."));\n const r = ++this.roleId;\n return new Promise((resolve, reject) => {\n const timer = this.input.setTimeout(() => {\n this.roleRequests.delete(r);\n reject(creaErrore("timeout", "The role request timed out."));\n }, 5e3);\n this.roleRequests.set(r, { resolve, reject, timer });\n try {\n this.invia({ t: "request-role", r, role });\n } catch (error) {\n this.input.clearTimeout(timer);\n this.roleRequests.delete(r);\n reject(error);\n }\n });\n }\n clearRoleRequests() {\n for (const request of this.roleRequests.values()) {\n this.input.clearTimeout(request.timer);\n request.reject(creaErrore("offline", "The room connection ended."));\n }\n this.roleRequests.clear();\n }\n disconnect() {\n if (this.lasciata) return;\n this.lasciata = true;\n const socket = this.socket;\n this.socket = null;\n this.voice.termina();\n this.fermaPing();\n this.fermaRiconnessione();\n this.clearRoleRequests();\n if (this.timerRoster !== null) this.input.clearTimeout(this.timerRoster);\n socket?.close(1e3);\n this.segnalaStanza(null);\n this.metadataChanged({ connection: "disconnected", closedCode: null });\n if (!this.prontaRisolta) {\n this.prontaRisolta = true;\n this.rifiutaPronta(creaErrore("cancelled", "The room was disconnected."));\n }\n }\n get state() {\n return this.statoPubblico;\n }\n get tick() {\n return this.tickCorrente;\n }\n get seed() {\n return this.seedCorrente;\n }\n get status() {\n return this.statusCorrente;\n }\n get players() {\n return this.copiaGiocatori();\n }\n get you() {\n return this.youCorrente;\n }\n get host() {\n return this.hostCorrente;\n }\n get code() {\n return this.codice;\n }\n get result() {\n return this.resultCorrente;\n }\n get delayMs() {\n return this.delaySpettatore;\n }\n pronta() {\n return this.promessaPronta;\n }\n invite() {\n return { code: this.codice, url: new URL(`/r/${this.codice}`, this.input.appOrigin).href };\n }\n onState(listener) {\n this.ascoltatoriStato.add(listener);\n return () => {\n this.ascoltatoriStato.delete(listener);\n };\n }\n onPlayers(listener) {\n this.ascoltatoriGiocatori.add(listener);\n return () => {\n this.ascoltatoriGiocatori.delete(listener);\n };\n }\n onStatus(listener) {\n this.ascoltatoriStatus.add(listener);\n return () => {\n this.ascoltatoriStatus.delete(listener);\n };\n }\n onMessage(listener) {\n this.ascoltatoriMessaggi.add(listener);\n return () => {\n this.ascoltatoriMessaggi.delete(listener);\n };\n }\n send(message) {\n const prossimo = this.seq + 1;\n this.invia({ t: "msg", seq: prossimo, m: message });\n this.seq = prossimo;\n }\n ready(ready) {\n this.invia({ t: "ready", ready });\n }\n setRole(role) {\n this.invia({ t: "role", role });\n }\n setTeam(team) {\n this.invia({ t: "team", team });\n }\n start() {\n this.invia({ t: "start" });\n }\n leave() {\n if (this.lasciata) return;\n if (!this.spettatore) this.voice.leave();\n this.lasciata = true;\n this.segnalaStanza(null);\n if (this.socket?.readyState === APERTO) {\n const socket = this.socket;\n this.invia({ t: "leave" });\n if (this.spettatore) socket.close(1e3);\n }\n this.termina(1e3);\n }\n serverTime() {\n return this.input.ora() + this.scartoOrario;\n }\n copiaGiocatori() {\n return this.giocatoriCorrenti.map((player) => ({ ...player }));\n }\n notifica(listeners, ...args) {\n for (const listener of listeners) {\n try {\n listener(...args);\n } catch {\n }\n }\n }\n invia(message) {\n if (this.socket?.readyState !== APERTO) {\n throw creaErrore("offline", "The room is reconnecting.");\n }\n let frame;\n try {\n frame = JSON.stringify(message);\n } catch {\n throw creaErrore("invalid_request", "Room messages must be valid JSON.");\n }\n this.socket.send(frame);\n }\n apri(url) {\n let socket;\n try {\n socket = this.input.apriSocket(url);\n } catch {\n this.programmaRiconnessione();\n return;\n }\n this.socket = socket;\n socket.addEventListener("open", () => {\n if (this.socket === socket) this.avviaPing();\n });\n socket.addEventListener("message", (evento) => {\n if (this.socket === socket && typeof evento.data === "string") this.ricevi(evento.data);\n });\n socket.addEventListener("close", (evento) => {\n if (this.socket === socket) this.chiuso(evento.code);\n });\n }\n avviaPing() {\n if (this.timerPing !== null) this.input.clearInterval(this.timerPing);\n this.timerPing = this.input.setInterval(() => {\n if (this.socket?.readyState !== APERTO) return;\n try {\n this.invia({ t: "ping", c: this.input.ora() });\n } catch {\n }\n }, INTERVALLO_PING);\n }\n fermaPing() {\n if (this.timerPing === null) return;\n this.input.clearInterval(this.timerPing);\n this.timerPing = null;\n }\n ricevi(frame) {\n let dati;\n try {\n const value = JSON.parse(frame);\n const oggetto2 = record3(value);\n if (oggetto2 === null || typeof oggetto2.t !== "string") return;\n dati = oggetto2;\n } catch {\n return;\n }\n try {\n if (dati.t === "watching") this.riceviWatching(dati);\n else if (dati.t === "welcome") this.riceviWelcome(dati);\n else if (dati.t === "players") this.riceviGiocatori(dati.players, dati.host);\n else if (dati.t === "status") this.riceviStatus(dati);\n else if (dati.t === "state") this.riceviDiff(dati);\n else if (dati.t === "snapshot") this.riceviSnapshot(dati);\n else if (dati.t === "msg") this.notifica(this.ascoltatoriMessaggi, copiaJson(dati.m));\n else if (dati.t === "pong") this.riceviPong(dati);\n else if (dati.t === "error") this.notifica(this.errorListeners, { code: dati.code, message: dati.message });\n else if (dati.t === "flush") this.richiediFlush();\n else if (dati.t === "score-queued" && !this.spettatore) {\n this.scores.push(structuredClone(dati.score));\n this.scores = this.scores.slice(-32);\n this.notifica(this.scoreListeners, structuredClone(dati.score));\n } else if (dati.t === "role-result") {\n const request = this.roleRequests.get(dati.r);\n if (request) {\n this.input.clearTimeout(request.timer);\n this.roleRequests.delete(dati.r);\n if (dati.ok) request.resolve();\n else request.reject(creaErrore(dati.code ?? "role_change_refused", "The role change was not accepted."));\n }\n } else if (dati.t === "voice") this.voice.ricevi(dati);\n } catch {\n if (dati.t === "state" || dati.t === "snapshot") this.chiediResync();\n }\n }\n riceviWatching(dati) {\n const room = dati.room;\n if (!this.spettatore || room.id !== this.roomId) return;\n this.seedCorrente = room.seed;\n this.hostCorrente = room.host;\n this.statusCorrente = room.status;\n this.giocatoriCorrenti = dati.players.map((player) => ({ ...player }));\n this.delaySpettatore = dati.delayMs;\n this.aggiornaStato(dati.state, room.tick, room.serverTime);\n this.scartoOrario = room.serverTime - this.input.ora();\n this.resyncRichiesto = false;\n this.welcomeRicevuto = true;\n this.ritardoIndice = 0;\n this.tempoRiconnessione = 0;\n this.notifica(this.ascoltatoriGiocatori, this.copiaGiocatori());\n this.notifica(\n this.ascoltatoriStatus,\n this.statusCorrente,\n this.resultCorrente,\n room.serverTime\n );\n this.initialMetadata(room);\n this.risolviProntaSePossibile();\n }\n riceviWelcome(dati) {\n const room = dati.room;\n if (room.id !== this.roomId) return;\n this.youCorrente = dati.you;\n this.seedCorrente = room.seed;\n this.hostCorrente = room.host;\n this.statusCorrente = room.status;\n this.giocatoriCorrenti = dati.players.map((player) => ({ ...player }));\n this.aggiornaStato(dati.state, room.tick, room.serverTime);\n this.scartoOrario = room.serverTime - this.input.ora();\n this.resyncRichiesto = false;\n this.welcomeRicevuto = true;\n if (!this.rosterRicevuto && this.timerRoster === null) {\n this.timerRoster = this.input.setTimeout(() => {\n this.timerRoster = null;\n this.rosterRicevuto = true;\n this.risolviProntaSePossibile();\n }, ATTESA_ROSTER);\n }\n this.ritardoIndice = 0;\n this.tempoRiconnessione = 0;\n this.notifica(this.ascoltatoriGiocatori, this.copiaGiocatori());\n this.voice.giocatoriCambiati();\n this.voice.socketRiconnesso();\n this.notifica(\n this.ascoltatoriStatus,\n this.statusCorrente,\n this.resultCorrente,\n room.serverTime\n );\n this.initialMetadata(room);\n this.risolviProntaSePossibile();\n }\n riceviGiocatori(value, host) {\n this.giocatoriCorrenti = value.map((player) => ({ ...player }));\n if (host !== void 0) this.hostCorrente = host;\n else if (!this.giocatoriCorrenti.some(\n (player) => player.id === this.hostCorrente && player.connected\n )) {\n this.hostCorrente = this.giocatoriCorrenti.find((player) => player.connected)?.id ?? null;\n }\n this.metadataChanged({ host: this.hostCorrente });\n this.notifica(this.ascoltatoriGiocatori, this.copiaGiocatori());\n this.voice.giocatoriCambiati();\n }\n riceviStatus(dati) {\n this.statusCorrente = dati.status;\n if (dati.host !== void 0) this.hostCorrente = dati.host;\n this.resultCorrente = copiaJson(dati.result);\n if (dati.status === "ended") {\n this.terminata = true;\n this.clearRoleRequests();\n this.segnalaStanza(null);\n if (!this.spettatore) this.voice.termina();\n this.fermaPing();\n this.fermaRiconnessione();\n }\n this.metadataChanged({\n host: this.hostCorrente,\n countdownAt: dati.countdownAt ?? (dati.status === "countdown" ? dati.at : null),\n ...dati.status === "ended" ? { connection: "ended", closedCode: 4004 } : {}\n });\n this.notifica(this.ascoltatoriStatus, this.statusCorrente, this.resultCorrente, dati.at);\n }\n riceviDiff(dati) {\n if (dati.base !== this.tickCorrente) {\n this.chiediResync();\n return;\n }\n const risultato = applicaPatch(this.statoSincronizzato, dati.patch);\n if (!risultato.ok) {\n this.chiediResync();\n return;\n }\n this.resyncRichiesto = false;\n this.aggiornaStato(risultato.state, dati.tick, dati.serverTime);\n }\n riceviSnapshot(dati) {\n if (dati.tick < this.tickCorrente) return;\n this.resyncRichiesto = false;\n this.aggiornaStato(dati.state, dati.tick, dati.serverTime);\n }\n aggiornaStato(state, tick, serverTime) {\n this.statoSincronizzato = copiaJson(state);\n this.statoPubblico = copiaJson(state);\n this.tickCorrente = tick;\n this.notifica(this.ascoltatoriStato, this.statoPubblico, tick, serverTime);\n }\n chiediResync() {\n if (this.resyncRichiesto || this.socket?.readyState !== APERTO) return;\n this.resyncRichiesto = true;\n try {\n this.invia({ t: "resync" });\n } catch {\n this.resyncRichiesto = false;\n }\n }\n riceviPong(dati) {\n this.scartoOrario = dati.s - (dati.c + this.input.ora()) / 2;\n }\n chiuso(code) {\n this.socket = null;\n this.welcomeRicevuto = false;\n this.fermaPing();\n if (this.lasciata || this.terminata) return;\n if (CHIUSURE_DEFINITIVE.has(code) || this.spettatore && CHIUSURE_DEFINITIVE_SPETTATORE.has(code)) {\n this.termina(code);\n return;\n }\n this.clearRoleRequests();\n if (!this.spettatore) this.voice.socketDisconnesso();\n this.programmaRiconnessione();\n }\n programmaRiconnessione() {\n if (this.terminata || this.lasciata || this.timerRiconnessione !== null) return;\n this.metadataChanged({ connection: "reconnecting" });\n const indice = Math.min(this.ritardoIndice, RITARDI_RICONNESSIONE2.length - 1);\n const ritardo = RITARDI_RICONNESSIONE2[indice];\n if (this.tempoRiconnessione + ritardo > GRAZIA_RICONNESSIONE) {\n this.termina("timeout");\n return;\n }\n this.ritardoIndice++;\n this.tempoRiconnessione += ritardo;\n this.timerRiconnessione = this.input.setTimeout(() => {\n this.timerRiconnessione = null;\n void this.riconnetti();\n }, ritardo);\n }\n async riconnetti() {\n if (this.terminata || this.lasciata) return;\n try {\n const ingresso = this.spettatore ? await this.api.watchRoom(this.roomId) : await this.api.joinRoom(this.roomId);\n if (this.terminata || this.lasciata) return;\n const codiceCambiato = this.codice !== ingresso.code;\n this.codice = ingresso.code;\n if (codiceCambiato && this.prontaRisolta && !this.terminata && !this.lasciata) {\n this.segnalaStanza({ code: this.codice });\n }\n this.apri(ingresso.url);\n } catch {\n this.programmaRiconnessione();\n }\n }\n fermaRiconnessione() {\n if (this.timerRiconnessione === null) return;\n this.input.clearTimeout(this.timerRiconnessione);\n this.timerRiconnessione = null;\n }\n termina(code) {\n this.clearRoleRequests();\n this.metadataChanged({ connection: code === 1e3 ? "disconnected" : code === 4006 ? "replaced" : "closed", closedCode: typeof code === "number" ? code : null });\n const risultato = { closed: code };\n const cambiato = this.statusCorrente !== "ended" || JSON.stringify(this.resultCorrente) !== JSON.stringify(risultato);\n this.terminata = true;\n this.segnalaStanza(null);\n this.statusCorrente = "ended";\n this.resultCorrente = risultato;\n if (!this.spettatore) this.voice.termina();\n this.fermaPing();\n this.fermaRiconnessione();\n if (cambiato) this.notifica(this.ascoltatoriStatus, "ended", risultato, this.serverTime());\n if (!this.prontaRisolta) {\n this.prontaRisolta = true;\n const codici = {\n 4003: "kicked",\n 4004: "room_ended",\n 4005: "version_closed",\n 4006: "replaced",\n 4008: "rate_limited",\n 4009: "invalid_request"\n };\n const erroreCode = typeof code === "number" ? codici[code] ?? "offline" : "offline";\n this.rifiutaPronta(creaErrore(erroreCode, "The room connection ended."));\n }\n }\n risolviProntaSePossibile() {\n if (this.prontaRisolta || !this.welcomeRicevuto || !this.rosterRicevuto) return;\n if (this.timerRoster !== null) {\n this.input.clearTimeout(this.timerRoster);\n this.timerRoster = null;\n }\n this.prontaRisolta = true;\n if (!this.spettatore && !this.terminata && !this.lasciata) {\n this.segnalaStanza({ code: this.codice });\n }\n this.risolviPronta();\n }\n richiediFlush() {\n this.flushRichiesto = true;\n if (this.flushInCorso || this.timerFlush !== null) return;\n this.timerFlush = this.input.setTimeout(() => {\n this.timerFlush = null;\n void this.eseguiFlush();\n }, RITARDO_FLUSH);\n }\n async eseguiFlush() {\n if (this.flushInCorso || !this.flushRichiesto) return;\n this.flushInCorso = true;\n this.flushRichiesto = false;\n try {\n await this.api.flush(this.roomId);\n } catch {\n } finally {\n this.flushInCorso = false;\n if (this.flushRichiesto) this.richiediFlush();\n }\n }\n};\nfunction creaStanzeOffline(invited = null) {\n return {\n invited,\n async create() {\n throw erroreOffline();\n },\n async join() {\n throw erroreOffline();\n },\n async watch() {\n throw erroreOffline();\n },\n async match() {\n throw erroreOffline();\n }\n };\n}\nfunction creaGestoreStanze(input, invited) {\n const api = creaApiLive(input);\n let haSegnalato = false;\n let ultimoCodice = null;\n const segnalaStanza = (room) => {\n const codice = room?.code ?? null;\n if (haSegnalato && codice === ultimoCodice) return;\n haSegnalato = true;\n ultimoCodice = codice;\n input.segnalaStanza?.(room);\n };\n const collega = async (ingresso) => {\n const stanza = new StanzaClient(\n ingresso.roomId,\n ingresso.code,\n ingresso.url,\n input,\n api,\n segnalaStanza\n );\n await stanza.pronta();\n return stanza;\n };\n const guarda = async (ingresso) => {\n const stanza = new StanzaClient(\n ingresso.roomId,\n ingresso.code,\n ingresso.url,\n input,\n api,\n () => void 0,\n true\n );\n await stanza.pronta();\n return {\n get mode() {\n return stanza.mode;\n },\n get countdownAt() {\n return stanza.countdownAt;\n },\n get connection() {\n return stanza.connection;\n },\n get metadata() {\n return stanza.metadata;\n },\n onMetadata: (listener) => stanza.onMetadata(listener),\n onConnection: (listener) => stanza.onConnection(listener),\n disconnect: () => stanza.disconnect(),\n get state() {\n return stanza.state;\n },\n get tick() {\n return stanza.tick;\n },\n get seed() {\n return stanza.seed;\n },\n get status() {\n return stanza.status;\n },\n get players() {\n return stanza.players;\n },\n get host() {\n return stanza.host;\n },\n get code() {\n return stanza.code;\n },\n get result() {\n return stanza.result;\n },\n get delayMs() {\n return stanza.delayMs;\n },\n onState: (listener) => stanza.onState(listener),\n onPlayers: (listener) => stanza.onPlayers(listener),\n onStatus: (listener) => stanza.onStatus(listener),\n onMessage: (listener) => stanza.onMessage(listener),\n leave: () => {\n stanza.leave();\n },\n serverTime: () => stanza.serverTime()\n };\n };\n const attendiMatch = (url, options) => new Promise((resolve, reject) => {\n let socket;\n let conclusa = false;\n const pulisci = () => {\n socket.removeEventListener("message", ricevi);\n socket.removeEventListener("close", chiuso);\n socket.removeEventListener("error", caduto);\n options.signal?.removeEventListener("abort", annulla);\n };\n const chiudi = () => {\n try {\n socket.close(1e3);\n } catch {\n }\n };\n const fallisci = (errore, chiudiSocket) => {\n if (conclusa) return;\n conclusa = true;\n pulisci();\n if (chiudiSocket) chiudi();\n reject(errore);\n };\n function annulla() {\n fallisci(\n creaErrore("cancelled", "The matchmaking search was cancelled."),\n true\n );\n }\n function chiuso() {\n fallisci(erroreOffline(), false);\n }\n function caduto() {\n fallisci(erroreOffline(), true);\n }\n function ricevi(evento) {\n let dati = null;\n try {\n dati = typeof evento.data === "string" ? record3(JSON.parse(evento.data)) : null;\n } catch {\n }\n if (dati === null || typeof dati.t !== "string") {\n fallisci(creaErrore("internal_error", "The matchmaking service sent an invalid message."), true);\n return;\n }\n if (dati.t === "waiting") {\n if (!Number.isInteger(dati.players) || !Number.isInteger(dati.min) || !Number.isInteger(dati.max)) {\n fallisci(creaErrore("internal_error", "The matchmaking service sent an invalid message."), true);\n return;\n }\n try {\n options.onWaiting?.({\n players: dati.players,\n min: dati.min,\n max: dati.max\n });\n } catch {\n }\n return;\n }\n if (dati.t === "matched") {\n if (!ingressoValido(dati)) {\n fallisci(creaErrore("internal_error", "The matchmaking service sent an invalid message."), true);\n return;\n }\n conclusa = true;\n pulisci();\n chiudi();\n resolve(dati);\n return;\n }\n if (dati.t === "no_match") {\n fallisci(creaErrore("no_match", "No match was found before the timeout."), true);\n return;\n }\n if (dati.t === "error") {\n fallisci(creaErrore(\n typeof dati.code === "string" ? dati.code : "internal_error",\n typeof dati.message === "string" ? dati.message : "The matchmaking service could not complete the search."\n ), true);\n return;\n }\n if (dati.t !== "pong") {\n fallisci(creaErrore("internal_error", "The matchmaking service sent an invalid message."), true);\n }\n }\n try {\n socket = input.apriSocket(url);\n } catch {\n reject(erroreOffline());\n return;\n }\n socket.addEventListener("message", ricevi);\n socket.addEventListener("close", chiuso);\n socket.addEventListener("error", caduto);\n options.signal?.addEventListener("abort", annulla, { once: true });\n if (options.signal?.aborted === true) annulla();\n });\n return {\n invited,\n async create(options) {\n return collega(await api.create(options.mode));\n },\n async join(code) {\n const scelto = code ?? invited;\n if (scelto === null || scelto === void 0 || scelto.length === 0) {\n throw creaErrore("invalid_request", "A room invitation code is required.");\n }\n return collega(await api.joinCode(scelto));\n },\n async watch(code) {\n if (typeof code !== "string" || code.length === 0) {\n throw creaErrore("invalid_request", "A room invitation code is required.");\n }\n return guarda(await api.watchCode(code));\n },\n async match(options) {\n const annullata = () => options.signal?.aborted === true;\n if (annullata()) {\n throw creaErrore("cancelled", "The matchmaking search was cancelled.");\n }\n const risposta = await api.match(options);\n if (annullata()) {\n throw creaErrore("cancelled", "The matchmaking search was cancelled.");\n }\n return collega(await attendiMatch(risposta.url, options));\n }\n };\n}\n\n// src/standalone.ts\nvar PREFISSO = "caisual:save:";\nvar CHIAVE_VALIDA = /^[a-z0-9][a-z0-9_-]{0,31}$/;\nfunction verificaChiave(key) {\n if (!CHIAVE_VALIDA.test(key)) {\n throw creaErrore("invalid_request", "Save keys must use lowercase letters, numbers, underscores, or hyphens.");\n }\n}\nfunction leggiSalvataggio(testo) {\n if (testo === null) return null;\n try {\n return JSON.parse(testo);\n } catch {\n return null;\n }\n}\nfunction chiavi(archivio) {\n const risultato = [];\n for (let indice = 0; indice < archivio.length; indice++) {\n const key = archivio.key(indice);\n if (key?.startsWith(PREFISSO)) risultato.push(key.slice(PREFISSO.length));\n }\n return risultato;\n}\nfunction creaSave(archivio, ora) {\n const disponibile = () => {\n if (archivio === null) throw erroreOffline();\n return archivio;\n };\n return {\n async set(key, value) {\n verificaChiave(key);\n const locale = disponibile();\n const corpo = JSON.stringify({ value });\n const bytes = new TextEncoder().encode(corpo).byteLength;\n if (bytes > 262144) {\n throw creaErrore("payload_too_large", "The save is larger than 262144 bytes.");\n }\n if (locale.getItem(PREFISSO + key) === null && chiavi(locale).length >= 32) {\n throw creaErrore("save_limit", "A game can store at most 32 save keys.");\n }\n const voce = { value, bytes, updatedAt: ora() };\n locale.setItem(PREFISSO + key, JSON.stringify(voce));\n return { key, bytes, updatedAt: voce.updatedAt };\n },\n async get(key) {\n verificaChiave(key);\n return leggiSalvataggio(disponibile().getItem(PREFISSO + key))?.value ?? null;\n },\n async remove(key) {\n verificaChiave(key);\n disponibile().removeItem(PREFISSO + key);\n },\n async list() {\n const locale = disponibile();\n return chiavi(locale).flatMap((key) => {\n const voce = leggiSalvataggio(locale.getItem(PREFISSO + key));\n return voce === null ? [] : [{ key, bytes: voce.bytes, updatedAt: voce.updatedAt }];\n }).sort((a, b) => a.key.localeCompare(b.key));\n }\n };\n}\nasync function creaStandalone(input, invited = null) {\n const day = giornoUtc(input.ora());\n const seed = await calcolaSeed(input.hostname, day, input.subtle);\n return {\n connected: false,\n player: { id: "local", name: "Guest", guest: true },\n daily: { day, seed, random: creaMulberry32(seed) },\n time: { now: input.ora },\n save: creaSave(input.archivio, input.ora),\n board: {\n async submit() {\n return { accepted: false, reason: "offline", verified: false };\n },\n async top(_board, opzioni = {}) {\n if (opzioni.day !== void 0 && (!validBoardDay(opzioni.day) || opzioni.daily === false)) throw creaErrore("invalid_request", "day must be a real UTC date and cannot be combined with daily: false.");\n return { day: opzioni.day ?? (opzioni.daily ? day : null), entries: [], me: null };\n }\n },\n room: creaStanzeOffline(invited)\n };\n}\n\n// src/kit.ts\nfunction leggiAppOrigin(documento) {\n const valore = documento?.querySelector(\'meta[name="caisual-app"]\')?.getAttribute("content");\n if (valore === null || valore === void 0) return null;\n try {\n const url = new URL(valore);\n return url.origin === valore && (url.protocol === "https:" || url.protocol === "http:") ? valore : null;\n } catch {\n return null;\n }\n}\nfunction archivioReale() {\n try {\n return typeof localStorage === "undefined" ? null : localStorage;\n } catch {\n return null;\n }\n}\nfunction dipendenzeReali2() {\n return {\n finestra: typeof window === "undefined" ? null : window,\n documento: typeof document === "undefined" ? null : document,\n fetcher: (input, init) => globalThis.fetch(input, init),\n archivio: archivioReale(),\n hostname: typeof location === "undefined" ? "" : location.hostname,\n subtle: globalThis.crypto.subtle,\n ora: Date.now,\n sonda: () => probeDevice()\n };\n}\nasync function connetti(input) {\n const appOrigin = leggiAppOrigin(input.documento);\n const senzaPadre = input.finestra === null || input.finestra.parent === input.finestra;\n if (appOrigin === null || senzaPadre) {\n return localConnection(input);\n }\n const handshake = await attendiHandshake(\n input.finestra,\n appOrigin,\n input.timeoutHandshake\n );\n if (handshake === null) return localConnection(input);\n const biglietto = creaGestoreBiglietto(\n handshake.ticket,\n handshake.porta,\n input.finestra,\n input.ora,\n input.timeoutRinnovo,\n "portal"\n );\n const api = creaClienteApi(appOrigin, input.fetcher, biglietto);\n const prima = input.ora();\n let me;\n try {\n me = await api.me();\n } catch {\n const base2 = await creaStandalone(input, handshake.invite);\n return installSession(base2, handshake, input);\n }\n const dopo = input.ora();\n const scartoOrario = me.serverTime - (prima + dopo) / 2;\n const room = handshake.live === null ? creaStanzeOffline(handshake.invite) : creaGestoreStanze({\n appOrigin,\n liveOrigin: handshake.live,\n fetcher: input.fetcher,\n biglietto: creaGestoreBiglietto(\n null,\n handshake.porta,\n input.finestra,\n input.ora,\n input.timeoutRinnovo,\n "live"\n ),\n apriSocket(url) {\n if (input.apriSocket !== void 0) return input.apriSocket(url);\n if (typeof WebSocket === "undefined") throw erroreOffline();\n return new WebSocket(url);\n },\n ora: input.ora,\n setTimeout: (handler, timeout) => globalThis.setTimeout(handler, timeout),\n clearTimeout: (id) => globalThis.clearTimeout(id),\n setInterval: (handler, timeout) => globalThis.setInterval(handler, timeout),\n clearInterval: (id) => globalThis.clearInterval(id),\n voce: input.voce,\n segnalaStanza(room2) {\n try {\n handshake.porta.postMessage({ type: "caisual:room", room: room2 });\n } catch {\n }\n }\n }, handshake.invite);\n const base = {\n connected: true,\n player: me.player,\n daily: { day: me.day, seed: me.seed, random: creaMulberry32(me.seed) },\n time: { now: () => input.ora() + scartoOrario },\n save: {\n set: (key, value) => api.saveSet(key, value),\n get: (key) => api.saveGet(key),\n remove: (key) => api.saveRemove(key),\n list: () => api.saveList()\n },\n board: {\n async submit(board, score, opzioni = {}) {\n try {\n return await api.boardSubmit(board, score, opzioni.daily === true);\n } catch (errore) {\n if (typeof errore === "object" && errore !== null && "code" in errore && errore.code === "offline") return { accepted: false, reason: "offline", verified: false };\n throw errore;\n }\n },\n top: (board, opzioni = {}) => api.boardTop(board, opzioni)\n },\n room\n };\n return installSession(base, handshake, input);\n}\nfunction installSession(base, handshake, input) {\n const coordinator = createSession(base, handshake?.overlay?.configuration ?? null, base.connected && handshake?.live != null);\n if (handshake?.overlay) {\n const dispose = attachKitBridge(handshake.porta, handshake.overlay, coordinator);\n if (coordinator.session.capabilities.overlay && typeof window !== "undefined" && input?.finestra === window) window.addEventListener("pagehide", dispose, { once: true });\n }\n return { ...base, room: coordinator.rooms, session: coordinator.session, overlay: coordinator.overlay };\n}\nasync function localConnection(input) {\n return installSession(await creaStandalone(input));\n}\nfunction dispositivoSconosciuto() {\n return {\n webgl2: false,\n webgpu: false,\n wasm: false,\n threads: false,\n isolated: false,\n gpu: "none",\n memoryMb: null,\n cores: null,\n mobile: false,\n tier: "low"\n };\n}\nasync function attendiSonda(sonda) {\n let timer;\n try {\n return await Promise.race([\n Promise.resolve().then(sonda).catch(() => dispositivoSconosciuto()),\n new Promise((resolve) => {\n timer = globalThis.setTimeout(() => resolve(dispositivoSconosciuto()), 1500);\n })\n ]);\n } finally {\n if (timer !== void 0) globalThis.clearTimeout(timer);\n }\n}\nfunction creaKit(input = dipendenzeReali2()) {\n let promessa = null;\n return {\n connect() {\n promessa ?? (promessa = Promise.all([connetti(input), attendiSonda(input.sonda)]).then(([connessione, device]) => ({ ...connessione, device })));\n return promessa;\n }\n };\n}\n\n// src/index.ts\nvar caisual = creaKit();\nglobalThis.caisual = caisual;\nvar index_default = caisual;\nexport {\n caisual,\n index_default as default\n};\n');
|
|
3532
4393
|
return;
|
|
3533
4394
|
}
|
|
3534
4395
|
let decoded;
|
|
@@ -3564,6 +4425,16 @@ var DevService = class {
|
|
|
3564
4425
|
response.end(request.method === "HEAD" ? void 0 : body);
|
|
3565
4426
|
}
|
|
3566
4427
|
async handlePortal(request, response, url) {
|
|
4428
|
+
if (url.pathname === "/__caisual/overlay/v1.css" && (request.method === "GET" || request.method === "HEAD")) {
|
|
4429
|
+
response.writeHead(200, { "Content-Type": "text/css; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
|
|
4430
|
+
response.end(request.method === "HEAD" ? void 0 : styles);
|
|
4431
|
+
return;
|
|
4432
|
+
}
|
|
4433
|
+
if (url.pathname === "/__caisual/overlay/v1.js" && (request.method === "GET" || request.method === "HEAD")) {
|
|
4434
|
+
response.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
|
|
4435
|
+
response.end(request.method === "HEAD" ? void 0 : '// ../contracts/src/slug.ts\nvar NOMI_RISERVATI = [\n "www",\n "api",\n "app",\n "play",\n "live",\n "multi",\n "cdn",\n "assets",\n "static",\n "mail",\n "mx",\n "ns1",\n "ns2",\n "autodiscover",\n "_dmarc",\n "admin",\n "login",\n "account",\n "auth",\n "pay",\n "secure",\n "support",\n "help",\n "blog",\n "status",\n "dev",\n "staging",\n "test",\n "caisual",\n "shipz"\n];\nvar RISERVATI = new Set(NOMI_RISERVATI);\nvar SLUG_NUOVO = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;\nvar SLUG_STORICO = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;\nfunction isValidSlug(value) {\n return value.length >= 3 && value.length <= 32 && SLUG_NUOVO.test(value) || SLUG_STORICO.test(value);\n}\nfunction isReservedSlug(value) {\n return RISERVATI.has(value);\n}\n\n// ../contracts/src/manifest.ts\nfunction risolviModalita(manifest, mode) {\n const scelta = mode === null ? void 0 : manifest.modes.find((voce) => voce.id === mode);\n if (mode !== null && scelta === void 0) throw new Error("The selected game mode does not exist.");\n return { players: { ...scelta?.players ?? manifest.players }, lobby: scelta?.lobby ?? manifest.lobby };\n}\nfunction risolviPresentazione(manifest, mode) {\n risolviModalita(manifest, mode);\n const scelta = manifest.modes.find((voce) => voce.id === mode);\n return {\n execution: scelta?.execution ?? null,\n label: scelta?.label ?? scelta?.id ?? manifest.name ?? "Play",\n instructions: scelta?.instructions ?? null\n };\n}\nvar TETTO_GIOCATORI = 24;\nvar RITARDO_SPETTATORI_MS = 3e3;\nvar MASSIMO_CLASSIFICHE = 32;\nvar CAMPI = /* @__PURE__ */ new Set([\n "overlay",\n "manifest",\n "id",\n "name",\n "description",\n "cover",\n "screenshots",\n "tags",\n "language",\n "platform",\n "orientation",\n "input",\n "visibility",\n "network",\n "isolated",\n "requires",\n "players",\n "lobby",\n "persistent",\n "spectators",\n "boards",\n "roles",\n "teams",\n "voice",\n "modes"\n]);\nvar INPUT = /* @__PURE__ */ new Set(["keyboard", "mouse", "touch", "gamepad"]);\nvar PLATFORM = /* @__PURE__ */ new Set(["desktop", "mobile", "both"]);\nvar ORIENTATION = /* @__PURE__ */ new Set(["landscape", "portrait"]);\nvar VISIBILITY = /* @__PURE__ */ new Set(["public", "unlisted"]);\nvar VOICE = /* @__PURE__ */ new Set(["none", "room", "team", "proximity"]);\nvar PERFORMANCE = /* @__PURE__ */ new Set(["light", "medium", "heavy"]);\nvar TAG = /^[a-z0-9-]+$/;\nvar ID_INTERNO = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;\nvar CAMPO_MATCHMAKING = /^[a-z0-9][a-z0-9-]{0,31}$/;\nvar ID_CLASSIFICA = /^[a-z0-9][a-z0-9_-]{0,31}$/;\nfunction oggetto(value) {\n if (typeof value !== "object" || value === null || Array.isArray(value)) return null;\n return value;\n}\nfunction percorsoRelativo(value) {\n if (value === "" || value.startsWith("/") || value.includes("\\\\") || value.includes("\\0")) return false;\n if (value.includes("?") || value.includes("#")) return false;\n const parti = value.split("/");\n if (parti.some((parte) => parte === "" || parte === "." || parte === "..")) return false;\n try {\n const decoded = parti.map((parte) => decodeURIComponent(parte));\n return !decoded.some((parte) => parte === "" || parte === "." || parte === ".." || parte.includes("/"));\n } catch {\n return false;\n }\n}\nfunction hostValido(value) {\n if (value.length === 0 || value.length > 253) return false;\n if (value.includes("://") || /[/:?#@]/.test(value)) return false;\n const parti = value.split(".");\n return parti.every(\n (parte) => parte.length >= 1 && parte.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(parte)\n );\n}\nfunction interoTra(value, min, max) {\n return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;\n}\nfunction stringaDefault(dati, campo, valoreDefault, errori) {\n const value = dati[campo];\n if (value === void 0) return valoreDefault;\n if (typeof value !== "string") {\n errori.push(`${campo}: must be a string.`);\n return valoreDefault;\n }\n return value;\n}\nfunction testoFacoltativo(value, key, max, path, errors) {\n if (value[key] === void 0) return void 0;\n const text = value[key];\n if (typeof text !== "string" || text.trim().length === 0 || text.trim().length > max || /[\\r\\n\\u0000-\\u001f]/.test(text)) {\n errors.push(`${path}.${key}: must contain 1-${max} characters on one line.`);\n return void 0;\n }\n return text.trim();\n}\nfunction validaManifest(valore) {\n const errori = [];\n const dati = oggetto(valore);\n if (dati === null) return { ok: false, errori: ["manifest: must be a JSON object."] };\n for (const campo of Object.keys(dati)) {\n if (!CAMPI.has(campo)) errori.push(`${campo}: unknown field.`);\n }\n if (dati.manifest === void 0) errori.push("manifest: is required and must be 1.");\n else if (dati.manifest !== 1) errori.push("manifest: must be exactly 1.");\n const id = stringaDefault(dati, "id", "", errori);\n if (dati.id === void 0) errori.push("id: is required.");\n else if (typeof dati.id === "string") {\n if (!isValidSlug(id)) {\n errori.push("id: must be 3-32 lowercase ASCII letters or digits with internal hyphens; historical UUID v4 slugs may be 36 characters.");\n } else if (isReservedSlug(id)) errori.push("id: this slug is reserved.");\n }\n const name = stringaDefault(dati, "name", "", errori);\n if (dati.name === void 0) errori.push("name: is required.");\n else if (typeof dati.name === "string" && (name.trim() === "" || name.length > 60)) {\n errori.push("name: must contain 1-60 characters.");\n }\n const description = stringaDefault(dati, "description", "", errori);\n if (description.length > 500) errori.push("description: must be at most 500 characters.");\n let cover = null;\n if (dati.cover !== void 0 && dati.cover !== null) {\n if (typeof dati.cover !== "string") errori.push("cover: must be a relative file path or null.");\n else if (!percorsoRelativo(dati.cover)) errori.push("cover: must be a relative file path without query, fragment, or parent segments.");\n else cover = dati.cover;\n }\n const screenshots = [];\n if (dati.screenshots !== void 0) {\n if (!Array.isArray(dati.screenshots)) errori.push("screenshots: must be an array of relative file paths.");\n else {\n if (dati.screenshots.length > 8) errori.push("screenshots: must contain at most 8 paths.");\n for (const [indice, value] of dati.screenshots.entries()) {\n if (typeof value !== "string" || !percorsoRelativo(value)) {\n errori.push(`screenshots[${indice}]: must be a relative file path without query, fragment, or parent segments.`);\n } else screenshots.push(value);\n }\n }\n }\n const tags = [];\n if (dati.tags !== void 0) {\n if (!Array.isArray(dati.tags)) errori.push("tags: must be an array.");\n else {\n if (dati.tags.length > 10) errori.push("tags: must contain at most 10 tags.");\n for (const [indice, value] of dati.tags.entries()) {\n if (typeof value !== "string" || value.length > 24 || !TAG.test(value)) {\n errori.push(`tags[${indice}]: must be 1-24 lowercase letters, digits, or hyphens.`);\n } else tags.push(value);\n }\n }\n }\n const language = stringaDefault(dati, "language", "en", errori);\n if (!/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/.test(language)) {\n errori.push("language: must be a BCP 47 language tag such as en, it, or pt-BR.");\n }\n let platform = "both";\n if (dati.platform === void 0) errori.push("platform: is required.");\n else if (typeof dati.platform !== "string" || !PLATFORM.has(dati.platform)) {\n errori.push("platform: must be desktop, mobile, or both.");\n } else platform = dati.platform;\n let orientation = "landscape";\n if (dati.orientation !== void 0) {\n if (typeof dati.orientation !== "string" || !ORIENTATION.has(dati.orientation)) {\n errori.push("orientation: must be landscape or portrait.");\n } else orientation = dati.orientation;\n }\n const input = [];\n if (dati.input !== void 0) {\n if (!Array.isArray(dati.input)) errori.push("input: must be an array.");\n else for (const [indice, value] of dati.input.entries()) {\n if (typeof value !== "string" || !INPUT.has(value)) {\n errori.push(`input[${indice}]: must be keyboard, mouse, touch, or gamepad.`);\n } else if (input.includes(value)) errori.push(`input[${indice}]: duplicate value ${value}.`);\n else input.push(value);\n }\n }\n let visibility = "public";\n if (dati.visibility !== void 0) {\n if (typeof dati.visibility !== "string" || !VISIBILITY.has(dati.visibility)) {\n errori.push("visibility: must be public or unlisted.");\n } else visibility = dati.visibility;\n }\n const network = [];\n if (dati.network !== void 0) {\n if (!Array.isArray(dati.network)) errori.push("network: must be an array of host names.");\n else for (const [indice, value] of dati.network.entries()) {\n if (typeof value !== "string" || !hostValido(value)) {\n errori.push(`network[${indice}]: must be a host name without scheme, port, path, query, or fragment.`);\n } else if (network.includes(value)) errori.push(`network[${indice}]: duplicate host ${value}.`);\n else network.push(value);\n }\n }\n let isolated = false;\n if (dati.isolated !== void 0) {\n if (typeof dati.isolated !== "boolean") errori.push("isolated: must be a boolean.");\n else isolated = dati.isolated;\n }\n const requires = {\n webgl2: false,\n webgpu: false,\n wasm: false,\n threads: false,\n memoryMb: null,\n performance: "light"\n };\n if (dati.requires !== void 0) {\n const value = oggetto(dati.requires);\n if (value === null) errori.push("requires: must be an object.");\n else {\n for (const campo of Object.keys(value)) {\n if (!["webgl2", "webgpu", "wasm", "threads", "memoryMb", "performance"].includes(campo)) {\n errori.push(`requires.${campo}: unknown field.`);\n }\n }\n for (const campo of ["webgl2", "webgpu", "wasm", "threads"]) {\n if (value[campo] === void 0) continue;\n if (typeof value[campo] !== "boolean") errori.push(`requires.${campo}: must be a boolean.`);\n else requires[campo] = value[campo];\n }\n if (value.memoryMb !== void 0) {\n if (value.memoryMb !== null && (!interoTra(value.memoryMb, 512, 32768) || value.memoryMb % 256 !== 0)) {\n errori.push("requires.memoryMb: must be an integer from 512 to 32768 in steps of 256, or null.");\n } else requires.memoryMb = value.memoryMb;\n }\n if (value.performance !== void 0) {\n if (typeof value.performance !== "string" || !PERFORMANCE.has(value.performance)) {\n errori.push("requires.performance: must be light, medium, or heavy.");\n } else requires.performance = value.performance;\n }\n if (requires.threads && !isolated) errori.push("requires.threads: needs isolated: true.");\n }\n }\n let players = { min: 1, max: 1 };\n if (dati.players !== void 0) {\n const value = oggetto(dati.players);\n if (value === null) errori.push("players: must be an object with min and max.");\n else {\n for (const campo of Object.keys(value)) {\n if (campo !== "min" && campo !== "max") errori.push(`players.${campo}: unknown field.`);\n }\n if (!interoTra(value.min, 1, TETTO_GIOCATORI)) errori.push(`players.min: must be an integer from 1 to ${TETTO_GIOCATORI}.`);\n if (!interoTra(value.max, 1, TETTO_GIOCATORI)) errori.push(`players.max: must be an integer from 1 to ${TETTO_GIOCATORI} in manifest version 1.`);\n if (interoTra(value.min, 1, TETTO_GIOCATORI) && interoTra(value.max, 1, TETTO_GIOCATORI)) {\n if (value.min > value.max) errori.push("players.max: must be greater than or equal to players.min.");\n else players = { min: value.min, max: value.max };\n }\n }\n }\n let lobby = false;\n if (dati.lobby !== void 0) {\n if (typeof dati.lobby !== "boolean") errori.push("lobby: must be a boolean.");\n else lobby = dati.lobby;\n }\n let persistent = false;\n if (dati.persistent !== void 0) {\n if (typeof dati.persistent !== "boolean") errori.push("persistent: must be a boolean.");\n else persistent = dati.persistent;\n }\n let spectators = { delayMs: RITARDO_SPETTATORI_MS };\n if (dati.spectators === false || dati.spectators === null) spectators = null;\n else if (dati.spectators !== void 0 && dati.spectators !== true) {\n const value = oggetto(dati.spectators);\n if (value === null) {\n errori.push("spectators: must be a boolean or an object with delayMs.");\n } else {\n for (const campo of Object.keys(value)) {\n if (campo !== "delayMs") errori.push(`spectators.${campo}: unknown field.`);\n }\n if (!interoTra(value.delayMs, 0, 3e4)) {\n errori.push("spectators.delayMs: must be an integer from 0 to 30000.");\n } else spectators = { delayMs: value.delayMs };\n }\n }\n let overlay = null;\n if (dati.overlay !== void 0 && dati.overlay !== null) {\n const value = oggetto(dati.overlay);\n if (value === null) errori.push("overlay: must be an object or null.");\n else {\n for (const key of Object.keys(value)) if (!["version", "accent"].includes(key)) errori.push(`overlay.${key}: unknown field.`);\n if (value.version !== 1) errori.push("overlay.version: must be exactly 1.");\n if (value.accent !== void 0 && (typeof value.accent !== "string" || !/^#[0-9a-fA-F]{6}$/.test(value.accent))) {\n errori.push("overlay.accent: must be a six-digit hexadecimal color, such as #336699.");\n }\n overlay = { version: 1, ...typeof value.accent === "string" ? { accent: value.accent } : {} };\n }\n }\n const boards = {};\n if (dati.boards !== void 0) {\n const value = oggetto(dati.boards);\n if (value === null) errori.push("boards: must be an object of board ids.");\n else {\n if (Object.keys(value).length > MASSIMO_CLASSIFICHE) {\n errori.push(`boards: at most ${MASSIMO_CLASSIFICHE} boards.`);\n }\n for (const [id2, raw] of Object.entries(value)) {\n let valido = true;\n if (!ID_CLASSIFICA.test(id2)) {\n errori.push(`boards.${id2}: invalid board id.`);\n valido = false;\n }\n const board = oggetto(raw);\n if (board === null) {\n errori.push(`boards.${id2}.source: must be "client" or "server".`);\n continue;\n }\n for (const campo of Object.keys(board)) {\n if (!["source", "label", "periods"].includes(campo)) errori.push(`boards.${id2}.${campo}: unknown field.`);\n }\n if (board.source !== "client" && board.source !== "server") {\n errori.push(`boards.${id2}.source: must be "client" or "server".`);\n valido = false;\n }\n const label = testoFacoltativo(board, "label", 48, `boards.${id2}`, errori);\n let periods = ["all-time"];\n if (board.periods !== void 0) {\n if (!Array.isArray(board.periods) || board.periods.length < 1 || board.periods.length > 2 || board.periods.some((period) => period !== "daily" && period !== "all-time") || new Set(board.periods).size !== board.periods.length) {\n errori.push(`boards.${id2}.periods: must contain daily, all-time, or both without duplicates.`);\n } else periods = [...board.periods];\n }\n if (valido) Object.defineProperty(boards, id2, { value: {\n source: board.source,\n periods,\n ...label === void 0 ? {} : { label }\n }, enumerable: true, configurable: true, writable: true });\n }\n }\n }\n const roles = [];\n if (dati.roles !== void 0) {\n if (!Array.isArray(dati.roles)) errori.push("roles: must be an array.");\n else {\n const ids = /* @__PURE__ */ new Set();\n for (const [indice, raw] of dati.roles.entries()) {\n const value = oggetto(raw);\n if (value === null) {\n errori.push(`roles[${indice}]: must be an object.`);\n continue;\n }\n for (const campo of Object.keys(value)) {\n if (!["id", "min", "max", "label"].includes(campo)) errori.push(`roles[${indice}].${campo}: unknown field.`);\n }\n const idRuolo = value.id;\n const min = value.min;\n const max = value.max;\n let valido = true;\n if (typeof idRuolo !== "string" || idRuolo.length > 32 || !ID_INTERNO.test(idRuolo)) {\n errori.push(`roles[${indice}].id: must be 1-32 lowercase letters, digits, or internal hyphens.`);\n valido = false;\n } else if (ids.has(idRuolo)) {\n errori.push(`roles[${indice}].id: duplicate role ${idRuolo}.`);\n valido = false;\n } else ids.add(idRuolo);\n if (!interoTra(min, 0, TETTO_GIOCATORI)) {\n errori.push(`roles[${indice}].min: must be an integer from 0 to ${TETTO_GIOCATORI}.`);\n valido = false;\n }\n if (max !== void 0 && !interoTra(max, 0, TETTO_GIOCATORI)) {\n errori.push(`roles[${indice}].max: must be an integer from 0 to ${TETTO_GIOCATORI} when present.`);\n valido = false;\n }\n if (typeof min === "number" && typeof max === "number" && min > max) {\n errori.push(`roles[${indice}].max: must be greater than or equal to min.`);\n valido = false;\n }\n const label = testoFacoltativo(value, "label", 32, `roles[${indice}]`, errori);\n if (valido) roles.push({\n id: idRuolo,\n min,\n ...max === void 0 ? {} : { max },\n ...label === void 0 ? {} : { label }\n });\n }\n }\n }\n let teams = null;\n if (dati.teams !== void 0 && dati.teams !== null) {\n const value = oggetto(dati.teams);\n if (value === null) errori.push("teams: must be null or an object with min and max.");\n else {\n for (const campo of Object.keys(value)) {\n if (campo !== "min" && campo !== "max") errori.push(`teams.${campo}: unknown field.`);\n }\n if (!interoTra(value.min, 2, TETTO_GIOCATORI)) errori.push(`teams.min: must be an integer from 2 to ${TETTO_GIOCATORI}.`);\n if (!interoTra(value.max, 2, TETTO_GIOCATORI)) errori.push(`teams.max: must be an integer from 2 to ${TETTO_GIOCATORI}.`);\n if (interoTra(value.min, 2, TETTO_GIOCATORI) && interoTra(value.max, 2, TETTO_GIOCATORI)) {\n if (value.min > value.max) errori.push("teams.max: must be greater than or equal to teams.min.");\n else teams = { min: value.min, max: value.max };\n }\n }\n }\n let voice = "none";\n if (dati.voice !== void 0) {\n if (typeof dati.voice !== "string" || !VOICE.has(dati.voice)) {\n errori.push("voice: must be none, room, team, or proximity.");\n } else voice = dati.voice;\n }\n const modes = [];\n if (dati.modes !== void 0) {\n if (!Array.isArray(dati.modes)) errori.push("modes: must be an array.");\n else {\n const ids = /* @__PURE__ */ new Set();\n for (const [indice, raw] of dati.modes.entries()) {\n const value = oggetto(raw);\n if (value === null) {\n errori.push(`modes[${indice}]: must be an object.`);\n continue;\n }\n for (const campo of Object.keys(value)) {\n if (!["id", "players", "lobby", "matchmaking", "execution", "label", "instructions"].includes(campo)) errori.push(`modes[${indice}].${campo}: unknown field.`);\n }\n if (typeof value.id !== "string" || value.id.length > 32 || !ID_INTERNO.test(value.id)) {\n errori.push(`modes[${indice}].id: must be 1-32 lowercase letters, digits, or internal hyphens.`);\n continue;\n }\n if (ids.has(value.id)) {\n errori.push(`modes[${indice}].id: duplicate mode ${value.id}.`);\n continue;\n }\n ids.add(value.id);\n const modo = { id: value.id };\n for (const [key2, max] of [["label", 48], ["instructions", 160]]) {\n const text = testoFacoltativo(value, key2, max, `modes[${indice}]`, errori);\n if (text !== void 0) modo[key2] = text;\n }\n if (value.execution !== void 0) {\n if (value.execution !== "local" && value.execution !== "room") errori.push(`modes[${indice}].execution: must be local or room.`);\n else modo.execution = value.execution;\n }\n if (overlay !== null && modo.execution === void 0) errori.push(`modes[${indice}].execution: is required with the standard overlay.`);\n if (value.players !== void 0) {\n const campo = `modes[${indice}].players`;\n const range = oggetto(value.players);\n if (range === null) errori.push(`${campo}: must be an object with min and max.`);\n else {\n for (const key2 of Object.keys(range)) {\n if (key2 !== "min" && key2 !== "max") errori.push(`${campo}.${key2}: unknown field.`);\n }\n if (!interoTra(range.min, 1, TETTO_GIOCATORI)) errori.push(`${campo}.min: must be an integer from 1 to ${TETTO_GIOCATORI}.`);\n if (!interoTra(range.max, 1, TETTO_GIOCATORI)) errori.push(`${campo}.max: must be an integer from 1 to ${TETTO_GIOCATORI}.`);\n if (interoTra(range.min, 1, TETTO_GIOCATORI) && interoTra(range.max, 1, TETTO_GIOCATORI)) {\n if (range.min > range.max) errori.push(`${campo}.max: must be greater than or equal to min.`);\n else modo.players = { min: range.min, max: range.max };\n }\n }\n }\n if (value.lobby !== void 0) {\n if (typeof value.lobby !== "boolean") errori.push(`modes[${indice}].lobby: must be a boolean.`);\n else modo.lobby = value.lobby;\n }\n if (modo.execution === "local") {\n const range = modo.players ?? players;\n if (range.min !== 1 || range.max !== 1) errori.push(`modes[${indice}].players: local execution requires min and max to be 1.`);\n if (modo.lobby ?? lobby) errori.push(`modes[${indice}].lobby: local execution requires false.`);\n if (value.matchmaking !== void 0) errori.push(`modes[${indice}].matchmaking: local execution cannot use matchmaking.`);\n }\n if (value.matchmaking === void 0) {\n modes.push(modo);\n continue;\n }\n const matchmaking = oggetto(value.matchmaking);\n if (matchmaking === null) {\n errori.push(`modes[${indice}].matchmaking: must be an object.`);\n continue;\n }\n for (const campo of Object.keys(matchmaking)) {\n if (!["key", "timeoutMs", "defaults"].includes(campo)) {\n errori.push(`modes[${indice}].matchmaking.${campo}: unknown field.`);\n }\n }\n let valido = true;\n const key = [];\n if (!Array.isArray(matchmaking.key) || matchmaking.key.length < 1 || matchmaking.key.length > 8) {\n errori.push(`modes[${indice}].matchmaking.key: must contain from 1 to 8 fields.`);\n valido = false;\n } else for (const [keyIndice, item] of matchmaking.key.entries()) {\n if (typeof item !== "string" || !CAMPO_MATCHMAKING.test(item)) {\n errori.push(`modes[${indice}].matchmaking.key[${keyIndice}]: must be 1-32 lowercase letters, digits, or hyphens and start with a letter or digit.`);\n valido = false;\n } else if (key.includes(item)) {\n errori.push(`modes[${indice}].matchmaking.key[${keyIndice}]: duplicate field ${item}.`);\n valido = false;\n } else key.push(item);\n }\n if (!interoTra(matchmaking.timeoutMs, 1e3, 3e5)) {\n errori.push(`modes[${indice}].matchmaking.timeoutMs: must be an integer from 1000 to 300000.`);\n valido = false;\n }\n let defaults;\n if (matchmaking.defaults !== void 0) {\n const values = oggetto(matchmaking.defaults);\n if (values === null || Object.keys(values).length !== key.length || key.some((field) => !Object.hasOwn(values, field))) {\n errori.push(`modes[${indice}].matchmaking.defaults: must contain exactly the declared key fields.`);\n } else {\n defaults = {};\n for (const [field, value2] of Object.entries(values)) {\n if (!(typeof value2 === "string" && value2.length >= 1 && value2.length <= 64 && /^[A-Za-z0-9_.:-]+$/.test(value2)) && !Number.isSafeInteger(value2)) {\n errori.push(`modes[${indice}].matchmaking.defaults.${field}: must be a string of 1-64 characters or a safe integer.`);\n } else Object.defineProperty(defaults, field, { value: value2, enumerable: true });\n }\n }\n }\n if (valido) modes.push({ ...modo, matchmaking: {\n ...defaults === void 0 ? {} : { defaults },\n key,\n timeoutMs: matchmaking.timeoutMs\n } });\n }\n }\n }\n if (overlay !== null && modes.length === 0) errori.push("modes: at least one explicit mode is required with the standard overlay.");\n if (errori.length > 0) return { ok: false, errori };\n return { ok: true, manifest: {\n manifest: 1,\n overlay,\n id,\n name,\n description,\n cover,\n screenshots,\n tags,\n language,\n platform,\n orientation,\n input,\n visibility,\n network,\n isolated,\n requires,\n players,\n lobby,\n persistent,\n spectators,\n boards,\n roles,\n teams,\n voice,\n modes\n } };\n}\n\n// ../contracts/src/overlay.ts\nvar OVERLAY_PANELS = ["home", "room", "invite", "friends", "voice", "boards"];\nfunction overlayConfiguration(manifest, coverUrl = null, invite = null) {\n const validated = validaManifest(manifest);\n if (!validated.ok) throw new Error("The overlay manifest is invalid.");\n return { manifest: validated.manifest, coverUrl, invite };\n}\nfunction record(value) {\n return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;\n}\nfunction validOverlayView(value) {\n const data = record(value);\n return data !== null && Object.keys(data).every((key) => ["inputBlocked", "reservedRects", "shortcutEnabled"].includes(key)) && (data.shortcutEnabled === void 0 || typeof data.shortcutEnabled === "boolean") && typeof data.inputBlocked === "boolean" && Array.isArray(data.reservedRects) && data.reservedRects.length <= 8 && data.reservedRects.every((value2) => {\n const rect = record(value2);\n return rect !== null && Object.keys(rect).length === 4 && ["x", "y", "width", "height"].every((key) => typeof rect[key] === "number" && Number.isFinite(rect[key]) && rect[key] >= 0 && rect[key] <= 1e5);\n });\n}\nfunction validOverlayRequest(value) {\n const message = record(value), args = record(message?.args);\n if (message?.type !== "caisual:overlay" || message.v !== 1 || typeof message.epoch !== "string" || message.epoch.length < 1 || message.epoch.length > 128 || typeof message.requestId !== "string" || !(/^[1-9][0-9]{0,15}$/.test(message.requestId) && Number.isSafeInteger(Number(message.requestId))) || args === null) return false;\n if (Object.keys(message).some((key) => !["type", "v", "epoch", "requestId", "sessionId", "op", "args"].includes(key)) || !(message.sessionId === void 0 || message.sessionId === null || typeof message.sessionId === "string" && /^[1-9][0-9]{0,15}$/.test(message.sessionId))) return false;\n const keys = (...allowed) => Object.keys(args).every((key) => allowed.includes(key));\n const text = (key) => typeof args[key] === "string" && args[key].length >= 1 && args[key].length <= 64;\n switch (message.op) {\n case "local.start":\n return keys("mode") && text("mode");\n case "room.create":\n return keys("mode") && (args.mode === null || text("mode"));\n case "room.join":\n return keys("code") && (args.code === void 0 || text("code"));\n case "room.watch":\n return keys("code") && text("code");\n case "room.match": {\n const key = record(args.key);\n return keys("mode", "key") && text("mode") && (args.key === void 0 || key !== null && Object.keys(key).length <= 8 && Object.values(key).every((v) => typeof v === "string" && v.length >= 1 && v.length <= 64 || typeof v === "number" && Number.isSafeInteger(v)));\n }\n case "room.ready":\n return keys("ready") && typeof args.ready === "boolean";\n case "room.role":\n case "room.requestRole":\n return keys("role") && text("role");\n case "room.team":\n return keys("team") && Number.isInteger(args.team) && args.team >= 1 && args.team <= 24;\n case "room.start":\n case "session.cancel":\n case "session.leave":\n case "session.disconnect":\n case "session.resume":\n return keys();\n case "voice.join":\n case "voice.leave":\n return keys() && typeof message.sessionId === "string";\n case "voice.mute":\n return keys("muted") && typeof args.muted === "boolean" && typeof message.sessionId === "string";\n case "voice.setVolume":\n return keys("playerId", "volume") && typeof message.sessionId === "string" && typeof args.playerId === "string" && args.playerId.length > 0 && args.playerId.length <= 128 && typeof args.volume === "number" && Number.isFinite(args.volume) && args.volume >= 0 && args.volume <= 1;\n case "overlay.view":\n return validOverlayView(args);\n default:\n return false;\n }\n}\nfunction validBoardDay(value) {\n if (typeof value !== "string" || !/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) return false;\n const at = Date.parse(`${value}T00:00:00Z`);\n return Number.isFinite(at) && new Date(at).toISOString().slice(0, 10) === value;\n}\nfunction validOverlaySessionState(value) {\n const data = record(value);\n const exact = (v, keys) => v !== null && Object.keys(v).length === keys.length && Object.keys(v).every((key) => keys.includes(key));\n const text = (v) => typeof v === "string" && v.length <= 128;\n const nullable = (v) => v === null || text(v);\n const finite = (v) => typeof v === "number" && Number.isFinite(v);\n if (!data || !exact(data, ["kind", "id", "mode", "localStatus", "ready", "capabilities", "room", "waiting", "resume", "resumeError", ..."voice" in data ? ["voice"] : []])) return false;\n if (data.voice !== void 0 && data.voice !== null && (data.kind !== "room" || !record(data.room) || !validOverlayVoice(data.voice))) return false;\n const capabilities = record(data.capabilities), room = record(data.room), waiting = record(data.waiting), resume = record(data.resume);\n if (!["boot", "home", "attaching", "matching", "local", "room", "watch"].includes(String(data.kind)) || !nullable(data.id) || !nullable(data.mode) || ![null, "playing", "ended"].includes(data.localStatus) || typeof data.ready !== "boolean" || typeof data.resumeError !== "boolean" || !exact(capabilities, ["local", "rooms", "overlay", "requestRole"]) || !Object.values(capabilities).every((v) => typeof v === "boolean")) return false;\n if (data.waiting !== null && (!exact(waiting, ["players", "min", "max"]) || !Object.values(waiting).every((v) => Number.isInteger(v) && Number(v) >= 0 && Number(v) <= 24))) return false;\n if (data.resume !== null && (!exact(resume, ["version", "code", "mode", "updatedAt"]) || resume.version !== 1 || !text(resume.code) || !nullable(resume.mode) || !finite(resume.updatedAt))) return false;\n if (data.room === null) return true;\n if (!exact(room, ["code", "mode", "status", "host", "you", "players", "countdownAt", "connection", "closedCode", "limits", "lobby", "persistent", "delayMs", "requestRole"]) || !room) return false;\n const limits = record(room.limits);\n return text(room.code) && nullable(room.mode) && nullable(room.host) && nullable(room.you) && ["lobby", "countdown", "playing", "ended"].includes(String(room.status)) && ["connecting", "connected", "reconnecting", "disconnected", "ended", "closed", "replaced"].includes(String(room.connection)) && ["countdownAt", "closedCode", "delayMs"].every((key) => room[key] === null || finite(room[key])) && ["lobby", "persistent", "requestRole"].every((key) => typeof room[key] === "boolean") && exact(limits, ["min", "max"]) && Object.values(limits).every((v) => Number.isInteger(v) && Number(v) >= 1 && Number(v) <= 24) && Array.isArray(room.players) && room.players.length <= 24 && room.players.every((value2) => {\n const player = record(value2);\n return exact(player, ["id", "name", "guest", "role", "team", "ready", "connected"]) && player !== null && text(player.id) && text(player.name) && nullable(player.role) && (player.team === null || Number.isInteger(player.team) && Number(player.team) >= 1 && Number(player.team) <= 24) && ["guest", "ready", "connected"].every((key) => typeof player[key] === "boolean");\n });\n}\nfunction validOverlayVoice(value) {\n const voice = record(value);\n if (!voice || Object.keys(voice).length !== 6 || !["mode", "state", "mic", "muted", "speaking", "peers"].every((key) => key in voice) || !["room", "team", "proximity"].includes(String(voice.mode)) || !["off", "joining", "on", "reconnecting"].includes(String(voice.state)) || !["mic", "muted", "speaking"].every((key) => typeof voice[key] === "boolean") || !Array.isArray(voice.peers) || voice.peers.length > 24) return false;\n const ids = /* @__PURE__ */ new Set();\n return voice.peers.every((value2) => {\n const peer = record(value2);\n if (!peer || Object.keys(peer).length !== 5 || !["id", "mic", "muted", "speaking", "volume"].every((key) => key in peer) || typeof peer.id !== "string" || !peer.id.length || peer.id.length > 128 || ids.has(peer.id) || !["mic", "muted", "speaking"].every((key) => typeof peer[key] === "boolean") || typeof peer.volume !== "number" || !Number.isFinite(peer.volume) || peer.volume < 0 || peer.volume > 1) return false;\n ids.add(peer.id);\n return true;\n });\n}\n\n// src/errors.ts\nfunction creaErrore(code, message) {\n return Object.assign(new Error(message), { name: "CaisualError", code });\n}\n\n// src/overlay/host-bridge.ts\nfunction eMessaggioReady(value) {\n return record(value)?.type === "caisual:ready";\n}\nfunction eRichiestaBiglietto(value) {\n const data = record(value);\n return data?.type === "caisual:ticket" && (data.aud === void 0 || data.aud === "portal" || data.aud === "live");\n}\nfunction stanzaDaMessaggio(value) {\n const data = record(value);\n if (data?.type !== "caisual:room") return void 0;\n if (data.room === null) return null;\n const room = record(data.room);\n return typeof room?.code === "string" && /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/.test(room.code) ? { code: room.code } : void 0;\n}\nfunction creaPonteOspite(input) {\n let port = null, epoch = null, instance = null;\n let disposed = false, legacyReady = true, sequence = 0, requestId = 0;\n let state = null, clockOffset = null;\n let polling = null, pollingEnd = null;\n const pending = /* @__PURE__ */ new Map();\n const states = /* @__PURE__ */ new Set();\n const shortcuts = /* @__PURE__ */ new Set();\n const opens = /* @__PURE__ */ new Set();\n const errors = /* @__PURE__ */ new Set();\n const scores = /* @__PURE__ */ new Set();\n const notify = (listeners, value) => {\n for (const listener of listeners) try {\n listener(value);\n } catch {\n }\n };\n const rejectPending = () => {\n for (const value of pending.values()) {\n input.finestra.clearTimeout(value.timer);\n value.reject(creaErrore("session_replaced", "The game document changed."));\n }\n pending.clear();\n };\n const stopPolling = () => {\n if (polling !== null) input.finestra.clearInterval(polling);\n if (pollingEnd !== null) input.finestra.clearTimeout(pollingEnd);\n polling = pollingEnd = null;\n };\n const askReady = () => {\n if (!disposed && input.frame.src !== "") input.frame.contentWindow?.postMessage({ type: "caisual:ready?" }, input.origineGioco);\n };\n const poll = () => {\n stopPolling();\n polling = input.finestra.setInterval(askReady, 500);\n pollingEnd = input.finestra.setTimeout(stopPolling, 1e4);\n askReady();\n };\n const loaded = () => {\n legacyReady = true;\n poll();\n };\n const listen = (event) => {\n if (disposed || event.origin !== input.origineGioco || event.source !== input.frame.contentWindow || !eMessaggioReady(event.data)) return;\n const data = record(event.data);\n const nextInstance = typeof data.instance === "string" && data.instance.length <= 128 ? data.instance : null;\n if (port && (nextInstance !== null ? nextInstance === instance : !legacyReady)) return;\n stopPolling();\n legacyReady = false;\n instance = nextInstance;\n rejectPending();\n port?.close();\n input.onRoom(null);\n epoch = input.epoch?.() ?? crypto.randomUUID();\n sequence = requestId = 0;\n state = null;\n clockOffset = null;\n notify(states, null);\n const channel = input.creaCanale?.() ?? new MessageChannel();\n const currentPort = channel.port1, currentEpoch = epoch;\n port = currentPort;\n const current = () => !disposed && port === currentPort && epoch === currentEpoch;\n currentPort.onmessage = (event2) => {\n if (!current()) return;\n const data2 = record(event2.data);\n if (eRichiestaBiglietto(data2)) {\n const aud = data2?.aud === "live" ? "live" : "portal";\n void input.rinnova(aud).then((ticket) => {\n if (current()) currentPort.postMessage({ type: "caisual:ticket", aud, ticket });\n }).catch(() => {\n if (current()) currentPort.postMessage({ type: "caisual:ticket", aud, error: "offline" });\n });\n return;\n }\n const room = stanzaDaMessaggio(data2);\n if (room !== void 0) {\n input.onRoom(room);\n return;\n }\n if (data2?.v !== 1 || data2.epoch !== currentEpoch) return;\n if (data2.type === "caisual:overlay-response" && typeof data2.requestId === "string") {\n const item = pending.get(data2.requestId);\n if (!item) return;\n if (data2.ok !== true && (data2.ok !== false || typeof record(data2.error)?.code !== "string" || typeof record(data2.error)?.message !== "string")) return;\n pending.delete(data2.requestId);\n input.finestra.clearTimeout(item.timer);\n const response = data2;\n if (response.ok) item.resolve();\n else item.reject(creaErrore(response.error.code, response.error.message));\n } else if (data2.type === "caisual:overlay-state" && Number.isSafeInteger(data2.seq) && data2.seq > sequence) {\n if (!validOverlaySessionState(data2.state) || typeof data2.serverTime !== "number" || !Number.isFinite(data2.serverTime)) return;\n clockOffset = data2.serverTime - Date.now();\n sequence = data2.seq;\n state = structuredClone(data2.state);\n notify(states, state);\n } else if (data2.type === "caisual:overlay-error" && data2.sessionId === state?.id) {\n const error = record(data2.error);\n if (typeof error?.code === "string" && typeof error.message === "string") notify(errors, { sessionId: data2.sessionId, error: { code: error.code, message: error.message } });\n } else if (data2.type === "caisual:overlay-shortcut") {\n notify(shortcuts, void 0);\n } else if (data2.type === "caisual:overlay-open" && OVERLAY_PANELS.includes(data2.panel)) {\n notify(opens, data2.panel);\n } else if (data2.type === "caisual:overlay-score") {\n const score = record(data2.score);\n if (score && typeof score.board === "string" && typeof score.player === "string" && Number.isSafeInteger(score.score) && Number.isFinite(score.submittedAt) && (score.day === null || typeof score.day === "string")) {\n notify(scores, { board: score.board, player: score.player, score: score.score, day: score.day, submittedAt: score.submittedAt });\n }\n }\n };\n currentPort.start();\n input.frame.contentWindow?.postMessage({\n type: "caisual:hello",\n ticket: input.ticket,\n live: input.origineLive,\n invite: input.invite,\n ...input.configuration && data.overlayVersion === 1 ? { overlay: { v: 1, epoch, configuration: input.configuration } } : {}\n }, input.origineGioco, [channel.port2]);\n };\n input.finestra.addEventListener("message", listen);\n input.frame.addEventListener?.("load", loaded);\n poll();\n return {\n get epoch() {\n return epoch;\n },\n serverTime() {\n return clockOffset === null ? null : Date.now() + clockOffset;\n },\n get state() {\n return state === null ? null : structuredClone(state);\n },\n subscribe(listener) {\n states.add(listener);\n listener(state);\n return () => {\n states.delete(listener);\n };\n },\n onShortcut(listener) {\n shortcuts.add(listener);\n return () => {\n shortcuts.delete(listener);\n };\n },\n onOpen(listener) {\n opens.add(listener);\n return () => {\n opens.delete(listener);\n };\n },\n onError(listener) {\n errors.add(listener);\n return () => {\n errors.delete(listener);\n };\n },\n onScore(listener) {\n scores.add(listener);\n return () => {\n scores.delete(listener);\n };\n },\n request(op, args) {\n if (!port || !epoch || disposed) return Promise.reject(creaErrore("offline", "The game bridge is not connected."));\n if (pending.size >= 32) return Promise.reject(creaErrore("rate_limited", "Too many overlay requests."));\n const id = String(++requestId), request = {\n type: "caisual:overlay",\n v: 1,\n epoch,\n requestId: id,\n op,\n args,\n ...["room.ready", "room.role", "room.requestRole", "room.team", "room.start", "session.leave", "session.disconnect", "voice.join", "voice.mute", "voice.leave", "voice.setVolume"].includes(op) ? { sessionId: state?.id ?? null } : {}\n };\n if (!validOverlayRequest(request)) return Promise.reject(creaErrore("invalid_request", "The overlay request is invalid."));\n return new Promise((resolve, reject) => {\n const timeout = op === "room.match" ? 31e4 : input.requestTimeoutMs ?? 15e3;\n const timer = input.finestra.setTimeout(() => {\n pending.delete(id);\n reject(creaErrore("timeout", "The overlay request timed out."));\n }, timeout);\n pending.set(id, { resolve, reject, timer });\n try {\n port.postMessage(request);\n } catch (error) {\n input.finestra.clearTimeout(timer);\n pending.delete(id);\n reject(error);\n }\n });\n },\n dispose() {\n disposed = true;\n stopPolling();\n rejectPending();\n port?.close();\n port = null;\n input.finestra.removeEventListener("message", listen);\n input.frame.removeEventListener?.("load", loaded);\n states.clear();\n opens.clear();\n shortcuts.clear();\n scores.clear();\n errors.clear();\n }\n };\n}\nfunction avviaHandshake(input) {\n const bridge = creaPonteOspite(input);\n return () => bridge.dispose();\n}\n\n// src/overlay/boards.ts\nfunction createBoardController(input) {\n let disposed = false, generation = 0, timer;\n const seen = /* @__PURE__ */ new Set();\n let query = null, data = null, error = false, loading = false;\n let queued = null, saving = null, reads = 0;\n const later = input.later ?? setTimeout, clear = input.clear ?? clearTimeout;\n const cancel = () => {\n if (timer !== void 0) clear(timer);\n timer = void 0;\n };\n const notify = () => {\n if (!disposed) input.changed();\n };\n const matches = () => queued && query?.board === queued.board && query.period === (queued.day ? "daily" : "all-time") && (query.day ?? queued.day) === queued.day;\n const refresh = async () => {\n if (!query || disposed) return;\n cancel();\n const current = ++generation, selected = { ...query };\n loading = true;\n error = false;\n notify();\n try {\n const result = await input.read(selected);\n if (disposed || current !== generation) return;\n data = result;\n if (matches()) {\n const own = result.me;\n if (own?.verified && own.score >= queued.score) saving = own.score === queued.score ? "saved" : "bestAlready";\n }\n } catch {\n if (!disposed && current === generation) error = true;\n }\n if (disposed || current !== generation) return;\n loading = false;\n if (matches() && saving !== "saved" && saving !== "bestAlready") {\n reads++;\n if (reads < 4) {\n saving = "saving";\n timer = later(() => {\n void refresh();\n }, [800, 1600, 3200][reads - 1]);\n } else saving = "refreshHint";\n }\n notify();\n };\n return {\n get state() {\n return { query, data, loading, error, saving: matches() ? saving : null };\n },\n select(next) {\n if (JSON.stringify(next) === JSON.stringify(query)) return;\n cancel();\n generation++;\n query = { ...next };\n data = null;\n reads = 0;\n if (matches()) saving = "saving";\n void refresh();\n },\n queued(score) {\n const board = input.manifest.boards[score.board];\n if (score.player !== input.player || !board || !Number.isSafeInteger(score.score) || score.score < 0 || score.day !== null && !validBoardDay(score.day) || !(board.periods ?? ["all-time"]).includes(score.day ? "daily" : "all-time")) return;\n const signature = JSON.stringify(score);\n if (seen.has(signature)) return;\n seen.add(signature);\n if (seen.size > 64) seen.delete(seen.values().next().value);\n queued = score;\n saving = "saving";\n reads = 0;\n this.select({ board: score.board, period: score.day ? "daily" : "all-time", guests: query?.guests ?? input.guests ?? false, ...score.day ? { day: score.day } : {} });\n if (!loading) void refresh();\n notify();\n },\n refresh,\n reset() {\n seen.clear();\n cancel();\n generation++;\n query = null;\n data = null;\n queued = null;\n saving = null;\n loading = false;\n error = false;\n },\n dispose() {\n disposed = true;\n cancel();\n generation++;\n }\n };\n}\n\n// src/overlay/i18n.ts\nvar languages = ["en", "it", "es", "fr", "de", "pt"];\nvar words = {\n loading: ["Loading game...", "Caricamento...", "Cargando...", "Chargement...", "Spiel wird geladen...", "Carregando..."],\n home: ["Play", "Gioca", "Jugar", "Jouer", "Spielen", "Jogar"],\n mode: ["Mode", "Modalit\\xE0", "Modo", "Mode", "Modus", "Modo"],\n play: ["Play", "Gioca", "Jugar", "Jouer", "Spielen", "Jogar"],\n friendsPlay: ["Play with friends", "Gioca con amici", "Jugar con amigos", "Jouer entre amis", "Mit Freunden spielen", "Jogar com amigos"],\n find: ["Find players", "Trova giocatori", "Buscar jugadores", "Trouver des joueurs", "Spieler finden", "Buscar jogadores"],\n join: ["Join with code", "Entra con codice", "Entrar con c\\xF3digo", "Rejoindre avec un code", "Mit Code beitreten", "Entrar com c\\xF3digo"],\n joinInvite: ["Join this room", "Entra in questa stanza", "Entrar en est\\xE1 sala", "Rejoindre cette salle", "Diesem Raum beitreten", "Entrar nest\\xE1 sala"],\n watch: ["Watch a room", "Guarda una stanza", "Observar una sala", "Regarder une salle", "Raum ansehen", "Assistir a uma sala"],\n resume: ["Resume", "Riprendi", "Continuar", "Reprendre", "Fortsetzen", "Continuar"],\n room: ["Room", "Stanza", "Sala", "Salle", "Raum", "Sala"],\n code: ["Room code", "Codice stanza", "C\\xF3digo de sala", "Code de salle", "Raumcode", "C\\xF3digo da sala"],\n copy: ["Copy invite", "Copia invito", "Copiar invitaci\\xF3n", "Copier le lien", "Einladung kopieren", "Copiar convite"],\n copied: ["Invite copied", "Invito copiato", "Invitacion copiada", "Lien copi\\xE9", "Einladung kopiert", "Convite copiado"],\n copyFailed: ["Copy this link:", "Copia questo link:", "Copia este enlace:", "Copiez ce lien :", "Diesen Link kopieren:", "Copie este link:"],\n joining: ["Joining room...", "Ingresso nella stanza...", "Entrando en la sala...", "Connexion \\xE0 la salle...", "Raum wird betreten...", "Entrando na sala..."],\n matching: ["Finding your people...", "Ricerca giocatori...", "Buscando jugadores...", "Recherche de joueurs...", "Spieler werden gesucht...", "Buscando jogadores..."],\n queue: ["{n} / {max} players", "{n} / {max} giocatori", "{n} / {max} jugadores", "{n} / {max} joueurs", "{n} / {max} Spieler", "{n} / {max} jogadores"],\n cancel: ["Cancel", "Annulla", "Cancelar", "Annuler", "Abbrechen", "Cancelar"],\n close: ["Close", "Chiudi", "Cerrar", "Fermer", "Schlie\\xDFen", "Fechar"],\n back: ["Back", "Indietro", "Volver", "Retour", "Zur\\xFCck", "Voltar"],\n ready: ["Ready", "Pronto", "Listo", "Pr\\xEAt", "Bereit", "Pronto"],\n unready: ["Not ready", "Non pronto", "No listo", "Pas pr\\xEAt", "Nicht bereit", "N\\xE3o pronto"],\n start: ["Start", "Inizia", "Empezar", "Commencer", "Starten", "Come\\xE7ar"],\n role: ["Role", "Ruolo", "Rol", "R\\xF4le", "Rolle", "Fun\\xE7\\xE3o"],\n team: ["Team", "Squadra", "Equipo", "\\xC9quipe", "Team", "Equipe"],\n host: ["Host", "Host", "Anfitrion", "H\\xF4te", "Host", "Anfitri\\xE3o"],\n you: ["You", "Tu", "T\\xFA", "Vous", "Du", "Voc\\xEA"],\n away: ["Away", "Assente", "Ausente", "Absent", "Abwesend", "Ausente"],\n needPlayers: ["Waiting for more players", "In attesa di giocatori", "Esperando m\\xE1s jugadores", "En attente de joueurs", "Weitere Spieler fehlen", "Esperando mais jogadores"],\n needReady: ["Everyone needs to be ready", "Tutti devono essere pronti", "Todos deben estar listos", "Tout le monde doit \\xEAtre pr\\xEAt", "Alle m\\xFCssen bereit sein", "Todos precisam estar prontos"],\n needRoles: ["Fill the required roles", "Completa i ruoli richiesti", "Completa los roles", "Compl\\xE9tez les r\\xF4les", "Ben\\xF6tigte Rollen besetzen", "Complete as fun\\xE7\\xF5es"],\n needTeams: ["Choose the required teams", "Scegli le squadre richieste", "Elige los equipos", "Choisissez les \\xE9quipes", "Teams auswahlen", "Escolha as equipes"],\n waitHost: ["Waiting for the host", "In attesa dell\'host", "Esperando al anfitrion", "En attente de l\\u2019h\\xF4te", "Warten auf den Host", "Esperando o anfitri\\xE3o"],\n starting: ["Starting in", "Si inizia tra", "Empieza en", "D\\xE9but dans", "Start in", "Come\\xE7a em"],\n playing: ["Playing", "In partita", "Jugando", "En jeu", "Im Spiel", "Jogando"],\n ended: ["Game finished", "Partita conclusa", "Partida terminada", "Partie termin\\xE9e", "Spiel beendet", "Partida encerrada"],\n again: ["Play again", "Gioca ancora", "Jugar de nuevo", "Rejouer", "Erneut spielen", "Jogar novamente"],\n newRoom: ["New room. Share the new invite.", "Nuova stanza. Condividi il nuovo invito.", "Nueva sala. Comparte la invitaci\\xF3n.", "Nouvelle salle. Partagez le lien.", "Neuer Raum. Neue Einladung teilen.", "Nova sala. Compartilhe o convite."],\n watching: ["Watching", "In osservazione", "Observando", "Spectateur", "Zuschauen", "Assistindo"],\n delay: ["{n}s delay", "Ritardo {n}s", "Retraso de {n}s", "Retard de {n}s", "{n}s Verz\\xF6gerung", "Atraso de {n}s"],\n exit: ["Exit", "Esci", "Salir", "Quitter", "Verlassen", "Sair"],\n leaveNow: ["Leave for now", "Esci per ora", "Salir por ahora", "Quitter pour le moment", "Vor\\xFCbergehend verlassen", "Sair por enquanto"],\n leaveRoom: ["Leave room", "Lascia la stanza", "Abandonar sala", "Abandonner la salle", "Raum verlassen", "Deixar a sala"],\n leaveHint: ["Your room stays available for Resume.", "La stanza resta disponibile con Riprendi.", "Podr\\xE1s volver a est\\xE1 sala.", "Vous pourrez reprendre cette salle.", "Du kannst den Raum fortsetzen.", "Voc\\xEA pode voltar a est\\xE1 sala."],\n temporaryHint: ["The game continues. Rejoining may only be possible briefly.", "La partita continua. Il rientro pu\\xF2 essere disponibile solo per poco.", "La partida continua. Volver puede ser posible solo por poco tiempo.", "La partie continue. Le retour peut \\xEAtre limit\\xE9.", "Das Spiel l\\xE4uft weiter. R\\xFCckkehr nur kurz m\\xF6glich.", "A partida continua. O retorno pode ser limitado."],\n abandonHint: ["Leave room gives up your place.", "Lascia la stanza libera il tuo posto.", "Abandonar libera tu plaza.", "Abandonner lib\\xE8re votre place.", "Raum verlassen gibt deinen Platz frei.", "Deixar a sala libera sua vaga."],\n reconnecting: ["Reconnecting...", "Riconnessione...", "Reconectando...", "Reconnexion...", "Verbindung wird erneuert...", "Reconectando..."],\n replaced: ["Opened in another tab", "Aperta in un\\u2019altra scheda", "Abierta en otra pest\\xE1na", "Ouverte dans un autre onglet", "In anderem Tab ge\\xF6ffnet", "Aberta em outra aba"],\n error: ["Something went wrong. Try again.", "Qualcosa non va. Riprova.", "Algo sali\\xF3 mal. Reintenta.", "Une erreur est survenue. R\\xE9essayez.", "Etwas ist schiefgelaufen. Erneut versuchen.", "Algo deu errado. Tente novamente."],\n noRoom: ["This room is no longer available.", "Questa stanza non \\xE8 pi\\xF9 disponibile.", "Esta sala ya no est\\xE1 disponible.", "Cette salle n\'est plus disponible.", "Dieser Raum ist nicht mehr verf\\xFCgbar.", "Esta sala n\\xE3o est\\xE1 mais disponivel."],\n full: ["This room is full.", "La stanza \\xE8 piena.", "La sala est\\xE1 llena.", "Cette salle est pleine.", "Dieser Raum ist voll.", "Esta sala est\\xE1 cheia."],\n noMatch: ["No match this time. Try again.", "Nessun gruppo trovato. Riprova.", "No hay grupo. Reintenta.", "Aucun groupe trouv\\xE9. R\\xE9essayez.", "Keine Gruppe gefunden. Erneut versuchen.", "Nenhum grupo encontrado. Tente novamente."],\n invalidCode: ["Enter a six-character room code.", "Inserisci un codice di sei caratteri.", "Escribe un c\\xF3digo de seis caracteres.", "Entrez un code de six caracteres.", "Sechsstelligen Raumcode eingeben.", "Digite um c\\xF3digo de seis caracteres."],\n refused: ["The room did not accept that change.", "La stanza ha rifiutato la modifica.", "La sala rechaz\\xF3 el cambio.", "La salle a refus\\xE9 ce changement.", "Der Raum hat die \\xC4nderung abgelehnt.", "A sala recusou a altera\\xE7\\xE3o."],\n unavailable: ["Unavailable right now", "Non disponibile ora", "No disponible ahora", "Indisponible pour le moment", "Derzeit nicht verf\\xFCgbar", "Indisponivel agora"],\n offline: ["Connection unavailable. Try again.", "Connessione non disponibile. Riprova.", "Sin conexi\\xF3n. Reintenta.", "Connexion indisponible. R\\xE9essayez.", "Keine Verbindung. Erneut versuchen.", "Sem conex\\xE3o. Tente novamente."],\n saveFailed: ["Keep the room code. Resume could not be saved.", "Conserva il codice. Riprendi non \\xE8 stato salvato.", "Guarda el c\\xF3digo. No se pudo guardar el regreso.", "Gardez le code. La reprise ne peut pas \\xEAtre enregistr\\xE9e.", "Raumcode aufbewahren. Fortsetzen nicht gespeichert.", "Guarde o c\\xF3digo. O retorno n\\xE3o foi salvo."],\n boards: ["Leaderboard", "Classifica", "Clasificaci\\xF3n", "Classement", "Bestenliste", "Classifica\\xE7\\xE3o"],\n board: ["Board", "Classifica", "Tabla", "Classement", "Bestenliste", "Tabela"],\n daily: ["Daily", "Giornaliera", "Diaria", "Du jour", "T\\xE4glich", "Di\\xE1ria"],\n allTime: ["All time", "Di sempre", "Hist\\xF3rica", "Tous les temps", "Gesamt", "Geral"],\n accounts: ["Accounts", "Account", "Cuentas", "Comptes", "Konten", "Contas"],\n guests: ["Guests", "Ospiti", "Invitados", "Invit\\xE9s", "G\\xE4ste", "Visitantes"],\n category: ["Category", "Categoria", "Categoria", "Cat\\xE9gorie", "Kategorie", "Categoria"],\n period: ["Period", "Periodo", "Per\\xEDodo", "P\\xE9riode", "Zeitraum", "Per\\xEDodo"],\n rank: ["Rank", "Posizione", "Puesto", "Rang", "Platz", "Posicao"],\n score: ["Score", "Punteggio", "Puntos", "Score", "Punkte", "Pontos"],\n verified: ["Verified", "Verificato", "Verificado", "V\\xE9rifi\\xE9", "Verifiziert", "Verificado"],\n own: ["Your best", "Il tuo record", "Tu record", "Votre record", "Dein Rekord", "Seu recorde"],\n empty: ["No scores yet", "Nessun punteggio", "A\\xFAn no hay puntos", "Aucun score", "Noch keine Punkte", "Ainda sem pontos"],\n saving: ["Saving score...", "Salvataggio punteggio...", "Guardando puntos...", "Enregistrement du score...", "Punkte werden gespeichert...", "Salvando pontos..."],\n saved: ["Your best is on the board", "Il tuo record \\xE8 in classifica", "Tu record est\\xE1 en la tabla", "Votre record est au classement", "Dein Rekord ist eingetragen", "Seu recorde est\\xE1 na tabela"],\n bestAlready: ["Your best is already on the board", "Il tuo record era gi\\xE0 in classifica", "Tu record ya estaba en la tabla", "Votre record est d\\xE9j\\xE0 au classement", "Dein Rekord ist bereits eingetragen", "Seu recorde j\\xE1 est\\xE1 na tabela"],\n refresh: ["Refresh", "Aggiorna", "Actualizar", "Actualiser", "Aktualisieren", "Atualizar"],\n refreshHint: ["Score not visible yet. Refresh to check.", "Punteggio non ancora visibile. Aggiorna per controllare.", "Puntos a\\xFAn no visibles. Actualiza.", "Score pas encore visible. Actualisez.", "Punkte noch nicht sichtbar. Aktualisieren.", "Pontos ainda n\\xE3o visiveis. Atualize."],\n friends: ["Friends & party", "Amici e gruppo", "Amigos y grupo", "Amis et groupe", "Freunde & Gruppe", "Amigos e grupo"],\n localCrew: ["Friends and party are unavailable in local preview.", "Amici e gruppo non disponibili in anteprima locale.", "Amigos y grupo no disponibles en la vista local.", "Amis et groupe indisponibles en aper\\xE7u local.", "Freunde und Gruppe in lokaler Vorschau nicht verf\\xFCgbar.", "Amigos e grupo indispon\\xEDveis na pr\\xE9via local."],\n loginCrew: ["Sign in on Caisual to use friends and party.", "Accedi a Caisual per amici e gruppo.", "Inicia sesion para amigos y grupo.", "Connectez-vous pour utiliser amis et groupe.", "F\\xFCr Freunde und Gruppe bei Caisual anmelden.", "Entre no Caisual para amigos e grupo."],\n online: ["Online", "Online", "En linea", "En ligne", "Online", "Online"],\n noFriends: ["No friends online", "Nessun amico online", "Sin amigos en linea", "Aucun ami en ligne", "Keine Freunde online", "Nenhum amigo online"],\n createParty: ["Create party", "Crea gruppo", "Crear grupo", "Cr\\xE9er un groupe", "Gruppe erstellen", "Criar grupo"],\n inviteParty: ["Invite to party", "Invita nel gruppo", "Invitar al grupo", "Inviter au groupe", "In Gruppe einladen", "Convidar para o grupo"],\n leaveParty: ["Leave party", "Lascia gruppo", "Salir del grupo", "Quitter le groupe", "Gruppe verlassen", "Sair do grupo"],\n accept: ["Accept", "Accetta", "Aceptar", "Accepter", "Annehmen", "Aceitar"],\n decline: ["Decline", "Rifiuta", "Rechazar", "Refuser", "Ablehnen", "Recusar"],\n follow: ["Join them", "Raggiungi", "Unirse", "Rejoindre", "Beitreten", "Juntar-se"],\n voice: ["Voice", "Voce", "Voz", "Voix", "Sprache", "Voz"],\n voiceJoin: ["Join voice", "Entra in voce", "Unirse a voz", "Activer la voix", "Sprachchat beitreten", "Entrar na voz"],\n voiceLeave: ["Leave voice", "Esci dalla voce", "Salir de voz", "Quitter la voix", "Sprachchat verlassen", "Sair da voz"],\n voiceMute: ["Mute", "Disattiva microfono", "Silenciar", "Couper le micro", "Stummschalten", "Silenciar"],\n voiceUnmute: ["Unmute", "Attiva microfono", "Activar micr\\xF3fono", "Activer le micro", "Mikrofon aktivieren", "Ativar microfone"],\n voiceOff: ["Voice off", "Voce disattivata", "Voz desactivada", "Voix d\\xE9sactiv\\xE9e", "Sprachchat aus", "Voz desativada"],\n voiceJoining: ["Joining voice...", "Connessione voce...", "Conectando voz...", "Connexion vocale...", "Sprachchat verbindet...", "Conectando voz..."],\n voiceOn: ["Voice connected", "Voce connessa", "Voz conectada", "Voix connect\\xE9e", "Sprachchat verbunden", "Voz conectada"],\n voiceMuted: ["Muted", "Microfono disattivato", "Silenciado", "Micro coup\\xE9", "Stumm", "Silenciado"],\n voiceMic: ["Mic on", "Microfono attivo", "Micr\\xF3fono activo", "Micro actif", "Mikrofon an", "Microfone ativo"],\n voiceListening: ["Listening only", "Solo ascolto", "Solo escucha", "\\xC9coute seule", "Nur zuh\\xF6ren", "Somente ouvindo"],\n voiceSpeaking: ["Speaking", "Sta parlando", "Hablando", "Parle", "Spricht", "Falando"],\n voicePeers: ["Voice participants", "Partecipanti in voce", "Participantes de voz", "Participants vocaux", "Sprachteilnehmer", "Participantes de voz"],\n voiceEmpty: ["No one else in voice yet.", "Nessun altro in voce per ora.", "A\\xFAn no hay nadie m\\xE1s en voz.", "Personne d\\u2019autre en voix pour le moment.", "Noch niemand im Sprachchat.", "Ningu\\xE9m mais na voz ainda."],\n voiceVolume: ["Volume for {name}", "Volume di {name}", "Volumen de {name}", "Volume de {name}", "Lautst\\xE4rke f\\xFCr {name}", "Volume de {name}"],\n voiceUnavailable: ["Join a room with voice to use these controls.", "Entra in una stanza con voce per usare questi controlli.", "Entra en una sala con voz para usar estos controles.", "Rejoignez une salle vocale pour utiliser ces commandes.", "Diese Steuerung braucht einen Raum mit Sprachchat.", "Entre em uma sala com voz para usar estes controles."],\n voiceWatch: ["Voice is unavailable while watching.", "La voce non e\' disponibile in osservazione.", "La voz no est\\xE1 disponible al observar.", "La voix est indisponible en observation.", "Beim Zuschauen ist kein Sprachchat verf\\xFCgbar.", "A voz n\\xE3o est\\xE1 dispon\\xEDvel ao assistir."],\n voiceDenied: ["Microphone permission denied. Allow it in your browser, then try again.", "Permesso microfono negato. Consenti l\'accesso nel browser e riprova.", "Permiso de micr\\xF3fono denegado. Act\\xEDvalo en el navegador e int\\xE9ntalo de nuevo.", "Acc\\xE8s au micro refus\\xE9. Autorisez-le dans le navigateur, puis r\\xE9essayez.", "Mikrofonzugriff verweigert. Im Browser erlauben und erneut versuchen.", "Permiss\\xE3o do microfone negada. Permita no navegador e tente novamente."],\n voiceUnsupported: ["Voice is not supported in this browser.", "Questo browser non supporta la voce.", "Este navegador no admite voz.", "Ce navigateur ne prend pas en charge la voix.", "Dieser Browser unterst\\xFCtzt keinen Sprachchat.", "Este navegador n\\xE3o oferece suporte a voz."],\n voiceFailed: ["Voice could not connect. Try again.", "Connessione voce non riuscita. Riprova.", "No se pudo conectar la voz. Int\\xE9ntalo de nuevo.", "Connexion vocale impossible. R\\xE9essayez.", "Sprachverbindung fehlgeschlagen. Erneut versuchen.", "N\\xE3o foi poss\\xEDvel conectar a voz. Tente novamente."],\n voicePeerGone: ["This participant has left voice.", "Questo partecipante e\' uscito dalla voce.", "Este participante sali\\xF3 de voz.", "Ce participant a quitt\\xE9 la voix.", "Diese Person hat den Sprachchat verlassen.", "Este participante saiu da voz."],\n shortcut: ["Shift+Tab shortcut", "Scorciatoia Shift+Tab", "Atajo Shift+Tab", "Raccourci Maj+Tab", "Umschalt+Tab-Kurzbefehl", "Atalho Shift+Tab"],\n menu: ["Caisual menu", "Menu Caisual", "Menu Caisual", "Menu Caisual", "Caisual-Menu", "Menu Caisual"],\n retry: ["Retry", "Riprova", "Reintentar", "R\\xE9essayer", "Erneut versuchen", "Tentar novamente"]\n};\nvar column = (index) => Object.fromEntries(Object.entries(words).map(([key, row]) => [key, row[index]]));\nvar dictionaries = { en: column(0), it: column(1), es: column(2), fr: column(3), de: column(4), pt: column(5) };\nfunction overlayLanguage(raw) {\n const value = raw?.toLowerCase().split("-")[0];\n return languages.includes(value) ? value : "en";\n}\nfunction translator(language) {\n const dictionary = dictionaries[overlayLanguage(language)];\n return (key, values = {}) => dictionary[key].replace(/\\{(\\w+)\\}/g, (_all, name) => String(values[name] ?? ""));\n}\nfunction errorText(code) {\n if (code === "permission_denied") return "voiceDenied";\n if (code === "unsupported") return "voiceUnsupported";\n if (code === "voice_disabled") return "voiceUnavailable";\n if (code === "voice_error") return "voiceFailed";\n if (code === "voice_peer_missing") return "voicePeerGone";\n if (code === "not_publishing") return "voiceListening";\n if (["room_not_found", "room_ended", "version_closed", "no_resume"].includes(code)) return "noRoom";\n if (["room_full", "role_full"].includes(code)) return "full";\n if (code === "replaced") return "replaced";\n if (code === "no_match") return "noMatch";\n if (code === "invalid_code") return "invalidCode";\n if (["offline", "timeout"].includes(code)) return "offline";\n if (code.startsWith("role_") || ["not_in_lobby", "not_host", "session_replaced"].includes(code)) return "refused";\n if (code === "save_failed") return "saveFailed";\n return "error";\n}\n\n// src/overlay/ui-model.ts\nfunction phase(session) {\n if (!session || session.kind === "boot") return "boot";\n if (session.kind === "attaching" || session.kind === "matching") return session.kind;\n if (session.room && ["closed", "replaced"].includes(session.room.connection)) return "error";\n if (session.kind === "local") return session.localStatus === "ended" ? "ended" : "playing";\n if (session.room?.status === "ended") return "ended";\n if (session.kind === "watch") return "watching";\n if (session.kind === "room" && session.room) return session.room.status;\n return "home";\n}\nfunction initialUi(manifest) {\n return { session: null, panel: "auto", mode: manifest.modes[0]?.id ?? "", busy: false, error: null, notice: null, shortcutEnabled: true };\n}\nfunction reduceUi(model, action) {\n switch (action.type) {\n case "session": {\n const next = action.session, changed = next?.id !== model.session?.id || next === null;\n const pending = next?.kind === "attaching" || next?.kind === "matching";\n const nextPhase = phase(next), transition = phase(model.session) !== nextPhase;\n const automatic = transition && ["countdown", "playing", "ended"].includes(nextPhase) && [null, "auto", "room", "invite", "home"].includes(model.panel);\n return {\n ...model,\n session: next,\n mode: next?.mode ?? model.mode,\n panel: changed || pending || automatic ? "auto" : model.panel,\n error: changed ? null : model.error,\n notice: changed ? null : model.notice\n };\n }\n case "panel":\n return { ...model, panel: action.panel, error: null, notice: null };\n case "mode":\n return { ...model, mode: action.mode, error: null };\n case "busy":\n return { ...model, busy: action.busy };\n case "error":\n return { ...model, error: action.code, busy: false };\n case "notice":\n return { ...model, notice: action.notice };\n case "shortcut":\n return { ...model, shortcutEnabled: action.enabled };\n }\n}\nfunction visiblePanel(model) {\n const current = phase(model.session);\n if (current === "boot" || current === "attaching" || current === "matching" || current === "error") return current;\n if (model.panel !== "auto") return model.panel;\n return current === "home" ? "home" : current === "lobby" ? "room" : current === "countdown" ? "countdown" : null;\n}\nfunction primaryAction(manifest, mode) {\n const selected = manifest.modes.find((item) => item.id === mode);\n if (!selected) return null;\n return {\n op: selected.execution === "local" ? "local.start" : "room.create",\n friends: selected.execution === "room" && risolviModalita(manifest, mode).players.max > 1\n };\n}\nfunction startReason(manifest, session) {\n const room = session?.room;\n if (!room || session.kind !== "room" || room.status !== "lobby" || room.connection !== "connected") return "unavailable";\n const connected = room.players.filter((p) => p.connected), active = connected.filter((p) => p.role !== "spectator");\n if (active.length < room.limits.min) return "needPlayers";\n if (connected.some((p) => !p.ready)) return "needReady";\n if (manifest.roles.some((role) => active.filter((p) => p.role === role.id).length < role.min)) return "needRoles";\n if (manifest.teams && (active.some((p) => p.team === null) || new Set(active.map((p) => p.team)).size < manifest.teams.min)) return "needTeams";\n return room.host !== room.you ? "waitHost" : null;\n}\nfunction canPlayAgain(session) {\n if (phase(session) !== "ended") return false;\n if (session?.kind === "local") return true;\n return session?.kind === "room" && (!session.room?.lobby || session.room.host === session.room.you);\n}\nfunction normalizeInvite(code) {\n const value = code.toUpperCase().replace(/[\\s-]/g, "");\n return /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/.test(value) ? value : null;\n}\n\n// src/overlay/styles.ts\nvar styles = `\n:host{all:initial;position:fixed;inset:0;z-index:10000;pointer-events:none;font:15px/1.45 system-ui,sans-serif;color:#f4f4f1;color-scheme:dark;--accent:#a8efc5}\n[data-layout],[data-surface],.sr{pointer-events:none}*{box-sizing:border-box}button,input,select{font:inherit}button,a,input,select{touch-action:manipulation}button,select,input{border:1px solid #ffffff30;background:#25292b;color:inherit;border-radius:12px;min-height:44px;padding:10px 14px}button{cursor:pointer}button:disabled{opacity:.45;cursor:default}button:hover:not(:disabled){background:#343b3a}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid var(--accent);outline-offset:3px}a{color:var(--accent)}.primary{background:var(--accent);color:var(--accent-ink,#11221b);border-color:transparent;font-weight:700}.primary:hover:not(:disabled){filter:brightness(1.1);background:var(--accent)}.quiet{background:transparent}label{display:grid;gap:6px;text-align:left}select,input{width:100%;min-width:0}h1,h2,p{margin:0}h1{font-size:clamp(26px,5vw,42px);line-height:1.1;letter-spacing:-.035em}h2{font-size:20px}small,.muted{color:#bdc5c1}.stack{display:grid;gap:16px}.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.row>*{flex:0 1 auto}.row .grow,.grow{flex:1}.split{display:grid;grid-template-columns:1fr 1fr;gap:10px}.pill{position:absolute;top:max(10px,env(safe-area-inset-top));right:max(10px,env(safe-area-inset-right));display:flex;height:44px;border:1px solid #ffffff35;border-radius:24px;background:#171e20eF;box-shadow:0 4px 20px #0004;pointer-events:auto;overflow:hidden}.pill button{border:0;border-radius:0;padding:8px 13px;background:transparent}.pill .exit{width:44px;border-left:1px solid #ffffff30}.pill button:focus-visible{outline-offset:-4px}.pill small{margin-left:8px}.backdrop{position:absolute;inset:0;background:#0b151ce8;backdrop-filter:blur(10px);pointer-events:auto;display:grid;place-items:center;padding:max(16px,env(safe-area-inset-top)) max(16px,env(safe-area-inset-right)) max(16px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left));overflow:auto}.backdrop.home{background-color:#142127;background-size:cover;background-position:center}.dialog{position:relative;width:min(100%,540px);max-height:100%;overflow:auto;overscroll-behavior:contain;background:#141b1df5;border:1px solid #ffffff25;border-radius:22px;padding:24px;box-shadow:0 20px 80px #0005}.dialog.wide{width:min(100%,700px)}.top{display:flex;align-items:center;gap:12px;margin-bottom:18px}.top h2{flex:1}.tabs{display:flex;gap:6px;flex-wrap:wrap;border-bottom:1px solid #ffffff25;padding-bottom:12px}.tabs button{min-height:36px;padding:6px 10px}.tabs [aria-current=true]{border-color:var(--accent)}.roster{list-style:none;padding:0;margin:0;display:grid;gap:8px;max-height:32dvh;overflow:auto}.roster li{display:flex;align-items:center;gap:8px;padding:10px;background:#ffffff08;border-radius:10px}.roster .name{flex:1;overflow-wrap:anywhere}.badge{border:1px solid #ffffff30;border-radius:6px;padding:2px 6px;font-size:12px}.code{font-size:24px;letter-spacing:.13em;font-variant-numeric:tabular-nums}.notice,.error{border-radius:10px;padding:10px;background:#a8efc514;overflow-wrap:anywhere}.error{background:#ff8b7720;color:#ffd2c9}.countdown{font-size:88px;line-height:1;text-align:center;font-variant-numeric:tabular-nums}.ended{position:absolute;bottom:max(12px,env(safe-area-inset-bottom));left:50%;transform:translateX(-50%);max-width:calc(100% - 24px);width:max-content;background:#171e20f5;pointer-events:auto;border:1px solid #ffffff30;border-radius:16px;padding:10px;display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap}.ended strong{padding:0 8px}.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.table-wrap{overflow:auto;max-height:38dvh}table{width:100%;border-collapse:collapse;text-align:left}th,td{padding:9px 6px;border-bottom:1px solid #ffffff20}td:nth-child(3){text-align:right}td small{display:block}.self{background:#ffffff0a}.checkbox{display:flex;align-items:center;gap:8px;font-size:13px}.checkbox input{width:18px;min-height:18px}.full{width:100%}\n[hidden]{display:none!important}.voice-peers{list-style:none;margin:0;padding:0;display:grid;gap:10px}.voice-peers li{border:1px solid #ffffff25;border-radius:12px;padding:12px;display:grid;gap:8px}.voice-peers [data-speaking=true]{border-color:var(--accent)}.voice-peers input{width:100%;accent-color:var(--accent);padding:0}.voice-peers label{font-size:13px}.pill .voice-toggle{width:44px;padding:8px}.voice-toggle[data-voice-state=on][data-muted=false]{color:var(--accent)}\n@media(max-width:480px){.dialog{padding:18px;border-radius:18px}.split{grid-template-columns:1fr 1fr;gap:8px}.tabs{gap:4px}.tabs button{font-size:13px;padding:6px 8px}.pill button:focus-visible{outline-offset:-4px}.pill small{display:none}.ended{gap:6px}.ended strong{font-size:13px}.ended button{padding:8px 10px;font-size:13px}.roster{max-height:28dvh}}\n@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto}}\n`;\n\n// src/overlay/voice-panel.ts\nfunction voiceEligible(manifest, session) {\n return manifest.voice !== "none" && session?.kind !== "watch" && session?.room?.players.find((player) => player.id === session.room?.you)?.role !== "spectator";\n}\nfunction voiceStatus(voice, t) {\n const key = voice.state === "joining" ? "voiceJoining" : voice.state === "reconnecting" ? "reconnecting" : voice.state === "off" ? "voiceOff" : "voiceOn";\n return t(key);\n}\nfunction updateVoicePanel(container, input) {\n const { session, t } = input, voice = session?.kind === "room" ? session.voice : null;\n if (!voiceEligible(input.manifest, session) || !voice) {\n container.replaceChildren();\n const note = container.ownerDocument.createElement("p");\n note.textContent = t(session?.kind === "watch" || session?.room?.players.find((p) => p.id === session.room?.you)?.role === "spectator" ? "voiceWatch" : "voiceUnavailable");\n container.append(note);\n return;\n }\n if (!container.querySelector("[data-voice-status]")) container.innerHTML = `<p role="status" aria-live="polite" data-voice-status></p><p data-voice-self></p>\n <div class="row"><button type="button" data-action="voice-join"></button><button type="button" data-action="voice-mute"></button><button type="button" data-action="voice-leave"></button></div>\n <p class="error" role="alert" data-voice-error hidden></p><h3 data-voice-heading></h3><ul class="voice-peers" data-voice-peers></ul><p class="muted" data-voice-empty></p>`;\n const get = (selector) => container.querySelector(selector);\n const status = get("[data-voice-status]");\n status.textContent = voiceStatus(voice, t);\n status.dataset.voiceState = voice.state;\n const mic = (value) => t(!value.mic ? "voiceListening" : value.muted ? "voiceMuted" : value.speaking ? "voiceSpeaking" : "voiceMic");\n get("[data-voice-self]").textContent = voice.state === "off" ? "" : `${t("you")}: ${mic(voice)}`;\n const join = get(\'[data-action="voice-join"]\'), mute = get(\'[data-action="voice-mute"]\'), leave = get(\'[data-action="voice-leave"]\');\n join.textContent = t("voiceJoin");\n join.hidden = voice.state !== "off";\n join.disabled = session?.room?.connection !== "connected" || input.pending === "voice.join";\n mute.textContent = t(voice.muted ? "voiceUnmute" : "voiceMute");\n mute.hidden = voice.state !== "on" || !voice.mic;\n mute.disabled = input.pending === "voice.mute";\n mute.setAttribute("aria-pressed", String(voice.muted));\n leave.textContent = t("voiceLeave");\n leave.hidden = voice.state === "off" && input.pending !== "voice.join";\n leave.disabled = input.pending === "voice.leave";\n const error = get("[data-voice-error]");\n error.hidden = !input.error;\n error.textContent = input.error ? t(errorText(input.error)) : "";\n get("[data-voice-heading]").textContent = t("voicePeers");\n get("[data-voice-empty]").textContent = t("voiceEmpty");\n get("[data-voice-empty]").hidden = voice.peers.length > 0;\n const list = get("[data-voice-peers]"), ids = new Set(voice.peers.map((peer) => peer.id));\n for (const row of list.querySelectorAll("[data-voice-peer]")) if (!ids.has(row.dataset.voicePeer)) row.remove();\n for (const peer of voice.peers) {\n let row = [...list.children].find((node) => node.dataset.voicePeer === peer.id);\n if (!row) {\n row = container.ownerDocument.createElement("li");\n row.dataset.voicePeer = peer.id;\n row.innerHTML = \'<div class="row"><strong data-peer-name></strong><small data-peer-status></small></div><label><span data-volume-label></span><input type="range" min="0" max="1" step="0.05" data-control="voice-volume"></label>\';\n row.querySelector("input").dataset.peer = peer.id;\n list.append(row);\n }\n const name = session?.room?.players.find((player) => player.id === peer.id)?.name ?? peer.id;\n row.dataset.mic = String(peer.mic);\n row.dataset.muted = String(peer.muted);\n row.dataset.speaking = String(peer.speaking);\n row.querySelector("[data-peer-name]").textContent = name;\n row.querySelector("[data-peer-status]").textContent = mic(peer);\n row.querySelector("[data-volume-label]").textContent = t("voiceVolume", { name });\n const range = row.querySelector("input");\n if (range.dataset.editing !== "true") range.value = String(peer.volume);\n range.setAttribute("aria-valuetext", `${Math.round(Number(range.value) * 100)}%`);\n range.disabled = voice.state !== "on";\n }\n}\n\n// src/overlay/ui.ts\nvar escape = (value) => String(value ?? "").replace(/[&<>"\']/g, (c) => ({ "&": "&", "<": "<", ">": ">", \'"\': """, "\'": "'" })[c]);\nfunction mountOverlay(input) {\n const manifest = input.configuration.manifest;\n if (manifest.overlay?.version !== 1) return null;\n const document = input.container.ownerDocument, win = document.defaultView, t = translator(input.language);\n const host = document.createElement("div");\n host.dataset.caisualOverlay = "";\n host.lang = overlayLanguage(input.language);\n host.style.setProperty("pointer-events", "none", "important");\n const root = host.attachShadow({ mode: "open" });\n if (typeof win.CSSStyleSheet?.prototype.replaceSync === "function" && "adoptedStyleSheets" in root) {\n const sheet = new win.CSSStyleSheet();\n sheet.replaceSync(styles);\n root.adoptedStyleSheets = [sheet];\n } else {\n const sheet = document.createElement("link");\n sheet.rel = "stylesheet";\n sheet.href = "/__caisual/overlay/v1.css";\n root.append(sheet);\n }\n const elements = document.createElement("div");\n elements.dataset.layout = "";\n elements.style.pointerEvents = "none";\n elements.innerHTML = `<div data-surface></div><div class="sr" role="status" aria-live="polite" data-live></div>`;\n root.append(elements);\n const surface = root.querySelector("[data-surface]"), live = root.querySelector("[data-live]");\n surface.style.pointerEvents = "none";\n live.style.pointerEvents = "none";\n const accent = manifest.overlay.accent ?? "#a8efc5";\n host.style.setProperty("--accent", accent);\n const rgb = [1, 3, 5].map((i) => parseInt(accent.slice(i, i + 2), 16) / 255).map((v) => v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);\n const luminance = rgb[0] * 0.2126 + rgb[1] * 0.7152 + rgb[2] * 0.0722;\n host.style.setProperty("--accent-ink", luminance > 0.179 ? "#000000" : "#ffffff");\n input.container.append(host);\n let model = initialUi(manifest), disposed = false, operation = 0, lastView = "", geometryFrame = 0;\n let lastPhase = "", wasModal = false, copyFallback = null;\n let voiceError = null, voicePending = null, voiceOperation = 0;\n let codeDraft = input.configuration.invite ?? "";\n const oldInert = Boolean(input.frame.inert), oldTabIndex = input.frame.getAttribute("tabindex");\n try {\n model.shortcutEnabled = win.localStorage.getItem("caisual-overlay-shortcut-v1") !== "off";\n } catch {\n }\n const boards = input.boards ? createBoardController({ manifest, player: input.player.id, guests: input.player.guest, read: input.boards, changed: () => render() }) : null;\n const stops = [];\n const selectedMode = () => manifest.modes.find((mode) => mode.id === model.mode);\n const disabled = () => model.busy ? " disabled" : "";\n const button = (action, key, extra = "", off = false) => `<button type="button" data-action="${action}"${extra}${off || model.busy ? " disabled" : ""}>${t(key)}</button>`;\n const dispatch = (action) => {\n if (disposed) return;\n model = reduceUi(model, action);\n render();\n };\n const announce = (text) => {\n if (live.textContent !== text) live.textContent = text;\n };\n const controls = () => [...root.querySelectorAll(\'button:not(:disabled),a[href],input:not(:disabled),select:not(:disabled),[tabindex="0"]\')].filter((el) => !el.closest("[hidden]"));\n const roomCode = () => model.session?.room?.code ?? null;\n const setPanel = (panel) => {\n if (panel === "boards" && boards && !boards.state.query) {\n const id = Object.keys(manifest.boards)[0];\n if (id) boards.select({ board: id, period: (manifest.boards[id].periods ?? ["all-time"])[0], guests: input.player.guest });\n }\n copyFallback = null;\n dispatch({ type: "panel", panel });\n };\n const close = () => {\n const current = phase(model.session), panel = visiblePanel(model);\n if (current === "home") setPanel("home");\n else if (current === "lobby" && panel !== "room") setPanel("room");\n else setPanel(null);\n };\n const toggle = () => {\n if (visiblePanel(model)) close();\n else setPanel(model.session?.room ? "room" : "home");\n };\n async function perform(op, args, after) {\n const token = ++operation;\n dispatch({ type: "error", code: null });\n dispatch({ type: "busy", busy: true });\n try {\n await input.bridge.request(op, args);\n if (token === operation && !disposed) await after?.();\n } catch (error) {\n if (token === operation && !disposed && error.code !== "cancelled") dispatch({ type: "error", code: error.code ?? "offline" });\n } finally {\n if (token === operation && !disposed) dispatch({ type: "busy", busy: false });\n }\n }\n function updateVoice() {\n const container = root.querySelector("[data-voice-panel]");\n if (container) updateVoicePanel(container, { manifest, session: model.session, t, error: voiceError, pending: voicePending });\n const toggle2 = root.querySelector("[data-voice-toggle]"), voice = model.session?.voice;\n if (toggle2) {\n toggle2.dataset.voiceState = voice?.state ?? "off";\n toggle2.dataset.muted = String(voice?.muted ?? false);\n toggle2.setAttribute("aria-label", `${t("voice")}: ${voice ? voiceStatus(voice, t) : t("voiceOff")}`);\n toggle2.textContent = voice?.state === "on" && !voice.muted ? "\\u25CF" : "\\u25CB";\n }\n }\n async function performVoice(op, args) {\n const sessionId = model.session?.id, epoch = input.bridge.epoch, volume = op === "voice.setVolume";\n const token = volume ? voiceOperation : ++voiceOperation;\n const current = () => !disposed && model.session?.id === sessionId && input.bridge.epoch === epoch && token === voiceOperation;\n voiceError = null;\n if (!volume) voicePending = op;\n updateVoice();\n try {\n await input.bridge.request(op, args);\n } catch (error) {\n if (current()) voiceError = error.code ?? "voice_error";\n } finally {\n if (current()) {\n if (!volume) voicePending = null;\n updateVoice();\n }\n }\n }\n async function copyInvite() {\n const code = roomCode();\n if (!code) return;\n const url = input.inviteUrl(code);\n try {\n await win.navigator.clipboard.writeText(url);\n dispatch({ type: "notice", notice: t("copied") });\n } catch {\n copyFallback = url;\n render();\n root.querySelector(\'input[data-control="invite-link"]\')?.select();\n }\n }\n function invitation() {\n const code = roomCode();\n if (!code) return `<p>${t("noRoom")}</p>`;\n return `<div class="row"><div class="grow"><small>${t("code")}</small><div class="code" data-room-code>${escape(code)}</div></div>${button("copy", "copy")}</div>${copyFallback ? `<label>${t("copyFailed")}<input data-control="invite-link" readonly value="${escape(copyFallback)}"></label>` : ""}`;\n }\n function navigation(panel) {\n const items = [];\n if (model.session?.room) items.push(["room", "room"], ["invite", "copy"]);\n items.push(["friends", "friends"]);\n if (Object.keys(manifest.boards).length) items.push(["boards", "boards"]);\n if (voiceEligible(manifest, model.session)) items.push(["voice", "voice"]);\n return `<nav class="tabs" aria-label="Caisual">${items.map(([id, key]) => button(`panel:${id}`, key, ` aria-current="${id === panel}"`)).join("")}</nav>`;\n }\n function home() {\n const selected = selectedMode(), action = primaryAction(manifest, model.mode), session = model.session;\n const hasRooms = manifest.modes.some((mode) => mode.execution === "room");\n return `<h1>${escape(manifest.name)}</h1><label>${t("mode")}<select data-control="mode"${disabled()}>${manifest.modes.map((mode) => `<option value="${escape(mode.id)}"${mode.id === model.mode ? " selected" : ""}>${escape(risolviPresentazione(manifest, mode.id).label)}</option>`).join("")}</select></label>\n ${selected?.instructions ? `<p class="muted">${escape(selected.instructions)}</p>` : ""}\n ${input.configuration.invite && phase(session) === "home" ? button("join-invite", "joinInvite", \' class="primary"\', !session?.ready) : ""}\n ${action ? button("play", action.friends ? "friendsPlay" : "play", \' class="primary"\', !session?.ready) : ""}\n ${selected?.matchmaking ? button("match", "find", "", !selected.matchmaking.defaults || !session?.ready) : ""}\n ${session?.resume ? button("resume", "resume", "", !session.ready) + `<small>${escape(session.resume.code)}</small>` : ""}\n ${hasRooms ? `<div class="split">${button("panel:join", "join", "", !session?.ready)}${manifest.spectators ? button("panel:watch", "watch", "", !session?.ready) : ""}</div>` : ""}\n ${navigation("home")}<label class="checkbox"><input type="checkbox" data-control="shortcut"${model.shortcutEnabled ? " checked" : ""}>${t("shortcut")}</label>`;\n }\n function room() {\n const session = model.session, room2 = session?.room;\n if (!room2) return `<p>${t("noRoom")}</p>`;\n const own = room2.players.find((player) => player.id === room2.you), lobby = room2.status === "lobby" && session?.kind === "room";\n const canRole = session?.kind === "room" && (lobby || room2.status === "playing" && room2.requestRole);\n const reason = startReason(manifest, session);\n return `${invitation()}<ul class="roster" aria-label="${t("room")}">${room2.players.map((p) => `<li data-player-id="${escape(p.id)}"><span class="name">${escape(p.name)} ${p.id === room2.you ? `<small>(${t("you")})</small>` : ""}</span>${p.id === room2.host ? `<span class="badge">${t("host")}</span>` : ""}${p.role ? `<small>${escape(manifest.roles.find((r) => r.id === p.role)?.label ?? p.role)}</small>` : ""}${p.team ? `<small>${t("team")} ${p.team}</small>` : ""}<small>${!p.connected ? t("away") : lobby ? t(p.ready ? "ready" : "unready") : ""}</small></li>`).join("")}</ul>\n ${canRole && manifest.roles.length ? `<label>${t("role")}<select data-control="role"${disabled()}><option value="" disabled${!own?.role ? " selected" : ""}>${t("role")}</option>${manifest.roles.map((role) => `<option value="${escape(role.id)}"${role.id === own?.role ? " selected" : ""}>${escape(role.label ?? role.id)}</option>`).join("")}</select></label>` : ""}\n ${lobby && manifest.teams ? `<label>${t("team")}<select data-control="team"${disabled()}><option value="" disabled${!own?.team ? " selected" : ""}>${t("team")}</option>${Array.from({ length: manifest.teams.max }, (_, i) => `<option value="${i + 1}"${own?.team === i + 1 ? " selected" : ""}>${t("team")} ${i + 1}</option>`).join("")}</select></label>` : ""}\n ${lobby ? `<div class="row">${button("ready", own?.ready ? "unready" : "ready", \' class="primary"\', room2.connection !== "connected")}${room2.host === room2.you ? button("start", "start", "", reason !== null) : ""}</div>${reason ? `<p class="muted" data-start-reason>${t(reason)}</p>` : ""}` : ""}\n ${session?.kind === "watch" ? `<p>${t("watching")} \\xB7 ${t("delay", { n: (room2.delayMs ?? 0) / 1e3 })}</p>` : ""}\n ${navigation("room")}<label class="checkbox"><input type="checkbox" data-control="shortcut"${model.shortcutEnabled ? " checked" : ""}>${t("shortcut")}</label>${button("panel:exit", "exit", \' class="quiet"\')}`;\n }\n function crew() {\n const provider = input.crew, state = provider?.getSnapshot();\n if (!provider || provider.unavailable || !state?.you) return `<p>${t(provider?.unavailable === "local" ? "localCrew" : "loginCrew")}</p>`;\n const online = state.friends.filter((friend) => friend.online), party = state.party;\n const person = (p) => `<li><span class="name">${escape(p.name)}<small>${p.game ? ` \\xB7 ${escape(p.game.name)}` : ""}</small></span>${p.room && p.game ? button("follow", "follow", ` data-code="${escape(p.room.code)}" data-game="${escape(p.game.slug)}"`) : ""}${party?.leader === state.you.id && !party.members.some((member) => member.id === p.id) ? button("party-invite", "inviteParty", ` data-player="${escape(p.id)}"`) : ""}</li>`;\n return `${!state.connected ? `<p>${t("reconnecting")}</p>` : ""}${party ? `<ul class="roster">${party.members.map(person).join("")}</ul>${button("party-leave", "leaveParty")}` : button("party-create", "createParty")}\n ${state.invites.map((invite) => `<div class="row"><span class="grow">${escape(invite.from.name)}</span>${button("party-accept", "accept", ` data-party="${escape(invite.party)}"`)}${button("party-decline", "decline", ` data-party="${escape(invite.party)}"`)}</div>`).join("")}\n ${state.follow ? `<div class="row"><span class="grow">${escape(state.follow.from.name)} \\xB7 ${escape(state.follow.game.name)}</span>${button("follow", "follow", ` data-code="${escape(state.follow.code)}" data-game="${escape(state.follow.game.slug)}"`)}</div>` : ""}\n <h2>${t("online")}</h2>${online.length ? `<ul class="roster">${online.map(person).join("")}</ul>` : `<p class="muted">${t("noFriends")}</p>`}`;\n }\n function leaderboard() {\n if (!boards || !boards.state.query) return `<p>${t("unavailable")}</p>`;\n const { query, data, loading, error, saving } = boards.state;\n const board = manifest.boards[query.board];\n return `<label>${t("board")}<select data-control="board">${Object.entries(manifest.boards).map(([id, value]) => `<option value="${escape(id)}"${query.board === id ? " selected" : ""}>${escape(value.label ?? id)}</option>`).join("")}</select></label>\n <div class="split"><label>${t("period")}<select data-control="period">${(board.periods ?? ["all-time"]).map((period) => `<option value="${period}"${query.period === period ? " selected" : ""}>${t(period === "daily" ? "daily" : "allTime")}</option>`).join("")}</select></label><label>${t("category")}<select data-control="category"><option value="accounts"${!query.guests ? " selected" : ""}>${t("accounts")}</option><option value="guests"${query.guests ? " selected" : ""}>${t("guests")}</option></select></label></div>\n ${query.period === "daily" ? `<small data-board-day>${escape(data?.day ?? query.day ?? new Date(input.bridge.serverTime() ?? Date.now()).toISOString().slice(0, 10))}</small>` : ""}\n ${saving ? `<p role="status" data-saving>${t(saving)}</p>` : ""}${error ? `<p role="alert">${t("offline")}</p>` : ""}\n ${data ? `<div class="table-wrap"><table><thead><tr><th>${t("rank")}</th><th>${t(query.guests ? "guests" : "accounts")}</th><th>${t("score")}</th></tr></thead><tbody>${data.entries.map((entry) => `<tr${entry.me ? \' class="self"\' : ""}><td>${entry.rank}</td><td>${escape(entry.name)}${entry.verified ? `<small>${t("verified")}</small>` : ""}</td><td>${entry.score}</td></tr>`).join("")}</tbody></table>${data.entries.length ? "" : `<p>${t("empty")}</p>`}</div><p data-own-score>${t("own")} (${t(data.ownGuest ? "guests" : "accounts")}): ${data.me ? `#${data.me.rank} \\xB7 ${data.me.score}${data.me.verified ? ` \\xB7 ${t("verified")}` : ""}` : t("empty")}</p>` : `<p>${t(loading ? "loading" : "empty")}</p>`}\n ${button("refresh", "refresh", "", loading)}`;\n }\n function content(panel) {\n switch (panel) {\n case "home":\n return home();\n case "room":\n return room();\n case "invite":\n return invitation();\n case "friends":\n return crew();\n case "voice":\n return \'<div class="stack" data-voice-panel></div>\';\n case "boards":\n return leaderboard();\n case "join":\n case "watch":\n return `<form class="stack" data-form="${panel}"><label>${t("code")}<input data-control="code" name="code" autocomplete="off" autocapitalize="characters" spellcheck="false" maxlength="16" value="${escape(codeDraft)}" required></label><button class="primary" type="submit"${disabled()}>${t(panel === "join" ? "join" : "watch")}</button></form>`;\n case "attaching":\n case "matching":\n return `<p role="status">${t(panel === "matching" ? "matching" : "joining")}</p>${model.session?.waiting ? `<p>${t("queue", { n: model.session.waiting.players, max: model.session.waiting.max })}</p>` : ""}<button type="button" data-action="cancel">${t("cancel")}</button>`;\n case "countdown":\n return `<p>${t("starting")}</p><div class="countdown" data-countdown></div>`;\n case "boot":\n return `<p role="status">${t("loading")}</p>${button("reload", "retry")}${button("exit-now", "exit")}`;\n case "error":\n return `<p role="alert">${t(model.session?.room?.connection === "replaced" ? "replaced" : "noRoom")}</p>${button("leave", "home")}${button("exit-now", "exit")}`;\n case "exit":\n return model.session?.kind === "room" && phase(model.session) !== "ended" ? `<p>${t(model.session.room?.persistent ? "leaveHint" : "temporaryHint")}</p>${invitation()}${button("disconnect-exit", "leaveNow", \' class="primary"\')}<p class="muted">${t("abandonHint")}</p>${button("leave-exit", "leaveRoom")}` : button("leave-exit", "exit", \' class="primary"\');\n }\n }\n function title(panel) {\n const keys = { boot: "loading", home: "home", room: "room", invite: "copy", friends: "friends", voice: "voice", boards: "boards", join: "join", watch: "watch", attaching: "joining", matching: "matching", countdown: "starting", error: "error", exit: "exit" };\n return t(keys[panel]);\n }\n function updateCountdown() {\n const at = model.session?.room?.countdownAt, now = input.bridge.serverTime();\n const value = at === null || at === void 0 || now === null ? "..." : String(Math.max(0, Math.round((at - now) / 1e3)));\n const node = root.querySelector("[data-countdown]");\n if (node && node.textContent !== value) {\n node.textContent = value;\n announce(`${t("starting")} ${value}`);\n }\n }\n function geometry() {\n geometryFrame = 0;\n if (disposed || !input.bridge.epoch || !model.session) return;\n const frame = input.frame.getBoundingClientRect(), scaleX = input.frame.clientWidth && frame.width ? input.frame.clientWidth / frame.width : 1, scaleY = input.frame.clientHeight && frame.height ? input.frame.clientHeight / frame.height : 1;\n const reservedRects = [...root.querySelectorAll("[data-reserve]")].map((el) => {\n const rect = el.getBoundingClientRect(), left = Math.max(frame.left, rect.left), top = Math.max(frame.top, rect.top), right = Math.min(frame.right, rect.right), bottom = Math.min(frame.bottom, rect.bottom);\n return { x: Math.max(0, Math.round((left - frame.left) * scaleX)), y: Math.max(0, Math.round((top - frame.top) * scaleY)), width: Math.max(0, Math.round((right - left) * scaleX)), height: Math.max(0, Math.round((bottom - top) * scaleY)) };\n }).filter((rect) => rect.width && rect.height).slice(0, 8);\n const view = { inputBlocked: !!visiblePanel(model), reservedRects, shortcutEnabled: model.shortcutEnabled };\n const serialized = `${input.bridge.epoch}:${JSON.stringify(view)}`;\n if (lastView === serialized) return;\n lastView = serialized;\n void input.bridge.request("overlay.view", view).catch(() => {\n if (lastView === serialized) lastView = "";\n });\n }\n function resize() {\n if (!geometryFrame) geometryFrame = win.requestAnimationFrame(geometry);\n }\n function render() {\n if (disposed) return;\n const panel = visiblePanel(model), current = phase(model.session), room2 = model.session?.room;\n const focused = root.activeElement;\n const focusPeer = focused?.dataset.peer;\n const focusKey = focused?.dataset.control ? ["control", focused.dataset.control] : focused?.dataset.action ? ["action", focused.dataset.action] : null;\n const previousScroll = root.querySelector(".dialog")?.scrollTop ?? 0;\n const selection = focused?.tagName === "INPUT" ? { start: focused.selectionStart, end: focused.selectionEnd } : null;\n const crewState = input.crew?.getSnapshot(), invitations = (crewState?.invites.length ?? 0) + (crewState?.follow ? 1 : 0);\n const label = current === "watching" ? t("watching") : room2?.connection === "reconnecting" ? t("reconnecting") : room2?.code ?? "Caisual";\n surface.innerHTML = `<div class="pill" data-reserve><button type="button" data-action="menu" aria-label="${t("menu")}" aria-expanded="${!!panel}">C<span aria-hidden="true"><small>${escape(label)}</small></span></button>${voiceEligible(manifest, model.session) && model.session?.kind === "room" ? `<button type="button" class="voice-toggle" data-voice-toggle data-action="panel:voice"></button>` : ""}${invitations ? `<button type="button" data-action="panel:friends" aria-label="${t("friends")} (${invitations})">${invitations}</button>` : ""}<button type="button" class="exit" data-action="panel:exit" aria-label="${t("exit")}">\\xD7</button></div>\n ${current === "ended" && !panel ? `<div class="ended" data-reserve role="region" aria-label="${t("ended")}"><strong>${t("ended")}</strong>${boards?.state.saving ? `<small role="status" data-saving>${t(boards.state.saving)}</small>` : ""}${canPlayAgain(model.session) ? button("again", "again", \' class="primary"\') : model.session?.kind === "room" ? `<small>${t("waitHost")}</small>` : ""}${Object.keys(manifest.boards).length ? button("panel:boards", "boards") : ""}${button("panel:home", "home")}</div>` : ""}\n ${panel ? `<div class="backdrop${panel === "home" ? " home" : ""}"><section class="dialog${panel === "boards" || panel === "friends" ? " wide" : ""}" role="dialog" aria-modal="true" aria-labelledby="panel-title" tabindex="-1"><div class="top"><h2 id="panel-title">${title(panel)}</h2><button type="button" data-action="close" aria-label="${t("close")}">\\xD7</button></div><div class="stack">${content(panel)}${model.error ? `<p class="error" role="alert" data-error>${t(errorText(model.error))}</p>` : ""}${model.session?.resumeError ? `<p class="error" role="alert">${t("saveFailed")}</p>` : ""}${model.notice ? `<p class="notice" role="status">${escape(model.notice)}</p>` : ""}</div></section></div>` : ""}`;\n for (const element of surface.querySelectorAll(".pill,.backdrop,.ended")) element.style.pointerEvents = "auto";\n const backdrop = root.querySelector(".backdrop.home");\n if (backdrop && input.configuration.coverUrl) backdrop.style.backgroundImage = `linear-gradient(#0b151c99,#0b151cee),url(${JSON.stringify(input.configuration.coverUrl)})`;\n host.dataset.phase = current;\n host.dataset.panel = panel ?? "";\n input.frame.inert = !!panel || oldInert;\n if (panel) input.frame.tabIndex = -1;\n else if (oldTabIndex === null) input.frame.removeAttribute("tabindex");\n else input.frame.setAttribute("tabindex", oldTabIndex);\n updateVoice();\n const dialog = root.querySelector(".dialog");\n if (dialog) dialog.scrollTop = previousScroll;\n const matched = focusKey ? [...root.querySelectorAll(`[data-${focusKey[0]}]`)].find((el) => el.getAttribute(`data-${focusKey[0]}`) === focusKey[1] && el.dataset.peer === focusPeer) : null;\n if (!panel && wasModal && !input.frame.inert && input.frame.isConnected) {\n input.frame.focus({ preventScroll: true });\n input.frame.contentWindow?.focus();\n } else if (matched && (!panel || matched.closest(".dialog")) && !matched.hasAttribute("disabled")) {\n matched.focus({ preventScroll: true });\n if (matched.tagName === "INPUT" && selection?.start !== null && selection?.end !== null && selection) matched.setSelectionRange(selection.start, selection.end);\n } else if (panel && (!wasModal || focused)) (dialog?.querySelector(\'select,input,button:not([data-action="close"]):not(:disabled)\') ?? dialog)?.focus({ preventScroll: true });\n wasModal = !!panel;\n if (lastPhase !== current) {\n lastPhase = current;\n announce(t({ boot: "loading", home: "home", attaching: "joining", matching: "matching", lobby: "room", countdown: "starting", playing: "playing", ended: "ended", watching: "watching", error: "error" }[current]));\n }\n updateCountdown();\n resize();\n }\n const click = (event) => {\n const target = event.target.closest("button[data-action]");\n if (!target || target.disabled) return;\n const action = target.dataset.action;\n event.stopPropagation();\n if (action.startsWith("panel:")) {\n setPanel(action.slice(6));\n return;\n }\n switch (action) {\n case "menu":\n toggle();\n break;\n case "close":\n close();\n break;\n case "play": {\n const selected = primaryAction(manifest, model.mode);\n if (selected) void perform(selected.op, { mode: model.mode });\n break;\n }\n case "match":\n void perform("room.match", { mode: model.mode });\n break;\n case "join-invite":\n if (input.configuration.invite) void perform("room.join", { code: input.configuration.invite });\n break;\n case "resume":\n void perform("session.resume", {});\n break;\n case "cancel":\n void perform("session.cancel", {});\n break;\n case "ready":\n void perform("room.ready", { ready: !model.session?.room?.players.find((p) => p.id === model.session?.room?.you)?.ready });\n break;\n case "start":\n void perform("room.start", {});\n break;\n case "copy":\n void copyInvite();\n break;\n case "voice-join":\n void performVoice("voice.join", {});\n break;\n case "voice-mute":\n void performVoice("voice.mute", { muted: !model.session?.voice?.muted });\n break;\n case "voice-leave":\n void performVoice("voice.leave", {});\n break;\n case "again": {\n if (!canPlayAgain(model.session)) break;\n if (model.session?.kind === "local") void perform("local.start", { mode: model.session.mode ?? model.mode });\n else void perform("room.create", { mode: model.session?.room?.mode ?? null }, async () => {\n setPanel("invite");\n dispatch({ type: "notice", notice: t("newRoom") });\n await copyInvite();\n });\n break;\n }\n case "disconnect-exit":\n void perform("session.disconnect", {}, input.exit);\n break;\n case "leave-exit":\n void perform("session.leave", {}, input.exit);\n break;\n case "leave":\n void perform("session.leave", {}, () => setPanel("home"));\n break;\n case "exit-now":\n input.exit();\n break;\n case "reload":\n input.frame.src = input.frame.src;\n break;\n case "refresh":\n void boards?.refresh();\n break;\n case "party-create":\n input.crew?.party.create();\n break;\n case "party-leave":\n input.crew?.party.leave();\n break;\n case "party-invite":\n input.crew?.party.invite(target.dataset.player);\n break;\n case "party-accept":\n input.crew?.party.accept(target.dataset.party);\n break;\n case "party-decline":\n input.crew?.party.decline(target.dataset.party);\n break;\n case "follow":\n input.crew?.follow(target.dataset.game, target.dataset.code);\n break;\n }\n };\n const change = (event) => {\n const target = event.target, field = target.dataset.control;\n if (field === "voice-volume") {\n target.dataset.editing = "true";\n void performVoice("voice.setVolume", { playerId: target.dataset.peer, volume: Number(target.value) }).finally(() => {\n delete target.dataset.editing;\n updateVoice();\n });\n return;\n }\n if (field === "mode") dispatch({ type: "mode", mode: target.value });\n if (field === "role") void perform(model.session?.room?.status === "lobby" ? "room.role" : "room.requestRole", { role: target.value });\n if (field === "team") void perform("room.team", { team: Number(target.value) });\n if (field === "shortcut") {\n const enabled = target.checked;\n try {\n win.localStorage.setItem("caisual-overlay-shortcut-v1", enabled ? "on" : "off");\n } catch {\n }\n dispatch({ type: "shortcut", enabled });\n }\n const query = boards?.state.query;\n if (query && ["board", "period", "category"].includes(field ?? "")) {\n const next = { ...query };\n if (field === "board") {\n next.board = target.value;\n next.period = (manifest.boards[next.board].periods ?? ["all-time"])[0];\n delete next.day;\n }\n if (field === "period") {\n next.period = target.value;\n delete next.day;\n }\n if (field === "category") next.guests = target.value === "guests";\n boards.select(next);\n }\n };\n const submit = (event) => {\n const form = event.target;\n if (!form.dataset.form) return;\n event.preventDefault();\n const code = normalizeInvite(form.querySelector(\'input[data-control="code"]\').value);\n if (!code) {\n dispatch({ type: "error", code: "invalid_code" });\n return;\n }\n void perform(form.dataset.form === "watch" ? "room.watch" : "room.join", { code });\n };\n const keydown = (event) => {\n const panel = visiblePanel(model);\n if (panel && event.key === "Escape") {\n event.preventDefault();\n event.stopImmediatePropagation();\n close();\n return;\n }\n if (panel && event.key === "Tab") {\n const items = controls().filter((el) => el.closest(".dialog")), first = items[0], last = items.at(-1);\n if (!first) {\n event.preventDefault();\n return;\n }\n if (event.shiftKey && (root.activeElement === first || !items.includes(root.activeElement))) {\n event.preventDefault();\n last?.focus();\n } else if (!event.shiftKey && (root.activeElement === last || !items.includes(root.activeElement))) {\n event.preventDefault();\n first.focus();\n }\n } else if (!panel && model.shortcutEnabled && event.key === "Tab" && event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey) {\n event.preventDefault();\n toggle();\n }\n };\n root.addEventListener("input", (event) => {\n const node = event.target;\n if (node.dataset.control === "code") codeDraft = node.value;\n if (node.dataset.control === "voice-volume") node.dataset.editing = "true";\n });\n root.addEventListener("click", click);\n root.addEventListener("change", change);\n root.addEventListener("submit", submit);\n win.addEventListener("keydown", keydown, true);\n win.addEventListener("resize", resize);\n const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(resize) : null;\n observer?.observe(input.frame);\n const countdownTimer = win.setInterval(updateCountdown, 250);\n stops.push(input.bridge.subscribe((session) => {\n const previous = model.session;\n if (!session || session.id !== previous?.id) {\n voiceOperation++;\n voicePending = null;\n voiceError = null;\n }\n if (!session) {\n lastView = "";\n boards?.reset();\n operation++;\n model.busy = false;\n }\n if (previous && session && JSON.stringify({ ...previous, voice: null }) === JSON.stringify({ ...session, voice: null })) {\n model = reduceUi(model, { type: "session", session });\n updateVoice();\n } else dispatch({ type: "session", session });\n }));\n stops.push(input.bridge.onOpen((panel) => setPanel(panel)), input.bridge.onShortcut(toggle), input.bridge.onError(({ error }) => {\n if (!visiblePanel(model)) setPanel("room");\n dispatch({ type: "error", code: error.code });\n }));\n stops.push(input.bridge.onScore((score) => boards?.queued(score)));\n if (input.crew) stops.push(input.crew.subscribe(() => {\n const state = input.crew.getSnapshot();\n if (state.follow || state.invites.length) announce(t("friends"));\n render();\n }));\n render();\n return { element: host, root, dispose() {\n disposed = true;\n operation++;\n stops.forEach((stop) => stop());\n boards?.dispose();\n observer?.disconnect();\n win.clearInterval(countdownTimer);\n win.cancelAnimationFrame(geometryFrame);\n win.removeEventListener("keydown", keydown, true);\n win.removeEventListener("resize", resize);\n input.frame.inert = oldInert;\n if (oldTabIndex === null) input.frame.removeAttribute("tabindex");\n else input.frame.setAttribute("tabindex", oldTabIndex);\n void input.bridge.request("overlay.view", { inputBlocked: false, reservedRects: [], shortcutEnabled: false }).catch(() => {\n });\n host.remove();\n } };\n}\nexport {\n avviaHandshake,\n creaPonteOspite,\n eMessaggioReady,\n eRichiestaBiglietto,\n mountOverlay,\n overlayConfiguration,\n overlayLanguage,\n styles as overlayStyles,\n stanzaDaMessaggio\n};\n');
|
|
4436
|
+
return;
|
|
4437
|
+
}
|
|
3567
4438
|
if (url.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) {
|
|
3568
4439
|
const voceAttiva = this.manifest.voice !== "none";
|
|
3569
4440
|
const body = parentPage({
|
|
@@ -3577,7 +4448,8 @@ var DevService = class {
|
|
|
3577
4448
|
].join("; "),
|
|
3578
4449
|
gameOrigin: this.gameOrigin,
|
|
3579
4450
|
portalOrigin: this.portalOrigin,
|
|
3580
|
-
slug: this.manifest.id
|
|
4451
|
+
slug: this.manifest.id,
|
|
4452
|
+
manifest: this.manifest
|
|
3581
4453
|
});
|
|
3582
4454
|
response.statusCode = 200;
|
|
3583
4455
|
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
@@ -3601,11 +4473,32 @@ var DevService = class {
|
|
|
3601
4473
|
this.handleSession(response, url);
|
|
3602
4474
|
return;
|
|
3603
4475
|
}
|
|
4476
|
+
const hostBoard = /^\/api\/overlay\/([^/]+)\/boards\/([^/]+)$/.exec(url.pathname);
|
|
4477
|
+
if (hostBoard) {
|
|
4478
|
+
try {
|
|
4479
|
+
if (request.method !== "GET") {
|
|
4480
|
+
response.setHeader("Allow", "GET");
|
|
4481
|
+
throw new DevHttpError(405, "method_not_allowed", "Use GET for this endpoint.");
|
|
4482
|
+
}
|
|
4483
|
+
const origin = typeof request.headers.origin === "string" ? request.headers.origin : null;
|
|
4484
|
+
const site = typeof request.headers["sec-fetch-site"] === "string" ? request.headers["sec-fetch-site"] : null;
|
|
4485
|
+
if (!overlayReadOrigin(origin, site, this.portalOrigin)) throw new DevHttpError(403, "forbidden", "This endpoint is only available to the host.");
|
|
4486
|
+
if (hostBoard[1] !== this.manifest.id) throw new DevHttpError(404, "not_found", "The game was not found.");
|
|
4487
|
+
const ticket = readServiceTicket(request, this.manifest.id, "portal", this.secret);
|
|
4488
|
+
const board = decodeURIComponent(hostBoard[2]);
|
|
4489
|
+
const error = overlayBoardError(this.manifest, board, url.searchParams);
|
|
4490
|
+
if (error) throw new DevHttpError(400, "invalid_request", error);
|
|
4491
|
+
this.topScores(response, board, url, ticket, null, true);
|
|
4492
|
+
} catch (error) {
|
|
4493
|
+
sendError(response, error);
|
|
4494
|
+
}
|
|
4495
|
+
return;
|
|
4496
|
+
}
|
|
3604
4497
|
if (url.pathname.startsWith("/api/kit/")) {
|
|
3605
4498
|
await this.handleKit(request, response, url);
|
|
3606
4499
|
return;
|
|
3607
4500
|
}
|
|
3608
|
-
if (url.pathname === "/match" || url.pathname === "/rooms" || url.pathname === "/rooms/join" || /^\/rooms\/[^/]+(?:\/flush)?$/.test(url.pathname)) {
|
|
4501
|
+
if (url.pathname === "/match" || url.pathname === "/rooms" || url.pathname === "/rooms/join" || url.pathname === "/rooms/watch" || /^\/rooms\/[^/]+(?:\/flush)?$/.test(url.pathname)) {
|
|
3609
4502
|
await this.handleLive(request, response, url);
|
|
3610
4503
|
return;
|
|
3611
4504
|
}
|
|
@@ -3721,7 +4614,7 @@ var DevService = class {
|
|
|
3721
4614
|
return;
|
|
3722
4615
|
}
|
|
3723
4616
|
if (request.method === "DELETE") {
|
|
3724
|
-
saves.delete(key);
|
|
4617
|
+
if (saves.delete(key)) await this.persistSaves();
|
|
3725
4618
|
sendJson(response, { deleted: true }, 200, origin);
|
|
3726
4619
|
return;
|
|
3727
4620
|
}
|
|
@@ -3749,6 +4642,7 @@ var DevService = class {
|
|
|
3749
4642
|
const updatedAt = Date.now();
|
|
3750
4643
|
const bytes = Buffer.byteLength(serialized);
|
|
3751
4644
|
saves.set(key, { value: structuredClone(body.value), bytes, updatedAt });
|
|
4645
|
+
await this.persistSaves();
|
|
3752
4646
|
sendJson(response, { key, bytes, updatedAt }, 200, origin);
|
|
3753
4647
|
}
|
|
3754
4648
|
scoreKey(playerId, game, board, day) {
|
|
@@ -3757,8 +4651,14 @@ var DevService = class {
|
|
|
3757
4651
|
putScore(input, now = Date.now()) {
|
|
3758
4652
|
const key = this.scoreKey(input.playerId, input.game, input.board, input.day);
|
|
3759
4653
|
const existing = this.scores.get(key);
|
|
3760
|
-
if (existing !== void 0
|
|
3761
|
-
|
|
4654
|
+
if (existing !== void 0) {
|
|
4655
|
+
if (!input.verified && (existing.verified || existing.score >= input.score)) return existing;
|
|
4656
|
+
if (input.verified && existing.verified && existing.score > input.score) return existing;
|
|
4657
|
+
}
|
|
4658
|
+
const record2 = {
|
|
4659
|
+
...input,
|
|
4660
|
+
createdAt: existing !== void 0 && input.score <= existing.score ? existing.createdAt : now
|
|
4661
|
+
};
|
|
3762
4662
|
this.scores.set(key, record2);
|
|
3763
4663
|
return record2;
|
|
3764
4664
|
}
|
|
@@ -3772,6 +4672,14 @@ var DevService = class {
|
|
|
3772
4672
|
if (body === null || typeof body.board !== "string" || !CHIAVE_BOARD.test(body.board)) {
|
|
3773
4673
|
throw new DevHttpError(400, "invalid_request", "The board name is not valid.");
|
|
3774
4674
|
}
|
|
4675
|
+
if (this.manifest.boards[body.board]?.source === "server") {
|
|
4676
|
+
throw new DevHttpError(
|
|
4677
|
+
403,
|
|
4678
|
+
"board_server_only",
|
|
4679
|
+
"This board only accepts scores from the room server.",
|
|
4680
|
+
["Submit the score with room.board.submit from server.js."]
|
|
4681
|
+
);
|
|
4682
|
+
}
|
|
3775
4683
|
if (typeof body.score !== "number" || !Number.isSafeInteger(body.score) || body.score < 0) {
|
|
3776
4684
|
throw new DevHttpError(400, "invalid_request", "score must be a non-negative safe integer.");
|
|
3777
4685
|
}
|
|
@@ -3793,10 +4701,10 @@ var DevService = class {
|
|
|
3793
4701
|
day: record2.day,
|
|
3794
4702
|
best: record2.score,
|
|
3795
4703
|
rank: this.scoreRank(record2),
|
|
3796
|
-
verified:
|
|
4704
|
+
verified: record2.verified
|
|
3797
4705
|
}, 200, origin);
|
|
3798
4706
|
}
|
|
3799
|
-
topScores(response, board, url, ticket, origin) {
|
|
4707
|
+
topScores(response, board, url, ticket, origin, host = false) {
|
|
3800
4708
|
if (!CHIAVE_BOARD.test(board)) {
|
|
3801
4709
|
throw new DevHttpError(400, "invalid_request", "The board name is not valid.");
|
|
3802
4710
|
}
|
|
@@ -3812,7 +4720,9 @@ var DevService = class {
|
|
|
3812
4720
|
if (!/^\d+$/.test(limitRaw) || Number(limitRaw) < 1 || Number(limitRaw) > 100) {
|
|
3813
4721
|
throw new DevHttpError(400, "invalid_request", "limit must be an integer from 1 to 100.");
|
|
3814
4722
|
}
|
|
3815
|
-
const
|
|
4723
|
+
const explicitDay = url.searchParams.get("day");
|
|
4724
|
+
if (explicitDay !== null && !validBoardDay(explicitDay)) throw new DevHttpError(400, "invalid_request", "day must be a real UTC date in YYYY-MM-DD format.");
|
|
4725
|
+
const day = explicitDay ?? (dailyValue === "1" ? utcDay() : null);
|
|
3816
4726
|
const guests = guestsValue === "1";
|
|
3817
4727
|
const category = [...this.scores.values()].filter(
|
|
3818
4728
|
(record2) => record2.game === ticket.game && record2.board === board && record2.day === day && record2.guest === guests
|
|
@@ -3821,6 +4731,7 @@ var DevService = class {
|
|
|
3821
4731
|
rank: this.scoreRank(record2),
|
|
3822
4732
|
name: record2.guest ? "Guest" : record2.name,
|
|
3823
4733
|
score: record2.score,
|
|
4734
|
+
verified: record2.verified,
|
|
3824
4735
|
guest: record2.guest,
|
|
3825
4736
|
me: record2.playerId === ticket.sub
|
|
3826
4737
|
}));
|
|
@@ -3828,8 +4739,13 @@ var DevService = class {
|
|
|
3828
4739
|
sendJson(response, {
|
|
3829
4740
|
board,
|
|
3830
4741
|
day,
|
|
4742
|
+
...host ? { ownGuest: ticket.guest } : {},
|
|
3831
4743
|
entries,
|
|
3832
|
-
me: own === void 0 ? null : {
|
|
4744
|
+
me: own === void 0 ? null : {
|
|
4745
|
+
rank: this.scoreRank(own),
|
|
4746
|
+
score: own.score,
|
|
4747
|
+
verified: own.verified
|
|
4748
|
+
}
|
|
3833
4749
|
}, 200, origin);
|
|
3834
4750
|
}
|
|
3835
4751
|
async handleLive(request, response, url) {
|
|
@@ -3854,9 +4770,13 @@ var DevService = class {
|
|
|
3854
4770
|
await this.joinRoom(request, response, ticket, origin);
|
|
3855
4771
|
return;
|
|
3856
4772
|
}
|
|
4773
|
+
if (url.pathname === "/rooms/watch" && request.method === "POST") {
|
|
4774
|
+
await this.watchRoom(request, response, ticket, origin);
|
|
4775
|
+
return;
|
|
4776
|
+
}
|
|
3857
4777
|
const match = /^\/rooms\/(g1-1\.[a-z0-9]{16})(?:\/(flush))?$/.exec(url.pathname);
|
|
3858
4778
|
if (match !== null && match[1] !== void 0) {
|
|
3859
|
-
const localRoom = this.
|
|
4779
|
+
const localRoom = await this.loadLocalRoom(match[1]);
|
|
3860
4780
|
if (localRoom === void 0 || localRoom.game !== ticket.game) {
|
|
3861
4781
|
throw new DevHttpError(404, "room_not_found", "The room was not found.");
|
|
3862
4782
|
}
|
|
@@ -3870,10 +4790,10 @@ var DevService = class {
|
|
|
3870
4790
|
guest: player?.guest ?? true,
|
|
3871
4791
|
game: ticket.game,
|
|
3872
4792
|
board: score.board,
|
|
3873
|
-
day: score.daily ? utcDay() : null,
|
|
4793
|
+
day: score.day === void 0 ? score.daily ? utcDay() : null : score.day,
|
|
3874
4794
|
score: score.score,
|
|
3875
4795
|
verified: true
|
|
3876
|
-
});
|
|
4796
|
+
}, score.submittedAt);
|
|
3877
4797
|
}
|
|
3878
4798
|
sendJson(response, {
|
|
3879
4799
|
scores: flushed.scores.length,
|
|
@@ -3934,24 +4854,26 @@ var DevService = class {
|
|
|
3934
4854
|
if (mode === void 0) {
|
|
3935
4855
|
throw new DevHttpError(400, "invalid_request", "The matchmaking mode does not exist.");
|
|
3936
4856
|
}
|
|
4857
|
+
if (mode.execution === "local") throw new DevHttpError(400, "invalid_request", "Local modes cannot use matchmaking.");
|
|
3937
4858
|
if (mode.matchmaking === void 0) {
|
|
3938
4859
|
throw new DevHttpError(400, "invalid_request", "This mode does not support matchmaking.");
|
|
3939
4860
|
}
|
|
3940
4861
|
const key = canonicalMatchKey(body.key, mode.matchmaking.key);
|
|
4862
|
+
const risolta = risolviModalita(this.manifest, mode.id);
|
|
3941
4863
|
const token = matchTicket(
|
|
3942
4864
|
playerFromTicket(ticket),
|
|
3943
4865
|
ticket.game,
|
|
3944
4866
|
mode.id,
|
|
3945
4867
|
key,
|
|
3946
4868
|
mode.matchmaking,
|
|
3947
|
-
|
|
3948
|
-
|
|
4869
|
+
risolta.players,
|
|
4870
|
+
risolta.lobby,
|
|
3949
4871
|
this.secret
|
|
3950
4872
|
);
|
|
3951
4873
|
sendJson(response, {
|
|
3952
4874
|
url: `ws://localhost:${this.port}/match?j=${encodeURIComponent(token)}`,
|
|
3953
4875
|
timeoutMs: mode.matchmaking.timeoutMs,
|
|
3954
|
-
players:
|
|
4876
|
+
players: risolta.players
|
|
3955
4877
|
}, 200, origin);
|
|
3956
4878
|
}
|
|
3957
4879
|
roomManifest() {
|
|
@@ -3960,6 +4882,7 @@ var DevService = class {
|
|
|
3960
4882
|
players: this.manifest.players,
|
|
3961
4883
|
lobby: this.manifest.lobby,
|
|
3962
4884
|
persistent: this.manifest.persistent,
|
|
4885
|
+
spectators: this.manifest.spectators,
|
|
3963
4886
|
roles: this.manifest.roles,
|
|
3964
4887
|
teams: this.manifest.teams,
|
|
3965
4888
|
modes: this.manifest.modes,
|
|
@@ -3970,6 +4893,7 @@ var DevService = class {
|
|
|
3970
4893
|
if (this.definition === null) {
|
|
3971
4894
|
throw new DevHttpError(409, "no_server", "This game has no multiplayer server.");
|
|
3972
4895
|
}
|
|
4896
|
+
if (modalitaLocale(this.manifest, mode)) throw new DevHttpError(400, "invalid_request", "Local modes cannot create rooms.");
|
|
3973
4897
|
const roomId = `g1-1.${randomUniform("abcdefghijklmnopqrstuvwxyz0123456789", 16)}`;
|
|
3974
4898
|
const room = await createNodeRoom(
|
|
3975
4899
|
this.definition,
|
|
@@ -3993,12 +4917,76 @@ var DevService = class {
|
|
|
3993
4917
|
room,
|
|
3994
4918
|
pendingMatch: /* @__PURE__ */ new Map()
|
|
3995
4919
|
};
|
|
4920
|
+
const record2 = { roomId, code, game: this.manifest.id, mode };
|
|
3996
4921
|
this.rooms.set(roomId, localRoom);
|
|
4922
|
+
this.roomIndex.set(roomId, record2);
|
|
3997
4923
|
this.roomByCode.set(code, roomId);
|
|
4924
|
+
try {
|
|
4925
|
+
await this.persistRoomIndex();
|
|
4926
|
+
} catch (cause) {
|
|
4927
|
+
this.rooms.delete(roomId);
|
|
4928
|
+
this.roomIndex.delete(roomId);
|
|
4929
|
+
this.roomByCode.delete(code);
|
|
4930
|
+
await room.close();
|
|
4931
|
+
throw cause;
|
|
4932
|
+
}
|
|
3998
4933
|
return { roomId, localRoom };
|
|
3999
4934
|
}
|
|
4935
|
+
async loadLocalRoom(roomId) {
|
|
4936
|
+
const loaded = this.rooms.get(roomId);
|
|
4937
|
+
if (loaded !== void 0) return loaded;
|
|
4938
|
+
const pending = this.roomLoads.get(roomId);
|
|
4939
|
+
if (pending !== void 0) return pending;
|
|
4940
|
+
const record2 = this.roomIndex.get(roomId);
|
|
4941
|
+
if (record2 === void 0 || record2.game !== this.manifest.id || this.definition === null) {
|
|
4942
|
+
return void 0;
|
|
4943
|
+
}
|
|
4944
|
+
const loading = this.restoreLocalRoom(record2);
|
|
4945
|
+
this.roomLoads.set(roomId, loading);
|
|
4946
|
+
try {
|
|
4947
|
+
return await loading;
|
|
4948
|
+
} finally {
|
|
4949
|
+
if (this.roomLoads.get(roomId) === loading) this.roomLoads.delete(roomId);
|
|
4950
|
+
}
|
|
4951
|
+
}
|
|
4952
|
+
async restoreLocalRoom(record2) {
|
|
4953
|
+
if (this.definition === null) return void 0;
|
|
4954
|
+
const room = await createNodeRoom(
|
|
4955
|
+
this.definition,
|
|
4956
|
+
this.roomManifest(),
|
|
4957
|
+
{
|
|
4958
|
+
storageFile: join2(this.root, ".caisual-dev", "rooms", `${record2.roomId}.json`),
|
|
4959
|
+
deposito: this.deposito
|
|
4960
|
+
}
|
|
4961
|
+
);
|
|
4962
|
+
const info = await room.info();
|
|
4963
|
+
if (info === null || info.roomId !== record2.roomId) {
|
|
4964
|
+
await room.close();
|
|
4965
|
+
return void 0;
|
|
4966
|
+
}
|
|
4967
|
+
const localRoom = {
|
|
4968
|
+
code: record2.code,
|
|
4969
|
+
game: record2.game,
|
|
4970
|
+
room,
|
|
4971
|
+
pendingMatch: /* @__PURE__ */ new Map()
|
|
4972
|
+
};
|
|
4973
|
+
this.rooms.set(record2.roomId, localRoom);
|
|
4974
|
+
return localRoom;
|
|
4975
|
+
}
|
|
4000
4976
|
async joinRoom(request, response, ticket, origin) {
|
|
4001
4977
|
const body = object(await readBody(request));
|
|
4978
|
+
const { roomId, localRoom } = await this.resolveLocalRoom(body, ticket.game);
|
|
4979
|
+
const permission = await localRoom.room.canJoin(playerFromTicket(ticket));
|
|
4980
|
+
if (!permission.ok) {
|
|
4981
|
+
throw new DevHttpError(
|
|
4982
|
+
permission.code === "room_not_found" ? 404 : 409,
|
|
4983
|
+
permission.code,
|
|
4984
|
+
this.roomErrorMessage(permission.code)
|
|
4985
|
+
);
|
|
4986
|
+
}
|
|
4987
|
+
sendJson(response, this.joinResponse(roomId, localRoom.code, playerFromTicket(ticket)), 200, origin);
|
|
4988
|
+
}
|
|
4989
|
+
async resolveLocalRoom(body, game) {
|
|
4002
4990
|
const hasCode = body !== null && typeof body.code === "string";
|
|
4003
4991
|
const hasRoomId = body !== null && typeof body.roomId === "string";
|
|
4004
4992
|
if (body === null || hasCode === hasRoomId) {
|
|
@@ -4009,11 +4997,16 @@ var DevService = class {
|
|
|
4009
4997
|
throw new DevHttpError(404, "room_not_found", "The room was not found.");
|
|
4010
4998
|
}
|
|
4011
4999
|
const roomId = codeInput === null ? body.roomId : this.roomByCode.get(codeInput);
|
|
4012
|
-
const localRoom = roomId === void 0 ? void 0 : this.
|
|
4013
|
-
if (roomId === void 0 || localRoom === void 0 || localRoom.game !==
|
|
5000
|
+
const localRoom = roomId === void 0 ? void 0 : await this.loadLocalRoom(roomId);
|
|
5001
|
+
if (roomId === void 0 || localRoom === void 0 || localRoom.game !== game) {
|
|
4014
5002
|
throw new DevHttpError(404, "room_not_found", "The room was not found.");
|
|
4015
5003
|
}
|
|
4016
|
-
|
|
5004
|
+
return { roomId, localRoom };
|
|
5005
|
+
}
|
|
5006
|
+
async watchRoom(request, response, ticket, origin) {
|
|
5007
|
+
const body = object(await readBody(request));
|
|
5008
|
+
const { roomId, localRoom } = await this.resolveLocalRoom(body, ticket.game);
|
|
5009
|
+
const permission = await localRoom.room.canWatch();
|
|
4017
5010
|
if (!permission.ok) {
|
|
4018
5011
|
throw new DevHttpError(
|
|
4019
5012
|
permission.code === "room_not_found" ? 404 : 409,
|
|
@@ -4021,7 +5014,7 @@ var DevService = class {
|
|
|
4021
5014
|
this.roomErrorMessage(permission.code)
|
|
4022
5015
|
);
|
|
4023
5016
|
}
|
|
4024
|
-
sendJson(response, this.
|
|
5017
|
+
sendJson(response, this.watchResponse(roomId, localRoom.code, playerFromTicket(ticket)), 200, origin);
|
|
4025
5018
|
}
|
|
4026
5019
|
joinResponse(roomId, code, player) {
|
|
4027
5020
|
const join4 = joinTicket(player, roomId, this.secret);
|
|
@@ -4032,6 +5025,15 @@ var DevService = class {
|
|
|
4032
5025
|
url: `ws://localhost:${this.port}/rooms/${roomId}?j=${encodeURIComponent(join4)}`
|
|
4033
5026
|
};
|
|
4034
5027
|
}
|
|
5028
|
+
watchResponse(roomId, code, player) {
|
|
5029
|
+
const watch = joinTicket(player, roomId, this.secret, "watch");
|
|
5030
|
+
return {
|
|
5031
|
+
roomId,
|
|
5032
|
+
code,
|
|
5033
|
+
watch,
|
|
5034
|
+
url: `ws://localhost:${this.port}/rooms/${roomId}?w=${encodeURIComponent(watch)}`
|
|
5035
|
+
};
|
|
5036
|
+
}
|
|
4035
5037
|
uniqueCode() {
|
|
4036
5038
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
4037
5039
|
const code = randomUniform(ALFABETO_CODICE, 6);
|
|
@@ -4054,6 +5056,8 @@ var DevService = class {
|
|
|
4054
5056
|
if (code === "room_full") return "Room is full.";
|
|
4055
5057
|
if (code === "room_playing") return "The game has already started.";
|
|
4056
5058
|
if (code === "room_ended") return "Room has ended.";
|
|
5059
|
+
if (code === "spectators_disabled") return "Spectators are not allowed in this game.";
|
|
5060
|
+
if (code === "spectators_full") return "The room has no spectator seats left.";
|
|
4057
5061
|
return "Room not found.";
|
|
4058
5062
|
}
|
|
4059
5063
|
rejectUpgrade(socket, status, code, message) {
|
|
@@ -4084,6 +5088,7 @@ async function runDev(options) {
|
|
|
4084
5088
|
readGame(root),
|
|
4085
5089
|
loadDefinition(root)
|
|
4086
5090
|
]);
|
|
5091
|
+
if (richiedeServer(manifest) && definition === null) throw new Error("server.js is required by a room mode.");
|
|
4087
5092
|
const server = createServer();
|
|
4088
5093
|
let service;
|
|
4089
5094
|
await new Promise((resolveListen, rejectListen) => {
|
|
@@ -4100,6 +5105,12 @@ async function runDev(options) {
|
|
|
4100
5105
|
const address = server.address();
|
|
4101
5106
|
if (address === null || typeof address === "string") throw new Error("The local server address is unavailable.");
|
|
4102
5107
|
service = new DevService(root, clientRoot, manifest, definition, address.port);
|
|
5108
|
+
try {
|
|
5109
|
+
await service.initialize();
|
|
5110
|
+
} catch (cause) {
|
|
5111
|
+
await new Promise((resolveClose) => server.close(() => resolveClose()));
|
|
5112
|
+
throw cause;
|
|
5113
|
+
}
|
|
4103
5114
|
server.on("request", (request, response) => {
|
|
4104
5115
|
void service.handle(request, response).catch((cause) => sendError(response, cause));
|
|
4105
5116
|
});
|
|
@@ -4122,6 +5133,117 @@ async function runDev(options) {
|
|
|
4122
5133
|
});
|
|
4123
5134
|
}
|
|
4124
5135
|
|
|
5136
|
+
// src/templates.ts
|
|
5137
|
+
function templateManifest(id, name, multiplayer) {
|
|
5138
|
+
return {
|
|
5139
|
+
manifest: 1,
|
|
5140
|
+
id,
|
|
5141
|
+
name,
|
|
5142
|
+
platform: "both",
|
|
5143
|
+
overlay: { version: 1, accent: "#a8efc5" },
|
|
5144
|
+
...multiplayer ? { players: { min: 2, max: 4 }, lobby: true, persistent: true, spectators: { delayMs: 3e3 } } : {},
|
|
5145
|
+
modes: [
|
|
5146
|
+
{ id: "practice", execution: "local", label: "Practice", instructions: "Tap eight lights. Click, tap or press Space.", players: { min: 1, max: 1 }, lobby: false },
|
|
5147
|
+
...multiplayer ? [{
|
|
5148
|
+
id: "together",
|
|
5149
|
+
execution: "room",
|
|
5150
|
+
label: "Together",
|
|
5151
|
+
instructions: "Light up the field together. Eight lights complete a round.",
|
|
5152
|
+
matchmaking: { key: ["pool"], defaults: { pool: "v1" }, timeoutMs: 12e3 }
|
|
5153
|
+
}] : []
|
|
5154
|
+
]
|
|
5155
|
+
};
|
|
5156
|
+
}
|
|
5157
|
+
var templateIndex = `<!doctype html>
|
|
5158
|
+
<html lang="en">
|
|
5159
|
+
<head>
|
|
5160
|
+
<meta charset="utf-8">
|
|
5161
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
5162
|
+
<title>Light field</title>
|
|
5163
|
+
<style>
|
|
5164
|
+
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#12252b;color:#f2faf3;font:16px system-ui}
|
|
5165
|
+
canvas{display:block;width:100vw;height:100dvh;touch-action:none;outline:none}
|
|
5166
|
+
#status{position:absolute;left:max(16px,env(safe-area-inset-left));top:max(14px,env(safe-area-inset-top));margin:0;pointer-events:none;max-width:calc(100% - 210px)}
|
|
5167
|
+
</style>
|
|
5168
|
+
</head>
|
|
5169
|
+
<body>
|
|
5170
|
+
<canvas tabindex="0" aria-label="Light field. Tap a light or press Space."></canvas>
|
|
5171
|
+
<p id="status" role="status" aria-live="polite">Loading...</p>
|
|
5172
|
+
<script type="module">
|
|
5173
|
+
import { caisual } from '/__caisual/kit/v1.js';
|
|
5174
|
+
const c = await caisual.connect();
|
|
5175
|
+
// La sonda locale legge il client del gioco senza aprire un'altra sessione.
|
|
5176
|
+
if (location.hostname === 'localhost' || location.hostname.endsWith('.localhost')) window.caisualDebug = { c };
|
|
5177
|
+
const canvas = document.querySelector('canvas'), ctx = canvas.getContext('2d'), status = document.querySelector('#status');
|
|
5178
|
+
let session = { kind: 'idle' }, state = { hits: 0 }, blocked = false, stops = [], localId = null, offline = false, reserved = [];
|
|
5179
|
+
let width = 1, height = 1, target = { x: 0, y: 0, radius: 24 };
|
|
5180
|
+
const position = (hits) => ({ x: .25 + ((hits * 7) % 11) / 20, y: .28 + ((hits * 3) % 7) / 14 });
|
|
5181
|
+
const playing = () => offline ? state.hits < 8 : session.kind === 'local' ? session.status === 'playing' : session.kind === 'room' && session.room.status === 'playing';
|
|
5182
|
+
function draw() {
|
|
5183
|
+
const point = position(state.hits), radius = Math.max(28, Math.min(width, height) * .09);
|
|
5184
|
+
target = { x: point.x * width, y: point.y * height, radius };
|
|
5185
|
+
ctx.clearRect(0, 0, width, height);
|
|
5186
|
+
const gradient = ctx.createLinearGradient(0, 0, width, height); gradient.addColorStop(0, '#12252b'); gradient.addColorStop(1, '#254945'); ctx.fillStyle = gradient; ctx.fillRect(0, 0, width, height);
|
|
5187
|
+
ctx.fillStyle = '#ffffff12';
|
|
5188
|
+
for (let x = 24; x < width; x += 48) for (let y = 24; y < height; y += 48) { ctx.beginPath(); ctx.arc(x, y, 2, 0, Math.PI * 2); ctx.fill(); }
|
|
5189
|
+
if (state.hits < 8) {
|
|
5190
|
+
ctx.fillStyle = '#a8efc518'; ctx.beginPath(); ctx.arc(target.x, target.y, radius * 1.55, 0, Math.PI * 2); ctx.fill();
|
|
5191
|
+
ctx.fillStyle = '#a8efc5'; ctx.beginPath(); ctx.arc(target.x, target.y, radius, 0, Math.PI * 2); ctx.fill();
|
|
5192
|
+
ctx.fillStyle = '#18322c'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = 'bold ' + Math.round(radius * .7) + 'px system-ui'; ctx.fillText(String(state.hits + 1), target.x, target.y);
|
|
5193
|
+
} else {
|
|
5194
|
+
ctx.fillStyle = '#a8efc5'; ctx.textAlign = 'center'; ctx.font = 'bold ' + Math.min(54, width / 10) + 'px system-ui'; ctx.fillText('All lit up!', width / 2, height / 2);
|
|
5195
|
+
}
|
|
5196
|
+
status.textContent = state.hits >= 8 ? 'Eight lights. Nicely done.' : 'Tap a light \xB7 ' + state.hits + ' / 8';
|
|
5197
|
+
canvas.dataset.state = JSON.stringify({ hits: state.hits, target, playing: playing() });
|
|
5198
|
+
}
|
|
5199
|
+
function resize() { width = innerWidth; height = innerHeight; const ratio = Math.min(devicePixelRatio || 1, 2); canvas.width = width * ratio; canvas.height = height * ratio; ctx.setTransform(ratio, 0, 0, ratio, 0, 0); draw(); placeHud(); }
|
|
5200
|
+
function placeHud() {
|
|
5201
|
+
// La pillola puo' crescere con un invito: il campo resta intero e l'HUD le lascia spazio.
|
|
5202
|
+
const left = parseFloat(getComputedStyle(status).left) || 16;
|
|
5203
|
+
const rectangles = reserved.filter((rect) => rect.y < 72 && rect.x + rect.width > left);
|
|
5204
|
+
const right = Math.min(width - 16, ...rectangles.map((rect) => rect.x));
|
|
5205
|
+
status.style.top = right - left < 100 ? Math.max(14, ...rectangles.map((rect) => rect.y + rect.height + 8)) + 'px' : '';
|
|
5206
|
+
status.style.maxWidth = Math.max(60, (right - left < 100 ? width - 16 : right - 12) - left) + 'px';
|
|
5207
|
+
}
|
|
5208
|
+
c.overlay.onChange((view) => { blocked = view.inputBlocked; reserved = view.reservedRects; placeHud(); });
|
|
5209
|
+
c.session.onChange((next) => {
|
|
5210
|
+
stops.forEach((stop) => stop()); stops = []; session = next;
|
|
5211
|
+
if (next.kind === 'local') { if (next.id !== localId) state = { hits: 0 }; localId = next.id; }
|
|
5212
|
+
else if (next.kind === 'room' || next.kind === 'watch') {
|
|
5213
|
+
const room = next.room; state = room.state ?? { hits: 0 };
|
|
5214
|
+
stops.push(room.onState((value) => { state = value; draw(); }), room.onStatus(draw));
|
|
5215
|
+
} else state = { hits: 0 };
|
|
5216
|
+
draw();
|
|
5217
|
+
});
|
|
5218
|
+
function hit() {
|
|
5219
|
+
if (blocked || !playing() || state.hits >= 8) return;
|
|
5220
|
+
// Il server decide il progresso condiviso; il client propone solo la luce corrente.
|
|
5221
|
+
if (session.kind === 'room') session.room.send({ hit: state.hits });
|
|
5222
|
+
else { state = { hits: state.hits + 1 }; if (state.hits === 8 && !offline) c.session.finish(); draw(); }
|
|
5223
|
+
}
|
|
5224
|
+
canvas.addEventListener('pointerdown', (event) => { if (Math.hypot(event.clientX - target.x, event.clientY - target.y) <= target.radius) hit(); });
|
|
5225
|
+
addEventListener('keydown', (event) => { if (event.code === 'Space' && !event.repeat && !blocked) { event.preventDefault(); hit(); } });
|
|
5226
|
+
addEventListener('resize', resize); resize(); c.session.ready();
|
|
5227
|
+
// Senza ospite resta una prova locale utilizzabile anche aprendo il file da un server statico.
|
|
5228
|
+
if (!c.session.capabilities.overlay) { offline = true; draw(); }
|
|
5229
|
+
</script>
|
|
5230
|
+
</body>
|
|
5231
|
+
</html>
|
|
5232
|
+
`;
|
|
5233
|
+
var templateServer = `import { defineGame } from '@caisual/kit/server';
|
|
5234
|
+
|
|
5235
|
+
export default defineGame({
|
|
5236
|
+
tickRate: 0,
|
|
5237
|
+
onCreate(room) { room.state = { hits: 0 }; },
|
|
5238
|
+
onMessage(room, player, message) {
|
|
5239
|
+
if (room.status !== 'playing' || player.role === 'spectator' || !message || message.hit !== room.state.hits) return;
|
|
5240
|
+
// La revisione rende innocui due tocchi contemporanei sulla stessa luce.
|
|
5241
|
+
room.state.hits++;
|
|
5242
|
+
if (room.state.hits === 8) room.end({ lights: 8 });
|
|
5243
|
+
},
|
|
5244
|
+
});
|
|
5245
|
+
`;
|
|
5246
|
+
|
|
4125
5247
|
// src/scan.ts
|
|
4126
5248
|
var MASSIMO_BYTE_SCANSIONE = 8e6;
|
|
4127
5249
|
var ESTENSIONI_TESTO = /* @__PURE__ */ new Set([".js", ".mjs", ".cjs", ".html"]);
|
|
@@ -4279,18 +5401,21 @@ var ApiError = class extends Error {
|
|
|
4279
5401
|
hints;
|
|
4280
5402
|
};
|
|
4281
5403
|
function help() {
|
|
4282
|
-
return `Caisual ${"0.
|
|
5404
|
+
return `Caisual ${"0.9.0"}
|
|
4283
5405
|
|
|
4284
5406
|
Usage:
|
|
4285
5407
|
caisual init [--multiplayer] [folder]
|
|
4286
5408
|
caisual dev [folder] [--port 8790]
|
|
4287
5409
|
caisual publish [folder]
|
|
5410
|
+
caisual unlist [folder|id]
|
|
5411
|
+
caisual relist [folder|id]
|
|
5412
|
+
caisual delete [folder|id] --yes
|
|
4288
5413
|
caisual skill
|
|
4289
5414
|
caisual --help
|
|
4290
5415
|
caisual --version
|
|
4291
5416
|
|
|
4292
5417
|
Environment:
|
|
4293
|
-
CAISUAL_KEY Required by publish
|
|
5418
|
+
CAISUAL_KEY Required by publish, unlist, relist, and delete. Never accepted as a flag.
|
|
4294
5419
|
CAISUAL_ORIGIN Portal origin for development. Defaults to ${DEFAULT_ORIGIN}.
|
|
4295
5420
|
`;
|
|
4296
5421
|
}
|
|
@@ -4320,130 +5445,15 @@ async function init(folderArgument, multiplayer) {
|
|
|
4320
5445
|
} catch {
|
|
4321
5446
|
throw new CliError(2, `The game folder could not be created: ${root}`);
|
|
4322
5447
|
}
|
|
4323
|
-
const folderName =
|
|
4324
|
-
const manifest =
|
|
4325
|
-
manifest: 1,
|
|
4326
|
-
id: slugFromFolder(folderName),
|
|
4327
|
-
name: displayName(folderName),
|
|
4328
|
-
platform: "both",
|
|
4329
|
-
...multiplayer ? { players: { min: 1, max: 4 }, lobby: true, voice: "room" } : {}
|
|
4330
|
-
};
|
|
5448
|
+
const folderName = basename2(root);
|
|
5449
|
+
const manifest = templateManifest(slugFromFolder(folderName), displayName(folderName), multiplayer);
|
|
4331
5450
|
const manifestPath = join3(root, "caisual.json");
|
|
4332
5451
|
const indexPath = join3(root, "client", "index.html");
|
|
4333
|
-
const singlePlayerIndex = `<!doctype html>
|
|
4334
|
-
<html lang="en">
|
|
4335
|
-
<head>
|
|
4336
|
-
<meta charset="utf-8">
|
|
4337
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
4338
|
-
<title>Hello</title>
|
|
4339
|
-
</head>
|
|
4340
|
-
<body>
|
|
4341
|
-
<main>Hello, <span id="player">player</span>. Today's seed is <span id="seed">?</span>.</main>
|
|
4342
|
-
<script type="module">
|
|
4343
|
-
// The Caisual kit: player identity, cloud saves, leaderboards, daily seed.
|
|
4344
|
-
// API reference: https://caisual.com/kit.md
|
|
4345
|
-
import { caisual } from '/__caisual/kit/v1.js';
|
|
4346
|
-
|
|
4347
|
-
const c = await caisual.connect();
|
|
4348
|
-
document.getElementById('player').textContent = c.player.name;
|
|
4349
|
-
document.getElementById('seed').textContent = String(c.daily.seed);
|
|
4350
|
-
</script>
|
|
4351
|
-
</body>
|
|
4352
|
-
</html>
|
|
4353
|
-
`;
|
|
4354
|
-
const multiplayerIndex = `<!doctype html>
|
|
4355
|
-
<html lang="en">
|
|
4356
|
-
<head>
|
|
4357
|
-
<meta charset="utf-8">
|
|
4358
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
4359
|
-
<title>Multiplayer game</title>
|
|
4360
|
-
</head>
|
|
4361
|
-
<body>
|
|
4362
|
-
<main>
|
|
4363
|
-
<p id="status">Connecting...</p>
|
|
4364
|
-
<p id="invite"></p>
|
|
4365
|
-
<ul id="players"></ul>
|
|
4366
|
-
<button id="mic" type="button">Mic</button>
|
|
4367
|
-
<ul id="voice-peers" aria-label="Voice participants"></ul>
|
|
4368
|
-
<button id="start" type="button" hidden>Start</button>
|
|
4369
|
-
<button id="send" type="button">Send a message</button>
|
|
4370
|
-
<pre id="messages"></pre>
|
|
4371
|
-
</main>
|
|
4372
|
-
<script type="module">
|
|
4373
|
-
// The Caisual kit: identity, rooms, invites. API reference: https://caisual.com/kit.md
|
|
4374
|
-
import { caisual } from '/__caisual/kit/v1.js';
|
|
4375
|
-
|
|
4376
|
-
const c = await caisual.connect();
|
|
4377
|
-
const room = c.room.invited
|
|
4378
|
-
? await c.room.join()
|
|
4379
|
-
: await c.room.create({ mode: null });
|
|
4380
|
-
globalThis.room = room;
|
|
4381
|
-
const invite = room.invite();
|
|
4382
|
-
document.getElementById('invite').textContent = \`Invite: \${invite.url}\`;
|
|
4383
|
-
|
|
4384
|
-
const show = () => {
|
|
4385
|
-
document.getElementById('status').textContent = \`Room \${room.code}: \${room.status}\`;
|
|
4386
|
-
document.getElementById('players').innerHTML = room.players
|
|
4387
|
-
.map((p) => \`<li>\${p.name}\${p.id === room.host ? ' (host)' : ''}\${p.ready ? ' ready' : ''}</li>\`)
|
|
4388
|
-
.join('');
|
|
4389
|
-
document.getElementById('start').hidden = !(room.status === 'lobby' && room.you === room.host);
|
|
4390
|
-
};
|
|
4391
|
-
show();
|
|
4392
|
-
room.onPlayers(show);
|
|
4393
|
-
room.onStatus(show);
|
|
4394
|
-
room.ready(true);
|
|
4395
|
-
const mic = document.getElementById('mic');
|
|
4396
|
-
const showVoice = () => {
|
|
4397
|
-
mic.textContent = room.voice.state === 'off'
|
|
4398
|
-
? 'Mic'
|
|
4399
|
-
: room.voice.muted ? 'Unmute' : 'Mute';
|
|
4400
|
-
mic.disabled = room.voice.state === 'joining' || room.voice.state === 'reconnecting';
|
|
4401
|
-
document.getElementById('voice-peers').innerHTML = room.voice.peers
|
|
4402
|
-
.map((peer) => {
|
|
4403
|
-
const player = room.players.find((item) => item.id === peer.id);
|
|
4404
|
-
const name = player?.name ?? peer.id;
|
|
4405
|
-
return \`<li>\${name}: \${peer.speaking ? 'speaking' : peer.muted ? 'muted' : 'quiet'}</li>\`;
|
|
4406
|
-
})
|
|
4407
|
-
.join('');
|
|
4408
|
-
};
|
|
4409
|
-
showVoice();
|
|
4410
|
-
room.voice.onState(showVoice);
|
|
4411
|
-
room.voice.onPeers(showVoice);
|
|
4412
|
-
mic.addEventListener('click', async () => {
|
|
4413
|
-
try {
|
|
4414
|
-
if (room.voice.state === 'off') await room.voice.join();
|
|
4415
|
-
else room.voice.mute(!room.voice.muted);
|
|
4416
|
-
} catch (error) {
|
|
4417
|
-
document.getElementById('messages').textContent +=
|
|
4418
|
-
'Voice: ' + (error instanceof Error ? error.message : String(error)) + '\\n';
|
|
4419
|
-
}
|
|
4420
|
-
showVoice();
|
|
4421
|
-
});
|
|
4422
|
-
document.getElementById('start').addEventListener('click', () => room.start());
|
|
4423
|
-
room.onMessage((message) => {
|
|
4424
|
-
document.getElementById('messages').textContent += JSON.stringify(message) + '\\n';
|
|
4425
|
-
});
|
|
4426
|
-
document.getElementById('send').addEventListener('click', () => {
|
|
4427
|
-
room.send({ text: 'Hello from ' + c.player.name });
|
|
4428
|
-
});
|
|
4429
|
-
</script>
|
|
4430
|
-
</body>
|
|
4431
|
-
</html>
|
|
4432
|
-
`;
|
|
4433
|
-
const server = `import { defineGame } from '@caisual/kit/server';
|
|
4434
|
-
|
|
4435
|
-
export default defineGame({
|
|
4436
|
-
tickRate: 0,
|
|
4437
|
-
onMessage(room, _player, message) {
|
|
4438
|
-
room.broadcast(message);
|
|
4439
|
-
},
|
|
4440
|
-
});
|
|
4441
|
-
`;
|
|
4442
5452
|
const manifestCreated = await writeNewFile(manifestPath, `${JSON.stringify(manifest, null, 2)}
|
|
4443
5453
|
`);
|
|
4444
5454
|
const indexCreated = await writeNewFile(
|
|
4445
5455
|
indexPath,
|
|
4446
|
-
|
|
5456
|
+
templateIndex
|
|
4447
5457
|
);
|
|
4448
5458
|
process.stdout.write(`${manifestCreated ? "Created" : "Kept"} ${manifestPath}
|
|
4449
5459
|
`);
|
|
@@ -4451,7 +5461,7 @@ export default defineGame({
|
|
|
4451
5461
|
`);
|
|
4452
5462
|
if (multiplayer) {
|
|
4453
5463
|
const serverPath = join3(root, "server.js");
|
|
4454
|
-
const serverCreated = await writeNewFile(serverPath,
|
|
5464
|
+
const serverCreated = await writeNewFile(serverPath, templateServer);
|
|
4455
5465
|
process.stdout.write(`${serverCreated ? "Created" : "Kept"} ${serverPath}
|
|
4456
5466
|
`);
|
|
4457
5467
|
}
|
|
@@ -4583,6 +5593,13 @@ function portalOrigin() {
|
|
|
4583
5593
|
}
|
|
4584
5594
|
return url.origin;
|
|
4585
5595
|
}
|
|
5596
|
+
function publishingKey() {
|
|
5597
|
+
const key = process.env.CAISUAL_KEY?.trim();
|
|
5598
|
+
if (!key) {
|
|
5599
|
+
throw new CliError(3, "CAISUAL_KEY is required. Set it with: export CAISUAL_KEY=ck_...");
|
|
5600
|
+
}
|
|
5601
|
+
return key;
|
|
5602
|
+
}
|
|
4586
5603
|
function object2(value) {
|
|
4587
5604
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
4588
5605
|
}
|
|
@@ -4803,16 +5820,14 @@ async function publish(folderArgument) {
|
|
|
4803
5820
|
const serverResult = await readServerFile(root);
|
|
4804
5821
|
const server = serverResult.file;
|
|
4805
5822
|
try {
|
|
5823
|
+
if (richiedeServer(manifest) && server === null) throw new CliError(2, "server.js is required by a room mode.");
|
|
4806
5824
|
const filePaths = new Set(files.map((file) => file.path));
|
|
4807
5825
|
for (const required of [manifest.cover, ...manifest.screenshots]) {
|
|
4808
5826
|
if (required !== null && !filePaths.has(required)) {
|
|
4809
5827
|
throw new CliError(2, `caisual.json: referenced file not found in client/: ${required}`);
|
|
4810
5828
|
}
|
|
4811
5829
|
}
|
|
4812
|
-
const key =
|
|
4813
|
-
if (!key) {
|
|
4814
|
-
throw new CliError(3, "CAISUAL_KEY is required. Set it with: export CAISUAL_KEY=ck_...");
|
|
4815
|
-
}
|
|
5830
|
+
const key = publishingKey();
|
|
4816
5831
|
const origin = portalOrigin();
|
|
4817
5832
|
const declared = files.map(({ path, bytes, sha256: digest }) => ({
|
|
4818
5833
|
path,
|
|
@@ -4868,6 +5883,40 @@ async function publish(folderArgument) {
|
|
|
4868
5883
|
}
|
|
4869
5884
|
}
|
|
4870
5885
|
}
|
|
5886
|
+
async function gameIdFromTarget(target) {
|
|
5887
|
+
const path = resolve2(process.cwd(), target);
|
|
5888
|
+
try {
|
|
5889
|
+
if ((await fs3.stat(path)).isDirectory()) return (await readManifest(path)).id;
|
|
5890
|
+
} catch (error) {
|
|
5891
|
+
if (error.code !== "ENOENT") {
|
|
5892
|
+
throw new CliError(2, `The game target could not be read: ${target}`);
|
|
5893
|
+
}
|
|
5894
|
+
}
|
|
5895
|
+
if (target === "") throw new CliError(1, "The game id cannot be empty.");
|
|
5896
|
+
return target;
|
|
5897
|
+
}
|
|
5898
|
+
async function manageGame(operation, target) {
|
|
5899
|
+
const id = await gameIdFromTarget(target);
|
|
5900
|
+
const key = publishingKey();
|
|
5901
|
+
const visibility = operation === "unlist" ? "unlisted" : "public";
|
|
5902
|
+
const payload = await requestJson(
|
|
5903
|
+
`${portalOrigin()}/api/games/${encodeURIComponent(id)}`,
|
|
5904
|
+
operation === "delete" ? { method: "DELETE", headers: { Authorization: `Bearer ${key}` } } : {
|
|
5905
|
+
method: "PATCH",
|
|
5906
|
+
headers: {
|
|
5907
|
+
Authorization: `Bearer ${key}`,
|
|
5908
|
+
"Content-Type": "application/json; charset=utf-8"
|
|
5909
|
+
},
|
|
5910
|
+
body: JSON.stringify({ visibility })
|
|
5911
|
+
}
|
|
5912
|
+
);
|
|
5913
|
+
if (payload.id !== id || (operation === "delete" ? payload.deleted !== true : payload.visibility !== visibility)) {
|
|
5914
|
+
throw new CliError(1, "The portal returned an invalid game response.");
|
|
5915
|
+
}
|
|
5916
|
+
const verb = operation === "unlist" ? "Unlisted" : operation === "relist" ? "Listed" : "Deleted";
|
|
5917
|
+
process.stdout.write(`${verb} ${id}.
|
|
5918
|
+
`);
|
|
5919
|
+
}
|
|
4871
5920
|
async function installSkill() {
|
|
4872
5921
|
const root = process.cwd();
|
|
4873
5922
|
const skillPath = join3(root, ".claude", "skills", "caisual", "SKILL.md");
|
|
@@ -4910,7 +5959,7 @@ async function run(argumentsList) {
|
|
|
4910
5959
|
return;
|
|
4911
5960
|
}
|
|
4912
5961
|
if (command === "--version" || command === "-V") {
|
|
4913
|
-
process.stdout.write(`${"0.
|
|
5962
|
+
process.stdout.write(`${"0.9.0"}
|
|
4914
5963
|
`);
|
|
4915
5964
|
return;
|
|
4916
5965
|
}
|
|
@@ -4928,6 +5977,33 @@ async function run(argumentsList) {
|
|
|
4928
5977
|
await publish(argumentsAfterCommand[0] ?? ".");
|
|
4929
5978
|
return;
|
|
4930
5979
|
}
|
|
5980
|
+
if (command === "unlist" || command === "relist") {
|
|
5981
|
+
if (argumentsAfterCommand.length > 1 || argumentsAfterCommand.some((value) => value.startsWith("-"))) {
|
|
5982
|
+
throw new CliError(1, `Usage: caisual ${command} [folder|id]`);
|
|
5983
|
+
}
|
|
5984
|
+
await manageGame(command, argumentsAfterCommand[0] ?? ".");
|
|
5985
|
+
return;
|
|
5986
|
+
}
|
|
5987
|
+
if (command === "delete") {
|
|
5988
|
+
let target = ".";
|
|
5989
|
+
let targetSeen = false;
|
|
5990
|
+
let confirmed = false;
|
|
5991
|
+
for (const argument of argumentsAfterCommand) {
|
|
5992
|
+
if (argument === "--yes" && !confirmed) {
|
|
5993
|
+
confirmed = true;
|
|
5994
|
+
} else if (!argument.startsWith("-") && !targetSeen) {
|
|
5995
|
+
target = argument;
|
|
5996
|
+
targetSeen = true;
|
|
5997
|
+
} else {
|
|
5998
|
+
throw new CliError(1, "Usage: caisual delete [folder|id] --yes");
|
|
5999
|
+
}
|
|
6000
|
+
}
|
|
6001
|
+
if (!confirmed) {
|
|
6002
|
+
throw new CliError(1, "Deletion is permanent. Re-run with --yes.");
|
|
6003
|
+
}
|
|
6004
|
+
await manageGame("delete", target);
|
|
6005
|
+
return;
|
|
6006
|
+
}
|
|
4931
6007
|
if (command === "dev") {
|
|
4932
6008
|
let folder = ".";
|
|
4933
6009
|
let port = 8790;
|
|
@@ -4970,7 +6046,7 @@ try {
|
|
|
4970
6046
|
await run(process.argv.slice(2));
|
|
4971
6047
|
} catch (error) {
|
|
4972
6048
|
if (error instanceof ApiError) {
|
|
4973
|
-
process.stderr.write(`${error.message}
|
|
6049
|
+
process.stderr.write(`${error.code}: ${error.message}
|
|
4974
6050
|
`);
|
|
4975
6051
|
for (const hint of error.hints) process.stderr.write(`Hint: ${hint}
|
|
4976
6052
|
`);
|