@usions/sdk 2.2.0 → 2.10.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,188 @@
1
+ /**
2
+ * Usion SDK Netcode — WebRTC peer-to-peer data channels.
3
+ *
4
+ * WebSocket runs over TCP (ordered+reliable) → one lost packet stalls
5
+ * everything behind it (head-of-line blocking). WebRTC data channels run over
6
+ * UDP/SCTP and can be unreliable+unordered, so the newest state always gets
7
+ * through. Gameplay flows peer-to-peer (no backend hop, no HOL blocking).
8
+ *
9
+ * Practices adopted (WebRTC game-networking guidance):
10
+ * - **Two channels**: 'unreliable' (ordered:false, maxRetransmits:0) for
11
+ * gameplay, 'reliable' (ordered:true) for must-arrive events.
12
+ * - **Sequence numbers** on the unreliable channel → drop stale/out-of-order
13
+ * frames (the receiver only ever advances).
14
+ * - **TURN-ready**: ~15–20% of sessions need a relay; pass iceServers (use the
15
+ * `MeshConnection.iceServers()` helper). Trickle ICE is used by default.
16
+ * - **Reconnect**: monitor (ice)connectionState and recover via ICE restart.
17
+ *
18
+ * Signaling I/O is injected, so the same class works in every connection mode
19
+ * and is testable with a fake RTCPeerConnection.
20
+ */
21
+ const DEFAULT_ICE = [{ urls: 'stun:stun.l.google.com:19302' }];
22
+
23
+ function isBinary(d) {
24
+ return d instanceof ArrayBuffer || ArrayBuffer.isView(d);
25
+ }
26
+
27
+ export class MeshConnection {
28
+ /**
29
+ * @param {object} opts
30
+ * @param {'host'|'guest'} opts.role
31
+ * @param {(payload:object)=>void} opts.sendSignal
32
+ * @param {Array} [opts.iceServers]
33
+ * @param {boolean} [opts.sequenced=true] Drop stale frames on the unreliable channel.
34
+ * @param {boolean} [opts.autoReconnect=true] Recover via ICE restart (host).
35
+ * @param {number} [opts.maxRestarts=5]
36
+ * @param {Function} [opts.RTCPeerConnection] Injectable for tests.
37
+ * @param {Function} [opts.setTimeout] Injectable for tests.
38
+ */
39
+ constructor(opts = {}) {
40
+ if (opts.role !== 'host' && opts.role !== 'guest') throw new Error("MeshConnection requires role 'host' or 'guest'");
41
+ if (typeof opts.sendSignal !== 'function') throw new Error('MeshConnection requires a sendSignal(payload) function');
42
+ this.role = opts.role;
43
+ this._sendSignal = opts.sendSignal;
44
+ this._iceServers = opts.iceServers || DEFAULT_ICE;
45
+ this._sequenced = opts.sequenced !== false;
46
+ this._autoReconnect = opts.autoReconnect !== false;
47
+ this._maxRestarts = opts.maxRestarts != null ? opts.maxRestarts : 5;
48
+ this._RTCPeerConnection = opts.RTCPeerConnection || (typeof RTCPeerConnection !== 'undefined' ? RTCPeerConnection : null);
49
+ this._setTimeout = opts.setTimeout || (typeof setTimeout !== 'undefined' ? setTimeout : null);
50
+
51
+ this.connected = false;
52
+ this._pc = null;
53
+ this._unreliable = null;
54
+ this._reliable = null;
55
+ this._sendSeq = 0;
56
+ this._recvSeq = 0;
57
+ this._restarts = 0;
58
+
59
+ this.onOpen = null; // () => void
60
+ this.onMessage = null; // (data, channel) => void
61
+ this.onClose = null; // () => void
62
+ this.onError = null; // (err) => void
63
+ this.onStateChange = null; // (state:string) => void
64
+ }
65
+
66
+ /** Build an iceServers array with optional TURN relay. */
67
+ static iceServers(cfg = {}) {
68
+ const list = [{ urls: cfg.stun || 'stun:stun.l.google.com:19302' }];
69
+ if (cfg.turn) {
70
+ const entry = { urls: cfg.turn };
71
+ if (cfg.turnUsername) entry.username = cfg.turnUsername;
72
+ if (cfg.turnCredential) entry.credential = cfg.turnCredential;
73
+ list.push(entry);
74
+ }
75
+ return list;
76
+ }
77
+
78
+ async start() {
79
+ if (!this._RTCPeerConnection) throw new Error('RTCPeerConnection unavailable');
80
+ const pc = new this._RTCPeerConnection({ iceServers: this._iceServers });
81
+ this._pc = pc;
82
+
83
+ pc.onicecandidate = (e) => { if (e && e.candidate) this._sendSignal({ type: 'ice', candidate: e.candidate }); };
84
+ const onState = () => this._handleStateChange(pc.connectionState || pc.iceConnectionState);
85
+ pc.onconnectionstatechange = onState;
86
+ pc.oniceconnectionstatechange = onState;
87
+
88
+ if (this.role === 'host') {
89
+ this._bindChannel((this._unreliable = pc.createDataChannel('unreliable', { ordered: false, maxRetransmits: 0 })), 'unreliable');
90
+ this._bindChannel((this._reliable = pc.createDataChannel('reliable', { ordered: true })), 'reliable');
91
+ await this._makeOffer(false);
92
+ } else {
93
+ pc.ondatachannel = (e) => {
94
+ const ch = e.channel;
95
+ if (ch.label === 'unreliable') this._bindChannel((this._unreliable = ch), 'unreliable');
96
+ else if (ch.label === 'reliable') this._bindChannel((this._reliable = ch), 'reliable');
97
+ };
98
+ }
99
+ }
100
+
101
+ async _makeOffer(iceRestart) {
102
+ const pc = this._pc;
103
+ const offer = await pc.createOffer(iceRestart ? { iceRestart: true } : undefined);
104
+ await pc.setLocalDescription(offer);
105
+ this._sendSignal({ type: 'offer', sdp: pc.localDescription, restart: !!iceRestart });
106
+ }
107
+
108
+ _handleStateChange(state) {
109
+ if (this.onStateChange) this.onStateChange(state);
110
+ if (state === 'connected') { this._restarts = 0; return; }
111
+ if (state === 'failed' || state === 'disconnected' || state === 'closed') {
112
+ if (this.connected) { this.connected = false; if (this.onClose) this.onClose(); }
113
+ if (this._autoReconnect && this.role === 'host' && state !== 'closed' && this._restarts < this._maxRestarts) {
114
+ this._restarts += 1;
115
+ const delay = Math.min(1000 * this._restarts, 5000);
116
+ if (this._setTimeout) this._setTimeout(() => { this._makeOffer(true).catch((err) => { if (this.onError) this.onError(err); }); }, delay);
117
+ }
118
+ }
119
+ }
120
+
121
+ async handleSignal(payload) {
122
+ const pc = this._pc;
123
+ if (!pc || !payload || !payload.type) return;
124
+ try {
125
+ if (payload.type === 'offer') {
126
+ await pc.setRemoteDescription(payload.sdp);
127
+ const answer = await pc.createAnswer();
128
+ await pc.setLocalDescription(answer);
129
+ this._sendSignal({ type: 'answer', sdp: pc.localDescription });
130
+ } else if (payload.type === 'answer') {
131
+ await pc.setRemoteDescription(payload.sdp);
132
+ } else if (payload.type === 'ice' && payload.candidate) {
133
+ await pc.addIceCandidate(payload.candidate);
134
+ }
135
+ } catch (err) {
136
+ if (this.onError) this.onError(err);
137
+ }
138
+ }
139
+
140
+ _bindChannel(ch, label) {
141
+ ch.binaryType = 'arraybuffer';
142
+ ch.onopen = () => {
143
+ if (label === 'unreliable' && !this.connected) { this.connected = true; if (this.onOpen) this.onOpen(); }
144
+ };
145
+ ch.onmessage = (e) => {
146
+ if (!this.onMessage) return;
147
+ let data = e.data;
148
+ if (isBinary(data)) { this.onMessage(data, label); return; }
149
+ try { data = typeof data === 'string' ? JSON.parse(data) : data; } catch (_) { this.onMessage(e.data, label); return; }
150
+ if (label === 'unreliable' && this._sequenced && data && typeof data.__s === 'number') {
151
+ if (data.__s <= this._recvSeq) return; // stale / out-of-order — drop
152
+ this._recvSeq = data.__s;
153
+ this.onMessage(data.m, label);
154
+ return;
155
+ }
156
+ this.onMessage(data, label);
157
+ };
158
+ ch.onclose = () => { if (this.connected) { this.connected = false; if (this.onClose) this.onClose(); } };
159
+ ch.onerror = (e) => { if (this.onError) this.onError(e); };
160
+ }
161
+
162
+ /** Send over the unreliable/unordered channel (sequenced unless binary). */
163
+ send(data) {
164
+ const ch = this._unreliable;
165
+ if (!ch || ch.readyState !== 'open') return false;
166
+ if (isBinary(data)) { ch.send(data); return true; }
167
+ if (this._sequenced) { this._sendSeq += 1; ch.send(JSON.stringify({ __s: this._sendSeq, m: data })); }
168
+ else ch.send(typeof data === 'string' ? data : JSON.stringify(data));
169
+ return true;
170
+ }
171
+
172
+ /** Send over the reliable/ordered channel. */
173
+ sendReliable(data) {
174
+ const ch = this._reliable;
175
+ if (!ch || ch.readyState !== 'open') return false;
176
+ if (isBinary(data)) ch.send(data);
177
+ else ch.send(typeof data === 'string' ? data : JSON.stringify(data));
178
+ return true;
179
+ }
180
+
181
+ close() {
182
+ try { if (this._unreliable) this._unreliable.close(); } catch (_) {}
183
+ try { if (this._reliable) this._reliable.close(); } catch (_) {}
184
+ try { if (this._pc) this._pc.close(); } catch (_) {}
185
+ this._unreliable = this._reliable = this._pc = null;
186
+ this.connected = false;
187
+ }
188
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Usion SDK Netcode — network condition simulator.
3
+ *
4
+ * Every serious game studio tests under degraded networks. NetworkSim wraps a
5
+ * send (or receive) function and injects artificial **latency**, **jitter**,
6
+ * **packet loss**, and **duplication** so creators can feel and tune their game
7
+ * on a bad connection — locally, before shipping. Pure and deterministic when
8
+ * you inject `random` / `setTimeout` (used in tests).
9
+ */
10
+ export class NetworkSim {
11
+ /**
12
+ * @param {object} [opts]
13
+ * @param {number} [opts.latencyMs=0] Base one-way delay added to each message.
14
+ * @param {number} [opts.jitterMs=0] Random +/- variation around the latency.
15
+ * @param {number} [opts.lossPct=0] Probability (0–100) a message is dropped.
16
+ * @param {number} [opts.dupPct=0] Probability (0–100) a message is duplicated.
17
+ * @param {Function} [opts.setTimeout] Injectable scheduler (tests).
18
+ * @param {Function} [opts.clearTimeout]
19
+ * @param {Function} [opts.random] Injectable RNG (tests).
20
+ */
21
+ constructor(opts = {}) {
22
+ this._set = opts.setTimeout || (typeof setTimeout !== 'undefined' ? setTimeout : null);
23
+ this._clear = opts.clearTimeout || (typeof clearTimeout !== 'undefined' ? clearTimeout : null);
24
+ this._rand = opts.random || Math.random;
25
+ this._timers = new Set();
26
+ this.set(opts);
27
+ }
28
+
29
+ /** Update conditions live. */
30
+ set(opts = {}) {
31
+ if (opts.latencyMs != null) this.latencyMs = Math.max(0, opts.latencyMs);
32
+ else if (this.latencyMs == null) this.latencyMs = 0;
33
+ if (opts.jitterMs != null) this.jitterMs = Math.max(0, opts.jitterMs);
34
+ else if (this.jitterMs == null) this.jitterMs = 0;
35
+ if (opts.lossPct != null) this.lossPct = Math.max(0, Math.min(100, opts.lossPct));
36
+ else if (this.lossPct == null) this.lossPct = 0;
37
+ if (opts.dupPct != null) this.dupPct = Math.max(0, Math.min(100, opts.dupPct));
38
+ else if (this.dupPct == null) this.dupPct = 0;
39
+ return this;
40
+ }
41
+
42
+ /** Delay for one message: latency ± jitter (never negative). */
43
+ _delay() {
44
+ const j = this.jitterMs ? (this._rand() * 2 - 1) * this.jitterMs : 0;
45
+ const d = this.latencyMs + j;
46
+ return d > 0 ? d : 0;
47
+ }
48
+
49
+ _schedule(fn, delay) {
50
+ if (!this._set) { fn(); return; }
51
+ const id = this._set(() => { this._timers.delete(id); fn(); }, delay);
52
+ this._timers.add(id);
53
+ }
54
+
55
+ /**
56
+ * Wrap a delivery function so calls are delayed/dropped/duplicated per the
57
+ * current conditions. The returned function has the same signature.
58
+ */
59
+ wrap(fn) {
60
+ const self = this;
61
+ return function (...args) {
62
+ // Fast path: no degradation configured.
63
+ if (!self.latencyMs && !self.jitterMs && !self.lossPct && !self.dupPct) return fn.apply(this, args);
64
+ if (self.lossPct && self._rand() * 100 < self.lossPct) return undefined; // dropped
65
+ const ctx = this;
66
+ self._schedule(() => fn.apply(ctx, args), self._delay());
67
+ if (self.dupPct && self._rand() * 100 < self.dupPct) self._schedule(() => fn.apply(ctx, args), self._delay());
68
+ return undefined;
69
+ };
70
+ }
71
+
72
+ /** Cancel all in-flight scheduled deliveries. */
73
+ flush() {
74
+ if (this._clear) for (const id of this._timers) this._clear(id);
75
+ this._timers.clear();
76
+ }
77
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Usion SDK Netcode — Round-trip-time meter.
3
+ *
4
+ * Tracks RTT with an exponentially-weighted moving average plus a jitter
5
+ * estimate, so games (and the debug overlay) can surface real latency and so
6
+ * the interpolation buffer can be tuned to network conditions. Transport
7
+ * agnostic: feed it raw samples, or use begin()/end() to time individual
8
+ * probes.
9
+ */
10
+ export class PingMeter {
11
+ /**
12
+ * @param {object} [opts]
13
+ * @param {number} [opts.alpha=0.2] EWMA smoothing factor (0..1).
14
+ * @param {Function} [opts.now] Clock source (default Date.now).
15
+ */
16
+ constructor(opts = {}) {
17
+ this._alpha = opts.alpha != null ? opts.alpha : 0.2;
18
+ this._now = opts.now || (() => Date.now());
19
+ this._rtt = null;
20
+ this._jitter = 0;
21
+ this._last = null;
22
+ this._id = 0;
23
+ this._outstanding = {};
24
+ }
25
+
26
+ /** Smoothed round-trip time in ms (null until the first sample). */
27
+ get rtt() { return this._rtt; }
28
+ /** One-way latency estimate (≈ rtt / 2). */
29
+ get latency() { return this._rtt == null ? null : this._rtt / 2; }
30
+ /** Smoothed absolute variation between samples (ms). */
31
+ get jitter() { return this._jitter; }
32
+ /** Most recent raw RTT sample (ms). */
33
+ get last() { return this._last; }
34
+
35
+ /** Start timing a probe; pass the returned id to end(). */
36
+ begin() {
37
+ const id = ++this._id;
38
+ this._outstanding[id] = this._now();
39
+ return id;
40
+ }
41
+
42
+ /** Complete a probe started with begin(); returns the RTT sample or null. */
43
+ end(id) {
44
+ const sent = this._outstanding[id];
45
+ if (sent == null) return null;
46
+ delete this._outstanding[id];
47
+ return this.sample(this._now() - sent);
48
+ }
49
+
50
+ /** Feed a raw RTT sample (ms). Returns the same value. */
51
+ sample(rttMs) {
52
+ if (!(rttMs >= 0)) return null;
53
+ if (this._rtt == null) {
54
+ this._rtt = rttMs;
55
+ } else {
56
+ this._jitter += this._alpha * (Math.abs(rttMs - this._rtt) - this._jitter);
57
+ this._rtt += this._alpha * (rttMs - this._rtt);
58
+ }
59
+ this._last = rttMs;
60
+ return rttMs;
61
+ }
62
+
63
+ reset() {
64
+ this._rtt = null;
65
+ this._jitter = 0;
66
+ this._last = null;
67
+ this._outstanding = {};
68
+ }
69
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Usion SDK Netcode — Client-side prediction + server reconciliation.
3
+ *
4
+ * Apply local input instantly (tagged with a sequence number); when the
5
+ * authoritative state arrives carrying the last input the server processed,
6
+ * snap to it and replay every still-unacknowledged input on top. The local
7
+ * player sees zero input delay while staying consistent with the server.
8
+ *
9
+ * Error smoothing (Overwatch / Gabriel Gambetta): hard-snapping the render
10
+ * position on every correction looks jittery. Instead we keep the visible
11
+ * position as `corrected + error`, where `error` is the gap a correction
12
+ * introduced, and decay that error to zero over a few frames — so corrections
13
+ * are smooth and, in the common case, invisible. Enable via `opts.smooth`.
14
+ *
15
+ * The game supplies a pure `apply(state, input) -> newState` (must not mutate).
16
+ */
17
+ function parseSmoothKeys(smooth) {
18
+ if (!smooth) return null;
19
+ let keys = smooth.keys != null ? smooth.keys : smooth;
20
+ if (typeof keys === 'string') keys = keys.trim().split(/\s+/).filter(Boolean);
21
+ return Array.isArray(keys) && keys.length ? keys : null;
22
+ }
23
+
24
+ export class Predictor {
25
+ /**
26
+ * @param {object} opts
27
+ * @param {(state:any, input:any)=>any} opts.apply Pure state transition.
28
+ * @param {any} [opts.initialState]
29
+ * @param {{keys?:string|string[], rate?:number, snapTo?:number}|string} [opts.smooth]
30
+ * Error-smoothing config. `keys` are numeric fields to blend; `rate` is the
31
+ * per-frame decay (0..1, default 0.2); `snapTo` is the residual below which
32
+ * the error is zeroed (default 0.001).
33
+ */
34
+ constructor(opts = {}) {
35
+ if (typeof opts.apply !== 'function') throw new Error('Predictor requires an apply(state, input) function');
36
+ this._apply = opts.apply;
37
+ this._state = opts.initialState !== undefined ? opts.initialState : null;
38
+ this._seq = 0;
39
+ this._pending = [];
40
+ this._smoothKeys = parseSmoothKeys(opts.smooth);
41
+ this._smoothRate = (opts.smooth && opts.smooth.rate != null) ? opts.smooth.rate : 0.2;
42
+ this._snapTo = (opts.smooth && opts.smooth.snapTo != null) ? opts.smooth.snapTo : 0.001;
43
+ this._error = {};
44
+ }
45
+
46
+ get state() { return this._state; }
47
+ get pending() { return this._pending.slice(); }
48
+ get lastSeq() { return this._seq; }
49
+
50
+ /** Apply an input locally and record it. Attach the returned `seq` when sending. */
51
+ predict(input) {
52
+ this._seq += 1;
53
+ const seq = this._seq;
54
+ this._pending.push({ seq, input });
55
+ this._state = this._apply(this._state, input);
56
+ return { seq, state: this._state };
57
+ }
58
+
59
+ /**
60
+ * Reconcile against authoritative server state.
61
+ * @param {any} serverState Authoritative state.
62
+ * @param {number} ackedSeq Highest input sequence the server has applied.
63
+ * @returns {any} the corrected predicted state (authoritative + replayed inputs)
64
+ */
65
+ reconcile(serverState, ackedSeq) {
66
+ const before = this._state;
67
+ const ack = ackedSeq == null ? -1 : ackedSeq;
68
+ this._pending = this._pending.filter((p) => p.seq > ack);
69
+ let s = serverState;
70
+ for (let i = 0; i < this._pending.length; i++) s = this._apply(s, this._pending[i].input);
71
+
72
+ // Accumulate the correction gap into the error offset for smoothing.
73
+ if (this._smoothKeys && before && s) {
74
+ for (let i = 0; i < this._smoothKeys.length; i++) {
75
+ const k = this._smoothKeys[i];
76
+ if (typeof before[k] === 'number' && typeof s[k] === 'number') {
77
+ this._error[k] = (this._error[k] || 0) + (before[k] - s[k]);
78
+ }
79
+ }
80
+ }
81
+ this._state = s;
82
+ return this._state;
83
+ }
84
+
85
+ /**
86
+ * The state to render: corrected state plus the decaying error offset.
87
+ * Call once per rendered frame; it decays the error toward zero.
88
+ * @param {number} [rate] Override the per-frame decay factor.
89
+ * @returns {any}
90
+ */
91
+ view(rate) {
92
+ if (!this._smoothKeys || !this._state || typeof this._state !== 'object') return this._state;
93
+ const r = rate != null ? rate : this._smoothRate;
94
+ const out = Array.isArray(this._state) ? this._state.slice() : Object.assign({}, this._state);
95
+ for (let i = 0; i < this._smoothKeys.length; i++) {
96
+ const k = this._smoothKeys[i];
97
+ let e = this._error[k] || 0;
98
+ if (typeof out[k] === 'number' && e !== 0) out[k] = out[k] + e;
99
+ e *= (1 - r);
100
+ if (Math.abs(e) < this._snapTo) e = 0;
101
+ this._error[k] = e;
102
+ }
103
+ return out;
104
+ }
105
+
106
+ /** Hard reset (e.g. on rematch). */
107
+ reset(state) {
108
+ this._state = state !== undefined ? state : null;
109
+ this._seq = 0;
110
+ this._pending = [];
111
+ this._error = {};
112
+ }
113
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Usion SDK Netcode — Outbound send coalescing at a fixed tick rate.
3
+ *
4
+ * Games tend to emit on every input event or every animation frame (often
5
+ * 60+/s). Each emit is a separate Socket.IO message and, in platform mode,
6
+ * a separate server-side broadcast (and historically a Redis write). That
7
+ * floods the wire and the server far beyond what gameplay needs.
8
+ *
9
+ * The Coalescer buffers outbound messages and flushes them on a fixed tick
10
+ * (e.g. 20 Hz). Two modes per channel:
11
+ * - queue(type, data): latest-wins — only the newest value per type is sent
12
+ * each tick. Ideal for state snapshots, cursor/position updates.
13
+ * - append(type, data): buffered — every value is kept and sent as a batch.
14
+ * Use for discrete inputs you can't afford to drop.
15
+ *
16
+ * `onFlush(entries)` receives `[{ type, data }]` in insertion order. The
17
+ * Coalescer is transport-agnostic; the caller decides how to send each entry
18
+ * (game.realtime, game.action, a WebRTC datachannel, etc.).
19
+ */
20
+ export class Coalescer {
21
+ /**
22
+ * @param {object} opts
23
+ * @param {number} [opts.hz=20] Flush frequency.
24
+ * @param {(entries:Array<{type:string,data:any}>)=>void} opts.onFlush
25
+ * @param {boolean} [opts.autoStart=true]
26
+ * @param {Function} [opts.setInterval] Injectable for tests.
27
+ * @param {Function} [opts.clearInterval]
28
+ */
29
+ constructor(opts = {}) {
30
+ if (typeof opts.onFlush !== 'function') {
31
+ throw new Error('Coalescer requires an onFlush(entries) callback');
32
+ }
33
+ this._onFlush = opts.onFlush;
34
+ this._hz = opts.hz || 20;
35
+ this._setInterval = opts.setInterval || (typeof setInterval !== 'undefined' ? setInterval : null);
36
+ this._clearInterval = opts.clearInterval || (typeof clearInterval !== 'undefined' ? clearInterval : null);
37
+ this._order = []; // slot keys in first-seen order
38
+ this._slots = {}; // key -> { mode:'latest'|'list', value, list }
39
+ this._timer = null;
40
+ if (opts.autoStart !== false) this.start();
41
+ }
42
+
43
+ get running() { return this._timer !== null; }
44
+
45
+ _ensure(type, mode) {
46
+ let slot = this._slots[type];
47
+ if (!slot) {
48
+ slot = { mode, value: undefined, list: [] };
49
+ this._slots[type] = slot;
50
+ this._order.push(type);
51
+ }
52
+ return slot;
53
+ }
54
+
55
+ /** Latest-wins: only the newest data for `type` is sent on the next flush. */
56
+ queue(type, data) {
57
+ const slot = this._ensure(type, 'latest');
58
+ slot.mode = 'latest';
59
+ slot.value = data;
60
+ }
61
+
62
+ /** Buffered: every value for `type` is kept and sent on the next flush. */
63
+ append(type, data) {
64
+ const slot = this._ensure(type, 'list');
65
+ slot.mode = 'list';
66
+ slot.list.push(data);
67
+ }
68
+
69
+ /** Build pending entries (insertion order) and clear the buffer. */
70
+ drain() {
71
+ const entries = [];
72
+ for (let i = 0; i < this._order.length; i++) {
73
+ const type = this._order[i];
74
+ const slot = this._slots[type];
75
+ if (slot.mode === 'latest') {
76
+ if (slot.value !== undefined) entries.push({ type, data: slot.value });
77
+ } else {
78
+ for (let j = 0; j < slot.list.length; j++) entries.push({ type, data: slot.list[j] });
79
+ }
80
+ }
81
+ this._order = [];
82
+ this._slots = {};
83
+ return entries;
84
+ }
85
+
86
+ /** Flush now (also called automatically on each tick). */
87
+ flush() {
88
+ if (this._order.length === 0) return;
89
+ const entries = this.drain();
90
+ if (entries.length) this._onFlush(entries);
91
+ }
92
+
93
+ start() {
94
+ if (this._timer || !this._setInterval) return;
95
+ this._timer = this._setInterval(() => this.flush(), Math.max(1, Math.round(1000 / this._hz)));
96
+ }
97
+
98
+ stop() {
99
+ if (this._timer && this._clearInterval) this._clearInterval(this._timer);
100
+ this._timer = null;
101
+ }
102
+ }