@tesana/sdk 1.0.2 → 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.2";
40
+ const VERSION = "1.0.3";
41
41
 
42
42
  const LOCAL_CACHE = "tesana.play.v1";
43
43
 
@@ -415,6 +415,21 @@ function deviceId() {
415
415
  return id;
416
416
  }
417
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
+
418
433
  /** How long the host has to answer a sign-in before we give up. */
419
434
  const SIGN_IN_TIMEOUT_MS = 120000;
420
435
 
@@ -901,6 +916,12 @@ class TesanaClient {
901
916
  * @param {{ clientRequestId?: string }} [opts]
902
917
  */
903
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);
904
925
  if (hasHost()) {
905
926
  // The host owns checkout: it opens Stripe in a window of its own and
906
927
  // tells us how it went. We only ask. The message names are the host's,
@@ -933,6 +954,67 @@ class TesanaClient {
933
954
  return result;
934
955
  }
935
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
+
936
1018
  _mpRefuse() {
937
1019
  const err = new Error("Multiplayer is not available for this game yet");
938
1020
  err.code = "multiplayer_disabled";
@@ -1508,6 +1590,13 @@ class TesanaClient {
1508
1590
  _signIn() {
1509
1591
  const signedIn = () => !this.offline && !this._isGuestSession();
1510
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);
1511
1600
  post({ type: "tesana-sign-in-request", gameId: this.gameId });
1512
1601
  return new Promise((resolve) => {
1513
1602
  let expiry = null;
@@ -1541,7 +1630,46 @@ class TesanaClient {
1541
1630
 
1542
1631
  /** A guest the site vouches for: saves in the cloud, but has no account to spend from. */
1543
1632
  _isGuestSession() {
1544
- return Boolean(this._player && this._player.isGuest);
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
+ });
1672
+ });
1545
1673
  }
1546
1674
 
1547
1675
  }
@@ -1,4 +1,4 @@
1
- /* Tesana play-time SDK v1.0.2 */
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.2";
42
+ const VERSION = "1.0.3";
43
43
 
44
44
  const LOCAL_CACHE = "tesana.play.v1";
45
45
 
@@ -417,6 +417,21 @@ function deviceId() {
417
417
  return id;
418
418
  }
419
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
+
420
435
  /** How long the host has to answer a sign-in before we give up. */
421
436
  const SIGN_IN_TIMEOUT_MS = 120000;
422
437
 
@@ -903,6 +918,12 @@ class TesanaClient {
903
918
  * @param {{ clientRequestId?: string }} [opts]
904
919
  */
905
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);
906
927
  if (hasHost()) {
907
928
  // The host owns checkout: it opens Stripe in a window of its own and
908
929
  // tells us how it went. We only ask. The message names are the host's,
@@ -935,6 +956,67 @@ class TesanaClient {
935
956
  return result;
936
957
  }
937
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
+
938
1020
  _mpRefuse() {
939
1021
  const err = new Error("Multiplayer is not available for this game yet");
940
1022
  err.code = "multiplayer_disabled";
@@ -1510,6 +1592,13 @@ class TesanaClient {
1510
1592
  _signIn() {
1511
1593
  const signedIn = () => !this.offline && !this._isGuestSession();
1512
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);
1513
1602
  post({ type: "tesana-sign-in-request", gameId: this.gameId });
1514
1603
  return new Promise((resolve) => {
1515
1604
  let expiry = null;
@@ -1543,7 +1632,46 @@ class TesanaClient {
1543
1632
 
1544
1633
  /** A guest the site vouches for: saves in the cloud, but has no account to spend from. */
1545
1634
  _isGuestSession() {
1546
- return Boolean(this._player && this._player.isGuest);
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
+ });
1674
+ });
1547
1675
  }
1548
1676
 
1549
1677
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tesana/sdk",
3
- "version": "1.0.2",
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.2";
40
+ const VERSION = "1.0.3";
41
41
 
42
42
  const LOCAL_CACHE = "tesana.play.v1";
43
43
 
@@ -415,6 +415,21 @@ function deviceId() {
415
415
  return id;
416
416
  }
417
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
+
418
433
  /** How long the host has to answer a sign-in before we give up. */
419
434
  const SIGN_IN_TIMEOUT_MS = 120000;
420
435
 
@@ -901,6 +916,12 @@ class TesanaClient {
901
916
  * @param {{ clientRequestId?: string }} [opts]
902
917
  */
903
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);
904
925
  if (hasHost()) {
905
926
  // The host owns checkout: it opens Stripe in a window of its own and
906
927
  // tells us how it went. We only ask. The message names are the host's,
@@ -933,6 +954,67 @@ class TesanaClient {
933
954
  return result;
934
955
  }
935
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
+
936
1018
  _mpRefuse() {
937
1019
  const err = new Error("Multiplayer is not available for this game yet");
938
1020
  err.code = "multiplayer_disabled";
@@ -1508,6 +1590,13 @@ class TesanaClient {
1508
1590
  _signIn() {
1509
1591
  const signedIn = () => !this.offline && !this._isGuestSession();
1510
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);
1511
1600
  post({ type: "tesana-sign-in-request", gameId: this.gameId });
1512
1601
  return new Promise((resolve) => {
1513
1602
  let expiry = null;
@@ -1541,7 +1630,46 @@ class TesanaClient {
1541
1630
 
1542
1631
  /** A guest the site vouches for: saves in the cloud, but has no account to spend from. */
1543
1632
  _isGuestSession() {
1544
- return Boolean(this._player && this._player.isGuest);
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
+ });
1672
+ });
1545
1673
  }
1546
1674
 
1547
1675
  }