@usions/sdk 2.11.1 → 2.13.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
@@ -69,7 +69,7 @@ var Usion = (function () {
69
69
  * Core Usion object with init, _post, _request
70
70
  */
71
71
  const core = {
72
- version: '2.1.0',
72
+ version: '2.13.0', // injected from package.json at build
73
73
  config: {},
74
74
  _initialized: false,
75
75
  _initCallback: null,
@@ -212,6 +212,22 @@ var Usion = (function () {
212
212
  return this.config.language || 'en';
213
213
  },
214
214
 
215
+ /**
216
+ * Launch parameters the host opened this app with. Use `path` to deep-link
217
+ * to a specific screen — e.g. when the user taps a notification sent via
218
+ * `Usion.notify.send({ path })`, the app reopens and `getLaunchParams().path`
219
+ * returns that same path so the app can route to it.
220
+ * @returns {{ path: string|null, ref: string|null, roomId: string|null }}
221
+ */
222
+ getLaunchParams: function() {
223
+ var c = this.config || {};
224
+ return {
225
+ path: c.launchPath || null,
226
+ ref: c.ref || c.launchRef || null,
227
+ roomId: c.roomId || null,
228
+ };
229
+ },
230
+
215
231
  /**
216
232
  * Send message to parent app
217
233
  * @private
@@ -510,7 +526,13 @@ var Usion = (function () {
510
526
  * @param {function} callback - Called with new balance
511
527
  */
512
528
  onBalanceChange: function(callback) {
513
- this._balanceChangeHandler = callback;
529
+ const self = this;
530
+ self._balanceChangeHandler = callback;
531
+ return function() {
532
+ if (self._balanceChangeHandler === callback) {
533
+ self._balanceChangeHandler = null;
534
+ }
535
+ };
514
536
  }
515
537
  };
516
538
  }
@@ -855,6 +877,26 @@ var Usion = (function () {
855
877
  return this.wallet.requestPayment(amount, reason, data);
856
878
  },
857
879
 
880
+ /**
881
+ * Live SDK diagnostics snapshot: version, transport, connection and
882
+ * sequence state. Surfaced automatically in the platform debug overlay
883
+ * (game.debug attaches it as _diag) and useful in bug reports.
884
+ */
885
+ diagnostics: function() {
886
+ const game = this.game || {};
887
+ return {
888
+ version: this.version,
889
+ transport: game.directMode ? 'direct' : (game._useProxy ? 'proxy' : (game.socket ? 'socket' : 'none')),
890
+ connected: !!game.connected,
891
+ joined: !!game._joined,
892
+ roomId: game.roomId || null,
893
+ playerId: game.playerId || null,
894
+ lastSequence: game._lastSequence || 0,
895
+ lastActionApplied: game._lastActionApplied || 0,
896
+ rejoining: !!game._rejoinPromise,
897
+ };
898
+ },
899
+
858
900
  /**
859
901
  * Submit result and signal completion
860
902
  * @param {object} data - Result data to send to parent
@@ -1060,9 +1102,7 @@ var Usion = (function () {
1060
1102
  self._connecting = false;
1061
1103
  self.connected = false;
1062
1104
  self.directMode = false;
1063
- if (self._eventHandlers.connectionError) {
1064
- self._eventHandlers.connectionError(err);
1065
- }
1105
+ self._dispatch('connectionError', err);
1066
1106
  throw err;
1067
1107
  });
1068
1108
  return self._connectPromise;
@@ -1171,9 +1211,7 @@ var Usion = (function () {
1171
1211
  clearInterval(self._heartbeatInterval);
1172
1212
  self._heartbeatInterval = null;
1173
1213
  }
1174
- if (self._eventHandlers.disconnect) {
1175
- self._eventHandlers.disconnect(evt && evt.reason ? evt.reason : 'direct socket closed');
1176
- }
1214
+ self._dispatch('disconnect', evt && evt.reason ? evt.reason : 'direct socket closed');
1177
1215
  // Seamless resume: if the drop wasn't an intentional disconnect()
1178
1216
  // (which clears directMode), transparently re-establish + re-join +
1179
1217
  // resync from the last sequence.
@@ -1207,7 +1245,7 @@ var Usion = (function () {
1207
1245
  self.connected = true;
1208
1246
  self._reconnecting = false;
1209
1247
  self._reconnectAttempt = 0;
1210
- if (self._eventHandlers.reconnect) self._eventHandlers.reconnect(attempt);
1248
+ self._dispatch('reconnect', attempt);
1211
1249
  if (self.roomId) self.requestSync(self._lastSequence || 0); // resync / resume
1212
1250
  })
1213
1251
  .catch(function() {
@@ -1245,20 +1283,20 @@ var Usion = (function () {
1245
1283
 
1246
1284
  if (data.type === 'joined') {
1247
1285
  this._joined = true;
1248
- if (this._eventHandlers.joined) this._eventHandlers.joined(payload);
1286
+ this._dispatch('joined', payload);
1249
1287
  return;
1250
1288
  }
1251
1289
  if (data.type === 'player_joined') {
1252
- if (this._eventHandlers.playerJoined) this._eventHandlers.playerJoined(payload);
1290
+ this._dispatch('playerJoined', payload);
1253
1291
  return;
1254
1292
  }
1255
1293
  if (data.type === 'player_left') {
1256
- if (this._eventHandlers.playerLeft) this._eventHandlers.playerLeft(payload);
1294
+ this._dispatch('playerLeft', payload);
1257
1295
  return;
1258
1296
  }
1259
1297
  if (data.type === 'state_snapshot' || data.type === 'state_delta') {
1260
- if (this._eventHandlers.realtime) this._eventHandlers.realtime(payload);
1261
- if (this._eventHandlers.stateUpdate) this._eventHandlers.stateUpdate(payload);
1298
+ this._dispatch('realtime', payload);
1299
+ this._dispatch('stateUpdate', payload);
1262
1300
  return;
1263
1301
  }
1264
1302
  if (data.type === 'pong') {
@@ -1267,15 +1305,15 @@ var Usion = (function () {
1267
1305
  const waiter = this._pongWaiters.shift();
1268
1306
  if (waiter) waiter();
1269
1307
  }
1270
- if (this._eventHandlers.sync) this._eventHandlers.sync(payload);
1308
+ this._dispatch('sync', payload);
1271
1309
  return;
1272
1310
  }
1273
1311
  if (data.type === 'match_end') {
1274
- if (this._eventHandlers.finished) this._eventHandlers.finished(payload);
1312
+ this._dispatch('finished', payload);
1275
1313
  return;
1276
1314
  }
1277
- if (data.type === 'error' && this._eventHandlers.error) {
1278
- this._eventHandlers.error(payload);
1315
+ if (data.type === 'error') {
1316
+ this._dispatch('error', payload);
1279
1317
  }
1280
1318
  };
1281
1319
  }
@@ -1339,17 +1377,27 @@ var Usion = (function () {
1339
1377
  }
1340
1378
  }, 25000);
1341
1379
 
1342
- // Re-join room after reconnect
1380
+ // Re-join room after reconnect. The promise is kept on
1381
+ // _rejoinPromise so action() can gate sends until the room
1382
+ // membership and sync are restored — otherwise a stale move could
1383
+ // go out before the client has caught up.
1343
1384
  if (self.roomId) {
1344
1385
  self._joined = false;
1345
1386
  self._joinPromise = null;
1346
- self.join(self.roomId)
1387
+ self._rejoinPromise = self.join(self.roomId)
1347
1388
  .then(function() {
1348
1389
  Usion.log('Reconnected - joined room ' + self.roomId);
1349
1390
  self.requestSync(self._lastSequence || 0);
1350
1391
  })
1351
1392
  .catch(function(err) {
1352
1393
  Usion.log('Rejoin failed: ' + (err && err.message ? err.message : String(err)));
1394
+ })
1395
+ .then(function() {
1396
+ self._rejoinPromise = null;
1397
+ // Send any moves queued while offline, now that membership
1398
+ // and sync are restored. (Socket.IO v4 emits 'reconnect' on
1399
+ // the Manager, not the Socket, so this is the reliable hook.)
1400
+ if (self._flushOfflineQueue) self._flushOfflineQueue();
1353
1401
  });
1354
1402
  }
1355
1403
 
@@ -1359,9 +1407,7 @@ var Usion = (function () {
1359
1407
  self.socket.on('connect_error', function(err) {
1360
1408
  self._connecting = false;
1361
1409
  Usion.log('Game socket error: ' + err.message);
1362
- if (self._eventHandlers.connectionError) {
1363
- self._eventHandlers.connectionError(err);
1364
- }
1410
+ self._dispatch('connectionError', err);
1365
1411
  reject(err);
1366
1412
  });
1367
1413
 
@@ -1374,104 +1420,92 @@ var Usion = (function () {
1374
1420
  self._heartbeatInterval = null;
1375
1421
  }
1376
1422
  Usion.log('Game socket disconnected: ' + reason);
1377
- if (self._eventHandlers.disconnect) {
1378
- self._eventHandlers.disconnect(reason);
1379
- }
1423
+ self._dispatch('disconnect', reason);
1380
1424
  });
1381
1425
 
1382
1426
  self.socket.on('reconnect', function(attemptNumber) {
1383
1427
  Usion.log('Game socket reconnected after ' + attemptNumber + ' attempts');
1384
- if (self._eventHandlers.reconnect) {
1385
- self._eventHandlers.reconnect(attemptNumber);
1386
- }
1428
+ self._dispatch('reconnect', attemptNumber);
1387
1429
  });
1388
1430
 
1389
1431
  // Game event handlers
1390
1432
  self.socket.on('game:joined', function(data) {
1391
1433
  if (data.sequence !== undefined) {
1392
1434
  self._lastSequence = data.sequence;
1435
+ // Everything up to this sequence is reflected in the joined
1436
+ // state — actions at or below it must not be re-delivered.
1437
+ self._lastActionApplied = Math.max(self._lastActionApplied, data.sequence);
1393
1438
  }
1394
- if (self._eventHandlers.joined) {
1395
- self._eventHandlers.joined(data);
1396
- }
1439
+ self._dispatch('joined', data);
1397
1440
  });
1398
1441
 
1399
1442
  self.socket.on('game:player_joined', function(data) {
1400
- if (self._eventHandlers.playerJoined) {
1401
- self._eventHandlers.playerJoined(data);
1402
- }
1443
+ self._dispatch('playerJoined', data);
1403
1444
  });
1404
1445
 
1405
1446
  self.socket.on('game:player_left', function(data) {
1406
- if (self._eventHandlers.playerLeft) {
1407
- self._eventHandlers.playerLeft(data);
1408
- }
1447
+ self._dispatch('playerLeft', data);
1409
1448
  });
1410
1449
 
1411
1450
  self.socket.on('game:state', function(data) {
1412
1451
  if (data.sequence !== undefined) {
1413
1452
  self._lastSequence = Math.max(self._lastSequence, data.sequence);
1414
1453
  }
1415
- if (self._eventHandlers.stateUpdate) {
1416
- self._eventHandlers.stateUpdate(data);
1417
- }
1454
+ self._dispatch('stateUpdate', data);
1418
1455
  });
1419
1456
 
1420
1457
  self.socket.on('game:sync', function(data) {
1421
1458
  if (data.sequence !== undefined) {
1422
1459
  self._lastSequence = data.sequence;
1460
+ // The sync payload carries all actions up to this sequence; a
1461
+ // live echo of one of them must not be applied a second time.
1462
+ self._lastActionApplied = Math.max(self._lastActionApplied, data.sequence);
1423
1463
  }
1424
- if (self._eventHandlers.sync) {
1425
- self._eventHandlers.sync(data);
1426
- }
1464
+ self._dispatch('sync', data);
1427
1465
  // Also trigger stateUpdate for backwards compat
1428
- if (self._eventHandlers.stateUpdate) {
1429
- self._eventHandlers.stateUpdate(data);
1430
- }
1466
+ self._dispatch('stateUpdate', data);
1431
1467
  });
1432
1468
 
1433
1469
  self.socket.on('game:action', function(data) {
1434
1470
  if (data.sequence !== undefined) {
1471
+ // Drop duplicates: actions are delivered exactly once per
1472
+ // sequence, whether they arrive live, as the sender's own echo,
1473
+ // or replayed after a reconnect.
1474
+ if (data.sequence <= self._lastActionApplied) return;
1475
+ self._lastActionApplied = data.sequence;
1435
1476
  self._lastSequence = Math.max(self._lastSequence, data.sequence);
1436
1477
  }
1437
- if (self._eventHandlers.action) {
1438
- self._eventHandlers.action(data);
1439
- }
1478
+ self._dispatch('action', data);
1479
+ });
1480
+
1481
+ self.socket.on('game:player_connection', function(data) {
1482
+ self._dispatch('playerConnection', data);
1440
1483
  });
1441
1484
 
1442
1485
  self.socket.on('game:realtime', function(data) {
1443
- if (self._eventHandlers.realtime) {
1444
- self._eventHandlers.realtime(data);
1445
- }
1486
+ self._dispatch('realtime', data);
1446
1487
  });
1447
1488
 
1448
1489
  self.socket.on('game:finished', function(data) {
1449
1490
  if (data.sequence !== undefined) {
1450
1491
  self._lastSequence = data.sequence;
1451
1492
  }
1452
- if (self._eventHandlers.finished) {
1453
- self._eventHandlers.finished(data);
1454
- }
1493
+ self._dispatch('finished', data);
1455
1494
  });
1456
1495
 
1457
1496
  self.socket.on('game:error', function(data) {
1458
1497
  Usion.log('Game error: ' + (data.message || data.code));
1459
- if (self._eventHandlers.error) {
1460
- self._eventHandlers.error(data);
1461
- }
1498
+ self._dispatch('error', data);
1462
1499
  });
1463
1500
 
1464
1501
  self.socket.on('game:rematch_request', function(data) {
1465
- if (self._eventHandlers.rematchRequest) {
1466
- self._eventHandlers.rematchRequest(data);
1467
- }
1502
+ self._dispatch('rematchRequest', data);
1468
1503
  });
1469
1504
 
1470
1505
  self.socket.on('game:restarted', function(data) {
1471
1506
  self._lastSequence = 0; // Reset sequence on rematch
1472
- if (self._eventHandlers.restarted) {
1473
- self._eventHandlers.restarted(data);
1474
- }
1507
+ self._lastActionApplied = 0;
1508
+ self._dispatch('restarted', data);
1475
1509
  });
1476
1510
 
1477
1511
  } catch (err) {
@@ -1480,6 +1514,82 @@ var Usion = (function () {
1480
1514
  };
1481
1515
  }
1482
1516
 
1517
+ /**
1518
+ * Usion SDK Errors — stable, machine-readable error codes.
1519
+ *
1520
+ * Developers should branch on `err.code`, never on message text. Messages
1521
+ * are human-readable and may change; codes are part of the public API and
1522
+ * follow the deprecation policy (never removed within a major version).
1523
+ */
1524
+
1525
+ /** @type {Record<string, string>} */
1526
+ const ERROR_CODES = {
1527
+ NOT_CONNECTED: 'NOT_CONNECTED', // No live connection for this call
1528
+ NO_ROOM: 'NO_ROOM', // No room id provided/known
1529
+ ROOM_NOT_FOUND: 'ROOM_NOT_FOUND', // Room does not exist server-side
1530
+ NOT_PARTICIPANT: 'NOT_PARTICIPANT', // Caller is not a player in the room
1531
+ NOT_AUTHORITY: 'NOT_AUTHORITY', // Authority-only call (e.g. setState)
1532
+ NOT_AUTHENTICATED: 'NOT_AUTHENTICATED', // Missing/invalid auth server-side
1533
+ JOIN_TIMEOUT: 'JOIN_TIMEOUT', // Join did not complete in time
1534
+ CONNECT_TIMEOUT: 'CONNECT_TIMEOUT', // Connect did not complete in time
1535
+ STATE_TOO_LARGE: 'STATE_TOO_LARGE', // setState payload over the quota
1536
+ INVALID_STATE: 'INVALID_STATE', // setState payload not a JSON object
1537
+ INVALID_NEXT_TURN: 'INVALID_NEXT_TURN', // nextTurn is not a player in the room
1538
+ RATE_LIMITED: 'RATE_LIMITED', // Too many calls; back off
1539
+ REQUEST_TIMEOUT: 'REQUEST_TIMEOUT', // Host/parent did not reply in time
1540
+ QUEUE_FULL: 'QUEUE_FULL', // Offline action queue at capacity
1541
+ UNSUPPORTED: 'UNSUPPORTED', // Not available in this transport
1542
+ UNKNOWN: 'UNKNOWN', // Unmapped error (see message)
1543
+ };
1544
+
1545
+ class UsionError extends Error {
1546
+ /**
1547
+ * @param {string} code - One of ERROR_CODES
1548
+ * @param {string} [message] - Human-readable detail (may change between versions)
1549
+ */
1550
+ constructor(code, message) {
1551
+ super(message || code);
1552
+ this.name = 'UsionError';
1553
+ this.code = ERROR_CODES[code] ? code : ERROR_CODES.UNKNOWN;
1554
+ }
1555
+ }
1556
+
1557
+ // Backend error strings → stable codes. Order matters: first match wins.
1558
+ /** @type {Array<[RegExp, string]>} */
1559
+ const BACKEND_PATTERNS = [
1560
+ [/not authenticated/i, ERROR_CODES.NOT_AUTHENTICATED],
1561
+ [/room_id required|no room id/i, ERROR_CODES.NO_ROOM],
1562
+ [/room not found/i, ERROR_CODES.ROOM_NOT_FOUND],
1563
+ [/not a participant/i, ERROR_CODES.NOT_PARTICIPANT],
1564
+ [/room authority/i, ERROR_CODES.NOT_AUTHORITY],
1565
+ [/exceeds .*limit/i, ERROR_CODES.STATE_TOO_LARGE],
1566
+ [/state must be/i, ERROR_CODES.INVALID_STATE],
1567
+ [/next_turn must be/i, ERROR_CODES.INVALID_NEXT_TURN],
1568
+ [/rate limit|too many/i, ERROR_CODES.RATE_LIMITED],
1569
+ [/join timeout/i, ERROR_CODES.JOIN_TIMEOUT],
1570
+ [/connection timeout|connect timeout/i, ERROR_CODES.CONNECT_TIMEOUT],
1571
+ [/request timeout/i, ERROR_CODES.REQUEST_TIMEOUT],
1572
+ [/not connected/i, ERROR_CODES.NOT_CONNECTED],
1573
+ ];
1574
+
1575
+ /**
1576
+ * Normalize anything (backend `{error}` string, Error, raw string) into a
1577
+ * UsionError with the best-matching stable code.
1578
+ * @param {*} err
1579
+ * @param {string} [fallbackCode] - Code to use when nothing matches
1580
+ * @returns {UsionError}
1581
+ */
1582
+ function toUsionError(err, fallbackCode) {
1583
+ if (err instanceof UsionError) return err;
1584
+ const message = err && err.message ? err.message : String(err || 'Unknown error');
1585
+ for (let i = 0; i < BACKEND_PATTERNS.length; i++) {
1586
+ if (BACKEND_PATTERNS[i][0].test(message)) {
1587
+ return new UsionError(BACKEND_PATTERNS[i][1], message);
1588
+ }
1589
+ }
1590
+ return new UsionError(fallbackCode || ERROR_CODES.UNKNOWN, message);
1591
+ }
1592
+
1483
1593
  /**
1484
1594
  * Usion SDK Game Proxy — postMessage relay through parent app
1485
1595
  */
@@ -1523,7 +1633,7 @@ var Usion = (function () {
1523
1633
  setTimeout(function() {
1524
1634
  if (!self.connected) {
1525
1635
  self._connecting = false;
1526
- reject(new Error('Proxy connection timeout'));
1636
+ reject(new UsionError(ERROR_CODES.CONNECT_TIMEOUT, 'Proxy connection timeout'));
1527
1637
  }
1528
1638
  }, 10000);
1529
1639
  });
@@ -1564,69 +1674,97 @@ var Usion = (function () {
1564
1674
  self._connecting = false;
1565
1675
  break;
1566
1676
 
1677
+ case 'GAME_DISCONNECTED':
1678
+ // The host app's socket dropped; it will rejoin and resync for us.
1679
+ self.connected = false;
1680
+ self._dispatch('disconnect', data.reason || 'transport');
1681
+ break;
1682
+
1683
+ case 'GAME_RECONNECTED':
1684
+ self.connected = true;
1685
+ self._dispatch('reconnect', data.attempts || 1);
1686
+ break;
1687
+
1688
+ case 'GAME_PLAYER_CONNECTION':
1689
+ self._dispatch('playerConnection', data);
1690
+ break;
1691
+
1567
1692
  case 'GAME_JOINED':
1568
1693
  self._joined = true;
1569
- if (data.sequence !== undefined) self._lastSequence = data.sequence;
1694
+ if (data.sequence !== undefined) {
1695
+ self._lastSequence = data.sequence;
1696
+ self._lastActionApplied = Math.max(self._lastActionApplied, data.sequence);
1697
+ }
1570
1698
  if (self._proxyJoinResolve) {
1571
1699
  self._proxyJoinResolve(data);
1572
1700
  self._proxyJoinResolve = null;
1573
1701
  }
1574
- if (self._eventHandlers.joined) self._eventHandlers.joined(data);
1702
+ self._dispatch('joined', data);
1575
1703
  break;
1576
1704
 
1577
1705
  case 'GAME_JOIN_ERROR':
1578
1706
  self._joined = false;
1579
1707
  if (self._proxyJoinReject) {
1580
- self._proxyJoinReject(new Error(data.message || 'Join failed'));
1708
+ self._proxyJoinReject(toUsionError(data.message || 'Join failed'));
1581
1709
  self._proxyJoinReject = null;
1582
1710
  }
1583
1711
  break;
1584
1712
 
1585
1713
  case 'GAME_PLAYER_JOINED':
1586
- if (self._eventHandlers.playerJoined) self._eventHandlers.playerJoined(data);
1714
+ self._dispatch('playerJoined', data);
1587
1715
  break;
1588
1716
 
1589
1717
  case 'GAME_PLAYER_LEFT':
1590
- if (self._eventHandlers.playerLeft) self._eventHandlers.playerLeft(data);
1718
+ self._dispatch('playerLeft', data);
1591
1719
  break;
1592
1720
 
1593
1721
  case 'GAME_STATE':
1594
1722
  if (data.sequence !== undefined) self._lastSequence = Math.max(self._lastSequence, data.sequence);
1595
- if (self._eventHandlers.stateUpdate) self._eventHandlers.stateUpdate(data);
1723
+ self._dispatch('stateUpdate', data);
1596
1724
  break;
1597
1725
 
1598
1726
  case 'GAME_ACTION_DATA':
1599
- if (data.sequence !== undefined) self._lastSequence = Math.max(self._lastSequence, data.sequence);
1600
- if (self._eventHandlers.action) self._eventHandlers.action(data);
1727
+ if (data.sequence !== undefined) {
1728
+ // Drop duplicates: actions are delivered exactly once per
1729
+ // sequence (live, sender echo, or replay after reconnect).
1730
+ if (data.sequence <= self._lastActionApplied) break;
1731
+ self._lastActionApplied = data.sequence;
1732
+ self._lastSequence = Math.max(self._lastSequence, data.sequence);
1733
+ }
1734
+ self._dispatch('action', data);
1601
1735
  break;
1602
1736
 
1603
1737
  case 'GAME_REALTIME_DATA':
1604
- if (self._eventHandlers.realtime) self._eventHandlers.realtime(data);
1738
+ self._dispatch('realtime', data);
1605
1739
  break;
1606
1740
 
1607
1741
  case 'GAME_FINISHED':
1608
1742
  if (data.sequence !== undefined) self._lastSequence = data.sequence;
1609
- if (self._eventHandlers.finished) self._eventHandlers.finished(data);
1743
+ self._dispatch('finished', data);
1610
1744
  break;
1611
1745
 
1612
1746
  case 'GAME_ERROR':
1613
1747
  Usion.log('Game error via proxy: ' + (data.message || data.code));
1614
- if (self._eventHandlers.error) self._eventHandlers.error(data);
1748
+ self._dispatch('error', data);
1615
1749
  break;
1616
1750
 
1617
1751
  case 'GAME_RESTARTED':
1618
1752
  self._lastSequence = 0;
1619
- if (self._eventHandlers.restarted) self._eventHandlers.restarted(data);
1753
+ self._lastActionApplied = 0;
1754
+ self._dispatch('restarted', data);
1620
1755
  break;
1621
1756
 
1622
1757
  case 'GAME_REMATCH_REQUEST':
1623
- if (self._eventHandlers.rematchRequest) self._eventHandlers.rematchRequest(data);
1758
+ self._dispatch('rematchRequest', data);
1624
1759
  break;
1625
1760
 
1626
1761
  case 'GAME_SYNC':
1627
- if (data.sequence !== undefined) self._lastSequence = data.sequence;
1628
- if (self._eventHandlers.sync) self._eventHandlers.sync(data);
1629
- if (self._eventHandlers.stateUpdate) self._eventHandlers.stateUpdate(data);
1762
+ if (data.sequence !== undefined) {
1763
+ self._lastSequence = data.sequence;
1764
+ self._lastActionApplied = Math.max(self._lastActionApplied, data.sequence);
1765
+ }
1766
+ self._dispatch('sync', data);
1767
+ self._dispatch('stateUpdate', data);
1630
1768
  break;
1631
1769
  }
1632
1770
  });
@@ -1637,6 +1775,7 @@ var Usion = (function () {
1637
1775
  * Usion SDK Game Methods — join, leave, action, realtime, sync, etc.
1638
1776
  */
1639
1777
 
1778
+
1640
1779
  /**
1641
1780
  * Add game action methods to game module
1642
1781
  * @param {object} game - The game module object
@@ -1678,7 +1817,7 @@ var Usion = (function () {
1678
1817
  setTimeout(function() {
1679
1818
  if (!self._joined && self._proxyJoinReject) {
1680
1819
  self._proxyJoinReject = null;
1681
- reject(new Error('Join timeout'));
1820
+ reject(new UsionError(ERROR_CODES.JOIN_TIMEOUT, 'Join timeout'));
1682
1821
  }
1683
1822
  }, 15000);
1684
1823
  });
@@ -1687,19 +1826,19 @@ var Usion = (function () {
1687
1826
 
1688
1827
  self._joinPromise = new Promise(function(resolve, reject) {
1689
1828
  if (!self.socket || !self.connected) {
1690
- reject(new Error('Not connected'));
1829
+ reject(new UsionError(ERROR_CODES.NOT_CONNECTED, 'Not connected'));
1691
1830
  return;
1692
1831
  }
1693
1832
 
1694
1833
  if (!roomId) {
1695
- reject(new Error('No room ID provided'));
1834
+ reject(new UsionError(ERROR_CODES.NO_ROOM, 'No room ID provided'));
1696
1835
  return;
1697
1836
  }
1698
1837
 
1699
1838
  self.socket.emit('game:join', { room_id: roomId }, function(response) {
1700
1839
  if (response.error) {
1701
1840
  self._joined = false;
1702
- reject(new Error(response.message || response.error));
1841
+ reject(toUsionError(response.message || response.error));
1703
1842
  } else {
1704
1843
  self._joined = true;
1705
1844
  if (response.sequence !== undefined) {
@@ -1747,13 +1886,25 @@ var Usion = (function () {
1747
1886
  };
1748
1887
 
1749
1888
  /**
1750
- * Send a game action
1889
+ * Send a game action.
1890
+ *
1891
+ * RELIABILITY CONTRACT: the platform echoes every action back to the
1892
+ * sender (with the authoritative sequence number). Apply game state ONLY
1893
+ * in onAction — never optimistically on send — so every client applies
1894
+ * the same actions in the same order. The SDK deduplicates by sequence,
1895
+ * so an action is delivered exactly once even across reconnect replays.
1896
+ *
1751
1897
  * @param {string} actionType - Type of action (e.g., 'move')
1752
1898
  * @param {object} actionData - Action data
1899
+ * @param {object} [opts] - Options. opts.nextTurn: player ID whose turn
1900
+ * is next — the server remembers it and hands it to any (re)joining
1901
+ * client as current_turn, so turn state survives reconnects.
1753
1902
  * @returns {Promise} Resolves when action is processed
1754
1903
  */
1755
- game.action = function(actionType, actionData) {
1904
+ game.action = function(actionType, actionData, opts) {
1756
1905
  const self = this;
1906
+ const nextTurn = opts && opts.nextTurn;
1907
+ const queueOffline = !!(opts && opts.queueOffline);
1757
1908
 
1758
1909
  if (self.directMode) {
1759
1910
  self._sendDirect('action', {
@@ -1763,29 +1914,153 @@ var Usion = (function () {
1763
1914
  return Promise.resolve({ success: true });
1764
1915
  }
1765
1916
 
1917
+ // Opt-in offline queue: instead of failing while disconnected, hold
1918
+ // the move and send it (in order) once the connection recovers.
1919
+ // Turn-based games get "your move is saved and sends when you're
1920
+ // back" for free; realtime games should NOT use this (stale inputs).
1921
+ if (queueOffline && !self.connected) {
1922
+ return self._queueOfflineAction(actionType, actionData, opts);
1923
+ }
1924
+
1766
1925
  if (self._useProxy) {
1767
- Usion._post({ type: 'GAME_ACTION', room_id: self.roomId, action_type: actionType, action_data: actionData });
1926
+ const proxyMsg = { type: 'GAME_ACTION', room_id: self.roomId, action_type: actionType, action_data: actionData };
1927
+ if (nextTurn) proxyMsg.next_turn = nextTurn;
1928
+ Usion._post(proxyMsg);
1768
1929
  return Promise.resolve({ success: true });
1769
1930
  }
1770
1931
 
1932
+ // Gate sends while a post-reconnect rejoin is in flight, so a stale
1933
+ // move can't go out before the client has resynced.
1934
+ const gate = self._rejoinPromise || Promise.resolve();
1935
+ return gate.then(function() {
1936
+ return new Promise(function(resolve, reject) {
1937
+ if (!self.socket || !self.connected) {
1938
+ if (queueOffline) {
1939
+ self._queueOfflineAction(actionType, actionData, opts).then(resolve, reject);
1940
+ return;
1941
+ }
1942
+ reject(new UsionError(ERROR_CODES.NOT_CONNECTED, 'Not connected'));
1943
+ return;
1944
+ }
1945
+
1946
+ const payload = {
1947
+ room_id: self.roomId,
1948
+ action_type: actionType,
1949
+ action_data: actionData
1950
+ };
1951
+ if (nextTurn) payload.next_turn = nextTurn;
1952
+ self.socket.emit('game:action', payload, function(response) {
1953
+ if (response.error) {
1954
+ reject(toUsionError(response.message || response.error));
1955
+ } else {
1956
+ if (response.sequence !== undefined) {
1957
+ self._lastSequence = response.sequence;
1958
+ }
1959
+ resolve(response);
1960
+ }
1961
+ });
1962
+ });
1963
+ });
1964
+ };
1965
+
1966
+ // ── Offline action queue (opt-in via action(..., { queueOffline: true })) ──
1967
+
1968
+ const OFFLINE_QUEUE_MAX = 20;
1969
+
1970
+ /** @private Hold an action until the connection recovers. */
1971
+ game._queueOfflineAction = function(actionType, actionData, opts) {
1972
+ const self = this;
1973
+ if (!self._offlineQueue) self._offlineQueue = [];
1974
+ if (self._offlineQueue.length >= OFFLINE_QUEUE_MAX) {
1975
+ return Promise.reject(new UsionError(ERROR_CODES.QUEUE_FULL,
1976
+ 'Offline action queue is full (' + OFFLINE_QUEUE_MAX + ')'));
1977
+ }
1978
+ Usion.log('Queued action while offline: ' + actionType);
1979
+ return new Promise(function(resolve, reject) {
1980
+ self._offlineQueue.push({
1981
+ actionType: actionType,
1982
+ actionData: actionData,
1983
+ opts: Object.assign({}, opts, { queueOffline: false }),
1984
+ resolve: resolve,
1985
+ reject: reject,
1986
+ });
1987
+ self._ensureOfflineFlushHook();
1988
+ });
1989
+ };
1990
+
1991
+ /** @private Flush queued actions, in order, once reconnected. */
1992
+ game._ensureOfflineFlushHook = function() {
1993
+ const self = this;
1994
+ if (self._offlineFlushHooked) return;
1995
+ self._offlineFlushHooked = true;
1996
+ self.on('reconnect', function() { self._flushOfflineQueue(); });
1997
+ };
1998
+
1999
+ /** @private */
2000
+ game._flushOfflineQueue = function() {
2001
+ const self = this;
2002
+ const queue = self._offlineQueue;
2003
+ if (!queue || !queue.length) return;
2004
+ self._offlineQueue = [];
2005
+ Usion.log('Flushing ' + queue.length + ' queued action(s) after reconnect');
2006
+ // Send strictly in order: each action waits for the previous ack so
2007
+ // the server assigns sequences in the order the player acted.
2008
+ let chain = Promise.resolve();
2009
+ queue.forEach(function(item) {
2010
+ chain = chain.then(function() {
2011
+ return self.action(item.actionType, item.actionData, item.opts)
2012
+ .then(item.resolve, item.reject);
2013
+ });
2014
+ });
2015
+ };
2016
+
2017
+ /**
2018
+ * Checkpoint the authoritative game state on the server. Any client that
2019
+ * joins or rejoins the room receives the latest checkpoint as game_state
2020
+ * in the join ack and in game:sync — recovery becomes "load checkpoint,
2021
+ * replay the tail" instead of replaying every action from zero.
2022
+ *
2023
+ * Only the room authority (player_ids[0] / host) may call this. The
2024
+ * serialized state is capped at 64 KB.
2025
+ *
2026
+ * @param {*} state - JSON-serializable authoritative game state
2027
+ * @returns {Promise<{success: boolean, error?: string}>}
2028
+ */
2029
+ game.setState = function(state) {
2030
+ const self = this;
2031
+
2032
+ if (self.directMode) {
2033
+ // Direct-mode game servers own their state; no platform checkpoint.
2034
+ return Promise.resolve({ success: false, error: 'not_supported_in_direct_mode', code: ERROR_CODES.UNSUPPORTED });
2035
+ }
2036
+
2037
+ if (self._useProxy) {
2038
+ return Usion._request('GAME_SET_STATE', { room_id: self.roomId, state: state || {} })
2039
+ .then(function(res) {
2040
+ if (res && res.error) {
2041
+ return { success: false, error: res.error, code: toUsionError(res.error).code };
2042
+ }
2043
+ return res || { success: true };
2044
+ })
2045
+ .catch(function(err) {
2046
+ const ue = toUsionError(err, ERROR_CODES.REQUEST_TIMEOUT);
2047
+ return { success: false, error: ue.message, code: ue.code };
2048
+ });
2049
+ }
2050
+
1771
2051
  return new Promise(function(resolve, reject) {
1772
2052
  if (!self.socket || !self.connected) {
1773
- reject(new Error('Not connected'));
2053
+ reject(new UsionError(ERROR_CODES.NOT_CONNECTED, 'Not connected'));
1774
2054
  return;
1775
2055
  }
1776
-
1777
- self.socket.emit('game:action', {
2056
+ self.socket.emit('game:set_state', {
1778
2057
  room_id: self.roomId,
1779
- action_type: actionType,
1780
- action_data: actionData
2058
+ state: state || {}
1781
2059
  }, function(response) {
1782
- if (response.error) {
1783
- reject(new Error(response.message || response.error));
2060
+ if (response && response.error) {
2061
+ resolve({ success: false, error: response.error, code: toUsionError(response.error).code });
1784
2062
  } else {
1785
- if (response.sequence !== undefined) {
1786
- self._lastSequence = response.sequence;
1787
- }
1788
- resolve(response);
2063
+ resolve(response || { success: true });
1789
2064
  }
1790
2065
  });
1791
2066
  });
@@ -1889,13 +2164,13 @@ var Usion = (function () {
1889
2164
 
1890
2165
  return new Promise(function(resolve, reject) {
1891
2166
  if (!self.socket || !self.connected) {
1892
- reject(new Error('Not connected'));
2167
+ reject(new UsionError(ERROR_CODES.NOT_CONNECTED, 'Not connected'));
1893
2168
  return;
1894
2169
  }
1895
2170
 
1896
2171
  self.socket.emit('game:forfeit', { room_id: self.roomId }, function(response) {
1897
2172
  if (response.error) {
1898
- reject(new Error(response.message || response.error));
2173
+ reject(toUsionError(response.message || response.error));
1899
2174
  } else {
1900
2175
  resolve(response);
1901
2176
  }
@@ -2057,7 +2332,9 @@ var Usion = (function () {
2057
2332
  var inFrame = window.parent && window.parent !== window;
2058
2333
  var inRNWebView = !!window.ReactNativeWebView;
2059
2334
  if (inFrame || inRNWebView) {
2060
- Usion._post({ type: 'GAME_DEBUG', payload: payload || {} });
2335
+ var body = Object.assign({}, payload || {});
2336
+ body._diag = Usion.diagnostics ? Usion.diagnostics() : undefined;
2337
+ Usion._post({ type: 'GAME_DEBUG', payload: body });
2061
2338
  }
2062
2339
  } catch (e) { /* non-fatal */ }
2063
2340
  };
@@ -4063,6 +4340,36 @@ var Usion = (function () {
4063
4340
  */
4064
4341
 
4065
4342
 
4343
+ // Map any reasonable spelling of a game event onto the internal handler
4344
+ // name: 'game:player_joined' / 'player_joined' / 'playerJoined' → 'playerJoined'.
4345
+ const _EVENT_ALIASES = {
4346
+ joined: 'joined',
4347
+ player_joined: 'playerJoined',
4348
+ player_left: 'playerLeft',
4349
+ player_connection: 'playerConnection',
4350
+ state: 'stateUpdate',
4351
+ state_update: 'stateUpdate',
4352
+ sync: 'sync',
4353
+ action: 'action',
4354
+ realtime: 'realtime',
4355
+ finished: 'finished',
4356
+ restarted: 'restarted',
4357
+ error: 'error',
4358
+ rematch_request: 'rematchRequest',
4359
+ disconnect: 'disconnect',
4360
+ reconnect: 'reconnect',
4361
+ connection_error: 'connectionError',
4362
+ };
4363
+
4364
+ function _normalizeEventName(event) {
4365
+ let name = String(event || '');
4366
+ if (name.indexOf('game:') === 0) name = name.slice(5);
4367
+ if (_EVENT_ALIASES[name]) return _EVENT_ALIASES[name];
4368
+ // camelCase passthrough ('playerJoined', 'stateUpdate', …)
4369
+ const snake = name.replace(/([A-Z])/g, function(m) { return '_' + m.toLowerCase(); });
4370
+ return _EVENT_ALIASES[snake] || name;
4371
+ }
4372
+
4066
4373
  /**
4067
4374
  * Create the game module with all sub-modules applied
4068
4375
  * @param {object} Usion - Reference to the main Usion object
@@ -4088,6 +4395,11 @@ var Usion = (function () {
4088
4395
  _heartbeatInterval: null,
4089
4396
  _pingMeter: null,
4090
4397
  _pongWaiters: [],
4398
+ // Reliability state: highest action sequence already delivered to the
4399
+ // game (duplicate echoes/replays are dropped), and the in-flight rejoin
4400
+ // promise that gates action() sends right after a reconnect.
4401
+ _lastActionApplied: 0,
4402
+ _rejoinPromise: null,
4091
4403
 
4092
4404
  /**
4093
4405
  * Connect to the game socket server
@@ -4170,34 +4482,85 @@ var Usion = (function () {
4170
4482
  return self._connectPromise;
4171
4483
  },
4172
4484
 
4173
- // Event handler registrations
4174
- onJoined: function(callback) { this._eventHandlers.joined = callback; },
4175
- onPlayerJoined: function(callback) { this._eventHandlers.playerJoined = callback; },
4176
- onPlayerLeft: function(callback) { this._eventHandlers.playerLeft = callback; },
4177
- onStateUpdate: function(callback) { this._eventHandlers.stateUpdate = callback; },
4178
- onSync: function(callback) { this._eventHandlers.sync = callback; },
4179
- onAction: function(callback) { this._eventHandlers.action = callback; },
4180
- onRealtime: function(callback) { this._eventHandlers.realtime = callback; },
4181
- onGameFinished: function(callback) { this._eventHandlers.finished = callback; },
4182
- onGameRestarted: function(callback) { this._eventHandlers.restarted = callback; },
4183
- onError: function(callback) { this._eventHandlers.error = callback; },
4184
- onRematchRequest: function(callback) { this._eventHandlers.rematchRequest = callback; },
4185
- onDisconnect: function(callback) { this._eventHandlers.disconnect = callback; },
4186
- onReconnect: function(callback) { this._eventHandlers.reconnect = callback; },
4187
- onConnectionError: function(callback) { this._eventHandlers.connectionError = callback; },
4485
+ // Event handler registrations.
4486
+ //
4487
+ // Each onX(cb) keeps the long-standing "single handler, last one wins"
4488
+ // behavior for back-compat, but now ALSO returns an unsubscribe
4489
+ // function. For multiple listeners use game.on(event, cb), which
4490
+ // supports any number of listeners, works before connect() in every
4491
+ // transport, and returns an unsubscribe function.
4492
+ onJoined: function(callback) { return this._setHandler('joined', callback); },
4493
+ onPlayerJoined: function(callback) { return this._setHandler('playerJoined', callback); },
4494
+ onPlayerLeft: function(callback) { return this._setHandler('playerLeft', callback); },
4495
+ onStateUpdate: function(callback) { return this._setHandler('stateUpdate', callback); },
4496
+ onSync: function(callback) { return this._setHandler('sync', callback); },
4497
+ onAction: function(callback) { return this._setHandler('action', callback); },
4498
+ onRealtime: function(callback) { return this._setHandler('realtime', callback); },
4499
+ onGameFinished: function(callback) { return this._setHandler('finished', callback); },
4500
+ onGameRestarted: function(callback) { return this._setHandler('restarted', callback); },
4501
+ onError: function(callback) { return this._setHandler('error', callback); },
4502
+ onRematchRequest: function(callback) { return this._setHandler('rematchRequest', callback); },
4503
+ onDisconnect: function(callback) { return this._setHandler('disconnect', callback); },
4504
+ onReconnect: function(callback) { return this._setHandler('reconnect', callback); },
4505
+ onConnectionError: function(callback) { return this._setHandler('connectionError', callback); },
4506
+ onPlayerConnection: function(callback) { return this._setHandler('playerConnection', callback); },
4507
+
4508
+ /** @private Set the single legacy handler; returns an unsubscribe fn. */
4509
+ _setHandler: function(name, callback) {
4510
+ const self = this;
4511
+ self._eventHandlers[name] = callback;
4512
+ return function() {
4513
+ if (self._eventHandlers[name] === callback) {
4514
+ self._eventHandlers[name] = null;
4515
+ }
4516
+ };
4517
+ },
4188
4518
 
4189
4519
  /**
4190
- * Register a generic event handler
4520
+ * @private Deliver an event to the legacy single handler and every
4521
+ * game.on() listener. All game event delivery flows through here.
4522
+ */
4523
+ _dispatch: function(name) {
4524
+ const args = Array.prototype.slice.call(arguments, 1);
4525
+ const single = this._eventHandlers[name];
4526
+ if (single) {
4527
+ try { single.apply(null, args); } catch (e) { Usion.log('game handler error (' + name + '): ' + e.message); }
4528
+ }
4529
+ const list = this._listeners[name];
4530
+ if (list && list.length) {
4531
+ const copy = list.slice();
4532
+ for (let i = 0; i < copy.length; i++) {
4533
+ try { copy[i].apply(null, args); } catch (e) { Usion.log('game listener error (' + name + '): ' + e.message); }
4534
+ }
4535
+ }
4536
+ },
4537
+
4538
+ /**
4539
+ * Register an additional event listener. Unlike the onX methods this
4540
+ * supports multiple listeners per event, can be called before
4541
+ * connect(), and works in every transport (standalone, embedded
4542
+ * proxy, direct). Accepts internal names ('action'), wire names
4543
+ * ('game:action'), or snake_case ('player_joined').
4191
4544
  * @param {string} event - Event name
4192
4545
  * @param {function} callback - Handler function
4546
+ * @returns {function} Unsubscribe function
4193
4547
  */
4194
4548
  on: function(event, callback) {
4195
- if (this.socket) {
4196
- this.socket.on(event, callback);
4197
- }
4549
+ const self = this;
4550
+ const name = _normalizeEventName(event);
4551
+ if (!self._listeners[name]) self._listeners[name] = [];
4552
+ self._listeners[name].push(callback);
4553
+ return function() {
4554
+ const list = self._listeners[name];
4555
+ if (!list) return;
4556
+ const i = list.indexOf(callback);
4557
+ if (i >= 0) list.splice(i, 1);
4558
+ };
4198
4559
  }
4199
4560
  };
4200
4561
 
4562
+ game._listeners = {};
4563
+
4201
4564
  // Apply sub-modules
4202
4565
  applyGameDirect(game, Usion);
4203
4566
  applyGameSocket(game, Usion);
@@ -4488,6 +4851,77 @@ var Usion = (function () {
4488
4851
  };
4489
4852
  }
4490
4853
 
4854
+ /**
4855
+ * Usion SDK Notify — let a mini-app notify its own user.
4856
+ *
4857
+ * A notification reaches the user even when they aren't looking at the app:
4858
+ * - online elsewhere in Usion -> in-app banner
4859
+ * - offline / app backgrounded -> OS push notification
4860
+ * Tapping it re-opens THIS mini-app, optionally at a specific internal screen
4861
+ * via `path` (read back on launch with `Usion.getLaunchParams().path`).
4862
+ *
4863
+ * Rides the unified backend channel, so it works standalone AND embedded.
4864
+ * Scope & safety: a notification can only target the CURRENT user (you cannot
4865
+ * notify other people from here), and the platform rate-limits per user per
4866
+ * service. Users can silence a service with `setMuted(true)`.
4867
+ *
4868
+ * await Usion.notify.send({
4869
+ * title: 'Render complete',
4870
+ * body: 'Your video is ready to view.',
4871
+ * path: '/render/abc123', // optional — deep-links inside the app
4872
+ * });
4873
+ *
4874
+ * await Usion.notify.setMuted(true); // user opts out for this app
4875
+ * const muted = await Usion.notify.isMuted();
4876
+ *
4877
+ * For server-triggered notifications (e.g. a long-running job finishing while
4878
+ * the app is closed), a mini-app's own backend calls the signed REST endpoint
4879
+ * `POST /services/{id}/notify` instead — see the publishing reference.
4880
+ */
4881
+ function createNotifyModule(Usion) {
4882
+ function serviceId(opts) {
4883
+ return (opts && opts.serviceId) || (Usion.config && Usion.config.serviceId);
4884
+ }
4885
+
4886
+ return {
4887
+ /**
4888
+ * Send a notification to the current user.
4889
+ * @param {{title: string, body: string, path?: string, serviceId?: string}} opts
4890
+ * @returns {Promise<{success: boolean, delivered?: string}>}
4891
+ */
4892
+ send: function (opts) {
4893
+ opts = opts || {};
4894
+ return Usion._backendEmit('notify:send', {
4895
+ service_id: serviceId(opts),
4896
+ title: opts.title,
4897
+ body: opts.body,
4898
+ path: opts.path,
4899
+ });
4900
+ },
4901
+
4902
+ /**
4903
+ * Mute (or unmute) notifications from this app for the current user.
4904
+ * @param {boolean} muted
4905
+ * @returns {Promise<{success: boolean, muted: boolean}>}
4906
+ */
4907
+ setMuted: function (muted, opts) {
4908
+ return Usion._backendEmit('notify:set_pref', {
4909
+ service_id: serviceId(opts),
4910
+ muted: !!muted,
4911
+ });
4912
+ },
4913
+
4914
+ /**
4915
+ * Whether the current user has muted notifications from this app.
4916
+ * @returns {Promise<boolean>}
4917
+ */
4918
+ isMuted: function (opts) {
4919
+ return Usion._backendEmit('notify:get_pref', { service_id: serviceId(opts) })
4920
+ .then(function (r) { return !!(r && r.muted); });
4921
+ },
4922
+ };
4923
+ }
4924
+
4491
4925
  /**
4492
4926
  * Usion SDK — unified backend channel.
4493
4927
  *
@@ -4616,12 +5050,16 @@ var Usion = (function () {
4616
5050
  Usion.bot = createBotModule(Usion);
4617
5051
  Usion.fileStorage = createFileStorageModule(Usion);
4618
5052
  Usion.game = createGameModule(Usion);
5053
+ // Stable error class + codes — developers branch on err.code, not message text.
5054
+ Usion.UsionError = UsionError;
5055
+ Usion.ERROR_CODES = ERROR_CODES;
4619
5056
  // Unified backend channel (used by lobby etc.; works standalone + embedded).
4620
5057
  applyBackendChannel(Usion);
4621
5058
  Usion.lobby = createLobbyModule(Usion);
4622
5059
  Usion.leaderboard = createLeaderboardModule(Usion);
4623
5060
  Usion.cloud = createCloudModule(Usion);
4624
5061
  Usion.matchmaking = createMatchmakingModule(Usion);
5062
+ Usion.notify = createNotifyModule(Usion);
4625
5063
 
4626
5064
  // Netcode toolkit (transport-agnostic, zero-dependency).
4627
5065
  Usion.netcode = netcode;