@caisual/cli 0.22.0 → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/caisual.mjs +130 -305
- package/package.json +2 -2
package/dist/caisual.mjs
CHANGED
|
@@ -87,7 +87,7 @@ async function loadGameTexts(language, defaultLanguage, read) {
|
|
|
87
87
|
function risolviModalita(manifest, mode) {
|
|
88
88
|
const scelta = mode === null ? void 0 : manifest.modes.find((voce) => voce.id === mode);
|
|
89
89
|
if (mode !== null && scelta === void 0) throw new Error("The selected game mode does not exist.");
|
|
90
|
-
return { players: { ...scelta?.players ?? manifest.players }
|
|
90
|
+
return { players: { ...scelta?.players ?? manifest.players } };
|
|
91
91
|
}
|
|
92
92
|
function richiedeServer(manifest) {
|
|
93
93
|
return manifest.modes.length > 0 ? manifest.modes.some((mode) => risolviModalita(manifest, mode.id).players.max > 1) : manifest.players.max > 1;
|
|
@@ -123,14 +123,15 @@ var CAMPI = /* @__PURE__ */ new Set([
|
|
|
123
123
|
"isolated",
|
|
124
124
|
"requires",
|
|
125
125
|
"players",
|
|
126
|
-
"lobby",
|
|
127
126
|
"persistent",
|
|
128
127
|
"roles",
|
|
129
128
|
"teams",
|
|
130
129
|
"voice",
|
|
131
130
|
"modes"
|
|
132
131
|
]);
|
|
133
|
-
var CAMPI_RIMOSSI = /* @__PURE__ */ new Set(["overlay", "replays", "boards"]);
|
|
132
|
+
var CAMPI_RIMOSSI = /* @__PURE__ */ new Set(["overlay", "replays", "boards", "lobby"]);
|
|
133
|
+
var MOTIVO_LOBBY = "rooms opened by a player always have a lobby; matchmaking rooms never do";
|
|
134
|
+
var MOTIVO_MATCHMAKING = "the matchmaking object no longer exists; use matchmaking: true.";
|
|
134
135
|
var MOTIVO_RIMOSSO = "this field no longer exists.";
|
|
135
136
|
var INPUT = /* @__PURE__ */ new Set(["keyboard", "mouse", "touch", "gamepad"]);
|
|
136
137
|
var PLATFORM = /* @__PURE__ */ new Set(["desktop", "mobile", "both"]);
|
|
@@ -140,7 +141,6 @@ var VOICE = /* @__PURE__ */ new Set(["none", "room", "team", "proximity"]);
|
|
|
140
141
|
var PERFORMANCE = /* @__PURE__ */ new Set(["light", "medium", "heavy"]);
|
|
141
142
|
var TAG = /^[a-z0-9-]+$/;
|
|
142
143
|
var ID_INTERNO = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
143
|
-
var CAMPO_MATCHMAKING = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
144
144
|
function oggetto(value) {
|
|
145
145
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
146
146
|
return value;
|
|
@@ -219,7 +219,7 @@ function validaDocumentoManifest(valore, pubblicazione) {
|
|
|
219
219
|
if (dati === null) return { ok: false, errori: ["manifest: must be a JSON object."] };
|
|
220
220
|
for (const campo of Object.keys(dati)) {
|
|
221
221
|
if (CAMPI_RIMOSSI.has(campo)) {
|
|
222
|
-
if (pubblicazione) errori.push(`${campo}: ${MOTIVO_RIMOSSO}`);
|
|
222
|
+
if (pubblicazione) errori.push(`${campo}: ${campo === "lobby" ? MOTIVO_LOBBY : MOTIVO_RIMOSSO}`);
|
|
223
223
|
} else if (!CAMPI.has(campo)) errori.push(`${campo}: unknown field.`);
|
|
224
224
|
}
|
|
225
225
|
if (dati.manifest === void 0) errori.push("manifest: is required and must be 1.");
|
|
@@ -389,11 +389,6 @@ function validaDocumentoManifest(valore, pubblicazione) {
|
|
|
389
389
|
}
|
|
390
390
|
}
|
|
391
391
|
}
|
|
392
|
-
let lobby = false;
|
|
393
|
-
if (dati.lobby !== void 0) {
|
|
394
|
-
if (typeof dati.lobby !== "boolean") errori.push("lobby: must be a boolean.");
|
|
395
|
-
else lobby = dati.lobby;
|
|
396
|
-
}
|
|
397
392
|
let persistent = false;
|
|
398
393
|
if (dati.persistent !== void 0) {
|
|
399
394
|
if (typeof dati.persistent !== "boolean") errori.push("persistent: must be a boolean.");
|
|
@@ -499,8 +494,8 @@ function validaDocumentoManifest(valore, pubblicazione) {
|
|
|
499
494
|
const range = oggetto(value.players);
|
|
500
495
|
if (range === null) errori.push(`${campo}: must be an object with min and max.`);
|
|
501
496
|
else {
|
|
502
|
-
for (const
|
|
503
|
-
if (
|
|
497
|
+
for (const key of Object.keys(range)) {
|
|
498
|
+
if (key !== "min" && key !== "max") errori.push(`${campo}.${key}: unknown field.`);
|
|
504
499
|
}
|
|
505
500
|
if (!interoTra(range.min, 1, TETTO_GIOCATORI)) errori.push(`${campo}.min: must be an integer from 1 to ${TETTO_GIOCATORI}.`);
|
|
506
501
|
if (!interoTra(range.max, 1, TETTO_GIOCATORI)) errori.push(`${campo}.max: must be an integer from 1 to ${TETTO_GIOCATORI}.`);
|
|
@@ -510,64 +505,19 @@ function validaDocumentoManifest(valore, pubblicazione) {
|
|
|
510
505
|
}
|
|
511
506
|
}
|
|
512
507
|
}
|
|
513
|
-
if (value.lobby !== void 0) {
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
}
|
|
524
|
-
const matchmaking = oggetto(value.matchmaking);
|
|
525
|
-
if (matchmaking === null) {
|
|
526
|
-
errori.push(`modes[${indice}].matchmaking: must be an object.`);
|
|
527
|
-
continue;
|
|
528
|
-
}
|
|
529
|
-
for (const campo of Object.keys(matchmaking)) {
|
|
530
|
-
if (!["key", "timeoutMs", "defaults"].includes(campo)) {
|
|
531
|
-
errori.push(`modes[${indice}].matchmaking.${campo}: unknown field.`);
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
let valido = true;
|
|
535
|
-
const key = [];
|
|
536
|
-
if (!Array.isArray(matchmaking.key) || matchmaking.key.length < 1 || matchmaking.key.length > 8) {
|
|
537
|
-
errori.push(`modes[${indice}].matchmaking.key: must contain from 1 to 8 fields.`);
|
|
538
|
-
valido = false;
|
|
539
|
-
} else for (const [keyIndice, item] of matchmaking.key.entries()) {
|
|
540
|
-
if (typeof item !== "string" || !CAMPO_MATCHMAKING.test(item)) {
|
|
541
|
-
errori.push(`modes[${indice}].matchmaking.key[${keyIndice}]: must be 1-32 lowercase letters, digits, or hyphens and start with a letter or digit.`);
|
|
542
|
-
valido = false;
|
|
543
|
-
} else if (key.includes(item)) {
|
|
544
|
-
errori.push(`modes[${indice}].matchmaking.key[${keyIndice}]: duplicate field ${item}.`);
|
|
545
|
-
valido = false;
|
|
546
|
-
} else key.push(item);
|
|
547
|
-
}
|
|
548
|
-
if (!interoTra(matchmaking.timeoutMs, 1e3, 3e5)) {
|
|
549
|
-
errori.push(`modes[${indice}].matchmaking.timeoutMs: must be an integer from 1000 to 300000.`);
|
|
550
|
-
valido = false;
|
|
551
|
-
}
|
|
552
|
-
let defaults;
|
|
553
|
-
if (matchmaking.defaults !== void 0) {
|
|
554
|
-
const values = oggetto(matchmaking.defaults);
|
|
555
|
-
if (values === null || Object.keys(values).length !== key.length || key.some((field) => !Object.hasOwn(values, field))) {
|
|
556
|
-
errori.push(`modes[${indice}].matchmaking.defaults: must contain exactly the declared key fields.`);
|
|
557
|
-
} else {
|
|
558
|
-
defaults = {};
|
|
559
|
-
for (const [field, value2] of Object.entries(values)) {
|
|
560
|
-
if (!(typeof value2 === "string" && value2.length >= 1 && value2.length <= 64 && /^[A-Za-z0-9_.:-]+$/.test(value2)) && !Number.isSafeInteger(value2)) {
|
|
561
|
-
errori.push(`modes[${indice}].matchmaking.defaults.${field}: must be a string of 1-64 characters or a safe integer.`);
|
|
562
|
-
} else Object.defineProperty(defaults, field, { value: value2, enumerable: true });
|
|
563
|
-
}
|
|
508
|
+
if (pubblicazione && value.lobby !== void 0) errori.push(`modes[${indice}].lobby: ${MOTIVO_LOBBY}`);
|
|
509
|
+
if (value.matchmaking !== void 0) {
|
|
510
|
+
if (oggetto(value.matchmaking) !== null) {
|
|
511
|
+
if (pubblicazione) errori.push(`modes[${indice}].matchmaking: ${MOTIVO_MATCHMAKING}`);
|
|
512
|
+
modo.matchmaking = true;
|
|
513
|
+
} else if (typeof value.matchmaking !== "boolean") {
|
|
514
|
+
errori.push(`modes[${indice}].matchmaking: must be a boolean.`);
|
|
515
|
+
} else modo.matchmaking = value.matchmaking;
|
|
516
|
+
if (pubblicazione && modo.matchmaking && (modo.players ?? players).max === 1) {
|
|
517
|
+
errori.push(`modes[${indice}].matchmaking: ${MODE_LOCAL_MESSAGE}`);
|
|
564
518
|
}
|
|
565
519
|
}
|
|
566
|
-
|
|
567
|
-
...defaults === void 0 ? {} : { defaults },
|
|
568
|
-
key,
|
|
569
|
-
timeoutMs: matchmaking.timeoutMs
|
|
570
|
-
} });
|
|
520
|
+
modes.push(modo);
|
|
571
521
|
}
|
|
572
522
|
}
|
|
573
523
|
}
|
|
@@ -591,7 +541,6 @@ function validaDocumentoManifest(valore, pubblicazione) {
|
|
|
591
541
|
network,
|
|
592
542
|
requires,
|
|
593
543
|
players,
|
|
594
|
-
lobby,
|
|
595
544
|
persistent,
|
|
596
545
|
roles,
|
|
597
546
|
teams,
|
|
@@ -934,6 +883,7 @@ ${NETWORK_GUIDANCE}
|
|
|
934
883
|
${SINGLE_PLAYER_RULE}
|
|
935
884
|
The game fills the window and draws everything: menu, lobby, invites, results, "play again". The platform draws nothing over it.
|
|
936
885
|
The kit gives functions only: player identity and language, cloud saves, the daily seed, rooms (create, join with a code, invite link, ready, rematch), matchmaking, friends and party, roles and teams, voice, and the server side of a room.
|
|
886
|
+
Player-created rooms always have a lobby with ready, roles and teams. Matchmaking uses matchmaking: true and c.room.match({ mode }); it enters a room immediately. No search screen, ready or setup choices. The server assigns roles and teams. Draw waiting below players.min as a playable practice area with a short notice. Matchmaking starts a three-second countdown at the minimum and restarts automatically eight seconds after a round.
|
|
937
887
|
Read \`.claude/skills/caisual/SKILL.md\` before creating or publishing a Caisual game.
|
|
938
888
|
Use the current guides at ${site}/publish.md and ${site}/kit.md; the index of every guide is at ${site}/llms.txt.
|
|
939
889
|
<!-- caisual:end -->`;
|
|
@@ -1158,10 +1108,10 @@ import { tmpdir } from "node:os";
|
|
|
1158
1108
|
import { basename as basename2, dirname as dirname3, extname as extname2, join as join4, resolve as resolve3 } from "node:path";
|
|
1159
1109
|
|
|
1160
1110
|
// ../../docs/publish.md
|
|
1161
|
-
var publish_default = '# Publish a game on Caisual\n\nMultiplayer budget: **100 kB/s per recipient and 2 MB/s per room**, before compression, over rolling 5-second windows. At 20 updates/s, budget **5 kB per update**. Keep visual trails and animation on the client. Game input remains limited to 30 messages/s per connection.\n\nWarnings start at 80% of either budget. These are platform budgets, not measured phone capacity. We count UTF-8 bytes of every application message actually sent to each recipient, including snapshots, changes and replies. Room traffic is their sum. Voice audio and transport headers are separate. Recipient and room peaks can come from different windows.\n\n`caisual dev` prints the budget and warns when measured traffic reaches it. Static `caisual check` does not prove network performance. Before publication, Caisual runs the uploaded server in an isolated room for every mode with resolved `players.max > 1`, fills it to its declared maximum and exercises 20 seconds of simulated time without game input. A measured excess refuses publication. A probe that cannot start or complete does not pass. No creator-written network test is required. A passing probe does not cover every action or long match.\n\nReal matches are measured with the same counter. Your account shows each version\'s worst 5-second rates and its publication probe separately. Any excess is a warning. Three consecutive measurements above either budget, at least 5 seconds apart in the same room, block new rooms for that version. Existing rooms keep running and accepting permitted joins. Publish a corrected version to open new rooms again. Monitoring never slows the simulation, drops state updates or suspends a match. There is no operations-per-tick limit. Quiet periods reset the consecutive count; room process restarts begin a new observation period, while saved version peaks and blocks remain.\n\nCaisual hosts browser games supplied as folders. Each publish creates an immutable version. It becomes current unless another version was activated after the upload opened.\nThe publishing flow supports both single-player and multiplayer games and does not require changes in the Caisual dashboard. Player identity, rooms, cloud saves, and the daily challenge come from the game kit, documented in [kit.md](./kit.md).\n\nCaisual draws nothing over the game: the game fills the window and draws its own menu, lobby, invitation, results and "play again" with the kit\'s functions. `caisual init` writes a working minimal menu in `client/menu.js` to copy or replace. See [The game draws everything](./kit.md#the-game-draws-everything).\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 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()`, and `client/menu.js`, the game\'s own menu: solo or online, create room, copy invite, join with a code, players with ready, start, play again.\n\nThe whole CLI is:\n\n```text\ncaisual init [--multiplayer | --arcade] [folder]\ncaisual dev [folder] [--port 8790] [--day YYYY-MM-DD] [--latency ms [--jitter ms] [--loss percent]]\ncaisual check [folder] [--json]\ncaisual publish [folder]\ncaisual versions [folder|id]\ncaisual rollback [folder|id] --to <n>\ncaisual unlist [folder|id]\ncaisual relist [folder|id]\ncaisual delete [folder|id] --yes\ncaisual skill\ncaisual --help\ncaisual --version\n```\n\n`caisual init --arcade my-arena` creates a complete online action starter in English and Italian. It includes a shared fixed-step simulation, an authoritative `server.js`, numbered and acknowledged controls, local prediction with reconciliation, remote interpolation, its own menu and lobby, standings and fast rematches. All `init` variants also generate `package.json` with `dev`, `test` and optional `test:browser` scripts. See [Responsive action games](./kit.md#responsive-action-games) and [Testing with a browser](/docs/local-development#testing-with-a-browser).\n\n`caisual check [folder] [--json]` runs every local check used by publish: the manifest, game texts, client files, the `server.js` bundle, the three required images, and screenshots. It needs no key and uploads nothing. It validates files without launching or playing the game in a browser. 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, [kit.md](./kit.md) and the index of the guides at [/docs](/docs) 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. `caisual init` does the same in the new game folder, so a fresh game already carries the skill.\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": { "en": "Grow huge. Guard your tail. Devour light in a cosmic arena." },\n "cover": "cover.png",\n "card": "card.png",\n "icon": "icon.png",\n "screenshots": ["screenshots/level-one.png"],\n "tags": ["puzzle"],\n "languages": ["en", "it"],\n "platform": "both",\n "orientation": "landscape",\n "input": ["keyboard", "mouse", "touch"],\n "visibility": "public",\n "network": [],\n "requires": { "webgl2": false, "webgpu": false, "wasm": false, "threads": false, "memoryMb": null, "performance": "light" },\n "players": { "min": 1, "max": 1 },\n "lobby": false,\n "persistent": false,\n "roles": [],\n "teams": null,\n "voice": "none",\n "modes": [\n { "id": "solo" }\n ]\n}\n```\n\n- `manifest` is required and must be `1`.\n- `id` is required. Use 3 to 32 lowercase ASCII letters or digits, with single hyphens only between groups. The ID becomes the URL slug. Choose it carefully because it cannot be renamed or reused after deletion.\n- `name` is required and must contain 1 to 60 characters.\n- `description` is an optional one-line subtitle, shown below the game name on cards. It defaults to an empty string and accepts a plain string or a localized object such as `{ "en": "Grow huge. Guard your tail. Devour light in a cosmic arena.", "it": "Diventa enorme. Proteggi la coda. Divora luce in un\'arena cosmica." }`. For new publications, every value must contain 1-80 characters after trimming whitespace, on one line, with a BCP 47 key listed in `languages`. The limit applies to every language, including translations other than the default. A plain empty string means no subtitle. Previously published versions with longer descriptions remain readable; publishing an update requires the new limit.\n- `cover`, `card` and `icon` are required, distinct relative file paths inside `client/`. Use PNG, JPEG or WebP, at most 2 MB (2,000,000 bytes) per image. Do not include a query, fragment, empty segment or parent segment. See the exact dimensions below.\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 required: a non-empty array of distinct BCP 47 tags that includes `en`, such as `["it", "en", "pt-BR"]`. English is always required alongside the game\'s own languages. The first entry remains the default and may be a language other than English. Tags are normalized to canonical casing. The catalog and standard menu show the available 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- `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`. Shared memory and threaded WebAssembly are unavailable inside the portal page; use a build without threads. `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 and mark themselves ready before play: the room starts on its own as soon as everyone present is ready and the minimums are met. With `false`, play starts when the first player enters and later players may join in progress. A room mode with resolved `players.max === 1` always starts on entry and bypasses the lobby, including an inherited `lobby: true`.\n- `persistent` is optional and defaults to `false`. Use `true` when room members must be able to return with the same code after disconnecting, including while the game is already playing. Persistent rooms expire after 30 days without activity.\n- `roles` is optional and defaults to `[]`. Each entry has an `id` of 1 to 32 lowercase letters, digits, or internal hyphens, a `min` integer from 0 to 24, and an optional `max` in the same range. Rooms enforce these capacities in the lobby. The name the player reads comes from the game\'s own dictionary, not from the manifest.\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. Catalogue labels consider the resolved modes, or the root range when there are no modes: Single player, Multiplayer, or Solo + Multiplayer.\n- Single player runs in the browser and never touches the server; a room exists only for two or more players. A mode with resolved `players.max: 1` is local; room creation, joining and matchmaking reject it with `mode_local`. Modes with `players.max > 1` require `server.js`, checked by the CLI and again when the version is published. A server supplied for an entirely single-player game produces a warning because it is unnecessary. The manifest carries no player-facing text for modes or roles: the game names them in its own dictionaries.\n- `matchmaking.defaults` is optional. It holds exactly the fields listed in `key`, with safe integers or strings of 1 to 64 characters from letters, digits, `_ . : -`, so the game can start a search without composing a key.\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`, always including `en`, 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()`, during setup. Render with `t(\'score\', { n: 3 })`. Use `c.player.language` when formatting dates or numbers.\n4. Translate `description` in the manifest; it is the only localized manifest field. `name` and `tags` are not localized. Mode and role names are the game\'s own text, so they live in the dictionaries with everything else.\n\nA dictionary at `client/i18n/en.json` can contain:\n\n```json\n{ "score": "Lights: {n} / 3", "done": "Complete" }\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. 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 the description from the page language in the catalogue, game page, creator profiles and invitations, including description metadata. 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 translation objects. Description translations must use languages declared in `languages`; a missing description is omitted.\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\nDeclare `languages: ["it", "en"]` to keep Italian as the default. Add English game strings and translate the description too. Declaring English does not create translations. A manifest without `languages`, or without `en`, is rejected.\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, 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\nThe 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 nothing on top, so the whole surface and every corner belong to the game. Use `env(safe-area-inset-*)` for the notch and keep the menu, the HUD and the pause action inside it.\n\nDo not register a service worker. The game runs in an iframe on its own origin inside `caisual.com`. Test it without assuming access to the parent page, parent cookies, or files outside `client/`.\n\nWhen `voice` is not `none`, the portal grants microphone access to the game iframe. The browser still asks the player for permission when the game calls `room.voice.join()`. Call it from a button click or another user gesture, not automatically when the page loads.\n\n## Multiplayer server\n\nAdd `server.js` beside `caisual.json` when the game uses rooms. It is the ESM entry point and must have an `export default`. It may import local files such as `./logic/ships.js`, including `.js`, `.ts`, and `.json` files, and npm packages installed in the game folder. The CLI bundles these imports into one file both when publishing and when starting `caisual dev`. Relative default imports of `.wasm` stay external and their files are copied separately.\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 final bundle may only import `@caisual/kit/server` and default-import `.wasm` files, for example `import engine from \'./physics/add.wasm\'`. Paths stay inside the game folder without `..` segments. The value is an already compiled `WebAssembly.Module`; use `new WebAssembly.Instance(engine, imports)` in `onCreate` or on first use. No shared memory or threads. At most 8 files are allowed, 8,000,000 bytes per file and 16,000,000 bytes total. No manifest field is added: `requires.wasm` still describes the browser client. Use this only for existing engines. Budget for compilation on room wake, because a larger binary delays resumption; recreate instances lazily and restore their state after a wake. See [WebAssembly on the server](./kit.md#webassembly-on-the-server).\n\nThe bundled `server.js` may be at most 4,000,000 bytes. Room state must remain plain JSON and may be at most 512 KB when serialized. Game messages are limited to 64 KB per frame in either direction and 30 per second per connection. Incoming service frames also have a 64 KB limit; state synchronization carries the separately limited room state. 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 30/s budget with the same drop policy. More than 150 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 256 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. Each imported binary is declared in the optional `server.wasm` array as `{ path, bytes, sha256 }`, uploaded to its `serverWasmUploads` URL with Content-Length, and verified with SHA-256. The files stay in the private server archive and follow the version retention rules. The portal validates the stored bundle, the imported file list and the binaries 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. Invalid dates are usage errors; omitting the flag uses today in UTC. Saves, identities and rooms stay shared. Room daily contexts stay fixed at creation, including on restoration; new rooms follow the simulated day. Daily `expiresAt` stays on the next real UTC midnight, so it can be compared with the unchanged server clock. 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`. Player identity, saves, 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`. Without the parameter, the game uses the browser\'s ordered preferences. Friends are empty locally. Each new browser tab gets a different guest identity, while reloading one tab keeps that tab\'s identity.\n\nOpen `/__caisual/players?n=4` on the printed portal URL for independent guest frames. Each has a Drop control that interrupts that guest\'s room connection. Use `dev --latency 120 --jitter 40 --loss 2` to delay room WebSocket messages in each direction, vary the delay and discard 2% of messages for recovery testing. Latency and jitter accept integer milliseconds from 0 to 60000, loss accepts 0 to 100 percent; jitter and loss require latency. HTTP, matchmaking and audio are unaffected. See [Local dev](/docs/local-development) for precise semantics and frame sizes.\n\nReload after client edits; restart dev after editing `caisual.json`, `server.js`, or server imports, including shared physics. Rooms restore from `.caisual-dev/`, so incompatible state changes may need a new room. There is no automatic reload. If a port is busy, dev suggests a command using a currently available port.\n\nWhen `server.js` exists, room data is stored as JSON under `.caisual-dev/` in the game folder. Single-player modes run in the browser and reject room operations with `mode_local`; multiplayer modes require `server.js`.\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 3,000 files per version.\n- At most 100,000,000 bytes per file.\n- At most 500,000,000 bytes for all files in one version.\n- At most 4,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 use a build without threads if shared memory is detected. 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## Versions and rollback\n\nNew games start on the current version. An existing room stays on its own version for its entire life, including players arriving later through an invitation, a typed code or a friend. Its client, manifest, iframe permissions and images all come from that version.\n\nEvery `publish` creates a new immutable version, even for a tiny correction. The permanent address stays `/g/<id>`. The home page sorts games by the creation date of the current version; publishing can move a game up and a rollback can move it down.\n\n## Updates while a page is open\n\nCreating a room or starting a matchmaking search from an outdated page fails with `version_outdated` and `currentVersion`. Joining an existing room from another version fails with `version_mismatch` and `roomVersion`. A room whose version is no longer ready, or whose game was deleted, shows **This room is no longer available**, with a link to the current game.\n\nMatchmaking keeps versions separate. A reservation already accepted before an update can finish on its original version. Existing rooms are not restarted by an update or rollback.\n\nThe game receives these errors both as rejected room operations and through `c.room.onError(listener)`. Show a button that calls `c.room.reload()` after either error, to load the current game or the room referenced by the failed join:\n\n```js\nc.room.onError((error) => {\n if (error.code === \'version_outdated\' || error.code === \'version_mismatch\') {\n showReloadButton(() => c.room.reload());\n }\n});\n```\n\n## List and restore versions\n\nUse the same `CAISUAL_KEY` environment variable as for publishing:\n\n```sh\ncaisual versions\ncaisual versions ./my-game\ncaisual rollback --to 7\ncaisual rollback my-game --to 7\n```\n\nThe target defaults to the current folder, or accepts a folder or game ID. `versions` lists the number, date, state and size and marks the current version. The account page also marks it.\n\nRollback selects an existing ready version of that game. It copies no files, creates no new number and does not consume the 60-version daily allowance. Visibility and moderation stay unchanged. **Rollback restores code, not saved data.**\n\nIf another version was activated while a publish was uploading, the upload becomes ready but does not replace it. The CLI reports:\n\n```text\nVersion N is ready but not current: version M was activated in the meantime. Run caisual rollback --to N to activate it.\n```\n\nA manifest\'s visibility takes effect only when activation succeeds. An `unlist` or `relist` issued during an upload wins over that upload\'s manifest visibility.\n\n## Game versions and data formats\n\nSaves belong to the game, not to a game version. The creator is responsible for data compatibility. A game version and a data format version are separate things.\n\nFor a compatible change, keep the same keys. For an incompatible change, use a new key such as `progress_v2`. Import from `progress` only when `progress_v2` is missing. Keep the original, identify the format inside each value and never overwrite a format you do not recognize. Progress written later by an old version is not automatically merged into the new key.\n\nThe daily seed does not depend on the game version. Do not change the generator or rules halfway through a UTC day without changing the board ID.\n\n## Retention and local development\n\nGood versions stay. Old ready versions are retained for rooms and rollback, without automatic age-based deletion. Failed uploads are cleaned from both client and server storage; uploads left open for more than 24 hours fail with a note and are cleaned. Version numbers are never reused. Deleting a game removes its client and server files.\n\n`caisual dev` always uses game version 1. It does not simulate publishing, version changes or rollback.\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`, `card`, `icon` and every screenshot path match a file under `client/`, including letter case.\n- `file is larger than 100 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: use a build without threads. Games embedded in the portal cannot use shared memory.\n\n## Required game images\n\nThe look of the game belongs to its creator: templates are deliberately neutral, and their placeholder images must be replaced.\n\nEvery manifest must declare three different files inside `client/`. All three are required PNG, JPEG or WebP images, at most 2 MB (2,000,000 bytes) each:\n\n| Field | Exact size | Use |\n| --- | --- | --- |\n| `cover` | 1536x1024, 3:2 | Featured home card, game page background, social preview, mobile invitation |\n| `card` | 1024x1024, 1:1 | Secondary home cards and square invitation artwork |\n| `icon` | 1024x1024, 1:1 | Game window favicon, friends and parties |\n\n**No text inside any image:** no title, slogan, letters or numbers. The portal displays the game name. Keep the icon simple enough to read at a small size. The files must be different, including `card` and `icon`. Screenshots keep their existing rules.\n\nThe 3:2 and 1:1 ratios match native formats of widely used image generators. Export at the exact sizes above: Caisual does not crop, stretch or accept a size range. `caisual check` and publish validate the format from the file headers, exact dimensions and weight. The portal repeats these checks for direct publication. The absence of text is an editorial requirement, not an automatic check.\n\nAll three `caisual init` variants include small, real PNG examples at these sizes. Replace them with artwork for your game before sharing it. English (`en`) is always required in `languages`; the first language remains the default, for example `["it", "en"]`.\n';
|
|
1111
|
+
var publish_default = '# Publish a game on Caisual\n\nMultiplayer budget: **100 kB/s per recipient and 2 MB/s per room**, before compression, over rolling 5-second windows. At 20 updates/s, budget **5 kB per update**. Keep visual trails and animation on the client. Game input remains limited to 30 messages/s per connection.\n\nWarnings start at 80% of either budget. These are platform budgets, not measured phone capacity. We count UTF-8 bytes of every application message actually sent to each recipient, including snapshots, changes and replies. Room traffic is their sum. Voice audio and transport headers are separate. Recipient and room peaks can come from different windows.\n\n`caisual dev` prints the budget and warns when measured traffic reaches it. Static `caisual check` does not prove network performance. Before publication, Caisual runs the uploaded server in an isolated room for every mode with resolved `players.max > 1`, fills it to its declared maximum and exercises 20 seconds of simulated time without game input. A measured excess refuses publication. A probe that cannot start or complete does not pass. No creator-written network test is required. A passing probe does not cover every action or long match.\n\nReal matches are measured with the same counter. Your account shows each version\'s worst 5-second rates and its publication probe separately. Any excess is a warning. Three consecutive measurements above either budget, at least 5 seconds apart in the same room, block new rooms for that version. Existing rooms keep running and accepting permitted joins. Publish a corrected version to open new rooms again. Monitoring never slows the simulation, drops state updates or suspends a match. There is no operations-per-tick limit. Quiet periods reset the consecutive count; room process restarts begin a new observation period, while saved version peaks and blocks remain.\n\nCaisual hosts browser games supplied as folders. Each publish creates an immutable version. It becomes current unless another version was activated after the upload opened.\nThe publishing flow supports both single-player and multiplayer games and does not require changes in the Caisual dashboard. Player identity, rooms, cloud saves, and the daily challenge come from the game kit, documented in [kit.md](./kit.md).\n\nCaisual draws nothing over the game: the game fills the window and draws its own menu, lobby, invitation, results and "play again" with the kit\'s functions. `caisual init` writes a working minimal menu in `client/menu.js` to copy or replace. See [The game draws everything](./kit.md#the-game-draws-everything).\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 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()`, and `client/menu.js`, the game\'s own menu: solo or online, create room, copy invite, join with a code, players with ready, start, play again.\n\nThe whole CLI is:\n\n```text\ncaisual init [--multiplayer | --arcade] [folder]\ncaisual dev [folder] [--port 8790] [--day YYYY-MM-DD] [--latency ms [--jitter ms] [--loss percent]]\ncaisual check [folder] [--json]\ncaisual publish [folder]\ncaisual versions [folder|id]\ncaisual rollback [folder|id] --to <n>\ncaisual unlist [folder|id]\ncaisual relist [folder|id]\ncaisual delete [folder|id] --yes\ncaisual skill\ncaisual --help\ncaisual --version\n```\n\n`caisual init --arcade my-arena` creates a complete online action starter in English and Italian. It includes a shared fixed-step simulation, an authoritative `server.js`, numbered and acknowledged controls, local prediction with reconciliation, remote interpolation, its own menu and lobby, standings and fast rematches. All `init` variants also generate `package.json` with `dev`, `test` and optional `test:browser` scripts. See [Responsive action games](./kit.md#responsive-action-games) and [Testing with a browser](/docs/local-development#testing-with-a-browser).\n\n`caisual check [folder] [--json]` runs every local check used by publish: the manifest, game texts, client files, the `server.js` bundle, the three required images, and screenshots. It needs no key and uploads nothing. It validates files without launching or playing the game in a browser. 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, [kit.md](./kit.md) and the index of the guides at [/docs](/docs) 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. `caisual init` does the same in the new game folder, so a fresh game already carries the skill.\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": { "en": "Grow huge. Guard your tail. Devour light in a cosmic arena." },\n "cover": "cover.png",\n "card": "card.png",\n "icon": "icon.png",\n "screenshots": ["screenshots/level-one.png"],\n "tags": ["puzzle"],\n "languages": ["en", "it"],\n "platform": "both",\n "orientation": "landscape",\n "input": ["keyboard", "mouse", "touch"],\n "visibility": "public",\n "network": [],\n "requires": { "webgl2": false, "webgpu": false, "wasm": false, "threads": false, "memoryMb": null, "performance": "light" },\n "players": { "min": 1, "max": 1 },\n "persistent": false,\n "roles": [],\n "teams": null,\n "voice": "none",\n "modes": [\n { "id": "solo" }\n ]\n}\n```\n\n- `manifest` is required and must be `1`.\n- `id` is required. Use 3 to 32 lowercase ASCII letters or digits, with single hyphens only between groups. The ID becomes the URL slug. Choose it carefully because it cannot be renamed or reused after deletion.\n- `name` is required and must contain 1 to 60 characters.\n- `description` is an optional one-line subtitle, shown below the game name on cards. It defaults to an empty string and accepts a plain string or a localized object such as `{ "en": "Grow huge. Guard your tail. Devour light in a cosmic arena.", "it": "Diventa enorme. Proteggi la coda. Divora luce in un\'arena cosmica." }`. For new publications, every value must contain 1-80 characters after trimming whitespace, on one line, with a BCP 47 key listed in `languages`. The limit applies to every language, including translations other than the default. A plain empty string means no subtitle. Previously published versions with longer descriptions remain readable; publishing an update requires the new limit.\n- `cover`, `card` and `icon` are required, distinct relative file paths inside `client/`. Use PNG, JPEG or WebP, at most 2 MB (2,000,000 bytes) per image. Do not include a query, fragment, empty segment or parent segment. See the exact dimensions below.\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 required: a non-empty array of distinct BCP 47 tags that includes `en`, such as `["it", "en", "pt-BR"]`. English is always required alongside the game\'s own languages. The first entry remains the default and may be a language other than English. Tags are normalized to canonical casing. The catalog and standard menu show the available 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- `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`. Shared memory and threaded WebAssembly are unavailable inside the portal page; use a build without threads. `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 removed at the root and on modes. Player-created rooms always have a lobby; matchmaking rooms never do. Publication and `caisual check` reject this field.\n- `persistent` is optional and defaults to `false`. Use `true` when room members must be able to return with the same code after disconnecting, including while the game is already playing. Persistent rooms expire after 30 days without activity.\n- `roles` is optional and defaults to `[]`. Each entry has an `id` of 1 to 32 lowercase letters, digits, or internal hyphens, a `min` integer from 0 to 24, and an optional `max` in the same range. Rooms enforce these capacities in the lobby. The name the player reads comes from the game\'s own dictionary, not from the manifest.\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` defaults to `[]`. Each mode has a unique `id` of 1 to 32 lowercase letters, digits or internal hyphens. Optional `players: { min, max }` replaces the whole root range. Omitted ranges inherit the root; `mode: null` uses the root. Set `matchmaking: true` for immediate entry into rooms with server-assigned roles and teams. Roles, teams, voice and persistence remain game-wide. Catalogue labels reflect the resolved player ranges.\n- Single player runs in the browser and never touches the server; a room exists only for two or more players. A mode with resolved `players.max: 1` is local; room creation, joining and matchmaking reject it with `mode_local`. Modes with `players.max > 1` require `server.js`, checked by the CLI and again when the version is published. A server supplied for an entirely single-player game produces a warning because it is unnecessary. The manifest carries no player-facing text for modes or roles: the game names them in its own dictionaries.\n- The old matchmaking object is removed. Use `"matchmaking": true`. Publication and `caisual check` reject the object form. Published manifests remain readable.\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`, always including `en`, 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()`, during setup. Render with `t(\'score\', { n: 3 })`. Use `c.player.language` when formatting dates or numbers.\n4. Translate `description` in the manifest; it is the only localized manifest field. `name` and `tags` are not localized. Mode and role names are the game\'s own text, so they live in the dictionaries with everything else.\n\nA dictionary at `client/i18n/en.json` can contain:\n\n```json\n{ "score": "Lights: {n} / 3", "done": "Complete" }\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. 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 the description from the page language in the catalogue, game page, creator profiles and invitations, including description metadata. 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 translation objects. Description translations must use languages declared in `languages`; a missing description is omitted.\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\nDeclare `languages: ["it", "en"]` to keep Italian as the default. Add English game strings and translate the description too. Declaring English does not create translations. A manifest without `languages`, or without `en`, is rejected.\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, 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\nThe 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 nothing on top, so the whole surface and every corner belong to the game. Use `env(safe-area-inset-*)` for the notch and keep the menu, the HUD and the pause action inside it.\n\nDo not register a service worker. The game runs in an iframe on its own origin inside `caisual.com`. Test it without assuming access to the parent page, parent cookies, or files outside `client/`.\n\nWhen `voice` is not `none`, the portal grants microphone access to the game iframe. The browser still asks the player for permission when the game calls `room.voice.join()`. Call it from a button click or another user gesture, not automatically when the page loads.\n\n## Multiplayer server\n\nAdd `server.js` beside `caisual.json` when the game uses rooms. It is the ESM entry point and must have an `export default`. It may import local files such as `./logic/ships.js`, including `.js`, `.ts`, and `.json` files, and npm packages installed in the game folder. The CLI bundles these imports into one file both when publishing and when starting `caisual dev`. Relative default imports of `.wasm` stay external and their files are copied separately.\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 final bundle may only import `@caisual/kit/server` and default-import `.wasm` files, for example `import engine from \'./physics/add.wasm\'`. Paths stay inside the game folder without `..` segments. The value is an already compiled `WebAssembly.Module`; use `new WebAssembly.Instance(engine, imports)` in `onCreate` or on first use. No shared memory or threads. At most 8 files are allowed, 8,000,000 bytes per file and 16,000,000 bytes total. No manifest field is added: `requires.wasm` still describes the browser client. Use this only for existing engines. Budget for compilation on room wake, because a larger binary delays resumption; recreate instances lazily and restore their state after a wake. See [WebAssembly on the server](./kit.md#webassembly-on-the-server).\n\nThe bundled `server.js` may be at most 4,000,000 bytes. Room state must remain plain JSON and may be at most 512 KB when serialized. Game messages are limited to 64 KB per frame in either direction and 30 per second per connection. Incoming service frames also have a 64 KB limit; state synchronization carries the separately limited room state. 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 30/s budget with the same drop policy. More than 150 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 256 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. Each imported binary is declared in the optional `server.wasm` array as `{ path, bytes, sha256 }`, uploaded to its `serverWasmUploads` URL with Content-Length, and verified with SHA-256. The files stay in the private server archive and follow the version retention rules. The portal validates the stored bundle, the imported file list and the binaries 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. Invalid dates are usage errors; omitting the flag uses today in UTC. Saves, identities and rooms stay shared. Room daily contexts stay fixed at creation, including on restoration; new rooms follow the simulated day. Daily `expiresAt` stays on the next real UTC midnight, so it can be compared with the unchanged server clock. 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`. Player identity, saves, 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`. Without the parameter, the game uses the browser\'s ordered preferences. Friends are empty locally. Each new browser tab gets a different guest identity, while reloading one tab keeps that tab\'s identity.\n\nOpen `/__caisual/players?n=4` on the printed portal URL for independent guest frames. Each has a Drop control that interrupts that guest\'s room connection. Use `dev --latency 120 --jitter 40 --loss 2` to delay room WebSocket messages in each direction, vary the delay and discard 2% of messages for recovery testing. Latency and jitter accept integer milliseconds from 0 to 60000, loss accepts 0 to 100 percent; jitter and loss require latency. HTTP, matchmaking and audio are unaffected. See [Local dev](/docs/local-development) for precise semantics and frame sizes.\n\nReload after client edits; restart dev after editing `caisual.json`, `server.js`, or server imports, including shared physics. Rooms restore from `.caisual-dev/`, so incompatible state changes may need a new room. There is no automatic reload. If a port is busy, dev suggests a command using a currently available port.\n\nWhen `server.js` exists, room data is stored as JSON under `.caisual-dev/` in the game folder. Single-player modes run in the browser and reject room operations with `mode_local`; multiplayer modes require `server.js`.\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 3,000 files per version.\n- At most 100,000,000 bytes per file.\n- At most 500,000,000 bytes for all files in one version.\n- At most 4,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 use a build without threads if shared memory is detected. 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## Versions and rollback\n\nNew games start on the current version. An existing room stays on its own version for its entire life, including players arriving later through an invitation, a typed code or a friend. Its client, manifest, iframe permissions and images all come from that version.\n\nEvery `publish` creates a new immutable version, even for a tiny correction. The permanent address stays `/g/<id>`. The home page sorts games by the creation date of the current version; publishing can move a game up and a rollback can move it down.\n\n## Updates while a page is open\n\nCreating a room or requesting a matchmaking room from an outdated page fails with `version_outdated` and `currentVersion`. Joining an existing room from another version fails with `version_mismatch` and `roomVersion`. A room whose version is no longer ready, or whose game was deleted, shows **This room is no longer available**, with a link to the current game.\n\nMatchmaking keeps versions separate. A reservation already accepted before an update can finish on its original version. Existing rooms are not restarted by an update or rollback.\n\nThe game receives these errors both as rejected room operations and through `c.room.onError(listener)`. Show a button that calls `c.room.reload()` after either error, to load the current game or the room referenced by the failed join:\n\n```js\nc.room.onError((error) => {\n if (error.code === \'version_outdated\' || error.code === \'version_mismatch\') {\n showReloadButton(() => c.room.reload());\n }\n});\n```\n\n## List and restore versions\n\nUse the same `CAISUAL_KEY` environment variable as for publishing:\n\n```sh\ncaisual versions\ncaisual versions ./my-game\ncaisual rollback --to 7\ncaisual rollback my-game --to 7\n```\n\nThe target defaults to the current folder, or accepts a folder or game ID. `versions` lists the number, date, state and size and marks the current version. The account page also marks it.\n\nRollback selects an existing ready version of that game. It copies no files, creates no new number and does not consume the 60-version daily allowance. Visibility and moderation stay unchanged. **Rollback restores code, not saved data.**\n\nIf another version was activated while a publish was uploading, the upload becomes ready but does not replace it. The CLI reports:\n\n```text\nVersion N is ready but not current: version M was activated in the meantime. Run caisual rollback --to N to activate it.\n```\n\nA manifest\'s visibility takes effect only when activation succeeds. An `unlist` or `relist` issued during an upload wins over that upload\'s manifest visibility.\n\n## Game versions and data formats\n\nSaves belong to the game, not to a game version. The creator is responsible for data compatibility. A game version and a data format version are separate things.\n\nFor a compatible change, keep the same keys. For an incompatible change, use a new key such as `progress_v2`. Import from `progress` only when `progress_v2` is missing. Keep the original, identify the format inside each value and never overwrite a format you do not recognize. Progress written later by an old version is not automatically merged into the new key.\n\nThe daily seed does not depend on the game version. Do not change the generator or rules halfway through a UTC day without changing the board ID.\n\n## Retention and local development\n\nGood versions stay. Old ready versions are retained for rooms and rollback, without automatic age-based deletion. Failed uploads are cleaned from both client and server storage; uploads left open for more than 24 hours fail with a note and are cleaned. Version numbers are never reused. Deleting a game removes its client and server files.\n\n`caisual dev` always uses game version 1. It does not simulate publishing, version changes or rollback.\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`, `card`, `icon` and every screenshot path match a file under `client/`, including letter case.\n- `file is larger than 100 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: use a build without threads. Games embedded in the portal cannot use shared memory.\n\n## Required game images\n\nThe look of the game belongs to its creator: templates are deliberately neutral, and their placeholder images must be replaced.\n\nEvery manifest must declare three different files inside `client/`. All three are required PNG, JPEG or WebP images, at most 2 MB (2,000,000 bytes) each:\n\n| Field | Exact size | Use |\n| --- | --- | --- |\n| `cover` | 1536x1024, 3:2 | Featured home card, game page background, social preview, mobile invitation |\n| `card` | 1024x1024, 1:1 | Secondary home cards and square invitation artwork |\n| `icon` | 1024x1024, 1:1 | Game window favicon, friends and parties |\n\n**No text inside any image:** no title, slogan, letters or numbers. The portal displays the game name. Keep the icon simple enough to read at a small size. The files must be different, including `card` and `icon`. Screenshots keep their existing rules.\n\nThe 3:2 and 1:1 ratios match native formats of widely used image generators. Export at the exact sizes above: Caisual does not crop, stretch or accept a size range. `caisual check` and publish validate the format from the file headers, exact dimensions and weight. The portal repeats these checks for direct publication. The absence of text is an editorial requirement, not an automatic check.\n\nAll three `caisual init` variants include small, real PNG examples at these sizes. Replace them with artwork for your game before sharing it. English (`en`) is always required in `languages`; the first language remains the default, for example `["it", "en"]`.\n';
|
|
1162
1112
|
|
|
1163
1113
|
// ../../docs/kit.md
|
|
1164
|
-
var kit_default = "# Caisual game kit\n\nMultiplayer budget: **100 kB/s per recipient and 2 MB/s per room**, before compression, over rolling 5-second windows. At 20 updates/s, budget **5 kB per update**. Keep visual trails and animation on the client. Game input remains limited to 30 messages/s per connection.\n\nWarnings start at 80% of either budget. These are platform budgets, not measured phone capacity. We count UTF-8 bytes of every application message actually sent to each recipient, including snapshots, changes and replies. Room traffic is their sum. Voice audio and transport headers are separate. Recipient and room peaks can come from different windows.\n\n`caisual dev` prints the budget and warns when measured traffic reaches it. Static `caisual check` does not prove network performance. Before publication, Caisual runs the uploaded server in an isolated room for every mode with resolved `players.max > 1`, fills it to its declared maximum and exercises 20 seconds of simulated time without game input. A measured excess refuses publication. A probe that cannot start or complete does not pass. No creator-written network test is required. A passing probe does not cover every action or long match.\n\nReal matches are measured with the same counter. Your account shows each version's worst 5-second rates and its publication probe separately. Any excess is a warning. Three consecutive measurements above either budget, at least 5 seconds apart in the same room, block new rooms for that version. Existing rooms keep running and accepting permitted joins. Publish a corrected version to open new rooms again. Monitoring never slows the simulation, drops state updates or suspends a match. There is no operations-per-tick limit. Quiet periods reset the consecutive count; room process restarts begin a new observation period, while saved version peaks and blocks remain.\n\nThe kit gives a published game a stable player identity, cloud saves, a daily challenge seed, multiplayer rooms with server-owned state, and the player's friends.\n\nThe kit is a single JavaScript module with no dependencies. It never touches the page and never draws anything: the game fills the window and draws its own menu, lobby, results and HUD.\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 }`. `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. On first sign-in to an account without a player identity, the browser guest is adopted with its existing id and saves. An account that already has a player identity uses that identity instead; guest data is not merged.\n- When not connected, `c.player` has `id: \"local\"`, `name: \"Guest\"`, `guest: true` and a `language`. If the host answered, its language information is kept; without a handshake, `language` is the normalized `navigator.language`, or `en`.\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, chosen by the site. Use it for game strings and formatting.\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`. The site itself speaks English, Italian, Spanish, French, German, Portuguese and Japanese; a game may declare any language.\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 sends `language`, `languagePreferences` and `gameLanguages`. The kit resolves the game language, including when player services fail after a successful handshake.\n\nWithout a handshake, no declared language list is available: `language` is the raw preference from `navigator.language`, normalized as a BCP 47 tag, or `en` if invalid or unavailable. In `caisual dev`, `?lang=ja` selects `ja` when the manifest declares it. Without `?lang=`, dev uses `navigator.languages` in order.\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`, always including `en`. English is required alongside the game's own languages; the first entry is the default and may be another language. This complete manifest supports a local game:\n\n```json\n{\n \"manifest\": 1,\n \"cover\": \"cover.png\",\n \"card\": \"card.png\",\n \"icon\": \"icon.png\",\n \"id\": \"three-lights\",\n \"name\": \"Three Lights\",\n \"platform\": \"both\",\n \"languages\": [\"en\", \"it\"],\n \"modes\": [{ \"id\": \"solo\" }]\n}\n```\n\n`client/i18n/en.json`:\n\n```json\n{ \"score\": \"Lights: {n} / 3\", \"light\": \"Light up\", \"done\": \"Complete\" }\n```\n\n`client/i18n/it.json`:\n\n```json\n{ \"score\": \"Luci: {n} / 3\", \"light\": \"Accendi\", \"done\": \"Tutte accese!\" }\n```\n\nLoad the dictionary once during setup. 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;\n function draw() {\n score.textContent = n === 3 ? t('done') : t('score', { n });\n light.textContent = t('light');\n light.disabled = n === 3;\n }\n light.onclick = () => { n += 1; draw(); };\n draw();\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\n`description` accepts a string or a language-to-text object, and resolves from the page language in the catalogue, profiles, invitations and metadata. `description` is an optional one-line subtitle, at most 80 characters after trimming whitespace in every language for new publications, for example `{ \"en\": \"Grow huge. Guard your tail. Devour light in a cosmic arena.\" }`. Each translation must be non-empty and use a key declared in `languages`; an absent field or a plain empty string omits the subtitle. Previously published versions with longer descriptions remain readable. `name` and `tags` keep their existing forms. See [manifest languages and validation](./publish.md#game-translations) for CLI checks and migration from `language`.\n\n## The game draws everything\n\nCaisual draws nothing over the game. The game gets the whole window and builds its own menu, lobby, invitation, result and \"play again\" from the calls below. There is no platform overlay, no menu, no pill, no reserved rectangles, no keyboard shortcut and no end-of-match screen. `env(safe-area-inset-*)` inside the game document is the real safe area.\n\n`caisual init` writes that minimal menu in `client/menu.js`: solo or online, create room, copy invite, join with a code, the player list with ready, start, and play again. It is about a hundred lines with no dependencies. Copy it, recolour it, or delete it and draw the same actions in your own style.\n\n```js\nconst c = await caisual.connect();\n\n// Solo: the state belongs to the game.\nstartLocalRun();\n\n// Online: the state belongs to the server.\nconst room = await c.room.create({ mode: 'duel' });\nroom.onPlayers((players) => drawLobby(players));\nroom.onStatus((status, result) => {\n if (status === 'playing') hideMenu();\n if (status === 'finished') drawResult(result); // then room.restart() for a rematch\n});\nroom.ready(true);\n```\n\nNothing has to be announced at startup: there is no handshake call, no session object and no capability flag. Draw your menu when the assets are ready. `c.connected` is `false` when the game runs outside caisual.com: rooms and friends are unavailable, everything else works on local data.\n\n### Full screen\n\nThe 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, the menu 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## Friends and party\n\n`c.crew` mirrors the player's Caisual friends inside the game, so the game can draw its own friends list and its own invitations. The platform owns the relationships; the game reads them and asks for the few actions it needs.\n\n```js\nc.crew.available; // false in a copy of the game running outside caisual.com\nc.crew.connected; // the friends channel is live\nc.crew.you; // { id, name } or null\nc.crew.friends; // [{ id, name, online, game: { slug, name, iconUrl } | null, room: { code } | null }]\nc.crew.party; // { id, leader, members: [...] } or null\nc.crew.invites; // [{ party, from: { id, name }, at }]\n\nconst stop = c.crew.onChange((crew) => drawFriends(crew));\n\nc.crew.createParty();\nc.crew.invite('player-id');\nc.crew.accept('party-id');\nc.crew.decline('party-id');\nc.crew.kick('player-id');\nc.crew.leave();\n\nconst room = await c.crew.join('player-id'); // enter the friend's room, in this game\nc.crew.follow('player-id'); // the site opens the friend's game, even another one\n```\n\n`onChange` repeats the current value immediately and returns a function that removes the listener. `join()` rejects with `no_room` when that player is not in a room, `other_game` when the friend is playing something else, which is what `follow()` is for, and `friend_not_found` for an unknown id. `follow()` hands navigation to the site, so the page changes. Outside caisual.com the lists stay empty, the actions do nothing, and `join()` rejects with `offline`.\n\nThe kit reports the player's current room to the site by itself, so friends can also join from the site with one click.\n\n## Daily challenge\n\n`c.daily` describes the day selected when the connection opened. `day`, `seed` and `expiresAt` stay fixed for that connection, including across UTC midnight. `expiresAt` is the next UTC midnight in milliseconds on the server clock.\n\n```js\nc.daily.day;\nc.daily.seed;\nc.daily.expiresAt;\nconst random = c.daily.rng();\nconst stop = c.daily.onChange(({ day, seed, expiresAt }) => {\n offerNextDailyRun({ day, seed, expiresAt });\n});\n```\n\n`onChange` reports a new `{ day, seed, expiresAt }` when the current UTC day changes. It returns an unsubscribe function and does not immediately replay the initial value. A suspended browser or a connection failure can delay the notification; the kit retries and reports the latest day when it can. The event does not mutate `c.daily` or reseed either generator. `rng()` always creates a fresh generator from the connection's original seed, and `random()` keeps advancing the original shared generator. Use the event's context explicitly for a new daily run, or open a new document. Repeated `connect()` calls return the same connection.\n\n`day` is a UTC date such as `\"2026-09-04\"`; `seed` is an unsigned 32-bit integer shared by all players of that game on that day. Both generators produce values in [0, 1). `c.time.now()` is in milliseconds aligned with the portal clock. Without a connection, daily data uses the local clock and hostname, with the same listener and generator behavior.\n\nOn the server, `room.daily` is `{ day, seed, expiresAt }`, fixed at **room creation**, persisted across sleep and rematches. Its seed matches a client connection opened for that game on the creation day. A client connected yesterday can therefore have a different `c.daily` from a room created today: send the room's daily context through game state when rendering its course. To switch a persistent or rematched room to a new daily course, create a new room.\n\n`caisual dev --day YYYY-MM-DD` fixes the simulated day and seed for new connections and rooms. Clocks stay real: `expiresAt` is the next **real** UTC midnight at connection or room creation, so comparing it with `c.time.now()` or `room.time.now()` remains valid even for a simulated date. Restored rooms keep their original daily context. Restart dev and reload to change the flag; create a new room for the newly selected day.\n\n## Saves\n\nEach player has up to 64 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## 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\n`device.isolated` measures the browser's actual `crossOriginIsolated` state. Games embedded in the portal report `false`; shared memory and threaded WebAssembly are unavailable there. This runtime probe does not enable isolation.\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## Game version changes\n\nNew games use the current version. Existing rooms retain their version for their whole life, including invitations, typed codes and friends. Matchmaking never mixes versions; an accepted reservation can finish on its original version. Saves remain per game, with data compatibility handled by the creator. See [Versions and rollback](./publish.md#versions-and-rollback).\n\nA room create or match operation rejects with `version_outdated` and `currentVersion` when the loaded game is no longer current. A join or reconnect rejects with `version_mismatch` and `roomVersion` when the room uses another version.\n\nSubscribe with `c.room.onError(listener)`, which returns an unsubscribe function. The error has `code`, `message`, and the applicable version number. The operation also rejects with that error. `c.room.reload()` reloads the page on the current game after `version_outdated`, or on the last mismatched room after `version_mismatch`. Show a button calling it after either error: it is the only way out of both.\n\n`caisual dev` always uses game version 1 and does not simulate version changes.\n\n## Rooms\n\nA room brings players into the same running game. Single player runs in the browser and never touches the server; a room exists only for two or more players. Creating and joining require a published `server.js` and a mode with `players.max > 1`. A local mode rejects room operations with `mode_local`.\n\nAn optional [MatchResult](#recognized-match-results) in `room.end` gives the game a ready shape for winners and standings, while `room.result` keeps the original JSON.\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\nA room you create always waits in its lobby, regardless of the mode\u2019s `lobby` flag.\nOnly matchmaking can drop players into a running match, when the mode declares `lobby: false`.\nRead `room.metadata.configuration.lobby` for the room\u2019s effective setting; during play, a created room only admits returning members.\n\nPass a mode id from the manifest to `create({ mode })`, or `null` to use the root configuration. Optional `players` on that mode replaces the root range. The mode\u2019s `lobby` override applies to matchmaking; joining keeps the effective configuration of the room being joined. `join()` uses `c.room.invited`; without an invitation, pass the six-character code explicitly. `invite()` returns the code and the link to copy.\n\n### Friends in a room\n\nThe kit reports the player's current room to the site by itself, so friends can see where the player is and join. See [Friends and party](#friends-and-party) for the data and the calls.\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`. Declare `matchmaking.defaults` in the mode to give the game a complete key to start a search with.\n\nRoom status is one of:\n\n- `lobby`: players are joining and choosing their setup.\n- `countdown`: everyone present is ready 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\n\nroom.ready(true);\nroom.setRole('captain');\nroom.setTeam(1);\n```\n\n`ready`, role and team are the lobby actions: draw the roster from `room.players` and call them from your own lobby. **Nobody hosts and nobody presses start.** As soon as every connected player is ready and the player, role and team minimums from the manifest are met, the room begins a three-second countdown on its own. If someone joins during the countdown, which they may, or takes back their readiness, or a minimum stops being met, the room returns to the lobby. A role or team change in the lobby clears that player's ready state. Every room member is a player. If all declared roles are full, entry fails with `room_full` before adding a member, even below `players.max`. Roles must be declared in the manifest; assigning an undeclared role fails with `invalid_role`.\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 stays connected. `room.end(result)` or `{ rematch: false }` still ends the room permanently with status `ended` and close code 4004.\n\nWith `rematch: true` in a multiplayer room, each connected player calls `room.restart()` once to accept the rematch. In `finished`, `room.players[].ready` means rematch readiness. The rematch starts by itself as soon as every connected player has accepted and the mode's `players.min` is met, including when a departure leaves that condition satisfied. Repeated calls do nothing. Calling outside `finished` fails with `rematch_unavailable`.\n\nDraw \"Play again\" with the readiness count. No member is removed for declining.\n\nAt the start, the kit clears the result to `null` and all readiness flags, then calls optional `onRestart(room)` with status `lobby` when the room 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, so the same room object stays valid. Listen to `room.onState` and `room.onStatus`.\n\n### Fast rematches\n\nThe existing `rematch: true` flow above is unchanged for multiplayer rooms. To shorten it, use:\n\n```js\nroom.end(result, { rematch: { keepSetup: true } });\n```\n\n`keepSetup` defaults to `false`. It restores the previous readiness flags, keeps roles and teams, and skips the lobby: `onRestart` runs with status `countdown`, followed by the usual three-second countdown and `onStart`. The result clears before `onRestart`. Even a mode without a lobby gets this countdown. Readiness during `finished` is still consent for the next match, separate from the previous setup. Player, role and team minimums are rechecked during the countdown; if they fail, the room returns to the lobby so the setup can be repaired.\n\nMembers who have not accepted remain in the room and join the next phase; they are not removed. Without `keepSetup`, the next phase is the usual lobby or immediate play.\n\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.\n- Disconnecting clears that member's readiness. Disconnected players do not count toward readiness or the minimum, so a departure can be what starts the rematch. 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- 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 status, not a terminal connection state: the sockets stay open. Handle it explicitly in the game's status listener, and draw the result and the rematch action there.\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.\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 30 times per second, or at the effective `room.tickRate` when that is lower and positive. With `tickRate: 0`, it still sends at most 30/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 64 KB per frame in either direction and 30 per second per connection. Incoming service frames also have a 64 KB limit; state synchronization carries the separately limited room state. 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 30/s budget with the same drop policy. More than 150 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, which is how a \"leave for now\" action works.\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. 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`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. 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`, `mode_local`, `no_server`, `no_match`, `cancelled`, `room_not_found`, `room_full`, `room_playing`, `room_ended`, `rate_limited`, `invalid_ticket`, `internal_error`, and `offline`. `mode_local` means \"Single-player modes run in the browser and have no room.\" `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`, and `onError`.\n\n### Responsive action games\n\n1. Start with `npx @caisual/cli init --arcade my-arena`, a complete English/Italian canvas game with shared rules and tests.\n2. Accumulate `deltaSeconds` on the server and advance shared physics at a fixed 1/60 second step.\n3. Keep positions, collision or range checks, cooldowns and scores authoritative in `server.js`.\n4. Use `room.input()` for continuous controls and `room.send()` for discrete actions such as the starter's pulse.\n5. Number commands in the game and publish each player's last **applied** `ack` in `room.state`; transport sequence numbers are separate.\n6. Because `input()` coalesces values, send a bounded batch of unacknowledged commands; deduplicate on the server and consume at most one per simulation step.\n7. Predict your own entity with the shared step, replace it with each authoritative snapshot, then replay only commands after `ack`; ease corrections in the drawing only.\n8. Buffer remote samples by the `onState` timestamp and render behind `room.serverTime()`, accounting for RTT in `room.latency` and the effective `room.tickRate`.\n9. Clear pending controls on reconnection or a new round; keep the HUD inside the safe areas, and end with `standings` plus a `keepSetup` rematch.\n10. Run the generated browser fixture and `/__caisual/players?n=2` with `dev --latency 120 --jitter 40 --loss 2`; see [Testing with a browser](/docs/local-development#testing-with-a-browser).\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\nThe game draws the voice controls, and must offer an explicit one: `join()` has to 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.\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`, `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 everyone present is ready, 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 when a rematch starts, after clearing the result and selecting the next status. Readiness is reset by default or restored with `keepSetup`. The `keepSetup` and solo matchmaking rules above override the default 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;\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');\nroom.schedule(milliseconds, 'methodName', payload);\n\nroom.daily.day;\nroom.daily.seed;\nroom.daily.expiresAt;\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 512 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 is enabled with `true` or an options object. 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 256 KB. `schedule` names a method on the definition so it can run even after a quiet room resumes.\n\n### Recognized match results\n\n`room.end(result)` still accepts any JSON. The optional `MatchResult` type is exported by the contracts and by `@caisual/kit` and `@caisual/kit/server`:\n\n```ts\ntype MatchResult = {\n standings: Array<{ playerId: string; score?: number; rank?: number }>;\n winners?: string[];\n draw?: boolean;\n unit?: 'points' | 'time' | 'distance' | string;\n data?: JsonValue;\n} & Record<string, JsonValue>;\n```\n\nOrder `standings` from first place onward. Scores must be finite numbers; ranks are positive safe integers. `draw: true` takes precedence; otherwise `winners` identifies the winners. Without `winners`, the first row wins, together with rows sharing its explicit rank. Equal scores alone do not imply a draw. An explicit empty `winners` list means nobody won.\n\nThis shape is a convention, not a screen: the game reads `room.result` and draws its own result. `time` and `distance` in `unit` do not convert values or imply a measurement scale. **`room.result` keeps the original JSON without normalization**, including `data` and any other game fields. Results are public to every room member, so keep secrets elsewhere.\n\n```js\nroom.end({\n standings: [{ playerId: winner.id, score: 12, rank: 1 }, { playerId: other.id, score: 9, rank: 2 }],\n winners: [winner.id], unit: 'points', data: { rounds: 3 },\n}, { rematch: true });\n```\n\n### Hidden information\n\nEverything in `room.state` reaches every player in the room. 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 })`.\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 fixed at room creation and shared by rooms created for that game on the same 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### WebAssembly on the server\n\nUse WebAssembly only to bring an existing engine, such as physics, pathfinding or a Rust simulation. For new game logic, start with JavaScript.\n\n```js\nimport { defineGame } from '@caisual/kit/server';\nimport engine from './physics/add.wasm';\n\nconst engines = new WeakMap();\nfunction instance(room) {\n if (!engines.has(room)) engines.set(room, new WebAssembly.Instance(engine, {}));\n return engines.get(room);\n}\n\nexport default defineGame({\n tickRate: 0,\n onCreate(room) {\n room.state = { sum: instance(room).exports.add(19, 23) };\n },\n onMessage(room) {\n room.state.sum = instance(room).exports.add(room.state.sum, 1);\n },\n});\n```\n\nA default import such as `import engine from './physics/add.wasm'` returns an already compiled `WebAssembly.Module`. Instantiate it in `onCreate` or on first use with `new WebAssembly.Instance(engine, imports)`. Paths must start with `./`, stay inside the game folder and contain no `..` segments. Named, namespace and dynamic imports of `.wasm` are not supported. Shared memory and threads are not supported.\n\nThe room server accepts at most **8 `.wasm` files, 8,000,000 bytes per file and 16,000,000 bytes in total**, in addition to the 4,000,000-byte `server.js` limit. The CLI discovers them from the bundle and uploads them privately alongside the server, checking size and SHA-256. There is no manifest change; `requires.wasm` describes the browser client only. `caisual check` and publish enforce these limits, and `caisual dev` compiles the same files locally. Restart dev after changing a binary.\n\nBudget for compilation when the room wakes: a large binary makes resumption slower. The platform may reuse compiled code for the same game version, but reuse is not guaranteed. Instances and their memory are temporary, so recreate an instance on first use after a wake and restore any engine state from room JSON state or room saves. `onCreate` does not run again after a wake. Never put a module, instance or binary memory in `room.state`.\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. To offer a \"come back\" action, store `room.code` with `c.save.set()` and call `c.room.join(code)` later. 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: 64 keys per player per game, 256 KB per value.\n- Room state: 512 KB of plain JSON.\n- Game messages: 64 KB each and 30/s per connection; excess messages are dropped with at most one `rate_limited` error per second. Service messages have a separate 30/s budget. More than 150 attempts/s in either budget for three consecutive one-second windows closes with 4008. Oversized frames close with 4009 `message_too_large`.\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: 256 KB each.\n\n## Development\n\nRun `npx @caisual/cli dev` from the game folder, then open the printed portal URL. The preview supplies the same handshake as the site, so `c.connected` is `true` and the game receives a local guest identity. `?lang=` chooses the language preference, resolved against the manifest: `?lang=ja` gives `c.player.language === \"ja\"` when declared. Friends are empty locally, so `c.crew.friends` is `[]`; everything else, including saves, 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. 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. Saves, identities and rooms are shared across these dates. Existing rooms retain their creation context; new rooms follow the selected day. `expiresAt` follows the real clock even with `--day`. A changed flag takes effect after restarting dev and reloading the game.\n\nOpen the printed `/__caisual/players?n=4` URL for 1 to 8 independent guest frames. Use the game's own menu to create a room in one frame and join its code in the others. Each frame has **Drop** (1 to 60 seconds, default 3) to interrupt that guest's room connection. Phone frames are 390 x 844; the desktop preset is 960 x 640. Add `&lang=it` to test Italian.\n\n`dev --latency 120 --jitter 40 --loss 2` delays room WebSocket messages in both directions. Latency and jitter are integer milliseconds from 0 to 60000; loss is a percentage from 0 to 100 and may be fractional. Jitter and loss require `--latency`, which may be zero. Jitter varies delay uniformly within plus/minus the supplied value, clamped at zero, and preserves message order. Loss discards whole application messages, including protocol messages, to exercise recovery; it is not a model of TCP packet retransmission. HTTP, matchmaking and audio are unaffected. Drop closes the guest's room sockets and prevents successful reconnects for the selected duration; the kit's retry schedule can make the return later.\n\nClient files are read on each request: reload the portal or frame after editing, or rebuild into `client/` first if using a bundler. There is no automatic browser reload. Restart dev after changing `caisual.json`, `server.js`, or any server import (including shared client physics). Rooms restore from `.caisual-dev/`; changing the shape of their state may require creating a fresh room. A busy port error suggests a command with a currently available port.\n\nEvery `init` variant generates Node test and optional Playwright browser scripts. Playwright is a development dependency of the generated game, never of the CLI. See [Local dev](/docs/local-development#testing-with-a-browser) for installation and the full browser workflow.\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. Single-player modes reject room operations with `mode_local`. Multiplayer modes require `server.js`.\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, 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\nSingle player runs in the browser and never touches the server; a room exists only for two or more players. A mode is local when its resolved `players.max` is 1. Modes with `players.max > 1` require `server.js`. The manifest carries no text for the platform to draw: the names the player reads live in the game's own dictionaries. A mode with matchmaking adds `matchmaking.defaults`, one value for every field of its `key`, so the game can start a search without composing one.\n\n```json\n{\n \"players\": { \"min\": 2, \"max\": 4 },\n \"lobby\": true,\n \"modes\": [\n { \"id\": \"practice\", \"players\": { \"min\": 1, \"max\": 1 }, \"lobby\": false },\n { \"id\": \"duel\",\n \"matchmaking\": { \"key\": [\"pool\"], \"defaults\": { \"pool\": \"v1\" }, \"timeoutMs\": 12000 } }\n ]\n}\n```\n\nNo manifest field is required for identity, saves, or the daily challenge. 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, with lobby forced on for player-created rooms. For rooms, set `players` to the supported range and use `lobby`, `persistent`, `roles`, `teams`, and `modes` to describe the setup and lifetime. Set `voice` to `room`, `team`, or `proximity` to enable the corresponding voice mode, or omit it for `none`. A single-player game keeps `players` at `{ \"min\": 1, \"max\": 1 }`, `lobby` at `false`, and needs no `server.js`. See [publish.md](./publish.md#caisualjson) for every field and the publishing steps.\n\n## Required game images\n\nEnglish (`en`) is required in `languages`; three distinct files inside `client/` with no text inside are also required: cover 1536x1024 (3:2), card 1024x1024 and icon 1024x1024, each PNG, JPEG or WebP and at most 2 MB. See [Manifest](https://caisual.com/docs/manifest#required-game-images).\n";
|
|
1114
|
+
var kit_default = "# Caisual game kit\n\nMultiplayer budget: **100 kB/s per recipient and 2 MB/s per room**, before compression, over rolling 5-second windows. At 20 updates/s, budget **5 kB per update**. Keep visual trails and animation on the client. Game input remains limited to 30 messages/s per connection.\n\nWarnings start at 80% of either budget. These are platform budgets, not measured phone capacity. We count UTF-8 bytes of every application message actually sent to each recipient, including snapshots, changes and replies. Room traffic is their sum. Voice audio and transport headers are separate. Recipient and room peaks can come from different windows.\n\n`caisual dev` prints the budget and warns when measured traffic reaches it. Static `caisual check` does not prove network performance. Before publication, Caisual runs the uploaded server in an isolated room for every mode with resolved `players.max > 1`, fills it to its declared maximum and exercises 20 seconds of simulated time without game input. A measured excess refuses publication. A probe that cannot start or complete does not pass. No creator-written network test is required. A passing probe does not cover every action or long match.\n\nReal matches are measured with the same counter. Your account shows each version's worst 5-second rates and its publication probe separately. Any excess is a warning. Three consecutive measurements above either budget, at least 5 seconds apart in the same room, block new rooms for that version. Existing rooms keep running and accepting permitted joins. Publish a corrected version to open new rooms again. Monitoring never slows the simulation, drops state updates or suspends a match. There is no operations-per-tick limit. Quiet periods reset the consecutive count; room process restarts begin a new observation period, while saved version peaks and blocks remain.\n\nThe kit gives a published game a stable player identity, cloud saves, a daily challenge seed, multiplayer rooms with server-owned state, and the player's friends.\n\nThe kit is a single JavaScript module with no dependencies. It never touches the page and never draws anything: the game fills the window and draws its own menu, lobby, results and HUD.\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 }`. `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. On first sign-in to an account without a player identity, the browser guest is adopted with its existing id and saves. An account that already has a player identity uses that identity instead; guest data is not merged.\n- When not connected, `c.player` has `id: \"local\"`, `name: \"Guest\"`, `guest: true` and a `language`. If the host answered, its language information is kept; without a handshake, `language` is the normalized `navigator.language`, or `en`.\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, chosen by the site. Use it for game strings and formatting.\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`. The site itself speaks English, Italian, Spanish, French, German, Portuguese and Japanese; a game may declare any language.\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 sends `language`, `languagePreferences` and `gameLanguages`. The kit resolves the game language, including when player services fail after a successful handshake.\n\nWithout a handshake, no declared language list is available: `language` is the raw preference from `navigator.language`, normalized as a BCP 47 tag, or `en` if invalid or unavailable. In `caisual dev`, `?lang=ja` selects `ja` when the manifest declares it. Without `?lang=`, dev uses `navigator.languages` in order.\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`, always including `en`. English is required alongside the game's own languages; the first entry is the default and may be another language. This complete manifest supports a local game:\n\n```json\n{\n \"manifest\": 1,\n \"cover\": \"cover.png\",\n \"card\": \"card.png\",\n \"icon\": \"icon.png\",\n \"id\": \"three-lights\",\n \"name\": \"Three Lights\",\n \"platform\": \"both\",\n \"languages\": [\"en\", \"it\"],\n \"modes\": [{ \"id\": \"solo\" }]\n}\n```\n\n`client/i18n/en.json`:\n\n```json\n{ \"score\": \"Lights: {n} / 3\", \"light\": \"Light up\", \"done\": \"Complete\" }\n```\n\n`client/i18n/it.json`:\n\n```json\n{ \"score\": \"Luci: {n} / 3\", \"light\": \"Accendi\", \"done\": \"Tutte accese!\" }\n```\n\nLoad the dictionary once during setup. 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;\n function draw() {\n score.textContent = n === 3 ? t('done') : t('score', { n });\n light.textContent = t('light');\n light.disabled = n === 3;\n }\n light.onclick = () => { n += 1; draw(); };\n draw();\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\n`description` accepts a string or a language-to-text object, and resolves from the page language in the catalogue, profiles, invitations and metadata. `description` is an optional one-line subtitle, at most 80 characters after trimming whitespace in every language for new publications, for example `{ \"en\": \"Grow huge. Guard your tail. Devour light in a cosmic arena.\" }`. Each translation must be non-empty and use a key declared in `languages`; an absent field or a plain empty string omits the subtitle. Previously published versions with longer descriptions remain readable. `name` and `tags` keep their existing forms. See [manifest languages and validation](./publish.md#game-translations) for CLI checks and migration from `language`.\n\n## The game draws everything\n\nCaisual draws nothing over the game. The game gets the whole window and builds its own menu, lobby, invitation, result and \"play again\" from the calls below. There is no platform overlay, no menu, no pill, no reserved rectangles, no keyboard shortcut and no end-of-match screen. `env(safe-area-inset-*)` inside the game document is the real safe area.\n\n`caisual init` writes that minimal menu in `client/menu.js`: solo or online, create room, copy invite, join with a code, the player list with ready, start, and play again. It is about a hundred lines with no dependencies. Copy it, recolour it, or delete it and draw the same actions in your own style.\n\n```js\nconst c = await caisual.connect();\n\n// Solo: the state belongs to the game.\nstartLocalRun();\n\n// Online: the state belongs to the server.\nconst room = await c.room.create({ mode: 'duel' });\nroom.onPlayers((players) => drawLobby(players));\nroom.onStatus((status, result) => {\n if (status === 'playing') hideMenu();\n if (status === 'finished') drawResult(result); // then room.restart() for a rematch\n});\nroom.ready(true);\n```\n\nNothing has to be announced at startup: there is no handshake call, no session object and no capability flag. Draw your menu when the assets are ready. `c.connected` is `false` when the game runs outside caisual.com: rooms and friends are unavailable, everything else works on local data.\n\n### Full screen\n\nThe 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, the menu 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## Friends and party\n\n`c.crew` mirrors the player's Caisual friends inside the game, so the game can draw its own friends list and its own invitations. The platform owns the relationships; the game reads them and asks for the few actions it needs.\n\n```js\nc.crew.available; // false in a copy of the game running outside caisual.com\nc.crew.connected; // the friends channel is live\nc.crew.you; // { id, name } or null\nc.crew.friends; // [{ id, name, online, game: { slug, name, iconUrl } | null, room: { code } | null }]\nc.crew.party; // { id, leader, members: [...] } or null\nc.crew.invites; // [{ party, from: { id, name }, at }]\n\nconst stop = c.crew.onChange((crew) => drawFriends(crew));\n\nc.crew.createParty();\nc.crew.invite('player-id');\nc.crew.accept('party-id');\nc.crew.decline('party-id');\nc.crew.kick('player-id');\nc.crew.leave();\n\nconst room = await c.crew.join('player-id'); // enter the friend's room, in this game\nc.crew.follow('player-id'); // the site opens the friend's game, even another one\n```\n\n`onChange` repeats the current value immediately and returns a function that removes the listener. `join()` rejects with `no_room` when that player is not in a room, `other_game` when the friend is playing something else, which is what `follow()` is for, and `friend_not_found` for an unknown id. `follow()` hands navigation to the site, so the page changes. Outside caisual.com the lists stay empty, the actions do nothing, and `join()` rejects with `offline`.\n\nThe kit reports the player's current room to the site by itself, so friends can also join from the site with one click.\n\n## Daily challenge\n\n`c.daily` describes the day selected when the connection opened. `day`, `seed` and `expiresAt` stay fixed for that connection, including across UTC midnight. `expiresAt` is the next UTC midnight in milliseconds on the server clock.\n\n```js\nc.daily.day;\nc.daily.seed;\nc.daily.expiresAt;\nconst random = c.daily.rng();\nconst stop = c.daily.onChange(({ day, seed, expiresAt }) => {\n offerNextDailyRun({ day, seed, expiresAt });\n});\n```\n\n`onChange` reports a new `{ day, seed, expiresAt }` when the current UTC day changes. It returns an unsubscribe function and does not immediately replay the initial value. A suspended browser or a connection failure can delay the notification; the kit retries and reports the latest day when it can. The event does not mutate `c.daily` or reseed either generator. `rng()` always creates a fresh generator from the connection's original seed, and `random()` keeps advancing the original shared generator. Use the event's context explicitly for a new daily run, or open a new document. Repeated `connect()` calls return the same connection.\n\n`day` is a UTC date such as `\"2026-09-04\"`; `seed` is an unsigned 32-bit integer shared by all players of that game on that day. Both generators produce values in [0, 1). `c.time.now()` is in milliseconds aligned with the portal clock. Without a connection, daily data uses the local clock and hostname, with the same listener and generator behavior.\n\nOn the server, `room.daily` is `{ day, seed, expiresAt }`, fixed at **room creation**, persisted across sleep and rematches. Its seed matches a client connection opened for that game on the creation day. A client connected yesterday can therefore have a different `c.daily` from a room created today: send the room's daily context through game state when rendering its course. To switch a persistent or rematched room to a new daily course, create a new room.\n\n`caisual dev --day YYYY-MM-DD` fixes the simulated day and seed for new connections and rooms. Clocks stay real: `expiresAt` is the next **real** UTC midnight at connection or room creation, so comparing it with `c.time.now()` or `room.time.now()` remains valid even for a simulated date. Restored rooms keep their original daily context. Restart dev and reload to change the flag; create a new room for the newly selected day.\n\n## Saves\n\nEach player has up to 64 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## 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\n`device.isolated` measures the browser's actual `crossOriginIsolated` state. Games embedded in the portal report `false`; shared memory and threaded WebAssembly are unavailable there. This runtime probe does not enable isolation.\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## Game version changes\n\nNew games use the current version. Existing rooms retain their version for their whole life, including invitations, typed codes and friends. Matchmaking never mixes versions; an accepted reservation can finish on its original version. Saves remain per game, with data compatibility handled by the creator. See [Versions and rollback](./publish.md#versions-and-rollback).\n\nA room create or match operation rejects with `version_outdated` and `currentVersion` when the loaded game is no longer current. A join or reconnect rejects with `version_mismatch` and `roomVersion` when the room uses another version.\n\nSubscribe with `c.room.onError(listener)`, which returns an unsubscribe function. The error has `code`, `message`, and the applicable version number. The operation also rejects with that error. `c.room.reload()` reloads the page on the current game after `version_outdated`, or on the last mismatched room after `version_mismatch`. Show a button calling it after either error: it is the only way out of both.\n\n`caisual dev` always uses game version 1 and does not simulate version changes.\n\n## Rooms\n\nA room brings players into the same running game. Single player runs in the browser and never touches the server; a room exists only for two or more players. Creating and joining require a published `server.js` and a mode with `players.max > 1`. A local mode rejects room operations with `mode_local`.\n\nAn optional [MatchResult](#recognized-match-results) in `room.end` gives the game a ready shape for winners and standings, while `room.result` keeps the original JSON.\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\nA room you create always has a lobby. Matchmaking rooms never have one.\nRead `room.origin` (`player` or `matchmaking`) to distinguish them. Games published before version 0.23 read `room.metadata.configuration.lobby` instead: it still works and is `true` exactly when the origin is `player`. During play, a player-created room only admits returning members. Matchmaking admits new players up to `players.max`.\n\nPass a mode id from the manifest to `create({ mode })`, or `null` to use the root configuration. Optional `players` on that mode replaces the root range. Joining keeps the configuration and origin of the room being joined. `join()` uses `c.room.invited`; without an invitation, pass the six-character code explicitly. `invite()` returns the code and the link to copy.\n\n### Friends in a room\n\nThe kit reports the player's current room to the site by itself, so friends can see where the player is and join. See [Friends and party](#friends-and-party) for the data and the calls.\n\n### Matchmaking\n\nDeclare `\"matchmaking\": true` on a mode with resolved `players.max > 1`.\n\n```js\nconst room = await c.room.match({ mode: 'arena' });\n```\n\nYou enter immediately, even alone. There is no search screen or lobby. Roles and teams are assigned by the server. Below `players.min`, `room.status` is `waiting`: draw a playable practice area with a short notice above the scene. At the minimum, a three-second countdown starts. New players can join during countdown and play without resetting it.\n\nA normal round ends in `finished` and restarts automatically after eight seconds. `ready()` and `restart()` are ignored. Lobby role and team calls fail with `matchmaking_room`; gameplay `requestRole()` remains available when the server supports it. Make character and equipment choices during the first seconds of play with a timer.\n\nPass an optional `signal` to cancel before assignment (`cancelled`). Errors are `invalid_request`, `mode_local`, `no_server`, `rate_limited`, `offline`, `version_outdated` and `room_full`. See [Matchmaking](/docs/matchmaking) for examples and the full waiting rules.\n\nRoom status is one of:\n\n- `waiting`: a matchmaking room is below `players.min`. Draw a playable practice scene.\n\n- `lobby`: players are joining and choosing their setup.\n- `countdown`: play begins at the announced server time, three seconds after the start condition is met.\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\n\nroom.ready(true);\nroom.setRole('captain');\nroom.setTeam(1);\n```\n\n`ready`, role and team are the lobby actions for player-created rooms: draw the roster from `room.players` and call them from your own lobby. **Nobody hosts and nobody presses start.** As soon as every connected player is ready and the player, role and team minimums from the manifest are met, the room begins a three-second countdown on its own. If someone joins during the countdown, which they may, or takes back their readiness, or a minimum stops being met, the room returns to the lobby. A role or team change in the lobby clears that player's ready state. Every room member is a player. If all declared roles are full, entry fails with `room_full` before adding a member, even below `players.max`. Roles must be declared in the manifest; assigning an undeclared role fails with `invalid_role`.\n\n### Rematch in the same room\n\nMatchmaking rooms restart automatically eight seconds after a normal round ends. `restart()` and `ready()` are ignored. Reset state in `onRestart`; the next phase is `waiting` below the minimum, otherwise `countdown`. See [Matchmaking](/docs/matchmaking).\n\n\nIn player-created rooms, the 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 stays connected. `room.end(result)` or `{ rematch: false }` still ends the room permanently with status `ended` and close code 4004.\n\nWith `rematch: true` in a multiplayer room, each connected player calls `room.restart()` once to accept the rematch. In `finished`, `room.players[].ready` means rematch readiness. The rematch starts by itself as soon as every connected player has accepted and the mode's `players.min` is met, including when a departure leaves that condition satisfied. Repeated calls do nothing. Calling outside `finished` fails with `rematch_unavailable`.\n\nDraw \"Play again\" with the readiness count. No member is removed for declining.\n\nAt the start, the kit clears the result to `null` and all readiness flags, then calls optional `onRestart(room)` with status `lobby`. **The kit does not reset `room.state`.** Reset match data in `onRestart`, keeping series scores or other data as needed. Players choose their setup and get ready again before the normal countdown and `onStart`. Clients receive the new state and an `onStatus` transition with a null result. The room identity, seed and tick sequence are retained, so the same room object stays valid. Listen to `room.onState` and `room.onStatus`.\n\n### Fast rematches\n\nThe existing `rematch: true` flow above is unchanged for multiplayer rooms. To shorten it, use:\n\n```js\nroom.end(result, { rematch: { keepSetup: true } });\n```\n\n`keepSetup` defaults to `false`. It restores the previous readiness flags, keeps roles and teams, and skips the lobby: `onRestart` runs with status `countdown`, followed by the usual three-second countdown and `onStart`. The result clears before `onRestart`. Readiness during `finished` is still consent for the next match, separate from the previous setup. Player, role and team minimums are rechecked during the countdown; if they fail, the room returns to the lobby so the setup can be repaired.\n\nMembers who have not accepted remain in the room and join the next phase; they are not removed. Without `keepSetup`, the next phase is the usual lobby.\n\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.\n- Disconnecting clears that member's readiness. Disconnected players do not count toward readiness or the minimum, so a departure can be what starts the rematch. 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. Player-created rooms reopen admission while `finished`. Disconnected members still occupy seats until removed. Roles and teams retain their existing capacity rules.\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 status, not a terminal connection state: the sockets stay open. Handle it explicitly in the game's status listener, and draw the result and the rematch action there.\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.\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 30 times per second, or at the effective `room.tickRate` when that is lower and positive. With `tickRate: 0`, it still sends at most 30/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 64 KB per frame in either direction and 30 per second per connection. Incoming service frames also have a 64 KB limit; state synchronization carries the separately limited room state. 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 30/s budget with the same drop policy. More than 150 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, which is how a \"leave for now\" action works.\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. 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`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. 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`, `mode_local`, `no_server`, `cancelled`, `room_not_found`, `room_full`, `room_playing`, `room_ended`, `rate_limited`, `invalid_ticket`, `internal_error`, and `offline`. `mode_local` means \"Single-player modes run in the browser and have no room.\" `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`, and `onError`.\n\n### Responsive action games\n\n1. Start with `npx @caisual/cli init --arcade my-arena`, a complete English/Italian canvas game with shared rules and tests.\n2. Accumulate `deltaSeconds` on the server and advance shared physics at a fixed 1/60 second step.\n3. Keep positions, collision or range checks, cooldowns and scores authoritative in `server.js`.\n4. Use `room.input()` for continuous controls and `room.send()` for discrete actions such as the starter's pulse.\n5. Number commands in the game and publish each player's last **applied** `ack` in `room.state`; transport sequence numbers are separate.\n6. Because `input()` coalesces values, send a bounded batch of unacknowledged commands; deduplicate on the server and consume at most one per simulation step.\n7. Predict your own entity with the shared step, replace it with each authoritative snapshot, then replay only commands after `ack`; ease corrections in the drawing only.\n8. Buffer remote samples by the `onState` timestamp and render behind `room.serverTime()`, accounting for RTT in `room.latency` and the effective `room.tickRate`.\n9. Clear pending controls on reconnection or a new round; keep the HUD inside the safe areas, and end with `standings` plus a `keepSetup` rematch.\n10. Run the generated browser fixture and `/__caisual/players?n=2` with `dev --latency 120 --jitter 40 --loss 2`; see [Testing with a browser](/docs/local-development#testing-with-a-browser).\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\nThe game draws the voice controls, and must offer an explicit one: `join()` has to 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.\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`, `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. The countdown completes before `onStart`, for both room origins.\n- `onStart` runs when the room changes to `playing`. The three-second countdown starts at the connected player minimum in matchmaking, or when all player-created room members are ready and setup minimums are met.\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 when a rematch starts, after clearing the result and selecting the next status. Readiness is reset by default or restored with `keepSetup`. Player-created rooms prepare `lobby`, or `countdown` with `keepSetup`. Matchmaking prepares `waiting` after eight seconds, then starts the countdown if the minimum is met. 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;\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');\nroom.schedule(milliseconds, 'methodName', payload);\n\nroom.daily.day;\nroom.daily.seed;\nroom.daily.expiresAt;\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 512 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 is enabled with `true` or an options object. 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 256 KB. `schedule` names a method on the definition so it can run even after a quiet room resumes.\n\n### Recognized match results\n\n`room.end(result)` still accepts any JSON. The optional `MatchResult` type is exported by the contracts and by `@caisual/kit` and `@caisual/kit/server`:\n\n```ts\ntype MatchResult = {\n standings: Array<{ playerId: string; score?: number; rank?: number }>;\n winners?: string[];\n draw?: boolean;\n unit?: 'points' | 'time' | 'distance' | string;\n data?: JsonValue;\n} & Record<string, JsonValue>;\n```\n\nOrder `standings` from first place onward. Scores must be finite numbers; ranks are positive safe integers. `draw: true` takes precedence; otherwise `winners` identifies the winners. Without `winners`, the first row wins, together with rows sharing its explicit rank. Equal scores alone do not imply a draw. An explicit empty `winners` list means nobody won.\n\nThis shape is a convention, not a screen: the game reads `room.result` and draws its own result. `time` and `distance` in `unit` do not convert values or imply a measurement scale. **`room.result` keeps the original JSON without normalization**, including `data` and any other game fields. Results are public to every room member, so keep secrets elsewhere.\n\n```js\nroom.end({\n standings: [{ playerId: winner.id, score: 12, rank: 1 }, { playerId: other.id, score: 9, rank: 2 }],\n winners: [winner.id], unit: 'points', data: { rounds: 3 },\n}, { rematch: true });\n```\n\n### Hidden information\n\nEverything in `room.state` reaches every player in the room. 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 })`.\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 fixed at room creation and shared by rooms created for that game on the same 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### WebAssembly on the server\n\nUse WebAssembly only to bring an existing engine, such as physics, pathfinding or a Rust simulation. For new game logic, start with JavaScript.\n\n```js\nimport { defineGame } from '@caisual/kit/server';\nimport engine from './physics/add.wasm';\n\nconst engines = new WeakMap();\nfunction instance(room) {\n if (!engines.has(room)) engines.set(room, new WebAssembly.Instance(engine, {}));\n return engines.get(room);\n}\n\nexport default defineGame({\n tickRate: 0,\n onCreate(room) {\n room.state = { sum: instance(room).exports.add(19, 23) };\n },\n onMessage(room) {\n room.state.sum = instance(room).exports.add(room.state.sum, 1);\n },\n});\n```\n\nA default import such as `import engine from './physics/add.wasm'` returns an already compiled `WebAssembly.Module`. Instantiate it in `onCreate` or on first use with `new WebAssembly.Instance(engine, imports)`. Paths must start with `./`, stay inside the game folder and contain no `..` segments. Named, namespace and dynamic imports of `.wasm` are not supported. Shared memory and threads are not supported.\n\nThe room server accepts at most **8 `.wasm` files, 8,000,000 bytes per file and 16,000,000 bytes in total**, in addition to the 4,000,000-byte `server.js` limit. The CLI discovers them from the bundle and uploads them privately alongside the server, checking size and SHA-256. There is no manifest change; `requires.wasm` describes the browser client only. `caisual check` and publish enforce these limits, and `caisual dev` compiles the same files locally. Restart dev after changing a binary.\n\nBudget for compilation when the room wakes: a large binary makes resumption slower. The platform may reuse compiled code for the same game version, but reuse is not guaranteed. Instances and their memory are temporary, so recreate an instance on first use after a wake and restore any engine state from room JSON state or room saves. `onCreate` does not run again after a wake. Never put a module, instance or binary memory in `room.state`.\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. To offer a \"come back\" action, store `room.code` with `c.save.set()` and call `c.room.join(code)` later. 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: 64 keys per player per game, 256 KB per value.\n- Room state: 512 KB of plain JSON.\n- Game messages: 64 KB each and 30/s per connection; excess messages are dropped with at most one `rate_limited` error per second. Service messages have a separate 30/s budget. More than 150 attempts/s in either budget for three consecutive one-second windows closes with 4008. Oversized frames close with 4009 `message_too_large`.\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: 256 KB each.\n\n## Development\n\nRun `npx @caisual/cli dev` from the game folder, then open the printed portal URL. The preview supplies the same handshake as the site, so `c.connected` is `true` and the game receives a local guest identity. `?lang=` chooses the language preference, resolved against the manifest: `?lang=ja` gives `c.player.language === \"ja\"` when declared. Friends are empty locally, so `c.crew.friends` is `[]`; everything else, including saves, 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. 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. Saves, identities and rooms are shared across these dates. Existing rooms retain their creation context; new rooms follow the selected day. `expiresAt` follows the real clock even with `--day`. A changed flag takes effect after restarting dev and reloading the game.\n\nOpen the printed `/__caisual/players?n=4` URL for 1 to 8 independent guest frames. Use the game's own menu to create a room in one frame and join its code in the others. Each frame has **Drop** (1 to 60 seconds, default 3) to interrupt that guest's room connection. Phone frames are 390 x 844; the desktop preset is 960 x 640. Add `&lang=it` to test Italian.\n\n`dev --latency 120 --jitter 40 --loss 2` delays room WebSocket messages in both directions. Latency and jitter are integer milliseconds from 0 to 60000; loss is a percentage from 0 to 100 and may be fractional. Jitter and loss require `--latency`, which may be zero. Jitter varies delay uniformly within plus/minus the supplied value, clamped at zero, and preserves message order. Loss discards whole application messages, including protocol messages, to exercise recovery; it is not a model of TCP packet retransmission. HTTP, matchmaking and audio are unaffected. Drop closes the guest's room sockets and prevents successful reconnects for the selected duration; the kit's retry schedule can make the return later.\n\nClient files are read on each request: reload the portal or frame after editing, or rebuild into `client/` first if using a bundler. There is no automatic browser reload. Restart dev after changing `caisual.json`, `server.js`, or any server import (including shared client physics). Rooms restore from `.caisual-dev/`; changing the shape of their state may require creating a fresh room. A busy port error suggests a command with a currently available port.\n\nEvery `init` variant generates Node test and optional Playwright browser scripts. Playwright is a development dependency of the generated game, never of the CLI. See [Local dev](/docs/local-development#testing-with-a-browser) for installation and the full browser workflow.\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. Single-player modes reject room operations with `mode_local`. Multiplayer modes require `server.js`.\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, 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\nSingle player runs in the browser and never touches the server; a room exists only for two or more players. A mode is local when its resolved `players.max` is 1. Modes with `players.max > 1` require `server.js`. The manifest carries no text for the platform to draw: the names the player reads live in the game's own dictionaries. A mode enables instant matchmaking with `\"matchmaking\": true`.\n\n```json\n{\n \"players\": { \"min\": 2, \"max\": 4 },\n \"modes\": [\n { \"id\": \"practice\", \"players\": { \"min\": 1, \"max\": 1 } },\n { \"id\": \"duel\",\n \"matchmaking\": true }\n ]\n}\n```\n\nNo manifest field is required for identity, saves or the daily challenge. A mode may override `players: { min, max }`; otherwise it inherits the root range. `mode: null` uses the root range. Use `matchmaking: true` for instant online entry. Player-created rooms always have a lobby; matchmaking rooms never do. Roles, teams, voice and persistence remain game-wide. A mode with `players.max: 1` runs locally and needs no server. See [publish.md](./publish.md#caisualjson) for every field.\n\n## Required game images\n\nEnglish (`en`) is required in `languages`; three distinct files inside `client/` with no text inside are also required: cover 1536x1024 (3:2), card 1024x1024 and icon 1024x1024, each PNG, JPEG or WebP and at most 2 MB. See [Manifest](https://caisual.com/docs/manifest#required-game-images).\n";
|
|
1165
1115
|
|
|
1166
1116
|
// src/dev.ts
|
|
1167
1117
|
import { createHash as createHash3, createHmac, randomBytes, randomUUID as randomUUID2, timingSafeEqual } from "node:crypto";
|
|
@@ -1212,7 +1162,7 @@ var RISERVATI2 = new Set(NOMI_RISERVATI2);
|
|
|
1212
1162
|
function risolviModalita2(manifest, mode) {
|
|
1213
1163
|
const scelta = mode === null ? void 0 : manifest.modes.find((voce) => voce.id === mode);
|
|
1214
1164
|
if (mode !== null && scelta === void 0) throw new Error("The selected game mode does not exist.");
|
|
1215
|
-
return { players: { ...scelta?.players ?? manifest.players }
|
|
1165
|
+
return { players: { ...scelta?.players ?? manifest.players } };
|
|
1216
1166
|
}
|
|
1217
1167
|
function modalitaLocale2(manifest, mode) {
|
|
1218
1168
|
return risolviModalita2(manifest, mode).players.max === 1;
|
|
@@ -1728,20 +1678,25 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1728
1678
|
if (modalitaLocale2(manifest, salvato.mode)) throw Object.assign(new Error(MODE_LOCAL_MESSAGE2), { code: "mode_local" });
|
|
1729
1679
|
nucleo.dati = salvato;
|
|
1730
1680
|
nucleo.tickRateSincronizzato = salvato.tickRate;
|
|
1731
|
-
|
|
1681
|
+
let daAggiornare = salvato.ultimoInputAt === void 0 || salvato.ultimoCambioStatoAt === void 0 || salvato.daily === void 0 || salvato.origin === void 0;
|
|
1732
1682
|
salvato.origin ??= "player";
|
|
1683
|
+
if (salvato.origin === "matchmaking" && salvato.status === "lobby") {
|
|
1684
|
+
salvato.status = "waiting";
|
|
1685
|
+
daAggiornare = true;
|
|
1686
|
+
}
|
|
1733
1687
|
salvato.daily ??= nucleo.contestoGiornaliero();
|
|
1734
1688
|
salvato.ultimoInputAt ??= adattatore.ora();
|
|
1735
1689
|
salvato.ultimoCambioStatoAt ??= salvato.ultimoInputAt;
|
|
1736
1690
|
nucleo.ultimoStatoOsservato = JSON.stringify(salvato.state);
|
|
1737
1691
|
await nucleo.riconciliaConnessioni();
|
|
1692
|
+
if (salvato.origin === "matchmaking") nucleo.rivalutaAvvio();
|
|
1738
1693
|
if (daAggiornare) await nucleo.persisti();
|
|
1739
1694
|
}
|
|
1740
1695
|
await nucleo.aggiornaProgrammazione();
|
|
1741
1696
|
return nucleo;
|
|
1742
1697
|
}
|
|
1743
1698
|
static verificaManifest(manifest) {
|
|
1744
|
-
if (typeof manifest?.id !== "string" || !Number.isInteger(manifest.players?.min) || !Number.isInteger(manifest.players?.max) || manifest.players.min < 1 || manifest.players.max < manifest.players.min ||
|
|
1699
|
+
if (typeof manifest?.id !== "string" || !Number.isInteger(manifest.players?.min) || !Number.isInteger(manifest.players?.max) || manifest.players.min < 1 || manifest.players.max < manifest.players.min || manifest.persistent !== void 0 && typeof manifest.persistent !== "boolean" || !Array.isArray(manifest.roles) || !Array.isArray(manifest.modes) || manifest.voice !== void 0 && !["none", "room", "team", "proximity"].includes(manifest.voice)) {
|
|
1745
1700
|
throw new TypeError("The room manifest is invalid.");
|
|
1746
1701
|
}
|
|
1747
1702
|
}
|
|
@@ -1765,7 +1720,7 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1765
1720
|
daily: this.contestoGiornaliero(ora),
|
|
1766
1721
|
id,
|
|
1767
1722
|
mode,
|
|
1768
|
-
status: "lobby",
|
|
1723
|
+
status: origin === "matchmaking" ? "waiting" : "lobby",
|
|
1769
1724
|
tick: 0,
|
|
1770
1725
|
tickRate: this.definizione.tickRate,
|
|
1771
1726
|
ultimoInputAt: ora,
|
|
@@ -1803,15 +1758,13 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1803
1758
|
return true;
|
|
1804
1759
|
}
|
|
1805
1760
|
get configurazione() {
|
|
1806
|
-
|
|
1807
|
-
return this.dati?.origin === "matchmaking" ? mode : { ...mode, lobby: true };
|
|
1761
|
+
return risolviModalita2(this.manifest, this.dati?.mode ?? null);
|
|
1808
1762
|
}
|
|
1809
1763
|
info(playerId) {
|
|
1810
1764
|
if (this.dati === null) return null;
|
|
1811
1765
|
return {
|
|
1812
1766
|
roomId: this.dati.id,
|
|
1813
1767
|
origin: this.dati.origin,
|
|
1814
|
-
lobby: this.configurazione.lobby,
|
|
1815
1768
|
status: this.dati.status,
|
|
1816
1769
|
players: this.manifest.persistent === true ? this.dati.giocatori.length : this.dati.giocatori.filter(
|
|
1817
1770
|
(player) => player.connected || player.graziaFinoA !== null
|
|
@@ -1856,10 +1809,10 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1856
1809
|
}
|
|
1857
1810
|
puoEntrare(identity) {
|
|
1858
1811
|
if (this.dati === null) return { ok: false, code: "room_not_found" };
|
|
1859
|
-
if (this.dati.status === "ended") return { ok: false, code: "room_ended" };
|
|
1812
|
+
if (this.dati.status === "ended" || this.dati.origin === "matchmaking" && this.dati.status === "finished") return { ok: false, code: "room_ended" };
|
|
1860
1813
|
const esistente = this.dati.giocatori.find((player) => player.id === identity.id);
|
|
1861
1814
|
if (esistente !== void 0) return { ok: true };
|
|
1862
|
-
if (this.
|
|
1815
|
+
if (this.dati?.origin === "player" && !["lobby", "countdown", "finished"].includes(this.dati.status)) {
|
|
1863
1816
|
return { ok: false, code: "room_playing" };
|
|
1864
1817
|
}
|
|
1865
1818
|
if (this.dati.giocatori.length >= this.configurazione.players.max) {
|
|
@@ -1909,8 +1862,6 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1909
1862
|
}
|
|
1910
1863
|
dati.vuotaDa = null;
|
|
1911
1864
|
dati.ultimoInputAt = ora;
|
|
1912
|
-
const primaConnessione = !this.configurazione.lobby && dati.status === "lobby";
|
|
1913
|
-
if (primaConnessione) dati.status = "playing";
|
|
1914
1865
|
if (nuovo) {
|
|
1915
1866
|
await this.chiama(
|
|
1916
1867
|
this.definizione.onJoin,
|
|
@@ -1921,10 +1872,6 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
1921
1872
|
if (riconnesso) {
|
|
1922
1873
|
await this.chiama(this.definizione.onConnection, this.room, copiaGiocatore(player), true);
|
|
1923
1874
|
}
|
|
1924
|
-
if (primaConnessione) {
|
|
1925
|
-
await this.chiama(this.definizione.onStart, this.room);
|
|
1926
|
-
this.inviaStatus(ora);
|
|
1927
|
-
}
|
|
1928
1875
|
await this.concludiEvento();
|
|
1929
1876
|
if (dati.status !== "ended") {
|
|
1930
1877
|
this.inviaWelcome(player);
|
|
@@ -2003,6 +1950,11 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
2003
1950
|
await this.persistiEProgramma();
|
|
2004
1951
|
return;
|
|
2005
1952
|
}
|
|
1953
|
+
if (this.dati.origin === "matchmaking" && (message.t === "ready" || message.t === "restart")) return;
|
|
1954
|
+
if (this.dati.origin === "matchmaking" && (message.t === "role" || message.t === "team")) {
|
|
1955
|
+
this.inviaErrore(player, "matchmaking_room", "Roles and teams are assigned by the server in matchmaking rooms.");
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
2006
1958
|
if (message.t === "restart") {
|
|
2007
1959
|
await this.rivincita(player);
|
|
2008
1960
|
return;
|
|
@@ -2123,7 +2075,7 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
2123
2075
|
if (this.dati.status === "countdown" && this.dati.countdownAt !== null && this.dati.countdownAt <= ora) {
|
|
2124
2076
|
const errore = this.erroreMinimi(this.dati.countdownRivincita);
|
|
2125
2077
|
if (errore !== null) {
|
|
2126
|
-
this.dati.status = "lobby";
|
|
2078
|
+
this.dati.status = this.dati.origin === "matchmaking" ? "waiting" : "lobby";
|
|
2127
2079
|
this.dati.countdownAt = null;
|
|
2128
2080
|
this.broadcast({ t: "error", code: errore.code, message: errore.message });
|
|
2129
2081
|
this.inviaStatus(ora);
|
|
@@ -2307,6 +2259,7 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
2307
2259
|
if (connessi.length < this.configurazione.players.min) {
|
|
2308
2260
|
return { code: "not_enough_players", message: "The room does not have enough players." };
|
|
2309
2261
|
}
|
|
2262
|
+
if (this.dati?.origin === "matchmaking") return null;
|
|
2310
2263
|
if (!saltaPronti && connessi.some((player) => !player.ready)) {
|
|
2311
2264
|
return { code: "players_not_ready", message: "Every connected player must be ready." };
|
|
2312
2265
|
}
|
|
@@ -2341,8 +2294,13 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
2341
2294
|
}
|
|
2342
2295
|
async avviaRivincita() {
|
|
2343
2296
|
const dati = this.richiediDati();
|
|
2344
|
-
|
|
2345
|
-
|
|
2297
|
+
if (dati.origin === "matchmaking") {
|
|
2298
|
+
for (const player of dati.giocatori.filter((item) => !item.connected)) await this.rimuoviGiocatore(player, "timeout");
|
|
2299
|
+
await this.applicaAzioni();
|
|
2300
|
+
if (this.stanzaTerminata()) return;
|
|
2301
|
+
}
|
|
2302
|
+
const keepSetup = dati.origin === "player" && dati.rivincita?.keepSetup === true;
|
|
2303
|
+
dati.status = dati.origin === "matchmaking" ? "waiting" : keepSetup ? "countdown" : "lobby";
|
|
2346
2304
|
dati.countdownRivincita = keepSetup;
|
|
2347
2305
|
dati.countdownAt = keepSetup ? this.adattatore.ora() + COUNTDOWN_MS : null;
|
|
2348
2306
|
dati.rivincitaFinoA = null;
|
|
@@ -2354,11 +2312,9 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
2354
2312
|
dati.prontiPrecedenti = [];
|
|
2355
2313
|
await this.chiama(this.definizione.onRestart, this.room);
|
|
2356
2314
|
await this.applicaAzioni();
|
|
2357
|
-
if (dati.status === "playing") {
|
|
2358
|
-
await this.chiama(this.definizione.onStart, this.room);
|
|
2359
|
-
}
|
|
2360
2315
|
await this.concludiEvento();
|
|
2361
|
-
|
|
2316
|
+
this.rivalutaAvvio();
|
|
2317
|
+
if (["waiting", "lobby", "playing", "countdown"].includes(dati.status)) {
|
|
2362
2318
|
this.inviaSnapshotTutti();
|
|
2363
2319
|
this.inviaGiocatori();
|
|
2364
2320
|
this.inviaStatus(this.adattatore.ora());
|
|
@@ -2374,12 +2330,12 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
2374
2330
|
const dati = this.richiediDati();
|
|
2375
2331
|
if (dati.status === "countdown") {
|
|
2376
2332
|
if (this.erroreMinimi(dati.countdownRivincita) === null) return;
|
|
2377
|
-
dati.status = "lobby";
|
|
2333
|
+
dati.status = dati.origin === "matchmaking" ? "waiting" : "lobby";
|
|
2378
2334
|
dati.countdownAt = null;
|
|
2379
2335
|
this.inviaStatus(this.adattatore.ora());
|
|
2380
2336
|
return;
|
|
2381
2337
|
}
|
|
2382
|
-
if (dati.status
|
|
2338
|
+
if (!["lobby", "waiting"].includes(dati.status) || this.erroreMinimi() !== null) return;
|
|
2383
2339
|
dati.status = "countdown";
|
|
2384
2340
|
dati.countdownRivincita = false;
|
|
2385
2341
|
dati.countdownAt = this.adattatore.ora() + COUNTDOWN_MS;
|
|
@@ -2536,6 +2492,7 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
2536
2492
|
const dati = this.richiediDati();
|
|
2537
2493
|
return {
|
|
2538
2494
|
id: dati.id,
|
|
2495
|
+
origin: dati.origin,
|
|
2539
2496
|
seed: seedStanza(dati.id),
|
|
2540
2497
|
status: dati.status,
|
|
2541
2498
|
result: dati.result,
|
|
@@ -2547,9 +2504,10 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
2547
2504
|
countdownAt: dati.countdownAt,
|
|
2548
2505
|
configuration: {
|
|
2549
2506
|
players: { ...this.configurazione.players },
|
|
2550
|
-
lobby: this.configurazione.lobby,
|
|
2551
2507
|
persistent: this.manifest.persistent === true,
|
|
2552
|
-
requestRole: this.definizione.onRoleRequest !== void 0
|
|
2508
|
+
requestRole: this.definizione.onRoleRequest !== void 0,
|
|
2509
|
+
// Campo tenuto per i giochi pubblicati prima della 0.23, che lo leggono per disegnare la sala d'attesa.
|
|
2510
|
+
lobby: dati.origin === "player"
|
|
2553
2511
|
}
|
|
2554
2512
|
};
|
|
2555
2513
|
}
|
|
@@ -2632,7 +2590,7 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
2632
2590
|
async concludiEvento() {
|
|
2633
2591
|
await this.applicaAzioni();
|
|
2634
2592
|
if (this.dati === null || this.dati.status === "ended") return;
|
|
2635
|
-
if (this.dati.status === "finished" && this.dati.rivincita !== null && this.prontiRivincita()) {
|
|
2593
|
+
if (this.dati.origin === "player" && this.dati.status === "finished" && this.dati.rivincita !== null && this.prontiRivincita()) {
|
|
2636
2594
|
await this.avviaRivincita();
|
|
2637
2595
|
return;
|
|
2638
2596
|
}
|
|
@@ -2730,10 +2688,11 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
2730
2688
|
const dati = this.richiediDati();
|
|
2731
2689
|
if (dati.status === "ended") return;
|
|
2732
2690
|
const giaFinita = dati.status === "finished";
|
|
2691
|
+
if (dati.origin === "matchmaking" && record(result)?.error === void 0) rematch = true;
|
|
2733
2692
|
dati.status = rematch ? "finished" : "ended";
|
|
2734
2693
|
dati.rivincita = rematch ? { keepSetup: typeof rematch === "object" && rematch.keepSetup === true } : null;
|
|
2735
2694
|
dati.prontiPrecedenti = rematch ? dati.giocatori.filter((p) => p.ready).map((p) => p.id) : [];
|
|
2736
|
-
dati.rivincitaFinoA = rematch ? this.adattatore.ora() + ATTESA_RIVINCITA_MS : null;
|
|
2695
|
+
dati.rivincitaFinoA = rematch ? this.adattatore.ora() + (dati.origin === "matchmaking" ? 8e3 : ATTESA_RIVINCITA_MS) : null;
|
|
2737
2696
|
dati.timer = [];
|
|
2738
2697
|
if (rematch) for (const player of dati.giocatori) player.ready = false;
|
|
2739
2698
|
dati.countdownAt = null;
|
|
@@ -2811,6 +2770,10 @@ var NucleoStanza = class _NucleoStanza {
|
|
|
2811
2770
|
async terminaSeInattiva() {
|
|
2812
2771
|
if (this.dati?.status === "finished") {
|
|
2813
2772
|
if (this.adattatore.ora() < (this.dati.rivincitaFinoA ?? 0)) return false;
|
|
2773
|
+
if (this.dati.origin === "matchmaking") {
|
|
2774
|
+
await this.avviaRivincita();
|
|
2775
|
+
return false;
|
|
2776
|
+
}
|
|
2814
2777
|
await this.terminaInterna(this.dati.result);
|
|
2815
2778
|
return true;
|
|
2816
2779
|
}
|
|
@@ -3655,8 +3618,6 @@ var FORMA_CODICE = /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/;
|
|
|
3655
3618
|
var ALFABETO_CODICE = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
3656
3619
|
var MASSIMO_CORPO = 262144;
|
|
3657
3620
|
var MASSIMO_FRAME_MATCH = 4096;
|
|
3658
|
-
var DURATA_STANZA_APERTA = 24 * 60 * 60 * 1e3;
|
|
3659
|
-
var VALORE_CHIAVE_MATCH = /^[A-Za-z0-9_.:-]+$/;
|
|
3660
3621
|
var VERSIONE_STATO_DEV = 1;
|
|
3661
3622
|
var DevHttpError = class extends Error {
|
|
3662
3623
|
constructor(status, code, message, hints = []) {
|
|
@@ -3760,7 +3721,7 @@ function joinTicket(player, room, secret) {
|
|
|
3760
3721
|
exp: iat + DURATA_INGRESSO
|
|
3761
3722
|
}, secret);
|
|
3762
3723
|
}
|
|
3763
|
-
function matchTicket(player, game, mode,
|
|
3724
|
+
function matchTicket(player, game, mode, secret) {
|
|
3764
3725
|
const iat = currentSeconds();
|
|
3765
3726
|
return signJwt({
|
|
3766
3727
|
sub: player.id,
|
|
@@ -3768,11 +3729,6 @@ function matchTicket(player, game, mode, key, matchmaking, players, lobby, secre
|
|
|
3768
3729
|
name: player.name,
|
|
3769
3730
|
guest: player.guest,
|
|
3770
3731
|
mode,
|
|
3771
|
-
key,
|
|
3772
|
-
timeoutMs: matchmaking.timeoutMs,
|
|
3773
|
-
min: players.min,
|
|
3774
|
-
max: players.max,
|
|
3775
|
-
lobby,
|
|
3776
3732
|
aud: "match",
|
|
3777
3733
|
iat,
|
|
3778
3734
|
exp: iat + DURATA_INGRESSO
|
|
@@ -3799,7 +3755,7 @@ function readJoinTicket(token, room, secret) {
|
|
|
3799
3755
|
}
|
|
3800
3756
|
function readMatchTicket(token, game, secret) {
|
|
3801
3757
|
const payload = verifyJwt(token, secret);
|
|
3802
|
-
if (payload === null || payload.aud !== "match" || payload.game !== game || typeof payload.sub !== "string" || payload.sub === "" || typeof payload.name !== "string" || payload.name === "" || typeof payload.guest !== "boolean" || typeof payload.mode !== "string" || payload.mode === "" ||
|
|
3758
|
+
if (payload === null || payload.aud !== "match" || payload.game !== game || typeof payload.sub !== "string" || payload.sub === "" || typeof payload.name !== "string" || payload.name === "" || typeof payload.guest !== "boolean" || typeof payload.mode !== "string" || payload.mode === "" || !validTimes(payload, DURATA_INGRESSO)) return null;
|
|
3803
3759
|
return payload;
|
|
3804
3760
|
}
|
|
3805
3761
|
function playerFromTicket(ticket) {
|
|
@@ -3917,34 +3873,6 @@ async function readBody(request, maximum = MASSIMO_CORPO) {
|
|
|
3917
3873
|
throw new DevHttpError(400, "invalid_request", "The request body must be valid JSON.");
|
|
3918
3874
|
}
|
|
3919
3875
|
}
|
|
3920
|
-
function canonicalMatchKey(value, fields) {
|
|
3921
|
-
const key = object(value);
|
|
3922
|
-
if (key === null) {
|
|
3923
|
-
throw new DevHttpError(400, "invalid_request", "key must be an object.");
|
|
3924
|
-
}
|
|
3925
|
-
for (const field of fields) {
|
|
3926
|
-
if (!Object.hasOwn(key, field)) {
|
|
3927
|
-
throw new DevHttpError(400, "invalid_request", `Matchmaking key field ${field} is missing.`);
|
|
3928
|
-
}
|
|
3929
|
-
}
|
|
3930
|
-
for (const field of Object.keys(key)) {
|
|
3931
|
-
if (!fields.includes(field)) {
|
|
3932
|
-
throw new DevHttpError(400, "invalid_request", `Matchmaking key field ${field} is not allowed.`);
|
|
3933
|
-
}
|
|
3934
|
-
}
|
|
3935
|
-
return fields.map((field) => {
|
|
3936
|
-
const item = key[field];
|
|
3937
|
-
if (typeof item === "string" && item.length >= 1 && item.length <= 64 && VALORE_CHIAVE_MATCH.test(item)) {
|
|
3938
|
-
return encodeURIComponent(item);
|
|
3939
|
-
}
|
|
3940
|
-
if (typeof item === "number" && Number.isSafeInteger(item)) return encodeURIComponent(String(item));
|
|
3941
|
-
throw new DevHttpError(
|
|
3942
|
-
400,
|
|
3943
|
-
"invalid_request",
|
|
3944
|
-
`Matchmaking key field ${field} must be a valid string or safe integer.`
|
|
3945
|
-
);
|
|
3946
|
-
}).join("/");
|
|
3947
|
-
}
|
|
3948
3876
|
function parentPage(input) {
|
|
3949
3877
|
return `<!doctype html>
|
|
3950
3878
|
<html lang="en">
|
|
@@ -4086,7 +4014,7 @@ var DevService = class {
|
|
|
4086
4014
|
roomIndex = /* @__PURE__ */ new Map();
|
|
4087
4015
|
roomByCode = /* @__PURE__ */ new Map();
|
|
4088
4016
|
roomLoads = /* @__PURE__ */ new Map();
|
|
4089
|
-
|
|
4017
|
+
matchRegistries = /* @__PURE__ */ new Map();
|
|
4090
4018
|
kitRequests = /* @__PURE__ */ new Map();
|
|
4091
4019
|
liveRequests = /* @__PURE__ */ new Map();
|
|
4092
4020
|
matchOperations = Promise.resolve();
|
|
@@ -4313,30 +4241,33 @@ var DevService = class {
|
|
|
4313
4241
|
}
|
|
4314
4242
|
return;
|
|
4315
4243
|
}
|
|
4316
|
-
const queueId = this.
|
|
4244
|
+
const queueId = this.matchRegistryId(ticket);
|
|
4317
4245
|
websocket.on("message", (message) => this.handleMatchMessage(websocket, message));
|
|
4318
|
-
websocket.on("close", () => {
|
|
4319
|
-
void this.serializeMatch(() => this.removeMatchWaiter(queueId, websocket));
|
|
4320
|
-
});
|
|
4321
4246
|
try {
|
|
4322
|
-
await this.serializeMatch(() => this.
|
|
4247
|
+
await this.serializeMatch(() => this.enterMatchRegistry(queueId, ticket, websocket));
|
|
4323
4248
|
} catch {
|
|
4324
|
-
this.sendMatchError(websocket, "internal_error", "The local
|
|
4249
|
+
this.sendMatchError(websocket, "internal_error", "The local match request failed.");
|
|
4325
4250
|
}
|
|
4326
4251
|
}
|
|
4327
|
-
|
|
4328
|
-
return `${ticket.game}\0${ticket.mode}
|
|
4252
|
+
matchRegistryId(ticket) {
|
|
4253
|
+
return `${ticket.game}\0${ticket.mode}`;
|
|
4329
4254
|
}
|
|
4330
4255
|
serializeMatch(operation) {
|
|
4331
4256
|
const result = this.matchOperations.then(operation);
|
|
4332
4257
|
this.matchOperations = result.then(() => void 0, () => void 0);
|
|
4333
4258
|
return result;
|
|
4334
4259
|
}
|
|
4335
|
-
|
|
4336
|
-
let queue = this.
|
|
4260
|
+
async matchRegistry(queueId, ticket) {
|
|
4261
|
+
let queue = this.matchRegistries.get(queueId);
|
|
4337
4262
|
if (queue === void 0) {
|
|
4338
|
-
queue = {
|
|
4339
|
-
this.
|
|
4263
|
+
queue = { open: [] };
|
|
4264
|
+
for (const record2 of this.roomIndex.values()) {
|
|
4265
|
+
if (record2.game !== ticket.game || record2.mode !== ticket.mode) continue;
|
|
4266
|
+
const local = await this.loadLocalRoom(record2.roomId);
|
|
4267
|
+
const info = await local?.room.info();
|
|
4268
|
+
if (info?.origin === "matchmaking" && info.status !== "ended") queue.open.push({ roomId: record2.roomId });
|
|
4269
|
+
}
|
|
4270
|
+
this.matchRegistries.set(queueId, queue);
|
|
4340
4271
|
}
|
|
4341
4272
|
return queue;
|
|
4342
4273
|
}
|
|
@@ -4356,32 +4287,18 @@ var DevService = class {
|
|
|
4356
4287
|
}
|
|
4357
4288
|
socket.send(JSON.stringify({ t: "pong", c: message.c }));
|
|
4358
4289
|
}
|
|
4359
|
-
async
|
|
4360
|
-
const queue = this.
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
if (await this.fillOpenMatchRoom(queue, ticket, socket)) {
|
|
4367
|
-
this.scheduleMatchQueue(queueId, queue);
|
|
4368
|
-
return;
|
|
4369
|
-
}
|
|
4370
|
-
queue.waiting.push({ ticket, socket, at: Date.now() });
|
|
4371
|
-
this.notifyMatchQueue(queue);
|
|
4372
|
-
this.scheduleMatchQueue(queueId, queue);
|
|
4373
|
-
if (queue.waiting.length >= ticket.max) {
|
|
4374
|
-
await this.openMatchRoom(queueId, queue, ticket.max);
|
|
4375
|
-
}
|
|
4290
|
+
async enterMatchRegistry(queueId, ticket, socket) {
|
|
4291
|
+
const queue = await this.matchRegistry(queueId, ticket);
|
|
4292
|
+
if (await this.fillOpenMatchRoom(queue, ticket, socket)) return;
|
|
4293
|
+
const { roomId, localRoom } = await this.openLocalRoom(ticket.mode, playerFromTicket(ticket), "matchmaking");
|
|
4294
|
+
localRoom.pendingMatch.set(ticket.sub, Date.now() + DURATA_INGRESSO * 1e3);
|
|
4295
|
+
queue.open.push({ roomId });
|
|
4296
|
+
this.sendMatched(socket, roomId, localRoom, playerFromTicket(ticket));
|
|
4376
4297
|
}
|
|
4377
4298
|
async fillOpenMatchRoom(queue, ticket, socket) {
|
|
4378
4299
|
const now = Date.now();
|
|
4379
4300
|
const remove = /* @__PURE__ */ new Set();
|
|
4380
|
-
for (const opened of
|
|
4381
|
-
if (now - opened.at >= DURATA_STANZA_APERTA) {
|
|
4382
|
-
remove.add(opened.roomId);
|
|
4383
|
-
continue;
|
|
4384
|
-
}
|
|
4301
|
+
for (const opened of queue.open) {
|
|
4385
4302
|
const localRoom = this.rooms.get(opened.roomId);
|
|
4386
4303
|
const info = localRoom === void 0 ? null : await localRoom.room.info();
|
|
4387
4304
|
if (localRoom === void 0 || info === null || info.status === "ended") {
|
|
@@ -4393,7 +4310,7 @@ var DevService = class {
|
|
|
4393
4310
|
}
|
|
4394
4311
|
if (info.origin !== "matchmaking" || info.mode !== ticket.mode) continue;
|
|
4395
4312
|
const risolta = risolviModalita(this.manifest, info.mode);
|
|
4396
|
-
const canEnter =
|
|
4313
|
+
const canEnter = ["waiting", "countdown", "playing"].includes(info.status);
|
|
4397
4314
|
if (!canEnter || info.players + localRoom.pendingMatch.size >= risolta.players.max) continue;
|
|
4398
4315
|
const permission = await localRoom.room.canJoin(playerFromTicket(ticket));
|
|
4399
4316
|
if (!permission.ok) {
|
|
@@ -4410,92 +4327,6 @@ var DevService = class {
|
|
|
4410
4327
|
queue.open = queue.open.filter((entry) => !remove.has(entry.roomId));
|
|
4411
4328
|
return false;
|
|
4412
4329
|
}
|
|
4413
|
-
async openMatchRoom(queueId, queue, count) {
|
|
4414
|
-
const selected = queue.waiting.splice(0, Math.min(count, queue.waiting.length));
|
|
4415
|
-
const first = selected[0];
|
|
4416
|
-
if (first === void 0) return;
|
|
4417
|
-
try {
|
|
4418
|
-
const { roomId, localRoom } = await this.openLocalRoom(
|
|
4419
|
-
first.ticket.mode,
|
|
4420
|
-
playerFromTicket(first.ticket),
|
|
4421
|
-
"matchmaking"
|
|
4422
|
-
);
|
|
4423
|
-
const expiresAt = Date.now() + DURATA_INGRESSO * 1e3;
|
|
4424
|
-
for (const waiting of selected) {
|
|
4425
|
-
localRoom.pendingMatch.set(waiting.ticket.sub, expiresAt);
|
|
4426
|
-
}
|
|
4427
|
-
queue.open = queue.open.filter((entry) => Date.now() - entry.at < DURATA_STANZA_APERTA);
|
|
4428
|
-
queue.open.push({ roomId, at: Date.now() });
|
|
4429
|
-
if (queue.open.length > 20) queue.open.splice(0, queue.open.length - 20);
|
|
4430
|
-
for (const waiting of selected) {
|
|
4431
|
-
this.sendMatched(waiting.socket, roomId, localRoom, playerFromTicket(waiting.ticket));
|
|
4432
|
-
}
|
|
4433
|
-
} catch (cause) {
|
|
4434
|
-
const code = cause instanceof DevHttpError ? cause.code : "internal_error";
|
|
4435
|
-
const message = cause instanceof DevHttpError ? cause.message : "The local room could not be created.";
|
|
4436
|
-
for (const waiting of selected) this.sendMatchError(waiting.socket, code, message);
|
|
4437
|
-
}
|
|
4438
|
-
this.notifyMatchQueue(queue);
|
|
4439
|
-
this.scheduleMatchQueue(queueId, queue);
|
|
4440
|
-
}
|
|
4441
|
-
removeMatchWaiter(queueId, socket) {
|
|
4442
|
-
const queue = this.matchQueues.get(queueId);
|
|
4443
|
-
if (queue === void 0) return;
|
|
4444
|
-
const index = queue.waiting.findIndex((waiting) => waiting.socket === socket);
|
|
4445
|
-
if (index < 0) return;
|
|
4446
|
-
queue.waiting.splice(index, 1);
|
|
4447
|
-
this.notifyMatchQueue(queue);
|
|
4448
|
-
this.scheduleMatchQueue(queueId, queue);
|
|
4449
|
-
}
|
|
4450
|
-
notifyMatchQueue(queue) {
|
|
4451
|
-
for (const waiting of queue.waiting) {
|
|
4452
|
-
waiting.socket.send(JSON.stringify({
|
|
4453
|
-
t: "waiting",
|
|
4454
|
-
players: queue.waiting.length,
|
|
4455
|
-
min: waiting.ticket.min,
|
|
4456
|
-
max: waiting.ticket.max
|
|
4457
|
-
}));
|
|
4458
|
-
}
|
|
4459
|
-
}
|
|
4460
|
-
scheduleMatchQueue(queueId, queue) {
|
|
4461
|
-
if (queue.timer !== null) clearTimeout(queue.timer);
|
|
4462
|
-
queue.timer = null;
|
|
4463
|
-
const next = queue.waiting.reduce(
|
|
4464
|
-
(nearest, waiting) => Math.min(nearest, waiting.at + waiting.ticket.timeoutMs),
|
|
4465
|
-
Number.POSITIVE_INFINITY
|
|
4466
|
-
);
|
|
4467
|
-
if (!Number.isFinite(next)) return;
|
|
4468
|
-
queue.timer = setTimeout(() => {
|
|
4469
|
-
queue.timer = null;
|
|
4470
|
-
void this.serializeMatch(() => this.expireMatchQueue(queueId));
|
|
4471
|
-
}, Math.max(0, next - Date.now()));
|
|
4472
|
-
queue.timer.unref();
|
|
4473
|
-
}
|
|
4474
|
-
async expireMatchQueue(queueId) {
|
|
4475
|
-
const queue = this.matchQueues.get(queueId);
|
|
4476
|
-
if (queue === void 0 || queue.waiting.length === 0) return;
|
|
4477
|
-
const now = Date.now();
|
|
4478
|
-
const expired = queue.waiting.filter(
|
|
4479
|
-
(waiting) => waiting.at + waiting.ticket.timeoutMs <= now
|
|
4480
|
-
);
|
|
4481
|
-
if (expired.length === 0) {
|
|
4482
|
-
this.scheduleMatchQueue(queueId, queue);
|
|
4483
|
-
return;
|
|
4484
|
-
}
|
|
4485
|
-
const first = queue.waiting[0];
|
|
4486
|
-
if (first !== void 0 && queue.waiting.length >= first.ticket.min) {
|
|
4487
|
-
await this.openMatchRoom(queueId, queue, Math.min(first.ticket.max, queue.waiting.length));
|
|
4488
|
-
return;
|
|
4489
|
-
}
|
|
4490
|
-
const expiredSockets = new Set(expired.map((waiting) => waiting.socket));
|
|
4491
|
-
queue.waiting = queue.waiting.filter((waiting) => !expiredSockets.has(waiting.socket));
|
|
4492
|
-
for (const waiting of expired) {
|
|
4493
|
-
waiting.socket.send(JSON.stringify({ t: "no_match" }));
|
|
4494
|
-
waiting.socket.close(1e3);
|
|
4495
|
-
}
|
|
4496
|
-
this.notifyMatchQueue(queue);
|
|
4497
|
-
this.scheduleMatchQueue(queueId, queue);
|
|
4498
|
-
}
|
|
4499
4330
|
sendMatched(socket, roomId, localRoom, player) {
|
|
4500
4331
|
socket.send(JSON.stringify({
|
|
4501
4332
|
t: "matched",
|
|
@@ -4508,11 +4339,7 @@ var DevService = class {
|
|
|
4508
4339
|
socket.close(1e3);
|
|
4509
4340
|
}
|
|
4510
4341
|
async close() {
|
|
4511
|
-
|
|
4512
|
-
if (queue.timer !== null) clearTimeout(queue.timer);
|
|
4513
|
-
for (const waiting of queue.waiting) waiting.socket.close(1001, "server_shutdown");
|
|
4514
|
-
}
|
|
4515
|
-
this.matchQueues.clear();
|
|
4342
|
+
this.matchRegistries.clear();
|
|
4516
4343
|
await this.persistenceOperations;
|
|
4517
4344
|
await Promise.all([...this.rooms.values()].map((entry) => entry.room.close()));
|
|
4518
4345
|
}
|
|
@@ -4526,7 +4353,7 @@ var DevService = class {
|
|
|
4526
4353
|
response.setHeader("Content-Type", "text/javascript; charset=utf-8");
|
|
4527
4354
|
response.setHeader("Cache-Control", "no-store");
|
|
4528
4355
|
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
4529
|
-
response.end(request.method === "HEAD" ? void 0 : '// Caisual game kit v0.22.0\nvar xe=["www","api","app","play","live","multi","cdn","assets","static","mail","mx","ns1","ns2","autodiscover","_dmarc","admin","login","account","auth","pay","secure","support","help","blog","status","dev","staging","test","caisual","shipz"],nt=new Set(xe);function b(n){if(typeof n!="string"||n.length>128)return null;try{return Intl.getCanonicalLocales(n)[0]??null}catch{return null}}function ke(n,e="en"){let t=[],i=b(n);for(;i;){t.push(i);let r=i.split("-");r.pop(),r.at(-1)?.length===1&&r.pop(),i=r.join("-")}return t.push(b(e)??e),[...new Set(t)]}function Q(n,e=[]){let t=e.map(b).filter(r=>r!==null),i=n.map(b).filter(r=>r!==null);if(!t.length)return i[0]??"en";for(let r of i)for(let o of ke(r,r))if(t.includes(o))return o;return t.includes("en")?"en":t[0]}function ee(n){return typeof n=="object"&&n!==null&&!Array.isArray(n)&&Object.values(n).every(e=>typeof e=="string")}function Re(n){return n.gpu!=="hardware"||n.memoryMb!==null&&n.memoryMb<=2048?"low":n.mobile||n.memoryMb!==null&&n.memoryMb<=4096||n.cores!==null&&n.cores<=4?"mid":"high"}function te(n){try{n?.getExtension("WEBGL_lose_context")?.loseContext()}catch{}}function Pe(n){let e;try{e=n.navigator}catch{e=void 0}let t=null;try{let a=e?.deviceMemory,s=typeof a=="number"?a*1024:NaN;Number.isFinite(s)&&(t=s)}catch{t=null}let i=null;try{let a=e?.hardwareConcurrency;typeof a=="number"&&Number.isFinite(a)&&(i=a)}catch{i=null}let r=!1;try{r=typeof e?.userAgentData?.mobile=="boolean"?e.userAgentData.mobile:/Android|iPhone|iPad|iPod|Mobile/i.test(e?.userAgent??"")}catch{r=!1}let o=!1;try{o=n.crossOriginIsolated===!0}catch{o=!1}return{webgl2:!1,webgpu:!1,wasm:!1,threads:!1,isolated:o,gpu:"none",memoryMb:t,cores:i,mobile:r}}async function ie(n,e=1500){let t=n??globalThis,i=Pe(t),r=Promise.resolve().then(()=>{try{let c=t.document?.createElement("canvas");if(c===void 0)return;let l=c.getContext("webgl2",{failIfMajorPerformanceCaveat:!0});if(l!==null){i.webgl2=!0,i.gpu="hardware",te(l);return}let m=c.getContext("webgl2");m!==null&&(i.webgl2=!0,i.gpu="software",te(m))}catch{i.webgl2=!1,i.gpu="none"}}),o=Promise.resolve().then(async()=>{let c;try{let l=t.navigator?.gpu;if(l===void 0)return;let m=await l.requestAdapter();if(m===null)return;c=await m.requestDevice(),i.webgpu=!0}catch{i.webgpu=!1}finally{try{c?.destroy?.()}catch{}}}),a=Promise.resolve().then(()=>{try{i.wasm=t.WebAssembly?.validate(new Uint8Array([0,97,115,109,1,0,0,0]))===!0}catch{i.wasm=!1}}),s=Promise.resolve().then(()=>{try{if(t.WebAssembly===void 0)return;new t.WebAssembly.Memory({initial:1,maximum:1,shared:!0}),i.threads=!0}catch{i.threads=!1}}),u;return await Promise.race([Promise.all([r,o,a,s]),new Promise(c=>{u=setTimeout(c,Math.max(0,e))})]),u!==void 0&&clearTimeout(u),{...i,tier:Re(i)}}function G(n){return typeof n=="number"&&Number.isSafeInteger(n)&&n>0}var L=Object.freeze({recipientBytesPerSecond:1e5,roomBytesPerSecond:2e6,warningRatio:.8,windowMs:5e3,blockingWindows:3}),Tt=`Multiplayer budget: ${L.recipientBytesPerSecond/1e3} kB/s per recipient, ${L.roomBytesPerSecond/1e6} MB/s per room, before compression over 5 seconds. At 20 updates/s, budget ${L.recipientBytesPerSecond/2e4} kB per update. Keep visual trails and animation on the client. Warnings start at 80%. Publication measures your server automatically; repeated excess in real matches blocks new rooms only. Game input: 30 messages/s per connection.`;function ne(n,e,t="/"){let i,r=t.match(/^\\/rt\\/[^/]+\\/[1-9][0-9]*\\//)?.[0]??"/";return()=>i??(i=(async()=>{let o={};try{let a=await n(`${r}__caisual/text/${encodeURIComponent(e)}.json`);if(a.ok){let s=await a.json();ee(s)&&(o=s)}}catch{}return(a,s={})=>Object.hasOwn(o,a)?o[a].replace(/\\{([^{}]+)\\}/g,(c,l)=>Object.hasOwn(s,l)?String(s[l]):c):a})())}function d(n,e,t={}){return Object.assign(new Error(e),{name:"CaisualError",code:n,...t})}function p(){return d("offline","Caisual services are unavailable.")}function C(n){return typeof n=="object"&&n!==null&&"code"in n?n.code:null}async function Ie(n){let e={};try{e=await n.json()}catch{}return d(typeof e.error?.code=="string"?e.error.code:n.status===401?"invalid_ticket":"internal_error",typeof e.error?.message=="string"?e.error.message:`The request failed with status ${n.status}.`,{currentVersion:e.error?.currentVersion,roomVersion:e.error?.roomVersion})}function T(n,e,t,i){async function r(o,a,s,u){let c=new Headers({Authorization:`Bearer ${s}`}),l;if(u!==void 0){c.set("Content-Type","application/json");try{l=JSON.stringify(u)}catch{throw d("invalid_request","The value must be valid JSON.")}}try{return await t(new URL(e+o,n),{method:a,headers:c,body:l,credentials:"omit"})}catch{throw p()}}return async function(a,s,u,c=!1){let l;try{l=c?await i.rinnova():await i.ottieni()}catch{throw p()}let m=await r(a,s,l,u);if(m.status===401){try{l=await i.rinnova()}catch{throw p()}m=await r(a,s,l,u)}if(!m.ok)throw await Ie(m);try{return await m.json()}catch{throw d("internal_error","The service returned an invalid response.")}}}function re(n,e,t){let i=T(n,"/api/kit",e,t);return{me:()=>i("/me","GET"),saveSet:(r,o)=>i(`/saves/${encodeURIComponent(r)}`,"PUT",{value:o}),async saveGet(r){try{return(await i(`/saves/${encodeURIComponent(r)}`,"GET")).value}catch(o){if(C(o)==="not_found")return null;throw o}},async saveRemove(r){await i(`/saves/${encodeURIComponent(r)}`,"DELETE")},async saveList(){return(await i("/saves","GET")).saves}}}function w(n){return typeof n=="object"&&n!==null&&!Array.isArray(n)?n:null}var z={connected:!1,you:null,friends:[],party:null,invites:[]};function oe(n){let e=w(n);if(!e||typeof e.id!="string"||typeof e.name!="string")return null;let t=w(e.game),i=w(e.room);return{id:e.id,name:e.name,online:e.online===!0,game:t&&typeof t.slug=="string"?{slug:t.slug,name:typeof t.name=="string"?t.name:t.slug,iconUrl:typeof t.iconUrl=="string"?t.iconUrl:""}:null,room:i&&typeof i.code=="string"?{code:i.code}:null}}function Te(n){let e=w(n);if(!e||typeof e.id!="string"||typeof e.leader!="string")return null;let t=Array.isArray(e.members)?e.members.map(i=>oe(i)).filter(i=>i!==null):[];return{id:e.id,leader:e.leader,members:t}}function ze(n){let e=w(n),t=w(e?.from);return!e||typeof e.party!="string"||!t||typeof t.id!="string"?null:{party:e.party,from:{id:t.id,name:typeof t.name=="string"?t.name:t.id},at:typeof e.at=="number"?e.at:0}}function _e(n){let e=w(n);if(!e)return z;let t=w(e.you);return{connected:e.connected===!0,you:t&&typeof t.id=="string"?{id:t.id,name:typeof t.name=="string"?t.name:""}:null,friends:Array.isArray(e.friends)?e.friends.map(i=>oe(i)).filter(i=>i!==null):[],party:Te(e.party),invites:Array.isArray(e.invites)?e.invites.map(i=>ze(i)).filter(i=>i!==null):[]}}function se(){return{available:!1,get connected(){return!1},get you(){return null},get friends(){return[]},get party(){return null},get invites(){return[]},onChange(n){return n({...z}),()=>{}},createParty(){},invite(){},accept(){},decline(){},kick(){},leave(){},follow(){},join(){return Promise.reject(d("offline","Friends are unavailable in this copy of the game."))}}}function ae(n,e,t){let i={...z},r=new Set,o=s=>{try{n.postMessage({type:"caisual:crew",...s})}catch{}};n.addEventListener("message",s=>{let u=w(s.data);if(u?.type==="caisual:crew-state"){i=u.state===null?{...z}:_e(u.state);for(let c of r)try{c({...i})}catch{}}}),o({op:"subscribe"});let a=s=>{let u=i.friends.find(c=>c.id===s)??i.party?.members.find(c=>c.id===s);if(!u)throw d("friend_not_found","This player is not in your friends list.");return u};return{available:!0,get connected(){return i.connected},get you(){return i.you===null?null:{...i.you}},get friends(){return i.friends.map(s=>({...s}))},get party(){return i.party===null?null:{...i.party,members:i.party.members.map(s=>({...s}))}},get invites(){return i.invites.map(s=>({...s}))},onChange(s){return r.add(s),s({...i}),()=>{r.delete(s)}},createParty(){o({op:"party",action:"create"})},invite(s){o({op:"party",action:"invite",player:s})},accept(s){o({op:"party",action:"accept",party:s})},decline(s){o({op:"party",action:"decline",party:s})},kick(s){o({op:"party",action:"kick",player:s})},leave(){o({op:"party",action:"leave"})},follow(s){let u=a(s);if(!u.room||!u.game)throw d("no_room","This player is not in a room.");o({op:"follow",slug:u.game.slug,code:u.room.code})},join(s){let u=a(s);return u.room?e!==null&&u.game!==null&&u.game.slug!==e?Promise.reject(d("other_game","This player is playing another game. Use follow instead.")):t.join(u.room.code):Promise.reject(d("no_room","This player is not in a room."))}}}function P(n){return(Math.floor(n/864e5)+1)*864e5}function _(n,e,t){let i=new Set,r={...n},o,a=!1;function s(){!i.size||o!==void 0||a||(o=setTimeout(u,Math.max(0,Math.min(2147483647,r.expiresAt-e()))),o.unref?.())}async function u(){o=void 0,a=!0;try{let c=await t(),l=c.day!==r.day;if(r={...c},l)for(let m of[...i])try{m({...c})}catch{}}catch{}finally{a=!1,r.expiresAt<=e()&&(r.expiresAt=e()+3e4),s()}}return{...n,random:ce(n.seed),rng:()=>ce(n.seed),onChange(c){return i.add(c),s(),()=>{i.delete(c),!i.size&&o!==void 0&&(clearTimeout(o),o=void 0)}}}}function j(n){return new Date(n).toISOString().slice(0,10)}async function B(n,e,t){let i=new TextEncoder().encode(`caisual:${n}:${e}`),r=new Uint8Array(await t.digest("SHA-256",i));return(r[0]??0)*16777216+((r[1]??0)<<16)+((r[2]??0)<<8)+(r[3]??0)>>>0}function ce(n){let e=n>>>0;return()=>{e=e+1831565813>>>0;let t=e;return t=Math.imul(t^t>>>15,t|1),t^=t+Math.imul(t^t>>>7,t|61),((t^t>>>14)>>>0)/4294967296}}function E(n){return typeof n=="object"&&n!==null&&!Array.isArray(n)?n:null}function le(n,e){return E(n)?.type===e}function Ee(n){if(typeof n!="string")return null;try{let e=new URL(n);return e.origin===n&&(e.protocol==="https:"||e.protocol==="http:")?n:null}catch{return null}}function ue(n,e,t=3e3){return new Promise(i=>{let r=!1,o=globalThis.crypto.randomUUID(),a=l=>{r||(r=!0,n.removeEventListener("message",u),n.clearTimeout(c),i(l))},s=()=>{n.parent.postMessage({type:"caisual:ready",instance:o},e)},u=l=>{if(l.origin!==e||l.source!==n.parent)return;if(le(l.data,"caisual:ready?")){s();return}if(!le(l.data,"caisual:hello"))return;let m=E(l.data),f=l.ports[0];if(typeof m?.ticket!="string"||!G(m.n)||f===void 0)return;f.start();let g=v=>Array.isArray(v)?v.map(b).filter(M=>M!==null):void 0;a({...b(m.language)?{language:b(m.language)}:{},languagePreferences:g(m.languagePreferences),gameLanguages:g(m.gameLanguages),ticket:m.ticket,n:m.n,live:Ee(m.live),invite:typeof m.invite=="string"?m.invite:null,porta:f})};n.addEventListener("message",u);let c=n.setTimeout(()=>a(null),t);s()})}function Ve(n){let e=n.split(".")[1];if(e===void 0)return null;let t=e.replace(/-/g,"+").replace(/_/g,"/").padEnd(Math.ceil(e.length/4)*4,"=");try{let i=E(JSON.parse(globalThis.atob(t)));return typeof i?.exp=="number"&&Number.isFinite(i.exp)?i.exp*1e3:null}catch{return null}}function Oe(n,e,t,i){return new Promise((r,o)=>{let a=!1,s=l=>{a||(a=!0,n.removeEventListener("message",u),e.clearTimeout(c),l===null?o(new Error("Ticket refresh timed out.")):r(l))},u=l=>{let m=E(l.data),f=m?.aud===void 0?"portal":m.aud;m?.type==="caisual:ticket"&&f===i&&typeof m.ticket=="string"&&s(m.ticket)};n.addEventListener("message",u);let c=e.setTimeout(()=>s(null),t);try{n.postMessage(i==="live"?{type:"caisual:ticket",aud:"live"}:{type:"caisual:ticket"})}catch{s(null)}})}function J(n,e,t,i,r=3e3,o="portal"){let a=n,s=null,u=()=>{if(s!==null)return s;let l=Oe(e,t,r,o).then(m=>(a=m,m)).finally(()=>{s===l&&(s=null)});return s=l,l};return{async ottieni(){if(a===null)return u();let c=Ve(a);return c!==null&&c-i()<3e4?u():a},rinnova:u}}var U=.02,de=300,De=200,me=3e3,$e=1e4,Ne=[1e3,2e3,4e3];function fe(n){return Number.isNaN(n)?1:Math.min(1,Math.max(0,n))}function Ge(n){let e=globalThis,t=e.AudioContext??e.webkitAudioContext;return typeof RTCPeerConnection>"u"||typeof MediaStream>"u"||t===void 0||typeof navigator>"u"||navigator.mediaDevices?.getUserMedia===void 0||typeof document>"u"?null:{...n,creaPeerConnection:i=>new RTCPeerConnection(i),getUserMedia:i=>navigator.mediaDevices.getUserMedia(i),creaAudioContext:()=>new t,creaAudioElement:()=>document.createElement("audio"),creaMediaStream:i=>new MediaStream(i)}}var V=class{constructor(e,t,i){this.contesto=e;this.modeCorrente="none";this.stateCorrente="off";this.mutedCorrente=!1;this.speakingCorrente=!1;this.roster=[];this.gains=new Map;this.volumi=new Map;this.speakingPeers=new Map;this.ultimoAudio=new Map;this.zeroDa=new Map;this.timerZero=new Map;this.ascoltatoriPeers=new Set;this.ascoltatoriState=new Set;this.richieste=new Map;this.riproduzioni=new Map;this.sfuAttive=new Map;this.midGiocatori=new Map;this.negati=new Set;this.mesh=new Map;this.stream=null;this.tracciaMic=null;this.audioContext=null;this.analyser=null;this.peerSfu=null;this.sessioneSfu=null;this.connessioneSfuAttesa=!1;this.trasporto=null;this.intervalloAudio=null;this.timerConnessione=null;this.cancellaAttesaConnessione=null;this.timerRiconnessione=null;this.ultimoAudioMic=Number.NEGATIVE_INFINITY;this.sequenzaRichieste=0;this.generazione=0;this.tentativoRiconnessione=0;this.desiderata=!1;this.micDesiderato=!0;this.promessaIngresso=null;this.negoziazione=Promise.resolve();this.dipendenze=i??Ge(t)}get mode(){return this.modeCorrente}get state(){return this.stateCorrente}get mic(){return this.stateCorrente==="on"&&this.tracciaMic!==null}get muted(){return this.mutedCorrente}get speaking(){return this.speakingCorrente}get peers(){return this.copiaPeers()}async join(e={}){if(this.stateCorrente==="on")return;if(this.stateCorrente==="joining"){this.promessaIngresso!==null&&await this.promessaIngresso;return}if(this.stateCorrente==="reconnecting"&&this.desiderata)return;let t=e.mic??!0;this.verificaIngresso(),this.micDesiderato=t,this.desiderata=!0,this.tentativoRiconnessione=0,this.aggiornaState("joining");let i=++this.generazione,r=this.completaIngresso(i);this.promessaIngresso=r;try{await r}finally{this.promessaIngresso===r&&(this.promessaIngresso=null)}}async completaIngresso(e){try{await this.entra(e)}catch(t){if(e!==this.generazione)return;throw this.desiderata=!1,this.chiudiRisorse(),this.aggiornaState("off"),this.mappaErrore(t)}}leave(){let e=this.desiderata||this.stateCorrente!=="off";this.desiderata=!1,this.generazione++,this.fermaRiconnessione(),e&&this.contesto.connessa()&&this.richiedi({t:"voice",op:"stop"}).catch(()=>{}),this.rifiutaRichieste(d("offline","Voice has stopped.")),this.chiudiRisorse(),this.aggiornaState("off")}mute(e=!0){if(this.stateCorrente!=="on"||this.tracciaMic===null)throw d("not_publishing","Join voice before changing mute.");this.mutedCorrente=e,this.tracciaMic.enabled=!e,e&&(this.speakingCorrente=!1),this.notificaPeers(),this.richiedi({t:"voice",op:"mute",muted:e}).catch(()=>{})}setVolume(e,t){let i=fe(t);this.volumi.set(e,i),this.aggiornaGuadagno(e),this.notificaPeers()}onPeers(e){return this.ascoltatoriPeers.add(e),()=>{this.ascoltatoriPeers.delete(e)}}onState(e){return this.ascoltatoriState.add(e),()=>{this.ascoltatoriState.delete(e)}}ricevi(e){if("r"in e){let t=this.richieste.get(e.r);t!==void 0&&(this.richieste.delete(e.r),"error"in e?t.reject(d(e.error.code,e.error.message)):t.resolve(e));return}if(e.op==="roster"){this.negati.clear(),this.modeCorrente=e.mode;let t=new Set(e.peers.map(i=>i.id));this.roster=[...e.peers.map(i=>({...i,mic:!0})),...e.listeners.flatMap(i=>t.has(i)?[]:[{id:i,mic:!1,muted:!0}])];for(let i of this.roster)i.muted&&this.speakingPeers.set(i.id,!1);this.pulisciPeerAssenti(),this.contesto.rosterPronto(),this.notificaPeers(),this.accodaRiconciliazione();return}if(e.op==="gain"){this.negati.clear();for(let[t,i]of Object.entries(e.gains))this.gains.set(t,fe(i)),this.aggiornaZero(t),this.aggiornaGuadagno(t);this.notificaPeers(),this.accodaRiconciliazione();return}if(e.op==="closed"){for(let t of e.mids){let i=this.midGiocatori.get(t);if(i===void 0)continue;let r=this.sfuAttive.get(i);r?.mid===t&&!this.riproduzioni.has(i)&&r.receiver?.track.stop(),r?.mid===t&&this.sfuAttive.delete(i),this.midGiocatori.delete(t),this.scollegaTraccia(i),this.negati.add(i)}this.notificaPeers();return}e.op==="signal"&&this.riceviSegnale(e.from,e.data)}giocatoriCambiati(){this.negati.clear();let e=new Set(this.contesto.giocatori().map(t=>t.id));for(let t of this.gains.keys()){if(e.has(t))continue;this.gains.delete(t),this.zeroDa.delete(t);let i=this.timerZero.get(t);i!==void 0&&this.dipendenze?.clearTimeout(i),this.timerZero.delete(t),this.aggiornaGuadagno(t)}this.notificaPeers(),this.accodaRiconciliazione()}socketDisconnesso(){this.sequenzaRichieste=0,this.rifiutaRichieste(d("offline","The room is reconnecting.")),this.desiderata&&(this.generazione++,this.chiudiRisorse(),this.tentativoRiconnessione=0,this.aggiornaState("reconnecting"))}socketRiconnesso(){this.sequenzaRichieste=0,this.desiderata&&this.stateCorrente==="reconnecting"&&this.programmaRiconnessione()}termina(){this.desiderata=!1,this.generazione++,this.fermaRiconnessione(),this.rifiutaRichieste(d("offline","The room connection ended.")),this.chiudiRisorse(),this.aggiornaState("off")}verificaIngresso(){if(!this.contesto.connessa())throw d("offline","The room is not connected.");if(this.modeCorrente==="none")throw d("voice_disabled","Voice is disabled for this room.");if(this.dipendenze===null)throw d("unsupported","Voice is not supported in this browser.")}async entra(e){this.verificaIngresso();let t=this.richiediDipendenze(),i=t.creaAudioContext();if(this.audioContext=i,this.micDesiderato){let o;try{o=await t.getUserMedia({audio:!0})}catch(s){throw this.permessoNegato(s)?d("permission_denied","Microphone permission was denied."):d("voice_error","The microphone could not be opened.")}try{this.controllaGenerazione(e)}catch(s){for(let u of o.getTracks())u.stop();throw s}let a=o.getAudioTracks()[0];if(a===void 0)throw d("voice_error","The microphone has no audio track.");this.stream=o,this.tracciaMic=a,a.enabled=!this.mutedCorrente,this.preparaAnalizzatore(o)}try{await i.resume()}catch{}this.controllaGenerazione(e);let r=await this.richiedi({t:"voice",op:"ice"});if(this.controllaGenerazione(e),r.op!=="ice")throw d("voice_error","The voice service returned an invalid response.");if(this.modeCorrente=r.mode,r.mode==="none")throw d("voice_disabled","Voice is disabled for this room.");this.trasporto=r.transport,r.transport==="sfu"?await this.entraSfu(r.iceServers,e):await this.richiedi({t:"voice",op:"publish",mic:this.micDesiderato}),this.micDesiderato&&this.mutedCorrente&&await this.richiedi({t:"voice",op:"mute",muted:!0}),this.controllaGenerazione(e),this.tentativoRiconnessione=0,this.aggiornaState("on"),this.avviaMisuraAudio();for(let o of this.gains.keys())this.aggiornaZero(o);this.accodaRiconciliazione()}async entraSfu(e,t){let i=this.richiediDipendenze().creaPeerConnection({iceServers:e,bundlePolicy:"max-bundle"});this.peerSfu=i,i.ontrack=o=>{let a=o.transceiver.mid,s=a===null?void 0:this.midGiocatori.get(a);s!==void 0&&this.collegaTraccia(s,o.track,o.receiver)},this.osservaCaduta(i);let r;if(this.micDesiderato){let o=i.addTransceiver(this.richiediMic(),{direction:"sendonly"}),a=await i.createOffer();await i.setLocalDescription(a),this.controllaGenerazione(t);let s=o.mid,u=i.localDescription?.sdp;if(s===null||u===void 0)throw d("voice_error","The voice connection could not create an offer.");r=await this.richiedi({t:"voice",op:"session",sdp:u,mid:s})}else r=await this.richiedi({t:"voice",op:"session"});if(r.op!=="session")throw d("voice_error","The voice service returned an invalid response.");if(this.sessioneSfu=r.session,this.micDesiderato){if(r.sdp===null)throw d("voice_error","The voice service returned an invalid response.");await i.setRemoteDescription({type:"answer",sdp:r.sdp}),await this.attendiConnessione(i,t),this.connessioneSfuAttesa=!0;return}if(r.sdp!==null)throw d("voice_error","The voice service returned an invalid response.");this.publisherDesiderati().length>0&&await this.riconciliaSfu()}attendiConnessione(e,t){if(e.connectionState==="connected")return Promise.resolve();let i=this.richiediDipendenze();return new Promise((r,o)=>{let a=()=>{e.removeEventListener("connectionstatechange",s),this.timerConnessione!==null&&i.clearTimeout(this.timerConnessione),this.timerConnessione=null,this.cancellaAttesaConnessione=null},s=()=>{t!==this.generazione?(a(),o(d("offline","Voice was stopped."))):e.connectionState==="connected"?(a(),r()):(e.connectionState==="failed"||e.connectionState==="closed")&&(a(),o(d("voice_error","The voice connection failed.")))};e.addEventListener("connectionstatechange",s),this.cancellaAttesaConnessione=()=>{a(),o(d("offline","Voice was stopped."))},this.timerConnessione=i.setTimeout(()=>{a(),o(d("voice_error","The voice connection timed out."))},$e)})}accodaRiconciliazione(){this.stateCorrente==="on"&&(this.negoziazione=this.negoziazione.then(async()=>{this.stateCorrente==="on"&&(this.trasporto==="sfu"?await this.riconciliaSfu():this.trasporto==="mesh"&&this.riconciliaMesh())}).catch(()=>this.avviaRiconnessione()))}async riconciliaSfu(){let e=this.sessioneSfu,t=this.peerSfu;if(e===null||t===null)return;let i=new Map(this.publisherDesiderati().map(c=>[c.id,c])),r=[];for(let[c,l]of this.sfuAttive){let m=i.get(c);m!==void 0&&m.session===l.session&&m.track===l.track||(r.push(l),this.riproduzioni.has(c)||l.receiver?.track.stop(),this.sfuAttive.delete(c),this.midGiocatori.delete(l.mid),this.scollegaTraccia(c))}r.length>0&&await this.richiedi({t:"voice",op:"close",session:e,mids:r.map(c=>c.mid)});let o=[...i.values()].filter(c=>!this.sfuAttive.has(c.id));if(o.length===0)return;let a;try{a=await this.richiedi({t:"voice",op:"subscribe",session:e,tracks:o.map(c=>({session:c.session,track:c.track}))})}catch(c){if(C(c)!=="not_allowed")throw c;for(let l of o)this.negati.add(l.id);return}if(a.op!=="subscribe")throw d("voice_error","The voice service returned an invalid response.");for(let c of a.tracks){let l=o.find(m=>m.session===c.session&&m.track===c.track);c.error==="not_allowed"&&l!==void 0&&this.negati.add(l.id),!(c?.mid===null||c?.mid===void 0||c.error!==null||l===void 0)&&(this.midGiocatori.set(c.mid,l.id),this.sfuAttive.set(l.id,{session:l.session,track:l.track,mid:c.mid,receiver:null}))}await t.setRemoteDescription({type:"offer",sdp:a.sdp});let s=await t.createAnswer();await t.setLocalDescription(s);let u=t.localDescription?.sdp;if(u===void 0)throw d("voice_error","The voice answer is missing.");await this.richiedi({t:"voice",op:"answer",session:e,sdp:u}),this.connessioneSfuAttesa||(await this.attendiConnessione(t,this.generazione),this.connessioneSfuAttesa=!0)}riconciliaMesh(){let e=new Map(this.peerDesiderati().map(t=>[t.id,t]));for(let[t,i]of this.mesh)e.has(t)||(i.pc.close(),this.mesh.delete(t),this.scollegaTraccia(t));for(let t of e.values())this.mesh.has(t.id)||this.creaMesh(t)}creaMesh(e){let t=e.id,i=this.richiediDipendenze().creaPeerConnection(),r={pc:i,makingOffer:!1,ignoreOffer:!1,settingRemoteAnswer:!1,polite:this.contesto.you()>t,receiver:null};this.mesh.set(t,r),i.onicecandidate=o=>{o.candidate!==null&&this.inviaSegnale(t,{kind:"candidate",candidate:o.candidate.toJSON()})},r.polite||(i.onnegotiationneeded=()=>{this.offriMesh(t,r)}),i.ontrack=o=>{r.receiver=o.receiver,this.collegaTraccia(t,o.track,o.receiver)},this.osservaCaduta(i),this.micDesiderato?i.addTransceiver(this.richiediMic(),{direction:e.mic?"sendrecv":"sendonly"}):i.addTransceiver("audio",{direction:"recvonly"})}async offriMesh(e,t){try{t.makingOffer=!0;let i=await t.pc.createOffer();await t.pc.setLocalDescription(i);let r=t.pc.localDescription?.sdp;r!==void 0&&await this.inviaSegnale(e,{kind:"offer",sdp:r})}finally{t.makingOffer=!1}}async riceviSegnale(e,t){if(this.trasporto!=="mesh"||this.stateCorrente!=="on")return;let i=this.peerDesiderati().find(a=>a.id===e);if(i===void 0)return;this.mesh.has(e)||this.creaMesh(i);let r=this.mesh.get(e);if(r===void 0||typeof t!="object"||t===null||Array.isArray(t))return;let o=t;try{if(o.kind==="candidate"){r.ignoreOffer||await r.pc.addIceCandidate(o.candidate);return}if(o.kind!=="offer"&&o.kind!=="answer"||typeof o.sdp!="string")return;let a=!r.makingOffer&&(r.pc.signalingState==="stable"||r.settingRemoteAnswer),s=o.kind==="offer"&&!a;if(r.ignoreOffer=!r.polite&&s,r.ignoreOffer)return;if(r.settingRemoteAnswer=o.kind==="answer",await r.pc.setRemoteDescription({type:o.kind,sdp:o.sdp}),r.settingRemoteAnswer=!1,o.kind==="offer"){let u=await r.pc.createAnswer();await r.pc.setLocalDescription(u);let c=r.pc.localDescription?.sdp;c!==void 0&&await this.inviaSegnale(e,{kind:"answer",sdp:c})}}catch{this.avviaRiconnessione()}}async inviaSegnale(e,t){try{await this.richiedi({t:"voice",op:"signal",to:e,data:t})}catch(i){if(C(i)!=="not_allowed")throw i;this.mesh.get(e)?.pc.close(),this.mesh.delete(e),this.scollegaTraccia(e),this.negati.add(e)}}peerDesiderati(){let e=this.contesto.you(),t=this.contesto.giocatori(),i=t.find(r=>r.id===e);return this.roster.filter(r=>!(r.id===e||this.negati.has(r.id)||!this.micDesiderato&&!r.mic||this.modeCorrente==="team"&&t.find(a=>a.id===r.id)?.team!==i?.team))}publisherDesiderati(){return this.peerDesiderati().filter(e=>{if(!e.mic)return!1;let t=this.zeroDa.get(e.id);return t===void 0||this.richiediDipendenze().ora()-t<me})}aggiornaZero(e){let t=this.dipendenze;if(t===null)return;let i=this.timerZero.get(e);if(i!==void 0&&t.clearTimeout(i),this.timerZero.delete(e),(this.gains.get(e)??1)>0){this.zeroDa.delete(e);return}this.zeroDa.has(e)||this.zeroDa.set(e,t.ora());let r=t.ora()-(this.zeroDa.get(e)??t.ora()),o=t.setTimeout(()=>{this.timerZero.delete(e),this.accodaRiconciliazione()},Math.max(0,me-r));this.timerZero.set(e,o)}collegaTraccia(e,t,i){this.scollegaTraccia(e);let r=this.richiediDipendenze(),o=r.creaMediaStream([t]),a=this.richiediAudioContext().createMediaStreamSource(o),s=this.richiediAudioContext().createGain();a.connect(s),s.connect(this.richiediAudioContext().destination);let u=null;try{u=this.richiediAudioContext().createAnalyser(),u.fftSize=256,a.connect(u)}catch{u=null}let c=r.creaAudioElement();c.srcObject=o,c.muted=!0,c.playsInline=!0,c.play().catch(()=>{}),this.riproduzioni.set(e,{source:a,gain:s,analyser:u,audio:c,track:t,receiver:i});let l=this.sfuAttive.get(e);l!==void 0&&(l.receiver=i),this.aggiornaGuadagno(e)}scollegaTraccia(e){let t=this.riproduzioni.get(e);t!==void 0&&(t.source.disconnect(),t.gain.disconnect(),t.analyser?.disconnect(),t.track.stop(),t.audio.pause(),t.audio.srcObject=null,this.riproduzioni.delete(e),this.speakingPeers.delete(e),this.ultimoAudio.delete(e))}aggiornaGuadagno(e){let t=this.riproduzioni.get(e);t!==void 0&&(t.gain.gain.value=(this.volumi.get(e)??1)*(this.gains.get(e)??1))}preparaAnalizzatore(e){let t=this.richiediAudioContext(),i=t.createAnalyser();i.fftSize=256,t.createMediaStreamSource(e).connect(i),this.analyser=i}avviaMisuraAudio(){let e=this.richiediDipendenze();this.intervalloAudio!==null&&e.clearInterval(this.intervalloAudio),this.intervalloAudio=e.setInterval(()=>this.misuraAudio(),De)}misuraAudio(){let e=this.dipendenze;if(e===null)return;let t=!1;this.analyser!==null&&(t=this.livelloAnalizzatore(this.analyser)>U),t&&(this.ultimoAudioMic=e.ora());let i=!this.mutedCorrente&&e.ora()-this.ultimoAudioMic<=de;i!==this.speakingCorrente&&(this.speakingCorrente=i,this.notificaPeers());let r=!1;for(let o of this.copiaPeers()){let a=this.riproduzioni.get(o.id);this.livelloAnalizzatore(a?.analyser??null)>U?this.ultimoAudio.set(o.id,e.ora()):(a?.analyser===null||a?.analyser===void 0)&&(a?.receiver?.getSynchronizationSources?.()??[]).some(c=>(c.audioLevel??0)>U)&&this.ultimoAudio.set(o.id,e.ora());let s=!o.muted&&e.ora()-(this.ultimoAudio.get(o.id)??0)<=de;(this.speakingPeers.get(o.id)??!1)!==s&&(this.speakingPeers.set(o.id,s),r=!0)}r&&this.notificaPeers()}livelloAnalizzatore(e){let t=e;if(t?.getFloatTimeDomainData===void 0)return 0;let i=new Float32Array(t.fftSize);return t.getFloatTimeDomainData(i),Math.sqrt(i.reduce((r,o)=>r+o*o,0)/Math.max(1,i.length))}copiaPeers(){let e=this.contesto.you(),t=this.contesto.giocatori(),i=t.find(r=>r.id===e);return this.roster.flatMap(r=>r.id===e?[]:this.modeCorrente==="team"&&t.find(a=>a.id===r.id)?.team!==i?.team?[]:[{id:r.id,mic:r.mic,muted:r.muted,speaking:r.mic&&!r.muted&&(this.speakingPeers.get(r.id)??!1),volume:this.volumi.get(r.id)??1,gain:this.gains.get(r.id)??1}])}pulisciPeerAssenti(){let e=new Set(this.roster.map(t=>t.id));for(let t of this.speakingPeers.keys())e.has(t)||this.speakingPeers.delete(t);for(let t of this.zeroDa.keys()){if(e.has(t))continue;this.zeroDa.delete(t);let i=this.timerZero.get(t);i!==void 0&&this.dipendenze?.clearTimeout(i),this.timerZero.delete(t)}}osservaCaduta(e){e.addEventListener("connectionstatechange",()=>{this.stateCorrente==="on"&&(e.connectionState==="failed"||e.connectionState==="disconnected")&&this.avviaRiconnessione()})}avviaRiconnessione(){!this.desiderata||this.stateCorrente==="reconnecting"||(this.generazione++,this.rifiutaRichieste(d("voice_error","The voice connection was restarted.")),this.chiudiRisorse(),this.tentativoRiconnessione=0,this.aggiornaState("reconnecting"),this.programmaRiconnessione())}programmaRiconnessione(){if(!this.desiderata||!this.contesto.connessa()||this.timerRiconnessione!==null||this.stateCorrente!=="reconnecting")return;let e=Ne[this.tentativoRiconnessione];if(e===void 0){this.desiderata=!1,this.aggiornaState("off");return}this.tentativoRiconnessione++,this.timerRiconnessione=this.richiediDipendenze().setTimeout(()=>{this.timerRiconnessione=null;let t=++this.generazione;this.entra(t).catch(()=>{t!==this.generazione||!this.desiderata||(this.chiudiRisorse(),this.aggiornaState("reconnecting"),this.programmaRiconnessione())})},e)}fermaRiconnessione(){this.timerRiconnessione===null||this.dipendenze===null||(this.dipendenze.clearTimeout(this.timerRiconnessione),this.timerRiconnessione=null)}chiudiRisorse(){let e=this.dipendenze;if(this.cancellaAttesaConnessione?.(),this.cancellaAttesaConnessione=null,e!==null){this.intervalloAudio!==null&&e.clearInterval(this.intervalloAudio),this.timerConnessione!==null&&e.clearTimeout(this.timerConnessione);for(let t of this.timerZero.values())e.clearTimeout(t)}this.intervalloAudio=null,this.timerConnessione=null,this.timerZero.clear();for(let t of[...this.riproduzioni.keys()])this.scollegaTraccia(t);this.peerSfu?.close(),this.peerSfu=null;for(let t of this.mesh.values())t.pc.close();this.mesh.clear(),this.sfuAttive.clear(),this.midGiocatori.clear(),this.negati.clear();for(let t of this.stream?.getTracks()??[])t.stop();this.stream=null,this.tracciaMic=null,this.analyser=null,this.audioContext?.close().catch(()=>{}),this.audioContext=null,this.sessioneSfu=null,this.connessioneSfuAttesa=!1,this.trasporto=null,this.speakingCorrente=!1,this.ultimoAudioMic=Number.NEGATIVE_INFINITY,this.speakingPeers.clear(),this.ultimoAudio.clear(),this.negoziazione=Promise.resolve()}richiedi(e){if(!this.contesto.connessa())return Promise.reject(d("offline","The room is reconnecting."));let t=++this.sequenzaRichieste;return new Promise((i,r)=>{this.richieste.set(t,{resolve:i,reject:r});try{this.contesto.invia({...e,r:t})}catch(o){this.richieste.delete(t),r(o)}})}rifiutaRichieste(e){for(let t of this.richieste.values())t.reject(e);this.richieste.clear()}aggiornaState(e){if(e!==this.stateCorrente){this.stateCorrente=e;for(let t of this.ascoltatoriState)try{t(e)}catch{}}}notificaPeers(){let e=this.copiaPeers();for(let t of this.ascoltatoriPeers)try{t(e)}catch{}}controllaGenerazione(e){if(e!==this.generazione||!this.desiderata)throw d("offline","Voice was stopped.")}richiediDipendenze(){if(this.dipendenze===null)throw d("unsupported","Voice is not supported.");return this.dipendenze}richiediMic(){if(this.tracciaMic===null)throw d("voice_error","The microphone is not ready.");return this.tracciaMic}richiediAudioContext(){if(this.audioContext===null)throw d("voice_error","Audio is not ready.");return this.audioContext}permessoNegato(e){return typeof e=="object"&&e!==null&&"name"in e&&(e.name==="NotAllowedError"||e.name==="SecurityError")}mappaErrore(e){if(typeof e=="object"&&e!==null&&"code"in e){let t=e.code;return t==="voice_disabled"||t==="permission_denied"||t==="unsupported"||t==="offline"||t==="voice_error"?e:d("voice_error","Voice could not be started.")}return d("voice_error","Voice could not be started.")}};var k=1,he=[1e3,2e3,4e3,8e3],Le=6e4,je=5e3,Be=2e4,Je=500,Ue=2e3,qe=new Set([4003,4004,4005,4006,4008,4009]);function x(n){return typeof n=="object"&&n!==null&&!Array.isArray(n)?n:null}function pe(n){let e=x(n);return e!==null&&typeof e.roomId=="string"&&typeof e.code=="string"&&typeof e.join=="string"&&typeof e.url=="string"}function Fe(n){let e=x(n),t=x(e?.players);return e!==null&&typeof e.url=="string"&&Number.isInteger(e.timeoutMs)&&e.timeoutMs>=1e3&&e.timeoutMs<=3e5&&t!==null&&Number.isInteger(t.min)&&Number.isInteger(t.max)&&t.min>=1&&t.max>=t.min}function S(n){return JSON.parse(JSON.stringify(n))}function We(n,e){let t=S(n);for(let i of e){if(i.path.length===0){if(i.op!=="set")return{ok:!1};t=S(i.value);continue}let r=t,o=i.path;for(let s=0;s<o.length-1;s++){let u=o[s];if(Array.isArray(r)){if(typeof u!="number"||u>=r.length)return{ok:!1};r=r[u]}else{let c=x(r);if(c===null||typeof u!="string"||!Object.hasOwn(c,u))return{ok:!1};r=c[u]}}let a=o.at(-1);if(Array.isArray(r)){if(i.op!=="set"||typeof a!="number"||a>=r.length)return{ok:!1};r[a]=S(i.value)}else{let s=x(r);if(s===null||typeof a!="string")return{ok:!1};if(i.op==="del"){if(!Object.hasOwn(s,a))return{ok:!1};delete s[a]}else Object.defineProperty(s,a,{configurable:!0,enumerable:!0,value:S(i.value),writable:!0})}}return{ok:!0,state:t}}function He(n){let e=T(n.liveOrigin,"",n.fetcher,n.biglietto),t=async(o,a,s,u)=>{try{return await e(o,a,{...x(s),n:n.n},u)}catch(c){if(c instanceof Error&&"code"in c&&["version_outdated","version_mismatch"].includes(String(c.code))){let l=x(s),m=typeof l?.code=="string"?l.code.toUpperCase().replace(/[\\s-]/g,""):void 0,f=typeof l?.roomId=="string"?l.roomId:void 0;n.onVersionError?.(c,c.code==="version_mismatch"?{code:m,roomId:f}:void 0)}throw c}};async function i(o,a,s=!1){let u=await t(o,"POST",a,s);if(!pe(u))throw d("internal_error","The room service returned an invalid response.");return u}async function r(o){let a=await t("/match","POST",{mode:o.mode,key:o.key});if(!Fe(a))throw d("internal_error","The matchmaking service returned an invalid response.");return a}return{create:o=>i("/rooms",{mode:o}),joinCode:o=>i("/rooms/join",{code:o}),joinRoom:o=>i("/rooms/join",{roomId:o},!0),match:r,flush:o=>t(`/rooms/${encodeURIComponent(o)}/flush`,"POST")}}var q=class{constructor(e,t,i,r,o,a){this.roomId=e;this.codice=t;this.dipendenze=r;this.api=o;this.segnalaStanza=a;this.meta={mode:null,countdownAt:null,configuration:null,connection:"connecting",closedCode:null};this.metaListeners=new Set;this.connectionListeners=new Set;this.errorListeners=new Set;this.roleId=0;this.roleRequests=new Map;this.statoPubblico=null;this.statoSincronizzato=null;this.tickCorrente=0;this.tickRateCorrente=0;this.latenzaCorrente=null;this.ultimoInput=null;this.inputInviato=null;this.timerInput=null;this.ultimoInvioGioco=-1/0;this.inviiGioco=[];this.seedCorrente=0;this.statusCorrente="lobby";this.giocatoriCorrenti=[];this.youCorrente="";this.resultCorrente=null;this.socket=null;this.seq=0;this.scartoOrario=0;this.timerPing=null;this.intervalloPing=null;this.timerRiconnessione=null;this.timerFlush=null;this.flushInCorso=!1;this.flushRichiesto=!1;this.ritardoIndice=0;this.tempoRiconnessione=0;this.resyncRichiesto=!1;this.terminata=!1;this.lasciata=!1;this.prontaRisolta=!1;this.welcomeRicevuto=!1;this.rosterRicevuto=!1;this.timerRoster=null;this.risolviPronta=()=>{};this.rifiutaPronta=()=>{};this.ascoltatoriStato=new Set;this.ascoltatoriGiocatori=new Set;this.ascoltatoriStatus=new Set;this.ascoltatoriMessaggi=new Set;this.promessaPronta=new Promise((s,u)=>{this.risolviPronta=s,this.rifiutaPronta=u}),this.voice=new V({invia:s=>this.invia(s),connessa:()=>this.socket?.readyState===k&&this.welcomeRicevuto&&!this.terminata&&!this.lasciata,you:()=>this.youCorrente,giocatori:()=>this.copiaGiocatori(),rosterPronto:()=>{this.rosterRicevuto=!0,this.risolviProntaSePossibile()}},r,r.voce),this.apri(i)}get mode(){return this.meta.mode}get countdownAt(){return this.meta.countdownAt}get connection(){return this.meta.connection}get metadata(){return structuredClone(this.meta)}onMetadata(e){return this.metaListeners.add(e),()=>this.metaListeners.delete(e)}onConnection(e){return this.connectionListeners.add(e),()=>this.connectionListeners.delete(e)}onError(e){return this.errorListeners.add(e),()=>this.errorListeners.delete(e)}metadataChanged(e){let t=this.meta.connection;this.meta={...this.meta,...e},this.notifica(this.metaListeners,this.metadata),t!==this.meta.connection&&this.notifica(this.connectionListeners,this.meta.connection)}initialMetadata(e){this.metadataChanged({mode:e.mode,countdownAt:e.countdownAt??null,rematch:e.rematch??null,configuration:e.configuration??null,connection:"connected",closedCode:null})}requestRole(e){if(typeof e!="string"||e.length<1||e.length>32)return Promise.reject(d("invalid_role","The role is not valid."));if(this.connection!=="connected"||this.status!=="playing"||!this.meta.configuration?.requestRole)return Promise.reject(d("role_change_unavailable","Roles cannot be requested right now."));if(this.roleRequests.size>=8)return Promise.reject(d("rate_limited","Too many role requests."));let t=++this.roleId;return new Promise((i,r)=>{let o=this.dipendenze.setTimeout(()=>{this.roleRequests.delete(t),r(d("timeout","The role request timed out."))},5e3);this.roleRequests.set(t,{resolve:i,reject:r,timer:o});try{this.invia({t:"request-role",r:t,role:e})}catch(a){this.dipendenze.clearTimeout(o),this.roleRequests.delete(t),r(a)}})}clearRoleRequests(){for(let e of this.roleRequests.values())this.dipendenze.clearTimeout(e.timer),e.reject(d("offline","The room connection ended."));this.roleRequests.clear()}disconnect(){if(this.lasciata)return;this.lasciata=!0;let e=this.socket;this.socket=null,this.voice.termina(),this.fermaInput(),this.fermaPing(),this.fermaRiconnessione(),this.clearRoleRequests(),this.timerRoster!==null&&this.dipendenze.clearTimeout(this.timerRoster),e?.close(1e3),this.segnalaStanza(null),this.metadataChanged({connection:"disconnected",closedCode:null}),this.prontaRisolta||(this.prontaRisolta=!0,this.rifiutaPronta(d("cancelled","The room was disconnected.")))}get role(){return this.giocatoriCorrenti.find(e=>e.id===this.youCorrente)?.role??null}get state(){return this.statoPubblico}get tick(){return this.tickCorrente}get tickRate(){return this.tickRateCorrente}get latency(){return this.latenzaCorrente}get seed(){return this.seedCorrente}get status(){return this.statusCorrente}get players(){return this.copiaGiocatori()}get you(){return this.youCorrente}get code(){return this.codice}get result(){return this.resultCorrente}pronta(){return this.promessaPronta}invite(){return{code:this.codice,url:new URL(`/r/${this.codice}`,this.dipendenze.appOrigin).href}}onState(e){return this.ascoltatoriStato.add(e),()=>{this.ascoltatoriStato.delete(e)}}onPlayers(e){return this.ascoltatoriGiocatori.add(e),()=>{this.ascoltatoriGiocatori.delete(e)}}onStatus(e){return this.ascoltatoriStatus.add(e),()=>{this.ascoltatoriStatus.delete(e)}}onMessage(e){return this.ascoltatoriMessaggi.add(e),()=>{this.ascoltatoriMessaggi.delete(e)}}send(e){if(this.statusCorrente==="finished")return;let t=this.seq+1;this.invia({t:"msg",seq:t,m:e}),this.seq=t,this.ultimoInvioGioco=this.dipendenze.ora(),this.inviiGioco=[...this.inviiGioco.slice(-29),this.ultimoInvioGioco]}input(e){if(!(this.terminata||this.lasciata||this.statusCorrente==="finished")){try{let t=JSON.stringify(e);if(t===void 0)throw new TypeError;this.ultimoInput=t}catch{throw d("invalid_request","Room input must be valid JSON.")}this.programmaInput()}}pulisciInput(){this.fermaInput(),this.ultimoInput=this.inputInviato=null,this.ultimoInvioGioco=-1/0,this.inviiGioco=[]}fermaInput(){this.timerInput!==null&&this.dipendenze.clearTimeout(this.timerInput),this.timerInput=null}programmaInput(){if(this.timerInput!==null||this.ultimoInput===null||this.ultimoInput===this.inputInviato||!this.welcomeRicevuto||this.socket?.readyState!==k||this.terminata||this.lasciata)return;let e=this.dipendenze.ora(),i=1e3/(this.tickRateCorrente>0?Math.min(30,this.tickRateCorrente):30);this.inviiGioco=this.inviiGioco.filter(a=>e-a<1e3);let r=this.inviiGioco.length>=30?this.inviiGioco[0]+1e3:e,o=Number.isFinite(this.ultimoInvioGioco)?this.ultimoInvioGioco+i:e+i;this.timerInput=this.dipendenze.setTimeout(()=>{if(this.timerInput=null,this.ultimoInput===null||this.ultimoInput===this.inputInviato||!this.welcomeRicevuto||this.socket?.readyState!==k||this.terminata||this.lasciata)return;let a=this.dipendenze.ora();if(a<this.ultimoInvioGioco+i||this.inviiGioco.filter(u=>a-u<1e3).length>=30){this.programmaInput();return}let s=this.ultimoInput;try{this.send(JSON.parse(s)),this.inputInviato=s}catch{}},Math.max(0,Math.ceil(Math.max(o,r)-e)))}aggiornaTickRate(e){e===void 0||!Number.isInteger(e)||e<0||e>60||e===this.tickRateCorrente||(this.tickRateCorrente=e,this.fermaInput(),this.programmaInput())}ready(e){this.invia({t:"ready",ready:e})}setRole(e){this.invia({t:"role",role:e})}setTeam(e){this.invia({t:"team",team:e})}restart(){if(this.statusCorrente!=="finished")throw d("rematch_unavailable","This room is not waiting for a rematch.");this.invia({t:"restart"})}leave(){this.lasciata||(this.voice.leave(),this.lasciata=!0,this.segnalaStanza(null),this.socket?.readyState===k&&this.invia({t:"leave"}),this.termina(1e3))}serverTime(){return this.dipendenze.ora()+this.scartoOrario}copiaGiocatori(){return this.giocatoriCorrenti.map(e=>({...e}))}notifica(e,...t){for(let i of e)try{i(...t)}catch{}}invia(e){if(this.socket?.readyState!==k)throw d("offline","The room is reconnecting.");let t;try{t=JSON.stringify(e)}catch{throw d("invalid_request","Room messages must be valid JSON.")}this.socket.send(t)}apri(e){let t;try{t=this.dipendenze.apriSocket(e)}catch{this.programmaRiconnessione();return}this.socket=t,t.addEventListener("open",()=>{this.socket===t&&this.avviaPing()}),t.addEventListener("message",i=>{this.socket===t&&typeof i.data=="string"&&this.ricevi(i.data)}),t.addEventListener("close",i=>{this.socket===t&&this.chiuso(i.code,i.reason)})}avviaPing(){if(this.socket?.readyState!==k||this.terminata||this.lasciata)return;let e=this.statusCorrente==="playing"?je:Be;this.timerPing!==null&&this.intervalloPing===e||(this.timerPing!==null&&this.dipendenze.clearInterval(this.timerPing),this.intervalloPing=e,this.timerPing=this.dipendenze.setInterval(()=>{if(this.socket?.readyState===k)try{this.invia({t:"ping",c:this.dipendenze.ora()})}catch{}},e))}fermaPing(){this.timerPing!==null&&(this.dipendenze.clearInterval(this.timerPing),this.timerPing=null,this.intervalloPing=null)}ricevi(e){let t;try{let i=JSON.parse(e),r=x(i);if(r===null||typeof r.t!="string")return;t=r}catch{return}try{if(t.t==="welcome")this.riceviWelcome(t);else if(t.t==="players")this.riceviGiocatori(t.players);else if(t.t==="status")this.riceviStatus(t);else if(t.t==="state")this.riceviDiff(t);else if(t.t==="snapshot")this.riceviSnapshot(t);else if(t.t==="msg")this.notifica(this.ascoltatoriMessaggi,S(t.m));else if(t.t==="pong")this.riceviPong(t);else if(t.t==="error")this.notifica(this.errorListeners,{code:t.code,message:t.message});else if(t.t==="flush")this.richiediFlush();else if(t.t==="role-result"){let i=this.roleRequests.get(t.r);i&&(this.dipendenze.clearTimeout(i.timer),this.roleRequests.delete(t.r),t.ok?i.resolve():i.reject(d(t.code??"role_change_refused","The role change was not accepted.")))}else t.t==="voice"&&this.voice.ricevi(t)}catch{(t.t==="state"||t.t==="snapshot")&&this.chiediResync()}}riceviWelcome(e){let t=e.room;t.id===this.roomId&&(this.youCorrente=e.you,this.aggiornaTickRate(t.tickRate),this.seedCorrente=t.seed,this.statusCorrente=t.status,this.avviaPing(),this.resultCorrente=S(t.result??null),t.status==="finished"&&this.pulisciInput(),this.giocatoriCorrenti=e.players.map(i=>({...i})),this.aggiornaStato(e.state,t.tick,t.serverTime),this.scartoOrario=t.serverTime-this.dipendenze.ora(),this.resyncRichiesto=!1,this.welcomeRicevuto=!0,!this.rosterRicevuto&&this.timerRoster===null&&(this.timerRoster=this.dipendenze.setTimeout(()=>{this.timerRoster=null,this.rosterRicevuto=!0,this.risolviProntaSePossibile()},Ue)),this.ritardoIndice=0,this.tempoRiconnessione=0,this.notifica(this.ascoltatoriGiocatori,this.copiaGiocatori()),this.voice.giocatoriCambiati(),this.voice.socketRiconnesso(),this.notifica(this.ascoltatoriStatus,this.statusCorrente,this.resultCorrente,t.serverTime),this.initialMetadata(t),this.programmaInput(),this.risolviProntaSePossibile())}riceviGiocatori(e){this.giocatoriCorrenti=e.map(t=>({...t})),this.notifica(this.ascoltatoriGiocatori,this.copiaGiocatori()),this.voice.giocatoriCambiati()}riceviStatus(e){this.statusCorrente=e.status,this.resultCorrente=S(e.result),e.status==="finished"&&(this.pulisciInput(),this.clearRoleRequests()),e.status==="ended"?(this.terminata=!0,this.clearRoleRequests(),this.segnalaStanza(null),this.voice.termina(),this.fermaPing(),this.fermaRiconnessione(),this.fermaInput(),this.ultimoInput=null):this.avviaPing(),this.metadataChanged({rematch:e.rematch??null,countdownAt:e.countdownAt??(e.status==="countdown"?e.at:null),...e.status==="ended"?{connection:"ended",closedCode:4004}:{}}),this.notifica(this.ascoltatoriStatus,this.statusCorrente,this.resultCorrente,e.at)}riceviDiff(e){if(this.aggiornaTickRate(e.tickRate),e.base!==this.tickCorrente){this.chiediResync();return}let t=We(this.statoSincronizzato,e.patch);if(!t.ok){this.chiediResync();return}this.resyncRichiesto=!1,this.aggiornaStato(t.state,e.tick,e.serverTime)}riceviSnapshot(e){e.tick<this.tickCorrente||(this.aggiornaTickRate(e.tickRate),this.resyncRichiesto=!1,this.aggiornaStato(e.state,e.tick,e.serverTime))}aggiornaStato(e,t,i){this.statoSincronizzato=S(e),this.statoPubblico=S(e),this.tickCorrente=t,this.notifica(this.ascoltatoriStato,this.statoPubblico,t,i)}chiediResync(){if(!(this.resyncRichiesto||this.socket?.readyState!==k)){this.resyncRichiesto=!0;try{this.invia({t:"resync"})}catch{this.resyncRichiesto=!1}}}riceviPong(e){let t=this.dipendenze.ora();if(!Number.isFinite(e.c)||!Number.isFinite(e.s)||e.c>t)return;let i=t-e.c;this.latenzaCorrente=this.latenzaCorrente===null?i:this.latenzaCorrente*.8+i*.2,this.scartoOrario=e.s-(e.c+t)/2}chiuso(e,t){if(this.socket=null,this.welcomeRicevuto=!1,this.latenzaCorrente=null,this.fermaInput(),this.inputInviato=null,this.ultimoInvioGioco=-1/0,this.inviiGioco=[],this.fermaPing(),!(this.lasciata||this.terminata)){if(qe.has(e)){let i=e===4009&&t==="message_too_large"?"message_too_large":void 0;i&&this.notifica(this.errorListeners,{code:i,message:"The room message is too large."}),this.termina(e,i);return}this.clearRoleRequests(),this.voice.socketDisconnesso(),this.programmaRiconnessione()}}programmaRiconnessione(){if(this.terminata||this.lasciata||this.timerRiconnessione!==null)return;this.metadataChanged({connection:"reconnecting"});let e=Math.min(this.ritardoIndice,he.length-1),t=he[e];if(this.tempoRiconnessione+t>Le){this.termina("timeout");return}this.ritardoIndice++,this.tempoRiconnessione+=t,this.timerRiconnessione=this.dipendenze.setTimeout(()=>{this.timerRiconnessione=null,this.riconnetti()},t)}async riconnetti(){if(!(this.terminata||this.lasciata))try{let e=await this.api.joinRoom(this.roomId);if(this.terminata||this.lasciata)return;let t=this.codice!==e.code;this.codice=e.code,t&&this.prontaRisolta&&!this.terminata&&!this.lasciata&&this.segnalaStanza({code:this.codice}),this.apri(e.url)}catch(e){e instanceof Error&&"code"in e&&["version_mismatch","version_outdated","room_not_found"].includes(String(e.code))?(this.notifica(this.errorListeners,{code:String(e.code),message:e.message}),this.termina(4004,String(e.code))):this.programmaRiconnessione()}}fermaRiconnessione(){this.timerRiconnessione!==null&&(this.dipendenze.clearTimeout(this.timerRiconnessione),this.timerRiconnessione=null)}termina(e,t){this.clearRoleRequests(),this.metadataChanged({connection:e===1e3?"disconnected":e===4006?"replaced":"closed",closedCode:typeof e=="number"?e:null});let i={closed:e},r=this.statusCorrente!=="ended"||JSON.stringify(this.resultCorrente)!==JSON.stringify(i);if(this.terminata=!0,this.fermaInput(),this.ultimoInput=null,this.segnalaStanza(null),this.statusCorrente="ended",this.resultCorrente=i,this.voice.termina(),this.fermaPing(),this.fermaRiconnessione(),r&&this.notifica(this.ascoltatoriStatus,"ended",i,this.serverTime()),!this.prontaRisolta){this.prontaRisolta=!0;let a=t??(typeof e=="number"?{4003:"kicked",4004:"room_ended",4005:"version_closed",4006:"replaced",4008:"rate_limited",4009:"invalid_request"}[e]??"offline":"offline");this.rifiutaPronta(d(a,"The room connection ended."))}}risolviProntaSePossibile(){this.prontaRisolta||!this.welcomeRicevuto||!this.rosterRicevuto||(this.timerRoster!==null&&(this.dipendenze.clearTimeout(this.timerRoster),this.timerRoster=null),this.prontaRisolta=!0,!this.terminata&&!this.lasciata&&this.segnalaStanza({code:this.codice}),this.risolviPronta())}richiediFlush(){this.flushRichiesto=!0,!(this.flushInCorso||this.timerFlush!==null)&&(this.timerFlush=this.dipendenze.setTimeout(()=>{this.timerFlush=null,this.eseguiFlush()},Je))}async eseguiFlush(){if(!(this.flushInCorso||!this.flushRichiesto)){this.flushInCorso=!0,this.flushRichiesto=!1;try{await this.api.flush(this.roomId)}catch{}finally{this.flushInCorso=!1,this.flushRichiesto&&this.richiediFlush()}}}};function O(n=null){return{invited:n,reload(){typeof window<"u"&&window.location.reload()},onError(){return()=>{}},async create(){throw p()},async join(){throw p()},async match(){throw p()}}}function ge(n,e){let t,i=new Set,r=He({...n,onVersionError(l,m){t=m;for(let f of i)try{f(l)}catch{}}}),o=!1,a=null,s=l=>{let m=l?.code??null;o&&m===a||(o=!0,a=m,n.segnalaStanza?.(l))},u=async l=>{let m=new q(l.roomId,l.code,l.url,n,r,s);return await m.pronta(),m},c=(l,m)=>new Promise((f,g)=>{let v,M=!1,W=()=>{v.removeEventListener("message",Y),v.removeEventListener("close",Z),v.removeEventListener("error",K),m.signal?.removeEventListener("abort",N)},H=()=>{try{v.close(1e3)}catch{}},y=(I,h)=>{M||(M=!0,W(),h&&H(),g(I))};function N(){y(d("cancelled","The matchmaking search was cancelled."),!0)}function Z(){y(p(),!1)}function K(){y(p(),!0)}function Y(I){let h=null;try{h=typeof I.data=="string"?x(JSON.parse(I.data)):null}catch{}if(h===null||typeof h.t!="string"){y(d("internal_error","The matchmaking service sent an invalid message."),!0);return}if(h.t==="waiting"){if(!Number.isInteger(h.players)||!Number.isInteger(h.min)||!Number.isInteger(h.max)){y(d("internal_error","The matchmaking service sent an invalid message."),!0);return}try{m.onWaiting?.({players:h.players,min:h.min,max:h.max})}catch{}return}if(h.t==="matched"){if(!pe(h)){y(d("internal_error","The matchmaking service sent an invalid message."),!0);return}M=!0,W(),H(),f(h);return}if(h.t==="no_match"){y(d("no_match","No match was found before the timeout."),!0);return}if(h.t==="error"){y(d(typeof h.code=="string"?h.code:"internal_error",typeof h.message=="string"?h.message:"The matchmaking service could not complete the search."),!0);return}h.t!=="pong"&&y(d("internal_error","The matchmaking service sent an invalid message."),!0)}try{v=n.apriSocket(l)}catch{g(p());return}v.addEventListener("message",Y),v.addEventListener("close",Z),v.addEventListener("error",K),m.signal?.addEventListener("abort",N,{once:!0}),m.signal?.aborted===!0&&N()});return{invited:e,reload(){n.reload?.(t)},onError(l){return i.add(l),()=>{i.delete(l)}},async create(l){return u(await r.create(l.mode))},async join(l){let m=l??e;if(m==null||m.length===0)throw d("invalid_request","A room invitation code is required.");return u(await r.joinCode(m))},async match(l){let m=()=>l.signal?.aborted===!0;if(m())throw d("cancelled","The matchmaking search was cancelled.");let f=await r.match(l);if(m())throw d("cancelled","The matchmaking search was cancelled.");return u(await c(f.url,l))}}}var R="caisual:save:",Ze=/^[a-z0-9][a-z0-9_-]{0,31}$/;function F(n){if(!Ze.test(n))throw d("invalid_request","Save keys must use lowercase letters, numbers, underscores, or hyphens.")}function ve(n){if(n===null)return null;try{return JSON.parse(n)}catch{return null}}function ye(n){let e=[];for(let t=0;t<n.length;t++){let i=n.key(t);i?.startsWith(R)&&e.push(i.slice(R.length))}return e}function Ke(n,e){let t=()=>{if(n===null)throw p();return n};return{async set(i,r){F(i);let o=t(),a=JSON.stringify({value:r}),s=new TextEncoder().encode(a).byteLength;if(s>262144)throw d("payload_too_large","The save is larger than 262144 bytes.");if(o.getItem(R+i)===null&&ye(o).length>=64)throw d("save_limit","A game can store at most 64 save keys.");let u={value:r,bytes:s,updatedAt:e()};return o.setItem(R+i,JSON.stringify(u)),{key:i,bytes:s,updatedAt:u.updatedAt}},async get(i){return F(i),ve(t().getItem(R+i))?.value??null},async remove(i){F(i),t().removeItem(R+i)},async list(){let i=t();return ye(i).flatMap(r=>{let o=ve(i.getItem(R+r));return o===null?[]:[{key:r,bytes:o.bytes,updatedAt:o.updatedAt}]}).sort((r,o)=>r.key.localeCompare(o.key))}}}async function D(n,e=null){let t=n.ora(),i=j(t),r=await B(n.hostname,i,n.subtle);return{connected:!1,player:{id:"local",name:"Guest",guest:!0},daily:_({day:i,seed:r,expiresAt:P(t)},n.ora,async()=>{let o=n.ora(),a=j(o);return{day:a,seed:await B(n.hostname,a,n.subtle),expiresAt:P(o)}}),time:{now:n.ora},save:Ke(n.archivio,n.ora),room:O(e)}}function Ye(n){let e=n?.querySelector(\'meta[name="caisual-app"]\')?.getAttribute("content");if(e==null)return null;try{let t=new URL(e);return t.origin===e&&(t.protocol==="https:"||t.protocol==="http:")?e:null}catch{return null}}function Xe(){try{return typeof localStorage>"u"?null:localStorage}catch{return null}}function Qe(){return{finestra:typeof window>"u"?null:window,documento:typeof document>"u"?null:document,fetcher:(n,e)=>globalThis.fetch(n,e),archivio:Xe(),language:typeof navigator>"u"?"en":navigator.language,pathname:typeof location>"u"?"/":location.pathname,hostname:typeof location>"u"?"":location.hostname,subtle:globalThis.crypto.subtle,ora:Date.now,sonda:()=>ie()}}async function et(n){let e=Ye(n.documento),t=n.finestra===null||n.finestra.parent===n.finestra;if(e===null||t)return $(await D(n),void 0,n,null);let i=await ue(n.finestra,e,n.timeoutHandshake);if(i===null)return $(await D(n),void 0,n,null);let r=J(i.ticket,i.porta,n.finestra,n.ora,n.timeoutRinnovo,"portal"),o=re(e,n.fetcher,r),a=n.ora(),s;try{s=await o.me()}catch{let f=await D(n,i.invite);return $(f,i,n,null)}let u=n.ora(),c=s.serverTime-(a+u)/2,l=i.live===null?O(i.invite):ge({appOrigin:e,n:i.n,reload:f=>i.porta.postMessage({type:"caisual:reload",target:f}),liveOrigin:i.live,fetcher:n.fetcher,biglietto:J(null,i.porta,n.finestra,n.ora,n.timeoutRinnovo,"live"),apriSocket(f){if(n.apriSocket!==void 0)return n.apriSocket(f);if(typeof WebSocket>"u")throw p();return new WebSocket(f)},ora:n.ora,setTimeout:(f,g)=>globalThis.setTimeout(f,g),clearTimeout:f=>globalThis.clearTimeout(f),setInterval:(f,g)=>globalThis.setInterval(f,g),clearInterval:f=>globalThis.clearInterval(f),voce:n.voce,segnalaStanza(f){try{i.porta.postMessage({type:"caisual:room",room:f})}catch{}}},i.invite),m={connected:!0,player:s.player,daily:_({day:s.day,seed:s.seed,expiresAt:s.expiresAt??P(s.serverTime)},()=>n.ora()+c,async()=>{let f=await o.me();return{day:f.day,seed:f.seed,expiresAt:f.expiresAt??P(f.serverTime)}}),time:{now:()=>n.ora()+c},save:{set:(f,g)=>o.saveSet(f,g),get:f=>o.saveGet(f),remove:f=>o.saveRemove(f),list:()=>o.saveList()},room:l};return $(m,i,n,s.game.slug)}function $(n,e,t,i){let r=e?.languagePreferences?.length?e.languagePreferences:[e?.language??t?.language??"en"],o=Q(r,e?.gameLanguages);return{...n,player:{...n.player,language:o},text:ne(t?.fetcher??globalThis.fetch,o,t?.pathname),crew:e?ae(e.porta,i,n.room):se()}}function be(){return{webgl2:!1,webgpu:!1,wasm:!1,threads:!1,isolated:!1,gpu:"none",memoryMb:null,cores:null,mobile:!1,tier:"low"}}async function tt(n){let e;try{return await Promise.race([Promise.resolve().then(n).catch(()=>be()),new Promise(t=>{e=globalThis.setTimeout(()=>t(be()),1500)})])}finally{e!==void 0&&globalThis.clearTimeout(e)}}function we(n=Qe()){let e=null;return{connect(){return e??(e=Promise.all([et(n),tt(n.sonda)]).then(([t,i])=>({...t,device:i}))),e}}}var Se=we();globalThis.caisual=Se;var Ni=Se;export{Se as caisual,Ni as default};\n');
|
|
4356
|
+
response.end(request.method === "HEAD" ? void 0 : '// Caisual game kit v0.23.1\nvar xe=["www","api","app","play","live","multi","cdn","assets","static","mail","mx","ns1","ns2","autodiscover","_dmarc","admin","login","account","auth","pay","secure","support","help","blog","status","dev","staging","test","caisual","shipz"],nt=new Set(xe);function y(n){if(typeof n!="string"||n.length>128)return null;try{return Intl.getCanonicalLocales(n)[0]??null}catch{return null}}function Re(n,e="en"){let t=[],i=y(n);for(;i;){t.push(i);let r=i.split("-");r.pop(),r.at(-1)?.length===1&&r.pop(),i=r.join("-")}return t.push(y(e)??e),[...new Set(t)]}function Q(n,e=[]){let t=e.map(y).filter(r=>r!==null),i=n.map(y).filter(r=>r!==null);if(!t.length)return i[0]??"en";for(let r of i)for(let o of Re(r,r))if(t.includes(o))return o;return t.includes("en")?"en":t[0]}function ee(n){return typeof n=="object"&&n!==null&&!Array.isArray(n)&&Object.values(n).every(e=>typeof e=="string")}function ke(n){return n.gpu!=="hardware"||n.memoryMb!==null&&n.memoryMb<=2048?"low":n.mobile||n.memoryMb!==null&&n.memoryMb<=4096||n.cores!==null&&n.cores<=4?"mid":"high"}function te(n){try{n?.getExtension("WEBGL_lose_context")?.loseContext()}catch{}}function Pe(n){let e;try{e=n.navigator}catch{e=void 0}let t=null;try{let a=e?.deviceMemory,s=typeof a=="number"?a*1024:NaN;Number.isFinite(s)&&(t=s)}catch{t=null}let i=null;try{let a=e?.hardwareConcurrency;typeof a=="number"&&Number.isFinite(a)&&(i=a)}catch{i=null}let r=!1;try{r=typeof e?.userAgentData?.mobile=="boolean"?e.userAgentData.mobile:/Android|iPhone|iPad|iPod|Mobile/i.test(e?.userAgent??"")}catch{r=!1}let o=!1;try{o=n.crossOriginIsolated===!0}catch{o=!1}return{webgl2:!1,webgpu:!1,wasm:!1,threads:!1,isolated:o,gpu:"none",memoryMb:t,cores:i,mobile:r}}async function ie(n,e=1500){let t=n??globalThis,i=Pe(t),r=Promise.resolve().then(()=>{try{let c=t.document?.createElement("canvas");if(c===void 0)return;let l=c.getContext("webgl2",{failIfMajorPerformanceCaveat:!0});if(l!==null){i.webgl2=!0,i.gpu="hardware",te(l);return}let m=c.getContext("webgl2");m!==null&&(i.webgl2=!0,i.gpu="software",te(m))}catch{i.webgl2=!1,i.gpu="none"}}),o=Promise.resolve().then(async()=>{let c;try{let l=t.navigator?.gpu;if(l===void 0)return;let m=await l.requestAdapter();if(m===null)return;c=await m.requestDevice(),i.webgpu=!0}catch{i.webgpu=!1}finally{try{c?.destroy?.()}catch{}}}),a=Promise.resolve().then(()=>{try{i.wasm=t.WebAssembly?.validate(new Uint8Array([0,97,115,109,1,0,0,0]))===!0}catch{i.wasm=!1}}),s=Promise.resolve().then(()=>{try{if(t.WebAssembly===void 0)return;new t.WebAssembly.Memory({initial:1,maximum:1,shared:!0}),i.threads=!0}catch{i.threads=!1}}),u;return await Promise.race([Promise.all([r,o,a,s]),new Promise(c=>{u=setTimeout(c,Math.max(0,e))})]),u!==void 0&&clearTimeout(u),{...i,tier:ke(i)}}function G(n){return typeof n=="number"&&Number.isSafeInteger(n)&&n>0}var N=Object.freeze({recipientBytesPerSecond:1e5,roomBytesPerSecond:2e6,warningRatio:.8,windowMs:5e3,blockingWindows:3}),It=`Multiplayer budget: ${N.recipientBytesPerSecond/1e3} kB/s per recipient, ${N.roomBytesPerSecond/1e6} MB/s per room, before compression over 5 seconds. At 20 updates/s, budget ${N.recipientBytesPerSecond/2e4} kB per update. Keep visual trails and animation on the client. Warnings start at 80%. Publication measures your server automatically; repeated excess in real matches blocks new rooms only. Game input: 30 messages/s per connection.`;function ne(n,e,t="/"){let i,r=t.match(/^\\/rt\\/[^/]+\\/[1-9][0-9]*\\//)?.[0]??"/";return()=>i??(i=(async()=>{let o={};try{let a=await n(`${r}__caisual/text/${encodeURIComponent(e)}.json`);if(a.ok){let s=await a.json();ee(s)&&(o=s)}}catch{}return(a,s={})=>Object.hasOwn(o,a)?o[a].replace(/\\{([^{}]+)\\}/g,(c,l)=>Object.hasOwn(s,l)?String(s[l]):c):a})())}function d(n,e,t={}){return Object.assign(new Error(e),{name:"CaisualError",code:n,...t})}function h(){return d("offline","Caisual services are unavailable.")}function M(n){return typeof n=="object"&&n!==null&&"code"in n?n.code:null}async function Te(n){let e={};try{e=await n.json()}catch{}return d(typeof e.error?.code=="string"?e.error.code:n.status===401?"invalid_ticket":"internal_error",typeof e.error?.message=="string"?e.error.message:`The request failed with status ${n.status}.`,{currentVersion:e.error?.currentVersion,roomVersion:e.error?.roomVersion})}function I(n,e,t,i){async function r(o,a,s,u){let c=new Headers({Authorization:`Bearer ${s}`}),l;if(u!==void 0){c.set("Content-Type","application/json");try{l=JSON.stringify(u)}catch{throw d("invalid_request","The value must be valid JSON.")}}try{return await t(new URL(e+o,n),{method:a,headers:c,body:l,credentials:"omit"})}catch{throw h()}}return async function(a,s,u,c=!1){let l;try{l=c?await i.rinnova():await i.ottieni()}catch{throw h()}let m=await r(a,s,l,u);if(m.status===401){try{l=await i.rinnova()}catch{throw h()}m=await r(a,s,l,u)}if(!m.ok)throw await Te(m);try{return await m.json()}catch{throw d("internal_error","The service returned an invalid response.")}}}function re(n,e,t){let i=I(n,"/api/kit",e,t);return{me:()=>i("/me","GET"),saveSet:(r,o)=>i(`/saves/${encodeURIComponent(r)}`,"PUT",{value:o}),async saveGet(r){try{return(await i(`/saves/${encodeURIComponent(r)}`,"GET")).value}catch(o){if(M(o)==="not_found")return null;throw o}},async saveRemove(r){await i(`/saves/${encodeURIComponent(r)}`,"DELETE")},async saveList(){return(await i("/saves","GET")).saves}}}function b(n){return typeof n=="object"&&n!==null&&!Array.isArray(n)?n:null}var z={connected:!1,you:null,friends:[],party:null,invites:[]};function oe(n){let e=b(n);if(!e||typeof e.id!="string"||typeof e.name!="string")return null;let t=b(e.game),i=b(e.room);return{id:e.id,name:e.name,online:e.online===!0,game:t&&typeof t.slug=="string"?{slug:t.slug,name:typeof t.name=="string"?t.name:t.slug,iconUrl:typeof t.iconUrl=="string"?t.iconUrl:""}:null,room:i&&typeof i.code=="string"?{code:i.code}:null}}function Ie(n){let e=b(n);if(!e||typeof e.id!="string"||typeof e.leader!="string")return null;let t=Array.isArray(e.members)?e.members.map(i=>oe(i)).filter(i=>i!==null):[];return{id:e.id,leader:e.leader,members:t}}function ze(n){let e=b(n),t=b(e?.from);return!e||typeof e.party!="string"||!t||typeof t.id!="string"?null:{party:e.party,from:{id:t.id,name:typeof t.name=="string"?t.name:t.id},at:typeof e.at=="number"?e.at:0}}function _e(n){let e=b(n);if(!e)return z;let t=b(e.you);return{connected:e.connected===!0,you:t&&typeof t.id=="string"?{id:t.id,name:typeof t.name=="string"?t.name:""}:null,friends:Array.isArray(e.friends)?e.friends.map(i=>oe(i)).filter(i=>i!==null):[],party:Ie(e.party),invites:Array.isArray(e.invites)?e.invites.map(i=>ze(i)).filter(i=>i!==null):[]}}function se(){return{available:!1,get connected(){return!1},get you(){return null},get friends(){return[]},get party(){return null},get invites(){return[]},onChange(n){return n({...z}),()=>{}},createParty(){},invite(){},accept(){},decline(){},kick(){},leave(){},follow(){},join(){return Promise.reject(d("offline","Friends are unavailable in this copy of the game."))}}}function ae(n,e,t){let i={...z},r=new Set,o=s=>{try{n.postMessage({type:"caisual:crew",...s})}catch{}};n.addEventListener("message",s=>{let u=b(s.data);if(u?.type==="caisual:crew-state"){i=u.state===null?{...z}:_e(u.state);for(let c of r)try{c({...i})}catch{}}}),o({op:"subscribe"});let a=s=>{let u=i.friends.find(c=>c.id===s)??i.party?.members.find(c=>c.id===s);if(!u)throw d("friend_not_found","This player is not in your friends list.");return u};return{available:!0,get connected(){return i.connected},get you(){return i.you===null?null:{...i.you}},get friends(){return i.friends.map(s=>({...s}))},get party(){return i.party===null?null:{...i.party,members:i.party.members.map(s=>({...s}))}},get invites(){return i.invites.map(s=>({...s}))},onChange(s){return r.add(s),s({...i}),()=>{r.delete(s)}},createParty(){o({op:"party",action:"create"})},invite(s){o({op:"party",action:"invite",player:s})},accept(s){o({op:"party",action:"accept",party:s})},decline(s){o({op:"party",action:"decline",party:s})},kick(s){o({op:"party",action:"kick",player:s})},leave(){o({op:"party",action:"leave"})},follow(s){let u=a(s);if(!u.room||!u.game)throw d("no_room","This player is not in a room.");o({op:"follow",slug:u.game.slug,code:u.room.code})},join(s){let u=a(s);return u.room?e!==null&&u.game!==null&&u.game.slug!==e?Promise.reject(d("other_game","This player is playing another game. Use follow instead.")):t.join(u.room.code):Promise.reject(d("no_room","This player is not in a room."))}}}function P(n){return(Math.floor(n/864e5)+1)*864e5}function _(n,e,t){let i=new Set,r={...n},o,a=!1;function s(){!i.size||o!==void 0||a||(o=setTimeout(u,Math.max(0,Math.min(2147483647,r.expiresAt-e()))),o.unref?.())}async function u(){o=void 0,a=!0;try{let c=await t(),l=c.day!==r.day;if(r={...c},l)for(let m of[...i])try{m({...c})}catch{}}catch{}finally{a=!1,r.expiresAt<=e()&&(r.expiresAt=e()+3e4),s()}}return{...n,random:ce(n.seed),rng:()=>ce(n.seed),onChange(c){return i.add(c),s(),()=>{i.delete(c),!i.size&&o!==void 0&&(clearTimeout(o),o=void 0)}}}}function j(n){return new Date(n).toISOString().slice(0,10)}async function B(n,e,t){let i=new TextEncoder().encode(`caisual:${n}:${e}`),r=new Uint8Array(await t.digest("SHA-256",i));return(r[0]??0)*16777216+((r[1]??0)<<16)+((r[2]??0)<<8)+(r[3]??0)>>>0}function ce(n){let e=n>>>0;return()=>{e=e+1831565813>>>0;let t=e;return t=Math.imul(t^t>>>15,t|1),t^=t+Math.imul(t^t>>>7,t|61),((t^t>>>14)>>>0)/4294967296}}function E(n){return typeof n=="object"&&n!==null&&!Array.isArray(n)?n:null}function le(n,e){return E(n)?.type===e}function Ee(n){if(typeof n!="string")return null;try{let e=new URL(n);return e.origin===n&&(e.protocol==="https:"||e.protocol==="http:")?n:null}catch{return null}}function ue(n,e,t=3e3){return new Promise(i=>{let r=!1,o=globalThis.crypto.randomUUID(),a=l=>{r||(r=!0,n.removeEventListener("message",u),n.clearTimeout(c),i(l))},s=()=>{n.parent.postMessage({type:"caisual:ready",instance:o},e)},u=l=>{if(l.origin!==e||l.source!==n.parent)return;if(le(l.data,"caisual:ready?")){s();return}if(!le(l.data,"caisual:hello"))return;let m=E(l.data),f=l.ports[0];if(typeof m?.ticket!="string"||!G(m.n)||f===void 0)return;f.start();let g=v=>Array.isArray(v)?v.map(y).filter(C=>C!==null):void 0;a({...y(m.language)?{language:y(m.language)}:{},languagePreferences:g(m.languagePreferences),gameLanguages:g(m.gameLanguages),ticket:m.ticket,n:m.n,live:Ee(m.live),invite:typeof m.invite=="string"?m.invite:null,porta:f})};n.addEventListener("message",u);let c=n.setTimeout(()=>a(null),t);s()})}function Ve(n){let e=n.split(".")[1];if(e===void 0)return null;let t=e.replace(/-/g,"+").replace(/_/g,"/").padEnd(Math.ceil(e.length/4)*4,"=");try{let i=E(JSON.parse(globalThis.atob(t)));return typeof i?.exp=="number"&&Number.isFinite(i.exp)?i.exp*1e3:null}catch{return null}}function Oe(n,e,t,i){return new Promise((r,o)=>{let a=!1,s=l=>{a||(a=!0,n.removeEventListener("message",u),e.clearTimeout(c),l===null?o(new Error("Ticket refresh timed out.")):r(l))},u=l=>{let m=E(l.data),f=m?.aud===void 0?"portal":m.aud;m?.type==="caisual:ticket"&&f===i&&typeof m.ticket=="string"&&s(m.ticket)};n.addEventListener("message",u);let c=e.setTimeout(()=>s(null),t);try{n.postMessage(i==="live"?{type:"caisual:ticket",aud:"live"}:{type:"caisual:ticket"})}catch{s(null)}})}function J(n,e,t,i,r=3e3,o="portal"){let a=n,s=null,u=()=>{if(s!==null)return s;let l=Oe(e,t,r,o).then(m=>(a=m,m)).finally(()=>{s===l&&(s=null)});return s=l,l};return{async ottieni(){if(a===null)return u();let c=Ve(a);return c!==null&&c-i()<3e4?u():a},rinnova:u}}var q=.02,de=300,De=200,me=3e3,$e=1e4,Le=[1e3,2e3,4e3];function fe(n){return Number.isNaN(n)?1:Math.min(1,Math.max(0,n))}function Ge(n){let e=globalThis,t=e.AudioContext??e.webkitAudioContext;return typeof RTCPeerConnection>"u"||typeof MediaStream>"u"||t===void 0||typeof navigator>"u"||navigator.mediaDevices?.getUserMedia===void 0||typeof document>"u"?null:{...n,creaPeerConnection:i=>new RTCPeerConnection(i),getUserMedia:i=>navigator.mediaDevices.getUserMedia(i),creaAudioContext:()=>new t,creaAudioElement:()=>document.createElement("audio"),creaMediaStream:i=>new MediaStream(i)}}var V=class{constructor(e,t,i){this.contesto=e;this.modeCorrente="none";this.stateCorrente="off";this.mutedCorrente=!1;this.speakingCorrente=!1;this.roster=[];this.gains=new Map;this.volumi=new Map;this.speakingPeers=new Map;this.ultimoAudio=new Map;this.zeroDa=new Map;this.timerZero=new Map;this.ascoltatoriPeers=new Set;this.ascoltatoriState=new Set;this.richieste=new Map;this.riproduzioni=new Map;this.sfuAttive=new Map;this.midGiocatori=new Map;this.negati=new Set;this.mesh=new Map;this.stream=null;this.tracciaMic=null;this.audioContext=null;this.analyser=null;this.peerSfu=null;this.sessioneSfu=null;this.connessioneSfuAttesa=!1;this.trasporto=null;this.intervalloAudio=null;this.timerConnessione=null;this.cancellaAttesaConnessione=null;this.timerRiconnessione=null;this.ultimoAudioMic=Number.NEGATIVE_INFINITY;this.sequenzaRichieste=0;this.generazione=0;this.tentativoRiconnessione=0;this.desiderata=!1;this.micDesiderato=!0;this.promessaIngresso=null;this.negoziazione=Promise.resolve();this.dipendenze=i??Ge(t)}get mode(){return this.modeCorrente}get state(){return this.stateCorrente}get mic(){return this.stateCorrente==="on"&&this.tracciaMic!==null}get muted(){return this.mutedCorrente}get speaking(){return this.speakingCorrente}get peers(){return this.copiaPeers()}async join(e={}){if(this.stateCorrente==="on")return;if(this.stateCorrente==="joining"){this.promessaIngresso!==null&&await this.promessaIngresso;return}if(this.stateCorrente==="reconnecting"&&this.desiderata)return;let t=e.mic??!0;this.verificaIngresso(),this.micDesiderato=t,this.desiderata=!0,this.tentativoRiconnessione=0,this.aggiornaState("joining");let i=++this.generazione,r=this.completaIngresso(i);this.promessaIngresso=r;try{await r}finally{this.promessaIngresso===r&&(this.promessaIngresso=null)}}async completaIngresso(e){try{await this.entra(e)}catch(t){if(e!==this.generazione)return;throw this.desiderata=!1,this.chiudiRisorse(),this.aggiornaState("off"),this.mappaErrore(t)}}leave(){let e=this.desiderata||this.stateCorrente!=="off";this.desiderata=!1,this.generazione++,this.fermaRiconnessione(),e&&this.contesto.connessa()&&this.richiedi({t:"voice",op:"stop"}).catch(()=>{}),this.rifiutaRichieste(d("offline","Voice has stopped.")),this.chiudiRisorse(),this.aggiornaState("off")}mute(e=!0){if(this.stateCorrente!=="on"||this.tracciaMic===null)throw d("not_publishing","Join voice before changing mute.");this.mutedCorrente=e,this.tracciaMic.enabled=!e,e&&(this.speakingCorrente=!1),this.notificaPeers(),this.richiedi({t:"voice",op:"mute",muted:e}).catch(()=>{})}setVolume(e,t){let i=fe(t);this.volumi.set(e,i),this.aggiornaGuadagno(e),this.notificaPeers()}onPeers(e){return this.ascoltatoriPeers.add(e),()=>{this.ascoltatoriPeers.delete(e)}}onState(e){return this.ascoltatoriState.add(e),()=>{this.ascoltatoriState.delete(e)}}ricevi(e){if("r"in e){let t=this.richieste.get(e.r);t!==void 0&&(this.richieste.delete(e.r),"error"in e?t.reject(d(e.error.code,e.error.message)):t.resolve(e));return}if(e.op==="roster"){this.negati.clear(),this.modeCorrente=e.mode;let t=new Set(e.peers.map(i=>i.id));this.roster=[...e.peers.map(i=>({...i,mic:!0})),...e.listeners.flatMap(i=>t.has(i)?[]:[{id:i,mic:!1,muted:!0}])];for(let i of this.roster)i.muted&&this.speakingPeers.set(i.id,!1);this.pulisciPeerAssenti(),this.contesto.rosterPronto(),this.notificaPeers(),this.accodaRiconciliazione();return}if(e.op==="gain"){this.negati.clear();for(let[t,i]of Object.entries(e.gains))this.gains.set(t,fe(i)),this.aggiornaZero(t),this.aggiornaGuadagno(t);this.notificaPeers(),this.accodaRiconciliazione();return}if(e.op==="closed"){for(let t of e.mids){let i=this.midGiocatori.get(t);if(i===void 0)continue;let r=this.sfuAttive.get(i);r?.mid===t&&!this.riproduzioni.has(i)&&r.receiver?.track.stop(),r?.mid===t&&this.sfuAttive.delete(i),this.midGiocatori.delete(t),this.scollegaTraccia(i),this.negati.add(i)}this.notificaPeers();return}e.op==="signal"&&this.riceviSegnale(e.from,e.data)}giocatoriCambiati(){this.negati.clear();let e=new Set(this.contesto.giocatori().map(t=>t.id));for(let t of this.gains.keys()){if(e.has(t))continue;this.gains.delete(t),this.zeroDa.delete(t);let i=this.timerZero.get(t);i!==void 0&&this.dipendenze?.clearTimeout(i),this.timerZero.delete(t),this.aggiornaGuadagno(t)}this.notificaPeers(),this.accodaRiconciliazione()}socketDisconnesso(){this.sequenzaRichieste=0,this.rifiutaRichieste(d("offline","The room is reconnecting.")),this.desiderata&&(this.generazione++,this.chiudiRisorse(),this.tentativoRiconnessione=0,this.aggiornaState("reconnecting"))}socketRiconnesso(){this.sequenzaRichieste=0,this.desiderata&&this.stateCorrente==="reconnecting"&&this.programmaRiconnessione()}termina(){this.desiderata=!1,this.generazione++,this.fermaRiconnessione(),this.rifiutaRichieste(d("offline","The room connection ended.")),this.chiudiRisorse(),this.aggiornaState("off")}verificaIngresso(){if(!this.contesto.connessa())throw d("offline","The room is not connected.");if(this.modeCorrente==="none")throw d("voice_disabled","Voice is disabled for this room.");if(this.dipendenze===null)throw d("unsupported","Voice is not supported in this browser.")}async entra(e){this.verificaIngresso();let t=this.richiediDipendenze(),i=t.creaAudioContext();if(this.audioContext=i,this.micDesiderato){let o;try{o=await t.getUserMedia({audio:!0})}catch(s){throw this.permessoNegato(s)?d("permission_denied","Microphone permission was denied."):d("voice_error","The microphone could not be opened.")}try{this.controllaGenerazione(e)}catch(s){for(let u of o.getTracks())u.stop();throw s}let a=o.getAudioTracks()[0];if(a===void 0)throw d("voice_error","The microphone has no audio track.");this.stream=o,this.tracciaMic=a,a.enabled=!this.mutedCorrente,this.preparaAnalizzatore(o)}try{await i.resume()}catch{}this.controllaGenerazione(e);let r=await this.richiedi({t:"voice",op:"ice"});if(this.controllaGenerazione(e),r.op!=="ice")throw d("voice_error","The voice service returned an invalid response.");if(this.modeCorrente=r.mode,r.mode==="none")throw d("voice_disabled","Voice is disabled for this room.");this.trasporto=r.transport,r.transport==="sfu"?await this.entraSfu(r.iceServers,e):await this.richiedi({t:"voice",op:"publish",mic:this.micDesiderato}),this.micDesiderato&&this.mutedCorrente&&await this.richiedi({t:"voice",op:"mute",muted:!0}),this.controllaGenerazione(e),this.tentativoRiconnessione=0,this.aggiornaState("on"),this.avviaMisuraAudio();for(let o of this.gains.keys())this.aggiornaZero(o);this.accodaRiconciliazione()}async entraSfu(e,t){let i=this.richiediDipendenze().creaPeerConnection({iceServers:e,bundlePolicy:"max-bundle"});this.peerSfu=i,i.ontrack=o=>{let a=o.transceiver.mid,s=a===null?void 0:this.midGiocatori.get(a);s!==void 0&&this.collegaTraccia(s,o.track,o.receiver)},this.osservaCaduta(i);let r;if(this.micDesiderato){let o=i.addTransceiver(this.richiediMic(),{direction:"sendonly"}),a=await i.createOffer();await i.setLocalDescription(a),this.controllaGenerazione(t);let s=o.mid,u=i.localDescription?.sdp;if(s===null||u===void 0)throw d("voice_error","The voice connection could not create an offer.");r=await this.richiedi({t:"voice",op:"session",sdp:u,mid:s})}else r=await this.richiedi({t:"voice",op:"session"});if(r.op!=="session")throw d("voice_error","The voice service returned an invalid response.");if(this.sessioneSfu=r.session,this.micDesiderato){if(r.sdp===null)throw d("voice_error","The voice service returned an invalid response.");await i.setRemoteDescription({type:"answer",sdp:r.sdp}),await this.attendiConnessione(i,t),this.connessioneSfuAttesa=!0;return}if(r.sdp!==null)throw d("voice_error","The voice service returned an invalid response.");this.publisherDesiderati().length>0&&await this.riconciliaSfu()}attendiConnessione(e,t){if(e.connectionState==="connected")return Promise.resolve();let i=this.richiediDipendenze();return new Promise((r,o)=>{let a=()=>{e.removeEventListener("connectionstatechange",s),this.timerConnessione!==null&&i.clearTimeout(this.timerConnessione),this.timerConnessione=null,this.cancellaAttesaConnessione=null},s=()=>{t!==this.generazione?(a(),o(d("offline","Voice was stopped."))):e.connectionState==="connected"?(a(),r()):(e.connectionState==="failed"||e.connectionState==="closed")&&(a(),o(d("voice_error","The voice connection failed.")))};e.addEventListener("connectionstatechange",s),this.cancellaAttesaConnessione=()=>{a(),o(d("offline","Voice was stopped."))},this.timerConnessione=i.setTimeout(()=>{a(),o(d("voice_error","The voice connection timed out."))},$e)})}accodaRiconciliazione(){this.stateCorrente==="on"&&(this.negoziazione=this.negoziazione.then(async()=>{this.stateCorrente==="on"&&(this.trasporto==="sfu"?await this.riconciliaSfu():this.trasporto==="mesh"&&this.riconciliaMesh())}).catch(()=>this.avviaRiconnessione()))}async riconciliaSfu(){let e=this.sessioneSfu,t=this.peerSfu;if(e===null||t===null)return;let i=new Map(this.publisherDesiderati().map(c=>[c.id,c])),r=[];for(let[c,l]of this.sfuAttive){let m=i.get(c);m!==void 0&&m.session===l.session&&m.track===l.track||(r.push(l),this.riproduzioni.has(c)||l.receiver?.track.stop(),this.sfuAttive.delete(c),this.midGiocatori.delete(l.mid),this.scollegaTraccia(c))}r.length>0&&await this.richiedi({t:"voice",op:"close",session:e,mids:r.map(c=>c.mid)});let o=[...i.values()].filter(c=>!this.sfuAttive.has(c.id));if(o.length===0)return;let a;try{a=await this.richiedi({t:"voice",op:"subscribe",session:e,tracks:o.map(c=>({session:c.session,track:c.track}))})}catch(c){if(M(c)!=="not_allowed")throw c;for(let l of o)this.negati.add(l.id);return}if(a.op!=="subscribe")throw d("voice_error","The voice service returned an invalid response.");for(let c of a.tracks){let l=o.find(m=>m.session===c.session&&m.track===c.track);c.error==="not_allowed"&&l!==void 0&&this.negati.add(l.id),!(c?.mid===null||c?.mid===void 0||c.error!==null||l===void 0)&&(this.midGiocatori.set(c.mid,l.id),this.sfuAttive.set(l.id,{session:l.session,track:l.track,mid:c.mid,receiver:null}))}await t.setRemoteDescription({type:"offer",sdp:a.sdp});let s=await t.createAnswer();await t.setLocalDescription(s);let u=t.localDescription?.sdp;if(u===void 0)throw d("voice_error","The voice answer is missing.");await this.richiedi({t:"voice",op:"answer",session:e,sdp:u}),this.connessioneSfuAttesa||(await this.attendiConnessione(t,this.generazione),this.connessioneSfuAttesa=!0)}riconciliaMesh(){let e=new Map(this.peerDesiderati().map(t=>[t.id,t]));for(let[t,i]of this.mesh)e.has(t)||(i.pc.close(),this.mesh.delete(t),this.scollegaTraccia(t));for(let t of e.values())this.mesh.has(t.id)||this.creaMesh(t)}creaMesh(e){let t=e.id,i=this.richiediDipendenze().creaPeerConnection(),r={pc:i,makingOffer:!1,ignoreOffer:!1,settingRemoteAnswer:!1,polite:this.contesto.you()>t,receiver:null};this.mesh.set(t,r),i.onicecandidate=o=>{o.candidate!==null&&this.inviaSegnale(t,{kind:"candidate",candidate:o.candidate.toJSON()})},r.polite||(i.onnegotiationneeded=()=>{this.offriMesh(t,r)}),i.ontrack=o=>{r.receiver=o.receiver,this.collegaTraccia(t,o.track,o.receiver)},this.osservaCaduta(i),this.micDesiderato?i.addTransceiver(this.richiediMic(),{direction:e.mic?"sendrecv":"sendonly"}):i.addTransceiver("audio",{direction:"recvonly"})}async offriMesh(e,t){try{t.makingOffer=!0;let i=await t.pc.createOffer();await t.pc.setLocalDescription(i);let r=t.pc.localDescription?.sdp;r!==void 0&&await this.inviaSegnale(e,{kind:"offer",sdp:r})}finally{t.makingOffer=!1}}async riceviSegnale(e,t){if(this.trasporto!=="mesh"||this.stateCorrente!=="on")return;let i=this.peerDesiderati().find(a=>a.id===e);if(i===void 0)return;this.mesh.has(e)||this.creaMesh(i);let r=this.mesh.get(e);if(r===void 0||typeof t!="object"||t===null||Array.isArray(t))return;let o=t;try{if(o.kind==="candidate"){r.ignoreOffer||await r.pc.addIceCandidate(o.candidate);return}if(o.kind!=="offer"&&o.kind!=="answer"||typeof o.sdp!="string")return;let a=!r.makingOffer&&(r.pc.signalingState==="stable"||r.settingRemoteAnswer),s=o.kind==="offer"&&!a;if(r.ignoreOffer=!r.polite&&s,r.ignoreOffer)return;if(r.settingRemoteAnswer=o.kind==="answer",await r.pc.setRemoteDescription({type:o.kind,sdp:o.sdp}),r.settingRemoteAnswer=!1,o.kind==="offer"){let u=await r.pc.createAnswer();await r.pc.setLocalDescription(u);let c=r.pc.localDescription?.sdp;c!==void 0&&await this.inviaSegnale(e,{kind:"answer",sdp:c})}}catch{this.avviaRiconnessione()}}async inviaSegnale(e,t){try{await this.richiedi({t:"voice",op:"signal",to:e,data:t})}catch(i){if(M(i)!=="not_allowed")throw i;this.mesh.get(e)?.pc.close(),this.mesh.delete(e),this.scollegaTraccia(e),this.negati.add(e)}}peerDesiderati(){let e=this.contesto.you(),t=this.contesto.giocatori(),i=t.find(r=>r.id===e);return this.roster.filter(r=>!(r.id===e||this.negati.has(r.id)||!this.micDesiderato&&!r.mic||this.modeCorrente==="team"&&t.find(a=>a.id===r.id)?.team!==i?.team))}publisherDesiderati(){return this.peerDesiderati().filter(e=>{if(!e.mic)return!1;let t=this.zeroDa.get(e.id);return t===void 0||this.richiediDipendenze().ora()-t<me})}aggiornaZero(e){let t=this.dipendenze;if(t===null)return;let i=this.timerZero.get(e);if(i!==void 0&&t.clearTimeout(i),this.timerZero.delete(e),(this.gains.get(e)??1)>0){this.zeroDa.delete(e);return}this.zeroDa.has(e)||this.zeroDa.set(e,t.ora());let r=t.ora()-(this.zeroDa.get(e)??t.ora()),o=t.setTimeout(()=>{this.timerZero.delete(e),this.accodaRiconciliazione()},Math.max(0,me-r));this.timerZero.set(e,o)}collegaTraccia(e,t,i){this.scollegaTraccia(e);let r=this.richiediDipendenze(),o=r.creaMediaStream([t]),a=this.richiediAudioContext().createMediaStreamSource(o),s=this.richiediAudioContext().createGain();a.connect(s),s.connect(this.richiediAudioContext().destination);let u=null;try{u=this.richiediAudioContext().createAnalyser(),u.fftSize=256,a.connect(u)}catch{u=null}let c=r.creaAudioElement();c.srcObject=o,c.muted=!0,c.playsInline=!0,c.play().catch(()=>{}),this.riproduzioni.set(e,{source:a,gain:s,analyser:u,audio:c,track:t,receiver:i});let l=this.sfuAttive.get(e);l!==void 0&&(l.receiver=i),this.aggiornaGuadagno(e)}scollegaTraccia(e){let t=this.riproduzioni.get(e);t!==void 0&&(t.source.disconnect(),t.gain.disconnect(),t.analyser?.disconnect(),t.track.stop(),t.audio.pause(),t.audio.srcObject=null,this.riproduzioni.delete(e),this.speakingPeers.delete(e),this.ultimoAudio.delete(e))}aggiornaGuadagno(e){let t=this.riproduzioni.get(e);t!==void 0&&(t.gain.gain.value=(this.volumi.get(e)??1)*(this.gains.get(e)??1))}preparaAnalizzatore(e){let t=this.richiediAudioContext(),i=t.createAnalyser();i.fftSize=256,t.createMediaStreamSource(e).connect(i),this.analyser=i}avviaMisuraAudio(){let e=this.richiediDipendenze();this.intervalloAudio!==null&&e.clearInterval(this.intervalloAudio),this.intervalloAudio=e.setInterval(()=>this.misuraAudio(),De)}misuraAudio(){let e=this.dipendenze;if(e===null)return;let t=!1;this.analyser!==null&&(t=this.livelloAnalizzatore(this.analyser)>q),t&&(this.ultimoAudioMic=e.ora());let i=!this.mutedCorrente&&e.ora()-this.ultimoAudioMic<=de;i!==this.speakingCorrente&&(this.speakingCorrente=i,this.notificaPeers());let r=!1;for(let o of this.copiaPeers()){let a=this.riproduzioni.get(o.id);this.livelloAnalizzatore(a?.analyser??null)>q?this.ultimoAudio.set(o.id,e.ora()):(a?.analyser===null||a?.analyser===void 0)&&(a?.receiver?.getSynchronizationSources?.()??[]).some(c=>(c.audioLevel??0)>q)&&this.ultimoAudio.set(o.id,e.ora());let s=!o.muted&&e.ora()-(this.ultimoAudio.get(o.id)??0)<=de;(this.speakingPeers.get(o.id)??!1)!==s&&(this.speakingPeers.set(o.id,s),r=!0)}r&&this.notificaPeers()}livelloAnalizzatore(e){let t=e;if(t?.getFloatTimeDomainData===void 0)return 0;let i=new Float32Array(t.fftSize);return t.getFloatTimeDomainData(i),Math.sqrt(i.reduce((r,o)=>r+o*o,0)/Math.max(1,i.length))}copiaPeers(){let e=this.contesto.you(),t=this.contesto.giocatori(),i=t.find(r=>r.id===e);return this.roster.flatMap(r=>r.id===e?[]:this.modeCorrente==="team"&&t.find(a=>a.id===r.id)?.team!==i?.team?[]:[{id:r.id,mic:r.mic,muted:r.muted,speaking:r.mic&&!r.muted&&(this.speakingPeers.get(r.id)??!1),volume:this.volumi.get(r.id)??1,gain:this.gains.get(r.id)??1}])}pulisciPeerAssenti(){let e=new Set(this.roster.map(t=>t.id));for(let t of this.speakingPeers.keys())e.has(t)||this.speakingPeers.delete(t);for(let t of this.zeroDa.keys()){if(e.has(t))continue;this.zeroDa.delete(t);let i=this.timerZero.get(t);i!==void 0&&this.dipendenze?.clearTimeout(i),this.timerZero.delete(t)}}osservaCaduta(e){e.addEventListener("connectionstatechange",()=>{this.stateCorrente==="on"&&(e.connectionState==="failed"||e.connectionState==="disconnected")&&this.avviaRiconnessione()})}avviaRiconnessione(){!this.desiderata||this.stateCorrente==="reconnecting"||(this.generazione++,this.rifiutaRichieste(d("voice_error","The voice connection was restarted.")),this.chiudiRisorse(),this.tentativoRiconnessione=0,this.aggiornaState("reconnecting"),this.programmaRiconnessione())}programmaRiconnessione(){if(!this.desiderata||!this.contesto.connessa()||this.timerRiconnessione!==null||this.stateCorrente!=="reconnecting")return;let e=Le[this.tentativoRiconnessione];if(e===void 0){this.desiderata=!1,this.aggiornaState("off");return}this.tentativoRiconnessione++,this.timerRiconnessione=this.richiediDipendenze().setTimeout(()=>{this.timerRiconnessione=null;let t=++this.generazione;this.entra(t).catch(()=>{t!==this.generazione||!this.desiderata||(this.chiudiRisorse(),this.aggiornaState("reconnecting"),this.programmaRiconnessione())})},e)}fermaRiconnessione(){this.timerRiconnessione===null||this.dipendenze===null||(this.dipendenze.clearTimeout(this.timerRiconnessione),this.timerRiconnessione=null)}chiudiRisorse(){let e=this.dipendenze;if(this.cancellaAttesaConnessione?.(),this.cancellaAttesaConnessione=null,e!==null){this.intervalloAudio!==null&&e.clearInterval(this.intervalloAudio),this.timerConnessione!==null&&e.clearTimeout(this.timerConnessione);for(let t of this.timerZero.values())e.clearTimeout(t)}this.intervalloAudio=null,this.timerConnessione=null,this.timerZero.clear();for(let t of[...this.riproduzioni.keys()])this.scollegaTraccia(t);this.peerSfu?.close(),this.peerSfu=null;for(let t of this.mesh.values())t.pc.close();this.mesh.clear(),this.sfuAttive.clear(),this.midGiocatori.clear(),this.negati.clear();for(let t of this.stream?.getTracks()??[])t.stop();this.stream=null,this.tracciaMic=null,this.analyser=null,this.audioContext?.close().catch(()=>{}),this.audioContext=null,this.sessioneSfu=null,this.connessioneSfuAttesa=!1,this.trasporto=null,this.speakingCorrente=!1,this.ultimoAudioMic=Number.NEGATIVE_INFINITY,this.speakingPeers.clear(),this.ultimoAudio.clear(),this.negoziazione=Promise.resolve()}richiedi(e){if(!this.contesto.connessa())return Promise.reject(d("offline","The room is reconnecting."));let t=++this.sequenzaRichieste;return new Promise((i,r)=>{this.richieste.set(t,{resolve:i,reject:r});try{this.contesto.invia({...e,r:t})}catch(o){this.richieste.delete(t),r(o)}})}rifiutaRichieste(e){for(let t of this.richieste.values())t.reject(e);this.richieste.clear()}aggiornaState(e){if(e!==this.stateCorrente){this.stateCorrente=e;for(let t of this.ascoltatoriState)try{t(e)}catch{}}}notificaPeers(){let e=this.copiaPeers();for(let t of this.ascoltatoriPeers)try{t(e)}catch{}}controllaGenerazione(e){if(e!==this.generazione||!this.desiderata)throw d("offline","Voice was stopped.")}richiediDipendenze(){if(this.dipendenze===null)throw d("unsupported","Voice is not supported.");return this.dipendenze}richiediMic(){if(this.tracciaMic===null)throw d("voice_error","The microphone is not ready.");return this.tracciaMic}richiediAudioContext(){if(this.audioContext===null)throw d("voice_error","Audio is not ready.");return this.audioContext}permessoNegato(e){return typeof e=="object"&&e!==null&&"name"in e&&(e.name==="NotAllowedError"||e.name==="SecurityError")}mappaErrore(e){if(typeof e=="object"&&e!==null&&"code"in e){let t=e.code;return t==="voice_disabled"||t==="permission_denied"||t==="unsupported"||t==="offline"||t==="voice_error"?e:d("voice_error","Voice could not be started.")}return d("voice_error","Voice could not be started.")}};var S=1,he=[1e3,2e3,4e3,8e3],Ne=6e4,je=5e3,Be=2e4,Je=500,qe=2e3,Ue=new Set([4003,4004,4005,4006,4008,4009]);function x(n){return typeof n=="object"&&n!==null&&!Array.isArray(n)?n:null}function pe(n){let e=x(n);return e!==null&&typeof e.roomId=="string"&&typeof e.code=="string"&&typeof e.join=="string"&&typeof e.url=="string"}function Fe(n){let e=x(n);return e!==null&&typeof e.url=="string"}function w(n){return JSON.parse(JSON.stringify(n))}function We(n,e){let t=w(n);for(let i of e){if(i.path.length===0){if(i.op!=="set")return{ok:!1};t=w(i.value);continue}let r=t,o=i.path;for(let s=0;s<o.length-1;s++){let u=o[s];if(Array.isArray(r)){if(typeof u!="number"||u>=r.length)return{ok:!1};r=r[u]}else{let c=x(r);if(c===null||typeof u!="string"||!Object.hasOwn(c,u))return{ok:!1};r=c[u]}}let a=o.at(-1);if(Array.isArray(r)){if(i.op!=="set"||typeof a!="number"||a>=r.length)return{ok:!1};r[a]=w(i.value)}else{let s=x(r);if(s===null||typeof a!="string")return{ok:!1};if(i.op==="del"){if(!Object.hasOwn(s,a))return{ok:!1};delete s[a]}else Object.defineProperty(s,a,{configurable:!0,enumerable:!0,value:w(i.value),writable:!0})}}return{ok:!0,state:t}}function He(n){let e=I(n.liveOrigin,"",n.fetcher,n.biglietto),t=async(o,a,s,u)=>{try{return await e(o,a,{...x(s),n:n.n},u)}catch(c){if(c instanceof Error&&"code"in c&&["version_outdated","version_mismatch"].includes(String(c.code))){let l=x(s),m=typeof l?.code=="string"?l.code.toUpperCase().replace(/[\\s-]/g,""):void 0,f=typeof l?.roomId=="string"?l.roomId:void 0;n.onVersionError?.(c,c.code==="version_mismatch"?{code:m,roomId:f}:void 0)}throw c}};async function i(o,a,s=!1){let u=await t(o,"POST",a,s);if(!pe(u))throw d("internal_error","The room service returned an invalid response.");return u}async function r(o){let a=await t("/match","POST",{mode:o.mode});if(!Fe(a))throw d("internal_error","The match service returned an invalid response.");return a}return{create:o=>i("/rooms",{mode:o}),joinCode:o=>i("/rooms/join",{code:o}),joinRoom:o=>i("/rooms/join",{roomId:o},!0),match:r,flush:o=>t(`/rooms/${encodeURIComponent(o)}/flush`,"POST")}}var U=class{constructor(e,t,i,r,o,a){this.roomId=e;this.codice=t;this.dipendenze=r;this.api=o;this.segnalaStanza=a;this.meta={mode:null,countdownAt:null,configuration:null,connection:"connecting",closedCode:null};this.metaListeners=new Set;this.connectionListeners=new Set;this.errorListeners=new Set;this.roleId=0;this.roleRequests=new Map;this.statoPubblico=null;this.statoSincronizzato=null;this.tickCorrente=0;this.tickRateCorrente=0;this.latenzaCorrente=null;this.ultimoInput=null;this.inputInviato=null;this.timerInput=null;this.ultimoInvioGioco=-1/0;this.inviiGioco=[];this.seedCorrente=0;this.statusCorrente="lobby";this.giocatoriCorrenti=[];this.youCorrente="";this.resultCorrente=null;this.socket=null;this.seq=0;this.scartoOrario=0;this.timerPing=null;this.intervalloPing=null;this.timerRiconnessione=null;this.timerFlush=null;this.flushInCorso=!1;this.flushRichiesto=!1;this.ritardoIndice=0;this.tempoRiconnessione=0;this.resyncRichiesto=!1;this.terminata=!1;this.lasciata=!1;this.prontaRisolta=!1;this.welcomeRicevuto=!1;this.rosterRicevuto=!1;this.timerRoster=null;this.risolviPronta=()=>{};this.rifiutaPronta=()=>{};this.ascoltatoriStato=new Set;this.ascoltatoriGiocatori=new Set;this.ascoltatoriStatus=new Set;this.ascoltatoriMessaggi=new Set;this.origine="player";this.promessaPronta=new Promise((s,u)=>{this.risolviPronta=s,this.rifiutaPronta=u}),this.voice=new V({invia:s=>this.invia(s),connessa:()=>this.socket?.readyState===S&&this.welcomeRicevuto&&!this.terminata&&!this.lasciata,you:()=>this.youCorrente,giocatori:()=>this.copiaGiocatori(),rosterPronto:()=>{this.rosterRicevuto=!0,this.risolviProntaSePossibile()}},r,r.voce),this.apri(i)}get origin(){return this.origine}get mode(){return this.meta.mode}get countdownAt(){return this.meta.countdownAt}get connection(){return this.meta.connection}get metadata(){return structuredClone(this.meta)}onMetadata(e){return this.metaListeners.add(e),()=>this.metaListeners.delete(e)}onConnection(e){return this.connectionListeners.add(e),()=>this.connectionListeners.delete(e)}onError(e){return this.errorListeners.add(e),()=>this.errorListeners.delete(e)}metadataChanged(e){let t=this.meta.connection;this.meta={...this.meta,...e},this.notifica(this.metaListeners,this.metadata),t!==this.meta.connection&&this.notifica(this.connectionListeners,this.meta.connection)}initialMetadata(e){this.origine=e.origin??"player",this.metadataChanged({mode:e.mode,countdownAt:e.countdownAt??null,rematch:e.rematch??null,configuration:e.configuration??null,connection:"connected",closedCode:null})}requestRole(e){if(typeof e!="string"||e.length<1||e.length>32)return Promise.reject(d("invalid_role","The role is not valid."));if(this.connection!=="connected"||this.status!=="playing"||!this.meta.configuration?.requestRole)return Promise.reject(d("role_change_unavailable","Roles cannot be requested right now."));if(this.roleRequests.size>=8)return Promise.reject(d("rate_limited","Too many role requests."));let t=++this.roleId;return new Promise((i,r)=>{let o=this.dipendenze.setTimeout(()=>{this.roleRequests.delete(t),r(d("timeout","The role request timed out."))},5e3);this.roleRequests.set(t,{resolve:i,reject:r,timer:o});try{this.invia({t:"request-role",r:t,role:e})}catch(a){this.dipendenze.clearTimeout(o),this.roleRequests.delete(t),r(a)}})}clearRoleRequests(){for(let e of this.roleRequests.values())this.dipendenze.clearTimeout(e.timer),e.reject(d("offline","The room connection ended."));this.roleRequests.clear()}disconnect(){if(this.lasciata)return;this.lasciata=!0;let e=this.socket;this.socket=null,this.voice.termina(),this.fermaInput(),this.fermaPing(),this.fermaRiconnessione(),this.clearRoleRequests(),this.timerRoster!==null&&this.dipendenze.clearTimeout(this.timerRoster),e?.close(1e3),this.segnalaStanza(null),this.metadataChanged({connection:"disconnected",closedCode:null}),this.prontaRisolta||(this.prontaRisolta=!0,this.rifiutaPronta(d("cancelled","The room was disconnected.")))}get role(){return this.giocatoriCorrenti.find(e=>e.id===this.youCorrente)?.role??null}get state(){return this.statoPubblico}get tick(){return this.tickCorrente}get tickRate(){return this.tickRateCorrente}get latency(){return this.latenzaCorrente}get seed(){return this.seedCorrente}get status(){return this.statusCorrente}get players(){return this.copiaGiocatori()}get you(){return this.youCorrente}get code(){return this.codice}get result(){return this.resultCorrente}pronta(){return this.promessaPronta}invite(){return{code:this.codice,url:new URL(`/r/${this.codice}`,this.dipendenze.appOrigin).href}}onState(e){return this.ascoltatoriStato.add(e),()=>{this.ascoltatoriStato.delete(e)}}onPlayers(e){return this.ascoltatoriGiocatori.add(e),()=>{this.ascoltatoriGiocatori.delete(e)}}onStatus(e){return this.ascoltatoriStatus.add(e),()=>{this.ascoltatoriStatus.delete(e)}}onMessage(e){return this.ascoltatoriMessaggi.add(e),()=>{this.ascoltatoriMessaggi.delete(e)}}send(e){if(this.statusCorrente==="finished")return;let t=this.seq+1;this.invia({t:"msg",seq:t,m:e}),this.seq=t,this.ultimoInvioGioco=this.dipendenze.ora(),this.inviiGioco=[...this.inviiGioco.slice(-29),this.ultimoInvioGioco]}input(e){if(!(this.terminata||this.lasciata||this.statusCorrente==="finished")){try{let t=JSON.stringify(e);if(t===void 0)throw new TypeError;this.ultimoInput=t}catch{throw d("invalid_request","Room input must be valid JSON.")}this.programmaInput()}}pulisciInput(){this.fermaInput(),this.ultimoInput=this.inputInviato=null,this.ultimoInvioGioco=-1/0,this.inviiGioco=[]}fermaInput(){this.timerInput!==null&&this.dipendenze.clearTimeout(this.timerInput),this.timerInput=null}programmaInput(){if(this.timerInput!==null||this.ultimoInput===null||this.ultimoInput===this.inputInviato||!this.welcomeRicevuto||this.socket?.readyState!==S||this.terminata||this.lasciata)return;let e=this.dipendenze.ora(),i=1e3/(this.tickRateCorrente>0?Math.min(30,this.tickRateCorrente):30);this.inviiGioco=this.inviiGioco.filter(a=>e-a<1e3);let r=this.inviiGioco.length>=30?this.inviiGioco[0]+1e3:e,o=Number.isFinite(this.ultimoInvioGioco)?this.ultimoInvioGioco+i:e+i;this.timerInput=this.dipendenze.setTimeout(()=>{if(this.timerInput=null,this.ultimoInput===null||this.ultimoInput===this.inputInviato||!this.welcomeRicevuto||this.socket?.readyState!==S||this.terminata||this.lasciata)return;let a=this.dipendenze.ora();if(a<this.ultimoInvioGioco+i||this.inviiGioco.filter(u=>a-u<1e3).length>=30){this.programmaInput();return}let s=this.ultimoInput;try{this.send(JSON.parse(s)),this.inputInviato=s}catch{}},Math.max(0,Math.ceil(Math.max(o,r)-e)))}aggiornaTickRate(e){e===void 0||!Number.isInteger(e)||e<0||e>60||e===this.tickRateCorrente||(this.tickRateCorrente=e,this.fermaInput(),this.programmaInput())}ready(e){this.origin==="player"&&this.invia({t:"ready",ready:e})}setRole(e){this.invia({t:"role",role:e})}setTeam(e){this.invia({t:"team",team:e})}restart(){if(this.origin!=="matchmaking"){if(this.statusCorrente!=="finished")throw d("rematch_unavailable","This room is not waiting for a rematch.");this.invia({t:"restart"})}}leave(){this.lasciata||(this.voice.leave(),this.lasciata=!0,this.segnalaStanza(null),this.socket?.readyState===S&&this.invia({t:"leave"}),this.termina(1e3))}serverTime(){return this.dipendenze.ora()+this.scartoOrario}copiaGiocatori(){return this.giocatoriCorrenti.map(e=>({...e}))}notifica(e,...t){for(let i of e)try{i(...t)}catch{}}invia(e){if(this.socket?.readyState!==S)throw d("offline","The room is reconnecting.");let t;try{t=JSON.stringify(e)}catch{throw d("invalid_request","Room messages must be valid JSON.")}this.socket.send(t)}apri(e){let t;try{t=this.dipendenze.apriSocket(e)}catch{this.programmaRiconnessione();return}this.socket=t,t.addEventListener("open",()=>{this.socket===t&&this.avviaPing()}),t.addEventListener("message",i=>{this.socket===t&&typeof i.data=="string"&&this.ricevi(i.data)}),t.addEventListener("close",i=>{this.socket===t&&this.chiuso(i.code,i.reason)})}avviaPing(){if(this.socket?.readyState!==S||this.terminata||this.lasciata)return;let e=this.statusCorrente==="playing"?je:Be;this.timerPing!==null&&this.intervalloPing===e||(this.timerPing!==null&&this.dipendenze.clearInterval(this.timerPing),this.intervalloPing=e,this.timerPing=this.dipendenze.setInterval(()=>{if(this.socket?.readyState===S)try{this.invia({t:"ping",c:this.dipendenze.ora()})}catch{}},e))}fermaPing(){this.timerPing!==null&&(this.dipendenze.clearInterval(this.timerPing),this.timerPing=null,this.intervalloPing=null)}ricevi(e){let t;try{let i=JSON.parse(e),r=x(i);if(r===null||typeof r.t!="string")return;t=r}catch{return}try{if(t.t==="welcome")this.riceviWelcome(t);else if(t.t==="players")this.riceviGiocatori(t.players);else if(t.t==="status")this.riceviStatus(t);else if(t.t==="state")this.riceviDiff(t);else if(t.t==="snapshot")this.riceviSnapshot(t);else if(t.t==="msg")this.notifica(this.ascoltatoriMessaggi,w(t.m));else if(t.t==="pong")this.riceviPong(t);else if(t.t==="error")this.notifica(this.errorListeners,{code:t.code,message:t.message});else if(t.t==="flush")this.richiediFlush();else if(t.t==="role-result"){let i=this.roleRequests.get(t.r);i&&(this.dipendenze.clearTimeout(i.timer),this.roleRequests.delete(t.r),t.ok?i.resolve():i.reject(d(t.code??"role_change_refused","The role change was not accepted.")))}else t.t==="voice"&&this.voice.ricevi(t)}catch{(t.t==="state"||t.t==="snapshot")&&this.chiediResync()}}riceviWelcome(e){let t=e.room;t.id===this.roomId&&(this.youCorrente=e.you,this.aggiornaTickRate(t.tickRate),this.seedCorrente=t.seed,this.statusCorrente=t.status,this.avviaPing(),this.resultCorrente=w(t.result??null),t.status==="finished"&&this.pulisciInput(),this.giocatoriCorrenti=e.players.map(i=>({...i})),this.aggiornaStato(e.state,t.tick,t.serverTime),this.scartoOrario=t.serverTime-this.dipendenze.ora(),this.resyncRichiesto=!1,this.welcomeRicevuto=!0,!this.rosterRicevuto&&this.timerRoster===null&&(this.timerRoster=this.dipendenze.setTimeout(()=>{this.timerRoster=null,this.rosterRicevuto=!0,this.risolviProntaSePossibile()},qe)),this.ritardoIndice=0,this.tempoRiconnessione=0,this.notifica(this.ascoltatoriGiocatori,this.copiaGiocatori()),this.voice.giocatoriCambiati(),this.voice.socketRiconnesso(),this.notifica(this.ascoltatoriStatus,this.statusCorrente,this.resultCorrente,t.serverTime),this.initialMetadata(t),this.programmaInput(),this.risolviProntaSePossibile())}riceviGiocatori(e){this.giocatoriCorrenti=e.map(t=>({...t})),this.notifica(this.ascoltatoriGiocatori,this.copiaGiocatori()),this.voice.giocatoriCambiati()}riceviStatus(e){this.statusCorrente=e.status,this.resultCorrente=w(e.result),e.status==="finished"&&(this.pulisciInput(),this.clearRoleRequests()),e.status==="ended"?(this.terminata=!0,this.clearRoleRequests(),this.segnalaStanza(null),this.voice.termina(),this.fermaPing(),this.fermaRiconnessione(),this.fermaInput(),this.ultimoInput=null):this.avviaPing(),this.metadataChanged({rematch:e.rematch??null,countdownAt:e.countdownAt??(e.status==="countdown"?e.at:null),...e.status==="ended"?{connection:"ended",closedCode:4004}:{}}),this.notifica(this.ascoltatoriStatus,this.statusCorrente,this.resultCorrente,e.at)}riceviDiff(e){if(this.aggiornaTickRate(e.tickRate),e.base!==this.tickCorrente){this.chiediResync();return}let t=We(this.statoSincronizzato,e.patch);if(!t.ok){this.chiediResync();return}this.resyncRichiesto=!1,this.aggiornaStato(t.state,e.tick,e.serverTime)}riceviSnapshot(e){e.tick<this.tickCorrente||(this.aggiornaTickRate(e.tickRate),this.resyncRichiesto=!1,this.aggiornaStato(e.state,e.tick,e.serverTime))}aggiornaStato(e,t,i){this.statoSincronizzato=w(e),this.statoPubblico=w(e),this.tickCorrente=t,this.notifica(this.ascoltatoriStato,this.statoPubblico,t,i)}chiediResync(){if(!(this.resyncRichiesto||this.socket?.readyState!==S)){this.resyncRichiesto=!0;try{this.invia({t:"resync"})}catch{this.resyncRichiesto=!1}}}riceviPong(e){let t=this.dipendenze.ora();if(!Number.isFinite(e.c)||!Number.isFinite(e.s)||e.c>t)return;let i=t-e.c;this.latenzaCorrente=this.latenzaCorrente===null?i:this.latenzaCorrente*.8+i*.2,this.scartoOrario=e.s-(e.c+t)/2}chiuso(e,t){if(this.socket=null,this.welcomeRicevuto=!1,this.latenzaCorrente=null,this.fermaInput(),this.inputInviato=null,this.ultimoInvioGioco=-1/0,this.inviiGioco=[],this.fermaPing(),!(this.lasciata||this.terminata)){if(Ue.has(e)){let i=e===4009&&t==="message_too_large"?"message_too_large":void 0;i&&this.notifica(this.errorListeners,{code:i,message:"The room message is too large."}),this.termina(e,i);return}this.clearRoleRequests(),this.voice.socketDisconnesso(),this.programmaRiconnessione()}}programmaRiconnessione(){if(this.terminata||this.lasciata||this.timerRiconnessione!==null)return;this.metadataChanged({connection:"reconnecting"});let e=Math.min(this.ritardoIndice,he.length-1),t=he[e];if(this.tempoRiconnessione+t>Ne){this.termina("timeout");return}this.ritardoIndice++,this.tempoRiconnessione+=t,this.timerRiconnessione=this.dipendenze.setTimeout(()=>{this.timerRiconnessione=null,this.riconnetti()},t)}async riconnetti(){if(!(this.terminata||this.lasciata))try{let e=await this.api.joinRoom(this.roomId);if(this.terminata||this.lasciata)return;let t=this.codice!==e.code;this.codice=e.code,t&&this.prontaRisolta&&!this.terminata&&!this.lasciata&&this.segnalaStanza({code:this.codice}),this.apri(e.url)}catch(e){e instanceof Error&&"code"in e&&["version_mismatch","version_outdated","room_not_found"].includes(String(e.code))?(this.notifica(this.errorListeners,{code:String(e.code),message:e.message}),this.termina(4004,String(e.code))):this.programmaRiconnessione()}}fermaRiconnessione(){this.timerRiconnessione!==null&&(this.dipendenze.clearTimeout(this.timerRiconnessione),this.timerRiconnessione=null)}termina(e,t){this.clearRoleRequests(),this.metadataChanged({connection:e===1e3?"disconnected":e===4006?"replaced":"closed",closedCode:typeof e=="number"?e:null});let i={closed:e},r=this.statusCorrente!=="ended"||JSON.stringify(this.resultCorrente)!==JSON.stringify(i);if(this.terminata=!0,this.fermaInput(),this.ultimoInput=null,this.segnalaStanza(null),this.statusCorrente="ended",this.resultCorrente=i,this.voice.termina(),this.fermaPing(),this.fermaRiconnessione(),r&&this.notifica(this.ascoltatoriStatus,"ended",i,this.serverTime()),!this.prontaRisolta){this.prontaRisolta=!0;let a=t??(typeof e=="number"?{4003:"kicked",4004:"room_ended",4005:"version_closed",4006:"replaced",4008:"rate_limited",4009:"invalid_request"}[e]??"offline":"offline");this.rifiutaPronta(d(a,"The room connection ended."))}}risolviProntaSePossibile(){this.prontaRisolta||!this.welcomeRicevuto||!this.rosterRicevuto||(this.timerRoster!==null&&(this.dipendenze.clearTimeout(this.timerRoster),this.timerRoster=null),this.prontaRisolta=!0,!this.terminata&&!this.lasciata&&this.segnalaStanza({code:this.codice}),this.risolviPronta())}richiediFlush(){this.flushRichiesto=!0,!(this.flushInCorso||this.timerFlush!==null)&&(this.timerFlush=this.dipendenze.setTimeout(()=>{this.timerFlush=null,this.eseguiFlush()},Je))}async eseguiFlush(){if(!(this.flushInCorso||!this.flushRichiesto)){this.flushInCorso=!0,this.flushRichiesto=!1;try{await this.api.flush(this.roomId)}catch{}finally{this.flushInCorso=!1,this.flushRichiesto&&this.richiediFlush()}}}};function O(n=null){return{invited:n,reload(){typeof window<"u"&&window.location.reload()},onError(){return()=>{}},async create(){throw h()},async join(){throw h()},async match(){throw h()}}}function ge(n,e){let t,i=new Set,r=He({...n,onVersionError(l,m){t=m;for(let f of i)try{f(l)}catch{}}}),o=!1,a=null,s=l=>{let m=l?.code??null;o&&m===a||(o=!0,a=m,n.segnalaStanza?.(l))},u=async l=>{let m=new U(l.roomId,l.code,l.url,n,r,s);return await m.pronta(),m},c=(l,m)=>new Promise((f,g)=>{let v,C=!1,W=()=>{v.removeEventListener("message",Y),v.removeEventListener("close",Z),v.removeEventListener("error",K),m.signal?.removeEventListener("abort",L)},H=()=>{try{v.close(1e3)}catch{}},R=(T,p)=>{C||(C=!0,W(),p&&H(),g(T))};function L(){R(d("cancelled","The match request was cancelled."),!0)}function Z(){R(h(),!1)}function K(){R(h(),!0)}function Y(T){let p=null;try{p=typeof T.data=="string"?x(JSON.parse(T.data)):null}catch{}if(p===null||typeof p.t!="string"){R(d("internal_error","The match service sent an invalid message."),!0);return}if(p.t==="matched"){if(!pe(p)){R(d("internal_error","The match service sent an invalid message."),!0);return}C=!0,W(),H(),f(p);return}if(p.t==="error"){R(d(typeof p.code=="string"?p.code:"internal_error",typeof p.message=="string"?p.message:"The match service could not complete the request."),!0);return}p.t!=="pong"&&R(d("internal_error","The match service sent an invalid message."),!0)}try{v=n.apriSocket(l)}catch{g(h());return}v.addEventListener("message",Y),v.addEventListener("close",Z),v.addEventListener("error",K),m.signal?.addEventListener("abort",L,{once:!0}),m.signal?.aborted===!0&&L()});return{invited:e,reload(){n.reload?.(t)},onError(l){return i.add(l),()=>{i.delete(l)}},async create(l){return u(await r.create(l.mode))},async join(l){let m=l??e;if(m==null||m.length===0)throw d("invalid_request","A room invitation code is required.");return u(await r.joinCode(m))},async match(l){let m=()=>l.signal?.aborted===!0;if(m())throw d("cancelled","The match request was cancelled.");try{let f=await r.match(l);if(m())throw d("cancelled","The match request was cancelled.");return await u(await c(f.url,l))}catch(f){let g=f instanceof Error&&"code"in f?String(f.code):"";throw["invalid_request","mode_local","no_server","rate_limited","offline","version_outdated","room_full","cancelled"].includes(g)?f:h()}}}}var k="caisual:save:",Ze=/^[a-z0-9][a-z0-9_-]{0,31}$/;function F(n){if(!Ze.test(n))throw d("invalid_request","Save keys must use lowercase letters, numbers, underscores, or hyphens.")}function ve(n){if(n===null)return null;try{return JSON.parse(n)}catch{return null}}function ye(n){let e=[];for(let t=0;t<n.length;t++){let i=n.key(t);i?.startsWith(k)&&e.push(i.slice(k.length))}return e}function Ke(n,e){let t=()=>{if(n===null)throw h();return n};return{async set(i,r){F(i);let o=t(),a=JSON.stringify({value:r}),s=new TextEncoder().encode(a).byteLength;if(s>262144)throw d("payload_too_large","The save is larger than 262144 bytes.");if(o.getItem(k+i)===null&&ye(o).length>=64)throw d("save_limit","A game can store at most 64 save keys.");let u={value:r,bytes:s,updatedAt:e()};return o.setItem(k+i,JSON.stringify(u)),{key:i,bytes:s,updatedAt:u.updatedAt}},async get(i){return F(i),ve(t().getItem(k+i))?.value??null},async remove(i){F(i),t().removeItem(k+i)},async list(){let i=t();return ye(i).flatMap(r=>{let o=ve(i.getItem(k+r));return o===null?[]:[{key:r,bytes:o.bytes,updatedAt:o.updatedAt}]}).sort((r,o)=>r.key.localeCompare(o.key))}}}async function D(n,e=null){let t=n.ora(),i=j(t),r=await B(n.hostname,i,n.subtle);return{connected:!1,player:{id:"local",name:"Guest",guest:!0},daily:_({day:i,seed:r,expiresAt:P(t)},n.ora,async()=>{let o=n.ora(),a=j(o);return{day:a,seed:await B(n.hostname,a,n.subtle),expiresAt:P(o)}}),time:{now:n.ora},save:Ke(n.archivio,n.ora),room:O(e)}}function Ye(n){let e=n?.querySelector(\'meta[name="caisual-app"]\')?.getAttribute("content");if(e==null)return null;try{let t=new URL(e);return t.origin===e&&(t.protocol==="https:"||t.protocol==="http:")?e:null}catch{return null}}function Xe(){try{return typeof localStorage>"u"?null:localStorage}catch{return null}}function Qe(){return{finestra:typeof window>"u"?null:window,documento:typeof document>"u"?null:document,fetcher:(n,e)=>globalThis.fetch(n,e),archivio:Xe(),language:typeof navigator>"u"?"en":navigator.language,pathname:typeof location>"u"?"/":location.pathname,hostname:typeof location>"u"?"":location.hostname,subtle:globalThis.crypto.subtle,ora:Date.now,sonda:()=>ie()}}async function et(n){let e=Ye(n.documento),t=n.finestra===null||n.finestra.parent===n.finestra;if(e===null||t)return $(await D(n),void 0,n,null);let i=await ue(n.finestra,e,n.timeoutHandshake);if(i===null)return $(await D(n),void 0,n,null);let r=J(i.ticket,i.porta,n.finestra,n.ora,n.timeoutRinnovo,"portal"),o=re(e,n.fetcher,r),a=n.ora(),s;try{s=await o.me()}catch{let f=await D(n,i.invite);return $(f,i,n,null)}let u=n.ora(),c=s.serverTime-(a+u)/2,l=i.live===null?O(i.invite):ge({appOrigin:e,n:i.n,reload:f=>i.porta.postMessage({type:"caisual:reload",target:f}),liveOrigin:i.live,fetcher:n.fetcher,biglietto:J(null,i.porta,n.finestra,n.ora,n.timeoutRinnovo,"live"),apriSocket(f){if(n.apriSocket!==void 0)return n.apriSocket(f);if(typeof WebSocket>"u")throw h();return new WebSocket(f)},ora:n.ora,setTimeout:(f,g)=>globalThis.setTimeout(f,g),clearTimeout:f=>globalThis.clearTimeout(f),setInterval:(f,g)=>globalThis.setInterval(f,g),clearInterval:f=>globalThis.clearInterval(f),voce:n.voce,segnalaStanza(f){try{i.porta.postMessage({type:"caisual:room",room:f})}catch{}}},i.invite),m={connected:!0,player:s.player,daily:_({day:s.day,seed:s.seed,expiresAt:s.expiresAt??P(s.serverTime)},()=>n.ora()+c,async()=>{let f=await o.me();return{day:f.day,seed:f.seed,expiresAt:f.expiresAt??P(f.serverTime)}}),time:{now:()=>n.ora()+c},save:{set:(f,g)=>o.saveSet(f,g),get:f=>o.saveGet(f),remove:f=>o.saveRemove(f),list:()=>o.saveList()},room:l};return $(m,i,n,s.game.slug)}function $(n,e,t,i){let r=e?.languagePreferences?.length?e.languagePreferences:[e?.language??t?.language??"en"],o=Q(r,e?.gameLanguages);return{...n,player:{...n.player,language:o},text:ne(t?.fetcher??globalThis.fetch,o,t?.pathname),crew:e?ae(e.porta,i,n.room):se()}}function be(){return{webgl2:!1,webgpu:!1,wasm:!1,threads:!1,isolated:!1,gpu:"none",memoryMb:null,cores:null,mobile:!1,tier:"low"}}async function tt(n){let e;try{return await Promise.race([Promise.resolve().then(n).catch(()=>be()),new Promise(t=>{e=globalThis.setTimeout(()=>t(be()),1500)})])}finally{e!==void 0&&globalThis.clearTimeout(e)}}function we(n=Qe()){let e=null;return{connect(){return e??(e=Promise.all([et(n),tt(n.sonda)]).then(([t,i])=>({...t,device:i}))),e}}}var Se=we();globalThis.caisual=Se;var Li=Se;export{Se as caisual,Li as default};\n');
|
|
4530
4357
|
return;
|
|
4531
4358
|
}
|
|
4532
4359
|
const textMatch = url.pathname.match(/^\/__caisual\/text\/([^/]+)\.json$/);
|
|
@@ -5017,8 +4844,8 @@ export {
|
|
|
5017
4844
|
}
|
|
5018
4845
|
async startMatch(request, response, ticket, origin) {
|
|
5019
4846
|
const body = object(await readBody(request));
|
|
5020
|
-
if (body === null || !Object.hasOwn(body, "mode") ||
|
|
5021
|
-
throw new DevHttpError(400, "invalid_request", "The match request must contain
|
|
4847
|
+
if (body === null || !Object.hasOwn(body, "mode") || Object.keys(body).some((field) => field !== "mode" && field !== "key" && field !== "n")) {
|
|
4848
|
+
throw new DevHttpError(400, "invalid_request", "The match request must contain a valid mode and n.");
|
|
5022
4849
|
}
|
|
5023
4850
|
if (body.n !== void 0 && body.n !== ticket.n) throw new DevHttpError(400, "invalid_request", "n must match the local game version.");
|
|
5024
4851
|
if (typeof body.mode !== "string") {
|
|
@@ -5030,32 +4857,18 @@ export {
|
|
|
5030
4857
|
}
|
|
5031
4858
|
if (modalitaLocale(this.manifest, mode.id)) throw new DevHttpError(400, "mode_local", MODE_LOCAL_MESSAGE);
|
|
5032
4859
|
if (this.definition === null) throw new DevHttpError(409, "no_server", "This game has no multiplayer server.");
|
|
5033
|
-
if (mode.matchmaking
|
|
4860
|
+
if (mode.matchmaking !== true) {
|
|
5034
4861
|
throw new DevHttpError(400, "invalid_request", "This mode does not support matchmaking.");
|
|
5035
4862
|
}
|
|
5036
|
-
const
|
|
5037
|
-
const risolta = risolviModalita(this.manifest, mode.id);
|
|
5038
|
-
const token = matchTicket(
|
|
5039
|
-
playerFromTicket(ticket),
|
|
5040
|
-
ticket.game,
|
|
5041
|
-
mode.id,
|
|
5042
|
-
key,
|
|
5043
|
-
mode.matchmaking,
|
|
5044
|
-
risolta.players,
|
|
5045
|
-
risolta.lobby,
|
|
5046
|
-
this.secret
|
|
5047
|
-
);
|
|
4863
|
+
const token = matchTicket(playerFromTicket(ticket), ticket.game, mode.id, this.secret);
|
|
5048
4864
|
sendJson(response, {
|
|
5049
|
-
url: `ws://localhost:${this.port}/match?j=${encodeURIComponent(token)}
|
|
5050
|
-
timeoutMs: mode.matchmaking.timeoutMs,
|
|
5051
|
-
players: risolta.players
|
|
4865
|
+
url: `ws://localhost:${this.port}/match?j=${encodeURIComponent(token)}`
|
|
5052
4866
|
}, 200, origin);
|
|
5053
4867
|
}
|
|
5054
4868
|
roomManifest() {
|
|
5055
4869
|
return {
|
|
5056
4870
|
id: this.manifest.id,
|
|
5057
4871
|
players: this.manifest.players,
|
|
5058
|
-
lobby: this.manifest.lobby,
|
|
5059
4872
|
persistent: this.manifest.persistent,
|
|
5060
4873
|
roles: this.manifest.roles,
|
|
5061
4874
|
teams: this.manifest.teams,
|
|
@@ -5316,12 +5129,12 @@ function templateManifest(id, name, multiplayer) {
|
|
|
5316
5129
|
icon: "icon.png",
|
|
5317
5130
|
languages: ["en"],
|
|
5318
5131
|
platform: "both",
|
|
5319
|
-
...multiplayer ? { players: { min: 2, max: 4 },
|
|
5132
|
+
...multiplayer ? { players: { min: 2, max: 4 }, persistent: true } : {},
|
|
5320
5133
|
modes: [
|
|
5321
|
-
{ id: "practice", players: { min: 1, max: 1 }
|
|
5134
|
+
{ id: "practice", players: { min: 1, max: 1 } },
|
|
5322
5135
|
...multiplayer ? [{
|
|
5323
5136
|
id: "together",
|
|
5324
|
-
matchmaking:
|
|
5137
|
+
matchmaking: true
|
|
5325
5138
|
}] : []
|
|
5326
5139
|
]
|
|
5327
5140
|
};
|
|
@@ -5331,7 +5144,8 @@ var templateTexts = {
|
|
|
5331
5144
|
controls: "Click or tap a target, or press Space.",
|
|
5332
5145
|
complete: "Complete",
|
|
5333
5146
|
result: "8 / 8",
|
|
5334
|
-
progress: "{n} / 8"
|
|
5147
|
+
progress: "{n} / 8",
|
|
5148
|
+
waiting: "Waiting for {n} more player(s) ({players}/{min}). Try the targets."
|
|
5335
5149
|
};
|
|
5336
5150
|
function templateIndex(multiplayer) {
|
|
5337
5151
|
return `<!doctype html>
|
|
@@ -5360,11 +5174,11 @@ function templateIndex(multiplayer) {
|
|
|
5360
5174
|
if (location.hostname === 'localhost' || location.hostname.endsWith('.localhost')) window.caisualDebug = { c, get room() { return room; }, get playing() { return playing; } };
|
|
5361
5175
|
const canvas = document.querySelector('canvas'), ctx = canvas.getContext('2d'), status = document.querySelector('#status');
|
|
5362
5176
|
canvas.setAttribute('aria-label', t('controls'));
|
|
5363
|
-
let state = { hits: 0 }, room = null, stops = [], playing = false;
|
|
5177
|
+
let practiceHits = 0, state = { hits: 0 }, room = null, stops = [], playing = false;
|
|
5364
5178
|
let width = 1, height = 1, target = { x: 0, y: 0, radius: 24 };
|
|
5365
5179
|
const position = (hits) => ({ x: .25 + ((hits * 7) % 11) / 20, y: .28 + ((hits * 3) % 7) / 14 });
|
|
5366
5180
|
function draw() {
|
|
5367
|
-
const point = position(state.hits), radius = Math.max(28, Math.min(width, height) * .09);
|
|
5181
|
+
const point = position(room?.status === 'waiting' ? practiceHits : state.hits), radius = Math.max(28, Math.min(width, height) * .09);
|
|
5368
5182
|
target = { x: point.x * width, y: point.y * height, radius };
|
|
5369
5183
|
ctx.clearRect(0, 0, width, height);
|
|
5370
5184
|
ctx.fillStyle = '#000000'; ctx.fillRect(0, 0, width, height);
|
|
@@ -5378,6 +5192,7 @@ function templateIndex(multiplayer) {
|
|
|
5378
5192
|
ctx.fillStyle = '#ffffff'; ctx.textAlign = 'center'; ctx.font = 'bold ' + Math.min(54, width / 10) + 'px system-ui'; ctx.fillText(t('complete'), width / 2, height / 2);
|
|
5379
5193
|
}
|
|
5380
5194
|
status.textContent = state.hits >= 8 ? t('result') : t('progress', { n: state.hits });
|
|
5195
|
+
if (room?.status === 'waiting') { const min = room.metadata.configuration.players.min, count = room.players.filter(player => player.connected).length; status.textContent = t('waiting', { n: Math.max(0, min - count), players: count, min }); }
|
|
5381
5196
|
canvas.dataset.state = JSON.stringify({ hits: state.hits, target, playing });
|
|
5382
5197
|
}
|
|
5383
5198
|
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(); }
|
|
@@ -5389,14 +5204,16 @@ function templateIndex(multiplayer) {
|
|
|
5389
5204
|
onRoom(next) {
|
|
5390
5205
|
stops.forEach((stop) => stop()); room = next; state = next.state ?? { hits: 0 };
|
|
5391
5206
|
stops = [
|
|
5207
|
+
next.onPlayers(() => draw()),
|
|
5392
5208
|
next.onState((value) => { state = value; draw(); }),
|
|
5393
|
-
next.onStatus((roomStatus) => { playing = roomStatus === 'playing'; draw(); }),
|
|
5209
|
+
next.onStatus((roomStatus) => { playing = roomStatus === 'playing' || roomStatus === 'waiting'; draw(); }),
|
|
5394
5210
|
];
|
|
5395
|
-
playing = next.status === 'playing'; draw();
|
|
5211
|
+
playing = next.status === 'playing' || next.status === 'waiting'; draw();
|
|
5396
5212
|
},
|
|
5397
5213
|
onExit() { room = null; playing = false; state = { hits: 0 }; draw(); },
|
|
5398
5214
|
});
|
|
5399
5215
|
function hit() {
|
|
5216
|
+
if (room?.status === 'waiting') { practiceHits++; draw(); return; }
|
|
5400
5217
|
if (!playing || state.hits >= 8) return;
|
|
5401
5218
|
// Il server decide il progresso condiviso; il client propone solo la luce corrente.
|
|
5402
5219
|
if (room) { room.send({ hit: state.hits }); return; }
|
|
@@ -5433,13 +5250,13 @@ export default defineGame({
|
|
|
5433
5250
|
var index_html_default = '<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>Beacon arena</title>\n<style>\n:root{--safe-top:env(safe-area-inset-top,0px);--safe-right:env(safe-area-inset-right,0px);--safe-bottom:env(safe-area-inset-bottom,0px);--safe-left:env(safe-area-inset-left,0px)}\nhtml,body{margin:0;width:100%;height:100%;overflow:hidden;background:#000000;color:#ffffff;font:15px system-ui}canvas{display:block;width:100%;height:100dvh;touch-action:none;outline:none}\n#hud{position:absolute;top:calc(var(--safe-top) + 12px);left:calc(var(--safe-left) + 12px);width:180px;max-width:calc(100% - var(--safe-left) - var(--safe-right) - 24px);box-sizing:border-box;padding:10px 12px;border:1px solid #808080;background:#000000}#hud[hidden]{display:none}#hud strong,#hud span{display:block}#hud span{font-size:12px;margin:4px 0;color:#ffffff}button{font:inherit;border:0;background:#ffffff;color:#000000;padding:7px 16px;touch-action:none}button:disabled{opacity:.5}\n</style></head><body><canvas tabindex="0"></canvas><div id="hud" hidden><strong id="score"></strong><span id="hint"></span><button id="pulse"></button></div><script type="module" src="./game.js"></script></body></html>\n';
|
|
5434
5251
|
|
|
5435
5252
|
// src/arcade/game.js.txt
|
|
5436
|
-
var game_js_default = "import { caisual } from '/__caisual/kit/v1.js';\nimport { createMenu } from './menu.js';\nimport { RULES, entity, stepEntity, reconcile, beacon, interpolate } from './physics.js';\nconst c = await caisual.connect(), t = await c.text();\ndocument.documentElement.lang = c.player.language; document.title = t('title');\nconst canvas = document.querySelector('canvas'), ctx = canvas.getContext('2d'), hud = document.querySelector('#hud');\nconst score = document.querySelector('#score'), hint = document.querySelector('#hint'), button = document.querySelector('#pulse');\ncanvas.setAttribute('aria-label', t('controls')); button.textContent = t('pulse'); hint.textContent = t('hint');\nlet room = null, state = null;\nlet stops = [], pending = [], samples = [], predicted = null, seq = 0, actionSeq = 0, action = null, round = null;\nlet accumulator = 0, lastFrame = performance.now(), lastSend = 0, lastPulse = -Infinity, visual = null, offset = { x: 0, y: 0 };\nlet rendered = [];\nlet keys = new Set(), pointer = null, layout = { x: 0, y: 0, scale: 1 }, width = innerWidth, height = innerHeight;\nconst active = () => room !== null && room.status === 'playing' && room.connection === 'connected';\nfunction clearControls() { keys.clear(); pointer = null; }\nfunction read(next, tick, at = room.serverTime()) {\n if (!next?.entities) return;\n const newRound = next.round !== round;\n state = next;\n if (newRound) { round = next.round; pending = []; samples = []; predicted = visual = null; offset = { x: 0, y: 0 }; seq = actionSeq = 0; action = null; accumulator = 0; clearControls(); }\n const mine = state.entities[room.you];\n if (mine) {\n seq = Math.max(seq, mine.ack); actionSeq = Math.max(actionSeq, mine.actionAck);\n if (action && mine.actionAck >= action.seq) action = null;\n const before = predicted;\n ({ predicted, pending } = reconcile(mine, pending));\n // La correzione cambia subito la simulazione; solo il disegno assorbe lo scarto in pochi fotogrammi.\n if (before && !newRound) { offset.x += before.x - predicted.x; offset.y += before.y - predicted.y; }\n }\n samples.push({ at, entities: structuredClone(next.entities) });\n samples = samples.filter(sample => at - sample.at < 2500).slice(-120);\n}\nfunction attach(next) {\n stops.forEach(stop => stop()); stops = []; room = next;\n state = null; round = null; pending = []; samples = []; predicted = visual = null; action = null; clearControls();\n read(room.state);\n stops.push(room.onState(read), room.onConnection(() => {\n pending = []; action = null; accumulator = 0; offset = { x: 0, y: 0 }; clearControls();\n const mine = room.state?.entities?.[room.you];\n if (mine) { predicted = { ...mine }; seq = mine.ack; }\n // Un valore neutro sostituisce anche l'ultimo input conservato dal kit durante la riconnessione.\n if (room) room.input({ type: 'move', round, commands: [] });\n }), room.onStatus(() => { clearControls(); }));\n}\nfunction detach() {\n stops.forEach(stop => stop()); stops = []; room = null; state = null; round = null;\n pending = []; samples = []; predicted = visual = null; action = null; clearControls();\n}\nfunction resize() {\n width = innerWidth; height = innerHeight;\n const ratio = Math.min(devicePixelRatio || 1, 2); canvas.width = width * ratio; canvas.height = height * ratio; ctx.setTransform(ratio, 0, 0, ratio, 0, 0);\n // Il campo occupa tutta la finestra: il margine serve solo a non finire sotto la tacca.\n const style = getComputedStyle(document.documentElement);\n const inset = side => parseFloat(style.getPropertyValue('--safe-' + side)) || 0;\n const availableWidth = width - inset('left') - inset('right'), availableHeight = height - inset('top') - inset('bottom');\n const scale = Math.max(.05, Math.min((availableWidth - 24) / RULES.width, (availableHeight - 24) / RULES.height));\n layout = { scale, x: inset('left') + (availableWidth - RULES.width * scale) / 2, y: inset('top') + (availableHeight - RULES.height * scale) / 2 };\n}\nfunction control() {\n if (!active()) return { x: 0, y: 0 };\n if (pointer && predicted) {\n const dx = pointer.x - predicted.x, dy = pointer.y - predicted.y, length = Math.hypot(dx, dy);\n return length < 8 ? { x: 0, y: 0 } : { x: dx / Math.max(36, length), y: dy / Math.max(36, length) };\n }\n return { x: Number(keys.has('ArrowRight') || keys.has('KeyD')) - Number(keys.has('ArrowLeft') || keys.has('KeyA')),\n y: Number(keys.has('ArrowDown') || keys.has('KeyS')) - Number(keys.has('ArrowUp') || keys.has('KeyW')) };\n}\nfunction pulse() {\n const now = performance.now();\n if (!active() || now - lastPulse < 650 || action) return;\n action = { type: 'pulse', round, seq: ++actionSeq }; lastPulse = now;\n try { room.send(action); } catch { action = null; }\n}\nbutton.addEventListener('pointerdown', event => { event.preventDefault(); pulse(); });\nbutton.addEventListener('click', pulse);\ncanvas.addEventListener('pointerdown', event => { if (!active()) return; canvas.focus(); canvas.setPointerCapture(event.pointerId); pointer = { x: (event.clientX - layout.x) / layout.scale, y: (event.clientY - layout.y) / layout.scale }; });\ncanvas.addEventListener('pointermove', event => { if (pointer) pointer = { x: (event.clientX - layout.x) / layout.scale, y: (event.clientY - layout.y) / layout.scale }; });\ncanvas.addEventListener('pointerup', () => { pointer = null; }); canvas.addEventListener('pointercancel', clearControls);\naddEventListener('keydown', event => { if (!active() || event.target.closest?.('input,select,textarea')) return; if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','KeyW','KeyA','KeyS','KeyD','Space'].includes(event.code)) { event.preventDefault(); keys.add(event.code); if (event.code === 'Space' && !event.repeat) pulse(); } });\naddEventListener('keyup', event => keys.delete(event.code)); addEventListener('blur', clearControls); document.addEventListener('visibilitychange', clearControls);\naddEventListener('resize', resize); resize();\n// Menu, lobby e risultato li disegna il gioco: la piattaforma da' solo le funzioni.\ncreateMenu({\n c, t, online: true, solo: false, mode: 'arena',\n resultText: () => t('score', { n: state?.entities?.[room?.you]?.score ?? 0, seconds: 0 }),\n onRoom: attach,\n onExit: detach,\n});\nfunction frame(now) {\n const dt = Math.min(.1, Math.max(0, (now - lastFrame) / 1000)); lastFrame = now;\n if (active() && predicted) {\n accumulator += dt;\n while (accumulator + 1e-9 >= RULES.step) {\n accumulator -= RULES.step;\n // Il limite interrompe la previsione dopo un lungo silenzio invece di accumulare secondi di comandi arretrati.\n if (pending.length >= RULES.history) continue;\n const command = { seq: ++seq, ...control() }; pending.push(command); stepEntity(predicted, command);\n }\n const period = 1000 / Math.min(12, room.tickRate || 12);\n if (now - lastSend >= period) {\n lastSend = now;\n room.input({ type: 'move', round, commands: pending.slice(-RULES.history) });\n if (action && now - lastPulse >= 300) { try { room.send(action); lastPulse = now; } catch {} }\n }\n }\n const decay = Math.exp(-16 * dt); offset.x *= decay; offset.y *= decay;\n visual = predicted ? { ...predicted, x: predicted.x + offset.x, y: predicted.y + offset.y } : null;\n ctx.fillStyle = '#000000'; ctx.fillRect(0, 0, width, height); ctx.save(); ctx.translate(layout.x, layout.y); ctx.scale(layout.scale, layout.scale);\n ctx.fillStyle = '#808080'; ctx.fillRect(0, 0, RULES.width, RULES.height);\n ctx.strokeStyle = '#ffffff0b'; ctx.lineWidth = 1;\n for (let x = 0; x < RULES.width; x += 40) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, RULES.height); ctx.stroke(); }\n for (let y = 0; y < RULES.height; y += 40) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(RULES.width, y); ctx.stroke(); }\n const target = beacon(state?.step ?? 0); ctx.fillStyle = '#ffffff1c'; ctx.beginPath(); ctx.arc(target.x, target.y, 90, 0, Math.PI * 2); ctx.fill();\n ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(target.x, target.y, 12 + Math.sin(now / 220) * 2, 0, Math.PI * 2); ctx.fill();\n const delay = room ? Math.max(100, (room.latency ?? 0) / 2 + 2000 / Math.max(1, room.tickRate)) : 100;\n // Il buffer usa il tempo dei campioni, cosi' gli altri corpi non saltano fra due aggiornamenti.\n const at = room ? room.serverTime() - delay : 0;\n const bodies = state ? Object.values(state.entities) : [entity('demo', 0), entity('demo2', 1)];\n rendered = [];\n bodies.forEach((body, index) => {\n const own = room !== null && body.id === room.you;\n const shown = own && visual ? visual : interpolate(samples, at, body.id) ?? body;\n rendered.push({ id: body.id, x: shown.x, y: shown.y });\n const color = ['#ff0000', '#0066ff', '#ffff00', '#00cc00'][index % 4];\n ctx.fillStyle = color; ctx.beginPath(); ctx.arc(shown.x, shown.y, RULES.radius, 0, Math.PI * 2); ctx.fill();\n ctx.strokeStyle = own ? '#ffffff' : color; ctx.lineWidth = own ? 3 : 1; ctx.beginPath(); ctx.arc(shown.x, shown.y, RULES.radius + 6 + (body.flash ? (18 - body.flash) * 3 : 0), 0, Math.PI * 2); ctx.stroke();\n ctx.fillStyle = '#ffffff'; ctx.font = '14px system-ui'; ctx.textAlign = 'center'; ctx.fillText(own ? t('you') : String(index + 1), shown.x, shown.y - 32);\n });\n ctx.restore();\n score.textContent = t('score', { n: state?.entities?.[room?.you]?.score ?? 0, seconds: Math.max(0, Math.ceil(RULES.seconds - (state?.step ?? 0) * RULES.step)) });\n hud.hidden = !active(); button.disabled = !active() || !!action;\n requestAnimationFrame(frame);\n}\n// La sonda esiste solo in sviluppo per misurare previsione, geometria e stato senza creare una seconda connessione.\nif (location.hostname === 'localhost' || location.hostname.endsWith('.localhost')) window.caisualDebug = { c, get room() { return room; }, get playing() { return active(); }, get motion() { return { predicted, visual, pending: pending.length, samples: samples.length, rendered, layout }; } };\nrequestAnimationFrame(frame);\n";
|
|
5253
|
+
var game_js_default = "import { caisual } from '/__caisual/kit/v1.js';\nimport { createMenu } from './menu.js';\nimport { RULES, entity, stepEntity, reconcile, beacon, interpolate } from './physics.js';\nconst c = await caisual.connect(), t = await c.text();\ndocument.documentElement.lang = c.player.language; document.title = t('title');\nconst canvas = document.querySelector('canvas'), ctx = canvas.getContext('2d'), hud = document.querySelector('#hud');\nconst score = document.querySelector('#score'), hint = document.querySelector('#hint'), button = document.querySelector('#pulse');\ncanvas.setAttribute('aria-label', t('controls')); button.textContent = t('pulse'); hint.textContent = t('hint');\nlet room = null, state = null;\nlet stops = [], pending = [], samples = [], predicted = null, seq = 0, actionSeq = 0, action = null, round = null;\nlet accumulator = 0, lastFrame = performance.now(), lastSend = 0, lastPulse = -Infinity, visual = null, offset = { x: 0, y: 0 };\nlet rendered = [];\nlet keys = new Set(), pointer = null, layout = { x: 0, y: 0, scale: 1 }, width = innerWidth, height = innerHeight;\nconst active = () => room !== null && ['waiting', 'playing'].includes(room.status) && room.connection === 'connected';\nfunction clearControls() { keys.clear(); pointer = null; }\nfunction read(next, tick, at = room.serverTime()) {\n if (!next?.entities) return;\n const newRound = next.round !== round;\n state = next;\n if (newRound) { round = next.round; pending = []; samples = []; predicted = visual = null; offset = { x: 0, y: 0 }; seq = actionSeq = 0; action = null; accumulator = 0; clearControls(); }\n const mine = state.entities[room.you];\n if (mine) {\n seq = Math.max(seq, mine.ack); actionSeq = Math.max(actionSeq, mine.actionAck);\n if (action && mine.actionAck >= action.seq) action = null;\n const before = predicted;\n ({ predicted, pending } = reconcile(mine, pending));\n // La correzione cambia subito la simulazione; solo il disegno assorbe lo scarto in pochi fotogrammi.\n if (before && !newRound) { offset.x += before.x - predicted.x; offset.y += before.y - predicted.y; }\n }\n samples.push({ at, entities: structuredClone(next.entities) });\n samples = samples.filter(sample => at - sample.at < 2500).slice(-120);\n}\nfunction attach(next) {\n stops.forEach(stop => stop()); stops = []; room = next;\n state = null; round = null; pending = []; samples = []; predicted = visual = null; action = null; clearControls();\n read(room.state);\n if (room.status === 'waiting') predicted = entity(room.you, 0);\n stops.push(room.onState(read), room.onConnection(() => {\n pending = []; action = null; accumulator = 0; offset = { x: 0, y: 0 }; clearControls();\n const mine = room.state?.entities?.[room.you];\n if (mine) { predicted = { ...mine }; seq = mine.ack; }\n // Un valore neutro sostituisce anche l'ultimo input conservato dal kit durante la riconnessione.\n if (room) room.input({ type: 'move', round, commands: [] });\n }), room.onStatus(() => { clearControls(); if (room.status === 'waiting') predicted = entity(room.you, 0); }));\n}\nfunction detach() {\n stops.forEach(stop => stop()); stops = []; room = null; state = null; round = null;\n pending = []; samples = []; predicted = visual = null; action = null; clearControls();\n}\nfunction resize() {\n width = innerWidth; height = innerHeight;\n const ratio = Math.min(devicePixelRatio || 1, 2); canvas.width = width * ratio; canvas.height = height * ratio; ctx.setTransform(ratio, 0, 0, ratio, 0, 0);\n // Il campo occupa tutta la finestra: il margine serve solo a non finire sotto la tacca.\n const style = getComputedStyle(document.documentElement);\n const inset = side => parseFloat(style.getPropertyValue('--safe-' + side)) || 0;\n const availableWidth = width - inset('left') - inset('right'), availableHeight = height - inset('top') - inset('bottom');\n const scale = Math.max(.05, Math.min((availableWidth - 24) / RULES.width, (availableHeight - 24) / RULES.height));\n layout = { scale, x: inset('left') + (availableWidth - RULES.width * scale) / 2, y: inset('top') + (availableHeight - RULES.height * scale) / 2 };\n}\nfunction control() {\n if (!active()) return { x: 0, y: 0 };\n if (pointer && predicted) {\n const dx = pointer.x - predicted.x, dy = pointer.y - predicted.y, length = Math.hypot(dx, dy);\n return length < 8 ? { x: 0, y: 0 } : { x: dx / Math.max(36, length), y: dy / Math.max(36, length) };\n }\n return { x: Number(keys.has('ArrowRight') || keys.has('KeyD')) - Number(keys.has('ArrowLeft') || keys.has('KeyA')),\n y: Number(keys.has('ArrowDown') || keys.has('KeyS')) - Number(keys.has('ArrowUp') || keys.has('KeyW')) };\n}\nfunction pulse() {\n const now = performance.now();\n if (!active() || now - lastPulse < 650 || action) return;\n if (room.status === 'waiting') { lastPulse = now; return; }\n action = { type: 'pulse', round, seq: ++actionSeq }; lastPulse = now;\n try { room.send(action); } catch { action = null; }\n}\nbutton.addEventListener('pointerdown', event => { event.preventDefault(); pulse(); });\nbutton.addEventListener('click', pulse);\ncanvas.addEventListener('pointerdown', event => { if (!active()) return; canvas.focus(); canvas.setPointerCapture(event.pointerId); pointer = { x: (event.clientX - layout.x) / layout.scale, y: (event.clientY - layout.y) / layout.scale }; });\ncanvas.addEventListener('pointermove', event => { if (pointer) pointer = { x: (event.clientX - layout.x) / layout.scale, y: (event.clientY - layout.y) / layout.scale }; });\ncanvas.addEventListener('pointerup', () => { pointer = null; }); canvas.addEventListener('pointercancel', clearControls);\naddEventListener('keydown', event => { if (!active() || event.target.closest?.('input,select,textarea')) return; if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','KeyW','KeyA','KeyS','KeyD','Space'].includes(event.code)) { event.preventDefault(); keys.add(event.code); if (event.code === 'Space' && !event.repeat) pulse(); } });\naddEventListener('keyup', event => keys.delete(event.code)); addEventListener('blur', clearControls); document.addEventListener('visibilitychange', clearControls);\naddEventListener('resize', resize); resize();\n// Menu, lobby e risultato li disegna il gioco: la piattaforma da' solo le funzioni.\ncreateMenu({\n c, t, online: true, solo: false, mode: 'arena',\n resultText: () => t('score', { n: state?.entities?.[room?.you]?.score ?? 0, seconds: 0 }),\n onRoom: attach,\n onExit: detach,\n});\nfunction frame(now) {\n const dt = Math.min(.1, Math.max(0, (now - lastFrame) / 1000)); lastFrame = now;\n if (active() && predicted) {\n accumulator += dt;\n while (accumulator + 1e-9 >= RULES.step) {\n accumulator -= RULES.step;\n // Il limite interrompe la previsione dopo un lungo silenzio invece di accumulare secondi di comandi arretrati.\n if (pending.length >= RULES.history) continue;\n const command = { seq: ++seq, ...control() };\n if (room.status === 'playing') pending.push(command);\n stepEntity(predicted, command);\n }\n const period = 1000 / Math.min(12, room.tickRate || 12);\n if (room.status === 'playing' && now - lastSend >= period) {\n lastSend = now;\n room.input({ type: 'move', round, commands: pending.slice(-RULES.history) });\n if (action && now - lastPulse >= 300) { try { room.send(action); lastPulse = now; } catch {} }\n }\n }\n const decay = Math.exp(-16 * dt); offset.x *= decay; offset.y *= decay;\n visual = predicted ? { ...predicted, x: predicted.x + offset.x, y: predicted.y + offset.y } : null;\n ctx.fillStyle = '#000000'; ctx.fillRect(0, 0, width, height); ctx.save(); ctx.translate(layout.x, layout.y); ctx.scale(layout.scale, layout.scale);\n ctx.fillStyle = '#808080'; ctx.fillRect(0, 0, RULES.width, RULES.height);\n ctx.strokeStyle = '#ffffff0b'; ctx.lineWidth = 1;\n for (let x = 0; x < RULES.width; x += 40) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, RULES.height); ctx.stroke(); }\n for (let y = 0; y < RULES.height; y += 40) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(RULES.width, y); ctx.stroke(); }\n const target = beacon(state?.step ?? 0); ctx.fillStyle = '#ffffff1c'; ctx.beginPath(); ctx.arc(target.x, target.y, 90, 0, Math.PI * 2); ctx.fill();\n ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(target.x, target.y, 12 + Math.sin(now / 220) * 2, 0, Math.PI * 2); ctx.fill();\n const delay = room ? Math.max(100, (room.latency ?? 0) / 2 + 2000 / Math.max(1, room.tickRate)) : 100;\n // Il buffer usa il tempo dei campioni, cosi' gli altri corpi non saltano fra due aggiornamenti.\n const at = room ? room.serverTime() - delay : 0;\n const bodies = room?.status === 'waiting' && predicted ? [predicted] : state ? Object.values(state.entities) : [entity('demo', 0), entity('demo2', 1)];\n rendered = [];\n bodies.forEach((body, index) => {\n const own = room !== null && body.id === room.you;\n const shown = own && visual ? visual : interpolate(samples, at, body.id) ?? body;\n rendered.push({ id: body.id, x: shown.x, y: shown.y });\n const color = ['#ff0000', '#0066ff', '#ffff00', '#00cc00'][index % 4];\n ctx.fillStyle = color; ctx.beginPath(); ctx.arc(shown.x, shown.y, RULES.radius, 0, Math.PI * 2); ctx.fill();\n ctx.strokeStyle = own ? '#ffffff' : color; ctx.lineWidth = own ? 3 : 1; ctx.beginPath(); ctx.arc(shown.x, shown.y, RULES.radius + 6 + (room?.status === 'waiting' && own && now - lastPulse < 600 ? (now - lastPulse) / 10 : body.flash ? (18 - body.flash) * 3 : 0), 0, Math.PI * 2); ctx.stroke();\n ctx.fillStyle = '#ffffff'; ctx.font = '14px system-ui'; ctx.textAlign = 'center'; ctx.fillText(own ? t('you') : String(index + 1), shown.x, shown.y - 32);\n });\n ctx.restore();\n score.textContent = t('score', { n: state?.entities?.[room?.you]?.score ?? 0, seconds: Math.max(0, Math.ceil(RULES.seconds - (state?.step ?? 0) * RULES.step)) });\n if (room?.status === 'waiting') { const min = room.metadata.configuration.players.min, count = room.players.filter(player => player.connected).length; score.textContent = t('waiting', { n: Math.max(0, min - count), players: count, min }); }\n hud.hidden = !active(); button.disabled = !active() || !!action;\n requestAnimationFrame(frame);\n}\n// La sonda esiste solo in sviluppo per misurare previsione, geometria e stato senza creare una seconda connessione.\nif (location.hostname === 'localhost' || location.hostname.endsWith('.localhost')) window.caisualDebug = { c, get room() { return room; }, get playing() { return active(); }, get motion() { return { predicted, visual, pending: pending.length, samples: samples.length, rendered, layout }; } };\nrequestAnimationFrame(frame);\n";
|
|
5437
5254
|
|
|
5438
5255
|
// src/arcade/physics.js.txt
|
|
5439
5256
|
var physics_js_default = "export const RULES = Object.freeze({ step: 1 / 60, width: 800, height: 600, radius: 18, speed: 220, seconds: 45, history: 60 });\nexport const clamp = (n, min, max) => Math.max(min, Math.min(max, n));\nexport function entity(id, index) {\n return { id, x: 220 + index % 2 * 360, y: 200 + Math.floor(index / 2) * 200, vx: 0, vy: 0, ack: 0, actionAck: 0, score: 0, cooldown: 0, flash: 0, queue: [] };\n}\nexport function stepEntity(body, control = { x: 0, y: 0 }) {\n const length = Math.max(1, Math.hypot(control.x, control.y));\n const blend = 1 - Math.exp(-18 * RULES.step);\n body.vx += (control.x / length * RULES.speed - body.vx) * blend;\n body.vy += (control.y / length * RULES.speed - body.vy) * blend;\n body.x = clamp(body.x + body.vx * RULES.step, RULES.radius, RULES.width - RULES.radius);\n body.y = clamp(body.y + body.vy * RULES.step, RULES.radius, RULES.height - RULES.radius);\n}\nexport function reconcile(authoritative, pending) {\n // Le conferme eliminano anche i comandi ritrasmessi, cosi' un movimento non viene applicato due volte.\n const remaining = pending.filter(command => command.seq > authoritative.ack).slice(-RULES.history);\n const predicted = { ...authoritative, queue: [] };\n for (const command of remaining) stepEntity(predicted, command);\n return { predicted, pending: remaining };\n}\nexport function beacon(step) {\n const places = [[400, 300], [170, 150], [630, 450], [630, 150], [170, 450]];\n const [x, y] = places[Math.floor(step * RULES.step / 5) % places.length];\n return { x, y };\n}\nexport function acceptControls(body, commands) {\n if (!Array.isArray(commands) || commands.length > RULES.history) return;\n const queued = new Map(body.queue.map(command => [command.seq, command]));\n for (const command of commands) {\n if (!command || !Number.isSafeInteger(command.seq) || command.seq <= body.ack || command.seq > body.ack + 600 ||\n !Number.isFinite(command.x) || !Number.isFinite(command.y) || Math.abs(command.x) > 1 || Math.abs(command.y) > 1) continue;\n if (!queued.has(command.seq)) queued.set(command.seq, { seq: command.seq, x: command.x, y: command.y });\n }\n // Il limite contiene memoria e ritardo; il server consuma al massimo un comando per passo, mai tempo inviato dal browser.\n body.queue = [...queued.values()].sort((a, b) => a.seq - b.seq).slice(-RULES.history);\n}\nexport function advance(state, deltaSeconds) {\n // Accumulare il tempo mantiene la stessa velocita' anche quando cambia la frequenza effettiva della stanza.\n state.accumulator += clamp(deltaSeconds, 0, .25);\n while (state.accumulator + 1e-9 >= RULES.step && state.step < RULES.seconds / RULES.step) {\n state.accumulator -= RULES.step; state.step++;\n for (const body of Object.values(state.entities)) {\n const command = body.queue.shift();\n stepEntity(body, command);\n if (command) body.ack = command.seq;\n body.cooldown = Math.max(0, body.cooldown - 1);\n body.flash = Math.max(0, body.flash - 1);\n }\n }\n}\nexport function pulse(state, body, seq) {\n if (!Number.isSafeInteger(seq) || seq <= body.actionAck || seq > body.actionAck + 600) return;\n body.actionAck = seq;\n // Una conferma vale anche per un tentativo rifiutato, per evitare ritrasmissioni infinite durante il recupero.\n if (body.cooldown > 0) return;\n body.cooldown = 36; body.flash = 18;\n const target = beacon(state.step);\n if (Math.hypot(body.x - target.x, body.y - target.y) <= 90) body.score++;\n}\nexport function standings(entities) {\n const order = Object.values(entities).sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));\n return order.map((body, i) => ({ playerId: body.id, score: body.score, rank: order.findIndex(other => other.score === body.score) + 1 }));\n}\nexport function interpolate(samples, at, id) {\n if (!samples.length) return null;\n let before = samples[0], after = samples.at(-1);\n for (const sample of samples) { if (sample.at <= at) before = sample; if (sample.at >= at) { after = sample; break; } }\n const a = before.entities[id], b = after.entities[id];\n if (!a || !b) return b ?? a ?? null;\n const alpha = clamp((at - before.at) / Math.max(1, after.at - before.at), 0, 1);\n return { ...b, x: a.x + (b.x - a.x) * alpha, y: a.y + (b.y - a.y) * alpha };\n}\n";
|
|
5440
5257
|
|
|
5441
5258
|
// src/arcade/server.js.txt
|
|
5442
|
-
var server_js_default = "import { defineGame } from '@caisual/kit/server';\nimport { RULES, entity, acceptControls, advance, pulse, standings } from './client/physics.js';\nfunction reset(room) {\n room.state = { round: (room.state?.round ?? 0) + 1, step: 0, accumulator: 0, entities: {} };\n}\nexport default defineGame({\n tickRate: 30,\n onCreate: reset,\n onRestart: reset,\n onStart(room) {\n room.state.entities = Object.fromEntries(room.players.map((p, i) => [p.id, entity(p.id, i)]));\n },\n onMessage(room, player, message) {\n const body = room.state.entities[player.id];\n if (room.status !== 'playing' || !player.connected || !body || message?.round !== room.state.round) return;\n if (message.type === 'move') acceptControls(body, message.commands);\n if (message.type === 'pulse') pulse(room.state, body, message.seq);\n },\n onConnection(room, player, connected) {\n // Dopo una caduta scartiamo il movimento arretrato: il rientro riparte dallo stato autorevole.\n if (!connected && room.state.entities[player.id]) room.state.entities[player.id].queue = [];\n },\n onLeave(room, player) { if (room.state.entities[player.id]) room.state.entities[player.id].queue = []; },\n onTick(room, deltaSeconds) {\n if (room.status !== 'playing') return;\n advance(room.state, deltaSeconds);\n if (room.state.step >= RULES.seconds / RULES.step) {\n const rows = standings(room.state.entities), winners = rows.filter(row => row.rank === 1).map(row => row.playerId);\n room.end({ standings: rows, winners, draw: winners.length === rows.length && rows.length > 1, unit: 'points' }, { rematch: { keepSetup: true } });\n }\n },\n});\n";
|
|
5259
|
+
var server_js_default = "import { defineGame } from '@caisual/kit/server';\nimport { RULES, entity, acceptControls, advance, pulse, standings } from './client/physics.js';\nfunction reset(room) {\n room.state = { round: (room.state?.round ?? 0) + 1, step: 0, accumulator: 0, entities: {} };\n}\nexport default defineGame({\n tickRate: 30,\n onCreate: reset,\n onRestart: reset,\n onStart(room) {\n room.state.entities = Object.fromEntries(room.players.map((p, i) => [p.id, entity(p.id, i)]));\n },\n onJoin(room, player) {\n if (room.status === 'playing') room.state.entities[player.id] = entity(player.id, room.players.length - 1);\n },\n onMessage(room, player, message) {\n const body = room.state.entities[player.id];\n if (room.status !== 'playing' || !player.connected || !body || message?.round !== room.state.round) return;\n if (message.type === 'move') acceptControls(body, message.commands);\n if (message.type === 'pulse') pulse(room.state, body, message.seq);\n },\n onConnection(room, player, connected) {\n // Dopo una caduta scartiamo il movimento arretrato: il rientro riparte dallo stato autorevole.\n if (!connected && room.state.entities[player.id]) room.state.entities[player.id].queue = [];\n },\n onLeave(room, player) { if (room.state.entities[player.id]) room.state.entities[player.id].queue = []; },\n onTick(room, deltaSeconds) {\n if (room.status !== 'playing') return;\n advance(room.state, deltaSeconds);\n if (room.state.step >= RULES.seconds / RULES.step) {\n const rows = standings(room.state.entities), winners = rows.filter(row => row.rank === 1).map(row => row.playerId);\n room.end({ standings: rows, winners, draw: winners.length === rows.length && rows.length > 1, unit: 'points' }, { rematch: { keepSetup: true } });\n }\n },\n});\n";
|
|
5443
5260
|
|
|
5444
5261
|
// src/arcade/physics.test.mjs.txt
|
|
5445
5262
|
var physics_test_mjs_default = "import test from 'node:test';\nimport assert from 'node:assert/strict';\nimport { RULES, entity, stepEntity, reconcile, advance, acceptControls, pulse, standings, interpolate } from '../client/physics.js';\n\ntest('fixed steps keep movement identical at changing server frequencies', () => {\n const run = hz => {\n const body = entity('a', 0), state = { step: 0, accumulator: 0, entities: { a: body } };\n acceptControls(body, Array.from({ length: 60 }, (_, i) => ({ seq: i + 1, x: 1, y: .5 })));\n for (let i = 0; i < hz; i++) advance(state, 1 / hz);\n return body;\n };\n for (const hz of [60, 30, 20, 10, 5]) assert.deepEqual(run(hz), run(60));\n});\n\ntest('reconciliation replays only commands after the applied acknowledgement', () => {\n const body = entity('a', 0), pending = Array.from({ length: 24 }, (_, i) => ({ seq: i + 1, x: i < 12 ? 1 : -1, y: .4 }));\n const expected = { ...body };\n pending.forEach(command => stepEntity(expected, command));\n pending.slice(0, 9).forEach(command => stepEntity(body, command)); body.ack = 9;\n const result = reconcile(body, pending);\n assert.equal(result.pending.length, 15); assert.equal(result.predicted.x, expected.x); assert.equal(result.predicted.y, expected.y);\n const again = reconcile(body, result.pending); assert.deepEqual(again, result);\n assert.equal(body.ack, 9);\n});\n\ntest('duplicate, missing, malformed and excessive commands cannot speed up the server', () => {\n const body = entity('a', 0), state = { step: 0, accumulator: 0, entities: { a: body } };\n const commands = Array.from({ length: 60 }, (_, i) => ({ seq: i + 1, x: 1, y: 1 }));\n acceptControls(body, commands); acceptControls(body, commands);\n acceptControls(body, [{ seq: 61, x: Infinity, y: 0 }, { seq: 5000, x: 0, y: 1 }, { seq: 3, x: 999, y: 0 }]);\n assert.equal(body.queue.length, 60);\n advance(state, RULES.step); assert.equal(body.ack, 1);\n assert.ok(Math.hypot(body.vx, body.vy) <= RULES.speed);\n for (let i = 0; i < 59; i++) advance(state, RULES.step);\n assert.equal(body.ack, 60);\n acceptControls(body, commands); assert.equal(body.queue.length, 0);\n const previous = body.vx; advance(state, RULES.step); assert.ok(body.vx < previous);\n acceptControls(body, [{ seq: 64, x: -1, y: 0 }]); advance(state, RULES.step); assert.equal(body.ack, 64);\n});\n\ntest('pulse scores only near the beacon, at server speed, and duplicate actions are harmless', () => {\n const body = entity('a', 0), state = { step: 0, accumulator: 0, entities: { a: body } };\n pulse(state, body, 1); assert.equal(body.score, 0);\n body.x = 400; body.y = 300; body.cooldown = 0;\n pulse(state, body, 2); pulse(state, body, 2); pulse(state, body, 3);\n assert.equal(body.score, 1); assert.equal(body.actionAck, 3);\n for (let i = 0; i < 36; i++) advance(state, RULES.step);\n pulse(state, body, 4); assert.equal(body.score, 2);\n assert.deepEqual(standings({ a: body, b: { ...body, id: 'b' } }).map(row => row.rank), [1, 1]);\n});\n\ntest('remote interpolation uses sample times and holds at the buffer edges', () => {\n const samples = [{ at: 1000, entities: { a: { x: 10, y: 20 } } }, { at: 1100, entities: { a: { x: 30, y: 40 } } }];\n assert.equal(interpolate(samples, 1050, 'a').x, 20);\n assert.equal(interpolate(samples, 2000, 'a').x, 30);\n assert.equal(interpolate(samples, 0, 'a').x, 10);\n});\n";
|
|
@@ -5454,7 +5271,7 @@ var menu_js_default = `// Il menu del gioco. La piattaforma non disegna piu' nul
|
|
|
5454
5271
|
// sono pronti la partita parte da sola. Una stanza creata aspetta sempre in lobby. Copia questo file e ricoloralo: lo
|
|
5455
5272
|
// stile e' tuo, i pochi attributi data-action servono anche ai test del browser.
|
|
5456
5273
|
const WORDS = {
|
|
5457
|
-
solo: 'Play solo', online: 'Play
|
|
5274
|
+
solo: 'Play solo', online: 'Play', create: 'Create room', join: 'Join with a code',
|
|
5458
5275
|
code: 'Room code', enter: 'Enter', invite: 'Copy invite', copied: 'Link copied',
|
|
5459
5276
|
players: 'Players', ready: 'Ready', notReady: 'Not ready',
|
|
5460
5277
|
leave: 'Leave', again: 'Play again', back: 'Menu', working: 'One moment', you: 'you',
|
|
@@ -5506,12 +5323,12 @@ export function createMenu(input) {
|
|
|
5506
5323
|
if (view === 'home') {
|
|
5507
5324
|
root.innerHTML = \`<div class="panel">
|
|
5508
5325
|
<h1>\${escape(t('title'))}</h1>
|
|
5509
|
-
\${input.
|
|
5510
|
-
\${input.online ? \`\${button('create', 'create', joining ? ' disabled' : '')}
|
|
5326
|
+
\${input.online ? \`\${button('match', 'online', joining ? ' disabled' : '')}\${button('create', 'create', joining ? ' disabled' : '')}
|
|
5511
5327
|
<form data-form="join" class="row">
|
|
5512
5328
|
<input data-control="code" name="code" maxlength="7" autocomplete="off" aria-label="\${escape(word('code'))}" placeholder="\${escape(word('code'))}">
|
|
5513
|
-
<button type="submit" data-action="join"\${joining ? ' disabled' : ''}>\${escape(word('
|
|
5329
|
+
<button type="submit" data-action="join"\${joining ? ' disabled' : ''}>\${escape(word('join'))}</button>
|
|
5514
5330
|
</form>\` : ''}
|
|
5331
|
+
\${input.solo === false ? '' : button('solo', 'solo')}
|
|
5515
5332
|
<p class="note" role="status">\${escape(note)}</p>
|
|
5516
5333
|
</div>\`;
|
|
5517
5334
|
return;
|
|
@@ -5519,7 +5336,7 @@ export function createMenu(input) {
|
|
|
5519
5336
|
if (view === 'result') {
|
|
5520
5337
|
root.innerHTML = \`<div class="panel">
|
|
5521
5338
|
<h1>\${escape(result)}</h1>
|
|
5522
|
-
<div class="row">\${room ? button('again', 'again') : ''}\${button('back', 'back')}</div>
|
|
5339
|
+
<div class="row">\${room?.origin === 'player' ? button('again', 'again') : ''}\${button('back', 'back')}</div>
|
|
5523
5340
|
<p class="note" role="status">\${escape(note)}</p>
|
|
5524
5341
|
</div>\`;
|
|
5525
5342
|
return;
|
|
@@ -5551,7 +5368,7 @@ export function createMenu(input) {
|
|
|
5551
5368
|
input.onRoom?.(next);
|
|
5552
5369
|
const sync = () => {
|
|
5553
5370
|
// Il menu si fa da parte quando si gioca e torna per il risultato.
|
|
5554
|
-
if (room.status === 'playing' || room.status === 'countdown') view = 'playing';
|
|
5371
|
+
if (room.status === 'waiting' || room.status === 'playing' || room.status === 'countdown') view = 'playing';
|
|
5555
5372
|
else if (room.status === 'finished' || room.status === 'ended') {
|
|
5556
5373
|
// Il testo del risultato lo scrive il gioco, che conosce il suo punteggio.
|
|
5557
5374
|
result = input.resultText?.(room) ?? word('over');
|
|
@@ -5572,6 +5389,11 @@ export function createMenu(input) {
|
|
|
5572
5389
|
if (!action || action === 'join') return;
|
|
5573
5390
|
// Da soli lo stato vive nel browser: non serve aprire una stanza.
|
|
5574
5391
|
if (action === 'solo') { view = 'playing'; render(); input.onSolo?.(); return; }
|
|
5392
|
+
if (action === 'match') {
|
|
5393
|
+
joining = true; note = word('working'); render();
|
|
5394
|
+
c.room.match({ mode: input.mode }).then(attach, fail);
|
|
5395
|
+
return;
|
|
5396
|
+
}
|
|
5575
5397
|
if (action === 'create') {
|
|
5576
5398
|
joining = true; note = word('working'); render();
|
|
5577
5399
|
c.room.create({ mode: input.mode ?? null }).then(attach, fail);
|
|
@@ -5629,10 +5451,9 @@ function arcadeManifest(id, name) {
|
|
|
5629
5451
|
platform: "both",
|
|
5630
5452
|
languages: ["en", "it"],
|
|
5631
5453
|
players: { min: 2, max: 4 },
|
|
5632
|
-
lobby: true,
|
|
5633
5454
|
modes: [{
|
|
5634
5455
|
id: "arena",
|
|
5635
|
-
matchmaking:
|
|
5456
|
+
matchmaking: true
|
|
5636
5457
|
}]
|
|
5637
5458
|
};
|
|
5638
5459
|
}
|
|
@@ -5651,9 +5472,11 @@ var arcadeFiles = {
|
|
|
5651
5472
|
pulse: "Pulse",
|
|
5652
5473
|
hint: "Arrows or drag to move; Space or Pulse to score near the beacon.",
|
|
5653
5474
|
you: "You",
|
|
5475
|
+
waiting: "Waiting for {n} more player(s) ({players}/{min}). Try moving and pulsing.",
|
|
5476
|
+
"menu.online": "Play",
|
|
5654
5477
|
score: "{n} points \xB7 {seconds}s",
|
|
5655
5478
|
"menu.create": "Create room",
|
|
5656
|
-
"menu.join": "Join with code",
|
|
5479
|
+
"menu.join": "Join with a code",
|
|
5657
5480
|
"menu.code": "Room code",
|
|
5658
5481
|
"menu.enter": "Enter",
|
|
5659
5482
|
"menu.invite": "Copy invite",
|
|
@@ -5675,6 +5498,8 @@ var arcadeFiles = {
|
|
|
5675
5498
|
pulse: "Impulso",
|
|
5676
5499
|
hint: "Frecce o trascina per muoverti; Spazio o Impulso per segnare vicino al faro.",
|
|
5677
5500
|
you: "Tu",
|
|
5501
|
+
waiting: "Mancano {n} giocatori ({players}/{min}). Prova a muoverti e usare gli impulsi.",
|
|
5502
|
+
"menu.online": "Gioca",
|
|
5678
5503
|
score: "{n} punti \xB7 {seconds}s",
|
|
5679
5504
|
"menu.create": "Crea stanza",
|
|
5680
5505
|
"menu.join": "Entra con codice",
|
|
@@ -6023,7 +5848,7 @@ var ApiError = class extends Error {
|
|
|
6023
5848
|
hints;
|
|
6024
5849
|
};
|
|
6025
5850
|
function help() {
|
|
6026
|
-
return `Caisual ${"0.
|
|
5851
|
+
return `Caisual ${"0.23.1"}
|
|
6027
5852
|
|
|
6028
5853
|
Usage:
|
|
6029
5854
|
caisual init [--multiplayer | --arcade] [folder]
|
|
@@ -6791,7 +6616,7 @@ async function gameVersions(target, to) {
|
|
|
6791
6616
|
}
|
|
6792
6617
|
var SITO = "https://caisual.com";
|
|
6793
6618
|
function guidesSection() {
|
|
6794
|
-
const guides = JSON.parse(`[{"slug":"overview","title":"What Caisual is","description":"A free home for small browser games, with multiplayer built in."},{"slug":"quick-start","title":"Quick start","description":"From an empty folder to a permanent link."},{"slug":"manifest","title":"The manifest","description":"caisual.json is everything the portal knows about your game."},{"slug":"rooms","title":"Rooms","description":"Server-owned state for up to 24 players."},{"slug":"voice","title":"Voice","description":"Room voice in one manifest field, with gains owned by your server."},{"slug":"matchmaking","title":"Matchmaking","description":"
|
|
6619
|
+
const guides = JSON.parse(`[{"slug":"overview","title":"What Caisual is","description":"A free home for small browser games, with multiplayer built in."},{"slug":"quick-start","title":"Quick start","description":"From an empty folder to a permanent link."},{"slug":"manifest","title":"The manifest","description":"caisual.json is everything the portal knows about your game."},{"slug":"rooms","title":"Rooms","description":"Server-owned state for up to 24 players."},{"slug":"voice","title":"Voice","description":"Room voice in one manifest field, with gains owned by your server."},{"slug":"matchmaking","title":"Matchmaking","description":"Enter a room immediately. Play while other players arrive."},{"slug":"friends-and-parties","title":"Friends","description":"The player's friends, their party and their invitations, inside your game."},{"slug":"player-identity","title":"Identity","description":"Every player has a stable id before your game draws a frame."},{"slug":"saves","title":"Saves","description":"Cloud saves and one daily seed, single player included."},{"slug":"local-development","title":"Local dev","description":"The production handshake, multiple guests and network simulation."},{"slug":"device-requirements","title":"Devices","description":"Declare what you need, then read what the machine has."},{"slug":"versions","title":"Game versions","description":"Updates, rooms, rollback and compatible player data."},{"slug":"limits-and-rules","title":"Limits","description":"Every ceiling, every rule, in one page."},{"slug":"faq","title":"FAQ","description":"Short answers, in one place."}]`);
|
|
6795
6620
|
return [
|
|
6796
6621
|
"# Guides",
|
|
6797
6622
|
"",
|
|
@@ -6839,7 +6664,7 @@ async function run(argumentsList) {
|
|
|
6839
6664
|
return;
|
|
6840
6665
|
}
|
|
6841
6666
|
if (command === "--version" || command === "-V") {
|
|
6842
|
-
process.stdout.write(`${"0.
|
|
6667
|
+
process.stdout.write(`${"0.23.1"}
|
|
6843
6668
|
`);
|
|
6844
6669
|
return;
|
|
6845
6670
|
}
|