@tesana/sdk 1.0.4 → 1.1.0

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.
@@ -1,10 +1,10 @@
1
- /* Tesana play-time SDK v1.0.4 */
1
+ /* Tesana play-time SDK v1.1.0 */
2
2
  (function (root) {
3
3
  /**
4
4
  * Tesana play-time SDK. Vanilla JS, no npm in games.
5
5
  * Host injects window.__TESANA__ or postMessage { type: "tesana-bootstrap", ... }.
6
6
  * v1 is player identity, per-game saves, optional open-lobby multiplayer,
7
- * cloud saves, and the in-game coin economy.
7
+ * cloud saves, the in-game coin economy, and gameplay analytics.
8
8
  *
9
9
  * THIS FILE IS THE ONE COPY. It is the source of truth for `window.Tesana`:
10
10
  * the engine injects the built artifact into every game and the CDN serves it
@@ -39,7 +39,11 @@
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.4";
42
+ //
43
+ // 1.1.0 adds gameplay analytics, `tesana.track` and `tesana.gameplay.*`. The
44
+ // engine's analytics skill already taught them, so a game following it threw a
45
+ // TypeError on its first death; this is the release that makes those calls real.
46
+ const VERSION = "1.1.0";
43
47
 
44
48
  const LOCAL_CACHE = "tesana.play.v1";
45
49
 
@@ -55,6 +59,21 @@ const LOCAL_CACHE = "tesana.play.v1";
55
59
  const MP_RECONNECT_BASE_MS = 1000;
56
60
  const MP_RECONNECT_MAX_MS = 30000;
57
61
  const MP_RECONNECT_MAX_ATTEMPTS = 8;
62
+ //: How long to wait for the host to answer a bootstrap request before giving
63
+ //: up on that attempt. The answer is a token mint; a host that is slow must not
64
+ //: hold a request open indefinitely.
65
+ const MP_HOST_ASK_TIMEOUT_MS = 5000;
66
+ //: How long `create`/`quickJoin` will wait for a room that is being placed.
67
+ //:
68
+ //: Room creation on GameLift is asynchronous, so the service answers 202 with
69
+ //: no `wsUrl` and a `retryAfterMs` instead of holding the request open. The
70
+ //: client polls until the room is ready or this budget runs out. It is longer
71
+ //: than the service's own placement poll (90s) only in the sense that it gives
72
+ //: up first: a client should decide for itself how long it will wait, and a
73
+ //: game can watch `onStatus` rather than being blocked.
74
+ const MP_PLACING_TIMEOUT_MS = 45000;
75
+ //: Default gap between placement polls, used when the service does not say.
76
+ const MP_PLACING_RETRY_MS = 1500;
58
77
 
59
78
  /**
60
79
  * A polite, dns-safe label: lowercase letters, digits and internal hyphens,
@@ -128,6 +147,18 @@ const bootstrapWaiters = [];
128
147
  // can re-point them in place. A game holds a reference to its client, so it
129
148
  // must not be rebuilt — only upgraded.
130
149
  const upgradeHandlers = [];
150
+ // Whether a bootstrap the client asked for is in flight. It is what makes a
151
+ // late answer an update rather than an unsolicited message that re-points a
152
+ // live session.
153
+ let bootstrapRequestPending = false;
154
+ // The in-flight ask, so a burst of refused calls mints once rather than once
155
+ // per caller. The host does its own rate-limiting; this only removes the
156
+ // duplicate round-trips a simultaneous burst would otherwise make.
157
+ let bootstrapAskPromise = null;
158
+ //: Bumped every time a host answer replaces the live bootstrap. A caller that
159
+ //: asked for a fresh token can tell whether it actually arrived by watching
160
+ //: this, without having to hold a listener of its own.
161
+ let bootstrapGeneration = 0;
131
162
 
132
163
  /**
133
164
  * Diagnostics that should say their piece once per session, not once per call.
@@ -152,17 +183,34 @@ function isGuestBootstrap(data) {
152
183
  }
153
184
 
154
185
  /** Fold a host's answer into the live config, upgrading a provisional one. */
155
- function applyBootstrap(data) {
186
+ function applyBootstrap(data, opts = {}) {
156
187
  const next = data && typeof data === "object" ? data : {};
188
+ // Whether the client asked for this answer. A bootstrap it asked for is a
189
+ // refresh — its token aged out and the host minted another — and may update a
190
+ // live session. One it did not ask for may not, or anything able to post into
191
+ // the frame could re-point a running game's credential and identity.
192
+ const asked = opts.force || bootstrapRequestPending;
193
+ bootstrapRequestPending = false;
157
194
  if (bootstrap) {
158
195
  // A real host has already answered — unless that answer was a guest and
159
196
  // this one is the account they just signed in to.
160
- if (!bootstrap.offline && !(isGuestBootstrap(bootstrap) && !next.offline && !isGuestBootstrap(next))) return;
197
+ if (!bootstrap.offline && !(isGuestBootstrap(bootstrap) && !next.offline && !isGuestBootstrap(next))) {
198
+ if (asked) {
199
+ // A forced update may refresh credentials (token, save token, room
200
+ // token) but never the player's identity: the player is who the
201
+ // signing-in page said they are, and a stale or mismatched answer must
202
+ // not silently become them.
203
+ if (bootstrap.player && next.player !== undefined) delete next.player;
204
+ } else {
205
+ return;
206
+ }
207
+ }
161
208
  // Still offline, unless it now carries a room token: a signed-out
162
209
  // visitor's multiplayer arrives after the site has already said "offline".
163
- if (next.offline && !(next.roomToken && !bootstrap.roomToken)) return;
210
+ if (next.offline && !(next.roomToken && !bootstrap.roomToken) && !asked) return;
164
211
  }
165
212
  bootstrap = next;
213
+ bootstrapGeneration += 1;
166
214
  window.__TESANA__ = { ...(window.__TESANA__ || {}), ...next };
167
215
  const waiting = bootstrapWaiters.splice(0);
168
216
  for (const resolve of waiting) resolve(bootstrap);
@@ -404,6 +452,8 @@ function startListening() {
404
452
  // The site posts `{ type, payload }`; a baked or spliced bootstrap is the
405
453
  // payload itself. Accept both, so one SDK reads either host.
406
454
  if (data.type === "tesana-bootstrap") {
455
+ // `applyBootstrap` decides whether this answer may update a live session:
456
+ // one the client asked for may, one it did not may not.
407
457
  applyBootstrap(data.payload && typeof data.payload === "object" ? data.payload : data);
408
458
  return;
409
459
  }
@@ -466,16 +516,73 @@ function waitBootstrap(timeoutMs = 1500) {
466
516
  if (bootstrap) return Promise.resolve(bootstrap);
467
517
 
468
518
  startListening();
469
- // Ask, rather than wait to be told. The frame can finish loading before the
470
- // site has minted the session, and a request is what makes the site post the
471
- // second time — without it a signed-in player's real bootstrap never arrives.
472
- post({ type: "tesana-bootstrap-request" });
473
519
  return new Promise((resolve) => {
520
+ // The waiter is registered before the ask goes out. A host that answers
521
+ // from the same tick — a test double, or a same-document frame — would
522
+ // otherwise be answered into an empty list and the game would fall to
523
+ // "offline" a second and a half later for no reason.
474
524
  bootstrapWaiters.push(resolve);
475
525
  setTimeout(() => applyBootstrap({ offline: true }), timeoutMs);
526
+ // Ask, rather than wait to be told. The frame can finish loading before the
527
+ // site has minted the session, and a request is what makes the site post the
528
+ // second time — without it a signed-in player's real bootstrap never arrives.
529
+ askHostForBootstrap();
476
530
  });
477
531
  }
478
532
 
533
+ /** Ask the host to post a bootstrap. Shared by boot and refresh, so the flag
534
+ * that makes an answer an update is set in exactly one place. The message name
535
+ * is what tells the host whether to re-mint or merely re-post. */
536
+ function askHostForBootstrap(type = "tesana-bootstrap-request") {
537
+ bootstrapRequestPending = true;
538
+ post({ type });
539
+ }
540
+
541
+ /**
542
+ * Ask the host for a fresh bootstrap after a credential was refused.
543
+ *
544
+ * This is the SDK half of token expiry. A player token lives two hours and
545
+ * used to be re-minted only on a page load, so after two hours a blip was
546
+ * permanent until the player reloaded. Asking the host re-mints through it, and
547
+ * the answer flows back through the same upgrade path identity and ads already
548
+ * use, so a live game is re-pointed in place.
549
+ *
550
+ * Resolves with the new bootstrap, or `null` when there is no host or the host
551
+ * did not answer in time. Concurrent callers share one ask: a game that has
552
+ * been open for hours hits a burst of refusals at once, and one mint is what
553
+ * all of them needed. The host rate-limits the mints themselves.
554
+ *
555
+ * @returns {Promise<object|null>}
556
+ */
557
+ function requestHostBootstrap() {
558
+ if (typeof window === "undefined") return Promise.resolve(null);
559
+ if (bootstrapAskPromise) return bootstrapAskPromise;
560
+ startListening();
561
+ const askedAt = bootstrapGeneration;
562
+ // A refresh, named as one. The host re-mints on this and merely re-posts on a
563
+ // boot request, so the two are never confused.
564
+ askHostForBootstrap("tesana-bootstrap-refresh");
565
+ bootstrapAskPromise = new Promise((resolve) => {
566
+ let done = false;
567
+ const finish = (value) => {
568
+ if (done) return;
569
+ done = true;
570
+ clearInterval(poll);
571
+ clearTimeout(expiry);
572
+ bootstrapAskPromise = null;
573
+ resolve(value);
574
+ };
575
+ // The bootstrap is delivered by the module-level listener, so watch the
576
+ // generation rather than registering a second listener — the token the host
577
+ // mints is scoped to this frame and is not ours to consume only here.
578
+ const poll = setInterval(() => {
579
+ if (bootstrapGeneration !== askedAt) finish(bootstrap);
580
+ }, 25);
581
+ const expiry = setTimeout(() => finish(null), MP_HOST_ASK_TIMEOUT_MS);
582
+ });
583
+ return bootstrapAskPromise;
584
+ }
585
+
479
586
  function deviceId() {
480
587
  const cache = readCache();
481
588
  if (cache.deviceId) return cache.deviceId;
@@ -520,6 +627,308 @@ function post(message) {
520
627
  }
521
628
  }
522
629
 
630
+ // ---------------------------------------------------------------------------
631
+ // Gameplay analytics: `tesana.track` and `tesana.gameplay`
632
+ // ---------------------------------------------------------------------------
633
+ //
634
+ // A game reports what happened in it (a level started, the player died, a run
635
+ // ended with a score) so its creator can see where players get stuck and where
636
+ // they quit. The pipe already exists end to end: the runtime shell injected
637
+ // into every web build exposes `window.__TESANA_EVENT__`, the Tesana page above
638
+ // the game buffers `tesana-gameplay-event` messages and files them against the
639
+ // play session with a token only it holds, and the ingest endpoint stores them.
640
+ // These functions are the game's end of that pipe.
641
+ //
642
+ // Three rules shape everything below:
643
+ //
644
+ // - **Never throw.** The engine tells the AI to call these inline, at the
645
+ // moment a player dies, with no guard. A TypeError here would turn a death
646
+ // into a crash, so every entry point is wrapped and answers `false` when it
647
+ // drops something.
648
+ // - **Match the ingest endpoint's caps.** The server keeps at most 20 payload
649
+ // keys, 40-character keys, 64-character strings and 2,000 characters in all,
650
+ // and throws away the whole payload when it is over. Trimming here means a
651
+ // slightly-too-big event loses a key rather than all of its context.
652
+ // - **Discrete moments, not frames.** A game that reports on every frame
653
+ // would fill the table and answer nothing, so events are rate limited per
654
+ // second and capped per page load.
655
+ //
656
+ // They are synchronous and need no `init()`: they never touch the play service,
657
+ // only the page, so a game can report a level start on its first frame.
658
+
659
+ //: The message the host's play tracking listens for. Mirrored by the web host
660
+ //: (`PLAY_MESSAGES.gameplayEvent`) and the runtime shell.
661
+ const GAMEPLAY_MESSAGE = "tesana-gameplay-event";
662
+
663
+ //: The event types the ingest endpoint understands, minus the two the shell
664
+ //: owns (`session_end`, `error`). `track()` with one of these names files the
665
+ //: event under that type; any other name is a `custom` event.
666
+ const GAMEPLAY_EVENT_TYPES = new Set(["level_start", "level_complete", "death", "checkpoint", "score"]);
667
+ const GAMEPLAY_TYPE_ALIASES = { levelStart: "level_start", levelComplete: "level_complete" };
668
+
669
+ //: The ingest endpoint's own limits (`games/api/play_events.py`).
670
+ const GAMEPLAY_NAME_MAX = 64;
671
+ const GAMEPLAY_STRING_MAX = 64;
672
+ const GAMEPLAY_KEY_MAX = 40;
673
+ const GAMEPLAY_PROPS_MAX_KEYS = 20;
674
+ const GAMEPLAY_PROPS_MAX_CHARS = 2000;
675
+
676
+ //: At most this many events in any one second. Ten a second is exactly what
677
+ //: one host flush can carry (100 events every 10 seconds), and far above what a
678
+ //: game reporting real moments ever needs.
679
+ const GAMEPLAY_RATE_PER_SECOND = 10;
680
+ //: At most this many events per page load. An honest session sends tens; a
681
+ //: game stuck reporting at the rate limit for an hour would send 36,000.
682
+ const GAMEPLAY_SESSION_MAX = 1000;
683
+
684
+ //: Props promoted to the event's own columns rather than kept as context.
685
+ const GAMEPLAY_LEVEL_ID_KEYS = ["levelId", "level_id", "level"];
686
+ const GAMEPLAY_LEVEL_INDEX_KEYS = ["levelIndex", "level_index"];
687
+ const GAMEPLAY_NUMBER_KEYS = ["x", "y", "z", "value"];
688
+ const GAMEPLAY_PROMOTED = new Set([
689
+ ...GAMEPLAY_LEVEL_ID_KEYS,
690
+ ...GAMEPLAY_LEVEL_INDEX_KEYS,
691
+ ...GAMEPLAY_NUMBER_KEYS,
692
+ "name",
693
+ ]);
694
+
695
+ let gameplaySecond = -1;
696
+ let gameplayInSecond = 0;
697
+ let gameplaySent = 0;
698
+
699
+ /** A finite number, or `undefined`. Numeric strings count; NaN never does. */
700
+ function gameplayNumber(value) {
701
+ if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
702
+ if (typeof value === "string" && value.trim() !== "") {
703
+ const parsed = Number(value);
704
+ return Number.isFinite(parsed) ? parsed : undefined;
705
+ }
706
+ return undefined;
707
+ }
708
+
709
+ /** A trimmed, capped string id from a string or a number, or `undefined`. */
710
+ function gameplayId(value, limit) {
711
+ if (typeof value === "number") {
712
+ if (!Number.isFinite(value)) return undefined;
713
+ return String(value).slice(0, limit);
714
+ }
715
+ if (typeof value !== "string") return undefined;
716
+ const text = value.trim().slice(0, limit);
717
+ return text || undefined;
718
+ }
719
+
720
+ /** The first of `keys` that `props` holds a value for. */
721
+ function gameplayFirst(props, keys) {
722
+ for (const key of keys) {
723
+ if (props[key] !== undefined && props[key] !== null) return props[key];
724
+ }
725
+ return undefined;
726
+ }
727
+
728
+ /**
729
+ * Roughly how long the server's `repr()` of this payload will be.
730
+ *
731
+ * The endpoint measures Python's `repr`, not JSON: it writes `1.0` for `1` and
732
+ * puts a space after each colon and comma. Four characters per key is a safe
733
+ * margin over both differences, so a payload that passes here passes there.
734
+ */
735
+ function gameplayPayloadSize(payload, keys) {
736
+ return JSON.stringify(payload).length + 4 * keys;
737
+ }
738
+
739
+ /**
740
+ * Keep the scalar context a game passed, within the endpoint's caps.
741
+ *
742
+ * @param {object} props
743
+ * @param {boolean} keepName keep a `name` prop as context (a custom event, whose
744
+ * own name is the event's name) rather than treating it as the name column
745
+ */
746
+ function gameplayPayload(props, keepName) {
747
+ const payload = {};
748
+ let kept = 0;
749
+ for (const rawKey of Object.keys(props)) {
750
+ if (kept >= GAMEPLAY_PROPS_MAX_KEYS) break;
751
+ if (GAMEPLAY_PROMOTED.has(rawKey) && !(keepName && rawKey === "name")) continue;
752
+ const key = String(rawKey).trim().slice(0, GAMEPLAY_KEY_MAX);
753
+ if (!key || Object.prototype.hasOwnProperty.call(payload, key)) continue;
754
+ let value;
755
+ try {
756
+ value = props[rawKey];
757
+ } catch {
758
+ continue;
759
+ }
760
+ if (typeof value === "string") {
761
+ value = value.slice(0, GAMEPLAY_STRING_MAX);
762
+ } else if (typeof value === "number") {
763
+ if (!Number.isFinite(value)) continue;
764
+ } else if (typeof value !== "boolean" && value !== null) {
765
+ // Objects, arrays, functions, undefined, symbols and bigints are not
766
+ // stored: the analytics layer queries flat keys, and a nested shape it
767
+ // did not expect is a foot-gun rather than a feature.
768
+ continue;
769
+ }
770
+ payload[key] = value;
771
+ if (gameplayPayloadSize(payload, kept + 1) > GAMEPLAY_PROPS_MAX_CHARS) {
772
+ delete payload[key];
773
+ continue;
774
+ }
775
+ kept += 1;
776
+ }
777
+ return kept ? payload : null;
778
+ }
779
+
780
+ /**
781
+ * Build the event the host files, in the shape `POST /play/events` accepts.
782
+ *
783
+ * @param {string} type one of the ingest endpoint's event types
784
+ * @param {string|undefined} name the event's own name (a custom event, a death's cause)
785
+ * @param {object} props what the game passed
786
+ */
787
+ function gameplayEvent(type, name, props) {
788
+ const event = { type };
789
+ const named = name !== undefined ? name : gameplayId(props.name, GAMEPLAY_NAME_MAX);
790
+ if (named) event.name = named;
791
+ const levelId = gameplayId(gameplayFirst(props, GAMEPLAY_LEVEL_ID_KEYS), GAMEPLAY_STRING_MAX);
792
+ if (levelId) event.level_id = levelId;
793
+ const levelIndex = gameplayNumber(gameplayFirst(props, GAMEPLAY_LEVEL_INDEX_KEYS));
794
+ if (levelIndex !== undefined) event.level_index = Math.max(0, Math.floor(levelIndex));
795
+ for (const key of GAMEPLAY_NUMBER_KEYS) {
796
+ const number = gameplayNumber(props[key]);
797
+ if (number !== undefined) event[key] = number;
798
+ }
799
+ // A custom event's own name is already its name, so a `name` prop is kept as
800
+ // context like any other key instead of overwriting it.
801
+ const payload = gameplayPayload(props, type === "custom");
802
+ if (payload) event.payload = payload;
803
+ event.ts = Date.now();
804
+ return event;
805
+ }
806
+
807
+ /** Whether one more event fits this second and this page load. */
808
+ function gameplayAllowed() {
809
+ if (gameplaySent >= GAMEPLAY_SESSION_MAX) {
810
+ warnOnce(
811
+ "gameplay-session-max",
812
+ `tesana.track: ${GAMEPLAY_SESSION_MAX} events this session; further events are dropped. Report discrete moments (a level, a death, a run's end), never every frame.`,
813
+ );
814
+ return false;
815
+ }
816
+ const second = Math.floor(Date.now() / 1000);
817
+ if (second !== gameplaySecond) {
818
+ gameplaySecond = second;
819
+ gameplayInSecond = 0;
820
+ }
821
+ if (gameplayInSecond >= GAMEPLAY_RATE_PER_SECOND) {
822
+ warnOnce(
823
+ "gameplay-rate",
824
+ `tesana.track: more than ${GAMEPLAY_RATE_PER_SECOND} events in one second; the extra ones are dropped. Report discrete moments, never every frame.`,
825
+ );
826
+ return false;
827
+ }
828
+ gameplayInSecond += 1;
829
+ gameplaySent += 1;
830
+ return true;
831
+ }
832
+
833
+ /**
834
+ * Hand an event to whoever files it. `true` when it was handed on.
835
+ *
836
+ * The shell's `__TESANA_EVENT__` comes first: it posts the same message, and
837
+ * going through it lets the shell name the last level in its `session_end`.
838
+ * Without the shell, the message goes straight to the Tesana page above. With
839
+ * neither there is nobody to tell, and the event is dropped.
840
+ */
841
+ function gameplayDeliver(event) {
842
+ if (typeof window === "undefined") return false;
843
+ const bridge = window.__TESANA_EVENT__;
844
+ if (typeof bridge === "function") {
845
+ bridge(event);
846
+ return true;
847
+ }
848
+ if (!hasHost()) return false;
849
+ post({ type: GAMEPLAY_MESSAGE, event });
850
+ return true;
851
+ }
852
+
853
+ /** The game's props as a plain object, whatever it actually passed. */
854
+ function gameplayProps(props) {
855
+ return props && typeof props === "object" && !Array.isArray(props) ? props : {};
856
+ }
857
+
858
+ /**
859
+ * Report something that happened in the game.
860
+ *
861
+ * `name` is the game's own name for it (`"powerup_picked"`). A name that is one
862
+ * of the platform's types (`"death"`, `"level_start"`, `"level_complete"`,
863
+ * `"checkpoint"`, `"score"`) is filed as that type. `props` is a small object of
864
+ * scalars; `levelId`, `levelIndex`, `x`, `y`, `z` and `value` become columns and
865
+ * anything else rides along as context.
866
+ *
867
+ * Synchronous, never throws, and never returns a promise: `true` when the event
868
+ * was handed on, `false` when it was dropped (a bad name, too many events, or
869
+ * nothing above the game to hand it to).
870
+ *
871
+ * @param {string} name
872
+ * @param {Record<string, string|number|boolean|null>} [props]
873
+ * @returns {boolean}
874
+ */
875
+ function track(name, props) {
876
+ try {
877
+ const label = gameplayId(name, GAMEPLAY_NAME_MAX);
878
+ if (!label) {
879
+ warnOnce("gameplay-name", "tesana.track: the first argument must be a non-empty event name.");
880
+ return false;
881
+ }
882
+ if (!gameplayAllowed()) return false;
883
+ const builtIn = GAMEPLAY_TYPE_ALIASES[label] || label;
884
+ const event = GAMEPLAY_EVENT_TYPES.has(builtIn)
885
+ ? gameplayEvent(builtIn, undefined, gameplayProps(props))
886
+ : gameplayEvent("custom", label, gameplayProps(props));
887
+ return gameplayDeliver(event);
888
+ } catch {
889
+ return false;
890
+ }
891
+ }
892
+
893
+ /**
894
+ * One gameplay helper. `props` may also be a bare level id (`levelStart(3)`), or
895
+ * for `score` a bare value (`score(1200)`).
896
+ *
897
+ * @param {string} type
898
+ * @param {"levelId"|"value"} shorthand
899
+ */
900
+ function gameplayHelper(type, shorthand) {
901
+ return function report(props) {
902
+ try {
903
+ if (!gameplayAllowed()) return false;
904
+ const given = typeof props === "string" || typeof props === "number"
905
+ ? { [shorthand]: props }
906
+ : gameplayProps(props);
907
+ return gameplayDeliver(gameplayEvent(type, undefined, given));
908
+ } catch {
909
+ return false;
910
+ }
911
+ };
912
+ }
913
+
914
+ /**
915
+ * The progression events every game with levels, waves, runs or deaths should
916
+ * send. `levelId` is the join key: give it the same stable value in each call
917
+ * for one level, so a funnel can line them up.
918
+ */
919
+ const gameplay = Object.freeze({
920
+ /** A level, stage, wave or area begins: `{ levelId, levelIndex? }`. */
921
+ levelStart: gameplayHelper("level_start", "levelId"),
922
+ /** A level is cleared: `{ levelId }`. */
923
+ levelComplete: gameplayHelper("level_complete", "levelId"),
924
+ /** The player died: `{ levelId, x?, y?, z?, name? }` (`name` is the cause). */
925
+ death: gameplayHelper("death", "levelId"),
926
+ /** A checkpoint is reached: `{ levelId, name? }`. */
927
+ checkpoint: gameplayHelper("checkpoint", "levelId"),
928
+ /** A run ended: `{ value, levelId? }`. */
929
+ score: gameplayHelper("score", "value"),
930
+ });
931
+
523
932
  /**
524
933
  * @param {TesanaConfig} [config]
525
934
  */
@@ -647,6 +1056,9 @@ function buildConfig(merged) {
647
1056
  locale: normalizeLocale(
648
1057
  merged.locale || merged.language || merged.lang || (typeof navigator !== "undefined" && navigator.language) || "",
649
1058
  ),
1059
+ // How a dropped room socket is retried. A game can shrink the waits (a
1060
+ // test, or a game with unusual tolerance); the defaults are the fleet's.
1061
+ multiplayerReconnect: merged.multiplayerReconnect || undefined,
650
1062
  };
651
1063
  }
652
1064
 
@@ -709,6 +1121,10 @@ class TesanaClient {
709
1121
  leaving: false,
710
1122
  reconnectTimer: null,
711
1123
  reconnectAttempts: 0,
1124
+ // The code of the last close we saw. 4401 means the grant was refused —
1125
+ // expired, most likely — and a fresh one has to be minted before the next
1126
+ // reconnect can succeed. Cleared once a reconnect has used it.
1127
+ lastCloseCode: null,
712
1128
  reconnectExhausted: false,
713
1129
  statusHandlers: [],
714
1130
  // Overridable so a test (or a game with unusual tolerance) can shorten
@@ -925,6 +1341,12 @@ class TesanaClient {
925
1341
  this.l10n = this._makeL10n(opts);
926
1342
  l10nClient = this.l10n;
927
1343
 
1344
+ // Gameplay analytics. The same functions as `Tesana.track` and
1345
+ // `Tesana.gameplay` on the global, so a game can call them before its
1346
+ // `await __TESANA_READY__` resolves; none of them depends on this client.
1347
+ this.track = track;
1348
+ this.gameplay = gameplay;
1349
+
928
1350
  this.multiplayer = {
929
1351
  // Read this before drawing a lobby: when it is false the game is not
930
1352
  // allowed rooms yet and the calls below refuse.
@@ -1153,7 +1575,7 @@ class TesanaClient {
1153
1575
  * @param {Promise<{ wsUrl: string, roomId?: string, room?: object, playerId?: string }>} session
1154
1576
  */
1155
1577
  async _mpEnter(session) {
1156
- const joined = await session;
1578
+ const joined = await this._mpAwaitPlacement(await session);
1157
1579
  // A room that was opened and then could not be entered is reported as what
1158
1580
  // it is: the room exists. The SDK cannot seat the player — that is the
1159
1581
  // host's job — but it can name the room so a game retries `join(roomId)`
@@ -1180,6 +1602,56 @@ class TesanaClient {
1180
1602
  };
1181
1603
  }
1182
1604
 
1605
+ /**
1606
+ * Wait out a room that is still being placed, then hand back its session.
1607
+ *
1608
+ * Room creation on GameLift outlives a single HTTP request — a cold fleet can
1609
+ * take minutes — so the service answers 202 with the room's identity, the
1610
+ * caller's seat, and `retryAfterMs`, and no `wsUrl`. Treating that as a failed
1611
+ * create is what used to leave a ghost: the room existed, its creator was
1612
+ * seated, and the client believed nothing had happened. So the client polls
1613
+ * the same room it was handed (`join` re-issues the seat, it does not take a
1614
+ * second) until the address exists or the wait budget runs out.
1615
+ *
1616
+ * The budget is the client's own. A game that would rather show a spinner and
1617
+ * decide for itself calls `join(roomId)` after `create` rejects with
1618
+ * `mp_room_placing`, and this consumes the same code path.
1619
+ *
1620
+ * @param {{ wsUrl?: string, roomId?: string, state?: string, retryAfterMs?: number, room?: object }} session
1621
+ */
1622
+ async _mpAwaitPlacement(session) {
1623
+ if (!session || session.wsUrl || session.state !== "placing") return session;
1624
+ const roomId = session.roomId || session.room?.id;
1625
+ if (!roomId) return session;
1626
+ this._mpEmitStatus({ status: "placing", roomId });
1627
+ // Instance-overridable so a game (or a test) can decide how long it will
1628
+ // wait for placement without reaching into module scope.
1629
+ const budget = Number(this._mpPlacingTimeoutMs) || MP_PLACING_TIMEOUT_MS;
1630
+ const deadline = Date.now() + budget;
1631
+ let current = session;
1632
+ while (!current?.wsUrl && Date.now() < deadline) {
1633
+ const gap = Math.max(250, Number(current.retryAfterMs) || MP_PLACING_RETRY_MS);
1634
+ await new Promise((resolve) => setTimeout(resolve, Math.min(gap, Math.max(0, deadline - Date.now()))));
1635
+ if (Date.now() >= deadline) break;
1636
+ try {
1637
+ current = await this._mpRequest("POST", `/v1/mp/rooms/${enc(roomId)}/join`, {});
1638
+ } catch (err) {
1639
+ // A 404 here is a room that ended between polls, which is a real
1640
+ // failure; anything else is worth another try until the budget is gone.
1641
+ if (err?.status === 404) throw err;
1642
+ }
1643
+ }
1644
+ if (!current?.wsUrl) {
1645
+ // The room is still real, so the code names it and says so — a game can
1646
+ // choose to keep waiting with `join(roomId)` rather than start over.
1647
+ const err = new Error("Room is still starting");
1648
+ err.code = "mp_room_placing";
1649
+ err.roomId = roomId;
1650
+ throw err;
1651
+ }
1652
+ return current;
1653
+ }
1654
+
1183
1655
  /**
1184
1656
  * @param {string} wsUrl
1185
1657
  */
@@ -1238,13 +1710,22 @@ class TesanaClient {
1238
1710
  if (this._mp.ws === ws) this._mp.ws = null;
1239
1711
  if (!opened) {
1240
1712
  clearTimeout(timer);
1241
- reject(new Error(event.reason || "multiplayer closed"));
1713
+ const err = new Error(event.reason || "multiplayer closed");
1714
+ // The code is carried, not just the reason: 4401 is an expired or
1715
+ // rejected grant, which a re-mint can fix, while 4403 is the game
1716
+ // being denied rooms, which it cannot. Recovery keys off this.
1717
+ err.code = event.code || 1006;
1718
+ // Recorded for the same reason: a reconnect whose fresh grant is
1719
+ // *also* refused must mint again rather than retry a dead URL.
1720
+ if (this._mp.wsUrl) this._mp.lastCloseCode = event.code || null;
1721
+ reject(err);
1242
1722
  return;
1243
1723
  }
1244
1724
  // The socket dropped after we were in the room. That is a scale-in,
1245
1725
  // a deploy, or a network blip — none of which the player caused, and
1246
1726
  // all of which used to leave them silently alone in a match. Retry
1247
1727
  // unless they actually meant to leave.
1728
+ this._mp.lastCloseCode = event.code || null;
1248
1729
  this._mpScheduleReconnect();
1249
1730
  };
1250
1731
  });
@@ -1276,7 +1757,7 @@ class TesanaClient {
1276
1757
  mp.reconnectTimer = setTimeout(() => {
1277
1758
  mp.reconnectTimer = null;
1278
1759
  if (mp.leaving) return;
1279
- this._mpOpen(mp.wsUrl).catch(() => {
1760
+ this._mpReconnectAttempt().catch(() => {
1280
1761
  // Still not up — schedule the next attempt, which is where the
1281
1762
  // attempt count grows and the backoff widens.
1282
1763
  this._mpScheduleReconnect();
@@ -1284,6 +1765,56 @@ class TesanaClient {
1284
1765
  }, delay);
1285
1766
  }
1286
1767
 
1768
+ /**
1769
+ * One reconnect: get a current grant if the last one was refused, then try the
1770
+ * room again.
1771
+ *
1772
+ * The grant the client holds is embedded in `wsUrl` and was minted when it
1773
+ * first joined. A game open longer than the token's life therefore cannot
1774
+ * re-enter on a reconnect, and the backoff above only retries the same dead
1775
+ * URL — the player sits in "reconnecting" until they reload. Re-requesting the
1776
+ * room session mints a fresh URL, and `mpJoin` is idempotent, so asking as an
1777
+ * already-seated player re-issues the same seat rather than a second one.
1778
+ *
1779
+ * The fresh grant itself comes from the ordinary 401 path in `_send`: the join
1780
+ * goes out with the token the client already has, comes back refused, and is
1781
+ * retried once with one minted token. That is deliberate — it keeps the mint in
1782
+ * the one place that knows how to do it, so a stale socket and a stale HTTP
1783
+ * call recover identically and only ever ask the host once between them.
1784
+ */
1785
+ async _mpReconnectAttempt() {
1786
+ const mp = this._mp;
1787
+ let wsUrl = mp.wsUrl;
1788
+ // 4401 — the grant was refused, so the URL itself is dead and a fresh one is
1789
+ // required. 4409 — the room is being re-placed and has no address yet, so
1790
+ // the URL the client holds is not usable either. Both are fixed by asking
1791
+ // for the room again; 4403 (the game is denied rooms) and 4404 (the room is
1792
+ // gone) are not, and fall through to the URL we have so the attempt is still
1793
+ // counted and the backoff still applies.
1794
+ if ((mp.lastCloseCode === 4401 || mp.lastCloseCode === 4409) && mp.roomId) {
1795
+ try {
1796
+ // `_mpAwaitPlacement` is harmless when the room is already up (it
1797
+ // returns the session untouched) and necessary when a reconnect lands
1798
+ // during a re-placement — a 4409 close, or a room swept and reopened,
1799
+ // answers with no `wsUrl`, and retrying the old URL would never work.
1800
+ const session = await this._mpAwaitPlacement(
1801
+ await this._mpRequest("POST", `/v1/mp/rooms/${enc(mp.roomId)}/join`),
1802
+ );
1803
+ if (session && session.wsUrl) {
1804
+ wsUrl = session.wsUrl;
1805
+ mp.wsUrl = wsUrl;
1806
+ mp.code = session.room?.code ?? mp.code;
1807
+ }
1808
+ } catch {
1809
+ // The room is gone, or the fresh grant was refused too. Fall through to
1810
+ // the URL we have: the room may have been replaced under the same id,
1811
+ // and if it has not, the next attempt retries this same path.
1812
+ }
1813
+ }
1814
+ mp.lastCloseCode = null;
1815
+ return this._mpOpen(wsUrl);
1816
+ }
1817
+
1287
1818
  _mpEmitStatus(status) {
1288
1819
  for (const fn of this._mp.statusHandlers) {
1289
1820
  try {
@@ -1317,7 +1848,14 @@ class TesanaClient {
1317
1848
  */
1318
1849
  _mpSend(data) {
1319
1850
  const ws = this._mp.ws;
1320
- if (!ws || ws.readyState !== 1) throw new Error("Not in a room");
1851
+ if (!ws || ws.readyState !== 1) {
1852
+ // Not a room failure — a socket that is between attempts. Tagged so a
1853
+ // game can tell it from a real refusal, and so a caller that has an
1854
+ // outbox can wait for the reconnect rather than drop the message.
1855
+ const err = new Error("Not in a room");
1856
+ err.code = "mp_not_connected";
1857
+ throw err;
1858
+ }
1321
1859
  ws.send(JSON.stringify(data && typeof data === "object" ? data : { value: data }));
1322
1860
  }
1323
1861
 
@@ -1355,6 +1893,7 @@ class TesanaClient {
1355
1893
  this._mp.wsUrl = null;
1356
1894
  this._mp.reconnectAttempts = 0;
1357
1895
  this._mp.reconnectExhausted = false;
1896
+ this._mp.lastCloseCode = null;
1358
1897
  try {
1359
1898
  if (!roomId) return { ok: true, room: null };
1360
1899
  try {
@@ -1376,24 +1915,13 @@ class TesanaClient {
1376
1915
  return this._send(this._baseFor(), this._authFor(), method, path, body);
1377
1916
  }
1378
1917
 
1379
- /** Whether multiplayer rides a rooms-only token while everything else is offline. */
1380
- _roomsOnly() {
1381
- return Boolean(this.offline && this.roomEndpoint && this.roomToken);
1382
- }
1383
-
1384
- _mpRequest(method, path, body) {
1385
- return this._roomsOnly()
1386
- ? this._send(this.roomEndpoint, this.roomToken, method, path, body)
1387
- : this.request(method, path, body);
1388
- }
1389
-
1390
- async _send(base, authToken, method, path, body) {
1918
+ /** One transport attempt, with no retry and no body parsing. */
1919
+ async _fetchSend(base, authToken, method, path, body) {
1391
1920
  const headers = { "content-type": "application/json" };
1392
1921
  if (authToken) headers.authorization = `Bearer ${authToken}`;
1393
1922
  if (this.deviceId) headers["x-tesana-device"] = this.deviceId;
1394
- let res;
1395
1923
  try {
1396
- res = await fetch(`${base}${path}`, {
1924
+ return await fetch(`${base}${path}`, {
1397
1925
  method,
1398
1926
  headers,
1399
1927
  body: body == null || method === "GET" ? undefined : JSON.stringify(body),
@@ -1406,6 +1934,34 @@ class TesanaClient {
1406
1934
  // still has, reported as missing.
1407
1935
  throw err;
1408
1936
  }
1937
+ }
1938
+
1939
+ /** Whether multiplayer rides a rooms-only token while everything else is offline. */
1940
+ _roomsOnly() {
1941
+ return Boolean(this.offline && this.roomEndpoint && this.roomToken);
1942
+ }
1943
+
1944
+ _mpRequest(method, path, body) {
1945
+ return this._roomsOnly()
1946
+ ? this._send(this.roomEndpoint, this.roomToken, method, path, body)
1947
+ : this.request(method, path, body);
1948
+ }
1949
+
1950
+ async _send(base, authToken, method, path, body) {
1951
+ let res = await this._fetchSend(base, authToken, method, path, body);
1952
+ // A refused credential is usually age, not refusal: the player token lives
1953
+ // two hours. Ask the host to re-mint and retry once with the new one. The
1954
+ // retry is guarded on the token actually changing, so a genuinely
1955
+ // unauthorized player fails exactly as they did before rather than looping.
1956
+ if (res.status === 401) {
1957
+ // The ask may land on a host we cannot reach, so `asked` is null and the
1958
+ // retry is skipped — the 401 below is thrown exactly as it always was.
1959
+ const asked = await requestHostBootstrap();
1960
+ const next = base === this.roomEndpoint ? this.roomToken : this._authFor();
1961
+ if (asked && next && next !== authToken) {
1962
+ res = await this._fetchSend(base, next, method, path, body);
1963
+ }
1964
+ }
1409
1965
  const data = await res.json().catch(() => ({}));
1410
1966
  if (!res.ok) {
1411
1967
  const error = new Error(data.message || res.statusText);
@@ -2125,7 +2681,7 @@ function enc(value) {
2125
2681
  }
2126
2682
 
2127
2683
 
2128
- var api = { init: init, TesanaClient: TesanaClient, version: VERSION };
2684
+ var api = { init: init, TesanaClient: TesanaClient, version: VERSION, track: track, gameplay: gameplay };
2129
2685
  root.Tesana = api;
2130
2686
  // A game's first line may be `await window.__TESANA_READY__`, the promise
2131
2687
  // form the engine's skills teach, so start booting the moment this loads.