@net-mesh/browser 0.43.0 → 0.44.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/CHANGELOG.md +176 -0
- package/dist/index.bundle.js +2 -2
- package/dist/lobby.d.ts +29 -2
- package/dist/lobby.js +72 -6
- package/dist/net_leaf.d.ts +19 -21
- package/dist/net_leaf.js +44 -46
- package/dist/net_leaf_bg.wasm +0 -0
- package/dist/net_leaf_bg.wasm.d.ts +10 -10
- package/dist/netcode/assignment.d.ts +169 -0
- package/dist/netcode/assignment.js +321 -0
- package/dist/netcode/client.d.ts +125 -3
- package/dist/netcode/client.js +291 -27
- package/dist/netcode/codec.d.ts +180 -0
- package/dist/netcode/codec.js +547 -0
- package/dist/netcode/host.d.ts +58 -1
- package/dist/netcode/host.js +254 -9
- package/dist/netcode/index.d.ts +14 -4
- package/dist/netcode/index.js +8 -3
- package/dist/netcode/interpolate.d.ts +24 -1
- package/dist/netcode/interpolate.js +52 -1
- package/dist/netcode/paths.d.ts +115 -0
- package/dist/netcode/paths.js +261 -0
- package/dist/netcode/shards.d.ts +138 -0
- package/dist/netcode/shards.js +270 -0
- package/dist/netcode/wire.d.ts +37 -3
- package/dist/netcode/wire.js +28 -1
- package/dist/node.d.ts +5 -0
- package/dist/node.js +1 -0
- package/dist/world/link.d.ts +9 -0
- package/dist/world/link.js +4 -3
- package/dist/world/regions.d.ts +9 -2
- package/dist/world/regions.js +34 -4
- package/package.json +3 -2
package/dist/netcode/client.js
CHANGED
|
@@ -16,7 +16,28 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { ClockEstimator } from './clock.js';
|
|
18
18
|
import { lerpNumbers, SnapshotBuffer } from './interpolate.js';
|
|
19
|
-
import { MAX_NETCODE_INTEREST_KEYS, MAX_NETCODE_INTEREST_KEY_LENGTH, chunkOf, defaultNow, decodeFrame, encodeFrame, eventPeer, peerHex, } from './wire.js';
|
|
19
|
+
import { MAX_NETCODE_INTEREST_KEYS, MAX_NETCODE_INTEREST_KEY_LENGTH, MAX_NAME_REQUESTS, NAME_FORGET_MS, chunkOf, defaultNow, decodeFrame, encodeFrame, eventPeer, peerHex, } from './wire.js';
|
|
20
|
+
import { decodeBinarySnapshot, isBinaryFrame } from './codec.js';
|
|
21
|
+
/** A typed join failure from {@link NetcodeClient.ready}. */
|
|
22
|
+
export class NetcodeError extends Error {
|
|
23
|
+
kind;
|
|
24
|
+
constructor(kind, message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.kind = kind;
|
|
27
|
+
this.name = 'NetcodeError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Unacknowledged inputs one frame may carry when there is a backlog — the
|
|
32
|
+
* inputs held across a change of owner while the new epoch propagated. One
|
|
33
|
+
* frame, so they arrive together: inputs split over frames can land in two
|
|
34
|
+
* ticks, and a host skips the gap the earlier frame would have filled.
|
|
35
|
+
*/
|
|
36
|
+
const MAX_INPUT_BACKLOG = 64;
|
|
37
|
+
/** A fresh {@link InputSequencer}: `seq` 0, nothing pending. */
|
|
38
|
+
export function createInputSequencer() {
|
|
39
|
+
return { seq: 0, pending: [] };
|
|
40
|
+
}
|
|
20
41
|
/** Join a host's netcode. */
|
|
21
42
|
export function joinNetcode(options) {
|
|
22
43
|
const now = options.now ?? defaultNow;
|
|
@@ -25,14 +46,22 @@ export function joinNetcode(options) {
|
|
|
25
46
|
throw new TypeError(`host is not a node id: ${JSON.stringify(options.host)}`);
|
|
26
47
|
const label = options.label;
|
|
27
48
|
const delay = options.interpolationDelayMs ?? 100;
|
|
28
|
-
const blend = options.interpolate ?? lerpNumbers;
|
|
49
|
+
const blend = options.interpolate ?? options.codec?.interpolate ?? lerpNumbers;
|
|
29
50
|
const redundancy = Math.max(1, options.inputRedundancy ?? 8);
|
|
30
51
|
const clock = new ClockEstimator();
|
|
31
|
-
|
|
52
|
+
// Replaced, not cleared, when a binary snapshot shows a re-created host.
|
|
53
|
+
let snapshots = new SnapshotBuffer();
|
|
32
54
|
let stream = null;
|
|
33
55
|
let closed = false;
|
|
34
|
-
|
|
35
|
-
|
|
56
|
+
// Shared with the other shards' clients when `sequencer` is given, so the
|
|
57
|
+
// sequence and the unacknowledged inputs survive a change of owner.
|
|
58
|
+
const inputs = options.sequencer ?? createInputSequencer();
|
|
59
|
+
// Whether this client speaks for the inputs: sends them, and trims them
|
|
60
|
+
// on its host's acks. With a shared sequencer (`joinShards`) only the
|
|
61
|
+
// client predicting the local entity does; the other shards' clients hold
|
|
62
|
+
// the same list and must neither send it to their hosts nor trim it on
|
|
63
|
+
// acks for inputs those hosts never received.
|
|
64
|
+
const speaks = options.sequencer === undefined || options.local !== undefined;
|
|
36
65
|
let predicted = null;
|
|
37
66
|
// A correction being blended in: the stale prediction (still advanced
|
|
38
67
|
// by every input, so it keeps pace with the controls) and when it began.
|
|
@@ -50,6 +79,10 @@ export function joinNetcode(options) {
|
|
|
50
79
|
return blend(correcting.from, predicted, Math.max(0, alpha));
|
|
51
80
|
};
|
|
52
81
|
let snapshotsSeen = 0;
|
|
82
|
+
// With a codec the host's state arrives quantized, and a prediction is
|
|
83
|
+
// never exactly a quantized value: compare within the codec's quanta, or
|
|
84
|
+
// every snapshot would read as a correction.
|
|
85
|
+
const sameState = (a, b) => options.codec !== undefined ? options.codec.near(a, b) : JSON.stringify(a) === JSON.stringify(b);
|
|
53
86
|
// The interest set: what was asked, its version, and the newest version a
|
|
54
87
|
// snapshot has echoed (the `w` frame repeats until they meet).
|
|
55
88
|
let interest = null;
|
|
@@ -82,23 +115,73 @@ export function joinNetcode(options) {
|
|
|
82
115
|
let partialSnapshots = 0;
|
|
83
116
|
let corrections = 0;
|
|
84
117
|
let joined = false;
|
|
118
|
+
// The epoch of the newest-tick snapshot that carried one.
|
|
119
|
+
let snapshotEpoch = null;
|
|
120
|
+
let epochTick = -1;
|
|
121
|
+
let lastSnapshotAt = null;
|
|
122
|
+
const inputEpoch = () => (options.epoch === undefined ? {} : { ep: options.epoch() });
|
|
123
|
+
// The newest `redundancy` inputs — or, past that, the whole backlog up to
|
|
124
|
+
// MAX_INPUT_BACKLOG, in one frame.
|
|
125
|
+
const sendInputs = () => {
|
|
126
|
+
const count = inputs.pending.length > redundancy ? MAX_INPUT_BACKLOG : redundancy;
|
|
127
|
+
send({ n: label, k: 'i', i: inputs.pending.slice(-count), ...inputEpoch() });
|
|
128
|
+
};
|
|
85
129
|
const offset = () => clock.estimate()?.offsetMs ?? null;
|
|
86
130
|
const hostNow = () => {
|
|
87
131
|
const o = offset();
|
|
88
132
|
return o === null ? null : now() + o;
|
|
89
133
|
};
|
|
134
|
+
// What the last attempt to reach the host said, for `stats()` and for
|
|
135
|
+
// `ready()`'s typed failure: a join that never connects must say why.
|
|
136
|
+
let connectOutcome = null;
|
|
137
|
+
let lastError = null;
|
|
138
|
+
const message = (error) => String(error?.message ?? error);
|
|
139
|
+
// Whether a reopen should offer again. Found by the S0b topology run: a
|
|
140
|
+
// pair whose direct attempt ended relayed was re-offered on EVERY stream
|
|
141
|
+
// reopen. `connectPeer` is a no-op only on a healthy direct pair, so each
|
|
142
|
+
// reopen began a fresh ICE attempt, a late one bumped the session, the
|
|
143
|
+
// stream went stale, the next send failed and reopened — 44 attempts in
|
|
144
|
+
// five minutes and no snapshot. A relayed pair is a supported outcome:
|
|
145
|
+
// reopen the stream on it, and try direct again only after
|
|
146
|
+
// `directRetryMs`. `noAnnouncement` is retried at once (it is cheap and
|
|
147
|
+
// discovery needs it); a direct pair's call is the idempotent check.
|
|
148
|
+
//
|
|
149
|
+
// A `connectPeer` that rejects (the peer answered `Reject`, say) is
|
|
150
|
+
// throttled the same way: it leaves no outcome, and without this every
|
|
151
|
+
// reopen would offer again.
|
|
152
|
+
const directRetryMs = Math.max(0, options.directRetryMs ?? 30_000);
|
|
153
|
+
let lastOfferAt = -Infinity;
|
|
154
|
+
let offerRejected = false;
|
|
155
|
+
const shouldOffer = () => now() - lastOfferAt >= directRetryMs ||
|
|
156
|
+
(!offerRejected &&
|
|
157
|
+
(connectOutcome === null || connectOutcome === 'noAnnouncement' || connectOutcome === 'direct'));
|
|
90
158
|
let opening = null;
|
|
91
159
|
const open = () => {
|
|
92
160
|
opening ??= (async () => {
|
|
93
|
-
|
|
161
|
+
if (options.transport.connectPeer !== undefined && shouldOffer()) {
|
|
162
|
+
lastOfferAt = now();
|
|
163
|
+
try {
|
|
164
|
+
const outcome = (await options.transport.connectPeer(host));
|
|
165
|
+
offerRejected = false;
|
|
166
|
+
if (outcome !== null && typeof outcome === 'object' && typeof outcome.type === 'string')
|
|
167
|
+
connectOutcome = outcome.type;
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
offerRejected = true;
|
|
171
|
+
lastError = message(error);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
94
174
|
const opened = await options.transport.openStream({ reliability: 'fireAndForget', peer: host, label, lossy: true });
|
|
95
175
|
if (closed) {
|
|
96
176
|
opened.close();
|
|
97
177
|
return;
|
|
98
178
|
}
|
|
99
179
|
stream = opened;
|
|
180
|
+
lastError = null;
|
|
100
181
|
})()
|
|
101
|
-
.catch(
|
|
182
|
+
.catch(error => {
|
|
183
|
+
lastError = message(error);
|
|
184
|
+
})
|
|
102
185
|
.finally(() => {
|
|
103
186
|
opening = null;
|
|
104
187
|
});
|
|
@@ -114,7 +197,9 @@ export function joinNetcode(options) {
|
|
|
114
197
|
void open();
|
|
115
198
|
return;
|
|
116
199
|
}
|
|
117
|
-
const retire = () => {
|
|
200
|
+
const retire = (error) => {
|
|
201
|
+
if (error !== undefined)
|
|
202
|
+
lastError = message(error);
|
|
118
203
|
if (stream === current) {
|
|
119
204
|
stream = null;
|
|
120
205
|
try {
|
|
@@ -128,8 +213,8 @@ export function joinNetcode(options) {
|
|
|
128
213
|
try {
|
|
129
214
|
void Promise.resolve(current.send(encodeFrame(frame))).catch(retire);
|
|
130
215
|
}
|
|
131
|
-
catch {
|
|
132
|
-
retire();
|
|
216
|
+
catch (error) {
|
|
217
|
+
retire(error);
|
|
133
218
|
}
|
|
134
219
|
};
|
|
135
220
|
const stopEvents = options.transport.onEvent(event => {
|
|
@@ -137,6 +222,10 @@ export function joinNetcode(options) {
|
|
|
137
222
|
return;
|
|
138
223
|
if (eventPeer(event.peerNode) !== host)
|
|
139
224
|
return;
|
|
225
|
+
if (isBinaryFrame(event.payload)) {
|
|
226
|
+
onBinary(event.payload);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
140
229
|
const frame = decodeFrame(event.payload, label);
|
|
141
230
|
if (frame === null)
|
|
142
231
|
return;
|
|
@@ -146,7 +235,47 @@ export function joinNetcode(options) {
|
|
|
146
235
|
}
|
|
147
236
|
if (frame.k !== 's')
|
|
148
237
|
return;
|
|
149
|
-
|
|
238
|
+
onSnapshot(frame);
|
|
239
|
+
});
|
|
240
|
+
/** One snapshot frame, JSON or decoded binary: whole, or a chunk. */
|
|
241
|
+
// `ready()`: settled once, at the first snapshot, the deadline, or close.
|
|
242
|
+
let readySettle = null;
|
|
243
|
+
let readyPromise = null;
|
|
244
|
+
let readyTimer = null;
|
|
245
|
+
let readyFailure = null;
|
|
246
|
+
const settleReady = (failure) => {
|
|
247
|
+
if (readyTimer !== null)
|
|
248
|
+
clearTimeout(readyTimer);
|
|
249
|
+
readyTimer = null;
|
|
250
|
+
const settle = readySettle;
|
|
251
|
+
readySettle = null;
|
|
252
|
+
if (failure === null)
|
|
253
|
+
settle?.resolve();
|
|
254
|
+
else {
|
|
255
|
+
readyFailure = failure;
|
|
256
|
+
settle?.reject(failure);
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
const joinFailure = () => {
|
|
260
|
+
const why = lastError === null ? '' : ` (${lastError})`;
|
|
261
|
+
if (connectOutcome === 'noAnnouncement') {
|
|
262
|
+
return new NetcodeError('no-announcement', `no announcement from host ${host}: the host must announce, and this node must have heard it before it can connect${why}`);
|
|
263
|
+
}
|
|
264
|
+
if (stream === null)
|
|
265
|
+
return new NetcodeError('unreachable', `could not open a stream to host ${host}${why}`);
|
|
266
|
+
return new NetcodeError('timeout', `no snapshot from host ${host} within ${String(joinTimeoutMs)} ms`);
|
|
267
|
+
};
|
|
268
|
+
const joinTimeoutMs = Math.max(0, options.joinTimeoutMs ?? 15_000);
|
|
269
|
+
function onSnapshot(frame) {
|
|
270
|
+
lastSnapshotAt = now();
|
|
271
|
+
if (frame.ep !== undefined && frame.tick >= epochTick) {
|
|
272
|
+
snapshotEpoch = frame.ep;
|
|
273
|
+
epochTick = frame.tick;
|
|
274
|
+
}
|
|
275
|
+
if (!joined) {
|
|
276
|
+
joined = true;
|
|
277
|
+
settleReady(null);
|
|
278
|
+
}
|
|
150
279
|
if (frame.iv !== undefined && frame.iv > interestAcked)
|
|
151
280
|
interestAcked = frame.iv;
|
|
152
281
|
if (frame.of === undefined || frame.c === undefined) {
|
|
@@ -172,7 +301,100 @@ export function joinNetcode(options) {
|
|
|
172
301
|
assembly.ack = Math.max(assembly.ack, frame.ack);
|
|
173
302
|
if (assembly.parts.size === assembly.of)
|
|
174
303
|
finish(frame.tick, assembly);
|
|
175
|
-
}
|
|
304
|
+
}
|
|
305
|
+
// Binary snapshots: index → id, learnt from the frames that name them,
|
|
306
|
+
// and when a frame last carried the index. One host instance never
|
|
307
|
+
// reuses an index, so a name, once known, stays right for that instance.
|
|
308
|
+
const idOf = new Map();
|
|
309
|
+
let binaryFrames = 0;
|
|
310
|
+
let unnamedEntities = 0;
|
|
311
|
+
// The host instance the names belong to, and instances replaced since
|
|
312
|
+
// (a late frame from one is not a new host).
|
|
313
|
+
let hostInstance = null;
|
|
314
|
+
const retiredInstances = new Set();
|
|
315
|
+
/**
|
|
316
|
+
* A new host instance: the host was re-created under this label. Its
|
|
317
|
+
* indexes start over, and so do its ticks — drop every name, assembly
|
|
318
|
+
* and snapshot the old one left, rather than read the new one through
|
|
319
|
+
* them.
|
|
320
|
+
*/
|
|
321
|
+
const newHostInstance = (instance) => {
|
|
322
|
+
if (hostInstance !== null) {
|
|
323
|
+
retiredInstances.add(hostInstance);
|
|
324
|
+
if (retiredInstances.size > 8)
|
|
325
|
+
retiredInstances.delete(retiredInstances.values().next().value);
|
|
326
|
+
idOf.clear();
|
|
327
|
+
assemblies.clear();
|
|
328
|
+
newestTick = null;
|
|
329
|
+
snapshots = new SnapshotBuffer();
|
|
330
|
+
snapshotEpoch = null;
|
|
331
|
+
epochTick = -1;
|
|
332
|
+
}
|
|
333
|
+
hostInstance = instance;
|
|
334
|
+
};
|
|
335
|
+
// Names not carried by a frame for three times the host's forget time
|
|
336
|
+
// are forgotten (see NAME_FORGET_MS), checked about once a second.
|
|
337
|
+
let lastForget = now();
|
|
338
|
+
const forgetNames = (at) => {
|
|
339
|
+
if (at - lastForget < 1000)
|
|
340
|
+
return;
|
|
341
|
+
lastForget = at;
|
|
342
|
+
for (const [index, known] of idOf)
|
|
343
|
+
if (at - known.seen > 3 * NAME_FORGET_MS)
|
|
344
|
+
idOf.delete(index);
|
|
345
|
+
};
|
|
346
|
+
function onBinary(payload) {
|
|
347
|
+
const codec = options.codec;
|
|
348
|
+
if (codec === undefined)
|
|
349
|
+
return;
|
|
350
|
+
const decoded = decodeBinarySnapshot(payload, label, codec);
|
|
351
|
+
if (decoded === null)
|
|
352
|
+
return;
|
|
353
|
+
if (decoded.instance !== undefined && decoded.instance !== hostInstance) {
|
|
354
|
+
if (retiredInstances.has(decoded.instance))
|
|
355
|
+
return;
|
|
356
|
+
newHostInstance(decoded.instance);
|
|
357
|
+
}
|
|
358
|
+
const at = now();
|
|
359
|
+
for (const [index, id] of decoded.names) {
|
|
360
|
+
const known = idOf.get(index);
|
|
361
|
+
if (known !== undefined && known.id !== id) {
|
|
362
|
+
// A different name for a known index is another host's numbering
|
|
363
|
+
// (one from before instances were stamped): start over from this
|
|
364
|
+
// frame's names, and ask for the rest below.
|
|
365
|
+
idOf.clear();
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
for (const [index, id] of decoded.names)
|
|
370
|
+
idOf.set(index, { id, seen: at });
|
|
371
|
+
binaryFrames += 1;
|
|
372
|
+
const entities = {};
|
|
373
|
+
const unknown = [];
|
|
374
|
+
for (const [index, entity] of decoded.entities) {
|
|
375
|
+
const known = idOf.get(index);
|
|
376
|
+
if (known === undefined) {
|
|
377
|
+
unknown.push(index);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
known.seen = at;
|
|
381
|
+
Object.defineProperty(entities, known.id, { value: entity, enumerable: true, writable: true, configurable: true });
|
|
382
|
+
}
|
|
383
|
+
forgetNames(at);
|
|
384
|
+
if (unknown.length > 0) {
|
|
385
|
+
unnamedEntities += unknown.length;
|
|
386
|
+
send({ n: label, k: 'u', u: unknown.slice(0, MAX_NAME_REQUESTS) });
|
|
387
|
+
}
|
|
388
|
+
onSnapshot({
|
|
389
|
+
tick: decoded.tick,
|
|
390
|
+
t: decoded.t,
|
|
391
|
+
ack: decoded.ack,
|
|
392
|
+
e: entities,
|
|
393
|
+
...(decoded.iv === undefined ? {} : { iv: decoded.iv }),
|
|
394
|
+
...(decoded.ep === undefined ? {} : { ep: decoded.ep }),
|
|
395
|
+
...(decoded.of === undefined ? {} : { c: decoded.c, of: decoded.of }),
|
|
396
|
+
});
|
|
397
|
+
}
|
|
176
398
|
/**
|
|
177
399
|
* A chunked snapshot, done: every chunk, or the chunks that came, with
|
|
178
400
|
* each missing chunk's entities carried over from the previous snapshot
|
|
@@ -207,18 +429,22 @@ export function joinNetcode(options) {
|
|
|
207
429
|
snapshots.add({ time, tick, entities });
|
|
208
430
|
if (newestTick === null || tick > newestTick)
|
|
209
431
|
newestTick = tick;
|
|
432
|
+
// Acknowledged inputs are done with, predicting or not: a client that
|
|
433
|
+
// never trimmed would send its whole backlog in every input frame. An
|
|
434
|
+
// ack only grows, so an older snapshot's trims nothing.
|
|
435
|
+
if (speaks)
|
|
436
|
+
inputs.pending = inputs.pending.filter(input => input.seq > ack);
|
|
210
437
|
// Reconcile only on a snapshot newer than any seen: an older one
|
|
211
438
|
// (reordered by the carrier) must not roll the local entity back.
|
|
212
439
|
if (options.local && (newest === null || tick > newest.tick)) {
|
|
213
|
-
pending = pending.filter(input => input.seq > ack);
|
|
214
440
|
const authoritative = Object.prototype.hasOwnProperty.call(sent, options.local.id)
|
|
215
441
|
? sent[options.local.id]
|
|
216
442
|
: undefined;
|
|
217
443
|
if (authoritative !== undefined) {
|
|
218
444
|
let replayed = authoritative;
|
|
219
|
-
for (const input of pending)
|
|
445
|
+
for (const input of inputs.pending)
|
|
220
446
|
replayed = options.local.predict(replayed, input.data);
|
|
221
|
-
if (predicted !== null &&
|
|
447
|
+
if (predicted !== null && !sameState(replayed, predicted)) {
|
|
222
448
|
corrections += 1;
|
|
223
449
|
// Blend from what is on screen now, so a correction that lands
|
|
224
450
|
// mid-blend continues from there rather than jumping back.
|
|
@@ -229,36 +455,49 @@ export function joinNetcode(options) {
|
|
|
229
455
|
}
|
|
230
456
|
}
|
|
231
457
|
}
|
|
232
|
-
// Join, and keep the clock honest.
|
|
233
|
-
const
|
|
458
|
+
// Join, and keep the clock honest. The codec fingerprint rides both.
|
|
459
|
+
const fingerprint = options.codec === undefined ? {} : { f: options.codec.fingerprint };
|
|
460
|
+
const pingRate = Math.max(0.5, options.pingRate ?? 4);
|
|
461
|
+
const pingEvery = 1000 / pingRate;
|
|
462
|
+
// Once the clock has settled, ping on every Nth beat of the timer only.
|
|
463
|
+
const settledEvery = Math.max(1, Math.round(pingRate / Math.max(0.1, Math.min(pingRate, options.settledPingRate ?? 1))));
|
|
464
|
+
const SETTLED_SAMPLES = 8;
|
|
465
|
+
let beat = 0;
|
|
234
466
|
const timer = setInterval(() => {
|
|
235
467
|
if (!joined)
|
|
236
|
-
send({ n: label, k: 'h' });
|
|
237
|
-
|
|
468
|
+
send({ n: label, k: 'h', ...fingerprint });
|
|
469
|
+
const settled = (clock.estimate()?.samples ?? 0) >= SETTLED_SAMPLES;
|
|
470
|
+
beat += 1;
|
|
471
|
+
if (!settled || beat % settledEvery === 0)
|
|
472
|
+
send({ n: label, k: 'p', t0: now(), ...fingerprint });
|
|
238
473
|
sendInterest();
|
|
239
474
|
// Unacknowledged inputs are repeated even with nothing new to send, so
|
|
240
475
|
// a lost input frame is repaired without waiting for the next input.
|
|
241
|
-
|
|
242
|
-
|
|
476
|
+
// With a shared sequencer, only the client predicting the local entity
|
|
477
|
+
// speaks for the inputs (`speaks`, above).
|
|
478
|
+
if (speaks && inputs.pending.length > 0) {
|
|
479
|
+
sendInputs();
|
|
480
|
+
}
|
|
243
481
|
}, pingEvery);
|
|
244
482
|
void open().then(() => {
|
|
245
|
-
send({ n: label, k: 'h' });
|
|
246
|
-
send({ n: label, k: 'p', t0: now() });
|
|
483
|
+
send({ n: label, k: 'h', ...fingerprint });
|
|
484
|
+
send({ n: label, k: 'p', t0: now(), ...fingerprint });
|
|
247
485
|
sendInterest();
|
|
248
486
|
});
|
|
249
487
|
return {
|
|
250
488
|
input(data) {
|
|
251
489
|
if (closed)
|
|
252
490
|
return;
|
|
253
|
-
seq += 1;
|
|
491
|
+
inputs.seq += 1;
|
|
492
|
+
const seq = inputs.seq;
|
|
254
493
|
const seen = (hostNow() ?? now()) - delay;
|
|
255
|
-
pending.push({ seq, seen, data });
|
|
494
|
+
inputs.pending.push({ seq, seen, data });
|
|
256
495
|
if (options.local && predicted !== null) {
|
|
257
496
|
predicted = options.local.predict(predicted, data);
|
|
258
497
|
if (correcting !== null)
|
|
259
498
|
correcting = { ...correcting, from: options.local.predict(correcting.from, data) };
|
|
260
499
|
}
|
|
261
|
-
|
|
500
|
+
sendInputs();
|
|
262
501
|
},
|
|
263
502
|
view() {
|
|
264
503
|
const at = hostNow();
|
|
@@ -275,20 +514,45 @@ export function joinNetcode(options) {
|
|
|
275
514
|
if (!closed)
|
|
276
515
|
sendInterest();
|
|
277
516
|
},
|
|
517
|
+
ready() {
|
|
518
|
+
if (joined)
|
|
519
|
+
return Promise.resolve();
|
|
520
|
+
if (readyFailure !== null && closed)
|
|
521
|
+
return Promise.reject(readyFailure);
|
|
522
|
+
readyPromise ??= new Promise((resolve, reject) => {
|
|
523
|
+
readySettle = { resolve, reject };
|
|
524
|
+
readyTimer = setTimeout(() => {
|
|
525
|
+
// Failed for THIS wait; a later call waits afresh, since the
|
|
526
|
+
// client keeps trying.
|
|
527
|
+
settleReady(joinFailure());
|
|
528
|
+
readyPromise = null;
|
|
529
|
+
}, joinTimeoutMs);
|
|
530
|
+
});
|
|
531
|
+
return readyPromise;
|
|
532
|
+
},
|
|
278
533
|
hostNow,
|
|
534
|
+
sampleAt: hostTime => snapshots.sample(hostTime, blend),
|
|
535
|
+
latest: () => snapshots.latest(),
|
|
279
536
|
stats: () => ({
|
|
280
537
|
clock: clock.estimate(),
|
|
281
538
|
snapshots: snapshotsSeen,
|
|
282
539
|
lateSnapshots: snapshots.late,
|
|
283
|
-
pendingInputs: pending.length,
|
|
540
|
+
pendingInputs: inputs.pending.length,
|
|
284
541
|
corrections,
|
|
285
542
|
partialSnapshots,
|
|
543
|
+
binaryFrames,
|
|
544
|
+
unnamedEntities,
|
|
545
|
+
connectOutcome,
|
|
546
|
+
lastError,
|
|
547
|
+
epoch: snapshotEpoch,
|
|
548
|
+
lastSnapshotAt,
|
|
286
549
|
}),
|
|
287
550
|
close() {
|
|
288
551
|
if (closed)
|
|
289
552
|
return;
|
|
290
553
|
closed = true;
|
|
291
554
|
clearInterval(timer);
|
|
555
|
+
settleReady(new NetcodeError('closed', 'the netcode client was closed before it joined'));
|
|
292
556
|
stopEvents();
|
|
293
557
|
stream?.close();
|
|
294
558
|
},
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compact snapshots: a game-supplied entity schema, and the binary snapshot
|
|
3
|
+
* frame it makes possible (sharded-match plan, slice S0c).
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* const shipCodec = entityCodec({
|
|
7
|
+
* x: quantized(0, 512), // 16 bits over the arena: ~8 mm
|
|
8
|
+
* z: quantized(0, 512),
|
|
9
|
+
* yaw: angle(), // 8 bits: ~1.4°
|
|
10
|
+
* hp: uint(8),
|
|
11
|
+
* });
|
|
12
|
+
* hostNetcode({ …, codec: shipCodec });
|
|
13
|
+
* joinNetcode({ …, codec: shipCodec });
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* JSON stays the default and the fallback. A player announces its codec's
|
|
17
|
+
* fingerprint in its join and ping frames; the host sends binary snapshots
|
|
18
|
+
* only to a player whose fingerprint matches its own, and JSON to everyone
|
|
19
|
+
* else — so a mismatched or older peer degrades to JSON, never to garbage.
|
|
20
|
+
*
|
|
21
|
+
* **Ids.** On the binary wire an entity is named by a varint index, not its
|
|
22
|
+
* id string. The host assigns indexes in order of first appearance and
|
|
23
|
+
* **never reuses one** within its lifetime, so a late or reordered
|
|
24
|
+
* snapshot cannot name the wrong entity. A snapshot carries an entity's
|
|
25
|
+
* name the first time it shows that entity to a player; a player that sees
|
|
26
|
+
* an index it has no name for does not draw that entity and asks again
|
|
27
|
+
* (`u` frame) — the lossy carrier's repair, with no acknowledgements.
|
|
28
|
+
*
|
|
29
|
+
* Every binary snapshot also carries the host's **instance**, a random
|
|
30
|
+
* 32-bit number drawn when `hostNetcode` starts. A host re-created under
|
|
31
|
+
* the same label starts its indexes over; a player that sees a new instance
|
|
32
|
+
* drops every name (and every snapshot) it learnt from the old one, so the
|
|
33
|
+
* new host's indexes are never read through the old host's names.
|
|
34
|
+
*
|
|
35
|
+
* Names are also forgotten: an id no player has been shown for
|
|
36
|
+
* `NAME_FORGET_MS` (10 s) loses its index, and comes back, if it does,
|
|
37
|
+
* under a fresh one, named again. Transient ids (projectiles) do not grow
|
|
38
|
+
* the tables for the whole match.
|
|
39
|
+
*
|
|
40
|
+
* **Versions.** The fingerprint covers the binary wire's version as well as
|
|
41
|
+
* the schema. Frames carrying an instance are version 2; a peer from before
|
|
42
|
+
* it has another fingerprint for the same schema, so the pair falls back
|
|
43
|
+
* to JSON instead of refusing each other's frames.
|
|
44
|
+
*
|
|
45
|
+
* **Interpolation.** `entityCodec` also supplies an `interpolate` blend
|
|
46
|
+
* that knows its fields: an `angle()` turns the short way round across
|
|
47
|
+
* 0 / 2π. `joinNetcode` uses it when it has a codec and no `interpolate`
|
|
48
|
+
* of its own.
|
|
49
|
+
*
|
|
50
|
+
* **What the schema promises.** An entity is a flat object of the schema's
|
|
51
|
+
* fields and nothing else. Writing an entity with a field the schema does
|
|
52
|
+
* not have, or a value that is not a finite number (or boolean, for
|
|
53
|
+
* `bool()`), throws: the host then sends that player JSON for that tick
|
|
54
|
+
* (counted in `dropped`) rather than silently losing a field.
|
|
55
|
+
*/
|
|
56
|
+
/** One field of an entity schema: its bytes, and how it is written and read. */
|
|
57
|
+
export interface FieldCodec {
|
|
58
|
+
/** Bytes it takes. */
|
|
59
|
+
readonly size: number;
|
|
60
|
+
/** A stable description, part of the codec's fingerprint. */
|
|
61
|
+
readonly spec: string;
|
|
62
|
+
write(view: DataView, at: number, value: unknown): void;
|
|
63
|
+
read(view: DataView, at: number): number | boolean;
|
|
64
|
+
/** The largest error a written value may come back with (0: exact). */
|
|
65
|
+
readonly quantum: number;
|
|
66
|
+
/** Two values this field cannot tell apart once quantized (within two quantization steps). */
|
|
67
|
+
near(a: unknown, b: unknown): boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Blend two values of this field for interpolation (`alpha` in `[0, 1]`,
|
|
70
|
+
* above 1 when extrapolating). Omitted: linear. `angle()` turns the short
|
|
71
|
+
* way round.
|
|
72
|
+
*/
|
|
73
|
+
blend?(from: number, to: number, alpha: number): number;
|
|
74
|
+
}
|
|
75
|
+
/** A whole-entity codec: fixed size, and a fingerprint both ends compare. */
|
|
76
|
+
export interface EntityCodec<E> {
|
|
77
|
+
readonly size: number;
|
|
78
|
+
/** Equal on host and player iff they encode entities the same way. */
|
|
79
|
+
readonly fingerprint: string;
|
|
80
|
+
write(entity: E, view: DataView, at: number): void;
|
|
81
|
+
read(view: DataView, at: number): E;
|
|
82
|
+
/**
|
|
83
|
+
* Two states equal within what the codec can carry — how a player tells a
|
|
84
|
+
* real correction from quantization. A prediction is never exactly a
|
|
85
|
+
* quantized value, and comparing exactly would read every snapshot as a
|
|
86
|
+
* correction.
|
|
87
|
+
*/
|
|
88
|
+
near(a: E, b: E): boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Blend two states of an entity for interpolation, field by field:
|
|
91
|
+
* linear, except where a field blends its own way (an `angle()` turns the
|
|
92
|
+
* short way). `joinNetcode` uses it when given this codec and no
|
|
93
|
+
* `interpolate`. Optional: a hand-written codec may leave it out.
|
|
94
|
+
*/
|
|
95
|
+
readonly interpolate?: Interpolator<E>;
|
|
96
|
+
}
|
|
97
|
+
import { type Interpolator } from './interpolate.js';
|
|
98
|
+
type Bits = 8 | 16 | 32;
|
|
99
|
+
/**
|
|
100
|
+
* A number in `[min, max]`, quantized to `bits` (8 or 16; default 16).
|
|
101
|
+
* Values outside the range are clamped — including one a hair past an edge
|
|
102
|
+
* after rounding, which must not wrap around.
|
|
103
|
+
*/
|
|
104
|
+
export declare function quantized(min: number, max: number, bits?: 8 | 16): FieldCodec;
|
|
105
|
+
/** An angle in radians, any value, stored as a fraction of a turn in `bits` (default 8). Reads in `[0, 2π)`. */
|
|
106
|
+
export declare function angle(bits?: 8 | 16): FieldCodec;
|
|
107
|
+
/** A whole number in `[0, 2^bits)`, exact (default 8 bits). Out of range throws. */
|
|
108
|
+
export declare function uint(bits?: Bits): FieldCodec;
|
|
109
|
+
/** A whole number in `[-2^(bits-1), 2^(bits-1))`, exact (default 16 bits). Out of range throws. */
|
|
110
|
+
export declare function int(bits?: Bits): FieldCodec;
|
|
111
|
+
/** A 32-bit float: ~7 significant digits, no range to choose. A NaN or an infinity read off the wire refuses the frame. */
|
|
112
|
+
export declare function float32(): FieldCodec;
|
|
113
|
+
/** A boolean, one byte. */
|
|
114
|
+
export declare function bool(): FieldCodec;
|
|
115
|
+
/**
|
|
116
|
+
* An entity codec from a schema: one field codec per property, written in
|
|
117
|
+
* the schema's key order. The fingerprint covers names, order and specs,
|
|
118
|
+
* and the binary wire's version.
|
|
119
|
+
*/
|
|
120
|
+
export declare function entityCodec<E extends object>(fields: {
|
|
121
|
+
readonly [K in keyof E]: FieldCodec;
|
|
122
|
+
}): EntityCodec<E>;
|
|
123
|
+
/** First byte of a binary netcode frame. Never `{` (0x7b), so it cannot be JSON. */
|
|
124
|
+
export declare const BINARY_FRAME_MAGIC = 177;
|
|
125
|
+
/** The label's 32-bit FNV-1a: a binary frame's stand-in for the JSON `n`. */
|
|
126
|
+
export declare function labelHash(label: string): number;
|
|
127
|
+
/** What a binary snapshot carries; the same fields as the JSON `s` frame, plus names. */
|
|
128
|
+
export interface BinarySnapshot<E> {
|
|
129
|
+
readonly tick: number;
|
|
130
|
+
readonly t: number;
|
|
131
|
+
readonly ack: number;
|
|
132
|
+
readonly iv?: number;
|
|
133
|
+
readonly c?: number;
|
|
134
|
+
readonly of?: number;
|
|
135
|
+
/** The assignment epoch the host sent it under, if it stamps one. */
|
|
136
|
+
readonly ep?: number;
|
|
137
|
+
/** The sending host's instance (wire version 2), if the frame carries one. */
|
|
138
|
+
readonly instance?: number;
|
|
139
|
+
/** Index → id, for the indexes this frame names. */
|
|
140
|
+
readonly names: ReadonlyMap<number, string>;
|
|
141
|
+
/** Index → entity. */
|
|
142
|
+
readonly entities: ReadonlyMap<number, E>;
|
|
143
|
+
}
|
|
144
|
+
/** The binary size of one snapshot frame, without building it. */
|
|
145
|
+
export declare function binarySnapshotSize(frame: {
|
|
146
|
+
readonly tick: number;
|
|
147
|
+
readonly ack: number;
|
|
148
|
+
readonly iv?: number;
|
|
149
|
+
readonly c?: number;
|
|
150
|
+
readonly of?: number;
|
|
151
|
+
readonly ep?: number;
|
|
152
|
+
readonly instance?: number;
|
|
153
|
+
}, names: Iterable<readonly [number, Uint8Array]>, indexes: Iterable<number>, entitySize: number): number;
|
|
154
|
+
/**
|
|
155
|
+
* Build one binary snapshot frame. `names` are UTF-8 ids by index;
|
|
156
|
+
* `entities` are by index. Throws if the codec refuses an entity.
|
|
157
|
+
*/
|
|
158
|
+
export declare function encodeBinarySnapshot<E>(label: string, frame: {
|
|
159
|
+
readonly tick: number;
|
|
160
|
+
readonly t: number;
|
|
161
|
+
readonly ack: number;
|
|
162
|
+
readonly iv?: number;
|
|
163
|
+
readonly c?: number;
|
|
164
|
+
readonly of?: number;
|
|
165
|
+
readonly ep?: number;
|
|
166
|
+
/** The host's instance, a 32-bit unsigned integer: written as wire version 2. */
|
|
167
|
+
readonly instance?: number;
|
|
168
|
+
}, names: ReadonlyMap<number, Uint8Array>, entities: ReadonlyMap<number, E>, codec: EntityCodec<E>): Uint8Array;
|
|
169
|
+
/** True if `payload` is a binary netcode frame (of any label). */
|
|
170
|
+
export declare function isBinaryFrame(payload: Uint8Array | undefined): payload is Uint8Array;
|
|
171
|
+
/**
|
|
172
|
+
* Read a binary snapshot for `label`, or `null` for another label, another
|
|
173
|
+
* kind, or anything malformed (truncated, trailing bytes, bad chunk fields,
|
|
174
|
+
* a duplicate index, a name that is not UTF-8, an entity the codec will not
|
|
175
|
+
* read, such as a NaN `float32()`).
|
|
176
|
+
*/
|
|
177
|
+
export declare function decodeBinarySnapshot<E>(payload: Uint8Array, label: string, codec: EntityCodec<E>): BinarySnapshot<E> | null;
|
|
178
|
+
/** UTF-8 of an id, for {@link encodeBinarySnapshot}'s names. */
|
|
179
|
+
export declare function nameBytes(id: string): Uint8Array;
|
|
180
|
+
export {};
|