@caisual/cli 0.1.0 → 0.3.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 +3365 -210
- package/package.json +2 -1
package/dist/caisual.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/caisual.ts
|
|
4
|
-
import { createHash } from "node:crypto";
|
|
5
|
-
import { createReadStream, promises as
|
|
6
|
-
import { basename, extname, join, resolve } from "node:path";
|
|
4
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
5
|
+
import { createReadStream, promises as fs2 } from "node:fs";
|
|
6
|
+
import { basename, extname as extname2, join as join2, resolve as resolve2 } from "node:path";
|
|
7
7
|
|
|
8
8
|
// ../contracts/src/slug.ts
|
|
9
9
|
var NOMI_RISERVATI = [
|
|
@@ -229,166 +229,3130 @@ function validaManifest(valore) {
|
|
|
229
229
|
}
|
|
230
230
|
}
|
|
231
231
|
}
|
|
232
|
-
let lobby = false;
|
|
233
|
-
if (dati.lobby !== void 0) {
|
|
234
|
-
if (typeof dati.lobby !== "boolean") errori.push("lobby: must be a boolean.");
|
|
235
|
-
else lobby = dati.lobby;
|
|
232
|
+
let lobby = false;
|
|
233
|
+
if (dati.lobby !== void 0) {
|
|
234
|
+
if (typeof dati.lobby !== "boolean") errori.push("lobby: must be a boolean.");
|
|
235
|
+
else lobby = dati.lobby;
|
|
236
|
+
}
|
|
237
|
+
const roles = [];
|
|
238
|
+
if (dati.roles !== void 0) {
|
|
239
|
+
if (!Array.isArray(dati.roles)) errori.push("roles: must be an array.");
|
|
240
|
+
else {
|
|
241
|
+
const ids = /* @__PURE__ */ new Set();
|
|
242
|
+
for (const [indice, raw] of dati.roles.entries()) {
|
|
243
|
+
const value = oggetto(raw);
|
|
244
|
+
if (value === null) {
|
|
245
|
+
errori.push(`roles[${indice}]: must be an object.`);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
for (const campo of Object.keys(value)) {
|
|
249
|
+
if (!["id", "min", "max"].includes(campo)) errori.push(`roles[${indice}].${campo}: unknown field.`);
|
|
250
|
+
}
|
|
251
|
+
const idRuolo = value.id;
|
|
252
|
+
const min = value.min;
|
|
253
|
+
const max = value.max;
|
|
254
|
+
let valido = true;
|
|
255
|
+
if (typeof idRuolo !== "string" || idRuolo.length > 32 || !ID_INTERNO.test(idRuolo)) {
|
|
256
|
+
errori.push(`roles[${indice}].id: must be 1-32 lowercase letters, digits, or internal hyphens.`);
|
|
257
|
+
valido = false;
|
|
258
|
+
} else if (ids.has(idRuolo)) {
|
|
259
|
+
errori.push(`roles[${indice}].id: duplicate role ${idRuolo}.`);
|
|
260
|
+
valido = false;
|
|
261
|
+
} else ids.add(idRuolo);
|
|
262
|
+
if (!interoTra(min, 0, 16)) {
|
|
263
|
+
errori.push(`roles[${indice}].min: must be an integer from 0 to 16.`);
|
|
264
|
+
valido = false;
|
|
265
|
+
}
|
|
266
|
+
if (max !== void 0 && !interoTra(max, 0, 16)) {
|
|
267
|
+
errori.push(`roles[${indice}].max: must be an integer from 0 to 16 when present.`);
|
|
268
|
+
valido = false;
|
|
269
|
+
}
|
|
270
|
+
if (typeof min === "number" && typeof max === "number" && min > max) {
|
|
271
|
+
errori.push(`roles[${indice}].max: must be greater than or equal to min.`);
|
|
272
|
+
valido = false;
|
|
273
|
+
}
|
|
274
|
+
if (valido) roles.push(max === void 0 ? { id: idRuolo, min } : { id: idRuolo, min, max });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
let teams = null;
|
|
279
|
+
if (dati.teams !== void 0 && dati.teams !== null) {
|
|
280
|
+
const value = oggetto(dati.teams);
|
|
281
|
+
if (value === null) errori.push("teams: must be null or an object with min and max.");
|
|
282
|
+
else {
|
|
283
|
+
for (const campo of Object.keys(value)) {
|
|
284
|
+
if (campo !== "min" && campo !== "max") errori.push(`teams.${campo}: unknown field.`);
|
|
285
|
+
}
|
|
286
|
+
if (!interoTra(value.min, 2, 16)) errori.push("teams.min: must be an integer from 2 to 16.");
|
|
287
|
+
if (!interoTra(value.max, 2, 16)) errori.push("teams.max: must be an integer from 2 to 16.");
|
|
288
|
+
if (interoTra(value.min, 2, 16) && interoTra(value.max, 2, 16)) {
|
|
289
|
+
if (value.min > value.max) errori.push("teams.max: must be greater than or equal to teams.min.");
|
|
290
|
+
else teams = { min: value.min, max: value.max };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
let voice = "none";
|
|
295
|
+
if (dati.voice !== void 0) {
|
|
296
|
+
if (typeof dati.voice !== "string" || !VOICE.has(dati.voice)) {
|
|
297
|
+
errori.push("voice: must be none, room, team, or proximity.");
|
|
298
|
+
} else voice = dati.voice;
|
|
299
|
+
}
|
|
300
|
+
const modes = [];
|
|
301
|
+
if (dati.modes !== void 0) {
|
|
302
|
+
if (!Array.isArray(dati.modes)) errori.push("modes: must be an array.");
|
|
303
|
+
else {
|
|
304
|
+
const ids = /* @__PURE__ */ new Set();
|
|
305
|
+
for (const [indice, raw] of dati.modes.entries()) {
|
|
306
|
+
const value = oggetto(raw);
|
|
307
|
+
if (value === null) {
|
|
308
|
+
errori.push(`modes[${indice}]: must be an object.`);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
for (const campo of Object.keys(value)) {
|
|
312
|
+
if (campo !== "id" && campo !== "matchmaking") errori.push(`modes[${indice}].${campo}: unknown field.`);
|
|
313
|
+
}
|
|
314
|
+
if (typeof value.id !== "string" || value.id.length > 32 || !ID_INTERNO.test(value.id)) {
|
|
315
|
+
errori.push(`modes[${indice}].id: must be 1-32 lowercase letters, digits, or internal hyphens.`);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (ids.has(value.id)) {
|
|
319
|
+
errori.push(`modes[${indice}].id: duplicate mode ${value.id}.`);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
ids.add(value.id);
|
|
323
|
+
if (value.matchmaking === void 0) {
|
|
324
|
+
modes.push({ id: value.id });
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const matchmaking = oggetto(value.matchmaking);
|
|
328
|
+
if (matchmaking === null) {
|
|
329
|
+
errori.push(`modes[${indice}].matchmaking: must be an object.`);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
for (const campo of Object.keys(matchmaking)) {
|
|
333
|
+
if (!["key", "timeoutMs", "fallback"].includes(campo)) {
|
|
334
|
+
errori.push(`modes[${indice}].matchmaking.${campo}: unknown field.`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
let valido = true;
|
|
338
|
+
const key = [];
|
|
339
|
+
if (!Array.isArray(matchmaking.key) || matchmaking.key.length === 0) {
|
|
340
|
+
errori.push(`modes[${indice}].matchmaking.key: must be a non-empty array.`);
|
|
341
|
+
valido = false;
|
|
342
|
+
} else for (const [keyIndice, item] of matchmaking.key.entries()) {
|
|
343
|
+
if (typeof item !== "string" || item.length > 32 || !ID_INTERNO.test(item)) {
|
|
344
|
+
errori.push(`modes[${indice}].matchmaking.key[${keyIndice}]: must be 1-32 lowercase letters, digits, or internal hyphens.`);
|
|
345
|
+
valido = false;
|
|
346
|
+
} else key.push(item);
|
|
347
|
+
}
|
|
348
|
+
if (!Number.isSafeInteger(matchmaking.timeoutMs) || matchmaking.timeoutMs < 1) {
|
|
349
|
+
errori.push(`modes[${indice}].matchmaking.timeoutMs: must be a positive integer.`);
|
|
350
|
+
valido = false;
|
|
351
|
+
}
|
|
352
|
+
if (matchmaking.fallback !== "ghost" && matchmaking.fallback !== "bot") {
|
|
353
|
+
errori.push(`modes[${indice}].matchmaking.fallback: must be ghost or bot.`);
|
|
354
|
+
valido = false;
|
|
355
|
+
}
|
|
356
|
+
if (valido) modes.push({ id: value.id, matchmaking: {
|
|
357
|
+
key,
|
|
358
|
+
timeoutMs: matchmaking.timeoutMs,
|
|
359
|
+
fallback: matchmaking.fallback
|
|
360
|
+
} });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (errori.length > 0) return { ok: false, errori };
|
|
365
|
+
return { ok: true, manifest: {
|
|
366
|
+
manifest: 1,
|
|
367
|
+
id,
|
|
368
|
+
name,
|
|
369
|
+
description,
|
|
370
|
+
cover,
|
|
371
|
+
screenshots,
|
|
372
|
+
tags,
|
|
373
|
+
language,
|
|
374
|
+
platform,
|
|
375
|
+
orientation,
|
|
376
|
+
input,
|
|
377
|
+
visibility,
|
|
378
|
+
network,
|
|
379
|
+
isolated,
|
|
380
|
+
players,
|
|
381
|
+
lobby,
|
|
382
|
+
roles,
|
|
383
|
+
teams,
|
|
384
|
+
voice,
|
|
385
|
+
modes
|
|
386
|
+
} };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ../contracts/src/server-js.ts
|
|
390
|
+
var MASSIMO_BYTE_SERVER_JS = 1e6;
|
|
391
|
+
var IMPORT_KIT = /\bimport\s+(?:(?:[$A-Z_a-z][$\w]*\s*,\s*)?(?:\*\s+as\s+[$A-Z_a-z][$\w]*|\{[^{}]*\})|[$A-Z_a-z][$\w]*)\s+from\s+(['"])@caisual\/kit\/server\1\s*;?/g;
|
|
392
|
+
function spaziCome(value) {
|
|
393
|
+
return value.replace(/[^\n]/g, " ");
|
|
394
|
+
}
|
|
395
|
+
function mascheraTestiECommenti(sorgente) {
|
|
396
|
+
const risultato = [...sorgente].map((carattere) => carattere === "\n" ? "\n" : " ");
|
|
397
|
+
function copiaCodice(inizio, chiudiSuGraffa) {
|
|
398
|
+
let profonditaGraffe = chiudiSuGraffa ? 1 : 0;
|
|
399
|
+
for (let indice = inizio; indice < sorgente.length; indice += 1) {
|
|
400
|
+
const carattere = sorgente[indice];
|
|
401
|
+
const prossimo = sorgente[indice + 1];
|
|
402
|
+
if (carattere === "/" && prossimo === "/") {
|
|
403
|
+
indice += 2;
|
|
404
|
+
while (indice < sorgente.length && sorgente[indice] !== "\n") indice += 1;
|
|
405
|
+
if (sorgente[indice] === "\n") risultato[indice] = "\n";
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (carattere === "/" && prossimo === "*") {
|
|
409
|
+
indice += 2;
|
|
410
|
+
while (indice < sorgente.length) {
|
|
411
|
+
if (sorgente[indice] === "\n") risultato[indice] = "\n";
|
|
412
|
+
if (sorgente[indice] === "*" && sorgente[indice + 1] === "/") {
|
|
413
|
+
indice += 1;
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
indice += 1;
|
|
417
|
+
}
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (carattere === "'" || carattere === '"') {
|
|
421
|
+
const delimitatore = carattere;
|
|
422
|
+
for (indice += 1; indice < sorgente.length; indice += 1) {
|
|
423
|
+
const interno = sorgente[indice];
|
|
424
|
+
if (interno === "\n") risultato[indice] = "\n";
|
|
425
|
+
if (interno === "\\") indice += 1;
|
|
426
|
+
else if (interno === delimitatore) break;
|
|
427
|
+
}
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (carattere === "`") {
|
|
431
|
+
indice += 1;
|
|
432
|
+
while (indice < sorgente.length) {
|
|
433
|
+
const interno = sorgente[indice];
|
|
434
|
+
const dopo = sorgente[indice + 1];
|
|
435
|
+
if (interno === "\n") risultato[indice] = "\n";
|
|
436
|
+
if (interno === "\\") indice += 2;
|
|
437
|
+
else if (interno === "`") break;
|
|
438
|
+
else if (interno === "$" && dopo === "{") {
|
|
439
|
+
indice = copiaCodice(indice + 2, true);
|
|
440
|
+
} else indice += 1;
|
|
441
|
+
}
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (chiudiSuGraffa && carattere === "{") profonditaGraffe += 1;
|
|
445
|
+
if (chiudiSuGraffa && carattere === "}") {
|
|
446
|
+
profonditaGraffe -= 1;
|
|
447
|
+
if (profonditaGraffe === 0) return indice;
|
|
448
|
+
}
|
|
449
|
+
risultato[indice] = carattere;
|
|
450
|
+
}
|
|
451
|
+
return sorgente.length;
|
|
452
|
+
}
|
|
453
|
+
copiaCodice(0, false);
|
|
454
|
+
return risultato.join("");
|
|
455
|
+
}
|
|
456
|
+
function validaServerJs(sorgente) {
|
|
457
|
+
const errori = [];
|
|
458
|
+
if (new TextEncoder().encode(sorgente).byteLength > MASSIMO_BYTE_SERVER_JS) {
|
|
459
|
+
errori.push("server.js must be at most 1 MB.");
|
|
460
|
+
}
|
|
461
|
+
const senzaImportKit = sorgente.replace(IMPORT_KIT, (importazione) => spaziCome(importazione));
|
|
462
|
+
const codice = mascheraTestiECommenti(senzaImportKit);
|
|
463
|
+
if (/\bmodule\s*\.\s*exports\b|\bexports\s*\./.test(codice)) {
|
|
464
|
+
errori.push("server.js must use ESM and cannot use CommonJS exports.");
|
|
465
|
+
}
|
|
466
|
+
if (/\brequire\s*\(/.test(codice)) {
|
|
467
|
+
errori.push("server.js must not use require().");
|
|
468
|
+
}
|
|
469
|
+
if (/\bimport\s*\(/.test(codice)) {
|
|
470
|
+
errori.push("server.js must not use dynamic import().");
|
|
471
|
+
}
|
|
472
|
+
const senzaImportMeta = codice.replace(/\bimport\s*\.\s*meta\b/g, "");
|
|
473
|
+
const riesportaDipendenza = /\bexport\s+(?:\*\s*(?:as\s+[$A-Z_a-z][$\w]*\s*)?|\{[^}]*\})\s+from\b/.test(codice);
|
|
474
|
+
if (/\bimport\b/.test(senzaImportMeta) || riesportaDipendenza) {
|
|
475
|
+
errori.push("server.js may only import from '@caisual/kit/server'.");
|
|
476
|
+
}
|
|
477
|
+
if (!/\bexport\s+default\b/.test(codice)) {
|
|
478
|
+
errori.push("server.js must have an export default.");
|
|
479
|
+
}
|
|
480
|
+
return errori.length === 0 ? { ok: true } : { ok: false, errori };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ../../docs/publish.md
|
|
484
|
+
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 "players": { "min": 1, "max": 1 },\n "lobby": 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- `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- `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 a non-empty `key` array using the same format, a positive integer `timeoutMs`, and `fallback` set to `ghost` or `bot`.\n\nThe CLI prints every manifest error in one run. Fix every listed field and rule before retrying.\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 must be one ESM file with an `export default`. Its only permitted dependency is `@caisual/kit/server`, imported with either single or double quotes. Static imports from any other path, dynamic `import()`, `require()`, and CommonJS exports are rejected.\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 cannot make outbound network requests. The `network` field in `caisual.json` controls only requests made by the browser client.\n\nThe source file 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. The CLI validates `server.js`, declares its size and SHA-256 digest, and uploads it separately from browser files. The portal validates the stored source 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\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';
|
|
485
|
+
|
|
486
|
+
// ../../docs/kit.md
|
|
487
|
+
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## 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.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\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 and joining reject with an `Error` carrying a stable `code`. Common codes are `invalid_request`, `no_server`, `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`, both `create` and `join` 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 request microphone permission and start audio.\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.muted ? 'Unmute' : 'Mute';\n}\n\nmicButton.addEventListener('click', async () => {\n if (room.voice.state === 'off') await room.voice.join();\n else 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 each pair. Spectators cannot publish audio, and `join()` rejects for them.\n\n`room.voice.state` is `off`, `joining`, `on`, or `reconnecting`. `room.voice.muted` and `room.voice.speaking` describe the local microphone. `room.voice.peers` contains the other voice participants as `{ id, muted, speaking, volume, gain }`. `volume` is the local setting and `gain` is the proximity 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, mute state, speaking state, volume, or proximity 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 the microphone and leave voice without leaving the room. `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.mode;\nroom.status;\nroom.tick;\nroom.state;\nroom.players;\nroom.host;\n\nroom.broadcast(message);\nroom.send(playerOrId, message);\nroom.kick(playerOrId);\nroom.end(result);\n\nawait room.save('round', value);\nawait room.load('round');\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.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\nFor a room using `\"voice\": \"proximity\"`, update the symmetric gain between players from server-owned positions. The value is limited to the range from 0 to 1. Calls in other voice modes have no effect.\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## 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\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\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`, `roles`, `teams`, and `modes` to describe the setup enforced before play starts. 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";
|
|
488
|
+
|
|
489
|
+
// src/dev.ts
|
|
490
|
+
import { createHash as createHash2, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
491
|
+
import { promises as fs } from "node:fs";
|
|
492
|
+
import { createServer } from "node:http";
|
|
493
|
+
import { extname, join, relative, resolve, sep } from "node:path";
|
|
494
|
+
|
|
495
|
+
// ../kit/dist/node.js
|
|
496
|
+
import { randomUUID } from "node:crypto";
|
|
497
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
498
|
+
import { dirname } from "node:path";
|
|
499
|
+
import { performance } from "node:perf_hooks";
|
|
500
|
+
import { createHash } from "node:crypto";
|
|
501
|
+
import { EventEmitter } from "node:events";
|
|
502
|
+
function isPlainObject(value) {
|
|
503
|
+
const prototype = Object.getPrototypeOf(value);
|
|
504
|
+
return prototype === Object.prototype || prototype === null;
|
|
505
|
+
}
|
|
506
|
+
function verificaValore(value, visitati) {
|
|
507
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
508
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
509
|
+
if (typeof value !== "object") return false;
|
|
510
|
+
if (visitati.has(value)) return false;
|
|
511
|
+
visitati.add(value);
|
|
512
|
+
let valido = true;
|
|
513
|
+
if (Array.isArray(value)) {
|
|
514
|
+
const chiavi = Reflect.ownKeys(value);
|
|
515
|
+
if (chiavi.some((key) => {
|
|
516
|
+
if (key === "length") return false;
|
|
517
|
+
if (typeof key !== "string" || !/^(0|[1-9][0-9]*)$/.test(key)) return true;
|
|
518
|
+
const indice = Number(key);
|
|
519
|
+
return !Number.isSafeInteger(indice) || indice >= value.length;
|
|
520
|
+
})) valido = false;
|
|
521
|
+
for (let indice = 0; indice < value.length; indice++) {
|
|
522
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(indice));
|
|
523
|
+
if (!valido || descriptor === void 0 || !descriptor.enumerable || !("value" in descriptor) || !verificaValore(descriptor.value, visitati)) {
|
|
524
|
+
valido = false;
|
|
525
|
+
break;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
} else if (isPlainObject(value)) {
|
|
529
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
530
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
531
|
+
if (typeof key !== "string" || descriptor === void 0 || !descriptor.enumerable || !("value" in descriptor) || !verificaValore(descriptor.value, visitati)) {
|
|
532
|
+
valido = false;
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
} else {
|
|
537
|
+
valido = false;
|
|
538
|
+
}
|
|
539
|
+
visitati.delete(value);
|
|
540
|
+
return valido;
|
|
541
|
+
}
|
|
542
|
+
function analizzaJson(value) {
|
|
543
|
+
if (!verificaValore(value, /* @__PURE__ */ new Set())) return { ok: false, code: "state_invalid" };
|
|
544
|
+
const testo = JSON.stringify(value);
|
|
545
|
+
return {
|
|
546
|
+
ok: true,
|
|
547
|
+
testo,
|
|
548
|
+
valore: JSON.parse(testo),
|
|
549
|
+
bytes: new TextEncoder().encode(testo).byteLength
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
function isObject(value) {
|
|
553
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
554
|
+
}
|
|
555
|
+
function visitaDiff(prima, dopo, path, patch) {
|
|
556
|
+
if (Array.isArray(prima) && Array.isArray(dopo)) {
|
|
557
|
+
if (prima.length !== dopo.length) {
|
|
558
|
+
patch.push({ op: "set", path, value: dopo });
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
for (let indice = 0; indice < dopo.length; indice++) {
|
|
562
|
+
visitaDiff(prima[indice], dopo[indice], [...path, indice], patch);
|
|
563
|
+
}
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (isObject(prima) && isObject(dopo)) {
|
|
567
|
+
for (const key of Object.keys(prima)) {
|
|
568
|
+
if (!(key in dopo)) patch.push({ op: "del", path: [...path, key] });
|
|
569
|
+
}
|
|
570
|
+
for (const [key, value] of Object.entries(dopo)) {
|
|
571
|
+
if (!(key in prima)) patch.push({ op: "set", path: [...path, key], value });
|
|
572
|
+
else visitaDiff(prima[key], value, [...path, key], patch);
|
|
573
|
+
}
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (!Object.is(prima, dopo)) patch.push({ op: "set", path, value: dopo });
|
|
577
|
+
}
|
|
578
|
+
function creaDiff(prima, dopo) {
|
|
579
|
+
const patch = [];
|
|
580
|
+
visitaDiff(prima, dopo, [], patch);
|
|
581
|
+
return patch;
|
|
582
|
+
}
|
|
583
|
+
function giornoUtc(ora) {
|
|
584
|
+
return new Date(ora).toISOString().slice(0, 10);
|
|
585
|
+
}
|
|
586
|
+
var COSTANTI_SHA256 = [
|
|
587
|
+
1116352408,
|
|
588
|
+
1899447441,
|
|
589
|
+
3049323471,
|
|
590
|
+
3921009573,
|
|
591
|
+
961987163,
|
|
592
|
+
1508970993,
|
|
593
|
+
2453635748,
|
|
594
|
+
2870763221,
|
|
595
|
+
3624381080,
|
|
596
|
+
310598401,
|
|
597
|
+
607225278,
|
|
598
|
+
1426881987,
|
|
599
|
+
1925078388,
|
|
600
|
+
2162078206,
|
|
601
|
+
2614888103,
|
|
602
|
+
3248222580,
|
|
603
|
+
3835390401,
|
|
604
|
+
4022224774,
|
|
605
|
+
264347078,
|
|
606
|
+
604807628,
|
|
607
|
+
770255983,
|
|
608
|
+
1249150122,
|
|
609
|
+
1555081692,
|
|
610
|
+
1996064986,
|
|
611
|
+
2554220882,
|
|
612
|
+
2821834349,
|
|
613
|
+
2952996808,
|
|
614
|
+
3210313671,
|
|
615
|
+
3336571891,
|
|
616
|
+
3584528711,
|
|
617
|
+
113926993,
|
|
618
|
+
338241895,
|
|
619
|
+
666307205,
|
|
620
|
+
773529912,
|
|
621
|
+
1294757372,
|
|
622
|
+
1396182291,
|
|
623
|
+
1695183700,
|
|
624
|
+
1986661051,
|
|
625
|
+
2177026350,
|
|
626
|
+
2456956037,
|
|
627
|
+
2730485921,
|
|
628
|
+
2820302411,
|
|
629
|
+
3259730800,
|
|
630
|
+
3345764771,
|
|
631
|
+
3516065817,
|
|
632
|
+
3600352804,
|
|
633
|
+
4094571909,
|
|
634
|
+
275423344,
|
|
635
|
+
430227734,
|
|
636
|
+
506948616,
|
|
637
|
+
659060556,
|
|
638
|
+
883997877,
|
|
639
|
+
958139571,
|
|
640
|
+
1322822218,
|
|
641
|
+
1537002063,
|
|
642
|
+
1747873779,
|
|
643
|
+
1955562222,
|
|
644
|
+
2024104815,
|
|
645
|
+
2227730452,
|
|
646
|
+
2361852424,
|
|
647
|
+
2428436474,
|
|
648
|
+
2756734187,
|
|
649
|
+
3204031479,
|
|
650
|
+
3329325298
|
|
651
|
+
];
|
|
652
|
+
function ruotaDestra(value, bits) {
|
|
653
|
+
return value >>> bits | value << 32 - bits;
|
|
654
|
+
}
|
|
655
|
+
function seedGiornata(slug, day) {
|
|
656
|
+
const testo = `caisual:${slug}:${day}`;
|
|
657
|
+
const bytes = Array.from(testo, (carattere) => carattere.charCodeAt(0));
|
|
658
|
+
const bitLength = bytes.length * 8;
|
|
659
|
+
bytes.push(128);
|
|
660
|
+
while (bytes.length % 64 !== 56) bytes.push(0);
|
|
661
|
+
for (let indice = 7; indice >= 0; indice--) {
|
|
662
|
+
bytes.push(indice < 4 ? bitLength >>> indice * 8 & 255 : 0);
|
|
663
|
+
}
|
|
664
|
+
let h0 = 1779033703;
|
|
665
|
+
let h1 = 3144134277;
|
|
666
|
+
let h2 = 1013904242;
|
|
667
|
+
let h3 = 2773480762;
|
|
668
|
+
let h4 = 1359893119;
|
|
669
|
+
let h5 = 2600822924;
|
|
670
|
+
let h6 = 528734635;
|
|
671
|
+
let h7 = 1541459225;
|
|
672
|
+
const parole = new Uint32Array(64);
|
|
673
|
+
for (let blocco = 0; blocco < bytes.length; blocco += 64) {
|
|
674
|
+
for (let indice = 0; indice < 16; indice++) {
|
|
675
|
+
const offset = blocco + indice * 4;
|
|
676
|
+
parole[indice] = ((bytes[offset] ?? 0) << 24 | (bytes[offset + 1] ?? 0) << 16 | (bytes[offset + 2] ?? 0) << 8 | (bytes[offset + 3] ?? 0)) >>> 0;
|
|
677
|
+
}
|
|
678
|
+
for (let indice = 16; indice < 64; indice++) {
|
|
679
|
+
const x = parole[indice - 15] ?? 0;
|
|
680
|
+
const y = parole[indice - 2] ?? 0;
|
|
681
|
+
const s0 = ruotaDestra(x, 7) ^ ruotaDestra(x, 18) ^ x >>> 3;
|
|
682
|
+
const s1 = ruotaDestra(y, 17) ^ ruotaDestra(y, 19) ^ y >>> 10;
|
|
683
|
+
parole[indice] = (parole[indice - 16] ?? 0) + s0 + (parole[indice - 7] ?? 0) + s1 >>> 0;
|
|
684
|
+
}
|
|
685
|
+
let a = h0;
|
|
686
|
+
let b = h1;
|
|
687
|
+
let c = h2;
|
|
688
|
+
let d = h3;
|
|
689
|
+
let e = h4;
|
|
690
|
+
let f = h5;
|
|
691
|
+
let g = h6;
|
|
692
|
+
let h = h7;
|
|
693
|
+
for (let indice = 0; indice < 64; indice++) {
|
|
694
|
+
const s1 = ruotaDestra(e, 6) ^ ruotaDestra(e, 11) ^ ruotaDestra(e, 25);
|
|
695
|
+
const scelta = e & f ^ ~e & g;
|
|
696
|
+
const temp1 = h + s1 + scelta + (COSTANTI_SHA256[indice] ?? 0) + (parole[indice] ?? 0) >>> 0;
|
|
697
|
+
const s0 = ruotaDestra(a, 2) ^ ruotaDestra(a, 13) ^ ruotaDestra(a, 22);
|
|
698
|
+
const maggioranza = a & b ^ a & c ^ b & c;
|
|
699
|
+
const temp2 = s0 + maggioranza >>> 0;
|
|
700
|
+
h = g;
|
|
701
|
+
g = f;
|
|
702
|
+
f = e;
|
|
703
|
+
e = d + temp1 >>> 0;
|
|
704
|
+
d = c;
|
|
705
|
+
c = b;
|
|
706
|
+
b = a;
|
|
707
|
+
a = temp1 + temp2 >>> 0;
|
|
708
|
+
}
|
|
709
|
+
h0 = h0 + a >>> 0;
|
|
710
|
+
h1 = h1 + b >>> 0;
|
|
711
|
+
h2 = h2 + c >>> 0;
|
|
712
|
+
h3 = h3 + d >>> 0;
|
|
713
|
+
h4 = h4 + e >>> 0;
|
|
714
|
+
h5 = h5 + f >>> 0;
|
|
715
|
+
h6 = h6 + g >>> 0;
|
|
716
|
+
h7 = h7 + h >>> 0;
|
|
717
|
+
}
|
|
718
|
+
return h0 >>> 0;
|
|
719
|
+
}
|
|
720
|
+
var CHIAVE_NUCLEO = "nucleo";
|
|
721
|
+
var PREFISSO_SAVE = "save:";
|
|
722
|
+
var LIMITE_FRAME = 16 * 1024;
|
|
723
|
+
var LIMITE_STATO = 256 * 1024;
|
|
724
|
+
var LIMITE_SAVE = 128 * 1024;
|
|
725
|
+
var GRAZIA_MS = 6e4;
|
|
726
|
+
var STANZA_VUOTA_MS = 5 * 6e4;
|
|
727
|
+
var COUNTDOWN_MS = 3e3;
|
|
728
|
+
var RIPOSO_TICK_MS = 3e4;
|
|
729
|
+
var INATTIVITA_MS = 10 * 6e4;
|
|
730
|
+
var CHIAVE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
|
|
731
|
+
var GAME_DEFINITION = /* @__PURE__ */ Symbol.for("@caisual/kit/game-definition");
|
|
732
|
+
function record(value) {
|
|
733
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
734
|
+
}
|
|
735
|
+
function definizioneValida(value) {
|
|
736
|
+
return record(value)?.[GAME_DEFINITION] === true;
|
|
737
|
+
}
|
|
738
|
+
function idGiocatore(player) {
|
|
739
|
+
return typeof player === "string" ? player : player.id;
|
|
740
|
+
}
|
|
741
|
+
function copiaGiocatore(player) {
|
|
742
|
+
return {
|
|
743
|
+
id: player.id,
|
|
744
|
+
name: player.name,
|
|
745
|
+
guest: player.guest,
|
|
746
|
+
role: player.role,
|
|
747
|
+
team: player.team,
|
|
748
|
+
connected: player.connected
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
function copiaJson(value) {
|
|
752
|
+
return JSON.parse(JSON.stringify(value));
|
|
753
|
+
}
|
|
754
|
+
function codiceIngresso(code) {
|
|
755
|
+
if (code === "room_not_found") return 4001;
|
|
756
|
+
if (code === "room_full") return 4e3;
|
|
757
|
+
return 4004;
|
|
758
|
+
}
|
|
759
|
+
var NucleoStanza = class _NucleoStanza {
|
|
760
|
+
constructor(definizione, manifest, adattatore) {
|
|
761
|
+
this.definizione = definizione;
|
|
762
|
+
this.manifest = manifest;
|
|
763
|
+
this.adattatore = adattatore;
|
|
764
|
+
this.dati = null;
|
|
765
|
+
this.frequenza = /* @__PURE__ */ new Map();
|
|
766
|
+
this.kickRichiesti = /* @__PURE__ */ new Set();
|
|
767
|
+
this.voceGuadagniCambiati = /* @__PURE__ */ new Map();
|
|
768
|
+
this.fineRichiesta = null;
|
|
769
|
+
this.applicandoAzioni = false;
|
|
770
|
+
this.ultimoStatoOsservato = "";
|
|
771
|
+
this.voceDaPersistire = false;
|
|
772
|
+
const nucleo = this;
|
|
773
|
+
const daily = {
|
|
774
|
+
get day() {
|
|
775
|
+
return giornoUtc(nucleo.adattatore.ora());
|
|
776
|
+
},
|
|
777
|
+
get seed() {
|
|
778
|
+
return seedGiornata(nucleo.manifest.id, giornoUtc(nucleo.adattatore.ora()));
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
this.room = {
|
|
782
|
+
get id() {
|
|
783
|
+
return nucleo.richiediDati().id;
|
|
784
|
+
},
|
|
785
|
+
get mode() {
|
|
786
|
+
return nucleo.richiediDati().mode;
|
|
787
|
+
},
|
|
788
|
+
get status() {
|
|
789
|
+
return nucleo.richiediDati().status;
|
|
790
|
+
},
|
|
791
|
+
get tick() {
|
|
792
|
+
return nucleo.richiediDati().tick;
|
|
793
|
+
},
|
|
794
|
+
get state() {
|
|
795
|
+
return nucleo.richiediDati().state;
|
|
796
|
+
},
|
|
797
|
+
set state(value) {
|
|
798
|
+
nucleo.richiediDati().state = value;
|
|
799
|
+
},
|
|
800
|
+
get players() {
|
|
801
|
+
return nucleo.richiediDati().giocatori.map(copiaGiocatore);
|
|
802
|
+
},
|
|
803
|
+
get host() {
|
|
804
|
+
const dati = nucleo.richiediDati();
|
|
805
|
+
const host = dati.giocatori.find((player) => player.id === dati.hostId);
|
|
806
|
+
return host === void 0 ? null : copiaGiocatore(host);
|
|
807
|
+
},
|
|
808
|
+
broadcast(message) {
|
|
809
|
+
nucleo.broadcastCreatore(message);
|
|
810
|
+
},
|
|
811
|
+
send(player, message) {
|
|
812
|
+
nucleo.inviaCreatore(idGiocatore(player), message);
|
|
813
|
+
},
|
|
814
|
+
kick(player) {
|
|
815
|
+
nucleo.kickRichiesti.add(idGiocatore(player));
|
|
816
|
+
},
|
|
817
|
+
end(result) {
|
|
818
|
+
nucleo.richiediFine(result);
|
|
819
|
+
},
|
|
820
|
+
save(key, value) {
|
|
821
|
+
return nucleo.salva(key, value);
|
|
822
|
+
},
|
|
823
|
+
load(key) {
|
|
824
|
+
return nucleo.caricaSave(key);
|
|
825
|
+
},
|
|
826
|
+
schedule(milliseconds, handler, payload) {
|
|
827
|
+
nucleo.pianifica(milliseconds, handler, payload);
|
|
828
|
+
},
|
|
829
|
+
board: {
|
|
830
|
+
submit(player, board, score, options = {}) {
|
|
831
|
+
nucleo.accodaPunteggio(idGiocatore(player), board, score, options.daily === true);
|
|
832
|
+
}
|
|
833
|
+
},
|
|
834
|
+
daily,
|
|
835
|
+
time: { now: () => nucleo.adattatore.ora() },
|
|
836
|
+
voice: {
|
|
837
|
+
get mode() {
|
|
838
|
+
return nucleo.manifest.voice ?? "none";
|
|
839
|
+
},
|
|
840
|
+
setProximity(a, b, gain) {
|
|
841
|
+
nucleo.impostaProssimita(idGiocatore(a), idGiocatore(b), gain);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
static async apri(definizione, manifest, adattatore) {
|
|
847
|
+
if (!definizioneValida(definizione)) {
|
|
848
|
+
throw new TypeError("The game server must export a definition created with defineGame.");
|
|
849
|
+
}
|
|
850
|
+
_NucleoStanza.verificaManifest(manifest);
|
|
851
|
+
const nucleo = new _NucleoStanza(definizione, manifest, adattatore);
|
|
852
|
+
const salvato = await adattatore.storage.get(CHIAVE_NUCLEO);
|
|
853
|
+
if (salvato !== void 0) {
|
|
854
|
+
nucleo.dati = salvato;
|
|
855
|
+
const daAggiornare = salvato.ultimoInputAt === void 0 || salvato.ultimoCambioStatoAt === void 0;
|
|
856
|
+
salvato.ultimoInputAt ??= adattatore.ora();
|
|
857
|
+
salvato.ultimoCambioStatoAt ??= salvato.ultimoInputAt;
|
|
858
|
+
nucleo.ultimoStatoOsservato = JSON.stringify(salvato.state);
|
|
859
|
+
await nucleo.riconciliaConnessioni();
|
|
860
|
+
if (daAggiornare) await nucleo.persisti();
|
|
861
|
+
}
|
|
862
|
+
await nucleo.aggiornaProgrammazione();
|
|
863
|
+
return nucleo;
|
|
864
|
+
}
|
|
865
|
+
static verificaManifest(manifest) {
|
|
866
|
+
if (typeof manifest?.id !== "string" || !Number.isInteger(manifest.players?.min) || !Number.isInteger(manifest.players?.max) || manifest.players.min < 1 || manifest.players.max < manifest.players.min || typeof manifest.lobby !== "boolean" || !Array.isArray(manifest.roles) || !Array.isArray(manifest.modes) || manifest.voice !== void 0 && !["none", "room", "team", "proximity"].includes(manifest.voice)) {
|
|
867
|
+
throw new TypeError("The room manifest is invalid.");
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
richiediDati() {
|
|
871
|
+
if (this.dati === null) throw new Error("The room has not been created.");
|
|
872
|
+
return this.dati;
|
|
873
|
+
}
|
|
874
|
+
stanzaTerminata() {
|
|
875
|
+
return this.dati?.status === "ended";
|
|
876
|
+
}
|
|
877
|
+
get esiste() {
|
|
878
|
+
return this.dati !== null;
|
|
879
|
+
}
|
|
880
|
+
async crea(id, mode, _creator) {
|
|
881
|
+
if (this.dati !== null) return false;
|
|
882
|
+
if (mode !== null && !this.manifest.modes.some((item) => item.id === mode)) {
|
|
883
|
+
throw new Error("The selected game mode does not exist.");
|
|
884
|
+
}
|
|
885
|
+
const ora = this.adattatore.ora();
|
|
886
|
+
this.dati = {
|
|
887
|
+
versione: 1,
|
|
888
|
+
id,
|
|
889
|
+
mode,
|
|
890
|
+
status: "lobby",
|
|
891
|
+
tick: 0,
|
|
892
|
+
tickRate: this.definizione.tickRate,
|
|
893
|
+
ultimoInputAt: ora,
|
|
894
|
+
ultimoCambioStatoAt: ora,
|
|
895
|
+
state: {},
|
|
896
|
+
giocatori: [],
|
|
897
|
+
hostId: null,
|
|
898
|
+
result: null,
|
|
899
|
+
resultAt: null,
|
|
900
|
+
countdownAt: null,
|
|
901
|
+
timer: [],
|
|
902
|
+
prossimoTimerId: 1,
|
|
903
|
+
punteggi: [],
|
|
904
|
+
fineInCoda: null,
|
|
905
|
+
vuotaDa: ora,
|
|
906
|
+
cancellaDopoFlush: false,
|
|
907
|
+
tickSincronizzato: 0,
|
|
908
|
+
statoSincronizzato: {},
|
|
909
|
+
durateCpu: [],
|
|
910
|
+
cpuOltreCento: 0,
|
|
911
|
+
voceGuadagni: {}
|
|
912
|
+
};
|
|
913
|
+
await this.chiama(this.definizione.onCreate, this.room);
|
|
914
|
+
await this.applicaAzioni();
|
|
915
|
+
this.inviaGuadagniCambiati();
|
|
916
|
+
if (this.dati.status !== "ended") {
|
|
917
|
+
const stato = analizzaJson(this.dati.state);
|
|
918
|
+
if (!stato.ok || stato.bytes > LIMITE_STATO) {
|
|
919
|
+
await this.terminaPerStato(stato.ok ? "state_too_large" : "state_invalid");
|
|
920
|
+
} else {
|
|
921
|
+
this.dati.state = stato.valore;
|
|
922
|
+
this.dati.statoSincronizzato = copiaJson(stato.valore);
|
|
923
|
+
this.ultimoStatoOsservato = stato.testo;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
await this.persistiEProgramma();
|
|
927
|
+
return true;
|
|
928
|
+
}
|
|
929
|
+
info() {
|
|
930
|
+
if (this.dati === null) return null;
|
|
931
|
+
return {
|
|
932
|
+
roomId: this.dati.id,
|
|
933
|
+
status: this.dati.status,
|
|
934
|
+
players: this.dati.giocatori.filter(
|
|
935
|
+
(player) => player.connected || player.graziaFinoA !== null
|
|
936
|
+
).length,
|
|
937
|
+
max: this.manifest.players.max,
|
|
938
|
+
mode: this.dati.mode
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
giocatoreConnesso(connessione) {
|
|
942
|
+
const player = this.dati?.giocatori.find(
|
|
943
|
+
(item) => item.connected && item.connessione === connessione
|
|
944
|
+
);
|
|
945
|
+
return player === void 0 ? null : copiaGiocatore(player);
|
|
946
|
+
}
|
|
947
|
+
puoEntrare(identity) {
|
|
948
|
+
if (this.dati === null) return { ok: false, code: "room_not_found" };
|
|
949
|
+
if (this.dati.status === "ended") return { ok: false, code: "room_ended" };
|
|
950
|
+
const esistente = this.dati.giocatori.find((player) => player.id === identity.id);
|
|
951
|
+
if (esistente !== void 0) return { ok: true };
|
|
952
|
+
if (this.manifest.lobby && this.dati.status !== "lobby") {
|
|
953
|
+
return { ok: false, code: "room_playing" };
|
|
954
|
+
}
|
|
955
|
+
if (this.dati.giocatori.length >= this.manifest.players.max) {
|
|
956
|
+
return { ok: false, code: "room_full" };
|
|
957
|
+
}
|
|
958
|
+
return { ok: true };
|
|
959
|
+
}
|
|
960
|
+
async entra(identity, connessione) {
|
|
961
|
+
await this.terminaSeInattiva();
|
|
962
|
+
const permesso = this.puoEntrare(identity);
|
|
963
|
+
if (!permesso.ok) {
|
|
964
|
+
this.adattatore.chiudi(connessione, codiceIngresso(permesso.code), permesso.code);
|
|
965
|
+
return permesso;
|
|
966
|
+
}
|
|
967
|
+
const dati = this.richiediDati();
|
|
968
|
+
const ora = this.adattatore.ora();
|
|
969
|
+
let player = dati.giocatori.find((item) => item.id === identity.id);
|
|
970
|
+
const nuovo = player === void 0;
|
|
971
|
+
if (player === void 0) {
|
|
972
|
+
player = {
|
|
973
|
+
...identity,
|
|
974
|
+
role: this.ruoloAutomatico(),
|
|
975
|
+
team: this.squadraAutomatica(),
|
|
976
|
+
ready: false,
|
|
977
|
+
connected: true,
|
|
978
|
+
entratoAt: ora,
|
|
979
|
+
graziaFinoA: null,
|
|
980
|
+
connessione,
|
|
981
|
+
seq: 0
|
|
982
|
+
};
|
|
983
|
+
dati.giocatori.push(player);
|
|
984
|
+
dati.hostId ??= player.id;
|
|
985
|
+
} else {
|
|
986
|
+
if (player.connected && player.connessione !== null && player.connessione !== connessione) {
|
|
987
|
+
this.adattatore.chiudi(player.connessione, 4006, "replaced");
|
|
988
|
+
}
|
|
989
|
+
player.name = identity.name;
|
|
990
|
+
player.guest = identity.guest;
|
|
991
|
+
player.connected = true;
|
|
992
|
+
player.graziaFinoA = null;
|
|
993
|
+
player.connessione = connessione;
|
|
994
|
+
player.seq = 0;
|
|
995
|
+
}
|
|
996
|
+
dati.vuotaDa = null;
|
|
997
|
+
dati.ultimoInputAt = ora;
|
|
998
|
+
const primaConnessione = !this.manifest.lobby && dati.status === "lobby";
|
|
999
|
+
if (primaConnessione) dati.status = "playing";
|
|
1000
|
+
if (nuovo) {
|
|
1001
|
+
await this.chiama(
|
|
1002
|
+
this.definizione.onJoin,
|
|
1003
|
+
this.room,
|
|
1004
|
+
copiaGiocatore(player)
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
if (primaConnessione) {
|
|
1008
|
+
await this.chiama(this.definizione.onStart, this.room);
|
|
1009
|
+
this.inviaStatus(ora);
|
|
1010
|
+
}
|
|
1011
|
+
await this.concludiEvento();
|
|
1012
|
+
if (dati.status !== "ended") {
|
|
1013
|
+
this.inviaWelcome(player);
|
|
1014
|
+
this.inviaSnapshotTutti();
|
|
1015
|
+
this.inviaGiocatori();
|
|
1016
|
+
}
|
|
1017
|
+
await this.persistiEProgramma();
|
|
1018
|
+
return { ok: true };
|
|
1019
|
+
}
|
|
1020
|
+
async disconnetti(connessione) {
|
|
1021
|
+
if (this.dati === null || this.dati.status === "ended") return;
|
|
1022
|
+
const player = this.dati.giocatori.find(
|
|
1023
|
+
(item) => item.connected && item.connessione === connessione
|
|
1024
|
+
);
|
|
1025
|
+
if (player === void 0) return;
|
|
1026
|
+
player.connected = false;
|
|
1027
|
+
player.connessione = null;
|
|
1028
|
+
player.graziaFinoA = this.adattatore.ora() + GRAZIA_MS;
|
|
1029
|
+
this.frequenza.delete(connessione);
|
|
1030
|
+
if (this.dati.hostId === player.id) this.assegnaHost();
|
|
1031
|
+
this.verificaCountdown();
|
|
1032
|
+
this.inviaGiocatori();
|
|
1033
|
+
await this.persistiEProgramma();
|
|
1034
|
+
}
|
|
1035
|
+
async ricevi(connessione, frame) {
|
|
1036
|
+
if (this.dati === null || this.dati.status === "ended") return;
|
|
1037
|
+
if (await this.terminaSeInattiva()) return;
|
|
1038
|
+
const player = this.dati.giocatori.find(
|
|
1039
|
+
(item) => item.connected && item.connessione === connessione
|
|
1040
|
+
);
|
|
1041
|
+
if (player === void 0) return;
|
|
1042
|
+
if (new TextEncoder().encode(frame).byteLength > LIMITE_FRAME) {
|
|
1043
|
+
await this.chiudiConnessione(player, 4009, "bad_message");
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
const ora = this.adattatore.ora();
|
|
1047
|
+
const recenti = (this.frequenza.get(connessione) ?? []).filter((at) => ora - at < 1e3);
|
|
1048
|
+
if (recenti.length >= 20) {
|
|
1049
|
+
await this.chiudiConnessione(player, 4008, "rate_limited");
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
recenti.push(ora);
|
|
1053
|
+
this.frequenza.set(connessione, recenti);
|
|
1054
|
+
let message = null;
|
|
1055
|
+
try {
|
|
1056
|
+
message = record(JSON.parse(frame));
|
|
1057
|
+
} catch {
|
|
1058
|
+
}
|
|
1059
|
+
if (message === null || typeof message.t !== "string") {
|
|
1060
|
+
await this.chiudiConnessione(player, 4009, "bad_message");
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
if (message.t === "ping") {
|
|
1064
|
+
if (typeof message.c !== "number" || !Number.isFinite(message.c)) {
|
|
1065
|
+
await this.chiudiConnessione(player, 4009, "bad_message");
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
this.adattatore.invia(connessione, { t: "pong", c: message.c, s: ora });
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
if (message.t === "resync") {
|
|
1072
|
+
this.inviaSnapshotTutti();
|
|
1073
|
+
await this.persistiEProgramma();
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
if (message.t === "leave") {
|
|
1077
|
+
this.dati.ultimoInputAt = ora;
|
|
1078
|
+
await this.rimuoviGiocatore(player, "left");
|
|
1079
|
+
this.adattatore.chiudi(connessione, 1e3, "left");
|
|
1080
|
+
await this.concludiEvento();
|
|
1081
|
+
this.inviaGiocatori();
|
|
1082
|
+
await this.persistiEProgramma();
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
if (message.t === "ready") {
|
|
1086
|
+
if (typeof message.ready !== "boolean") return this.messaggioErrato(player);
|
|
1087
|
+
if (!this.inLobby(player)) return;
|
|
1088
|
+
this.dati.ultimoInputAt = ora;
|
|
1089
|
+
player.ready = message.ready;
|
|
1090
|
+
this.inviaGiocatori();
|
|
1091
|
+
await this.persistiEProgramma();
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
if (message.t === "role") {
|
|
1095
|
+
if (typeof message.role !== "string") return this.messaggioErrato(player);
|
|
1096
|
+
if (!this.inLobby(player)) return;
|
|
1097
|
+
this.dati.ultimoInputAt = ora;
|
|
1098
|
+
this.scegliRuolo(player, message.role);
|
|
1099
|
+
await this.persistiEProgramma();
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
if (message.t === "team") {
|
|
1103
|
+
if (!Number.isInteger(message.team)) return this.messaggioErrato(player);
|
|
1104
|
+
if (!this.inLobby(player)) return;
|
|
1105
|
+
this.dati.ultimoInputAt = ora;
|
|
1106
|
+
this.scegliSquadra(player, message.team);
|
|
1107
|
+
await this.persistiEProgramma();
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
if (message.t === "start") {
|
|
1111
|
+
if (!this.inLobby(player)) return;
|
|
1112
|
+
this.dati.ultimoInputAt = ora;
|
|
1113
|
+
await this.avviaCountdown(player);
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
if (message.t === "msg") {
|
|
1117
|
+
if (!Number.isSafeInteger(message.seq) || message.seq < 1) {
|
|
1118
|
+
await this.messaggioErrato(player);
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
const seq = message.seq;
|
|
1122
|
+
if (seq <= player.seq) return;
|
|
1123
|
+
const contenuto = analizzaJson(message.m);
|
|
1124
|
+
if (!contenuto.ok) {
|
|
1125
|
+
await this.messaggioErrato(player);
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
player.seq = seq;
|
|
1129
|
+
if (player.role === "spectator") {
|
|
1130
|
+
this.inviaErrore(player, "spectator", "Spectators cannot send game input.");
|
|
1131
|
+
await this.persistiEProgramma();
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
this.dati.ultimoInputAt = ora;
|
|
1135
|
+
await this.chiama(
|
|
1136
|
+
this.definizione.onMessage,
|
|
1137
|
+
this.room,
|
|
1138
|
+
copiaGiocatore(player),
|
|
1139
|
+
contenuto.valore
|
|
1140
|
+
);
|
|
1141
|
+
await this.concludiEvento();
|
|
1142
|
+
await this.persistiEProgramma();
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
await this.messaggioErrato(player);
|
|
1146
|
+
}
|
|
1147
|
+
async eseguiTick() {
|
|
1148
|
+
if (this.dati === null || this.dati.status !== "playing" || this.dati.tickRate === 0) return;
|
|
1149
|
+
if (await this.terminaSeInattiva()) return;
|
|
1150
|
+
if (!this.serveTick()) {
|
|
1151
|
+
await this.persistiEProgramma();
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
const dati = this.dati;
|
|
1155
|
+
dati.tick++;
|
|
1156
|
+
const inizio = this.adattatore.misuraCpu();
|
|
1157
|
+
await this.chiama(
|
|
1158
|
+
this.definizione.onTick,
|
|
1159
|
+
this.room,
|
|
1160
|
+
1 / dati.tickRate
|
|
1161
|
+
);
|
|
1162
|
+
const durata = Math.max(0, this.adattatore.misuraCpu() - inizio);
|
|
1163
|
+
this.registraCpu(durata);
|
|
1164
|
+
await this.applicaAzioni();
|
|
1165
|
+
if (dati.status === "ended") return;
|
|
1166
|
+
this.inviaGuadagniCambiati();
|
|
1167
|
+
const stato = await this.verificaStato();
|
|
1168
|
+
if (stato === null || this.stanzaTerminata()) return;
|
|
1169
|
+
const cambiato = stato.testo !== JSON.stringify(dati.statoSincronizzato);
|
|
1170
|
+
if (dati.tick % 100 === 0) {
|
|
1171
|
+
dati.state = stato.valore;
|
|
1172
|
+
this.inviaSnapshotTutti();
|
|
1173
|
+
} else if (cambiato) {
|
|
1174
|
+
const base = dati.tickSincronizzato;
|
|
1175
|
+
this.broadcast({
|
|
1176
|
+
t: "state",
|
|
1177
|
+
tick: dati.tick,
|
|
1178
|
+
serverTime: this.adattatore.ora(),
|
|
1179
|
+
base,
|
|
1180
|
+
patch: creaDiff(dati.statoSincronizzato, stato.valore)
|
|
1181
|
+
});
|
|
1182
|
+
dati.state = stato.valore;
|
|
1183
|
+
dati.statoSincronizzato = copiaJson(stato.valore);
|
|
1184
|
+
dati.tickSincronizzato = dati.tick;
|
|
1185
|
+
}
|
|
1186
|
+
if (dati.tick % 100 === 0 || this.voceDaPersistire) await this.persisti();
|
|
1187
|
+
await this.aggiornaProgrammazione();
|
|
1188
|
+
}
|
|
1189
|
+
async sveglia() {
|
|
1190
|
+
if (this.dati === null || this.dati.status === "ended") return;
|
|
1191
|
+
if (await this.terminaSeInattiva()) return;
|
|
1192
|
+
const ora = this.adattatore.ora();
|
|
1193
|
+
const scaduti = this.dati.giocatori.filter(
|
|
1194
|
+
(player) => !player.connected && player.graziaFinoA !== null && player.graziaFinoA <= ora
|
|
1195
|
+
);
|
|
1196
|
+
for (const player of scaduti) await this.rimuoviGiocatore(player, "timeout");
|
|
1197
|
+
if (this.dati.status === "countdown" && this.dati.countdownAt !== null && this.dati.countdownAt <= ora) {
|
|
1198
|
+
const errore = this.erroreMinimi();
|
|
1199
|
+
if (errore !== null) {
|
|
1200
|
+
this.dati.status = "lobby";
|
|
1201
|
+
this.dati.countdownAt = null;
|
|
1202
|
+
this.broadcast({ t: "error", code: errore.code, message: errore.message });
|
|
1203
|
+
this.inviaStatus(ora);
|
|
1204
|
+
} else {
|
|
1205
|
+
this.dati.status = "playing";
|
|
1206
|
+
this.dati.ultimoInputAt = ora;
|
|
1207
|
+
this.dati.countdownAt = null;
|
|
1208
|
+
await this.chiama(this.definizione.onStart, this.room);
|
|
1209
|
+
this.inviaStatus(ora);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
const dovuti = this.dati.timer.filter((timer) => timer.at <= ora).sort((a, b) => a.at - b.at || a.id - b.id);
|
|
1213
|
+
if (dovuti.length > 0) {
|
|
1214
|
+
const ids = new Set(dovuti.map((timer) => timer.id));
|
|
1215
|
+
this.dati.timer = this.dati.timer.filter((timer) => !ids.has(timer.id));
|
|
1216
|
+
for (const timer of dovuti) await this.eseguiTimer(timer);
|
|
1217
|
+
}
|
|
1218
|
+
await this.concludiEvento();
|
|
1219
|
+
if (!this.stanzaTerminata() && this.dati.giocatori.length === 0 && this.dati.vuotaDa !== null && this.dati.vuotaDa + STANZA_VUOTA_MS <= ora) {
|
|
1220
|
+
this.dati.cancellaDopoFlush = true;
|
|
1221
|
+
await this.terminaInterna(null);
|
|
1222
|
+
}
|
|
1223
|
+
if (scaduti.length > 0) this.inviaGiocatori();
|
|
1224
|
+
await this.concludiEvento();
|
|
1225
|
+
await this.persistiEProgramma();
|
|
1226
|
+
}
|
|
1227
|
+
async flush() {
|
|
1228
|
+
const dati = this.richiediDati();
|
|
1229
|
+
const esito = {
|
|
1230
|
+
scores: dati.punteggi.map((score) => ({ ...score })),
|
|
1231
|
+
ended: dati.fineInCoda === null ? null : { result: dati.fineInCoda.result, at: dati.fineInCoda.at }
|
|
1232
|
+
};
|
|
1233
|
+
dati.punteggi = [];
|
|
1234
|
+
dati.fineInCoda = null;
|
|
1235
|
+
if (dati.cancellaDopoFlush && dati.status === "ended") {
|
|
1236
|
+
const chiavi = await this.adattatore.storage.list();
|
|
1237
|
+
for (const key of chiavi.keys()) await this.adattatore.storage.delete(key);
|
|
1238
|
+
this.dati = null;
|
|
1239
|
+
this.adattatore.programmaTick(null);
|
|
1240
|
+
await this.adattatore.programmaSveglia(null);
|
|
1241
|
+
} else {
|
|
1242
|
+
await this.persistiEProgramma();
|
|
1243
|
+
}
|
|
1244
|
+
return esito;
|
|
1245
|
+
}
|
|
1246
|
+
async riconciliaConnessioni() {
|
|
1247
|
+
const dati = this.richiediDati();
|
|
1248
|
+
const attive = new Set(this.adattatore.connessioniAttive());
|
|
1249
|
+
const ora = this.adattatore.ora();
|
|
1250
|
+
let cambiato = false;
|
|
1251
|
+
for (const player of dati.giocatori) {
|
|
1252
|
+
if (player.connected && (player.connessione === null || !attive.has(player.connessione))) {
|
|
1253
|
+
player.connected = false;
|
|
1254
|
+
player.connessione = null;
|
|
1255
|
+
player.graziaFinoA = ora + GRAZIA_MS;
|
|
1256
|
+
cambiato = true;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
if (cambiato) {
|
|
1260
|
+
this.assegnaHost();
|
|
1261
|
+
await this.persisti();
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
ruoloAutomatico() {
|
|
1265
|
+
if (this.manifest.roles.length === 0) return null;
|
|
1266
|
+
const dati = this.richiediDati();
|
|
1267
|
+
const candidati = this.manifest.roles.filter((role) => {
|
|
1268
|
+
const count = dati.giocatori.filter((player) => player.role === role.id).length;
|
|
1269
|
+
return role.max === void 0 || count < role.max;
|
|
1270
|
+
});
|
|
1271
|
+
candidati.sort((a, b) => {
|
|
1272
|
+
const countA = dati.giocatori.filter((player) => player.role === a.id).length;
|
|
1273
|
+
const countB = dati.giocatori.filter((player) => player.role === b.id).length;
|
|
1274
|
+
const mancaA = countA < a.min ? 0 : 1;
|
|
1275
|
+
const mancaB = countB < b.min ? 0 : 1;
|
|
1276
|
+
return mancaA - mancaB || countA - countB;
|
|
1277
|
+
});
|
|
1278
|
+
return candidati[0]?.id ?? "spectator";
|
|
1279
|
+
}
|
|
1280
|
+
squadraAutomatica() {
|
|
1281
|
+
if (this.manifest.teams === null) return null;
|
|
1282
|
+
const dati = this.richiediDati();
|
|
1283
|
+
let scelta = 1;
|
|
1284
|
+
let minimo = Number.POSITIVE_INFINITY;
|
|
1285
|
+
for (let team = 1; team <= this.manifest.teams.min; team++) {
|
|
1286
|
+
const count = dati.giocatori.filter((player) => player.team === team).length;
|
|
1287
|
+
if (count < minimo) {
|
|
1288
|
+
minimo = count;
|
|
1289
|
+
scelta = team;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
return scelta;
|
|
1293
|
+
}
|
|
1294
|
+
inLobby(player) {
|
|
1295
|
+
if (this.richiediDati().status === "lobby") return true;
|
|
1296
|
+
this.inviaErrore(player, "not_in_lobby", "This action is only available in the lobby.");
|
|
1297
|
+
return false;
|
|
1298
|
+
}
|
|
1299
|
+
scegliRuolo(player, roleId) {
|
|
1300
|
+
const ruolo = this.manifest.roles.find((item) => item.id === roleId);
|
|
1301
|
+
if (ruolo === void 0 && roleId !== "spectator") {
|
|
1302
|
+
this.inviaErrore(player, "invalid_role", "This role does not exist.");
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
const occupati = this.richiediDati().giocatori.filter(
|
|
1306
|
+
(item) => item.id !== player.id && item.role === roleId
|
|
1307
|
+
).length;
|
|
1308
|
+
if (ruolo?.max !== void 0 && occupati >= ruolo.max) {
|
|
1309
|
+
this.inviaErrore(player, "role_full", "This role is full.");
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
player.role = roleId;
|
|
1313
|
+
if (roleId === "spectator") player.team = null;
|
|
1314
|
+
else if (player.team === null) player.team = this.squadraAutomatica();
|
|
1315
|
+
player.ready = false;
|
|
1316
|
+
this.inviaGiocatori();
|
|
1317
|
+
}
|
|
1318
|
+
scegliSquadra(player, team) {
|
|
1319
|
+
if (this.manifest.teams === null || team < 1 || team > this.manifest.teams.max) {
|
|
1320
|
+
this.inviaErrore(player, "invalid_team", "This team does not exist.");
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
if (player.role === "spectator") {
|
|
1324
|
+
this.inviaErrore(player, "spectator", "Spectators cannot join a team.");
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
player.team = team;
|
|
1328
|
+
player.ready = false;
|
|
1329
|
+
this.inviaGiocatori();
|
|
1330
|
+
}
|
|
1331
|
+
erroreMinimi() {
|
|
1332
|
+
const connessi = this.richiediDati().giocatori.filter((player) => player.connected);
|
|
1333
|
+
const attivi = connessi.filter((player) => player.role !== "spectator");
|
|
1334
|
+
if (attivi.length < this.manifest.players.min) {
|
|
1335
|
+
return { code: "not_enough_players", message: "The room does not have enough players." };
|
|
1336
|
+
}
|
|
1337
|
+
if (connessi.some((player) => !player.ready)) {
|
|
1338
|
+
return { code: "players_not_ready", message: "Every connected player must be ready." };
|
|
1339
|
+
}
|
|
1340
|
+
for (const role of this.manifest.roles) {
|
|
1341
|
+
if (attivi.filter((player) => player.role === role.id).length < role.min) {
|
|
1342
|
+
return { code: "role_minimum", message: `Role ${role.id} does not meet its minimum.` };
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
if (this.manifest.teams !== null) {
|
|
1346
|
+
const squadre = new Set(attivi.map((player) => player.team).filter((team) => team !== null));
|
|
1347
|
+
if (attivi.some((player) => player.team === null) || squadre.size < this.manifest.teams.min) {
|
|
1348
|
+
return { code: "team_minimum", message: "The room does not have enough teams." };
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
return null;
|
|
1352
|
+
}
|
|
1353
|
+
async avviaCountdown(player) {
|
|
1354
|
+
const dati = this.richiediDati();
|
|
1355
|
+
if (dati.hostId !== player.id) {
|
|
1356
|
+
this.inviaErrore(player, "not_host", "Only the host can start the game.");
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
const errore = this.erroreMinimi();
|
|
1360
|
+
if (errore !== null) {
|
|
1361
|
+
this.inviaErrore(player, errore.code, errore.message);
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
dati.status = "countdown";
|
|
1365
|
+
dati.countdownAt = this.adattatore.ora() + COUNTDOWN_MS;
|
|
1366
|
+
this.inviaStatus(dati.countdownAt);
|
|
1367
|
+
await this.persistiEProgramma();
|
|
1368
|
+
}
|
|
1369
|
+
verificaCountdown() {
|
|
1370
|
+
const dati = this.richiediDati();
|
|
1371
|
+
if (dati.status !== "countdown" || this.erroreMinimi() === null) return;
|
|
1372
|
+
dati.status = "lobby";
|
|
1373
|
+
dati.countdownAt = null;
|
|
1374
|
+
this.inviaStatus(this.adattatore.ora());
|
|
1375
|
+
}
|
|
1376
|
+
async messaggioErrato(player) {
|
|
1377
|
+
await this.chiudiConnessione(player, 4009, "bad_message");
|
|
1378
|
+
}
|
|
1379
|
+
async chiudiConnessione(player, codice, motivo) {
|
|
1380
|
+
const connessione = player.connessione;
|
|
1381
|
+
if (connessione !== null) this.adattatore.chiudi(connessione, codice, motivo);
|
|
1382
|
+
await this.disconnetti(connessione ?? "");
|
|
1383
|
+
}
|
|
1384
|
+
async rimuoviGiocatore(player, reason) {
|
|
1385
|
+
const dati = this.richiediDati();
|
|
1386
|
+
const indice = dati.giocatori.findIndex((item) => item.id === player.id);
|
|
1387
|
+
if (indice < 0) return;
|
|
1388
|
+
dati.giocatori.splice(indice, 1);
|
|
1389
|
+
this.pulisciGuadagni(player.id);
|
|
1390
|
+
if (dati.hostId === player.id) this.assegnaHost();
|
|
1391
|
+
if (dati.giocatori.length === 0) dati.vuotaDa = this.adattatore.ora();
|
|
1392
|
+
await this.chiama(
|
|
1393
|
+
this.definizione.onLeave,
|
|
1394
|
+
this.room,
|
|
1395
|
+
copiaGiocatore({ ...player, connected: false, connessione: null }),
|
|
1396
|
+
reason
|
|
1397
|
+
);
|
|
1398
|
+
this.verificaCountdown();
|
|
1399
|
+
}
|
|
1400
|
+
assegnaHost() {
|
|
1401
|
+
const dati = this.richiediDati();
|
|
1402
|
+
const host = dati.giocatori.filter((player) => player.connected).sort((a, b) => a.entratoAt - b.entratoAt)[0];
|
|
1403
|
+
dati.hostId = host?.id ?? null;
|
|
1404
|
+
}
|
|
1405
|
+
async eseguiTimer(timer) {
|
|
1406
|
+
const value = this.definizione[timer.handler];
|
|
1407
|
+
await this.chiama(
|
|
1408
|
+
typeof value === "function" ? value : void 0,
|
|
1409
|
+
this.room,
|
|
1410
|
+
timer.payload
|
|
1411
|
+
);
|
|
1412
|
+
}
|
|
1413
|
+
pianifica(milliseconds, handler, payload) {
|
|
1414
|
+
if (!Number.isSafeInteger(milliseconds) || milliseconds < 0) {
|
|
1415
|
+
throw new TypeError("Schedule delay must be a non-negative integer.");
|
|
1416
|
+
}
|
|
1417
|
+
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(handler)) {
|
|
1418
|
+
throw new TypeError("Schedule handler must be a method name.");
|
|
1419
|
+
}
|
|
1420
|
+
const callback = this.definizione[handler];
|
|
1421
|
+
if (typeof callback !== "function") {
|
|
1422
|
+
throw new TypeError(`Schedule handler ${handler} is not defined.`);
|
|
1423
|
+
}
|
|
1424
|
+
const json = analizzaJson(payload);
|
|
1425
|
+
if (!json.ok) throw new TypeError("Schedule payload must be valid JSON.");
|
|
1426
|
+
const dati = this.richiediDati();
|
|
1427
|
+
dati.timer.push({
|
|
1428
|
+
id: dati.prossimoTimerId++,
|
|
1429
|
+
at: this.adattatore.ora() + milliseconds,
|
|
1430
|
+
handler,
|
|
1431
|
+
payload: json.valore
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
accodaPunteggio(playerId, board, score, daily) {
|
|
1435
|
+
if (!this.richiediDati().giocatori.some((player) => player.id === playerId)) {
|
|
1436
|
+
throw new Error("Player not found.");
|
|
1437
|
+
}
|
|
1438
|
+
if (!CHIAVE.test(board)) {
|
|
1439
|
+
throw new TypeError("Board names must use lowercase letters, numbers, underscores, or hyphens.");
|
|
1440
|
+
}
|
|
1441
|
+
if (!Number.isSafeInteger(score) || score < 0) {
|
|
1442
|
+
throw new TypeError("Score must be a non-negative safe integer.");
|
|
1443
|
+
}
|
|
1444
|
+
this.richiediDati().punteggi.push({ playerId, board, score, daily });
|
|
1445
|
+
this.broadcast({ t: "flush" });
|
|
1446
|
+
}
|
|
1447
|
+
richiediFine(result) {
|
|
1448
|
+
const json = analizzaJson(result);
|
|
1449
|
+
if (!json.ok) throw new TypeError("Game result must be valid JSON.");
|
|
1450
|
+
this.fineRichiesta = json.valore;
|
|
1451
|
+
}
|
|
1452
|
+
async salva(key, value) {
|
|
1453
|
+
if (!CHIAVE.test(key)) {
|
|
1454
|
+
throw new TypeError("Save keys must use lowercase letters, numbers, underscores, or hyphens.");
|
|
1455
|
+
}
|
|
1456
|
+
const json = analizzaJson(value);
|
|
1457
|
+
if (!json.ok) throw new TypeError("Saved values must be valid JSON.");
|
|
1458
|
+
if (json.bytes > LIMITE_SAVE) {
|
|
1459
|
+
throw new RangeError("Saved values must be at most 131072 bytes.");
|
|
1460
|
+
}
|
|
1461
|
+
await this.adattatore.storage.put(PREFISSO_SAVE + key, json.valore);
|
|
1462
|
+
}
|
|
1463
|
+
async caricaSave(key) {
|
|
1464
|
+
if (!CHIAVE.test(key)) {
|
|
1465
|
+
throw new TypeError("Save keys must use lowercase letters, numbers, underscores, or hyphens.");
|
|
1466
|
+
}
|
|
1467
|
+
const value = await this.adattatore.storage.get(PREFISSO_SAVE + key);
|
|
1468
|
+
if (value === void 0) return null;
|
|
1469
|
+
const json = analizzaJson(value);
|
|
1470
|
+
if (!json.ok) throw new Error("The saved value is invalid.");
|
|
1471
|
+
return json.valore;
|
|
1472
|
+
}
|
|
1473
|
+
broadcastCreatore(message) {
|
|
1474
|
+
const json = analizzaJson(message);
|
|
1475
|
+
if (!json.ok) throw new TypeError("Messages must be valid JSON.");
|
|
1476
|
+
this.broadcast({ t: "msg", m: json.valore });
|
|
1477
|
+
}
|
|
1478
|
+
inviaCreatore(playerId, message) {
|
|
1479
|
+
const json = analizzaJson(message);
|
|
1480
|
+
if (!json.ok) throw new TypeError("Messages must be valid JSON.");
|
|
1481
|
+
const player = this.richiediDati().giocatori.find((item) => item.id === playerId);
|
|
1482
|
+
if (player === void 0) throw new Error("Player not found.");
|
|
1483
|
+
if (player.connected && player.connessione !== null) {
|
|
1484
|
+
this.adattatore.invia(player.connessione, { t: "msg", m: json.valore });
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
broadcast(message) {
|
|
1488
|
+
for (const player of this.richiediDati().giocatori) {
|
|
1489
|
+
if (player.connected && player.connessione !== null) {
|
|
1490
|
+
this.adattatore.invia(player.connessione, message);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
inviaErrore(player, code, message) {
|
|
1495
|
+
if (player.connected && player.connessione !== null) {
|
|
1496
|
+
this.adattatore.invia(player.connessione, { t: "error", code, message });
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
giocatoriProtocollo() {
|
|
1500
|
+
return this.richiediDati().giocatori.map((player) => ({
|
|
1501
|
+
id: player.id,
|
|
1502
|
+
name: player.name,
|
|
1503
|
+
guest: player.guest,
|
|
1504
|
+
role: player.role,
|
|
1505
|
+
team: player.team,
|
|
1506
|
+
ready: player.ready,
|
|
1507
|
+
connected: player.connected
|
|
1508
|
+
}));
|
|
1509
|
+
}
|
|
1510
|
+
inviaWelcome(player) {
|
|
1511
|
+
if (!player.connected || player.connessione === null) return;
|
|
1512
|
+
const dati = this.richiediDati();
|
|
1513
|
+
this.adattatore.invia(player.connessione, {
|
|
1514
|
+
t: "welcome",
|
|
1515
|
+
you: player.id,
|
|
1516
|
+
room: {
|
|
1517
|
+
id: dati.id,
|
|
1518
|
+
status: dati.status,
|
|
1519
|
+
mode: dati.mode,
|
|
1520
|
+
tick: dati.tick,
|
|
1521
|
+
tickRate: dati.tickRate,
|
|
1522
|
+
serverTime: this.adattatore.ora(),
|
|
1523
|
+
host: dati.hostId
|
|
1524
|
+
},
|
|
1525
|
+
players: this.giocatoriProtocollo(),
|
|
1526
|
+
state: dati.state
|
|
1527
|
+
});
|
|
1528
|
+
const gains = dati.voceGuadagni?.[player.id];
|
|
1529
|
+
if (gains !== void 0 && Object.keys(gains).length > 0) {
|
|
1530
|
+
this.adattatore.invia(player.connessione, { t: "voice", op: "gain", gains: { ...gains } });
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
inviaGiocatori() {
|
|
1534
|
+
this.broadcast({ t: "players", players: this.giocatoriProtocollo() });
|
|
1535
|
+
}
|
|
1536
|
+
inviaStatus(at) {
|
|
1537
|
+
const dati = this.richiediDati();
|
|
1538
|
+
this.broadcast({
|
|
1539
|
+
t: "status",
|
|
1540
|
+
status: dati.status,
|
|
1541
|
+
at,
|
|
1542
|
+
result: dati.status === "ended" ? dati.result : null
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
inviaSnapshotTutti() {
|
|
1546
|
+
const dati = this.richiediDati();
|
|
1547
|
+
const stato = analizzaJson(dati.state);
|
|
1548
|
+
if (!stato.ok || stato.bytes > LIMITE_STATO) return;
|
|
1549
|
+
dati.state = stato.valore;
|
|
1550
|
+
dati.statoSincronizzato = copiaJson(stato.valore);
|
|
1551
|
+
dati.tickSincronizzato = dati.tick;
|
|
1552
|
+
this.broadcast({
|
|
1553
|
+
t: "snapshot",
|
|
1554
|
+
tick: dati.tick,
|
|
1555
|
+
serverTime: this.adattatore.ora(),
|
|
1556
|
+
state: stato.valore
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
async chiama(callback, ...args) {
|
|
1560
|
+
if (callback === void 0 || this.dati?.status === "ended") return;
|
|
1561
|
+
try {
|
|
1562
|
+
await callback(...args);
|
|
1563
|
+
} catch {
|
|
1564
|
+
this.fineRichiesta = { error: "callback_error" };
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
async applicaAzioni() {
|
|
1568
|
+
if (this.applicandoAzioni || this.dati === null) return;
|
|
1569
|
+
this.applicandoAzioni = true;
|
|
1570
|
+
try {
|
|
1571
|
+
while (this.kickRichiesti.size > 0 && this.dati.status !== "ended") {
|
|
1572
|
+
const playerId = this.kickRichiesti.values().next().value;
|
|
1573
|
+
if (playerId === void 0) break;
|
|
1574
|
+
this.kickRichiesti.delete(playerId);
|
|
1575
|
+
const player = this.dati.giocatori.find((item) => item.id === playerId);
|
|
1576
|
+
if (player === void 0) continue;
|
|
1577
|
+
if (player.connessione !== null) this.adattatore.chiudi(player.connessione, 4003, "kicked");
|
|
1578
|
+
await this.rimuoviGiocatore(player, "kicked");
|
|
1579
|
+
}
|
|
1580
|
+
if (this.fineRichiesta !== null && this.dati.status !== "ended") {
|
|
1581
|
+
const result = this.fineRichiesta;
|
|
1582
|
+
this.fineRichiesta = null;
|
|
1583
|
+
await this.terminaInterna(result);
|
|
1584
|
+
}
|
|
1585
|
+
} finally {
|
|
1586
|
+
this.applicandoAzioni = false;
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
async concludiEvento() {
|
|
1590
|
+
await this.applicaAzioni();
|
|
1591
|
+
if (this.dati === null || this.dati.status === "ended") return;
|
|
1592
|
+
this.inviaGuadagniCambiati();
|
|
1593
|
+
const stato = await this.verificaStato();
|
|
1594
|
+
if (stato === null || this.stanzaTerminata()) return;
|
|
1595
|
+
const precedente = JSON.stringify(this.dati.statoSincronizzato);
|
|
1596
|
+
this.dati.state = stato.valore;
|
|
1597
|
+
if ((this.dati.tickRate === 0 || this.dati.status !== "playing") && stato.testo !== precedente) {
|
|
1598
|
+
this.dati.tick++;
|
|
1599
|
+
this.inviaSnapshotTutti();
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
async verificaStato() {
|
|
1603
|
+
const stato = analizzaJson(this.richiediDati().state);
|
|
1604
|
+
if (!stato.ok) {
|
|
1605
|
+
await this.terminaPerStato("state_invalid");
|
|
1606
|
+
return null;
|
|
1607
|
+
}
|
|
1608
|
+
if (stato.bytes > LIMITE_STATO) {
|
|
1609
|
+
await this.terminaPerStato("state_too_large");
|
|
1610
|
+
return null;
|
|
1611
|
+
}
|
|
1612
|
+
if (stato.testo !== this.ultimoStatoOsservato) {
|
|
1613
|
+
this.richiediDati().ultimoCambioStatoAt = this.adattatore.ora();
|
|
1614
|
+
this.ultimoStatoOsservato = stato.testo;
|
|
1615
|
+
}
|
|
1616
|
+
return stato;
|
|
1617
|
+
}
|
|
1618
|
+
impostaProssimita(a, b, gain) {
|
|
1619
|
+
if ((this.manifest.voice ?? "none") !== "proximity") return;
|
|
1620
|
+
const dati = this.richiediDati();
|
|
1621
|
+
if (!dati.giocatori.some((player) => player.id === a) || !dati.giocatori.some((player) => player.id === b)) throw new Error("Player not found.");
|
|
1622
|
+
if (Number.isNaN(gain)) throw new TypeError("Voice gain must be a number.");
|
|
1623
|
+
if (a === b) return;
|
|
1624
|
+
const valore = Math.round(Math.min(1, Math.max(0, gain)) * 100) / 100;
|
|
1625
|
+
dati.voceGuadagni ??= {};
|
|
1626
|
+
this.salvaGuadagno(a, b, valore);
|
|
1627
|
+
this.salvaGuadagno(b, a, valore);
|
|
1628
|
+
}
|
|
1629
|
+
salvaGuadagno(playerId, altroId, valore) {
|
|
1630
|
+
const dati = this.richiediDati();
|
|
1631
|
+
dati.voceGuadagni ??= {};
|
|
1632
|
+
const precedente = dati.voceGuadagni[playerId]?.[altroId] ?? 1;
|
|
1633
|
+
if (precedente === valore) return;
|
|
1634
|
+
this.voceDaPersistire = true;
|
|
1635
|
+
if (valore === 1) {
|
|
1636
|
+
const riga = dati.voceGuadagni[playerId];
|
|
1637
|
+
if (riga !== void 0) {
|
|
1638
|
+
delete riga[altroId];
|
|
1639
|
+
if (Object.keys(riga).length === 0) delete dati.voceGuadagni[playerId];
|
|
1640
|
+
}
|
|
1641
|
+
} else {
|
|
1642
|
+
(dati.voceGuadagni[playerId] ??= {})[altroId] = valore;
|
|
1643
|
+
}
|
|
1644
|
+
(this.voceGuadagniCambiati.get(playerId) ?? this.creaCambiVoce(playerId))[altroId] = valore;
|
|
1645
|
+
}
|
|
1646
|
+
creaCambiVoce(playerId) {
|
|
1647
|
+
const cambi = {};
|
|
1648
|
+
this.voceGuadagniCambiati.set(playerId, cambi);
|
|
1649
|
+
return cambi;
|
|
1650
|
+
}
|
|
1651
|
+
inviaGuadagniCambiati() {
|
|
1652
|
+
if (this.dati === null || this.voceGuadagniCambiati.size === 0) return;
|
|
1653
|
+
for (const [playerId, gains] of this.voceGuadagniCambiati) {
|
|
1654
|
+
const player = this.dati.giocatori.find((item) => item.id === playerId);
|
|
1655
|
+
if (player?.connected && player.connessione !== null) {
|
|
1656
|
+
this.adattatore.invia(player.connessione, { t: "voice", op: "gain", gains: { ...gains } });
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
this.voceGuadagniCambiati.clear();
|
|
1660
|
+
}
|
|
1661
|
+
pulisciGuadagni(playerId) {
|
|
1662
|
+
const guadagni = this.richiediDati().voceGuadagni;
|
|
1663
|
+
if (guadagni === void 0) return;
|
|
1664
|
+
let cambiato = Object.hasOwn(guadagni, playerId);
|
|
1665
|
+
delete guadagni[playerId];
|
|
1666
|
+
this.voceGuadagniCambiati.delete(playerId);
|
|
1667
|
+
for (const [altroId, riga] of Object.entries(guadagni)) {
|
|
1668
|
+
cambiato = Object.hasOwn(riga, playerId) || cambiato;
|
|
1669
|
+
delete riga[playerId];
|
|
1670
|
+
if (Object.keys(riga).length === 0) delete guadagni[altroId];
|
|
1671
|
+
}
|
|
1672
|
+
if (cambiato) this.voceDaPersistire = true;
|
|
1673
|
+
}
|
|
1674
|
+
async terminaPerStato(code) {
|
|
1675
|
+
const dati = this.richiediDati();
|
|
1676
|
+
dati.state = dati.statoSincronizzato;
|
|
1677
|
+
await this.terminaInterna({ error: code });
|
|
1678
|
+
}
|
|
1679
|
+
async terminaInterna(result) {
|
|
1680
|
+
const dati = this.richiediDati();
|
|
1681
|
+
if (dati.status === "ended") return;
|
|
1682
|
+
dati.status = "ended";
|
|
1683
|
+
dati.countdownAt = null;
|
|
1684
|
+
dati.result = result;
|
|
1685
|
+
dati.resultAt = this.adattatore.ora();
|
|
1686
|
+
try {
|
|
1687
|
+
await this.definizione.onEnd?.(this.room);
|
|
1688
|
+
} catch {
|
|
1689
|
+
if (record(result)?.error === void 0) dati.result = { error: "callback_error" };
|
|
1690
|
+
}
|
|
1691
|
+
const stato = analizzaJson(dati.state);
|
|
1692
|
+
if (!stato.ok || stato.bytes > LIMITE_STATO) {
|
|
1693
|
+
dati.state = dati.statoSincronizzato;
|
|
1694
|
+
dati.result = { error: stato.ok ? "state_too_large" : "state_invalid" };
|
|
1695
|
+
} else if (stato.testo !== JSON.stringify(dati.statoSincronizzato)) {
|
|
1696
|
+
dati.tick++;
|
|
1697
|
+
dati.state = stato.valore;
|
|
1698
|
+
this.inviaSnapshotTutti();
|
|
1699
|
+
}
|
|
1700
|
+
const fine = { result: dati.result, at: dati.resultAt };
|
|
1701
|
+
dati.fineInCoda = fine;
|
|
1702
|
+
this.inviaStatus(dati.resultAt);
|
|
1703
|
+
this.broadcast({ t: "flush" });
|
|
1704
|
+
for (const player of dati.giocatori) {
|
|
1705
|
+
if (player.connected && player.connessione !== null) {
|
|
1706
|
+
this.adattatore.chiudi(player.connessione, 4004, "room_ended");
|
|
1707
|
+
player.connected = false;
|
|
1708
|
+
player.connessione = null;
|
|
1709
|
+
player.graziaFinoA = null;
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
await this.persistiEProgramma();
|
|
1713
|
+
}
|
|
1714
|
+
registraCpu(durata) {
|
|
1715
|
+
const dati = this.richiediDati();
|
|
1716
|
+
dati.durateCpu.push(durata);
|
|
1717
|
+
if (dati.durateCpu.length > 50) dati.durateCpu.shift();
|
|
1718
|
+
dati.cpuOltreCento = durata > 100 ? dati.cpuOltreCento + 1 : 0;
|
|
1719
|
+
if (dati.cpuOltreCento >= 20) {
|
|
1720
|
+
this.fineRichiesta = { error: "cpu_budget" };
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
if (dati.durateCpu.length === 50) {
|
|
1724
|
+
const media = dati.durateCpu.reduce((somma, value) => somma + value, 0) / 50;
|
|
1725
|
+
if (media > 20 && dati.tickRate > 5) {
|
|
1726
|
+
dati.tickRate = Math.max(5, Math.floor(dati.tickRate / 2));
|
|
1727
|
+
dati.durateCpu = [];
|
|
1728
|
+
this.broadcast({
|
|
1729
|
+
t: "error",
|
|
1730
|
+
code: "tick_rate_reduced",
|
|
1731
|
+
message: `Tick rate reduced to ${dati.tickRate} because the game exceeded its CPU budget.`
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
async persisti() {
|
|
1737
|
+
if (this.dati !== null) {
|
|
1738
|
+
await this.adattatore.storage.put(CHIAVE_NUCLEO, this.dati);
|
|
1739
|
+
this.voceDaPersistire = false;
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
async persistiEProgramma() {
|
|
1743
|
+
await this.persisti();
|
|
1744
|
+
await this.aggiornaProgrammazione();
|
|
1745
|
+
}
|
|
1746
|
+
serveTick() {
|
|
1747
|
+
const dati = this.dati;
|
|
1748
|
+
return dati !== null && dati.status === "playing" && dati.tickRate > 0 && dati.giocatori.some((player) => player.connected) && this.adattatore.ora() - Math.max(dati.ultimoInputAt, dati.ultimoCambioStatoAt) < RIPOSO_TICK_MS;
|
|
1749
|
+
}
|
|
1750
|
+
async terminaSeInattiva() {
|
|
1751
|
+
if (this.dati?.status !== "playing" || this.adattatore.ora() < this.dati.ultimoInputAt + INATTIVITA_MS) return false;
|
|
1752
|
+
await this.terminaInterna({ error: "idle" });
|
|
1753
|
+
return true;
|
|
1754
|
+
}
|
|
1755
|
+
async aggiornaProgrammazione() {
|
|
1756
|
+
if (this.dati === null || this.dati.status === "ended") {
|
|
1757
|
+
this.adattatore.programmaTick(null);
|
|
1758
|
+
await this.adattatore.programmaSveglia(null);
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
this.adattatore.programmaTick(
|
|
1762
|
+
this.serveTick() ? 1e3 / this.dati.tickRate : null
|
|
1763
|
+
);
|
|
1764
|
+
const prossime = [];
|
|
1765
|
+
if (this.dati.status === "playing") prossime.push(this.dati.ultimoInputAt + INATTIVITA_MS);
|
|
1766
|
+
if (this.dati.countdownAt !== null) prossime.push(this.dati.countdownAt);
|
|
1767
|
+
for (const player of this.dati.giocatori) {
|
|
1768
|
+
if (!player.connected && player.graziaFinoA !== null) prossime.push(player.graziaFinoA);
|
|
1769
|
+
}
|
|
1770
|
+
for (const timer of this.dati.timer) prossime.push(timer.at);
|
|
1771
|
+
if (this.dati.giocatori.length === 0 && this.dati.vuotaDa !== null) {
|
|
1772
|
+
prossime.push(this.dati.vuotaDa + STANZA_VUOTA_MS);
|
|
1773
|
+
}
|
|
1774
|
+
await this.adattatore.programmaSveglia(prossime.length === 0 ? null : Math.min(...prossime));
|
|
1775
|
+
}
|
|
1776
|
+
};
|
|
1777
|
+
var GUID_WEBSOCKET = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
1778
|
+
var MASSIMO_MESSAGGIO = 1e6;
|
|
1779
|
+
function contieneToken(value, token) {
|
|
1780
|
+
return value?.split(",").some((parte) => parte.trim().toLowerCase() === token) === true;
|
|
1781
|
+
}
|
|
1782
|
+
function chiaveValida(value) {
|
|
1783
|
+
if (value === void 0 || !/^[A-Za-z0-9+/]{22}==$/.test(value)) return false;
|
|
1784
|
+
return Buffer.from(value, "base64").byteLength === 16;
|
|
1785
|
+
}
|
|
1786
|
+
function creaFrame(opcode, payload) {
|
|
1787
|
+
const primo = Buffer.from([128 | opcode]);
|
|
1788
|
+
if (payload.byteLength < 126) {
|
|
1789
|
+
return Buffer.concat([primo, Buffer.from([payload.byteLength]), payload]);
|
|
1790
|
+
}
|
|
1791
|
+
if (payload.byteLength <= 65535) {
|
|
1792
|
+
const header2 = Buffer.allocUnsafe(3);
|
|
1793
|
+
header2[0] = 126;
|
|
1794
|
+
header2.writeUInt16BE(payload.byteLength, 1);
|
|
1795
|
+
return Buffer.concat([primo, header2, payload]);
|
|
1796
|
+
}
|
|
1797
|
+
const header = Buffer.allocUnsafe(9);
|
|
1798
|
+
header[0] = 127;
|
|
1799
|
+
header.writeBigUInt64BE(BigInt(payload.byteLength), 1);
|
|
1800
|
+
return Buffer.concat([primo, header, payload]);
|
|
1801
|
+
}
|
|
1802
|
+
function testoUtf8(payload) {
|
|
1803
|
+
try {
|
|
1804
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(payload);
|
|
1805
|
+
} catch {
|
|
1806
|
+
return null;
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
var NodeWebSocket = class extends EventEmitter {
|
|
1810
|
+
constructor(socket, head, massimoMessaggio = MASSIMO_MESSAGGIO) {
|
|
1811
|
+
super();
|
|
1812
|
+
this.socket = socket;
|
|
1813
|
+
this.massimoMessaggio = massimoMessaggio;
|
|
1814
|
+
this.buffer = Buffer.alloc(0);
|
|
1815
|
+
this.frammenti = [];
|
|
1816
|
+
this.byteFrammenti = 0;
|
|
1817
|
+
this.frammentando = false;
|
|
1818
|
+
this.chiusuraInviata = false;
|
|
1819
|
+
this.terminato = false;
|
|
1820
|
+
socket.on("data", (chunk) => this.aggiungi(chunk));
|
|
1821
|
+
socket.on("end", () => this.termina());
|
|
1822
|
+
socket.on("close", () => this.termina());
|
|
1823
|
+
socket.on("error", () => this.termina());
|
|
1824
|
+
if (head.byteLength > 0) queueMicrotask(() => this.aggiungi(head));
|
|
1825
|
+
}
|
|
1826
|
+
send(message) {
|
|
1827
|
+
if (this.chiusuraInviata || this.terminato) return;
|
|
1828
|
+
this.socket.write(creaFrame(1, Buffer.from(message, "utf8")));
|
|
1829
|
+
}
|
|
1830
|
+
close(code = 1e3, reason = "") {
|
|
1831
|
+
if (this.chiusuraInviata || this.terminato) return;
|
|
1832
|
+
const motivo = Buffer.from(reason, "utf8");
|
|
1833
|
+
if (motivo.byteLength > 123) throw new RangeError("WebSocket close reasons must be at most 123 bytes.");
|
|
1834
|
+
const payload = Buffer.allocUnsafe(2 + motivo.byteLength);
|
|
1835
|
+
payload.writeUInt16BE(code, 0);
|
|
1836
|
+
motivo.copy(payload, 2);
|
|
1837
|
+
this.chiusuraInviata = true;
|
|
1838
|
+
this.socket.write(creaFrame(8, payload), () => this.socket.end());
|
|
1839
|
+
}
|
|
1840
|
+
termina() {
|
|
1841
|
+
if (this.terminato) return;
|
|
1842
|
+
this.terminato = true;
|
|
1843
|
+
this.emit("close");
|
|
1844
|
+
}
|
|
1845
|
+
erroreProtocollo(code, reason) {
|
|
1846
|
+
this.close(code, reason);
|
|
1847
|
+
}
|
|
1848
|
+
aggiungi(chunk) {
|
|
1849
|
+
if (this.terminato) return;
|
|
1850
|
+
this.buffer = this.buffer.byteLength === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
|
|
1851
|
+
this.leggiFrame();
|
|
1852
|
+
}
|
|
1853
|
+
leggiFrame() {
|
|
1854
|
+
while (!this.terminato && this.buffer.byteLength >= 2) {
|
|
1855
|
+
const primo = this.buffer[0] ?? 0;
|
|
1856
|
+
const secondo = this.buffer[1] ?? 0;
|
|
1857
|
+
const fin = (primo & 128) !== 0;
|
|
1858
|
+
const opcode = primo & 15;
|
|
1859
|
+
const controllo = opcode >= 8;
|
|
1860
|
+
if ((primo & 112) !== 0 || ![0, 1, 8, 9, 10].includes(opcode)) {
|
|
1861
|
+
this.erroreProtocollo(1002, "protocol_error");
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
if ((secondo & 128) === 0 || controllo && !fin) {
|
|
1865
|
+
this.erroreProtocollo(1002, "protocol_error");
|
|
1866
|
+
return;
|
|
1867
|
+
}
|
|
1868
|
+
let lunghezza = secondo & 127;
|
|
1869
|
+
let offset = 2;
|
|
1870
|
+
if (lunghezza === 126) {
|
|
1871
|
+
if (this.buffer.byteLength < 4) return;
|
|
1872
|
+
lunghezza = this.buffer.readUInt16BE(2);
|
|
1873
|
+
offset = 4;
|
|
1874
|
+
} else if (lunghezza === 127) {
|
|
1875
|
+
if (this.buffer.byteLength < 10) return;
|
|
1876
|
+
const grande = this.buffer.readBigUInt64BE(2);
|
|
1877
|
+
if (grande > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
1878
|
+
this.erroreProtocollo(1009, "message_too_large");
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1881
|
+
lunghezza = Number(grande);
|
|
1882
|
+
offset = 10;
|
|
1883
|
+
}
|
|
1884
|
+
if (controllo && lunghezza > 125) {
|
|
1885
|
+
this.erroreProtocollo(1002, "protocol_error");
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
if (this.buffer.byteLength < offset + 4 + lunghezza) return;
|
|
1889
|
+
const maschera = this.buffer.subarray(offset, offset + 4);
|
|
1890
|
+
const payload = Buffer.from(this.buffer.subarray(offset + 4, offset + 4 + lunghezza));
|
|
1891
|
+
this.buffer = this.buffer.subarray(offset + 4 + lunghezza);
|
|
1892
|
+
for (let indice = 0; indice < payload.byteLength; indice += 1) {
|
|
1893
|
+
payload[indice] = (payload[indice] ?? 0) ^ (maschera[indice % 4] ?? 0);
|
|
1894
|
+
}
|
|
1895
|
+
if (controllo) {
|
|
1896
|
+
this.gestisciControllo(opcode, payload);
|
|
1897
|
+
continue;
|
|
1898
|
+
}
|
|
1899
|
+
this.gestisciDati(opcode, fin, payload);
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
gestisciControllo(opcode, payload) {
|
|
1903
|
+
if (opcode === 9) {
|
|
1904
|
+
if (!this.chiusuraInviata) this.socket.write(creaFrame(10, payload));
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
if (opcode === 10) return;
|
|
1908
|
+
if (payload.byteLength === 1) {
|
|
1909
|
+
this.erroreProtocollo(1002, "protocol_error");
|
|
1910
|
+
return;
|
|
1911
|
+
}
|
|
1912
|
+
if (!this.chiusuraInviata) {
|
|
1913
|
+
this.chiusuraInviata = true;
|
|
1914
|
+
this.socket.write(creaFrame(8, payload), () => this.socket.end());
|
|
1915
|
+
} else {
|
|
1916
|
+
this.socket.end();
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
gestisciDati(opcode, fin, payload) {
|
|
1920
|
+
if (opcode === 1 && this.frammentando) {
|
|
1921
|
+
this.erroreProtocollo(1002, "protocol_error");
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
if (opcode === 0 && !this.frammentando) {
|
|
1925
|
+
this.erroreProtocollo(1002, "protocol_error");
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
if (opcode === 1 && !fin) this.frammentando = true;
|
|
1929
|
+
this.frammenti.push(payload);
|
|
1930
|
+
this.byteFrammenti += payload.byteLength;
|
|
1931
|
+
if (this.byteFrammenti > this.massimoMessaggio) {
|
|
1932
|
+
this.erroreProtocollo(1009, "message_too_large");
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
if (!fin) return;
|
|
1936
|
+
const completo = this.frammenti.length === 1 ? this.frammenti[0] ?? Buffer.alloc(0) : Buffer.concat(this.frammenti, this.byteFrammenti);
|
|
1937
|
+
this.frammenti = [];
|
|
1938
|
+
this.byteFrammenti = 0;
|
|
1939
|
+
this.frammentando = false;
|
|
1940
|
+
const testo = testoUtf8(completo);
|
|
1941
|
+
if (testo === null) {
|
|
1942
|
+
this.erroreProtocollo(1007, "invalid_utf8");
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
this.emit("message", testo);
|
|
1946
|
+
}
|
|
1947
|
+
};
|
|
1948
|
+
function acceptNodeWebSocket(request, socket, head = Buffer.alloc(0)) {
|
|
1949
|
+
const key = request.headers["sec-websocket-key"];
|
|
1950
|
+
if (request.method !== "GET" || request.headers.upgrade?.toLowerCase() !== "websocket" || !contieneToken(request.headers.connection, "upgrade") || request.headers["sec-websocket-version"] !== "13" || !chiaveValida(key)) {
|
|
1951
|
+
throw new TypeError("The WebSocket upgrade request is invalid.");
|
|
1952
|
+
}
|
|
1953
|
+
const accept = createHash("sha1").update(key + GUID_WEBSOCKET).digest("base64");
|
|
1954
|
+
socket.write(
|
|
1955
|
+
`HTTP/1.1 101 Switching Protocols\r
|
|
1956
|
+
Upgrade: websocket\r
|
|
1957
|
+
Connection: Upgrade\r
|
|
1958
|
+
Sec-WebSocket-Accept: ${accept}\r
|
|
1959
|
+
\r
|
|
1960
|
+
`
|
|
1961
|
+
);
|
|
1962
|
+
return new NodeWebSocket(socket, head);
|
|
1963
|
+
}
|
|
1964
|
+
function acceptNodeWebSocket2(request, socket, head = Buffer.alloc(0)) {
|
|
1965
|
+
return acceptNodeWebSocket(request, socket, head);
|
|
1966
|
+
}
|
|
1967
|
+
function documentoValido(value) {
|
|
1968
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
1969
|
+
const dati = value;
|
|
1970
|
+
return dati.version === 1 && typeof dati.values === "object" && dati.values !== null && !Array.isArray(dati.values);
|
|
1971
|
+
}
|
|
1972
|
+
var ArchivioNode = class _ArchivioNode {
|
|
1973
|
+
constructor(file) {
|
|
1974
|
+
this.file = file;
|
|
1975
|
+
this.valori = /* @__PURE__ */ new Map();
|
|
1976
|
+
this.scritture = Promise.resolve();
|
|
1977
|
+
}
|
|
1978
|
+
static async apri(file) {
|
|
1979
|
+
const archivio = new _ArchivioNode(file);
|
|
1980
|
+
if (file === null) return archivio;
|
|
1981
|
+
let source;
|
|
1982
|
+
try {
|
|
1983
|
+
source = await readFile(file, "utf8");
|
|
1984
|
+
} catch (cause) {
|
|
1985
|
+
if (cause.code === "ENOENT") return archivio;
|
|
1986
|
+
throw cause;
|
|
1987
|
+
}
|
|
1988
|
+
let parsed;
|
|
1989
|
+
try {
|
|
1990
|
+
parsed = JSON.parse(source);
|
|
1991
|
+
} catch {
|
|
1992
|
+
throw new Error(`The room storage file is not valid JSON: ${file}`);
|
|
1993
|
+
}
|
|
1994
|
+
if (!documentoValido(parsed)) throw new Error(`The room storage file is invalid: ${file}`);
|
|
1995
|
+
for (const [key, value] of Object.entries(parsed.values)) {
|
|
1996
|
+
archivio.valori.set(key, structuredClone(value));
|
|
1997
|
+
}
|
|
1998
|
+
return archivio;
|
|
1999
|
+
}
|
|
2000
|
+
async get(key) {
|
|
2001
|
+
const value = this.valori.get(key);
|
|
2002
|
+
return value === void 0 ? void 0 : structuredClone(value);
|
|
2003
|
+
}
|
|
2004
|
+
async put(key, value) {
|
|
2005
|
+
this.valori.set(key, structuredClone(value));
|
|
2006
|
+
await this.persisti();
|
|
2007
|
+
}
|
|
2008
|
+
async delete(key) {
|
|
2009
|
+
this.valori.delete(key);
|
|
2010
|
+
await this.persisti();
|
|
2011
|
+
}
|
|
2012
|
+
async list(prefix = "") {
|
|
2013
|
+
return new Map([...this.valori].flatMap(
|
|
2014
|
+
([key, value]) => key.startsWith(prefix) ? [[key, structuredClone(value)]] : []
|
|
2015
|
+
));
|
|
2016
|
+
}
|
|
2017
|
+
persisti() {
|
|
2018
|
+
if (this.file === null) return Promise.resolve();
|
|
2019
|
+
const file = this.file;
|
|
2020
|
+
const documento = {
|
|
2021
|
+
version: 1,
|
|
2022
|
+
values: Object.fromEntries([...this.valori].map(([key, value]) => [key, structuredClone(value)]))
|
|
2023
|
+
};
|
|
2024
|
+
const operazione = this.scritture.then(async () => {
|
|
2025
|
+
await mkdir(dirname(file), { recursive: true });
|
|
2026
|
+
const temporaneo = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
2027
|
+
try {
|
|
2028
|
+
await writeFile(temporaneo, `${JSON.stringify(documento, null, 2)}
|
|
2029
|
+
`, "utf8");
|
|
2030
|
+
await rename(temporaneo, file);
|
|
2031
|
+
} finally {
|
|
2032
|
+
await rm(temporaneo, { force: true });
|
|
2033
|
+
}
|
|
2034
|
+
});
|
|
2035
|
+
this.scritture = operazione.catch(() => void 0);
|
|
2036
|
+
return operazione;
|
|
2037
|
+
}
|
|
2038
|
+
};
|
|
2039
|
+
var AdattatoreNode = class {
|
|
2040
|
+
constructor(storage) {
|
|
2041
|
+
this.storage = storage;
|
|
2042
|
+
this.connessioni = /* @__PURE__ */ new Map();
|
|
2043
|
+
this.tickTimer = null;
|
|
2044
|
+
this.tickIntervallo = null;
|
|
2045
|
+
this.tickGenerazione = 0;
|
|
2046
|
+
this.svegliaTimer = null;
|
|
2047
|
+
this.svegliaAt = null;
|
|
2048
|
+
this.eseguiTick = async () => void 0;
|
|
2049
|
+
this.eseguiSveglia = async () => void 0;
|
|
2050
|
+
}
|
|
2051
|
+
collega(input) {
|
|
2052
|
+
this.eseguiTick = input.tick;
|
|
2053
|
+
this.eseguiSveglia = input.sveglia;
|
|
2054
|
+
}
|
|
2055
|
+
aggiungi(id, socket) {
|
|
2056
|
+
this.connessioni.set(id, socket);
|
|
2057
|
+
}
|
|
2058
|
+
rimuovi(id) {
|
|
2059
|
+
this.connessioni.delete(id);
|
|
2060
|
+
}
|
|
2061
|
+
elencoConnessioni() {
|
|
2062
|
+
return [...this.connessioni];
|
|
2063
|
+
}
|
|
2064
|
+
invia(connessione, messaggio) {
|
|
2065
|
+
this.connessioni.get(connessione)?.send(JSON.stringify(messaggio));
|
|
2066
|
+
}
|
|
2067
|
+
chiudi(connessione, codice, motivo) {
|
|
2068
|
+
this.connessioni.get(connessione)?.close(codice, motivo);
|
|
2069
|
+
}
|
|
2070
|
+
connessioniAttive() {
|
|
2071
|
+
return [...this.connessioni.keys()];
|
|
2072
|
+
}
|
|
2073
|
+
ora() {
|
|
2074
|
+
return Date.now();
|
|
2075
|
+
}
|
|
2076
|
+
misuraCpu() {
|
|
2077
|
+
return performance.now();
|
|
2078
|
+
}
|
|
2079
|
+
programmaTick(intervalloMs) {
|
|
2080
|
+
if (this.tickIntervallo === intervalloMs) return;
|
|
2081
|
+
this.tickIntervallo = intervalloMs;
|
|
2082
|
+
this.tickGenerazione += 1;
|
|
2083
|
+
if (this.tickTimer !== null) clearTimeout(this.tickTimer);
|
|
2084
|
+
this.tickTimer = null;
|
|
2085
|
+
if (intervalloMs !== null) this.pianificaTick(this.tickGenerazione);
|
|
2086
|
+
}
|
|
2087
|
+
async programmaSveglia(ora) {
|
|
2088
|
+
this.svegliaAt = ora;
|
|
2089
|
+
if (this.svegliaTimer !== null) clearTimeout(this.svegliaTimer);
|
|
2090
|
+
this.svegliaTimer = null;
|
|
2091
|
+
if (ora !== null) this.pianificaSveglia();
|
|
2092
|
+
}
|
|
2093
|
+
fermaTimer() {
|
|
2094
|
+
this.tickIntervallo = null;
|
|
2095
|
+
this.svegliaAt = null;
|
|
2096
|
+
this.tickGenerazione += 1;
|
|
2097
|
+
if (this.tickTimer !== null) clearTimeout(this.tickTimer);
|
|
2098
|
+
if (this.svegliaTimer !== null) clearTimeout(this.svegliaTimer);
|
|
2099
|
+
this.tickTimer = null;
|
|
2100
|
+
this.svegliaTimer = null;
|
|
2101
|
+
}
|
|
2102
|
+
pianificaTick(generazione) {
|
|
2103
|
+
const intervallo = this.tickIntervallo;
|
|
2104
|
+
if (intervallo === null) return;
|
|
2105
|
+
this.tickTimer = setTimeout(() => {
|
|
2106
|
+
this.tickTimer = null;
|
|
2107
|
+
void this.eseguiTick().finally(() => {
|
|
2108
|
+
if (this.tickGenerazione === generazione && this.tickIntervallo !== null) {
|
|
2109
|
+
this.pianificaTick(generazione);
|
|
2110
|
+
}
|
|
2111
|
+
});
|
|
2112
|
+
}, intervallo);
|
|
2113
|
+
this.tickTimer.unref();
|
|
2114
|
+
}
|
|
2115
|
+
pianificaSveglia() {
|
|
2116
|
+
const ora = this.svegliaAt;
|
|
2117
|
+
if (ora === null) return;
|
|
2118
|
+
const attesa = Math.min(Math.max(0, ora - Date.now()), 2147483647);
|
|
2119
|
+
this.svegliaTimer = setTimeout(() => {
|
|
2120
|
+
this.svegliaTimer = null;
|
|
2121
|
+
if (this.svegliaAt !== null && this.svegliaAt > Date.now()) {
|
|
2122
|
+
this.pianificaSveglia();
|
|
2123
|
+
return;
|
|
2124
|
+
}
|
|
2125
|
+
void this.eseguiSveglia();
|
|
2126
|
+
}, attesa);
|
|
2127
|
+
this.svegliaTimer.unref();
|
|
2128
|
+
}
|
|
2129
|
+
};
|
|
2130
|
+
var StanzaNode = class {
|
|
2131
|
+
constructor(nucleo, adattatore, manifest) {
|
|
2132
|
+
this.nucleo = nucleo;
|
|
2133
|
+
this.adattatore = adattatore;
|
|
2134
|
+
this.manifest = manifest;
|
|
2135
|
+
this.coda = Promise.resolve();
|
|
2136
|
+
this.voceRoster = /* @__PURE__ */ new Map();
|
|
2137
|
+
this.voceFrequenza = /* @__PURE__ */ new Map();
|
|
2138
|
+
this.voceUltimaRichiesta = /* @__PURE__ */ new Map();
|
|
2139
|
+
this.frameFrequenza = /* @__PURE__ */ new Map();
|
|
2140
|
+
this.connessioniGiocatori = /* @__PURE__ */ new Map();
|
|
2141
|
+
this.giocatoriConnessioni = /* @__PURE__ */ new Map();
|
|
2142
|
+
adattatore.collega({
|
|
2143
|
+
tick: () => this.serializza(() => nucleo.eseguiTick()),
|
|
2144
|
+
sveglia: () => this.serializza(() => nucleo.sveglia())
|
|
2145
|
+
});
|
|
2146
|
+
}
|
|
2147
|
+
create(roomId, mode, creator) {
|
|
2148
|
+
return this.serializza(() => this.nucleo.crea(roomId, mode, creator));
|
|
2149
|
+
}
|
|
2150
|
+
info() {
|
|
2151
|
+
return this.serializza(() => Promise.resolve(this.nucleo.info()));
|
|
2152
|
+
}
|
|
2153
|
+
canJoin(identity) {
|
|
2154
|
+
return this.serializza(() => Promise.resolve(this.nucleo.puoEntrare(identity)));
|
|
2155
|
+
}
|
|
2156
|
+
async connect(socket, identity) {
|
|
2157
|
+
const connessione = randomUUID();
|
|
2158
|
+
this.adattatore.aggiungi(connessione, socket);
|
|
2159
|
+
this.connessioniGiocatori.set(connessione, identity.id);
|
|
2160
|
+
socket.on("message", (message) => {
|
|
2161
|
+
void this.serializza(() => this.ricevi(connessione, message));
|
|
2162
|
+
});
|
|
2163
|
+
socket.on("close", () => {
|
|
2164
|
+
this.adattatore.rimuovi(connessione);
|
|
2165
|
+
void this.serializza(async () => {
|
|
2166
|
+
this.rimuoviConnessione(connessione);
|
|
2167
|
+
await this.nucleo.disconnetti(connessione);
|
|
2168
|
+
});
|
|
2169
|
+
});
|
|
2170
|
+
try {
|
|
2171
|
+
return await this.serializza(async () => {
|
|
2172
|
+
const precedente = this.giocatoriConnessioni.get(identity.id);
|
|
2173
|
+
if (precedente !== void 0 && precedente !== connessione) this.rimuoviConnessione(precedente);
|
|
2174
|
+
this.giocatoriConnessioni.set(identity.id, connessione);
|
|
2175
|
+
const esito = await this.nucleo.entra(identity, connessione);
|
|
2176
|
+
if (esito.ok) this.inviaRoster(connessione);
|
|
2177
|
+
return esito;
|
|
2178
|
+
});
|
|
2179
|
+
} catch (cause) {
|
|
2180
|
+
this.adattatore.rimuovi(connessione);
|
|
2181
|
+
this.rimuoviConnessione(connessione);
|
|
2182
|
+
socket.close(1011, "room_error");
|
|
2183
|
+
throw cause;
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
flush() {
|
|
2187
|
+
return this.serializza(() => this.nucleo.flush());
|
|
2188
|
+
}
|
|
2189
|
+
wake() {
|
|
2190
|
+
return this.serializza(() => this.nucleo.sveglia());
|
|
2191
|
+
}
|
|
2192
|
+
async close() {
|
|
2193
|
+
this.adattatore.fermaTimer();
|
|
2194
|
+
for (const [connessione, socket] of this.adattatore.elencoConnessioni()) {
|
|
2195
|
+
this.adattatore.rimuovi(connessione);
|
|
2196
|
+
this.rimuoviConnessione(connessione);
|
|
2197
|
+
socket.close(1001, "server_shutdown");
|
|
2198
|
+
await this.serializza(() => this.nucleo.disconnetti(connessione));
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
serializza(operazione) {
|
|
2202
|
+
const risultato = this.coda.then(operazione);
|
|
2203
|
+
this.coda = risultato.then(() => void 0, () => void 0);
|
|
2204
|
+
return risultato;
|
|
2205
|
+
}
|
|
2206
|
+
async ricevi(connessione, frame) {
|
|
2207
|
+
const player = this.nucleo.giocatoreConnesso(connessione);
|
|
2208
|
+
if (player === null) return;
|
|
2209
|
+
const ora = Date.now();
|
|
2210
|
+
const frames = (this.frameFrequenza.get(connessione) ?? []).filter((at) => ora - at < 1e3);
|
|
2211
|
+
if (frames.length >= 20) {
|
|
2212
|
+
this.adattatore.chiudi(connessione, 4008, "rate_limited");
|
|
2213
|
+
this.rimuoviConnessione(connessione);
|
|
2214
|
+
await this.nucleo.disconnetti(connessione);
|
|
2215
|
+
return;
|
|
2216
|
+
}
|
|
2217
|
+
frames.push(ora);
|
|
2218
|
+
this.frameFrequenza.set(connessione, frames);
|
|
2219
|
+
let value;
|
|
2220
|
+
try {
|
|
2221
|
+
value = JSON.parse(frame);
|
|
2222
|
+
} catch {
|
|
2223
|
+
await this.nucleo.ricevi(connessione, frame);
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
const message = typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
2227
|
+
if (message?.t !== "voice") {
|
|
2228
|
+
await this.nucleo.ricevi(connessione, frame);
|
|
2229
|
+
this.riconciliaRoster();
|
|
2230
|
+
return;
|
|
2231
|
+
}
|
|
2232
|
+
if (Buffer.byteLength(frame, "utf8") > 64 * 1024) {
|
|
2233
|
+
this.adattatore.chiudi(connessione, 4009, "bad_message");
|
|
2234
|
+
this.rimuoviConnessione(connessione);
|
|
2235
|
+
await this.nucleo.disconnetti(connessione);
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
2238
|
+
await this.riceviVoce(connessione, player.id, player.role, message);
|
|
2239
|
+
}
|
|
2240
|
+
async riceviVoce(connessione, playerId, role, value) {
|
|
2241
|
+
const richiesta = this.richiestaVoce(value);
|
|
2242
|
+
if (richiesta === null) {
|
|
2243
|
+
this.inviaErroreVoce(connessione, value, "invalid_request", "The voice request is invalid.");
|
|
2244
|
+
return;
|
|
2245
|
+
}
|
|
2246
|
+
const ultima = this.voceUltimaRichiesta.get(connessione) ?? 0;
|
|
2247
|
+
if (richiesta.r <= ultima) {
|
|
2248
|
+
this.inviaErroreVoce(
|
|
2249
|
+
connessione,
|
|
2250
|
+
richiesta,
|
|
2251
|
+
"invalid_request",
|
|
2252
|
+
"Voice request numbers must increase."
|
|
2253
|
+
);
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
this.voceUltimaRichiesta.set(connessione, richiesta.r);
|
|
2257
|
+
const ora = Date.now();
|
|
2258
|
+
const recenti = (this.voceFrequenza.get(connessione) ?? []).filter((at) => ora - at < 1e4);
|
|
2259
|
+
if (recenti.length >= 30) {
|
|
2260
|
+
this.inviaErroreVoce(
|
|
2261
|
+
connessione,
|
|
2262
|
+
richiesta,
|
|
2263
|
+
"rate_limited",
|
|
2264
|
+
"Too many voice requests. Try again later."
|
|
2265
|
+
);
|
|
2266
|
+
return;
|
|
2267
|
+
}
|
|
2268
|
+
recenti.push(ora);
|
|
2269
|
+
this.voceFrequenza.set(connessione, recenti);
|
|
2270
|
+
const mode = this.modoVoce();
|
|
2271
|
+
if (mode === "none") {
|
|
2272
|
+
this.inviaErroreVoce(connessione, richiesta, "voice_disabled", "Voice is disabled for this room.");
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
if (richiesta.op === "ice") {
|
|
2276
|
+
this.adattatore.invia(connessione, {
|
|
2277
|
+
t: "voice",
|
|
2278
|
+
op: "ice",
|
|
2279
|
+
r: richiesta.r,
|
|
2280
|
+
transport: "mesh",
|
|
2281
|
+
mode,
|
|
2282
|
+
iceServers: []
|
|
2283
|
+
});
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
if (richiesta.op === "publish") {
|
|
2287
|
+
if (role === "spectator") {
|
|
2288
|
+
this.inviaErroreVoce(connessione, richiesta, "spectator", "Spectators cannot join voice.");
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
this.voceRoster.set(playerId, {
|
|
2292
|
+
id: playerId,
|
|
2293
|
+
session: "mesh",
|
|
2294
|
+
track: "mic",
|
|
2295
|
+
muted: false,
|
|
2296
|
+
connessione
|
|
2297
|
+
});
|
|
2298
|
+
this.adattatore.invia(connessione, { t: "voice", op: "publish", r: richiesta.r });
|
|
2299
|
+
this.broadcastRoster();
|
|
2300
|
+
return;
|
|
2301
|
+
}
|
|
2302
|
+
if (richiesta.op === "signal") {
|
|
2303
|
+
const destinazione = this.giocatoriConnessioni.get(richiesta.to);
|
|
2304
|
+
if (destinazione !== void 0) {
|
|
2305
|
+
this.adattatore.invia(destinazione, {
|
|
2306
|
+
t: "voice",
|
|
2307
|
+
op: "signal",
|
|
2308
|
+
from: playerId,
|
|
2309
|
+
data: richiesta.data
|
|
2310
|
+
});
|
|
2311
|
+
}
|
|
2312
|
+
this.adattatore.invia(connessione, { t: "voice", op: "signal", r: richiesta.r });
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
if (richiesta.op === "mute") {
|
|
2316
|
+
const peer = this.voceRoster.get(playerId);
|
|
2317
|
+
if (peer === void 0 || peer.connessione !== connessione) {
|
|
2318
|
+
this.inviaErroreVoce(
|
|
2319
|
+
connessione,
|
|
2320
|
+
richiesta,
|
|
2321
|
+
"not_publishing",
|
|
2322
|
+
"Join voice before changing mute."
|
|
2323
|
+
);
|
|
2324
|
+
return;
|
|
2325
|
+
}
|
|
2326
|
+
peer.muted = richiesta.muted;
|
|
2327
|
+
this.adattatore.invia(connessione, { t: "voice", op: "mute", r: richiesta.r });
|
|
2328
|
+
this.broadcastRoster();
|
|
2329
|
+
return;
|
|
2330
|
+
}
|
|
2331
|
+
if (richiesta.op === "stop") {
|
|
2332
|
+
const peer = this.voceRoster.get(playerId);
|
|
2333
|
+
const rimossa = peer?.connessione === connessione && this.voceRoster.delete(playerId);
|
|
2334
|
+
this.adattatore.invia(connessione, { t: "voice", op: "stop", r: richiesta.r });
|
|
2335
|
+
if (rimossa) this.broadcastRoster();
|
|
2336
|
+
return;
|
|
2337
|
+
}
|
|
2338
|
+
this.inviaErroreVoce(connessione, richiesta, "invalid_request", "The voice request is invalid.");
|
|
2339
|
+
}
|
|
2340
|
+
richiestaVoce(value) {
|
|
2341
|
+
if (!Number.isSafeInteger(value.r) || value.r < 1 || typeof value.op !== "string") {
|
|
2342
|
+
return null;
|
|
2343
|
+
}
|
|
2344
|
+
const base = { t: "voice", r: value.r };
|
|
2345
|
+
if (value.op === "ice" || value.op === "publish" || value.op === "stop") {
|
|
2346
|
+
return { ...base, op: value.op };
|
|
2347
|
+
}
|
|
2348
|
+
if (value.op === "mute" && typeof value.muted === "boolean") {
|
|
2349
|
+
return { ...base, op: "mute", muted: value.muted };
|
|
2350
|
+
}
|
|
2351
|
+
if (value.op === "signal" && typeof value.to === "string" && Object.hasOwn(value, "data")) {
|
|
2352
|
+
return { ...base, op: "signal", to: value.to, data: value.data };
|
|
2353
|
+
}
|
|
2354
|
+
if (["session", "subscribe", "answer", "close"].includes(value.op)) {
|
|
2355
|
+
return value;
|
|
2356
|
+
}
|
|
2357
|
+
return null;
|
|
2358
|
+
}
|
|
2359
|
+
modoVoce() {
|
|
2360
|
+
return this.manifest.voice ?? "none";
|
|
2361
|
+
}
|
|
2362
|
+
rosterPubblico() {
|
|
2363
|
+
return [...this.voceRoster.values()].map(({ connessione: _connessione, ...peer }) => peer);
|
|
2364
|
+
}
|
|
2365
|
+
inviaRoster(connessione) {
|
|
2366
|
+
this.adattatore.invia(connessione, {
|
|
2367
|
+
t: "voice",
|
|
2368
|
+
op: "roster",
|
|
2369
|
+
mode: this.modoVoce(),
|
|
2370
|
+
peers: this.rosterPubblico()
|
|
2371
|
+
});
|
|
2372
|
+
}
|
|
2373
|
+
broadcastRoster() {
|
|
2374
|
+
for (const connessione of this.adattatore.connessioniAttive()) this.inviaRoster(connessione);
|
|
2375
|
+
}
|
|
2376
|
+
riconciliaRoster() {
|
|
2377
|
+
let cambiato = false;
|
|
2378
|
+
for (const [playerId, peer] of this.voceRoster) {
|
|
2379
|
+
const player = this.nucleo.giocatoreConnesso(peer.connessione);
|
|
2380
|
+
if (player?.id === playerId && player.role !== "spectator") continue;
|
|
2381
|
+
this.voceRoster.delete(playerId);
|
|
2382
|
+
cambiato = true;
|
|
2383
|
+
}
|
|
2384
|
+
if (cambiato) this.broadcastRoster();
|
|
2385
|
+
}
|
|
2386
|
+
rimuoviConnessione(connessione) {
|
|
2387
|
+
const playerId = this.connessioniGiocatori.get(connessione);
|
|
2388
|
+
this.connessioniGiocatori.delete(connessione);
|
|
2389
|
+
this.voceFrequenza.delete(connessione);
|
|
2390
|
+
this.voceUltimaRichiesta.delete(connessione);
|
|
2391
|
+
this.frameFrequenza.delete(connessione);
|
|
2392
|
+
if (playerId === void 0) return;
|
|
2393
|
+
if (this.giocatoriConnessioni.get(playerId) === connessione) {
|
|
2394
|
+
this.giocatoriConnessioni.delete(playerId);
|
|
2395
|
+
}
|
|
2396
|
+
if (this.voceRoster.get(playerId)?.connessione === connessione) {
|
|
2397
|
+
this.voceRoster.delete(playerId);
|
|
2398
|
+
this.broadcastRoster();
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
inviaErroreVoce(connessione, richiesta, code, message) {
|
|
2402
|
+
const op = typeof richiesta.op === "string" ? richiesta.op : "ice";
|
|
2403
|
+
const r = Number.isSafeInteger(richiesta.r) && richiesta.r >= 1 ? richiesta.r : 1;
|
|
2404
|
+
this.adattatore.invia(connessione, { t: "voice", op, r, error: { code, message } });
|
|
2405
|
+
}
|
|
2406
|
+
};
|
|
2407
|
+
async function createNodeRoom(definition, manifest, options = {}) {
|
|
2408
|
+
const storage = await ArchivioNode.apri(options.storageFile ?? null);
|
|
2409
|
+
const adattatore = new AdattatoreNode(storage);
|
|
2410
|
+
const nucleo = await NucleoStanza.apri(definition, manifest, adattatore);
|
|
2411
|
+
return new StanzaNode(nucleo, adattatore, manifest);
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
// src/dev.ts
|
|
2415
|
+
var DURATA_BIGLIETTO = 120;
|
|
2416
|
+
var DURATA_INGRESSO = 60;
|
|
2417
|
+
var CHIAVE_SAVE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
|
|
2418
|
+
var CHIAVE_BOARD = CHIAVE_SAVE;
|
|
2419
|
+
var FORMA_SESSIONE = /^[A-Za-z0-9_-]{8,128}$/;
|
|
2420
|
+
var FORMA_CODICE = /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/;
|
|
2421
|
+
var ALFABETO_CODICE = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
2422
|
+
var MASSIMO_CORPO = 262144;
|
|
2423
|
+
var DevHttpError = class extends Error {
|
|
2424
|
+
constructor(status, code, message, hints = []) {
|
|
2425
|
+
super(message);
|
|
2426
|
+
this.status = status;
|
|
2427
|
+
this.code = code;
|
|
2428
|
+
this.hints = hints;
|
|
2429
|
+
this.name = "DevHttpError";
|
|
2430
|
+
}
|
|
2431
|
+
status;
|
|
2432
|
+
code;
|
|
2433
|
+
hints;
|
|
2434
|
+
};
|
|
2435
|
+
function object(value) {
|
|
2436
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
2437
|
+
}
|
|
2438
|
+
function base64Url(value) {
|
|
2439
|
+
return Buffer.from(value).toString("base64url");
|
|
2440
|
+
}
|
|
2441
|
+
function signJwt(payload, secret) {
|
|
2442
|
+
const header = base64Url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
|
2443
|
+
const body = base64Url(JSON.stringify(payload));
|
|
2444
|
+
const signed = `${header}.${body}`;
|
|
2445
|
+
return `${signed}.${createHmac("sha256", secret).update(signed).digest("base64url")}`;
|
|
2446
|
+
}
|
|
2447
|
+
function verifyJwt(token, secret) {
|
|
2448
|
+
const parts = token.split(".");
|
|
2449
|
+
if (parts.length !== 3) return null;
|
|
2450
|
+
const [header, body, signature] = parts;
|
|
2451
|
+
if (header === void 0 || body === void 0 || signature === void 0) return null;
|
|
2452
|
+
const expected = createHmac("sha256", secret).update(`${header}.${body}`).digest();
|
|
2453
|
+
let actual;
|
|
2454
|
+
try {
|
|
2455
|
+
actual = Buffer.from(signature, "base64url");
|
|
2456
|
+
} catch {
|
|
2457
|
+
return null;
|
|
2458
|
+
}
|
|
2459
|
+
if (actual.byteLength !== expected.byteLength || !timingSafeEqual(actual, expected)) return null;
|
|
2460
|
+
try {
|
|
2461
|
+
const headerValue = object(JSON.parse(Buffer.from(header, "base64url").toString("utf8")));
|
|
2462
|
+
const payload = object(JSON.parse(Buffer.from(body, "base64url").toString("utf8")));
|
|
2463
|
+
if (headerValue?.alg !== "HS256" || headerValue.typ !== "JWT") return null;
|
|
2464
|
+
return payload;
|
|
2465
|
+
} catch {
|
|
2466
|
+
return null;
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
function currentSeconds() {
|
|
2470
|
+
return Math.floor(Date.now() / 1e3);
|
|
2471
|
+
}
|
|
2472
|
+
function serviceTicket(player, game, aud, secret) {
|
|
2473
|
+
const iat = currentSeconds();
|
|
2474
|
+
return signJwt({
|
|
2475
|
+
sub: player.id,
|
|
2476
|
+
game,
|
|
2477
|
+
name: player.name,
|
|
2478
|
+
guest: player.guest,
|
|
2479
|
+
aud,
|
|
2480
|
+
iat,
|
|
2481
|
+
exp: iat + DURATA_BIGLIETTO
|
|
2482
|
+
}, secret);
|
|
2483
|
+
}
|
|
2484
|
+
function joinTicket(player, room, secret) {
|
|
2485
|
+
const iat = currentSeconds();
|
|
2486
|
+
return signJwt({
|
|
2487
|
+
sub: player.id,
|
|
2488
|
+
name: player.name,
|
|
2489
|
+
guest: player.guest,
|
|
2490
|
+
room,
|
|
2491
|
+
aud: "room",
|
|
2492
|
+
iat,
|
|
2493
|
+
exp: iat + DURATA_INGRESSO
|
|
2494
|
+
}, secret);
|
|
2495
|
+
}
|
|
2496
|
+
function validTimes(payload, duration) {
|
|
2497
|
+
const now = currentSeconds();
|
|
2498
|
+
return typeof payload.iat === "number" && Number.isInteger(payload.iat) && typeof payload.exp === "number" && Number.isInteger(payload.exp) && payload.exp === payload.iat + duration && payload.iat <= now + 5 && payload.exp > now;
|
|
2499
|
+
}
|
|
2500
|
+
function readServiceTicket(request, game, aud, secret) {
|
|
2501
|
+
const found = request.headers.authorization?.match(/^Bearer ([^\s]+)$/i);
|
|
2502
|
+
const payload = found === null || found === void 0 ? null : verifyJwt(found[1] ?? "", secret);
|
|
2503
|
+
if (payload === null || payload.aud !== aud || payload.game !== game || typeof payload.sub !== "string" || payload.sub === "" || typeof payload.name !== "string" || payload.name === "" || typeof payload.guest !== "boolean" || !validTimes(payload, DURATA_BIGLIETTO)) {
|
|
2504
|
+
throw new DevHttpError(401, "invalid_ticket", "The game ticket is missing, expired, or invalid.", [
|
|
2505
|
+
"Ask the portal for a fresh ticket and retry."
|
|
2506
|
+
]);
|
|
2507
|
+
}
|
|
2508
|
+
return payload;
|
|
2509
|
+
}
|
|
2510
|
+
function readJoinTicket(token, room, secret) {
|
|
2511
|
+
const payload = verifyJwt(token, secret);
|
|
2512
|
+
if (payload === null || payload.aud !== "room" || 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;
|
|
2513
|
+
return payload;
|
|
2514
|
+
}
|
|
2515
|
+
function playerFromTicket(ticket) {
|
|
2516
|
+
return { id: ticket.sub, name: ticket.name, guest: ticket.guest };
|
|
2517
|
+
}
|
|
2518
|
+
function utcDay(now = Date.now()) {
|
|
2519
|
+
return new Date(now).toISOString().slice(0, 10);
|
|
2520
|
+
}
|
|
2521
|
+
function dailySeed(game, day) {
|
|
2522
|
+
return createHash2("sha256").update(`caisual:${game}:${day}`).digest().readUInt32BE(0);
|
|
2523
|
+
}
|
|
2524
|
+
function randomUniform(alphabet, length) {
|
|
2525
|
+
const limit = Math.floor(256 / alphabet.length) * alphabet.length;
|
|
2526
|
+
let result = "";
|
|
2527
|
+
while (result.length < length) {
|
|
2528
|
+
for (const byte of randomBytes(Math.max(16, length - result.length))) {
|
|
2529
|
+
if (byte >= limit) continue;
|
|
2530
|
+
result += alphabet[byte % alphabet.length];
|
|
2531
|
+
if (result.length === length) break;
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
return result;
|
|
2535
|
+
}
|
|
2536
|
+
function contentType(path) {
|
|
2537
|
+
const types = {
|
|
2538
|
+
".avif": "image/avif",
|
|
2539
|
+
".css": "text/css; charset=utf-8",
|
|
2540
|
+
".gif": "image/gif",
|
|
2541
|
+
".glb": "model/gltf-binary",
|
|
2542
|
+
".gltf": "model/gltf+json",
|
|
2543
|
+
".html": "text/html; charset=utf-8",
|
|
2544
|
+
".ico": "image/x-icon",
|
|
2545
|
+
".jpeg": "image/jpeg",
|
|
2546
|
+
".jpg": "image/jpeg",
|
|
2547
|
+
".js": "text/javascript; charset=utf-8",
|
|
2548
|
+
".json": "application/json; charset=utf-8",
|
|
2549
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
2550
|
+
".mp3": "audio/mpeg",
|
|
2551
|
+
".mp4": "video/mp4",
|
|
2552
|
+
".ogg": "audio/ogg",
|
|
2553
|
+
".opus": "audio/ogg",
|
|
2554
|
+
".png": "image/png",
|
|
2555
|
+
".svg": "image/svg+xml",
|
|
2556
|
+
".ttf": "font/ttf",
|
|
2557
|
+
".txt": "text/plain; charset=utf-8",
|
|
2558
|
+
".wasm": "application/wasm",
|
|
2559
|
+
".wav": "audio/wav",
|
|
2560
|
+
".webm": "video/webm",
|
|
2561
|
+
".webp": "image/webp",
|
|
2562
|
+
".woff": "font/woff",
|
|
2563
|
+
".woff2": "font/woff2"
|
|
2564
|
+
};
|
|
2565
|
+
return types[extname(path).toLowerCase()] ?? "application/octet-stream";
|
|
2566
|
+
}
|
|
2567
|
+
function injectAppMeta(html, portalOrigin2) {
|
|
2568
|
+
const meta = `<meta name="caisual-app" content="${portalOrigin2}">`;
|
|
2569
|
+
const head = /<head(?:\s[^>]*)?>/i;
|
|
2570
|
+
if (head.test(html)) return html.replace(head, (tag) => `${tag}
|
|
2571
|
+
${meta}`);
|
|
2572
|
+
return `${meta}
|
|
2573
|
+
${html}`;
|
|
2574
|
+
}
|
|
2575
|
+
function sendJson(response, value, status = 200, origin = null) {
|
|
2576
|
+
const body = JSON.stringify(value);
|
|
2577
|
+
response.statusCode = status;
|
|
2578
|
+
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
2579
|
+
response.setHeader("Content-Length", Buffer.byteLength(body));
|
|
2580
|
+
response.setHeader("Cache-Control", "private, no-store");
|
|
2581
|
+
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
2582
|
+
if (origin !== null) {
|
|
2583
|
+
response.setHeader("Access-Control-Allow-Origin", origin);
|
|
2584
|
+
response.setHeader("Access-Control-Expose-Headers", "Retry-After");
|
|
2585
|
+
response.setHeader("Vary", "Origin");
|
|
2586
|
+
}
|
|
2587
|
+
response.end(body);
|
|
2588
|
+
}
|
|
2589
|
+
function sendError(response, error, origin = null) {
|
|
2590
|
+
const known = error instanceof DevHttpError ? error : new DevHttpError(500, "internal_error", "The local game service failed.");
|
|
2591
|
+
if (known.code === "rate_limited") response.setHeader("Retry-After", "10");
|
|
2592
|
+
sendJson(response, { error: {
|
|
2593
|
+
code: known.code,
|
|
2594
|
+
message: known.message,
|
|
2595
|
+
hints: known.hints
|
|
2596
|
+
} }, known.status, origin);
|
|
2597
|
+
}
|
|
2598
|
+
function sendPreflight(response, origin) {
|
|
2599
|
+
response.statusCode = 204;
|
|
2600
|
+
response.setHeader("Cache-Control", "private, no-store");
|
|
2601
|
+
response.setHeader("Access-Control-Allow-Origin", origin);
|
|
2602
|
+
response.setHeader("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, OPTIONS");
|
|
2603
|
+
response.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
2604
|
+
response.setHeader("Access-Control-Max-Age", "86400");
|
|
2605
|
+
response.setHeader("Access-Control-Expose-Headers", "Retry-After");
|
|
2606
|
+
response.setHeader("Vary", "Origin");
|
|
2607
|
+
response.end();
|
|
2608
|
+
}
|
|
2609
|
+
async function readBody(request, maximum = MASSIMO_CORPO) {
|
|
2610
|
+
const declared = request.headers["content-length"];
|
|
2611
|
+
if (declared !== void 0 && /^\d+$/.test(declared) && Number(declared) > maximum) {
|
|
2612
|
+
throw new DevHttpError(413, "payload_too_large", `The request body cannot exceed ${maximum} bytes.`);
|
|
2613
|
+
}
|
|
2614
|
+
const chunks = [];
|
|
2615
|
+
let bytes = 0;
|
|
2616
|
+
for await (const chunk of request) {
|
|
2617
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
2618
|
+
bytes += buffer.byteLength;
|
|
2619
|
+
if (bytes > maximum) {
|
|
2620
|
+
throw new DevHttpError(413, "payload_too_large", `The request body cannot exceed ${maximum} bytes.`);
|
|
2621
|
+
}
|
|
2622
|
+
chunks.push(buffer);
|
|
2623
|
+
}
|
|
2624
|
+
try {
|
|
2625
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
2626
|
+
} catch {
|
|
2627
|
+
throw new DevHttpError(400, "invalid_request", "The request body must be valid JSON.");
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
function parentPage(input) {
|
|
2631
|
+
return `<!doctype html>
|
|
2632
|
+
<html lang="en">
|
|
2633
|
+
<head>
|
|
2634
|
+
<meta charset="utf-8">
|
|
2635
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2636
|
+
<title>Local preview: ${input.slug}</title>
|
|
2637
|
+
<style>
|
|
2638
|
+
html, body, iframe { width: 100%; height: 100%; margin: 0; border: 0; }
|
|
2639
|
+
body { background: #111; }
|
|
2640
|
+
</style>
|
|
2641
|
+
</head>
|
|
2642
|
+
<body>
|
|
2643
|
+
<iframe id="game" title="${input.slug}" src="${input.gameOrigin}/" allow="${input.allow}"></iframe>
|
|
2644
|
+
<script type="module">
|
|
2645
|
+
const gameOrigin = ${JSON.stringify(input.gameOrigin)};
|
|
2646
|
+
const portalOrigin = ${JSON.stringify(input.portalOrigin)};
|
|
2647
|
+
const frame = document.getElementById('game');
|
|
2648
|
+
const storageKey = 'caisual:dev:player';
|
|
2649
|
+
let sessionId = sessionStorage.getItem(storageKey);
|
|
2650
|
+
if (!sessionId) {
|
|
2651
|
+
sessionId = crypto.randomUUID();
|
|
2652
|
+
sessionStorage.setItem(storageKey, sessionId);
|
|
2653
|
+
}
|
|
2654
|
+
const getSession = async () => {
|
|
2655
|
+
const response = await fetch('/__caisual/session?id=' + encodeURIComponent(sessionId));
|
|
2656
|
+
if (!response.ok) throw new Error('Local player session is unavailable.');
|
|
2657
|
+
return response.json();
|
|
2658
|
+
};
|
|
2659
|
+
let session = await getSession();
|
|
2660
|
+
const rawInvite = new URL(location.href).searchParams.get('invite');
|
|
2661
|
+
const normalizedInvite = rawInvite?.toUpperCase().replace(/[\\s-]/g, '') ?? null;
|
|
2662
|
+
const invite = normalizedInvite && /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/.test(normalizedInvite)
|
|
2663
|
+
? normalizedInvite
|
|
2664
|
+
: null;
|
|
2665
|
+
let ready = false;
|
|
2666
|
+
let timer = null;
|
|
2667
|
+
const askReady = () => {
|
|
2668
|
+
if (!ready) frame.contentWindow?.postMessage({ type: 'caisual:ready?' }, gameOrigin);
|
|
2669
|
+
};
|
|
2670
|
+
const listen = (event) => {
|
|
2671
|
+
if (event.source !== frame.contentWindow || event.origin !== gameOrigin) return;
|
|
2672
|
+
if (event.data?.type !== 'caisual:ready' || ready) return;
|
|
2673
|
+
ready = true;
|
|
2674
|
+
if (timer !== null) clearInterval(timer);
|
|
2675
|
+
const channel = new MessageChannel();
|
|
2676
|
+
channel.port1.onmessage = async (message) => {
|
|
2677
|
+
if (message.data?.type !== 'caisual:ticket') return;
|
|
2678
|
+
const aud = message.data.aud === 'live' ? 'live' : 'portal';
|
|
2679
|
+
session = await getSession();
|
|
2680
|
+
channel.port1.postMessage({ type: 'caisual:ticket', aud, ticket: session[aud] });
|
|
2681
|
+
};
|
|
2682
|
+
channel.port1.start();
|
|
2683
|
+
frame.contentWindow?.postMessage({
|
|
2684
|
+
type: 'caisual:hello',
|
|
2685
|
+
ticket: session.portal,
|
|
2686
|
+
live: portalOrigin,
|
|
2687
|
+
invite,
|
|
2688
|
+
}, gameOrigin, [channel.port2]);
|
|
2689
|
+
};
|
|
2690
|
+
addEventListener('message', listen);
|
|
2691
|
+
timer = setInterval(askReady, 500);
|
|
2692
|
+
setTimeout(() => { if (timer !== null) clearInterval(timer); }, 10_000);
|
|
2693
|
+
askReady();
|
|
2694
|
+
</script>
|
|
2695
|
+
</body>
|
|
2696
|
+
</html>
|
|
2697
|
+
`;
|
|
2698
|
+
}
|
|
2699
|
+
async function readGame(root) {
|
|
2700
|
+
const manifestPath = join(root, "caisual.json");
|
|
2701
|
+
let parsed;
|
|
2702
|
+
try {
|
|
2703
|
+
parsed = JSON.parse(await fs.readFile(manifestPath, "utf8"));
|
|
2704
|
+
} catch {
|
|
2705
|
+
throw new Error("caisual.json: file not found, unreadable, or invalid JSON.");
|
|
2706
|
+
}
|
|
2707
|
+
const result = validaManifest(parsed);
|
|
2708
|
+
if (!result.ok) {
|
|
2709
|
+
throw new Error(`caisual.json is not valid:
|
|
2710
|
+
${result.errori.map((error) => `- ${error}`).join("\n")}`);
|
|
2711
|
+
}
|
|
2712
|
+
const clientRoot = await fs.realpath(join(root, "client")).catch(() => null);
|
|
2713
|
+
if (clientRoot === null) throw new Error("client/: folder not found.");
|
|
2714
|
+
const stat = await fs.stat(clientRoot);
|
|
2715
|
+
if (!stat.isDirectory()) throw new Error("client/: must be a folder.");
|
|
2716
|
+
const index = await fs.stat(join(clientRoot, "index.html")).catch(() => null);
|
|
2717
|
+
if (index === null || !index.isFile()) throw new Error("client/index.html: file not found.");
|
|
2718
|
+
return { manifest: result.manifest, clientRoot };
|
|
2719
|
+
}
|
|
2720
|
+
async function loadDefinition(root) {
|
|
2721
|
+
const path = join(root, "server.js");
|
|
2722
|
+
let source;
|
|
2723
|
+
try {
|
|
2724
|
+
source = await fs.readFile(path, "utf8");
|
|
2725
|
+
} catch (cause) {
|
|
2726
|
+
if (cause.code === "ENOENT") return null;
|
|
2727
|
+
throw new Error("server.js: file not readable.");
|
|
2728
|
+
}
|
|
2729
|
+
const result = validaServerJs(source);
|
|
2730
|
+
if (!result.ok) {
|
|
2731
|
+
throw new Error(`server.js is not valid:
|
|
2732
|
+
${result.errori.map((error) => `- ${error}`).join("\n")}`);
|
|
2733
|
+
}
|
|
2734
|
+
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")}`;
|
|
2735
|
+
const rewritten = source.replace(
|
|
2736
|
+
/(\bfrom\s*)(['"])@caisual\/kit\/server\2/g,
|
|
2737
|
+
(_match, prefix) => `${prefix}${JSON.stringify(kitUrl)}`
|
|
2738
|
+
);
|
|
2739
|
+
const sourceUrl = `data:text/javascript;base64,${Buffer.from(rewritten).toString("base64")}`;
|
|
2740
|
+
const loaded = await import(sourceUrl);
|
|
2741
|
+
if (loaded.default === void 0) throw new Error("server.js must have an export default.");
|
|
2742
|
+
return loaded.default;
|
|
2743
|
+
}
|
|
2744
|
+
var DevService = class {
|
|
2745
|
+
constructor(root, clientRoot, manifest, definition, port) {
|
|
2746
|
+
this.root = root;
|
|
2747
|
+
this.clientRoot = clientRoot;
|
|
2748
|
+
this.manifest = manifest;
|
|
2749
|
+
this.definition = definition;
|
|
2750
|
+
this.port = port;
|
|
2751
|
+
}
|
|
2752
|
+
root;
|
|
2753
|
+
clientRoot;
|
|
2754
|
+
manifest;
|
|
2755
|
+
definition;
|
|
2756
|
+
port;
|
|
2757
|
+
secret = randomBytes(32);
|
|
2758
|
+
playersBySession = /* @__PURE__ */ new Map();
|
|
2759
|
+
playersById = /* @__PURE__ */ new Map();
|
|
2760
|
+
saves = /* @__PURE__ */ new Map();
|
|
2761
|
+
scores = /* @__PURE__ */ new Map();
|
|
2762
|
+
rooms = /* @__PURE__ */ new Map();
|
|
2763
|
+
roomByCode = /* @__PURE__ */ new Map();
|
|
2764
|
+
kitRequests = /* @__PURE__ */ new Map();
|
|
2765
|
+
liveRequests = /* @__PURE__ */ new Map();
|
|
2766
|
+
playerNumber = 0;
|
|
2767
|
+
get portalOrigin() {
|
|
2768
|
+
return `http://localhost:${this.port}`;
|
|
2769
|
+
}
|
|
2770
|
+
get gameOrigin() {
|
|
2771
|
+
return `http://${this.manifest.id}.localhost:${this.port}`;
|
|
2772
|
+
}
|
|
2773
|
+
async handle(request, response) {
|
|
2774
|
+
const url = new URL(request.url ?? "/", this.portalOrigin);
|
|
2775
|
+
const hostname = (request.headers.host ?? "").split(":")[0]?.toLowerCase();
|
|
2776
|
+
if (hostname === `${this.manifest.id}.localhost`) {
|
|
2777
|
+
await this.handleGame(request, response, url);
|
|
2778
|
+
return;
|
|
2779
|
+
}
|
|
2780
|
+
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
|
|
2781
|
+
await this.handlePortal(request, response, url);
|
|
2782
|
+
return;
|
|
2783
|
+
}
|
|
2784
|
+
sendError(response, new DevHttpError(404, "not_found", "The local game page was not found."));
|
|
2785
|
+
}
|
|
2786
|
+
async handleUpgrade(request, socket, head) {
|
|
2787
|
+
const url = new URL(request.url ?? "/", this.portalOrigin);
|
|
2788
|
+
const match = /^\/rooms\/(g1-1\.[a-z0-9]{16})$/.exec(url.pathname);
|
|
2789
|
+
if (match === null || match[1] === void 0) {
|
|
2790
|
+
this.rejectUpgrade(socket, 404, "room_not_found", "The room was not found.");
|
|
2791
|
+
return;
|
|
2792
|
+
}
|
|
2793
|
+
const localRoom = this.rooms.get(match[1]);
|
|
2794
|
+
if (localRoom === void 0) {
|
|
2795
|
+
this.rejectUpgrade(socket, 404, "room_not_found", "The room was not found.");
|
|
2796
|
+
return;
|
|
2797
|
+
}
|
|
2798
|
+
const token = url.searchParams.get("j");
|
|
2799
|
+
const joined = token === null ? null : readJoinTicket(token, match[1], this.secret);
|
|
2800
|
+
const origin = request.headers.origin;
|
|
2801
|
+
const nodeClient = origin === void 0 && request.headers["user-agent"] === "node";
|
|
2802
|
+
if (joined === null || origin !== this.gameOrigin && !nodeClient) {
|
|
2803
|
+
this.rejectUpgrade(socket, 401, "unauthorized", "The room connection is not authorized.");
|
|
2804
|
+
return;
|
|
2805
|
+
}
|
|
2806
|
+
const identity = playerFromTicket(joined);
|
|
2807
|
+
const permission = await localRoom.room.canJoin(identity);
|
|
2808
|
+
if (!permission.ok) {
|
|
2809
|
+
const status = permission.code === "room_not_found" ? 404 : 409;
|
|
2810
|
+
this.rejectUpgrade(socket, status, permission.code, this.roomErrorMessage(permission.code));
|
|
2811
|
+
return;
|
|
2812
|
+
}
|
|
2813
|
+
try {
|
|
2814
|
+
const websocket = acceptNodeWebSocket2(request, socket, head);
|
|
2815
|
+
await localRoom.room.connect(websocket, identity);
|
|
2816
|
+
} catch {
|
|
2817
|
+
if (!socket.destroyed) this.rejectUpgrade(socket, 400, "invalid_request", "The WebSocket request is invalid.");
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
async close() {
|
|
2821
|
+
await Promise.all([...this.rooms.values()].map((entry) => entry.room.close()));
|
|
2822
|
+
}
|
|
2823
|
+
async handleGame(request, response, url) {
|
|
2824
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
2825
|
+
sendError(response, new DevHttpError(404, "not_found", "The game file was not found."));
|
|
2826
|
+
return;
|
|
2827
|
+
}
|
|
2828
|
+
if (url.pathname === "/__caisual/kit/v1.js") {
|
|
2829
|
+
response.statusCode = 200;
|
|
2830
|
+
response.setHeader("Content-Type", "text/javascript; charset=utf-8");
|
|
2831
|
+
response.setHeader("Cache-Control", "no-store");
|
|
2832
|
+
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
2833
|
+
response.end(request.method === "HEAD" ? void 0 : '// Caisual game kit v0.3.0\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.mic = null;\n this.audioContext = null;\n this.analyser = null;\n this.peerSfu = null;\n this.sessioneSfu = null;\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.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 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() {\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 this.verificaIngresso();\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.mic === null) {\n throw creaErrore("not_publishing", "Join voice before changing mute.");\n }\n this.mutedCorrente = muted;\n this.mic.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 this.roster = message.peers.map((peer) => ({ ...peer }));\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 verificaIngresso() {\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") {\n throw creaErrore("spectator", "Spectators cannot join 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 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.mic = mic;\n mic.enabled = !this.mutedCorrente;\n this.preparaAnalizzatore(stream);\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" });\n }\n if (this.mutedCorrente) await this.richiedi({ t: "voice", op: "mute", muted: true });\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 mid2 = event.transceiver.mid;\n const playerId = mid2 === null ? void 0 : this.midGiocatori.get(mid2);\n if (playerId !== void 0) this.collegaTraccia(playerId, event.track, event.receiver);\n };\n this.osservaCaduta(pc);\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 const risposta = await this.richiedi({ t: "voice", op: "session", sdp, mid });\n if (risposta.op !== "session") {\n throw creaErrore("voice_error", "The voice service returned an invalid response.");\n }\n this.sessioneSfu = risposta.session;\n await pc.setRemoteDescription({ type: "answer", sdp: risposta.sdp });\n await this.attendiConnessione(pc, generazione);\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.peerDesiderati().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 }\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.id);\n }\n }\n creaMesh(playerId) {\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 pc.addTrack(this.richiediMic(), this.richiediStream());\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 if (!this.peerDesiderati().some((peer) => peer.id === from)) return;\n if (!this.mesh.has(from)) this.creaMesh(from);\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.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 const zeroAt = this.zeroDa.get(peer.id);\n return zeroAt === void 0 || this.richiediDipendenze().ora() - zeroAt < DURATA_ZERO;\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 muted: peer.muted,\n speaking: !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.mic = null;\n this.analyser = null;\n void this.audioContext?.close().catch(() => void 0);\n this.audioContext = null;\n this.sessioneSfu = null;\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.mic === null) throw creaErrore("voice_error", "The microphone is not ready.");\n return this.mic;\n }\n richiediStream() {\n if (this.stream === null) throw creaErrore("voice_error", "The microphone is not ready.");\n return this.stream;\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 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 function 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 }\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 return {\n create: (mode) => ingresso("/rooms", { mode }),\n joinCode: (code) => ingresso("/rooms/join", { code }),\n joinRoom: (roomId) => ingresso("/rooms/join", { roomId }, true),\n flush: (roomId) => richiesta(\n `/rooms/${encodeURIComponent(roomId)}/flush`,\n "POST"\n )\n };\n}\nvar StanzaClient = class {\n constructor(roomId, codice, url, input, api) {\n this.roomId = roomId;\n this.codice = codice;\n this.input = input;\n this.api = api;\n this.statoPubblico = null;\n this.statoSincronizzato = null;\n this.tickCorrente = 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 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 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.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.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 this.codice = ingresso.code;\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.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 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 };\n}\nfunction creaGestoreStanze(input, invited) {\n const api = creaApiLive(input);\n const collega = async (ingresso) => {\n const stanza = new StanzaClient(ingresso.roomId, ingresso.code, ingresso.url, input, api);\n await stanza.pronta();\n return stanza;\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 };\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 };\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 }, 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 creaKit(input = dipendenzeReali2()) {\n let promessa = null;\n return {\n connect() {\n promessa ?? (promessa = connetti(input));\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');
|
|
2834
|
+
return;
|
|
2835
|
+
}
|
|
2836
|
+
let decoded;
|
|
2837
|
+
try {
|
|
2838
|
+
decoded = decodeURIComponent(url.pathname);
|
|
2839
|
+
} catch {
|
|
2840
|
+
sendError(response, new DevHttpError(404, "not_found", "The game file was not found."));
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
const relativePath = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, "");
|
|
2844
|
+
const candidate = resolve(this.clientRoot, relativePath);
|
|
2845
|
+
if (relative(this.clientRoot, candidate).startsWith(`..${sep}`) || candidate === this.clientRoot) {
|
|
2846
|
+
sendError(response, new DevHttpError(404, "not_found", "The game file was not found."));
|
|
2847
|
+
return;
|
|
2848
|
+
}
|
|
2849
|
+
const real = await fs.realpath(candidate).catch(() => null);
|
|
2850
|
+
if (real === null || real !== this.clientRoot && !real.startsWith(`${this.clientRoot}${sep}`)) {
|
|
2851
|
+
sendError(response, new DevHttpError(404, "not_found", "The game file was not found."));
|
|
2852
|
+
return;
|
|
2853
|
+
}
|
|
2854
|
+
const stat = await fs.stat(real);
|
|
2855
|
+
if (!stat.isFile()) {
|
|
2856
|
+
sendError(response, new DevHttpError(404, "not_found", "The game file was not found."));
|
|
2857
|
+
return;
|
|
2858
|
+
}
|
|
2859
|
+
const html = extname(real).toLowerCase() === ".html";
|
|
2860
|
+
const body = html ? Buffer.from(injectAppMeta(await fs.readFile(real, "utf8"), this.portalOrigin)) : await fs.readFile(real);
|
|
2861
|
+
response.statusCode = 200;
|
|
2862
|
+
response.setHeader("Content-Type", contentType(real));
|
|
2863
|
+
response.setHeader("Content-Length", body.byteLength);
|
|
2864
|
+
response.setHeader("Cache-Control", "no-store");
|
|
2865
|
+
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
2866
|
+
response.end(request.method === "HEAD" ? void 0 : body);
|
|
2867
|
+
}
|
|
2868
|
+
async handlePortal(request, response, url) {
|
|
2869
|
+
if (url.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) {
|
|
2870
|
+
const voceAttiva = this.manifest.voice !== "none";
|
|
2871
|
+
const body = parentPage({
|
|
2872
|
+
allow: [
|
|
2873
|
+
"fullscreen",
|
|
2874
|
+
"autoplay",
|
|
2875
|
+
"pointer-lock",
|
|
2876
|
+
...this.manifest.input.includes("gamepad") ? ["gamepad"] : [],
|
|
2877
|
+
...voceAttiva ? ["microphone"] : [],
|
|
2878
|
+
...this.manifest.isolated ? ["cross-origin-isolated"] : []
|
|
2879
|
+
].join("; "),
|
|
2880
|
+
gameOrigin: this.gameOrigin,
|
|
2881
|
+
portalOrigin: this.portalOrigin,
|
|
2882
|
+
slug: this.manifest.id
|
|
2883
|
+
});
|
|
2884
|
+
response.statusCode = 200;
|
|
2885
|
+
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
2886
|
+
response.setHeader("Cache-Control", "no-store");
|
|
2887
|
+
response.setHeader(
|
|
2888
|
+
"Permissions-Policy",
|
|
2889
|
+
voceAttiva ? `microphone=(self "${this.gameOrigin}")` : "microphone=()"
|
|
2890
|
+
);
|
|
2891
|
+
response.end(request.method === "HEAD" ? void 0 : body);
|
|
2892
|
+
return;
|
|
2893
|
+
}
|
|
2894
|
+
const invitation = /^\/r\/([ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6})$/.exec(url.pathname);
|
|
2895
|
+
if (invitation !== null && invitation[1] !== void 0 && request.method === "GET") {
|
|
2896
|
+
response.statusCode = 302;
|
|
2897
|
+
response.setHeader("Location", `/?invite=${invitation[1]}`);
|
|
2898
|
+
response.setHeader("Cache-Control", "no-store");
|
|
2899
|
+
response.end();
|
|
2900
|
+
return;
|
|
2901
|
+
}
|
|
2902
|
+
if (url.pathname === "/__caisual/session" && request.method === "GET") {
|
|
2903
|
+
this.handleSession(response, url);
|
|
2904
|
+
return;
|
|
2905
|
+
}
|
|
2906
|
+
if (url.pathname.startsWith("/api/kit/")) {
|
|
2907
|
+
await this.handleKit(request, response, url);
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2910
|
+
if (url.pathname === "/rooms" || url.pathname === "/rooms/join" || /^\/rooms\/[^/]+(?:\/flush)?$/.test(url.pathname)) {
|
|
2911
|
+
await this.handleLive(request, response, url);
|
|
2912
|
+
return;
|
|
2913
|
+
}
|
|
2914
|
+
sendError(response, new DevHttpError(404, "not_found", "The local endpoint was not found."));
|
|
2915
|
+
}
|
|
2916
|
+
handleSession(response, url) {
|
|
2917
|
+
const sessionId = url.searchParams.get("id");
|
|
2918
|
+
if (sessionId === null || !FORMA_SESSIONE.test(sessionId)) {
|
|
2919
|
+
sendError(response, new DevHttpError(400, "invalid_request", "The local player session is invalid."));
|
|
2920
|
+
return;
|
|
2921
|
+
}
|
|
2922
|
+
let player = this.playersBySession.get(sessionId);
|
|
2923
|
+
if (player === void 0) {
|
|
2924
|
+
this.playerNumber += 1;
|
|
2925
|
+
player = {
|
|
2926
|
+
sessionId,
|
|
2927
|
+
id: `dev_${createHash2("sha256").update(sessionId).digest("hex").slice(0, 24)}`,
|
|
2928
|
+
name: `Guest ${this.playerNumber}`,
|
|
2929
|
+
guest: true
|
|
2930
|
+
};
|
|
2931
|
+
this.playersBySession.set(sessionId, player);
|
|
2932
|
+
this.playersById.set(player.id, player);
|
|
2933
|
+
}
|
|
2934
|
+
sendJson(response, {
|
|
2935
|
+
player: playerFromTicket({
|
|
2936
|
+
sub: player.id,
|
|
2937
|
+
name: player.name,
|
|
2938
|
+
guest: player.guest,
|
|
2939
|
+
room: "",
|
|
2940
|
+
aud: "room",
|
|
2941
|
+
iat: 0,
|
|
2942
|
+
exp: 0
|
|
2943
|
+
}),
|
|
2944
|
+
portal: serviceTicket(player, this.manifest.id, "portal", this.secret),
|
|
2945
|
+
live: serviceTicket(player, this.manifest.id, "live", this.secret)
|
|
2946
|
+
});
|
|
2947
|
+
}
|
|
2948
|
+
validOrigin(request) {
|
|
2949
|
+
if (request.headers.origin !== this.gameOrigin) {
|
|
2950
|
+
throw new DevHttpError(403, "forbidden_origin", "This origin is not allowed to use the game API.");
|
|
2951
|
+
}
|
|
2952
|
+
return this.gameOrigin;
|
|
2953
|
+
}
|
|
2954
|
+
async handleKit(request, response, url) {
|
|
2955
|
+
let origin = null;
|
|
2956
|
+
try {
|
|
2957
|
+
origin = this.validOrigin(request);
|
|
2958
|
+
if (request.method === "OPTIONS") {
|
|
2959
|
+
sendPreflight(response, origin);
|
|
2960
|
+
return;
|
|
2961
|
+
}
|
|
2962
|
+
const ticket = readServiceTicket(request, this.manifest.id, "portal", this.secret);
|
|
2963
|
+
this.checkRate(this.kitRequests, ticket.sub);
|
|
2964
|
+
if (url.pathname === "/api/kit/me" && request.method === "GET") {
|
|
2965
|
+
const day = utcDay();
|
|
2966
|
+
sendJson(response, {
|
|
2967
|
+
player: playerFromTicket(ticket),
|
|
2968
|
+
game: { slug: this.manifest.id },
|
|
2969
|
+
day,
|
|
2970
|
+
seed: dailySeed(this.manifest.id, day),
|
|
2971
|
+
serverTime: Date.now()
|
|
2972
|
+
}, 200, origin);
|
|
2973
|
+
return;
|
|
2974
|
+
}
|
|
2975
|
+
if (url.pathname === "/api/kit/saves" && request.method === "GET") {
|
|
2976
|
+
const saves = [...this.playerSaves(ticket).entries()].map(([key, record2]) => ({
|
|
2977
|
+
key,
|
|
2978
|
+
bytes: record2.bytes,
|
|
2979
|
+
updatedAt: record2.updatedAt
|
|
2980
|
+
})).sort((left, right) => left.key.localeCompare(right.key));
|
|
2981
|
+
sendJson(response, { saves }, 200, origin);
|
|
2982
|
+
return;
|
|
2983
|
+
}
|
|
2984
|
+
const saveMatch = /^\/api\/kit\/saves\/([^/]+)$/.exec(url.pathname);
|
|
2985
|
+
if (saveMatch !== null && saveMatch[1] !== void 0) {
|
|
2986
|
+
await this.handleSave(request, response, decodeURIComponent(saveMatch[1]), ticket, origin);
|
|
2987
|
+
return;
|
|
2988
|
+
}
|
|
2989
|
+
if (url.pathname === "/api/kit/scores" && request.method === "POST") {
|
|
2990
|
+
await this.submitScore(request, response, ticket, origin);
|
|
2991
|
+
return;
|
|
2992
|
+
}
|
|
2993
|
+
const scoreMatch = /^\/api\/kit\/scores\/([^/]+)$/.exec(url.pathname);
|
|
2994
|
+
if (scoreMatch !== null && scoreMatch[1] !== void 0 && request.method === "GET") {
|
|
2995
|
+
this.topScores(response, decodeURIComponent(scoreMatch[1]), url, ticket, origin);
|
|
2996
|
+
return;
|
|
2997
|
+
}
|
|
2998
|
+
throw new DevHttpError(404, "not_found", "The game API endpoint was not found.");
|
|
2999
|
+
} catch (cause) {
|
|
3000
|
+
sendError(response, cause, origin);
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
playerSaves(ticket) {
|
|
3004
|
+
const id = `${ticket.game}\0${ticket.sub}`;
|
|
3005
|
+
let records = this.saves.get(id);
|
|
3006
|
+
if (records === void 0) {
|
|
3007
|
+
records = /* @__PURE__ */ new Map();
|
|
3008
|
+
this.saves.set(id, records);
|
|
3009
|
+
}
|
|
3010
|
+
return records;
|
|
236
3011
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
let valido = true;
|
|
255
|
-
if (typeof idRuolo !== "string" || idRuolo.length > 32 || !ID_INTERNO.test(idRuolo)) {
|
|
256
|
-
errori.push(`roles[${indice}].id: must be 1-32 lowercase letters, digits, or internal hyphens.`);
|
|
257
|
-
valido = false;
|
|
258
|
-
} else if (ids.has(idRuolo)) {
|
|
259
|
-
errori.push(`roles[${indice}].id: duplicate role ${idRuolo}.`);
|
|
260
|
-
valido = false;
|
|
261
|
-
} else ids.add(idRuolo);
|
|
262
|
-
if (!interoTra(min, 0, 16)) {
|
|
263
|
-
errori.push(`roles[${indice}].min: must be an integer from 0 to 16.`);
|
|
264
|
-
valido = false;
|
|
265
|
-
}
|
|
266
|
-
if (max !== void 0 && !interoTra(max, 0, 16)) {
|
|
267
|
-
errori.push(`roles[${indice}].max: must be an integer from 0 to 16 when present.`);
|
|
268
|
-
valido = false;
|
|
269
|
-
}
|
|
270
|
-
if (typeof min === "number" && typeof max === "number" && min > max) {
|
|
271
|
-
errori.push(`roles[${indice}].max: must be greater than or equal to min.`);
|
|
272
|
-
valido = false;
|
|
273
|
-
}
|
|
274
|
-
if (valido) roles.push(max === void 0 ? { id: idRuolo, min } : { id: idRuolo, min, max });
|
|
275
|
-
}
|
|
3012
|
+
async handleSave(request, response, key, ticket, origin) {
|
|
3013
|
+
if (!CHIAVE_SAVE.test(key)) {
|
|
3014
|
+
throw new DevHttpError(400, "invalid_request", "The save key is not valid.", [
|
|
3015
|
+
"Use 1-32 lowercase letters, digits, underscores, or hyphens."
|
|
3016
|
+
]);
|
|
3017
|
+
}
|
|
3018
|
+
const saves = this.playerSaves(ticket);
|
|
3019
|
+
if (request.method === "GET") {
|
|
3020
|
+
const record2 = saves.get(key);
|
|
3021
|
+
if (record2 === void 0) throw new DevHttpError(404, "not_found", "The save was not found.");
|
|
3022
|
+
sendJson(response, { key, value: record2.value, updatedAt: record2.updatedAt }, 200, origin);
|
|
3023
|
+
return;
|
|
3024
|
+
}
|
|
3025
|
+
if (request.method === "DELETE") {
|
|
3026
|
+
saves.delete(key);
|
|
3027
|
+
sendJson(response, { deleted: true }, 200, origin);
|
|
3028
|
+
return;
|
|
276
3029
|
}
|
|
3030
|
+
if (request.method !== "PUT") {
|
|
3031
|
+
throw new DevHttpError(400, "invalid_request", "This method is not supported for this endpoint.");
|
|
3032
|
+
}
|
|
3033
|
+
const body = object(await readBody(request));
|
|
3034
|
+
if (body === null || !Object.hasOwn(body, "value")) {
|
|
3035
|
+
throw new DevHttpError(400, "invalid_request", "The request body must contain value.");
|
|
3036
|
+
}
|
|
3037
|
+
if (!saves.has(key) && saves.size >= 32) {
|
|
3038
|
+
throw new DevHttpError(409, "save_limit", "This player already has 32 saves for this game.", [
|
|
3039
|
+
"Remove an existing save before creating a new key."
|
|
3040
|
+
]);
|
|
3041
|
+
}
|
|
3042
|
+
let serialized;
|
|
3043
|
+
try {
|
|
3044
|
+
serialized = JSON.stringify(body.value);
|
|
3045
|
+
} catch {
|
|
3046
|
+
throw new DevHttpError(400, "invalid_request", "The save value must be valid JSON.");
|
|
3047
|
+
}
|
|
3048
|
+
if (serialized === void 0) {
|
|
3049
|
+
throw new DevHttpError(400, "invalid_request", "The save value must be valid JSON.");
|
|
3050
|
+
}
|
|
3051
|
+
const updatedAt = Date.now();
|
|
3052
|
+
const bytes = Buffer.byteLength(serialized);
|
|
3053
|
+
saves.set(key, { value: structuredClone(body.value), bytes, updatedAt });
|
|
3054
|
+
sendJson(response, { key, bytes, updatedAt }, 200, origin);
|
|
277
3055
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
3056
|
+
scoreKey(playerId, game, board, day) {
|
|
3057
|
+
return `${playerId}\0${game}\0${board}\0${day ?? ""}`;
|
|
3058
|
+
}
|
|
3059
|
+
putScore(input, now = Date.now()) {
|
|
3060
|
+
const key = this.scoreKey(input.playerId, input.game, input.board, input.day);
|
|
3061
|
+
const existing = this.scores.get(key);
|
|
3062
|
+
if (existing !== void 0 && existing.score >= input.score) return existing;
|
|
3063
|
+
const record2 = { ...input, createdAt: now };
|
|
3064
|
+
this.scores.set(key, record2);
|
|
3065
|
+
return record2;
|
|
3066
|
+
}
|
|
3067
|
+
scoreRank(record2) {
|
|
3068
|
+
return 1 + [...this.scores.values()].filter(
|
|
3069
|
+
(other) => other.game === record2.game && other.board === record2.board && other.day === record2.day && other.guest === record2.guest && other.score > record2.score
|
|
3070
|
+
).length;
|
|
3071
|
+
}
|
|
3072
|
+
async submitScore(request, response, ticket, origin) {
|
|
3073
|
+
const body = object(await readBody(request));
|
|
3074
|
+
if (body === null || typeof body.board !== "string" || !CHIAVE_BOARD.test(body.board)) {
|
|
3075
|
+
throw new DevHttpError(400, "invalid_request", "The board name is not valid.");
|
|
3076
|
+
}
|
|
3077
|
+
if (typeof body.score !== "number" || !Number.isSafeInteger(body.score) || body.score < 0) {
|
|
3078
|
+
throw new DevHttpError(400, "invalid_request", "score must be a non-negative safe integer.");
|
|
292
3079
|
}
|
|
3080
|
+
if (typeof body.daily !== "boolean") {
|
|
3081
|
+
throw new DevHttpError(400, "invalid_request", "daily must be true or false.");
|
|
3082
|
+
}
|
|
3083
|
+
const record2 = this.putScore({
|
|
3084
|
+
playerId: ticket.sub,
|
|
3085
|
+
name: ticket.name,
|
|
3086
|
+
guest: ticket.guest,
|
|
3087
|
+
game: ticket.game,
|
|
3088
|
+
board: body.board,
|
|
3089
|
+
day: body.daily ? utcDay() : null,
|
|
3090
|
+
score: body.score,
|
|
3091
|
+
verified: false
|
|
3092
|
+
});
|
|
3093
|
+
sendJson(response, {
|
|
3094
|
+
board: record2.board,
|
|
3095
|
+
day: record2.day,
|
|
3096
|
+
best: record2.score,
|
|
3097
|
+
rank: this.scoreRank(record2),
|
|
3098
|
+
verified: false
|
|
3099
|
+
}, 200, origin);
|
|
293
3100
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
3101
|
+
topScores(response, board, url, ticket, origin) {
|
|
3102
|
+
if (!CHIAVE_BOARD.test(board)) {
|
|
3103
|
+
throw new DevHttpError(400, "invalid_request", "The board name is not valid.");
|
|
3104
|
+
}
|
|
3105
|
+
const dailyValue = url.searchParams.get("daily");
|
|
3106
|
+
const guestsValue = url.searchParams.get("guests");
|
|
3107
|
+
if (dailyValue !== null && dailyValue !== "1") {
|
|
3108
|
+
throw new DevHttpError(400, "invalid_request", "daily must be 1 when present.");
|
|
3109
|
+
}
|
|
3110
|
+
if (guestsValue !== null && guestsValue !== "1") {
|
|
3111
|
+
throw new DevHttpError(400, "invalid_request", "guests must be 1 when present.");
|
|
3112
|
+
}
|
|
3113
|
+
const limitRaw = url.searchParams.get("limit") ?? "10";
|
|
3114
|
+
if (!/^\d+$/.test(limitRaw) || Number(limitRaw) < 1 || Number(limitRaw) > 100) {
|
|
3115
|
+
throw new DevHttpError(400, "invalid_request", "limit must be an integer from 1 to 100.");
|
|
3116
|
+
}
|
|
3117
|
+
const day = dailyValue === "1" ? utcDay() : null;
|
|
3118
|
+
const guests = guestsValue === "1";
|
|
3119
|
+
const category = [...this.scores.values()].filter(
|
|
3120
|
+
(record2) => record2.game === ticket.game && record2.board === board && record2.day === day && record2.guest === guests
|
|
3121
|
+
).sort((left, right) => right.score - left.score || left.createdAt - right.createdAt);
|
|
3122
|
+
const entries = category.slice(0, Number(limitRaw)).map((record2) => ({
|
|
3123
|
+
rank: this.scoreRank(record2),
|
|
3124
|
+
name: record2.guest ? "Guest" : record2.name,
|
|
3125
|
+
score: record2.score,
|
|
3126
|
+
guest: record2.guest,
|
|
3127
|
+
me: record2.playerId === ticket.sub
|
|
3128
|
+
}));
|
|
3129
|
+
const own = this.scores.get(this.scoreKey(ticket.sub, ticket.game, board, day));
|
|
3130
|
+
sendJson(response, {
|
|
3131
|
+
board,
|
|
3132
|
+
day,
|
|
3133
|
+
entries,
|
|
3134
|
+
me: own === void 0 ? null : { rank: this.scoreRank(own), score: own.score }
|
|
3135
|
+
}, 200, origin);
|
|
299
3136
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
if (value.matchmaking === void 0) {
|
|
324
|
-
modes.push({ id: value.id });
|
|
325
|
-
continue;
|
|
326
|
-
}
|
|
327
|
-
const matchmaking = oggetto(value.matchmaking);
|
|
328
|
-
if (matchmaking === null) {
|
|
329
|
-
errori.push(`modes[${indice}].matchmaking: must be an object.`);
|
|
330
|
-
continue;
|
|
3137
|
+
async handleLive(request, response, url) {
|
|
3138
|
+
let origin = null;
|
|
3139
|
+
try {
|
|
3140
|
+
origin = this.validOrigin(request);
|
|
3141
|
+
if (request.method === "OPTIONS") {
|
|
3142
|
+
sendPreflight(response, origin);
|
|
3143
|
+
return;
|
|
3144
|
+
}
|
|
3145
|
+
const ticket = readServiceTicket(request, this.manifest.id, "live", this.secret);
|
|
3146
|
+
this.checkRate(this.liveRequests, ticket.sub);
|
|
3147
|
+
if (url.pathname === "/rooms" && request.method === "POST") {
|
|
3148
|
+
await this.createRoom(request, response, ticket, origin);
|
|
3149
|
+
return;
|
|
3150
|
+
}
|
|
3151
|
+
if (url.pathname === "/rooms/join" && request.method === "POST") {
|
|
3152
|
+
await this.joinRoom(request, response, ticket, origin);
|
|
3153
|
+
return;
|
|
3154
|
+
}
|
|
3155
|
+
const match = /^\/rooms\/(g1-1\.[a-z0-9]{16})(?:\/(flush))?$/.exec(url.pathname);
|
|
3156
|
+
if (match !== null && match[1] !== void 0) {
|
|
3157
|
+
const localRoom = this.rooms.get(match[1]);
|
|
3158
|
+
if (localRoom === void 0 || localRoom.game !== ticket.game) {
|
|
3159
|
+
throw new DevHttpError(404, "room_not_found", "The room was not found.");
|
|
331
3160
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
3161
|
+
if (match[2] === "flush" && request.method === "POST") {
|
|
3162
|
+
const flushed = await localRoom.room.flush();
|
|
3163
|
+
for (const score of flushed.scores) {
|
|
3164
|
+
const player = this.playersById.get(score.playerId);
|
|
3165
|
+
this.putScore({
|
|
3166
|
+
playerId: score.playerId,
|
|
3167
|
+
name: player?.name ?? "Guest",
|
|
3168
|
+
guest: player?.guest ?? true,
|
|
3169
|
+
game: ticket.game,
|
|
3170
|
+
board: score.board,
|
|
3171
|
+
day: score.daily ? utcDay() : null,
|
|
3172
|
+
score: score.score,
|
|
3173
|
+
verified: true
|
|
3174
|
+
});
|
|
335
3175
|
}
|
|
3176
|
+
sendJson(response, {
|
|
3177
|
+
scores: flushed.scores.length,
|
|
3178
|
+
ended: flushed.ended !== null
|
|
3179
|
+
}, 200, origin);
|
|
3180
|
+
return;
|
|
336
3181
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
} else for (const [keyIndice, item] of matchmaking.key.entries()) {
|
|
343
|
-
if (typeof item !== "string" || item.length > 32 || !ID_INTERNO.test(item)) {
|
|
344
|
-
errori.push(`modes[${indice}].matchmaking.key[${keyIndice}]: must be 1-32 lowercase letters, digits, or internal hyphens.`);
|
|
345
|
-
valido = false;
|
|
346
|
-
} else key.push(item);
|
|
347
|
-
}
|
|
348
|
-
if (!Number.isSafeInteger(matchmaking.timeoutMs) || matchmaking.timeoutMs < 1) {
|
|
349
|
-
errori.push(`modes[${indice}].matchmaking.timeoutMs: must be a positive integer.`);
|
|
350
|
-
valido = false;
|
|
351
|
-
}
|
|
352
|
-
if (matchmaking.fallback !== "ghost" && matchmaking.fallback !== "bot") {
|
|
353
|
-
errori.push(`modes[${indice}].matchmaking.fallback: must be ghost or bot.`);
|
|
354
|
-
valido = false;
|
|
3182
|
+
if (match[2] === void 0 && request.method === "GET") {
|
|
3183
|
+
const info = await localRoom.room.info();
|
|
3184
|
+
if (info === null) throw new DevHttpError(404, "room_not_found", "The room was not found.");
|
|
3185
|
+
sendJson(response, info, 200, origin);
|
|
3186
|
+
return;
|
|
355
3187
|
}
|
|
356
|
-
if (valido) modes.push({ id: value.id, matchmaking: {
|
|
357
|
-
key,
|
|
358
|
-
timeoutMs: matchmaking.timeoutMs,
|
|
359
|
-
fallback: matchmaking.fallback
|
|
360
|
-
} });
|
|
361
3188
|
}
|
|
3189
|
+
throw new DevHttpError(404, "room_not_found", "The room was not found.");
|
|
3190
|
+
} catch (cause) {
|
|
3191
|
+
sendError(response, cause, origin);
|
|
362
3192
|
}
|
|
363
3193
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
3194
|
+
async createRoom(request, response, ticket, origin) {
|
|
3195
|
+
if (this.definition === null) {
|
|
3196
|
+
throw new DevHttpError(409, "no_server", "This game has no multiplayer server.");
|
|
3197
|
+
}
|
|
3198
|
+
const body = object(await readBody(request));
|
|
3199
|
+
if (body === null || !Object.hasOwn(body, "mode") || body.mode !== null && typeof body.mode !== "string") {
|
|
3200
|
+
throw new DevHttpError(400, "invalid_request", "mode must be null or a valid mode name.");
|
|
3201
|
+
}
|
|
3202
|
+
const roomId = `g1-1.${randomUniform("abcdefghijklmnopqrstuvwxyz0123456789", 16)}`;
|
|
3203
|
+
const roomManifest = {
|
|
3204
|
+
id: this.manifest.id,
|
|
3205
|
+
players: this.manifest.players,
|
|
3206
|
+
lobby: this.manifest.lobby,
|
|
3207
|
+
roles: this.manifest.roles,
|
|
3208
|
+
teams: this.manifest.teams,
|
|
3209
|
+
modes: this.manifest.modes,
|
|
3210
|
+
voice: this.manifest.voice
|
|
3211
|
+
};
|
|
3212
|
+
const room = await createNodeRoom(
|
|
3213
|
+
this.definition,
|
|
3214
|
+
roomManifest,
|
|
3215
|
+
{ storageFile: join(this.root, ".caisual-dev", "rooms", `${roomId}.json`) }
|
|
3216
|
+
);
|
|
3217
|
+
try {
|
|
3218
|
+
await room.create(roomId, body.mode, playerFromTicket(ticket));
|
|
3219
|
+
} catch (cause) {
|
|
3220
|
+
await room.close();
|
|
3221
|
+
throw new DevHttpError(
|
|
3222
|
+
400,
|
|
3223
|
+
"invalid_request",
|
|
3224
|
+
cause instanceof Error ? cause.message : "The room request is invalid."
|
|
3225
|
+
);
|
|
3226
|
+
}
|
|
3227
|
+
const code = this.uniqueCode();
|
|
3228
|
+
this.rooms.set(roomId, { code, game: ticket.game, room });
|
|
3229
|
+
this.roomByCode.set(code, roomId);
|
|
3230
|
+
sendJson(response, this.joinResponse(roomId, code, playerFromTicket(ticket)), 201, origin);
|
|
3231
|
+
}
|
|
3232
|
+
async joinRoom(request, response, ticket, origin) {
|
|
3233
|
+
const body = object(await readBody(request));
|
|
3234
|
+
const hasCode = body !== null && typeof body.code === "string";
|
|
3235
|
+
const hasRoomId = body !== null && typeof body.roomId === "string";
|
|
3236
|
+
if (body === null || hasCode === hasRoomId) {
|
|
3237
|
+
throw new DevHttpError(400, "invalid_request", "Provide either code or roomId.");
|
|
3238
|
+
}
|
|
3239
|
+
const codeInput = hasCode ? body.code.toUpperCase().replace(/[\s-]/g, "") : null;
|
|
3240
|
+
if (codeInput !== null && !FORMA_CODICE.test(codeInput)) {
|
|
3241
|
+
throw new DevHttpError(404, "room_not_found", "The room was not found.");
|
|
3242
|
+
}
|
|
3243
|
+
const roomId = codeInput === null ? body.roomId : this.roomByCode.get(codeInput);
|
|
3244
|
+
const localRoom = roomId === void 0 ? void 0 : this.rooms.get(roomId);
|
|
3245
|
+
if (roomId === void 0 || localRoom === void 0 || localRoom.game !== ticket.game) {
|
|
3246
|
+
throw new DevHttpError(404, "room_not_found", "The room was not found.");
|
|
3247
|
+
}
|
|
3248
|
+
const permission = await localRoom.room.canJoin(playerFromTicket(ticket));
|
|
3249
|
+
if (!permission.ok) {
|
|
3250
|
+
throw new DevHttpError(
|
|
3251
|
+
permission.code === "room_not_found" ? 404 : 409,
|
|
3252
|
+
permission.code,
|
|
3253
|
+
this.roomErrorMessage(permission.code)
|
|
3254
|
+
);
|
|
3255
|
+
}
|
|
3256
|
+
sendJson(response, this.joinResponse(roomId, localRoom.code, playerFromTicket(ticket)), 200, origin);
|
|
3257
|
+
}
|
|
3258
|
+
joinResponse(roomId, code, player) {
|
|
3259
|
+
const join3 = joinTicket(player, roomId, this.secret);
|
|
3260
|
+
return {
|
|
3261
|
+
roomId,
|
|
3262
|
+
code,
|
|
3263
|
+
join: join3,
|
|
3264
|
+
url: `ws://localhost:${this.port}/rooms/${roomId}?j=${encodeURIComponent(join3)}`
|
|
3265
|
+
};
|
|
3266
|
+
}
|
|
3267
|
+
uniqueCode() {
|
|
3268
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
3269
|
+
const code = randomUniform(ALFABETO_CODICE, 6);
|
|
3270
|
+
if (!this.roomByCode.has(code)) return code;
|
|
3271
|
+
}
|
|
3272
|
+
throw new Error("Unable to allocate a local room code.");
|
|
3273
|
+
}
|
|
3274
|
+
checkRate(requests, playerId) {
|
|
3275
|
+
const now = Date.now();
|
|
3276
|
+
const recent = (requests.get(playerId) ?? []).filter((time) => now - time < 6e4);
|
|
3277
|
+
if (recent.length >= 120) {
|
|
3278
|
+
throw new DevHttpError(429, "rate_limited", "Too many game API requests were sent.", [
|
|
3279
|
+
"Wait 10 seconds and retry."
|
|
3280
|
+
]);
|
|
3281
|
+
}
|
|
3282
|
+
recent.push(now);
|
|
3283
|
+
requests.set(playerId, recent);
|
|
3284
|
+
}
|
|
3285
|
+
roomErrorMessage(code) {
|
|
3286
|
+
if (code === "room_full") return "Room is full.";
|
|
3287
|
+
if (code === "room_playing") return "The game has already started.";
|
|
3288
|
+
if (code === "room_ended") return "Room has ended.";
|
|
3289
|
+
return "Room not found.";
|
|
3290
|
+
}
|
|
3291
|
+
rejectUpgrade(socket, status, code, message) {
|
|
3292
|
+
const body = JSON.stringify({ error: { code, message, hints: [] } });
|
|
3293
|
+
const names = {
|
|
3294
|
+
400: "Bad Request",
|
|
3295
|
+
401: "Unauthorized",
|
|
3296
|
+
404: "Not Found",
|
|
3297
|
+
409: "Conflict"
|
|
3298
|
+
};
|
|
3299
|
+
socket.end(
|
|
3300
|
+
`HTTP/1.1 ${status} ${names[status] ?? "Error"}\r
|
|
3301
|
+
Content-Type: application/json; charset=utf-8\r
|
|
3302
|
+
Content-Length: ${Buffer.byteLength(body)}\r
|
|
3303
|
+
Cache-Control: private, no-store\r
|
|
3304
|
+
Connection: close\r
|
|
3305
|
+
\r
|
|
3306
|
+
` + body
|
|
3307
|
+
);
|
|
3308
|
+
}
|
|
3309
|
+
};
|
|
3310
|
+
async function runDev(options) {
|
|
3311
|
+
const root = resolve(process.cwd(), options.folder);
|
|
3312
|
+
const stat = await fs.stat(root).catch(() => null);
|
|
3313
|
+
if (stat === null || !stat.isDirectory()) throw new Error(`The game folder was not found: ${root}`);
|
|
3314
|
+
const [{ manifest, clientRoot }, definition] = await Promise.all([
|
|
3315
|
+
readGame(root),
|
|
3316
|
+
loadDefinition(root)
|
|
3317
|
+
]);
|
|
3318
|
+
const server = createServer();
|
|
3319
|
+
let service;
|
|
3320
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
3321
|
+
const onError = (cause) => rejectListen(cause);
|
|
3322
|
+
server.once("error", onError);
|
|
3323
|
+
server.listen(options.port, "127.0.0.1", () => {
|
|
3324
|
+
server.off("error", onError);
|
|
3325
|
+
resolveListen();
|
|
3326
|
+
});
|
|
3327
|
+
}).catch((cause) => {
|
|
3328
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
3329
|
+
throw new Error(`The local server could not start: ${detail}`);
|
|
3330
|
+
});
|
|
3331
|
+
const address = server.address();
|
|
3332
|
+
if (address === null || typeof address === "string") throw new Error("The local server address is unavailable.");
|
|
3333
|
+
service = new DevService(root, clientRoot, manifest, definition, address.port);
|
|
3334
|
+
server.on("request", (request, response) => {
|
|
3335
|
+
void service.handle(request, response).catch((cause) => sendError(response, cause));
|
|
3336
|
+
});
|
|
3337
|
+
server.on("upgrade", (request, socket, head) => {
|
|
3338
|
+
void service.handleUpgrade(request, socket, head);
|
|
3339
|
+
});
|
|
3340
|
+
process.stdout.write(`Game: ${service.gameOrigin}/
|
|
3341
|
+
`);
|
|
3342
|
+
process.stdout.write(`Portal: ${service.portalOrigin}/
|
|
3343
|
+
`);
|
|
3344
|
+
process.stdout.write("ctrl+c to stop\n");
|
|
3345
|
+
await new Promise((resolveStop) => {
|
|
3346
|
+
const stop = () => resolveStop();
|
|
3347
|
+
process.once("SIGINT", stop);
|
|
3348
|
+
process.once("SIGTERM", stop);
|
|
3349
|
+
});
|
|
3350
|
+
await service.close();
|
|
3351
|
+
await new Promise((resolveClose, rejectClose) => {
|
|
3352
|
+
server.close((cause) => cause ? rejectClose(cause) : resolveClose());
|
|
3353
|
+
});
|
|
387
3354
|
}
|
|
388
3355
|
|
|
389
|
-
// ../../docs/publish.md
|
|
390
|
-
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 current publishing flow is for single-player games and does not require changes in the Caisual dashboard.\n\n## Game folder\n\nUse this structure:\n\n```text\nmy-game/\n caisual.json\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 folder, or create these files yourself.\n\n## caisual.json\n\nThe file must contain one JSON object. Unknown fields are rejected. This is a complete example for the current single-player release:\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 "players": { "min": 1, "max": 1 },\n "lobby": 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- `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`. Keep both at `1` in the current release.\n- `lobby` is optional and defaults to `false`. Keep it `false` in the current release.\n- `roles` is optional and defaults to `[]`. Each future 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. Keep it empty in the current release.\n- `teams` is optional and defaults to `null`. A future object has `min` and `max` integers from 2 to 16, with `max` at least `min`. Keep it `null` in the current release.\n- `voice` is optional and defaults to `none`. The accepted values are `none`, `room`, `team`, and `proximity`. Use `none` in the current release.\n- `modes` is optional and defaults to `[]`. A future mode has a unique `id` using 1 to 32 lowercase letters, digits, or internal hyphens. It may have `matchmaking` with a non-empty `key` array using the same format, a positive integer `timeoutMs`, and `fallback` set to `ghost` or `bot`. Keep it empty in the current release.\n\nThe CLI prints every manifest error in one run. Fix every listed field and rule before retrying.\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\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\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- 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\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- An external request works locally but fails after publishing: add its host to `network` and publish a new version.\n- A threaded WebAssembly game fails to start: set `isolated` to `true` and verify that every declared external host supports cross-origin isolation.\n\nMultiplayer rooms arrive with the kit; the same folder and command will publish them.\n';
|
|
391
|
-
|
|
392
3356
|
// src/caisual.ts
|
|
393
3357
|
var DEFAULT_ORIGIN = "https://caisual.com";
|
|
394
3358
|
var MAX_FILE_BYTES = 5e7;
|
|
@@ -396,7 +3360,6 @@ var MAX_VERSION_BYTES = 2e8;
|
|
|
396
3360
|
var MAX_FILES = 2e3;
|
|
397
3361
|
var UPLOAD_CONCURRENCY = 4;
|
|
398
3362
|
var MAX_RETRIES = 3;
|
|
399
|
-
var SERVER_MESSAGE = "Multiplayer servers arrive in the next kit version. Remove server.js to publish this game as single player.";
|
|
400
3363
|
var CliError = class extends Error {
|
|
401
3364
|
constructor(exitCode, message) {
|
|
402
3365
|
super(message);
|
|
@@ -418,10 +3381,11 @@ var ApiError = class extends Error {
|
|
|
418
3381
|
hints;
|
|
419
3382
|
};
|
|
420
3383
|
function help() {
|
|
421
|
-
return `Caisual ${"0.
|
|
3384
|
+
return `Caisual ${"0.3.0"}
|
|
422
3385
|
|
|
423
3386
|
Usage:
|
|
424
|
-
caisual init [folder]
|
|
3387
|
+
caisual init [--multiplayer] [folder]
|
|
3388
|
+
caisual dev [folder] [--port 8790]
|
|
425
3389
|
caisual publish [folder]
|
|
426
3390
|
caisual skill
|
|
427
3391
|
caisual --help
|
|
@@ -442,28 +3406,19 @@ function displayName(folderName) {
|
|
|
442
3406
|
const name = folderName.replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim();
|
|
443
3407
|
return (name === "" ? "My Game" : name).slice(0, 60);
|
|
444
3408
|
}
|
|
445
|
-
async function exists(path) {
|
|
446
|
-
try {
|
|
447
|
-
await fs.lstat(path);
|
|
448
|
-
return true;
|
|
449
|
-
} catch (error) {
|
|
450
|
-
if (error.code === "ENOENT") return false;
|
|
451
|
-
throw error;
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
3409
|
async function writeNewFile(path, content) {
|
|
455
3410
|
try {
|
|
456
|
-
await
|
|
3411
|
+
await fs2.writeFile(path, content, { encoding: "utf8", flag: "wx" });
|
|
457
3412
|
return true;
|
|
458
3413
|
} catch (error) {
|
|
459
3414
|
if (error.code === "EEXIST") return false;
|
|
460
3415
|
throw error;
|
|
461
3416
|
}
|
|
462
3417
|
}
|
|
463
|
-
async function init(folderArgument) {
|
|
464
|
-
const root =
|
|
3418
|
+
async function init(folderArgument, multiplayer) {
|
|
3419
|
+
const root = resolve2(process.cwd(), folderArgument);
|
|
465
3420
|
try {
|
|
466
|
-
await
|
|
3421
|
+
await fs2.mkdir(join2(root, "client"), { recursive: true });
|
|
467
3422
|
} catch {
|
|
468
3423
|
throw new CliError(2, `The game folder could not be created: ${root}`);
|
|
469
3424
|
}
|
|
@@ -472,11 +3427,12 @@ async function init(folderArgument) {
|
|
|
472
3427
|
manifest: 1,
|
|
473
3428
|
id: slugFromFolder(folderName),
|
|
474
3429
|
name: displayName(folderName),
|
|
475
|
-
platform: "both"
|
|
3430
|
+
platform: "both",
|
|
3431
|
+
...multiplayer ? { players: { min: 1, max: 4 }, lobby: true, voice: "room" } : {}
|
|
476
3432
|
};
|
|
477
|
-
const manifestPath =
|
|
478
|
-
const indexPath =
|
|
479
|
-
const
|
|
3433
|
+
const manifestPath = join2(root, "caisual.json");
|
|
3434
|
+
const indexPath = join2(root, "client", "index.html");
|
|
3435
|
+
const singlePlayerIndex = `<!doctype html>
|
|
480
3436
|
<html lang="en">
|
|
481
3437
|
<head>
|
|
482
3438
|
<meta charset="utf-8">
|
|
@@ -484,21 +3440,126 @@ async function init(folderArgument) {
|
|
|
484
3440
|
<title>Hello</title>
|
|
485
3441
|
</head>
|
|
486
3442
|
<body>
|
|
487
|
-
<main>Hello</main>
|
|
488
|
-
|
|
3443
|
+
<main>Hello, <span id="player">player</span>. Today's seed is <span id="seed">?</span>.</main>
|
|
3444
|
+
<script type="module">
|
|
3445
|
+
// The Caisual kit: player identity, cloud saves, leaderboards, daily seed.
|
|
3446
|
+
// API reference: https://caisual.com/kit.md
|
|
3447
|
+
import { caisual } from '/__caisual/kit/v1.js';
|
|
3448
|
+
|
|
3449
|
+
const c = await caisual.connect();
|
|
3450
|
+
document.getElementById('player').textContent = c.player.name;
|
|
3451
|
+
document.getElementById('seed').textContent = String(c.daily.seed);
|
|
3452
|
+
</script>
|
|
3453
|
+
</body>
|
|
3454
|
+
</html>
|
|
3455
|
+
`;
|
|
3456
|
+
const multiplayerIndex = `<!doctype html>
|
|
3457
|
+
<html lang="en">
|
|
3458
|
+
<head>
|
|
3459
|
+
<meta charset="utf-8">
|
|
3460
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
3461
|
+
<title>Multiplayer game</title>
|
|
3462
|
+
</head>
|
|
3463
|
+
<body>
|
|
3464
|
+
<main>
|
|
3465
|
+
<p id="status">Connecting...</p>
|
|
3466
|
+
<p id="invite"></p>
|
|
3467
|
+
<ul id="players"></ul>
|
|
3468
|
+
<button id="mic" type="button">Mic</button>
|
|
3469
|
+
<ul id="voice-peers" aria-label="Voice participants"></ul>
|
|
3470
|
+
<button id="start" type="button" hidden>Start</button>
|
|
3471
|
+
<button id="send" type="button">Send a message</button>
|
|
3472
|
+
<pre id="messages"></pre>
|
|
3473
|
+
</main>
|
|
3474
|
+
<script type="module">
|
|
3475
|
+
// The Caisual kit: identity, rooms, invites. API reference: https://caisual.com/kit.md
|
|
3476
|
+
import { caisual } from '/__caisual/kit/v1.js';
|
|
3477
|
+
|
|
3478
|
+
const c = await caisual.connect();
|
|
3479
|
+
const room = c.room.invited
|
|
3480
|
+
? await c.room.join()
|
|
3481
|
+
: await c.room.create({ mode: null });
|
|
3482
|
+
globalThis.room = room;
|
|
3483
|
+
const invite = room.invite();
|
|
3484
|
+
document.getElementById('invite').textContent = \`Invite: \${invite.url}\`;
|
|
3485
|
+
|
|
3486
|
+
const show = () => {
|
|
3487
|
+
document.getElementById('status').textContent = \`Room \${room.code}: \${room.status}\`;
|
|
3488
|
+
document.getElementById('players').innerHTML = room.players
|
|
3489
|
+
.map((p) => \`<li>\${p.name}\${p.id === room.host ? ' (host)' : ''}\${p.ready ? ' ready' : ''}</li>\`)
|
|
3490
|
+
.join('');
|
|
3491
|
+
document.getElementById('start').hidden = !(room.status === 'lobby' && room.you === room.host);
|
|
3492
|
+
};
|
|
3493
|
+
show();
|
|
3494
|
+
room.onPlayers(show);
|
|
3495
|
+
room.onStatus(show);
|
|
3496
|
+
room.ready(true);
|
|
3497
|
+
const mic = document.getElementById('mic');
|
|
3498
|
+
const showVoice = () => {
|
|
3499
|
+
mic.textContent = room.voice.state === 'off'
|
|
3500
|
+
? 'Mic'
|
|
3501
|
+
: room.voice.muted ? 'Unmute' : 'Mute';
|
|
3502
|
+
mic.disabled = room.voice.state === 'joining' || room.voice.state === 'reconnecting';
|
|
3503
|
+
document.getElementById('voice-peers').innerHTML = room.voice.peers
|
|
3504
|
+
.map((peer) => {
|
|
3505
|
+
const player = room.players.find((item) => item.id === peer.id);
|
|
3506
|
+
const name = player?.name ?? peer.id;
|
|
3507
|
+
return \`<li>\${name}: \${peer.speaking ? 'speaking' : peer.muted ? 'muted' : 'quiet'}</li>\`;
|
|
3508
|
+
})
|
|
3509
|
+
.join('');
|
|
3510
|
+
};
|
|
3511
|
+
showVoice();
|
|
3512
|
+
room.voice.onState(showVoice);
|
|
3513
|
+
room.voice.onPeers(showVoice);
|
|
3514
|
+
mic.addEventListener('click', async () => {
|
|
3515
|
+
try {
|
|
3516
|
+
if (room.voice.state === 'off') await room.voice.join();
|
|
3517
|
+
else room.voice.mute(!room.voice.muted);
|
|
3518
|
+
} catch (error) {
|
|
3519
|
+
document.getElementById('messages').textContent +=
|
|
3520
|
+
'Voice: ' + (error instanceof Error ? error.message : String(error)) + '\\n';
|
|
3521
|
+
}
|
|
3522
|
+
showVoice();
|
|
3523
|
+
});
|
|
3524
|
+
document.getElementById('start').addEventListener('click', () => room.start());
|
|
3525
|
+
room.onMessage((message) => {
|
|
3526
|
+
document.getElementById('messages').textContent += JSON.stringify(message) + '\\n';
|
|
3527
|
+
});
|
|
3528
|
+
document.getElementById('send').addEventListener('click', () => {
|
|
3529
|
+
room.send({ text: 'Hello from ' + c.player.name });
|
|
3530
|
+
});
|
|
3531
|
+
</script>
|
|
489
3532
|
</body>
|
|
490
3533
|
</html>
|
|
3534
|
+
`;
|
|
3535
|
+
const server = `import { defineGame } from '@caisual/kit/server';
|
|
3536
|
+
|
|
3537
|
+
export default defineGame({
|
|
3538
|
+
tickRate: 0,
|
|
3539
|
+
onMessage(room, _player, message) {
|
|
3540
|
+
room.broadcast(message);
|
|
3541
|
+
},
|
|
3542
|
+
});
|
|
491
3543
|
`;
|
|
492
3544
|
const manifestCreated = await writeNewFile(manifestPath, `${JSON.stringify(manifest, null, 2)}
|
|
493
3545
|
`);
|
|
494
|
-
const indexCreated = await writeNewFile(
|
|
3546
|
+
const indexCreated = await writeNewFile(
|
|
3547
|
+
indexPath,
|
|
3548
|
+
multiplayer ? multiplayerIndex : singlePlayerIndex
|
|
3549
|
+
);
|
|
495
3550
|
process.stdout.write(`${manifestCreated ? "Created" : "Kept"} ${manifestPath}
|
|
496
3551
|
`);
|
|
497
3552
|
process.stdout.write(`${indexCreated ? "Created" : "Kept"} ${indexPath}
|
|
498
3553
|
`);
|
|
3554
|
+
if (multiplayer) {
|
|
3555
|
+
const serverPath = join2(root, "server.js");
|
|
3556
|
+
const serverCreated = await writeNewFile(serverPath, server);
|
|
3557
|
+
process.stdout.write(`${serverCreated ? "Created" : "Kept"} ${serverPath}
|
|
3558
|
+
`);
|
|
3559
|
+
}
|
|
499
3560
|
}
|
|
500
3561
|
async function sha256(path) {
|
|
501
|
-
const hash =
|
|
3562
|
+
const hash = createHash3("sha256");
|
|
502
3563
|
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
|
503
3564
|
return hash.digest("hex");
|
|
504
3565
|
}
|
|
@@ -522,19 +3583,19 @@ async function mapLimited(items, limit, operation) {
|
|
|
522
3583
|
async function listClientFiles(clientRoot) {
|
|
523
3584
|
let rootStat;
|
|
524
3585
|
try {
|
|
525
|
-
rootStat = await
|
|
3586
|
+
rootStat = await fs2.stat(clientRoot);
|
|
526
3587
|
} catch {
|
|
527
3588
|
throw new CliError(2, "client/: folder not found.");
|
|
528
3589
|
}
|
|
529
3590
|
if (!rootStat.isDirectory()) throw new CliError(2, "client/: must be a folder.");
|
|
530
3591
|
const found = [];
|
|
531
3592
|
async function visit(folder, prefix) {
|
|
532
|
-
const entries = await
|
|
3593
|
+
const entries = await fs2.readdir(folder, { withFileTypes: true });
|
|
533
3594
|
entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
|
|
534
3595
|
for (const entry of entries) {
|
|
535
3596
|
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
536
3597
|
const relativePath = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
537
|
-
const absolutePath =
|
|
3598
|
+
const absolutePath = join2(folder, entry.name);
|
|
538
3599
|
if (entry.isDirectory()) {
|
|
539
3600
|
await visit(absolutePath, relativePath);
|
|
540
3601
|
continue;
|
|
@@ -542,7 +3603,7 @@ async function listClientFiles(clientRoot) {
|
|
|
542
3603
|
if (!entry.isFile()) {
|
|
543
3604
|
throw new CliError(2, `${relativePath}: only regular files are supported.`);
|
|
544
3605
|
}
|
|
545
|
-
const fileStat = await
|
|
3606
|
+
const fileStat = await fs2.stat(absolutePath);
|
|
546
3607
|
if (fileStat.size > MAX_FILE_BYTES) {
|
|
547
3608
|
throw new CliError(2, `${relativePath}: file is larger than 50 MB (${fileStat.size} bytes).`);
|
|
548
3609
|
}
|
|
@@ -565,6 +3626,34 @@ async function listClientFiles(clientRoot) {
|
|
|
565
3626
|
sha256: await sha256(file.absolutePath)
|
|
566
3627
|
}));
|
|
567
3628
|
}
|
|
3629
|
+
async function readServerFile(root) {
|
|
3630
|
+
const absolutePath = join2(root, "server.js");
|
|
3631
|
+
let stat;
|
|
3632
|
+
try {
|
|
3633
|
+
stat = await fs2.lstat(absolutePath);
|
|
3634
|
+
} catch (error) {
|
|
3635
|
+
if (error.code === "ENOENT") return null;
|
|
3636
|
+
throw new CliError(2, "server.js: file not readable.");
|
|
3637
|
+
}
|
|
3638
|
+
if (!stat.isFile()) throw new CliError(2, "server.js: must be a regular file.");
|
|
3639
|
+
let source;
|
|
3640
|
+
try {
|
|
3641
|
+
source = await fs2.readFile(absolutePath, "utf8");
|
|
3642
|
+
} catch {
|
|
3643
|
+
throw new CliError(2, "server.js: file not readable.");
|
|
3644
|
+
}
|
|
3645
|
+
const result = validaServerJs(source);
|
|
3646
|
+
if (!result.ok) {
|
|
3647
|
+
throw new CliError(2, `server.js is not valid:
|
|
3648
|
+
${result.errori.map((error) => `- ${error}`).join("\n")}`);
|
|
3649
|
+
}
|
|
3650
|
+
return {
|
|
3651
|
+
path: "server.js",
|
|
3652
|
+
absolutePath,
|
|
3653
|
+
bytes: stat.size,
|
|
3654
|
+
sha256: await sha256(absolutePath)
|
|
3655
|
+
};
|
|
3656
|
+
}
|
|
568
3657
|
function portalOrigin() {
|
|
569
3658
|
const raw = process.env.CAISUAL_ORIGIN?.trim() || DEFAULT_ORIGIN;
|
|
570
3659
|
let url;
|
|
@@ -578,7 +3667,7 @@ function portalOrigin() {
|
|
|
578
3667
|
}
|
|
579
3668
|
return url.origin;
|
|
580
3669
|
}
|
|
581
|
-
function
|
|
3670
|
+
function object2(value) {
|
|
582
3671
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
583
3672
|
}
|
|
584
3673
|
async function apiError(response) {
|
|
@@ -588,8 +3677,8 @@ async function apiError(response) {
|
|
|
588
3677
|
} catch {
|
|
589
3678
|
payload = null;
|
|
590
3679
|
}
|
|
591
|
-
const envelope =
|
|
592
|
-
const detail =
|
|
3680
|
+
const envelope = object2(payload);
|
|
3681
|
+
const detail = object2(envelope?.error);
|
|
593
3682
|
const message = typeof detail?.message === "string" ? detail.message : `The portal returned HTTP ${response.status}.`;
|
|
594
3683
|
const code = typeof detail?.code === "string" ? detail.code : "http_error";
|
|
595
3684
|
const hints = Array.isArray(detail?.hints) ? detail.hints.filter((hint) => typeof hint === "string") : [];
|
|
@@ -610,11 +3699,11 @@ async function requestJson(url, init2) {
|
|
|
610
3699
|
} catch {
|
|
611
3700
|
throw new CliError(1, "The portal returned an invalid JSON response.");
|
|
612
3701
|
}
|
|
613
|
-
const result =
|
|
3702
|
+
const result = object2(payload);
|
|
614
3703
|
if (result === null) throw new CliError(1, "The portal returned an invalid JSON response.");
|
|
615
3704
|
return result;
|
|
616
3705
|
}
|
|
617
|
-
function
|
|
3706
|
+
function contentType2(path) {
|
|
618
3707
|
const types = {
|
|
619
3708
|
".aac": "audio/aac",
|
|
620
3709
|
".avif": "image/avif",
|
|
@@ -653,7 +3742,7 @@ function contentType(path) {
|
|
|
653
3742
|
".xml": "application/xml; charset=utf-8",
|
|
654
3743
|
".zip": "application/zip"
|
|
655
3744
|
};
|
|
656
|
-
return types[
|
|
3745
|
+
return types[extname2(path).toLowerCase()] ?? "application/octet-stream";
|
|
657
3746
|
}
|
|
658
3747
|
function progressLine(path, sent, total, retry) {
|
|
659
3748
|
const percentage = total === 0 ? 100 : Math.min(100, Math.floor(sent / total * 100));
|
|
@@ -697,7 +3786,7 @@ async function uploadFile(file, target, key, origin) {
|
|
|
697
3786
|
method: "PUT",
|
|
698
3787
|
headers: {
|
|
699
3788
|
Authorization: `Bearer ${key}`,
|
|
700
|
-
"Content-Type":
|
|
3789
|
+
"Content-Type": contentType2(file.path),
|
|
701
3790
|
"Content-Length": String(file.bytes)
|
|
702
3791
|
},
|
|
703
3792
|
body: uploadBody(file, retry),
|
|
@@ -722,14 +3811,14 @@ async function uploadFile(file, target, key, origin) {
|
|
|
722
3811
|
await sleep(100 * 2 ** retry);
|
|
723
3812
|
}
|
|
724
3813
|
}
|
|
725
|
-
function parseUploads(payload, files) {
|
|
3814
|
+
function parseUploads(payload, files, server) {
|
|
726
3815
|
const versionId = payload.versionId;
|
|
727
3816
|
if (!Number.isSafeInteger(versionId) || versionId <= 0 || !Array.isArray(payload.uploads)) {
|
|
728
3817
|
throw new CliError(1, "The portal returned an invalid version response.");
|
|
729
3818
|
}
|
|
730
3819
|
const byPath = /* @__PURE__ */ new Map();
|
|
731
3820
|
for (const raw of payload.uploads) {
|
|
732
|
-
const target =
|
|
3821
|
+
const target = object2(raw);
|
|
733
3822
|
if (typeof target?.path !== "string" || typeof target.url !== "string" || target.method !== "PUT") {
|
|
734
3823
|
throw new CliError(1, "The portal returned an invalid upload target.");
|
|
735
3824
|
}
|
|
@@ -742,17 +3831,28 @@ function parseUploads(payload, files) {
|
|
|
742
3831
|
return target;
|
|
743
3832
|
});
|
|
744
3833
|
if (byPath.size !== files.length) throw new CliError(1, "The portal returned an upload URL for an unknown file.");
|
|
3834
|
+
const rawServerTarget = object2(payload.serverUpload);
|
|
3835
|
+
let serverTarget = null;
|
|
3836
|
+
if (server !== null) {
|
|
3837
|
+
if (typeof rawServerTarget?.url !== "string" || rawServerTarget.method !== "PUT") {
|
|
3838
|
+
throw new CliError(1, "The portal did not return an upload URL for server.js.");
|
|
3839
|
+
}
|
|
3840
|
+
serverTarget = { url: rawServerTarget.url, method: "PUT" };
|
|
3841
|
+
} else if (payload.serverUpload !== void 0 && payload.serverUpload !== null) {
|
|
3842
|
+
throw new CliError(1, "The portal returned an unexpected upload URL for server.js.");
|
|
3843
|
+
}
|
|
745
3844
|
return {
|
|
746
3845
|
versionId,
|
|
747
3846
|
n: Number.isSafeInteger(payload.n) ? payload.n : null,
|
|
748
|
-
targets
|
|
3847
|
+
targets,
|
|
3848
|
+
serverTarget
|
|
749
3849
|
};
|
|
750
3850
|
}
|
|
751
3851
|
async function readManifest(root) {
|
|
752
|
-
const path =
|
|
3852
|
+
const path = join2(root, "caisual.json");
|
|
753
3853
|
let source;
|
|
754
3854
|
try {
|
|
755
|
-
source = await
|
|
3855
|
+
source = await fs2.readFile(path, "utf8");
|
|
756
3856
|
} catch {
|
|
757
3857
|
throw new CliError(2, "caisual.json: file not found or unreadable.");
|
|
758
3858
|
}
|
|
@@ -770,17 +3870,19 @@ ${result.errori.map((error) => `- ${error}`).join("\n")}`);
|
|
|
770
3870
|
return result.manifest;
|
|
771
3871
|
}
|
|
772
3872
|
async function publish(folderArgument) {
|
|
773
|
-
const root =
|
|
3873
|
+
const root = resolve2(process.cwd(), folderArgument);
|
|
774
3874
|
let rootStat;
|
|
775
3875
|
try {
|
|
776
|
-
rootStat = await
|
|
3876
|
+
rootStat = await fs2.stat(root);
|
|
777
3877
|
} catch {
|
|
778
3878
|
throw new CliError(2, `The game folder was not found: ${root}`);
|
|
779
3879
|
}
|
|
780
3880
|
if (!rootStat.isDirectory()) throw new CliError(2, `The game path is not a folder: ${root}`);
|
|
781
|
-
if (await exists(join(root, "server.js"))) throw new CliError(2, SERVER_MESSAGE);
|
|
782
3881
|
const manifest = await readManifest(root);
|
|
783
|
-
const files = await
|
|
3882
|
+
const [files, server] = await Promise.all([
|
|
3883
|
+
listClientFiles(join2(root, "client")),
|
|
3884
|
+
readServerFile(root)
|
|
3885
|
+
]);
|
|
784
3886
|
const filePaths = new Set(files.map((file) => file.path));
|
|
785
3887
|
for (const required of [manifest.cover, ...manifest.screenshots]) {
|
|
786
3888
|
if (required !== null && !filePaths.has(required)) {
|
|
@@ -797,19 +3899,37 @@ async function publish(folderArgument) {
|
|
|
797
3899
|
bytes,
|
|
798
3900
|
sha256: digest
|
|
799
3901
|
}));
|
|
800
|
-
process.stdout.write(
|
|
801
|
-
`
|
|
3902
|
+
process.stdout.write(
|
|
3903
|
+
`Preparing ${files.length} client file${files.length === 1 ? "" : "s"}${server === null ? "" : " and server.js"}.
|
|
3904
|
+
`
|
|
3905
|
+
);
|
|
802
3906
|
const opened = await requestJson(`${origin}/api/versions`, {
|
|
803
3907
|
method: "POST",
|
|
804
3908
|
headers: {
|
|
805
3909
|
Authorization: `Bearer ${key}`,
|
|
806
3910
|
"Content-Type": "application/json; charset=utf-8"
|
|
807
3911
|
},
|
|
808
|
-
body: JSON.stringify({
|
|
3912
|
+
body: JSON.stringify({
|
|
3913
|
+
manifest,
|
|
3914
|
+
files: declared,
|
|
3915
|
+
...server === null ? {} : {
|
|
3916
|
+
server: { bytes: server.bytes, sha256: server.sha256 }
|
|
3917
|
+
}
|
|
3918
|
+
})
|
|
809
3919
|
});
|
|
810
|
-
const version = parseUploads(opened, files);
|
|
811
|
-
|
|
812
|
-
|
|
3920
|
+
const version = parseUploads(opened, files, server);
|
|
3921
|
+
const caricamenti = files.map((file, index) => ({
|
|
3922
|
+
file,
|
|
3923
|
+
target: version.targets[index]
|
|
3924
|
+
}));
|
|
3925
|
+
if (server !== null && version.serverTarget !== null) {
|
|
3926
|
+
caricamenti.push({
|
|
3927
|
+
file: server,
|
|
3928
|
+
target: { path: "server.js", ...version.serverTarget }
|
|
3929
|
+
});
|
|
3930
|
+
}
|
|
3931
|
+
await mapLimited(caricamenti, UPLOAD_CONCURRENCY, async ({ file, target }) => {
|
|
3932
|
+
await uploadFile(file, target, key, origin);
|
|
813
3933
|
});
|
|
814
3934
|
const completed = await requestJson(`${origin}/api/versions/${version.versionId}/complete`, {
|
|
815
3935
|
method: "POST",
|
|
@@ -825,33 +3945,35 @@ async function publish(folderArgument) {
|
|
|
825
3945
|
}
|
|
826
3946
|
async function installSkill() {
|
|
827
3947
|
const root = process.cwd();
|
|
828
|
-
const skillPath =
|
|
3948
|
+
const skillPath = join2(root, ".claude", "skills", "caisual", "SKILL.md");
|
|
829
3949
|
const skill = `---
|
|
830
3950
|
name: caisual
|
|
831
|
-
description: Create and publish a browser game on Caisual.
|
|
3951
|
+
description: Create and publish a browser game on Caisual, with player identity, cloud saves, leaderboards and a daily challenge.
|
|
832
3952
|
---
|
|
833
3953
|
|
|
834
3954
|
${publish_default.trim()}
|
|
3955
|
+
|
|
3956
|
+
${kit_default.trim()}
|
|
835
3957
|
`;
|
|
836
|
-
await
|
|
3958
|
+
await fs2.mkdir(join2(root, ".claude", "skills", "caisual"), { recursive: true });
|
|
837
3959
|
let currentSkill = null;
|
|
838
3960
|
try {
|
|
839
|
-
currentSkill = await
|
|
3961
|
+
currentSkill = await fs2.readFile(skillPath, "utf8");
|
|
840
3962
|
} catch (error) {
|
|
841
3963
|
if (error.code !== "ENOENT") throw error;
|
|
842
3964
|
}
|
|
843
|
-
if (currentSkill !== skill) await
|
|
844
|
-
const agentsPath =
|
|
3965
|
+
if (currentSkill !== skill) await fs2.writeFile(skillPath, skill, "utf8");
|
|
3966
|
+
const agentsPath = join2(root, "AGENTS.md");
|
|
845
3967
|
let agents = "";
|
|
846
3968
|
try {
|
|
847
|
-
agents = await
|
|
3969
|
+
agents = await fs2.readFile(agentsPath, "utf8");
|
|
848
3970
|
} catch (error) {
|
|
849
3971
|
if (error.code !== "ENOENT") throw error;
|
|
850
3972
|
}
|
|
851
3973
|
if (!/^## Caisual\s*$/m.test(agents)) {
|
|
852
|
-
const section = "## Caisual\nRead `.claude/skills/caisual/SKILL.md` before creating or publishing a Caisual game.\nUse the current
|
|
3974
|
+
const section = "## Caisual\nRead `.claude/skills/caisual/SKILL.md` before creating or publishing a Caisual game.\nUse the current guides at https://caisual.com/publish.md and https://caisual.com/kit.md.\n";
|
|
853
3975
|
const separator = agents === "" ? "" : agents.endsWith("\n\n") ? "" : agents.endsWith("\n") ? "\n" : "\n\n";
|
|
854
|
-
await
|
|
3976
|
+
await fs2.writeFile(agentsPath, `${agents}${separator}${section}`, "utf8");
|
|
855
3977
|
}
|
|
856
3978
|
process.stdout.write(`Installed ${skillPath}
|
|
857
3979
|
`);
|
|
@@ -863,13 +3985,17 @@ async function run(argumentsList) {
|
|
|
863
3985
|
return;
|
|
864
3986
|
}
|
|
865
3987
|
if (command === "--version" || command === "-V") {
|
|
866
|
-
process.stdout.write(`${"0.
|
|
3988
|
+
process.stdout.write(`${"0.3.0"}
|
|
867
3989
|
`);
|
|
868
3990
|
return;
|
|
869
3991
|
}
|
|
870
3992
|
if (command === "init") {
|
|
871
|
-
|
|
872
|
-
|
|
3993
|
+
const multiplayer = argumentsAfterCommand.includes("--multiplayer");
|
|
3994
|
+
const cartelle = argumentsAfterCommand.filter((value) => value !== "--multiplayer");
|
|
3995
|
+
if (cartelle.length > 1 || cartelle.some((value) => value.startsWith("-"))) {
|
|
3996
|
+
throw new CliError(1, "Usage: caisual init [--multiplayer] [folder]");
|
|
3997
|
+
}
|
|
3998
|
+
await init(cartelle[0] ?? ".", multiplayer);
|
|
873
3999
|
return;
|
|
874
4000
|
}
|
|
875
4001
|
if (command === "publish") {
|
|
@@ -877,6 +4003,35 @@ async function run(argumentsList) {
|
|
|
877
4003
|
await publish(argumentsAfterCommand[0] ?? ".");
|
|
878
4004
|
return;
|
|
879
4005
|
}
|
|
4006
|
+
if (command === "dev") {
|
|
4007
|
+
let folder = ".";
|
|
4008
|
+
let port = 8790;
|
|
4009
|
+
let folderSeen = false;
|
|
4010
|
+
for (let index = 0; index < argumentsAfterCommand.length; index += 1) {
|
|
4011
|
+
const argument = argumentsAfterCommand[index];
|
|
4012
|
+
if (argument === "--port") {
|
|
4013
|
+
const value = argumentsAfterCommand[index + 1];
|
|
4014
|
+
if (value === void 0) throw new CliError(1, "Usage: caisual dev [folder] [--port 8790]");
|
|
4015
|
+
port = Number(value);
|
|
4016
|
+
index += 1;
|
|
4017
|
+
continue;
|
|
4018
|
+
}
|
|
4019
|
+
if (argument.startsWith("--port=")) {
|
|
4020
|
+
port = Number(argument.slice("--port=".length));
|
|
4021
|
+
continue;
|
|
4022
|
+
}
|
|
4023
|
+
if (argument.startsWith("-") || folderSeen) {
|
|
4024
|
+
throw new CliError(1, "Usage: caisual dev [folder] [--port 8790]");
|
|
4025
|
+
}
|
|
4026
|
+
folder = argument;
|
|
4027
|
+
folderSeen = true;
|
|
4028
|
+
}
|
|
4029
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
4030
|
+
throw new CliError(1, "--port must be an integer from 1 to 65535.");
|
|
4031
|
+
}
|
|
4032
|
+
await runDev({ folder, port });
|
|
4033
|
+
return;
|
|
4034
|
+
}
|
|
880
4035
|
if (command === "skill") {
|
|
881
4036
|
if (argumentsAfterCommand.length > 0) throw new CliError(1, "Usage: caisual skill");
|
|
882
4037
|
await installSkill();
|