@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/README.md +36 -0
- package/package.json +11 -4
- package/src/browser.js +560 -122
- package/src/modules/core.js +17 -1
- package/src/modules/errors.js +75 -0
- package/src/modules/game-core.js +105 -19
- package/src/modules/game-direct.js +12 -16
- package/src/modules/game-methods.js +161 -21
- package/src/modules/game-proxy.js +46 -17
- package/src/modules/game-socket.js +43 -47
- package/src/modules/index.js +6 -0
- package/src/modules/misc.js +20 -0
- package/src/modules/notify.js +70 -0
- package/src/modules/wallet.js +7 -1
- package/types/index.d.ts +166 -20
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1286
|
+
this._dispatch('joined', payload);
|
|
1249
1287
|
return;
|
|
1250
1288
|
}
|
|
1251
1289
|
if (data.type === 'player_joined') {
|
|
1252
|
-
|
|
1290
|
+
this._dispatch('playerJoined', payload);
|
|
1253
1291
|
return;
|
|
1254
1292
|
}
|
|
1255
1293
|
if (data.type === 'player_left') {
|
|
1256
|
-
|
|
1294
|
+
this._dispatch('playerLeft', payload);
|
|
1257
1295
|
return;
|
|
1258
1296
|
}
|
|
1259
1297
|
if (data.type === 'state_snapshot' || data.type === 'state_delta') {
|
|
1260
|
-
|
|
1261
|
-
|
|
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
|
-
|
|
1308
|
+
this._dispatch('sync', payload);
|
|
1271
1309
|
return;
|
|
1272
1310
|
}
|
|
1273
1311
|
if (data.type === 'match_end') {
|
|
1274
|
-
|
|
1312
|
+
this._dispatch('finished', payload);
|
|
1275
1313
|
return;
|
|
1276
1314
|
}
|
|
1277
|
-
if (data.type === 'error'
|
|
1278
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1425
|
-
self._eventHandlers.sync(data);
|
|
1426
|
-
}
|
|
1464
|
+
self._dispatch('sync', data);
|
|
1427
1465
|
// Also trigger stateUpdate for backwards compat
|
|
1428
|
-
|
|
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
|
-
|
|
1438
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1473
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1714
|
+
self._dispatch('playerJoined', data);
|
|
1587
1715
|
break;
|
|
1588
1716
|
|
|
1589
1717
|
case 'GAME_PLAYER_LEFT':
|
|
1590
|
-
|
|
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
|
-
|
|
1723
|
+
self._dispatch('stateUpdate', data);
|
|
1596
1724
|
break;
|
|
1597
1725
|
|
|
1598
1726
|
case 'GAME_ACTION_DATA':
|
|
1599
|
-
if (data.sequence !== undefined)
|
|
1600
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1748
|
+
self._dispatch('error', data);
|
|
1615
1749
|
break;
|
|
1616
1750
|
|
|
1617
1751
|
case 'GAME_RESTARTED':
|
|
1618
1752
|
self._lastSequence = 0;
|
|
1619
|
-
|
|
1753
|
+
self._lastActionApplied = 0;
|
|
1754
|
+
self._dispatch('restarted', data);
|
|
1620
1755
|
break;
|
|
1621
1756
|
|
|
1622
1757
|
case 'GAME_REMATCH_REQUEST':
|
|
1623
|
-
|
|
1758
|
+
self._dispatch('rematchRequest', data);
|
|
1624
1759
|
break;
|
|
1625
1760
|
|
|
1626
1761
|
case 'GAME_SYNC':
|
|
1627
|
-
if (data.sequence !== undefined)
|
|
1628
|
-
|
|
1629
|
-
|
|
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
|
|
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
|
|
1829
|
+
reject(new UsionError(ERROR_CODES.NOT_CONNECTED, 'Not connected'));
|
|
1691
1830
|
return;
|
|
1692
1831
|
}
|
|
1693
1832
|
|
|
1694
1833
|
if (!roomId) {
|
|
1695
|
-
reject(new
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1780
|
-
action_data: actionData
|
|
2058
|
+
state: state || {}
|
|
1781
2059
|
}, function(response) {
|
|
1782
|
-
if (response.error) {
|
|
1783
|
-
|
|
2060
|
+
if (response && response.error) {
|
|
2061
|
+
resolve({ success: false, error: response.error, code: toUsionError(response.error).code });
|
|
1784
2062
|
} else {
|
|
1785
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
4196
|
-
|
|
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;
|