@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.
@@ -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
- const s = self.game && self.game.socket;
57
- if (s && s.connected) {
63
+
64
+ function emitOn(sock) {
58
65
  return new Promise(function (resolve, reject) {
59
66
  let done = false;
60
- const timer = setTimeout(function () { if (done) return; done = true; reject(new Error('Backend request timeout')); }, timeout);
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
- s.emit(event, data || {}, function (resp) {
72
+ sock.emit(event, data || {}, function (resp) {
63
73
  if (done) return; done = true; clearTimeout(timer);
64
- if (resp && resp.error) reject(new Error(resp.message || resp.error));
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 Error('No backend connection — call Usion.game.connect() first'));
106
+ return Promise.reject(new UsionError(ERROR_CODES.NOT_CONNECTED,
107
+ 'No backend connection — socket is offline or reconnecting'));
75
108
  };
76
109
  }
@@ -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
- * @param {function} callback - Called with config when ready
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
- reject(new Error(data.error));
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 Error('Request timeout'));
390
+ reject(new UsionError(ERROR_CODES.REQUEST_TIMEOUT, 'Request timeout'));
340
391
  }, timeout);
341
392
 
342
393
  // Store pending request
@@ -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
- NOT_AUTHORITY: 'NOT_AUTHORITY', // Authority-only call (e.g. setState)
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
- RATE_LIMITED: 'RATE_LIMITED', // Too many calls; back off
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 (backend `{error}` string, Error, raw string) into a
61
- * UsionError with the best-matching stable code.
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)) {
@@ -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 socket.io client
127
- var script = document.createElement('script');
128
- script.src = '/socket.io.min.js';
129
- script.onload = function() {
130
- self._initSocket(socketUrl, token, resolve, reject);
131
- };
132
- script.onerror = function() {
133
- // Local file not available, try CDN as fallback
134
- var cdnScript = document.createElement('script');
135
- cdnScript.src = 'https://cdn.socket.io/4.7.2/socket.io.min.js';
136
- cdnScript.onload = function() {
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(cdnScript);
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.message || response.error));
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
- 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
+ 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
- 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
+ * @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
- return { success: false, error: res.error, code: toUsionError(res.error).code };
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
- resolve({ success: false, error: response.error, code: toUsionError(response.error).code });
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.message || response.error));
473
+ reject(toUsionError(response));
405
474
  } else {
406
475
  resolve(response);
407
476
  }