@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.
@@ -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.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
- if (!bootstrap.offline) return; // a real host has already answered
153
- if (next.offline) return; // still nobody; nothing has changed
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
- await client.ensureSession();
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? }`. Guest runs
687
- // are never listed — a ranking of identities that vanish with a tab is
688
- // not a ranking.
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.request("GET", "/v1/mp/rooms").then((r) => r.rooms)
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.request("POST", "/v1/mp/rooms", opts || {}))
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.request("POST", `/v1/mp/rooms/${enc(roomId)}/join`))
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.request("POST", "/v1/mp/quick-join", opts || {}))
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.request("POST", `/v1/mp/rooms/${enc(roomId)}/leave`);
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
- async request(method, path, body) {
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(`${this._baseFor()}${path}`, {
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
- if (!this.offline) return Promise.resolve({ signedIn: true });
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 (!this.offline) return finish({ signedIn: true });
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: !this.offline }), SIGN_IN_TIMEOUT_MS);
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) {
@@ -1,4 +1,4 @@
1
- /* Tesana play-time SDK v1.0.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.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
- if (!bootstrap.offline) return; // a real host has already answered
155
- if (next.offline) return; // still nobody; nothing has changed
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
- await client.ensureSession();
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? }`. Guest runs
689
- // are never listed — a ranking of identities that vanish with a tab is
690
- // not a ranking.
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.request("GET", "/v1/mp/rooms").then((r) => r.rooms)
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.request("POST", "/v1/mp/rooms", opts || {}))
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.request("POST", `/v1/mp/rooms/${enc(roomId)}/join`))
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.request("POST", "/v1/mp/quick-join", opts || {}))
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.request("POST", `/v1/mp/rooms/${enc(roomId)}/leave`);
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
- async request(method, path, body) {
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(`${this._baseFor()}${path}`, {
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
- if (!this.offline) return Promise.resolve({ signedIn: true });
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 (!this.offline) return finish({ signedIn: true });
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: !this.offline }), SIGN_IN_TIMEOUT_MS);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tesana/sdk",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Tesana play-time SDK — players, cloud saves, leaderboards, achievements and the in-game coin shop for a web game.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/tesana.js CHANGED
@@ -37,7 +37,7 @@
37
37
  // 1.0.0 ships six services together: saves, shop, leaderboards, achievements,
38
38
  // a vanity address (`tesana.vanity`) and in-game ads (`tesana.ads`). These are
39
39
  // the first release, so there is no earlier number to stay compatible with.
40
- const VERSION = "1.0.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
- if (!bootstrap.offline) return; // a real host has already answered
153
- if (next.offline) return; // still nobody; nothing has changed
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
- await client.ensureSession();
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? }`. Guest runs
687
- // are never listed — a ranking of identities that vanish with a tab is
688
- // not a ranking.
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.request("GET", "/v1/mp/rooms").then((r) => r.rooms)
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.request("POST", "/v1/mp/rooms", opts || {}))
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.request("POST", `/v1/mp/rooms/${enc(roomId)}/join`))
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.request("POST", "/v1/mp/quick-join", opts || {}))
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.request("POST", `/v1/mp/rooms/${enc(roomId)}/leave`);
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
- async request(method, path, body) {
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(`${this._baseFor()}${path}`, {
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
- if (!this.offline) return Promise.resolve({ signedIn: true });
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 (!this.offline) return finish({ signedIn: true });
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: !this.offline }), SIGN_IN_TIMEOUT_MS);
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) {