@driftengine/network 3.61.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.
Files changed (49) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +125 -0
  4. package/dist/authority.d.ts +170 -0
  5. package/dist/authority.js +235 -0
  6. package/dist/fingerprint.d.ts +64 -0
  7. package/dist/fingerprint.js +108 -0
  8. package/dist/fixture.d.ts +32 -0
  9. package/dist/fixture.js +136 -0
  10. package/dist/index.d.ts +45 -0
  11. package/dist/index.js +32 -0
  12. package/dist/inputLog.d.ts +117 -0
  13. package/dist/inputLog.js +232 -0
  14. package/dist/lockstep.d.ts +181 -0
  15. package/dist/lockstep.js +291 -0
  16. package/dist/loopback.d.ts +115 -0
  17. package/dist/loopback.js +207 -0
  18. package/dist/rewind.d.ts +206 -0
  19. package/dist/rewind.js +212 -0
  20. package/dist/scriptSession.d.ts +77 -0
  21. package/dist/scriptSession.js +81 -0
  22. package/dist/simNumber.d.ts +103 -0
  23. package/dist/simNumber.js +156 -0
  24. package/dist/snapshotter.d.ts +43 -0
  25. package/dist/snapshotter.js +81 -0
  26. package/dist/transport.d.ts +63 -0
  27. package/dist/transport.js +40 -0
  28. package/dist/webrtc.d.ts +74 -0
  29. package/dist/webrtc.js +108 -0
  30. package/dist/websocket.d.ts +70 -0
  31. package/dist/websocket.js +105 -0
  32. package/dist/wire.d.ts +94 -0
  33. package/dist/wire.js +238 -0
  34. package/package.json +61 -0
  35. package/src/authority.ts +340 -0
  36. package/src/fingerprint.ts +143 -0
  37. package/src/fixture.ts +188 -0
  38. package/src/index.ts +95 -0
  39. package/src/inputLog.ts +262 -0
  40. package/src/lockstep.ts +374 -0
  41. package/src/loopback.ts +250 -0
  42. package/src/rewind.ts +337 -0
  43. package/src/scriptSession.ts +137 -0
  44. package/src/simNumber.ts +176 -0
  45. package/src/snapshotter.ts +98 -0
  46. package/src/transport.ts +69 -0
  47. package/src/webrtc.ts +141 -0
  48. package/src/websocket.ts +140 -0
  49. package/src/wire.ts +269 -0
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Every peer simulates every tick, and a wrong guess is unwound.
3
+ *
4
+ * **Classic lockstep stalls; this predicts.** The original design waits until every participant's
5
+ * input for a tick has arrived and only then advances, which makes the whole session run at the
6
+ * latency of its worst link and turns a single dropped packet into a visible freeze for everyone.
7
+ * With a rewind loop underneath, a missing input can be guessed and corrected — so the session runs
8
+ * at local speed and pays for a wrong guess only when the guess was wrong.
9
+ *
10
+ * ---
11
+ *
12
+ * ## The input delay, and why it is not just latency tolerance
13
+ *
14
+ * A local input for tick *T* is published for tick *T + delay*. That buys `delay` ticks for it to
15
+ * reach everyone before anybody needs it, so on a link inside that budget **nobody predicts and
16
+ * nobody rewinds** — the session is exact, not merely convergent.
17
+ *
18
+ * What it costs is that a player's own control is `delay` ticks late on their own screen. That is a
19
+ * real cost and it is a game's to choose: two ticks is 33 ms and unnoticeable in a racing game,
20
+ * and unacceptable in a fighting game where it is the whole argument for rollback.
21
+ *
22
+ * ## Halting is a feature
23
+ *
24
+ * Two things stop the session rather than degrade it, because both mean the local world can no
25
+ * longer be made correct:
26
+ *
27
+ * - **An input arrives for a tick outside the rewind window.** It cannot be applied, so this peer's
28
+ * world is permanently different from the one that input describes.
29
+ * - **A fingerprint disagrees at a confirmed tick.** Two peers computed different worlds from the
30
+ * same inputs, which is a determinism bug rather than a networking one, and continuing produces
31
+ * two games that both believe they are right.
32
+ *
33
+ * A session that carried on would be showing each player a plausible world with no relationship to
34
+ * anyone else's. Halting names the tick, which is where the debugging starts.
35
+ */
36
+ import { Fingerprint } from './fingerprint.ts';
37
+ import type { InputLog } from './inputLog.ts';
38
+ import type { RewindLoop } from './rewind.ts';
39
+ import { BROADCAST, type PeerId, type Transport } from './transport.ts';
40
+ import {
41
+ MESSAGE_FINGERPRINT,
42
+ MESSAGE_INPUT,
43
+ createDecoded,
44
+ decode,
45
+ encodeFingerprint,
46
+ encodeInput,
47
+ } from './wire.ts';
48
+
49
+ export type LockstepStatus = 'running' | 'halted';
50
+
51
+ export interface LockstepOptions<S> {
52
+ readonly transport: Transport;
53
+ readonly loop: RewindLoop<S>;
54
+ readonly inputs: InputLog;
55
+ /** This peer's id, and the index its inputs occupy in the log. */
56
+ readonly self: PeerId;
57
+ readonly participants: number;
58
+ /**
59
+ * How many ticks ahead a local input is published. Two is 33 ms at 60 Hz.
60
+ *
61
+ * See the header: inside this budget the session is exact rather than convergent.
62
+ */
63
+ readonly inputDelay?: number;
64
+ /**
65
+ * How often a fingerprint is published, in ticks. Every tick is wasteful and every hundred is
66
+ * slow to notice; the default is every eight, which at 60 Hz is seven a second.
67
+ */
68
+ readonly fingerprintEvery?: number;
69
+ /** Publish fingerprints at all. Off needs no digest from the snapshotter. */
70
+ readonly compareStates?: boolean;
71
+ /**
72
+ * How many recent inputs ride along in every packet. Four by default.
73
+ *
74
+ * **This is what makes lockstep survive loss, and one is not enough.** An input is only useful
75
+ * for the tick it names, so asking for a lost one and waiting a round trip delivers it after that
76
+ * tick has gone — the peer's world is then permanently different and nothing local repairs it.
77
+ * Sending the last few in every packet covers a run of drops before anybody notices, for a
78
+ * handful of bytes on a message that already has a header.
79
+ *
80
+ * Four covers three consecutive losses. A link losing four in a row at 60 Hz has stopped being a
81
+ * link.
82
+ *
83
+ * **It may reach further back than the rewind window without any arrangement between the two.**
84
+ * The session forgets inputs below the oldest tick a rewind can reach, so past that depth every
85
+ * packet's oldest words name ticks the log can no longer say anything about. Those are skipped on
86
+ * the strength of what this world already applied, not on what the log still holds — see the
87
+ * watermark in `receive`. Choose this number for the link and the rewind depth for the game.
88
+ */
89
+ readonly redundancy?: number;
90
+ }
91
+
92
+ export interface Desync {
93
+ readonly tick: number;
94
+ readonly peer: PeerId;
95
+ readonly ours: string;
96
+ readonly theirs: string;
97
+ }
98
+
99
+ export class LockstepSession<S> {
100
+ readonly self: PeerId;
101
+ readonly participants: number;
102
+ readonly inputDelay: number;
103
+
104
+ private readonly transport: Transport;
105
+ private readonly loop: RewindLoop<S>;
106
+ private readonly inputs: InputLog;
107
+ private readonly fingerprintEvery: number;
108
+ private readonly compareStates: boolean;
109
+
110
+ /** One buffer for the process. Sized for the largest message this session sends. */
111
+ private readonly outgoing: Uint8Array;
112
+ /** The last `redundancy` inputs this peer submitted, oldest first, flat. */
113
+ private readonly recent: Uint8Array;
114
+ private readonly redundancy: number;
115
+ /** The tick the first payload in `recent` belongs to, or -1 before anything is submitted. */
116
+ private recentFrom = -1;
117
+ private recentCount = 0;
118
+ private readonly decoded = createDecoded();
119
+ private readonly scratchDigest = new Fingerprint();
120
+
121
+ /** Digests we published, by tick, so a peer's claim has something to be compared against. */
122
+ private readonly ourDigests = new Map<number, string>();
123
+
124
+ private statusValue: LockstepStatus = 'running';
125
+ private desyncValue: Desync | null = null;
126
+ private haltReason = '';
127
+ private confirmedValue = -1;
128
+ /**
129
+ * The highest tick this world has ever had every participant's real input for.
130
+ *
131
+ * **Separate from `confirmedValue`, and monotonic where that one is not.** `confirmedThrough`
132
+ * answers for the window the log currently holds, and the session moves that window forward every
133
+ * tick — so the moment retention passes a tick, the log stops being able to say anything about it
134
+ * and the live watermark can drop. What was applied stays applied, which is the fact a redundant
135
+ * copy has to be judged against, so it is kept here rather than asked of the log.
136
+ */
137
+ private appliedThrough = -1;
138
+
139
+ constructor(options: LockstepOptions<S>) {
140
+ this.transport = options.transport;
141
+ this.loop = options.loop;
142
+ this.inputs = options.inputs;
143
+ this.self = options.self;
144
+ this.participants = Math.max(1, options.participants);
145
+ this.inputDelay = Math.max(0, Math.trunc(options.inputDelay ?? 2));
146
+ this.fingerprintEvery = Math.max(1, Math.trunc(options.fingerprintEvery ?? 8));
147
+ this.compareStates = options.compareStates ?? true;
148
+ this.redundancy = Math.max(1, Math.trunc(options.redundancy ?? 4));
149
+ this.recent = new Uint8Array(this.redundancy * this.inputs.inputBytes);
150
+ this.outgoing = new Uint8Array(64 + this.redundancy * this.inputs.inputBytes);
151
+ }
152
+
153
+ get status(): LockstepStatus {
154
+ return this.statusValue;
155
+ }
156
+
157
+ /** Why the session halted, or an empty string. */
158
+ get reason(): string {
159
+ return this.haltReason;
160
+ }
161
+
162
+ /** The disagreement that halted it, naming the tick it began at. */
163
+ get desync(): Desync | null {
164
+ return this.desyncValue;
165
+ }
166
+
167
+ /** The highest tick every participant's real input has arrived for. */
168
+ get confirmed(): number {
169
+ return this.confirmedValue;
170
+ }
171
+
172
+ /**
173
+ * Publish this peer's input for `tick + inputDelay`, and record it locally at the same tick.
174
+ *
175
+ * **Recorded locally as well as sent, and that is not redundancy.** A peer's own input is not
176
+ * echoed back to it, so without this the local simulation would predict its own controls — and a
177
+ * player would feel their own input arrive late and get corrected, which is the one thing
178
+ * prediction is supposed to prevent.
179
+ */
180
+ submit(tick: number, payload: Uint8Array): void {
181
+ if (this.statusValue !== 'running') return;
182
+ const at = tick + this.inputDelay;
183
+
184
+ if (!this.inputs.holds(at)) {
185
+ this.halt(`an input for tick ${at} is outside the log's window`);
186
+ return;
187
+ }
188
+ this.inputs.set(this.self, at, payload);
189
+ this.remember(at, payload);
190
+
191
+ const length = encodeInput(
192
+ this.outgoing,
193
+ this.self,
194
+ this.recentFrom,
195
+ this.recent,
196
+ this.inputs.inputBytes,
197
+ this.recentCount,
198
+ );
199
+ if (length < 0) {
200
+ this.halt(`an input of ${payload.length} bytes does not fit this session's buffer`);
201
+ return;
202
+ }
203
+ this.transport.send(BROADCAST, this.outgoing.subarray(0, length));
204
+ }
205
+
206
+ /**
207
+ * Keep this input, and the few before it, for the next packet to carry.
208
+ *
209
+ * Shifted rather than kept in a ring, because the run has to be **contiguous and in order** on
210
+ * the wire: a receiver reads it as "these are the inputs for ticks `firstTick` onward", and a ring
211
+ * would have to be unwrapped before sending anyway. `redundancy` is four, so the shift is three
212
+ * copies of a handful of bytes.
213
+ */
214
+ private remember(tick: number, payload: Uint8Array): void {
215
+ const stride = this.inputs.inputBytes;
216
+
217
+ /* A gap — a tick submitted out of order, or the first one — restarts the run. */
218
+ if (this.recentFrom < 0 || tick !== this.recentFrom + this.recentCount) {
219
+ this.recentFrom = tick;
220
+ this.recentCount = 0;
221
+ }
222
+
223
+ if (this.recentCount === this.redundancy) {
224
+ this.recent.copyWithin(0, stride);
225
+ this.recentFrom += 1;
226
+ this.recentCount -= 1;
227
+ }
228
+
229
+ const at = this.recentCount * stride;
230
+ for (let i = 0; i < stride; i++) this.recent[at + i] = payload[i] ?? 0;
231
+ this.recentCount += 1;
232
+ }
233
+
234
+ /**
235
+ * Take everything the transport has and apply it.
236
+ *
237
+ * Called once per tick, before advancing. Never from a transport callback — see `transport.ts`
238
+ * for why a message must not arrive in the middle of a step.
239
+ */
240
+ poll(): void {
241
+ if (this.statusValue !== 'running') return;
242
+ this.transport.drain((from, message) => this.receive(from, message));
243
+ }
244
+
245
+ /**
246
+ * Advance one tick, then publish a fingerprint if this is a tick to publish one at.
247
+ *
248
+ * The rewind loop performs any pending correction first, so a late input that arrived during
249
+ * `poll` is applied before this tick runs rather than after it.
250
+ */
251
+ advance(dt: number, tick: number): void {
252
+ if (this.statusValue !== 'running') return;
253
+ this.loop.advance(dt, tick);
254
+
255
+ this.confirmedValue = this.inputs.confirmedThrough(tick);
256
+ if (this.confirmedValue > this.appliedThrough) this.appliedThrough = this.confirmedValue;
257
+
258
+ /* Everything below the oldest tick a rewind can reach is never needed again. */
259
+ const earliest = this.loop.earliest;
260
+ if (earliest > 0) this.inputs.retain(earliest);
261
+
262
+ this.publishDigest();
263
+ }
264
+
265
+ /**
266
+ * Publish a hash of the state at a **confirmed** tick, which is the only state worth comparing.
267
+ *
268
+ * **Not the live world, and this cost a healthy session at tick 32 during development.** The
269
+ * newest tick is speculative: each peer has predicted inputs the other already knows, so their
270
+ * live worlds differ constantly and correctly. A snapshot at a tick every participant's input has
271
+ * arrived for was computed from the same inputs on both ends, and a disagreement there is a real
272
+ * one.
273
+ *
274
+ * The slot for tick *T* holds the state as *T* began, so it is final once every input below *T*
275
+ * is confirmed — which is what `confirmedThrough` answers.
276
+ */
277
+ private publishDigest(): void {
278
+ if (!this.compareStates) return;
279
+ const at = this.confirmedValue;
280
+ if (at < 0 || at % this.fingerprintEvery !== 0) return;
281
+ if (this.ourDigests.has(at)) return;
282
+
283
+ const digest = this.loop.digestOf(at);
284
+ if (digest === null) return;
285
+
286
+ this.ourDigests.set(at, digest);
287
+ this.forgetDigestsBefore(this.loop.earliest);
288
+ const length = encodeFingerprint(this.outgoing, this.self, at, digest);
289
+ if (length > 0) this.transport.send(BROADCAST, this.outgoing.subarray(0, length));
290
+ }
291
+
292
+ /** Stop the session with a reason. Public so a consumer can halt on their own condition. */
293
+ halt(reason: string): void {
294
+ if (this.statusValue === 'halted') return;
295
+ this.statusValue = 'halted';
296
+ this.haltReason = reason;
297
+ }
298
+
299
+ private receive(from: PeerId, message: Uint8Array): void {
300
+ if (!decode(message, this.decoded)) return;
301
+
302
+ if (this.decoded.kind === MESSAGE_INPUT) {
303
+ const participant = this.decoded.participant;
304
+ if (participant === this.self) return;
305
+
306
+ const stride = this.decoded.payloadBytes;
307
+ for (let i = 0; i < this.decoded.count; i++) {
308
+ const tick = this.decoded.tick + i;
309
+ const at = this.decoded.payloadAt + i * stride;
310
+ const payload = message.subarray(at, at + stride);
311
+
312
+ /*
313
+ * **A tick already past the window is skipped rather than fatal, when the rest of the run
314
+ * is still usable.** Redundancy means most of what arrives is a repeat, and the oldest entry
315
+ * of a run naturally falls out of the window first — halting on that would end every
316
+ * session the moment the redundancy window outran the rewind window. What is fatal is an
317
+ * input this peer never had and can no longer apply, which is the case below.
318
+ */
319
+ if (this.inputs.isConfirmed(participant, tick)) continue;
320
+ /*
321
+ * **And the same skip for a tick the log can no longer answer for, which is the common
322
+ * case rather than the edge one.** `isConfirmed` speaks for the window the log holds, and
323
+ * retention drops everything below the oldest tick a rewind can reach — so on any session
324
+ * whose redundancy reaches further back than that, the oldest word of a run arrives after
325
+ * the slot that would have recognised it is gone, and a link that lost nothing halts.
326
+ * A tick at or below this watermark had every participant's real input and was stepped
327
+ * with it: a second copy cannot change this world and so cannot be evidence of a
328
+ * divergence. Above the watermark nothing changes, halt included, and there it is right —
329
+ * an input this peer never had and can no longer apply really is fatal.
330
+ */
331
+ if (tick <= this.appliedThrough) continue;
332
+ if (this.loop.supply(participant, tick, payload)) continue;
333
+ if (!this.inputs.holds(tick)) {
334
+ this.halt(
335
+ `an input from peer ${participant} for tick ${tick} arrived after the rewind window ` +
336
+ 'had passed it, so this world can no longer be made to match theirs',
337
+ );
338
+ return;
339
+ }
340
+ }
341
+ return;
342
+ }
343
+
344
+ if (this.decoded.kind === MESSAGE_FINGERPRINT) {
345
+ const ours = this.ourDigests.get(this.decoded.tick);
346
+ /* A tick we never published a digest for, or have already forgotten. Not a disagreement. */
347
+ if (ours === undefined) return;
348
+ if (ours === this.decoded.digest) return;
349
+
350
+ this.desyncValue = {
351
+ tick: this.decoded.tick,
352
+ peer: from === BROADCAST ? this.decoded.participant : from,
353
+ ours,
354
+ theirs: this.decoded.digest,
355
+ };
356
+ this.halt(
357
+ `state diverged at tick ${this.decoded.tick}: this peer has ${ours}, peer ` +
358
+ `${this.decoded.participant} has ${this.decoded.digest}`,
359
+ );
360
+ }
361
+ }
362
+
363
+ private forgetDigestsBefore(tick: number): void {
364
+ if (tick <= 0) return;
365
+ for (const at of this.ourDigests.keys()) {
366
+ if (at < tick) this.ourDigests.delete(at);
367
+ }
368
+ }
369
+
370
+ /** The shared digest builder, for a consumer whose `digestAt` wants one without allocating. */
371
+ get digestBuilder(): Fingerprint {
372
+ return this.scratchDigest;
373
+ }
374
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * A transport with no network, and a link you can make as bad as you like on purpose.
3
+ *
4
+ * **This is the load-bearing piece of the whole track's testing.** A rollback test run against a
5
+ * real network is not a test: it cannot be run twice, a failure cannot be reproduced, and a green
6
+ * result says only that this minute's conditions did not happen to trigger anything. Every
7
+ * interesting property here — that a late input replays to the same world, that a lockstep pair
8
+ * converges, that a desync is caught at the right tick — needs a link whose every delay, drop and
9
+ * reordering is decided by a seed.
10
+ *
11
+ * Track O's `packages/ai/src/provider/latency.ts` is the precedent, and for the same reason: a
12
+ * deterministic provider with programmable latency is what made an agent loop testable without a
13
+ * model behind it.
14
+ *
15
+ * ---
16
+ *
17
+ * ## Time is supplied, not read
18
+ *
19
+ * `advance(ms)` moves this transport's clock. It never reads one. A transport that called
20
+ * `performance.now()` would make every test depend on how fast the machine running it happened to
21
+ * be, which is the property that makes a suite flaky on a loaded CI box and green on a laptop.
22
+ *
23
+ * ## The generator here is not the engine's
24
+ *
25
+ * A small xorshift, deliberately **not** `mulberry32`. The engine's seeded sequence is a frozen
26
+ * promise about stored replays and ghosts; this is a test instrument's schedule. Sharing the
27
+ * algorithm would invite somebody to assume the two are related, and the day one of them has to
28
+ * change, the other would be dragged along.
29
+ */
30
+ import {
31
+ BROADCAST,
32
+ type MessageSink,
33
+ type PeerId,
34
+ type Transport,
35
+ type TransportState,
36
+ } from './transport.ts';
37
+
38
+ export interface Impairment {
39
+ /** One-way delay in milliseconds, before jitter. */
40
+ readonly latencyMs?: number;
41
+ /** Delay varies uniformly by ± this many milliseconds. */
42
+ readonly jitterMs?: number;
43
+ /** Fraction of messages dropped, 0 to 1. */
44
+ readonly loss?: number;
45
+ /** Fraction of messages delivered a further `reorderMs` late, which is what reorders them. */
46
+ readonly reorder?: number;
47
+ readonly reorderMs?: number;
48
+ /** Fraction of messages delivered twice. */
49
+ readonly duplicate?: number;
50
+ }
51
+
52
+ export interface LoopbackOptions {
53
+ readonly self: PeerId;
54
+ readonly impairment?: Impairment;
55
+ /** Anything reproducible. The same seed gives the same delivery schedule, exactly. */
56
+ readonly seed?: number;
57
+ }
58
+
59
+ interface Pending {
60
+ from: PeerId;
61
+ to: PeerId;
62
+ at: number;
63
+ bytes: Uint8Array;
64
+ live: boolean;
65
+ }
66
+
67
+ /**
68
+ * A set of endpoints that can reach each other, holding the clock they share.
69
+ *
70
+ * The network owns the clock rather than each endpoint owning one, because two endpoints whose
71
+ * clocks advanced separately would let a test deliver a message before it was sent.
72
+ */
73
+ export class LoopbackNetwork {
74
+ private readonly endpoints = new Map<PeerId, LoopbackTransport>();
75
+ private nowMs = 0;
76
+
77
+ get now(): number {
78
+ return this.nowMs;
79
+ }
80
+
81
+ /** Make an endpoint. Its impairment is its own, so a link can be bad in one direction only. */
82
+ open(options: LoopbackOptions): LoopbackTransport {
83
+ const transport = new LoopbackTransport(this, options);
84
+ this.endpoints.set(options.self, transport);
85
+ return transport;
86
+ }
87
+
88
+ /**
89
+ * Move the shared clock, delivering everything whose time has come.
90
+ *
91
+ * Delivery happens in **send order among messages due at the same instant**, which is what makes
92
+ * a schedule reproducible: two messages with identical arrival times would otherwise be delivered
93
+ * in whatever order a queue happened to hold them.
94
+ */
95
+ advance(ms: number): void {
96
+ this.nowMs += ms;
97
+ for (const endpoint of this.endpoints.values()) endpoint.deliverDue(this.nowMs);
98
+ }
99
+
100
+ /** Every endpoint, for a caller draining them all. */
101
+ reach(to: PeerId): LoopbackTransport[] {
102
+ if (to !== BROADCAST) {
103
+ const one = this.endpoints.get(to);
104
+ return one === undefined ? [] : [one];
105
+ }
106
+ return [...this.endpoints.values()];
107
+ }
108
+ }
109
+
110
+ export class LoopbackTransport implements Transport {
111
+ readonly self: PeerId;
112
+
113
+ private readonly network: LoopbackNetwork;
114
+ private readonly impairment: Required<Impairment>;
115
+ private readonly inflight: Pending[] = [];
116
+ private readonly arrived: { from: PeerId; bytes: Uint8Array }[] = [];
117
+ private stateValue: TransportState = 'open';
118
+ private randomState: number;
119
+ /** Sends made, and how many the impairment threw away. For a test asserting the link was bad. */
120
+ private sentCount = 0;
121
+ private droppedCount = 0;
122
+
123
+ constructor(network: LoopbackNetwork, options: LoopbackOptions) {
124
+ this.network = network;
125
+ this.self = options.self;
126
+ this.randomState = (options.seed ?? 1) | 0 || 1;
127
+ const given = options.impairment ?? {};
128
+ this.impairment = {
129
+ latencyMs: given.latencyMs ?? 0,
130
+ jitterMs: given.jitterMs ?? 0,
131
+ loss: given.loss ?? 0,
132
+ reorder: given.reorder ?? 0,
133
+ reorderMs: given.reorderMs ?? 0,
134
+ duplicate: given.duplicate ?? 0,
135
+ };
136
+ }
137
+
138
+ get state(): TransportState {
139
+ return this.stateValue;
140
+ }
141
+
142
+ get sent(): number {
143
+ return this.sentCount;
144
+ }
145
+
146
+ get dropped(): number {
147
+ return this.droppedCount;
148
+ }
149
+
150
+ /**
151
+ * Queue a message for everyone it is addressed to, impaired.
152
+ *
153
+ * **The bytes are copied here and nowhere else.** A caller sending from a reusable buffer — which
154
+ * every caller in this package does — would otherwise have the buffer overwritten before delivery,
155
+ * and the message that arrived would be whatever was sent most recently. That bug looks exactly
156
+ * like a reordering bug and is not one.
157
+ */
158
+ send(to: PeerId, message: Uint8Array): void {
159
+ if (this.stateValue !== 'open') return;
160
+ this.sentCount += 1;
161
+
162
+ if (this.random() < this.impairment.loss) {
163
+ this.droppedCount += 1;
164
+ return;
165
+ }
166
+
167
+ const copy = message.slice();
168
+ let delay = this.impairment.latencyMs;
169
+ if (this.impairment.jitterMs > 0) {
170
+ delay += (this.random() * 2 - 1) * this.impairment.jitterMs;
171
+ }
172
+ if (this.random() < this.impairment.reorder) delay += this.impairment.reorderMs;
173
+ if (delay < 0) delay = 0;
174
+
175
+ for (const endpoint of this.network.reach(to)) {
176
+ if (endpoint === this) continue;
177
+ endpoint.accept({
178
+ from: this.self,
179
+ to,
180
+ at: this.network.now + delay,
181
+ bytes: copy,
182
+ live: true,
183
+ });
184
+ if (this.random() < this.impairment.duplicate) {
185
+ endpoint.accept({
186
+ from: this.self,
187
+ to,
188
+ at: this.network.now + delay,
189
+ bytes: copy,
190
+ live: true,
191
+ });
192
+ }
193
+ }
194
+ }
195
+
196
+ drain(into: MessageSink): void {
197
+ for (const message of this.arrived) into(message.from, message.bytes);
198
+ this.arrived.length = 0;
199
+ }
200
+
201
+ close(): void {
202
+ this.stateValue = 'closed';
203
+ this.inflight.length = 0;
204
+ this.arrived.length = 0;
205
+ }
206
+
207
+ /** Called by the network. Not for a consumer. */
208
+ accept(pending: Pending): void {
209
+ if (this.stateValue !== 'open') return;
210
+ this.inflight.push(pending);
211
+ }
212
+
213
+ /** Called by the network when the clock moves. Not for a consumer. */
214
+ deliverDue(nowMs: number): void {
215
+ if (this.inflight.length === 0) return;
216
+
217
+ /*
218
+ * Stable by arrival time, then by the order they were queued. Sorting by time alone leaves two
219
+ * messages due at the same instant in an order the engine's sort happens to choose, and a
220
+ * schedule that depends on a sort's stability is a schedule that is not reproducible.
221
+ */
222
+ let due = 0;
223
+ for (const pending of this.inflight) if (pending.at <= nowMs) due += 1;
224
+ if (due === 0) return;
225
+
226
+ const ready: Pending[] = [];
227
+ const keep: Pending[] = [];
228
+ for (const pending of this.inflight) {
229
+ if (pending.at <= nowMs) ready.push(pending);
230
+ else keep.push(pending);
231
+ }
232
+ ready.sort((a, b) => a.at - b.at);
233
+
234
+ this.inflight.length = 0;
235
+ for (const pending of keep) this.inflight.push(pending);
236
+ for (const pending of ready) this.arrived.push({ from: pending.from, bytes: pending.bytes });
237
+ }
238
+
239
+ /**
240
+ * A xorshift32 in [0, 1). Deliberately not the engine's generator; see the header.
241
+ */
242
+ private random(): number {
243
+ let x = this.randomState;
244
+ x ^= x << 13;
245
+ x ^= x >>> 17;
246
+ x ^= x << 5;
247
+ this.randomState = x | 0;
248
+ return ((x >>> 0) % 0x1000000) / 0x1000000;
249
+ }
250
+ }