@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.
- package/README.md +86 -0
- package/dist/tesana.esm.js +1441 -0
- package/dist/tesana.iife.js +1451 -0
- package/package.json +49 -0
- package/src/tesana.js +1441 -0
|
@@ -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);
|