@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,98 @@
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, createWorldSnapshot } from '@driftengine/entities';
11
+ import { Fingerprint, fingerprintSnapshot } from './fingerprint.ts';
12
+ import type { Snapshotter } from './rewind.ts';
13
+
14
+ /** A world, through the identity-preserving snapshot `@driftengine/entities` owns. */
15
+ export function worldSnapshotter(world: World): Snapshotter<WorldSnapshot> {
16
+ const shared = new Fingerprint();
17
+ return {
18
+ create: () => createWorldSnapshot(),
19
+ save: (into) => world.saveInto(into),
20
+ restore: (from) => world.loadFrom(from),
21
+ digest: (from) => fingerprintSnapshot(from, shared),
22
+ };
23
+ }
24
+
25
+ /**
26
+ * A seeded generator's position.
27
+ *
28
+ * **The shape is declared here and no core type is named**, which is the seam `present/` in
29
+ * `@driftengine/chemistry` uses for the same reason: this package imports `@driftengine/entities`
30
+ * and nothing else, so that a deterministic host has no renderer in its module graph.
31
+ * `savableMulberry32` from core satisfies this structurally.
32
+ */
33
+ export interface SavablePosition {
34
+ save(): number;
35
+ restore(state: number): void;
36
+ }
37
+
38
+ /**
39
+ * A generator, as one number.
40
+ *
41
+ * **Not restoring this is the classic silent rollback bug.** Everything looks correct until a
42
+ * replayed tick draws from the generator, gets the *next* number instead of the one it got the
43
+ * first time, and produces a world that is plausible and different. Nothing in a frame can see it.
44
+ */
45
+ export function randomSnapshotter(rng: SavablePosition): Snapshotter<{ state: number }> {
46
+ return {
47
+ create: () => ({ state: rng.save() }),
48
+ save: (into) => {
49
+ into.state = rng.save();
50
+ },
51
+ restore: (from) => rng.restore(from.state),
52
+ /* The position is one integer, so its hash is its hex. Two peers whose generators sit at
53
+ different positions have diverged even when every visible value still matches. */
54
+ digest: (from) => (from.state >>> 0).toString(16).padStart(8, '0'),
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Several snapshotters as one, saving and restoring in the order given.
60
+ *
61
+ * The slot is an array of the parts' slots, so nothing is allocated per save once the ring is
62
+ * built. **Restore runs in the same order as save**, which matters for a consumer whose parts are
63
+ * not independent: state derived from the world has to be restored after it.
64
+ */
65
+ export function combineSnapshotters(
66
+ parts: readonly Snapshotter<never>[],
67
+ ): Snapshotter<readonly unknown[]> {
68
+ const members = parts as readonly Snapshotter<unknown>[];
69
+ return {
70
+ create: () => members.map((part) => part.create()),
71
+ save: (into) => {
72
+ for (let i = 0; i < members.length; i++) {
73
+ (members[i] as Snapshotter<unknown>).save(into[i]);
74
+ }
75
+ },
76
+ restore: (from) => {
77
+ for (let i = 0; i < members.length; i++) {
78
+ (members[i] as Snapshotter<unknown>).restore(from[i]);
79
+ }
80
+ },
81
+ /*
82
+ * Every part's digest, folded into one. A part with none contributes nothing rather than
83
+ * disabling the whole — a consumer combining a world with a scratch buffer they do not care
84
+ * about should still get a comparable hash of the world.
85
+ */
86
+ digest: (from) => {
87
+ const combined = new Fingerprint();
88
+ for (let i = 0; i < members.length; i++) {
89
+ const part = members[i] as Snapshotter<unknown>;
90
+ if (part.digest === undefined) continue;
91
+ const text = part.digest(from[i]);
92
+ combined.int32(i);
93
+ for (let at = 0; at < text.length; at++) combined.byte(text.charCodeAt(at));
94
+ }
95
+ return combined.digest();
96
+ },
97
+ };
98
+ }
@@ -0,0 +1,69 @@
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
+
40
+ /** Which participant a message is for or from. Small integers, assigned by the host. */
41
+ export type PeerId = number;
42
+
43
+ /** Send to everyone the transport can reach. */
44
+ export const BROADCAST: PeerId = -1;
45
+
46
+ export type TransportState = 'connecting' | 'open' | 'closed';
47
+
48
+ /** What a role means is in this file's header, not in these names. */
49
+ export type SessionRole = 'relay' | 'authority' | 'participant';
50
+
51
+ /**
52
+ * Where a drained message goes.
53
+ *
54
+ * The bytes are **borrowed**, not given: the array is the transport's and may be reused as soon as
55
+ * this returns. A sink that needs to keep one copies it. That is the same contract `MessageQueue`
56
+ * and the render passes use, and it is what keeps a drain allocation-free.
57
+ */
58
+ export type MessageSink = (from: PeerId, message: Uint8Array) => void;
59
+
60
+ export interface Transport {
61
+ readonly state: TransportState;
62
+ /** This end's own id, or -1 before a host has assigned one. */
63
+ readonly self: PeerId;
64
+ /** Queue a message. Whether it arrives, in order, or at all is the implementation's business. */
65
+ send(to: PeerId, message: Uint8Array): void;
66
+ /** Hand every message that has arrived to the sink, oldest first, and clear them. */
67
+ drain(into: MessageSink): void;
68
+ close(): void;
69
+ }
package/src/webrtc.ts ADDED
@@ -0,0 +1,141 @@
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 {
34
+ BROADCAST,
35
+ type MessageSink,
36
+ type PeerId,
37
+ type Transport,
38
+ type TransportState,
39
+ } from './transport.ts';
40
+
41
+ /** What an `RTCDataChannel` satisfies. Declared rather than imported; see `websocket.ts`. */
42
+ export interface DataChannelLike {
43
+ readonly readyState: string;
44
+ binaryType?: string;
45
+ send(data: ArrayBufferView | ArrayBuffer): void;
46
+ close(): void;
47
+ addEventListener(type: string, listener: (event: unknown) => void): void;
48
+ }
49
+
50
+ /** The part of `RTCPeerConnection` `unreliableChannel` needs. */
51
+ export interface PeerConnectionLike {
52
+ createDataChannel(label: string, options: Record<string, unknown>): DataChannelLike;
53
+ }
54
+
55
+ /**
56
+ * A channel configured the way a simulation wants one.
57
+ *
58
+ * **`maxRetransmits: 0` and `ordered: false` together**, and both matter. Unordered alone still
59
+ * retransmits, so a drop still costs a round trip before the data arrives — late, for a tick that
60
+ * has gone. Zero retransmissions alone still delivers in order, so a lost frame still blocks the
61
+ * ones behind it. Either on its own reads like the fix and is half of it.
62
+ */
63
+ export function unreliableChannel(
64
+ connection: PeerConnectionLike,
65
+ label = 'drift',
66
+ ): DataChannelLike {
67
+ return connection.createDataChannel(label, { ordered: false, maxRetransmits: 0 });
68
+ }
69
+
70
+ export interface WebRtcTransportOptions {
71
+ readonly channel: DataChannelLike;
72
+ readonly self?: PeerId;
73
+ /** See `WebSocketTransportOptions.maxQueued`; the oldest goes for the same reason. */
74
+ readonly maxQueued?: number;
75
+ }
76
+
77
+ export class WebRtcTransport implements Transport {
78
+ readonly self: PeerId;
79
+
80
+ private readonly channel: DataChannelLike;
81
+ private readonly maxQueued: number;
82
+ private readonly arrived: Uint8Array[] = [];
83
+ private closedByUs = false;
84
+ private droppedCount = 0;
85
+
86
+ constructor(options: WebRtcTransportOptions) {
87
+ this.channel = options.channel;
88
+ this.self = options.self ?? -1;
89
+ this.maxQueued = Math.max(1, options.maxQueued ?? 1024);
90
+
91
+ if ('binaryType' in this.channel) this.channel.binaryType = 'arraybuffer';
92
+
93
+ this.channel.addEventListener('message', (event) => {
94
+ const data = (event as { data?: unknown }).data;
95
+ const bytes = toBytes(data);
96
+ if (bytes === null) return;
97
+ if (this.arrived.length >= this.maxQueued) {
98
+ this.arrived.shift();
99
+ this.droppedCount += 1;
100
+ }
101
+ this.arrived.push(bytes);
102
+ });
103
+ }
104
+
105
+ get state(): TransportState {
106
+ if (this.closedByUs) return 'closed';
107
+ const ready = this.channel.readyState;
108
+ if (ready === 'open') return 'open';
109
+ if (ready === 'connecting') return 'connecting';
110
+ return 'closed';
111
+ }
112
+
113
+ get dropped(): number {
114
+ return this.droppedCount;
115
+ }
116
+
117
+ send(_to: PeerId, message: Uint8Array): void {
118
+ if (this.state !== 'open') return;
119
+ this.channel.send(message);
120
+ }
121
+
122
+ drain(into: MessageSink): void {
123
+ for (const message of this.arrived) into(BROADCAST, message);
124
+ this.arrived.length = 0;
125
+ }
126
+
127
+ close(): void {
128
+ this.closedByUs = true;
129
+ this.arrived.length = 0;
130
+ this.channel.close();
131
+ }
132
+ }
133
+
134
+ function toBytes(data: unknown): Uint8Array | null {
135
+ if (data instanceof Uint8Array) return data;
136
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
137
+ if (ArrayBuffer.isView(data)) {
138
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
139
+ }
140
+ return null;
141
+ }
@@ -0,0 +1,140 @@
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 {
27
+ BROADCAST,
28
+ type MessageSink,
29
+ type PeerId,
30
+ type Transport,
31
+ type TransportState,
32
+ } from './transport.ts';
33
+
34
+ /** What a browser `WebSocket` and a Node `ws` both satisfy. Named here so neither is imported. */
35
+ export interface SocketLike {
36
+ readonly readyState: number;
37
+ binaryType?: string;
38
+ send(data: ArrayBufferView | ArrayBuffer): void;
39
+ close(): void;
40
+ addEventListener(type: string, listener: (event: unknown) => void): void;
41
+ }
42
+
43
+ /** The `readyState` values, which are the same three numbers everywhere. */
44
+ const OPEN = 1;
45
+ const CLOSED = 3;
46
+
47
+ export interface WebSocketTransportOptions {
48
+ readonly socket: SocketLike;
49
+ /** This end's id, or -1 to wait for a host to assign one through a welcome. */
50
+ readonly self?: PeerId;
51
+ /**
52
+ * How many messages may queue before the oldest is dropped.
53
+ *
54
+ * A bound rather than none, because a socket that stops draining is a socket behind a paused tab
55
+ * or a stalled link, and an unbounded queue there is a leak that ends the session with a crash
56
+ * instead of a disconnection. **The oldest goes**, because in a simulation the newest input is
57
+ * the one still worth having.
58
+ */
59
+ readonly maxQueued?: number;
60
+ }
61
+
62
+ export class WebSocketTransport implements Transport {
63
+ readonly self: PeerId;
64
+
65
+ private readonly socket: SocketLike;
66
+ private readonly maxQueued: number;
67
+ private readonly arrived: Uint8Array[] = [];
68
+ private closedByUs = false;
69
+ private droppedCount = 0;
70
+
71
+ constructor(options: WebSocketTransportOptions) {
72
+ this.socket = options.socket;
73
+ this.self = options.self ?? -1;
74
+ this.maxQueued = Math.max(1, options.maxQueued ?? 1024);
75
+
76
+ /* Binary frames, or every message arrives as a string and every payload is corrupted by the
77
+ round trip through UTF-8. Set before any listener, because a frame can arrive immediately. */
78
+ if ('binaryType' in this.socket) this.socket.binaryType = 'arraybuffer';
79
+
80
+ this.socket.addEventListener('message', (event) => {
81
+ const data = (event as { data?: unknown }).data;
82
+ const bytes = toBytes(data);
83
+ if (bytes === null) return;
84
+ if (this.arrived.length >= this.maxQueued) {
85
+ this.arrived.shift();
86
+ this.droppedCount += 1;
87
+ }
88
+ this.arrived.push(bytes);
89
+ });
90
+ }
91
+
92
+ get state(): TransportState {
93
+ if (this.closedByUs || this.socket.readyState === CLOSED) return 'closed';
94
+ return this.socket.readyState === OPEN ? 'open' : 'connecting';
95
+ }
96
+
97
+ /** Messages dropped because nothing drained. A consumer watching for a stalled tab reads this. */
98
+ get dropped(): number {
99
+ return this.droppedCount;
100
+ }
101
+
102
+ /**
103
+ * Send, or do nothing if the socket is not open yet.
104
+ *
105
+ * **Nothing is queued while connecting, deliberately.** Every message this package sends is
106
+ * addressed to a tick, and a tick that has passed by the time the socket opens is a tick nobody
107
+ * can use. Flushing a backlog of stale inputs on connect is worse than having sent none.
108
+ */
109
+ send(_to: PeerId, message: Uint8Array): void {
110
+ if (this.state !== 'open') return;
111
+ this.socket.send(message);
112
+ }
113
+
114
+ drain(into: MessageSink): void {
115
+ for (const message of this.arrived) into(BROADCAST, message);
116
+ this.arrived.length = 0;
117
+ }
118
+
119
+ close(): void {
120
+ this.closedByUs = true;
121
+ this.arrived.length = 0;
122
+ this.socket.close();
123
+ }
124
+ }
125
+
126
+ /**
127
+ * A message's bytes, whatever the socket handed over, or null.
128
+ *
129
+ * Browsers deliver an `ArrayBuffer` when `binaryType` is set, `ws` under Node delivers a `Buffer`,
130
+ * and a socket left on its default delivers a string. The first two are usable and the third is
131
+ * not: a payload that has been through UTF-8 has had every byte above 0x7f replaced.
132
+ */
133
+ function toBytes(data: unknown): Uint8Array | null {
134
+ if (data instanceof Uint8Array) return data;
135
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
136
+ if (ArrayBuffer.isView(data)) {
137
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
138
+ }
139
+ return null;
140
+ }