@pylonsync/realtime 0.14.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.
- package/README.md +30 -0
- package/dist/clock.d.ts +74 -0
- package/dist/connection.d.ts +95 -0
- package/dist/game.d.ts +80 -0
- package/dist/index.d.ts +7 -0
- package/dist/interpolation.d.ts +104 -0
- package/dist/prediction.d.ts +45 -0
- package/dist/replication.d.ts +65 -0
- package/dist/wire.d.ts +62 -0
- package/package.json +25 -0
- package/src/clock.test.ts +143 -0
- package/src/clock.ts +206 -0
- package/src/connection.test.ts +80 -0
- package/src/connection.ts +356 -0
- package/src/game.test.ts +137 -0
- package/src/game.ts +161 -0
- package/src/index.ts +7 -0
- package/src/interpolation.test.ts +163 -0
- package/src/interpolation.ts +317 -0
- package/src/prediction.test.ts +41 -0
- package/src/prediction.ts +80 -0
- package/src/replication.fixtures.json +2014 -0
- package/src/replication.test.ts +64 -0
- package/src/replication.ts +241 -0
- package/src/wire.test.ts +72 -0
- package/src/wire.ts +129 -0
- package/src/world3d.e2e.test.ts +234 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { EntityTable, ReplicationError } from "./replication";
|
|
4
|
+
import fixtures from "./replication.fixtures.json";
|
|
5
|
+
|
|
6
|
+
function bytes(hex: string): Uint8Array {
|
|
7
|
+
const out = new Uint8Array(hex.length / 2);
|
|
8
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
9
|
+
return out;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function hex(b: Uint8Array): string {
|
|
13
|
+
return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("the Rust encoder's frames (replication.fixtures.json)", () => {
|
|
17
|
+
test("apply to the same tables the Rust decoder holds", () => {
|
|
18
|
+
const table = new EntityTable();
|
|
19
|
+
for (const [i, step] of fixtures.frames.entries()) {
|
|
20
|
+
table.apply(bytes(step.frame));
|
|
21
|
+
const got = [...table.entities.values()]
|
|
22
|
+
.sort((a, b) => a.id - b.id)
|
|
23
|
+
.map((e) => ({
|
|
24
|
+
id: e.id,
|
|
25
|
+
q: [e.qx, e.qy, e.qz],
|
|
26
|
+
components: Object.fromEntries(
|
|
27
|
+
[...e.components.entries()].map(([k, v]) => [String(k), hex(v)]),
|
|
28
|
+
),
|
|
29
|
+
}));
|
|
30
|
+
expect(got, `after frame ${i}`).toEqual(step.table as unknown as typeof got);
|
|
31
|
+
for (const e of table.entities.values()) {
|
|
32
|
+
expect(e.x).toBeCloseTo(e.qx * step.precision, 9);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("hostile frames", () => {
|
|
39
|
+
const head = (...rest: number[]) => {
|
|
40
|
+
const f = new Uint8Array(6 + rest.length);
|
|
41
|
+
f[0] = 1;
|
|
42
|
+
new DataView(f.buffer).setFloat32(2, 1, true);
|
|
43
|
+
f.set(rest, 6);
|
|
44
|
+
return f;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
test("are refused, not half-applied into garbage", () => {
|
|
48
|
+
const t = new EntityTable();
|
|
49
|
+
expect(() => t.apply(new Uint8Array())).toThrow(ReplicationError);
|
|
50
|
+
expect(() => t.apply(new Uint8Array([2, 0, 0, 0, 0x80, 0x3f]))).toThrow("version 2");
|
|
51
|
+
expect(() => t.apply(new Uint8Array([1, 0, 0, 0, 0, 0]))).toThrow("bad precision");
|
|
52
|
+
expect(() => t.apply(head(0xff, 0xff, 0xff, 0xff, 0x0f))).toThrow("count too large");
|
|
53
|
+
expect(() => t.apply(head(0, 0, 1, 7, 0))).toThrow("unknown entity 7");
|
|
54
|
+
expect(() => t.apply(head(2, 5, 0, 0, 0))).toThrow("do not ascend");
|
|
55
|
+
expect(() => t.apply(head(0, 0, 0, 9))).toThrow("trailing bytes");
|
|
56
|
+
expect(() => t.apply(head(0, 1, 1, 0, 0, 0, 1, 4, 50, 1))).toThrow("runs past the end");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("an id beyond 2^53 is refused rather than rounded", () => {
|
|
60
|
+
// One spawn with id 2^53 (varint 80 80 80 80 80 80 80 10).
|
|
61
|
+
const f = head(0, 1, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x10, 0, 0, 0, 0, 0);
|
|
62
|
+
expect(() => new EntityTable().apply(f)).toThrow("beyond 2^53");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity replication frames (see `pylon_replication::frame` in Rust).
|
|
3
|
+
*
|
|
4
|
+
* A shard that replicates entities sends each subscriber, per tick, the
|
|
5
|
+
* entities that left its view (despawn), came into view (spawn, full
|
|
6
|
+
* state), or changed (update: only the axes that moved, as a quantized
|
|
7
|
+
* difference, and the components that changed or were removed).
|
|
8
|
+
* {@link EntityTable} applies them; after each frame it holds what the
|
|
9
|
+
* server says this subscriber sees.
|
|
10
|
+
*
|
|
11
|
+
* ```text
|
|
12
|
+
* u8 version (1)
|
|
13
|
+
* u8 flags: bit 0 FULL: clear the table first
|
|
14
|
+
* f32 LE precision
|
|
15
|
+
* varint despawn count, ids (first absolute, then differences; ascending)
|
|
16
|
+
* varint spawn count, per entity: id, x, y, z (zigzag, quantized), components
|
|
17
|
+
* varint update count, per entity: id, u8 mask (1 x, 2 y, 4 z, 8 components),
|
|
18
|
+
* changed axes (zigzag differences), components if bit 8
|
|
19
|
+
* components: varint count, per component: u8 id, varint (length + 1),
|
|
20
|
+
* bytes; length field 0 means removed
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* JavaScript numbers hold integers exactly up to 2^53. Entity ids and
|
|
24
|
+
* quantized positions must stay below that; a frame that exceeds it is
|
|
25
|
+
* refused rather than rounded.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export const REPLICATION_VERSION = 1;
|
|
29
|
+
|
|
30
|
+
const FLAG_FULL = 1;
|
|
31
|
+
const MASK_X = 1;
|
|
32
|
+
const MASK_Y = 2;
|
|
33
|
+
const MASK_Z = 4;
|
|
34
|
+
const MASK_COMPONENTS = 8;
|
|
35
|
+
|
|
36
|
+
/** Longest id list a frame may declare. */
|
|
37
|
+
const MAX_COUNT = 1 << 24;
|
|
38
|
+
|
|
39
|
+
export class ReplicationError extends Error {
|
|
40
|
+
constructor(message: string) {
|
|
41
|
+
super(`replication frame: ${message}`);
|
|
42
|
+
this.name = "ReplicationError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One entity as the client sees it. */
|
|
47
|
+
export interface ReplicatedEntity {
|
|
48
|
+
readonly id: number;
|
|
49
|
+
/** Quantized position; `x/y/z` are these times the frame precision. */
|
|
50
|
+
qx: number;
|
|
51
|
+
qy: number;
|
|
52
|
+
qz: number;
|
|
53
|
+
x: number;
|
|
54
|
+
y: number;
|
|
55
|
+
z: number;
|
|
56
|
+
/** Component id to bytes, in the game's own encoding. */
|
|
57
|
+
components: Map<number, Uint8Array>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What one frame did. */
|
|
61
|
+
export interface ReplicationSummary {
|
|
62
|
+
full: boolean;
|
|
63
|
+
spawned: number[];
|
|
64
|
+
updated: number[];
|
|
65
|
+
despawned: number[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
class Reader {
|
|
69
|
+
private offset = 0;
|
|
70
|
+
private readonly view: DataView;
|
|
71
|
+
|
|
72
|
+
constructor(private readonly bytes: Uint8Array) {
|
|
73
|
+
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get done(): boolean {
|
|
77
|
+
return this.offset >= this.bytes.length;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
u8(): number {
|
|
81
|
+
if (this.offset >= this.bytes.length) throw new ReplicationError("ends early");
|
|
82
|
+
return this.bytes[this.offset++];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
f32(): number {
|
|
86
|
+
if (this.offset + 4 > this.bytes.length) throw new ReplicationError("ends early");
|
|
87
|
+
const v = this.view.getFloat32(this.offset, true);
|
|
88
|
+
this.offset += 4;
|
|
89
|
+
return v;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** An unsigned LEB128 varint, as a bigint (exact to 64 bits). */
|
|
93
|
+
varint(): bigint {
|
|
94
|
+
let v = 0n;
|
|
95
|
+
for (let i = 0; i < 10; i++) {
|
|
96
|
+
const b = this.u8();
|
|
97
|
+
const part = BigInt(b & 0x7f);
|
|
98
|
+
if (i === 9 && part > 1n) throw new ReplicationError("varint past 64 bits");
|
|
99
|
+
v |= part << BigInt(7 * i);
|
|
100
|
+
if ((b & 0x80) === 0) return v;
|
|
101
|
+
}
|
|
102
|
+
throw new ReplicationError("varint too long");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A zigzag signed varint, as a bigint. */
|
|
106
|
+
zigzag(): bigint {
|
|
107
|
+
const z = this.varint();
|
|
108
|
+
return (z >> 1n) ^ -(z & 1n);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
take(n: number): Uint8Array {
|
|
112
|
+
if (this.offset + n > this.bytes.length) throw new ReplicationError("component runs past the end");
|
|
113
|
+
const out = this.bytes.slice(this.offset, this.offset + n);
|
|
114
|
+
this.offset += n;
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function toSafe(v: bigint, what: string): number {
|
|
120
|
+
if (v > BigInt(Number.MAX_SAFE_INTEGER) || v < BigInt(Number.MIN_SAFE_INTEGER)) {
|
|
121
|
+
throw new ReplicationError(`${what} ${v} is beyond 2^53`);
|
|
122
|
+
}
|
|
123
|
+
return Number(v);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function count(r: Reader): number {
|
|
127
|
+
const n = r.varint();
|
|
128
|
+
if (n > BigInt(MAX_COUNT)) throw new ReplicationError("count too large");
|
|
129
|
+
return Number(n);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function nextId(r: Reader, last: number | null): number {
|
|
133
|
+
const v = r.varint();
|
|
134
|
+
if (last === null) return toSafe(v, "entity id");
|
|
135
|
+
if (v === 0n) throw new ReplicationError("ids do not ascend");
|
|
136
|
+
return toSafe(BigInt(last) + v, "entity id");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readComponents(r: Reader, into: Map<number, Uint8Array>): void {
|
|
140
|
+
const n = r.varint();
|
|
141
|
+
if (n > 256n) throw new ReplicationError("more than 256 components");
|
|
142
|
+
for (let i = 0; i < Number(n); i++) {
|
|
143
|
+
const id = r.u8();
|
|
144
|
+
const len = r.varint();
|
|
145
|
+
if (len === 0n) {
|
|
146
|
+
into.delete(id);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (len - 1n > BigInt(Number.MAX_SAFE_INTEGER)) throw new ReplicationError("component too large");
|
|
150
|
+
into.set(id, r.take(Number(len - 1n)));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The entities one subscriber has been told about. Apply every
|
|
156
|
+
* replication frame in order; a frame that fails to apply means the
|
|
157
|
+
* connection is out of sync and should be reopened (the server then sends
|
|
158
|
+
* a full frame).
|
|
159
|
+
*/
|
|
160
|
+
export class EntityTable {
|
|
161
|
+
readonly entities = new Map<number, ReplicatedEntity>();
|
|
162
|
+
/** World units per quantization step, from the last frame. */
|
|
163
|
+
precision = 0.01;
|
|
164
|
+
|
|
165
|
+
get size(): number {
|
|
166
|
+
return this.entities.size;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
get(id: number): ReplicatedEntity | undefined {
|
|
170
|
+
return this.entities.get(id);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
clear(): void {
|
|
174
|
+
this.entities.clear();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
apply(frame: Uint8Array): ReplicationSummary {
|
|
178
|
+
const r = new Reader(frame);
|
|
179
|
+
const version = r.u8();
|
|
180
|
+
if (version !== REPLICATION_VERSION) throw new ReplicationError(`version ${version}`);
|
|
181
|
+
const full = (r.u8() & FLAG_FULL) !== 0;
|
|
182
|
+
const precision = r.f32();
|
|
183
|
+
if (!Number.isFinite(precision) || precision <= 0) throw new ReplicationError("bad precision");
|
|
184
|
+
if (full) this.entities.clear();
|
|
185
|
+
this.precision = precision;
|
|
186
|
+
const summary: ReplicationSummary = { full, spawned: [], updated: [], despawned: [] };
|
|
187
|
+
|
|
188
|
+
let n = count(r);
|
|
189
|
+
let last: number | null = null;
|
|
190
|
+
for (let i = 0; i < n; i++) {
|
|
191
|
+
last = nextId(r, last);
|
|
192
|
+
this.entities.delete(last);
|
|
193
|
+
summary.despawned.push(last);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
n = count(r);
|
|
197
|
+
last = null;
|
|
198
|
+
for (let i = 0; i < n; i++) {
|
|
199
|
+
last = nextId(r, last);
|
|
200
|
+
const qx = toSafe(r.zigzag(), "position");
|
|
201
|
+
const qy = toSafe(r.zigzag(), "position");
|
|
202
|
+
const qz = toSafe(r.zigzag(), "position");
|
|
203
|
+
const components = new Map<number, Uint8Array>();
|
|
204
|
+
readComponents(r, components);
|
|
205
|
+
this.entities.set(last, {
|
|
206
|
+
id: last,
|
|
207
|
+
qx,
|
|
208
|
+
qy,
|
|
209
|
+
qz,
|
|
210
|
+
x: qx * precision,
|
|
211
|
+
y: qy * precision,
|
|
212
|
+
z: qz * precision,
|
|
213
|
+
components,
|
|
214
|
+
});
|
|
215
|
+
summary.spawned.push(last);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
n = count(r);
|
|
219
|
+
last = null;
|
|
220
|
+
for (let i = 0; i < n; i++) {
|
|
221
|
+
last = nextId(r, last);
|
|
222
|
+
const mask = r.u8();
|
|
223
|
+
const e = this.entities.get(last);
|
|
224
|
+
if (!e) throw new ReplicationError(`update for unknown entity ${last}`);
|
|
225
|
+
if (mask & MASK_X) e.qx = toSafe(BigInt(e.qx) + r.zigzag(), "position");
|
|
226
|
+
if (mask & MASK_Y) e.qy = toSafe(BigInt(e.qy) + r.zigzag(), "position");
|
|
227
|
+
if (mask & MASK_Z) e.qz = toSafe(BigInt(e.qz) + r.zigzag(), "position");
|
|
228
|
+
if (mask & MASK_COMPONENTS) readComponents(r, e.components);
|
|
229
|
+
summary.updated.push(last);
|
|
230
|
+
}
|
|
231
|
+
if (!r.done) throw new ReplicationError("trailing bytes");
|
|
232
|
+
|
|
233
|
+
// Positions follow the frame's precision (a full frame may change it).
|
|
234
|
+
for (const e of this.entities.values()) {
|
|
235
|
+
e.x = e.qx * precision;
|
|
236
|
+
e.y = e.qy * precision;
|
|
237
|
+
e.z = e.qz * precision;
|
|
238
|
+
}
|
|
239
|
+
return summary;
|
|
240
|
+
}
|
|
241
|
+
}
|
package/src/wire.test.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { encode as msgpackEncode, decode as msgpackDecode } from "@msgpack/msgpack";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
SHARD_HEADER_LEN,
|
|
6
|
+
ShardCodec,
|
|
7
|
+
ShardFrameKind,
|
|
8
|
+
decodeShardPayload,
|
|
9
|
+
decodeShardRejection,
|
|
10
|
+
encodeShardInput,
|
|
11
|
+
parseShardFrame,
|
|
12
|
+
} from "./wire";
|
|
13
|
+
|
|
14
|
+
function frame(kind: number, codec: number, tick: number, ack: number, payload: Uint8Array) {
|
|
15
|
+
const buf = new ArrayBuffer(SHARD_HEADER_LEN + payload.length);
|
|
16
|
+
const view = new DataView(buf);
|
|
17
|
+
view.setUint8(0, kind);
|
|
18
|
+
view.setUint8(1, codec);
|
|
19
|
+
view.setUint32(2, Math.floor(tick / 0x1_0000_0000));
|
|
20
|
+
view.setUint32(6, tick >>> 0);
|
|
21
|
+
view.setUint32(10, Math.floor(ack / 0x1_0000_0000));
|
|
22
|
+
view.setUint32(14, ack >>> 0);
|
|
23
|
+
new Uint8Array(buf, SHARD_HEADER_LEN).set(payload);
|
|
24
|
+
return buf;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("parseShardFrame", () => {
|
|
28
|
+
test("reads kind, codec, tick, ack, and payload", () => {
|
|
29
|
+
const f = parseShardFrame(
|
|
30
|
+
frame(ShardFrameKind.Snapshot, ShardCodec.Json, 2 ** 33 + 5, 7, new TextEncoder().encode("42")),
|
|
31
|
+
);
|
|
32
|
+
expect(f.kind).toBe(1);
|
|
33
|
+
expect(f.codec).toBe(0);
|
|
34
|
+
expect(f.tick).toBe(2 ** 33 + 5);
|
|
35
|
+
expect(f.ack).toBe(7);
|
|
36
|
+
expect(decodeShardPayload(f.codec, f.payload)).toBe(42);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("rejects a frame shorter than the header", () => {
|
|
40
|
+
expect(() => parseShardFrame(new ArrayBuffer(10))).toThrow("too short");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("MessagePack payloads decode to objects", () => {
|
|
45
|
+
const payload = msgpackEncode({ players: [{ id: "a", x: 1.5 }], tick: 3 });
|
|
46
|
+
const f = parseShardFrame(frame(ShardFrameKind.Snapshot, ShardCodec.MessagePack, 3, 0, payload));
|
|
47
|
+
expect(decodeShardPayload(f.codec, f.payload)).toEqual({ players: [{ id: "a", x: 1.5 }], tick: 3 });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("rejection frames decode with camelCase fields", () => {
|
|
51
|
+
const payload = msgpackEncode({ client_seq: 9, code: "rate_limited", message: "slow down" });
|
|
52
|
+
const f = parseShardFrame(frame(ShardFrameKind.InputRejected, ShardCodec.MessagePack, 1, 8, payload));
|
|
53
|
+
expect(decodeShardRejection(f.codec, f.payload)).toEqual({
|
|
54
|
+
clientSeq: 9,
|
|
55
|
+
code: "rate_limited",
|
|
56
|
+
message: "slow down",
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("an unknown codec needs a custom decoder", () => {
|
|
61
|
+
const bytes = new Uint8Array([1, 2, 3]);
|
|
62
|
+
expect(() => decodeShardPayload(ShardCodec.Custom, bytes)).toThrow("custom decoder");
|
|
63
|
+
expect(decodeShardPayload(ShardCodec.Custom, bytes, (p) => p.length)).toBe(3);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("inputs are MessagePack for MessagePack shards and JSON otherwise", () => {
|
|
67
|
+
const bin = encodeShardInput(ShardCodec.MessagePack, { move: "n" }, 4);
|
|
68
|
+
expect(bin).toBeInstanceOf(Uint8Array);
|
|
69
|
+
expect(msgpackDecode(bin as Uint8Array)).toEqual({ input: { move: "n" }, client_seq: 4 });
|
|
70
|
+
expect(encodeShardInput(null, 5, 1)).toBe('{"input":5,"client_seq":1}');
|
|
71
|
+
expect(encodeShardInput(ShardCodec.Json, 5, 2)).toBe('{"input":5,"client_seq":2}');
|
|
72
|
+
});
|
package/src/wire.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shard wire protocol, version 2 (see `pylon_realtime::wire` in Rust).
|
|
3
|
+
*
|
|
4
|
+
* A client asks for it with `?v=2` on the shard WebSocket URL. Each server
|
|
5
|
+
* message is a binary frame with an 18-byte header:
|
|
6
|
+
*
|
|
7
|
+
* 0 1 frame kind: 1 snapshot, 2 input rejected, 3 entity replication
|
|
8
|
+
* 1 1 codec: 0 JSON, 1 MessagePack, 2 bincode, 3 custom, 4 replication
|
|
9
|
+
* 2 8 tick (u64 big-endian)
|
|
10
|
+
* 10 8 ack: highest client_seq the shard processed for this subscriber (0 = none)
|
|
11
|
+
* 18 .. payload in the codec
|
|
12
|
+
*
|
|
13
|
+
* Inputs go up as `{ input, client_seq }`: JSON in a text frame, or the
|
|
14
|
+
* shard's codec in a binary frame.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { decode as msgpackDecode, encode as msgpackEncode } from "@msgpack/msgpack";
|
|
18
|
+
|
|
19
|
+
export const SHARD_PROTOCOL_VERSION = 2;
|
|
20
|
+
export const SHARD_HEADER_LEN = 18;
|
|
21
|
+
|
|
22
|
+
export const ShardFrameKind = {
|
|
23
|
+
Snapshot: 1,
|
|
24
|
+
InputRejected: 2,
|
|
25
|
+
/** An entity replication frame: apply it to an `EntityTable`. */
|
|
26
|
+
Replication: 3,
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
export const ShardCodec = {
|
|
30
|
+
Json: 0,
|
|
31
|
+
MessagePack: 1,
|
|
32
|
+
Bincode: 2,
|
|
33
|
+
Custom: 3,
|
|
34
|
+
/** The replication frame format (frame kind 3). */
|
|
35
|
+
Replication: 4,
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
export interface ShardFrame {
|
|
39
|
+
kind: number;
|
|
40
|
+
codec: number;
|
|
41
|
+
tick: number;
|
|
42
|
+
ack: number;
|
|
43
|
+
payload: Uint8Array;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Why an input did not take effect. */
|
|
47
|
+
export interface ShardInputRejection {
|
|
48
|
+
clientSeq: number | null;
|
|
49
|
+
/** `unauthorized`, `rate_limited`, `queue_full`, `invalid`, `stopped`, or `apply_failed`. */
|
|
50
|
+
code: string;
|
|
51
|
+
message: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Decode a payload in a codec the JS client does not know (bincode, or a
|
|
56
|
+
* game's own codec `3`).
|
|
57
|
+
*/
|
|
58
|
+
export type ShardPayloadDecoder = (payload: Uint8Array, codec: number) => unknown;
|
|
59
|
+
|
|
60
|
+
function readU64(view: DataView, offset: number): number {
|
|
61
|
+
return view.getUint32(offset) * 0x1_0000_0000 + view.getUint32(offset + 4);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Split a version 2 frame into its header fields and payload. */
|
|
65
|
+
export function parseShardFrame(data: ArrayBuffer): ShardFrame {
|
|
66
|
+
if (data.byteLength < SHARD_HEADER_LEN) {
|
|
67
|
+
throw new Error(`shard frame too short (${data.byteLength} bytes)`);
|
|
68
|
+
}
|
|
69
|
+
const view = new DataView(data);
|
|
70
|
+
return {
|
|
71
|
+
kind: view.getUint8(0),
|
|
72
|
+
codec: view.getUint8(1),
|
|
73
|
+
tick: readU64(view, 2),
|
|
74
|
+
ack: readU64(view, 10),
|
|
75
|
+
payload: new Uint8Array(data, SHARD_HEADER_LEN),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Decode a payload. JSON and MessagePack are built in. */
|
|
80
|
+
export function decodeShardPayload(
|
|
81
|
+
codec: number,
|
|
82
|
+
payload: Uint8Array,
|
|
83
|
+
custom?: ShardPayloadDecoder,
|
|
84
|
+
): unknown {
|
|
85
|
+
switch (codec) {
|
|
86
|
+
case ShardCodec.Json:
|
|
87
|
+
return JSON.parse(new TextDecoder().decode(payload));
|
|
88
|
+
case ShardCodec.MessagePack:
|
|
89
|
+
return msgpackDecode(payload);
|
|
90
|
+
default:
|
|
91
|
+
if (custom) return custom(payload, codec);
|
|
92
|
+
throw new Error(
|
|
93
|
+
`shard codec ${codec} needs a custom decoder (pass \`decode\` to connectShard)`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The payload of an input-rejected frame, with camelCase fields. */
|
|
99
|
+
export function decodeShardRejection(
|
|
100
|
+
codec: number,
|
|
101
|
+
payload: Uint8Array,
|
|
102
|
+
custom?: ShardPayloadDecoder,
|
|
103
|
+
): ShardInputRejection {
|
|
104
|
+
const raw = decodeShardPayload(codec, payload, custom) as {
|
|
105
|
+
client_seq?: number | null;
|
|
106
|
+
code?: string;
|
|
107
|
+
message?: string;
|
|
108
|
+
};
|
|
109
|
+
return {
|
|
110
|
+
clientSeq: typeof raw.client_seq === "number" ? raw.client_seq : null,
|
|
111
|
+
code: String(raw.code ?? "invalid"),
|
|
112
|
+
message: String(raw.message ?? ""),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Encode an input envelope. MessagePack shards get a binary frame; every
|
|
118
|
+
* other codec (and a connection that has not seen a frame yet) gets JSON
|
|
119
|
+
* text, which the server always accepts.
|
|
120
|
+
*/
|
|
121
|
+
export function encodeShardInput(
|
|
122
|
+
codec: number | null,
|
|
123
|
+
input: unknown,
|
|
124
|
+
clientSeq: number,
|
|
125
|
+
): string | Uint8Array {
|
|
126
|
+
const envelope = { input, client_seq: clientSeq };
|
|
127
|
+
if (codec === ShardCodec.MessagePack) return msgpackEncode(envelope);
|
|
128
|
+
return JSON.stringify(envelope);
|
|
129
|
+
}
|