@caisual/cli 0.9.0 → 0.11.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.
Files changed (2) hide show
  1. package/dist/caisual.mjs +741 -201
  2. package/package.json +1 -1
package/dist/caisual.mjs CHANGED
@@ -1,10 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/caisual.ts
4
- import { createHash as createHash3 } from "node:crypto";
5
- import { createReadStream, promises as fs3 } from "node:fs";
6
- import { tmpdir } from "node:os";
7
- import { basename as basename2, extname as extname2, join as join3, resolve as resolve2 } from "node:path";
3
+ // src/i18n.ts
4
+ import { promises as fs2 } from "node:fs";
5
+ import { join as join2, sep } from "node:path";
8
6
 
9
7
  // ../contracts/src/slug.ts
10
8
  var NOMI_RISERVATI = [
@@ -49,6 +47,46 @@ function isReservedSlug(value) {
49
47
  return RISERVATI.has(value);
50
48
  }
51
49
 
50
+ // ../contracts/src/i18n.ts
51
+ function normalizeLanguage(value) {
52
+ if (typeof value !== "string" || value.length > 128) return null;
53
+ try {
54
+ return Intl.getCanonicalLocales(value)[0] ?? null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+ function manifestLanguages(manifest) {
60
+ return manifest.languages?.length ? [...manifest.languages] : [manifest.language ?? "en"];
61
+ }
62
+ function languageFallbacks(language, defaultLanguage = "en") {
63
+ const result = [];
64
+ let tag = normalizeLanguage(language);
65
+ while (tag) {
66
+ result.push(tag);
67
+ const parts = tag.split("-");
68
+ parts.pop();
69
+ if (parts.at(-1)?.length === 1) parts.pop();
70
+ tag = parts.join("-");
71
+ }
72
+ result.push(normalizeLanguage(defaultLanguage) ?? defaultLanguage);
73
+ return [...new Set(result)];
74
+ }
75
+ function isTextDictionary(value) {
76
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((text) => typeof text === "string");
77
+ }
78
+ async function loadGameTexts(language, defaultLanguage, read) {
79
+ const dictionaries2 = await Promise.all(languageFallbacks(language, defaultLanguage).map(async (tag) => {
80
+ try {
81
+ const value = await read(tag);
82
+ return isTextDictionary(value) ? value : {};
83
+ } catch {
84
+ return {};
85
+ }
86
+ }));
87
+ return Object.assign(/* @__PURE__ */ Object.create(null), ...dictionaries2.reverse());
88
+ }
89
+
52
90
  // ../contracts/src/manifest.ts
53
91
  function risolviModalita(manifest, mode) {
54
92
  const scelta = mode === null ? void 0 : manifest.modes.find((voce) => voce.id === mode);
@@ -73,6 +111,7 @@ var CAMPI = /* @__PURE__ */ new Set([
73
111
  "cover",
74
112
  "screenshots",
75
113
  "tags",
114
+ "languages",
76
115
  "language",
77
116
  "platform",
78
117
  "orientation",
@@ -139,12 +178,32 @@ function stringaDefault(dati, campo, valoreDefault, errori) {
139
178
  }
140
179
  function testoFacoltativo(value, key, max, path, errors) {
141
180
  if (value[key] === void 0) return void 0;
142
- const text = value[key];
143
- if (typeof text !== "string" || text.trim().length === 0 || text.trim().length > max || /[\r\n\u0000-\u001f]/.test(text)) {
144
- errors.push(`${path}.${key}: must contain 1-${max} characters on one line.`);
181
+ const check2 = (text2, field2) => {
182
+ if (typeof text2 !== "string" || text2.trim().length === 0 || text2.trim().length > max || /[\r\n\u0000-\u001f]/.test(text2)) {
183
+ errors.push(`${field2}: must contain 1-${max} characters on one line.`);
184
+ return void 0;
185
+ }
186
+ return text2.trim();
187
+ };
188
+ const text = value[key], field = `${path}.${key}`;
189
+ if (typeof text === "string") return check2(text, field);
190
+ const translations = oggetto(text);
191
+ if (!translations || Object.keys(translations).length === 0) {
192
+ errors.push(`${field}: must be a string or a non-empty language-to-text object.`);
145
193
  return void 0;
146
194
  }
147
- return text.trim();
195
+ const result = {};
196
+ for (const [raw, text2] of Object.entries(translations)) {
197
+ const tag = normalizeLanguage(raw);
198
+ if (!tag) {
199
+ errors.push(`${field}.${raw}: must be a BCP 47 language tag.`);
200
+ continue;
201
+ }
202
+ if (Object.hasOwn(result, tag)) errors.push(`${field}.${raw}: duplicate language.`);
203
+ const checked = check2(text2, `${field}.${raw}`);
204
+ if (checked !== void 0) result[tag] = checked;
205
+ }
206
+ return result;
148
207
  }
149
208
  function validaManifest(valore) {
150
209
  const errori = [];
@@ -199,10 +258,24 @@ function validaManifest(valore) {
199
258
  }
200
259
  }
201
260
  }
202
- const language = stringaDefault(dati, "language", "en", errori);
203
- if (!/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/.test(language)) {
261
+ const legacyLanguage = stringaDefault(dati, "language", "en", errori);
262
+ if (!/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/.test(legacyLanguage)) {
204
263
  errori.push("language: must be a BCP 47 language tag such as en, it, or pt-BR.");
205
264
  }
265
+ const languages = [];
266
+ if (dati.languages === void 0) languages.push(normalizeLanguage(legacyLanguage) ?? legacyLanguage);
267
+ else if (!Array.isArray(dati.languages) || dati.languages.length === 0) {
268
+ errori.push("languages: must be a non-empty array of BCP 47 language tags.");
269
+ } else for (const [index, raw] of dati.languages.entries()) {
270
+ const tag = normalizeLanguage(raw);
271
+ if (!tag) errori.push(`languages[${index}]: must be a BCP 47 language tag.`);
272
+ else if (languages.includes(tag)) errori.push(`languages[${index}]: duplicate language ${tag}.`);
273
+ else languages.push(tag);
274
+ }
275
+ const language = languages[0] ?? legacyLanguage;
276
+ if (dati.language !== void 0 && dati.languages !== void 0 && legacyLanguage.toLowerCase() !== language.toLowerCase()) {
277
+ errori.push("language: must match the first entry in languages when both are present.");
278
+ }
206
279
  let platform = "both";
207
280
  if (dati.platform === void 0) errori.push("platform: is required.");
208
281
  else if (typeof dati.platform !== "string" || !PLATFORM.has(dati.platform)) {
@@ -568,6 +641,7 @@ function validaManifest(valore) {
568
641
  cover,
569
642
  screenshots,
570
643
  tags,
644
+ languages,
571
645
  language,
572
646
  platform,
573
647
  orientation,
@@ -708,11 +782,20 @@ function overlayReadOrigin(origin, site, expected) {
708
782
  return site === null || site === "same-origin" || site === "none";
709
783
  }
710
784
 
711
- // ../../docs/publish.md
712
- var publish_default = '# Publish a game on Caisual\n\nCaisual hosts browser games supplied as folders. Each publish creates an immutable version and moves the game\'s stable link to that version.\nThe publishing flow supports both single-player and multiplayer games and does not require changes in the Caisual dashboard. Player identity, rooms, cloud saves, leaderboards, and the daily challenge come from the game kit, documented in [kit.md](./kit.md).\n\nA game published with `"overlay": { "version": 1 }` is a standard game: it runs full screen and Caisual draws the menu, the lobby, invitations, friends, matchmaking, spectators, leaderboards, voice, the end of a match and Play again on top of it. Write the field, the HUD and the settings; declare the rest in the manifest. See [Sessions and the standard overlay](./kit.md#sessions-and-the-standard-overlay).\n\n## Game folder\n\nUse this structure:\n\n```text\nmy-game/\n caisual.json\n server.js # optional, required only for multiplayer rooms\n client/\n index.html\n ...\n```\n\n`caisual.json` and `client/index.html` are required. Put every file used by the game under `client/`.\n\nRun `npx @caisual/cli init my-game` to create a minimal single-player folder with the standard overlay and one local mode. Run `npx @caisual/cli init --multiplayer my-game` to add a room mode with matchmaking and a `server.js`. Both templates are full screen and use `c.session` and `c.overlay`; neither draws a menu or a lobby of its own.\n\n## caisual.json\n\nThe file must contain one JSON object. Unknown fields are rejected. This is a complete single-player example:\n\n```json\n{\n "manifest": 1,\n "id": "my-game",\n "name": "My Game",\n "description": "A short description of the game.",\n "cover": "cover.png",\n "screenshots": ["screenshots/level-one.png"],\n "tags": ["puzzle"],\n "language": "en",\n "platform": "both",\n "overlay": { "version": 1, "accent": "#397e83" },\n "orientation": "landscape",\n "input": ["keyboard", "mouse", "touch"],\n "visibility": "public",\n "network": [],\n "isolated": false,\n "requires": { "webgl2": false, "webgpu": false, "wasm": false, "threads": false, "memoryMb": null, "performance": "light" },\n "players": { "min": 1, "max": 1 },\n "lobby": false,\n "persistent": false,\n "spectators": true,\n "boards": { "main": { "source": "server", "label": "Best run", "periods": ["daily", "all-time"] } },\n "roles": [],\n "teams": null,\n "voice": "none",\n "modes": [\n { "id": "solo", "execution": "local", "label": "Solo", "instructions": "One run against the clock." }\n ]\n}\n```\n\n- `manifest` is required and must be `1`.\n- `overlay` is optional and defaults to absent. Set `{ "version": 1 }` to publish a standard game and get the whole overlay. `accent` is optional and must be a six-digit `#RRGGBB` colour; no other CSS is accepted. A game without `overlay` keeps its historical flow and draws its own menus, and nothing in this guide changes for it.\n- `id` is required. Use 3 to 32 lowercase ASCII letters or digits, with single hyphens only between groups. The ID becomes the URL slug. Choose it carefully because it cannot be renamed or reused after deletion.\n- `name` is required and must contain 1 to 60 characters.\n- `description` is optional, defaults to an empty string, and can contain at most 500 characters.\n- `cover` is optional. Use a relative path inside `client/`, or `null`. Do not include a query, fragment, empty segment, or parent segment.\n- `screenshots` is optional and defaults to `[]`. It accepts up to 8 relative paths inside `client/`.\n- `tags` is optional and defaults to `[]`. It accepts up to 10 values. Each value uses 1 to 24 lowercase letters, digits, or hyphens.\n- `language` is optional and defaults to `en`. Use a BCP 47 language tag such as `en`, `it`, or `pt-BR`.\n- `platform` is required. Use `desktop` when the game needs a keyboard, mouse, large display, or desktop performance. Use `mobile` when it is designed only for touch and small screens. Use `both` only after checking that layout, performance, and controls work on both.\n- `orientation` is optional and defaults to `landscape`. Use `landscape` or `portrait` to describe the intended mobile layout. The device may not honor an orientation request.\n- `input` is optional and defaults to `[]`. Include every supported input from `keyboard`, `mouse`, `touch`, and `gamepad`. Do not claim an input until the game is usable with it.\n- `visibility` is optional and defaults to `public`. Use `public` for catalog eligibility or `unlisted` for access by direct link only.\n- `network` is optional and defaults to `[]`. List every external host contacted or loaded by the game, without scheme, port, path, query, or fragment, for example `api.example.com`. If an external host is missing, the browser blocks the request. Keep the array empty when the game uses only its own files and Caisual services.\n- `isolated` is optional and defaults to `false`. Use `true` only when the game requires shared memory or threaded WebAssembly. Every external host in `network` must then send headers compatible with cross-origin isolation.\n- `requires` is optional and defaults to `{ "webgl2": false, "webgpu": false, "wasm": false, "threads": false, "memoryMb": null, "performance": "light" }`. Declare the minimum capabilities the game truly needs to run. For example, a game with a WebGPU renderer and a WebGL2 fallback declares only `webgl2`. Set `threads` together with `isolated: true`. `memoryMb` accepts `null` or a multiple of 256 from 512 to 32768. Use `light`, `medium`, or `heavy` for the expected performance load.\n- `players` is optional and defaults to `{ "min": 1, "max": 1 }`. Both values are integers from 1 to 24 and `max` must be at least `min`. Set the range that a room needs before play can start.\n- `lobby` is optional and defaults to `false`. Use `true` when players must choose roles or teams, mark themselves ready, and wait for the host to start. With `false`, play starts when the first player enters and later players may join in progress.\n- `persistent` is optional and defaults to `false`. Use `true` when room members must be able to return with the same code after disconnecting, including while the game is already playing. Persistent rooms expire after 30 days without activity.\n- `spectators` is optional and defaults to `{ "delayMs": 3000 }`. Use `false` to disable watching, `true` for the default three-second delay, or `{ "delayMs": N }` to choose an integer delay from 0 to 30000 milliseconds.\n- `boards` is optional and defaults to `{}`. Each key is a leaderboard id. Use `{ "source": "server" }` to accept only `room.board.submit`, or `{ "source": "client" }` to allow browser submissions. Boards not listed use `client`. A manifest may list up to 32 boards. `label` is optional plain text, 1 to 48 characters on one line, and names the board in the overlay; without it the overlay shows the id. `periods` is optional and defaults to `["all-time"]`: list `daily`, `all-time` or both, without duplicates. `all-time` means the best score with no day attached, not a sum of days. `periods` only chooses what the overlay offers; it does not change what the score APIs accept.\n- `roles` is optional and defaults to `[]`. Each entry has an `id` of 1 to 32 lowercase letters, digits, or internal hyphens, a `min` integer from 0 to 24, and an optional `max` in the same range. Rooms enforce these capacities in the lobby. `label` is optional plain text, 1 to 32 characters on one line, and names the role in the overlay lobby; without it the overlay shows the id.\n- `teams` is optional and defaults to `null`. An object has `min` and `max` integers from 2 to 24, with `max` at least `min`. Rooms balance players who do not choose a team.\n- `voice` is optional and defaults to `none`. Use `room` so everyone in the room can hear each other, `team` to restrict voice to teammates, or `proximity` when `server.js` sets the gain between player pairs. Use `none` to disable voice.\n- `modes` is optional and defaults to `[]`. A mode has a unique `id` using 1 to 32 lowercase letters, digits, or internal hyphens. It may have `matchmaking` with `key`, an array of 1 to 8 unique field names, and `timeoutMs`, an integer from 1,000 to 300,000. Each field name uses 1 to 32 lowercase letters, digits, or hyphens and starts with a letter or digit. A mode may also define `players: { min, max }` (both integers from 1 to 24, max at least min) and `lobby` (boolean). Each supplied field replaces its root counterpart for creation, joining and matchmaking, including filling an open room; omitted fields inherit the root value. `players` is replaced as a whole, not merged. `mode: null` uses the root configuration. Roles, teams, voice and persistence remain game-wide. Catalog labels consider the resolved modes, or the root range when there are no modes: Single player, Multiplayer, or Solo + Multiplayer.\n- A standard game declares at least one mode, and every mode of a standard game needs `execution`: `local` for a run inside the browser, `room` for a room. A `local` mode resolves to exactly one player with `lobby` false and no matchmaking; it is not a room of one, and the create and match APIs refuse it. A `room` mode requires `server.js`, checked by the CLI and again when the version is published.\n- `label` is optional plain text, 1 to 48 characters on one line, and names the mode in the standard menu; without it the menu shows the id. `instructions` is optional plain text, 1 to 160 characters on one line, and adds a line under the label. Both are text, never HTML, and stay in the author\'s own language.\n- `matchmaking.defaults` is required when the overlay is expected to start a search on its own. It holds exactly the fields listed in `key`, with safe integers or strings of 1 to 64 characters from letters, digits, `_ . : -`. Without it a search must come from the game\'s own `c.room.match()` call.\n\nThe CLI prints every manifest error in one run. Fix every listed field and rule before retrying.\n\n`requires` is also available to the game itself through `c.device` in the kit, so the game can show its own warning or pick a lighter renderer. The portal does not gate the Play link on it.\n\n## client/index.html\n\n`index.html` must be at the root of `client/`. Use relative URLs such as `./game.js` or `assets/sprite.png`. Do not use root-relative URLs such as `/game.js`, and do not use parent paths that leave the published `client/` tree.\n\nTo use player identity, saves, and leaderboards, import the kit from `/__caisual/kit/v1.js` as shown in [kit.md](./kit.md). The path `/__caisual/` is reserved: do not put game files under it.\n\nA standard game fills the window: `html`, `body` and the game surface are 100% of the viewport, with no maximum width, no header, no footer and no editorial frame, and the document must not scroll at 1366x768 or at 390x844 with safe areas applied. Aim for the playable field to cover at least 70% of the visible area on desktop and 60% on mobile; a board with a fixed aspect ratio uses the geometric exception described in [kit.md](./kit.md#full-screen).\n\nCaisual draws its own controls on top: a pill in the top-right corner, about 44 pixels tall and wider when it carries an invitation, and a compact bar at the end of a match. Exit lives in that pill. The exact positions arrive in the game as `reservedRects` on `c.overlay.onChange`, in CSS pixels of the game viewport, so place the game\'s own HUD outside them rather than guessing a corner. While a panel is open `inputBlocked` is `true`: release held keys and stop reading input, but keep simulating, because a panel never pauses a room.\n\nA game published without `overlay` keeps the historical control instead: a small round Exit button over the top-right corner, 36 pixels, inside the safe area. Keep that corner free of controls.\n\nDo not register a service worker. The game runs in an iframe on its own origin inside `caisual.com`. Test it without assuming access to the parent page, parent cookies, or files outside `client/`.\n\nWhen `voice` is not `none`, the portal grants microphone access to the game iframe. The browser still asks the player for permission when the game calls `room.voice.join()`. Call it from a button click or another user gesture, not automatically when the page loads.\n\n## Multiplayer server\n\nAdd `server.js` beside `caisual.json` when the game uses rooms. It is the ESM entry point and must have an `export default`. It may import local files such as `./logic/ships.js`, including `.js`, `.ts`, and `.json` files, and npm packages installed in the game folder. The CLI bundles these imports into one file both when publishing and when starting `caisual dev`.\n\nA minimal relay server looks like this:\n\n```js\nimport { defineGame } from \'@caisual/kit/server\';\n\nexport default defineGame({\n onMessage(room, player, message) {\n room.broadcast(message);\n },\n});\n```\n\nThe file may define the optional room callbacks documented in [kit.md](./kit.md). Server code runs without Node.js APIs or network access. Dynamic `import()`, `require()`, and CommonJS exports are not supported. Use only pure JavaScript packages, such as a noise or vector library. A package that needs an HTTP client is not suitable. The `network` field in `caisual.json` controls only requests made by the browser client.\n\nThe bundled `server.js` may be at most 1,000,000 bytes. Room state must remain plain JSON and may be at most 256 KB when serialized. Each incoming player message may be at most 16 KB, and each connection may send at most 20 messages per second. Room save values may be at most 128 KB.\n\nPublish a multiplayer game with the same `npx @caisual/cli publish` command. When imports need bundling, the CLI prints `Bundling server.js (N KB).` The uploaded file is the bundle: the CLI validates it, declares its size and SHA-256 digest, and uploads it separately from browser files. The portal validates the stored bundle again before making the new game version current.\n\nIf the portal finds an invalid `server.js`, the command prints `The multiplayer server could not be published.` followed by diagnostic hints. The failed version is kept for diagnosis but never becomes current. If the game already has a working version, players continue to receive that version. Fix the reported problem and publish again to create a new version.\n\n## Test locally\n\nRun the local preview from the game folder before publishing:\n\n```sh\nnpx @caisual/cli dev\n```\n\nYou can pass a game folder and choose another port:\n\n```sh\nnpx @caisual/cli dev ./my-game --port 8790\n```\n\nThe command prints a portal URL and a game URL. Open the portal URL. It loads the game in an iframe with the same handshake used after publishing, so `c.connected` is `true`, and it mounts the same standard overlay when the manifest declares one. Player identity, saves, leaderboards, daily data, invitations, and rooms all use local data. Add `?lang=` with `en`, `it`, `es`, `fr`, `de` or `pt` to see the overlay in another language; friends and parties are marked unavailable locally. Each new browser tab gets a different guest identity, while reloading one tab keeps that tab\'s identity.\n\nWhen `server.js` exists, room data is stored as JSON under `.caisual-dev/` in the game folder. Without `server.js`, the game remains single player and attempts to create a room return `no_server`.\n\nPress Ctrl+C in the terminal to stop the preview. No account or publish key is required.\n\n## Limits\n\n- At most 2,000 files per version.\n- At most 50,000,000 bytes per file.\n- At most 200,000,000 bytes for all files in one version.\n- At most 1,000,000 bytes for `server.js`.\n- Dotfiles, dot-directories, and directories named `node_modules` are ignored.\n- Symbolic links and other non-regular files are rejected.\n\nReduce or split files that exceed the per-file limit. Remove generated files that the browser does not need.\n\n## Publish\n\nUse the key supplied by the creator. Set it in the environment so it does not enter shell history as a command-line flag:\n\n```sh\nexport CAISUAL_KEY=\'ck_...\'\nnpx @caisual/cli publish\n```\n\nRun the command from the game folder, or pass the folder path after `publish`. For local portal development only, set `CAISUAL_ORIGIN` to the local HTTP origin.\n\nThe CLI validates the folder, computes every file size and SHA-256 digest, creates a new version, uploads the files, completes the version, and prints the game URL. The stable URL is `https://caisual.com/g/<id>`.\n\nBefore contacting the portal, the CLI scans browser files for common WebGL2, WebGPU, WebAssembly, and shared-memory signatures. A possible mismatch is printed to stderr with a `Warning:` prefix and never blocks publishing. Correct an accurate warning by declaring the minimum matching `requires` field, and set `isolated: true` for shared memory. If the signature belongs to unused code, remove that code from the published client bundle.\n\nThe first games from a new creator are reviewed before they can appear in the public catalog. Their stable links still work while review is pending.\n\n## Update, unlist, or delete\n\nTo update a game, change its files without changing `id`, then run `npx @caisual/cli publish` again. This creates a new version and keeps the same stable game URL.\n\nTo remove the current game from the catalog without publishing a new version, run:\n\n```sh\nCAISUAL_KEY=\'ck_...\' npx @caisual/cli unlist\n```\n\nRestore its public visibility with:\n\n```sh\nCAISUAL_KEY=\'ck_...\' npx @caisual/cli relist\n```\n\nDelete it permanently only when you are certain:\n\n```sh\nCAISUAL_KEY=\'ck_...\' npx @caisual/cli delete --yes\n```\n\nEach command reads the `id` from `caisual.json` in the current folder. You may instead pass a game folder or an ID directly, for example `npx @caisual/cli unlist ./my-game` or `npx @caisual/cli relist my-game`. The publishing key always comes from `CAISUAL_KEY`, never from a flag. Deletion has no interactive prompt, is permanent, removes the stored game files, and never frees the ID for reuse.\n\n## Common errors\n\n- `CAISUAL_KEY is required`: export the creator\'s key in the same shell before publishing or managing a game.\n- `The publish API key is not valid`: create a new key in the account dashboard if the old key expired or was revoked.\n- `game_not_found`: check that the game ID is correct and belongs to the creator represented by `CAISUAL_KEY`; deleted games return the same error.\n- `caisual.json is not valid`: read every reported field and rule, fix all of them, then retry.\n- `client/index.html: file not found`: place `index.html` directly under `client/`, not in a nested build folder.\n- `referenced file not found`: make sure `cover` and every screenshot path match a file under `client/`, including letter case.\n- `file is larger than 50 MB`: compress, reduce, or split the asset and update its references.\n- `upload failed` or a temporary portal error: keep the files unchanged and retry the same publish command. The CLI retries temporary upload failures automatically.\n- `The multiplayer server could not be published`: read every diagnostic hint, fix `server.js`, and publish again. The failed version does not replace the current one.\n- An external browser request works locally but fails after publishing: add its host to `network` and publish a new version. Server code cannot make outbound network requests.\n- A threaded WebAssembly game fails to start: set `isolated` to `true` and verify that every declared external host supports cross-origin isolation.\n';
713
-
714
- // ../../docs/kit.md
715
- var kit_default = "# Caisual game kit\n\nThe kit gives a published game a stable player identity, cloud saves, leaderboards, a daily challenge seed, and multiplayer rooms with server-owned state.\n\nThe kit is a single JavaScript module with no dependencies. It never touches the page: the game keeps its own rendering, input, and UI.\n\n## Load the kit\n\nEvery published game can import the kit from its own origin, without a bundler and without an npm install:\n\n```html\n<script type=\"module\">\n import { caisual } from '/__caisual/kit/v1.js';\n\n const c = await caisual.connect();\n console.log(c.player.name);\n</script>\n```\n\nGames built with a bundler can install the same module from npm:\n\n```sh\nnpm install @caisual/kit\n```\n\n```js\nimport { caisual } from '@caisual/kit';\n```\n\nBoth forms expose the same API. The module also sets `globalThis.caisual` for classic scripts that load it first.\n\nThe path `/__caisual/` is reserved on every game origin. Do not put game files under it.\n\n## Connect\n\n```js\nconst c = await caisual.connect();\n```\n\n`connect()` completes when the game is running inside caisual.com and has received its player identity, or after a short timeout when it is not. Calling it again returns the same promise.\n\n- `c.connected` is `true` inside caisual.com and `false` when the game runs on its own, for example from a local folder during development or when its files are copied elsewhere.\n- `c.player` is `{ id, name, guest }`. `id` is stable for the player across sessions and across every version of the game. `name` is the account username, or `\"Guest\"`. `guest` is `true` for players without an account. When a guest later signs in, saves and scores stay attached to the same `id`.\n- When not connected, `c.player` is `{ id: \"local\", name: \"Guest\", guest: true }`.\n\nDo not store the ticket or reimplement the handshake. The kit handles identity, renewal, and retries.\n\n## Sessions and the standard overlay\n\nA game that declares `overlay` in `caisual.json` is a standard game: it runs full screen and Caisual draws everything around it. The opening menu, the mode choice, the lobby with roles, teams and ready, invitations, friends and parties, matchmaking, spectators, leaderboards, voice, the end of a match and Play again belong to the platform. The game keeps the field, its own HUD and its own settings.\n\n```json\n{ \"overlay\": { \"version\": 1, \"accent\": \"#397e83\" } }\n```\n\nTwo objects appear on the connection. `c.session` says which session the game is in, `c.overlay` says when the platform is on top of it.\n\n```js\nconst c = await caisual.connect();\n\nconst stopSession = c.session.onChange((session) => {\n detachGameListeners();\n if (session.kind === 'idle') return showAttractScene();\n if (session.kind === 'local') return showLocalRun(session.mode, session.status);\n attachGameListeners(session.room, session.kind === 'watch');\n draw(session.room.state);\n});\n\nconst stopOverlay = c.overlay.onChange(({ inputBlocked, reservedRects }) => {\n clearHeldKeys();\n setInputEnabled(!inputBlocked);\n placeHudOutside(reservedRects);\n});\n\nawait loadAssetsAndChosenView();\nc.session.ready();\n```\n\n### The session\n\n`c.session.current` reads the session at once. `onChange` repeats the current value immediately to every new listener, returns a function that removes it, and then reports attaches, detaches and the end of a local run. It never fires for a move or a roster change: those stay on the room listeners.\n\n- `{ kind: 'idle' }`: no session. Show an attract scene, not a menu.\n- `{ kind: 'local', id, mode, status }`: a run of a mode declared with `\"execution\": \"local\"`. `status` is `playing` or `ended`.\n- `{ kind: 'room', id, room }`: `room` is the `Room` documented below, already attached.\n- `{ kind: 'watch', id, room }`: `room` is a `Spectate`. Draw it read only.\n\n`id` changes on every attach, so a second local run is distinguishable from the first.\n\n`c.session.ready()` says the game has loaded its assets and installed its listeners. Call it once, at the end of setup: until then the overlay waits instead of starting a session under a game that is still downloading. It is idempotent.\n\n`c.session.finish()` ends a local run and returns to the standard menu with a Play again action. It applies only to `kind: 'local'`: on a room it fails with `not_local` and on idle it does nothing. The result of an online match comes from the server, never from `finish()`.\n\n`c.session.capabilities` is `{ local, rooms, overlay, requestRole }`. Outside caisual.com `overlay` and `rooms` are `false` while `local` stays `true`, which is the signal to run the game's own offline fallback. No fake room is created.\n\nTwo front ends of the same game share one session. Switching view does not call `ready()` again and does not detach the room: the new renderer reads `c.session.current` and draws.\n\n### The overlay on top\n\n`c.overlay.onChange` repeats the current geometry immediately, then on every change:\n\n- `inputBlocked` is `true` while a panel is open. Release held keys and stop reading input, but keep simulating: opening a panel never pauses a room.\n- `reservedRects` is up to eight rectangles, in CSS pixels of the game viewport. Keep the game's own HUD out of them. The field under a closed overlay stays visible and clickable.\n\n`c.overlay.open(panel)` asks the platform to open one of `home`, `room`, `invite`, `friends`, `voice`, `boards`. It is a request, not a permission: it creates no room and grants nothing. Outside caisual.com it does nothing.\n\nShift+Tab from the field opens the menu and Escape closes it. Text fields inside the game keep their own shortcut.\n\n### What a standard game no longer builds\n\nRemove these and let the overlay do them:\n\n- a start menu with Create, Join or a code field;\n- invitation links, copy buttons and share sheets;\n- the lobby: roster, ready, role and team pickers, the Start button;\n- a matchmaking screen with its cancel button;\n- a friends or party list;\n- voice buttons;\n- a leaderboard screen;\n- an Exit or Back to Caisual button;\n- a Play again button after a match.\n\nThe game still draws its own result inside the field. `c.room.create`, `join`, `match` and `watch` stay available for a game that wants its own entry point: under a standard overlay the room they return becomes the current session and the standard controls follow it. Do not attach a second room controller from a second renderer.\n\n### Full screen\n\nA standard game fills the window. `html`, `body` and the game surface are 100% of the viewport: no maximum width, no header, no footer, no editorial frame, and no document scrolling at 1366x768 or 390x844, safe areas included. Only the HUD and compact controls sit over the scene.\n\nAim for the playable field to cover at least 70% of the visible area on desktop and 60% on mobile, counting only what shows or controls the game. A board with a fixed aspect ratio cannot always reach that: a square board on a 1366x768 window tops out near 56% before any HUD. That is the declared geometric exception: the board must then fill at least 90% of the largest rectangle that fits the area left free, and the HUD must have a stated ceiling, typically 48 to 64 pixels on desktop and about 160 pixels of controls on a phone.\n\n### Resume\n\nCaisual keeps one resume reference per game and per player, in a save key it owns. Leaving through the overlay with Leave for now stores the room code and detaches without giving up the seat; the standard menu then offers Resume, which rejoins from that code. Leave room removes the reference and gives up the seat, and so does a normal end of match. A network drop keeps it.\n\nA game does not read or write that key, and does not build its own Resume button. A reference that is no longer valid returns the service error and the overlay explains it: it does not retry forever. Resume carries the room code, not a promise to reopen the same published version of the game.\n\n### Voice and leaderboards\n\nVoice belongs to the overlay panel, together with the microphone gesture the browser requires. The panel is already in place; while its controls are still arriving, a standard game simply does not draw voice buttons. `room.voice` in this guide keeps working for games that already have their own controls.\n\nLeaderboards are read by the overlay from the published manifest, using the boards and periods declared there. The overlay reads the official verified scores after a submission and offers Refresh: a game does not need a board screen. Scores are still submitted by the game or, better, by `server.js`.\n\n### Known gaps\n\nFour things are deliberately not in this version, and a game should not work around them:\n\n- continuing with the same company: Play again opens a new room and copies the invitation, without moving the other players;\n- resolving the original game version behind a persistent Resume;\n- inviting one friend straight into a room, as opposed to a party;\n- matchmaking for a whole group at once.\n\n## Daily challenge\n\n```js\nc.daily.day; // \"2026-09-04\", the current UTC day\nc.daily.seed; // unsigned 32-bit integer, identical for every player on that day\nconst r = c.daily.random(); // number in [0, 1), deterministic from the seed\n```\n\n`c.daily.random()` is a deterministic generator initialized from `c.daily.seed`. Every `connect()` starts the sequence from the beginning, so two players who call it the same number of times get the same values. Use it to build the level of the day.\n\n`c.time.now()` returns milliseconds aligned with the portal clock. Prefer it to `Date.now()` for anything that must agree with the current day.\n\nWhen not connected, `day` comes from the local clock and `seed` from the local hostname, so a game copied elsewhere still runs.\n\n## Saves\n\nEach player has up to 32 saves per game. A save is any JSON value up to 256 KB when serialized.\n\n```js\nawait c.save.set('slot1', { level: 3, coins: 120 }); // -> { key, bytes, updatedAt }\nconst data = await c.save.get('slot1'); // -> the value, or null\nawait c.save.remove('slot1');\nconst saves = await c.save.list(); // -> [{ key, bytes, updatedAt }]\n```\n\n- Keys use 1 to 32 characters: lowercase letters, digits, `_` or `-`, starting with a letter or digit.\n- `updatedAt` is a millisecond timestamp.\n- Saves are per player and per game. Another game cannot read them.\n- When not connected, saves go to the browser's local storage on the game origin.\n\nErrors reject the promise with an `Error` whose `code` is one of `invalid_request`, `not_found`, `save_limit`, `payload_too_large`, `rate_limited`, `invalid_ticket`, `internal_error`, or `offline`.\n\n## Leaderboards\n\nA leaderboard is identified by a board id chosen by the game. Scores are non-negative integers and higher is better. Each player keeps one entry per board, and one per board per day for daily boards: the best score is kept.\n\n```js\nconst result = await c.board.submit('main', 1234);\n// -> { accepted: true, best: 1234, rank: 7, day: null, verified: false }\n\nconst daily = await c.board.submit('main', 1234, { daily: true });\n// -> { accepted: true, best: 1234, rank: 7, day: \"2026-09-04\", verified: false }\n\nconst top = await c.board.top('main', { daily: true, limit: 10 });\n// -> { day: \"2026-09-04\", entries: [{ rank, name, score, guest, me, verified }], me: { rank, score, verified } | null }\n```\n\n- Board ids use the same format as save keys.\n- `submit` never rejects because of connectivity. When the game is not connected it resolves `{ accepted: false, reason: \"offline\" }`.\n- `best` is the score kept for this player after the submission, which can be higher than the submitted one.\n- `rank` counts players with a strictly higher score. Ties are ordered by who reached the score first.\n- Accounts and guests are ranked separately. `top()` returns account players by default; pass `guests: true` to list guests instead. `me` always refers to the current player within their own category, even beyond `limit`.\n- `limit` is 1 to 100 and defaults to 10.\n- Pass `day: \"2026-09-06\"` to `top()` to read exactly that UTC day, even after midnight. A day implies the daily filter. A date that is not a real `YYYY-MM-DD` is rejected.\n- `verified` is `true` when the kept score came from the room server. Browser scores cannot replace a verified score.\n- Add `\"boards\": { \"main\": { \"source\": \"server\" } }` to `caisual.json` for a server-only board. It accepts scores only from `room.board.submit` in `server.js`.\n- An omitted board has `source: \"client\"`. Browser submissions keep working for existing games.\n- A browser submission to a server-only board rejects with `board_server_only`.\n\n## Device\n\n`c.device` contains the browser and device report collected while `connect()` runs:\n\n```ts\ninterface DeviceReport {\n webgl2: boolean;\n webgpu: boolean;\n wasm: boolean;\n threads: boolean;\n isolated: boolean;\n gpu: 'hardware' | 'software' | 'none';\n memoryMb: number | null;\n cores: number | null;\n mobile: boolean;\n tier: 'low' | 'mid' | 'high';\n}\n```\n\nUse capability fields to choose a renderer, then use `tier` to reduce pixel ratio and quality on smaller devices:\n\n```js\nconst renderer = c.device.webgpu\n ? createWebGpuRenderer()\n : createWebGl2Renderer();\n\nconst pixelRatio = c.device.tier === 'high' ? devicePixelRatio : 1;\nconst quality = c.device.tier === 'low' ? 'low' : 'high';\nrenderer.configure({ pixelRatio, quality });\n```\n\nThe probe takes at most 1.5 seconds. `memoryMb` and `cores` are `null` when the browser does not expose them. The report stays in the browser and is not saved or sent to Caisual.\n\n### Two front ends, one game\n\nKeep one `client/index.html`, one game ID and one server. With `platform: \"both\"`, choose separate front ends in that entry without navigating or adding another iframe:\n```js\nimport { caisual } from '/__caisual/kit/v1.js';\nconst c = await caisual.connect();\nlet preference = null;\ntry { preference = localStorage.getItem('layout'); } catch {}\nconst touch = preference === 'touch' || (preference !== 'desktop' && (c.device.mobile || matchMedia('(pointer: coarse)').matches));\nconst screen = touch ? await import('./touch/main.js') : await import('./desktop/main.js');\nscreen.mount({ c, root: document.querySelector('#app') });\n```\nOffer a manual layout choice, persist it when storage is available, and keep rules and room connections shared. Both front ends use relative asset paths inside `client/`.\n\n## Rooms\n\nA room brings players into the same running game. Creating and joining require a published `server.js`; single-player games can ignore `c.room`.\n\nIn a standard game the overlay creates, joins, matches and watches on the player's behalf, and hands the game the room through `c.session`. The calls below stay available, and their result becomes the current session. Read them for what the room object offers; do not rebuild the entry screens around them.\n\n```js\nconst c = await caisual.connect();\n\nc.room.invited; // invitation code from the game page, or null\n\nconst room = await c.room.create({ mode: null });\n// Or join the invitation that opened the game:\nconst invitedRoom = await c.room.join();\n// Or enter a code supplied by the player:\nconst codedRoom = await c.room.join('ABC234');\n\nroom.code;\nroom.seed; // unsigned 32-bit integer fixed for this room\nroom.invite(); // { code: \"ABC234\", url: \"https://caisual.com/r/ABC234\" }\n```\n\nPass a mode id from the manifest to `create({ mode })`, or `null` to use the root configuration. Optional `players` and `lobby` on that mode replace the root values; joining keeps the configuration of the room being joined. `join()` uses `c.room.invited`; without an invitation, pass the six-character code explicitly. A standard game does not need `invite()`: the overlay owns the invitation panel and the copy action.\n\n### Crew\n\nThe kit automatically reports the player's current room to the Caisual portal, so the player's friends can join with one click. The game does not need to send or handle anything for this. There is no `c.crew` API in this version. In a standard game the friends and party list is a panel of the overlay, so there is nothing to draw either.\n\n### Matchmaking\n\nUse `c.room.match()` to find players who requested the same mode and key. The key must contain exactly the fields declared by that mode's `matchmaking.key` in `caisual.json`.\n\n```js\nconst room = await c.room.match({\n mode: 'daily',\n key: { day: c.daily.day, stage: 3 },\n onWaiting({ players, min, max }) {\n showQueue(`${players}/${max} players, ${min} required`);\n },\n});\n```\n\nMatchmaking uses the selected mode's resolved `players` and `lobby`, and that mode's `matchmaking.timeoutMs`. A room opens as soon as the queue reaches the resolved `players.max`. When `matchmaking.timeoutMs` expires, it also opens if at least `players.min` players are waiting. Otherwise the promise rejects with `no_match`, and the game should offer the player another option. A new search first tries to fill a matching room that is already open and can still accept players.\n\nPass an `AbortSignal` as `signal` to let the player cancel a search. Cancellation rejects with `cancelled`. In a standard game the search screen, its Cancel button and the lobby that follows are the overlay's: declare `matchmaking.defaults` in the mode and the player can start a search from the standard menu without the game passing a key.\n\nRoom status is one of:\n\n- `lobby`: players are joining and choosing their setup.\n- `countdown`: the lobby has accepted `start()` and play begins at the announced server time.\n- `playing`: the game server is running the match.\n- `ended`: the match or connection has ended. `room.result` contains the result last reported by the room. A definitive connection closure uses `{ closed: 4003 }`, `{ closed: 4004 }`, `{ closed: 4005 }`, or `{ closed: 4006 }`.\n\nThe current lobby data is available directly:\n\n```js\nroom.players; // [{ id, name, guest, role, team, ready, connected }]\nroom.you; // this player's id\nroom.host; // the current host's id, or null\n\nroom.ready(true);\nroom.setRole('captain');\nroom.setTeam(1);\n\nif (room.you === room.host) room.start();\n```\n\nIn a standard game the overlay calls these four for the player: read `room.players` to draw the field, not to build a roster panel. `ready`, role, team, and `start()` are lobby actions. Starting requires the host, every connected player to be ready, and the player, role, and team minimums from the manifest. Calling `start()` begins a three-second countdown. A role or team change clears that player's ready state. The built-in `spectator` role is still a player slot for setups such as a shared screen with phone controllers. Use `watch()` for someone who only observes and does not occupy a player slot.\n\nThe server owns room state. Read it and react to updates, but do not assign to it or mutate nested values from the browser:\n\n```js\ndraw(room.state);\n\nconst stopState = room.onState((state, tick, serverTime) => {\n draw(state);\n});\n\nconst stopPlayers = room.onPlayers((players) => updateLobby(players));\nconst stopStatus = room.onStatus((status, result, at) => showStatus(status, result, at));\n\nstopState();\nstopPlayers();\nstopStatus();\n```\n\n`room.tick` identifies the latest state. The kit applies structural updates in order for both tick-based and event-only servers. Full state is sent on entry, reconnection or resync when an update does not match the current tick; normal updates, including every hundredth tick, remain diffs. `room.serverTime()` returns milliseconds aligned with the room clock and is kept current by a ping every five seconds.\n\nSend JSON input to `onMessage` in the server definition, and receive JSON sent or broadcast by the server:\n\n```js\nroom.send({ type: 'fire', target: 3 });\n\nconst stopMessages = room.onMessage((message) => {\n showEvent(message);\n});\n```\n\nThe kit numbers outgoing inputs in increasing order. It automatically reconnects temporary failures with delays of 1, 2, 4, then 8 seconds, for at most the room's 60-second grace period. Each attempt gets a fresh room token. A successful reconnect replaces local state with a full server state. Inputs sent while reconnecting throw an error with `code: \"offline\"`.\n\nCall `room.leave()` for an intentional departure. The kit does not reconnect after leaving, being kicked, the room ending, the published version closing, or the same player opening the room in another tab.\n\n`room.disconnect()` is the other departure: it stops the transport, the retries and voice without sending a leave, so the server keeps the seat under its own persistence and grace rules. It is not reversible on the same object; returning means entering again from the code. The overlay uses it for Leave for now, together with the resume reference.\n\nA room also exposes `room.mode`, `room.countdownAt`, `room.connection`, `room.metadata`, and the `onMetadata` and `onConnection` listeners. `connection` is one of `connecting`, `connected`, `reconnecting`, `disconnected`, `ended`, `closed`, or `replaced`, where `replaced` means the same player opened the room in another tab. Unlike `session.onChange` and `overlay.onChange`, these listeners do not repeat the current value: read the getter first.\n\n`await room.requestRole('scout')` asks the server for a role change during a match. It works only while the room is playing, only for a role declared in the manifest, and only when `server.js` defines `onRoleRequest(room, player, role)`; the server approves by calling `room.setRole`. Without that callback nothing changes, and the capability shows as `false` in `c.session.capabilities`. It is not a shortcut for changing roles from the browser.\n\nRoom creation, joining, and matchmaking reject with an `Error` carrying a stable `code`. Common codes are `invalid_request`, `no_server`, `no_match`, `cancelled`, `room_not_found`, `room_full`, `room_playing`, `room_ended`, `rate_limited`, `invalid_ticket`, `internal_error`, and `offline`. `no_server` means the published game has no multiplayer server. When `c.connected` is `false`, `create`, `join`, and `match` reject with `offline`.\n\nEvery `onState`, `onPlayers`, `onStatus`, and `onMessage` call returns a function that removes that listener.\n\n### Spectators\n\nIn a standard game the overlay offers watching from the menu and the session arrives as `{ kind: 'watch' }`. Use `c.room.watch(code)` to observe a room without joining it as a player:\n\n```js\nconst view = await c.room.watch('ABC234');\n\ndraw(view.state);\nview.onState((state) => draw(state));\nview.onPlayers((players) => updateRoster(players));\nview.onStatus((status, result) => showStatus(status, result));\nview.onMessage((message) => showEvent(message));\n\nview.leave();\n```\n\nThe returned `Spectate` object exposes `state`, `tick`, `seed`, `status`, `players`, `host`, `code`, `result`, `delayMs`, the four listeners shown above, `serverTime()`, and `leave()`. It receives the room's public state, snapshots and updates, player list, status, and messages broadcast by `server.js`. The kit repairs a missed update automatically and reconnects temporary failures for the same 60-second grace period used by players.\n\nPublic room events are delayed by `delayMs`, which defaults to 3000 milliseconds. A game can set `\"spectators\": { \"delayMs\": N }` in `caisual.json`, where `N` is from 0 to 30000, or set `\"spectators\": false` to disable watching.\n\nA spectator has no `you`, `invite()`, `send()`, or voice API. Watching does not add anyone to `room.players`, does not affect roles, teams, player minimums, the host, or room lifetime, and is not visible to `server.js`. `watch()` can reject with `room_not_found`, `room_ended`, `spectators_disabled`, `spectators_full`, `rate_limited`, `offline`, or `invalid_request`.\n\n## Voice\n\nEvery room has a `room.voice` object. Voice is disabled by default and is enabled with the manifest's `voice` field.\n\nIn a standard game voice belongs to the overlay panel, with the click the browser requires. The panel is in place and its controls are arriving; until then a standard game simply draws no voice buttons. The rest of this section is the API used by games that keep their own controls, and it stays supported.\n\nA game with its own controls must offer an explicit one, because `join()` must be called from a click or another user gesture so the browser can start audio and, when publishing, request microphone permission.\n\n```js\nconst micButton = document.querySelector('#mic');\nconst voiceList = document.querySelector('#voice-list');\n\nfunction renderVoice(peers = room.voice.peers) {\n voiceList.replaceChildren(...peers.map((peer) => {\n const item = document.createElement('li');\n const player = room.players.find((entry) => entry.id === peer.id);\n item.textContent = `${player?.name ?? peer.id}: ${\n peer.speaking ? 'speaking' : peer.muted ? 'muted' : 'quiet'\n }`;\n return item;\n }));\n micButton.textContent = room.voice.state === 'off'\n ? 'Join voice'\n : !room.voice.mic ? 'Listening' : room.voice.muted ? 'Unmute' : 'Mute';\n}\n\nmicButton.addEventListener('click', async () => {\n if (room.voice.state === 'off') await room.voice.join();\n else if (room.voice.mic) room.voice.mute(!room.voice.muted);\n renderVoice();\n});\n\nroom.voice.onPeers(renderVoice);\nroom.voice.onState(() => renderVoice());\nrenderVoice();\n```\n\n`room.voice.mode` is `none`, `room`, `team`, or `proximity`. In `room` mode, every participant in voice can hear every other participant. In `team` mode, players hear only their team. In `proximity` mode, the room server controls the gain between participants. Call `room.voice.join({ mic: false })` to listen without opening or publishing a microphone. Spectators join in listening mode when they call `join()` without options. A spectator that calls `join({ mic: true })` receives the `spectator` error.\n\nThe room server authorizes every voice track by team and gain. Listening that is no longer allowed is refused or closed.\n\n`room.voice.state` is `off`, `joining`, `on`, or `reconnecting`. `room.voice.mic` is `true` while the local player is publishing. `room.voice.muted` and `room.voice.speaking` describe the local microphone. `room.voice.peers` contains the other voice participants as `{ id, mic, muted, speaking, volume, gain }`. A listening participant has `mic: false`, `muted: true`, and `speaking: false`. `volume` is the local setting and `gain` is the value from the room server. Use `room.voice.setVolume(playerId, volume)` with a value from 0 to 1 to change only local playback.\n\n`room.voice.onPeers(listener)` runs when participants, microphone state, mute state, speaking state, volume, or gain changes. `room.voice.onState(listener)` reports connection state changes. Both return a function that removes the listener.\n\nCall `room.voice.leave()` to stop publishing or listening without leaving the room. `room.voice.mute()` requires an active published microphone and otherwise throws `not_publishing`. `room.leave()` and the end of the room stop voice automatically.\n\n`join()` rejects with an `Error` carrying one of these stable codes: `voice_disabled`, `permission_denied`, `unsupported`, `spectator`, `offline`, or `voice_error`. Voice can reconnect after a temporary room or media connection failure. The state becomes `reconnecting` while the kit retries.\n\n## Server\n\nPut `server.js` next to `caisual.json` and publish it with the game. See [publish.md](./publish.md#multiplayer-server) for the file rules, validation, and publishing flow.\n\n```js\nimport { defineGame } from '@caisual/kit/server';\n\nexport default defineGame({\n tickRate: 20, // 0 runs only in response to events\n onCreate(room) {},\n onStart(room) {},\n onJoin(room, player) {},\n onLeave(room, player, reason) {}, // \"left\", \"timeout\", or \"kicked\"\n onMessage(room, player, message) {},\n onTick(room, deltaSeconds) {},\n onEnd(room) {},\n});\n```\n\nAll callbacks are optional. A player is `{ id, name, guest, role, team, connected }`. The room object provides:\n\n```js\nroom.id;\nroom.seed;\nroom.mode;\nroom.status;\nroom.tick;\nroom.tickRate;\nroom.result;\nroom.state;\nroom.players;\nroom.host;\n\nroom.broadcast(message);\nroom.send(playerOrId, message);\nroom.kick(playerOrId);\nroom.setRole(playerOrId, role);\nroom.setTeam(playerOrId, team);\nroom.end(result);\n\nawait room.save('round', value);\nawait room.load('round');\nawait room.shared.get('ship_abc');\nawait room.shared.set('ship_abc', value);\nawait room.shared.delete('ship_abc');\nawait room.shared.list('ship_');\nawait room.shared.increment('visits', 1);\nroom.schedule(milliseconds, 'methodName', payload);\nroom.board.submit(playerOrId, 'main', score, { daily: true });\n\nroom.daily.day;\nroom.daily.seed;\nroom.time.now();\n\nroom.voice.mode;\nroom.voice.setGain(listener, speaker, 0.25);\nroom.voice.setProximity(playerA, playerB, 0.5);\n```\n\nSet `room.state` in `onCreate`, then mutate it only in server callbacks. It must remain plain JSON and may be at most 256 KB when serialized. `broadcast` sends a JSON message to everyone; `send` targets one player. `end` records a JSON result and ends the room. Room saves use keys with the same format as player save keys and values up to 128 KB. `schedule` names a method on the definition so it can run even after a quiet room resumes. Scores submitted through `room.board` are verified. The room fixes their UTC `day` and millisecond `submittedAt` when `submit` is called, so delayed writes and retries do not move them to another day. Older queued scores without these fields retain the write-time day. A daily run crossing midnight is scored on its submission day; games should define a deadline if they require the starting day.\n\nThe browser can change its role or team only while the room is in `lobby`. During a match, the server decides when a player changes role or team with `room.setRole` and `room.setTeam`. Both methods accept a player object or id and immediately update `room.players` for every client.\n\n```js\nonMessage(room, player, message) {\n if (message?.swap === 'captain') {\n room.setRole(player, 'captain');\n }\n},\n```\n\n`room.daily.seed` is shared by every room for the game on the current UTC day. `room.seed` is fixed for one room and is identical on the server and clients, so rooms created on the same day can generate different maps.\n\n### Shared game store\n\n`room.shared` is a server-only JSON key/value store shared by every room of the same game. It is useful when one room must leave data for another room, while `room.save` remains private to one room.\n\nThe following server leaves a ship when a room ends, then loads every previously left ship when another room is created. The room id suffix is used because shared-store keys follow the save-key format.\n\n```js\nexport default defineGame({\n tickRate: 0,\n\n async onCreate(room) {\n const keys = await room.shared.list('ship_');\n room.state = {\n ships: await Promise.all(keys.map((key) => room.shared.get(key))),\n };\n },\n\n async onEnd(room) {\n const roomSuffix = room.id.split('.')[1];\n await room.shared.set('ship_' + roomSuffix, {\n position: room.state.position,\n cargo: room.state.cargo,\n });\n },\n});\n```\n\nThe five methods are asynchronous:\n\n```js\nconst value = await room.shared.get(key); // JSON value, or null\nawait room.shared.set(key, value); // last writer wins\nawait room.shared.delete(key);\nconst keys = await room.shared.list(prefix); // sorted, up to 1024\nconst total = await room.shared.increment(key, 1); // atomic, defaults to 1\n```\n\nKeys contain 1 to 32 lowercase letters, numbers, underscores, or hyphens. Values may be up to 64 KB when serialized, and each game may keep up to 1024 keys. Each room may perform up to 120 shared-store operations per minute. `increment` treats a missing key as zero and rejects unless the existing value, amount, and result are safe integers.\n\nUse the store in `onCreate`, `onStart`, `onEnd`, `onMessage`, or a `schedule` handler. Do not call it on every tick: each call waits for a remote operation, and the CPU budget uses elapsed wall-clock time. Browser clients cannot access this store. Send only the data they need with `room.broadcast` or `room.send`.\n\nFailures reject with an `Error` carrying `store_invalid_key`, `store_too_large`, `store_full`, `store_not_integer`, `store_unavailable`, or `store_rate_limited` in `code`.\n\n`room.voice.setGain(listener, speaker, gain)` controls how much one listener hears one speaker. It is directional, limited to the range from 0 to 1, and rounded to two decimal places. For example, the following setup lets the captain hear everyone while each crew member hears only the captain:\n\n```js\nconst captain = room.players.find((player) => player.role === 'captain');\nconst crew = room.players.filter((player) => player.id !== captain.id);\n\nfor (const speaker of room.players) {\n room.voice.setGain(captain, speaker, 1);\n}\nfor (const listener of crew) {\n for (const speaker of room.players) {\n room.voice.setGain(listener, speaker, speaker.id === captain.id ? 1 : 0);\n }\n}\n```\n\n`room.voice.setProximity(a, b, gain)` is the symmetric shortcut for setting both directions. Both methods work in `room`, `team`, and `proximity` modes, and do nothing in `none`. In `team` mode, gains remain inside the team and cannot make a player hear another team.\n\nFor position-based audio, update the symmetric gain between players from server-owned positions:\n\n```js\nexport default defineGame({\n tickRate: 20,\n onTick(room) {\n for (const a of room.players) {\n for (const b of room.players) {\n if (a.id >= b.id) continue;\n const pa = room.state.positions[a.id];\n const pb = room.state.positions[b.id];\n const distance = Math.hypot(pa.x - pb.x, pa.y - pb.y);\n room.voice.setProximity(a, b, Math.max(0, 1 - distance / 20));\n }\n }\n },\n});\n```\n\n### Sleeping and cost\n\nPrefer `tickRate: 0` for turn based and party games. A room with a tick loop sleeps automatically after 30 seconds without player input or state changes and wakes on the next game message or player joining. Automatic ping and resync messages do not count as player input. A match with no player input for 10 minutes ends with `{ error: 'idle' }`. Timers set with `schedule` and the countdown keep working while the room sleeps.\n\n### CPU budget\n\nEvery `onTick` and `onMessage` call is measured. Twenty consecutive calls above 100 ms end the room with `{ error: 'cpu_budget' }`. If the average over 50 ticks is above 20 ms, the effective `tickRate` is halved, down to a minimum of 5, and clients receive an `error` message with code `tick_rate_reduced`.\n\n`room.tickRate` starts at the definition's `tickRate` and always reports the current effective frequency. `deltaSeconds` follows that frequency, so a fixed-step simulation must accumulate `deltaSeconds` instead of counting ticks. Measurement uses elapsed wall-clock time, so a slow `await` inside a callback also counts. `room.result` is `null` until the room ends, then contains the final game or automatic error result and is available inside `onEnd`.\n\n### Persistent rooms\n\nSet `\"persistent\": true` in `caisual.json` for a room that must survive long breaks. It does not use the normal inactivity ending rule and does not end when every player disconnects. Players remain members until they call `room.leave()` or the server removes them with `room.kick()`. They can use the same room code to return while the game is already playing. The code remains valid while the room lives, and absent members remain in `room.players` with `connected: false`.\n\nA persistent room ends when the server calls `room.end()`, after 30 days without player input, entry, or a state change with `{ error: 'expired' }`, or after five minutes without any members. Consider storing `room.code` with `c.save.set()` and offering a Resume action. A persistent room incurs cost only while it is awake.\n\n## Limits\n\n- 120 requests per minute per player. Beyond that the kit rejects with `rate_limited`; wait and retry.\n- Saves: 32 keys per player per game, 256 KB per value.\n- Scores: safe integers from 0 upward.\n- Room state: 256 KB of plain JSON.\n- Room messages: 16 KB each and 20 messages per second per connection.\n- Spectators: 100 per room, with a configured delay from 0 to 30 seconds.\n- Voice supports audio only and one voice channel per room.\n- Voice control messages: 64 KB each and 30 operations per 10 seconds per connection. Voice traffic is not counted against the room's message limits.\n- Room save values: 128 KB each.\n- Shared game store: 64 KB per JSON value, 1024 keys per game, and 120 operations per minute per room.\n\n## Development\n\nRun `npx @caisual/cli dev` from the game folder, then open the printed portal URL. The preview supplies the normal handshake, so `c.connected` is `true` and the game receives a local guest identity. It also mounts the same standard overlay as the portal, in the language chosen with `?lang=` among `en`, `it`, `es`, `fr`, `de` and `pt`. Friends and parties are marked unavailable locally; everything else, including saves, leaderboards, daily data, invitations, and rooms, works on local data. Opening the portal URL in more browser tabs creates more local players, which makes multiplayer testing possible without publishing.\n\nWhen building the client with a bundler, remember that `client/` is served as-is. Configure Vite, esbuild, or another bundler to write into that folder, for example `vite build --outDir client`, and use relative paths such as `base: './'`.\n\nKeep loading the kit from the `<script type=\"module\">` shown at the beginning of this guide, using `/__caisual/kit/v1.js`.\n\nIf the game has `server.js`, room state is handled locally and stored under `.caisual-dev/` in the game folder. If it has no `server.js`, room creation rejects with `no_server` and the single-player APIs still work.\n\nOpening `client/index.html` from a plain static server still uses standalone mode: `c.connected` is `false`, saves use local storage, `submit` returns `accepted: false`, leaderboards are empty, the daily seed is local, and room creation and joining reject with `offline`. The rest of the game logic does not need a different code path.\n\nAfter publishing with `npx @caisual/cli publish`, open the game from its caisual.com page: `c.connected` becomes `true` and every call goes to the portal.\n\n## Manifest\n\nDeclare `\"overlay\": { \"version\": 1 }` to get the standard overlay, with an optional `accent` colour. A standard game must declare at least one mode, and every mode needs `execution`, either `local` for a single-player run of exactly one player or `room` for a room backed by `server.js`. `label` names the mode in the standard menu and `instructions` adds one line under it. `roles[].label` and `boards[<id>].label` name roles and boards in the same UI, and `boards[<id>].periods` lists `daily`, `all-time` or both. A mode with matchmaking adds `matchmaking.defaults`, one value for every field of its `key`, so the overlay can start a search on its own.\n\n```json\n{\n \"overlay\": { \"version\": 1, \"accent\": \"#397e83\" },\n \"players\": { \"min\": 2, \"max\": 4 },\n \"lobby\": true,\n \"boards\": { \"solo\": { \"source\": \"server\", \"label\": \"Best run\", \"periods\": [\"daily\", \"all-time\"] } },\n \"modes\": [\n { \"id\": \"practice\", \"execution\": \"local\", \"label\": \"Practice\",\n \"instructions\": \"One run against the clock.\",\n \"players\": { \"min\": 1, \"max\": 1 }, \"lobby\": false },\n { \"id\": \"duel\", \"execution\": \"room\", \"label\": \"Online\",\n \"matchmaking\": { \"key\": [\"pool\"], \"defaults\": { \"pool\": \"v1\" }, \"timeoutMs\": 12000 } }\n ]\n}\n```\n\nA game without `overlay` keeps its historical flow and draws its own menus. Nothing else changes for it.\n\nNo manifest field is required for identity, saves, leaderboards, or the daily challenge. Use `boards` to make selected leaderboards server-only. A mode may override only `players: { min, max }` and `lobby`; omitted fields inherit the root configuration, and `mode: null` uses the root values. Matchmaking thresholds and room admission use this same resolution. For rooms, set `players` to the supported range and use `lobby`, `persistent`, `spectators`, `roles`, `teams`, and `modes` to describe the setup and lifetime. Set `voice` to `room`, `team`, or `proximity` to enable the corresponding voice mode, or omit it for `none`. A single-player game can keep `players` at `{ \"min\": 1, \"max\": 1 }`, `lobby` at `false`, and omit `server.js`. See [publish.md](./publish.md#caisualjson) for every field and the publishing steps.\n";
785
+ // ../contracts/src/player.ts
786
+ var GUEST_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
787
+ function guestName(id) {
788
+ let hash = 2166136261;
789
+ for (let index = 0; index < id.length; index++) {
790
+ hash = Math.imul(hash ^ id.charCodeAt(index), 16777619) >>> 0;
791
+ }
792
+ let suffix = "";
793
+ for (let index = 0; index < 4; index++) {
794
+ suffix += GUEST_ALPHABET[hash % GUEST_ALPHABET.length];
795
+ hash = Math.floor(hash / GUEST_ALPHABET.length);
796
+ }
797
+ return `Guest-${suffix}`;
798
+ }
716
799
 
717
800
  // src/bundle.ts
718
801
  import { promises as fs } from "node:fs";
@@ -825,11 +908,78 @@ ${bundledValidation.errori.map((error) => `- ${error}`).join("\n")}`
825
908
  return { source: output, bundled: true };
826
909
  }
827
910
 
911
+ // src/i18n.ts
912
+ var defaultWarn = (message) => process.stderr.write(`Warning: ${message}
913
+ `);
914
+ function warnLegacyLanguage(value, warn = defaultWarn) {
915
+ if (typeof value === "object" && value !== null && "language" in value && !("languages" in value)) {
916
+ warn('language is deprecated; use languages: ["' + String(value.language) + '"]. The first language is the default.');
917
+ }
918
+ }
919
+ async function readLocalDictionary(clientRoot, language) {
920
+ const root = await fs2.realpath(clientRoot);
921
+ const path = await fs2.realpath(join2(root, "i18n", `${language}.json`));
922
+ if (!path.startsWith(`${root}${sep}`)) throw new Error("The dictionary must be inside client/.");
923
+ return JSON.parse(await fs2.readFile(path, "utf8"));
924
+ }
925
+ async function checkGameTexts(clientRoot, manifest, warn = defaultWarn) {
926
+ const folder = join2(clientRoot, "i18n");
927
+ const stat = await fs2.lstat(folder).catch((error) => {
928
+ if (error.code === "ENOENT") return null;
929
+ throw error;
930
+ });
931
+ if (!stat) return;
932
+ const defaultLanguage = normalizeLanguage(manifestLanguages(manifest)[0]) ?? manifestLanguages(manifest)[0];
933
+ const entries = stat.isDirectory() ? await fs2.readdir(folder, { withFileTypes: true }) : [];
934
+ if (!entries.some((entry) => entry.isFile() && entry.name === `${defaultLanguage}.json`)) {
935
+ throw new CliError(2, `client/i18n/${defaultLanguage}.json: the default language file is required when client/i18n exists.`);
936
+ }
937
+ const dictionaries2 = /* @__PURE__ */ new Map();
938
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
939
+ if (entry.name.startsWith(".")) continue;
940
+ const language = entry.name.slice(0, -5);
941
+ if (!entry.isFile() || !entry.name.endsWith(".json") || normalizeLanguage(language) !== language) {
942
+ warn(`client/i18n/${entry.name}: use a canonical BCP 47 filename such as en.json or pt-BR.json.`);
943
+ continue;
944
+ }
945
+ try {
946
+ const value = await readLocalDictionary(clientRoot, language);
947
+ if (!isTextDictionary(value)) {
948
+ warn(`client/i18n/${entry.name}: expected a flat object with string values; this dictionary will be ignored.`);
949
+ continue;
950
+ }
951
+ dictionaries2.set(language, value);
952
+ } catch {
953
+ warn(`client/i18n/${entry.name}: invalid or unreadable JSON; this dictionary will be ignored.`);
954
+ }
955
+ }
956
+ const keys = new Set([...dictionaries2.values()].flatMap((dictionary) => Object.keys(dictionary)));
957
+ for (const [language, dictionary] of dictionaries2) {
958
+ const missing = [...keys].filter((key) => !Object.hasOwn(dictionary, key)).sort();
959
+ if (missing.length) warn(`client/i18n/${language}.json: missing keys: ${missing.join(", ")}.`);
960
+ }
961
+ for (const language of manifestLanguages(manifest)) {
962
+ if (!dictionaries2.has(language)) warn(`client/i18n/${language}.json: no usable dictionary for a declared language; the fallback will be used.`);
963
+ }
964
+ }
965
+
966
+ // src/caisual.ts
967
+ import { createHash as createHash3 } from "node:crypto";
968
+ import { createReadStream, promises as fs4 } from "node:fs";
969
+ import { tmpdir } from "node:os";
970
+ import { basename as basename2, extname as extname2, join as join4, resolve as resolve2 } from "node:path";
971
+
972
+ // ../../docs/publish.md
973
+ var publish_default = '# Publish a game on Caisual\n\nCaisual hosts browser games supplied as folders. Each publish creates an immutable version and moves the game\'s stable link to that version.\nThe publishing flow supports both single-player and multiplayer games and does not require changes in the Caisual dashboard. Player identity, rooms, cloud saves, leaderboards, and the daily challenge come from the game kit, documented in [kit.md](./kit.md).\n\nA game published with `"overlay": { "version": 1 }` is a standard game: it runs full screen and Caisual draws the menu, the lobby, invitations, friends, matchmaking, spectators, leaderboards, voice, the end of a match and Play again on top of it. Write the field, the HUD and the settings; declare the rest in the manifest. See [Sessions and the standard overlay](./kit.md#sessions-and-the-standard-overlay).\n\n## Game folder\n\nUse this structure:\n\n```text\nmy-game/\n caisual.json\n server.js # optional, required only for multiplayer rooms\n client/\n index.html\n i18n/\n en.json\n it.json\n ...\n```\n\n`caisual.json` and `client/index.html` are required. Put every file used by the game under `client/`.\n\nRun `npx @caisual/cli init my-game` to create a minimal single-player folder with the standard overlay and one local mode. Run `npx @caisual/cli init --multiplayer my-game` to add a room mode with matchmaking and a `server.js`. Both templates include `client/i18n/en.json` used by the example client through `const t = await c.text()`. They are full screen and use `c.session` and `c.overlay`; neither draws a menu or a lobby of its own.\n\nThe whole CLI is:\n\n```text\ncaisual init [--multiplayer] [folder]\ncaisual dev [folder] [--port 8790]\ncaisual check [folder] [--json]\ncaisual publish [folder]\ncaisual unlist [folder|id]\ncaisual relist [folder|id]\ncaisual delete [folder|id] --yes\ncaisual skill\ncaisual --help\ncaisual --version\n```\n\n`caisual check [folder] [--json]` runs every local check used by publish: the manifest, game texts, client files, the `server.js` bundle, the cover, and screenshots. It needs no key and uploads nothing. With `--json` it prints a report for tools and agents. It exits with code 2 when the report has errors.\n\n`caisual skill` writes this guide and [kit.md](./kit.md) into `.claude/skills/caisual/SKILL.md` in the current folder and adds a `## Caisual` section to `AGENTS.md`, so an agent working in that repository reads the rules before it starts.\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 "languages": ["en", "it"],\n "platform": "both",\n "overlay": { "version": 1, "accent": "#397e83" },\n "orientation": "landscape",\n "input": ["keyboard", "mouse", "touch"],\n "visibility": "public",\n "network": [],\n "isolated": false,\n "requires": { "webgl2": false, "webgpu": false, "wasm": false, "threads": false, "memoryMb": null, "performance": "light" },\n "players": { "min": 1, "max": 1 },\n "lobby": false,\n "persistent": false,\n "spectators": true,\n "boards": { "main": { "source": "client", "label": "Best run", "periods": ["daily", "all-time"] } },\n "roles": [],\n "teams": null,\n "voice": "none",\n "modes": [\n { "id": "solo", "execution": "local", "label": "Solo", "instructions": "One run against the clock." }\n ]\n}\n```\n\n- `manifest` is required and must be `1`.\n- `overlay` is optional and defaults to absent. Set `{ "version": 1 }` to publish a standard game and get the whole overlay. `accent` is optional and must be a six-digit `#RRGGBB` colour; no other CSS is accepted. A game without `overlay` keeps its historical flow and draws its own menus, and nothing in this guide changes for it.\n- `id` is required. Use 3 to 32 lowercase ASCII letters or digits, with single hyphens only between groups. The ID becomes the URL slug. Choose it carefully because it cannot be renamed or reused after deletion.\n- `name` is required and must contain 1 to 60 characters.\n- `description` is optional, defaults to an empty string, and can contain at most 500 characters.\n- `cover` is optional. Use a relative path inside `client/`, or `null`. Do not include a query, fragment, empty segment, or parent segment.\n- `screenshots` is optional and defaults to `[]`. It accepts up to 8 relative paths inside `client/`.\n- `tags` is optional and defaults to `[]`. It accepts up to 10 values. Each value uses 1 to 24 lowercase letters, digits, or hyphens.\n- `languages` is optional and defaults to `["en"]`. Use a non-empty array of distinct BCP 47 tags such as `["it", "en", "pt-BR"]`. The first language is the default. Tags are normalized to canonical casing. The catalog and standard menu show the available languages. The old `language: "it"` remains accepted as an alias for `languages: ["it"]`, with a CLI deprecation warning. If both fields are supplied, `language` must match the first entry in `languages`.\n- `platform` is required. Use `desktop` when the game needs a keyboard, mouse, large display, or desktop performance. Use `mobile` when it is designed only for touch and small screens. Use `both` only after checking that layout, performance, and controls work on both.\n- `orientation` is optional and defaults to `landscape`. Use `landscape` or `portrait` to describe the intended mobile layout. The device may not honor an orientation request.\n- `input` is optional and defaults to `[]`. Include every supported input from `keyboard`, `mouse`, `touch`, and `gamepad`. Do not claim an input until the game is usable with it.\n- `visibility` is optional and defaults to `public`. Use `public` for catalog eligibility or `unlisted` for access by direct link only.\n- `network` is optional and defaults to `[]`. List every external host contacted or loaded by the game, without scheme, port, path, query, or fragment, for example `api.example.com`. If an external host is missing, the browser blocks the request. Keep the array empty when the game uses only its own files and Caisual services.\n- `isolated` is optional and defaults to `false`. Use `true` only when the game requires shared memory or threaded WebAssembly. Every external host in `network` must then send headers compatible with cross-origin isolation.\n- `requires` is optional and defaults to `{ "webgl2": false, "webgpu": false, "wasm": false, "threads": false, "memoryMb": null, "performance": "light" }`. Declare the minimum capabilities the game truly needs to run. For example, a game with a WebGPU renderer and a WebGL2 fallback declares only `webgl2`. Set `threads` together with `isolated: true`. `memoryMb` accepts `null` or a multiple of 256 from 512 to 32768. Use `light`, `medium`, or `heavy` for the expected performance load.\n- `players` is optional and defaults to `{ "min": 1, "max": 1 }`. Both values are integers from 1 to 24 and `max` must be at least `min`. Set the range that a room needs before play can start.\n- `lobby` is optional and defaults to `false`. Use `true` when players must choose roles or teams, mark themselves ready, and wait for the host to start. With `false`, play starts when the first player enters and later players may join in progress.\n- `persistent` is optional and defaults to `false`. Use `true` when room members must be able to return with the same code after disconnecting, including while the game is already playing. Persistent rooms expire after 30 days without activity.\n- `spectators` is optional and defaults to `{ "delayMs": 3000 }`. Use `false` to disable watching, `true` for the default three-second delay, or `{ "delayMs": N }` to choose an integer delay from 0 to 30000 milliseconds.\n- `boards` is optional and defaults to `{}`. Each key is a leaderboard id. Use `{ "source": "server" }` to accept only `room.board.submit`, or `{ "source": "client" }` to allow browser submissions. Boards not listed use `client`. A manifest may list up to 32 boards. `label` is optional text or a language-to-text object, 1 to 48 characters on one line per translation, and names the board in the overlay; without it the overlay shows the id. `periods` is optional and defaults to `["all-time"]`: list `daily`, `all-time` or both, without duplicates. `all-time` means the best score with no day attached, not a sum of days. `periods` only chooses what the overlay offers; it does not change what the score APIs accept.\n- `roles` is optional and defaults to `[]`. Each entry has an `id` of 1 to 32 lowercase letters, digits, or internal hyphens, a `min` integer from 0 to 24, and an optional `max` in the same range. Rooms enforce these capacities in the lobby. `label` is optional text or a language-to-text object, 1 to 32 characters on one line per translation, and names the role in the overlay lobby; without it the overlay shows the id.\n- `teams` is optional and defaults to `null`. An object has `min` and `max` integers from 2 to 24, with `max` at least `min`. Rooms balance players who do not choose a team.\n- `voice` is optional and defaults to `none`. Use `room` so everyone in the room can hear each other, `team` to restrict voice to teammates, or `proximity` when `server.js` sets the gain between player pairs. Use `none` to disable voice.\n- `modes` is optional and defaults to `[]`. A mode has a unique `id` using 1 to 32 lowercase letters, digits, or internal hyphens. It may have `matchmaking` with `key`, an array of 1 to 8 unique field names, and `timeoutMs`, an integer from 1,000 to 300,000. Each field name uses 1 to 32 lowercase letters, digits, or hyphens and starts with a letter or digit. A mode may also define `players: { min, max }` (both integers from 1 to 24, max at least min) and `lobby` (boolean). Each supplied field replaces its root counterpart for creation, joining and matchmaking, including filling an open room; omitted fields inherit the root value. `players` is replaced as a whole, not merged. `mode: null` uses the root configuration. Roles, teams, voice and persistence remain game-wide. Catalog labels consider the resolved modes, or the root range when there are no modes: Single player, Multiplayer, or Solo + Multiplayer.\n- A standard game declares at least one mode, and every mode of a standard game needs `execution`: `local` for a run inside the browser, `room` for a room. A `local` mode resolves to exactly one player with `lobby` false and no matchmaking; it is not a room of one, and the create and match APIs refuse it. A `room` mode requires `server.js`, checked by the CLI and again when the version is published.\n- `label` is optional text or a language-to-text object, 1 to 48 characters on one line per translation, and names the mode in the standard menu; without it the menu shows the id. `instructions` is optional text or a language-to-text object, 1 to 160 characters on one line per translation, and adds a line under the label. Both are text, never HTML, and resolve to the player\'s language with the fallback described below.\n- `matchmaking.defaults` is required when the overlay is expected to start a search on its own. It holds exactly the fields listed in `key`, with safe integers or strings of 1 to 64 characters from letters, digits, `_ . : -`. Without it a search must come from the game\'s own `c.room.match()` call.\n\nThe CLI prints every manifest error in one run. Fix every listed field and rule before retrying.\n\n`requires` is also available to the game itself through `c.device` in the kit, so the game can show its own warning or pick a lighter renderer. The portal does not gate the Play link on it.\n\n## Game translations\n\nUse this convention for every new game, including games with only one language:\n\n1. Put the supported languages in `languages`, with the default first.\n2. Put game UI strings in `client/i18n/<lang>.json`, with canonical filenames such as `en.json`, `it.json` and `pt-BR.json`. Dictionaries are flat objects with identical keys and string values, including placeholders such as `{n}`.\n3. Call `const t = await c.text()` after `caisual.connect()` and before `c.session.ready()`. Render with `t(\'score\', { n: 3 })`. Use `c.player.language` when formatting dates or numbers.\n4. Translate the mode labels and instructions in the manifest. Role and leaderboard labels support the same objects. `name`, `description` and `tags` are not localized fields.\n\nFor example, a mode can contain:\n\n```json\n{\n "id": "solo",\n "execution": "local",\n "label": { "en": "Solo", "it": "Da solo" },\n "instructions": { "en": "Light up three lights.", "it": "Accendi tre luci." }\n}\n```\n\nA dictionary at `client/i18n/en.json` can contain:\n\n```json\n{ "score": "Lights: {n} / 3", "done": "All lit up!" }\n```\n\nSee [Game language and strings](./kit.md#game-language-and-strings) for a complete manifest, two dictionaries and a working client.\n\nThe kit makes one request to the game\'s own origin. The kit first resolves the player\'s ordered preferences against declared game languages, using exact tags, parent tags, then the game\'s default. `c.player.language` is that declared game language; `c.player.uiLanguage` is the overlay\'s locale. Caisual resolves each text key from the selected game language, its parent language tags, then the manifest\'s default language, then the key itself. For `pt-BR` with English as default, that is `pt-BR`, `pt`, `en`, key. The same chain selects localized manifest text from `uiLanguage` in the overlay; a missing label falls back to its id and missing instructions are omitted. A string continues to appear as written. Translation objects must be non-empty, contain valid language tags and satisfy the original text limits for every value. Empty strings are allowed in game dictionaries, but not in manifest labels or instructions.\n\n`caisual dev` at startup and `caisual publish` before any upload check `client/i18n/`. No folder is required for an existing game. If the folder exists, the default language file must exist as a regular file or the command fails. All other dictionary issues produce warnings: invalid JSON, non-string values, non-canonical filenames, missing dictionaries for declared languages, and differing keys. Missing-key warnings compare the union of keys across every usable file, including keys absent from the default. Invalid dictionaries are ignored at runtime. Fix the warnings before sharing the game; they do not block development or publication. Restart dev after changing the manifest to reload its language list and repeat the checks.\n\nChanging `language: "it"` to `languages: ["it"]` preserves the default and existing behavior. Adding `"en"` declares support; it does not create translations. Add `client/i18n/en.json` and translate the manifest text too. Games and manifests without this convention remain valid.\n\n## client/index.html\n\n`index.html` must be at the root of `client/`. Use relative URLs such as `./game.js` or `assets/sprite.png`. Do not use root-relative URLs such as `/game.js`, and do not use parent paths that leave the published `client/` tree.\n\nTo use player identity, saves, and leaderboards, import the kit from `/__caisual/kit/v1.js` as shown in [kit.md](./kit.md). The path `/__caisual/` is reserved: do not put game files under it.\n\nA standard game fills the window: `html`, `body` and the game surface are 100% of the viewport, with no maximum width, no header, no footer and no editorial frame, and the document must not scroll at 1366x768 or at 390x844 with safe areas applied. Aim for the playable field to cover at least 70% of the visible area on desktop and 60% on mobile; a board with a fixed aspect ratio uses the geometric exception described in [kit.md](./kit.md#full-screen).\n\nCaisual draws its own controls on top: a pill in the top-right corner, about 44 pixels tall and wider when it carries an invitation, and a compact bar at the end of a match. Exit lives in that pill. The exact positions arrive in the game as `reservedRects` on `c.overlay.onChange`, in CSS pixels of the game viewport, so place the game\'s own HUD outside them rather than guessing a corner. While a panel is open `inputBlocked` is `true`: release held keys and stop reading input, but keep simulating, because a panel never pauses a room.\n\nA game published without `overlay` keeps the historical control instead: a small round Exit button over the top-right corner, 36 pixels, inside the safe area. Keep that corner free of controls.\n\nDo not register a service worker. The game runs in an iframe on its own origin inside `caisual.com`. Test it without assuming access to the parent page, parent cookies, or files outside `client/`.\n\nWhen `voice` is not `none`, the portal grants microphone access to the game iframe. The browser still asks the player for permission when the game calls `room.voice.join()`. Call it from a button click or another user gesture, not automatically when the page loads.\n\n## Multiplayer server\n\nAdd `server.js` beside `caisual.json` when the game uses rooms. It is the ESM entry point and must have an `export default`. It may import local files such as `./logic/ships.js`, including `.js`, `.ts`, and `.json` files, and npm packages installed in the game folder. The CLI bundles these imports into one file both when publishing and when starting `caisual dev`.\n\nA minimal relay server looks like this:\n\n```js\nimport { defineGame } from \'@caisual/kit/server\';\n\nexport default defineGame({\n tickRate: 0,\n onMessage(room, player, message) {\n room.broadcast(message);\n },\n});\n```\n\n`tickRate` is required and must be an integer from 0 to 60; `defineGame` throws without it. Use `0` for a server that runs only in response to events. Every callback is optional.\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 game message may be at most 16 KB. Game messages are limited to 20 per second per connection. Excess messages are dropped; `room.onError` receives `rate_limited` at most once per second across both budgets. Protocol service messages, including ping, lobby actions, state requests and voice signaling, have a separate 20/s budget with the same drop policy. More than 100 attempts in each of three consecutive one-second windows in either budget closes the connection with 4008 `rate_limited`; the kit does not reconnect automatically after this abuse closure. Abuse windows start with the first message on the connection; a normal or empty window resets the sequence. Oversized game frames close with 4009 `message_too_large`. 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 --day 2026-09-04\n```\n\nThe optional `--day YYYY-MM-DD` flag pins the UTC date for daily seeds and local daily leaderboards, including room scores. Invalid dates are usage errors; omitting the flag uses today in UTC. Scores persist by day in `.caisual-dev/`, so restarting with another date switches boards without erasing earlier scores. Saves, identities and rooms stay shared; queued room scores keep their submission day across restarts. Reload the game after restarting dev. Real clocks and timers are unchanged.\n\nThe command prints a portal URL and a game URL. Open the portal URL. It loads the game in an iframe with the same handshake used after publishing, so `c.connected` is `true`, and it mounts the same standard overlay when the manifest declares one. Player identity, saves, leaderboards, daily data, invitations, and rooms all use local data. Add `?lang=` with any game language to test the manifest resolution, including regional tags such as `pt-BR`. For `?lang=ja` with Japanese declared, `c.player.language` is `ja` and `c.player.uiLanguage` is `en`: the overlay supports en, it, es, fr, de and pt. Without the parameter, the game uses the browser\'s ordered preferences. Friends and parties are marked unavailable locally. Each new browser tab gets a different guest identity, while reloading one tab keeps that tab\'s identity.\n\nWhen `server.js` exists, room data is stored as JSON under `.caisual-dev/` in the game folder. Without `server.js`, the game remains single player and attempts to create a room return `no_server`.\n\nPress Ctrl+C in the terminal to stop the preview. No account or publish key is required.\n\n## Limits\n\n- At most 2,000 files per version.\n- At most 50,000,000 bytes per file.\n- At most 200,000,000 bytes for all files in one version.\n- At most 1,000,000 bytes for `server.js`.\n- At most 60 versions per publishing key in any 24-hour window. Beyond that the portal answers `publish_rate_limit`.\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\nnpx @caisual/cli check\nexport CAISUAL_KEY=\'ck_...\'\nnpx @caisual/cli publish\n```\n\nRun the command from the game folder, or pass the folder path after `publish`. For local portal development only, set `CAISUAL_ORIGIN` to the local HTTP origin.\n\nThe CLI validates the folder, computes every file size and SHA-256 digest, creates a new version, uploads the files, completes the version, and prints the game URL. The stable URL is `https://caisual.com/g/<id>`.\n\nBefore contacting the portal, the CLI scans browser files for common WebGL2, WebGPU, WebAssembly, and shared-memory signatures. A possible mismatch is printed to stderr with a `Warning:` prefix and never blocks publishing. Correct an accurate warning by declaring the minimum matching `requires` field, and set `isolated: true` for shared memory. If the signature belongs to unused code, remove that code from the published client bundle.\n\nThe first games from a new creator are reviewed before they can appear in the public catalog. Their stable links still work while review is pending.\n\n## Update, unlist, or delete\n\nTo update a game, change its files without changing `id`, then run `npx @caisual/cli publish` again. This creates a new version and keeps the same stable game URL.\n\nTo remove the current game from the catalog without publishing a new version, run:\n\n```sh\nCAISUAL_KEY=\'ck_...\' npx @caisual/cli unlist\n```\n\nRestore its public visibility with:\n\n```sh\nCAISUAL_KEY=\'ck_...\' npx @caisual/cli relist\n```\n\nDelete it permanently only when you are certain:\n\n```sh\nCAISUAL_KEY=\'ck_...\' npx @caisual/cli delete --yes\n```\n\nEach command reads the `id` from `caisual.json` in the current folder. You may instead pass a game folder or an ID directly, for example `npx @caisual/cli unlist ./my-game` or `npx @caisual/cli relist my-game`. The publishing key always comes from `CAISUAL_KEY`, never from a flag. Deletion has no interactive prompt, is permanent, removes the stored game files, and never frees the ID for reuse.\n\n## Common errors\n\n- `CAISUAL_KEY is required`: export the creator\'s key in the same shell before publishing or managing a game.\n- `The publish API key is not valid`: create a new key in the account dashboard if the old key expired or was revoked.\n- `game_not_found`: check that the game ID is correct and belongs to the creator represented by `CAISUAL_KEY`; deleted games return the same error.\n- `caisual.json is not valid`: read every reported field and rule, fix all of them, then retry.\n- `client/index.html: file not found`: place `index.html` directly under `client/`, not in a nested build folder.\n- `referenced file not found`: make sure `cover` and every screenshot path match a file under `client/`, including letter case.\n- `file is larger than 50 MB`: compress, reduce, or split the asset and update its references.\n- `upload failed` or a temporary portal error: keep the files unchanged and retry the same publish command. The CLI retries temporary upload failures automatically.\n- `publish_rate_limit`: this key has already created 60 versions in the last 24 hours. Wait until the oldest one leaves the window.\n- `burst_rate_limit`: too many publishing or management requests arrived at once. Wait briefly and retry.\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';
974
+
975
+ // ../../docs/kit.md
976
+ 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, language, uiLanguage }`. `id` is stable for the player across sessions and across every version of the game. `name` is the account username, or a stable `Guest-XXXX` name derived from the player id. The four-character suffix uses `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`, excluding I, O, 0 and 1; it helps distinguish guests but is not a unique identifier. `guest` is `true` for players without an account. `language` selects game strings; `uiLanguage` is the overlay locale. When a guest later signs in, saves and scores stay attached to the same `id`.\n- When not connected, `c.player` has `id: "local"`, `name: "Guest"`, `guest: true`, plus both language fields. If the host answered, its language information is kept; without a handshake, `language` is the normalized `navigator.language` (or `en`) and `uiLanguage` is its overlay fallback.\n\nDo not store the ticket or reimplement the handshake. The kit handles identity, renewal, and retries.\n\n## Game language and strings\n\n`c.player.language` is the game\'s language; `c.player.uiLanguage` is the overlay\'s locale. Use the former for game strings and formatting, or the latter when intentionally aligning text with the overlay.\n\nThe kit resolves the player\'s explicit portal language choice, otherwise `navigator.languages` in order, against `manifest.languages`. For each preference it tries the exact tag and then its parent tags; if none of the preferences match, it uses the game\'s first declared language. The result is always a normalized declared tag. For example, `ja-JP` with `languages: ["en", "ja"]` selects `ja`, while `uiLanguage` is `en`. The overlay supports English, Italian, Spanish, French, German and Portuguese; it keeps regional tags in those families, such as `pt-BR`, and falls back to `en` for other languages. Games may declare languages outside these six.\n\nA localized portal URL counts as a language choice. The language selector remembers explicit choices, including English; without either, the browser\'s ordered preferences apply. The handshake keeps its legacy `language` field for existing kits, and also sends `uiLanguage`, `languagePreferences` and `gameLanguages`. The kit resolves the game language, including when player services fail after a successful handshake.\n\nWithout a handshake, no manifest is available: `language` is the raw preference from `navigator.language`, normalized as a BCP 47 tag, or `en` if invalid or unavailable. It is not restricted to declared game languages. `uiLanguage` uses the overlay fallback. In `caisual dev`, `?lang=ja` selects `ja` when the manifest declares it, while the overlay stays in English. Without `?lang=`, dev uses `navigator.languages` in order for the game.\n\nPut all game UI strings in flat JSON dictionaries named `client/i18n/<lang>.json`. Use canonical BCP 47 filenames, for example `en.json`, `it.json`, `pt.json`, `pt-BR.json`. Every value is a string; keys are identical across dictionaries. Text can contain named placeholders such as `{n}`.\n\nDeclare supported languages in `caisual.json`. The first is the default. This complete manifest supports a local game:\n\n```json\n{\n "manifest": 1,\n "id": "three-lights",\n "name": "Three Lights",\n "platform": "both",\n "languages": ["en", "it"],\n "overlay": { "version": 1 },\n "modes": [{\n "id": "solo",\n "execution": "local",\n "label": { "en": "Solo", "it": "Da solo" },\n "instructions": { "en": "Light up three lights.", "it": "Accendi tre luci." }\n }]\n}\n```\n\n`client/i18n/en.json`:\n\n```json\n{ "score": "Lights: {n} / 3", "light": "Light up", "done": "All lit up!" }\n```\n\n`client/i18n/it.json`:\n\n```json\n{ "score": "Luci: {n} / 3", "light": "Accendi", "done": "Tutte accese!" }\n```\n\nLoad once during setup, before `c.session.ready()`. The returned function is synchronous and can be used in every draw call:\n\n```html\n<!doctype html>\n<html>\n<head><meta charset="utf-8"><title>Three Lights</title></head>\n<body style="margin:0;min-height:100dvh;display:grid;place-content:center">\n <p id="score"></p>\n <button id="light" disabled></button>\n <script type="module">\n import { caisual } from \'/__caisual/kit/v1.js\';\n const c = await caisual.connect();\n const t = await c.text();\n document.documentElement.lang = c.player.language;\n const score = document.querySelector(\'#score\');\n const light = document.querySelector(\'#light\');\n let n = 0, blocked = false, active = !c.session.capabilities.overlay;\n function draw() {\n score.textContent = n === 3 ? t(\'done\') : t(\'score\', { n });\n light.textContent = t(\'light\');\n light.disabled = blocked || !active || n === 3;\n }\n c.overlay.onChange((view) => { blocked = view.inputBlocked; draw(); });\n c.session.onChange((session) => {\n if (session.kind === \'local\' && session.status === \'playing\') n = 0;\n active = !c.session.capabilities.overlay || (session.kind === \'local\' && session.status === \'playing\');\n draw();\n });\n light.onclick = () => {\n n += 1;\n if (n === 3 && c.session.current.kind === \'local\') c.session.finish();\n draw();\n };\n draw();\n c.session.ready();\n </script>\n</body>\n</html>\n```\n\n`c.text(): Promise<Text>` makes one request to the game\'s own origin, tied to the version currently open. Caisual and `caisual dev` read the matching files and merge them per key: `pt-BR` then `pt` then the manifest\'s default language. Longer tags fall back through their parent tags, such as `zh-Hant-TW`, `zh-Hant`, `zh`. The default file is tried once. If no file contains a key, `t` returns that key. Empty strings are valid translations.\n\nConcurrent and later `c.text()` calls share the same promise and translator for that connection. There are no dependencies, eager downloads, or per-call network requests. Missing files, invalid dictionaries and network failures do not prevent startup. If the Caisual text service is unavailable, for example on a plain static host outside Caisual, the translator returns keys; it does not probe other URLs. Use `caisual dev` to preview the complete convention.\n\n`t(\'score\', { n: 3 })` replaces named placeholders with strings or numbers. An omitted placeholder stays unchanged, such as `{n}`. The result is plain text, with no HTML processing, plural rules or automatic translation. Set `textContent` or draw it on the canvas; do not insert it as HTML. A new document gets the host\'s current language and a fresh translator.\n\nMode `label` and `instructions`, role `label`, and leaderboard `label` accept a string or a language-to-text object, and resolve from `uiLanguage` in the overlay using the same fallback chain. `name`, `description` and `tags` keep their existing forms. Existing single-string labels stay unchanged. See [manifest languages and validation](./publish.md#game-translations) for CLI checks and migration from `language`.\n\n## Sessions and the standard overlay\n\nA game that declares `overlay` in `caisual.json` is a standard game: it runs full screen and Caisual draws everything around it. The opening menu, the mode choice, the lobby with roles, teams and ready, invitations, friends and parties, matchmaking, spectators, leaderboards, voice, the end of a match and Play again belong to the platform. The game keeps the field, its own HUD and its own settings.\n\n```json\n{ "overlay": { "version": 1, "accent": "#397e83" } }\n```\n\nTwo objects appear on the connection. `c.session` says which session the game is in, `c.overlay` says when the platform is on top of it.\n\n```js\nconst c = await caisual.connect();\n\nconst stopSession = c.session.onChange((session) => {\n detachGameListeners();\n if (session.kind === \'idle\') return showAttractScene();\n if (session.kind === \'local\') return showLocalRun(session.mode, session.status);\n attachGameListeners(session.room, session.kind === \'watch\');\n draw(session.room.state);\n});\n\nconst stopOverlay = c.overlay.onChange(({ inputBlocked, reservedRects }) => {\n clearHeldKeys();\n setInputEnabled(!inputBlocked);\n placeHudOutside(reservedRects);\n});\n\nawait loadAssetsAndChosenView();\nc.session.ready();\n```\n\n### The session\n\n`c.session.current` reads the session at once. `onChange` repeats the current value immediately to every new listener, returns a function that removes it, and then reports attaches, detaches and the end of a local run. It never fires for a move or a roster change: those stay on the room listeners.\n\n- `{ kind: \'idle\' }`: no session. Show an attract scene, not a menu.\n- `{ kind: \'local\', id, mode, status }`: a run of a mode declared with `"execution": "local"`. `status` is `playing` or `ended`.\n- `{ kind: \'room\', id, room }`: `room` is the `Room` documented below, already attached.\n- `{ kind: \'watch\', id, room }`: `room` is a `Spectate`. Draw it read only.\n\n`id` changes on every attach, so a second local run is distinguishable from the first.\n\n`c.session.ready()` says the game has loaded its assets and installed its listeners. Call it once, at the end of setup: until then the overlay waits instead of starting a session under a game that is still downloading. It is idempotent.\n\n`c.session.finish()` ends a local run and returns to the standard menu with a Play again action. It applies only to `kind: \'local\'`: on a room it fails with `not_local` and on idle it does nothing. The result of an online match comes from the server, never from `finish()`.\n\n`c.session.capabilities` is `{ local, rooms, overlay, requestRole }`. Outside caisual.com `overlay` and `rooms` are `false` while `local` stays `true`, which is the signal to run the game\'s own offline fallback. No fake room is created.\n\nTwo front ends of the same game share one session. Switching view does not call `ready()` again and does not detach the room: the new renderer reads `c.session.current` and draws.\n\n### The overlay on top\n\n`c.overlay.onChange` repeats the current geometry immediately, then on every change:\n\n- `inputBlocked` is `true` while a panel is open. Release held keys and stop reading input, but keep simulating: opening a panel never pauses a room.\n- `reservedRects` is an array of up to eight `{ x, y, width, height }` rectangles in CSS pixels of the game viewport. Keep the game\'s own HUD out of them, and nothing else: never resize, move, or letterbox the field because of them. The overlay sits on top of the game and the game must not shift when a bar or a pill appears. The field under a closed overlay stays visible and clickable.\n\nThe complete `OverlayView` value is `{ inputBlocked, reservedRects, safeArea?: { top, right, bottom, left }, shortcutEnabled? }`.\n\nThe host measures `safeArea: { top, right, bottom, left }` in CSS pixels of the game viewport, accounting for the iframe\'s position, borders and scale. It updates after viewport changes, including rotation. Use these values for HUD margins: `env(safe-area-inset-*)` inside the iframe usually reads zero. The kit also sets `--caisual-safe-top`, `--caisual-safe-right`, `--caisual-safe-bottom` and `--caisual-safe-left` on the game document root:\n\n```css\n.hud {\n top: max(16px, var(--caisual-safe-top, 0px));\n left: max(16px, var(--caisual-safe-left, 0px));\n}\n```\n\n`safeArea` is optional for compatibility with older hosts; treat a missing value as four zeros. Older kits keep receiving their supported geometry fields. During the opening screen, `inputBlocked` is true from the first view, including before the first host measurement.\n\n\n`c.overlay.open(panel)` asks the platform to open one of `home`, `room`, `invite`, `friends`, `voice`, `boards`. It is a request, not a permission: it creates no room and grants nothing. Outside caisual.com it does nothing.\n\nShift+Tab from the field opens the menu and Escape closes it. Text fields inside the game keep their own shortcut.\n\n### What a standard game no longer builds\n\nRemove these and let the overlay do them:\n\n- a start menu with Create, Join or a code field;\n- invitation links, copy buttons and share sheets;\n- the lobby: roster, ready, role and team pickers, the Start button;\n- a matchmaking screen with its cancel button;\n- a friends or party list;\n- voice buttons;\n- a leaderboard screen;\n- an Exit or Back to Caisual button;\n- a Play again button after a match.\n\nThe game still draws its own result inside the field. `c.room.create`, `join`, `match` and `watch` stay available for a game that wants its own entry point: under a standard overlay the room they return becomes the current session and the standard controls follow it. Do not attach a second room controller from a second renderer.\n\n### Full screen\n\nA standard game fills the window. `html`, `body` and the game surface are 100% of the viewport: no maximum width, no header, no footer, no editorial frame, and no document scrolling at 1366x768 or 390x844, safe areas included. Only the HUD and compact controls sit over the scene.\n\nAim for the playable field to cover at least 70% of the visible area on desktop and 60% on mobile, counting only what shows or controls the game. A board with a fixed aspect ratio cannot always reach that: a square board on a 1366x768 window tops out near 56% before any HUD. That is the declared geometric exception: the board must then fill at least 90% of the largest rectangle that fits the area left free, and the HUD must have a stated ceiling, typically 48 to 64 pixels on desktop and about 160 pixels of controls on a phone.\n\n### Resume\n\nCaisual keeps one resume reference per game and per player, in a save key it owns. Leaving through the overlay with Leave for now stores the room code and detaches without giving up the seat; the standard menu then offers Resume, which rejoins from that code. Leave room removes the reference and gives up the seat, and so does a terminal room end. A `finished` room waiting for a rematch keeps its resume reference. A network drop keeps it.\n\nA game does not read or write that key, and does not build its own Resume button. A reference that is no longer valid returns the service error and the overlay explains it: it does not retry forever. Resume carries the room code, not a promise to reopen the same published version of the game.\n\n### Voice and leaderboards\n\nIn a standard game the overlay\'s voice panel carries Join, Leave, Mute, and the list of who is in the call, with the click the browser requires. A standard game does not draw its own voice buttons. The `room.voice` API below remains for games without the standard overlay and for server-side gain and proximity rules.\n\nLeaderboards are read by the overlay from the published manifest, using the boards and periods declared there. The overlay reads the official verified scores after a submission and offers Refresh: a game does not need a board screen. Scores are still submitted by the game or, better, by `server.js`.\n\n### Known gaps\n\nThree things are deliberately not in this version, and a game should not work around them:\n\n- resolving the original game version behind a persistent Resume;\n- inviting one friend straight into a room, as opposed to a party;\n- matchmaking for a whole group at once.\n\n## Daily challenge\n\n`day` and `seed` stay fixed for the lifetime of the connection, including across UTC midnight. A client score sent with `c.board.submit(..., { daily: true })` is assigned to the UTC day when the portal receives the request, so a submission after midnight belongs to the new day. Room server scores keep the day when `room.board.submit()` queued them, even if their later flush crosses midnight. This does not attach scores to the run\'s starting day.\n\n```js\nc.daily.day;\nc.daily.seed;\nconst random = c.daily.rng();\nconst r = random();\n```\n\n`day` is a UTC date such as `"2026-09-04"`, and `seed` is an unsigned 32-bit integer shared by all players of that game on that day. `c.daily.rng()` returns a fresh deterministic generator producing numbers in [0, 1). Call it at the start of each run, including Play again: two generators made from the same connection yield the same sequence independently.\n\n`c.daily.random()` remains available and is shared for the connection. It advances with every call; use `rng()` to restart. Repeated `caisual.connect()` calls return the same promise and do not reset either the connection or its shared generator.\n\n`c.time.now()` returns milliseconds aligned with the portal clock. Prefer it to `Date.now()` for anything that must agree with the current day.\n\nWhen not connected, `day` comes from the local clock and `seed` from the local hostname, so a game copied elsewhere still runs.\n\n## Saves\n\nEach player has up to 32 saves per game. A save is any JSON value up to 256 KB when serialized.\n\n```js\nawait c.save.set(\'slot1\', { level: 3, coins: 120 }); // -> { key, bytes, updatedAt }\nconst data = await c.save.get(\'slot1\'); // -> the value, or null\nawait c.save.remove(\'slot1\');\nconst saves = await c.save.list(); // -> [{ key, bytes, updatedAt }]\n```\n\n- Keys use 1 to 32 characters: lowercase letters, digits, `_` or `-`, starting with a letter or digit.\n- `updatedAt` is a millisecond timestamp.\n- Saves are per player and per game. Another game cannot read them.\n- When not connected, saves go to the browser\'s local storage on the game origin.\n\nErrors reject the promise with an `Error` whose `code` is one of `invalid_request`, `not_found`, `save_limit`, `payload_too_large`, `rate_limited`, `invalid_ticket`, `internal_error`, or `offline`.\n\n## Leaderboards\n\nA leaderboard is identified by a board id chosen by the game. Scores are non-negative integers and higher is better. Each player keeps one entry per board, and one per board per day for daily boards: the best score is kept.\n\n```js\nconst result = await c.board.submit(\'main\', 1234);\n// -> { accepted: true, best: 1234, rank: 7, day: null, verified: false }\n\nconst daily = await c.board.submit(\'main\', 1234, { daily: true });\n// -> { accepted: true, best: 1234, rank: 7, day: "2026-09-04", verified: false }\n\nconst top = await c.board.top(\'main\', { daily: true, limit: 10 });\n// -> { day: "2026-09-04", entries: [{ rank, name, score, guest, me, verified }], me: { rank, score, verified } | null }\n```\n\n- Board ids use the same format as save keys.\n- `submit` never rejects because of connectivity. When the game is not connected it resolves `{ accepted: false, reason: "offline" }`.\n- `best` is the score kept for this player after the submission, which can be higher than the submitted one.\n- `rank` counts players with a strictly higher score. Ties are ordered by who reached the score first.\n- Accounts and guests are ranked separately. `top()` returns account players by default; pass `guests: true` to list guests instead. `me` always refers to the current player within their own category, even beyond `limit`.\n- `limit` is 1 to 100 and defaults to 10.\n- Pass `day: "2026-09-06"` to `top()` to read exactly that UTC day, even after midnight. A day implies the daily filter. A date that is not a real `YYYY-MM-DD` is rejected.\n- `verified` is `true` when the kept score came from the room server. Browser scores cannot replace a verified score.\n- Add `"boards": { "main": { "source": "server" } }` to `caisual.json` for a server-only board. It accepts scores only from `room.board.submit` in `server.js`.\n- An omitted board has `source: "client"`. Browser submissions keep working for existing games.\n- A browser submission to a server-only board rejects with `board_server_only`.\n\n### Verified scores in a single-player game\n\nDeclare a `room` mode with `players: { "min": 1, "max": 1 }` and `lobby: false`, run the match in `server.js`, and submit the score with `room.board.submit`. The resulting score is `verified`.\n\n## Device\n\n`c.device` contains the browser and device report collected while `connect()` runs:\n\n```ts\ninterface DeviceReport {\n webgl2: boolean;\n webgpu: boolean;\n wasm: boolean;\n threads: boolean;\n isolated: boolean;\n gpu: \'hardware\' | \'software\' | \'none\';\n memoryMb: number | null;\n cores: number | null;\n mobile: boolean;\n tier: \'low\' | \'mid\' | \'high\';\n}\n```\n\nUse capability fields to choose a renderer, then use `tier` to reduce pixel ratio and quality on smaller devices:\n\n```js\nconst renderer = c.device.webgpu\n ? createWebGpuRenderer()\n : createWebGl2Renderer();\n\nconst pixelRatio = c.device.tier === \'high\' ? devicePixelRatio : 1;\nconst quality = c.device.tier === \'low\' ? \'low\' : \'high\';\nrenderer.configure({ pixelRatio, quality });\n```\n\nThe probe takes at most 1.5 seconds. `memoryMb` and `cores` are `null` when the browser does not expose them. The report stays in the browser and is not saved or sent to Caisual.\n\n### Two front ends, one game\n\nKeep one `client/index.html`, one game ID and one server. With `platform: "both"`, choose separate front ends in that entry without navigating or adding another iframe:\n```js\nimport { caisual } from \'/__caisual/kit/v1.js\';\nconst c = await caisual.connect();\nlet preference = null;\ntry { preference = localStorage.getItem(\'layout\'); } catch {}\nconst touch = preference === \'touch\' || (preference !== \'desktop\' && (c.device.mobile || matchMedia(\'(pointer: coarse)\').matches));\nconst screen = touch ? await import(\'./touch/main.js\') : await import(\'./desktop/main.js\');\nscreen.mount({ c, root: document.querySelector(\'#app\') });\n```\nOffer a manual layout choice, persist it when storage is available, and keep rules and room connections shared. Both front ends use relative asset paths inside `client/`.\n\n## Rooms\n\nA room brings players into the same running game. Creating and joining require a published `server.js`; single-player games can ignore `c.room`.\n\nIn a standard game the overlay creates, joins, matches and watches on the player\'s behalf, and hands the game the room through `c.session`. The calls below stay available, and their result becomes the current session. Read them for what the room object offers; do not rebuild the entry screens around them.\n\n```js\nconst c = await caisual.connect();\n\nc.room.invited; // invitation code from the game page, or null\n\nconst room = await c.room.create({ mode: null });\n// Or join the invitation that opened the game:\nconst invitedRoom = await c.room.join();\n// Or enter a code supplied by the player:\nconst codedRoom = await c.room.join(\'ABC234\');\n\nroom.code;\nroom.seed; // unsigned 32-bit integer fixed for this room\nroom.tickRate;\nroom.latency;\nroom.invite(); // { code: "ABC234", url: "https://caisual.com/r/ABC234" }\n```\n\nPass a mode id from the manifest to `create({ mode })`, or `null` to use the root configuration. Optional `players` and `lobby` on that mode replace the root values; joining keeps the configuration of the room being joined. `join()` uses `c.room.invited`; without an invitation, pass the six-character code explicitly. A standard game does not need `invite()`: the overlay owns the invitation panel and the copy action.\n\n### Crew\n\nThe kit automatically reports the player\'s current room to the Caisual portal, so the player\'s friends can join with one click. The game does not need to send or handle anything for this. There is no `c.crew` API in this version. In a standard game the friends and party list is a panel of the overlay, so there is nothing to draw either.\n\n### Matchmaking\n\nUse `c.room.match()` to find players who requested the same mode and key. The key must contain exactly the fields declared by that mode\'s `matchmaking.key` in `caisual.json`.\n\n```js\nconst room = await c.room.match({\n mode: \'daily\',\n key: { day: c.daily.day, stage: 3 },\n onWaiting({ players, min, max }) {\n showQueue(`${players}/${max} players, ${min} required`);\n },\n});\n```\n\nMatchmaking uses the selected mode\'s resolved `players` and `lobby`, and that mode\'s `matchmaking.timeoutMs`. A room opens as soon as the queue reaches the resolved `players.max`. When `matchmaking.timeoutMs` expires, it also opens if at least `players.min` players are waiting. Otherwise the promise rejects with `no_match`, and the game should offer the player another option. A new search first tries to fill a matching room that is already open and can still accept players.\n\nPass an `AbortSignal` as `signal` to let the player cancel a search. Cancellation rejects with `cancelled`. In a standard game the search screen, its Cancel button and the lobby that follows are the overlay\'s: declare `matchmaking.defaults` in the mode and the player can start a search from the standard menu without the game passing a key.\n\nRoom status is one of:\n\n- `lobby`: players are joining and choosing their setup.\n- `countdown`: the lobby has accepted `start()` and play begins at the announced server time.\n- `playing`: the game server is running the match.\n- `finished`: the match is over and the room is waiting for a rematch, with sockets open and `room.result` available.\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 }` when the player was kicked, `{ closed: 4004 }` when the room ended, `{ closed: 4005 }` when the published version closed, or `{ closed: 4006 }` when the same player opened the room in another tab.\n\nThe current lobby data is available directly:\n\n```js\nroom.players; // [{ id, name, guest, role, team, ready, connected }]\nroom.you; // this player\'s id\nroom.host; // the current host\'s id, or null\n\nroom.ready(true);\nroom.setRole(\'captain\');\nroom.setTeam(1);\n\nif (room.you === room.host) room.start();\n```\n\nIn a standard game the overlay calls these four for the player: read `room.players` to draw the field, not to build a roster panel. `ready`, role, team, and `start()` are lobby actions. Starting requires the host, every connected player to be ready, and the player, role, and team minimums from the manifest. Calling `start()` begins a three-second countdown. A role or team change clears that player\'s ready state. The built-in `spectator` role is still a player slot for setups such as a shared screen with phone controllers. Use `watch()` for someone who only observes and does not occupy a player slot.\n\n### Rematch in the same room\n\nThe server opts in per match with `room.end(result, { rematch: true })`. The room becomes `finished`, keeps its code, members, state and open connections, and exposes the result through `room.result` and `onStatus(\'finished\', result, at)`. Voice and spectator streams remain connected. `room.end(result)` or `{ rematch: false }` still ends the room permanently with status `ended` and close code 4004.\n\nEach connected player calls `room.restart()` once to become ready for the rematch. In `finished`, `room.players[].ready` means rematch readiness. Once every connected non-spectator player is ready and the mode\'s `players.min` is met, the host calls `room.restart()` again to confirm. The first host call only registers readiness. A host with the built-in `spectator` role does not register readiness and can confirm once the players are ready. Repeated non-host calls do nothing. Calling outside `finished` fails with `rematch_unavailable`; an early host confirmation reports `players_not_ready` through `room.onError`.\n\nThe standard overlay handles these calls with Play again, the readiness count and names, and Start rematch for the host. Games without the overlay can call `restart()` directly. No member is removed for declining. There is no automatic start after a departure: the current host must confirm.\n\nAt confirmation, the kit clears the result to `null` and all readiness flags, then calls optional `onRestart(room)` with status `lobby` when the mode has a lobby, or `playing` otherwise. **The kit does not reset `room.state`.** Reset match data in `onRestart`, keeping series scores or other data as needed. With a lobby, players choose their setup and get ready again before the normal countdown and `onStart`. Without a lobby, `onStart` follows `onRestart` immediately. Clients receive the new state and an `onStatus` transition with a null result. The room identity, seed and tick sequence are retained; `session.onChange` does not create a new session for a rematch. Listen to `room.onState` and `room.onStatus`.\n\nThe waiting rules are:\n\n- All pending `room.schedule` handlers are cancelled when the match finishes, including handlers already due in the same batch. Scheduling during `finished` has no effect. Schedule new work in `onRestart` or `onStart`. Tick callbacks stop immediately; game input during the wait is discarded, and queued continuous input is cleared on the client.\n- `onEnd` runs once for the completed match, with status `finished` and its result. A subsequent timeout only closes the room and does not call `onEnd` again. Restart does not resubmit or clear pending board scores: each submission is drained once from the existing score queue. The game must not submit the previous match\'s scores again in `onRestart`.\n- Disconnecting clears that member\'s readiness and transfers the host to the oldest connected member. Disconnected players do not count toward readiness or the minimum. The usual 60-second reconnection grace applies; persistent members keep their seats after it. Rejoining requires a new readiness call. Connection and departure callbacks continue during the wait.\n- New members may join by invitation during the wait, up to the resolved `players.max`, and start unready. A lobby mode therefore reopens admission while `finished`; without a lobby, admission continues as during play. Disconnected members still occupy seats until removed. Roles and teams retain their existing capacity rules.\n- `watch()` spectators do not vote or occupy seats and keep following the same delayed stream across matches. Members with role `spectator` occupy a seat but do not vote or count toward the rematch minimum.\n- The room closes with 4004 exactly two minutes after entering `finished` if no restart is confirmed, retaining the last result. Readiness, joins and pings do not extend this deadline. It also applies to persistent rooms and survives sleep or restoration; it does not depend on active ticks.\n\n`finished` is a new status, not a terminal connection state. Older room clients receive the unfamiliar status and keep their sockets open instead of taking their `ended` cleanup path; they have no `restart()` control, and older overlays may retain their previous screen. Use an updated kit for games opting into rematches, and explicitly handle `finished` in game status listeners.\n\nThe server owns room state. Read it and react to updates, but do not assign to it or mutate nested values from the browser:\n\n```js\ndraw(room.state);\n\nconst stopState = room.onState((state, tick, serverTime) => {\n draw(state);\n});\n\nconst stopPlayers = room.onPlayers((players) => updateLobby(players));\nconst stopStatus = room.onStatus((status, result, at) => showStatus(status, result, at));\n\nstopState();\nstopPlayers();\nstopStatus();\n```\n\n`room.tick` identifies the latest state. The kit applies structural updates in order for both tick-based and event-only servers. Full state is sent on entry, reconnection or resync when an update does not match the current tick; normal updates, including every hundredth tick, remain diffs. `room.serverTime()` returns milliseconds aligned with the room clock and is kept current by a ping every five seconds.\n\n`room.tickRate` is the current effective server frequency, including reductions caused by the CPU budget; zero means event-only. Updates arrive with state diffs and snapshots, including an empty diff when only the frequency changes. `room.latency` is the smoothed round-trip time in milliseconds, or `null` before the first pong and after a dropped connection until the next pong. Each pong uses 20% of the new RTT and 80% of the previous estimate, with the first sample used directly. Read the property when drawing network status; there is no `onLatency` listener. Spectators expose the same properties; their tick rate follows the delayed state stream.\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. `room.send` calls while reconnecting throw an error with `code: "offline"`.\n\nUse `room.input(value)` for continuous controls, including calls from every animation frame:\n\n```js\nroom.input({ type: \'move\', x: axisX, y: axisY });\nroom.onError(({ code }) => {\n if (code === \'rate_limited\') showInputWarning();\n});\n```\n\n`input` copies and keeps only the latest JSON value in one slot. It coalesces updates and sends at most 20 times per second, or at the effective `room.tickRate` when that is lower and positive. With `tickRate: 0`, it still sends at most 20/s. It also waits for budget used by `send`. Values with the same JSON serialization are not resent on the same connection. Combine independent controls into that one value; there is no channel option. On the server it is an ordinary message passed unchanged to `onMessage`, exactly like `send`, with no extra envelope.\n\nDuring reconnection, `input` accepts updates without throwing `offline`. After the new welcome it sends only the latest value, even if it was sent on the previous connection. It never replays intermediate values or old commands. Use `send` for individual actions such as firing or confirming a turn; `send` still throws `offline` during reconnection. Invalid JSON input can throw `invalid_request`. Input stops after leaving, disconnecting intentionally or ending the room.\n\nGame messages are limited to 20 per second per connection. Excess messages are dropped; `room.onError` receives `rate_limited` at most once per second across both budgets. Protocol service messages, including ping, lobby actions, state requests and voice signaling, have a separate 20/s budget with the same drop policy. More than 100 attempts in each of three consecutive one-second windows in either budget closes the connection with 4008 `rate_limited`; the kit does not reconnect automatically after this abuse closure. Abuse windows start with the first message on the connection; a normal or empty window resets the sequence. Oversized game frames close with 4009 `message_too_large`, reported through `room.onError`; malformed frames use 4009 `bad_message`. Neither closure is retried automatically.\n\nCall `room.leave()` for an intentional departure. The kit does not reconnect after leaving, being kicked, the room ending, the published version closing, or the same player opening the room in another tab.\n\n`room.disconnect()` is the other departure: it stops the transport, the retries and voice without sending a leave, so the server keeps the seat under its own persistence and grace rules. It is not reversible on the same object; returning means entering again from the code. The overlay uses it for Leave for now, together with the resume reference.\n\nA room also exposes `room.mode`, `room.countdownAt`, `room.connection`, `room.metadata`, and the `onMetadata` and `onConnection` listeners. `connection` is one of `connecting`, `connected`, `reconnecting`, `disconnected`, `ended`, `closed`, or `replaced`, where `replaced` means the same player opened the room in another tab. Unlike `session.onChange` and `overlay.onChange`, these listeners do not repeat the current value: read the getter first.\n\n`room.onError(listener)` reports technical protocol errors of the room as `{ code, message }`; it is not the place where a game reads its own result.\n\n`room.onScoreQueued(listener)` and `room.queuedScores` cover scores submitted for this player by `server.js`. Each entry is `{ board, player, score, day, submittedAt }`, only the owner\'s connection receives it, and the last 32 are kept. It is a technical notice that the server accepted the score, not a receipt that it is already on the board: read the board back with `c.board.top()` for that.\n\n`await room.requestRole(\'scout\')` asks the server for a role change during a match. It works only while the room is playing, only for a role declared in the manifest, and only when `server.js` defines `onRoleRequest(room, player, role)`; the server approves by calling `room.setRole`. Without that callback nothing changes, and the capability shows as `false` in `c.session.capabilities`. It is not a shortcut for changing roles from the browser.\n\nRoom creation, joining, and matchmaking reject with an `Error` carrying a stable `code`. Common codes are `invalid_request`, `no_server`, `no_match`, `cancelled`, `room_not_found`, `room_full`, `room_playing`, `room_ended`, `rate_limited`, `invalid_ticket`, `internal_error`, and `offline`. `no_server` means the published game has no multiplayer server. When `c.connected` is `false`, `create`, `join`, and `match` reject with `offline`.\n\n- `invalid_role`: a requested role id is malformed or is not declared in the manifest. Request a declared role id.\n- `role_change_unavailable`: the room is disconnected, is not playing, or `server.js` has no `onRoleRequest`. Wait for a connected playing state and provide that callback before offering the action.\n- `role_change_refused`: `onRoleRequest` returned without assigning the requested role. Leave the current role in place, or have the server approve with `room.setRole`.\n- `version_closed`: the room connection ended because its published version closed. Reopen the current game version and enter a current room.\n\nEvery listener call on a room returns a function that removes that listener: `onState`, `onPlayers`, `onStatus`, `onMessage`, `onMetadata`, `onConnection`, `onError`, and `onScoreQueued`.\n\n### Spectators\n\nIn a standard game the overlay offers watching from the menu and the session arrives as `{ kind: \'watch\' }`. Use `c.room.watch(code)` to observe a room without joining it as a player:\n\n```js\nconst view = await c.room.watch(\'ABC234\');\n\ndraw(view.state);\nview.onState((state) => draw(state));\nview.onPlayers((players) => updateRoster(players));\nview.onStatus((status, result) => showStatus(status, result));\nview.onMessage((message) => showEvent(message));\n\nview.leave();\n```\n\nThe returned `Spectate` object exposes `state`, `tick`, `seed`, `status`, `players`, `host`, `code`, `result`, `delayMs`, the four listeners shown above, `serverTime()`, and `leave()`. It receives the room\'s public state, snapshots and updates, player list, status, and messages broadcast by `server.js`. The kit repairs a missed update automatically and reconnects temporary failures for the same 60-second grace period used by players.\n\nPublic room events are delayed by `delayMs`, which defaults to 3000 milliseconds. A game can set `"spectators": { "delayMs": N }` in `caisual.json`, where `N` is from 0 to 30000, or set `"spectators": false` to disable watching.\n\nA spectator has no `you`, `invite()`, `send()`, or voice API. Watching does not add anyone to `room.players`, does not affect roles, teams, player minimums, the host, or room lifetime, and is not visible to `server.js`. `watch()` can reject with `room_not_found`, `room_ended`, `spectators_disabled`, `spectators_full`, `rate_limited`, `offline`, or `invalid_request`.\n\n## Voice\n\nEvery room has a `room.voice` object. Voice is disabled by default and is enabled with the manifest\'s `voice` field.\n\nIn a standard game the overlay\'s voice panel carries Join, Leave, Mute, and the list of who is in the call, with the click the browser requires. A standard game does not draw its own voice buttons. The `room.voice` API below remains for games without the standard overlay and for server-side gain and proximity rules.\n\nA game with its own controls must offer an explicit one, because `join()` must be called from a click or another user gesture so the browser can start audio and, when publishing, request microphone permission.\n\n```js\nconst micButton = document.querySelector(\'#mic\');\nconst voiceList = document.querySelector(\'#voice-list\');\n\nfunction renderVoice(peers = room.voice.peers) {\n voiceList.replaceChildren(...peers.map((peer) => {\n const item = document.createElement(\'li\');\n const player = room.players.find((entry) => entry.id === peer.id);\n item.textContent = `${player?.name ?? peer.id}: ${\n peer.speaking ? \'speaking\' : peer.muted ? \'muted\' : \'quiet\'\n }`;\n return item;\n }));\n micButton.textContent = room.voice.state === \'off\'\n ? \'Join voice\'\n : !room.voice.mic ? \'Listening\' : room.voice.muted ? \'Unmute\' : \'Mute\';\n}\n\nmicButton.addEventListener(\'click\', async () => {\n if (room.voice.state === \'off\') await room.voice.join();\n else if (room.voice.mic) room.voice.mute(!room.voice.muted);\n renderVoice();\n});\n\nroom.voice.onPeers(renderVoice);\nroom.voice.onState(() => renderVoice());\nrenderVoice();\n```\n\n`room.voice.mode` is `none`, `room`, `team`, or `proximity`. In `room` mode, every participant in voice can hear every other participant. In `team` mode, players hear only their team. In `proximity` mode, the room server controls the gain between participants. Call `room.voice.join({ mic: false })` to listen without opening or publishing a microphone. Spectators join in listening mode when they call `join()` without options. A spectator that calls `join({ mic: true })` receives the `spectator` error.\n\nThe room server authorizes every voice track by team and gain. Listening that is no longer allowed is refused or closed.\n\n`room.voice.state` is `off`, `joining`, `on`, or `reconnecting`. `room.voice.mic` is `true` while the local player is publishing. `room.voice.muted` and `room.voice.speaking` describe the local microphone. `room.voice.peers` contains the other voice participants as `{ id, mic, muted, speaking, volume, gain }`. A listening participant has `mic: false`, `muted: true`, and `speaking: false`. `volume` is the local setting and `gain` is the value from the room server. Use `room.voice.setVolume(playerId, volume)` with a value from 0 to 1 to change only local playback.\n\n`room.voice.onPeers(listener)` runs when participants, microphone state, mute state, speaking state, volume, or gain changes. `room.voice.onState(listener)` reports connection state changes. Both return a function that removes the listener.\n\nCall `room.voice.leave()` to stop publishing or listening without leaving the room. `room.voice.mute()` requires an active published microphone and otherwise throws `not_publishing`. `room.leave()` and the end of the room stop voice automatically.\n\n`join()` rejects with an `Error` carrying one of these stable codes: `voice_disabled`, `permission_denied`, `unsupported`, `spectator`, `offline`, or `voice_error`. Voice can reconnect after a temporary room or media connection failure. The state becomes `reconnecting` while the kit retries.\n\n## Server\n\nPut `server.js` next to `caisual.json` and publish it with the game. See [publish.md](./publish.md#multiplayer-server) for the file rules, validation, and publishing flow.\n\n```js\nimport { defineGame } from \'@caisual/kit/server\';\n\nexport default defineGame({\n tickRate: 20, // required, an integer from 0 to 60; 0 runs only in response to events\n onCreate(room) {},\n onStart(room) {},\n onJoin(room, player) {},\n onConnection(room, player, connected) {},\n onLeave(room, player, reason) {}, // "left", "timeout", or "kicked"\n onRoleRequest(room, player, role) {}, // approve with room.setRole\n onMessage(room, player, message) {},\n onTick(room, deltaSeconds) {},\n onEnd(room) {},\n onRestart(room) {},\n});\n```\n\n`tickRate` is the only required field: `defineGame` throws a `TypeError` when it is missing or is not an integer from 0 to 60. All callbacks are optional. A player is `{ id, name, guest, role, team, connected }`.\n\n### Callback order\n\n- `onCreate` runs once when the room is first created, before any player joins.\n- `onJoin` runs when a player first enters the room, not when that same member reconnects. Without a lobby, the first player\'s `onJoin` is followed by `onStart`.\n- `onStart` runs when the room changes to `playing`. Without a lobby this is the first player entry. With a lobby it is after the host starts, the three-second countdown finishes, and the room still meets its minimums.\n- `onTick` runs for each active game tick when `tickRate` is greater than zero. `onMessage` runs for accepted client game messages, and `onRoleRequest` runs for an in-match role request when that callback exists. These events are processed serially, so their relative order is the order in which the room processes them.\n- `onConnection(room, player, connected)` runs with `false` when an existing member loses their connection and with `true` when that disconnected member returns. Both `player.connected` and `room.players` are already updated. It does not run for the first entry, a socket replacement while the member is still connected, or a permanent removal. Restoring a room reconciles actual connections: members saved as connected whose sockets are gone receive `false`, and their later return receives `true`. Surviving sockets receive no extra callback.\n- `onLeave` runs only when a player is removed with reason `left`, `timeout`, or `kicked`. A dropped connection calls `onConnection` during the grace period and does not call `onLeave`.\n- `onEnd` runs after the callback that requested `room.end`, with the final result and status `ended` or `finished`. Automatic room endings also use it, except closure of an already `finished` match, which must not run it twice.\n- `onRestart` runs on host confirmation of a rematch, after clearing readiness and the result and selecting the next status. With a lobby it prepares `lobby`; without one it prepares `playing` and is immediately followed by `onStart`. It is optional; `room.state` is preserved unless the game changes it.\n\nThere is no `onResume` callback. A sleeping room restores its saved state without calling one. When a scheduled time arrives, the room invokes the method named by `room.schedule`.\n\nFor a turn-based game, store the active player\'s id and pause that turn when the player disconnects. This also works with `tickRate: 0`, because callback state changes are published immediately:\n\n```js\nonConnection(room, player, connected) {\n if (room.state.turnPlayerId === player.id) {\n room.state.turnPaused = !connected;\n }\n},\nonMessage(room, player, message) {\n if (room.state.turnPaused || player.id !== room.state.turnPlayerId) return;\n applyTurn(room, player, message);\n},\n```\n\nIf turns have deadlines, save the remaining time when pausing and calculate a new deadline on return. Scheduled handlers must check whether the turn is still paused or current. Decide separately in `onLeave` how to handle a permanent departure. Secret redelivery remains a client responsibility through `room.onConnection`; see [Hidden information](#hidden-information).\n\nThe 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);\nroom.end(result, { rematch: true });\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 closes the room unless `{ rematch: true }` is passed. See [Rematch in the same room](#rematch-in-the-same-room) for readiness, callback order, timer cancellation and the waiting deadline. Room saves use keys with the same format as player save keys and values up to 128 KB. `schedule` names a method on the definition so it can run even after a quiet room resumes. Scores submitted through `room.board` are verified. The room fixes their UTC `day` and millisecond `submittedAt` when `submit` is called, so delayed writes and retries do not move them to another day. Older queued scores without these fields retain the write-time day. A daily run crossing midnight is scored on its submission day; games should define a deadline if they require the starting day.\n\n### Hidden information\n\nEverything in `room.state` reaches every player and every spectator. Never store cards in hand, secret roles, fog of war, or any other private value there.\n\nKeep secrets in room saves through `room.save` and `room.load`, which are server-only, or in module-level variables in `server.js`, keyed by room when needed. Deliver a secret to one player with `room.send(player, { type: \'hand\', cards })`. `onJoin` runs only on the player\'s first entry, not on reconnection, so the client requests its secrets for the current connection and every later reconnection:\n\n```js\nif (room.connection === \'connected\') room.send({ type: \'hand?\' });\nroom.onConnection((state) => {\n if (state === \'connected\') room.send({ type: \'hand?\' });\n});\n```\n\nThe server answers from `onMessage` with `room.send(player, { type: \'hand\', cards })`. The three-second delay of `c.room.watch` does not protect secrets. It only stops a player from watching an opponent\'s live screen in another tab.\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`. The optional `tickRate` field in `state` and `snapshot` protocol messages updates client `room.tickRate`; older clients ignore the added field. A frequency change sends a state diff even when its patch is empty.\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` during a match, contains its result in `finished` or `ended` and inside `onEnd`, and returns to `null` before `onRestart`.\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(result)` without rematch, when its two-minute rematch wait expires, 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- Game messages: 16 KB each and 20/s per connection; excess messages are dropped with at most one `rate_limited` error per second. Service messages have a separate 20/s budget. More than 100 attempts/s in either budget for three consecutive one-second windows closes with 4008. Oversized frames close with 4009 `message_too_large`.\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 signaling also uses the separate service-message budget; audio traffic does not consume either message budget.\n- Room save values: 128 KB each.\n- Shared game store: 64 KB per JSON value, 1024 keys per game, and 120 operations per minute per room.\n\n## Development\n\nRun `npx @caisual/cli dev` from the game folder, then open the printed portal URL. The preview supplies the normal handshake, so `c.connected` is `true` and the game receives a local guest identity. It also mounts the same standard overlay as the portal. `?lang=` chooses the game preference, resolved against the manifest; `c.player.uiLanguage` follows the overlay fallback. For example, `?lang=ja` gives `c.player.language === "ja"` when declared, with the overlay in English. Friends and parties are marked unavailable locally; everything else, including saves, leaderboards, daily data, invitations, and rooms, works on local data. Opening the portal URL in more browser tabs creates more local players, which makes multiplayer testing possible without publishing.\n\nUse `npx @caisual/cli dev --day 2026-09-04` to pin the UTC day used by client and room daily seeds and local daily leaderboards. The flag accepts only a real date in `YYYY-MM-DD` format; without it, dev uses today\'s UTC date. Real clocks and room timers keep running normally. Scores remain in `.caisual-dev/scores.json` under their assigned day: restarting with another `--day` selects that day\'s board, and returning to a previous day restores its scores. Saves, identities and rooms are shared across these dates; queued room scores keep their original day when flushed after a restart. Use `c.board.top(\'main\', { day: \'2026-09-04\', guests: true })` to inspect a specific local day. A changed flag takes effect after restarting dev and reloading the game.\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`, when publishing on Caisual. That URL exists only in `caisual dev` and in the published game.\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\nTo run from any other static server, install `@caisual/kit` from npm and import it with a bundler as `import { caisual } from \'@caisual/kit\'`. In that build standalone mode applies: `c.connected` is `false`, saves use local storage, `submit` returns `accepted: false`, leaderboards are empty, the daily seed is local, and room creation and joining reject with `offline`. The rest of the game logic does not need a different code path.\n\nAfter publishing with `npx @caisual/cli publish`, open the game from its caisual.com page: `c.connected` becomes `true` and every call goes to the portal.\n\n## Manifest\n\nDeclare `"overlay": { "version": 1 }` to get the standard overlay, with an optional `accent` colour. A standard game must declare at least one mode, and every mode needs `execution`, either `local` for a single-player run of exactly one player or `room` for a room backed by `server.js`. `label` names the mode in the standard menu and `instructions` adds one line under it. `roles[].label` and `boards[<id>].label` name roles and boards in the same UI, and `boards[<id>].periods` lists `daily`, `all-time` or both. A mode with matchmaking adds `matchmaking.defaults`, one value for every field of its `key`, so the overlay can start a search on its own.\n\n```json\n{\n "overlay": { "version": 1, "accent": "#397e83" },\n "players": { "min": 2, "max": 4 },\n "lobby": true,\n "boards": { "solo": { "source": "server", "label": "Best run", "periods": ["daily", "all-time"] } },\n "modes": [\n { "id": "practice", "execution": "local", "label": "Practice",\n "instructions": "One run against the clock.",\n "players": { "min": 1, "max": 1 }, "lobby": false },\n { "id": "duel", "execution": "room", "label": "Online",\n "matchmaking": { "key": ["pool"], "defaults": { "pool": "v1" }, "timeoutMs": 12000 } }\n ]\n}\n```\n\nA game without `overlay` keeps its historical flow and draws its own menus. Nothing else changes for it.\n\nNo manifest field is required for identity, saves, leaderboards, or the daily challenge. Use `boards` to make selected leaderboards server-only. A mode may override only `players: { min, max }` and `lobby`; omitted fields inherit the root configuration, and `mode: null` uses the root values. Matchmaking thresholds and room admission use this same resolution. For rooms, set `players` to the supported range and use `lobby`, `persistent`, `spectators`, `roles`, `teams`, and `modes` to describe the setup and lifetime. Set `voice` to `room`, `team`, or `proximity` to enable the corresponding voice mode, or omit it for `none`. A single-player game can keep `players` at `{ "min": 1, "max": 1 }`, `lobby` at `false`, and omit `server.js`. See [publish.md](./publish.md#caisualjson) for every field and the publishing steps.\n';
977
+
828
978
  // src/dev.ts
829
979
  import { createHash as createHash2, createHmac, randomBytes, randomUUID as randomUUID2, timingSafeEqual } from "node:crypto";
830
- import { promises as fs2 } from "node:fs";
980
+ import { promises as fs3 } from "node:fs";
831
981
  import { createServer } from "node:http";
832
- import { basename, dirname as dirname2, extname, join as join2, relative as relative2, resolve, sep } from "node:path";
982
+ import { basename, dirname as dirname2, extname, join as join3, relative as relative2, resolve, sep as sep2 } from "node:path";
833
983
 
834
984
  // ../kit/dist/node.js
835
985
  import { randomUUID } from "node:crypto";
@@ -881,6 +1031,53 @@ function modalitaLocale2(manifest, mode) {
881
1031
  }
882
1032
  var MASSIMO_SPETTATORI = 100;
883
1033
  var RITARDO_SPETTATORI_MS2 = 3e3;
1034
+ function validBoardDay2(value) {
1035
+ if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
1036
+ const at = Date.parse(`${value}T00:00:00Z`);
1037
+ return Number.isFinite(at) && new Date(at).toISOString().slice(0, 10) === value;
1038
+ }
1039
+ var MESSAGGI_GIOCO_AL_SECONDO = 20;
1040
+ var MESSAGGI_SERVIZIO_AL_SECONDO = 20;
1041
+ var LimiteMessaggiStanza = class {
1042
+ connessioni = /* @__PURE__ */ new Map();
1043
+ delete(connessione) {
1044
+ this.connessioni.delete(connessione);
1045
+ }
1046
+ clear() {
1047
+ this.connessioni.clear();
1048
+ }
1049
+ controlla(connessione, gioco, ora) {
1050
+ let stato = this.connessioni.get(connessione);
1051
+ if (stato === void 0) {
1052
+ const budget2 = () => ({ accettati: [], finestra: ora, tentativi: 0, finestreAbusive: 0 });
1053
+ stato = { gioco: budget2(), servizio: budget2(), ultimoErrore: -Infinity };
1054
+ this.connessioni.set(connessione, stato);
1055
+ }
1056
+ const budget = gioco ? stato.gioco : stato.servizio;
1057
+ const limite = gioco ? MESSAGGI_GIOCO_AL_SECONDO : MESSAGGI_SERVIZIO_AL_SECONDO;
1058
+ if (ora - budget.finestra >= 1e3) {
1059
+ budget.finestreAbusive = ora - budget.finestra < 2e3 && budget.tentativi > limite * 5 ? budget.finestreAbusive + 1 : 0;
1060
+ budget.finestra += Math.floor((ora - budget.finestra) / 1e3) * 1e3;
1061
+ budget.tentativi = 0;
1062
+ }
1063
+ budget.tentativi = Math.min(budget.tentativi + 1, limite * 5 + 1);
1064
+ budget.accettati = budget.accettati.filter((at) => ora - at < 1e3);
1065
+ if (budget.accettati.length < limite) {
1066
+ budget.accettati.push(ora);
1067
+ return { accetta: true, avvisa: false, chiudi: false };
1068
+ }
1069
+ const avvisa = ora - stato.ultimoErrore >= 1e3;
1070
+ if (avvisa) stato.ultimoErrore = ora;
1071
+ return {
1072
+ accetta: false,
1073
+ avvisa,
1074
+ chiudi: budget.finestreAbusive >= 2 && budget.tentativi > limite * 5
1075
+ };
1076
+ }
1077
+ };
1078
+ function giornoUtc(ora) {
1079
+ return new Date(ora).toISOString().slice(0, 10);
1080
+ }
884
1081
  function isPlainObject(value) {
885
1082
  const prototype = Object.getPrototypeOf(value);
886
1083
  return prototype === Object.prototype || prototype === null;
@@ -962,7 +1159,7 @@ function creaDiff(prima, dopo) {
962
1159
  visitaDiff(prima, dopo, [], patch);
963
1160
  return patch;
964
1161
  }
965
- function giornoUtc(ora) {
1162
+ function giornoUtc2(ora) {
966
1163
  return new Date(ora).toISOString().slice(0, 10);
967
1164
  }
968
1165
  var COSTANTI_SHA256 = [
@@ -1113,6 +1310,7 @@ var LIMITE_DEPOSITO = 64 * 1024;
1113
1310
  var LIMITE_OPERAZIONI_DEPOSITO = 120;
1114
1311
  var GRAZIA_MS = 6e4;
1115
1312
  var STANZA_VUOTA_MS = 5 * 6e4;
1313
+ var ATTESA_RIVINCITA_MS = 2 * 6e4;
1116
1314
  var COUNTDOWN_MS = 3e3;
1117
1315
  var RIPOSO_TICK_MS = 3e4;
1118
1316
  var INATTIVITA_MS = 10 * 6e4;
@@ -1164,21 +1362,22 @@ var NucleoStanza = class _NucleoStanza {
1164
1362
  this.manifest = manifest;
1165
1363
  this.adattatore = adattatore;
1166
1364
  this.dati = null;
1167
- this.frequenza = /* @__PURE__ */ new Map();
1365
+ this.frequenza = new LimiteMessaggiStanza();
1168
1366
  this.frequenzaDeposito = [];
1169
1367
  this.kickRichiesti = /* @__PURE__ */ new Set();
1170
1368
  this.voceGuadagniCambiati = /* @__PURE__ */ new Map();
1171
1369
  this.fineRichiesta = null;
1172
1370
  this.applicandoAzioni = false;
1173
1371
  this.ultimoStatoOsservato = "";
1372
+ this.tickRateSincronizzato = null;
1174
1373
  this.voceDaPersistire = false;
1175
1374
  const nucleo = this;
1176
1375
  const daily = {
1177
1376
  get day() {
1178
- return giornoUtc(nucleo.adattatore.ora());
1377
+ return nucleo.giornata();
1179
1378
  },
1180
1379
  get seed() {
1181
- return seedGiornata(nucleo.manifest.id, giornoUtc(nucleo.adattatore.ora()));
1380
+ return seedGiornata(nucleo.manifest.id, nucleo.giornata());
1182
1381
  }
1183
1382
  };
1184
1383
  this.room = {
@@ -1232,8 +1431,8 @@ var NucleoStanza = class _NucleoStanza {
1232
1431
  setTeam(player, team) {
1233
1432
  nucleo.impostaSquadraDalServer(idGiocatore(player), team);
1234
1433
  },
1235
- end(result) {
1236
- nucleo.richiediFine(result);
1434
+ end(result, options) {
1435
+ nucleo.richiediFine(result, options?.rematch === true);
1237
1436
  },
1238
1437
  save(key, value) {
1239
1438
  return nucleo.salva(key, value);
@@ -1293,6 +1492,7 @@ var NucleoStanza = class _NucleoStanza {
1293
1492
  const salvato = await adattatore.storage.get(CHIAVE_NUCLEO);
1294
1493
  if (salvato !== void 0) {
1295
1494
  nucleo.dati = salvato;
1495
+ nucleo.tickRateSincronizzato = salvato.tickRate;
1296
1496
  const daAggiornare = salvato.ultimoInputAt === void 0 || salvato.ultimoCambioStatoAt === void 0;
1297
1497
  salvato.ultimoInputAt ??= adattatore.ora();
1298
1498
  salvato.ultimoCambioStatoAt ??= salvato.ultimoInputAt;
@@ -1420,7 +1620,7 @@ var NucleoStanza = class _NucleoStanza {
1420
1620
  if (this.dati.status === "ended") return { ok: false, code: "room_ended" };
1421
1621
  const esistente = this.dati.giocatori.find((player) => player.id === identity.id);
1422
1622
  if (esistente !== void 0) return { ok: true };
1423
- if (this.configurazione.lobby && this.dati.status !== "lobby") {
1623
+ if (this.configurazione.lobby && !["lobby", "finished"].includes(this.dati.status)) {
1424
1624
  return { ok: false, code: "room_playing" };
1425
1625
  }
1426
1626
  if (this.dati.giocatori.length >= this.configurazione.players.max) {
@@ -1439,6 +1639,7 @@ var NucleoStanza = class _NucleoStanza {
1439
1639
  const ora = this.adattatore.ora();
1440
1640
  let player = dati.giocatori.find((item) => item.id === identity.id);
1441
1641
  const nuovo = player === void 0;
1642
+ const riconnesso = player !== void 0 && !player.connected;
1442
1643
  if (player === void 0) {
1443
1644
  player = {
1444
1645
  ...identity,
@@ -1454,6 +1655,7 @@ var NucleoStanza = class _NucleoStanza {
1454
1655
  dati.giocatori.push(player);
1455
1656
  } else {
1456
1657
  if (player.connected && player.connessione !== null && player.connessione !== connessione) {
1658
+ this.frequenza.delete(player.connessione);
1457
1659
  this.adattatore.chiudi(player.connessione, 4006, "replaced");
1458
1660
  }
1459
1661
  player.name = identity.name;
@@ -1475,6 +1677,9 @@ var NucleoStanza = class _NucleoStanza {
1475
1677
  copiaGiocatore(player)
1476
1678
  );
1477
1679
  }
1680
+ if (riconnesso) {
1681
+ await this.chiama(this.definizione.onConnection, this.room, copiaGiocatore(player), true);
1682
+ }
1478
1683
  if (primaConnessione) {
1479
1684
  await this.chiama(this.definizione.onStart, this.room);
1480
1685
  this.inviaStatus(ora);
@@ -1497,14 +1702,16 @@ var NucleoStanza = class _NucleoStanza {
1497
1702
  player.connected = false;
1498
1703
  player.connessione = null;
1499
1704
  player.graziaFinoA = this.adattatore.ora() + GRAZIA_MS;
1500
- if (this.manifest.persistent === true && this.dati.status === "lobby") player.ready = false;
1705
+ if (this.dati.status === "finished" || this.manifest.persistent === true && this.dati.status === "lobby") player.ready = false;
1501
1706
  this.frequenza.delete(connessione);
1502
1707
  if (this.dati.hostId === player.id) this.assegnaHost();
1503
1708
  this.verificaCountdown();
1709
+ await this.chiama(this.definizione.onConnection, this.room, copiaGiocatore(player), false);
1710
+ await this.concludiEvento();
1504
1711
  this.inviaGiocatori();
1505
1712
  await this.persistiEProgramma();
1506
1713
  }
1507
- async ricevi(connessione, frame) {
1714
+ async ricevi(connessione, frame, limiteVerificato = false) {
1508
1715
  if (this.dati === null || this.dati.status === "ended") return;
1509
1716
  if (await this.terminaSeInattiva()) return;
1510
1717
  const player = this.dati.giocatori.find(
@@ -1512,17 +1719,10 @@ var NucleoStanza = class _NucleoStanza {
1512
1719
  );
1513
1720
  if (player === void 0) return;
1514
1721
  if (new TextEncoder().encode(frame).byteLength > LIMITE_FRAME) {
1515
- await this.chiudiConnessione(player, 4009, "bad_message");
1722
+ await this.chiudiConnessione(player, 4009, "message_too_large");
1516
1723
  return;
1517
1724
  }
1518
1725
  const ora = this.adattatore.ora();
1519
- const recenti = (this.frequenza.get(connessione) ?? []).filter((at) => ora - at < 1e3);
1520
- if (recenti.length >= 20) {
1521
- await this.chiudiConnessione(player, 4008, "rate_limited");
1522
- return;
1523
- }
1524
- recenti.push(ora);
1525
- this.frequenza.set(connessione, recenti);
1526
1726
  let message = null;
1527
1727
  try {
1528
1728
  message = record(JSON.parse(frame));
@@ -1532,6 +1732,14 @@ var NucleoStanza = class _NucleoStanza {
1532
1732
  await this.chiudiConnessione(player, 4009, "bad_message");
1533
1733
  return;
1534
1734
  }
1735
+ if (!limiteVerificato) {
1736
+ const limite = this.frequenza.controlla(connessione, message.t === "msg", ora);
1737
+ if (!limite.accetta) {
1738
+ if (limite.avvisa) this.inviaErrore(player, "rate_limited", "Too many room messages. Excess messages are dropped.");
1739
+ if (limite.chiudi) await this.chiudiConnessione(player, 4008, "rate_limited");
1740
+ return;
1741
+ }
1742
+ }
1535
1743
  if (message.t === "ping") {
1536
1744
  if (typeof message.c !== "number" || !Number.isFinite(message.c)) {
1537
1745
  await this.chiudiConnessione(player, 4009, "bad_message");
@@ -1554,6 +1762,10 @@ var NucleoStanza = class _NucleoStanza {
1554
1762
  await this.persistiEProgramma();
1555
1763
  return;
1556
1764
  }
1765
+ if (message.t === "restart") {
1766
+ await this.rivincita(player);
1767
+ return;
1768
+ }
1557
1769
  if (message.t === "ready") {
1558
1770
  if (typeof message.ready !== "boolean") return this.messaggioErrato(player);
1559
1771
  if (!this.inLobby(player)) return;
@@ -1625,6 +1837,7 @@ var NucleoStanza = class _NucleoStanza {
1625
1837
  await this.persistiEProgramma();
1626
1838
  return;
1627
1839
  }
1840
+ if (this.dati.status === "finished") return;
1628
1841
  this.dati.ultimoInputAt = ora;
1629
1842
  await this.chiama(
1630
1843
  this.definizione.onMessage,
@@ -1695,7 +1908,11 @@ var NucleoStanza = class _NucleoStanza {
1695
1908
  if (dovuti.length > 0) {
1696
1909
  const ids = new Set(dovuti.map((timer) => timer.id));
1697
1910
  this.dati.timer = this.dati.timer.filter((timer) => !ids.has(timer.id));
1698
- for (const timer of dovuti) await this.eseguiTimer(timer);
1911
+ for (const timer of dovuti) {
1912
+ await this.eseguiTimer(timer);
1913
+ await this.applicaAzioni();
1914
+ if (this.dati.status === "finished" || this.stanzaTerminata()) break;
1915
+ }
1699
1916
  }
1700
1917
  await this.concludiEvento();
1701
1918
  if (!this.stanzaTerminata() && this.dati.giocatori.length === 0 && this.dati.vuotaDa !== null && this.dati.vuotaDa + STANZA_VUOTA_MS <= ora) {
@@ -1726,22 +1943,30 @@ var NucleoStanza = class _NucleoStanza {
1726
1943
  return esito;
1727
1944
  }
1728
1945
  async riconciliaConnessioni() {
1946
+ if (this.dati === null || this.dati.status === "ended") return;
1729
1947
  const dati = this.richiediDati();
1730
1948
  const attive = new Set(this.adattatore.connessioniAttive());
1731
1949
  const ora = this.adattatore.ora();
1732
- let cambiato = false;
1950
+ const disconnessi = [];
1733
1951
  for (const player of dati.giocatori) {
1734
1952
  if (player.connected && (player.connessione === null || !attive.has(player.connessione))) {
1735
1953
  player.connected = false;
1954
+ if (player.connessione !== null) this.frequenza.delete(player.connessione);
1736
1955
  player.connessione = null;
1737
1956
  player.graziaFinoA = ora + GRAZIA_MS;
1738
- if (this.manifest.persistent === true && dati.status === "lobby") player.ready = false;
1739
- cambiato = true;
1957
+ if (dati.status === "finished" || this.manifest.persistent === true && dati.status === "lobby") player.ready = false;
1958
+ disconnessi.push(player);
1740
1959
  }
1741
1960
  }
1742
- if (cambiato) {
1961
+ if (disconnessi.length > 0) {
1743
1962
  this.assegnaHost();
1744
- await this.persisti();
1963
+ this.verificaCountdown();
1964
+ for (const player of disconnessi) {
1965
+ await this.chiama(this.definizione.onConnection, this.room, copiaGiocatore(player), false);
1966
+ }
1967
+ await this.concludiEvento();
1968
+ this.inviaGiocatori();
1969
+ await this.persistiEProgramma();
1745
1970
  }
1746
1971
  }
1747
1972
  ruoloAutomatico() {
@@ -1855,6 +2080,43 @@ var NucleoStanza = class _NucleoStanza {
1855
2080
  }
1856
2081
  return null;
1857
2082
  }
2083
+ async rivincita(player) {
2084
+ const dati = this.richiediDati();
2085
+ if (dati.status !== "finished") {
2086
+ this.inviaErrore(player, "rematch_unavailable", "This room is not waiting for a rematch.");
2087
+ return;
2088
+ }
2089
+ if (player.role !== "spectator" && !player.ready) {
2090
+ player.ready = true;
2091
+ this.inviaGiocatori();
2092
+ await this.persistiEProgramma();
2093
+ return;
2094
+ }
2095
+ if (dati.hostId !== player.id) return;
2096
+ const attivi = dati.giocatori.filter((item) => item.connected && item.role !== "spectator");
2097
+ if (attivi.length < this.configurazione.players.min || attivi.some((item) => !item.ready)) {
2098
+ this.inviaErrore(player, "players_not_ready", "Enough connected players must be ready for the rematch.");
2099
+ return;
2100
+ }
2101
+ dati.status = this.configurazione.lobby ? "lobby" : "playing";
2102
+ dati.rivincitaFinoA = null;
2103
+ dati.result = null;
2104
+ dati.resultAt = null;
2105
+ dati.ultimoInputAt = this.adattatore.ora();
2106
+ for (const item of dati.giocatori) item.ready = false;
2107
+ await this.chiama(this.definizione.onRestart, this.room);
2108
+ await this.applicaAzioni();
2109
+ if (dati.status === "playing") {
2110
+ await this.chiama(this.definizione.onStart, this.room);
2111
+ }
2112
+ await this.concludiEvento();
2113
+ if (dati.status === "lobby" || dati.status === "playing") {
2114
+ this.inviaSnapshotTutti();
2115
+ this.inviaGiocatori();
2116
+ this.inviaStatus(this.adattatore.ora());
2117
+ }
2118
+ await this.persistiEProgramma();
2119
+ }
1858
2120
  async avviaCountdown(player) {
1859
2121
  const dati = this.richiediDati();
1860
2122
  if (dati.hostId !== player.id) {
@@ -1891,6 +2153,7 @@ var NucleoStanza = class _NucleoStanza {
1891
2153
  const indice = dati.giocatori.findIndex((item) => item.id === player.id);
1892
2154
  if (indice < 0) return;
1893
2155
  dati.giocatori.splice(indice, 1);
2156
+ if (player.connessione !== null) this.frequenza.delete(player.connessione);
1894
2157
  this.rivediVoce();
1895
2158
  this.pulisciGuadagni(player.id);
1896
2159
  if (dati.hostId === player.id) this.assegnaHost();
@@ -1930,6 +2193,7 @@ var NucleoStanza = class _NucleoStanza {
1930
2193
  const json = analizzaJson(payload);
1931
2194
  if (!json.ok) throw new TypeError("Schedule payload must be valid JSON.");
1932
2195
  const dati = this.richiediDati();
2196
+ if (dati.status === "finished" || dati.status === "ended") return;
1933
2197
  dati.timer.push({
1934
2198
  id: dati.prossimoTimerId++,
1935
2199
  at: this.adattatore.ora() + milliseconds,
@@ -1937,6 +2201,9 @@ var NucleoStanza = class _NucleoStanza {
1937
2201
  payload: json.valore
1938
2202
  });
1939
2203
  }
2204
+ giornata(ora = this.adattatore.ora()) {
2205
+ return this.adattatore.giorno?.(ora) ?? giornoUtc2(ora);
2206
+ }
1940
2207
  accodaPunteggio(playerId, board, score, daily) {
1941
2208
  if (!this.richiediDati().giocatori.some((player2) => player2.id === playerId)) {
1942
2209
  throw new Error("Player not found.");
@@ -1948,25 +2215,27 @@ var NucleoStanza = class _NucleoStanza {
1948
2215
  throw new TypeError("Score must be a non-negative safe integer.");
1949
2216
  }
1950
2217
  const submittedAt = this.adattatore.ora();
2218
+ const day = daily ? this.giornata(submittedAt) : null;
1951
2219
  this.richiediDati().punteggi.push({
1952
2220
  playerId,
1953
2221
  board,
1954
2222
  score,
1955
2223
  daily,
1956
2224
  submittedAt,
1957
- day: daily ? giornoUtc(submittedAt) : null
2225
+ day
1958
2226
  });
1959
2227
  const player = this.richiediDati().giocatori.find((item) => item.id === playerId);
1960
2228
  if (player.connected && player.connessione !== null) this.adattatore.invia(player.connessione, {
1961
2229
  t: "score-queued",
1962
- score: { player: playerId, board, score, day: daily ? giornoUtc(submittedAt) : null, submittedAt }
2230
+ score: { player: playerId, board, score, day, submittedAt }
1963
2231
  });
1964
2232
  this.broadcast({ t: "flush" });
1965
2233
  }
1966
- richiediFine(result) {
2234
+ richiediFine(result, rematch = false) {
1967
2235
  const json = analizzaJson(result);
1968
2236
  if (!json.ok) throw new TypeError("Game result must be valid JSON.");
1969
- this.fineRichiesta = json.valore;
2237
+ if (this.dati?.status === "ended" || this.dati?.status === "finished" && rematch) return;
2238
+ this.fineRichiesta = { result: json.valore, rematch };
1970
2239
  }
1971
2240
  async salva(key, value) {
1972
2241
  if (!CHIAVE.test(key)) {
@@ -2133,6 +2402,7 @@ var NucleoStanza = class _NucleoStanza {
2133
2402
  id: dati.id,
2134
2403
  seed: seedStanza(dati.id),
2135
2404
  status: dati.status,
2405
+ result: dati.result,
2136
2406
  mode: dati.mode,
2137
2407
  tick: dati.tick,
2138
2408
  tickRate: dati.tickRate,
@@ -2158,16 +2428,18 @@ var NucleoStanza = class _NucleoStanza {
2158
2428
  host: dati.hostId,
2159
2429
  countdownAt: dati.countdownAt,
2160
2430
  at,
2161
- result: dati.status === "ended" ? dati.result : null
2431
+ result: dati.status === "ended" || dati.status === "finished" ? dati.result : null
2162
2432
  });
2163
2433
  }
2164
2434
  inviaDiff() {
2165
2435
  const dati = this.richiediDati();
2166
2436
  const patch = creaDiff(dati.statoSincronizzato, dati.state);
2167
- if (patch.length === 0) return;
2437
+ if (patch.length === 0 && this.tickRateSincronizzato === dati.tickRate) return;
2438
+ this.tickRateSincronizzato = dati.tickRate;
2168
2439
  this.broadcast({
2169
2440
  t: "state",
2170
2441
  tick: dati.tick,
2442
+ tickRate: dati.tickRate,
2171
2443
  base: dati.tickSincronizzato,
2172
2444
  serverTime: this.adattatore.ora(),
2173
2445
  patch
@@ -2182,8 +2454,10 @@ var NucleoStanza = class _NucleoStanza {
2182
2454
  dati.state = stato.valore;
2183
2455
  dati.statoSincronizzato = copiaJson(stato.valore);
2184
2456
  dati.tickSincronizzato = dati.tick;
2457
+ this.tickRateSincronizzato = dati.tickRate;
2185
2458
  this.broadcast({
2186
2459
  t: "snapshot",
2460
+ tickRate: dati.tickRate,
2187
2461
  tick: dati.tick,
2188
2462
  serverTime: this.adattatore.ora(),
2189
2463
  state: stato.valore
@@ -2194,7 +2468,7 @@ var NucleoStanza = class _NucleoStanza {
2194
2468
  try {
2195
2469
  await callback(...args);
2196
2470
  } catch {
2197
- this.fineRichiesta = { error: "callback_error" };
2471
+ this.fineRichiesta = { result: { error: "callback_error" }, rematch: false };
2198
2472
  }
2199
2473
  }
2200
2474
  async applicaAzioni() {
@@ -2213,7 +2487,7 @@ var NucleoStanza = class _NucleoStanza {
2213
2487
  if (this.fineRichiesta !== null && this.dati.status !== "ended") {
2214
2488
  const result = this.fineRichiesta;
2215
2489
  this.fineRichiesta = null;
2216
- await this.terminaInterna(result);
2490
+ await this.terminaInterna(result.result, result.rematch);
2217
2491
  }
2218
2492
  } finally {
2219
2493
  this.applicandoAzioni = false;
@@ -2312,33 +2586,42 @@ var NucleoStanza = class _NucleoStanza {
2312
2586
  dati.state = dati.statoSincronizzato;
2313
2587
  await this.terminaInterna({ error: code });
2314
2588
  }
2315
- async terminaInterna(result) {
2589
+ async terminaInterna(result, rematch = false) {
2316
2590
  const dati = this.richiediDati();
2317
2591
  if (dati.status === "ended") return;
2318
- dati.status = "ended";
2592
+ const giaFinita = dati.status === "finished";
2593
+ dati.status = rematch ? "finished" : "ended";
2594
+ dati.rivincitaFinoA = rematch ? this.adattatore.ora() + ATTESA_RIVINCITA_MS : null;
2595
+ dati.timer = [];
2596
+ if (rematch) for (const player of dati.giocatori) player.ready = false;
2319
2597
  dati.countdownAt = null;
2320
2598
  dati.result = result;
2321
2599
  dati.resultAt = this.adattatore.ora();
2322
2600
  try {
2323
- await this.definizione.onEnd?.(this.room);
2601
+ if (!giaFinita) await this.definizione.onEnd?.(this.room);
2324
2602
  } catch {
2325
2603
  if (record(result)?.error === void 0) dati.result = { error: "callback_error" };
2604
+ dati.status = "ended";
2605
+ dati.rivincitaFinoA = null;
2326
2606
  }
2327
2607
  const stato = analizzaJson(dati.state);
2328
2608
  if (!stato.ok || stato.bytes > LIMITE_STATO) {
2329
2609
  dati.state = dati.statoSincronizzato;
2330
2610
  dati.result = { error: stato.ok ? "state_too_large" : "state_invalid" };
2611
+ dati.status = "ended";
2612
+ dati.rivincitaFinoA = null;
2331
2613
  } else if (stato.testo !== JSON.stringify(dati.statoSincronizzato)) {
2332
2614
  dati.tick++;
2333
2615
  dati.state = stato.valore;
2334
2616
  this.inviaDiff();
2335
2617
  }
2336
2618
  const fine = { result: dati.result, at: dati.resultAt };
2337
- dati.fineInCoda = fine;
2619
+ if (dati.status === "ended") dati.fineInCoda = fine;
2620
+ if (dati.status === "finished") this.inviaGiocatori();
2338
2621
  this.inviaStatus(dati.resultAt);
2339
2622
  this.broadcast({ t: "flush" });
2340
2623
  for (const player of dati.giocatori) {
2341
- if (player.connected && player.connessione !== null) {
2624
+ if (dati.status === "ended" && player.connected && player.connessione !== null) {
2342
2625
  this.adattatore.chiudi(player.connessione, 4004, "room_ended");
2343
2626
  player.connected = false;
2344
2627
  player.connessione = null;
@@ -2353,7 +2636,7 @@ var NucleoStanza = class _NucleoStanza {
2353
2636
  if (dati.durateCpu.length > 50) dati.durateCpu.shift();
2354
2637
  dati.cpuOltreCento = durata > 100 ? dati.cpuOltreCento + 1 : 0;
2355
2638
  if (dati.cpuOltreCento >= 20) {
2356
- this.fineRichiesta = { error: "cpu_budget" };
2639
+ this.fineRichiesta = { result: { error: "cpu_budget" }, rematch: false };
2357
2640
  return;
2358
2641
  }
2359
2642
  if (dati.durateCpu.length === 50) {
@@ -2384,6 +2667,11 @@ var NucleoStanza = class _NucleoStanza {
2384
2667
  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;
2385
2668
  }
2386
2669
  async terminaSeInattiva() {
2670
+ if (this.dati?.status === "finished") {
2671
+ if (this.adattatore.ora() < (this.dati.rivincitaFinoA ?? 0)) return false;
2672
+ await this.terminaInterna(this.dati.result);
2673
+ return true;
2674
+ }
2387
2675
  if (this.manifest.persistent === true) {
2388
2676
  if (this.dati?.status === "ended" || this.dati === null || this.adattatore.ora() < Math.max(this.dati.ultimoInputAt, this.dati.ultimoCambioStatoAt) + SCADENZA_PERSISTENTE_MS) return false;
2389
2677
  await this.terminaInterna({ error: "expired" });
@@ -2403,6 +2691,7 @@ var NucleoStanza = class _NucleoStanza {
2403
2691
  this.serveTick() ? 1e3 / this.dati.tickRate : null
2404
2692
  );
2405
2693
  const prossime = [];
2694
+ if (this.dati.status === "finished") prossime.push(this.dati.rivincitaFinoA ?? this.adattatore.ora());
2406
2695
  if (this.manifest.persistent === true) {
2407
2696
  prossime.push(
2408
2697
  Math.max(this.dati.ultimoInputAt, this.dati.ultimoCambioStatoAt) + SCADENZA_PERSISTENTE_MS
@@ -2684,10 +2973,11 @@ var ArchivioNode = class _ArchivioNode {
2684
2973
  }
2685
2974
  };
2686
2975
  var AdattatoreNode = class {
2687
- constructor(storage, deposito, ritardoSpettatori) {
2976
+ constructor(storage, deposito, ritardoSpettatori, dailyDay) {
2688
2977
  this.storage = storage;
2689
2978
  this.deposito = deposito;
2690
2979
  this.ritardoSpettatori = ritardoSpettatori;
2980
+ this.dailyDay = dailyDay;
2691
2981
  this.connessioni = /* @__PURE__ */ new Map();
2692
2982
  this.spettatori = /* @__PURE__ */ new Map();
2693
2983
  this.timerSpettatori = /* @__PURE__ */ new Set();
@@ -2778,6 +3068,10 @@ var AdattatoreNode = class {
2778
3068
  ora() {
2779
3069
  return Date.now();
2780
3070
  }
3071
+ // La giornata di prova non deve spostare scadenze, timer o misure della connessione.
3072
+ giorno(ora) {
3073
+ return this.dailyDay ?? giornoUtc(ora);
3074
+ }
2781
3075
  misuraCpu() {
2782
3076
  return performance.now();
2783
3077
  }
@@ -2846,7 +3140,7 @@ var StanzaNode = class {
2846
3140
  this.voceListeners = /* @__PURE__ */ new Map();
2847
3141
  this.voceFrequenza = /* @__PURE__ */ new Map();
2848
3142
  this.voceUltimaRichiesta = /* @__PURE__ */ new Map();
2849
- this.frameFrequenza = /* @__PURE__ */ new Map();
3143
+ this.frameFrequenza = new LimiteMessaggiStanza();
2850
3144
  this.connessioniGiocatori = /* @__PURE__ */ new Map();
2851
3145
  this.giocatoriConnessioni = /* @__PURE__ */ new Map();
2852
3146
  adattatore.collega({
@@ -2947,15 +3241,6 @@ var StanzaNode = class {
2947
3241
  const player = this.nucleo.giocatoreConnesso(connessione);
2948
3242
  if (player === null) return;
2949
3243
  const ora = Date.now();
2950
- const frames = (this.frameFrequenza.get(connessione) ?? []).filter((at) => ora - at < 1e3);
2951
- if (frames.length >= 20) {
2952
- this.adattatore.chiudi(connessione, 4008, "rate_limited");
2953
- this.rimuoviConnessione(connessione);
2954
- await this.nucleo.disconnetti(connessione);
2955
- return;
2956
- }
2957
- frames.push(ora);
2958
- this.frameFrequenza.set(connessione, frames);
2959
3244
  let value;
2960
3245
  try {
2961
3246
  value = JSON.parse(frame);
@@ -2964,17 +3249,31 @@ var StanzaNode = class {
2964
3249
  return;
2965
3250
  }
2966
3251
  const message = typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
2967
- if (message?.t !== "voice") {
2968
- await this.nucleo.ricevi(connessione, frame);
2969
- this.riconciliaRoster();
2970
- return;
2971
- }
2972
- if (Buffer.byteLength(frame, "utf8") > 64 * 1024) {
2973
- this.adattatore.chiudi(connessione, 4009, "bad_message");
3252
+ if (Buffer.byteLength(frame, "utf8") > (message?.t === "voice" ? 64 : 16) * 1024) {
3253
+ this.adattatore.chiudi(connessione, 4009, "message_too_large");
2974
3254
  this.rimuoviConnessione(connessione);
2975
3255
  await this.nucleo.disconnetti(connessione);
2976
3256
  return;
2977
3257
  }
3258
+ const limite = this.frameFrequenza.controlla(connessione, message?.t === "msg", ora);
3259
+ if (!limite.accetta) {
3260
+ if (limite.avvisa) this.adattatore.invia(connessione, {
3261
+ t: "error",
3262
+ code: "rate_limited",
3263
+ message: "Too many room messages. Excess messages are dropped."
3264
+ });
3265
+ if (limite.chiudi) {
3266
+ this.adattatore.chiudi(connessione, 4008, "rate_limited");
3267
+ this.rimuoviConnessione(connessione);
3268
+ await this.nucleo.disconnetti(connessione);
3269
+ }
3270
+ return;
3271
+ }
3272
+ if (message?.t !== "voice") {
3273
+ await this.nucleo.ricevi(connessione, frame, true);
3274
+ this.riconciliaRoster();
3275
+ return;
3276
+ }
2978
3277
  await this.riceviVoce(connessione, player.id, player.role, message);
2979
3278
  }
2980
3279
  permessoSpettatore() {
@@ -2998,19 +3297,24 @@ var StanzaNode = class {
2998
3297
  }
2999
3298
  async riceviSpettatore(connessione, frame) {
3000
3299
  if (Buffer.byteLength(frame, "utf8") > 16 * 1024) {
3001
- this.adattatore.chiudiSpettatore(connessione, 4008, "rate_limited");
3300
+ this.adattatore.chiudiSpettatore(connessione, 4009, "message_too_large");
3002
3301
  this.frameFrequenza.delete(connessione);
3003
3302
  return;
3004
3303
  }
3005
3304
  const ora = Date.now();
3006
- const frames = (this.frameFrequenza.get(connessione) ?? []).filter((at) => ora - at < 1e3);
3007
- if (frames.length >= 20) {
3008
- this.adattatore.chiudiSpettatore(connessione, 4008, "rate_limited");
3009
- this.frameFrequenza.delete(connessione);
3305
+ const limite = this.frameFrequenza.controlla(connessione, false, ora);
3306
+ if (!limite.accetta) {
3307
+ if (limite.avvisa) this.adattatore.inviaSpettatoreSubito(connessione, JSON.stringify({
3308
+ t: "error",
3309
+ code: "rate_limited",
3310
+ message: "Too many room messages. Excess messages are dropped."
3311
+ }));
3312
+ if (limite.chiudi) {
3313
+ this.adattatore.chiudiSpettatore(connessione, 4008, "rate_limited");
3314
+ this.frameFrequenza.delete(connessione);
3315
+ }
3010
3316
  return;
3011
3317
  }
3012
- frames.push(ora);
3013
- this.frameFrequenza.set(connessione, frames);
3014
3318
  let message = null;
3015
3319
  try {
3016
3320
  const value = JSON.parse(frame);
@@ -3239,9 +3543,10 @@ var StanzaNode = class {
3239
3543
  }
3240
3544
  };
3241
3545
  async function createNodeRoom(definition, manifest, options = {}) {
3546
+ if (options.dailyDay !== void 0 && !validBoardDay2(options.dailyDay)) throw new TypeError("dailyDay must be a real UTC date in YYYY-MM-DD format.");
3242
3547
  const storage = await ArchivioNode.apri(options.storageFile ?? null);
3243
3548
  const ritardoSpettatori = manifest.spectators === null ? null : manifest.spectators?.delayMs ?? RITARDO_SPETTATORI_MS2;
3244
- const adattatore = new AdattatoreNode(storage, options.deposito ?? null, ritardoSpettatori);
3549
+ const adattatore = new AdattatoreNode(storage, options.deposito ?? null, ritardoSpettatori, options.dailyDay);
3245
3550
  const nucleo = await NucleoStanza.apri(definition, manifest, adattatore);
3246
3551
  return new StanzaNode(nucleo, adattatore, manifest);
3247
3552
  }
@@ -3281,8 +3586,11 @@ var NOMI_RISERVATI3 = [
3281
3586
  ];
3282
3587
  var RISERVATI3 = new Set(NOMI_RISERVATI3);
3283
3588
  var words = {
3589
+ gameLanguages: ["Game languages", "Lingue del gioco", "Idiomas del juego", "Langues du jeu", "Spielsprachen", "Idiomas do jogo"],
3284
3590
  loading: ["Loading game...", "Caricamento...", "Cargando...", "Chargement...", "Spiel wird geladen...", "Carregando..."],
3591
+ loadingSlow: ["This game is taking longer than expected. You can wait a little longer or try again.", "Il gioco ci sta mettendo pi\xF9 del previsto. Puoi aspettare ancora un po\u2019 o riprovare.", "El juego est\xE1 tardando m\xE1s de lo esperado. Puedes esperar un poco m\xE1s o volver a intentarlo.", "Le jeu met plus de temps que pr\xE9vu. Vous pouvez patienter encore un peu ou r\xE9essayer.", "Das Spiel braucht l\xE4nger als erwartet. Du kannst noch etwas warten oder es erneut versuchen.", "O jogo est\xE1 demorando mais do que o esperado. Voc\xEA pode esperar mais um pouco ou tentar novamente."],
3285
3592
  home: ["Play", "Gioca", "Jugar", "Jouer", "Spielen", "Jogar"],
3593
+ homeMenu: ["Menu", "Menu", "Men\xFA", "Menu", "Men\xFC", "Menu"],
3286
3594
  mode: ["Mode", "Modalit\xE0", "Modo", "Mode", "Modus", "Modo"],
3287
3595
  play: ["Play", "Gioca", "Jugar", "Jouer", "Spielen", "Jogar"],
3288
3596
  friendsPlay: ["Play with friends", "Gioca con amici", "Jugar con amigos", "Jouer entre amis", "Mit Freunden spielen", "Jogar com amigos"],
@@ -3318,6 +3626,8 @@ var words = {
3318
3626
  starting: ["Starting in", "Si inizia tra", "Empieza en", "D\xE9but dans", "Start in", "Come\xE7a em"],
3319
3627
  playing: ["Playing", "In partita", "Jugando", "En jeu", "Im Spiel", "Jogando"],
3320
3628
  ended: ["Game finished", "Partita conclusa", "Partida terminada", "Partie termin\xE9e", "Spiel beendet", "Partida encerrada"],
3629
+ rematchReady: ["{n}/{max} ready", "{n}/{max} pronti", "{n}/{max} listos", "{n}/{max} pr\xEAts", "{n}/{max} bereit", "{n}/{max} prontos"],
3630
+ rematchStart: ["Start rematch", "Avvia rivincita", "Iniciar revancha", "Lancer la revanche", "Revanche starten", "Iniciar revanche"],
3321
3631
  again: ["Play again", "Gioca ancora", "Jugar de nuevo", "Rejouer", "Erneut spielen", "Jogar novamente"],
3322
3632
  newRoom: ["New room. Share the new invite.", "Nuova stanza. Condividi il nuovo invito.", "Nueva sala. Comparte la invitaci\xF3n.", "Nouvelle salle. Partagez le lien.", "Neuer Raum. Neue Einladung teilen.", "Nova sala. Compartilhe o convite."],
3323
3633
  watching: ["Watching", "In osservazione", "Observando", "Spectateur", "Zuschauen", "Assistindo"],
@@ -3396,11 +3706,19 @@ var words = {
3396
3706
  var column = (index) => Object.fromEntries(Object.entries(words).map(([key, row]) => [key, row[index]]));
3397
3707
  var dictionaries = { en: column(0), it: column(1), es: column(2), fr: column(3), de: column(4), pt: column(5) };
3398
3708
  var styles = `
3709
+ .safe-area-probe{position:fixed;visibility:hidden;pointer-events:none;padding:env(safe-area-inset-top,0px) env(safe-area-inset-right,0px) env(safe-area-inset-bottom,0px) env(safe-area-inset-left,0px)}
3399
3710
  :host{all:initial;position:fixed;inset:0;z-index:10000;pointer-events:none;font:15px/1.45 system-ui,sans-serif;color:#f4f4f1;color-scheme:dark;--accent:#a8efc5}
3400
- [data-layout],[data-surface],.sr{pointer-events:none}*{box-sizing:border-box}button,input,select{font:inherit}button,a,input,select{touch-action:manipulation}button,select,input{border:1px solid #ffffff30;background:#25292b;color:inherit;border-radius:12px;min-height:44px;padding:10px 14px}button{cursor:pointer}button:disabled{opacity:.45;cursor:default}button:hover:not(:disabled){background:#343b3a}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid var(--accent);outline-offset:3px}a{color:var(--accent)}.primary{background:var(--accent);color:var(--accent-ink,#11221b);border-color:transparent;font-weight:700}.primary:hover:not(:disabled){filter:brightness(1.1);background:var(--accent)}.quiet{background:transparent}label{display:grid;gap:6px;text-align:left}select,input{width:100%;min-width:0}h1,h2,p{margin:0}h1{font-size:clamp(26px,5vw,42px);line-height:1.1;letter-spacing:-.035em}h2{font-size:20px}small,.muted{color:#bdc5c1}.stack{display:grid;gap:16px}.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.row>*{flex:0 1 auto}.row .grow,.grow{flex:1}.split{display:grid;grid-template-columns:1fr 1fr;gap:10px}.pill{position:absolute;top:max(10px,env(safe-area-inset-top));right:max(10px,env(safe-area-inset-right));display:flex;height:44px;border:1px solid #ffffff35;border-radius:24px;background:#171e20eF;box-shadow:0 4px 20px #0004;pointer-events:auto;overflow:hidden}.pill button{border:0;border-radius:0;padding:8px 13px;background:transparent}.pill .exit{width:44px;border-left:1px solid #ffffff30}.pill button:focus-visible{outline-offset:-4px}.pill small{margin-left:8px}.backdrop{position:absolute;inset:0;background:#0b151ce8;backdrop-filter:blur(10px);pointer-events:auto;display:grid;place-items:center;padding:max(16px,env(safe-area-inset-top)) max(16px,env(safe-area-inset-right)) max(16px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left));overflow:auto}.backdrop.home{background-color:#142127;background-size:cover;background-position:center}.dialog{position:relative;width:min(100%,540px);max-height:100%;overflow:auto;overscroll-behavior:contain;background:#141b1df5;border:1px solid #ffffff25;border-radius:22px;padding:24px;box-shadow:0 20px 80px #0005}.dialog.wide{width:min(100%,700px)}.top{display:flex;align-items:center;gap:12px;margin-bottom:18px}.top h2{flex:1}.tabs{display:flex;gap:6px;flex-wrap:wrap;border-bottom:1px solid #ffffff25;padding-bottom:12px}.tabs button{min-height:36px;padding:6px 10px}.tabs [aria-current=true]{border-color:var(--accent)}.roster{list-style:none;padding:0;margin:0;display:grid;gap:8px;max-height:32dvh;overflow:auto}.roster li{display:flex;align-items:center;gap:8px;padding:10px;background:#ffffff08;border-radius:10px}.roster .name{flex:1;overflow-wrap:anywhere}.badge{border:1px solid #ffffff30;border-radius:6px;padding:2px 6px;font-size:12px}.code{font-size:24px;letter-spacing:.13em;font-variant-numeric:tabular-nums}.notice,.error{border-radius:10px;padding:10px;background:#a8efc514;overflow-wrap:anywhere}.error{background:#ff8b7720;color:#ffd2c9}.countdown{font-size:88px;line-height:1;text-align:center;font-variant-numeric:tabular-nums}.ended{position:absolute;bottom:max(12px,env(safe-area-inset-bottom));left:50%;transform:translateX(-50%);max-width:calc(100% - 24px);width:max-content;background:#171e20f5;pointer-events:auto;border:1px solid #ffffff30;border-radius:16px;padding:10px;display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap}.ended strong{padding:0 8px}.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.table-wrap{overflow:auto;max-height:38dvh}table{width:100%;border-collapse:collapse;text-align:left}th,td{padding:9px 6px;border-bottom:1px solid #ffffff20}td:nth-child(3){text-align:right}td small{display:block}.self{background:#ffffff0a}.checkbox{display:flex;align-items:center;gap:8px;font-size:13px}.checkbox input{width:18px;min-height:18px}.full{width:100%}
3711
+ [data-layout],[data-surface],.sr{pointer-events:none}*{box-sizing:border-box}button,input,select{font:inherit}button,a,input,select{touch-action:manipulation}button,select,input{border:1px solid #ffffff30;background:#25292b;color:inherit;border-radius:12px;min-height:44px;padding:10px 14px}button{cursor:pointer}button:disabled{opacity:.45;cursor:default}button:hover:not(:disabled){background:#343b3a}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid var(--accent);outline-offset:3px}a{color:var(--accent)}.primary{background:var(--accent);color:var(--accent-ink,#11221b);border-color:transparent;font-weight:700}.primary:hover:not(:disabled){filter:brightness(1.1);background:var(--accent)}.quiet{background:transparent}label{display:grid;gap:6px;text-align:left}select,input{width:100%;min-width:0}h1,h2,p{margin:0}h1{font-size:clamp(26px,5vw,42px);line-height:1.1;letter-spacing:-.035em}h2{font-size:20px}small,.muted{color:#bdc5c1}.stack{display:grid;gap:16px}.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.row>*{flex:0 1 auto}.row .grow,.grow{flex:1}.split{display:grid;grid-template-columns:1fr 1fr;gap:10px}.pill{position:absolute;top:max(10px,env(safe-area-inset-top));right:max(10px,env(safe-area-inset-right));display:flex;height:44px;border:1px solid #ffffff35;border-radius:24px;background:#171e20eF;box-shadow:0 4px 20px #0004;pointer-events:auto;overflow:hidden}.pill button{border:0;border-radius:0;padding:8px 13px;background:transparent}.pill button:focus-visible{outline-offset:-4px}.pill small{margin-left:8px}.backdrop{position:absolute;inset:0;background:#0b151ce8;backdrop-filter:blur(10px);pointer-events:auto;display:grid;place-items:center;padding:max(16px,env(safe-area-inset-top)) max(16px,env(safe-area-inset-right)) max(16px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left));overflow:auto}.backdrop.home{background-color:#142127;background-size:cover;background-position:center}.dialog{position:relative;width:min(100%,540px);max-height:100%;overflow:auto;overscroll-behavior:contain;background:#141b1df5;border:1px solid #ffffff25;border-radius:22px;padding:24px;box-shadow:0 20px 80px #0005}.dialog.wide{width:min(100%,700px)}.top{display:flex;align-items:center;gap:12px;margin-bottom:18px}.top h2{flex:1}.tabs{display:flex;gap:6px;flex-wrap:wrap;border-bottom:1px solid #ffffff25;padding-bottom:12px}.tabs button{min-height:36px;padding:6px 10px}.tabs [aria-current=true]{border-color:var(--accent)}.roster{list-style:none;padding:0;margin:0;display:grid;gap:8px;max-height:32dvh;overflow:auto}.roster li{display:flex;align-items:center;gap:8px;padding:10px;background:#ffffff08;border-radius:10px}.roster .name{flex:1;overflow-wrap:anywhere}.badge{border:1px solid #ffffff30;border-radius:6px;padding:2px 6px;font-size:12px}.code{font-size:24px;letter-spacing:.13em;font-variant-numeric:tabular-nums}.notice,.error{border-radius:10px;padding:10px;background:#a8efc514;overflow-wrap:anywhere}.error{background:#ff8b7720;color:#ffd2c9}.countdown{font-size:88px;line-height:1;text-align:center;font-variant-numeric:tabular-nums}.ended{position:absolute;bottom:max(12px,env(safe-area-inset-bottom));left:50%;transform:translateX(-50%);max-width:calc(100% - 24px);width:max-content;background:#171e20f5;pointer-events:auto;border:1px solid #ffffff30;border-radius:16px;padding:10px;display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap}.ended [data-rematch-players]{max-width:100%;max-height:3.2em;overflow:auto;overflow-wrap:anywhere}.ended strong{padding:0 8px}.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.table-wrap{overflow:auto;max-height:38dvh}table{width:100%;border-collapse:collapse;text-align:left}th,td{padding:9px 6px;border-bottom:1px solid #ffffff20}td:nth-child(3){text-align:right}td small{display:block}.self{background:#ffffff0a}.checkbox{display:flex;align-items:center;gap:8px;font-size:13px}.checkbox input{width:18px;min-height:18px}.full{width:100%}
3401
3712
  [hidden]{display:none!important}.voice-peers{list-style:none;margin:0;padding:0;display:grid;gap:10px}.voice-peers li{border:1px solid #ffffff25;border-radius:12px;padding:12px;display:grid;gap:8px}.voice-peers [data-speaking=true]{border-color:var(--accent)}.voice-peers input{width:100%;accent-color:var(--accent);padding:0}.voice-peers label{font-size:13px}.pill .voice-toggle{width:44px;padding:8px}.voice-toggle[data-voice-state=on][data-muted=false]{color:var(--accent)}
3713
+ .boot{position:absolute;inset:0;z-index:2;isolation:isolate;display:grid;place-items:center;overflow:auto;overscroll-behavior:contain;padding:max(100px,env(safe-area-inset-top)) max(24px,env(safe-area-inset-right)) max(48px,env(safe-area-inset-bottom)) max(24px,env(safe-area-inset-left));background:#0b151c;opacity:1;transition:opacity .4s ease;pointer-events:auto;outline:none}
3714
+ .boot::before,.boot::after{content:"";position:fixed;inset:0;pointer-events:none;z-index:-1}.boot::before{background:radial-gradient(ellipse at 50% 38%,color-mix(in srgb,var(--accent),transparent 80%),transparent 65%)}.boot::after{background:radial-gradient(ellipse at 50% 38%,#0b151c20,#0b151cd9 85%),linear-gradient(#0b151c66,#0b151cbf)}
3715
+ .boot-cover{position:fixed;inset:0;z-index:-2;width:100%;height:100%;object-fit:cover;filter:blur(20px);transform:scale(1.08);opacity:.65;pointer-events:none}
3716
+ .boot-brand{position:absolute;top:max(28px,env(safe-area-inset-top));left:max(32px,env(safe-area-inset-left));display:flex;align-items:center;gap:10px;font-size:14px;font-weight:650;letter-spacing:.02em;color:#f4f4f1b3}.boot-brand span{display:grid;place-items:center;width:36px;height:36px;border:1px solid #ffffff25;border-radius:12px;background:#171e20af;box-shadow:0 4px 20px #0004;color:var(--boot-accent);font-size:20px;font-weight:800}
3717
+ .boot-content{width:min(100%,900px);text-align:center;display:grid;justify-items:center;gap:24px}.boot h1{max-width:16ch;font-size:clamp(44px,8vw,108px);font-weight:800;line-height:1.04;letter-spacing:-.05em;overflow-wrap:anywhere;text-wrap:balance;color:var(--boot-accent);text-shadow:0 20px 80px #0005}
3718
+ .boot-progress{width:112px;height:3px;border-radius:12px;background:#ffffff20;overflow:hidden;margin-top:12px}.boot-progress span{display:block;width:44%;height:100%;border-radius:inherit;background:var(--boot-accent);animation:boot-progress 1.8s ease-in-out infinite}.boot-status{max-width:42ch;min-height:3em;font-size:14px;line-height:1.5;color:#d3dad6;text-wrap:balance}.boot-recovery{min-height:44px}.boot-recovery .row{justify-content:center}.boot-leaving{opacity:0;pointer-events:none}
3719
+ @keyframes boot-progress{0%{transform:translateX(-110%)}100%{transform:translateX(340%)}}
3402
3720
  @media(max-width:480px){.dialog{padding:18px;border-radius:18px}.split{grid-template-columns:1fr 1fr;gap:8px}.tabs{gap:4px}.tabs button{font-size:13px;padding:6px 8px}.pill button:focus-visible{outline-offset:-4px}.pill small{display:none}.ended{gap:6px}.ended strong{font-size:13px}.ended button{padding:8px 10px;font-size:13px}.roster{max-height:28dvh}}
3403
- @media(prefers-reduced-motion:reduce){*{scroll-behavior:auto}}
3721
+ @media(prefers-reduced-motion:reduce){*{scroll-behavior:auto}.boot{transition:none}.boot-progress span{animation:none;transform:translateX(65%)}}
3404
3722
  `;
3405
3723
 
3406
3724
  // src/dev.ts
@@ -3509,7 +3827,7 @@ function object(value) {
3509
3827
  async function leggiJsonFacoltativo(path) {
3510
3828
  let testo;
3511
3829
  try {
3512
- testo = await fs2.readFile(path, "utf8");
3830
+ testo = await fs3.readFile(path, "utf8");
3513
3831
  } catch (cause) {
3514
3832
  if (cause.code === "ENOENT") return null;
3515
3833
  throw cause;
@@ -3517,16 +3835,16 @@ async function leggiJsonFacoltativo(path) {
3517
3835
  return JSON.parse(testo);
3518
3836
  }
3519
3837
  async function scriviFileAtomico(path, contenuto, mode) {
3520
- await fs2.mkdir(dirname2(path), { recursive: true });
3521
- const temporaneo = join2(
3838
+ await fs3.mkdir(dirname2(path), { recursive: true });
3839
+ const temporaneo = join3(
3522
3840
  dirname2(path),
3523
3841
  `.${basename(path)}.${process.pid}.${randomUUID2()}.tmp`
3524
3842
  );
3525
3843
  try {
3526
- await fs2.writeFile(temporaneo, contenuto, { encoding: "utf8", mode });
3527
- await fs2.rename(temporaneo, path);
3844
+ await fs3.writeFile(temporaneo, contenuto, { encoding: "utf8", mode });
3845
+ await fs3.rename(temporaneo, path);
3528
3846
  } catch (cause) {
3529
- await fs2.rm(temporaneo, { force: true }).catch(() => void 0);
3847
+ await fs3.rm(temporaneo, { force: true }).catch(() => void 0);
3530
3848
  throw cause;
3531
3849
  }
3532
3850
  }
@@ -3794,7 +4112,7 @@ function parentPage(input) {
3794
4112
  <body>
3795
4113
  <iframe id="game" title="${input.slug}" data-src="${input.gameOrigin}/" allow="${input.allow}"></iframe>
3796
4114
  <script type="module">
3797
- import { creaPonteOspite, overlayConfiguration, mountOverlay } from '/__caisual/overlay/v1.js';
4115
+ import { creaPonteOspite, overlayConfiguration, mountOverlay, overlayLocale } from '/__caisual/overlay/v1.js';
3798
4116
  const manifest = ${JSON.stringify(input.manifest).replaceAll("<", "\\u003c")};
3799
4117
  const gameOrigin = ${JSON.stringify(input.gameOrigin)};
3800
4118
  const portalOrigin = ${JSON.stringify(input.portalOrigin)};
@@ -3817,18 +4135,22 @@ function parentPage(input) {
3817
4135
  ? normalizedInvite
3818
4136
  : null;
3819
4137
  const configuration = overlayConfiguration(manifest, manifest.cover ? gameOrigin + '/' + manifest.cover : null, invite);
4138
+ const choice = new URL(location.href).searchParams.get('lang');
4139
+ const languagePreferences = choice ? [choice] : (navigator.languages.length ? navigator.languages : [navigator.language]);
4140
+ const language = overlayLocale(languagePreferences[0]);
4141
+ document.documentElement.lang = language;
3820
4142
  const bridge = creaPonteOspite({
3821
4143
  finestra: window, frame, origineGioco: gameOrigin, origineLive: portalOrigin,
3822
- invite, ticket: session.portal,
4144
+ invite, ticket: session.portal, language, languagePreferences,
3823
4145
  configuration,
3824
4146
  rinnova: async (aud) => { session = await getSession(); return session[aud]; },
3825
4147
  onRoom() {},
3826
4148
  });
3827
4149
  const overlay = mountOverlay({
3828
4150
  container: document.body, frame, bridge, configuration, player: session.player,
3829
- language: new URL(location.href).searchParams.get('lang') || navigator.language,
4151
+ language,
3830
4152
  exit: () => { location.href = '/?lang=' + encodeURIComponent(new URL(location.href).searchParams.get('lang') || navigator.language); },
3831
- inviteUrl: (code) => portalOrigin + '/?invite=' + code,
4153
+ inviteUrl: (code) => portalOrigin + '/?invite=' + code + '&lang=' + encodeURIComponent(languagePreferences[0]),
3832
4154
  boards: async (query) => {
3833
4155
  session = await getSession();
3834
4156
  const params = new URLSearchParams({ limit: '25' });
@@ -3852,10 +4174,10 @@ function parentPage(input) {
3852
4174
  `;
3853
4175
  }
3854
4176
  async function readGame(root) {
3855
- const manifestPath = join2(root, "caisual.json");
4177
+ const manifestPath = join3(root, "caisual.json");
3856
4178
  let parsed;
3857
4179
  try {
3858
- parsed = JSON.parse(await fs2.readFile(manifestPath, "utf8"));
4180
+ parsed = JSON.parse(await fs3.readFile(manifestPath, "utf8"));
3859
4181
  } catch {
3860
4182
  throw new Error("caisual.json: file not found, unreadable, or invalid JSON.");
3861
4183
  }
@@ -3864,26 +4186,28 @@ async function readGame(root) {
3864
4186
  throw new Error(`caisual.json is not valid:
3865
4187
  ${result.errori.map((error) => `- ${error}`).join("\n")}`);
3866
4188
  }
3867
- const clientRoot = await fs2.realpath(join2(root, "client")).catch(() => null);
4189
+ const clientRoot = await fs3.realpath(join3(root, "client")).catch(() => null);
3868
4190
  if (clientRoot === null) throw new Error("client/: folder not found.");
3869
- const stat = await fs2.stat(clientRoot);
4191
+ const stat = await fs3.stat(clientRoot);
3870
4192
  if (!stat.isDirectory()) throw new Error("client/: must be a folder.");
3871
- const index = await fs2.stat(join2(clientRoot, "index.html")).catch(() => null);
4193
+ const index = await fs3.stat(join3(clientRoot, "index.html")).catch(() => null);
3872
4194
  if (index === null || !index.isFile()) throw new Error("client/index.html: file not found.");
4195
+ warnLegacyLanguage(parsed);
4196
+ await checkGameTexts(clientRoot, result.manifest);
3873
4197
  return { manifest: result.manifest, clientRoot };
3874
4198
  }
3875
4199
  async function loadDefinition(root) {
3876
- const path = join2(root, "server.js");
4200
+ const path = join3(root, "server.js");
3877
4201
  let stat;
3878
4202
  try {
3879
- stat = await fs2.lstat(path);
4203
+ stat = await fs3.lstat(path);
3880
4204
  } catch (cause) {
3881
4205
  if (cause.code === "ENOENT") return null;
3882
4206
  throw new Error("server.js: file not readable.");
3883
4207
  }
3884
4208
  if (!stat.isFile()) throw new Error("server.js: file not readable.");
3885
4209
  const { source } = await bundleServer(root);
3886
- const kitUrl = `data:text/javascript;base64,${Buffer.from('// src/server/index.ts\nvar GAME_DEFINITION = /* @__PURE__ */ Symbol.for("@caisual/kit/game-definition");\nvar CALLBACKS = [\n "onCreate",\n "onStart",\n "onJoin",\n "onLeave",\n "onMessage",\n "onRoleRequest",\n "onTick",\n "onEnd"\n];\nfunction isRecord(value) {\n return typeof value === "object" && value !== null && !Array.isArray(value);\n}\nfunction defineGame(definition) {\n if (!isRecord(definition)) {\n throw new TypeError("Game definition must be an object.");\n }\n if (typeof definition.tickRate !== "number" || !Number.isInteger(definition.tickRate) || definition.tickRate < 0 || definition.tickRate > 60) {\n throw new TypeError("Game definition tickRate must be an integer from 0 to 60.");\n }\n for (const callback of CALLBACKS) {\n const value = definition[callback];\n if (value !== void 0 && typeof value !== "function") {\n throw new TypeError(`Game definition ${callback} must be a function.`);\n }\n }\n Object.defineProperty(definition, GAME_DEFINITION, {\n configurable: false,\n enumerable: false,\n value: true,\n writable: false\n });\n return definition;\n}\nexport {\n defineGame\n};\n').toString("base64")}`;
4210
+ 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 "onRestart",\n "onJoin",\n "onConnection",\n "onLeave",\n "onMessage",\n "onRoleRequest",\n "onTick",\n "onEnd"\n];\nfunction isRecord(value) {\n return typeof value === "object" && value !== null && !Array.isArray(value);\n}\nfunction defineGame(definition) {\n if (!isRecord(definition)) {\n throw new TypeError("Game definition must be an object.");\n }\n if (typeof definition.tickRate !== "number" || !Number.isInteger(definition.tickRate) || definition.tickRate < 0 || definition.tickRate > 60) {\n throw new TypeError("Game definition tickRate must be an integer from 0 to 60.");\n }\n for (const callback of CALLBACKS) {\n const value = definition[callback];\n if (value !== void 0 && typeof value !== "function") {\n throw new TypeError(`Game definition ${callback} must be a function.`);\n }\n }\n Object.defineProperty(definition, GAME_DEFINITION, {\n configurable: false,\n enumerable: false,\n value: true,\n writable: false\n });\n return definition;\n}\nexport {\n defineGame\n};\n').toString("base64")}`;
3887
4211
  const rewritten = source.replace(
3888
4212
  /(\bfrom\s*)(['"])@caisual\/kit\/server\2/g,
3889
4213
  (_match, prefix) => `${prefix}${JSON.stringify(kitUrl)}`
@@ -3894,12 +4218,13 @@ async function loadDefinition(root) {
3894
4218
  return loaded.default;
3895
4219
  }
3896
4220
  var DevService = class {
3897
- constructor(root, clientRoot, manifest, definition, port) {
4221
+ constructor(root, clientRoot, manifest, definition, port, day) {
3898
4222
  this.root = root;
3899
4223
  this.clientRoot = clientRoot;
3900
4224
  this.manifest = manifest;
3901
4225
  this.definition = definition;
3902
4226
  this.port = port;
4227
+ this.day = day;
3903
4228
  this.deposito = new DepositoDev((valori) => this.persistShared(valori));
3904
4229
  }
3905
4230
  root;
@@ -3907,6 +4232,7 @@ var DevService = class {
3907
4232
  manifest;
3908
4233
  definition;
3909
4234
  port;
4235
+ day;
3910
4236
  secret = Buffer.alloc(0);
3911
4237
  playersBySession = /* @__PURE__ */ new Map();
3912
4238
  playersById = /* @__PURE__ */ new Map();
@@ -3922,23 +4248,26 @@ var DevService = class {
3922
4248
  liveRequests = /* @__PURE__ */ new Map();
3923
4249
  matchOperations = Promise.resolve();
3924
4250
  persistenceOperations = Promise.resolve();
3925
- playerNumber = 0;
4251
+ currentDay() {
4252
+ return this.day ?? utcDay();
4253
+ }
3926
4254
  async initialize() {
3927
4255
  await Promise.all([
3928
4256
  this.loadSecret(),
3929
4257
  this.loadRoomIndex(),
3930
4258
  this.loadSaves(),
4259
+ this.loadScores(),
3931
4260
  this.loadShared()
3932
4261
  ]);
3933
4262
  }
3934
4263
  statePath(name) {
3935
- return join2(this.root, ".caisual-dev", name);
4264
+ return join3(this.root, ".caisual-dev", name);
3936
4265
  }
3937
4266
  async loadSecret() {
3938
4267
  const path = this.statePath("secret");
3939
4268
  let encoded;
3940
4269
  try {
3941
- encoded = (await fs2.readFile(path, "utf8")).trim();
4270
+ encoded = (await fs3.readFile(path, "utf8")).trim();
3942
4271
  } catch (cause) {
3943
4272
  if (cause.code !== "ENOENT") throw cause;
3944
4273
  const secret2 = randomBytes(32);
@@ -4004,6 +4333,24 @@ var DevService = class {
4004
4333
  this.saves.set(id, records);
4005
4334
  }
4006
4335
  }
4336
+ async loadScores() {
4337
+ const value = await leggiJsonFacoltativo(this.statePath("scores.json"));
4338
+ if (value === null) return;
4339
+ const file = object(value);
4340
+ if (file?.version !== VERSIONE_STATO_DEV || !Array.isArray(file.scores)) {
4341
+ throw new Error("The local scores are invalid.");
4342
+ }
4343
+ for (const valueScore of file.scores) {
4344
+ const score = object(valueScore);
4345
+ if (score === null || typeof score.playerId !== "string" || typeof score.game !== "string" || typeof score.board !== "string" || !CHIAVE_BOARD.test(score.board) || typeof score.name !== "string" || typeof score.guest !== "boolean" || typeof score.verified !== "boolean" || score.day !== null && !validBoardDay(score.day) || !Number.isSafeInteger(score.score) || score.score < 0 || !Number.isSafeInteger(score.createdAt) || score.createdAt < 0) {
4346
+ throw new Error("The local scores are invalid.");
4347
+ }
4348
+ const record2 = score;
4349
+ const key = this.scoreKey(record2.playerId, record2.game, record2.board, record2.day);
4350
+ if (this.scores.has(key)) throw new Error("The local scores are invalid.");
4351
+ this.scores.set(key, record2);
4352
+ }
4353
+ }
4007
4354
  async loadShared() {
4008
4355
  const value = await leggiJsonFacoltativo(this.statePath("shared.json"));
4009
4356
  if (value === null) return;
@@ -4057,6 +4404,12 @@ var DevService = class {
4057
4404
  });
4058
4405
  });
4059
4406
  }
4407
+ persistScores() {
4408
+ return this.serializePersistence(() => scriviJsonAtomico(this.statePath("scores.json"), {
4409
+ version: VERSIONE_STATO_DEV,
4410
+ scores: [...this.scores.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([, score]) => score)
4411
+ }));
4412
+ }
4060
4413
  persistShared(valori) {
4061
4414
  return this.serializePersistence(() => scriviJsonAtomico(this.statePath("shared.json"), {
4062
4415
  version: VERSIONE_STATO_DEV,
@@ -4389,7 +4742,19 @@ var DevService = class {
4389
4742
  response.setHeader("Content-Type", "text/javascript; charset=utf-8");
4390
4743
  response.setHeader("Cache-Control", "no-store");
4391
4744
  response.setHeader("X-Content-Type-Options", "nosniff");
4392
- response.end(request.method === "HEAD" ? void 0 : '// Caisual game kit v0.8.0\n\n// ../contracts/src/slug.ts\nvar NOMI_RISERVATI = [\n "www",\n "api",\n "app",\n "play",\n "live",\n "multi",\n "cdn",\n "assets",\n "static",\n "mail",\n "mx",\n "ns1",\n "ns2",\n "autodiscover",\n "_dmarc",\n "admin",\n "login",\n "account",\n "auth",\n "pay",\n "secure",\n "support",\n "help",\n "blog",\n "status",\n "dev",\n "staging",\n "test",\n "caisual",\n "shipz"\n];\nvar RISERVATI = new Set(NOMI_RISERVATI);\nvar SLUG_NUOVO = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;\nvar SLUG_STORICO = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;\nfunction isValidSlug(value) {\n return value.length >= 3 && value.length <= 32 && SLUG_NUOVO.test(value) || SLUG_STORICO.test(value);\n}\nfunction isReservedSlug(value) {\n return RISERVATI.has(value);\n}\n\n// ../contracts/src/manifest.ts\nfunction risolviModalita(manifest, mode) {\n const scelta = mode === null ? void 0 : manifest.modes.find((voce) => voce.id === mode);\n if (mode !== null && scelta === void 0) throw new Error("The selected game mode does not exist.");\n return { players: { ...scelta?.players ?? manifest.players }, lobby: scelta?.lobby ?? manifest.lobby };\n}\nfunction modalitaLocale(manifest, mode) {\n return mode !== null && manifest.modes.some((voce) => voce.id === mode && voce.execution === "local");\n}\nvar TETTO_GIOCATORI = 24;\nvar RITARDO_SPETTATORI_MS = 3e3;\nvar MASSIMO_CLASSIFICHE = 32;\nvar CAMPI = /* @__PURE__ */ new Set([\n "overlay",\n "manifest",\n "id",\n "name",\n "description",\n "cover",\n "screenshots",\n "tags",\n "language",\n "platform",\n "orientation",\n "input",\n "visibility",\n "network",\n "isolated",\n "requires",\n "players",\n "lobby",\n "persistent",\n "spectators",\n "boards",\n "roles",\n "teams",\n "voice",\n "modes"\n]);\nvar INPUT = /* @__PURE__ */ new Set(["keyboard", "mouse", "touch", "gamepad"]);\nvar PLATFORM = /* @__PURE__ */ new Set(["desktop", "mobile", "both"]);\nvar ORIENTATION = /* @__PURE__ */ new Set(["landscape", "portrait"]);\nvar VISIBILITY = /* @__PURE__ */ new Set(["public", "unlisted"]);\nvar VOICE = /* @__PURE__ */ new Set(["none", "room", "team", "proximity"]);\nvar PERFORMANCE = /* @__PURE__ */ new Set(["light", "medium", "heavy"]);\nvar TAG = /^[a-z0-9-]+$/;\nvar ID_INTERNO = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;\nvar CAMPO_MATCHMAKING = /^[a-z0-9][a-z0-9-]{0,31}$/;\nvar ID_CLASSIFICA = /^[a-z0-9][a-z0-9_-]{0,31}$/;\nfunction oggetto(value) {\n if (typeof value !== "object" || value === null || Array.isArray(value)) return null;\n return value;\n}\nfunction percorsoRelativo(value) {\n if (value === "" || value.startsWith("/") || value.includes("\\\\") || value.includes("\\0")) return false;\n if (value.includes("?") || value.includes("#")) return false;\n const parti = value.split("/");\n if (parti.some((parte) => parte === "" || parte === "." || parte === "..")) return false;\n try {\n const decoded = parti.map((parte) => decodeURIComponent(parte));\n return !decoded.some((parte) => parte === "" || parte === "." || parte === ".." || parte.includes("/"));\n } catch {\n return false;\n }\n}\nfunction hostValido(value) {\n if (value.length === 0 || value.length > 253) return false;\n if (value.includes("://") || /[/:?#@]/.test(value)) return false;\n const parti = value.split(".");\n return parti.every(\n (parte) => parte.length >= 1 && parte.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(parte)\n );\n}\nfunction interoTra(value, min, max) {\n return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;\n}\nfunction stringaDefault(dati, campo, valoreDefault, errori) {\n const value = dati[campo];\n if (value === void 0) return valoreDefault;\n if (typeof value !== "string") {\n errori.push(`${campo}: must be a string.`);\n return valoreDefault;\n }\n return value;\n}\nfunction testoFacoltativo(value, key, max, path, errors) {\n if (value[key] === void 0) return void 0;\n const text = value[key];\n if (typeof text !== "string" || text.trim().length === 0 || text.trim().length > max || /[\\r\\n\\u0000-\\u001f]/.test(text)) {\n errors.push(`${path}.${key}: must contain 1-${max} characters on one line.`);\n return void 0;\n }\n return text.trim();\n}\nfunction validaManifest(valore) {\n const errori = [];\n const dati = oggetto(valore);\n if (dati === null) return { ok: false, errori: ["manifest: must be a JSON object."] };\n for (const campo of Object.keys(dati)) {\n if (!CAMPI.has(campo)) errori.push(`${campo}: unknown field.`);\n }\n if (dati.manifest === void 0) errori.push("manifest: is required and must be 1.");\n else if (dati.manifest !== 1) errori.push("manifest: must be exactly 1.");\n const id = stringaDefault(dati, "id", "", errori);\n if (dati.id === void 0) errori.push("id: is required.");\n else if (typeof dati.id === "string") {\n if (!isValidSlug(id)) {\n errori.push("id: must be 3-32 lowercase ASCII letters or digits with internal hyphens; historical UUID v4 slugs may be 36 characters.");\n } else if (isReservedSlug(id)) errori.push("id: this slug is reserved.");\n }\n const name = stringaDefault(dati, "name", "", errori);\n if (dati.name === void 0) errori.push("name: is required.");\n else if (typeof dati.name === "string" && (name.trim() === "" || name.length > 60)) {\n errori.push("name: must contain 1-60 characters.");\n }\n const description = stringaDefault(dati, "description", "", errori);\n if (description.length > 500) errori.push("description: must be at most 500 characters.");\n let cover = null;\n if (dati.cover !== void 0 && dati.cover !== null) {\n if (typeof dati.cover !== "string") errori.push("cover: must be a relative file path or null.");\n else if (!percorsoRelativo(dati.cover)) errori.push("cover: must be a relative file path without query, fragment, or parent segments.");\n else cover = dati.cover;\n }\n const screenshots = [];\n if (dati.screenshots !== void 0) {\n if (!Array.isArray(dati.screenshots)) errori.push("screenshots: must be an array of relative file paths.");\n else {\n if (dati.screenshots.length > 8) errori.push("screenshots: must contain at most 8 paths.");\n for (const [indice, value] of dati.screenshots.entries()) {\n if (typeof value !== "string" || !percorsoRelativo(value)) {\n errori.push(`screenshots[${indice}]: must be a relative file path without query, fragment, or parent segments.`);\n } else screenshots.push(value);\n }\n }\n }\n const tags = [];\n if (dati.tags !== void 0) {\n if (!Array.isArray(dati.tags)) errori.push("tags: must be an array.");\n else {\n if (dati.tags.length > 10) errori.push("tags: must contain at most 10 tags.");\n for (const [indice, value] of dati.tags.entries()) {\n if (typeof value !== "string" || value.length > 24 || !TAG.test(value)) {\n errori.push(`tags[${indice}]: must be 1-24 lowercase letters, digits, or hyphens.`);\n } else tags.push(value);\n }\n }\n }\n const language = stringaDefault(dati, "language", "en", errori);\n if (!/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/.test(language)) {\n errori.push("language: must be a BCP 47 language tag such as en, it, or pt-BR.");\n }\n let platform = "both";\n if (dati.platform === void 0) errori.push("platform: is required.");\n else if (typeof dati.platform !== "string" || !PLATFORM.has(dati.platform)) {\n errori.push("platform: must be desktop, mobile, or both.");\n } else platform = dati.platform;\n let orientation = "landscape";\n if (dati.orientation !== void 0) {\n if (typeof dati.orientation !== "string" || !ORIENTATION.has(dati.orientation)) {\n errori.push("orientation: must be landscape or portrait.");\n } else orientation = dati.orientation;\n }\n const input = [];\n if (dati.input !== void 0) {\n if (!Array.isArray(dati.input)) errori.push("input: must be an array.");\n else for (const [indice, value] of dati.input.entries()) {\n if (typeof value !== "string" || !INPUT.has(value)) {\n errori.push(`input[${indice}]: must be keyboard, mouse, touch, or gamepad.`);\n } else if (input.includes(value)) errori.push(`input[${indice}]: duplicate value ${value}.`);\n else input.push(value);\n }\n }\n let visibility = "public";\n if (dati.visibility !== void 0) {\n if (typeof dati.visibility !== "string" || !VISIBILITY.has(dati.visibility)) {\n errori.push("visibility: must be public or unlisted.");\n } else visibility = dati.visibility;\n }\n const network = [];\n if (dati.network !== void 0) {\n if (!Array.isArray(dati.network)) errori.push("network: must be an array of host names.");\n else for (const [indice, value] of dati.network.entries()) {\n if (typeof value !== "string" || !hostValido(value)) {\n errori.push(`network[${indice}]: must be a host name without scheme, port, path, query, or fragment.`);\n } else if (network.includes(value)) errori.push(`network[${indice}]: duplicate host ${value}.`);\n else network.push(value);\n }\n }\n let isolated = false;\n if (dati.isolated !== void 0) {\n if (typeof dati.isolated !== "boolean") errori.push("isolated: must be a boolean.");\n else isolated = dati.isolated;\n }\n const requires = {\n webgl2: false,\n webgpu: false,\n wasm: false,\n threads: false,\n memoryMb: null,\n performance: "light"\n };\n if (dati.requires !== void 0) {\n const value = oggetto(dati.requires);\n if (value === null) errori.push("requires: must be an object.");\n else {\n for (const campo of Object.keys(value)) {\n if (!["webgl2", "webgpu", "wasm", "threads", "memoryMb", "performance"].includes(campo)) {\n errori.push(`requires.${campo}: unknown field.`);\n }\n }\n for (const campo of ["webgl2", "webgpu", "wasm", "threads"]) {\n if (value[campo] === void 0) continue;\n if (typeof value[campo] !== "boolean") errori.push(`requires.${campo}: must be a boolean.`);\n else requires[campo] = value[campo];\n }\n if (value.memoryMb !== void 0) {\n if (value.memoryMb !== null && (!interoTra(value.memoryMb, 512, 32768) || value.memoryMb % 256 !== 0)) {\n errori.push("requires.memoryMb: must be an integer from 512 to 32768 in steps of 256, or null.");\n } else requires.memoryMb = value.memoryMb;\n }\n if (value.performance !== void 0) {\n if (typeof value.performance !== "string" || !PERFORMANCE.has(value.performance)) {\n errori.push("requires.performance: must be light, medium, or heavy.");\n } else requires.performance = value.performance;\n }\n if (requires.threads && !isolated) errori.push("requires.threads: needs isolated: true.");\n }\n }\n let players = { min: 1, max: 1 };\n if (dati.players !== void 0) {\n const value = oggetto(dati.players);\n if (value === null) errori.push("players: must be an object with min and max.");\n else {\n for (const campo of Object.keys(value)) {\n if (campo !== "min" && campo !== "max") errori.push(`players.${campo}: unknown field.`);\n }\n if (!interoTra(value.min, 1, TETTO_GIOCATORI)) errori.push(`players.min: must be an integer from 1 to ${TETTO_GIOCATORI}.`);\n if (!interoTra(value.max, 1, TETTO_GIOCATORI)) errori.push(`players.max: must be an integer from 1 to ${TETTO_GIOCATORI} in manifest version 1.`);\n if (interoTra(value.min, 1, TETTO_GIOCATORI) && interoTra(value.max, 1, TETTO_GIOCATORI)) {\n if (value.min > value.max) errori.push("players.max: must be greater than or equal to players.min.");\n else players = { min: value.min, max: value.max };\n }\n }\n }\n let lobby = false;\n if (dati.lobby !== void 0) {\n if (typeof dati.lobby !== "boolean") errori.push("lobby: must be a boolean.");\n else lobby = dati.lobby;\n }\n let persistent = false;\n if (dati.persistent !== void 0) {\n if (typeof dati.persistent !== "boolean") errori.push("persistent: must be a boolean.");\n else persistent = dati.persistent;\n }\n let spectators = { delayMs: RITARDO_SPETTATORI_MS };\n if (dati.spectators === false || dati.spectators === null) spectators = null;\n else if (dati.spectators !== void 0 && dati.spectators !== true) {\n const value = oggetto(dati.spectators);\n if (value === null) {\n errori.push("spectators: must be a boolean or an object with delayMs.");\n } else {\n for (const campo of Object.keys(value)) {\n if (campo !== "delayMs") errori.push(`spectators.${campo}: unknown field.`);\n }\n if (!interoTra(value.delayMs, 0, 3e4)) {\n errori.push("spectators.delayMs: must be an integer from 0 to 30000.");\n } else spectators = { delayMs: value.delayMs };\n }\n }\n let overlay = null;\n if (dati.overlay !== void 0 && dati.overlay !== null) {\n const value = oggetto(dati.overlay);\n if (value === null) errori.push("overlay: must be an object or null.");\n else {\n for (const key of Object.keys(value)) if (!["version", "accent"].includes(key)) errori.push(`overlay.${key}: unknown field.`);\n if (value.version !== 1) errori.push("overlay.version: must be exactly 1.");\n if (value.accent !== void 0 && (typeof value.accent !== "string" || !/^#[0-9a-fA-F]{6}$/.test(value.accent))) {\n errori.push("overlay.accent: must be a six-digit hexadecimal color, such as #336699.");\n }\n overlay = { version: 1, ...typeof value.accent === "string" ? { accent: value.accent } : {} };\n }\n }\n const boards = {};\n if (dati.boards !== void 0) {\n const value = oggetto(dati.boards);\n if (value === null) errori.push("boards: must be an object of board ids.");\n else {\n if (Object.keys(value).length > MASSIMO_CLASSIFICHE) {\n errori.push(`boards: at most ${MASSIMO_CLASSIFICHE} boards.`);\n }\n for (const [id2, raw] of Object.entries(value)) {\n let valido = true;\n if (!ID_CLASSIFICA.test(id2)) {\n errori.push(`boards.${id2}: invalid board id.`);\n valido = false;\n }\n const board = oggetto(raw);\n if (board === null) {\n errori.push(`boards.${id2}.source: must be "client" or "server".`);\n continue;\n }\n for (const campo of Object.keys(board)) {\n if (!["source", "label", "periods"].includes(campo)) errori.push(`boards.${id2}.${campo}: unknown field.`);\n }\n if (board.source !== "client" && board.source !== "server") {\n errori.push(`boards.${id2}.source: must be "client" or "server".`);\n valido = false;\n }\n const label = testoFacoltativo(board, "label", 48, `boards.${id2}`, errori);\n let periods = ["all-time"];\n if (board.periods !== void 0) {\n if (!Array.isArray(board.periods) || board.periods.length < 1 || board.periods.length > 2 || board.periods.some((period) => period !== "daily" && period !== "all-time") || new Set(board.periods).size !== board.periods.length) {\n errori.push(`boards.${id2}.periods: must contain daily, all-time, or both without duplicates.`);\n } else periods = [...board.periods];\n }\n if (valido) Object.defineProperty(boards, id2, { value: {\n source: board.source,\n periods,\n ...label === void 0 ? {} : { label }\n }, enumerable: true, configurable: true, writable: true });\n }\n }\n }\n const roles = [];\n if (dati.roles !== void 0) {\n if (!Array.isArray(dati.roles)) errori.push("roles: must be an array.");\n else {\n const ids = /* @__PURE__ */ new Set();\n for (const [indice, raw] of dati.roles.entries()) {\n const value = oggetto(raw);\n if (value === null) {\n errori.push(`roles[${indice}]: must be an object.`);\n continue;\n }\n for (const campo of Object.keys(value)) {\n if (!["id", "min", "max", "label"].includes(campo)) errori.push(`roles[${indice}].${campo}: unknown field.`);\n }\n const idRuolo = value.id;\n const min = value.min;\n const max = value.max;\n let valido = true;\n if (typeof idRuolo !== "string" || idRuolo.length > 32 || !ID_INTERNO.test(idRuolo)) {\n errori.push(`roles[${indice}].id: must be 1-32 lowercase letters, digits, or internal hyphens.`);\n valido = false;\n } else if (ids.has(idRuolo)) {\n errori.push(`roles[${indice}].id: duplicate role ${idRuolo}.`);\n valido = false;\n } else ids.add(idRuolo);\n if (!interoTra(min, 0, TETTO_GIOCATORI)) {\n errori.push(`roles[${indice}].min: must be an integer from 0 to ${TETTO_GIOCATORI}.`);\n valido = false;\n }\n if (max !== void 0 && !interoTra(max, 0, TETTO_GIOCATORI)) {\n errori.push(`roles[${indice}].max: must be an integer from 0 to ${TETTO_GIOCATORI} when present.`);\n valido = false;\n }\n if (typeof min === "number" && typeof max === "number" && min > max) {\n errori.push(`roles[${indice}].max: must be greater than or equal to min.`);\n valido = false;\n }\n const label = testoFacoltativo(value, "label", 32, `roles[${indice}]`, errori);\n if (valido) roles.push({\n id: idRuolo,\n min,\n ...max === void 0 ? {} : { max },\n ...label === void 0 ? {} : { label }\n });\n }\n }\n }\n let teams = null;\n if (dati.teams !== void 0 && dati.teams !== null) {\n const value = oggetto(dati.teams);\n if (value === null) errori.push("teams: must be null or an object with min and max.");\n else {\n for (const campo of Object.keys(value)) {\n if (campo !== "min" && campo !== "max") errori.push(`teams.${campo}: unknown field.`);\n }\n if (!interoTra(value.min, 2, TETTO_GIOCATORI)) errori.push(`teams.min: must be an integer from 2 to ${TETTO_GIOCATORI}.`);\n if (!interoTra(value.max, 2, TETTO_GIOCATORI)) errori.push(`teams.max: must be an integer from 2 to ${TETTO_GIOCATORI}.`);\n if (interoTra(value.min, 2, TETTO_GIOCATORI) && interoTra(value.max, 2, TETTO_GIOCATORI)) {\n if (value.min > value.max) errori.push("teams.max: must be greater than or equal to teams.min.");\n else teams = { min: value.min, max: value.max };\n }\n }\n }\n let voice = "none";\n if (dati.voice !== void 0) {\n if (typeof dati.voice !== "string" || !VOICE.has(dati.voice)) {\n errori.push("voice: must be none, room, team, or proximity.");\n } else voice = dati.voice;\n }\n const modes = [];\n if (dati.modes !== void 0) {\n if (!Array.isArray(dati.modes)) errori.push("modes: must be an array.");\n else {\n const ids = /* @__PURE__ */ new Set();\n for (const [indice, raw] of dati.modes.entries()) {\n const value = oggetto(raw);\n if (value === null) {\n errori.push(`modes[${indice}]: must be an object.`);\n continue;\n }\n for (const campo of Object.keys(value)) {\n if (!["id", "players", "lobby", "matchmaking", "execution", "label", "instructions"].includes(campo)) errori.push(`modes[${indice}].${campo}: unknown field.`);\n }\n if (typeof value.id !== "string" || value.id.length > 32 || !ID_INTERNO.test(value.id)) {\n errori.push(`modes[${indice}].id: must be 1-32 lowercase letters, digits, or internal hyphens.`);\n continue;\n }\n if (ids.has(value.id)) {\n errori.push(`modes[${indice}].id: duplicate mode ${value.id}.`);\n continue;\n }\n ids.add(value.id);\n const modo = { id: value.id };\n for (const [key2, max] of [["label", 48], ["instructions", 160]]) {\n const text = testoFacoltativo(value, key2, max, `modes[${indice}]`, errori);\n if (text !== void 0) modo[key2] = text;\n }\n if (value.execution !== void 0) {\n if (value.execution !== "local" && value.execution !== "room") errori.push(`modes[${indice}].execution: must be local or room.`);\n else modo.execution = value.execution;\n }\n if (overlay !== null && modo.execution === void 0) errori.push(`modes[${indice}].execution: is required with the standard overlay.`);\n if (value.players !== void 0) {\n const campo = `modes[${indice}].players`;\n const range = oggetto(value.players);\n if (range === null) errori.push(`${campo}: must be an object with min and max.`);\n else {\n for (const key2 of Object.keys(range)) {\n if (key2 !== "min" && key2 !== "max") errori.push(`${campo}.${key2}: unknown field.`);\n }\n if (!interoTra(range.min, 1, TETTO_GIOCATORI)) errori.push(`${campo}.min: must be an integer from 1 to ${TETTO_GIOCATORI}.`);\n if (!interoTra(range.max, 1, TETTO_GIOCATORI)) errori.push(`${campo}.max: must be an integer from 1 to ${TETTO_GIOCATORI}.`);\n if (interoTra(range.min, 1, TETTO_GIOCATORI) && interoTra(range.max, 1, TETTO_GIOCATORI)) {\n if (range.min > range.max) errori.push(`${campo}.max: must be greater than or equal to min.`);\n else modo.players = { min: range.min, max: range.max };\n }\n }\n }\n if (value.lobby !== void 0) {\n if (typeof value.lobby !== "boolean") errori.push(`modes[${indice}].lobby: must be a boolean.`);\n else modo.lobby = value.lobby;\n }\n if (modo.execution === "local") {\n const range = modo.players ?? players;\n if (range.min !== 1 || range.max !== 1) errori.push(`modes[${indice}].players: local execution requires min and max to be 1.`);\n if (modo.lobby ?? lobby) errori.push(`modes[${indice}].lobby: local execution requires false.`);\n if (value.matchmaking !== void 0) errori.push(`modes[${indice}].matchmaking: local execution cannot use matchmaking.`);\n }\n if (value.matchmaking === void 0) {\n modes.push(modo);\n continue;\n }\n const matchmaking = oggetto(value.matchmaking);\n if (matchmaking === null) {\n errori.push(`modes[${indice}].matchmaking: must be an object.`);\n continue;\n }\n for (const campo of Object.keys(matchmaking)) {\n if (!["key", "timeoutMs", "defaults"].includes(campo)) {\n errori.push(`modes[${indice}].matchmaking.${campo}: unknown field.`);\n }\n }\n let valido = true;\n const key = [];\n if (!Array.isArray(matchmaking.key) || matchmaking.key.length < 1 || matchmaking.key.length > 8) {\n errori.push(`modes[${indice}].matchmaking.key: must contain from 1 to 8 fields.`);\n valido = false;\n } else for (const [keyIndice, item] of matchmaking.key.entries()) {\n if (typeof item !== "string" || !CAMPO_MATCHMAKING.test(item)) {\n errori.push(`modes[${indice}].matchmaking.key[${keyIndice}]: must be 1-32 lowercase letters, digits, or hyphens and start with a letter or digit.`);\n valido = false;\n } else if (key.includes(item)) {\n errori.push(`modes[${indice}].matchmaking.key[${keyIndice}]: duplicate field ${item}.`);\n valido = false;\n } else key.push(item);\n }\n if (!interoTra(matchmaking.timeoutMs, 1e3, 3e5)) {\n errori.push(`modes[${indice}].matchmaking.timeoutMs: must be an integer from 1000 to 300000.`);\n valido = false;\n }\n let defaults;\n if (matchmaking.defaults !== void 0) {\n const values = oggetto(matchmaking.defaults);\n if (values === null || Object.keys(values).length !== key.length || key.some((field) => !Object.hasOwn(values, field))) {\n errori.push(`modes[${indice}].matchmaking.defaults: must contain exactly the declared key fields.`);\n } else {\n defaults = {};\n for (const [field, value2] of Object.entries(values)) {\n if (!(typeof value2 === "string" && value2.length >= 1 && value2.length <= 64 && /^[A-Za-z0-9_.:-]+$/.test(value2)) && !Number.isSafeInteger(value2)) {\n errori.push(`modes[${indice}].matchmaking.defaults.${field}: must be a string of 1-64 characters or a safe integer.`);\n } else Object.defineProperty(defaults, field, { value: value2, enumerable: true });\n }\n }\n }\n if (valido) modes.push({ ...modo, matchmaking: {\n ...defaults === void 0 ? {} : { defaults },\n key,\n timeoutMs: matchmaking.timeoutMs\n } });\n }\n }\n }\n if (overlay !== null && modes.length === 0) errori.push("modes: at least one explicit mode is required with the standard overlay.");\n if (errori.length > 0) return { ok: false, errori };\n return { ok: true, manifest: {\n manifest: 1,\n overlay,\n id,\n name,\n description,\n cover,\n screenshots,\n tags,\n language,\n platform,\n orientation,\n input,\n visibility,\n network,\n isolated,\n requires,\n players,\n lobby,\n persistent,\n spectators,\n boards,\n roles,\n teams,\n voice,\n modes\n } };\n}\n\n// ../contracts/src/device.ts\nfunction deviceTier(report) {\n if (report.gpu !== "hardware" || report.memoryMb !== null && report.memoryMb <= 2048) return "low";\n if (report.mobile || report.memoryMb !== null && report.memoryMb <= 4096 || report.cores !== null && report.cores <= 4) return "mid";\n return "high";\n}\nfunction perdiContesto(context) {\n try {\n context?.getExtension("WEBGL_lose_context")?.loseContext();\n } catch {\n }\n}\nfunction valoriSincroni(ambiente) {\n let navigator2;\n try {\n navigator2 = ambiente.navigator;\n } catch {\n navigator2 = void 0;\n }\n let memoryMb = null;\n try {\n const memory = navigator2?.deviceMemory;\n const converted = typeof memory === "number" ? memory * 1024 : NaN;\n if (Number.isFinite(converted)) memoryMb = converted;\n } catch {\n memoryMb = null;\n }\n let cores = null;\n try {\n const value = navigator2?.hardwareConcurrency;\n if (typeof value === "number" && Number.isFinite(value)) cores = value;\n } catch {\n cores = null;\n }\n let mobile = false;\n try {\n mobile = typeof navigator2?.userAgentData?.mobile === "boolean" ? navigator2.userAgentData.mobile : /Android|iPhone|iPad|iPod|Mobile/i.test(navigator2?.userAgent ?? "");\n } catch {\n mobile = false;\n }\n let isolated = false;\n try {\n isolated = ambiente.crossOriginIsolated === true;\n } catch {\n isolated = false;\n }\n return {\n webgl2: false,\n webgpu: false,\n wasm: false,\n threads: false,\n isolated,\n gpu: "none",\n memoryMb,\n cores,\n mobile\n };\n}\nasync function probeDevice(globals, timeoutMs = 1500) {\n const ambiente = globals ?? globalThis;\n const report = valoriSincroni(ambiente);\n const webgl = Promise.resolve().then(() => {\n try {\n const canvas = ambiente.document?.createElement("canvas");\n if (canvas === void 0) return;\n const hardware = canvas.getContext("webgl2", { failIfMajorPerformanceCaveat: true });\n if (hardware !== null) {\n report.webgl2 = true;\n report.gpu = "hardware";\n perdiContesto(hardware);\n return;\n }\n const software = canvas.getContext("webgl2");\n if (software !== null) {\n report.webgl2 = true;\n report.gpu = "software";\n perdiContesto(software);\n }\n } catch {\n report.webgl2 = false;\n report.gpu = "none";\n }\n });\n const webgpu = Promise.resolve().then(async () => {\n let device;\n try {\n const gpu = ambiente.navigator?.gpu;\n if (gpu === void 0) return;\n const adapter = await gpu.requestAdapter();\n if (adapter === null) return;\n device = await adapter.requestDevice();\n report.webgpu = true;\n } catch {\n report.webgpu = false;\n } finally {\n try {\n device?.destroy?.();\n } catch {\n }\n }\n });\n const wasm = Promise.resolve().then(() => {\n try {\n report.wasm = ambiente.WebAssembly?.validate(\n new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0])\n ) === true;\n } catch {\n report.wasm = false;\n }\n });\n const threads = Promise.resolve().then(() => {\n try {\n if (ambiente.WebAssembly === void 0) return;\n new ambiente.WebAssembly.Memory({ initial: 1, maximum: 1, shared: true });\n report.threads = true;\n } catch {\n report.threads = false;\n }\n });\n let timer;\n await Promise.race([\n Promise.all([webgl, webgpu, wasm, threads]),\n new Promise((resolve) => {\n timer = setTimeout(resolve, Math.max(0, timeoutMs));\n })\n ]);\n if (timer !== void 0) clearTimeout(timer);\n return { ...report, tier: deviceTier(report) };\n}\n\n// ../contracts/src/overlay.ts\nfunction overlayConfiguration(manifest, coverUrl = null, invite = null) {\n const validated = validaManifest(manifest);\n if (!validated.ok) throw new Error("The overlay manifest is invalid.");\n return { manifest: validated.manifest, coverUrl, invite };\n}\nfunction record(value) {\n return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;\n}\nfunction validOverlayHello(value) {\n const hello = record(value), config = record(hello?.configuration);\n return hello?.v === 1 && typeof hello.epoch === "string" && hello.epoch.length > 0 && hello.epoch.length <= 128 && config !== null && (config.coverUrl === null || typeof config.coverUrl === "string") && (config.invite === null || typeof config.invite === "string" && /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/.test(config.invite)) && validaManifest(config.manifest).ok;\n}\nfunction normalizeOverlayHello(value) {\n if (!validOverlayHello(value)) return null;\n return { v: 1, epoch: value.epoch, configuration: overlayConfiguration(value.configuration.manifest, value.configuration.coverUrl, value.configuration.invite) };\n}\nfunction validOverlayView(value) {\n const data = record(value);\n return data !== null && Object.keys(data).every((key) => ["inputBlocked", "reservedRects", "shortcutEnabled"].includes(key)) && (data.shortcutEnabled === void 0 || typeof data.shortcutEnabled === "boolean") && typeof data.inputBlocked === "boolean" && Array.isArray(data.reservedRects) && data.reservedRects.length <= 8 && data.reservedRects.every((value2) => {\n const rect = record(value2);\n return rect !== null && Object.keys(rect).length === 4 && ["x", "y", "width", "height"].every((key) => typeof rect[key] === "number" && Number.isFinite(rect[key]) && rect[key] >= 0 && rect[key] <= 1e5);\n });\n}\nfunction validOverlayRequest(value) {\n const message = record(value), args = record(message?.args);\n if (message?.type !== "caisual:overlay" || message.v !== 1 || typeof message.epoch !== "string" || message.epoch.length < 1 || message.epoch.length > 128 || typeof message.requestId !== "string" || !(/^[1-9][0-9]{0,15}$/.test(message.requestId) && Number.isSafeInteger(Number(message.requestId))) || args === null) return false;\n if (Object.keys(message).some((key) => !["type", "v", "epoch", "requestId", "sessionId", "op", "args"].includes(key)) || !(message.sessionId === void 0 || message.sessionId === null || typeof message.sessionId === "string" && /^[1-9][0-9]{0,15}$/.test(message.sessionId))) return false;\n const keys = (...allowed) => Object.keys(args).every((key) => allowed.includes(key));\n const text = (key) => typeof args[key] === "string" && args[key].length >= 1 && args[key].length <= 64;\n switch (message.op) {\n case "local.start":\n return keys("mode") && text("mode");\n case "room.create":\n return keys("mode") && (args.mode === null || text("mode"));\n case "room.join":\n return keys("code") && (args.code === void 0 || text("code"));\n case "room.watch":\n return keys("code") && text("code");\n case "room.match": {\n const key = record(args.key);\n return keys("mode", "key") && text("mode") && (args.key === void 0 || key !== null && Object.keys(key).length <= 8 && Object.values(key).every((v) => typeof v === "string" && v.length >= 1 && v.length <= 64 || typeof v === "number" && Number.isSafeInteger(v)));\n }\n case "room.ready":\n return keys("ready") && typeof args.ready === "boolean";\n case "room.role":\n case "room.requestRole":\n return keys("role") && text("role");\n case "room.team":\n return keys("team") && Number.isInteger(args.team) && args.team >= 1 && args.team <= 24;\n case "room.start":\n case "session.cancel":\n case "session.leave":\n case "session.disconnect":\n case "session.resume":\n return keys();\n case "voice.join":\n case "voice.leave":\n return keys() && typeof message.sessionId === "string";\n case "voice.mute":\n return keys("muted") && typeof args.muted === "boolean" && typeof message.sessionId === "string";\n case "voice.setVolume":\n return keys("playerId", "volume") && typeof message.sessionId === "string" && typeof args.playerId === "string" && args.playerId.length > 0 && args.playerId.length <= 128 && typeof args.volume === "number" && Number.isFinite(args.volume) && args.volume >= 0 && args.volume <= 1;\n case "overlay.view":\n return validOverlayView(args);\n default:\n return false;\n }\n}\nfunction validBoardDay(value) {\n if (typeof value !== "string" || !/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) return false;\n const at = Date.parse(`${value}T00:00:00Z`);\n return Number.isFinite(at) && new Date(at).toISOString().slice(0, 10) === value;\n}\n\n// src/errors.ts\nfunction creaErrore(code, message) {\n return Object.assign(new Error(message), { name: "CaisualError", code });\n}\nfunction erroreOffline() {\n return creaErrore("offline", "Caisual services are unavailable.");\n}\nfunction codiceErrore(valore) {\n return typeof valore === "object" && valore !== null && "code" in valore ? valore.code : null;\n}\n\n// src/session/resume.ts\nvar KEY = "caisual-session-v1";\nfunction resume(value) {\n const data = record(value);\n if (!data || typeof data.code !== "string" || !/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/.test(data.code) || !(data.mode === void 0 || data.mode === null || typeof data.mode === "string")) return null;\n return {\n version: 1,\n code: data.code,\n mode: typeof data.mode === "string" ? data.mode : null,\n updatedAt: typeof data.updatedAt === "number" && Number.isFinite(data.updatedAt) ? data.updatedAt : 0\n };\n}\nfunction createResume(save, changed) {\n let current = null, error = false, work = Promise.resolve();\n const write = async () => {\n const value = { version: 1, imported: true, resume: current };\n work = work.catch(() => void 0).then(async () => {\n try {\n await save.set(KEY, value);\n error = false;\n } catch (cause) {\n error = true;\n throw cause;\n } finally {\n changed();\n }\n });\n return work;\n };\n const loaded = (async () => {\n try {\n const data = record(await save.get(KEY));\n if (data?.version === 1 && data.imported === true) current = resume(data.resume);\n else {\n current = resume(await save.get("resume"));\n await write();\n }\n } catch {\n error = true;\n }\n changed();\n })();\n return {\n loaded,\n get value() {\n return current === null ? null : { ...current };\n },\n get error() {\n return error;\n },\n async set(value) {\n await loaded;\n current = value;\n changed();\n await write();\n }\n };\n}\n\n// src/session/index.ts\nfunction notify(listeners, value) {\n for (const listener of listeners) {\n try {\n listener(value);\n } catch {\n }\n }\n}\nfunction createSession(base, configuration = null, roomsAvailable = base.connected) {\n const standard = configuration?.manifest.overlay?.version === 1;\n const manifest = configuration?.manifest;\n let current = { kind: "idle" }, ready = false, operation = 0, identifier = 0;\n let pending = null, pendingMode = null;\n let waiting = null, controller = null;\n let stops = [], disposed = false, lastState = "";\n let view = { inputBlocked: false, reservedRects: [] };\n const listeners = /* @__PURE__ */ new Set();\n const viewListeners = /* @__PURE__ */ new Set();\n const stateListeners = /* @__PURE__ */ new Set();\n const openListeners = /* @__PURE__ */ new Set();\n const errorListeners = /* @__PURE__ */ new Set();\n const scoreListeners = /* @__PURE__ */ new Set();\n let resumeStore = null;\n const capabilities = () => ({\n local: true,\n rooms: roomsAvailable,\n overlay: standard,\n requestRole: current.kind === "room" && current.room.metadata.configuration?.requestRole === true\n });\n function voiceSnapshot() {\n if (current.kind !== "room" || !manifest || manifest.voice === "none") return null;\n const room = current.room, voice = room.voice;\n if (!voice || voice.mode === "none" || room.players.find((p) => p.id === room.you)?.role === "spectator") return null;\n return {\n mode: voice.mode,\n state: voice.state,\n mic: voice.mic,\n muted: voice.muted,\n speaking: voice.speaking,\n peers: voice.peers.map(({ id, mic, muted, speaking, volume }) => ({ id, mic, muted, speaking, volume }))\n };\n }\n function snapshot() {\n const attached = current.kind === "room" || current.kind === "watch" ? current.room : null;\n const configured = attached?.metadata.configuration;\n const fallback = manifest && attached && (attached.mode === null || manifest.modes.some((m) => m.id === attached.mode)) ? risolviModalita(manifest, attached.mode) : { players: { min: 1, max: 1 }, lobby: false };\n return {\n kind: pending ?? (current.kind === "idle" ? ready ? "home" : "boot" : current.kind),\n id: current.kind === "idle" ? null : current.id,\n mode: pending ? pendingMode : current.kind === "local" ? current.mode : attached?.mode ?? null,\n localStatus: current.kind === "local" ? current.status : null,\n ready,\n capabilities: capabilities(),\n room: attached ? {\n code: attached.code,\n mode: attached.mode,\n status: attached.status,\n host: attached.host,\n you: current.kind === "room" ? current.room.you : null,\n players: attached.players.map((p) => ({ id: p.id, name: p.name, guest: p.guest, role: p.role, team: p.team, ready: p.ready, connected: p.connected })),\n countdownAt: attached.countdownAt,\n connection: attached.connection,\n closedCode: attached.metadata.closedCode,\n limits: { ...configured?.players ?? fallback.players },\n lobby: configured?.lobby ?? fallback.lobby,\n persistent: configured?.persistent ?? manifest?.persistent ?? false,\n delayMs: current.kind === "watch" ? current.room.delayMs : null,\n requestRole: configured?.requestRole ?? false\n } : null,\n voice: pending ? null : voiceSnapshot(),\n waiting: waiting ? { ...waiting } : null,\n resume: resumeStore?.value ?? null,\n resumeError: resumeStore?.error ?? false\n };\n }\n function emit() {\n if (disposed) return;\n const state = snapshot(), serialized = JSON.stringify(state);\n if (serialized === lastState) return;\n lastState = serialized;\n notify(stateListeners, state);\n }\n function changed() {\n notify(listeners, { ...current });\n emit();\n }\n function active() {\n if (current.kind !== "room") throw creaErrore("no_room", "There is no active player room.");\n return current.room;\n }\n function activeVoice() {\n const room = active();\n if (room.players.find((p) => p.id === room.you)?.role === "spectator") throw creaErrore("spectator", "Spectators cannot use voice controls.");\n if (!manifest || manifest.voice === "none" || room.voice.mode === "none") throw creaErrore("voice_disabled", "Voice is disabled for this room.");\n return room.voice;\n }\n function cancel() {\n operation++;\n controller?.abort();\n controller = null;\n pending = null;\n waiting = null;\n emit();\n }\n function detach(preserve) {\n stops.splice(0).forEach((stop) => stop());\n if (current.kind === "room" || current.kind === "watch") {\n if (preserve) current.room.disconnect();\n else current.room.leave();\n }\n current = { kind: "idle" };\n changed();\n }\n async function clearResume(code) {\n if (resumeStore?.value?.code === code) await resumeStore.set(null).catch(() => void 0);\n }\n async function adopt(next, watch, token) {\n if (token !== operation || disposed) {\n next.leave();\n throw creaErrore("cancelled", "The operation was cancelled.");\n }\n detach(false);\n current = watch ? { kind: "watch", room: next, id: String(++identifier) } : { kind: "room", room: next, id: String(++identifier) };\n const room = next;\n stops = [room.onPlayers(emit), room.onMetadata(() => {\n if (room.connection === "disconnected" && (current.kind === "room" || current.kind === "watch") && current.room === room) {\n stops.splice(0).forEach((stop) => stop());\n if (!watch && room.metadata.closedCode === 1e3) void clearResume(room.code);\n current = { kind: "idle" };\n changed();\n } else emit();\n }), room.onStatus(() => {\n emit();\n if (!watch && room.connection === "ended") void clearResume(room.code);\n })];\n if (!watch) {\n const playerRoom = next;\n const sessionId = current.id;\n if (playerRoom.voice) stops.push(playerRoom.voice.onState(emit), playerRoom.voice.onPeers(emit));\n stops.push(playerRoom.onError((error) => notify(errorListeners, { sessionId, error: { ...error } })));\n stops.push(playerRoom.onScoreQueued((score) => notify(scoreListeners, { ...score })));\n for (const score of playerRoom.queuedScores) notify(scoreListeners, { ...score });\n }\n pending = null;\n waiting = null;\n changed();\n if (!watch && resumeStore && room.connection !== "ended") {\n await resumeStore.set({ version: 1, code: room.code, mode: room.mode, updatedAt: base.time.now() }).catch(() => void 0);\n }\n return next;\n }\n async function run(kind, mode, work, watch = false) {\n cancel();\n const token = operation;\n controller = new AbortController();\n pending = kind;\n pendingMode = mode;\n emit();\n try {\n const next = await work(controller.signal, token);\n await adopt(next, watch, token);\n if (token !== operation || disposed) throw creaErrore("cancelled", "The operation was cancelled.");\n return next;\n } finally {\n if (token === operation) {\n pending = null;\n waiting = null;\n controller = null;\n emit();\n }\n }\n }\n const direct = base.room;\n const rooms = !standard ? direct : {\n invited: direct.invited,\n create(options) {\n if (manifest && modalitaLocale(manifest, options.mode)) return Promise.reject(creaErrore("invalid_request", "Local modes cannot create rooms."));\n return run("attaching", options.mode, () => direct.create(options));\n },\n join(code) {\n return run("attaching", null, () => direct.join(code));\n },\n watch(code) {\n return run("attaching", null, () => direct.watch(code), true);\n },\n match(options) {\n if (manifest && modalitaLocale(manifest, options.mode)) return Promise.reject(creaErrore("invalid_request", "Local modes cannot use matchmaking."));\n return run("matching", options.mode, (signal, token) => {\n const abort = () => {\n if (operation === token) cancel();\n };\n options.signal?.addEventListener("abort", abort, { once: true });\n if (options.signal?.aborted) abort();\n return direct.match({ ...options, signal, onWaiting(value) {\n if (token !== operation) return;\n waiting = { ...value };\n emit();\n options.onWaiting?.(value);\n } }).finally(() => options.signal?.removeEventListener("abort", abort));\n });\n }\n };\n if (standard) resumeStore = createResume(base.save, emit);\n const session = {\n get current() {\n return { ...current };\n },\n get capabilities() {\n return capabilities();\n },\n onChange(listener) {\n listeners.add(listener);\n notify(/* @__PURE__ */ new Set([listener]), { ...current });\n return () => {\n listeners.delete(listener);\n };\n },\n ready() {\n if (disposed || ready) return;\n ready = true;\n emit();\n },\n finish() {\n if (current.kind === "room" || current.kind === "watch") throw creaErrore("not_local", "Only a local session can be finished by the client.");\n if (current.kind === "local") {\n current = { ...current, status: "ended" };\n changed();\n }\n }\n };\n const overlay = {\n open(panel) {\n if (!["home", "room", "invite", "friends", "voice", "boards"].includes(panel)) throw creaErrore("invalid_request", "Unknown overlay panel.");\n if (standard) notify(openListeners, panel);\n },\n onChange(listener) {\n viewListeners.add(listener);\n notify(/* @__PURE__ */ new Set([listener]), structuredClone(view));\n return () => {\n viewListeners.delete(listener);\n };\n }\n };\n return {\n session,\n overlay,\n rooms,\n snapshot,\n serverTime: () => current.kind === "room" || current.kind === "watch" ? current.room.serverTime() : base.time.now(),\n onState(listener) {\n stateListeners.add(listener);\n listener(snapshot());\n return () => {\n stateListeners.delete(listener);\n };\n },\n onOpen(listener) {\n openListeners.add(listener);\n return () => {\n openListeners.delete(listener);\n };\n },\n onError(listener) {\n errorListeners.add(listener);\n return () => {\n errorListeners.delete(listener);\n };\n },\n onScore(listener) {\n scoreListeners.add(listener);\n return () => {\n scoreListeners.delete(listener);\n };\n },\n async execute(request) {\n if (!standard) throw creaErrore("overlay_disabled", "This game uses its own room flow.");\n if (request.op === "overlay.view") {\n if (!validOverlayView(request.args)) throw creaErrore("invalid_request", "The overlay geometry is invalid.");\n view = structuredClone(request.args);\n notify(viewListeners, structuredClone(view));\n return;\n }\n if (request.sessionId !== void 0 && request.sessionId !== (current.kind === "idle" ? null : current.id)) throw creaErrore("session_replaced", "The active session changed.");\n if (request.op.startsWith("voice.") && request.sessionId !== (current.kind === "idle" ? null : current.id)) throw creaErrore("session_replaced", "The active session changed.");\n if (!ready) throw creaErrore("game_not_ready", "The game is still loading.");\n switch (request.op) {\n case "local.start": {\n if (!manifest || !modalitaLocale(manifest, request.args.mode)) throw creaErrore("invalid_mode", "This is not a local mode.");\n cancel();\n const token = operation;\n if (current.kind === "room") await clearResume(current.room.code);\n if (token !== operation || disposed) throw creaErrore("cancelled", "The operation was cancelled.");\n detach(false);\n current = { kind: "local", id: String(++identifier), mode: request.args.mode, status: "playing" };\n changed();\n return;\n }\n case "room.create":\n await rooms.create(request.args);\n return;\n case "room.join":\n await rooms.join(request.args.code);\n return;\n case "room.watch":\n await rooms.watch(request.args.code);\n return;\n case "room.match": {\n const mode = manifest?.modes.find((m) => m.id === request.args.mode);\n const key = request.args.key ?? mode?.matchmaking?.defaults;\n if (!key) throw creaErrore("invalid_request", "Matchmaking needs a complete key.");\n await rooms.match({ mode: request.args.mode, key });\n return;\n }\n case "voice.join": {\n const room = active(), voice = activeVoice();\n await voice.join();\n if (current.kind !== "room" || current.room !== room) throw creaErrore("session_replaced", "The active session changed.");\n emit();\n return;\n }\n case "voice.mute":\n activeVoice().mute(request.args.muted);\n emit();\n return;\n case "voice.leave":\n activeVoice().leave();\n emit();\n return;\n case "voice.setVolume": {\n const voice = activeVoice();\n if (!voice.peers.some((peer) => peer.id === request.args.playerId)) throw creaErrore("voice_peer_missing", "This voice participant is no longer available.");\n voice.setVolume(request.args.playerId, request.args.volume);\n emit();\n return;\n }\n case "room.ready":\n active().ready(request.args.ready);\n return;\n case "room.role":\n active().setRole(request.args.role);\n return;\n case "room.requestRole":\n await active().requestRole(request.args.role);\n return;\n case "room.team":\n active().setTeam(request.args.team);\n return;\n case "room.start":\n active().start();\n return;\n case "session.cancel":\n cancel();\n return;\n case "session.resume": {\n await run("attaching", null, async (signal) => {\n await resumeStore?.loaded;\n if (signal.aborted) throw creaErrore("cancelled", "The operation was cancelled.");\n if (!resumeStore?.value) throw creaErrore("no_resume", "There is no saved room.");\n return direct.join(resumeStore.value.code);\n });\n return;\n }\n case "session.disconnect": {\n cancel();\n const token = operation;\n if (current.kind === "room" && current.room.connection !== "ended" && resumeStore) await resumeStore.set({ version: 1, code: current.room.code, mode: current.room.mode, updatedAt: base.time.now() });\n if (token !== operation || disposed) throw creaErrore("cancelled", "The operation was cancelled.");\n detach(true);\n return;\n }\n case "session.leave": {\n cancel();\n const token = operation;\n if (current.kind === "room") await clearResume(current.room.code);\n if (token !== operation || disposed) throw creaErrore("cancelled", "The operation was cancelled.");\n detach(false);\n return;\n }\n }\n },\n dispose() {\n cancel();\n detach(true);\n disposed = true;\n listeners.clear();\n viewListeners.clear();\n stateListeners.clear();\n openListeners.clear();\n scoreListeners.clear();\n errorListeners.clear();\n }\n };\n}\n\n// src/overlay/shortcut.ts\nfunction bindOverlayShortcut(target, overlay, open) {\n let enabled = true, blocked = false;\n const stop = overlay.onChange((view) => {\n enabled = view.shortcutEnabled !== false;\n blocked = view.inputBlocked;\n });\n const listener = (event) => {\n const element = event.target;\n if (!enabled || blocked || event.repeat || event.key !== "Tab" || !event.shiftKey || event.ctrlKey || event.altKey || event.metaKey || element?.closest?.(\'input,textarea,select,[contenteditable="true"]\')) return;\n event.preventDefault();\n event.stopImmediatePropagation();\n open();\n };\n target.addEventListener("keydown", listener, true);\n return () => {\n stop();\n target.removeEventListener("keydown", listener, true);\n };\n}\n\n// src/overlay/bridge.ts\nfunction attachKitBridge(port, hello, coordinator) {\n let disposed = false, seq = 0, highestRequest = 0, activeRequests = 0;\n const replies = /* @__PURE__ */ new Map();\n const send = (message) => {\n if (!disposed) try {\n port.postMessage(message);\n } catch {\n }\n };\n const stops = [\n ...hello.configuration.manifest.overlay && typeof window !== "undefined" ? [bindOverlayShortcut(window, coordinator.overlay, () => send({ type: "caisual:overlay-shortcut", v: 1, epoch: hello.epoch }))] : [],\n coordinator.onState((state) => send({ type: "caisual:overlay-state", v: 1, epoch: hello.epoch, seq: ++seq, serverTime: coordinator.serverTime(), state })),\n coordinator.onOpen((panel) => send({ type: "caisual:overlay-open", v: 1, epoch: hello.epoch, panel })),\n coordinator.onError(({ sessionId, error }) => send({ type: "caisual:overlay-error", v: 1, epoch: hello.epoch, sessionId, error })),\n coordinator.onScore((score) => send({ type: "caisual:overlay-score", v: 1, epoch: hello.epoch, score }))\n ];\n const listener = (event) => {\n const raw = record(event.data);\n if (raw?.type !== "caisual:overlay" || raw.epoch !== hello.epoch || disposed) return;\n const reply = { type: "caisual:overlay-response", v: 1, epoch: hello.epoch, requestId: typeof raw.requestId === "string" ? raw.requestId : "" };\n if (!validOverlayRequest(raw)) {\n send({ ...reply, ok: false, error: { code: "invalid_request", message: "The overlay request is invalid." } });\n return;\n }\n const fingerprint = JSON.stringify([raw.op, raw.args, raw.sessionId]);\n const previous = replies.get(raw.requestId);\n if (previous) {\n if (previous.fingerprint !== fingerprint) send({ ...reply, ok: false, error: { code: "duplicate_request", message: "The request id was already used." } });\n else void previous.response.then(send);\n return;\n }\n if (Number(raw.requestId) <= highestRequest || activeRequests >= 32) {\n send({ ...reply, ok: false, error: { code: "stale_request", message: "The request is stale or too many requests are pending." } });\n return;\n }\n highestRequest = Number(raw.requestId);\n activeRequests++;\n const response = Promise.resolve().then(() => coordinator.execute(raw)).then(\n () => ({ ...reply, ok: true }),\n (error) => ({ ...reply, ok: false, error: {\n code: typeof record(error)?.code === "string" ? record(error).code : "internal_error",\n message: error instanceof Error ? error.message : "The operation could not be completed."\n } })\n );\n replies.set(raw.requestId, { fingerprint, response });\n void response.then((value) => {\n activeRequests--;\n send(value);\n if (replies.size > 64) for (const id of replies.keys()) {\n if (Number(id) < highestRequest - 64) replies.delete(id);\n }\n });\n };\n port.addEventListener("message", listener);\n port.start();\n return () => {\n disposed = true;\n port.removeEventListener("message", listener);\n stops.forEach((stop) => stop());\n coordinator.dispose();\n replies.clear();\n };\n}\n\n// src/http.ts\nasync function leggiErrore(response) {\n let corpo = {};\n try {\n corpo = await response.json();\n } catch {\n }\n return creaErrore(\n typeof corpo.error?.code === "string" ? corpo.error.code : response.status === 401 ? "invalid_ticket" : "internal_error",\n typeof corpo.error?.message === "string" ? corpo.error.message : `The request failed with status ${response.status}.`\n );\n}\nfunction creaRichiedente(origin, prefix, fetcher, biglietto) {\n async function manda(path, metodo, ticket, corpo) {\n const headers = new Headers({ Authorization: `Bearer ${ticket}` });\n let body;\n if (corpo !== void 0) {\n headers.set("Content-Type", "application/json");\n try {\n body = JSON.stringify(corpo);\n } catch {\n throw creaErrore("invalid_request", "The value must be valid JSON.");\n }\n }\n try {\n return await fetcher(new URL(prefix + path, origin), {\n method: metodo,\n headers,\n body,\n credentials: "omit"\n });\n } catch {\n throw erroreOffline();\n }\n }\n return async function richiesta(path, metodo, corpo, forzaRinnovo = false) {\n let ticket;\n try {\n ticket = forzaRinnovo ? await biglietto.rinnova() : await biglietto.ottieni();\n } catch {\n throw erroreOffline();\n }\n let response = await manda(path, metodo, ticket, corpo);\n if (response.status === 401) {\n try {\n ticket = await biglietto.rinnova();\n } catch {\n throw erroreOffline();\n }\n response = await manda(path, metodo, ticket, corpo);\n }\n if (!response.ok) throw await leggiErrore(response);\n try {\n return await response.json();\n } catch {\n throw creaErrore("internal_error", "The service returned an invalid response.");\n }\n };\n}\n\n// src/api.ts\nfunction creaClienteApi(appOrigin, fetcher, biglietto) {\n const richiesta = creaRichiedente(appOrigin, "/api/kit", fetcher, biglietto);\n return {\n me: () => richiesta("/me", "GET"),\n saveSet: (key, value) => richiesta(`/saves/${encodeURIComponent(key)}`, "PUT", { value }),\n async saveGet(key) {\n try {\n return (await richiesta(`/saves/${encodeURIComponent(key)}`, "GET")).value;\n } catch (errore) {\n if (codiceErrore(errore) === "not_found") return null;\n throw errore;\n }\n },\n async saveRemove(key) {\n await richiesta(`/saves/${encodeURIComponent(key)}`, "DELETE");\n },\n async saveList() {\n return (await richiesta("/saves", "GET")).saves;\n },\n async boardSubmit(board, score, daily) {\n const risultato = await richiesta("/scores", "POST", { board, score, daily });\n return {\n accepted: true,\n best: risultato.best,\n rank: risultato.rank,\n day: risultato.day,\n verified: risultato.verified\n };\n },\n async boardTop(board, opzioni) {\n if (opzioni.day !== void 0 && (!validBoardDay(opzioni.day) || opzioni.daily === false)) throw creaErrore("invalid_request", "day must be a real UTC date and cannot be combined with daily: false.");\n const query = new URLSearchParams();\n if (opzioni.day !== void 0) query.set("day", opzioni.day);\n if (opzioni.daily) query.set("daily", "1");\n if (opzioni.limit !== void 0) query.set("limit", String(opzioni.limit));\n if (opzioni.guests) query.set("guests", "1");\n const suffisso = query.size === 0 ? "" : `?${query.toString()}`;\n const { day, entries, me } = await richiesta(\n `/scores/${encodeURIComponent(board)}${suffisso}`,\n "GET"\n );\n return { day, entries, me };\n }\n };\n}\n\n// src/daily.ts\nvar DIVISORE_UINT32 = 4294967296;\nfunction giornoUtc(ora) {\n return new Date(ora).toISOString().slice(0, 10);\n}\nasync function calcolaSeed(gioco, giorno, subtle) {\n const dati = new TextEncoder().encode(`caisual:${gioco}:${giorno}`);\n const digest = new Uint8Array(await subtle.digest("SHA-256", dati));\n return (digest[0] ?? 0) * 16777216 + ((digest[1] ?? 0) << 16) + ((digest[2] ?? 0) << 8) + (digest[3] ?? 0) >>> 0;\n}\nfunction creaMulberry32(seed) {\n let stato = seed >>> 0;\n return () => {\n stato = stato + 1831565813 >>> 0;\n let valore = stato;\n valore = Math.imul(valore ^ valore >>> 15, valore | 1);\n valore ^= valore + Math.imul(valore ^ valore >>> 7, valore | 61);\n return ((valore ^ valore >>> 14) >>> 0) / DIVISORE_UINT32;\n };\n}\n\n// src/handshake.ts\nfunction record2(valore) {\n return typeof valore === "object" && valore !== null && !Array.isArray(valore) ? valore : null;\n}\nfunction eTipo(valore, tipo) {\n return record2(valore)?.type === tipo;\n}\nfunction leggiOrigine(valore) {\n if (typeof valore !== "string") return null;\n try {\n const url = new URL(valore);\n return url.origin === valore && (url.protocol === "https:" || url.protocol === "http:") ? valore : null;\n } catch {\n return null;\n }\n}\nfunction attendiHandshake(finestra, appOrigin, timeoutMs = 3e3) {\n return new Promise((resolve) => {\n let concluso = false;\n const instance = globalThis.crypto.randomUUID();\n const termina = (esito) => {\n if (concluso) return;\n concluso = true;\n finestra.removeEventListener("message", ascolta);\n finestra.clearTimeout(scadenza);\n resolve(esito);\n };\n const segnalaPronto = () => {\n finestra.parent.postMessage({ type: "caisual:ready", instance, overlayVersion: 1 }, appOrigin);\n };\n const ascolta = (evento) => {\n if (evento.origin !== appOrigin || evento.source !== finestra.parent) return;\n if (eTipo(evento.data, "caisual:ready?")) {\n segnalaPronto();\n return;\n }\n if (!eTipo(evento.data, "caisual:hello")) return;\n const dati = record2(evento.data);\n const porta = evento.ports[0];\n if (typeof dati?.ticket !== "string" || porta === void 0) return;\n porta.start();\n const overlay = normalizeOverlayHello(dati.overlay);\n termina({\n ...overlay ? { overlay } : {},\n ticket: dati.ticket,\n live: leggiOrigine(dati.live),\n invite: typeof dati.invite === "string" ? dati.invite : null,\n porta\n });\n };\n finestra.addEventListener("message", ascolta);\n const scadenza = finestra.setTimeout(() => termina(null), timeoutMs);\n segnalaPronto();\n });\n}\nfunction scadenzaJwt(ticket) {\n const parte = ticket.split(".")[1];\n if (parte === void 0) return null;\n const base64 = parte.replace(/-/g, "+").replace(/_/g, "/").padEnd(\n Math.ceil(parte.length / 4) * 4,\n "="\n );\n try {\n const payload = record2(JSON.parse(globalThis.atob(base64)));\n return typeof payload?.exp === "number" && Number.isFinite(payload.exp) ? payload.exp * 1e3 : null;\n } catch {\n return null;\n }\n}\nfunction chiediBiglietto(porta, finestra, timeoutMs, aud) {\n return new Promise((resolve, reject) => {\n let concluso = false;\n const termina = (ticket) => {\n if (concluso) return;\n concluso = true;\n porta.removeEventListener("message", ascolta);\n finestra.clearTimeout(scadenza);\n if (ticket === null) reject(new Error("Ticket refresh timed out."));\n else resolve(ticket);\n };\n const ascolta = (evento) => {\n const dati = record2(evento.data);\n const destinatario = dati?.aud === void 0 ? "portal" : dati.aud;\n if (dati?.type === "caisual:ticket" && destinatario === aud && typeof dati.ticket === "string") {\n termina(dati.ticket);\n }\n };\n porta.addEventListener("message", ascolta);\n const scadenza = finestra.setTimeout(() => termina(null), timeoutMs);\n try {\n porta.postMessage(aud === "live" ? { type: "caisual:ticket", aud: "live" } : { type: "caisual:ticket" });\n } catch {\n termina(null);\n }\n });\n}\nfunction creaGestoreBiglietto(ticketIniziale, porta, finestra, ora, timeoutMs = 3e3, aud = "portal") {\n let ticket = ticketIniziale;\n let rinnovo = null;\n const rinnova = () => {\n if (rinnovo !== null) return rinnovo;\n const richiesta = chiediBiglietto(porta, finestra, timeoutMs, aud).then((nuovo) => {\n ticket = nuovo;\n return nuovo;\n });\n const completa = richiesta.finally(() => {\n if (rinnovo === completa) rinnovo = null;\n });\n rinnovo = completa;\n return completa;\n };\n return {\n async ottieni() {\n if (ticket === null) return rinnova();\n const scadenza = scadenzaJwt(ticket);\n return scadenza !== null && scadenza - ora() < 3e4 ? rinnova() : ticket;\n },\n rinnova\n };\n}\n\n// src/voce/index.ts\nvar SOGLIA_AUDIO = 0.02;\nvar DURATA_PARLANTE = 300;\nvar INTERVALLO_AUDIO = 200;\nvar DURATA_ZERO = 3e3;\nvar TIMEOUT_CONNESSIONE = 1e4;\nvar RITARDI_RICONNESSIONE = [1e3, 2e3, 4e3];\nfunction limita(value) {\n return Number.isNaN(value) ? 1 : Math.min(1, Math.max(0, value));\n}\nfunction dipendenzeReali(input) {\n const globali = globalThis;\n const AudioContextClass = globali.AudioContext ?? globali.webkitAudioContext;\n if (typeof RTCPeerConnection === "undefined" || typeof MediaStream === "undefined" || AudioContextClass === void 0 || typeof navigator === "undefined" || navigator.mediaDevices?.getUserMedia === void 0 || typeof document === "undefined") return null;\n return {\n ...input,\n creaPeerConnection: (configuration) => new RTCPeerConnection(configuration),\n getUserMedia: (constraints) => navigator.mediaDevices.getUserMedia(constraints),\n creaAudioContext: () => new AudioContextClass(),\n creaAudioElement: () => document.createElement("audio"),\n creaMediaStream: (tracks) => new MediaStream(tracks)\n };\n}\nvar VoceClient = class {\n constructor(contesto, timer, dipendenze) {\n this.contesto = contesto;\n this.modeCorrente = "none";\n this.stateCorrente = "off";\n this.mutedCorrente = false;\n this.speakingCorrente = false;\n this.roster = [];\n this.gains = /* @__PURE__ */ new Map();\n this.volumi = /* @__PURE__ */ new Map();\n this.speakingPeers = /* @__PURE__ */ new Map();\n this.ultimoAudio = /* @__PURE__ */ new Map();\n this.zeroDa = /* @__PURE__ */ new Map();\n this.timerZero = /* @__PURE__ */ new Map();\n this.ascoltatoriPeers = /* @__PURE__ */ new Set();\n this.ascoltatoriState = /* @__PURE__ */ new Set();\n this.richieste = /* @__PURE__ */ new Map();\n this.riproduzioni = /* @__PURE__ */ new Map();\n this.sfuAttive = /* @__PURE__ */ new Map();\n this.midGiocatori = /* @__PURE__ */ new Map();\n this.negati = /* @__PURE__ */ new Set();\n this.mesh = /* @__PURE__ */ new Map();\n this.stream = null;\n this.tracciaMic = null;\n this.audioContext = null;\n this.analyser = null;\n this.peerSfu = null;\n this.sessioneSfu = null;\n this.connessioneSfuAttesa = false;\n this.trasporto = null;\n this.intervalloAudio = null;\n this.timerConnessione = null;\n this.cancellaAttesaConnessione = null;\n this.timerRiconnessione = null;\n this.ultimoAudioMic = Number.NEGATIVE_INFINITY;\n this.sequenzaRichieste = 0;\n this.generazione = 0;\n this.tentativoRiconnessione = 0;\n this.desiderata = false;\n this.micDesiderato = true;\n this.promessaIngresso = null;\n this.negoziazione = Promise.resolve();\n this.dipendenze = dipendenze ?? dipendenzeReali(timer);\n }\n get mode() {\n return this.modeCorrente;\n }\n get state() {\n return this.stateCorrente;\n }\n get mic() {\n return this.stateCorrente === "on" && this.tracciaMic !== null;\n }\n get muted() {\n return this.mutedCorrente;\n }\n get speaking() {\n return this.speakingCorrente;\n }\n get peers() {\n return this.copiaPeers();\n }\n async join(options = {}) {\n if (this.stateCorrente === "on") return;\n if (this.stateCorrente === "joining") {\n if (this.promessaIngresso !== null) await this.promessaIngresso;\n return;\n }\n if (this.stateCorrente === "reconnecting" && this.desiderata) return;\n const mic = this.scegliMic(options);\n this.verificaIngresso(mic);\n this.micDesiderato = mic;\n this.desiderata = true;\n this.tentativoRiconnessione = 0;\n this.aggiornaState("joining");\n const generazione = ++this.generazione;\n const promessa = this.completaIngresso(generazione);\n this.promessaIngresso = promessa;\n try {\n await promessa;\n } finally {\n if (this.promessaIngresso === promessa) this.promessaIngresso = null;\n }\n }\n async completaIngresso(generazione) {\n try {\n await this.entra(generazione);\n } catch (cause) {\n if (generazione !== this.generazione) return;\n this.desiderata = false;\n this.chiudiRisorse();\n this.aggiornaState("off");\n throw this.mappaErrore(cause);\n }\n }\n leave() {\n const deveFermare = this.desiderata || this.stateCorrente !== "off";\n this.desiderata = false;\n this.generazione++;\n this.fermaRiconnessione();\n if (deveFermare && this.contesto.connessa()) {\n void this.richiedi({ t: "voice", op: "stop" }).catch(() => void 0);\n }\n this.rifiutaRichieste(creaErrore("offline", "Voice has stopped."));\n this.chiudiRisorse();\n this.aggiornaState("off");\n }\n mute(muted = true) {\n if (this.stateCorrente !== "on" || this.tracciaMic === null) {\n throw creaErrore("not_publishing", "Join voice before changing mute.");\n }\n this.mutedCorrente = muted;\n this.tracciaMic.enabled = !muted;\n if (muted) this.speakingCorrente = false;\n this.notificaPeers();\n void this.richiedi({ t: "voice", op: "mute", muted }).catch(() => void 0);\n }\n setVolume(playerId, volume) {\n const valore = limita(volume);\n this.volumi.set(playerId, valore);\n this.aggiornaGuadagno(playerId);\n this.notificaPeers();\n }\n onPeers(listener) {\n this.ascoltatoriPeers.add(listener);\n return () => {\n this.ascoltatoriPeers.delete(listener);\n };\n }\n onState(listener) {\n this.ascoltatoriState.add(listener);\n return () => {\n this.ascoltatoriState.delete(listener);\n };\n }\n ricevi(message) {\n if ("r" in message) {\n const pending = this.richieste.get(message.r);\n if (pending !== void 0) {\n this.richieste.delete(message.r);\n if ("error" in message) {\n pending.reject(creaErrore(message.error.code, message.error.message));\n } else pending.resolve(message);\n }\n return;\n }\n if (message.op === "roster") {\n this.negati.clear();\n this.modeCorrente = message.mode;\n const publisher = new Set(message.peers.map((peer) => peer.id));\n this.roster = [\n ...message.peers.map((peer) => ({ ...peer, mic: true })),\n ...message.listeners.flatMap((id) => publisher.has(id) ? [] : [{ id, mic: false, muted: true }])\n ];\n for (const peer of this.roster) {\n if (peer.muted) this.speakingPeers.set(peer.id, false);\n }\n this.pulisciPeerAssenti();\n this.contesto.rosterPronto();\n this.notificaPeers();\n this.accodaRiconciliazione();\n return;\n }\n if (message.op === "gain") {\n this.negati.clear();\n for (const [playerId, gain] of Object.entries(message.gains)) {\n this.gains.set(playerId, limita(gain));\n this.aggiornaZero(playerId);\n this.aggiornaGuadagno(playerId);\n }\n this.notificaPeers();\n this.accodaRiconciliazione();\n return;\n }\n if (message.op === "closed") {\n for (const mid of message.mids) {\n const playerId = this.midGiocatori.get(mid);\n if (playerId === void 0) continue;\n const attiva = this.sfuAttive.get(playerId);\n if (attiva?.mid === mid && !this.riproduzioni.has(playerId)) attiva.receiver?.track.stop();\n if (attiva?.mid === mid) this.sfuAttive.delete(playerId);\n this.midGiocatori.delete(mid);\n this.scollegaTraccia(playerId);\n this.negati.add(playerId);\n }\n this.notificaPeers();\n return;\n }\n if (message.op === "signal") void this.riceviSegnale(message.from, message.data);\n }\n giocatoriCambiati() {\n this.negati.clear();\n const presenti = new Set(this.contesto.giocatori().map((player) => player.id));\n for (const playerId of this.gains.keys()) {\n if (presenti.has(playerId)) continue;\n this.gains.delete(playerId);\n this.zeroDa.delete(playerId);\n const timer = this.timerZero.get(playerId);\n if (timer !== void 0) this.dipendenze?.clearTimeout(timer);\n this.timerZero.delete(playerId);\n this.aggiornaGuadagno(playerId);\n }\n this.notificaPeers();\n this.accodaRiconciliazione();\n }\n socketDisconnesso() {\n this.sequenzaRichieste = 0;\n this.rifiutaRichieste(creaErrore("offline", "The room is reconnecting."));\n if (!this.desiderata) return;\n this.generazione++;\n this.chiudiRisorse();\n this.tentativoRiconnessione = 0;\n this.aggiornaState("reconnecting");\n }\n socketRiconnesso() {\n this.sequenzaRichieste = 0;\n if (this.desiderata && this.stateCorrente === "reconnecting") this.programmaRiconnessione();\n }\n termina() {\n this.desiderata = false;\n this.generazione++;\n this.fermaRiconnessione();\n this.rifiutaRichieste(creaErrore("offline", "The room connection ended."));\n this.chiudiRisorse();\n this.aggiornaState("off");\n }\n scegliMic(options) {\n if (options.mic !== void 0) return options.mic;\n const you = this.contesto.giocatori().find((player) => player.id === this.contesto.you());\n return you?.role !== "spectator";\n }\n verificaIngresso(mic = this.micDesiderato) {\n if (!this.contesto.connessa()) throw creaErrore("offline", "The room is not connected.");\n if (this.modeCorrente === "none") {\n throw creaErrore("voice_disabled", "Voice is disabled for this room.");\n }\n const you = this.contesto.giocatori().find((player) => player.id === this.contesto.you());\n if (you?.role === "spectator" && mic) {\n throw creaErrore("spectator", "Spectators cannot publish voice.");\n }\n if (this.dipendenze === null) {\n throw creaErrore("unsupported", "Voice is not supported in this browser.");\n }\n }\n async entra(generazione) {\n this.verificaIngresso();\n const dipendenze = this.richiediDipendenze();\n const audioContext = dipendenze.creaAudioContext();\n this.audioContext = audioContext;\n if (this.micDesiderato) {\n let stream;\n try {\n stream = await dipendenze.getUserMedia({ audio: true });\n } catch (cause) {\n if (this.permessoNegato(cause)) {\n throw creaErrore("permission_denied", "Microphone permission was denied.");\n }\n throw creaErrore("voice_error", "The microphone could not be opened.");\n }\n try {\n this.controllaGenerazione(generazione);\n } catch (cause) {\n for (const track of stream.getTracks()) track.stop();\n throw cause;\n }\n const mic = stream.getAudioTracks()[0];\n if (mic === void 0) throw creaErrore("voice_error", "The microphone has no audio track.");\n this.stream = stream;\n this.tracciaMic = mic;\n mic.enabled = !this.mutedCorrente;\n this.preparaAnalizzatore(stream);\n }\n try {\n await audioContext.resume();\n } catch {\n }\n this.controllaGenerazione(generazione);\n const risposta = await this.richiedi({ t: "voice", op: "ice" });\n this.controllaGenerazione(generazione);\n if (risposta.op !== "ice") throw creaErrore("voice_error", "The voice service returned an invalid response.");\n this.modeCorrente = risposta.mode;\n if (risposta.mode === "none") throw creaErrore("voice_disabled", "Voice is disabled for this room.");\n this.trasporto = risposta.transport;\n if (risposta.transport === "sfu") {\n await this.entraSfu(risposta.iceServers, generazione);\n } else {\n await this.richiedi({ t: "voice", op: "publish", mic: this.micDesiderato });\n }\n if (this.micDesiderato && this.mutedCorrente) {\n await this.richiedi({ t: "voice", op: "mute", muted: true });\n }\n this.controllaGenerazione(generazione);\n this.tentativoRiconnessione = 0;\n this.aggiornaState("on");\n this.avviaMisuraAudio();\n for (const playerId of this.gains.keys()) this.aggiornaZero(playerId);\n this.accodaRiconciliazione();\n }\n async entraSfu(iceServers, generazione) {\n const pc = this.richiediDipendenze().creaPeerConnection({\n iceServers,\n bundlePolicy: "max-bundle"\n });\n this.peerSfu = pc;\n pc.ontrack = (event) => {\n const mid = event.transceiver.mid;\n const playerId = mid === null ? void 0 : this.midGiocatori.get(mid);\n if (playerId !== void 0) this.collegaTraccia(playerId, event.track, event.receiver);\n };\n this.osservaCaduta(pc);\n let risposta;\n if (this.micDesiderato) {\n const transceiver = pc.addTransceiver(this.richiediMic(), { direction: "sendonly" });\n const offer = await pc.createOffer();\n await pc.setLocalDescription(offer);\n this.controllaGenerazione(generazione);\n const mid = transceiver.mid;\n const sdp = pc.localDescription?.sdp;\n if (mid === null || sdp === void 0) {\n throw creaErrore("voice_error", "The voice connection could not create an offer.");\n }\n risposta = await this.richiedi({ t: "voice", op: "session", sdp, mid });\n } else {\n risposta = await this.richiedi({ t: "voice", op: "session" });\n }\n if (risposta.op !== "session") {\n throw creaErrore("voice_error", "The voice service returned an invalid response.");\n }\n this.sessioneSfu = risposta.session;\n if (this.micDesiderato) {\n if (risposta.sdp === null) {\n throw creaErrore("voice_error", "The voice service returned an invalid response.");\n }\n await pc.setRemoteDescription({ type: "answer", sdp: risposta.sdp });\n await this.attendiConnessione(pc, generazione);\n this.connessioneSfuAttesa = true;\n return;\n }\n if (risposta.sdp !== null) {\n throw creaErrore("voice_error", "The voice service returned an invalid response.");\n }\n if (this.publisherDesiderati().length > 0) {\n await this.riconciliaSfu();\n }\n }\n attendiConnessione(pc, generazione) {\n if (pc.connectionState === "connected") return Promise.resolve();\n const dipendenze = this.richiediDipendenze();\n return new Promise((resolve, reject) => {\n const pulisci = () => {\n pc.removeEventListener("connectionstatechange", cambiata);\n if (this.timerConnessione !== null) dipendenze.clearTimeout(this.timerConnessione);\n this.timerConnessione = null;\n this.cancellaAttesaConnessione = null;\n };\n const cambiata = () => {\n if (generazione !== this.generazione) {\n pulisci();\n reject(creaErrore("offline", "Voice was stopped."));\n } else if (pc.connectionState === "connected") {\n pulisci();\n resolve();\n } else if (pc.connectionState === "failed" || pc.connectionState === "closed") {\n pulisci();\n reject(creaErrore("voice_error", "The voice connection failed."));\n }\n };\n pc.addEventListener("connectionstatechange", cambiata);\n this.cancellaAttesaConnessione = () => {\n pulisci();\n reject(creaErrore("offline", "Voice was stopped."));\n };\n this.timerConnessione = dipendenze.setTimeout(() => {\n pulisci();\n reject(creaErrore("voice_error", "The voice connection timed out."));\n }, TIMEOUT_CONNESSIONE);\n });\n }\n accodaRiconciliazione() {\n if (this.stateCorrente !== "on") return;\n this.negoziazione = this.negoziazione.then(async () => {\n if (this.stateCorrente !== "on") return;\n if (this.trasporto === "sfu") await this.riconciliaSfu();\n else if (this.trasporto === "mesh") this.riconciliaMesh();\n }).catch(() => this.avviaRiconnessione());\n }\n async riconciliaSfu() {\n const sessione = this.sessioneSfu;\n const pc = this.peerSfu;\n if (sessione === null || pc === null) return;\n const desiderati = new Map(this.publisherDesiderati().map((peer) => [peer.id, peer]));\n const daChiudere = [];\n for (const [playerId, attiva] of this.sfuAttive) {\n const peer = desiderati.get(playerId);\n if (peer !== void 0 && peer.session === attiva.session && peer.track === attiva.track) continue;\n daChiudere.push(attiva);\n if (!this.riproduzioni.has(playerId)) attiva.receiver?.track.stop();\n this.sfuAttive.delete(playerId);\n this.midGiocatori.delete(attiva.mid);\n this.scollegaTraccia(playerId);\n }\n if (daChiudere.length > 0) {\n await this.richiedi({\n t: "voice",\n op: "close",\n session: sessione,\n mids: daChiudere.map((item) => item.mid)\n });\n }\n const nuove = [...desiderati.values()].filter((peer) => !this.sfuAttive.has(peer.id));\n if (nuove.length === 0) return;\n let risposta;\n try {\n risposta = await this.richiedi({\n t: "voice",\n op: "subscribe",\n session: sessione,\n tracks: nuove.map((peer) => ({ session: peer.session, track: peer.track }))\n });\n } catch (cause) {\n if (codiceErrore(cause) !== "not_allowed") throw cause;\n for (const peer of nuove) this.negati.add(peer.id);\n return;\n }\n if (risposta.op !== "subscribe") {\n throw creaErrore("voice_error", "The voice service returned an invalid response.");\n }\n for (const risultato of risposta.tracks) {\n const peer = nuove.find(\n (item) => item.session === risultato.session && item.track === risultato.track\n );\n if (risultato.error === "not_allowed" && peer !== void 0) this.negati.add(peer.id);\n if (risultato?.mid === null || risultato?.mid === void 0 || risultato.error !== null || peer === void 0) continue;\n this.midGiocatori.set(risultato.mid, peer.id);\n this.sfuAttive.set(peer.id, {\n session: peer.session,\n track: peer.track,\n mid: risultato.mid,\n receiver: null\n });\n }\n await pc.setRemoteDescription({ type: "offer", sdp: risposta.sdp });\n const answer = await pc.createAnswer();\n await pc.setLocalDescription(answer);\n const sdp = pc.localDescription?.sdp;\n if (sdp === void 0) throw creaErrore("voice_error", "The voice answer is missing.");\n await this.richiedi({ t: "voice", op: "answer", session: sessione, sdp });\n if (!this.connessioneSfuAttesa) {\n await this.attendiConnessione(pc, this.generazione);\n this.connessioneSfuAttesa = true;\n }\n }\n riconciliaMesh() {\n const desiderati = new Map(this.peerDesiderati().map((peer) => [peer.id, peer]));\n for (const [playerId, item] of this.mesh) {\n if (desiderati.has(playerId)) continue;\n item.pc.close();\n this.mesh.delete(playerId);\n this.scollegaTraccia(playerId);\n }\n for (const peer of desiderati.values()) {\n if (!this.mesh.has(peer.id)) this.creaMesh(peer);\n }\n }\n creaMesh(peer) {\n const playerId = peer.id;\n const pc = this.richiediDipendenze().creaPeerConnection();\n const item = {\n pc,\n makingOffer: false,\n ignoreOffer: false,\n settingRemoteAnswer: false,\n polite: this.contesto.you() > playerId,\n receiver: null\n };\n this.mesh.set(playerId, item);\n pc.onicecandidate = (event) => {\n if (event.candidate === null) return;\n void this.inviaSegnale(playerId, { kind: "candidate", candidate: event.candidate.toJSON() });\n };\n if (!item.polite) pc.onnegotiationneeded = () => {\n void this.offriMesh(playerId, item);\n };\n pc.ontrack = (event) => {\n item.receiver = event.receiver;\n this.collegaTraccia(playerId, event.track, event.receiver);\n };\n this.osservaCaduta(pc);\n if (this.micDesiderato) {\n pc.addTransceiver(this.richiediMic(), {\n direction: peer.mic ? "sendrecv" : "sendonly"\n });\n } else {\n pc.addTransceiver("audio", { direction: "recvonly" });\n }\n }\n async offriMesh(playerId, item) {\n try {\n item.makingOffer = true;\n const offer = await item.pc.createOffer();\n await item.pc.setLocalDescription(offer);\n const sdp = item.pc.localDescription?.sdp;\n if (sdp !== void 0) await this.inviaSegnale(playerId, { kind: "offer", sdp });\n } finally {\n item.makingOffer = false;\n }\n }\n async riceviSegnale(from, data) {\n if (this.trasporto !== "mesh" || this.stateCorrente !== "on") return;\n const peer = this.peerDesiderati().find((item2) => item2.id === from);\n if (peer === void 0) return;\n if (!this.mesh.has(from)) this.creaMesh(peer);\n const item = this.mesh.get(from);\n if (item === void 0 || typeof data !== "object" || data === null || Array.isArray(data)) return;\n const segnale = data;\n try {\n if (segnale.kind === "candidate") {\n if (!item.ignoreOffer) await item.pc.addIceCandidate(segnale.candidate);\n return;\n }\n if (segnale.kind !== "offer" && segnale.kind !== "answer" || typeof segnale.sdp !== "string") return;\n const pronta = !item.makingOffer && (item.pc.signalingState === "stable" || item.settingRemoteAnswer);\n const collisione = segnale.kind === "offer" && !pronta;\n item.ignoreOffer = !item.polite && collisione;\n if (item.ignoreOffer) return;\n item.settingRemoteAnswer = segnale.kind === "answer";\n await item.pc.setRemoteDescription({ type: segnale.kind, sdp: segnale.sdp });\n item.settingRemoteAnswer = false;\n if (segnale.kind === "offer") {\n const answer = await item.pc.createAnswer();\n await item.pc.setLocalDescription(answer);\n const sdp = item.pc.localDescription?.sdp;\n if (sdp !== void 0) await this.inviaSegnale(from, { kind: "answer", sdp });\n }\n } catch {\n this.avviaRiconnessione();\n }\n }\n async inviaSegnale(to, data) {\n try {\n await this.richiedi({ t: "voice", op: "signal", to, data });\n } catch (cause) {\n if (codiceErrore(cause) !== "not_allowed") throw cause;\n const item = this.mesh.get(to);\n item?.pc.close();\n this.mesh.delete(to);\n this.scollegaTraccia(to);\n this.negati.add(to);\n }\n }\n peerDesiderati() {\n const you = this.contesto.you();\n const giocatori = this.contesto.giocatori();\n const player = giocatori.find((item) => item.id === you);\n return this.roster.filter((peer) => {\n if (peer.id === you) return false;\n if (this.negati.has(peer.id)) return false;\n if (!this.micDesiderato && !peer.mic) return false;\n if (this.modeCorrente === "team") {\n const altro = giocatori.find((item) => item.id === peer.id);\n if (player?.role !== "spectator" && altro?.team !== player?.team) return false;\n }\n return true;\n });\n }\n publisherDesiderati() {\n return this.peerDesiderati().filter(\n (peer) => {\n if (!peer.mic) return false;\n const zeroAt = this.zeroDa.get(peer.id);\n return zeroAt === void 0 || this.richiediDipendenze().ora() - zeroAt < DURATA_ZERO;\n }\n );\n }\n aggiornaZero(playerId) {\n const dipendenze = this.dipendenze;\n if (dipendenze === null) return;\n const precedente = this.timerZero.get(playerId);\n if (precedente !== void 0) dipendenze.clearTimeout(precedente);\n this.timerZero.delete(playerId);\n if ((this.gains.get(playerId) ?? 1) > 0) {\n this.zeroDa.delete(playerId);\n return;\n }\n if (!this.zeroDa.has(playerId)) this.zeroDa.set(playerId, dipendenze.ora());\n const trascorso = dipendenze.ora() - (this.zeroDa.get(playerId) ?? dipendenze.ora());\n const timer = dipendenze.setTimeout(() => {\n this.timerZero.delete(playerId);\n this.accodaRiconciliazione();\n }, Math.max(0, DURATA_ZERO - trascorso));\n this.timerZero.set(playerId, timer);\n }\n collegaTraccia(playerId, track, receiver) {\n this.scollegaTraccia(playerId);\n const dipendenze = this.richiediDipendenze();\n const media = dipendenze.creaMediaStream([track]);\n const source = this.richiediAudioContext().createMediaStreamSource(media);\n const gain = this.richiediAudioContext().createGain();\n source.connect(gain);\n gain.connect(this.richiediAudioContext().destination);\n let analyser = null;\n try {\n analyser = this.richiediAudioContext().createAnalyser();\n analyser.fftSize = 256;\n source.connect(analyser);\n } catch {\n analyser = null;\n }\n const audio = dipendenze.creaAudioElement();\n audio.srcObject = media;\n audio.muted = true;\n audio.playsInline = true;\n void audio.play().catch(() => void 0);\n this.riproduzioni.set(playerId, { source, gain, analyser, audio, track, receiver });\n const attiva = this.sfuAttive.get(playerId);\n if (attiva !== void 0) attiva.receiver = receiver;\n this.aggiornaGuadagno(playerId);\n }\n scollegaTraccia(playerId) {\n const riproduzione = this.riproduzioni.get(playerId);\n if (riproduzione === void 0) return;\n riproduzione.source.disconnect();\n riproduzione.gain.disconnect();\n riproduzione.analyser?.disconnect();\n riproduzione.track.stop();\n riproduzione.audio.pause();\n riproduzione.audio.srcObject = null;\n this.riproduzioni.delete(playerId);\n this.speakingPeers.delete(playerId);\n this.ultimoAudio.delete(playerId);\n }\n aggiornaGuadagno(playerId) {\n const riproduzione = this.riproduzioni.get(playerId);\n if (riproduzione !== void 0) {\n riproduzione.gain.gain.value = (this.volumi.get(playerId) ?? 1) * (this.gains.get(playerId) ?? 1);\n }\n }\n preparaAnalizzatore(stream) {\n const context = this.richiediAudioContext();\n const analyser = context.createAnalyser();\n analyser.fftSize = 256;\n context.createMediaStreamSource(stream).connect(analyser);\n this.analyser = analyser;\n }\n avviaMisuraAudio() {\n const dipendenze = this.richiediDipendenze();\n if (this.intervalloAudio !== null) dipendenze.clearInterval(this.intervalloAudio);\n this.intervalloAudio = dipendenze.setInterval(() => this.misuraAudio(), INTERVALLO_AUDIO);\n }\n misuraAudio() {\n const dipendenze = this.dipendenze;\n if (dipendenze === null) return;\n let sopraSoglia = false;\n if (this.analyser !== null) sopraSoglia = this.livelloAnalizzatore(this.analyser) > SOGLIA_AUDIO;\n if (sopraSoglia) this.ultimoAudioMic = dipendenze.ora();\n const parlando = !this.mutedCorrente && dipendenze.ora() - this.ultimoAudioMic <= DURATA_PARLANTE;\n if (parlando !== this.speakingCorrente) {\n this.speakingCorrente = parlando;\n this.notificaPeers();\n }\n let cambiato = false;\n for (const peer of this.copiaPeers()) {\n const riproduzione = this.riproduzioni.get(peer.id);\n if (this.livelloAnalizzatore(riproduzione?.analyser ?? null) > SOGLIA_AUDIO) {\n this.ultimoAudio.set(peer.id, dipendenze.ora());\n } else if (riproduzione?.analyser === null || riproduzione?.analyser === void 0) {\n const sources = riproduzione?.receiver?.getSynchronizationSources?.() ?? [];\n if (sources.some((source) => (source.audioLevel ?? 0) > SOGLIA_AUDIO)) {\n this.ultimoAudio.set(peer.id, dipendenze.ora());\n }\n }\n const speaking = !peer.muted && dipendenze.ora() - (this.ultimoAudio.get(peer.id) ?? 0) <= DURATA_PARLANTE;\n if ((this.speakingPeers.get(peer.id) ?? false) !== speaking) {\n this.speakingPeers.set(peer.id, speaking);\n cambiato = true;\n }\n }\n if (cambiato) this.notificaPeers();\n }\n livelloAnalizzatore(analyser) {\n const nodo = analyser;\n if (nodo?.getFloatTimeDomainData === void 0) return 0;\n const campioni = new Float32Array(nodo.fftSize);\n nodo.getFloatTimeDomainData(campioni);\n return Math.sqrt(campioni.reduce((somma, valore) => somma + valore * valore, 0) / Math.max(1, campioni.length));\n }\n copiaPeers() {\n const you = this.contesto.you();\n const giocatori = this.contesto.giocatori();\n const player = giocatori.find((item) => item.id === you);\n return this.roster.flatMap((peer) => {\n if (peer.id === you) return [];\n if (this.modeCorrente === "team") {\n const altro = giocatori.find((item) => item.id === peer.id);\n if (player?.role !== "spectator" && altro?.team !== player?.team) return [];\n }\n return [{\n id: peer.id,\n mic: peer.mic,\n muted: peer.muted,\n speaking: peer.mic && !peer.muted && (this.speakingPeers.get(peer.id) ?? false),\n volume: this.volumi.get(peer.id) ?? 1,\n gain: this.gains.get(peer.id) ?? 1\n }];\n });\n }\n pulisciPeerAssenti() {\n const presenti = new Set(this.roster.map((peer) => peer.id));\n for (const playerId of this.speakingPeers.keys()) {\n if (!presenti.has(playerId)) this.speakingPeers.delete(playerId);\n }\n for (const playerId of this.zeroDa.keys()) {\n if (presenti.has(playerId)) continue;\n this.zeroDa.delete(playerId);\n const timer = this.timerZero.get(playerId);\n if (timer !== void 0) this.dipendenze?.clearTimeout(timer);\n this.timerZero.delete(playerId);\n }\n }\n osservaCaduta(pc) {\n pc.addEventListener("connectionstatechange", () => {\n if (this.stateCorrente === "on" && (pc.connectionState === "failed" || pc.connectionState === "disconnected")) this.avviaRiconnessione();\n });\n }\n avviaRiconnessione() {\n if (!this.desiderata || this.stateCorrente === "reconnecting") return;\n this.generazione++;\n this.rifiutaRichieste(creaErrore("voice_error", "The voice connection was restarted."));\n this.chiudiRisorse();\n this.tentativoRiconnessione = 0;\n this.aggiornaState("reconnecting");\n this.programmaRiconnessione();\n }\n programmaRiconnessione() {\n if (!this.desiderata || !this.contesto.connessa() || this.timerRiconnessione !== null || this.stateCorrente !== "reconnecting") return;\n const ritardo = RITARDI_RICONNESSIONE[this.tentativoRiconnessione];\n if (ritardo === void 0) {\n this.desiderata = false;\n this.aggiornaState("off");\n return;\n }\n this.tentativoRiconnessione++;\n this.timerRiconnessione = this.richiediDipendenze().setTimeout(() => {\n this.timerRiconnessione = null;\n const generazione = ++this.generazione;\n void this.entra(generazione).catch(() => {\n if (generazione !== this.generazione || !this.desiderata) return;\n this.chiudiRisorse();\n this.aggiornaState("reconnecting");\n this.programmaRiconnessione();\n });\n }, ritardo);\n }\n fermaRiconnessione() {\n if (this.timerRiconnessione === null || this.dipendenze === null) return;\n this.dipendenze.clearTimeout(this.timerRiconnessione);\n this.timerRiconnessione = null;\n }\n chiudiRisorse() {\n const dipendenze = this.dipendenze;\n this.cancellaAttesaConnessione?.();\n this.cancellaAttesaConnessione = null;\n if (dipendenze !== null) {\n if (this.intervalloAudio !== null) dipendenze.clearInterval(this.intervalloAudio);\n if (this.timerConnessione !== null) dipendenze.clearTimeout(this.timerConnessione);\n for (const timer of this.timerZero.values()) dipendenze.clearTimeout(timer);\n }\n this.intervalloAudio = null;\n this.timerConnessione = null;\n this.timerZero.clear();\n for (const playerId of [...this.riproduzioni.keys()]) this.scollegaTraccia(playerId);\n this.peerSfu?.close();\n this.peerSfu = null;\n for (const item of this.mesh.values()) item.pc.close();\n this.mesh.clear();\n this.sfuAttive.clear();\n this.midGiocatori.clear();\n this.negati.clear();\n for (const track of this.stream?.getTracks() ?? []) track.stop();\n this.stream = null;\n this.tracciaMic = null;\n this.analyser = null;\n void this.audioContext?.close().catch(() => void 0);\n this.audioContext = null;\n this.sessioneSfu = null;\n this.connessioneSfuAttesa = false;\n this.trasporto = null;\n this.speakingCorrente = false;\n this.ultimoAudioMic = Number.NEGATIVE_INFINITY;\n this.speakingPeers.clear();\n this.ultimoAudio.clear();\n this.negoziazione = Promise.resolve();\n }\n richiedi(message) {\n if (!this.contesto.connessa()) return Promise.reject(creaErrore("offline", "The room is reconnecting."));\n const r = ++this.sequenzaRichieste;\n return new Promise((resolve, reject) => {\n this.richieste.set(r, { resolve, reject });\n try {\n this.contesto.invia({ ...message, r });\n } catch (cause) {\n this.richieste.delete(r);\n reject(cause);\n }\n });\n }\n rifiutaRichieste(reason) {\n for (const richiesta of this.richieste.values()) richiesta.reject(reason);\n this.richieste.clear();\n }\n aggiornaState(state) {\n if (state === this.stateCorrente) return;\n this.stateCorrente = state;\n for (const listener of this.ascoltatoriState) {\n try {\n listener(state);\n } catch {\n }\n }\n }\n notificaPeers() {\n const peers = this.copiaPeers();\n for (const listener of this.ascoltatoriPeers) {\n try {\n listener(peers);\n } catch {\n }\n }\n }\n controllaGenerazione(generazione) {\n if (generazione !== this.generazione || !this.desiderata) {\n throw creaErrore("offline", "Voice was stopped.");\n }\n }\n richiediDipendenze() {\n if (this.dipendenze === null) throw creaErrore("unsupported", "Voice is not supported.");\n return this.dipendenze;\n }\n richiediMic() {\n if (this.tracciaMic === null) throw creaErrore("voice_error", "The microphone is not ready.");\n return this.tracciaMic;\n }\n richiediAudioContext() {\n if (this.audioContext === null) throw creaErrore("voice_error", "Audio is not ready.");\n return this.audioContext;\n }\n permessoNegato(cause) {\n return typeof cause === "object" && cause !== null && "name" in cause && (cause.name === "NotAllowedError" || cause.name === "SecurityError");\n }\n mappaErrore(cause) {\n if (typeof cause === "object" && cause !== null && "code" in cause) {\n const code = cause.code;\n if (code === "voice_disabled" || code === "permission_denied" || code === "unsupported" || code === "spectator" || code === "offline" || code === "voice_error") return cause;\n return creaErrore("voice_error", "Voice could not be started.");\n }\n return creaErrore("voice_error", "Voice could not be started.");\n }\n};\n\n// src/stanza-client/index.ts\nvar APERTO = 1;\nvar RITARDI_RICONNESSIONE2 = [1e3, 2e3, 4e3, 8e3];\nvar GRAZIA_RICONNESSIONE = 6e4;\nvar INTERVALLO_PING = 5e3;\nvar RITARDO_FLUSH = 500;\nvar ATTESA_ROSTER = 2e3;\nvar CHIUSURE_DEFINITIVE = /* @__PURE__ */ new Set([4003, 4004, 4005, 4006]);\nvar CHIUSURE_DEFINITIVE_SPETTATORE = /* @__PURE__ */ new Set([4008, 4009]);\nfunction record3(value) {\n return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;\n}\nfunction ingressoValido(value) {\n const dati = record3(value);\n return dati !== null && typeof dati.roomId === "string" && typeof dati.code === "string" && typeof dati.join === "string" && typeof dati.url === "string";\n}\nfunction visioneValida(value) {\n const dati = record3(value);\n return dati !== null && typeof dati.roomId === "string" && typeof dati.code === "string" && typeof dati.watch === "string" && typeof dati.url === "string";\n}\nfunction rispostaMatchValida(value) {\n const dati = record3(value);\n const players = record3(dati?.players);\n return dati !== null && typeof dati.url === "string" && Number.isInteger(dati.timeoutMs) && dati.timeoutMs >= 1e3 && dati.timeoutMs <= 3e5 && players !== null && Number.isInteger(players.min) && Number.isInteger(players.max) && players.min >= 1 && players.max >= players.min;\n}\nfunction copiaJson(value) {\n return JSON.parse(JSON.stringify(value));\n}\nfunction applicaPatch(state, value) {\n let risultato = copiaJson(state);\n for (const operazione of value) {\n if (operazione.path.length === 0) {\n if (operazione.op !== "set") return { ok: false };\n risultato = copiaJson(operazione.value);\n continue;\n }\n let contenitore = risultato;\n const percorso = operazione.path;\n for (let indice = 0; indice < percorso.length - 1; indice++) {\n const parte = percorso[indice];\n if (Array.isArray(contenitore)) {\n if (typeof parte !== "number" || parte >= contenitore.length) return { ok: false };\n contenitore = contenitore[parte];\n } else {\n const oggetto2 = record3(contenitore);\n if (oggetto2 === null || typeof parte !== "string" || !Object.hasOwn(oggetto2, parte)) {\n return { ok: false };\n }\n contenitore = oggetto2[parte];\n }\n }\n const ultima = percorso.at(-1);\n if (Array.isArray(contenitore)) {\n if (operazione.op !== "set" || typeof ultima !== "number" || ultima >= contenitore.length) return { ok: false };\n contenitore[ultima] = copiaJson(operazione.value);\n } else {\n const oggetto2 = record3(contenitore);\n if (oggetto2 === null || typeof ultima !== "string") return { ok: false };\n if (operazione.op === "del") {\n if (!Object.hasOwn(oggetto2, ultima)) return { ok: false };\n delete oggetto2[ultima];\n } else {\n Object.defineProperty(oggetto2, ultima, {\n configurable: true,\n enumerable: true,\n value: copiaJson(operazione.value),\n writable: true\n });\n }\n }\n }\n return { ok: true, state: risultato };\n}\nfunction creaApiLive(input) {\n const richiesta = creaRichiedente(input.liveOrigin, "", input.fetcher, input.biglietto);\n async function ingresso(path, body, rinnova = false) {\n const value = await richiesta(path, "POST", body, rinnova);\n if (!ingressoValido(value)) {\n throw creaErrore("internal_error", "The room service returned an invalid response.");\n }\n return value;\n }\n async function match(options) {\n const value = await richiesta("/match", "POST", {\n mode: options.mode,\n key: options.key\n });\n if (!rispostaMatchValida(value)) {\n throw creaErrore("internal_error", "The matchmaking service returned an invalid response.");\n }\n return value;\n }\n async function visione(body, rinnova = false) {\n const value = await richiesta("/rooms/watch", "POST", body, rinnova);\n if (!visioneValida(value)) {\n throw creaErrore("internal_error", "The room service returned an invalid response.");\n }\n return value;\n }\n return {\n create: (mode) => ingresso("/rooms", { mode }),\n joinCode: (code) => ingresso("/rooms/join", { code }),\n joinRoom: (roomId) => ingresso("/rooms/join", { roomId }, true),\n watchCode: (code) => visione({ code }),\n watchRoom: (roomId) => visione({ roomId }, true),\n match,\n flush: (roomId) => richiesta(\n `/rooms/${encodeURIComponent(roomId)}/flush`,\n "POST"\n )\n };\n}\nvar StanzaClient = class {\n constructor(roomId, codice, url, input, api, segnalaStanza, spettatore = false) {\n this.roomId = roomId;\n this.codice = codice;\n this.input = input;\n this.api = api;\n this.segnalaStanza = segnalaStanza;\n this.spettatore = spettatore;\n this.meta = { host: null, mode: null, countdownAt: null, configuration: null, connection: "connecting", closedCode: null };\n this.metaListeners = /* @__PURE__ */ new Set();\n this.connectionListeners = /* @__PURE__ */ new Set();\n this.scoreListeners = /* @__PURE__ */ new Set();\n this.scores = [];\n this.errorListeners = /* @__PURE__ */ new Set();\n this.roleId = 0;\n this.roleRequests = /* @__PURE__ */ new Map();\n this.statoPubblico = null;\n this.statoSincronizzato = null;\n this.tickCorrente = 0;\n this.seedCorrente = 0;\n this.statusCorrente = "lobby";\n this.giocatoriCorrenti = [];\n this.youCorrente = "";\n this.hostCorrente = null;\n this.resultCorrente = null;\n this.delaySpettatore = 0;\n this.socket = null;\n this.seq = 0;\n this.scartoOrario = 0;\n this.timerPing = null;\n this.timerRiconnessione = null;\n this.timerFlush = null;\n this.flushInCorso = false;\n this.flushRichiesto = false;\n this.ritardoIndice = 0;\n this.tempoRiconnessione = 0;\n this.resyncRichiesto = false;\n this.terminata = false;\n this.lasciata = false;\n this.prontaRisolta = false;\n this.welcomeRicevuto = false;\n this.rosterRicevuto = false;\n this.timerRoster = null;\n this.risolviPronta = () => void 0;\n this.rifiutaPronta = () => void 0;\n this.ascoltatoriStato = /* @__PURE__ */ new Set();\n this.ascoltatoriGiocatori = /* @__PURE__ */ new Set();\n this.ascoltatoriStatus = /* @__PURE__ */ new Set();\n this.ascoltatoriMessaggi = /* @__PURE__ */ new Set();\n this.promessaPronta = new Promise((resolve, reject) => {\n this.risolviPronta = resolve;\n this.rifiutaPronta = reject;\n });\n this.voice = new VoceClient({\n invia: (message) => this.invia(message),\n connessa: () => this.socket?.readyState === APERTO && this.welcomeRicevuto && !this.terminata && !this.lasciata,\n you: () => this.youCorrente,\n giocatori: () => this.copiaGiocatori(),\n rosterPronto: () => {\n this.rosterRicevuto = true;\n this.risolviProntaSePossibile();\n }\n }, input, input.voce);\n if (spettatore) this.rosterRicevuto = true;\n this.apri(url);\n }\n get mode() {\n return this.meta.mode;\n }\n get countdownAt() {\n return this.meta.countdownAt;\n }\n get connection() {\n return this.meta.connection;\n }\n get metadata() {\n return structuredClone(this.meta);\n }\n get queuedScores() {\n return structuredClone(this.scores);\n }\n onMetadata(listener) {\n this.metaListeners.add(listener);\n return () => this.metaListeners.delete(listener);\n }\n onConnection(listener) {\n this.connectionListeners.add(listener);\n return () => this.connectionListeners.delete(listener);\n }\n onError(listener) {\n this.errorListeners.add(listener);\n return () => this.errorListeners.delete(listener);\n }\n onScoreQueued(listener) {\n this.scoreListeners.add(listener);\n return () => this.scoreListeners.delete(listener);\n }\n metadataChanged(change) {\n const old = this.meta.connection;\n this.meta = { ...this.meta, ...change };\n this.notifica(this.metaListeners, this.metadata);\n if (old !== this.meta.connection) this.notifica(this.connectionListeners, this.meta.connection);\n }\n initialMetadata(room) {\n this.metadataChanged({\n host: room.host,\n mode: room.mode,\n countdownAt: room.countdownAt ?? null,\n configuration: room.configuration ?? null,\n connection: "connected",\n closedCode: null\n });\n }\n requestRole(role) {\n if (typeof role !== "string" || role.length < 1 || role.length > 32) return Promise.reject(creaErrore("invalid_role", "The role is not valid."));\n if (this.connection !== "connected" || this.status !== "playing" || !this.meta.configuration?.requestRole) {\n return Promise.reject(creaErrore("role_change_unavailable", "Roles cannot be requested right now."));\n }\n if (this.roleRequests.size >= 8) return Promise.reject(creaErrore("rate_limited", "Too many role requests."));\n const r = ++this.roleId;\n return new Promise((resolve, reject) => {\n const timer = this.input.setTimeout(() => {\n this.roleRequests.delete(r);\n reject(creaErrore("timeout", "The role request timed out."));\n }, 5e3);\n this.roleRequests.set(r, { resolve, reject, timer });\n try {\n this.invia({ t: "request-role", r, role });\n } catch (error) {\n this.input.clearTimeout(timer);\n this.roleRequests.delete(r);\n reject(error);\n }\n });\n }\n clearRoleRequests() {\n for (const request of this.roleRequests.values()) {\n this.input.clearTimeout(request.timer);\n request.reject(creaErrore("offline", "The room connection ended."));\n }\n this.roleRequests.clear();\n }\n disconnect() {\n if (this.lasciata) return;\n this.lasciata = true;\n const socket = this.socket;\n this.socket = null;\n this.voice.termina();\n this.fermaPing();\n this.fermaRiconnessione();\n this.clearRoleRequests();\n if (this.timerRoster !== null) this.input.clearTimeout(this.timerRoster);\n socket?.close(1e3);\n this.segnalaStanza(null);\n this.metadataChanged({ connection: "disconnected", closedCode: null });\n if (!this.prontaRisolta) {\n this.prontaRisolta = true;\n this.rifiutaPronta(creaErrore("cancelled", "The room was disconnected."));\n }\n }\n get state() {\n return this.statoPubblico;\n }\n get tick() {\n return this.tickCorrente;\n }\n get seed() {\n return this.seedCorrente;\n }\n get status() {\n return this.statusCorrente;\n }\n get players() {\n return this.copiaGiocatori();\n }\n get you() {\n return this.youCorrente;\n }\n get host() {\n return this.hostCorrente;\n }\n get code() {\n return this.codice;\n }\n get result() {\n return this.resultCorrente;\n }\n get delayMs() {\n return this.delaySpettatore;\n }\n pronta() {\n return this.promessaPronta;\n }\n invite() {\n return { code: this.codice, url: new URL(`/r/${this.codice}`, this.input.appOrigin).href };\n }\n onState(listener) {\n this.ascoltatoriStato.add(listener);\n return () => {\n this.ascoltatoriStato.delete(listener);\n };\n }\n onPlayers(listener) {\n this.ascoltatoriGiocatori.add(listener);\n return () => {\n this.ascoltatoriGiocatori.delete(listener);\n };\n }\n onStatus(listener) {\n this.ascoltatoriStatus.add(listener);\n return () => {\n this.ascoltatoriStatus.delete(listener);\n };\n }\n onMessage(listener) {\n this.ascoltatoriMessaggi.add(listener);\n return () => {\n this.ascoltatoriMessaggi.delete(listener);\n };\n }\n send(message) {\n const prossimo = this.seq + 1;\n this.invia({ t: "msg", seq: prossimo, m: message });\n this.seq = prossimo;\n }\n ready(ready) {\n this.invia({ t: "ready", ready });\n }\n setRole(role) {\n this.invia({ t: "role", role });\n }\n setTeam(team) {\n this.invia({ t: "team", team });\n }\n start() {\n this.invia({ t: "start" });\n }\n leave() {\n if (this.lasciata) return;\n if (!this.spettatore) this.voice.leave();\n this.lasciata = true;\n this.segnalaStanza(null);\n if (this.socket?.readyState === APERTO) {\n const socket = this.socket;\n this.invia({ t: "leave" });\n if (this.spettatore) socket.close(1e3);\n }\n this.termina(1e3);\n }\n serverTime() {\n return this.input.ora() + this.scartoOrario;\n }\n copiaGiocatori() {\n return this.giocatoriCorrenti.map((player) => ({ ...player }));\n }\n notifica(listeners, ...args) {\n for (const listener of listeners) {\n try {\n listener(...args);\n } catch {\n }\n }\n }\n invia(message) {\n if (this.socket?.readyState !== APERTO) {\n throw creaErrore("offline", "The room is reconnecting.");\n }\n let frame;\n try {\n frame = JSON.stringify(message);\n } catch {\n throw creaErrore("invalid_request", "Room messages must be valid JSON.");\n }\n this.socket.send(frame);\n }\n apri(url) {\n let socket;\n try {\n socket = this.input.apriSocket(url);\n } catch {\n this.programmaRiconnessione();\n return;\n }\n this.socket = socket;\n socket.addEventListener("open", () => {\n if (this.socket === socket) this.avviaPing();\n });\n socket.addEventListener("message", (evento) => {\n if (this.socket === socket && typeof evento.data === "string") this.ricevi(evento.data);\n });\n socket.addEventListener("close", (evento) => {\n if (this.socket === socket) this.chiuso(evento.code);\n });\n }\n avviaPing() {\n if (this.timerPing !== null) this.input.clearInterval(this.timerPing);\n this.timerPing = this.input.setInterval(() => {\n if (this.socket?.readyState !== APERTO) return;\n try {\n this.invia({ t: "ping", c: this.input.ora() });\n } catch {\n }\n }, INTERVALLO_PING);\n }\n fermaPing() {\n if (this.timerPing === null) return;\n this.input.clearInterval(this.timerPing);\n this.timerPing = null;\n }\n ricevi(frame) {\n let dati;\n try {\n const value = JSON.parse(frame);\n const oggetto2 = record3(value);\n if (oggetto2 === null || typeof oggetto2.t !== "string") return;\n dati = oggetto2;\n } catch {\n return;\n }\n try {\n if (dati.t === "watching") this.riceviWatching(dati);\n else if (dati.t === "welcome") this.riceviWelcome(dati);\n else if (dati.t === "players") this.riceviGiocatori(dati.players, dati.host);\n else if (dati.t === "status") this.riceviStatus(dati);\n else if (dati.t === "state") this.riceviDiff(dati);\n else if (dati.t === "snapshot") this.riceviSnapshot(dati);\n else if (dati.t === "msg") this.notifica(this.ascoltatoriMessaggi, copiaJson(dati.m));\n else if (dati.t === "pong") this.riceviPong(dati);\n else if (dati.t === "error") this.notifica(this.errorListeners, { code: dati.code, message: dati.message });\n else if (dati.t === "flush") this.richiediFlush();\n else if (dati.t === "score-queued" && !this.spettatore) {\n this.scores.push(structuredClone(dati.score));\n this.scores = this.scores.slice(-32);\n this.notifica(this.scoreListeners, structuredClone(dati.score));\n } else if (dati.t === "role-result") {\n const request = this.roleRequests.get(dati.r);\n if (request) {\n this.input.clearTimeout(request.timer);\n this.roleRequests.delete(dati.r);\n if (dati.ok) request.resolve();\n else request.reject(creaErrore(dati.code ?? "role_change_refused", "The role change was not accepted."));\n }\n } else if (dati.t === "voice") this.voice.ricevi(dati);\n } catch {\n if (dati.t === "state" || dati.t === "snapshot") this.chiediResync();\n }\n }\n riceviWatching(dati) {\n const room = dati.room;\n if (!this.spettatore || room.id !== this.roomId) return;\n this.seedCorrente = room.seed;\n this.hostCorrente = room.host;\n this.statusCorrente = room.status;\n this.giocatoriCorrenti = dati.players.map((player) => ({ ...player }));\n this.delaySpettatore = dati.delayMs;\n this.aggiornaStato(dati.state, room.tick, room.serverTime);\n this.scartoOrario = room.serverTime - this.input.ora();\n this.resyncRichiesto = false;\n this.welcomeRicevuto = true;\n this.ritardoIndice = 0;\n this.tempoRiconnessione = 0;\n this.notifica(this.ascoltatoriGiocatori, this.copiaGiocatori());\n this.notifica(\n this.ascoltatoriStatus,\n this.statusCorrente,\n this.resultCorrente,\n room.serverTime\n );\n this.initialMetadata(room);\n this.risolviProntaSePossibile();\n }\n riceviWelcome(dati) {\n const room = dati.room;\n if (room.id !== this.roomId) return;\n this.youCorrente = dati.you;\n this.seedCorrente = room.seed;\n this.hostCorrente = room.host;\n this.statusCorrente = room.status;\n this.giocatoriCorrenti = dati.players.map((player) => ({ ...player }));\n this.aggiornaStato(dati.state, room.tick, room.serverTime);\n this.scartoOrario = room.serverTime - this.input.ora();\n this.resyncRichiesto = false;\n this.welcomeRicevuto = true;\n if (!this.rosterRicevuto && this.timerRoster === null) {\n this.timerRoster = this.input.setTimeout(() => {\n this.timerRoster = null;\n this.rosterRicevuto = true;\n this.risolviProntaSePossibile();\n }, ATTESA_ROSTER);\n }\n this.ritardoIndice = 0;\n this.tempoRiconnessione = 0;\n this.notifica(this.ascoltatoriGiocatori, this.copiaGiocatori());\n this.voice.giocatoriCambiati();\n this.voice.socketRiconnesso();\n this.notifica(\n this.ascoltatoriStatus,\n this.statusCorrente,\n this.resultCorrente,\n room.serverTime\n );\n this.initialMetadata(room);\n this.risolviProntaSePossibile();\n }\n riceviGiocatori(value, host) {\n this.giocatoriCorrenti = value.map((player) => ({ ...player }));\n if (host !== void 0) this.hostCorrente = host;\n else if (!this.giocatoriCorrenti.some(\n (player) => player.id === this.hostCorrente && player.connected\n )) {\n this.hostCorrente = this.giocatoriCorrenti.find((player) => player.connected)?.id ?? null;\n }\n this.metadataChanged({ host: this.hostCorrente });\n this.notifica(this.ascoltatoriGiocatori, this.copiaGiocatori());\n this.voice.giocatoriCambiati();\n }\n riceviStatus(dati) {\n this.statusCorrente = dati.status;\n if (dati.host !== void 0) this.hostCorrente = dati.host;\n this.resultCorrente = copiaJson(dati.result);\n if (dati.status === "ended") {\n this.terminata = true;\n this.clearRoleRequests();\n this.segnalaStanza(null);\n if (!this.spettatore) this.voice.termina();\n this.fermaPing();\n this.fermaRiconnessione();\n }\n this.metadataChanged({\n host: this.hostCorrente,\n countdownAt: dati.countdownAt ?? (dati.status === "countdown" ? dati.at : null),\n ...dati.status === "ended" ? { connection: "ended", closedCode: 4004 } : {}\n });\n this.notifica(this.ascoltatoriStatus, this.statusCorrente, this.resultCorrente, dati.at);\n }\n riceviDiff(dati) {\n if (dati.base !== this.tickCorrente) {\n this.chiediResync();\n return;\n }\n const risultato = applicaPatch(this.statoSincronizzato, dati.patch);\n if (!risultato.ok) {\n this.chiediResync();\n return;\n }\n this.resyncRichiesto = false;\n this.aggiornaStato(risultato.state, dati.tick, dati.serverTime);\n }\n riceviSnapshot(dati) {\n if (dati.tick < this.tickCorrente) return;\n this.resyncRichiesto = false;\n this.aggiornaStato(dati.state, dati.tick, dati.serverTime);\n }\n aggiornaStato(state, tick, serverTime) {\n this.statoSincronizzato = copiaJson(state);\n this.statoPubblico = copiaJson(state);\n this.tickCorrente = tick;\n this.notifica(this.ascoltatoriStato, this.statoPubblico, tick, serverTime);\n }\n chiediResync() {\n if (this.resyncRichiesto || this.socket?.readyState !== APERTO) return;\n this.resyncRichiesto = true;\n try {\n this.invia({ t: "resync" });\n } catch {\n this.resyncRichiesto = false;\n }\n }\n riceviPong(dati) {\n this.scartoOrario = dati.s - (dati.c + this.input.ora()) / 2;\n }\n chiuso(code) {\n this.socket = null;\n this.welcomeRicevuto = false;\n this.fermaPing();\n if (this.lasciata || this.terminata) return;\n if (CHIUSURE_DEFINITIVE.has(code) || this.spettatore && CHIUSURE_DEFINITIVE_SPETTATORE.has(code)) {\n this.termina(code);\n return;\n }\n this.clearRoleRequests();\n if (!this.spettatore) this.voice.socketDisconnesso();\n this.programmaRiconnessione();\n }\n programmaRiconnessione() {\n if (this.terminata || this.lasciata || this.timerRiconnessione !== null) return;\n this.metadataChanged({ connection: "reconnecting" });\n const indice = Math.min(this.ritardoIndice, RITARDI_RICONNESSIONE2.length - 1);\n const ritardo = RITARDI_RICONNESSIONE2[indice];\n if (this.tempoRiconnessione + ritardo > GRAZIA_RICONNESSIONE) {\n this.termina("timeout");\n return;\n }\n this.ritardoIndice++;\n this.tempoRiconnessione += ritardo;\n this.timerRiconnessione = this.input.setTimeout(() => {\n this.timerRiconnessione = null;\n void this.riconnetti();\n }, ritardo);\n }\n async riconnetti() {\n if (this.terminata || this.lasciata) return;\n try {\n const ingresso = this.spettatore ? await this.api.watchRoom(this.roomId) : await this.api.joinRoom(this.roomId);\n if (this.terminata || this.lasciata) return;\n const codiceCambiato = this.codice !== ingresso.code;\n this.codice = ingresso.code;\n if (codiceCambiato && this.prontaRisolta && !this.terminata && !this.lasciata) {\n this.segnalaStanza({ code: this.codice });\n }\n this.apri(ingresso.url);\n } catch {\n this.programmaRiconnessione();\n }\n }\n fermaRiconnessione() {\n if (this.timerRiconnessione === null) return;\n this.input.clearTimeout(this.timerRiconnessione);\n this.timerRiconnessione = null;\n }\n termina(code) {\n this.clearRoleRequests();\n this.metadataChanged({ connection: code === 1e3 ? "disconnected" : code === 4006 ? "replaced" : "closed", closedCode: typeof code === "number" ? code : null });\n const risultato = { closed: code };\n const cambiato = this.statusCorrente !== "ended" || JSON.stringify(this.resultCorrente) !== JSON.stringify(risultato);\n this.terminata = true;\n this.segnalaStanza(null);\n this.statusCorrente = "ended";\n this.resultCorrente = risultato;\n if (!this.spettatore) this.voice.termina();\n this.fermaPing();\n this.fermaRiconnessione();\n if (cambiato) this.notifica(this.ascoltatoriStatus, "ended", risultato, this.serverTime());\n if (!this.prontaRisolta) {\n this.prontaRisolta = true;\n const codici = {\n 4003: "kicked",\n 4004: "room_ended",\n 4005: "version_closed",\n 4006: "replaced",\n 4008: "rate_limited",\n 4009: "invalid_request"\n };\n const erroreCode = typeof code === "number" ? codici[code] ?? "offline" : "offline";\n this.rifiutaPronta(creaErrore(erroreCode, "The room connection ended."));\n }\n }\n risolviProntaSePossibile() {\n if (this.prontaRisolta || !this.welcomeRicevuto || !this.rosterRicevuto) return;\n if (this.timerRoster !== null) {\n this.input.clearTimeout(this.timerRoster);\n this.timerRoster = null;\n }\n this.prontaRisolta = true;\n if (!this.spettatore && !this.terminata && !this.lasciata) {\n this.segnalaStanza({ code: this.codice });\n }\n this.risolviPronta();\n }\n richiediFlush() {\n this.flushRichiesto = true;\n if (this.flushInCorso || this.timerFlush !== null) return;\n this.timerFlush = this.input.setTimeout(() => {\n this.timerFlush = null;\n void this.eseguiFlush();\n }, RITARDO_FLUSH);\n }\n async eseguiFlush() {\n if (this.flushInCorso || !this.flushRichiesto) return;\n this.flushInCorso = true;\n this.flushRichiesto = false;\n try {\n await this.api.flush(this.roomId);\n } catch {\n } finally {\n this.flushInCorso = false;\n if (this.flushRichiesto) this.richiediFlush();\n }\n }\n};\nfunction creaStanzeOffline(invited = null) {\n return {\n invited,\n async create() {\n throw erroreOffline();\n },\n async join() {\n throw erroreOffline();\n },\n async watch() {\n throw erroreOffline();\n },\n async match() {\n throw erroreOffline();\n }\n };\n}\nfunction creaGestoreStanze(input, invited) {\n const api = creaApiLive(input);\n let haSegnalato = false;\n let ultimoCodice = null;\n const segnalaStanza = (room) => {\n const codice = room?.code ?? null;\n if (haSegnalato && codice === ultimoCodice) return;\n haSegnalato = true;\n ultimoCodice = codice;\n input.segnalaStanza?.(room);\n };\n const collega = async (ingresso) => {\n const stanza = new StanzaClient(\n ingresso.roomId,\n ingresso.code,\n ingresso.url,\n input,\n api,\n segnalaStanza\n );\n await stanza.pronta();\n return stanza;\n };\n const guarda = async (ingresso) => {\n const stanza = new StanzaClient(\n ingresso.roomId,\n ingresso.code,\n ingresso.url,\n input,\n api,\n () => void 0,\n true\n );\n await stanza.pronta();\n return {\n get mode() {\n return stanza.mode;\n },\n get countdownAt() {\n return stanza.countdownAt;\n },\n get connection() {\n return stanza.connection;\n },\n get metadata() {\n return stanza.metadata;\n },\n onMetadata: (listener) => stanza.onMetadata(listener),\n onConnection: (listener) => stanza.onConnection(listener),\n disconnect: () => stanza.disconnect(),\n get state() {\n return stanza.state;\n },\n get tick() {\n return stanza.tick;\n },\n get seed() {\n return stanza.seed;\n },\n get status() {\n return stanza.status;\n },\n get players() {\n return stanza.players;\n },\n get host() {\n return stanza.host;\n },\n get code() {\n return stanza.code;\n },\n get result() {\n return stanza.result;\n },\n get delayMs() {\n return stanza.delayMs;\n },\n onState: (listener) => stanza.onState(listener),\n onPlayers: (listener) => stanza.onPlayers(listener),\n onStatus: (listener) => stanza.onStatus(listener),\n onMessage: (listener) => stanza.onMessage(listener),\n leave: () => {\n stanza.leave();\n },\n serverTime: () => stanza.serverTime()\n };\n };\n const attendiMatch = (url, options) => new Promise((resolve, reject) => {\n let socket;\n let conclusa = false;\n const pulisci = () => {\n socket.removeEventListener("message", ricevi);\n socket.removeEventListener("close", chiuso);\n socket.removeEventListener("error", caduto);\n options.signal?.removeEventListener("abort", annulla);\n };\n const chiudi = () => {\n try {\n socket.close(1e3);\n } catch {\n }\n };\n const fallisci = (errore, chiudiSocket) => {\n if (conclusa) return;\n conclusa = true;\n pulisci();\n if (chiudiSocket) chiudi();\n reject(errore);\n };\n function annulla() {\n fallisci(\n creaErrore("cancelled", "The matchmaking search was cancelled."),\n true\n );\n }\n function chiuso() {\n fallisci(erroreOffline(), false);\n }\n function caduto() {\n fallisci(erroreOffline(), true);\n }\n function ricevi(evento) {\n let dati = null;\n try {\n dati = typeof evento.data === "string" ? record3(JSON.parse(evento.data)) : null;\n } catch {\n }\n if (dati === null || typeof dati.t !== "string") {\n fallisci(creaErrore("internal_error", "The matchmaking service sent an invalid message."), true);\n return;\n }\n if (dati.t === "waiting") {\n if (!Number.isInteger(dati.players) || !Number.isInteger(dati.min) || !Number.isInteger(dati.max)) {\n fallisci(creaErrore("internal_error", "The matchmaking service sent an invalid message."), true);\n return;\n }\n try {\n options.onWaiting?.({\n players: dati.players,\n min: dati.min,\n max: dati.max\n });\n } catch {\n }\n return;\n }\n if (dati.t === "matched") {\n if (!ingressoValido(dati)) {\n fallisci(creaErrore("internal_error", "The matchmaking service sent an invalid message."), true);\n return;\n }\n conclusa = true;\n pulisci();\n chiudi();\n resolve(dati);\n return;\n }\n if (dati.t === "no_match") {\n fallisci(creaErrore("no_match", "No match was found before the timeout."), true);\n return;\n }\n if (dati.t === "error") {\n fallisci(creaErrore(\n typeof dati.code === "string" ? dati.code : "internal_error",\n typeof dati.message === "string" ? dati.message : "The matchmaking service could not complete the search."\n ), true);\n return;\n }\n if (dati.t !== "pong") {\n fallisci(creaErrore("internal_error", "The matchmaking service sent an invalid message."), true);\n }\n }\n try {\n socket = input.apriSocket(url);\n } catch {\n reject(erroreOffline());\n return;\n }\n socket.addEventListener("message", ricevi);\n socket.addEventListener("close", chiuso);\n socket.addEventListener("error", caduto);\n options.signal?.addEventListener("abort", annulla, { once: true });\n if (options.signal?.aborted === true) annulla();\n });\n return {\n invited,\n async create(options) {\n return collega(await api.create(options.mode));\n },\n async join(code) {\n const scelto = code ?? invited;\n if (scelto === null || scelto === void 0 || scelto.length === 0) {\n throw creaErrore("invalid_request", "A room invitation code is required.");\n }\n return collega(await api.joinCode(scelto));\n },\n async watch(code) {\n if (typeof code !== "string" || code.length === 0) {\n throw creaErrore("invalid_request", "A room invitation code is required.");\n }\n return guarda(await api.watchCode(code));\n },\n async match(options) {\n const annullata = () => options.signal?.aborted === true;\n if (annullata()) {\n throw creaErrore("cancelled", "The matchmaking search was cancelled.");\n }\n const risposta = await api.match(options);\n if (annullata()) {\n throw creaErrore("cancelled", "The matchmaking search was cancelled.");\n }\n return collega(await attendiMatch(risposta.url, options));\n }\n };\n}\n\n// src/standalone.ts\nvar PREFISSO = "caisual:save:";\nvar CHIAVE_VALIDA = /^[a-z0-9][a-z0-9_-]{0,31}$/;\nfunction verificaChiave(key) {\n if (!CHIAVE_VALIDA.test(key)) {\n throw creaErrore("invalid_request", "Save keys must use lowercase letters, numbers, underscores, or hyphens.");\n }\n}\nfunction leggiSalvataggio(testo) {\n if (testo === null) return null;\n try {\n return JSON.parse(testo);\n } catch {\n return null;\n }\n}\nfunction chiavi(archivio) {\n const risultato = [];\n for (let indice = 0; indice < archivio.length; indice++) {\n const key = archivio.key(indice);\n if (key?.startsWith(PREFISSO)) risultato.push(key.slice(PREFISSO.length));\n }\n return risultato;\n}\nfunction creaSave(archivio, ora) {\n const disponibile = () => {\n if (archivio === null) throw erroreOffline();\n return archivio;\n };\n return {\n async set(key, value) {\n verificaChiave(key);\n const locale = disponibile();\n const corpo = JSON.stringify({ value });\n const bytes = new TextEncoder().encode(corpo).byteLength;\n if (bytes > 262144) {\n throw creaErrore("payload_too_large", "The save is larger than 262144 bytes.");\n }\n if (locale.getItem(PREFISSO + key) === null && chiavi(locale).length >= 32) {\n throw creaErrore("save_limit", "A game can store at most 32 save keys.");\n }\n const voce = { value, bytes, updatedAt: ora() };\n locale.setItem(PREFISSO + key, JSON.stringify(voce));\n return { key, bytes, updatedAt: voce.updatedAt };\n },\n async get(key) {\n verificaChiave(key);\n return leggiSalvataggio(disponibile().getItem(PREFISSO + key))?.value ?? null;\n },\n async remove(key) {\n verificaChiave(key);\n disponibile().removeItem(PREFISSO + key);\n },\n async list() {\n const locale = disponibile();\n return chiavi(locale).flatMap((key) => {\n const voce = leggiSalvataggio(locale.getItem(PREFISSO + key));\n return voce === null ? [] : [{ key, bytes: voce.bytes, updatedAt: voce.updatedAt }];\n }).sort((a, b) => a.key.localeCompare(b.key));\n }\n };\n}\nasync function creaStandalone(input, invited = null) {\n const day = giornoUtc(input.ora());\n const seed = await calcolaSeed(input.hostname, day, input.subtle);\n return {\n connected: false,\n player: { id: "local", name: "Guest", guest: true },\n daily: { day, seed, random: creaMulberry32(seed) },\n time: { now: input.ora },\n save: creaSave(input.archivio, input.ora),\n board: {\n async submit() {\n return { accepted: false, reason: "offline", verified: false };\n },\n async top(_board, opzioni = {}) {\n if (opzioni.day !== void 0 && (!validBoardDay(opzioni.day) || opzioni.daily === false)) throw creaErrore("invalid_request", "day must be a real UTC date and cannot be combined with daily: false.");\n return { day: opzioni.day ?? (opzioni.daily ? day : null), entries: [], me: null };\n }\n },\n room: creaStanzeOffline(invited)\n };\n}\n\n// src/kit.ts\nfunction leggiAppOrigin(documento) {\n const valore = documento?.querySelector(\'meta[name="caisual-app"]\')?.getAttribute("content");\n if (valore === null || valore === void 0) return null;\n try {\n const url = new URL(valore);\n return url.origin === valore && (url.protocol === "https:" || url.protocol === "http:") ? valore : null;\n } catch {\n return null;\n }\n}\nfunction archivioReale() {\n try {\n return typeof localStorage === "undefined" ? null : localStorage;\n } catch {\n return null;\n }\n}\nfunction dipendenzeReali2() {\n return {\n finestra: typeof window === "undefined" ? null : window,\n documento: typeof document === "undefined" ? null : document,\n fetcher: (input, init) => globalThis.fetch(input, init),\n archivio: archivioReale(),\n hostname: typeof location === "undefined" ? "" : location.hostname,\n subtle: globalThis.crypto.subtle,\n ora: Date.now,\n sonda: () => probeDevice()\n };\n}\nasync function connetti(input) {\n const appOrigin = leggiAppOrigin(input.documento);\n const senzaPadre = input.finestra === null || input.finestra.parent === input.finestra;\n if (appOrigin === null || senzaPadre) {\n return localConnection(input);\n }\n const handshake = await attendiHandshake(\n input.finestra,\n appOrigin,\n input.timeoutHandshake\n );\n if (handshake === null) return localConnection(input);\n const biglietto = creaGestoreBiglietto(\n handshake.ticket,\n handshake.porta,\n input.finestra,\n input.ora,\n input.timeoutRinnovo,\n "portal"\n );\n const api = creaClienteApi(appOrigin, input.fetcher, biglietto);\n const prima = input.ora();\n let me;\n try {\n me = await api.me();\n } catch {\n const base2 = await creaStandalone(input, handshake.invite);\n return installSession(base2, handshake, input);\n }\n const dopo = input.ora();\n const scartoOrario = me.serverTime - (prima + dopo) / 2;\n const room = handshake.live === null ? creaStanzeOffline(handshake.invite) : creaGestoreStanze({\n appOrigin,\n liveOrigin: handshake.live,\n fetcher: input.fetcher,\n biglietto: creaGestoreBiglietto(\n null,\n handshake.porta,\n input.finestra,\n input.ora,\n input.timeoutRinnovo,\n "live"\n ),\n apriSocket(url) {\n if (input.apriSocket !== void 0) return input.apriSocket(url);\n if (typeof WebSocket === "undefined") throw erroreOffline();\n return new WebSocket(url);\n },\n ora: input.ora,\n setTimeout: (handler, timeout) => globalThis.setTimeout(handler, timeout),\n clearTimeout: (id) => globalThis.clearTimeout(id),\n setInterval: (handler, timeout) => globalThis.setInterval(handler, timeout),\n clearInterval: (id) => globalThis.clearInterval(id),\n voce: input.voce,\n segnalaStanza(room2) {\n try {\n handshake.porta.postMessage({ type: "caisual:room", room: room2 });\n } catch {\n }\n }\n }, handshake.invite);\n const base = {\n connected: true,\n player: me.player,\n daily: { day: me.day, seed: me.seed, random: creaMulberry32(me.seed) },\n time: { now: () => input.ora() + scartoOrario },\n save: {\n set: (key, value) => api.saveSet(key, value),\n get: (key) => api.saveGet(key),\n remove: (key) => api.saveRemove(key),\n list: () => api.saveList()\n },\n board: {\n async submit(board, score, opzioni = {}) {\n try {\n return await api.boardSubmit(board, score, opzioni.daily === true);\n } catch (errore) {\n if (typeof errore === "object" && errore !== null && "code" in errore && errore.code === "offline") return { accepted: false, reason: "offline", verified: false };\n throw errore;\n }\n },\n top: (board, opzioni = {}) => api.boardTop(board, opzioni)\n },\n room\n };\n return installSession(base, handshake, input);\n}\nfunction installSession(base, handshake, input) {\n const coordinator = createSession(base, handshake?.overlay?.configuration ?? null, base.connected && handshake?.live != null);\n if (handshake?.overlay) {\n const dispose = attachKitBridge(handshake.porta, handshake.overlay, coordinator);\n if (coordinator.session.capabilities.overlay && typeof window !== "undefined" && input?.finestra === window) window.addEventListener("pagehide", dispose, { once: true });\n }\n return { ...base, room: coordinator.rooms, session: coordinator.session, overlay: coordinator.overlay };\n}\nasync function localConnection(input) {\n return installSession(await creaStandalone(input));\n}\nfunction dispositivoSconosciuto() {\n return {\n webgl2: false,\n webgpu: false,\n wasm: false,\n threads: false,\n isolated: false,\n gpu: "none",\n memoryMb: null,\n cores: null,\n mobile: false,\n tier: "low"\n };\n}\nasync function attendiSonda(sonda) {\n let timer;\n try {\n return await Promise.race([\n Promise.resolve().then(sonda).catch(() => dispositivoSconosciuto()),\n new Promise((resolve) => {\n timer = globalThis.setTimeout(() => resolve(dispositivoSconosciuto()), 1500);\n })\n ]);\n } finally {\n if (timer !== void 0) globalThis.clearTimeout(timer);\n }\n}\nfunction creaKit(input = dipendenzeReali2()) {\n let promessa = null;\n return {\n connect() {\n promessa ?? (promessa = Promise.all([connetti(input), attendiSonda(input.sonda)]).then(([connessione, device]) => ({ ...connessione, device })));\n return promessa;\n }\n };\n}\n\n// src/index.ts\nvar caisual = creaKit();\nglobalThis.caisual = caisual;\nvar index_default = caisual;\nexport {\n caisual,\n index_default as default\n};\n');
4745
+ response.end(request.method === "HEAD" ? void 0 : '// Caisual game kit v0.10.0\n\n// ../contracts/src/slug.ts\nvar NOMI_RISERVATI = [\n "www",\n "api",\n "app",\n "play",\n "live",\n "multi",\n "cdn",\n "assets",\n "static",\n "mail",\n "mx",\n "ns1",\n "ns2",\n "autodiscover",\n "_dmarc",\n "admin",\n "login",\n "account",\n "auth",\n "pay",\n "secure",\n "support",\n "help",\n "blog",\n "status",\n "dev",\n "staging",\n "test",\n "caisual",\n "shipz"\n];\nvar RISERVATI = new Set(NOMI_RISERVATI);\nvar SLUG_NUOVO = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;\nvar SLUG_STORICO = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;\nfunction isValidSlug(value) {\n return value.length >= 3 && value.length <= 32 && SLUG_NUOVO.test(value) || SLUG_STORICO.test(value);\n}\nfunction isReservedSlug(value) {\n return RISERVATI.has(value);\n}\n\n// ../contracts/src/i18n.ts\nfunction normalizeLanguage(value) {\n if (typeof value !== "string" || value.length > 128) return null;\n try {\n return Intl.getCanonicalLocales(value)[0] ?? null;\n } catch {\n return null;\n }\n}\nfunction manifestLanguages(manifest) {\n return manifest.languages?.length ? [...manifest.languages] : [manifest.language ?? "en"];\n}\nfunction languageFallbacks(language, defaultLanguage = "en") {\n const result = [];\n let tag = normalizeLanguage(language);\n while (tag) {\n result.push(tag);\n const parts = tag.split("-");\n parts.pop();\n if (parts.at(-1)?.length === 1) parts.pop();\n tag = parts.join("-");\n }\n result.push(normalizeLanguage(defaultLanguage) ?? defaultLanguage);\n return [...new Set(result)];\n}\nfunction resolveGameLanguage(preferences, languages2 = []) {\n const declared = languages2.map(normalizeLanguage).filter((tag) => tag !== null);\n const preferred = preferences.map(normalizeLanguage).filter((tag) => tag !== null);\n if (!declared.length) return preferred[0] ?? "en";\n for (const preference of preferred) {\n for (const tag of languageFallbacks(preference, preference)) {\n if (declared.includes(tag)) return tag;\n }\n }\n return declared[0];\n}\nfunction isTextDictionary(value) {\n return typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((text) => typeof text === "string");\n}\n\n// ../contracts/src/manifest.ts\nfunction risolviModalita(manifest, mode) {\n const scelta = mode === null ? void 0 : manifest.modes.find((voce) => voce.id === mode);\n if (mode !== null && scelta === void 0) throw new Error("The selected game mode does not exist.");\n return { players: { ...scelta?.players ?? manifest.players }, lobby: scelta?.lobby ?? manifest.lobby };\n}\nfunction modalitaLocale(manifest, mode) {\n return mode !== null && manifest.modes.some((voce) => voce.id === mode && voce.execution === "local");\n}\nvar TETTO_GIOCATORI = 24;\nvar RITARDO_SPETTATORI_MS = 3e3;\nvar MASSIMO_CLASSIFICHE = 32;\nvar CAMPI = /* @__PURE__ */ new Set([\n "overlay",\n "manifest",\n "id",\n "name",\n "description",\n "cover",\n "screenshots",\n "tags",\n "languages",\n "language",\n "platform",\n "orientation",\n "input",\n "visibility",\n "network",\n "isolated",\n "requires",\n "players",\n "lobby",\n "persistent",\n "spectators",\n "boards",\n "roles",\n "teams",\n "voice",\n "modes"\n]);\nvar INPUT = /* @__PURE__ */ new Set(["keyboard", "mouse", "touch", "gamepad"]);\nvar PLATFORM = /* @__PURE__ */ new Set(["desktop", "mobile", "both"]);\nvar ORIENTATION = /* @__PURE__ */ new Set(["landscape", "portrait"]);\nvar VISIBILITY = /* @__PURE__ */ new Set(["public", "unlisted"]);\nvar VOICE = /* @__PURE__ */ new Set(["none", "room", "team", "proximity"]);\nvar PERFORMANCE = /* @__PURE__ */ new Set(["light", "medium", "heavy"]);\nvar TAG = /^[a-z0-9-]+$/;\nvar ID_INTERNO = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;\nvar CAMPO_MATCHMAKING = /^[a-z0-9][a-z0-9-]{0,31}$/;\nvar ID_CLASSIFICA = /^[a-z0-9][a-z0-9_-]{0,31}$/;\nfunction oggetto(value) {\n if (typeof value !== "object" || value === null || Array.isArray(value)) return null;\n return value;\n}\nfunction percorsoRelativo(value) {\n if (value === "" || value.startsWith("/") || value.includes("\\\\") || value.includes("\\0")) return false;\n if (value.includes("?") || value.includes("#")) return false;\n const parti = value.split("/");\n if (parti.some((parte) => parte === "" || parte === "." || parte === "..")) return false;\n try {\n const decoded = parti.map((parte) => decodeURIComponent(parte));\n return !decoded.some((parte) => parte === "" || parte === "." || parte === ".." || parte.includes("/"));\n } catch {\n return false;\n }\n}\nfunction hostValido(value) {\n if (value.length === 0 || value.length > 253) return false;\n if (value.includes("://") || /[/:?#@]/.test(value)) return false;\n const parti = value.split(".");\n return parti.every(\n (parte) => parte.length >= 1 && parte.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(parte)\n );\n}\nfunction interoTra(value, min, max) {\n return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;\n}\nfunction stringaDefault(dati, campo, valoreDefault, errori) {\n const value = dati[campo];\n if (value === void 0) return valoreDefault;\n if (typeof value !== "string") {\n errori.push(`${campo}: must be a string.`);\n return valoreDefault;\n }\n return value;\n}\nfunction testoFacoltativo(value, key, max, path, errors) {\n if (value[key] === void 0) return void 0;\n const check = (text2, field2) => {\n if (typeof text2 !== "string" || text2.trim().length === 0 || text2.trim().length > max || /[\\r\\n\\u0000-\\u001f]/.test(text2)) {\n errors.push(`${field2}: must contain 1-${max} characters on one line.`);\n return void 0;\n }\n return text2.trim();\n };\n const text = value[key], field = `${path}.${key}`;\n if (typeof text === "string") return check(text, field);\n const translations = oggetto(text);\n if (!translations || Object.keys(translations).length === 0) {\n errors.push(`${field}: must be a string or a non-empty language-to-text object.`);\n return void 0;\n }\n const result = {};\n for (const [raw, text2] of Object.entries(translations)) {\n const tag = normalizeLanguage(raw);\n if (!tag) {\n errors.push(`${field}.${raw}: must be a BCP 47 language tag.`);\n continue;\n }\n if (Object.hasOwn(result, tag)) errors.push(`${field}.${raw}: duplicate language.`);\n const checked = check(text2, `${field}.${raw}`);\n if (checked !== void 0) result[tag] = checked;\n }\n return result;\n}\nfunction validaManifest(valore) {\n const errori = [];\n const dati = oggetto(valore);\n if (dati === null) return { ok: false, errori: ["manifest: must be a JSON object."] };\n for (const campo of Object.keys(dati)) {\n if (!CAMPI.has(campo)) errori.push(`${campo}: unknown field.`);\n }\n if (dati.manifest === void 0) errori.push("manifest: is required and must be 1.");\n else if (dati.manifest !== 1) errori.push("manifest: must be exactly 1.");\n const id = stringaDefault(dati, "id", "", errori);\n if (dati.id === void 0) errori.push("id: is required.");\n else if (typeof dati.id === "string") {\n if (!isValidSlug(id)) {\n errori.push("id: must be 3-32 lowercase ASCII letters or digits with internal hyphens; historical UUID v4 slugs may be 36 characters.");\n } else if (isReservedSlug(id)) errori.push("id: this slug is reserved.");\n }\n const name = stringaDefault(dati, "name", "", errori);\n if (dati.name === void 0) errori.push("name: is required.");\n else if (typeof dati.name === "string" && (name.trim() === "" || name.length > 60)) {\n errori.push("name: must contain 1-60 characters.");\n }\n const description = stringaDefault(dati, "description", "", errori);\n if (description.length > 500) errori.push("description: must be at most 500 characters.");\n let cover = null;\n if (dati.cover !== void 0 && dati.cover !== null) {\n if (typeof dati.cover !== "string") errori.push("cover: must be a relative file path or null.");\n else if (!percorsoRelativo(dati.cover)) errori.push("cover: must be a relative file path without query, fragment, or parent segments.");\n else cover = dati.cover;\n }\n const screenshots = [];\n if (dati.screenshots !== void 0) {\n if (!Array.isArray(dati.screenshots)) errori.push("screenshots: must be an array of relative file paths.");\n else {\n if (dati.screenshots.length > 8) errori.push("screenshots: must contain at most 8 paths.");\n for (const [indice, value] of dati.screenshots.entries()) {\n if (typeof value !== "string" || !percorsoRelativo(value)) {\n errori.push(`screenshots[${indice}]: must be a relative file path without query, fragment, or parent segments.`);\n } else screenshots.push(value);\n }\n }\n }\n const tags = [];\n if (dati.tags !== void 0) {\n if (!Array.isArray(dati.tags)) errori.push("tags: must be an array.");\n else {\n if (dati.tags.length > 10) errori.push("tags: must contain at most 10 tags.");\n for (const [indice, value] of dati.tags.entries()) {\n if (typeof value !== "string" || value.length > 24 || !TAG.test(value)) {\n errori.push(`tags[${indice}]: must be 1-24 lowercase letters, digits, or hyphens.`);\n } else tags.push(value);\n }\n }\n }\n const legacyLanguage = stringaDefault(dati, "language", "en", errori);\n if (!/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/.test(legacyLanguage)) {\n errori.push("language: must be a BCP 47 language tag such as en, it, or pt-BR.");\n }\n const languages2 = [];\n if (dati.languages === void 0) languages2.push(normalizeLanguage(legacyLanguage) ?? legacyLanguage);\n else if (!Array.isArray(dati.languages) || dati.languages.length === 0) {\n errori.push("languages: must be a non-empty array of BCP 47 language tags.");\n } else for (const [index, raw] of dati.languages.entries()) {\n const tag = normalizeLanguage(raw);\n if (!tag) errori.push(`languages[${index}]: must be a BCP 47 language tag.`);\n else if (languages2.includes(tag)) errori.push(`languages[${index}]: duplicate language ${tag}.`);\n else languages2.push(tag);\n }\n const language = languages2[0] ?? legacyLanguage;\n if (dati.language !== void 0 && dati.languages !== void 0 && legacyLanguage.toLowerCase() !== language.toLowerCase()) {\n errori.push("language: must match the first entry in languages when both are present.");\n }\n let platform = "both";\n if (dati.platform === void 0) errori.push("platform: is required.");\n else if (typeof dati.platform !== "string" || !PLATFORM.has(dati.platform)) {\n errori.push("platform: must be desktop, mobile, or both.");\n } else platform = dati.platform;\n let orientation = "landscape";\n if (dati.orientation !== void 0) {\n if (typeof dati.orientation !== "string" || !ORIENTATION.has(dati.orientation)) {\n errori.push("orientation: must be landscape or portrait.");\n } else orientation = dati.orientation;\n }\n const input = [];\n if (dati.input !== void 0) {\n if (!Array.isArray(dati.input)) errori.push("input: must be an array.");\n else for (const [indice, value] of dati.input.entries()) {\n if (typeof value !== "string" || !INPUT.has(value)) {\n errori.push(`input[${indice}]: must be keyboard, mouse, touch, or gamepad.`);\n } else if (input.includes(value)) errori.push(`input[${indice}]: duplicate value ${value}.`);\n else input.push(value);\n }\n }\n let visibility = "public";\n if (dati.visibility !== void 0) {\n if (typeof dati.visibility !== "string" || !VISIBILITY.has(dati.visibility)) {\n errori.push("visibility: must be public or unlisted.");\n } else visibility = dati.visibility;\n }\n const network = [];\n if (dati.network !== void 0) {\n if (!Array.isArray(dati.network)) errori.push("network: must be an array of host names.");\n else for (const [indice, value] of dati.network.entries()) {\n if (typeof value !== "string" || !hostValido(value)) {\n errori.push(`network[${indice}]: must be a host name without scheme, port, path, query, or fragment.`);\n } else if (network.includes(value)) errori.push(`network[${indice}]: duplicate host ${value}.`);\n else network.push(value);\n }\n }\n let isolated = false;\n if (dati.isolated !== void 0) {\n if (typeof dati.isolated !== "boolean") errori.push("isolated: must be a boolean.");\n else isolated = dati.isolated;\n }\n const requires = {\n webgl2: false,\n webgpu: false,\n wasm: false,\n threads: false,\n memoryMb: null,\n performance: "light"\n };\n if (dati.requires !== void 0) {\n const value = oggetto(dati.requires);\n if (value === null) errori.push("requires: must be an object.");\n else {\n for (const campo of Object.keys(value)) {\n if (!["webgl2", "webgpu", "wasm", "threads", "memoryMb", "performance"].includes(campo)) {\n errori.push(`requires.${campo}: unknown field.`);\n }\n }\n for (const campo of ["webgl2", "webgpu", "wasm", "threads"]) {\n if (value[campo] === void 0) continue;\n if (typeof value[campo] !== "boolean") errori.push(`requires.${campo}: must be a boolean.`);\n else requires[campo] = value[campo];\n }\n if (value.memoryMb !== void 0) {\n if (value.memoryMb !== null && (!interoTra(value.memoryMb, 512, 32768) || value.memoryMb % 256 !== 0)) {\n errori.push("requires.memoryMb: must be an integer from 512 to 32768 in steps of 256, or null.");\n } else requires.memoryMb = value.memoryMb;\n }\n if (value.performance !== void 0) {\n if (typeof value.performance !== "string" || !PERFORMANCE.has(value.performance)) {\n errori.push("requires.performance: must be light, medium, or heavy.");\n } else requires.performance = value.performance;\n }\n if (requires.threads && !isolated) errori.push("requires.threads: needs isolated: true.");\n }\n }\n let players = { min: 1, max: 1 };\n if (dati.players !== void 0) {\n const value = oggetto(dati.players);\n if (value === null) errori.push("players: must be an object with min and max.");\n else {\n for (const campo of Object.keys(value)) {\n if (campo !== "min" && campo !== "max") errori.push(`players.${campo}: unknown field.`);\n }\n if (!interoTra(value.min, 1, TETTO_GIOCATORI)) errori.push(`players.min: must be an integer from 1 to ${TETTO_GIOCATORI}.`);\n if (!interoTra(value.max, 1, TETTO_GIOCATORI)) errori.push(`players.max: must be an integer from 1 to ${TETTO_GIOCATORI} in manifest version 1.`);\n if (interoTra(value.min, 1, TETTO_GIOCATORI) && interoTra(value.max, 1, TETTO_GIOCATORI)) {\n if (value.min > value.max) errori.push("players.max: must be greater than or equal to players.min.");\n else players = { min: value.min, max: value.max };\n }\n }\n }\n let lobby = false;\n if (dati.lobby !== void 0) {\n if (typeof dati.lobby !== "boolean") errori.push("lobby: must be a boolean.");\n else lobby = dati.lobby;\n }\n let persistent = false;\n if (dati.persistent !== void 0) {\n if (typeof dati.persistent !== "boolean") errori.push("persistent: must be a boolean.");\n else persistent = dati.persistent;\n }\n let spectators = { delayMs: RITARDO_SPETTATORI_MS };\n if (dati.spectators === false || dati.spectators === null) spectators = null;\n else if (dati.spectators !== void 0 && dati.spectators !== true) {\n const value = oggetto(dati.spectators);\n if (value === null) {\n errori.push("spectators: must be a boolean or an object with delayMs.");\n } else {\n for (const campo of Object.keys(value)) {\n if (campo !== "delayMs") errori.push(`spectators.${campo}: unknown field.`);\n }\n if (!interoTra(value.delayMs, 0, 3e4)) {\n errori.push("spectators.delayMs: must be an integer from 0 to 30000.");\n } else spectators = { delayMs: value.delayMs };\n }\n }\n let overlay = null;\n if (dati.overlay !== void 0 && dati.overlay !== null) {\n const value = oggetto(dati.overlay);\n if (value === null) errori.push("overlay: must be an object or null.");\n else {\n for (const key of Object.keys(value)) if (!["version", "accent"].includes(key)) errori.push(`overlay.${key}: unknown field.`);\n if (value.version !== 1) errori.push("overlay.version: must be exactly 1.");\n if (value.accent !== void 0 && (typeof value.accent !== "string" || !/^#[0-9a-fA-F]{6}$/.test(value.accent))) {\n errori.push("overlay.accent: must be a six-digit hexadecimal color, such as #336699.");\n }\n overlay = { version: 1, ...typeof value.accent === "string" ? { accent: value.accent } : {} };\n }\n }\n const boards = {};\n if (dati.boards !== void 0) {\n const value = oggetto(dati.boards);\n if (value === null) errori.push("boards: must be an object of board ids.");\n else {\n if (Object.keys(value).length > MASSIMO_CLASSIFICHE) {\n errori.push(`boards: at most ${MASSIMO_CLASSIFICHE} boards.`);\n }\n for (const [id2, raw] of Object.entries(value)) {\n let valido = true;\n if (!ID_CLASSIFICA.test(id2)) {\n errori.push(`boards.${id2}: invalid board id.`);\n valido = false;\n }\n const board = oggetto(raw);\n if (board === null) {\n errori.push(`boards.${id2}.source: must be "client" or "server".`);\n continue;\n }\n for (const campo of Object.keys(board)) {\n if (!["source", "label", "periods"].includes(campo)) errori.push(`boards.${id2}.${campo}: unknown field.`);\n }\n if (board.source !== "client" && board.source !== "server") {\n errori.push(`boards.${id2}.source: must be "client" or "server".`);\n valido = false;\n }\n const label = testoFacoltativo(board, "label", 48, `boards.${id2}`, errori);\n let periods = ["all-time"];\n if (board.periods !== void 0) {\n if (!Array.isArray(board.periods) || board.periods.length < 1 || board.periods.length > 2 || board.periods.some((period) => period !== "daily" && period !== "all-time") || new Set(board.periods).size !== board.periods.length) {\n errori.push(`boards.${id2}.periods: must contain daily, all-time, or both without duplicates.`);\n } else periods = [...board.periods];\n }\n if (valido) Object.defineProperty(boards, id2, { value: {\n source: board.source,\n periods,\n ...label === void 0 ? {} : { label }\n }, enumerable: true, configurable: true, writable: true });\n }\n }\n }\n const roles = [];\n if (dati.roles !== void 0) {\n if (!Array.isArray(dati.roles)) errori.push("roles: must be an array.");\n else {\n const ids = /* @__PURE__ */ new Set();\n for (const [indice, raw] of dati.roles.entries()) {\n const value = oggetto(raw);\n if (value === null) {\n errori.push(`roles[${indice}]: must be an object.`);\n continue;\n }\n for (const campo of Object.keys(value)) {\n if (!["id", "min", "max", "label"].includes(campo)) errori.push(`roles[${indice}].${campo}: unknown field.`);\n }\n const idRuolo = value.id;\n const min = value.min;\n const max = value.max;\n let valido = true;\n if (typeof idRuolo !== "string" || idRuolo.length > 32 || !ID_INTERNO.test(idRuolo)) {\n errori.push(`roles[${indice}].id: must be 1-32 lowercase letters, digits, or internal hyphens.`);\n valido = false;\n } else if (ids.has(idRuolo)) {\n errori.push(`roles[${indice}].id: duplicate role ${idRuolo}.`);\n valido = false;\n } else ids.add(idRuolo);\n if (!interoTra(min, 0, TETTO_GIOCATORI)) {\n errori.push(`roles[${indice}].min: must be an integer from 0 to ${TETTO_GIOCATORI}.`);\n valido = false;\n }\n if (max !== void 0 && !interoTra(max, 0, TETTO_GIOCATORI)) {\n errori.push(`roles[${indice}].max: must be an integer from 0 to ${TETTO_GIOCATORI} when present.`);\n valido = false;\n }\n if (typeof min === "number" && typeof max === "number" && min > max) {\n errori.push(`roles[${indice}].max: must be greater than or equal to min.`);\n valido = false;\n }\n const label = testoFacoltativo(value, "label", 32, `roles[${indice}]`, errori);\n if (valido) roles.push({\n id: idRuolo,\n min,\n ...max === void 0 ? {} : { max },\n ...label === void 0 ? {} : { label }\n });\n }\n }\n }\n let teams = null;\n if (dati.teams !== void 0 && dati.teams !== null) {\n const value = oggetto(dati.teams);\n if (value === null) errori.push("teams: must be null or an object with min and max.");\n else {\n for (const campo of Object.keys(value)) {\n if (campo !== "min" && campo !== "max") errori.push(`teams.${campo}: unknown field.`);\n }\n if (!interoTra(value.min, 2, TETTO_GIOCATORI)) errori.push(`teams.min: must be an integer from 2 to ${TETTO_GIOCATORI}.`);\n if (!interoTra(value.max, 2, TETTO_GIOCATORI)) errori.push(`teams.max: must be an integer from 2 to ${TETTO_GIOCATORI}.`);\n if (interoTra(value.min, 2, TETTO_GIOCATORI) && interoTra(value.max, 2, TETTO_GIOCATORI)) {\n if (value.min > value.max) errori.push("teams.max: must be greater than or equal to teams.min.");\n else teams = { min: value.min, max: value.max };\n }\n }\n }\n let voice = "none";\n if (dati.voice !== void 0) {\n if (typeof dati.voice !== "string" || !VOICE.has(dati.voice)) {\n errori.push("voice: must be none, room, team, or proximity.");\n } else voice = dati.voice;\n }\n const modes = [];\n if (dati.modes !== void 0) {\n if (!Array.isArray(dati.modes)) errori.push("modes: must be an array.");\n else {\n const ids = /* @__PURE__ */ new Set();\n for (const [indice, raw] of dati.modes.entries()) {\n const value = oggetto(raw);\n if (value === null) {\n errori.push(`modes[${indice}]: must be an object.`);\n continue;\n }\n for (const campo of Object.keys(value)) {\n if (!["id", "players", "lobby", "matchmaking", "execution", "label", "instructions"].includes(campo)) errori.push(`modes[${indice}].${campo}: unknown field.`);\n }\n if (typeof value.id !== "string" || value.id.length > 32 || !ID_INTERNO.test(value.id)) {\n errori.push(`modes[${indice}].id: must be 1-32 lowercase letters, digits, or internal hyphens.`);\n continue;\n }\n if (ids.has(value.id)) {\n errori.push(`modes[${indice}].id: duplicate mode ${value.id}.`);\n continue;\n }\n ids.add(value.id);\n const modo = { id: value.id };\n for (const [key2, max] of [["label", 48], ["instructions", 160]]) {\n const text = testoFacoltativo(value, key2, max, `modes[${indice}]`, errori);\n if (text !== void 0) modo[key2] = text;\n }\n if (value.execution !== void 0) {\n if (value.execution !== "local" && value.execution !== "room") errori.push(`modes[${indice}].execution: must be local or room.`);\n else modo.execution = value.execution;\n }\n if (overlay !== null && modo.execution === void 0) errori.push(`modes[${indice}].execution: is required with the standard overlay.`);\n if (value.players !== void 0) {\n const campo = `modes[${indice}].players`;\n const range = oggetto(value.players);\n if (range === null) errori.push(`${campo}: must be an object with min and max.`);\n else {\n for (const key2 of Object.keys(range)) {\n if (key2 !== "min" && key2 !== "max") errori.push(`${campo}.${key2}: unknown field.`);\n }\n if (!interoTra(range.min, 1, TETTO_GIOCATORI)) errori.push(`${campo}.min: must be an integer from 1 to ${TETTO_GIOCATORI}.`);\n if (!interoTra(range.max, 1, TETTO_GIOCATORI)) errori.push(`${campo}.max: must be an integer from 1 to ${TETTO_GIOCATORI}.`);\n if (interoTra(range.min, 1, TETTO_GIOCATORI) && interoTra(range.max, 1, TETTO_GIOCATORI)) {\n if (range.min > range.max) errori.push(`${campo}.max: must be greater than or equal to min.`);\n else modo.players = { min: range.min, max: range.max };\n }\n }\n }\n if (value.lobby !== void 0) {\n if (typeof value.lobby !== "boolean") errori.push(`modes[${indice}].lobby: must be a boolean.`);\n else modo.lobby = value.lobby;\n }\n if (modo.execution === "local") {\n const range = modo.players ?? players;\n if (range.min !== 1 || range.max !== 1) errori.push(`modes[${indice}].players: local execution requires min and max to be 1.`);\n if (modo.lobby ?? lobby) errori.push(`modes[${indice}].lobby: local execution requires false.`);\n if (value.matchmaking !== void 0) errori.push(`modes[${indice}].matchmaking: local execution cannot use matchmaking.`);\n }\n if (value.matchmaking === void 0) {\n modes.push(modo);\n continue;\n }\n const matchmaking = oggetto(value.matchmaking);\n if (matchmaking === null) {\n errori.push(`modes[${indice}].matchmaking: must be an object.`);\n continue;\n }\n for (const campo of Object.keys(matchmaking)) {\n if (!["key", "timeoutMs", "defaults"].includes(campo)) {\n errori.push(`modes[${indice}].matchmaking.${campo}: unknown field.`);\n }\n }\n let valido = true;\n const key = [];\n if (!Array.isArray(matchmaking.key) || matchmaking.key.length < 1 || matchmaking.key.length > 8) {\n errori.push(`modes[${indice}].matchmaking.key: must contain from 1 to 8 fields.`);\n valido = false;\n } else for (const [keyIndice, item] of matchmaking.key.entries()) {\n if (typeof item !== "string" || !CAMPO_MATCHMAKING.test(item)) {\n errori.push(`modes[${indice}].matchmaking.key[${keyIndice}]: must be 1-32 lowercase letters, digits, or hyphens and start with a letter or digit.`);\n valido = false;\n } else if (key.includes(item)) {\n errori.push(`modes[${indice}].matchmaking.key[${keyIndice}]: duplicate field ${item}.`);\n valido = false;\n } else key.push(item);\n }\n if (!interoTra(matchmaking.timeoutMs, 1e3, 3e5)) {\n errori.push(`modes[${indice}].matchmaking.timeoutMs: must be an integer from 1000 to 300000.`);\n valido = false;\n }\n let defaults;\n if (matchmaking.defaults !== void 0) {\n const values = oggetto(matchmaking.defaults);\n if (values === null || Object.keys(values).length !== key.length || key.some((field) => !Object.hasOwn(values, field))) {\n errori.push(`modes[${indice}].matchmaking.defaults: must contain exactly the declared key fields.`);\n } else {\n defaults = {};\n for (const [field, value2] of Object.entries(values)) {\n if (!(typeof value2 === "string" && value2.length >= 1 && value2.length <= 64 && /^[A-Za-z0-9_.:-]+$/.test(value2)) && !Number.isSafeInteger(value2)) {\n errori.push(`modes[${indice}].matchmaking.defaults.${field}: must be a string of 1-64 characters or a safe integer.`);\n } else Object.defineProperty(defaults, field, { value: value2, enumerable: true });\n }\n }\n }\n if (valido) modes.push({ ...modo, matchmaking: {\n ...defaults === void 0 ? {} : { defaults },\n key,\n timeoutMs: matchmaking.timeoutMs\n } });\n }\n }\n }\n if (overlay !== null && modes.length === 0) errori.push("modes: at least one explicit mode is required with the standard overlay.");\n if (errori.length > 0) return { ok: false, errori };\n return { ok: true, manifest: {\n manifest: 1,\n overlay,\n id,\n name,\n description,\n cover,\n screenshots,\n tags,\n languages: languages2,\n language,\n platform,\n orientation,\n input,\n visibility,\n network,\n isolated,\n requires,\n players,\n lobby,\n persistent,\n spectators,\n boards,\n roles,\n teams,\n voice,\n modes\n } };\n}\n\n// ../contracts/src/device.ts\nfunction deviceTier(report) {\n if (report.gpu !== "hardware" || report.memoryMb !== null && report.memoryMb <= 2048) return "low";\n if (report.mobile || report.memoryMb !== null && report.memoryMb <= 4096 || report.cores !== null && report.cores <= 4) return "mid";\n return "high";\n}\nfunction perdiContesto(context) {\n try {\n context?.getExtension("WEBGL_lose_context")?.loseContext();\n } catch {\n }\n}\nfunction valoriSincroni(ambiente) {\n let navigator2;\n try {\n navigator2 = ambiente.navigator;\n } catch {\n navigator2 = void 0;\n }\n let memoryMb = null;\n try {\n const memory = navigator2?.deviceMemory;\n const converted = typeof memory === "number" ? memory * 1024 : NaN;\n if (Number.isFinite(converted)) memoryMb = converted;\n } catch {\n memoryMb = null;\n }\n let cores = null;\n try {\n const value = navigator2?.hardwareConcurrency;\n if (typeof value === "number" && Number.isFinite(value)) cores = value;\n } catch {\n cores = null;\n }\n let mobile = false;\n try {\n mobile = typeof navigator2?.userAgentData?.mobile === "boolean" ? navigator2.userAgentData.mobile : /Android|iPhone|iPad|iPod|Mobile/i.test(navigator2?.userAgent ?? "");\n } catch {\n mobile = false;\n }\n let isolated = false;\n try {\n isolated = ambiente.crossOriginIsolated === true;\n } catch {\n isolated = false;\n }\n return {\n webgl2: false,\n webgpu: false,\n wasm: false,\n threads: false,\n isolated,\n gpu: "none",\n memoryMb,\n cores,\n mobile\n };\n}\nasync function probeDevice(globals, timeoutMs = 1500) {\n const ambiente = globals ?? globalThis;\n const report = valoriSincroni(ambiente);\n const webgl = Promise.resolve().then(() => {\n try {\n const canvas = ambiente.document?.createElement("canvas");\n if (canvas === void 0) return;\n const hardware = canvas.getContext("webgl2", { failIfMajorPerformanceCaveat: true });\n if (hardware !== null) {\n report.webgl2 = true;\n report.gpu = "hardware";\n perdiContesto(hardware);\n return;\n }\n const software = canvas.getContext("webgl2");\n if (software !== null) {\n report.webgl2 = true;\n report.gpu = "software";\n perdiContesto(software);\n }\n } catch {\n report.webgl2 = false;\n report.gpu = "none";\n }\n });\n const webgpu = Promise.resolve().then(async () => {\n let device;\n try {\n const gpu = ambiente.navigator?.gpu;\n if (gpu === void 0) return;\n const adapter = await gpu.requestAdapter();\n if (adapter === null) return;\n device = await adapter.requestDevice();\n report.webgpu = true;\n } catch {\n report.webgpu = false;\n } finally {\n try {\n device?.destroy?.();\n } catch {\n }\n }\n });\n const wasm = Promise.resolve().then(() => {\n try {\n report.wasm = ambiente.WebAssembly?.validate(\n new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0])\n ) === true;\n } catch {\n report.wasm = false;\n }\n });\n const threads = Promise.resolve().then(() => {\n try {\n if (ambiente.WebAssembly === void 0) return;\n new ambiente.WebAssembly.Memory({ initial: 1, maximum: 1, shared: true });\n report.threads = true;\n } catch {\n report.threads = false;\n }\n });\n let timer;\n await Promise.race([\n Promise.all([webgl, webgpu, wasm, threads]),\n new Promise((resolve) => {\n timer = setTimeout(resolve, Math.max(0, timeoutMs));\n })\n ]);\n if (timer !== void 0) clearTimeout(timer);\n return { ...report, tier: deviceTier(report) };\n}\n\n// ../contracts/src/overlay.ts\nfunction overlayConfiguration(manifest, coverUrl = null, invite = null) {\n const validated = validaManifest(manifest);\n if (!validated.ok) throw new Error("The overlay manifest is invalid.");\n return { manifest: validated.manifest, coverUrl, invite };\n}\nfunction record(value) {\n return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;\n}\nfunction validOverlayHello(value) {\n const hello = record(value), config = record(hello?.configuration);\n return hello?.v === 1 && typeof hello.epoch === "string" && hello.epoch.length > 0 && hello.epoch.length <= 128 && config !== null && (config.coverUrl === null || typeof config.coverUrl === "string") && (config.invite === null || typeof config.invite === "string" && /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/.test(config.invite)) && validaManifest(config.manifest).ok;\n}\nfunction normalizeOverlayHello(value) {\n if (!validOverlayHello(value)) return null;\n return { v: 1, epoch: value.epoch, configuration: overlayConfiguration(value.configuration.manifest, value.configuration.coverUrl, value.configuration.invite) };\n}\nfunction validSafeArea(value) {\n const area = record(value);\n return area !== null && Object.keys(area).length === 4 && ["top", "right", "bottom", "left"].every((key) => typeof area[key] === "number" && Number.isFinite(area[key]) && Number(area[key]) >= 0 && Number(area[key]) <= 1e5);\n}\nfunction validOverlayView(value) {\n const data = record(value);\n return data !== null && Object.keys(data).every((key) => ["inputBlocked", "reservedRects", "safeArea", "shortcutEnabled"].includes(key)) && (data.safeArea === void 0 || validSafeArea(data.safeArea)) && (data.shortcutEnabled === void 0 || typeof data.shortcutEnabled === "boolean") && typeof data.inputBlocked === "boolean" && Array.isArray(data.reservedRects) && data.reservedRects.length <= 8 && data.reservedRects.every((value2) => {\n const rect = record(value2);\n return rect !== null && Object.keys(rect).length === 4 && ["x", "y", "width", "height"].every((key) => typeof rect[key] === "number" && Number.isFinite(rect[key]) && rect[key] >= 0 && rect[key] <= 1e5);\n });\n}\nfunction validOverlayRequest(value) {\n const message = record(value), args = record(message?.args);\n if (message?.type !== "caisual:overlay" || message.v !== 1 || typeof message.epoch !== "string" || message.epoch.length < 1 || message.epoch.length > 128 || typeof message.requestId !== "string" || !(/^[1-9][0-9]{0,15}$/.test(message.requestId) && Number.isSafeInteger(Number(message.requestId))) || args === null) return false;\n if (Object.keys(message).some((key) => !["type", "v", "epoch", "requestId", "sessionId", "op", "args"].includes(key)) || !(message.sessionId === void 0 || message.sessionId === null || typeof message.sessionId === "string" && /^[1-9][0-9]{0,15}$/.test(message.sessionId))) return false;\n const keys = (...allowed) => Object.keys(args).every((key) => allowed.includes(key));\n const text = (key) => typeof args[key] === "string" && args[key].length >= 1 && args[key].length <= 64;\n switch (message.op) {\n case "local.start":\n return keys("mode") && text("mode");\n case "room.create":\n return keys("mode") && (args.mode === null || text("mode"));\n case "room.join":\n return keys("code") && (args.code === void 0 || text("code"));\n case "room.watch":\n return keys("code") && text("code");\n case "room.match": {\n const key = record(args.key);\n return keys("mode", "key") && text("mode") && (args.key === void 0 || key !== null && Object.keys(key).length <= 8 && Object.values(key).every((v) => typeof v === "string" && v.length >= 1 && v.length <= 64 || typeof v === "number" && Number.isSafeInteger(v)));\n }\n case "room.ready":\n return keys("ready") && typeof args.ready === "boolean";\n case "room.role":\n case "room.requestRole":\n return keys("role") && text("role");\n case "room.team":\n return keys("team") && Number.isInteger(args.team) && args.team >= 1 && args.team <= 24;\n case "room.restart":\n case "room.start":\n case "session.cancel":\n case "session.leave":\n case "session.disconnect":\n case "session.resume":\n return keys();\n case "voice.join":\n case "voice.leave":\n return keys() && typeof message.sessionId === "string";\n case "voice.mute":\n return keys("muted") && typeof args.muted === "boolean" && typeof message.sessionId === "string";\n case "voice.setVolume":\n return keys("playerId", "volume") && typeof message.sessionId === "string" && typeof args.playerId === "string" && args.playerId.length > 0 && args.playerId.length <= 128 && typeof args.volume === "number" && Number.isFinite(args.volume) && args.volume >= 0 && args.volume <= 1;\n case "overlay.view":\n return validOverlayView(args);\n default:\n return false;\n }\n}\nfunction validBoardDay(value) {\n if (typeof value !== "string" || !/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) return false;\n const at = Date.parse(`${value}T00:00:00Z`);\n return Number.isFinite(at) && new Date(at).toISOString().slice(0, 10) === value;\n}\n\n// ../contracts/src/room-limits.ts\nvar MESSAGGI_GIOCO_AL_SECONDO = 20;\n\n// src/overlay/i18n.ts\nvar languages = ["en", "it", "es", "fr", "de", "pt"];\nvar words = {\n gameLanguages: ["Game languages", "Lingue del gioco", "Idiomas del juego", "Langues du jeu", "Spielsprachen", "Idiomas do jogo"],\n loading: ["Loading game...", "Caricamento...", "Cargando...", "Chargement...", "Spiel wird geladen...", "Carregando..."],\n loadingSlow: ["This game is taking longer than expected. You can wait a little longer or try again.", "Il gioco ci sta mettendo pi\\xF9 del previsto. Puoi aspettare ancora un po\\u2019 o riprovare.", "El juego est\\xE1 tardando m\\xE1s de lo esperado. Puedes esperar un poco m\\xE1s o volver a intentarlo.", "Le jeu met plus de temps que pr\\xE9vu. Vous pouvez patienter encore un peu ou r\\xE9essayer.", "Das Spiel braucht l\\xE4nger als erwartet. Du kannst noch etwas warten oder es erneut versuchen.", "O jogo est\\xE1 demorando mais do que o esperado. Voc\\xEA pode esperar mais um pouco ou tentar novamente."],\n home: ["Play", "Gioca", "Jugar", "Jouer", "Spielen", "Jogar"],\n homeMenu: ["Menu", "Menu", "Men\\xFA", "Menu", "Men\\xFC", "Menu"],\n mode: ["Mode", "Modalit\\xE0", "Modo", "Mode", "Modus", "Modo"],\n play: ["Play", "Gioca", "Jugar", "Jouer", "Spielen", "Jogar"],\n friendsPlay: ["Play with friends", "Gioca con amici", "Jugar con amigos", "Jouer entre amis", "Mit Freunden spielen", "Jogar com amigos"],\n find: ["Find players", "Trova giocatori", "Buscar jugadores", "Trouver des joueurs", "Spieler finden", "Buscar jogadores"],\n join: ["Join with code", "Entra con codice", "Entrar con c\\xF3digo", "Rejoindre avec un code", "Mit Code beitreten", "Entrar com c\\xF3digo"],\n joinInvite: ["Join this room", "Entra in questa stanza", "Entrar en est\\xE1 sala", "Rejoindre cette salle", "Diesem Raum beitreten", "Entrar nest\\xE1 sala"],\n watch: ["Watch a room", "Guarda una stanza", "Observar una sala", "Regarder une salle", "Raum ansehen", "Assistir a uma sala"],\n resume: ["Resume", "Riprendi", "Continuar", "Reprendre", "Fortsetzen", "Continuar"],\n room: ["Room", "Stanza", "Sala", "Salle", "Raum", "Sala"],\n code: ["Room code", "Codice stanza", "C\\xF3digo de sala", "Code de salle", "Raumcode", "C\\xF3digo da sala"],\n copy: ["Copy invite", "Copia invito", "Copiar invitaci\\xF3n", "Copier le lien", "Einladung kopieren", "Copiar convite"],\n copied: ["Invite copied", "Invito copiato", "Invitacion copiada", "Lien copi\\xE9", "Einladung kopiert", "Convite copiado"],\n copyFailed: ["Copy this link:", "Copia questo link:", "Copia este enlace:", "Copiez ce lien :", "Diesen Link kopieren:", "Copie este link:"],\n joining: ["Joining room...", "Ingresso nella stanza...", "Entrando en la sala...", "Connexion \\xE0 la salle...", "Raum wird betreten...", "Entrando na sala..."],\n matching: ["Finding your people...", "Ricerca giocatori...", "Buscando jugadores...", "Recherche de joueurs...", "Spieler werden gesucht...", "Buscando jogadores..."],\n queue: ["{n} / {max} players", "{n} / {max} giocatori", "{n} / {max} jugadores", "{n} / {max} joueurs", "{n} / {max} Spieler", "{n} / {max} jogadores"],\n cancel: ["Cancel", "Annulla", "Cancelar", "Annuler", "Abbrechen", "Cancelar"],\n close: ["Close", "Chiudi", "Cerrar", "Fermer", "Schlie\\xDFen", "Fechar"],\n back: ["Back", "Indietro", "Volver", "Retour", "Zur\\xFCck", "Voltar"],\n ready: ["Ready", "Pronto", "Listo", "Pr\\xEAt", "Bereit", "Pronto"],\n unready: ["Not ready", "Non pronto", "No listo", "Pas pr\\xEAt", "Nicht bereit", "N\\xE3o pronto"],\n start: ["Start", "Inizia", "Empezar", "Commencer", "Starten", "Come\\xE7ar"],\n role: ["Role", "Ruolo", "Rol", "R\\xF4le", "Rolle", "Fun\\xE7\\xE3o"],\n team: ["Team", "Squadra", "Equipo", "\\xC9quipe", "Team", "Equipe"],\n host: ["Host", "Host", "Anfitrion", "H\\xF4te", "Host", "Anfitri\\xE3o"],\n you: ["You", "Tu", "T\\xFA", "Vous", "Du", "Voc\\xEA"],\n away: ["Away", "Assente", "Ausente", "Absent", "Abwesend", "Ausente"],\n needPlayers: ["Waiting for more players", "In attesa di giocatori", "Esperando m\\xE1s jugadores", "En attente de joueurs", "Weitere Spieler fehlen", "Esperando mais jogadores"],\n needReady: ["Everyone needs to be ready", "Tutti devono essere pronti", "Todos deben estar listos", "Tout le monde doit \\xEAtre pr\\xEAt", "Alle m\\xFCssen bereit sein", "Todos precisam estar prontos"],\n needRoles: ["Fill the required roles", "Completa i ruoli richiesti", "Completa los roles", "Compl\\xE9tez les r\\xF4les", "Ben\\xF6tigte Rollen besetzen", "Complete as fun\\xE7\\xF5es"],\n needTeams: ["Choose the required teams", "Scegli le squadre richieste", "Elige los equipos", "Choisissez les \\xE9quipes", "Teams auswahlen", "Escolha as equipes"],\n waitHost: ["Waiting for the host", "In attesa dell\'host", "Esperando al anfitrion", "En attente de l\\u2019h\\xF4te", "Warten auf den Host", "Esperando o anfitri\\xE3o"],\n starting: ["Starting in", "Si inizia tra", "Empieza en", "D\\xE9but dans", "Start in", "Come\\xE7a em"],\n playing: ["Playing", "In partita", "Jugando", "En jeu", "Im Spiel", "Jogando"],\n ended: ["Game finished", "Partita conclusa", "Partida terminada", "Partie termin\\xE9e", "Spiel beendet", "Partida encerrada"],\n rematchReady: ["{n}/{max} ready", "{n}/{max} pronti", "{n}/{max} listos", "{n}/{max} pr\\xEAts", "{n}/{max} bereit", "{n}/{max} prontos"],\n rematchStart: ["Start rematch", "Avvia rivincita", "Iniciar revancha", "Lancer la revanche", "Revanche starten", "Iniciar revanche"],\n again: ["Play again", "Gioca ancora", "Jugar de nuevo", "Rejouer", "Erneut spielen", "Jogar novamente"],\n newRoom: ["New room. Share the new invite.", "Nuova stanza. Condividi il nuovo invito.", "Nueva sala. Comparte la invitaci\\xF3n.", "Nouvelle salle. Partagez le lien.", "Neuer Raum. Neue Einladung teilen.", "Nova sala. Compartilhe o convite."],\n watching: ["Watching", "In osservazione", "Observando", "Spectateur", "Zuschauen", "Assistindo"],\n delay: ["{n}s delay", "Ritardo {n}s", "Retraso de {n}s", "Retard de {n}s", "{n}s Verz\\xF6gerung", "Atraso de {n}s"],\n exit: ["Exit", "Esci", "Salir", "Quitter", "Verlassen", "Sair"],\n leaveNow: ["Leave for now", "Esci per ora", "Salir por ahora", "Quitter pour le moment", "Vor\\xFCbergehend verlassen", "Sair por enquanto"],\n leaveRoom: ["Leave room", "Lascia la stanza", "Abandonar sala", "Abandonner la salle", "Raum verlassen", "Deixar a sala"],\n leaveHint: ["Your room stays available for Resume.", "La stanza resta disponibile con Riprendi.", "Podr\\xE1s volver a est\\xE1 sala.", "Vous pourrez reprendre cette salle.", "Du kannst den Raum fortsetzen.", "Voc\\xEA pode voltar a est\\xE1 sala."],\n temporaryHint: ["The game continues. Rejoining may only be possible briefly.", "La partita continua. Il rientro pu\\xF2 essere disponibile solo per poco.", "La partida continua. Volver puede ser posible solo por poco tiempo.", "La partie continue. Le retour peut \\xEAtre limit\\xE9.", "Das Spiel l\\xE4uft weiter. R\\xFCckkehr nur kurz m\\xF6glich.", "A partida continua. O retorno pode ser limitado."],\n abandonHint: ["Leave room gives up your place.", "Lascia la stanza libera il tuo posto.", "Abandonar libera tu plaza.", "Abandonner lib\\xE8re votre place.", "Raum verlassen gibt deinen Platz frei.", "Deixar a sala libera sua vaga."],\n reconnecting: ["Reconnecting...", "Riconnessione...", "Reconectando...", "Reconnexion...", "Verbindung wird erneuert...", "Reconectando..."],\n replaced: ["Opened in another tab", "Aperta in un\\u2019altra scheda", "Abierta en otra pest\\xE1na", "Ouverte dans un autre onglet", "In anderem Tab ge\\xF6ffnet", "Aberta em outra aba"],\n error: ["Something went wrong. Try again.", "Qualcosa non va. Riprova.", "Algo sali\\xF3 mal. Reintenta.", "Une erreur est survenue. R\\xE9essayez.", "Etwas ist schiefgelaufen. Erneut versuchen.", "Algo deu errado. Tente novamente."],\n noRoom: ["This room is no longer available.", "Questa stanza non \\xE8 pi\\xF9 disponibile.", "Esta sala ya no est\\xE1 disponible.", "Cette salle n\'est plus disponible.", "Dieser Raum ist nicht mehr verf\\xFCgbar.", "Esta sala n\\xE3o est\\xE1 mais disponivel."],\n full: ["This room is full.", "La stanza \\xE8 piena.", "La sala est\\xE1 llena.", "Cette salle est pleine.", "Dieser Raum ist voll.", "Esta sala est\\xE1 cheia."],\n noMatch: ["No match this time. Try again.", "Nessun gruppo trovato. Riprova.", "No hay grupo. Reintenta.", "Aucun groupe trouv\\xE9. R\\xE9essayez.", "Keine Gruppe gefunden. Erneut versuchen.", "Nenhum grupo encontrado. Tente novamente."],\n invalidCode: ["Enter a six-character room code.", "Inserisci un codice di sei caratteri.", "Escribe un c\\xF3digo de seis caracteres.", "Entrez un code de six caracteres.", "Sechsstelligen Raumcode eingeben.", "Digite um c\\xF3digo de seis caracteres."],\n refused: ["The room did not accept that change.", "La stanza ha rifiutato la modifica.", "La sala rechaz\\xF3 el cambio.", "La salle a refus\\xE9 ce changement.", "Der Raum hat die \\xC4nderung abgelehnt.", "A sala recusou a altera\\xE7\\xE3o."],\n unavailable: ["Unavailable right now", "Non disponibile ora", "No disponible ahora", "Indisponible pour le moment", "Derzeit nicht verf\\xFCgbar", "Indisponivel agora"],\n offline: ["Connection unavailable. Try again.", "Connessione non disponibile. Riprova.", "Sin conexi\\xF3n. Reintenta.", "Connexion indisponible. R\\xE9essayez.", "Keine Verbindung. Erneut versuchen.", "Sem conex\\xE3o. Tente novamente."],\n saveFailed: ["Keep the room code. Resume could not be saved.", "Conserva il codice. Riprendi non \\xE8 stato salvato.", "Guarda el c\\xF3digo. No se pudo guardar el regreso.", "Gardez le code. La reprise ne peut pas \\xEAtre enregistr\\xE9e.", "Raumcode aufbewahren. Fortsetzen nicht gespeichert.", "Guarde o c\\xF3digo. O retorno n\\xE3o foi salvo."],\n boards: ["Leaderboard", "Classifica", "Clasificaci\\xF3n", "Classement", "Bestenliste", "Classifica\\xE7\\xE3o"],\n board: ["Board", "Classifica", "Tabla", "Classement", "Bestenliste", "Tabela"],\n daily: ["Daily", "Giornaliera", "Diaria", "Du jour", "T\\xE4glich", "Di\\xE1ria"],\n allTime: ["All time", "Di sempre", "Hist\\xF3rica", "Tous les temps", "Gesamt", "Geral"],\n accounts: ["Accounts", "Account", "Cuentas", "Comptes", "Konten", "Contas"],\n guests: ["Guests", "Ospiti", "Invitados", "Invit\\xE9s", "G\\xE4ste", "Visitantes"],\n category: ["Category", "Categoria", "Categoria", "Cat\\xE9gorie", "Kategorie", "Categoria"],\n period: ["Period", "Periodo", "Per\\xEDodo", "P\\xE9riode", "Zeitraum", "Per\\xEDodo"],\n rank: ["Rank", "Posizione", "Puesto", "Rang", "Platz", "Posicao"],\n score: ["Score", "Punteggio", "Puntos", "Score", "Punkte", "Pontos"],\n verified: ["Verified", "Verificato", "Verificado", "V\\xE9rifi\\xE9", "Verifiziert", "Verificado"],\n own: ["Your best", "Il tuo record", "Tu record", "Votre record", "Dein Rekord", "Seu recorde"],\n empty: ["No scores yet", "Nessun punteggio", "A\\xFAn no hay puntos", "Aucun score", "Noch keine Punkte", "Ainda sem pontos"],\n saving: ["Saving score...", "Salvataggio punteggio...", "Guardando puntos...", "Enregistrement du score...", "Punkte werden gespeichert...", "Salvando pontos..."],\n saved: ["Your best is on the board", "Il tuo record \\xE8 in classifica", "Tu record est\\xE1 en la tabla", "Votre record est au classement", "Dein Rekord ist eingetragen", "Seu recorde est\\xE1 na tabela"],\n bestAlready: ["Your best is already on the board", "Il tuo record era gi\\xE0 in classifica", "Tu record ya estaba en la tabla", "Votre record est d\\xE9j\\xE0 au classement", "Dein Rekord ist bereits eingetragen", "Seu recorde j\\xE1 est\\xE1 na tabela"],\n refresh: ["Refresh", "Aggiorna", "Actualizar", "Actualiser", "Aktualisieren", "Atualizar"],\n refreshHint: ["Score not visible yet. Refresh to check.", "Punteggio non ancora visibile. Aggiorna per controllare.", "Puntos a\\xFAn no visibles. Actualiza.", "Score pas encore visible. Actualisez.", "Punkte noch nicht sichtbar. Aktualisieren.", "Pontos ainda n\\xE3o visiveis. Atualize."],\n friends: ["Friends & party", "Amici e gruppo", "Amigos y grupo", "Amis et groupe", "Freunde & Gruppe", "Amigos e grupo"],\n localCrew: ["Friends and party are unavailable in local preview.", "Amici e gruppo non disponibili in anteprima locale.", "Amigos y grupo no disponibles en la vista local.", "Amis et groupe indisponibles en aper\\xE7u local.", "Freunde und Gruppe in lokaler Vorschau nicht verf\\xFCgbar.", "Amigos e grupo indispon\\xEDveis na pr\\xE9via local."],\n loginCrew: ["Sign in on Caisual to use friends and party.", "Accedi a Caisual per amici e gruppo.", "Inicia sesion para amigos y grupo.", "Connectez-vous pour utiliser amis et groupe.", "F\\xFCr Freunde und Gruppe bei Caisual anmelden.", "Entre no Caisual para amigos e grupo."],\n online: ["Online", "Online", "En linea", "En ligne", "Online", "Online"],\n noFriends: ["No friends online", "Nessun amico online", "Sin amigos en linea", "Aucun ami en ligne", "Keine Freunde online", "Nenhum amigo online"],\n createParty: ["Create party", "Crea gruppo", "Crear grupo", "Cr\\xE9er un groupe", "Gruppe erstellen", "Criar grupo"],\n inviteParty: ["Invite to party", "Invita nel gruppo", "Invitar al grupo", "Inviter au groupe", "In Gruppe einladen", "Convidar para o grupo"],\n leaveParty: ["Leave party", "Lascia gruppo", "Salir del grupo", "Quitter le groupe", "Gruppe verlassen", "Sair do grupo"],\n accept: ["Accept", "Accetta", "Aceptar", "Accepter", "Annehmen", "Aceitar"],\n decline: ["Decline", "Rifiuta", "Rechazar", "Refuser", "Ablehnen", "Recusar"],\n follow: ["Join them", "Raggiungi", "Unirse", "Rejoindre", "Beitreten", "Juntar-se"],\n voice: ["Voice", "Voce", "Voz", "Voix", "Sprache", "Voz"],\n voiceJoin: ["Join voice", "Entra in voce", "Unirse a voz", "Activer la voix", "Sprachchat beitreten", "Entrar na voz"],\n voiceLeave: ["Leave voice", "Esci dalla voce", "Salir de voz", "Quitter la voix", "Sprachchat verlassen", "Sair da voz"],\n voiceMute: ["Mute", "Disattiva microfono", "Silenciar", "Couper le micro", "Stummschalten", "Silenciar"],\n voiceUnmute: ["Unmute", "Attiva microfono", "Activar micr\\xF3fono", "Activer le micro", "Mikrofon aktivieren", "Ativar microfone"],\n voiceOff: ["Voice off", "Voce disattivata", "Voz desactivada", "Voix d\\xE9sactiv\\xE9e", "Sprachchat aus", "Voz desativada"],\n voiceJoining: ["Joining voice...", "Connessione voce...", "Conectando voz...", "Connexion vocale...", "Sprachchat verbindet...", "Conectando voz..."],\n voiceOn: ["Voice connected", "Voce connessa", "Voz conectada", "Voix connect\\xE9e", "Sprachchat verbunden", "Voz conectada"],\n voiceMuted: ["Muted", "Microfono disattivato", "Silenciado", "Micro coup\\xE9", "Stumm", "Silenciado"],\n voiceMic: ["Mic on", "Microfono attivo", "Micr\\xF3fono activo", "Micro actif", "Mikrofon an", "Microfone ativo"],\n voiceListening: ["Listening only", "Solo ascolto", "Solo escucha", "\\xC9coute seule", "Nur zuh\\xF6ren", "Somente ouvindo"],\n voiceSpeaking: ["Speaking", "Sta parlando", "Hablando", "Parle", "Spricht", "Falando"],\n voicePeers: ["Voice participants", "Partecipanti in voce", "Participantes de voz", "Participants vocaux", "Sprachteilnehmer", "Participantes de voz"],\n voiceEmpty: ["No one else in voice yet.", "Nessun altro in voce per ora.", "A\\xFAn no hay nadie m\\xE1s en voz.", "Personne d\\u2019autre en voix pour le moment.", "Noch niemand im Sprachchat.", "Ningu\\xE9m mais na voz ainda."],\n voiceVolume: ["Volume for {name}", "Volume di {name}", "Volumen de {name}", "Volume de {name}", "Lautst\\xE4rke f\\xFCr {name}", "Volume de {name}"],\n voiceUnavailable: ["Join a room with voice to use these controls.", "Entra in una stanza con voce per usare questi controlli.", "Entra en una sala con voz para usar estos controles.", "Rejoignez une salle vocale pour utiliser ces commandes.", "Diese Steuerung braucht einen Raum mit Sprachchat.", "Entre em uma sala com voz para usar estes controles."],\n voiceWatch: ["Voice is unavailable while watching.", "La voce non e\' disponibile in osservazione.", "La voz no est\\xE1 disponible al observar.", "La voix est indisponible en observation.", "Beim Zuschauen ist kein Sprachchat verf\\xFCgbar.", "A voz n\\xE3o est\\xE1 dispon\\xEDvel ao assistir."],\n voiceDenied: ["Microphone permission denied. Allow it in your browser, then try again.", "Permesso microfono negato. Consenti l\'accesso nel browser e riprova.", "Permiso de micr\\xF3fono denegado. Act\\xEDvalo en el navegador e int\\xE9ntalo de nuevo.", "Acc\\xE8s au micro refus\\xE9. Autorisez-le dans le navigateur, puis r\\xE9essayez.", "Mikrofonzugriff verweigert. Im Browser erlauben und erneut versuchen.", "Permiss\\xE3o do microfone negada. Permita no navegador e tente novamente."],\n voiceUnsupported: ["Voice is not supported in this browser.", "Questo browser non supporta la voce.", "Este navegador no admite voz.", "Ce navigateur ne prend pas en charge la voix.", "Dieser Browser unterst\\xFCtzt keinen Sprachchat.", "Este navegador n\\xE3o oferece suporte a voz."],\n voiceFailed: ["Voice could not connect. Try again.", "Connessione voce non riuscita. Riprova.", "No se pudo conectar la voz. Int\\xE9ntalo de nuevo.", "Connexion vocale impossible. R\\xE9essayez.", "Sprachverbindung fehlgeschlagen. Erneut versuchen.", "N\\xE3o foi poss\\xEDvel conectar a voz. Tente novamente."],\n voicePeerGone: ["This participant has left voice.", "Questo partecipante e\' uscito dalla voce.", "Este participante sali\\xF3 de voz.", "Ce participant a quitt\\xE9 la voix.", "Diese Person hat den Sprachchat verlassen.", "Este participante saiu da voz."],\n shortcut: ["Shift+Tab shortcut", "Scorciatoia Shift+Tab", "Atajo Shift+Tab", "Raccourci Maj+Tab", "Umschalt+Tab-Kurzbefehl", "Atalho Shift+Tab"],\n menu: ["Caisual menu", "Menu Caisual", "Menu Caisual", "Menu Caisual", "Caisual-Menu", "Menu Caisual"],\n retry: ["Retry", "Riprova", "Reintentar", "R\\xE9essayer", "Erneut versuchen", "Tentar novamente"]\n};\nvar column = (index) => Object.fromEntries(Object.entries(words).map(([key, row]) => [key, row[index]]));\nvar dictionaries = { en: column(0), it: column(1), es: column(2), fr: column(3), de: column(4), pt: column(5) };\nfunction overlayLocale(raw) {\n const tag = normalizeLanguage(raw);\n return tag && languages.includes(tag.split("-")[0]) ? tag : "en";\n}\n\n// src/text.ts\nfunction createTextLoader(fetcher, language, pathname = "/") {\n let pending;\n const root = pathname.match(/^\\/rt\\/[^/]+\\/[1-9][0-9]*\\//)?.[0] ?? "/";\n return () => pending ?? (pending = (async () => {\n let dictionary = {};\n try {\n const response = await fetcher(`${root}__caisual/text/${encodeURIComponent(language)}.json`);\n if (response.ok) {\n const value = await response.json();\n if (isTextDictionary(value)) dictionary = value;\n }\n } catch {\n }\n return (key, values = {}) => {\n if (!Object.hasOwn(dictionary, key)) return key;\n const text = dictionary[key];\n return text.replace(/\\{([^{}]+)\\}/g, (placeholder, name) => Object.hasOwn(values, name) ? String(values[name]) : placeholder);\n };\n })());\n}\n\n// src/errors.ts\nfunction creaErrore(code, message) {\n return Object.assign(new Error(message), { name: "CaisualError", code });\n}\nfunction erroreOffline() {\n return creaErrore("offline", "Caisual services are unavailable.");\n}\nfunction codiceErrore(valore) {\n return typeof valore === "object" && valore !== null && "code" in valore ? valore.code : null;\n}\n\n// src/session/resume.ts\nvar KEY = "caisual-session-v1";\nfunction resume(value) {\n const data = record(value);\n if (!data || typeof data.code !== "string" || !/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/.test(data.code) || !(data.mode === void 0 || data.mode === null || typeof data.mode === "string")) return null;\n return {\n version: 1,\n code: data.code,\n mode: typeof data.mode === "string" ? data.mode : null,\n updatedAt: typeof data.updatedAt === "number" && Number.isFinite(data.updatedAt) ? data.updatedAt : 0\n };\n}\nfunction createResume(save, changed) {\n let current = null, error = false, work = Promise.resolve();\n const write = async () => {\n const value = { version: 1, imported: true, resume: current };\n work = work.catch(() => void 0).then(async () => {\n try {\n await save.set(KEY, value);\n error = false;\n } catch (cause) {\n error = true;\n throw cause;\n } finally {\n changed();\n }\n });\n return work;\n };\n const loaded = (async () => {\n try {\n const data = record(await save.get(KEY));\n if (data?.version === 1 && data.imported === true) current = resume(data.resume);\n else {\n current = resume(await save.get("resume"));\n await write();\n }\n } catch {\n error = true;\n }\n changed();\n })();\n return {\n loaded,\n get value() {\n return current === null ? null : { ...current };\n },\n get error() {\n return error;\n },\n async set(value) {\n await loaded;\n current = value;\n changed();\n await write();\n }\n };\n}\n\n// src/session/index.ts\nfunction notify(listeners, value) {\n for (const listener of listeners) {\n try {\n listener(value);\n } catch {\n }\n }\n}\nfunction createSession(base, configuration = null, roomsAvailable = base.connected) {\n const standard = configuration?.manifest.overlay?.version === 1;\n const manifest = configuration?.manifest;\n let current = { kind: "idle" }, ready = false, operation = 0, identifier = 0;\n let pending = null, pendingMode = null;\n let waiting = null, controller = null;\n let stops = [], disposed = false, lastState = "";\n let view = { inputBlocked: standard, reservedRects: [], safeArea: { top: 0, right: 0, bottom: 0, left: 0 } };\n const listeners = /* @__PURE__ */ new Set();\n const viewListeners = /* @__PURE__ */ new Set();\n const stateListeners = /* @__PURE__ */ new Set();\n const openListeners = /* @__PURE__ */ new Set();\n const errorListeners = /* @__PURE__ */ new Set();\n const scoreListeners = /* @__PURE__ */ new Set();\n let resumeStore = null;\n const capabilities = () => ({\n local: true,\n rooms: roomsAvailable,\n overlay: standard,\n requestRole: current.kind === "room" && current.room.metadata.configuration?.requestRole === true\n });\n function voiceSnapshot() {\n if (current.kind !== "room" || !manifest || manifest.voice === "none") return null;\n const room = current.room, voice = room.voice;\n if (!voice || voice.mode === "none" || room.players.find((p) => p.id === room.you)?.role === "spectator") return null;\n return {\n mode: voice.mode,\n state: voice.state,\n mic: voice.mic,\n muted: voice.muted,\n speaking: voice.speaking,\n peers: voice.peers.map(({ id, mic, muted, speaking, volume }) => ({ id, mic, muted, speaking, volume }))\n };\n }\n function snapshot() {\n const attached = current.kind === "room" || current.kind === "watch" ? current.room : null;\n const configured = attached?.metadata.configuration;\n const fallback = manifest && attached && (attached.mode === null || manifest.modes.some((m) => m.id === attached.mode)) ? risolviModalita(manifest, attached.mode) : { players: { min: 1, max: 1 }, lobby: false };\n return {\n kind: pending ?? (current.kind === "idle" ? ready ? "home" : "boot" : current.kind),\n id: current.kind === "idle" ? null : current.id,\n mode: pending ? pendingMode : current.kind === "local" ? current.mode : attached?.mode ?? null,\n localStatus: current.kind === "local" ? current.status : null,\n ready,\n capabilities: capabilities(),\n room: attached ? {\n code: attached.code,\n mode: attached.mode,\n status: attached.status,\n host: attached.host,\n you: current.kind === "room" ? current.room.you : null,\n players: attached.players.map((p) => ({ id: p.id, name: p.name, guest: p.guest, role: p.role, team: p.team, ready: p.ready, connected: p.connected })),\n countdownAt: attached.countdownAt,\n connection: attached.connection,\n closedCode: attached.metadata.closedCode,\n limits: { ...configured?.players ?? fallback.players },\n lobby: configured?.lobby ?? fallback.lobby,\n persistent: configured?.persistent ?? manifest?.persistent ?? false,\n delayMs: current.kind === "watch" ? current.room.delayMs : null,\n requestRole: configured?.requestRole ?? false\n } : null,\n voice: pending ? null : voiceSnapshot(),\n waiting: waiting ? { ...waiting } : null,\n resume: resumeStore?.value ?? null,\n resumeError: resumeStore?.error ?? false\n };\n }\n function emit() {\n if (disposed) return;\n const state = snapshot(), serialized = JSON.stringify(state);\n if (serialized === lastState) return;\n lastState = serialized;\n notify(stateListeners, state);\n }\n function changed() {\n notify(listeners, { ...current });\n emit();\n }\n function active() {\n if (current.kind !== "room") throw creaErrore("no_room", "There is no active player room.");\n return current.room;\n }\n function activeVoice() {\n const room = active();\n if (room.players.find((p) => p.id === room.you)?.role === "spectator") throw creaErrore("spectator", "Spectators cannot use voice controls.");\n if (!manifest || manifest.voice === "none" || room.voice.mode === "none") throw creaErrore("voice_disabled", "Voice is disabled for this room.");\n return room.voice;\n }\n function cancel() {\n operation++;\n controller?.abort();\n controller = null;\n pending = null;\n waiting = null;\n emit();\n }\n function detach(preserve) {\n stops.splice(0).forEach((stop) => stop());\n if (current.kind === "room" || current.kind === "watch") {\n if (preserve) current.room.disconnect();\n else current.room.leave();\n }\n current = { kind: "idle" };\n changed();\n }\n async function clearResume(code) {\n if (resumeStore?.value?.code === code) await resumeStore.set(null).catch(() => void 0);\n }\n async function adopt(next, watch, token) {\n if (token !== operation || disposed) {\n next.leave();\n throw creaErrore("cancelled", "The operation was cancelled.");\n }\n detach(false);\n current = watch ? { kind: "watch", room: next, id: String(++identifier) } : { kind: "room", room: next, id: String(++identifier) };\n const room = next;\n stops = [room.onPlayers(emit), room.onMetadata(() => {\n if (room.connection === "disconnected" && (current.kind === "room" || current.kind === "watch") && current.room === room) {\n stops.splice(0).forEach((stop) => stop());\n if (!watch && room.metadata.closedCode === 1e3) void clearResume(room.code);\n current = { kind: "idle" };\n changed();\n } else emit();\n }), room.onStatus(() => {\n emit();\n if (!watch && room.connection === "ended") void clearResume(room.code);\n })];\n if (!watch) {\n const playerRoom = next;\n const sessionId = current.id;\n if (playerRoom.voice) stops.push(playerRoom.voice.onState(emit), playerRoom.voice.onPeers(emit));\n stops.push(playerRoom.onError((error) => notify(errorListeners, { sessionId, error: { ...error } })));\n stops.push(playerRoom.onScoreQueued((score) => notify(scoreListeners, { ...score })));\n for (const score of playerRoom.queuedScores) notify(scoreListeners, { ...score });\n }\n pending = null;\n waiting = null;\n changed();\n if (!watch && resumeStore && room.connection !== "ended") {\n await resumeStore.set({ version: 1, code: room.code, mode: room.mode, updatedAt: base.time.now() }).catch(() => void 0);\n }\n return next;\n }\n async function run(kind, mode, work, watch = false) {\n cancel();\n const token = operation;\n controller = new AbortController();\n pending = kind;\n pendingMode = mode;\n emit();\n try {\n const next = await work(controller.signal, token);\n await adopt(next, watch, token);\n if (token !== operation || disposed) throw creaErrore("cancelled", "The operation was cancelled.");\n return next;\n } finally {\n if (token === operation) {\n pending = null;\n waiting = null;\n controller = null;\n emit();\n }\n }\n }\n const direct = base.room;\n const rooms = !standard ? direct : {\n invited: direct.invited,\n create(options) {\n if (manifest && modalitaLocale(manifest, options.mode)) return Promise.reject(creaErrore("invalid_request", "Local modes cannot create rooms."));\n return run("attaching", options.mode, () => direct.create(options));\n },\n join(code) {\n return run("attaching", null, () => direct.join(code));\n },\n watch(code) {\n return run("attaching", null, () => direct.watch(code), true);\n },\n match(options) {\n if (manifest && modalitaLocale(manifest, options.mode)) return Promise.reject(creaErrore("invalid_request", "Local modes cannot use matchmaking."));\n return run("matching", options.mode, (signal, token) => {\n const abort = () => {\n if (operation === token) cancel();\n };\n options.signal?.addEventListener("abort", abort, { once: true });\n if (options.signal?.aborted) abort();\n return direct.match({ ...options, signal, onWaiting(value) {\n if (token !== operation) return;\n waiting = { ...value };\n emit();\n options.onWaiting?.(value);\n } }).finally(() => options.signal?.removeEventListener("abort", abort));\n });\n }\n };\n if (standard) resumeStore = createResume(base.save, emit);\n const session = {\n get current() {\n return { ...current };\n },\n get capabilities() {\n return capabilities();\n },\n onChange(listener) {\n listeners.add(listener);\n notify(/* @__PURE__ */ new Set([listener]), { ...current });\n return () => {\n listeners.delete(listener);\n };\n },\n ready() {\n if (disposed || ready) return;\n ready = true;\n emit();\n },\n finish() {\n if (current.kind === "room" || current.kind === "watch") throw creaErrore("not_local", "Only a local session can be finished by the client.");\n if (current.kind === "local") {\n current = { ...current, status: "ended" };\n changed();\n }\n }\n };\n const overlay = {\n open(panel) {\n if (!["home", "room", "invite", "friends", "voice", "boards"].includes(panel)) throw creaErrore("invalid_request", "Unknown overlay panel.");\n if (standard) notify(openListeners, panel);\n },\n onChange(listener) {\n viewListeners.add(listener);\n notify(/* @__PURE__ */ new Set([listener]), structuredClone(view));\n return () => {\n viewListeners.delete(listener);\n };\n }\n };\n return {\n session,\n overlay,\n rooms,\n snapshot,\n serverTime: () => current.kind === "room" || current.kind === "watch" ? current.room.serverTime() : base.time.now(),\n onState(listener) {\n stateListeners.add(listener);\n listener(snapshot());\n return () => {\n stateListeners.delete(listener);\n };\n },\n onOpen(listener) {\n openListeners.add(listener);\n return () => {\n openListeners.delete(listener);\n };\n },\n onError(listener) {\n errorListeners.add(listener);\n return () => {\n errorListeners.delete(listener);\n };\n },\n onScore(listener) {\n scoreListeners.add(listener);\n return () => {\n scoreListeners.delete(listener);\n };\n },\n async execute(request) {\n if (!standard) throw creaErrore("overlay_disabled", "This game uses its own room flow.");\n if (request.op === "overlay.view") {\n if (!validOverlayView(request.args)) throw creaErrore("invalid_request", "The overlay geometry is invalid.");\n view = { ...structuredClone(request.args), safeArea: { top: 0, right: 0, bottom: 0, left: 0, ...request.args.safeArea } };\n if (typeof document !== "undefined") for (const [side, value] of Object.entries(view.safeArea)) {\n document.documentElement.style.setProperty(`--caisual-safe-${side}`, `${value}px`);\n }\n notify(viewListeners, structuredClone(view));\n return;\n }\n if (request.sessionId !== void 0 && request.sessionId !== (current.kind === "idle" ? null : current.id)) throw creaErrore("session_replaced", "The active session changed.");\n if (request.op.startsWith("voice.") && request.sessionId !== (current.kind === "idle" ? null : current.id)) throw creaErrore("session_replaced", "The active session changed.");\n if (!ready) throw creaErrore("game_not_ready", "The game is still loading.");\n switch (request.op) {\n case "local.start": {\n if (!manifest || !modalitaLocale(manifest, request.args.mode)) throw creaErrore("invalid_mode", "This is not a local mode.");\n cancel();\n const token = operation;\n if (current.kind === "room") await clearResume(current.room.code);\n if (token !== operation || disposed) throw creaErrore("cancelled", "The operation was cancelled.");\n detach(false);\n current = { kind: "local", id: String(++identifier), mode: request.args.mode, status: "playing" };\n changed();\n return;\n }\n case "room.create":\n await rooms.create(request.args);\n return;\n case "room.join":\n await rooms.join(request.args.code);\n return;\n case "room.watch":\n await rooms.watch(request.args.code);\n return;\n case "room.match": {\n const mode = manifest?.modes.find((m) => m.id === request.args.mode);\n const key = request.args.key ?? mode?.matchmaking?.defaults;\n if (!key) throw creaErrore("invalid_request", "Matchmaking needs a complete key.");\n await rooms.match({ mode: request.args.mode, key });\n return;\n }\n case "voice.join": {\n const room = active(), voice = activeVoice();\n await voice.join();\n if (current.kind !== "room" || current.room !== room) throw creaErrore("session_replaced", "The active session changed.");\n emit();\n return;\n }\n case "voice.mute":\n activeVoice().mute(request.args.muted);\n emit();\n return;\n case "voice.leave":\n activeVoice().leave();\n emit();\n return;\n case "voice.setVolume": {\n const voice = activeVoice();\n if (!voice.peers.some((peer) => peer.id === request.args.playerId)) throw creaErrore("voice_peer_missing", "This voice participant is no longer available.");\n voice.setVolume(request.args.playerId, request.args.volume);\n emit();\n return;\n }\n case "room.ready":\n active().ready(request.args.ready);\n return;\n case "room.role":\n active().setRole(request.args.role);\n return;\n case "room.requestRole":\n await active().requestRole(request.args.role);\n return;\n case "room.team":\n active().setTeam(request.args.team);\n return;\n case "room.start":\n active().start();\n return;\n case "room.restart":\n active().restart();\n return;\n case "session.cancel":\n cancel();\n return;\n case "session.resume": {\n await run("attaching", null, async (signal) => {\n await resumeStore?.loaded;\n if (signal.aborted) throw creaErrore("cancelled", "The operation was cancelled.");\n if (!resumeStore?.value) throw creaErrore("no_resume", "There is no saved room.");\n return direct.join(resumeStore.value.code);\n });\n return;\n }\n case "session.disconnect": {\n cancel();\n const token = operation;\n if (current.kind === "room" && current.room.connection !== "ended" && resumeStore) await resumeStore.set({ version: 1, code: current.room.code, mode: current.room.mode, updatedAt: base.time.now() });\n if (token !== operation || disposed) throw creaErrore("cancelled", "The operation was cancelled.");\n detach(true);\n return;\n }\n case "session.leave": {\n cancel();\n const token = operation;\n if (current.kind === "room") await clearResume(current.room.code);\n if (token !== operation || disposed) throw creaErrore("cancelled", "The operation was cancelled.");\n detach(false);\n return;\n }\n }\n },\n dispose() {\n cancel();\n detach(true);\n disposed = true;\n listeners.clear();\n viewListeners.clear();\n stateListeners.clear();\n openListeners.clear();\n scoreListeners.clear();\n errorListeners.clear();\n }\n };\n}\n\n// src/overlay/shortcut.ts\nfunction bindOverlayShortcut(target, overlay, open) {\n let enabled = true, blocked = false;\n const stop = overlay.onChange((view) => {\n enabled = view.shortcutEnabled !== false;\n blocked = view.inputBlocked;\n });\n const listener = (event) => {\n const element = event.target;\n if (!enabled || blocked || event.repeat || event.key !== "Tab" || !event.shiftKey || event.ctrlKey || event.altKey || event.metaKey || element?.closest?.(\'input,textarea,select,[contenteditable="true"]\')) return;\n event.preventDefault();\n event.stopImmediatePropagation();\n open();\n };\n target.addEventListener("keydown", listener, true);\n return () => {\n stop();\n target.removeEventListener("keydown", listener, true);\n };\n}\n\n// src/overlay/bridge.ts\nfunction attachKitBridge(port, hello, coordinator) {\n let disposed = false, seq = 0, highestRequest = 0, activeRequests = 0;\n const replies = /* @__PURE__ */ new Map();\n const send = (message) => {\n if (!disposed) try {\n port.postMessage(message);\n } catch {\n }\n };\n const stops = [\n ...hello.configuration.manifest.overlay && typeof window !== "undefined" ? [bindOverlayShortcut(window, coordinator.overlay, () => send({ type: "caisual:overlay-shortcut", v: 1, epoch: hello.epoch }))] : [],\n coordinator.onState((state) => send({ type: "caisual:overlay-state", v: 1, epoch: hello.epoch, seq: ++seq, serverTime: coordinator.serverTime(), state })),\n coordinator.onOpen((panel) => send({ type: "caisual:overlay-open", v: 1, epoch: hello.epoch, panel })),\n coordinator.onError(({ sessionId, error }) => send({ type: "caisual:overlay-error", v: 1, epoch: hello.epoch, sessionId, error })),\n coordinator.onScore((score) => send({ type: "caisual:overlay-score", v: 1, epoch: hello.epoch, score }))\n ];\n const listener = (event) => {\n const raw = record(event.data);\n if (raw?.type !== "caisual:overlay" || raw.epoch !== hello.epoch || disposed) return;\n const reply = { type: "caisual:overlay-response", v: 1, epoch: hello.epoch, requestId: typeof raw.requestId === "string" ? raw.requestId : "" };\n if (!validOverlayRequest(raw)) {\n send({ ...reply, ok: false, error: { code: "invalid_request", message: "The overlay request is invalid." } });\n return;\n }\n const fingerprint = JSON.stringify([raw.op, raw.args, raw.sessionId]);\n const previous = replies.get(raw.requestId);\n if (previous) {\n if (previous.fingerprint !== fingerprint) send({ ...reply, ok: false, error: { code: "duplicate_request", message: "The request id was already used." } });\n else void previous.response.then(send);\n return;\n }\n if (Number(raw.requestId) <= highestRequest || activeRequests >= 32) {\n send({ ...reply, ok: false, error: { code: "stale_request", message: "The request is stale or too many requests are pending." } });\n return;\n }\n highestRequest = Number(raw.requestId);\n activeRequests++;\n const response = Promise.resolve().then(() => coordinator.execute(raw)).then(\n () => ({ ...reply, ok: true }),\n (error) => ({ ...reply, ok: false, error: {\n code: typeof record(error)?.code === "string" ? record(error).code : "internal_error",\n message: error instanceof Error ? error.message : "The operation could not be completed."\n } })\n );\n replies.set(raw.requestId, { fingerprint, response });\n void response.then((value) => {\n activeRequests--;\n send(value);\n if (replies.size > 64) for (const id of replies.keys()) {\n if (Number(id) < highestRequest - 64) replies.delete(id);\n }\n });\n };\n port.addEventListener("message", listener);\n port.start();\n return () => {\n disposed = true;\n port.removeEventListener("message", listener);\n stops.forEach((stop) => stop());\n coordinator.dispose();\n replies.clear();\n };\n}\n\n// src/http.ts\nasync function leggiErrore(response) {\n let corpo = {};\n try {\n corpo = await response.json();\n } catch {\n }\n return creaErrore(\n typeof corpo.error?.code === "string" ? corpo.error.code : response.status === 401 ? "invalid_ticket" : "internal_error",\n typeof corpo.error?.message === "string" ? corpo.error.message : `The request failed with status ${response.status}.`\n );\n}\nfunction creaRichiedente(origin, prefix, fetcher, biglietto) {\n async function manda(path, metodo, ticket, corpo) {\n const headers = new Headers({ Authorization: `Bearer ${ticket}` });\n let body;\n if (corpo !== void 0) {\n headers.set("Content-Type", "application/json");\n try {\n body = JSON.stringify(corpo);\n } catch {\n throw creaErrore("invalid_request", "The value must be valid JSON.");\n }\n }\n try {\n return await fetcher(new URL(prefix + path, origin), {\n method: metodo,\n headers,\n body,\n credentials: "omit"\n });\n } catch {\n throw erroreOffline();\n }\n }\n return async function richiesta(path, metodo, corpo, forzaRinnovo = false) {\n let ticket;\n try {\n ticket = forzaRinnovo ? await biglietto.rinnova() : await biglietto.ottieni();\n } catch {\n throw erroreOffline();\n }\n let response = await manda(path, metodo, ticket, corpo);\n if (response.status === 401) {\n try {\n ticket = await biglietto.rinnova();\n } catch {\n throw erroreOffline();\n }\n response = await manda(path, metodo, ticket, corpo);\n }\n if (!response.ok) throw await leggiErrore(response);\n try {\n return await response.json();\n } catch {\n throw creaErrore("internal_error", "The service returned an invalid response.");\n }\n };\n}\n\n// src/api.ts\nfunction creaClienteApi(appOrigin, fetcher, biglietto) {\n const richiesta = creaRichiedente(appOrigin, "/api/kit", fetcher, biglietto);\n return {\n me: () => richiesta("/me", "GET"),\n saveSet: (key, value) => richiesta(`/saves/${encodeURIComponent(key)}`, "PUT", { value }),\n async saveGet(key) {\n try {\n return (await richiesta(`/saves/${encodeURIComponent(key)}`, "GET")).value;\n } catch (errore) {\n if (codiceErrore(errore) === "not_found") return null;\n throw errore;\n }\n },\n async saveRemove(key) {\n await richiesta(`/saves/${encodeURIComponent(key)}`, "DELETE");\n },\n async saveList() {\n return (await richiesta("/saves", "GET")).saves;\n },\n async boardSubmit(board, score, daily) {\n const risultato = await richiesta("/scores", "POST", { board, score, daily });\n return {\n accepted: true,\n best: risultato.best,\n rank: risultato.rank,\n day: risultato.day,\n verified: risultato.verified\n };\n },\n async boardTop(board, opzioni) {\n if (opzioni.day !== void 0 && (!validBoardDay(opzioni.day) || opzioni.daily === false)) throw creaErrore("invalid_request", "day must be a real UTC date and cannot be combined with daily: false.");\n const query = new URLSearchParams();\n if (opzioni.day !== void 0) query.set("day", opzioni.day);\n if (opzioni.daily) query.set("daily", "1");\n if (opzioni.limit !== void 0) query.set("limit", String(opzioni.limit));\n if (opzioni.guests) query.set("guests", "1");\n const suffisso = query.size === 0 ? "" : `?${query.toString()}`;\n const { day, entries, me } = await richiesta(\n `/scores/${encodeURIComponent(board)}${suffisso}`,\n "GET"\n );\n return { day, entries, me };\n }\n };\n}\n\n// src/daily.ts\nvar DIVISORE_UINT32 = 4294967296;\nfunction giornoUtc(ora) {\n return new Date(ora).toISOString().slice(0, 10);\n}\nasync function calcolaSeed(gioco, giorno, subtle) {\n const dati = new TextEncoder().encode(`caisual:${gioco}:${giorno}`);\n const digest = new Uint8Array(await subtle.digest("SHA-256", dati));\n return (digest[0] ?? 0) * 16777216 + ((digest[1] ?? 0) << 16) + ((digest[2] ?? 0) << 8) + (digest[3] ?? 0) >>> 0;\n}\nfunction creaMulberry32(seed) {\n let stato = seed >>> 0;\n return () => {\n stato = stato + 1831565813 >>> 0;\n let valore = stato;\n valore = Math.imul(valore ^ valore >>> 15, valore | 1);\n valore ^= valore + Math.imul(valore ^ valore >>> 7, valore | 61);\n return ((valore ^ valore >>> 14) >>> 0) / DIVISORE_UINT32;\n };\n}\n\n// src/handshake.ts\nfunction record2(valore) {\n return typeof valore === "object" && valore !== null && !Array.isArray(valore) ? valore : null;\n}\nfunction eTipo(valore, tipo) {\n return record2(valore)?.type === tipo;\n}\nfunction leggiOrigine(valore) {\n if (typeof valore !== "string") return null;\n try {\n const url = new URL(valore);\n return url.origin === valore && (url.protocol === "https:" || url.protocol === "http:") ? valore : null;\n } catch {\n return null;\n }\n}\nfunction attendiHandshake(finestra, appOrigin, timeoutMs = 3e3) {\n return new Promise((resolve) => {\n let concluso = false;\n const instance = globalThis.crypto.randomUUID();\n const termina = (esito) => {\n if (concluso) return;\n concluso = true;\n finestra.removeEventListener("message", ascolta);\n finestra.clearTimeout(scadenza);\n resolve(esito);\n };\n const segnalaPronto = () => {\n finestra.parent.postMessage({ type: "caisual:ready", instance, overlayVersion: 1 }, appOrigin);\n };\n const ascolta = (evento) => {\n if (evento.origin !== appOrigin || evento.source !== finestra.parent) return;\n if (eTipo(evento.data, "caisual:ready?")) {\n segnalaPronto();\n return;\n }\n if (!eTipo(evento.data, "caisual:hello")) return;\n const dati = record2(evento.data);\n const porta = evento.ports[0];\n if (typeof dati?.ticket !== "string" || porta === void 0) return;\n porta.start();\n const overlay = normalizeOverlayHello(dati.overlay);\n const tags = (value) => Array.isArray(value) ? value.map(normalizeLanguage).filter((tag) => tag !== null) : void 0;\n termina({\n ...overlay ? { overlay } : {},\n ...normalizeLanguage(dati.language) ? { language: normalizeLanguage(dati.language) } : {},\n uiLanguage: normalizeLanguage(dati.uiLanguage) ?? void 0,\n languagePreferences: tags(dati.languagePreferences),\n gameLanguages: tags(dati.gameLanguages),\n ticket: dati.ticket,\n live: leggiOrigine(dati.live),\n invite: typeof dati.invite === "string" ? dati.invite : null,\n porta\n });\n };\n finestra.addEventListener("message", ascolta);\n const scadenza = finestra.setTimeout(() => termina(null), timeoutMs);\n segnalaPronto();\n });\n}\nfunction scadenzaJwt(ticket) {\n const parte = ticket.split(".")[1];\n if (parte === void 0) return null;\n const base64 = parte.replace(/-/g, "+").replace(/_/g, "/").padEnd(\n Math.ceil(parte.length / 4) * 4,\n "="\n );\n try {\n const payload = record2(JSON.parse(globalThis.atob(base64)));\n return typeof payload?.exp === "number" && Number.isFinite(payload.exp) ? payload.exp * 1e3 : null;\n } catch {\n return null;\n }\n}\nfunction chiediBiglietto(porta, finestra, timeoutMs, aud) {\n return new Promise((resolve, reject) => {\n let concluso = false;\n const termina = (ticket) => {\n if (concluso) return;\n concluso = true;\n porta.removeEventListener("message", ascolta);\n finestra.clearTimeout(scadenza);\n if (ticket === null) reject(new Error("Ticket refresh timed out."));\n else resolve(ticket);\n };\n const ascolta = (evento) => {\n const dati = record2(evento.data);\n const destinatario = dati?.aud === void 0 ? "portal" : dati.aud;\n if (dati?.type === "caisual:ticket" && destinatario === aud && typeof dati.ticket === "string") {\n termina(dati.ticket);\n }\n };\n porta.addEventListener("message", ascolta);\n const scadenza = finestra.setTimeout(() => termina(null), timeoutMs);\n try {\n porta.postMessage(aud === "live" ? { type: "caisual:ticket", aud: "live" } : { type: "caisual:ticket" });\n } catch {\n termina(null);\n }\n });\n}\nfunction creaGestoreBiglietto(ticketIniziale, porta, finestra, ora, timeoutMs = 3e3, aud = "portal") {\n let ticket = ticketIniziale;\n let rinnovo = null;\n const rinnova = () => {\n if (rinnovo !== null) return rinnovo;\n const richiesta = chiediBiglietto(porta, finestra, timeoutMs, aud).then((nuovo) => {\n ticket = nuovo;\n return nuovo;\n });\n const completa = richiesta.finally(() => {\n if (rinnovo === completa) rinnovo = null;\n });\n rinnovo = completa;\n return completa;\n };\n return {\n async ottieni() {\n if (ticket === null) return rinnova();\n const scadenza = scadenzaJwt(ticket);\n return scadenza !== null && scadenza - ora() < 3e4 ? rinnova() : ticket;\n },\n rinnova\n };\n}\n\n// src/voce/index.ts\nvar SOGLIA_AUDIO = 0.02;\nvar DURATA_PARLANTE = 300;\nvar INTERVALLO_AUDIO = 200;\nvar DURATA_ZERO = 3e3;\nvar TIMEOUT_CONNESSIONE = 1e4;\nvar RITARDI_RICONNESSIONE = [1e3, 2e3, 4e3];\nfunction limita(value) {\n return Number.isNaN(value) ? 1 : Math.min(1, Math.max(0, value));\n}\nfunction dipendenzeReali(input) {\n const globali = globalThis;\n const AudioContextClass = globali.AudioContext ?? globali.webkitAudioContext;\n if (typeof RTCPeerConnection === "undefined" || typeof MediaStream === "undefined" || AudioContextClass === void 0 || typeof navigator === "undefined" || navigator.mediaDevices?.getUserMedia === void 0 || typeof document === "undefined") return null;\n return {\n ...input,\n creaPeerConnection: (configuration) => new RTCPeerConnection(configuration),\n getUserMedia: (constraints) => navigator.mediaDevices.getUserMedia(constraints),\n creaAudioContext: () => new AudioContextClass(),\n creaAudioElement: () => document.createElement("audio"),\n creaMediaStream: (tracks) => new MediaStream(tracks)\n };\n}\nvar VoceClient = class {\n constructor(contesto, timer, dipendenze) {\n this.contesto = contesto;\n this.modeCorrente = "none";\n this.stateCorrente = "off";\n this.mutedCorrente = false;\n this.speakingCorrente = false;\n this.roster = [];\n this.gains = /* @__PURE__ */ new Map();\n this.volumi = /* @__PURE__ */ new Map();\n this.speakingPeers = /* @__PURE__ */ new Map();\n this.ultimoAudio = /* @__PURE__ */ new Map();\n this.zeroDa = /* @__PURE__ */ new Map();\n this.timerZero = /* @__PURE__ */ new Map();\n this.ascoltatoriPeers = /* @__PURE__ */ new Set();\n this.ascoltatoriState = /* @__PURE__ */ new Set();\n this.richieste = /* @__PURE__ */ new Map();\n this.riproduzioni = /* @__PURE__ */ new Map();\n this.sfuAttive = /* @__PURE__ */ new Map();\n this.midGiocatori = /* @__PURE__ */ new Map();\n this.negati = /* @__PURE__ */ new Set();\n this.mesh = /* @__PURE__ */ new Map();\n this.stream = null;\n this.tracciaMic = null;\n this.audioContext = null;\n this.analyser = null;\n this.peerSfu = null;\n this.sessioneSfu = null;\n this.connessioneSfuAttesa = false;\n this.trasporto = null;\n this.intervalloAudio = null;\n this.timerConnessione = null;\n this.cancellaAttesaConnessione = null;\n this.timerRiconnessione = null;\n this.ultimoAudioMic = Number.NEGATIVE_INFINITY;\n this.sequenzaRichieste = 0;\n this.generazione = 0;\n this.tentativoRiconnessione = 0;\n this.desiderata = false;\n this.micDesiderato = true;\n this.promessaIngresso = null;\n this.negoziazione = Promise.resolve();\n this.dipendenze = dipendenze ?? dipendenzeReali(timer);\n }\n get mode() {\n return this.modeCorrente;\n }\n get state() {\n return this.stateCorrente;\n }\n get mic() {\n return this.stateCorrente === "on" && this.tracciaMic !== null;\n }\n get muted() {\n return this.mutedCorrente;\n }\n get speaking() {\n return this.speakingCorrente;\n }\n get peers() {\n return this.copiaPeers();\n }\n async join(options = {}) {\n if (this.stateCorrente === "on") return;\n if (this.stateCorrente === "joining") {\n if (this.promessaIngresso !== null) await this.promessaIngresso;\n return;\n }\n if (this.stateCorrente === "reconnecting" && this.desiderata) return;\n const mic = this.scegliMic(options);\n this.verificaIngresso(mic);\n this.micDesiderato = mic;\n this.desiderata = true;\n this.tentativoRiconnessione = 0;\n this.aggiornaState("joining");\n const generazione = ++this.generazione;\n const promessa = this.completaIngresso(generazione);\n this.promessaIngresso = promessa;\n try {\n await promessa;\n } finally {\n if (this.promessaIngresso === promessa) this.promessaIngresso = null;\n }\n }\n async completaIngresso(generazione) {\n try {\n await this.entra(generazione);\n } catch (cause) {\n if (generazione !== this.generazione) return;\n this.desiderata = false;\n this.chiudiRisorse();\n this.aggiornaState("off");\n throw this.mappaErrore(cause);\n }\n }\n leave() {\n const deveFermare = this.desiderata || this.stateCorrente !== "off";\n this.desiderata = false;\n this.generazione++;\n this.fermaRiconnessione();\n if (deveFermare && this.contesto.connessa()) {\n void this.richiedi({ t: "voice", op: "stop" }).catch(() => void 0);\n }\n this.rifiutaRichieste(creaErrore("offline", "Voice has stopped."));\n this.chiudiRisorse();\n this.aggiornaState("off");\n }\n mute(muted = true) {\n if (this.stateCorrente !== "on" || this.tracciaMic === null) {\n throw creaErrore("not_publishing", "Join voice before changing mute.");\n }\n this.mutedCorrente = muted;\n this.tracciaMic.enabled = !muted;\n if (muted) this.speakingCorrente = false;\n this.notificaPeers();\n void this.richiedi({ t: "voice", op: "mute", muted }).catch(() => void 0);\n }\n setVolume(playerId, volume) {\n const valore = limita(volume);\n this.volumi.set(playerId, valore);\n this.aggiornaGuadagno(playerId);\n this.notificaPeers();\n }\n onPeers(listener) {\n this.ascoltatoriPeers.add(listener);\n return () => {\n this.ascoltatoriPeers.delete(listener);\n };\n }\n onState(listener) {\n this.ascoltatoriState.add(listener);\n return () => {\n this.ascoltatoriState.delete(listener);\n };\n }\n ricevi(message) {\n if ("r" in message) {\n const pending = this.richieste.get(message.r);\n if (pending !== void 0) {\n this.richieste.delete(message.r);\n if ("error" in message) {\n pending.reject(creaErrore(message.error.code, message.error.message));\n } else pending.resolve(message);\n }\n return;\n }\n if (message.op === "roster") {\n this.negati.clear();\n this.modeCorrente = message.mode;\n const publisher = new Set(message.peers.map((peer) => peer.id));\n this.roster = [\n ...message.peers.map((peer) => ({ ...peer, mic: true })),\n ...message.listeners.flatMap((id) => publisher.has(id) ? [] : [{ id, mic: false, muted: true }])\n ];\n for (const peer of this.roster) {\n if (peer.muted) this.speakingPeers.set(peer.id, false);\n }\n this.pulisciPeerAssenti();\n this.contesto.rosterPronto();\n this.notificaPeers();\n this.accodaRiconciliazione();\n return;\n }\n if (message.op === "gain") {\n this.negati.clear();\n for (const [playerId, gain] of Object.entries(message.gains)) {\n this.gains.set(playerId, limita(gain));\n this.aggiornaZero(playerId);\n this.aggiornaGuadagno(playerId);\n }\n this.notificaPeers();\n this.accodaRiconciliazione();\n return;\n }\n if (message.op === "closed") {\n for (const mid of message.mids) {\n const playerId = this.midGiocatori.get(mid);\n if (playerId === void 0) continue;\n const attiva = this.sfuAttive.get(playerId);\n if (attiva?.mid === mid && !this.riproduzioni.has(playerId)) attiva.receiver?.track.stop();\n if (attiva?.mid === mid) this.sfuAttive.delete(playerId);\n this.midGiocatori.delete(mid);\n this.scollegaTraccia(playerId);\n this.negati.add(playerId);\n }\n this.notificaPeers();\n return;\n }\n if (message.op === "signal") void this.riceviSegnale(message.from, message.data);\n }\n giocatoriCambiati() {\n this.negati.clear();\n const presenti = new Set(this.contesto.giocatori().map((player) => player.id));\n for (const playerId of this.gains.keys()) {\n if (presenti.has(playerId)) continue;\n this.gains.delete(playerId);\n this.zeroDa.delete(playerId);\n const timer = this.timerZero.get(playerId);\n if (timer !== void 0) this.dipendenze?.clearTimeout(timer);\n this.timerZero.delete(playerId);\n this.aggiornaGuadagno(playerId);\n }\n this.notificaPeers();\n this.accodaRiconciliazione();\n }\n socketDisconnesso() {\n this.sequenzaRichieste = 0;\n this.rifiutaRichieste(creaErrore("offline", "The room is reconnecting."));\n if (!this.desiderata) return;\n this.generazione++;\n this.chiudiRisorse();\n this.tentativoRiconnessione = 0;\n this.aggiornaState("reconnecting");\n }\n socketRiconnesso() {\n this.sequenzaRichieste = 0;\n if (this.desiderata && this.stateCorrente === "reconnecting") this.programmaRiconnessione();\n }\n termina() {\n this.desiderata = false;\n this.generazione++;\n this.fermaRiconnessione();\n this.rifiutaRichieste(creaErrore("offline", "The room connection ended."));\n this.chiudiRisorse();\n this.aggiornaState("off");\n }\n scegliMic(options) {\n if (options.mic !== void 0) return options.mic;\n const you = this.contesto.giocatori().find((player) => player.id === this.contesto.you());\n return you?.role !== "spectator";\n }\n verificaIngresso(mic = this.micDesiderato) {\n if (!this.contesto.connessa()) throw creaErrore("offline", "The room is not connected.");\n if (this.modeCorrente === "none") {\n throw creaErrore("voice_disabled", "Voice is disabled for this room.");\n }\n const you = this.contesto.giocatori().find((player) => player.id === this.contesto.you());\n if (you?.role === "spectator" && mic) {\n throw creaErrore("spectator", "Spectators cannot publish voice.");\n }\n if (this.dipendenze === null) {\n throw creaErrore("unsupported", "Voice is not supported in this browser.");\n }\n }\n async entra(generazione) {\n this.verificaIngresso();\n const dipendenze = this.richiediDipendenze();\n const audioContext = dipendenze.creaAudioContext();\n this.audioContext = audioContext;\n if (this.micDesiderato) {\n let stream;\n try {\n stream = await dipendenze.getUserMedia({ audio: true });\n } catch (cause) {\n if (this.permessoNegato(cause)) {\n throw creaErrore("permission_denied", "Microphone permission was denied.");\n }\n throw creaErrore("voice_error", "The microphone could not be opened.");\n }\n try {\n this.controllaGenerazione(generazione);\n } catch (cause) {\n for (const track of stream.getTracks()) track.stop();\n throw cause;\n }\n const mic = stream.getAudioTracks()[0];\n if (mic === void 0) throw creaErrore("voice_error", "The microphone has no audio track.");\n this.stream = stream;\n this.tracciaMic = mic;\n mic.enabled = !this.mutedCorrente;\n this.preparaAnalizzatore(stream);\n }\n try {\n await audioContext.resume();\n } catch {\n }\n this.controllaGenerazione(generazione);\n const risposta = await this.richiedi({ t: "voice", op: "ice" });\n this.controllaGenerazione(generazione);\n if (risposta.op !== "ice") throw creaErrore("voice_error", "The voice service returned an invalid response.");\n this.modeCorrente = risposta.mode;\n if (risposta.mode === "none") throw creaErrore("voice_disabled", "Voice is disabled for this room.");\n this.trasporto = risposta.transport;\n if (risposta.transport === "sfu") {\n await this.entraSfu(risposta.iceServers, generazione);\n } else {\n await this.richiedi({ t: "voice", op: "publish", mic: this.micDesiderato });\n }\n if (this.micDesiderato && this.mutedCorrente) {\n await this.richiedi({ t: "voice", op: "mute", muted: true });\n }\n this.controllaGenerazione(generazione);\n this.tentativoRiconnessione = 0;\n this.aggiornaState("on");\n this.avviaMisuraAudio();\n for (const playerId of this.gains.keys()) this.aggiornaZero(playerId);\n this.accodaRiconciliazione();\n }\n async entraSfu(iceServers, generazione) {\n const pc = this.richiediDipendenze().creaPeerConnection({\n iceServers,\n bundlePolicy: "max-bundle"\n });\n this.peerSfu = pc;\n pc.ontrack = (event) => {\n const mid = event.transceiver.mid;\n const playerId = mid === null ? void 0 : this.midGiocatori.get(mid);\n if (playerId !== void 0) this.collegaTraccia(playerId, event.track, event.receiver);\n };\n this.osservaCaduta(pc);\n let risposta;\n if (this.micDesiderato) {\n const transceiver = pc.addTransceiver(this.richiediMic(), { direction: "sendonly" });\n const offer = await pc.createOffer();\n await pc.setLocalDescription(offer);\n this.controllaGenerazione(generazione);\n const mid = transceiver.mid;\n const sdp = pc.localDescription?.sdp;\n if (mid === null || sdp === void 0) {\n throw creaErrore("voice_error", "The voice connection could not create an offer.");\n }\n risposta = await this.richiedi({ t: "voice", op: "session", sdp, mid });\n } else {\n risposta = await this.richiedi({ t: "voice", op: "session" });\n }\n if (risposta.op !== "session") {\n throw creaErrore("voice_error", "The voice service returned an invalid response.");\n }\n this.sessioneSfu = risposta.session;\n if (this.micDesiderato) {\n if (risposta.sdp === null) {\n throw creaErrore("voice_error", "The voice service returned an invalid response.");\n }\n await pc.setRemoteDescription({ type: "answer", sdp: risposta.sdp });\n await this.attendiConnessione(pc, generazione);\n this.connessioneSfuAttesa = true;\n return;\n }\n if (risposta.sdp !== null) {\n throw creaErrore("voice_error", "The voice service returned an invalid response.");\n }\n if (this.publisherDesiderati().length > 0) {\n await this.riconciliaSfu();\n }\n }\n attendiConnessione(pc, generazione) {\n if (pc.connectionState === "connected") return Promise.resolve();\n const dipendenze = this.richiediDipendenze();\n return new Promise((resolve, reject) => {\n const pulisci = () => {\n pc.removeEventListener("connectionstatechange", cambiata);\n if (this.timerConnessione !== null) dipendenze.clearTimeout(this.timerConnessione);\n this.timerConnessione = null;\n this.cancellaAttesaConnessione = null;\n };\n const cambiata = () => {\n if (generazione !== this.generazione) {\n pulisci();\n reject(creaErrore("offline", "Voice was stopped."));\n } else if (pc.connectionState === "connected") {\n pulisci();\n resolve();\n } else if (pc.connectionState === "failed" || pc.connectionState === "closed") {\n pulisci();\n reject(creaErrore("voice_error", "The voice connection failed."));\n }\n };\n pc.addEventListener("connectionstatechange", cambiata);\n this.cancellaAttesaConnessione = () => {\n pulisci();\n reject(creaErrore("offline", "Voice was stopped."));\n };\n this.timerConnessione = dipendenze.setTimeout(() => {\n pulisci();\n reject(creaErrore("voice_error", "The voice connection timed out."));\n }, TIMEOUT_CONNESSIONE);\n });\n }\n accodaRiconciliazione() {\n if (this.stateCorrente !== "on") return;\n this.negoziazione = this.negoziazione.then(async () => {\n if (this.stateCorrente !== "on") return;\n if (this.trasporto === "sfu") await this.riconciliaSfu();\n else if (this.trasporto === "mesh") this.riconciliaMesh();\n }).catch(() => this.avviaRiconnessione());\n }\n async riconciliaSfu() {\n const sessione = this.sessioneSfu;\n const pc = this.peerSfu;\n if (sessione === null || pc === null) return;\n const desiderati = new Map(this.publisherDesiderati().map((peer) => [peer.id, peer]));\n const daChiudere = [];\n for (const [playerId, attiva] of this.sfuAttive) {\n const peer = desiderati.get(playerId);\n if (peer !== void 0 && peer.session === attiva.session && peer.track === attiva.track) continue;\n daChiudere.push(attiva);\n if (!this.riproduzioni.has(playerId)) attiva.receiver?.track.stop();\n this.sfuAttive.delete(playerId);\n this.midGiocatori.delete(attiva.mid);\n this.scollegaTraccia(playerId);\n }\n if (daChiudere.length > 0) {\n await this.richiedi({\n t: "voice",\n op: "close",\n session: sessione,\n mids: daChiudere.map((item) => item.mid)\n });\n }\n const nuove = [...desiderati.values()].filter((peer) => !this.sfuAttive.has(peer.id));\n if (nuove.length === 0) return;\n let risposta;\n try {\n risposta = await this.richiedi({\n t: "voice",\n op: "subscribe",\n session: sessione,\n tracks: nuove.map((peer) => ({ session: peer.session, track: peer.track }))\n });\n } catch (cause) {\n if (codiceErrore(cause) !== "not_allowed") throw cause;\n for (const peer of nuove) this.negati.add(peer.id);\n return;\n }\n if (risposta.op !== "subscribe") {\n throw creaErrore("voice_error", "The voice service returned an invalid response.");\n }\n for (const risultato of risposta.tracks) {\n const peer = nuove.find(\n (item) => item.session === risultato.session && item.track === risultato.track\n );\n if (risultato.error === "not_allowed" && peer !== void 0) this.negati.add(peer.id);\n if (risultato?.mid === null || risultato?.mid === void 0 || risultato.error !== null || peer === void 0) continue;\n this.midGiocatori.set(risultato.mid, peer.id);\n this.sfuAttive.set(peer.id, {\n session: peer.session,\n track: peer.track,\n mid: risultato.mid,\n receiver: null\n });\n }\n await pc.setRemoteDescription({ type: "offer", sdp: risposta.sdp });\n const answer = await pc.createAnswer();\n await pc.setLocalDescription(answer);\n const sdp = pc.localDescription?.sdp;\n if (sdp === void 0) throw creaErrore("voice_error", "The voice answer is missing.");\n await this.richiedi({ t: "voice", op: "answer", session: sessione, sdp });\n if (!this.connessioneSfuAttesa) {\n await this.attendiConnessione(pc, this.generazione);\n this.connessioneSfuAttesa = true;\n }\n }\n riconciliaMesh() {\n const desiderati = new Map(this.peerDesiderati().map((peer) => [peer.id, peer]));\n for (const [playerId, item] of this.mesh) {\n if (desiderati.has(playerId)) continue;\n item.pc.close();\n this.mesh.delete(playerId);\n this.scollegaTraccia(playerId);\n }\n for (const peer of desiderati.values()) {\n if (!this.mesh.has(peer.id)) this.creaMesh(peer);\n }\n }\n creaMesh(peer) {\n const playerId = peer.id;\n const pc = this.richiediDipendenze().creaPeerConnection();\n const item = {\n pc,\n makingOffer: false,\n ignoreOffer: false,\n settingRemoteAnswer: false,\n polite: this.contesto.you() > playerId,\n receiver: null\n };\n this.mesh.set(playerId, item);\n pc.onicecandidate = (event) => {\n if (event.candidate === null) return;\n void this.inviaSegnale(playerId, { kind: "candidate", candidate: event.candidate.toJSON() });\n };\n if (!item.polite) pc.onnegotiationneeded = () => {\n void this.offriMesh(playerId, item);\n };\n pc.ontrack = (event) => {\n item.receiver = event.receiver;\n this.collegaTraccia(playerId, event.track, event.receiver);\n };\n this.osservaCaduta(pc);\n if (this.micDesiderato) {\n pc.addTransceiver(this.richiediMic(), {\n direction: peer.mic ? "sendrecv" : "sendonly"\n });\n } else {\n pc.addTransceiver("audio", { direction: "recvonly" });\n }\n }\n async offriMesh(playerId, item) {\n try {\n item.makingOffer = true;\n const offer = await item.pc.createOffer();\n await item.pc.setLocalDescription(offer);\n const sdp = item.pc.localDescription?.sdp;\n if (sdp !== void 0) await this.inviaSegnale(playerId, { kind: "offer", sdp });\n } finally {\n item.makingOffer = false;\n }\n }\n async riceviSegnale(from, data) {\n if (this.trasporto !== "mesh" || this.stateCorrente !== "on") return;\n const peer = this.peerDesiderati().find((item2) => item2.id === from);\n if (peer === void 0) return;\n if (!this.mesh.has(from)) this.creaMesh(peer);\n const item = this.mesh.get(from);\n if (item === void 0 || typeof data !== "object" || data === null || Array.isArray(data)) return;\n const segnale = data;\n try {\n if (segnale.kind === "candidate") {\n if (!item.ignoreOffer) await item.pc.addIceCandidate(segnale.candidate);\n return;\n }\n if (segnale.kind !== "offer" && segnale.kind !== "answer" || typeof segnale.sdp !== "string") return;\n const pronta = !item.makingOffer && (item.pc.signalingState === "stable" || item.settingRemoteAnswer);\n const collisione = segnale.kind === "offer" && !pronta;\n item.ignoreOffer = !item.polite && collisione;\n if (item.ignoreOffer) return;\n item.settingRemoteAnswer = segnale.kind === "answer";\n await item.pc.setRemoteDescription({ type: segnale.kind, sdp: segnale.sdp });\n item.settingRemoteAnswer = false;\n if (segnale.kind === "offer") {\n const answer = await item.pc.createAnswer();\n await item.pc.setLocalDescription(answer);\n const sdp = item.pc.localDescription?.sdp;\n if (sdp !== void 0) await this.inviaSegnale(from, { kind: "answer", sdp });\n }\n } catch {\n this.avviaRiconnessione();\n }\n }\n async inviaSegnale(to, data) {\n try {\n await this.richiedi({ t: "voice", op: "signal", to, data });\n } catch (cause) {\n if (codiceErrore(cause) !== "not_allowed") throw cause;\n const item = this.mesh.get(to);\n item?.pc.close();\n this.mesh.delete(to);\n this.scollegaTraccia(to);\n this.negati.add(to);\n }\n }\n peerDesiderati() {\n const you = this.contesto.you();\n const giocatori = this.contesto.giocatori();\n const player = giocatori.find((item) => item.id === you);\n return this.roster.filter((peer) => {\n if (peer.id === you) return false;\n if (this.negati.has(peer.id)) return false;\n if (!this.micDesiderato && !peer.mic) return false;\n if (this.modeCorrente === "team") {\n const altro = giocatori.find((item) => item.id === peer.id);\n if (player?.role !== "spectator" && altro?.team !== player?.team) return false;\n }\n return true;\n });\n }\n publisherDesiderati() {\n return this.peerDesiderati().filter(\n (peer) => {\n if (!peer.mic) return false;\n const zeroAt = this.zeroDa.get(peer.id);\n return zeroAt === void 0 || this.richiediDipendenze().ora() - zeroAt < DURATA_ZERO;\n }\n );\n }\n aggiornaZero(playerId) {\n const dipendenze = this.dipendenze;\n if (dipendenze === null) return;\n const precedente = this.timerZero.get(playerId);\n if (precedente !== void 0) dipendenze.clearTimeout(precedente);\n this.timerZero.delete(playerId);\n if ((this.gains.get(playerId) ?? 1) > 0) {\n this.zeroDa.delete(playerId);\n return;\n }\n if (!this.zeroDa.has(playerId)) this.zeroDa.set(playerId, dipendenze.ora());\n const trascorso = dipendenze.ora() - (this.zeroDa.get(playerId) ?? dipendenze.ora());\n const timer = dipendenze.setTimeout(() => {\n this.timerZero.delete(playerId);\n this.accodaRiconciliazione();\n }, Math.max(0, DURATA_ZERO - trascorso));\n this.timerZero.set(playerId, timer);\n }\n collegaTraccia(playerId, track, receiver) {\n this.scollegaTraccia(playerId);\n const dipendenze = this.richiediDipendenze();\n const media = dipendenze.creaMediaStream([track]);\n const source = this.richiediAudioContext().createMediaStreamSource(media);\n const gain = this.richiediAudioContext().createGain();\n source.connect(gain);\n gain.connect(this.richiediAudioContext().destination);\n let analyser = null;\n try {\n analyser = this.richiediAudioContext().createAnalyser();\n analyser.fftSize = 256;\n source.connect(analyser);\n } catch {\n analyser = null;\n }\n const audio = dipendenze.creaAudioElement();\n audio.srcObject = media;\n audio.muted = true;\n audio.playsInline = true;\n void audio.play().catch(() => void 0);\n this.riproduzioni.set(playerId, { source, gain, analyser, audio, track, receiver });\n const attiva = this.sfuAttive.get(playerId);\n if (attiva !== void 0) attiva.receiver = receiver;\n this.aggiornaGuadagno(playerId);\n }\n scollegaTraccia(playerId) {\n const riproduzione = this.riproduzioni.get(playerId);\n if (riproduzione === void 0) return;\n riproduzione.source.disconnect();\n riproduzione.gain.disconnect();\n riproduzione.analyser?.disconnect();\n riproduzione.track.stop();\n riproduzione.audio.pause();\n riproduzione.audio.srcObject = null;\n this.riproduzioni.delete(playerId);\n this.speakingPeers.delete(playerId);\n this.ultimoAudio.delete(playerId);\n }\n aggiornaGuadagno(playerId) {\n const riproduzione = this.riproduzioni.get(playerId);\n if (riproduzione !== void 0) {\n riproduzione.gain.gain.value = (this.volumi.get(playerId) ?? 1) * (this.gains.get(playerId) ?? 1);\n }\n }\n preparaAnalizzatore(stream) {\n const context = this.richiediAudioContext();\n const analyser = context.createAnalyser();\n analyser.fftSize = 256;\n context.createMediaStreamSource(stream).connect(analyser);\n this.analyser = analyser;\n }\n avviaMisuraAudio() {\n const dipendenze = this.richiediDipendenze();\n if (this.intervalloAudio !== null) dipendenze.clearInterval(this.intervalloAudio);\n this.intervalloAudio = dipendenze.setInterval(() => this.misuraAudio(), INTERVALLO_AUDIO);\n }\n misuraAudio() {\n const dipendenze = this.dipendenze;\n if (dipendenze === null) return;\n let sopraSoglia = false;\n if (this.analyser !== null) sopraSoglia = this.livelloAnalizzatore(this.analyser) > SOGLIA_AUDIO;\n if (sopraSoglia) this.ultimoAudioMic = dipendenze.ora();\n const parlando = !this.mutedCorrente && dipendenze.ora() - this.ultimoAudioMic <= DURATA_PARLANTE;\n if (parlando !== this.speakingCorrente) {\n this.speakingCorrente = parlando;\n this.notificaPeers();\n }\n let cambiato = false;\n for (const peer of this.copiaPeers()) {\n const riproduzione = this.riproduzioni.get(peer.id);\n if (this.livelloAnalizzatore(riproduzione?.analyser ?? null) > SOGLIA_AUDIO) {\n this.ultimoAudio.set(peer.id, dipendenze.ora());\n } else if (riproduzione?.analyser === null || riproduzione?.analyser === void 0) {\n const sources = riproduzione?.receiver?.getSynchronizationSources?.() ?? [];\n if (sources.some((source) => (source.audioLevel ?? 0) > SOGLIA_AUDIO)) {\n this.ultimoAudio.set(peer.id, dipendenze.ora());\n }\n }\n const speaking = !peer.muted && dipendenze.ora() - (this.ultimoAudio.get(peer.id) ?? 0) <= DURATA_PARLANTE;\n if ((this.speakingPeers.get(peer.id) ?? false) !== speaking) {\n this.speakingPeers.set(peer.id, speaking);\n cambiato = true;\n }\n }\n if (cambiato) this.notificaPeers();\n }\n livelloAnalizzatore(analyser) {\n const nodo = analyser;\n if (nodo?.getFloatTimeDomainData === void 0) return 0;\n const campioni = new Float32Array(nodo.fftSize);\n nodo.getFloatTimeDomainData(campioni);\n return Math.sqrt(campioni.reduce((somma, valore) => somma + valore * valore, 0) / Math.max(1, campioni.length));\n }\n copiaPeers() {\n const you = this.contesto.you();\n const giocatori = this.contesto.giocatori();\n const player = giocatori.find((item) => item.id === you);\n return this.roster.flatMap((peer) => {\n if (peer.id === you) return [];\n if (this.modeCorrente === "team") {\n const altro = giocatori.find((item) => item.id === peer.id);\n if (player?.role !== "spectator" && altro?.team !== player?.team) return [];\n }\n return [{\n id: peer.id,\n mic: peer.mic,\n muted: peer.muted,\n speaking: peer.mic && !peer.muted && (this.speakingPeers.get(peer.id) ?? false),\n volume: this.volumi.get(peer.id) ?? 1,\n gain: this.gains.get(peer.id) ?? 1\n }];\n });\n }\n pulisciPeerAssenti() {\n const presenti = new Set(this.roster.map((peer) => peer.id));\n for (const playerId of this.speakingPeers.keys()) {\n if (!presenti.has(playerId)) this.speakingPeers.delete(playerId);\n }\n for (const playerId of this.zeroDa.keys()) {\n if (presenti.has(playerId)) continue;\n this.zeroDa.delete(playerId);\n const timer = this.timerZero.get(playerId);\n if (timer !== void 0) this.dipendenze?.clearTimeout(timer);\n this.timerZero.delete(playerId);\n }\n }\n osservaCaduta(pc) {\n pc.addEventListener("connectionstatechange", () => {\n if (this.stateCorrente === "on" && (pc.connectionState === "failed" || pc.connectionState === "disconnected")) this.avviaRiconnessione();\n });\n }\n avviaRiconnessione() {\n if (!this.desiderata || this.stateCorrente === "reconnecting") return;\n this.generazione++;\n this.rifiutaRichieste(creaErrore("voice_error", "The voice connection was restarted."));\n this.chiudiRisorse();\n this.tentativoRiconnessione = 0;\n this.aggiornaState("reconnecting");\n this.programmaRiconnessione();\n }\n programmaRiconnessione() {\n if (!this.desiderata || !this.contesto.connessa() || this.timerRiconnessione !== null || this.stateCorrente !== "reconnecting") return;\n const ritardo = RITARDI_RICONNESSIONE[this.tentativoRiconnessione];\n if (ritardo === void 0) {\n this.desiderata = false;\n this.aggiornaState("off");\n return;\n }\n this.tentativoRiconnessione++;\n this.timerRiconnessione = this.richiediDipendenze().setTimeout(() => {\n this.timerRiconnessione = null;\n const generazione = ++this.generazione;\n void this.entra(generazione).catch(() => {\n if (generazione !== this.generazione || !this.desiderata) return;\n this.chiudiRisorse();\n this.aggiornaState("reconnecting");\n this.programmaRiconnessione();\n });\n }, ritardo);\n }\n fermaRiconnessione() {\n if (this.timerRiconnessione === null || this.dipendenze === null) return;\n this.dipendenze.clearTimeout(this.timerRiconnessione);\n this.timerRiconnessione = null;\n }\n chiudiRisorse() {\n const dipendenze = this.dipendenze;\n this.cancellaAttesaConnessione?.();\n this.cancellaAttesaConnessione = null;\n if (dipendenze !== null) {\n if (this.intervalloAudio !== null) dipendenze.clearInterval(this.intervalloAudio);\n if (this.timerConnessione !== null) dipendenze.clearTimeout(this.timerConnessione);\n for (const timer of this.timerZero.values()) dipendenze.clearTimeout(timer);\n }\n this.intervalloAudio = null;\n this.timerConnessione = null;\n this.timerZero.clear();\n for (const playerId of [...this.riproduzioni.keys()]) this.scollegaTraccia(playerId);\n this.peerSfu?.close();\n this.peerSfu = null;\n for (const item of this.mesh.values()) item.pc.close();\n this.mesh.clear();\n this.sfuAttive.clear();\n this.midGiocatori.clear();\n this.negati.clear();\n for (const track of this.stream?.getTracks() ?? []) track.stop();\n this.stream = null;\n this.tracciaMic = null;\n this.analyser = null;\n void this.audioContext?.close().catch(() => void 0);\n this.audioContext = null;\n this.sessioneSfu = null;\n this.connessioneSfuAttesa = false;\n this.trasporto = null;\n this.speakingCorrente = false;\n this.ultimoAudioMic = Number.NEGATIVE_INFINITY;\n this.speakingPeers.clear();\n this.ultimoAudio.clear();\n this.negoziazione = Promise.resolve();\n }\n richiedi(message) {\n if (!this.contesto.connessa()) return Promise.reject(creaErrore("offline", "The room is reconnecting."));\n const r = ++this.sequenzaRichieste;\n return new Promise((resolve, reject) => {\n this.richieste.set(r, { resolve, reject });\n try {\n this.contesto.invia({ ...message, r });\n } catch (cause) {\n this.richieste.delete(r);\n reject(cause);\n }\n });\n }\n rifiutaRichieste(reason) {\n for (const richiesta of this.richieste.values()) richiesta.reject(reason);\n this.richieste.clear();\n }\n aggiornaState(state) {\n if (state === this.stateCorrente) return;\n this.stateCorrente = state;\n for (const listener of this.ascoltatoriState) {\n try {\n listener(state);\n } catch {\n }\n }\n }\n notificaPeers() {\n const peers = this.copiaPeers();\n for (const listener of this.ascoltatoriPeers) {\n try {\n listener(peers);\n } catch {\n }\n }\n }\n controllaGenerazione(generazione) {\n if (generazione !== this.generazione || !this.desiderata) {\n throw creaErrore("offline", "Voice was stopped.");\n }\n }\n richiediDipendenze() {\n if (this.dipendenze === null) throw creaErrore("unsupported", "Voice is not supported.");\n return this.dipendenze;\n }\n richiediMic() {\n if (this.tracciaMic === null) throw creaErrore("voice_error", "The microphone is not ready.");\n return this.tracciaMic;\n }\n richiediAudioContext() {\n if (this.audioContext === null) throw creaErrore("voice_error", "Audio is not ready.");\n return this.audioContext;\n }\n permessoNegato(cause) {\n return typeof cause === "object" && cause !== null && "name" in cause && (cause.name === "NotAllowedError" || cause.name === "SecurityError");\n }\n mappaErrore(cause) {\n if (typeof cause === "object" && cause !== null && "code" in cause) {\n const code = cause.code;\n if (code === "voice_disabled" || code === "permission_denied" || code === "unsupported" || code === "spectator" || code === "offline" || code === "voice_error") return cause;\n return creaErrore("voice_error", "Voice could not be started.");\n }\n return creaErrore("voice_error", "Voice could not be started.");\n }\n};\n\n// src/stanza-client/index.ts\nvar APERTO = 1;\nvar RITARDI_RICONNESSIONE2 = [1e3, 2e3, 4e3, 8e3];\nvar GRAZIA_RICONNESSIONE = 6e4;\nvar INTERVALLO_PING = 5e3;\nvar RITARDO_FLUSH = 500;\nvar ATTESA_ROSTER = 2e3;\nvar CHIUSURE_DEFINITIVE = /* @__PURE__ */ new Set([4003, 4004, 4005, 4006, 4008, 4009]);\nfunction record3(value) {\n return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;\n}\nfunction ingressoValido(value) {\n const dati = record3(value);\n return dati !== null && typeof dati.roomId === "string" && typeof dati.code === "string" && typeof dati.join === "string" && typeof dati.url === "string";\n}\nfunction visioneValida(value) {\n const dati = record3(value);\n return dati !== null && typeof dati.roomId === "string" && typeof dati.code === "string" && typeof dati.watch === "string" && typeof dati.url === "string";\n}\nfunction rispostaMatchValida(value) {\n const dati = record3(value);\n const players = record3(dati?.players);\n return dati !== null && typeof dati.url === "string" && Number.isInteger(dati.timeoutMs) && dati.timeoutMs >= 1e3 && dati.timeoutMs <= 3e5 && players !== null && Number.isInteger(players.min) && Number.isInteger(players.max) && players.min >= 1 && players.max >= players.min;\n}\nfunction copiaJson(value) {\n return JSON.parse(JSON.stringify(value));\n}\nfunction applicaPatch(state, value) {\n let risultato = copiaJson(state);\n for (const operazione of value) {\n if (operazione.path.length === 0) {\n if (operazione.op !== "set") return { ok: false };\n risultato = copiaJson(operazione.value);\n continue;\n }\n let contenitore = risultato;\n const percorso = operazione.path;\n for (let indice = 0; indice < percorso.length - 1; indice++) {\n const parte = percorso[indice];\n if (Array.isArray(contenitore)) {\n if (typeof parte !== "number" || parte >= contenitore.length) return { ok: false };\n contenitore = contenitore[parte];\n } else {\n const oggetto2 = record3(contenitore);\n if (oggetto2 === null || typeof parte !== "string" || !Object.hasOwn(oggetto2, parte)) {\n return { ok: false };\n }\n contenitore = oggetto2[parte];\n }\n }\n const ultima = percorso.at(-1);\n if (Array.isArray(contenitore)) {\n if (operazione.op !== "set" || typeof ultima !== "number" || ultima >= contenitore.length) return { ok: false };\n contenitore[ultima] = copiaJson(operazione.value);\n } else {\n const oggetto2 = record3(contenitore);\n if (oggetto2 === null || typeof ultima !== "string") return { ok: false };\n if (operazione.op === "del") {\n if (!Object.hasOwn(oggetto2, ultima)) return { ok: false };\n delete oggetto2[ultima];\n } else {\n Object.defineProperty(oggetto2, ultima, {\n configurable: true,\n enumerable: true,\n value: copiaJson(operazione.value),\n writable: true\n });\n }\n }\n }\n return { ok: true, state: risultato };\n}\nfunction creaApiLive(input) {\n const richiesta = creaRichiedente(input.liveOrigin, "", input.fetcher, input.biglietto);\n async function ingresso(path, body, rinnova = false) {\n const value = await richiesta(path, "POST", body, rinnova);\n if (!ingressoValido(value)) {\n throw creaErrore("internal_error", "The room service returned an invalid response.");\n }\n return value;\n }\n async function match(options) {\n const value = await richiesta("/match", "POST", {\n mode: options.mode,\n key: options.key\n });\n if (!rispostaMatchValida(value)) {\n throw creaErrore("internal_error", "The matchmaking service returned an invalid response.");\n }\n return value;\n }\n async function visione(body, rinnova = false) {\n const value = await richiesta("/rooms/watch", "POST", body, rinnova);\n if (!visioneValida(value)) {\n throw creaErrore("internal_error", "The room service returned an invalid response.");\n }\n return value;\n }\n return {\n create: (mode) => ingresso("/rooms", { mode }),\n joinCode: (code) => ingresso("/rooms/join", { code }),\n joinRoom: (roomId) => ingresso("/rooms/join", { roomId }, true),\n watchCode: (code) => visione({ code }),\n watchRoom: (roomId) => visione({ roomId }, true),\n match,\n flush: (roomId) => richiesta(\n `/rooms/${encodeURIComponent(roomId)}/flush`,\n "POST"\n )\n };\n}\nvar StanzaClient = class {\n constructor(roomId, codice, url, dipendenze, api, segnalaStanza, spettatore = false) {\n this.roomId = roomId;\n this.codice = codice;\n this.dipendenze = dipendenze;\n this.api = api;\n this.segnalaStanza = segnalaStanza;\n this.spettatore = spettatore;\n this.meta = { host: null, mode: null, countdownAt: null, configuration: null, connection: "connecting", closedCode: null };\n this.metaListeners = /* @__PURE__ */ new Set();\n this.connectionListeners = /* @__PURE__ */ new Set();\n this.scoreListeners = /* @__PURE__ */ new Set();\n this.scores = [];\n this.errorListeners = /* @__PURE__ */ new Set();\n this.roleId = 0;\n this.roleRequests = /* @__PURE__ */ new Map();\n this.statoPubblico = null;\n this.statoSincronizzato = null;\n this.tickCorrente = 0;\n this.tickRateCorrente = 0;\n this.latenzaCorrente = null;\n this.ultimoInput = null;\n this.inputInviato = null;\n this.timerInput = null;\n this.ultimoInvioGioco = -Infinity;\n this.inviiGioco = [];\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 }, dipendenze, dipendenze.voce);\n if (spettatore) this.rosterRicevuto = true;\n this.apri(url);\n }\n get mode() {\n return this.meta.mode;\n }\n get countdownAt() {\n return this.meta.countdownAt;\n }\n get connection() {\n return this.meta.connection;\n }\n get metadata() {\n return structuredClone(this.meta);\n }\n get queuedScores() {\n return structuredClone(this.scores);\n }\n onMetadata(listener) {\n this.metaListeners.add(listener);\n return () => this.metaListeners.delete(listener);\n }\n onConnection(listener) {\n this.connectionListeners.add(listener);\n return () => this.connectionListeners.delete(listener);\n }\n onError(listener) {\n this.errorListeners.add(listener);\n return () => this.errorListeners.delete(listener);\n }\n onScoreQueued(listener) {\n this.scoreListeners.add(listener);\n return () => this.scoreListeners.delete(listener);\n }\n metadataChanged(change) {\n const old = this.meta.connection;\n this.meta = { ...this.meta, ...change };\n this.notifica(this.metaListeners, this.metadata);\n if (old !== this.meta.connection) this.notifica(this.connectionListeners, this.meta.connection);\n }\n initialMetadata(room) {\n this.metadataChanged({\n host: room.host,\n mode: room.mode,\n countdownAt: room.countdownAt ?? null,\n configuration: room.configuration ?? null,\n connection: "connected",\n closedCode: null\n });\n }\n requestRole(role) {\n if (typeof role !== "string" || role.length < 1 || role.length > 32) return Promise.reject(creaErrore("invalid_role", "The role is not valid."));\n if (this.connection !== "connected" || this.status !== "playing" || !this.meta.configuration?.requestRole) {\n return Promise.reject(creaErrore("role_change_unavailable", "Roles cannot be requested right now."));\n }\n if (this.roleRequests.size >= 8) return Promise.reject(creaErrore("rate_limited", "Too many role requests."));\n const r = ++this.roleId;\n return new Promise((resolve, reject) => {\n const timer = this.dipendenze.setTimeout(() => {\n this.roleRequests.delete(r);\n reject(creaErrore("timeout", "The role request timed out."));\n }, 5e3);\n this.roleRequests.set(r, { resolve, reject, timer });\n try {\n this.invia({ t: "request-role", r, role });\n } catch (error) {\n this.dipendenze.clearTimeout(timer);\n this.roleRequests.delete(r);\n reject(error);\n }\n });\n }\n clearRoleRequests() {\n for (const request of this.roleRequests.values()) {\n this.dipendenze.clearTimeout(request.timer);\n request.reject(creaErrore("offline", "The room connection ended."));\n }\n this.roleRequests.clear();\n }\n disconnect() {\n if (this.lasciata) return;\n this.lasciata = true;\n const socket = this.socket;\n this.socket = null;\n this.voice.termina();\n this.fermaInput();\n this.fermaPing();\n this.fermaRiconnessione();\n this.clearRoleRequests();\n if (this.timerRoster !== null) this.dipendenze.clearTimeout(this.timerRoster);\n socket?.close(1e3);\n this.segnalaStanza(null);\n this.metadataChanged({ connection: "disconnected", closedCode: null });\n if (!this.prontaRisolta) {\n this.prontaRisolta = true;\n this.rifiutaPronta(creaErrore("cancelled", "The room was disconnected."));\n }\n }\n get state() {\n return this.statoPubblico;\n }\n get tick() {\n return this.tickCorrente;\n }\n get tickRate() {\n return this.tickRateCorrente;\n }\n get latency() {\n return this.latenzaCorrente;\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.dipendenze.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 if (this.statusCorrente === "finished") return;\n const prossimo = this.seq + 1;\n this.invia({ t: "msg", seq: prossimo, m: message });\n this.seq = prossimo;\n this.ultimoInvioGioco = this.dipendenze.ora();\n this.inviiGioco = [...this.inviiGioco.slice(-(MESSAGGI_GIOCO_AL_SECONDO - 1)), this.ultimoInvioGioco];\n }\n input(value) {\n if (this.terminata || this.lasciata || this.statusCorrente === "finished") return;\n try {\n const serializzato = JSON.stringify(value);\n if (serializzato === void 0) throw new TypeError();\n this.ultimoInput = serializzato;\n } catch {\n throw creaErrore("invalid_request", "Room input must be valid JSON.");\n }\n this.programmaInput();\n }\n pulisciInput() {\n this.fermaInput();\n this.ultimoInput = this.inputInviato = null;\n this.ultimoInvioGioco = -Infinity;\n this.inviiGioco = [];\n }\n fermaInput() {\n if (this.timerInput !== null) this.dipendenze.clearTimeout(this.timerInput);\n this.timerInput = null;\n }\n programmaInput() {\n if (this.timerInput !== null || this.ultimoInput === null || this.ultimoInput === this.inputInviato || !this.welcomeRicevuto || this.socket?.readyState !== APERTO || this.terminata || this.lasciata) return;\n const ora = this.dipendenze.ora();\n const frequenza = this.tickRateCorrente > 0 ? Math.min(MESSAGGI_GIOCO_AL_SECONDO, this.tickRateCorrente) : MESSAGGI_GIOCO_AL_SECONDO;\n const periodo = 1e3 / frequenza;\n this.inviiGioco = this.inviiGioco.filter((at) => ora - at < 1e3);\n const spazio = this.inviiGioco.length >= MESSAGGI_GIOCO_AL_SECONDO ? this.inviiGioco[0] + 1e3 : ora;\n const prossimo = Number.isFinite(this.ultimoInvioGioco) ? this.ultimoInvioGioco + periodo : ora + periodo;\n this.timerInput = this.dipendenze.setTimeout(() => {\n this.timerInput = null;\n if (this.ultimoInput === null || this.ultimoInput === this.inputInviato || !this.welcomeRicevuto || this.socket?.readyState !== APERTO || this.terminata || this.lasciata) return;\n const adesso = this.dipendenze.ora();\n if (adesso < this.ultimoInvioGioco + periodo || this.inviiGioco.filter((at) => adesso - at < 1e3).length >= MESSAGGI_GIOCO_AL_SECONDO) {\n this.programmaInput();\n return;\n }\n const valore = this.ultimoInput;\n try {\n this.send(JSON.parse(valore));\n this.inputInviato = valore;\n } catch {\n }\n }, Math.max(0, Math.ceil(Math.max(prossimo, spazio) - ora)));\n }\n aggiornaTickRate(value) {\n if (value === void 0 || !Number.isInteger(value) || value < 0 || value > 60 || value === this.tickRateCorrente) return;\n this.tickRateCorrente = value;\n this.fermaInput();\n this.programmaInput();\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 restart() {\n if (this.statusCorrente !== "finished") throw creaErrore("rematch_unavailable", "This room is not waiting for a rematch.");\n this.invia({ t: "restart" });\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.dipendenze.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.dipendenze.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, evento.reason);\n });\n }\n avviaPing() {\n if (this.timerPing !== null) this.dipendenze.clearInterval(this.timerPing);\n this.timerPing = this.dipendenze.setInterval(() => {\n if (this.socket?.readyState !== APERTO) return;\n try {\n this.invia({ t: "ping", c: this.dipendenze.ora() });\n } catch {\n }\n }, INTERVALLO_PING);\n }\n fermaPing() {\n if (this.timerPing === null) return;\n this.dipendenze.clearInterval(this.timerPing);\n this.timerPing = null;\n }\n ricevi(frame) {\n let dati;\n try {\n const value = JSON.parse(frame);\n const oggetto2 = record3(value);\n if (oggetto2 === null || typeof oggetto2.t !== "string") return;\n dati = oggetto2;\n } catch {\n return;\n }\n try {\n if (dati.t === "watching") this.riceviWatching(dati);\n else if (dati.t === "welcome") this.riceviWelcome(dati);\n else if (dati.t === "players") this.riceviGiocatori(dati.players, dati.host);\n else if (dati.t === "status") this.riceviStatus(dati);\n else if (dati.t === "state") this.riceviDiff(dati);\n else if (dati.t === "snapshot") this.riceviSnapshot(dati);\n else if (dati.t === "msg") this.notifica(this.ascoltatoriMessaggi, copiaJson(dati.m));\n else if (dati.t === "pong") this.riceviPong(dati);\n else if (dati.t === "error") this.notifica(this.errorListeners, { code: dati.code, message: dati.message });\n else if (dati.t === "flush") this.richiediFlush();\n else if (dati.t === "score-queued" && !this.spettatore) {\n this.scores.push(structuredClone(dati.score));\n this.scores = this.scores.slice(-32);\n this.notifica(this.scoreListeners, structuredClone(dati.score));\n } else if (dati.t === "role-result") {\n const request = this.roleRequests.get(dati.r);\n if (request) {\n this.dipendenze.clearTimeout(request.timer);\n this.roleRequests.delete(dati.r);\n if (dati.ok) request.resolve();\n else request.reject(creaErrore(dati.code ?? "role_change_refused", "The role change was not accepted."));\n }\n } else if (dati.t === "voice") this.voice.ricevi(dati);\n } catch {\n if (dati.t === "state" || dati.t === "snapshot") this.chiediResync();\n }\n }\n riceviWatching(dati) {\n const room = dati.room;\n if (!this.spettatore || room.id !== this.roomId) return;\n this.aggiornaTickRate(room.tickRate);\n this.seedCorrente = room.seed;\n this.hostCorrente = room.host;\n this.statusCorrente = room.status;\n this.resultCorrente = copiaJson(room.result ?? null);\n if (room.status === "finished") this.pulisciInput();\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.dipendenze.ora();\n this.resyncRichiesto = false;\n this.welcomeRicevuto = true;\n this.ritardoIndice = 0;\n this.tempoRiconnessione = 0;\n this.notifica(this.ascoltatoriGiocatori, this.copiaGiocatori());\n this.notifica(\n this.ascoltatoriStatus,\n this.statusCorrente,\n this.resultCorrente,\n room.serverTime\n );\n this.initialMetadata(room);\n this.programmaInput();\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.aggiornaTickRate(room.tickRate);\n this.seedCorrente = room.seed;\n this.hostCorrente = room.host;\n this.statusCorrente = room.status;\n this.resultCorrente = copiaJson(room.result ?? null);\n if (room.status === "finished") this.pulisciInput();\n this.giocatoriCorrenti = dati.players.map((player) => ({ ...player }));\n this.aggiornaStato(dati.state, room.tick, room.serverTime);\n this.scartoOrario = room.serverTime - this.dipendenze.ora();\n this.resyncRichiesto = false;\n this.welcomeRicevuto = true;\n if (!this.rosterRicevuto && this.timerRoster === null) {\n this.timerRoster = this.dipendenze.setTimeout(() => {\n this.timerRoster = null;\n this.rosterRicevuto = true;\n this.risolviProntaSePossibile();\n }, ATTESA_ROSTER);\n }\n this.ritardoIndice = 0;\n this.tempoRiconnessione = 0;\n this.notifica(this.ascoltatoriGiocatori, this.copiaGiocatori());\n this.voice.giocatoriCambiati();\n this.voice.socketRiconnesso();\n this.notifica(\n this.ascoltatoriStatus,\n this.statusCorrente,\n this.resultCorrente,\n room.serverTime\n );\n this.initialMetadata(room);\n this.programmaInput();\n this.risolviProntaSePossibile();\n }\n riceviGiocatori(value, host) {\n this.giocatoriCorrenti = value.map((player) => ({ ...player }));\n if (host !== void 0) this.hostCorrente = host;\n else if (!this.giocatoriCorrenti.some(\n (player) => player.id === this.hostCorrente && player.connected\n )) {\n this.hostCorrente = this.giocatoriCorrenti.find((player) => player.connected)?.id ?? null;\n }\n this.metadataChanged({ host: this.hostCorrente });\n this.notifica(this.ascoltatoriGiocatori, this.copiaGiocatori());\n this.voice.giocatoriCambiati();\n }\n riceviStatus(dati) {\n this.statusCorrente = dati.status;\n if (dati.host !== void 0) this.hostCorrente = dati.host;\n this.resultCorrente = copiaJson(dati.result);\n if (dati.status === "finished") {\n this.pulisciInput();\n this.clearRoleRequests();\n }\n if (dati.status === "ended") {\n this.terminata = true;\n this.clearRoleRequests();\n this.segnalaStanza(null);\n if (!this.spettatore) this.voice.termina();\n this.fermaPing();\n this.fermaRiconnessione();\n this.fermaInput();\n this.ultimoInput = null;\n }\n this.metadataChanged({\n host: this.hostCorrente,\n countdownAt: dati.countdownAt ?? (dati.status === "countdown" ? dati.at : null),\n ...dati.status === "ended" ? { connection: "ended", closedCode: 4004 } : {}\n });\n this.notifica(this.ascoltatoriStatus, this.statusCorrente, this.resultCorrente, dati.at);\n }\n riceviDiff(dati) {\n this.aggiornaTickRate(dati.tickRate);\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.aggiornaTickRate(dati.tickRate);\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 const ora = this.dipendenze.ora();\n if (!Number.isFinite(dati.c) || !Number.isFinite(dati.s) || dati.c > ora) return;\n const rtt = ora - dati.c;\n this.latenzaCorrente = this.latenzaCorrente === null ? rtt : this.latenzaCorrente * 0.8 + rtt * 0.2;\n this.scartoOrario = dati.s - (dati.c + ora) / 2;\n }\n chiuso(code, reason) {\n this.socket = null;\n this.welcomeRicevuto = false;\n this.latenzaCorrente = null;\n this.fermaInput();\n this.inputInviato = null;\n this.ultimoInvioGioco = -Infinity;\n this.inviiGioco = [];\n this.fermaPing();\n if (this.lasciata || this.terminata) return;\n if (CHIUSURE_DEFINITIVE.has(code)) {\n const errore = code === 4009 && reason === "message_too_large" ? "message_too_large" : void 0;\n if (errore) this.notifica(this.errorListeners, { code: errore, message: "The room message is too large." });\n this.termina(code, errore);\n return;\n }\n this.clearRoleRequests();\n if (!this.spettatore) this.voice.socketDisconnesso();\n this.programmaRiconnessione();\n }\n programmaRiconnessione() {\n if (this.terminata || this.lasciata || this.timerRiconnessione !== null) return;\n this.metadataChanged({ connection: "reconnecting" });\n const indice = Math.min(this.ritardoIndice, RITARDI_RICONNESSIONE2.length - 1);\n const ritardo = RITARDI_RICONNESSIONE2[indice];\n if (this.tempoRiconnessione + ritardo > GRAZIA_RICONNESSIONE) {\n this.termina("timeout");\n return;\n }\n this.ritardoIndice++;\n this.tempoRiconnessione += ritardo;\n this.timerRiconnessione = this.dipendenze.setTimeout(() => {\n this.timerRiconnessione = null;\n void this.riconnetti();\n }, ritardo);\n }\n async riconnetti() {\n if (this.terminata || this.lasciata) return;\n try {\n const ingresso = this.spettatore ? await this.api.watchRoom(this.roomId) : await this.api.joinRoom(this.roomId);\n if (this.terminata || this.lasciata) return;\n const codiceCambiato = this.codice !== ingresso.code;\n this.codice = ingresso.code;\n if (codiceCambiato && this.prontaRisolta && !this.terminata && !this.lasciata) {\n this.segnalaStanza({ code: this.codice });\n }\n this.apri(ingresso.url);\n } catch {\n this.programmaRiconnessione();\n }\n }\n fermaRiconnessione() {\n if (this.timerRiconnessione === null) return;\n this.dipendenze.clearTimeout(this.timerRiconnessione);\n this.timerRiconnessione = null;\n }\n termina(code, errore) {\n this.clearRoleRequests();\n this.metadataChanged({ connection: code === 1e3 ? "disconnected" : code === 4006 ? "replaced" : "closed", closedCode: typeof code === "number" ? code : null });\n const risultato = { closed: code };\n const cambiato = this.statusCorrente !== "ended" || JSON.stringify(this.resultCorrente) !== JSON.stringify(risultato);\n this.terminata = true;\n this.fermaInput();\n this.ultimoInput = null;\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 = errore ?? (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.dipendenze.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.dipendenze.setTimeout(() => {\n this.timerFlush = null;\n void this.eseguiFlush();\n }, RITARDO_FLUSH);\n }\n async eseguiFlush() {\n if (this.flushInCorso || !this.flushRichiesto) return;\n this.flushInCorso = true;\n this.flushRichiesto = false;\n try {\n await this.api.flush(this.roomId);\n } catch {\n } finally {\n this.flushInCorso = false;\n if (this.flushRichiesto) this.richiediFlush();\n }\n }\n};\nfunction creaStanzeOffline(invited = null) {\n return {\n invited,\n async create() {\n throw erroreOffline();\n },\n async join() {\n throw erroreOffline();\n },\n async watch() {\n throw erroreOffline();\n },\n async match() {\n throw erroreOffline();\n }\n };\n}\nfunction creaGestoreStanze(input, invited) {\n const api = creaApiLive(input);\n let haSegnalato = false;\n let ultimoCodice = null;\n const segnalaStanza = (room) => {\n const codice = room?.code ?? null;\n if (haSegnalato && codice === ultimoCodice) return;\n haSegnalato = true;\n ultimoCodice = codice;\n input.segnalaStanza?.(room);\n };\n const collega = async (ingresso) => {\n const stanza = new StanzaClient(\n ingresso.roomId,\n ingresso.code,\n ingresso.url,\n input,\n api,\n segnalaStanza\n );\n await stanza.pronta();\n return stanza;\n };\n const guarda = async (ingresso) => {\n const stanza = new StanzaClient(\n ingresso.roomId,\n ingresso.code,\n ingresso.url,\n input,\n api,\n () => void 0,\n true\n );\n await stanza.pronta();\n return {\n get mode() {\n return stanza.mode;\n },\n get countdownAt() {\n return stanza.countdownAt;\n },\n get connection() {\n return stanza.connection;\n },\n get metadata() {\n return stanza.metadata;\n },\n onMetadata: (listener) => stanza.onMetadata(listener),\n onConnection: (listener) => stanza.onConnection(listener),\n disconnect: () => stanza.disconnect(),\n get state() {\n return stanza.state;\n },\n get tick() {\n return stanza.tick;\n },\n get tickRate() {\n return stanza.tickRate;\n },\n get latency() {\n return stanza.latency;\n },\n get seed() {\n return stanza.seed;\n },\n get status() {\n return stanza.status;\n },\n get players() {\n return stanza.players;\n },\n get host() {\n return stanza.host;\n },\n get code() {\n return stanza.code;\n },\n get result() {\n return stanza.result;\n },\n get delayMs() {\n return stanza.delayMs;\n },\n onState: (listener) => stanza.onState(listener),\n onPlayers: (listener) => stanza.onPlayers(listener),\n onStatus: (listener) => stanza.onStatus(listener),\n onMessage: (listener) => stanza.onMessage(listener),\n leave: () => {\n stanza.leave();\n },\n serverTime: () => stanza.serverTime()\n };\n };\n const attendiMatch = (url, options) => new Promise((resolve, reject) => {\n let socket;\n let conclusa = false;\n const pulisci = () => {\n socket.removeEventListener("message", ricevi);\n socket.removeEventListener("close", chiuso);\n socket.removeEventListener("error", caduto);\n options.signal?.removeEventListener("abort", annulla);\n };\n const chiudi = () => {\n try {\n socket.close(1e3);\n } catch {\n }\n };\n const fallisci = (errore, chiudiSocket) => {\n if (conclusa) return;\n conclusa = true;\n pulisci();\n if (chiudiSocket) chiudi();\n reject(errore);\n };\n function annulla() {\n fallisci(\n creaErrore("cancelled", "The matchmaking search was cancelled."),\n true\n );\n }\n function chiuso() {\n fallisci(erroreOffline(), false);\n }\n function caduto() {\n fallisci(erroreOffline(), true);\n }\n function ricevi(evento) {\n let dati = null;\n try {\n dati = typeof evento.data === "string" ? record3(JSON.parse(evento.data)) : null;\n } catch {\n }\n if (dati === null || typeof dati.t !== "string") {\n fallisci(creaErrore("internal_error", "The matchmaking service sent an invalid message."), true);\n return;\n }\n if (dati.t === "waiting") {\n if (!Number.isInteger(dati.players) || !Number.isInteger(dati.min) || !Number.isInteger(dati.max)) {\n fallisci(creaErrore("internal_error", "The matchmaking service sent an invalid message."), true);\n return;\n }\n try {\n options.onWaiting?.({\n players: dati.players,\n min: dati.min,\n max: dati.max\n });\n } catch {\n }\n return;\n }\n if (dati.t === "matched") {\n if (!ingressoValido(dati)) {\n fallisci(creaErrore("internal_error", "The matchmaking service sent an invalid message."), true);\n return;\n }\n conclusa = true;\n pulisci();\n chiudi();\n resolve(dati);\n return;\n }\n if (dati.t === "no_match") {\n fallisci(creaErrore("no_match", "No match was found before the timeout."), true);\n return;\n }\n if (dati.t === "error") {\n fallisci(creaErrore(\n typeof dati.code === "string" ? dati.code : "internal_error",\n typeof dati.message === "string" ? dati.message : "The matchmaking service could not complete the search."\n ), true);\n return;\n }\n if (dati.t !== "pong") {\n fallisci(creaErrore("internal_error", "The matchmaking service sent an invalid message."), true);\n }\n }\n try {\n socket = input.apriSocket(url);\n } catch {\n reject(erroreOffline());\n return;\n }\n socket.addEventListener("message", ricevi);\n socket.addEventListener("close", chiuso);\n socket.addEventListener("error", caduto);\n options.signal?.addEventListener("abort", annulla, { once: true });\n if (options.signal?.aborted === true) annulla();\n });\n return {\n invited,\n async create(options) {\n return collega(await api.create(options.mode));\n },\n async join(code) {\n const scelto = code ?? invited;\n if (scelto === null || scelto === void 0 || scelto.length === 0) {\n throw creaErrore("invalid_request", "A room invitation code is required.");\n }\n return collega(await api.joinCode(scelto));\n },\n async watch(code) {\n if (typeof code !== "string" || code.length === 0) {\n throw creaErrore("invalid_request", "A room invitation code is required.");\n }\n return guarda(await api.watchCode(code));\n },\n async match(options) {\n const annullata = () => options.signal?.aborted === true;\n if (annullata()) {\n throw creaErrore("cancelled", "The matchmaking search was cancelled.");\n }\n const risposta = await api.match(options);\n if (annullata()) {\n throw creaErrore("cancelled", "The matchmaking search was cancelled.");\n }\n return collega(await attendiMatch(risposta.url, options));\n }\n };\n}\n\n// src/standalone.ts\nvar PREFISSO = "caisual:save:";\nvar CHIAVE_VALIDA = /^[a-z0-9][a-z0-9_-]{0,31}$/;\nfunction verificaChiave(key) {\n if (!CHIAVE_VALIDA.test(key)) {\n throw creaErrore("invalid_request", "Save keys must use lowercase letters, numbers, underscores, or hyphens.");\n }\n}\nfunction leggiSalvataggio(testo) {\n if (testo === null) return null;\n try {\n return JSON.parse(testo);\n } catch {\n return null;\n }\n}\nfunction chiavi(archivio) {\n const risultato = [];\n for (let indice = 0; indice < archivio.length; indice++) {\n const key = archivio.key(indice);\n if (key?.startsWith(PREFISSO)) risultato.push(key.slice(PREFISSO.length));\n }\n return risultato;\n}\nfunction creaSave(archivio, ora) {\n const disponibile = () => {\n if (archivio === null) throw erroreOffline();\n return archivio;\n };\n return {\n async set(key, value) {\n verificaChiave(key);\n const locale = disponibile();\n const corpo = JSON.stringify({ value });\n const bytes = new TextEncoder().encode(corpo).byteLength;\n if (bytes > 262144) {\n throw creaErrore("payload_too_large", "The save is larger than 262144 bytes.");\n }\n if (locale.getItem(PREFISSO + key) === null && chiavi(locale).length >= 32) {\n throw creaErrore("save_limit", "A game can store at most 32 save keys.");\n }\n const voce = { value, bytes, updatedAt: ora() };\n locale.setItem(PREFISSO + key, JSON.stringify(voce));\n return { key, bytes, updatedAt: voce.updatedAt };\n },\n async get(key) {\n verificaChiave(key);\n return leggiSalvataggio(disponibile().getItem(PREFISSO + key))?.value ?? null;\n },\n async remove(key) {\n verificaChiave(key);\n disponibile().removeItem(PREFISSO + key);\n },\n async list() {\n const locale = disponibile();\n return chiavi(locale).flatMap((key) => {\n const voce = leggiSalvataggio(locale.getItem(PREFISSO + key));\n return voce === null ? [] : [{ key, bytes: voce.bytes, updatedAt: voce.updatedAt }];\n }).sort((a, b) => a.key.localeCompare(b.key));\n }\n };\n}\nasync function creaStandalone(input, invited = null) {\n const day = giornoUtc(input.ora());\n const seed = await calcolaSeed(input.hostname, day, input.subtle);\n return {\n connected: false,\n player: { id: "local", name: "Guest", guest: true },\n daily: { day, seed, random: creaMulberry32(seed), rng: () => creaMulberry32(seed) },\n time: { now: input.ora },\n save: creaSave(input.archivio, input.ora),\n board: {\n async submit() {\n return { accepted: false, reason: "offline", verified: false };\n },\n async top(_board, opzioni = {}) {\n if (opzioni.day !== void 0 && (!validBoardDay(opzioni.day) || opzioni.daily === false)) throw creaErrore("invalid_request", "day must be a real UTC date and cannot be combined with daily: false.");\n return { day: opzioni.day ?? (opzioni.daily ? day : null), entries: [], me: null };\n }\n },\n room: creaStanzeOffline(invited)\n };\n}\n\n// src/kit.ts\nfunction leggiAppOrigin(documento) {\n const valore = documento?.querySelector(\'meta[name="caisual-app"]\')?.getAttribute("content");\n if (valore === null || valore === void 0) return null;\n try {\n const url = new URL(valore);\n return url.origin === valore && (url.protocol === "https:" || url.protocol === "http:") ? valore : null;\n } catch {\n return null;\n }\n}\nfunction archivioReale() {\n try {\n return typeof localStorage === "undefined" ? null : localStorage;\n } catch {\n return null;\n }\n}\nfunction dipendenzeReali2() {\n return {\n finestra: typeof window === "undefined" ? null : window,\n documento: typeof document === "undefined" ? null : document,\n fetcher: (input, init) => globalThis.fetch(input, init),\n archivio: archivioReale(),\n language: typeof navigator === "undefined" ? "en" : navigator.language,\n pathname: typeof location === "undefined" ? "/" : location.pathname,\n hostname: typeof location === "undefined" ? "" : location.hostname,\n subtle: globalThis.crypto.subtle,\n ora: Date.now,\n sonda: () => probeDevice()\n };\n}\nasync function connetti(input) {\n const appOrigin = leggiAppOrigin(input.documento);\n const senzaPadre = input.finestra === null || input.finestra.parent === input.finestra;\n if (appOrigin === null || senzaPadre) {\n return localConnection(input);\n }\n const handshake = await attendiHandshake(\n input.finestra,\n appOrigin,\n input.timeoutHandshake\n );\n if (handshake === null) return localConnection(input);\n const biglietto = creaGestoreBiglietto(\n handshake.ticket,\n handshake.porta,\n input.finestra,\n input.ora,\n input.timeoutRinnovo,\n "portal"\n );\n const api = creaClienteApi(appOrigin, input.fetcher, biglietto);\n const prima = input.ora();\n let me;\n try {\n me = await api.me();\n } catch {\n const base2 = await creaStandalone(input, handshake.invite);\n return installSession(base2, handshake, input);\n }\n const dopo = input.ora();\n const scartoOrario = me.serverTime - (prima + dopo) / 2;\n const room = handshake.live === null ? creaStanzeOffline(handshake.invite) : creaGestoreStanze({\n appOrigin,\n liveOrigin: handshake.live,\n fetcher: input.fetcher,\n biglietto: creaGestoreBiglietto(\n null,\n handshake.porta,\n input.finestra,\n input.ora,\n input.timeoutRinnovo,\n "live"\n ),\n apriSocket(url) {\n if (input.apriSocket !== void 0) return input.apriSocket(url);\n if (typeof WebSocket === "undefined") throw erroreOffline();\n return new WebSocket(url);\n },\n ora: input.ora,\n setTimeout: (handler, timeout) => globalThis.setTimeout(handler, timeout),\n clearTimeout: (id) => globalThis.clearTimeout(id),\n setInterval: (handler, timeout) => globalThis.setInterval(handler, timeout),\n clearInterval: (id) => globalThis.clearInterval(id),\n voce: input.voce,\n segnalaStanza(room2) {\n try {\n handshake.porta.postMessage({ type: "caisual:room", room: room2 });\n } catch {\n }\n }\n }, handshake.invite);\n const base = {\n connected: true,\n player: me.player,\n daily: { day: me.day, seed: me.seed, random: creaMulberry32(me.seed), rng: () => creaMulberry32(me.seed) },\n time: { now: () => input.ora() + scartoOrario },\n save: {\n set: (key, value) => api.saveSet(key, value),\n get: (key) => api.saveGet(key),\n remove: (key) => api.saveRemove(key),\n list: () => api.saveList()\n },\n board: {\n async submit(board, score, opzioni = {}) {\n try {\n return await api.boardSubmit(board, score, opzioni.daily === true);\n } catch (errore) {\n if (typeof errore === "object" && errore !== null && "code" in errore && errore.code === "offline") return { accepted: false, reason: "offline", verified: false };\n throw errore;\n }\n },\n top: (board, opzioni = {}) => api.boardTop(board, opzioni)\n },\n room\n };\n return installSession(base, handshake, input);\n}\nfunction installSession(base, handshake, input) {\n const coordinator = createSession(base, handshake?.overlay?.configuration ?? null, base.connected && handshake?.live != null);\n if (handshake?.overlay) {\n const dispose = attachKitBridge(handshake.porta, handshake.overlay, coordinator);\n if (coordinator.session.capabilities.overlay && typeof window !== "undefined" && input?.finestra === window) window.addEventListener("pagehide", dispose, { once: true });\n }\n const preferences = handshake?.languagePreferences?.length ? handshake.languagePreferences : [handshake?.language ?? input?.language ?? "en"];\n const language = resolveGameLanguage(preferences, handshake?.gameLanguages ?? (handshake?.overlay ? manifestLanguages(handshake.overlay.configuration.manifest) : void 0));\n const uiLanguage = overlayLocale(handshake?.uiLanguage ?? handshake?.language ?? input?.language);\n return {\n ...base,\n player: { ...base.player, language, uiLanguage },\n text: createTextLoader(input?.fetcher ?? globalThis.fetch, language, input?.pathname),\n room: coordinator.rooms,\n session: coordinator.session,\n overlay: coordinator.overlay\n };\n}\nasync function localConnection(input) {\n return installSession(await creaStandalone(input), void 0, input);\n}\nfunction dispositivoSconosciuto() {\n return {\n webgl2: false,\n webgpu: false,\n wasm: false,\n threads: false,\n isolated: false,\n gpu: "none",\n memoryMb: null,\n cores: null,\n mobile: false,\n tier: "low"\n };\n}\nasync function attendiSonda(sonda) {\n let timer;\n try {\n return await Promise.race([\n Promise.resolve().then(sonda).catch(() => dispositivoSconosciuto()),\n new Promise((resolve) => {\n timer = globalThis.setTimeout(() => resolve(dispositivoSconosciuto()), 1500);\n })\n ]);\n } finally {\n if (timer !== void 0) globalThis.clearTimeout(timer);\n }\n}\nfunction creaKit(input = dipendenzeReali2()) {\n let promessa = null;\n return {\n connect() {\n promessa ?? (promessa = Promise.all([connetti(input), attendiSonda(input.sonda)]).then(([connessione, device]) => ({ ...connessione, device })));\n return promessa;\n }\n };\n}\n\n// src/index.ts\nvar caisual = creaKit();\nglobalThis.caisual = caisual;\nvar index_default = caisual;\nexport {\n caisual,\n index_default as default\n};\n');
4746
+ return;
4747
+ }
4748
+ const textMatch = url.pathname.match(/^\/__caisual\/text\/([^/]+)\.json$/);
4749
+ if (textMatch) {
4750
+ const language = normalizeLanguage(textMatch[1]);
4751
+ if (!language) {
4752
+ sendError(response, new DevHttpError(400, "invalid_language", "Use a BCP 47 language tag."));
4753
+ return;
4754
+ }
4755
+ const dictionary = await loadGameTexts(language, manifestLanguages(this.manifest)[0], (tag) => readLocalDictionary(this.clientRoot, tag));
4756
+ response.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
4757
+ response.end(request.method === "HEAD" ? void 0 : JSON.stringify(dictionary));
4393
4758
  return;
4394
4759
  }
4395
4760
  let decoded;
@@ -4401,22 +4766,22 @@ var DevService = class {
4401
4766
  }
4402
4767
  const relativePath = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, "");
4403
4768
  const candidate = resolve(this.clientRoot, relativePath);
4404
- if (relative2(this.clientRoot, candidate).startsWith(`..${sep}`) || candidate === this.clientRoot) {
4769
+ if (relative2(this.clientRoot, candidate).startsWith(`..${sep2}`) || candidate === this.clientRoot) {
4405
4770
  sendError(response, new DevHttpError(404, "not_found", "The game file was not found."));
4406
4771
  return;
4407
4772
  }
4408
- const real = await fs2.realpath(candidate).catch(() => null);
4409
- if (real === null || real !== this.clientRoot && !real.startsWith(`${this.clientRoot}${sep}`)) {
4773
+ const real = await fs3.realpath(candidate).catch(() => null);
4774
+ if (real === null || real !== this.clientRoot && !real.startsWith(`${this.clientRoot}${sep2}`)) {
4410
4775
  sendError(response, new DevHttpError(404, "not_found", "The game file was not found."));
4411
4776
  return;
4412
4777
  }
4413
- const stat = await fs2.stat(real);
4778
+ const stat = await fs3.stat(real);
4414
4779
  if (!stat.isFile()) {
4415
4780
  sendError(response, new DevHttpError(404, "not_found", "The game file was not found."));
4416
4781
  return;
4417
4782
  }
4418
4783
  const html = extname(real).toLowerCase() === ".html";
4419
- const body = html ? Buffer.from(injectAppMeta(await fs2.readFile(real, "utf8"), this.portalOrigin)) : await fs2.readFile(real);
4784
+ const body = html ? Buffer.from(injectAppMeta(await fs3.readFile(real, "utf8"), this.portalOrigin)) : await fs3.readFile(real);
4420
4785
  response.statusCode = 200;
4421
4786
  response.setHeader("Content-Type", contentType(real));
4422
4787
  response.setHeader("Content-Length", body.byteLength);
@@ -4432,7 +4797,7 @@ var DevService = class {
4432
4797
  }
4433
4798
  if (url.pathname === "/__caisual/overlay/v1.js" && (request.method === "GET" || request.method === "HEAD")) {
4434
4799
  response.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
4435
- response.end(request.method === "HEAD" ? void 0 : '// ../contracts/src/slug.ts\nvar NOMI_RISERVATI = [\n "www",\n "api",\n "app",\n "play",\n "live",\n "multi",\n "cdn",\n "assets",\n "static",\n "mail",\n "mx",\n "ns1",\n "ns2",\n "autodiscover",\n "_dmarc",\n "admin",\n "login",\n "account",\n "auth",\n "pay",\n "secure",\n "support",\n "help",\n "blog",\n "status",\n "dev",\n "staging",\n "test",\n "caisual",\n "shipz"\n];\nvar RISERVATI = new Set(NOMI_RISERVATI);\nvar SLUG_NUOVO = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;\nvar SLUG_STORICO = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;\nfunction isValidSlug(value) {\n return value.length >= 3 && value.length <= 32 && SLUG_NUOVO.test(value) || SLUG_STORICO.test(value);\n}\nfunction isReservedSlug(value) {\n return RISERVATI.has(value);\n}\n\n// ../contracts/src/manifest.ts\nfunction risolviModalita(manifest, mode) {\n const scelta = mode === null ? void 0 : manifest.modes.find((voce) => voce.id === mode);\n if (mode !== null && scelta === void 0) throw new Error("The selected game mode does not exist.");\n return { players: { ...scelta?.players ?? manifest.players }, lobby: scelta?.lobby ?? manifest.lobby };\n}\nfunction risolviPresentazione(manifest, mode) {\n risolviModalita(manifest, mode);\n const scelta = manifest.modes.find((voce) => voce.id === mode);\n return {\n execution: scelta?.execution ?? null,\n label: scelta?.label ?? scelta?.id ?? manifest.name ?? "Play",\n instructions: scelta?.instructions ?? null\n };\n}\nvar TETTO_GIOCATORI = 24;\nvar RITARDO_SPETTATORI_MS = 3e3;\nvar MASSIMO_CLASSIFICHE = 32;\nvar CAMPI = /* @__PURE__ */ new Set([\n "overlay",\n "manifest",\n "id",\n "name",\n "description",\n "cover",\n "screenshots",\n "tags",\n "language",\n "platform",\n "orientation",\n "input",\n "visibility",\n "network",\n "isolated",\n "requires",\n "players",\n "lobby",\n "persistent",\n "spectators",\n "boards",\n "roles",\n "teams",\n "voice",\n "modes"\n]);\nvar INPUT = /* @__PURE__ */ new Set(["keyboard", "mouse", "touch", "gamepad"]);\nvar PLATFORM = /* @__PURE__ */ new Set(["desktop", "mobile", "both"]);\nvar ORIENTATION = /* @__PURE__ */ new Set(["landscape", "portrait"]);\nvar VISIBILITY = /* @__PURE__ */ new Set(["public", "unlisted"]);\nvar VOICE = /* @__PURE__ */ new Set(["none", "room", "team", "proximity"]);\nvar PERFORMANCE = /* @__PURE__ */ new Set(["light", "medium", "heavy"]);\nvar TAG = /^[a-z0-9-]+$/;\nvar ID_INTERNO = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;\nvar CAMPO_MATCHMAKING = /^[a-z0-9][a-z0-9-]{0,31}$/;\nvar ID_CLASSIFICA = /^[a-z0-9][a-z0-9_-]{0,31}$/;\nfunction oggetto(value) {\n if (typeof value !== "object" || value === null || Array.isArray(value)) return null;\n return value;\n}\nfunction percorsoRelativo(value) {\n if (value === "" || value.startsWith("/") || value.includes("\\\\") || value.includes("\\0")) return false;\n if (value.includes("?") || value.includes("#")) return false;\n const parti = value.split("/");\n if (parti.some((parte) => parte === "" || parte === "." || parte === "..")) return false;\n try {\n const decoded = parti.map((parte) => decodeURIComponent(parte));\n return !decoded.some((parte) => parte === "" || parte === "." || parte === ".." || parte.includes("/"));\n } catch {\n return false;\n }\n}\nfunction hostValido(value) {\n if (value.length === 0 || value.length > 253) return false;\n if (value.includes("://") || /[/:?#@]/.test(value)) return false;\n const parti = value.split(".");\n return parti.every(\n (parte) => parte.length >= 1 && parte.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(parte)\n );\n}\nfunction interoTra(value, min, max) {\n return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;\n}\nfunction stringaDefault(dati, campo, valoreDefault, errori) {\n const value = dati[campo];\n if (value === void 0) return valoreDefault;\n if (typeof value !== "string") {\n errori.push(`${campo}: must be a string.`);\n return valoreDefault;\n }\n return value;\n}\nfunction testoFacoltativo(value, key, max, path, errors) {\n if (value[key] === void 0) return void 0;\n const text = value[key];\n if (typeof text !== "string" || text.trim().length === 0 || text.trim().length > max || /[\\r\\n\\u0000-\\u001f]/.test(text)) {\n errors.push(`${path}.${key}: must contain 1-${max} characters on one line.`);\n return void 0;\n }\n return text.trim();\n}\nfunction validaManifest(valore) {\n const errori = [];\n const dati = oggetto(valore);\n if (dati === null) return { ok: false, errori: ["manifest: must be a JSON object."] };\n for (const campo of Object.keys(dati)) {\n if (!CAMPI.has(campo)) errori.push(`${campo}: unknown field.`);\n }\n if (dati.manifest === void 0) errori.push("manifest: is required and must be 1.");\n else if (dati.manifest !== 1) errori.push("manifest: must be exactly 1.");\n const id = stringaDefault(dati, "id", "", errori);\n if (dati.id === void 0) errori.push("id: is required.");\n else if (typeof dati.id === "string") {\n if (!isValidSlug(id)) {\n errori.push("id: must be 3-32 lowercase ASCII letters or digits with internal hyphens; historical UUID v4 slugs may be 36 characters.");\n } else if (isReservedSlug(id)) errori.push("id: this slug is reserved.");\n }\n const name = stringaDefault(dati, "name", "", errori);\n if (dati.name === void 0) errori.push("name: is required.");\n else if (typeof dati.name === "string" && (name.trim() === "" || name.length > 60)) {\n errori.push("name: must contain 1-60 characters.");\n }\n const description = stringaDefault(dati, "description", "", errori);\n if (description.length > 500) errori.push("description: must be at most 500 characters.");\n let cover = null;\n if (dati.cover !== void 0 && dati.cover !== null) {\n if (typeof dati.cover !== "string") errori.push("cover: must be a relative file path or null.");\n else if (!percorsoRelativo(dati.cover)) errori.push("cover: must be a relative file path without query, fragment, or parent segments.");\n else cover = dati.cover;\n }\n const screenshots = [];\n if (dati.screenshots !== void 0) {\n if (!Array.isArray(dati.screenshots)) errori.push("screenshots: must be an array of relative file paths.");\n else {\n if (dati.screenshots.length > 8) errori.push("screenshots: must contain at most 8 paths.");\n for (const [indice, value] of dati.screenshots.entries()) {\n if (typeof value !== "string" || !percorsoRelativo(value)) {\n errori.push(`screenshots[${indice}]: must be a relative file path without query, fragment, or parent segments.`);\n } else screenshots.push(value);\n }\n }\n }\n const tags = [];\n if (dati.tags !== void 0) {\n if (!Array.isArray(dati.tags)) errori.push("tags: must be an array.");\n else {\n if (dati.tags.length > 10) errori.push("tags: must contain at most 10 tags.");\n for (const [indice, value] of dati.tags.entries()) {\n if (typeof value !== "string" || value.length > 24 || !TAG.test(value)) {\n errori.push(`tags[${indice}]: must be 1-24 lowercase letters, digits, or hyphens.`);\n } else tags.push(value);\n }\n }\n }\n const language = stringaDefault(dati, "language", "en", errori);\n if (!/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/.test(language)) {\n errori.push("language: must be a BCP 47 language tag such as en, it, or pt-BR.");\n }\n let platform = "both";\n if (dati.platform === void 0) errori.push("platform: is required.");\n else if (typeof dati.platform !== "string" || !PLATFORM.has(dati.platform)) {\n errori.push("platform: must be desktop, mobile, or both.");\n } else platform = dati.platform;\n let orientation = "landscape";\n if (dati.orientation !== void 0) {\n if (typeof dati.orientation !== "string" || !ORIENTATION.has(dati.orientation)) {\n errori.push("orientation: must be landscape or portrait.");\n } else orientation = dati.orientation;\n }\n const input = [];\n if (dati.input !== void 0) {\n if (!Array.isArray(dati.input)) errori.push("input: must be an array.");\n else for (const [indice, value] of dati.input.entries()) {\n if (typeof value !== "string" || !INPUT.has(value)) {\n errori.push(`input[${indice}]: must be keyboard, mouse, touch, or gamepad.`);\n } else if (input.includes(value)) errori.push(`input[${indice}]: duplicate value ${value}.`);\n else input.push(value);\n }\n }\n let visibility = "public";\n if (dati.visibility !== void 0) {\n if (typeof dati.visibility !== "string" || !VISIBILITY.has(dati.visibility)) {\n errori.push("visibility: must be public or unlisted.");\n } else visibility = dati.visibility;\n }\n const network = [];\n if (dati.network !== void 0) {\n if (!Array.isArray(dati.network)) errori.push("network: must be an array of host names.");\n else for (const [indice, value] of dati.network.entries()) {\n if (typeof value !== "string" || !hostValido(value)) {\n errori.push(`network[${indice}]: must be a host name without scheme, port, path, query, or fragment.`);\n } else if (network.includes(value)) errori.push(`network[${indice}]: duplicate host ${value}.`);\n else network.push(value);\n }\n }\n let isolated = false;\n if (dati.isolated !== void 0) {\n if (typeof dati.isolated !== "boolean") errori.push("isolated: must be a boolean.");\n else isolated = dati.isolated;\n }\n const requires = {\n webgl2: false,\n webgpu: false,\n wasm: false,\n threads: false,\n memoryMb: null,\n performance: "light"\n };\n if (dati.requires !== void 0) {\n const value = oggetto(dati.requires);\n if (value === null) errori.push("requires: must be an object.");\n else {\n for (const campo of Object.keys(value)) {\n if (!["webgl2", "webgpu", "wasm", "threads", "memoryMb", "performance"].includes(campo)) {\n errori.push(`requires.${campo}: unknown field.`);\n }\n }\n for (const campo of ["webgl2", "webgpu", "wasm", "threads"]) {\n if (value[campo] === void 0) continue;\n if (typeof value[campo] !== "boolean") errori.push(`requires.${campo}: must be a boolean.`);\n else requires[campo] = value[campo];\n }\n if (value.memoryMb !== void 0) {\n if (value.memoryMb !== null && (!interoTra(value.memoryMb, 512, 32768) || value.memoryMb % 256 !== 0)) {\n errori.push("requires.memoryMb: must be an integer from 512 to 32768 in steps of 256, or null.");\n } else requires.memoryMb = value.memoryMb;\n }\n if (value.performance !== void 0) {\n if (typeof value.performance !== "string" || !PERFORMANCE.has(value.performance)) {\n errori.push("requires.performance: must be light, medium, or heavy.");\n } else requires.performance = value.performance;\n }\n if (requires.threads && !isolated) errori.push("requires.threads: needs isolated: true.");\n }\n }\n let players = { min: 1, max: 1 };\n if (dati.players !== void 0) {\n const value = oggetto(dati.players);\n if (value === null) errori.push("players: must be an object with min and max.");\n else {\n for (const campo of Object.keys(value)) {\n if (campo !== "min" && campo !== "max") errori.push(`players.${campo}: unknown field.`);\n }\n if (!interoTra(value.min, 1, TETTO_GIOCATORI)) errori.push(`players.min: must be an integer from 1 to ${TETTO_GIOCATORI}.`);\n if (!interoTra(value.max, 1, TETTO_GIOCATORI)) errori.push(`players.max: must be an integer from 1 to ${TETTO_GIOCATORI} in manifest version 1.`);\n if (interoTra(value.min, 1, TETTO_GIOCATORI) && interoTra(value.max, 1, TETTO_GIOCATORI)) {\n if (value.min > value.max) errori.push("players.max: must be greater than or equal to players.min.");\n else players = { min: value.min, max: value.max };\n }\n }\n }\n let lobby = false;\n if (dati.lobby !== void 0) {\n if (typeof dati.lobby !== "boolean") errori.push("lobby: must be a boolean.");\n else lobby = dati.lobby;\n }\n let persistent = false;\n if (dati.persistent !== void 0) {\n if (typeof dati.persistent !== "boolean") errori.push("persistent: must be a boolean.");\n else persistent = dati.persistent;\n }\n let spectators = { delayMs: RITARDO_SPETTATORI_MS };\n if (dati.spectators === false || dati.spectators === null) spectators = null;\n else if (dati.spectators !== void 0 && dati.spectators !== true) {\n const value = oggetto(dati.spectators);\n if (value === null) {\n errori.push("spectators: must be a boolean or an object with delayMs.");\n } else {\n for (const campo of Object.keys(value)) {\n if (campo !== "delayMs") errori.push(`spectators.${campo}: unknown field.`);\n }\n if (!interoTra(value.delayMs, 0, 3e4)) {\n errori.push("spectators.delayMs: must be an integer from 0 to 30000.");\n } else spectators = { delayMs: value.delayMs };\n }\n }\n let overlay = null;\n if (dati.overlay !== void 0 && dati.overlay !== null) {\n const value = oggetto(dati.overlay);\n if (value === null) errori.push("overlay: must be an object or null.");\n else {\n for (const key of Object.keys(value)) if (!["version", "accent"].includes(key)) errori.push(`overlay.${key}: unknown field.`);\n if (value.version !== 1) errori.push("overlay.version: must be exactly 1.");\n if (value.accent !== void 0 && (typeof value.accent !== "string" || !/^#[0-9a-fA-F]{6}$/.test(value.accent))) {\n errori.push("overlay.accent: must be a six-digit hexadecimal color, such as #336699.");\n }\n overlay = { version: 1, ...typeof value.accent === "string" ? { accent: value.accent } : {} };\n }\n }\n const boards = {};\n if (dati.boards !== void 0) {\n const value = oggetto(dati.boards);\n if (value === null) errori.push("boards: must be an object of board ids.");\n else {\n if (Object.keys(value).length > MASSIMO_CLASSIFICHE) {\n errori.push(`boards: at most ${MASSIMO_CLASSIFICHE} boards.`);\n }\n for (const [id2, raw] of Object.entries(value)) {\n let valido = true;\n if (!ID_CLASSIFICA.test(id2)) {\n errori.push(`boards.${id2}: invalid board id.`);\n valido = false;\n }\n const board = oggetto(raw);\n if (board === null) {\n errori.push(`boards.${id2}.source: must be "client" or "server".`);\n continue;\n }\n for (const campo of Object.keys(board)) {\n if (!["source", "label", "periods"].includes(campo)) errori.push(`boards.${id2}.${campo}: unknown field.`);\n }\n if (board.source !== "client" && board.source !== "server") {\n errori.push(`boards.${id2}.source: must be "client" or "server".`);\n valido = false;\n }\n const label = testoFacoltativo(board, "label", 48, `boards.${id2}`, errori);\n let periods = ["all-time"];\n if (board.periods !== void 0) {\n if (!Array.isArray(board.periods) || board.periods.length < 1 || board.periods.length > 2 || board.periods.some((period) => period !== "daily" && period !== "all-time") || new Set(board.periods).size !== board.periods.length) {\n errori.push(`boards.${id2}.periods: must contain daily, all-time, or both without duplicates.`);\n } else periods = [...board.periods];\n }\n if (valido) Object.defineProperty(boards, id2, { value: {\n source: board.source,\n periods,\n ...label === void 0 ? {} : { label }\n }, enumerable: true, configurable: true, writable: true });\n }\n }\n }\n const roles = [];\n if (dati.roles !== void 0) {\n if (!Array.isArray(dati.roles)) errori.push("roles: must be an array.");\n else {\n const ids = /* @__PURE__ */ new Set();\n for (const [indice, raw] of dati.roles.entries()) {\n const value = oggetto(raw);\n if (value === null) {\n errori.push(`roles[${indice}]: must be an object.`);\n continue;\n }\n for (const campo of Object.keys(value)) {\n if (!["id", "min", "max", "label"].includes(campo)) errori.push(`roles[${indice}].${campo}: unknown field.`);\n }\n const idRuolo = value.id;\n const min = value.min;\n const max = value.max;\n let valido = true;\n if (typeof idRuolo !== "string" || idRuolo.length > 32 || !ID_INTERNO.test(idRuolo)) {\n errori.push(`roles[${indice}].id: must be 1-32 lowercase letters, digits, or internal hyphens.`);\n valido = false;\n } else if (ids.has(idRuolo)) {\n errori.push(`roles[${indice}].id: duplicate role ${idRuolo}.`);\n valido = false;\n } else ids.add(idRuolo);\n if (!interoTra(min, 0, TETTO_GIOCATORI)) {\n errori.push(`roles[${indice}].min: must be an integer from 0 to ${TETTO_GIOCATORI}.`);\n valido = false;\n }\n if (max !== void 0 && !interoTra(max, 0, TETTO_GIOCATORI)) {\n errori.push(`roles[${indice}].max: must be an integer from 0 to ${TETTO_GIOCATORI} when present.`);\n valido = false;\n }\n if (typeof min === "number" && typeof max === "number" && min > max) {\n errori.push(`roles[${indice}].max: must be greater than or equal to min.`);\n valido = false;\n }\n const label = testoFacoltativo(value, "label", 32, `roles[${indice}]`, errori);\n if (valido) roles.push({\n id: idRuolo,\n min,\n ...max === void 0 ? {} : { max },\n ...label === void 0 ? {} : { label }\n });\n }\n }\n }\n let teams = null;\n if (dati.teams !== void 0 && dati.teams !== null) {\n const value = oggetto(dati.teams);\n if (value === null) errori.push("teams: must be null or an object with min and max.");\n else {\n for (const campo of Object.keys(value)) {\n if (campo !== "min" && campo !== "max") errori.push(`teams.${campo}: unknown field.`);\n }\n if (!interoTra(value.min, 2, TETTO_GIOCATORI)) errori.push(`teams.min: must be an integer from 2 to ${TETTO_GIOCATORI}.`);\n if (!interoTra(value.max, 2, TETTO_GIOCATORI)) errori.push(`teams.max: must be an integer from 2 to ${TETTO_GIOCATORI}.`);\n if (interoTra(value.min, 2, TETTO_GIOCATORI) && interoTra(value.max, 2, TETTO_GIOCATORI)) {\n if (value.min > value.max) errori.push("teams.max: must be greater than or equal to teams.min.");\n else teams = { min: value.min, max: value.max };\n }\n }\n }\n let voice = "none";\n if (dati.voice !== void 0) {\n if (typeof dati.voice !== "string" || !VOICE.has(dati.voice)) {\n errori.push("voice: must be none, room, team, or proximity.");\n } else voice = dati.voice;\n }\n const modes = [];\n if (dati.modes !== void 0) {\n if (!Array.isArray(dati.modes)) errori.push("modes: must be an array.");\n else {\n const ids = /* @__PURE__ */ new Set();\n for (const [indice, raw] of dati.modes.entries()) {\n const value = oggetto(raw);\n if (value === null) {\n errori.push(`modes[${indice}]: must be an object.`);\n continue;\n }\n for (const campo of Object.keys(value)) {\n if (!["id", "players", "lobby", "matchmaking", "execution", "label", "instructions"].includes(campo)) errori.push(`modes[${indice}].${campo}: unknown field.`);\n }\n if (typeof value.id !== "string" || value.id.length > 32 || !ID_INTERNO.test(value.id)) {\n errori.push(`modes[${indice}].id: must be 1-32 lowercase letters, digits, or internal hyphens.`);\n continue;\n }\n if (ids.has(value.id)) {\n errori.push(`modes[${indice}].id: duplicate mode ${value.id}.`);\n continue;\n }\n ids.add(value.id);\n const modo = { id: value.id };\n for (const [key2, max] of [["label", 48], ["instructions", 160]]) {\n const text = testoFacoltativo(value, key2, max, `modes[${indice}]`, errori);\n if (text !== void 0) modo[key2] = text;\n }\n if (value.execution !== void 0) {\n if (value.execution !== "local" && value.execution !== "room") errori.push(`modes[${indice}].execution: must be local or room.`);\n else modo.execution = value.execution;\n }\n if (overlay !== null && modo.execution === void 0) errori.push(`modes[${indice}].execution: is required with the standard overlay.`);\n if (value.players !== void 0) {\n const campo = `modes[${indice}].players`;\n const range = oggetto(value.players);\n if (range === null) errori.push(`${campo}: must be an object with min and max.`);\n else {\n for (const key2 of Object.keys(range)) {\n if (key2 !== "min" && key2 !== "max") errori.push(`${campo}.${key2}: unknown field.`);\n }\n if (!interoTra(range.min, 1, TETTO_GIOCATORI)) errori.push(`${campo}.min: must be an integer from 1 to ${TETTO_GIOCATORI}.`);\n if (!interoTra(range.max, 1, TETTO_GIOCATORI)) errori.push(`${campo}.max: must be an integer from 1 to ${TETTO_GIOCATORI}.`);\n if (interoTra(range.min, 1, TETTO_GIOCATORI) && interoTra(range.max, 1, TETTO_GIOCATORI)) {\n if (range.min > range.max) errori.push(`${campo}.max: must be greater than or equal to min.`);\n else modo.players = { min: range.min, max: range.max };\n }\n }\n }\n if (value.lobby !== void 0) {\n if (typeof value.lobby !== "boolean") errori.push(`modes[${indice}].lobby: must be a boolean.`);\n else modo.lobby = value.lobby;\n }\n if (modo.execution === "local") {\n const range = modo.players ?? players;\n if (range.min !== 1 || range.max !== 1) errori.push(`modes[${indice}].players: local execution requires min and max to be 1.`);\n if (modo.lobby ?? lobby) errori.push(`modes[${indice}].lobby: local execution requires false.`);\n if (value.matchmaking !== void 0) errori.push(`modes[${indice}].matchmaking: local execution cannot use matchmaking.`);\n }\n if (value.matchmaking === void 0) {\n modes.push(modo);\n continue;\n }\n const matchmaking = oggetto(value.matchmaking);\n if (matchmaking === null) {\n errori.push(`modes[${indice}].matchmaking: must be an object.`);\n continue;\n }\n for (const campo of Object.keys(matchmaking)) {\n if (!["key", "timeoutMs", "defaults"].includes(campo)) {\n errori.push(`modes[${indice}].matchmaking.${campo}: unknown field.`);\n }\n }\n let valido = true;\n const key = [];\n if (!Array.isArray(matchmaking.key) || matchmaking.key.length < 1 || matchmaking.key.length > 8) {\n errori.push(`modes[${indice}].matchmaking.key: must contain from 1 to 8 fields.`);\n valido = false;\n } else for (const [keyIndice, item] of matchmaking.key.entries()) {\n if (typeof item !== "string" || !CAMPO_MATCHMAKING.test(item)) {\n errori.push(`modes[${indice}].matchmaking.key[${keyIndice}]: must be 1-32 lowercase letters, digits, or hyphens and start with a letter or digit.`);\n valido = false;\n } else if (key.includes(item)) {\n errori.push(`modes[${indice}].matchmaking.key[${keyIndice}]: duplicate field ${item}.`);\n valido = false;\n } else key.push(item);\n }\n if (!interoTra(matchmaking.timeoutMs, 1e3, 3e5)) {\n errori.push(`modes[${indice}].matchmaking.timeoutMs: must be an integer from 1000 to 300000.`);\n valido = false;\n }\n let defaults;\n if (matchmaking.defaults !== void 0) {\n const values = oggetto(matchmaking.defaults);\n if (values === null || Object.keys(values).length !== key.length || key.some((field) => !Object.hasOwn(values, field))) {\n errori.push(`modes[${indice}].matchmaking.defaults: must contain exactly the declared key fields.`);\n } else {\n defaults = {};\n for (const [field, value2] of Object.entries(values)) {\n if (!(typeof value2 === "string" && value2.length >= 1 && value2.length <= 64 && /^[A-Za-z0-9_.:-]+$/.test(value2)) && !Number.isSafeInteger(value2)) {\n errori.push(`modes[${indice}].matchmaking.defaults.${field}: must be a string of 1-64 characters or a safe integer.`);\n } else Object.defineProperty(defaults, field, { value: value2, enumerable: true });\n }\n }\n }\n if (valido) modes.push({ ...modo, matchmaking: {\n ...defaults === void 0 ? {} : { defaults },\n key,\n timeoutMs: matchmaking.timeoutMs\n } });\n }\n }\n }\n if (overlay !== null && modes.length === 0) errori.push("modes: at least one explicit mode is required with the standard overlay.");\n if (errori.length > 0) return { ok: false, errori };\n return { ok: true, manifest: {\n manifest: 1,\n overlay,\n id,\n name,\n description,\n cover,\n screenshots,\n tags,\n language,\n platform,\n orientation,\n input,\n visibility,\n network,\n isolated,\n requires,\n players,\n lobby,\n persistent,\n spectators,\n boards,\n roles,\n teams,\n voice,\n modes\n } };\n}\n\n// ../contracts/src/overlay.ts\nvar OVERLAY_PANELS = ["home", "room", "invite", "friends", "voice", "boards"];\nfunction overlayConfiguration(manifest, coverUrl = null, invite = null) {\n const validated = validaManifest(manifest);\n if (!validated.ok) throw new Error("The overlay manifest is invalid.");\n return { manifest: validated.manifest, coverUrl, invite };\n}\nfunction record(value) {\n return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;\n}\nfunction validOverlayView(value) {\n const data = record(value);\n return data !== null && Object.keys(data).every((key) => ["inputBlocked", "reservedRects", "shortcutEnabled"].includes(key)) && (data.shortcutEnabled === void 0 || typeof data.shortcutEnabled === "boolean") && typeof data.inputBlocked === "boolean" && Array.isArray(data.reservedRects) && data.reservedRects.length <= 8 && data.reservedRects.every((value2) => {\n const rect = record(value2);\n return rect !== null && Object.keys(rect).length === 4 && ["x", "y", "width", "height"].every((key) => typeof rect[key] === "number" && Number.isFinite(rect[key]) && rect[key] >= 0 && rect[key] <= 1e5);\n });\n}\nfunction validOverlayRequest(value) {\n const message = record(value), args = record(message?.args);\n if (message?.type !== "caisual:overlay" || message.v !== 1 || typeof message.epoch !== "string" || message.epoch.length < 1 || message.epoch.length > 128 || typeof message.requestId !== "string" || !(/^[1-9][0-9]{0,15}$/.test(message.requestId) && Number.isSafeInteger(Number(message.requestId))) || args === null) return false;\n if (Object.keys(message).some((key) => !["type", "v", "epoch", "requestId", "sessionId", "op", "args"].includes(key)) || !(message.sessionId === void 0 || message.sessionId === null || typeof message.sessionId === "string" && /^[1-9][0-9]{0,15}$/.test(message.sessionId))) return false;\n const keys = (...allowed) => Object.keys(args).every((key) => allowed.includes(key));\n const text = (key) => typeof args[key] === "string" && args[key].length >= 1 && args[key].length <= 64;\n switch (message.op) {\n case "local.start":\n return keys("mode") && text("mode");\n case "room.create":\n return keys("mode") && (args.mode === null || text("mode"));\n case "room.join":\n return keys("code") && (args.code === void 0 || text("code"));\n case "room.watch":\n return keys("code") && text("code");\n case "room.match": {\n const key = record(args.key);\n return keys("mode", "key") && text("mode") && (args.key === void 0 || key !== null && Object.keys(key).length <= 8 && Object.values(key).every((v) => typeof v === "string" && v.length >= 1 && v.length <= 64 || typeof v === "number" && Number.isSafeInteger(v)));\n }\n case "room.ready":\n return keys("ready") && typeof args.ready === "boolean";\n case "room.role":\n case "room.requestRole":\n return keys("role") && text("role");\n case "room.team":\n return keys("team") && Number.isInteger(args.team) && args.team >= 1 && args.team <= 24;\n case "room.start":\n case "session.cancel":\n case "session.leave":\n case "session.disconnect":\n case "session.resume":\n return keys();\n case "voice.join":\n case "voice.leave":\n return keys() && typeof message.sessionId === "string";\n case "voice.mute":\n return keys("muted") && typeof args.muted === "boolean" && typeof message.sessionId === "string";\n case "voice.setVolume":\n return keys("playerId", "volume") && typeof message.sessionId === "string" && typeof args.playerId === "string" && args.playerId.length > 0 && args.playerId.length <= 128 && typeof args.volume === "number" && Number.isFinite(args.volume) && args.volume >= 0 && args.volume <= 1;\n case "overlay.view":\n return validOverlayView(args);\n default:\n return false;\n }\n}\nfunction validBoardDay(value) {\n if (typeof value !== "string" || !/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) return false;\n const at = Date.parse(`${value}T00:00:00Z`);\n return Number.isFinite(at) && new Date(at).toISOString().slice(0, 10) === value;\n}\nfunction validOverlaySessionState(value) {\n const data = record(value);\n const exact = (v, keys) => v !== null && Object.keys(v).length === keys.length && Object.keys(v).every((key) => keys.includes(key));\n const text = (v) => typeof v === "string" && v.length <= 128;\n const nullable = (v) => v === null || text(v);\n const finite = (v) => typeof v === "number" && Number.isFinite(v);\n if (!data || !exact(data, ["kind", "id", "mode", "localStatus", "ready", "capabilities", "room", "waiting", "resume", "resumeError", ..."voice" in data ? ["voice"] : []])) return false;\n if (data.voice !== void 0 && data.voice !== null && (data.kind !== "room" || !record(data.room) || !validOverlayVoice(data.voice))) return false;\n const capabilities = record(data.capabilities), room = record(data.room), waiting = record(data.waiting), resume = record(data.resume);\n if (!["boot", "home", "attaching", "matching", "local", "room", "watch"].includes(String(data.kind)) || !nullable(data.id) || !nullable(data.mode) || ![null, "playing", "ended"].includes(data.localStatus) || typeof data.ready !== "boolean" || typeof data.resumeError !== "boolean" || !exact(capabilities, ["local", "rooms", "overlay", "requestRole"]) || !Object.values(capabilities).every((v) => typeof v === "boolean")) return false;\n if (data.waiting !== null && (!exact(waiting, ["players", "min", "max"]) || !Object.values(waiting).every((v) => Number.isInteger(v) && Number(v) >= 0 && Number(v) <= 24))) return false;\n if (data.resume !== null && (!exact(resume, ["version", "code", "mode", "updatedAt"]) || resume.version !== 1 || !text(resume.code) || !nullable(resume.mode) || !finite(resume.updatedAt))) return false;\n if (data.room === null) return true;\n if (!exact(room, ["code", "mode", "status", "host", "you", "players", "countdownAt", "connection", "closedCode", "limits", "lobby", "persistent", "delayMs", "requestRole"]) || !room) return false;\n const limits = record(room.limits);\n return text(room.code) && nullable(room.mode) && nullable(room.host) && nullable(room.you) && ["lobby", "countdown", "playing", "ended"].includes(String(room.status)) && ["connecting", "connected", "reconnecting", "disconnected", "ended", "closed", "replaced"].includes(String(room.connection)) && ["countdownAt", "closedCode", "delayMs"].every((key) => room[key] === null || finite(room[key])) && ["lobby", "persistent", "requestRole"].every((key) => typeof room[key] === "boolean") && exact(limits, ["min", "max"]) && Object.values(limits).every((v) => Number.isInteger(v) && Number(v) >= 1 && Number(v) <= 24) && Array.isArray(room.players) && room.players.length <= 24 && room.players.every((value2) => {\n const player = record(value2);\n return exact(player, ["id", "name", "guest", "role", "team", "ready", "connected"]) && player !== null && text(player.id) && text(player.name) && nullable(player.role) && (player.team === null || Number.isInteger(player.team) && Number(player.team) >= 1 && Number(player.team) <= 24) && ["guest", "ready", "connected"].every((key) => typeof player[key] === "boolean");\n });\n}\nfunction validOverlayVoice(value) {\n const voice = record(value);\n if (!voice || Object.keys(voice).length !== 6 || !["mode", "state", "mic", "muted", "speaking", "peers"].every((key) => key in voice) || !["room", "team", "proximity"].includes(String(voice.mode)) || !["off", "joining", "on", "reconnecting"].includes(String(voice.state)) || !["mic", "muted", "speaking"].every((key) => typeof voice[key] === "boolean") || !Array.isArray(voice.peers) || voice.peers.length > 24) return false;\n const ids = /* @__PURE__ */ new Set();\n return voice.peers.every((value2) => {\n const peer = record(value2);\n if (!peer || Object.keys(peer).length !== 5 || !["id", "mic", "muted", "speaking", "volume"].every((key) => key in peer) || typeof peer.id !== "string" || !peer.id.length || peer.id.length > 128 || ids.has(peer.id) || !["mic", "muted", "speaking"].every((key) => typeof peer[key] === "boolean") || typeof peer.volume !== "number" || !Number.isFinite(peer.volume) || peer.volume < 0 || peer.volume > 1) return false;\n ids.add(peer.id);\n return true;\n });\n}\n\n// src/errors.ts\nfunction creaErrore(code, message) {\n return Object.assign(new Error(message), { name: "CaisualError", code });\n}\n\n// src/overlay/host-bridge.ts\nfunction eMessaggioReady(value) {\n return record(value)?.type === "caisual:ready";\n}\nfunction eRichiestaBiglietto(value) {\n const data = record(value);\n return data?.type === "caisual:ticket" && (data.aud === void 0 || data.aud === "portal" || data.aud === "live");\n}\nfunction stanzaDaMessaggio(value) {\n const data = record(value);\n if (data?.type !== "caisual:room") return void 0;\n if (data.room === null) return null;\n const room = record(data.room);\n return typeof room?.code === "string" && /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/.test(room.code) ? { code: room.code } : void 0;\n}\nfunction creaPonteOspite(input) {\n let port = null, epoch = null, instance = null;\n let disposed = false, legacyReady = true, sequence = 0, requestId = 0;\n let state = null, clockOffset = null;\n let polling = null, pollingEnd = null;\n const pending = /* @__PURE__ */ new Map();\n const states = /* @__PURE__ */ new Set();\n const shortcuts = /* @__PURE__ */ new Set();\n const opens = /* @__PURE__ */ new Set();\n const errors = /* @__PURE__ */ new Set();\n const scores = /* @__PURE__ */ new Set();\n const notify = (listeners, value) => {\n for (const listener of listeners) try {\n listener(value);\n } catch {\n }\n };\n const rejectPending = () => {\n for (const value of pending.values()) {\n input.finestra.clearTimeout(value.timer);\n value.reject(creaErrore("session_replaced", "The game document changed."));\n }\n pending.clear();\n };\n const stopPolling = () => {\n if (polling !== null) input.finestra.clearInterval(polling);\n if (pollingEnd !== null) input.finestra.clearTimeout(pollingEnd);\n polling = pollingEnd = null;\n };\n const askReady = () => {\n if (!disposed && input.frame.src !== "") input.frame.contentWindow?.postMessage({ type: "caisual:ready?" }, input.origineGioco);\n };\n const poll = () => {\n stopPolling();\n polling = input.finestra.setInterval(askReady, 500);\n pollingEnd = input.finestra.setTimeout(stopPolling, 1e4);\n askReady();\n };\n const loaded = () => {\n legacyReady = true;\n poll();\n };\n const listen = (event) => {\n if (disposed || event.origin !== input.origineGioco || event.source !== input.frame.contentWindow || !eMessaggioReady(event.data)) return;\n const data = record(event.data);\n const nextInstance = typeof data.instance === "string" && data.instance.length <= 128 ? data.instance : null;\n if (port && (nextInstance !== null ? nextInstance === instance : !legacyReady)) return;\n stopPolling();\n legacyReady = false;\n instance = nextInstance;\n rejectPending();\n port?.close();\n input.onRoom(null);\n epoch = input.epoch?.() ?? crypto.randomUUID();\n sequence = requestId = 0;\n state = null;\n clockOffset = null;\n notify(states, null);\n const channel = input.creaCanale?.() ?? new MessageChannel();\n const currentPort = channel.port1, currentEpoch = epoch;\n port = currentPort;\n const current = () => !disposed && port === currentPort && epoch === currentEpoch;\n currentPort.onmessage = (event2) => {\n if (!current()) return;\n const data2 = record(event2.data);\n if (eRichiestaBiglietto(data2)) {\n const aud = data2?.aud === "live" ? "live" : "portal";\n void input.rinnova(aud).then((ticket) => {\n if (current()) currentPort.postMessage({ type: "caisual:ticket", aud, ticket });\n }).catch(() => {\n if (current()) currentPort.postMessage({ type: "caisual:ticket", aud, error: "offline" });\n });\n return;\n }\n const room = stanzaDaMessaggio(data2);\n if (room !== void 0) {\n input.onRoom(room);\n return;\n }\n if (data2?.v !== 1 || data2.epoch !== currentEpoch) return;\n if (data2.type === "caisual:overlay-response" && typeof data2.requestId === "string") {\n const item = pending.get(data2.requestId);\n if (!item) return;\n if (data2.ok !== true && (data2.ok !== false || typeof record(data2.error)?.code !== "string" || typeof record(data2.error)?.message !== "string")) return;\n pending.delete(data2.requestId);\n input.finestra.clearTimeout(item.timer);\n const response = data2;\n if (response.ok) item.resolve();\n else item.reject(creaErrore(response.error.code, response.error.message));\n } else if (data2.type === "caisual:overlay-state" && Number.isSafeInteger(data2.seq) && data2.seq > sequence) {\n if (!validOverlaySessionState(data2.state) || typeof data2.serverTime !== "number" || !Number.isFinite(data2.serverTime)) return;\n clockOffset = data2.serverTime - Date.now();\n sequence = data2.seq;\n state = structuredClone(data2.state);\n notify(states, state);\n } else if (data2.type === "caisual:overlay-error" && data2.sessionId === state?.id) {\n const error = record(data2.error);\n if (typeof error?.code === "string" && typeof error.message === "string") notify(errors, { sessionId: data2.sessionId, error: { code: error.code, message: error.message } });\n } else if (data2.type === "caisual:overlay-shortcut") {\n notify(shortcuts, void 0);\n } else if (data2.type === "caisual:overlay-open" && OVERLAY_PANELS.includes(data2.panel)) {\n notify(opens, data2.panel);\n } else if (data2.type === "caisual:overlay-score") {\n const score = record(data2.score);\n if (score && typeof score.board === "string" && typeof score.player === "string" && Number.isSafeInteger(score.score) && Number.isFinite(score.submittedAt) && (score.day === null || typeof score.day === "string")) {\n notify(scores, { board: score.board, player: score.player, score: score.score, day: score.day, submittedAt: score.submittedAt });\n }\n }\n };\n currentPort.start();\n input.frame.contentWindow?.postMessage({\n type: "caisual:hello",\n ticket: input.ticket,\n live: input.origineLive,\n invite: input.invite,\n ...input.configuration && data.overlayVersion === 1 ? { overlay: { v: 1, epoch, configuration: input.configuration } } : {}\n }, input.origineGioco, [channel.port2]);\n };\n input.finestra.addEventListener("message", listen);\n input.frame.addEventListener?.("load", loaded);\n poll();\n return {\n get epoch() {\n return epoch;\n },\n serverTime() {\n return clockOffset === null ? null : Date.now() + clockOffset;\n },\n get state() {\n return state === null ? null : structuredClone(state);\n },\n subscribe(listener) {\n states.add(listener);\n listener(state);\n return () => {\n states.delete(listener);\n };\n },\n onShortcut(listener) {\n shortcuts.add(listener);\n return () => {\n shortcuts.delete(listener);\n };\n },\n onOpen(listener) {\n opens.add(listener);\n return () => {\n opens.delete(listener);\n };\n },\n onError(listener) {\n errors.add(listener);\n return () => {\n errors.delete(listener);\n };\n },\n onScore(listener) {\n scores.add(listener);\n return () => {\n scores.delete(listener);\n };\n },\n request(op, args) {\n if (!port || !epoch || disposed) return Promise.reject(creaErrore("offline", "The game bridge is not connected."));\n if (pending.size >= 32) return Promise.reject(creaErrore("rate_limited", "Too many overlay requests."));\n const id = String(++requestId), request = {\n type: "caisual:overlay",\n v: 1,\n epoch,\n requestId: id,\n op,\n args,\n ...["room.ready", "room.role", "room.requestRole", "room.team", "room.start", "session.leave", "session.disconnect", "voice.join", "voice.mute", "voice.leave", "voice.setVolume"].includes(op) ? { sessionId: state?.id ?? null } : {}\n };\n if (!validOverlayRequest(request)) return Promise.reject(creaErrore("invalid_request", "The overlay request is invalid."));\n return new Promise((resolve, reject) => {\n const timeout = op === "room.match" ? 31e4 : input.requestTimeoutMs ?? 15e3;\n const timer = input.finestra.setTimeout(() => {\n pending.delete(id);\n reject(creaErrore("timeout", "The overlay request timed out."));\n }, timeout);\n pending.set(id, { resolve, reject, timer });\n try {\n port.postMessage(request);\n } catch (error) {\n input.finestra.clearTimeout(timer);\n pending.delete(id);\n reject(error);\n }\n });\n },\n dispose() {\n disposed = true;\n stopPolling();\n rejectPending();\n port?.close();\n port = null;\n input.finestra.removeEventListener("message", listen);\n input.frame.removeEventListener?.("load", loaded);\n states.clear();\n opens.clear();\n shortcuts.clear();\n scores.clear();\n errors.clear();\n }\n };\n}\nfunction avviaHandshake(input) {\n const bridge = creaPonteOspite(input);\n return () => bridge.dispose();\n}\n\n// src/overlay/boards.ts\nfunction createBoardController(input) {\n let disposed = false, generation = 0, timer;\n const seen = /* @__PURE__ */ new Set();\n let query = null, data = null, error = false, loading = false;\n let queued = null, saving = null, reads = 0;\n const later = input.later ?? setTimeout, clear = input.clear ?? clearTimeout;\n const cancel = () => {\n if (timer !== void 0) clear(timer);\n timer = void 0;\n };\n const notify = () => {\n if (!disposed) input.changed();\n };\n const matches = () => queued && query?.board === queued.board && query.period === (queued.day ? "daily" : "all-time") && (query.day ?? queued.day) === queued.day;\n const refresh = async () => {\n if (!query || disposed) return;\n cancel();\n const current = ++generation, selected = { ...query };\n loading = true;\n error = false;\n notify();\n try {\n const result = await input.read(selected);\n if (disposed || current !== generation) return;\n data = result;\n if (matches()) {\n const own = result.me;\n if (own?.verified && own.score >= queued.score) saving = own.score === queued.score ? "saved" : "bestAlready";\n }\n } catch {\n if (!disposed && current === generation) error = true;\n }\n if (disposed || current !== generation) return;\n loading = false;\n if (matches() && saving !== "saved" && saving !== "bestAlready") {\n reads++;\n if (reads < 4) {\n saving = "saving";\n timer = later(() => {\n void refresh();\n }, [800, 1600, 3200][reads - 1]);\n } else saving = "refreshHint";\n }\n notify();\n };\n return {\n get state() {\n return { query, data, loading, error, saving: matches() ? saving : null };\n },\n select(next) {\n if (JSON.stringify(next) === JSON.stringify(query)) return;\n cancel();\n generation++;\n query = { ...next };\n data = null;\n reads = 0;\n if (matches()) saving = "saving";\n void refresh();\n },\n queued(score) {\n const board = input.manifest.boards[score.board];\n if (score.player !== input.player || !board || !Number.isSafeInteger(score.score) || score.score < 0 || score.day !== null && !validBoardDay(score.day) || !(board.periods ?? ["all-time"]).includes(score.day ? "daily" : "all-time")) return;\n const signature = JSON.stringify(score);\n if (seen.has(signature)) return;\n seen.add(signature);\n if (seen.size > 64) seen.delete(seen.values().next().value);\n queued = score;\n saving = "saving";\n reads = 0;\n this.select({ board: score.board, period: score.day ? "daily" : "all-time", guests: query?.guests ?? input.guests ?? false, ...score.day ? { day: score.day } : {} });\n if (!loading) void refresh();\n notify();\n },\n refresh,\n reset() {\n seen.clear();\n cancel();\n generation++;\n query = null;\n data = null;\n queued = null;\n saving = null;\n loading = false;\n error = false;\n },\n dispose() {\n disposed = true;\n cancel();\n generation++;\n }\n };\n}\n\n// src/overlay/i18n.ts\nvar languages = ["en", "it", "es", "fr", "de", "pt"];\nvar words = {\n loading: ["Loading game...", "Caricamento...", "Cargando...", "Chargement...", "Spiel wird geladen...", "Carregando..."],\n home: ["Play", "Gioca", "Jugar", "Jouer", "Spielen", "Jogar"],\n mode: ["Mode", "Modalit\\xE0", "Modo", "Mode", "Modus", "Modo"],\n play: ["Play", "Gioca", "Jugar", "Jouer", "Spielen", "Jogar"],\n friendsPlay: ["Play with friends", "Gioca con amici", "Jugar con amigos", "Jouer entre amis", "Mit Freunden spielen", "Jogar com amigos"],\n find: ["Find players", "Trova giocatori", "Buscar jugadores", "Trouver des joueurs", "Spieler finden", "Buscar jogadores"],\n join: ["Join with code", "Entra con codice", "Entrar con c\\xF3digo", "Rejoindre avec un code", "Mit Code beitreten", "Entrar com c\\xF3digo"],\n joinInvite: ["Join this room", "Entra in questa stanza", "Entrar en est\\xE1 sala", "Rejoindre cette salle", "Diesem Raum beitreten", "Entrar nest\\xE1 sala"],\n watch: ["Watch a room", "Guarda una stanza", "Observar una sala", "Regarder une salle", "Raum ansehen", "Assistir a uma sala"],\n resume: ["Resume", "Riprendi", "Continuar", "Reprendre", "Fortsetzen", "Continuar"],\n room: ["Room", "Stanza", "Sala", "Salle", "Raum", "Sala"],\n code: ["Room code", "Codice stanza", "C\\xF3digo de sala", "Code de salle", "Raumcode", "C\\xF3digo da sala"],\n copy: ["Copy invite", "Copia invito", "Copiar invitaci\\xF3n", "Copier le lien", "Einladung kopieren", "Copiar convite"],\n copied: ["Invite copied", "Invito copiato", "Invitacion copiada", "Lien copi\\xE9", "Einladung kopiert", "Convite copiado"],\n copyFailed: ["Copy this link:", "Copia questo link:", "Copia este enlace:", "Copiez ce lien :", "Diesen Link kopieren:", "Copie este link:"],\n joining: ["Joining room...", "Ingresso nella stanza...", "Entrando en la sala...", "Connexion \\xE0 la salle...", "Raum wird betreten...", "Entrando na sala..."],\n matching: ["Finding your people...", "Ricerca giocatori...", "Buscando jugadores...", "Recherche de joueurs...", "Spieler werden gesucht...", "Buscando jogadores..."],\n queue: ["{n} / {max} players", "{n} / {max} giocatori", "{n} / {max} jugadores", "{n} / {max} joueurs", "{n} / {max} Spieler", "{n} / {max} jogadores"],\n cancel: ["Cancel", "Annulla", "Cancelar", "Annuler", "Abbrechen", "Cancelar"],\n close: ["Close", "Chiudi", "Cerrar", "Fermer", "Schlie\\xDFen", "Fechar"],\n back: ["Back", "Indietro", "Volver", "Retour", "Zur\\xFCck", "Voltar"],\n ready: ["Ready", "Pronto", "Listo", "Pr\\xEAt", "Bereit", "Pronto"],\n unready: ["Not ready", "Non pronto", "No listo", "Pas pr\\xEAt", "Nicht bereit", "N\\xE3o pronto"],\n start: ["Start", "Inizia", "Empezar", "Commencer", "Starten", "Come\\xE7ar"],\n role: ["Role", "Ruolo", "Rol", "R\\xF4le", "Rolle", "Fun\\xE7\\xE3o"],\n team: ["Team", "Squadra", "Equipo", "\\xC9quipe", "Team", "Equipe"],\n host: ["Host", "Host", "Anfitrion", "H\\xF4te", "Host", "Anfitri\\xE3o"],\n you: ["You", "Tu", "T\\xFA", "Vous", "Du", "Voc\\xEA"],\n away: ["Away", "Assente", "Ausente", "Absent", "Abwesend", "Ausente"],\n needPlayers: ["Waiting for more players", "In attesa di giocatori", "Esperando m\\xE1s jugadores", "En attente de joueurs", "Weitere Spieler fehlen", "Esperando mais jogadores"],\n needReady: ["Everyone needs to be ready", "Tutti devono essere pronti", "Todos deben estar listos", "Tout le monde doit \\xEAtre pr\\xEAt", "Alle m\\xFCssen bereit sein", "Todos precisam estar prontos"],\n needRoles: ["Fill the required roles", "Completa i ruoli richiesti", "Completa los roles", "Compl\\xE9tez les r\\xF4les", "Ben\\xF6tigte Rollen besetzen", "Complete as fun\\xE7\\xF5es"],\n needTeams: ["Choose the required teams", "Scegli le squadre richieste", "Elige los equipos", "Choisissez les \\xE9quipes", "Teams auswahlen", "Escolha as equipes"],\n waitHost: ["Waiting for the host", "In attesa dell\'host", "Esperando al anfitrion", "En attente de l\\u2019h\\xF4te", "Warten auf den Host", "Esperando o anfitri\\xE3o"],\n starting: ["Starting in", "Si inizia tra", "Empieza en", "D\\xE9but dans", "Start in", "Come\\xE7a em"],\n playing: ["Playing", "In partita", "Jugando", "En jeu", "Im Spiel", "Jogando"],\n ended: ["Game finished", "Partita conclusa", "Partida terminada", "Partie termin\\xE9e", "Spiel beendet", "Partida encerrada"],\n again: ["Play again", "Gioca ancora", "Jugar de nuevo", "Rejouer", "Erneut spielen", "Jogar novamente"],\n newRoom: ["New room. Share the new invite.", "Nuova stanza. Condividi il nuovo invito.", "Nueva sala. Comparte la invitaci\\xF3n.", "Nouvelle salle. Partagez le lien.", "Neuer Raum. Neue Einladung teilen.", "Nova sala. Compartilhe o convite."],\n watching: ["Watching", "In osservazione", "Observando", "Spectateur", "Zuschauen", "Assistindo"],\n delay: ["{n}s delay", "Ritardo {n}s", "Retraso de {n}s", "Retard de {n}s", "{n}s Verz\\xF6gerung", "Atraso de {n}s"],\n exit: ["Exit", "Esci", "Salir", "Quitter", "Verlassen", "Sair"],\n leaveNow: ["Leave for now", "Esci per ora", "Salir por ahora", "Quitter pour le moment", "Vor\\xFCbergehend verlassen", "Sair por enquanto"],\n leaveRoom: ["Leave room", "Lascia la stanza", "Abandonar sala", "Abandonner la salle", "Raum verlassen", "Deixar a sala"],\n leaveHint: ["Your room stays available for Resume.", "La stanza resta disponibile con Riprendi.", "Podr\\xE1s volver a est\\xE1 sala.", "Vous pourrez reprendre cette salle.", "Du kannst den Raum fortsetzen.", "Voc\\xEA pode voltar a est\\xE1 sala."],\n temporaryHint: ["The game continues. Rejoining may only be possible briefly.", "La partita continua. Il rientro pu\\xF2 essere disponibile solo per poco.", "La partida continua. Volver puede ser posible solo por poco tiempo.", "La partie continue. Le retour peut \\xEAtre limit\\xE9.", "Das Spiel l\\xE4uft weiter. R\\xFCckkehr nur kurz m\\xF6glich.", "A partida continua. O retorno pode ser limitado."],\n abandonHint: ["Leave room gives up your place.", "Lascia la stanza libera il tuo posto.", "Abandonar libera tu plaza.", "Abandonner lib\\xE8re votre place.", "Raum verlassen gibt deinen Platz frei.", "Deixar a sala libera sua vaga."],\n reconnecting: ["Reconnecting...", "Riconnessione...", "Reconectando...", "Reconnexion...", "Verbindung wird erneuert...", "Reconectando..."],\n replaced: ["Opened in another tab", "Aperta in un\\u2019altra scheda", "Abierta en otra pest\\xE1na", "Ouverte dans un autre onglet", "In anderem Tab ge\\xF6ffnet", "Aberta em outra aba"],\n error: ["Something went wrong. Try again.", "Qualcosa non va. Riprova.", "Algo sali\\xF3 mal. Reintenta.", "Une erreur est survenue. R\\xE9essayez.", "Etwas ist schiefgelaufen. Erneut versuchen.", "Algo deu errado. Tente novamente."],\n noRoom: ["This room is no longer available.", "Questa stanza non \\xE8 pi\\xF9 disponibile.", "Esta sala ya no est\\xE1 disponible.", "Cette salle n\'est plus disponible.", "Dieser Raum ist nicht mehr verf\\xFCgbar.", "Esta sala n\\xE3o est\\xE1 mais disponivel."],\n full: ["This room is full.", "La stanza \\xE8 piena.", "La sala est\\xE1 llena.", "Cette salle est pleine.", "Dieser Raum ist voll.", "Esta sala est\\xE1 cheia."],\n noMatch: ["No match this time. Try again.", "Nessun gruppo trovato. Riprova.", "No hay grupo. Reintenta.", "Aucun groupe trouv\\xE9. R\\xE9essayez.", "Keine Gruppe gefunden. Erneut versuchen.", "Nenhum grupo encontrado. Tente novamente."],\n invalidCode: ["Enter a six-character room code.", "Inserisci un codice di sei caratteri.", "Escribe un c\\xF3digo de seis caracteres.", "Entrez un code de six caracteres.", "Sechsstelligen Raumcode eingeben.", "Digite um c\\xF3digo de seis caracteres."],\n refused: ["The room did not accept that change.", "La stanza ha rifiutato la modifica.", "La sala rechaz\\xF3 el cambio.", "La salle a refus\\xE9 ce changement.", "Der Raum hat die \\xC4nderung abgelehnt.", "A sala recusou a altera\\xE7\\xE3o."],\n unavailable: ["Unavailable right now", "Non disponibile ora", "No disponible ahora", "Indisponible pour le moment", "Derzeit nicht verf\\xFCgbar", "Indisponivel agora"],\n offline: ["Connection unavailable. Try again.", "Connessione non disponibile. Riprova.", "Sin conexi\\xF3n. Reintenta.", "Connexion indisponible. R\\xE9essayez.", "Keine Verbindung. Erneut versuchen.", "Sem conex\\xE3o. Tente novamente."],\n saveFailed: ["Keep the room code. Resume could not be saved.", "Conserva il codice. Riprendi non \\xE8 stato salvato.", "Guarda el c\\xF3digo. No se pudo guardar el regreso.", "Gardez le code. La reprise ne peut pas \\xEAtre enregistr\\xE9e.", "Raumcode aufbewahren. Fortsetzen nicht gespeichert.", "Guarde o c\\xF3digo. O retorno n\\xE3o foi salvo."],\n boards: ["Leaderboard", "Classifica", "Clasificaci\\xF3n", "Classement", "Bestenliste", "Classifica\\xE7\\xE3o"],\n board: ["Board", "Classifica", "Tabla", "Classement", "Bestenliste", "Tabela"],\n daily: ["Daily", "Giornaliera", "Diaria", "Du jour", "T\\xE4glich", "Di\\xE1ria"],\n allTime: ["All time", "Di sempre", "Hist\\xF3rica", "Tous les temps", "Gesamt", "Geral"],\n accounts: ["Accounts", "Account", "Cuentas", "Comptes", "Konten", "Contas"],\n guests: ["Guests", "Ospiti", "Invitados", "Invit\\xE9s", "G\\xE4ste", "Visitantes"],\n category: ["Category", "Categoria", "Categoria", "Cat\\xE9gorie", "Kategorie", "Categoria"],\n period: ["Period", "Periodo", "Per\\xEDodo", "P\\xE9riode", "Zeitraum", "Per\\xEDodo"],\n rank: ["Rank", "Posizione", "Puesto", "Rang", "Platz", "Posicao"],\n score: ["Score", "Punteggio", "Puntos", "Score", "Punkte", "Pontos"],\n verified: ["Verified", "Verificato", "Verificado", "V\\xE9rifi\\xE9", "Verifiziert", "Verificado"],\n own: ["Your best", "Il tuo record", "Tu record", "Votre record", "Dein Rekord", "Seu recorde"],\n empty: ["No scores yet", "Nessun punteggio", "A\\xFAn no hay puntos", "Aucun score", "Noch keine Punkte", "Ainda sem pontos"],\n saving: ["Saving score...", "Salvataggio punteggio...", "Guardando puntos...", "Enregistrement du score...", "Punkte werden gespeichert...", "Salvando pontos..."],\n saved: ["Your best is on the board", "Il tuo record \\xE8 in classifica", "Tu record est\\xE1 en la tabla", "Votre record est au classement", "Dein Rekord ist eingetragen", "Seu recorde est\\xE1 na tabela"],\n bestAlready: ["Your best is already on the board", "Il tuo record era gi\\xE0 in classifica", "Tu record ya estaba en la tabla", "Votre record est d\\xE9j\\xE0 au classement", "Dein Rekord ist bereits eingetragen", "Seu recorde j\\xE1 est\\xE1 na tabela"],\n refresh: ["Refresh", "Aggiorna", "Actualizar", "Actualiser", "Aktualisieren", "Atualizar"],\n refreshHint: ["Score not visible yet. Refresh to check.", "Punteggio non ancora visibile. Aggiorna per controllare.", "Puntos a\\xFAn no visibles. Actualiza.", "Score pas encore visible. Actualisez.", "Punkte noch nicht sichtbar. Aktualisieren.", "Pontos ainda n\\xE3o visiveis. Atualize."],\n friends: ["Friends & party", "Amici e gruppo", "Amigos y grupo", "Amis et groupe", "Freunde & Gruppe", "Amigos e grupo"],\n localCrew: ["Friends and party are unavailable in local preview.", "Amici e gruppo non disponibili in anteprima locale.", "Amigos y grupo no disponibles en la vista local.", "Amis et groupe indisponibles en aper\\xE7u local.", "Freunde und Gruppe in lokaler Vorschau nicht verf\\xFCgbar.", "Amigos e grupo indispon\\xEDveis na pr\\xE9via local."],\n loginCrew: ["Sign in on Caisual to use friends and party.", "Accedi a Caisual per amici e gruppo.", "Inicia sesion para amigos y grupo.", "Connectez-vous pour utiliser amis et groupe.", "F\\xFCr Freunde und Gruppe bei Caisual anmelden.", "Entre no Caisual para amigos e grupo."],\n online: ["Online", "Online", "En linea", "En ligne", "Online", "Online"],\n noFriends: ["No friends online", "Nessun amico online", "Sin amigos en linea", "Aucun ami en ligne", "Keine Freunde online", "Nenhum amigo online"],\n createParty: ["Create party", "Crea gruppo", "Crear grupo", "Cr\\xE9er un groupe", "Gruppe erstellen", "Criar grupo"],\n inviteParty: ["Invite to party", "Invita nel gruppo", "Invitar al grupo", "Inviter au groupe", "In Gruppe einladen", "Convidar para o grupo"],\n leaveParty: ["Leave party", "Lascia gruppo", "Salir del grupo", "Quitter le groupe", "Gruppe verlassen", "Sair do grupo"],\n accept: ["Accept", "Accetta", "Aceptar", "Accepter", "Annehmen", "Aceitar"],\n decline: ["Decline", "Rifiuta", "Rechazar", "Refuser", "Ablehnen", "Recusar"],\n follow: ["Join them", "Raggiungi", "Unirse", "Rejoindre", "Beitreten", "Juntar-se"],\n voice: ["Voice", "Voce", "Voz", "Voix", "Sprache", "Voz"],\n voiceJoin: ["Join voice", "Entra in voce", "Unirse a voz", "Activer la voix", "Sprachchat beitreten", "Entrar na voz"],\n voiceLeave: ["Leave voice", "Esci dalla voce", "Salir de voz", "Quitter la voix", "Sprachchat verlassen", "Sair da voz"],\n voiceMute: ["Mute", "Disattiva microfono", "Silenciar", "Couper le micro", "Stummschalten", "Silenciar"],\n voiceUnmute: ["Unmute", "Attiva microfono", "Activar micr\\xF3fono", "Activer le micro", "Mikrofon aktivieren", "Ativar microfone"],\n voiceOff: ["Voice off", "Voce disattivata", "Voz desactivada", "Voix d\\xE9sactiv\\xE9e", "Sprachchat aus", "Voz desativada"],\n voiceJoining: ["Joining voice...", "Connessione voce...", "Conectando voz...", "Connexion vocale...", "Sprachchat verbindet...", "Conectando voz..."],\n voiceOn: ["Voice connected", "Voce connessa", "Voz conectada", "Voix connect\\xE9e", "Sprachchat verbunden", "Voz conectada"],\n voiceMuted: ["Muted", "Microfono disattivato", "Silenciado", "Micro coup\\xE9", "Stumm", "Silenciado"],\n voiceMic: ["Mic on", "Microfono attivo", "Micr\\xF3fono activo", "Micro actif", "Mikrofon an", "Microfone ativo"],\n voiceListening: ["Listening only", "Solo ascolto", "Solo escucha", "\\xC9coute seule", "Nur zuh\\xF6ren", "Somente ouvindo"],\n voiceSpeaking: ["Speaking", "Sta parlando", "Hablando", "Parle", "Spricht", "Falando"],\n voicePeers: ["Voice participants", "Partecipanti in voce", "Participantes de voz", "Participants vocaux", "Sprachteilnehmer", "Participantes de voz"],\n voiceEmpty: ["No one else in voice yet.", "Nessun altro in voce per ora.", "A\\xFAn no hay nadie m\\xE1s en voz.", "Personne d\\u2019autre en voix pour le moment.", "Noch niemand im Sprachchat.", "Ningu\\xE9m mais na voz ainda."],\n voiceVolume: ["Volume for {name}", "Volume di {name}", "Volumen de {name}", "Volume de {name}", "Lautst\\xE4rke f\\xFCr {name}", "Volume de {name}"],\n voiceUnavailable: ["Join a room with voice to use these controls.", "Entra in una stanza con voce per usare questi controlli.", "Entra en una sala con voz para usar estos controles.", "Rejoignez une salle vocale pour utiliser ces commandes.", "Diese Steuerung braucht einen Raum mit Sprachchat.", "Entre em uma sala com voz para usar estes controles."],\n voiceWatch: ["Voice is unavailable while watching.", "La voce non e\' disponibile in osservazione.", "La voz no est\\xE1 disponible al observar.", "La voix est indisponible en observation.", "Beim Zuschauen ist kein Sprachchat verf\\xFCgbar.", "A voz n\\xE3o est\\xE1 dispon\\xEDvel ao assistir."],\n voiceDenied: ["Microphone permission denied. Allow it in your browser, then try again.", "Permesso microfono negato. Consenti l\'accesso nel browser e riprova.", "Permiso de micr\\xF3fono denegado. Act\\xEDvalo en el navegador e int\\xE9ntalo de nuevo.", "Acc\\xE8s au micro refus\\xE9. Autorisez-le dans le navigateur, puis r\\xE9essayez.", "Mikrofonzugriff verweigert. Im Browser erlauben und erneut versuchen.", "Permiss\\xE3o do microfone negada. Permita no navegador e tente novamente."],\n voiceUnsupported: ["Voice is not supported in this browser.", "Questo browser non supporta la voce.", "Este navegador no admite voz.", "Ce navigateur ne prend pas en charge la voix.", "Dieser Browser unterst\\xFCtzt keinen Sprachchat.", "Este navegador n\\xE3o oferece suporte a voz."],\n voiceFailed: ["Voice could not connect. Try again.", "Connessione voce non riuscita. Riprova.", "No se pudo conectar la voz. Int\\xE9ntalo de nuevo.", "Connexion vocale impossible. R\\xE9essayez.", "Sprachverbindung fehlgeschlagen. Erneut versuchen.", "N\\xE3o foi poss\\xEDvel conectar a voz. Tente novamente."],\n voicePeerGone: ["This participant has left voice.", "Questo partecipante e\' uscito dalla voce.", "Este participante sali\\xF3 de voz.", "Ce participant a quitt\\xE9 la voix.", "Diese Person hat den Sprachchat verlassen.", "Este participante saiu da voz."],\n shortcut: ["Shift+Tab shortcut", "Scorciatoia Shift+Tab", "Atajo Shift+Tab", "Raccourci Maj+Tab", "Umschalt+Tab-Kurzbefehl", "Atalho Shift+Tab"],\n menu: ["Caisual menu", "Menu Caisual", "Menu Caisual", "Menu Caisual", "Caisual-Menu", "Menu Caisual"],\n retry: ["Retry", "Riprova", "Reintentar", "R\\xE9essayer", "Erneut versuchen", "Tentar novamente"]\n};\nvar column = (index) => Object.fromEntries(Object.entries(words).map(([key, row]) => [key, row[index]]));\nvar dictionaries = { en: column(0), it: column(1), es: column(2), fr: column(3), de: column(4), pt: column(5) };\nfunction overlayLanguage(raw) {\n const value = raw?.toLowerCase().split("-")[0];\n return languages.includes(value) ? value : "en";\n}\nfunction translator(language) {\n const dictionary = dictionaries[overlayLanguage(language)];\n return (key, values = {}) => dictionary[key].replace(/\\{(\\w+)\\}/g, (_all, name) => String(values[name] ?? ""));\n}\nfunction errorText(code) {\n if (code === "permission_denied") return "voiceDenied";\n if (code === "unsupported") return "voiceUnsupported";\n if (code === "voice_disabled") return "voiceUnavailable";\n if (code === "voice_error") return "voiceFailed";\n if (code === "voice_peer_missing") return "voicePeerGone";\n if (code === "not_publishing") return "voiceListening";\n if (["room_not_found", "room_ended", "version_closed", "no_resume"].includes(code)) return "noRoom";\n if (["room_full", "role_full"].includes(code)) return "full";\n if (code === "replaced") return "replaced";\n if (code === "no_match") return "noMatch";\n if (code === "invalid_code") return "invalidCode";\n if (["offline", "timeout"].includes(code)) return "offline";\n if (code.startsWith("role_") || ["not_in_lobby", "not_host", "session_replaced"].includes(code)) return "refused";\n if (code === "save_failed") return "saveFailed";\n return "error";\n}\n\n// src/overlay/ui-model.ts\nfunction phase(session) {\n if (!session || session.kind === "boot") return "boot";\n if (session.kind === "attaching" || session.kind === "matching") return session.kind;\n if (session.room && ["closed", "replaced"].includes(session.room.connection)) return "error";\n if (session.kind === "local") return session.localStatus === "ended" ? "ended" : "playing";\n if (session.room?.status === "ended") return "ended";\n if (session.kind === "watch") return "watching";\n if (session.kind === "room" && session.room) return session.room.status;\n return "home";\n}\nfunction initialUi(manifest) {\n return { session: null, panel: "auto", mode: manifest.modes[0]?.id ?? "", busy: false, error: null, notice: null, shortcutEnabled: true };\n}\nfunction reduceUi(model, action) {\n switch (action.type) {\n case "session": {\n const next = action.session, changed = next?.id !== model.session?.id || next === null;\n const pending = next?.kind === "attaching" || next?.kind === "matching";\n const nextPhase = phase(next), transition = phase(model.session) !== nextPhase;\n const automatic = transition && ["countdown", "playing", "ended"].includes(nextPhase) && [null, "auto", "room", "invite", "home"].includes(model.panel);\n return {\n ...model,\n session: next,\n mode: next?.mode ?? model.mode,\n panel: changed || pending || automatic ? "auto" : model.panel,\n error: changed ? null : model.error,\n notice: changed ? null : model.notice\n };\n }\n case "panel":\n return { ...model, panel: action.panel, error: null, notice: null };\n case "mode":\n return { ...model, mode: action.mode, error: null };\n case "busy":\n return { ...model, busy: action.busy };\n case "error":\n return { ...model, error: action.code, busy: false };\n case "notice":\n return { ...model, notice: action.notice };\n case "shortcut":\n return { ...model, shortcutEnabled: action.enabled };\n }\n}\nfunction visiblePanel(model) {\n const current = phase(model.session);\n if (current === "boot" || current === "attaching" || current === "matching" || current === "error") return current;\n if (model.panel !== "auto") return model.panel;\n return current === "home" ? "home" : current === "lobby" ? "room" : current === "countdown" ? "countdown" : null;\n}\nfunction primaryAction(manifest, mode) {\n const selected = manifest.modes.find((item) => item.id === mode);\n if (!selected) return null;\n return {\n op: selected.execution === "local" ? "local.start" : "room.create",\n friends: selected.execution === "room" && risolviModalita(manifest, mode).players.max > 1\n };\n}\nfunction startReason(manifest, session) {\n const room = session?.room;\n if (!room || session.kind !== "room" || room.status !== "lobby" || room.connection !== "connected") return "unavailable";\n const connected = room.players.filter((p) => p.connected), active = connected.filter((p) => p.role !== "spectator");\n if (active.length < room.limits.min) return "needPlayers";\n if (connected.some((p) => !p.ready)) return "needReady";\n if (manifest.roles.some((role) => active.filter((p) => p.role === role.id).length < role.min)) return "needRoles";\n if (manifest.teams && (active.some((p) => p.team === null) || new Set(active.map((p) => p.team)).size < manifest.teams.min)) return "needTeams";\n return room.host !== room.you ? "waitHost" : null;\n}\nfunction canPlayAgain(session) {\n if (phase(session) !== "ended") return false;\n if (session?.kind === "local") return true;\n return session?.kind === "room" && (!session.room?.lobby || session.room.host === session.room.you);\n}\nfunction normalizeInvite(code) {\n const value = code.toUpperCase().replace(/[\\s-]/g, "");\n return /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/.test(value) ? value : null;\n}\n\n// src/overlay/styles.ts\nvar styles = `\n:host{all:initial;position:fixed;inset:0;z-index:10000;pointer-events:none;font:15px/1.45 system-ui,sans-serif;color:#f4f4f1;color-scheme:dark;--accent:#a8efc5}\n[data-layout],[data-surface],.sr{pointer-events:none}*{box-sizing:border-box}button,input,select{font:inherit}button,a,input,select{touch-action:manipulation}button,select,input{border:1px solid #ffffff30;background:#25292b;color:inherit;border-radius:12px;min-height:44px;padding:10px 14px}button{cursor:pointer}button:disabled{opacity:.45;cursor:default}button:hover:not(:disabled){background:#343b3a}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid var(--accent);outline-offset:3px}a{color:var(--accent)}.primary{background:var(--accent);color:var(--accent-ink,#11221b);border-color:transparent;font-weight:700}.primary:hover:not(:disabled){filter:brightness(1.1);background:var(--accent)}.quiet{background:transparent}label{display:grid;gap:6px;text-align:left}select,input{width:100%;min-width:0}h1,h2,p{margin:0}h1{font-size:clamp(26px,5vw,42px);line-height:1.1;letter-spacing:-.035em}h2{font-size:20px}small,.muted{color:#bdc5c1}.stack{display:grid;gap:16px}.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.row>*{flex:0 1 auto}.row .grow,.grow{flex:1}.split{display:grid;grid-template-columns:1fr 1fr;gap:10px}.pill{position:absolute;top:max(10px,env(safe-area-inset-top));right:max(10px,env(safe-area-inset-right));display:flex;height:44px;border:1px solid #ffffff35;border-radius:24px;background:#171e20eF;box-shadow:0 4px 20px #0004;pointer-events:auto;overflow:hidden}.pill button{border:0;border-radius:0;padding:8px 13px;background:transparent}.pill .exit{width:44px;border-left:1px solid #ffffff30}.pill button:focus-visible{outline-offset:-4px}.pill small{margin-left:8px}.backdrop{position:absolute;inset:0;background:#0b151ce8;backdrop-filter:blur(10px);pointer-events:auto;display:grid;place-items:center;padding:max(16px,env(safe-area-inset-top)) max(16px,env(safe-area-inset-right)) max(16px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left));overflow:auto}.backdrop.home{background-color:#142127;background-size:cover;background-position:center}.dialog{position:relative;width:min(100%,540px);max-height:100%;overflow:auto;overscroll-behavior:contain;background:#141b1df5;border:1px solid #ffffff25;border-radius:22px;padding:24px;box-shadow:0 20px 80px #0005}.dialog.wide{width:min(100%,700px)}.top{display:flex;align-items:center;gap:12px;margin-bottom:18px}.top h2{flex:1}.tabs{display:flex;gap:6px;flex-wrap:wrap;border-bottom:1px solid #ffffff25;padding-bottom:12px}.tabs button{min-height:36px;padding:6px 10px}.tabs [aria-current=true]{border-color:var(--accent)}.roster{list-style:none;padding:0;margin:0;display:grid;gap:8px;max-height:32dvh;overflow:auto}.roster li{display:flex;align-items:center;gap:8px;padding:10px;background:#ffffff08;border-radius:10px}.roster .name{flex:1;overflow-wrap:anywhere}.badge{border:1px solid #ffffff30;border-radius:6px;padding:2px 6px;font-size:12px}.code{font-size:24px;letter-spacing:.13em;font-variant-numeric:tabular-nums}.notice,.error{border-radius:10px;padding:10px;background:#a8efc514;overflow-wrap:anywhere}.error{background:#ff8b7720;color:#ffd2c9}.countdown{font-size:88px;line-height:1;text-align:center;font-variant-numeric:tabular-nums}.ended{position:absolute;bottom:max(12px,env(safe-area-inset-bottom));left:50%;transform:translateX(-50%);max-width:calc(100% - 24px);width:max-content;background:#171e20f5;pointer-events:auto;border:1px solid #ffffff30;border-radius:16px;padding:10px;display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap}.ended strong{padding:0 8px}.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.table-wrap{overflow:auto;max-height:38dvh}table{width:100%;border-collapse:collapse;text-align:left}th,td{padding:9px 6px;border-bottom:1px solid #ffffff20}td:nth-child(3){text-align:right}td small{display:block}.self{background:#ffffff0a}.checkbox{display:flex;align-items:center;gap:8px;font-size:13px}.checkbox input{width:18px;min-height:18px}.full{width:100%}\n[hidden]{display:none!important}.voice-peers{list-style:none;margin:0;padding:0;display:grid;gap:10px}.voice-peers li{border:1px solid #ffffff25;border-radius:12px;padding:12px;display:grid;gap:8px}.voice-peers [data-speaking=true]{border-color:var(--accent)}.voice-peers input{width:100%;accent-color:var(--accent);padding:0}.voice-peers label{font-size:13px}.pill .voice-toggle{width:44px;padding:8px}.voice-toggle[data-voice-state=on][data-muted=false]{color:var(--accent)}\n@media(max-width:480px){.dialog{padding:18px;border-radius:18px}.split{grid-template-columns:1fr 1fr;gap:8px}.tabs{gap:4px}.tabs button{font-size:13px;padding:6px 8px}.pill button:focus-visible{outline-offset:-4px}.pill small{display:none}.ended{gap:6px}.ended strong{font-size:13px}.ended button{padding:8px 10px;font-size:13px}.roster{max-height:28dvh}}\n@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto}}\n`;\n\n// src/overlay/voice-panel.ts\nfunction voiceEligible(manifest, session) {\n return manifest.voice !== "none" && session?.kind !== "watch" && session?.room?.players.find((player) => player.id === session.room?.you)?.role !== "spectator";\n}\nfunction voiceStatus(voice, t) {\n const key = voice.state === "joining" ? "voiceJoining" : voice.state === "reconnecting" ? "reconnecting" : voice.state === "off" ? "voiceOff" : "voiceOn";\n return t(key);\n}\nfunction updateVoicePanel(container, input) {\n const { session, t } = input, voice = session?.kind === "room" ? session.voice : null;\n if (!voiceEligible(input.manifest, session) || !voice) {\n container.replaceChildren();\n const note = container.ownerDocument.createElement("p");\n note.textContent = t(session?.kind === "watch" || session?.room?.players.find((p) => p.id === session.room?.you)?.role === "spectator" ? "voiceWatch" : "voiceUnavailable");\n container.append(note);\n return;\n }\n if (!container.querySelector("[data-voice-status]")) container.innerHTML = `<p role="status" aria-live="polite" data-voice-status></p><p data-voice-self></p>\n <div class="row"><button type="button" data-action="voice-join"></button><button type="button" data-action="voice-mute"></button><button type="button" data-action="voice-leave"></button></div>\n <p class="error" role="alert" data-voice-error hidden></p><h3 data-voice-heading></h3><ul class="voice-peers" data-voice-peers></ul><p class="muted" data-voice-empty></p>`;\n const get = (selector) => container.querySelector(selector);\n const status = get("[data-voice-status]");\n status.textContent = voiceStatus(voice, t);\n status.dataset.voiceState = voice.state;\n const mic = (value) => t(!value.mic ? "voiceListening" : value.muted ? "voiceMuted" : value.speaking ? "voiceSpeaking" : "voiceMic");\n get("[data-voice-self]").textContent = voice.state === "off" ? "" : `${t("you")}: ${mic(voice)}`;\n const join = get(\'[data-action="voice-join"]\'), mute = get(\'[data-action="voice-mute"]\'), leave = get(\'[data-action="voice-leave"]\');\n join.textContent = t("voiceJoin");\n join.hidden = voice.state !== "off";\n join.disabled = session?.room?.connection !== "connected" || input.pending === "voice.join";\n mute.textContent = t(voice.muted ? "voiceUnmute" : "voiceMute");\n mute.hidden = voice.state !== "on" || !voice.mic;\n mute.disabled = input.pending === "voice.mute";\n mute.setAttribute("aria-pressed", String(voice.muted));\n leave.textContent = t("voiceLeave");\n leave.hidden = voice.state === "off" && input.pending !== "voice.join";\n leave.disabled = input.pending === "voice.leave";\n const error = get("[data-voice-error]");\n error.hidden = !input.error;\n error.textContent = input.error ? t(errorText(input.error)) : "";\n get("[data-voice-heading]").textContent = t("voicePeers");\n get("[data-voice-empty]").textContent = t("voiceEmpty");\n get("[data-voice-empty]").hidden = voice.peers.length > 0;\n const list = get("[data-voice-peers]"), ids = new Set(voice.peers.map((peer) => peer.id));\n for (const row of list.querySelectorAll("[data-voice-peer]")) if (!ids.has(row.dataset.voicePeer)) row.remove();\n for (const peer of voice.peers) {\n let row = [...list.children].find((node) => node.dataset.voicePeer === peer.id);\n if (!row) {\n row = container.ownerDocument.createElement("li");\n row.dataset.voicePeer = peer.id;\n row.innerHTML = \'<div class="row"><strong data-peer-name></strong><small data-peer-status></small></div><label><span data-volume-label></span><input type="range" min="0" max="1" step="0.05" data-control="voice-volume"></label>\';\n row.querySelector("input").dataset.peer = peer.id;\n list.append(row);\n }\n const name = session?.room?.players.find((player) => player.id === peer.id)?.name ?? peer.id;\n row.dataset.mic = String(peer.mic);\n row.dataset.muted = String(peer.muted);\n row.dataset.speaking = String(peer.speaking);\n row.querySelector("[data-peer-name]").textContent = name;\n row.querySelector("[data-peer-status]").textContent = mic(peer);\n row.querySelector("[data-volume-label]").textContent = t("voiceVolume", { name });\n const range = row.querySelector("input");\n if (range.dataset.editing !== "true") range.value = String(peer.volume);\n range.setAttribute("aria-valuetext", `${Math.round(Number(range.value) * 100)}%`);\n range.disabled = voice.state !== "on";\n }\n}\n\n// src/overlay/ui.ts\nvar escape = (value) => String(value ?? "").replace(/[&<>"\']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", \'"\': "&quot;", "\'": "&#39;" })[c]);\nfunction mountOverlay(input) {\n const manifest = input.configuration.manifest;\n if (manifest.overlay?.version !== 1) return null;\n const document = input.container.ownerDocument, win = document.defaultView, t = translator(input.language);\n const host = document.createElement("div");\n host.dataset.caisualOverlay = "";\n host.lang = overlayLanguage(input.language);\n host.style.setProperty("pointer-events", "none", "important");\n const root = host.attachShadow({ mode: "open" });\n if (typeof win.CSSStyleSheet?.prototype.replaceSync === "function" && "adoptedStyleSheets" in root) {\n const sheet = new win.CSSStyleSheet();\n sheet.replaceSync(styles);\n root.adoptedStyleSheets = [sheet];\n } else {\n const sheet = document.createElement("link");\n sheet.rel = "stylesheet";\n sheet.href = "/__caisual/overlay/v1.css";\n root.append(sheet);\n }\n const elements = document.createElement("div");\n elements.dataset.layout = "";\n elements.style.pointerEvents = "none";\n elements.innerHTML = `<div data-surface></div><div class="sr" role="status" aria-live="polite" data-live></div>`;\n root.append(elements);\n const surface = root.querySelector("[data-surface]"), live = root.querySelector("[data-live]");\n surface.style.pointerEvents = "none";\n live.style.pointerEvents = "none";\n const accent = manifest.overlay.accent ?? "#a8efc5";\n host.style.setProperty("--accent", accent);\n const rgb = [1, 3, 5].map((i) => parseInt(accent.slice(i, i + 2), 16) / 255).map((v) => v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);\n const luminance = rgb[0] * 0.2126 + rgb[1] * 0.7152 + rgb[2] * 0.0722;\n host.style.setProperty("--accent-ink", luminance > 0.179 ? "#000000" : "#ffffff");\n input.container.append(host);\n let model = initialUi(manifest), disposed = false, operation = 0, lastView = "", geometryFrame = 0;\n let lastPhase = "", wasModal = false, copyFallback = null;\n let voiceError = null, voicePending = null, voiceOperation = 0;\n let codeDraft = input.configuration.invite ?? "";\n const oldInert = Boolean(input.frame.inert), oldTabIndex = input.frame.getAttribute("tabindex");\n try {\n model.shortcutEnabled = win.localStorage.getItem("caisual-overlay-shortcut-v1") !== "off";\n } catch {\n }\n const boards = input.boards ? createBoardController({ manifest, player: input.player.id, guests: input.player.guest, read: input.boards, changed: () => render() }) : null;\n const stops = [];\n const selectedMode = () => manifest.modes.find((mode) => mode.id === model.mode);\n const disabled = () => model.busy ? " disabled" : "";\n const button = (action, key, extra = "", off = false) => `<button type="button" data-action="${action}"${extra}${off || model.busy ? " disabled" : ""}>${t(key)}</button>`;\n const dispatch = (action) => {\n if (disposed) return;\n model = reduceUi(model, action);\n render();\n };\n const announce = (text) => {\n if (live.textContent !== text) live.textContent = text;\n };\n const controls = () => [...root.querySelectorAll(\'button:not(:disabled),a[href],input:not(:disabled),select:not(:disabled),[tabindex="0"]\')].filter((el) => !el.closest("[hidden]"));\n const roomCode = () => model.session?.room?.code ?? null;\n const setPanel = (panel) => {\n if (panel === "boards" && boards && !boards.state.query) {\n const id = Object.keys(manifest.boards)[0];\n if (id) boards.select({ board: id, period: (manifest.boards[id].periods ?? ["all-time"])[0], guests: input.player.guest });\n }\n copyFallback = null;\n dispatch({ type: "panel", panel });\n };\n const close = () => {\n const current = phase(model.session), panel = visiblePanel(model);\n if (current === "home") setPanel("home");\n else if (current === "lobby" && panel !== "room") setPanel("room");\n else setPanel(null);\n };\n const toggle = () => {\n if (visiblePanel(model)) close();\n else setPanel(model.session?.room ? "room" : "home");\n };\n async function perform(op, args, after) {\n const token = ++operation;\n dispatch({ type: "error", code: null });\n dispatch({ type: "busy", busy: true });\n try {\n await input.bridge.request(op, args);\n if (token === operation && !disposed) await after?.();\n } catch (error) {\n if (token === operation && !disposed && error.code !== "cancelled") dispatch({ type: "error", code: error.code ?? "offline" });\n } finally {\n if (token === operation && !disposed) dispatch({ type: "busy", busy: false });\n }\n }\n function updateVoice() {\n const container = root.querySelector("[data-voice-panel]");\n if (container) updateVoicePanel(container, { manifest, session: model.session, t, error: voiceError, pending: voicePending });\n const toggle2 = root.querySelector("[data-voice-toggle]"), voice = model.session?.voice;\n if (toggle2) {\n toggle2.dataset.voiceState = voice?.state ?? "off";\n toggle2.dataset.muted = String(voice?.muted ?? false);\n toggle2.setAttribute("aria-label", `${t("voice")}: ${voice ? voiceStatus(voice, t) : t("voiceOff")}`);\n toggle2.textContent = voice?.state === "on" && !voice.muted ? "\\u25CF" : "\\u25CB";\n }\n }\n async function performVoice(op, args) {\n const sessionId = model.session?.id, epoch = input.bridge.epoch, volume = op === "voice.setVolume";\n const token = volume ? voiceOperation : ++voiceOperation;\n const current = () => !disposed && model.session?.id === sessionId && input.bridge.epoch === epoch && token === voiceOperation;\n voiceError = null;\n if (!volume) voicePending = op;\n updateVoice();\n try {\n await input.bridge.request(op, args);\n } catch (error) {\n if (current()) voiceError = error.code ?? "voice_error";\n } finally {\n if (current()) {\n if (!volume) voicePending = null;\n updateVoice();\n }\n }\n }\n async function copyInvite() {\n const code = roomCode();\n if (!code) return;\n const url = input.inviteUrl(code);\n try {\n await win.navigator.clipboard.writeText(url);\n dispatch({ type: "notice", notice: t("copied") });\n } catch {\n copyFallback = url;\n render();\n root.querySelector(\'input[data-control="invite-link"]\')?.select();\n }\n }\n function invitation() {\n const code = roomCode();\n if (!code) return `<p>${t("noRoom")}</p>`;\n return `<div class="row"><div class="grow"><small>${t("code")}</small><div class="code" data-room-code>${escape(code)}</div></div>${button("copy", "copy")}</div>${copyFallback ? `<label>${t("copyFailed")}<input data-control="invite-link" readonly value="${escape(copyFallback)}"></label>` : ""}`;\n }\n function navigation(panel) {\n const items = [];\n if (model.session?.room) items.push(["room", "room"], ["invite", "copy"]);\n items.push(["friends", "friends"]);\n if (Object.keys(manifest.boards).length) items.push(["boards", "boards"]);\n if (voiceEligible(manifest, model.session)) items.push(["voice", "voice"]);\n return `<nav class="tabs" aria-label="Caisual">${items.map(([id, key]) => button(`panel:${id}`, key, ` aria-current="${id === panel}"`)).join("")}</nav>`;\n }\n function home() {\n const selected = selectedMode(), action = primaryAction(manifest, model.mode), session = model.session;\n const hasRooms = manifest.modes.some((mode) => mode.execution === "room");\n return `<h1>${escape(manifest.name)}</h1><label>${t("mode")}<select data-control="mode"${disabled()}>${manifest.modes.map((mode) => `<option value="${escape(mode.id)}"${mode.id === model.mode ? " selected" : ""}>${escape(risolviPresentazione(manifest, mode.id).label)}</option>`).join("")}</select></label>\n ${selected?.instructions ? `<p class="muted">${escape(selected.instructions)}</p>` : ""}\n ${input.configuration.invite && phase(session) === "home" ? button("join-invite", "joinInvite", \' class="primary"\', !session?.ready) : ""}\n ${action ? button("play", action.friends ? "friendsPlay" : "play", \' class="primary"\', !session?.ready) : ""}\n ${selected?.matchmaking ? button("match", "find", "", !selected.matchmaking.defaults || !session?.ready) : ""}\n ${session?.resume ? button("resume", "resume", "", !session.ready) + `<small>${escape(session.resume.code)}</small>` : ""}\n ${hasRooms ? `<div class="split">${button("panel:join", "join", "", !session?.ready)}${manifest.spectators ? button("panel:watch", "watch", "", !session?.ready) : ""}</div>` : ""}\n ${navigation("home")}<label class="checkbox"><input type="checkbox" data-control="shortcut"${model.shortcutEnabled ? " checked" : ""}>${t("shortcut")}</label>`;\n }\n function room() {\n const session = model.session, room2 = session?.room;\n if (!room2) return `<p>${t("noRoom")}</p>`;\n const own = room2.players.find((player) => player.id === room2.you), lobby = room2.status === "lobby" && session?.kind === "room";\n const canRole = session?.kind === "room" && (lobby || room2.status === "playing" && room2.requestRole);\n const reason = startReason(manifest, session);\n return `${invitation()}<ul class="roster" aria-label="${t("room")}">${room2.players.map((p) => `<li data-player-id="${escape(p.id)}"><span class="name">${escape(p.name)} ${p.id === room2.you ? `<small>(${t("you")})</small>` : ""}</span>${p.id === room2.host ? `<span class="badge">${t("host")}</span>` : ""}${p.role ? `<small>${escape(manifest.roles.find((r) => r.id === p.role)?.label ?? p.role)}</small>` : ""}${p.team ? `<small>${t("team")} ${p.team}</small>` : ""}<small>${!p.connected ? t("away") : lobby ? t(p.ready ? "ready" : "unready") : ""}</small></li>`).join("")}</ul>\n ${canRole && manifest.roles.length ? `<label>${t("role")}<select data-control="role"${disabled()}><option value="" disabled${!own?.role ? " selected" : ""}>${t("role")}</option>${manifest.roles.map((role) => `<option value="${escape(role.id)}"${role.id === own?.role ? " selected" : ""}>${escape(role.label ?? role.id)}</option>`).join("")}</select></label>` : ""}\n ${lobby && manifest.teams ? `<label>${t("team")}<select data-control="team"${disabled()}><option value="" disabled${!own?.team ? " selected" : ""}>${t("team")}</option>${Array.from({ length: manifest.teams.max }, (_, i) => `<option value="${i + 1}"${own?.team === i + 1 ? " selected" : ""}>${t("team")} ${i + 1}</option>`).join("")}</select></label>` : ""}\n ${lobby ? `<div class="row">${button("ready", own?.ready ? "unready" : "ready", \' class="primary"\', room2.connection !== "connected")}${room2.host === room2.you ? button("start", "start", "", reason !== null) : ""}</div>${reason ? `<p class="muted" data-start-reason>${t(reason)}</p>` : ""}` : ""}\n ${session?.kind === "watch" ? `<p>${t("watching")} \\xB7 ${t("delay", { n: (room2.delayMs ?? 0) / 1e3 })}</p>` : ""}\n ${navigation("room")}<label class="checkbox"><input type="checkbox" data-control="shortcut"${model.shortcutEnabled ? " checked" : ""}>${t("shortcut")}</label>${button("panel:exit", "exit", \' class="quiet"\')}`;\n }\n function crew() {\n const provider = input.crew, state = provider?.getSnapshot();\n if (!provider || provider.unavailable || !state?.you) return `<p>${t(provider?.unavailable === "local" ? "localCrew" : "loginCrew")}</p>`;\n const online = state.friends.filter((friend) => friend.online), party = state.party;\n const person = (p) => `<li><span class="name">${escape(p.name)}<small>${p.game ? ` \\xB7 ${escape(p.game.name)}` : ""}</small></span>${p.room && p.game ? button("follow", "follow", ` data-code="${escape(p.room.code)}" data-game="${escape(p.game.slug)}"`) : ""}${party?.leader === state.you.id && !party.members.some((member) => member.id === p.id) ? button("party-invite", "inviteParty", ` data-player="${escape(p.id)}"`) : ""}</li>`;\n return `${!state.connected ? `<p>${t("reconnecting")}</p>` : ""}${party ? `<ul class="roster">${party.members.map(person).join("")}</ul>${button("party-leave", "leaveParty")}` : button("party-create", "createParty")}\n ${state.invites.map((invite) => `<div class="row"><span class="grow">${escape(invite.from.name)}</span>${button("party-accept", "accept", ` data-party="${escape(invite.party)}"`)}${button("party-decline", "decline", ` data-party="${escape(invite.party)}"`)}</div>`).join("")}\n ${state.follow ? `<div class="row"><span class="grow">${escape(state.follow.from.name)} \\xB7 ${escape(state.follow.game.name)}</span>${button("follow", "follow", ` data-code="${escape(state.follow.code)}" data-game="${escape(state.follow.game.slug)}"`)}</div>` : ""}\n <h2>${t("online")}</h2>${online.length ? `<ul class="roster">${online.map(person).join("")}</ul>` : `<p class="muted">${t("noFriends")}</p>`}`;\n }\n function leaderboard() {\n if (!boards || !boards.state.query) return `<p>${t("unavailable")}</p>`;\n const { query, data, loading, error, saving } = boards.state;\n const board = manifest.boards[query.board];\n return `<label>${t("board")}<select data-control="board">${Object.entries(manifest.boards).map(([id, value]) => `<option value="${escape(id)}"${query.board === id ? " selected" : ""}>${escape(value.label ?? id)}</option>`).join("")}</select></label>\n <div class="split"><label>${t("period")}<select data-control="period">${(board.periods ?? ["all-time"]).map((period) => `<option value="${period}"${query.period === period ? " selected" : ""}>${t(period === "daily" ? "daily" : "allTime")}</option>`).join("")}</select></label><label>${t("category")}<select data-control="category"><option value="accounts"${!query.guests ? " selected" : ""}>${t("accounts")}</option><option value="guests"${query.guests ? " selected" : ""}>${t("guests")}</option></select></label></div>\n ${query.period === "daily" ? `<small data-board-day>${escape(data?.day ?? query.day ?? new Date(input.bridge.serverTime() ?? Date.now()).toISOString().slice(0, 10))}</small>` : ""}\n ${saving ? `<p role="status" data-saving>${t(saving)}</p>` : ""}${error ? `<p role="alert">${t("offline")}</p>` : ""}\n ${data ? `<div class="table-wrap"><table><thead><tr><th>${t("rank")}</th><th>${t(query.guests ? "guests" : "accounts")}</th><th>${t("score")}</th></tr></thead><tbody>${data.entries.map((entry) => `<tr${entry.me ? \' class="self"\' : ""}><td>${entry.rank}</td><td>${escape(entry.name)}${entry.verified ? `<small>${t("verified")}</small>` : ""}</td><td>${entry.score}</td></tr>`).join("")}</tbody></table>${data.entries.length ? "" : `<p>${t("empty")}</p>`}</div><p data-own-score>${t("own")} (${t(data.ownGuest ? "guests" : "accounts")}): ${data.me ? `#${data.me.rank} \\xB7 ${data.me.score}${data.me.verified ? ` \\xB7 ${t("verified")}` : ""}` : t("empty")}</p>` : `<p>${t(loading ? "loading" : "empty")}</p>`}\n ${button("refresh", "refresh", "", loading)}`;\n }\n function content(panel) {\n switch (panel) {\n case "home":\n return home();\n case "room":\n return room();\n case "invite":\n return invitation();\n case "friends":\n return crew();\n case "voice":\n return \'<div class="stack" data-voice-panel></div>\';\n case "boards":\n return leaderboard();\n case "join":\n case "watch":\n return `<form class="stack" data-form="${panel}"><label>${t("code")}<input data-control="code" name="code" autocomplete="off" autocapitalize="characters" spellcheck="false" maxlength="16" value="${escape(codeDraft)}" required></label><button class="primary" type="submit"${disabled()}>${t(panel === "join" ? "join" : "watch")}</button></form>`;\n case "attaching":\n case "matching":\n return `<p role="status">${t(panel === "matching" ? "matching" : "joining")}</p>${model.session?.waiting ? `<p>${t("queue", { n: model.session.waiting.players, max: model.session.waiting.max })}</p>` : ""}<button type="button" data-action="cancel">${t("cancel")}</button>`;\n case "countdown":\n return `<p>${t("starting")}</p><div class="countdown" data-countdown></div>`;\n case "boot":\n return `<p role="status">${t("loading")}</p>${button("reload", "retry")}${button("exit-now", "exit")}`;\n case "error":\n return `<p role="alert">${t(model.session?.room?.connection === "replaced" ? "replaced" : "noRoom")}</p>${button("leave", "home")}${button("exit-now", "exit")}`;\n case "exit":\n return model.session?.kind === "room" && phase(model.session) !== "ended" ? `<p>${t(model.session.room?.persistent ? "leaveHint" : "temporaryHint")}</p>${invitation()}${button("disconnect-exit", "leaveNow", \' class="primary"\')}<p class="muted">${t("abandonHint")}</p>${button("leave-exit", "leaveRoom")}` : button("leave-exit", "exit", \' class="primary"\');\n }\n }\n function title(panel) {\n const keys = { boot: "loading", home: "home", room: "room", invite: "copy", friends: "friends", voice: "voice", boards: "boards", join: "join", watch: "watch", attaching: "joining", matching: "matching", countdown: "starting", error: "error", exit: "exit" };\n return t(keys[panel]);\n }\n function updateCountdown() {\n const at = model.session?.room?.countdownAt, now = input.bridge.serverTime();\n const value = at === null || at === void 0 || now === null ? "..." : String(Math.max(0, Math.round((at - now) / 1e3)));\n const node = root.querySelector("[data-countdown]");\n if (node && node.textContent !== value) {\n node.textContent = value;\n announce(`${t("starting")} ${value}`);\n }\n }\n function geometry() {\n geometryFrame = 0;\n if (disposed || !input.bridge.epoch || !model.session) return;\n const frame = input.frame.getBoundingClientRect(), scaleX = input.frame.clientWidth && frame.width ? input.frame.clientWidth / frame.width : 1, scaleY = input.frame.clientHeight && frame.height ? input.frame.clientHeight / frame.height : 1;\n const reservedRects = [...root.querySelectorAll("[data-reserve]")].map((el) => {\n const rect = el.getBoundingClientRect(), left = Math.max(frame.left, rect.left), top = Math.max(frame.top, rect.top), right = Math.min(frame.right, rect.right), bottom = Math.min(frame.bottom, rect.bottom);\n return { x: Math.max(0, Math.round((left - frame.left) * scaleX)), y: Math.max(0, Math.round((top - frame.top) * scaleY)), width: Math.max(0, Math.round((right - left) * scaleX)), height: Math.max(0, Math.round((bottom - top) * scaleY)) };\n }).filter((rect) => rect.width && rect.height).slice(0, 8);\n const view = { inputBlocked: !!visiblePanel(model), reservedRects, shortcutEnabled: model.shortcutEnabled };\n const serialized = `${input.bridge.epoch}:${JSON.stringify(view)}`;\n if (lastView === serialized) return;\n lastView = serialized;\n void input.bridge.request("overlay.view", view).catch(() => {\n if (lastView === serialized) lastView = "";\n });\n }\n function resize() {\n if (!geometryFrame) geometryFrame = win.requestAnimationFrame(geometry);\n }\n function render() {\n if (disposed) return;\n const panel = visiblePanel(model), current = phase(model.session), room2 = model.session?.room;\n const focused = root.activeElement;\n const focusPeer = focused?.dataset.peer;\n const focusKey = focused?.dataset.control ? ["control", focused.dataset.control] : focused?.dataset.action ? ["action", focused.dataset.action] : null;\n const previousScroll = root.querySelector(".dialog")?.scrollTop ?? 0;\n const selection = focused?.tagName === "INPUT" ? { start: focused.selectionStart, end: focused.selectionEnd } : null;\n const crewState = input.crew?.getSnapshot(), invitations = (crewState?.invites.length ?? 0) + (crewState?.follow ? 1 : 0);\n const label = current === "watching" ? t("watching") : room2?.connection === "reconnecting" ? t("reconnecting") : room2?.code ?? "Caisual";\n surface.innerHTML = `<div class="pill" data-reserve><button type="button" data-action="menu" aria-label="${t("menu")}" aria-expanded="${!!panel}">C<span aria-hidden="true"><small>${escape(label)}</small></span></button>${voiceEligible(manifest, model.session) && model.session?.kind === "room" ? `<button type="button" class="voice-toggle" data-voice-toggle data-action="panel:voice"></button>` : ""}${invitations ? `<button type="button" data-action="panel:friends" aria-label="${t("friends")} (${invitations})">${invitations}</button>` : ""}<button type="button" class="exit" data-action="panel:exit" aria-label="${t("exit")}">\\xD7</button></div>\n ${current === "ended" && !panel ? `<div class="ended" data-reserve role="region" aria-label="${t("ended")}"><strong>${t("ended")}</strong>${boards?.state.saving ? `<small role="status" data-saving>${t(boards.state.saving)}</small>` : ""}${canPlayAgain(model.session) ? button("again", "again", \' class="primary"\') : model.session?.kind === "room" ? `<small>${t("waitHost")}</small>` : ""}${Object.keys(manifest.boards).length ? button("panel:boards", "boards") : ""}${button("panel:home", "home")}</div>` : ""}\n ${panel ? `<div class="backdrop${panel === "home" ? " home" : ""}"><section class="dialog${panel === "boards" || panel === "friends" ? " wide" : ""}" role="dialog" aria-modal="true" aria-labelledby="panel-title" tabindex="-1"><div class="top"><h2 id="panel-title">${title(panel)}</h2><button type="button" data-action="close" aria-label="${t("close")}">\\xD7</button></div><div class="stack">${content(panel)}${model.error ? `<p class="error" role="alert" data-error>${t(errorText(model.error))}</p>` : ""}${model.session?.resumeError ? `<p class="error" role="alert">${t("saveFailed")}</p>` : ""}${model.notice ? `<p class="notice" role="status">${escape(model.notice)}</p>` : ""}</div></section></div>` : ""}`;\n for (const element of surface.querySelectorAll(".pill,.backdrop,.ended")) element.style.pointerEvents = "auto";\n const backdrop = root.querySelector(".backdrop.home");\n if (backdrop && input.configuration.coverUrl) backdrop.style.backgroundImage = `linear-gradient(#0b151c99,#0b151cee),url(${JSON.stringify(input.configuration.coverUrl)})`;\n host.dataset.phase = current;\n host.dataset.panel = panel ?? "";\n input.frame.inert = !!panel || oldInert;\n if (panel) input.frame.tabIndex = -1;\n else if (oldTabIndex === null) input.frame.removeAttribute("tabindex");\n else input.frame.setAttribute("tabindex", oldTabIndex);\n updateVoice();\n const dialog = root.querySelector(".dialog");\n if (dialog) dialog.scrollTop = previousScroll;\n const matched = focusKey ? [...root.querySelectorAll(`[data-${focusKey[0]}]`)].find((el) => el.getAttribute(`data-${focusKey[0]}`) === focusKey[1] && el.dataset.peer === focusPeer) : null;\n if (!panel && wasModal && !input.frame.inert && input.frame.isConnected) {\n input.frame.focus({ preventScroll: true });\n input.frame.contentWindow?.focus();\n } else if (matched && (!panel || matched.closest(".dialog")) && !matched.hasAttribute("disabled")) {\n matched.focus({ preventScroll: true });\n if (matched.tagName === "INPUT" && selection?.start !== null && selection?.end !== null && selection) matched.setSelectionRange(selection.start, selection.end);\n } else if (panel && (!wasModal || focused)) (dialog?.querySelector(\'select,input,button:not([data-action="close"]):not(:disabled)\') ?? dialog)?.focus({ preventScroll: true });\n wasModal = !!panel;\n if (lastPhase !== current) {\n lastPhase = current;\n announce(t({ boot: "loading", home: "home", attaching: "joining", matching: "matching", lobby: "room", countdown: "starting", playing: "playing", ended: "ended", watching: "watching", error: "error" }[current]));\n }\n updateCountdown();\n resize();\n }\n const click = (event) => {\n const target = event.target.closest("button[data-action]");\n if (!target || target.disabled) return;\n const action = target.dataset.action;\n event.stopPropagation();\n if (action.startsWith("panel:")) {\n setPanel(action.slice(6));\n return;\n }\n switch (action) {\n case "menu":\n toggle();\n break;\n case "close":\n close();\n break;\n case "play": {\n const selected = primaryAction(manifest, model.mode);\n if (selected) void perform(selected.op, { mode: model.mode });\n break;\n }\n case "match":\n void perform("room.match", { mode: model.mode });\n break;\n case "join-invite":\n if (input.configuration.invite) void perform("room.join", { code: input.configuration.invite });\n break;\n case "resume":\n void perform("session.resume", {});\n break;\n case "cancel":\n void perform("session.cancel", {});\n break;\n case "ready":\n void perform("room.ready", { ready: !model.session?.room?.players.find((p) => p.id === model.session?.room?.you)?.ready });\n break;\n case "start":\n void perform("room.start", {});\n break;\n case "copy":\n void copyInvite();\n break;\n case "voice-join":\n void performVoice("voice.join", {});\n break;\n case "voice-mute":\n void performVoice("voice.mute", { muted: !model.session?.voice?.muted });\n break;\n case "voice-leave":\n void performVoice("voice.leave", {});\n break;\n case "again": {\n if (!canPlayAgain(model.session)) break;\n if (model.session?.kind === "local") void perform("local.start", { mode: model.session.mode ?? model.mode });\n else void perform("room.create", { mode: model.session?.room?.mode ?? null }, async () => {\n setPanel("invite");\n dispatch({ type: "notice", notice: t("newRoom") });\n await copyInvite();\n });\n break;\n }\n case "disconnect-exit":\n void perform("session.disconnect", {}, input.exit);\n break;\n case "leave-exit":\n void perform("session.leave", {}, input.exit);\n break;\n case "leave":\n void perform("session.leave", {}, () => setPanel("home"));\n break;\n case "exit-now":\n input.exit();\n break;\n case "reload":\n input.frame.src = input.frame.src;\n break;\n case "refresh":\n void boards?.refresh();\n break;\n case "party-create":\n input.crew?.party.create();\n break;\n case "party-leave":\n input.crew?.party.leave();\n break;\n case "party-invite":\n input.crew?.party.invite(target.dataset.player);\n break;\n case "party-accept":\n input.crew?.party.accept(target.dataset.party);\n break;\n case "party-decline":\n input.crew?.party.decline(target.dataset.party);\n break;\n case "follow":\n input.crew?.follow(target.dataset.game, target.dataset.code);\n break;\n }\n };\n const change = (event) => {\n const target = event.target, field = target.dataset.control;\n if (field === "voice-volume") {\n target.dataset.editing = "true";\n void performVoice("voice.setVolume", { playerId: target.dataset.peer, volume: Number(target.value) }).finally(() => {\n delete target.dataset.editing;\n updateVoice();\n });\n return;\n }\n if (field === "mode") dispatch({ type: "mode", mode: target.value });\n if (field === "role") void perform(model.session?.room?.status === "lobby" ? "room.role" : "room.requestRole", { role: target.value });\n if (field === "team") void perform("room.team", { team: Number(target.value) });\n if (field === "shortcut") {\n const enabled = target.checked;\n try {\n win.localStorage.setItem("caisual-overlay-shortcut-v1", enabled ? "on" : "off");\n } catch {\n }\n dispatch({ type: "shortcut", enabled });\n }\n const query = boards?.state.query;\n if (query && ["board", "period", "category"].includes(field ?? "")) {\n const next = { ...query };\n if (field === "board") {\n next.board = target.value;\n next.period = (manifest.boards[next.board].periods ?? ["all-time"])[0];\n delete next.day;\n }\n if (field === "period") {\n next.period = target.value;\n delete next.day;\n }\n if (field === "category") next.guests = target.value === "guests";\n boards.select(next);\n }\n };\n const submit = (event) => {\n const form = event.target;\n if (!form.dataset.form) return;\n event.preventDefault();\n const code = normalizeInvite(form.querySelector(\'input[data-control="code"]\').value);\n if (!code) {\n dispatch({ type: "error", code: "invalid_code" });\n return;\n }\n void perform(form.dataset.form === "watch" ? "room.watch" : "room.join", { code });\n };\n const keydown = (event) => {\n const panel = visiblePanel(model);\n if (panel && event.key === "Escape") {\n event.preventDefault();\n event.stopImmediatePropagation();\n close();\n return;\n }\n if (panel && event.key === "Tab") {\n const items = controls().filter((el) => el.closest(".dialog")), first = items[0], last = items.at(-1);\n if (!first) {\n event.preventDefault();\n return;\n }\n if (event.shiftKey && (root.activeElement === first || !items.includes(root.activeElement))) {\n event.preventDefault();\n last?.focus();\n } else if (!event.shiftKey && (root.activeElement === last || !items.includes(root.activeElement))) {\n event.preventDefault();\n first.focus();\n }\n } else if (!panel && model.shortcutEnabled && event.key === "Tab" && event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey) {\n event.preventDefault();\n toggle();\n }\n };\n root.addEventListener("input", (event) => {\n const node = event.target;\n if (node.dataset.control === "code") codeDraft = node.value;\n if (node.dataset.control === "voice-volume") node.dataset.editing = "true";\n });\n root.addEventListener("click", click);\n root.addEventListener("change", change);\n root.addEventListener("submit", submit);\n win.addEventListener("keydown", keydown, true);\n win.addEventListener("resize", resize);\n const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(resize) : null;\n observer?.observe(input.frame);\n const countdownTimer = win.setInterval(updateCountdown, 250);\n stops.push(input.bridge.subscribe((session) => {\n const previous = model.session;\n if (!session || session.id !== previous?.id) {\n voiceOperation++;\n voicePending = null;\n voiceError = null;\n }\n if (!session) {\n lastView = "";\n boards?.reset();\n operation++;\n model.busy = false;\n }\n if (previous && session && JSON.stringify({ ...previous, voice: null }) === JSON.stringify({ ...session, voice: null })) {\n model = reduceUi(model, { type: "session", session });\n updateVoice();\n } else dispatch({ type: "session", session });\n }));\n stops.push(input.bridge.onOpen((panel) => setPanel(panel)), input.bridge.onShortcut(toggle), input.bridge.onError(({ error }) => {\n if (!visiblePanel(model)) setPanel("room");\n dispatch({ type: "error", code: error.code });\n }));\n stops.push(input.bridge.onScore((score) => boards?.queued(score)));\n if (input.crew) stops.push(input.crew.subscribe(() => {\n const state = input.crew.getSnapshot();\n if (state.follow || state.invites.length) announce(t("friends"));\n render();\n }));\n render();\n return { element: host, root, dispose() {\n disposed = true;\n operation++;\n stops.forEach((stop) => stop());\n boards?.dispose();\n observer?.disconnect();\n win.clearInterval(countdownTimer);\n win.cancelAnimationFrame(geometryFrame);\n win.removeEventListener("keydown", keydown, true);\n win.removeEventListener("resize", resize);\n input.frame.inert = oldInert;\n if (oldTabIndex === null) input.frame.removeAttribute("tabindex");\n else input.frame.setAttribute("tabindex", oldTabIndex);\n void input.bridge.request("overlay.view", { inputBlocked: false, reservedRects: [], shortcutEnabled: false }).catch(() => {\n });\n host.remove();\n } };\n}\nexport {\n avviaHandshake,\n creaPonteOspite,\n eMessaggioReady,\n eRichiestaBiglietto,\n mountOverlay,\n overlayConfiguration,\n overlayLanguage,\n styles as overlayStyles,\n stanzaDaMessaggio\n};\n');
4800
+ response.end(request.method === "HEAD" ? void 0 : '// ../contracts/src/slug.ts\nvar NOMI_RISERVATI = [\n "www",\n "api",\n "app",\n "play",\n "live",\n "multi",\n "cdn",\n "assets",\n "static",\n "mail",\n "mx",\n "ns1",\n "ns2",\n "autodiscover",\n "_dmarc",\n "admin",\n "login",\n "account",\n "auth",\n "pay",\n "secure",\n "support",\n "help",\n "blog",\n "status",\n "dev",\n "staging",\n "test",\n "caisual",\n "shipz"\n];\nvar RISERVATI = new Set(NOMI_RISERVATI);\nvar SLUG_NUOVO = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;\nvar SLUG_STORICO = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;\nfunction isValidSlug(value) {\n return value.length >= 3 && value.length <= 32 && SLUG_NUOVO.test(value) || SLUG_STORICO.test(value);\n}\nfunction isReservedSlug(value) {\n return RISERVATI.has(value);\n}\n\n// ../contracts/src/i18n.ts\nfunction normalizeLanguage(value) {\n if (typeof value !== "string" || value.length > 128) return null;\n try {\n return Intl.getCanonicalLocales(value)[0] ?? null;\n } catch {\n return null;\n }\n}\nfunction manifestLanguages(manifest) {\n return manifest.languages?.length ? [...manifest.languages] : [manifest.language ?? "en"];\n}\nfunction languageFallbacks(language, defaultLanguage = "en") {\n const result = [];\n let tag = normalizeLanguage(language);\n while (tag) {\n result.push(tag);\n const parts = tag.split("-");\n parts.pop();\n if (parts.at(-1)?.length === 1) parts.pop();\n tag = parts.join("-");\n }\n result.push(normalizeLanguage(defaultLanguage) ?? defaultLanguage);\n return [...new Set(result)];\n}\nfunction resolveText(value, language, defaultLanguage = "en", key = "") {\n if (typeof value === "string") return value;\n if (value) {\n for (const tag of languageFallbacks(language, defaultLanguage)) {\n const name = Object.keys(value).find((name2) => name2.toLowerCase() === tag.toLowerCase());\n if (name !== void 0 && typeof value[name] === "string") return value[name];\n }\n }\n return key;\n}\n\n// ../contracts/src/manifest.ts\nfunction risolviModalita(manifest, mode) {\n const scelta = mode === null ? void 0 : manifest.modes.find((voce) => voce.id === mode);\n if (mode !== null && scelta === void 0) throw new Error("The selected game mode does not exist.");\n return { players: { ...scelta?.players ?? manifest.players }, lobby: scelta?.lobby ?? manifest.lobby };\n}\nfunction risolviPresentazione(manifest, mode, language = manifestLanguages(manifest)[0]) {\n risolviModalita(manifest, mode);\n const scelta = manifest.modes.find((voce) => voce.id === mode);\n return {\n execution: scelta?.execution ?? null,\n label: resolveText(scelta?.label, language, manifestLanguages(manifest)[0], scelta?.id ?? manifest.name ?? "Play"),\n instructions: resolveText(scelta?.instructions, language, manifestLanguages(manifest)[0]) || null\n };\n}\nvar TETTO_GIOCATORI = 24;\nvar RITARDO_SPETTATORI_MS = 3e3;\nvar MASSIMO_CLASSIFICHE = 32;\nvar CAMPI = /* @__PURE__ */ new Set([\n "overlay",\n "manifest",\n "id",\n "name",\n "description",\n "cover",\n "screenshots",\n "tags",\n "languages",\n "language",\n "platform",\n "orientation",\n "input",\n "visibility",\n "network",\n "isolated",\n "requires",\n "players",\n "lobby",\n "persistent",\n "spectators",\n "boards",\n "roles",\n "teams",\n "voice",\n "modes"\n]);\nvar INPUT = /* @__PURE__ */ new Set(["keyboard", "mouse", "touch", "gamepad"]);\nvar PLATFORM = /* @__PURE__ */ new Set(["desktop", "mobile", "both"]);\nvar ORIENTATION = /* @__PURE__ */ new Set(["landscape", "portrait"]);\nvar VISIBILITY = /* @__PURE__ */ new Set(["public", "unlisted"]);\nvar VOICE = /* @__PURE__ */ new Set(["none", "room", "team", "proximity"]);\nvar PERFORMANCE = /* @__PURE__ */ new Set(["light", "medium", "heavy"]);\nvar TAG = /^[a-z0-9-]+$/;\nvar ID_INTERNO = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;\nvar CAMPO_MATCHMAKING = /^[a-z0-9][a-z0-9-]{0,31}$/;\nvar ID_CLASSIFICA = /^[a-z0-9][a-z0-9_-]{0,31}$/;\nfunction oggetto(value) {\n if (typeof value !== "object" || value === null || Array.isArray(value)) return null;\n return value;\n}\nfunction percorsoRelativo(value) {\n if (value === "" || value.startsWith("/") || value.includes("\\\\") || value.includes("\\0")) return false;\n if (value.includes("?") || value.includes("#")) return false;\n const parti = value.split("/");\n if (parti.some((parte) => parte === "" || parte === "." || parte === "..")) return false;\n try {\n const decoded = parti.map((parte) => decodeURIComponent(parte));\n return !decoded.some((parte) => parte === "" || parte === "." || parte === ".." || parte.includes("/"));\n } catch {\n return false;\n }\n}\nfunction hostValido(value) {\n if (value.length === 0 || value.length > 253) return false;\n if (value.includes("://") || /[/:?#@]/.test(value)) return false;\n const parti = value.split(".");\n return parti.every(\n (parte) => parte.length >= 1 && parte.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(parte)\n );\n}\nfunction interoTra(value, min, max) {\n return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;\n}\nfunction stringaDefault(dati, campo, valoreDefault, errori) {\n const value = dati[campo];\n if (value === void 0) return valoreDefault;\n if (typeof value !== "string") {\n errori.push(`${campo}: must be a string.`);\n return valoreDefault;\n }\n return value;\n}\nfunction testoFacoltativo(value, key, max, path, errors) {\n if (value[key] === void 0) return void 0;\n const check = (text2, field2) => {\n if (typeof text2 !== "string" || text2.trim().length === 0 || text2.trim().length > max || /[\\r\\n\\u0000-\\u001f]/.test(text2)) {\n errors.push(`${field2}: must contain 1-${max} characters on one line.`);\n return void 0;\n }\n return text2.trim();\n };\n const text = value[key], field = `${path}.${key}`;\n if (typeof text === "string") return check(text, field);\n const translations = oggetto(text);\n if (!translations || Object.keys(translations).length === 0) {\n errors.push(`${field}: must be a string or a non-empty language-to-text object.`);\n return void 0;\n }\n const result = {};\n for (const [raw, text2] of Object.entries(translations)) {\n const tag = normalizeLanguage(raw);\n if (!tag) {\n errors.push(`${field}.${raw}: must be a BCP 47 language tag.`);\n continue;\n }\n if (Object.hasOwn(result, tag)) errors.push(`${field}.${raw}: duplicate language.`);\n const checked = check(text2, `${field}.${raw}`);\n if (checked !== void 0) result[tag] = checked;\n }\n return result;\n}\nfunction validaManifest(valore) {\n const errori = [];\n const dati = oggetto(valore);\n if (dati === null) return { ok: false, errori: ["manifest: must be a JSON object."] };\n for (const campo of Object.keys(dati)) {\n if (!CAMPI.has(campo)) errori.push(`${campo}: unknown field.`);\n }\n if (dati.manifest === void 0) errori.push("manifest: is required and must be 1.");\n else if (dati.manifest !== 1) errori.push("manifest: must be exactly 1.");\n const id = stringaDefault(dati, "id", "", errori);\n if (dati.id === void 0) errori.push("id: is required.");\n else if (typeof dati.id === "string") {\n if (!isValidSlug(id)) {\n errori.push("id: must be 3-32 lowercase ASCII letters or digits with internal hyphens; historical UUID v4 slugs may be 36 characters.");\n } else if (isReservedSlug(id)) errori.push("id: this slug is reserved.");\n }\n const name = stringaDefault(dati, "name", "", errori);\n if (dati.name === void 0) errori.push("name: is required.");\n else if (typeof dati.name === "string" && (name.trim() === "" || name.length > 60)) {\n errori.push("name: must contain 1-60 characters.");\n }\n const description = stringaDefault(dati, "description", "", errori);\n if (description.length > 500) errori.push("description: must be at most 500 characters.");\n let cover = null;\n if (dati.cover !== void 0 && dati.cover !== null) {\n if (typeof dati.cover !== "string") errori.push("cover: must be a relative file path or null.");\n else if (!percorsoRelativo(dati.cover)) errori.push("cover: must be a relative file path without query, fragment, or parent segments.");\n else cover = dati.cover;\n }\n const screenshots = [];\n if (dati.screenshots !== void 0) {\n if (!Array.isArray(dati.screenshots)) errori.push("screenshots: must be an array of relative file paths.");\n else {\n if (dati.screenshots.length > 8) errori.push("screenshots: must contain at most 8 paths.");\n for (const [indice, value] of dati.screenshots.entries()) {\n if (typeof value !== "string" || !percorsoRelativo(value)) {\n errori.push(`screenshots[${indice}]: must be a relative file path without query, fragment, or parent segments.`);\n } else screenshots.push(value);\n }\n }\n }\n const tags = [];\n if (dati.tags !== void 0) {\n if (!Array.isArray(dati.tags)) errori.push("tags: must be an array.");\n else {\n if (dati.tags.length > 10) errori.push("tags: must contain at most 10 tags.");\n for (const [indice, value] of dati.tags.entries()) {\n if (typeof value !== "string" || value.length > 24 || !TAG.test(value)) {\n errori.push(`tags[${indice}]: must be 1-24 lowercase letters, digits, or hyphens.`);\n } else tags.push(value);\n }\n }\n }\n const legacyLanguage = stringaDefault(dati, "language", "en", errori);\n if (!/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/.test(legacyLanguage)) {\n errori.push("language: must be a BCP 47 language tag such as en, it, or pt-BR.");\n }\n const languages2 = [];\n if (dati.languages === void 0) languages2.push(normalizeLanguage(legacyLanguage) ?? legacyLanguage);\n else if (!Array.isArray(dati.languages) || dati.languages.length === 0) {\n errori.push("languages: must be a non-empty array of BCP 47 language tags.");\n } else for (const [index, raw] of dati.languages.entries()) {\n const tag = normalizeLanguage(raw);\n if (!tag) errori.push(`languages[${index}]: must be a BCP 47 language tag.`);\n else if (languages2.includes(tag)) errori.push(`languages[${index}]: duplicate language ${tag}.`);\n else languages2.push(tag);\n }\n const language = languages2[0] ?? legacyLanguage;\n if (dati.language !== void 0 && dati.languages !== void 0 && legacyLanguage.toLowerCase() !== language.toLowerCase()) {\n errori.push("language: must match the first entry in languages when both are present.");\n }\n let platform = "both";\n if (dati.platform === void 0) errori.push("platform: is required.");\n else if (typeof dati.platform !== "string" || !PLATFORM.has(dati.platform)) {\n errori.push("platform: must be desktop, mobile, or both.");\n } else platform = dati.platform;\n let orientation = "landscape";\n if (dati.orientation !== void 0) {\n if (typeof dati.orientation !== "string" || !ORIENTATION.has(dati.orientation)) {\n errori.push("orientation: must be landscape or portrait.");\n } else orientation = dati.orientation;\n }\n const input = [];\n if (dati.input !== void 0) {\n if (!Array.isArray(dati.input)) errori.push("input: must be an array.");\n else for (const [indice, value] of dati.input.entries()) {\n if (typeof value !== "string" || !INPUT.has(value)) {\n errori.push(`input[${indice}]: must be keyboard, mouse, touch, or gamepad.`);\n } else if (input.includes(value)) errori.push(`input[${indice}]: duplicate value ${value}.`);\n else input.push(value);\n }\n }\n let visibility = "public";\n if (dati.visibility !== void 0) {\n if (typeof dati.visibility !== "string" || !VISIBILITY.has(dati.visibility)) {\n errori.push("visibility: must be public or unlisted.");\n } else visibility = dati.visibility;\n }\n const network = [];\n if (dati.network !== void 0) {\n if (!Array.isArray(dati.network)) errori.push("network: must be an array of host names.");\n else for (const [indice, value] of dati.network.entries()) {\n if (typeof value !== "string" || !hostValido(value)) {\n errori.push(`network[${indice}]: must be a host name without scheme, port, path, query, or fragment.`);\n } else if (network.includes(value)) errori.push(`network[${indice}]: duplicate host ${value}.`);\n else network.push(value);\n }\n }\n let isolated = false;\n if (dati.isolated !== void 0) {\n if (typeof dati.isolated !== "boolean") errori.push("isolated: must be a boolean.");\n else isolated = dati.isolated;\n }\n const requires = {\n webgl2: false,\n webgpu: false,\n wasm: false,\n threads: false,\n memoryMb: null,\n performance: "light"\n };\n if (dati.requires !== void 0) {\n const value = oggetto(dati.requires);\n if (value === null) errori.push("requires: must be an object.");\n else {\n for (const campo of Object.keys(value)) {\n if (!["webgl2", "webgpu", "wasm", "threads", "memoryMb", "performance"].includes(campo)) {\n errori.push(`requires.${campo}: unknown field.`);\n }\n }\n for (const campo of ["webgl2", "webgpu", "wasm", "threads"]) {\n if (value[campo] === void 0) continue;\n if (typeof value[campo] !== "boolean") errori.push(`requires.${campo}: must be a boolean.`);\n else requires[campo] = value[campo];\n }\n if (value.memoryMb !== void 0) {\n if (value.memoryMb !== null && (!interoTra(value.memoryMb, 512, 32768) || value.memoryMb % 256 !== 0)) {\n errori.push("requires.memoryMb: must be an integer from 512 to 32768 in steps of 256, or null.");\n } else requires.memoryMb = value.memoryMb;\n }\n if (value.performance !== void 0) {\n if (typeof value.performance !== "string" || !PERFORMANCE.has(value.performance)) {\n errori.push("requires.performance: must be light, medium, or heavy.");\n } else requires.performance = value.performance;\n }\n if (requires.threads && !isolated) errori.push("requires.threads: needs isolated: true.");\n }\n }\n let players = { min: 1, max: 1 };\n if (dati.players !== void 0) {\n const value = oggetto(dati.players);\n if (value === null) errori.push("players: must be an object with min and max.");\n else {\n for (const campo of Object.keys(value)) {\n if (campo !== "min" && campo !== "max") errori.push(`players.${campo}: unknown field.`);\n }\n if (!interoTra(value.min, 1, TETTO_GIOCATORI)) errori.push(`players.min: must be an integer from 1 to ${TETTO_GIOCATORI}.`);\n if (!interoTra(value.max, 1, TETTO_GIOCATORI)) errori.push(`players.max: must be an integer from 1 to ${TETTO_GIOCATORI} in manifest version 1.`);\n if (interoTra(value.min, 1, TETTO_GIOCATORI) && interoTra(value.max, 1, TETTO_GIOCATORI)) {\n if (value.min > value.max) errori.push("players.max: must be greater than or equal to players.min.");\n else players = { min: value.min, max: value.max };\n }\n }\n }\n let lobby = false;\n if (dati.lobby !== void 0) {\n if (typeof dati.lobby !== "boolean") errori.push("lobby: must be a boolean.");\n else lobby = dati.lobby;\n }\n let persistent = false;\n if (dati.persistent !== void 0) {\n if (typeof dati.persistent !== "boolean") errori.push("persistent: must be a boolean.");\n else persistent = dati.persistent;\n }\n let spectators = { delayMs: RITARDO_SPETTATORI_MS };\n if (dati.spectators === false || dati.spectators === null) spectators = null;\n else if (dati.spectators !== void 0 && dati.spectators !== true) {\n const value = oggetto(dati.spectators);\n if (value === null) {\n errori.push("spectators: must be a boolean or an object with delayMs.");\n } else {\n for (const campo of Object.keys(value)) {\n if (campo !== "delayMs") errori.push(`spectators.${campo}: unknown field.`);\n }\n if (!interoTra(value.delayMs, 0, 3e4)) {\n errori.push("spectators.delayMs: must be an integer from 0 to 30000.");\n } else spectators = { delayMs: value.delayMs };\n }\n }\n let overlay = null;\n if (dati.overlay !== void 0 && dati.overlay !== null) {\n const value = oggetto(dati.overlay);\n if (value === null) errori.push("overlay: must be an object or null.");\n else {\n for (const key of Object.keys(value)) if (!["version", "accent"].includes(key)) errori.push(`overlay.${key}: unknown field.`);\n if (value.version !== 1) errori.push("overlay.version: must be exactly 1.");\n if (value.accent !== void 0 && (typeof value.accent !== "string" || !/^#[0-9a-fA-F]{6}$/.test(value.accent))) {\n errori.push("overlay.accent: must be a six-digit hexadecimal color, such as #336699.");\n }\n overlay = { version: 1, ...typeof value.accent === "string" ? { accent: value.accent } : {} };\n }\n }\n const boards = {};\n if (dati.boards !== void 0) {\n const value = oggetto(dati.boards);\n if (value === null) errori.push("boards: must be an object of board ids.");\n else {\n if (Object.keys(value).length > MASSIMO_CLASSIFICHE) {\n errori.push(`boards: at most ${MASSIMO_CLASSIFICHE} boards.`);\n }\n for (const [id2, raw] of Object.entries(value)) {\n let valido = true;\n if (!ID_CLASSIFICA.test(id2)) {\n errori.push(`boards.${id2}: invalid board id.`);\n valido = false;\n }\n const board = oggetto(raw);\n if (board === null) {\n errori.push(`boards.${id2}.source: must be "client" or "server".`);\n continue;\n }\n for (const campo of Object.keys(board)) {\n if (!["source", "label", "periods"].includes(campo)) errori.push(`boards.${id2}.${campo}: unknown field.`);\n }\n if (board.source !== "client" && board.source !== "server") {\n errori.push(`boards.${id2}.source: must be "client" or "server".`);\n valido = false;\n }\n const label = testoFacoltativo(board, "label", 48, `boards.${id2}`, errori);\n let periods = ["all-time"];\n if (board.periods !== void 0) {\n if (!Array.isArray(board.periods) || board.periods.length < 1 || board.periods.length > 2 || board.periods.some((period) => period !== "daily" && period !== "all-time") || new Set(board.periods).size !== board.periods.length) {\n errori.push(`boards.${id2}.periods: must contain daily, all-time, or both without duplicates.`);\n } else periods = [...board.periods];\n }\n if (valido) Object.defineProperty(boards, id2, { value: {\n source: board.source,\n periods,\n ...label === void 0 ? {} : { label }\n }, enumerable: true, configurable: true, writable: true });\n }\n }\n }\n const roles = [];\n if (dati.roles !== void 0) {\n if (!Array.isArray(dati.roles)) errori.push("roles: must be an array.");\n else {\n const ids = /* @__PURE__ */ new Set();\n for (const [indice, raw] of dati.roles.entries()) {\n const value = oggetto(raw);\n if (value === null) {\n errori.push(`roles[${indice}]: must be an object.`);\n continue;\n }\n for (const campo of Object.keys(value)) {\n if (!["id", "min", "max", "label"].includes(campo)) errori.push(`roles[${indice}].${campo}: unknown field.`);\n }\n const idRuolo = value.id;\n const min = value.min;\n const max = value.max;\n let valido = true;\n if (typeof idRuolo !== "string" || idRuolo.length > 32 || !ID_INTERNO.test(idRuolo)) {\n errori.push(`roles[${indice}].id: must be 1-32 lowercase letters, digits, or internal hyphens.`);\n valido = false;\n } else if (ids.has(idRuolo)) {\n errori.push(`roles[${indice}].id: duplicate role ${idRuolo}.`);\n valido = false;\n } else ids.add(idRuolo);\n if (!interoTra(min, 0, TETTO_GIOCATORI)) {\n errori.push(`roles[${indice}].min: must be an integer from 0 to ${TETTO_GIOCATORI}.`);\n valido = false;\n }\n if (max !== void 0 && !interoTra(max, 0, TETTO_GIOCATORI)) {\n errori.push(`roles[${indice}].max: must be an integer from 0 to ${TETTO_GIOCATORI} when present.`);\n valido = false;\n }\n if (typeof min === "number" && typeof max === "number" && min > max) {\n errori.push(`roles[${indice}].max: must be greater than or equal to min.`);\n valido = false;\n }\n const label = testoFacoltativo(value, "label", 32, `roles[${indice}]`, errori);\n if (valido) roles.push({\n id: idRuolo,\n min,\n ...max === void 0 ? {} : { max },\n ...label === void 0 ? {} : { label }\n });\n }\n }\n }\n let teams = null;\n if (dati.teams !== void 0 && dati.teams !== null) {\n const value = oggetto(dati.teams);\n if (value === null) errori.push("teams: must be null or an object with min and max.");\n else {\n for (const campo of Object.keys(value)) {\n if (campo !== "min" && campo !== "max") errori.push(`teams.${campo}: unknown field.`);\n }\n if (!interoTra(value.min, 2, TETTO_GIOCATORI)) errori.push(`teams.min: must be an integer from 2 to ${TETTO_GIOCATORI}.`);\n if (!interoTra(value.max, 2, TETTO_GIOCATORI)) errori.push(`teams.max: must be an integer from 2 to ${TETTO_GIOCATORI}.`);\n if (interoTra(value.min, 2, TETTO_GIOCATORI) && interoTra(value.max, 2, TETTO_GIOCATORI)) {\n if (value.min > value.max) errori.push("teams.max: must be greater than or equal to teams.min.");\n else teams = { min: value.min, max: value.max };\n }\n }\n }\n let voice = "none";\n if (dati.voice !== void 0) {\n if (typeof dati.voice !== "string" || !VOICE.has(dati.voice)) {\n errori.push("voice: must be none, room, team, or proximity.");\n } else voice = dati.voice;\n }\n const modes = [];\n if (dati.modes !== void 0) {\n if (!Array.isArray(dati.modes)) errori.push("modes: must be an array.");\n else {\n const ids = /* @__PURE__ */ new Set();\n for (const [indice, raw] of dati.modes.entries()) {\n const value = oggetto(raw);\n if (value === null) {\n errori.push(`modes[${indice}]: must be an object.`);\n continue;\n }\n for (const campo of Object.keys(value)) {\n if (!["id", "players", "lobby", "matchmaking", "execution", "label", "instructions"].includes(campo)) errori.push(`modes[${indice}].${campo}: unknown field.`);\n }\n if (typeof value.id !== "string" || value.id.length > 32 || !ID_INTERNO.test(value.id)) {\n errori.push(`modes[${indice}].id: must be 1-32 lowercase letters, digits, or internal hyphens.`);\n continue;\n }\n if (ids.has(value.id)) {\n errori.push(`modes[${indice}].id: duplicate mode ${value.id}.`);\n continue;\n }\n ids.add(value.id);\n const modo = { id: value.id };\n for (const [key2, max] of [["label", 48], ["instructions", 160]]) {\n const text = testoFacoltativo(value, key2, max, `modes[${indice}]`, errori);\n if (text !== void 0) modo[key2] = text;\n }\n if (value.execution !== void 0) {\n if (value.execution !== "local" && value.execution !== "room") errori.push(`modes[${indice}].execution: must be local or room.`);\n else modo.execution = value.execution;\n }\n if (overlay !== null && modo.execution === void 0) errori.push(`modes[${indice}].execution: is required with the standard overlay.`);\n if (value.players !== void 0) {\n const campo = `modes[${indice}].players`;\n const range = oggetto(value.players);\n if (range === null) errori.push(`${campo}: must be an object with min and max.`);\n else {\n for (const key2 of Object.keys(range)) {\n if (key2 !== "min" && key2 !== "max") errori.push(`${campo}.${key2}: unknown field.`);\n }\n if (!interoTra(range.min, 1, TETTO_GIOCATORI)) errori.push(`${campo}.min: must be an integer from 1 to ${TETTO_GIOCATORI}.`);\n if (!interoTra(range.max, 1, TETTO_GIOCATORI)) errori.push(`${campo}.max: must be an integer from 1 to ${TETTO_GIOCATORI}.`);\n if (interoTra(range.min, 1, TETTO_GIOCATORI) && interoTra(range.max, 1, TETTO_GIOCATORI)) {\n if (range.min > range.max) errori.push(`${campo}.max: must be greater than or equal to min.`);\n else modo.players = { min: range.min, max: range.max };\n }\n }\n }\n if (value.lobby !== void 0) {\n if (typeof value.lobby !== "boolean") errori.push(`modes[${indice}].lobby: must be a boolean.`);\n else modo.lobby = value.lobby;\n }\n if (modo.execution === "local") {\n const range = modo.players ?? players;\n if (range.min !== 1 || range.max !== 1) errori.push(`modes[${indice}].players: local execution requires min and max to be 1.`);\n if (modo.lobby ?? lobby) errori.push(`modes[${indice}].lobby: local execution requires false.`);\n if (value.matchmaking !== void 0) errori.push(`modes[${indice}].matchmaking: local execution cannot use matchmaking.`);\n }\n if (value.matchmaking === void 0) {\n modes.push(modo);\n continue;\n }\n const matchmaking = oggetto(value.matchmaking);\n if (matchmaking === null) {\n errori.push(`modes[${indice}].matchmaking: must be an object.`);\n continue;\n }\n for (const campo of Object.keys(matchmaking)) {\n if (!["key", "timeoutMs", "defaults"].includes(campo)) {\n errori.push(`modes[${indice}].matchmaking.${campo}: unknown field.`);\n }\n }\n let valido = true;\n const key = [];\n if (!Array.isArray(matchmaking.key) || matchmaking.key.length < 1 || matchmaking.key.length > 8) {\n errori.push(`modes[${indice}].matchmaking.key: must contain from 1 to 8 fields.`);\n valido = false;\n } else for (const [keyIndice, item] of matchmaking.key.entries()) {\n if (typeof item !== "string" || !CAMPO_MATCHMAKING.test(item)) {\n errori.push(`modes[${indice}].matchmaking.key[${keyIndice}]: must be 1-32 lowercase letters, digits, or hyphens and start with a letter or digit.`);\n valido = false;\n } else if (key.includes(item)) {\n errori.push(`modes[${indice}].matchmaking.key[${keyIndice}]: duplicate field ${item}.`);\n valido = false;\n } else key.push(item);\n }\n if (!interoTra(matchmaking.timeoutMs, 1e3, 3e5)) {\n errori.push(`modes[${indice}].matchmaking.timeoutMs: must be an integer from 1000 to 300000.`);\n valido = false;\n }\n let defaults;\n if (matchmaking.defaults !== void 0) {\n const values = oggetto(matchmaking.defaults);\n if (values === null || Object.keys(values).length !== key.length || key.some((field) => !Object.hasOwn(values, field))) {\n errori.push(`modes[${indice}].matchmaking.defaults: must contain exactly the declared key fields.`);\n } else {\n defaults = {};\n for (const [field, value2] of Object.entries(values)) {\n if (!(typeof value2 === "string" && value2.length >= 1 && value2.length <= 64 && /^[A-Za-z0-9_.:-]+$/.test(value2)) && !Number.isSafeInteger(value2)) {\n errori.push(`modes[${indice}].matchmaking.defaults.${field}: must be a string of 1-64 characters or a safe integer.`);\n } else Object.defineProperty(defaults, field, { value: value2, enumerable: true });\n }\n }\n }\n if (valido) modes.push({ ...modo, matchmaking: {\n ...defaults === void 0 ? {} : { defaults },\n key,\n timeoutMs: matchmaking.timeoutMs\n } });\n }\n }\n }\n if (overlay !== null && modes.length === 0) errori.push("modes: at least one explicit mode is required with the standard overlay.");\n if (errori.length > 0) return { ok: false, errori };\n return { ok: true, manifest: {\n manifest: 1,\n overlay,\n id,\n name,\n description,\n cover,\n screenshots,\n tags,\n languages: languages2,\n language,\n platform,\n orientation,\n input,\n visibility,\n network,\n isolated,\n requires,\n players,\n lobby,\n persistent,\n spectators,\n boards,\n roles,\n teams,\n voice,\n modes\n } };\n}\n\n// ../contracts/src/overlay.ts\nvar OVERLAY_PANELS = ["home", "room", "invite", "friends", "voice", "boards"];\nfunction overlayConfiguration(manifest, coverUrl = null, invite = null) {\n const validated = validaManifest(manifest);\n if (!validated.ok) throw new Error("The overlay manifest is invalid.");\n return { manifest: validated.manifest, coverUrl, invite };\n}\nfunction record(value) {\n return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;\n}\nfunction validSafeArea(value) {\n const area = record(value);\n return area !== null && Object.keys(area).length === 4 && ["top", "right", "bottom", "left"].every((key) => typeof area[key] === "number" && Number.isFinite(area[key]) && Number(area[key]) >= 0 && Number(area[key]) <= 1e5);\n}\nfunction validOverlayView(value) {\n const data = record(value);\n return data !== null && Object.keys(data).every((key) => ["inputBlocked", "reservedRects", "safeArea", "shortcutEnabled"].includes(key)) && (data.safeArea === void 0 || validSafeArea(data.safeArea)) && (data.shortcutEnabled === void 0 || typeof data.shortcutEnabled === "boolean") && typeof data.inputBlocked === "boolean" && Array.isArray(data.reservedRects) && data.reservedRects.length <= 8 && data.reservedRects.every((value2) => {\n const rect = record(value2);\n return rect !== null && Object.keys(rect).length === 4 && ["x", "y", "width", "height"].every((key) => typeof rect[key] === "number" && Number.isFinite(rect[key]) && rect[key] >= 0 && rect[key] <= 1e5);\n });\n}\nfunction validOverlayRequest(value) {\n const message = record(value), args = record(message?.args);\n if (message?.type !== "caisual:overlay" || message.v !== 1 || typeof message.epoch !== "string" || message.epoch.length < 1 || message.epoch.length > 128 || typeof message.requestId !== "string" || !(/^[1-9][0-9]{0,15}$/.test(message.requestId) && Number.isSafeInteger(Number(message.requestId))) || args === null) return false;\n if (Object.keys(message).some((key) => !["type", "v", "epoch", "requestId", "sessionId", "op", "args"].includes(key)) || !(message.sessionId === void 0 || message.sessionId === null || typeof message.sessionId === "string" && /^[1-9][0-9]{0,15}$/.test(message.sessionId))) return false;\n const keys = (...allowed) => Object.keys(args).every((key) => allowed.includes(key));\n const text = (key) => typeof args[key] === "string" && args[key].length >= 1 && args[key].length <= 64;\n switch (message.op) {\n case "local.start":\n return keys("mode") && text("mode");\n case "room.create":\n return keys("mode") && (args.mode === null || text("mode"));\n case "room.join":\n return keys("code") && (args.code === void 0 || text("code"));\n case "room.watch":\n return keys("code") && text("code");\n case "room.match": {\n const key = record(args.key);\n return keys("mode", "key") && text("mode") && (args.key === void 0 || key !== null && Object.keys(key).length <= 8 && Object.values(key).every((v) => typeof v === "string" && v.length >= 1 && v.length <= 64 || typeof v === "number" && Number.isSafeInteger(v)));\n }\n case "room.ready":\n return keys("ready") && typeof args.ready === "boolean";\n case "room.role":\n case "room.requestRole":\n return keys("role") && text("role");\n case "room.team":\n return keys("team") && Number.isInteger(args.team) && args.team >= 1 && args.team <= 24;\n case "room.restart":\n case "room.start":\n case "session.cancel":\n case "session.leave":\n case "session.disconnect":\n case "session.resume":\n return keys();\n case "voice.join":\n case "voice.leave":\n return keys() && typeof message.sessionId === "string";\n case "voice.mute":\n return keys("muted") && typeof args.muted === "boolean" && typeof message.sessionId === "string";\n case "voice.setVolume":\n return keys("playerId", "volume") && typeof message.sessionId === "string" && typeof args.playerId === "string" && args.playerId.length > 0 && args.playerId.length <= 128 && typeof args.volume === "number" && Number.isFinite(args.volume) && args.volume >= 0 && args.volume <= 1;\n case "overlay.view":\n return validOverlayView(args);\n default:\n return false;\n }\n}\nfunction validBoardDay(value) {\n if (typeof value !== "string" || !/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) return false;\n const at = Date.parse(`${value}T00:00:00Z`);\n return Number.isFinite(at) && new Date(at).toISOString().slice(0, 10) === value;\n}\nfunction validOverlaySessionState(value) {\n const data = record(value);\n const exact = (v, keys) => v !== null && Object.keys(v).length === keys.length && Object.keys(v).every((key) => keys.includes(key));\n const text = (v) => typeof v === "string" && v.length <= 128;\n const nullable = (v) => v === null || text(v);\n const finite = (v) => typeof v === "number" && Number.isFinite(v);\n if (!data || !exact(data, ["kind", "id", "mode", "localStatus", "ready", "capabilities", "room", "waiting", "resume", "resumeError", ..."voice" in data ? ["voice"] : []])) return false;\n if (data.voice !== void 0 && data.voice !== null && (data.kind !== "room" || !record(data.room) || !validOverlayVoice(data.voice))) return false;\n const capabilities = record(data.capabilities), room = record(data.room), waiting = record(data.waiting), resume = record(data.resume);\n if (!["boot", "home", "attaching", "matching", "local", "room", "watch"].includes(String(data.kind)) || !nullable(data.id) || !nullable(data.mode) || ![null, "playing", "ended"].includes(data.localStatus) || typeof data.ready !== "boolean" || typeof data.resumeError !== "boolean" || !exact(capabilities, ["local", "rooms", "overlay", "requestRole"]) || !Object.values(capabilities).every((v) => typeof v === "boolean")) return false;\n if (data.waiting !== null && (!exact(waiting, ["players", "min", "max"]) || !Object.values(waiting).every((v) => Number.isInteger(v) && Number(v) >= 0 && Number(v) <= 24))) return false;\n if (data.resume !== null && (!exact(resume, ["version", "code", "mode", "updatedAt"]) || resume.version !== 1 || !text(resume.code) || !nullable(resume.mode) || !finite(resume.updatedAt))) return false;\n if (data.room === null) return true;\n if (!exact(room, ["code", "mode", "status", "host", "you", "players", "countdownAt", "connection", "closedCode", "limits", "lobby", "persistent", "delayMs", "requestRole"]) || !room) return false;\n const limits = record(room.limits);\n return text(room.code) && nullable(room.mode) && nullable(room.host) && nullable(room.you) && ["lobby", "countdown", "playing", "finished", "ended"].includes(String(room.status)) && ["connecting", "connected", "reconnecting", "disconnected", "ended", "closed", "replaced"].includes(String(room.connection)) && ["countdownAt", "closedCode", "delayMs"].every((key) => room[key] === null || finite(room[key])) && ["lobby", "persistent", "requestRole"].every((key) => typeof room[key] === "boolean") && exact(limits, ["min", "max"]) && Object.values(limits).every((v) => Number.isInteger(v) && Number(v) >= 1 && Number(v) <= 24) && Array.isArray(room.players) && room.players.length <= 24 && room.players.every((value2) => {\n const player = record(value2);\n return exact(player, ["id", "name", "guest", "role", "team", "ready", "connected"]) && player !== null && text(player.id) && text(player.name) && nullable(player.role) && (player.team === null || Number.isInteger(player.team) && Number(player.team) >= 1 && Number(player.team) <= 24) && ["guest", "ready", "connected"].every((key) => typeof player[key] === "boolean");\n });\n}\nfunction validOverlayVoice(value) {\n const voice = record(value);\n if (!voice || Object.keys(voice).length !== 6 || !["mode", "state", "mic", "muted", "speaking", "peers"].every((key) => key in voice) || !["room", "team", "proximity"].includes(String(voice.mode)) || !["off", "joining", "on", "reconnecting"].includes(String(voice.state)) || !["mic", "muted", "speaking"].every((key) => typeof voice[key] === "boolean") || !Array.isArray(voice.peers) || voice.peers.length > 24) return false;\n const ids = /* @__PURE__ */ new Set();\n return voice.peers.every((value2) => {\n const peer = record(value2);\n if (!peer || Object.keys(peer).length !== 5 || !["id", "mic", "muted", "speaking", "volume"].every((key) => key in peer) || typeof peer.id !== "string" || !peer.id.length || peer.id.length > 128 || ids.has(peer.id) || !["mic", "muted", "speaking"].every((key) => typeof peer[key] === "boolean") || typeof peer.volume !== "number" || !Number.isFinite(peer.volume) || peer.volume < 0 || peer.volume > 1) return false;\n ids.add(peer.id);\n return true;\n });\n}\n\n// src/errors.ts\nfunction creaErrore(code, message) {\n return Object.assign(new Error(message), { name: "CaisualError", code });\n}\n\n// src/overlay/host-bridge.ts\nfunction eMessaggioReady(value) {\n return record(value)?.type === "caisual:ready";\n}\nfunction eRichiestaBiglietto(value) {\n const data = record(value);\n return data?.type === "caisual:ticket" && (data.aud === void 0 || data.aud === "portal" || data.aud === "live");\n}\nfunction stanzaDaMessaggio(value) {\n const data = record(value);\n if (data?.type !== "caisual:room") return void 0;\n if (data.room === null) return null;\n const room = record(data.room);\n return typeof room?.code === "string" && /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/.test(room.code) ? { code: room.code } : void 0;\n}\nfunction creaPonteOspite(input) {\n let port = null, epoch = null, instance = null;\n let disposed = false, legacyReady = true, sequence = 0, requestId = 0;\n let state = null, clockOffset = null;\n let polling = null, pollingEnd = null;\n const pending = /* @__PURE__ */ new Map();\n const states = /* @__PURE__ */ new Set();\n const shortcuts = /* @__PURE__ */ new Set();\n const opens = /* @__PURE__ */ new Set();\n const errors = /* @__PURE__ */ new Set();\n const scores = /* @__PURE__ */ new Set();\n const notify = (listeners, value) => {\n for (const listener of listeners) try {\n listener(value);\n } catch {\n }\n };\n const rejectPending = () => {\n for (const value of pending.values()) {\n input.finestra.clearTimeout(value.timer);\n value.reject(creaErrore("session_replaced", "The game document changed."));\n }\n pending.clear();\n };\n const stopPolling = () => {\n if (polling !== null) input.finestra.clearInterval(polling);\n if (pollingEnd !== null) input.finestra.clearTimeout(pollingEnd);\n polling = pollingEnd = null;\n };\n const askReady = () => {\n if (!disposed && input.frame.src !== "") input.frame.contentWindow?.postMessage({ type: "caisual:ready?" }, input.origineGioco);\n };\n const poll = () => {\n stopPolling();\n polling = input.finestra.setInterval(askReady, 500);\n pollingEnd = input.finestra.setTimeout(stopPolling, 1e4);\n askReady();\n };\n const loaded = () => {\n legacyReady = true;\n poll();\n };\n const listen = (event) => {\n if (disposed || event.origin !== input.origineGioco || event.source !== input.frame.contentWindow || !eMessaggioReady(event.data)) return;\n const data = record(event.data);\n const nextInstance = typeof data.instance === "string" && data.instance.length <= 128 ? data.instance : null;\n if (port && (nextInstance !== null ? nextInstance === instance : !legacyReady)) return;\n stopPolling();\n legacyReady = false;\n instance = nextInstance;\n rejectPending();\n port?.close();\n input.onRoom(null);\n epoch = input.epoch?.() ?? crypto.randomUUID();\n sequence = requestId = 0;\n state = null;\n clockOffset = null;\n notify(states, null);\n const channel = input.creaCanale?.() ?? new MessageChannel();\n const currentPort = channel.port1, currentEpoch = epoch;\n port = currentPort;\n const current = () => !disposed && port === currentPort && epoch === currentEpoch;\n currentPort.onmessage = (event2) => {\n if (!current()) return;\n const data2 = record(event2.data);\n if (eRichiestaBiglietto(data2)) {\n const aud = data2?.aud === "live" ? "live" : "portal";\n void input.rinnova(aud).then((ticket) => {\n if (current()) currentPort.postMessage({ type: "caisual:ticket", aud, ticket });\n }).catch(() => {\n if (current()) currentPort.postMessage({ type: "caisual:ticket", aud, error: "offline" });\n });\n return;\n }\n const room = stanzaDaMessaggio(data2);\n if (room !== void 0) {\n input.onRoom(room);\n return;\n }\n if (data2?.v !== 1 || data2.epoch !== currentEpoch) return;\n if (data2.type === "caisual:overlay-response" && typeof data2.requestId === "string") {\n const item = pending.get(data2.requestId);\n if (!item) return;\n if (data2.ok !== true && (data2.ok !== false || typeof record(data2.error)?.code !== "string" || typeof record(data2.error)?.message !== "string")) return;\n pending.delete(data2.requestId);\n input.finestra.clearTimeout(item.timer);\n const response = data2;\n if (response.ok) item.resolve();\n else item.reject(creaErrore(response.error.code, response.error.message));\n } else if (data2.type === "caisual:overlay-state" && Number.isSafeInteger(data2.seq) && data2.seq > sequence) {\n if (!validOverlaySessionState(data2.state) || typeof data2.serverTime !== "number" || !Number.isFinite(data2.serverTime)) return;\n clockOffset = data2.serverTime - Date.now();\n sequence = data2.seq;\n state = structuredClone(data2.state);\n notify(states, state);\n } else if (data2.type === "caisual:overlay-error" && data2.sessionId === state?.id) {\n const error = record(data2.error);\n if (typeof error?.code === "string" && typeof error.message === "string") notify(errors, { sessionId: data2.sessionId, error: { code: error.code, message: error.message } });\n } else if (data2.type === "caisual:overlay-shortcut") {\n notify(shortcuts, void 0);\n } else if (data2.type === "caisual:overlay-open" && OVERLAY_PANELS.includes(data2.panel)) {\n notify(opens, data2.panel);\n } else if (data2.type === "caisual:overlay-score") {\n const score = record(data2.score);\n if (score && typeof score.board === "string" && typeof score.player === "string" && Number.isSafeInteger(score.score) && Number.isFinite(score.submittedAt) && (score.day === null || typeof score.day === "string")) {\n notify(scores, { board: score.board, player: score.player, score: score.score, day: score.day, submittedAt: score.submittedAt });\n }\n }\n };\n currentPort.start();\n input.frame.contentWindow?.postMessage({\n type: "caisual:hello",\n ticket: input.ticket,\n live: input.origineLive,\n invite: input.invite,\n // language resta disponibile ai kit pubblicati prima della separazione delle lingue.\n ...input.language ? { language: input.language, uiLanguage: input.language } : {},\n ...input.languagePreferences ? { languagePreferences: input.languagePreferences } : {},\n ...input.configuration ? { gameLanguages: manifestLanguages(input.configuration.manifest) } : {},\n ...input.configuration && data.overlayVersion === 1 ? { overlay: { v: 1, epoch, configuration: input.configuration } } : {}\n }, input.origineGioco, [channel.port2]);\n };\n input.finestra.addEventListener("message", listen);\n input.frame.addEventListener?.("load", loaded);\n poll();\n return {\n get epoch() {\n return epoch;\n },\n serverTime() {\n return clockOffset === null ? null : Date.now() + clockOffset;\n },\n get state() {\n return state === null ? null : structuredClone(state);\n },\n subscribe(listener) {\n states.add(listener);\n listener(state);\n return () => {\n states.delete(listener);\n };\n },\n onShortcut(listener) {\n shortcuts.add(listener);\n return () => {\n shortcuts.delete(listener);\n };\n },\n onOpen(listener) {\n opens.add(listener);\n return () => {\n opens.delete(listener);\n };\n },\n onError(listener) {\n errors.add(listener);\n return () => {\n errors.delete(listener);\n };\n },\n onScore(listener) {\n scores.add(listener);\n return () => {\n scores.delete(listener);\n };\n },\n request(op, args) {\n if (!port || !epoch || disposed) return Promise.reject(creaErrore("offline", "The game bridge is not connected."));\n if (pending.size >= 32) return Promise.reject(creaErrore("rate_limited", "Too many overlay requests."));\n const id = String(++requestId), request = {\n type: "caisual:overlay",\n v: 1,\n epoch,\n requestId: id,\n op,\n args,\n ...["room.ready", "room.role", "room.requestRole", "room.team", "room.start", "room.restart", "session.leave", "session.disconnect", "voice.join", "voice.mute", "voice.leave", "voice.setVolume"].includes(op) ? { sessionId: state?.id ?? null } : {}\n };\n if (!validOverlayRequest(request)) return Promise.reject(creaErrore("invalid_request", "The overlay request is invalid."));\n return new Promise((resolve, reject) => {\n const timeout = op === "room.match" ? 31e4 : input.requestTimeoutMs ?? 15e3;\n const timer = input.finestra.setTimeout(() => {\n pending.delete(id);\n reject(creaErrore("timeout", "The overlay request timed out."));\n }, timeout);\n pending.set(id, { resolve, reject, timer });\n try {\n port.postMessage(request);\n } catch (error) {\n input.finestra.clearTimeout(timer);\n pending.delete(id);\n reject(error);\n }\n });\n },\n dispose() {\n disposed = true;\n stopPolling();\n rejectPending();\n port?.close();\n port = null;\n input.finestra.removeEventListener("message", listen);\n input.frame.removeEventListener?.("load", loaded);\n states.clear();\n opens.clear();\n shortcuts.clear();\n scores.clear();\n errors.clear();\n }\n };\n}\nfunction avviaHandshake(input) {\n const bridge = creaPonteOspite(input);\n return () => bridge.dispose();\n}\nfunction gameViewport(frame) {\n const rect = frame.getBoundingClientRect();\n const zoomX = frame.offsetWidth ? rect.width / frame.offsetWidth : 1;\n const zoomY = frame.offsetHeight ? rect.height / frame.offsetHeight : 1;\n const left = rect.left + frame.clientLeft * zoomX, top = rect.top + frame.clientTop * zoomY;\n return {\n left,\n top,\n right: left + frame.clientWidth * zoomX,\n bottom: top + frame.clientHeight * zoomY,\n scaleX: zoomX ? 1 / zoomX : 1,\n scaleY: zoomY ? 1 / zoomY : 1\n };\n}\nfunction measureSafeArea(frame, probe) {\n const win = frame.ownerDocument.defaultView, css = win.getComputedStyle(probe), viewport = gameViewport(frame);\n const clamp = (value, max) => Math.max(0, Math.min(max, value));\n return {\n top: clamp(((parseFloat(css.paddingTop) || 0) - viewport.top) * viewport.scaleY, frame.clientHeight),\n right: clamp((viewport.right - (win.innerWidth - (parseFloat(css.paddingRight) || 0))) * viewport.scaleX, frame.clientWidth),\n bottom: clamp((viewport.bottom - (win.innerHeight - (parseFloat(css.paddingBottom) || 0))) * viewport.scaleY, frame.clientHeight),\n left: clamp(((parseFloat(css.paddingLeft) || 0) - viewport.left) * viewport.scaleX, frame.clientWidth)\n };\n}\n\n// src/overlay/boards.ts\nfunction createBoardController(input) {\n let disposed = false, generation = 0, timer;\n const seen = /* @__PURE__ */ new Set();\n let query = null, data = null, error = false, loading = false;\n let queued = null, saving = null, reads = 0;\n const later = input.later ?? setTimeout, clear = input.clear ?? clearTimeout;\n const cancel = () => {\n if (timer !== void 0) clear(timer);\n timer = void 0;\n };\n const notify = () => {\n if (!disposed) input.changed();\n };\n const matches = () => queued && query?.board === queued.board && query.period === (queued.day ? "daily" : "all-time") && (query.day ?? queued.day) === queued.day;\n const refresh = async () => {\n if (!query || disposed) return;\n cancel();\n const current = ++generation, selected = { ...query };\n loading = true;\n error = false;\n notify();\n try {\n const result = await input.read(selected);\n if (disposed || current !== generation) return;\n data = result;\n if (matches()) {\n const own = result.me;\n if (own?.verified && own.score >= queued.score) saving = own.score === queued.score ? "saved" : "bestAlready";\n }\n } catch {\n if (!disposed && current === generation) error = true;\n }\n if (disposed || current !== generation) return;\n loading = false;\n if (matches() && saving !== "saved" && saving !== "bestAlready") {\n reads++;\n if (reads < 4) {\n saving = "saving";\n timer = later(() => {\n void refresh();\n }, [800, 1600, 3200][reads - 1]);\n } else saving = "refreshHint";\n }\n notify();\n };\n return {\n get state() {\n return { query, data, loading, error, saving: matches() ? saving : null };\n },\n select(next) {\n if (JSON.stringify(next) === JSON.stringify(query)) return;\n cancel();\n generation++;\n query = { ...next };\n data = null;\n reads = 0;\n if (matches()) saving = "saving";\n void refresh();\n },\n queued(score) {\n const board = input.manifest.boards[score.board];\n if (score.player !== input.player || !board || !Number.isSafeInteger(score.score) || score.score < 0 || score.day !== null && !validBoardDay(score.day) || !(board.periods ?? ["all-time"]).includes(score.day ? "daily" : "all-time")) return;\n const signature = JSON.stringify(score);\n if (seen.has(signature)) return;\n seen.add(signature);\n if (seen.size > 64) seen.delete(seen.values().next().value);\n queued = score;\n saving = "saving";\n reads = 0;\n this.select({ board: score.board, period: score.day ? "daily" : "all-time", guests: query?.guests ?? input.guests ?? false, ...score.day ? { day: score.day } : {} });\n if (!loading) void refresh();\n notify();\n },\n refresh,\n reset() {\n seen.clear();\n cancel();\n generation++;\n query = null;\n data = null;\n queued = null;\n saving = null;\n loading = false;\n error = false;\n },\n dispose() {\n disposed = true;\n cancel();\n generation++;\n }\n };\n}\n\n// src/overlay/i18n.ts\nvar languages = ["en", "it", "es", "fr", "de", "pt"];\nvar words = {\n gameLanguages: ["Game languages", "Lingue del gioco", "Idiomas del juego", "Langues du jeu", "Spielsprachen", "Idiomas do jogo"],\n loading: ["Loading game...", "Caricamento...", "Cargando...", "Chargement...", "Spiel wird geladen...", "Carregando..."],\n loadingSlow: ["This game is taking longer than expected. You can wait a little longer or try again.", "Il gioco ci sta mettendo pi\\xF9 del previsto. Puoi aspettare ancora un po\\u2019 o riprovare.", "El juego est\\xE1 tardando m\\xE1s de lo esperado. Puedes esperar un poco m\\xE1s o volver a intentarlo.", "Le jeu met plus de temps que pr\\xE9vu. Vous pouvez patienter encore un peu ou r\\xE9essayer.", "Das Spiel braucht l\\xE4nger als erwartet. Du kannst noch etwas warten oder es erneut versuchen.", "O jogo est\\xE1 demorando mais do que o esperado. Voc\\xEA pode esperar mais um pouco ou tentar novamente."],\n home: ["Play", "Gioca", "Jugar", "Jouer", "Spielen", "Jogar"],\n homeMenu: ["Menu", "Menu", "Men\\xFA", "Menu", "Men\\xFC", "Menu"],\n mode: ["Mode", "Modalit\\xE0", "Modo", "Mode", "Modus", "Modo"],\n play: ["Play", "Gioca", "Jugar", "Jouer", "Spielen", "Jogar"],\n friendsPlay: ["Play with friends", "Gioca con amici", "Jugar con amigos", "Jouer entre amis", "Mit Freunden spielen", "Jogar com amigos"],\n find: ["Find players", "Trova giocatori", "Buscar jugadores", "Trouver des joueurs", "Spieler finden", "Buscar jogadores"],\n join: ["Join with code", "Entra con codice", "Entrar con c\\xF3digo", "Rejoindre avec un code", "Mit Code beitreten", "Entrar com c\\xF3digo"],\n joinInvite: ["Join this room", "Entra in questa stanza", "Entrar en est\\xE1 sala", "Rejoindre cette salle", "Diesem Raum beitreten", "Entrar nest\\xE1 sala"],\n watch: ["Watch a room", "Guarda una stanza", "Observar una sala", "Regarder une salle", "Raum ansehen", "Assistir a uma sala"],\n resume: ["Resume", "Riprendi", "Continuar", "Reprendre", "Fortsetzen", "Continuar"],\n room: ["Room", "Stanza", "Sala", "Salle", "Raum", "Sala"],\n code: ["Room code", "Codice stanza", "C\\xF3digo de sala", "Code de salle", "Raumcode", "C\\xF3digo da sala"],\n copy: ["Copy invite", "Copia invito", "Copiar invitaci\\xF3n", "Copier le lien", "Einladung kopieren", "Copiar convite"],\n copied: ["Invite copied", "Invito copiato", "Invitacion copiada", "Lien copi\\xE9", "Einladung kopiert", "Convite copiado"],\n copyFailed: ["Copy this link:", "Copia questo link:", "Copia este enlace:", "Copiez ce lien :", "Diesen Link kopieren:", "Copie este link:"],\n joining: ["Joining room...", "Ingresso nella stanza...", "Entrando en la sala...", "Connexion \\xE0 la salle...", "Raum wird betreten...", "Entrando na sala..."],\n matching: ["Finding your people...", "Ricerca giocatori...", "Buscando jugadores...", "Recherche de joueurs...", "Spieler werden gesucht...", "Buscando jogadores..."],\n queue: ["{n} / {max} players", "{n} / {max} giocatori", "{n} / {max} jugadores", "{n} / {max} joueurs", "{n} / {max} Spieler", "{n} / {max} jogadores"],\n cancel: ["Cancel", "Annulla", "Cancelar", "Annuler", "Abbrechen", "Cancelar"],\n close: ["Close", "Chiudi", "Cerrar", "Fermer", "Schlie\\xDFen", "Fechar"],\n back: ["Back", "Indietro", "Volver", "Retour", "Zur\\xFCck", "Voltar"],\n ready: ["Ready", "Pronto", "Listo", "Pr\\xEAt", "Bereit", "Pronto"],\n unready: ["Not ready", "Non pronto", "No listo", "Pas pr\\xEAt", "Nicht bereit", "N\\xE3o pronto"],\n start: ["Start", "Inizia", "Empezar", "Commencer", "Starten", "Come\\xE7ar"],\n role: ["Role", "Ruolo", "Rol", "R\\xF4le", "Rolle", "Fun\\xE7\\xE3o"],\n team: ["Team", "Squadra", "Equipo", "\\xC9quipe", "Team", "Equipe"],\n host: ["Host", "Host", "Anfitrion", "H\\xF4te", "Host", "Anfitri\\xE3o"],\n you: ["You", "Tu", "T\\xFA", "Vous", "Du", "Voc\\xEA"],\n away: ["Away", "Assente", "Ausente", "Absent", "Abwesend", "Ausente"],\n needPlayers: ["Waiting for more players", "In attesa di giocatori", "Esperando m\\xE1s jugadores", "En attente de joueurs", "Weitere Spieler fehlen", "Esperando mais jogadores"],\n needReady: ["Everyone needs to be ready", "Tutti devono essere pronti", "Todos deben estar listos", "Tout le monde doit \\xEAtre pr\\xEAt", "Alle m\\xFCssen bereit sein", "Todos precisam estar prontos"],\n needRoles: ["Fill the required roles", "Completa i ruoli richiesti", "Completa los roles", "Compl\\xE9tez les r\\xF4les", "Ben\\xF6tigte Rollen besetzen", "Complete as fun\\xE7\\xF5es"],\n needTeams: ["Choose the required teams", "Scegli le squadre richieste", "Elige los equipos", "Choisissez les \\xE9quipes", "Teams auswahlen", "Escolha as equipes"],\n waitHost: ["Waiting for the host", "In attesa dell\'host", "Esperando al anfitrion", "En attente de l\\u2019h\\xF4te", "Warten auf den Host", "Esperando o anfitri\\xE3o"],\n starting: ["Starting in", "Si inizia tra", "Empieza en", "D\\xE9but dans", "Start in", "Come\\xE7a em"],\n playing: ["Playing", "In partita", "Jugando", "En jeu", "Im Spiel", "Jogando"],\n ended: ["Game finished", "Partita conclusa", "Partida terminada", "Partie termin\\xE9e", "Spiel beendet", "Partida encerrada"],\n rematchReady: ["{n}/{max} ready", "{n}/{max} pronti", "{n}/{max} listos", "{n}/{max} pr\\xEAts", "{n}/{max} bereit", "{n}/{max} prontos"],\n rematchStart: ["Start rematch", "Avvia rivincita", "Iniciar revancha", "Lancer la revanche", "Revanche starten", "Iniciar revanche"],\n again: ["Play again", "Gioca ancora", "Jugar de nuevo", "Rejouer", "Erneut spielen", "Jogar novamente"],\n newRoom: ["New room. Share the new invite.", "Nuova stanza. Condividi il nuovo invito.", "Nueva sala. Comparte la invitaci\\xF3n.", "Nouvelle salle. Partagez le lien.", "Neuer Raum. Neue Einladung teilen.", "Nova sala. Compartilhe o convite."],\n watching: ["Watching", "In osservazione", "Observando", "Spectateur", "Zuschauen", "Assistindo"],\n delay: ["{n}s delay", "Ritardo {n}s", "Retraso de {n}s", "Retard de {n}s", "{n}s Verz\\xF6gerung", "Atraso de {n}s"],\n exit: ["Exit", "Esci", "Salir", "Quitter", "Verlassen", "Sair"],\n leaveNow: ["Leave for now", "Esci per ora", "Salir por ahora", "Quitter pour le moment", "Vor\\xFCbergehend verlassen", "Sair por enquanto"],\n leaveRoom: ["Leave room", "Lascia la stanza", "Abandonar sala", "Abandonner la salle", "Raum verlassen", "Deixar a sala"],\n leaveHint: ["Your room stays available for Resume.", "La stanza resta disponibile con Riprendi.", "Podr\\xE1s volver a est\\xE1 sala.", "Vous pourrez reprendre cette salle.", "Du kannst den Raum fortsetzen.", "Voc\\xEA pode voltar a est\\xE1 sala."],\n temporaryHint: ["The game continues. Rejoining may only be possible briefly.", "La partita continua. Il rientro pu\\xF2 essere disponibile solo per poco.", "La partida continua. Volver puede ser posible solo por poco tiempo.", "La partie continue. Le retour peut \\xEAtre limit\\xE9.", "Das Spiel l\\xE4uft weiter. R\\xFCckkehr nur kurz m\\xF6glich.", "A partida continua. O retorno pode ser limitado."],\n abandonHint: ["Leave room gives up your place.", "Lascia la stanza libera il tuo posto.", "Abandonar libera tu plaza.", "Abandonner lib\\xE8re votre place.", "Raum verlassen gibt deinen Platz frei.", "Deixar a sala libera sua vaga."],\n reconnecting: ["Reconnecting...", "Riconnessione...", "Reconectando...", "Reconnexion...", "Verbindung wird erneuert...", "Reconectando..."],\n replaced: ["Opened in another tab", "Aperta in un\\u2019altra scheda", "Abierta en otra pest\\xE1na", "Ouverte dans un autre onglet", "In anderem Tab ge\\xF6ffnet", "Aberta em outra aba"],\n error: ["Something went wrong. Try again.", "Qualcosa non va. Riprova.", "Algo sali\\xF3 mal. Reintenta.", "Une erreur est survenue. R\\xE9essayez.", "Etwas ist schiefgelaufen. Erneut versuchen.", "Algo deu errado. Tente novamente."],\n noRoom: ["This room is no longer available.", "Questa stanza non \\xE8 pi\\xF9 disponibile.", "Esta sala ya no est\\xE1 disponible.", "Cette salle n\'est plus disponible.", "Dieser Raum ist nicht mehr verf\\xFCgbar.", "Esta sala n\\xE3o est\\xE1 mais disponivel."],\n full: ["This room is full.", "La stanza \\xE8 piena.", "La sala est\\xE1 llena.", "Cette salle est pleine.", "Dieser Raum ist voll.", "Esta sala est\\xE1 cheia."],\n noMatch: ["No match this time. Try again.", "Nessun gruppo trovato. Riprova.", "No hay grupo. Reintenta.", "Aucun groupe trouv\\xE9. R\\xE9essayez.", "Keine Gruppe gefunden. Erneut versuchen.", "Nenhum grupo encontrado. Tente novamente."],\n invalidCode: ["Enter a six-character room code.", "Inserisci un codice di sei caratteri.", "Escribe un c\\xF3digo de seis caracteres.", "Entrez un code de six caracteres.", "Sechsstelligen Raumcode eingeben.", "Digite um c\\xF3digo de seis caracteres."],\n refused: ["The room did not accept that change.", "La stanza ha rifiutato la modifica.", "La sala rechaz\\xF3 el cambio.", "La salle a refus\\xE9 ce changement.", "Der Raum hat die \\xC4nderung abgelehnt.", "A sala recusou a altera\\xE7\\xE3o."],\n unavailable: ["Unavailable right now", "Non disponibile ora", "No disponible ahora", "Indisponible pour le moment", "Derzeit nicht verf\\xFCgbar", "Indisponivel agora"],\n offline: ["Connection unavailable. Try again.", "Connessione non disponibile. Riprova.", "Sin conexi\\xF3n. Reintenta.", "Connexion indisponible. R\\xE9essayez.", "Keine Verbindung. Erneut versuchen.", "Sem conex\\xE3o. Tente novamente."],\n saveFailed: ["Keep the room code. Resume could not be saved.", "Conserva il codice. Riprendi non \\xE8 stato salvato.", "Guarda el c\\xF3digo. No se pudo guardar el regreso.", "Gardez le code. La reprise ne peut pas \\xEAtre enregistr\\xE9e.", "Raumcode aufbewahren. Fortsetzen nicht gespeichert.", "Guarde o c\\xF3digo. O retorno n\\xE3o foi salvo."],\n boards: ["Leaderboard", "Classifica", "Clasificaci\\xF3n", "Classement", "Bestenliste", "Classifica\\xE7\\xE3o"],\n board: ["Board", "Classifica", "Tabla", "Classement", "Bestenliste", "Tabela"],\n daily: ["Daily", "Giornaliera", "Diaria", "Du jour", "T\\xE4glich", "Di\\xE1ria"],\n allTime: ["All time", "Di sempre", "Hist\\xF3rica", "Tous les temps", "Gesamt", "Geral"],\n accounts: ["Accounts", "Account", "Cuentas", "Comptes", "Konten", "Contas"],\n guests: ["Guests", "Ospiti", "Invitados", "Invit\\xE9s", "G\\xE4ste", "Visitantes"],\n category: ["Category", "Categoria", "Categoria", "Cat\\xE9gorie", "Kategorie", "Categoria"],\n period: ["Period", "Periodo", "Per\\xEDodo", "P\\xE9riode", "Zeitraum", "Per\\xEDodo"],\n rank: ["Rank", "Posizione", "Puesto", "Rang", "Platz", "Posicao"],\n score: ["Score", "Punteggio", "Puntos", "Score", "Punkte", "Pontos"],\n verified: ["Verified", "Verificato", "Verificado", "V\\xE9rifi\\xE9", "Verifiziert", "Verificado"],\n own: ["Your best", "Il tuo record", "Tu record", "Votre record", "Dein Rekord", "Seu recorde"],\n empty: ["No scores yet", "Nessun punteggio", "A\\xFAn no hay puntos", "Aucun score", "Noch keine Punkte", "Ainda sem pontos"],\n saving: ["Saving score...", "Salvataggio punteggio...", "Guardando puntos...", "Enregistrement du score...", "Punkte werden gespeichert...", "Salvando pontos..."],\n saved: ["Your best is on the board", "Il tuo record \\xE8 in classifica", "Tu record est\\xE1 en la tabla", "Votre record est au classement", "Dein Rekord ist eingetragen", "Seu recorde est\\xE1 na tabela"],\n bestAlready: ["Your best is already on the board", "Il tuo record era gi\\xE0 in classifica", "Tu record ya estaba en la tabla", "Votre record est d\\xE9j\\xE0 au classement", "Dein Rekord ist bereits eingetragen", "Seu recorde j\\xE1 est\\xE1 na tabela"],\n refresh: ["Refresh", "Aggiorna", "Actualizar", "Actualiser", "Aktualisieren", "Atualizar"],\n refreshHint: ["Score not visible yet. Refresh to check.", "Punteggio non ancora visibile. Aggiorna per controllare.", "Puntos a\\xFAn no visibles. Actualiza.", "Score pas encore visible. Actualisez.", "Punkte noch nicht sichtbar. Aktualisieren.", "Pontos ainda n\\xE3o visiveis. Atualize."],\n friends: ["Friends & party", "Amici e gruppo", "Amigos y grupo", "Amis et groupe", "Freunde & Gruppe", "Amigos e grupo"],\n localCrew: ["Friends and party are unavailable in local preview.", "Amici e gruppo non disponibili in anteprima locale.", "Amigos y grupo no disponibles en la vista local.", "Amis et groupe indisponibles en aper\\xE7u local.", "Freunde und Gruppe in lokaler Vorschau nicht verf\\xFCgbar.", "Amigos e grupo indispon\\xEDveis na pr\\xE9via local."],\n loginCrew: ["Sign in on Caisual to use friends and party.", "Accedi a Caisual per amici e gruppo.", "Inicia sesion para amigos y grupo.", "Connectez-vous pour utiliser amis et groupe.", "F\\xFCr Freunde und Gruppe bei Caisual anmelden.", "Entre no Caisual para amigos e grupo."],\n online: ["Online", "Online", "En linea", "En ligne", "Online", "Online"],\n noFriends: ["No friends online", "Nessun amico online", "Sin amigos en linea", "Aucun ami en ligne", "Keine Freunde online", "Nenhum amigo online"],\n createParty: ["Create party", "Crea gruppo", "Crear grupo", "Cr\\xE9er un groupe", "Gruppe erstellen", "Criar grupo"],\n inviteParty: ["Invite to party", "Invita nel gruppo", "Invitar al grupo", "Inviter au groupe", "In Gruppe einladen", "Convidar para o grupo"],\n leaveParty: ["Leave party", "Lascia gruppo", "Salir del grupo", "Quitter le groupe", "Gruppe verlassen", "Sair do grupo"],\n accept: ["Accept", "Accetta", "Aceptar", "Accepter", "Annehmen", "Aceitar"],\n decline: ["Decline", "Rifiuta", "Rechazar", "Refuser", "Ablehnen", "Recusar"],\n follow: ["Join them", "Raggiungi", "Unirse", "Rejoindre", "Beitreten", "Juntar-se"],\n voice: ["Voice", "Voce", "Voz", "Voix", "Sprache", "Voz"],\n voiceJoin: ["Join voice", "Entra in voce", "Unirse a voz", "Activer la voix", "Sprachchat beitreten", "Entrar na voz"],\n voiceLeave: ["Leave voice", "Esci dalla voce", "Salir de voz", "Quitter la voix", "Sprachchat verlassen", "Sair da voz"],\n voiceMute: ["Mute", "Disattiva microfono", "Silenciar", "Couper le micro", "Stummschalten", "Silenciar"],\n voiceUnmute: ["Unmute", "Attiva microfono", "Activar micr\\xF3fono", "Activer le micro", "Mikrofon aktivieren", "Ativar microfone"],\n voiceOff: ["Voice off", "Voce disattivata", "Voz desactivada", "Voix d\\xE9sactiv\\xE9e", "Sprachchat aus", "Voz desativada"],\n voiceJoining: ["Joining voice...", "Connessione voce...", "Conectando voz...", "Connexion vocale...", "Sprachchat verbindet...", "Conectando voz..."],\n voiceOn: ["Voice connected", "Voce connessa", "Voz conectada", "Voix connect\\xE9e", "Sprachchat verbunden", "Voz conectada"],\n voiceMuted: ["Muted", "Microfono disattivato", "Silenciado", "Micro coup\\xE9", "Stumm", "Silenciado"],\n voiceMic: ["Mic on", "Microfono attivo", "Micr\\xF3fono activo", "Micro actif", "Mikrofon an", "Microfone ativo"],\n voiceListening: ["Listening only", "Solo ascolto", "Solo escucha", "\\xC9coute seule", "Nur zuh\\xF6ren", "Somente ouvindo"],\n voiceSpeaking: ["Speaking", "Sta parlando", "Hablando", "Parle", "Spricht", "Falando"],\n voicePeers: ["Voice participants", "Partecipanti in voce", "Participantes de voz", "Participants vocaux", "Sprachteilnehmer", "Participantes de voz"],\n voiceEmpty: ["No one else in voice yet.", "Nessun altro in voce per ora.", "A\\xFAn no hay nadie m\\xE1s en voz.", "Personne d\\u2019autre en voix pour le moment.", "Noch niemand im Sprachchat.", "Ningu\\xE9m mais na voz ainda."],\n voiceVolume: ["Volume for {name}", "Volume di {name}", "Volumen de {name}", "Volume de {name}", "Lautst\\xE4rke f\\xFCr {name}", "Volume de {name}"],\n voiceUnavailable: ["Join a room with voice to use these controls.", "Entra in una stanza con voce per usare questi controlli.", "Entra en una sala con voz para usar estos controles.", "Rejoignez une salle vocale pour utiliser ces commandes.", "Diese Steuerung braucht einen Raum mit Sprachchat.", "Entre em uma sala com voz para usar estes controles."],\n voiceWatch: ["Voice is unavailable while watching.", "La voce non e\' disponibile in osservazione.", "La voz no est\\xE1 disponible al observar.", "La voix est indisponible en observation.", "Beim Zuschauen ist kein Sprachchat verf\\xFCgbar.", "A voz n\\xE3o est\\xE1 dispon\\xEDvel ao assistir."],\n voiceDenied: ["Microphone permission denied. Allow it in your browser, then try again.", "Permesso microfono negato. Consenti l\'accesso nel browser e riprova.", "Permiso de micr\\xF3fono denegado. Act\\xEDvalo en el navegador e int\\xE9ntalo de nuevo.", "Acc\\xE8s au micro refus\\xE9. Autorisez-le dans le navigateur, puis r\\xE9essayez.", "Mikrofonzugriff verweigert. Im Browser erlauben und erneut versuchen.", "Permiss\\xE3o do microfone negada. Permita no navegador e tente novamente."],\n voiceUnsupported: ["Voice is not supported in this browser.", "Questo browser non supporta la voce.", "Este navegador no admite voz.", "Ce navigateur ne prend pas en charge la voix.", "Dieser Browser unterst\\xFCtzt keinen Sprachchat.", "Este navegador n\\xE3o oferece suporte a voz."],\n voiceFailed: ["Voice could not connect. Try again.", "Connessione voce non riuscita. Riprova.", "No se pudo conectar la voz. Int\\xE9ntalo de nuevo.", "Connexion vocale impossible. R\\xE9essayez.", "Sprachverbindung fehlgeschlagen. Erneut versuchen.", "N\\xE3o foi poss\\xEDvel conectar a voz. Tente novamente."],\n voicePeerGone: ["This participant has left voice.", "Questo partecipante e\' uscito dalla voce.", "Este participante sali\\xF3 de voz.", "Ce participant a quitt\\xE9 la voix.", "Diese Person hat den Sprachchat verlassen.", "Este participante saiu da voz."],\n shortcut: ["Shift+Tab shortcut", "Scorciatoia Shift+Tab", "Atajo Shift+Tab", "Raccourci Maj+Tab", "Umschalt+Tab-Kurzbefehl", "Atalho Shift+Tab"],\n menu: ["Caisual menu", "Menu Caisual", "Menu Caisual", "Menu Caisual", "Caisual-Menu", "Menu Caisual"],\n retry: ["Retry", "Riprova", "Reintentar", "R\\xE9essayer", "Erneut versuchen", "Tentar novamente"]\n};\nvar column = (index) => Object.fromEntries(Object.entries(words).map(([key, row]) => [key, row[index]]));\nvar dictionaries = { en: column(0), it: column(1), es: column(2), fr: column(3), de: column(4), pt: column(5) };\nfunction overlayLanguage(raw) {\n const value = raw?.toLowerCase().split("-")[0];\n return languages.includes(value) ? value : "en";\n}\nfunction overlayLocale(raw) {\n const tag = normalizeLanguage(raw);\n return tag && languages.includes(tag.split("-")[0]) ? tag : "en";\n}\nfunction translator(language) {\n const dictionary = dictionaries[overlayLanguage(language)];\n return (key, values = {}) => dictionary[key].replace(/\\{(\\w+)\\}/g, (_all, name) => String(values[name] ?? ""));\n}\nfunction errorText(code) {\n if (code === "permission_denied") return "voiceDenied";\n if (code === "unsupported") return "voiceUnsupported";\n if (code === "voice_disabled") return "voiceUnavailable";\n if (code === "voice_error") return "voiceFailed";\n if (code === "voice_peer_missing") return "voicePeerGone";\n if (code === "not_publishing") return "voiceListening";\n if (["room_not_found", "room_ended", "version_closed", "no_resume"].includes(code)) return "noRoom";\n if (["room_full", "role_full"].includes(code)) return "full";\n if (code === "replaced") return "replaced";\n if (code === "no_match") return "noMatch";\n if (code === "invalid_code") return "invalidCode";\n if (["offline", "timeout"].includes(code)) return "offline";\n if (code.startsWith("role_") || ["not_in_lobby", "not_host", "session_replaced"].includes(code)) return "refused";\n if (code === "save_failed") return "saveFailed";\n return "error";\n}\n\n// src/overlay/ui-model.ts\nfunction phase(session) {\n if (!session || session.kind === "boot") return "boot";\n if (session.kind === "attaching" || session.kind === "matching") return session.kind;\n if (session.room && ["closed", "replaced"].includes(session.room.connection)) return "error";\n if (session.kind === "local") return session.localStatus === "ended" ? "ended" : "playing";\n if (session.room?.status === "ended" || session.room?.status === "finished") return "ended";\n if (session.kind === "watch") return "watching";\n if (session.kind === "room" && session.room) return session.room.status;\n return "home";\n}\nfunction initialUi(manifest) {\n return { session: null, panel: "auto", mode: manifest.modes[0]?.id ?? "", busy: false, error: null, notice: null, shortcutEnabled: true };\n}\nfunction reduceUi(model, action) {\n switch (action.type) {\n case "session": {\n const next = action.session, changed = next?.id !== model.session?.id || next === null;\n const pending = next?.kind === "attaching" || next?.kind === "matching";\n const nextPhase = phase(next), transition = phase(model.session) !== nextPhase;\n const automatic = transition && ["lobby", "countdown", "playing", "ended"].includes(nextPhase) && [null, "auto", "room", "invite", "home"].includes(model.panel);\n return {\n ...model,\n session: next,\n mode: next?.mode ?? model.mode,\n panel: changed || pending || automatic ? "auto" : model.panel,\n error: changed ? null : model.error,\n notice: changed ? null : model.notice\n };\n }\n case "panel":\n return { ...model, panel: action.panel, error: null, notice: null };\n case "mode":\n return { ...model, mode: action.mode, error: null };\n case "busy":\n return { ...model, busy: action.busy };\n case "error":\n return { ...model, error: action.code, busy: false };\n case "notice":\n return { ...model, notice: action.notice };\n case "shortcut":\n return { ...model, shortcutEnabled: action.enabled };\n }\n}\nfunction visiblePanel(model) {\n const current = phase(model.session);\n if (current === "boot" || current === "attaching" || current === "matching" || current === "error") return current;\n if (model.panel !== "auto") return model.panel;\n return current === "home" ? "home" : current === "lobby" ? "room" : current === "countdown" ? "countdown" : null;\n}\nfunction primaryAction(manifest, mode) {\n const selected = manifest.modes.find((item) => item.id === mode);\n if (!selected) return null;\n return {\n op: selected.execution === "local" ? "local.start" : "room.create",\n friends: selected.execution === "room" && risolviModalita(manifest, mode).players.max > 1\n };\n}\nfunction startReason(manifest, session) {\n const room = session?.room;\n if (!room || session.kind !== "room" || room.status !== "lobby" || room.connection !== "connected") return "unavailable";\n const connected = room.players.filter((p) => p.connected), active = connected.filter((p) => p.role !== "spectator");\n if (active.length < room.limits.min) return "needPlayers";\n if (connected.some((p) => !p.ready)) return "needReady";\n if (manifest.roles.some((role) => active.filter((p) => p.role === role.id).length < role.min)) return "needRoles";\n if (manifest.teams && (active.some((p) => p.team === null) || new Set(active.map((p) => p.team)).size < manifest.teams.min)) return "needTeams";\n return room.host !== room.you ? "waitHost" : null;\n}\nfunction canPlayAgain(session) {\n if (phase(session) !== "ended") return false;\n if (session?.kind === "local") return true;\n if (session?.room?.status === "finished") return session.kind === "room" && session.room.connection === "connected" && session.room.players.some((p) => p.id === session.room.you && p.connected && p.role !== "spectator" && !p.ready);\n return session?.kind === "room" && (!session.room?.lobby || session.room.host === session.room.you);\n}\nfunction normalizeInvite(code) {\n const value = code.toUpperCase().replace(/[\\s-]/g, "");\n return /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/.test(value) ? value : null;\n}\n\n// src/overlay/styles.ts\nvar styles = `\n.safe-area-probe{position:fixed;visibility:hidden;pointer-events:none;padding:env(safe-area-inset-top,0px) env(safe-area-inset-right,0px) env(safe-area-inset-bottom,0px) env(safe-area-inset-left,0px)}\n:host{all:initial;position:fixed;inset:0;z-index:10000;pointer-events:none;font:15px/1.45 system-ui,sans-serif;color:#f4f4f1;color-scheme:dark;--accent:#a8efc5}\n[data-layout],[data-surface],.sr{pointer-events:none}*{box-sizing:border-box}button,input,select{font:inherit}button,a,input,select{touch-action:manipulation}button,select,input{border:1px solid #ffffff30;background:#25292b;color:inherit;border-radius:12px;min-height:44px;padding:10px 14px}button{cursor:pointer}button:disabled{opacity:.45;cursor:default}button:hover:not(:disabled){background:#343b3a}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid var(--accent);outline-offset:3px}a{color:var(--accent)}.primary{background:var(--accent);color:var(--accent-ink,#11221b);border-color:transparent;font-weight:700}.primary:hover:not(:disabled){filter:brightness(1.1);background:var(--accent)}.quiet{background:transparent}label{display:grid;gap:6px;text-align:left}select,input{width:100%;min-width:0}h1,h2,p{margin:0}h1{font-size:clamp(26px,5vw,42px);line-height:1.1;letter-spacing:-.035em}h2{font-size:20px}small,.muted{color:#bdc5c1}.stack{display:grid;gap:16px}.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.row>*{flex:0 1 auto}.row .grow,.grow{flex:1}.split{display:grid;grid-template-columns:1fr 1fr;gap:10px}.pill{position:absolute;top:max(10px,env(safe-area-inset-top));right:max(10px,env(safe-area-inset-right));display:flex;height:44px;border:1px solid #ffffff35;border-radius:24px;background:#171e20eF;box-shadow:0 4px 20px #0004;pointer-events:auto;overflow:hidden}.pill button{border:0;border-radius:0;padding:8px 13px;background:transparent}.pill button:focus-visible{outline-offset:-4px}.pill small{margin-left:8px}.backdrop{position:absolute;inset:0;background:#0b151ce8;backdrop-filter:blur(10px);pointer-events:auto;display:grid;place-items:center;padding:max(16px,env(safe-area-inset-top)) max(16px,env(safe-area-inset-right)) max(16px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left));overflow:auto}.backdrop.home{background-color:#142127;background-size:cover;background-position:center}.dialog{position:relative;width:min(100%,540px);max-height:100%;overflow:auto;overscroll-behavior:contain;background:#141b1df5;border:1px solid #ffffff25;border-radius:22px;padding:24px;box-shadow:0 20px 80px #0005}.dialog.wide{width:min(100%,700px)}.top{display:flex;align-items:center;gap:12px;margin-bottom:18px}.top h2{flex:1}.tabs{display:flex;gap:6px;flex-wrap:wrap;border-bottom:1px solid #ffffff25;padding-bottom:12px}.tabs button{min-height:36px;padding:6px 10px}.tabs [aria-current=true]{border-color:var(--accent)}.roster{list-style:none;padding:0;margin:0;display:grid;gap:8px;max-height:32dvh;overflow:auto}.roster li{display:flex;align-items:center;gap:8px;padding:10px;background:#ffffff08;border-radius:10px}.roster .name{flex:1;overflow-wrap:anywhere}.badge{border:1px solid #ffffff30;border-radius:6px;padding:2px 6px;font-size:12px}.code{font-size:24px;letter-spacing:.13em;font-variant-numeric:tabular-nums}.notice,.error{border-radius:10px;padding:10px;background:#a8efc514;overflow-wrap:anywhere}.error{background:#ff8b7720;color:#ffd2c9}.countdown{font-size:88px;line-height:1;text-align:center;font-variant-numeric:tabular-nums}.ended{position:absolute;bottom:max(12px,env(safe-area-inset-bottom));left:50%;transform:translateX(-50%);max-width:calc(100% - 24px);width:max-content;background:#171e20f5;pointer-events:auto;border:1px solid #ffffff30;border-radius:16px;padding:10px;display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap}.ended [data-rematch-players]{max-width:100%;max-height:3.2em;overflow:auto;overflow-wrap:anywhere}.ended strong{padding:0 8px}.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.table-wrap{overflow:auto;max-height:38dvh}table{width:100%;border-collapse:collapse;text-align:left}th,td{padding:9px 6px;border-bottom:1px solid #ffffff20}td:nth-child(3){text-align:right}td small{display:block}.self{background:#ffffff0a}.checkbox{display:flex;align-items:center;gap:8px;font-size:13px}.checkbox input{width:18px;min-height:18px}.full{width:100%}\n[hidden]{display:none!important}.voice-peers{list-style:none;margin:0;padding:0;display:grid;gap:10px}.voice-peers li{border:1px solid #ffffff25;border-radius:12px;padding:12px;display:grid;gap:8px}.voice-peers [data-speaking=true]{border-color:var(--accent)}.voice-peers input{width:100%;accent-color:var(--accent);padding:0}.voice-peers label{font-size:13px}.pill .voice-toggle{width:44px;padding:8px}.voice-toggle[data-voice-state=on][data-muted=false]{color:var(--accent)}\n.boot{position:absolute;inset:0;z-index:2;isolation:isolate;display:grid;place-items:center;overflow:auto;overscroll-behavior:contain;padding:max(100px,env(safe-area-inset-top)) max(24px,env(safe-area-inset-right)) max(48px,env(safe-area-inset-bottom)) max(24px,env(safe-area-inset-left));background:#0b151c;opacity:1;transition:opacity .4s ease;pointer-events:auto;outline:none}\n.boot::before,.boot::after{content:"";position:fixed;inset:0;pointer-events:none;z-index:-1}.boot::before{background:radial-gradient(ellipse at 50% 38%,color-mix(in srgb,var(--accent),transparent 80%),transparent 65%)}.boot::after{background:radial-gradient(ellipse at 50% 38%,#0b151c20,#0b151cd9 85%),linear-gradient(#0b151c66,#0b151cbf)}\n.boot-cover{position:fixed;inset:0;z-index:-2;width:100%;height:100%;object-fit:cover;filter:blur(20px);transform:scale(1.08);opacity:.65;pointer-events:none}\n.boot-brand{position:absolute;top:max(28px,env(safe-area-inset-top));left:max(32px,env(safe-area-inset-left));display:flex;align-items:center;gap:10px;font-size:14px;font-weight:650;letter-spacing:.02em;color:#f4f4f1b3}.boot-brand span{display:grid;place-items:center;width:36px;height:36px;border:1px solid #ffffff25;border-radius:12px;background:#171e20af;box-shadow:0 4px 20px #0004;color:var(--boot-accent);font-size:20px;font-weight:800}\n.boot-content{width:min(100%,900px);text-align:center;display:grid;justify-items:center;gap:24px}.boot h1{max-width:16ch;font-size:clamp(44px,8vw,108px);font-weight:800;line-height:1.04;letter-spacing:-.05em;overflow-wrap:anywhere;text-wrap:balance;color:var(--boot-accent);text-shadow:0 20px 80px #0005}\n.boot-progress{width:112px;height:3px;border-radius:12px;background:#ffffff20;overflow:hidden;margin-top:12px}.boot-progress span{display:block;width:44%;height:100%;border-radius:inherit;background:var(--boot-accent);animation:boot-progress 1.8s ease-in-out infinite}.boot-status{max-width:42ch;min-height:3em;font-size:14px;line-height:1.5;color:#d3dad6;text-wrap:balance}.boot-recovery{min-height:44px}.boot-recovery .row{justify-content:center}.boot-leaving{opacity:0;pointer-events:none}\n@keyframes boot-progress{0%{transform:translateX(-110%)}100%{transform:translateX(340%)}}\n@media(max-width:480px){.dialog{padding:18px;border-radius:18px}.split{grid-template-columns:1fr 1fr;gap:8px}.tabs{gap:4px}.tabs button{font-size:13px;padding:6px 8px}.pill button:focus-visible{outline-offset:-4px}.pill small{display:none}.ended{gap:6px}.ended strong{font-size:13px}.ended button{padding:8px 10px;font-size:13px}.roster{max-height:28dvh}}\n@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto}.boot{transition:none}.boot-progress span{animation:none;transform:translateX(65%)}}\n`;\n\n// src/overlay/voice-panel.ts\nfunction voiceEligible(manifest, session) {\n return manifest.voice !== "none" && session?.kind !== "watch" && session?.room?.players.find((player) => player.id === session.room?.you)?.role !== "spectator";\n}\nfunction voiceStatus(voice, t) {\n const key = voice.state === "joining" ? "voiceJoining" : voice.state === "reconnecting" ? "reconnecting" : voice.state === "off" ? "voiceOff" : "voiceOn";\n return t(key);\n}\nfunction updateVoicePanel(container, input) {\n const { session, t } = input, voice = session?.kind === "room" ? session.voice : null;\n if (!voiceEligible(input.manifest, session) || !voice) {\n container.replaceChildren();\n const note = container.ownerDocument.createElement("p");\n note.textContent = t(session?.kind === "watch" || session?.room?.players.find((p) => p.id === session.room?.you)?.role === "spectator" ? "voiceWatch" : "voiceUnavailable");\n container.append(note);\n return;\n }\n if (!container.querySelector("[data-voice-status]")) container.innerHTML = `<p role="status" aria-live="polite" data-voice-status></p><p data-voice-self></p>\n <div class="row"><button type="button" data-action="voice-join"></button><button type="button" data-action="voice-mute"></button><button type="button" data-action="voice-leave"></button></div>\n <p class="error" role="alert" data-voice-error hidden></p><h3 data-voice-heading></h3><ul class="voice-peers" data-voice-peers></ul><p class="muted" data-voice-empty></p>`;\n const get = (selector) => container.querySelector(selector);\n const status = get("[data-voice-status]");\n status.textContent = voiceStatus(voice, t);\n status.dataset.voiceState = voice.state;\n const mic = (value) => t(!value.mic ? "voiceListening" : value.muted ? "voiceMuted" : value.speaking ? "voiceSpeaking" : "voiceMic");\n get("[data-voice-self]").textContent = voice.state === "off" ? "" : `${t("you")}: ${mic(voice)}`;\n const join = get(\'[data-action="voice-join"]\'), mute = get(\'[data-action="voice-mute"]\'), leave = get(\'[data-action="voice-leave"]\');\n join.textContent = t("voiceJoin");\n join.hidden = voice.state !== "off";\n join.disabled = session?.room?.connection !== "connected" || input.pending === "voice.join";\n mute.textContent = t(voice.muted ? "voiceUnmute" : "voiceMute");\n mute.hidden = voice.state !== "on" || !voice.mic;\n mute.disabled = input.pending === "voice.mute";\n mute.setAttribute("aria-pressed", String(voice.muted));\n leave.textContent = t("voiceLeave");\n leave.hidden = voice.state === "off" && input.pending !== "voice.join";\n leave.disabled = input.pending === "voice.leave";\n const error = get("[data-voice-error]");\n error.hidden = !input.error;\n error.textContent = input.error ? t(errorText(input.error)) : "";\n get("[data-voice-heading]").textContent = t("voicePeers");\n get("[data-voice-empty]").textContent = t("voiceEmpty");\n get("[data-voice-empty]").hidden = voice.peers.length > 0;\n const list = get("[data-voice-peers]"), ids = new Set(voice.peers.map((peer) => peer.id));\n for (const row of list.querySelectorAll("[data-voice-peer]")) if (!ids.has(row.dataset.voicePeer)) row.remove();\n for (const peer of voice.peers) {\n let row = [...list.children].find((node) => node.dataset.voicePeer === peer.id);\n if (!row) {\n row = container.ownerDocument.createElement("li");\n row.dataset.voicePeer = peer.id;\n row.innerHTML = \'<div class="row"><strong data-peer-name></strong><small data-peer-status></small></div><label><span data-volume-label></span><input type="range" min="0" max="1" step="0.05" data-control="voice-volume"></label>\';\n row.querySelector("input").dataset.peer = peer.id;\n list.append(row);\n }\n const name = session?.room?.players.find((player) => player.id === peer.id)?.name ?? peer.id;\n row.dataset.mic = String(peer.mic);\n row.dataset.muted = String(peer.muted);\n row.dataset.speaking = String(peer.speaking);\n row.querySelector("[data-peer-name]").textContent = name;\n row.querySelector("[data-peer-status]").textContent = mic(peer);\n row.querySelector("[data-volume-label]").textContent = t("voiceVolume", { name });\n const range = row.querySelector("input");\n if (range.dataset.editing !== "true") range.value = String(peer.volume);\n range.setAttribute("aria-valuetext", `${Math.round(Number(range.value) * 100)}%`);\n range.disabled = voice.state !== "on";\n }\n}\n\n// src/overlay/ui.ts\nvar escape = (value) => String(value ?? "").replace(/[&<>"\']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", \'"\': "&quot;", "\'": "&#39;" })[c]);\nfunction mountOverlay(input) {\n const manifest = input.configuration.manifest;\n if (manifest.overlay?.version !== 1) return null;\n const document = input.container.ownerDocument, win = document.defaultView, t = translator(input.language);\n const host = document.createElement("div");\n host.dataset.caisualOverlay = "";\n host.lang = overlayLanguage(input.language);\n host.style.setProperty("pointer-events", "none", "important");\n const root = host.attachShadow({ mode: "open" });\n if (typeof win.CSSStyleSheet?.prototype.replaceSync === "function" && "adoptedStyleSheets" in root) {\n const sheet = new win.CSSStyleSheet();\n sheet.replaceSync(styles);\n root.adoptedStyleSheets = [sheet];\n } else {\n const sheet = document.createElement("link");\n sheet.rel = "stylesheet";\n sheet.href = "/__caisual/overlay/v1.css";\n root.append(sheet);\n }\n const elements = document.createElement("div");\n elements.dataset.layout = "";\n elements.style.pointerEvents = "none";\n elements.innerHTML = `<div data-surface></div><div class="sr" role="status" aria-live="polite" data-live></div>`;\n const safeProbe = document.createElement("div");\n safeProbe.className = "safe-area-probe";\n safeProbe.setAttribute("aria-hidden", "true");\n root.append(elements, safeProbe);\n const surface = root.querySelector("[data-surface]"), live = root.querySelector("[data-live]");\n surface.style.pointerEvents = "none";\n live.style.pointerEvents = "none";\n const accent = manifest.overlay.accent ?? "#a8efc5";\n host.style.setProperty("--accent", accent);\n const rgb = [1, 3, 5].map((i) => parseInt(accent.slice(i, i + 2), 16) / 255).map((v) => v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);\n const luminance = rgb[0] * 0.2126 + rgb[1] * 0.7152 + rgb[2] * 0.0722;\n host.style.setProperty("--accent-ink", luminance > 0.179 ? "#000000" : "#ffffff");\n host.style.setProperty("--boot-accent", luminance > 0.179 ? accent : `color-mix(in srgb, ${accent}, #ffffff 70%)`);\n input.container.append(host);\n let model = initialUi(manifest), disposed = false, operation = 0, lastView = "", geometryFrame = 0;\n let lastPhase = "", wasModal = false, copyFallback = null;\n let boot = null, bootTimer = 0, bootFadeTimer = 0;\n let voiceError = null, voicePending = null, voiceOperation = 0;\n let codeDraft = input.configuration.invite ?? "";\n const oldInert = Boolean(input.frame.inert), oldTabIndex = input.frame.getAttribute("tabindex");\n try {\n model.shortcutEnabled = win.localStorage.getItem("caisual-overlay-shortcut-v1") !== "off";\n } catch {\n }\n const boards = input.boards ? createBoardController({ manifest, player: input.player.id, guests: input.player.guest, read: input.boards, changed: () => render() }) : null;\n const stops = [];\n const selectedMode = () => manifest.modes.find((mode) => mode.id === model.mode);\n const disabled = () => model.busy ? " disabled" : "";\n const button = (action, key, extra = "", off = false) => `<button type="button" data-action="${action}"${extra}${off || model.busy ? " disabled" : ""}>${t(key)}</button>`;\n function resetBootWait() {\n win.clearTimeout(bootTimer);\n if (!boot || phase(model.session) !== "boot") return;\n boot.querySelector("[data-boot-message]").textContent = t("loading");\n boot.querySelector("[data-boot-recovery]").hidden = true;\n bootTimer = win.setTimeout(() => {\n bootTimer = 0;\n if (disposed || !boot) return;\n boot.querySelector("[data-boot-message]").textContent = t("loadingSlow");\n boot.querySelector("[data-boot-recovery]").hidden = false;\n }, 9e3);\n }\n function updateBoot(loading) {\n if (loading) {\n if (boot) {\n win.clearTimeout(bootFadeTimer);\n bootFadeTimer = 0;\n boot.inert = false;\n boot.removeAttribute("aria-hidden");\n boot.style.pointerEvents = "auto";\n boot.classList.remove("boot-leaving");\n return;\n }\n boot = document.createElement("section");\n boot.className = "boot";\n boot.tabIndex = -1;\n boot.setAttribute("aria-labelledby", "boot-title");\n boot.setAttribute("aria-describedby", "boot-status");\n boot.style.pointerEvents = "auto";\n boot.innerHTML = `<div class="boot-brand" aria-hidden="true"><span>C</span>Caisual</div>\n <div class="boot-content"><h1 id="boot-title">${escape(manifest.name)}</h1>\n <div class="boot-progress" aria-hidden="true"><span></span></div>\n <p class="boot-status" id="boot-status" role="status" aria-live="polite" aria-atomic="true"><span class="sr">${escape(manifest.name)}. </span><span data-boot-message></span></p>\n <div class="boot-recovery"><div class="row" data-boot-recovery hidden>${button("reload", "retry", \' class="primary"\')}${button("exit-now", "exit", \' class="quiet"\')}</div></div>\n </div>`;\n if (input.configuration.coverUrl) {\n const cover = document.createElement("img");\n cover.className = "boot-cover";\n cover.alt = "";\n cover.setAttribute("aria-hidden", "true");\n cover.addEventListener("error", () => {\n cover.hidden = true;\n }, { once: true });\n cover.src = input.configuration.coverUrl;\n boot.prepend(cover);\n }\n elements.append(boot);\n resetBootWait();\n } else if (boot && !boot.inert) {\n boot.inert = true;\n boot.setAttribute("aria-hidden", "true");\n boot.style.pointerEvents = "none";\n boot.classList.add("boot-leaving");\n const remove = () => {\n win.clearTimeout(bootTimer);\n bootTimer = 0;\n boot?.remove();\n boot = null;\n bootFadeTimer = 0;\n };\n if (win.matchMedia?.("(prefers-reduced-motion: reduce)").matches) remove();\n else bootFadeTimer = win.setTimeout(remove, 420);\n }\n }\n const dispatch = (action) => {\n if (disposed) return;\n model = reduceUi(model, action);\n render();\n };\n const announce = (text) => {\n if (live.textContent !== text) live.textContent = text;\n };\n const controls = () => [...root.querySelectorAll(\'button:not(:disabled),a[href],input:not(:disabled),select:not(:disabled),[tabindex="0"]\')].filter((el) => !el.closest("[hidden]"));\n const roomCode = () => model.session?.room?.code ?? null;\n const setPanel = (panel) => {\n if (panel === "boards" && boards && !boards.state.query) {\n const id = Object.keys(manifest.boards)[0];\n if (id) boards.select({ board: id, period: (manifest.boards[id].periods ?? ["all-time"])[0], guests: input.player.guest });\n }\n copyFallback = null;\n dispatch({ type: "panel", panel });\n };\n const close = () => {\n const current = phase(model.session), panel = visiblePanel(model);\n if (current === "boot") return;\n if (current === "home") setPanel("home");\n else if (current === "lobby" && panel !== "room") setPanel("room");\n else setPanel(null);\n };\n const toggle = () => {\n if (visiblePanel(model)) close();\n else setPanel(model.session?.room ? "room" : "home");\n };\n async function perform(op, args, after) {\n const token = ++operation;\n dispatch({ type: "error", code: null });\n dispatch({ type: "busy", busy: true });\n try {\n await input.bridge.request(op, args);\n if (token === operation && !disposed) await after?.();\n } catch (error) {\n if (token === operation && !disposed && error.code !== "cancelled") dispatch({ type: "error", code: error.code ?? "offline" });\n } finally {\n if (token === operation && !disposed) dispatch({ type: "busy", busy: false });\n }\n }\n function updateVoice() {\n const container = root.querySelector("[data-voice-panel]");\n if (container) updateVoicePanel(container, { manifest, session: model.session, t, error: voiceError, pending: voicePending });\n const toggle2 = root.querySelector("[data-voice-toggle]"), voice = model.session?.voice;\n if (toggle2) {\n toggle2.dataset.voiceState = voice?.state ?? "off";\n toggle2.dataset.muted = String(voice?.muted ?? false);\n toggle2.setAttribute("aria-label", `${t("voice")}: ${voice ? voiceStatus(voice, t) : t("voiceOff")}`);\n toggle2.textContent = voice?.state === "on" && !voice.muted ? "\\u25CF" : "\\u25CB";\n }\n }\n async function performVoice(op, args) {\n const sessionId = model.session?.id, epoch = input.bridge.epoch, volume = op === "voice.setVolume";\n const token = volume ? voiceOperation : ++voiceOperation;\n const current = () => !disposed && model.session?.id === sessionId && input.bridge.epoch === epoch && token === voiceOperation;\n voiceError = null;\n if (!volume) voicePending = op;\n updateVoice();\n try {\n await input.bridge.request(op, args);\n } catch (error) {\n if (current()) voiceError = error.code ?? "voice_error";\n } finally {\n if (current()) {\n if (!volume) voicePending = null;\n updateVoice();\n }\n }\n }\n async function copyInvite() {\n const code = roomCode();\n if (!code) return;\n const url = input.inviteUrl(code);\n try {\n await win.navigator.clipboard.writeText(url);\n dispatch({ type: "notice", notice: t("copied") });\n } catch {\n copyFallback = url;\n render();\n root.querySelector(\'input[data-control="invite-link"]\')?.select();\n }\n }\n function invitation() {\n const code = roomCode();\n if (!code) return `<p>${t("noRoom")}</p>`;\n return `<div class="row"><div class="grow"><small>${t("code")}</small><div class="code" data-room-code>${escape(code)}</div></div>${button("copy", "copy")}</div>${copyFallback ? `<label>${t("copyFailed")}<input data-control="invite-link" readonly value="${escape(copyFallback)}"></label>` : ""}`;\n }\n function navigation(panel) {\n const items = [];\n if (model.session?.room) items.push(["room", "room"], ["invite", "copy"]);\n items.push(["friends", "friends"]);\n if (Object.keys(manifest.boards).length) items.push(["boards", "boards"]);\n if (voiceEligible(manifest, model.session)) items.push(["voice", "voice"]);\n return `<nav class="tabs" aria-label="Caisual">${items.map(([id, key]) => button(`panel:${id}`, key, ` aria-current="${id === panel}"`)).join("")}</nav>`;\n }\n function home() {\n const selected = selectedMode(), action = primaryAction(manifest, model.mode), session = model.session;\n const hasRooms = manifest.modes.some((mode) => mode.execution === "room");\n return `<h1>${escape(manifest.name)}</h1><p class="muted" data-game-languages>${t("gameLanguages")}: ${escape(manifestLanguages(manifest).join(" \\xB7 "))}</p><label>${t("mode")}<select data-control="mode"${disabled()}>${manifest.modes.map((mode) => `<option value="${escape(mode.id)}"${mode.id === model.mode ? " selected" : ""}>${escape(risolviPresentazione(manifest, mode.id, input.language).label)}</option>`).join("")}</select></label>\n ${selected?.instructions ? `<p class="muted">${escape(resolveText(selected.instructions, input.language, manifestLanguages(manifest)[0]))}</p>` : ""}\n ${input.configuration.invite && phase(session) === "home" ? button("join-invite", "joinInvite", \' class="primary"\', !session?.ready) : ""}\n ${action ? button("play", action.friends ? "friendsPlay" : "play", \' class="primary"\', !session?.ready) : ""}\n ${selected?.matchmaking ? button("match", "find", "", !selected.matchmaking.defaults || !session?.ready) : ""}\n ${session?.resume ? button("resume", "resume", "", !session.ready) + `<small>${escape(session.resume.code)}</small>` : ""}\n ${hasRooms ? `<div class="split">${button("panel:join", "join", "", !session?.ready)}${manifest.spectators ? button("panel:watch", "watch", "", !session?.ready) : ""}</div>` : ""}\n ${navigation("home")}<label class="checkbox"><input type="checkbox" data-control="shortcut"${model.shortcutEnabled ? " checked" : ""}>${t("shortcut")}</label>`;\n }\n function room() {\n const session = model.session, room2 = session?.room;\n if (!room2) return `<p>${t("noRoom")}</p>`;\n const own = room2.players.find((player) => player.id === room2.you), lobby = room2.status === "lobby" && session?.kind === "room";\n const canRole = session?.kind === "room" && (lobby || room2.status === "playing" && room2.requestRole);\n const reason = startReason(manifest, session);\n return `${invitation()}<ul class="roster" aria-label="${t("room")}">${room2.players.map((p) => `<li data-player-id="${escape(p.id)}"><span class="name">${escape(p.name)} ${p.id === room2.you ? `<small>(${t("you")})</small>` : ""}</span>${p.id === room2.host ? `<span class="badge">${t("host")}</span>` : ""}${p.role ? `<small>${escape(resolveText(manifest.roles.find((r) => r.id === p.role)?.label, input.language, manifestLanguages(manifest)[0], p.role))}</small>` : ""}${p.team ? `<small>${t("team")} ${p.team}</small>` : ""}<small>${!p.connected ? t("away") : lobby ? t(p.ready ? "ready" : "unready") : ""}</small></li>`).join("")}</ul>\n ${canRole && manifest.roles.length ? `<label>${t("role")}<select data-control="role"${disabled()}><option value="" disabled${!own?.role ? " selected" : ""}>${t("role")}</option>${manifest.roles.map((role) => `<option value="${escape(role.id)}"${role.id === own?.role ? " selected" : ""}>${escape(resolveText(role.label, input.language, manifestLanguages(manifest)[0], role.id))}</option>`).join("")}</select></label>` : ""}\n ${lobby && manifest.teams ? `<label>${t("team")}<select data-control="team"${disabled()}><option value="" disabled${!own?.team ? " selected" : ""}>${t("team")}</option>${Array.from({ length: manifest.teams.max }, (_, i) => `<option value="${i + 1}"${own?.team === i + 1 ? " selected" : ""}>${t("team")} ${i + 1}</option>`).join("")}</select></label>` : ""}\n ${lobby ? `<div class="row">${button("ready", own?.ready ? "unready" : "ready", \' class="primary"\', room2.connection !== "connected")}${room2.host === room2.you ? button("start", "start", "", reason !== null) : ""}</div>${reason ? `<p class="muted" data-start-reason>${t(reason)}</p>` : ""}` : ""}\n ${session?.kind === "watch" ? `<p>${t("watching")} \\xB7 ${t("delay", { n: (room2.delayMs ?? 0) / 1e3 })}</p>` : ""}\n ${navigation("room")}<label class="checkbox"><input type="checkbox" data-control="shortcut"${model.shortcutEnabled ? " checked" : ""}>${t("shortcut")}</label>${button("panel:exit", "exit", \' class="quiet"\')}`;\n }\n function crew() {\n const provider = input.crew, state = provider?.getSnapshot();\n if (!provider || provider.unavailable || !state?.you) return `<p>${t(provider?.unavailable === "local" ? "localCrew" : "loginCrew")}</p>`;\n const online = state.friends.filter((friend) => friend.online), party = state.party;\n const person = (p) => `<li><span class="name">${escape(p.name)}<small>${p.game ? ` \\xB7 ${escape(p.game.name)}` : ""}</small></span>${p.room && p.game ? button("follow", "follow", ` data-code="${escape(p.room.code)}" data-game="${escape(p.game.slug)}"`) : ""}${party?.leader === state.you.id && !party.members.some((member) => member.id === p.id) ? button("party-invite", "inviteParty", ` data-player="${escape(p.id)}"`) : ""}</li>`;\n return `${!state.connected ? `<p>${t("reconnecting")}</p>` : ""}${party ? `<ul class="roster">${party.members.map(person).join("")}</ul>${button("party-leave", "leaveParty")}` : button("party-create", "createParty")}\n ${state.invites.map((invite) => `<div class="row"><span class="grow">${escape(invite.from.name)}</span>${button("party-accept", "accept", ` data-party="${escape(invite.party)}"`)}${button("party-decline", "decline", ` data-party="${escape(invite.party)}"`)}</div>`).join("")}\n ${state.follow ? `<div class="row"><span class="grow">${escape(state.follow.from.name)} \\xB7 ${escape(state.follow.game.name)}</span>${button("follow", "follow", ` data-code="${escape(state.follow.code)}" data-game="${escape(state.follow.game.slug)}"`)}</div>` : ""}\n <h2>${t("online")}</h2>${online.length ? `<ul class="roster">${online.map(person).join("")}</ul>` : `<p class="muted">${t("noFriends")}</p>`}`;\n }\n function leaderboard() {\n if (!boards || !boards.state.query) return `<p>${t("unavailable")}</p>`;\n const { query, data, loading, error, saving } = boards.state;\n const board = manifest.boards[query.board];\n return `<label>${t("board")}<select data-control="board">${Object.entries(manifest.boards).map(([id, value]) => `<option value="${escape(id)}"${query.board === id ? " selected" : ""}>${escape(resolveText(value.label, input.language, manifestLanguages(manifest)[0], id))}</option>`).join("")}</select></label>\n <div class="split"><label>${t("period")}<select data-control="period">${(board.periods ?? ["all-time"]).map((period) => `<option value="${period}"${query.period === period ? " selected" : ""}>${t(period === "daily" ? "daily" : "allTime")}</option>`).join("")}</select></label><label>${t("category")}<select data-control="category"><option value="accounts"${!query.guests ? " selected" : ""}>${t("accounts")}</option><option value="guests"${query.guests ? " selected" : ""}>${t("guests")}</option></select></label></div>\n ${query.period === "daily" ? `<small data-board-day>${escape(data?.day ?? query.day ?? new Date(input.bridge.serverTime() ?? Date.now()).toISOString().slice(0, 10))}</small>` : ""}\n ${saving ? `<p role="status" data-saving>${t(saving)}</p>` : ""}${error ? `<p role="alert">${t("offline")}</p>` : ""}\n ${data ? `<div class="table-wrap"><table><thead><tr><th>${t("rank")}</th><th>${t(query.guests ? "guests" : "accounts")}</th><th>${t("score")}</th></tr></thead><tbody>${data.entries.map((entry) => `<tr${entry.me ? \' class="self"\' : ""}><td>${entry.rank}</td><td>${escape(entry.name)}${entry.verified ? `<small>${t("verified")}</small>` : ""}</td><td>${entry.score}</td></tr>`).join("")}</tbody></table>${data.entries.length ? "" : `<p>${t("empty")}</p>`}</div><p data-own-score>${t("own")} (${t(data.ownGuest ? "guests" : "accounts")}): ${data.me ? `#${data.me.rank} \\xB7 ${data.me.score}${data.me.verified ? ` \\xB7 ${t("verified")}` : ""}` : t("empty")}</p>` : `<p>${t(loading ? "loading" : "empty")}</p>`}\n ${button("refresh", "refresh", "", loading)}`;\n }\n function content(panel) {\n switch (panel) {\n case "home":\n return home();\n case "room":\n return room();\n case "invite":\n return invitation();\n case "friends":\n return crew();\n case "voice":\n return \'<div class="stack" data-voice-panel></div>\';\n case "boards":\n return leaderboard();\n case "join":\n case "watch":\n return `<form class="stack" data-form="${panel}"><label>${t("code")}<input data-control="code" name="code" autocomplete="off" autocapitalize="characters" spellcheck="false" maxlength="16" value="${escape(codeDraft)}" required></label><button class="primary" type="submit"${disabled()}>${t(panel === "join" ? "join" : "watch")}</button></form>`;\n case "attaching":\n case "matching":\n return `<p role="status">${t(panel === "matching" ? "matching" : "joining")}</p>${model.session?.waiting ? `<p>${t("queue", { n: model.session.waiting.players, max: model.session.waiting.max })}</p>` : ""}<button type="button" data-action="cancel">${t("cancel")}</button>`;\n case "countdown":\n return `<p>${t("starting")}</p><div class="countdown" data-countdown></div>`;\n case "boot":\n return "";\n case "error":\n return `<p role="alert">${t(model.session?.room?.connection === "replaced" ? "replaced" : "noRoom")}</p>${button("leave", "home")}${button("exit-now", "exit")}`;\n case "exit":\n return model.session?.kind === "room" && phase(model.session) !== "ended" ? `<p>${t(model.session.room?.persistent ? "leaveHint" : "temporaryHint")}</p>${invitation()}${button("disconnect-exit", "leaveNow", \' class="primary"\')}<p class="muted">${t("abandonHint")}</p>${button("leave-exit", "leaveRoom")}` : button("leave-exit", "exit", \' class="primary"\');\n }\n }\n function title(panel) {\n const keys = { boot: "loading", home: "home", room: "room", invite: "copy", friends: "friends", voice: "voice", boards: "boards", join: "join", watch: "watch", attaching: "joining", matching: "matching", countdown: "starting", error: "error", exit: "exit" };\n return t(keys[panel]);\n }\n function updateCountdown() {\n const at = model.session?.room?.countdownAt, now = input.bridge.serverTime();\n const value = at === null || at === void 0 || now === null ? "..." : String(Math.max(0, Math.round((at - now) / 1e3)));\n const node = root.querySelector("[data-countdown]");\n if (node && node.textContent !== value) {\n node.textContent = value;\n announce(`${t("starting")} ${value}`);\n }\n }\n function geometry() {\n geometryFrame = 0;\n if (disposed || !input.bridge.epoch || !model.session) return;\n const frame = gameViewport(input.frame), { scaleX, scaleY } = frame;\n const reservedRects = [...root.querySelectorAll("[data-reserve]")].map((el) => {\n const rect = el.getBoundingClientRect(), left = Math.max(frame.left, rect.left), top = Math.max(frame.top, rect.top), right = Math.min(frame.right, rect.right), bottom = Math.min(frame.bottom, rect.bottom);\n return { x: Math.max(0, Math.round((left - frame.left) * scaleX)), y: Math.max(0, Math.round((top - frame.top) * scaleY)), width: Math.max(0, Math.round((right - left) * scaleX)), height: Math.max(0, Math.round((bottom - top) * scaleY)) };\n }).filter((rect) => rect.width && rect.height).slice(0, 8);\n const view = { inputBlocked: phase(model.session) === "boot" || !!visiblePanel(model), reservedRects, safeArea: measureSafeArea(input.frame, safeProbe), shortcutEnabled: model.shortcutEnabled };\n const serialized = `${input.bridge.epoch}:${JSON.stringify(view)}`;\n if (lastView === serialized) return;\n lastView = serialized;\n void input.bridge.request("overlay.view", view).catch(async (error) => {\n if (error?.code === "invalid_request" && lastView === serialized) {\n const { safeArea, ...legacy } = view;\n try {\n await input.bridge.request("overlay.view", legacy);\n return;\n } catch {\n }\n }\n if (lastView === serialized) lastView = "";\n });\n }\n function resize() {\n if (!geometryFrame) geometryFrame = win.requestAnimationFrame(geometry);\n }\n function rematchBar() {\n const session = model.session, room2 = session?.room;\n if (!room2 || room2.status !== "finished") return "";\n const active = room2.players.filter((p) => p.connected && p.role !== "spectator");\n const ready = active.filter((p) => p.ready), host2 = session.kind === "room" && room2.host === room2.you;\n const canStart = room2.connection === "connected" && active.length >= room2.limits.min && ready.length === active.length;\n return `<small role="status" data-rematch-ready>${t("rematchReady", { n: ready.length, max: active.length })}</small>\n ${ready.length ? `<small data-rematch-players>${ready.map((p) => escape(p.name)).join(", ")}</small>` : ""}\n ${host2 ? button("restart", "rematchStart", \' class="primary"\', !canStart) : `<small>${t("waitHost")}</small>`}`;\n }\n function render() {\n if (disposed) return;\n const panel = visiblePanel(model), current = phase(model.session), room2 = model.session?.room;\n const focused = root.activeElement;\n const focusPeer = focused?.dataset.peer;\n const focusKey = focused?.dataset.control ? ["control", focused.dataset.control] : focused?.dataset.action ? ["action", focused.dataset.action] : null;\n const previousScroll = root.querySelector(".dialog")?.scrollTop ?? 0;\n const selection = focused?.tagName === "INPUT" ? { start: focused.selectionStart, end: focused.selectionEnd } : null;\n const crewState = input.crew?.getSnapshot(), invitations = (crewState?.invites.length ?? 0) + (crewState?.follow ? 1 : 0);\n const label = current === "watching" ? t("watching") : room2?.connection === "reconnecting" ? t("reconnecting") : room2?.code ?? "Caisual";\n surface.innerHTML = current === "boot" ? "" : `<div class="pill" data-reserve><button type="button" data-action="menu" aria-label="${t("menu")}" aria-expanded="${!!panel}">C<span aria-hidden="true"><small>${escape(label)}</small></span></button>${voiceEligible(manifest, model.session) && model.session?.kind === "room" ? `<button type="button" class="voice-toggle" data-voice-toggle data-action="panel:voice"></button>` : ""}${invitations ? `<button type="button" data-action="panel:friends" aria-label="${t("friends")} (${invitations})">${invitations}</button>` : ""}</div>\n ${current === "ended" && !panel ? `<div class="ended" data-reserve role="region" aria-label="${t("ended")}"><strong>${t("ended")}</strong>${boards?.state.saving ? `<small role="status" data-saving>${t(boards.state.saving)}</small>` : ""}${canPlayAgain(model.session) ? button("again", "again", \' class="primary"\') : model.session?.kind === "room" && room2?.status !== "finished" ? `<small>${t("waitHost")}</small>` : ""}${rematchBar()}${Object.keys(manifest.boards).length ? button("panel:boards", "boards") : ""}${button("panel:home", "homeMenu")}</div>` : ""}\n ${panel ? `<div class="backdrop${panel === "home" ? " home" : ""}"><section class="dialog${panel === "boards" || panel === "friends" ? " wide" : ""}" role="dialog" aria-modal="true" aria-labelledby="panel-title" tabindex="-1"><div class="top"><h2 id="panel-title">${title(panel)}</h2><button type="button" data-action="close" aria-label="${t("close")}">\\xD7</button></div><div class="stack">${content(panel)}${model.error ? `<p class="error" role="alert" data-error>${t(errorText(model.error))}</p>` : ""}${model.session?.resumeError ? `<p class="error" role="alert">${t("saveFailed")}</p>` : ""}${model.notice ? `<p class="notice" role="status">${escape(model.notice)}</p>` : ""}</div></section></div>` : ""}`;\n for (const element of surface.querySelectorAll(".pill,.backdrop,.ended")) element.style.pointerEvents = "auto";\n const backdrop = root.querySelector(".backdrop.home");\n if (backdrop && input.configuration.coverUrl) backdrop.style.backgroundImage = `linear-gradient(#0b151c99,#0b151cee),url(${JSON.stringify(input.configuration.coverUrl)})`;\n host.dataset.phase = current;\n host.dataset.panel = panel ?? "";\n input.frame.inert = !!panel || oldInert;\n if (panel) input.frame.tabIndex = -1;\n else if (oldTabIndex === null) input.frame.removeAttribute("tabindex");\n else input.frame.setAttribute("tabindex", oldTabIndex);\n updateVoice();\n updateBoot(current === "boot");\n const dialog = root.querySelector(".dialog");\n if (dialog) dialog.scrollTop = previousScroll;\n const focusPanel = current === "boot" ? boot : dialog;\n const matched = focusKey ? [...root.querySelectorAll(`[data-${focusKey[0]}]`)].find((el) => el.getAttribute(`data-${focusKey[0]}`) === focusKey[1] && el.dataset.peer === focusPeer) : null;\n if (!panel && wasModal && !input.frame.inert && input.frame.isConnected) {\n input.frame.focus({ preventScroll: true });\n input.frame.contentWindow?.focus();\n } else if (matched && (!panel || focusPanel?.contains(matched)) && !matched.hasAttribute("disabled")) {\n matched.focus({ preventScroll: true });\n if (matched.tagName === "INPUT" && selection?.start !== null && selection?.end !== null && selection) matched.setSelectionRange(selection.start, selection.end);\n } else if (panel && (!wasModal || focused)) (current === "boot" ? boot : dialog?.querySelector(\'select,input,button:not([data-action="close"]):not(:disabled)\') ?? dialog)?.focus({ preventScroll: true });\n wasModal = !!panel;\n if (lastPhase !== current) {\n lastPhase = current;\n announce(current === "boot" ? "" : t({ home: "home", attaching: "joining", matching: "matching", lobby: "room", countdown: "starting", playing: "playing", ended: "ended", watching: "watching", error: "error" }[current]));\n }\n updateCountdown();\n resize();\n }\n const click = (event) => {\n const target = event.target.closest("button[data-action]");\n if (!target || target.disabled) return;\n const action = target.dataset.action;\n event.stopPropagation();\n if (action.startsWith("panel:")) {\n setPanel(action.slice(6));\n return;\n }\n switch (action) {\n case "menu":\n toggle();\n break;\n case "close":\n close();\n break;\n case "play": {\n const selected = primaryAction(manifest, model.mode);\n if (selected) void perform(selected.op, { mode: model.mode });\n break;\n }\n case "match":\n void perform("room.match", { mode: model.mode });\n break;\n case "join-invite":\n if (input.configuration.invite) void perform("room.join", { code: input.configuration.invite });\n break;\n case "resume":\n void perform("session.resume", {});\n break;\n case "cancel":\n void perform("session.cancel", {});\n break;\n case "ready":\n void perform("room.ready", { ready: !model.session?.room?.players.find((p) => p.id === model.session?.room?.you)?.ready });\n break;\n case "start":\n void perform("room.start", {});\n break;\n case "restart":\n void perform("room.restart", {});\n break;\n case "copy":\n void copyInvite();\n break;\n case "voice-join":\n void performVoice("voice.join", {});\n break;\n case "voice-mute":\n void performVoice("voice.mute", { muted: !model.session?.voice?.muted });\n break;\n case "voice-leave":\n void performVoice("voice.leave", {});\n break;\n case "again": {\n if (!canPlayAgain(model.session)) break;\n if (model.session?.kind === "local") void perform("local.start", { mode: model.session.mode ?? model.mode });\n else if (model.session?.room?.status === "finished") void perform("room.restart", {});\n else void perform("room.create", { mode: model.session?.room?.mode ?? null }, async () => {\n setPanel("invite");\n dispatch({ type: "notice", notice: t("newRoom") });\n await copyInvite();\n });\n break;\n }\n case "disconnect-exit":\n void perform("session.disconnect", {}, input.exit);\n break;\n case "leave-exit":\n void perform("session.leave", {}, input.exit);\n break;\n case "leave":\n void perform("session.leave", {}, () => setPanel("home"));\n break;\n case "exit-now":\n input.exit();\n break;\n case "reload":\n boot?.focus({ preventScroll: true });\n resetBootWait();\n input.frame.src = input.frame.src;\n break;\n case "refresh":\n void boards?.refresh();\n break;\n case "party-create":\n input.crew?.party.create();\n break;\n case "party-leave":\n input.crew?.party.leave();\n break;\n case "party-invite":\n input.crew?.party.invite(target.dataset.player);\n break;\n case "party-accept":\n input.crew?.party.accept(target.dataset.party);\n break;\n case "party-decline":\n input.crew?.party.decline(target.dataset.party);\n break;\n case "follow":\n input.crew?.follow(target.dataset.game, target.dataset.code);\n break;\n }\n };\n const change = (event) => {\n const target = event.target, field = target.dataset.control;\n if (field === "voice-volume") {\n target.dataset.editing = "true";\n void performVoice("voice.setVolume", { playerId: target.dataset.peer, volume: Number(target.value) }).finally(() => {\n delete target.dataset.editing;\n updateVoice();\n });\n return;\n }\n if (field === "mode") dispatch({ type: "mode", mode: target.value });\n if (field === "role") void perform(model.session?.room?.status === "lobby" ? "room.role" : "room.requestRole", { role: target.value });\n if (field === "team") void perform("room.team", { team: Number(target.value) });\n if (field === "shortcut") {\n const enabled = target.checked;\n try {\n win.localStorage.setItem("caisual-overlay-shortcut-v1", enabled ? "on" : "off");\n } catch {\n }\n dispatch({ type: "shortcut", enabled });\n }\n const query = boards?.state.query;\n if (query && ["board", "period", "category"].includes(field ?? "")) {\n const next = { ...query };\n if (field === "board") {\n next.board = target.value;\n next.period = (manifest.boards[next.board].periods ?? ["all-time"])[0];\n delete next.day;\n }\n if (field === "period") {\n next.period = target.value;\n delete next.day;\n }\n if (field === "category") next.guests = target.value === "guests";\n boards.select(next);\n }\n };\n const submit = (event) => {\n const form = event.target;\n if (!form.dataset.form) return;\n event.preventDefault();\n const code = normalizeInvite(form.querySelector(\'input[data-control="code"]\').value);\n if (!code) {\n dispatch({ type: "error", code: "invalid_code" });\n return;\n }\n void perform(form.dataset.form === "watch" ? "room.watch" : "room.join", { code });\n };\n const keydown = (event) => {\n const panel = visiblePanel(model);\n if (panel && event.key === "Escape") {\n event.preventDefault();\n event.stopImmediatePropagation();\n close();\n return;\n }\n if (panel && event.key === "Tab") {\n const items = controls().filter((el) => el.closest(panel === "boot" ? ".boot" : ".dialog")), first = items[0], last = items.at(-1);\n if (!first) {\n event.preventDefault();\n return;\n }\n if (event.shiftKey && (root.activeElement === first || !items.includes(root.activeElement))) {\n event.preventDefault();\n last?.focus();\n } else if (!event.shiftKey && (root.activeElement === last || !items.includes(root.activeElement))) {\n event.preventDefault();\n first.focus();\n }\n } else if (!panel && model.shortcutEnabled && event.key === "Tab" && event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey) {\n event.preventDefault();\n toggle();\n }\n };\n root.addEventListener("input", (event) => {\n const node = event.target;\n if (node.dataset.control === "code") codeDraft = node.value;\n if (node.dataset.control === "voice-volume") node.dataset.editing = "true";\n });\n root.addEventListener("click", click);\n root.addEventListener("change", change);\n root.addEventListener("submit", submit);\n win.addEventListener("keydown", keydown, true);\n win.addEventListener("resize", resize);\n win.addEventListener("scroll", resize, true);\n win.visualViewport?.addEventListener("resize", resize);\n win.visualViewport?.addEventListener("scroll", resize);\n const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(resize) : null;\n observer?.observe(input.frame);\n const countdownTimer = win.setInterval(updateCountdown, 250);\n stops.push(input.bridge.subscribe((session) => {\n const previous = model.session;\n if (!session || session.id !== previous?.id) {\n voiceOperation++;\n voicePending = null;\n voiceError = null;\n }\n if (!session) {\n lastView = "";\n boards?.reset();\n operation++;\n model.busy = false;\n }\n if (previous && session && JSON.stringify({ ...previous, voice: null }) === JSON.stringify({ ...session, voice: null })) {\n model = reduceUi(model, { type: "session", session });\n updateVoice();\n } else dispatch({ type: "session", session });\n }));\n stops.push(input.bridge.onOpen((panel) => setPanel(panel)), input.bridge.onShortcut(toggle), input.bridge.onError(({ error }) => {\n if (!visiblePanel(model)) setPanel("room");\n dispatch({ type: "error", code: error.code });\n }));\n stops.push(input.bridge.onScore((score) => boards?.queued(score)));\n if (input.crew) stops.push(input.crew.subscribe(() => {\n const state = input.crew.getSnapshot();\n if (state.follow || state.invites.length) announce(t("friends"));\n render();\n }));\n render();\n return { element: host, root, dispose() {\n disposed = true;\n operation++;\n stops.forEach((stop) => stop());\n boards?.dispose();\n observer?.disconnect();\n win.clearInterval(countdownTimer);\n win.cancelAnimationFrame(geometryFrame);\n win.clearTimeout(bootTimer);\n win.clearTimeout(bootFadeTimer);\n win.removeEventListener("keydown", keydown, true);\n win.removeEventListener("resize", resize);\n win.removeEventListener("scroll", resize, true);\n win.visualViewport?.removeEventListener("resize", resize);\n win.visualViewport?.removeEventListener("scroll", resize);\n input.frame.inert = oldInert;\n if (oldTabIndex === null) input.frame.removeAttribute("tabindex");\n else input.frame.setAttribute("tabindex", oldTabIndex);\n void input.bridge.request("overlay.view", { inputBlocked: false, reservedRects: [], shortcutEnabled: false }).catch(() => {\n });\n host.remove();\n } };\n}\nexport {\n avviaHandshake,\n creaPonteOspite,\n eMessaggioReady,\n eRichiestaBiglietto,\n mountOverlay,\n overlayConfiguration,\n overlayLanguage,\n overlayLocale,\n styles as overlayStyles,\n stanzaDaMessaggio\n};\n');
4436
4801
  return;
4437
4802
  }
4438
4803
  if (url.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) {
@@ -4512,11 +4877,11 @@ var DevService = class {
4512
4877
  }
4513
4878
  let player = this.playersBySession.get(sessionId);
4514
4879
  if (player === void 0) {
4515
- this.playerNumber += 1;
4880
+ const id = `dev_${createHash2("sha256").update(sessionId).digest("hex").slice(0, 24)}`;
4516
4881
  player = {
4517
4882
  sessionId,
4518
- id: `dev_${createHash2("sha256").update(sessionId).digest("hex").slice(0, 24)}`,
4519
- name: `Guest ${this.playerNumber}`,
4883
+ id,
4884
+ name: guestName(id),
4520
4885
  guest: true
4521
4886
  };
4522
4887
  this.playersBySession.set(sessionId, player);
@@ -4553,7 +4918,7 @@ var DevService = class {
4553
4918
  const ticket = readServiceTicket(request, this.manifest.id, "portal", this.secret);
4554
4919
  this.checkRate(this.kitRequests, ticket.sub);
4555
4920
  if (url.pathname === "/api/kit/me" && request.method === "GET") {
4556
- const day = utcDay();
4921
+ const day = this.currentDay();
4557
4922
  sendJson(response, {
4558
4923
  player: playerFromTicket(ticket),
4559
4924
  game: { slug: this.manifest.id },
@@ -4648,7 +5013,7 @@ var DevService = class {
4648
5013
  scoreKey(playerId, game, board, day) {
4649
5014
  return `${playerId}\0${game}\0${board}\0${day ?? ""}`;
4650
5015
  }
4651
- putScore(input, now = Date.now()) {
5016
+ async putScore(input, now = Date.now()) {
4652
5017
  const key = this.scoreKey(input.playerId, input.game, input.board, input.day);
4653
5018
  const existing = this.scores.get(key);
4654
5019
  if (existing !== void 0) {
@@ -4660,6 +5025,7 @@ var DevService = class {
4660
5025
  createdAt: existing !== void 0 && input.score <= existing.score ? existing.createdAt : now
4661
5026
  };
4662
5027
  this.scores.set(key, record2);
5028
+ await this.persistScores();
4663
5029
  return record2;
4664
5030
  }
4665
5031
  scoreRank(record2) {
@@ -4686,13 +5052,13 @@ var DevService = class {
4686
5052
  if (typeof body.daily !== "boolean") {
4687
5053
  throw new DevHttpError(400, "invalid_request", "daily must be true or false.");
4688
5054
  }
4689
- const record2 = this.putScore({
5055
+ const record2 = await this.putScore({
4690
5056
  playerId: ticket.sub,
4691
5057
  name: ticket.name,
4692
5058
  guest: ticket.guest,
4693
5059
  game: ticket.game,
4694
5060
  board: body.board,
4695
- day: body.daily ? utcDay() : null,
5061
+ day: body.daily ? this.currentDay() : null,
4696
5062
  score: body.score,
4697
5063
  verified: false
4698
5064
  });
@@ -4722,14 +5088,14 @@ var DevService = class {
4722
5088
  }
4723
5089
  const explicitDay = url.searchParams.get("day");
4724
5090
  if (explicitDay !== null && !validBoardDay(explicitDay)) throw new DevHttpError(400, "invalid_request", "day must be a real UTC date in YYYY-MM-DD format.");
4725
- const day = explicitDay ?? (dailyValue === "1" ? utcDay() : null);
5091
+ const day = explicitDay ?? (dailyValue === "1" ? this.currentDay() : null);
4726
5092
  const guests = guestsValue === "1";
4727
5093
  const category = [...this.scores.values()].filter(
4728
5094
  (record2) => record2.game === ticket.game && record2.board === board && record2.day === day && record2.guest === guests
4729
5095
  ).sort((left, right) => right.score - left.score || left.createdAt - right.createdAt);
4730
5096
  const entries = category.slice(0, Number(limitRaw)).map((record2) => ({
4731
5097
  rank: this.scoreRank(record2),
4732
- name: record2.guest ? "Guest" : record2.name,
5098
+ name: record2.guest ? guestName(record2.playerId) : record2.name,
4733
5099
  score: record2.score,
4734
5100
  verified: record2.verified,
4735
5101
  guest: record2.guest,
@@ -4784,13 +5150,13 @@ var DevService = class {
4784
5150
  const flushed = await localRoom.room.flush();
4785
5151
  for (const score of flushed.scores) {
4786
5152
  const player = this.playersById.get(score.playerId);
4787
- this.putScore({
5153
+ await this.putScore({
4788
5154
  playerId: score.playerId,
4789
- name: player?.name ?? "Guest",
5155
+ name: player?.name ?? guestName(score.playerId),
4790
5156
  guest: player?.guest ?? true,
4791
5157
  game: ticket.game,
4792
5158
  board: score.board,
4793
- day: score.day === void 0 ? score.daily ? utcDay() : null : score.day,
5159
+ day: score.day === void 0 ? score.daily ? this.currentDay() : null : score.day,
4794
5160
  score: score.score,
4795
5161
  verified: true
4796
5162
  }, score.submittedAt);
@@ -4899,8 +5265,9 @@ var DevService = class {
4899
5265
  this.definition,
4900
5266
  this.roomManifest(),
4901
5267
  {
4902
- storageFile: join2(this.root, ".caisual-dev", "rooms", `${roomId}.json`),
4903
- deposito: this.deposito
5268
+ storageFile: join3(this.root, ".caisual-dev", "rooms", `${roomId}.json`),
5269
+ deposito: this.deposito,
5270
+ dailyDay: this.day
4904
5271
  }
4905
5272
  );
4906
5273
  try {
@@ -4955,8 +5322,9 @@ var DevService = class {
4955
5322
  this.definition,
4956
5323
  this.roomManifest(),
4957
5324
  {
4958
- storageFile: join2(this.root, ".caisual-dev", "rooms", `${record2.roomId}.json`),
4959
- deposito: this.deposito
5325
+ storageFile: join3(this.root, ".caisual-dev", "rooms", `${record2.roomId}.json`),
5326
+ deposito: this.deposito,
5327
+ dailyDay: this.day
4960
5328
  }
4961
5329
  );
4962
5330
  const info = await room.info();
@@ -5017,12 +5385,12 @@ var DevService = class {
5017
5385
  sendJson(response, this.watchResponse(roomId, localRoom.code, playerFromTicket(ticket)), 200, origin);
5018
5386
  }
5019
5387
  joinResponse(roomId, code, player) {
5020
- const join4 = joinTicket(player, roomId, this.secret);
5388
+ const join5 = joinTicket(player, roomId, this.secret);
5021
5389
  return {
5022
5390
  roomId,
5023
5391
  code,
5024
- join: join4,
5025
- url: `ws://localhost:${this.port}/rooms/${roomId}?j=${encodeURIComponent(join4)}`
5392
+ join: join5,
5393
+ url: `ws://localhost:${this.port}/rooms/${roomId}?j=${encodeURIComponent(join5)}`
5026
5394
  };
5027
5395
  }
5028
5396
  watchResponse(roomId, code, player) {
@@ -5082,7 +5450,7 @@ Connection: close\r
5082
5450
  };
5083
5451
  async function runDev(options) {
5084
5452
  const root = resolve(process.cwd(), options.folder);
5085
- const stat = await fs2.stat(root).catch(() => null);
5453
+ const stat = await fs3.stat(root).catch(() => null);
5086
5454
  if (stat === null || !stat.isDirectory()) throw new Error(`The game folder was not found: ${root}`);
5087
5455
  const [{ manifest, clientRoot }, definition] = await Promise.all([
5088
5456
  readGame(root),
@@ -5104,7 +5472,7 @@ async function runDev(options) {
5104
5472
  });
5105
5473
  const address = server.address();
5106
5474
  if (address === null || typeof address === "string") throw new Error("The local server address is unavailable.");
5107
- service = new DevService(root, clientRoot, manifest, definition, address.port);
5475
+ service = new DevService(root, clientRoot, manifest, definition, address.port, options.day);
5108
5476
  try {
5109
5477
  await service.initialize();
5110
5478
  } catch (cause) {
@@ -5139,42 +5507,54 @@ function templateManifest(id, name, multiplayer) {
5139
5507
  manifest: 1,
5140
5508
  id,
5141
5509
  name,
5510
+ languages: ["en"],
5142
5511
  platform: "both",
5143
5512
  overlay: { version: 1, accent: "#a8efc5" },
5144
5513
  ...multiplayer ? { players: { min: 2, max: 4 }, lobby: true, persistent: true, spectators: { delayMs: 3e3 } } : {},
5145
5514
  modes: [
5146
- { id: "practice", execution: "local", label: "Practice", instructions: "Tap eight lights. Click, tap or press Space.", players: { min: 1, max: 1 }, lobby: false },
5515
+ { id: "practice", execution: "local", label: { en: "Practice" }, instructions: { en: "Tap eight lights. Click, tap or press Space." }, players: { min: 1, max: 1 }, lobby: false },
5147
5516
  ...multiplayer ? [{
5148
5517
  id: "together",
5149
5518
  execution: "room",
5150
- label: "Together",
5151
- instructions: "Light up the field together. Eight lights complete a round.",
5519
+ label: { en: "Together" },
5520
+ instructions: { en: "Light up the field together. Eight lights complete a round." },
5152
5521
  matchmaking: { key: ["pool"], defaults: { pool: "v1" }, timeoutMs: 12e3 }
5153
5522
  }] : []
5154
5523
  ]
5155
5524
  };
5156
5525
  }
5526
+ var templateTexts = {
5527
+ title: "Light field",
5528
+ controls: "Light field. Tap a light or press Space.",
5529
+ complete: "All lit up!",
5530
+ result: "Eight lights. Nicely done.",
5531
+ progress: "Tap a light \xB7 {n} / 8"
5532
+ };
5157
5533
  var templateIndex = `<!doctype html>
5158
5534
  <html lang="en">
5159
5535
  <head>
5160
5536
  <meta charset="utf-8">
5161
5537
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
5162
- <title>Light field</title>
5538
+ <title></title>
5163
5539
  <style>
5164
5540
  html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#12252b;color:#f2faf3;font:16px system-ui}
5165
5541
  canvas{display:block;width:100vw;height:100dvh;touch-action:none;outline:none}
5166
- #status{position:absolute;left:max(16px,env(safe-area-inset-left));top:max(14px,env(safe-area-inset-top));margin:0;pointer-events:none;max-width:calc(100% - 210px)}
5542
+ #status{position:absolute;left:max(16px,var(--caisual-safe-left, 0px));top:max(14px,var(--caisual-safe-top, 0px));margin:0;pointer-events:none;max-width:calc(100% - 210px)}
5167
5543
  </style>
5168
5544
  </head>
5169
5545
  <body>
5170
- <canvas tabindex="0" aria-label="Light field. Tap a light or press Space."></canvas>
5171
- <p id="status" role="status" aria-live="polite">Loading...</p>
5546
+ <canvas tabindex="0"></canvas>
5547
+ <p id="status" role="status" aria-live="polite"></p>
5172
5548
  <script type="module">
5173
5549
  import { caisual } from '/__caisual/kit/v1.js';
5174
5550
  const c = await caisual.connect();
5551
+ const t = await c.text();
5552
+ document.documentElement.lang = c.player.language;
5553
+ document.title = t('title');
5175
5554
  // La sonda locale legge il client del gioco senza aprire un'altra sessione.
5176
5555
  if (location.hostname === 'localhost' || location.hostname.endsWith('.localhost')) window.caisualDebug = { c };
5177
5556
  const canvas = document.querySelector('canvas'), ctx = canvas.getContext('2d'), status = document.querySelector('#status');
5557
+ canvas.setAttribute('aria-label', t('controls'));
5178
5558
  let session = { kind: 'idle' }, state = { hits: 0 }, blocked = false, stops = [], localId = null, offline = false, reserved = [];
5179
5559
  let width = 1, height = 1, target = { x: 0, y: 0, radius: 24 };
5180
5560
  const position = (hits) => ({ x: .25 + ((hits * 7) % 11) / 20, y: .28 + ((hits * 3) % 7) / 14 });
@@ -5191,9 +5571,9 @@ var templateIndex = `<!doctype html>
5191
5571
  ctx.fillStyle = '#a8efc5'; ctx.beginPath(); ctx.arc(target.x, target.y, radius, 0, Math.PI * 2); ctx.fill();
5192
5572
  ctx.fillStyle = '#18322c'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = 'bold ' + Math.round(radius * .7) + 'px system-ui'; ctx.fillText(String(state.hits + 1), target.x, target.y);
5193
5573
  } else {
5194
- ctx.fillStyle = '#a8efc5'; ctx.textAlign = 'center'; ctx.font = 'bold ' + Math.min(54, width / 10) + 'px system-ui'; ctx.fillText('All lit up!', width / 2, height / 2);
5574
+ ctx.fillStyle = '#a8efc5'; ctx.textAlign = 'center'; ctx.font = 'bold ' + Math.min(54, width / 10) + 'px system-ui'; ctx.fillText(t('complete'), width / 2, height / 2);
5195
5575
  }
5196
- status.textContent = state.hits >= 8 ? 'Eight lights. Nicely done.' : 'Tap a light \xB7 ' + state.hits + ' / 8';
5576
+ status.textContent = state.hits >= 8 ? t('result') : t('progress', { n: state.hits });
5197
5577
  canvas.dataset.state = JSON.stringify({ hits: state.hits, target, playing: playing() });
5198
5578
  }
5199
5579
  function resize() { width = innerWidth; height = innerHeight; const ratio = Math.min(devicePixelRatio || 1, 2); canvas.width = width * ratio; canvas.height = height * ratio; ctx.setTransform(ratio, 0, 0, ratio, 0, 0); draw(); placeHud(); }
@@ -5235,11 +5615,12 @@ var templateServer = `import { defineGame } from '@caisual/kit/server';
5235
5615
  export default defineGame({
5236
5616
  tickRate: 0,
5237
5617
  onCreate(room) { room.state = { hits: 0 }; },
5618
+ onRestart(room) { room.state = { hits: 0 }; },
5238
5619
  onMessage(room, player, message) {
5239
5620
  if (room.status !== 'playing' || player.role === 'spectator' || !message || message.hit !== room.state.hits) return;
5240
5621
  // La revisione rende innocui due tocchi contemporanei sulla stessa luce.
5241
5622
  room.state.hits++;
5242
- if (room.state.hits === 8) room.end({ lights: 8 });
5623
+ if (room.state.hits === 8) room.end({ lights: 8 }, { rematch: true });
5243
5624
  },
5244
5625
  });
5245
5626
  `;
@@ -5401,11 +5782,12 @@ var ApiError = class extends Error {
5401
5782
  hints;
5402
5783
  };
5403
5784
  function help() {
5404
- return `Caisual ${"0.9.0"}
5785
+ return `Caisual ${"0.11.0"}
5405
5786
 
5406
5787
  Usage:
5407
5788
  caisual init [--multiplayer] [folder]
5408
- caisual dev [folder] [--port 8790]
5789
+ caisual dev [folder] [--port 8790] [--day YYYY-MM-DD]
5790
+ caisual check [folder] [--json]
5409
5791
  caisual publish [folder]
5410
5792
  caisual unlist [folder|id]
5411
5793
  caisual relist [folder|id]
@@ -5431,7 +5813,7 @@ function displayName(folderName) {
5431
5813
  }
5432
5814
  async function writeNewFile(path, content) {
5433
5815
  try {
5434
- await fs3.writeFile(path, content, { encoding: "utf8", flag: "wx" });
5816
+ await fs4.writeFile(path, content, { encoding: "utf8", flag: "wx" });
5435
5817
  return true;
5436
5818
  } catch (error) {
5437
5819
  if (error.code === "EEXIST") return false;
@@ -5441,26 +5823,33 @@ async function writeNewFile(path, content) {
5441
5823
  async function init(folderArgument, multiplayer) {
5442
5824
  const root = resolve2(process.cwd(), folderArgument);
5443
5825
  try {
5444
- await fs3.mkdir(join3(root, "client"), { recursive: true });
5826
+ await fs4.mkdir(join4(root, "client"), { recursive: true });
5445
5827
  } catch {
5446
5828
  throw new CliError(2, `The game folder could not be created: ${root}`);
5447
5829
  }
5448
5830
  const folderName = basename2(root);
5449
5831
  const manifest = templateManifest(slugFromFolder(folderName), displayName(folderName), multiplayer);
5450
- const manifestPath = join3(root, "caisual.json");
5451
- const indexPath = join3(root, "client", "index.html");
5832
+ const manifestPath = join4(root, "caisual.json");
5833
+ const indexPath = join4(root, "client", "index.html");
5452
5834
  const manifestCreated = await writeNewFile(manifestPath, `${JSON.stringify(manifest, null, 2)}
5453
5835
  `);
5454
5836
  const indexCreated = await writeNewFile(
5455
5837
  indexPath,
5456
5838
  templateIndex
5457
5839
  );
5840
+ if (indexCreated) {
5841
+ await fs4.mkdir(join4(root, "client", "i18n"), { recursive: true });
5842
+ const textPath = join4(root, "client", "i18n", "en.json");
5843
+ const textCreated = await writeNewFile(textPath, JSON.stringify(templateTexts, null, 2) + "\n");
5844
+ process.stdout.write(`${textCreated ? "Created" : "Kept"} ${textPath}
5845
+ `);
5846
+ }
5458
5847
  process.stdout.write(`${manifestCreated ? "Created" : "Kept"} ${manifestPath}
5459
5848
  `);
5460
5849
  process.stdout.write(`${indexCreated ? "Created" : "Kept"} ${indexPath}
5461
5850
  `);
5462
5851
  if (multiplayer) {
5463
- const serverPath = join3(root, "server.js");
5852
+ const serverPath = join4(root, "server.js");
5464
5853
  const serverCreated = await writeNewFile(serverPath, templateServer);
5465
5854
  process.stdout.write(`${serverCreated ? "Created" : "Kept"} ${serverPath}
5466
5855
  `);
@@ -5491,19 +5880,19 @@ async function mapLimited(items, limit, operation) {
5491
5880
  async function listClientFiles(clientRoot) {
5492
5881
  let rootStat;
5493
5882
  try {
5494
- rootStat = await fs3.stat(clientRoot);
5883
+ rootStat = await fs4.stat(clientRoot);
5495
5884
  } catch {
5496
5885
  throw new CliError(2, "client/: folder not found.");
5497
5886
  }
5498
5887
  if (!rootStat.isDirectory()) throw new CliError(2, "client/: must be a folder.");
5499
5888
  const found = [];
5500
5889
  async function visit(folder, prefix) {
5501
- const entries = await fs3.readdir(folder, { withFileTypes: true });
5890
+ const entries = await fs4.readdir(folder, { withFileTypes: true });
5502
5891
  entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
5503
5892
  for (const entry of entries) {
5504
5893
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
5505
5894
  const relativePath = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
5506
- const absolutePath = join3(folder, entry.name);
5895
+ const absolutePath = join4(folder, entry.name);
5507
5896
  if (entry.isDirectory()) {
5508
5897
  await visit(absolutePath, relativePath);
5509
5898
  continue;
@@ -5511,7 +5900,7 @@ async function listClientFiles(clientRoot) {
5511
5900
  if (!entry.isFile()) {
5512
5901
  throw new CliError(2, `${relativePath}: only regular files are supported.`);
5513
5902
  }
5514
- const fileStat = await fs3.stat(absolutePath);
5903
+ const fileStat = await fs4.stat(absolutePath);
5515
5904
  if (fileStat.size > MAX_FILE_BYTES) {
5516
5905
  throw new CliError(2, `${relativePath}: file is larger than 50 MB (${fileStat.size} bytes).`);
5517
5906
  }
@@ -5532,17 +5921,20 @@ async function listClientFiles(clientRoot) {
5532
5921
  return await mapLimited(found, UPLOAD_CONCURRENCY, async (file) => ({
5533
5922
  ...file,
5534
5923
  sha256: await sha256(file.absolutePath),
5535
- read: () => fs3.readFile(file.absolutePath)
5924
+ read: () => fs4.readFile(file.absolutePath)
5536
5925
  }));
5537
5926
  }
5538
- async function readServerFile(root) {
5539
- const absolutePath = join3(root, "server.js");
5927
+ async function readServerFile(root, notifyBundle = (bytes) => {
5928
+ process.stdout.write(`Bundling server.js (${Math.ceil(bytes / 1e3)} KB).
5929
+ `);
5930
+ }) {
5931
+ const absolutePath = join4(root, "server.js");
5540
5932
  let stat;
5541
5933
  try {
5542
- stat = await fs3.lstat(absolutePath);
5934
+ stat = await fs4.lstat(absolutePath);
5543
5935
  } catch (error) {
5544
5936
  if (error.code === "ENOENT") {
5545
- return { file: null, temporaryDirectory: null };
5937
+ return { file: null, temporaryDirectory: null, bundled: false };
5546
5938
  }
5547
5939
  throw new CliError(2, "server.js: file not readable.");
5548
5940
  }
@@ -5556,16 +5948,16 @@ async function readServerFile(root) {
5556
5948
  bytes: stat.size,
5557
5949
  sha256: await sha256(absolutePath)
5558
5950
  },
5559
- temporaryDirectory: null
5951
+ temporaryDirectory: null,
5952
+ bundled: false
5560
5953
  };
5561
5954
  }
5562
- const temporaryDirectory = await fs3.mkdtemp(join3(tmpdir(), "caisual-server-"));
5563
- const bundledPath = join3(temporaryDirectory, "server.js");
5955
+ const temporaryDirectory = await fs4.mkdtemp(join4(tmpdir(), "caisual-server-"));
5956
+ const bundledPath = join4(temporaryDirectory, "server.js");
5564
5957
  try {
5565
- await fs3.writeFile(bundledPath, result.source, "utf8");
5958
+ await fs4.writeFile(bundledPath, result.source, "utf8");
5566
5959
  const bytes = Buffer.byteLength(result.source);
5567
- process.stdout.write(`Bundling server.js (${Math.ceil(bytes / 1e3)} KB).
5568
- `);
5960
+ notifyBundle(bytes);
5569
5961
  return {
5570
5962
  file: {
5571
5963
  path: "server.js",
@@ -5573,10 +5965,11 @@ async function readServerFile(root) {
5573
5965
  bytes,
5574
5966
  sha256: await sha256(bundledPath)
5575
5967
  },
5576
- temporaryDirectory
5968
+ temporaryDirectory,
5969
+ bundled: true
5577
5970
  };
5578
5971
  } catch (error) {
5579
- await fs3.rm(temporaryDirectory, { recursive: true, force: true });
5972
+ await fs4.rm(temporaryDirectory, { recursive: true, force: true });
5580
5973
  throw error;
5581
5974
  }
5582
5975
  }
@@ -5781,11 +6174,11 @@ function parseUploads(payload, files, server) {
5781
6174
  serverTarget
5782
6175
  };
5783
6176
  }
5784
- async function readManifest(root) {
5785
- const path = join3(root, "caisual.json");
6177
+ async function readManifest(root, warn) {
6178
+ const path = join4(root, "caisual.json");
5786
6179
  let source;
5787
6180
  try {
5788
- source = await fs3.readFile(path, "utf8");
6181
+ source = await fs4.readFile(path, "utf8");
5789
6182
  } catch {
5790
6183
  throw new CliError(2, "caisual.json: file not found or unreadable.");
5791
6184
  }
@@ -5800,33 +6193,153 @@ async function readManifest(root) {
5800
6193
  throw new CliError(2, `caisual.json is not valid:
5801
6194
  ${result.errori.map((error) => `- ${error}`).join("\n")}`);
5802
6195
  }
6196
+ warnLegacyLanguage(value, warn);
5803
6197
  return result.manifest;
5804
6198
  }
5805
- async function publish(folderArgument) {
5806
- const root = resolve2(process.cwd(), folderArgument);
5807
- let rootStat;
6199
+ async function captureGameError(errors, operation) {
5808
6200
  try {
5809
- rootStat = await fs3.stat(root);
5810
- } catch {
5811
- throw new CliError(2, `The game folder was not found: ${root}`);
6201
+ return await operation();
6202
+ } catch (error) {
6203
+ if (!(error instanceof CliError)) throw error;
6204
+ errors.push(error.message);
6205
+ return null;
6206
+ }
6207
+ }
6208
+ function finishReport(report) {
6209
+ report.ok = report.errors.length === 0;
6210
+ return report;
6211
+ }
6212
+ async function checkGame(root) {
6213
+ const report = {
6214
+ ok: false,
6215
+ errors: [],
6216
+ warnings: [],
6217
+ manifest: null,
6218
+ client: null,
6219
+ server: null
6220
+ };
6221
+ const warn = (message) => report.warnings.push(message);
6222
+ const rootStat = await captureGameError(report.errors, async () => {
6223
+ try {
6224
+ return await fs4.stat(root);
6225
+ } catch {
6226
+ throw new CliError(2, `The game folder was not found: ${root}`);
6227
+ }
6228
+ });
6229
+ if (rootStat === null) return finishReport(report);
6230
+ if (!rootStat.isDirectory()) {
6231
+ report.errors.push(`The game path is not a folder: ${root}`);
6232
+ return finishReport(report);
6233
+ }
6234
+ const manifest = await captureGameError(report.errors, () => readManifest(root, warn));
6235
+ if (manifest === null) return finishReport(report);
6236
+ report.manifest = {
6237
+ id: manifest.id,
6238
+ name: manifest.name,
6239
+ languages: [...manifest.languages],
6240
+ modes: manifest.modes.map((mode) => mode.id),
6241
+ overlay: manifest.overlay !== null
6242
+ };
6243
+ await captureGameError(report.errors, () => checkGameTexts(join4(root, "client"), manifest, warn));
6244
+ const files = await captureGameError(report.errors, () => listClientFiles(join4(root, "client")));
6245
+ if (files !== null) {
6246
+ report.client = {
6247
+ files: files.length,
6248
+ bytes: files.reduce((total, file) => total + file.bytes, 0),
6249
+ largest: files.map((file) => ({ path: `client/${file.path}`, bytes: file.bytes })).sort((left, right) => right.bytes - left.bytes || left.path.localeCompare(right.path)).slice(0, 5)
6250
+ };
6251
+ report.warnings.push(...await scanClient(files, manifest));
6252
+ }
6253
+ const serverResult = await captureGameError(
6254
+ report.errors,
6255
+ () => readServerFile(root, () => void 0)
6256
+ );
6257
+ if (serverResult === null) {
6258
+ let bytes = 0;
6259
+ try {
6260
+ const stat = await fs4.lstat(join4(root, "server.js"));
6261
+ if (stat.isFile()) bytes = stat.size;
6262
+ } catch {
6263
+ }
6264
+ report.server = { present: true, bundled: false, bytes };
6265
+ } else {
6266
+ const server = serverResult.file;
6267
+ report.server = server === null ? { present: false, bundled: false, bytes: 0 } : { present: true, bundled: serverResult.bundled, bytes: server.bytes };
6268
+ }
6269
+ try {
6270
+ if (serverResult !== null && richiedeServer(manifest) && serverResult.file === null) {
6271
+ report.errors.push("server.js is required by a room mode.");
6272
+ }
6273
+ if (files !== null) {
6274
+ const filePaths = new Set(files.map((file) => file.path));
6275
+ await captureGameError(report.errors, async () => {
6276
+ for (const required of [manifest.cover, ...manifest.screenshots]) {
6277
+ if (required !== null && !filePaths.has(required)) {
6278
+ throw new CliError(2, `caisual.json: referenced file not found in client/: ${required}`);
6279
+ }
6280
+ }
6281
+ });
6282
+ }
6283
+ } finally {
6284
+ if (serverResult?.temporaryDirectory !== null && serverResult?.temporaryDirectory !== void 0) {
6285
+ await fs4.rm(serverResult.temporaryDirectory, { recursive: true, force: true });
6286
+ }
5812
6287
  }
5813
- if (!rootStat.isDirectory()) throw new CliError(2, `The game path is not a folder: ${root}`);
5814
- const manifest = await readManifest(root);
5815
- const files = await listClientFiles(join3(root, "client"));
5816
- for (const warning of await scanClient(files, manifest)) {
5817
- process.stderr.write(`Warning: ${warning}
6288
+ return finishReport(report);
6289
+ }
6290
+ function formatBytes(bytes) {
6291
+ if (bytes < 1e3) return `${bytes} B`;
6292
+ const unit = bytes < 1e6 ? "KB" : "MB";
6293
+ const divisor = bytes < 1e6 ? 1e3 : 1e6;
6294
+ const value = Math.round(bytes / divisor * 10) / 10;
6295
+ return `${value} ${unit}`;
6296
+ }
6297
+ async function check(folderArgument, json) {
6298
+ const report = await checkGame(resolve2(process.cwd(), folderArgument));
6299
+ if (json) {
6300
+ process.stdout.write(`${JSON.stringify(report, null, 2)}
6301
+ `);
6302
+ } else {
6303
+ if (report.manifest !== null) {
6304
+ const modes = report.manifest.modes.length === 0 ? "none" : report.manifest.modes.join(", ");
6305
+ process.stdout.write(
6306
+ `caisual.json: ${report.manifest.id} (${report.manifest.name}), languages ${report.manifest.languages.join(", ")}, modes ${modes}, overlay ${report.manifest.overlay ? "on" : "off"}
6307
+ `
6308
+ );
6309
+ }
6310
+ if (report.client !== null) {
6311
+ const largest = report.client.largest[0];
6312
+ process.stdout.write(
6313
+ `client/: ${report.client.files} file${report.client.files === 1 ? "" : "s"}, ${formatBytes(report.client.bytes)}${largest === void 0 ? "" : `, largest ${largest.path} (${formatBytes(largest.bytes)})`}
6314
+ `
6315
+ );
6316
+ }
6317
+ if (report.server !== null) {
6318
+ process.stdout.write(report.server.present ? `server.js: present${report.server.bundled ? ", bundled" : ""}
6319
+ ` : "server.js: absent\n");
6320
+ }
6321
+ for (const warning of report.warnings) process.stderr.write(`Warning: ${warning}
6322
+ `);
6323
+ for (const error of report.errors) {
6324
+ process.stderr.write(`Error: ${error.replaceAll("\n", "\nError: ")}
6325
+ `);
6326
+ }
6327
+ process.stdout.write(report.ok ? "OK\n" : `FAILED: ${report.errors.length} error${report.errors.length === 1 ? "" : "s"}
5818
6328
  `);
5819
6329
  }
6330
+ if (!report.ok) process.exitCode = 2;
6331
+ }
6332
+ async function publish(folderArgument) {
6333
+ const root = resolve2(process.cwd(), folderArgument);
6334
+ const report = await checkGame(root);
6335
+ for (const warning of report.warnings) process.stderr.write(`Warning: ${warning}
6336
+ `);
6337
+ if (!report.ok) throw new CliError(2, report.errors.join("\n"));
6338
+ const manifest = await readManifest(root, () => void 0);
6339
+ const files = await listClientFiles(join4(root, "client"));
5820
6340
  const serverResult = await readServerFile(root);
5821
6341
  const server = serverResult.file;
5822
6342
  try {
5823
- if (richiedeServer(manifest) && server === null) throw new CliError(2, "server.js is required by a room mode.");
5824
- const filePaths = new Set(files.map((file) => file.path));
5825
- for (const required of [manifest.cover, ...manifest.screenshots]) {
5826
- if (required !== null && !filePaths.has(required)) {
5827
- throw new CliError(2, `caisual.json: referenced file not found in client/: ${required}`);
5828
- }
5829
- }
5830
6343
  const key = publishingKey();
5831
6344
  const origin = portalOrigin();
5832
6345
  const declared = files.map(({ path, bytes, sha256: digest }) => ({
@@ -5879,14 +6392,14 @@ async function publish(folderArgument) {
5879
6392
  `);
5880
6393
  } finally {
5881
6394
  if (serverResult.temporaryDirectory !== null) {
5882
- await fs3.rm(serverResult.temporaryDirectory, { recursive: true, force: true });
6395
+ await fs4.rm(serverResult.temporaryDirectory, { recursive: true, force: true });
5883
6396
  }
5884
6397
  }
5885
6398
  }
5886
6399
  async function gameIdFromTarget(target) {
5887
6400
  const path = resolve2(process.cwd(), target);
5888
6401
  try {
5889
- if ((await fs3.stat(path)).isDirectory()) return (await readManifest(path)).id;
6402
+ if ((await fs4.stat(path)).isDirectory()) return (await readManifest(path)).id;
5890
6403
  } catch (error) {
5891
6404
  if (error.code !== "ENOENT") {
5892
6405
  throw new CliError(2, `The game target could not be read: ${target}`);
@@ -5919,7 +6432,7 @@ async function manageGame(operation, target) {
5919
6432
  }
5920
6433
  async function installSkill() {
5921
6434
  const root = process.cwd();
5922
- const skillPath = join3(root, ".claude", "skills", "caisual", "SKILL.md");
6435
+ const skillPath = join4(root, ".claude", "skills", "caisual", "SKILL.md");
5923
6436
  const skill = `---
5924
6437
  name: caisual
5925
6438
  description: Create and publish a browser game on Caisual, with player identity, cloud saves, leaderboards and a daily challenge.
@@ -5929,25 +6442,25 @@ ${publish_default.trim()}
5929
6442
 
5930
6443
  ${kit_default.trim()}
5931
6444
  `;
5932
- await fs3.mkdir(join3(root, ".claude", "skills", "caisual"), { recursive: true });
6445
+ await fs4.mkdir(join4(root, ".claude", "skills", "caisual"), { recursive: true });
5933
6446
  let currentSkill = null;
5934
6447
  try {
5935
- currentSkill = await fs3.readFile(skillPath, "utf8");
6448
+ currentSkill = await fs4.readFile(skillPath, "utf8");
5936
6449
  } catch (error) {
5937
6450
  if (error.code !== "ENOENT") throw error;
5938
6451
  }
5939
- if (currentSkill !== skill) await fs3.writeFile(skillPath, skill, "utf8");
5940
- const agentsPath = join3(root, "AGENTS.md");
6452
+ if (currentSkill !== skill) await fs4.writeFile(skillPath, skill, "utf8");
6453
+ const agentsPath = join4(root, "AGENTS.md");
5941
6454
  let agents = "";
5942
6455
  try {
5943
- agents = await fs3.readFile(agentsPath, "utf8");
6456
+ agents = await fs4.readFile(agentsPath, "utf8");
5944
6457
  } catch (error) {
5945
6458
  if (error.code !== "ENOENT") throw error;
5946
6459
  }
5947
6460
  if (!/^## Caisual\s*$/m.test(agents)) {
5948
6461
  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";
5949
6462
  const separator = agents === "" ? "" : agents.endsWith("\n\n") ? "" : agents.endsWith("\n") ? "\n" : "\n\n";
5950
- await fs3.writeFile(agentsPath, `${agents}${separator}${section}`, "utf8");
6463
+ await fs4.writeFile(agentsPath, `${agents}${separator}${section}`, "utf8");
5951
6464
  }
5952
6465
  process.stdout.write(`Installed ${skillPath}
5953
6466
  `);
@@ -5959,7 +6472,7 @@ async function run(argumentsList) {
5959
6472
  return;
5960
6473
  }
5961
6474
  if (command === "--version" || command === "-V") {
5962
- process.stdout.write(`${"0.9.0"}
6475
+ process.stdout.write(`${"0.11.0"}
5963
6476
  `);
5964
6477
  return;
5965
6478
  }
@@ -5977,6 +6490,23 @@ async function run(argumentsList) {
5977
6490
  await publish(argumentsAfterCommand[0] ?? ".");
5978
6491
  return;
5979
6492
  }
6493
+ if (command === "check") {
6494
+ let folder = ".";
6495
+ let folderSeen = false;
6496
+ let json = false;
6497
+ for (const argument of argumentsAfterCommand) {
6498
+ if (argument === "--json" && !json) {
6499
+ json = true;
6500
+ } else if (!argument.startsWith("-") && !folderSeen) {
6501
+ folder = argument;
6502
+ folderSeen = true;
6503
+ } else {
6504
+ throw new CliError(1, "Usage: caisual check [folder] [--json]");
6505
+ }
6506
+ }
6507
+ await check(folder, json);
6508
+ return;
6509
+ }
5980
6510
  if (command === "unlist" || command === "relist") {
5981
6511
  if (argumentsAfterCommand.length > 1 || argumentsAfterCommand.some((value) => value.startsWith("-"))) {
5982
6512
  throw new CliError(1, `Usage: caisual ${command} [folder|id]`);
@@ -6007,12 +6537,19 @@ async function run(argumentsList) {
6007
6537
  if (command === "dev") {
6008
6538
  let folder = ".";
6009
6539
  let port = 8790;
6540
+ let day;
6010
6541
  let folderSeen = false;
6011
6542
  for (let index = 0; index < argumentsAfterCommand.length; index += 1) {
6012
6543
  const argument = argumentsAfterCommand[index];
6544
+ if (argument === "--day" || argument.startsWith("--day=")) {
6545
+ const value = argument === "--day" ? argumentsAfterCommand[++index] : argument.slice("--day=".length);
6546
+ if (!validBoardDay(value)) throw new CliError(1, "--day must be a real UTC date in YYYY-MM-DD format.");
6547
+ day = value;
6548
+ continue;
6549
+ }
6013
6550
  if (argument === "--port") {
6014
6551
  const value = argumentsAfterCommand[index + 1];
6015
- if (value === void 0) throw new CliError(1, "Usage: caisual dev [folder] [--port 8790]");
6552
+ if (value === void 0) throw new CliError(1, "Usage: caisual dev [folder] [--port 8790] [--day YYYY-MM-DD]");
6016
6553
  port = Number(value);
6017
6554
  index += 1;
6018
6555
  continue;
@@ -6022,7 +6559,7 @@ async function run(argumentsList) {
6022
6559
  continue;
6023
6560
  }
6024
6561
  if (argument.startsWith("-") || folderSeen) {
6025
- throw new CliError(1, "Usage: caisual dev [folder] [--port 8790]");
6562
+ throw new CliError(1, "Usage: caisual dev [folder] [--port 8790] [--day YYYY-MM-DD]");
6026
6563
  }
6027
6564
  folder = argument;
6028
6565
  folderSeen = true;
@@ -6030,7 +6567,7 @@ async function run(argumentsList) {
6030
6567
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
6031
6568
  throw new CliError(1, "--port must be an integer from 1 to 65535.");
6032
6569
  }
6033
- await runDev({ folder, port });
6570
+ await runDev({ folder, port, day });
6034
6571
  return;
6035
6572
  }
6036
6573
  if (command === "skill") {
@@ -6062,3 +6599,6 @@ try {
6062
6599
  process.exitCode = 1;
6063
6600
  }
6064
6601
  }
6602
+ export {
6603
+ checkGame
6604
+ };