@bountyboard/arcade-sdk 1.1.0 → 1.3.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.
package/dist/global.js CHANGED
@@ -5,6 +5,316 @@
5
5
  var SDK_MESSAGE_SOURCE = "bb-arcade";
6
6
  var HOST_MESSAGE_SOURCE = "bb-arcade-host";
7
7
 
8
+ // src/storage.ts
9
+ var FLUSH_DELAY_MS = 400;
10
+ var ENVELOPE_TAG = "__bbArcadeStorage";
11
+ var ENVELOPE_VERSION = 1;
12
+ function hasOwn(target, key) {
13
+ return Object.prototype.hasOwnProperty.call(target, key);
14
+ }
15
+ function emptyMap() {
16
+ return /* @__PURE__ */ Object.create(null);
17
+ }
18
+ function storageError(code) {
19
+ const err = new Error(code);
20
+ err.code = code;
21
+ return err;
22
+ }
23
+ function parseEnvelope(raw) {
24
+ if (raw == null) return { ls: {}, app: null };
25
+ if (raw.charAt(0) !== "{") return { ls: {}, app: raw };
26
+ let parsed;
27
+ try {
28
+ parsed = JSON.parse(raw);
29
+ } catch (err) {
30
+ return { ls: {}, app: raw };
31
+ }
32
+ if (!parsed || typeof parsed !== "object") return { ls: {}, app: raw };
33
+ const record = parsed;
34
+ if (record[ENVELOPE_TAG] !== ENVELOPE_VERSION) return { ls: {}, app: raw };
35
+ const ls = emptyMap();
36
+ const source = record.ls;
37
+ if (source && typeof source === "object") {
38
+ const entries = source;
39
+ for (const key in entries) {
40
+ if (hasOwn(entries, key) && typeof entries[key] === "string") {
41
+ ls[key] = entries[key];
42
+ }
43
+ }
44
+ }
45
+ return { ls, app: typeof record.app === "string" ? record.app : null };
46
+ }
47
+ function buildEnvelope(ls, app) {
48
+ const envelope = {};
49
+ envelope[ENVELOPE_TAG] = ENVELOPE_VERSION;
50
+ envelope.ls = ls;
51
+ envelope.app = app;
52
+ return JSON.stringify(envelope);
53
+ }
54
+ function quotaError() {
55
+ try {
56
+ return new DOMException("Cloud save quota exceeded", "QuotaExceededError");
57
+ } catch (err) {
58
+ const fallback = new Error("Cloud save quota exceeded");
59
+ fallback.name = "QuotaExceededError";
60
+ return fallback;
61
+ }
62
+ }
63
+ var RESERVED = {
64
+ getItem: true,
65
+ setItem: true,
66
+ removeItem: true,
67
+ clear: true,
68
+ key: true,
69
+ length: true
70
+ };
71
+ function createStorageShim(backend = {}) {
72
+ const map = emptyMap();
73
+ const localWrites = emptyMap();
74
+ function keys() {
75
+ return Object.keys(map);
76
+ }
77
+ function changed() {
78
+ if (backend.onChange) backend.onChange();
79
+ }
80
+ const api = {
81
+ getItem: function(key) {
82
+ const name = String(key);
83
+ return hasOwn(map, name) ? map[name] : null;
84
+ },
85
+ setItem: function(key, value) {
86
+ const name = String(key);
87
+ const next = String(value);
88
+ if (backend.wouldOverflow && backend.wouldOverflow(name, next)) throw quotaError();
89
+ map[name] = next;
90
+ localWrites[name] = true;
91
+ changed();
92
+ },
93
+ removeItem: function(key) {
94
+ const name = String(key);
95
+ localWrites[name] = true;
96
+ if (!hasOwn(map, name)) return;
97
+ delete map[name];
98
+ changed();
99
+ },
100
+ clear: function() {
101
+ const existing = keys();
102
+ for (let i = 0; i < existing.length; i++) {
103
+ delete map[existing[i]];
104
+ localWrites[existing[i]] = true;
105
+ }
106
+ changed();
107
+ },
108
+ key: function(index) {
109
+ const existing = keys();
110
+ const at = Number(index);
111
+ return at >= 0 && at < existing.length ? existing[at] : null;
112
+ },
113
+ get length() {
114
+ return keys().length;
115
+ }
116
+ };
117
+ const storage = new Proxy(api, {
118
+ get(target, prop, receiver) {
119
+ if (typeof prop !== "string") return Reflect.get(target, prop, receiver);
120
+ if (hasOwn(RESERVED, prop)) return Reflect.get(target, prop, receiver);
121
+ return hasOwn(map, prop) ? map[prop] : void 0;
122
+ },
123
+ set(target, prop, value) {
124
+ if (typeof prop !== "string" || hasOwn(RESERVED, prop)) return true;
125
+ api.setItem(prop, value);
126
+ return true;
127
+ },
128
+ has(target, prop) {
129
+ if (typeof prop !== "string") return Reflect.has(target, prop);
130
+ return hasOwn(RESERVED, prop) || hasOwn(map, prop);
131
+ },
132
+ deleteProperty(target, prop) {
133
+ if (typeof prop === "string" && !hasOwn(RESERVED, prop)) api.removeItem(prop);
134
+ return true;
135
+ },
136
+ ownKeys() {
137
+ return keys();
138
+ },
139
+ getOwnPropertyDescriptor(target, prop) {
140
+ if (typeof prop !== "string" || !hasOwn(map, prop)) return void 0;
141
+ return { value: map[prop], writable: true, enumerable: true, configurable: true };
142
+ }
143
+ });
144
+ return {
145
+ storage,
146
+ entries: function() {
147
+ const copy = emptyMap();
148
+ const existing = keys();
149
+ for (let i = 0; i < existing.length; i++) copy[existing[i]] = map[existing[i]];
150
+ return copy;
151
+ },
152
+ hydrate: function(values) {
153
+ for (const key in values) {
154
+ if (hasOwn(values, key) && !hasOwn(localWrites, key)) map[key] = values[key];
155
+ }
156
+ }
157
+ };
158
+ }
159
+ function settled(promise) {
160
+ return promise.then(
161
+ function() {
162
+ },
163
+ function() {
164
+ }
165
+ );
166
+ }
167
+ function installSessionStorageShim() {
168
+ if (nativeStorageWorks("sessionStorage")) return;
169
+ try {
170
+ Object.defineProperty(window, "sessionStorage", {
171
+ value: createStorageShim().storage,
172
+ configurable: true
173
+ });
174
+ } catch (err) {
175
+ }
176
+ }
177
+ function nativeStorageWorks(area) {
178
+ try {
179
+ const store = window[area];
180
+ if (!store) return false;
181
+ const probe = "__bb_arcade_probe__";
182
+ store.setItem(probe, "1");
183
+ store.removeItem(probe);
184
+ return true;
185
+ } catch (err) {
186
+ return false;
187
+ }
188
+ }
189
+ function createStorageController(options) {
190
+ let shim = null;
191
+ let mode = "native";
192
+ let appBlob = null;
193
+ let hydration = null;
194
+ let flushTimer = null;
195
+ let inFlight = Promise.resolve();
196
+ let dirty = false;
197
+ let hydrated = false;
198
+ function serialize() {
199
+ return buildEnvelope(shim ? shim.entries() : {}, appBlob);
200
+ }
201
+ function hydrateFromCloud() {
202
+ hydration = options.load().then(
203
+ function(raw) {
204
+ const envelope = parseEnvelope(raw);
205
+ if (shim) shim.hydrate(envelope.ls);
206
+ if (appBlob === null) appBlob = envelope.app;
207
+ hydrated = true;
208
+ return mode;
209
+ },
210
+ function(err) {
211
+ const code = err && err.code;
212
+ if (code === "unsupported" || code === "unauthenticated") mode = "memory";
213
+ return mode;
214
+ }
215
+ );
216
+ return hydration;
217
+ }
218
+ function wouldOverflow(key, value) {
219
+ if (!shim) return false;
220
+ const next = shim.entries();
221
+ next[key] = value;
222
+ return options.byteLength(buildEnvelope(next, appBlob)) > options.maxBytes;
223
+ }
224
+ function persist() {
225
+ return whenHydrated().then(function() {
226
+ if (mode === "memory") return;
227
+ if (hydrated) return options.save(serialize());
228
+ return hydrateFromCloud().then(function() {
229
+ if (mode === "memory") return;
230
+ if (!hydrated) throw storageError("error");
231
+ return options.save(serialize());
232
+ });
233
+ }).then(void 0, function(err) {
234
+ dirty = true;
235
+ throw err;
236
+ });
237
+ }
238
+ function flushNow() {
239
+ if (flushTimer !== null) {
240
+ clearTimeout(flushTimer);
241
+ flushTimer = null;
242
+ }
243
+ dirty = false;
244
+ const next = inFlight.then(persist, persist);
245
+ inFlight = settled(next);
246
+ return next;
247
+ }
248
+ function scheduleFlush() {
249
+ dirty = true;
250
+ if (flushTimer !== null) return;
251
+ flushTimer = setTimeout(function() {
252
+ flushTimer = null;
253
+ void settled(flushNow());
254
+ }, FLUSH_DELAY_MS);
255
+ }
256
+ function whenHydrated() {
257
+ return hydration ? hydration : Promise.resolve(mode);
258
+ }
259
+ function attachUnloadFlush() {
260
+ const flushIfDirty = function() {
261
+ if (dirty) void settled(flushNow());
262
+ };
263
+ try {
264
+ window.addEventListener("pagehide", flushIfDirty);
265
+ window.addEventListener("visibilitychange", function() {
266
+ if (document.visibilityState === "hidden") flushIfDirty();
267
+ });
268
+ } catch (err) {
269
+ }
270
+ }
271
+ function install() {
272
+ if (shim) return mode;
273
+ installSessionStorageShim();
274
+ if (nativeStorageWorks("localStorage")) {
275
+ mode = "native";
276
+ return mode;
277
+ }
278
+ const handle = createStorageShim({ onChange: scheduleFlush, wouldOverflow });
279
+ try {
280
+ Object.defineProperty(window, "localStorage", {
281
+ value: handle.storage,
282
+ configurable: true
283
+ });
284
+ } catch (err) {
285
+ return mode;
286
+ }
287
+ shim = handle;
288
+ mode = "cloud";
289
+ attachUnloadFlush();
290
+ void hydrateFromCloud();
291
+ return mode;
292
+ }
293
+ return {
294
+ install,
295
+ active: function() {
296
+ return shim !== null;
297
+ },
298
+ ready: function() {
299
+ return whenHydrated();
300
+ },
301
+ flush: function() {
302
+ if (!shim && appBlob === null) return Promise.resolve();
303
+ return flushNow();
304
+ },
305
+ writeApp: function(blob) {
306
+ if (options.byteLength(buildEnvelope(shim ? shim.entries() : {}, blob)) > options.maxBytes) {
307
+ return Promise.reject(storageError("too_large"));
308
+ }
309
+ appBlob = blob;
310
+ return flushNow();
311
+ },
312
+ get mode() {
313
+ return mode;
314
+ }
315
+ };
316
+ }
317
+
8
318
  // src/core.ts
9
319
  var BOUNTY_BOARD_URL = "https://www.bountyboard.gg/arcade";
10
320
  var ADSENSE_CLIENT_ID = "ca-pub-7727110228190547";
@@ -12,6 +322,8 @@
12
322
  var AD_PLACEMENT_SCRIPT_ID = "bb-arcade-h5-ads";
13
323
  var DEFAULT_AD_LOAD_TIMEOUT_MS = 6e3;
14
324
  var SAVE_TIMEOUT_MS = 15e3;
325
+ var LOCK_TIMEOUT_MS = 5e3;
326
+ var MAX_SAVE_BYTES = 1024 * 1024;
15
327
  var INIT_CONFIG_GRACE_MS = 1500;
16
328
  var DEFAULT_ALLOW = [
17
329
  "bountyboard.gg",
@@ -36,6 +348,14 @@
36
348
  if (typeof value !== "string") return fallback || "";
37
349
  return value.replace(/[^\w:.-]+/g, "_").slice(0, max || 80) || fallback || "";
38
350
  }
351
+ var cachedTextEncoder = null;
352
+ function utf8ByteLength(value) {
353
+ if (typeof TextEncoder !== "undefined") {
354
+ cachedTextEncoder = cachedTextEncoder || new TextEncoder();
355
+ return cachedTextEncoder.encode(value).length;
356
+ }
357
+ return new Blob([value]).size;
358
+ }
39
359
  function toBoolean(value) {
40
360
  return value === true || value === "true" || value === "1";
41
361
  }
@@ -155,13 +475,14 @@
155
475
  };
156
476
  let hostConfigReceived = false;
157
477
  let hostPlayer = null;
478
+ const playerChangeHandlers = [];
158
479
  let blocked = false;
159
480
  const pending = {};
160
481
  let seq = 0;
161
482
  function post(type, score, extra) {
162
483
  if (blocked) return;
163
484
  const message = { source: SDK_MESSAGE_SOURCE, v: VERSION, type };
164
- if (typeof score === "number" && isFinite(score)) message.score = Math.floor(score);
485
+ if (typeof score === "number" && isFinite(score)) message.score = Math.trunc(score);
165
486
  if (extra && typeof extra === "object") {
166
487
  const fields = extra;
167
488
  for (const key in fields) {
@@ -182,7 +503,7 @@
182
503
  return true;
183
504
  }
184
505
  }
185
- function request(type, payload) {
506
+ function request(type, payload, timeoutMs = SAVE_TIMEOUT_MS) {
186
507
  return new Promise(function(resolve, reject) {
187
508
  if (blocked) return reject(sdkError("error"));
188
509
  if (!isEmbedded()) return reject(sdkError("unsupported"));
@@ -192,7 +513,7 @@
192
513
  delete pending[requestId];
193
514
  reject(sdkError("error"));
194
515
  }
195
- }, SAVE_TIMEOUT_MS);
516
+ }, timeoutMs);
196
517
  pending[requestId] = { resolve, reject, timer };
197
518
  try {
198
519
  window.parent.postMessage(
@@ -284,7 +605,7 @@
284
605
  if (!hostnameAllowed(origin, allow)) blockHost(options);
285
606
  return;
286
607
  }
287
- request("host").then(
608
+ request("host", void 0, LOCK_TIMEOUT_MS).then(
288
609
  function(res) {
289
610
  const r = res;
290
611
  if (!hostnameAllowed(r && r.origin, allow)) blockHost(options);
@@ -308,7 +629,7 @@
308
629
  lockToHostBestEffort(opts, allow);
309
630
  return;
310
631
  }
311
- request("host_attest").then(
632
+ request("host_attest", void 0, LOCK_TIMEOUT_MS).then(
312
633
  function(res) {
313
634
  const r = res;
314
635
  if (!r || !r.attestation) {
@@ -370,7 +691,15 @@
370
691
  if (msg.source !== HOST_MESSAGE_SOURCE || msg.type !== "config") return;
371
692
  hostConfigReceived = true;
372
693
  if (msg.player !== void 0) {
373
- hostPlayer = sanitizePlayer(msg.player);
694
+ const nextPlayer = sanitizePlayer(msg.player);
695
+ const changed = nextPlayer === null ? hostPlayer !== null : hostPlayer === null || nextPlayer.name !== hostPlayer.name || nextPlayer.avatarUrl !== hostPlayer.avatarUrl;
696
+ hostPlayer = nextPlayer;
697
+ if (changed) {
698
+ const snapshot = nextPlayer ? { name: nextPlayer.name, avatarUrl: nextPlayer.avatarUrl } : null;
699
+ for (let i = 0; i < playerChangeHandlers.length; i++) {
700
+ safeCall(playerChangeHandlers[i], snapshot);
701
+ }
702
+ }
374
703
  }
375
704
  const rewardedAds = msg.rewardedAds;
376
705
  if (rewardedAds && typeof rewardedAds === "object") {
@@ -502,6 +831,7 @@
502
831
  let resolved = false;
503
832
  let status = "unavailable";
504
833
  let rewardOffered = false;
834
+ let adShown = false;
505
835
  const waitTimer = setTimeout(function() {
506
836
  if (rewardOffered || resolved) return;
507
837
  const timeout = Object.assign(
@@ -546,6 +876,7 @@
546
876
  }
547
877
  post("rewarded_ad", void 0, Object.assign({ event: "started" }, base));
548
878
  try {
879
+ adShown = true;
549
880
  showAdFn();
550
881
  } catch (err) {
551
882
  const showError = Object.assign(
@@ -595,7 +926,13 @@
595
926
  },
596
927
  adBreakDone: function(placementInfo) {
597
928
  if (resolved) return;
598
- const result = rewardOffered || status !== "unavailable" ? { status } : { status: "unavailable", error: "no_rewarded_ad" };
929
+ const result = rewardOffered || status !== "unavailable" ? {
930
+ // A reward was offered but the ad never reached the screen
931
+ // (the game retained show() and let the break lapse): the
932
+ // break is over without a view, so resolve 'dismissed',
933
+ // not the non-terminal-looking 'ready'.
934
+ status: status === "ready" && !adShown ? "dismissed" : status
935
+ } : { status: "unavailable", error: "no_rewarded_ad" };
599
936
  if (placementInfo && typeof placementInfo === "object") {
600
937
  result.breakStatus = cleanText(
601
938
  placementInfo.breakStatus
@@ -766,6 +1103,16 @@
766
1103
  if (!hostnameAllowed(event.origin, DEFAULT_ALLOW)) return;
767
1104
  applyHostConfig(event.data);
768
1105
  });
1106
+ const storage = createStorageController({
1107
+ save: function(blob) {
1108
+ return request("save", blob);
1109
+ },
1110
+ load: function() {
1111
+ return request("load");
1112
+ },
1113
+ byteLength: utf8ByteLength,
1114
+ maxBytes: MAX_SAVE_BYTES
1115
+ });
769
1116
  const sdk = {
770
1117
  lockToHost,
771
1118
  init,
@@ -798,12 +1145,29 @@
798
1145
  prepareRewardedBreak: prepareRewardedAd,
799
1146
  rewardedBreak,
800
1147
  save: function(blob) {
801
- return request("save", String(blob));
1148
+ const data = String(blob);
1149
+ if (utf8ByteLength(data) > MAX_SAVE_BYTES) {
1150
+ return Promise.reject(sdkError("too_large"));
1151
+ }
1152
+ if (storage.active()) return storage.writeApp(data);
1153
+ return request("save", data);
802
1154
  },
803
1155
  load: function() {
804
- return request("load");
1156
+ return request("load").then(function(raw) {
1157
+ return parseEnvelope(raw).app;
1158
+ });
805
1159
  },
1160
+ storage,
806
1161
  getPlayer,
1162
+ onPlayerChange: function(handler) {
1163
+ if (typeof handler !== "function") return function() {
1164
+ };
1165
+ playerChangeHandlers.push(handler);
1166
+ return function() {
1167
+ const idx = playerChangeHandlers.indexOf(handler);
1168
+ if (idx !== -1) playerChangeHandlers.splice(idx, 1);
1169
+ };
1170
+ },
807
1171
  getVariant: function(key, variants) {
808
1172
  const sorted = Array.isArray(variants) ? variants.slice().sort() : [];
809
1173
  const fallback = sorted.length > 0 ? sorted[0] : null;
@@ -869,7 +1233,17 @@
869
1233
  rewardedBreak: () => Promise.resolve(false),
870
1234
  save: () => Promise.reject(sdkError2("unsupported")),
871
1235
  load: () => Promise.reject(sdkError2("unsupported")),
1236
+ // No window to shim and nothing to persist to: report the honest mode
1237
+ // rather than pretending an install succeeded.
1238
+ storage: {
1239
+ install: () => "memory",
1240
+ ready: () => Promise.resolve("memory"),
1241
+ flush: () => Promise.resolve(),
1242
+ mode: "memory"
1243
+ },
872
1244
  getPlayer: () => Promise.resolve(null),
1245
+ onPlayerChange: () => () => {
1246
+ },
873
1247
  getVariant: (key, variants) => {
874
1248
  const sorted = Array.isArray(variants) ? variants.slice().sort() : [];
875
1249
  return Promise.resolve(sorted.length > 0 ? sorted[0] : null);
@@ -883,13 +1257,17 @@
883
1257
 
884
1258
  // src/multiplayer.ts
885
1259
  var WELCOME_TIMEOUT_MS = 1e4;
1260
+ var MIN_WELCOME_TIMEOUT_MS = 1e3;
1261
+ var MAX_WELCOME_TIMEOUT_MS = 6e4;
886
1262
  var MAX_RECONNECT_ATTEMPTS = 3;
887
1263
  var MAX_JOIN_DATA_BYTES = 1024;
1264
+ var SEAT_FULL_CLOSE_CODE = 4005;
888
1265
  var CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
889
1266
  var CODE_LENGTH = 4;
890
- function sdkError3(code) {
1267
+ function sdkError3(code, detail) {
891
1268
  const err = new Error(code);
892
1269
  err.code = code;
1270
+ if (detail) err.detail = detail;
893
1271
  return err;
894
1272
  }
895
1273
  function randomChars(length) {
@@ -906,23 +1284,13 @@
906
1284
  function generateCode() {
907
1285
  return randomChars(CODE_LENGTH);
908
1286
  }
909
- function utf8ByteLength(value) {
910
- let bytes = 0;
911
- for (let i = 0; i < value.length; i++) {
912
- const code = value.charCodeAt(i);
913
- if (code < 128) bytes += 1;
914
- else if (code < 2048) bytes += 2;
915
- else if (code >= 55296 && code <= 56319 && i + 1 < value.length) {
916
- const next = value.charCodeAt(i + 1);
917
- if (next >= 56320 && next <= 57343) {
918
- bytes += 4;
919
- i += 1;
920
- } else {
921
- bytes += 3;
922
- }
923
- } else bytes += 3;
1287
+ var cachedTextEncoder2 = null;
1288
+ function utf8ByteLength2(value) {
1289
+ if (typeof TextEncoder !== "undefined") {
1290
+ cachedTextEncoder2 = cachedTextEncoder2 || new TextEncoder();
1291
+ return cachedTextEncoder2.encode(value).length;
924
1292
  }
925
- return bytes;
1293
+ return new Blob([value]).size;
926
1294
  }
927
1295
  function serializeJoinData(data) {
928
1296
  if (data === void 0) return null;
@@ -933,28 +1301,34 @@
933
1301
  } catch (e) {
934
1302
  throw sdkError3("rejected");
935
1303
  }
936
- if (!serialized || utf8ByteLength(serialized) > MAX_JOIN_DATA_BYTES) {
1304
+ if (!serialized || utf8ByteLength2(serialized) > MAX_JOIN_DATA_BYTES) {
937
1305
  throw sdkError3("rejected");
938
1306
  }
939
1307
  return serialized;
940
1308
  }
941
- function requestTicket(roomId) {
1309
+ function requestTicket(payload) {
942
1310
  const request = BBArcade._request;
943
1311
  if (typeof request !== "function") return Promise.reject(sdkError3("unsupported"));
944
- return request("mp_ticket", { roomId }).then((grant) => {
1312
+ return request("mp_ticket", payload).then((grant) => {
945
1313
  const g = grant;
946
1314
  if (!g || typeof g.ticket !== "string" || typeof g.roomUrl !== "string") {
947
1315
  throw sdkError3("error");
948
1316
  }
949
- return { ticket: g.ticket, roomUrl: g.roomUrl, expiresAt: Number(g.expiresAt) || 0 };
1317
+ return {
1318
+ ticket: g.ticket,
1319
+ roomUrl: g.roomUrl,
1320
+ expiresAt: Number(g.expiresAt) || 0,
1321
+ roomId: typeof g.roomId === "string" && g.roomId ? g.roomId : null
1322
+ };
950
1323
  });
951
1324
  }
952
1325
  var RoomConnection = class {
953
- constructor(code, devGrant, joinData) {
1326
+ constructor(code, devGrant, joinData, welcomeTimeoutMs = WELCOME_TIMEOUT_MS) {
954
1327
  this.playerId = "";
955
1328
  this.players = [];
956
1329
  this.state = null;
957
1330
  this.connected = false;
1331
+ this.latencyMs = null;
958
1332
  this.ws = null;
959
1333
  // Keyed by event name; values are erased to (unknown) => void internally and
960
1334
  // retyped at the on()/emit() boundary, which keeps the public API strictly
@@ -967,15 +1341,20 @@
967
1341
  // or the welcome timed out — permanent outcomes that must not reconnect.
968
1342
  this.terminal = false;
969
1343
  this.reconnectAttempts = 0;
1344
+ // The in-flight connect()'s settlement, so leave() can reject a join that
1345
+ // is still waiting on its welcome instead of stranding it on the timeout.
1346
+ this.pendingConnect = null;
970
1347
  this.code = code;
971
1348
  this.devGrant = devGrant;
972
1349
  this.joinData = joinData;
1350
+ this.welcomeTimeoutMs = welcomeTimeoutMs;
973
1351
  }
974
1352
  /** Connect and resolve on the server's welcome (post-ticket-verification). */
975
1353
  connect(grant) {
976
1354
  return new Promise((resolve, reject) => {
977
- let settled = false;
1355
+ let settled2 = false;
978
1356
  const url = grant.roomUrl + (grant.roomUrl.indexOf("?") === -1 ? "?" : "&") + "ticket=" + encodeURIComponent(grant.ticket) + (this.joinData === null ? "" : "&join=" + encodeURIComponent(this.joinData));
1357
+ const connectStartedAt = Date.now();
979
1358
  let ws;
980
1359
  try {
981
1360
  ws = new WebSocket(url);
@@ -984,15 +1363,17 @@
984
1363
  }
985
1364
  this.ws = ws;
986
1365
  const welcomeTimer = setTimeout(() => {
987
- if (settled) return;
988
- settled = true;
1366
+ if (settled2) return;
1367
+ settled2 = true;
1368
+ this.pendingConnect = null;
989
1369
  this.terminal = true;
990
1370
  try {
991
1371
  ws.close();
992
1372
  } catch (err) {
993
1373
  }
994
1374
  reject(sdkError3("error"));
995
- }, WELCOME_TIMEOUT_MS);
1375
+ }, this.welcomeTimeoutMs);
1376
+ this.pendingConnect = { reject, timer: welcomeTimer };
996
1377
  ws.onmessage = (event) => {
997
1378
  var _a, _b, _c, _d, _e;
998
1379
  let msg;
@@ -1003,14 +1384,23 @@
1003
1384
  }
1004
1385
  if (!msg || typeof msg.t !== "string") return;
1005
1386
  if (msg.t === "welcome") {
1387
+ if (this.intentionalClose) {
1388
+ try {
1389
+ ws.close();
1390
+ } catch (err) {
1391
+ }
1392
+ return;
1393
+ }
1006
1394
  const reconnected = this.reconnectAttempts > 0;
1007
1395
  this.playerId = String((_a = msg.playerId) != null ? _a : "");
1008
1396
  this.players = Array.isArray(msg.players) ? msg.players : [];
1009
1397
  this.state = (_b = msg.state) != null ? _b : null;
1010
1398
  this.connected = true;
1011
1399
  this.reconnectAttempts = 0;
1012
- if (!settled) {
1013
- settled = true;
1400
+ this.latencyMs = Math.max(0, Date.now() - connectStartedAt);
1401
+ if (!settled2) {
1402
+ settled2 = true;
1403
+ this.pendingConnect = null;
1014
1404
  clearTimeout(welcomeTimer);
1015
1405
  resolve(this);
1016
1406
  }
@@ -1049,24 +1439,27 @@
1049
1439
  }
1050
1440
  if (msg.t === "error") {
1051
1441
  const code = String((_e = msg.code) != null ? _e : "error");
1052
- if (!settled) {
1053
- settled = true;
1442
+ if (!settled2) {
1443
+ settled2 = true;
1054
1444
  this.terminal = true;
1445
+ this.pendingConnect = null;
1055
1446
  clearTimeout(welcomeTimer);
1056
1447
  try {
1057
1448
  ws.close();
1058
1449
  } catch (err) {
1059
1450
  }
1060
- reject(sdkError3(code === "unauthenticated" ? "unauthenticated" : "rejected"));
1451
+ reject(sdkError3(code === "unauthenticated" ? "unauthenticated" : "rejected", code));
1061
1452
  return;
1062
1453
  }
1063
1454
  this.emit("error", { code });
1064
1455
  }
1065
1456
  };
1066
- ws.onclose = () => {
1457
+ ws.onclose = (event) => {
1067
1458
  this.connected = false;
1068
- if (!settled) {
1069
- settled = true;
1459
+ if (event && event.code === SEAT_FULL_CLOSE_CODE) this.terminal = true;
1460
+ if (!settled2) {
1461
+ settled2 = true;
1462
+ this.pendingConnect = null;
1070
1463
  clearTimeout(welcomeTimer);
1071
1464
  reject(sdkError3("error"));
1072
1465
  return;
@@ -1095,7 +1488,7 @@
1095
1488
  await new Promise((r) => setTimeout(r, 400 * this.reconnectAttempts));
1096
1489
  if (this.intentionalClose || this.ended || this.terminal) return;
1097
1490
  try {
1098
- const grant = (_a = this.devGrant) != null ? _a : await requestTicket(this.code);
1491
+ const grant = (_a = this.devGrant) != null ? _a : await requestTicket({ roomId: this.code });
1099
1492
  if (this.intentionalClose || this.ended || this.terminal) return;
1100
1493
  await this.connect(grant);
1101
1494
  if (this.intentionalClose) this.leave();
@@ -1130,6 +1523,12 @@
1130
1523
  var _a;
1131
1524
  this.intentionalClose = true;
1132
1525
  this.connected = false;
1526
+ if (this.pendingConnect) {
1527
+ const pending = this.pendingConnect;
1528
+ this.pendingConnect = null;
1529
+ clearTimeout(pending.timer);
1530
+ pending.reject(sdkError3("error"));
1531
+ }
1133
1532
  try {
1134
1533
  (_a = this.ws) == null ? void 0 : _a.close(1e3);
1135
1534
  } catch (err) {
@@ -1149,27 +1548,62 @@
1149
1548
  }
1150
1549
  }
1151
1550
  };
1551
+ var MATCH_ATTEMPTS = 3;
1552
+ function normalizeWelcomeTimeoutMs(value) {
1553
+ if (typeof value !== "number" || !isFinite(value)) return WELCOME_TIMEOUT_MS;
1554
+ return Math.min(MAX_WELCOME_TIMEOUT_MS, Math.max(MIN_WELCOME_TIMEOUT_MS, Math.floor(value)));
1555
+ }
1556
+ async function matchJoin(joinData, timeoutMs) {
1557
+ let lastError = sdkError3("error");
1558
+ for (let attempt = 0; attempt < MATCH_ATTEMPTS; attempt++) {
1559
+ const grant = await requestTicket({ matchmake: true });
1560
+ if (!grant.roomId) throw sdkError3("error");
1561
+ try {
1562
+ return await new RoomConnection(grant.roomId, null, joinData, timeoutMs).connect(grant);
1563
+ } catch (error) {
1564
+ lastError = error;
1565
+ const detail = error == null ? void 0 : error.detail;
1566
+ if (detail !== "room_full" && detail !== "room_over") throw error;
1567
+ }
1568
+ }
1569
+ throw lastError;
1570
+ }
1152
1571
  function joinRoom(options) {
1153
1572
  const opts = options != null ? options : {};
1154
1573
  if (typeof window === "undefined" || typeof WebSocket === "undefined") {
1155
1574
  return Promise.reject(sdkError3("unsupported"));
1156
1575
  }
1157
- const code = opts.create || !opts.code ? generateCode() : opts.code.toUpperCase();
1158
1576
  let joinData;
1159
1577
  try {
1160
1578
  joinData = serializeJoinData(opts.joinData);
1161
1579
  } catch (err) {
1162
1580
  return Promise.reject(err);
1163
1581
  }
1582
+ const timeoutMs = normalizeWelcomeTimeoutMs(opts.timeoutMs);
1583
+ if (opts.match) {
1584
+ if (opts.code || opts.create || opts.roomUrl || opts.ticket) {
1585
+ return Promise.reject(sdkError3("rejected"));
1586
+ }
1587
+ return matchJoin(joinData, timeoutMs);
1588
+ }
1589
+ const code = opts.create || !opts.code ? generateCode() : opts.code.toUpperCase();
1164
1590
  if (opts.roomUrl && opts.ticket) {
1165
- const devGrant = { ticket: opts.ticket, roomUrl: opts.roomUrl, expiresAt: 0 };
1166
- return new RoomConnection(code, devGrant, joinData).connect(devGrant);
1591
+ const devGrant = {
1592
+ ticket: opts.ticket,
1593
+ roomUrl: opts.roomUrl,
1594
+ expiresAt: 0,
1595
+ roomId: code
1596
+ };
1597
+ return new RoomConnection(code, devGrant, joinData, timeoutMs).connect(devGrant);
1167
1598
  }
1168
- return requestTicket(code).then((grant) => new RoomConnection(code, null, joinData).connect(grant));
1599
+ return requestTicket({ roomId: code }).then(
1600
+ (grant) => new RoomConnection(code, null, joinData, timeoutMs).connect(grant)
1601
+ );
1169
1602
  }
1170
1603
  var multiplayer = { joinRoom };
1171
1604
 
1172
1605
  // src/global.ts
1173
1606
  BBArcade.multiplayer = multiplayer;
1174
1607
  window.BBArcade = BBArcade;
1608
+ BBArcade.storage.install();
1175
1609
  })();