@usions/sdk 2.20.1 → 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/README.md +5 -4
- package/package.json +1 -1
- package/src/browser.js +320 -9
- package/src/modules/game-core.js +5 -0
- package/src/modules/game-methods.js +12 -1
- package/src/modules/game-reliability.js +278 -0
- package/src/modules/game-socket.js +24 -7
- package/types/index.d.ts +92 -5
package/README.md
CHANGED
|
@@ -139,10 +139,11 @@ follow four rules so both screens always agree:
|
|
|
139
139
|
actions automatically. Use `onPlayerConnection` for the opponent's status;
|
|
140
140
|
don't end the match on `'reconnecting'` (a ~15s grace window applies),
|
|
141
141
|
treat `'gone'` as the opponent having left.
|
|
142
|
-
4. **
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
142
|
+
4. **Checkpoint with `setState`** — any participant (not just the host)
|
|
143
|
+
should checkpoint at meaningful transitions so a rejoining client restores
|
|
144
|
+
instantly (delivered as `game_state` in the join result and `onSync`; max
|
|
145
|
+
64 KB). Letting whoever made the move persist state keeps the snapshot
|
|
146
|
+
fresh even while the host is backgrounded:
|
|
146
147
|
```javascript
|
|
147
148
|
await Usion.game.setState(fullGameState)
|
|
148
149
|
```
|
package/package.json
CHANGED
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.
|
|
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. (
|
|
1599
|
-
//
|
|
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
|
});
|
|
@@ -2220,7 +2245,10 @@ var Usion = (function () {
|
|
|
2220
2245
|
* in the join ack and in game:sync — recovery becomes "load checkpoint,
|
|
2221
2246
|
* replay the tail" instead of replaying every action from zero.
|
|
2222
2247
|
*
|
|
2223
|
-
*
|
|
2248
|
+
* Any participant in the room may call this (not just the host) — the
|
|
2249
|
+
* server only requires that you're in player_ids. This keeps the snapshot
|
|
2250
|
+
* fresh even while the host is backgrounded; the host-authority pattern
|
|
2251
|
+
* (only the host calls setState) still works for games that prefer it. The
|
|
2224
2252
|
* serialized state is capped at 64 KB.
|
|
2225
2253
|
*
|
|
2226
2254
|
* @param {*} state - JSON-serializable authoritative game state
|
|
@@ -4612,6 +4640,285 @@ var Usion = (function () {
|
|
|
4612
4640
|
game.getRtt = function () { return this._pingMeter ? this._pingMeter.rtt : null; };
|
|
4613
4641
|
}
|
|
4614
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
|
+
|
|
4615
4922
|
/**
|
|
4616
4923
|
* Usion SDK Game Core — game module base, connect routing, event registrations
|
|
4617
4924
|
*/
|
|
@@ -4637,6 +4944,8 @@ var Usion = (function () {
|
|
|
4637
4944
|
reconnect: 'reconnect',
|
|
4638
4945
|
connection_error: 'connectionError',
|
|
4639
4946
|
room_assigned: 'roomAssigned',
|
|
4947
|
+
connection_state: 'connectionState',
|
|
4948
|
+
reconnected: 'reconnected',
|
|
4640
4949
|
};
|
|
4641
4950
|
|
|
4642
4951
|
function _normalizeEventName(event) {
|
|
@@ -4850,6 +5159,8 @@ var Usion = (function () {
|
|
|
4850
5159
|
applyGameProxy(game, Usion);
|
|
4851
5160
|
applyGameMethods(game, Usion);
|
|
4852
5161
|
applyGameNetcode(game, Usion);
|
|
5162
|
+
// Applied last: wraps connect/connectDirect and rides dispatched events.
|
|
5163
|
+
applyGameReliability(game, Usion);
|
|
4853
5164
|
|
|
4854
5165
|
// Foreground catch-up safety net (generic across every transport).
|
|
4855
5166
|
//
|
package/src/modules/game-core.js
CHANGED
|
@@ -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
|
});
|
|
@@ -248,7 +256,10 @@ export function applyGameMethods(game, Usion) {
|
|
|
248
256
|
* in the join ack and in game:sync — recovery becomes "load checkpoint,
|
|
249
257
|
* replay the tail" instead of replaying every action from zero.
|
|
250
258
|
*
|
|
251
|
-
*
|
|
259
|
+
* Any participant in the room may call this (not just the host) — the
|
|
260
|
+
* server only requires that you're in player_ids. This keeps the snapshot
|
|
261
|
+
* fresh even while the host is backgrounded; the host-authority pattern
|
|
262
|
+
* (only the host calls setState) still works for games that prefer it. The
|
|
252
263
|
* serialized state is capped at 64 KB.
|
|
253
264
|
*
|
|
254
265
|
* @param {*} state - JSON-serializable authoritative game state
|
|
@@ -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. (
|
|
79
|
-
//
|
|
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>;
|
|
@@ -300,8 +354,9 @@ export interface GameModule {
|
|
|
300
354
|
// pause input and show a "reconnecting" indicator.
|
|
301
355
|
// - For turn-based games, pass { nextTurn } on each move and trust
|
|
302
356
|
// current_turn from join/sync instead of deriving the turn locally.
|
|
303
|
-
// -
|
|
304
|
-
//
|
|
357
|
+
// - Any participant should checkpoint via setState() at meaningful
|
|
358
|
+
// transitions so rejoining clients can restore instantly (the host
|
|
359
|
+
// need not be the one to call it).
|
|
305
360
|
join(roomId?: string): Promise<GameJoinResult>;
|
|
306
361
|
leave(): void;
|
|
307
362
|
action(actionType: string, actionData?: Record<string, any>, opts?: GameActionOptions): Promise<ActionResult>;
|
|
@@ -322,9 +377,11 @@ export interface GameModule {
|
|
|
322
377
|
invite(opts?: GameInviteOptions): Promise<GameInviteResult>;
|
|
323
378
|
|
|
324
379
|
/**
|
|
325
|
-
* Checkpoint authoritative game state on the server
|
|
326
|
-
* player_ids[0]/host)
|
|
327
|
-
*
|
|
380
|
+
* Checkpoint authoritative game state on the server. Any participant in the
|
|
381
|
+
* room may call this (not just player_ids[0]/host), so the snapshot stays
|
|
382
|
+
* fresh even while the host is backgrounded. (Re)joining clients receive the
|
|
383
|
+
* latest checkpoint as game_state in the join ack and in game:sync. Max 64 KB
|
|
384
|
+
* serialized.
|
|
328
385
|
*/
|
|
329
386
|
setState(state: Record<string, any>): Promise<{ success: boolean; error?: string; code?: UsionErrorCode }>;
|
|
330
387
|
|
|
@@ -365,6 +422,36 @@ export interface GameModule {
|
|
|
365
422
|
*/
|
|
366
423
|
onRoomAssigned(callback: (data: { roomId: string }) => void): UnsubscribeFn;
|
|
367
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
|
+
|
|
368
455
|
/**
|
|
369
456
|
* Register an ADDITIONAL event listener. Unlike the onX methods this
|
|
370
457
|
* supports multiple listeners per event, can be called before connect(),
|