@usions/sdk 2.11.1 → 2.12.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.12.0', // injected from package.json at build
73
73
  config: {},
74
74
  _initialized: false,
75
75
  _initCallback: null,
@@ -510,7 +510,13 @@ var Usion = (function () {
510
510
  * @param {function} callback - Called with new balance
511
511
  */
512
512
  onBalanceChange: function(callback) {
513
- this._balanceChangeHandler = callback;
513
+ const self = this;
514
+ self._balanceChangeHandler = callback;
515
+ return function() {
516
+ if (self._balanceChangeHandler === callback) {
517
+ self._balanceChangeHandler = null;
518
+ }
519
+ };
514
520
  }
515
521
  };
516
522
  }
@@ -855,6 +861,26 @@ var Usion = (function () {
855
861
  return this.wallet.requestPayment(amount, reason, data);
856
862
  },
857
863
 
864
+ /**
865
+ * Live SDK diagnostics snapshot: version, transport, connection and
866
+ * sequence state. Surfaced automatically in the platform debug overlay
867
+ * (game.debug attaches it as _diag) and useful in bug reports.
868
+ */
869
+ diagnostics: function() {
870
+ const game = this.game || {};
871
+ return {
872
+ version: this.version,
873
+ transport: game.directMode ? 'direct' : (game._useProxy ? 'proxy' : (game.socket ? 'socket' : 'none')),
874
+ connected: !!game.connected,
875
+ joined: !!game._joined,
876
+ roomId: game.roomId || null,
877
+ playerId: game.playerId || null,
878
+ lastSequence: game._lastSequence || 0,
879
+ lastActionApplied: game._lastActionApplied || 0,
880
+ rejoining: !!game._rejoinPromise,
881
+ };
882
+ },
883
+
858
884
  /**
859
885
  * Submit result and signal completion
860
886
  * @param {object} data - Result data to send to parent
@@ -1060,9 +1086,7 @@ var Usion = (function () {
1060
1086
  self._connecting = false;
1061
1087
  self.connected = false;
1062
1088
  self.directMode = false;
1063
- if (self._eventHandlers.connectionError) {
1064
- self._eventHandlers.connectionError(err);
1065
- }
1089
+ self._dispatch('connectionError', err);
1066
1090
  throw err;
1067
1091
  });
1068
1092
  return self._connectPromise;
@@ -1171,9 +1195,7 @@ var Usion = (function () {
1171
1195
  clearInterval(self._heartbeatInterval);
1172
1196
  self._heartbeatInterval = null;
1173
1197
  }
1174
- if (self._eventHandlers.disconnect) {
1175
- self._eventHandlers.disconnect(evt && evt.reason ? evt.reason : 'direct socket closed');
1176
- }
1198
+ self._dispatch('disconnect', evt && evt.reason ? evt.reason : 'direct socket closed');
1177
1199
  // Seamless resume: if the drop wasn't an intentional disconnect()
1178
1200
  // (which clears directMode), transparently re-establish + re-join +
1179
1201
  // resync from the last sequence.
@@ -1207,7 +1229,7 @@ var Usion = (function () {
1207
1229
  self.connected = true;
1208
1230
  self._reconnecting = false;
1209
1231
  self._reconnectAttempt = 0;
1210
- if (self._eventHandlers.reconnect) self._eventHandlers.reconnect(attempt);
1232
+ self._dispatch('reconnect', attempt);
1211
1233
  if (self.roomId) self.requestSync(self._lastSequence || 0); // resync / resume
1212
1234
  })
1213
1235
  .catch(function() {
@@ -1245,20 +1267,20 @@ var Usion = (function () {
1245
1267
 
1246
1268
  if (data.type === 'joined') {
1247
1269
  this._joined = true;
1248
- if (this._eventHandlers.joined) this._eventHandlers.joined(payload);
1270
+ this._dispatch('joined', payload);
1249
1271
  return;
1250
1272
  }
1251
1273
  if (data.type === 'player_joined') {
1252
- if (this._eventHandlers.playerJoined) this._eventHandlers.playerJoined(payload);
1274
+ this._dispatch('playerJoined', payload);
1253
1275
  return;
1254
1276
  }
1255
1277
  if (data.type === 'player_left') {
1256
- if (this._eventHandlers.playerLeft) this._eventHandlers.playerLeft(payload);
1278
+ this._dispatch('playerLeft', payload);
1257
1279
  return;
1258
1280
  }
1259
1281
  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);
1282
+ this._dispatch('realtime', payload);
1283
+ this._dispatch('stateUpdate', payload);
1262
1284
  return;
1263
1285
  }
1264
1286
  if (data.type === 'pong') {
@@ -1267,15 +1289,15 @@ var Usion = (function () {
1267
1289
  const waiter = this._pongWaiters.shift();
1268
1290
  if (waiter) waiter();
1269
1291
  }
1270
- if (this._eventHandlers.sync) this._eventHandlers.sync(payload);
1292
+ this._dispatch('sync', payload);
1271
1293
  return;
1272
1294
  }
1273
1295
  if (data.type === 'match_end') {
1274
- if (this._eventHandlers.finished) this._eventHandlers.finished(payload);
1296
+ this._dispatch('finished', payload);
1275
1297
  return;
1276
1298
  }
1277
- if (data.type === 'error' && this._eventHandlers.error) {
1278
- this._eventHandlers.error(payload);
1299
+ if (data.type === 'error') {
1300
+ this._dispatch('error', payload);
1279
1301
  }
1280
1302
  };
1281
1303
  }
@@ -1339,17 +1361,27 @@ var Usion = (function () {
1339
1361
  }
1340
1362
  }, 25000);
1341
1363
 
1342
- // Re-join room after reconnect
1364
+ // Re-join room after reconnect. The promise is kept on
1365
+ // _rejoinPromise so action() can gate sends until the room
1366
+ // membership and sync are restored — otherwise a stale move could
1367
+ // go out before the client has caught up.
1343
1368
  if (self.roomId) {
1344
1369
  self._joined = false;
1345
1370
  self._joinPromise = null;
1346
- self.join(self.roomId)
1371
+ self._rejoinPromise = self.join(self.roomId)
1347
1372
  .then(function() {
1348
1373
  Usion.log('Reconnected - joined room ' + self.roomId);
1349
1374
  self.requestSync(self._lastSequence || 0);
1350
1375
  })
1351
1376
  .catch(function(err) {
1352
1377
  Usion.log('Rejoin failed: ' + (err && err.message ? err.message : String(err)));
1378
+ })
1379
+ .then(function() {
1380
+ self._rejoinPromise = null;
1381
+ // Send any moves queued while offline, now that membership
1382
+ // and sync are restored. (Socket.IO v4 emits 'reconnect' on
1383
+ // the Manager, not the Socket, so this is the reliable hook.)
1384
+ if (self._flushOfflineQueue) self._flushOfflineQueue();
1353
1385
  });
1354
1386
  }
1355
1387
 
@@ -1359,9 +1391,7 @@ var Usion = (function () {
1359
1391
  self.socket.on('connect_error', function(err) {
1360
1392
  self._connecting = false;
1361
1393
  Usion.log('Game socket error: ' + err.message);
1362
- if (self._eventHandlers.connectionError) {
1363
- self._eventHandlers.connectionError(err);
1364
- }
1394
+ self._dispatch('connectionError', err);
1365
1395
  reject(err);
1366
1396
  });
1367
1397
 
@@ -1374,104 +1404,92 @@ var Usion = (function () {
1374
1404
  self._heartbeatInterval = null;
1375
1405
  }
1376
1406
  Usion.log('Game socket disconnected: ' + reason);
1377
- if (self._eventHandlers.disconnect) {
1378
- self._eventHandlers.disconnect(reason);
1379
- }
1407
+ self._dispatch('disconnect', reason);
1380
1408
  });
1381
1409
 
1382
1410
  self.socket.on('reconnect', function(attemptNumber) {
1383
1411
  Usion.log('Game socket reconnected after ' + attemptNumber + ' attempts');
1384
- if (self._eventHandlers.reconnect) {
1385
- self._eventHandlers.reconnect(attemptNumber);
1386
- }
1412
+ self._dispatch('reconnect', attemptNumber);
1387
1413
  });
1388
1414
 
1389
1415
  // Game event handlers
1390
1416
  self.socket.on('game:joined', function(data) {
1391
1417
  if (data.sequence !== undefined) {
1392
1418
  self._lastSequence = data.sequence;
1419
+ // Everything up to this sequence is reflected in the joined
1420
+ // state — actions at or below it must not be re-delivered.
1421
+ self._lastActionApplied = Math.max(self._lastActionApplied, data.sequence);
1393
1422
  }
1394
- if (self._eventHandlers.joined) {
1395
- self._eventHandlers.joined(data);
1396
- }
1423
+ self._dispatch('joined', data);
1397
1424
  });
1398
1425
 
1399
1426
  self.socket.on('game:player_joined', function(data) {
1400
- if (self._eventHandlers.playerJoined) {
1401
- self._eventHandlers.playerJoined(data);
1402
- }
1427
+ self._dispatch('playerJoined', data);
1403
1428
  });
1404
1429
 
1405
1430
  self.socket.on('game:player_left', function(data) {
1406
- if (self._eventHandlers.playerLeft) {
1407
- self._eventHandlers.playerLeft(data);
1408
- }
1431
+ self._dispatch('playerLeft', data);
1409
1432
  });
1410
1433
 
1411
1434
  self.socket.on('game:state', function(data) {
1412
1435
  if (data.sequence !== undefined) {
1413
1436
  self._lastSequence = Math.max(self._lastSequence, data.sequence);
1414
1437
  }
1415
- if (self._eventHandlers.stateUpdate) {
1416
- self._eventHandlers.stateUpdate(data);
1417
- }
1438
+ self._dispatch('stateUpdate', data);
1418
1439
  });
1419
1440
 
1420
1441
  self.socket.on('game:sync', function(data) {
1421
1442
  if (data.sequence !== undefined) {
1422
1443
  self._lastSequence = data.sequence;
1444
+ // The sync payload carries all actions up to this sequence; a
1445
+ // live echo of one of them must not be applied a second time.
1446
+ self._lastActionApplied = Math.max(self._lastActionApplied, data.sequence);
1423
1447
  }
1424
- if (self._eventHandlers.sync) {
1425
- self._eventHandlers.sync(data);
1426
- }
1448
+ self._dispatch('sync', data);
1427
1449
  // Also trigger stateUpdate for backwards compat
1428
- if (self._eventHandlers.stateUpdate) {
1429
- self._eventHandlers.stateUpdate(data);
1430
- }
1450
+ self._dispatch('stateUpdate', data);
1431
1451
  });
1432
1452
 
1433
1453
  self.socket.on('game:action', function(data) {
1434
1454
  if (data.sequence !== undefined) {
1455
+ // Drop duplicates: actions are delivered exactly once per
1456
+ // sequence, whether they arrive live, as the sender's own echo,
1457
+ // or replayed after a reconnect.
1458
+ if (data.sequence <= self._lastActionApplied) return;
1459
+ self._lastActionApplied = data.sequence;
1435
1460
  self._lastSequence = Math.max(self._lastSequence, data.sequence);
1436
1461
  }
1437
- if (self._eventHandlers.action) {
1438
- self._eventHandlers.action(data);
1439
- }
1462
+ self._dispatch('action', data);
1463
+ });
1464
+
1465
+ self.socket.on('game:player_connection', function(data) {
1466
+ self._dispatch('playerConnection', data);
1440
1467
  });
1441
1468
 
1442
1469
  self.socket.on('game:realtime', function(data) {
1443
- if (self._eventHandlers.realtime) {
1444
- self._eventHandlers.realtime(data);
1445
- }
1470
+ self._dispatch('realtime', data);
1446
1471
  });
1447
1472
 
1448
1473
  self.socket.on('game:finished', function(data) {
1449
1474
  if (data.sequence !== undefined) {
1450
1475
  self._lastSequence = data.sequence;
1451
1476
  }
1452
- if (self._eventHandlers.finished) {
1453
- self._eventHandlers.finished(data);
1454
- }
1477
+ self._dispatch('finished', data);
1455
1478
  });
1456
1479
 
1457
1480
  self.socket.on('game:error', function(data) {
1458
1481
  Usion.log('Game error: ' + (data.message || data.code));
1459
- if (self._eventHandlers.error) {
1460
- self._eventHandlers.error(data);
1461
- }
1482
+ self._dispatch('error', data);
1462
1483
  });
1463
1484
 
1464
1485
  self.socket.on('game:rematch_request', function(data) {
1465
- if (self._eventHandlers.rematchRequest) {
1466
- self._eventHandlers.rematchRequest(data);
1467
- }
1486
+ self._dispatch('rematchRequest', data);
1468
1487
  });
1469
1488
 
1470
1489
  self.socket.on('game:restarted', function(data) {
1471
1490
  self._lastSequence = 0; // Reset sequence on rematch
1472
- if (self._eventHandlers.restarted) {
1473
- self._eventHandlers.restarted(data);
1474
- }
1491
+ self._lastActionApplied = 0;
1492
+ self._dispatch('restarted', data);
1475
1493
  });
1476
1494
 
1477
1495
  } catch (err) {
@@ -1480,6 +1498,82 @@ var Usion = (function () {
1480
1498
  };
1481
1499
  }
1482
1500
 
1501
+ /**
1502
+ * Usion SDK Errors — stable, machine-readable error codes.
1503
+ *
1504
+ * Developers should branch on `err.code`, never on message text. Messages
1505
+ * are human-readable and may change; codes are part of the public API and
1506
+ * follow the deprecation policy (never removed within a major version).
1507
+ */
1508
+
1509
+ /** @type {Record<string, string>} */
1510
+ const ERROR_CODES = {
1511
+ NOT_CONNECTED: 'NOT_CONNECTED', // No live connection for this call
1512
+ NO_ROOM: 'NO_ROOM', // No room id provided/known
1513
+ ROOM_NOT_FOUND: 'ROOM_NOT_FOUND', // Room does not exist server-side
1514
+ NOT_PARTICIPANT: 'NOT_PARTICIPANT', // Caller is not a player in the room
1515
+ NOT_AUTHORITY: 'NOT_AUTHORITY', // Authority-only call (e.g. setState)
1516
+ NOT_AUTHENTICATED: 'NOT_AUTHENTICATED', // Missing/invalid auth server-side
1517
+ JOIN_TIMEOUT: 'JOIN_TIMEOUT', // Join did not complete in time
1518
+ CONNECT_TIMEOUT: 'CONNECT_TIMEOUT', // Connect did not complete in time
1519
+ STATE_TOO_LARGE: 'STATE_TOO_LARGE', // setState payload over the quota
1520
+ INVALID_STATE: 'INVALID_STATE', // setState payload not a JSON object
1521
+ INVALID_NEXT_TURN: 'INVALID_NEXT_TURN', // nextTurn is not a player in the room
1522
+ RATE_LIMITED: 'RATE_LIMITED', // Too many calls; back off
1523
+ REQUEST_TIMEOUT: 'REQUEST_TIMEOUT', // Host/parent did not reply in time
1524
+ QUEUE_FULL: 'QUEUE_FULL', // Offline action queue at capacity
1525
+ UNSUPPORTED: 'UNSUPPORTED', // Not available in this transport
1526
+ UNKNOWN: 'UNKNOWN', // Unmapped error (see message)
1527
+ };
1528
+
1529
+ class UsionError extends Error {
1530
+ /**
1531
+ * @param {string} code - One of ERROR_CODES
1532
+ * @param {string} [message] - Human-readable detail (may change between versions)
1533
+ */
1534
+ constructor(code, message) {
1535
+ super(message || code);
1536
+ this.name = 'UsionError';
1537
+ this.code = ERROR_CODES[code] ? code : ERROR_CODES.UNKNOWN;
1538
+ }
1539
+ }
1540
+
1541
+ // Backend error strings → stable codes. Order matters: first match wins.
1542
+ /** @type {Array<[RegExp, string]>} */
1543
+ const BACKEND_PATTERNS = [
1544
+ [/not authenticated/i, ERROR_CODES.NOT_AUTHENTICATED],
1545
+ [/room_id required|no room id/i, ERROR_CODES.NO_ROOM],
1546
+ [/room not found/i, ERROR_CODES.ROOM_NOT_FOUND],
1547
+ [/not a participant/i, ERROR_CODES.NOT_PARTICIPANT],
1548
+ [/room authority/i, ERROR_CODES.NOT_AUTHORITY],
1549
+ [/exceeds .*limit/i, ERROR_CODES.STATE_TOO_LARGE],
1550
+ [/state must be/i, ERROR_CODES.INVALID_STATE],
1551
+ [/next_turn must be/i, ERROR_CODES.INVALID_NEXT_TURN],
1552
+ [/rate limit|too many/i, ERROR_CODES.RATE_LIMITED],
1553
+ [/join timeout/i, ERROR_CODES.JOIN_TIMEOUT],
1554
+ [/connection timeout|connect timeout/i, ERROR_CODES.CONNECT_TIMEOUT],
1555
+ [/request timeout/i, ERROR_CODES.REQUEST_TIMEOUT],
1556
+ [/not connected/i, ERROR_CODES.NOT_CONNECTED],
1557
+ ];
1558
+
1559
+ /**
1560
+ * Normalize anything (backend `{error}` string, Error, raw string) into a
1561
+ * UsionError with the best-matching stable code.
1562
+ * @param {*} err
1563
+ * @param {string} [fallbackCode] - Code to use when nothing matches
1564
+ * @returns {UsionError}
1565
+ */
1566
+ function toUsionError(err, fallbackCode) {
1567
+ if (err instanceof UsionError) return err;
1568
+ const message = err && err.message ? err.message : String(err || 'Unknown error');
1569
+ for (let i = 0; i < BACKEND_PATTERNS.length; i++) {
1570
+ if (BACKEND_PATTERNS[i][0].test(message)) {
1571
+ return new UsionError(BACKEND_PATTERNS[i][1], message);
1572
+ }
1573
+ }
1574
+ return new UsionError(fallbackCode || ERROR_CODES.UNKNOWN, message);
1575
+ }
1576
+
1483
1577
  /**
1484
1578
  * Usion SDK Game Proxy — postMessage relay through parent app
1485
1579
  */
@@ -1523,7 +1617,7 @@ var Usion = (function () {
1523
1617
  setTimeout(function() {
1524
1618
  if (!self.connected) {
1525
1619
  self._connecting = false;
1526
- reject(new Error('Proxy connection timeout'));
1620
+ reject(new UsionError(ERROR_CODES.CONNECT_TIMEOUT, 'Proxy connection timeout'));
1527
1621
  }
1528
1622
  }, 10000);
1529
1623
  });
@@ -1564,69 +1658,97 @@ var Usion = (function () {
1564
1658
  self._connecting = false;
1565
1659
  break;
1566
1660
 
1661
+ case 'GAME_DISCONNECTED':
1662
+ // The host app's socket dropped; it will rejoin and resync for us.
1663
+ self.connected = false;
1664
+ self._dispatch('disconnect', data.reason || 'transport');
1665
+ break;
1666
+
1667
+ case 'GAME_RECONNECTED':
1668
+ self.connected = true;
1669
+ self._dispatch('reconnect', data.attempts || 1);
1670
+ break;
1671
+
1672
+ case 'GAME_PLAYER_CONNECTION':
1673
+ self._dispatch('playerConnection', data);
1674
+ break;
1675
+
1567
1676
  case 'GAME_JOINED':
1568
1677
  self._joined = true;
1569
- if (data.sequence !== undefined) self._lastSequence = data.sequence;
1678
+ if (data.sequence !== undefined) {
1679
+ self._lastSequence = data.sequence;
1680
+ self._lastActionApplied = Math.max(self._lastActionApplied, data.sequence);
1681
+ }
1570
1682
  if (self._proxyJoinResolve) {
1571
1683
  self._proxyJoinResolve(data);
1572
1684
  self._proxyJoinResolve = null;
1573
1685
  }
1574
- if (self._eventHandlers.joined) self._eventHandlers.joined(data);
1686
+ self._dispatch('joined', data);
1575
1687
  break;
1576
1688
 
1577
1689
  case 'GAME_JOIN_ERROR':
1578
1690
  self._joined = false;
1579
1691
  if (self._proxyJoinReject) {
1580
- self._proxyJoinReject(new Error(data.message || 'Join failed'));
1692
+ self._proxyJoinReject(toUsionError(data.message || 'Join failed'));
1581
1693
  self._proxyJoinReject = null;
1582
1694
  }
1583
1695
  break;
1584
1696
 
1585
1697
  case 'GAME_PLAYER_JOINED':
1586
- if (self._eventHandlers.playerJoined) self._eventHandlers.playerJoined(data);
1698
+ self._dispatch('playerJoined', data);
1587
1699
  break;
1588
1700
 
1589
1701
  case 'GAME_PLAYER_LEFT':
1590
- if (self._eventHandlers.playerLeft) self._eventHandlers.playerLeft(data);
1702
+ self._dispatch('playerLeft', data);
1591
1703
  break;
1592
1704
 
1593
1705
  case 'GAME_STATE':
1594
1706
  if (data.sequence !== undefined) self._lastSequence = Math.max(self._lastSequence, data.sequence);
1595
- if (self._eventHandlers.stateUpdate) self._eventHandlers.stateUpdate(data);
1707
+ self._dispatch('stateUpdate', data);
1596
1708
  break;
1597
1709
 
1598
1710
  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);
1711
+ if (data.sequence !== undefined) {
1712
+ // Drop duplicates: actions are delivered exactly once per
1713
+ // sequence (live, sender echo, or replay after reconnect).
1714
+ if (data.sequence <= self._lastActionApplied) break;
1715
+ self._lastActionApplied = data.sequence;
1716
+ self._lastSequence = Math.max(self._lastSequence, data.sequence);
1717
+ }
1718
+ self._dispatch('action', data);
1601
1719
  break;
1602
1720
 
1603
1721
  case 'GAME_REALTIME_DATA':
1604
- if (self._eventHandlers.realtime) self._eventHandlers.realtime(data);
1722
+ self._dispatch('realtime', data);
1605
1723
  break;
1606
1724
 
1607
1725
  case 'GAME_FINISHED':
1608
1726
  if (data.sequence !== undefined) self._lastSequence = data.sequence;
1609
- if (self._eventHandlers.finished) self._eventHandlers.finished(data);
1727
+ self._dispatch('finished', data);
1610
1728
  break;
1611
1729
 
1612
1730
  case 'GAME_ERROR':
1613
1731
  Usion.log('Game error via proxy: ' + (data.message || data.code));
1614
- if (self._eventHandlers.error) self._eventHandlers.error(data);
1732
+ self._dispatch('error', data);
1615
1733
  break;
1616
1734
 
1617
1735
  case 'GAME_RESTARTED':
1618
1736
  self._lastSequence = 0;
1619
- if (self._eventHandlers.restarted) self._eventHandlers.restarted(data);
1737
+ self._lastActionApplied = 0;
1738
+ self._dispatch('restarted', data);
1620
1739
  break;
1621
1740
 
1622
1741
  case 'GAME_REMATCH_REQUEST':
1623
- if (self._eventHandlers.rematchRequest) self._eventHandlers.rematchRequest(data);
1742
+ self._dispatch('rematchRequest', data);
1624
1743
  break;
1625
1744
 
1626
1745
  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);
1746
+ if (data.sequence !== undefined) {
1747
+ self._lastSequence = data.sequence;
1748
+ self._lastActionApplied = Math.max(self._lastActionApplied, data.sequence);
1749
+ }
1750
+ self._dispatch('sync', data);
1751
+ self._dispatch('stateUpdate', data);
1630
1752
  break;
1631
1753
  }
1632
1754
  });
@@ -1637,6 +1759,7 @@ var Usion = (function () {
1637
1759
  * Usion SDK Game Methods — join, leave, action, realtime, sync, etc.
1638
1760
  */
1639
1761
 
1762
+
1640
1763
  /**
1641
1764
  * Add game action methods to game module
1642
1765
  * @param {object} game - The game module object
@@ -1678,7 +1801,7 @@ var Usion = (function () {
1678
1801
  setTimeout(function() {
1679
1802
  if (!self._joined && self._proxyJoinReject) {
1680
1803
  self._proxyJoinReject = null;
1681
- reject(new Error('Join timeout'));
1804
+ reject(new UsionError(ERROR_CODES.JOIN_TIMEOUT, 'Join timeout'));
1682
1805
  }
1683
1806
  }, 15000);
1684
1807
  });
@@ -1687,19 +1810,19 @@ var Usion = (function () {
1687
1810
 
1688
1811
  self._joinPromise = new Promise(function(resolve, reject) {
1689
1812
  if (!self.socket || !self.connected) {
1690
- reject(new Error('Not connected'));
1813
+ reject(new UsionError(ERROR_CODES.NOT_CONNECTED, 'Not connected'));
1691
1814
  return;
1692
1815
  }
1693
1816
 
1694
1817
  if (!roomId) {
1695
- reject(new Error('No room ID provided'));
1818
+ reject(new UsionError(ERROR_CODES.NO_ROOM, 'No room ID provided'));
1696
1819
  return;
1697
1820
  }
1698
1821
 
1699
1822
  self.socket.emit('game:join', { room_id: roomId }, function(response) {
1700
1823
  if (response.error) {
1701
1824
  self._joined = false;
1702
- reject(new Error(response.message || response.error));
1825
+ reject(toUsionError(response.message || response.error));
1703
1826
  } else {
1704
1827
  self._joined = true;
1705
1828
  if (response.sequence !== undefined) {
@@ -1747,13 +1870,25 @@ var Usion = (function () {
1747
1870
  };
1748
1871
 
1749
1872
  /**
1750
- * Send a game action
1873
+ * Send a game action.
1874
+ *
1875
+ * RELIABILITY CONTRACT: the platform echoes every action back to the
1876
+ * sender (with the authoritative sequence number). Apply game state ONLY
1877
+ * in onAction — never optimistically on send — so every client applies
1878
+ * the same actions in the same order. The SDK deduplicates by sequence,
1879
+ * so an action is delivered exactly once even across reconnect replays.
1880
+ *
1751
1881
  * @param {string} actionType - Type of action (e.g., 'move')
1752
1882
  * @param {object} actionData - Action data
1883
+ * @param {object} [opts] - Options. opts.nextTurn: player ID whose turn
1884
+ * is next — the server remembers it and hands it to any (re)joining
1885
+ * client as current_turn, so turn state survives reconnects.
1753
1886
  * @returns {Promise} Resolves when action is processed
1754
1887
  */
1755
- game.action = function(actionType, actionData) {
1888
+ game.action = function(actionType, actionData, opts) {
1756
1889
  const self = this;
1890
+ const nextTurn = opts && opts.nextTurn;
1891
+ const queueOffline = !!(opts && opts.queueOffline);
1757
1892
 
1758
1893
  if (self.directMode) {
1759
1894
  self._sendDirect('action', {
@@ -1763,29 +1898,153 @@ var Usion = (function () {
1763
1898
  return Promise.resolve({ success: true });
1764
1899
  }
1765
1900
 
1901
+ // Opt-in offline queue: instead of failing while disconnected, hold
1902
+ // the move and send it (in order) once the connection recovers.
1903
+ // Turn-based games get "your move is saved and sends when you're
1904
+ // back" for free; realtime games should NOT use this (stale inputs).
1905
+ if (queueOffline && !self.connected) {
1906
+ return self._queueOfflineAction(actionType, actionData, opts);
1907
+ }
1908
+
1766
1909
  if (self._useProxy) {
1767
- Usion._post({ type: 'GAME_ACTION', room_id: self.roomId, action_type: actionType, action_data: actionData });
1910
+ const proxyMsg = { type: 'GAME_ACTION', room_id: self.roomId, action_type: actionType, action_data: actionData };
1911
+ if (nextTurn) proxyMsg.next_turn = nextTurn;
1912
+ Usion._post(proxyMsg);
1768
1913
  return Promise.resolve({ success: true });
1769
1914
  }
1770
1915
 
1916
+ // Gate sends while a post-reconnect rejoin is in flight, so a stale
1917
+ // move can't go out before the client has resynced.
1918
+ const gate = self._rejoinPromise || Promise.resolve();
1919
+ return gate.then(function() {
1920
+ return new Promise(function(resolve, reject) {
1921
+ if (!self.socket || !self.connected) {
1922
+ if (queueOffline) {
1923
+ self._queueOfflineAction(actionType, actionData, opts).then(resolve, reject);
1924
+ return;
1925
+ }
1926
+ reject(new UsionError(ERROR_CODES.NOT_CONNECTED, 'Not connected'));
1927
+ return;
1928
+ }
1929
+
1930
+ const payload = {
1931
+ room_id: self.roomId,
1932
+ action_type: actionType,
1933
+ action_data: actionData
1934
+ };
1935
+ if (nextTurn) payload.next_turn = nextTurn;
1936
+ self.socket.emit('game:action', payload, function(response) {
1937
+ if (response.error) {
1938
+ reject(toUsionError(response.message || response.error));
1939
+ } else {
1940
+ if (response.sequence !== undefined) {
1941
+ self._lastSequence = response.sequence;
1942
+ }
1943
+ resolve(response);
1944
+ }
1945
+ });
1946
+ });
1947
+ });
1948
+ };
1949
+
1950
+ // ── Offline action queue (opt-in via action(..., { queueOffline: true })) ──
1951
+
1952
+ const OFFLINE_QUEUE_MAX = 20;
1953
+
1954
+ /** @private Hold an action until the connection recovers. */
1955
+ game._queueOfflineAction = function(actionType, actionData, opts) {
1956
+ const self = this;
1957
+ if (!self._offlineQueue) self._offlineQueue = [];
1958
+ if (self._offlineQueue.length >= OFFLINE_QUEUE_MAX) {
1959
+ return Promise.reject(new UsionError(ERROR_CODES.QUEUE_FULL,
1960
+ 'Offline action queue is full (' + OFFLINE_QUEUE_MAX + ')'));
1961
+ }
1962
+ Usion.log('Queued action while offline: ' + actionType);
1963
+ return new Promise(function(resolve, reject) {
1964
+ self._offlineQueue.push({
1965
+ actionType: actionType,
1966
+ actionData: actionData,
1967
+ opts: Object.assign({}, opts, { queueOffline: false }),
1968
+ resolve: resolve,
1969
+ reject: reject,
1970
+ });
1971
+ self._ensureOfflineFlushHook();
1972
+ });
1973
+ };
1974
+
1975
+ /** @private Flush queued actions, in order, once reconnected. */
1976
+ game._ensureOfflineFlushHook = function() {
1977
+ const self = this;
1978
+ if (self._offlineFlushHooked) return;
1979
+ self._offlineFlushHooked = true;
1980
+ self.on('reconnect', function() { self._flushOfflineQueue(); });
1981
+ };
1982
+
1983
+ /** @private */
1984
+ game._flushOfflineQueue = function() {
1985
+ const self = this;
1986
+ const queue = self._offlineQueue;
1987
+ if (!queue || !queue.length) return;
1988
+ self._offlineQueue = [];
1989
+ Usion.log('Flushing ' + queue.length + ' queued action(s) after reconnect');
1990
+ // Send strictly in order: each action waits for the previous ack so
1991
+ // the server assigns sequences in the order the player acted.
1992
+ let chain = Promise.resolve();
1993
+ queue.forEach(function(item) {
1994
+ chain = chain.then(function() {
1995
+ return self.action(item.actionType, item.actionData, item.opts)
1996
+ .then(item.resolve, item.reject);
1997
+ });
1998
+ });
1999
+ };
2000
+
2001
+ /**
2002
+ * Checkpoint the authoritative game state on the server. Any client that
2003
+ * joins or rejoins the room receives the latest checkpoint as game_state
2004
+ * in the join ack and in game:sync — recovery becomes "load checkpoint,
2005
+ * replay the tail" instead of replaying every action from zero.
2006
+ *
2007
+ * Only the room authority (player_ids[0] / host) may call this. The
2008
+ * serialized state is capped at 64 KB.
2009
+ *
2010
+ * @param {*} state - JSON-serializable authoritative game state
2011
+ * @returns {Promise<{success: boolean, error?: string}>}
2012
+ */
2013
+ game.setState = function(state) {
2014
+ const self = this;
2015
+
2016
+ if (self.directMode) {
2017
+ // Direct-mode game servers own their state; no platform checkpoint.
2018
+ return Promise.resolve({ success: false, error: 'not_supported_in_direct_mode', code: ERROR_CODES.UNSUPPORTED });
2019
+ }
2020
+
2021
+ if (self._useProxy) {
2022
+ return Usion._request('GAME_SET_STATE', { room_id: self.roomId, state: state || {} })
2023
+ .then(function(res) {
2024
+ if (res && res.error) {
2025
+ return { success: false, error: res.error, code: toUsionError(res.error).code };
2026
+ }
2027
+ return res || { success: true };
2028
+ })
2029
+ .catch(function(err) {
2030
+ const ue = toUsionError(err, ERROR_CODES.REQUEST_TIMEOUT);
2031
+ return { success: false, error: ue.message, code: ue.code };
2032
+ });
2033
+ }
2034
+
1771
2035
  return new Promise(function(resolve, reject) {
1772
2036
  if (!self.socket || !self.connected) {
1773
- reject(new Error('Not connected'));
2037
+ reject(new UsionError(ERROR_CODES.NOT_CONNECTED, 'Not connected'));
1774
2038
  return;
1775
2039
  }
1776
-
1777
- self.socket.emit('game:action', {
2040
+ self.socket.emit('game:set_state', {
1778
2041
  room_id: self.roomId,
1779
- action_type: actionType,
1780
- action_data: actionData
2042
+ state: state || {}
1781
2043
  }, function(response) {
1782
- if (response.error) {
1783
- reject(new Error(response.message || response.error));
2044
+ if (response && response.error) {
2045
+ resolve({ success: false, error: response.error, code: toUsionError(response.error).code });
1784
2046
  } else {
1785
- if (response.sequence !== undefined) {
1786
- self._lastSequence = response.sequence;
1787
- }
1788
- resolve(response);
2047
+ resolve(response || { success: true });
1789
2048
  }
1790
2049
  });
1791
2050
  });
@@ -1889,13 +2148,13 @@ var Usion = (function () {
1889
2148
 
1890
2149
  return new Promise(function(resolve, reject) {
1891
2150
  if (!self.socket || !self.connected) {
1892
- reject(new Error('Not connected'));
2151
+ reject(new UsionError(ERROR_CODES.NOT_CONNECTED, 'Not connected'));
1893
2152
  return;
1894
2153
  }
1895
2154
 
1896
2155
  self.socket.emit('game:forfeit', { room_id: self.roomId }, function(response) {
1897
2156
  if (response.error) {
1898
- reject(new Error(response.message || response.error));
2157
+ reject(toUsionError(response.message || response.error));
1899
2158
  } else {
1900
2159
  resolve(response);
1901
2160
  }
@@ -2057,7 +2316,9 @@ var Usion = (function () {
2057
2316
  var inFrame = window.parent && window.parent !== window;
2058
2317
  var inRNWebView = !!window.ReactNativeWebView;
2059
2318
  if (inFrame || inRNWebView) {
2060
- Usion._post({ type: 'GAME_DEBUG', payload: payload || {} });
2319
+ var body = Object.assign({}, payload || {});
2320
+ body._diag = Usion.diagnostics ? Usion.diagnostics() : undefined;
2321
+ Usion._post({ type: 'GAME_DEBUG', payload: body });
2061
2322
  }
2062
2323
  } catch (e) { /* non-fatal */ }
2063
2324
  };
@@ -4063,6 +4324,36 @@ var Usion = (function () {
4063
4324
  */
4064
4325
 
4065
4326
 
4327
+ // Map any reasonable spelling of a game event onto the internal handler
4328
+ // name: 'game:player_joined' / 'player_joined' / 'playerJoined' → 'playerJoined'.
4329
+ const _EVENT_ALIASES = {
4330
+ joined: 'joined',
4331
+ player_joined: 'playerJoined',
4332
+ player_left: 'playerLeft',
4333
+ player_connection: 'playerConnection',
4334
+ state: 'stateUpdate',
4335
+ state_update: 'stateUpdate',
4336
+ sync: 'sync',
4337
+ action: 'action',
4338
+ realtime: 'realtime',
4339
+ finished: 'finished',
4340
+ restarted: 'restarted',
4341
+ error: 'error',
4342
+ rematch_request: 'rematchRequest',
4343
+ disconnect: 'disconnect',
4344
+ reconnect: 'reconnect',
4345
+ connection_error: 'connectionError',
4346
+ };
4347
+
4348
+ function _normalizeEventName(event) {
4349
+ let name = String(event || '');
4350
+ if (name.indexOf('game:') === 0) name = name.slice(5);
4351
+ if (_EVENT_ALIASES[name]) return _EVENT_ALIASES[name];
4352
+ // camelCase passthrough ('playerJoined', 'stateUpdate', …)
4353
+ const snake = name.replace(/([A-Z])/g, function(m) { return '_' + m.toLowerCase(); });
4354
+ return _EVENT_ALIASES[snake] || name;
4355
+ }
4356
+
4066
4357
  /**
4067
4358
  * Create the game module with all sub-modules applied
4068
4359
  * @param {object} Usion - Reference to the main Usion object
@@ -4088,6 +4379,11 @@ var Usion = (function () {
4088
4379
  _heartbeatInterval: null,
4089
4380
  _pingMeter: null,
4090
4381
  _pongWaiters: [],
4382
+ // Reliability state: highest action sequence already delivered to the
4383
+ // game (duplicate echoes/replays are dropped), and the in-flight rejoin
4384
+ // promise that gates action() sends right after a reconnect.
4385
+ _lastActionApplied: 0,
4386
+ _rejoinPromise: null,
4091
4387
 
4092
4388
  /**
4093
4389
  * Connect to the game socket server
@@ -4170,34 +4466,85 @@ var Usion = (function () {
4170
4466
  return self._connectPromise;
4171
4467
  },
4172
4468
 
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; },
4469
+ // Event handler registrations.
4470
+ //
4471
+ // Each onX(cb) keeps the long-standing "single handler, last one wins"
4472
+ // behavior for back-compat, but now ALSO returns an unsubscribe
4473
+ // function. For multiple listeners use game.on(event, cb), which
4474
+ // supports any number of listeners, works before connect() in every
4475
+ // transport, and returns an unsubscribe function.
4476
+ onJoined: function(callback) { return this._setHandler('joined', callback); },
4477
+ onPlayerJoined: function(callback) { return this._setHandler('playerJoined', callback); },
4478
+ onPlayerLeft: function(callback) { return this._setHandler('playerLeft', callback); },
4479
+ onStateUpdate: function(callback) { return this._setHandler('stateUpdate', callback); },
4480
+ onSync: function(callback) { return this._setHandler('sync', callback); },
4481
+ onAction: function(callback) { return this._setHandler('action', callback); },
4482
+ onRealtime: function(callback) { return this._setHandler('realtime', callback); },
4483
+ onGameFinished: function(callback) { return this._setHandler('finished', callback); },
4484
+ onGameRestarted: function(callback) { return this._setHandler('restarted', callback); },
4485
+ onError: function(callback) { return this._setHandler('error', callback); },
4486
+ onRematchRequest: function(callback) { return this._setHandler('rematchRequest', callback); },
4487
+ onDisconnect: function(callback) { return this._setHandler('disconnect', callback); },
4488
+ onReconnect: function(callback) { return this._setHandler('reconnect', callback); },
4489
+ onConnectionError: function(callback) { return this._setHandler('connectionError', callback); },
4490
+ onPlayerConnection: function(callback) { return this._setHandler('playerConnection', callback); },
4491
+
4492
+ /** @private Set the single legacy handler; returns an unsubscribe fn. */
4493
+ _setHandler: function(name, callback) {
4494
+ const self = this;
4495
+ self._eventHandlers[name] = callback;
4496
+ return function() {
4497
+ if (self._eventHandlers[name] === callback) {
4498
+ self._eventHandlers[name] = null;
4499
+ }
4500
+ };
4501
+ },
4188
4502
 
4189
4503
  /**
4190
- * Register a generic event handler
4504
+ * @private Deliver an event to the legacy single handler and every
4505
+ * game.on() listener. All game event delivery flows through here.
4506
+ */
4507
+ _dispatch: function(name) {
4508
+ const args = Array.prototype.slice.call(arguments, 1);
4509
+ const single = this._eventHandlers[name];
4510
+ if (single) {
4511
+ try { single.apply(null, args); } catch (e) { Usion.log('game handler error (' + name + '): ' + e.message); }
4512
+ }
4513
+ const list = this._listeners[name];
4514
+ if (list && list.length) {
4515
+ const copy = list.slice();
4516
+ for (let i = 0; i < copy.length; i++) {
4517
+ try { copy[i].apply(null, args); } catch (e) { Usion.log('game listener error (' + name + '): ' + e.message); }
4518
+ }
4519
+ }
4520
+ },
4521
+
4522
+ /**
4523
+ * Register an additional event listener. Unlike the onX methods this
4524
+ * supports multiple listeners per event, can be called before
4525
+ * connect(), and works in every transport (standalone, embedded
4526
+ * proxy, direct). Accepts internal names ('action'), wire names
4527
+ * ('game:action'), or snake_case ('player_joined').
4191
4528
  * @param {string} event - Event name
4192
4529
  * @param {function} callback - Handler function
4530
+ * @returns {function} Unsubscribe function
4193
4531
  */
4194
4532
  on: function(event, callback) {
4195
- if (this.socket) {
4196
- this.socket.on(event, callback);
4197
- }
4533
+ const self = this;
4534
+ const name = _normalizeEventName(event);
4535
+ if (!self._listeners[name]) self._listeners[name] = [];
4536
+ self._listeners[name].push(callback);
4537
+ return function() {
4538
+ const list = self._listeners[name];
4539
+ if (!list) return;
4540
+ const i = list.indexOf(callback);
4541
+ if (i >= 0) list.splice(i, 1);
4542
+ };
4198
4543
  }
4199
4544
  };
4200
4545
 
4546
+ game._listeners = {};
4547
+
4201
4548
  // Apply sub-modules
4202
4549
  applyGameDirect(game, Usion);
4203
4550
  applyGameSocket(game, Usion);
@@ -4616,6 +4963,9 @@ var Usion = (function () {
4616
4963
  Usion.bot = createBotModule(Usion);
4617
4964
  Usion.fileStorage = createFileStorageModule(Usion);
4618
4965
  Usion.game = createGameModule(Usion);
4966
+ // Stable error class + codes — developers branch on err.code, not message text.
4967
+ Usion.UsionError = UsionError;
4968
+ Usion.ERROR_CODES = ERROR_CODES;
4619
4969
  // Unified backend channel (used by lobby etc.; works standalone + embedded).
4620
4970
  applyBackendChannel(Usion);
4621
4971
  Usion.lobby = createLobbyModule(Usion);