@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.
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Usion SDK Game Reliability — the reconnection-lifecycle surface promised in
3
+ * v2.16: sequence getters, the unified connection-state machine, and
4
+ * syncedState (reconnect-safe authoritative shared state).
5
+ *
6
+ * Everything here rides the events the game module already dispatches
7
+ * ('disconnect' / 'reconnect' / 'joined' / 'sync' / 'action'), so it behaves
8
+ * identically across the socket, embedded-proxy, and direct transports.
9
+ */
10
+
11
+ export function applyGameReliability(game, Usion) {
12
+ // ── Sequence getters ──────────────────────────────────────────────────────
13
+
14
+ /** Highest action sequence seen (from joins, syncs, and live actions). */
15
+ game.getLastSequence = function () { return this._lastSequence || 0; };
16
+
17
+ /** Highest action sequence applied locally; trails while catching up. */
18
+ game.getLastAppliedSequence = function () { return this._lastActionApplied || 0; };
19
+
20
+ // ── Connection-state machine ──────────────────────────────────────────────
21
+ // connected → disconnected → rejoining → reconnected → connected.
22
+ // 'reconnected' is a transient notification state: observers see it, then
23
+ // the machine settles back to 'connected' in the same tick.
24
+
25
+ game._connState = 'disconnected';
26
+ game._awaitResync = false;
27
+
28
+ game._setConnState = function (state) {
29
+ if (this._connState === state) return;
30
+ this._connState = state;
31
+ this._dispatch('connectionState', state);
32
+ };
33
+
34
+ /** Current connection state, synchronously. */
35
+ game.getConnectionState = function () { return this._connState; };
36
+
37
+ /**
38
+ * Observe connection-state transitions — one hook to drive a
39
+ * "Reconnecting…" overlay and gate input, identical on every transport.
40
+ * Single handler (last wins); returns an unsubscribe fn. For multiple
41
+ * listeners use game.on('connectionState', cb).
42
+ */
43
+ game.onConnectionState = function (callback) { return this._setHandler('connectionState', callback); };
44
+
45
+ /**
46
+ * Fires once per reconnect, after the resync completes, with
47
+ * { state, lastSequence, viaSync }. Restore local state here — it does NOT
48
+ * fire for a manually-requested sync.
49
+ */
50
+ game.onReconnected = function (callback) { return this._setHandler('reconnected', callback); };
51
+
52
+ game.on('disconnect', function () {
53
+ game._awaitResync = false;
54
+ game._setConnState('disconnected');
55
+ });
56
+
57
+ game.on('reconnect', function () {
58
+ if (game.roomId) {
59
+ game._awaitResync = true;
60
+ game._setConnState('rejoining');
61
+ } else {
62
+ game._setConnState('connected');
63
+ }
64
+ });
65
+
66
+ game.on('sync', function (data) {
67
+ if (!game._awaitResync) return;
68
+ game._awaitResync = false;
69
+ game._dispatch('reconnected', {
70
+ state: (data && data.game_state) || null,
71
+ lastSequence: game._lastSequence || 0,
72
+ viaSync: true,
73
+ });
74
+ game._setConnState('reconnected');
75
+ game._setConnState('connected');
76
+ });
77
+
78
+ // Transport-level connects (initial connect in every mode) resolve through
79
+ // connect()/connectDirect() — reflect them in the state machine.
80
+ function wrapConnect(name) {
81
+ var orig = game[name];
82
+ if (typeof orig !== 'function') return;
83
+ game[name] = function () {
84
+ var self = this;
85
+ return orig.apply(self, arguments).then(function (result) {
86
+ if (!self._awaitResync) self._setConnState('connected');
87
+ return result;
88
+ });
89
+ };
90
+ }
91
+ wrapConnect('connect');
92
+ wrapConnect('connectDirect');
93
+
94
+ // ── Host tracking ─────────────────────────────────────────────────────────
95
+ // Kept on the game module (registered at module creation, so it never
96
+ // misses an event) — a syncedState created AFTER the join still knows who
97
+ // the authority is.
98
+
99
+ game._hostId = null;
100
+ function trackHost(d) {
101
+ if (!d) return;
102
+ if (d.host_id) game._hostId = d.host_id;
103
+ else if (d.player_ids && d.player_ids.length) game._hostId = d.player_ids[0];
104
+ }
105
+ game.on('joined', trackHost);
106
+ game.on('playerJoined', trackHost);
107
+
108
+ // ── syncedState ───────────────────────────────────────────────────────────
109
+
110
+ /**
111
+ * Reconnect-safe authoritative shared state. Commits are sequenced actions
112
+ * applied through `reduce` on every client in the same order (deduped by
113
+ * sequence); the authority (player_ids[0] by default) auto-checkpoints via
114
+ * setState, and (re)joining clients recover automatically (checkpoint +
115
+ * un-checkpointed tail). Degrades to local-apply in direct mode.
116
+ * Full contract: SyncedStateOptions / SyncedState in types/index.d.ts.
117
+ */
118
+ game.syncedState = function (initial, opts) {
119
+ opts = opts || {};
120
+ var self = this;
121
+ var reduce = typeof opts.reduce === 'function'
122
+ ? opts.reduce
123
+ : function (state, action) { return Object.assign({}, state, action.data); };
124
+ var checkpointEvery = opts.checkpointEvery != null ? opts.checkpointEvery : 1;
125
+ var authorityMode = opts.authority === 'all' ? 'all' : 'host';
126
+ var defaultType = opts.type || 'update';
127
+
128
+ var state = initial === undefined ? {} : initial;
129
+ var appliedSeq = 0;
130
+ var sinceCheckpoint = 0;
131
+ var lastGapRequest = -1;
132
+ var changeCbs = [];
133
+ var offs = [];
134
+
135
+ function notify(reason) {
136
+ var copy = changeCbs.slice();
137
+ for (var i = 0; i < copy.length; i++) {
138
+ try { copy[i](state, reason); } catch (e) { Usion.log('syncedState onChange error: ' + e.message); }
139
+ }
140
+ }
141
+
142
+ function isAuthority() {
143
+ if (authorityMode === 'all') return true;
144
+ var me = self.playerId || (Usion.user && Usion.user.getId && Usion.user.getId());
145
+ return !!(me && self._hostId && me === self._hostId);
146
+ }
147
+
148
+ function doCheckpoint() {
149
+ if (self.directMode) {
150
+ return Promise.resolve({ success: false, error: 'not_supported_in_direct_mode' });
151
+ }
152
+ // The wrapper carries the sequence the checkpoint reflects, so recovery
153
+ // knows exactly where the un-checkpointed tail starts.
154
+ return self.setState({ __usionSyncedState: 1, seq: appliedSeq, state: state });
155
+ }
156
+
157
+ function maybeAutoCheckpoint() {
158
+ if (!(checkpointEvery > 0) || !isAuthority()) return;
159
+ sinceCheckpoint += 1;
160
+ if (sinceCheckpoint >= checkpointEvery) {
161
+ sinceCheckpoint = 0;
162
+ doCheckpoint();
163
+ }
164
+ }
165
+
166
+ function applyOne(playerId, type, data, sequence) {
167
+ if (sequence !== undefined && sequence <= appliedSeq) return false; // dedupe
168
+ var next;
169
+ try {
170
+ next = reduce(state, { playerId: playerId, type: type, data: data, sequence: sequence });
171
+ } catch (e) {
172
+ Usion.log('syncedState reduce error: ' + e.message);
173
+ return false;
174
+ }
175
+ if (next !== undefined) state = next;
176
+ if (sequence !== undefined) appliedSeq = sequence;
177
+ return true;
178
+ }
179
+
180
+ function onAction(d) {
181
+ if (!d) return;
182
+ if (applyOne(d.player_id, d.action_type, d.action_data, d.sequence)) {
183
+ notify('action');
184
+ maybeAutoCheckpoint();
185
+ }
186
+ }
187
+
188
+ // Recover from a join ack or sync payload: load the checkpoint (if it is
189
+ // ours and newer), replay the action tail in order, and if the server's
190
+ // sequence is still ahead of us, ask once for the missing range.
191
+ function recover(d) {
192
+ if (!d) return;
193
+ var changed = false;
194
+
195
+ var gs = d.game_state;
196
+ if (gs && gs.__usionSyncedState && typeof gs.seq === 'number'
197
+ && gs.seq >= appliedSeq && gs.state !== undefined) {
198
+ state = gs.state;
199
+ appliedSeq = gs.seq;
200
+ changed = true;
201
+ }
202
+
203
+ var actions = d.actions;
204
+ if (actions && actions.length) {
205
+ var sorted = actions.slice().sort(function (a, b) {
206
+ return (a.sequence || 0) - (b.sequence || 0);
207
+ });
208
+ for (var i = 0; i < sorted.length; i++) {
209
+ var item = sorted[i];
210
+ if (item && applyOne(item.player_id, item.action_type, item.action_data, item.sequence)) {
211
+ changed = true;
212
+ }
213
+ }
214
+ }
215
+
216
+ if (changed) notify('recover');
217
+
218
+ // Gap fill: the checkpoint may lag the room sequence (checkpointEvery>1).
219
+ // Request the tail from OUR applied sequence — once per position, so a
220
+ // trimmed history can't loop us.
221
+ if (d.sequence !== undefined && d.sequence > appliedSeq && lastGapRequest !== appliedSeq) {
222
+ lastGapRequest = appliedSeq;
223
+ try { self.requestSync(appliedSeq); } catch (e) { /* non-fatal */ }
224
+ }
225
+ }
226
+
227
+ offs.push(self.on('action', onAction));
228
+ offs.push(self.on('sync', recover));
229
+ offs.push(self.on('joined', recover));
230
+
231
+ return {
232
+ /** Current state value. */
233
+ get: function () { return state; },
234
+
235
+ /**
236
+ * Commit a change: commit(data) with the default action type, or
237
+ * commit(type, data, opts) to name it (opts forwarded to game.action,
238
+ * e.g. nextTurn / queueOffline). Applied exactly once via the echo.
239
+ */
240
+ commit: function (typeOrData, data, actionOpts) {
241
+ var t, payload, o;
242
+ if (typeof typeOrData === 'string') { t = typeOrData; payload = data; o = actionOpts; }
243
+ else { t = defaultType; payload = typeOrData; o = data; }
244
+ if (self.directMode) {
245
+ // No platform echo in direct mode — apply locally, then send.
246
+ if (applyOne(self.playerId, t, payload, undefined)) notify('action');
247
+ return self.action(t, payload, o);
248
+ }
249
+ return self.action(t, payload, o);
250
+ },
251
+
252
+ /** Subscribe to changes: cb(state, reason). Returns an unsubscribe fn. */
253
+ onChange: function (cb) {
254
+ changeCbs.push(cb);
255
+ return function () {
256
+ var i = changeCbs.indexOf(cb);
257
+ if (i >= 0) changeCbs.splice(i, 1);
258
+ };
259
+ },
260
+
261
+ /** Whether this client is the checkpointing authority. */
262
+ isAuthority: isAuthority,
263
+
264
+ /** Force a server checkpoint now (no-op result in direct mode). */
265
+ checkpoint: function () { return doCheckpoint(); },
266
+
267
+ /** Sequence of the last action applied into this state. */
268
+ getSequence: function () { return appliedSeq; },
269
+
270
+ /** Detach all listeners; the instance stops receiving updates. */
271
+ destroy: function () {
272
+ for (var i = 0; i < offs.length; i++) { try { offs[i](); } catch (e) { /* noop */ } }
273
+ offs.length = 0;
274
+ changeCbs.length = 0;
275
+ },
276
+ };
277
+ };
278
+ }
@@ -44,7 +44,20 @@ export function applyGameSocket(game, Usion) {
44
44
  // backend channel — robust to listener registration order.
45
45
  if (Usion._bindBackendSocket) Usion._bindBackendSocket(self.socket);
46
46
 
47
+ // Socket.IO v4 emits 'reconnect' on the Manager, not the Socket — a
48
+ // Socket-level 'reconnect' listener never fires. Track attempts on the
49
+ // Manager and treat any connect after the first (per socket) as the
50
+ // reconnect signal, dispatched below AFTER rejoin gating is in place.
51
+ var hadConnect = false;
52
+ if (self.socket.io && typeof self.socket.io.on === 'function') {
53
+ self.socket.io.on('reconnect_attempt', function(n) {
54
+ self._reconnAttempts = n;
55
+ });
56
+ }
57
+
47
58
  self.socket.on('connect', function() {
59
+ var isReconnect = hadConnect;
60
+ hadConnect = true;
48
61
  self.connected = true;
49
62
  self._connecting = false;
50
63
  Usion.log('Game socket connected');
@@ -75,12 +88,21 @@ export function applyGameSocket(game, Usion) {
75
88
  .then(function() {
76
89
  self._rejoinPromise = null;
77
90
  // Send any moves queued while offline, now that membership
78
- // and sync are restored. (Socket.IO v4 emits 'reconnect' on
79
- // the Manager, not the Socket, so this is the reliable hook.)
91
+ // and sync are restored. (This connect handler is the reliable
92
+ // hook see the Manager-vs-Socket note above.)
80
93
  if (self._flushOfflineQueue) self._flushOfflineQueue();
81
94
  });
82
95
  }
83
96
 
97
+ // Dispatched after _rejoinPromise is set, so anything reacting to
98
+ // 'reconnect' (offline-queue flush, connection-state machine) sees
99
+ // sends gated behind the rejoin+resync.
100
+ if (isReconnect) {
101
+ Usion.log('Game socket reconnected');
102
+ self._dispatch('reconnect', self._reconnAttempts || 1);
103
+ self._reconnAttempts = 0;
104
+ }
105
+
84
106
  resolve();
85
107
  });
86
108
 
@@ -103,11 +125,6 @@ export function applyGameSocket(game, Usion) {
103
125
  self._dispatch('disconnect', reason);
104
126
  });
105
127
 
106
- self.socket.on('reconnect', function(attemptNumber) {
107
- Usion.log('Game socket reconnected after ' + attemptNumber + ' attempts');
108
- self._dispatch('reconnect', attemptNumber);
109
- });
110
-
111
128
  // Game event handlers
112
129
  self.socket.on('game:joined', function(data) {
113
130
  if (data.sequence !== undefined) {
@@ -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 } for an in-flight find()
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 }. Stays pending until a
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
- const self = this;
47
- return Usion._backendEmit('mm:join', { service_id: sid, size: opts.size || 2 }).then(function () {
48
- return new Promise(function (resolve, reject) {
49
- if (pending) pending.reject(new Error('superseded'));
50
- pending = { resolve: resolve, reject: reject };
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) { pending.reject(new Error('cancelled')); pending = null; }
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
  };
@@ -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
- window.addEventListener('message', function(event) {
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
  };
@@ -21,13 +21,17 @@ export function createWalletModule(Usion) {
21
21
 
22
22
  /**
23
23
  * Get current wallet balance
24
+ * @param {object} [opts]
25
+ * @param {boolean} [opts.fresh] - Bypass the cache and re-query the host.
26
+ * Use after out-of-band balance changes (e.g. a server-side settle).
24
27
  * @returns {Promise<number>} Balance in credits
25
28
  */
26
- getBalance: function() {
29
+ getBalance: function(opts) {
27
30
  const self = this;
31
+ const fresh = !!(opts && opts.fresh);
28
32
 
29
33
  // If we have cached balance, return it
30
- if (self._balance !== null) {
34
+ if (!fresh && self._balance !== null) {
31
35
  return Promise.resolve(self._balance);
32
36
  }
33
37
 
@@ -94,11 +98,14 @@ export function createWalletModule(Usion) {
94
98
  settled = true;
95
99
  cleanup();
96
100
  // Update cached balance from the authoritative new balance, else
97
- // best-effort subtract (no-op if balance is unknown).
101
+ // best-effort subtract now and re-query the host so the cache
102
+ // converges on the real value (an estimate would otherwise stick
103
+ // until reload).
98
104
  if (response && response.newBalance !== undefined) {
99
105
  self._balance = response.newBalance;
100
- } else if (self._balance !== null) {
101
- self._balance -= amount;
106
+ } else {
107
+ if (self._balance !== null) self._balance -= amount;
108
+ self.getBalance({ fresh: true }).catch(function() { /* keep estimate */ });
102
109
  }
103
110
  resolve(response);
104
111
  }