@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,291 @@
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.js';
37
+ import { BROADCAST } from './transport.js';
38
+ import { MESSAGE_FINGERPRINT, MESSAGE_INPUT, createDecoded, decode, encodeFingerprint, encodeInput, } from './wire.js';
39
+ export class LockstepSession {
40
+ self;
41
+ participants;
42
+ inputDelay;
43
+ transport;
44
+ loop;
45
+ inputs;
46
+ fingerprintEvery;
47
+ compareStates;
48
+ /** One buffer for the process. Sized for the largest message this session sends. */
49
+ outgoing;
50
+ /** The last `redundancy` inputs this peer submitted, oldest first, flat. */
51
+ recent;
52
+ redundancy;
53
+ /** The tick the first payload in `recent` belongs to, or -1 before anything is submitted. */
54
+ recentFrom = -1;
55
+ recentCount = 0;
56
+ decoded = createDecoded();
57
+ scratchDigest = new Fingerprint();
58
+ /** Digests we published, by tick, so a peer's claim has something to be compared against. */
59
+ ourDigests = new Map();
60
+ statusValue = 'running';
61
+ desyncValue = null;
62
+ haltReason = '';
63
+ confirmedValue = -1;
64
+ /**
65
+ * The highest tick this world has ever had every participant's real input for.
66
+ *
67
+ * **Separate from `confirmedValue`, and monotonic where that one is not.** `confirmedThrough`
68
+ * answers for the window the log currently holds, and the session moves that window forward every
69
+ * tick — so the moment retention passes a tick, the log stops being able to say anything about it
70
+ * and the live watermark can drop. What was applied stays applied, which is the fact a redundant
71
+ * copy has to be judged against, so it is kept here rather than asked of the log.
72
+ */
73
+ appliedThrough = -1;
74
+ constructor(options) {
75
+ this.transport = options.transport;
76
+ this.loop = options.loop;
77
+ this.inputs = options.inputs;
78
+ this.self = options.self;
79
+ this.participants = Math.max(1, options.participants);
80
+ this.inputDelay = Math.max(0, Math.trunc(options.inputDelay ?? 2));
81
+ this.fingerprintEvery = Math.max(1, Math.trunc(options.fingerprintEvery ?? 8));
82
+ this.compareStates = options.compareStates ?? true;
83
+ this.redundancy = Math.max(1, Math.trunc(options.redundancy ?? 4));
84
+ this.recent = new Uint8Array(this.redundancy * this.inputs.inputBytes);
85
+ this.outgoing = new Uint8Array(64 + this.redundancy * this.inputs.inputBytes);
86
+ }
87
+ get status() {
88
+ return this.statusValue;
89
+ }
90
+ /** Why the session halted, or an empty string. */
91
+ get reason() {
92
+ return this.haltReason;
93
+ }
94
+ /** The disagreement that halted it, naming the tick it began at. */
95
+ get desync() {
96
+ return this.desyncValue;
97
+ }
98
+ /** The highest tick every participant's real input has arrived for. */
99
+ get confirmed() {
100
+ return this.confirmedValue;
101
+ }
102
+ /**
103
+ * Publish this peer's input for `tick + inputDelay`, and record it locally at the same tick.
104
+ *
105
+ * **Recorded locally as well as sent, and that is not redundancy.** A peer's own input is not
106
+ * echoed back to it, so without this the local simulation would predict its own controls — and a
107
+ * player would feel their own input arrive late and get corrected, which is the one thing
108
+ * prediction is supposed to prevent.
109
+ */
110
+ submit(tick, payload) {
111
+ if (this.statusValue !== 'running')
112
+ return;
113
+ const at = tick + this.inputDelay;
114
+ if (!this.inputs.holds(at)) {
115
+ this.halt(`an input for tick ${at} is outside the log's window`);
116
+ return;
117
+ }
118
+ this.inputs.set(this.self, at, payload);
119
+ this.remember(at, payload);
120
+ const length = encodeInput(this.outgoing, this.self, this.recentFrom, this.recent, this.inputs.inputBytes, this.recentCount);
121
+ if (length < 0) {
122
+ this.halt(`an input of ${payload.length} bytes does not fit this session's buffer`);
123
+ return;
124
+ }
125
+ this.transport.send(BROADCAST, this.outgoing.subarray(0, length));
126
+ }
127
+ /**
128
+ * Keep this input, and the few before it, for the next packet to carry.
129
+ *
130
+ * Shifted rather than kept in a ring, because the run has to be **contiguous and in order** on
131
+ * the wire: a receiver reads it as "these are the inputs for ticks `firstTick` onward", and a ring
132
+ * would have to be unwrapped before sending anyway. `redundancy` is four, so the shift is three
133
+ * copies of a handful of bytes.
134
+ */
135
+ remember(tick, payload) {
136
+ const stride = this.inputs.inputBytes;
137
+ /* A gap — a tick submitted out of order, or the first one — restarts the run. */
138
+ if (this.recentFrom < 0 || tick !== this.recentFrom + this.recentCount) {
139
+ this.recentFrom = tick;
140
+ this.recentCount = 0;
141
+ }
142
+ if (this.recentCount === this.redundancy) {
143
+ this.recent.copyWithin(0, stride);
144
+ this.recentFrom += 1;
145
+ this.recentCount -= 1;
146
+ }
147
+ const at = this.recentCount * stride;
148
+ for (let i = 0; i < stride; i++)
149
+ this.recent[at + i] = payload[i] ?? 0;
150
+ this.recentCount += 1;
151
+ }
152
+ /**
153
+ * Take everything the transport has and apply it.
154
+ *
155
+ * Called once per tick, before advancing. Never from a transport callback — see `transport.ts`
156
+ * for why a message must not arrive in the middle of a step.
157
+ */
158
+ poll() {
159
+ if (this.statusValue !== 'running')
160
+ return;
161
+ this.transport.drain((from, message) => this.receive(from, message));
162
+ }
163
+ /**
164
+ * Advance one tick, then publish a fingerprint if this is a tick to publish one at.
165
+ *
166
+ * The rewind loop performs any pending correction first, so a late input that arrived during
167
+ * `poll` is applied before this tick runs rather than after it.
168
+ */
169
+ advance(dt, tick) {
170
+ if (this.statusValue !== 'running')
171
+ return;
172
+ this.loop.advance(dt, tick);
173
+ this.confirmedValue = this.inputs.confirmedThrough(tick);
174
+ if (this.confirmedValue > this.appliedThrough)
175
+ this.appliedThrough = this.confirmedValue;
176
+ /* Everything below the oldest tick a rewind can reach is never needed again. */
177
+ const earliest = this.loop.earliest;
178
+ if (earliest > 0)
179
+ this.inputs.retain(earliest);
180
+ this.publishDigest();
181
+ }
182
+ /**
183
+ * Publish a hash of the state at a **confirmed** tick, which is the only state worth comparing.
184
+ *
185
+ * **Not the live world, and this cost a healthy session at tick 32 during development.** The
186
+ * newest tick is speculative: each peer has predicted inputs the other already knows, so their
187
+ * live worlds differ constantly and correctly. A snapshot at a tick every participant's input has
188
+ * arrived for was computed from the same inputs on both ends, and a disagreement there is a real
189
+ * one.
190
+ *
191
+ * The slot for tick *T* holds the state as *T* began, so it is final once every input below *T*
192
+ * is confirmed — which is what `confirmedThrough` answers.
193
+ */
194
+ publishDigest() {
195
+ if (!this.compareStates)
196
+ return;
197
+ const at = this.confirmedValue;
198
+ if (at < 0 || at % this.fingerprintEvery !== 0)
199
+ return;
200
+ if (this.ourDigests.has(at))
201
+ return;
202
+ const digest = this.loop.digestOf(at);
203
+ if (digest === null)
204
+ return;
205
+ this.ourDigests.set(at, digest);
206
+ this.forgetDigestsBefore(this.loop.earliest);
207
+ const length = encodeFingerprint(this.outgoing, this.self, at, digest);
208
+ if (length > 0)
209
+ this.transport.send(BROADCAST, this.outgoing.subarray(0, length));
210
+ }
211
+ /** Stop the session with a reason. Public so a consumer can halt on their own condition. */
212
+ halt(reason) {
213
+ if (this.statusValue === 'halted')
214
+ return;
215
+ this.statusValue = 'halted';
216
+ this.haltReason = reason;
217
+ }
218
+ receive(from, message) {
219
+ if (!decode(message, this.decoded))
220
+ return;
221
+ if (this.decoded.kind === MESSAGE_INPUT) {
222
+ const participant = this.decoded.participant;
223
+ if (participant === this.self)
224
+ return;
225
+ const stride = this.decoded.payloadBytes;
226
+ for (let i = 0; i < this.decoded.count; i++) {
227
+ const tick = this.decoded.tick + i;
228
+ const at = this.decoded.payloadAt + i * stride;
229
+ const payload = message.subarray(at, at + stride);
230
+ /*
231
+ * **A tick already past the window is skipped rather than fatal, when the rest of the run
232
+ * is still usable.** Redundancy means most of what arrives is a repeat, and the oldest entry
233
+ * of a run naturally falls out of the window first — halting on that would end every
234
+ * session the moment the redundancy window outran the rewind window. What is fatal is an
235
+ * input this peer never had and can no longer apply, which is the case below.
236
+ */
237
+ if (this.inputs.isConfirmed(participant, tick))
238
+ continue;
239
+ /*
240
+ * **And the same skip for a tick the log can no longer answer for, which is the common
241
+ * case rather than the edge one.** `isConfirmed` speaks for the window the log holds, and
242
+ * retention drops everything below the oldest tick a rewind can reach — so on any session
243
+ * whose redundancy reaches further back than that, the oldest word of a run arrives after
244
+ * the slot that would have recognised it is gone, and a link that lost nothing halts.
245
+ * A tick at or below this watermark had every participant's real input and was stepped
246
+ * with it: a second copy cannot change this world and so cannot be evidence of a
247
+ * divergence. Above the watermark nothing changes, halt included, and there it is right —
248
+ * an input this peer never had and can no longer apply really is fatal.
249
+ */
250
+ if (tick <= this.appliedThrough)
251
+ continue;
252
+ if (this.loop.supply(participant, tick, payload))
253
+ continue;
254
+ if (!this.inputs.holds(tick)) {
255
+ this.halt(`an input from peer ${participant} for tick ${tick} arrived after the rewind window ` +
256
+ 'had passed it, so this world can no longer be made to match theirs');
257
+ return;
258
+ }
259
+ }
260
+ return;
261
+ }
262
+ if (this.decoded.kind === MESSAGE_FINGERPRINT) {
263
+ const ours = this.ourDigests.get(this.decoded.tick);
264
+ /* A tick we never published a digest for, or have already forgotten. Not a disagreement. */
265
+ if (ours === undefined)
266
+ return;
267
+ if (ours === this.decoded.digest)
268
+ return;
269
+ this.desyncValue = {
270
+ tick: this.decoded.tick,
271
+ peer: from === BROADCAST ? this.decoded.participant : from,
272
+ ours,
273
+ theirs: this.decoded.digest,
274
+ };
275
+ this.halt(`state diverged at tick ${this.decoded.tick}: this peer has ${ours}, peer ` +
276
+ `${this.decoded.participant} has ${this.decoded.digest}`);
277
+ }
278
+ }
279
+ forgetDigestsBefore(tick) {
280
+ if (tick <= 0)
281
+ return;
282
+ for (const at of this.ourDigests.keys()) {
283
+ if (at < tick)
284
+ this.ourDigests.delete(at);
285
+ }
286
+ }
287
+ /** The shared digest builder, for a consumer whose `digestAt` wants one without allocating. */
288
+ get digestBuilder() {
289
+ return this.scratchDigest;
290
+ }
291
+ }
@@ -0,0 +1,115 @@
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 { type MessageSink, type PeerId, type Transport, type TransportState } from './transport.ts';
31
+ export interface Impairment {
32
+ /** One-way delay in milliseconds, before jitter. */
33
+ readonly latencyMs?: number;
34
+ /** Delay varies uniformly by ± this many milliseconds. */
35
+ readonly jitterMs?: number;
36
+ /** Fraction of messages dropped, 0 to 1. */
37
+ readonly loss?: number;
38
+ /** Fraction of messages delivered a further `reorderMs` late, which is what reorders them. */
39
+ readonly reorder?: number;
40
+ readonly reorderMs?: number;
41
+ /** Fraction of messages delivered twice. */
42
+ readonly duplicate?: number;
43
+ }
44
+ export interface LoopbackOptions {
45
+ readonly self: PeerId;
46
+ readonly impairment?: Impairment;
47
+ /** Anything reproducible. The same seed gives the same delivery schedule, exactly. */
48
+ readonly seed?: number;
49
+ }
50
+ interface Pending {
51
+ from: PeerId;
52
+ to: PeerId;
53
+ at: number;
54
+ bytes: Uint8Array;
55
+ live: boolean;
56
+ }
57
+ /**
58
+ * A set of endpoints that can reach each other, holding the clock they share.
59
+ *
60
+ * The network owns the clock rather than each endpoint owning one, because two endpoints whose
61
+ * clocks advanced separately would let a test deliver a message before it was sent.
62
+ */
63
+ export declare class LoopbackNetwork {
64
+ private readonly endpoints;
65
+ private nowMs;
66
+ get now(): number;
67
+ /** Make an endpoint. Its impairment is its own, so a link can be bad in one direction only. */
68
+ open(options: LoopbackOptions): LoopbackTransport;
69
+ /**
70
+ * Move the shared clock, delivering everything whose time has come.
71
+ *
72
+ * Delivery happens in **send order among messages due at the same instant**, which is what makes
73
+ * a schedule reproducible: two messages with identical arrival times would otherwise be delivered
74
+ * in whatever order a queue happened to hold them.
75
+ */
76
+ advance(ms: number): void;
77
+ /** Every endpoint, for a caller draining them all. */
78
+ reach(to: PeerId): LoopbackTransport[];
79
+ }
80
+ export declare class LoopbackTransport implements Transport {
81
+ readonly self: PeerId;
82
+ private readonly network;
83
+ private readonly impairment;
84
+ private readonly inflight;
85
+ private readonly arrived;
86
+ private stateValue;
87
+ private randomState;
88
+ /** Sends made, and how many the impairment threw away. For a test asserting the link was bad. */
89
+ private sentCount;
90
+ private droppedCount;
91
+ constructor(network: LoopbackNetwork, options: LoopbackOptions);
92
+ get state(): TransportState;
93
+ get sent(): number;
94
+ get dropped(): number;
95
+ /**
96
+ * Queue a message for everyone it is addressed to, impaired.
97
+ *
98
+ * **The bytes are copied here and nowhere else.** A caller sending from a reusable buffer — which
99
+ * every caller in this package does — would otherwise have the buffer overwritten before delivery,
100
+ * and the message that arrived would be whatever was sent most recently. That bug looks exactly
101
+ * like a reordering bug and is not one.
102
+ */
103
+ send(to: PeerId, message: Uint8Array): void;
104
+ drain(into: MessageSink): void;
105
+ close(): void;
106
+ /** Called by the network. Not for a consumer. */
107
+ accept(pending: Pending): void;
108
+ /** Called by the network when the clock moves. Not for a consumer. */
109
+ deliverDue(nowMs: number): void;
110
+ /**
111
+ * A xorshift32 in [0, 1). Deliberately not the engine's generator; see the header.
112
+ */
113
+ private random;
114
+ }
115
+ export {};
@@ -0,0 +1,207 @@
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 { BROADCAST, } from './transport.js';
31
+ /**
32
+ * A set of endpoints that can reach each other, holding the clock they share.
33
+ *
34
+ * The network owns the clock rather than each endpoint owning one, because two endpoints whose
35
+ * clocks advanced separately would let a test deliver a message before it was sent.
36
+ */
37
+ export class LoopbackNetwork {
38
+ endpoints = new Map();
39
+ nowMs = 0;
40
+ get now() {
41
+ return this.nowMs;
42
+ }
43
+ /** Make an endpoint. Its impairment is its own, so a link can be bad in one direction only. */
44
+ open(options) {
45
+ const transport = new LoopbackTransport(this, options);
46
+ this.endpoints.set(options.self, transport);
47
+ return transport;
48
+ }
49
+ /**
50
+ * Move the shared clock, delivering everything whose time has come.
51
+ *
52
+ * Delivery happens in **send order among messages due at the same instant**, which is what makes
53
+ * a schedule reproducible: two messages with identical arrival times would otherwise be delivered
54
+ * in whatever order a queue happened to hold them.
55
+ */
56
+ advance(ms) {
57
+ this.nowMs += ms;
58
+ for (const endpoint of this.endpoints.values())
59
+ endpoint.deliverDue(this.nowMs);
60
+ }
61
+ /** Every endpoint, for a caller draining them all. */
62
+ reach(to) {
63
+ if (to !== BROADCAST) {
64
+ const one = this.endpoints.get(to);
65
+ return one === undefined ? [] : [one];
66
+ }
67
+ return [...this.endpoints.values()];
68
+ }
69
+ }
70
+ export class LoopbackTransport {
71
+ self;
72
+ network;
73
+ impairment;
74
+ inflight = [];
75
+ arrived = [];
76
+ stateValue = 'open';
77
+ randomState;
78
+ /** Sends made, and how many the impairment threw away. For a test asserting the link was bad. */
79
+ sentCount = 0;
80
+ droppedCount = 0;
81
+ constructor(network, options) {
82
+ this.network = network;
83
+ this.self = options.self;
84
+ this.randomState = (options.seed ?? 1) | 0 || 1;
85
+ const given = options.impairment ?? {};
86
+ this.impairment = {
87
+ latencyMs: given.latencyMs ?? 0,
88
+ jitterMs: given.jitterMs ?? 0,
89
+ loss: given.loss ?? 0,
90
+ reorder: given.reorder ?? 0,
91
+ reorderMs: given.reorderMs ?? 0,
92
+ duplicate: given.duplicate ?? 0,
93
+ };
94
+ }
95
+ get state() {
96
+ return this.stateValue;
97
+ }
98
+ get sent() {
99
+ return this.sentCount;
100
+ }
101
+ get dropped() {
102
+ return this.droppedCount;
103
+ }
104
+ /**
105
+ * Queue a message for everyone it is addressed to, impaired.
106
+ *
107
+ * **The bytes are copied here and nowhere else.** A caller sending from a reusable buffer — which
108
+ * every caller in this package does — would otherwise have the buffer overwritten before delivery,
109
+ * and the message that arrived would be whatever was sent most recently. That bug looks exactly
110
+ * like a reordering bug and is not one.
111
+ */
112
+ send(to, message) {
113
+ if (this.stateValue !== 'open')
114
+ return;
115
+ this.sentCount += 1;
116
+ if (this.random() < this.impairment.loss) {
117
+ this.droppedCount += 1;
118
+ return;
119
+ }
120
+ const copy = message.slice();
121
+ let delay = this.impairment.latencyMs;
122
+ if (this.impairment.jitterMs > 0) {
123
+ delay += (this.random() * 2 - 1) * this.impairment.jitterMs;
124
+ }
125
+ if (this.random() < this.impairment.reorder)
126
+ delay += this.impairment.reorderMs;
127
+ if (delay < 0)
128
+ delay = 0;
129
+ for (const endpoint of this.network.reach(to)) {
130
+ if (endpoint === this)
131
+ continue;
132
+ endpoint.accept({
133
+ from: this.self,
134
+ to,
135
+ at: this.network.now + delay,
136
+ bytes: copy,
137
+ live: true,
138
+ });
139
+ if (this.random() < this.impairment.duplicate) {
140
+ endpoint.accept({
141
+ from: this.self,
142
+ to,
143
+ at: this.network.now + delay,
144
+ bytes: copy,
145
+ live: true,
146
+ });
147
+ }
148
+ }
149
+ }
150
+ drain(into) {
151
+ for (const message of this.arrived)
152
+ into(message.from, message.bytes);
153
+ this.arrived.length = 0;
154
+ }
155
+ close() {
156
+ this.stateValue = 'closed';
157
+ this.inflight.length = 0;
158
+ this.arrived.length = 0;
159
+ }
160
+ /** Called by the network. Not for a consumer. */
161
+ accept(pending) {
162
+ if (this.stateValue !== 'open')
163
+ return;
164
+ this.inflight.push(pending);
165
+ }
166
+ /** Called by the network when the clock moves. Not for a consumer. */
167
+ deliverDue(nowMs) {
168
+ if (this.inflight.length === 0)
169
+ return;
170
+ /*
171
+ * Stable by arrival time, then by the order they were queued. Sorting by time alone leaves two
172
+ * messages due at the same instant in an order the engine's sort happens to choose, and a
173
+ * schedule that depends on a sort's stability is a schedule that is not reproducible.
174
+ */
175
+ let due = 0;
176
+ for (const pending of this.inflight)
177
+ if (pending.at <= nowMs)
178
+ due += 1;
179
+ if (due === 0)
180
+ return;
181
+ const ready = [];
182
+ const keep = [];
183
+ for (const pending of this.inflight) {
184
+ if (pending.at <= nowMs)
185
+ ready.push(pending);
186
+ else
187
+ keep.push(pending);
188
+ }
189
+ ready.sort((a, b) => a.at - b.at);
190
+ this.inflight.length = 0;
191
+ for (const pending of keep)
192
+ this.inflight.push(pending);
193
+ for (const pending of ready)
194
+ this.arrived.push({ from: pending.from, bytes: pending.bytes });
195
+ }
196
+ /**
197
+ * A xorshift32 in [0, 1). Deliberately not the engine's generator; see the header.
198
+ */
199
+ random() {
200
+ let x = this.randomState;
201
+ x ^= x << 13;
202
+ x ^= x >>> 17;
203
+ x ^= x << 5;
204
+ this.randomState = x | 0;
205
+ return ((x >>> 0) % 0x1000000) / 0x1000000;
206
+ }
207
+ }