@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.
- package/dist/tesana.esm.js +130 -2
- package/dist/tesana.iife.js +131 -3
- package/package.json +1 -1
- package/src/tesana.js +130 -2
package/dist/tesana.esm.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.
|
|
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
|
-
|
|
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
|
}
|
package/dist/tesana.iife.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* Tesana play-time SDK v1.0.
|
|
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.
|
|
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
|
-
|
|
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
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.
|
|
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
|
-
|
|
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
|
}
|