@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.
@@ -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 = interestVersion > 0 ? { iv: interestVersion } : {};
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(encodeFrame(frame))).catch(retire);
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 (!Number.isInteger(input.seq) || input.seq <= player.lastApplied)
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
- const frames = snapshotFrames(options.label, tick, time, player.lastApplied, view, maxFrameBytes, player.interestVersion);
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
  }
@@ -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 { chunkOf, decodeFrame, defaultNow, encodeFrame, eventPeer, MAX_NETCODE_INTEREST_KEY_LENGTH, MAX_NETCODE_INTEREST_KEYS, MAX_SNAPSHOT_CHUNKS, peerHex, } from './wire.js';
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';
@@ -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 { chunkOf, decodeFrame, defaultNow, encodeFrame, eventPeer, MAX_NETCODE_INTEREST_KEY_LENGTH, MAX_NETCODE_INTEREST_KEYS, MAX_SNAPSHOT_CHUNKS, peerHex, } from './wire.js';
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>;