@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,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
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Usion SDK Netcode — WebTransport (HTTP/3 / QUIC) client transport.
3
+ *
4
+ * The lowest-latency *client-server* path for browser games (Baseline across
5
+ * major browsers since 2026). Unlike WebSocket (TCP → head-of-line blocking)
6
+ * it offers UDP-like unreliable **datagrams** — the newest snapshot always
7
+ * gets through — plus reliable **streams** for must-arrive events, over a
8
+ * single QUIC connection, without WebRTC's ICE/STUN/TURN/SDP complexity.
9
+ *
10
+ * Uses the native `WebTransport` API (zero dependency). On the server, pair
11
+ * with an HTTP/3 server such as the open-source `@fails-components/webtransport`
12
+ * (Node). Same interface shape as MeshConnection so it drops into the same
13
+ * snapshot sender/receiver + interpolation pipeline.
14
+ *
15
+ * Framing:
16
+ * - datagram = [seq:uint32 BE][type:uint8][payload] (one datagram = one msg)
17
+ * - stream = [len:uint32 BE][type:uint8][payload] (len = 1 + payload bytes)
18
+ * - type 0 = binary (Uint8Array), 1 = JSON (utf8). Sequenced datagrams drop
19
+ * stale/out-of-order frames (receiver only advances).
20
+ */
21
+ const _enc = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
22
+ const _dec = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null;
23
+
24
+ function toBytes(s) { return _enc ? _enc.encode(s) : Uint8Array.from(Buffer.from(s, 'utf8')); }
25
+ function fromBytes(b) { return _dec ? _dec.decode(b) : Buffer.from(b).toString('utf8'); }
26
+ function isBinary(d) { return d instanceof ArrayBuffer || ArrayBuffer.isView(d); }
27
+
28
+ /** Encode a value to { type, bytes }: binary passthrough or JSON utf8. */
29
+ function encodePayload(data) {
30
+ if (isBinary(data)) return { type: 0, bytes: data instanceof Uint8Array ? data : new Uint8Array(data.buffer || data) };
31
+ return { type: 1, bytes: toBytes(JSON.stringify(data)) };
32
+ }
33
+ function decodePayload(type, bytes) {
34
+ if (type === 1) { try { return JSON.parse(fromBytes(bytes)); } catch (_) { return null; } }
35
+ return bytes;
36
+ }
37
+
38
+ /** datagram frame: [seq:4][type:1][payload] */
39
+ export function encodeDatagram(seq, data) {
40
+ const { type, bytes } = encodePayload(data);
41
+ const out = new Uint8Array(5 + bytes.length);
42
+ new DataView(out.buffer).setUint32(0, seq >>> 0, false);
43
+ out[4] = type;
44
+ out.set(bytes, 5);
45
+ return out;
46
+ }
47
+ export function decodeDatagram(frame) {
48
+ const b = frame instanceof Uint8Array ? frame : new Uint8Array(frame);
49
+ if (b.length < 5) return null;
50
+ const seq = new DataView(b.buffer, b.byteOffset, b.byteLength).getUint32(0, false);
51
+ return { seq: seq, value: decodePayload(b[4], b.subarray(5)) };
52
+ }
53
+
54
+ /** stream frame: [len:4][type:1][payload], len = 1 + payload length */
55
+ export function encodeStreamFrame(data) {
56
+ const { type, bytes } = encodePayload(data);
57
+ const out = new Uint8Array(4 + 1 + bytes.length);
58
+ new DataView(out.buffer).setUint32(0, 1 + bytes.length, false);
59
+ out[4] = type;
60
+ out.set(bytes, 5);
61
+ return out;
62
+ }
63
+
64
+ /** Stateful deframer for the reliable byte stream. push(bytes) → [values]. */
65
+ export class StreamDeframer {
66
+ constructor() { this._buf = new Uint8Array(0); }
67
+ push(chunk) {
68
+ const c = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
69
+ const merged = new Uint8Array(this._buf.length + c.length);
70
+ merged.set(this._buf, 0); merged.set(c, this._buf.length);
71
+ this._buf = merged;
72
+ const out = [];
73
+ while (this._buf.length >= 4) {
74
+ const len = new DataView(this._buf.buffer, this._buf.byteOffset, 4).getUint32(0, false);
75
+ if (this._buf.length < 4 + len) break;
76
+ const type = this._buf[4];
77
+ const payload = this._buf.subarray(5, 4 + len);
78
+ out.push(decodePayload(type, payload.slice()));
79
+ this._buf = this._buf.subarray(4 + len);
80
+ }
81
+ return out;
82
+ }
83
+ }
84
+
85
+ export class WebTransportConnection {
86
+ /**
87
+ * @param {object} opts
88
+ * @param {string} opts.url https:// HTTP/3 endpoint
89
+ * @param {Array} [opts.serverCertificateHashes] for self-signed dev certs
90
+ * @param {boolean}[opts.sequenced=true] drop stale datagrams
91
+ * @param {Function}[opts.WebTransport] injectable for tests
92
+ */
93
+ constructor(opts = {}) {
94
+ if (!opts.url) throw new Error('WebTransportConnection requires a url');
95
+ this._url = opts.url;
96
+ this._opts = opts;
97
+ this._sequenced = opts.sequenced !== false;
98
+ this._WT = opts.WebTransport || (typeof WebTransport !== 'undefined' ? WebTransport : null);
99
+
100
+ this.connected = false;
101
+ this._t = null;
102
+ this._dgWriter = null;
103
+ this._streamWriter = null;
104
+ this._deframer = new StreamDeframer();
105
+ this._sendSeq = 0;
106
+ this._recvSeq = 0;
107
+
108
+ this.onOpen = null; // () => void
109
+ this.onMessage = null; // (data, channel:'datagram'|'reliable') => void
110
+ this.onClose = null; // () => void
111
+ this.onError = null; // (err) => void
112
+ }
113
+
114
+ async connect() {
115
+ if (!this._WT) throw new Error('WebTransport unavailable in this environment');
116
+ const init = {};
117
+ if (this._opts.serverCertificateHashes) init.serverCertificateHashes = this._opts.serverCertificateHashes;
118
+ const t = new this._WT(this._url, init);
119
+ this._t = t;
120
+ if (t.ready && typeof t.ready.then === 'function') await t.ready;
121
+
122
+ if (t.datagrams && t.datagrams.writable) this._dgWriter = t.datagrams.writable.getWriter();
123
+ if (typeof t.createBidirectionalStream === 'function') {
124
+ try {
125
+ const s = await t.createBidirectionalStream();
126
+ this._streamWriter = s.writable.getWriter();
127
+ this._pumpReadable(s.readable, 'reliable');
128
+ } catch (e) { /* reliable stream optional */ }
129
+ }
130
+ if (t.datagrams && t.datagrams.readable) this._pumpDatagrams(t.datagrams.readable);
131
+ if (t.closed && typeof t.closed.then === 'function') t.closed.then(() => this._onClosed(), () => this._onClosed());
132
+
133
+ this.connected = true;
134
+ if (this.onOpen) this.onOpen();
135
+ }
136
+
137
+ async _pumpDatagrams(readable) {
138
+ try {
139
+ const reader = readable.getReader();
140
+ for (;;) {
141
+ const { value, done } = await reader.read();
142
+ if (done) break;
143
+ if (value) this._onDatagramBytes(value);
144
+ }
145
+ } catch (e) { if (this.onError) this.onError(e); }
146
+ }
147
+
148
+ async _pumpReadable(readable, channel) {
149
+ try {
150
+ const reader = readable.getReader();
151
+ for (;;) {
152
+ const { value, done } = await reader.read();
153
+ if (done) break;
154
+ if (value) { const msgs = this._deframer.push(value); for (let i = 0; i < msgs.length; i++) if (this.onMessage) this.onMessage(msgs[i], channel); }
155
+ }
156
+ } catch (e) { if (this.onError) this.onError(e); }
157
+ }
158
+
159
+ /** Handle one inbound datagram frame (also the test entry point). */
160
+ _onDatagramBytes(frame) {
161
+ const parsed = decodeDatagram(frame);
162
+ if (!parsed) return;
163
+ if (this._sequenced) {
164
+ if (parsed.seq <= this._recvSeq) return; // stale / out-of-order
165
+ this._recvSeq = parsed.seq;
166
+ }
167
+ if (this.onMessage) this.onMessage(parsed.value, 'datagram');
168
+ }
169
+
170
+ /** Send over the unreliable datagram channel (sequenced). */
171
+ send(data) {
172
+ if (!this._dgWriter) return false;
173
+ this._sendSeq += 1;
174
+ try { this._dgWriter.write(encodeDatagram(this._sendSeq, data)); return true; }
175
+ catch (e) { return false; }
176
+ }
177
+
178
+ /** Send over the reliable ordered stream. */
179
+ sendReliable(data) {
180
+ if (!this._streamWriter) return false;
181
+ try { this._streamWriter.write(encodeStreamFrame(data)); return true; }
182
+ catch (e) { return false; }
183
+ }
184
+
185
+ _onClosed() {
186
+ if (this.connected) { this.connected = false; if (this.onClose) this.onClose(); }
187
+ }
188
+
189
+ close() {
190
+ try { if (this._dgWriter) this._dgWriter.releaseLock && this._dgWriter.releaseLock(); } catch (_) {}
191
+ try { if (this._t) this._t.close(); } catch (_) {}
192
+ this._t = this._dgWriter = this._streamWriter = null;
193
+ this.connected = false;
194
+ }
195
+ }
@@ -2,7 +2,7 @@
2
2
  * Usion SDK Wallet Module — wallet and payment operations
3
3
  */
4
4
 
5
- import { getNextRequestId } from './core.js';
5
+ import { getNextRequestId, isTrustedMessageSource } from './core.js';
6
6
 
7
7
  /**
8
8
  * @param {object} Usion - Reference to the main Usion object
@@ -57,6 +57,10 @@ export function createWalletModule(Usion) {
57
57
 
58
58
  // Listen for response
59
59
  function handler(event) {
60
+ // Only honor payment results from the trusted host shell — a forged
61
+ // PAYMENT_SUCCESS must never resolve this promise.
62
+ if (!isTrustedMessageSource(event)) return;
63
+
60
64
  let response;
61
65
  try {
62
66
  response = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;