@tesana/sdk 1.0.0 → 1.0.2
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 +133 -20
- package/dist/tesana.iife.js +134 -21
- package/package.json +1 -1
- package/src/tesana.js +133 -20
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.2";
|
|
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 };
|
|
@@ -444,7 +452,28 @@ export async function init(config = {}) {
|
|
|
444
452
|
startListening();
|
|
445
453
|
const boot = await waitBootstrap();
|
|
446
454
|
const client = new TesanaClient(buildConfig(mergedConfig()));
|
|
447
|
-
|
|
455
|
+
// An unreachable play service must cost a *feature*, never the game.
|
|
456
|
+
//
|
|
457
|
+
// This is the client half of a contract the server already keeps: Django's
|
|
458
|
+
// mint returns None rather than failing a request, precisely so that "the
|
|
459
|
+
// play service being slow, down, or unconfigured means the SDK keeps saves in
|
|
460
|
+
// the browser — worse, but the game still runs". Without this catch the
|
|
461
|
+
// client contradicted that. `ensureSession()` mints an identity over HTTP, so
|
|
462
|
+
// a failure here rejected `__TESANA_READY__`, and a game doing the documented
|
|
463
|
+
// `await window.__TESANA_READY__` threw on its first line — turning a service
|
|
464
|
+
// outage into a broken game for everyone using it.
|
|
465
|
+
//
|
|
466
|
+
// Falling back to offline is the correct degradation, not a swallow: every
|
|
467
|
+
// read already has a local or empty answer (`balance()` reports zero,
|
|
468
|
+
// `shop.read()` reports a shut shutter, saves read as missing), and a later
|
|
469
|
+
// bootstrap upgrade still re-runs the identity step through
|
|
470
|
+
// `onBootstrapUpgrade` below. The player loses cloud features for that
|
|
471
|
+
// session and keeps their game.
|
|
472
|
+
try {
|
|
473
|
+
await client.ensureSession();
|
|
474
|
+
} catch (err) {
|
|
475
|
+
client.goOffline(err);
|
|
476
|
+
}
|
|
448
477
|
window.__TESANA_PLAYER__ = client.player.me();
|
|
449
478
|
// A provisional "offline" answer can upgrade later. Re-point this client in
|
|
450
479
|
// place — the game holds a reference to it — and re-run the identity step,
|
|
@@ -497,6 +526,11 @@ function buildConfig(merged) {
|
|
|
497
526
|
// it still has to play — the shop stays dark and saves stay local, which
|
|
498
527
|
// is the same shape as `offline`, only without a host to have said so.
|
|
499
528
|
offline: Boolean(merged.offline) || !endpoint || !gameId,
|
|
529
|
+
// A signed-out visitor stays offline for saves and the shop, but the host
|
|
530
|
+
// may still hand them a token that only opens rooms, so they can play
|
|
531
|
+
// together on the game's own address. Used by `multiplayer.*` alone.
|
|
532
|
+
roomEndpoint: merged.roomEndpoint || "",
|
|
533
|
+
roomToken: merged.roomToken || "",
|
|
500
534
|
player: merged.player || null,
|
|
501
535
|
// The host tells the frame which public address this game answers on, so
|
|
502
536
|
// a game can show and copy its own link without asking the player to
|
|
@@ -521,7 +555,7 @@ function defaultEndpoint() {
|
|
|
521
555
|
|
|
522
556
|
class TesanaClient {
|
|
523
557
|
/**
|
|
524
|
-
* @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
|
|
558
|
+
* @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
|
|
525
559
|
*/
|
|
526
560
|
constructor(opts) {
|
|
527
561
|
this.endpoint = opts.endpoint;
|
|
@@ -556,6 +590,8 @@ class TesanaClient {
|
|
|
556
590
|
// doomed request on every save is noise, not a save.
|
|
557
591
|
this.canSaveToCloud = Boolean(this.saveEndpoint && this.saveToken);
|
|
558
592
|
this.offline = Boolean(opts.offline);
|
|
593
|
+
this.roomEndpoint = String(opts.roomEndpoint || "").replace(/\/$/, "");
|
|
594
|
+
this.roomToken = opts.roomToken || "";
|
|
559
595
|
this._player = opts.player || null;
|
|
560
596
|
const reconnect = opts.multiplayerReconnect || {};
|
|
561
597
|
this._mp = {
|
|
@@ -683,9 +719,9 @@ class TesanaClient {
|
|
|
683
719
|
submit: (board, score) =>
|
|
684
720
|
this.request("POST", "/v1/scores", { board: String(board || ""), score: Number(score) }),
|
|
685
721
|
// One board's standings, best first. `{ board, order, entries }`, where
|
|
686
|
-
// an entry is `{ rank, score, displayName, isGuest, you? }`.
|
|
687
|
-
//
|
|
688
|
-
//
|
|
722
|
+
// an entry is `{ rank, score, displayName, isGuest, you? }`. A guest the
|
|
723
|
+
// site vouches for is listed under its placeholder name; one the game
|
|
724
|
+
// minted for itself is not.
|
|
689
725
|
top: (board, opts) =>
|
|
690
726
|
read(
|
|
691
727
|
`/v1/scores/${enc(board)}?gameId=${enc(this.gameId)}${
|
|
@@ -778,21 +814,29 @@ class TesanaClient {
|
|
|
778
814
|
// Read this before drawing a lobby: when it is false the game is not
|
|
779
815
|
// allowed rooms yet and the calls below refuse.
|
|
780
816
|
enabled: this._mpEnabled,
|
|
817
|
+
// `_mpEnabled` alone is not enough. A game with no host has multiplayer
|
|
818
|
+
// "allowed" and no service to ask, so `list()` used to reject with a
|
|
819
|
+
// transport error — and a game that draws a lobby on load (which the
|
|
820
|
+
// skill tells games to do) got an unhandled rejection on its title
|
|
821
|
+
// screen. An empty lobby is the honest answer for a game that cannot
|
|
822
|
+
// reach the room service, and it is what the game can render.
|
|
781
823
|
list: () =>
|
|
782
|
-
this._mpEnabled
|
|
783
|
-
? this.
|
|
824
|
+
this._mpEnabled && (!this.offline || this._roomsOnly())
|
|
825
|
+
? this._mpRequest("GET", "/v1/mp/rooms")
|
|
826
|
+
.then((r) => r.rooms)
|
|
827
|
+
.catch(() => [])
|
|
784
828
|
: Promise.resolve([]),
|
|
785
829
|
create: (opts) =>
|
|
786
830
|
this._mpEnabled
|
|
787
|
-
? this._mpEnter(this.
|
|
831
|
+
? this._mpEnter(this._mpRequest("POST", "/v1/mp/rooms", opts || {}))
|
|
788
832
|
: this._mpRefuse(),
|
|
789
833
|
join: (roomId) =>
|
|
790
834
|
this._mpEnabled
|
|
791
|
-
? this._mpEnter(this.
|
|
835
|
+
? this._mpEnter(this._mpRequest("POST", `/v1/mp/rooms/${enc(roomId)}/join`))
|
|
792
836
|
: this._mpRefuse(),
|
|
793
837
|
quickJoin: (opts) =>
|
|
794
838
|
this._mpEnabled
|
|
795
|
-
? this._mpEnter(this.
|
|
839
|
+
? this._mpEnter(this._mpRequest("POST", "/v1/mp/quick-join", opts || {}))
|
|
796
840
|
: this._mpRefuse(),
|
|
797
841
|
send: (data) => this._mpSend(data),
|
|
798
842
|
on: (fn) => this._mpOn(fn),
|
|
@@ -1087,7 +1131,7 @@ class TesanaClient {
|
|
|
1087
1131
|
try {
|
|
1088
1132
|
if (!roomId) return { ok: true, room: null };
|
|
1089
1133
|
try {
|
|
1090
|
-
return await this.
|
|
1134
|
+
return await this._mpRequest("POST", `/v1/mp/rooms/${enc(roomId)}/leave`);
|
|
1091
1135
|
} catch {
|
|
1092
1136
|
return { ok: true, room: null };
|
|
1093
1137
|
}
|
|
@@ -1101,14 +1145,28 @@ class TesanaClient {
|
|
|
1101
1145
|
* @param {string} path
|
|
1102
1146
|
* @param {object} [body]
|
|
1103
1147
|
*/
|
|
1104
|
-
|
|
1148
|
+
request(method, path, body) {
|
|
1149
|
+
return this._send(this._baseFor(), this._authFor(), method, path, body);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/** Whether multiplayer rides a rooms-only token while everything else is offline. */
|
|
1153
|
+
_roomsOnly() {
|
|
1154
|
+
return Boolean(this.offline && this.roomEndpoint && this.roomToken);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
_mpRequest(method, path, body) {
|
|
1158
|
+
return this._roomsOnly()
|
|
1159
|
+
? this._send(this.roomEndpoint, this.roomToken, method, path, body)
|
|
1160
|
+
: this.request(method, path, body);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
async _send(base, authToken, method, path, body) {
|
|
1105
1164
|
const headers = { "content-type": "application/json" };
|
|
1106
|
-
const authToken = this._authFor();
|
|
1107
1165
|
if (authToken) headers.authorization = `Bearer ${authToken}`;
|
|
1108
1166
|
if (this.deviceId) headers["x-tesana-device"] = this.deviceId;
|
|
1109
1167
|
let res;
|
|
1110
1168
|
try {
|
|
1111
|
-
res = await fetch(`${
|
|
1169
|
+
res = await fetch(`${base}${path}`, {
|
|
1112
1170
|
method,
|
|
1113
1171
|
headers,
|
|
1114
1172
|
body: body == null || method === "GET" ? undefined : JSON.stringify(body),
|
|
@@ -1152,6 +1210,8 @@ class TesanaClient {
|
|
|
1152
1210
|
}
|
|
1153
1211
|
this.canSaveToCloud = Boolean(this.saveEndpoint && this.saveToken);
|
|
1154
1212
|
this.offline = Boolean(config.offline) || !this.endpoint || !this.gameId;
|
|
1213
|
+
this.roomEndpoint = String(config.roomEndpoint || "").replace(/\/$/, "");
|
|
1214
|
+
this.roomToken = config.roomToken || "";
|
|
1155
1215
|
if (config.player) this._player = config.player;
|
|
1156
1216
|
if (config.vanityAddress !== undefined) {
|
|
1157
1217
|
this._vanityAddress = config.vanityAddress ? String(config.vanityAddress) : null;
|
|
@@ -1168,6 +1228,46 @@ class TesanaClient {
|
|
|
1168
1228
|
return this;
|
|
1169
1229
|
}
|
|
1170
1230
|
|
|
1231
|
+
/**
|
|
1232
|
+
* Carry on without the play service.
|
|
1233
|
+
*
|
|
1234
|
+
* The boot path calls this when the identity step fails — an unreachable
|
|
1235
|
+
* endpoint, a 500, a bad secret, a cert the browser will not accept. A game
|
|
1236
|
+
* must not die of it: the server side of this contract already returns no
|
|
1237
|
+
* token and lets the game run, so the client falls back to exactly the state
|
|
1238
|
+
* a game with no endpoint is in.
|
|
1239
|
+
*
|
|
1240
|
+
* Marking the client offline is the honest outcome rather than a silent
|
|
1241
|
+
* swallow. Every read already has an offline answer — `balance()` reports
|
|
1242
|
+
* zero coins and `canSpend: false`, the shop reports a shut shutter, saves
|
|
1243
|
+
* read as absent and write to the local cache — so the game draws its
|
|
1244
|
+
* title screen instead of throwing. A later bootstrap upgrade re-runs the
|
|
1245
|
+
* identity step and flips this back off.
|
|
1246
|
+
*
|
|
1247
|
+
* The token is cleared rather than kept: it is the thing that failed to
|
|
1248
|
+
* authenticate, and half an identity is worse than none — every call would
|
|
1249
|
+
* retry a credential the service just refused.
|
|
1250
|
+
*
|
|
1251
|
+
* @param {unknown} [reason] the failure, kept for diagnostics
|
|
1252
|
+
*/
|
|
1253
|
+
goOffline(reason) {
|
|
1254
|
+
this.offline = true;
|
|
1255
|
+
this.token = "";
|
|
1256
|
+
this.saveToken = "";
|
|
1257
|
+
this.canSaveToCloud = false;
|
|
1258
|
+
this._mpEnabled = false;
|
|
1259
|
+
this.multiplayer.enabled = false;
|
|
1260
|
+
// One deduped line rather than a wall: a page whose endpoint is
|
|
1261
|
+
// misconfigured would otherwise log this on every call.
|
|
1262
|
+
warnOnce(
|
|
1263
|
+
"play-session",
|
|
1264
|
+
`[tesana] play service unavailable; running without cloud saves or the shop${
|
|
1265
|
+
reason ? ` (${(reason && reason.message) || reason})` : ""
|
|
1266
|
+
}`,
|
|
1267
|
+
);
|
|
1268
|
+
return this;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1171
1271
|
/**
|
|
1172
1272
|
* Which origin and which credential a path uses.
|
|
1173
1273
|
*
|
|
@@ -1367,6 +1467,13 @@ class TesanaClient {
|
|
|
1367
1467
|
if (!this.canSaveToCloud) return local === undefined ? null : local;
|
|
1368
1468
|
try {
|
|
1369
1469
|
const data = await this.request("GET", `/v1/db/${enc(key)}`);
|
|
1470
|
+
if ((data.value === undefined || data.value === null) && local != null && this._isGuestSession()) {
|
|
1471
|
+
// A guest's first cloud session, after playing with saves kept only in
|
|
1472
|
+
// this browser: their progress is theirs, so it goes up rather than
|
|
1473
|
+
// being overwritten by the empty cloud answer.
|
|
1474
|
+
this.request("PUT", `/v1/db/${enc(key)}`, { value: local }).catch(() => {});
|
|
1475
|
+
return local;
|
|
1476
|
+
}
|
|
1370
1477
|
const db = readCache().db || {};
|
|
1371
1478
|
db[key] = data.value;
|
|
1372
1479
|
writeCache({ db });
|
|
@@ -1399,7 +1506,8 @@ class TesanaClient {
|
|
|
1399
1506
|
* inventory() and buy() start working without the game reloading.
|
|
1400
1507
|
*/
|
|
1401
1508
|
_signIn() {
|
|
1402
|
-
|
|
1509
|
+
const signedIn = () => !this.offline && !this._isGuestSession();
|
|
1510
|
+
if (signedIn()) return Promise.resolve({ signedIn: true });
|
|
1403
1511
|
post({ type: "tesana-sign-in-request", gameId: this.gameId });
|
|
1404
1512
|
return new Promise((resolve) => {
|
|
1405
1513
|
let expiry = null;
|
|
@@ -1414,7 +1522,7 @@ class TesanaClient {
|
|
|
1414
1522
|
// second time for still being offline — the exact dead end this whole
|
|
1415
1523
|
// exchange exists to remove.
|
|
1416
1524
|
const waitForToken = (attempt) => {
|
|
1417
|
-
if (
|
|
1525
|
+
if (signedIn()) return finish({ signedIn: true });
|
|
1418
1526
|
if ((attempt || 0) >= 60) return finish({ signedIn: true });
|
|
1419
1527
|
setTimeout(() => waitForToken((attempt || 0) + 1), 50);
|
|
1420
1528
|
};
|
|
@@ -1427,10 +1535,15 @@ class TesanaClient {
|
|
|
1427
1535
|
window.addEventListener("message", onMessage);
|
|
1428
1536
|
// Declining is an answer too. Report what is actually true rather than
|
|
1429
1537
|
// leaving the game's promise pending forever.
|
|
1430
|
-
expiry = setTimeout(() => finish({ signedIn:
|
|
1538
|
+
expiry = setTimeout(() => finish({ signedIn: signedIn() }), SIGN_IN_TIMEOUT_MS);
|
|
1431
1539
|
});
|
|
1432
1540
|
}
|
|
1433
1541
|
|
|
1542
|
+
/** A guest the site vouches for: saves in the cloud, but has no account to spend from. */
|
|
1543
|
+
_isGuestSession() {
|
|
1544
|
+
return Boolean(this._player && this._player.isGuest);
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1434
1547
|
}
|
|
1435
1548
|
|
|
1436
1549
|
function enc(value) {
|
package/dist/tesana.iife.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* Tesana play-time SDK v1.0.
|
|
1
|
+
/* Tesana play-time SDK v1.0.2 */
|
|
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.2";
|
|
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 };
|
|
@@ -446,7 +454,28 @@ async function init(config = {}) {
|
|
|
446
454
|
startListening();
|
|
447
455
|
const boot = await waitBootstrap();
|
|
448
456
|
const client = new TesanaClient(buildConfig(mergedConfig()));
|
|
449
|
-
|
|
457
|
+
// An unreachable play service must cost a *feature*, never the game.
|
|
458
|
+
//
|
|
459
|
+
// This is the client half of a contract the server already keeps: Django's
|
|
460
|
+
// mint returns None rather than failing a request, precisely so that "the
|
|
461
|
+
// play service being slow, down, or unconfigured means the SDK keeps saves in
|
|
462
|
+
// the browser — worse, but the game still runs". Without this catch the
|
|
463
|
+
// client contradicted that. `ensureSession()` mints an identity over HTTP, so
|
|
464
|
+
// a failure here rejected `__TESANA_READY__`, and a game doing the documented
|
|
465
|
+
// `await window.__TESANA_READY__` threw on its first line — turning a service
|
|
466
|
+
// outage into a broken game for everyone using it.
|
|
467
|
+
//
|
|
468
|
+
// Falling back to offline is the correct degradation, not a swallow: every
|
|
469
|
+
// read already has a local or empty answer (`balance()` reports zero,
|
|
470
|
+
// `shop.read()` reports a shut shutter, saves read as missing), and a later
|
|
471
|
+
// bootstrap upgrade still re-runs the identity step through
|
|
472
|
+
// `onBootstrapUpgrade` below. The player loses cloud features for that
|
|
473
|
+
// session and keeps their game.
|
|
474
|
+
try {
|
|
475
|
+
await client.ensureSession();
|
|
476
|
+
} catch (err) {
|
|
477
|
+
client.goOffline(err);
|
|
478
|
+
}
|
|
450
479
|
window.__TESANA_PLAYER__ = client.player.me();
|
|
451
480
|
// A provisional "offline" answer can upgrade later. Re-point this client in
|
|
452
481
|
// place — the game holds a reference to it — and re-run the identity step,
|
|
@@ -499,6 +528,11 @@ function buildConfig(merged) {
|
|
|
499
528
|
// it still has to play — the shop stays dark and saves stay local, which
|
|
500
529
|
// is the same shape as `offline`, only without a host to have said so.
|
|
501
530
|
offline: Boolean(merged.offline) || !endpoint || !gameId,
|
|
531
|
+
// A signed-out visitor stays offline for saves and the shop, but the host
|
|
532
|
+
// may still hand them a token that only opens rooms, so they can play
|
|
533
|
+
// together on the game's own address. Used by `multiplayer.*` alone.
|
|
534
|
+
roomEndpoint: merged.roomEndpoint || "",
|
|
535
|
+
roomToken: merged.roomToken || "",
|
|
502
536
|
player: merged.player || null,
|
|
503
537
|
// The host tells the frame which public address this game answers on, so
|
|
504
538
|
// a game can show and copy its own link without asking the player to
|
|
@@ -523,7 +557,7 @@ function defaultEndpoint() {
|
|
|
523
557
|
|
|
524
558
|
class TesanaClient {
|
|
525
559
|
/**
|
|
526
|
-
* @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
|
|
560
|
+
* @param {{ endpoint: string, gameId: string, token: string, rail: string, deviceId: string, multiplayer?: boolean, saveEndpoint?: string, saveToken?: string, offline?: boolean, roomEndpoint?: string, roomToken?: string, player?: object|null, vanityAddress?: string, adsEnabled?: boolean }} opts
|
|
527
561
|
*/
|
|
528
562
|
constructor(opts) {
|
|
529
563
|
this.endpoint = opts.endpoint;
|
|
@@ -558,6 +592,8 @@ class TesanaClient {
|
|
|
558
592
|
// doomed request on every save is noise, not a save.
|
|
559
593
|
this.canSaveToCloud = Boolean(this.saveEndpoint && this.saveToken);
|
|
560
594
|
this.offline = Boolean(opts.offline);
|
|
595
|
+
this.roomEndpoint = String(opts.roomEndpoint || "").replace(/\/$/, "");
|
|
596
|
+
this.roomToken = opts.roomToken || "";
|
|
561
597
|
this._player = opts.player || null;
|
|
562
598
|
const reconnect = opts.multiplayerReconnect || {};
|
|
563
599
|
this._mp = {
|
|
@@ -685,9 +721,9 @@ class TesanaClient {
|
|
|
685
721
|
submit: (board, score) =>
|
|
686
722
|
this.request("POST", "/v1/scores", { board: String(board || ""), score: Number(score) }),
|
|
687
723
|
// One board's standings, best first. `{ board, order, entries }`, where
|
|
688
|
-
// an entry is `{ rank, score, displayName, isGuest, you? }`.
|
|
689
|
-
//
|
|
690
|
-
//
|
|
724
|
+
// an entry is `{ rank, score, displayName, isGuest, you? }`. A guest the
|
|
725
|
+
// site vouches for is listed under its placeholder name; one the game
|
|
726
|
+
// minted for itself is not.
|
|
691
727
|
top: (board, opts) =>
|
|
692
728
|
read(
|
|
693
729
|
`/v1/scores/${enc(board)}?gameId=${enc(this.gameId)}${
|
|
@@ -780,21 +816,29 @@ class TesanaClient {
|
|
|
780
816
|
// Read this before drawing a lobby: when it is false the game is not
|
|
781
817
|
// allowed rooms yet and the calls below refuse.
|
|
782
818
|
enabled: this._mpEnabled,
|
|
819
|
+
// `_mpEnabled` alone is not enough. A game with no host has multiplayer
|
|
820
|
+
// "allowed" and no service to ask, so `list()` used to reject with a
|
|
821
|
+
// transport error — and a game that draws a lobby on load (which the
|
|
822
|
+
// skill tells games to do) got an unhandled rejection on its title
|
|
823
|
+
// screen. An empty lobby is the honest answer for a game that cannot
|
|
824
|
+
// reach the room service, and it is what the game can render.
|
|
783
825
|
list: () =>
|
|
784
|
-
this._mpEnabled
|
|
785
|
-
? this.
|
|
826
|
+
this._mpEnabled && (!this.offline || this._roomsOnly())
|
|
827
|
+
? this._mpRequest("GET", "/v1/mp/rooms")
|
|
828
|
+
.then((r) => r.rooms)
|
|
829
|
+
.catch(() => [])
|
|
786
830
|
: Promise.resolve([]),
|
|
787
831
|
create: (opts) =>
|
|
788
832
|
this._mpEnabled
|
|
789
|
-
? this._mpEnter(this.
|
|
833
|
+
? this._mpEnter(this._mpRequest("POST", "/v1/mp/rooms", opts || {}))
|
|
790
834
|
: this._mpRefuse(),
|
|
791
835
|
join: (roomId) =>
|
|
792
836
|
this._mpEnabled
|
|
793
|
-
? this._mpEnter(this.
|
|
837
|
+
? this._mpEnter(this._mpRequest("POST", `/v1/mp/rooms/${enc(roomId)}/join`))
|
|
794
838
|
: this._mpRefuse(),
|
|
795
839
|
quickJoin: (opts) =>
|
|
796
840
|
this._mpEnabled
|
|
797
|
-
? this._mpEnter(this.
|
|
841
|
+
? this._mpEnter(this._mpRequest("POST", "/v1/mp/quick-join", opts || {}))
|
|
798
842
|
: this._mpRefuse(),
|
|
799
843
|
send: (data) => this._mpSend(data),
|
|
800
844
|
on: (fn) => this._mpOn(fn),
|
|
@@ -1089,7 +1133,7 @@ class TesanaClient {
|
|
|
1089
1133
|
try {
|
|
1090
1134
|
if (!roomId) return { ok: true, room: null };
|
|
1091
1135
|
try {
|
|
1092
|
-
return await this.
|
|
1136
|
+
return await this._mpRequest("POST", `/v1/mp/rooms/${enc(roomId)}/leave`);
|
|
1093
1137
|
} catch {
|
|
1094
1138
|
return { ok: true, room: null };
|
|
1095
1139
|
}
|
|
@@ -1103,14 +1147,28 @@ class TesanaClient {
|
|
|
1103
1147
|
* @param {string} path
|
|
1104
1148
|
* @param {object} [body]
|
|
1105
1149
|
*/
|
|
1106
|
-
|
|
1150
|
+
request(method, path, body) {
|
|
1151
|
+
return this._send(this._baseFor(), this._authFor(), method, path, body);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/** Whether multiplayer rides a rooms-only token while everything else is offline. */
|
|
1155
|
+
_roomsOnly() {
|
|
1156
|
+
return Boolean(this.offline && this.roomEndpoint && this.roomToken);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
_mpRequest(method, path, body) {
|
|
1160
|
+
return this._roomsOnly()
|
|
1161
|
+
? this._send(this.roomEndpoint, this.roomToken, method, path, body)
|
|
1162
|
+
: this.request(method, path, body);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
async _send(base, authToken, method, path, body) {
|
|
1107
1166
|
const headers = { "content-type": "application/json" };
|
|
1108
|
-
const authToken = this._authFor();
|
|
1109
1167
|
if (authToken) headers.authorization = `Bearer ${authToken}`;
|
|
1110
1168
|
if (this.deviceId) headers["x-tesana-device"] = this.deviceId;
|
|
1111
1169
|
let res;
|
|
1112
1170
|
try {
|
|
1113
|
-
res = await fetch(`${
|
|
1171
|
+
res = await fetch(`${base}${path}`, {
|
|
1114
1172
|
method,
|
|
1115
1173
|
headers,
|
|
1116
1174
|
body: body == null || method === "GET" ? undefined : JSON.stringify(body),
|
|
@@ -1154,6 +1212,8 @@ class TesanaClient {
|
|
|
1154
1212
|
}
|
|
1155
1213
|
this.canSaveToCloud = Boolean(this.saveEndpoint && this.saveToken);
|
|
1156
1214
|
this.offline = Boolean(config.offline) || !this.endpoint || !this.gameId;
|
|
1215
|
+
this.roomEndpoint = String(config.roomEndpoint || "").replace(/\/$/, "");
|
|
1216
|
+
this.roomToken = config.roomToken || "";
|
|
1157
1217
|
if (config.player) this._player = config.player;
|
|
1158
1218
|
if (config.vanityAddress !== undefined) {
|
|
1159
1219
|
this._vanityAddress = config.vanityAddress ? String(config.vanityAddress) : null;
|
|
@@ -1170,6 +1230,46 @@ class TesanaClient {
|
|
|
1170
1230
|
return this;
|
|
1171
1231
|
}
|
|
1172
1232
|
|
|
1233
|
+
/**
|
|
1234
|
+
* Carry on without the play service.
|
|
1235
|
+
*
|
|
1236
|
+
* The boot path calls this when the identity step fails — an unreachable
|
|
1237
|
+
* endpoint, a 500, a bad secret, a cert the browser will not accept. A game
|
|
1238
|
+
* must not die of it: the server side of this contract already returns no
|
|
1239
|
+
* token and lets the game run, so the client falls back to exactly the state
|
|
1240
|
+
* a game with no endpoint is in.
|
|
1241
|
+
*
|
|
1242
|
+
* Marking the client offline is the honest outcome rather than a silent
|
|
1243
|
+
* swallow. Every read already has an offline answer — `balance()` reports
|
|
1244
|
+
* zero coins and `canSpend: false`, the shop reports a shut shutter, saves
|
|
1245
|
+
* read as absent and write to the local cache — so the game draws its
|
|
1246
|
+
* title screen instead of throwing. A later bootstrap upgrade re-runs the
|
|
1247
|
+
* identity step and flips this back off.
|
|
1248
|
+
*
|
|
1249
|
+
* The token is cleared rather than kept: it is the thing that failed to
|
|
1250
|
+
* authenticate, and half an identity is worse than none — every call would
|
|
1251
|
+
* retry a credential the service just refused.
|
|
1252
|
+
*
|
|
1253
|
+
* @param {unknown} [reason] the failure, kept for diagnostics
|
|
1254
|
+
*/
|
|
1255
|
+
goOffline(reason) {
|
|
1256
|
+
this.offline = true;
|
|
1257
|
+
this.token = "";
|
|
1258
|
+
this.saveToken = "";
|
|
1259
|
+
this.canSaveToCloud = false;
|
|
1260
|
+
this._mpEnabled = false;
|
|
1261
|
+
this.multiplayer.enabled = false;
|
|
1262
|
+
// One deduped line rather than a wall: a page whose endpoint is
|
|
1263
|
+
// misconfigured would otherwise log this on every call.
|
|
1264
|
+
warnOnce(
|
|
1265
|
+
"play-session",
|
|
1266
|
+
`[tesana] play service unavailable; running without cloud saves or the shop${
|
|
1267
|
+
reason ? ` (${(reason && reason.message) || reason})` : ""
|
|
1268
|
+
}`,
|
|
1269
|
+
);
|
|
1270
|
+
return this;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1173
1273
|
/**
|
|
1174
1274
|
* Which origin and which credential a path uses.
|
|
1175
1275
|
*
|
|
@@ -1369,6 +1469,13 @@ class TesanaClient {
|
|
|
1369
1469
|
if (!this.canSaveToCloud) return local === undefined ? null : local;
|
|
1370
1470
|
try {
|
|
1371
1471
|
const data = await this.request("GET", `/v1/db/${enc(key)}`);
|
|
1472
|
+
if ((data.value === undefined || data.value === null) && local != null && this._isGuestSession()) {
|
|
1473
|
+
// A guest's first cloud session, after playing with saves kept only in
|
|
1474
|
+
// this browser: their progress is theirs, so it goes up rather than
|
|
1475
|
+
// being overwritten by the empty cloud answer.
|
|
1476
|
+
this.request("PUT", `/v1/db/${enc(key)}`, { value: local }).catch(() => {});
|
|
1477
|
+
return local;
|
|
1478
|
+
}
|
|
1372
1479
|
const db = readCache().db || {};
|
|
1373
1480
|
db[key] = data.value;
|
|
1374
1481
|
writeCache({ db });
|
|
@@ -1401,7 +1508,8 @@ class TesanaClient {
|
|
|
1401
1508
|
* inventory() and buy() start working without the game reloading.
|
|
1402
1509
|
*/
|
|
1403
1510
|
_signIn() {
|
|
1404
|
-
|
|
1511
|
+
const signedIn = () => !this.offline && !this._isGuestSession();
|
|
1512
|
+
if (signedIn()) return Promise.resolve({ signedIn: true });
|
|
1405
1513
|
post({ type: "tesana-sign-in-request", gameId: this.gameId });
|
|
1406
1514
|
return new Promise((resolve) => {
|
|
1407
1515
|
let expiry = null;
|
|
@@ -1416,7 +1524,7 @@ class TesanaClient {
|
|
|
1416
1524
|
// second time for still being offline — the exact dead end this whole
|
|
1417
1525
|
// exchange exists to remove.
|
|
1418
1526
|
const waitForToken = (attempt) => {
|
|
1419
|
-
if (
|
|
1527
|
+
if (signedIn()) return finish({ signedIn: true });
|
|
1420
1528
|
if ((attempt || 0) >= 60) return finish({ signedIn: true });
|
|
1421
1529
|
setTimeout(() => waitForToken((attempt || 0) + 1), 50);
|
|
1422
1530
|
};
|
|
@@ -1429,10 +1537,15 @@ class TesanaClient {
|
|
|
1429
1537
|
window.addEventListener("message", onMessage);
|
|
1430
1538
|
// Declining is an answer too. Report what is actually true rather than
|
|
1431
1539
|
// leaving the game's promise pending forever.
|
|
1432
|
-
expiry = setTimeout(() => finish({ signedIn:
|
|
1540
|
+
expiry = setTimeout(() => finish({ signedIn: signedIn() }), SIGN_IN_TIMEOUT_MS);
|
|
1433
1541
|
});
|
|
1434
1542
|
}
|
|
1435
1543
|
|
|
1544
|
+
/** A guest the site vouches for: saves in the cloud, but has no account to spend from. */
|
|
1545
|
+
_isGuestSession() {
|
|
1546
|
+
return Boolean(this._player && this._player.isGuest);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1436
1549
|
}
|
|
1437
1550
|
|
|
1438
1551
|
function enc(value) {
|
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.2";
|
|
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 };
|
|
@@ -444,7 +452,28 @@ export async function init(config = {}) {
|
|
|
444
452
|
startListening();
|
|
445
453
|
const boot = await waitBootstrap();
|
|
446
454
|
const client = new TesanaClient(buildConfig(mergedConfig()));
|
|
447
|
-
|
|
455
|
+
// An unreachable play service must cost a *feature*, never the game.
|
|
456
|
+
//
|
|
457
|
+
// This is the client half of a contract the server already keeps: Django's
|
|
458
|
+
// mint returns None rather than failing a request, precisely so that "the
|
|
459
|
+
// play service being slow, down, or unconfigured means the SDK keeps saves in
|
|
460
|
+
// the browser — worse, but the game still runs". Without this catch the
|
|
461
|
+
// client contradicted that. `ensureSession()` mints an identity over HTTP, so
|
|
462
|
+
// a failure here rejected `__TESANA_READY__`, and a game doing the documented
|
|
463
|
+
// `await window.__TESANA_READY__` threw on its first line — turning a service
|
|
464
|
+
// outage into a broken game for everyone using it.
|
|
465
|
+
//
|
|
466
|
+
// Falling back to offline is the correct degradation, not a swallow: every
|
|
467
|
+
// read already has a local or empty answer (`balance()` reports zero,
|
|
468
|
+
// `shop.read()` reports a shut shutter, saves read as missing), and a later
|
|
469
|
+
// bootstrap upgrade still re-runs the identity step through
|
|
470
|
+
// `onBootstrapUpgrade` below. The player loses cloud features for that
|
|
471
|
+
// session and keeps their game.
|
|
472
|
+
try {
|
|
473
|
+
await client.ensureSession();
|
|
474
|
+
} catch (err) {
|
|
475
|
+
client.goOffline(err);
|
|
476
|
+
}
|
|
448
477
|
window.__TESANA_PLAYER__ = client.player.me();
|
|
449
478
|
// A provisional "offline" answer can upgrade later. Re-point this client in
|
|
450
479
|
// place — the game holds a reference to it — and re-run the identity step,
|
|
@@ -497,6 +526,11 @@ function buildConfig(merged) {
|
|
|
497
526
|
// it still has to play — the shop stays dark and saves stay local, which
|
|
498
527
|
// is the same shape as `offline`, only without a host to have said so.
|
|
499
528
|
offline: Boolean(merged.offline) || !endpoint || !gameId,
|
|
529
|
+
// A signed-out visitor stays offline for saves and the shop, but the host
|
|
530
|
+
// may still hand them a token that only opens rooms, so they can play
|
|
531
|
+
// together on the game's own address. Used by `multiplayer.*` alone.
|
|
532
|
+
roomEndpoint: merged.roomEndpoint || "",
|
|
533
|
+
roomToken: merged.roomToken || "",
|
|
500
534
|
player: merged.player || null,
|
|
501
535
|
// The host tells the frame which public address this game answers on, so
|
|
502
536
|
// a game can show and copy its own link without asking the player to
|
|
@@ -521,7 +555,7 @@ function defaultEndpoint() {
|
|
|
521
555
|
|
|
522
556
|
class TesanaClient {
|
|
523
557
|
/**
|
|
524
|
-
* @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
|
|
558
|
+
* @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
|
|
525
559
|
*/
|
|
526
560
|
constructor(opts) {
|
|
527
561
|
this.endpoint = opts.endpoint;
|
|
@@ -556,6 +590,8 @@ class TesanaClient {
|
|
|
556
590
|
// doomed request on every save is noise, not a save.
|
|
557
591
|
this.canSaveToCloud = Boolean(this.saveEndpoint && this.saveToken);
|
|
558
592
|
this.offline = Boolean(opts.offline);
|
|
593
|
+
this.roomEndpoint = String(opts.roomEndpoint || "").replace(/\/$/, "");
|
|
594
|
+
this.roomToken = opts.roomToken || "";
|
|
559
595
|
this._player = opts.player || null;
|
|
560
596
|
const reconnect = opts.multiplayerReconnect || {};
|
|
561
597
|
this._mp = {
|
|
@@ -683,9 +719,9 @@ class TesanaClient {
|
|
|
683
719
|
submit: (board, score) =>
|
|
684
720
|
this.request("POST", "/v1/scores", { board: String(board || ""), score: Number(score) }),
|
|
685
721
|
// One board's standings, best first. `{ board, order, entries }`, where
|
|
686
|
-
// an entry is `{ rank, score, displayName, isGuest, you? }`.
|
|
687
|
-
//
|
|
688
|
-
//
|
|
722
|
+
// an entry is `{ rank, score, displayName, isGuest, you? }`. A guest the
|
|
723
|
+
// site vouches for is listed under its placeholder name; one the game
|
|
724
|
+
// minted for itself is not.
|
|
689
725
|
top: (board, opts) =>
|
|
690
726
|
read(
|
|
691
727
|
`/v1/scores/${enc(board)}?gameId=${enc(this.gameId)}${
|
|
@@ -778,21 +814,29 @@ class TesanaClient {
|
|
|
778
814
|
// Read this before drawing a lobby: when it is false the game is not
|
|
779
815
|
// allowed rooms yet and the calls below refuse.
|
|
780
816
|
enabled: this._mpEnabled,
|
|
817
|
+
// `_mpEnabled` alone is not enough. A game with no host has multiplayer
|
|
818
|
+
// "allowed" and no service to ask, so `list()` used to reject with a
|
|
819
|
+
// transport error — and a game that draws a lobby on load (which the
|
|
820
|
+
// skill tells games to do) got an unhandled rejection on its title
|
|
821
|
+
// screen. An empty lobby is the honest answer for a game that cannot
|
|
822
|
+
// reach the room service, and it is what the game can render.
|
|
781
823
|
list: () =>
|
|
782
|
-
this._mpEnabled
|
|
783
|
-
? this.
|
|
824
|
+
this._mpEnabled && (!this.offline || this._roomsOnly())
|
|
825
|
+
? this._mpRequest("GET", "/v1/mp/rooms")
|
|
826
|
+
.then((r) => r.rooms)
|
|
827
|
+
.catch(() => [])
|
|
784
828
|
: Promise.resolve([]),
|
|
785
829
|
create: (opts) =>
|
|
786
830
|
this._mpEnabled
|
|
787
|
-
? this._mpEnter(this.
|
|
831
|
+
? this._mpEnter(this._mpRequest("POST", "/v1/mp/rooms", opts || {}))
|
|
788
832
|
: this._mpRefuse(),
|
|
789
833
|
join: (roomId) =>
|
|
790
834
|
this._mpEnabled
|
|
791
|
-
? this._mpEnter(this.
|
|
835
|
+
? this._mpEnter(this._mpRequest("POST", `/v1/mp/rooms/${enc(roomId)}/join`))
|
|
792
836
|
: this._mpRefuse(),
|
|
793
837
|
quickJoin: (opts) =>
|
|
794
838
|
this._mpEnabled
|
|
795
|
-
? this._mpEnter(this.
|
|
839
|
+
? this._mpEnter(this._mpRequest("POST", "/v1/mp/quick-join", opts || {}))
|
|
796
840
|
: this._mpRefuse(),
|
|
797
841
|
send: (data) => this._mpSend(data),
|
|
798
842
|
on: (fn) => this._mpOn(fn),
|
|
@@ -1087,7 +1131,7 @@ class TesanaClient {
|
|
|
1087
1131
|
try {
|
|
1088
1132
|
if (!roomId) return { ok: true, room: null };
|
|
1089
1133
|
try {
|
|
1090
|
-
return await this.
|
|
1134
|
+
return await this._mpRequest("POST", `/v1/mp/rooms/${enc(roomId)}/leave`);
|
|
1091
1135
|
} catch {
|
|
1092
1136
|
return { ok: true, room: null };
|
|
1093
1137
|
}
|
|
@@ -1101,14 +1145,28 @@ class TesanaClient {
|
|
|
1101
1145
|
* @param {string} path
|
|
1102
1146
|
* @param {object} [body]
|
|
1103
1147
|
*/
|
|
1104
|
-
|
|
1148
|
+
request(method, path, body) {
|
|
1149
|
+
return this._send(this._baseFor(), this._authFor(), method, path, body);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/** Whether multiplayer rides a rooms-only token while everything else is offline. */
|
|
1153
|
+
_roomsOnly() {
|
|
1154
|
+
return Boolean(this.offline && this.roomEndpoint && this.roomToken);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
_mpRequest(method, path, body) {
|
|
1158
|
+
return this._roomsOnly()
|
|
1159
|
+
? this._send(this.roomEndpoint, this.roomToken, method, path, body)
|
|
1160
|
+
: this.request(method, path, body);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
async _send(base, authToken, method, path, body) {
|
|
1105
1164
|
const headers = { "content-type": "application/json" };
|
|
1106
|
-
const authToken = this._authFor();
|
|
1107
1165
|
if (authToken) headers.authorization = `Bearer ${authToken}`;
|
|
1108
1166
|
if (this.deviceId) headers["x-tesana-device"] = this.deviceId;
|
|
1109
1167
|
let res;
|
|
1110
1168
|
try {
|
|
1111
|
-
res = await fetch(`${
|
|
1169
|
+
res = await fetch(`${base}${path}`, {
|
|
1112
1170
|
method,
|
|
1113
1171
|
headers,
|
|
1114
1172
|
body: body == null || method === "GET" ? undefined : JSON.stringify(body),
|
|
@@ -1152,6 +1210,8 @@ class TesanaClient {
|
|
|
1152
1210
|
}
|
|
1153
1211
|
this.canSaveToCloud = Boolean(this.saveEndpoint && this.saveToken);
|
|
1154
1212
|
this.offline = Boolean(config.offline) || !this.endpoint || !this.gameId;
|
|
1213
|
+
this.roomEndpoint = String(config.roomEndpoint || "").replace(/\/$/, "");
|
|
1214
|
+
this.roomToken = config.roomToken || "";
|
|
1155
1215
|
if (config.player) this._player = config.player;
|
|
1156
1216
|
if (config.vanityAddress !== undefined) {
|
|
1157
1217
|
this._vanityAddress = config.vanityAddress ? String(config.vanityAddress) : null;
|
|
@@ -1168,6 +1228,46 @@ class TesanaClient {
|
|
|
1168
1228
|
return this;
|
|
1169
1229
|
}
|
|
1170
1230
|
|
|
1231
|
+
/**
|
|
1232
|
+
* Carry on without the play service.
|
|
1233
|
+
*
|
|
1234
|
+
* The boot path calls this when the identity step fails — an unreachable
|
|
1235
|
+
* endpoint, a 500, a bad secret, a cert the browser will not accept. A game
|
|
1236
|
+
* must not die of it: the server side of this contract already returns no
|
|
1237
|
+
* token and lets the game run, so the client falls back to exactly the state
|
|
1238
|
+
* a game with no endpoint is in.
|
|
1239
|
+
*
|
|
1240
|
+
* Marking the client offline is the honest outcome rather than a silent
|
|
1241
|
+
* swallow. Every read already has an offline answer — `balance()` reports
|
|
1242
|
+
* zero coins and `canSpend: false`, the shop reports a shut shutter, saves
|
|
1243
|
+
* read as absent and write to the local cache — so the game draws its
|
|
1244
|
+
* title screen instead of throwing. A later bootstrap upgrade re-runs the
|
|
1245
|
+
* identity step and flips this back off.
|
|
1246
|
+
*
|
|
1247
|
+
* The token is cleared rather than kept: it is the thing that failed to
|
|
1248
|
+
* authenticate, and half an identity is worse than none — every call would
|
|
1249
|
+
* retry a credential the service just refused.
|
|
1250
|
+
*
|
|
1251
|
+
* @param {unknown} [reason] the failure, kept for diagnostics
|
|
1252
|
+
*/
|
|
1253
|
+
goOffline(reason) {
|
|
1254
|
+
this.offline = true;
|
|
1255
|
+
this.token = "";
|
|
1256
|
+
this.saveToken = "";
|
|
1257
|
+
this.canSaveToCloud = false;
|
|
1258
|
+
this._mpEnabled = false;
|
|
1259
|
+
this.multiplayer.enabled = false;
|
|
1260
|
+
// One deduped line rather than a wall: a page whose endpoint is
|
|
1261
|
+
// misconfigured would otherwise log this on every call.
|
|
1262
|
+
warnOnce(
|
|
1263
|
+
"play-session",
|
|
1264
|
+
`[tesana] play service unavailable; running without cloud saves or the shop${
|
|
1265
|
+
reason ? ` (${(reason && reason.message) || reason})` : ""
|
|
1266
|
+
}`,
|
|
1267
|
+
);
|
|
1268
|
+
return this;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1171
1271
|
/**
|
|
1172
1272
|
* Which origin and which credential a path uses.
|
|
1173
1273
|
*
|
|
@@ -1367,6 +1467,13 @@ class TesanaClient {
|
|
|
1367
1467
|
if (!this.canSaveToCloud) return local === undefined ? null : local;
|
|
1368
1468
|
try {
|
|
1369
1469
|
const data = await this.request("GET", `/v1/db/${enc(key)}`);
|
|
1470
|
+
if ((data.value === undefined || data.value === null) && local != null && this._isGuestSession()) {
|
|
1471
|
+
// A guest's first cloud session, after playing with saves kept only in
|
|
1472
|
+
// this browser: their progress is theirs, so it goes up rather than
|
|
1473
|
+
// being overwritten by the empty cloud answer.
|
|
1474
|
+
this.request("PUT", `/v1/db/${enc(key)}`, { value: local }).catch(() => {});
|
|
1475
|
+
return local;
|
|
1476
|
+
}
|
|
1370
1477
|
const db = readCache().db || {};
|
|
1371
1478
|
db[key] = data.value;
|
|
1372
1479
|
writeCache({ db });
|
|
@@ -1399,7 +1506,8 @@ class TesanaClient {
|
|
|
1399
1506
|
* inventory() and buy() start working without the game reloading.
|
|
1400
1507
|
*/
|
|
1401
1508
|
_signIn() {
|
|
1402
|
-
|
|
1509
|
+
const signedIn = () => !this.offline && !this._isGuestSession();
|
|
1510
|
+
if (signedIn()) return Promise.resolve({ signedIn: true });
|
|
1403
1511
|
post({ type: "tesana-sign-in-request", gameId: this.gameId });
|
|
1404
1512
|
return new Promise((resolve) => {
|
|
1405
1513
|
let expiry = null;
|
|
@@ -1414,7 +1522,7 @@ class TesanaClient {
|
|
|
1414
1522
|
// second time for still being offline — the exact dead end this whole
|
|
1415
1523
|
// exchange exists to remove.
|
|
1416
1524
|
const waitForToken = (attempt) => {
|
|
1417
|
-
if (
|
|
1525
|
+
if (signedIn()) return finish({ signedIn: true });
|
|
1418
1526
|
if ((attempt || 0) >= 60) return finish({ signedIn: true });
|
|
1419
1527
|
setTimeout(() => waitForToken((attempt || 0) + 1), 50);
|
|
1420
1528
|
};
|
|
@@ -1427,10 +1535,15 @@ class TesanaClient {
|
|
|
1427
1535
|
window.addEventListener("message", onMessage);
|
|
1428
1536
|
// Declining is an answer too. Report what is actually true rather than
|
|
1429
1537
|
// leaving the game's promise pending forever.
|
|
1430
|
-
expiry = setTimeout(() => finish({ signedIn:
|
|
1538
|
+
expiry = setTimeout(() => finish({ signedIn: signedIn() }), SIGN_IN_TIMEOUT_MS);
|
|
1431
1539
|
});
|
|
1432
1540
|
}
|
|
1433
1541
|
|
|
1542
|
+
/** A guest the site vouches for: saves in the cloud, but has no account to spend from. */
|
|
1543
|
+
_isGuestSession() {
|
|
1544
|
+
return Boolean(this._player && this._player.isGuest);
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1434
1547
|
}
|
|
1435
1548
|
|
|
1436
1549
|
function enc(value) {
|