@tesana/sdk 1.0.1 → 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 +63 -19
- package/dist/tesana.iife.js +64 -20
- package/package.json +1 -1
- package/src/tesana.js +63 -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.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 };
|
|
@@ -518,6 +526,11 @@ function buildConfig(merged) {
|
|
|
518
526
|
// it still has to play — the shop stays dark and saves stay local, which
|
|
519
527
|
// is the same shape as `offline`, only without a host to have said so.
|
|
520
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 || "",
|
|
521
534
|
player: merged.player || null,
|
|
522
535
|
// The host tells the frame which public address this game answers on, so
|
|
523
536
|
// a game can show and copy its own link without asking the player to
|
|
@@ -542,7 +555,7 @@ function defaultEndpoint() {
|
|
|
542
555
|
|
|
543
556
|
class TesanaClient {
|
|
544
557
|
/**
|
|
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
|
|
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
|
|
546
559
|
*/
|
|
547
560
|
constructor(opts) {
|
|
548
561
|
this.endpoint = opts.endpoint;
|
|
@@ -577,6 +590,8 @@ class TesanaClient {
|
|
|
577
590
|
// doomed request on every save is noise, not a save.
|
|
578
591
|
this.canSaveToCloud = Boolean(this.saveEndpoint && this.saveToken);
|
|
579
592
|
this.offline = Boolean(opts.offline);
|
|
593
|
+
this.roomEndpoint = String(opts.roomEndpoint || "").replace(/\/$/, "");
|
|
594
|
+
this.roomToken = opts.roomToken || "";
|
|
580
595
|
this._player = opts.player || null;
|
|
581
596
|
const reconnect = opts.multiplayerReconnect || {};
|
|
582
597
|
this._mp = {
|
|
@@ -704,9 +719,9 @@ class TesanaClient {
|
|
|
704
719
|
submit: (board, score) =>
|
|
705
720
|
this.request("POST", "/v1/scores", { board: String(board || ""), score: Number(score) }),
|
|
706
721
|
// One board's standings, best first. `{ board, order, entries }`, where
|
|
707
|
-
// an entry is `{ rank, score, displayName, isGuest, you? }`.
|
|
708
|
-
//
|
|
709
|
-
//
|
|
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.
|
|
710
725
|
top: (board, opts) =>
|
|
711
726
|
read(
|
|
712
727
|
`/v1/scores/${enc(board)}?gameId=${enc(this.gameId)}${
|
|
@@ -806,22 +821,22 @@ class TesanaClient {
|
|
|
806
821
|
// screen. An empty lobby is the honest answer for a game that cannot
|
|
807
822
|
// reach the room service, and it is what the game can render.
|
|
808
823
|
list: () =>
|
|
809
|
-
this._mpEnabled && !this.offline
|
|
810
|
-
? this.
|
|
824
|
+
this._mpEnabled && (!this.offline || this._roomsOnly())
|
|
825
|
+
? this._mpRequest("GET", "/v1/mp/rooms")
|
|
811
826
|
.then((r) => r.rooms)
|
|
812
827
|
.catch(() => [])
|
|
813
828
|
: Promise.resolve([]),
|
|
814
829
|
create: (opts) =>
|
|
815
830
|
this._mpEnabled
|
|
816
|
-
? this._mpEnter(this.
|
|
831
|
+
? this._mpEnter(this._mpRequest("POST", "/v1/mp/rooms", opts || {}))
|
|
817
832
|
: this._mpRefuse(),
|
|
818
833
|
join: (roomId) =>
|
|
819
834
|
this._mpEnabled
|
|
820
|
-
? this._mpEnter(this.
|
|
835
|
+
? this._mpEnter(this._mpRequest("POST", `/v1/mp/rooms/${enc(roomId)}/join`))
|
|
821
836
|
: this._mpRefuse(),
|
|
822
837
|
quickJoin: (opts) =>
|
|
823
838
|
this._mpEnabled
|
|
824
|
-
? this._mpEnter(this.
|
|
839
|
+
? this._mpEnter(this._mpRequest("POST", "/v1/mp/quick-join", opts || {}))
|
|
825
840
|
: this._mpRefuse(),
|
|
826
841
|
send: (data) => this._mpSend(data),
|
|
827
842
|
on: (fn) => this._mpOn(fn),
|
|
@@ -1116,7 +1131,7 @@ class TesanaClient {
|
|
|
1116
1131
|
try {
|
|
1117
1132
|
if (!roomId) return { ok: true, room: null };
|
|
1118
1133
|
try {
|
|
1119
|
-
return await this.
|
|
1134
|
+
return await this._mpRequest("POST", `/v1/mp/rooms/${enc(roomId)}/leave`);
|
|
1120
1135
|
} catch {
|
|
1121
1136
|
return { ok: true, room: null };
|
|
1122
1137
|
}
|
|
@@ -1130,14 +1145,28 @@ class TesanaClient {
|
|
|
1130
1145
|
* @param {string} path
|
|
1131
1146
|
* @param {object} [body]
|
|
1132
1147
|
*/
|
|
1133
|
-
|
|
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) {
|
|
1134
1164
|
const headers = { "content-type": "application/json" };
|
|
1135
|
-
const authToken = this._authFor();
|
|
1136
1165
|
if (authToken) headers.authorization = `Bearer ${authToken}`;
|
|
1137
1166
|
if (this.deviceId) headers["x-tesana-device"] = this.deviceId;
|
|
1138
1167
|
let res;
|
|
1139
1168
|
try {
|
|
1140
|
-
res = await fetch(`${
|
|
1169
|
+
res = await fetch(`${base}${path}`, {
|
|
1141
1170
|
method,
|
|
1142
1171
|
headers,
|
|
1143
1172
|
body: body == null || method === "GET" ? undefined : JSON.stringify(body),
|
|
@@ -1181,6 +1210,8 @@ class TesanaClient {
|
|
|
1181
1210
|
}
|
|
1182
1211
|
this.canSaveToCloud = Boolean(this.saveEndpoint && this.saveToken);
|
|
1183
1212
|
this.offline = Boolean(config.offline) || !this.endpoint || !this.gameId;
|
|
1213
|
+
this.roomEndpoint = String(config.roomEndpoint || "").replace(/\/$/, "");
|
|
1214
|
+
this.roomToken = config.roomToken || "";
|
|
1184
1215
|
if (config.player) this._player = config.player;
|
|
1185
1216
|
if (config.vanityAddress !== undefined) {
|
|
1186
1217
|
this._vanityAddress = config.vanityAddress ? String(config.vanityAddress) : null;
|
|
@@ -1436,6 +1467,13 @@ class TesanaClient {
|
|
|
1436
1467
|
if (!this.canSaveToCloud) return local === undefined ? null : local;
|
|
1437
1468
|
try {
|
|
1438
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
|
+
}
|
|
1439
1477
|
const db = readCache().db || {};
|
|
1440
1478
|
db[key] = data.value;
|
|
1441
1479
|
writeCache({ db });
|
|
@@ -1468,7 +1506,8 @@ class TesanaClient {
|
|
|
1468
1506
|
* inventory() and buy() start working without the game reloading.
|
|
1469
1507
|
*/
|
|
1470
1508
|
_signIn() {
|
|
1471
|
-
|
|
1509
|
+
const signedIn = () => !this.offline && !this._isGuestSession();
|
|
1510
|
+
if (signedIn()) return Promise.resolve({ signedIn: true });
|
|
1472
1511
|
post({ type: "tesana-sign-in-request", gameId: this.gameId });
|
|
1473
1512
|
return new Promise((resolve) => {
|
|
1474
1513
|
let expiry = null;
|
|
@@ -1483,7 +1522,7 @@ class TesanaClient {
|
|
|
1483
1522
|
// second time for still being offline — the exact dead end this whole
|
|
1484
1523
|
// exchange exists to remove.
|
|
1485
1524
|
const waitForToken = (attempt) => {
|
|
1486
|
-
if (
|
|
1525
|
+
if (signedIn()) return finish({ signedIn: true });
|
|
1487
1526
|
if ((attempt || 0) >= 60) return finish({ signedIn: true });
|
|
1488
1527
|
setTimeout(() => waitForToken((attempt || 0) + 1), 50);
|
|
1489
1528
|
};
|
|
@@ -1496,10 +1535,15 @@ class TesanaClient {
|
|
|
1496
1535
|
window.addEventListener("message", onMessage);
|
|
1497
1536
|
// Declining is an answer too. Report what is actually true rather than
|
|
1498
1537
|
// leaving the game's promise pending forever.
|
|
1499
|
-
expiry = setTimeout(() => finish({ signedIn:
|
|
1538
|
+
expiry = setTimeout(() => finish({ signedIn: signedIn() }), SIGN_IN_TIMEOUT_MS);
|
|
1500
1539
|
});
|
|
1501
1540
|
}
|
|
1502
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
|
+
|
|
1503
1547
|
}
|
|
1504
1548
|
|
|
1505
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 };
|
|
@@ -520,6 +528,11 @@ function buildConfig(merged) {
|
|
|
520
528
|
// it still has to play — the shop stays dark and saves stay local, which
|
|
521
529
|
// is the same shape as `offline`, only without a host to have said so.
|
|
522
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 || "",
|
|
523
536
|
player: merged.player || null,
|
|
524
537
|
// The host tells the frame which public address this game answers on, so
|
|
525
538
|
// a game can show and copy its own link without asking the player to
|
|
@@ -544,7 +557,7 @@ function defaultEndpoint() {
|
|
|
544
557
|
|
|
545
558
|
class TesanaClient {
|
|
546
559
|
/**
|
|
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
|
|
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
|
|
548
561
|
*/
|
|
549
562
|
constructor(opts) {
|
|
550
563
|
this.endpoint = opts.endpoint;
|
|
@@ -579,6 +592,8 @@ class TesanaClient {
|
|
|
579
592
|
// doomed request on every save is noise, not a save.
|
|
580
593
|
this.canSaveToCloud = Boolean(this.saveEndpoint && this.saveToken);
|
|
581
594
|
this.offline = Boolean(opts.offline);
|
|
595
|
+
this.roomEndpoint = String(opts.roomEndpoint || "").replace(/\/$/, "");
|
|
596
|
+
this.roomToken = opts.roomToken || "";
|
|
582
597
|
this._player = opts.player || null;
|
|
583
598
|
const reconnect = opts.multiplayerReconnect || {};
|
|
584
599
|
this._mp = {
|
|
@@ -706,9 +721,9 @@ class TesanaClient {
|
|
|
706
721
|
submit: (board, score) =>
|
|
707
722
|
this.request("POST", "/v1/scores", { board: String(board || ""), score: Number(score) }),
|
|
708
723
|
// One board's standings, best first. `{ board, order, entries }`, where
|
|
709
|
-
// an entry is `{ rank, score, displayName, isGuest, you? }`.
|
|
710
|
-
//
|
|
711
|
-
//
|
|
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.
|
|
712
727
|
top: (board, opts) =>
|
|
713
728
|
read(
|
|
714
729
|
`/v1/scores/${enc(board)}?gameId=${enc(this.gameId)}${
|
|
@@ -808,22 +823,22 @@ class TesanaClient {
|
|
|
808
823
|
// screen. An empty lobby is the honest answer for a game that cannot
|
|
809
824
|
// reach the room service, and it is what the game can render.
|
|
810
825
|
list: () =>
|
|
811
|
-
this._mpEnabled && !this.offline
|
|
812
|
-
? this.
|
|
826
|
+
this._mpEnabled && (!this.offline || this._roomsOnly())
|
|
827
|
+
? this._mpRequest("GET", "/v1/mp/rooms")
|
|
813
828
|
.then((r) => r.rooms)
|
|
814
829
|
.catch(() => [])
|
|
815
830
|
: Promise.resolve([]),
|
|
816
831
|
create: (opts) =>
|
|
817
832
|
this._mpEnabled
|
|
818
|
-
? this._mpEnter(this.
|
|
833
|
+
? this._mpEnter(this._mpRequest("POST", "/v1/mp/rooms", opts || {}))
|
|
819
834
|
: this._mpRefuse(),
|
|
820
835
|
join: (roomId) =>
|
|
821
836
|
this._mpEnabled
|
|
822
|
-
? this._mpEnter(this.
|
|
837
|
+
? this._mpEnter(this._mpRequest("POST", `/v1/mp/rooms/${enc(roomId)}/join`))
|
|
823
838
|
: this._mpRefuse(),
|
|
824
839
|
quickJoin: (opts) =>
|
|
825
840
|
this._mpEnabled
|
|
826
|
-
? this._mpEnter(this.
|
|
841
|
+
? this._mpEnter(this._mpRequest("POST", "/v1/mp/quick-join", opts || {}))
|
|
827
842
|
: this._mpRefuse(),
|
|
828
843
|
send: (data) => this._mpSend(data),
|
|
829
844
|
on: (fn) => this._mpOn(fn),
|
|
@@ -1118,7 +1133,7 @@ class TesanaClient {
|
|
|
1118
1133
|
try {
|
|
1119
1134
|
if (!roomId) return { ok: true, room: null };
|
|
1120
1135
|
try {
|
|
1121
|
-
return await this.
|
|
1136
|
+
return await this._mpRequest("POST", `/v1/mp/rooms/${enc(roomId)}/leave`);
|
|
1122
1137
|
} catch {
|
|
1123
1138
|
return { ok: true, room: null };
|
|
1124
1139
|
}
|
|
@@ -1132,14 +1147,28 @@ class TesanaClient {
|
|
|
1132
1147
|
* @param {string} path
|
|
1133
1148
|
* @param {object} [body]
|
|
1134
1149
|
*/
|
|
1135
|
-
|
|
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) {
|
|
1136
1166
|
const headers = { "content-type": "application/json" };
|
|
1137
|
-
const authToken = this._authFor();
|
|
1138
1167
|
if (authToken) headers.authorization = `Bearer ${authToken}`;
|
|
1139
1168
|
if (this.deviceId) headers["x-tesana-device"] = this.deviceId;
|
|
1140
1169
|
let res;
|
|
1141
1170
|
try {
|
|
1142
|
-
res = await fetch(`${
|
|
1171
|
+
res = await fetch(`${base}${path}`, {
|
|
1143
1172
|
method,
|
|
1144
1173
|
headers,
|
|
1145
1174
|
body: body == null || method === "GET" ? undefined : JSON.stringify(body),
|
|
@@ -1183,6 +1212,8 @@ class TesanaClient {
|
|
|
1183
1212
|
}
|
|
1184
1213
|
this.canSaveToCloud = Boolean(this.saveEndpoint && this.saveToken);
|
|
1185
1214
|
this.offline = Boolean(config.offline) || !this.endpoint || !this.gameId;
|
|
1215
|
+
this.roomEndpoint = String(config.roomEndpoint || "").replace(/\/$/, "");
|
|
1216
|
+
this.roomToken = config.roomToken || "";
|
|
1186
1217
|
if (config.player) this._player = config.player;
|
|
1187
1218
|
if (config.vanityAddress !== undefined) {
|
|
1188
1219
|
this._vanityAddress = config.vanityAddress ? String(config.vanityAddress) : null;
|
|
@@ -1438,6 +1469,13 @@ class TesanaClient {
|
|
|
1438
1469
|
if (!this.canSaveToCloud) return local === undefined ? null : local;
|
|
1439
1470
|
try {
|
|
1440
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
|
+
}
|
|
1441
1479
|
const db = readCache().db || {};
|
|
1442
1480
|
db[key] = data.value;
|
|
1443
1481
|
writeCache({ db });
|
|
@@ -1470,7 +1508,8 @@ class TesanaClient {
|
|
|
1470
1508
|
* inventory() and buy() start working without the game reloading.
|
|
1471
1509
|
*/
|
|
1472
1510
|
_signIn() {
|
|
1473
|
-
|
|
1511
|
+
const signedIn = () => !this.offline && !this._isGuestSession();
|
|
1512
|
+
if (signedIn()) return Promise.resolve({ signedIn: true });
|
|
1474
1513
|
post({ type: "tesana-sign-in-request", gameId: this.gameId });
|
|
1475
1514
|
return new Promise((resolve) => {
|
|
1476
1515
|
let expiry = null;
|
|
@@ -1485,7 +1524,7 @@ class TesanaClient {
|
|
|
1485
1524
|
// second time for still being offline — the exact dead end this whole
|
|
1486
1525
|
// exchange exists to remove.
|
|
1487
1526
|
const waitForToken = (attempt) => {
|
|
1488
|
-
if (
|
|
1527
|
+
if (signedIn()) return finish({ signedIn: true });
|
|
1489
1528
|
if ((attempt || 0) >= 60) return finish({ signedIn: true });
|
|
1490
1529
|
setTimeout(() => waitForToken((attempt || 0) + 1), 50);
|
|
1491
1530
|
};
|
|
@@ -1498,10 +1537,15 @@ class TesanaClient {
|
|
|
1498
1537
|
window.addEventListener("message", onMessage);
|
|
1499
1538
|
// Declining is an answer too. Report what is actually true rather than
|
|
1500
1539
|
// leaving the game's promise pending forever.
|
|
1501
|
-
expiry = setTimeout(() => finish({ signedIn:
|
|
1540
|
+
expiry = setTimeout(() => finish({ signedIn: signedIn() }), SIGN_IN_TIMEOUT_MS);
|
|
1502
1541
|
});
|
|
1503
1542
|
}
|
|
1504
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
|
+
|
|
1505
1549
|
}
|
|
1506
1550
|
|
|
1507
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 };
|
|
@@ -518,6 +526,11 @@ function buildConfig(merged) {
|
|
|
518
526
|
// it still has to play — the shop stays dark and saves stay local, which
|
|
519
527
|
// is the same shape as `offline`, only without a host to have said so.
|
|
520
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 || "",
|
|
521
534
|
player: merged.player || null,
|
|
522
535
|
// The host tells the frame which public address this game answers on, so
|
|
523
536
|
// a game can show and copy its own link without asking the player to
|
|
@@ -542,7 +555,7 @@ function defaultEndpoint() {
|
|
|
542
555
|
|
|
543
556
|
class TesanaClient {
|
|
544
557
|
/**
|
|
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
|
|
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
|
|
546
559
|
*/
|
|
547
560
|
constructor(opts) {
|
|
548
561
|
this.endpoint = opts.endpoint;
|
|
@@ -577,6 +590,8 @@ class TesanaClient {
|
|
|
577
590
|
// doomed request on every save is noise, not a save.
|
|
578
591
|
this.canSaveToCloud = Boolean(this.saveEndpoint && this.saveToken);
|
|
579
592
|
this.offline = Boolean(opts.offline);
|
|
593
|
+
this.roomEndpoint = String(opts.roomEndpoint || "").replace(/\/$/, "");
|
|
594
|
+
this.roomToken = opts.roomToken || "";
|
|
580
595
|
this._player = opts.player || null;
|
|
581
596
|
const reconnect = opts.multiplayerReconnect || {};
|
|
582
597
|
this._mp = {
|
|
@@ -704,9 +719,9 @@ class TesanaClient {
|
|
|
704
719
|
submit: (board, score) =>
|
|
705
720
|
this.request("POST", "/v1/scores", { board: String(board || ""), score: Number(score) }),
|
|
706
721
|
// One board's standings, best first. `{ board, order, entries }`, where
|
|
707
|
-
// an entry is `{ rank, score, displayName, isGuest, you? }`.
|
|
708
|
-
//
|
|
709
|
-
//
|
|
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.
|
|
710
725
|
top: (board, opts) =>
|
|
711
726
|
read(
|
|
712
727
|
`/v1/scores/${enc(board)}?gameId=${enc(this.gameId)}${
|
|
@@ -806,22 +821,22 @@ class TesanaClient {
|
|
|
806
821
|
// screen. An empty lobby is the honest answer for a game that cannot
|
|
807
822
|
// reach the room service, and it is what the game can render.
|
|
808
823
|
list: () =>
|
|
809
|
-
this._mpEnabled && !this.offline
|
|
810
|
-
? this.
|
|
824
|
+
this._mpEnabled && (!this.offline || this._roomsOnly())
|
|
825
|
+
? this._mpRequest("GET", "/v1/mp/rooms")
|
|
811
826
|
.then((r) => r.rooms)
|
|
812
827
|
.catch(() => [])
|
|
813
828
|
: Promise.resolve([]),
|
|
814
829
|
create: (opts) =>
|
|
815
830
|
this._mpEnabled
|
|
816
|
-
? this._mpEnter(this.
|
|
831
|
+
? this._mpEnter(this._mpRequest("POST", "/v1/mp/rooms", opts || {}))
|
|
817
832
|
: this._mpRefuse(),
|
|
818
833
|
join: (roomId) =>
|
|
819
834
|
this._mpEnabled
|
|
820
|
-
? this._mpEnter(this.
|
|
835
|
+
? this._mpEnter(this._mpRequest("POST", `/v1/mp/rooms/${enc(roomId)}/join`))
|
|
821
836
|
: this._mpRefuse(),
|
|
822
837
|
quickJoin: (opts) =>
|
|
823
838
|
this._mpEnabled
|
|
824
|
-
? this._mpEnter(this.
|
|
839
|
+
? this._mpEnter(this._mpRequest("POST", "/v1/mp/quick-join", opts || {}))
|
|
825
840
|
: this._mpRefuse(),
|
|
826
841
|
send: (data) => this._mpSend(data),
|
|
827
842
|
on: (fn) => this._mpOn(fn),
|
|
@@ -1116,7 +1131,7 @@ class TesanaClient {
|
|
|
1116
1131
|
try {
|
|
1117
1132
|
if (!roomId) return { ok: true, room: null };
|
|
1118
1133
|
try {
|
|
1119
|
-
return await this.
|
|
1134
|
+
return await this._mpRequest("POST", `/v1/mp/rooms/${enc(roomId)}/leave`);
|
|
1120
1135
|
} catch {
|
|
1121
1136
|
return { ok: true, room: null };
|
|
1122
1137
|
}
|
|
@@ -1130,14 +1145,28 @@ class TesanaClient {
|
|
|
1130
1145
|
* @param {string} path
|
|
1131
1146
|
* @param {object} [body]
|
|
1132
1147
|
*/
|
|
1133
|
-
|
|
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) {
|
|
1134
1164
|
const headers = { "content-type": "application/json" };
|
|
1135
|
-
const authToken = this._authFor();
|
|
1136
1165
|
if (authToken) headers.authorization = `Bearer ${authToken}`;
|
|
1137
1166
|
if (this.deviceId) headers["x-tesana-device"] = this.deviceId;
|
|
1138
1167
|
let res;
|
|
1139
1168
|
try {
|
|
1140
|
-
res = await fetch(`${
|
|
1169
|
+
res = await fetch(`${base}${path}`, {
|
|
1141
1170
|
method,
|
|
1142
1171
|
headers,
|
|
1143
1172
|
body: body == null || method === "GET" ? undefined : JSON.stringify(body),
|
|
@@ -1181,6 +1210,8 @@ class TesanaClient {
|
|
|
1181
1210
|
}
|
|
1182
1211
|
this.canSaveToCloud = Boolean(this.saveEndpoint && this.saveToken);
|
|
1183
1212
|
this.offline = Boolean(config.offline) || !this.endpoint || !this.gameId;
|
|
1213
|
+
this.roomEndpoint = String(config.roomEndpoint || "").replace(/\/$/, "");
|
|
1214
|
+
this.roomToken = config.roomToken || "";
|
|
1184
1215
|
if (config.player) this._player = config.player;
|
|
1185
1216
|
if (config.vanityAddress !== undefined) {
|
|
1186
1217
|
this._vanityAddress = config.vanityAddress ? String(config.vanityAddress) : null;
|
|
@@ -1436,6 +1467,13 @@ class TesanaClient {
|
|
|
1436
1467
|
if (!this.canSaveToCloud) return local === undefined ? null : local;
|
|
1437
1468
|
try {
|
|
1438
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
|
+
}
|
|
1439
1477
|
const db = readCache().db || {};
|
|
1440
1478
|
db[key] = data.value;
|
|
1441
1479
|
writeCache({ db });
|
|
@@ -1468,7 +1506,8 @@ class TesanaClient {
|
|
|
1468
1506
|
* inventory() and buy() start working without the game reloading.
|
|
1469
1507
|
*/
|
|
1470
1508
|
_signIn() {
|
|
1471
|
-
|
|
1509
|
+
const signedIn = () => !this.offline && !this._isGuestSession();
|
|
1510
|
+
if (signedIn()) return Promise.resolve({ signedIn: true });
|
|
1472
1511
|
post({ type: "tesana-sign-in-request", gameId: this.gameId });
|
|
1473
1512
|
return new Promise((resolve) => {
|
|
1474
1513
|
let expiry = null;
|
|
@@ -1483,7 +1522,7 @@ class TesanaClient {
|
|
|
1483
1522
|
// second time for still being offline — the exact dead end this whole
|
|
1484
1523
|
// exchange exists to remove.
|
|
1485
1524
|
const waitForToken = (attempt) => {
|
|
1486
|
-
if (
|
|
1525
|
+
if (signedIn()) return finish({ signedIn: true });
|
|
1487
1526
|
if ((attempt || 0) >= 60) return finish({ signedIn: true });
|
|
1488
1527
|
setTimeout(() => waitForToken((attempt || 0) + 1), 50);
|
|
1489
1528
|
};
|
|
@@ -1496,10 +1535,15 @@ class TesanaClient {
|
|
|
1496
1535
|
window.addEventListener("message", onMessage);
|
|
1497
1536
|
// Declining is an answer too. Report what is actually true rather than
|
|
1498
1537
|
// leaving the game's promise pending forever.
|
|
1499
|
-
expiry = setTimeout(() => finish({ signedIn:
|
|
1538
|
+
expiry = setTimeout(() => finish({ signedIn: signedIn() }), SIGN_IN_TIMEOUT_MS);
|
|
1500
1539
|
});
|
|
1501
1540
|
}
|
|
1502
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
|
+
|
|
1503
1547
|
}
|
|
1504
1548
|
|
|
1505
1549
|
function enc(value) {
|