@usions/sdk 2.20.2 → 2.21.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usions/sdk",
3
- "version": "2.20.2",
3
+ "version": "2.21.0",
4
4
  "description": "Usion Mini App SDK for iframe games and services",
5
5
  "type": "module",
6
6
  "main": "src/modules/index.js",
package/src/browser.js CHANGED
@@ -69,7 +69,7 @@ var Usion = (function () {
69
69
  * Core Usion object with init, _post, _request
70
70
  */
71
71
  const core = {
72
- version: '2.20.2', // injected from package.json at build
72
+ version: '2.21.0', // injected from package.json at build
73
73
  config: {},
74
74
  _initialized: false,
75
75
  _initCallback: null,
@@ -1564,7 +1564,20 @@ var Usion = (function () {
1564
1564
  // backend channel — robust to listener registration order.
1565
1565
  if (Usion._bindBackendSocket) Usion._bindBackendSocket(self.socket);
1566
1566
 
1567
+ // Socket.IO v4 emits 'reconnect' on the Manager, not the Socket — a
1568
+ // Socket-level 'reconnect' listener never fires. Track attempts on the
1569
+ // Manager and treat any connect after the first (per socket) as the
1570
+ // reconnect signal, dispatched below AFTER rejoin gating is in place.
1571
+ var hadConnect = false;
1572
+ if (self.socket.io && typeof self.socket.io.on === 'function') {
1573
+ self.socket.io.on('reconnect_attempt', function(n) {
1574
+ self._reconnAttempts = n;
1575
+ });
1576
+ }
1577
+
1567
1578
  self.socket.on('connect', function() {
1579
+ var isReconnect = hadConnect;
1580
+ hadConnect = true;
1568
1581
  self.connected = true;
1569
1582
  self._connecting = false;
1570
1583
  Usion.log('Game socket connected');
@@ -1595,12 +1608,21 @@ var Usion = (function () {
1595
1608
  .then(function() {
1596
1609
  self._rejoinPromise = null;
1597
1610
  // Send any moves queued while offline, now that membership
1598
- // and sync are restored. (Socket.IO v4 emits 'reconnect' on
1599
- // the Manager, not the Socket, so this is the reliable hook.)
1611
+ // and sync are restored. (This connect handler is the reliable
1612
+ // hook see the Manager-vs-Socket note above.)
1600
1613
  if (self._flushOfflineQueue) self._flushOfflineQueue();
1601
1614
  });
1602
1615
  }
1603
1616
 
1617
+ // Dispatched after _rejoinPromise is set, so anything reacting to
1618
+ // 'reconnect' (offline-queue flush, connection-state machine) sees
1619
+ // sends gated behind the rejoin+resync.
1620
+ if (isReconnect) {
1621
+ Usion.log('Game socket reconnected');
1622
+ self._dispatch('reconnect', self._reconnAttempts || 1);
1623
+ self._reconnAttempts = 0;
1624
+ }
1625
+
1604
1626
  resolve();
1605
1627
  });
1606
1628
 
@@ -1623,11 +1645,6 @@ var Usion = (function () {
1623
1645
  self._dispatch('disconnect', reason);
1624
1646
  });
1625
1647
 
1626
- self.socket.on('reconnect', function(attemptNumber) {
1627
- Usion.log('Game socket reconnected after ' + attemptNumber + ' attempts');
1628
- self._dispatch('reconnect', attemptNumber);
1629
- });
1630
-
1631
1648
  // Game event handlers
1632
1649
  self.socket.on('game:joined', function(data) {
1633
1650
  if (data.sequence !== undefined) {
@@ -2043,7 +2060,15 @@ var Usion = (function () {
2043
2060
  self._joined = true;
2044
2061
  if (response.sequence !== undefined) {
2045
2062
  self._lastSequence = response.sequence;
2063
+ // The joined state reflects everything up to this sequence —
2064
+ // actions at or below it must not be re-delivered (same
2065
+ // baseline the proxy transport sets from GAME_JOINED).
2066
+ self._lastActionApplied = Math.max(self._lastActionApplied, response.sequence);
2046
2067
  }
2068
+ // Transport parity: proxy and direct modes dispatch 'joined';
2069
+ // the socket transport now does too, so onJoined / recovery
2070
+ // logic behaves the same everywhere.
2071
+ self._dispatch('joined', response);
2047
2072
  resolve(response);
2048
2073
  }
2049
2074
  });
@@ -4615,6 +4640,285 @@ var Usion = (function () {
4615
4640
  game.getRtt = function () { return this._pingMeter ? this._pingMeter.rtt : null; };
4616
4641
  }
4617
4642
 
4643
+ /**
4644
+ * Usion SDK Game Reliability — the reconnection-lifecycle surface promised in
4645
+ * v2.16: sequence getters, the unified connection-state machine, and
4646
+ * syncedState (reconnect-safe authoritative shared state).
4647
+ *
4648
+ * Everything here rides the events the game module already dispatches
4649
+ * ('disconnect' / 'reconnect' / 'joined' / 'sync' / 'action'), so it behaves
4650
+ * identically across the socket, embedded-proxy, and direct transports.
4651
+ */
4652
+
4653
+ function applyGameReliability(game, Usion) {
4654
+ // ── Sequence getters ──────────────────────────────────────────────────────
4655
+
4656
+ /** Highest action sequence seen (from joins, syncs, and live actions). */
4657
+ game.getLastSequence = function () { return this._lastSequence || 0; };
4658
+
4659
+ /** Highest action sequence applied locally; trails while catching up. */
4660
+ game.getLastAppliedSequence = function () { return this._lastActionApplied || 0; };
4661
+
4662
+ // ── Connection-state machine ──────────────────────────────────────────────
4663
+ // connected → disconnected → rejoining → reconnected → connected.
4664
+ // 'reconnected' is a transient notification state: observers see it, then
4665
+ // the machine settles back to 'connected' in the same tick.
4666
+
4667
+ game._connState = 'disconnected';
4668
+ game._awaitResync = false;
4669
+
4670
+ game._setConnState = function (state) {
4671
+ if (this._connState === state) return;
4672
+ this._connState = state;
4673
+ this._dispatch('connectionState', state);
4674
+ };
4675
+
4676
+ /** Current connection state, synchronously. */
4677
+ game.getConnectionState = function () { return this._connState; };
4678
+
4679
+ /**
4680
+ * Observe connection-state transitions — one hook to drive a
4681
+ * "Reconnecting…" overlay and gate input, identical on every transport.
4682
+ * Single handler (last wins); returns an unsubscribe fn. For multiple
4683
+ * listeners use game.on('connectionState', cb).
4684
+ */
4685
+ game.onConnectionState = function (callback) { return this._setHandler('connectionState', callback); };
4686
+
4687
+ /**
4688
+ * Fires once per reconnect, after the resync completes, with
4689
+ * { state, lastSequence, viaSync }. Restore local state here — it does NOT
4690
+ * fire for a manually-requested sync.
4691
+ */
4692
+ game.onReconnected = function (callback) { return this._setHandler('reconnected', callback); };
4693
+
4694
+ game.on('disconnect', function () {
4695
+ game._awaitResync = false;
4696
+ game._setConnState('disconnected');
4697
+ });
4698
+
4699
+ game.on('reconnect', function () {
4700
+ if (game.roomId) {
4701
+ game._awaitResync = true;
4702
+ game._setConnState('rejoining');
4703
+ } else {
4704
+ game._setConnState('connected');
4705
+ }
4706
+ });
4707
+
4708
+ game.on('sync', function (data) {
4709
+ if (!game._awaitResync) return;
4710
+ game._awaitResync = false;
4711
+ game._dispatch('reconnected', {
4712
+ state: (data && data.game_state) || null,
4713
+ lastSequence: game._lastSequence || 0,
4714
+ viaSync: true,
4715
+ });
4716
+ game._setConnState('reconnected');
4717
+ game._setConnState('connected');
4718
+ });
4719
+
4720
+ // Transport-level connects (initial connect in every mode) resolve through
4721
+ // connect()/connectDirect() — reflect them in the state machine.
4722
+ function wrapConnect(name) {
4723
+ var orig = game[name];
4724
+ if (typeof orig !== 'function') return;
4725
+ game[name] = function () {
4726
+ var self = this;
4727
+ return orig.apply(self, arguments).then(function (result) {
4728
+ if (!self._awaitResync) self._setConnState('connected');
4729
+ return result;
4730
+ });
4731
+ };
4732
+ }
4733
+ wrapConnect('connect');
4734
+ wrapConnect('connectDirect');
4735
+
4736
+ // ── Host tracking ─────────────────────────────────────────────────────────
4737
+ // Kept on the game module (registered at module creation, so it never
4738
+ // misses an event) — a syncedState created AFTER the join still knows who
4739
+ // the authority is.
4740
+
4741
+ game._hostId = null;
4742
+ function trackHost(d) {
4743
+ if (!d) return;
4744
+ if (d.host_id) game._hostId = d.host_id;
4745
+ else if (d.player_ids && d.player_ids.length) game._hostId = d.player_ids[0];
4746
+ }
4747
+ game.on('joined', trackHost);
4748
+ game.on('playerJoined', trackHost);
4749
+
4750
+ // ── syncedState ───────────────────────────────────────────────────────────
4751
+
4752
+ /**
4753
+ * Reconnect-safe authoritative shared state. Commits are sequenced actions
4754
+ * applied through `reduce` on every client in the same order (deduped by
4755
+ * sequence); the authority (player_ids[0] by default) auto-checkpoints via
4756
+ * setState, and (re)joining clients recover automatically (checkpoint +
4757
+ * un-checkpointed tail). Degrades to local-apply in direct mode.
4758
+ * Full contract: SyncedStateOptions / SyncedState in types/index.d.ts.
4759
+ */
4760
+ game.syncedState = function (initial, opts) {
4761
+ opts = opts || {};
4762
+ var self = this;
4763
+ var reduce = typeof opts.reduce === 'function'
4764
+ ? opts.reduce
4765
+ : function (state, action) { return Object.assign({}, state, action.data); };
4766
+ var checkpointEvery = opts.checkpointEvery != null ? opts.checkpointEvery : 1;
4767
+ var authorityMode = opts.authority === 'all' ? 'all' : 'host';
4768
+ var defaultType = opts.type || 'update';
4769
+
4770
+ var state = initial === undefined ? {} : initial;
4771
+ var appliedSeq = 0;
4772
+ var sinceCheckpoint = 0;
4773
+ var lastGapRequest = -1;
4774
+ var changeCbs = [];
4775
+ var offs = [];
4776
+
4777
+ function notify(reason) {
4778
+ var copy = changeCbs.slice();
4779
+ for (var i = 0; i < copy.length; i++) {
4780
+ try { copy[i](state, reason); } catch (e) { Usion.log('syncedState onChange error: ' + e.message); }
4781
+ }
4782
+ }
4783
+
4784
+ function isAuthority() {
4785
+ if (authorityMode === 'all') return true;
4786
+ var me = self.playerId || (Usion.user && Usion.user.getId && Usion.user.getId());
4787
+ return !!(me && self._hostId && me === self._hostId);
4788
+ }
4789
+
4790
+ function doCheckpoint() {
4791
+ if (self.directMode) {
4792
+ return Promise.resolve({ success: false, error: 'not_supported_in_direct_mode' });
4793
+ }
4794
+ // The wrapper carries the sequence the checkpoint reflects, so recovery
4795
+ // knows exactly where the un-checkpointed tail starts.
4796
+ return self.setState({ __usionSyncedState: 1, seq: appliedSeq, state: state });
4797
+ }
4798
+
4799
+ function maybeAutoCheckpoint() {
4800
+ if (!(checkpointEvery > 0) || !isAuthority()) return;
4801
+ sinceCheckpoint += 1;
4802
+ if (sinceCheckpoint >= checkpointEvery) {
4803
+ sinceCheckpoint = 0;
4804
+ doCheckpoint();
4805
+ }
4806
+ }
4807
+
4808
+ function applyOne(playerId, type, data, sequence) {
4809
+ if (sequence !== undefined && sequence <= appliedSeq) return false; // dedupe
4810
+ var next;
4811
+ try {
4812
+ next = reduce(state, { playerId: playerId, type: type, data: data, sequence: sequence });
4813
+ } catch (e) {
4814
+ Usion.log('syncedState reduce error: ' + e.message);
4815
+ return false;
4816
+ }
4817
+ if (next !== undefined) state = next;
4818
+ if (sequence !== undefined) appliedSeq = sequence;
4819
+ return true;
4820
+ }
4821
+
4822
+ function onAction(d) {
4823
+ if (!d) return;
4824
+ if (applyOne(d.player_id, d.action_type, d.action_data, d.sequence)) {
4825
+ notify('action');
4826
+ maybeAutoCheckpoint();
4827
+ }
4828
+ }
4829
+
4830
+ // Recover from a join ack or sync payload: load the checkpoint (if it is
4831
+ // ours and newer), replay the action tail in order, and if the server's
4832
+ // sequence is still ahead of us, ask once for the missing range.
4833
+ function recover(d) {
4834
+ if (!d) return;
4835
+ var changed = false;
4836
+
4837
+ var gs = d.game_state;
4838
+ if (gs && gs.__usionSyncedState && typeof gs.seq === 'number'
4839
+ && gs.seq >= appliedSeq && gs.state !== undefined) {
4840
+ state = gs.state;
4841
+ appliedSeq = gs.seq;
4842
+ changed = true;
4843
+ }
4844
+
4845
+ var actions = d.actions;
4846
+ if (actions && actions.length) {
4847
+ var sorted = actions.slice().sort(function (a, b) {
4848
+ return (a.sequence || 0) - (b.sequence || 0);
4849
+ });
4850
+ for (var i = 0; i < sorted.length; i++) {
4851
+ var item = sorted[i];
4852
+ if (item && applyOne(item.player_id, item.action_type, item.action_data, item.sequence)) {
4853
+ changed = true;
4854
+ }
4855
+ }
4856
+ }
4857
+
4858
+ if (changed) notify('recover');
4859
+
4860
+ // Gap fill: the checkpoint may lag the room sequence (checkpointEvery>1).
4861
+ // Request the tail from OUR applied sequence — once per position, so a
4862
+ // trimmed history can't loop us.
4863
+ if (d.sequence !== undefined && d.sequence > appliedSeq && lastGapRequest !== appliedSeq) {
4864
+ lastGapRequest = appliedSeq;
4865
+ try { self.requestSync(appliedSeq); } catch (e) { /* non-fatal */ }
4866
+ }
4867
+ }
4868
+
4869
+ offs.push(self.on('action', onAction));
4870
+ offs.push(self.on('sync', recover));
4871
+ offs.push(self.on('joined', recover));
4872
+
4873
+ return {
4874
+ /** Current state value. */
4875
+ get: function () { return state; },
4876
+
4877
+ /**
4878
+ * Commit a change: commit(data) with the default action type, or
4879
+ * commit(type, data, opts) to name it (opts forwarded to game.action,
4880
+ * e.g. nextTurn / queueOffline). Applied exactly once via the echo.
4881
+ */
4882
+ commit: function (typeOrData, data, actionOpts) {
4883
+ var t, payload, o;
4884
+ if (typeof typeOrData === 'string') { t = typeOrData; payload = data; o = actionOpts; }
4885
+ else { t = defaultType; payload = typeOrData; o = data; }
4886
+ if (self.directMode) {
4887
+ // No platform echo in direct mode — apply locally, then send.
4888
+ if (applyOne(self.playerId, t, payload, undefined)) notify('action');
4889
+ return self.action(t, payload, o);
4890
+ }
4891
+ return self.action(t, payload, o);
4892
+ },
4893
+
4894
+ /** Subscribe to changes: cb(state, reason). Returns an unsubscribe fn. */
4895
+ onChange: function (cb) {
4896
+ changeCbs.push(cb);
4897
+ return function () {
4898
+ var i = changeCbs.indexOf(cb);
4899
+ if (i >= 0) changeCbs.splice(i, 1);
4900
+ };
4901
+ },
4902
+
4903
+ /** Whether this client is the checkpointing authority. */
4904
+ isAuthority: isAuthority,
4905
+
4906
+ /** Force a server checkpoint now (no-op result in direct mode). */
4907
+ checkpoint: function () { return doCheckpoint(); },
4908
+
4909
+ /** Sequence of the last action applied into this state. */
4910
+ getSequence: function () { return appliedSeq; },
4911
+
4912
+ /** Detach all listeners; the instance stops receiving updates. */
4913
+ destroy: function () {
4914
+ for (var i = 0; i < offs.length; i++) { try { offs[i](); } catch (e) { /* noop */ } }
4915
+ offs.length = 0;
4916
+ changeCbs.length = 0;
4917
+ },
4918
+ };
4919
+ };
4920
+ }
4921
+
4618
4922
  /**
4619
4923
  * Usion SDK Game Core — game module base, connect routing, event registrations
4620
4924
  */
@@ -4640,6 +4944,8 @@ var Usion = (function () {
4640
4944
  reconnect: 'reconnect',
4641
4945
  connection_error: 'connectionError',
4642
4946
  room_assigned: 'roomAssigned',
4947
+ connection_state: 'connectionState',
4948
+ reconnected: 'reconnected',
4643
4949
  };
4644
4950
 
4645
4951
  function _normalizeEventName(event) {
@@ -4853,6 +5159,8 @@ var Usion = (function () {
4853
5159
  applyGameProxy(game, Usion);
4854
5160
  applyGameMethods(game, Usion);
4855
5161
  applyGameNetcode(game, Usion);
5162
+ // Applied last: wraps connect/connectDirect and rides dispatched events.
5163
+ applyGameReliability(game, Usion);
4856
5164
 
4857
5165
  // Foreground catch-up safety net (generic across every transport).
4858
5166
  //
@@ -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) {
@@ -241,6 +244,8 @@ export function createGameModule(Usion) {
241
244
  applyGameProxy(game, Usion);
242
245
  applyGameMethods(game, Usion);
243
246
  applyGameNetcode(game, Usion);
247
+ // Applied last: wraps connect/connectDirect and rides dispatched events.
248
+ applyGameReliability(game, Usion);
244
249
 
245
250
  // Foreground catch-up safety net (generic across every transport).
246
251
  //
@@ -71,7 +71,15 @@ export function applyGameMethods(game, Usion) {
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
  });
@@ -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) {
package/types/index.d.ts CHANGED
@@ -274,6 +274,60 @@ export interface GameInviteResult {
274
274
  invited: string[];
275
275
  }
276
276
 
277
+ /** Unified connection-state machine, identical across all transports. */
278
+ export type GameConnectionState = 'connected' | 'disconnected' | 'rejoining' | 'reconnected';
279
+
280
+ /** Payload of game.onReconnected — fires once after a reconnect's resync. */
281
+ export interface ReconnectedInfo {
282
+ /** The server checkpoint from the resync (may be empty). */
283
+ state: Record<string, any> | null;
284
+ /** Highest action sequence after the resync. */
285
+ lastSequence: number;
286
+ /** True when recovery came through a game:sync payload. */
287
+ viaSync: boolean;
288
+ }
289
+
290
+ /** An action as seen by a syncedState reducer. */
291
+ export interface SyncedStateAction {
292
+ playerId: string;
293
+ type: string;
294
+ data: any;
295
+ /** Authoritative sequence; undefined only for direct-mode local applies. */
296
+ sequence: number | undefined;
297
+ }
298
+
299
+ /** Options for game.syncedState(). */
300
+ export interface SyncedStateOptions<S = Record<string, any>> {
301
+ /** Pure reducer applied to every action; default shallow-merges action.data. */
302
+ reduce?: (state: S, action: SyncedStateAction) => S;
303
+ /** Authority checkpoint cadence in actions (1 = every action; 0 disables). */
304
+ checkpointEvery?: number;
305
+ /** Who checkpoints: 'host' (player_ids[0], default) or 'all' participants. */
306
+ authority?: 'host' | 'all';
307
+ /** Default action type used by commit(data) (default 'update'). */
308
+ type?: string;
309
+ }
310
+
311
+ /** Reconnect-safe authoritative shared state (see game.syncedState). */
312
+ export interface SyncedState<S = Record<string, any>> {
313
+ /** Current state value. */
314
+ get(): S;
315
+ /** Commit with the default action type. Applied exactly once via the echo. */
316
+ commit(data: Record<string, any>): Promise<ActionResult>;
317
+ /** Commit a named action; opts forwarded to game.action (nextTurn, …). */
318
+ commit(type: string, data?: Record<string, any>, opts?: GameActionOptions): Promise<ActionResult>;
319
+ /** Subscribe to changes: cb(state, reason). Returns an unsubscribe fn. */
320
+ onChange(callback: (state: S, reason: 'action' | 'recover') => void): UnsubscribeFn;
321
+ /** Whether this client is the checkpointing authority. */
322
+ isAuthority(): boolean;
323
+ /** Force a server checkpoint now (no-op result in direct mode). */
324
+ checkpoint(): Promise<{ success: boolean; error?: string; code?: UsionErrorCode }>;
325
+ /** Sequence of the last action applied into this state. */
326
+ getSequence(): number;
327
+ /** Detach all listeners; the instance stops receiving updates. */
328
+ destroy(): void;
329
+ }
330
+
277
331
  export interface GameModule {
278
332
  // Connection
279
333
  connect(socketUrl?: string, token?: string): Promise<void>;
@@ -368,6 +422,36 @@ export interface GameModule {
368
422
  */
369
423
  onRoomAssigned(callback: (data: { roomId: string }) => void): UnsubscribeFn;
370
424
 
425
+ // ─── Reconnection lifecycle (SDK ≥ 2.21; documented since 2.16) ──────────
426
+ /** Highest action sequence seen (joins, syncs, live actions). */
427
+ getLastSequence(): number;
428
+ /** Highest action sequence applied locally; trails while catching up. */
429
+ getLastAppliedSequence(): number;
430
+ /** Current connection state, synchronously. */
431
+ getConnectionState(): GameConnectionState;
432
+ /**
433
+ * Observe connection-state transitions ('connected' → 'disconnected' →
434
+ * 'rejoining' → 'reconnected' → 'connected') — identical across the socket,
435
+ * embedded-proxy, and direct transports. One hook to drive a
436
+ * "Reconnecting…" overlay and gate input.
437
+ */
438
+ onConnectionState(callback: (state: GameConnectionState) => void): UnsubscribeFn;
439
+ /**
440
+ * Fires once per reconnect, after the resync completes. Restore local
441
+ * state here — it does NOT fire for a manually-requested sync.
442
+ */
443
+ onReconnected(callback: (info: ReconnectedInfo) => void): UnsubscribeFn;
444
+ /**
445
+ * Reconnect-safe authoritative shared state. Commits are sequenced actions
446
+ * applied through `reduce` on every client in the same order (deduped by
447
+ * sequence); the authority (player_ids[0] by default) auto-checkpoints via
448
+ * setState, and (re)joining clients recover automatically (load checkpoint,
449
+ * replay the un-checkpointed tail). Use for state that must survive a
450
+ * disconnect — unlike replicate() (realtime, lossy). Degrades to
451
+ * local-apply in direct mode (no platform checkpoint there).
452
+ */
453
+ syncedState<S = Record<string, any>>(initial: S, opts?: SyncedStateOptions<S>): SyncedState<S>;
454
+
371
455
  /**
372
456
  * Register an ADDITIONAL event listener. Unlike the onX methods this
373
457
  * supports multiple listeners per event, can be called before connect(),