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