@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.
@@ -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.1";
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 };
@@ -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? }`. Guest runs
708
- // are never listed — a ranking of identities that vanish with a tab is
709
- // 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.
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.request("GET", "/v1/mp/rooms")
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.request("POST", "/v1/mp/rooms", opts || {}))
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.request("POST", `/v1/mp/rooms/${enc(roomId)}/join`))
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.request("POST", "/v1/mp/quick-join", opts || {}))
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.request("POST", `/v1/mp/rooms/${enc(roomId)}/leave`);
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
- 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) {
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(`${this._baseFor()}${path}`, {
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
- if (!this.offline) return Promise.resolve({ signedIn: true });
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 (!this.offline) return finish({ signedIn: true });
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: !this.offline }), SIGN_IN_TIMEOUT_MS);
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) {
@@ -1,4 +1,4 @@
1
- /* Tesana play-time SDK v1.0.1 */
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.1";
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 };
@@ -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? }`. Guest runs
710
- // are never listed — a ranking of identities that vanish with a tab is
711
- // 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.
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.request("GET", "/v1/mp/rooms")
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.request("POST", "/v1/mp/rooms", opts || {}))
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.request("POST", `/v1/mp/rooms/${enc(roomId)}/join`))
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.request("POST", "/v1/mp/quick-join", opts || {}))
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.request("POST", `/v1/mp/rooms/${enc(roomId)}/leave`);
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
- 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) {
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(`${this._baseFor()}${path}`, {
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
- if (!this.offline) return Promise.resolve({ signedIn: true });
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 (!this.offline) return finish({ signedIn: true });
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: !this.offline }), SIGN_IN_TIMEOUT_MS);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tesana/sdk",
3
- "version": "1.0.1",
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.1";
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 };
@@ -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? }`. Guest runs
708
- // are never listed — a ranking of identities that vanish with a tab is
709
- // 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.
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.request("GET", "/v1/mp/rooms")
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.request("POST", "/v1/mp/rooms", opts || {}))
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.request("POST", `/v1/mp/rooms/${enc(roomId)}/join`))
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.request("POST", "/v1/mp/quick-join", opts || {}))
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.request("POST", `/v1/mp/rooms/${enc(roomId)}/leave`);
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
- 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) {
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(`${this._baseFor()}${path}`, {
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
- if (!this.offline) return Promise.resolve({ signedIn: true });
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 (!this.offline) return finish({ signedIn: true });
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: !this.offline }), SIGN_IN_TIMEOUT_MS);
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) {