@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,156 @@
1
+ /**
2
+ * Fixed-point arithmetic, 48.16, for a simulation that wants determinism by construction.
3
+ *
4
+ * **Opt-in, and the gate is the default.** `scripts/determinism.mjs` plus `math/exact.ts` make a
5
+ * floating-point simulation reproducible across engines, and that is the recommended path: it costs
6
+ * nothing, changes no stored replay, and is what every package in this repository already does.
7
+ * This exists for a consumer who would rather not depend on an argument about IEEE 754 at all.
8
+ *
9
+ * **A value is an integer held in a double**, scaled by 2^16. Add, subtract, compare and negate are
10
+ * integer operations and are exact by inspection. Multiply and divide are where a fixed-point
11
+ * library is either right or quietly wrong, so they are §2 and §3 below.
12
+ *
13
+ * ---
14
+ *
15
+ * ## 1. Why 48.16 and not 32.32
16
+ *
17
+ * A double holds integers exactly to 2^53. Spending 32 bits on the fraction leaves 21 for the
18
+ * integer part — about two million, which as metres is a world 2,000 km across but as *raw values*
19
+ * leaves almost nothing for intermediate results. 16 fractional bits give a resolution of 15
20
+ * micrometres and an integer range of ±2^37, which is more world than anybody has and enough
21
+ * headroom that a multiply's terms stay exact.
22
+ *
23
+ * ## 2. Multiplication is exact wherever its result is representable
24
+ *
25
+ * The naive `a * b / ONE` overflows immediately: two raws of 2^40 multiply to 2^80 and a double
26
+ * stops being exact at 2^53. The fix is to split both operands at the fixed point —
27
+ * `a = A·2^16 + α` — and sum four terms:
28
+ *
29
+ * ```text
30
+ * a·b/2^16 = A·B·2^16 + A·β + α·B + ⌊α·β/2^16⌋
31
+ * ```
32
+ *
33
+ * Every term is exact for a reason: `A·β` and `α·B` are at most 2^37·2^16 = 2^53; `α·β` is at most
34
+ * 2^32; and `A·B·2^16` is the result's own integer part, so it is exact exactly when the result is
35
+ * representable. **The domain is therefore "results that fit", which is the only domain a caller
36
+ * can reason about.**
37
+ *
38
+ * The low term truncates toward negative infinity, which is a decision and not a rounding accident:
39
+ * `Math.floor` is exactly specified, `Math.round` is too, and either is reproducible — but flooring
40
+ * is the one that makes the decomposition's arithmetic hold, because `α` and `β` are defined as the
41
+ * remainders a floor produces.
42
+ *
43
+ * ## 3. Division is deterministic rather than exact, and that is the honest word
44
+ *
45
+ * `⌊(a/b)·2^16⌋` uses one IEEE division, which every conforming engine rounds identically, then an
46
+ * exact multiplication by a power of two and an exactly specified floor. The result may differ by a
47
+ * unit in the last place from the true quotient. **It cannot differ between machines**, which is the
48
+ * property this file exists for. A caller wanting exact division wants rationals, which is a
49
+ * different library.
50
+ *
51
+ * ## 4. Overflow saturates, and says so
52
+ *
53
+ * A result outside the representable range clamps to the end of it. Not a throw, because
54
+ * `AGENTS.md` forbids one in a frame loop; not a wrap, because a position that teleports from one
55
+ * edge of the world to the other is a bug that looks like a physics explosion. Saturation is
56
+ * deterministic, which is the one property that must not be given up — every peer saturates
57
+ * identically, so a session stays in agreement even while it is wrong.
58
+ */
59
+ /** Fractional bits. The whole format is this number. */
60
+ export const SIM_FRACTION_BITS = 16;
61
+ /** One, as a raw value. */
62
+ export const SIM_ONE = 65536;
63
+ /** The largest and smallest representable raws, which is a double's exact integer range. */
64
+ export const SIM_MAX = 9007199254740991;
65
+ export const SIM_MIN = -9007199254740991;
66
+ /** From an ordinary number. Truncates toward negative infinity, as everything here does. */
67
+ export function simFrom(value) {
68
+ return clamp(Math.floor(value * SIM_ONE));
69
+ }
70
+ /** Back to an ordinary number. Exact: a division by a power of two. */
71
+ export function simTo(value) {
72
+ return value / SIM_ONE;
73
+ }
74
+ /** From an integer, exactly, with no rounding to think about. */
75
+ export function simFromInt(value) {
76
+ return clamp(Math.trunc(value) * SIM_ONE);
77
+ }
78
+ export function simAdd(a, b) {
79
+ return clamp(a + b);
80
+ }
81
+ export function simSub(a, b) {
82
+ return clamp(a - b);
83
+ }
84
+ export function simNeg(a) {
85
+ return -a;
86
+ }
87
+ export function simAbs(a) {
88
+ return a < 0 ? -a : a;
89
+ }
90
+ export function simMin(a, b) {
91
+ return a < b ? a : b;
92
+ }
93
+ export function simMax(a, b) {
94
+ return a > b ? a : b;
95
+ }
96
+ /** -1, 0 or 1. Present so a caller compares through this file rather than by hand. */
97
+ export function simCompare(a, b) {
98
+ return a < b ? -1 : a > b ? 1 : 0;
99
+ }
100
+ /**
101
+ * Multiply. Exact wherever the result is representable; see §2 of the header.
102
+ *
103
+ * The split is at the fixed point, so `high` is the integer part and `low` the fractional part
104
+ * scaled — both non-negative for a negative operand too, because `Math.floor` goes toward negative
105
+ * infinity and the remainder is taken against that.
106
+ */
107
+ export function simMul(a, b) {
108
+ const aHigh = Math.floor(a / SIM_ONE);
109
+ const aLow = a - aHigh * SIM_ONE;
110
+ const bHigh = Math.floor(b / SIM_ONE);
111
+ const bLow = b - bHigh * SIM_ONE;
112
+ const product = aHigh * bHigh * SIM_ONE + aHigh * bLow + aLow * bHigh + Math.floor((aLow * bLow) / SIM_ONE);
113
+ return clamp(product);
114
+ }
115
+ /** Divide. Deterministic rather than exact; see §3 of the header. Division by zero saturates. */
116
+ export function simDiv(a, b) {
117
+ if (b === 0)
118
+ return a < 0 ? SIM_MIN : a > 0 ? SIM_MAX : 0;
119
+ return clamp(Math.floor((a / b) * SIM_ONE));
120
+ }
121
+ /**
122
+ * Square root, by Newton's method on integers.
123
+ *
124
+ * **Not `Math.sqrt` of the scaled value**, even though `Math.sqrt` is exactly rounded and would be
125
+ * reproducible: the scaling back and forth introduces a rounding this file has no way to pin, and
126
+ * the whole argument for fixed point is that no floating-point reasoning is needed to believe it.
127
+ * Newton on integers converges in a bounded number of steps and every step is exact.
128
+ *
129
+ * Negative input answers zero, the way a length does. It is not an error worth a branch at every
130
+ * call site.
131
+ */
132
+ export function simSqrt(a) {
133
+ if (a <= 0)
134
+ return 0;
135
+ /* sqrt(a/ONE) * ONE = sqrt(a * ONE). The product is the one place range matters. */
136
+ const target = a * SIM_ONE;
137
+ if (!Number.isFinite(target) || target > SIM_MAX) {
138
+ /* Halve the argument twice, take the root, and double the result: sqrt(4x) = 2·sqrt(x). */
139
+ return clamp(2 * simSqrt(Math.floor(a / 4)));
140
+ }
141
+ let guess = Math.floor(a) || 1;
142
+ for (let i = 0; i < 40; i++) {
143
+ const next = Math.floor((guess + Math.floor(target / guess)) / 2);
144
+ if (next === guess || next === guess - 1)
145
+ return clamp(next);
146
+ guess = next;
147
+ }
148
+ return clamp(guess);
149
+ }
150
+ function clamp(value) {
151
+ if (value > SIM_MAX)
152
+ return SIM_MAX;
153
+ if (value < SIM_MIN)
154
+ return SIM_MIN;
155
+ return value;
156
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The snapshotters this package ships, and the composition that makes them enough.
3
+ *
4
+ * **A tick's state is rarely one thing.** It is the world, and the generator anything random drew
5
+ * from, and whatever the consumer keeps outside both. Restoring two of those three is not a rewind;
6
+ * it is a rewind plus a source of divergence that will not show up until something draws a random
7
+ * number during a replayed tick. So `combineSnapshotters` exists, and the ordinary shape of a
8
+ * session is a combination rather than a single snapshotter.
9
+ */
10
+ import { type WorldSnapshot, type World } from '@driftengine/entities';
11
+ import type { Snapshotter } from './rewind.ts';
12
+ /** A world, through the identity-preserving snapshot `@driftengine/entities` owns. */
13
+ export declare function worldSnapshotter(world: World): Snapshotter<WorldSnapshot>;
14
+ /**
15
+ * A seeded generator's position.
16
+ *
17
+ * **The shape is declared here and no core type is named**, which is the seam `present/` in
18
+ * `@driftengine/chemistry` uses for the same reason: this package imports `@driftengine/entities`
19
+ * and nothing else, so that a deterministic host has no renderer in its module graph.
20
+ * `savableMulberry32` from core satisfies this structurally.
21
+ */
22
+ export interface SavablePosition {
23
+ save(): number;
24
+ restore(state: number): void;
25
+ }
26
+ /**
27
+ * A generator, as one number.
28
+ *
29
+ * **Not restoring this is the classic silent rollback bug.** Everything looks correct until a
30
+ * replayed tick draws from the generator, gets the *next* number instead of the one it got the
31
+ * first time, and produces a world that is plausible and different. Nothing in a frame can see it.
32
+ */
33
+ export declare function randomSnapshotter(rng: SavablePosition): Snapshotter<{
34
+ state: number;
35
+ }>;
36
+ /**
37
+ * Several snapshotters as one, saving and restoring in the order given.
38
+ *
39
+ * The slot is an array of the parts' slots, so nothing is allocated per save once the ring is
40
+ * built. **Restore runs in the same order as save**, which matters for a consumer whose parts are
41
+ * not independent: state derived from the world has to be restored after it.
42
+ */
43
+ export declare function combineSnapshotters(parts: readonly Snapshotter<never>[]): Snapshotter<readonly unknown[]>;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The snapshotters this package ships, and the composition that makes them enough.
3
+ *
4
+ * **A tick's state is rarely one thing.** It is the world, and the generator anything random drew
5
+ * from, and whatever the consumer keeps outside both. Restoring two of those three is not a rewind;
6
+ * it is a rewind plus a source of divergence that will not show up until something draws a random
7
+ * number during a replayed tick. So `combineSnapshotters` exists, and the ordinary shape of a
8
+ * session is a combination rather than a single snapshotter.
9
+ */
10
+ import { createWorldSnapshot } from '@driftengine/entities';
11
+ import { Fingerprint, fingerprintSnapshot } from './fingerprint.js';
12
+ /** A world, through the identity-preserving snapshot `@driftengine/entities` owns. */
13
+ export function worldSnapshotter(world) {
14
+ const shared = new Fingerprint();
15
+ return {
16
+ create: () => createWorldSnapshot(),
17
+ save: (into) => world.saveInto(into),
18
+ restore: (from) => world.loadFrom(from),
19
+ digest: (from) => fingerprintSnapshot(from, shared),
20
+ };
21
+ }
22
+ /**
23
+ * A generator, as one number.
24
+ *
25
+ * **Not restoring this is the classic silent rollback bug.** Everything looks correct until a
26
+ * replayed tick draws from the generator, gets the *next* number instead of the one it got the
27
+ * first time, and produces a world that is plausible and different. Nothing in a frame can see it.
28
+ */
29
+ export function randomSnapshotter(rng) {
30
+ return {
31
+ create: () => ({ state: rng.save() }),
32
+ save: (into) => {
33
+ into.state = rng.save();
34
+ },
35
+ restore: (from) => rng.restore(from.state),
36
+ /* The position is one integer, so its hash is its hex. Two peers whose generators sit at
37
+ different positions have diverged even when every visible value still matches. */
38
+ digest: (from) => (from.state >>> 0).toString(16).padStart(8, '0'),
39
+ };
40
+ }
41
+ /**
42
+ * Several snapshotters as one, saving and restoring in the order given.
43
+ *
44
+ * The slot is an array of the parts' slots, so nothing is allocated per save once the ring is
45
+ * built. **Restore runs in the same order as save**, which matters for a consumer whose parts are
46
+ * not independent: state derived from the world has to be restored after it.
47
+ */
48
+ export function combineSnapshotters(parts) {
49
+ const members = parts;
50
+ return {
51
+ create: () => members.map((part) => part.create()),
52
+ save: (into) => {
53
+ for (let i = 0; i < members.length; i++) {
54
+ members[i].save(into[i]);
55
+ }
56
+ },
57
+ restore: (from) => {
58
+ for (let i = 0; i < members.length; i++) {
59
+ members[i].restore(from[i]);
60
+ }
61
+ },
62
+ /*
63
+ * Every part's digest, folded into one. A part with none contributes nothing rather than
64
+ * disabling the whole — a consumer combining a world with a scratch buffer they do not care
65
+ * about should still get a comparable hash of the world.
66
+ */
67
+ digest: (from) => {
68
+ const combined = new Fingerprint();
69
+ for (let i = 0; i < members.length; i++) {
70
+ const part = members[i];
71
+ if (part.digest === undefined)
72
+ continue;
73
+ const text = part.digest(from[i]);
74
+ combined.int32(i);
75
+ for (let at = 0; at < text.length; at++)
76
+ combined.byte(text.charCodeAt(at));
77
+ }
78
+ return combined.digest();
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The seam a caller supplies, and the three roles a session can hold.
3
+ *
4
+ * **`AGENTS.md` settles the shape without being asked**: *"Nothing here may call a platform API
5
+ * directly when a consumer might want a different one ... Same for any clock, network or filesystem
6
+ * access — take the capability as a parameter, ship a browser implementation as the default."* So
7
+ * `Transport` is an interface, `LoopbackTransport` is the one that needs no network, and the two
8
+ * real ones are beside them.
9
+ *
10
+ * ---
11
+ *
12
+ * ## Three roles, because a relay need not be a simulator
13
+ *
14
+ * Most netcode assumes the machine that relays messages is the machine that runs the simulation.
15
+ * That assumption fails the moment the relay is written in another language — and a service that
16
+ * forwards messages, hosts rooms and re-simulates replays for anti-cheat is exactly the kind of
17
+ * thing written in another language. Such a service cannot run this simulation, because this
18
+ * engine is TypeScript.
19
+ *
20
+ * So the roles separate:
21
+ *
22
+ * - **Relay** forwards messages and reads none of them. Language-agnostic, and a Rust service is a
23
+ * perfectly good one. Lockstep needs nothing more than this.
24
+ * - **Authority** runs the simulation and its state is the truth. A browser peer today; a Node
25
+ * process once the packages can be imported without a bundler.
26
+ * - **Participant** sends inputs, receives inputs or state, and predicts locally.
27
+ *
28
+ * A session declares which it holds. Nothing in the protocol assumes the relay and the authority
29
+ * are the same process.
30
+ *
31
+ * ## Delivery is drained, never pushed into a frame
32
+ *
33
+ * A transport does not call back into a simulation. It collects what has arrived and a caller
34
+ * drains it at a point of their choosing, which is the top of a tick. A callback that fired
35
+ * mid-frame would deliver an input into the middle of a step that had already read its inputs, and
36
+ * the resulting world would depend on packet timing — which is the one thing a deterministic
37
+ * simulation must not do.
38
+ */
39
+ /** Which participant a message is for or from. Small integers, assigned by the host. */
40
+ export type PeerId = number;
41
+ /** Send to everyone the transport can reach. */
42
+ export declare const BROADCAST: PeerId;
43
+ export type TransportState = 'connecting' | 'open' | 'closed';
44
+ /** What a role means is in this file's header, not in these names. */
45
+ export type SessionRole = 'relay' | 'authority' | 'participant';
46
+ /**
47
+ * Where a drained message goes.
48
+ *
49
+ * The bytes are **borrowed**, not given: the array is the transport's and may be reused as soon as
50
+ * this returns. A sink that needs to keep one copies it. That is the same contract `MessageQueue`
51
+ * and the render passes use, and it is what keeps a drain allocation-free.
52
+ */
53
+ export type MessageSink = (from: PeerId, message: Uint8Array) => void;
54
+ export interface Transport {
55
+ readonly state: TransportState;
56
+ /** This end's own id, or -1 before a host has assigned one. */
57
+ readonly self: PeerId;
58
+ /** Queue a message. Whether it arrives, in order, or at all is the implementation's business. */
59
+ send(to: PeerId, message: Uint8Array): void;
60
+ /** Hand every message that has arrived to the sink, oldest first, and clear them. */
61
+ drain(into: MessageSink): void;
62
+ close(): void;
63
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The seam a caller supplies, and the three roles a session can hold.
3
+ *
4
+ * **`AGENTS.md` settles the shape without being asked**: *"Nothing here may call a platform API
5
+ * directly when a consumer might want a different one ... Same for any clock, network or filesystem
6
+ * access — take the capability as a parameter, ship a browser implementation as the default."* So
7
+ * `Transport` is an interface, `LoopbackTransport` is the one that needs no network, and the two
8
+ * real ones are beside them.
9
+ *
10
+ * ---
11
+ *
12
+ * ## Three roles, because a relay need not be a simulator
13
+ *
14
+ * Most netcode assumes the machine that relays messages is the machine that runs the simulation.
15
+ * That assumption fails the moment the relay is written in another language — and a service that
16
+ * forwards messages, hosts rooms and re-simulates replays for anti-cheat is exactly the kind of
17
+ * thing written in another language. Such a service cannot run this simulation, because this
18
+ * engine is TypeScript.
19
+ *
20
+ * So the roles separate:
21
+ *
22
+ * - **Relay** forwards messages and reads none of them. Language-agnostic, and a Rust service is a
23
+ * perfectly good one. Lockstep needs nothing more than this.
24
+ * - **Authority** runs the simulation and its state is the truth. A browser peer today; a Node
25
+ * process once the packages can be imported without a bundler.
26
+ * - **Participant** sends inputs, receives inputs or state, and predicts locally.
27
+ *
28
+ * A session declares which it holds. Nothing in the protocol assumes the relay and the authority
29
+ * are the same process.
30
+ *
31
+ * ## Delivery is drained, never pushed into a frame
32
+ *
33
+ * A transport does not call back into a simulation. It collects what has arrived and a caller
34
+ * drains it at a point of their choosing, which is the top of a tick. A callback that fired
35
+ * mid-frame would deliver an input into the middle of a step that had already read its inputs, and
36
+ * the resulting world would depend on packet timing — which is the one thing a deterministic
37
+ * simulation must not do.
38
+ */
39
+ /** Send to everyone the transport can reach. */
40
+ export const BROADCAST = -1;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `Transport` over a WebRTC data channel, which is what peer lockstep actually wants.
3
+ *
4
+ * **Unreliable and unordered, and that is the feature.** An input is only useful for the tick it
5
+ * names: a frame that took two retransmissions to arrive is a frame whose tick has been simulated,
6
+ * corrected and forgotten. Reliable ordered delivery makes the input *behind* it wait for that —
7
+ * head-of-line blocking, paid on every drop, on a link where the sender is about to send a newer
8
+ * one anyway. `{ ordered: false, maxRetransmits: 0 }` is the configuration, and `unreliableChannel`
9
+ * below is the one line of it worth not getting wrong.
10
+ *
11
+ * ---
12
+ *
13
+ * ## Signalling is a service and is not here
14
+ *
15
+ * Two peers cannot exchange WebRTC offers over WebRTC. Something else — a WebSocket, a REST
16
+ * endpoint, a person pasting a string — has to carry the first messages, and that something is a
17
+ * *service*: it has an address, an operator, and a lifetime longer than a session. Building one
18
+ * into a transport would make this package own a server.
19
+ *
20
+ * So a consumer opens the connection, does whatever signalling they already do, and hands over a
21
+ * channel. That is the same shape `KeyValueStore` has for persistence and `Transport` has for the
22
+ * network one level up.
23
+ *
24
+ * ## What this limits about testing, stated rather than discovered
25
+ *
26
+ * A data channel needs two real peer connections and an ICE exchange. There is no headless harness
27
+ * for that here, so what is tested is everything on this side of the channel — queueing, draining,
28
+ * the state mapping, and that the channel is configured unreliably — against a double. **The
29
+ * channel itself is exercised by `demo/dev/network.html`, in a browser, against a loopback pair.**
30
+ * That is a weaker guarantee than the loopback transport has, and it is why WebSocket is the
31
+ * default rather than this.
32
+ */
33
+ import { type MessageSink, type PeerId, type Transport, type TransportState } from './transport.ts';
34
+ /** What an `RTCDataChannel` satisfies. Declared rather than imported; see `websocket.ts`. */
35
+ export interface DataChannelLike {
36
+ readonly readyState: string;
37
+ binaryType?: string;
38
+ send(data: ArrayBufferView | ArrayBuffer): void;
39
+ close(): void;
40
+ addEventListener(type: string, listener: (event: unknown) => void): void;
41
+ }
42
+ /** The part of `RTCPeerConnection` `unreliableChannel` needs. */
43
+ export interface PeerConnectionLike {
44
+ createDataChannel(label: string, options: Record<string, unknown>): DataChannelLike;
45
+ }
46
+ /**
47
+ * A channel configured the way a simulation wants one.
48
+ *
49
+ * **`maxRetransmits: 0` and `ordered: false` together**, and both matter. Unordered alone still
50
+ * retransmits, so a drop still costs a round trip before the data arrives — late, for a tick that
51
+ * has gone. Zero retransmissions alone still delivers in order, so a lost frame still blocks the
52
+ * ones behind it. Either on its own reads like the fix and is half of it.
53
+ */
54
+ export declare function unreliableChannel(connection: PeerConnectionLike, label?: string): DataChannelLike;
55
+ export interface WebRtcTransportOptions {
56
+ readonly channel: DataChannelLike;
57
+ readonly self?: PeerId;
58
+ /** See `WebSocketTransportOptions.maxQueued`; the oldest goes for the same reason. */
59
+ readonly maxQueued?: number;
60
+ }
61
+ export declare class WebRtcTransport implements Transport {
62
+ readonly self: PeerId;
63
+ private readonly channel;
64
+ private readonly maxQueued;
65
+ private readonly arrived;
66
+ private closedByUs;
67
+ private droppedCount;
68
+ constructor(options: WebRtcTransportOptions);
69
+ get state(): TransportState;
70
+ get dropped(): number;
71
+ send(_to: PeerId, message: Uint8Array): void;
72
+ drain(into: MessageSink): void;
73
+ close(): void;
74
+ }
package/dist/webrtc.js ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * `Transport` over a WebRTC data channel, which is what peer lockstep actually wants.
3
+ *
4
+ * **Unreliable and unordered, and that is the feature.** An input is only useful for the tick it
5
+ * names: a frame that took two retransmissions to arrive is a frame whose tick has been simulated,
6
+ * corrected and forgotten. Reliable ordered delivery makes the input *behind* it wait for that —
7
+ * head-of-line blocking, paid on every drop, on a link where the sender is about to send a newer
8
+ * one anyway. `{ ordered: false, maxRetransmits: 0 }` is the configuration, and `unreliableChannel`
9
+ * below is the one line of it worth not getting wrong.
10
+ *
11
+ * ---
12
+ *
13
+ * ## Signalling is a service and is not here
14
+ *
15
+ * Two peers cannot exchange WebRTC offers over WebRTC. Something else — a WebSocket, a REST
16
+ * endpoint, a person pasting a string — has to carry the first messages, and that something is a
17
+ * *service*: it has an address, an operator, and a lifetime longer than a session. Building one
18
+ * into a transport would make this package own a server.
19
+ *
20
+ * So a consumer opens the connection, does whatever signalling they already do, and hands over a
21
+ * channel. That is the same shape `KeyValueStore` has for persistence and `Transport` has for the
22
+ * network one level up.
23
+ *
24
+ * ## What this limits about testing, stated rather than discovered
25
+ *
26
+ * A data channel needs two real peer connections and an ICE exchange. There is no headless harness
27
+ * for that here, so what is tested is everything on this side of the channel — queueing, draining,
28
+ * the state mapping, and that the channel is configured unreliably — against a double. **The
29
+ * channel itself is exercised by `demo/dev/network.html`, in a browser, against a loopback pair.**
30
+ * That is a weaker guarantee than the loopback transport has, and it is why WebSocket is the
31
+ * default rather than this.
32
+ */
33
+ import { BROADCAST, } from './transport.js';
34
+ /**
35
+ * A channel configured the way a simulation wants one.
36
+ *
37
+ * **`maxRetransmits: 0` and `ordered: false` together**, and both matter. Unordered alone still
38
+ * retransmits, so a drop still costs a round trip before the data arrives — late, for a tick that
39
+ * has gone. Zero retransmissions alone still delivers in order, so a lost frame still blocks the
40
+ * ones behind it. Either on its own reads like the fix and is half of it.
41
+ */
42
+ export function unreliableChannel(connection, label = 'drift') {
43
+ return connection.createDataChannel(label, { ordered: false, maxRetransmits: 0 });
44
+ }
45
+ export class WebRtcTransport {
46
+ self;
47
+ channel;
48
+ maxQueued;
49
+ arrived = [];
50
+ closedByUs = false;
51
+ droppedCount = 0;
52
+ constructor(options) {
53
+ this.channel = options.channel;
54
+ this.self = options.self ?? -1;
55
+ this.maxQueued = Math.max(1, options.maxQueued ?? 1024);
56
+ if ('binaryType' in this.channel)
57
+ this.channel.binaryType = 'arraybuffer';
58
+ this.channel.addEventListener('message', (event) => {
59
+ const data = event.data;
60
+ const bytes = toBytes(data);
61
+ if (bytes === null)
62
+ return;
63
+ if (this.arrived.length >= this.maxQueued) {
64
+ this.arrived.shift();
65
+ this.droppedCount += 1;
66
+ }
67
+ this.arrived.push(bytes);
68
+ });
69
+ }
70
+ get state() {
71
+ if (this.closedByUs)
72
+ return 'closed';
73
+ const ready = this.channel.readyState;
74
+ if (ready === 'open')
75
+ return 'open';
76
+ if (ready === 'connecting')
77
+ return 'connecting';
78
+ return 'closed';
79
+ }
80
+ get dropped() {
81
+ return this.droppedCount;
82
+ }
83
+ send(_to, message) {
84
+ if (this.state !== 'open')
85
+ return;
86
+ this.channel.send(message);
87
+ }
88
+ drain(into) {
89
+ for (const message of this.arrived)
90
+ into(BROADCAST, message);
91
+ this.arrived.length = 0;
92
+ }
93
+ close() {
94
+ this.closedByUs = true;
95
+ this.arrived.length = 0;
96
+ this.channel.close();
97
+ }
98
+ }
99
+ function toBytes(data) {
100
+ if (data instanceof Uint8Array)
101
+ return data;
102
+ if (data instanceof ArrayBuffer)
103
+ return new Uint8Array(data);
104
+ if (ArrayBuffer.isView(data)) {
105
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
106
+ }
107
+ return null;
108
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `Transport` over a WebSocket, which is the browser default.
3
+ *
4
+ * **Ordered and reliable, which is the wrong guarantee for inputs and the right one for this
5
+ * topology.** A dropped input frame blocks the frames behind it while TCP retransmits, and by the
6
+ * time it arrives the tick it belonged to is usually past — so a lockstep peer waits for news it no
7
+ * longer needs. The unordered answer is `WebRtcTransport` beside this.
8
+ *
9
+ * It is the default anyway, for a reason outside the protocol: WebSocket and WebTransport are what
10
+ * a relay service realistically speaks, whatever language it is written in. A transport nothing can
11
+ * connect to is not a default.
12
+ *
13
+ * ---
14
+ *
15
+ * ## The socket is supplied and its type is declared structurally
16
+ *
17
+ * No `WebSocket` type is named. `@driftengine/network` imports `@driftengine/entities` and nothing
18
+ * else, and that is the property that lets an authoritative host run it — a package that named a
19
+ * DOM type would compile only where a DOM does. The shape below is what a browser `WebSocket` and
20
+ * a Node `ws` socket both satisfy, which is also what makes the tests possible.
21
+ *
22
+ * The socket is **supplied rather than constructed**, so a consumer owns the URL, the protocols,
23
+ * the credentials and the reconnection policy. A transport that opened its own connection would be
24
+ * making four decisions that belong to whoever is paying for the server.
25
+ */
26
+ import { type MessageSink, type PeerId, type Transport, type TransportState } from './transport.ts';
27
+ /** What a browser `WebSocket` and a Node `ws` both satisfy. Named here so neither is imported. */
28
+ export interface SocketLike {
29
+ readonly readyState: number;
30
+ binaryType?: string;
31
+ send(data: ArrayBufferView | ArrayBuffer): void;
32
+ close(): void;
33
+ addEventListener(type: string, listener: (event: unknown) => void): void;
34
+ }
35
+ export interface WebSocketTransportOptions {
36
+ readonly socket: SocketLike;
37
+ /** This end's id, or -1 to wait for a host to assign one through a welcome. */
38
+ readonly self?: PeerId;
39
+ /**
40
+ * How many messages may queue before the oldest is dropped.
41
+ *
42
+ * A bound rather than none, because a socket that stops draining is a socket behind a paused tab
43
+ * or a stalled link, and an unbounded queue there is a leak that ends the session with a crash
44
+ * instead of a disconnection. **The oldest goes**, because in a simulation the newest input is
45
+ * the one still worth having.
46
+ */
47
+ readonly maxQueued?: number;
48
+ }
49
+ export declare class WebSocketTransport implements Transport {
50
+ readonly self: PeerId;
51
+ private readonly socket;
52
+ private readonly maxQueued;
53
+ private readonly arrived;
54
+ private closedByUs;
55
+ private droppedCount;
56
+ constructor(options: WebSocketTransportOptions);
57
+ get state(): TransportState;
58
+ /** Messages dropped because nothing drained. A consumer watching for a stalled tab reads this. */
59
+ get dropped(): number;
60
+ /**
61
+ * Send, or do nothing if the socket is not open yet.
62
+ *
63
+ * **Nothing is queued while connecting, deliberately.** Every message this package sends is
64
+ * addressed to a tick, and a tick that has passed by the time the socket opens is a tick nobody
65
+ * can use. Flushing a backlog of stale inputs on connect is worse than having sent none.
66
+ */
67
+ send(_to: PeerId, message: Uint8Array): void;
68
+ drain(into: MessageSink): void;
69
+ close(): void;
70
+ }