@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.
@@ -30,6 +30,316 @@ var VERSION = 1;
30
30
  var SDK_MESSAGE_SOURCE = "bb-arcade";
31
31
  var HOST_MESSAGE_SOURCE = "bb-arcade-host";
32
32
 
33
+ // src/storage.ts
34
+ var FLUSH_DELAY_MS = 400;
35
+ var ENVELOPE_TAG = "__bbArcadeStorage";
36
+ var ENVELOPE_VERSION = 1;
37
+ function hasOwn(target, key) {
38
+ return Object.prototype.hasOwnProperty.call(target, key);
39
+ }
40
+ function emptyMap() {
41
+ return /* @__PURE__ */ Object.create(null);
42
+ }
43
+ function storageError(code) {
44
+ const err = new Error(code);
45
+ err.code = code;
46
+ return err;
47
+ }
48
+ function parseEnvelope(raw) {
49
+ if (raw == null) return { ls: {}, app: null };
50
+ if (raw.charAt(0) !== "{") return { ls: {}, app: raw };
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(raw);
54
+ } catch (err) {
55
+ return { ls: {}, app: raw };
56
+ }
57
+ if (!parsed || typeof parsed !== "object") return { ls: {}, app: raw };
58
+ const record = parsed;
59
+ if (record[ENVELOPE_TAG] !== ENVELOPE_VERSION) return { ls: {}, app: raw };
60
+ const ls = emptyMap();
61
+ const source = record.ls;
62
+ if (source && typeof source === "object") {
63
+ const entries = source;
64
+ for (const key in entries) {
65
+ if (hasOwn(entries, key) && typeof entries[key] === "string") {
66
+ ls[key] = entries[key];
67
+ }
68
+ }
69
+ }
70
+ return { ls, app: typeof record.app === "string" ? record.app : null };
71
+ }
72
+ function buildEnvelope(ls, app) {
73
+ const envelope = {};
74
+ envelope[ENVELOPE_TAG] = ENVELOPE_VERSION;
75
+ envelope.ls = ls;
76
+ envelope.app = app;
77
+ return JSON.stringify(envelope);
78
+ }
79
+ function quotaError() {
80
+ try {
81
+ return new DOMException("Cloud save quota exceeded", "QuotaExceededError");
82
+ } catch (err) {
83
+ const fallback = new Error("Cloud save quota exceeded");
84
+ fallback.name = "QuotaExceededError";
85
+ return fallback;
86
+ }
87
+ }
88
+ var RESERVED = {
89
+ getItem: true,
90
+ setItem: true,
91
+ removeItem: true,
92
+ clear: true,
93
+ key: true,
94
+ length: true
95
+ };
96
+ function createStorageShim(backend = {}) {
97
+ const map = emptyMap();
98
+ const localWrites = emptyMap();
99
+ function keys() {
100
+ return Object.keys(map);
101
+ }
102
+ function changed() {
103
+ if (backend.onChange) backend.onChange();
104
+ }
105
+ const api = {
106
+ getItem: function(key) {
107
+ const name = String(key);
108
+ return hasOwn(map, name) ? map[name] : null;
109
+ },
110
+ setItem: function(key, value) {
111
+ const name = String(key);
112
+ const next = String(value);
113
+ if (backend.wouldOverflow && backend.wouldOverflow(name, next)) throw quotaError();
114
+ map[name] = next;
115
+ localWrites[name] = true;
116
+ changed();
117
+ },
118
+ removeItem: function(key) {
119
+ const name = String(key);
120
+ localWrites[name] = true;
121
+ if (!hasOwn(map, name)) return;
122
+ delete map[name];
123
+ changed();
124
+ },
125
+ clear: function() {
126
+ const existing = keys();
127
+ for (let i = 0; i < existing.length; i++) {
128
+ delete map[existing[i]];
129
+ localWrites[existing[i]] = true;
130
+ }
131
+ changed();
132
+ },
133
+ key: function(index) {
134
+ const existing = keys();
135
+ const at = Number(index);
136
+ return at >= 0 && at < existing.length ? existing[at] : null;
137
+ },
138
+ get length() {
139
+ return keys().length;
140
+ }
141
+ };
142
+ const storage = new Proxy(api, {
143
+ get(target, prop, receiver) {
144
+ if (typeof prop !== "string") return Reflect.get(target, prop, receiver);
145
+ if (hasOwn(RESERVED, prop)) return Reflect.get(target, prop, receiver);
146
+ return hasOwn(map, prop) ? map[prop] : void 0;
147
+ },
148
+ set(target, prop, value) {
149
+ if (typeof prop !== "string" || hasOwn(RESERVED, prop)) return true;
150
+ api.setItem(prop, value);
151
+ return true;
152
+ },
153
+ has(target, prop) {
154
+ if (typeof prop !== "string") return Reflect.has(target, prop);
155
+ return hasOwn(RESERVED, prop) || hasOwn(map, prop);
156
+ },
157
+ deleteProperty(target, prop) {
158
+ if (typeof prop === "string" && !hasOwn(RESERVED, prop)) api.removeItem(prop);
159
+ return true;
160
+ },
161
+ ownKeys() {
162
+ return keys();
163
+ },
164
+ getOwnPropertyDescriptor(target, prop) {
165
+ if (typeof prop !== "string" || !hasOwn(map, prop)) return void 0;
166
+ return { value: map[prop], writable: true, enumerable: true, configurable: true };
167
+ }
168
+ });
169
+ return {
170
+ storage,
171
+ entries: function() {
172
+ const copy = emptyMap();
173
+ const existing = keys();
174
+ for (let i = 0; i < existing.length; i++) copy[existing[i]] = map[existing[i]];
175
+ return copy;
176
+ },
177
+ hydrate: function(values) {
178
+ for (const key in values) {
179
+ if (hasOwn(values, key) && !hasOwn(localWrites, key)) map[key] = values[key];
180
+ }
181
+ }
182
+ };
183
+ }
184
+ function settled(promise) {
185
+ return promise.then(
186
+ function() {
187
+ },
188
+ function() {
189
+ }
190
+ );
191
+ }
192
+ function installSessionStorageShim() {
193
+ if (nativeStorageWorks("sessionStorage")) return;
194
+ try {
195
+ Object.defineProperty(window, "sessionStorage", {
196
+ value: createStorageShim().storage,
197
+ configurable: true
198
+ });
199
+ } catch (err) {
200
+ }
201
+ }
202
+ function nativeStorageWorks(area) {
203
+ try {
204
+ const store = window[area];
205
+ if (!store) return false;
206
+ const probe = "__bb_arcade_probe__";
207
+ store.setItem(probe, "1");
208
+ store.removeItem(probe);
209
+ return true;
210
+ } catch (err) {
211
+ return false;
212
+ }
213
+ }
214
+ function createStorageController(options) {
215
+ let shim = null;
216
+ let mode = "native";
217
+ let appBlob = null;
218
+ let hydration = null;
219
+ let flushTimer = null;
220
+ let inFlight = Promise.resolve();
221
+ let dirty = false;
222
+ let hydrated = false;
223
+ function serialize() {
224
+ return buildEnvelope(shim ? shim.entries() : {}, appBlob);
225
+ }
226
+ function hydrateFromCloud() {
227
+ hydration = options.load().then(
228
+ function(raw) {
229
+ const envelope = parseEnvelope(raw);
230
+ if (shim) shim.hydrate(envelope.ls);
231
+ if (appBlob === null) appBlob = envelope.app;
232
+ hydrated = true;
233
+ return mode;
234
+ },
235
+ function(err) {
236
+ const code = err && err.code;
237
+ if (code === "unsupported" || code === "unauthenticated") mode = "memory";
238
+ return mode;
239
+ }
240
+ );
241
+ return hydration;
242
+ }
243
+ function wouldOverflow(key, value) {
244
+ if (!shim) return false;
245
+ const next = shim.entries();
246
+ next[key] = value;
247
+ return options.byteLength(buildEnvelope(next, appBlob)) > options.maxBytes;
248
+ }
249
+ function persist() {
250
+ return whenHydrated().then(function() {
251
+ if (mode === "memory") return;
252
+ if (hydrated) return options.save(serialize());
253
+ return hydrateFromCloud().then(function() {
254
+ if (mode === "memory") return;
255
+ if (!hydrated) throw storageError("error");
256
+ return options.save(serialize());
257
+ });
258
+ }).then(void 0, function(err) {
259
+ dirty = true;
260
+ throw err;
261
+ });
262
+ }
263
+ function flushNow() {
264
+ if (flushTimer !== null) {
265
+ clearTimeout(flushTimer);
266
+ flushTimer = null;
267
+ }
268
+ dirty = false;
269
+ const next = inFlight.then(persist, persist);
270
+ inFlight = settled(next);
271
+ return next;
272
+ }
273
+ function scheduleFlush() {
274
+ dirty = true;
275
+ if (flushTimer !== null) return;
276
+ flushTimer = setTimeout(function() {
277
+ flushTimer = null;
278
+ void settled(flushNow());
279
+ }, FLUSH_DELAY_MS);
280
+ }
281
+ function whenHydrated() {
282
+ return hydration ? hydration : Promise.resolve(mode);
283
+ }
284
+ function attachUnloadFlush() {
285
+ const flushIfDirty = function() {
286
+ if (dirty) void settled(flushNow());
287
+ };
288
+ try {
289
+ window.addEventListener("pagehide", flushIfDirty);
290
+ window.addEventListener("visibilitychange", function() {
291
+ if (document.visibilityState === "hidden") flushIfDirty();
292
+ });
293
+ } catch (err) {
294
+ }
295
+ }
296
+ function install() {
297
+ if (shim) return mode;
298
+ installSessionStorageShim();
299
+ if (nativeStorageWorks("localStorage")) {
300
+ mode = "native";
301
+ return mode;
302
+ }
303
+ const handle = createStorageShim({ onChange: scheduleFlush, wouldOverflow });
304
+ try {
305
+ Object.defineProperty(window, "localStorage", {
306
+ value: handle.storage,
307
+ configurable: true
308
+ });
309
+ } catch (err) {
310
+ return mode;
311
+ }
312
+ shim = handle;
313
+ mode = "cloud";
314
+ attachUnloadFlush();
315
+ void hydrateFromCloud();
316
+ return mode;
317
+ }
318
+ return {
319
+ install,
320
+ active: function() {
321
+ return shim !== null;
322
+ },
323
+ ready: function() {
324
+ return whenHydrated();
325
+ },
326
+ flush: function() {
327
+ if (!shim && appBlob === null) return Promise.resolve();
328
+ return flushNow();
329
+ },
330
+ writeApp: function(blob) {
331
+ if (options.byteLength(buildEnvelope(shim ? shim.entries() : {}, blob)) > options.maxBytes) {
332
+ return Promise.reject(storageError("too_large"));
333
+ }
334
+ appBlob = blob;
335
+ return flushNow();
336
+ },
337
+ get mode() {
338
+ return mode;
339
+ }
340
+ };
341
+ }
342
+
33
343
  // src/core.ts
34
344
  var BOUNTY_BOARD_URL = "https://www.bountyboard.gg/arcade";
35
345
  var ADSENSE_CLIENT_ID = "ca-pub-7727110228190547";
@@ -37,6 +347,8 @@ var AD_PLACEMENT_SRC = "https://pagead2.googlesyndication.com/pagead/js/adsbygoo
37
347
  var AD_PLACEMENT_SCRIPT_ID = "bb-arcade-h5-ads";
38
348
  var DEFAULT_AD_LOAD_TIMEOUT_MS = 6e3;
39
349
  var SAVE_TIMEOUT_MS = 15e3;
350
+ var LOCK_TIMEOUT_MS = 5e3;
351
+ var MAX_SAVE_BYTES = 1024 * 1024;
40
352
  var INIT_CONFIG_GRACE_MS = 1500;
41
353
  var DEFAULT_ALLOW = [
42
354
  "bountyboard.gg",
@@ -61,6 +373,14 @@ function cleanText(value, fallback, max) {
61
373
  if (typeof value !== "string") return fallback || "";
62
374
  return value.replace(/[^\w:.-]+/g, "_").slice(0, max || 80) || fallback || "";
63
375
  }
376
+ var cachedTextEncoder = null;
377
+ function utf8ByteLength(value) {
378
+ if (typeof TextEncoder !== "undefined") {
379
+ cachedTextEncoder = cachedTextEncoder || new TextEncoder();
380
+ return cachedTextEncoder.encode(value).length;
381
+ }
382
+ return new Blob([value]).size;
383
+ }
64
384
  function toBoolean(value) {
65
385
  return value === true || value === "true" || value === "1";
66
386
  }
@@ -180,13 +500,14 @@ function createBBArcade() {
180
500
  };
181
501
  let hostConfigReceived = false;
182
502
  let hostPlayer = null;
503
+ const playerChangeHandlers = [];
183
504
  let blocked = false;
184
505
  const pending = {};
185
506
  let seq = 0;
186
507
  function post(type, score, extra) {
187
508
  if (blocked) return;
188
509
  const message = { source: SDK_MESSAGE_SOURCE, v: VERSION, type };
189
- if (typeof score === "number" && isFinite(score)) message.score = Math.floor(score);
510
+ if (typeof score === "number" && isFinite(score)) message.score = Math.trunc(score);
190
511
  if (extra && typeof extra === "object") {
191
512
  const fields = extra;
192
513
  for (const key in fields) {
@@ -207,7 +528,7 @@ function createBBArcade() {
207
528
  return true;
208
529
  }
209
530
  }
210
- function request(type, payload) {
531
+ function request(type, payload, timeoutMs = SAVE_TIMEOUT_MS) {
211
532
  return new Promise(function(resolve, reject) {
212
533
  if (blocked) return reject(sdkError("error"));
213
534
  if (!isEmbedded()) return reject(sdkError("unsupported"));
@@ -217,7 +538,7 @@ function createBBArcade() {
217
538
  delete pending[requestId];
218
539
  reject(sdkError("error"));
219
540
  }
220
- }, SAVE_TIMEOUT_MS);
541
+ }, timeoutMs);
221
542
  pending[requestId] = { resolve, reject, timer };
222
543
  try {
223
544
  window.parent.postMessage(
@@ -309,7 +630,7 @@ function createBBArcade() {
309
630
  if (!hostnameAllowed(origin, allow)) blockHost(options);
310
631
  return;
311
632
  }
312
- request("host").then(
633
+ request("host", void 0, LOCK_TIMEOUT_MS).then(
313
634
  function(res) {
314
635
  const r = res;
315
636
  if (!hostnameAllowed(r && r.origin, allow)) blockHost(options);
@@ -333,7 +654,7 @@ function createBBArcade() {
333
654
  lockToHostBestEffort(opts, allow);
334
655
  return;
335
656
  }
336
- request("host_attest").then(
657
+ request("host_attest", void 0, LOCK_TIMEOUT_MS).then(
337
658
  function(res) {
338
659
  const r = res;
339
660
  if (!r || !r.attestation) {
@@ -395,7 +716,15 @@ function createBBArcade() {
395
716
  if (msg.source !== HOST_MESSAGE_SOURCE || msg.type !== "config") return;
396
717
  hostConfigReceived = true;
397
718
  if (msg.player !== void 0) {
398
- hostPlayer = sanitizePlayer(msg.player);
719
+ const nextPlayer = sanitizePlayer(msg.player);
720
+ const changed = nextPlayer === null ? hostPlayer !== null : hostPlayer === null || nextPlayer.name !== hostPlayer.name || nextPlayer.avatarUrl !== hostPlayer.avatarUrl;
721
+ hostPlayer = nextPlayer;
722
+ if (changed) {
723
+ const snapshot = nextPlayer ? { name: nextPlayer.name, avatarUrl: nextPlayer.avatarUrl } : null;
724
+ for (let i = 0; i < playerChangeHandlers.length; i++) {
725
+ safeCall(playerChangeHandlers[i], snapshot);
726
+ }
727
+ }
399
728
  }
400
729
  const rewardedAds = msg.rewardedAds;
401
730
  if (rewardedAds && typeof rewardedAds === "object") {
@@ -527,6 +856,7 @@ function createBBArcade() {
527
856
  let resolved = false;
528
857
  let status = "unavailable";
529
858
  let rewardOffered = false;
859
+ let adShown = false;
530
860
  const waitTimer = setTimeout(function() {
531
861
  if (rewardOffered || resolved) return;
532
862
  const timeout = Object.assign(
@@ -571,6 +901,7 @@ function createBBArcade() {
571
901
  }
572
902
  post("rewarded_ad", void 0, Object.assign({ event: "started" }, base));
573
903
  try {
904
+ adShown = true;
574
905
  showAdFn();
575
906
  } catch (err) {
576
907
  const showError = Object.assign(
@@ -620,7 +951,13 @@ function createBBArcade() {
620
951
  },
621
952
  adBreakDone: function(placementInfo) {
622
953
  if (resolved) return;
623
- const result = rewardOffered || status !== "unavailable" ? { status } : { status: "unavailable", error: "no_rewarded_ad" };
954
+ const result = rewardOffered || status !== "unavailable" ? {
955
+ // A reward was offered but the ad never reached the screen
956
+ // (the game retained show() and let the break lapse): the
957
+ // break is over without a view, so resolve 'dismissed',
958
+ // not the non-terminal-looking 'ready'.
959
+ status: status === "ready" && !adShown ? "dismissed" : status
960
+ } : { status: "unavailable", error: "no_rewarded_ad" };
624
961
  if (placementInfo && typeof placementInfo === "object") {
625
962
  result.breakStatus = cleanText(
626
963
  placementInfo.breakStatus
@@ -791,6 +1128,16 @@ function createBBArcade() {
791
1128
  if (!hostnameAllowed(event.origin, DEFAULT_ALLOW)) return;
792
1129
  applyHostConfig(event.data);
793
1130
  });
1131
+ const storage = createStorageController({
1132
+ save: function(blob) {
1133
+ return request("save", blob);
1134
+ },
1135
+ load: function() {
1136
+ return request("load");
1137
+ },
1138
+ byteLength: utf8ByteLength,
1139
+ maxBytes: MAX_SAVE_BYTES
1140
+ });
794
1141
  const sdk = {
795
1142
  lockToHost,
796
1143
  init,
@@ -823,12 +1170,29 @@ function createBBArcade() {
823
1170
  prepareRewardedBreak: prepareRewardedAd,
824
1171
  rewardedBreak,
825
1172
  save: function(blob) {
826
- return request("save", String(blob));
1173
+ const data = String(blob);
1174
+ if (utf8ByteLength(data) > MAX_SAVE_BYTES) {
1175
+ return Promise.reject(sdkError("too_large"));
1176
+ }
1177
+ if (storage.active()) return storage.writeApp(data);
1178
+ return request("save", data);
827
1179
  },
828
1180
  load: function() {
829
- return request("load");
1181
+ return request("load").then(function(raw) {
1182
+ return parseEnvelope(raw).app;
1183
+ });
830
1184
  },
1185
+ storage,
831
1186
  getPlayer,
1187
+ onPlayerChange: function(handler) {
1188
+ if (typeof handler !== "function") return function() {
1189
+ };
1190
+ playerChangeHandlers.push(handler);
1191
+ return function() {
1192
+ const idx = playerChangeHandlers.indexOf(handler);
1193
+ if (idx !== -1) playerChangeHandlers.splice(idx, 1);
1194
+ };
1195
+ },
832
1196
  getVariant: function(key, variants) {
833
1197
  const sorted = Array.isArray(variants) ? variants.slice().sort() : [];
834
1198
  const fallback = sorted.length > 0 ? sorted[0] : null;
@@ -894,7 +1258,17 @@ function createInertBBArcade() {
894
1258
  rewardedBreak: () => Promise.resolve(false),
895
1259
  save: () => Promise.reject(sdkError2("unsupported")),
896
1260
  load: () => Promise.reject(sdkError2("unsupported")),
1261
+ // No window to shim and nothing to persist to: report the honest mode
1262
+ // rather than pretending an install succeeded.
1263
+ storage: {
1264
+ install: () => "memory",
1265
+ ready: () => Promise.resolve("memory"),
1266
+ flush: () => Promise.resolve(),
1267
+ mode: "memory"
1268
+ },
897
1269
  getPlayer: () => Promise.resolve(null),
1270
+ onPlayerChange: () => () => {
1271
+ },
898
1272
  getVariant: (key, variants) => {
899
1273
  const sorted = Array.isArray(variants) ? variants.slice().sort() : [];
900
1274
  return Promise.resolve(sorted.length > 0 ? sorted[0] : null);
@@ -908,13 +1282,17 @@ var BBArcade = typeof window === "undefined" ? createInertBBArcade() : createBBA
908
1282
 
909
1283
  // src/multiplayer.ts
910
1284
  var WELCOME_TIMEOUT_MS = 1e4;
1285
+ var MIN_WELCOME_TIMEOUT_MS = 1e3;
1286
+ var MAX_WELCOME_TIMEOUT_MS = 6e4;
911
1287
  var MAX_RECONNECT_ATTEMPTS = 3;
912
1288
  var MAX_JOIN_DATA_BYTES = 1024;
1289
+ var SEAT_FULL_CLOSE_CODE = 4005;
913
1290
  var CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
914
1291
  var CODE_LENGTH = 4;
915
- function sdkError3(code) {
1292
+ function sdkError3(code, detail) {
916
1293
  const err = new Error(code);
917
1294
  err.code = code;
1295
+ if (detail) err.detail = detail;
918
1296
  return err;
919
1297
  }
920
1298
  function randomChars(length) {
@@ -931,23 +1309,13 @@ function randomChars(length) {
931
1309
  function generateCode() {
932
1310
  return randomChars(CODE_LENGTH);
933
1311
  }
934
- function utf8ByteLength(value) {
935
- let bytes = 0;
936
- for (let i = 0; i < value.length; i++) {
937
- const code = value.charCodeAt(i);
938
- if (code < 128) bytes += 1;
939
- else if (code < 2048) bytes += 2;
940
- else if (code >= 55296 && code <= 56319 && i + 1 < value.length) {
941
- const next = value.charCodeAt(i + 1);
942
- if (next >= 56320 && next <= 57343) {
943
- bytes += 4;
944
- i += 1;
945
- } else {
946
- bytes += 3;
947
- }
948
- } else bytes += 3;
1312
+ var cachedTextEncoder2 = null;
1313
+ function utf8ByteLength2(value) {
1314
+ if (typeof TextEncoder !== "undefined") {
1315
+ cachedTextEncoder2 = cachedTextEncoder2 || new TextEncoder();
1316
+ return cachedTextEncoder2.encode(value).length;
949
1317
  }
950
- return bytes;
1318
+ return new Blob([value]).size;
951
1319
  }
952
1320
  function serializeJoinData(data) {
953
1321
  if (data === void 0) return null;
@@ -958,28 +1326,34 @@ function serializeJoinData(data) {
958
1326
  } catch (e) {
959
1327
  throw sdkError3("rejected");
960
1328
  }
961
- if (!serialized || utf8ByteLength(serialized) > MAX_JOIN_DATA_BYTES) {
1329
+ if (!serialized || utf8ByteLength2(serialized) > MAX_JOIN_DATA_BYTES) {
962
1330
  throw sdkError3("rejected");
963
1331
  }
964
1332
  return serialized;
965
1333
  }
966
- function requestTicket(roomId) {
1334
+ function requestTicket(payload) {
967
1335
  const request = BBArcade._request;
968
1336
  if (typeof request !== "function") return Promise.reject(sdkError3("unsupported"));
969
- return request("mp_ticket", { roomId }).then((grant) => {
1337
+ return request("mp_ticket", payload).then((grant) => {
970
1338
  const g = grant;
971
1339
  if (!g || typeof g.ticket !== "string" || typeof g.roomUrl !== "string") {
972
1340
  throw sdkError3("error");
973
1341
  }
974
- return { ticket: g.ticket, roomUrl: g.roomUrl, expiresAt: Number(g.expiresAt) || 0 };
1342
+ return {
1343
+ ticket: g.ticket,
1344
+ roomUrl: g.roomUrl,
1345
+ expiresAt: Number(g.expiresAt) || 0,
1346
+ roomId: typeof g.roomId === "string" && g.roomId ? g.roomId : null
1347
+ };
975
1348
  });
976
1349
  }
977
1350
  var RoomConnection = class {
978
- constructor(code, devGrant, joinData) {
1351
+ constructor(code, devGrant, joinData, welcomeTimeoutMs = WELCOME_TIMEOUT_MS) {
979
1352
  this.playerId = "";
980
1353
  this.players = [];
981
1354
  this.state = null;
982
1355
  this.connected = false;
1356
+ this.latencyMs = null;
983
1357
  this.ws = null;
984
1358
  // Keyed by event name; values are erased to (unknown) => void internally and
985
1359
  // retyped at the on()/emit() boundary, which keeps the public API strictly
@@ -992,15 +1366,20 @@ var RoomConnection = class {
992
1366
  // or the welcome timed out — permanent outcomes that must not reconnect.
993
1367
  this.terminal = false;
994
1368
  this.reconnectAttempts = 0;
1369
+ // The in-flight connect()'s settlement, so leave() can reject a join that
1370
+ // is still waiting on its welcome instead of stranding it on the timeout.
1371
+ this.pendingConnect = null;
995
1372
  this.code = code;
996
1373
  this.devGrant = devGrant;
997
1374
  this.joinData = joinData;
1375
+ this.welcomeTimeoutMs = welcomeTimeoutMs;
998
1376
  }
999
1377
  /** Connect and resolve on the server's welcome (post-ticket-verification). */
1000
1378
  connect(grant) {
1001
1379
  return new Promise((resolve, reject) => {
1002
- let settled = false;
1380
+ let settled2 = false;
1003
1381
  const url = grant.roomUrl + (grant.roomUrl.indexOf("?") === -1 ? "?" : "&") + "ticket=" + encodeURIComponent(grant.ticket) + (this.joinData === null ? "" : "&join=" + encodeURIComponent(this.joinData));
1382
+ const connectStartedAt = Date.now();
1004
1383
  let ws;
1005
1384
  try {
1006
1385
  ws = new WebSocket(url);
@@ -1009,15 +1388,17 @@ var RoomConnection = class {
1009
1388
  }
1010
1389
  this.ws = ws;
1011
1390
  const welcomeTimer = setTimeout(() => {
1012
- if (settled) return;
1013
- settled = true;
1391
+ if (settled2) return;
1392
+ settled2 = true;
1393
+ this.pendingConnect = null;
1014
1394
  this.terminal = true;
1015
1395
  try {
1016
1396
  ws.close();
1017
1397
  } catch (err) {
1018
1398
  }
1019
1399
  reject(sdkError3("error"));
1020
- }, WELCOME_TIMEOUT_MS);
1400
+ }, this.welcomeTimeoutMs);
1401
+ this.pendingConnect = { reject, timer: welcomeTimer };
1021
1402
  ws.onmessage = (event) => {
1022
1403
  var _a, _b, _c, _d, _e;
1023
1404
  let msg;
@@ -1028,14 +1409,23 @@ var RoomConnection = class {
1028
1409
  }
1029
1410
  if (!msg || typeof msg.t !== "string") return;
1030
1411
  if (msg.t === "welcome") {
1412
+ if (this.intentionalClose) {
1413
+ try {
1414
+ ws.close();
1415
+ } catch (err) {
1416
+ }
1417
+ return;
1418
+ }
1031
1419
  const reconnected = this.reconnectAttempts > 0;
1032
1420
  this.playerId = String((_a = msg.playerId) != null ? _a : "");
1033
1421
  this.players = Array.isArray(msg.players) ? msg.players : [];
1034
1422
  this.state = (_b = msg.state) != null ? _b : null;
1035
1423
  this.connected = true;
1036
1424
  this.reconnectAttempts = 0;
1037
- if (!settled) {
1038
- settled = true;
1425
+ this.latencyMs = Math.max(0, Date.now() - connectStartedAt);
1426
+ if (!settled2) {
1427
+ settled2 = true;
1428
+ this.pendingConnect = null;
1039
1429
  clearTimeout(welcomeTimer);
1040
1430
  resolve(this);
1041
1431
  }
@@ -1074,24 +1464,27 @@ var RoomConnection = class {
1074
1464
  }
1075
1465
  if (msg.t === "error") {
1076
1466
  const code = String((_e = msg.code) != null ? _e : "error");
1077
- if (!settled) {
1078
- settled = true;
1467
+ if (!settled2) {
1468
+ settled2 = true;
1079
1469
  this.terminal = true;
1470
+ this.pendingConnect = null;
1080
1471
  clearTimeout(welcomeTimer);
1081
1472
  try {
1082
1473
  ws.close();
1083
1474
  } catch (err) {
1084
1475
  }
1085
- reject(sdkError3(code === "unauthenticated" ? "unauthenticated" : "rejected"));
1476
+ reject(sdkError3(code === "unauthenticated" ? "unauthenticated" : "rejected", code));
1086
1477
  return;
1087
1478
  }
1088
1479
  this.emit("error", { code });
1089
1480
  }
1090
1481
  };
1091
- ws.onclose = () => {
1482
+ ws.onclose = (event) => {
1092
1483
  this.connected = false;
1093
- if (!settled) {
1094
- settled = true;
1484
+ if (event && event.code === SEAT_FULL_CLOSE_CODE) this.terminal = true;
1485
+ if (!settled2) {
1486
+ settled2 = true;
1487
+ this.pendingConnect = null;
1095
1488
  clearTimeout(welcomeTimer);
1096
1489
  reject(sdkError3("error"));
1097
1490
  return;
@@ -1120,7 +1513,7 @@ var RoomConnection = class {
1120
1513
  await new Promise((r) => setTimeout(r, 400 * this.reconnectAttempts));
1121
1514
  if (this.intentionalClose || this.ended || this.terminal) return;
1122
1515
  try {
1123
- const grant = (_a = this.devGrant) != null ? _a : await requestTicket(this.code);
1516
+ const grant = (_a = this.devGrant) != null ? _a : await requestTicket({ roomId: this.code });
1124
1517
  if (this.intentionalClose || this.ended || this.terminal) return;
1125
1518
  await this.connect(grant);
1126
1519
  if (this.intentionalClose) this.leave();
@@ -1155,6 +1548,12 @@ var RoomConnection = class {
1155
1548
  var _a;
1156
1549
  this.intentionalClose = true;
1157
1550
  this.connected = false;
1551
+ if (this.pendingConnect) {
1552
+ const pending = this.pendingConnect;
1553
+ this.pendingConnect = null;
1554
+ clearTimeout(pending.timer);
1555
+ pending.reject(sdkError3("error"));
1556
+ }
1158
1557
  try {
1159
1558
  (_a = this.ws) == null ? void 0 : _a.close(1e3);
1160
1559
  } catch (err) {
@@ -1174,23 +1573,57 @@ var RoomConnection = class {
1174
1573
  }
1175
1574
  }
1176
1575
  };
1576
+ var MATCH_ATTEMPTS = 3;
1577
+ function normalizeWelcomeTimeoutMs(value) {
1578
+ if (typeof value !== "number" || !isFinite(value)) return WELCOME_TIMEOUT_MS;
1579
+ return Math.min(MAX_WELCOME_TIMEOUT_MS, Math.max(MIN_WELCOME_TIMEOUT_MS, Math.floor(value)));
1580
+ }
1581
+ async function matchJoin(joinData, timeoutMs) {
1582
+ let lastError = sdkError3("error");
1583
+ for (let attempt = 0; attempt < MATCH_ATTEMPTS; attempt++) {
1584
+ const grant = await requestTicket({ matchmake: true });
1585
+ if (!grant.roomId) throw sdkError3("error");
1586
+ try {
1587
+ return await new RoomConnection(grant.roomId, null, joinData, timeoutMs).connect(grant);
1588
+ } catch (error) {
1589
+ lastError = error;
1590
+ const detail = error == null ? void 0 : error.detail;
1591
+ if (detail !== "room_full" && detail !== "room_over") throw error;
1592
+ }
1593
+ }
1594
+ throw lastError;
1595
+ }
1177
1596
  function joinRoom(options) {
1178
1597
  const opts = options != null ? options : {};
1179
1598
  if (typeof window === "undefined" || typeof WebSocket === "undefined") {
1180
1599
  return Promise.reject(sdkError3("unsupported"));
1181
1600
  }
1182
- const code = opts.create || !opts.code ? generateCode() : opts.code.toUpperCase();
1183
1601
  let joinData;
1184
1602
  try {
1185
1603
  joinData = serializeJoinData(opts.joinData);
1186
1604
  } catch (err) {
1187
1605
  return Promise.reject(err);
1188
1606
  }
1607
+ const timeoutMs = normalizeWelcomeTimeoutMs(opts.timeoutMs);
1608
+ if (opts.match) {
1609
+ if (opts.code || opts.create || opts.roomUrl || opts.ticket) {
1610
+ return Promise.reject(sdkError3("rejected"));
1611
+ }
1612
+ return matchJoin(joinData, timeoutMs);
1613
+ }
1614
+ const code = opts.create || !opts.code ? generateCode() : opts.code.toUpperCase();
1189
1615
  if (opts.roomUrl && opts.ticket) {
1190
- const devGrant = { ticket: opts.ticket, roomUrl: opts.roomUrl, expiresAt: 0 };
1191
- return new RoomConnection(code, devGrant, joinData).connect(devGrant);
1616
+ const devGrant = {
1617
+ ticket: opts.ticket,
1618
+ roomUrl: opts.roomUrl,
1619
+ expiresAt: 0,
1620
+ roomId: code
1621
+ };
1622
+ return new RoomConnection(code, devGrant, joinData, timeoutMs).connect(devGrant);
1192
1623
  }
1193
- return requestTicket(code).then((grant) => new RoomConnection(code, null, joinData).connect(grant));
1624
+ return requestTicket({ roomId: code }).then(
1625
+ (grant) => new RoomConnection(code, null, joinData, timeoutMs).connect(grant)
1626
+ );
1194
1627
  }
1195
1628
  var multiplayer = { joinRoom };
1196
1629
  // Annotate the CommonJS export names for ESM import in node: