@usions/sdk 2.1.5 → 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,104 @@
1
+ /**
2
+ * Usion SDK Netcode — server-side lag compensation ("server rewind").
3
+ *
4
+ * The fairness technique behind CS:GO/CS2 and Valorant. The authoritative
5
+ * server records a short history of world snapshots; when a client claims an
6
+ * action (e.g. a shot) the server **rewinds** the world to what that client
7
+ * actually saw — accounting for their latency and interpolation delay — and
8
+ * resolves the hit against that past state. Without this, high-ping players
9
+ * must "lead" their targets and hit registration feels broken.
10
+ *
11
+ * Entities are matched by `id` and interpolated between the two recorded
12
+ * snapshots straddling the rewind time (same math as client interpolation).
13
+ * Pure and testable; use it inside your game server (direct mode).
14
+ */
15
+ function lerp(a, b, t) { return a + (b - a) * t; }
16
+
17
+ export class LagCompensator {
18
+ /**
19
+ * @param {object} [opts]
20
+ * @param {number} [opts.historyMs=1000] How far back to retain snapshots.
21
+ * @param {number} [opts.maxSize=256] Hard cap on retained snapshots.
22
+ * @param {function} [opts.now] Clock source (default Date.now).
23
+ */
24
+ constructor(opts = {}) {
25
+ this._historyMs = opts.historyMs || 1000;
26
+ this._maxSize = opts.maxSize || 256;
27
+ this._now = opts.now || (() => Date.now());
28
+ this._history = []; // [{ time, entities:[{id,...}] }] oldest→newest
29
+ }
30
+
31
+ /**
32
+ * Record the authoritative world state for this tick.
33
+ * @param {Array} entities Array of entities (each with a stable `id`).
34
+ * @param {number} [time] Server time (default now()).
35
+ */
36
+ record(entities, time) {
37
+ const t = time != null ? time : this._now();
38
+ this._history.push({ time: t, entities: entities.map((e) => Object.assign({}, e)) });
39
+ const cutoff = t - this._historyMs;
40
+ while (this._history.length > this._maxSize || (this._history.length > 2 && this._history[0].time < cutoff)) {
41
+ this._history.shift();
42
+ }
43
+ }
44
+
45
+ get size() { return this._history.length; }
46
+ clear() { this._history = []; }
47
+
48
+ /**
49
+ * Reconstruct the world as it was at `time` (interpolated), keyed by id.
50
+ * @param {number} time Absolute server time to rewind to.
51
+ * @param {string[]} [keys] Numeric fields to interpolate (default: all numbers).
52
+ * @returns {Object<string, object>} entities by id, or {} if no history.
53
+ */
54
+ rewind(time, keys) {
55
+ const h = this._history;
56
+ if (h.length === 0) return {};
57
+ if (time <= h[0].time) return byId(h[0].entities);
58
+ if (time >= h[h.length - 1].time) return byId(h[h.length - 1].entities);
59
+
60
+ let older = h[0], newer = h[h.length - 1];
61
+ for (let i = h.length - 1; i > 0; i--) {
62
+ if (h[i - 1].time <= time && time <= h[i].time) { older = h[i - 1]; newer = h[i]; break; }
63
+ }
64
+ const span = newer.time - older.time;
65
+ const t = span > 0 ? (time - older.time) / span : 0;
66
+ const oldById = byId(older.entities);
67
+ const out = {};
68
+ const newById = byId(newer.entities);
69
+ for (const id in newById) {
70
+ const b = newById[id];
71
+ const a = oldById[id];
72
+ const e = Object.assign({}, b);
73
+ if (a) {
74
+ const fields = keys || Object.keys(b);
75
+ for (let k = 0; k < fields.length; k++) {
76
+ const key = fields[k];
77
+ if (typeof a[key] === 'number' && typeof b[key] === 'number') e[key] = lerp(a[key], b[key], t);
78
+ }
79
+ }
80
+ out[id] = e;
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /**
86
+ * Convenience: rewind to what a client saw, given their measured round-trip
87
+ * time and interpolation buffer. rewindMs is clamped to `maxRewindMs` (cap
88
+ * it — Valorant caps ~35ms; CS allows more).
89
+ * @returns {Object<string, object>} entities by id
90
+ */
91
+ rewindForClient(rttMs, interpBufferMs, opts) {
92
+ opts = opts || {};
93
+ const maxRewind = opts.maxRewindMs != null ? opts.maxRewindMs : 250;
94
+ let rewindMs = (rttMs || 0) / 2 + (interpBufferMs || 0);
95
+ if (rewindMs > maxRewind) rewindMs = maxRewind;
96
+ return this.rewind(this._now() - rewindMs, opts.keys);
97
+ }
98
+ }
99
+
100
+ function byId(entities) {
101
+ const m = {};
102
+ for (let i = 0; i < entities.length; i++) m[entities[i].id] = entities[i];
103
+ return m;
104
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Usion SDK Netcode — deterministic lockstep (the League-of-Legends / RTS model).
3
+ *
4
+ * Instead of streaming world state, every client runs the **same deterministic
5
+ * simulation** and exchanges only **inputs**. Bandwidth is tiny even with
6
+ * hundreds of units, and you get **replays for free** (re-run the input log).
7
+ *
8
+ * A frame advances only once every player's input for that frame is known.
9
+ * Local inputs are scheduled `inputDelay` frames ahead so they have time to
10
+ * reach peers before that frame is simulated (this hides latency). The game
11
+ * owns its simulation; this class only orders inputs and tells you when to step.
12
+ *
13
+ * Determinism is the contract: your `step(frame, inputsByPlayer)` must be
14
+ * fully deterministic (no Date.now/Math.random without a seeded source, no
15
+ * floating-point divergence) or clients will desync.
16
+ */
17
+ export class Lockstep {
18
+ /**
19
+ * @param {object} opts
20
+ * @param {string} opts.playerId
21
+ * @param {string[]} opts.players All player ids in the match.
22
+ * @param {(frame:number, inputs:Object)=>void} opts.step Deterministic sim step.
23
+ * @param {(msg:{frame:number,playerId:string,input:any})=>void} [opts.send]
24
+ * @param {number} [opts.inputDelay=2] Frames of input delay (latency hiding).
25
+ * @param {any} [opts.idleInput=null] Input used for the seeded startup frames.
26
+ */
27
+ constructor(opts = {}) {
28
+ if (!opts.playerId) throw new Error('Lockstep requires playerId');
29
+ if (!Array.isArray(opts.players) || opts.players.length === 0) throw new Error('Lockstep requires players[]');
30
+ if (typeof opts.step !== 'function') throw new Error('Lockstep requires a step(frame, inputs) function');
31
+ this.playerId = opts.playerId;
32
+ this._players = opts.players.slice();
33
+ this._step = opts.step;
34
+ this._send = opts.send || function () {};
35
+ this._inputDelay = opts.inputDelay != null ? opts.inputDelay : 2;
36
+ this._idle = opts.idleInput != null ? opts.idleInput : null;
37
+
38
+ this._inputs = {}; // frame -> { playerId: input }
39
+ this._frame = 0; // next frame to simulate
40
+ this._submitFrame = this._inputDelay; // next frame the local player will fill
41
+ this._replay = []; // [{ frame, inputs }]
42
+
43
+ // Seed the first `inputDelay` frames as idle so the sim can start.
44
+ for (let f = 0; f < this._inputDelay; f++) {
45
+ this._inputs[f] = {};
46
+ for (let i = 0; i < this._players.length; i++) this._inputs[f][this._players[i]] = this._idle;
47
+ }
48
+ }
49
+
50
+ get frame() { return this._frame; }
51
+ get players() { return this._players.slice(); }
52
+
53
+ /** Submit the local player's input for the next schedulable frame. */
54
+ submit(input) {
55
+ const frame = this._submitFrame++;
56
+ if (!this._inputs[frame]) this._inputs[frame] = {};
57
+ this._inputs[frame][this.playerId] = input;
58
+ this._send({ frame: frame, playerId: this.playerId, input: input });
59
+ return frame;
60
+ }
61
+
62
+ /** Record a remote player's input. */
63
+ receive(msg) {
64
+ if (!msg || msg.frame == null || !msg.playerId) return;
65
+ if (!this._inputs[msg.frame]) this._inputs[msg.frame] = {};
66
+ this._inputs[msg.frame][msg.playerId] = msg.input;
67
+ }
68
+
69
+ _ready(frame) {
70
+ const fr = this._inputs[frame];
71
+ if (!fr) return false;
72
+ for (let i = 0; i < this._players.length; i++) if (!(this._players[i] in fr)) return false;
73
+ return true;
74
+ }
75
+
76
+ /**
77
+ * Advance the simulation by every frame whose inputs are fully known.
78
+ * @returns {number} frames advanced this call.
79
+ */
80
+ tick() {
81
+ let advanced = 0;
82
+ while (this._ready(this._frame)) {
83
+ const inputs = this._inputs[this._frame];
84
+ this._step(this._frame, inputs);
85
+ this._replay.push({ frame: this._frame, inputs: inputs });
86
+ delete this._inputs[this._frame];
87
+ this._frame += 1;
88
+ advanced += 1;
89
+ }
90
+ return advanced;
91
+ }
92
+
93
+ /** True if the sim is blocked waiting on a missing input (stall detection). */
94
+ isStalled() { return !this._ready(this._frame); }
95
+
96
+ /** The ordered input log — persist it to enable replays. */
97
+ getReplay() { return this._replay.slice(); }
98
+
99
+ /** Re-simulate a recorded match by replaying its input log. */
100
+ static replay(log, step) {
101
+ for (let i = 0; i < log.length; i++) step(log[i].frame, log[i].inputs);
102
+ }
103
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Usion SDK Netcode — N-peer full mesh.
3
+ *
4
+ * Manages one MeshConnection per remote peer so >2 players can talk directly
5
+ * peer-to-peer. For each pair, the peer with the lexicographically smaller id
6
+ * is the host (deterministic role assignment avoids offer/answer "glare").
7
+ * Signaling is routed per-peer (`sendSignal(toPeerId, payload)`); feed inbound
8
+ * signaling with `handleSignal(fromPeerId, payload)`.
9
+ */
10
+ import { MeshConnection } from './mesh.js';
11
+
12
+ export class MeshNetwork {
13
+ /**
14
+ * @param {object} opts
15
+ * @param {string} opts.selfId
16
+ * @param {(toPeerId:string, payload:object)=>void} opts.sendSignal
17
+ * @param {Array} [opts.iceServers]
18
+ * @param {Function} [opts.RTCPeerConnection]
19
+ * @param {Function} [opts.setTimeout]
20
+ * @param {boolean} [opts.sequenced]
21
+ * @param {boolean} [opts.autoReconnect]
22
+ */
23
+ constructor(opts = {}) {
24
+ if (!opts.selfId) throw new Error('MeshNetwork requires a selfId');
25
+ if (typeof opts.sendSignal !== 'function') throw new Error('MeshNetwork requires sendSignal(toPeerId, payload)');
26
+ this.selfId = opts.selfId;
27
+ this._sendSignal = opts.sendSignal;
28
+ this._opts = opts;
29
+ this._peers = {}; // peerId -> MeshConnection
30
+
31
+ this.onPeerOpen = null; // (peerId) => void
32
+ this.onPeerClose = null; // (peerId) => void
33
+ this.onMessage = null; // (peerId, data, channel) => void
34
+ this.onError = null; // (peerId, err) => void
35
+ }
36
+
37
+ get peerIds() { return Object.keys(this._peers); }
38
+ get connectedCount() { let n = 0; for (const id in this._peers) if (this._peers[id].connected) n += 1; return n; }
39
+ peer(peerId) { return this._peers[peerId] || null; }
40
+
41
+ _roleFor(peerId) { return this.selfId < peerId ? 'host' : 'guest'; }
42
+
43
+ _ensurePeer(peerId) {
44
+ if (this._peers[peerId]) return this._peers[peerId];
45
+ const self = this;
46
+ const conn = new MeshConnection({
47
+ role: this._roleFor(peerId),
48
+ iceServers: this._opts.iceServers,
49
+ RTCPeerConnection: this._opts.RTCPeerConnection,
50
+ setTimeout: this._opts.setTimeout,
51
+ sequenced: this._opts.sequenced,
52
+ autoReconnect: this._opts.autoReconnect,
53
+ sendSignal: (payload) => self._sendSignal(peerId, payload),
54
+ });
55
+ conn.onOpen = () => { if (self.onPeerOpen) self.onPeerOpen(peerId); };
56
+ conn.onClose = () => { if (self.onPeerClose) self.onPeerClose(peerId); };
57
+ conn.onMessage = (data, channel) => { if (self.onMessage) self.onMessage(peerId, data, channel); };
58
+ conn.onError = (err) => { if (self.onError) self.onError(peerId, err); };
59
+ this._peers[peerId] = conn;
60
+ return conn;
61
+ }
62
+
63
+ /** Connect to a peer (creates the connection and starts negotiation). */
64
+ async addPeer(peerId) {
65
+ if (peerId === this.selfId) return null;
66
+ const conn = this._ensurePeer(peerId);
67
+ await conn.start();
68
+ return conn;
69
+ }
70
+
71
+ /** Sync the mesh to a roster of peer ids: connect to new ones, drop missing. */
72
+ async setRoster(peerIds) {
73
+ const want = {};
74
+ for (let i = 0; i < peerIds.length; i++) if (peerIds[i] !== this.selfId) want[peerIds[i]] = true;
75
+ for (const id in want) if (!this._peers[id]) await this.addPeer(id);
76
+ for (const id in this._peers) if (!want[id]) this.removePeer(id);
77
+ }
78
+
79
+ removePeer(peerId) {
80
+ const conn = this._peers[peerId];
81
+ if (!conn) return;
82
+ try { conn.close(); } catch (_) {}
83
+ delete this._peers[peerId];
84
+ }
85
+
86
+ /** Route an inbound signaling message from a peer. */
87
+ async handleSignal(fromPeerId, payload) {
88
+ if (!fromPeerId || fromPeerId === this.selfId) return;
89
+ const conn = this._ensurePeer(fromPeerId);
90
+ if (!conn._pc) await conn.start(); // lazily start guest side on first offer
91
+ await conn.handleSignal(payload);
92
+ }
93
+
94
+ /** Send to one peer over the unreliable channel. */
95
+ send(peerId, data) { const c = this._peers[peerId]; return c ? c.send(data) : false; }
96
+ sendReliable(peerId, data) { const c = this._peers[peerId]; return c ? c.sendReliable(data) : false; }
97
+
98
+ /** Broadcast to all connected peers (unreliable). */
99
+ broadcast(data) { for (const id in this._peers) this._peers[id].send(data); }
100
+ broadcastReliable(data) { for (const id in this._peers) this._peers[id].sendReliable(data); }
101
+
102
+ close() { for (const id in this._peers) { try { this._peers[id].close(); } catch (_) {} } this._peers = {}; }
103
+ }
@@ -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
+ }