@net-mesh/browser 0.43.0 → 0.44.1
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 +42 -44
- 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/host.js
CHANGED
|
@@ -16,7 +16,18 @@
|
|
|
16
16
|
* });
|
|
17
17
|
* ```
|
|
18
18
|
*/
|
|
19
|
-
import { MAX_SNAPSHOT_CHUNKS, chunkOf, defaultNow, decodeFrame, encodeFrame, eventPeer, } from './wire.js';
|
|
19
|
+
import { MAX_SNAPSHOT_CHUNKS, NAME_FORGET_MS, chunkOf, defaultNow, decodeFrame, encodeFrame, eventPeer, } from './wire.js';
|
|
20
|
+
import { encodeBinarySnapshot, nameBytes } from './codec.js';
|
|
21
|
+
/** Departed players whose boundary is kept, at most (the oldest is forgotten first). */
|
|
22
|
+
const MAX_PARKED = 4096;
|
|
23
|
+
/**
|
|
24
|
+
* A name request (`u`) for an index named less than this long ago is not
|
|
25
|
+
* honoured: a player asks again on its next snapshot anyway, and a player
|
|
26
|
+
* spamming requests cannot make every tick carry every name.
|
|
27
|
+
*/
|
|
28
|
+
const NAME_RESEND_MS = 100;
|
|
29
|
+
/** An input seq the host takes: a positive safe integer (seqs count from 1). */
|
|
30
|
+
const isSeq = (seq) => Number.isSafeInteger(seq) && seq > 0;
|
|
20
31
|
/** History kept: enough ticks to cover the rewind cap with room. */
|
|
21
32
|
function historySize(tickRate, maxRewindMs) {
|
|
22
33
|
return Math.max(8, Math.ceil((maxRewindMs / 1000) * tickRate) + 4);
|
|
@@ -27,10 +38,13 @@ function historySize(tickRate, maxRewindMs) {
|
|
|
27
38
|
* {@link MAX_SNAPSHOT_CHUNKS}; past that the chunks are sent over-size and
|
|
28
39
|
* ride as fragments).
|
|
29
40
|
*/
|
|
30
|
-
export function snapshotFrames(label, tick, time, ack, entities, maxBytes, interestVersion = 0) {
|
|
41
|
+
export function snapshotFrames(label, tick, time, ack, entities, maxBytes, interestVersion = 0, epoch) {
|
|
31
42
|
// The interest version the view was filtered by, echoed so the player
|
|
32
|
-
// stops repeating its `w` frame.
|
|
33
|
-
const iv =
|
|
43
|
+
// stops repeating its `w` frame; and the epoch, when the host has one.
|
|
44
|
+
const iv = {
|
|
45
|
+
...(interestVersion > 0 ? { iv: interestVersion } : {}),
|
|
46
|
+
...(epoch === undefined ? {} : { ep: epoch }),
|
|
47
|
+
};
|
|
34
48
|
const whole = { n: label, k: 's', tick, t: time, ack, e: entities, ...iv };
|
|
35
49
|
const size = encodeFrame(whole).length;
|
|
36
50
|
if (size <= maxBytes)
|
|
@@ -59,6 +73,17 @@ export function hostNetcode(options) {
|
|
|
59
73
|
const maxFrameBytes = Math.max(256, options.maxFrameBytes ?? 8000);
|
|
60
74
|
const playerTimeoutMs = options.playerTimeoutMs ?? 5000;
|
|
61
75
|
const players = new Map();
|
|
76
|
+
// Boundaries of players without a record: timed out, or adopted before
|
|
77
|
+
// `authorize` admits them. Restored when the player is next admitted.
|
|
78
|
+
const parked = new Map();
|
|
79
|
+
const park = (peer, state) => {
|
|
80
|
+
parked.delete(peer);
|
|
81
|
+
if (!(state.lastApplied > 0 || state.released || state.resuming))
|
|
82
|
+
return;
|
|
83
|
+
parked.set(peer, state);
|
|
84
|
+
if (parked.size > MAX_PARKED)
|
|
85
|
+
parked.delete(parked.keys().next().value);
|
|
86
|
+
};
|
|
62
87
|
const history = [];
|
|
63
88
|
const keep = historySize(tickRate, maxRewindMs);
|
|
64
89
|
const dropped = {};
|
|
@@ -69,7 +94,8 @@ export function hostNetcode(options) {
|
|
|
69
94
|
let lastError = null;
|
|
70
95
|
let lastTickAt = now();
|
|
71
96
|
let closed = false;
|
|
72
|
-
const send = (peer, player, frame) =>
|
|
97
|
+
const send = (peer, player, frame) => sendBytes(peer, player, encodeFrame(frame));
|
|
98
|
+
const sendBytes = (peer, player, payload) => {
|
|
73
99
|
if (player.stream === null) {
|
|
74
100
|
if (!player.opening) {
|
|
75
101
|
player.opening = true;
|
|
@@ -111,7 +137,7 @@ export function hostNetcode(options) {
|
|
|
111
137
|
}
|
|
112
138
|
};
|
|
113
139
|
try {
|
|
114
|
-
void Promise.resolve(stream.send(
|
|
140
|
+
void Promise.resolve(stream.send(payload)).catch(retire);
|
|
115
141
|
}
|
|
116
142
|
catch (error) {
|
|
117
143
|
retire(error);
|
|
@@ -125,18 +151,124 @@ export function hostNetcode(options) {
|
|
|
125
151
|
drop('unauthorized');
|
|
126
152
|
return null;
|
|
127
153
|
}
|
|
154
|
+
const restored = parked.get(peer);
|
|
155
|
+
parked.delete(peer);
|
|
128
156
|
const player = {
|
|
129
157
|
stream: null,
|
|
130
158
|
opening: false,
|
|
131
|
-
lastApplied: 0,
|
|
159
|
+
lastApplied: restored?.lastApplied ?? 0,
|
|
132
160
|
pending: new Map(),
|
|
133
161
|
lastHeard: now(),
|
|
134
162
|
interest: null,
|
|
135
163
|
interestVersion: 0,
|
|
164
|
+
binary: false,
|
|
165
|
+
named: new Map(),
|
|
166
|
+
requested: new Set(),
|
|
167
|
+
released: restored?.released ?? false,
|
|
168
|
+
resuming: restored?.resuming ?? false,
|
|
136
169
|
};
|
|
137
170
|
players.set(peer, player);
|
|
138
171
|
return player;
|
|
139
172
|
};
|
|
173
|
+
// Binary ids: an index per entity id, in order of first appearance, never
|
|
174
|
+
// reused while this host runs — so a stale snapshot cannot name the wrong
|
|
175
|
+
// entity. An id no player has been shown for NAME_FORGET_MS is forgotten,
|
|
176
|
+
// and gets a fresh index if it comes back. Every frame carries this
|
|
177
|
+
// host's instance, so a player tells a re-created host's indexes from
|
|
178
|
+
// this one's.
|
|
179
|
+
const codec = options.codec;
|
|
180
|
+
const instance = Math.floor(Math.random() * 0x100000000) >>> 0;
|
|
181
|
+
const indexOf = new Map();
|
|
182
|
+
const names = new Map();
|
|
183
|
+
let nextIndex = 0;
|
|
184
|
+
const indexFor = (id, time) => {
|
|
185
|
+
let index = indexOf.get(id);
|
|
186
|
+
if (index === undefined) {
|
|
187
|
+
index = nextIndex;
|
|
188
|
+
nextIndex += 1;
|
|
189
|
+
indexOf.set(id, index);
|
|
190
|
+
names.set(index, { id, name: nameBytes(id), used: time });
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
names.get(index).used = time;
|
|
194
|
+
}
|
|
195
|
+
return index;
|
|
196
|
+
};
|
|
197
|
+
// Forget names gone from every view for NAME_FORGET_MS, about once a second.
|
|
198
|
+
let lastForget = now();
|
|
199
|
+
const forgetNames = (time) => {
|
|
200
|
+
if (time - lastForget < 1000)
|
|
201
|
+
return;
|
|
202
|
+
lastForget = time;
|
|
203
|
+
for (const [index, entry] of names) {
|
|
204
|
+
if (time - entry.used > NAME_FORGET_MS) {
|
|
205
|
+
names.delete(index);
|
|
206
|
+
indexOf.delete(entry.id);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
for (const player of players.values()) {
|
|
210
|
+
for (const [index, sent] of player.named) {
|
|
211
|
+
if (time - sent.shown > NAME_FORGET_MS || !names.has(index))
|
|
212
|
+
player.named.delete(index);
|
|
213
|
+
}
|
|
214
|
+
for (const index of player.requested)
|
|
215
|
+
if (!player.named.has(index))
|
|
216
|
+
player.requested.delete(index);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
const noteFingerprint = (player, fingerprint) => {
|
|
220
|
+
if (codec !== undefined)
|
|
221
|
+
player.binary = fingerprint === codec.fingerprint;
|
|
222
|
+
};
|
|
223
|
+
/** One player's view as binary frames, chunked to fit; names it needs included. */
|
|
224
|
+
const binaryFrames = (player, tick, time, view, epoch) => {
|
|
225
|
+
const header = {
|
|
226
|
+
tick,
|
|
227
|
+
t: time,
|
|
228
|
+
ack: player.lastApplied,
|
|
229
|
+
instance,
|
|
230
|
+
...(player.interestVersion > 0 ? { iv: player.interestVersion } : {}),
|
|
231
|
+
...(epoch === undefined ? {} : { ep: epoch }),
|
|
232
|
+
};
|
|
233
|
+
const ids = Object.keys(view);
|
|
234
|
+
const named = (index) => player.named.has(index) && !player.requested.has(index);
|
|
235
|
+
const build = (of) => {
|
|
236
|
+
const parts = Array.from({ length: of }, () => ({ names: new Map(), entities: new Map() }));
|
|
237
|
+
for (const id of ids) {
|
|
238
|
+
const index = indexFor(id, time);
|
|
239
|
+
const part = parts[of === 1 ? 0 : chunkOf(id, of)];
|
|
240
|
+
part.entities.set(index, view[id]);
|
|
241
|
+
if (!named(index))
|
|
242
|
+
part.names.set(index, names.get(index).name);
|
|
243
|
+
}
|
|
244
|
+
return parts.map((part, c) => encodeBinarySnapshot(options.label, of === 1 ? header : { ...header, c, of }, part.names, part.entities, codec));
|
|
245
|
+
};
|
|
246
|
+
let frames = build(1);
|
|
247
|
+
if (frames[0].length > maxFrameBytes) {
|
|
248
|
+
let of = Math.min(MAX_SNAPSHOT_CHUNKS, Math.max(2, Math.ceil(frames[0].length / (maxFrameBytes * 0.75))));
|
|
249
|
+
for (;;) {
|
|
250
|
+
frames = build(of);
|
|
251
|
+
if (of >= MAX_SNAPSHOT_CHUNKS || frames.every(frame => frame.length <= maxFrameBytes))
|
|
252
|
+
break;
|
|
253
|
+
of = Math.min(MAX_SNAPSHOT_CHUNKS, of * 2);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// Built: their names count as sent. A lost one is asked for again.
|
|
257
|
+
for (const id of ids) {
|
|
258
|
+
const index = indexOf.get(id);
|
|
259
|
+
const sent = player.named.get(index);
|
|
260
|
+
if (sent === undefined) {
|
|
261
|
+
player.named.set(index, { shown: time, namedAt: time });
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
if (player.requested.has(index))
|
|
265
|
+
sent.namedAt = time;
|
|
266
|
+
sent.shown = time;
|
|
267
|
+
}
|
|
268
|
+
player.requested.delete(index);
|
|
269
|
+
}
|
|
270
|
+
return frames;
|
|
271
|
+
};
|
|
140
272
|
const stopEvents = options.transport.onEvent(event => {
|
|
141
273
|
if (closed || event.type !== 'stream_data')
|
|
142
274
|
return;
|
|
@@ -155,10 +287,29 @@ export function hostNetcode(options) {
|
|
|
155
287
|
player.lastHeard = t1;
|
|
156
288
|
switch (frame.k) {
|
|
157
289
|
case 'h':
|
|
290
|
+
noteFingerprint(player, frame.f);
|
|
158
291
|
return;
|
|
159
292
|
case 'p':
|
|
293
|
+
noteFingerprint(player, frame.f);
|
|
160
294
|
send(peer, player, { n: options.label, k: 'q', t0: frame.t0, t1, t2: now() });
|
|
161
295
|
return;
|
|
296
|
+
case 'u': {
|
|
297
|
+
// Only indexes this player was shown, and not named again just
|
|
298
|
+
// now; anything else is noise or spam.
|
|
299
|
+
let throttled = false;
|
|
300
|
+
for (const index of frame.u) {
|
|
301
|
+
const sent = player.named.get(index);
|
|
302
|
+
if (sent === undefined)
|
|
303
|
+
continue;
|
|
304
|
+
if (t1 - sent.namedAt < NAME_RESEND_MS)
|
|
305
|
+
throttled = true;
|
|
306
|
+
else
|
|
307
|
+
player.requested.add(index);
|
|
308
|
+
}
|
|
309
|
+
if (throttled)
|
|
310
|
+
drop('name-request-throttled');
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
162
313
|
case 'w':
|
|
163
314
|
// The lossy carrier reorders and repeats: only a newer version counts.
|
|
164
315
|
if (frame.v > player.interestVersion) {
|
|
@@ -167,8 +318,26 @@ export function hostNetcode(options) {
|
|
|
167
318
|
}
|
|
168
319
|
return;
|
|
169
320
|
case 'i':
|
|
321
|
+
if (options.epoch !== undefined && frame.ep !== undefined && frame.ep !== options.epoch()) {
|
|
322
|
+
drop('stale-epoch');
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (player.released) {
|
|
326
|
+
drop('released');
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (player.resuming) {
|
|
330
|
+
player.resuming = false;
|
|
331
|
+
let newest = player.lastApplied;
|
|
332
|
+
for (const input of frame.i)
|
|
333
|
+
if (isSeq(input.seq))
|
|
334
|
+
newest = Math.max(newest, input.seq);
|
|
335
|
+
player.lastApplied = newest;
|
|
336
|
+
dropped['resumed'] = (dropped['resumed'] ?? 0) + frame.i.length;
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
170
339
|
for (const input of frame.i) {
|
|
171
|
-
if (!
|
|
340
|
+
if (!isSeq(input.seq) || input.seq <= player.lastApplied)
|
|
172
341
|
continue;
|
|
173
342
|
if (!player.pending.has(input.seq)) {
|
|
174
343
|
player.pending.set(input.seq, { seq: input.seq, seen: Number(input.seen) || 0, data: input.data });
|
|
@@ -185,12 +354,30 @@ export function hostNetcode(options) {
|
|
|
185
354
|
const time = now();
|
|
186
355
|
const dtMs = tick === 0 ? tickMs : time - lastTickAt;
|
|
187
356
|
lastTickAt = time;
|
|
357
|
+
if (options.live !== undefined && !options.live()) {
|
|
358
|
+
for (const player of players.values()) {
|
|
359
|
+
if (player.pending.size > 0) {
|
|
360
|
+
dropped['lease-lost'] = (dropped['lease-lost'] ?? 0) + player.pending.size;
|
|
361
|
+
// Applied as if seen, so a repeat of these inputs is not applied later either.
|
|
362
|
+
for (const seq of player.pending.keys())
|
|
363
|
+
if (seq > player.lastApplied)
|
|
364
|
+
player.lastApplied = seq;
|
|
365
|
+
player.pending.clear();
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
188
370
|
for (const [peer, player] of players) {
|
|
189
371
|
if (time - player.lastHeard > playerTimeoutMs) {
|
|
190
372
|
player.stream?.close();
|
|
191
373
|
players.delete(peer);
|
|
374
|
+
// The boundary outlives the record: a player that comes back (or an
|
|
375
|
+
// adoption still waiting for its first frame) carries on from it.
|
|
376
|
+
park(peer, { lastApplied: player.lastApplied, released: player.released, resuming: player.resuming });
|
|
192
377
|
}
|
|
193
378
|
}
|
|
379
|
+
if (codec !== undefined)
|
|
380
|
+
forgetNames(time);
|
|
194
381
|
const inputs = new Map();
|
|
195
382
|
for (const [peer, player] of players) {
|
|
196
383
|
if (player.pending.size === 0)
|
|
@@ -204,6 +391,7 @@ export function hostNetcode(options) {
|
|
|
204
391
|
}
|
|
205
392
|
options.step({ tick, time, dtMs, inputs });
|
|
206
393
|
const entities = options.snapshot(tick);
|
|
394
|
+
const epoch = options.epoch?.();
|
|
207
395
|
history.push({ tick, time, entities });
|
|
208
396
|
if (history.length > keep)
|
|
209
397
|
history.shift();
|
|
@@ -230,7 +418,24 @@ export function hostNetcode(options) {
|
|
|
230
418
|
}
|
|
231
419
|
view = filtered;
|
|
232
420
|
}
|
|
233
|
-
|
|
421
|
+
if (codec !== undefined && player.binary) {
|
|
422
|
+
let binary = null;
|
|
423
|
+
try {
|
|
424
|
+
binary = binaryFrames(player, tick, time, view, epoch);
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
// The codec refused an entity: this player gets JSON this tick,
|
|
428
|
+
// not a snapshot missing a field.
|
|
429
|
+
lastError = String(error?.message ?? error);
|
|
430
|
+
drop('codec-write-failed');
|
|
431
|
+
}
|
|
432
|
+
if (binary !== null) {
|
|
433
|
+
for (const payload of binary)
|
|
434
|
+
sendBytes(peer, player, payload);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const frames = snapshotFrames(options.label, tick, time, player.lastApplied, view, maxFrameBytes, player.interestVersion, epoch);
|
|
234
439
|
for (const frame of frames)
|
|
235
440
|
send(peer, player, frame);
|
|
236
441
|
}
|
|
@@ -239,6 +444,45 @@ export function hostNetcode(options) {
|
|
|
239
444
|
const timer = options.autoTick === false ? null : setInterval(runTick, tickMs);
|
|
240
445
|
return {
|
|
241
446
|
players: () => [...players.keys()],
|
|
447
|
+
releasePlayer(peer) {
|
|
448
|
+
const player = players.get(peer);
|
|
449
|
+
if (player === undefined) {
|
|
450
|
+
// Timed out: its boundary was kept.
|
|
451
|
+
const kept = parked.get(peer);
|
|
452
|
+
if (kept === undefined)
|
|
453
|
+
return { lastApplied: 0 };
|
|
454
|
+
kept.released = true;
|
|
455
|
+
kept.resuming = false;
|
|
456
|
+
return { lastApplied: kept.lastApplied };
|
|
457
|
+
}
|
|
458
|
+
// Received but not yet applied: not in the frozen state, so not
|
|
459
|
+
// acknowledged — the player still holds them, for the new owner.
|
|
460
|
+
player.pending.clear();
|
|
461
|
+
player.released = true;
|
|
462
|
+
return { lastApplied: player.lastApplied };
|
|
463
|
+
},
|
|
464
|
+
adoptPlayer(peer, from) {
|
|
465
|
+
const player = playerFor(peer);
|
|
466
|
+
if (player === null) {
|
|
467
|
+
// Not admitted now: keep the boundary for when it is.
|
|
468
|
+
drop('adopt-unauthorized');
|
|
469
|
+
const kept = parked.get(peer);
|
|
470
|
+
park(peer, 'resume' in from
|
|
471
|
+
? { lastApplied: kept?.lastApplied ?? 0, released: false, resuming: true }
|
|
472
|
+
: { lastApplied: Math.max(kept?.lastApplied ?? 0, from.lastApplied), released: false, resuming: false });
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
player.released = false;
|
|
476
|
+
if ('resume' in from) {
|
|
477
|
+
player.resuming = true;
|
|
478
|
+
player.pending.clear();
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
player.lastApplied = Math.max(player.lastApplied, from.lastApplied);
|
|
482
|
+
for (const seq of [...player.pending.keys()])
|
|
483
|
+
if (seq <= player.lastApplied)
|
|
484
|
+
player.pending.delete(seq);
|
|
485
|
+
},
|
|
242
486
|
get currentTick() {
|
|
243
487
|
return tick;
|
|
244
488
|
},
|
|
@@ -271,6 +515,7 @@ export function hostNetcode(options) {
|
|
|
271
515
|
for (const player of players.values())
|
|
272
516
|
player.stream?.close();
|
|
273
517
|
players.clear();
|
|
518
|
+
parked.clear();
|
|
274
519
|
},
|
|
275
520
|
};
|
|
276
521
|
}
|
package/dist/netcode/index.d.ts
CHANGED
|
@@ -10,13 +10,23 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export { hostNetcode } from './host.js';
|
|
12
12
|
export type { HostNetcode, HostNetcodeOptions, Rewound, TickContext, TickInput } from './host.js';
|
|
13
|
-
export { joinNetcode } from './client.js';
|
|
14
|
-
export type { JoinNetcodeOptions, LocalEntity, NetcodeClient, NetcodeStats } from './client.js';
|
|
13
|
+
export { NetcodeError, createInputSequencer, joinNetcode } from './client.js';
|
|
14
|
+
export type { InputSequencer, JoinNetcodeOptions, LocalEntity, NetcodeClient, NetcodeErrorKind, NetcodeStats } from './client.js';
|
|
15
|
+
export { joinShards } from './shards.js';
|
|
16
|
+
export { MAX_PATH_POINTS, npcModes, pathAction, pathAt, pathProblems } from './paths.js';
|
|
17
|
+
export type { ModedEntity, NpcModes, NpcModesOptions, NpcPath, PathPoint } from './paths.js';
|
|
18
|
+
export { assignAction, assignmentProblems, authorizeTeams, leaseWatch, mayReassign, npcShard, renewLeases, shardAssignment, teamOf, withNpcs, } from './assignment.js';
|
|
19
|
+
export type { LeaseWatch, LeaseWatchOptions, MatchAssignment, ShardRecord, Squad } from './assignment.js';
|
|
20
|
+
export type { JoinShardsOptions, ShardAssignment, ShardStats, ShardedMatch, ShardedMatchStats } from './shards.js';
|
|
15
21
|
export { ClockEstimator, CLOCK_WINDOW } from './clock.js';
|
|
16
22
|
export type { ClockEstimate, Millis } from './clock.js';
|
|
17
|
-
export { SnapshotBuffer, lerpNumbers, SNAPSHOT_BUFFER } from './interpolate.js';
|
|
23
|
+
export { SnapshotBuffer, lerpAngle, lerpNumbers, SNAPSHOT_BUFFER } from './interpolate.js';
|
|
18
24
|
export type { Interpolator, TimedSnapshot } from './interpolate.js';
|
|
19
25
|
export type { NetcodeStream, NetcodeTransport } from './wire.js';
|
|
26
|
+
export { angle, bool, entityCodec, float32, int, quantized, uint } from './codec.js';
|
|
27
|
+
export type { EntityCodec, FieldCodec } from './codec.js';
|
|
20
28
|
export { snapshotFrames, } from './host.js';
|
|
21
|
-
export {
|
|
29
|
+
export { BINARY_FRAME_MAGIC, decodeBinarySnapshot, encodeBinarySnapshot, isBinaryFrame, labelHash, } from './codec.js';
|
|
30
|
+
export type { BinarySnapshot } from './codec.js';
|
|
31
|
+
export { MAX_NAME_REQUESTS, chunkOf, decodeFrame, defaultNow, encodeFrame, eventPeer, MAX_NETCODE_INTEREST_KEY_LENGTH, MAX_NETCODE_INTEREST_KEYS, MAX_SNAPSHOT_CHUNKS, peerHex, } from './wire.js';
|
|
22
32
|
export type { Frame, Now, WireInput, } from './wire.js';
|
package/dist/netcode/index.js
CHANGED
|
@@ -9,10 +9,15 @@
|
|
|
9
9
|
* transforms that are superseded many times a second.
|
|
10
10
|
*/
|
|
11
11
|
export { hostNetcode } from './host.js';
|
|
12
|
-
export { joinNetcode } from './client.js';
|
|
12
|
+
export { NetcodeError, createInputSequencer, joinNetcode } from './client.js';
|
|
13
|
+
export { joinShards } from './shards.js';
|
|
14
|
+
export { MAX_PATH_POINTS, npcModes, pathAction, pathAt, pathProblems } from './paths.js';
|
|
15
|
+
export { assignAction, assignmentProblems, authorizeTeams, leaseWatch, mayReassign, npcShard, renewLeases, shardAssignment, teamOf, withNpcs, } from './assignment.js';
|
|
13
16
|
export { ClockEstimator, CLOCK_WINDOW } from './clock.js';
|
|
14
|
-
export { SnapshotBuffer, lerpNumbers, SNAPSHOT_BUFFER } from './interpolate.js';
|
|
17
|
+
export { SnapshotBuffer, lerpAngle, lerpNumbers, SNAPSHOT_BUFFER } from './interpolate.js';
|
|
18
|
+
export { angle, bool, entityCodec, float32, int, quantized, uint } from './codec.js';
|
|
15
19
|
// The netcode wire format and its helpers, exported so callers can
|
|
16
20
|
// build their own transports and tooling on the same frames.
|
|
17
21
|
export { snapshotFrames, } from './host.js';
|
|
18
|
-
export {
|
|
22
|
+
export { BINARY_FRAME_MAGIC, decodeBinarySnapshot, encodeBinarySnapshot, isBinaryFrame, labelHash, } from './codec.js';
|
|
23
|
+
export { MAX_NAME_REQUESTS, chunkOf, decodeFrame, defaultNow, encodeFrame, eventPeer, MAX_NETCODE_INTEREST_KEY_LENGTH, MAX_NETCODE_INTEREST_KEYS, MAX_SNAPSHOT_CHUNKS, peerHex, } from './wire.js';
|
|
@@ -20,9 +20,17 @@ export type Interpolator<E> = (from: E, to: E, alpha: number) => E;
|
|
|
20
20
|
* The default blend: every numeric field is linearly interpolated, every
|
|
21
21
|
* other field is taken from `to`. Nested objects are blended the same way,
|
|
22
22
|
* one level at a time. Angles are not special-cased: a game with wrapping
|
|
23
|
-
* angles supplies its own
|
|
23
|
+
* angles supplies its own (built on {@link lerpAngle}), or uses a codec,
|
|
24
|
+
* whose `interpolate` turns its `angle()` fields the short way.
|
|
24
25
|
*/
|
|
25
26
|
export declare function lerpNumbers<E>(from: E, to: E, alpha: number): E;
|
|
27
|
+
/**
|
|
28
|
+
* Blend two angles in radians the short way round: from 350° to 10° passes
|
|
29
|
+
* 0°, not 180°. `alpha` 1 gives `to` exactly, and above 1 carries on along
|
|
30
|
+
* the same short arc (extrapolation). The result is not normalized: it is
|
|
31
|
+
* within half a turn of `to`, on `to`'s side of any wrap.
|
|
32
|
+
*/
|
|
33
|
+
export declare function lerpAngle(from: number, to: number, alpha: number): number;
|
|
26
34
|
/** Snapshots kept, at most. */
|
|
27
35
|
export declare const SNAPSHOT_BUFFER = 64;
|
|
28
36
|
/** A time-ordered buffer of snapshots with interpolated reads. */
|
|
@@ -38,6 +46,21 @@ export declare class SnapshotBuffer<E> {
|
|
|
38
46
|
get late(): number;
|
|
39
47
|
/** The newest snapshot, or `null`. */
|
|
40
48
|
latest(): TimedSnapshot<E> | null;
|
|
49
|
+
/**
|
|
50
|
+
* History, not rendering: the entities as of host time `time`, from the
|
|
51
|
+
* two snapshots that bracket it, and how far apart those two were. `null`
|
|
52
|
+
* when no two held snapshots bracket `time` — before the oldest, after
|
|
53
|
+
* the newest — so a caller checking a claim against history can tell "no
|
|
54
|
+
* history" from "this is where it was". Never extrapolates.
|
|
55
|
+
*
|
|
56
|
+
* `gapMs` is the evidence's resolution: one snapshot interval normally,
|
|
57
|
+
* more where snapshots were lost. A caller sets its tolerance from it, or
|
|
58
|
+
* refuses a gap too wide to judge.
|
|
59
|
+
*/
|
|
60
|
+
sample(time: number, blend: Interpolator<E>): {
|
|
61
|
+
readonly entities: Readonly<Record<string, E>>;
|
|
62
|
+
readonly gapMs: number;
|
|
63
|
+
} | null;
|
|
41
64
|
/**
|
|
42
65
|
* The entities as of host time `at`: interpolated between the two
|
|
43
66
|
* snapshots around it. Before the oldest held, the oldest. Past the
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* The default blend: every numeric field is linearly interpolated, every
|
|
8
8
|
* other field is taken from `to`. Nested objects are blended the same way,
|
|
9
9
|
* one level at a time. Angles are not special-cased: a game with wrapping
|
|
10
|
-
* angles supplies its own
|
|
10
|
+
* angles supplies its own (built on {@link lerpAngle}), or uses a codec,
|
|
11
|
+
* whose `interpolate` turns its `angle()` fields the short way.
|
|
11
12
|
*/
|
|
12
13
|
export function lerpNumbers(from, to, alpha) {
|
|
13
14
|
if (typeof from === 'number' && typeof to === 'number') {
|
|
@@ -23,6 +24,18 @@ export function lerpNumbers(from, to, alpha) {
|
|
|
23
24
|
}
|
|
24
25
|
return out;
|
|
25
26
|
}
|
|
27
|
+
const TURN = Math.PI * 2;
|
|
28
|
+
/**
|
|
29
|
+
* Blend two angles in radians the short way round: from 350° to 10° passes
|
|
30
|
+
* 0°, not 180°. `alpha` 1 gives `to` exactly, and above 1 carries on along
|
|
31
|
+
* the same short arc (extrapolation). The result is not normalized: it is
|
|
32
|
+
* within half a turn of `to`, on `to`'s side of any wrap.
|
|
33
|
+
*/
|
|
34
|
+
export function lerpAngle(from, to, alpha) {
|
|
35
|
+
// The signed difference from `to` back to `from`, in [-π, π).
|
|
36
|
+
const back = ((((from - to) % TURN) + TURN + Math.PI) % TURN) - Math.PI;
|
|
37
|
+
return to + back * (1 - alpha);
|
|
38
|
+
}
|
|
26
39
|
/** Snapshots kept, at most. */
|
|
27
40
|
export const SNAPSHOT_BUFFER = 64;
|
|
28
41
|
/** A time-ordered buffer of snapshots with interpolated reads. */
|
|
@@ -55,6 +68,44 @@ export class SnapshotBuffer {
|
|
|
55
68
|
latest() {
|
|
56
69
|
return this.#snapshots.at(-1) ?? null;
|
|
57
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* History, not rendering: the entities as of host time `time`, from the
|
|
73
|
+
* two snapshots that bracket it, and how far apart those two were. `null`
|
|
74
|
+
* when no two held snapshots bracket `time` — before the oldest, after
|
|
75
|
+
* the newest — so a caller checking a claim against history can tell "no
|
|
76
|
+
* history" from "this is where it was". Never extrapolates.
|
|
77
|
+
*
|
|
78
|
+
* `gapMs` is the evidence's resolution: one snapshot interval normally,
|
|
79
|
+
* more where snapshots were lost. A caller sets its tolerance from it, or
|
|
80
|
+
* refuses a gap too wide to judge.
|
|
81
|
+
*/
|
|
82
|
+
sample(time, blend) {
|
|
83
|
+
const list = this.#snapshots;
|
|
84
|
+
if (list.length === 0 || time < list[0].time || time > list.at(-1).time)
|
|
85
|
+
return null;
|
|
86
|
+
if (list.length === 1)
|
|
87
|
+
return { entities: list[0].entities, gapMs: 0 };
|
|
88
|
+
let i = 1;
|
|
89
|
+
while (i < list.length - 1 && list[i].time < time)
|
|
90
|
+
i += 1;
|
|
91
|
+
const a = list[i - 1];
|
|
92
|
+
const b = list[i];
|
|
93
|
+
// Exactly at a snapshot: that snapshot is the evidence, whole, an
|
|
94
|
+
// entity that first appears in it included.
|
|
95
|
+
if (b.time === time)
|
|
96
|
+
return { entities: b.entities, gapMs: b.time - a.time };
|
|
97
|
+
if (a.time === time)
|
|
98
|
+
return { entities: a.entities, gapMs: b.time - a.time };
|
|
99
|
+
const alpha = (time - a.time) / (b.time - a.time);
|
|
100
|
+
const out = {};
|
|
101
|
+
for (const [id, to] of Object.entries(b.entities)) {
|
|
102
|
+
const from = a.entities[id];
|
|
103
|
+
// In only one of the two: known at one end of the gap, not across it.
|
|
104
|
+
if (from !== undefined)
|
|
105
|
+
out[id] = blend(from, to, alpha);
|
|
106
|
+
}
|
|
107
|
+
return { entities: out, gapMs: b.time - a.time };
|
|
108
|
+
}
|
|
58
109
|
/**
|
|
59
110
|
* The entities as of host time `at`: interpolated between the two
|
|
60
111
|
* snapshots around it. Before the oldest held, the oldest. Past the
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NPCs driven by paths in the store (sharded-match plan, slice N2): an NPC
|
|
3
|
+
* on a known route is published once as a path record, instead of in every
|
|
4
|
+
* snapshot, and every client evaluates the path itself.
|
|
5
|
+
*
|
|
6
|
+
* Two sources, then, for one NPC: snapshots (live, lossy, every tick while
|
|
7
|
+
* live) and the store's path record (reliable, written on a switch). They
|
|
8
|
+
* can arrive in either order, so each carries a **mode version** `mv` that
|
|
9
|
+
* the owner bumps on every switch between `'path'` and `'live'`:
|
|
10
|
+
*
|
|
11
|
+
* - the client draws from **whichever source shows the highest `mv`**;
|
|
12
|
+
* - if that says `'path'` and the record has not arrived yet, the last live
|
|
13
|
+
* motion is carried forward for at most `carryMs` (500), then held;
|
|
14
|
+
* - a switch of source blends over `smoothingMs` (100), so it never jumps;
|
|
15
|
+
* - an NPC whose highest `mv` says `'live'` but that no snapshot shows any
|
|
16
|
+
* more (dead, or out of interest) is held for `carryMs`, then no longer
|
|
17
|
+
* drawn — which is how a retired path record (`'live'`, no points)
|
|
18
|
+
* clears it.
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* const modes = npcModes<Npc>();
|
|
22
|
+
* renderLoop(() => draw(modes.resolve(match.view(), lobbyReplica.getState().paths, id => match.hostNow(ownerOf(id)))));
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* An NPC whose position is secret (fog) never uses a path: a record goes to
|
|
26
|
+
* every reader of the store. The shard host recipe refuses to put such an
|
|
27
|
+
* NPC on a path.
|
|
28
|
+
*/
|
|
29
|
+
import type { MatchAssignment } from './assignment.js';
|
|
30
|
+
/** One waypoint, in the owner host's clock. */
|
|
31
|
+
export interface PathPoint {
|
|
32
|
+
readonly t: number;
|
|
33
|
+
readonly x: number;
|
|
34
|
+
readonly z: number;
|
|
35
|
+
}
|
|
36
|
+
/** An NPC's path record, as the lobby store holds it (D8). */
|
|
37
|
+
export interface NpcPath {
|
|
38
|
+
/** The mode version: bumped by the owner on every switch. */
|
|
39
|
+
readonly mv: number;
|
|
40
|
+
readonly mode: 'path' | 'live';
|
|
41
|
+
/** The interest key the record is found under (`null`: everyone). */
|
|
42
|
+
readonly key: string | null;
|
|
43
|
+
/** Waypoints by owner host time, ascending; empty while `live`. */
|
|
44
|
+
readonly points: readonly PathPoint[];
|
|
45
|
+
}
|
|
46
|
+
/** Waypoints one record may carry. */
|
|
47
|
+
export declare const MAX_PATH_POINTS = 64;
|
|
48
|
+
/** What an NPC entity carries so a client can order its two sources. */
|
|
49
|
+
export interface ModedEntity {
|
|
50
|
+
readonly x: number;
|
|
51
|
+
readonly z: number;
|
|
52
|
+
readonly mv?: number;
|
|
53
|
+
readonly mode?: 'path' | 'live';
|
|
54
|
+
}
|
|
55
|
+
/** Where a path puts its NPC at owner host time `t`: interpolated, held before the first point and after the last. */
|
|
56
|
+
export declare function pathAt(points: readonly PathPoint[], t: number): {
|
|
57
|
+
readonly x: number;
|
|
58
|
+
readonly z: number;
|
|
59
|
+
} | null;
|
|
60
|
+
/** What is wrong with a path record; empty when it is valid. */
|
|
61
|
+
export declare function pathProblems(path: NpcPath): string[];
|
|
62
|
+
/**
|
|
63
|
+
* The lobby store's `setPath` action (D8): record `path` for `npc`. Refused
|
|
64
|
+
* (the action rejects, the state is unchanged) unless the caller hosts the
|
|
65
|
+
* shard that owns `npc` in the installed assignment, the record is valid,
|
|
66
|
+
* and its `mv` is above the one held.
|
|
67
|
+
*/
|
|
68
|
+
export declare function pathAction<S extends object>(options: {
|
|
69
|
+
/** The installed assignment, read from the state. */
|
|
70
|
+
readonly assignment: (state: S) => MatchAssignment | null | undefined;
|
|
71
|
+
/** Where the records live in the state. Default `paths`. */
|
|
72
|
+
readonly field?: string;
|
|
73
|
+
}): (input: {
|
|
74
|
+
readonly npc: string;
|
|
75
|
+
readonly path: NpcPath;
|
|
76
|
+
}, context: {
|
|
77
|
+
readonly peer: string;
|
|
78
|
+
getState(): unknown;
|
|
79
|
+
setState(update: Partial<S>): void;
|
|
80
|
+
}) => {
|
|
81
|
+
readonly mv: number;
|
|
82
|
+
};
|
|
83
|
+
/** {@link npcModes}'s argument. */
|
|
84
|
+
export interface NpcModesOptions {
|
|
85
|
+
/** How long a switch of source blends, ms. Default 100. */
|
|
86
|
+
readonly smoothingMs?: number;
|
|
87
|
+
/** How long to carry the last live motion forward while a path record is late, ms. Default 500. */
|
|
88
|
+
readonly carryMs?: number;
|
|
89
|
+
/** How far behind the owner's clock paths are drawn, ms — match `interpolationDelayMs`. Default 100. */
|
|
90
|
+
readonly interpolationDelayMs?: number;
|
|
91
|
+
readonly now?: () => number;
|
|
92
|
+
}
|
|
93
|
+
/** The running resolver. */
|
|
94
|
+
export interface NpcModes<E extends ModedEntity> {
|
|
95
|
+
/**
|
|
96
|
+
* The world to draw: `view` (from `joinShards` / `joinNetcode`) with every
|
|
97
|
+
* NPC that has a mode version resolved between its snapshot entry and its
|
|
98
|
+
* path record. `ownerNow(id)` is the owning host's clock (`hostNow`).
|
|
99
|
+
*/
|
|
100
|
+
resolve(view: Readonly<Record<string, E>>, records: Readonly<Record<string, NpcPath>> | null | undefined, ownerNow: (id: string) => number | null): Readonly<Record<string, E>>;
|
|
101
|
+
/**
|
|
102
|
+
* `carried`: times a path was due but its record had not arrived (carried
|
|
103
|
+
* forward, then held). Not counted while the record is here and only the
|
|
104
|
+
* owner's clock is unknown: that is held, not carried. `tracks`: NPCs
|
|
105
|
+
* being followed (pruned once absent from both sources for longer than
|
|
106
|
+
* `carryMs + smoothingMs`).
|
|
107
|
+
*/
|
|
108
|
+
readonly stats: () => {
|
|
109
|
+
readonly carried: number;
|
|
110
|
+
readonly switches: number;
|
|
111
|
+
readonly tracks: number;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/** Resolve NPCs between their snapshot entries and their path records. */
|
|
115
|
+
export declare function npcModes<E extends ModedEntity>(options?: NpcModesOptions): NpcModes<E>;
|