@pylonsync/realtime 0.18.0 → 0.19.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/dist/connection.d.ts +32 -4
- package/dist/game.d.ts +6 -1
- package/dist/replication.d.ts +70 -1
- package/dist/wire.d.ts +84 -1
- package/package.json +1 -1
- package/src/connection-webtransport.test.ts +543 -0
- package/src/connection.test.ts +48 -1
- package/src/connection.ts +602 -94
- package/src/game.ts +16 -2
- package/src/replication.fixtures.json +2416 -0
- package/src/replication.test.ts +46 -0
- package/src/replication.ts +167 -2
- package/src/shard-restart.e2e.test.ts +150 -0
- package/src/wire.ts +185 -1
package/src/replication.test.ts
CHANGED
|
@@ -35,6 +35,52 @@ describe("the Rust encoder's frames (replication.fixtures.json)", () => {
|
|
|
35
35
|
});
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
describe("the Rust encoder's datagrams (replication.fixtures.json)", () => {
|
|
39
|
+
test("apply, and skip, exactly as the Rust table does", () => {
|
|
40
|
+
const table = new EntityTable();
|
|
41
|
+
const tableJson = () =>
|
|
42
|
+
[...table.entities.values()]
|
|
43
|
+
.sort((a, b) => a.id - b.id)
|
|
44
|
+
.map((e) => ({
|
|
45
|
+
id: e.id,
|
|
46
|
+
q: [e.qx, e.qy, e.qz],
|
|
47
|
+
components: Object.fromEntries(
|
|
48
|
+
[...e.components.entries()].map(([k, v]) => [String(k), hex(v)]),
|
|
49
|
+
),
|
|
50
|
+
}));
|
|
51
|
+
for (const [i, event] of fixtures.datagrams.entries()) {
|
|
52
|
+
if ("stream" in event && event.stream) {
|
|
53
|
+
table.apply(bytes(event.stream), event.tick as number);
|
|
54
|
+
} else if ("datagram" in event && event.datagram) {
|
|
55
|
+
const s = table.applyDatagram(bytes(event.datagram));
|
|
56
|
+
expect(s.frame, `event ${i}`).toBe(event.frame);
|
|
57
|
+
expect(s.tick).toBe(event.tick);
|
|
58
|
+
expect(s.ack).toBe(event.ack);
|
|
59
|
+
expect(s.streamTick).toBe(event.sentStreamTick);
|
|
60
|
+
expect(s.parts).toBe(event.parts);
|
|
61
|
+
expect(s.updated, `event ${i}`).toEqual(event.updated);
|
|
62
|
+
expect(s.skipped, `event ${i}`).toBe(event.skipped);
|
|
63
|
+
expect(table.streamTick).toBe(event.streamTick);
|
|
64
|
+
}
|
|
65
|
+
expect(tableJson(), `after event ${i}`).toEqual(event.table as unknown as ReturnType<typeof tableJson>);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("hostile datagrams are refused, not half-applied", () => {
|
|
70
|
+
const t = new EntityTable();
|
|
71
|
+
expect(() => t.applyDatagram(new Uint8Array())).toThrow(ReplicationError);
|
|
72
|
+
expect(() => t.applyDatagram(new Uint8Array([1]))).toThrow("datagram version 1");
|
|
73
|
+
// version, frame 1, tick 1, ack 0, stream tick 1, precision 1.0,
|
|
74
|
+
// parts 1, count 1, id 0, then nothing.
|
|
75
|
+
expect(() =>
|
|
76
|
+
t.applyDatagram(new Uint8Array([2, 1, 1, 0, 1, 0, 0, 0x80, 0x3f, 1, 1, 0])),
|
|
77
|
+
).toThrow("ends early");
|
|
78
|
+
expect(() =>
|
|
79
|
+
t.applyDatagram(new Uint8Array([2, 1, 1, 0, 1, 0, 0, 0x80, 0x3f, 1, 0, 7])),
|
|
80
|
+
).toThrow("trailing bytes");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
38
84
|
describe("hostile frames", () => {
|
|
39
85
|
const head = (...rest: number[]) => {
|
|
40
86
|
const f = new Uint8Array(6 + rest.length);
|
package/src/replication.ts
CHANGED
|
@@ -23,9 +23,31 @@
|
|
|
23
23
|
* JavaScript numbers hold integers exactly up to 2^53. Entity ids and
|
|
24
24
|
* quantized positions must stay below that; a frame that exceeds it is
|
|
25
25
|
* refused rather than rounded.
|
|
26
|
+
*
|
|
27
|
+
* **Datagrams** (`pylon_replication::datagram`): on WebTransport, spawns,
|
|
28
|
+
* despawns, and full frames come as frames on the stream, and updates as
|
|
29
|
+
* datagrams that may be lost, duplicated, or reordered. A datagram update
|
|
30
|
+
* carries the entity's absolute position and the tick of the stream frame
|
|
31
|
+
* that spawned it (low 16 bits). {@link EntityTable.applyDatagram} applies
|
|
32
|
+
* an update only to the entity spawned at that tick and only when it is
|
|
33
|
+
* newer than the entity's last datagram, and skips it otherwise. Stream
|
|
34
|
+
* frames must go through `apply(frame, tick)` so the table knows the
|
|
35
|
+
* ticks; a full frame starts them over.
|
|
36
|
+
*
|
|
37
|
+
* ```text
|
|
38
|
+
* u8 version (2)
|
|
39
|
+
* varint frame number, tick, input ack, stream tick (of the last stream
|
|
40
|
+
* frame sent by this tick)
|
|
41
|
+
* f32 LE precision
|
|
42
|
+
* varint parts (datagrams sent for this tick)
|
|
43
|
+
* varint update count, per entity (ids ascending, delta-coded): id,
|
|
44
|
+
* u16 LE spawn tick, u8 mask, the masked axes (zigzag, absolute),
|
|
45
|
+
* components if bit 8
|
|
46
|
+
* ```
|
|
26
47
|
*/
|
|
27
48
|
|
|
28
49
|
export const REPLICATION_VERSION = 1;
|
|
50
|
+
export const DATAGRAM_VERSION = 2;
|
|
29
51
|
|
|
30
52
|
const FLAG_FULL = 1;
|
|
31
53
|
const MASK_X = 1;
|
|
@@ -57,6 +79,24 @@ export interface ReplicatedEntity {
|
|
|
57
79
|
components: Map<number, Uint8Array>;
|
|
58
80
|
}
|
|
59
81
|
|
|
82
|
+
/** What one datagram did. */
|
|
83
|
+
export interface DatagramSummary {
|
|
84
|
+
frame: number;
|
|
85
|
+
tick: number;
|
|
86
|
+
ack: number;
|
|
87
|
+
/** The tick of the last stream frame the server had sent by `tick`. */
|
|
88
|
+
streamTick: number;
|
|
89
|
+
/** Datagrams the server sent for `tick`. */
|
|
90
|
+
parts: number;
|
|
91
|
+
/** Entities it updated. */
|
|
92
|
+
updated: number[];
|
|
93
|
+
/**
|
|
94
|
+
* Updates it skipped: an unknown entity, another spawn, an older
|
|
95
|
+
* datagram than the entity's last, or another precision.
|
|
96
|
+
*/
|
|
97
|
+
skipped: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
60
100
|
/** What one frame did. */
|
|
61
101
|
export interface ReplicationSummary {
|
|
62
102
|
full: boolean;
|
|
@@ -65,6 +105,28 @@ export interface ReplicationSummary {
|
|
|
65
105
|
despawned: number[];
|
|
66
106
|
}
|
|
67
107
|
|
|
108
|
+
/**
|
|
109
|
+
* A datagram's frame number, tick, and input ack, without applying it.
|
|
110
|
+
* Throws on bytes that are not a datagram.
|
|
111
|
+
*/
|
|
112
|
+
export function readDatagramHeader(datagram: Uint8Array): Omit<DatagramSummary, "updated" | "skipped"> {
|
|
113
|
+
const r = new Reader(datagram);
|
|
114
|
+
const version = r.u8();
|
|
115
|
+
if (version !== DATAGRAM_VERSION) throw new ReplicationError(`datagram version ${version}`);
|
|
116
|
+
const frame = toSafe(r.varint(), "frame number");
|
|
117
|
+
const tick = toSafe(r.varint(), "tick");
|
|
118
|
+
const ack = toSafe(r.varint(), "ack");
|
|
119
|
+
const streamTick = toSafe(r.varint(), "stream tick");
|
|
120
|
+
r.f32();
|
|
121
|
+
const parts = toSafe(r.varint(), "parts");
|
|
122
|
+
return { frame, tick, ack, streamTick, parts };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The low 16 bits of a spawn tick, as a datagram update carries them. */
|
|
126
|
+
function spawnTag(tick: number | undefined): number | undefined {
|
|
127
|
+
return tick === undefined ? undefined : tick % 0x10000;
|
|
128
|
+
}
|
|
129
|
+
|
|
68
130
|
class Reader {
|
|
69
131
|
private offset = 0;
|
|
70
132
|
private readonly view: DataView;
|
|
@@ -161,6 +223,18 @@ export class EntityTable {
|
|
|
161
223
|
readonly entities = new Map<number, ReplicatedEntity>();
|
|
162
224
|
/** World units per quantization step, from the last frame. */
|
|
163
225
|
precision = 0.01;
|
|
226
|
+
/**
|
|
227
|
+
* The tick of the stream frame that spawned each entity, when frames are
|
|
228
|
+
* applied with their tick. Datagram updates name it.
|
|
229
|
+
*/
|
|
230
|
+
readonly spawnTicks = new Map<number, number>();
|
|
231
|
+
/** The last datagram applied to each entity. */
|
|
232
|
+
readonly datagramFrames = new Map<number, number>();
|
|
233
|
+
/**
|
|
234
|
+
* The tick of the last frame applied with its tick. A client acks each
|
|
235
|
+
* datagram with it, so the server knows which spawns it had.
|
|
236
|
+
*/
|
|
237
|
+
streamTick = 0;
|
|
164
238
|
|
|
165
239
|
get size(): number {
|
|
166
240
|
return this.entities.size;
|
|
@@ -170,18 +244,32 @@ export class EntityTable {
|
|
|
170
244
|
return this.entities.get(id);
|
|
171
245
|
}
|
|
172
246
|
|
|
247
|
+
/** Forget everything. */
|
|
173
248
|
clear(): void {
|
|
174
249
|
this.entities.clear();
|
|
250
|
+
this.spawnTicks.clear();
|
|
251
|
+
this.datagramFrames.clear();
|
|
252
|
+
this.streamTick = 0;
|
|
175
253
|
}
|
|
176
254
|
|
|
177
|
-
|
|
255
|
+
/**
|
|
256
|
+
* Apply a replication frame. On a connection that also gets datagrams,
|
|
257
|
+
* pass the tick from the frame's header: the table records the tick each
|
|
258
|
+
* entity spawned at and the tick of the last frame, which datagrams and
|
|
259
|
+
* their acks name. A full frame starts that record over.
|
|
260
|
+
*/
|
|
261
|
+
apply(frame: Uint8Array, tick?: number): ReplicationSummary {
|
|
178
262
|
const r = new Reader(frame);
|
|
179
263
|
const version = r.u8();
|
|
180
264
|
if (version !== REPLICATION_VERSION) throw new ReplicationError(`version ${version}`);
|
|
181
265
|
const full = (r.u8() & FLAG_FULL) !== 0;
|
|
182
266
|
const precision = r.f32();
|
|
183
267
|
if (!Number.isFinite(precision) || precision <= 0) throw new ReplicationError("bad precision");
|
|
184
|
-
if (full)
|
|
268
|
+
if (full) {
|
|
269
|
+
this.entities.clear();
|
|
270
|
+
this.datagramFrames.clear();
|
|
271
|
+
this.spawnTicks.clear();
|
|
272
|
+
}
|
|
185
273
|
this.precision = precision;
|
|
186
274
|
const summary: ReplicationSummary = { full, spawned: [], updated: [], despawned: [] };
|
|
187
275
|
|
|
@@ -190,6 +278,8 @@ export class EntityTable {
|
|
|
190
278
|
for (let i = 0; i < n; i++) {
|
|
191
279
|
last = nextId(r, last);
|
|
192
280
|
this.entities.delete(last);
|
|
281
|
+
this.datagramFrames.delete(last);
|
|
282
|
+
this.spawnTicks.delete(last);
|
|
193
283
|
summary.despawned.push(last);
|
|
194
284
|
}
|
|
195
285
|
|
|
@@ -212,6 +302,9 @@ export class EntityTable {
|
|
|
212
302
|
z: qz * precision,
|
|
213
303
|
components,
|
|
214
304
|
});
|
|
305
|
+
this.datagramFrames.delete(last);
|
|
306
|
+
if (tick === undefined) this.spawnTicks.delete(last);
|
|
307
|
+
else this.spawnTicks.set(last, tick);
|
|
215
308
|
summary.spawned.push(last);
|
|
216
309
|
}
|
|
217
310
|
|
|
@@ -236,6 +329,78 @@ export class EntityTable {
|
|
|
236
329
|
e.y = e.qy * precision;
|
|
237
330
|
e.z = e.qz * precision;
|
|
238
331
|
}
|
|
332
|
+
if (tick !== undefined) this.streamTick = tick;
|
|
333
|
+
return summary;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Apply one datagram. It changes nothing it cannot apply exactly (see
|
|
338
|
+
* `DatagramSummary.skipped`). Throws only on bytes that are not a
|
|
339
|
+
* datagram.
|
|
340
|
+
*/
|
|
341
|
+
applyDatagram(datagram: Uint8Array): DatagramSummary {
|
|
342
|
+
const r = new Reader(datagram);
|
|
343
|
+
const version = r.u8();
|
|
344
|
+
if (version !== DATAGRAM_VERSION) throw new ReplicationError(`datagram version ${version}`);
|
|
345
|
+
const frame = toSafe(r.varint(), "frame number");
|
|
346
|
+
const tick = toSafe(r.varint(), "tick");
|
|
347
|
+
const ack = toSafe(r.varint(), "ack");
|
|
348
|
+
const streamTick = toSafe(r.varint(), "stream tick");
|
|
349
|
+
const precision = r.f32();
|
|
350
|
+
const parts = toSafe(r.varint(), "parts");
|
|
351
|
+
const n = r.varint();
|
|
352
|
+
if (n > 1n << 16n) throw new ReplicationError("count too large");
|
|
353
|
+
const summary: DatagramSummary = { frame, tick, ack, streamTick, parts, updated: [], skipped: 0 };
|
|
354
|
+
// Positions in another precision mean nothing to this table: the full
|
|
355
|
+
// frame of the new precision is still on its way.
|
|
356
|
+
const usable = precision === Math.fround(this.precision);
|
|
357
|
+
let last: number | null = null;
|
|
358
|
+
for (let i = 0; i < Number(n); i++) {
|
|
359
|
+
last = nextId(r, last);
|
|
360
|
+
const tag = r.u8() | (r.u8() << 8);
|
|
361
|
+
const mask = r.u8();
|
|
362
|
+
const qx = mask & MASK_X ? toSafe(r.zigzag(), "position") : null;
|
|
363
|
+
const qy = mask & MASK_Y ? toSafe(r.zigzag(), "position") : null;
|
|
364
|
+
const qz = mask & MASK_Z ? toSafe(r.zigzag(), "position") : null;
|
|
365
|
+
const changes = new Map<number, Uint8Array>();
|
|
366
|
+
const removed: number[] = [];
|
|
367
|
+
if (mask & MASK_COMPONENTS) {
|
|
368
|
+
const count = r.varint();
|
|
369
|
+
if (count > 256n) throw new ReplicationError("more than 256 components");
|
|
370
|
+
for (let c = 0; c < Number(count); c++) {
|
|
371
|
+
const id = r.u8();
|
|
372
|
+
const len = r.varint();
|
|
373
|
+
if (len === 0n) {
|
|
374
|
+
removed.push(id);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (len - 1n > BigInt(Number.MAX_SAFE_INTEGER)) throw new ReplicationError("component too large");
|
|
378
|
+
changes.set(id, r.take(Number(len - 1n)));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const e = this.entities.get(last);
|
|
382
|
+
const lastFrame = this.datagramFrames.get(last);
|
|
383
|
+
const current =
|
|
384
|
+
usable &&
|
|
385
|
+
e !== undefined &&
|
|
386
|
+
spawnTag(this.spawnTicks.get(last)) === tag &&
|
|
387
|
+
(lastFrame === undefined || frame > lastFrame);
|
|
388
|
+
if (!current || !e) {
|
|
389
|
+
summary.skipped += 1;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (qx !== null) e.qx = qx;
|
|
393
|
+
if (qy !== null) e.qy = qy;
|
|
394
|
+
if (qz !== null) e.qz = qz;
|
|
395
|
+
e.x = e.qx * this.precision;
|
|
396
|
+
e.y = e.qy * this.precision;
|
|
397
|
+
e.z = e.qz * this.precision;
|
|
398
|
+
for (const id of removed) e.components.delete(id);
|
|
399
|
+
for (const [id, bytes] of changes) e.components.set(id, bytes);
|
|
400
|
+
this.datagramFrames.set(last, frame);
|
|
401
|
+
summary.updated.push(last);
|
|
402
|
+
}
|
|
403
|
+
if (!r.done) throw new ReplicationError("trailing bytes");
|
|
239
404
|
return summary;
|
|
240
405
|
}
|
|
241
406
|
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// End-to-end: a zone's state survives a restart of a `pylon start` process
|
|
2
|
+
// on SQLite (examples/shard-arena's zone). The heal step heals a player,
|
|
3
|
+
// which only the zone's state records (the Character row has no hp); the
|
|
4
|
+
// check step, after the server restarts, finds the healed hp.
|
|
5
|
+
//
|
|
6
|
+
// Runs only when PYLON_SHARD_RESTART_E2E is "<host:port>",
|
|
7
|
+
// PYLON_SHARD_ADMIN_TOKEN is the server's admin token, PYLON_SHARD_RESTART_FILE
|
|
8
|
+
// names a file that carries the zone between steps, and
|
|
9
|
+
// PYLON_SHARD_RESTART_STEP is "heal" or "check" with PYLON_SHARD_RESTART_HP
|
|
10
|
+
// the hp to expect. tools/smoke-wasm-shard.sh runs it.
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
|
|
14
|
+
import { expect, test } from "bun:test";
|
|
15
|
+
|
|
16
|
+
import { connectShard } from "./connection";
|
|
17
|
+
|
|
18
|
+
const host = process.env.PYLON_SHARD_RESTART_E2E ?? "";
|
|
19
|
+
const adminToken = process.env.PYLON_SHARD_ADMIN_TOKEN ?? "";
|
|
20
|
+
const file = process.env.PYLON_SHARD_RESTART_FILE ?? "";
|
|
21
|
+
const step = process.env.PYLON_SHARD_RESTART_STEP ?? "";
|
|
22
|
+
const hp = Number(process.env.PYLON_SHARD_RESTART_HP ?? "0");
|
|
23
|
+
const enabled = Boolean(host && adminToken && file && step && hp);
|
|
24
|
+
|
|
25
|
+
interface Player {
|
|
26
|
+
hp: number;
|
|
27
|
+
loaded: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Zone {
|
|
31
|
+
players: Record<string, Player>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Joined = { subscriberId: string; ticket: string };
|
|
35
|
+
|
|
36
|
+
/** The zone and the player the steps share. */
|
|
37
|
+
interface Carried {
|
|
38
|
+
zone: string;
|
|
39
|
+
sid: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sleep(ms: number): Promise<void> {
|
|
43
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function guest(): Promise<string> {
|
|
47
|
+
const res = await fetch(`http://${host}/api/auth/guest`, { method: "POST" });
|
|
48
|
+
expect(res.ok).toBe(true);
|
|
49
|
+
return ((await res.json()) as { token: string }).token;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function call<T>(token: string, fn: string, args: unknown): Promise<T> {
|
|
53
|
+
const res = await fetch(`http://${host}/api/fn/${fn}`, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: {
|
|
56
|
+
authorization: `Bearer ${token}`,
|
|
57
|
+
"content-type": "application/json",
|
|
58
|
+
},
|
|
59
|
+
body: JSON.stringify(args),
|
|
60
|
+
});
|
|
61
|
+
const text = await res.text();
|
|
62
|
+
if (res.status !== 200) throw new Error(`${fn}: ${res.status} ${text}`);
|
|
63
|
+
return JSON.parse(text) as T;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Watch `zone` as a new guest until player `sid` has `want` hp. The zone can
|
|
68
|
+
* still be starting after a restart: each try joins again.
|
|
69
|
+
*/
|
|
70
|
+
async function waitForHp(zone: string, sid: string, want: number, ms: number) {
|
|
71
|
+
const token = await guest();
|
|
72
|
+
const start = Date.now();
|
|
73
|
+
let seen: number | undefined;
|
|
74
|
+
while (Date.now() - start < ms) {
|
|
75
|
+
let snapshot: Zone | undefined;
|
|
76
|
+
try {
|
|
77
|
+
const joined = await call<Joined>(token, "joinZone", { zone });
|
|
78
|
+
const watcher = connectShard<Zone, unknown>(zone, {
|
|
79
|
+
subscriberId: joined.subscriberId,
|
|
80
|
+
baseUrl: host,
|
|
81
|
+
ticket: async (shard) =>
|
|
82
|
+
(await call<Joined>(token, "joinZone", { zone: shard })).ticket,
|
|
83
|
+
});
|
|
84
|
+
watcher.onSnapshot((s) => (snapshot = s));
|
|
85
|
+
const until = Date.now() + 3000;
|
|
86
|
+
while (Date.now() < until && snapshot?.players[sid]?.hp !== want) {
|
|
87
|
+
await sleep(100);
|
|
88
|
+
}
|
|
89
|
+
watcher.close();
|
|
90
|
+
} catch {
|
|
91
|
+
// The zone is not up yet.
|
|
92
|
+
}
|
|
93
|
+
seen = snapshot?.players[sid]?.hp;
|
|
94
|
+
if (seen === want) return;
|
|
95
|
+
await sleep(500);
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`player ${sid} in ${zone}: hp ${seen}, want ${want}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
test.skipIf(!enabled || step !== "heal")(
|
|
101
|
+
"heal: a player's hp, kept only in the zone's state, goes up",
|
|
102
|
+
async () => {
|
|
103
|
+
let carried: Carried;
|
|
104
|
+
if (existsSync(file)) {
|
|
105
|
+
carried = JSON.parse(readFileSync(file, "utf8")) as Carried;
|
|
106
|
+
} else {
|
|
107
|
+
// A new zone and player.
|
|
108
|
+
const zone = `restart-${Math.random().toString(36).slice(2, 8)}`;
|
|
109
|
+
const token = await guest();
|
|
110
|
+
const joined = await call<Joined>(token, "joinZone", { zone });
|
|
111
|
+
let snapshot: Zone | undefined;
|
|
112
|
+
const client = connectShard<Zone, unknown>(zone, {
|
|
113
|
+
subscriberId: joined.subscriberId,
|
|
114
|
+
baseUrl: host,
|
|
115
|
+
ticket: async (shard) =>
|
|
116
|
+
(await call<Joined>(token, "joinZone", { zone: shard })).ticket,
|
|
117
|
+
});
|
|
118
|
+
client.onSnapshot((s) => (snapshot = s));
|
|
119
|
+
const start = Date.now();
|
|
120
|
+
while (!snapshot) {
|
|
121
|
+
if (Date.now() - start > 10_000) throw new Error("no snapshot");
|
|
122
|
+
await sleep(50);
|
|
123
|
+
}
|
|
124
|
+
client.send("join");
|
|
125
|
+
while (!snapshot?.players[joined.subscriberId]?.loaded) {
|
|
126
|
+
if (Date.now() - start > 10_000) throw new Error("the character did not load");
|
|
127
|
+
await sleep(50);
|
|
128
|
+
}
|
|
129
|
+
client.close();
|
|
130
|
+
carried = { zone, sid: joined.subscriberId };
|
|
131
|
+
writeFileSync(file, JSON.stringify(carried));
|
|
132
|
+
}
|
|
133
|
+
await call(adminToken, "gmHeal", {
|
|
134
|
+
zone: carried.zone,
|
|
135
|
+
userId: carried.sid,
|
|
136
|
+
hp: 7,
|
|
137
|
+
});
|
|
138
|
+
await waitForHp(carried.zone, carried.sid, hp, 10_000);
|
|
139
|
+
},
|
|
140
|
+
60_000,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
test.skipIf(!enabled || step !== "check")(
|
|
144
|
+
"check: after the restart, the zone has the healed hp",
|
|
145
|
+
async () => {
|
|
146
|
+
const carried = JSON.parse(readFileSync(file, "utf8")) as Carried;
|
|
147
|
+
await waitForHp(carried.zone, carried.sid, hp, 30_000);
|
|
148
|
+
},
|
|
149
|
+
60_000,
|
|
150
|
+
);
|
package/src/wire.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* message is a binary frame with an 18-byte header:
|
|
6
6
|
*
|
|
7
7
|
* 0 1 frame kind: 1 snapshot, 2 input rejected, 3 entity replication,
|
|
8
|
-
* 4 transfer (JSON `{ shard, ticket }`: the last frame; reconnect there)
|
|
8
|
+
* 4 transfer (JSON `{ shard, ticket }`: the last frame; reconnect there),
|
|
9
|
+
* 6 closing (WebTransport only, JSON `{ code, reason }`)
|
|
9
10
|
* 1 1 codec: 0 JSON, 1 MessagePack, 2 bincode, 3 custom, 4 replication
|
|
10
11
|
* 2 8 tick (u64 big-endian)
|
|
11
12
|
* 10 8 ack: highest client_seq the shard processed for this subscriber (0 = none)
|
|
@@ -13,6 +14,12 @@
|
|
|
13
14
|
*
|
|
14
15
|
* Inputs go up as `{ input, client_seq }`: JSON in a text frame, or the
|
|
15
16
|
* shard's codec in a binary frame.
|
|
17
|
+
*
|
|
18
|
+
* Over WebTransport (wire version 3 in Rust) the same frames travel on one
|
|
19
|
+
* bidirectional stream, each after a 4-byte big-endian length, and entity
|
|
20
|
+
* updates travel as QUIC datagrams (`EntityTable.applyDatagram`). The
|
|
21
|
+
* client's hello, its inputs, and its datagram acks are described at
|
|
22
|
+
* `encodeWebTransportHello`, `ShardClientMessage`, and `encodeDatagramAcks`.
|
|
16
23
|
*/
|
|
17
24
|
|
|
18
25
|
import { decode as msgpackDecode, encode as msgpackEncode } from "@msgpack/msgpack";
|
|
@@ -30,6 +37,13 @@ export const ShardFrameKind = {
|
|
|
30
37
|
* last frame on the connection.
|
|
31
38
|
*/
|
|
32
39
|
Transfer: 4,
|
|
40
|
+
/** A datagram frame (wire version 3 WebSocket only). */
|
|
41
|
+
Datagram: 5,
|
|
42
|
+
/**
|
|
43
|
+
* WebTransport only: the server is about to close the session, with
|
|
44
|
+
* JSON `{ code, reason }` (`WEBTRANSPORT_CLOSE`). The client closes it.
|
|
45
|
+
*/
|
|
46
|
+
Closing: 6,
|
|
33
47
|
} as const;
|
|
34
48
|
|
|
35
49
|
/** Where the subscriber went: connect to `shard` with `ticket`. */
|
|
@@ -142,3 +156,173 @@ export function encodeShardInput(
|
|
|
142
156
|
if (codec === ShardCodec.MessagePack) return msgpackEncode(envelope);
|
|
143
157
|
return JSON.stringify(envelope);
|
|
144
158
|
}
|
|
159
|
+
|
|
160
|
+
/** Acks one message may carry (the server refuses more). */
|
|
161
|
+
export const MAX_ACKS_PER_MESSAGE = 512;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Type bytes of the client's messages on a WebTransport stream (the first
|
|
165
|
+
* byte; the rest is the message).
|
|
166
|
+
*/
|
|
167
|
+
export const ShardClientMessage = {
|
|
168
|
+
/** An input envelope in the shard's codec (MessagePack shards). */
|
|
169
|
+
Input: 0,
|
|
170
|
+
/** Datagram acks (`encodeDatagramAcks`). */
|
|
171
|
+
Acks: 1,
|
|
172
|
+
/** An input envelope as JSON. */
|
|
173
|
+
JsonInput: 2,
|
|
174
|
+
} as const;
|
|
175
|
+
|
|
176
|
+
/** WebTransport session close codes the server uses. */
|
|
177
|
+
export const WEBTRANSPORT_CLOSE = {
|
|
178
|
+
Normal: 0,
|
|
179
|
+
/** Refused: bad credentials, an unknown shard. */
|
|
180
|
+
Policy: 1,
|
|
181
|
+
/** The client broke the protocol. */
|
|
182
|
+
Protocol: 2,
|
|
183
|
+
/** Try again: the client was too slow, or the server was busy. */
|
|
184
|
+
Again: 3,
|
|
185
|
+
} as const;
|
|
186
|
+
|
|
187
|
+
/** What `GET /_pylon/shard/webtransport` returns. */
|
|
188
|
+
export interface WebTransportInfo {
|
|
189
|
+
/** The `https://` URL of the WebTransport endpoint. */
|
|
190
|
+
url: string;
|
|
191
|
+
/**
|
|
192
|
+
* SHA-256 hashes of the server's self-signed certificates, for
|
|
193
|
+
* `serverCertificateHashes`. Empty when the certificate has a public CA.
|
|
194
|
+
*/
|
|
195
|
+
certHashes: Uint8Array[];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Parse the body of `GET /_pylon/shard/webtransport`. */
|
|
199
|
+
export function decodeWebTransportInfo(body: unknown): WebTransportInfo {
|
|
200
|
+
const raw = body as { url?: unknown; certHashes?: unknown };
|
|
201
|
+
if (typeof raw?.url !== "string") throw new Error("WebTransport info without a url");
|
|
202
|
+
const hashes = Array.isArray(raw.certHashes) ? raw.certHashes : [];
|
|
203
|
+
return {
|
|
204
|
+
url: raw.url,
|
|
205
|
+
certHashes: hashes.map((h) => {
|
|
206
|
+
if (typeof h !== "string") throw new Error("a WebTransport certificate hash is not a string");
|
|
207
|
+
const bin = atob(h);
|
|
208
|
+
const bytes = new Uint8Array(bin.length);
|
|
209
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
210
|
+
if (bytes.length !== 32) throw new Error(`a ${bytes.length}-byte certificate hash`);
|
|
211
|
+
return bytes;
|
|
212
|
+
}),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function pushVarint(out: number[], v: number): void {
|
|
217
|
+
if (!Number.isSafeInteger(v) || v < 0) throw new Error(`varint out of range: ${v}`);
|
|
218
|
+
while (v >= 0x80) {
|
|
219
|
+
out.push((v % 0x80) | 0x80);
|
|
220
|
+
v = Math.floor(v / 0x80);
|
|
221
|
+
}
|
|
222
|
+
out.push(v);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Datagram acks: the type byte `ShardClientMessage.Acks`, a varint count,
|
|
227
|
+
* then per ack the datagram's frame number and `EntityTable.streamTick`
|
|
228
|
+
* when the client applied it, both varints. Sent as a datagram, or on the
|
|
229
|
+
* stream.
|
|
230
|
+
*/
|
|
231
|
+
export function encodeDatagramAcks(acks: ReadonlyArray<readonly [number, number]>): Uint8Array {
|
|
232
|
+
const out: number[] = [ShardClientMessage.Acks];
|
|
233
|
+
pushVarint(out, acks.length);
|
|
234
|
+
for (const [frame, applied] of acks) {
|
|
235
|
+
pushVarint(out, frame);
|
|
236
|
+
pushVarint(out, applied);
|
|
237
|
+
}
|
|
238
|
+
return Uint8Array.from(out);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** A message for a WebTransport stream: a 4-byte big-endian length, then `bytes`. */
|
|
242
|
+
export function lengthPrefixed(bytes: Uint8Array): Uint8Array {
|
|
243
|
+
const out = new Uint8Array(4 + bytes.length);
|
|
244
|
+
new DataView(out.buffer).setUint32(0, bytes.length);
|
|
245
|
+
out.set(bytes, 4);
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The first message on a WebTransport stream: who connects to which shard. */
|
|
250
|
+
export function encodeWebTransportHello(hello: {
|
|
251
|
+
shard: string;
|
|
252
|
+
sid: string;
|
|
253
|
+
ticket?: string;
|
|
254
|
+
token?: string;
|
|
255
|
+
}): Uint8Array {
|
|
256
|
+
return lengthPrefixed(new TextEncoder().encode(JSON.stringify(hello)));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* An input as a WebTransport stream message: `encodeShardInput`'s output
|
|
261
|
+
* after its type byte.
|
|
262
|
+
*/
|
|
263
|
+
export function encodeWebTransportInput(encoded: string | Uint8Array): Uint8Array {
|
|
264
|
+
const body = typeof encoded === "string" ? new TextEncoder().encode(encoded) : encoded;
|
|
265
|
+
const msg = new Uint8Array(1 + body.length);
|
|
266
|
+
msg[0] = typeof encoded === "string" ? ShardClientMessage.JsonInput : ShardClientMessage.Input;
|
|
267
|
+
msg.set(body, 1);
|
|
268
|
+
return lengthPrefixed(msg);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** A frame on a WebTransport stream larger than this ends the session. */
|
|
272
|
+
const MAX_STREAM_FRAME = 64 * 1024 * 1024;
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Splits a WebTransport stream's bytes into its length-prefixed frames.
|
|
276
|
+
* Chunks can end anywhere: in a length, in a frame.
|
|
277
|
+
*/
|
|
278
|
+
export class StreamFrames {
|
|
279
|
+
private chunks: Uint8Array[] = [];
|
|
280
|
+
private buffered = 0;
|
|
281
|
+
|
|
282
|
+
/** Add a chunk and return the frames it completed. */
|
|
283
|
+
push(chunk: Uint8Array): ArrayBuffer[] {
|
|
284
|
+
this.chunks.push(chunk);
|
|
285
|
+
this.buffered += chunk.length;
|
|
286
|
+
const frames: ArrayBuffer[] = [];
|
|
287
|
+
while (this.buffered >= 4) {
|
|
288
|
+
const head = this.peek(4);
|
|
289
|
+
const len = new DataView(head.buffer, head.byteOffset, 4).getUint32(0);
|
|
290
|
+
if (len > MAX_STREAM_FRAME) throw new Error(`a ${len}-byte stream frame`);
|
|
291
|
+
if (this.buffered < 4 + len) break;
|
|
292
|
+
this.take(4);
|
|
293
|
+
const frame = this.take(len);
|
|
294
|
+
frames.push(frame.buffer.slice(frame.byteOffset, frame.byteOffset + frame.length) as ArrayBuffer);
|
|
295
|
+
}
|
|
296
|
+
return frames;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private peek(n: number): Uint8Array {
|
|
300
|
+
if (this.chunks[0].length >= n) return this.chunks[0].subarray(0, n);
|
|
301
|
+
const out = new Uint8Array(n);
|
|
302
|
+
let at = 0;
|
|
303
|
+
for (const c of this.chunks) {
|
|
304
|
+
const part = c.subarray(0, n - at);
|
|
305
|
+
out.set(part, at);
|
|
306
|
+
at += part.length;
|
|
307
|
+
if (at === n) break;
|
|
308
|
+
}
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private take(n: number): Uint8Array {
|
|
313
|
+
const out = this.peek(n);
|
|
314
|
+
let left = n;
|
|
315
|
+
while (left > 0) {
|
|
316
|
+
const c = this.chunks[0];
|
|
317
|
+
if (c.length <= left) {
|
|
318
|
+
this.chunks.shift();
|
|
319
|
+
left -= c.length;
|
|
320
|
+
} else {
|
|
321
|
+
this.chunks[0] = c.subarray(left);
|
|
322
|
+
left = 0;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
this.buffered -= n;
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
}
|