@tesana/sdk 1.0.2 → 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.
@@ -1,4 +1,4 @@
1
- /* Tesana play-time SDK v1.0.2 */
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.2";
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;
@@ -417,6 +484,21 @@ function deviceId() {
417
484
  return id;
418
485
  }
419
486
 
487
+ /**
488
+ * The store-native purchase surface a desktop prelude publishes, or null.
489
+ *
490
+ * A desktop, Steam or App Store build has no page above it and no Stripe — the
491
+ * store's own purchase must run instead, and only the native side can open it.
492
+ * `tesana-desktop.js` publishes `TesanaDesktop.storePurchase` on exactly those
493
+ * builds and nothing else, so its presence is the whole signal. Reaching for it
494
+ * on a plain web page finds `undefined` and changes nothing.
495
+ */
496
+ function storeBilling() {
497
+ const desktop = typeof window !== "undefined" ? window.TesanaDesktop : null;
498
+ if (!desktop || typeof desktop.storePurchase !== "function") return null;
499
+ return desktop;
500
+ }
501
+
420
502
  /** How long the host has to answer a sign-in before we give up. */
421
503
  const SIGN_IN_TIMEOUT_MS = 120000;
422
504
 
@@ -476,6 +558,16 @@ async function init(config = {}) {
476
558
  } catch (err) {
477
559
  client.goOffline(err);
478
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
+ }
479
571
  window.__TESANA_PLAYER__ = client.player.me();
480
572
  // A provisional "offline" answer can upgrade later. Re-point this client in
481
573
  // place — the game holds a reference to it — and re-run the identity step,
@@ -483,7 +575,7 @@ async function init(config = {}) {
483
575
  // player with a balance frozen at zero.
484
576
  const finishUpgrade = () => {
485
577
  client.configure(buildConfig(mergedConfig()));
486
- client.ensureSession();
578
+ client.ensureSession().then(() => client.l10n.reload());
487
579
  window.__TESANA_PLAYER__ = client.player.me();
488
580
  };
489
581
  onBootstrapUpgrade(finishUpgrade);
@@ -546,6 +638,15 @@ function buildConfig(merged) {
546
638
  adsEnabled: merged.adsEnabled,
547
639
  adFormats: merged.adFormats || merged.ad_formats || [],
548
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
+ ),
549
650
  };
550
651
  }
551
652
 
@@ -557,7 +658,7 @@ function defaultEndpoint() {
557
658
 
558
659
  class TesanaClient {
559
660
  /**
560
- * @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
561
662
  */
562
663
  constructor(opts) {
563
664
  this.endpoint = opts.endpoint;
@@ -600,6 +701,8 @@ class TesanaClient {
600
701
  ws: null,
601
702
  handlers: [],
602
703
  roomId: null,
704
+ // The current room's share code, or null for a public room.
705
+ code: null,
603
706
  // Reconnect state. `wsUrl` is kept so a dropped socket can re-enter the
604
707
  // same room; `leaving` stops a deliberate leave from being retried.
605
708
  wsUrl: null,
@@ -812,6 +915,16 @@ class TesanaClient {
812
915
  // same object in place, rather than the game holding a stale policy.
813
916
  adClient = this.ads;
814
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
+
815
928
  this.multiplayer = {
816
929
  // Read this before drawing a lobby: when it is false the game is not
817
930
  // allowed rooms yet and the calls below refuse.
@@ -828,14 +941,42 @@ class TesanaClient {
828
941
  .then((r) => r.rooms)
829
942
  .catch(() => [])
830
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.
831
949
  create: (opts) =>
832
950
  this._mpEnabled
833
951
  ? this._mpEnter(this._mpRequest("POST", "/v1/mp/rooms", opts || {}))
834
952
  : this._mpRefuse(),
835
- join: (roomId) =>
836
- this._mpEnabled
837
- ? this._mpEnter(this._mpRequest("POST", `/v1/mp/rooms/${enc(roomId)}/join`))
838
- : this._mpRefuse(),
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.
839
980
  quickJoin: (opts) =>
840
981
  this._mpEnabled
841
982
  ? this._mpEnter(this._mpRequest("POST", "/v1/mp/quick-join", opts || {}))
@@ -903,6 +1044,12 @@ class TesanaClient {
903
1044
  * @param {{ clientRequestId?: string }} [opts]
904
1045
  */
905
1046
  async _topUp(packId, opts = {}) {
1047
+ // A desktop/Steam build buys through its store, not through a Tesana page.
1048
+ // `hasHost()` is false there (no parent frame) and Stripe is the wrong rail,
1049
+ // so store billing is checked first — its presence is what distinguishes a
1050
+ // storefront build from a page opened straight from the play CDN.
1051
+ const desktop = storeBilling();
1052
+ if (desktop) return this._topUpViaStore(desktop, packId);
906
1053
  if (hasHost()) {
907
1054
  // The host owns checkout: it opens Stripe in a window of its own and
908
1055
  // tells us how it went. We only ask. The message names are the host's,
@@ -935,6 +1082,67 @@ class TesanaClient {
935
1082
  return result;
936
1083
  }
937
1084
 
1085
+ /**
1086
+ * Buy a coin pack through the store this build ships on.
1087
+ *
1088
+ * Two halves, in this order for a reason: the store takes the money, and only
1089
+ * then does the play service turn the store's own transaction id into coins.
1090
+ * The id is the store's, not ours — it is the one thing a client cannot
1091
+ * invent twice, so it is what makes the credit idempotent when the purchase
1092
+ * is replayed after a crash. Reporting before a successful purchase would
1093
+ * credit coins for a purchase that never happened.
1094
+ *
1095
+ * The result is shaped like the web top-up: a purchase returns the new
1096
+ * balance, and a decline is `{ ok: false, code: "cancelled" }` rather than an
1097
+ * exception, because a player closing the store sheet is a normal answer.
1098
+ *
1099
+ * @param {any} desktop `window.TesanaDesktop` with `storePurchase`
1100
+ * @param {string} packId
1101
+ */
1102
+ async _topUpViaStore(desktop, packId) {
1103
+ // Sign in *before* the store sheet, never after.
1104
+ //
1105
+ // Coins are account-scoped, so a player with no account has nothing to hold
1106
+ // them: `redeem` refuses an unlinked player, and a guest is exactly that.
1107
+ // On the web rail the refusal arrives harmlessly first, because the host
1108
+ // runs checkout server-side and no money has moved yet. Here the store
1109
+ // charges on this side of the wire, so a refusal discovered at redemption
1110
+ // is a player who was billed and got nothing — the one outcome worth
1111
+ // blocking a sale over. A launcher that cannot sign anyone in therefore
1112
+ // refuses the sale rather than taking payment it cannot honour.
1113
+ const canBuy = () => !this.offline && !this._isGuestSession();
1114
+ if (!canBuy()) {
1115
+ if (typeof desktop.signIn !== "function") {
1116
+ return { ok: false, purchased: false, code: "login_required" };
1117
+ }
1118
+ const answer = await this._signInViaNative(canBuy);
1119
+ if (!answer || answer.signedIn !== true) {
1120
+ return { ok: false, purchased: false, code: "login_required" };
1121
+ }
1122
+ }
1123
+ const purchase = await desktop.storePurchase({ packId });
1124
+ if (!purchase || purchase.ok !== true) {
1125
+ return {
1126
+ ok: false,
1127
+ purchased: false,
1128
+ code: (purchase && purchase.code) || "cancelled",
1129
+ };
1130
+ }
1131
+ // A store that charged but reported no id cannot be credited — there would
1132
+ // be no way to tell a replay from a new purchase. Surface it as a failure
1133
+ // rather than granting an untraceable lot.
1134
+ if (!purchase.transactionId) {
1135
+ const err = new Error("The store did not return a transaction id.");
1136
+ err.code = "no_transaction_id";
1137
+ throw err;
1138
+ }
1139
+ return this.request("POST", "/v1/economy/redeem", {
1140
+ rail: this.rail,
1141
+ packId,
1142
+ transactionId: purchase.transactionId,
1143
+ });
1144
+ }
1145
+
938
1146
  _mpRefuse() {
939
1147
  const err = new Error("Multiplayer is not available for this game yet");
940
1148
  err.code = "multiplayer_disabled";
@@ -946,11 +1154,27 @@ class TesanaClient {
946
1154
  */
947
1155
  async _mpEnter(session) {
948
1156
  const joined = await session;
949
- await this._mpOpen(joined.wsUrl);
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
+ }
950
1172
  this._mp.roomId = joined.roomId || joined.room?.id || null;
1173
+ this._mp.code = joined.room?.code || null;
951
1174
  return {
952
1175
  wsUrl: joined.wsUrl,
953
1176
  roomId: this._mp.roomId,
1177
+ code: this._mp.code,
954
1178
  playerId: joined.playerId || this._player?.id,
955
1179
  room: joined.room,
956
1180
  };
@@ -1127,6 +1351,7 @@ class TesanaClient {
1127
1351
  this._mp.leaving = true;
1128
1352
  this._mpClose();
1129
1353
  this._mp.roomId = null;
1354
+ this._mp.code = null;
1130
1355
  this._mp.wsUrl = null;
1131
1356
  this._mp.reconnectAttempts = 0;
1132
1357
  this._mp.reconnectExhausted = false;
@@ -1225,6 +1450,13 @@ class TesanaClient {
1225
1450
  minIntervalMs: config.adMinIntervalMs,
1226
1451
  });
1227
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
+ }
1228
1460
  this._mpEnabled = config.multiplayer !== false;
1229
1461
  this.multiplayer.enabled = this._mpEnabled;
1230
1462
  return this;
@@ -1458,6 +1690,300 @@ class TesanaClient {
1458
1690
  };
1459
1691
  }
1460
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
+
1461
1987
  // ------------------------------------------------------------------ saves
1462
1988
  //
1463
1989
  // Writes go to the local cache first, always, so a failed upload is a save
@@ -1510,6 +2036,13 @@ class TesanaClient {
1510
2036
  _signIn() {
1511
2037
  const signedIn = () => !this.offline && !this._isGuestSession();
1512
2038
  if (signedIn()) return Promise.resolve({ signedIn: true });
2039
+ // A desktop/Steam build has no host to draw an account UI. Its launcher is
2040
+ // the sign-in surface, and only it can vouch for the player to the host
2041
+ // with the internal secret that account linking requires — so this asks the
2042
+ // native side to run sign-in and then waits for the bootstrap that carries
2043
+ // the linked token, exactly as the web path waits for the host's answer.
2044
+ const desktop = storeBilling();
2045
+ if (desktop && typeof desktop.signIn === "function") return this._signInViaNative(signedIn);
1513
2046
  post({ type: "tesana-sign-in-request", gameId: this.gameId });
1514
2047
  return new Promise((resolve) => {
1515
2048
  let expiry = null;
@@ -1543,7 +2076,46 @@ class TesanaClient {
1543
2076
 
1544
2077
  /** A guest the site vouches for: saves in the cloud, but has no account to spend from. */
1545
2078
  _isGuestSession() {
1546
- return Boolean(this._player && this._player.isGuest);
2079
+ if (!this._player) return false;
2080
+ // Two signals, because the answer arrives from two places. A host posts
2081
+ // `isGuest: true` in its bootstrap — the web's word for it — while an
2082
+ // identity this client resolves itself (`/v1/player/me`) reports `isLinked`
2083
+ // and no `isGuest` at all. Reading only the first would let a native
2084
+ // session, whose whole point is an account-free identity, pass as a
2085
+ // signed-in player; the store path gates a sale on exactly this, so that
2086
+ // mistake is a card charged for coins we then refuse to grant.
2087
+ return this._player.isGuest === true || this._player.isLinked === false;
2088
+ }
2089
+
2090
+ /**
2091
+ * Ask the native launcher to sign the player in, then wait for the token.
2092
+ *
2093
+ * The launcher is the only party that can reach the host route with the
2094
+ * internal secret that account linking needs, so this hands it the request
2095
+ * and then watches `signedIn()` — which flips the moment the linked bootstrap
2096
+ * replaces the guest one. Polling rather than listening is deliberate: the
2097
+ * bootstrap arrives through the SDK's own upgrade path (`onBootstrapUpgrade`
2098
+ * re-points the client and re-runs identity), and there is no second channel
2099
+ * to subscribe to here.
2100
+ */
2101
+ _signInViaNative(signedIn) {
2102
+ return window.TesanaDesktop.signIn({ gameId: this.gameId }).then((answer) => {
2103
+ if (!answer || answer.ok !== true || answer.signedIn !== true) return { signedIn: false };
2104
+ return new Promise((resolve) => {
2105
+ const waitForToken = (attempt) => {
2106
+ if (signedIn()) return resolve({ signedIn: true });
2107
+ // Report what is actually true when the wait runs out. The web path
2108
+ // predates this and answers optimistically; its launcher-started
2109
+ // sign-in gets a fresh page instead, so it never observes the lie.
2110
+ // Here the caller is a store purchase deciding whether money may
2111
+ // move, and "probably signed in" is not a state a charge may proceed
2112
+ // on — a token that never arrived means no account to hold the coins.
2113
+ if ((attempt || 0) >= 60) return resolve({ signedIn: signedIn() });
2114
+ setTimeout(() => waitForToken((attempt || 0) + 1), 50);
2115
+ };
2116
+ waitForToken(0);
2117
+ });
2118
+ });
1547
2119
  }
1548
2120
 
1549
2121
  }