@usions/sdk 2.21.0 → 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 +401 -156
- 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 +22 -17
- package/src/modules/game-methods.js +90 -29
- 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 +39 -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
|
@@ -126,26 +126,31 @@ export function createGameModule(Usion) {
|
|
|
126
126
|
self._connectPromise = new Promise(function(resolve, reject) {
|
|
127
127
|
// Check if socket.io-client is available
|
|
128
128
|
if (typeof io === 'undefined') {
|
|
129
|
-
// Load
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
self._initSocket(socketUrl, token, resolve, reject);
|
|
141
|
-
};
|
|
142
|
-
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) {
|
|
143
140
|
self._connecting = false;
|
|
144
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);
|
|
145
151
|
};
|
|
146
|
-
document.head.appendChild(
|
|
147
|
-
};
|
|
148
|
-
document.head.appendChild(script);
|
|
152
|
+
document.head.appendChild(script);
|
|
153
|
+
})(0);
|
|
149
154
|
} else {
|
|
150
155
|
self._initSocket(socketUrl, token, resolve, reject);
|
|
151
156
|
}
|
|
@@ -66,7 +66,7 @@ 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) {
|
|
@@ -169,36 +169,81 @@ export function applyGameMethods(game, Usion) {
|
|
|
169
169
|
// move can't go out before the client has resynced.
|
|
170
170
|
const gate = self._rejoinPromise || Promise.resolve();
|
|
171
171
|
return gate.then(function() {
|
|
172
|
-
|
|
173
|
-
if (
|
|
174
|
-
|
|
175
|
-
self._queueOfflineAction(actionType, actionData, opts).then(resolve, reject);
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
reject(new UsionError(ERROR_CODES.NOT_CONNECTED, 'Not connected'));
|
|
179
|
-
return;
|
|
172
|
+
if (!self.socket || !self.connected) {
|
|
173
|
+
if (queueOffline) {
|
|
174
|
+
return self._queueOfflineAction(actionType, actionData, opts);
|
|
180
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
|
+
};
|
|
181
187
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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;
|
|
196
209
|
}
|
|
197
|
-
|
|
210
|
+
reject(ue);
|
|
211
|
+
} else {
|
|
212
|
+
if (response && response.sequence !== undefined) {
|
|
213
|
+
self._lastSequence = response.sequence;
|
|
214
|
+
}
|
|
215
|
+
resolve(response);
|
|
216
|
+
}
|
|
198
217
|
});
|
|
199
218
|
});
|
|
200
219
|
};
|
|
201
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
|
+
|
|
202
247
|
// ── Offline action queue (opt-in via action(..., { queueOffline: true })) ──
|
|
203
248
|
|
|
204
249
|
const OFFLINE_QUEUE_MAX = 20;
|
|
@@ -273,11 +318,17 @@ export function applyGameMethods(game, Usion) {
|
|
|
273
318
|
return Promise.resolve({ success: false, error: 'not_supported_in_direct_mode', code: ERROR_CODES.UNSUPPORTED });
|
|
274
319
|
}
|
|
275
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
|
+
|
|
276
326
|
if (self._useProxy) {
|
|
277
|
-
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 })
|
|
278
328
|
.then(function(res) {
|
|
279
329
|
if (res && res.error) {
|
|
280
|
-
|
|
330
|
+
const rue = toUsionError(res);
|
|
331
|
+
return { success: false, error: rue.message, code: rue.code };
|
|
281
332
|
}
|
|
282
333
|
return res || { success: true };
|
|
283
334
|
})
|
|
@@ -294,10 +345,12 @@ export function applyGameMethods(game, Usion) {
|
|
|
294
345
|
}
|
|
295
346
|
self.socket.emit('game:set_state', {
|
|
296
347
|
room_id: self.roomId,
|
|
297
|
-
state: state || {}
|
|
348
|
+
state: state || {},
|
|
349
|
+
sequence: checkpointSeq
|
|
298
350
|
}, function(response) {
|
|
299
351
|
if (response && response.error) {
|
|
300
|
-
|
|
352
|
+
const ue = toUsionError(response);
|
|
353
|
+
resolve({ success: false, error: ue.message, code: ue.code });
|
|
301
354
|
} else {
|
|
302
355
|
resolve(response || { success: true });
|
|
303
356
|
}
|
|
@@ -330,10 +383,18 @@ export function applyGameMethods(game, Usion) {
|
|
|
330
383
|
return;
|
|
331
384
|
}
|
|
332
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.
|
|
333
389
|
self.socket.emit('game:realtime', {
|
|
334
390
|
room_id: self.roomId,
|
|
335
391
|
action_type: actionType,
|
|
336
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();
|
|
337
398
|
});
|
|
338
399
|
};
|
|
339
400
|
|
|
@@ -409,7 +470,7 @@ export function applyGameMethods(game, Usion) {
|
|
|
409
470
|
|
|
410
471
|
self.socket.emit('game:forfeit', { room_id: self.roomId }, function(response) {
|
|
411
472
|
if (response.error) {
|
|
412
|
-
reject(toUsionError(response
|
|
473
|
+
reject(toUsionError(response));
|
|
413
474
|
} else {
|
|
414
475
|
resolve(response);
|
|
415
476
|
}
|
|
@@ -7,12 +7,15 @@
|
|
|
7
7
|
*
|
|
8
8
|
* const m = await Usion.matchmaking.find(); // resolves when matched
|
|
9
9
|
* await Usion.game.connect(); await Usion.game.join(m.roomId);
|
|
10
|
-
* // ...or cancel while waiting:
|
|
10
|
+
* // ...or bound the wait / cancel while waiting:
|
|
11
|
+
* const m2 = await Usion.matchmaking.find(null, { timeout: 30000 });
|
|
11
12
|
* Usion.matchmaking.cancel();
|
|
12
13
|
* Usion.matchmaking.onMatch(({ roomId, players }) => { ... });
|
|
13
14
|
*/
|
|
15
|
+
import { UsionError, toUsionError, ERROR_CODES } from './errors.js';
|
|
16
|
+
|
|
14
17
|
export function createMatchmakingModule(Usion) {
|
|
15
|
-
let pending = null; // { resolve, reject }
|
|
18
|
+
let pending = null; // active find() entry: { resolve, reject }
|
|
16
19
|
let onMatchCb = null;
|
|
17
20
|
let bound = false;
|
|
18
21
|
|
|
@@ -36,25 +39,64 @@ export function createMatchmakingModule(Usion) {
|
|
|
36
39
|
|
|
37
40
|
/**
|
|
38
41
|
* Join the queue for `serviceId` (defaults to the current game) and resolve
|
|
39
|
-
* when matched with { roomId, players, serviceId }.
|
|
40
|
-
* match (use cancel() to stop waiting)
|
|
42
|
+
* when matched with { roomId, players, serviceId }. Without `opts.timeout`
|
|
43
|
+
* it stays pending until a match (use cancel() to stop waiting); with it,
|
|
44
|
+
* rejects MATCH_TIMEOUT (and leaves the queue) once the wait elapses.
|
|
45
|
+
* @param {string} [serviceId]
|
|
46
|
+
* @param {{size?: number, timeout?: number}} [opts]
|
|
41
47
|
*/
|
|
42
48
|
find: function (serviceId, opts) {
|
|
43
49
|
opts = opts || {};
|
|
44
50
|
const sid = serviceId || (Usion.config && Usion.config.serviceId);
|
|
45
51
|
bind();
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
|
|
53
|
+
if (pending) {
|
|
54
|
+
const old = pending; pending = null;
|
|
55
|
+
old.reject(new UsionError(ERROR_CODES.SUPERSEDED, 'Superseded by a newer find()'));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let timer = null;
|
|
59
|
+
const entry = {};
|
|
60
|
+
const promise = new Promise(function (resolve, reject) {
|
|
61
|
+
entry.resolve = function (r) {
|
|
62
|
+
if (timer) clearTimeout(timer);
|
|
63
|
+
resolve(r);
|
|
64
|
+
};
|
|
65
|
+
entry.reject = function (e) {
|
|
66
|
+
if (timer) clearTimeout(timer);
|
|
67
|
+
if (pending === entry) pending = null;
|
|
68
|
+
reject(e);
|
|
69
|
+
};
|
|
52
70
|
});
|
|
71
|
+
|
|
72
|
+
// Register BEFORE emitting mm:join: when someone is already waiting in
|
|
73
|
+
// the queue, mm:matched can arrive ahead of the join ack — registering
|
|
74
|
+
// afterwards dropped that instant match and left find() hanging forever.
|
|
75
|
+
pending = entry;
|
|
76
|
+
|
|
77
|
+
if (opts.timeout > 0) {
|
|
78
|
+
timer = setTimeout(function () {
|
|
79
|
+
timer = null;
|
|
80
|
+
Usion._backendEmit('mm:cancel', {}).catch(function () { /* best-effort dequeue */ });
|
|
81
|
+
entry.reject(new UsionError(ERROR_CODES.MATCH_TIMEOUT,
|
|
82
|
+
'No match within ' + opts.timeout + 'ms'));
|
|
83
|
+
}, opts.timeout);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
Usion._backendEmit('mm:join', { service_id: sid, size: opts.size || 2 })
|
|
87
|
+
.catch(function (err) {
|
|
88
|
+
if (pending === entry) entry.reject(toUsionError(err));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return promise;
|
|
53
92
|
},
|
|
54
93
|
|
|
55
94
|
/** Leave the queue / stop waiting. */
|
|
56
95
|
cancel: function () {
|
|
57
|
-
if (pending) {
|
|
96
|
+
if (pending) {
|
|
97
|
+
const old = pending; pending = null;
|
|
98
|
+
old.reject(new UsionError(ERROR_CODES.CANCELLED, 'cancelled'));
|
|
99
|
+
}
|
|
58
100
|
return Usion._backendEmit('mm:cancel', {});
|
|
59
101
|
},
|
|
60
102
|
};
|
package/src/modules/misc.js
CHANGED
|
@@ -127,9 +127,10 @@ export const miscMethods = {
|
|
|
127
127
|
* Listen for messages from parent app
|
|
128
128
|
* @param {string} type - Message type to listen for
|
|
129
129
|
* @param {function} callback - Handler function
|
|
130
|
+
* @returns {function} Unsubscribe function
|
|
130
131
|
*/
|
|
131
132
|
on: function(type, callback) {
|
|
132
|
-
|
|
133
|
+
function listener(event) {
|
|
133
134
|
if (!isTrustedMessageSource(event)) return;
|
|
134
135
|
|
|
135
136
|
let data;
|
|
@@ -142,6 +143,10 @@ export const miscMethods = {
|
|
|
142
143
|
if (data.type === type) {
|
|
143
144
|
callback(data);
|
|
144
145
|
}
|
|
145
|
-
}
|
|
146
|
+
}
|
|
147
|
+
window.addEventListener('message', listener);
|
|
148
|
+
return function() {
|
|
149
|
+
window.removeEventListener('message', listener);
|
|
150
|
+
};
|
|
146
151
|
}
|
|
147
152
|
};
|