@usions/sdk 2.20.2 → 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.20.2', // 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
 
@@ -1564,7 +1745,20 @@ var Usion = (function () {
1564
1745
  // backend channel — robust to listener registration order.
1565
1746
  if (Usion._bindBackendSocket) Usion._bindBackendSocket(self.socket);
1566
1747
 
1748
+ // Socket.IO v4 emits 'reconnect' on the Manager, not the Socket — a
1749
+ // Socket-level 'reconnect' listener never fires. Track attempts on the
1750
+ // Manager and treat any connect after the first (per socket) as the
1751
+ // reconnect signal, dispatched below AFTER rejoin gating is in place.
1752
+ var hadConnect = false;
1753
+ if (self.socket.io && typeof self.socket.io.on === 'function') {
1754
+ self.socket.io.on('reconnect_attempt', function(n) {
1755
+ self._reconnAttempts = n;
1756
+ });
1757
+ }
1758
+
1567
1759
  self.socket.on('connect', function() {
1760
+ var isReconnect = hadConnect;
1761
+ hadConnect = true;
1568
1762
  self.connected = true;
1569
1763
  self._connecting = false;
1570
1764
  Usion.log('Game socket connected');
@@ -1595,12 +1789,21 @@ var Usion = (function () {
1595
1789
  .then(function() {
1596
1790
  self._rejoinPromise = null;
1597
1791
  // Send any moves queued while offline, now that membership
1598
- // and sync are restored. (Socket.IO v4 emits 'reconnect' on
1599
- // the Manager, not the Socket, so this is the reliable hook.)
1792
+ // and sync are restored. (This connect handler is the reliable
1793
+ // hook see the Manager-vs-Socket note above.)
1600
1794
  if (self._flushOfflineQueue) self._flushOfflineQueue();
1601
1795
  });
1602
1796
  }
1603
1797
 
1798
+ // Dispatched after _rejoinPromise is set, so anything reacting to
1799
+ // 'reconnect' (offline-queue flush, connection-state machine) sees
1800
+ // sends gated behind the rejoin+resync.
1801
+ if (isReconnect) {
1802
+ Usion.log('Game socket reconnected');
1803
+ self._dispatch('reconnect', self._reconnAttempts || 1);
1804
+ self._reconnAttempts = 0;
1805
+ }
1806
+
1604
1807
  resolve();
1605
1808
  });
1606
1809
 
@@ -1623,11 +1826,6 @@ var Usion = (function () {
1623
1826
  self._dispatch('disconnect', reason);
1624
1827
  });
1625
1828
 
1626
- self.socket.on('reconnect', function(attemptNumber) {
1627
- Usion.log('Game socket reconnected after ' + attemptNumber + ' attempts');
1628
- self._dispatch('reconnect', attemptNumber);
1629
- });
1630
-
1631
1829
  // Game event handlers
1632
1830
  self.socket.on('game:joined', function(data) {
1633
1831
  if (data.sequence !== undefined) {
@@ -1714,82 +1912,6 @@ var Usion = (function () {
1714
1912
  };
1715
1913
  }
1716
1914
 
1717
- /**
1718
- * Usion SDK Errors — stable, machine-readable error codes.
1719
- *
1720
- * Developers should branch on `err.code`, never on message text. Messages
1721
- * are human-readable and may change; codes are part of the public API and
1722
- * follow the deprecation policy (never removed within a major version).
1723
- */
1724
-
1725
- /** @type {Record<string, string>} */
1726
- const ERROR_CODES = {
1727
- NOT_CONNECTED: 'NOT_CONNECTED', // No live connection for this call
1728
- NO_ROOM: 'NO_ROOM', // No room id provided/known
1729
- ROOM_NOT_FOUND: 'ROOM_NOT_FOUND', // Room does not exist server-side
1730
- NOT_PARTICIPANT: 'NOT_PARTICIPANT', // Caller is not a player in the room
1731
- NOT_AUTHORITY: 'NOT_AUTHORITY', // Authority-only call (e.g. setState)
1732
- NOT_AUTHENTICATED: 'NOT_AUTHENTICATED', // Missing/invalid auth server-side
1733
- JOIN_TIMEOUT: 'JOIN_TIMEOUT', // Join did not complete in time
1734
- CONNECT_TIMEOUT: 'CONNECT_TIMEOUT', // Connect did not complete in time
1735
- STATE_TOO_LARGE: 'STATE_TOO_LARGE', // setState payload over the quota
1736
- INVALID_STATE: 'INVALID_STATE', // setState payload not a JSON object
1737
- INVALID_NEXT_TURN: 'INVALID_NEXT_TURN', // nextTurn is not a player in the room
1738
- RATE_LIMITED: 'RATE_LIMITED', // Too many calls; back off
1739
- REQUEST_TIMEOUT: 'REQUEST_TIMEOUT', // Host/parent did not reply in time
1740
- QUEUE_FULL: 'QUEUE_FULL', // Offline action queue at capacity
1741
- UNSUPPORTED: 'UNSUPPORTED', // Not available in this transport
1742
- UNKNOWN: 'UNKNOWN', // Unmapped error (see message)
1743
- };
1744
-
1745
- class UsionError extends Error {
1746
- /**
1747
- * @param {string} code - One of ERROR_CODES
1748
- * @param {string} [message] - Human-readable detail (may change between versions)
1749
- */
1750
- constructor(code, message) {
1751
- super(message || code);
1752
- this.name = 'UsionError';
1753
- this.code = ERROR_CODES[code] ? code : ERROR_CODES.UNKNOWN;
1754
- }
1755
- }
1756
-
1757
- // Backend error strings → stable codes. Order matters: first match wins.
1758
- /** @type {Array<[RegExp, string]>} */
1759
- const BACKEND_PATTERNS = [
1760
- [/not authenticated/i, ERROR_CODES.NOT_AUTHENTICATED],
1761
- [/room_id required|no room id/i, ERROR_CODES.NO_ROOM],
1762
- [/room not found/i, ERROR_CODES.ROOM_NOT_FOUND],
1763
- [/not a participant/i, ERROR_CODES.NOT_PARTICIPANT],
1764
- [/room authority/i, ERROR_CODES.NOT_AUTHORITY],
1765
- [/exceeds .*limit/i, ERROR_CODES.STATE_TOO_LARGE],
1766
- [/state must be/i, ERROR_CODES.INVALID_STATE],
1767
- [/next_turn must be/i, ERROR_CODES.INVALID_NEXT_TURN],
1768
- [/rate limit|too many/i, ERROR_CODES.RATE_LIMITED],
1769
- [/join timeout/i, ERROR_CODES.JOIN_TIMEOUT],
1770
- [/connection timeout|connect timeout/i, ERROR_CODES.CONNECT_TIMEOUT],
1771
- [/request timeout/i, ERROR_CODES.REQUEST_TIMEOUT],
1772
- [/not connected/i, ERROR_CODES.NOT_CONNECTED],
1773
- ];
1774
-
1775
- /**
1776
- * Normalize anything (backend `{error}` string, Error, raw string) into a
1777
- * UsionError with the best-matching stable code.
1778
- * @param {*} err
1779
- * @param {string} [fallbackCode] - Code to use when nothing matches
1780
- * @returns {UsionError}
1781
- */
1782
- function toUsionError(err, fallbackCode) {
1783
- if (err instanceof UsionError) return err;
1784
- const message = err && err.message ? err.message : String(err || 'Unknown error');
1785
- for (let i = 0; i < BACKEND_PATTERNS.length; i++) {
1786
- if (BACKEND_PATTERNS[i][0].test(message)) {
1787
- return new UsionError(BACKEND_PATTERNS[i][1], message);
1788
- }
1789
- }
1790
- return new UsionError(fallbackCode || ERROR_CODES.UNKNOWN, message);
1791
- }
1792
-
1793
1915
  /**
1794
1916
  * Usion SDK Game Proxy — postMessage relay through parent app
1795
1917
  */
@@ -2038,12 +2160,20 @@ var Usion = (function () {
2038
2160
  self.socket.emit('game:join', { room_id: roomId }, function(response) {
2039
2161
  if (response.error) {
2040
2162
  self._joined = false;
2041
- reject(toUsionError(response.message || response.error));
2163
+ reject(toUsionError(response));
2042
2164
  } else {
2043
2165
  self._joined = true;
2044
2166
  if (response.sequence !== undefined) {
2045
2167
  self._lastSequence = response.sequence;
2168
+ // The joined state reflects everything up to this sequence —
2169
+ // actions at or below it must not be re-delivered (same
2170
+ // baseline the proxy transport sets from GAME_JOINED).
2171
+ self._lastActionApplied = Math.max(self._lastActionApplied, response.sequence);
2046
2172
  }
2173
+ // Transport parity: proxy and direct modes dispatch 'joined';
2174
+ // the socket transport now does too, so onJoined / recovery
2175
+ // logic behaves the same everywhere.
2176
+ self._dispatch('joined', response);
2047
2177
  resolve(response);
2048
2178
  }
2049
2179
  });
@@ -2133,36 +2263,81 @@ var Usion = (function () {
2133
2263
  // move can't go out before the client has resynced.
2134
2264
  const gate = self._rejoinPromise || Promise.resolve();
2135
2265
  return gate.then(function() {
2136
- return new Promise(function(resolve, reject) {
2137
- if (!self.socket || !self.connected) {
2138
- if (queueOffline) {
2139
- self._queueOfflineAction(actionType, actionData, opts).then(resolve, reject);
2140
- return;
2141
- }
2142
- reject(new UsionError(ERROR_CODES.NOT_CONNECTED, 'Not connected'));
2143
- return;
2266
+ if (!self.socket || !self.connected) {
2267
+ if (queueOffline) {
2268
+ return self._queueOfflineAction(actionType, actionData, opts);
2144
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
+ };
2145
2281
 
2146
- const payload = {
2147
- room_id: self.roomId,
2148
- action_type: actionType,
2149
- action_data: actionData
2150
- };
2151
- if (nextTurn) payload.next_turn = nextTurn;
2152
- self.socket.emit('game:action', payload, function(response) {
2153
- if (response.error) {
2154
- reject(toUsionError(response.message || response.error));
2155
- } else {
2156
- if (response.sequence !== undefined) {
2157
- self._lastSequence = response.sequence;
2158
- }
2159
- 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;
2160
2303
  }
2161
- });
2304
+ reject(ue);
2305
+ } else {
2306
+ if (response && response.sequence !== undefined) {
2307
+ self._lastSequence = response.sequence;
2308
+ }
2309
+ resolve(response);
2310
+ }
2162
2311
  });
2163
2312
  });
2164
2313
  };
2165
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
+
2166
2341
  // ── Offline action queue (opt-in via action(..., { queueOffline: true })) ──
2167
2342
 
2168
2343
  const OFFLINE_QUEUE_MAX = 20;
@@ -2237,11 +2412,17 @@ var Usion = (function () {
2237
2412
  return Promise.resolve({ success: false, error: 'not_supported_in_direct_mode', code: ERROR_CODES.UNSUPPORTED });
2238
2413
  }
2239
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
+
2240
2420
  if (self._useProxy) {
2241
- 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 })
2242
2422
  .then(function(res) {
2243
2423
  if (res && res.error) {
2244
- 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 };
2245
2426
  }
2246
2427
  return res || { success: true };
2247
2428
  })
@@ -2258,10 +2439,12 @@ var Usion = (function () {
2258
2439
  }
2259
2440
  self.socket.emit('game:set_state', {
2260
2441
  room_id: self.roomId,
2261
- state: state || {}
2442
+ state: state || {},
2443
+ sequence: checkpointSeq
2262
2444
  }, function(response) {
2263
2445
  if (response && response.error) {
2264
- 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 });
2265
2448
  } else {
2266
2449
  resolve(response || { success: true });
2267
2450
  }
@@ -2294,10 +2477,18 @@ var Usion = (function () {
2294
2477
  return;
2295
2478
  }
2296
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.
2297
2483
  self.socket.emit('game:realtime', {
2298
2484
  room_id: self.roomId,
2299
2485
  action_type: actionType,
2300
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();
2301
2492
  });
2302
2493
  };
2303
2494
 
@@ -2373,7 +2564,7 @@ var Usion = (function () {
2373
2564
 
2374
2565
  self.socket.emit('game:forfeit', { room_id: self.roomId }, function(response) {
2375
2566
  if (response.error) {
2376
- reject(toUsionError(response.message || response.error));
2567
+ reject(toUsionError(response));
2377
2568
  } else {
2378
2569
  resolve(response);
2379
2570
  }
@@ -4615,6 +4806,285 @@ var Usion = (function () {
4615
4806
  game.getRtt = function () { return this._pingMeter ? this._pingMeter.rtt : null; };
4616
4807
  }
4617
4808
 
4809
+ /**
4810
+ * Usion SDK Game Reliability — the reconnection-lifecycle surface promised in
4811
+ * v2.16: sequence getters, the unified connection-state machine, and
4812
+ * syncedState (reconnect-safe authoritative shared state).
4813
+ *
4814
+ * Everything here rides the events the game module already dispatches
4815
+ * ('disconnect' / 'reconnect' / 'joined' / 'sync' / 'action'), so it behaves
4816
+ * identically across the socket, embedded-proxy, and direct transports.
4817
+ */
4818
+
4819
+ function applyGameReliability(game, Usion) {
4820
+ // ── Sequence getters ──────────────────────────────────────────────────────
4821
+
4822
+ /** Highest action sequence seen (from joins, syncs, and live actions). */
4823
+ game.getLastSequence = function () { return this._lastSequence || 0; };
4824
+
4825
+ /** Highest action sequence applied locally; trails while catching up. */
4826
+ game.getLastAppliedSequence = function () { return this._lastActionApplied || 0; };
4827
+
4828
+ // ── Connection-state machine ──────────────────────────────────────────────
4829
+ // connected → disconnected → rejoining → reconnected → connected.
4830
+ // 'reconnected' is a transient notification state: observers see it, then
4831
+ // the machine settles back to 'connected' in the same tick.
4832
+
4833
+ game._connState = 'disconnected';
4834
+ game._awaitResync = false;
4835
+
4836
+ game._setConnState = function (state) {
4837
+ if (this._connState === state) return;
4838
+ this._connState = state;
4839
+ this._dispatch('connectionState', state);
4840
+ };
4841
+
4842
+ /** Current connection state, synchronously. */
4843
+ game.getConnectionState = function () { return this._connState; };
4844
+
4845
+ /**
4846
+ * Observe connection-state transitions — one hook to drive a
4847
+ * "Reconnecting…" overlay and gate input, identical on every transport.
4848
+ * Single handler (last wins); returns an unsubscribe fn. For multiple
4849
+ * listeners use game.on('connectionState', cb).
4850
+ */
4851
+ game.onConnectionState = function (callback) { return this._setHandler('connectionState', callback); };
4852
+
4853
+ /**
4854
+ * Fires once per reconnect, after the resync completes, with
4855
+ * { state, lastSequence, viaSync }. Restore local state here — it does NOT
4856
+ * fire for a manually-requested sync.
4857
+ */
4858
+ game.onReconnected = function (callback) { return this._setHandler('reconnected', callback); };
4859
+
4860
+ game.on('disconnect', function () {
4861
+ game._awaitResync = false;
4862
+ game._setConnState('disconnected');
4863
+ });
4864
+
4865
+ game.on('reconnect', function () {
4866
+ if (game.roomId) {
4867
+ game._awaitResync = true;
4868
+ game._setConnState('rejoining');
4869
+ } else {
4870
+ game._setConnState('connected');
4871
+ }
4872
+ });
4873
+
4874
+ game.on('sync', function (data) {
4875
+ if (!game._awaitResync) return;
4876
+ game._awaitResync = false;
4877
+ game._dispatch('reconnected', {
4878
+ state: (data && data.game_state) || null,
4879
+ lastSequence: game._lastSequence || 0,
4880
+ viaSync: true,
4881
+ });
4882
+ game._setConnState('reconnected');
4883
+ game._setConnState('connected');
4884
+ });
4885
+
4886
+ // Transport-level connects (initial connect in every mode) resolve through
4887
+ // connect()/connectDirect() — reflect them in the state machine.
4888
+ function wrapConnect(name) {
4889
+ var orig = game[name];
4890
+ if (typeof orig !== 'function') return;
4891
+ game[name] = function () {
4892
+ var self = this;
4893
+ return orig.apply(self, arguments).then(function (result) {
4894
+ if (!self._awaitResync) self._setConnState('connected');
4895
+ return result;
4896
+ });
4897
+ };
4898
+ }
4899
+ wrapConnect('connect');
4900
+ wrapConnect('connectDirect');
4901
+
4902
+ // ── Host tracking ─────────────────────────────────────────────────────────
4903
+ // Kept on the game module (registered at module creation, so it never
4904
+ // misses an event) — a syncedState created AFTER the join still knows who
4905
+ // the authority is.
4906
+
4907
+ game._hostId = null;
4908
+ function trackHost(d) {
4909
+ if (!d) return;
4910
+ if (d.host_id) game._hostId = d.host_id;
4911
+ else if (d.player_ids && d.player_ids.length) game._hostId = d.player_ids[0];
4912
+ }
4913
+ game.on('joined', trackHost);
4914
+ game.on('playerJoined', trackHost);
4915
+
4916
+ // ── syncedState ───────────────────────────────────────────────────────────
4917
+
4918
+ /**
4919
+ * Reconnect-safe authoritative shared state. Commits are sequenced actions
4920
+ * applied through `reduce` on every client in the same order (deduped by
4921
+ * sequence); the authority (player_ids[0] by default) auto-checkpoints via
4922
+ * setState, and (re)joining clients recover automatically (checkpoint +
4923
+ * un-checkpointed tail). Degrades to local-apply in direct mode.
4924
+ * Full contract: SyncedStateOptions / SyncedState in types/index.d.ts.
4925
+ */
4926
+ game.syncedState = function (initial, opts) {
4927
+ opts = opts || {};
4928
+ var self = this;
4929
+ var reduce = typeof opts.reduce === 'function'
4930
+ ? opts.reduce
4931
+ : function (state, action) { return Object.assign({}, state, action.data); };
4932
+ var checkpointEvery = opts.checkpointEvery != null ? opts.checkpointEvery : 1;
4933
+ var authorityMode = opts.authority === 'all' ? 'all' : 'host';
4934
+ var defaultType = opts.type || 'update';
4935
+
4936
+ var state = initial === undefined ? {} : initial;
4937
+ var appliedSeq = 0;
4938
+ var sinceCheckpoint = 0;
4939
+ var lastGapRequest = -1;
4940
+ var changeCbs = [];
4941
+ var offs = [];
4942
+
4943
+ function notify(reason) {
4944
+ var copy = changeCbs.slice();
4945
+ for (var i = 0; i < copy.length; i++) {
4946
+ try { copy[i](state, reason); } catch (e) { Usion.log('syncedState onChange error: ' + e.message); }
4947
+ }
4948
+ }
4949
+
4950
+ function isAuthority() {
4951
+ if (authorityMode === 'all') return true;
4952
+ var me = self.playerId || (Usion.user && Usion.user.getId && Usion.user.getId());
4953
+ return !!(me && self._hostId && me === self._hostId);
4954
+ }
4955
+
4956
+ function doCheckpoint() {
4957
+ if (self.directMode) {
4958
+ return Promise.resolve({ success: false, error: 'not_supported_in_direct_mode' });
4959
+ }
4960
+ // The wrapper carries the sequence the checkpoint reflects, so recovery
4961
+ // knows exactly where the un-checkpointed tail starts.
4962
+ return self.setState({ __usionSyncedState: 1, seq: appliedSeq, state: state });
4963
+ }
4964
+
4965
+ function maybeAutoCheckpoint() {
4966
+ if (!(checkpointEvery > 0) || !isAuthority()) return;
4967
+ sinceCheckpoint += 1;
4968
+ if (sinceCheckpoint >= checkpointEvery) {
4969
+ sinceCheckpoint = 0;
4970
+ doCheckpoint();
4971
+ }
4972
+ }
4973
+
4974
+ function applyOne(playerId, type, data, sequence) {
4975
+ if (sequence !== undefined && sequence <= appliedSeq) return false; // dedupe
4976
+ var next;
4977
+ try {
4978
+ next = reduce(state, { playerId: playerId, type: type, data: data, sequence: sequence });
4979
+ } catch (e) {
4980
+ Usion.log('syncedState reduce error: ' + e.message);
4981
+ return false;
4982
+ }
4983
+ if (next !== undefined) state = next;
4984
+ if (sequence !== undefined) appliedSeq = sequence;
4985
+ return true;
4986
+ }
4987
+
4988
+ function onAction(d) {
4989
+ if (!d) return;
4990
+ if (applyOne(d.player_id, d.action_type, d.action_data, d.sequence)) {
4991
+ notify('action');
4992
+ maybeAutoCheckpoint();
4993
+ }
4994
+ }
4995
+
4996
+ // Recover from a join ack or sync payload: load the checkpoint (if it is
4997
+ // ours and newer), replay the action tail in order, and if the server's
4998
+ // sequence is still ahead of us, ask once for the missing range.
4999
+ function recover(d) {
5000
+ if (!d) return;
5001
+ var changed = false;
5002
+
5003
+ var gs = d.game_state;
5004
+ if (gs && gs.__usionSyncedState && typeof gs.seq === 'number'
5005
+ && gs.seq >= appliedSeq && gs.state !== undefined) {
5006
+ state = gs.state;
5007
+ appliedSeq = gs.seq;
5008
+ changed = true;
5009
+ }
5010
+
5011
+ var actions = d.actions;
5012
+ if (actions && actions.length) {
5013
+ var sorted = actions.slice().sort(function (a, b) {
5014
+ return (a.sequence || 0) - (b.sequence || 0);
5015
+ });
5016
+ for (var i = 0; i < sorted.length; i++) {
5017
+ var item = sorted[i];
5018
+ if (item && applyOne(item.player_id, item.action_type, item.action_data, item.sequence)) {
5019
+ changed = true;
5020
+ }
5021
+ }
5022
+ }
5023
+
5024
+ if (changed) notify('recover');
5025
+
5026
+ // Gap fill: the checkpoint may lag the room sequence (checkpointEvery>1).
5027
+ // Request the tail from OUR applied sequence — once per position, so a
5028
+ // trimmed history can't loop us.
5029
+ if (d.sequence !== undefined && d.sequence > appliedSeq && lastGapRequest !== appliedSeq) {
5030
+ lastGapRequest = appliedSeq;
5031
+ try { self.requestSync(appliedSeq); } catch (e) { /* non-fatal */ }
5032
+ }
5033
+ }
5034
+
5035
+ offs.push(self.on('action', onAction));
5036
+ offs.push(self.on('sync', recover));
5037
+ offs.push(self.on('joined', recover));
5038
+
5039
+ return {
5040
+ /** Current state value. */
5041
+ get: function () { return state; },
5042
+
5043
+ /**
5044
+ * Commit a change: commit(data) with the default action type, or
5045
+ * commit(type, data, opts) to name it (opts forwarded to game.action,
5046
+ * e.g. nextTurn / queueOffline). Applied exactly once via the echo.
5047
+ */
5048
+ commit: function (typeOrData, data, actionOpts) {
5049
+ var t, payload, o;
5050
+ if (typeof typeOrData === 'string') { t = typeOrData; payload = data; o = actionOpts; }
5051
+ else { t = defaultType; payload = typeOrData; o = data; }
5052
+ if (self.directMode) {
5053
+ // No platform echo in direct mode — apply locally, then send.
5054
+ if (applyOne(self.playerId, t, payload, undefined)) notify('action');
5055
+ return self.action(t, payload, o);
5056
+ }
5057
+ return self.action(t, payload, o);
5058
+ },
5059
+
5060
+ /** Subscribe to changes: cb(state, reason). Returns an unsubscribe fn. */
5061
+ onChange: function (cb) {
5062
+ changeCbs.push(cb);
5063
+ return function () {
5064
+ var i = changeCbs.indexOf(cb);
5065
+ if (i >= 0) changeCbs.splice(i, 1);
5066
+ };
5067
+ },
5068
+
5069
+ /** Whether this client is the checkpointing authority. */
5070
+ isAuthority: isAuthority,
5071
+
5072
+ /** Force a server checkpoint now (no-op result in direct mode). */
5073
+ checkpoint: function () { return doCheckpoint(); },
5074
+
5075
+ /** Sequence of the last action applied into this state. */
5076
+ getSequence: function () { return appliedSeq; },
5077
+
5078
+ /** Detach all listeners; the instance stops receiving updates. */
5079
+ destroy: function () {
5080
+ for (var i = 0; i < offs.length; i++) { try { offs[i](); } catch (e) { /* noop */ } }
5081
+ offs.length = 0;
5082
+ changeCbs.length = 0;
5083
+ },
5084
+ };
5085
+ };
5086
+ }
5087
+
4618
5088
  /**
4619
5089
  * Usion SDK Game Core — game module base, connect routing, event registrations
4620
5090
  */
@@ -4640,6 +5110,8 @@ var Usion = (function () {
4640
5110
  reconnect: 'reconnect',
4641
5111
  connection_error: 'connectionError',
4642
5112
  room_assigned: 'roomAssigned',
5113
+ connection_state: 'connectionState',
5114
+ reconnected: 'reconnected',
4643
5115
  };
4644
5116
 
4645
5117
  function _normalizeEventName(event) {
@@ -4735,26 +5207,31 @@ var Usion = (function () {
4735
5207
  self._connectPromise = new Promise(function(resolve, reject) {
4736
5208
  // Check if socket.io-client is available
4737
5209
  if (typeof io === 'undefined') {
4738
- // Load socket.io client
4739
- var script = document.createElement('script');
4740
- script.src = '/socket.io.min.js';
4741
- script.onload = function() {
4742
- self._initSocket(socketUrl, token, resolve, reject);
4743
- };
4744
- script.onerror = function() {
4745
- // Local file not available, try CDN as fallback
4746
- var cdnScript = document.createElement('script');
4747
- cdnScript.src = 'https://cdn.socket.io/4.7.2/socket.io.min.js';
4748
- cdnScript.onload = function() {
4749
- self._initSocket(socketUrl, token, resolve, reject);
4750
- };
4751
- 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) {
4752
5221
  self._connecting = false;
4753
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);
4754
5229
  };
4755
- document.head.appendChild(cdnScript);
4756
- };
4757
- document.head.appendChild(script);
5230
+ script.onerror = function() {
5231
+ tryLoad(i + 1);
5232
+ };
5233
+ document.head.appendChild(script);
5234
+ })(0);
4758
5235
  } else {
4759
5236
  self._initSocket(socketUrl, token, resolve, reject);
4760
5237
  }
@@ -4853,6 +5330,8 @@ var Usion = (function () {
4853
5330
  applyGameProxy(game, Usion);
4854
5331
  applyGameMethods(game, Usion);
4855
5332
  applyGameNetcode(game, Usion);
5333
+ // Applied last: wraps connect/connectDirect and rides dispatched events.
5334
+ applyGameReliability(game, Usion);
4856
5335
 
4857
5336
  // Foreground catch-up safety net (generic across every transport).
4858
5337
  //
@@ -5103,12 +5582,14 @@ var Usion = (function () {
5103
5582
  *
5104
5583
  * const m = await Usion.matchmaking.find(); // resolves when matched
5105
5584
  * await Usion.game.connect(); await Usion.game.join(m.roomId);
5106
- * // ...or cancel while waiting:
5585
+ * // ...or bound the wait / cancel while waiting:
5586
+ * const m2 = await Usion.matchmaking.find(null, { timeout: 30000 });
5107
5587
  * Usion.matchmaking.cancel();
5108
5588
  * Usion.matchmaking.onMatch(({ roomId, players }) => { ... });
5109
5589
  */
5590
+
5110
5591
  function createMatchmakingModule(Usion) {
5111
- let pending = null; // { resolve, reject } for an in-flight find()
5592
+ let pending = null; // active find() entry: { resolve, reject }
5112
5593
  let onMatchCb = null;
5113
5594
  let bound = false;
5114
5595
 
@@ -5132,24 +5613,64 @@ var Usion = (function () {
5132
5613
 
5133
5614
  /**
5134
5615
  * Join the queue for `serviceId` (defaults to the current game) and resolve
5135
- * when matched with { roomId, players, serviceId }. Stays pending until a
5136
- * 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]
5137
5621
  */
5138
5622
  find: function (serviceId, opts) {
5139
5623
  opts = opts || {};
5140
5624
  const sid = serviceId || (Usion.config && Usion.config.serviceId);
5141
5625
  bind();
5142
- return Usion._backendEmit('mm:join', { service_id: sid, size: opts.size || 2 }).then(function () {
5143
- return new Promise(function (resolve, reject) {
5144
- if (pending) pending.reject(new Error('superseded'));
5145
- pending = { resolve: resolve, reject: reject };
5146
- });
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
+ };
5147
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;
5148
5666
  },
5149
5667
 
5150
5668
  /** Leave the queue / stop waiting. */
5151
5669
  cancel: function () {
5152
- 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
+ }
5153
5674
  return Usion._backendEmit('mm:cancel', {});
5154
5675
  },
5155
5676
  };
@@ -5399,6 +5920,7 @@ var Usion = (function () {
5399
5920
  * safe, namespaced allow-list (e.g. lobby:* / mm:*) so a mini-app can't abuse
5400
5921
  * the user's authenticated connection. The backend re-validates every call.
5401
5922
  */
5923
+
5402
5924
  function applyBackendChannel(Usion) {
5403
5925
  Usion._backendHandlers = {};
5404
5926
  Usion._boundSockets = typeof WeakSet !== 'undefined' ? new WeakSet() : null;
@@ -5433,30 +5955,61 @@ var Usion = (function () {
5433
5955
  /**
5434
5956
  * Emit a backend request and await its ack. Routes to the SDK socket when
5435
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.
5436
5963
  * @returns {Promise<any>}
5437
5964
  */
5438
5965
  Usion._backendEmit = function (event, data, timeout) {
5439
5966
  const self = this;
5440
5967
  timeout = timeout || 8000;
5441
- const s = self.game && self.game.socket;
5442
- if (s && s.connected) {
5968
+
5969
+ function emitOn(sock) {
5443
5970
  return new Promise(function (resolve, reject) {
5444
5971
  let done = false;
5445
- 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);
5446
5976
  try {
5447
- s.emit(event, data || {}, function (resp) {
5977
+ sock.emit(event, data || {}, function (resp) {
5448
5978
  if (done) return; done = true; clearTimeout(timer);
5449
- if (resp && resp.error) reject(new Error(resp.message || resp.error));
5979
+ if (resp && resp.error) reject(toUsionError(resp));
5450
5980
  else resolve(resp);
5451
5981
  });
5452
- } catch (e) { clearTimeout(timer); reject(e); }
5982
+ } catch (e) { clearTimeout(timer); reject(toUsionError(e)); }
5453
5983
  });
5454
5984
  }
5985
+
5986
+ const s = self.game && self.game.socket;
5987
+ if (s && s.connected) return emitOn(s);
5455
5988
  if (self._isEmbedded) {
5456
5989
  // Host relays this onto its authenticated socket and replies with the ack.
5457
- 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
+ });
5458
6010
  }
5459
- 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'));
5460
6013
  };
5461
6014
  }
5462
6015