@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.
- package/dist/tesana.esm.js +191 -19
- package/dist/tesana.iife.js +192 -20
- package/package.json +1 -1
- package/src/tesana.js +191 -19
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
|
|
|
@@ -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
|
-
|
|
153
|
-
|
|
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? }`.
|
|
708
|
-
//
|
|
709
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(`${
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
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
|
|
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
|
|
|
@@ -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
|
-
|
|
155
|
-
|
|
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? }`.
|
|
710
|
-
//
|
|
711
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(`${
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
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
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
|
|
|
@@ -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
|
-
|
|
153
|
-
|
|
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? }`.
|
|
708
|
-
//
|
|
709
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(`${
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
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
|
|