@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
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* safe, namespaced allow-list (e.g. lobby:* / mm:*) so a mini-app can't abuse
|
|
15
15
|
* the user's authenticated connection. The backend re-validates every call.
|
|
16
16
|
*/
|
|
17
|
+
import { UsionError, toUsionError, ERROR_CODES } from './errors.js';
|
|
18
|
+
|
|
17
19
|
export function applyBackendChannel(Usion) {
|
|
18
20
|
Usion._backendHandlers = {};
|
|
19
21
|
Usion._boundSockets = typeof WeakSet !== 'undefined' ? new WeakSet() : null;
|
|
@@ -48,29 +50,60 @@ export function applyBackendChannel(Usion) {
|
|
|
48
50
|
/**
|
|
49
51
|
* Emit a backend request and await its ack. Routes to the SDK socket when
|
|
50
52
|
* standalone, or through the parent host when embedded.
|
|
53
|
+
* Standalone apps that never call Usion.game.connect() (e.g. a non-game
|
|
54
|
+
* app using cloud/leaderboard/notify) get an automatic one-time connect —
|
|
55
|
+
* the backend channel is not coupled to starting a game.
|
|
56
|
+
* Rejections are always UsionError (stable `code`, plus `retryAfter` on
|
|
57
|
+
* RATE_LIMITED), regardless of transport.
|
|
51
58
|
* @returns {Promise<any>}
|
|
52
59
|
*/
|
|
53
60
|
Usion._backendEmit = function (event, data, timeout) {
|
|
54
61
|
const self = this;
|
|
55
62
|
timeout = timeout || 8000;
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
|
|
64
|
+
function emitOn(sock) {
|
|
58
65
|
return new Promise(function (resolve, reject) {
|
|
59
66
|
let done = false;
|
|
60
|
-
const timer = setTimeout(function () {
|
|
67
|
+
const timer = setTimeout(function () {
|
|
68
|
+
if (done) return; done = true;
|
|
69
|
+
reject(new UsionError(ERROR_CODES.REQUEST_TIMEOUT, 'Backend request timeout'));
|
|
70
|
+
}, timeout);
|
|
61
71
|
try {
|
|
62
|
-
|
|
72
|
+
sock.emit(event, data || {}, function (resp) {
|
|
63
73
|
if (done) return; done = true; clearTimeout(timer);
|
|
64
|
-
if (resp && resp.error) reject(
|
|
74
|
+
if (resp && resp.error) reject(toUsionError(resp));
|
|
65
75
|
else resolve(resp);
|
|
66
76
|
});
|
|
67
|
-
} catch (e) { clearTimeout(timer); reject(e); }
|
|
77
|
+
} catch (e) { clearTimeout(timer); reject(toUsionError(e)); }
|
|
68
78
|
});
|
|
69
79
|
}
|
|
80
|
+
|
|
81
|
+
const s = self.game && self.game.socket;
|
|
82
|
+
if (s && s.connected) return emitOn(s);
|
|
70
83
|
if (self._isEmbedded) {
|
|
71
84
|
// Host relays this onto its authenticated socket and replies with the ack.
|
|
72
|
-
return self._request('BACKEND_EMIT', { event: event, data: data || {} }, timeout)
|
|
85
|
+
return self._request('BACKEND_EMIT', { event: event, data: data || {} }, timeout)
|
|
86
|
+
.catch(function (e) { throw toUsionError(e); });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Standalone without a live socket. If no socket exists yet, auto-connect
|
|
90
|
+
// once (connect() dedupes concurrent callers via _connectPromise). A
|
|
91
|
+
// socket that exists but is mid-reconnect is left to Socket.IO's own
|
|
92
|
+
// reconnection — the caller gets a coded error and can retry.
|
|
93
|
+
const connectionMode = (self.config && self.config.connectionMode) || 'platform';
|
|
94
|
+
if (!s && connectionMode !== 'direct'
|
|
95
|
+
&& self.game && typeof self.game.connect === 'function') {
|
|
96
|
+
return self.game.connect().then(function () {
|
|
97
|
+
const sock = self.game.socket;
|
|
98
|
+
if (sock && sock.connected) return emitOn(sock);
|
|
99
|
+
throw new UsionError(ERROR_CODES.NOT_CONNECTED, 'No backend connection');
|
|
100
|
+
}).catch(function (e) {
|
|
101
|
+
if (e instanceof UsionError) throw e;
|
|
102
|
+
throw new UsionError(ERROR_CODES.NOT_CONNECTED,
|
|
103
|
+
'Backend connect failed: ' + (e && e.message ? e.message : String(e)));
|
|
104
|
+
});
|
|
73
105
|
}
|
|
74
|
-
return Promise.reject(new
|
|
106
|
+
return Promise.reject(new UsionError(ERROR_CODES.NOT_CONNECTED,
|
|
107
|
+
'No backend connection — socket is offline or reconnecting'));
|
|
75
108
|
};
|
|
76
109
|
}
|
package/src/modules/core.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Usion SDK Core — init, _post, _request, message handling
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { UsionError, toUsionError, ERROR_CODES } from './errors.js';
|
|
6
|
+
|
|
5
7
|
// Request ID counter for tracking async responses
|
|
6
8
|
let _requestId = 0;
|
|
7
9
|
export const _pendingRequests = {};
|
|
@@ -75,24 +77,60 @@ export const core = {
|
|
|
75
77
|
_backButtonCallback: null,
|
|
76
78
|
|
|
77
79
|
/**
|
|
78
|
-
* Initialize the SDK with config from parent app
|
|
79
|
-
*
|
|
80
|
+
* Initialize the SDK with config from parent app.
|
|
81
|
+
*
|
|
82
|
+
* Callback form (unchanged): `Usion.init(cb)` — cb(config) fires when the
|
|
83
|
+
* host's INIT arrives. Promise form: every call ALSO returns a
|
|
84
|
+
* Promise<config>, so `const config = await Usion.init()` works.
|
|
85
|
+
*
|
|
86
|
+
* `opts.timeout` (ms, optional) rejects the promise with
|
|
87
|
+
* UsionError(INIT_TIMEOUT) if no INIT arrives in time — the "embedded but
|
|
88
|
+
* host silent" case that otherwise hangs forever. The callback still fires
|
|
89
|
+
* if INIT arrives late. Both `init(cb, opts)` and `init(opts)` are accepted.
|
|
90
|
+
*
|
|
91
|
+
* @param {function|{timeout?: number}} [callback] - Called with config when ready
|
|
92
|
+
* @param {{timeout?: number}} [opts]
|
|
93
|
+
* @returns {Promise<object>} Resolves with config
|
|
80
94
|
*/
|
|
81
|
-
init: function(callback) {
|
|
95
|
+
init: function(callback, opts) {
|
|
82
96
|
const self = this;
|
|
97
|
+
if (callback && typeof callback === 'object') {
|
|
98
|
+
opts = callback;
|
|
99
|
+
callback = null;
|
|
100
|
+
}
|
|
101
|
+
opts = opts || {};
|
|
83
102
|
|
|
84
103
|
// Prevent double initialization - just update callback
|
|
85
104
|
if (self._initialized) {
|
|
86
105
|
if (callback) callback(self.config);
|
|
87
|
-
return;
|
|
106
|
+
return Promise.resolve(self.config);
|
|
88
107
|
}
|
|
89
108
|
|
|
90
109
|
// Store callback for when config arrives
|
|
91
|
-
self._initCallback = callback;
|
|
110
|
+
if (callback) self._initCallback = callback;
|
|
111
|
+
|
|
112
|
+
// One shared promise for every init() call before INIT arrives.
|
|
113
|
+
if (!self._initPromise) {
|
|
114
|
+
self._initPromise = new Promise(function(resolve, reject) {
|
|
115
|
+
self._initResolve = resolve;
|
|
116
|
+
self._initReject = reject;
|
|
117
|
+
});
|
|
118
|
+
// Callback-style users never touch the promise — don't let an
|
|
119
|
+
// INIT_TIMEOUT rejection surface as an unhandled-rejection error.
|
|
120
|
+
self._initPromise.catch(function() {});
|
|
121
|
+
}
|
|
122
|
+
if (opts.timeout > 0 && !self._initTimer) {
|
|
123
|
+
self._initTimer = setTimeout(function() {
|
|
124
|
+
self._initTimer = null;
|
|
125
|
+
if (self._initialized || !self._initReject) return;
|
|
126
|
+
self._initReject(new UsionError(ERROR_CODES.INIT_TIMEOUT,
|
|
127
|
+
'No INIT from host within ' + opts.timeout + 'ms — host silent or running standalone'));
|
|
128
|
+
}, opts.timeout);
|
|
129
|
+
}
|
|
92
130
|
|
|
93
131
|
// Only register message handler once
|
|
94
132
|
if (self._messageHandlerRegistered) {
|
|
95
|
-
return;
|
|
133
|
+
return self._initPromise;
|
|
96
134
|
}
|
|
97
135
|
self._messageHandlerRegistered = true;
|
|
98
136
|
|
|
@@ -148,6 +186,15 @@ export const core = {
|
|
|
148
186
|
if (self._initCallback) {
|
|
149
187
|
self._initCallback(data.config);
|
|
150
188
|
}
|
|
189
|
+
|
|
190
|
+
// Settle the promise form
|
|
191
|
+
if (self._initTimer) {
|
|
192
|
+
clearTimeout(self._initTimer);
|
|
193
|
+
self._initTimer = null;
|
|
194
|
+
}
|
|
195
|
+
if (self._initResolve) {
|
|
196
|
+
self._initResolve(data.config);
|
|
197
|
+
}
|
|
151
198
|
}
|
|
152
199
|
|
|
153
200
|
// Handle response messages for async requests
|
|
@@ -156,7 +203,9 @@ export const core = {
|
|
|
156
203
|
delete _pendingRequests[data._requestId];
|
|
157
204
|
|
|
158
205
|
if (data.error) {
|
|
159
|
-
|
|
206
|
+
// Preserve the backend's stable code/retry_after when the host
|
|
207
|
+
// relayed them (see errors.js).
|
|
208
|
+
reject(toUsionError(data));
|
|
160
209
|
} else {
|
|
161
210
|
resolve(data);
|
|
162
211
|
}
|
|
@@ -215,6 +264,8 @@ export const core = {
|
|
|
215
264
|
|
|
216
265
|
// Signal ready to parent
|
|
217
266
|
this._post({ type: 'READY' });
|
|
267
|
+
|
|
268
|
+
return self._initPromise;
|
|
218
269
|
},
|
|
219
270
|
|
|
220
271
|
/**
|
|
@@ -336,7 +387,7 @@ export const core = {
|
|
|
336
387
|
// Setup timeout
|
|
337
388
|
const timer = setTimeout(function() {
|
|
338
389
|
delete _pendingRequests[requestId];
|
|
339
|
-
reject(new
|
|
390
|
+
reject(new UsionError(ERROR_CODES.REQUEST_TIMEOUT, 'Request timeout'));
|
|
340
391
|
}, timeout);
|
|
341
392
|
|
|
342
393
|
// Store pending request
|
package/src/modules/errors.js
CHANGED
|
@@ -12,16 +12,29 @@ export const ERROR_CODES = {
|
|
|
12
12
|
NO_ROOM: 'NO_ROOM', // No room id provided/known
|
|
13
13
|
ROOM_NOT_FOUND: 'ROOM_NOT_FOUND', // Room does not exist server-side
|
|
14
14
|
NOT_PARTICIPANT: 'NOT_PARTICIPANT', // Caller is not a player in the room
|
|
15
|
-
|
|
15
|
+
NOT_IN_ROOM: 'NOT_IN_ROOM', // Socket not in the room — rejoin required
|
|
16
|
+
NOT_AUTHORITY: 'NOT_AUTHORITY', // Authority-only call (e.g. lobby start)
|
|
16
17
|
NOT_AUTHENTICATED: 'NOT_AUTHENTICATED', // Missing/invalid auth server-side
|
|
17
18
|
JOIN_TIMEOUT: 'JOIN_TIMEOUT', // Join did not complete in time
|
|
18
19
|
CONNECT_TIMEOUT: 'CONNECT_TIMEOUT', // Connect did not complete in time
|
|
20
|
+
INIT_TIMEOUT: 'INIT_TIMEOUT', // init() got no INIT from the host in time
|
|
21
|
+
MATCH_TIMEOUT: 'MATCH_TIMEOUT', // matchmaking.find() opts.timeout elapsed
|
|
19
22
|
STATE_TOO_LARGE: 'STATE_TOO_LARGE', // setState payload over the quota
|
|
20
23
|
INVALID_STATE: 'INVALID_STATE', // setState payload not a JSON object
|
|
24
|
+
STALE_STATE: 'STALE_STATE', // setState older than the stored checkpoint
|
|
21
25
|
INVALID_NEXT_TURN: 'INVALID_NEXT_TURN', // nextTurn is not a player in the room
|
|
22
|
-
|
|
26
|
+
INVALID_INPUT: 'INVALID_INPUT', // Missing/malformed argument server-side
|
|
27
|
+
NOT_FOUND: 'NOT_FOUND', // Referenced entity (service, lobby, …) missing
|
|
28
|
+
QUOTA_EXCEEDED: 'QUOTA_EXCEEDED', // Bucket key/size quota reached (cloud KV)
|
|
29
|
+
VALUE_TOO_LARGE: 'VALUE_TOO_LARGE', // Single value over the per-value quota
|
|
30
|
+
LOBBY_FULL: 'LOBBY_FULL', // Lobby at max_players
|
|
31
|
+
LOBBY_CLOSED: 'LOBBY_CLOSED', // Lobby exists but is not open
|
|
32
|
+
CONFLICT: 'CONFLICT', // Concurrent-write conflict; retry
|
|
33
|
+
RATE_LIMITED: 'RATE_LIMITED', // Too many calls; back off (see retryAfter)
|
|
23
34
|
REQUEST_TIMEOUT: 'REQUEST_TIMEOUT', // Host/parent did not reply in time
|
|
24
35
|
QUEUE_FULL: 'QUEUE_FULL', // Offline action queue at capacity
|
|
36
|
+
CANCELLED: 'CANCELLED', // Cancelled by a local cancel() call
|
|
37
|
+
SUPERSEDED: 'SUPERSEDED', // Replaced by a newer identical request
|
|
25
38
|
UNSUPPORTED: 'UNSUPPORTED', // Not available in this transport
|
|
26
39
|
UNKNOWN: 'UNKNOWN', // Unmapped error (see message)
|
|
27
40
|
};
|
|
@@ -35,18 +48,25 @@ export class UsionError extends Error {
|
|
|
35
48
|
super(message || code);
|
|
36
49
|
this.name = 'UsionError';
|
|
37
50
|
this.code = ERROR_CODES[code] ? code : ERROR_CODES.UNKNOWN;
|
|
51
|
+
/** @type {number|undefined} Seconds until a RATE_LIMITED call may retry. */
|
|
52
|
+
this.retryAfter = undefined;
|
|
38
53
|
}
|
|
39
54
|
}
|
|
40
55
|
|
|
41
56
|
// Backend error strings → stable codes. Order matters: first match wins.
|
|
57
|
+
// Fallback path only: the backend now sends a structured `code` on every
|
|
58
|
+
// error ack, which toUsionError prefers. These patterns cover old backends
|
|
59
|
+
// and hosts that strip the code.
|
|
42
60
|
/** @type {Array<[RegExp, string]>} */
|
|
43
61
|
const BACKEND_PATTERNS = [
|
|
44
62
|
[/not authenticated/i, ERROR_CODES.NOT_AUTHENTICATED],
|
|
45
63
|
[/room_id required|no room id/i, ERROR_CODES.NO_ROOM],
|
|
46
64
|
[/room not found/i, ERROR_CODES.ROOM_NOT_FOUND],
|
|
65
|
+
[/not in room/i, ERROR_CODES.NOT_IN_ROOM],
|
|
47
66
|
[/not a participant/i, ERROR_CODES.NOT_PARTICIPANT],
|
|
48
67
|
[/room authority/i, ERROR_CODES.NOT_AUTHORITY],
|
|
49
68
|
[/exceeds .*limit/i, ERROR_CODES.STATE_TOO_LARGE],
|
|
69
|
+
[/stale checkpoint|stale state/i, ERROR_CODES.STALE_STATE],
|
|
50
70
|
[/state must be/i, ERROR_CODES.INVALID_STATE],
|
|
51
71
|
[/next_turn must be/i, ERROR_CODES.INVALID_NEXT_TURN],
|
|
52
72
|
[/rate limit|too many/i, ERROR_CODES.RATE_LIMITED],
|
|
@@ -54,17 +74,40 @@ const BACKEND_PATTERNS = [
|
|
|
54
74
|
[/connection timeout|connect timeout/i, ERROR_CODES.CONNECT_TIMEOUT],
|
|
55
75
|
[/request timeout/i, ERROR_CODES.REQUEST_TIMEOUT],
|
|
56
76
|
[/not connected/i, ERROR_CODES.NOT_CONNECTED],
|
|
77
|
+
[/quota exceeded/i, ERROR_CODES.QUOTA_EXCEEDED],
|
|
78
|
+
[/value too large/i, ERROR_CODES.VALUE_TOO_LARGE],
|
|
79
|
+
[/lobby is full/i, ERROR_CODES.LOBBY_FULL],
|
|
80
|
+
[/lobby is not open/i, ERROR_CODES.LOBBY_CLOSED],
|
|
81
|
+
[/not found|unknown service/i, ERROR_CODES.NOT_FOUND],
|
|
82
|
+
[/conflict/i, ERROR_CODES.CONFLICT],
|
|
83
|
+
[/cancelled/i, ERROR_CODES.CANCELLED],
|
|
84
|
+
[/superseded/i, ERROR_CODES.SUPERSEDED],
|
|
57
85
|
];
|
|
58
86
|
|
|
59
87
|
/**
|
|
60
|
-
* Normalize anything
|
|
61
|
-
* UsionError with the best
|
|
88
|
+
* Normalize anything — a structured backend ack ({ error, code?, retry_after? }),
|
|
89
|
+
* an Error, or a raw string — into a UsionError with the best stable code.
|
|
90
|
+
* A machine-readable `code` on the input always wins over message matching.
|
|
62
91
|
* @param {*} err
|
|
63
92
|
* @param {string} [fallbackCode] - Code to use when nothing matches
|
|
64
93
|
* @returns {UsionError}
|
|
65
94
|
*/
|
|
66
95
|
export function toUsionError(err, fallbackCode) {
|
|
67
96
|
if (err instanceof UsionError) return err;
|
|
97
|
+
|
|
98
|
+
// Structured input (backend ack object or an Error carrying a code).
|
|
99
|
+
if (err && typeof err === 'object') {
|
|
100
|
+
const code = typeof err.code === 'string' && ERROR_CODES[err.code] ? err.code : null;
|
|
101
|
+
const message = err.message || err.error;
|
|
102
|
+
if (code) {
|
|
103
|
+
const ue = new UsionError(code, typeof message === 'string' ? message : code);
|
|
104
|
+
const retryAfter = err.retry_after != null ? err.retry_after : err.retryAfter;
|
|
105
|
+
if (typeof retryAfter === 'number' && isFinite(retryAfter)) ue.retryAfter = retryAfter;
|
|
106
|
+
return ue;
|
|
107
|
+
}
|
|
108
|
+
if (!(err instanceof Error) && typeof message === 'string') err = message;
|
|
109
|
+
}
|
|
110
|
+
|
|
68
111
|
const message = err && err.message ? err.message : String(err || 'Unknown error');
|
|
69
112
|
for (let i = 0; i < BACKEND_PATTERNS.length; i++) {
|
|
70
113
|
if (BACKEND_PATTERNS[i][0].test(message)) {
|
package/src/modules/game-core.js
CHANGED
|
@@ -7,6 +7,7 @@ import { applyGameSocket } from './game-socket.js';
|
|
|
7
7
|
import { applyGameProxy } from './game-proxy.js';
|
|
8
8
|
import { applyGameMethods } from './game-methods.js';
|
|
9
9
|
import { applyGameNetcode } from './game-netcode.js';
|
|
10
|
+
import { applyGameReliability } from './game-reliability.js';
|
|
10
11
|
|
|
11
12
|
// Map any reasonable spelling of a game event onto the internal handler
|
|
12
13
|
// name: 'game:player_joined' / 'player_joined' / 'playerJoined' → 'playerJoined'.
|
|
@@ -28,6 +29,8 @@ const _EVENT_ALIASES = {
|
|
|
28
29
|
reconnect: 'reconnect',
|
|
29
30
|
connection_error: 'connectionError',
|
|
30
31
|
room_assigned: 'roomAssigned',
|
|
32
|
+
connection_state: 'connectionState',
|
|
33
|
+
reconnected: 'reconnected',
|
|
31
34
|
};
|
|
32
35
|
|
|
33
36
|
function _normalizeEventName(event) {
|
|
@@ -123,26 +126,31 @@ export function createGameModule(Usion) {
|
|
|
123
126
|
self._connectPromise = new Promise(function(resolve, reject) {
|
|
124
127
|
// Check if socket.io-client is available
|
|
125
128
|
if (typeof io === 'undefined') {
|
|
126
|
-
// Load
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
self._initSocket(socketUrl, token, resolve, reject);
|
|
138
|
-
};
|
|
139
|
-
cdnScript.onerror = function() {
|
|
129
|
+
// Load the Socket.IO client: same-origin copy first, then the
|
|
130
|
+
// platform-hosted copy (same origin as the SDK itself — reachable
|
|
131
|
+
// wherever the app is), and only then the public CDN, which
|
|
132
|
+
// restricted networks often block.
|
|
133
|
+
var sources = [
|
|
134
|
+
'/socket.io.min.js',
|
|
135
|
+
'https://usions.com/socket.io.min.js',
|
|
136
|
+
'https://cdn.socket.io/4.7.2/socket.io.min.js'
|
|
137
|
+
];
|
|
138
|
+
(function tryLoad(i) {
|
|
139
|
+
if (i >= sources.length) {
|
|
140
140
|
self._connecting = false;
|
|
141
141
|
reject(new Error('Failed to load Socket.IO client'));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
var script = document.createElement('script');
|
|
145
|
+
script.src = sources[i];
|
|
146
|
+
script.onload = function() {
|
|
147
|
+
self._initSocket(socketUrl, token, resolve, reject);
|
|
148
|
+
};
|
|
149
|
+
script.onerror = function() {
|
|
150
|
+
tryLoad(i + 1);
|
|
142
151
|
};
|
|
143
|
-
document.head.appendChild(
|
|
144
|
-
};
|
|
145
|
-
document.head.appendChild(script);
|
|
152
|
+
document.head.appendChild(script);
|
|
153
|
+
})(0);
|
|
146
154
|
} else {
|
|
147
155
|
self._initSocket(socketUrl, token, resolve, reject);
|
|
148
156
|
}
|
|
@@ -241,6 +249,8 @@ export function createGameModule(Usion) {
|
|
|
241
249
|
applyGameProxy(game, Usion);
|
|
242
250
|
applyGameMethods(game, Usion);
|
|
243
251
|
applyGameNetcode(game, Usion);
|
|
252
|
+
// Applied last: wraps connect/connectDirect and rides dispatched events.
|
|
253
|
+
applyGameReliability(game, Usion);
|
|
244
254
|
|
|
245
255
|
// Foreground catch-up safety net (generic across every transport).
|
|
246
256
|
//
|
|
@@ -66,12 +66,20 @@ export function applyGameMethods(game, Usion) {
|
|
|
66
66
|
self.socket.emit('game:join', { room_id: roomId }, function(response) {
|
|
67
67
|
if (response.error) {
|
|
68
68
|
self._joined = false;
|
|
69
|
-
reject(toUsionError(response
|
|
69
|
+
reject(toUsionError(response));
|
|
70
70
|
} else {
|
|
71
71
|
self._joined = true;
|
|
72
72
|
if (response.sequence !== undefined) {
|
|
73
73
|
self._lastSequence = response.sequence;
|
|
74
|
+
// The joined state reflects everything up to this sequence —
|
|
75
|
+
// actions at or below it must not be re-delivered (same
|
|
76
|
+
// baseline the proxy transport sets from GAME_JOINED).
|
|
77
|
+
self._lastActionApplied = Math.max(self._lastActionApplied, response.sequence);
|
|
74
78
|
}
|
|
79
|
+
// Transport parity: proxy and direct modes dispatch 'joined';
|
|
80
|
+
// the socket transport now does too, so onJoined / recovery
|
|
81
|
+
// logic behaves the same everywhere.
|
|
82
|
+
self._dispatch('joined', response);
|
|
75
83
|
resolve(response);
|
|
76
84
|
}
|
|
77
85
|
});
|
|
@@ -161,36 +169,81 @@ export function applyGameMethods(game, Usion) {
|
|
|
161
169
|
// move can't go out before the client has resynced.
|
|
162
170
|
const gate = self._rejoinPromise || Promise.resolve();
|
|
163
171
|
return gate.then(function() {
|
|
164
|
-
|
|
165
|
-
if (
|
|
166
|
-
|
|
167
|
-
self._queueOfflineAction(actionType, actionData, opts).then(resolve, reject);
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
reject(new UsionError(ERROR_CODES.NOT_CONNECTED, 'Not connected'));
|
|
171
|
-
return;
|
|
172
|
+
if (!self.socket || !self.connected) {
|
|
173
|
+
if (queueOffline) {
|
|
174
|
+
return self._queueOfflineAction(actionType, actionData, opts);
|
|
172
175
|
}
|
|
176
|
+
throw new UsionError(ERROR_CODES.NOT_CONNECTED, 'Not connected');
|
|
177
|
+
}
|
|
178
|
+
const payload = {
|
|
179
|
+
room_id: self.roomId,
|
|
180
|
+
action_type: actionType,
|
|
181
|
+
action_data: actionData
|
|
182
|
+
};
|
|
183
|
+
if (nextTurn) payload.next_turn = nextTurn;
|
|
184
|
+
return self._emitAction(payload, true);
|
|
185
|
+
});
|
|
186
|
+
};
|
|
173
187
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
+
/**
|
|
189
|
+
* @private Emit game:action and handle the ack. On NOT_IN_ROOM (the server
|
|
190
|
+
* detected a detached socket — connected but no room membership), auto-
|
|
191
|
+
* rejoin + resync once and retry the same action, so a move made in the
|
|
192
|
+
* detached window is delivered instead of silently lost.
|
|
193
|
+
*/
|
|
194
|
+
game._emitAction = function(payload, retryOnNotInRoom) {
|
|
195
|
+
const self = this;
|
|
196
|
+
return new Promise(function(resolve, reject) {
|
|
197
|
+
if (!self.socket || !self.connected) {
|
|
198
|
+
reject(new UsionError(ERROR_CODES.NOT_CONNECTED, 'Not connected'));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
self.socket.emit('game:action', payload, function(response) {
|
|
202
|
+
if (response && response.error) {
|
|
203
|
+
const ue = toUsionError(response);
|
|
204
|
+
if (retryOnNotInRoom && ue.code === ERROR_CODES.NOT_IN_ROOM) {
|
|
205
|
+
self._autoRejoin()
|
|
206
|
+
.then(function() { return self._emitAction(payload, false); })
|
|
207
|
+
.then(resolve, reject);
|
|
208
|
+
return;
|
|
188
209
|
}
|
|
189
|
-
|
|
210
|
+
reject(ue);
|
|
211
|
+
} else {
|
|
212
|
+
if (response && response.sequence !== undefined) {
|
|
213
|
+
self._lastSequence = response.sequence;
|
|
214
|
+
}
|
|
215
|
+
resolve(response);
|
|
216
|
+
}
|
|
190
217
|
});
|
|
191
218
|
});
|
|
192
219
|
};
|
|
193
220
|
|
|
221
|
+
/**
|
|
222
|
+
* @private Recover a detached socket: the server said we're not in the
|
|
223
|
+
* Socket.IO room (NOT_IN_ROOM), so rejoin + resync exactly like the
|
|
224
|
+
* post-reconnect path, gating action() sends on _rejoinPromise meanwhile.
|
|
225
|
+
*/
|
|
226
|
+
game._autoRejoin = function() {
|
|
227
|
+
const self = this;
|
|
228
|
+
if (self._rejoinPromise) return self._rejoinPromise;
|
|
229
|
+
if (!self.roomId || !self.socket || !self.connected) return Promise.resolve();
|
|
230
|
+
Usion.log('Server reports socket not in room - auto-rejoining ' + self.roomId);
|
|
231
|
+
self._joined = false;
|
|
232
|
+
self._joinPromise = null;
|
|
233
|
+
self._rejoinPromise = self.join(self.roomId)
|
|
234
|
+
.then(function() {
|
|
235
|
+
self.requestSync(self._lastSequence || 0);
|
|
236
|
+
})
|
|
237
|
+
.catch(function(err) {
|
|
238
|
+
Usion.log('Auto-rejoin failed: ' + (err && err.message ? err.message : String(err)));
|
|
239
|
+
})
|
|
240
|
+
.then(function() {
|
|
241
|
+
self._rejoinPromise = null;
|
|
242
|
+
if (self._flushOfflineQueue) self._flushOfflineQueue();
|
|
243
|
+
});
|
|
244
|
+
return self._rejoinPromise;
|
|
245
|
+
};
|
|
246
|
+
|
|
194
247
|
// ── Offline action queue (opt-in via action(..., { queueOffline: true })) ──
|
|
195
248
|
|
|
196
249
|
const OFFLINE_QUEUE_MAX = 20;
|
|
@@ -265,11 +318,17 @@ export function applyGameMethods(game, Usion) {
|
|
|
265
318
|
return Promise.resolve({ success: false, error: 'not_supported_in_direct_mode', code: ERROR_CODES.UNSUPPORTED });
|
|
266
319
|
}
|
|
267
320
|
|
|
321
|
+
// The checkpoint carries the action sequence this state reflects. The
|
|
322
|
+
// server persists it and rejects OLDER checkpoints (STALE_STATE), so two
|
|
323
|
+
// concurrent writers can't clobber newer state with older.
|
|
324
|
+
const checkpointSeq = self._lastSequence || 0;
|
|
325
|
+
|
|
268
326
|
if (self._useProxy) {
|
|
269
|
-
return Usion._request('GAME_SET_STATE', { room_id: self.roomId, state: state || {} })
|
|
327
|
+
return Usion._request('GAME_SET_STATE', { room_id: self.roomId, state: state || {}, sequence: checkpointSeq })
|
|
270
328
|
.then(function(res) {
|
|
271
329
|
if (res && res.error) {
|
|
272
|
-
|
|
330
|
+
const rue = toUsionError(res);
|
|
331
|
+
return { success: false, error: rue.message, code: rue.code };
|
|
273
332
|
}
|
|
274
333
|
return res || { success: true };
|
|
275
334
|
})
|
|
@@ -286,10 +345,12 @@ export function applyGameMethods(game, Usion) {
|
|
|
286
345
|
}
|
|
287
346
|
self.socket.emit('game:set_state', {
|
|
288
347
|
room_id: self.roomId,
|
|
289
|
-
state: state || {}
|
|
348
|
+
state: state || {},
|
|
349
|
+
sequence: checkpointSeq
|
|
290
350
|
}, function(response) {
|
|
291
351
|
if (response && response.error) {
|
|
292
|
-
|
|
352
|
+
const ue = toUsionError(response);
|
|
353
|
+
resolve({ success: false, error: ue.message, code: ue.code });
|
|
293
354
|
} else {
|
|
294
355
|
resolve(response || { success: true });
|
|
295
356
|
}
|
|
@@ -322,10 +383,18 @@ export function applyGameMethods(game, Usion) {
|
|
|
322
383
|
return;
|
|
323
384
|
}
|
|
324
385
|
|
|
386
|
+
// Still fire-and-forget for the game, but the server now acks failures
|
|
387
|
+
// (previously dropped silently): surface them via onError, and on
|
|
388
|
+
// NOT_IN_ROOM (detached socket) auto-rejoin so the stream recovers.
|
|
325
389
|
self.socket.emit('game:realtime', {
|
|
326
390
|
room_id: self.roomId,
|
|
327
391
|
action_type: actionType,
|
|
328
392
|
action_data: actionData
|
|
393
|
+
}, function(response) {
|
|
394
|
+
if (!response || !response.error) return;
|
|
395
|
+
const ue = toUsionError(response);
|
|
396
|
+
self._dispatch('error', { code: ue.code, message: ue.message, source: 'realtime' });
|
|
397
|
+
if (ue.code === ERROR_CODES.NOT_IN_ROOM) self._autoRejoin();
|
|
329
398
|
});
|
|
330
399
|
};
|
|
331
400
|
|
|
@@ -401,7 +470,7 @@ export function applyGameMethods(game, Usion) {
|
|
|
401
470
|
|
|
402
471
|
self.socket.emit('game:forfeit', { room_id: self.roomId }, function(response) {
|
|
403
472
|
if (response.error) {
|
|
404
|
-
reject(toUsionError(response
|
|
473
|
+
reject(toUsionError(response));
|
|
405
474
|
} else {
|
|
406
475
|
resolve(response);
|
|
407
476
|
}
|