@usions/sdk 2.21.0 → 2.22.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/src/browser.js CHANGED
@@ -1,10 +1,130 @@
1
1
  var Usion = (function () {
2
2
  'use strict';
3
3
 
4
+ /**
5
+ * Usion SDK Errors — stable, machine-readable error codes.
6
+ *
7
+ * Developers should branch on `err.code`, never on message text. Messages
8
+ * are human-readable and may change; codes are part of the public API and
9
+ * follow the deprecation policy (never removed within a major version).
10
+ */
11
+
12
+ /** @type {Record<string, string>} */
13
+ const ERROR_CODES = {
14
+ NOT_CONNECTED: 'NOT_CONNECTED', // No live connection for this call
15
+ NO_ROOM: 'NO_ROOM', // No room id provided/known
16
+ ROOM_NOT_FOUND: 'ROOM_NOT_FOUND', // Room does not exist server-side
17
+ NOT_PARTICIPANT: 'NOT_PARTICIPANT', // Caller is not a player in the room
18
+ NOT_IN_ROOM: 'NOT_IN_ROOM', // Socket not in the room — rejoin required
19
+ NOT_AUTHORITY: 'NOT_AUTHORITY', // Authority-only call (e.g. lobby start)
20
+ NOT_AUTHENTICATED: 'NOT_AUTHENTICATED', // Missing/invalid auth server-side
21
+ JOIN_TIMEOUT: 'JOIN_TIMEOUT', // Join did not complete in time
22
+ CONNECT_TIMEOUT: 'CONNECT_TIMEOUT', // Connect did not complete in time
23
+ INIT_TIMEOUT: 'INIT_TIMEOUT', // init() got no INIT from the host in time
24
+ MATCH_TIMEOUT: 'MATCH_TIMEOUT', // matchmaking.find() opts.timeout elapsed
25
+ STATE_TOO_LARGE: 'STATE_TOO_LARGE', // setState payload over the quota
26
+ INVALID_STATE: 'INVALID_STATE', // setState payload not a JSON object
27
+ STALE_STATE: 'STALE_STATE', // setState older than the stored checkpoint
28
+ INVALID_NEXT_TURN: 'INVALID_NEXT_TURN', // nextTurn is not a player in the room
29
+ INVALID_INPUT: 'INVALID_INPUT', // Missing/malformed argument server-side
30
+ NOT_FOUND: 'NOT_FOUND', // Referenced entity (service, lobby, …) missing
31
+ QUOTA_EXCEEDED: 'QUOTA_EXCEEDED', // Bucket key/size quota reached (cloud KV)
32
+ VALUE_TOO_LARGE: 'VALUE_TOO_LARGE', // Single value over the per-value quota
33
+ LOBBY_FULL: 'LOBBY_FULL', // Lobby at max_players
34
+ LOBBY_CLOSED: 'LOBBY_CLOSED', // Lobby exists but is not open
35
+ CONFLICT: 'CONFLICT', // Concurrent-write conflict; retry
36
+ RATE_LIMITED: 'RATE_LIMITED', // Too many calls; back off (see retryAfter)
37
+ REQUEST_TIMEOUT: 'REQUEST_TIMEOUT', // Host/parent did not reply in time
38
+ QUEUE_FULL: 'QUEUE_FULL', // Offline action queue at capacity
39
+ CANCELLED: 'CANCELLED', // Cancelled by a local cancel() call
40
+ SUPERSEDED: 'SUPERSEDED', // Replaced by a newer identical request
41
+ UNSUPPORTED: 'UNSUPPORTED', // Not available in this transport
42
+ UNKNOWN: 'UNKNOWN', // Unmapped error (see message)
43
+ };
44
+
45
+ class UsionError extends Error {
46
+ /**
47
+ * @param {string} code - One of ERROR_CODES
48
+ * @param {string} [message] - Human-readable detail (may change between versions)
49
+ */
50
+ constructor(code, message) {
51
+ super(message || code);
52
+ this.name = 'UsionError';
53
+ this.code = ERROR_CODES[code] ? code : ERROR_CODES.UNKNOWN;
54
+ /** @type {number|undefined} Seconds until a RATE_LIMITED call may retry. */
55
+ this.retryAfter = undefined;
56
+ }
57
+ }
58
+
59
+ // Backend error strings → stable codes. Order matters: first match wins.
60
+ // Fallback path only: the backend now sends a structured `code` on every
61
+ // error ack, which toUsionError prefers. These patterns cover old backends
62
+ // and hosts that strip the code.
63
+ /** @type {Array<[RegExp, string]>} */
64
+ const BACKEND_PATTERNS = [
65
+ [/not authenticated/i, ERROR_CODES.NOT_AUTHENTICATED],
66
+ [/room_id required|no room id/i, ERROR_CODES.NO_ROOM],
67
+ [/room not found/i, ERROR_CODES.ROOM_NOT_FOUND],
68
+ [/not in room/i, ERROR_CODES.NOT_IN_ROOM],
69
+ [/not a participant/i, ERROR_CODES.NOT_PARTICIPANT],
70
+ [/room authority/i, ERROR_CODES.NOT_AUTHORITY],
71
+ [/exceeds .*limit/i, ERROR_CODES.STATE_TOO_LARGE],
72
+ [/stale checkpoint|stale state/i, ERROR_CODES.STALE_STATE],
73
+ [/state must be/i, ERROR_CODES.INVALID_STATE],
74
+ [/next_turn must be/i, ERROR_CODES.INVALID_NEXT_TURN],
75
+ [/rate limit|too many/i, ERROR_CODES.RATE_LIMITED],
76
+ [/join timeout/i, ERROR_CODES.JOIN_TIMEOUT],
77
+ [/connection timeout|connect timeout/i, ERROR_CODES.CONNECT_TIMEOUT],
78
+ [/request timeout/i, ERROR_CODES.REQUEST_TIMEOUT],
79
+ [/not connected/i, ERROR_CODES.NOT_CONNECTED],
80
+ [/quota exceeded/i, ERROR_CODES.QUOTA_EXCEEDED],
81
+ [/value too large/i, ERROR_CODES.VALUE_TOO_LARGE],
82
+ [/lobby is full/i, ERROR_CODES.LOBBY_FULL],
83
+ [/lobby is not open/i, ERROR_CODES.LOBBY_CLOSED],
84
+ [/not found|unknown service/i, ERROR_CODES.NOT_FOUND],
85
+ [/conflict/i, ERROR_CODES.CONFLICT],
86
+ [/cancelled/i, ERROR_CODES.CANCELLED],
87
+ [/superseded/i, ERROR_CODES.SUPERSEDED],
88
+ ];
89
+
90
+ /**
91
+ * Normalize anything — a structured backend ack ({ error, code?, retry_after? }),
92
+ * an Error, or a raw string — into a UsionError with the best stable code.
93
+ * A machine-readable `code` on the input always wins over message matching.
94
+ * @param {*} err
95
+ * @param {string} [fallbackCode] - Code to use when nothing matches
96
+ * @returns {UsionError}
97
+ */
98
+ function toUsionError(err, fallbackCode) {
99
+ if (err instanceof UsionError) return err;
100
+
101
+ // Structured input (backend ack object or an Error carrying a code).
102
+ if (err && typeof err === 'object') {
103
+ const code = typeof err.code === 'string' && ERROR_CODES[err.code] ? err.code : null;
104
+ const message = err.message || err.error;
105
+ if (code) {
106
+ const ue = new UsionError(code, typeof message === 'string' ? message : code);
107
+ const retryAfter = err.retry_after != null ? err.retry_after : err.retryAfter;
108
+ if (typeof retryAfter === 'number' && isFinite(retryAfter)) ue.retryAfter = retryAfter;
109
+ return ue;
110
+ }
111
+ if (!(err instanceof Error) && typeof message === 'string') err = message;
112
+ }
113
+
114
+ const message = err && err.message ? err.message : String(err || 'Unknown error');
115
+ for (let i = 0; i < BACKEND_PATTERNS.length; i++) {
116
+ if (BACKEND_PATTERNS[i][0].test(message)) {
117
+ return new UsionError(BACKEND_PATTERNS[i][1], message);
118
+ }
119
+ }
120
+ return new UsionError(fallbackCode || ERROR_CODES.UNKNOWN, message);
121
+ }
122
+
4
123
  /**
5
124
  * Usion SDK Core — init, _post, _request, message handling
6
125
  */
7
126
 
127
+
8
128
  // Request ID counter for tracking async responses
9
129
  let _requestId = 0;
10
130
  const _pendingRequests = {};
@@ -69,7 +189,7 @@ var Usion = (function () {
69
189
  * Core Usion object with init, _post, _request
70
190
  */
71
191
  const core = {
72
- version: '2.21.0', // injected from package.json at build
192
+ version: '2.22.0', // injected from package.json at build
73
193
  config: {},
74
194
  _initialized: false,
75
195
  _initCallback: null,
@@ -78,24 +198,60 @@ var Usion = (function () {
78
198
  _backButtonCallback: null,
79
199
 
80
200
  /**
81
- * Initialize the SDK with config from parent app
82
- * @param {function} callback - Called with config when ready
201
+ * Initialize the SDK with config from parent app.
202
+ *
203
+ * Callback form (unchanged): `Usion.init(cb)` — cb(config) fires when the
204
+ * host's INIT arrives. Promise form: every call ALSO returns a
205
+ * Promise<config>, so `const config = await Usion.init()` works.
206
+ *
207
+ * `opts.timeout` (ms, optional) rejects the promise with
208
+ * UsionError(INIT_TIMEOUT) if no INIT arrives in time — the "embedded but
209
+ * host silent" case that otherwise hangs forever. The callback still fires
210
+ * if INIT arrives late. Both `init(cb, opts)` and `init(opts)` are accepted.
211
+ *
212
+ * @param {function|{timeout?: number}} [callback] - Called with config when ready
213
+ * @param {{timeout?: number}} [opts]
214
+ * @returns {Promise<object>} Resolves with config
83
215
  */
84
- init: function(callback) {
216
+ init: function(callback, opts) {
85
217
  const self = this;
218
+ if (callback && typeof callback === 'object') {
219
+ opts = callback;
220
+ callback = null;
221
+ }
222
+ opts = opts || {};
86
223
 
87
224
  // Prevent double initialization - just update callback
88
225
  if (self._initialized) {
89
226
  if (callback) callback(self.config);
90
- return;
227
+ return Promise.resolve(self.config);
91
228
  }
92
229
 
93
230
  // Store callback for when config arrives
94
- self._initCallback = callback;
231
+ if (callback) self._initCallback = callback;
232
+
233
+ // One shared promise for every init() call before INIT arrives.
234
+ if (!self._initPromise) {
235
+ self._initPromise = new Promise(function(resolve, reject) {
236
+ self._initResolve = resolve;
237
+ self._initReject = reject;
238
+ });
239
+ // Callback-style users never touch the promise — don't let an
240
+ // INIT_TIMEOUT rejection surface as an unhandled-rejection error.
241
+ self._initPromise.catch(function() {});
242
+ }
243
+ if (opts.timeout > 0 && !self._initTimer) {
244
+ self._initTimer = setTimeout(function() {
245
+ self._initTimer = null;
246
+ if (self._initialized || !self._initReject) return;
247
+ self._initReject(new UsionError(ERROR_CODES.INIT_TIMEOUT,
248
+ 'No INIT from host within ' + opts.timeout + 'ms — host silent or running standalone'));
249
+ }, opts.timeout);
250
+ }
95
251
 
96
252
  // Only register message handler once
97
253
  if (self._messageHandlerRegistered) {
98
- return;
254
+ return self._initPromise;
99
255
  }
100
256
  self._messageHandlerRegistered = true;
101
257
 
@@ -151,6 +307,15 @@ var Usion = (function () {
151
307
  if (self._initCallback) {
152
308
  self._initCallback(data.config);
153
309
  }
310
+
311
+ // Settle the promise form
312
+ if (self._initTimer) {
313
+ clearTimeout(self._initTimer);
314
+ self._initTimer = null;
315
+ }
316
+ if (self._initResolve) {
317
+ self._initResolve(data.config);
318
+ }
154
319
  }
155
320
 
156
321
  // Handle response messages for async requests
@@ -159,7 +324,9 @@ var Usion = (function () {
159
324
  delete _pendingRequests[data._requestId];
160
325
 
161
326
  if (data.error) {
162
- reject(new Error(data.error));
327
+ // Preserve the backend's stable code/retry_after when the host
328
+ // relayed them (see errors.js).
329
+ reject(toUsionError(data));
163
330
  } else {
164
331
  resolve(data);
165
332
  }
@@ -218,6 +385,8 @@ var Usion = (function () {
218
385
 
219
386
  // Signal ready to parent
220
387
  this._post({ type: 'READY' });
388
+
389
+ return self._initPromise;
221
390
  },
222
391
 
223
392
  /**
@@ -339,7 +508,7 @@ var Usion = (function () {
339
508
  // Setup timeout
340
509
  const timer = setTimeout(function() {
341
510
  delete _pendingRequests[requestId];
342
- reject(new Error('Request timeout'));
511
+ reject(new UsionError(ERROR_CODES.REQUEST_TIMEOUT, 'Request timeout'));
343
512
  }, timeout);
344
513
 
345
514
  // Store pending request
@@ -513,13 +682,17 @@ var Usion = (function () {
513
682
 
514
683
  /**
515
684
  * Get current wallet balance
685
+ * @param {object} [opts]
686
+ * @param {boolean} [opts.fresh] - Bypass the cache and re-query the host.
687
+ * Use after out-of-band balance changes (e.g. a server-side settle).
516
688
  * @returns {Promise<number>} Balance in credits
517
689
  */
518
- getBalance: function() {
690
+ getBalance: function(opts) {
519
691
  const self = this;
692
+ const fresh = !!(opts && opts.fresh);
520
693
 
521
694
  // If we have cached balance, return it
522
- if (self._balance !== null) {
695
+ if (!fresh && self._balance !== null) {
523
696
  return Promise.resolve(self._balance);
524
697
  }
525
698
 
@@ -586,11 +759,14 @@ var Usion = (function () {
586
759
  settled = true;
587
760
  cleanup();
588
761
  // Update cached balance from the authoritative new balance, else
589
- // best-effort subtract (no-op if balance is unknown).
762
+ // best-effort subtract now and re-query the host so the cache
763
+ // converges on the real value (an estimate would otherwise stick
764
+ // until reload).
590
765
  if (response && response.newBalance !== undefined) {
591
766
  self._balance = response.newBalance;
592
- } else if (self._balance !== null) {
593
- self._balance -= amount;
767
+ } else {
768
+ if (self._balance !== null) self._balance -= amount;
769
+ self.getBalance({ fresh: true }).catch(function() { /* keep estimate */ });
594
770
  }
595
771
  resolve(response);
596
772
  }
@@ -1191,9 +1367,10 @@ var Usion = (function () {
1191
1367
  * Listen for messages from parent app
1192
1368
  * @param {string} type - Message type to listen for
1193
1369
  * @param {function} callback - Handler function
1370
+ * @returns {function} Unsubscribe function
1194
1371
  */
1195
1372
  on: function(type, callback) {
1196
- window.addEventListener('message', function(event) {
1373
+ function listener(event) {
1197
1374
  if (!isTrustedMessageSource(event)) return;
1198
1375
 
1199
1376
  let data;
@@ -1206,7 +1383,11 @@ var Usion = (function () {
1206
1383
  if (data.type === type) {
1207
1384
  callback(data);
1208
1385
  }
1209
- });
1386
+ }
1387
+ window.addEventListener('message', listener);
1388
+ return function() {
1389
+ window.removeEventListener('message', listener);
1390
+ };
1210
1391
  }
1211
1392
  };
1212
1393
 
@@ -1731,82 +1912,6 @@ var Usion = (function () {
1731
1912
  };
1732
1913
  }
1733
1914
 
1734
- /**
1735
- * Usion SDK Errors — stable, machine-readable error codes.
1736
- *
1737
- * Developers should branch on `err.code`, never on message text. Messages
1738
- * are human-readable and may change; codes are part of the public API and
1739
- * follow the deprecation policy (never removed within a major version).
1740
- */
1741
-
1742
- /** @type {Record<string, string>} */
1743
- const ERROR_CODES = {
1744
- NOT_CONNECTED: 'NOT_CONNECTED', // No live connection for this call
1745
- NO_ROOM: 'NO_ROOM', // No room id provided/known
1746
- ROOM_NOT_FOUND: 'ROOM_NOT_FOUND', // Room does not exist server-side
1747
- NOT_PARTICIPANT: 'NOT_PARTICIPANT', // Caller is not a player in the room
1748
- NOT_AUTHORITY: 'NOT_AUTHORITY', // Authority-only call (e.g. setState)
1749
- NOT_AUTHENTICATED: 'NOT_AUTHENTICATED', // Missing/invalid auth server-side
1750
- JOIN_TIMEOUT: 'JOIN_TIMEOUT', // Join did not complete in time
1751
- CONNECT_TIMEOUT: 'CONNECT_TIMEOUT', // Connect did not complete in time
1752
- STATE_TOO_LARGE: 'STATE_TOO_LARGE', // setState payload over the quota
1753
- INVALID_STATE: 'INVALID_STATE', // setState payload not a JSON object
1754
- INVALID_NEXT_TURN: 'INVALID_NEXT_TURN', // nextTurn is not a player in the room
1755
- RATE_LIMITED: 'RATE_LIMITED', // Too many calls; back off
1756
- REQUEST_TIMEOUT: 'REQUEST_TIMEOUT', // Host/parent did not reply in time
1757
- QUEUE_FULL: 'QUEUE_FULL', // Offline action queue at capacity
1758
- UNSUPPORTED: 'UNSUPPORTED', // Not available in this transport
1759
- UNKNOWN: 'UNKNOWN', // Unmapped error (see message)
1760
- };
1761
-
1762
- class UsionError extends Error {
1763
- /**
1764
- * @param {string} code - One of ERROR_CODES
1765
- * @param {string} [message] - Human-readable detail (may change between versions)
1766
- */
1767
- constructor(code, message) {
1768
- super(message || code);
1769
- this.name = 'UsionError';
1770
- this.code = ERROR_CODES[code] ? code : ERROR_CODES.UNKNOWN;
1771
- }
1772
- }
1773
-
1774
- // Backend error strings → stable codes. Order matters: first match wins.
1775
- /** @type {Array<[RegExp, string]>} */
1776
- const BACKEND_PATTERNS = [
1777
- [/not authenticated/i, ERROR_CODES.NOT_AUTHENTICATED],
1778
- [/room_id required|no room id/i, ERROR_CODES.NO_ROOM],
1779
- [/room not found/i, ERROR_CODES.ROOM_NOT_FOUND],
1780
- [/not a participant/i, ERROR_CODES.NOT_PARTICIPANT],
1781
- [/room authority/i, ERROR_CODES.NOT_AUTHORITY],
1782
- [/exceeds .*limit/i, ERROR_CODES.STATE_TOO_LARGE],
1783
- [/state must be/i, ERROR_CODES.INVALID_STATE],
1784
- [/next_turn must be/i, ERROR_CODES.INVALID_NEXT_TURN],
1785
- [/rate limit|too many/i, ERROR_CODES.RATE_LIMITED],
1786
- [/join timeout/i, ERROR_CODES.JOIN_TIMEOUT],
1787
- [/connection timeout|connect timeout/i, ERROR_CODES.CONNECT_TIMEOUT],
1788
- [/request timeout/i, ERROR_CODES.REQUEST_TIMEOUT],
1789
- [/not connected/i, ERROR_CODES.NOT_CONNECTED],
1790
- ];
1791
-
1792
- /**
1793
- * Normalize anything (backend `{error}` string, Error, raw string) into a
1794
- * UsionError with the best-matching stable code.
1795
- * @param {*} err
1796
- * @param {string} [fallbackCode] - Code to use when nothing matches
1797
- * @returns {UsionError}
1798
- */
1799
- function toUsionError(err, fallbackCode) {
1800
- if (err instanceof UsionError) return err;
1801
- const message = err && err.message ? err.message : String(err || 'Unknown error');
1802
- for (let i = 0; i < BACKEND_PATTERNS.length; i++) {
1803
- if (BACKEND_PATTERNS[i][0].test(message)) {
1804
- return new UsionError(BACKEND_PATTERNS[i][1], message);
1805
- }
1806
- }
1807
- return new UsionError(fallbackCode || ERROR_CODES.UNKNOWN, message);
1808
- }
1809
-
1810
1915
  /**
1811
1916
  * Usion SDK Game Proxy — postMessage relay through parent app
1812
1917
  */
@@ -2055,7 +2160,7 @@ var Usion = (function () {
2055
2160
  self.socket.emit('game:join', { room_id: roomId }, function(response) {
2056
2161
  if (response.error) {
2057
2162
  self._joined = false;
2058
- reject(toUsionError(response.message || response.error));
2163
+ reject(toUsionError(response));
2059
2164
  } else {
2060
2165
  self._joined = true;
2061
2166
  if (response.sequence !== undefined) {
@@ -2158,36 +2263,81 @@ var Usion = (function () {
2158
2263
  // move can't go out before the client has resynced.
2159
2264
  const gate = self._rejoinPromise || Promise.resolve();
2160
2265
  return gate.then(function() {
2161
- return new Promise(function(resolve, reject) {
2162
- if (!self.socket || !self.connected) {
2163
- if (queueOffline) {
2164
- self._queueOfflineAction(actionType, actionData, opts).then(resolve, reject);
2165
- return;
2166
- }
2167
- reject(new UsionError(ERROR_CODES.NOT_CONNECTED, 'Not connected'));
2168
- return;
2266
+ if (!self.socket || !self.connected) {
2267
+ if (queueOffline) {
2268
+ return self._queueOfflineAction(actionType, actionData, opts);
2169
2269
  }
2270
+ throw new UsionError(ERROR_CODES.NOT_CONNECTED, 'Not connected');
2271
+ }
2272
+ const payload = {
2273
+ room_id: self.roomId,
2274
+ action_type: actionType,
2275
+ action_data: actionData
2276
+ };
2277
+ if (nextTurn) payload.next_turn = nextTurn;
2278
+ return self._emitAction(payload, true);
2279
+ });
2280
+ };
2170
2281
 
2171
- const payload = {
2172
- room_id: self.roomId,
2173
- action_type: actionType,
2174
- action_data: actionData
2175
- };
2176
- if (nextTurn) payload.next_turn = nextTurn;
2177
- self.socket.emit('game:action', payload, function(response) {
2178
- if (response.error) {
2179
- reject(toUsionError(response.message || response.error));
2180
- } else {
2181
- if (response.sequence !== undefined) {
2182
- self._lastSequence = response.sequence;
2183
- }
2184
- resolve(response);
2282
+ /**
2283
+ * @private Emit game:action and handle the ack. On NOT_IN_ROOM (the server
2284
+ * detected a detached socket — connected but no room membership), auto-
2285
+ * rejoin + resync once and retry the same action, so a move made in the
2286
+ * detached window is delivered instead of silently lost.
2287
+ */
2288
+ game._emitAction = function(payload, retryOnNotInRoom) {
2289
+ const self = this;
2290
+ return new Promise(function(resolve, reject) {
2291
+ if (!self.socket || !self.connected) {
2292
+ reject(new UsionError(ERROR_CODES.NOT_CONNECTED, 'Not connected'));
2293
+ return;
2294
+ }
2295
+ self.socket.emit('game:action', payload, function(response) {
2296
+ if (response && response.error) {
2297
+ const ue = toUsionError(response);
2298
+ if (retryOnNotInRoom && ue.code === ERROR_CODES.NOT_IN_ROOM) {
2299
+ self._autoRejoin()
2300
+ .then(function() { return self._emitAction(payload, false); })
2301
+ .then(resolve, reject);
2302
+ return;
2185
2303
  }
2186
- });
2304
+ reject(ue);
2305
+ } else {
2306
+ if (response && response.sequence !== undefined) {
2307
+ self._lastSequence = response.sequence;
2308
+ }
2309
+ resolve(response);
2310
+ }
2187
2311
  });
2188
2312
  });
2189
2313
  };
2190
2314
 
2315
+ /**
2316
+ * @private Recover a detached socket: the server said we're not in the
2317
+ * Socket.IO room (NOT_IN_ROOM), so rejoin + resync exactly like the
2318
+ * post-reconnect path, gating action() sends on _rejoinPromise meanwhile.
2319
+ */
2320
+ game._autoRejoin = function() {
2321
+ const self = this;
2322
+ if (self._rejoinPromise) return self._rejoinPromise;
2323
+ if (!self.roomId || !self.socket || !self.connected) return Promise.resolve();
2324
+ Usion.log('Server reports socket not in room - auto-rejoining ' + self.roomId);
2325
+ self._joined = false;
2326
+ self._joinPromise = null;
2327
+ self._rejoinPromise = self.join(self.roomId)
2328
+ .then(function() {
2329
+ self.requestSync(self._lastSequence || 0);
2330
+ })
2331
+ .catch(function(err) {
2332
+ Usion.log('Auto-rejoin failed: ' + (err && err.message ? err.message : String(err)));
2333
+ })
2334
+ .then(function() {
2335
+ self._rejoinPromise = null;
2336
+ if (self._flushOfflineQueue) self._flushOfflineQueue();
2337
+ });
2338
+ return self._rejoinPromise;
2339
+ };
2340
+
2191
2341
  // ── Offline action queue (opt-in via action(..., { queueOffline: true })) ──
2192
2342
 
2193
2343
  const OFFLINE_QUEUE_MAX = 20;
@@ -2262,11 +2412,17 @@ var Usion = (function () {
2262
2412
  return Promise.resolve({ success: false, error: 'not_supported_in_direct_mode', code: ERROR_CODES.UNSUPPORTED });
2263
2413
  }
2264
2414
 
2415
+ // The checkpoint carries the action sequence this state reflects. The
2416
+ // server persists it and rejects OLDER checkpoints (STALE_STATE), so two
2417
+ // concurrent writers can't clobber newer state with older.
2418
+ const checkpointSeq = self._lastSequence || 0;
2419
+
2265
2420
  if (self._useProxy) {
2266
- return Usion._request('GAME_SET_STATE', { room_id: self.roomId, state: state || {} })
2421
+ return Usion._request('GAME_SET_STATE', { room_id: self.roomId, state: state || {}, sequence: checkpointSeq })
2267
2422
  .then(function(res) {
2268
2423
  if (res && res.error) {
2269
- return { success: false, error: res.error, code: toUsionError(res.error).code };
2424
+ const rue = toUsionError(res);
2425
+ return { success: false, error: rue.message, code: rue.code };
2270
2426
  }
2271
2427
  return res || { success: true };
2272
2428
  })
@@ -2283,10 +2439,12 @@ var Usion = (function () {
2283
2439
  }
2284
2440
  self.socket.emit('game:set_state', {
2285
2441
  room_id: self.roomId,
2286
- state: state || {}
2442
+ state: state || {},
2443
+ sequence: checkpointSeq
2287
2444
  }, function(response) {
2288
2445
  if (response && response.error) {
2289
- resolve({ success: false, error: response.error, code: toUsionError(response.error).code });
2446
+ const ue = toUsionError(response);
2447
+ resolve({ success: false, error: ue.message, code: ue.code });
2290
2448
  } else {
2291
2449
  resolve(response || { success: true });
2292
2450
  }
@@ -2319,10 +2477,18 @@ var Usion = (function () {
2319
2477
  return;
2320
2478
  }
2321
2479
 
2480
+ // Still fire-and-forget for the game, but the server now acks failures
2481
+ // (previously dropped silently): surface them via onError, and on
2482
+ // NOT_IN_ROOM (detached socket) auto-rejoin so the stream recovers.
2322
2483
  self.socket.emit('game:realtime', {
2323
2484
  room_id: self.roomId,
2324
2485
  action_type: actionType,
2325
2486
  action_data: actionData
2487
+ }, function(response) {
2488
+ if (!response || !response.error) return;
2489
+ const ue = toUsionError(response);
2490
+ self._dispatch('error', { code: ue.code, message: ue.message, source: 'realtime' });
2491
+ if (ue.code === ERROR_CODES.NOT_IN_ROOM) self._autoRejoin();
2326
2492
  });
2327
2493
  };
2328
2494
 
@@ -2398,7 +2564,7 @@ var Usion = (function () {
2398
2564
 
2399
2565
  self.socket.emit('game:forfeit', { room_id: self.roomId }, function(response) {
2400
2566
  if (response.error) {
2401
- reject(toUsionError(response.message || response.error));
2567
+ reject(toUsionError(response));
2402
2568
  } else {
2403
2569
  resolve(response);
2404
2570
  }
@@ -5041,26 +5207,31 @@ var Usion = (function () {
5041
5207
  self._connectPromise = new Promise(function(resolve, reject) {
5042
5208
  // Check if socket.io-client is available
5043
5209
  if (typeof io === 'undefined') {
5044
- // Load socket.io client
5045
- var script = document.createElement('script');
5046
- script.src = '/socket.io.min.js';
5047
- script.onload = function() {
5048
- self._initSocket(socketUrl, token, resolve, reject);
5049
- };
5050
- script.onerror = function() {
5051
- // Local file not available, try CDN as fallback
5052
- var cdnScript = document.createElement('script');
5053
- cdnScript.src = 'https://cdn.socket.io/4.7.2/socket.io.min.js';
5054
- cdnScript.onload = function() {
5055
- self._initSocket(socketUrl, token, resolve, reject);
5056
- };
5057
- cdnScript.onerror = function() {
5210
+ // Load the Socket.IO client: same-origin copy first, then the
5211
+ // platform-hosted copy (same origin as the SDK itself — reachable
5212
+ // wherever the app is), and only then the public CDN, which
5213
+ // restricted networks often block.
5214
+ var sources = [
5215
+ '/socket.io.min.js',
5216
+ 'https://usions.com/socket.io.min.js',
5217
+ 'https://cdn.socket.io/4.7.2/socket.io.min.js'
5218
+ ];
5219
+ (function tryLoad(i) {
5220
+ if (i >= sources.length) {
5058
5221
  self._connecting = false;
5059
5222
  reject(new Error('Failed to load Socket.IO client'));
5223
+ return;
5224
+ }
5225
+ var script = document.createElement('script');
5226
+ script.src = sources[i];
5227
+ script.onload = function() {
5228
+ self._initSocket(socketUrl, token, resolve, reject);
5229
+ };
5230
+ script.onerror = function() {
5231
+ tryLoad(i + 1);
5060
5232
  };
5061
- document.head.appendChild(cdnScript);
5062
- };
5063
- document.head.appendChild(script);
5233
+ document.head.appendChild(script);
5234
+ })(0);
5064
5235
  } else {
5065
5236
  self._initSocket(socketUrl, token, resolve, reject);
5066
5237
  }
@@ -5411,12 +5582,14 @@ var Usion = (function () {
5411
5582
  *
5412
5583
  * const m = await Usion.matchmaking.find(); // resolves when matched
5413
5584
  * await Usion.game.connect(); await Usion.game.join(m.roomId);
5414
- * // ...or cancel while waiting:
5585
+ * // ...or bound the wait / cancel while waiting:
5586
+ * const m2 = await Usion.matchmaking.find(null, { timeout: 30000 });
5415
5587
  * Usion.matchmaking.cancel();
5416
5588
  * Usion.matchmaking.onMatch(({ roomId, players }) => { ... });
5417
5589
  */
5590
+
5418
5591
  function createMatchmakingModule(Usion) {
5419
- let pending = null; // { resolve, reject } for an in-flight find()
5592
+ let pending = null; // active find() entry: { resolve, reject }
5420
5593
  let onMatchCb = null;
5421
5594
  let bound = false;
5422
5595
 
@@ -5440,24 +5613,64 @@ var Usion = (function () {
5440
5613
 
5441
5614
  /**
5442
5615
  * Join the queue for `serviceId` (defaults to the current game) and resolve
5443
- * when matched with { roomId, players, serviceId }. Stays pending until a
5444
- * match (use cancel() to stop waiting).
5616
+ * when matched with { roomId, players, serviceId }. Without `opts.timeout`
5617
+ * it stays pending until a match (use cancel() to stop waiting); with it,
5618
+ * rejects MATCH_TIMEOUT (and leaves the queue) once the wait elapses.
5619
+ * @param {string} [serviceId]
5620
+ * @param {{size?: number, timeout?: number}} [opts]
5445
5621
  */
5446
5622
  find: function (serviceId, opts) {
5447
5623
  opts = opts || {};
5448
5624
  const sid = serviceId || (Usion.config && Usion.config.serviceId);
5449
5625
  bind();
5450
- return Usion._backendEmit('mm:join', { service_id: sid, size: opts.size || 2 }).then(function () {
5451
- return new Promise(function (resolve, reject) {
5452
- if (pending) pending.reject(new Error('superseded'));
5453
- pending = { resolve: resolve, reject: reject };
5454
- });
5626
+
5627
+ if (pending) {
5628
+ const old = pending; pending = null;
5629
+ old.reject(new UsionError(ERROR_CODES.SUPERSEDED, 'Superseded by a newer find()'));
5630
+ }
5631
+
5632
+ let timer = null;
5633
+ const entry = {};
5634
+ const promise = new Promise(function (resolve, reject) {
5635
+ entry.resolve = function (r) {
5636
+ if (timer) clearTimeout(timer);
5637
+ resolve(r);
5638
+ };
5639
+ entry.reject = function (e) {
5640
+ if (timer) clearTimeout(timer);
5641
+ if (pending === entry) pending = null;
5642
+ reject(e);
5643
+ };
5455
5644
  });
5645
+
5646
+ // Register BEFORE emitting mm:join: when someone is already waiting in
5647
+ // the queue, mm:matched can arrive ahead of the join ack — registering
5648
+ // afterwards dropped that instant match and left find() hanging forever.
5649
+ pending = entry;
5650
+
5651
+ if (opts.timeout > 0) {
5652
+ timer = setTimeout(function () {
5653
+ timer = null;
5654
+ Usion._backendEmit('mm:cancel', {}).catch(function () { /* best-effort dequeue */ });
5655
+ entry.reject(new UsionError(ERROR_CODES.MATCH_TIMEOUT,
5656
+ 'No match within ' + opts.timeout + 'ms'));
5657
+ }, opts.timeout);
5658
+ }
5659
+
5660
+ Usion._backendEmit('mm:join', { service_id: sid, size: opts.size || 2 })
5661
+ .catch(function (err) {
5662
+ if (pending === entry) entry.reject(toUsionError(err));
5663
+ });
5664
+
5665
+ return promise;
5456
5666
  },
5457
5667
 
5458
5668
  /** Leave the queue / stop waiting. */
5459
5669
  cancel: function () {
5460
- if (pending) { pending.reject(new Error('cancelled')); pending = null; }
5670
+ if (pending) {
5671
+ const old = pending; pending = null;
5672
+ old.reject(new UsionError(ERROR_CODES.CANCELLED, 'cancelled'));
5673
+ }
5461
5674
  return Usion._backendEmit('mm:cancel', {});
5462
5675
  },
5463
5676
  };
@@ -5707,6 +5920,7 @@ var Usion = (function () {
5707
5920
  * safe, namespaced allow-list (e.g. lobby:* / mm:*) so a mini-app can't abuse
5708
5921
  * the user's authenticated connection. The backend re-validates every call.
5709
5922
  */
5923
+
5710
5924
  function applyBackendChannel(Usion) {
5711
5925
  Usion._backendHandlers = {};
5712
5926
  Usion._boundSockets = typeof WeakSet !== 'undefined' ? new WeakSet() : null;
@@ -5741,30 +5955,61 @@ var Usion = (function () {
5741
5955
  /**
5742
5956
  * Emit a backend request and await its ack. Routes to the SDK socket when
5743
5957
  * standalone, or through the parent host when embedded.
5958
+ * Standalone apps that never call Usion.game.connect() (e.g. a non-game
5959
+ * app using cloud/leaderboard/notify) get an automatic one-time connect —
5960
+ * the backend channel is not coupled to starting a game.
5961
+ * Rejections are always UsionError (stable `code`, plus `retryAfter` on
5962
+ * RATE_LIMITED), regardless of transport.
5744
5963
  * @returns {Promise<any>}
5745
5964
  */
5746
5965
  Usion._backendEmit = function (event, data, timeout) {
5747
5966
  const self = this;
5748
5967
  timeout = timeout || 8000;
5749
- const s = self.game && self.game.socket;
5750
- if (s && s.connected) {
5968
+
5969
+ function emitOn(sock) {
5751
5970
  return new Promise(function (resolve, reject) {
5752
5971
  let done = false;
5753
- const timer = setTimeout(function () { if (done) return; done = true; reject(new Error('Backend request timeout')); }, timeout);
5972
+ const timer = setTimeout(function () {
5973
+ if (done) return; done = true;
5974
+ reject(new UsionError(ERROR_CODES.REQUEST_TIMEOUT, 'Backend request timeout'));
5975
+ }, timeout);
5754
5976
  try {
5755
- s.emit(event, data || {}, function (resp) {
5977
+ sock.emit(event, data || {}, function (resp) {
5756
5978
  if (done) return; done = true; clearTimeout(timer);
5757
- if (resp && resp.error) reject(new Error(resp.message || resp.error));
5979
+ if (resp && resp.error) reject(toUsionError(resp));
5758
5980
  else resolve(resp);
5759
5981
  });
5760
- } catch (e) { clearTimeout(timer); reject(e); }
5982
+ } catch (e) { clearTimeout(timer); reject(toUsionError(e)); }
5761
5983
  });
5762
5984
  }
5985
+
5986
+ const s = self.game && self.game.socket;
5987
+ if (s && s.connected) return emitOn(s);
5763
5988
  if (self._isEmbedded) {
5764
5989
  // Host relays this onto its authenticated socket and replies with the ack.
5765
- return self._request('BACKEND_EMIT', { event: event, data: data || {} }, timeout);
5990
+ return self._request('BACKEND_EMIT', { event: event, data: data || {} }, timeout)
5991
+ .catch(function (e) { throw toUsionError(e); });
5992
+ }
5993
+
5994
+ // Standalone without a live socket. If no socket exists yet, auto-connect
5995
+ // once (connect() dedupes concurrent callers via _connectPromise). A
5996
+ // socket that exists but is mid-reconnect is left to Socket.IO's own
5997
+ // reconnection — the caller gets a coded error and can retry.
5998
+ const connectionMode = (self.config && self.config.connectionMode) || 'platform';
5999
+ if (!s && connectionMode !== 'direct'
6000
+ && self.game && typeof self.game.connect === 'function') {
6001
+ return self.game.connect().then(function () {
6002
+ const sock = self.game.socket;
6003
+ if (sock && sock.connected) return emitOn(sock);
6004
+ throw new UsionError(ERROR_CODES.NOT_CONNECTED, 'No backend connection');
6005
+ }).catch(function (e) {
6006
+ if (e instanceof UsionError) throw e;
6007
+ throw new UsionError(ERROR_CODES.NOT_CONNECTED,
6008
+ 'Backend connect failed: ' + (e && e.message ? e.message : String(e)));
6009
+ });
5766
6010
  }
5767
- return Promise.reject(new Error('No backend connection — call Usion.game.connect() first'));
6011
+ return Promise.reject(new UsionError(ERROR_CODES.NOT_CONNECTED,
6012
+ 'No backend connection — socket is offline or reconnecting'));
5768
6013
  };
5769
6014
  }
5770
6015