@usions/sdk 2.20.2 → 2.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/browser.js +716 -163
- package/src/modules/backend-channel.js +41 -8
- package/src/modules/core.js +59 -8
- package/src/modules/errors.js +47 -4
- package/src/modules/game-core.js +27 -17
- package/src/modules/game-methods.js +98 -29
- package/src/modules/game-reliability.js +278 -0
- package/src/modules/game-socket.js +24 -7
- package/src/modules/matchmaking.js +53 -11
- package/src/modules/misc.js +7 -2
- package/src/modules/wallet.js +12 -5
- package/types/index.d.ts +123 -6
package/src/browser.js
CHANGED
|
@@ -1,10 +1,130 @@
|
|
|
1
1
|
var Usion = (function () {
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Usion SDK Errors — stable, machine-readable error codes.
|
|
6
|
+
*
|
|
7
|
+
* Developers should branch on `err.code`, never on message text. Messages
|
|
8
|
+
* are human-readable and may change; codes are part of the public API and
|
|
9
|
+
* follow the deprecation policy (never removed within a major version).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** @type {Record<string, string>} */
|
|
13
|
+
const ERROR_CODES = {
|
|
14
|
+
NOT_CONNECTED: 'NOT_CONNECTED', // No live connection for this call
|
|
15
|
+
NO_ROOM: 'NO_ROOM', // No room id provided/known
|
|
16
|
+
ROOM_NOT_FOUND: 'ROOM_NOT_FOUND', // Room does not exist server-side
|
|
17
|
+
NOT_PARTICIPANT: 'NOT_PARTICIPANT', // Caller is not a player in the room
|
|
18
|
+
NOT_IN_ROOM: 'NOT_IN_ROOM', // Socket not in the room — rejoin required
|
|
19
|
+
NOT_AUTHORITY: 'NOT_AUTHORITY', // Authority-only call (e.g. lobby start)
|
|
20
|
+
NOT_AUTHENTICATED: 'NOT_AUTHENTICATED', // Missing/invalid auth server-side
|
|
21
|
+
JOIN_TIMEOUT: 'JOIN_TIMEOUT', // Join did not complete in time
|
|
22
|
+
CONNECT_TIMEOUT: 'CONNECT_TIMEOUT', // Connect did not complete in time
|
|
23
|
+
INIT_TIMEOUT: 'INIT_TIMEOUT', // init() got no INIT from the host in time
|
|
24
|
+
MATCH_TIMEOUT: 'MATCH_TIMEOUT', // matchmaking.find() opts.timeout elapsed
|
|
25
|
+
STATE_TOO_LARGE: 'STATE_TOO_LARGE', // setState payload over the quota
|
|
26
|
+
INVALID_STATE: 'INVALID_STATE', // setState payload not a JSON object
|
|
27
|
+
STALE_STATE: 'STALE_STATE', // setState older than the stored checkpoint
|
|
28
|
+
INVALID_NEXT_TURN: 'INVALID_NEXT_TURN', // nextTurn is not a player in the room
|
|
29
|
+
INVALID_INPUT: 'INVALID_INPUT', // Missing/malformed argument server-side
|
|
30
|
+
NOT_FOUND: 'NOT_FOUND', // Referenced entity (service, lobby, …) missing
|
|
31
|
+
QUOTA_EXCEEDED: 'QUOTA_EXCEEDED', // Bucket key/size quota reached (cloud KV)
|
|
32
|
+
VALUE_TOO_LARGE: 'VALUE_TOO_LARGE', // Single value over the per-value quota
|
|
33
|
+
LOBBY_FULL: 'LOBBY_FULL', // Lobby at max_players
|
|
34
|
+
LOBBY_CLOSED: 'LOBBY_CLOSED', // Lobby exists but is not open
|
|
35
|
+
CONFLICT: 'CONFLICT', // Concurrent-write conflict; retry
|
|
36
|
+
RATE_LIMITED: 'RATE_LIMITED', // Too many calls; back off (see retryAfter)
|
|
37
|
+
REQUEST_TIMEOUT: 'REQUEST_TIMEOUT', // Host/parent did not reply in time
|
|
38
|
+
QUEUE_FULL: 'QUEUE_FULL', // Offline action queue at capacity
|
|
39
|
+
CANCELLED: 'CANCELLED', // Cancelled by a local cancel() call
|
|
40
|
+
SUPERSEDED: 'SUPERSEDED', // Replaced by a newer identical request
|
|
41
|
+
UNSUPPORTED: 'UNSUPPORTED', // Not available in this transport
|
|
42
|
+
UNKNOWN: 'UNKNOWN', // Unmapped error (see message)
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
class UsionError extends Error {
|
|
46
|
+
/**
|
|
47
|
+
* @param {string} code - One of ERROR_CODES
|
|
48
|
+
* @param {string} [message] - Human-readable detail (may change between versions)
|
|
49
|
+
*/
|
|
50
|
+
constructor(code, message) {
|
|
51
|
+
super(message || code);
|
|
52
|
+
this.name = 'UsionError';
|
|
53
|
+
this.code = ERROR_CODES[code] ? code : ERROR_CODES.UNKNOWN;
|
|
54
|
+
/** @type {number|undefined} Seconds until a RATE_LIMITED call may retry. */
|
|
55
|
+
this.retryAfter = undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Backend error strings → stable codes. Order matters: first match wins.
|
|
60
|
+
// Fallback path only: the backend now sends a structured `code` on every
|
|
61
|
+
// error ack, which toUsionError prefers. These patterns cover old backends
|
|
62
|
+
// and hosts that strip the code.
|
|
63
|
+
/** @type {Array<[RegExp, string]>} */
|
|
64
|
+
const BACKEND_PATTERNS = [
|
|
65
|
+
[/not authenticated/i, ERROR_CODES.NOT_AUTHENTICATED],
|
|
66
|
+
[/room_id required|no room id/i, ERROR_CODES.NO_ROOM],
|
|
67
|
+
[/room not found/i, ERROR_CODES.ROOM_NOT_FOUND],
|
|
68
|
+
[/not in room/i, ERROR_CODES.NOT_IN_ROOM],
|
|
69
|
+
[/not a participant/i, ERROR_CODES.NOT_PARTICIPANT],
|
|
70
|
+
[/room authority/i, ERROR_CODES.NOT_AUTHORITY],
|
|
71
|
+
[/exceeds .*limit/i, ERROR_CODES.STATE_TOO_LARGE],
|
|
72
|
+
[/stale checkpoint|stale state/i, ERROR_CODES.STALE_STATE],
|
|
73
|
+
[/state must be/i, ERROR_CODES.INVALID_STATE],
|
|
74
|
+
[/next_turn must be/i, ERROR_CODES.INVALID_NEXT_TURN],
|
|
75
|
+
[/rate limit|too many/i, ERROR_CODES.RATE_LIMITED],
|
|
76
|
+
[/join timeout/i, ERROR_CODES.JOIN_TIMEOUT],
|
|
77
|
+
[/connection timeout|connect timeout/i, ERROR_CODES.CONNECT_TIMEOUT],
|
|
78
|
+
[/request timeout/i, ERROR_CODES.REQUEST_TIMEOUT],
|
|
79
|
+
[/not connected/i, ERROR_CODES.NOT_CONNECTED],
|
|
80
|
+
[/quota exceeded/i, ERROR_CODES.QUOTA_EXCEEDED],
|
|
81
|
+
[/value too large/i, ERROR_CODES.VALUE_TOO_LARGE],
|
|
82
|
+
[/lobby is full/i, ERROR_CODES.LOBBY_FULL],
|
|
83
|
+
[/lobby is not open/i, ERROR_CODES.LOBBY_CLOSED],
|
|
84
|
+
[/not found|unknown service/i, ERROR_CODES.NOT_FOUND],
|
|
85
|
+
[/conflict/i, ERROR_CODES.CONFLICT],
|
|
86
|
+
[/cancelled/i, ERROR_CODES.CANCELLED],
|
|
87
|
+
[/superseded/i, ERROR_CODES.SUPERSEDED],
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Normalize anything — a structured backend ack ({ error, code?, retry_after? }),
|
|
92
|
+
* an Error, or a raw string — into a UsionError with the best stable code.
|
|
93
|
+
* A machine-readable `code` on the input always wins over message matching.
|
|
94
|
+
* @param {*} err
|
|
95
|
+
* @param {string} [fallbackCode] - Code to use when nothing matches
|
|
96
|
+
* @returns {UsionError}
|
|
97
|
+
*/
|
|
98
|
+
function toUsionError(err, fallbackCode) {
|
|
99
|
+
if (err instanceof UsionError) return err;
|
|
100
|
+
|
|
101
|
+
// Structured input (backend ack object or an Error carrying a code).
|
|
102
|
+
if (err && typeof err === 'object') {
|
|
103
|
+
const code = typeof err.code === 'string' && ERROR_CODES[err.code] ? err.code : null;
|
|
104
|
+
const message = err.message || err.error;
|
|
105
|
+
if (code) {
|
|
106
|
+
const ue = new UsionError(code, typeof message === 'string' ? message : code);
|
|
107
|
+
const retryAfter = err.retry_after != null ? err.retry_after : err.retryAfter;
|
|
108
|
+
if (typeof retryAfter === 'number' && isFinite(retryAfter)) ue.retryAfter = retryAfter;
|
|
109
|
+
return ue;
|
|
110
|
+
}
|
|
111
|
+
if (!(err instanceof Error) && typeof message === 'string') err = message;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const message = err && err.message ? err.message : String(err || 'Unknown error');
|
|
115
|
+
for (let i = 0; i < BACKEND_PATTERNS.length; i++) {
|
|
116
|
+
if (BACKEND_PATTERNS[i][0].test(message)) {
|
|
117
|
+
return new UsionError(BACKEND_PATTERNS[i][1], message);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return new UsionError(fallbackCode || ERROR_CODES.UNKNOWN, message);
|
|
121
|
+
}
|
|
122
|
+
|
|
4
123
|
/**
|
|
5
124
|
* Usion SDK Core — init, _post, _request, message handling
|
|
6
125
|
*/
|
|
7
126
|
|
|
127
|
+
|
|
8
128
|
// Request ID counter for tracking async responses
|
|
9
129
|
let _requestId = 0;
|
|
10
130
|
const _pendingRequests = {};
|
|
@@ -69,7 +189,7 @@ var Usion = (function () {
|
|
|
69
189
|
* Core Usion object with init, _post, _request
|
|
70
190
|
*/
|
|
71
191
|
const core = {
|
|
72
|
-
version: '2.
|
|
192
|
+
version: '2.22.0', // injected from package.json at build
|
|
73
193
|
config: {},
|
|
74
194
|
_initialized: false,
|
|
75
195
|
_initCallback: null,
|
|
@@ -78,24 +198,60 @@ var Usion = (function () {
|
|
|
78
198
|
_backButtonCallback: null,
|
|
79
199
|
|
|
80
200
|
/**
|
|
81
|
-
* Initialize the SDK with config from parent app
|
|
82
|
-
*
|
|
201
|
+
* Initialize the SDK with config from parent app.
|
|
202
|
+
*
|
|
203
|
+
* Callback form (unchanged): `Usion.init(cb)` — cb(config) fires when the
|
|
204
|
+
* host's INIT arrives. Promise form: every call ALSO returns a
|
|
205
|
+
* Promise<config>, so `const config = await Usion.init()` works.
|
|
206
|
+
*
|
|
207
|
+
* `opts.timeout` (ms, optional) rejects the promise with
|
|
208
|
+
* UsionError(INIT_TIMEOUT) if no INIT arrives in time — the "embedded but
|
|
209
|
+
* host silent" case that otherwise hangs forever. The callback still fires
|
|
210
|
+
* if INIT arrives late. Both `init(cb, opts)` and `init(opts)` are accepted.
|
|
211
|
+
*
|
|
212
|
+
* @param {function|{timeout?: number}} [callback] - Called with config when ready
|
|
213
|
+
* @param {{timeout?: number}} [opts]
|
|
214
|
+
* @returns {Promise<object>} Resolves with config
|
|
83
215
|
*/
|
|
84
|
-
init: function(callback) {
|
|
216
|
+
init: function(callback, opts) {
|
|
85
217
|
const self = this;
|
|
218
|
+
if (callback && typeof callback === 'object') {
|
|
219
|
+
opts = callback;
|
|
220
|
+
callback = null;
|
|
221
|
+
}
|
|
222
|
+
opts = opts || {};
|
|
86
223
|
|
|
87
224
|
// Prevent double initialization - just update callback
|
|
88
225
|
if (self._initialized) {
|
|
89
226
|
if (callback) callback(self.config);
|
|
90
|
-
return;
|
|
227
|
+
return Promise.resolve(self.config);
|
|
91
228
|
}
|
|
92
229
|
|
|
93
230
|
// Store callback for when config arrives
|
|
94
|
-
self._initCallback = callback;
|
|
231
|
+
if (callback) self._initCallback = callback;
|
|
232
|
+
|
|
233
|
+
// One shared promise for every init() call before INIT arrives.
|
|
234
|
+
if (!self._initPromise) {
|
|
235
|
+
self._initPromise = new Promise(function(resolve, reject) {
|
|
236
|
+
self._initResolve = resolve;
|
|
237
|
+
self._initReject = reject;
|
|
238
|
+
});
|
|
239
|
+
// Callback-style users never touch the promise — don't let an
|
|
240
|
+
// INIT_TIMEOUT rejection surface as an unhandled-rejection error.
|
|
241
|
+
self._initPromise.catch(function() {});
|
|
242
|
+
}
|
|
243
|
+
if (opts.timeout > 0 && !self._initTimer) {
|
|
244
|
+
self._initTimer = setTimeout(function() {
|
|
245
|
+
self._initTimer = null;
|
|
246
|
+
if (self._initialized || !self._initReject) return;
|
|
247
|
+
self._initReject(new UsionError(ERROR_CODES.INIT_TIMEOUT,
|
|
248
|
+
'No INIT from host within ' + opts.timeout + 'ms — host silent or running standalone'));
|
|
249
|
+
}, opts.timeout);
|
|
250
|
+
}
|
|
95
251
|
|
|
96
252
|
// Only register message handler once
|
|
97
253
|
if (self._messageHandlerRegistered) {
|
|
98
|
-
return;
|
|
254
|
+
return self._initPromise;
|
|
99
255
|
}
|
|
100
256
|
self._messageHandlerRegistered = true;
|
|
101
257
|
|
|
@@ -151,6 +307,15 @@ var Usion = (function () {
|
|
|
151
307
|
if (self._initCallback) {
|
|
152
308
|
self._initCallback(data.config);
|
|
153
309
|
}
|
|
310
|
+
|
|
311
|
+
// Settle the promise form
|
|
312
|
+
if (self._initTimer) {
|
|
313
|
+
clearTimeout(self._initTimer);
|
|
314
|
+
self._initTimer = null;
|
|
315
|
+
}
|
|
316
|
+
if (self._initResolve) {
|
|
317
|
+
self._initResolve(data.config);
|
|
318
|
+
}
|
|
154
319
|
}
|
|
155
320
|
|
|
156
321
|
// Handle response messages for async requests
|
|
@@ -159,7 +324,9 @@ var Usion = (function () {
|
|
|
159
324
|
delete _pendingRequests[data._requestId];
|
|
160
325
|
|
|
161
326
|
if (data.error) {
|
|
162
|
-
|
|
327
|
+
// Preserve the backend's stable code/retry_after when the host
|
|
328
|
+
// relayed them (see errors.js).
|
|
329
|
+
reject(toUsionError(data));
|
|
163
330
|
} else {
|
|
164
331
|
resolve(data);
|
|
165
332
|
}
|
|
@@ -218,6 +385,8 @@ var Usion = (function () {
|
|
|
218
385
|
|
|
219
386
|
// Signal ready to parent
|
|
220
387
|
this._post({ type: 'READY' });
|
|
388
|
+
|
|
389
|
+
return self._initPromise;
|
|
221
390
|
},
|
|
222
391
|
|
|
223
392
|
/**
|
|
@@ -339,7 +508,7 @@ var Usion = (function () {
|
|
|
339
508
|
// Setup timeout
|
|
340
509
|
const timer = setTimeout(function() {
|
|
341
510
|
delete _pendingRequests[requestId];
|
|
342
|
-
reject(new
|
|
511
|
+
reject(new UsionError(ERROR_CODES.REQUEST_TIMEOUT, 'Request timeout'));
|
|
343
512
|
}, timeout);
|
|
344
513
|
|
|
345
514
|
// Store pending request
|
|
@@ -513,13 +682,17 @@ var Usion = (function () {
|
|
|
513
682
|
|
|
514
683
|
/**
|
|
515
684
|
* Get current wallet balance
|
|
685
|
+
* @param {object} [opts]
|
|
686
|
+
* @param {boolean} [opts.fresh] - Bypass the cache and re-query the host.
|
|
687
|
+
* Use after out-of-band balance changes (e.g. a server-side settle).
|
|
516
688
|
* @returns {Promise<number>} Balance in credits
|
|
517
689
|
*/
|
|
518
|
-
getBalance: function() {
|
|
690
|
+
getBalance: function(opts) {
|
|
519
691
|
const self = this;
|
|
692
|
+
const fresh = !!(opts && opts.fresh);
|
|
520
693
|
|
|
521
694
|
// If we have cached balance, return it
|
|
522
|
-
if (self._balance !== null) {
|
|
695
|
+
if (!fresh && self._balance !== null) {
|
|
523
696
|
return Promise.resolve(self._balance);
|
|
524
697
|
}
|
|
525
698
|
|
|
@@ -586,11 +759,14 @@ var Usion = (function () {
|
|
|
586
759
|
settled = true;
|
|
587
760
|
cleanup();
|
|
588
761
|
// Update cached balance from the authoritative new balance, else
|
|
589
|
-
// best-effort subtract
|
|
762
|
+
// best-effort subtract now and re-query the host so the cache
|
|
763
|
+
// converges on the real value (an estimate would otherwise stick
|
|
764
|
+
// until reload).
|
|
590
765
|
if (response && response.newBalance !== undefined) {
|
|
591
766
|
self._balance = response.newBalance;
|
|
592
|
-
} else
|
|
593
|
-
self._balance -= amount;
|
|
767
|
+
} else {
|
|
768
|
+
if (self._balance !== null) self._balance -= amount;
|
|
769
|
+
self.getBalance({ fresh: true }).catch(function() { /* keep estimate */ });
|
|
594
770
|
}
|
|
595
771
|
resolve(response);
|
|
596
772
|
}
|
|
@@ -1191,9 +1367,10 @@ var Usion = (function () {
|
|
|
1191
1367
|
* Listen for messages from parent app
|
|
1192
1368
|
* @param {string} type - Message type to listen for
|
|
1193
1369
|
* @param {function} callback - Handler function
|
|
1370
|
+
* @returns {function} Unsubscribe function
|
|
1194
1371
|
*/
|
|
1195
1372
|
on: function(type, callback) {
|
|
1196
|
-
|
|
1373
|
+
function listener(event) {
|
|
1197
1374
|
if (!isTrustedMessageSource(event)) return;
|
|
1198
1375
|
|
|
1199
1376
|
let data;
|
|
@@ -1206,7 +1383,11 @@ var Usion = (function () {
|
|
|
1206
1383
|
if (data.type === type) {
|
|
1207
1384
|
callback(data);
|
|
1208
1385
|
}
|
|
1209
|
-
}
|
|
1386
|
+
}
|
|
1387
|
+
window.addEventListener('message', listener);
|
|
1388
|
+
return function() {
|
|
1389
|
+
window.removeEventListener('message', listener);
|
|
1390
|
+
};
|
|
1210
1391
|
}
|
|
1211
1392
|
};
|
|
1212
1393
|
|
|
@@ -1564,7 +1745,20 @@ var Usion = (function () {
|
|
|
1564
1745
|
// backend channel — robust to listener registration order.
|
|
1565
1746
|
if (Usion._bindBackendSocket) Usion._bindBackendSocket(self.socket);
|
|
1566
1747
|
|
|
1748
|
+
// Socket.IO v4 emits 'reconnect' on the Manager, not the Socket — a
|
|
1749
|
+
// Socket-level 'reconnect' listener never fires. Track attempts on the
|
|
1750
|
+
// Manager and treat any connect after the first (per socket) as the
|
|
1751
|
+
// reconnect signal, dispatched below AFTER rejoin gating is in place.
|
|
1752
|
+
var hadConnect = false;
|
|
1753
|
+
if (self.socket.io && typeof self.socket.io.on === 'function') {
|
|
1754
|
+
self.socket.io.on('reconnect_attempt', function(n) {
|
|
1755
|
+
self._reconnAttempts = n;
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1567
1759
|
self.socket.on('connect', function() {
|
|
1760
|
+
var isReconnect = hadConnect;
|
|
1761
|
+
hadConnect = true;
|
|
1568
1762
|
self.connected = true;
|
|
1569
1763
|
self._connecting = false;
|
|
1570
1764
|
Usion.log('Game socket connected');
|
|
@@ -1595,12 +1789,21 @@ var Usion = (function () {
|
|
|
1595
1789
|
.then(function() {
|
|
1596
1790
|
self._rejoinPromise = null;
|
|
1597
1791
|
// Send any moves queued while offline, now that membership
|
|
1598
|
-
// and sync are restored. (
|
|
1599
|
-
//
|
|
1792
|
+
// and sync are restored. (This connect handler is the reliable
|
|
1793
|
+
// hook — see the Manager-vs-Socket note above.)
|
|
1600
1794
|
if (self._flushOfflineQueue) self._flushOfflineQueue();
|
|
1601
1795
|
});
|
|
1602
1796
|
}
|
|
1603
1797
|
|
|
1798
|
+
// Dispatched after _rejoinPromise is set, so anything reacting to
|
|
1799
|
+
// 'reconnect' (offline-queue flush, connection-state machine) sees
|
|
1800
|
+
// sends gated behind the rejoin+resync.
|
|
1801
|
+
if (isReconnect) {
|
|
1802
|
+
Usion.log('Game socket reconnected');
|
|
1803
|
+
self._dispatch('reconnect', self._reconnAttempts || 1);
|
|
1804
|
+
self._reconnAttempts = 0;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1604
1807
|
resolve();
|
|
1605
1808
|
});
|
|
1606
1809
|
|
|
@@ -1623,11 +1826,6 @@ var Usion = (function () {
|
|
|
1623
1826
|
self._dispatch('disconnect', reason);
|
|
1624
1827
|
});
|
|
1625
1828
|
|
|
1626
|
-
self.socket.on('reconnect', function(attemptNumber) {
|
|
1627
|
-
Usion.log('Game socket reconnected after ' + attemptNumber + ' attempts');
|
|
1628
|
-
self._dispatch('reconnect', attemptNumber);
|
|
1629
|
-
});
|
|
1630
|
-
|
|
1631
1829
|
// Game event handlers
|
|
1632
1830
|
self.socket.on('game:joined', function(data) {
|
|
1633
1831
|
if (data.sequence !== undefined) {
|
|
@@ -1714,82 +1912,6 @@ var Usion = (function () {
|
|
|
1714
1912
|
};
|
|
1715
1913
|
}
|
|
1716
1914
|
|
|
1717
|
-
/**
|
|
1718
|
-
* Usion SDK Errors — stable, machine-readable error codes.
|
|
1719
|
-
*
|
|
1720
|
-
* Developers should branch on `err.code`, never on message text. Messages
|
|
1721
|
-
* are human-readable and may change; codes are part of the public API and
|
|
1722
|
-
* follow the deprecation policy (never removed within a major version).
|
|
1723
|
-
*/
|
|
1724
|
-
|
|
1725
|
-
/** @type {Record<string, string>} */
|
|
1726
|
-
const ERROR_CODES = {
|
|
1727
|
-
NOT_CONNECTED: 'NOT_CONNECTED', // No live connection for this call
|
|
1728
|
-
NO_ROOM: 'NO_ROOM', // No room id provided/known
|
|
1729
|
-
ROOM_NOT_FOUND: 'ROOM_NOT_FOUND', // Room does not exist server-side
|
|
1730
|
-
NOT_PARTICIPANT: 'NOT_PARTICIPANT', // Caller is not a player in the room
|
|
1731
|
-
NOT_AUTHORITY: 'NOT_AUTHORITY', // Authority-only call (e.g. setState)
|
|
1732
|
-
NOT_AUTHENTICATED: 'NOT_AUTHENTICATED', // Missing/invalid auth server-side
|
|
1733
|
-
JOIN_TIMEOUT: 'JOIN_TIMEOUT', // Join did not complete in time
|
|
1734
|
-
CONNECT_TIMEOUT: 'CONNECT_TIMEOUT', // Connect did not complete in time
|
|
1735
|
-
STATE_TOO_LARGE: 'STATE_TOO_LARGE', // setState payload over the quota
|
|
1736
|
-
INVALID_STATE: 'INVALID_STATE', // setState payload not a JSON object
|
|
1737
|
-
INVALID_NEXT_TURN: 'INVALID_NEXT_TURN', // nextTurn is not a player in the room
|
|
1738
|
-
RATE_LIMITED: 'RATE_LIMITED', // Too many calls; back off
|
|
1739
|
-
REQUEST_TIMEOUT: 'REQUEST_TIMEOUT', // Host/parent did not reply in time
|
|
1740
|
-
QUEUE_FULL: 'QUEUE_FULL', // Offline action queue at capacity
|
|
1741
|
-
UNSUPPORTED: 'UNSUPPORTED', // Not available in this transport
|
|
1742
|
-
UNKNOWN: 'UNKNOWN', // Unmapped error (see message)
|
|
1743
|
-
};
|
|
1744
|
-
|
|
1745
|
-
class UsionError extends Error {
|
|
1746
|
-
/**
|
|
1747
|
-
* @param {string} code - One of ERROR_CODES
|
|
1748
|
-
* @param {string} [message] - Human-readable detail (may change between versions)
|
|
1749
|
-
*/
|
|
1750
|
-
constructor(code, message) {
|
|
1751
|
-
super(message || code);
|
|
1752
|
-
this.name = 'UsionError';
|
|
1753
|
-
this.code = ERROR_CODES[code] ? code : ERROR_CODES.UNKNOWN;
|
|
1754
|
-
}
|
|
1755
|
-
}
|
|
1756
|
-
|
|
1757
|
-
// Backend error strings → stable codes. Order matters: first match wins.
|
|
1758
|
-
/** @type {Array<[RegExp, string]>} */
|
|
1759
|
-
const BACKEND_PATTERNS = [
|
|
1760
|
-
[/not authenticated/i, ERROR_CODES.NOT_AUTHENTICATED],
|
|
1761
|
-
[/room_id required|no room id/i, ERROR_CODES.NO_ROOM],
|
|
1762
|
-
[/room not found/i, ERROR_CODES.ROOM_NOT_FOUND],
|
|
1763
|
-
[/not a participant/i, ERROR_CODES.NOT_PARTICIPANT],
|
|
1764
|
-
[/room authority/i, ERROR_CODES.NOT_AUTHORITY],
|
|
1765
|
-
[/exceeds .*limit/i, ERROR_CODES.STATE_TOO_LARGE],
|
|
1766
|
-
[/state must be/i, ERROR_CODES.INVALID_STATE],
|
|
1767
|
-
[/next_turn must be/i, ERROR_CODES.INVALID_NEXT_TURN],
|
|
1768
|
-
[/rate limit|too many/i, ERROR_CODES.RATE_LIMITED],
|
|
1769
|
-
[/join timeout/i, ERROR_CODES.JOIN_TIMEOUT],
|
|
1770
|
-
[/connection timeout|connect timeout/i, ERROR_CODES.CONNECT_TIMEOUT],
|
|
1771
|
-
[/request timeout/i, ERROR_CODES.REQUEST_TIMEOUT],
|
|
1772
|
-
[/not connected/i, ERROR_CODES.NOT_CONNECTED],
|
|
1773
|
-
];
|
|
1774
|
-
|
|
1775
|
-
/**
|
|
1776
|
-
* Normalize anything (backend `{error}` string, Error, raw string) into a
|
|
1777
|
-
* UsionError with the best-matching stable code.
|
|
1778
|
-
* @param {*} err
|
|
1779
|
-
* @param {string} [fallbackCode] - Code to use when nothing matches
|
|
1780
|
-
* @returns {UsionError}
|
|
1781
|
-
*/
|
|
1782
|
-
function toUsionError(err, fallbackCode) {
|
|
1783
|
-
if (err instanceof UsionError) return err;
|
|
1784
|
-
const message = err && err.message ? err.message : String(err || 'Unknown error');
|
|
1785
|
-
for (let i = 0; i < BACKEND_PATTERNS.length; i++) {
|
|
1786
|
-
if (BACKEND_PATTERNS[i][0].test(message)) {
|
|
1787
|
-
return new UsionError(BACKEND_PATTERNS[i][1], message);
|
|
1788
|
-
}
|
|
1789
|
-
}
|
|
1790
|
-
return new UsionError(fallbackCode || ERROR_CODES.UNKNOWN, message);
|
|
1791
|
-
}
|
|
1792
|
-
|
|
1793
1915
|
/**
|
|
1794
1916
|
* Usion SDK Game Proxy — postMessage relay through parent app
|
|
1795
1917
|
*/
|
|
@@ -2038,12 +2160,20 @@ var Usion = (function () {
|
|
|
2038
2160
|
self.socket.emit('game:join', { room_id: roomId }, function(response) {
|
|
2039
2161
|
if (response.error) {
|
|
2040
2162
|
self._joined = false;
|
|
2041
|
-
reject(toUsionError(response
|
|
2163
|
+
reject(toUsionError(response));
|
|
2042
2164
|
} else {
|
|
2043
2165
|
self._joined = true;
|
|
2044
2166
|
if (response.sequence !== undefined) {
|
|
2045
2167
|
self._lastSequence = response.sequence;
|
|
2168
|
+
// The joined state reflects everything up to this sequence —
|
|
2169
|
+
// actions at or below it must not be re-delivered (same
|
|
2170
|
+
// baseline the proxy transport sets from GAME_JOINED).
|
|
2171
|
+
self._lastActionApplied = Math.max(self._lastActionApplied, response.sequence);
|
|
2046
2172
|
}
|
|
2173
|
+
// Transport parity: proxy and direct modes dispatch 'joined';
|
|
2174
|
+
// the socket transport now does too, so onJoined / recovery
|
|
2175
|
+
// logic behaves the same everywhere.
|
|
2176
|
+
self._dispatch('joined', response);
|
|
2047
2177
|
resolve(response);
|
|
2048
2178
|
}
|
|
2049
2179
|
});
|
|
@@ -2133,36 +2263,81 @@ var Usion = (function () {
|
|
|
2133
2263
|
// move can't go out before the client has resynced.
|
|
2134
2264
|
const gate = self._rejoinPromise || Promise.resolve();
|
|
2135
2265
|
return gate.then(function() {
|
|
2136
|
-
|
|
2137
|
-
if (
|
|
2138
|
-
|
|
2139
|
-
self._queueOfflineAction(actionType, actionData, opts).then(resolve, reject);
|
|
2140
|
-
return;
|
|
2141
|
-
}
|
|
2142
|
-
reject(new UsionError(ERROR_CODES.NOT_CONNECTED, 'Not connected'));
|
|
2143
|
-
return;
|
|
2266
|
+
if (!self.socket || !self.connected) {
|
|
2267
|
+
if (queueOffline) {
|
|
2268
|
+
return self._queueOfflineAction(actionType, actionData, opts);
|
|
2144
2269
|
}
|
|
2270
|
+
throw new UsionError(ERROR_CODES.NOT_CONNECTED, 'Not connected');
|
|
2271
|
+
}
|
|
2272
|
+
const payload = {
|
|
2273
|
+
room_id: self.roomId,
|
|
2274
|
+
action_type: actionType,
|
|
2275
|
+
action_data: actionData
|
|
2276
|
+
};
|
|
2277
|
+
if (nextTurn) payload.next_turn = nextTurn;
|
|
2278
|
+
return self._emitAction(payload, true);
|
|
2279
|
+
});
|
|
2280
|
+
};
|
|
2145
2281
|
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2282
|
+
/**
|
|
2283
|
+
* @private Emit game:action and handle the ack. On NOT_IN_ROOM (the server
|
|
2284
|
+
* detected a detached socket — connected but no room membership), auto-
|
|
2285
|
+
* rejoin + resync once and retry the same action, so a move made in the
|
|
2286
|
+
* detached window is delivered instead of silently lost.
|
|
2287
|
+
*/
|
|
2288
|
+
game._emitAction = function(payload, retryOnNotInRoom) {
|
|
2289
|
+
const self = this;
|
|
2290
|
+
return new Promise(function(resolve, reject) {
|
|
2291
|
+
if (!self.socket || !self.connected) {
|
|
2292
|
+
reject(new UsionError(ERROR_CODES.NOT_CONNECTED, 'Not connected'));
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
self.socket.emit('game:action', payload, function(response) {
|
|
2296
|
+
if (response && response.error) {
|
|
2297
|
+
const ue = toUsionError(response);
|
|
2298
|
+
if (retryOnNotInRoom && ue.code === ERROR_CODES.NOT_IN_ROOM) {
|
|
2299
|
+
self._autoRejoin()
|
|
2300
|
+
.then(function() { return self._emitAction(payload, false); })
|
|
2301
|
+
.then(resolve, reject);
|
|
2302
|
+
return;
|
|
2160
2303
|
}
|
|
2161
|
-
|
|
2304
|
+
reject(ue);
|
|
2305
|
+
} else {
|
|
2306
|
+
if (response && response.sequence !== undefined) {
|
|
2307
|
+
self._lastSequence = response.sequence;
|
|
2308
|
+
}
|
|
2309
|
+
resolve(response);
|
|
2310
|
+
}
|
|
2162
2311
|
});
|
|
2163
2312
|
});
|
|
2164
2313
|
};
|
|
2165
2314
|
|
|
2315
|
+
/**
|
|
2316
|
+
* @private Recover a detached socket: the server said we're not in the
|
|
2317
|
+
* Socket.IO room (NOT_IN_ROOM), so rejoin + resync exactly like the
|
|
2318
|
+
* post-reconnect path, gating action() sends on _rejoinPromise meanwhile.
|
|
2319
|
+
*/
|
|
2320
|
+
game._autoRejoin = function() {
|
|
2321
|
+
const self = this;
|
|
2322
|
+
if (self._rejoinPromise) return self._rejoinPromise;
|
|
2323
|
+
if (!self.roomId || !self.socket || !self.connected) return Promise.resolve();
|
|
2324
|
+
Usion.log('Server reports socket not in room - auto-rejoining ' + self.roomId);
|
|
2325
|
+
self._joined = false;
|
|
2326
|
+
self._joinPromise = null;
|
|
2327
|
+
self._rejoinPromise = self.join(self.roomId)
|
|
2328
|
+
.then(function() {
|
|
2329
|
+
self.requestSync(self._lastSequence || 0);
|
|
2330
|
+
})
|
|
2331
|
+
.catch(function(err) {
|
|
2332
|
+
Usion.log('Auto-rejoin failed: ' + (err && err.message ? err.message : String(err)));
|
|
2333
|
+
})
|
|
2334
|
+
.then(function() {
|
|
2335
|
+
self._rejoinPromise = null;
|
|
2336
|
+
if (self._flushOfflineQueue) self._flushOfflineQueue();
|
|
2337
|
+
});
|
|
2338
|
+
return self._rejoinPromise;
|
|
2339
|
+
};
|
|
2340
|
+
|
|
2166
2341
|
// ── Offline action queue (opt-in via action(..., { queueOffline: true })) ──
|
|
2167
2342
|
|
|
2168
2343
|
const OFFLINE_QUEUE_MAX = 20;
|
|
@@ -2237,11 +2412,17 @@ var Usion = (function () {
|
|
|
2237
2412
|
return Promise.resolve({ success: false, error: 'not_supported_in_direct_mode', code: ERROR_CODES.UNSUPPORTED });
|
|
2238
2413
|
}
|
|
2239
2414
|
|
|
2415
|
+
// The checkpoint carries the action sequence this state reflects. The
|
|
2416
|
+
// server persists it and rejects OLDER checkpoints (STALE_STATE), so two
|
|
2417
|
+
// concurrent writers can't clobber newer state with older.
|
|
2418
|
+
const checkpointSeq = self._lastSequence || 0;
|
|
2419
|
+
|
|
2240
2420
|
if (self._useProxy) {
|
|
2241
|
-
return Usion._request('GAME_SET_STATE', { room_id: self.roomId, state: state || {} })
|
|
2421
|
+
return Usion._request('GAME_SET_STATE', { room_id: self.roomId, state: state || {}, sequence: checkpointSeq })
|
|
2242
2422
|
.then(function(res) {
|
|
2243
2423
|
if (res && res.error) {
|
|
2244
|
-
|
|
2424
|
+
const rue = toUsionError(res);
|
|
2425
|
+
return { success: false, error: rue.message, code: rue.code };
|
|
2245
2426
|
}
|
|
2246
2427
|
return res || { success: true };
|
|
2247
2428
|
})
|
|
@@ -2258,10 +2439,12 @@ var Usion = (function () {
|
|
|
2258
2439
|
}
|
|
2259
2440
|
self.socket.emit('game:set_state', {
|
|
2260
2441
|
room_id: self.roomId,
|
|
2261
|
-
state: state || {}
|
|
2442
|
+
state: state || {},
|
|
2443
|
+
sequence: checkpointSeq
|
|
2262
2444
|
}, function(response) {
|
|
2263
2445
|
if (response && response.error) {
|
|
2264
|
-
|
|
2446
|
+
const ue = toUsionError(response);
|
|
2447
|
+
resolve({ success: false, error: ue.message, code: ue.code });
|
|
2265
2448
|
} else {
|
|
2266
2449
|
resolve(response || { success: true });
|
|
2267
2450
|
}
|
|
@@ -2294,10 +2477,18 @@ var Usion = (function () {
|
|
|
2294
2477
|
return;
|
|
2295
2478
|
}
|
|
2296
2479
|
|
|
2480
|
+
// Still fire-and-forget for the game, but the server now acks failures
|
|
2481
|
+
// (previously dropped silently): surface them via onError, and on
|
|
2482
|
+
// NOT_IN_ROOM (detached socket) auto-rejoin so the stream recovers.
|
|
2297
2483
|
self.socket.emit('game:realtime', {
|
|
2298
2484
|
room_id: self.roomId,
|
|
2299
2485
|
action_type: actionType,
|
|
2300
2486
|
action_data: actionData
|
|
2487
|
+
}, function(response) {
|
|
2488
|
+
if (!response || !response.error) return;
|
|
2489
|
+
const ue = toUsionError(response);
|
|
2490
|
+
self._dispatch('error', { code: ue.code, message: ue.message, source: 'realtime' });
|
|
2491
|
+
if (ue.code === ERROR_CODES.NOT_IN_ROOM) self._autoRejoin();
|
|
2301
2492
|
});
|
|
2302
2493
|
};
|
|
2303
2494
|
|
|
@@ -2373,7 +2564,7 @@ var Usion = (function () {
|
|
|
2373
2564
|
|
|
2374
2565
|
self.socket.emit('game:forfeit', { room_id: self.roomId }, function(response) {
|
|
2375
2566
|
if (response.error) {
|
|
2376
|
-
reject(toUsionError(response
|
|
2567
|
+
reject(toUsionError(response));
|
|
2377
2568
|
} else {
|
|
2378
2569
|
resolve(response);
|
|
2379
2570
|
}
|
|
@@ -4615,6 +4806,285 @@ var Usion = (function () {
|
|
|
4615
4806
|
game.getRtt = function () { return this._pingMeter ? this._pingMeter.rtt : null; };
|
|
4616
4807
|
}
|
|
4617
4808
|
|
|
4809
|
+
/**
|
|
4810
|
+
* Usion SDK Game Reliability — the reconnection-lifecycle surface promised in
|
|
4811
|
+
* v2.16: sequence getters, the unified connection-state machine, and
|
|
4812
|
+
* syncedState (reconnect-safe authoritative shared state).
|
|
4813
|
+
*
|
|
4814
|
+
* Everything here rides the events the game module already dispatches
|
|
4815
|
+
* ('disconnect' / 'reconnect' / 'joined' / 'sync' / 'action'), so it behaves
|
|
4816
|
+
* identically across the socket, embedded-proxy, and direct transports.
|
|
4817
|
+
*/
|
|
4818
|
+
|
|
4819
|
+
function applyGameReliability(game, Usion) {
|
|
4820
|
+
// ── Sequence getters ──────────────────────────────────────────────────────
|
|
4821
|
+
|
|
4822
|
+
/** Highest action sequence seen (from joins, syncs, and live actions). */
|
|
4823
|
+
game.getLastSequence = function () { return this._lastSequence || 0; };
|
|
4824
|
+
|
|
4825
|
+
/** Highest action sequence applied locally; trails while catching up. */
|
|
4826
|
+
game.getLastAppliedSequence = function () { return this._lastActionApplied || 0; };
|
|
4827
|
+
|
|
4828
|
+
// ── Connection-state machine ──────────────────────────────────────────────
|
|
4829
|
+
// connected → disconnected → rejoining → reconnected → connected.
|
|
4830
|
+
// 'reconnected' is a transient notification state: observers see it, then
|
|
4831
|
+
// the machine settles back to 'connected' in the same tick.
|
|
4832
|
+
|
|
4833
|
+
game._connState = 'disconnected';
|
|
4834
|
+
game._awaitResync = false;
|
|
4835
|
+
|
|
4836
|
+
game._setConnState = function (state) {
|
|
4837
|
+
if (this._connState === state) return;
|
|
4838
|
+
this._connState = state;
|
|
4839
|
+
this._dispatch('connectionState', state);
|
|
4840
|
+
};
|
|
4841
|
+
|
|
4842
|
+
/** Current connection state, synchronously. */
|
|
4843
|
+
game.getConnectionState = function () { return this._connState; };
|
|
4844
|
+
|
|
4845
|
+
/**
|
|
4846
|
+
* Observe connection-state transitions — one hook to drive a
|
|
4847
|
+
* "Reconnecting…" overlay and gate input, identical on every transport.
|
|
4848
|
+
* Single handler (last wins); returns an unsubscribe fn. For multiple
|
|
4849
|
+
* listeners use game.on('connectionState', cb).
|
|
4850
|
+
*/
|
|
4851
|
+
game.onConnectionState = function (callback) { return this._setHandler('connectionState', callback); };
|
|
4852
|
+
|
|
4853
|
+
/**
|
|
4854
|
+
* Fires once per reconnect, after the resync completes, with
|
|
4855
|
+
* { state, lastSequence, viaSync }. Restore local state here — it does NOT
|
|
4856
|
+
* fire for a manually-requested sync.
|
|
4857
|
+
*/
|
|
4858
|
+
game.onReconnected = function (callback) { return this._setHandler('reconnected', callback); };
|
|
4859
|
+
|
|
4860
|
+
game.on('disconnect', function () {
|
|
4861
|
+
game._awaitResync = false;
|
|
4862
|
+
game._setConnState('disconnected');
|
|
4863
|
+
});
|
|
4864
|
+
|
|
4865
|
+
game.on('reconnect', function () {
|
|
4866
|
+
if (game.roomId) {
|
|
4867
|
+
game._awaitResync = true;
|
|
4868
|
+
game._setConnState('rejoining');
|
|
4869
|
+
} else {
|
|
4870
|
+
game._setConnState('connected');
|
|
4871
|
+
}
|
|
4872
|
+
});
|
|
4873
|
+
|
|
4874
|
+
game.on('sync', function (data) {
|
|
4875
|
+
if (!game._awaitResync) return;
|
|
4876
|
+
game._awaitResync = false;
|
|
4877
|
+
game._dispatch('reconnected', {
|
|
4878
|
+
state: (data && data.game_state) || null,
|
|
4879
|
+
lastSequence: game._lastSequence || 0,
|
|
4880
|
+
viaSync: true,
|
|
4881
|
+
});
|
|
4882
|
+
game._setConnState('reconnected');
|
|
4883
|
+
game._setConnState('connected');
|
|
4884
|
+
});
|
|
4885
|
+
|
|
4886
|
+
// Transport-level connects (initial connect in every mode) resolve through
|
|
4887
|
+
// connect()/connectDirect() — reflect them in the state machine.
|
|
4888
|
+
function wrapConnect(name) {
|
|
4889
|
+
var orig = game[name];
|
|
4890
|
+
if (typeof orig !== 'function') return;
|
|
4891
|
+
game[name] = function () {
|
|
4892
|
+
var self = this;
|
|
4893
|
+
return orig.apply(self, arguments).then(function (result) {
|
|
4894
|
+
if (!self._awaitResync) self._setConnState('connected');
|
|
4895
|
+
return result;
|
|
4896
|
+
});
|
|
4897
|
+
};
|
|
4898
|
+
}
|
|
4899
|
+
wrapConnect('connect');
|
|
4900
|
+
wrapConnect('connectDirect');
|
|
4901
|
+
|
|
4902
|
+
// ── Host tracking ─────────────────────────────────────────────────────────
|
|
4903
|
+
// Kept on the game module (registered at module creation, so it never
|
|
4904
|
+
// misses an event) — a syncedState created AFTER the join still knows who
|
|
4905
|
+
// the authority is.
|
|
4906
|
+
|
|
4907
|
+
game._hostId = null;
|
|
4908
|
+
function trackHost(d) {
|
|
4909
|
+
if (!d) return;
|
|
4910
|
+
if (d.host_id) game._hostId = d.host_id;
|
|
4911
|
+
else if (d.player_ids && d.player_ids.length) game._hostId = d.player_ids[0];
|
|
4912
|
+
}
|
|
4913
|
+
game.on('joined', trackHost);
|
|
4914
|
+
game.on('playerJoined', trackHost);
|
|
4915
|
+
|
|
4916
|
+
// ── syncedState ───────────────────────────────────────────────────────────
|
|
4917
|
+
|
|
4918
|
+
/**
|
|
4919
|
+
* Reconnect-safe authoritative shared state. Commits are sequenced actions
|
|
4920
|
+
* applied through `reduce` on every client in the same order (deduped by
|
|
4921
|
+
* sequence); the authority (player_ids[0] by default) auto-checkpoints via
|
|
4922
|
+
* setState, and (re)joining clients recover automatically (checkpoint +
|
|
4923
|
+
* un-checkpointed tail). Degrades to local-apply in direct mode.
|
|
4924
|
+
* Full contract: SyncedStateOptions / SyncedState in types/index.d.ts.
|
|
4925
|
+
*/
|
|
4926
|
+
game.syncedState = function (initial, opts) {
|
|
4927
|
+
opts = opts || {};
|
|
4928
|
+
var self = this;
|
|
4929
|
+
var reduce = typeof opts.reduce === 'function'
|
|
4930
|
+
? opts.reduce
|
|
4931
|
+
: function (state, action) { return Object.assign({}, state, action.data); };
|
|
4932
|
+
var checkpointEvery = opts.checkpointEvery != null ? opts.checkpointEvery : 1;
|
|
4933
|
+
var authorityMode = opts.authority === 'all' ? 'all' : 'host';
|
|
4934
|
+
var defaultType = opts.type || 'update';
|
|
4935
|
+
|
|
4936
|
+
var state = initial === undefined ? {} : initial;
|
|
4937
|
+
var appliedSeq = 0;
|
|
4938
|
+
var sinceCheckpoint = 0;
|
|
4939
|
+
var lastGapRequest = -1;
|
|
4940
|
+
var changeCbs = [];
|
|
4941
|
+
var offs = [];
|
|
4942
|
+
|
|
4943
|
+
function notify(reason) {
|
|
4944
|
+
var copy = changeCbs.slice();
|
|
4945
|
+
for (var i = 0; i < copy.length; i++) {
|
|
4946
|
+
try { copy[i](state, reason); } catch (e) { Usion.log('syncedState onChange error: ' + e.message); }
|
|
4947
|
+
}
|
|
4948
|
+
}
|
|
4949
|
+
|
|
4950
|
+
function isAuthority() {
|
|
4951
|
+
if (authorityMode === 'all') return true;
|
|
4952
|
+
var me = self.playerId || (Usion.user && Usion.user.getId && Usion.user.getId());
|
|
4953
|
+
return !!(me && self._hostId && me === self._hostId);
|
|
4954
|
+
}
|
|
4955
|
+
|
|
4956
|
+
function doCheckpoint() {
|
|
4957
|
+
if (self.directMode) {
|
|
4958
|
+
return Promise.resolve({ success: false, error: 'not_supported_in_direct_mode' });
|
|
4959
|
+
}
|
|
4960
|
+
// The wrapper carries the sequence the checkpoint reflects, so recovery
|
|
4961
|
+
// knows exactly where the un-checkpointed tail starts.
|
|
4962
|
+
return self.setState({ __usionSyncedState: 1, seq: appliedSeq, state: state });
|
|
4963
|
+
}
|
|
4964
|
+
|
|
4965
|
+
function maybeAutoCheckpoint() {
|
|
4966
|
+
if (!(checkpointEvery > 0) || !isAuthority()) return;
|
|
4967
|
+
sinceCheckpoint += 1;
|
|
4968
|
+
if (sinceCheckpoint >= checkpointEvery) {
|
|
4969
|
+
sinceCheckpoint = 0;
|
|
4970
|
+
doCheckpoint();
|
|
4971
|
+
}
|
|
4972
|
+
}
|
|
4973
|
+
|
|
4974
|
+
function applyOne(playerId, type, data, sequence) {
|
|
4975
|
+
if (sequence !== undefined && sequence <= appliedSeq) return false; // dedupe
|
|
4976
|
+
var next;
|
|
4977
|
+
try {
|
|
4978
|
+
next = reduce(state, { playerId: playerId, type: type, data: data, sequence: sequence });
|
|
4979
|
+
} catch (e) {
|
|
4980
|
+
Usion.log('syncedState reduce error: ' + e.message);
|
|
4981
|
+
return false;
|
|
4982
|
+
}
|
|
4983
|
+
if (next !== undefined) state = next;
|
|
4984
|
+
if (sequence !== undefined) appliedSeq = sequence;
|
|
4985
|
+
return true;
|
|
4986
|
+
}
|
|
4987
|
+
|
|
4988
|
+
function onAction(d) {
|
|
4989
|
+
if (!d) return;
|
|
4990
|
+
if (applyOne(d.player_id, d.action_type, d.action_data, d.sequence)) {
|
|
4991
|
+
notify('action');
|
|
4992
|
+
maybeAutoCheckpoint();
|
|
4993
|
+
}
|
|
4994
|
+
}
|
|
4995
|
+
|
|
4996
|
+
// Recover from a join ack or sync payload: load the checkpoint (if it is
|
|
4997
|
+
// ours and newer), replay the action tail in order, and if the server's
|
|
4998
|
+
// sequence is still ahead of us, ask once for the missing range.
|
|
4999
|
+
function recover(d) {
|
|
5000
|
+
if (!d) return;
|
|
5001
|
+
var changed = false;
|
|
5002
|
+
|
|
5003
|
+
var gs = d.game_state;
|
|
5004
|
+
if (gs && gs.__usionSyncedState && typeof gs.seq === 'number'
|
|
5005
|
+
&& gs.seq >= appliedSeq && gs.state !== undefined) {
|
|
5006
|
+
state = gs.state;
|
|
5007
|
+
appliedSeq = gs.seq;
|
|
5008
|
+
changed = true;
|
|
5009
|
+
}
|
|
5010
|
+
|
|
5011
|
+
var actions = d.actions;
|
|
5012
|
+
if (actions && actions.length) {
|
|
5013
|
+
var sorted = actions.slice().sort(function (a, b) {
|
|
5014
|
+
return (a.sequence || 0) - (b.sequence || 0);
|
|
5015
|
+
});
|
|
5016
|
+
for (var i = 0; i < sorted.length; i++) {
|
|
5017
|
+
var item = sorted[i];
|
|
5018
|
+
if (item && applyOne(item.player_id, item.action_type, item.action_data, item.sequence)) {
|
|
5019
|
+
changed = true;
|
|
5020
|
+
}
|
|
5021
|
+
}
|
|
5022
|
+
}
|
|
5023
|
+
|
|
5024
|
+
if (changed) notify('recover');
|
|
5025
|
+
|
|
5026
|
+
// Gap fill: the checkpoint may lag the room sequence (checkpointEvery>1).
|
|
5027
|
+
// Request the tail from OUR applied sequence — once per position, so a
|
|
5028
|
+
// trimmed history can't loop us.
|
|
5029
|
+
if (d.sequence !== undefined && d.sequence > appliedSeq && lastGapRequest !== appliedSeq) {
|
|
5030
|
+
lastGapRequest = appliedSeq;
|
|
5031
|
+
try { self.requestSync(appliedSeq); } catch (e) { /* non-fatal */ }
|
|
5032
|
+
}
|
|
5033
|
+
}
|
|
5034
|
+
|
|
5035
|
+
offs.push(self.on('action', onAction));
|
|
5036
|
+
offs.push(self.on('sync', recover));
|
|
5037
|
+
offs.push(self.on('joined', recover));
|
|
5038
|
+
|
|
5039
|
+
return {
|
|
5040
|
+
/** Current state value. */
|
|
5041
|
+
get: function () { return state; },
|
|
5042
|
+
|
|
5043
|
+
/**
|
|
5044
|
+
* Commit a change: commit(data) with the default action type, or
|
|
5045
|
+
* commit(type, data, opts) to name it (opts forwarded to game.action,
|
|
5046
|
+
* e.g. nextTurn / queueOffline). Applied exactly once via the echo.
|
|
5047
|
+
*/
|
|
5048
|
+
commit: function (typeOrData, data, actionOpts) {
|
|
5049
|
+
var t, payload, o;
|
|
5050
|
+
if (typeof typeOrData === 'string') { t = typeOrData; payload = data; o = actionOpts; }
|
|
5051
|
+
else { t = defaultType; payload = typeOrData; o = data; }
|
|
5052
|
+
if (self.directMode) {
|
|
5053
|
+
// No platform echo in direct mode — apply locally, then send.
|
|
5054
|
+
if (applyOne(self.playerId, t, payload, undefined)) notify('action');
|
|
5055
|
+
return self.action(t, payload, o);
|
|
5056
|
+
}
|
|
5057
|
+
return self.action(t, payload, o);
|
|
5058
|
+
},
|
|
5059
|
+
|
|
5060
|
+
/** Subscribe to changes: cb(state, reason). Returns an unsubscribe fn. */
|
|
5061
|
+
onChange: function (cb) {
|
|
5062
|
+
changeCbs.push(cb);
|
|
5063
|
+
return function () {
|
|
5064
|
+
var i = changeCbs.indexOf(cb);
|
|
5065
|
+
if (i >= 0) changeCbs.splice(i, 1);
|
|
5066
|
+
};
|
|
5067
|
+
},
|
|
5068
|
+
|
|
5069
|
+
/** Whether this client is the checkpointing authority. */
|
|
5070
|
+
isAuthority: isAuthority,
|
|
5071
|
+
|
|
5072
|
+
/** Force a server checkpoint now (no-op result in direct mode). */
|
|
5073
|
+
checkpoint: function () { return doCheckpoint(); },
|
|
5074
|
+
|
|
5075
|
+
/** Sequence of the last action applied into this state. */
|
|
5076
|
+
getSequence: function () { return appliedSeq; },
|
|
5077
|
+
|
|
5078
|
+
/** Detach all listeners; the instance stops receiving updates. */
|
|
5079
|
+
destroy: function () {
|
|
5080
|
+
for (var i = 0; i < offs.length; i++) { try { offs[i](); } catch (e) { /* noop */ } }
|
|
5081
|
+
offs.length = 0;
|
|
5082
|
+
changeCbs.length = 0;
|
|
5083
|
+
},
|
|
5084
|
+
};
|
|
5085
|
+
};
|
|
5086
|
+
}
|
|
5087
|
+
|
|
4618
5088
|
/**
|
|
4619
5089
|
* Usion SDK Game Core — game module base, connect routing, event registrations
|
|
4620
5090
|
*/
|
|
@@ -4640,6 +5110,8 @@ var Usion = (function () {
|
|
|
4640
5110
|
reconnect: 'reconnect',
|
|
4641
5111
|
connection_error: 'connectionError',
|
|
4642
5112
|
room_assigned: 'roomAssigned',
|
|
5113
|
+
connection_state: 'connectionState',
|
|
5114
|
+
reconnected: 'reconnected',
|
|
4643
5115
|
};
|
|
4644
5116
|
|
|
4645
5117
|
function _normalizeEventName(event) {
|
|
@@ -4735,26 +5207,31 @@ var Usion = (function () {
|
|
|
4735
5207
|
self._connectPromise = new Promise(function(resolve, reject) {
|
|
4736
5208
|
// Check if socket.io-client is available
|
|
4737
5209
|
if (typeof io === 'undefined') {
|
|
4738
|
-
// Load
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
|
|
4746
|
-
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
self._initSocket(socketUrl, token, resolve, reject);
|
|
4750
|
-
};
|
|
4751
|
-
cdnScript.onerror = function() {
|
|
5210
|
+
// Load the Socket.IO client: same-origin copy first, then the
|
|
5211
|
+
// platform-hosted copy (same origin as the SDK itself — reachable
|
|
5212
|
+
// wherever the app is), and only then the public CDN, which
|
|
5213
|
+
// restricted networks often block.
|
|
5214
|
+
var sources = [
|
|
5215
|
+
'/socket.io.min.js',
|
|
5216
|
+
'https://usions.com/socket.io.min.js',
|
|
5217
|
+
'https://cdn.socket.io/4.7.2/socket.io.min.js'
|
|
5218
|
+
];
|
|
5219
|
+
(function tryLoad(i) {
|
|
5220
|
+
if (i >= sources.length) {
|
|
4752
5221
|
self._connecting = false;
|
|
4753
5222
|
reject(new Error('Failed to load Socket.IO client'));
|
|
5223
|
+
return;
|
|
5224
|
+
}
|
|
5225
|
+
var script = document.createElement('script');
|
|
5226
|
+
script.src = sources[i];
|
|
5227
|
+
script.onload = function() {
|
|
5228
|
+
self._initSocket(socketUrl, token, resolve, reject);
|
|
4754
5229
|
};
|
|
4755
|
-
|
|
4756
|
-
|
|
4757
|
-
|
|
5230
|
+
script.onerror = function() {
|
|
5231
|
+
tryLoad(i + 1);
|
|
5232
|
+
};
|
|
5233
|
+
document.head.appendChild(script);
|
|
5234
|
+
})(0);
|
|
4758
5235
|
} else {
|
|
4759
5236
|
self._initSocket(socketUrl, token, resolve, reject);
|
|
4760
5237
|
}
|
|
@@ -4853,6 +5330,8 @@ var Usion = (function () {
|
|
|
4853
5330
|
applyGameProxy(game, Usion);
|
|
4854
5331
|
applyGameMethods(game, Usion);
|
|
4855
5332
|
applyGameNetcode(game, Usion);
|
|
5333
|
+
// Applied last: wraps connect/connectDirect and rides dispatched events.
|
|
5334
|
+
applyGameReliability(game, Usion);
|
|
4856
5335
|
|
|
4857
5336
|
// Foreground catch-up safety net (generic across every transport).
|
|
4858
5337
|
//
|
|
@@ -5103,12 +5582,14 @@ var Usion = (function () {
|
|
|
5103
5582
|
*
|
|
5104
5583
|
* const m = await Usion.matchmaking.find(); // resolves when matched
|
|
5105
5584
|
* await Usion.game.connect(); await Usion.game.join(m.roomId);
|
|
5106
|
-
* // ...or cancel while waiting:
|
|
5585
|
+
* // ...or bound the wait / cancel while waiting:
|
|
5586
|
+
* const m2 = await Usion.matchmaking.find(null, { timeout: 30000 });
|
|
5107
5587
|
* Usion.matchmaking.cancel();
|
|
5108
5588
|
* Usion.matchmaking.onMatch(({ roomId, players }) => { ... });
|
|
5109
5589
|
*/
|
|
5590
|
+
|
|
5110
5591
|
function createMatchmakingModule(Usion) {
|
|
5111
|
-
let pending = null; // { resolve, reject }
|
|
5592
|
+
let pending = null; // active find() entry: { resolve, reject }
|
|
5112
5593
|
let onMatchCb = null;
|
|
5113
5594
|
let bound = false;
|
|
5114
5595
|
|
|
@@ -5132,24 +5613,64 @@ var Usion = (function () {
|
|
|
5132
5613
|
|
|
5133
5614
|
/**
|
|
5134
5615
|
* Join the queue for `serviceId` (defaults to the current game) and resolve
|
|
5135
|
-
* when matched with { roomId, players, serviceId }.
|
|
5136
|
-
* match (use cancel() to stop waiting)
|
|
5616
|
+
* when matched with { roomId, players, serviceId }. Without `opts.timeout`
|
|
5617
|
+
* it stays pending until a match (use cancel() to stop waiting); with it,
|
|
5618
|
+
* rejects MATCH_TIMEOUT (and leaves the queue) once the wait elapses.
|
|
5619
|
+
* @param {string} [serviceId]
|
|
5620
|
+
* @param {{size?: number, timeout?: number}} [opts]
|
|
5137
5621
|
*/
|
|
5138
5622
|
find: function (serviceId, opts) {
|
|
5139
5623
|
opts = opts || {};
|
|
5140
5624
|
const sid = serviceId || (Usion.config && Usion.config.serviceId);
|
|
5141
5625
|
bind();
|
|
5142
|
-
|
|
5143
|
-
|
|
5144
|
-
|
|
5145
|
-
|
|
5146
|
-
|
|
5626
|
+
|
|
5627
|
+
if (pending) {
|
|
5628
|
+
const old = pending; pending = null;
|
|
5629
|
+
old.reject(new UsionError(ERROR_CODES.SUPERSEDED, 'Superseded by a newer find()'));
|
|
5630
|
+
}
|
|
5631
|
+
|
|
5632
|
+
let timer = null;
|
|
5633
|
+
const entry = {};
|
|
5634
|
+
const promise = new Promise(function (resolve, reject) {
|
|
5635
|
+
entry.resolve = function (r) {
|
|
5636
|
+
if (timer) clearTimeout(timer);
|
|
5637
|
+
resolve(r);
|
|
5638
|
+
};
|
|
5639
|
+
entry.reject = function (e) {
|
|
5640
|
+
if (timer) clearTimeout(timer);
|
|
5641
|
+
if (pending === entry) pending = null;
|
|
5642
|
+
reject(e);
|
|
5643
|
+
};
|
|
5147
5644
|
});
|
|
5645
|
+
|
|
5646
|
+
// Register BEFORE emitting mm:join: when someone is already waiting in
|
|
5647
|
+
// the queue, mm:matched can arrive ahead of the join ack — registering
|
|
5648
|
+
// afterwards dropped that instant match and left find() hanging forever.
|
|
5649
|
+
pending = entry;
|
|
5650
|
+
|
|
5651
|
+
if (opts.timeout > 0) {
|
|
5652
|
+
timer = setTimeout(function () {
|
|
5653
|
+
timer = null;
|
|
5654
|
+
Usion._backendEmit('mm:cancel', {}).catch(function () { /* best-effort dequeue */ });
|
|
5655
|
+
entry.reject(new UsionError(ERROR_CODES.MATCH_TIMEOUT,
|
|
5656
|
+
'No match within ' + opts.timeout + 'ms'));
|
|
5657
|
+
}, opts.timeout);
|
|
5658
|
+
}
|
|
5659
|
+
|
|
5660
|
+
Usion._backendEmit('mm:join', { service_id: sid, size: opts.size || 2 })
|
|
5661
|
+
.catch(function (err) {
|
|
5662
|
+
if (pending === entry) entry.reject(toUsionError(err));
|
|
5663
|
+
});
|
|
5664
|
+
|
|
5665
|
+
return promise;
|
|
5148
5666
|
},
|
|
5149
5667
|
|
|
5150
5668
|
/** Leave the queue / stop waiting. */
|
|
5151
5669
|
cancel: function () {
|
|
5152
|
-
if (pending) {
|
|
5670
|
+
if (pending) {
|
|
5671
|
+
const old = pending; pending = null;
|
|
5672
|
+
old.reject(new UsionError(ERROR_CODES.CANCELLED, 'cancelled'));
|
|
5673
|
+
}
|
|
5153
5674
|
return Usion._backendEmit('mm:cancel', {});
|
|
5154
5675
|
},
|
|
5155
5676
|
};
|
|
@@ -5399,6 +5920,7 @@ var Usion = (function () {
|
|
|
5399
5920
|
* safe, namespaced allow-list (e.g. lobby:* / mm:*) so a mini-app can't abuse
|
|
5400
5921
|
* the user's authenticated connection. The backend re-validates every call.
|
|
5401
5922
|
*/
|
|
5923
|
+
|
|
5402
5924
|
function applyBackendChannel(Usion) {
|
|
5403
5925
|
Usion._backendHandlers = {};
|
|
5404
5926
|
Usion._boundSockets = typeof WeakSet !== 'undefined' ? new WeakSet() : null;
|
|
@@ -5433,30 +5955,61 @@ var Usion = (function () {
|
|
|
5433
5955
|
/**
|
|
5434
5956
|
* Emit a backend request and await its ack. Routes to the SDK socket when
|
|
5435
5957
|
* standalone, or through the parent host when embedded.
|
|
5958
|
+
* Standalone apps that never call Usion.game.connect() (e.g. a non-game
|
|
5959
|
+
* app using cloud/leaderboard/notify) get an automatic one-time connect —
|
|
5960
|
+
* the backend channel is not coupled to starting a game.
|
|
5961
|
+
* Rejections are always UsionError (stable `code`, plus `retryAfter` on
|
|
5962
|
+
* RATE_LIMITED), regardless of transport.
|
|
5436
5963
|
* @returns {Promise<any>}
|
|
5437
5964
|
*/
|
|
5438
5965
|
Usion._backendEmit = function (event, data, timeout) {
|
|
5439
5966
|
const self = this;
|
|
5440
5967
|
timeout = timeout || 8000;
|
|
5441
|
-
|
|
5442
|
-
|
|
5968
|
+
|
|
5969
|
+
function emitOn(sock) {
|
|
5443
5970
|
return new Promise(function (resolve, reject) {
|
|
5444
5971
|
let done = false;
|
|
5445
|
-
const timer = setTimeout(function () {
|
|
5972
|
+
const timer = setTimeout(function () {
|
|
5973
|
+
if (done) return; done = true;
|
|
5974
|
+
reject(new UsionError(ERROR_CODES.REQUEST_TIMEOUT, 'Backend request timeout'));
|
|
5975
|
+
}, timeout);
|
|
5446
5976
|
try {
|
|
5447
|
-
|
|
5977
|
+
sock.emit(event, data || {}, function (resp) {
|
|
5448
5978
|
if (done) return; done = true; clearTimeout(timer);
|
|
5449
|
-
if (resp && resp.error) reject(
|
|
5979
|
+
if (resp && resp.error) reject(toUsionError(resp));
|
|
5450
5980
|
else resolve(resp);
|
|
5451
5981
|
});
|
|
5452
|
-
} catch (e) { clearTimeout(timer); reject(e); }
|
|
5982
|
+
} catch (e) { clearTimeout(timer); reject(toUsionError(e)); }
|
|
5453
5983
|
});
|
|
5454
5984
|
}
|
|
5985
|
+
|
|
5986
|
+
const s = self.game && self.game.socket;
|
|
5987
|
+
if (s && s.connected) return emitOn(s);
|
|
5455
5988
|
if (self._isEmbedded) {
|
|
5456
5989
|
// Host relays this onto its authenticated socket and replies with the ack.
|
|
5457
|
-
return self._request('BACKEND_EMIT', { event: event, data: data || {} }, timeout)
|
|
5990
|
+
return self._request('BACKEND_EMIT', { event: event, data: data || {} }, timeout)
|
|
5991
|
+
.catch(function (e) { throw toUsionError(e); });
|
|
5992
|
+
}
|
|
5993
|
+
|
|
5994
|
+
// Standalone without a live socket. If no socket exists yet, auto-connect
|
|
5995
|
+
// once (connect() dedupes concurrent callers via _connectPromise). A
|
|
5996
|
+
// socket that exists but is mid-reconnect is left to Socket.IO's own
|
|
5997
|
+
// reconnection — the caller gets a coded error and can retry.
|
|
5998
|
+
const connectionMode = (self.config && self.config.connectionMode) || 'platform';
|
|
5999
|
+
if (!s && connectionMode !== 'direct'
|
|
6000
|
+
&& self.game && typeof self.game.connect === 'function') {
|
|
6001
|
+
return self.game.connect().then(function () {
|
|
6002
|
+
const sock = self.game.socket;
|
|
6003
|
+
if (sock && sock.connected) return emitOn(sock);
|
|
6004
|
+
throw new UsionError(ERROR_CODES.NOT_CONNECTED, 'No backend connection');
|
|
6005
|
+
}).catch(function (e) {
|
|
6006
|
+
if (e instanceof UsionError) throw e;
|
|
6007
|
+
throw new UsionError(ERROR_CODES.NOT_CONNECTED,
|
|
6008
|
+
'Backend connect failed: ' + (e && e.message ? e.message : String(e)));
|
|
6009
|
+
});
|
|
5458
6010
|
}
|
|
5459
|
-
return Promise.reject(new
|
|
6011
|
+
return Promise.reject(new UsionError(ERROR_CODES.NOT_CONNECTED,
|
|
6012
|
+
'No backend connection — socket is offline or reconnecting'));
|
|
5460
6013
|
};
|
|
5461
6014
|
}
|
|
5462
6015
|
|