@tesana/sdk 1.0.3 → 1.1.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 +55 -0
- package/dist/tesana.esm.js +1035 -35
- package/dist/tesana.iife.js +1036 -36
- package/package.json +1 -1
- package/src/tesana.js +1035 -35
package/dist/tesana.iife.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
/* Tesana play-time SDK v1.0
|
|
1
|
+
/* Tesana play-time SDK v1.1.0 */
|
|
2
2
|
(function (root) {
|
|
3
3
|
/**
|
|
4
4
|
* Tesana play-time SDK. Vanilla JS, no npm in games.
|
|
5
5
|
* Host injects window.__TESANA__ or postMessage { type: "tesana-bootstrap", ... }.
|
|
6
6
|
* v1 is player identity, per-game saves, optional open-lobby multiplayer,
|
|
7
|
-
* cloud saves,
|
|
7
|
+
* cloud saves, the in-game coin economy, and gameplay analytics.
|
|
8
8
|
*
|
|
9
9
|
* THIS FILE IS THE ONE COPY. It is the source of truth for `window.Tesana`:
|
|
10
10
|
* the engine injects the built artifact into every game and the CDN serves it
|
|
@@ -39,7 +39,11 @@
|
|
|
39
39
|
// 1.0.0 ships six services together: saves, shop, leaderboards, achievements,
|
|
40
40
|
// a vanity address (`tesana.vanity`) and in-game ads (`tesana.ads`). These are
|
|
41
41
|
// the first release, so there is no earlier number to stay compatible with.
|
|
42
|
-
|
|
42
|
+
//
|
|
43
|
+
// 1.1.0 adds gameplay analytics, `tesana.track` and `tesana.gameplay.*`. The
|
|
44
|
+
// engine's analytics skill already taught them, so a game following it threw a
|
|
45
|
+
// TypeError on its first death; this is the release that makes those calls real.
|
|
46
|
+
const VERSION = "1.1.0";
|
|
43
47
|
|
|
44
48
|
const LOCAL_CACHE = "tesana.play.v1";
|
|
45
49
|
|
|
@@ -55,6 +59,21 @@ const LOCAL_CACHE = "tesana.play.v1";
|
|
|
55
59
|
const MP_RECONNECT_BASE_MS = 1000;
|
|
56
60
|
const MP_RECONNECT_MAX_MS = 30000;
|
|
57
61
|
const MP_RECONNECT_MAX_ATTEMPTS = 8;
|
|
62
|
+
//: How long to wait for the host to answer a bootstrap request before giving
|
|
63
|
+
//: up on that attempt. The answer is a token mint; a host that is slow must not
|
|
64
|
+
//: hold a request open indefinitely.
|
|
65
|
+
const MP_HOST_ASK_TIMEOUT_MS = 5000;
|
|
66
|
+
//: How long `create`/`quickJoin` will wait for a room that is being placed.
|
|
67
|
+
//:
|
|
68
|
+
//: Room creation on GameLift is asynchronous, so the service answers 202 with
|
|
69
|
+
//: no `wsUrl` and a `retryAfterMs` instead of holding the request open. The
|
|
70
|
+
//: client polls until the room is ready or this budget runs out. It is longer
|
|
71
|
+
//: than the service's own placement poll (90s) only in the sense that it gives
|
|
72
|
+
//: up first: a client should decide for itself how long it will wait, and a
|
|
73
|
+
//: game can watch `onStatus` rather than being blocked.
|
|
74
|
+
const MP_PLACING_TIMEOUT_MS = 45000;
|
|
75
|
+
//: Default gap between placement polls, used when the service does not say.
|
|
76
|
+
const MP_PLACING_RETRY_MS = 1500;
|
|
58
77
|
|
|
59
78
|
/**
|
|
60
79
|
* A polite, dns-safe label: lowercase letters, digits and internal hyphens,
|
|
@@ -128,6 +147,18 @@ const bootstrapWaiters = [];
|
|
|
128
147
|
// can re-point them in place. A game holds a reference to its client, so it
|
|
129
148
|
// must not be rebuilt — only upgraded.
|
|
130
149
|
const upgradeHandlers = [];
|
|
150
|
+
// Whether a bootstrap the client asked for is in flight. It is what makes a
|
|
151
|
+
// late answer an update rather than an unsolicited message that re-points a
|
|
152
|
+
// live session.
|
|
153
|
+
let bootstrapRequestPending = false;
|
|
154
|
+
// The in-flight ask, so a burst of refused calls mints once rather than once
|
|
155
|
+
// per caller. The host does its own rate-limiting; this only removes the
|
|
156
|
+
// duplicate round-trips a simultaneous burst would otherwise make.
|
|
157
|
+
let bootstrapAskPromise = null;
|
|
158
|
+
//: Bumped every time a host answer replaces the live bootstrap. A caller that
|
|
159
|
+
//: asked for a fresh token can tell whether it actually arrived by watching
|
|
160
|
+
//: this, without having to hold a listener of its own.
|
|
161
|
+
let bootstrapGeneration = 0;
|
|
131
162
|
|
|
132
163
|
/**
|
|
133
164
|
* Diagnostics that should say their piece once per session, not once per call.
|
|
@@ -152,17 +183,34 @@ function isGuestBootstrap(data) {
|
|
|
152
183
|
}
|
|
153
184
|
|
|
154
185
|
/** Fold a host's answer into the live config, upgrading a provisional one. */
|
|
155
|
-
function applyBootstrap(data) {
|
|
186
|
+
function applyBootstrap(data, opts = {}) {
|
|
156
187
|
const next = data && typeof data === "object" ? data : {};
|
|
188
|
+
// Whether the client asked for this answer. A bootstrap it asked for is a
|
|
189
|
+
// refresh — its token aged out and the host minted another — and may update a
|
|
190
|
+
// live session. One it did not ask for may not, or anything able to post into
|
|
191
|
+
// the frame could re-point a running game's credential and identity.
|
|
192
|
+
const asked = opts.force || bootstrapRequestPending;
|
|
193
|
+
bootstrapRequestPending = false;
|
|
157
194
|
if (bootstrap) {
|
|
158
195
|
// A real host has already answered — unless that answer was a guest and
|
|
159
196
|
// this one is the account they just signed in to.
|
|
160
|
-
if (!bootstrap.offline && !(isGuestBootstrap(bootstrap) && !next.offline && !isGuestBootstrap(next)))
|
|
197
|
+
if (!bootstrap.offline && !(isGuestBootstrap(bootstrap) && !next.offline && !isGuestBootstrap(next))) {
|
|
198
|
+
if (asked) {
|
|
199
|
+
// A forced update may refresh credentials (token, save token, room
|
|
200
|
+
// token) but never the player's identity: the player is who the
|
|
201
|
+
// signing-in page said they are, and a stale or mismatched answer must
|
|
202
|
+
// not silently become them.
|
|
203
|
+
if (bootstrap.player && next.player !== undefined) delete next.player;
|
|
204
|
+
} else {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
161
208
|
// Still offline, unless it now carries a room token: a signed-out
|
|
162
209
|
// visitor's multiplayer arrives after the site has already said "offline".
|
|
163
|
-
if (next.offline && !(next.roomToken && !bootstrap.roomToken)) return;
|
|
210
|
+
if (next.offline && !(next.roomToken && !bootstrap.roomToken) && !asked) return;
|
|
164
211
|
}
|
|
165
212
|
bootstrap = next;
|
|
213
|
+
bootstrapGeneration += 1;
|
|
166
214
|
window.__TESANA__ = { ...(window.__TESANA__ || {}), ...next };
|
|
167
215
|
const waiting = bootstrapWaiters.splice(0);
|
|
168
216
|
for (const resolve of waiting) resolve(bootstrap);
|
|
@@ -199,6 +247,60 @@ function onBootstrapUpgrade(fn) {
|
|
|
199
247
|
*/
|
|
200
248
|
let adClient = null;
|
|
201
249
|
|
|
250
|
+
/**
|
|
251
|
+
* The live localization capability, kept module-scoped for the same reason as
|
|
252
|
+
* the ad client: the host can change the language at any moment, and the game
|
|
253
|
+
* holds a reference to `tesana.l10n` that must not be replaced underneath it.
|
|
254
|
+
* The listener calls `applyLocale`, which re-points this object in place.
|
|
255
|
+
*/
|
|
256
|
+
let l10nClient = null;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The languages the platform can translate into, in display order.
|
|
260
|
+
*
|
|
261
|
+
* Mirrors the service's own list, which is the authority; this copy exists so a
|
|
262
|
+
* language picker can be drawn before the service has answered — and so a page
|
|
263
|
+
* with no host at all, where nothing will ever answer, still has an honest
|
|
264
|
+
* list rather than an empty one.
|
|
265
|
+
*/
|
|
266
|
+
const L10N_TARGETS = ["en", "es", "fr", "de", "it", "pt-BR", "ru", "zh", "ja", "ko"];
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Fold whatever a host calls a language onto one of ours.
|
|
270
|
+
*
|
|
271
|
+
* `navigator.language` reports `en-US` and `pt-PT`; a site may store `pt` or
|
|
272
|
+
* `zh`; a player may be handed `de-AT`. None of those are in the list above and
|
|
273
|
+
* all of them mean a language we do translate into, so the base tag is what
|
|
274
|
+
* gets matched and the region is dropped — a game in `pt-BR` is more useful to
|
|
275
|
+
* a `pt-PT` player than one left in English. An unknown tag stays as it is and
|
|
276
|
+
* simply falls back to the source language at the call site.
|
|
277
|
+
*/
|
|
278
|
+
function normalizeLocale(tag) {
|
|
279
|
+
const value = String(tag || "").trim().replace(/_/g, "-");
|
|
280
|
+
if (!value) return "";
|
|
281
|
+
const exact = L10N_TARGETS.find((t) => t.toLowerCase() === value.toLowerCase());
|
|
282
|
+
if (exact) return exact;
|
|
283
|
+
const base = value.split("-")[0].toLowerCase();
|
|
284
|
+
return L10N_TARGETS.find((t) => t.split("-")[0].toLowerCase() === base) || value;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** `{name}` in `text`, filled from `values`. The one interpolation rule the SDK and the service share. */
|
|
288
|
+
function fillPlaceholders(text, values) {
|
|
289
|
+
const source = String(text == null ? "" : text);
|
|
290
|
+
if (!values || typeof values !== "object") return source;
|
|
291
|
+
return source.replace(/\{(\w+)\}/g, (whole, key) =>
|
|
292
|
+
Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : whole,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Fold a locale message from the host into the live client, if it is listening. */
|
|
297
|
+
function resyncLocale(data) {
|
|
298
|
+
if (!l10nClient) return;
|
|
299
|
+
const payload = data && typeof data.payload === "object" ? data.payload : data;
|
|
300
|
+
const next = payload && (payload.locale || payload.lang || payload.language);
|
|
301
|
+
if (next) l10nClient.applyLocale(next);
|
|
302
|
+
}
|
|
303
|
+
|
|
202
304
|
/** A policy message from the host, or `null` when none is in force. */
|
|
203
305
|
function adPolicyFrom(data) {
|
|
204
306
|
if (!data || typeof data !== "object") return null;
|
|
@@ -350,6 +452,8 @@ function startListening() {
|
|
|
350
452
|
// The site posts `{ type, payload }`; a baked or spliced bootstrap is the
|
|
351
453
|
// payload itself. Accept both, so one SDK reads either host.
|
|
352
454
|
if (data.type === "tesana-bootstrap") {
|
|
455
|
+
// `applyBootstrap` decides whether this answer may update a live session:
|
|
456
|
+
// one the client asked for may, one it did not may not.
|
|
353
457
|
applyBootstrap(data.payload && typeof data.payload === "object" ? data.payload : data);
|
|
354
458
|
return;
|
|
355
459
|
}
|
|
@@ -368,6 +472,19 @@ function startListening() {
|
|
|
368
472
|
}
|
|
369
473
|
return;
|
|
370
474
|
}
|
|
475
|
+
// The player changed the site's language. The host is the authority — it
|
|
476
|
+
// resolved which language the player wants, and it owns the setting — so
|
|
477
|
+
// this only carries it down. The game's own text re-renders when it draws
|
|
478
|
+
// again, which is why the skill tells games to redraw from `t()` rather
|
|
479
|
+
// than cache translated strings at startup.
|
|
480
|
+
if (data.type === "tesana-locale") {
|
|
481
|
+
try {
|
|
482
|
+
resyncLocale(data);
|
|
483
|
+
} catch {
|
|
484
|
+
/* a bad locale must never break the listener */
|
|
485
|
+
}
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
371
488
|
if (data.type === "tesana-ad-show") {
|
|
372
489
|
try {
|
|
373
490
|
const requestId = data.requestId || data.request_id;
|
|
@@ -399,16 +516,73 @@ function waitBootstrap(timeoutMs = 1500) {
|
|
|
399
516
|
if (bootstrap) return Promise.resolve(bootstrap);
|
|
400
517
|
|
|
401
518
|
startListening();
|
|
402
|
-
// Ask, rather than wait to be told. The frame can finish loading before the
|
|
403
|
-
// site has minted the session, and a request is what makes the site post the
|
|
404
|
-
// second time — without it a signed-in player's real bootstrap never arrives.
|
|
405
|
-
post({ type: "tesana-bootstrap-request" });
|
|
406
519
|
return new Promise((resolve) => {
|
|
520
|
+
// The waiter is registered before the ask goes out. A host that answers
|
|
521
|
+
// from the same tick — a test double, or a same-document frame — would
|
|
522
|
+
// otherwise be answered into an empty list and the game would fall to
|
|
523
|
+
// "offline" a second and a half later for no reason.
|
|
407
524
|
bootstrapWaiters.push(resolve);
|
|
408
525
|
setTimeout(() => applyBootstrap({ offline: true }), timeoutMs);
|
|
526
|
+
// Ask, rather than wait to be told. The frame can finish loading before the
|
|
527
|
+
// site has minted the session, and a request is what makes the site post the
|
|
528
|
+
// second time — without it a signed-in player's real bootstrap never arrives.
|
|
529
|
+
askHostForBootstrap();
|
|
409
530
|
});
|
|
410
531
|
}
|
|
411
532
|
|
|
533
|
+
/** Ask the host to post a bootstrap. Shared by boot and refresh, so the flag
|
|
534
|
+
* that makes an answer an update is set in exactly one place. The message name
|
|
535
|
+
* is what tells the host whether to re-mint or merely re-post. */
|
|
536
|
+
function askHostForBootstrap(type = "tesana-bootstrap-request") {
|
|
537
|
+
bootstrapRequestPending = true;
|
|
538
|
+
post({ type });
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Ask the host for a fresh bootstrap after a credential was refused.
|
|
543
|
+
*
|
|
544
|
+
* This is the SDK half of token expiry. A player token lives two hours and
|
|
545
|
+
* used to be re-minted only on a page load, so after two hours a blip was
|
|
546
|
+
* permanent until the player reloaded. Asking the host re-mints through it, and
|
|
547
|
+
* the answer flows back through the same upgrade path identity and ads already
|
|
548
|
+
* use, so a live game is re-pointed in place.
|
|
549
|
+
*
|
|
550
|
+
* Resolves with the new bootstrap, or `null` when there is no host or the host
|
|
551
|
+
* did not answer in time. Concurrent callers share one ask: a game that has
|
|
552
|
+
* been open for hours hits a burst of refusals at once, and one mint is what
|
|
553
|
+
* all of them needed. The host rate-limits the mints themselves.
|
|
554
|
+
*
|
|
555
|
+
* @returns {Promise<object|null>}
|
|
556
|
+
*/
|
|
557
|
+
function requestHostBootstrap() {
|
|
558
|
+
if (typeof window === "undefined") return Promise.resolve(null);
|
|
559
|
+
if (bootstrapAskPromise) return bootstrapAskPromise;
|
|
560
|
+
startListening();
|
|
561
|
+
const askedAt = bootstrapGeneration;
|
|
562
|
+
// A refresh, named as one. The host re-mints on this and merely re-posts on a
|
|
563
|
+
// boot request, so the two are never confused.
|
|
564
|
+
askHostForBootstrap("tesana-bootstrap-refresh");
|
|
565
|
+
bootstrapAskPromise = new Promise((resolve) => {
|
|
566
|
+
let done = false;
|
|
567
|
+
const finish = (value) => {
|
|
568
|
+
if (done) return;
|
|
569
|
+
done = true;
|
|
570
|
+
clearInterval(poll);
|
|
571
|
+
clearTimeout(expiry);
|
|
572
|
+
bootstrapAskPromise = null;
|
|
573
|
+
resolve(value);
|
|
574
|
+
};
|
|
575
|
+
// The bootstrap is delivered by the module-level listener, so watch the
|
|
576
|
+
// generation rather than registering a second listener — the token the host
|
|
577
|
+
// mints is scoped to this frame and is not ours to consume only here.
|
|
578
|
+
const poll = setInterval(() => {
|
|
579
|
+
if (bootstrapGeneration !== askedAt) finish(bootstrap);
|
|
580
|
+
}, 25);
|
|
581
|
+
const expiry = setTimeout(() => finish(null), MP_HOST_ASK_TIMEOUT_MS);
|
|
582
|
+
});
|
|
583
|
+
return bootstrapAskPromise;
|
|
584
|
+
}
|
|
585
|
+
|
|
412
586
|
function deviceId() {
|
|
413
587
|
const cache = readCache();
|
|
414
588
|
if (cache.deviceId) return cache.deviceId;
|
|
@@ -453,6 +627,308 @@ function post(message) {
|
|
|
453
627
|
}
|
|
454
628
|
}
|
|
455
629
|
|
|
630
|
+
// ---------------------------------------------------------------------------
|
|
631
|
+
// Gameplay analytics: `tesana.track` and `tesana.gameplay`
|
|
632
|
+
// ---------------------------------------------------------------------------
|
|
633
|
+
//
|
|
634
|
+
// A game reports what happened in it (a level started, the player died, a run
|
|
635
|
+
// ended with a score) so its creator can see where players get stuck and where
|
|
636
|
+
// they quit. The pipe already exists end to end: the runtime shell injected
|
|
637
|
+
// into every web build exposes `window.__TESANA_EVENT__`, the Tesana page above
|
|
638
|
+
// the game buffers `tesana-gameplay-event` messages and files them against the
|
|
639
|
+
// play session with a token only it holds, and the ingest endpoint stores them.
|
|
640
|
+
// These functions are the game's end of that pipe.
|
|
641
|
+
//
|
|
642
|
+
// Three rules shape everything below:
|
|
643
|
+
//
|
|
644
|
+
// - **Never throw.** The engine tells the AI to call these inline, at the
|
|
645
|
+
// moment a player dies, with no guard. A TypeError here would turn a death
|
|
646
|
+
// into a crash, so every entry point is wrapped and answers `false` when it
|
|
647
|
+
// drops something.
|
|
648
|
+
// - **Match the ingest endpoint's caps.** The server keeps at most 20 payload
|
|
649
|
+
// keys, 40-character keys, 64-character strings and 2,000 characters in all,
|
|
650
|
+
// and throws away the whole payload when it is over. Trimming here means a
|
|
651
|
+
// slightly-too-big event loses a key rather than all of its context.
|
|
652
|
+
// - **Discrete moments, not frames.** A game that reports on every frame
|
|
653
|
+
// would fill the table and answer nothing, so events are rate limited per
|
|
654
|
+
// second and capped per page load.
|
|
655
|
+
//
|
|
656
|
+
// They are synchronous and need no `init()`: they never touch the play service,
|
|
657
|
+
// only the page, so a game can report a level start on its first frame.
|
|
658
|
+
|
|
659
|
+
//: The message the host's play tracking listens for. Mirrored by the web host
|
|
660
|
+
//: (`PLAY_MESSAGES.gameplayEvent`) and the runtime shell.
|
|
661
|
+
const GAMEPLAY_MESSAGE = "tesana-gameplay-event";
|
|
662
|
+
|
|
663
|
+
//: The event types the ingest endpoint understands, minus the two the shell
|
|
664
|
+
//: owns (`session_end`, `error`). `track()` with one of these names files the
|
|
665
|
+
//: event under that type; any other name is a `custom` event.
|
|
666
|
+
const GAMEPLAY_EVENT_TYPES = new Set(["level_start", "level_complete", "death", "checkpoint", "score"]);
|
|
667
|
+
const GAMEPLAY_TYPE_ALIASES = { levelStart: "level_start", levelComplete: "level_complete" };
|
|
668
|
+
|
|
669
|
+
//: The ingest endpoint's own limits (`games/api/play_events.py`).
|
|
670
|
+
const GAMEPLAY_NAME_MAX = 64;
|
|
671
|
+
const GAMEPLAY_STRING_MAX = 64;
|
|
672
|
+
const GAMEPLAY_KEY_MAX = 40;
|
|
673
|
+
const GAMEPLAY_PROPS_MAX_KEYS = 20;
|
|
674
|
+
const GAMEPLAY_PROPS_MAX_CHARS = 2000;
|
|
675
|
+
|
|
676
|
+
//: At most this many events in any one second. Ten a second is exactly what
|
|
677
|
+
//: one host flush can carry (100 events every 10 seconds), and far above what a
|
|
678
|
+
//: game reporting real moments ever needs.
|
|
679
|
+
const GAMEPLAY_RATE_PER_SECOND = 10;
|
|
680
|
+
//: At most this many events per page load. An honest session sends tens; a
|
|
681
|
+
//: game stuck reporting at the rate limit for an hour would send 36,000.
|
|
682
|
+
const GAMEPLAY_SESSION_MAX = 1000;
|
|
683
|
+
|
|
684
|
+
//: Props promoted to the event's own columns rather than kept as context.
|
|
685
|
+
const GAMEPLAY_LEVEL_ID_KEYS = ["levelId", "level_id", "level"];
|
|
686
|
+
const GAMEPLAY_LEVEL_INDEX_KEYS = ["levelIndex", "level_index"];
|
|
687
|
+
const GAMEPLAY_NUMBER_KEYS = ["x", "y", "z", "value"];
|
|
688
|
+
const GAMEPLAY_PROMOTED = new Set([
|
|
689
|
+
...GAMEPLAY_LEVEL_ID_KEYS,
|
|
690
|
+
...GAMEPLAY_LEVEL_INDEX_KEYS,
|
|
691
|
+
...GAMEPLAY_NUMBER_KEYS,
|
|
692
|
+
"name",
|
|
693
|
+
]);
|
|
694
|
+
|
|
695
|
+
let gameplaySecond = -1;
|
|
696
|
+
let gameplayInSecond = 0;
|
|
697
|
+
let gameplaySent = 0;
|
|
698
|
+
|
|
699
|
+
/** A finite number, or `undefined`. Numeric strings count; NaN never does. */
|
|
700
|
+
function gameplayNumber(value) {
|
|
701
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
|
|
702
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
703
|
+
const parsed = Number(value);
|
|
704
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
705
|
+
}
|
|
706
|
+
return undefined;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** A trimmed, capped string id from a string or a number, or `undefined`. */
|
|
710
|
+
function gameplayId(value, limit) {
|
|
711
|
+
if (typeof value === "number") {
|
|
712
|
+
if (!Number.isFinite(value)) return undefined;
|
|
713
|
+
return String(value).slice(0, limit);
|
|
714
|
+
}
|
|
715
|
+
if (typeof value !== "string") return undefined;
|
|
716
|
+
const text = value.trim().slice(0, limit);
|
|
717
|
+
return text || undefined;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** The first of `keys` that `props` holds a value for. */
|
|
721
|
+
function gameplayFirst(props, keys) {
|
|
722
|
+
for (const key of keys) {
|
|
723
|
+
if (props[key] !== undefined && props[key] !== null) return props[key];
|
|
724
|
+
}
|
|
725
|
+
return undefined;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Roughly how long the server's `repr()` of this payload will be.
|
|
730
|
+
*
|
|
731
|
+
* The endpoint measures Python's `repr`, not JSON: it writes `1.0` for `1` and
|
|
732
|
+
* puts a space after each colon and comma. Four characters per key is a safe
|
|
733
|
+
* margin over both differences, so a payload that passes here passes there.
|
|
734
|
+
*/
|
|
735
|
+
function gameplayPayloadSize(payload, keys) {
|
|
736
|
+
return JSON.stringify(payload).length + 4 * keys;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Keep the scalar context a game passed, within the endpoint's caps.
|
|
741
|
+
*
|
|
742
|
+
* @param {object} props
|
|
743
|
+
* @param {boolean} keepName keep a `name` prop as context (a custom event, whose
|
|
744
|
+
* own name is the event's name) rather than treating it as the name column
|
|
745
|
+
*/
|
|
746
|
+
function gameplayPayload(props, keepName) {
|
|
747
|
+
const payload = {};
|
|
748
|
+
let kept = 0;
|
|
749
|
+
for (const rawKey of Object.keys(props)) {
|
|
750
|
+
if (kept >= GAMEPLAY_PROPS_MAX_KEYS) break;
|
|
751
|
+
if (GAMEPLAY_PROMOTED.has(rawKey) && !(keepName && rawKey === "name")) continue;
|
|
752
|
+
const key = String(rawKey).trim().slice(0, GAMEPLAY_KEY_MAX);
|
|
753
|
+
if (!key || Object.prototype.hasOwnProperty.call(payload, key)) continue;
|
|
754
|
+
let value;
|
|
755
|
+
try {
|
|
756
|
+
value = props[rawKey];
|
|
757
|
+
} catch {
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
if (typeof value === "string") {
|
|
761
|
+
value = value.slice(0, GAMEPLAY_STRING_MAX);
|
|
762
|
+
} else if (typeof value === "number") {
|
|
763
|
+
if (!Number.isFinite(value)) continue;
|
|
764
|
+
} else if (typeof value !== "boolean" && value !== null) {
|
|
765
|
+
// Objects, arrays, functions, undefined, symbols and bigints are not
|
|
766
|
+
// stored: the analytics layer queries flat keys, and a nested shape it
|
|
767
|
+
// did not expect is a foot-gun rather than a feature.
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
payload[key] = value;
|
|
771
|
+
if (gameplayPayloadSize(payload, kept + 1) > GAMEPLAY_PROPS_MAX_CHARS) {
|
|
772
|
+
delete payload[key];
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
kept += 1;
|
|
776
|
+
}
|
|
777
|
+
return kept ? payload : null;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Build the event the host files, in the shape `POST /play/events` accepts.
|
|
782
|
+
*
|
|
783
|
+
* @param {string} type one of the ingest endpoint's event types
|
|
784
|
+
* @param {string|undefined} name the event's own name (a custom event, a death's cause)
|
|
785
|
+
* @param {object} props what the game passed
|
|
786
|
+
*/
|
|
787
|
+
function gameplayEvent(type, name, props) {
|
|
788
|
+
const event = { type };
|
|
789
|
+
const named = name !== undefined ? name : gameplayId(props.name, GAMEPLAY_NAME_MAX);
|
|
790
|
+
if (named) event.name = named;
|
|
791
|
+
const levelId = gameplayId(gameplayFirst(props, GAMEPLAY_LEVEL_ID_KEYS), GAMEPLAY_STRING_MAX);
|
|
792
|
+
if (levelId) event.level_id = levelId;
|
|
793
|
+
const levelIndex = gameplayNumber(gameplayFirst(props, GAMEPLAY_LEVEL_INDEX_KEYS));
|
|
794
|
+
if (levelIndex !== undefined) event.level_index = Math.max(0, Math.floor(levelIndex));
|
|
795
|
+
for (const key of GAMEPLAY_NUMBER_KEYS) {
|
|
796
|
+
const number = gameplayNumber(props[key]);
|
|
797
|
+
if (number !== undefined) event[key] = number;
|
|
798
|
+
}
|
|
799
|
+
// A custom event's own name is already its name, so a `name` prop is kept as
|
|
800
|
+
// context like any other key instead of overwriting it.
|
|
801
|
+
const payload = gameplayPayload(props, type === "custom");
|
|
802
|
+
if (payload) event.payload = payload;
|
|
803
|
+
event.ts = Date.now();
|
|
804
|
+
return event;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** Whether one more event fits this second and this page load. */
|
|
808
|
+
function gameplayAllowed() {
|
|
809
|
+
if (gameplaySent >= GAMEPLAY_SESSION_MAX) {
|
|
810
|
+
warnOnce(
|
|
811
|
+
"gameplay-session-max",
|
|
812
|
+
`tesana.track: ${GAMEPLAY_SESSION_MAX} events this session; further events are dropped. Report discrete moments (a level, a death, a run's end), never every frame.`,
|
|
813
|
+
);
|
|
814
|
+
return false;
|
|
815
|
+
}
|
|
816
|
+
const second = Math.floor(Date.now() / 1000);
|
|
817
|
+
if (second !== gameplaySecond) {
|
|
818
|
+
gameplaySecond = second;
|
|
819
|
+
gameplayInSecond = 0;
|
|
820
|
+
}
|
|
821
|
+
if (gameplayInSecond >= GAMEPLAY_RATE_PER_SECOND) {
|
|
822
|
+
warnOnce(
|
|
823
|
+
"gameplay-rate",
|
|
824
|
+
`tesana.track: more than ${GAMEPLAY_RATE_PER_SECOND} events in one second; the extra ones are dropped. Report discrete moments, never every frame.`,
|
|
825
|
+
);
|
|
826
|
+
return false;
|
|
827
|
+
}
|
|
828
|
+
gameplayInSecond += 1;
|
|
829
|
+
gameplaySent += 1;
|
|
830
|
+
return true;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Hand an event to whoever files it. `true` when it was handed on.
|
|
835
|
+
*
|
|
836
|
+
* The shell's `__TESANA_EVENT__` comes first: it posts the same message, and
|
|
837
|
+
* going through it lets the shell name the last level in its `session_end`.
|
|
838
|
+
* Without the shell, the message goes straight to the Tesana page above. With
|
|
839
|
+
* neither there is nobody to tell, and the event is dropped.
|
|
840
|
+
*/
|
|
841
|
+
function gameplayDeliver(event) {
|
|
842
|
+
if (typeof window === "undefined") return false;
|
|
843
|
+
const bridge = window.__TESANA_EVENT__;
|
|
844
|
+
if (typeof bridge === "function") {
|
|
845
|
+
bridge(event);
|
|
846
|
+
return true;
|
|
847
|
+
}
|
|
848
|
+
if (!hasHost()) return false;
|
|
849
|
+
post({ type: GAMEPLAY_MESSAGE, event });
|
|
850
|
+
return true;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** The game's props as a plain object, whatever it actually passed. */
|
|
854
|
+
function gameplayProps(props) {
|
|
855
|
+
return props && typeof props === "object" && !Array.isArray(props) ? props : {};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Report something that happened in the game.
|
|
860
|
+
*
|
|
861
|
+
* `name` is the game's own name for it (`"powerup_picked"`). A name that is one
|
|
862
|
+
* of the platform's types (`"death"`, `"level_start"`, `"level_complete"`,
|
|
863
|
+
* `"checkpoint"`, `"score"`) is filed as that type. `props` is a small object of
|
|
864
|
+
* scalars; `levelId`, `levelIndex`, `x`, `y`, `z` and `value` become columns and
|
|
865
|
+
* anything else rides along as context.
|
|
866
|
+
*
|
|
867
|
+
* Synchronous, never throws, and never returns a promise: `true` when the event
|
|
868
|
+
* was handed on, `false` when it was dropped (a bad name, too many events, or
|
|
869
|
+
* nothing above the game to hand it to).
|
|
870
|
+
*
|
|
871
|
+
* @param {string} name
|
|
872
|
+
* @param {Record<string, string|number|boolean|null>} [props]
|
|
873
|
+
* @returns {boolean}
|
|
874
|
+
*/
|
|
875
|
+
function track(name, props) {
|
|
876
|
+
try {
|
|
877
|
+
const label = gameplayId(name, GAMEPLAY_NAME_MAX);
|
|
878
|
+
if (!label) {
|
|
879
|
+
warnOnce("gameplay-name", "tesana.track: the first argument must be a non-empty event name.");
|
|
880
|
+
return false;
|
|
881
|
+
}
|
|
882
|
+
if (!gameplayAllowed()) return false;
|
|
883
|
+
const builtIn = GAMEPLAY_TYPE_ALIASES[label] || label;
|
|
884
|
+
const event = GAMEPLAY_EVENT_TYPES.has(builtIn)
|
|
885
|
+
? gameplayEvent(builtIn, undefined, gameplayProps(props))
|
|
886
|
+
: gameplayEvent("custom", label, gameplayProps(props));
|
|
887
|
+
return gameplayDeliver(event);
|
|
888
|
+
} catch {
|
|
889
|
+
return false;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* One gameplay helper. `props` may also be a bare level id (`levelStart(3)`), or
|
|
895
|
+
* for `score` a bare value (`score(1200)`).
|
|
896
|
+
*
|
|
897
|
+
* @param {string} type
|
|
898
|
+
* @param {"levelId"|"value"} shorthand
|
|
899
|
+
*/
|
|
900
|
+
function gameplayHelper(type, shorthand) {
|
|
901
|
+
return function report(props) {
|
|
902
|
+
try {
|
|
903
|
+
if (!gameplayAllowed()) return false;
|
|
904
|
+
const given = typeof props === "string" || typeof props === "number"
|
|
905
|
+
? { [shorthand]: props }
|
|
906
|
+
: gameplayProps(props);
|
|
907
|
+
return gameplayDeliver(gameplayEvent(type, undefined, given));
|
|
908
|
+
} catch {
|
|
909
|
+
return false;
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* The progression events every game with levels, waves, runs or deaths should
|
|
916
|
+
* send. `levelId` is the join key: give it the same stable value in each call
|
|
917
|
+
* for one level, so a funnel can line them up.
|
|
918
|
+
*/
|
|
919
|
+
const gameplay = Object.freeze({
|
|
920
|
+
/** A level, stage, wave or area begins: `{ levelId, levelIndex? }`. */
|
|
921
|
+
levelStart: gameplayHelper("level_start", "levelId"),
|
|
922
|
+
/** A level is cleared: `{ levelId }`. */
|
|
923
|
+
levelComplete: gameplayHelper("level_complete", "levelId"),
|
|
924
|
+
/** The player died: `{ levelId, x?, y?, z?, name? }` (`name` is the cause). */
|
|
925
|
+
death: gameplayHelper("death", "levelId"),
|
|
926
|
+
/** A checkpoint is reached: `{ levelId, name? }`. */
|
|
927
|
+
checkpoint: gameplayHelper("checkpoint", "levelId"),
|
|
928
|
+
/** A run ended: `{ value, levelId? }`. */
|
|
929
|
+
score: gameplayHelper("score", "value"),
|
|
930
|
+
});
|
|
931
|
+
|
|
456
932
|
/**
|
|
457
933
|
* @param {TesanaConfig} [config]
|
|
458
934
|
*/
|
|
@@ -491,6 +967,16 @@ async function init(config = {}) {
|
|
|
491
967
|
} catch (err) {
|
|
492
968
|
client.goOffline(err);
|
|
493
969
|
}
|
|
970
|
+
// Localization comes after identity, not before: the bundle is fetched with
|
|
971
|
+
// the player's token, so a game that just minted one would otherwise load it
|
|
972
|
+
// unauthenticated and stay in its source language all session. Awaited here
|
|
973
|
+
// so `await __TESANA_READY__` is genuinely enough — the first frame a game
|
|
974
|
+
// draws can already be translated.
|
|
975
|
+
try {
|
|
976
|
+
await client.l10n.reload();
|
|
977
|
+
} catch {
|
|
978
|
+
/* localization must never block the game's start */
|
|
979
|
+
}
|
|
494
980
|
window.__TESANA_PLAYER__ = client.player.me();
|
|
495
981
|
// A provisional "offline" answer can upgrade later. Re-point this client in
|
|
496
982
|
// place — the game holds a reference to it — and re-run the identity step,
|
|
@@ -498,7 +984,7 @@ async function init(config = {}) {
|
|
|
498
984
|
// player with a balance frozen at zero.
|
|
499
985
|
const finishUpgrade = () => {
|
|
500
986
|
client.configure(buildConfig(mergedConfig()));
|
|
501
|
-
client.ensureSession();
|
|
987
|
+
client.ensureSession().then(() => client.l10n.reload());
|
|
502
988
|
window.__TESANA_PLAYER__ = client.player.me();
|
|
503
989
|
};
|
|
504
990
|
onBootstrapUpgrade(finishUpgrade);
|
|
@@ -561,6 +1047,18 @@ function buildConfig(merged) {
|
|
|
561
1047
|
adsEnabled: merged.adsEnabled,
|
|
562
1048
|
adFormats: merged.adFormats || merged.ad_formats || [],
|
|
563
1049
|
adMinIntervalMs: merged.adMinIntervalMs ?? merged.ad_min_interval_ms,
|
|
1050
|
+
// The player's language, as the host resolved it. The host owns the
|
|
1051
|
+
// setting (the site has a language picker and the game does not), so this
|
|
1052
|
+
// is read from the bootstrap rather than sniffed here — a game that
|
|
1053
|
+
// guessed from `navigator.language` would disagree with the site the
|
|
1054
|
+
// player is reading. Absent means an older host: localization then falls
|
|
1055
|
+
// back to the source language and the game renders exactly as it did.
|
|
1056
|
+
locale: normalizeLocale(
|
|
1057
|
+
merged.locale || merged.language || merged.lang || (typeof navigator !== "undefined" && navigator.language) || "",
|
|
1058
|
+
),
|
|
1059
|
+
// How a dropped room socket is retried. A game can shrink the waits (a
|
|
1060
|
+
// test, or a game with unusual tolerance); the defaults are the fleet's.
|
|
1061
|
+
multiplayerReconnect: merged.multiplayerReconnect || undefined,
|
|
564
1062
|
};
|
|
565
1063
|
}
|
|
566
1064
|
|
|
@@ -572,7 +1070,7 @@ function defaultEndpoint() {
|
|
|
572
1070
|
|
|
573
1071
|
class TesanaClient {
|
|
574
1072
|
/**
|
|
575
|
-
* @param {{ endpoint: string, gameId: string, token: string, rail: string, deviceId: string, multiplayer?: boolean, saveEndpoint?: string, saveToken?: string, offline?: boolean, roomEndpoint?: string, roomToken?: string, player?: object|null, vanityAddress?: string, adsEnabled?: boolean }} opts
|
|
1073
|
+
* @param {{ endpoint: string, gameId: string, token: string, rail: string, deviceId: string, multiplayer?: boolean, saveEndpoint?: string, saveToken?: string, offline?: boolean, roomEndpoint?: string, roomToken?: string, player?: object|null, vanityAddress?: string, adsEnabled?: boolean, locale?: string }} opts
|
|
576
1074
|
*/
|
|
577
1075
|
constructor(opts) {
|
|
578
1076
|
this.endpoint = opts.endpoint;
|
|
@@ -615,12 +1113,18 @@ class TesanaClient {
|
|
|
615
1113
|
ws: null,
|
|
616
1114
|
handlers: [],
|
|
617
1115
|
roomId: null,
|
|
1116
|
+
// The current room's share code, or null for a public room.
|
|
1117
|
+
code: null,
|
|
618
1118
|
// Reconnect state. `wsUrl` is kept so a dropped socket can re-enter the
|
|
619
1119
|
// same room; `leaving` stops a deliberate leave from being retried.
|
|
620
1120
|
wsUrl: null,
|
|
621
1121
|
leaving: false,
|
|
622
1122
|
reconnectTimer: null,
|
|
623
1123
|
reconnectAttempts: 0,
|
|
1124
|
+
// The code of the last close we saw. 4401 means the grant was refused —
|
|
1125
|
+
// expired, most likely — and a fresh one has to be minted before the next
|
|
1126
|
+
// reconnect can succeed. Cleared once a reconnect has used it.
|
|
1127
|
+
lastCloseCode: null,
|
|
624
1128
|
reconnectExhausted: false,
|
|
625
1129
|
statusHandlers: [],
|
|
626
1130
|
// Overridable so a test (or a game with unusual tolerance) can shorten
|
|
@@ -827,6 +1331,22 @@ class TesanaClient {
|
|
|
827
1331
|
// same object in place, rather than the game holding a stale policy.
|
|
828
1332
|
adClient = this.ads;
|
|
829
1333
|
|
|
1334
|
+
// Localization. A generated game draws its text onto a canvas, so the
|
|
1335
|
+
// platform cannot translate the running game the way it translates the
|
|
1336
|
+
// site's own pages — the game has to ask, and this is the surface it asks
|
|
1337
|
+
// through. The same one rule as the shop: an unreachable service costs a
|
|
1338
|
+
// translation and never the game. Every method here has a source-language
|
|
1339
|
+
// answer, and `t()` is synchronous, so a menu never waits on a network to
|
|
1340
|
+
// draw itself in the player's language.
|
|
1341
|
+
this.l10n = this._makeL10n(opts);
|
|
1342
|
+
l10nClient = this.l10n;
|
|
1343
|
+
|
|
1344
|
+
// Gameplay analytics. The same functions as `Tesana.track` and
|
|
1345
|
+
// `Tesana.gameplay` on the global, so a game can call them before its
|
|
1346
|
+
// `await __TESANA_READY__` resolves; none of them depends on this client.
|
|
1347
|
+
this.track = track;
|
|
1348
|
+
this.gameplay = gameplay;
|
|
1349
|
+
|
|
830
1350
|
this.multiplayer = {
|
|
831
1351
|
// Read this before drawing a lobby: when it is false the game is not
|
|
832
1352
|
// allowed rooms yet and the calls below refuse.
|
|
@@ -843,14 +1363,42 @@ class TesanaClient {
|
|
|
843
1363
|
.then((r) => r.rooms)
|
|
844
1364
|
.catch(() => [])
|
|
845
1365
|
: Promise.resolve([]),
|
|
1366
|
+
// Open a room. It is public and always joinable unless you pass a `code`,
|
|
1367
|
+
// which is the only way entry is restricted: the room is then unlisted and
|
|
1368
|
+
// only someone with the code can reach it. The resolved code is on
|
|
1369
|
+
// `room.code` in the result (the service generates one if you pass
|
|
1370
|
+
// `code: true`), so show it to the room.
|
|
846
1371
|
create: (opts) =>
|
|
847
1372
|
this._mpEnabled
|
|
848
1373
|
? this._mpEnter(this._mpRequest("POST", "/v1/mp/rooms", opts || {}))
|
|
849
1374
|
: this._mpRefuse(),
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
1375
|
+
// Accept the room a game got from list() as well as from a session. Both
|
|
1376
|
+
// read as "the room to join": `list()` rows are keyed `id`, a
|
|
1377
|
+
// create/quickJoin/join result carries `roomId`. Taking an object here is
|
|
1378
|
+
// what stops a lobby that calls `join(room)` from having to know which.
|
|
1379
|
+
join: (room) => {
|
|
1380
|
+
if (!this._mpEnabled) return this._mpRefuse();
|
|
1381
|
+
const roomId =
|
|
1382
|
+
room && typeof room === "object" ? room.id || room.roomId : room;
|
|
1383
|
+
// The bug this guards: a game read `room.roomId` off a list() row,
|
|
1384
|
+
// where it is undefined, and we asked the service for the room named
|
|
1385
|
+
// "undefined" — a 404 indistinguishable from a deleted room. Fail
|
|
1386
|
+
// here, by name, so the mistake is legible.
|
|
1387
|
+
if (roomId == null || roomId === "") {
|
|
1388
|
+
const err = new Error(
|
|
1389
|
+
"join() needs a room id — pass a room from list() (its `id`) or a create/join result",
|
|
1390
|
+
);
|
|
1391
|
+
err.code = "mp_room_id_required";
|
|
1392
|
+
return Promise.reject(err);
|
|
1393
|
+
}
|
|
1394
|
+
return this._mpEnter(
|
|
1395
|
+
this._mpRequest("POST", `/v1/mp/rooms/${enc(roomId)}/join`),
|
|
1396
|
+
);
|
|
1397
|
+
},
|
|
1398
|
+
// Join the fullest public room with a free seat, creating one if there is
|
|
1399
|
+
// none. Pass `code` to enter a private room instead: the first player to
|
|
1400
|
+
// use a code opens that room and the rest join it, and a full one refuses
|
|
1401
|
+
// rather than opening a second room under the same code.
|
|
854
1402
|
quickJoin: (opts) =>
|
|
855
1403
|
this._mpEnabled
|
|
856
1404
|
? this._mpEnter(this._mpRequest("POST", "/v1/mp/quick-join", opts || {}))
|
|
@@ -1027,17 +1575,83 @@ class TesanaClient {
|
|
|
1027
1575
|
* @param {Promise<{ wsUrl: string, roomId?: string, room?: object, playerId?: string }>} session
|
|
1028
1576
|
*/
|
|
1029
1577
|
async _mpEnter(session) {
|
|
1030
|
-
const joined = await session;
|
|
1031
|
-
|
|
1578
|
+
const joined = await this._mpAwaitPlacement(await session);
|
|
1579
|
+
// A room that was opened and then could not be entered is reported as what
|
|
1580
|
+
// it is: the room exists. The SDK cannot seat the player — that is the
|
|
1581
|
+
// host's job — but it can name the room so a game retries `join(roomId)`
|
|
1582
|
+
// instead of opening a second one and stranding the first.
|
|
1583
|
+
try {
|
|
1584
|
+
await this._mpOpen(joined.wsUrl);
|
|
1585
|
+
} catch (err) {
|
|
1586
|
+
const roomId = joined.roomId || joined.room?.id || null;
|
|
1587
|
+
const wrapped = new Error(
|
|
1588
|
+
roomId ? `${err?.message || "multiplayer unavailable"} (room ${roomId})` : err?.message || "multiplayer unavailable",
|
|
1589
|
+
);
|
|
1590
|
+
wrapped.code = err?.code || "mp_enter_failed";
|
|
1591
|
+
wrapped.roomId = roomId;
|
|
1592
|
+
throw wrapped;
|
|
1593
|
+
}
|
|
1032
1594
|
this._mp.roomId = joined.roomId || joined.room?.id || null;
|
|
1595
|
+
this._mp.code = joined.room?.code || null;
|
|
1033
1596
|
return {
|
|
1034
1597
|
wsUrl: joined.wsUrl,
|
|
1035
1598
|
roomId: this._mp.roomId,
|
|
1599
|
+
code: this._mp.code,
|
|
1036
1600
|
playerId: joined.playerId || this._player?.id,
|
|
1037
1601
|
room: joined.room,
|
|
1038
1602
|
};
|
|
1039
1603
|
}
|
|
1040
1604
|
|
|
1605
|
+
/**
|
|
1606
|
+
* Wait out a room that is still being placed, then hand back its session.
|
|
1607
|
+
*
|
|
1608
|
+
* Room creation on GameLift outlives a single HTTP request — a cold fleet can
|
|
1609
|
+
* take minutes — so the service answers 202 with the room's identity, the
|
|
1610
|
+
* caller's seat, and `retryAfterMs`, and no `wsUrl`. Treating that as a failed
|
|
1611
|
+
* create is what used to leave a ghost: the room existed, its creator was
|
|
1612
|
+
* seated, and the client believed nothing had happened. So the client polls
|
|
1613
|
+
* the same room it was handed (`join` re-issues the seat, it does not take a
|
|
1614
|
+
* second) until the address exists or the wait budget runs out.
|
|
1615
|
+
*
|
|
1616
|
+
* The budget is the client's own. A game that would rather show a spinner and
|
|
1617
|
+
* decide for itself calls `join(roomId)` after `create` rejects with
|
|
1618
|
+
* `mp_room_placing`, and this consumes the same code path.
|
|
1619
|
+
*
|
|
1620
|
+
* @param {{ wsUrl?: string, roomId?: string, state?: string, retryAfterMs?: number, room?: object }} session
|
|
1621
|
+
*/
|
|
1622
|
+
async _mpAwaitPlacement(session) {
|
|
1623
|
+
if (!session || session.wsUrl || session.state !== "placing") return session;
|
|
1624
|
+
const roomId = session.roomId || session.room?.id;
|
|
1625
|
+
if (!roomId) return session;
|
|
1626
|
+
this._mpEmitStatus({ status: "placing", roomId });
|
|
1627
|
+
// Instance-overridable so a game (or a test) can decide how long it will
|
|
1628
|
+
// wait for placement without reaching into module scope.
|
|
1629
|
+
const budget = Number(this._mpPlacingTimeoutMs) || MP_PLACING_TIMEOUT_MS;
|
|
1630
|
+
const deadline = Date.now() + budget;
|
|
1631
|
+
let current = session;
|
|
1632
|
+
while (!current?.wsUrl && Date.now() < deadline) {
|
|
1633
|
+
const gap = Math.max(250, Number(current.retryAfterMs) || MP_PLACING_RETRY_MS);
|
|
1634
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(gap, Math.max(0, deadline - Date.now()))));
|
|
1635
|
+
if (Date.now() >= deadline) break;
|
|
1636
|
+
try {
|
|
1637
|
+
current = await this._mpRequest("POST", `/v1/mp/rooms/${enc(roomId)}/join`, {});
|
|
1638
|
+
} catch (err) {
|
|
1639
|
+
// A 404 here is a room that ended between polls, which is a real
|
|
1640
|
+
// failure; anything else is worth another try until the budget is gone.
|
|
1641
|
+
if (err?.status === 404) throw err;
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
if (!current?.wsUrl) {
|
|
1645
|
+
// The room is still real, so the code names it and says so — a game can
|
|
1646
|
+
// choose to keep waiting with `join(roomId)` rather than start over.
|
|
1647
|
+
const err = new Error("Room is still starting");
|
|
1648
|
+
err.code = "mp_room_placing";
|
|
1649
|
+
err.roomId = roomId;
|
|
1650
|
+
throw err;
|
|
1651
|
+
}
|
|
1652
|
+
return current;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1041
1655
|
/**
|
|
1042
1656
|
* @param {string} wsUrl
|
|
1043
1657
|
*/
|
|
@@ -1096,13 +1710,22 @@ class TesanaClient {
|
|
|
1096
1710
|
if (this._mp.ws === ws) this._mp.ws = null;
|
|
1097
1711
|
if (!opened) {
|
|
1098
1712
|
clearTimeout(timer);
|
|
1099
|
-
|
|
1713
|
+
const err = new Error(event.reason || "multiplayer closed");
|
|
1714
|
+
// The code is carried, not just the reason: 4401 is an expired or
|
|
1715
|
+
// rejected grant, which a re-mint can fix, while 4403 is the game
|
|
1716
|
+
// being denied rooms, which it cannot. Recovery keys off this.
|
|
1717
|
+
err.code = event.code || 1006;
|
|
1718
|
+
// Recorded for the same reason: a reconnect whose fresh grant is
|
|
1719
|
+
// *also* refused must mint again rather than retry a dead URL.
|
|
1720
|
+
if (this._mp.wsUrl) this._mp.lastCloseCode = event.code || null;
|
|
1721
|
+
reject(err);
|
|
1100
1722
|
return;
|
|
1101
1723
|
}
|
|
1102
1724
|
// The socket dropped after we were in the room. That is a scale-in,
|
|
1103
1725
|
// a deploy, or a network blip — none of which the player caused, and
|
|
1104
1726
|
// all of which used to leave them silently alone in a match. Retry
|
|
1105
1727
|
// unless they actually meant to leave.
|
|
1728
|
+
this._mp.lastCloseCode = event.code || null;
|
|
1106
1729
|
this._mpScheduleReconnect();
|
|
1107
1730
|
};
|
|
1108
1731
|
});
|
|
@@ -1134,7 +1757,7 @@ class TesanaClient {
|
|
|
1134
1757
|
mp.reconnectTimer = setTimeout(() => {
|
|
1135
1758
|
mp.reconnectTimer = null;
|
|
1136
1759
|
if (mp.leaving) return;
|
|
1137
|
-
this.
|
|
1760
|
+
this._mpReconnectAttempt().catch(() => {
|
|
1138
1761
|
// Still not up — schedule the next attempt, which is where the
|
|
1139
1762
|
// attempt count grows and the backoff widens.
|
|
1140
1763
|
this._mpScheduleReconnect();
|
|
@@ -1142,6 +1765,56 @@ class TesanaClient {
|
|
|
1142
1765
|
}, delay);
|
|
1143
1766
|
}
|
|
1144
1767
|
|
|
1768
|
+
/**
|
|
1769
|
+
* One reconnect: get a current grant if the last one was refused, then try the
|
|
1770
|
+
* room again.
|
|
1771
|
+
*
|
|
1772
|
+
* The grant the client holds is embedded in `wsUrl` and was minted when it
|
|
1773
|
+
* first joined. A game open longer than the token's life therefore cannot
|
|
1774
|
+
* re-enter on a reconnect, and the backoff above only retries the same dead
|
|
1775
|
+
* URL — the player sits in "reconnecting" until they reload. Re-requesting the
|
|
1776
|
+
* room session mints a fresh URL, and `mpJoin` is idempotent, so asking as an
|
|
1777
|
+
* already-seated player re-issues the same seat rather than a second one.
|
|
1778
|
+
*
|
|
1779
|
+
* The fresh grant itself comes from the ordinary 401 path in `_send`: the join
|
|
1780
|
+
* goes out with the token the client already has, comes back refused, and is
|
|
1781
|
+
* retried once with one minted token. That is deliberate — it keeps the mint in
|
|
1782
|
+
* the one place that knows how to do it, so a stale socket and a stale HTTP
|
|
1783
|
+
* call recover identically and only ever ask the host once between them.
|
|
1784
|
+
*/
|
|
1785
|
+
async _mpReconnectAttempt() {
|
|
1786
|
+
const mp = this._mp;
|
|
1787
|
+
let wsUrl = mp.wsUrl;
|
|
1788
|
+
// 4401 — the grant was refused, so the URL itself is dead and a fresh one is
|
|
1789
|
+
// required. 4409 — the room is being re-placed and has no address yet, so
|
|
1790
|
+
// the URL the client holds is not usable either. Both are fixed by asking
|
|
1791
|
+
// for the room again; 4403 (the game is denied rooms) and 4404 (the room is
|
|
1792
|
+
// gone) are not, and fall through to the URL we have so the attempt is still
|
|
1793
|
+
// counted and the backoff still applies.
|
|
1794
|
+
if ((mp.lastCloseCode === 4401 || mp.lastCloseCode === 4409) && mp.roomId) {
|
|
1795
|
+
try {
|
|
1796
|
+
// `_mpAwaitPlacement` is harmless when the room is already up (it
|
|
1797
|
+
// returns the session untouched) and necessary when a reconnect lands
|
|
1798
|
+
// during a re-placement — a 4409 close, or a room swept and reopened,
|
|
1799
|
+
// answers with no `wsUrl`, and retrying the old URL would never work.
|
|
1800
|
+
const session = await this._mpAwaitPlacement(
|
|
1801
|
+
await this._mpRequest("POST", `/v1/mp/rooms/${enc(mp.roomId)}/join`),
|
|
1802
|
+
);
|
|
1803
|
+
if (session && session.wsUrl) {
|
|
1804
|
+
wsUrl = session.wsUrl;
|
|
1805
|
+
mp.wsUrl = wsUrl;
|
|
1806
|
+
mp.code = session.room?.code ?? mp.code;
|
|
1807
|
+
}
|
|
1808
|
+
} catch {
|
|
1809
|
+
// The room is gone, or the fresh grant was refused too. Fall through to
|
|
1810
|
+
// the URL we have: the room may have been replaced under the same id,
|
|
1811
|
+
// and if it has not, the next attempt retries this same path.
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
mp.lastCloseCode = null;
|
|
1815
|
+
return this._mpOpen(wsUrl);
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1145
1818
|
_mpEmitStatus(status) {
|
|
1146
1819
|
for (const fn of this._mp.statusHandlers) {
|
|
1147
1820
|
try {
|
|
@@ -1175,7 +1848,14 @@ class TesanaClient {
|
|
|
1175
1848
|
*/
|
|
1176
1849
|
_mpSend(data) {
|
|
1177
1850
|
const ws = this._mp.ws;
|
|
1178
|
-
if (!ws || ws.readyState !== 1)
|
|
1851
|
+
if (!ws || ws.readyState !== 1) {
|
|
1852
|
+
// Not a room failure — a socket that is between attempts. Tagged so a
|
|
1853
|
+
// game can tell it from a real refusal, and so a caller that has an
|
|
1854
|
+
// outbox can wait for the reconnect rather than drop the message.
|
|
1855
|
+
const err = new Error("Not in a room");
|
|
1856
|
+
err.code = "mp_not_connected";
|
|
1857
|
+
throw err;
|
|
1858
|
+
}
|
|
1179
1859
|
ws.send(JSON.stringify(data && typeof data === "object" ? data : { value: data }));
|
|
1180
1860
|
}
|
|
1181
1861
|
|
|
@@ -1209,9 +1889,11 @@ class TesanaClient {
|
|
|
1209
1889
|
this._mp.leaving = true;
|
|
1210
1890
|
this._mpClose();
|
|
1211
1891
|
this._mp.roomId = null;
|
|
1892
|
+
this._mp.code = null;
|
|
1212
1893
|
this._mp.wsUrl = null;
|
|
1213
1894
|
this._mp.reconnectAttempts = 0;
|
|
1214
1895
|
this._mp.reconnectExhausted = false;
|
|
1896
|
+
this._mp.lastCloseCode = null;
|
|
1215
1897
|
try {
|
|
1216
1898
|
if (!roomId) return { ok: true, room: null };
|
|
1217
1899
|
try {
|
|
@@ -1233,24 +1915,13 @@ class TesanaClient {
|
|
|
1233
1915
|
return this._send(this._baseFor(), this._authFor(), method, path, body);
|
|
1234
1916
|
}
|
|
1235
1917
|
|
|
1236
|
-
/**
|
|
1237
|
-
|
|
1238
|
-
return Boolean(this.offline && this.roomEndpoint && this.roomToken);
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
_mpRequest(method, path, body) {
|
|
1242
|
-
return this._roomsOnly()
|
|
1243
|
-
? this._send(this.roomEndpoint, this.roomToken, method, path, body)
|
|
1244
|
-
: this.request(method, path, body);
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
async _send(base, authToken, method, path, body) {
|
|
1918
|
+
/** One transport attempt, with no retry and no body parsing. */
|
|
1919
|
+
async _fetchSend(base, authToken, method, path, body) {
|
|
1248
1920
|
const headers = { "content-type": "application/json" };
|
|
1249
1921
|
if (authToken) headers.authorization = `Bearer ${authToken}`;
|
|
1250
1922
|
if (this.deviceId) headers["x-tesana-device"] = this.deviceId;
|
|
1251
|
-
let res;
|
|
1252
1923
|
try {
|
|
1253
|
-
|
|
1924
|
+
return await fetch(`${base}${path}`, {
|
|
1254
1925
|
method,
|
|
1255
1926
|
headers,
|
|
1256
1927
|
body: body == null || method === "GET" ? undefined : JSON.stringify(body),
|
|
@@ -1263,6 +1934,34 @@ class TesanaClient {
|
|
|
1263
1934
|
// still has, reported as missing.
|
|
1264
1935
|
throw err;
|
|
1265
1936
|
}
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
/** Whether multiplayer rides a rooms-only token while everything else is offline. */
|
|
1940
|
+
_roomsOnly() {
|
|
1941
|
+
return Boolean(this.offline && this.roomEndpoint && this.roomToken);
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
_mpRequest(method, path, body) {
|
|
1945
|
+
return this._roomsOnly()
|
|
1946
|
+
? this._send(this.roomEndpoint, this.roomToken, method, path, body)
|
|
1947
|
+
: this.request(method, path, body);
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
async _send(base, authToken, method, path, body) {
|
|
1951
|
+
let res = await this._fetchSend(base, authToken, method, path, body);
|
|
1952
|
+
// A refused credential is usually age, not refusal: the player token lives
|
|
1953
|
+
// two hours. Ask the host to re-mint and retry once with the new one. The
|
|
1954
|
+
// retry is guarded on the token actually changing, so a genuinely
|
|
1955
|
+
// unauthorized player fails exactly as they did before rather than looping.
|
|
1956
|
+
if (res.status === 401) {
|
|
1957
|
+
// The ask may land on a host we cannot reach, so `asked` is null and the
|
|
1958
|
+
// retry is skipped — the 401 below is thrown exactly as it always was.
|
|
1959
|
+
const asked = await requestHostBootstrap();
|
|
1960
|
+
const next = base === this.roomEndpoint ? this.roomToken : this._authFor();
|
|
1961
|
+
if (asked && next && next !== authToken) {
|
|
1962
|
+
res = await this._fetchSend(base, next, method, path, body);
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1266
1965
|
const data = await res.json().catch(() => ({}));
|
|
1267
1966
|
if (!res.ok) {
|
|
1268
1967
|
const error = new Error(data.message || res.statusText);
|
|
@@ -1307,6 +2006,13 @@ class TesanaClient {
|
|
|
1307
2006
|
minIntervalMs: config.adMinIntervalMs,
|
|
1308
2007
|
});
|
|
1309
2008
|
}
|
|
2009
|
+
// A later bootstrap may carry the player's language — a provisionally
|
|
2010
|
+
// "offline" answer has none, and the real one arrives a moment later. The
|
|
2011
|
+
// capability is re-pointed in place, exactly as the ad policy is.
|
|
2012
|
+
if (config.locale && this.l10n) {
|
|
2013
|
+
const next = normalizeLocale(config.locale);
|
|
2014
|
+
if (next && next !== this.l10n.locale()) this.l10n.applyLocale(next);
|
|
2015
|
+
}
|
|
1310
2016
|
this._mpEnabled = config.multiplayer !== false;
|
|
1311
2017
|
this.multiplayer.enabled = this._mpEnabled;
|
|
1312
2018
|
return this;
|
|
@@ -1540,6 +2246,300 @@ class TesanaClient {
|
|
|
1540
2246
|
};
|
|
1541
2247
|
}
|
|
1542
2248
|
|
|
2249
|
+
// -------------------------------------------------------------------- l10n
|
|
2250
|
+
//
|
|
2251
|
+
// Localization, from the game's side. The service owns the dictionary and the
|
|
2252
|
+
// translation; this owns one promise: a game that asks for its text in the
|
|
2253
|
+
// player's language gets something renderable, always.
|
|
2254
|
+
//
|
|
2255
|
+
// Deliberately synchronous where the game needs it to be. `t()` reads a map
|
|
2256
|
+
// that was filled before the first frame, so drawing a localized menu costs no
|
|
2257
|
+
// await and no round trip; only text the game discovers at runtime — a
|
|
2258
|
+
// procedurally generated item name, something the player typed — goes through
|
|
2259
|
+
// the async path. A game that never awaits anything still localizes.
|
|
2260
|
+
_makeL10n(opts) {
|
|
2261
|
+
const state = {
|
|
2262
|
+
locale: normalizeLocale(opts.locale || ""),
|
|
2263
|
+
// The language the game's own strings are written in. Declared by the
|
|
2264
|
+
// build; the service is told the same value, so the two agree rather
|
|
2265
|
+
// than one guessing what the other meant.
|
|
2266
|
+
source: "en",
|
|
2267
|
+
// The platform's list, refined by the service's answer when it arrives.
|
|
2268
|
+
languages: L10N_TARGETS.slice(),
|
|
2269
|
+
// Source text -> translated text, filled before `ready` resolves.
|
|
2270
|
+
bundle: {},
|
|
2271
|
+
// The game's own key -> source text, for `t("ui.play")`.
|
|
2272
|
+
index: {},
|
|
2273
|
+
handlers: [],
|
|
2274
|
+
// `requested`: what the game's source language resolved to (en, de, …)
|
|
2275
|
+
// vs `target`: what is actually being served — the difference is a
|
|
2276
|
+
// language the game did not declare, where the source is the answer.
|
|
2277
|
+
target: "",
|
|
2278
|
+
loaded: false,
|
|
2279
|
+
// The bundle is asked for from three places on a normal boot — `ready`,
|
|
2280
|
+
// `init`'s await, and the post-session reload — and all three want the
|
|
2281
|
+
// same one. `inflight` is the shared request; `loadedFor` is the target
|
|
2282
|
+
// that has already been answered, so a repeat is a no-op rather than a
|
|
2283
|
+
// second round trip for an object we already hold.
|
|
2284
|
+
inflight: null,
|
|
2285
|
+
inflightTarget: "",
|
|
2286
|
+
loadedFor: null,
|
|
2287
|
+
};
|
|
2288
|
+
|
|
2289
|
+
const self = this;
|
|
2290
|
+
|
|
2291
|
+
/** Emit a locale change to every listener, without letting one break the rest. */
|
|
2292
|
+
function emit() {
|
|
2293
|
+
const event = { locale: state.locale, target: state.target, languages: state.languages.slice() };
|
|
2294
|
+
for (const fn of [...state.handlers]) {
|
|
2295
|
+
try {
|
|
2296
|
+
fn(event);
|
|
2297
|
+
} catch {
|
|
2298
|
+
/* a game handler */
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
/**
|
|
2304
|
+
* Resolve `key` against the bundle.
|
|
2305
|
+
*
|
|
2306
|
+
* Three shapes, because a game reaches for all three: a key it declared
|
|
2307
|
+
* (`"ui.play"`), the source sentence itself (`"Play"`), and a key with a
|
|
2308
|
+
* fallback for the source text that was never declared. Whatever is not
|
|
2309
|
+
* found comes back as the fallback or the key, which is what makes an
|
|
2310
|
+
* unreachable service a non-event.
|
|
2311
|
+
*/
|
|
2312
|
+
function lookup(key, fallback) {
|
|
2313
|
+
const name = String(key == null ? "" : key);
|
|
2314
|
+
const source = state.index[name] || name;
|
|
2315
|
+
const hit = state.bundle[source];
|
|
2316
|
+
if (typeof hit === "string" && hit) return hit;
|
|
2317
|
+
if (typeof fallback === "string" && fallback) return fallback;
|
|
2318
|
+
return source;
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
/**
|
|
2322
|
+
* Load the bundle, at most once per (language, reachability) combination.
|
|
2323
|
+
*
|
|
2324
|
+
* A boot calls this from several independent places — the `ready` promise,
|
|
2325
|
+
* `init`'s await, and the post-session `reload` — and they all want the
|
|
2326
|
+
* same bundle. Coalescing them is not an optimization for its own sake:
|
|
2327
|
+
* every extra request is the player's bundle fetched twice, and a game
|
|
2328
|
+
* that redraws on `onChange` should not be able to multiply it.
|
|
2329
|
+
*/
|
|
2330
|
+
function load() {
|
|
2331
|
+
const target = self._canReach() && state.locale && state.locale !== state.source ? state.locale : "";
|
|
2332
|
+
if (state.inflight && state.inflightTarget === target) return state.inflight;
|
|
2333
|
+
if (state.loadedFor === target && state.loaded) return Promise.resolve(state);
|
|
2334
|
+
state.inflightTarget = target;
|
|
2335
|
+
state.inflight = fetchBundle(target).finally(() => {
|
|
2336
|
+
state.inflight = null;
|
|
2337
|
+
});
|
|
2338
|
+
return state.inflight;
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
async function fetchBundle(target) {
|
|
2342
|
+
if (!self._canReach()) {
|
|
2343
|
+
// No address, or no game id: nothing will ever answer, and asking
|
|
2344
|
+
// anyway would throw a relative-URL fetch. The source language is the
|
|
2345
|
+
// honest answer and the game renders exactly as it always did.
|
|
2346
|
+
state.loaded = true;
|
|
2347
|
+
state.loadedFor = target;
|
|
2348
|
+
return state;
|
|
2349
|
+
}
|
|
2350
|
+
try {
|
|
2351
|
+
if (!target) {
|
|
2352
|
+
// Nothing to fetch — and no request at all, either. A source-language
|
|
2353
|
+
// boot needs no bundle (nothing is translated) and no language list
|
|
2354
|
+
// (the platform's known set is already in memory), so a game with no
|
|
2355
|
+
// localization in force makes zero localization requests. That
|
|
2356
|
+
// matters beyond tidiness: it is the same rule as the rest of the
|
|
2357
|
+
// SDK, that a capability nobody asked for must not put bytes on the
|
|
2358
|
+
// wire, and it is what a game running entirely offline relies on.
|
|
2359
|
+
state.target = "";
|
|
2360
|
+
state.loaded = true;
|
|
2361
|
+
state.loadedFor = target;
|
|
2362
|
+
return state;
|
|
2363
|
+
}
|
|
2364
|
+
const data = await self.request(
|
|
2365
|
+
"GET",
|
|
2366
|
+
`/v1/l10n/bundle?target=${enc(target)}`,
|
|
2367
|
+
);
|
|
2368
|
+
if (data && typeof data.translations === "object" && data.translations) {
|
|
2369
|
+
state.bundle = data.translations;
|
|
2370
|
+
}
|
|
2371
|
+
if (data && typeof data.index === "object" && data.index) {
|
|
2372
|
+
state.index = data.index;
|
|
2373
|
+
}
|
|
2374
|
+
if (typeof data?.source === "string" && data.source) state.source = data.source;
|
|
2375
|
+
// `target` is the service's answer about what it actually served.
|
|
2376
|
+
// It is `""` for a language this game has not declared, which is the
|
|
2377
|
+
// signal that the source text IS the answer.
|
|
2378
|
+
state.target = typeof data?.target === "string" && data.target ? data.target : "";
|
|
2379
|
+
// The declared language set arrives with the bundle's own response
|
|
2380
|
+
// rather than a second route. It is one fact about one game, and two
|
|
2381
|
+
// requests to learn it is one more chance for a boot to be slower than
|
|
2382
|
+
// the first frame.
|
|
2383
|
+
if (Array.isArray(data?.languages) && data.languages.length) {
|
|
2384
|
+
// The game's own declared set, in the order it chose — which is how
|
|
2385
|
+
// a game that lists French first gets French first in its picker.
|
|
2386
|
+
state.languages = data.languages.slice();
|
|
2387
|
+
}
|
|
2388
|
+
} catch (err) {
|
|
2389
|
+
// An outage costs the translation, not the game. One deduped line is
|
|
2390
|
+
// enough to make it findable without spamming the console on retry.
|
|
2391
|
+
warnOnce(
|
|
2392
|
+
"l10n-unreachable",
|
|
2393
|
+
`Tesana localization: could not reach the translation service (${(err && err.message) || "request failed"}).`
|
|
2394
|
+
+ " The game keeps playing in its source language.",
|
|
2395
|
+
);
|
|
2396
|
+
}
|
|
2397
|
+
state.loaded = true;
|
|
2398
|
+
state.loadedFor = target;
|
|
2399
|
+
return state;
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
return {
|
|
2403
|
+
/**
|
|
2404
|
+
* The player's language, synchronously, as the host resolved it.
|
|
2405
|
+
*
|
|
2406
|
+
* Follows the same tag vocabulary the site uses (`pt-BR`, `zh`), not
|
|
2407
|
+
* AWS's, so a game can pass it straight to `Intl`/`toLocaleString`.
|
|
2408
|
+
*/
|
|
2409
|
+
locale: () => state.locale,
|
|
2410
|
+
/** The languages this game offers, the host's order preserved. */
|
|
2411
|
+
languages: () => state.languages.slice(),
|
|
2412
|
+
/** The language text is actually being served in, or `""` when it is the source. */
|
|
2413
|
+
target: () => state.target,
|
|
2414
|
+
/** The language the game's own strings are written in. */
|
|
2415
|
+
source: () => state.source,
|
|
2416
|
+
/** Whether the initial bundle has landed. `t()` works before it does. */
|
|
2417
|
+
get loaded() {
|
|
2418
|
+
return state.loaded;
|
|
2419
|
+
},
|
|
2420
|
+
|
|
2421
|
+
/**
|
|
2422
|
+
* The translated text for `key`, synchronously.
|
|
2423
|
+
*
|
|
2424
|
+
* Prefer this. It is filled before the first frame, so a game never has
|
|
2425
|
+
* to await anything to draw a localized menu — and when the bundle is
|
|
2426
|
+
* empty (a service outage, a language the game never declared) it returns
|
|
2427
|
+
* the source text, which is a rendered game rather than a blank one.
|
|
2428
|
+
*
|
|
2429
|
+
* @param {string} key a declared key, or the source sentence itself
|
|
2430
|
+
* @param {object|string} [vars] `{name}` values, or a fallback string
|
|
2431
|
+
*/
|
|
2432
|
+
t: (key, vars) => {
|
|
2433
|
+
// The second argument is both of the things a caller wants to pass:
|
|
2434
|
+
// placeholder values, and a fallback for an undeclared key. A string is
|
|
2435
|
+
// unambiguously a fallback; an object is unambiguously values.
|
|
2436
|
+
const values = vars && typeof vars === "object" ? vars : null;
|
|
2437
|
+
const fallback = typeof vars === "string" ? vars : "";
|
|
2438
|
+
return fillPlaceholders(lookup(key, fallback), values);
|
|
2439
|
+
},
|
|
2440
|
+
|
|
2441
|
+
/**
|
|
2442
|
+
* Translate text the game discovered while running.
|
|
2443
|
+
*
|
|
2444
|
+
* The one path that costs a round trip, so it is the one to keep off the
|
|
2445
|
+
* hot path: ask once, at the moment the text is created (a generated
|
|
2446
|
+
* name, a line of dialogue just chosen), never per frame. Resolves the
|
|
2447
|
+
* source text on any failure, so a `.then` never sees a rejection.
|
|
2448
|
+
*
|
|
2449
|
+
* @param {string} text
|
|
2450
|
+
* @param {{ id?: string, values?: Record<string, unknown>, target?: string }} [opts]
|
|
2451
|
+
*/
|
|
2452
|
+
translate: async (text, opts = {}) => {
|
|
2453
|
+
const values = opts.values && typeof opts.values === "object" ? opts.values : null;
|
|
2454
|
+
const target = opts.target || state.target || (state.locale !== state.source ? state.locale : "");
|
|
2455
|
+
// A named string is routed through the id-keyed path, which is what
|
|
2456
|
+
// lets the service remember it under the game's own name and serve it
|
|
2457
|
+
// from cache next time without even a hash lookup.
|
|
2458
|
+
if (opts.id && self._canReach() && target) {
|
|
2459
|
+
try {
|
|
2460
|
+
const data = await self.request("POST", "/v1/l10n/string", {
|
|
2461
|
+
id: String(opts.id),
|
|
2462
|
+
text: String(text == null ? "" : text),
|
|
2463
|
+
values: values || undefined,
|
|
2464
|
+
target,
|
|
2465
|
+
source: state.source,
|
|
2466
|
+
});
|
|
2467
|
+
if (data && typeof data.value === "string" && data.value) return data.value;
|
|
2468
|
+
} catch {
|
|
2469
|
+
/* falls through to the source text below */
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
// The anonymous path requires a target to be worth a call. With no
|
|
2473
|
+
// target this *is* the source language, and asking the server to hand
|
|
2474
|
+
// back what it was given is the most expensive no-op available.
|
|
2475
|
+
if (self._canReach() && target) {
|
|
2476
|
+
try {
|
|
2477
|
+
const data = await self.request("POST", "/v1/l10n/translate", {
|
|
2478
|
+
strings: [String(text == null ? "" : text)],
|
|
2479
|
+
target,
|
|
2480
|
+
source: state.source,
|
|
2481
|
+
});
|
|
2482
|
+
const hit = data?.translations?.[String(text)];
|
|
2483
|
+
if (typeof hit === "string" && hit) return fillPlaceholders(hit, values);
|
|
2484
|
+
} catch {
|
|
2485
|
+
/* ditto */
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
return fillPlaceholders(String(text == null ? "" : text), values);
|
|
2489
|
+
},
|
|
2490
|
+
|
|
2491
|
+
/**
|
|
2492
|
+
* Subscribe to a language change. Returns an unsubscribe function.
|
|
2493
|
+
*
|
|
2494
|
+
* A game should redraw its text (or call `t()` again) on this rather than
|
|
2495
|
+
* caching translated strings at startup, because the player can change
|
|
2496
|
+
* the site's language without reloading the frame.
|
|
2497
|
+
*
|
|
2498
|
+
* @param {(event: { locale: string, target: string, languages: string[] }) => void} fn
|
|
2499
|
+
*/
|
|
2500
|
+
onChange: (fn) => {
|
|
2501
|
+
if (typeof fn !== "function") return () => {};
|
|
2502
|
+
state.handlers.push(fn);
|
|
2503
|
+
return () => {
|
|
2504
|
+
state.handlers = state.handlers.filter((handler) => handler !== fn);
|
|
2505
|
+
};
|
|
2506
|
+
},
|
|
2507
|
+
|
|
2508
|
+
/** Resolves after the initial bundle. A game may await it with `__TESANA_READY__`. */
|
|
2509
|
+
ready: load(),
|
|
2510
|
+
|
|
2511
|
+
/**
|
|
2512
|
+
* Reload the bundle, after an identity or language change.
|
|
2513
|
+
*
|
|
2514
|
+
* The constructor loads immediately from whatever the page already
|
|
2515
|
+
* carries. A game that minted its own token — or a host whose real
|
|
2516
|
+
* bootstrap replaced a provisional "offline" one — has no credential that
|
|
2517
|
+
* first time, so the bundle would be empty and the game would stay in its
|
|
2518
|
+
* source language for the whole session. `init` awaits this after the
|
|
2519
|
+
* identity step, which is what makes the documented
|
|
2520
|
+
* `await window.__TESANA_READY__` sufficient for a localized first frame.
|
|
2521
|
+
*/
|
|
2522
|
+
reload: () => load(),
|
|
2523
|
+
|
|
2524
|
+
// ---- internal wiring (used by the bootstrap listener) ----
|
|
2525
|
+
/** A new language from the host. Resolves the same promise the game already holds. */
|
|
2526
|
+
applyLocale(next) {
|
|
2527
|
+
const locale = normalizeLocale(next);
|
|
2528
|
+
if (!locale || locale === state.locale) return state;
|
|
2529
|
+
state.locale = locale;
|
|
2530
|
+
// The old bundle is the *previous* language's, so it is cleared rather
|
|
2531
|
+
// than left to serve: showing a menu in a language the player just
|
|
2532
|
+
// switched away from is worse than showing the source text for the
|
|
2533
|
+
// moment the refill takes. `t()` falls back to source in between.
|
|
2534
|
+
state.bundle = {};
|
|
2535
|
+
state.target = "";
|
|
2536
|
+
emit();
|
|
2537
|
+
load().catch(() => {});
|
|
2538
|
+
return state;
|
|
2539
|
+
},
|
|
2540
|
+
};
|
|
2541
|
+
}
|
|
2542
|
+
|
|
1543
2543
|
// ------------------------------------------------------------------ saves
|
|
1544
2544
|
//
|
|
1545
2545
|
// Writes go to the local cache first, always, so a failed upload is a save
|
|
@@ -1681,7 +2681,7 @@ function enc(value) {
|
|
|
1681
2681
|
}
|
|
1682
2682
|
|
|
1683
2683
|
|
|
1684
|
-
var api = { init: init, TesanaClient: TesanaClient, version: VERSION };
|
|
2684
|
+
var api = { init: init, TesanaClient: TesanaClient, version: VERSION, track: track, gameplay: gameplay };
|
|
1685
2685
|
root.Tesana = api;
|
|
1686
2686
|
// A game's first line may be `await window.__TESANA_READY__`, the promise
|
|
1687
2687
|
// form the engine's skills teach, so start booting the moment this loads.
|