@tesana/sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1441 @@
1
+ /**
2
+ * Tesana play-time SDK. Vanilla JS, no npm in games.
3
+ * Host injects window.__TESANA__ or postMessage { type: "tesana-bootstrap", ... }.
4
+ * v1 is player identity, per-game saves, optional open-lobby multiplayer,
5
+ * cloud saves, and the in-game coin economy.
6
+ *
7
+ * THIS FILE IS THE ONE COPY. It is the source of truth for `window.Tesana`:
8
+ * the engine injects the built artifact into every game and the CDN serves it
9
+ * at /sdk/<major>/tesana-play.js, so a fix here reaches the fleet. The engine
10
+ * repo carries no implementation — only the skills that tell a generator how
11
+ * to call this. Do not re-implement it there.
12
+ *
13
+ * One service owns identity, saves, rooms, coins, prices and entitlements —
14
+ * the play service — and it is reached over `saveEndpoint` + `saveToken`
15
+ * (Bearer). `shop.*` and `economy.*` therefore read the same DynamoDB table
16
+ * that holds the wallet; nothing here asks the site what a price is.
17
+ *
18
+ * Real money is the deliberate exception: `economy.topUp` hands the checkout
19
+ * to the host page, so the confirmation UI — and the price it shows — belong
20
+ * to the site rather than to a game the player can rewrite.
21
+ */
22
+
23
+ /**
24
+ * @typedef {object} TesanaConfig
25
+ * @property {string} [endpoint]
26
+ * @property {string} [gameId]
27
+ * @property {string} [publicId]
28
+ * @property {string} [playerToken]
29
+ * @property {string} [rail]
30
+ * @property {string} [deviceId]
31
+ */
32
+
33
+ // The compatibility major a build pins to (`/sdk/v1/...`) is derived from this
34
+ // number, and the engine reads it to stamp each game, so this is the SDK's
35
+ // public version — bump the minor for fixes, the major for breaking changes.
36
+ //
37
+ // 1.0.0 ships six services together: saves, shop, leaderboards, achievements,
38
+ // a vanity address (`tesana.vanity`) and in-game ads (`tesana.ads`). These are
39
+ // the first release, so there is no earlier number to stay compatible with.
40
+ const VERSION = "1.0.0";
41
+
42
+ const LOCAL_CACHE = "tesana.play.v1";
43
+
44
+ /**
45
+ * Reconnect policy for a dropped multiplayer socket.
46
+ *
47
+ * A scale-in, a deploy or a flaky network can close the socket of a player
48
+ * who did nothing wrong, and without a retry they sat alone in a match that
49
+ * looked fine. Backoff is capped and jittered because those events drop
50
+ * everyone at once: un-jittered retries would arrive as a synchronized spike
51
+ * exactly when the fleet is least able to take one.
52
+ */
53
+ const MP_RECONNECT_BASE_MS = 1000;
54
+ const MP_RECONNECT_MAX_MS = 30000;
55
+ const MP_RECONNECT_MAX_ATTEMPTS = 8;
56
+
57
+ /**
58
+ * A polite, dns-safe label: lowercase letters, digits and internal hyphens,
59
+ * at least three characters and at most sixty-three. The same rule the server
60
+ * enforces, repeated here so a game can pre-validate and give the player a
61
+ * sentence instead of an error code.
62
+ */
63
+ const VANITY_RE = /^[a-z0-9](?:[a-z0-9-]{1,61}[a-z0-9])$/;
64
+ const VANITY_MIN = 3;
65
+ const VANITY_MAX = 63;
66
+
67
+ /**
68
+ * Labels that can never belong to a game, because they already mean
69
+ * something on our own domains (`api.tesana.ai`, `play.tesana.ai`) or are
70
+ * claimed by mail and operations. The server is the authority; this list
71
+ * exists so the common case is refused before a round-trip.
72
+ */
73
+ const VANITY_RESERVED = new Set([
74
+ "www", "api", "app", "play", "admin", "cdn", "mail", "docs", "status",
75
+ "support", "help", "blog", "staging", "static", "assets", "files", "media",
76
+ "sdk", "ws", "auth", "account", "billing", "signup", "login", "dashboard",
77
+ "m", "dev", "test", "prod", "preview", "store", "explore", "studio",
78
+ "wallet", "checkout", "gateway", "edge", "origin", "email", "smtp",
79
+ "postmaster", "abuse", "security", "system", "internal", "metrics", "logs",
80
+ ]);
81
+
82
+ /** Fold a display name or typed phrase into a candidate label. */
83
+ function slugify(value) {
84
+ return String(value || "")
85
+ .toLowerCase()
86
+ .trim()
87
+ .replace(/[^a-z0-9]+/g, "-")
88
+ .replace(/^-+|-+$/g, "")
89
+ .slice(0, 63);
90
+ }
91
+
92
+ /** Why a label cannot be used, or `""` when it can. */
93
+ function vanityProblem(label) {
94
+ if (!label) return "empty";
95
+ if (label.length < VANITY_MIN || label.length > VANITY_MAX) return "invalid";
96
+ if (!VANITY_RE.test(label)) return "invalid";
97
+ if (VANITY_RESERVED.has(label)) return "reserved";
98
+ return "";
99
+ }
100
+
101
+ function readCache() {
102
+ try {
103
+ return JSON.parse(localStorage.getItem(LOCAL_CACHE) || "{}");
104
+ } catch {
105
+ return {};
106
+ }
107
+ }
108
+
109
+ function writeCache(patch) {
110
+ try {
111
+ localStorage.setItem(LOCAL_CACHE, JSON.stringify({ ...readCache(), ...patch }));
112
+ } catch {
113
+ /* ignore */
114
+ }
115
+ }
116
+
117
+ // The host may answer more than once. A bootstrap that says `offline` is
118
+ // provisional — it means "nobody has answered yet", and the site posts one the
119
+ // moment the frame loads, before it has finished minting the play session.
120
+ // Treating the first answer as final locks a signed-in player into a game with
121
+ // no wallet, and the real bootstrap a moment later is dropped, so the only
122
+ // symptom is a balance frozen at zero. Keep listening and upgrade.
123
+ let bootstrap = null;
124
+ const bootstrapWaiters = [];
125
+ // Clients already built from a provisional answer, so a later real bootstrap
126
+ // can re-point them in place. A game holds a reference to its client, so it
127
+ // must not be rebuilt — only upgraded.
128
+ const upgradeHandlers = [];
129
+
130
+ /**
131
+ * Diagnostics that should say their piece once per session, not once per call.
132
+ *
133
+ * `_shopRead()` runs every time a game draws its shop, and a console repeating
134
+ * the same line a hundred times is a console nobody reads. Cleared by `init()`,
135
+ * so "once" means once per game session rather than once per page load.
136
+ */
137
+ const warnedKeys = new Set();
138
+ function warnOnce(key, message) {
139
+ if (warnedKeys.has(key)) return;
140
+ warnedKeys.add(key);
141
+ try {
142
+ console.warn(message);
143
+ } catch {
144
+ /* no console */
145
+ }
146
+ }
147
+
148
+ /** Fold a host's answer into the live config, upgrading a provisional one. */
149
+ function applyBootstrap(data) {
150
+ const next = data && typeof data === "object" ? data : {};
151
+ if (bootstrap) {
152
+ if (!bootstrap.offline) return; // a real host has already answered
153
+ if (next.offline) return; // still nobody; nothing has changed
154
+ }
155
+ bootstrap = next;
156
+ window.__TESANA__ = { ...(window.__TESANA__ || {}), ...next };
157
+ const waiting = bootstrapWaiters.splice(0);
158
+ for (const resolve of waiting) resolve(bootstrap);
159
+ for (const upgrade of [...upgradeHandlers]) {
160
+ try {
161
+ upgrade();
162
+ } catch {
163
+ /* an upgrade must never break the boot that is already running */
164
+ }
165
+ }
166
+ // The host's ad policy travels with the bootstrap, and the real answer can
167
+ // arrive after the first message. Re-point the ad capability in place so a
168
+ // game that drew its title screen from a provisional "offline" answer still
169
+ // gets ads once the host has spoken.
170
+ try {
171
+ resyncAds(next);
172
+ } catch {
173
+ /* ads must never break boot */
174
+ }
175
+ }
176
+
177
+ /** Called when a provisional bootstrap is replaced by a real one. */
178
+ function onBootstrapUpgrade(fn) {
179
+ upgradeHandlers.push(fn);
180
+ }
181
+
182
+ /**
183
+ * The live ads capability, kept module-scoped so a later bootstrap can
184
+ * re-point it.
185
+ *
186
+ * The client object is held by the game, so an upgrade must not replace it —
187
+ * only reconfigure it. `resyncAds` is what turns a provisional "offline"
188
+ * answer into a real ad policy when the host's second message arrives.
189
+ */
190
+ let adClient = null;
191
+
192
+ /** A policy message from the host, or `null` when none is in force. */
193
+ function adPolicyFrom(data) {
194
+ if (!data || typeof data !== "object") return null;
195
+ const payload = data.payload && typeof data.payload === "object" ? data.payload : data;
196
+ const enabled = Boolean(payload.enabled);
197
+ const formats = Array.isArray(payload.formats)
198
+ ? payload.formats.filter((f) => typeof f === "string")
199
+ : [];
200
+ const minIntervalMs = Math.max(
201
+ 0,
202
+ Number(payload.minIntervalMs ?? payload.min_interval_ms ?? 180000) || 0,
203
+ );
204
+ return { enabled, formats, minIntervalMs };
205
+ }
206
+
207
+ /**
208
+ * Fold a host answer into the live ad client.
209
+ *
210
+ * Called once at construction (from the client) and again from
211
+ * `applyBootstrap` whenever a newer answer lands, so the game's reference is
212
+ * never rebuilt.
213
+ */
214
+ function resyncAds(boot) {
215
+ if (!adClient) return;
216
+ const fromBootstrap =
217
+ boot && typeof boot === "object" && "adsEnabled" in boot
218
+ ? { enabled: Boolean(boot.adsEnabled), formats: boot.adFormats, minIntervalMs: boot.adMinIntervalMs }
219
+ : null;
220
+ // A host that named a policy on the last bootstrap outranks the earlier
221
+ // one; absent means keep whatever we have.
222
+ if (fromBootstrap) adClient.applyPolicy(fromBootstrap);
223
+ }
224
+
225
+ /** Ask the host for ads. `format` is our own vocabulary; the site maps it. */
226
+ function requestAdFromHost(requestId, format, context) {
227
+ post({ type: "tesana-ad-request", requestId, format, context: context || null });
228
+ }
229
+
230
+ /**
231
+ * Whether a host is above us that could run a checkout.
232
+ *
233
+ * A published game can be opened straight from the play CDN with no Tesana
234
+ * page over it. There is no one to show a price or take a tap there, so the
235
+ * SDK must not pretend otherwise — it says so instead of waiting out a
236
+ * timeout for an answer that was never coming.
237
+ */
238
+ function hasHost() {
239
+ return (
240
+ typeof window !== "undefined" &&
241
+ window.parent &&
242
+ window.parent !== window
243
+ );
244
+ }
245
+
246
+ /**
247
+ * Ask the host to run a purchase the game is not allowed to make itself.
248
+ *
249
+ * A game runs in a sandboxed cross-origin frame: the wrong place for a price
250
+ * confirmation, and one Stripe will not load into. So the game states *what*
251
+ * it wants to buy — never a price, which is the server's to decide — and the
252
+ * host draws the confirmation, runs the purchase and answers.
253
+ *
254
+ * Resolves the same shape the direct call would have returned, so a game
255
+ * cannot tell the difference. Rejects with `cancelled` when the player says
256
+ * no, and `checkout_unavailable` when there is no host to ask.
257
+ *
258
+ * @param {"item" | "topup"} kind
259
+ * @param {{ sku?: string, packId?: string }} what
260
+ */
261
+ function requestCheckoutFromHost(kind, what) {
262
+ if (!hasHost()) {
263
+ const err = new Error(
264
+ "This game is running outside a Tesana page, so there is no checkout to open.",
265
+ );
266
+ err.code = "checkout_unavailable";
267
+ return Promise.reject(err);
268
+ }
269
+ const requestId = `co_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
270
+ post({
271
+ type: "tesana-checkout-request",
272
+ requestId,
273
+ kind,
274
+ sku: what?.sku || "",
275
+ packId: what?.packId || "",
276
+ gameId: gameConfig?.gameId || bootstrap?.gameId || "",
277
+ });
278
+ return new Promise((resolve, reject) => {
279
+ let expiry = null;
280
+ const finish = (fn, value) => {
281
+ window.removeEventListener("message", onMessage);
282
+ if (expiry) clearTimeout(expiry);
283
+ fn(value);
284
+ };
285
+ function onMessage(event) {
286
+ const msg = event.data;
287
+ if (!msg || typeof msg !== "object") return;
288
+ if (msg.type !== "tesana-checkout-result") return;
289
+ if (msg.requestId !== requestId) return;
290
+ if (msg.ok === true) return finish(resolve, msg.result || { ok: true });
291
+ const err = new Error(msg.message || "The purchase was not completed.");
292
+ err.code = msg.code || "cancelled";
293
+ finish(reject, err);
294
+ }
295
+ window.addEventListener("message", onMessage);
296
+ expiry = setTimeout(() => {
297
+ const err = new Error("The checkout did not answer in time.");
298
+ err.code = "timeout";
299
+ finish(reject, err);
300
+ }, CHECKOUT_TIMEOUT_MS);
301
+ });
302
+ }
303
+
304
+ /** Tell the host how an ad went, so it can meter and report it. */
305
+ function reportAdToHost(result) {
306
+ post({ type: "tesana-ad-result", ...result });
307
+ }
308
+
309
+ /**
310
+ * Start this init() with no host answer inherited from a previous one.
311
+ *
312
+ * `bootstrap` and `upgradeHandlers` are module-scoped, and a page can call
313
+ * init() more than once. Without this the second call reads the first one's
314
+ * answer as if the host had already spoken — so a guest inherits a signed-in
315
+ * player's save token, and an "offline" game inherits a wallet. The symptom is
316
+ * the worst kind: the game looks configured, and the player's saves and coins
317
+ * belong to whoever initialized before them.
318
+ *
319
+ * The message listener itself is registered once per page and is left alone.
320
+ */
321
+ function resetHostChannel() {
322
+ bootstrap = null;
323
+ bootstrapWaiters.length = 0;
324
+ upgradeHandlers.length = 0;
325
+ }
326
+
327
+ // The window the message listener is attached to. A real page has exactly one,
328
+ // so this is "attach once". A test harness swaps `window` between cases, and
329
+ // keying on identity is what stops the listener being stranded on a discarded
330
+ // page — where posts from the new host would never be seen.
331
+ let listeningWindow = null;
332
+
333
+ /** Ask, once per page, for the bootstrap and the wallet changes that follow it. */
334
+ function startListening() {
335
+ if (typeof window === "undefined" || listeningWindow === window) return;
336
+ listeningWindow = window;
337
+ window.addEventListener("message", (event) => {
338
+ const data = event.data;
339
+ if (!data || typeof data !== "object") return;
340
+ // The site posts `{ type, payload }`; a baked or spliced bootstrap is the
341
+ // payload itself. Accept both, so one SDK reads either host.
342
+ if (data.type === "tesana-bootstrap") {
343
+ applyBootstrap(data.payload && typeof data.payload === "object" ? data.payload : data);
344
+ return;
345
+ }
346
+ if (data.type === "tesana-wallet-changed" && bootstrap) {
347
+ // The player topped up in the parent window. Nothing to do but let the
348
+ // next balance read be the truth.
349
+ bootstrap.balance = typeof data.balance === "number" ? data.balance : bootstrap.balance;
350
+ return;
351
+ }
352
+ // Ads. The host owns the policy and the actual ad; we only ask and report.
353
+ if (data.type === "tesana-ad-policy") {
354
+ try {
355
+ adClient?.applyPolicy(adPolicyFrom(data));
356
+ } catch {
357
+ /* a bad policy must never break the listener */
358
+ }
359
+ return;
360
+ }
361
+ if (data.type === "tesana-ad-show") {
362
+ try {
363
+ const requestId = data.requestId || data.request_id;
364
+ adClient?.settle(requestId, data);
365
+ } catch {
366
+ /* ignore */
367
+ }
368
+ }
369
+ });
370
+ }
371
+
372
+ function waitBootstrap(timeoutMs = 1500) {
373
+ if (typeof window === "undefined") return Promise.resolve({});
374
+ const baked = window.__TESANA__;
375
+ const named = Boolean(baked && (baked.gameId || baked.publicId));
376
+ // A published game ships a baked bootstrap that names the game but carries
377
+ // no token: the host posts the token, and the rooms grant along with it,
378
+ // once the frame loads. Resolving on the baked copy alone dropped that
379
+ // message, so the game minted its own guest identity and never saw the
380
+ // grant. Wait for the host unless the page already holds a token, or there
381
+ // is no host above us to post one.
382
+ const hosted = typeof window.parent === "object" && window.parent !== null && window.parent !== window;
383
+ if (baked && (baked.playerToken || (named && !hosted))) {
384
+ // Record it as the live bootstrap, not just the return value: a game may
385
+ // call init() a second time, and `mergedConfig()` reads this.
386
+ if (!bootstrap || bootstrap.offline) bootstrap = baked;
387
+ return Promise.resolve(baked);
388
+ }
389
+ if (bootstrap) return Promise.resolve(bootstrap);
390
+
391
+ startListening();
392
+ // Ask, rather than wait to be told. The frame can finish loading before the
393
+ // site has minted the session, and a request is what makes the site post the
394
+ // second time — without it a signed-in player's real bootstrap never arrives.
395
+ post({ type: "tesana-bootstrap-request" });
396
+ return new Promise((resolve) => {
397
+ bootstrapWaiters.push(resolve);
398
+ setTimeout(() => applyBootstrap({ offline: true }), timeoutMs);
399
+ });
400
+ }
401
+
402
+ function deviceId() {
403
+ const cache = readCache();
404
+ if (cache.deviceId) return cache.deviceId;
405
+ const id = `dev_${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`;
406
+ writeCache({ deviceId: id });
407
+ return id;
408
+ }
409
+
410
+ /** How long the host has to answer a sign-in before we give up. */
411
+ const SIGN_IN_TIMEOUT_MS = 120000;
412
+
413
+ /**
414
+ * How long a game waits for the host to run a checkout before giving up.
415
+ *
416
+ * Deliberately long: the player is reading a price and tapping a button, and
417
+ * a purchase that times out mid-decision is worse than one that waits. The
418
+ * game is told plainly when it does expire, so its shop never spins forever.
419
+ */
420
+ const CHECKOUT_TIMEOUT_MS = 120000;
421
+
422
+ /** Ask the host above us for something only it can do. */
423
+ function post(message) {
424
+ try {
425
+ (window.parent || window).postMessage(message, "*");
426
+ } catch {
427
+ /* not serialisable */
428
+ }
429
+ }
430
+
431
+ /**
432
+ * @param {TesanaConfig} [config]
433
+ */
434
+ export async function init(config = {}) {
435
+ gameConfig = config || {};
436
+ resetHostChannel();
437
+ // "Once per session" starts here. Diagnostics dedupe on a module-level set,
438
+ // which would otherwise be per-page-load — long after a game has re-inited.
439
+ warnedKeys.clear();
440
+ // Attach the host channel unconditionally. It used to be attached only when
441
+ // the game was *waiting* for a bootstrap, which meant a page that shipped a
442
+ // token never listened — so a wallet change, an ad policy, or an ad result
443
+ // posted later was dropped on the floor. Those all arrive after boot.
444
+ startListening();
445
+ const boot = await waitBootstrap();
446
+ const client = new TesanaClient(buildConfig(mergedConfig()));
447
+ await client.ensureSession();
448
+ window.__TESANA_PLAYER__ = client.player.me();
449
+ // A provisional "offline" answer can upgrade later. Re-point this client in
450
+ // place — the game holds a reference to it — and re-run the identity step,
451
+ // or the player's real token arrives and is ignored, leaving a signed-in
452
+ // player with a balance frozen at zero.
453
+ const finishUpgrade = () => {
454
+ client.configure(buildConfig(mergedConfig()));
455
+ client.ensureSession();
456
+ window.__TESANA_PLAYER__ = client.player.me();
457
+ };
458
+ onBootstrapUpgrade(finishUpgrade);
459
+ return client;
460
+ }
461
+
462
+ /** Everything the game passed to init(), kept so an upgrade can reuse it. */
463
+ let gameConfig = null;
464
+
465
+ /** Fold the latest host answer and the game's own `init(config)` into one. */
466
+ function mergedConfig() {
467
+ return { ...(bootstrap || {}), ...(gameConfig || {}) };
468
+ }
469
+
470
+ /** Turn a raw bootstrap + game config into the client's constructor options. */
471
+ function buildConfig(merged) {
472
+ const endpoint = String(merged.endpoint || merged.apiBase || defaultEndpoint()).replace(/\/$/, "");
473
+ // Keep the type the host sent: a Tesana page posts a number, a room build
474
+ // bakes a string, and `tesana.gameId` is public surface a game may compare
475
+ // against. Only trim, never coerce.
476
+ const rawGameId = merged.gameId ?? merged.publicId ?? "";
477
+ const gameId = typeof rawGameId === "string" ? rawGameId.trim() : rawGameId;
478
+ return {
479
+ endpoint,
480
+ gameId,
481
+ // The site hands this as `token`; the room build/server splice it as
482
+ // `playerToken`. Either way it is the play service's player token, and it
483
+ // authorises saves and the economy alike.
484
+ token: merged.token || merged.playToken || merged.playerToken || "",
485
+ // The host named a save service (even if it could not mint a token for
486
+ // this player). That is a different thing from a room build, which bakes
487
+ // one `endpoint` that *is* the save service — see `saveServiceNamed`.
488
+ saveEndpoint: merged.saveEndpoint || merged.save_endpoint || "",
489
+ saveToken: merged.saveToken || merged.save_token || "",
490
+ saveServiceNamed: "saveEndpoint" in merged || "save_endpoint" in merged,
491
+ rail: merged.rail || "tesana",
492
+ deviceId: merged.deviceId || deviceId(),
493
+ // The host decides whether this game may place rooms. Absent means an
494
+ // older host that predates the capability, so keep multiplayer working.
495
+ multiplayer: merged.multiplayer !== false,
496
+ // Not fatal. A page opened outside Tesana has no host and no game id, and
497
+ // it still has to play — the shop stays dark and saves stay local, which
498
+ // is the same shape as `offline`, only without a host to have said so.
499
+ offline: Boolean(merged.offline) || !endpoint || !gameId,
500
+ player: merged.player || null,
501
+ // The host tells the frame which public address this game answers on, so
502
+ // a game can show and copy its own link without asking the player to
503
+ // remember it. Absent means no vanity address is configured (or an older
504
+ // host that predates the capability) — `vanity.public()` then reports
505
+ // null and the game simply does not offer one.
506
+ vanityAddress: merged.vanityAddress || merged.vanity || "",
507
+ // Whether the host is willing to place ads in this game. Absent means an
508
+ // older host that predates the capability; treated as "get permission
509
+ // from the host before showing anything" rather than as a blanket yes.
510
+ adsEnabled: merged.adsEnabled,
511
+ adFormats: merged.adFormats || merged.ad_formats || [],
512
+ adMinIntervalMs: merged.adMinIntervalMs ?? merged.ad_min_interval_ms,
513
+ };
514
+ }
515
+
516
+ function defaultEndpoint() {
517
+ if (typeof window === "undefined") return "http://localhost:8788";
518
+ if (window.__TESANA__?.endpoint) return window.__TESANA__.endpoint;
519
+ return window.location.origin;
520
+ }
521
+
522
+ class TesanaClient {
523
+ /**
524
+ * @param {{ endpoint: string, gameId: string, token: string, rail: string, deviceId: string, multiplayer?: boolean, saveEndpoint?: string, saveToken?: string, offline?: boolean, player?: object|null, vanityAddress?: string, adsEnabled?: boolean }} opts
525
+ */
526
+ constructor(opts) {
527
+ this.endpoint = opts.endpoint;
528
+ this.gameId = opts.gameId;
529
+ this.token = opts.token;
530
+ this.rail = opts.rail;
531
+ this.deviceId = opts.deviceId;
532
+ // Saves and the shop fail independently: a game can have a wallet and no
533
+ // cloud save, or a save and no shop. Neither failing stops the game.
534
+ //
535
+ // Two host shapes reach here. A Tesana page hands the save service its own
536
+ // `saveEndpoint`/`saveToken`; a room build bakes one `endpoint` that *is*
537
+ // the save service and a single token. The second shape keeps working by
538
+ // falling back to `endpoint` when no separate save service was named — see
539
+ // `_saveBase`/`_saveToken`.
540
+ this.saveEndpoint = String(opts.saveEndpoint || "").replace(/\/$/, "");
541
+ this.saveToken = opts.saveToken || "";
542
+ // Whether the host told us it has a save service, separately from whether
543
+ // it could mint a token for this player. A page that sends an empty
544
+ // `saveEndpoint` is saying "no cloud saves for you" — not "your endpoint is
545
+ // me" — and the difference decides whether saves go quiet or 401 forever.
546
+ //
547
+ // Two host shapes reach here. A Tesana page names its save service; a room
548
+ // build bakes one `endpoint` that *is* the save service and a single token,
549
+ // and names nothing, so the endpoint stands in for it.
550
+ this.saveServiceNamed = Boolean(opts.saveServiceNamed);
551
+ if (!this.saveServiceNamed && this.endpoint && !this.saveEndpoint) {
552
+ this.saveEndpoint = this.endpoint;
553
+ this.saveToken = this.token;
554
+ }
555
+ // Cloud saves need an address and a credential. A guest has neither, and a
556
+ // doomed request on every save is noise, not a save.
557
+ this.canSaveToCloud = Boolean(this.saveEndpoint && this.saveToken);
558
+ this.offline = Boolean(opts.offline);
559
+ this._player = opts.player || null;
560
+ const reconnect = opts.multiplayerReconnect || {};
561
+ this._mp = {
562
+ ws: null,
563
+ handlers: [],
564
+ roomId: null,
565
+ // Reconnect state. `wsUrl` is kept so a dropped socket can re-enter the
566
+ // same room; `leaving` stops a deliberate leave from being retried.
567
+ wsUrl: null,
568
+ leaving: false,
569
+ reconnectTimer: null,
570
+ reconnectAttempts: 0,
571
+ reconnectExhausted: false,
572
+ statusHandlers: [],
573
+ // Overridable so a test (or a game with unusual tolerance) can shorten
574
+ // the wait without patching the global timers.
575
+ reconnectBaseMs: Number(reconnect.baseMs) || MP_RECONNECT_BASE_MS,
576
+ reconnectMaxMs: Number(reconnect.maxMs) || MP_RECONNECT_MAX_MS,
577
+ reconnectMaxAttempts: Number(reconnect.maxAttempts) || MP_RECONNECT_MAX_ATTEMPTS,
578
+ };
579
+ this._mpEnabled = opts.multiplayer !== false;
580
+ // The address the host said this game answers on. Kept on the instance so
581
+ // `vanity.public()` is synchronous; `null` rather than `""` because "no
582
+ // address configured" and "an empty address" should not both read as a
583
+ // usable link.
584
+ this._vanityAddress = opts.vanityAddress ? String(opts.vanityAddress) : null;
585
+
586
+ this.player = {
587
+ // No player yet means a guest, not a crash: a page with no host, and a
588
+ // game reading `player.me()` before its first save load, both land here.
589
+ me: () => this._player || { isGuest: true, displayName: "Player" },
590
+ isGuest: () => !this._player || Boolean(this._player.isGuest),
591
+ login: (identity) => this.login(identity),
592
+ update: (patch) => this.request("PATCH", "/v1/player/me", patch),
593
+ };
594
+ this.db = {
595
+ get: (key) => this._dbGet(key),
596
+ set: (key, value) => this._dbSet(key, value),
597
+ list: () => this.request("GET", "/v1/db").then((r) => r.items),
598
+ collection: (name) => ({
599
+ get: (id) => this.request("GET", `/v1/collections/${enc(name)}/${enc(id)}`),
600
+ put: (id, data, scope) =>
601
+ this.request("PUT", `/v1/collections/${enc(name)}/${enc(id)}`, { data, scope }),
602
+ list: () => this.request("GET", `/v1/collections/${enc(name)}`).then((r) => r.items),
603
+ delete: (id) => this.request("DELETE", `/v1/collections/${enc(name)}/${enc(id)}`),
604
+ }),
605
+ };
606
+
607
+ // The economy belongs to the play service, not the site: coins, prices and
608
+ // entitlements are its table's, and every call below rides the save
609
+ // transport (`this.request`), which it authenticates with the player token.
610
+ //
611
+ // Reads degrade rather than throw. A game draws its shop on the first
612
+ // frame, and a hostless page — or a service that is briefly down — has to
613
+ // render an empty shelf instead of throwing in the middle of a menu. Writes
614
+ // (`buy`, `topUp`) deliberately still surface their errors: a swallowed
615
+ // failure there is a purchase that silently did nothing.
616
+ const read = (path, fallback) =>
617
+ this._canReach() ? this.request("GET", path).catch(() => fallback) : Promise.resolve(fallback);
618
+
619
+ this.economy = {
620
+ balance: () => read("/v1/economy/balance", { coins: 0, soft: 0, canSpend: false }),
621
+ packs: () => read("/v1/economy/packs", { packs: [] }).then((r) => r.packs || []),
622
+ topUp: (packId, opts) => this._topUp(packId, opts),
623
+ // Signing in is the host's job — a game cannot show a password field.
624
+ signIn: () => this._signIn(),
625
+ };
626
+ this.shop = {
627
+ catalog: () => this._shopRead().then((result) => result.items),
628
+ // `items()` cannot say *why* a shelf is bare, and the difference decides
629
+ // what the player should see: "nothing is for sale" is an empty rack,
630
+ // "we could not ask" is a shut shutter and a retry. `read()` is that.
631
+ read: () => this._shopRead(),
632
+ buy: (sku, opts) => {
633
+ // When a Tesana page is above us, it runs the purchase: a price
634
+ // confirmation belongs in a window we control, and a sandboxed
635
+ // cross-origin frame is the wrong place for one. The host asks the
636
+ // server, so the game still never states a price.
637
+ //
638
+ // With no host — a game opened straight from the play CDN — there is
639
+ // nobody to draw a confirmation and no page to open checkout on, so
640
+ // the game falls back to the direct call it has always made. That path
641
+ // carries its own token and is exactly as safe as it was.
642
+ if (!hasHost()) {
643
+ return this.request("POST", "/v1/shop/buy", {
644
+ sku,
645
+ clientRequestId: opts?.clientRequestId,
646
+ });
647
+ }
648
+ return requestCheckoutFromHost("item", { sku: sku }).then((result) => {
649
+ if (result && result.ok === true) return result;
650
+ // A host that answered without a grant is a refusal, not a success.
651
+ const err = new Error("The purchase was not completed.");
652
+ err.code = "cancelled";
653
+ throw err;
654
+ });
655
+ },
656
+ entitlements: () => read("/v1/shop/entitlements", { items: [] }).then((r) => r.items || []),
657
+ // Kept so games written against the shop's earlier shape still run, with
658
+ // the shape they expect: `items()` was an array, `inventory()` a sku →
659
+ // count map, and `balance()` a bare number. The service models an
660
+ // entitlement as owned-or-not, so every owned sku maps to one.
661
+ items: () => this.shop.catalog(),
662
+ inventory: () =>
663
+ this.shop.entitlements().then((list) => Object.fromEntries(list.map((e) => [e.sku, 1]))),
664
+ balance: () => this.economy.balance().then((r) => r.coins || 0),
665
+ signIn: () => this._signIn(),
666
+ };
667
+
668
+ // Leaderboards and achievements. Both are declared by the build (the same
669
+ // way a shop catalogue is) and both are client-reported, so neither is
670
+ // authoritative — a leaderboard is a ranking to show off, not a prize
671
+ // table. Reads degrade like the shop's, because a scoreboard is drawn on a
672
+ // game-over screen and must never take that screen down with it.
673
+ //
674
+ // Writes surface their errors, like `buy` does: an unknown board id is a
675
+ // build mistake the developer needs to see, not a silent nothing. Gameplay
676
+ // calls should still catch — a score that fails to send is not a reason to
677
+ // break the game — but the failure is visible rather than swallowed here.
678
+ this.scores = {
679
+ // Report a result. Only kept if it beats this player's own best, so
680
+ // calling it on every run is cheap and idempotent. Resolves
681
+ // `{ board, score, best, improved }` — `improved` is the only thing a
682
+ // "new high score!" banner needs.
683
+ submit: (board, score) =>
684
+ this.request("POST", "/v1/scores", { board: String(board || ""), score: Number(score) }),
685
+ // One board's standings, best first. `{ board, order, entries }`, where
686
+ // an entry is `{ rank, score, displayName, isGuest, you? }`. Guest runs
687
+ // are never listed — a ranking of identities that vanish with a tab is
688
+ // not a ranking.
689
+ top: (board, opts) =>
690
+ read(
691
+ `/v1/scores/${enc(board)}?gameId=${enc(this.gameId)}${
692
+ opts && opts.limit ? `&limit=${Number(opts.limit)}` : ""
693
+ }`,
694
+ { board: String(board || ""), entries: [] },
695
+ ),
696
+ // This player's own bests across every board they have set.
697
+ me: () => read("/v1/scores", { scores: [] }).then((r) => r.scores || []),
698
+ // Convenience: this player's best on one board, or `null`, for a HUD.
699
+ best: (board) =>
700
+ this.scores.me().then((rows) => rows.find((row) => row.board === board) || null),
701
+ };
702
+
703
+ this.achievements = {
704
+ // Every declared achievement, with this player's progress and unlocks
705
+ // folded in — one call is enough to draw the whole screen.
706
+ list: () => read("/v1/achievements", { items: [] }).then((r) => r.items || []),
707
+ // What the player has actually earned, for a badge row or a count.
708
+ unlocked: () => this.achievements.list().then((items) => items.filter((a) => a.unlocked)),
709
+ // Unlock outright. Idempotent: a second call reports `alreadyUnlocked`
710
+ // and keeps the original timestamp rather than rewriting it, so a game
711
+ // that fires this on every frame cannot corrupt the player's history.
712
+ unlock: (id) => this.request("POST", "/v1/achievements/unlock", { id: String(id || "") }),
713
+ // Add to a named counter and unlock whatever the new total crosses.
714
+ // Monotonic — progress only ever goes up — so a replayed report can
715
+ // unlock something sooner but can never walk a target backwards.
716
+ //
717
+ // Call this at a checkpoint or at the end of a run, not per event: each
718
+ // call is a round trip, and reporting "kills" once per kill would spend
719
+ // the whole rate limit on one fight.
720
+ progress: (stat, delta) =>
721
+ this.request("POST", "/v1/achievements/progress", {
722
+ stat: String(stat || ""),
723
+ delta: delta == null ? 1 : Number(delta),
724
+ }),
725
+ };
726
+
727
+ // A vanity address is the game's own name on tesana.ai — the thing a
728
+ // creator puts on a poster. It is claimed and released by the site, and
729
+ // the play service is where a label's owner is decided, so the mutating
730
+ // calls are authenticated; the lookup is public because a game may want
731
+ // to show its own link before anyone signs in.
732
+ this.vanity = {
733
+ // The host's answer, or `null` on a page with no host. Synchronous so a
734
+ // title screen can print its own address without awaiting anything.
735
+ public: () => this._vanityAddress || null,
736
+ // What this game currently answers on, resolved server-side (which is
737
+ // the only place that knows whether a label has moved on).
738
+ //
739
+ // `gameId` is required by the route and was missing here, so every call
740
+ // was a 400 that `read` swallowed into the host's own answer — the
741
+ // server was never actually asked. Sending it is what makes this a real
742
+ // lookup rather than an echo of the bootstrap.
743
+ current: () =>
744
+ read(
745
+ `/v1/vanity/current?gameId=${enc(this.gameId)}`,
746
+ { address: this._vanityAddress || null },
747
+ ).then((r) => r.address || this._vanityAddress || null),
748
+ // Is this label free? Never throws; an unreachable service reports
749
+ // `status: "unreachable"` so the UI can offer a retry rather than
750
+ // claiming a name is taken.
751
+ check: (label) => this._vanityCheck(label),
752
+ // Claim it. Rejects with `code` on anything the player can fix:
753
+ // `taken`, `reserved`, `invalid`, `login_required`.
754
+ claim: (label) => this.request("POST", "/v1/vanity/claim", { label: String(label || "") }),
755
+ // Point the game at a label it already owns, or at another label of
756
+ // the player's. The server decides what is allowed.
757
+ set: (label) => this.request("POST", "/v1/vanity/set", { label: String(label || "") }),
758
+ release: () => this.request("POST", "/v1/vanity/release", {}),
759
+ // Convenience for a "share your game" button: a candidate label the
760
+ // player can edit, and the check that says whether it is free.
761
+ suggest: (source) => {
762
+ const label = slugify(source || "");
763
+ const problem = vanityProblem(label);
764
+ return { label, problem, valid: !problem };
765
+ },
766
+ };
767
+
768
+ // Ads are the host's business, not ours: the network, the SDK key and
769
+ // the consent screen all live in a window we do not control. A game asks
770
+ // through this object and the host decides whether, when and what to
771
+ // show. Nothing here ever draws an ad itself.
772
+ this.ads = this._makeAds(opts);
773
+ // Kept module-scoped so a bootstrap that lands later can re-point this
774
+ // same object in place, rather than the game holding a stale policy.
775
+ adClient = this.ads;
776
+
777
+ this.multiplayer = {
778
+ // Read this before drawing a lobby: when it is false the game is not
779
+ // allowed rooms yet and the calls below refuse.
780
+ enabled: this._mpEnabled,
781
+ list: () =>
782
+ this._mpEnabled
783
+ ? this.request("GET", "/v1/mp/rooms").then((r) => r.rooms)
784
+ : Promise.resolve([]),
785
+ create: (opts) =>
786
+ this._mpEnabled
787
+ ? this._mpEnter(this.request("POST", "/v1/mp/rooms", opts || {}))
788
+ : this._mpRefuse(),
789
+ join: (roomId) =>
790
+ this._mpEnabled
791
+ ? this._mpEnter(this.request("POST", `/v1/mp/rooms/${enc(roomId)}/join`))
792
+ : this._mpRefuse(),
793
+ quickJoin: (opts) =>
794
+ this._mpEnabled
795
+ ? this._mpEnter(this.request("POST", "/v1/mp/quick-join", opts || {}))
796
+ : this._mpRefuse(),
797
+ send: (data) => this._mpSend(data),
798
+ on: (fn) => this._mpOn(fn),
799
+ // Connection status, separate from room messages: a game can show
800
+ // "reconnecting" without mistaking it for something a player did.
801
+ onStatus: (fn) => this._mpOnStatus(fn),
802
+ leave: () => this._mpLeave(),
803
+ };
804
+ }
805
+
806
+ async ensureSession() {
807
+ // The site posted us a player and a token: that identity is authoritative
808
+ // and there is nothing to mint. Minting here would be the guest-identity
809
+ // bug again — the host's token is dropped and the grant goes unread.
810
+ if (this._player && this.token) return this._player;
811
+ if (this.offline) return this._player;
812
+ if (this.token) {
813
+ try {
814
+ const me = await this.request("GET", "/v1/player/me");
815
+ this._player = me.player;
816
+ return this._player;
817
+ } catch {
818
+ this.token = "";
819
+ }
820
+ }
821
+ const guest = await this.request("POST", "/v1/auth/guest", {
822
+ gameId: this.gameId,
823
+ deviceId: this.deviceId,
824
+ rail: this.rail,
825
+ });
826
+ this.token = guest.token;
827
+ this._player = guest.player;
828
+ writeCache({ token: this.token, gameId: this.gameId });
829
+ return this._player;
830
+ }
831
+
832
+ /**
833
+ * Link this device to a Tesana account. Saves follow the account after this.
834
+ * @param {{ tesanaUserId?: string, email?: string }} identity
835
+ */
836
+ async login(identity = {}) {
837
+ const tesanaUserId = identity.tesanaUserId || identity.email || this._player?.displayName || "linked-user";
838
+ const result = await this.request("POST", "/v1/auth/link", {
839
+ tesanaUserId,
840
+ email: identity.email || tesanaUserId,
841
+ });
842
+ this.token = result.token;
843
+ this._player = result.player;
844
+ writeCache({ token: this.token });
845
+ return this._player;
846
+ }
847
+
848
+ /**
849
+ * Buy a coin pack.
850
+ *
851
+ * When a Tesana page is above us it runs the whole thing — it opens
852
+ * checkout in a window of its own, which is where a payment form belongs.
853
+ * The game only asks. With no host (a game opened straight from the play
854
+ * CDN) there is no page to open checkout on, so the standalone result is
855
+ * returned as-is for the page to follow itself.
856
+ * @param {string} packId
857
+ * @param {{ clientRequestId?: string }} [opts]
858
+ */
859
+ async _topUp(packId, opts = {}) {
860
+ if (hasHost()) {
861
+ // The host owns checkout: it opens Stripe in a window of its own and
862
+ // tells us how it went. We only ask. The message names are the host's,
863
+ // because the host is the side that already had a top-up flow — this SDK
864
+ // used to post a name nobody listened for, which is why asking to buy
865
+ // coins inside a game did nothing at all.
866
+ post({ type: "tesana-topup-request", packId });
867
+ return new Promise((resolve) => {
868
+ let expiry = null;
869
+ const finish = (result) => {
870
+ window.removeEventListener("message", onMessage);
871
+ if (expiry) clearTimeout(expiry);
872
+ resolve(result);
873
+ };
874
+ function onMessage(event) {
875
+ const msg = event.data;
876
+ if (!msg || msg.type !== "tesana-topup-result") return;
877
+ finish({ ok: true, purchased: msg.purchased === true, balance: msg.balance });
878
+ }
879
+ window.addEventListener("message", onMessage);
880
+ // Declining is an answer too. A player who closed checkout should get
881
+ // their shop back, not watch it wait out a timeout.
882
+ expiry = setTimeout(() => finish({ ok: false, purchased: false, code: "timeout" }), CHECKOUT_TIMEOUT_MS);
883
+ });
884
+ }
885
+ const result = await this.request("POST", "/v1/economy/top-up", {
886
+ packId,
887
+ clientRequestId: opts.clientRequestId,
888
+ });
889
+ return result;
890
+ }
891
+
892
+ _mpRefuse() {
893
+ const err = new Error("Multiplayer is not available for this game yet");
894
+ err.code = "multiplayer_disabled";
895
+ return Promise.reject(err);
896
+ }
897
+
898
+ /**
899
+ * @param {Promise<{ wsUrl: string, roomId?: string, room?: object, playerId?: string }>} session
900
+ */
901
+ async _mpEnter(session) {
902
+ const joined = await session;
903
+ await this._mpOpen(joined.wsUrl);
904
+ this._mp.roomId = joined.roomId || joined.room?.id || null;
905
+ return {
906
+ wsUrl: joined.wsUrl,
907
+ roomId: this._mp.roomId,
908
+ playerId: joined.playerId || this._player?.id,
909
+ room: joined.room,
910
+ };
911
+ }
912
+
913
+ /**
914
+ * @param {string} wsUrl
915
+ */
916
+ _mpOpen(wsUrl) {
917
+ if (typeof WebSocket === "undefined") {
918
+ throw new Error("Tesana.multiplayer requires WebSocket");
919
+ }
920
+ this._mpClose();
921
+ // Reconnecting is re-entering the same room, so the URL is kept. The
922
+ // grant inside it is checked by the room host, which is what lets a
923
+ // client recover from a scale-in without going back through the lobby.
924
+ this._mp.wsUrl = wsUrl;
925
+ return new Promise((resolve, reject) => {
926
+ const ws = new WebSocket(wsUrl);
927
+ this._mp.ws = ws;
928
+ let opened = false;
929
+ const timer = setTimeout(() => {
930
+ if (!opened) {
931
+ this._mpClose();
932
+ reject(new Error("multiplayer connect timeout"));
933
+ }
934
+ }, 8000);
935
+ ws.onmessage = (event) => {
936
+ let msg;
937
+ try {
938
+ msg = JSON.parse(String(event.data));
939
+ } catch {
940
+ return;
941
+ }
942
+ if (msg?.t === "hello" && !opened) {
943
+ opened = true;
944
+ clearTimeout(timer);
945
+ // A reconnect that got its hello is a recovered session: clear the
946
+ // attempt counter so a later blip starts from a short delay again,
947
+ // and tell the game it is back.
948
+ if (this._mp.reconnectAttempts > 0) {
949
+ this._mpEmitStatus({ status: "reconnected" });
950
+ }
951
+ this._mp.reconnectAttempts = 0;
952
+ resolve(msg);
953
+ }
954
+ for (const fn of this._mp.handlers) {
955
+ try {
956
+ fn(msg);
957
+ } catch {
958
+ /* game handler */
959
+ }
960
+ }
961
+ };
962
+ ws.onerror = () => {
963
+ if (!opened) {
964
+ /* onclose rejects */
965
+ }
966
+ };
967
+ ws.onclose = (event) => {
968
+ if (this._mp.ws === ws) this._mp.ws = null;
969
+ if (!opened) {
970
+ clearTimeout(timer);
971
+ reject(new Error(event.reason || "multiplayer closed"));
972
+ return;
973
+ }
974
+ // The socket dropped after we were in the room. That is a scale-in,
975
+ // a deploy, or a network blip — none of which the player caused, and
976
+ // all of which used to leave them silently alone in a match. Retry
977
+ // unless they actually meant to leave.
978
+ this._mpScheduleReconnect();
979
+ };
980
+ });
981
+ }
982
+
983
+ /**
984
+ * Try to get back into a room we were dropped out of.
985
+ *
986
+ * Jittered exponential backoff, capped, and bounded in attempts: jitter
987
+ * because a scale-in drops every player at the same instant and un-jittered
988
+ * retries would arrive as one synchronized spike at the very moment the
989
+ * fleet is least able to take it. Bounded because a room that really is
990
+ * gone should stop being retried and say so.
991
+ */
992
+ _mpScheduleReconnect() {
993
+ const mp = this._mp;
994
+ if (!mp.wsUrl || mp.leaving) return;
995
+ if (mp.reconnectTimer) return;
996
+ const attempts = (mp.reconnectAttempts || 0) + 1;
997
+ mp.reconnectAttempts = attempts;
998
+ if (attempts > mp.reconnectMaxAttempts) {
999
+ mp.reconnectExhausted = true;
1000
+ this._mpEmitStatus({ status: "disconnected", reason: "reconnect_exhausted" });
1001
+ return;
1002
+ }
1003
+ this._mpEmitStatus({ status: "reconnecting", attempt: attempts });
1004
+ const base = Math.min(mp.reconnectMaxMs, mp.reconnectBaseMs * 2 ** (attempts - 1));
1005
+ const delay = base * (0.5 + Math.random() * 0.5);
1006
+ mp.reconnectTimer = setTimeout(() => {
1007
+ mp.reconnectTimer = null;
1008
+ if (mp.leaving) return;
1009
+ this._mpOpen(mp.wsUrl).catch(() => {
1010
+ // Still not up — schedule the next attempt, which is where the
1011
+ // attempt count grows and the backoff widens.
1012
+ this._mpScheduleReconnect();
1013
+ });
1014
+ }, delay);
1015
+ }
1016
+
1017
+ _mpEmitStatus(status) {
1018
+ for (const fn of this._mp.statusHandlers) {
1019
+ try {
1020
+ fn(status);
1021
+ } catch {
1022
+ /* game handler */
1023
+ }
1024
+ }
1025
+ }
1026
+
1027
+ _mpClose() {
1028
+ const ws = this._mp.ws;
1029
+ this._mp.ws = null;
1030
+ if (this._mp.reconnectTimer) {
1031
+ clearTimeout(this._mp.reconnectTimer);
1032
+ this._mp.reconnectTimer = null;
1033
+ }
1034
+ if (!ws) return;
1035
+ ws.onmessage = null;
1036
+ ws.onerror = null;
1037
+ ws.onclose = null;
1038
+ try {
1039
+ ws.close();
1040
+ } catch {
1041
+ /* ignore */
1042
+ }
1043
+ }
1044
+
1045
+ /**
1046
+ * @param {object} data
1047
+ */
1048
+ _mpSend(data) {
1049
+ const ws = this._mp.ws;
1050
+ if (!ws || ws.readyState !== 1) throw new Error("Not in a room");
1051
+ ws.send(JSON.stringify(data && typeof data === "object" ? data : { value: data }));
1052
+ }
1053
+
1054
+ /**
1055
+ * @param {(msg: object) => void} fn
1056
+ */
1057
+ _mpOn(fn) {
1058
+ if (typeof fn !== "function") throw new Error("tesana.multiplayer.on requires a function");
1059
+ this._mp.handlers.push(fn);
1060
+ return () => {
1061
+ this._mp.handlers = this._mp.handlers.filter((handler) => handler !== fn);
1062
+ };
1063
+ }
1064
+
1065
+ /**
1066
+ * @param {(status: { status: string, reason?: string }) => void} fn
1067
+ */
1068
+ _mpOnStatus(fn) {
1069
+ if (typeof fn !== "function") throw new Error("tesana.multiplayer.onStatus requires a function");
1070
+ this._mp.statusHandlers.push(fn);
1071
+ return () => {
1072
+ this._mp.statusHandlers = this._mp.statusHandlers.filter((handler) => handler !== fn);
1073
+ };
1074
+ }
1075
+
1076
+ async _mpLeave() {
1077
+ const roomId = this._mp.roomId;
1078
+ // Set before closing: `onclose` fires synchronously on `close()`, and
1079
+ // without this flag a deliberate leave would look like a dropped socket
1080
+ // and schedule a reconnect into the room the player just left.
1081
+ this._mp.leaving = true;
1082
+ this._mpClose();
1083
+ this._mp.roomId = null;
1084
+ this._mp.wsUrl = null;
1085
+ this._mp.reconnectAttempts = 0;
1086
+ this._mp.reconnectExhausted = false;
1087
+ try {
1088
+ if (!roomId) return { ok: true, room: null };
1089
+ try {
1090
+ return await this.request("POST", `/v1/mp/rooms/${enc(roomId)}/leave`);
1091
+ } catch {
1092
+ return { ok: true, room: null };
1093
+ }
1094
+ } finally {
1095
+ this._mp.leaving = false;
1096
+ }
1097
+ }
1098
+
1099
+ /**
1100
+ * @param {string} method
1101
+ * @param {string} path
1102
+ * @param {object} [body]
1103
+ */
1104
+ async request(method, path, body) {
1105
+ const headers = { "content-type": "application/json" };
1106
+ const authToken = this._authFor();
1107
+ if (authToken) headers.authorization = `Bearer ${authToken}`;
1108
+ if (this.deviceId) headers["x-tesana-device"] = this.deviceId;
1109
+ let res;
1110
+ try {
1111
+ res = await fetch(`${this._baseFor()}${path}`, {
1112
+ method,
1113
+ headers,
1114
+ body: body == null || method === "GET" ? undefined : JSON.stringify(body),
1115
+ });
1116
+ } catch (err) {
1117
+ // No cache fallback here. `_dbGet` owns the local-cache behaviour for
1118
+ // saves, keyed by the bare key; this generic transport path would have
1119
+ // to re-derive that key from the URL, and getting it wrong returns a
1120
+ // confident `null` that pre-empts the real fallback — a save the player
1121
+ // still has, reported as missing.
1122
+ throw err;
1123
+ }
1124
+ const data = await res.json().catch(() => ({}));
1125
+ if (!res.ok) {
1126
+ const error = new Error(data.message || res.statusText);
1127
+ error.code = data.error;
1128
+ error.status = res.status;
1129
+ throw error;
1130
+ }
1131
+ return data;
1132
+ }
1133
+
1134
+ /**
1135
+ * Re-point this client at a newer host answer, in place.
1136
+ *
1137
+ * The game holds a reference to this object, so an upgrade must never
1138
+ * replace it. This is what turns a provisional "offline" bootstrap into a
1139
+ * real one: without it a signed-in player's token arrives and is dropped,
1140
+ * and the only symptom is a wallet that never leaves zero.
1141
+ */
1142
+ configure(config) {
1143
+ this.endpoint = String(config.endpoint || "").replace(/\/$/, "");
1144
+ this.gameId = config.gameId ?? "";
1145
+ this.token = config.token || "";
1146
+ this.saveEndpoint = String(config.saveEndpoint || "").replace(/\/$/, "");
1147
+ this.saveToken = config.saveToken || "";
1148
+ this.saveServiceNamed = Boolean(config.saveServiceNamed);
1149
+ if (!this.saveServiceNamed && this.endpoint && !this.saveEndpoint) {
1150
+ this.saveEndpoint = this.endpoint;
1151
+ this.saveToken = this.token;
1152
+ }
1153
+ this.canSaveToCloud = Boolean(this.saveEndpoint && this.saveToken);
1154
+ this.offline = Boolean(config.offline) || !this.endpoint || !this.gameId;
1155
+ if (config.player) this._player = config.player;
1156
+ if (config.vanityAddress !== undefined) {
1157
+ this._vanityAddress = config.vanityAddress ? String(config.vanityAddress) : null;
1158
+ }
1159
+ if (this.ads && ("adsEnabled" in config || "adFormats" in config)) {
1160
+ this.ads.applyPolicy({
1161
+ enabled: Boolean(config.adsEnabled),
1162
+ formats: config.adFormats,
1163
+ minIntervalMs: config.adMinIntervalMs,
1164
+ });
1165
+ }
1166
+ this._mpEnabled = config.multiplayer !== false;
1167
+ this.multiplayer.enabled = this._mpEnabled;
1168
+ return this;
1169
+ }
1170
+
1171
+ /**
1172
+ * Which origin and which credential a path uses.
1173
+ *
1174
+ * A Tesana page gives the save service its own address and token; a room
1175
+ * build bakes one `endpoint` that is the save service and one token. Both
1176
+ * collapse to `_baseFor` / `_authFor` here so no call site has to know which
1177
+ * shape it is running in.
1178
+ */
1179
+ _baseFor() {
1180
+ return this.saveEndpoint || this.endpoint;
1181
+ }
1182
+
1183
+ _authFor() {
1184
+ return this.saveEndpoint ? this.saveToken : this.token;
1185
+ }
1186
+
1187
+ /**
1188
+ * Whether a call has somewhere to go at all.
1189
+ *
1190
+ * A game opened outside a Tesana page — or a build nothing ever answered —
1191
+ * has no address, and `fetch` on a relative URL throws. Reads check this and
1192
+ * answer empty; that is what keeps a shop drawing on a hostless page instead
1193
+ * of taking the menu down with it.
1194
+ */
1195
+ _canReach() {
1196
+ return Boolean(this._baseFor() && this.gameId);
1197
+ }
1198
+
1199
+ /**
1200
+ * This game's shelf, and whether we actually reached it.
1201
+ *
1202
+ * `status` is `ok`, `empty` (the server genuinely sells nothing here — do
1203
+ * not retry), `no_host` (nothing was ever handed to this page), or
1204
+ * `unreachable` (show a retry). Reads never reject; a shop that cannot be
1205
+ * priced must not take the menu down with it.
1206
+ */
1207
+ async _shopRead() {
1208
+ if (!this._canReach()) {
1209
+ warnOnce(
1210
+ "shop-no-host",
1211
+ "Tesana shop: nothing to ask — this game was not handed an API address."
1212
+ + " Running outside a Tesana page does that; so does a page that never"
1213
+ + " answered the bootstrap request.",
1214
+ );
1215
+ return { status: "no_host", items: [] };
1216
+ }
1217
+ try {
1218
+ const data = await this.request("GET", "/v1/shop/catalog");
1219
+ const items = Array.isArray(data?.items) ? data.items : [];
1220
+ if (!items.length) {
1221
+ // The one that earns its keep: an empty shelf and a catalogue nobody
1222
+ // declared look identical from inside a game, and this names the file
1223
+ // to fix. It is the runtime half of the build's empty-trader gate.
1224
+ warnOnce(
1225
+ "shop-empty",
1226
+ "Tesana shop: the server sells nothing for this game. If it is meant"
1227
+ + " to, the build has to declare its items in assets/ui/shop.json —"
1228
+ + " the server only sells what it was told about.",
1229
+ );
1230
+ return { status: "empty", items: [] };
1231
+ }
1232
+ return { status: "ok", items };
1233
+ } catch (err) {
1234
+ warnOnce(
1235
+ "shop-unreachable",
1236
+ `Tesana shop: could not reach the shop (${(err && err.message) || "request failed"}).`
1237
+ + " The game keeps playing; nothing is purchasable until it answers.",
1238
+ );
1239
+ return { status: "unreachable", items: [] };
1240
+ }
1241
+ }
1242
+
1243
+ // ------------------------------------------------------------------ vanity
1244
+ //
1245
+ // A game's own address. The claim and the lookup both belong to the site
1246
+ // and the play service; this is only the game's side of it. Checks never
1247
+ // throw, because a name field that reports "taken" when the network is
1248
+ // down is worse than one that says "try again".
1249
+ //
1250
+ // The check is strict about the label itself — it does not rewrite what the
1251
+ // player typed. Turning a phrase into a candidate is `suggest()`'s job, and
1252
+ // the two are deliberately separate: silently hyphenating what someone
1253
+ // asked for is how a player ends up owning a name they did not choose.
1254
+ async _vanityCheck(label) {
1255
+ const candidate = String(label || "").trim().toLowerCase();
1256
+ const problem = vanityProblem(candidate);
1257
+ if (problem) return { label: candidate, status: problem, available: false };
1258
+ if (!this._canReach()) return { label: candidate, status: "no_host", available: false };
1259
+ try {
1260
+ const data = await this.request("GET", `/v1/vanity/available/${enc(candidate)}`);
1261
+ return {
1262
+ label: candidate,
1263
+ status: "ok",
1264
+ available: Boolean(data?.available),
1265
+ };
1266
+ } catch {
1267
+ return { label: candidate, status: "unreachable", available: false };
1268
+ }
1269
+ }
1270
+
1271
+ // --------------------------------------------------------------------- ads
1272
+ //
1273
+ // A thin, honest wrapper around the host. The policy (whether ads run, how
1274
+ // often, which formats) is the host's and arrives in the bootstrap; the
1275
+ // network key and the consent screen never cross into the frame. A game can
1276
+ // therefore rely on one rule: if `ads.enabled()` is false, nothing will ever
1277
+ // be shown, and it should not pretend otherwise.
1278
+ _makeAds(opts) {
1279
+ const state = {
1280
+ enabled: Boolean(opts.adsEnabled),
1281
+ formats: Array.isArray(opts.adFormats) ? opts.adFormats.slice() : [],
1282
+ minIntervalMs:
1283
+ opts.adMinIntervalMs == null ? 180000 : Math.max(0, Number(opts.adMinIntervalMs) || 0),
1284
+ lastShownAt: 0,
1285
+ pending: new Map(),
1286
+ seq: 0,
1287
+ };
1288
+ const self = this;
1289
+
1290
+ function applyPolicy(policy) {
1291
+ if (!policy || typeof policy !== "object") return state;
1292
+ if ("enabled" in policy) state.enabled = Boolean(policy.enabled);
1293
+ if (Array.isArray(policy.formats)) state.formats = policy.formats.slice();
1294
+ if (policy.minIntervalMs != null) {
1295
+ state.minIntervalMs = Math.max(0, Number(policy.minIntervalMs) || 0);
1296
+ }
1297
+ return state;
1298
+ }
1299
+
1300
+ return {
1301
+ // Live state, for a menu that wants to show "no ads on this account".
1302
+ get enabled() {
1303
+ return state.enabled;
1304
+ },
1305
+ formats: () => state.formats.slice(),
1306
+ /** May the host show something of this format right now? */
1307
+ ready: (format) => {
1308
+ if (!state.enabled) return false;
1309
+ if (Date.now() - state.lastShownAt < state.minIntervalMs) return false;
1310
+ return !format || state.formats.length === 0 || state.formats.includes(format);
1311
+ },
1312
+ /**
1313
+ * Ask the host to show an ad. Resolves `{ shown, format, reason }` and
1314
+ * never rejects: an ad that did not run is a normal answer, not an
1315
+ * error the player must see. `reason` is one of `disabled`,
1316
+ * `too_soon`, `unsupported`, `declined`, `no_host`, `error`.
1317
+ */
1318
+ show: (format, context) => {
1319
+ const wanted = format || "interstitial";
1320
+ if (!state.enabled) return Promise.resolve({ shown: false, format: wanted, reason: "disabled" });
1321
+ if (Date.now() - state.lastShownAt < state.minIntervalMs) {
1322
+ return Promise.resolve({ shown: false, format: wanted, reason: "too_soon" });
1323
+ }
1324
+ if (state.formats.length && !state.formats.includes(wanted)) {
1325
+ return Promise.resolve({ shown: false, format: wanted, reason: "unsupported" });
1326
+ }
1327
+ const requestId = `ad_${Date.now().toString(36)}_${(state.seq += 1)}`;
1328
+ return new Promise((resolve) => {
1329
+ const finish = (result) => {
1330
+ state.pending.delete(requestId);
1331
+ // Record the attempt either way: a host that declined should not
1332
+ // be asked again on the very next frame.
1333
+ state.lastShownAt = Date.now();
1334
+ reportAdToHost({ requestId, format: wanted, ...result });
1335
+ resolve({ format: wanted, ...result });
1336
+ };
1337
+ state.pending.set(requestId, finish);
1338
+ requestAdFromHost(requestId, wanted, context);
1339
+ // No host above us, or one that never answers. Ten seconds is long
1340
+ // enough for a real network fill and short enough not to stall a
1341
+ // game waiting at a level boundary.
1342
+ setTimeout(
1343
+ () => state.pending.has(requestId) && finish({ shown: false, reason: "no_host" }),
1344
+ 10000,
1345
+ );
1346
+ });
1347
+ },
1348
+ // ---- internal wiring (used by the bootstrap listener) ----
1349
+ applyPolicy,
1350
+ settle(requestId, message) {
1351
+ const finish = state.pending.get(requestId);
1352
+ if (!finish) return;
1353
+ const shown = Boolean(message.shown);
1354
+ finish({ shown, reason: message.reason || (shown ? "ok" : "declined") });
1355
+ },
1356
+ };
1357
+ }
1358
+
1359
+ // ------------------------------------------------------------------ saves
1360
+ //
1361
+ // Writes go to the local cache first, always, so a failed upload is a save
1362
+ // that has not synced yet rather than a save the player has lost. Reads fall
1363
+ // back to the last cloud value when the service is unreachable — the last
1364
+ // known state beats dropping the player back into a new game.
1365
+ async _dbGet(key) {
1366
+ const local = (readCache().db || {})[key];
1367
+ if (!this.canSaveToCloud) return local === undefined ? null : local;
1368
+ try {
1369
+ const data = await this.request("GET", `/v1/db/${enc(key)}`);
1370
+ const db = readCache().db || {};
1371
+ db[key] = data.value;
1372
+ writeCache({ db });
1373
+ return data.value === undefined ? null : data.value;
1374
+ } catch {
1375
+ return local === undefined ? null : local;
1376
+ }
1377
+ }
1378
+
1379
+ async _dbSet(key, value) {
1380
+ const db = readCache().db || {};
1381
+ db[key] = value;
1382
+ writeCache({ db });
1383
+ if (!this.canSaveToCloud) return value;
1384
+ try {
1385
+ await this.request("PUT", `/v1/db/${enc(key)}`, { value });
1386
+ } catch {
1387
+ /* written locally already; it will sync when the service is back */
1388
+ }
1389
+ return value;
1390
+ }
1391
+
1392
+ /**
1393
+ * Ask the host to open its sign-in UI, then wait to be told it is done.
1394
+ *
1395
+ * The account UI belongs to the host: a game cannot show a password field in
1396
+ * an iframe the site does not control, and should not be trusted with one.
1397
+ * Resolves when the host says the player is signed in. The bootstrap that
1398
+ * follows carries a token, which flips `offline` off, so balance(),
1399
+ * inventory() and buy() start working without the game reloading.
1400
+ */
1401
+ _signIn() {
1402
+ if (!this.offline) return Promise.resolve({ signedIn: true });
1403
+ post({ type: "tesana-sign-in-request", gameId: this.gameId });
1404
+ return new Promise((resolve) => {
1405
+ let expiry = null;
1406
+ const finish = (result) => {
1407
+ if (expiry) clearTimeout(expiry);
1408
+ window.removeEventListener("message", onMessage);
1409
+ resolve(result);
1410
+ };
1411
+ // The player is signed in, but the token travels in a separate bootstrap
1412
+ // and may still be in flight. Resolving the moment we hear "yes" would
1413
+ // let a game that awaits signIn() and then immediately buys be refused a
1414
+ // second time for still being offline — the exact dead end this whole
1415
+ // exchange exists to remove.
1416
+ const waitForToken = (attempt) => {
1417
+ if (!this.offline) return finish({ signedIn: true });
1418
+ if ((attempt || 0) >= 60) return finish({ signedIn: true });
1419
+ setTimeout(() => waitForToken((attempt || 0) + 1), 50);
1420
+ };
1421
+ function onMessage(event) {
1422
+ const msg = event.data;
1423
+ if (!msg || msg.type !== "tesana-signed-in") return;
1424
+ if (!msg.signedIn) return finish({ signedIn: false });
1425
+ waitForToken(0);
1426
+ }
1427
+ window.addEventListener("message", onMessage);
1428
+ // Declining is an answer too. Report what is actually true rather than
1429
+ // leaving the game's promise pending forever.
1430
+ expiry = setTimeout(() => finish({ signedIn: !this.offline }), SIGN_IN_TIMEOUT_MS);
1431
+ });
1432
+ }
1433
+
1434
+ }
1435
+
1436
+ function enc(value) {
1437
+ return encodeURIComponent(String(value));
1438
+ }
1439
+
1440
+ export const Tesana = { init, TesanaClient, version: VERSION };
1441
+ export default Tesana;