@tesana/sdk 1.0.3 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tesana.esm.js +452 -8
- package/dist/tesana.iife.js +453 -9
- package/package.json +1 -1
- package/src/tesana.js +452 -8
package/dist/tesana.iife.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* Tesana play-time SDK v1.0.
|
|
1
|
+
/* Tesana play-time SDK v1.0.4 */
|
|
2
2
|
(function (root) {
|
|
3
3
|
/**
|
|
4
4
|
* Tesana play-time SDK. Vanilla JS, no npm in games.
|
|
@@ -39,7 +39,7 @@
|
|
|
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
|
-
const VERSION = "1.0.
|
|
42
|
+
const VERSION = "1.0.4";
|
|
43
43
|
|
|
44
44
|
const LOCAL_CACHE = "tesana.play.v1";
|
|
45
45
|
|
|
@@ -199,6 +199,60 @@ function onBootstrapUpgrade(fn) {
|
|
|
199
199
|
*/
|
|
200
200
|
let adClient = null;
|
|
201
201
|
|
|
202
|
+
/**
|
|
203
|
+
* The live localization capability, kept module-scoped for the same reason as
|
|
204
|
+
* the ad client: the host can change the language at any moment, and the game
|
|
205
|
+
* holds a reference to `tesana.l10n` that must not be replaced underneath it.
|
|
206
|
+
* The listener calls `applyLocale`, which re-points this object in place.
|
|
207
|
+
*/
|
|
208
|
+
let l10nClient = null;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The languages the platform can translate into, in display order.
|
|
212
|
+
*
|
|
213
|
+
* Mirrors the service's own list, which is the authority; this copy exists so a
|
|
214
|
+
* language picker can be drawn before the service has answered — and so a page
|
|
215
|
+
* with no host at all, where nothing will ever answer, still has an honest
|
|
216
|
+
* list rather than an empty one.
|
|
217
|
+
*/
|
|
218
|
+
const L10N_TARGETS = ["en", "es", "fr", "de", "it", "pt-BR", "ru", "zh", "ja", "ko"];
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Fold whatever a host calls a language onto one of ours.
|
|
222
|
+
*
|
|
223
|
+
* `navigator.language` reports `en-US` and `pt-PT`; a site may store `pt` or
|
|
224
|
+
* `zh`; a player may be handed `de-AT`. None of those are in the list above and
|
|
225
|
+
* all of them mean a language we do translate into, so the base tag is what
|
|
226
|
+
* gets matched and the region is dropped — a game in `pt-BR` is more useful to
|
|
227
|
+
* a `pt-PT` player than one left in English. An unknown tag stays as it is and
|
|
228
|
+
* simply falls back to the source language at the call site.
|
|
229
|
+
*/
|
|
230
|
+
function normalizeLocale(tag) {
|
|
231
|
+
const value = String(tag || "").trim().replace(/_/g, "-");
|
|
232
|
+
if (!value) return "";
|
|
233
|
+
const exact = L10N_TARGETS.find((t) => t.toLowerCase() === value.toLowerCase());
|
|
234
|
+
if (exact) return exact;
|
|
235
|
+
const base = value.split("-")[0].toLowerCase();
|
|
236
|
+
return L10N_TARGETS.find((t) => t.split("-")[0].toLowerCase() === base) || value;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** `{name}` in `text`, filled from `values`. The one interpolation rule the SDK and the service share. */
|
|
240
|
+
function fillPlaceholders(text, values) {
|
|
241
|
+
const source = String(text == null ? "" : text);
|
|
242
|
+
if (!values || typeof values !== "object") return source;
|
|
243
|
+
return source.replace(/\{(\w+)\}/g, (whole, key) =>
|
|
244
|
+
Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : whole,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Fold a locale message from the host into the live client, if it is listening. */
|
|
249
|
+
function resyncLocale(data) {
|
|
250
|
+
if (!l10nClient) return;
|
|
251
|
+
const payload = data && typeof data.payload === "object" ? data.payload : data;
|
|
252
|
+
const next = payload && (payload.locale || payload.lang || payload.language);
|
|
253
|
+
if (next) l10nClient.applyLocale(next);
|
|
254
|
+
}
|
|
255
|
+
|
|
202
256
|
/** A policy message from the host, or `null` when none is in force. */
|
|
203
257
|
function adPolicyFrom(data) {
|
|
204
258
|
if (!data || typeof data !== "object") return null;
|
|
@@ -368,6 +422,19 @@ function startListening() {
|
|
|
368
422
|
}
|
|
369
423
|
return;
|
|
370
424
|
}
|
|
425
|
+
// The player changed the site's language. The host is the authority — it
|
|
426
|
+
// resolved which language the player wants, and it owns the setting — so
|
|
427
|
+
// this only carries it down. The game's own text re-renders when it draws
|
|
428
|
+
// again, which is why the skill tells games to redraw from `t()` rather
|
|
429
|
+
// than cache translated strings at startup.
|
|
430
|
+
if (data.type === "tesana-locale") {
|
|
431
|
+
try {
|
|
432
|
+
resyncLocale(data);
|
|
433
|
+
} catch {
|
|
434
|
+
/* a bad locale must never break the listener */
|
|
435
|
+
}
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
371
438
|
if (data.type === "tesana-ad-show") {
|
|
372
439
|
try {
|
|
373
440
|
const requestId = data.requestId || data.request_id;
|
|
@@ -491,6 +558,16 @@ async function init(config = {}) {
|
|
|
491
558
|
} catch (err) {
|
|
492
559
|
client.goOffline(err);
|
|
493
560
|
}
|
|
561
|
+
// Localization comes after identity, not before: the bundle is fetched with
|
|
562
|
+
// the player's token, so a game that just minted one would otherwise load it
|
|
563
|
+
// unauthenticated and stay in its source language all session. Awaited here
|
|
564
|
+
// so `await __TESANA_READY__` is genuinely enough — the first frame a game
|
|
565
|
+
// draws can already be translated.
|
|
566
|
+
try {
|
|
567
|
+
await client.l10n.reload();
|
|
568
|
+
} catch {
|
|
569
|
+
/* localization must never block the game's start */
|
|
570
|
+
}
|
|
494
571
|
window.__TESANA_PLAYER__ = client.player.me();
|
|
495
572
|
// A provisional "offline" answer can upgrade later. Re-point this client in
|
|
496
573
|
// place — the game holds a reference to it — and re-run the identity step,
|
|
@@ -498,7 +575,7 @@ async function init(config = {}) {
|
|
|
498
575
|
// player with a balance frozen at zero.
|
|
499
576
|
const finishUpgrade = () => {
|
|
500
577
|
client.configure(buildConfig(mergedConfig()));
|
|
501
|
-
client.ensureSession();
|
|
578
|
+
client.ensureSession().then(() => client.l10n.reload());
|
|
502
579
|
window.__TESANA_PLAYER__ = client.player.me();
|
|
503
580
|
};
|
|
504
581
|
onBootstrapUpgrade(finishUpgrade);
|
|
@@ -561,6 +638,15 @@ function buildConfig(merged) {
|
|
|
561
638
|
adsEnabled: merged.adsEnabled,
|
|
562
639
|
adFormats: merged.adFormats || merged.ad_formats || [],
|
|
563
640
|
adMinIntervalMs: merged.adMinIntervalMs ?? merged.ad_min_interval_ms,
|
|
641
|
+
// The player's language, as the host resolved it. The host owns the
|
|
642
|
+
// setting (the site has a language picker and the game does not), so this
|
|
643
|
+
// is read from the bootstrap rather than sniffed here — a game that
|
|
644
|
+
// guessed from `navigator.language` would disagree with the site the
|
|
645
|
+
// player is reading. Absent means an older host: localization then falls
|
|
646
|
+
// back to the source language and the game renders exactly as it did.
|
|
647
|
+
locale: normalizeLocale(
|
|
648
|
+
merged.locale || merged.language || merged.lang || (typeof navigator !== "undefined" && navigator.language) || "",
|
|
649
|
+
),
|
|
564
650
|
};
|
|
565
651
|
}
|
|
566
652
|
|
|
@@ -572,7 +658,7 @@ function defaultEndpoint() {
|
|
|
572
658
|
|
|
573
659
|
class TesanaClient {
|
|
574
660
|
/**
|
|
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
|
|
661
|
+
* @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
662
|
*/
|
|
577
663
|
constructor(opts) {
|
|
578
664
|
this.endpoint = opts.endpoint;
|
|
@@ -615,6 +701,8 @@ class TesanaClient {
|
|
|
615
701
|
ws: null,
|
|
616
702
|
handlers: [],
|
|
617
703
|
roomId: null,
|
|
704
|
+
// The current room's share code, or null for a public room.
|
|
705
|
+
code: null,
|
|
618
706
|
// Reconnect state. `wsUrl` is kept so a dropped socket can re-enter the
|
|
619
707
|
// same room; `leaving` stops a deliberate leave from being retried.
|
|
620
708
|
wsUrl: null,
|
|
@@ -827,6 +915,16 @@ class TesanaClient {
|
|
|
827
915
|
// same object in place, rather than the game holding a stale policy.
|
|
828
916
|
adClient = this.ads;
|
|
829
917
|
|
|
918
|
+
// Localization. A generated game draws its text onto a canvas, so the
|
|
919
|
+
// platform cannot translate the running game the way it translates the
|
|
920
|
+
// site's own pages — the game has to ask, and this is the surface it asks
|
|
921
|
+
// through. The same one rule as the shop: an unreachable service costs a
|
|
922
|
+
// translation and never the game. Every method here has a source-language
|
|
923
|
+
// answer, and `t()` is synchronous, so a menu never waits on a network to
|
|
924
|
+
// draw itself in the player's language.
|
|
925
|
+
this.l10n = this._makeL10n(opts);
|
|
926
|
+
l10nClient = this.l10n;
|
|
927
|
+
|
|
830
928
|
this.multiplayer = {
|
|
831
929
|
// Read this before drawing a lobby: when it is false the game is not
|
|
832
930
|
// allowed rooms yet and the calls below refuse.
|
|
@@ -843,14 +941,42 @@ class TesanaClient {
|
|
|
843
941
|
.then((r) => r.rooms)
|
|
844
942
|
.catch(() => [])
|
|
845
943
|
: Promise.resolve([]),
|
|
944
|
+
// Open a room. It is public and always joinable unless you pass a `code`,
|
|
945
|
+
// which is the only way entry is restricted: the room is then unlisted and
|
|
946
|
+
// only someone with the code can reach it. The resolved code is on
|
|
947
|
+
// `room.code` in the result (the service generates one if you pass
|
|
948
|
+
// `code: true`), so show it to the room.
|
|
846
949
|
create: (opts) =>
|
|
847
950
|
this._mpEnabled
|
|
848
951
|
? this._mpEnter(this._mpRequest("POST", "/v1/mp/rooms", opts || {}))
|
|
849
952
|
: this._mpRefuse(),
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
953
|
+
// Accept the room a game got from list() as well as from a session. Both
|
|
954
|
+
// read as "the room to join": `list()` rows are keyed `id`, a
|
|
955
|
+
// create/quickJoin/join result carries `roomId`. Taking an object here is
|
|
956
|
+
// what stops a lobby that calls `join(room)` from having to know which.
|
|
957
|
+
join: (room) => {
|
|
958
|
+
if (!this._mpEnabled) return this._mpRefuse();
|
|
959
|
+
const roomId =
|
|
960
|
+
room && typeof room === "object" ? room.id || room.roomId : room;
|
|
961
|
+
// The bug this guards: a game read `room.roomId` off a list() row,
|
|
962
|
+
// where it is undefined, and we asked the service for the room named
|
|
963
|
+
// "undefined" — a 404 indistinguishable from a deleted room. Fail
|
|
964
|
+
// here, by name, so the mistake is legible.
|
|
965
|
+
if (roomId == null || roomId === "") {
|
|
966
|
+
const err = new Error(
|
|
967
|
+
"join() needs a room id — pass a room from list() (its `id`) or a create/join result",
|
|
968
|
+
);
|
|
969
|
+
err.code = "mp_room_id_required";
|
|
970
|
+
return Promise.reject(err);
|
|
971
|
+
}
|
|
972
|
+
return this._mpEnter(
|
|
973
|
+
this._mpRequest("POST", `/v1/mp/rooms/${enc(roomId)}/join`),
|
|
974
|
+
);
|
|
975
|
+
},
|
|
976
|
+
// Join the fullest public room with a free seat, creating one if there is
|
|
977
|
+
// none. Pass `code` to enter a private room instead: the first player to
|
|
978
|
+
// use a code opens that room and the rest join it, and a full one refuses
|
|
979
|
+
// rather than opening a second room under the same code.
|
|
854
980
|
quickJoin: (opts) =>
|
|
855
981
|
this._mpEnabled
|
|
856
982
|
? this._mpEnter(this._mpRequest("POST", "/v1/mp/quick-join", opts || {}))
|
|
@@ -1028,11 +1154,27 @@ class TesanaClient {
|
|
|
1028
1154
|
*/
|
|
1029
1155
|
async _mpEnter(session) {
|
|
1030
1156
|
const joined = await session;
|
|
1031
|
-
|
|
1157
|
+
// A room that was opened and then could not be entered is reported as what
|
|
1158
|
+
// it is: the room exists. The SDK cannot seat the player — that is the
|
|
1159
|
+
// host's job — but it can name the room so a game retries `join(roomId)`
|
|
1160
|
+
// instead of opening a second one and stranding the first.
|
|
1161
|
+
try {
|
|
1162
|
+
await this._mpOpen(joined.wsUrl);
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
const roomId = joined.roomId || joined.room?.id || null;
|
|
1165
|
+
const wrapped = new Error(
|
|
1166
|
+
roomId ? `${err?.message || "multiplayer unavailable"} (room ${roomId})` : err?.message || "multiplayer unavailable",
|
|
1167
|
+
);
|
|
1168
|
+
wrapped.code = err?.code || "mp_enter_failed";
|
|
1169
|
+
wrapped.roomId = roomId;
|
|
1170
|
+
throw wrapped;
|
|
1171
|
+
}
|
|
1032
1172
|
this._mp.roomId = joined.roomId || joined.room?.id || null;
|
|
1173
|
+
this._mp.code = joined.room?.code || null;
|
|
1033
1174
|
return {
|
|
1034
1175
|
wsUrl: joined.wsUrl,
|
|
1035
1176
|
roomId: this._mp.roomId,
|
|
1177
|
+
code: this._mp.code,
|
|
1036
1178
|
playerId: joined.playerId || this._player?.id,
|
|
1037
1179
|
room: joined.room,
|
|
1038
1180
|
};
|
|
@@ -1209,6 +1351,7 @@ class TesanaClient {
|
|
|
1209
1351
|
this._mp.leaving = true;
|
|
1210
1352
|
this._mpClose();
|
|
1211
1353
|
this._mp.roomId = null;
|
|
1354
|
+
this._mp.code = null;
|
|
1212
1355
|
this._mp.wsUrl = null;
|
|
1213
1356
|
this._mp.reconnectAttempts = 0;
|
|
1214
1357
|
this._mp.reconnectExhausted = false;
|
|
@@ -1307,6 +1450,13 @@ class TesanaClient {
|
|
|
1307
1450
|
minIntervalMs: config.adMinIntervalMs,
|
|
1308
1451
|
});
|
|
1309
1452
|
}
|
|
1453
|
+
// A later bootstrap may carry the player's language — a provisionally
|
|
1454
|
+
// "offline" answer has none, and the real one arrives a moment later. The
|
|
1455
|
+
// capability is re-pointed in place, exactly as the ad policy is.
|
|
1456
|
+
if (config.locale && this.l10n) {
|
|
1457
|
+
const next = normalizeLocale(config.locale);
|
|
1458
|
+
if (next && next !== this.l10n.locale()) this.l10n.applyLocale(next);
|
|
1459
|
+
}
|
|
1310
1460
|
this._mpEnabled = config.multiplayer !== false;
|
|
1311
1461
|
this.multiplayer.enabled = this._mpEnabled;
|
|
1312
1462
|
return this;
|
|
@@ -1540,6 +1690,300 @@ class TesanaClient {
|
|
|
1540
1690
|
};
|
|
1541
1691
|
}
|
|
1542
1692
|
|
|
1693
|
+
// -------------------------------------------------------------------- l10n
|
|
1694
|
+
//
|
|
1695
|
+
// Localization, from the game's side. The service owns the dictionary and the
|
|
1696
|
+
// translation; this owns one promise: a game that asks for its text in the
|
|
1697
|
+
// player's language gets something renderable, always.
|
|
1698
|
+
//
|
|
1699
|
+
// Deliberately synchronous where the game needs it to be. `t()` reads a map
|
|
1700
|
+
// that was filled before the first frame, so drawing a localized menu costs no
|
|
1701
|
+
// await and no round trip; only text the game discovers at runtime — a
|
|
1702
|
+
// procedurally generated item name, something the player typed — goes through
|
|
1703
|
+
// the async path. A game that never awaits anything still localizes.
|
|
1704
|
+
_makeL10n(opts) {
|
|
1705
|
+
const state = {
|
|
1706
|
+
locale: normalizeLocale(opts.locale || ""),
|
|
1707
|
+
// The language the game's own strings are written in. Declared by the
|
|
1708
|
+
// build; the service is told the same value, so the two agree rather
|
|
1709
|
+
// than one guessing what the other meant.
|
|
1710
|
+
source: "en",
|
|
1711
|
+
// The platform's list, refined by the service's answer when it arrives.
|
|
1712
|
+
languages: L10N_TARGETS.slice(),
|
|
1713
|
+
// Source text -> translated text, filled before `ready` resolves.
|
|
1714
|
+
bundle: {},
|
|
1715
|
+
// The game's own key -> source text, for `t("ui.play")`.
|
|
1716
|
+
index: {},
|
|
1717
|
+
handlers: [],
|
|
1718
|
+
// `requested`: what the game's source language resolved to (en, de, …)
|
|
1719
|
+
// vs `target`: what is actually being served — the difference is a
|
|
1720
|
+
// language the game did not declare, where the source is the answer.
|
|
1721
|
+
target: "",
|
|
1722
|
+
loaded: false,
|
|
1723
|
+
// The bundle is asked for from three places on a normal boot — `ready`,
|
|
1724
|
+
// `init`'s await, and the post-session reload — and all three want the
|
|
1725
|
+
// same one. `inflight` is the shared request; `loadedFor` is the target
|
|
1726
|
+
// that has already been answered, so a repeat is a no-op rather than a
|
|
1727
|
+
// second round trip for an object we already hold.
|
|
1728
|
+
inflight: null,
|
|
1729
|
+
inflightTarget: "",
|
|
1730
|
+
loadedFor: null,
|
|
1731
|
+
};
|
|
1732
|
+
|
|
1733
|
+
const self = this;
|
|
1734
|
+
|
|
1735
|
+
/** Emit a locale change to every listener, without letting one break the rest. */
|
|
1736
|
+
function emit() {
|
|
1737
|
+
const event = { locale: state.locale, target: state.target, languages: state.languages.slice() };
|
|
1738
|
+
for (const fn of [...state.handlers]) {
|
|
1739
|
+
try {
|
|
1740
|
+
fn(event);
|
|
1741
|
+
} catch {
|
|
1742
|
+
/* a game handler */
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
/**
|
|
1748
|
+
* Resolve `key` against the bundle.
|
|
1749
|
+
*
|
|
1750
|
+
* Three shapes, because a game reaches for all three: a key it declared
|
|
1751
|
+
* (`"ui.play"`), the source sentence itself (`"Play"`), and a key with a
|
|
1752
|
+
* fallback for the source text that was never declared. Whatever is not
|
|
1753
|
+
* found comes back as the fallback or the key, which is what makes an
|
|
1754
|
+
* unreachable service a non-event.
|
|
1755
|
+
*/
|
|
1756
|
+
function lookup(key, fallback) {
|
|
1757
|
+
const name = String(key == null ? "" : key);
|
|
1758
|
+
const source = state.index[name] || name;
|
|
1759
|
+
const hit = state.bundle[source];
|
|
1760
|
+
if (typeof hit === "string" && hit) return hit;
|
|
1761
|
+
if (typeof fallback === "string" && fallback) return fallback;
|
|
1762
|
+
return source;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
/**
|
|
1766
|
+
* Load the bundle, at most once per (language, reachability) combination.
|
|
1767
|
+
*
|
|
1768
|
+
* A boot calls this from several independent places — the `ready` promise,
|
|
1769
|
+
* `init`'s await, and the post-session `reload` — and they all want the
|
|
1770
|
+
* same bundle. Coalescing them is not an optimization for its own sake:
|
|
1771
|
+
* every extra request is the player's bundle fetched twice, and a game
|
|
1772
|
+
* that redraws on `onChange` should not be able to multiply it.
|
|
1773
|
+
*/
|
|
1774
|
+
function load() {
|
|
1775
|
+
const target = self._canReach() && state.locale && state.locale !== state.source ? state.locale : "";
|
|
1776
|
+
if (state.inflight && state.inflightTarget === target) return state.inflight;
|
|
1777
|
+
if (state.loadedFor === target && state.loaded) return Promise.resolve(state);
|
|
1778
|
+
state.inflightTarget = target;
|
|
1779
|
+
state.inflight = fetchBundle(target).finally(() => {
|
|
1780
|
+
state.inflight = null;
|
|
1781
|
+
});
|
|
1782
|
+
return state.inflight;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
async function fetchBundle(target) {
|
|
1786
|
+
if (!self._canReach()) {
|
|
1787
|
+
// No address, or no game id: nothing will ever answer, and asking
|
|
1788
|
+
// anyway would throw a relative-URL fetch. The source language is the
|
|
1789
|
+
// honest answer and the game renders exactly as it always did.
|
|
1790
|
+
state.loaded = true;
|
|
1791
|
+
state.loadedFor = target;
|
|
1792
|
+
return state;
|
|
1793
|
+
}
|
|
1794
|
+
try {
|
|
1795
|
+
if (!target) {
|
|
1796
|
+
// Nothing to fetch — and no request at all, either. A source-language
|
|
1797
|
+
// boot needs no bundle (nothing is translated) and no language list
|
|
1798
|
+
// (the platform's known set is already in memory), so a game with no
|
|
1799
|
+
// localization in force makes zero localization requests. That
|
|
1800
|
+
// matters beyond tidiness: it is the same rule as the rest of the
|
|
1801
|
+
// SDK, that a capability nobody asked for must not put bytes on the
|
|
1802
|
+
// wire, and it is what a game running entirely offline relies on.
|
|
1803
|
+
state.target = "";
|
|
1804
|
+
state.loaded = true;
|
|
1805
|
+
state.loadedFor = target;
|
|
1806
|
+
return state;
|
|
1807
|
+
}
|
|
1808
|
+
const data = await self.request(
|
|
1809
|
+
"GET",
|
|
1810
|
+
`/v1/l10n/bundle?target=${enc(target)}`,
|
|
1811
|
+
);
|
|
1812
|
+
if (data && typeof data.translations === "object" && data.translations) {
|
|
1813
|
+
state.bundle = data.translations;
|
|
1814
|
+
}
|
|
1815
|
+
if (data && typeof data.index === "object" && data.index) {
|
|
1816
|
+
state.index = data.index;
|
|
1817
|
+
}
|
|
1818
|
+
if (typeof data?.source === "string" && data.source) state.source = data.source;
|
|
1819
|
+
// `target` is the service's answer about what it actually served.
|
|
1820
|
+
// It is `""` for a language this game has not declared, which is the
|
|
1821
|
+
// signal that the source text IS the answer.
|
|
1822
|
+
state.target = typeof data?.target === "string" && data.target ? data.target : "";
|
|
1823
|
+
// The declared language set arrives with the bundle's own response
|
|
1824
|
+
// rather than a second route. It is one fact about one game, and two
|
|
1825
|
+
// requests to learn it is one more chance for a boot to be slower than
|
|
1826
|
+
// the first frame.
|
|
1827
|
+
if (Array.isArray(data?.languages) && data.languages.length) {
|
|
1828
|
+
// The game's own declared set, in the order it chose — which is how
|
|
1829
|
+
// a game that lists French first gets French first in its picker.
|
|
1830
|
+
state.languages = data.languages.slice();
|
|
1831
|
+
}
|
|
1832
|
+
} catch (err) {
|
|
1833
|
+
// An outage costs the translation, not the game. One deduped line is
|
|
1834
|
+
// enough to make it findable without spamming the console on retry.
|
|
1835
|
+
warnOnce(
|
|
1836
|
+
"l10n-unreachable",
|
|
1837
|
+
`Tesana localization: could not reach the translation service (${(err && err.message) || "request failed"}).`
|
|
1838
|
+
+ " The game keeps playing in its source language.",
|
|
1839
|
+
);
|
|
1840
|
+
}
|
|
1841
|
+
state.loaded = true;
|
|
1842
|
+
state.loadedFor = target;
|
|
1843
|
+
return state;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
return {
|
|
1847
|
+
/**
|
|
1848
|
+
* The player's language, synchronously, as the host resolved it.
|
|
1849
|
+
*
|
|
1850
|
+
* Follows the same tag vocabulary the site uses (`pt-BR`, `zh`), not
|
|
1851
|
+
* AWS's, so a game can pass it straight to `Intl`/`toLocaleString`.
|
|
1852
|
+
*/
|
|
1853
|
+
locale: () => state.locale,
|
|
1854
|
+
/** The languages this game offers, the host's order preserved. */
|
|
1855
|
+
languages: () => state.languages.slice(),
|
|
1856
|
+
/** The language text is actually being served in, or `""` when it is the source. */
|
|
1857
|
+
target: () => state.target,
|
|
1858
|
+
/** The language the game's own strings are written in. */
|
|
1859
|
+
source: () => state.source,
|
|
1860
|
+
/** Whether the initial bundle has landed. `t()` works before it does. */
|
|
1861
|
+
get loaded() {
|
|
1862
|
+
return state.loaded;
|
|
1863
|
+
},
|
|
1864
|
+
|
|
1865
|
+
/**
|
|
1866
|
+
* The translated text for `key`, synchronously.
|
|
1867
|
+
*
|
|
1868
|
+
* Prefer this. It is filled before the first frame, so a game never has
|
|
1869
|
+
* to await anything to draw a localized menu — and when the bundle is
|
|
1870
|
+
* empty (a service outage, a language the game never declared) it returns
|
|
1871
|
+
* the source text, which is a rendered game rather than a blank one.
|
|
1872
|
+
*
|
|
1873
|
+
* @param {string} key a declared key, or the source sentence itself
|
|
1874
|
+
* @param {object|string} [vars] `{name}` values, or a fallback string
|
|
1875
|
+
*/
|
|
1876
|
+
t: (key, vars) => {
|
|
1877
|
+
// The second argument is both of the things a caller wants to pass:
|
|
1878
|
+
// placeholder values, and a fallback for an undeclared key. A string is
|
|
1879
|
+
// unambiguously a fallback; an object is unambiguously values.
|
|
1880
|
+
const values = vars && typeof vars === "object" ? vars : null;
|
|
1881
|
+
const fallback = typeof vars === "string" ? vars : "";
|
|
1882
|
+
return fillPlaceholders(lookup(key, fallback), values);
|
|
1883
|
+
},
|
|
1884
|
+
|
|
1885
|
+
/**
|
|
1886
|
+
* Translate text the game discovered while running.
|
|
1887
|
+
*
|
|
1888
|
+
* The one path that costs a round trip, so it is the one to keep off the
|
|
1889
|
+
* hot path: ask once, at the moment the text is created (a generated
|
|
1890
|
+
* name, a line of dialogue just chosen), never per frame. Resolves the
|
|
1891
|
+
* source text on any failure, so a `.then` never sees a rejection.
|
|
1892
|
+
*
|
|
1893
|
+
* @param {string} text
|
|
1894
|
+
* @param {{ id?: string, values?: Record<string, unknown>, target?: string }} [opts]
|
|
1895
|
+
*/
|
|
1896
|
+
translate: async (text, opts = {}) => {
|
|
1897
|
+
const values = opts.values && typeof opts.values === "object" ? opts.values : null;
|
|
1898
|
+
const target = opts.target || state.target || (state.locale !== state.source ? state.locale : "");
|
|
1899
|
+
// A named string is routed through the id-keyed path, which is what
|
|
1900
|
+
// lets the service remember it under the game's own name and serve it
|
|
1901
|
+
// from cache next time without even a hash lookup.
|
|
1902
|
+
if (opts.id && self._canReach() && target) {
|
|
1903
|
+
try {
|
|
1904
|
+
const data = await self.request("POST", "/v1/l10n/string", {
|
|
1905
|
+
id: String(opts.id),
|
|
1906
|
+
text: String(text == null ? "" : text),
|
|
1907
|
+
values: values || undefined,
|
|
1908
|
+
target,
|
|
1909
|
+
source: state.source,
|
|
1910
|
+
});
|
|
1911
|
+
if (data && typeof data.value === "string" && data.value) return data.value;
|
|
1912
|
+
} catch {
|
|
1913
|
+
/* falls through to the source text below */
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
// The anonymous path requires a target to be worth a call. With no
|
|
1917
|
+
// target this *is* the source language, and asking the server to hand
|
|
1918
|
+
// back what it was given is the most expensive no-op available.
|
|
1919
|
+
if (self._canReach() && target) {
|
|
1920
|
+
try {
|
|
1921
|
+
const data = await self.request("POST", "/v1/l10n/translate", {
|
|
1922
|
+
strings: [String(text == null ? "" : text)],
|
|
1923
|
+
target,
|
|
1924
|
+
source: state.source,
|
|
1925
|
+
});
|
|
1926
|
+
const hit = data?.translations?.[String(text)];
|
|
1927
|
+
if (typeof hit === "string" && hit) return fillPlaceholders(hit, values);
|
|
1928
|
+
} catch {
|
|
1929
|
+
/* ditto */
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
return fillPlaceholders(String(text == null ? "" : text), values);
|
|
1933
|
+
},
|
|
1934
|
+
|
|
1935
|
+
/**
|
|
1936
|
+
* Subscribe to a language change. Returns an unsubscribe function.
|
|
1937
|
+
*
|
|
1938
|
+
* A game should redraw its text (or call `t()` again) on this rather than
|
|
1939
|
+
* caching translated strings at startup, because the player can change
|
|
1940
|
+
* the site's language without reloading the frame.
|
|
1941
|
+
*
|
|
1942
|
+
* @param {(event: { locale: string, target: string, languages: string[] }) => void} fn
|
|
1943
|
+
*/
|
|
1944
|
+
onChange: (fn) => {
|
|
1945
|
+
if (typeof fn !== "function") return () => {};
|
|
1946
|
+
state.handlers.push(fn);
|
|
1947
|
+
return () => {
|
|
1948
|
+
state.handlers = state.handlers.filter((handler) => handler !== fn);
|
|
1949
|
+
};
|
|
1950
|
+
},
|
|
1951
|
+
|
|
1952
|
+
/** Resolves after the initial bundle. A game may await it with `__TESANA_READY__`. */
|
|
1953
|
+
ready: load(),
|
|
1954
|
+
|
|
1955
|
+
/**
|
|
1956
|
+
* Reload the bundle, after an identity or language change.
|
|
1957
|
+
*
|
|
1958
|
+
* The constructor loads immediately from whatever the page already
|
|
1959
|
+
* carries. A game that minted its own token — or a host whose real
|
|
1960
|
+
* bootstrap replaced a provisional "offline" one — has no credential that
|
|
1961
|
+
* first time, so the bundle would be empty and the game would stay in its
|
|
1962
|
+
* source language for the whole session. `init` awaits this after the
|
|
1963
|
+
* identity step, which is what makes the documented
|
|
1964
|
+
* `await window.__TESANA_READY__` sufficient for a localized first frame.
|
|
1965
|
+
*/
|
|
1966
|
+
reload: () => load(),
|
|
1967
|
+
|
|
1968
|
+
// ---- internal wiring (used by the bootstrap listener) ----
|
|
1969
|
+
/** A new language from the host. Resolves the same promise the game already holds. */
|
|
1970
|
+
applyLocale(next) {
|
|
1971
|
+
const locale = normalizeLocale(next);
|
|
1972
|
+
if (!locale || locale === state.locale) return state;
|
|
1973
|
+
state.locale = locale;
|
|
1974
|
+
// The old bundle is the *previous* language's, so it is cleared rather
|
|
1975
|
+
// than left to serve: showing a menu in a language the player just
|
|
1976
|
+
// switched away from is worse than showing the source text for the
|
|
1977
|
+
// moment the refill takes. `t()` falls back to source in between.
|
|
1978
|
+
state.bundle = {};
|
|
1979
|
+
state.target = "";
|
|
1980
|
+
emit();
|
|
1981
|
+
load().catch(() => {});
|
|
1982
|
+
return state;
|
|
1983
|
+
},
|
|
1984
|
+
};
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1543
1987
|
// ------------------------------------------------------------------ saves
|
|
1544
1988
|
//
|
|
1545
1989
|
// Writes go to the local cache first, always, so a failed upload is a save
|
package/package.json
CHANGED