@usions/sdk 2.11.0 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -0
- package/package.json +15 -7
- package/src/browser.js +472 -122
- package/src/modules/core.js +1 -1
- package/src/modules/errors.js +75 -0
- package/src/modules/game-core.js +105 -19
- package/src/modules/game-direct.js +12 -16
- package/src/modules/game-methods.js +161 -21
- package/src/modules/game-proxy.js +46 -17
- package/src/modules/game-socket.js +43 -47
- package/src/modules/index.js +4 -0
- package/src/modules/misc.js +20 -0
- package/src/modules/wallet.js +7 -1
- package/types/index.d.ts +162 -20
package/src/modules/core.js
CHANGED
|
@@ -66,7 +66,7 @@ export function isTrustedMessageSource(event) {
|
|
|
66
66
|
* Core Usion object with init, _post, _request
|
|
67
67
|
*/
|
|
68
68
|
export const core = {
|
|
69
|
-
version: '
|
|
69
|
+
version: '__USION_SDK_VERSION__', // injected from package.json at build
|
|
70
70
|
config: {},
|
|
71
71
|
_initialized: false,
|
|
72
72
|
_initCallback: null,
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usion SDK Errors — stable, machine-readable error codes.
|
|
3
|
+
*
|
|
4
|
+
* Developers should branch on `err.code`, never on message text. Messages
|
|
5
|
+
* are human-readable and may change; codes are part of the public API and
|
|
6
|
+
* follow the deprecation policy (never removed within a major version).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** @type {Record<string, string>} */
|
|
10
|
+
export const ERROR_CODES = {
|
|
11
|
+
NOT_CONNECTED: 'NOT_CONNECTED', // No live connection for this call
|
|
12
|
+
NO_ROOM: 'NO_ROOM', // No room id provided/known
|
|
13
|
+
ROOM_NOT_FOUND: 'ROOM_NOT_FOUND', // Room does not exist server-side
|
|
14
|
+
NOT_PARTICIPANT: 'NOT_PARTICIPANT', // Caller is not a player in the room
|
|
15
|
+
NOT_AUTHORITY: 'NOT_AUTHORITY', // Authority-only call (e.g. setState)
|
|
16
|
+
NOT_AUTHENTICATED: 'NOT_AUTHENTICATED', // Missing/invalid auth server-side
|
|
17
|
+
JOIN_TIMEOUT: 'JOIN_TIMEOUT', // Join did not complete in time
|
|
18
|
+
CONNECT_TIMEOUT: 'CONNECT_TIMEOUT', // Connect did not complete in time
|
|
19
|
+
STATE_TOO_LARGE: 'STATE_TOO_LARGE', // setState payload over the quota
|
|
20
|
+
INVALID_STATE: 'INVALID_STATE', // setState payload not a JSON object
|
|
21
|
+
INVALID_NEXT_TURN: 'INVALID_NEXT_TURN', // nextTurn is not a player in the room
|
|
22
|
+
RATE_LIMITED: 'RATE_LIMITED', // Too many calls; back off
|
|
23
|
+
REQUEST_TIMEOUT: 'REQUEST_TIMEOUT', // Host/parent did not reply in time
|
|
24
|
+
QUEUE_FULL: 'QUEUE_FULL', // Offline action queue at capacity
|
|
25
|
+
UNSUPPORTED: 'UNSUPPORTED', // Not available in this transport
|
|
26
|
+
UNKNOWN: 'UNKNOWN', // Unmapped error (see message)
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export class UsionError extends Error {
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} code - One of ERROR_CODES
|
|
32
|
+
* @param {string} [message] - Human-readable detail (may change between versions)
|
|
33
|
+
*/
|
|
34
|
+
constructor(code, message) {
|
|
35
|
+
super(message || code);
|
|
36
|
+
this.name = 'UsionError';
|
|
37
|
+
this.code = ERROR_CODES[code] ? code : ERROR_CODES.UNKNOWN;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Backend error strings → stable codes. Order matters: first match wins.
|
|
42
|
+
/** @type {Array<[RegExp, string]>} */
|
|
43
|
+
const BACKEND_PATTERNS = [
|
|
44
|
+
[/not authenticated/i, ERROR_CODES.NOT_AUTHENTICATED],
|
|
45
|
+
[/room_id required|no room id/i, ERROR_CODES.NO_ROOM],
|
|
46
|
+
[/room not found/i, ERROR_CODES.ROOM_NOT_FOUND],
|
|
47
|
+
[/not a participant/i, ERROR_CODES.NOT_PARTICIPANT],
|
|
48
|
+
[/room authority/i, ERROR_CODES.NOT_AUTHORITY],
|
|
49
|
+
[/exceeds .*limit/i, ERROR_CODES.STATE_TOO_LARGE],
|
|
50
|
+
[/state must be/i, ERROR_CODES.INVALID_STATE],
|
|
51
|
+
[/next_turn must be/i, ERROR_CODES.INVALID_NEXT_TURN],
|
|
52
|
+
[/rate limit|too many/i, ERROR_CODES.RATE_LIMITED],
|
|
53
|
+
[/join timeout/i, ERROR_CODES.JOIN_TIMEOUT],
|
|
54
|
+
[/connection timeout|connect timeout/i, ERROR_CODES.CONNECT_TIMEOUT],
|
|
55
|
+
[/request timeout/i, ERROR_CODES.REQUEST_TIMEOUT],
|
|
56
|
+
[/not connected/i, ERROR_CODES.NOT_CONNECTED],
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Normalize anything (backend `{error}` string, Error, raw string) into a
|
|
61
|
+
* UsionError with the best-matching stable code.
|
|
62
|
+
* @param {*} err
|
|
63
|
+
* @param {string} [fallbackCode] - Code to use when nothing matches
|
|
64
|
+
* @returns {UsionError}
|
|
65
|
+
*/
|
|
66
|
+
export function toUsionError(err, fallbackCode) {
|
|
67
|
+
if (err instanceof UsionError) return err;
|
|
68
|
+
const message = err && err.message ? err.message : String(err || 'Unknown error');
|
|
69
|
+
for (let i = 0; i < BACKEND_PATTERNS.length; i++) {
|
|
70
|
+
if (BACKEND_PATTERNS[i][0].test(message)) {
|
|
71
|
+
return new UsionError(BACKEND_PATTERNS[i][1], message);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return new UsionError(fallbackCode || ERROR_CODES.UNKNOWN, message);
|
|
75
|
+
}
|
package/src/modules/game-core.js
CHANGED
|
@@ -8,6 +8,36 @@ import { applyGameProxy } from './game-proxy.js';
|
|
|
8
8
|
import { applyGameMethods } from './game-methods.js';
|
|
9
9
|
import { applyGameNetcode } from './game-netcode.js';
|
|
10
10
|
|
|
11
|
+
// Map any reasonable spelling of a game event onto the internal handler
|
|
12
|
+
// name: 'game:player_joined' / 'player_joined' / 'playerJoined' → 'playerJoined'.
|
|
13
|
+
const _EVENT_ALIASES = {
|
|
14
|
+
joined: 'joined',
|
|
15
|
+
player_joined: 'playerJoined',
|
|
16
|
+
player_left: 'playerLeft',
|
|
17
|
+
player_connection: 'playerConnection',
|
|
18
|
+
state: 'stateUpdate',
|
|
19
|
+
state_update: 'stateUpdate',
|
|
20
|
+
sync: 'sync',
|
|
21
|
+
action: 'action',
|
|
22
|
+
realtime: 'realtime',
|
|
23
|
+
finished: 'finished',
|
|
24
|
+
restarted: 'restarted',
|
|
25
|
+
error: 'error',
|
|
26
|
+
rematch_request: 'rematchRequest',
|
|
27
|
+
disconnect: 'disconnect',
|
|
28
|
+
reconnect: 'reconnect',
|
|
29
|
+
connection_error: 'connectionError',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function _normalizeEventName(event) {
|
|
33
|
+
let name = String(event || '');
|
|
34
|
+
if (name.indexOf('game:') === 0) name = name.slice(5);
|
|
35
|
+
if (_EVENT_ALIASES[name]) return _EVENT_ALIASES[name];
|
|
36
|
+
// camelCase passthrough ('playerJoined', 'stateUpdate', …)
|
|
37
|
+
const snake = name.replace(/([A-Z])/g, function(m) { return '_' + m.toLowerCase(); });
|
|
38
|
+
return _EVENT_ALIASES[snake] || name;
|
|
39
|
+
}
|
|
40
|
+
|
|
11
41
|
/**
|
|
12
42
|
* Create the game module with all sub-modules applied
|
|
13
43
|
* @param {object} Usion - Reference to the main Usion object
|
|
@@ -33,6 +63,11 @@ export function createGameModule(Usion) {
|
|
|
33
63
|
_heartbeatInterval: null,
|
|
34
64
|
_pingMeter: null,
|
|
35
65
|
_pongWaiters: [],
|
|
66
|
+
// Reliability state: highest action sequence already delivered to the
|
|
67
|
+
// game (duplicate echoes/replays are dropped), and the in-flight rejoin
|
|
68
|
+
// promise that gates action() sends right after a reconnect.
|
|
69
|
+
_lastActionApplied: 0,
|
|
70
|
+
_rejoinPromise: null,
|
|
36
71
|
|
|
37
72
|
/**
|
|
38
73
|
* Connect to the game socket server
|
|
@@ -115,34 +150,85 @@ export function createGameModule(Usion) {
|
|
|
115
150
|
return self._connectPromise;
|
|
116
151
|
},
|
|
117
152
|
|
|
118
|
-
// Event handler registrations
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
153
|
+
// Event handler registrations.
|
|
154
|
+
//
|
|
155
|
+
// Each onX(cb) keeps the long-standing "single handler, last one wins"
|
|
156
|
+
// behavior for back-compat, but now ALSO returns an unsubscribe
|
|
157
|
+
// function. For multiple listeners use game.on(event, cb), which
|
|
158
|
+
// supports any number of listeners, works before connect() in every
|
|
159
|
+
// transport, and returns an unsubscribe function.
|
|
160
|
+
onJoined: function(callback) { return this._setHandler('joined', callback); },
|
|
161
|
+
onPlayerJoined: function(callback) { return this._setHandler('playerJoined', callback); },
|
|
162
|
+
onPlayerLeft: function(callback) { return this._setHandler('playerLeft', callback); },
|
|
163
|
+
onStateUpdate: function(callback) { return this._setHandler('stateUpdate', callback); },
|
|
164
|
+
onSync: function(callback) { return this._setHandler('sync', callback); },
|
|
165
|
+
onAction: function(callback) { return this._setHandler('action', callback); },
|
|
166
|
+
onRealtime: function(callback) { return this._setHandler('realtime', callback); },
|
|
167
|
+
onGameFinished: function(callback) { return this._setHandler('finished', callback); },
|
|
168
|
+
onGameRestarted: function(callback) { return this._setHandler('restarted', callback); },
|
|
169
|
+
onError: function(callback) { return this._setHandler('error', callback); },
|
|
170
|
+
onRematchRequest: function(callback) { return this._setHandler('rematchRequest', callback); },
|
|
171
|
+
onDisconnect: function(callback) { return this._setHandler('disconnect', callback); },
|
|
172
|
+
onReconnect: function(callback) { return this._setHandler('reconnect', callback); },
|
|
173
|
+
onConnectionError: function(callback) { return this._setHandler('connectionError', callback); },
|
|
174
|
+
onPlayerConnection: function(callback) { return this._setHandler('playerConnection', callback); },
|
|
175
|
+
|
|
176
|
+
/** @private Set the single legacy handler; returns an unsubscribe fn. */
|
|
177
|
+
_setHandler: function(name, callback) {
|
|
178
|
+
const self = this;
|
|
179
|
+
self._eventHandlers[name] = callback;
|
|
180
|
+
return function() {
|
|
181
|
+
if (self._eventHandlers[name] === callback) {
|
|
182
|
+
self._eventHandlers[name] = null;
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
},
|
|
133
186
|
|
|
134
187
|
/**
|
|
135
|
-
*
|
|
188
|
+
* @private Deliver an event to the legacy single handler and every
|
|
189
|
+
* game.on() listener. All game event delivery flows through here.
|
|
190
|
+
*/
|
|
191
|
+
_dispatch: function(name) {
|
|
192
|
+
const args = Array.prototype.slice.call(arguments, 1);
|
|
193
|
+
const single = this._eventHandlers[name];
|
|
194
|
+
if (single) {
|
|
195
|
+
try { single.apply(null, args); } catch (e) { Usion.log('game handler error (' + name + '): ' + e.message); }
|
|
196
|
+
}
|
|
197
|
+
const list = this._listeners[name];
|
|
198
|
+
if (list && list.length) {
|
|
199
|
+
const copy = list.slice();
|
|
200
|
+
for (let i = 0; i < copy.length; i++) {
|
|
201
|
+
try { copy[i].apply(null, args); } catch (e) { Usion.log('game listener error (' + name + '): ' + e.message); }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Register an additional event listener. Unlike the onX methods this
|
|
208
|
+
* supports multiple listeners per event, can be called before
|
|
209
|
+
* connect(), and works in every transport (standalone, embedded
|
|
210
|
+
* proxy, direct). Accepts internal names ('action'), wire names
|
|
211
|
+
* ('game:action'), or snake_case ('player_joined').
|
|
136
212
|
* @param {string} event - Event name
|
|
137
213
|
* @param {function} callback - Handler function
|
|
214
|
+
* @returns {function} Unsubscribe function
|
|
138
215
|
*/
|
|
139
216
|
on: function(event, callback) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
217
|
+
const self = this;
|
|
218
|
+
const name = _normalizeEventName(event);
|
|
219
|
+
if (!self._listeners[name]) self._listeners[name] = [];
|
|
220
|
+
self._listeners[name].push(callback);
|
|
221
|
+
return function() {
|
|
222
|
+
const list = self._listeners[name];
|
|
223
|
+
if (!list) return;
|
|
224
|
+
const i = list.indexOf(callback);
|
|
225
|
+
if (i >= 0) list.splice(i, 1);
|
|
226
|
+
};
|
|
143
227
|
}
|
|
144
228
|
};
|
|
145
229
|
|
|
230
|
+
game._listeners = {};
|
|
231
|
+
|
|
146
232
|
// Apply sub-modules
|
|
147
233
|
applyGameDirect(game, Usion);
|
|
148
234
|
applyGameSocket(game, Usion);
|
|
@@ -43,9 +43,7 @@ export function applyGameDirect(game, Usion) {
|
|
|
43
43
|
self._connecting = false;
|
|
44
44
|
self.connected = false;
|
|
45
45
|
self.directMode = false;
|
|
46
|
-
|
|
47
|
-
self._eventHandlers.connectionError(err);
|
|
48
|
-
}
|
|
46
|
+
self._dispatch('connectionError', err);
|
|
49
47
|
throw err;
|
|
50
48
|
});
|
|
51
49
|
return self._connectPromise;
|
|
@@ -154,9 +152,7 @@ export function applyGameDirect(game, Usion) {
|
|
|
154
152
|
clearInterval(self._heartbeatInterval);
|
|
155
153
|
self._heartbeatInterval = null;
|
|
156
154
|
}
|
|
157
|
-
|
|
158
|
-
self._eventHandlers.disconnect(evt && evt.reason ? evt.reason : 'direct socket closed');
|
|
159
|
-
}
|
|
155
|
+
self._dispatch('disconnect', evt && evt.reason ? evt.reason : 'direct socket closed');
|
|
160
156
|
// Seamless resume: if the drop wasn't an intentional disconnect()
|
|
161
157
|
// (which clears directMode), transparently re-establish + re-join +
|
|
162
158
|
// resync from the last sequence.
|
|
@@ -190,7 +186,7 @@ export function applyGameDirect(game, Usion) {
|
|
|
190
186
|
self.connected = true;
|
|
191
187
|
self._reconnecting = false;
|
|
192
188
|
self._reconnectAttempt = 0;
|
|
193
|
-
|
|
189
|
+
self._dispatch('reconnect', attempt);
|
|
194
190
|
if (self.roomId) self.requestSync(self._lastSequence || 0); // resync / resume
|
|
195
191
|
})
|
|
196
192
|
.catch(function() {
|
|
@@ -228,20 +224,20 @@ export function applyGameDirect(game, Usion) {
|
|
|
228
224
|
|
|
229
225
|
if (data.type === 'joined') {
|
|
230
226
|
this._joined = true;
|
|
231
|
-
|
|
227
|
+
this._dispatch('joined', payload);
|
|
232
228
|
return;
|
|
233
229
|
}
|
|
234
230
|
if (data.type === 'player_joined') {
|
|
235
|
-
|
|
231
|
+
this._dispatch('playerJoined', payload);
|
|
236
232
|
return;
|
|
237
233
|
}
|
|
238
234
|
if (data.type === 'player_left') {
|
|
239
|
-
|
|
235
|
+
this._dispatch('playerLeft', payload);
|
|
240
236
|
return;
|
|
241
237
|
}
|
|
242
238
|
if (data.type === 'state_snapshot' || data.type === 'state_delta') {
|
|
243
|
-
|
|
244
|
-
|
|
239
|
+
this._dispatch('realtime', payload);
|
|
240
|
+
this._dispatch('stateUpdate', payload);
|
|
245
241
|
return;
|
|
246
242
|
}
|
|
247
243
|
if (data.type === 'pong') {
|
|
@@ -250,15 +246,15 @@ export function applyGameDirect(game, Usion) {
|
|
|
250
246
|
const waiter = this._pongWaiters.shift();
|
|
251
247
|
if (waiter) waiter();
|
|
252
248
|
}
|
|
253
|
-
|
|
249
|
+
this._dispatch('sync', payload);
|
|
254
250
|
return;
|
|
255
251
|
}
|
|
256
252
|
if (data.type === 'match_end') {
|
|
257
|
-
|
|
253
|
+
this._dispatch('finished', payload);
|
|
258
254
|
return;
|
|
259
255
|
}
|
|
260
|
-
if (data.type === 'error'
|
|
261
|
-
this.
|
|
256
|
+
if (data.type === 'error') {
|
|
257
|
+
this._dispatch('error', payload);
|
|
262
258
|
}
|
|
263
259
|
};
|
|
264
260
|
}
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Usion SDK Game Methods — join, leave, action, realtime, sync, etc.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { UsionError, toUsionError, ERROR_CODES } from './errors.js';
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* Add game action methods to game module
|
|
7
9
|
* @param {object} game - The game module object
|
|
@@ -43,7 +45,7 @@ export function applyGameMethods(game, Usion) {
|
|
|
43
45
|
setTimeout(function() {
|
|
44
46
|
if (!self._joined && self._proxyJoinReject) {
|
|
45
47
|
self._proxyJoinReject = null;
|
|
46
|
-
reject(new
|
|
48
|
+
reject(new UsionError(ERROR_CODES.JOIN_TIMEOUT, 'Join timeout'));
|
|
47
49
|
}
|
|
48
50
|
}, 15000);
|
|
49
51
|
});
|
|
@@ -52,19 +54,19 @@ export function applyGameMethods(game, Usion) {
|
|
|
52
54
|
|
|
53
55
|
self._joinPromise = new Promise(function(resolve, reject) {
|
|
54
56
|
if (!self.socket || !self.connected) {
|
|
55
|
-
reject(new
|
|
57
|
+
reject(new UsionError(ERROR_CODES.NOT_CONNECTED, 'Not connected'));
|
|
56
58
|
return;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
if (!roomId) {
|
|
60
|
-
reject(new
|
|
62
|
+
reject(new UsionError(ERROR_CODES.NO_ROOM, 'No room ID provided'));
|
|
61
63
|
return;
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
self.socket.emit('game:join', { room_id: roomId }, function(response) {
|
|
65
67
|
if (response.error) {
|
|
66
68
|
self._joined = false;
|
|
67
|
-
reject(
|
|
69
|
+
reject(toUsionError(response.message || response.error));
|
|
68
70
|
} else {
|
|
69
71
|
self._joined = true;
|
|
70
72
|
if (response.sequence !== undefined) {
|
|
@@ -112,13 +114,25 @@ export function applyGameMethods(game, Usion) {
|
|
|
112
114
|
};
|
|
113
115
|
|
|
114
116
|
/**
|
|
115
|
-
* Send a game action
|
|
117
|
+
* Send a game action.
|
|
118
|
+
*
|
|
119
|
+
* RELIABILITY CONTRACT: the platform echoes every action back to the
|
|
120
|
+
* sender (with the authoritative sequence number). Apply game state ONLY
|
|
121
|
+
* in onAction — never optimistically on send — so every client applies
|
|
122
|
+
* the same actions in the same order. The SDK deduplicates by sequence,
|
|
123
|
+
* so an action is delivered exactly once even across reconnect replays.
|
|
124
|
+
*
|
|
116
125
|
* @param {string} actionType - Type of action (e.g., 'move')
|
|
117
126
|
* @param {object} actionData - Action data
|
|
127
|
+
* @param {object} [opts] - Options. opts.nextTurn: player ID whose turn
|
|
128
|
+
* is next — the server remembers it and hands it to any (re)joining
|
|
129
|
+
* client as current_turn, so turn state survives reconnects.
|
|
118
130
|
* @returns {Promise} Resolves when action is processed
|
|
119
131
|
*/
|
|
120
|
-
game.action = function(actionType, actionData) {
|
|
132
|
+
game.action = function(actionType, actionData, opts) {
|
|
121
133
|
const self = this;
|
|
134
|
+
const nextTurn = opts && opts.nextTurn;
|
|
135
|
+
const queueOffline = !!(opts && opts.queueOffline);
|
|
122
136
|
|
|
123
137
|
if (self.directMode) {
|
|
124
138
|
self._sendDirect('action', {
|
|
@@ -128,29 +142,153 @@ export function applyGameMethods(game, Usion) {
|
|
|
128
142
|
return Promise.resolve({ success: true });
|
|
129
143
|
}
|
|
130
144
|
|
|
145
|
+
// Opt-in offline queue: instead of failing while disconnected, hold
|
|
146
|
+
// the move and send it (in order) once the connection recovers.
|
|
147
|
+
// Turn-based games get "your move is saved and sends when you're
|
|
148
|
+
// back" for free; realtime games should NOT use this (stale inputs).
|
|
149
|
+
if (queueOffline && !self.connected) {
|
|
150
|
+
return self._queueOfflineAction(actionType, actionData, opts);
|
|
151
|
+
}
|
|
152
|
+
|
|
131
153
|
if (self._useProxy) {
|
|
132
|
-
|
|
154
|
+
const proxyMsg = { type: 'GAME_ACTION', room_id: self.roomId, action_type: actionType, action_data: actionData };
|
|
155
|
+
if (nextTurn) proxyMsg.next_turn = nextTurn;
|
|
156
|
+
Usion._post(proxyMsg);
|
|
133
157
|
return Promise.resolve({ success: true });
|
|
134
158
|
}
|
|
135
159
|
|
|
160
|
+
// Gate sends while a post-reconnect rejoin is in flight, so a stale
|
|
161
|
+
// move can't go out before the client has resynced.
|
|
162
|
+
const gate = self._rejoinPromise || Promise.resolve();
|
|
163
|
+
return gate.then(function() {
|
|
164
|
+
return new Promise(function(resolve, reject) {
|
|
165
|
+
if (!self.socket || !self.connected) {
|
|
166
|
+
if (queueOffline) {
|
|
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
|
+
}
|
|
173
|
+
|
|
174
|
+
const payload = {
|
|
175
|
+
room_id: self.roomId,
|
|
176
|
+
action_type: actionType,
|
|
177
|
+
action_data: actionData
|
|
178
|
+
};
|
|
179
|
+
if (nextTurn) payload.next_turn = nextTurn;
|
|
180
|
+
self.socket.emit('game:action', payload, function(response) {
|
|
181
|
+
if (response.error) {
|
|
182
|
+
reject(toUsionError(response.message || response.error));
|
|
183
|
+
} else {
|
|
184
|
+
if (response.sequence !== undefined) {
|
|
185
|
+
self._lastSequence = response.sequence;
|
|
186
|
+
}
|
|
187
|
+
resolve(response);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// ── Offline action queue (opt-in via action(..., { queueOffline: true })) ──
|
|
195
|
+
|
|
196
|
+
const OFFLINE_QUEUE_MAX = 20;
|
|
197
|
+
|
|
198
|
+
/** @private Hold an action until the connection recovers. */
|
|
199
|
+
game._queueOfflineAction = function(actionType, actionData, opts) {
|
|
200
|
+
const self = this;
|
|
201
|
+
if (!self._offlineQueue) self._offlineQueue = [];
|
|
202
|
+
if (self._offlineQueue.length >= OFFLINE_QUEUE_MAX) {
|
|
203
|
+
return Promise.reject(new UsionError(ERROR_CODES.QUEUE_FULL,
|
|
204
|
+
'Offline action queue is full (' + OFFLINE_QUEUE_MAX + ')'));
|
|
205
|
+
}
|
|
206
|
+
Usion.log('Queued action while offline: ' + actionType);
|
|
207
|
+
return new Promise(function(resolve, reject) {
|
|
208
|
+
self._offlineQueue.push({
|
|
209
|
+
actionType: actionType,
|
|
210
|
+
actionData: actionData,
|
|
211
|
+
opts: Object.assign({}, opts, { queueOffline: false }),
|
|
212
|
+
resolve: resolve,
|
|
213
|
+
reject: reject,
|
|
214
|
+
});
|
|
215
|
+
self._ensureOfflineFlushHook();
|
|
216
|
+
});
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/** @private Flush queued actions, in order, once reconnected. */
|
|
220
|
+
game._ensureOfflineFlushHook = function() {
|
|
221
|
+
const self = this;
|
|
222
|
+
if (self._offlineFlushHooked) return;
|
|
223
|
+
self._offlineFlushHooked = true;
|
|
224
|
+
self.on('reconnect', function() { self._flushOfflineQueue(); });
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/** @private */
|
|
228
|
+
game._flushOfflineQueue = function() {
|
|
229
|
+
const self = this;
|
|
230
|
+
const queue = self._offlineQueue;
|
|
231
|
+
if (!queue || !queue.length) return;
|
|
232
|
+
self._offlineQueue = [];
|
|
233
|
+
Usion.log('Flushing ' + queue.length + ' queued action(s) after reconnect');
|
|
234
|
+
// Send strictly in order: each action waits for the previous ack so
|
|
235
|
+
// the server assigns sequences in the order the player acted.
|
|
236
|
+
let chain = Promise.resolve();
|
|
237
|
+
queue.forEach(function(item) {
|
|
238
|
+
chain = chain.then(function() {
|
|
239
|
+
return self.action(item.actionType, item.actionData, item.opts)
|
|
240
|
+
.then(item.resolve, item.reject);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Checkpoint the authoritative game state on the server. Any client that
|
|
247
|
+
* joins or rejoins the room receives the latest checkpoint as game_state
|
|
248
|
+
* in the join ack and in game:sync — recovery becomes "load checkpoint,
|
|
249
|
+
* replay the tail" instead of replaying every action from zero.
|
|
250
|
+
*
|
|
251
|
+
* Only the room authority (player_ids[0] / host) may call this. The
|
|
252
|
+
* serialized state is capped at 64 KB.
|
|
253
|
+
*
|
|
254
|
+
* @param {*} state - JSON-serializable authoritative game state
|
|
255
|
+
* @returns {Promise<{success: boolean, error?: string}>}
|
|
256
|
+
*/
|
|
257
|
+
game.setState = function(state) {
|
|
258
|
+
const self = this;
|
|
259
|
+
|
|
260
|
+
if (self.directMode) {
|
|
261
|
+
// Direct-mode game servers own their state; no platform checkpoint.
|
|
262
|
+
return Promise.resolve({ success: false, error: 'not_supported_in_direct_mode', code: ERROR_CODES.UNSUPPORTED });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (self._useProxy) {
|
|
266
|
+
return Usion._request('GAME_SET_STATE', { room_id: self.roomId, state: state || {} })
|
|
267
|
+
.then(function(res) {
|
|
268
|
+
if (res && res.error) {
|
|
269
|
+
return { success: false, error: res.error, code: toUsionError(res.error).code };
|
|
270
|
+
}
|
|
271
|
+
return res || { success: true };
|
|
272
|
+
})
|
|
273
|
+
.catch(function(err) {
|
|
274
|
+
const ue = toUsionError(err, ERROR_CODES.REQUEST_TIMEOUT);
|
|
275
|
+
return { success: false, error: ue.message, code: ue.code };
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
136
279
|
return new Promise(function(resolve, reject) {
|
|
137
280
|
if (!self.socket || !self.connected) {
|
|
138
|
-
reject(new
|
|
281
|
+
reject(new UsionError(ERROR_CODES.NOT_CONNECTED, 'Not connected'));
|
|
139
282
|
return;
|
|
140
283
|
}
|
|
141
|
-
|
|
142
|
-
self.socket.emit('game:action', {
|
|
284
|
+
self.socket.emit('game:set_state', {
|
|
143
285
|
room_id: self.roomId,
|
|
144
|
-
|
|
145
|
-
action_data: actionData
|
|
286
|
+
state: state || {}
|
|
146
287
|
}, function(response) {
|
|
147
|
-
if (response.error) {
|
|
148
|
-
|
|
288
|
+
if (response && response.error) {
|
|
289
|
+
resolve({ success: false, error: response.error, code: toUsionError(response.error).code });
|
|
149
290
|
} else {
|
|
150
|
-
|
|
151
|
-
self._lastSequence = response.sequence;
|
|
152
|
-
}
|
|
153
|
-
resolve(response);
|
|
291
|
+
resolve(response || { success: true });
|
|
154
292
|
}
|
|
155
293
|
});
|
|
156
294
|
});
|
|
@@ -254,13 +392,13 @@ export function applyGameMethods(game, Usion) {
|
|
|
254
392
|
|
|
255
393
|
return new Promise(function(resolve, reject) {
|
|
256
394
|
if (!self.socket || !self.connected) {
|
|
257
|
-
reject(new
|
|
395
|
+
reject(new UsionError(ERROR_CODES.NOT_CONNECTED, 'Not connected'));
|
|
258
396
|
return;
|
|
259
397
|
}
|
|
260
398
|
|
|
261
399
|
self.socket.emit('game:forfeit', { room_id: self.roomId }, function(response) {
|
|
262
400
|
if (response.error) {
|
|
263
|
-
reject(
|
|
401
|
+
reject(toUsionError(response.message || response.error));
|
|
264
402
|
} else {
|
|
265
403
|
resolve(response);
|
|
266
404
|
}
|
|
@@ -422,7 +560,9 @@ export function applyGameMethods(game, Usion) {
|
|
|
422
560
|
var inFrame = window.parent && window.parent !== window;
|
|
423
561
|
var inRNWebView = !!window.ReactNativeWebView;
|
|
424
562
|
if (inFrame || inRNWebView) {
|
|
425
|
-
|
|
563
|
+
var body = Object.assign({}, payload || {});
|
|
564
|
+
body._diag = Usion.diagnostics ? Usion.diagnostics() : undefined;
|
|
565
|
+
Usion._post({ type: 'GAME_DEBUG', payload: body });
|
|
426
566
|
}
|
|
427
567
|
} catch (e) { /* non-fatal */ }
|
|
428
568
|
};
|