@caisual/cli 0.3.0 → 0.5.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 +1442 -236
- package/package.json +4 -2
package/dist/caisual.mjs
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
// src/caisual.ts
|
|
4
4
|
import { createHash as createHash3 } from "node:crypto";
|
|
5
|
-
import { createReadStream, promises as
|
|
6
|
-
import {
|
|
5
|
+
import { createReadStream, promises as fs3 } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { basename, extname as extname2, join as join3, resolve as resolve2 } from "node:path";
|
|
7
8
|
|
|
8
9
|
// ../contracts/src/slug.ts
|
|
9
10
|
var NOMI_RISERVATI = [
|
|
@@ -49,6 +50,8 @@ function isReservedSlug(value) {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
// ../contracts/src/manifest.ts
|
|
53
|
+
var TETTO_GIOCATORI = 24;
|
|
54
|
+
var RITARDO_SPETTATORI_MS = 3e3;
|
|
52
55
|
var CAMPI = /* @__PURE__ */ new Set([
|
|
53
56
|
"manifest",
|
|
54
57
|
"id",
|
|
@@ -64,8 +67,11 @@ var CAMPI = /* @__PURE__ */ new Set([
|
|
|
64
67
|
"visibility",
|
|
65
68
|
"network",
|
|
66
69
|
"isolated",
|
|
70
|
+
"requires",
|
|
67
71
|
"players",
|
|
68
72
|
"lobby",
|
|
73
|
+
"persistent",
|
|
74
|
+
"spectators",
|
|
69
75
|
"roles",
|
|
70
76
|
"teams",
|
|
71
77
|
"voice",
|
|
@@ -76,8 +82,10 @@ var PLATFORM = /* @__PURE__ */ new Set(["desktop", "mobile", "both"]);
|
|
|
76
82
|
var ORIENTATION = /* @__PURE__ */ new Set(["landscape", "portrait"]);
|
|
77
83
|
var VISIBILITY = /* @__PURE__ */ new Set(["public", "unlisted"]);
|
|
78
84
|
var VOICE = /* @__PURE__ */ new Set(["none", "room", "team", "proximity"]);
|
|
85
|
+
var PERFORMANCE = /* @__PURE__ */ new Set(["light", "medium", "heavy"]);
|
|
79
86
|
var TAG = /^[a-z0-9-]+$/;
|
|
80
87
|
var ID_INTERNO = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
88
|
+
var CAMPO_MATCHMAKING = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
81
89
|
function oggetto(value) {
|
|
82
90
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
83
91
|
return value;
|
|
@@ -213,6 +221,41 @@ function validaManifest(valore) {
|
|
|
213
221
|
if (typeof dati.isolated !== "boolean") errori.push("isolated: must be a boolean.");
|
|
214
222
|
else isolated = dati.isolated;
|
|
215
223
|
}
|
|
224
|
+
const requires = {
|
|
225
|
+
webgl2: false,
|
|
226
|
+
webgpu: false,
|
|
227
|
+
wasm: false,
|
|
228
|
+
threads: false,
|
|
229
|
+
memoryMb: null,
|
|
230
|
+
performance: "light"
|
|
231
|
+
};
|
|
232
|
+
if (dati.requires !== void 0) {
|
|
233
|
+
const value = oggetto(dati.requires);
|
|
234
|
+
if (value === null) errori.push("requires: must be an object.");
|
|
235
|
+
else {
|
|
236
|
+
for (const campo of Object.keys(value)) {
|
|
237
|
+
if (!["webgl2", "webgpu", "wasm", "threads", "memoryMb", "performance"].includes(campo)) {
|
|
238
|
+
errori.push(`requires.${campo}: unknown field.`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
for (const campo of ["webgl2", "webgpu", "wasm", "threads"]) {
|
|
242
|
+
if (value[campo] === void 0) continue;
|
|
243
|
+
if (typeof value[campo] !== "boolean") errori.push(`requires.${campo}: must be a boolean.`);
|
|
244
|
+
else requires[campo] = value[campo];
|
|
245
|
+
}
|
|
246
|
+
if (value.memoryMb !== void 0) {
|
|
247
|
+
if (value.memoryMb !== null && (!interoTra(value.memoryMb, 512, 32768) || value.memoryMb % 256 !== 0)) {
|
|
248
|
+
errori.push("requires.memoryMb: must be an integer from 512 to 32768 in steps of 256, or null.");
|
|
249
|
+
} else requires.memoryMb = value.memoryMb;
|
|
250
|
+
}
|
|
251
|
+
if (value.performance !== void 0) {
|
|
252
|
+
if (typeof value.performance !== "string" || !PERFORMANCE.has(value.performance)) {
|
|
253
|
+
errori.push("requires.performance: must be light, medium, or heavy.");
|
|
254
|
+
} else requires.performance = value.performance;
|
|
255
|
+
}
|
|
256
|
+
if (requires.threads && !isolated) errori.push("requires.threads: needs isolated: true.");
|
|
257
|
+
}
|
|
258
|
+
}
|
|
216
259
|
let players = { min: 1, max: 1 };
|
|
217
260
|
if (dati.players !== void 0) {
|
|
218
261
|
const value = oggetto(dati.players);
|
|
@@ -221,9 +264,9 @@ function validaManifest(valore) {
|
|
|
221
264
|
for (const campo of Object.keys(value)) {
|
|
222
265
|
if (campo !== "min" && campo !== "max") errori.push(`players.${campo}: unknown field.`);
|
|
223
266
|
}
|
|
224
|
-
if (!interoTra(value.min, 1,
|
|
225
|
-
if (!interoTra(value.max, 1,
|
|
226
|
-
if (interoTra(value.min, 1,
|
|
267
|
+
if (!interoTra(value.min, 1, TETTO_GIOCATORI)) errori.push(`players.min: must be an integer from 1 to ${TETTO_GIOCATORI}.`);
|
|
268
|
+
if (!interoTra(value.max, 1, TETTO_GIOCATORI)) errori.push(`players.max: must be an integer from 1 to ${TETTO_GIOCATORI} in manifest version 1.`);
|
|
269
|
+
if (interoTra(value.min, 1, TETTO_GIOCATORI) && interoTra(value.max, 1, TETTO_GIOCATORI)) {
|
|
227
270
|
if (value.min > value.max) errori.push("players.max: must be greater than or equal to players.min.");
|
|
228
271
|
else players = { min: value.min, max: value.max };
|
|
229
272
|
}
|
|
@@ -234,6 +277,26 @@ function validaManifest(valore) {
|
|
|
234
277
|
if (typeof dati.lobby !== "boolean") errori.push("lobby: must be a boolean.");
|
|
235
278
|
else lobby = dati.lobby;
|
|
236
279
|
}
|
|
280
|
+
let persistent = false;
|
|
281
|
+
if (dati.persistent !== void 0) {
|
|
282
|
+
if (typeof dati.persistent !== "boolean") errori.push("persistent: must be a boolean.");
|
|
283
|
+
else persistent = dati.persistent;
|
|
284
|
+
}
|
|
285
|
+
let spectators = { delayMs: RITARDO_SPETTATORI_MS };
|
|
286
|
+
if (dati.spectators === false) spectators = null;
|
|
287
|
+
else if (dati.spectators !== void 0 && dati.spectators !== true) {
|
|
288
|
+
const value = oggetto(dati.spectators);
|
|
289
|
+
if (value === null) {
|
|
290
|
+
errori.push("spectators: must be a boolean or an object with delayMs.");
|
|
291
|
+
} else {
|
|
292
|
+
for (const campo of Object.keys(value)) {
|
|
293
|
+
if (campo !== "delayMs") errori.push(`spectators.${campo}: unknown field.`);
|
|
294
|
+
}
|
|
295
|
+
if (!interoTra(value.delayMs, 0, 3e4)) {
|
|
296
|
+
errori.push("spectators.delayMs: must be an integer from 0 to 30000.");
|
|
297
|
+
} else spectators = { delayMs: value.delayMs };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
237
300
|
const roles = [];
|
|
238
301
|
if (dati.roles !== void 0) {
|
|
239
302
|
if (!Array.isArray(dati.roles)) errori.push("roles: must be an array.");
|
|
@@ -259,12 +322,12 @@ function validaManifest(valore) {
|
|
|
259
322
|
errori.push(`roles[${indice}].id: duplicate role ${idRuolo}.`);
|
|
260
323
|
valido = false;
|
|
261
324
|
} else ids.add(idRuolo);
|
|
262
|
-
if (!interoTra(min, 0,
|
|
263
|
-
errori.push(`roles[${indice}].min: must be an integer from 0 to
|
|
325
|
+
if (!interoTra(min, 0, TETTO_GIOCATORI)) {
|
|
326
|
+
errori.push(`roles[${indice}].min: must be an integer from 0 to ${TETTO_GIOCATORI}.`);
|
|
264
327
|
valido = false;
|
|
265
328
|
}
|
|
266
|
-
if (max !== void 0 && !interoTra(max, 0,
|
|
267
|
-
errori.push(`roles[${indice}].max: must be an integer from 0 to
|
|
329
|
+
if (max !== void 0 && !interoTra(max, 0, TETTO_GIOCATORI)) {
|
|
330
|
+
errori.push(`roles[${indice}].max: must be an integer from 0 to ${TETTO_GIOCATORI} when present.`);
|
|
268
331
|
valido = false;
|
|
269
332
|
}
|
|
270
333
|
if (typeof min === "number" && typeof max === "number" && min > max) {
|
|
@@ -283,9 +346,9 @@ function validaManifest(valore) {
|
|
|
283
346
|
for (const campo of Object.keys(value)) {
|
|
284
347
|
if (campo !== "min" && campo !== "max") errori.push(`teams.${campo}: unknown field.`);
|
|
285
348
|
}
|
|
286
|
-
if (!interoTra(value.min, 2,
|
|
287
|
-
if (!interoTra(value.max, 2,
|
|
288
|
-
if (interoTra(value.min, 2,
|
|
349
|
+
if (!interoTra(value.min, 2, TETTO_GIOCATORI)) errori.push(`teams.min: must be an integer from 2 to ${TETTO_GIOCATORI}.`);
|
|
350
|
+
if (!interoTra(value.max, 2, TETTO_GIOCATORI)) errori.push(`teams.max: must be an integer from 2 to ${TETTO_GIOCATORI}.`);
|
|
351
|
+
if (interoTra(value.min, 2, TETTO_GIOCATORI) && interoTra(value.max, 2, TETTO_GIOCATORI)) {
|
|
289
352
|
if (value.min > value.max) errori.push("teams.max: must be greater than or equal to teams.min.");
|
|
290
353
|
else teams = { min: value.min, max: value.max };
|
|
291
354
|
}
|
|
@@ -330,33 +393,31 @@ function validaManifest(valore) {
|
|
|
330
393
|
continue;
|
|
331
394
|
}
|
|
332
395
|
for (const campo of Object.keys(matchmaking)) {
|
|
333
|
-
if (
|
|
396
|
+
if (campo !== "key" && campo !== "timeoutMs") {
|
|
334
397
|
errori.push(`modes[${indice}].matchmaking.${campo}: unknown field.`);
|
|
335
398
|
}
|
|
336
399
|
}
|
|
337
400
|
let valido = true;
|
|
338
401
|
const key = [];
|
|
339
|
-
if (!Array.isArray(matchmaking.key) || matchmaking.key.length
|
|
340
|
-
errori.push(`modes[${indice}].matchmaking.key: must
|
|
402
|
+
if (!Array.isArray(matchmaking.key) || matchmaking.key.length < 1 || matchmaking.key.length > 8) {
|
|
403
|
+
errori.push(`modes[${indice}].matchmaking.key: must contain from 1 to 8 fields.`);
|
|
341
404
|
valido = false;
|
|
342
405
|
} else for (const [keyIndice, item] of matchmaking.key.entries()) {
|
|
343
|
-
if (typeof item !== "string" ||
|
|
344
|
-
errori.push(`modes[${indice}].matchmaking.key[${keyIndice}]: must be 1-32 lowercase letters, digits, or
|
|
406
|
+
if (typeof item !== "string" || !CAMPO_MATCHMAKING.test(item)) {
|
|
407
|
+
errori.push(`modes[${indice}].matchmaking.key[${keyIndice}]: must be 1-32 lowercase letters, digits, or hyphens and start with a letter or digit.`);
|
|
408
|
+
valido = false;
|
|
409
|
+
} else if (key.includes(item)) {
|
|
410
|
+
errori.push(`modes[${indice}].matchmaking.key[${keyIndice}]: duplicate field ${item}.`);
|
|
345
411
|
valido = false;
|
|
346
412
|
} else key.push(item);
|
|
347
413
|
}
|
|
348
|
-
if (!
|
|
349
|
-
errori.push(`modes[${indice}].matchmaking.timeoutMs: must be
|
|
350
|
-
valido = false;
|
|
351
|
-
}
|
|
352
|
-
if (matchmaking.fallback !== "ghost" && matchmaking.fallback !== "bot") {
|
|
353
|
-
errori.push(`modes[${indice}].matchmaking.fallback: must be ghost or bot.`);
|
|
414
|
+
if (!interoTra(matchmaking.timeoutMs, 1e3, 3e5)) {
|
|
415
|
+
errori.push(`modes[${indice}].matchmaking.timeoutMs: must be an integer from 1000 to 300000.`);
|
|
354
416
|
valido = false;
|
|
355
417
|
}
|
|
356
418
|
if (valido) modes.push({ id: value.id, matchmaking: {
|
|
357
419
|
key,
|
|
358
|
-
timeoutMs: matchmaking.timeoutMs
|
|
359
|
-
fallback: matchmaking.fallback
|
|
420
|
+
timeoutMs: matchmaking.timeoutMs
|
|
360
421
|
} });
|
|
361
422
|
}
|
|
362
423
|
}
|
|
@@ -377,8 +438,11 @@ function validaManifest(valore) {
|
|
|
377
438
|
visibility,
|
|
378
439
|
network,
|
|
379
440
|
isolated,
|
|
441
|
+
requires,
|
|
380
442
|
players,
|
|
381
443
|
lobby,
|
|
444
|
+
persistent,
|
|
445
|
+
spectators,
|
|
382
446
|
roles,
|
|
383
447
|
teams,
|
|
384
448
|
voice,
|
|
@@ -481,16 +545,127 @@ function validaServerJs(sorgente) {
|
|
|
481
545
|
}
|
|
482
546
|
|
|
483
547
|
// ../../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';
|
|
548
|
+
var publish_default = '# Publish a game on Caisual\n\nCaisual hosts browser games supplied as folders. Each publish creates an immutable version and moves the game\'s stable link to that version.\nThe publishing flow supports both single-player and multiplayer games and does not require changes in the Caisual dashboard. Player identity, rooms, cloud saves, leaderboards, and the daily challenge come from the game kit, documented in [kit.md](./kit.md).\n\n## Game folder\n\nUse this structure:\n\n```text\nmy-game/\n caisual.json\n server.js # optional, required only for multiplayer rooms\n client/\n index.html\n ...\n```\n\n`caisual.json` and `client/index.html` are required. Put every file used by the game under `client/`.\n\nRun `npx @caisual/cli init my-game` to create a minimal single-player folder. Run `npx @caisual/cli init --multiplayer my-game` to include a four-player lobby, a relay server, and a room client example.\n\n## caisual.json\n\nThe file must contain one JSON object. Unknown fields are rejected. This is a complete single-player example:\n\n```json\n{\n "manifest": 1,\n "id": "my-game",\n "name": "My Game",\n "description": "A short description of the game.",\n "cover": "cover.png",\n "screenshots": ["screenshots/level-one.png"],\n "tags": ["puzzle"],\n "language": "en",\n "platform": "both",\n "orientation": "landscape",\n "input": ["keyboard", "mouse", "touch"],\n "visibility": "public",\n "network": [],\n "isolated": false,\n "requires": { "webgl2": false, "webgpu": false, "wasm": false, "threads": false, "memoryMb": null, "performance": "light" },\n "players": { "min": 1, "max": 1 },\n "lobby": false,\n "persistent": false,\n "spectators": true,\n "roles": [],\n "teams": null,\n "voice": "none",\n "modes": []\n}\n```\n\n- `manifest` is required and must be `1`.\n- `id` is required. Use 3 to 32 lowercase ASCII letters or digits, with single hyphens only between groups. The ID becomes the URL slug. Choose it carefully because it cannot be renamed or reused after deletion.\n- `name` is required and must contain 1 to 60 characters.\n- `description` is optional, defaults to an empty string, and can contain at most 500 characters.\n- `cover` is optional. Use a relative path inside `client/`, or `null`. Do not include a query, fragment, empty segment, or parent segment.\n- `screenshots` is optional and defaults to `[]`. It accepts up to 8 relative paths inside `client/`.\n- `tags` is optional and defaults to `[]`. It accepts up to 10 values. Each value uses 1 to 24 lowercase letters, digits, or hyphens.\n- `language` is optional and defaults to `en`. Use a BCP 47 language tag such as `en`, `it`, or `pt-BR`.\n- `platform` is required. Use `desktop` when the game needs a keyboard, mouse, large display, or desktop performance. Use `mobile` when it is designed only for touch and small screens. Use `both` only after checking that layout, performance, and controls work on both.\n- `orientation` is optional and defaults to `landscape`. Use `landscape` or `portrait` to describe the intended mobile layout. The device may not honor an orientation request.\n- `input` is optional and defaults to `[]`. Include every supported input from `keyboard`, `mouse`, `touch`, and `gamepad`. Do not claim an input until the game is usable with it.\n- `visibility` is optional and defaults to `public`. Use `public` for catalog eligibility or `unlisted` for access by direct link only.\n- `network` is optional and defaults to `[]`. List every external host contacted or loaded by the game, without scheme, port, path, query, or fragment, for example `api.example.com`. If an external host is missing, the browser blocks the request. Keep the array empty when the game uses only its own files and Caisual services.\n- `isolated` is optional and defaults to `false`. Use `true` only when the game requires shared memory or threaded WebAssembly. Every external host in `network` must then send headers compatible with cross-origin isolation.\n- `requires` is optional and defaults to `{ "webgl2": false, "webgpu": false, "wasm": false, "threads": false, "memoryMb": null, "performance": "light" }`. Declare the minimum capabilities the game truly needs to run. For example, a game with a WebGPU renderer and a WebGL2 fallback declares only `webgl2`. Set `threads` together with `isolated: true`. `memoryMb` accepts `null` or a multiple of 256 from 512 to 32768. Use `light`, `medium`, or `heavy` for the expected performance load.\n- `players` is optional and defaults to `{ "min": 1, "max": 1 }`. Both values are integers from 1 to 24 and `max` must be at least `min`. Set the range that a room needs before play can start.\n- `lobby` is optional and defaults to `false`. Use `true` when players must choose roles or teams, mark themselves ready, and wait for the host to start. With `false`, play starts when the first player enters and later players may join in progress.\n- `persistent` is optional and defaults to `false`. Use `true` when room members must be able to return with the same code after disconnecting, including while the game is already playing. Persistent rooms expire after 30 days without activity.\n- `spectators` is optional and defaults to `{ "delayMs": 3000 }`. Use `false` to disable watching, `true` for the default three-second delay, or `{ "delayMs": N }` to choose an integer delay from 0 to 30000 milliseconds.\n- `roles` is optional and defaults to `[]`. Each entry has an `id` of 1 to 32 lowercase letters, digits, or internal hyphens, a `min` integer from 0 to 24, and an optional `max` in the same range. Rooms enforce these capacities in the lobby.\n- `teams` is optional and defaults to `null`. An object has `min` and `max` integers from 2 to 24, with `max` at least `min`. Rooms balance players who do not choose a team.\n- `voice` is optional and defaults to `none`. Use `room` so everyone in the room can hear each other, `team` to restrict voice to teammates, or `proximity` when `server.js` sets the gain between player pairs. Use `none` to disable voice.\n- `modes` is optional and defaults to `[]`. A mode has a unique `id` using 1 to 32 lowercase letters, digits, or internal hyphens. It may have `matchmaking` with `key`, an array of 1 to 8 unique field names, and `timeoutMs`, an integer from 1,000 to 300,000. Each field name uses 1 to 32 lowercase letters, digits, or hyphens and starts with a letter or digit.\n\nThe CLI prints every manifest error in one run. Fix every listed field and rule before retrying.\n\nWhen `requires` is not at its default, the game page checks the player\'s browser and device. It reports that the game is compatible, may run slowly, or is missing a required capability. The Play button always remains active so the player can still try the game.\n\n## client/index.html\n\n`index.html` must be at the root of `client/`. Use relative URLs such as `./game.js` or `assets/sprite.png`. Do not use root-relative URLs such as `/game.js`, and do not use parent paths that leave the published `client/` tree.\n\nTo use player identity, saves, and leaderboards, import the kit from `/__caisual/kit/v1.js` as shown in [kit.md](./kit.md). The path `/__caisual/` is reserved: do not put game files under it.\n\nDo not register a service worker. The game runs in an iframe on its own origin inside `caisual.com`. Test it without assuming access to the parent page, parent cookies, or files outside `client/`.\n\nWhen `voice` is not `none`, the portal grants microphone access to the game iframe. The browser still asks the player for permission when the game calls `room.voice.join()`. Call it from a button click or another user gesture, not automatically when the page loads.\n\n## Multiplayer server\n\nAdd `server.js` beside `caisual.json` when the game uses rooms. It is the ESM entry point and must have an `export default`. It may import local files such as `./logic/ships.js`, including `.js`, `.ts`, and `.json` files, and npm packages installed in the game folder. The CLI bundles these imports into one file both when publishing and when starting `caisual dev`.\n\nA minimal relay server looks like this:\n\n```js\nimport { defineGame } from \'@caisual/kit/server\';\n\nexport default defineGame({\n onMessage(room, player, message) {\n room.broadcast(message);\n },\n});\n```\n\nThe file may define the optional room callbacks documented in [kit.md](./kit.md). Server code runs without Node.js APIs or network access. Dynamic `import()`, `require()`, and CommonJS exports are not supported. Use only pure JavaScript packages, such as a noise or vector library. A package that needs an HTTP client is not suitable. The `network` field in `caisual.json` controls only requests made by the browser client.\n\nThe bundled `server.js` may be at most 1,000,000 bytes. Room state must remain plain JSON and may be at most 256 KB when serialized. Each incoming player message may be at most 16 KB, and each connection may send at most 20 messages per second. Room save values may be at most 128 KB.\n\nPublish a multiplayer game with the same `npx @caisual/cli publish` command. When imports need bundling, the CLI prints `Bundling server.js (N KB).` The uploaded file is the bundle: the CLI validates it, declares its size and SHA-256 digest, and uploads it separately from browser files. The portal validates the stored bundle again before making the new game version current.\n\nIf the portal finds an invalid `server.js`, the command prints `The multiplayer server could not be published.` followed by diagnostic hints. The failed version is kept for diagnosis but never becomes current. If the game already has a working version, players continue to receive that version. Fix the reported problem and publish again to create a new version.\n\n## Test locally\n\nRun the local preview from the game folder before publishing:\n\n```sh\nnpx @caisual/cli dev\n```\n\nYou can pass a game folder and choose another port:\n\n```sh\nnpx @caisual/cli dev ./my-game --port 8790\n```\n\nThe command prints a portal URL and a game URL. Open the portal URL. It loads the game in an iframe with the same handshake used after publishing, so `c.connected` is `true`. Player identity, saves, leaderboards, daily data, invitations, and rooms all use local data. Each new browser tab gets a different guest identity, while reloading one tab keeps that tab\'s identity.\n\nWhen `server.js` exists, room data is stored as JSON under `.caisual-dev/` in the game folder. Without `server.js`, the game remains single player and attempts to create a room return `no_server`.\n\nPress Ctrl+C in the terminal to stop the preview. No account or publish key is required.\n\n## Limits\n\n- At most 2,000 files per version.\n- At most 50,000,000 bytes per file.\n- At most 200,000,000 bytes for all files in one version.\n- At most 1,000,000 bytes for `server.js`.\n- Dotfiles, dot-directories, and directories named `node_modules` are ignored.\n- Symbolic links and other non-regular files are rejected.\n\nReduce or split files that exceed the per-file limit. Remove generated files that the browser does not need.\n\n## Publish\n\nUse the key supplied by the creator. Set it in the environment so it does not enter shell history as a command-line flag:\n\n```sh\nexport CAISUAL_KEY=\'ck_...\'\nnpx @caisual/cli publish\n```\n\nRun the command from the game folder, or pass the folder path after `publish`. For local portal development only, set `CAISUAL_ORIGIN` to the local HTTP origin.\n\nThe CLI validates the folder, computes every file size and SHA-256 digest, creates a new version, uploads the files, completes the version, and prints the game URL. The stable URL is `https://caisual.com/g/<id>`.\n\nBefore contacting the portal, the CLI scans browser files for common WebGL2, WebGPU, WebAssembly, and shared-memory signatures. A possible mismatch is printed to stderr with a `Warning:` prefix and never blocks publishing. Correct an accurate warning by declaring the minimum matching `requires` field, and set `isolated: true` for shared memory. If the signature belongs to unused code, remove that code from the published client bundle.\n\nThe first games from a new creator are reviewed before they can appear in the public catalog. Their stable links still work while review is pending.\n\n## Update, unlist, or delete\n\nTo update a game, change its files without changing `id`, then run `npx @caisual/cli publish` again. This creates a new version and keeps the same stable game URL.\n\nTo remove a game from the catalog, set `visibility` to `unlisted` and publish, or change visibility from the dashboard. To delete a game, use the dashboard. Deletion is permanent and its ID cannot be reused.\n\n## Common errors\n\n- `CAISUAL_KEY is required`: export the creator\'s key in the same shell before publishing.\n- `The publish API key is not valid`: create a new key in the account dashboard if the old key expired or was revoked.\n- `caisual.json is not valid`: read every reported field and rule, fix all of them, then retry.\n- `client/index.html: file not found`: place `index.html` directly under `client/`, not in a nested build folder.\n- `referenced file not found`: make sure `cover` and every screenshot path match a file under `client/`, including letter case.\n- `file is larger than 50 MB`: compress, reduce, or split the asset and update its references.\n- `upload failed` or a temporary portal error: keep the files unchanged and retry the same publish command. The CLI retries temporary upload failures automatically.\n- `The multiplayer server could not be published`: read every diagnostic hint, fix `server.js`, and publish again. The failed version does not replace the current one.\n- An external browser request works locally but fails after publishing: add its host to `network` and publish a new version. Server code cannot make outbound network requests.\n- A threaded WebAssembly game fails to start: set `isolated` to `true` and verify that every declared external host supports cross-origin isolation.\n';
|
|
485
549
|
|
|
486
550
|
// ../../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";
|
|
551
|
+
var kit_default = "# Caisual game kit\n\nThe kit gives a published game a stable player identity, cloud saves, leaderboards, a daily challenge seed, and multiplayer rooms with server-owned state.\n\nThe kit is a single JavaScript module with no dependencies. It never touches the page: the game keeps its own rendering, input, and UI.\n\n## Load the kit\n\nEvery published game can import the kit from its own origin, without a bundler and without an npm install:\n\n```html\n<script type=\"module\">\n import { caisual } from '/__caisual/kit/v1.js';\n\n const c = await caisual.connect();\n console.log(c.player.name);\n</script>\n```\n\nGames built with a bundler can install the same module from npm:\n\n```sh\nnpm install @caisual/kit\n```\n\n```js\nimport { caisual } from '@caisual/kit';\n```\n\nBoth forms expose the same API. The module also sets `globalThis.caisual` for classic scripts that load it first.\n\nThe path `/__caisual/` is reserved on every game origin. Do not put game files under it.\n\n## Connect\n\n```js\nconst c = await caisual.connect();\n```\n\n`connect()` completes when the game is running inside caisual.com and has received its player identity, or after a short timeout when it is not. Calling it again returns the same promise.\n\n- `c.connected` is `true` inside caisual.com and `false` when the game runs on its own, for example from a local folder during development or when its files are copied elsewhere.\n- `c.player` is `{ id, name, guest }`. `id` is stable for the player across sessions and across every version of the game. `name` is the account username, or `\"Guest\"`. `guest` is `true` for players without an account. When a guest later signs in, saves and scores stay attached to the same `id`.\n- When not connected, `c.player` is `{ id: \"local\", name: \"Guest\", guest: true }`.\n\nDo not store the ticket or reimplement the handshake. The kit handles identity, renewal, and retries.\n\n## Daily challenge\n\n```js\nc.daily.day; // \"2026-09-04\", the current UTC day\nc.daily.seed; // unsigned 32-bit integer, identical for every player on that day\nconst r = c.daily.random(); // number in [0, 1), deterministic from the seed\n```\n\n`c.daily.random()` is a deterministic generator initialized from `c.daily.seed`. Every `connect()` starts the sequence from the beginning, so two players who call it the same number of times get the same values. Use it to build the level of the day.\n\n`c.time.now()` returns milliseconds aligned with the portal clock. Prefer it to `Date.now()` for anything that must agree with the current day.\n\nWhen not connected, `day` comes from the local clock and `seed` from the local hostname, so a game copied elsewhere still runs.\n\n## Saves\n\nEach player has up to 32 saves per game. A save is any JSON value up to 256 KB when serialized.\n\n```js\nawait c.save.set('slot1', { level: 3, coins: 120 }); // -> { key, bytes, updatedAt }\nconst data = await c.save.get('slot1'); // -> the value, or null\nawait c.save.remove('slot1');\nconst saves = await c.save.list(); // -> [{ key, bytes, updatedAt }]\n```\n\n- Keys use 1 to 32 characters: lowercase letters, digits, `_` or `-`, starting with a letter or digit.\n- `updatedAt` is a millisecond timestamp.\n- Saves are per player and per game. Another game cannot read them.\n- When not connected, saves go to the browser's local storage on the game origin.\n\nErrors reject the promise with an `Error` whose `code` is one of `invalid_request`, `not_found`, `save_limit`, `payload_too_large`, `rate_limited`, `invalid_ticket`, `internal_error`, or `offline`.\n\n## Leaderboards\n\nA leaderboard is identified by a board id chosen by the game. Scores are non-negative integers and higher is better. Each player keeps one entry per board, and one per board per day for daily boards: the best score is kept.\n\n```js\nconst result = await c.board.submit('main', 1234);\n// -> { accepted: true, best: 1234, rank: 7, day: null }\n\nconst daily = await c.board.submit('main', 1234, { daily: true });\n// -> { accepted: true, best: 1234, rank: 7, day: \"2026-09-04\" }\n\nconst top = await c.board.top('main', { daily: true, limit: 10 });\n// -> { day: \"2026-09-04\", entries: [{ rank, name, score, guest, me }], me: { rank, score } | null }\n```\n\n- Board ids use the same format as save keys.\n- `submit` never rejects because of connectivity. When the game is not connected it resolves `{ accepted: false, reason: \"offline\" }`.\n- `best` is the score kept for this player after the submission, which can be higher than the submitted one.\n- `rank` counts players with a strictly higher score. Ties are ordered by who reached the score first.\n- Accounts and guests are ranked separately. `top()` returns account players by default; pass `guests: true` to list guests instead. `me` always refers to the current player within their own category, even beyond `limit`.\n- `limit` is 1 to 100 and defaults to 10.\n- Scores submitted from the browser are recorded as unverified. A room server can submit verified scores.\n\n## Device\n\n`c.device` contains the browser and device report collected while `connect()` runs:\n\n```ts\ninterface DeviceReport {\n webgl2: boolean;\n webgpu: boolean;\n wasm: boolean;\n threads: boolean;\n isolated: boolean;\n gpu: 'hardware' | 'software' | 'none';\n memoryMb: number | null;\n cores: number | null;\n mobile: boolean;\n tier: 'low' | 'mid' | 'high';\n}\n```\n\nUse capability fields to choose a renderer, then use `tier` to reduce pixel ratio and quality on smaller devices:\n\n```js\nconst renderer = c.device.webgpu\n ? createWebGpuRenderer()\n : createWebGl2Renderer();\n\nconst pixelRatio = c.device.tier === 'high' ? devicePixelRatio : 1;\nconst quality = c.device.tier === 'low' ? 'low' : 'high';\nrenderer.configure({ pixelRatio, quality });\n```\n\nThe probe takes at most 1.5 seconds. `memoryMb` and `cores` are `null` when the browser does not expose them. The report stays in the browser and is not saved or sent to Caisual.\n\n## Rooms\n\nA room brings players into the same running game. Creating and joining require a published `server.js`; single-player games can ignore `c.room`.\n\n```js\nconst c = await caisual.connect();\n\nc.room.invited; // invitation code from the game page, or null\n\nconst room = await c.room.create({ mode: null });\n// Or join the invitation that opened the game:\nconst invitedRoom = await c.room.join();\n// Or enter a code supplied by the player:\nconst codedRoom = await c.room.join('ABC234');\n\nroom.code;\nroom.seed; // unsigned 32-bit integer fixed for this room\nroom.invite(); // { code: \"ABC234\", url: \"https://caisual.com/r/ABC234\" }\n```\n\nPass a mode id from the manifest to `create({ mode })`, or `null` when the game has no modes. `join()` uses `c.room.invited`; without an invitation, pass the six-character code explicitly. Show the URL returned by `invite()` in a share button or copy action.\n\n### Crew\n\nThe kit automatically reports the player's current room to the Caisual portal, so the player's friends can join with one click. The game does not need to send or handle anything for this. There is no `c.crew` API in this version.\n\n### Matchmaking\n\nUse `c.room.match()` to find players who requested the same mode and key. The key must contain exactly the fields declared by that mode's `matchmaking.key` in `caisual.json`.\n\n```js\nconst room = await c.room.match({\n mode: 'daily',\n key: { day: c.daily.day, stage: 3 },\n onWaiting({ players, min, max }) {\n showQueue(`${players}/${max} players, ${min} required`);\n },\n});\n```\n\nA room opens as soon as the queue reaches `players.max`. When `matchmaking.timeoutMs` expires, it also opens if at least `players.min` players are waiting. Otherwise the promise rejects with `no_match`, and the game should offer the player another option. A new search first tries to fill a matching room that is already open and can still accept players.\n\nPass an `AbortSignal` as `signal` to let the player cancel a search. Cancellation rejects with `cancelled`. For lobbies with players who may not know each other, have the game start automatically after everyone is ready:\n\n```js\nroom.onPlayers((players) => {\n if (players.every((player) => player.ready) && room.you === room.host) room.start();\n});\n```\n\nRoom status is one of:\n\n- `lobby`: players are joining and choosing their setup.\n- `countdown`: the lobby has accepted `start()` and play begins at the announced server time.\n- `playing`: the game server is running the match.\n- `ended`: the match or connection has ended. `room.result` contains the result last reported by the room. A definitive connection closure uses `{ closed: 4003 }`, `{ closed: 4004 }`, `{ closed: 4005 }`, or `{ closed: 4006 }`.\n\nThe current lobby data is available directly:\n\n```js\nroom.players; // [{ id, name, guest, role, team, ready, connected }]\nroom.you; // this player's id\nroom.host; // the current host's id, or null\n\nroom.ready(true);\nroom.setRole('captain');\nroom.setTeam(1);\n\nif (room.you === room.host) room.start();\n```\n\n`ready`, role, team, and `start()` are lobby actions. Starting requires the host, every connected player to be ready, and the player, role, and team minimums from the manifest. Calling `start()` begins a three-second countdown. A role or team change clears that player's ready state. The built-in `spectator` role is still a player slot for setups such as a shared screen with phone controllers. Use `watch()` for someone who only observes and does not occupy a player slot.\n\nThe server owns room state. Read it and react to updates, but do not assign to it or mutate nested values from the browser:\n\n```js\ndraw(room.state);\n\nconst stopState = room.onState((state, tick, serverTime) => {\n draw(state);\n});\n\nconst stopPlayers = room.onPlayers((players) => updateLobby(players));\nconst stopStatus = room.onStatus((status, result, at) => showStatus(status, result, at));\n\nstopState();\nstopPlayers();\nstopStatus();\n```\n\n`room.tick` identifies the latest state. The kit applies structural updates in order and requests a full state automatically if an update does not match the current tick. `room.serverTime()` returns milliseconds aligned with the room clock and is kept current by a ping every five seconds.\n\nSend JSON input to `onMessage` in the server definition, and receive JSON sent or broadcast by the server:\n\n```js\nroom.send({ type: 'fire', target: 3 });\n\nconst stopMessages = room.onMessage((message) => {\n showEvent(message);\n});\n```\n\nThe kit numbers outgoing inputs in increasing order. It automatically reconnects temporary failures with delays of 1, 2, 4, then 8 seconds, for at most the room's 60-second grace period. Each attempt gets a fresh room token. A successful reconnect replaces local state with a full server state. Inputs sent while reconnecting throw an error with `code: \"offline\"`.\n\nCall `room.leave()` for an intentional departure. The kit does not reconnect after leaving, being kicked, the room ending, the published version closing, or the same player opening the room in another tab.\n\nRoom creation, joining, and matchmaking reject with an `Error` carrying a stable `code`. Common codes are `invalid_request`, `no_server`, `no_match`, `cancelled`, `room_not_found`, `room_full`, `room_playing`, `room_ended`, `rate_limited`, `invalid_ticket`, `internal_error`, and `offline`. `no_server` means the published game has no multiplayer server. When `c.connected` is `false`, `create`, `join`, and `match` reject with `offline`.\n\nEvery `onState`, `onPlayers`, `onStatus`, and `onMessage` call returns a function that removes that listener.\n\n### Spectators\n\nUse `c.room.watch(code)` to observe a room without joining it as a player:\n\n```js\nconst view = await c.room.watch('ABC234');\n\ndraw(view.state);\nview.onState((state) => draw(state));\nview.onPlayers((players) => updateRoster(players));\nview.onStatus((status, result) => showStatus(status, result));\nview.onMessage((message) => showEvent(message));\n\nview.leave();\n```\n\nThe returned `Spectate` object exposes `state`, `tick`, `seed`, `status`, `players`, `host`, `code`, `result`, `delayMs`, the four listeners shown above, `serverTime()`, and `leave()`. It receives the room's public state, snapshots and updates, player list, status, and messages broadcast by `server.js`. The kit repairs a missed update automatically and reconnects temporary failures for the same 60-second grace period used by players.\n\nPublic room events are delayed by `delayMs`, which defaults to 3000 milliseconds. A game can set `\"spectators\": { \"delayMs\": N }` in `caisual.json`, where `N` is from 0 to 30000, or set `\"spectators\": false` to disable watching.\n\nA spectator has no `you`, `invite()`, `send()`, or voice API. Watching does not add anyone to `room.players`, does not affect roles, teams, player minimums, the host, or room lifetime, and is not visible to `server.js`. `watch()` can reject with `room_not_found`, `room_ended`, `spectators_disabled`, `spectators_full`, `rate_limited`, `offline`, or `invalid_request`.\n\n## Voice\n\nEvery room has a `room.voice` object. Voice is disabled by default and is enabled with the manifest's `voice` field. A game should offer an explicit control because `join()` must be called from a click or another user gesture so the browser can start audio and, when publishing, request microphone permission.\n\n```js\nconst micButton = document.querySelector('#mic');\nconst voiceList = document.querySelector('#voice-list');\n\nfunction renderVoice(peers = room.voice.peers) {\n voiceList.replaceChildren(...peers.map((peer) => {\n const item = document.createElement('li');\n const player = room.players.find((entry) => entry.id === peer.id);\n item.textContent = `${player?.name ?? peer.id}: ${\n peer.speaking ? 'speaking' : peer.muted ? 'muted' : 'quiet'\n }`;\n return item;\n }));\n micButton.textContent = room.voice.state === 'off'\n ? 'Join voice'\n : !room.voice.mic ? 'Listening' : room.voice.muted ? 'Unmute' : 'Mute';\n}\n\nmicButton.addEventListener('click', async () => {\n if (room.voice.state === 'off') await room.voice.join();\n else if (room.voice.mic) room.voice.mute(!room.voice.muted);\n renderVoice();\n});\n\nroom.voice.onPeers(renderVoice);\nroom.voice.onState(() => renderVoice());\nrenderVoice();\n```\n\n`room.voice.mode` is `none`, `room`, `team`, or `proximity`. In `room` mode, every participant in voice can hear every other participant. In `team` mode, players hear only their team. In `proximity` mode, the room server controls the gain between participants. Call `room.voice.join({ mic: false })` to listen without opening or publishing a microphone. Spectators join in listening mode when they call `join()` without options. A spectator that calls `join({ mic: true })` receives the `spectator` error.\n\n`room.voice.state` is `off`, `joining`, `on`, or `reconnecting`. `room.voice.mic` is `true` while the local player is publishing. `room.voice.muted` and `room.voice.speaking` describe the local microphone. `room.voice.peers` contains the other voice participants as `{ id, mic, muted, speaking, volume, gain }`. A listening participant has `mic: false`, `muted: true`, and `speaking: false`. `volume` is the local setting and `gain` is the value from the room server. Use `room.voice.setVolume(playerId, volume)` with a value from 0 to 1 to change only local playback.\n\n`room.voice.onPeers(listener)` runs when participants, microphone state, mute state, speaking state, volume, or gain changes. `room.voice.onState(listener)` reports connection state changes. Both return a function that removes the listener.\n\nCall `room.voice.leave()` to stop publishing or listening without leaving the room. `room.voice.mute()` requires an active published microphone and otherwise throws `not_publishing`. `room.leave()` and the end of the room stop voice automatically.\n\n`join()` rejects with an `Error` carrying one of these stable codes: `voice_disabled`, `permission_denied`, `unsupported`, `spectator`, `offline`, or `voice_error`. Voice can reconnect after a temporary room or media connection failure. The state becomes `reconnecting` while the kit retries.\n\n## Server\n\nPut `server.js` next to `caisual.json` and publish it with the game. See [publish.md](./publish.md#multiplayer-server) for the file rules, validation, and publishing flow.\n\n```js\nimport { defineGame } from '@caisual/kit/server';\n\nexport default defineGame({\n tickRate: 20, // 0 runs only in response to events\n onCreate(room) {},\n onStart(room) {},\n onJoin(room, player) {},\n onLeave(room, player, reason) {}, // \"left\", \"timeout\", or \"kicked\"\n onMessage(room, player, message) {},\n onTick(room, deltaSeconds) {},\n onEnd(room) {},\n});\n```\n\nAll callbacks are optional. A player is `{ id, name, guest, role, team, connected }`. The room object provides:\n\n```js\nroom.id;\nroom.seed;\nroom.mode;\nroom.status;\nroom.tick;\nroom.tickRate;\nroom.result;\nroom.state;\nroom.players;\nroom.host;\n\nroom.broadcast(message);\nroom.send(playerOrId, message);\nroom.kick(playerOrId);\nroom.setRole(playerOrId, role);\nroom.setTeam(playerOrId, team);\nroom.end(result);\n\nawait room.save('round', value);\nawait room.load('round');\nawait room.shared.get('ship_abc');\nawait room.shared.set('ship_abc', value);\nawait room.shared.delete('ship_abc');\nawait room.shared.list('ship_');\nawait room.shared.increment('visits', 1);\nroom.schedule(milliseconds, 'methodName', payload);\nroom.board.submit(playerOrId, 'main', score, { daily: true });\n\nroom.daily.day;\nroom.daily.seed;\nroom.time.now();\n\nroom.voice.mode;\nroom.voice.setGain(listener, speaker, 0.25);\nroom.voice.setProximity(playerA, playerB, 0.5);\n```\n\nSet `room.state` in `onCreate`, then mutate it only in server callbacks. It must remain plain JSON and may be at most 256 KB when serialized. `broadcast` sends a JSON message to everyone; `send` targets one player. `end` records a JSON result and ends the room. Room saves use keys with the same format as player save keys and values up to 128 KB. `schedule` names a method on the definition so it can run even after a quiet room resumes. Scores submitted through `room.board` are verified.\n\nThe browser can change its role or team only while the room is in `lobby`. During a match, the server decides when a player changes role or team with `room.setRole` and `room.setTeam`. Both methods accept a player object or id and immediately update `room.players` for every client.\n\n```js\nonMessage(room, player, message) {\n if (message?.swap === 'captain') {\n room.setRole(player, 'captain');\n }\n},\n```\n\n`room.daily.seed` is shared by every room for the game on the current UTC day. `room.seed` is fixed for one room and is identical on the server and clients, so rooms created on the same day can generate different maps.\n\n### Shared game store\n\n`room.shared` is a server-only JSON key/value store shared by every room of the same game. It is useful when one room must leave data for another room, while `room.save` remains private to one room.\n\nThe following server leaves a ship when a room ends, then loads every previously left ship when another room is created. The room id suffix is used because shared-store keys follow the save-key format.\n\n```js\nexport default defineGame({\n tickRate: 0,\n\n async onCreate(room) {\n const keys = await room.shared.list('ship_');\n room.state = {\n ships: await Promise.all(keys.map((key) => room.shared.get(key))),\n };\n },\n\n async onEnd(room) {\n const roomSuffix = room.id.split('.')[1];\n await room.shared.set('ship_' + roomSuffix, {\n position: room.state.position,\n cargo: room.state.cargo,\n });\n },\n});\n```\n\nThe five methods are asynchronous:\n\n```js\nconst value = await room.shared.get(key); // JSON value, or null\nawait room.shared.set(key, value); // last writer wins\nawait room.shared.delete(key);\nconst keys = await room.shared.list(prefix); // sorted, up to 1024\nconst total = await room.shared.increment(key, 1); // atomic, defaults to 1\n```\n\nKeys contain 1 to 32 lowercase letters, numbers, underscores, or hyphens. Values may be up to 64 KB when serialized, and each game may keep up to 1024 keys. Each room may perform up to 120 shared-store operations per minute. `increment` treats a missing key as zero and rejects unless the existing value, amount, and result are safe integers.\n\nUse the store in `onCreate`, `onStart`, `onEnd`, `onMessage`, or a `schedule` handler. Do not call it on every tick: each call waits for a remote operation, and the CPU budget uses elapsed wall-clock time. Browser clients cannot access this store. Send only the data they need with `room.broadcast` or `room.send`.\n\nFailures reject with an `Error` carrying `store_invalid_key`, `store_too_large`, `store_full`, `store_not_integer`, `store_unavailable`, or `store_rate_limited` in `code`.\n\n`room.voice.setGain(listener, speaker, gain)` controls how much one listener hears one speaker. It is directional, limited to the range from 0 to 1, and rounded to two decimal places. For example, the following setup lets the captain hear everyone while each crew member hears only the captain:\n\n```js\nconst captain = room.players.find((player) => player.role === 'captain');\nconst crew = room.players.filter((player) => player.id !== captain.id);\n\nfor (const speaker of room.players) {\n room.voice.setGain(captain, speaker, 1);\n}\nfor (const listener of crew) {\n for (const speaker of room.players) {\n room.voice.setGain(listener, speaker, speaker.id === captain.id ? 1 : 0);\n }\n}\n```\n\n`room.voice.setProximity(a, b, gain)` is the symmetric shortcut for setting both directions. Both methods work in `room`, `team`, and `proximity` modes, and do nothing in `none`. In `team` mode, gains remain inside the team and cannot make a player hear another team.\n\nFor position-based audio, update the symmetric gain between players from server-owned positions:\n\n```js\nexport default defineGame({\n tickRate: 20,\n onTick(room) {\n for (const a of room.players) {\n for (const b of room.players) {\n if (a.id >= b.id) continue;\n const pa = room.state.positions[a.id];\n const pb = room.state.positions[b.id];\n const distance = Math.hypot(pa.x - pb.x, pa.y - pb.y);\n room.voice.setProximity(a, b, Math.max(0, 1 - distance / 20));\n }\n }\n },\n});\n```\n\n### Sleeping and cost\n\nPrefer `tickRate: 0` for turn based and party games. A room with a tick loop sleeps automatically after 30 seconds without player input or state changes and wakes on the next game message or player joining. Automatic ping and resync messages do not count as player input. A match with no player input for 10 minutes ends with `{ error: 'idle' }`. Timers set with `schedule` and the countdown keep working while the room sleeps.\n\n### CPU budget\n\nEvery `onTick` and `onMessage` call is measured. Twenty consecutive calls above 100 ms end the room with `{ error: 'cpu_budget' }`. If the average over 50 ticks is above 20 ms, the effective `tickRate` is halved, down to a minimum of 5, and clients receive an `error` message with code `tick_rate_reduced`.\n\n`room.tickRate` starts at the definition's `tickRate` and always reports the current effective frequency. `deltaSeconds` follows that frequency, so a fixed-step simulation must accumulate `deltaSeconds` instead of counting ticks. Measurement uses elapsed wall-clock time, so a slow `await` inside a callback also counts. `room.result` is `null` until the room ends, then contains the final game or automatic error result and is available inside `onEnd`.\n\n### Persistent rooms\n\nSet `\"persistent\": true` in `caisual.json` for a room that must survive long breaks. It does not use the normal inactivity ending rule and does not end when every player disconnects. Players remain members until they call `room.leave()` or the server removes them with `room.kick()`. They can use the same room code to return while the game is already playing. The code remains valid while the room lives, and absent members remain in `room.players` with `connected: false`.\n\nA persistent room ends when the server calls `room.end()`, after 30 days without player input, entry, or a state change with `{ error: 'expired' }`, or after five minutes without any members. Consider storing `room.code` with `c.save.set()` and offering a Resume action. A persistent room incurs cost only while it is awake.\n\n## Limits\n\n- 120 requests per minute per player. Beyond that the kit rejects with `rate_limited`; wait and retry.\n- Saves: 32 keys per player per game, 256 KB per value.\n- Scores: safe integers from 0 upward.\n- Room state: 256 KB of plain JSON.\n- Room messages: 16 KB each and 20 messages per second per connection.\n- Spectators: 100 per room, with a configured delay from 0 to 30 seconds.\n- Voice supports audio only and one voice channel per room.\n- Voice control messages: 64 KB each and 30 operations per 10 seconds per connection. Voice traffic is not counted against the room's message limits.\n- Room save values: 128 KB each.\n- Shared game store: 64 KB per JSON value, 1024 keys per game, and 120 operations per minute per room.\n\n## Development\n\nRun `npx @caisual/cli dev` from the game folder, then open the printed portal URL. The preview supplies the normal handshake, so `c.connected` is `true` and the game receives a local guest identity. Saves, leaderboards, daily data, invitations, and rooms work locally. Opening the portal URL in more browser tabs creates more local players, which makes multiplayer testing possible without publishing.\n\nWhen building the client with a bundler, remember that `client/` is served as-is. Configure Vite, esbuild, or another bundler to write into that folder, for example `vite build --outDir client`, and use relative paths such as `base: './'`.\n\nKeep loading the kit from the `<script type=\"module\">` shown at the beginning of this guide, using `/__caisual/kit/v1.js`.\n\nIf the game has `server.js`, room state is handled locally and stored under `.caisual-dev/` in the game folder. If it has no `server.js`, room creation rejects with `no_server` and the single-player APIs still work.\n\nOpening `client/index.html` from a plain static server still uses standalone mode: `c.connected` is `false`, saves use local storage, `submit` returns `accepted: false`, leaderboards are empty, the daily seed is local, and room creation and joining reject with `offline`. The rest of the game logic does not need a different code path.\n\nAfter publishing with `npx @caisual/cli publish`, open the game from its caisual.com page: `c.connected` becomes `true` and every call goes to the portal.\n\n## Manifest\n\nNo manifest field is required for identity, saves, leaderboards, or the daily challenge. For rooms, set `players` to the supported range and use `lobby`, `persistent`, `spectators`, `roles`, `teams`, and `modes` to describe the setup and lifetime. Set `voice` to `room`, `team`, or `proximity` to enable the corresponding voice mode, or omit it for `none`. A single-player game can keep `players` at `{ \"min\": 1, \"max\": 1 }`, `lobby` at `false`, and omit `server.js`. See [publish.md](./publish.md#caisualjson) for every field and the publishing steps.\n";
|
|
552
|
+
|
|
553
|
+
// src/bundle.ts
|
|
554
|
+
import { promises as fs } from "node:fs";
|
|
555
|
+
import { builtinModules } from "node:module";
|
|
556
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
557
|
+
import { build } from "esbuild";
|
|
558
|
+
var CliError = class extends Error {
|
|
559
|
+
exitCode;
|
|
560
|
+
constructor(exitCode, message) {
|
|
561
|
+
super(message);
|
|
562
|
+
this.name = "CliError";
|
|
563
|
+
this.exitCode = exitCode;
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
function formatBuildError(root, error) {
|
|
567
|
+
const location = error.location;
|
|
568
|
+
if (location === null) return `- server.js:?: ${error.text}`;
|
|
569
|
+
const rawFile = location.file === "" ? "server.js" : location.file;
|
|
570
|
+
const file = (isAbsolute(rawFile) ? relative(root, rawFile) : rawFile).replaceAll("\\", "/");
|
|
571
|
+
return `- ${file}:${location.line}: ${error.text}`;
|
|
572
|
+
}
|
|
573
|
+
function isSandboxImport(error) {
|
|
574
|
+
const match = /^Could not resolve "([^"]+)"/.exec(error.text);
|
|
575
|
+
if (match === null) return false;
|
|
576
|
+
const specifier = match[1];
|
|
577
|
+
const bareSpecifier = specifier.startsWith("node:") ? specifier.slice("node:".length) : specifier;
|
|
578
|
+
const packageName = bareSpecifier.split("/")[0];
|
|
579
|
+
return specifier.startsWith("node:") || specifier.startsWith("cloudflare:") || builtinModules.includes(bareSpecifier) || builtinModules.includes(packageName);
|
|
580
|
+
}
|
|
581
|
+
function buildErrors(error) {
|
|
582
|
+
if (typeof error !== "object" || error === null || !("errors" in error)) return null;
|
|
583
|
+
const errors = error.errors;
|
|
584
|
+
return Array.isArray(errors) ? errors : null;
|
|
585
|
+
}
|
|
586
|
+
function normalizeDefaultExport(source) {
|
|
587
|
+
const exportBlock = /\nexport \{\n([\s\S]*?)\n\};\s*$/.exec(source);
|
|
588
|
+
if (exportBlock === null) return source;
|
|
589
|
+
const lines = exportBlock[1].split("\n");
|
|
590
|
+
let defaultName = null;
|
|
591
|
+
const remaining = lines.filter((line) => {
|
|
592
|
+
const match = /^\s*([$A-Z_a-z][$\w]*)\s+as\s+default,?\s*$/.exec(line);
|
|
593
|
+
if (match === null) return true;
|
|
594
|
+
defaultName = match[1];
|
|
595
|
+
return false;
|
|
596
|
+
});
|
|
597
|
+
if (defaultName === null) return source;
|
|
598
|
+
const prefix = source.slice(0, exportBlock.index);
|
|
599
|
+
const namedExports = remaining.length === 0 ? "" : `
|
|
600
|
+
export {
|
|
601
|
+
${remaining.join("\n")}
|
|
602
|
+
};`;
|
|
603
|
+
return `${prefix}${namedExports}
|
|
604
|
+
export default ${defaultName};
|
|
605
|
+
`;
|
|
606
|
+
}
|
|
607
|
+
async function bundleServer(root) {
|
|
608
|
+
const serverPath = join(root, "server.js");
|
|
609
|
+
let source;
|
|
610
|
+
try {
|
|
611
|
+
source = await fs.readFile(serverPath, "utf8");
|
|
612
|
+
} catch {
|
|
613
|
+
throw new CliError(2, "server.js: file not readable.");
|
|
614
|
+
}
|
|
615
|
+
const directValidation = validaServerJs(source);
|
|
616
|
+
if (directValidation.ok) return { source, bundled: false };
|
|
617
|
+
let output;
|
|
618
|
+
try {
|
|
619
|
+
const result = await build({
|
|
620
|
+
entryPoints: [serverPath],
|
|
621
|
+
bundle: true,
|
|
622
|
+
format: "esm",
|
|
623
|
+
platform: "neutral",
|
|
624
|
+
target: "es2022",
|
|
625
|
+
external: ["@caisual/kit/server"],
|
|
626
|
+
mainFields: ["module", "main"],
|
|
627
|
+
conditions: ["workerd", "worker", "import", "default"],
|
|
628
|
+
minify: false,
|
|
629
|
+
treeShaking: true,
|
|
630
|
+
sourcemap: false,
|
|
631
|
+
legalComments: "none",
|
|
632
|
+
logLevel: "silent",
|
|
633
|
+
absWorkingDir: root,
|
|
634
|
+
write: false
|
|
635
|
+
});
|
|
636
|
+
const file = result.outputFiles[0];
|
|
637
|
+
if (file === void 0) throw new Error("esbuild did not produce server.js.");
|
|
638
|
+
output = normalizeDefaultExport(file.text);
|
|
639
|
+
} catch (error) {
|
|
640
|
+
const errors = buildErrors(error);
|
|
641
|
+
if (errors === null || errors.length === 0) {
|
|
642
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
643
|
+
throw new CliError(2, `server.js could not be bundled:
|
|
644
|
+
- server.js:?: ${detail}`);
|
|
645
|
+
}
|
|
646
|
+
const lines = errors.map((buildError) => formatBuildError(root, buildError));
|
|
647
|
+
if (errors.some(isSandboxImport)) {
|
|
648
|
+
lines.push("The server runs in a sandbox without Node.js APIs or network access: only pure JavaScript packages can be bundled.");
|
|
649
|
+
}
|
|
650
|
+
throw new CliError(2, `server.js could not be bundled:
|
|
651
|
+
${lines.join("\n")}`);
|
|
652
|
+
}
|
|
653
|
+
const bundledValidation = validaServerJs(output);
|
|
654
|
+
if (!bundledValidation.ok) {
|
|
655
|
+
throw new CliError(
|
|
656
|
+
2,
|
|
657
|
+
`The bundled server.js violates the server rules:
|
|
658
|
+
${bundledValidation.errori.map((error) => `- ${error}`).join("\n")}`
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
return { source: output, bundled: true };
|
|
662
|
+
}
|
|
488
663
|
|
|
489
664
|
// src/dev.ts
|
|
490
665
|
import { createHash as createHash2, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
491
|
-
import { promises as
|
|
666
|
+
import { promises as fs2 } from "node:fs";
|
|
492
667
|
import { createServer } from "node:http";
|
|
493
|
-
import { extname, join, relative, resolve, sep } from "node:path";
|
|
668
|
+
import { extname, join as join2, relative as relative2, resolve, sep } from "node:path";
|
|
494
669
|
|
|
495
670
|
// ../kit/dist/node.js
|
|
496
671
|
import { randomUUID } from "node:crypto";
|
|
@@ -499,6 +674,41 @@ import { dirname } from "node:path";
|
|
|
499
674
|
import { performance } from "node:perf_hooks";
|
|
500
675
|
import { createHash } from "node:crypto";
|
|
501
676
|
import { EventEmitter } from "node:events";
|
|
677
|
+
var NOMI_RISERVATI2 = [
|
|
678
|
+
"www",
|
|
679
|
+
"api",
|
|
680
|
+
"app",
|
|
681
|
+
"play",
|
|
682
|
+
"live",
|
|
683
|
+
"multi",
|
|
684
|
+
"cdn",
|
|
685
|
+
"assets",
|
|
686
|
+
"static",
|
|
687
|
+
"mail",
|
|
688
|
+
"mx",
|
|
689
|
+
"ns1",
|
|
690
|
+
"ns2",
|
|
691
|
+
"autodiscover",
|
|
692
|
+
"_dmarc",
|
|
693
|
+
"admin",
|
|
694
|
+
"login",
|
|
695
|
+
"account",
|
|
696
|
+
"auth",
|
|
697
|
+
"pay",
|
|
698
|
+
"secure",
|
|
699
|
+
"support",
|
|
700
|
+
"help",
|
|
701
|
+
"blog",
|
|
702
|
+
"status",
|
|
703
|
+
"dev",
|
|
704
|
+
"staging",
|
|
705
|
+
"test",
|
|
706
|
+
"caisual",
|
|
707
|
+
"shipz"
|
|
708
|
+
];
|
|
709
|
+
var RISERVATI2 = new Set(NOMI_RISERVATI2);
|
|
710
|
+
var MASSIMO_SPETTATORI = 100;
|
|
711
|
+
var RITARDO_SPETTATORI_MS2 = 3e3;
|
|
502
712
|
function isPlainObject(value) {
|
|
503
713
|
const prototype = Object.getPrototypeOf(value);
|
|
504
714
|
return prototype === Object.prototype || prototype === null;
|
|
@@ -652,9 +862,8 @@ var COSTANTI_SHA256 = [
|
|
|
652
862
|
function ruotaDestra(value, bits) {
|
|
653
863
|
return value >>> bits | value << 32 - bits;
|
|
654
864
|
}
|
|
655
|
-
function
|
|
656
|
-
const
|
|
657
|
-
const bytes = Array.from(testo, (carattere) => carattere.charCodeAt(0));
|
|
865
|
+
function hashSeed(testo) {
|
|
866
|
+
const bytes = Array.from(new TextEncoder().encode(testo));
|
|
658
867
|
const bitLength = bytes.length * 8;
|
|
659
868
|
bytes.push(128);
|
|
660
869
|
while (bytes.length % 64 !== 56) bytes.push(0);
|
|
@@ -717,18 +926,36 @@ function seedGiornata(slug, day) {
|
|
|
717
926
|
}
|
|
718
927
|
return h0 >>> 0;
|
|
719
928
|
}
|
|
929
|
+
function seedGiornata(slug, day) {
|
|
930
|
+
return hashSeed(`caisual:${slug}:${day}`);
|
|
931
|
+
}
|
|
932
|
+
function seedStanza(roomId) {
|
|
933
|
+
return hashSeed(`caisual:room:${roomId}`);
|
|
934
|
+
}
|
|
720
935
|
var CHIAVE_NUCLEO = "nucleo";
|
|
721
936
|
var PREFISSO_SAVE = "save:";
|
|
722
937
|
var LIMITE_FRAME = 16 * 1024;
|
|
723
938
|
var LIMITE_STATO = 256 * 1024;
|
|
724
939
|
var LIMITE_SAVE = 128 * 1024;
|
|
940
|
+
var LIMITE_DEPOSITO = 64 * 1024;
|
|
941
|
+
var LIMITE_OPERAZIONI_DEPOSITO = 120;
|
|
725
942
|
var GRAZIA_MS = 6e4;
|
|
726
943
|
var STANZA_VUOTA_MS = 5 * 6e4;
|
|
727
944
|
var COUNTDOWN_MS = 3e3;
|
|
728
945
|
var RIPOSO_TICK_MS = 3e4;
|
|
729
946
|
var INATTIVITA_MS = 10 * 6e4;
|
|
947
|
+
var SCADENZA_PERSISTENTE_MS = 30 * 24 * 60 * 6e4;
|
|
730
948
|
var CHIAVE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
|
|
949
|
+
var PREFISSO_CHIAVE = /^[a-z0-9_-]{0,32}$/;
|
|
731
950
|
var GAME_DEFINITION = /* @__PURE__ */ Symbol.for("@caisual/kit/game-definition");
|
|
951
|
+
var CODICI_DEPOSITO = /* @__PURE__ */ new Set([
|
|
952
|
+
"store_invalid_key",
|
|
953
|
+
"store_too_large",
|
|
954
|
+
"store_full",
|
|
955
|
+
"store_not_integer",
|
|
956
|
+
"store_unavailable",
|
|
957
|
+
"store_rate_limited"
|
|
958
|
+
]);
|
|
732
959
|
function record(value) {
|
|
733
960
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
734
961
|
}
|
|
@@ -751,6 +978,9 @@ function copiaGiocatore(player) {
|
|
|
751
978
|
function copiaJson(value) {
|
|
752
979
|
return JSON.parse(JSON.stringify(value));
|
|
753
980
|
}
|
|
981
|
+
function erroreConCodice(code, message) {
|
|
982
|
+
return Object.assign(new Error(message), { code });
|
|
983
|
+
}
|
|
754
984
|
function codiceIngresso(code) {
|
|
755
985
|
if (code === "room_not_found") return 4001;
|
|
756
986
|
if (code === "room_full") return 4e3;
|
|
@@ -763,6 +993,7 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
763
993
|
this.adattatore = adattatore;
|
|
764
994
|
this.dati = null;
|
|
765
995
|
this.frequenza = /* @__PURE__ */ new Map();
|
|
996
|
+
this.frequenzaDeposito = [];
|
|
766
997
|
this.kickRichiesti = /* @__PURE__ */ new Set();
|
|
767
998
|
this.voceGuadagniCambiati = /* @__PURE__ */ new Map();
|
|
768
999
|
this.fineRichiesta = null;
|
|
@@ -782,6 +1013,9 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
782
1013
|
get id() {
|
|
783
1014
|
return nucleo.richiediDati().id;
|
|
784
1015
|
},
|
|
1016
|
+
get seed() {
|
|
1017
|
+
return seedStanza(nucleo.richiediDati().id);
|
|
1018
|
+
},
|
|
785
1019
|
get mode() {
|
|
786
1020
|
return nucleo.richiediDati().mode;
|
|
787
1021
|
},
|
|
@@ -791,6 +1025,12 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
791
1025
|
get tick() {
|
|
792
1026
|
return nucleo.richiediDati().tick;
|
|
793
1027
|
},
|
|
1028
|
+
get tickRate() {
|
|
1029
|
+
return nucleo.richiediDati().tickRate;
|
|
1030
|
+
},
|
|
1031
|
+
get result() {
|
|
1032
|
+
return nucleo.richiediDati().result;
|
|
1033
|
+
},
|
|
794
1034
|
get state() {
|
|
795
1035
|
return nucleo.richiediDati().state;
|
|
796
1036
|
},
|
|
@@ -814,6 +1054,12 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
814
1054
|
kick(player) {
|
|
815
1055
|
nucleo.kickRichiesti.add(idGiocatore(player));
|
|
816
1056
|
},
|
|
1057
|
+
setRole(player, role) {
|
|
1058
|
+
nucleo.impostaRuoloDalServer(idGiocatore(player), role);
|
|
1059
|
+
},
|
|
1060
|
+
setTeam(player, team) {
|
|
1061
|
+
nucleo.impostaSquadraDalServer(idGiocatore(player), team);
|
|
1062
|
+
},
|
|
817
1063
|
end(result) {
|
|
818
1064
|
nucleo.richiediFine(result);
|
|
819
1065
|
},
|
|
@@ -823,6 +1069,23 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
823
1069
|
load(key) {
|
|
824
1070
|
return nucleo.caricaSave(key);
|
|
825
1071
|
},
|
|
1072
|
+
shared: {
|
|
1073
|
+
get(key) {
|
|
1074
|
+
return nucleo.leggiDeposito(key);
|
|
1075
|
+
},
|
|
1076
|
+
set(key, value) {
|
|
1077
|
+
return nucleo.scriviDeposito(key, value);
|
|
1078
|
+
},
|
|
1079
|
+
delete(key) {
|
|
1080
|
+
return nucleo.eliminaDeposito(key);
|
|
1081
|
+
},
|
|
1082
|
+
list(prefix) {
|
|
1083
|
+
return nucleo.elencaDeposito(prefix);
|
|
1084
|
+
},
|
|
1085
|
+
increment(key, amount = 1) {
|
|
1086
|
+
return nucleo.incrementaDeposito(key, amount);
|
|
1087
|
+
}
|
|
1088
|
+
},
|
|
826
1089
|
schedule(milliseconds, handler, payload) {
|
|
827
1090
|
nucleo.pianifica(milliseconds, handler, payload);
|
|
828
1091
|
},
|
|
@@ -837,8 +1100,14 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
837
1100
|
get mode() {
|
|
838
1101
|
return nucleo.manifest.voice ?? "none";
|
|
839
1102
|
},
|
|
1103
|
+
setGain(listener, speaker, gain) {
|
|
1104
|
+
nucleo.impostaGuadagno(idGiocatore(listener), idGiocatore(speaker), gain);
|
|
1105
|
+
},
|
|
840
1106
|
setProximity(a, b, gain) {
|
|
841
|
-
|
|
1107
|
+
const primo = idGiocatore(a);
|
|
1108
|
+
const secondo = idGiocatore(b);
|
|
1109
|
+
nucleo.impostaGuadagno(primo, secondo, gain);
|
|
1110
|
+
nucleo.impostaGuadagno(secondo, primo, gain);
|
|
842
1111
|
}
|
|
843
1112
|
}
|
|
844
1113
|
};
|
|
@@ -863,7 +1132,7 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
863
1132
|
return nucleo;
|
|
864
1133
|
}
|
|
865
1134
|
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)) {
|
|
1135
|
+
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" || manifest.persistent !== void 0 && typeof manifest.persistent !== "boolean" || !Array.isArray(manifest.roles) || !Array.isArray(manifest.modes) || manifest.voice !== void 0 && !["none", "room", "team", "proximity"].includes(manifest.voice)) {
|
|
867
1136
|
throw new TypeError("The room manifest is invalid.");
|
|
868
1137
|
}
|
|
869
1138
|
}
|
|
@@ -926,16 +1195,25 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
926
1195
|
await this.persistiEProgramma();
|
|
927
1196
|
return true;
|
|
928
1197
|
}
|
|
929
|
-
info() {
|
|
1198
|
+
info(playerId) {
|
|
930
1199
|
if (this.dati === null) return null;
|
|
931
1200
|
return {
|
|
932
1201
|
roomId: this.dati.id,
|
|
933
1202
|
status: this.dati.status,
|
|
934
|
-
players: this.dati.giocatori.filter(
|
|
1203
|
+
players: this.manifest.persistent === true ? this.dati.giocatori.length : this.dati.giocatori.filter(
|
|
935
1204
|
(player) => player.connected || player.graziaFinoA !== null
|
|
936
1205
|
).length,
|
|
937
1206
|
max: this.manifest.players.max,
|
|
938
|
-
mode: this.dati.mode
|
|
1207
|
+
mode: this.dati.mode,
|
|
1208
|
+
member: playerId === void 0 ? false : this.dati.giocatori.some((player) => player.id === playerId)
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
fotografia() {
|
|
1212
|
+
if (this.dati === null) return null;
|
|
1213
|
+
return {
|
|
1214
|
+
room: this.stanzaProtocollo(),
|
|
1215
|
+
players: this.giocatoriProtocollo(),
|
|
1216
|
+
state: this.dati.state
|
|
939
1217
|
};
|
|
940
1218
|
}
|
|
941
1219
|
giocatoreConnesso(connessione) {
|
|
@@ -981,7 +1259,6 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
981
1259
|
seq: 0
|
|
982
1260
|
};
|
|
983
1261
|
dati.giocatori.push(player);
|
|
984
|
-
dati.hostId ??= player.id;
|
|
985
1262
|
} else {
|
|
986
1263
|
if (player.connected && player.connessione !== null && player.connessione !== connessione) {
|
|
987
1264
|
this.adattatore.chiudi(player.connessione, 4006, "replaced");
|
|
@@ -993,6 +1270,7 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
993
1270
|
player.connessione = connessione;
|
|
994
1271
|
player.seq = 0;
|
|
995
1272
|
}
|
|
1273
|
+
dati.hostId ??= player.id;
|
|
996
1274
|
dati.vuotaDa = null;
|
|
997
1275
|
dati.ultimoInputAt = ora;
|
|
998
1276
|
const primaConnessione = !this.manifest.lobby && dati.status === "lobby";
|
|
@@ -1026,6 +1304,7 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1026
1304
|
player.connected = false;
|
|
1027
1305
|
player.connessione = null;
|
|
1028
1306
|
player.graziaFinoA = this.adattatore.ora() + GRAZIA_MS;
|
|
1307
|
+
if (this.manifest.persistent === true && this.dati.status === "lobby") player.ready = false;
|
|
1029
1308
|
this.frequenza.delete(connessione);
|
|
1030
1309
|
if (this.dati.hostId === player.id) this.assegnaHost();
|
|
1031
1310
|
this.verificaCountdown();
|
|
@@ -1095,7 +1374,8 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1095
1374
|
if (typeof message.role !== "string") return this.messaggioErrato(player);
|
|
1096
1375
|
if (!this.inLobby(player)) return;
|
|
1097
1376
|
this.dati.ultimoInputAt = ora;
|
|
1098
|
-
this.
|
|
1377
|
+
const errore = this.cambiaRuolo(player, message.role);
|
|
1378
|
+
if (errore !== null) this.inviaErrore(player, errore.code, errore.message);
|
|
1099
1379
|
await this.persistiEProgramma();
|
|
1100
1380
|
return;
|
|
1101
1381
|
}
|
|
@@ -1103,7 +1383,8 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1103
1383
|
if (!Number.isInteger(message.team)) return this.messaggioErrato(player);
|
|
1104
1384
|
if (!this.inLobby(player)) return;
|
|
1105
1385
|
this.dati.ultimoInputAt = ora;
|
|
1106
|
-
this.
|
|
1386
|
+
const errore = this.cambiaSquadra(player, message.team);
|
|
1387
|
+
if (errore !== null) this.inviaErrore(player, errore.code, errore.message);
|
|
1107
1388
|
await this.persistiEProgramma();
|
|
1108
1389
|
return;
|
|
1109
1390
|
}
|
|
@@ -1193,7 +1474,10 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1193
1474
|
const scaduti = this.dati.giocatori.filter(
|
|
1194
1475
|
(player) => !player.connected && player.graziaFinoA !== null && player.graziaFinoA <= ora
|
|
1195
1476
|
);
|
|
1196
|
-
for (const player of scaduti)
|
|
1477
|
+
for (const player of scaduti) {
|
|
1478
|
+
if (this.manifest.persistent === true) player.graziaFinoA = null;
|
|
1479
|
+
else await this.rimuoviGiocatore(player, "timeout");
|
|
1480
|
+
}
|
|
1197
1481
|
if (this.dati.status === "countdown" && this.dati.countdownAt !== null && this.dati.countdownAt <= ora) {
|
|
1198
1482
|
const errore = this.erroreMinimi();
|
|
1199
1483
|
if (errore !== null) {
|
|
@@ -1253,6 +1537,7 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1253
1537
|
player.connected = false;
|
|
1254
1538
|
player.connessione = null;
|
|
1255
1539
|
player.graziaFinoA = ora + GRAZIA_MS;
|
|
1540
|
+
if (this.manifest.persistent === true && dati.status === "lobby") player.ready = false;
|
|
1256
1541
|
cambiato = true;
|
|
1257
1542
|
}
|
|
1258
1543
|
}
|
|
@@ -1296,37 +1581,52 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1296
1581
|
this.inviaErrore(player, "not_in_lobby", "This action is only available in the lobby.");
|
|
1297
1582
|
return false;
|
|
1298
1583
|
}
|
|
1299
|
-
|
|
1584
|
+
cambiaRuolo(player, roleId) {
|
|
1300
1585
|
const ruolo = this.manifest.roles.find((item) => item.id === roleId);
|
|
1301
1586
|
if (ruolo === void 0 && roleId !== "spectator") {
|
|
1302
|
-
|
|
1303
|
-
return;
|
|
1587
|
+
return { code: "invalid_role", message: "This role does not exist." };
|
|
1304
1588
|
}
|
|
1305
1589
|
const occupati = this.richiediDati().giocatori.filter(
|
|
1306
1590
|
(item) => item.id !== player.id && item.role === roleId
|
|
1307
1591
|
).length;
|
|
1308
1592
|
if (ruolo?.max !== void 0 && occupati >= ruolo.max) {
|
|
1309
|
-
|
|
1310
|
-
return;
|
|
1593
|
+
return { code: "role_full", message: "This role is full." };
|
|
1311
1594
|
}
|
|
1312
1595
|
player.role = roleId;
|
|
1313
1596
|
if (roleId === "spectator") player.team = null;
|
|
1314
1597
|
else if (player.team === null) player.team = this.squadraAutomatica();
|
|
1315
|
-
player.ready = false;
|
|
1598
|
+
if (this.richiediDati().status === "lobby") player.ready = false;
|
|
1316
1599
|
this.inviaGiocatori();
|
|
1600
|
+
return null;
|
|
1317
1601
|
}
|
|
1318
|
-
|
|
1319
|
-
if (this.manifest.teams === null || team < 1 || team > this.manifest.teams.max) {
|
|
1320
|
-
|
|
1321
|
-
return;
|
|
1602
|
+
cambiaSquadra(player, team) {
|
|
1603
|
+
if (this.manifest.teams === null || !Number.isInteger(team) || team < 1 || team > this.manifest.teams.max) {
|
|
1604
|
+
return { code: "invalid_team", message: "This team does not exist." };
|
|
1322
1605
|
}
|
|
1323
1606
|
if (player.role === "spectator") {
|
|
1324
|
-
|
|
1325
|
-
return;
|
|
1607
|
+
return { code: "spectator", message: "Spectators cannot join a team." };
|
|
1326
1608
|
}
|
|
1327
1609
|
player.team = team;
|
|
1328
|
-
player.ready = false;
|
|
1610
|
+
if (this.richiediDati().status === "lobby") player.ready = false;
|
|
1329
1611
|
this.inviaGiocatori();
|
|
1612
|
+
return null;
|
|
1613
|
+
}
|
|
1614
|
+
impostaRuoloDalServer(playerId, roleId) {
|
|
1615
|
+
const player = this.giocatorePerModificaDalServer(playerId);
|
|
1616
|
+
const errore = this.cambiaRuolo(player, roleId);
|
|
1617
|
+
if (errore !== null) throw erroreConCodice(errore.code, errore.message);
|
|
1618
|
+
}
|
|
1619
|
+
impostaSquadraDalServer(playerId, team) {
|
|
1620
|
+
const player = this.giocatorePerModificaDalServer(playerId);
|
|
1621
|
+
const errore = this.cambiaSquadra(player, team);
|
|
1622
|
+
if (errore !== null) throw erroreConCodice(errore.code, errore.message);
|
|
1623
|
+
}
|
|
1624
|
+
giocatorePerModificaDalServer(playerId) {
|
|
1625
|
+
const dati = this.richiediDati();
|
|
1626
|
+
if (dati.status === "ended") throw new Error("The room has ended.");
|
|
1627
|
+
const player = dati.giocatori.find((item) => item.id === playerId);
|
|
1628
|
+
if (player === void 0) throw erroreConCodice("player_not_found", "Player not found.");
|
|
1629
|
+
return player;
|
|
1330
1630
|
}
|
|
1331
1631
|
erroreMinimi() {
|
|
1332
1632
|
const connessi = this.richiediDati().giocatori.filter((player) => player.connected);
|
|
@@ -1470,6 +1770,91 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1470
1770
|
if (!json.ok) throw new Error("The saved value is invalid.");
|
|
1471
1771
|
return json.valore;
|
|
1472
1772
|
}
|
|
1773
|
+
verificaChiaveDeposito(key) {
|
|
1774
|
+
if (typeof key !== "string" || !CHIAVE.test(key)) {
|
|
1775
|
+
throw erroreConCodice(
|
|
1776
|
+
"store_invalid_key",
|
|
1777
|
+
"Shared store keys must use lowercase letters, numbers, underscores, or hyphens."
|
|
1778
|
+
);
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
verificaPrefissoDeposito(prefix) {
|
|
1782
|
+
if (prefix !== void 0 && (typeof prefix !== "string" || !PREFISSO_CHIAVE.test(prefix))) {
|
|
1783
|
+
throw erroreConCodice(
|
|
1784
|
+
"store_invalid_key",
|
|
1785
|
+
"Shared store prefixes may contain lowercase letters, numbers, underscores, or hyphens."
|
|
1786
|
+
);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
contaOperazioneDeposito() {
|
|
1790
|
+
const ora = this.adattatore.ora();
|
|
1791
|
+
this.frequenzaDeposito = this.frequenzaDeposito.filter((at) => ora - at < 6e4);
|
|
1792
|
+
if (this.frequenzaDeposito.length >= LIMITE_OPERAZIONI_DEPOSITO) {
|
|
1793
|
+
throw erroreConCodice(
|
|
1794
|
+
"store_rate_limited",
|
|
1795
|
+
"The shared store allows at most 120 operations per minute for each room."
|
|
1796
|
+
);
|
|
1797
|
+
}
|
|
1798
|
+
this.frequenzaDeposito.push(ora);
|
|
1799
|
+
}
|
|
1800
|
+
async usaDeposito(operazione) {
|
|
1801
|
+
this.contaOperazioneDeposito();
|
|
1802
|
+
const deposito = this.adattatore.deposito;
|
|
1803
|
+
if (deposito === null) {
|
|
1804
|
+
throw erroreConCodice("store_unavailable", "The shared store is unavailable.");
|
|
1805
|
+
}
|
|
1806
|
+
try {
|
|
1807
|
+
return await operazione(deposito);
|
|
1808
|
+
} catch (cause) {
|
|
1809
|
+
const code = cause instanceof Error ? cause.code : void 0;
|
|
1810
|
+
if (typeof code === "string" && CODICI_DEPOSITO.has(code)) throw cause;
|
|
1811
|
+
throw erroreConCodice("store_unavailable", "The shared store is unavailable.");
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
async leggiDeposito(key) {
|
|
1815
|
+
this.verificaChiaveDeposito(key);
|
|
1816
|
+
const value = await this.usaDeposito((deposito) => deposito.get(key));
|
|
1817
|
+
if (value === null) return null;
|
|
1818
|
+
try {
|
|
1819
|
+
return JSON.parse(JSON.stringify(value));
|
|
1820
|
+
} catch {
|
|
1821
|
+
throw erroreConCodice("store_unavailable", "The shared store returned invalid data.");
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
async scriviDeposito(key, value) {
|
|
1825
|
+
this.verificaChiaveDeposito(key);
|
|
1826
|
+
const json = analizzaJson(value);
|
|
1827
|
+
if (!json.ok || json.bytes > LIMITE_DEPOSITO) {
|
|
1828
|
+
throw erroreConCodice(
|
|
1829
|
+
"store_too_large",
|
|
1830
|
+
"Shared store values must be valid JSON of at most 65536 bytes."
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
await this.usaDeposito((deposito) => deposito.set(key, json.valore));
|
|
1834
|
+
}
|
|
1835
|
+
async eliminaDeposito(key) {
|
|
1836
|
+
this.verificaChiaveDeposito(key);
|
|
1837
|
+
await this.usaDeposito((deposito) => deposito.delete(key));
|
|
1838
|
+
}
|
|
1839
|
+
async elencaDeposito(prefix) {
|
|
1840
|
+
this.verificaPrefissoDeposito(prefix);
|
|
1841
|
+
const keys = await this.usaDeposito((deposito) => deposito.list(prefix));
|
|
1842
|
+
if (!Array.isArray(keys) || keys.some((key) => typeof key !== "string" || !CHIAVE.test(key))) {
|
|
1843
|
+
throw erroreConCodice("store_unavailable", "The shared store returned invalid keys.");
|
|
1844
|
+
}
|
|
1845
|
+
return [...keys].sort().slice(0, 1024);
|
|
1846
|
+
}
|
|
1847
|
+
async incrementaDeposito(key, amount) {
|
|
1848
|
+
this.verificaChiaveDeposito(key);
|
|
1849
|
+
if (!Number.isSafeInteger(amount)) {
|
|
1850
|
+
throw erroreConCodice("store_not_integer", "Shared store increments must be safe integers.");
|
|
1851
|
+
}
|
|
1852
|
+
const value = await this.usaDeposito((deposito) => deposito.increment(key, amount));
|
|
1853
|
+
if (!Number.isSafeInteger(value)) {
|
|
1854
|
+
throw erroreConCodice("store_unavailable", "The shared store returned an invalid integer.");
|
|
1855
|
+
}
|
|
1856
|
+
return value;
|
|
1857
|
+
}
|
|
1473
1858
|
broadcastCreatore(message) {
|
|
1474
1859
|
const json = analizzaJson(message);
|
|
1475
1860
|
if (!json.ok) throw new TypeError("Messages must be valid JSON.");
|
|
@@ -1490,6 +1875,7 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1490
1875
|
this.adattatore.invia(player.connessione, message);
|
|
1491
1876
|
}
|
|
1492
1877
|
}
|
|
1878
|
+
if (message.t !== "flush") this.adattatore.pubblica(message);
|
|
1493
1879
|
}
|
|
1494
1880
|
inviaErrore(player, code, message) {
|
|
1495
1881
|
if (player.connected && player.connessione !== null) {
|
|
@@ -1513,15 +1899,7 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1513
1899
|
this.adattatore.invia(player.connessione, {
|
|
1514
1900
|
t: "welcome",
|
|
1515
1901
|
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
|
-
},
|
|
1902
|
+
room: this.stanzaProtocollo(),
|
|
1525
1903
|
players: this.giocatoriProtocollo(),
|
|
1526
1904
|
state: dati.state
|
|
1527
1905
|
});
|
|
@@ -1530,6 +1908,19 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1530
1908
|
this.adattatore.invia(player.connessione, { t: "voice", op: "gain", gains: { ...gains } });
|
|
1531
1909
|
}
|
|
1532
1910
|
}
|
|
1911
|
+
stanzaProtocollo() {
|
|
1912
|
+
const dati = this.richiediDati();
|
|
1913
|
+
return {
|
|
1914
|
+
id: dati.id,
|
|
1915
|
+
seed: seedStanza(dati.id),
|
|
1916
|
+
status: dati.status,
|
|
1917
|
+
mode: dati.mode,
|
|
1918
|
+
tick: dati.tick,
|
|
1919
|
+
tickRate: dati.tickRate,
|
|
1920
|
+
serverTime: this.adattatore.ora(),
|
|
1921
|
+
host: dati.hostId
|
|
1922
|
+
};
|
|
1923
|
+
}
|
|
1533
1924
|
inviaGiocatori() {
|
|
1534
1925
|
this.broadcast({ t: "players", players: this.giocatoriProtocollo() });
|
|
1535
1926
|
}
|
|
@@ -1615,16 +2006,15 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1615
2006
|
}
|
|
1616
2007
|
return stato;
|
|
1617
2008
|
}
|
|
1618
|
-
|
|
1619
|
-
if ((this.manifest.voice ?? "none")
|
|
2009
|
+
impostaGuadagno(listener, speaker, gain) {
|
|
2010
|
+
if ((this.manifest.voice ?? "none") === "none") return;
|
|
1620
2011
|
const dati = this.richiediDati();
|
|
1621
|
-
if (!dati.giocatori.some((player) => player.id ===
|
|
2012
|
+
if (!dati.giocatori.some((player) => player.id === listener) || !dati.giocatori.some((player) => player.id === speaker)) throw new Error("Player not found.");
|
|
1622
2013
|
if (Number.isNaN(gain)) throw new TypeError("Voice gain must be a number.");
|
|
1623
|
-
if (
|
|
2014
|
+
if (listener === speaker) return;
|
|
1624
2015
|
const valore = Math.round(Math.min(1, Math.max(0, gain)) * 100) / 100;
|
|
1625
2016
|
dati.voceGuadagni ??= {};
|
|
1626
|
-
this.salvaGuadagno(
|
|
1627
|
-
this.salvaGuadagno(b, a, valore);
|
|
2017
|
+
this.salvaGuadagno(listener, speaker, valore);
|
|
1628
2018
|
}
|
|
1629
2019
|
salvaGuadagno(playerId, altroId, valore) {
|
|
1630
2020
|
const dati = this.richiediDati();
|
|
@@ -1748,6 +2138,11 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1748
2138
|
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
2139
|
}
|
|
1750
2140
|
async terminaSeInattiva() {
|
|
2141
|
+
if (this.manifest.persistent === true) {
|
|
2142
|
+
if (this.dati?.status === "ended" || this.dati === null || this.adattatore.ora() < Math.max(this.dati.ultimoInputAt, this.dati.ultimoCambioStatoAt) + SCADENZA_PERSISTENTE_MS) return false;
|
|
2143
|
+
await this.terminaInterna({ error: "expired" });
|
|
2144
|
+
return true;
|
|
2145
|
+
}
|
|
1751
2146
|
if (this.dati?.status !== "playing" || this.adattatore.ora() < this.dati.ultimoInputAt + INATTIVITA_MS) return false;
|
|
1752
2147
|
await this.terminaInterna({ error: "idle" });
|
|
1753
2148
|
return true;
|
|
@@ -1762,7 +2157,13 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1762
2157
|
this.serveTick() ? 1e3 / this.dati.tickRate : null
|
|
1763
2158
|
);
|
|
1764
2159
|
const prossime = [];
|
|
1765
|
-
if (this.
|
|
2160
|
+
if (this.manifest.persistent === true) {
|
|
2161
|
+
prossime.push(
|
|
2162
|
+
Math.max(this.dati.ultimoInputAt, this.dati.ultimoCambioStatoAt) + SCADENZA_PERSISTENTE_MS
|
|
2163
|
+
);
|
|
2164
|
+
} else if (this.dati.status === "playing") {
|
|
2165
|
+
prossime.push(this.dati.ultimoInputAt + INATTIVITA_MS);
|
|
2166
|
+
}
|
|
1766
2167
|
if (this.dati.countdownAt !== null) prossime.push(this.dati.countdownAt);
|
|
1767
2168
|
for (const player of this.dati.giocatori) {
|
|
1768
2169
|
if (!player.connected && player.graziaFinoA !== null) prossime.push(player.graziaFinoA);
|
|
@@ -2037,9 +2438,13 @@ var ArchivioNode = class _ArchivioNode {
|
|
|
2037
2438
|
}
|
|
2038
2439
|
};
|
|
2039
2440
|
var AdattatoreNode = class {
|
|
2040
|
-
constructor(storage) {
|
|
2441
|
+
constructor(storage, deposito, ritardoSpettatori) {
|
|
2041
2442
|
this.storage = storage;
|
|
2443
|
+
this.deposito = deposito;
|
|
2444
|
+
this.ritardoSpettatori = ritardoSpettatori;
|
|
2042
2445
|
this.connessioni = /* @__PURE__ */ new Map();
|
|
2446
|
+
this.spettatori = /* @__PURE__ */ new Map();
|
|
2447
|
+
this.timerSpettatori = /* @__PURE__ */ new Set();
|
|
2043
2448
|
this.tickTimer = null;
|
|
2044
2449
|
this.tickIntervallo = null;
|
|
2045
2450
|
this.tickGenerazione = 0;
|
|
@@ -2058,12 +2463,66 @@ var AdattatoreNode = class {
|
|
|
2058
2463
|
rimuovi(id) {
|
|
2059
2464
|
this.connessioni.delete(id);
|
|
2060
2465
|
}
|
|
2466
|
+
aggiungiSpettatore(id, socket) {
|
|
2467
|
+
this.spettatori.set(id, socket);
|
|
2468
|
+
}
|
|
2469
|
+
rimuoviSpettatore(id) {
|
|
2470
|
+
this.spettatori.delete(id);
|
|
2471
|
+
}
|
|
2472
|
+
numeroSpettatori() {
|
|
2473
|
+
return this.spettatori.size;
|
|
2474
|
+
}
|
|
2475
|
+
elencoSpettatori() {
|
|
2476
|
+
return [...this.spettatori];
|
|
2477
|
+
}
|
|
2061
2478
|
elencoConnessioni() {
|
|
2062
2479
|
return [...this.connessioni];
|
|
2063
2480
|
}
|
|
2064
2481
|
invia(connessione, messaggio) {
|
|
2065
2482
|
this.connessioni.get(connessione)?.send(JSON.stringify(messaggio));
|
|
2066
2483
|
}
|
|
2484
|
+
pubblica(messaggio) {
|
|
2485
|
+
const testo = JSON.stringify(messaggio);
|
|
2486
|
+
for (const [connessione] of this.spettatori) {
|
|
2487
|
+
this.inviaSpettatore(connessione, testo);
|
|
2488
|
+
if (messaggio.t === "status" && messaggio.status === "ended") {
|
|
2489
|
+
this.chiudiSpettatoreRitardato(connessione);
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
inviaSpettatore(connessione, testo) {
|
|
2494
|
+
this.accodaSpettatore(connessione, (socket) => {
|
|
2495
|
+
socket.send(testo);
|
|
2496
|
+
});
|
|
2497
|
+
}
|
|
2498
|
+
inviaSpettatoreSubito(connessione, testo) {
|
|
2499
|
+
this.spettatori.get(connessione)?.send(testo);
|
|
2500
|
+
}
|
|
2501
|
+
chiudiSpettatore(connessione, codice, motivo) {
|
|
2502
|
+
const socket = this.spettatori.get(connessione);
|
|
2503
|
+
this.spettatori.delete(connessione);
|
|
2504
|
+
socket?.close(codice, motivo);
|
|
2505
|
+
}
|
|
2506
|
+
chiudiSpettatoreRitardato(connessione) {
|
|
2507
|
+
this.accodaSpettatore(connessione, (socket) => {
|
|
2508
|
+
this.spettatori.delete(connessione);
|
|
2509
|
+
socket.close(4004, "room_ended");
|
|
2510
|
+
});
|
|
2511
|
+
}
|
|
2512
|
+
accodaSpettatore(connessione, azione) {
|
|
2513
|
+
if (this.ritardoSpettatori === 0) {
|
|
2514
|
+
const socket = this.spettatori.get(connessione);
|
|
2515
|
+
if (socket !== void 0) azione(socket);
|
|
2516
|
+
return;
|
|
2517
|
+
}
|
|
2518
|
+
const timer = setTimeout(() => {
|
|
2519
|
+
this.timerSpettatori.delete(timer);
|
|
2520
|
+
const socket = this.spettatori.get(connessione);
|
|
2521
|
+
if (socket !== void 0) azione(socket);
|
|
2522
|
+
}, this.ritardoSpettatori ?? RITARDO_SPETTATORI_MS2);
|
|
2523
|
+
this.timerSpettatori.add(timer);
|
|
2524
|
+
timer.unref();
|
|
2525
|
+
}
|
|
2067
2526
|
chiudi(connessione, codice, motivo) {
|
|
2068
2527
|
this.connessioni.get(connessione)?.close(codice, motivo);
|
|
2069
2528
|
}
|
|
@@ -2096,6 +2555,8 @@ var AdattatoreNode = class {
|
|
|
2096
2555
|
this.tickGenerazione += 1;
|
|
2097
2556
|
if (this.tickTimer !== null) clearTimeout(this.tickTimer);
|
|
2098
2557
|
if (this.svegliaTimer !== null) clearTimeout(this.svegliaTimer);
|
|
2558
|
+
for (const timer of this.timerSpettatori) clearTimeout(timer);
|
|
2559
|
+
this.timerSpettatori.clear();
|
|
2099
2560
|
this.tickTimer = null;
|
|
2100
2561
|
this.svegliaTimer = null;
|
|
2101
2562
|
}
|
|
@@ -2134,6 +2595,7 @@ var StanzaNode = class {
|
|
|
2134
2595
|
this.manifest = manifest;
|
|
2135
2596
|
this.coda = Promise.resolve();
|
|
2136
2597
|
this.voceRoster = /* @__PURE__ */ new Map();
|
|
2598
|
+
this.voceListeners = /* @__PURE__ */ new Map();
|
|
2137
2599
|
this.voceFrequenza = /* @__PURE__ */ new Map();
|
|
2138
2600
|
this.voceUltimaRichiesta = /* @__PURE__ */ new Map();
|
|
2139
2601
|
this.frameFrequenza = /* @__PURE__ */ new Map();
|
|
@@ -2153,6 +2615,9 @@ var StanzaNode = class {
|
|
|
2153
2615
|
canJoin(identity) {
|
|
2154
2616
|
return this.serializza(() => Promise.resolve(this.nucleo.puoEntrare(identity)));
|
|
2155
2617
|
}
|
|
2618
|
+
canWatch() {
|
|
2619
|
+
return this.serializza(() => Promise.resolve(this.permessoSpettatore()));
|
|
2620
|
+
}
|
|
2156
2621
|
async connect(socket, identity) {
|
|
2157
2622
|
const connessione = randomUUID();
|
|
2158
2623
|
this.adattatore.aggiungi(connessione, socket);
|
|
@@ -2183,6 +2648,28 @@ var StanzaNode = class {
|
|
|
2183
2648
|
throw cause;
|
|
2184
2649
|
}
|
|
2185
2650
|
}
|
|
2651
|
+
watch(socket, _identity) {
|
|
2652
|
+
return this.serializza(async () => {
|
|
2653
|
+
const permesso = this.permessoSpettatore();
|
|
2654
|
+
if (!permesso.ok) return permesso;
|
|
2655
|
+
const connessione = randomUUID();
|
|
2656
|
+
this.adattatore.aggiungiSpettatore(connessione, socket);
|
|
2657
|
+
socket.on("message", (message) => {
|
|
2658
|
+
void this.serializza(() => this.riceviSpettatore(connessione, message));
|
|
2659
|
+
});
|
|
2660
|
+
socket.on("close", () => {
|
|
2661
|
+
this.adattatore.rimuoviSpettatore(connessione);
|
|
2662
|
+
this.frameFrequenza.delete(connessione);
|
|
2663
|
+
});
|
|
2664
|
+
const fotografia = this.nucleo.fotografia();
|
|
2665
|
+
if (fotografia === null) {
|
|
2666
|
+
this.adattatore.rimuoviSpettatore(connessione);
|
|
2667
|
+
return { ok: false, code: "room_not_found" };
|
|
2668
|
+
}
|
|
2669
|
+
this.inviaWatching(connessione, fotografia);
|
|
2670
|
+
return { ok: true };
|
|
2671
|
+
});
|
|
2672
|
+
}
|
|
2186
2673
|
flush() {
|
|
2187
2674
|
return this.serializza(() => this.nucleo.flush());
|
|
2188
2675
|
}
|
|
@@ -2197,6 +2684,11 @@ var StanzaNode = class {
|
|
|
2197
2684
|
socket.close(1001, "server_shutdown");
|
|
2198
2685
|
await this.serializza(() => this.nucleo.disconnetti(connessione));
|
|
2199
2686
|
}
|
|
2687
|
+
for (const [connessione, socket] of this.adattatore.elencoSpettatori()) {
|
|
2688
|
+
this.adattatore.rimuoviSpettatore(connessione);
|
|
2689
|
+
this.frameFrequenza.delete(connessione);
|
|
2690
|
+
socket.close(1001, "server_shutdown");
|
|
2691
|
+
}
|
|
2200
2692
|
}
|
|
2201
2693
|
serializza(operazione) {
|
|
2202
2694
|
const risultato = this.coda.then(operazione);
|
|
@@ -2237,6 +2729,66 @@ var StanzaNode = class {
|
|
|
2237
2729
|
}
|
|
2238
2730
|
await this.riceviVoce(connessione, player.id, player.role, message);
|
|
2239
2731
|
}
|
|
2732
|
+
permessoSpettatore() {
|
|
2733
|
+
const info = this.nucleo.info();
|
|
2734
|
+
if (info === null) return { ok: false, code: "room_not_found" };
|
|
2735
|
+
if (info.status === "ended") return { ok: false, code: "room_ended" };
|
|
2736
|
+
if (this.manifest.spectators === null) {
|
|
2737
|
+
return { ok: false, code: "spectators_disabled" };
|
|
2738
|
+
}
|
|
2739
|
+
if (this.adattatore.numeroSpettatori() >= MASSIMO_SPETTATORI) {
|
|
2740
|
+
return { ok: false, code: "spectators_full" };
|
|
2741
|
+
}
|
|
2742
|
+
return { ok: true };
|
|
2743
|
+
}
|
|
2744
|
+
inviaWatching(connessione, fotografia) {
|
|
2745
|
+
this.adattatore.inviaSpettatore(connessione, JSON.stringify({
|
|
2746
|
+
t: "watching",
|
|
2747
|
+
...fotografia,
|
|
2748
|
+
delayMs: this.manifest.spectators?.delayMs ?? RITARDO_SPETTATORI_MS2
|
|
2749
|
+
}));
|
|
2750
|
+
}
|
|
2751
|
+
async riceviSpettatore(connessione, frame) {
|
|
2752
|
+
if (Buffer.byteLength(frame, "utf8") > 16 * 1024) {
|
|
2753
|
+
this.adattatore.chiudiSpettatore(connessione, 4008, "rate_limited");
|
|
2754
|
+
this.frameFrequenza.delete(connessione);
|
|
2755
|
+
return;
|
|
2756
|
+
}
|
|
2757
|
+
const ora = Date.now();
|
|
2758
|
+
const frames = (this.frameFrequenza.get(connessione) ?? []).filter((at) => ora - at < 1e3);
|
|
2759
|
+
if (frames.length >= 20) {
|
|
2760
|
+
this.adattatore.chiudiSpettatore(connessione, 4008, "rate_limited");
|
|
2761
|
+
this.frameFrequenza.delete(connessione);
|
|
2762
|
+
return;
|
|
2763
|
+
}
|
|
2764
|
+
frames.push(ora);
|
|
2765
|
+
this.frameFrequenza.set(connessione, frames);
|
|
2766
|
+
let message = null;
|
|
2767
|
+
try {
|
|
2768
|
+
const value = JSON.parse(frame);
|
|
2769
|
+
message = typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
2770
|
+
} catch {
|
|
2771
|
+
}
|
|
2772
|
+
if (message?.t === "ping" && typeof message.c === "number" && Number.isFinite(message.c)) {
|
|
2773
|
+
this.adattatore.inviaSpettatoreSubito(
|
|
2774
|
+
connessione,
|
|
2775
|
+
JSON.stringify({ t: "pong", c: message.c, s: ora })
|
|
2776
|
+
);
|
|
2777
|
+
return;
|
|
2778
|
+
}
|
|
2779
|
+
if (message?.t === "resync") {
|
|
2780
|
+
const fotografia = this.nucleo.fotografia();
|
|
2781
|
+
if (fotografia !== null) this.inviaWatching(connessione, fotografia);
|
|
2782
|
+
return;
|
|
2783
|
+
}
|
|
2784
|
+
if (message?.t === "leave") {
|
|
2785
|
+
this.adattatore.chiudiSpettatore(connessione, 1e3, "left");
|
|
2786
|
+
this.frameFrequenza.delete(connessione);
|
|
2787
|
+
return;
|
|
2788
|
+
}
|
|
2789
|
+
this.adattatore.chiudiSpettatore(connessione, 4009, "bad_message");
|
|
2790
|
+
this.frameFrequenza.delete(connessione);
|
|
2791
|
+
}
|
|
2240
2792
|
async riceviVoce(connessione, playerId, role, value) {
|
|
2241
2793
|
const richiesta = this.richiestaVoce(value);
|
|
2242
2794
|
if (richiesta === null) {
|
|
@@ -2284,17 +2836,24 @@ var StanzaNode = class {
|
|
|
2284
2836
|
return;
|
|
2285
2837
|
}
|
|
2286
2838
|
if (richiesta.op === "publish") {
|
|
2287
|
-
|
|
2839
|
+
const mic = richiesta.mic !== false;
|
|
2840
|
+
if (role === "spectator" && mic) {
|
|
2288
2841
|
this.inviaErroreVoce(connessione, richiesta, "spectator", "Spectators cannot join voice.");
|
|
2289
2842
|
return;
|
|
2290
2843
|
}
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2844
|
+
if (mic) {
|
|
2845
|
+
this.voceListeners.delete(playerId);
|
|
2846
|
+
this.voceRoster.set(playerId, {
|
|
2847
|
+
id: playerId,
|
|
2848
|
+
session: "mesh",
|
|
2849
|
+
track: "mic",
|
|
2850
|
+
muted: false,
|
|
2851
|
+
connessione
|
|
2852
|
+
});
|
|
2853
|
+
} else {
|
|
2854
|
+
this.voceRoster.delete(playerId);
|
|
2855
|
+
this.voceListeners.set(playerId, connessione);
|
|
2856
|
+
}
|
|
2298
2857
|
this.adattatore.invia(connessione, { t: "voice", op: "publish", r: richiesta.r });
|
|
2299
2858
|
this.broadcastRoster();
|
|
2300
2859
|
return;
|
|
@@ -2330,9 +2889,11 @@ var StanzaNode = class {
|
|
|
2330
2889
|
}
|
|
2331
2890
|
if (richiesta.op === "stop") {
|
|
2332
2891
|
const peer = this.voceRoster.get(playerId);
|
|
2333
|
-
const
|
|
2892
|
+
const listener = this.voceListeners.get(playerId);
|
|
2893
|
+
const rimossaPeer = peer?.connessione === connessione && this.voceRoster.delete(playerId);
|
|
2894
|
+
const rimossoListener = listener === connessione && this.voceListeners.delete(playerId);
|
|
2334
2895
|
this.adattatore.invia(connessione, { t: "voice", op: "stop", r: richiesta.r });
|
|
2335
|
-
if (
|
|
2896
|
+
if (rimossaPeer || rimossoListener) this.broadcastRoster();
|
|
2336
2897
|
return;
|
|
2337
2898
|
}
|
|
2338
2899
|
this.inviaErroreVoce(connessione, richiesta, "invalid_request", "The voice request is invalid.");
|
|
@@ -2342,9 +2903,12 @@ var StanzaNode = class {
|
|
|
2342
2903
|
return null;
|
|
2343
2904
|
}
|
|
2344
2905
|
const base = { t: "voice", r: value.r };
|
|
2345
|
-
if (value.op === "ice" || value.op === "
|
|
2906
|
+
if (value.op === "ice" || value.op === "stop") {
|
|
2346
2907
|
return { ...base, op: value.op };
|
|
2347
2908
|
}
|
|
2909
|
+
if (value.op === "publish" && (value.mic === void 0 || typeof value.mic === "boolean")) {
|
|
2910
|
+
return { ...base, op: "publish", ...value.mic === void 0 ? {} : { mic: value.mic } };
|
|
2911
|
+
}
|
|
2348
2912
|
if (value.op === "mute" && typeof value.muted === "boolean") {
|
|
2349
2913
|
return { ...base, op: "mute", muted: value.muted };
|
|
2350
2914
|
}
|
|
@@ -2367,7 +2931,8 @@ var StanzaNode = class {
|
|
|
2367
2931
|
t: "voice",
|
|
2368
2932
|
op: "roster",
|
|
2369
2933
|
mode: this.modoVoce(),
|
|
2370
|
-
peers: this.rosterPubblico()
|
|
2934
|
+
peers: this.rosterPubblico(),
|
|
2935
|
+
listeners: [...this.voceListeners.keys()].sort((a, b) => a.localeCompare(b))
|
|
2371
2936
|
});
|
|
2372
2937
|
}
|
|
2373
2938
|
broadcastRoster() {
|
|
@@ -2381,6 +2946,11 @@ var StanzaNode = class {
|
|
|
2381
2946
|
this.voceRoster.delete(playerId);
|
|
2382
2947
|
cambiato = true;
|
|
2383
2948
|
}
|
|
2949
|
+
for (const [playerId, connessione] of this.voceListeners) {
|
|
2950
|
+
if (this.nucleo.giocatoreConnesso(connessione)?.id === playerId) continue;
|
|
2951
|
+
this.voceListeners.delete(playerId);
|
|
2952
|
+
cambiato = true;
|
|
2953
|
+
}
|
|
2384
2954
|
if (cambiato) this.broadcastRoster();
|
|
2385
2955
|
}
|
|
2386
2956
|
rimuoviConnessione(connessione) {
|
|
@@ -2396,6 +2966,9 @@ var StanzaNode = class {
|
|
|
2396
2966
|
if (this.voceRoster.get(playerId)?.connessione === connessione) {
|
|
2397
2967
|
this.voceRoster.delete(playerId);
|
|
2398
2968
|
this.broadcastRoster();
|
|
2969
|
+
} else if (this.voceListeners.get(playerId) === connessione) {
|
|
2970
|
+
this.voceListeners.delete(playerId);
|
|
2971
|
+
this.broadcastRoster();
|
|
2399
2972
|
}
|
|
2400
2973
|
}
|
|
2401
2974
|
inviaErroreVoce(connessione, richiesta, code, message) {
|
|
@@ -2406,7 +2979,8 @@ var StanzaNode = class {
|
|
|
2406
2979
|
};
|
|
2407
2980
|
async function createNodeRoom(definition, manifest, options = {}) {
|
|
2408
2981
|
const storage = await ArchivioNode.apri(options.storageFile ?? null);
|
|
2409
|
-
const
|
|
2982
|
+
const ritardoSpettatori = manifest.spectators === null ? null : manifest.spectators?.delayMs ?? RITARDO_SPETTATORI_MS2;
|
|
2983
|
+
const adattatore = new AdattatoreNode(storage, options.deposito ?? null, ritardoSpettatori);
|
|
2410
2984
|
const nucleo = await NucleoStanza.apri(definition, manifest, adattatore);
|
|
2411
2985
|
return new StanzaNode(nucleo, adattatore, manifest);
|
|
2412
2986
|
}
|
|
@@ -2420,6 +2994,70 @@ var FORMA_SESSIONE = /^[A-Za-z0-9_-]{8,128}$/;
|
|
|
2420
2994
|
var FORMA_CODICE = /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/;
|
|
2421
2995
|
var ALFABETO_CODICE = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
2422
2996
|
var MASSIMO_CORPO = 262144;
|
|
2997
|
+
var MASSIMO_FRAME_MATCH = 4096;
|
|
2998
|
+
var DURATA_STANZA_APERTA = 24 * 60 * 60 * 1e3;
|
|
2999
|
+
var VALORE_CHIAVE_MATCH = /^[A-Za-z0-9_.:-]+$/;
|
|
3000
|
+
var PREFISSO_DEPOSITO = /^[a-z0-9_-]{0,32}$/;
|
|
3001
|
+
var LIMITE_DEPOSITO2 = 64 * 1024;
|
|
3002
|
+
var MASSIMO_CHIAVI_DEPOSITO = 1024;
|
|
3003
|
+
function erroreDeposito(code, message) {
|
|
3004
|
+
return Object.assign(new Error(message), { code });
|
|
3005
|
+
}
|
|
3006
|
+
var DepositoDev = class {
|
|
3007
|
+
valori = /* @__PURE__ */ new Map();
|
|
3008
|
+
verificaChiave(key) {
|
|
3009
|
+
if (typeof key !== "string" || !CHIAVE_SAVE.test(key)) {
|
|
3010
|
+
throw erroreDeposito("store_invalid_key", "The shared store key is invalid.");
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
3013
|
+
async get(key) {
|
|
3014
|
+
this.verificaChiave(key);
|
|
3015
|
+
const value = this.valori.get(key);
|
|
3016
|
+
return value === void 0 ? null : structuredClone(value);
|
|
3017
|
+
}
|
|
3018
|
+
async set(key, value) {
|
|
3019
|
+
this.verificaChiave(key);
|
|
3020
|
+
let testo;
|
|
3021
|
+
try {
|
|
3022
|
+
testo = JSON.stringify(value);
|
|
3023
|
+
} catch {
|
|
3024
|
+
throw erroreDeposito("store_too_large", "The shared store value is not valid JSON.");
|
|
3025
|
+
}
|
|
3026
|
+
if (testo === void 0 || Buffer.byteLength(testo, "utf8") > LIMITE_DEPOSITO2) {
|
|
3027
|
+
throw erroreDeposito("store_too_large", "The shared store value is too large.");
|
|
3028
|
+
}
|
|
3029
|
+
if (!this.valori.has(key) && this.valori.size >= MASSIMO_CHIAVI_DEPOSITO) {
|
|
3030
|
+
throw erroreDeposito("store_full", "The shared store is full.");
|
|
3031
|
+
}
|
|
3032
|
+
this.valori.set(key, JSON.parse(testo));
|
|
3033
|
+
}
|
|
3034
|
+
async delete(key) {
|
|
3035
|
+
this.verificaChiave(key);
|
|
3036
|
+
this.valori.delete(key);
|
|
3037
|
+
}
|
|
3038
|
+
async list(prefix = "") {
|
|
3039
|
+
if (typeof prefix !== "string" || !PREFISSO_DEPOSITO.test(prefix)) {
|
|
3040
|
+
throw erroreDeposito("store_invalid_key", "The shared store prefix is invalid.");
|
|
3041
|
+
}
|
|
3042
|
+
return [...this.valori.keys()].filter((key) => key.startsWith(prefix)).sort().slice(0, MASSIMO_CHIAVI_DEPOSITO);
|
|
3043
|
+
}
|
|
3044
|
+
async increment(key, amount = 1) {
|
|
3045
|
+
this.verificaChiave(key);
|
|
3046
|
+
const current = this.valori.get(key) ?? 0;
|
|
3047
|
+
if (!Number.isSafeInteger(current) || !Number.isSafeInteger(amount)) {
|
|
3048
|
+
throw erroreDeposito("store_not_integer", "The shared store value is not a safe integer.");
|
|
3049
|
+
}
|
|
3050
|
+
const result = Number(current) + amount;
|
|
3051
|
+
if (!Number.isSafeInteger(result)) {
|
|
3052
|
+
throw erroreDeposito("store_not_integer", "The shared store value is not a safe integer.");
|
|
3053
|
+
}
|
|
3054
|
+
if (!this.valori.has(key) && this.valori.size >= MASSIMO_CHIAVI_DEPOSITO) {
|
|
3055
|
+
throw erroreDeposito("store_full", "The shared store is full.");
|
|
3056
|
+
}
|
|
3057
|
+
this.valori.set(key, result);
|
|
3058
|
+
return result;
|
|
3059
|
+
}
|
|
3060
|
+
};
|
|
2423
3061
|
var DevHttpError = class extends Error {
|
|
2424
3062
|
constructor(status, code, message, hints = []) {
|
|
2425
3063
|
super(message);
|
|
@@ -2481,14 +3119,32 @@ function serviceTicket(player, game, aud, secret) {
|
|
|
2481
3119
|
exp: iat + DURATA_BIGLIETTO
|
|
2482
3120
|
}, secret);
|
|
2483
3121
|
}
|
|
2484
|
-
function joinTicket(player, room, secret) {
|
|
3122
|
+
function joinTicket(player, room, secret, aud = "room") {
|
|
2485
3123
|
const iat = currentSeconds();
|
|
2486
3124
|
return signJwt({
|
|
2487
3125
|
sub: player.id,
|
|
2488
3126
|
name: player.name,
|
|
2489
3127
|
guest: player.guest,
|
|
2490
3128
|
room,
|
|
2491
|
-
aud
|
|
3129
|
+
aud,
|
|
3130
|
+
iat,
|
|
3131
|
+
exp: iat + DURATA_INGRESSO
|
|
3132
|
+
}, secret);
|
|
3133
|
+
}
|
|
3134
|
+
function matchTicket(player, game, mode, key, matchmaking, players, lobby, secret) {
|
|
3135
|
+
const iat = currentSeconds();
|
|
3136
|
+
return signJwt({
|
|
3137
|
+
sub: player.id,
|
|
3138
|
+
game,
|
|
3139
|
+
name: player.name,
|
|
3140
|
+
guest: player.guest,
|
|
3141
|
+
mode,
|
|
3142
|
+
key,
|
|
3143
|
+
timeoutMs: matchmaking.timeoutMs,
|
|
3144
|
+
min: players.min,
|
|
3145
|
+
max: players.max,
|
|
3146
|
+
lobby,
|
|
3147
|
+
aud: "match",
|
|
2492
3148
|
iat,
|
|
2493
3149
|
exp: iat + DURATA_INGRESSO
|
|
2494
3150
|
}, secret);
|
|
@@ -2507,9 +3163,14 @@ function readServiceTicket(request, game, aud, secret) {
|
|
|
2507
3163
|
}
|
|
2508
3164
|
return payload;
|
|
2509
3165
|
}
|
|
2510
|
-
function readJoinTicket(token, room, secret) {
|
|
3166
|
+
function readJoinTicket(token, room, aud, secret) {
|
|
3167
|
+
const payload = verifyJwt(token, secret);
|
|
3168
|
+
if (payload === null || payload.aud !== aud || payload.room !== room || typeof payload.sub !== "string" || payload.sub === "" || typeof payload.name !== "string" || payload.name === "" || typeof payload.guest !== "boolean" || !validTimes(payload, DURATA_INGRESSO)) return null;
|
|
3169
|
+
return payload;
|
|
3170
|
+
}
|
|
3171
|
+
function readMatchTicket(token, game, secret) {
|
|
2511
3172
|
const payload = verifyJwt(token, secret);
|
|
2512
|
-
if (payload === null || payload.aud !== "
|
|
3173
|
+
if (payload === null || payload.aud !== "match" || payload.game !== game || typeof payload.sub !== "string" || payload.sub === "" || typeof payload.name !== "string" || payload.name === "" || typeof payload.guest !== "boolean" || typeof payload.mode !== "string" || payload.mode === "" || typeof payload.key !== "string" || payload.key === "" || !Number.isInteger(payload.timeoutMs) || payload.timeoutMs < 1e3 || payload.timeoutMs > 3e5 || !Number.isInteger(payload.min) || payload.min < 1 || !Number.isInteger(payload.max) || payload.max < payload.min || typeof payload.lobby !== "boolean" || !validTimes(payload, DURATA_INGRESSO)) return null;
|
|
2513
3174
|
return payload;
|
|
2514
3175
|
}
|
|
2515
3176
|
function playerFromTicket(ticket) {
|
|
@@ -2627,6 +3288,34 @@ async function readBody(request, maximum = MASSIMO_CORPO) {
|
|
|
2627
3288
|
throw new DevHttpError(400, "invalid_request", "The request body must be valid JSON.");
|
|
2628
3289
|
}
|
|
2629
3290
|
}
|
|
3291
|
+
function canonicalMatchKey(value, fields) {
|
|
3292
|
+
const key = object(value);
|
|
3293
|
+
if (key === null) {
|
|
3294
|
+
throw new DevHttpError(400, "invalid_request", "key must be an object.");
|
|
3295
|
+
}
|
|
3296
|
+
for (const field of fields) {
|
|
3297
|
+
if (!Object.hasOwn(key, field)) {
|
|
3298
|
+
throw new DevHttpError(400, "invalid_request", `Matchmaking key field ${field} is missing.`);
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3301
|
+
for (const field of Object.keys(key)) {
|
|
3302
|
+
if (!fields.includes(field)) {
|
|
3303
|
+
throw new DevHttpError(400, "invalid_request", `Matchmaking key field ${field} is not allowed.`);
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
return fields.map((field) => {
|
|
3307
|
+
const item = key[field];
|
|
3308
|
+
if (typeof item === "string" && item.length >= 1 && item.length <= 64 && VALORE_CHIAVE_MATCH.test(item)) {
|
|
3309
|
+
return encodeURIComponent(item);
|
|
3310
|
+
}
|
|
3311
|
+
if (typeof item === "number" && Number.isSafeInteger(item)) return encodeURIComponent(String(item));
|
|
3312
|
+
throw new DevHttpError(
|
|
3313
|
+
400,
|
|
3314
|
+
"invalid_request",
|
|
3315
|
+
`Matchmaking key field ${field} must be a valid string or safe integer.`
|
|
3316
|
+
);
|
|
3317
|
+
}).join("/");
|
|
3318
|
+
}
|
|
2630
3319
|
function parentPage(input) {
|
|
2631
3320
|
return `<!doctype html>
|
|
2632
3321
|
<html lang="en">
|
|
@@ -2697,10 +3386,10 @@ function parentPage(input) {
|
|
|
2697
3386
|
`;
|
|
2698
3387
|
}
|
|
2699
3388
|
async function readGame(root) {
|
|
2700
|
-
const manifestPath =
|
|
3389
|
+
const manifestPath = join2(root, "caisual.json");
|
|
2701
3390
|
let parsed;
|
|
2702
3391
|
try {
|
|
2703
|
-
parsed = JSON.parse(await
|
|
3392
|
+
parsed = JSON.parse(await fs2.readFile(manifestPath, "utf8"));
|
|
2704
3393
|
} catch {
|
|
2705
3394
|
throw new Error("caisual.json: file not found, unreadable, or invalid JSON.");
|
|
2706
3395
|
}
|
|
@@ -2709,28 +3398,25 @@ async function readGame(root) {
|
|
|
2709
3398
|
throw new Error(`caisual.json is not valid:
|
|
2710
3399
|
${result.errori.map((error) => `- ${error}`).join("\n")}`);
|
|
2711
3400
|
}
|
|
2712
|
-
const clientRoot = await
|
|
3401
|
+
const clientRoot = await fs2.realpath(join2(root, "client")).catch(() => null);
|
|
2713
3402
|
if (clientRoot === null) throw new Error("client/: folder not found.");
|
|
2714
|
-
const stat = await
|
|
3403
|
+
const stat = await fs2.stat(clientRoot);
|
|
2715
3404
|
if (!stat.isDirectory()) throw new Error("client/: must be a folder.");
|
|
2716
|
-
const index = await
|
|
3405
|
+
const index = await fs2.stat(join2(clientRoot, "index.html")).catch(() => null);
|
|
2717
3406
|
if (index === null || !index.isFile()) throw new Error("client/index.html: file not found.");
|
|
2718
3407
|
return { manifest: result.manifest, clientRoot };
|
|
2719
3408
|
}
|
|
2720
3409
|
async function loadDefinition(root) {
|
|
2721
|
-
const path =
|
|
2722
|
-
let
|
|
3410
|
+
const path = join2(root, "server.js");
|
|
3411
|
+
let stat;
|
|
2723
3412
|
try {
|
|
2724
|
-
|
|
3413
|
+
stat = await fs2.lstat(path);
|
|
2725
3414
|
} catch (cause) {
|
|
2726
3415
|
if (cause.code === "ENOENT") return null;
|
|
2727
3416
|
throw new Error("server.js: file not readable.");
|
|
2728
3417
|
}
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
throw new Error(`server.js is not valid:
|
|
2732
|
-
${result.errori.map((error) => `- ${error}`).join("\n")}`);
|
|
2733
|
-
}
|
|
3418
|
+
if (!stat.isFile()) throw new Error("server.js: file not readable.");
|
|
3419
|
+
const { source } = await bundleServer(root);
|
|
2734
3420
|
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
3421
|
const rewritten = source.replace(
|
|
2736
3422
|
/(\bfrom\s*)(['"])@caisual\/kit\/server\2/g,
|
|
@@ -2760,9 +3446,12 @@ var DevService = class {
|
|
|
2760
3446
|
saves = /* @__PURE__ */ new Map();
|
|
2761
3447
|
scores = /* @__PURE__ */ new Map();
|
|
2762
3448
|
rooms = /* @__PURE__ */ new Map();
|
|
3449
|
+
deposito = new DepositoDev();
|
|
2763
3450
|
roomByCode = /* @__PURE__ */ new Map();
|
|
3451
|
+
matchQueues = /* @__PURE__ */ new Map();
|
|
2764
3452
|
kitRequests = /* @__PURE__ */ new Map();
|
|
2765
3453
|
liveRequests = /* @__PURE__ */ new Map();
|
|
3454
|
+
matchOperations = Promise.resolve();
|
|
2766
3455
|
playerNumber = 0;
|
|
2767
3456
|
get portalOrigin() {
|
|
2768
3457
|
return `http://localhost:${this.port}`;
|
|
@@ -2785,6 +3474,10 @@ var DevService = class {
|
|
|
2785
3474
|
}
|
|
2786
3475
|
async handleUpgrade(request, socket, head) {
|
|
2787
3476
|
const url = new URL(request.url ?? "/", this.portalOrigin);
|
|
3477
|
+
if (url.pathname === "/match") {
|
|
3478
|
+
await this.handleMatchUpgrade(request, socket, head, url);
|
|
3479
|
+
return;
|
|
3480
|
+
}
|
|
2788
3481
|
const match = /^\/rooms\/(g1-1\.[a-z0-9]{16})$/.exec(url.pathname);
|
|
2789
3482
|
if (match === null || match[1] === void 0) {
|
|
2790
3483
|
this.rejectUpgrade(socket, 404, "room_not_found", "The room was not found.");
|
|
@@ -2795,8 +3488,15 @@ var DevService = class {
|
|
|
2795
3488
|
this.rejectUpgrade(socket, 404, "room_not_found", "The room was not found.");
|
|
2796
3489
|
return;
|
|
2797
3490
|
}
|
|
2798
|
-
const
|
|
2799
|
-
const
|
|
3491
|
+
const tokenRoom = url.searchParams.get("j");
|
|
3492
|
+
const tokenWatch = url.searchParams.get("w");
|
|
3493
|
+
if (tokenRoom !== null && tokenWatch !== null) {
|
|
3494
|
+
this.rejectUpgrade(socket, 400, "invalid_request", "Provide either a room token or a watch token.");
|
|
3495
|
+
return;
|
|
3496
|
+
}
|
|
3497
|
+
const aud = tokenWatch === null ? "room" : "watch";
|
|
3498
|
+
const token = tokenWatch ?? tokenRoom;
|
|
3499
|
+
const joined = token === null ? null : readJoinTicket(token, match[1], aud, this.secret);
|
|
2800
3500
|
const origin = request.headers.origin;
|
|
2801
3501
|
const nodeClient = origin === void 0 && request.headers["user-agent"] === "node";
|
|
2802
3502
|
if (joined === null || origin !== this.gameOrigin && !nodeClient) {
|
|
@@ -2804,6 +3504,30 @@ var DevService = class {
|
|
|
2804
3504
|
return;
|
|
2805
3505
|
}
|
|
2806
3506
|
const identity = playerFromTicket(joined);
|
|
3507
|
+
try {
|
|
3508
|
+
this.checkRate(this.liveRequests, identity.id);
|
|
3509
|
+
} catch (cause) {
|
|
3510
|
+
const error = cause instanceof DevHttpError ? cause : new DevHttpError(429, "rate_limited", "Too many game API requests were sent.");
|
|
3511
|
+
this.rejectUpgrade(socket, error.status, error.code, error.message);
|
|
3512
|
+
return;
|
|
3513
|
+
}
|
|
3514
|
+
if (aud === "watch") {
|
|
3515
|
+
const permission2 = await localRoom.room.canWatch();
|
|
3516
|
+
if (!permission2.ok) {
|
|
3517
|
+
const status = permission2.code === "room_not_found" ? 404 : 409;
|
|
3518
|
+
this.rejectUpgrade(socket, status, permission2.code, this.roomErrorMessage(permission2.code));
|
|
3519
|
+
return;
|
|
3520
|
+
}
|
|
3521
|
+
try {
|
|
3522
|
+
const websocket = acceptNodeWebSocket2(request, socket, head);
|
|
3523
|
+
await localRoom.room.watch(websocket, identity);
|
|
3524
|
+
} catch {
|
|
3525
|
+
if (!socket.destroyed) {
|
|
3526
|
+
this.rejectUpgrade(socket, 400, "invalid_request", "The WebSocket request is invalid.");
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
return;
|
|
3530
|
+
}
|
|
2807
3531
|
const permission = await localRoom.room.canJoin(identity);
|
|
2808
3532
|
if (!permission.ok) {
|
|
2809
3533
|
const status = permission.code === "room_not_found" ? 404 : 409;
|
|
@@ -2812,12 +3536,234 @@ var DevService = class {
|
|
|
2812
3536
|
}
|
|
2813
3537
|
try {
|
|
2814
3538
|
const websocket = acceptNodeWebSocket2(request, socket, head);
|
|
2815
|
-
await localRoom.room.connect(websocket, identity);
|
|
3539
|
+
const result = await localRoom.room.connect(websocket, identity);
|
|
3540
|
+
if (result.ok) localRoom.pendingMatch.delete(identity.id);
|
|
2816
3541
|
} catch {
|
|
2817
3542
|
if (!socket.destroyed) this.rejectUpgrade(socket, 400, "invalid_request", "The WebSocket request is invalid.");
|
|
2818
3543
|
}
|
|
2819
3544
|
}
|
|
3545
|
+
async handleMatchUpgrade(request, socket, head, url) {
|
|
3546
|
+
const token = url.searchParams.get("j");
|
|
3547
|
+
const ticket = token === null ? null : readMatchTicket(token, this.manifest.id, this.secret);
|
|
3548
|
+
const origin = request.headers.origin;
|
|
3549
|
+
const nodeClient = origin === void 0 && request.headers["user-agent"] === "node";
|
|
3550
|
+
if (ticket === null || origin !== this.gameOrigin && !nodeClient) {
|
|
3551
|
+
this.rejectUpgrade(socket, 401, "unauthorized", "The matchmaking connection is not authorized.");
|
|
3552
|
+
return;
|
|
3553
|
+
}
|
|
3554
|
+
try {
|
|
3555
|
+
this.checkRate(this.liveRequests, ticket.sub);
|
|
3556
|
+
} catch (cause) {
|
|
3557
|
+
const error = cause instanceof DevHttpError ? cause : new DevHttpError(429, "rate_limited", "Too many game API requests were sent.");
|
|
3558
|
+
this.rejectUpgrade(socket, error.status, error.code, error.message);
|
|
3559
|
+
return;
|
|
3560
|
+
}
|
|
3561
|
+
let websocket;
|
|
3562
|
+
try {
|
|
3563
|
+
websocket = acceptNodeWebSocket2(request, socket, head);
|
|
3564
|
+
} catch {
|
|
3565
|
+
if (!socket.destroyed) {
|
|
3566
|
+
this.rejectUpgrade(socket, 400, "invalid_request", "The WebSocket request is invalid.");
|
|
3567
|
+
}
|
|
3568
|
+
return;
|
|
3569
|
+
}
|
|
3570
|
+
const queueId = this.matchQueueId(ticket);
|
|
3571
|
+
websocket.on("message", (message) => this.handleMatchMessage(websocket, message));
|
|
3572
|
+
websocket.on("close", () => {
|
|
3573
|
+
void this.serializeMatch(() => this.removeMatchWaiter(queueId, websocket));
|
|
3574
|
+
});
|
|
3575
|
+
try {
|
|
3576
|
+
await this.serializeMatch(() => this.enterMatchQueue(queueId, ticket, websocket));
|
|
3577
|
+
} catch {
|
|
3578
|
+
this.sendMatchError(websocket, "internal_error", "The local matchmaking search failed.");
|
|
3579
|
+
}
|
|
3580
|
+
}
|
|
3581
|
+
matchQueueId(ticket) {
|
|
3582
|
+
return `${ticket.game}\0${ticket.mode}\0${ticket.key}`;
|
|
3583
|
+
}
|
|
3584
|
+
serializeMatch(operation) {
|
|
3585
|
+
const result = this.matchOperations.then(operation);
|
|
3586
|
+
this.matchOperations = result.then(() => void 0, () => void 0);
|
|
3587
|
+
return result;
|
|
3588
|
+
}
|
|
3589
|
+
matchQueue(queueId) {
|
|
3590
|
+
let queue = this.matchQueues.get(queueId);
|
|
3591
|
+
if (queue === void 0) {
|
|
3592
|
+
queue = { waiting: [], open: [], timer: null };
|
|
3593
|
+
this.matchQueues.set(queueId, queue);
|
|
3594
|
+
}
|
|
3595
|
+
return queue;
|
|
3596
|
+
}
|
|
3597
|
+
handleMatchMessage(socket, frame) {
|
|
3598
|
+
if (Buffer.byteLength(frame, "utf8") > MASSIMO_FRAME_MATCH) {
|
|
3599
|
+
socket.close(4009, "bad_message");
|
|
3600
|
+
return;
|
|
3601
|
+
}
|
|
3602
|
+
let message = null;
|
|
3603
|
+
try {
|
|
3604
|
+
message = object(JSON.parse(frame));
|
|
3605
|
+
} catch {
|
|
3606
|
+
}
|
|
3607
|
+
if (message === null || message.t !== "ping" || typeof message.c !== "number" || !Number.isFinite(message.c) || Object.keys(message).some((key) => key !== "t" && key !== "c")) {
|
|
3608
|
+
socket.close(4009, "bad_message");
|
|
3609
|
+
return;
|
|
3610
|
+
}
|
|
3611
|
+
socket.send(JSON.stringify({ t: "pong", c: message.c }));
|
|
3612
|
+
}
|
|
3613
|
+
async enterMatchQueue(queueId, ticket, socket) {
|
|
3614
|
+
const queue = this.matchQueue(queueId);
|
|
3615
|
+
const previous = queue.waiting.findIndex((waiting) => waiting.ticket.sub === ticket.sub);
|
|
3616
|
+
if (previous >= 0) {
|
|
3617
|
+
const [replaced] = queue.waiting.splice(previous, 1);
|
|
3618
|
+
replaced?.socket.close(4006, "replaced");
|
|
3619
|
+
}
|
|
3620
|
+
if (await this.fillOpenMatchRoom(queue, ticket, socket)) {
|
|
3621
|
+
this.scheduleMatchQueue(queueId, queue);
|
|
3622
|
+
return;
|
|
3623
|
+
}
|
|
3624
|
+
queue.waiting.push({ ticket, socket, at: Date.now() });
|
|
3625
|
+
this.notifyMatchQueue(queue);
|
|
3626
|
+
this.scheduleMatchQueue(queueId, queue);
|
|
3627
|
+
if (queue.waiting.length >= ticket.max) {
|
|
3628
|
+
await this.openMatchRoom(queueId, queue, ticket.max);
|
|
3629
|
+
}
|
|
3630
|
+
}
|
|
3631
|
+
async fillOpenMatchRoom(queue, ticket, socket) {
|
|
3632
|
+
const now = Date.now();
|
|
3633
|
+
const remove = /* @__PURE__ */ new Set();
|
|
3634
|
+
for (const opened of [...queue.open].sort((left, right) => right.at - left.at)) {
|
|
3635
|
+
if (now - opened.at >= DURATA_STANZA_APERTA) {
|
|
3636
|
+
remove.add(opened.roomId);
|
|
3637
|
+
continue;
|
|
3638
|
+
}
|
|
3639
|
+
const localRoom = this.rooms.get(opened.roomId);
|
|
3640
|
+
const info = localRoom === void 0 ? null : await localRoom.room.info();
|
|
3641
|
+
if (localRoom === void 0 || info === null || info.status === "ended") {
|
|
3642
|
+
remove.add(opened.roomId);
|
|
3643
|
+
continue;
|
|
3644
|
+
}
|
|
3645
|
+
for (const [playerId, expiresAt] of localRoom.pendingMatch) {
|
|
3646
|
+
if (expiresAt <= now) localRoom.pendingMatch.delete(playerId);
|
|
3647
|
+
}
|
|
3648
|
+
const canEnter = info.status === "lobby" || !ticket.lobby;
|
|
3649
|
+
if (!canEnter || info.players + localRoom.pendingMatch.size >= info.max) continue;
|
|
3650
|
+
const permission = await localRoom.room.canJoin(playerFromTicket(ticket));
|
|
3651
|
+
if (!permission.ok) {
|
|
3652
|
+
if (permission.code === "room_not_found" || permission.code === "room_ended") {
|
|
3653
|
+
remove.add(opened.roomId);
|
|
3654
|
+
}
|
|
3655
|
+
continue;
|
|
3656
|
+
}
|
|
3657
|
+
localRoom.pendingMatch.set(ticket.sub, now + DURATA_INGRESSO * 1e3);
|
|
3658
|
+
queue.open = queue.open.filter((entry) => !remove.has(entry.roomId));
|
|
3659
|
+
this.sendMatched(socket, opened.roomId, localRoom, playerFromTicket(ticket));
|
|
3660
|
+
return true;
|
|
3661
|
+
}
|
|
3662
|
+
queue.open = queue.open.filter((entry) => !remove.has(entry.roomId));
|
|
3663
|
+
return false;
|
|
3664
|
+
}
|
|
3665
|
+
async openMatchRoom(queueId, queue, count) {
|
|
3666
|
+
const selected = queue.waiting.splice(0, Math.min(count, queue.waiting.length));
|
|
3667
|
+
const first = selected[0];
|
|
3668
|
+
if (first === void 0) return;
|
|
3669
|
+
try {
|
|
3670
|
+
const { roomId, localRoom } = await this.openLocalRoom(
|
|
3671
|
+
first.ticket.mode,
|
|
3672
|
+
playerFromTicket(first.ticket)
|
|
3673
|
+
);
|
|
3674
|
+
const expiresAt = Date.now() + DURATA_INGRESSO * 1e3;
|
|
3675
|
+
for (const waiting of selected) {
|
|
3676
|
+
localRoom.pendingMatch.set(waiting.ticket.sub, expiresAt);
|
|
3677
|
+
}
|
|
3678
|
+
queue.open = queue.open.filter((entry) => Date.now() - entry.at < DURATA_STANZA_APERTA);
|
|
3679
|
+
queue.open.push({ roomId, at: Date.now() });
|
|
3680
|
+
if (queue.open.length > 20) queue.open.splice(0, queue.open.length - 20);
|
|
3681
|
+
for (const waiting of selected) {
|
|
3682
|
+
this.sendMatched(waiting.socket, roomId, localRoom, playerFromTicket(waiting.ticket));
|
|
3683
|
+
}
|
|
3684
|
+
} catch (cause) {
|
|
3685
|
+
const code = cause instanceof DevHttpError ? cause.code : "internal_error";
|
|
3686
|
+
const message = cause instanceof DevHttpError ? cause.message : "The local room could not be created.";
|
|
3687
|
+
for (const waiting of selected) this.sendMatchError(waiting.socket, code, message);
|
|
3688
|
+
}
|
|
3689
|
+
this.notifyMatchQueue(queue);
|
|
3690
|
+
this.scheduleMatchQueue(queueId, queue);
|
|
3691
|
+
}
|
|
3692
|
+
removeMatchWaiter(queueId, socket) {
|
|
3693
|
+
const queue = this.matchQueues.get(queueId);
|
|
3694
|
+
if (queue === void 0) return;
|
|
3695
|
+
const index = queue.waiting.findIndex((waiting) => waiting.socket === socket);
|
|
3696
|
+
if (index < 0) return;
|
|
3697
|
+
queue.waiting.splice(index, 1);
|
|
3698
|
+
this.notifyMatchQueue(queue);
|
|
3699
|
+
this.scheduleMatchQueue(queueId, queue);
|
|
3700
|
+
}
|
|
3701
|
+
notifyMatchQueue(queue) {
|
|
3702
|
+
for (const waiting of queue.waiting) {
|
|
3703
|
+
waiting.socket.send(JSON.stringify({
|
|
3704
|
+
t: "waiting",
|
|
3705
|
+
players: queue.waiting.length,
|
|
3706
|
+
min: waiting.ticket.min,
|
|
3707
|
+
max: waiting.ticket.max
|
|
3708
|
+
}));
|
|
3709
|
+
}
|
|
3710
|
+
}
|
|
3711
|
+
scheduleMatchQueue(queueId, queue) {
|
|
3712
|
+
if (queue.timer !== null) clearTimeout(queue.timer);
|
|
3713
|
+
queue.timer = null;
|
|
3714
|
+
const next = queue.waiting.reduce(
|
|
3715
|
+
(nearest, waiting) => Math.min(nearest, waiting.at + waiting.ticket.timeoutMs),
|
|
3716
|
+
Number.POSITIVE_INFINITY
|
|
3717
|
+
);
|
|
3718
|
+
if (!Number.isFinite(next)) return;
|
|
3719
|
+
queue.timer = setTimeout(() => {
|
|
3720
|
+
queue.timer = null;
|
|
3721
|
+
void this.serializeMatch(() => this.expireMatchQueue(queueId));
|
|
3722
|
+
}, Math.max(0, next - Date.now()));
|
|
3723
|
+
queue.timer.unref();
|
|
3724
|
+
}
|
|
3725
|
+
async expireMatchQueue(queueId) {
|
|
3726
|
+
const queue = this.matchQueues.get(queueId);
|
|
3727
|
+
if (queue === void 0 || queue.waiting.length === 0) return;
|
|
3728
|
+
const now = Date.now();
|
|
3729
|
+
const expired = queue.waiting.filter(
|
|
3730
|
+
(waiting) => waiting.at + waiting.ticket.timeoutMs <= now
|
|
3731
|
+
);
|
|
3732
|
+
if (expired.length === 0) {
|
|
3733
|
+
this.scheduleMatchQueue(queueId, queue);
|
|
3734
|
+
return;
|
|
3735
|
+
}
|
|
3736
|
+
const first = queue.waiting[0];
|
|
3737
|
+
if (first !== void 0 && queue.waiting.length >= first.ticket.min) {
|
|
3738
|
+
await this.openMatchRoom(queueId, queue, Math.min(first.ticket.max, queue.waiting.length));
|
|
3739
|
+
return;
|
|
3740
|
+
}
|
|
3741
|
+
const expiredSockets = new Set(expired.map((waiting) => waiting.socket));
|
|
3742
|
+
queue.waiting = queue.waiting.filter((waiting) => !expiredSockets.has(waiting.socket));
|
|
3743
|
+
for (const waiting of expired) {
|
|
3744
|
+
waiting.socket.send(JSON.stringify({ t: "no_match" }));
|
|
3745
|
+
waiting.socket.close(1e3);
|
|
3746
|
+
}
|
|
3747
|
+
this.notifyMatchQueue(queue);
|
|
3748
|
+
this.scheduleMatchQueue(queueId, queue);
|
|
3749
|
+
}
|
|
3750
|
+
sendMatched(socket, roomId, localRoom, player) {
|
|
3751
|
+
socket.send(JSON.stringify({
|
|
3752
|
+
t: "matched",
|
|
3753
|
+
...this.joinResponse(roomId, localRoom.code, player)
|
|
3754
|
+
}));
|
|
3755
|
+
socket.close(1e3);
|
|
3756
|
+
}
|
|
3757
|
+
sendMatchError(socket, code, message) {
|
|
3758
|
+
socket.send(JSON.stringify({ t: "error", code, message }));
|
|
3759
|
+
socket.close(1e3);
|
|
3760
|
+
}
|
|
2820
3761
|
async close() {
|
|
3762
|
+
for (const queue of this.matchQueues.values()) {
|
|
3763
|
+
if (queue.timer !== null) clearTimeout(queue.timer);
|
|
3764
|
+
for (const waiting of queue.waiting) waiting.socket.close(1001, "server_shutdown");
|
|
3765
|
+
}
|
|
3766
|
+
this.matchQueues.clear();
|
|
2821
3767
|
await Promise.all([...this.rooms.values()].map((entry) => entry.room.close()));
|
|
2822
3768
|
}
|
|
2823
3769
|
async handleGame(request, response, url) {
|
|
@@ -2830,7 +3776,7 @@ var DevService = class {
|
|
|
2830
3776
|
response.setHeader("Content-Type", "text/javascript; charset=utf-8");
|
|
2831
3777
|
response.setHeader("Cache-Control", "no-store");
|
|
2832
3778
|
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');
|
|
3779
|
+
response.end(request.method === "HEAD" ? void 0 : '// Caisual game kit v0.5.0\n\n// ../contracts/src/slug.ts\nvar NOMI_RISERVATI = [\n "www",\n "api",\n "app",\n "play",\n "live",\n "multi",\n "cdn",\n "assets",\n "static",\n "mail",\n "mx",\n "ns1",\n "ns2",\n "autodiscover",\n "_dmarc",\n "admin",\n "login",\n "account",\n "auth",\n "pay",\n "secure",\n "support",\n "help",\n "blog",\n "status",\n "dev",\n "staging",\n "test",\n "caisual",\n "shipz"\n];\nvar RISERVATI = new Set(NOMI_RISERVATI);\n\n// ../contracts/src/device.ts\nfunction deviceTier(report) {\n if (report.gpu !== "hardware" || report.memoryMb !== null && report.memoryMb <= 2048) return "low";\n if (report.mobile || report.memoryMb !== null && report.memoryMb <= 4096 || report.cores !== null && report.cores <= 4) return "mid";\n return "high";\n}\nfunction perdiContesto(context) {\n try {\n context?.getExtension("WEBGL_lose_context")?.loseContext();\n } catch {\n }\n}\nfunction valoriSincroni(ambiente) {\n let navigator2;\n try {\n navigator2 = ambiente.navigator;\n } catch {\n navigator2 = void 0;\n }\n let memoryMb = null;\n try {\n const memory = navigator2?.deviceMemory;\n const converted = typeof memory === "number" ? memory * 1024 : NaN;\n if (Number.isFinite(converted)) memoryMb = converted;\n } catch {\n memoryMb = null;\n }\n let cores = null;\n try {\n const value = navigator2?.hardwareConcurrency;\n if (typeof value === "number" && Number.isFinite(value)) cores = value;\n } catch {\n cores = null;\n }\n let mobile = false;\n try {\n mobile = typeof navigator2?.userAgentData?.mobile === "boolean" ? navigator2.userAgentData.mobile : /Android|iPhone|iPad|iPod|Mobile/i.test(navigator2?.userAgent ?? "");\n } catch {\n mobile = false;\n }\n let isolated = false;\n try {\n isolated = ambiente.crossOriginIsolated === true;\n } catch {\n isolated = false;\n }\n return {\n webgl2: false,\n webgpu: false,\n wasm: false,\n threads: false,\n isolated,\n gpu: "none",\n memoryMb,\n cores,\n mobile\n };\n}\nasync function probeDevice(globals, timeoutMs = 1500) {\n const ambiente = globals ?? globalThis;\n const report = valoriSincroni(ambiente);\n const webgl = Promise.resolve().then(() => {\n try {\n const canvas = ambiente.document?.createElement("canvas");\n if (canvas === void 0) return;\n const hardware = canvas.getContext("webgl2", { failIfMajorPerformanceCaveat: true });\n if (hardware !== null) {\n report.webgl2 = true;\n report.gpu = "hardware";\n perdiContesto(hardware);\n return;\n }\n const software = canvas.getContext("webgl2");\n if (software !== null) {\n report.webgl2 = true;\n report.gpu = "software";\n perdiContesto(software);\n }\n } catch {\n report.webgl2 = false;\n report.gpu = "none";\n }\n });\n const webgpu = Promise.resolve().then(async () => {\n let device;\n try {\n const gpu = ambiente.navigator?.gpu;\n if (gpu === void 0) return;\n const adapter = await gpu.requestAdapter();\n if (adapter === null) return;\n device = await adapter.requestDevice();\n report.webgpu = true;\n } catch {\n report.webgpu = false;\n } finally {\n try {\n device?.destroy?.();\n } catch {\n }\n }\n });\n const wasm = Promise.resolve().then(() => {\n try {\n report.wasm = ambiente.WebAssembly?.validate(\n new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0])\n ) === true;\n } catch {\n report.wasm = false;\n }\n });\n const threads = Promise.resolve().then(() => {\n try {\n if (ambiente.WebAssembly === void 0) return;\n new ambiente.WebAssembly.Memory({ initial: 1, maximum: 1, shared: true });\n report.threads = true;\n } catch {\n report.threads = false;\n }\n });\n let timer;\n await Promise.race([\n Promise.all([webgl, webgpu, wasm, threads]),\n new Promise((resolve) => {\n timer = setTimeout(resolve, Math.max(0, timeoutMs));\n })\n ]);\n if (timer !== void 0) clearTimeout(timer);\n return { ...report, tier: deviceTier(report) };\n}\n\n// src/errors.ts\nfunction creaErrore(code, message) {\n return Object.assign(new Error(message), { name: "CaisualError", code });\n}\nfunction erroreOffline() {\n return creaErrore("offline", "Caisual services are unavailable.");\n}\nfunction codiceErrore(valore) {\n return typeof valore === "object" && valore !== null && "code" in valore ? valore.code : null;\n}\n\n// src/http.ts\nasync function leggiErrore(response) {\n let corpo = {};\n try {\n corpo = await response.json();\n } catch {\n }\n return creaErrore(\n typeof corpo.error?.code === "string" ? corpo.error.code : response.status === 401 ? "invalid_ticket" : "internal_error",\n typeof corpo.error?.message === "string" ? corpo.error.message : `The request failed with status ${response.status}.`\n );\n}\nfunction creaRichiedente(origin, prefix, fetcher, biglietto) {\n async function manda(path, metodo, ticket, corpo) {\n const headers = new Headers({ Authorization: `Bearer ${ticket}` });\n let body;\n if (corpo !== void 0) {\n headers.set("Content-Type", "application/json");\n try {\n body = JSON.stringify(corpo);\n } catch {\n throw creaErrore("invalid_request", "The value must be valid JSON.");\n }\n }\n try {\n return await fetcher(new URL(prefix + path, origin), {\n method: metodo,\n headers,\n body,\n credentials: "omit"\n });\n } catch {\n throw erroreOffline();\n }\n }\n return async function richiesta(path, metodo, corpo, forzaRinnovo = false) {\n let ticket;\n try {\n ticket = forzaRinnovo ? await biglietto.rinnova() : await biglietto.ottieni();\n } catch {\n throw erroreOffline();\n }\n let response = await manda(path, metodo, ticket, corpo);\n if (response.status === 401) {\n try {\n ticket = await biglietto.rinnova();\n } catch {\n throw erroreOffline();\n }\n response = await manda(path, metodo, ticket, corpo);\n }\n if (!response.ok) throw await leggiErrore(response);\n try {\n return await response.json();\n } catch {\n throw creaErrore("internal_error", "The service returned an invalid response.");\n }\n };\n}\n\n// src/api.ts\nfunction creaClienteApi(appOrigin, fetcher, biglietto) {\n const richiesta = creaRichiedente(appOrigin, "/api/kit", fetcher, biglietto);\n return {\n me: () => richiesta("/me", "GET"),\n saveSet: (key, value) => richiesta(`/saves/${encodeURIComponent(key)}`, "PUT", { value }),\n async saveGet(key) {\n try {\n return (await richiesta(`/saves/${encodeURIComponent(key)}`, "GET")).value;\n } catch (errore) {\n if (codiceErrore(errore) === "not_found") return null;\n throw errore;\n }\n },\n async saveRemove(key) {\n await richiesta(`/saves/${encodeURIComponent(key)}`, "DELETE");\n },\n async saveList() {\n return (await richiesta("/saves", "GET")).saves;\n },\n async boardSubmit(board, score, daily) {\n const risultato = await richiesta("/scores", "POST", { board, score, daily });\n return { accepted: true, best: risultato.best, rank: risultato.rank, day: risultato.day };\n },\n async boardTop(board, opzioni) {\n const query = new URLSearchParams();\n if (opzioni.daily) query.set("daily", "1");\n if (opzioni.limit !== void 0) query.set("limit", String(opzioni.limit));\n if (opzioni.guests) query.set("guests", "1");\n const suffisso = query.size === 0 ? "" : `?${query.toString()}`;\n const { day, entries, me } = await richiesta(\n `/scores/${encodeURIComponent(board)}${suffisso}`,\n "GET"\n );\n return { day, entries, me };\n }\n };\n}\n\n// src/daily.ts\nvar DIVISORE_UINT32 = 4294967296;\nfunction giornoUtc(ora) {\n return new Date(ora).toISOString().slice(0, 10);\n}\nasync function calcolaSeed(gioco, giorno, subtle) {\n const dati = new TextEncoder().encode(`caisual:${gioco}:${giorno}`);\n const digest = new Uint8Array(await subtle.digest("SHA-256", dati));\n return (digest[0] ?? 0) * 16777216 + ((digest[1] ?? 0) << 16) + ((digest[2] ?? 0) << 8) + (digest[3] ?? 0) >>> 0;\n}\nfunction creaMulberry32(seed) {\n let stato = seed >>> 0;\n return () => {\n stato = stato + 1831565813 >>> 0;\n let valore = stato;\n valore = Math.imul(valore ^ valore >>> 15, valore | 1);\n valore ^= valore + Math.imul(valore ^ valore >>> 7, valore | 61);\n return ((valore ^ valore >>> 14) >>> 0) / DIVISORE_UINT32;\n };\n}\n\n// src/handshake.ts\nfunction record(valore) {\n return typeof valore === "object" && valore !== null && !Array.isArray(valore) ? valore : null;\n}\nfunction eTipo(valore, tipo) {\n return record(valore)?.type === tipo;\n}\nfunction leggiOrigine(valore) {\n if (typeof valore !== "string") return null;\n try {\n const url = new URL(valore);\n return url.origin === valore && (url.protocol === "https:" || url.protocol === "http:") ? valore : null;\n } catch {\n return null;\n }\n}\nfunction attendiHandshake(finestra, appOrigin, timeoutMs = 3e3) {\n return new Promise((resolve) => {\n let concluso = false;\n const termina = (esito) => {\n if (concluso) return;\n concluso = true;\n finestra.removeEventListener("message", ascolta);\n finestra.clearTimeout(scadenza);\n resolve(esito);\n };\n const segnalaPronto = () => {\n finestra.parent.postMessage({ type: "caisual:ready" }, appOrigin);\n };\n const ascolta = (evento) => {\n if (evento.origin !== appOrigin || evento.source !== finestra.parent) return;\n if (eTipo(evento.data, "caisual:ready?")) {\n segnalaPronto();\n return;\n }\n if (!eTipo(evento.data, "caisual:hello")) return;\n const dati = record(evento.data);\n const porta = evento.ports[0];\n if (typeof dati?.ticket !== "string" || porta === void 0) return;\n porta.start();\n termina({\n ticket: dati.ticket,\n live: leggiOrigine(dati.live),\n invite: typeof dati.invite === "string" ? dati.invite : null,\n porta\n });\n };\n finestra.addEventListener("message", ascolta);\n const scadenza = finestra.setTimeout(() => termina(null), timeoutMs);\n segnalaPronto();\n });\n}\nfunction scadenzaJwt(ticket) {\n const parte = ticket.split(".")[1];\n if (parte === void 0) return null;\n const base64 = parte.replace(/-/g, "+").replace(/_/g, "/").padEnd(\n Math.ceil(parte.length / 4) * 4,\n "="\n );\n try {\n const payload = record(JSON.parse(globalThis.atob(base64)));\n return typeof payload?.exp === "number" && Number.isFinite(payload.exp) ? payload.exp * 1e3 : null;\n } catch {\n return null;\n }\n}\nfunction chiediBiglietto(porta, finestra, timeoutMs, aud) {\n return new Promise((resolve, reject) => {\n let concluso = false;\n const termina = (ticket) => {\n if (concluso) return;\n concluso = true;\n porta.removeEventListener("message", ascolta);\n finestra.clearTimeout(scadenza);\n if (ticket === null) reject(new Error("Ticket refresh timed out."));\n else resolve(ticket);\n };\n const ascolta = (evento) => {\n const dati = record(evento.data);\n const destinatario = dati?.aud === void 0 ? "portal" : dati.aud;\n if (dati?.type === "caisual:ticket" && destinatario === aud && typeof dati.ticket === "string") {\n termina(dati.ticket);\n }\n };\n porta.addEventListener("message", ascolta);\n const scadenza = finestra.setTimeout(() => termina(null), timeoutMs);\n try {\n porta.postMessage(aud === "live" ? { type: "caisual:ticket", aud: "live" } : { type: "caisual:ticket" });\n } catch {\n termina(null);\n }\n });\n}\nfunction creaGestoreBiglietto(ticketIniziale, porta, finestra, ora, timeoutMs = 3e3, aud = "portal") {\n let ticket = ticketIniziale;\n let rinnovo = null;\n const rinnova = () => {\n if (rinnovo !== null) return rinnovo;\n const richiesta = chiediBiglietto(porta, finestra, timeoutMs, aud).then((nuovo) => {\n ticket = nuovo;\n return nuovo;\n });\n const completa = richiesta.finally(() => {\n if (rinnovo === completa) rinnovo = null;\n });\n rinnovo = completa;\n return completa;\n };\n return {\n async ottieni() {\n if (ticket === null) return rinnova();\n const scadenza = scadenzaJwt(ticket);\n return scadenza !== null && scadenza - ora() < 3e4 ? rinnova() : ticket;\n },\n rinnova\n };\n}\n\n// src/voce/index.ts\nvar SOGLIA_AUDIO = 0.02;\nvar DURATA_PARLANTE = 300;\nvar INTERVALLO_AUDIO = 200;\nvar DURATA_ZERO = 3e3;\nvar TIMEOUT_CONNESSIONE = 1e4;\nvar RITARDI_RICONNESSIONE = [1e3, 2e3, 4e3];\nfunction limita(value) {\n return Number.isNaN(value) ? 1 : Math.min(1, Math.max(0, value));\n}\nfunction dipendenzeReali(input) {\n const globali = globalThis;\n const AudioContextClass = globali.AudioContext ?? globali.webkitAudioContext;\n if (typeof RTCPeerConnection === "undefined" || typeof MediaStream === "undefined" || AudioContextClass === void 0 || typeof navigator === "undefined" || navigator.mediaDevices?.getUserMedia === void 0 || typeof document === "undefined") return null;\n return {\n ...input,\n creaPeerConnection: (configuration) => new RTCPeerConnection(configuration),\n getUserMedia: (constraints) => navigator.mediaDevices.getUserMedia(constraints),\n creaAudioContext: () => new AudioContextClass(),\n creaAudioElement: () => document.createElement("audio"),\n creaMediaStream: (tracks) => new MediaStream(tracks)\n };\n}\nvar VoceClient = class {\n constructor(contesto, timer, dipendenze) {\n this.contesto = contesto;\n this.modeCorrente = "none";\n this.stateCorrente = "off";\n this.mutedCorrente = false;\n this.speakingCorrente = false;\n this.roster = [];\n this.gains = /* @__PURE__ */ new Map();\n this.volumi = /* @__PURE__ */ new Map();\n this.speakingPeers = /* @__PURE__ */ new Map();\n this.ultimoAudio = /* @__PURE__ */ new Map();\n this.zeroDa = /* @__PURE__ */ new Map();\n this.timerZero = /* @__PURE__ */ new Map();\n this.ascoltatoriPeers = /* @__PURE__ */ new Set();\n this.ascoltatoriState = /* @__PURE__ */ new Set();\n this.richieste = /* @__PURE__ */ new Map();\n this.riproduzioni = /* @__PURE__ */ new Map();\n this.sfuAttive = /* @__PURE__ */ new Map();\n this.midGiocatori = /* @__PURE__ */ new Map();\n this.mesh = /* @__PURE__ */ new Map();\n this.stream = null;\n this.tracciaMic = null;\n this.audioContext = null;\n this.analyser = null;\n this.peerSfu = null;\n this.sessioneSfu = null;\n this.connessioneSfuAttesa = false;\n this.trasporto = null;\n this.intervalloAudio = null;\n this.timerConnessione = null;\n this.cancellaAttesaConnessione = null;\n this.timerRiconnessione = null;\n this.ultimoAudioMic = Number.NEGATIVE_INFINITY;\n this.sequenzaRichieste = 0;\n this.generazione = 0;\n this.tentativoRiconnessione = 0;\n this.desiderata = false;\n this.micDesiderato = true;\n this.promessaIngresso = null;\n this.negoziazione = Promise.resolve();\n this.dipendenze = dipendenze ?? dipendenzeReali(timer);\n }\n get mode() {\n return this.modeCorrente;\n }\n get state() {\n return this.stateCorrente;\n }\n get mic() {\n return this.stateCorrente === "on" && this.tracciaMic !== null;\n }\n get muted() {\n return this.mutedCorrente;\n }\n get speaking() {\n return this.speakingCorrente;\n }\n get peers() {\n return this.copiaPeers();\n }\n async join(options = {}) {\n if (this.stateCorrente === "on") return;\n if (this.stateCorrente === "joining") {\n if (this.promessaIngresso !== null) await this.promessaIngresso;\n return;\n }\n if (this.stateCorrente === "reconnecting" && this.desiderata) return;\n const mic = this.scegliMic(options);\n this.verificaIngresso(mic);\n this.micDesiderato = mic;\n this.desiderata = true;\n this.tentativoRiconnessione = 0;\n this.aggiornaState("joining");\n const generazione = ++this.generazione;\n const promessa = this.completaIngresso(generazione);\n this.promessaIngresso = promessa;\n try {\n await promessa;\n } finally {\n if (this.promessaIngresso === promessa) this.promessaIngresso = null;\n }\n }\n async completaIngresso(generazione) {\n try {\n await this.entra(generazione);\n } catch (cause) {\n if (generazione !== this.generazione) return;\n this.desiderata = false;\n this.chiudiRisorse();\n this.aggiornaState("off");\n throw this.mappaErrore(cause);\n }\n }\n leave() {\n const deveFermare = this.desiderata || this.stateCorrente !== "off";\n this.desiderata = false;\n this.generazione++;\n this.fermaRiconnessione();\n if (deveFermare && this.contesto.connessa()) {\n void this.richiedi({ t: "voice", op: "stop" }).catch(() => void 0);\n }\n this.rifiutaRichieste(creaErrore("offline", "Voice has stopped."));\n this.chiudiRisorse();\n this.aggiornaState("off");\n }\n mute(muted = true) {\n if (this.stateCorrente !== "on" || this.tracciaMic === null) {\n throw creaErrore("not_publishing", "Join voice before changing mute.");\n }\n this.mutedCorrente = muted;\n this.tracciaMic.enabled = !muted;\n void this.richiedi({ t: "voice", op: "mute", muted }).catch(() => void 0);\n }\n setVolume(playerId, volume) {\n const valore = limita(volume);\n this.volumi.set(playerId, valore);\n this.aggiornaGuadagno(playerId);\n this.notificaPeers();\n }\n onPeers(listener) {\n this.ascoltatoriPeers.add(listener);\n return () => {\n this.ascoltatoriPeers.delete(listener);\n };\n }\n onState(listener) {\n this.ascoltatoriState.add(listener);\n return () => {\n this.ascoltatoriState.delete(listener);\n };\n }\n ricevi(message) {\n if ("r" in message) {\n const pending = this.richieste.get(message.r);\n if (pending !== void 0) {\n this.richieste.delete(message.r);\n if ("error" in message) {\n pending.reject(creaErrore(message.error.code, message.error.message));\n } else pending.resolve(message);\n }\n return;\n }\n if (message.op === "roster") {\n this.modeCorrente = message.mode;\n const publisher = new Set(message.peers.map((peer) => peer.id));\n this.roster = [\n ...message.peers.map((peer) => ({ ...peer, mic: true })),\n ...message.listeners.flatMap((id) => publisher.has(id) ? [] : [{ id, mic: false, muted: true }])\n ];\n for (const peer of this.roster) {\n if (peer.muted) this.speakingPeers.set(peer.id, false);\n }\n this.pulisciPeerAssenti();\n this.contesto.rosterPronto();\n this.notificaPeers();\n this.accodaRiconciliazione();\n return;\n }\n if (message.op === "gain") {\n for (const [playerId, gain] of Object.entries(message.gains)) {\n this.gains.set(playerId, limita(gain));\n this.aggiornaZero(playerId);\n this.aggiornaGuadagno(playerId);\n }\n this.notificaPeers();\n this.accodaRiconciliazione();\n return;\n }\n if (message.op === "signal") void this.riceviSegnale(message.from, message.data);\n }\n giocatoriCambiati() {\n const presenti = new Set(this.contesto.giocatori().map((player) => player.id));\n for (const playerId of this.gains.keys()) {\n if (presenti.has(playerId)) continue;\n this.gains.delete(playerId);\n this.zeroDa.delete(playerId);\n const timer = this.timerZero.get(playerId);\n if (timer !== void 0) this.dipendenze?.clearTimeout(timer);\n this.timerZero.delete(playerId);\n this.aggiornaGuadagno(playerId);\n }\n this.notificaPeers();\n this.accodaRiconciliazione();\n }\n socketDisconnesso() {\n this.sequenzaRichieste = 0;\n this.rifiutaRichieste(creaErrore("offline", "The room is reconnecting."));\n if (!this.desiderata) return;\n this.generazione++;\n this.chiudiRisorse();\n this.tentativoRiconnessione = 0;\n this.aggiornaState("reconnecting");\n }\n socketRiconnesso() {\n this.sequenzaRichieste = 0;\n if (this.desiderata && this.stateCorrente === "reconnecting") this.programmaRiconnessione();\n }\n termina() {\n this.desiderata = false;\n this.generazione++;\n this.fermaRiconnessione();\n this.rifiutaRichieste(creaErrore("offline", "The room connection ended."));\n this.chiudiRisorse();\n this.aggiornaState("off");\n }\n scegliMic(options) {\n if (options.mic !== void 0) return options.mic;\n const you = this.contesto.giocatori().find((player) => player.id === this.contesto.you());\n return you?.role !== "spectator";\n }\n verificaIngresso(mic = this.micDesiderato) {\n if (!this.contesto.connessa()) throw creaErrore("offline", "The room is not connected.");\n if (this.modeCorrente === "none") {\n throw creaErrore("voice_disabled", "Voice is disabled for this room.");\n }\n const you = this.contesto.giocatori().find((player) => player.id === this.contesto.you());\n if (you?.role === "spectator" && mic) {\n throw creaErrore("spectator", "Spectators cannot publish voice.");\n }\n if (this.dipendenze === null) {\n throw creaErrore("unsupported", "Voice is not supported in this browser.");\n }\n }\n async entra(generazione) {\n this.verificaIngresso();\n const dipendenze = this.richiediDipendenze();\n const audioContext = dipendenze.creaAudioContext();\n this.audioContext = audioContext;\n try {\n await audioContext.resume();\n } catch {\n }\n if (this.micDesiderato) {\n let stream;\n try {\n stream = await dipendenze.getUserMedia({ audio: true });\n } catch (cause) {\n if (this.permessoNegato(cause)) {\n throw creaErrore("permission_denied", "Microphone permission was denied.");\n }\n throw creaErrore("voice_error", "The microphone could not be opened.");\n }\n try {\n this.controllaGenerazione(generazione);\n } catch (cause) {\n for (const track of stream.getTracks()) track.stop();\n throw cause;\n }\n const mic = stream.getAudioTracks()[0];\n if (mic === void 0) throw creaErrore("voice_error", "The microphone has no audio track.");\n this.stream = stream;\n this.tracciaMic = mic;\n mic.enabled = !this.mutedCorrente;\n this.preparaAnalizzatore(stream);\n }\n const risposta = await this.richiedi({ t: "voice", op: "ice" });\n this.controllaGenerazione(generazione);\n if (risposta.op !== "ice") throw creaErrore("voice_error", "The voice service returned an invalid response.");\n this.modeCorrente = risposta.mode;\n if (risposta.mode === "none") throw creaErrore("voice_disabled", "Voice is disabled for this room.");\n this.trasporto = risposta.transport;\n if (risposta.transport === "sfu") {\n await this.entraSfu(risposta.iceServers, generazione);\n } else {\n await this.richiedi({ t: "voice", op: "publish", mic: this.micDesiderato });\n }\n if (this.micDesiderato && this.mutedCorrente) {\n await this.richiedi({ t: "voice", op: "mute", muted: true });\n }\n this.controllaGenerazione(generazione);\n this.tentativoRiconnessione = 0;\n this.aggiornaState("on");\n this.avviaMisuraAudio();\n for (const playerId of this.gains.keys()) this.aggiornaZero(playerId);\n this.accodaRiconciliazione();\n }\n async entraSfu(iceServers, generazione) {\n const pc = this.richiediDipendenze().creaPeerConnection({\n iceServers,\n bundlePolicy: "max-bundle"\n });\n this.peerSfu = pc;\n pc.ontrack = (event) => {\n const mid = event.transceiver.mid;\n const playerId = mid === null ? void 0 : this.midGiocatori.get(mid);\n if (playerId !== void 0) this.collegaTraccia(playerId, event.track, event.receiver);\n };\n this.osservaCaduta(pc);\n let risposta;\n if (this.micDesiderato) {\n const transceiver = pc.addTransceiver(this.richiediMic(), { direction: "sendonly" });\n const offer = await pc.createOffer();\n await pc.setLocalDescription(offer);\n this.controllaGenerazione(generazione);\n const mid = transceiver.mid;\n const sdp = pc.localDescription?.sdp;\n if (mid === null || sdp === void 0) {\n throw creaErrore("voice_error", "The voice connection could not create an offer.");\n }\n risposta = await this.richiedi({ t: "voice", op: "session", sdp, mid });\n } else {\n risposta = await this.richiedi({ t: "voice", op: "session" });\n }\n if (risposta.op !== "session") {\n throw creaErrore("voice_error", "The voice service returned an invalid response.");\n }\n this.sessioneSfu = risposta.session;\n if (this.micDesiderato) {\n if (risposta.sdp === null) {\n throw creaErrore("voice_error", "The voice service returned an invalid response.");\n }\n await pc.setRemoteDescription({ type: "answer", sdp: risposta.sdp });\n await this.attendiConnessione(pc, generazione);\n this.connessioneSfuAttesa = true;\n return;\n }\n if (risposta.sdp !== null) {\n throw creaErrore("voice_error", "The voice service returned an invalid response.");\n }\n if (this.publisherDesiderati().length > 0) {\n await this.riconciliaSfu();\n }\n }\n attendiConnessione(pc, generazione) {\n if (pc.connectionState === "connected") return Promise.resolve();\n const dipendenze = this.richiediDipendenze();\n return new Promise((resolve, reject) => {\n const pulisci = () => {\n pc.removeEventListener("connectionstatechange", cambiata);\n if (this.timerConnessione !== null) dipendenze.clearTimeout(this.timerConnessione);\n this.timerConnessione = null;\n this.cancellaAttesaConnessione = null;\n };\n const cambiata = () => {\n if (generazione !== this.generazione) {\n pulisci();\n reject(creaErrore("offline", "Voice was stopped."));\n } else if (pc.connectionState === "connected") {\n pulisci();\n resolve();\n } else if (pc.connectionState === "failed" || pc.connectionState === "closed") {\n pulisci();\n reject(creaErrore("voice_error", "The voice connection failed."));\n }\n };\n pc.addEventListener("connectionstatechange", cambiata);\n this.cancellaAttesaConnessione = () => {\n pulisci();\n reject(creaErrore("offline", "Voice was stopped."));\n };\n this.timerConnessione = dipendenze.setTimeout(() => {\n pulisci();\n reject(creaErrore("voice_error", "The voice connection timed out."));\n }, TIMEOUT_CONNESSIONE);\n });\n }\n accodaRiconciliazione() {\n if (this.stateCorrente !== "on") return;\n this.negoziazione = this.negoziazione.then(async () => {\n if (this.stateCorrente !== "on") return;\n if (this.trasporto === "sfu") await this.riconciliaSfu();\n else if (this.trasporto === "mesh") this.riconciliaMesh();\n }).catch(() => this.avviaRiconnessione());\n }\n async riconciliaSfu() {\n const sessione = this.sessioneSfu;\n const pc = this.peerSfu;\n if (sessione === null || pc === null) return;\n const desiderati = new Map(this.publisherDesiderati().map((peer) => [peer.id, peer]));\n const daChiudere = [];\n for (const [playerId, attiva] of this.sfuAttive) {\n const peer = desiderati.get(playerId);\n if (peer !== void 0 && peer.session === attiva.session && peer.track === attiva.track) continue;\n daChiudere.push(attiva);\n if (!this.riproduzioni.has(playerId)) attiva.receiver?.track.stop();\n this.sfuAttive.delete(playerId);\n this.midGiocatori.delete(attiva.mid);\n this.scollegaTraccia(playerId);\n }\n if (daChiudere.length > 0) {\n await this.richiedi({\n t: "voice",\n op: "close",\n session: sessione,\n mids: daChiudere.map((item) => item.mid)\n });\n }\n const nuove = [...desiderati.values()].filter((peer) => !this.sfuAttive.has(peer.id));\n if (nuove.length === 0) return;\n const risposta = await this.richiedi({\n t: "voice",\n op: "subscribe",\n session: sessione,\n tracks: nuove.map((peer) => ({ session: peer.session, track: peer.track }))\n });\n if (risposta.op !== "subscribe") {\n throw creaErrore("voice_error", "The voice service returned an invalid response.");\n }\n for (const risultato of risposta.tracks) {\n const peer = nuove.find(\n (item) => item.session === risultato.session && item.track === risultato.track\n );\n if (risultato?.mid === null || risultato?.mid === void 0 || risultato.error !== null || peer === void 0) continue;\n this.midGiocatori.set(risultato.mid, peer.id);\n this.sfuAttive.set(peer.id, {\n session: peer.session,\n track: peer.track,\n mid: risultato.mid,\n receiver: null\n });\n }\n await pc.setRemoteDescription({ type: "offer", sdp: risposta.sdp });\n const answer = await pc.createAnswer();\n await pc.setLocalDescription(answer);\n const sdp = pc.localDescription?.sdp;\n if (sdp === void 0) throw creaErrore("voice_error", "The voice answer is missing.");\n await this.richiedi({ t: "voice", op: "answer", session: sessione, sdp });\n if (!this.connessioneSfuAttesa) {\n await this.attendiConnessione(pc, this.generazione);\n this.connessioneSfuAttesa = true;\n }\n }\n riconciliaMesh() {\n const desiderati = new Map(this.peerDesiderati().map((peer) => [peer.id, peer]));\n for (const [playerId, item] of this.mesh) {\n if (desiderati.has(playerId)) continue;\n item.pc.close();\n this.mesh.delete(playerId);\n this.scollegaTraccia(playerId);\n }\n for (const peer of desiderati.values()) {\n if (!this.mesh.has(peer.id)) this.creaMesh(peer);\n }\n }\n creaMesh(peer) {\n const playerId = peer.id;\n const pc = this.richiediDipendenze().creaPeerConnection();\n const item = {\n pc,\n makingOffer: false,\n ignoreOffer: false,\n settingRemoteAnswer: false,\n polite: this.contesto.you() > playerId,\n receiver: null\n };\n this.mesh.set(playerId, item);\n pc.onicecandidate = (event) => {\n if (event.candidate === null) return;\n void this.inviaSegnale(playerId, { kind: "candidate", candidate: event.candidate.toJSON() });\n };\n if (!item.polite) pc.onnegotiationneeded = () => {\n void this.offriMesh(playerId, item);\n };\n pc.ontrack = (event) => {\n item.receiver = event.receiver;\n this.collegaTraccia(playerId, event.track, event.receiver);\n };\n this.osservaCaduta(pc);\n if (this.micDesiderato) {\n pc.addTransceiver(this.richiediMic(), {\n direction: peer.mic ? "sendrecv" : "sendonly"\n });\n } else {\n pc.addTransceiver("audio", { direction: "recvonly" });\n }\n }\n async offriMesh(playerId, item) {\n try {\n item.makingOffer = true;\n const offer = await item.pc.createOffer();\n await item.pc.setLocalDescription(offer);\n const sdp = item.pc.localDescription?.sdp;\n if (sdp !== void 0) await this.inviaSegnale(playerId, { kind: "offer", sdp });\n } finally {\n item.makingOffer = false;\n }\n }\n async riceviSegnale(from, data) {\n if (this.trasporto !== "mesh" || this.stateCorrente !== "on") return;\n const peer = this.peerDesiderati().find((item2) => item2.id === from);\n if (peer === void 0) return;\n if (!this.mesh.has(from)) this.creaMesh(peer);\n const item = this.mesh.get(from);\n if (item === void 0 || typeof data !== "object" || data === null || Array.isArray(data)) return;\n const segnale = data;\n try {\n if (segnale.kind === "candidate") {\n if (!item.ignoreOffer) await item.pc.addIceCandidate(segnale.candidate);\n return;\n }\n if (segnale.kind !== "offer" && segnale.kind !== "answer" || typeof segnale.sdp !== "string") return;\n const pronta = !item.makingOffer && (item.pc.signalingState === "stable" || item.settingRemoteAnswer);\n const collisione = segnale.kind === "offer" && !pronta;\n item.ignoreOffer = !item.polite && collisione;\n if (item.ignoreOffer) return;\n item.settingRemoteAnswer = segnale.kind === "answer";\n await item.pc.setRemoteDescription({ type: segnale.kind, sdp: segnale.sdp });\n item.settingRemoteAnswer = false;\n if (segnale.kind === "offer") {\n const answer = await item.pc.createAnswer();\n await item.pc.setLocalDescription(answer);\n const sdp = item.pc.localDescription?.sdp;\n if (sdp !== void 0) await this.inviaSegnale(from, { kind: "answer", sdp });\n }\n } catch {\n this.avviaRiconnessione();\n }\n }\n inviaSegnale(to, data) {\n return this.richiedi({ t: "voice", op: "signal", to, data });\n }\n peerDesiderati() {\n const you = this.contesto.you();\n const giocatori = this.contesto.giocatori();\n const player = giocatori.find((item) => item.id === you);\n return this.roster.filter((peer) => {\n if (peer.id === you) return false;\n if (!this.micDesiderato && !peer.mic) return false;\n if (this.modeCorrente === "team") {\n const altro = giocatori.find((item) => item.id === peer.id);\n if (player?.role !== "spectator" && altro?.team !== player?.team) return false;\n }\n return true;\n });\n }\n publisherDesiderati() {\n return this.peerDesiderati().filter(\n (peer) => {\n if (!peer.mic) return false;\n const zeroAt = this.zeroDa.get(peer.id);\n return zeroAt === void 0 || this.richiediDipendenze().ora() - zeroAt < DURATA_ZERO;\n }\n );\n }\n aggiornaZero(playerId) {\n const dipendenze = this.dipendenze;\n if (dipendenze === null) return;\n const precedente = this.timerZero.get(playerId);\n if (precedente !== void 0) dipendenze.clearTimeout(precedente);\n this.timerZero.delete(playerId);\n if ((this.gains.get(playerId) ?? 1) > 0) {\n this.zeroDa.delete(playerId);\n return;\n }\n if (!this.zeroDa.has(playerId)) this.zeroDa.set(playerId, dipendenze.ora());\n const trascorso = dipendenze.ora() - (this.zeroDa.get(playerId) ?? dipendenze.ora());\n const timer = dipendenze.setTimeout(() => {\n this.timerZero.delete(playerId);\n this.accodaRiconciliazione();\n }, Math.max(0, DURATA_ZERO - trascorso));\n this.timerZero.set(playerId, timer);\n }\n collegaTraccia(playerId, track, receiver) {\n this.scollegaTraccia(playerId);\n const dipendenze = this.richiediDipendenze();\n const media = dipendenze.creaMediaStream([track]);\n const source = this.richiediAudioContext().createMediaStreamSource(media);\n const gain = this.richiediAudioContext().createGain();\n source.connect(gain);\n gain.connect(this.richiediAudioContext().destination);\n let analyser = null;\n try {\n analyser = this.richiediAudioContext().createAnalyser();\n analyser.fftSize = 256;\n source.connect(analyser);\n } catch {\n analyser = null;\n }\n const audio = dipendenze.creaAudioElement();\n audio.srcObject = media;\n audio.muted = true;\n audio.playsInline = true;\n void audio.play().catch(() => void 0);\n this.riproduzioni.set(playerId, { source, gain, analyser, audio, track, receiver });\n const attiva = this.sfuAttive.get(playerId);\n if (attiva !== void 0) attiva.receiver = receiver;\n this.aggiornaGuadagno(playerId);\n }\n scollegaTraccia(playerId) {\n const riproduzione = this.riproduzioni.get(playerId);\n if (riproduzione === void 0) return;\n riproduzione.source.disconnect();\n riproduzione.gain.disconnect();\n riproduzione.analyser?.disconnect();\n riproduzione.track.stop();\n riproduzione.audio.pause();\n riproduzione.audio.srcObject = null;\n this.riproduzioni.delete(playerId);\n this.speakingPeers.delete(playerId);\n this.ultimoAudio.delete(playerId);\n }\n aggiornaGuadagno(playerId) {\n const riproduzione = this.riproduzioni.get(playerId);\n if (riproduzione !== void 0) {\n riproduzione.gain.gain.value = (this.volumi.get(playerId) ?? 1) * (this.gains.get(playerId) ?? 1);\n }\n }\n preparaAnalizzatore(stream) {\n const context = this.richiediAudioContext();\n const analyser = context.createAnalyser();\n analyser.fftSize = 256;\n context.createMediaStreamSource(stream).connect(analyser);\n this.analyser = analyser;\n }\n avviaMisuraAudio() {\n const dipendenze = this.richiediDipendenze();\n if (this.intervalloAudio !== null) dipendenze.clearInterval(this.intervalloAudio);\n this.intervalloAudio = dipendenze.setInterval(() => this.misuraAudio(), INTERVALLO_AUDIO);\n }\n misuraAudio() {\n const dipendenze = this.dipendenze;\n if (dipendenze === null) return;\n let sopraSoglia = false;\n if (this.analyser !== null) sopraSoglia = this.livelloAnalizzatore(this.analyser) > SOGLIA_AUDIO;\n if (sopraSoglia) this.ultimoAudioMic = dipendenze.ora();\n const parlando = !this.mutedCorrente && dipendenze.ora() - this.ultimoAudioMic <= DURATA_PARLANTE;\n if (parlando !== this.speakingCorrente) {\n this.speakingCorrente = parlando;\n this.notificaPeers();\n }\n let cambiato = false;\n for (const peer of this.copiaPeers()) {\n const riproduzione = this.riproduzioni.get(peer.id);\n if (this.livelloAnalizzatore(riproduzione?.analyser ?? null) > SOGLIA_AUDIO) {\n this.ultimoAudio.set(peer.id, dipendenze.ora());\n } else if (riproduzione?.analyser === null || riproduzione?.analyser === void 0) {\n const sources = riproduzione?.receiver?.getSynchronizationSources?.() ?? [];\n if (sources.some((source) => (source.audioLevel ?? 0) > SOGLIA_AUDIO)) {\n this.ultimoAudio.set(peer.id, dipendenze.ora());\n }\n }\n const speaking = !peer.muted && dipendenze.ora() - (this.ultimoAudio.get(peer.id) ?? 0) <= DURATA_PARLANTE;\n if ((this.speakingPeers.get(peer.id) ?? false) !== speaking) {\n this.speakingPeers.set(peer.id, speaking);\n cambiato = true;\n }\n }\n if (cambiato) this.notificaPeers();\n }\n livelloAnalizzatore(analyser) {\n const nodo = analyser;\n if (nodo?.getFloatTimeDomainData === void 0) return 0;\n const campioni = new Float32Array(nodo.fftSize);\n nodo.getFloatTimeDomainData(campioni);\n return Math.sqrt(campioni.reduce((somma, valore) => somma + valore * valore, 0) / Math.max(1, campioni.length));\n }\n copiaPeers() {\n const you = this.contesto.you();\n const giocatori = this.contesto.giocatori();\n const player = giocatori.find((item) => item.id === you);\n return this.roster.flatMap((peer) => {\n if (peer.id === you) return [];\n if (this.modeCorrente === "team") {\n const altro = giocatori.find((item) => item.id === peer.id);\n if (player?.role !== "spectator" && altro?.team !== player?.team) return [];\n }\n return [{\n id: peer.id,\n mic: peer.mic,\n muted: peer.muted,\n speaking: peer.mic && !peer.muted && (this.speakingPeers.get(peer.id) ?? false),\n volume: this.volumi.get(peer.id) ?? 1,\n gain: this.gains.get(peer.id) ?? 1\n }];\n });\n }\n pulisciPeerAssenti() {\n const presenti = new Set(this.roster.map((peer) => peer.id));\n for (const playerId of this.speakingPeers.keys()) {\n if (!presenti.has(playerId)) this.speakingPeers.delete(playerId);\n }\n for (const playerId of this.zeroDa.keys()) {\n if (presenti.has(playerId)) continue;\n this.zeroDa.delete(playerId);\n const timer = this.timerZero.get(playerId);\n if (timer !== void 0) this.dipendenze?.clearTimeout(timer);\n this.timerZero.delete(playerId);\n }\n }\n osservaCaduta(pc) {\n pc.addEventListener("connectionstatechange", () => {\n if (this.stateCorrente === "on" && (pc.connectionState === "failed" || pc.connectionState === "disconnected")) this.avviaRiconnessione();\n });\n }\n avviaRiconnessione() {\n if (!this.desiderata || this.stateCorrente === "reconnecting") return;\n this.generazione++;\n this.rifiutaRichieste(creaErrore("voice_error", "The voice connection was restarted."));\n this.chiudiRisorse();\n this.tentativoRiconnessione = 0;\n this.aggiornaState("reconnecting");\n this.programmaRiconnessione();\n }\n programmaRiconnessione() {\n if (!this.desiderata || !this.contesto.connessa() || this.timerRiconnessione !== null || this.stateCorrente !== "reconnecting") return;\n const ritardo = RITARDI_RICONNESSIONE[this.tentativoRiconnessione];\n if (ritardo === void 0) {\n this.desiderata = false;\n this.aggiornaState("off");\n return;\n }\n this.tentativoRiconnessione++;\n this.timerRiconnessione = this.richiediDipendenze().setTimeout(() => {\n this.timerRiconnessione = null;\n const generazione = ++this.generazione;\n void this.entra(generazione).catch(() => {\n if (generazione !== this.generazione || !this.desiderata) return;\n this.chiudiRisorse();\n this.aggiornaState("reconnecting");\n this.programmaRiconnessione();\n });\n }, ritardo);\n }\n fermaRiconnessione() {\n if (this.timerRiconnessione === null || this.dipendenze === null) return;\n this.dipendenze.clearTimeout(this.timerRiconnessione);\n this.timerRiconnessione = null;\n }\n chiudiRisorse() {\n const dipendenze = this.dipendenze;\n this.cancellaAttesaConnessione?.();\n this.cancellaAttesaConnessione = null;\n if (dipendenze !== null) {\n if (this.intervalloAudio !== null) dipendenze.clearInterval(this.intervalloAudio);\n if (this.timerConnessione !== null) dipendenze.clearTimeout(this.timerConnessione);\n for (const timer of this.timerZero.values()) dipendenze.clearTimeout(timer);\n }\n this.intervalloAudio = null;\n this.timerConnessione = null;\n this.timerZero.clear();\n for (const playerId of [...this.riproduzioni.keys()]) this.scollegaTraccia(playerId);\n this.peerSfu?.close();\n this.peerSfu = null;\n for (const item of this.mesh.values()) item.pc.close();\n this.mesh.clear();\n this.sfuAttive.clear();\n this.midGiocatori.clear();\n for (const track of this.stream?.getTracks() ?? []) track.stop();\n this.stream = null;\n this.tracciaMic = null;\n this.analyser = null;\n void this.audioContext?.close().catch(() => void 0);\n this.audioContext = null;\n this.sessioneSfu = null;\n this.connessioneSfuAttesa = false;\n this.trasporto = null;\n this.speakingCorrente = false;\n this.ultimoAudioMic = Number.NEGATIVE_INFINITY;\n this.speakingPeers.clear();\n this.ultimoAudio.clear();\n this.negoziazione = Promise.resolve();\n }\n richiedi(message) {\n if (!this.contesto.connessa()) return Promise.reject(creaErrore("offline", "The room is reconnecting."));\n const r = ++this.sequenzaRichieste;\n return new Promise((resolve, reject) => {\n this.richieste.set(r, { resolve, reject });\n try {\n this.contesto.invia({ ...message, r });\n } catch (cause) {\n this.richieste.delete(r);\n reject(cause);\n }\n });\n }\n rifiutaRichieste(reason) {\n for (const richiesta of this.richieste.values()) richiesta.reject(reason);\n this.richieste.clear();\n }\n aggiornaState(state) {\n if (state === this.stateCorrente) return;\n this.stateCorrente = state;\n for (const listener of this.ascoltatoriState) {\n try {\n listener(state);\n } catch {\n }\n }\n }\n notificaPeers() {\n const peers = this.copiaPeers();\n for (const listener of this.ascoltatoriPeers) {\n try {\n listener(peers);\n } catch {\n }\n }\n }\n controllaGenerazione(generazione) {\n if (generazione !== this.generazione || !this.desiderata) {\n throw creaErrore("offline", "Voice was stopped.");\n }\n }\n richiediDipendenze() {\n if (this.dipendenze === null) throw creaErrore("unsupported", "Voice is not supported.");\n return this.dipendenze;\n }\n richiediMic() {\n if (this.tracciaMic === null) throw creaErrore("voice_error", "The microphone is not ready.");\n return this.tracciaMic;\n }\n richiediAudioContext() {\n if (this.audioContext === null) throw creaErrore("voice_error", "Audio is not ready.");\n return this.audioContext;\n }\n permessoNegato(cause) {\n return typeof cause === "object" && cause !== null && "name" in cause && (cause.name === "NotAllowedError" || cause.name === "SecurityError");\n }\n mappaErrore(cause) {\n if (typeof cause === "object" && cause !== null && "code" in cause) {\n const code = cause.code;\n if (code === "voice_disabled" || code === "permission_denied" || code === "unsupported" || code === "spectator" || code === "offline" || code === "voice_error") return cause;\n return creaErrore("voice_error", "Voice could not be started.");\n }\n return creaErrore("voice_error", "Voice could not be started.");\n }\n};\n\n// src/stanza-client/index.ts\nvar APERTO = 1;\nvar RITARDI_RICONNESSIONE2 = [1e3, 2e3, 4e3, 8e3];\nvar GRAZIA_RICONNESSIONE = 6e4;\nvar INTERVALLO_PING = 5e3;\nvar RITARDO_FLUSH = 500;\nvar ATTESA_ROSTER = 2e3;\nvar CHIUSURE_DEFINITIVE = /* @__PURE__ */ new Set([4003, 4004, 4005, 4006]);\nvar CHIUSURE_DEFINITIVE_SPETTATORE = /* @__PURE__ */ new Set([4008, 4009]);\nfunction record2(value) {\n return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;\n}\nfunction ingressoValido(value) {\n const dati = record2(value);\n return dati !== null && typeof dati.roomId === "string" && typeof dati.code === "string" && typeof dati.join === "string" && typeof dati.url === "string";\n}\nfunction visioneValida(value) {\n const dati = record2(value);\n return dati !== null && typeof dati.roomId === "string" && typeof dati.code === "string" && typeof dati.watch === "string" && typeof dati.url === "string";\n}\nfunction rispostaMatchValida(value) {\n const dati = record2(value);\n const players = record2(dati?.players);\n return dati !== null && typeof dati.url === "string" && Number.isInteger(dati.timeoutMs) && dati.timeoutMs >= 1e3 && dati.timeoutMs <= 3e5 && players !== null && Number.isInteger(players.min) && Number.isInteger(players.max) && players.min >= 1 && players.max >= players.min;\n}\nfunction copiaJson(value) {\n return JSON.parse(JSON.stringify(value));\n}\nfunction applicaPatch(state, value) {\n let risultato = copiaJson(state);\n for (const operazione of value) {\n if (operazione.path.length === 0) {\n if (operazione.op !== "set") return { ok: false };\n risultato = copiaJson(operazione.value);\n continue;\n }\n let contenitore = risultato;\n const percorso = operazione.path;\n for (let indice = 0; indice < percorso.length - 1; indice++) {\n const parte = percorso[indice];\n if (Array.isArray(contenitore)) {\n if (typeof parte !== "number" || parte >= contenitore.length) return { ok: false };\n contenitore = contenitore[parte];\n } else {\n const oggetto = record2(contenitore);\n if (oggetto === null || typeof parte !== "string" || !Object.hasOwn(oggetto, parte)) {\n return { ok: false };\n }\n contenitore = oggetto[parte];\n }\n }\n const ultima = percorso.at(-1);\n if (Array.isArray(contenitore)) {\n if (operazione.op !== "set" || typeof ultima !== "number" || ultima >= contenitore.length) return { ok: false };\n contenitore[ultima] = copiaJson(operazione.value);\n } else {\n const oggetto = record2(contenitore);\n if (oggetto === null || typeof ultima !== "string") return { ok: false };\n if (operazione.op === "del") {\n if (!Object.hasOwn(oggetto, ultima)) return { ok: false };\n delete oggetto[ultima];\n } else {\n Object.defineProperty(oggetto, ultima, {\n configurable: true,\n enumerable: true,\n value: copiaJson(operazione.value),\n writable: true\n });\n }\n }\n }\n return { ok: true, state: risultato };\n}\nfunction creaApiLive(input) {\n const richiesta = creaRichiedente(input.liveOrigin, "", input.fetcher, input.biglietto);\n async function ingresso(path, body, rinnova = false) {\n const value = await richiesta(path, "POST", body, rinnova);\n if (!ingressoValido(value)) {\n throw creaErrore("internal_error", "The room service returned an invalid response.");\n }\n return value;\n }\n async function match(options) {\n const value = await richiesta("/match", "POST", {\n mode: options.mode,\n key: options.key\n });\n if (!rispostaMatchValida(value)) {\n throw creaErrore("internal_error", "The matchmaking service returned an invalid response.");\n }\n return value;\n }\n async function visione(body, rinnova = false) {\n const value = await richiesta("/rooms/watch", "POST", body, rinnova);\n if (!visioneValida(value)) {\n throw creaErrore("internal_error", "The room service returned an invalid response.");\n }\n return value;\n }\n return {\n create: (mode) => ingresso("/rooms", { mode }),\n joinCode: (code) => ingresso("/rooms/join", { code }),\n joinRoom: (roomId) => ingresso("/rooms/join", { roomId }, true),\n watchCode: (code) => visione({ code }),\n watchRoom: (roomId) => visione({ roomId }, true),\n match,\n flush: (roomId) => richiesta(\n `/rooms/${encodeURIComponent(roomId)}/flush`,\n "POST"\n )\n };\n}\nvar StanzaClient = class {\n constructor(roomId, codice, url, input, api, segnalaStanza, spettatore = false) {\n this.roomId = roomId;\n this.codice = codice;\n this.input = input;\n this.api = api;\n this.segnalaStanza = segnalaStanza;\n this.spettatore = spettatore;\n this.statoPubblico = null;\n this.statoSincronizzato = null;\n this.tickCorrente = 0;\n this.seedCorrente = 0;\n this.statusCorrente = "lobby";\n this.giocatoriCorrenti = [];\n this.youCorrente = "";\n this.hostCorrente = null;\n this.resultCorrente = null;\n this.delaySpettatore = 0;\n this.socket = null;\n this.seq = 0;\n this.scartoOrario = 0;\n this.timerPing = null;\n this.timerRiconnessione = null;\n this.timerFlush = null;\n this.flushInCorso = false;\n this.flushRichiesto = false;\n this.ritardoIndice = 0;\n this.tempoRiconnessione = 0;\n this.resyncRichiesto = false;\n this.terminata = false;\n this.lasciata = false;\n this.prontaRisolta = false;\n this.welcomeRicevuto = false;\n this.rosterRicevuto = false;\n this.timerRoster = null;\n this.risolviPronta = () => void 0;\n this.rifiutaPronta = () => void 0;\n this.ascoltatoriStato = /* @__PURE__ */ new Set();\n this.ascoltatoriGiocatori = /* @__PURE__ */ new Set();\n this.ascoltatoriStatus = /* @__PURE__ */ new Set();\n this.ascoltatoriMessaggi = /* @__PURE__ */ new Set();\n this.promessaPronta = new Promise((resolve, reject) => {\n this.risolviPronta = resolve;\n this.rifiutaPronta = reject;\n });\n this.voice = new VoceClient({\n invia: (message) => this.invia(message),\n connessa: () => this.socket?.readyState === APERTO && this.welcomeRicevuto && !this.terminata && !this.lasciata,\n you: () => this.youCorrente,\n giocatori: () => this.copiaGiocatori(),\n rosterPronto: () => {\n this.rosterRicevuto = true;\n this.risolviProntaSePossibile();\n }\n }, input, input.voce);\n if (spettatore) this.rosterRicevuto = true;\n this.apri(url);\n }\n get state() {\n return this.statoPubblico;\n }\n get tick() {\n return this.tickCorrente;\n }\n get seed() {\n return this.seedCorrente;\n }\n get status() {\n return this.statusCorrente;\n }\n get players() {\n return this.copiaGiocatori();\n }\n get you() {\n return this.youCorrente;\n }\n get host() {\n return this.hostCorrente;\n }\n get code() {\n return this.codice;\n }\n get result() {\n return this.resultCorrente;\n }\n get delayMs() {\n return this.delaySpettatore;\n }\n pronta() {\n return this.promessaPronta;\n }\n invite() {\n return { code: this.codice, url: new URL(`/r/${this.codice}`, this.input.appOrigin).href };\n }\n onState(listener) {\n this.ascoltatoriStato.add(listener);\n return () => {\n this.ascoltatoriStato.delete(listener);\n };\n }\n onPlayers(listener) {\n this.ascoltatoriGiocatori.add(listener);\n return () => {\n this.ascoltatoriGiocatori.delete(listener);\n };\n }\n onStatus(listener) {\n this.ascoltatoriStatus.add(listener);\n return () => {\n this.ascoltatoriStatus.delete(listener);\n };\n }\n onMessage(listener) {\n this.ascoltatoriMessaggi.add(listener);\n return () => {\n this.ascoltatoriMessaggi.delete(listener);\n };\n }\n send(message) {\n const prossimo = this.seq + 1;\n this.invia({ t: "msg", seq: prossimo, m: message });\n this.seq = prossimo;\n }\n ready(ready) {\n this.invia({ t: "ready", ready });\n }\n setRole(role) {\n this.invia({ t: "role", role });\n }\n setTeam(team) {\n this.invia({ t: "team", team });\n }\n start() {\n this.invia({ t: "start" });\n }\n leave() {\n if (this.lasciata) return;\n if (!this.spettatore) this.voice.leave();\n this.lasciata = true;\n this.segnalaStanza(null);\n if (this.socket?.readyState === APERTO) {\n const socket = this.socket;\n this.invia({ t: "leave" });\n if (this.spettatore) socket.close(1e3);\n }\n this.termina(1e3);\n }\n serverTime() {\n return this.input.ora() + this.scartoOrario;\n }\n copiaGiocatori() {\n return this.giocatoriCorrenti.map((player) => ({ ...player }));\n }\n notifica(listeners, ...args) {\n for (const listener of listeners) {\n try {\n listener(...args);\n } catch {\n }\n }\n }\n invia(message) {\n if (this.socket?.readyState !== APERTO) {\n throw creaErrore("offline", "The room is reconnecting.");\n }\n let frame;\n try {\n frame = JSON.stringify(message);\n } catch {\n throw creaErrore("invalid_request", "Room messages must be valid JSON.");\n }\n this.socket.send(frame);\n }\n apri(url) {\n let socket;\n try {\n socket = this.input.apriSocket(url);\n } catch {\n this.programmaRiconnessione();\n return;\n }\n this.socket = socket;\n socket.addEventListener("open", () => {\n if (this.socket === socket) this.avviaPing();\n });\n socket.addEventListener("message", (evento) => {\n if (this.socket === socket && typeof evento.data === "string") this.ricevi(evento.data);\n });\n socket.addEventListener("close", (evento) => {\n if (this.socket === socket) this.chiuso(evento.code);\n });\n }\n avviaPing() {\n if (this.timerPing !== null) this.input.clearInterval(this.timerPing);\n this.timerPing = this.input.setInterval(() => {\n if (this.socket?.readyState !== APERTO) return;\n try {\n this.invia({ t: "ping", c: this.input.ora() });\n } catch {\n }\n }, INTERVALLO_PING);\n }\n fermaPing() {\n if (this.timerPing === null) return;\n this.input.clearInterval(this.timerPing);\n this.timerPing = null;\n }\n ricevi(frame) {\n let dati;\n try {\n const value = JSON.parse(frame);\n const 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 === "watching") this.riceviWatching(dati);\n else 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 riceviWatching(dati) {\n const room = dati.room;\n if (!this.spettatore || room.id !== this.roomId) return;\n this.seedCorrente = room.seed;\n this.hostCorrente = room.host;\n this.statusCorrente = room.status;\n this.giocatoriCorrenti = dati.players.map((player) => ({ ...player }));\n this.delaySpettatore = dati.delayMs;\n this.aggiornaStato(dati.state, room.tick, room.serverTime);\n this.scartoOrario = room.serverTime - this.input.ora();\n this.resyncRichiesto = false;\n this.welcomeRicevuto = true;\n this.ritardoIndice = 0;\n this.tempoRiconnessione = 0;\n this.notifica(this.ascoltatoriGiocatori, this.copiaGiocatori());\n this.notifica(\n this.ascoltatoriStatus,\n this.statusCorrente,\n this.resultCorrente,\n room.serverTime\n );\n this.risolviProntaSePossibile();\n }\n riceviWelcome(dati) {\n const room = dati.room;\n if (room.id !== this.roomId) return;\n this.youCorrente = dati.you;\n this.seedCorrente = room.seed;\n this.hostCorrente = room.host;\n this.statusCorrente = room.status;\n this.giocatoriCorrenti = dati.players.map((player) => ({ ...player }));\n this.aggiornaStato(dati.state, room.tick, room.serverTime);\n this.scartoOrario = room.serverTime - this.input.ora();\n this.resyncRichiesto = false;\n this.welcomeRicevuto = true;\n if (!this.rosterRicevuto && this.timerRoster === null) {\n this.timerRoster = this.input.setTimeout(() => {\n this.timerRoster = null;\n this.rosterRicevuto = true;\n this.risolviProntaSePossibile();\n }, ATTESA_ROSTER);\n }\n this.ritardoIndice = 0;\n this.tempoRiconnessione = 0;\n this.notifica(this.ascoltatoriGiocatori, this.copiaGiocatori());\n this.voice.giocatoriCambiati();\n this.voice.socketRiconnesso();\n this.notifica(\n this.ascoltatoriStatus,\n this.statusCorrente,\n this.resultCorrente,\n room.serverTime\n );\n this.risolviProntaSePossibile();\n }\n riceviGiocatori(value) {\n this.giocatoriCorrenti = value.map((player) => ({ ...player }));\n if (!this.giocatoriCorrenti.some(\n (player) => player.id === this.hostCorrente && player.connected\n )) {\n this.hostCorrente = this.giocatoriCorrenti.find((player) => player.connected)?.id ?? null;\n }\n this.notifica(this.ascoltatoriGiocatori, this.copiaGiocatori());\n this.voice.giocatoriCambiati();\n }\n riceviStatus(dati) {\n this.statusCorrente = dati.status;\n this.resultCorrente = copiaJson(dati.result);\n if (dati.status === "ended") {\n this.terminata = true;\n this.segnalaStanza(null);\n if (!this.spettatore) 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 (this.lasciata || this.terminata) return;\n if (CHIUSURE_DEFINITIVE.has(code) || this.spettatore && CHIUSURE_DEFINITIVE_SPETTATORE.has(code)) {\n this.termina(code);\n return;\n }\n if (!this.spettatore) 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 = this.spettatore ? await this.api.watchRoom(this.roomId) : await this.api.joinRoom(this.roomId);\n const codiceCambiato = this.codice !== ingresso.code;\n this.codice = ingresso.code;\n if (codiceCambiato && this.prontaRisolta && !this.terminata && !this.lasciata) {\n this.segnalaStanza({ code: this.codice });\n }\n this.apri(ingresso.url);\n } catch {\n this.programmaRiconnessione();\n }\n }\n fermaRiconnessione() {\n if (this.timerRiconnessione === null) return;\n this.input.clearTimeout(this.timerRiconnessione);\n this.timerRiconnessione = null;\n }\n termina(code) {\n const risultato = { closed: code };\n const cambiato = this.statusCorrente !== "ended" || JSON.stringify(this.resultCorrente) !== JSON.stringify(risultato);\n this.terminata = true;\n this.segnalaStanza(null);\n this.statusCorrente = "ended";\n this.resultCorrente = risultato;\n if (!this.spettatore) this.voice.termina();\n this.fermaPing();\n this.fermaRiconnessione();\n if (cambiato) this.notifica(this.ascoltatoriStatus, "ended", risultato, this.serverTime());\n if (!this.prontaRisolta) {\n this.prontaRisolta = true;\n const codici = {\n 4003: "kicked",\n 4004: "room_ended",\n 4005: "version_closed",\n 4006: "replaced",\n 4008: "rate_limited",\n 4009: "invalid_request"\n };\n const erroreCode = typeof code === "number" ? codici[code] ?? "offline" : "offline";\n this.rifiutaPronta(creaErrore(erroreCode, "The room connection ended."));\n }\n }\n risolviProntaSePossibile() {\n if (this.prontaRisolta || !this.welcomeRicevuto || !this.rosterRicevuto) return;\n if (this.timerRoster !== null) {\n this.input.clearTimeout(this.timerRoster);\n this.timerRoster = null;\n }\n this.prontaRisolta = true;\n if (!this.spettatore && !this.terminata && !this.lasciata) {\n this.segnalaStanza({ code: this.codice });\n }\n this.risolviPronta();\n }\n richiediFlush() {\n this.flushRichiesto = true;\n if (this.flushInCorso || this.timerFlush !== null) return;\n this.timerFlush = this.input.setTimeout(() => {\n this.timerFlush = null;\n void this.eseguiFlush();\n }, RITARDO_FLUSH);\n }\n async eseguiFlush() {\n if (this.flushInCorso || !this.flushRichiesto) return;\n this.flushInCorso = true;\n this.flushRichiesto = false;\n try {\n await this.api.flush(this.roomId);\n } catch {\n } finally {\n this.flushInCorso = false;\n if (this.flushRichiesto) this.richiediFlush();\n }\n }\n};\nfunction creaStanzeOffline(invited = null) {\n return {\n invited,\n async create() {\n throw erroreOffline();\n },\n async join() {\n throw erroreOffline();\n },\n async watch() {\n throw erroreOffline();\n },\n async match() {\n throw erroreOffline();\n }\n };\n}\nfunction creaGestoreStanze(input, invited) {\n const api = creaApiLive(input);\n let haSegnalato = false;\n let ultimoCodice = null;\n const segnalaStanza = (room) => {\n const codice = room?.code ?? null;\n if (haSegnalato && codice === ultimoCodice) return;\n haSegnalato = true;\n ultimoCodice = codice;\n input.segnalaStanza?.(room);\n };\n const collega = async (ingresso) => {\n const stanza = new StanzaClient(\n ingresso.roomId,\n ingresso.code,\n ingresso.url,\n input,\n api,\n segnalaStanza\n );\n await stanza.pronta();\n return stanza;\n };\n const guarda = async (ingresso) => {\n const stanza = new StanzaClient(\n ingresso.roomId,\n ingresso.code,\n ingresso.url,\n input,\n api,\n () => void 0,\n true\n );\n await stanza.pronta();\n return {\n get state() {\n return stanza.state;\n },\n get tick() {\n return stanza.tick;\n },\n get seed() {\n return stanza.seed;\n },\n get status() {\n return stanza.status;\n },\n get players() {\n return stanza.players;\n },\n get host() {\n return stanza.host;\n },\n get code() {\n return stanza.code;\n },\n get result() {\n return stanza.result;\n },\n get delayMs() {\n return stanza.delayMs;\n },\n onState: (listener) => stanza.onState(listener),\n onPlayers: (listener) => stanza.onPlayers(listener),\n onStatus: (listener) => stanza.onStatus(listener),\n onMessage: (listener) => stanza.onMessage(listener),\n leave: () => {\n stanza.leave();\n },\n serverTime: () => stanza.serverTime()\n };\n };\n const attendiMatch = (url, options) => new Promise((resolve, reject) => {\n let socket;\n let conclusa = false;\n const pulisci = () => {\n socket.removeEventListener("message", ricevi);\n socket.removeEventListener("close", chiuso);\n socket.removeEventListener("error", caduto);\n options.signal?.removeEventListener("abort", annulla);\n };\n const chiudi = () => {\n try {\n socket.close(1e3);\n } catch {\n }\n };\n const fallisci = (errore, chiudiSocket) => {\n if (conclusa) return;\n conclusa = true;\n pulisci();\n if (chiudiSocket) chiudi();\n reject(errore);\n };\n function annulla() {\n fallisci(\n creaErrore("cancelled", "The matchmaking search was cancelled."),\n true\n );\n }\n function chiuso() {\n fallisci(erroreOffline(), false);\n }\n function caduto() {\n fallisci(erroreOffline(), true);\n }\n function ricevi(evento) {\n let dati = null;\n try {\n dati = typeof evento.data === "string" ? record2(JSON.parse(evento.data)) : null;\n } catch {\n }\n if (dati === null || typeof dati.t !== "string") {\n fallisci(creaErrore("internal_error", "The matchmaking service sent an invalid message."), true);\n return;\n }\n if (dati.t === "waiting") {\n if (!Number.isInteger(dati.players) || !Number.isInteger(dati.min) || !Number.isInteger(dati.max)) {\n fallisci(creaErrore("internal_error", "The matchmaking service sent an invalid message."), true);\n return;\n }\n try {\n options.onWaiting?.({\n players: dati.players,\n min: dati.min,\n max: dati.max\n });\n } catch {\n }\n return;\n }\n if (dati.t === "matched") {\n if (!ingressoValido(dati)) {\n fallisci(creaErrore("internal_error", "The matchmaking service sent an invalid message."), true);\n return;\n }\n conclusa = true;\n pulisci();\n chiudi();\n resolve(dati);\n return;\n }\n if (dati.t === "no_match") {\n fallisci(creaErrore("no_match", "No match was found before the timeout."), true);\n return;\n }\n if (dati.t === "error") {\n fallisci(creaErrore(\n typeof dati.code === "string" ? dati.code : "internal_error",\n typeof dati.message === "string" ? dati.message : "The matchmaking service could not complete the search."\n ), true);\n return;\n }\n if (dati.t !== "pong") {\n fallisci(creaErrore("internal_error", "The matchmaking service sent an invalid message."), true);\n }\n }\n try {\n socket = input.apriSocket(url);\n } catch {\n reject(erroreOffline());\n return;\n }\n socket.addEventListener("message", ricevi);\n socket.addEventListener("close", chiuso);\n socket.addEventListener("error", caduto);\n options.signal?.addEventListener("abort", annulla, { once: true });\n if (options.signal?.aborted === true) annulla();\n });\n return {\n invited,\n async create(options) {\n return collega(await api.create(options.mode));\n },\n async join(code) {\n const scelto = code ?? invited;\n if (scelto === null || scelto === void 0 || scelto.length === 0) {\n throw creaErrore("invalid_request", "A room invitation code is required.");\n }\n return collega(await api.joinCode(scelto));\n },\n async watch(code) {\n if (typeof code !== "string" || code.length === 0) {\n throw creaErrore("invalid_request", "A room invitation code is required.");\n }\n return guarda(await api.watchCode(code));\n },\n async match(options) {\n const annullata = () => options.signal?.aborted === true;\n if (annullata()) {\n throw creaErrore("cancelled", "The matchmaking search was cancelled.");\n }\n const risposta = await api.match(options);\n if (annullata()) {\n throw creaErrore("cancelled", "The matchmaking search was cancelled.");\n }\n return collega(await attendiMatch(risposta.url, options));\n }\n };\n}\n\n// src/standalone.ts\nvar PREFISSO = "caisual:save:";\nvar CHIAVE_VALIDA = /^[a-z0-9][a-z0-9_-]{0,31}$/;\nfunction verificaChiave(key) {\n if (!CHIAVE_VALIDA.test(key)) {\n throw creaErrore("invalid_request", "Save keys must use lowercase letters, numbers, underscores, or hyphens.");\n }\n}\nfunction leggiSalvataggio(testo) {\n if (testo === null) return null;\n try {\n return JSON.parse(testo);\n } catch {\n return null;\n }\n}\nfunction chiavi(archivio) {\n const risultato = [];\n for (let indice = 0; indice < archivio.length; indice++) {\n const key = archivio.key(indice);\n if (key?.startsWith(PREFISSO)) risultato.push(key.slice(PREFISSO.length));\n }\n return risultato;\n}\nfunction creaSave(archivio, ora) {\n const disponibile = () => {\n if (archivio === null) throw erroreOffline();\n return archivio;\n };\n return {\n async set(key, value) {\n verificaChiave(key);\n const locale = disponibile();\n const corpo = JSON.stringify({ value });\n const bytes = new TextEncoder().encode(corpo).byteLength;\n if (bytes > 262144) {\n throw creaErrore("payload_too_large", "The save is larger than 262144 bytes.");\n }\n if (locale.getItem(PREFISSO + key) === null && chiavi(locale).length >= 32) {\n throw creaErrore("save_limit", "A game can store at most 32 save keys.");\n }\n const voce = { value, bytes, updatedAt: ora() };\n locale.setItem(PREFISSO + key, JSON.stringify(voce));\n return { key, bytes, updatedAt: voce.updatedAt };\n },\n async get(key) {\n verificaChiave(key);\n return leggiSalvataggio(disponibile().getItem(PREFISSO + key))?.value ?? null;\n },\n async remove(key) {\n verificaChiave(key);\n disponibile().removeItem(PREFISSO + key);\n },\n async list() {\n const locale = disponibile();\n return chiavi(locale).flatMap((key) => {\n const voce = leggiSalvataggio(locale.getItem(PREFISSO + key));\n return voce === null ? [] : [{ key, bytes: voce.bytes, updatedAt: voce.updatedAt }];\n }).sort((a, b) => a.key.localeCompare(b.key));\n }\n };\n}\nasync function creaStandalone(input, invited = null) {\n const day = giornoUtc(input.ora());\n const seed = await calcolaSeed(input.hostname, day, input.subtle);\n return {\n connected: false,\n player: { id: "local", name: "Guest", guest: true },\n daily: { day, seed, random: creaMulberry32(seed) },\n time: { now: input.ora },\n save: creaSave(input.archivio, input.ora),\n board: {\n async submit() {\n return { accepted: false, reason: "offline" };\n },\n async top(_board, opzioni = {}) {\n return { day: opzioni.daily ? day : null, entries: [], me: null };\n }\n },\n room: creaStanzeOffline(invited)\n };\n}\n\n// src/kit.ts\nfunction leggiAppOrigin(documento) {\n const valore = documento?.querySelector(\'meta[name="caisual-app"]\')?.getAttribute("content");\n if (valore === null || valore === void 0) return null;\n try {\n const url = new URL(valore);\n return url.origin === valore && (url.protocol === "https:" || url.protocol === "http:") ? valore : null;\n } catch {\n return null;\n }\n}\nfunction archivioReale() {\n try {\n return typeof localStorage === "undefined" ? null : localStorage;\n } catch {\n return null;\n }\n}\nfunction dipendenzeReali2() {\n return {\n finestra: typeof window === "undefined" ? null : window,\n documento: typeof document === "undefined" ? null : document,\n fetcher: (input, init) => globalThis.fetch(input, init),\n archivio: archivioReale(),\n hostname: typeof location === "undefined" ? "" : location.hostname,\n subtle: globalThis.crypto.subtle,\n ora: Date.now,\n sonda: () => probeDevice()\n };\n}\nasync function connetti(input) {\n const appOrigin = leggiAppOrigin(input.documento);\n const senzaPadre = input.finestra === null || input.finestra.parent === input.finestra;\n if (appOrigin === null || senzaPadre) {\n return creaStandalone(input);\n }\n const handshake = await attendiHandshake(\n input.finestra,\n appOrigin,\n input.timeoutHandshake\n );\n if (handshake === null) return creaStandalone(input);\n const biglietto = creaGestoreBiglietto(\n handshake.ticket,\n handshake.porta,\n input.finestra,\n input.ora,\n input.timeoutRinnovo,\n "portal"\n );\n const api = creaClienteApi(appOrigin, input.fetcher, biglietto);\n const prima = input.ora();\n let me;\n try {\n me = await api.me();\n } catch {\n return creaStandalone(input, handshake.invite);\n }\n const dopo = input.ora();\n const scartoOrario = me.serverTime - (prima + dopo) / 2;\n const room = handshake.live === null ? creaStanzeOffline(handshake.invite) : creaGestoreStanze({\n appOrigin,\n liveOrigin: handshake.live,\n fetcher: input.fetcher,\n biglietto: creaGestoreBiglietto(\n null,\n handshake.porta,\n input.finestra,\n input.ora,\n input.timeoutRinnovo,\n "live"\n ),\n apriSocket(url) {\n if (input.apriSocket !== void 0) return input.apriSocket(url);\n if (typeof WebSocket === "undefined") throw erroreOffline();\n return new WebSocket(url);\n },\n ora: input.ora,\n setTimeout: (handler, timeout) => globalThis.setTimeout(handler, timeout),\n clearTimeout: (id) => globalThis.clearTimeout(id),\n setInterval: (handler, timeout) => globalThis.setInterval(handler, timeout),\n clearInterval: (id) => globalThis.clearInterval(id),\n voce: input.voce,\n segnalaStanza(room2) {\n try {\n handshake.porta.postMessage({ type: "caisual:room", room: room2 });\n } catch {\n }\n }\n }, handshake.invite);\n return {\n connected: true,\n player: me.player,\n daily: { day: me.day, seed: me.seed, random: creaMulberry32(me.seed) },\n time: { now: () => input.ora() + scartoOrario },\n save: {\n set: (key, value) => api.saveSet(key, value),\n get: (key) => api.saveGet(key),\n remove: (key) => api.saveRemove(key),\n list: () => api.saveList()\n },\n board: {\n async submit(board, score, opzioni = {}) {\n try {\n return await api.boardSubmit(board, score, opzioni.daily === true);\n } catch (errore) {\n if (typeof errore === "object" && errore !== null && "code" in errore && errore.code === "offline") return { accepted: false, reason: "offline" };\n throw errore;\n }\n },\n top: (board, opzioni = {}) => api.boardTop(board, opzioni)\n },\n room\n };\n}\nfunction dispositivoSconosciuto() {\n return {\n webgl2: false,\n webgpu: false,\n wasm: false,\n threads: false,\n isolated: false,\n gpu: "none",\n memoryMb: null,\n cores: null,\n mobile: false,\n tier: "low"\n };\n}\nasync function attendiSonda(sonda) {\n let timer;\n try {\n return await Promise.race([\n Promise.resolve().then(sonda).catch(() => dispositivoSconosciuto()),\n new Promise((resolve) => {\n timer = globalThis.setTimeout(() => resolve(dispositivoSconosciuto()), 1500);\n })\n ]);\n } finally {\n if (timer !== void 0) globalThis.clearTimeout(timer);\n }\n}\nfunction creaKit(input = dipendenzeReali2()) {\n let promessa = null;\n return {\n connect() {\n promessa ?? (promessa = Promise.all([connetti(input), attendiSonda(input.sonda)]).then(([connessione, device]) => ({ ...connessione, device })));\n return promessa;\n }\n };\n}\n\n// src/index.ts\nvar caisual = creaKit();\nglobalThis.caisual = caisual;\nvar index_default = caisual;\nexport {\n caisual,\n index_default as default\n};\n');
|
|
2834
3780
|
return;
|
|
2835
3781
|
}
|
|
2836
3782
|
let decoded;
|
|
@@ -2842,22 +3788,22 @@ var DevService = class {
|
|
|
2842
3788
|
}
|
|
2843
3789
|
const relativePath = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, "");
|
|
2844
3790
|
const candidate = resolve(this.clientRoot, relativePath);
|
|
2845
|
-
if (
|
|
3791
|
+
if (relative2(this.clientRoot, candidate).startsWith(`..${sep}`) || candidate === this.clientRoot) {
|
|
2846
3792
|
sendError(response, new DevHttpError(404, "not_found", "The game file was not found."));
|
|
2847
3793
|
return;
|
|
2848
3794
|
}
|
|
2849
|
-
const real = await
|
|
3795
|
+
const real = await fs2.realpath(candidate).catch(() => null);
|
|
2850
3796
|
if (real === null || real !== this.clientRoot && !real.startsWith(`${this.clientRoot}${sep}`)) {
|
|
2851
3797
|
sendError(response, new DevHttpError(404, "not_found", "The game file was not found."));
|
|
2852
3798
|
return;
|
|
2853
3799
|
}
|
|
2854
|
-
const stat = await
|
|
3800
|
+
const stat = await fs2.stat(real);
|
|
2855
3801
|
if (!stat.isFile()) {
|
|
2856
3802
|
sendError(response, new DevHttpError(404, "not_found", "The game file was not found."));
|
|
2857
3803
|
return;
|
|
2858
3804
|
}
|
|
2859
3805
|
const html = extname(real).toLowerCase() === ".html";
|
|
2860
|
-
const body = html ? Buffer.from(injectAppMeta(await
|
|
3806
|
+
const body = html ? Buffer.from(injectAppMeta(await fs2.readFile(real, "utf8"), this.portalOrigin)) : await fs2.readFile(real);
|
|
2861
3807
|
response.statusCode = 200;
|
|
2862
3808
|
response.setHeader("Content-Type", contentType(real));
|
|
2863
3809
|
response.setHeader("Content-Length", body.byteLength);
|
|
@@ -2907,7 +3853,7 @@ var DevService = class {
|
|
|
2907
3853
|
await this.handleKit(request, response, url);
|
|
2908
3854
|
return;
|
|
2909
3855
|
}
|
|
2910
|
-
if (url.pathname === "/rooms" || url.pathname === "/rooms/join" || /^\/rooms\/[^/]+(?:\/flush)?$/.test(url.pathname)) {
|
|
3856
|
+
if (url.pathname === "/match" || url.pathname === "/rooms" || url.pathname === "/rooms/join" || url.pathname === "/rooms/watch" || /^\/rooms\/[^/]+(?:\/flush)?$/.test(url.pathname)) {
|
|
2911
3857
|
await this.handleLive(request, response, url);
|
|
2912
3858
|
return;
|
|
2913
3859
|
}
|
|
@@ -3144,6 +4090,10 @@ var DevService = class {
|
|
|
3144
4090
|
}
|
|
3145
4091
|
const ticket = readServiceTicket(request, this.manifest.id, "live", this.secret);
|
|
3146
4092
|
this.checkRate(this.liveRequests, ticket.sub);
|
|
4093
|
+
if (url.pathname === "/match" && request.method === "POST") {
|
|
4094
|
+
await this.startMatch(request, response, ticket, origin);
|
|
4095
|
+
return;
|
|
4096
|
+
}
|
|
3147
4097
|
if (url.pathname === "/rooms" && request.method === "POST") {
|
|
3148
4098
|
await this.createRoom(request, response, ticket, origin);
|
|
3149
4099
|
return;
|
|
@@ -3152,6 +4102,10 @@ var DevService = class {
|
|
|
3152
4102
|
await this.joinRoom(request, response, ticket, origin);
|
|
3153
4103
|
return;
|
|
3154
4104
|
}
|
|
4105
|
+
if (url.pathname === "/rooms/watch" && request.method === "POST") {
|
|
4106
|
+
await this.watchRoom(request, response, ticket, origin);
|
|
4107
|
+
return;
|
|
4108
|
+
}
|
|
3155
4109
|
const match = /^\/rooms\/(g1-1\.[a-z0-9]{16})(?:\/(flush))?$/.exec(url.pathname);
|
|
3156
4110
|
if (match !== null && match[1] !== void 0) {
|
|
3157
4111
|
const localRoom = this.rooms.get(match[1]);
|
|
@@ -3199,38 +4153,117 @@ var DevService = class {
|
|
|
3199
4153
|
if (body === null || !Object.hasOwn(body, "mode") || body.mode !== null && typeof body.mode !== "string") {
|
|
3200
4154
|
throw new DevHttpError(400, "invalid_request", "mode must be null or a valid mode name.");
|
|
3201
4155
|
}
|
|
3202
|
-
|
|
3203
|
-
|
|
4156
|
+
let opened;
|
|
4157
|
+
try {
|
|
4158
|
+
opened = await this.openLocalRoom(body.mode, playerFromTicket(ticket));
|
|
4159
|
+
} catch (cause) {
|
|
4160
|
+
if (cause instanceof DevHttpError) throw cause;
|
|
4161
|
+
throw new DevHttpError(
|
|
4162
|
+
400,
|
|
4163
|
+
"invalid_request",
|
|
4164
|
+
cause instanceof Error ? cause.message : "The room request is invalid."
|
|
4165
|
+
);
|
|
4166
|
+
}
|
|
4167
|
+
sendJson(
|
|
4168
|
+
response,
|
|
4169
|
+
this.joinResponse(opened.roomId, opened.localRoom.code, playerFromTicket(ticket)),
|
|
4170
|
+
201,
|
|
4171
|
+
origin
|
|
4172
|
+
);
|
|
4173
|
+
}
|
|
4174
|
+
async startMatch(request, response, ticket, origin) {
|
|
4175
|
+
if (this.definition === null) {
|
|
4176
|
+
throw new DevHttpError(409, "no_server", "This game has no multiplayer server.");
|
|
4177
|
+
}
|
|
4178
|
+
const body = object(await readBody(request));
|
|
4179
|
+
if (body === null || !Object.hasOwn(body, "mode") || !Object.hasOwn(body, "key") || Object.keys(body).some((field) => field !== "mode" && field !== "key")) {
|
|
4180
|
+
throw new DevHttpError(400, "invalid_request", "The match request must contain only mode and key.");
|
|
4181
|
+
}
|
|
4182
|
+
if (typeof body.mode !== "string") {
|
|
4183
|
+
throw new DevHttpError(400, "invalid_request", "mode must be a valid mode name.");
|
|
4184
|
+
}
|
|
4185
|
+
const mode = this.manifest.modes.find((item) => item.id === body.mode);
|
|
4186
|
+
if (mode === void 0) {
|
|
4187
|
+
throw new DevHttpError(400, "invalid_request", "The matchmaking mode does not exist.");
|
|
4188
|
+
}
|
|
4189
|
+
if (mode.matchmaking === void 0) {
|
|
4190
|
+
throw new DevHttpError(400, "invalid_request", "This mode does not support matchmaking.");
|
|
4191
|
+
}
|
|
4192
|
+
const key = canonicalMatchKey(body.key, mode.matchmaking.key);
|
|
4193
|
+
const token = matchTicket(
|
|
4194
|
+
playerFromTicket(ticket),
|
|
4195
|
+
ticket.game,
|
|
4196
|
+
mode.id,
|
|
4197
|
+
key,
|
|
4198
|
+
mode.matchmaking,
|
|
4199
|
+
this.manifest.players,
|
|
4200
|
+
this.manifest.lobby,
|
|
4201
|
+
this.secret
|
|
4202
|
+
);
|
|
4203
|
+
sendJson(response, {
|
|
4204
|
+
url: `ws://localhost:${this.port}/match?j=${encodeURIComponent(token)}`,
|
|
4205
|
+
timeoutMs: mode.matchmaking.timeoutMs,
|
|
4206
|
+
players: this.manifest.players
|
|
4207
|
+
}, 200, origin);
|
|
4208
|
+
}
|
|
4209
|
+
roomManifest() {
|
|
4210
|
+
return {
|
|
3204
4211
|
id: this.manifest.id,
|
|
3205
4212
|
players: this.manifest.players,
|
|
3206
4213
|
lobby: this.manifest.lobby,
|
|
4214
|
+
persistent: this.manifest.persistent,
|
|
4215
|
+
spectators: this.manifest.spectators,
|
|
3207
4216
|
roles: this.manifest.roles,
|
|
3208
4217
|
teams: this.manifest.teams,
|
|
3209
4218
|
modes: this.manifest.modes,
|
|
3210
4219
|
voice: this.manifest.voice
|
|
3211
4220
|
};
|
|
4221
|
+
}
|
|
4222
|
+
async openLocalRoom(mode, creator) {
|
|
4223
|
+
if (this.definition === null) {
|
|
4224
|
+
throw new DevHttpError(409, "no_server", "This game has no multiplayer server.");
|
|
4225
|
+
}
|
|
4226
|
+
const roomId = `g1-1.${randomUniform("abcdefghijklmnopqrstuvwxyz0123456789", 16)}`;
|
|
3212
4227
|
const room = await createNodeRoom(
|
|
3213
4228
|
this.definition,
|
|
3214
|
-
roomManifest,
|
|
3215
|
-
{
|
|
4229
|
+
this.roomManifest(),
|
|
4230
|
+
{
|
|
4231
|
+
storageFile: join2(this.root, ".caisual-dev", "rooms", `${roomId}.json`),
|
|
4232
|
+
deposito: this.deposito
|
|
4233
|
+
}
|
|
3216
4234
|
);
|
|
3217
4235
|
try {
|
|
3218
|
-
await room.create(roomId,
|
|
4236
|
+
const created = await room.create(roomId, mode, creator);
|
|
4237
|
+
if (!created) throw new Error("The room could not be created.");
|
|
3219
4238
|
} catch (cause) {
|
|
3220
4239
|
await room.close();
|
|
3221
|
-
throw
|
|
3222
|
-
400,
|
|
3223
|
-
"invalid_request",
|
|
3224
|
-
cause instanceof Error ? cause.message : "The room request is invalid."
|
|
3225
|
-
);
|
|
4240
|
+
throw cause;
|
|
3226
4241
|
}
|
|
3227
4242
|
const code = this.uniqueCode();
|
|
3228
|
-
|
|
4243
|
+
const localRoom = {
|
|
4244
|
+
code,
|
|
4245
|
+
game: this.manifest.id,
|
|
4246
|
+
room,
|
|
4247
|
+
pendingMatch: /* @__PURE__ */ new Map()
|
|
4248
|
+
};
|
|
4249
|
+
this.rooms.set(roomId, localRoom);
|
|
3229
4250
|
this.roomByCode.set(code, roomId);
|
|
3230
|
-
|
|
4251
|
+
return { roomId, localRoom };
|
|
3231
4252
|
}
|
|
3232
4253
|
async joinRoom(request, response, ticket, origin) {
|
|
3233
4254
|
const body = object(await readBody(request));
|
|
4255
|
+
const { roomId, localRoom } = this.resolveLocalRoom(body, ticket.game);
|
|
4256
|
+
const permission = await localRoom.room.canJoin(playerFromTicket(ticket));
|
|
4257
|
+
if (!permission.ok) {
|
|
4258
|
+
throw new DevHttpError(
|
|
4259
|
+
permission.code === "room_not_found" ? 404 : 409,
|
|
4260
|
+
permission.code,
|
|
4261
|
+
this.roomErrorMessage(permission.code)
|
|
4262
|
+
);
|
|
4263
|
+
}
|
|
4264
|
+
sendJson(response, this.joinResponse(roomId, localRoom.code, playerFromTicket(ticket)), 200, origin);
|
|
4265
|
+
}
|
|
4266
|
+
resolveLocalRoom(body, game) {
|
|
3234
4267
|
const hasCode = body !== null && typeof body.code === "string";
|
|
3235
4268
|
const hasRoomId = body !== null && typeof body.roomId === "string";
|
|
3236
4269
|
if (body === null || hasCode === hasRoomId) {
|
|
@@ -3242,10 +4275,15 @@ var DevService = class {
|
|
|
3242
4275
|
}
|
|
3243
4276
|
const roomId = codeInput === null ? body.roomId : this.roomByCode.get(codeInput);
|
|
3244
4277
|
const localRoom = roomId === void 0 ? void 0 : this.rooms.get(roomId);
|
|
3245
|
-
if (roomId === void 0 || localRoom === void 0 || localRoom.game !==
|
|
4278
|
+
if (roomId === void 0 || localRoom === void 0 || localRoom.game !== game) {
|
|
3246
4279
|
throw new DevHttpError(404, "room_not_found", "The room was not found.");
|
|
3247
4280
|
}
|
|
3248
|
-
|
|
4281
|
+
return { roomId, localRoom };
|
|
4282
|
+
}
|
|
4283
|
+
async watchRoom(request, response, ticket, origin) {
|
|
4284
|
+
const body = object(await readBody(request));
|
|
4285
|
+
const { roomId, localRoom } = this.resolveLocalRoom(body, ticket.game);
|
|
4286
|
+
const permission = await localRoom.room.canWatch();
|
|
3249
4287
|
if (!permission.ok) {
|
|
3250
4288
|
throw new DevHttpError(
|
|
3251
4289
|
permission.code === "room_not_found" ? 404 : 409,
|
|
@@ -3253,15 +4291,24 @@ var DevService = class {
|
|
|
3253
4291
|
this.roomErrorMessage(permission.code)
|
|
3254
4292
|
);
|
|
3255
4293
|
}
|
|
3256
|
-
sendJson(response, this.
|
|
4294
|
+
sendJson(response, this.watchResponse(roomId, localRoom.code, playerFromTicket(ticket)), 200, origin);
|
|
3257
4295
|
}
|
|
3258
4296
|
joinResponse(roomId, code, player) {
|
|
3259
|
-
const
|
|
4297
|
+
const join4 = joinTicket(player, roomId, this.secret);
|
|
3260
4298
|
return {
|
|
3261
4299
|
roomId,
|
|
3262
4300
|
code,
|
|
3263
|
-
join:
|
|
3264
|
-
url: `ws://localhost:${this.port}/rooms/${roomId}?j=${encodeURIComponent(
|
|
4301
|
+
join: join4,
|
|
4302
|
+
url: `ws://localhost:${this.port}/rooms/${roomId}?j=${encodeURIComponent(join4)}`
|
|
4303
|
+
};
|
|
4304
|
+
}
|
|
4305
|
+
watchResponse(roomId, code, player) {
|
|
4306
|
+
const watch = joinTicket(player, roomId, this.secret, "watch");
|
|
4307
|
+
return {
|
|
4308
|
+
roomId,
|
|
4309
|
+
code,
|
|
4310
|
+
watch,
|
|
4311
|
+
url: `ws://localhost:${this.port}/rooms/${roomId}?w=${encodeURIComponent(watch)}`
|
|
3265
4312
|
};
|
|
3266
4313
|
}
|
|
3267
4314
|
uniqueCode() {
|
|
@@ -3286,6 +4333,8 @@ var DevService = class {
|
|
|
3286
4333
|
if (code === "room_full") return "Room is full.";
|
|
3287
4334
|
if (code === "room_playing") return "The game has already started.";
|
|
3288
4335
|
if (code === "room_ended") return "Room has ended.";
|
|
4336
|
+
if (code === "spectators_disabled") return "Spectators are not allowed in this game.";
|
|
4337
|
+
if (code === "spectators_full") return "The room has no spectator seats left.";
|
|
3289
4338
|
return "Room not found.";
|
|
3290
4339
|
}
|
|
3291
4340
|
rejectUpgrade(socket, status, code, message) {
|
|
@@ -3294,7 +4343,8 @@ var DevService = class {
|
|
|
3294
4343
|
400: "Bad Request",
|
|
3295
4344
|
401: "Unauthorized",
|
|
3296
4345
|
404: "Not Found",
|
|
3297
|
-
409: "Conflict"
|
|
4346
|
+
409: "Conflict",
|
|
4347
|
+
429: "Too Many Requests"
|
|
3298
4348
|
};
|
|
3299
4349
|
socket.end(
|
|
3300
4350
|
`HTTP/1.1 ${status} ${names[status] ?? "Error"}\r
|
|
@@ -3309,7 +4359,7 @@ Connection: close\r
|
|
|
3309
4359
|
};
|
|
3310
4360
|
async function runDev(options) {
|
|
3311
4361
|
const root = resolve(process.cwd(), options.folder);
|
|
3312
|
-
const stat = await
|
|
4362
|
+
const stat = await fs2.stat(root).catch(() => null);
|
|
3313
4363
|
if (stat === null || !stat.isDirectory()) throw new Error(`The game folder was not found: ${root}`);
|
|
3314
4364
|
const [{ manifest, clientRoot }, definition] = await Promise.all([
|
|
3315
4365
|
readGame(root),
|
|
@@ -3353,6 +4403,143 @@ async function runDev(options) {
|
|
|
3353
4403
|
});
|
|
3354
4404
|
}
|
|
3355
4405
|
|
|
4406
|
+
// src/scan.ts
|
|
4407
|
+
var MASSIMO_BYTE_SCANSIONE = 8e6;
|
|
4408
|
+
var ESTENSIONI_TESTO = /* @__PURE__ */ new Set([".js", ".mjs", ".cjs", ".html"]);
|
|
4409
|
+
function estensione(path) {
|
|
4410
|
+
const indice = path.lastIndexOf(".");
|
|
4411
|
+
return indice < 0 ? "" : path.slice(indice).toLowerCase();
|
|
4412
|
+
}
|
|
4413
|
+
function leggiU32(bytes, cursore, fine) {
|
|
4414
|
+
let risultato = 0;
|
|
4415
|
+
for (let giro = 0; giro < 5; giro += 1) {
|
|
4416
|
+
if (cursore.posizione >= fine) throw new Error("leb incompleto");
|
|
4417
|
+
const byte = bytes[cursore.posizione++];
|
|
4418
|
+
if (giro === 4 && (byte & 240) !== 0) throw new Error("leb fuori limite");
|
|
4419
|
+
risultato += (byte & 127) * 2 ** (giro * 7);
|
|
4420
|
+
if ((byte & 128) === 0) return risultato;
|
|
4421
|
+
}
|
|
4422
|
+
throw new Error("leb troppo lungo");
|
|
4423
|
+
}
|
|
4424
|
+
function saltaNome(bytes, cursore, fine) {
|
|
4425
|
+
const lunghezza = leggiU32(bytes, cursore, fine);
|
|
4426
|
+
if (cursore.posizione + lunghezza > fine) throw new Error("nome incompleto");
|
|
4427
|
+
cursore.posizione += lunghezza;
|
|
4428
|
+
}
|
|
4429
|
+
function leggiLimits(bytes, cursore, fine) {
|
|
4430
|
+
if (cursore.posizione >= fine) throw new Error("limits assenti");
|
|
4431
|
+
const flags = bytes[cursore.posizione++];
|
|
4432
|
+
if (flags > 3 || flags === 2) throw new Error("limits non validi");
|
|
4433
|
+
leggiU32(bytes, cursore, fine);
|
|
4434
|
+
if ((flags & 1) !== 0) leggiU32(bytes, cursore, fine);
|
|
4435
|
+
return flags === 3;
|
|
4436
|
+
}
|
|
4437
|
+
function memoriaImportataCondivisa(bytes, inizio, fine) {
|
|
4438
|
+
const cursore = { posizione: inizio };
|
|
4439
|
+
const totale = leggiU32(bytes, cursore, fine);
|
|
4440
|
+
let condivisa = false;
|
|
4441
|
+
for (let indice = 0; indice < totale; indice += 1) {
|
|
4442
|
+
saltaNome(bytes, cursore, fine);
|
|
4443
|
+
saltaNome(bytes, cursore, fine);
|
|
4444
|
+
if (cursore.posizione >= fine) throw new Error("descrittore import assente");
|
|
4445
|
+
const tipo = bytes[cursore.posizione++];
|
|
4446
|
+
if (tipo === 0) leggiU32(bytes, cursore, fine);
|
|
4447
|
+
else if (tipo === 1) {
|
|
4448
|
+
if (cursore.posizione >= fine) throw new Error("tabella incompleta");
|
|
4449
|
+
cursore.posizione += 1;
|
|
4450
|
+
leggiLimits(bytes, cursore, fine);
|
|
4451
|
+
} else if (tipo === 2) condivisa = leggiLimits(bytes, cursore, fine) || condivisa;
|
|
4452
|
+
else if (tipo === 3) {
|
|
4453
|
+
if (cursore.posizione + 2 > fine) throw new Error("globale incompleta");
|
|
4454
|
+
cursore.posizione += 2;
|
|
4455
|
+
} else if (tipo === 4) {
|
|
4456
|
+
if (cursore.posizione >= fine) throw new Error("tag incompleto");
|
|
4457
|
+
cursore.posizione += 1;
|
|
4458
|
+
leggiU32(bytes, cursore, fine);
|
|
4459
|
+
} else throw new Error("descrittore import sconosciuto");
|
|
4460
|
+
}
|
|
4461
|
+
if (cursore.posizione !== fine) throw new Error("sezione import non consumata");
|
|
4462
|
+
return condivisa;
|
|
4463
|
+
}
|
|
4464
|
+
function memoriaDefinitaCondivisa(bytes, inizio, fine) {
|
|
4465
|
+
const cursore = { posizione: inizio };
|
|
4466
|
+
const totale = leggiU32(bytes, cursore, fine);
|
|
4467
|
+
let condivisa = false;
|
|
4468
|
+
for (let indice = 0; indice < totale; indice += 1) {
|
|
4469
|
+
condivisa = leggiLimits(bytes, cursore, fine) || condivisa;
|
|
4470
|
+
}
|
|
4471
|
+
if (cursore.posizione !== fine) throw new Error("sezione memory non consumata");
|
|
4472
|
+
return condivisa;
|
|
4473
|
+
}
|
|
4474
|
+
function usaMemoriaCondivisa(bytes) {
|
|
4475
|
+
try {
|
|
4476
|
+
const intestazione = [0, 97, 115, 109, 1, 0, 0, 0];
|
|
4477
|
+
if (bytes.length < intestazione.length || intestazione.some((byte, indice) => bytes[indice] !== byte)) {
|
|
4478
|
+
return false;
|
|
4479
|
+
}
|
|
4480
|
+
const cursore = { posizione: intestazione.length };
|
|
4481
|
+
let condivisa = false;
|
|
4482
|
+
while (cursore.posizione < bytes.length) {
|
|
4483
|
+
const id = bytes[cursore.posizione++];
|
|
4484
|
+
const dimensione = leggiU32(bytes, cursore, bytes.length);
|
|
4485
|
+
const fine = cursore.posizione + dimensione;
|
|
4486
|
+
if (fine > bytes.length) throw new Error("sezione incompleta");
|
|
4487
|
+
if (id === 2) condivisa = memoriaImportataCondivisa(bytes, cursore.posizione, fine) || condivisa;
|
|
4488
|
+
if (id === 5) condivisa = memoriaDefinitaCondivisa(bytes, cursore.posizione, fine) || condivisa;
|
|
4489
|
+
cursore.posizione = fine;
|
|
4490
|
+
}
|
|
4491
|
+
return condivisa;
|
|
4492
|
+
} catch {
|
|
4493
|
+
return false;
|
|
4494
|
+
}
|
|
4495
|
+
}
|
|
4496
|
+
function avviso(path, nome, campo) {
|
|
4497
|
+
return `client/${path} seems to use ${nome} but caisual.json does not declare requires.${campo}. Players on devices without it will not be warned.`;
|
|
4498
|
+
}
|
|
4499
|
+
async function scanClient(files, manifest) {
|
|
4500
|
+
const primi = {};
|
|
4501
|
+
const decoder = new TextDecoder();
|
|
4502
|
+
for (const file of files) {
|
|
4503
|
+
const tipo = estensione(file.path);
|
|
4504
|
+
if (!ESTENSIONI_TESTO.has(tipo) && tipo !== ".wasm" || file.bytes > MASSIMO_BYTE_SCANSIONE) continue;
|
|
4505
|
+
let bytes;
|
|
4506
|
+
try {
|
|
4507
|
+
bytes = await file.read();
|
|
4508
|
+
} catch {
|
|
4509
|
+
continue;
|
|
4510
|
+
}
|
|
4511
|
+
if (tipo === ".wasm") {
|
|
4512
|
+
primi.wasm ??= file.path;
|
|
4513
|
+
if (primi.threads === void 0 && usaMemoriaCondivisa(bytes)) primi.threads = file.path;
|
|
4514
|
+
continue;
|
|
4515
|
+
}
|
|
4516
|
+
const source = decoder.decode(bytes);
|
|
4517
|
+
if (primi.webgl2 === void 0 && /getContext\s*\(\s*(["'`])webgl2\1/.test(source)) {
|
|
4518
|
+
primi.webgl2 = file.path;
|
|
4519
|
+
}
|
|
4520
|
+
if (primi.webgpu === void 0 && (/navigator\s*\.\s*gpu\b/.test(source) || /requestAdapter\s*\(/.test(source) || /getContext\s*\(\s*(["'`])webgpu\1/.test(source))) primi.webgpu = file.path;
|
|
4521
|
+
if (primi.wasm === void 0 && /WebAssembly\s*\.\s*(?:instantiate|compile)(?:Streaming)?\s*\(/.test(source)) {
|
|
4522
|
+
primi.wasm = file.path;
|
|
4523
|
+
}
|
|
4524
|
+
if (primi.threads === void 0 && (/\bSharedArrayBuffer\b/.test(source) || /\bAtomics\s*\.\s*wait\s*\(/.test(source))) primi.threads = file.path;
|
|
4525
|
+
}
|
|
4526
|
+
const warnings = [];
|
|
4527
|
+
const nomi = {
|
|
4528
|
+
webgl2: "WebGL2",
|
|
4529
|
+
webgpu: "WebGPU",
|
|
4530
|
+
wasm: "WebAssembly",
|
|
4531
|
+
threads: "shared memory"
|
|
4532
|
+
};
|
|
4533
|
+
for (const campo of ["webgl2", "webgpu", "wasm", "threads"]) {
|
|
4534
|
+
const path = primi[campo];
|
|
4535
|
+
if (path !== void 0 && !manifest.requires[campo]) warnings.push(avviso(path, nomi[campo], campo));
|
|
4536
|
+
}
|
|
4537
|
+
if (primi.threads !== void 0 && !manifest.isolated) {
|
|
4538
|
+
warnings.push(`client/${primi.threads} seems to use shared memory but caisual.json does not set isolated: true. Shared memory will not be available.`);
|
|
4539
|
+
}
|
|
4540
|
+
return warnings;
|
|
4541
|
+
}
|
|
4542
|
+
|
|
3356
4543
|
// src/caisual.ts
|
|
3357
4544
|
var DEFAULT_ORIGIN = "https://caisual.com";
|
|
3358
4545
|
var MAX_FILE_BYTES = 5e7;
|
|
@@ -3360,14 +4547,6 @@ var MAX_VERSION_BYTES = 2e8;
|
|
|
3360
4547
|
var MAX_FILES = 2e3;
|
|
3361
4548
|
var UPLOAD_CONCURRENCY = 4;
|
|
3362
4549
|
var MAX_RETRIES = 3;
|
|
3363
|
-
var CliError = class extends Error {
|
|
3364
|
-
constructor(exitCode, message) {
|
|
3365
|
-
super(message);
|
|
3366
|
-
this.exitCode = exitCode;
|
|
3367
|
-
this.name = "CliError";
|
|
3368
|
-
}
|
|
3369
|
-
exitCode;
|
|
3370
|
-
};
|
|
3371
4550
|
var ApiError = class extends Error {
|
|
3372
4551
|
constructor(status, code, message, hints) {
|
|
3373
4552
|
super(message);
|
|
@@ -3381,7 +4560,7 @@ var ApiError = class extends Error {
|
|
|
3381
4560
|
hints;
|
|
3382
4561
|
};
|
|
3383
4562
|
function help() {
|
|
3384
|
-
return `Caisual ${"0.
|
|
4563
|
+
return `Caisual ${"0.5.0"}
|
|
3385
4564
|
|
|
3386
4565
|
Usage:
|
|
3387
4566
|
caisual init [--multiplayer] [folder]
|
|
@@ -3408,7 +4587,7 @@ function displayName(folderName) {
|
|
|
3408
4587
|
}
|
|
3409
4588
|
async function writeNewFile(path, content) {
|
|
3410
4589
|
try {
|
|
3411
|
-
await
|
|
4590
|
+
await fs3.writeFile(path, content, { encoding: "utf8", flag: "wx" });
|
|
3412
4591
|
return true;
|
|
3413
4592
|
} catch (error) {
|
|
3414
4593
|
if (error.code === "EEXIST") return false;
|
|
@@ -3418,7 +4597,7 @@ async function writeNewFile(path, content) {
|
|
|
3418
4597
|
async function init(folderArgument, multiplayer) {
|
|
3419
4598
|
const root = resolve2(process.cwd(), folderArgument);
|
|
3420
4599
|
try {
|
|
3421
|
-
await
|
|
4600
|
+
await fs3.mkdir(join3(root, "client"), { recursive: true });
|
|
3422
4601
|
} catch {
|
|
3423
4602
|
throw new CliError(2, `The game folder could not be created: ${root}`);
|
|
3424
4603
|
}
|
|
@@ -3430,8 +4609,8 @@ async function init(folderArgument, multiplayer) {
|
|
|
3430
4609
|
platform: "both",
|
|
3431
4610
|
...multiplayer ? { players: { min: 1, max: 4 }, lobby: true, voice: "room" } : {}
|
|
3432
4611
|
};
|
|
3433
|
-
const manifestPath =
|
|
3434
|
-
const indexPath =
|
|
4612
|
+
const manifestPath = join3(root, "caisual.json");
|
|
4613
|
+
const indexPath = join3(root, "client", "index.html");
|
|
3435
4614
|
const singlePlayerIndex = `<!doctype html>
|
|
3436
4615
|
<html lang="en">
|
|
3437
4616
|
<head>
|
|
@@ -3552,7 +4731,7 @@ export default defineGame({
|
|
|
3552
4731
|
process.stdout.write(`${indexCreated ? "Created" : "Kept"} ${indexPath}
|
|
3553
4732
|
`);
|
|
3554
4733
|
if (multiplayer) {
|
|
3555
|
-
const serverPath =
|
|
4734
|
+
const serverPath = join3(root, "server.js");
|
|
3556
4735
|
const serverCreated = await writeNewFile(serverPath, server);
|
|
3557
4736
|
process.stdout.write(`${serverCreated ? "Created" : "Kept"} ${serverPath}
|
|
3558
4737
|
`);
|
|
@@ -3583,19 +4762,19 @@ async function mapLimited(items, limit, operation) {
|
|
|
3583
4762
|
async function listClientFiles(clientRoot) {
|
|
3584
4763
|
let rootStat;
|
|
3585
4764
|
try {
|
|
3586
|
-
rootStat = await
|
|
4765
|
+
rootStat = await fs3.stat(clientRoot);
|
|
3587
4766
|
} catch {
|
|
3588
4767
|
throw new CliError(2, "client/: folder not found.");
|
|
3589
4768
|
}
|
|
3590
4769
|
if (!rootStat.isDirectory()) throw new CliError(2, "client/: must be a folder.");
|
|
3591
4770
|
const found = [];
|
|
3592
4771
|
async function visit(folder, prefix) {
|
|
3593
|
-
const entries = await
|
|
4772
|
+
const entries = await fs3.readdir(folder, { withFileTypes: true });
|
|
3594
4773
|
entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
|
|
3595
4774
|
for (const entry of entries) {
|
|
3596
4775
|
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
3597
4776
|
const relativePath = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
3598
|
-
const absolutePath =
|
|
4777
|
+
const absolutePath = join3(folder, entry.name);
|
|
3599
4778
|
if (entry.isDirectory()) {
|
|
3600
4779
|
await visit(absolutePath, relativePath);
|
|
3601
4780
|
continue;
|
|
@@ -3603,7 +4782,7 @@ async function listClientFiles(clientRoot) {
|
|
|
3603
4782
|
if (!entry.isFile()) {
|
|
3604
4783
|
throw new CliError(2, `${relativePath}: only regular files are supported.`);
|
|
3605
4784
|
}
|
|
3606
|
-
const fileStat = await
|
|
4785
|
+
const fileStat = await fs3.stat(absolutePath);
|
|
3607
4786
|
if (fileStat.size > MAX_FILE_BYTES) {
|
|
3608
4787
|
throw new CliError(2, `${relativePath}: file is larger than 50 MB (${fileStat.size} bytes).`);
|
|
3609
4788
|
}
|
|
@@ -3623,36 +4802,54 @@ async function listClientFiles(clientRoot) {
|
|
|
3623
4802
|
}
|
|
3624
4803
|
return await mapLimited(found, UPLOAD_CONCURRENCY, async (file) => ({
|
|
3625
4804
|
...file,
|
|
3626
|
-
sha256: await sha256(file.absolutePath)
|
|
4805
|
+
sha256: await sha256(file.absolutePath),
|
|
4806
|
+
read: () => fs3.readFile(file.absolutePath)
|
|
3627
4807
|
}));
|
|
3628
4808
|
}
|
|
3629
4809
|
async function readServerFile(root) {
|
|
3630
|
-
const absolutePath =
|
|
4810
|
+
const absolutePath = join3(root, "server.js");
|
|
3631
4811
|
let stat;
|
|
3632
4812
|
try {
|
|
3633
|
-
stat = await
|
|
4813
|
+
stat = await fs3.lstat(absolutePath);
|
|
3634
4814
|
} catch (error) {
|
|
3635
|
-
if (error.code === "ENOENT")
|
|
4815
|
+
if (error.code === "ENOENT") {
|
|
4816
|
+
return { file: null, temporaryDirectory: null };
|
|
4817
|
+
}
|
|
3636
4818
|
throw new CliError(2, "server.js: file not readable.");
|
|
3637
4819
|
}
|
|
3638
4820
|
if (!stat.isFile()) throw new CliError(2, "server.js: must be a regular file.");
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
4821
|
+
const result = await bundleServer(root);
|
|
4822
|
+
if (!result.bundled) {
|
|
4823
|
+
return {
|
|
4824
|
+
file: {
|
|
4825
|
+
path: "server.js",
|
|
4826
|
+
absolutePath,
|
|
4827
|
+
bytes: stat.size,
|
|
4828
|
+
sha256: await sha256(absolutePath)
|
|
4829
|
+
},
|
|
4830
|
+
temporaryDirectory: null
|
|
4831
|
+
};
|
|
3644
4832
|
}
|
|
3645
|
-
const
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
4833
|
+
const temporaryDirectory = await fs3.mkdtemp(join3(tmpdir(), "caisual-server-"));
|
|
4834
|
+
const bundledPath = join3(temporaryDirectory, "server.js");
|
|
4835
|
+
try {
|
|
4836
|
+
await fs3.writeFile(bundledPath, result.source, "utf8");
|
|
4837
|
+
const bytes = Buffer.byteLength(result.source);
|
|
4838
|
+
process.stdout.write(`Bundling server.js (${Math.ceil(bytes / 1e3)} KB).
|
|
4839
|
+
`);
|
|
4840
|
+
return {
|
|
4841
|
+
file: {
|
|
4842
|
+
path: "server.js",
|
|
4843
|
+
absolutePath: bundledPath,
|
|
4844
|
+
bytes,
|
|
4845
|
+
sha256: await sha256(bundledPath)
|
|
4846
|
+
},
|
|
4847
|
+
temporaryDirectory
|
|
4848
|
+
};
|
|
4849
|
+
} catch (error) {
|
|
4850
|
+
await fs3.rm(temporaryDirectory, { recursive: true, force: true });
|
|
4851
|
+
throw error;
|
|
3649
4852
|
}
|
|
3650
|
-
return {
|
|
3651
|
-
path: "server.js",
|
|
3652
|
-
absolutePath,
|
|
3653
|
-
bytes: stat.size,
|
|
3654
|
-
sha256: await sha256(absolutePath)
|
|
3655
|
-
};
|
|
3656
4853
|
}
|
|
3657
4854
|
function portalOrigin() {
|
|
3658
4855
|
const raw = process.env.CAISUAL_ORIGIN?.trim() || DEFAULT_ORIGIN;
|
|
@@ -3849,10 +5046,10 @@ function parseUploads(payload, files, server) {
|
|
|
3849
5046
|
};
|
|
3850
5047
|
}
|
|
3851
5048
|
async function readManifest(root) {
|
|
3852
|
-
const path =
|
|
5049
|
+
const path = join3(root, "caisual.json");
|
|
3853
5050
|
let source;
|
|
3854
5051
|
try {
|
|
3855
|
-
source = await
|
|
5052
|
+
source = await fs3.readFile(path, "utf8");
|
|
3856
5053
|
} catch {
|
|
3857
5054
|
throw new CliError(2, "caisual.json: file not found or unreadable.");
|
|
3858
5055
|
}
|
|
@@ -3873,79 +5070,88 @@ async function publish(folderArgument) {
|
|
|
3873
5070
|
const root = resolve2(process.cwd(), folderArgument);
|
|
3874
5071
|
let rootStat;
|
|
3875
5072
|
try {
|
|
3876
|
-
rootStat = await
|
|
5073
|
+
rootStat = await fs3.stat(root);
|
|
3877
5074
|
} catch {
|
|
3878
5075
|
throw new CliError(2, `The game folder was not found: ${root}`);
|
|
3879
5076
|
}
|
|
3880
5077
|
if (!rootStat.isDirectory()) throw new CliError(2, `The game path is not a folder: ${root}`);
|
|
3881
5078
|
const manifest = await readManifest(root);
|
|
3882
|
-
const
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
const filePaths = new Set(files.map((file) => file.path));
|
|
3887
|
-
for (const required of [manifest.cover, ...manifest.screenshots]) {
|
|
3888
|
-
if (required !== null && !filePaths.has(required)) {
|
|
3889
|
-
throw new CliError(2, `caisual.json: referenced file not found in client/: ${required}`);
|
|
3890
|
-
}
|
|
3891
|
-
}
|
|
3892
|
-
const key = process.env.CAISUAL_KEY?.trim();
|
|
3893
|
-
if (!key) {
|
|
3894
|
-
throw new CliError(3, "CAISUAL_KEY is required. Set it with: export CAISUAL_KEY=ck_...");
|
|
5079
|
+
const files = await listClientFiles(join3(root, "client"));
|
|
5080
|
+
for (const warning of await scanClient(files, manifest)) {
|
|
5081
|
+
process.stderr.write(`Warning: ${warning}
|
|
5082
|
+
`);
|
|
3895
5083
|
}
|
|
3896
|
-
const
|
|
3897
|
-
const
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
|
|
5084
|
+
const serverResult = await readServerFile(root);
|
|
5085
|
+
const server = serverResult.file;
|
|
5086
|
+
try {
|
|
5087
|
+
const filePaths = new Set(files.map((file) => file.path));
|
|
5088
|
+
for (const required of [manifest.cover, ...manifest.screenshots]) {
|
|
5089
|
+
if (required !== null && !filePaths.has(required)) {
|
|
5090
|
+
throw new CliError(2, `caisual.json: referenced file not found in client/: ${required}`);
|
|
5091
|
+
}
|
|
5092
|
+
}
|
|
5093
|
+
const key = process.env.CAISUAL_KEY?.trim();
|
|
5094
|
+
if (!key) {
|
|
5095
|
+
throw new CliError(3, "CAISUAL_KEY is required. Set it with: export CAISUAL_KEY=ck_...");
|
|
5096
|
+
}
|
|
5097
|
+
const origin = portalOrigin();
|
|
5098
|
+
const declared = files.map(({ path, bytes, sha256: digest }) => ({
|
|
5099
|
+
path,
|
|
5100
|
+
bytes,
|
|
5101
|
+
sha256: digest
|
|
5102
|
+
}));
|
|
5103
|
+
process.stdout.write(
|
|
5104
|
+
`Preparing ${files.length} client file${files.length === 1 ? "" : "s"}${server === null ? "" : " and server.js"}.
|
|
3904
5105
|
`
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
});
|
|
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 }
|
|
5106
|
+
);
|
|
5107
|
+
const opened = await requestJson(`${origin}/api/versions`, {
|
|
5108
|
+
method: "POST",
|
|
5109
|
+
headers: {
|
|
5110
|
+
Authorization: `Bearer ${key}`,
|
|
5111
|
+
"Content-Type": "application/json; charset=utf-8"
|
|
5112
|
+
},
|
|
5113
|
+
body: JSON.stringify({
|
|
5114
|
+
manifest,
|
|
5115
|
+
files: declared,
|
|
5116
|
+
...server === null ? {} : {
|
|
5117
|
+
server: { bytes: server.bytes, sha256: server.sha256 }
|
|
5118
|
+
}
|
|
5119
|
+
})
|
|
3929
5120
|
});
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
5121
|
+
const version = parseUploads(opened, files, server);
|
|
5122
|
+
const caricamenti = files.map((file, index) => ({
|
|
5123
|
+
file,
|
|
5124
|
+
target: version.targets[index]
|
|
5125
|
+
}));
|
|
5126
|
+
if (server !== null && version.serverTarget !== null) {
|
|
5127
|
+
caricamenti.push({
|
|
5128
|
+
file: server,
|
|
5129
|
+
target: { path: "server.js", ...version.serverTarget }
|
|
5130
|
+
});
|
|
5131
|
+
}
|
|
5132
|
+
await mapLimited(caricamenti, UPLOAD_CONCURRENCY, async ({ file, target }) => {
|
|
5133
|
+
await uploadFile(file, target, key, origin);
|
|
5134
|
+
});
|
|
5135
|
+
const completed = await requestJson(`${origin}/api/versions/${version.versionId}/complete`, {
|
|
5136
|
+
method: "POST",
|
|
5137
|
+
headers: { Authorization: `Bearer ${key}` }
|
|
5138
|
+
});
|
|
5139
|
+
if (typeof completed.url !== "string") {
|
|
5140
|
+
throw new CliError(1, "The portal completed the version without returning the game URL.");
|
|
5141
|
+
}
|
|
5142
|
+
if (version.n !== null) process.stdout.write(`Published version ${version.n}.
|
|
3942
5143
|
`);
|
|
3943
|
-
|
|
5144
|
+
process.stdout.write(`${completed.url}
|
|
3944
5145
|
`);
|
|
5146
|
+
} finally {
|
|
5147
|
+
if (serverResult.temporaryDirectory !== null) {
|
|
5148
|
+
await fs3.rm(serverResult.temporaryDirectory, { recursive: true, force: true });
|
|
5149
|
+
}
|
|
5150
|
+
}
|
|
3945
5151
|
}
|
|
3946
5152
|
async function installSkill() {
|
|
3947
5153
|
const root = process.cwd();
|
|
3948
|
-
const skillPath =
|
|
5154
|
+
const skillPath = join3(root, ".claude", "skills", "caisual", "SKILL.md");
|
|
3949
5155
|
const skill = `---
|
|
3950
5156
|
name: caisual
|
|
3951
5157
|
description: Create and publish a browser game on Caisual, with player identity, cloud saves, leaderboards and a daily challenge.
|
|
@@ -3955,25 +5161,25 @@ ${publish_default.trim()}
|
|
|
3955
5161
|
|
|
3956
5162
|
${kit_default.trim()}
|
|
3957
5163
|
`;
|
|
3958
|
-
await
|
|
5164
|
+
await fs3.mkdir(join3(root, ".claude", "skills", "caisual"), { recursive: true });
|
|
3959
5165
|
let currentSkill = null;
|
|
3960
5166
|
try {
|
|
3961
|
-
currentSkill = await
|
|
5167
|
+
currentSkill = await fs3.readFile(skillPath, "utf8");
|
|
3962
5168
|
} catch (error) {
|
|
3963
5169
|
if (error.code !== "ENOENT") throw error;
|
|
3964
5170
|
}
|
|
3965
|
-
if (currentSkill !== skill) await
|
|
3966
|
-
const agentsPath =
|
|
5171
|
+
if (currentSkill !== skill) await fs3.writeFile(skillPath, skill, "utf8");
|
|
5172
|
+
const agentsPath = join3(root, "AGENTS.md");
|
|
3967
5173
|
let agents = "";
|
|
3968
5174
|
try {
|
|
3969
|
-
agents = await
|
|
5175
|
+
agents = await fs3.readFile(agentsPath, "utf8");
|
|
3970
5176
|
} catch (error) {
|
|
3971
5177
|
if (error.code !== "ENOENT") throw error;
|
|
3972
5178
|
}
|
|
3973
5179
|
if (!/^## Caisual\s*$/m.test(agents)) {
|
|
3974
5180
|
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";
|
|
3975
5181
|
const separator = agents === "" ? "" : agents.endsWith("\n\n") ? "" : agents.endsWith("\n") ? "\n" : "\n\n";
|
|
3976
|
-
await
|
|
5182
|
+
await fs3.writeFile(agentsPath, `${agents}${separator}${section}`, "utf8");
|
|
3977
5183
|
}
|
|
3978
5184
|
process.stdout.write(`Installed ${skillPath}
|
|
3979
5185
|
`);
|
|
@@ -3985,7 +5191,7 @@ async function run(argumentsList) {
|
|
|
3985
5191
|
return;
|
|
3986
5192
|
}
|
|
3987
5193
|
if (command === "--version" || command === "-V") {
|
|
3988
|
-
process.stdout.write(`${"0.
|
|
5194
|
+
process.stdout.write(`${"0.5.0"}
|
|
3989
5195
|
`);
|
|
3990
5196
|
return;
|
|
3991
5197
|
}
|