@tesana/sdk 1.0.1 → 1.0.3

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.
@@ -37,7 +37,7 @@
37
37
  // 1.0.0 ships six services together: saves, shop, leaderboards, achievements,
38
38
  // a vanity address (`tesana.vanity`) and in-game ads (`tesana.ads`). These are
39
39
  // the first release, so there is no earlier number to stay compatible with.
40
- const VERSION = "1.0.1";
40
+ const VERSION = "1.0.3";
41
41
 
42
42
  const LOCAL_CACHE = "tesana.play.v1";
43
43
 
@@ -145,12 +145,20 @@ function warnOnce(key, message) {
145
145
  }
146
146
  }
147
147
 
148
+ function isGuestBootstrap(data) {
149
+ return Boolean(data && data.player && data.player.isGuest);
150
+ }
151
+
148
152
  /** Fold a host's answer into the live config, upgrading a provisional one. */
149
153
  function applyBootstrap(data) {
150
154
  const next = data && typeof data === "object" ? data : {};
151
155
  if (bootstrap) {
152
- if (!bootstrap.offline) return; // a real host has already answered
153
- if (next.offline) return; // still nobody; nothing has changed
156
+ // A real host has already answered — unless that answer was a guest and
157
+ // this one is the account they just signed in to.
158
+ if (!bootstrap.offline && !(isGuestBootstrap(bootstrap) && !next.offline && !isGuestBootstrap(next))) return;
159
+ // Still offline, unless it now carries a room token: a signed-out
160
+ // visitor's multiplayer arrives after the site has already said "offline".
161
+ if (next.offline && !(next.roomToken && !bootstrap.roomToken)) return;
154
162
  }
155
163
  bootstrap = next;
156
164
  window.__TESANA__ = { ...(window.__TESANA__ || {}), ...next };
@@ -407,6 +415,21 @@ function deviceId() {
407
415
  return id;
408
416
  }
409
417
 
418
+ /**
419
+ * The store-native purchase surface a desktop prelude publishes, or null.
420
+ *
421
+ * A desktop, Steam or App Store build has no page above it and no Stripe — the
422
+ * store's own purchase must run instead, and only the native side can open it.
423
+ * `tesana-desktop.js` publishes `TesanaDesktop.storePurchase` on exactly those
424
+ * builds and nothing else, so its presence is the whole signal. Reaching for it
425
+ * on a plain web page finds `undefined` and changes nothing.
426
+ */
427
+ function storeBilling() {
428
+ const desktop = typeof window !== "undefined" ? window.TesanaDesktop : null;
429
+ if (!desktop || typeof desktop.storePurchase !== "function") return null;
430
+ return desktop;
431
+ }
432
+
410
433
  /** How long the host has to answer a sign-in before we give up. */
411
434
  const SIGN_IN_TIMEOUT_MS = 120000;
412
435
 
@@ -518,6 +541,11 @@ function buildConfig(merged) {
518
541
  // it still has to play — the shop stays dark and saves stay local, which
519
542
  // is the same shape as `offline`, only without a host to have said so.
520
543
  offline: Boolean(merged.offline) || !endpoint || !gameId,
544
+ // A signed-out visitor stays offline for saves and the shop, but the host
545
+ // may still hand them a token that only opens rooms, so they can play
546
+ // together on the game's own address. Used by `multiplayer.*` alone.
547
+ roomEndpoint: merged.roomEndpoint || "",
548
+ roomToken: merged.roomToken || "",
521
549
  player: merged.player || null,
522
550
  // The host tells the frame which public address this game answers on, so
523
551
  // a game can show and copy its own link without asking the player to
@@ -542,7 +570,7 @@ function defaultEndpoint() {
542
570
 
543
571
  class TesanaClient {
544
572
  /**
545
- * @param {{ endpoint: string, gameId: string, token: string, rail: string, deviceId: string, multiplayer?: boolean, saveEndpoint?: string, saveToken?: string, offline?: boolean, player?: object|null, vanityAddress?: string, adsEnabled?: boolean }} opts
573
+ * @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
546
574
  */
547
575
  constructor(opts) {
548
576
  this.endpoint = opts.endpoint;
@@ -577,6 +605,8 @@ class TesanaClient {
577
605
  // doomed request on every save is noise, not a save.
578
606
  this.canSaveToCloud = Boolean(this.saveEndpoint && this.saveToken);
579
607
  this.offline = Boolean(opts.offline);
608
+ this.roomEndpoint = String(opts.roomEndpoint || "").replace(/\/$/, "");
609
+ this.roomToken = opts.roomToken || "";
580
610
  this._player = opts.player || null;
581
611
  const reconnect = opts.multiplayerReconnect || {};
582
612
  this._mp = {
@@ -704,9 +734,9 @@ class TesanaClient {
704
734
  submit: (board, score) =>
705
735
  this.request("POST", "/v1/scores", { board: String(board || ""), score: Number(score) }),
706
736
  // One board's standings, best first. `{ board, order, entries }`, where
707
- // an entry is `{ rank, score, displayName, isGuest, you? }`. Guest runs
708
- // are never listed — a ranking of identities that vanish with a tab is
709
- // not a ranking.
737
+ // an entry is `{ rank, score, displayName, isGuest, you? }`. A guest the
738
+ // site vouches for is listed under its placeholder name; one the game
739
+ // minted for itself is not.
710
740
  top: (board, opts) =>
711
741
  read(
712
742
  `/v1/scores/${enc(board)}?gameId=${enc(this.gameId)}${
@@ -806,22 +836,22 @@ class TesanaClient {
806
836
  // screen. An empty lobby is the honest answer for a game that cannot
807
837
  // reach the room service, and it is what the game can render.
808
838
  list: () =>
809
- this._mpEnabled && !this.offline
810
- ? this.request("GET", "/v1/mp/rooms")
839
+ this._mpEnabled && (!this.offline || this._roomsOnly())
840
+ ? this._mpRequest("GET", "/v1/mp/rooms")
811
841
  .then((r) => r.rooms)
812
842
  .catch(() => [])
813
843
  : Promise.resolve([]),
814
844
  create: (opts) =>
815
845
  this._mpEnabled
816
- ? this._mpEnter(this.request("POST", "/v1/mp/rooms", opts || {}))
846
+ ? this._mpEnter(this._mpRequest("POST", "/v1/mp/rooms", opts || {}))
817
847
  : this._mpRefuse(),
818
848
  join: (roomId) =>
819
849
  this._mpEnabled
820
- ? this._mpEnter(this.request("POST", `/v1/mp/rooms/${enc(roomId)}/join`))
850
+ ? this._mpEnter(this._mpRequest("POST", `/v1/mp/rooms/${enc(roomId)}/join`))
821
851
  : this._mpRefuse(),
822
852
  quickJoin: (opts) =>
823
853
  this._mpEnabled
824
- ? this._mpEnter(this.request("POST", "/v1/mp/quick-join", opts || {}))
854
+ ? this._mpEnter(this._mpRequest("POST", "/v1/mp/quick-join", opts || {}))
825
855
  : this._mpRefuse(),
826
856
  send: (data) => this._mpSend(data),
827
857
  on: (fn) => this._mpOn(fn),
@@ -886,6 +916,12 @@ class TesanaClient {
886
916
  * @param {{ clientRequestId?: string }} [opts]
887
917
  */
888
918
  async _topUp(packId, opts = {}) {
919
+ // A desktop/Steam build buys through its store, not through a Tesana page.
920
+ // `hasHost()` is false there (no parent frame) and Stripe is the wrong rail,
921
+ // so store billing is checked first — its presence is what distinguishes a
922
+ // storefront build from a page opened straight from the play CDN.
923
+ const desktop = storeBilling();
924
+ if (desktop) return this._topUpViaStore(desktop, packId);
889
925
  if (hasHost()) {
890
926
  // The host owns checkout: it opens Stripe in a window of its own and
891
927
  // tells us how it went. We only ask. The message names are the host's,
@@ -918,6 +954,67 @@ class TesanaClient {
918
954
  return result;
919
955
  }
920
956
 
957
+ /**
958
+ * Buy a coin pack through the store this build ships on.
959
+ *
960
+ * Two halves, in this order for a reason: the store takes the money, and only
961
+ * then does the play service turn the store's own transaction id into coins.
962
+ * The id is the store's, not ours — it is the one thing a client cannot
963
+ * invent twice, so it is what makes the credit idempotent when the purchase
964
+ * is replayed after a crash. Reporting before a successful purchase would
965
+ * credit coins for a purchase that never happened.
966
+ *
967
+ * The result is shaped like the web top-up: a purchase returns the new
968
+ * balance, and a decline is `{ ok: false, code: "cancelled" }` rather than an
969
+ * exception, because a player closing the store sheet is a normal answer.
970
+ *
971
+ * @param {any} desktop `window.TesanaDesktop` with `storePurchase`
972
+ * @param {string} packId
973
+ */
974
+ async _topUpViaStore(desktop, packId) {
975
+ // Sign in *before* the store sheet, never after.
976
+ //
977
+ // Coins are account-scoped, so a player with no account has nothing to hold
978
+ // them: `redeem` refuses an unlinked player, and a guest is exactly that.
979
+ // On the web rail the refusal arrives harmlessly first, because the host
980
+ // runs checkout server-side and no money has moved yet. Here the store
981
+ // charges on this side of the wire, so a refusal discovered at redemption
982
+ // is a player who was billed and got nothing — the one outcome worth
983
+ // blocking a sale over. A launcher that cannot sign anyone in therefore
984
+ // refuses the sale rather than taking payment it cannot honour.
985
+ const canBuy = () => !this.offline && !this._isGuestSession();
986
+ if (!canBuy()) {
987
+ if (typeof desktop.signIn !== "function") {
988
+ return { ok: false, purchased: false, code: "login_required" };
989
+ }
990
+ const answer = await this._signInViaNative(canBuy);
991
+ if (!answer || answer.signedIn !== true) {
992
+ return { ok: false, purchased: false, code: "login_required" };
993
+ }
994
+ }
995
+ const purchase = await desktop.storePurchase({ packId });
996
+ if (!purchase || purchase.ok !== true) {
997
+ return {
998
+ ok: false,
999
+ purchased: false,
1000
+ code: (purchase && purchase.code) || "cancelled",
1001
+ };
1002
+ }
1003
+ // A store that charged but reported no id cannot be credited — there would
1004
+ // be no way to tell a replay from a new purchase. Surface it as a failure
1005
+ // rather than granting an untraceable lot.
1006
+ if (!purchase.transactionId) {
1007
+ const err = new Error("The store did not return a transaction id.");
1008
+ err.code = "no_transaction_id";
1009
+ throw err;
1010
+ }
1011
+ return this.request("POST", "/v1/economy/redeem", {
1012
+ rail: this.rail,
1013
+ packId,
1014
+ transactionId: purchase.transactionId,
1015
+ });
1016
+ }
1017
+
921
1018
  _mpRefuse() {
922
1019
  const err = new Error("Multiplayer is not available for this game yet");
923
1020
  err.code = "multiplayer_disabled";
@@ -1116,7 +1213,7 @@ class TesanaClient {
1116
1213
  try {
1117
1214
  if (!roomId) return { ok: true, room: null };
1118
1215
  try {
1119
- return await this.request("POST", `/v1/mp/rooms/${enc(roomId)}/leave`);
1216
+ return await this._mpRequest("POST", `/v1/mp/rooms/${enc(roomId)}/leave`);
1120
1217
  } catch {
1121
1218
  return { ok: true, room: null };
1122
1219
  }
@@ -1130,14 +1227,28 @@ class TesanaClient {
1130
1227
  * @param {string} path
1131
1228
  * @param {object} [body]
1132
1229
  */
1133
- async request(method, path, body) {
1230
+ request(method, path, body) {
1231
+ return this._send(this._baseFor(), this._authFor(), method, path, body);
1232
+ }
1233
+
1234
+ /** Whether multiplayer rides a rooms-only token while everything else is offline. */
1235
+ _roomsOnly() {
1236
+ return Boolean(this.offline && this.roomEndpoint && this.roomToken);
1237
+ }
1238
+
1239
+ _mpRequest(method, path, body) {
1240
+ return this._roomsOnly()
1241
+ ? this._send(this.roomEndpoint, this.roomToken, method, path, body)
1242
+ : this.request(method, path, body);
1243
+ }
1244
+
1245
+ async _send(base, authToken, method, path, body) {
1134
1246
  const headers = { "content-type": "application/json" };
1135
- const authToken = this._authFor();
1136
1247
  if (authToken) headers.authorization = `Bearer ${authToken}`;
1137
1248
  if (this.deviceId) headers["x-tesana-device"] = this.deviceId;
1138
1249
  let res;
1139
1250
  try {
1140
- res = await fetch(`${this._baseFor()}${path}`, {
1251
+ res = await fetch(`${base}${path}`, {
1141
1252
  method,
1142
1253
  headers,
1143
1254
  body: body == null || method === "GET" ? undefined : JSON.stringify(body),
@@ -1181,6 +1292,8 @@ class TesanaClient {
1181
1292
  }
1182
1293
  this.canSaveToCloud = Boolean(this.saveEndpoint && this.saveToken);
1183
1294
  this.offline = Boolean(config.offline) || !this.endpoint || !this.gameId;
1295
+ this.roomEndpoint = String(config.roomEndpoint || "").replace(/\/$/, "");
1296
+ this.roomToken = config.roomToken || "";
1184
1297
  if (config.player) this._player = config.player;
1185
1298
  if (config.vanityAddress !== undefined) {
1186
1299
  this._vanityAddress = config.vanityAddress ? String(config.vanityAddress) : null;
@@ -1436,6 +1549,13 @@ class TesanaClient {
1436
1549
  if (!this.canSaveToCloud) return local === undefined ? null : local;
1437
1550
  try {
1438
1551
  const data = await this.request("GET", `/v1/db/${enc(key)}`);
1552
+ if ((data.value === undefined || data.value === null) && local != null && this._isGuestSession()) {
1553
+ // A guest's first cloud session, after playing with saves kept only in
1554
+ // this browser: their progress is theirs, so it goes up rather than
1555
+ // being overwritten by the empty cloud answer.
1556
+ this.request("PUT", `/v1/db/${enc(key)}`, { value: local }).catch(() => {});
1557
+ return local;
1558
+ }
1439
1559
  const db = readCache().db || {};
1440
1560
  db[key] = data.value;
1441
1561
  writeCache({ db });
@@ -1468,7 +1588,15 @@ class TesanaClient {
1468
1588
  * inventory() and buy() start working without the game reloading.
1469
1589
  */
1470
1590
  _signIn() {
1471
- if (!this.offline) return Promise.resolve({ signedIn: true });
1591
+ const signedIn = () => !this.offline && !this._isGuestSession();
1592
+ if (signedIn()) return Promise.resolve({ signedIn: true });
1593
+ // A desktop/Steam build has no host to draw an account UI. Its launcher is
1594
+ // the sign-in surface, and only it can vouch for the player to the host
1595
+ // with the internal secret that account linking requires — so this asks the
1596
+ // native side to run sign-in and then waits for the bootstrap that carries
1597
+ // the linked token, exactly as the web path waits for the host's answer.
1598
+ const desktop = storeBilling();
1599
+ if (desktop && typeof desktop.signIn === "function") return this._signInViaNative(signedIn);
1472
1600
  post({ type: "tesana-sign-in-request", gameId: this.gameId });
1473
1601
  return new Promise((resolve) => {
1474
1602
  let expiry = null;
@@ -1483,7 +1611,7 @@ class TesanaClient {
1483
1611
  // second time for still being offline — the exact dead end this whole
1484
1612
  // exchange exists to remove.
1485
1613
  const waitForToken = (attempt) => {
1486
- if (!this.offline) return finish({ signedIn: true });
1614
+ if (signedIn()) return finish({ signedIn: true });
1487
1615
  if ((attempt || 0) >= 60) return finish({ signedIn: true });
1488
1616
  setTimeout(() => waitForToken((attempt || 0) + 1), 50);
1489
1617
  };
@@ -1496,7 +1624,51 @@ class TesanaClient {
1496
1624
  window.addEventListener("message", onMessage);
1497
1625
  // Declining is an answer too. Report what is actually true rather than
1498
1626
  // leaving the game's promise pending forever.
1499
- expiry = setTimeout(() => finish({ signedIn: !this.offline }), SIGN_IN_TIMEOUT_MS);
1627
+ expiry = setTimeout(() => finish({ signedIn: signedIn() }), SIGN_IN_TIMEOUT_MS);
1628
+ });
1629
+ }
1630
+
1631
+ /** A guest the site vouches for: saves in the cloud, but has no account to spend from. */
1632
+ _isGuestSession() {
1633
+ if (!this._player) return false;
1634
+ // Two signals, because the answer arrives from two places. A host posts
1635
+ // `isGuest: true` in its bootstrap — the web's word for it — while an
1636
+ // identity this client resolves itself (`/v1/player/me`) reports `isLinked`
1637
+ // and no `isGuest` at all. Reading only the first would let a native
1638
+ // session, whose whole point is an account-free identity, pass as a
1639
+ // signed-in player; the store path gates a sale on exactly this, so that
1640
+ // mistake is a card charged for coins we then refuse to grant.
1641
+ return this._player.isGuest === true || this._player.isLinked === false;
1642
+ }
1643
+
1644
+ /**
1645
+ * Ask the native launcher to sign the player in, then wait for the token.
1646
+ *
1647
+ * The launcher is the only party that can reach the host route with the
1648
+ * internal secret that account linking needs, so this hands it the request
1649
+ * and then watches `signedIn()` — which flips the moment the linked bootstrap
1650
+ * replaces the guest one. Polling rather than listening is deliberate: the
1651
+ * bootstrap arrives through the SDK's own upgrade path (`onBootstrapUpgrade`
1652
+ * re-points the client and re-runs identity), and there is no second channel
1653
+ * to subscribe to here.
1654
+ */
1655
+ _signInViaNative(signedIn) {
1656
+ return window.TesanaDesktop.signIn({ gameId: this.gameId }).then((answer) => {
1657
+ if (!answer || answer.ok !== true || answer.signedIn !== true) return { signedIn: false };
1658
+ return new Promise((resolve) => {
1659
+ const waitForToken = (attempt) => {
1660
+ if (signedIn()) return resolve({ signedIn: true });
1661
+ // Report what is actually true when the wait runs out. The web path
1662
+ // predates this and answers optimistically; its launcher-started
1663
+ // sign-in gets a fresh page instead, so it never observes the lie.
1664
+ // Here the caller is a store purchase deciding whether money may
1665
+ // move, and "probably signed in" is not a state a charge may proceed
1666
+ // on — a token that never arrived means no account to hold the coins.
1667
+ if ((attempt || 0) >= 60) return resolve({ signedIn: signedIn() });
1668
+ setTimeout(() => waitForToken((attempt || 0) + 1), 50);
1669
+ };
1670
+ waitForToken(0);
1671
+ });
1500
1672
  });
1501
1673
  }
1502
1674
 
@@ -1,4 +1,4 @@
1
- /* Tesana play-time SDK v1.0.1 */
1
+ /* Tesana play-time SDK v1.0.3 */
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.1";
42
+ const VERSION = "1.0.3";
43
43
 
44
44
  const LOCAL_CACHE = "tesana.play.v1";
45
45
 
@@ -147,12 +147,20 @@ function warnOnce(key, message) {
147
147
  }
148
148
  }
149
149
 
150
+ function isGuestBootstrap(data) {
151
+ return Boolean(data && data.player && data.player.isGuest);
152
+ }
153
+
150
154
  /** Fold a host's answer into the live config, upgrading a provisional one. */
151
155
  function applyBootstrap(data) {
152
156
  const next = data && typeof data === "object" ? data : {};
153
157
  if (bootstrap) {
154
- if (!bootstrap.offline) return; // a real host has already answered
155
- if (next.offline) return; // still nobody; nothing has changed
158
+ // A real host has already answered — unless that answer was a guest and
159
+ // this one is the account they just signed in to.
160
+ if (!bootstrap.offline && !(isGuestBootstrap(bootstrap) && !next.offline && !isGuestBootstrap(next))) return;
161
+ // Still offline, unless it now carries a room token: a signed-out
162
+ // visitor's multiplayer arrives after the site has already said "offline".
163
+ if (next.offline && !(next.roomToken && !bootstrap.roomToken)) return;
156
164
  }
157
165
  bootstrap = next;
158
166
  window.__TESANA__ = { ...(window.__TESANA__ || {}), ...next };
@@ -409,6 +417,21 @@ function deviceId() {
409
417
  return id;
410
418
  }
411
419
 
420
+ /**
421
+ * The store-native purchase surface a desktop prelude publishes, or null.
422
+ *
423
+ * A desktop, Steam or App Store build has no page above it and no Stripe — the
424
+ * store's own purchase must run instead, and only the native side can open it.
425
+ * `tesana-desktop.js` publishes `TesanaDesktop.storePurchase` on exactly those
426
+ * builds and nothing else, so its presence is the whole signal. Reaching for it
427
+ * on a plain web page finds `undefined` and changes nothing.
428
+ */
429
+ function storeBilling() {
430
+ const desktop = typeof window !== "undefined" ? window.TesanaDesktop : null;
431
+ if (!desktop || typeof desktop.storePurchase !== "function") return null;
432
+ return desktop;
433
+ }
434
+
412
435
  /** How long the host has to answer a sign-in before we give up. */
413
436
  const SIGN_IN_TIMEOUT_MS = 120000;
414
437
 
@@ -520,6 +543,11 @@ function buildConfig(merged) {
520
543
  // it still has to play — the shop stays dark and saves stay local, which
521
544
  // is the same shape as `offline`, only without a host to have said so.
522
545
  offline: Boolean(merged.offline) || !endpoint || !gameId,
546
+ // A signed-out visitor stays offline for saves and the shop, but the host
547
+ // may still hand them a token that only opens rooms, so they can play
548
+ // together on the game's own address. Used by `multiplayer.*` alone.
549
+ roomEndpoint: merged.roomEndpoint || "",
550
+ roomToken: merged.roomToken || "",
523
551
  player: merged.player || null,
524
552
  // The host tells the frame which public address this game answers on, so
525
553
  // a game can show and copy its own link without asking the player to
@@ -544,7 +572,7 @@ function defaultEndpoint() {
544
572
 
545
573
  class TesanaClient {
546
574
  /**
547
- * @param {{ endpoint: string, gameId: string, token: string, rail: string, deviceId: string, multiplayer?: boolean, saveEndpoint?: string, saveToken?: string, offline?: boolean, player?: object|null, vanityAddress?: string, adsEnabled?: boolean }} opts
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
548
576
  */
549
577
  constructor(opts) {
550
578
  this.endpoint = opts.endpoint;
@@ -579,6 +607,8 @@ class TesanaClient {
579
607
  // doomed request on every save is noise, not a save.
580
608
  this.canSaveToCloud = Boolean(this.saveEndpoint && this.saveToken);
581
609
  this.offline = Boolean(opts.offline);
610
+ this.roomEndpoint = String(opts.roomEndpoint || "").replace(/\/$/, "");
611
+ this.roomToken = opts.roomToken || "";
582
612
  this._player = opts.player || null;
583
613
  const reconnect = opts.multiplayerReconnect || {};
584
614
  this._mp = {
@@ -706,9 +736,9 @@ class TesanaClient {
706
736
  submit: (board, score) =>
707
737
  this.request("POST", "/v1/scores", { board: String(board || ""), score: Number(score) }),
708
738
  // One board's standings, best first. `{ board, order, entries }`, where
709
- // an entry is `{ rank, score, displayName, isGuest, you? }`. Guest runs
710
- // are never listed — a ranking of identities that vanish with a tab is
711
- // not a ranking.
739
+ // an entry is `{ rank, score, displayName, isGuest, you? }`. A guest the
740
+ // site vouches for is listed under its placeholder name; one the game
741
+ // minted for itself is not.
712
742
  top: (board, opts) =>
713
743
  read(
714
744
  `/v1/scores/${enc(board)}?gameId=${enc(this.gameId)}${
@@ -808,22 +838,22 @@ class TesanaClient {
808
838
  // screen. An empty lobby is the honest answer for a game that cannot
809
839
  // reach the room service, and it is what the game can render.
810
840
  list: () =>
811
- this._mpEnabled && !this.offline
812
- ? this.request("GET", "/v1/mp/rooms")
841
+ this._mpEnabled && (!this.offline || this._roomsOnly())
842
+ ? this._mpRequest("GET", "/v1/mp/rooms")
813
843
  .then((r) => r.rooms)
814
844
  .catch(() => [])
815
845
  : Promise.resolve([]),
816
846
  create: (opts) =>
817
847
  this._mpEnabled
818
- ? this._mpEnter(this.request("POST", "/v1/mp/rooms", opts || {}))
848
+ ? this._mpEnter(this._mpRequest("POST", "/v1/mp/rooms", opts || {}))
819
849
  : this._mpRefuse(),
820
850
  join: (roomId) =>
821
851
  this._mpEnabled
822
- ? this._mpEnter(this.request("POST", `/v1/mp/rooms/${enc(roomId)}/join`))
852
+ ? this._mpEnter(this._mpRequest("POST", `/v1/mp/rooms/${enc(roomId)}/join`))
823
853
  : this._mpRefuse(),
824
854
  quickJoin: (opts) =>
825
855
  this._mpEnabled
826
- ? this._mpEnter(this.request("POST", "/v1/mp/quick-join", opts || {}))
856
+ ? this._mpEnter(this._mpRequest("POST", "/v1/mp/quick-join", opts || {}))
827
857
  : this._mpRefuse(),
828
858
  send: (data) => this._mpSend(data),
829
859
  on: (fn) => this._mpOn(fn),
@@ -888,6 +918,12 @@ class TesanaClient {
888
918
  * @param {{ clientRequestId?: string }} [opts]
889
919
  */
890
920
  async _topUp(packId, opts = {}) {
921
+ // A desktop/Steam build buys through its store, not through a Tesana page.
922
+ // `hasHost()` is false there (no parent frame) and Stripe is the wrong rail,
923
+ // so store billing is checked first — its presence is what distinguishes a
924
+ // storefront build from a page opened straight from the play CDN.
925
+ const desktop = storeBilling();
926
+ if (desktop) return this._topUpViaStore(desktop, packId);
891
927
  if (hasHost()) {
892
928
  // The host owns checkout: it opens Stripe in a window of its own and
893
929
  // tells us how it went. We only ask. The message names are the host's,
@@ -920,6 +956,67 @@ class TesanaClient {
920
956
  return result;
921
957
  }
922
958
 
959
+ /**
960
+ * Buy a coin pack through the store this build ships on.
961
+ *
962
+ * Two halves, in this order for a reason: the store takes the money, and only
963
+ * then does the play service turn the store's own transaction id into coins.
964
+ * The id is the store's, not ours — it is the one thing a client cannot
965
+ * invent twice, so it is what makes the credit idempotent when the purchase
966
+ * is replayed after a crash. Reporting before a successful purchase would
967
+ * credit coins for a purchase that never happened.
968
+ *
969
+ * The result is shaped like the web top-up: a purchase returns the new
970
+ * balance, and a decline is `{ ok: false, code: "cancelled" }` rather than an
971
+ * exception, because a player closing the store sheet is a normal answer.
972
+ *
973
+ * @param {any} desktop `window.TesanaDesktop` with `storePurchase`
974
+ * @param {string} packId
975
+ */
976
+ async _topUpViaStore(desktop, packId) {
977
+ // Sign in *before* the store sheet, never after.
978
+ //
979
+ // Coins are account-scoped, so a player with no account has nothing to hold
980
+ // them: `redeem` refuses an unlinked player, and a guest is exactly that.
981
+ // On the web rail the refusal arrives harmlessly first, because the host
982
+ // runs checkout server-side and no money has moved yet. Here the store
983
+ // charges on this side of the wire, so a refusal discovered at redemption
984
+ // is a player who was billed and got nothing — the one outcome worth
985
+ // blocking a sale over. A launcher that cannot sign anyone in therefore
986
+ // refuses the sale rather than taking payment it cannot honour.
987
+ const canBuy = () => !this.offline && !this._isGuestSession();
988
+ if (!canBuy()) {
989
+ if (typeof desktop.signIn !== "function") {
990
+ return { ok: false, purchased: false, code: "login_required" };
991
+ }
992
+ const answer = await this._signInViaNative(canBuy);
993
+ if (!answer || answer.signedIn !== true) {
994
+ return { ok: false, purchased: false, code: "login_required" };
995
+ }
996
+ }
997
+ const purchase = await desktop.storePurchase({ packId });
998
+ if (!purchase || purchase.ok !== true) {
999
+ return {
1000
+ ok: false,
1001
+ purchased: false,
1002
+ code: (purchase && purchase.code) || "cancelled",
1003
+ };
1004
+ }
1005
+ // A store that charged but reported no id cannot be credited — there would
1006
+ // be no way to tell a replay from a new purchase. Surface it as a failure
1007
+ // rather than granting an untraceable lot.
1008
+ if (!purchase.transactionId) {
1009
+ const err = new Error("The store did not return a transaction id.");
1010
+ err.code = "no_transaction_id";
1011
+ throw err;
1012
+ }
1013
+ return this.request("POST", "/v1/economy/redeem", {
1014
+ rail: this.rail,
1015
+ packId,
1016
+ transactionId: purchase.transactionId,
1017
+ });
1018
+ }
1019
+
923
1020
  _mpRefuse() {
924
1021
  const err = new Error("Multiplayer is not available for this game yet");
925
1022
  err.code = "multiplayer_disabled";
@@ -1118,7 +1215,7 @@ class TesanaClient {
1118
1215
  try {
1119
1216
  if (!roomId) return { ok: true, room: null };
1120
1217
  try {
1121
- return await this.request("POST", `/v1/mp/rooms/${enc(roomId)}/leave`);
1218
+ return await this._mpRequest("POST", `/v1/mp/rooms/${enc(roomId)}/leave`);
1122
1219
  } catch {
1123
1220
  return { ok: true, room: null };
1124
1221
  }
@@ -1132,14 +1229,28 @@ class TesanaClient {
1132
1229
  * @param {string} path
1133
1230
  * @param {object} [body]
1134
1231
  */
1135
- async request(method, path, body) {
1232
+ request(method, path, body) {
1233
+ return this._send(this._baseFor(), this._authFor(), method, path, body);
1234
+ }
1235
+
1236
+ /** Whether multiplayer rides a rooms-only token while everything else is offline. */
1237
+ _roomsOnly() {
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) {
1136
1248
  const headers = { "content-type": "application/json" };
1137
- const authToken = this._authFor();
1138
1249
  if (authToken) headers.authorization = `Bearer ${authToken}`;
1139
1250
  if (this.deviceId) headers["x-tesana-device"] = this.deviceId;
1140
1251
  let res;
1141
1252
  try {
1142
- res = await fetch(`${this._baseFor()}${path}`, {
1253
+ res = await fetch(`${base}${path}`, {
1143
1254
  method,
1144
1255
  headers,
1145
1256
  body: body == null || method === "GET" ? undefined : JSON.stringify(body),
@@ -1183,6 +1294,8 @@ class TesanaClient {
1183
1294
  }
1184
1295
  this.canSaveToCloud = Boolean(this.saveEndpoint && this.saveToken);
1185
1296
  this.offline = Boolean(config.offline) || !this.endpoint || !this.gameId;
1297
+ this.roomEndpoint = String(config.roomEndpoint || "").replace(/\/$/, "");
1298
+ this.roomToken = config.roomToken || "";
1186
1299
  if (config.player) this._player = config.player;
1187
1300
  if (config.vanityAddress !== undefined) {
1188
1301
  this._vanityAddress = config.vanityAddress ? String(config.vanityAddress) : null;
@@ -1438,6 +1551,13 @@ class TesanaClient {
1438
1551
  if (!this.canSaveToCloud) return local === undefined ? null : local;
1439
1552
  try {
1440
1553
  const data = await this.request("GET", `/v1/db/${enc(key)}`);
1554
+ if ((data.value === undefined || data.value === null) && local != null && this._isGuestSession()) {
1555
+ // A guest's first cloud session, after playing with saves kept only in
1556
+ // this browser: their progress is theirs, so it goes up rather than
1557
+ // being overwritten by the empty cloud answer.
1558
+ this.request("PUT", `/v1/db/${enc(key)}`, { value: local }).catch(() => {});
1559
+ return local;
1560
+ }
1441
1561
  const db = readCache().db || {};
1442
1562
  db[key] = data.value;
1443
1563
  writeCache({ db });
@@ -1470,7 +1590,15 @@ class TesanaClient {
1470
1590
  * inventory() and buy() start working without the game reloading.
1471
1591
  */
1472
1592
  _signIn() {
1473
- if (!this.offline) return Promise.resolve({ signedIn: true });
1593
+ const signedIn = () => !this.offline && !this._isGuestSession();
1594
+ if (signedIn()) return Promise.resolve({ signedIn: true });
1595
+ // A desktop/Steam build has no host to draw an account UI. Its launcher is
1596
+ // the sign-in surface, and only it can vouch for the player to the host
1597
+ // with the internal secret that account linking requires — so this asks the
1598
+ // native side to run sign-in and then waits for the bootstrap that carries
1599
+ // the linked token, exactly as the web path waits for the host's answer.
1600
+ const desktop = storeBilling();
1601
+ if (desktop && typeof desktop.signIn === "function") return this._signInViaNative(signedIn);
1474
1602
  post({ type: "tesana-sign-in-request", gameId: this.gameId });
1475
1603
  return new Promise((resolve) => {
1476
1604
  let expiry = null;
@@ -1485,7 +1613,7 @@ class TesanaClient {
1485
1613
  // second time for still being offline — the exact dead end this whole
1486
1614
  // exchange exists to remove.
1487
1615
  const waitForToken = (attempt) => {
1488
- if (!this.offline) return finish({ signedIn: true });
1616
+ if (signedIn()) return finish({ signedIn: true });
1489
1617
  if ((attempt || 0) >= 60) return finish({ signedIn: true });
1490
1618
  setTimeout(() => waitForToken((attempt || 0) + 1), 50);
1491
1619
  };
@@ -1498,7 +1626,51 @@ class TesanaClient {
1498
1626
  window.addEventListener("message", onMessage);
1499
1627
  // Declining is an answer too. Report what is actually true rather than
1500
1628
  // leaving the game's promise pending forever.
1501
- expiry = setTimeout(() => finish({ signedIn: !this.offline }), SIGN_IN_TIMEOUT_MS);
1629
+ expiry = setTimeout(() => finish({ signedIn: signedIn() }), SIGN_IN_TIMEOUT_MS);
1630
+ });
1631
+ }
1632
+
1633
+ /** A guest the site vouches for: saves in the cloud, but has no account to spend from. */
1634
+ _isGuestSession() {
1635
+ if (!this._player) return false;
1636
+ // Two signals, because the answer arrives from two places. A host posts
1637
+ // `isGuest: true` in its bootstrap — the web's word for it — while an
1638
+ // identity this client resolves itself (`/v1/player/me`) reports `isLinked`
1639
+ // and no `isGuest` at all. Reading only the first would let a native
1640
+ // session, whose whole point is an account-free identity, pass as a
1641
+ // signed-in player; the store path gates a sale on exactly this, so that
1642
+ // mistake is a card charged for coins we then refuse to grant.
1643
+ return this._player.isGuest === true || this._player.isLinked === false;
1644
+ }
1645
+
1646
+ /**
1647
+ * Ask the native launcher to sign the player in, then wait for the token.
1648
+ *
1649
+ * The launcher is the only party that can reach the host route with the
1650
+ * internal secret that account linking needs, so this hands it the request
1651
+ * and then watches `signedIn()` — which flips the moment the linked bootstrap
1652
+ * replaces the guest one. Polling rather than listening is deliberate: the
1653
+ * bootstrap arrives through the SDK's own upgrade path (`onBootstrapUpgrade`
1654
+ * re-points the client and re-runs identity), and there is no second channel
1655
+ * to subscribe to here.
1656
+ */
1657
+ _signInViaNative(signedIn) {
1658
+ return window.TesanaDesktop.signIn({ gameId: this.gameId }).then((answer) => {
1659
+ if (!answer || answer.ok !== true || answer.signedIn !== true) return { signedIn: false };
1660
+ return new Promise((resolve) => {
1661
+ const waitForToken = (attempt) => {
1662
+ if (signedIn()) return resolve({ signedIn: true });
1663
+ // Report what is actually true when the wait runs out. The web path
1664
+ // predates this and answers optimistically; its launcher-started
1665
+ // sign-in gets a fresh page instead, so it never observes the lie.
1666
+ // Here the caller is a store purchase deciding whether money may
1667
+ // move, and "probably signed in" is not a state a charge may proceed
1668
+ // on — a token that never arrived means no account to hold the coins.
1669
+ if ((attempt || 0) >= 60) return resolve({ signedIn: signedIn() });
1670
+ setTimeout(() => waitForToken((attempt || 0) + 1), 50);
1671
+ };
1672
+ waitForToken(0);
1673
+ });
1502
1674
  });
1503
1675
  }
1504
1676
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tesana/sdk",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Tesana play-time SDK — players, cloud saves, leaderboards, achievements and the in-game coin shop for a web game.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/tesana.js CHANGED
@@ -37,7 +37,7 @@
37
37
  // 1.0.0 ships six services together: saves, shop, leaderboards, achievements,
38
38
  // a vanity address (`tesana.vanity`) and in-game ads (`tesana.ads`). These are
39
39
  // the first release, so there is no earlier number to stay compatible with.
40
- const VERSION = "1.0.1";
40
+ const VERSION = "1.0.3";
41
41
 
42
42
  const LOCAL_CACHE = "tesana.play.v1";
43
43
 
@@ -145,12 +145,20 @@ function warnOnce(key, message) {
145
145
  }
146
146
  }
147
147
 
148
+ function isGuestBootstrap(data) {
149
+ return Boolean(data && data.player && data.player.isGuest);
150
+ }
151
+
148
152
  /** Fold a host's answer into the live config, upgrading a provisional one. */
149
153
  function applyBootstrap(data) {
150
154
  const next = data && typeof data === "object" ? data : {};
151
155
  if (bootstrap) {
152
- if (!bootstrap.offline) return; // a real host has already answered
153
- if (next.offline) return; // still nobody; nothing has changed
156
+ // A real host has already answered — unless that answer was a guest and
157
+ // this one is the account they just signed in to.
158
+ if (!bootstrap.offline && !(isGuestBootstrap(bootstrap) && !next.offline && !isGuestBootstrap(next))) return;
159
+ // Still offline, unless it now carries a room token: a signed-out
160
+ // visitor's multiplayer arrives after the site has already said "offline".
161
+ if (next.offline && !(next.roomToken && !bootstrap.roomToken)) return;
154
162
  }
155
163
  bootstrap = next;
156
164
  window.__TESANA__ = { ...(window.__TESANA__ || {}), ...next };
@@ -407,6 +415,21 @@ function deviceId() {
407
415
  return id;
408
416
  }
409
417
 
418
+ /**
419
+ * The store-native purchase surface a desktop prelude publishes, or null.
420
+ *
421
+ * A desktop, Steam or App Store build has no page above it and no Stripe — the
422
+ * store's own purchase must run instead, and only the native side can open it.
423
+ * `tesana-desktop.js` publishes `TesanaDesktop.storePurchase` on exactly those
424
+ * builds and nothing else, so its presence is the whole signal. Reaching for it
425
+ * on a plain web page finds `undefined` and changes nothing.
426
+ */
427
+ function storeBilling() {
428
+ const desktop = typeof window !== "undefined" ? window.TesanaDesktop : null;
429
+ if (!desktop || typeof desktop.storePurchase !== "function") return null;
430
+ return desktop;
431
+ }
432
+
410
433
  /** How long the host has to answer a sign-in before we give up. */
411
434
  const SIGN_IN_TIMEOUT_MS = 120000;
412
435
 
@@ -518,6 +541,11 @@ function buildConfig(merged) {
518
541
  // it still has to play — the shop stays dark and saves stay local, which
519
542
  // is the same shape as `offline`, only without a host to have said so.
520
543
  offline: Boolean(merged.offline) || !endpoint || !gameId,
544
+ // A signed-out visitor stays offline for saves and the shop, but the host
545
+ // may still hand them a token that only opens rooms, so they can play
546
+ // together on the game's own address. Used by `multiplayer.*` alone.
547
+ roomEndpoint: merged.roomEndpoint || "",
548
+ roomToken: merged.roomToken || "",
521
549
  player: merged.player || null,
522
550
  // The host tells the frame which public address this game answers on, so
523
551
  // a game can show and copy its own link without asking the player to
@@ -542,7 +570,7 @@ function defaultEndpoint() {
542
570
 
543
571
  class TesanaClient {
544
572
  /**
545
- * @param {{ endpoint: string, gameId: string, token: string, rail: string, deviceId: string, multiplayer?: boolean, saveEndpoint?: string, saveToken?: string, offline?: boolean, player?: object|null, vanityAddress?: string, adsEnabled?: boolean }} opts
573
+ * @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
546
574
  */
547
575
  constructor(opts) {
548
576
  this.endpoint = opts.endpoint;
@@ -577,6 +605,8 @@ class TesanaClient {
577
605
  // doomed request on every save is noise, not a save.
578
606
  this.canSaveToCloud = Boolean(this.saveEndpoint && this.saveToken);
579
607
  this.offline = Boolean(opts.offline);
608
+ this.roomEndpoint = String(opts.roomEndpoint || "").replace(/\/$/, "");
609
+ this.roomToken = opts.roomToken || "";
580
610
  this._player = opts.player || null;
581
611
  const reconnect = opts.multiplayerReconnect || {};
582
612
  this._mp = {
@@ -704,9 +734,9 @@ class TesanaClient {
704
734
  submit: (board, score) =>
705
735
  this.request("POST", "/v1/scores", { board: String(board || ""), score: Number(score) }),
706
736
  // One board's standings, best first. `{ board, order, entries }`, where
707
- // an entry is `{ rank, score, displayName, isGuest, you? }`. Guest runs
708
- // are never listed — a ranking of identities that vanish with a tab is
709
- // not a ranking.
737
+ // an entry is `{ rank, score, displayName, isGuest, you? }`. A guest the
738
+ // site vouches for is listed under its placeholder name; one the game
739
+ // minted for itself is not.
710
740
  top: (board, opts) =>
711
741
  read(
712
742
  `/v1/scores/${enc(board)}?gameId=${enc(this.gameId)}${
@@ -806,22 +836,22 @@ class TesanaClient {
806
836
  // screen. An empty lobby is the honest answer for a game that cannot
807
837
  // reach the room service, and it is what the game can render.
808
838
  list: () =>
809
- this._mpEnabled && !this.offline
810
- ? this.request("GET", "/v1/mp/rooms")
839
+ this._mpEnabled && (!this.offline || this._roomsOnly())
840
+ ? this._mpRequest("GET", "/v1/mp/rooms")
811
841
  .then((r) => r.rooms)
812
842
  .catch(() => [])
813
843
  : Promise.resolve([]),
814
844
  create: (opts) =>
815
845
  this._mpEnabled
816
- ? this._mpEnter(this.request("POST", "/v1/mp/rooms", opts || {}))
846
+ ? this._mpEnter(this._mpRequest("POST", "/v1/mp/rooms", opts || {}))
817
847
  : this._mpRefuse(),
818
848
  join: (roomId) =>
819
849
  this._mpEnabled
820
- ? this._mpEnter(this.request("POST", `/v1/mp/rooms/${enc(roomId)}/join`))
850
+ ? this._mpEnter(this._mpRequest("POST", `/v1/mp/rooms/${enc(roomId)}/join`))
821
851
  : this._mpRefuse(),
822
852
  quickJoin: (opts) =>
823
853
  this._mpEnabled
824
- ? this._mpEnter(this.request("POST", "/v1/mp/quick-join", opts || {}))
854
+ ? this._mpEnter(this._mpRequest("POST", "/v1/mp/quick-join", opts || {}))
825
855
  : this._mpRefuse(),
826
856
  send: (data) => this._mpSend(data),
827
857
  on: (fn) => this._mpOn(fn),
@@ -886,6 +916,12 @@ class TesanaClient {
886
916
  * @param {{ clientRequestId?: string }} [opts]
887
917
  */
888
918
  async _topUp(packId, opts = {}) {
919
+ // A desktop/Steam build buys through its store, not through a Tesana page.
920
+ // `hasHost()` is false there (no parent frame) and Stripe is the wrong rail,
921
+ // so store billing is checked first — its presence is what distinguishes a
922
+ // storefront build from a page opened straight from the play CDN.
923
+ const desktop = storeBilling();
924
+ if (desktop) return this._topUpViaStore(desktop, packId);
889
925
  if (hasHost()) {
890
926
  // The host owns checkout: it opens Stripe in a window of its own and
891
927
  // tells us how it went. We only ask. The message names are the host's,
@@ -918,6 +954,67 @@ class TesanaClient {
918
954
  return result;
919
955
  }
920
956
 
957
+ /**
958
+ * Buy a coin pack through the store this build ships on.
959
+ *
960
+ * Two halves, in this order for a reason: the store takes the money, and only
961
+ * then does the play service turn the store's own transaction id into coins.
962
+ * The id is the store's, not ours — it is the one thing a client cannot
963
+ * invent twice, so it is what makes the credit idempotent when the purchase
964
+ * is replayed after a crash. Reporting before a successful purchase would
965
+ * credit coins for a purchase that never happened.
966
+ *
967
+ * The result is shaped like the web top-up: a purchase returns the new
968
+ * balance, and a decline is `{ ok: false, code: "cancelled" }` rather than an
969
+ * exception, because a player closing the store sheet is a normal answer.
970
+ *
971
+ * @param {any} desktop `window.TesanaDesktop` with `storePurchase`
972
+ * @param {string} packId
973
+ */
974
+ async _topUpViaStore(desktop, packId) {
975
+ // Sign in *before* the store sheet, never after.
976
+ //
977
+ // Coins are account-scoped, so a player with no account has nothing to hold
978
+ // them: `redeem` refuses an unlinked player, and a guest is exactly that.
979
+ // On the web rail the refusal arrives harmlessly first, because the host
980
+ // runs checkout server-side and no money has moved yet. Here the store
981
+ // charges on this side of the wire, so a refusal discovered at redemption
982
+ // is a player who was billed and got nothing — the one outcome worth
983
+ // blocking a sale over. A launcher that cannot sign anyone in therefore
984
+ // refuses the sale rather than taking payment it cannot honour.
985
+ const canBuy = () => !this.offline && !this._isGuestSession();
986
+ if (!canBuy()) {
987
+ if (typeof desktop.signIn !== "function") {
988
+ return { ok: false, purchased: false, code: "login_required" };
989
+ }
990
+ const answer = await this._signInViaNative(canBuy);
991
+ if (!answer || answer.signedIn !== true) {
992
+ return { ok: false, purchased: false, code: "login_required" };
993
+ }
994
+ }
995
+ const purchase = await desktop.storePurchase({ packId });
996
+ if (!purchase || purchase.ok !== true) {
997
+ return {
998
+ ok: false,
999
+ purchased: false,
1000
+ code: (purchase && purchase.code) || "cancelled",
1001
+ };
1002
+ }
1003
+ // A store that charged but reported no id cannot be credited — there would
1004
+ // be no way to tell a replay from a new purchase. Surface it as a failure
1005
+ // rather than granting an untraceable lot.
1006
+ if (!purchase.transactionId) {
1007
+ const err = new Error("The store did not return a transaction id.");
1008
+ err.code = "no_transaction_id";
1009
+ throw err;
1010
+ }
1011
+ return this.request("POST", "/v1/economy/redeem", {
1012
+ rail: this.rail,
1013
+ packId,
1014
+ transactionId: purchase.transactionId,
1015
+ });
1016
+ }
1017
+
921
1018
  _mpRefuse() {
922
1019
  const err = new Error("Multiplayer is not available for this game yet");
923
1020
  err.code = "multiplayer_disabled";
@@ -1116,7 +1213,7 @@ class TesanaClient {
1116
1213
  try {
1117
1214
  if (!roomId) return { ok: true, room: null };
1118
1215
  try {
1119
- return await this.request("POST", `/v1/mp/rooms/${enc(roomId)}/leave`);
1216
+ return await this._mpRequest("POST", `/v1/mp/rooms/${enc(roomId)}/leave`);
1120
1217
  } catch {
1121
1218
  return { ok: true, room: null };
1122
1219
  }
@@ -1130,14 +1227,28 @@ class TesanaClient {
1130
1227
  * @param {string} path
1131
1228
  * @param {object} [body]
1132
1229
  */
1133
- async request(method, path, body) {
1230
+ request(method, path, body) {
1231
+ return this._send(this._baseFor(), this._authFor(), method, path, body);
1232
+ }
1233
+
1234
+ /** Whether multiplayer rides a rooms-only token while everything else is offline. */
1235
+ _roomsOnly() {
1236
+ return Boolean(this.offline && this.roomEndpoint && this.roomToken);
1237
+ }
1238
+
1239
+ _mpRequest(method, path, body) {
1240
+ return this._roomsOnly()
1241
+ ? this._send(this.roomEndpoint, this.roomToken, method, path, body)
1242
+ : this.request(method, path, body);
1243
+ }
1244
+
1245
+ async _send(base, authToken, method, path, body) {
1134
1246
  const headers = { "content-type": "application/json" };
1135
- const authToken = this._authFor();
1136
1247
  if (authToken) headers.authorization = `Bearer ${authToken}`;
1137
1248
  if (this.deviceId) headers["x-tesana-device"] = this.deviceId;
1138
1249
  let res;
1139
1250
  try {
1140
- res = await fetch(`${this._baseFor()}${path}`, {
1251
+ res = await fetch(`${base}${path}`, {
1141
1252
  method,
1142
1253
  headers,
1143
1254
  body: body == null || method === "GET" ? undefined : JSON.stringify(body),
@@ -1181,6 +1292,8 @@ class TesanaClient {
1181
1292
  }
1182
1293
  this.canSaveToCloud = Boolean(this.saveEndpoint && this.saveToken);
1183
1294
  this.offline = Boolean(config.offline) || !this.endpoint || !this.gameId;
1295
+ this.roomEndpoint = String(config.roomEndpoint || "").replace(/\/$/, "");
1296
+ this.roomToken = config.roomToken || "";
1184
1297
  if (config.player) this._player = config.player;
1185
1298
  if (config.vanityAddress !== undefined) {
1186
1299
  this._vanityAddress = config.vanityAddress ? String(config.vanityAddress) : null;
@@ -1436,6 +1549,13 @@ class TesanaClient {
1436
1549
  if (!this.canSaveToCloud) return local === undefined ? null : local;
1437
1550
  try {
1438
1551
  const data = await this.request("GET", `/v1/db/${enc(key)}`);
1552
+ if ((data.value === undefined || data.value === null) && local != null && this._isGuestSession()) {
1553
+ // A guest's first cloud session, after playing with saves kept only in
1554
+ // this browser: their progress is theirs, so it goes up rather than
1555
+ // being overwritten by the empty cloud answer.
1556
+ this.request("PUT", `/v1/db/${enc(key)}`, { value: local }).catch(() => {});
1557
+ return local;
1558
+ }
1439
1559
  const db = readCache().db || {};
1440
1560
  db[key] = data.value;
1441
1561
  writeCache({ db });
@@ -1468,7 +1588,15 @@ class TesanaClient {
1468
1588
  * inventory() and buy() start working without the game reloading.
1469
1589
  */
1470
1590
  _signIn() {
1471
- if (!this.offline) return Promise.resolve({ signedIn: true });
1591
+ const signedIn = () => !this.offline && !this._isGuestSession();
1592
+ if (signedIn()) return Promise.resolve({ signedIn: true });
1593
+ // A desktop/Steam build has no host to draw an account UI. Its launcher is
1594
+ // the sign-in surface, and only it can vouch for the player to the host
1595
+ // with the internal secret that account linking requires — so this asks the
1596
+ // native side to run sign-in and then waits for the bootstrap that carries
1597
+ // the linked token, exactly as the web path waits for the host's answer.
1598
+ const desktop = storeBilling();
1599
+ if (desktop && typeof desktop.signIn === "function") return this._signInViaNative(signedIn);
1472
1600
  post({ type: "tesana-sign-in-request", gameId: this.gameId });
1473
1601
  return new Promise((resolve) => {
1474
1602
  let expiry = null;
@@ -1483,7 +1611,7 @@ class TesanaClient {
1483
1611
  // second time for still being offline — the exact dead end this whole
1484
1612
  // exchange exists to remove.
1485
1613
  const waitForToken = (attempt) => {
1486
- if (!this.offline) return finish({ signedIn: true });
1614
+ if (signedIn()) return finish({ signedIn: true });
1487
1615
  if ((attempt || 0) >= 60) return finish({ signedIn: true });
1488
1616
  setTimeout(() => waitForToken((attempt || 0) + 1), 50);
1489
1617
  };
@@ -1496,7 +1624,51 @@ class TesanaClient {
1496
1624
  window.addEventListener("message", onMessage);
1497
1625
  // Declining is an answer too. Report what is actually true rather than
1498
1626
  // leaving the game's promise pending forever.
1499
- expiry = setTimeout(() => finish({ signedIn: !this.offline }), SIGN_IN_TIMEOUT_MS);
1627
+ expiry = setTimeout(() => finish({ signedIn: signedIn() }), SIGN_IN_TIMEOUT_MS);
1628
+ });
1629
+ }
1630
+
1631
+ /** A guest the site vouches for: saves in the cloud, but has no account to spend from. */
1632
+ _isGuestSession() {
1633
+ if (!this._player) return false;
1634
+ // Two signals, because the answer arrives from two places. A host posts
1635
+ // `isGuest: true` in its bootstrap — the web's word for it — while an
1636
+ // identity this client resolves itself (`/v1/player/me`) reports `isLinked`
1637
+ // and no `isGuest` at all. Reading only the first would let a native
1638
+ // session, whose whole point is an account-free identity, pass as a
1639
+ // signed-in player; the store path gates a sale on exactly this, so that
1640
+ // mistake is a card charged for coins we then refuse to grant.
1641
+ return this._player.isGuest === true || this._player.isLinked === false;
1642
+ }
1643
+
1644
+ /**
1645
+ * Ask the native launcher to sign the player in, then wait for the token.
1646
+ *
1647
+ * The launcher is the only party that can reach the host route with the
1648
+ * internal secret that account linking needs, so this hands it the request
1649
+ * and then watches `signedIn()` — which flips the moment the linked bootstrap
1650
+ * replaces the guest one. Polling rather than listening is deliberate: the
1651
+ * bootstrap arrives through the SDK's own upgrade path (`onBootstrapUpgrade`
1652
+ * re-points the client and re-runs identity), and there is no second channel
1653
+ * to subscribe to here.
1654
+ */
1655
+ _signInViaNative(signedIn) {
1656
+ return window.TesanaDesktop.signIn({ gameId: this.gameId }).then((answer) => {
1657
+ if (!answer || answer.ok !== true || answer.signedIn !== true) return { signedIn: false };
1658
+ return new Promise((resolve) => {
1659
+ const waitForToken = (attempt) => {
1660
+ if (signedIn()) return resolve({ signedIn: true });
1661
+ // Report what is actually true when the wait runs out. The web path
1662
+ // predates this and answers optimistically; its launcher-started
1663
+ // sign-in gets a fresh page instead, so it never observes the lie.
1664
+ // Here the caller is a store purchase deciding whether money may
1665
+ // move, and "probably signed in" is not a state a charge may proceed
1666
+ // on — a token that never arrived means no account to hold the coins.
1667
+ if ((attempt || 0) >= 60) return resolve({ signedIn: signedIn() });
1668
+ setTimeout(() => waitForToken((attempt || 0) + 1), 50);
1669
+ };
1670
+ waitForToken(0);
1671
+ });
1500
1672
  });
1501
1673
  }
1502
1674