@tesana/sdk 1.0.0 → 1.0.1

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.1";
41
41
 
42
42
  const LOCAL_CACHE = "tesana.play.v1";
43
43
 
@@ -444,7 +444,28 @@ export async function init(config = {}) {
444
444
  startListening();
445
445
  const boot = await waitBootstrap();
446
446
  const client = new TesanaClient(buildConfig(mergedConfig()));
447
- await client.ensureSession();
447
+ // An unreachable play service must cost a *feature*, never the game.
448
+ //
449
+ // This is the client half of a contract the server already keeps: Django's
450
+ // mint returns None rather than failing a request, precisely so that "the
451
+ // play service being slow, down, or unconfigured means the SDK keeps saves in
452
+ // the browser — worse, but the game still runs". Without this catch the
453
+ // client contradicted that. `ensureSession()` mints an identity over HTTP, so
454
+ // a failure here rejected `__TESANA_READY__`, and a game doing the documented
455
+ // `await window.__TESANA_READY__` threw on its first line — turning a service
456
+ // outage into a broken game for everyone using it.
457
+ //
458
+ // Falling back to offline is the correct degradation, not a swallow: every
459
+ // read already has a local or empty answer (`balance()` reports zero,
460
+ // `shop.read()` reports a shut shutter, saves read as missing), and a later
461
+ // bootstrap upgrade still re-runs the identity step through
462
+ // `onBootstrapUpgrade` below. The player loses cloud features for that
463
+ // session and keeps their game.
464
+ try {
465
+ await client.ensureSession();
466
+ } catch (err) {
467
+ client.goOffline(err);
468
+ }
448
469
  window.__TESANA_PLAYER__ = client.player.me();
449
470
  // A provisional "offline" answer can upgrade later. Re-point this client in
450
471
  // place — the game holds a reference to it — and re-run the identity step,
@@ -778,9 +799,17 @@ class TesanaClient {
778
799
  // Read this before drawing a lobby: when it is false the game is not
779
800
  // allowed rooms yet and the calls below refuse.
780
801
  enabled: this._mpEnabled,
802
+ // `_mpEnabled` alone is not enough. A game with no host has multiplayer
803
+ // "allowed" and no service to ask, so `list()` used to reject with a
804
+ // transport error — and a game that draws a lobby on load (which the
805
+ // skill tells games to do) got an unhandled rejection on its title
806
+ // screen. An empty lobby is the honest answer for a game that cannot
807
+ // reach the room service, and it is what the game can render.
781
808
  list: () =>
782
- this._mpEnabled
783
- ? this.request("GET", "/v1/mp/rooms").then((r) => r.rooms)
809
+ this._mpEnabled && !this.offline
810
+ ? this.request("GET", "/v1/mp/rooms")
811
+ .then((r) => r.rooms)
812
+ .catch(() => [])
784
813
  : Promise.resolve([]),
785
814
  create: (opts) =>
786
815
  this._mpEnabled
@@ -1168,6 +1197,46 @@ class TesanaClient {
1168
1197
  return this;
1169
1198
  }
1170
1199
 
1200
+ /**
1201
+ * Carry on without the play service.
1202
+ *
1203
+ * The boot path calls this when the identity step fails — an unreachable
1204
+ * endpoint, a 500, a bad secret, a cert the browser will not accept. A game
1205
+ * must not die of it: the server side of this contract already returns no
1206
+ * token and lets the game run, so the client falls back to exactly the state
1207
+ * a game with no endpoint is in.
1208
+ *
1209
+ * Marking the client offline is the honest outcome rather than a silent
1210
+ * swallow. Every read already has an offline answer — `balance()` reports
1211
+ * zero coins and `canSpend: false`, the shop reports a shut shutter, saves
1212
+ * read as absent and write to the local cache — so the game draws its
1213
+ * title screen instead of throwing. A later bootstrap upgrade re-runs the
1214
+ * identity step and flips this back off.
1215
+ *
1216
+ * The token is cleared rather than kept: it is the thing that failed to
1217
+ * authenticate, and half an identity is worse than none — every call would
1218
+ * retry a credential the service just refused.
1219
+ *
1220
+ * @param {unknown} [reason] the failure, kept for diagnostics
1221
+ */
1222
+ goOffline(reason) {
1223
+ this.offline = true;
1224
+ this.token = "";
1225
+ this.saveToken = "";
1226
+ this.canSaveToCloud = false;
1227
+ this._mpEnabled = false;
1228
+ this.multiplayer.enabled = false;
1229
+ // One deduped line rather than a wall: a page whose endpoint is
1230
+ // misconfigured would otherwise log this on every call.
1231
+ warnOnce(
1232
+ "play-session",
1233
+ `[tesana] play service unavailable; running without cloud saves or the shop${
1234
+ reason ? ` (${(reason && reason.message) || reason})` : ""
1235
+ }`,
1236
+ );
1237
+ return this;
1238
+ }
1239
+
1171
1240
  /**
1172
1241
  * Which origin and which credential a path uses.
1173
1242
  *
@@ -1,4 +1,4 @@
1
- /* Tesana play-time SDK v1.0.0 */
1
+ /* Tesana play-time SDK v1.0.1 */
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.1";
43
43
 
44
44
  const LOCAL_CACHE = "tesana.play.v1";
45
45
 
@@ -446,7 +446,28 @@ async function init(config = {}) {
446
446
  startListening();
447
447
  const boot = await waitBootstrap();
448
448
  const client = new TesanaClient(buildConfig(mergedConfig()));
449
- await client.ensureSession();
449
+ // An unreachable play service must cost a *feature*, never the game.
450
+ //
451
+ // This is the client half of a contract the server already keeps: Django's
452
+ // mint returns None rather than failing a request, precisely so that "the
453
+ // play service being slow, down, or unconfigured means the SDK keeps saves in
454
+ // the browser — worse, but the game still runs". Without this catch the
455
+ // client contradicted that. `ensureSession()` mints an identity over HTTP, so
456
+ // a failure here rejected `__TESANA_READY__`, and a game doing the documented
457
+ // `await window.__TESANA_READY__` threw on its first line — turning a service
458
+ // outage into a broken game for everyone using it.
459
+ //
460
+ // Falling back to offline is the correct degradation, not a swallow: every
461
+ // read already has a local or empty answer (`balance()` reports zero,
462
+ // `shop.read()` reports a shut shutter, saves read as missing), and a later
463
+ // bootstrap upgrade still re-runs the identity step through
464
+ // `onBootstrapUpgrade` below. The player loses cloud features for that
465
+ // session and keeps their game.
466
+ try {
467
+ await client.ensureSession();
468
+ } catch (err) {
469
+ client.goOffline(err);
470
+ }
450
471
  window.__TESANA_PLAYER__ = client.player.me();
451
472
  // A provisional "offline" answer can upgrade later. Re-point this client in
452
473
  // place — the game holds a reference to it — and re-run the identity step,
@@ -780,9 +801,17 @@ class TesanaClient {
780
801
  // Read this before drawing a lobby: when it is false the game is not
781
802
  // allowed rooms yet and the calls below refuse.
782
803
  enabled: this._mpEnabled,
804
+ // `_mpEnabled` alone is not enough. A game with no host has multiplayer
805
+ // "allowed" and no service to ask, so `list()` used to reject with a
806
+ // transport error — and a game that draws a lobby on load (which the
807
+ // skill tells games to do) got an unhandled rejection on its title
808
+ // screen. An empty lobby is the honest answer for a game that cannot
809
+ // reach the room service, and it is what the game can render.
783
810
  list: () =>
784
- this._mpEnabled
785
- ? this.request("GET", "/v1/mp/rooms").then((r) => r.rooms)
811
+ this._mpEnabled && !this.offline
812
+ ? this.request("GET", "/v1/mp/rooms")
813
+ .then((r) => r.rooms)
814
+ .catch(() => [])
786
815
  : Promise.resolve([]),
787
816
  create: (opts) =>
788
817
  this._mpEnabled
@@ -1170,6 +1199,46 @@ class TesanaClient {
1170
1199
  return this;
1171
1200
  }
1172
1201
 
1202
+ /**
1203
+ * Carry on without the play service.
1204
+ *
1205
+ * The boot path calls this when the identity step fails — an unreachable
1206
+ * endpoint, a 500, a bad secret, a cert the browser will not accept. A game
1207
+ * must not die of it: the server side of this contract already returns no
1208
+ * token and lets the game run, so the client falls back to exactly the state
1209
+ * a game with no endpoint is in.
1210
+ *
1211
+ * Marking the client offline is the honest outcome rather than a silent
1212
+ * swallow. Every read already has an offline answer — `balance()` reports
1213
+ * zero coins and `canSpend: false`, the shop reports a shut shutter, saves
1214
+ * read as absent and write to the local cache — so the game draws its
1215
+ * title screen instead of throwing. A later bootstrap upgrade re-runs the
1216
+ * identity step and flips this back off.
1217
+ *
1218
+ * The token is cleared rather than kept: it is the thing that failed to
1219
+ * authenticate, and half an identity is worse than none — every call would
1220
+ * retry a credential the service just refused.
1221
+ *
1222
+ * @param {unknown} [reason] the failure, kept for diagnostics
1223
+ */
1224
+ goOffline(reason) {
1225
+ this.offline = true;
1226
+ this.token = "";
1227
+ this.saveToken = "";
1228
+ this.canSaveToCloud = false;
1229
+ this._mpEnabled = false;
1230
+ this.multiplayer.enabled = false;
1231
+ // One deduped line rather than a wall: a page whose endpoint is
1232
+ // misconfigured would otherwise log this on every call.
1233
+ warnOnce(
1234
+ "play-session",
1235
+ `[tesana] play service unavailable; running without cloud saves or the shop${
1236
+ reason ? ` (${(reason && reason.message) || reason})` : ""
1237
+ }`,
1238
+ );
1239
+ return this;
1240
+ }
1241
+
1173
1242
  /**
1174
1243
  * Which origin and which credential a path uses.
1175
1244
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tesana/sdk",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
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.1";
41
41
 
42
42
  const LOCAL_CACHE = "tesana.play.v1";
43
43
 
@@ -444,7 +444,28 @@ export async function init(config = {}) {
444
444
  startListening();
445
445
  const boot = await waitBootstrap();
446
446
  const client = new TesanaClient(buildConfig(mergedConfig()));
447
- await client.ensureSession();
447
+ // An unreachable play service must cost a *feature*, never the game.
448
+ //
449
+ // This is the client half of a contract the server already keeps: Django's
450
+ // mint returns None rather than failing a request, precisely so that "the
451
+ // play service being slow, down, or unconfigured means the SDK keeps saves in
452
+ // the browser — worse, but the game still runs". Without this catch the
453
+ // client contradicted that. `ensureSession()` mints an identity over HTTP, so
454
+ // a failure here rejected `__TESANA_READY__`, and a game doing the documented
455
+ // `await window.__TESANA_READY__` threw on its first line — turning a service
456
+ // outage into a broken game for everyone using it.
457
+ //
458
+ // Falling back to offline is the correct degradation, not a swallow: every
459
+ // read already has a local or empty answer (`balance()` reports zero,
460
+ // `shop.read()` reports a shut shutter, saves read as missing), and a later
461
+ // bootstrap upgrade still re-runs the identity step through
462
+ // `onBootstrapUpgrade` below. The player loses cloud features for that
463
+ // session and keeps their game.
464
+ try {
465
+ await client.ensureSession();
466
+ } catch (err) {
467
+ client.goOffline(err);
468
+ }
448
469
  window.__TESANA_PLAYER__ = client.player.me();
449
470
  // A provisional "offline" answer can upgrade later. Re-point this client in
450
471
  // place — the game holds a reference to it — and re-run the identity step,
@@ -778,9 +799,17 @@ class TesanaClient {
778
799
  // Read this before drawing a lobby: when it is false the game is not
779
800
  // allowed rooms yet and the calls below refuse.
780
801
  enabled: this._mpEnabled,
802
+ // `_mpEnabled` alone is not enough. A game with no host has multiplayer
803
+ // "allowed" and no service to ask, so `list()` used to reject with a
804
+ // transport error — and a game that draws a lobby on load (which the
805
+ // skill tells games to do) got an unhandled rejection on its title
806
+ // screen. An empty lobby is the honest answer for a game that cannot
807
+ // reach the room service, and it is what the game can render.
781
808
  list: () =>
782
- this._mpEnabled
783
- ? this.request("GET", "/v1/mp/rooms").then((r) => r.rooms)
809
+ this._mpEnabled && !this.offline
810
+ ? this.request("GET", "/v1/mp/rooms")
811
+ .then((r) => r.rooms)
812
+ .catch(() => [])
784
813
  : Promise.resolve([]),
785
814
  create: (opts) =>
786
815
  this._mpEnabled
@@ -1168,6 +1197,46 @@ class TesanaClient {
1168
1197
  return this;
1169
1198
  }
1170
1199
 
1200
+ /**
1201
+ * Carry on without the play service.
1202
+ *
1203
+ * The boot path calls this when the identity step fails — an unreachable
1204
+ * endpoint, a 500, a bad secret, a cert the browser will not accept. A game
1205
+ * must not die of it: the server side of this contract already returns no
1206
+ * token and lets the game run, so the client falls back to exactly the state
1207
+ * a game with no endpoint is in.
1208
+ *
1209
+ * Marking the client offline is the honest outcome rather than a silent
1210
+ * swallow. Every read already has an offline answer — `balance()` reports
1211
+ * zero coins and `canSpend: false`, the shop reports a shut shutter, saves
1212
+ * read as absent and write to the local cache — so the game draws its
1213
+ * title screen instead of throwing. A later bootstrap upgrade re-runs the
1214
+ * identity step and flips this back off.
1215
+ *
1216
+ * The token is cleared rather than kept: it is the thing that failed to
1217
+ * authenticate, and half an identity is worse than none — every call would
1218
+ * retry a credential the service just refused.
1219
+ *
1220
+ * @param {unknown} [reason] the failure, kept for diagnostics
1221
+ */
1222
+ goOffline(reason) {
1223
+ this.offline = true;
1224
+ this.token = "";
1225
+ this.saveToken = "";
1226
+ this.canSaveToCloud = false;
1227
+ this._mpEnabled = false;
1228
+ this.multiplayer.enabled = false;
1229
+ // One deduped line rather than a wall: a page whose endpoint is
1230
+ // misconfigured would otherwise log this on every call.
1231
+ warnOnce(
1232
+ "play-session",
1233
+ `[tesana] play service unavailable; running without cloud saves or the shop${
1234
+ reason ? ` (${(reason && reason.message) || reason})` : ""
1235
+ }`,
1236
+ );
1237
+ return this;
1238
+ }
1239
+
1171
1240
  /**
1172
1241
  * Which origin and which credential a path uses.
1173
1242
  *