@net-mesh/browser 0.42.0 → 0.44.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/CHANGELOG.md +200 -0
- package/README.md +12 -0
- package/dist/index.bundle.js +2 -2
- package/dist/leader/session.d.ts +4 -2
- package/dist/leader/session.js +30 -6
- package/dist/lobby.d.ts +29 -2
- package/dist/lobby.js +80 -10
- package/dist/net_leaf.d.ts +19 -21
- package/dist/net_leaf.js +42 -44
- package/dist/net_leaf_bg.wasm +0 -0
- package/dist/net_leaf_bg.wasm.d.ts +10 -10
- package/dist/netcode/assignment.d.ts +169 -0
- package/dist/netcode/assignment.js +321 -0
- package/dist/netcode/client.d.ts +125 -3
- package/dist/netcode/client.js +291 -27
- package/dist/netcode/codec.d.ts +180 -0
- package/dist/netcode/codec.js +547 -0
- package/dist/netcode/host.d.ts +58 -1
- package/dist/netcode/host.js +254 -9
- package/dist/netcode/index.d.ts +14 -4
- package/dist/netcode/index.js +8 -3
- package/dist/netcode/interpolate.d.ts +24 -1
- package/dist/netcode/interpolate.js +52 -1
- package/dist/netcode/paths.d.ts +115 -0
- package/dist/netcode/paths.js +261 -0
- package/dist/netcode/shards.d.ts +138 -0
- package/dist/netcode/shards.js +270 -0
- package/dist/netcode/wire.d.ts +37 -3
- package/dist/netcode/wire.js +28 -1
- package/dist/node.d.ts +20 -0
- package/dist/node.js +76 -1
- package/dist/store/host.js +10 -4
- package/dist/store/join.js +38 -2
- package/dist/store/owner.d.ts +11 -0
- package/dist/store/owner.js +27 -7
- package/dist/store/types.d.ts +6 -0
- package/dist/stream.d.ts +29 -1
- package/dist/stream.js +66 -4
- package/dist/udp-probe.d.ts +21 -3
- package/dist/udp-probe.js +78 -7
- package/dist/world/link.d.ts +9 -0
- package/dist/world/link.js +4 -3
- package/dist/world/regions.d.ts +9 -2
- package/dist/world/regions.js +34 -4
- package/package.json +3 -2
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compact snapshots: a game-supplied entity schema, and the binary snapshot
|
|
3
|
+
* frame it makes possible (sharded-match plan, slice S0c).
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* const shipCodec = entityCodec({
|
|
7
|
+
* x: quantized(0, 512), // 16 bits over the arena: ~8 mm
|
|
8
|
+
* z: quantized(0, 512),
|
|
9
|
+
* yaw: angle(), // 8 bits: ~1.4°
|
|
10
|
+
* hp: uint(8),
|
|
11
|
+
* });
|
|
12
|
+
* hostNetcode({ …, codec: shipCodec });
|
|
13
|
+
* joinNetcode({ …, codec: shipCodec });
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* JSON stays the default and the fallback. A player announces its codec's
|
|
17
|
+
* fingerprint in its join and ping frames; the host sends binary snapshots
|
|
18
|
+
* only to a player whose fingerprint matches its own, and JSON to everyone
|
|
19
|
+
* else — so a mismatched or older peer degrades to JSON, never to garbage.
|
|
20
|
+
*
|
|
21
|
+
* **Ids.** On the binary wire an entity is named by a varint index, not its
|
|
22
|
+
* id string. The host assigns indexes in order of first appearance and
|
|
23
|
+
* **never reuses one** within its lifetime, so a late or reordered
|
|
24
|
+
* snapshot cannot name the wrong entity. A snapshot carries an entity's
|
|
25
|
+
* name the first time it shows that entity to a player; a player that sees
|
|
26
|
+
* an index it has no name for does not draw that entity and asks again
|
|
27
|
+
* (`u` frame) — the lossy carrier's repair, with no acknowledgements.
|
|
28
|
+
*
|
|
29
|
+
* Every binary snapshot also carries the host's **instance**, a random
|
|
30
|
+
* 32-bit number drawn when `hostNetcode` starts. A host re-created under
|
|
31
|
+
* the same label starts its indexes over; a player that sees a new instance
|
|
32
|
+
* drops every name (and every snapshot) it learnt from the old one, so the
|
|
33
|
+
* new host's indexes are never read through the old host's names.
|
|
34
|
+
*
|
|
35
|
+
* Names are also forgotten: an id no player has been shown for
|
|
36
|
+
* `NAME_FORGET_MS` (10 s) loses its index, and comes back, if it does,
|
|
37
|
+
* under a fresh one, named again. Transient ids (projectiles) do not grow
|
|
38
|
+
* the tables for the whole match.
|
|
39
|
+
*
|
|
40
|
+
* **Versions.** The fingerprint covers the binary wire's version as well as
|
|
41
|
+
* the schema. Frames carrying an instance are version 2; a peer from before
|
|
42
|
+
* it has another fingerprint for the same schema, so the pair falls back
|
|
43
|
+
* to JSON instead of refusing each other's frames.
|
|
44
|
+
*
|
|
45
|
+
* **Interpolation.** `entityCodec` also supplies an `interpolate` blend
|
|
46
|
+
* that knows its fields: an `angle()` turns the short way round across
|
|
47
|
+
* 0 / 2π. `joinNetcode` uses it when it has a codec and no `interpolate`
|
|
48
|
+
* of its own.
|
|
49
|
+
*
|
|
50
|
+
* **What the schema promises.** An entity is a flat object of the schema's
|
|
51
|
+
* fields and nothing else. Writing an entity with a field the schema does
|
|
52
|
+
* not have, or a value that is not a finite number (or boolean, for
|
|
53
|
+
* `bool()`), throws: the host then sends that player JSON for that tick
|
|
54
|
+
* (counted in `dropped`) rather than silently losing a field.
|
|
55
|
+
*/
|
|
56
|
+
import { lerpAngle, lerpNumbers } from './interpolate.js';
|
|
57
|
+
const finite = (value, field) => {
|
|
58
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
59
|
+
throw new TypeError(`field '${field}': expected a finite number, got ${String(value)}`);
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
};
|
|
63
|
+
function checkBits(bits, allowed) {
|
|
64
|
+
if (!allowed.includes(bits))
|
|
65
|
+
throw new RangeError(`bits must be one of ${allowed.join(', ')}, got ${String(bits)}`);
|
|
66
|
+
}
|
|
67
|
+
function putUint(view, at, bits, value) {
|
|
68
|
+
if (bits === 8)
|
|
69
|
+
view.setUint8(at, value);
|
|
70
|
+
else if (bits === 16)
|
|
71
|
+
view.setUint16(at, value);
|
|
72
|
+
else
|
|
73
|
+
view.setUint32(at, value);
|
|
74
|
+
}
|
|
75
|
+
function getUint(view, at, bits) {
|
|
76
|
+
return bits === 8 ? view.getUint8(at) : bits === 16 ? view.getUint16(at) : view.getUint32(at);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A number in `[min, max]`, quantized to `bits` (8 or 16; default 16).
|
|
80
|
+
* Values outside the range are clamped — including one a hair past an edge
|
|
81
|
+
* after rounding, which must not wrap around.
|
|
82
|
+
*/
|
|
83
|
+
export function quantized(min, max, bits = 16) {
|
|
84
|
+
if (!(Number.isFinite(min) && Number.isFinite(max) && max > min))
|
|
85
|
+
throw new RangeError('quantized(min, max): max must exceed min');
|
|
86
|
+
checkBits(bits, [8, 16]);
|
|
87
|
+
const steps = 2 ** bits - 1;
|
|
88
|
+
const span = max - min;
|
|
89
|
+
return {
|
|
90
|
+
size: bits / 8,
|
|
91
|
+
spec: `q${bits}(${min},${max})`,
|
|
92
|
+
quantum: span / steps / 2,
|
|
93
|
+
write(view, at, value) {
|
|
94
|
+
const v = finite(value, this.spec);
|
|
95
|
+
const q = Math.round(((v - min) / span) * steps);
|
|
96
|
+
putUint(view, at, bits, Math.min(steps, Math.max(0, q)));
|
|
97
|
+
},
|
|
98
|
+
read(view, at) {
|
|
99
|
+
return min + (getUint(view, at, bits) / steps) * span;
|
|
100
|
+
},
|
|
101
|
+
near(a, b) {
|
|
102
|
+
if (typeof a !== 'number' || typeof b !== 'number')
|
|
103
|
+
return a === b;
|
|
104
|
+
const clampTo = (v) => Math.min(max, Math.max(min, v));
|
|
105
|
+
return Math.abs(clampTo(a) - clampTo(b)) <= (2 * span) / steps + 1e-9;
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/** An angle in radians, any value, stored as a fraction of a turn in `bits` (default 8). Reads in `[0, 2π)`. */
|
|
110
|
+
export function angle(bits = 8) {
|
|
111
|
+
checkBits(bits, [8, 16]);
|
|
112
|
+
const steps = 2 ** bits;
|
|
113
|
+
const turn = Math.PI * 2;
|
|
114
|
+
return {
|
|
115
|
+
size: bits / 8,
|
|
116
|
+
spec: `a${bits}`,
|
|
117
|
+
quantum: turn / steps / 2,
|
|
118
|
+
write(view, at, value) {
|
|
119
|
+
const v = finite(value, this.spec);
|
|
120
|
+
const fraction = (((v % turn) + turn) % turn) / turn;
|
|
121
|
+
putUint(view, at, bits, Math.round(fraction * steps) % steps);
|
|
122
|
+
},
|
|
123
|
+
read(view, at) {
|
|
124
|
+
return (getUint(view, at, bits) / steps) * turn;
|
|
125
|
+
},
|
|
126
|
+
near(a, b) {
|
|
127
|
+
if (typeof a !== 'number' || typeof b !== 'number')
|
|
128
|
+
return a === b;
|
|
129
|
+
const d = Math.abs((((a - b) % turn) + turn) % turn);
|
|
130
|
+
return Math.min(d, turn - d) <= (2 * turn) / steps + 1e-9;
|
|
131
|
+
},
|
|
132
|
+
blend: lerpAngle,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/** A whole number in `[0, 2^bits)`, exact (default 8 bits). Out of range throws. */
|
|
136
|
+
export function uint(bits = 8) {
|
|
137
|
+
checkBits(bits, [8, 16, 32]);
|
|
138
|
+
const max = 2 ** bits - 1;
|
|
139
|
+
return {
|
|
140
|
+
size: bits / 8,
|
|
141
|
+
spec: `u${bits}`,
|
|
142
|
+
quantum: 0,
|
|
143
|
+
write(view, at, value) {
|
|
144
|
+
const v = finite(value, this.spec);
|
|
145
|
+
if (!Number.isInteger(v) || v < 0 || v > max)
|
|
146
|
+
throw new RangeError(`field u${bits}: ${String(v)} is not a whole number in [0, ${max}]`);
|
|
147
|
+
putUint(view, at, bits, v);
|
|
148
|
+
},
|
|
149
|
+
read(view, at) {
|
|
150
|
+
return getUint(view, at, bits);
|
|
151
|
+
},
|
|
152
|
+
near: (a, b) => a === b,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/** A whole number in `[-2^(bits-1), 2^(bits-1))`, exact (default 16 bits). Out of range throws. */
|
|
156
|
+
export function int(bits = 16) {
|
|
157
|
+
checkBits(bits, [8, 16, 32]);
|
|
158
|
+
const low = -(2 ** (bits - 1));
|
|
159
|
+
const high = 2 ** (bits - 1) - 1;
|
|
160
|
+
return {
|
|
161
|
+
size: bits / 8,
|
|
162
|
+
spec: `i${bits}`,
|
|
163
|
+
quantum: 0,
|
|
164
|
+
write(view, at, value) {
|
|
165
|
+
const v = finite(value, this.spec);
|
|
166
|
+
if (!Number.isInteger(v) || v < low || v > high)
|
|
167
|
+
throw new RangeError(`field i${bits}: ${String(v)} is not a whole number in [${low}, ${high}]`);
|
|
168
|
+
if (bits === 8)
|
|
169
|
+
view.setInt8(at, v);
|
|
170
|
+
else if (bits === 16)
|
|
171
|
+
view.setInt16(at, v);
|
|
172
|
+
else
|
|
173
|
+
view.setInt32(at, v);
|
|
174
|
+
},
|
|
175
|
+
read(view, at) {
|
|
176
|
+
return bits === 8 ? view.getInt8(at) : bits === 16 ? view.getInt16(at) : view.getInt32(at);
|
|
177
|
+
},
|
|
178
|
+
near: (a, b) => a === b,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/** A 32-bit float: ~7 significant digits, no range to choose. A NaN or an infinity read off the wire refuses the frame. */
|
|
182
|
+
export function float32() {
|
|
183
|
+
return {
|
|
184
|
+
size: 4,
|
|
185
|
+
spec: 'f32',
|
|
186
|
+
quantum: Number.NaN, // relative, not absolute: see Math.fround
|
|
187
|
+
write(view, at, value) {
|
|
188
|
+
view.setFloat32(at, finite(value, this.spec));
|
|
189
|
+
},
|
|
190
|
+
read(view, at) {
|
|
191
|
+
const value = view.getFloat32(at);
|
|
192
|
+
// The writer refuses these, so only a hostile or broken host sends them.
|
|
193
|
+
if (!Number.isFinite(value))
|
|
194
|
+
throw new RangeError(`field f32: ${String(value)} is not finite`);
|
|
195
|
+
return value;
|
|
196
|
+
},
|
|
197
|
+
near(a, b) {
|
|
198
|
+
if (typeof a !== 'number' || typeof b !== 'number')
|
|
199
|
+
return a === b;
|
|
200
|
+
return Math.fround(a) === Math.fround(b) || Math.abs(a - b) <= 1e-6 * Math.max(Math.abs(a), Math.abs(b));
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/** A boolean, one byte. */
|
|
205
|
+
export function bool() {
|
|
206
|
+
return {
|
|
207
|
+
size: 1,
|
|
208
|
+
spec: 'b',
|
|
209
|
+
quantum: 0,
|
|
210
|
+
write(view, at, value) {
|
|
211
|
+
if (typeof value !== 'boolean')
|
|
212
|
+
throw new TypeError(`field b: expected a boolean, got ${String(value)}`);
|
|
213
|
+
view.setUint8(at, value ? 1 : 0);
|
|
214
|
+
},
|
|
215
|
+
read(view, at) {
|
|
216
|
+
return view.getUint8(at) !== 0;
|
|
217
|
+
},
|
|
218
|
+
near: (a, b) => a === b,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/** FNV-1a of a string, as 8 hex digits. */
|
|
222
|
+
function fnv(text) {
|
|
223
|
+
let hash = 0x811c9dc5;
|
|
224
|
+
for (let i = 0; i < text.length; i += 1)
|
|
225
|
+
hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193);
|
|
226
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* The binary wire's version, part of every fingerprint. 2: snapshots carry
|
|
230
|
+
* the host's instance. A peer at another version has another fingerprint
|
|
231
|
+
* for the same schema, so the pair falls back to JSON.
|
|
232
|
+
*/
|
|
233
|
+
const BINARY_WIRE_VERSION = 2;
|
|
234
|
+
/**
|
|
235
|
+
* An entity codec from a schema: one field codec per property, written in
|
|
236
|
+
* the schema's key order. The fingerprint covers names, order and specs,
|
|
237
|
+
* and the binary wire's version.
|
|
238
|
+
*/
|
|
239
|
+
export function entityCodec(fields) {
|
|
240
|
+
const entries = Object.entries(fields);
|
|
241
|
+
if (entries.length === 0)
|
|
242
|
+
throw new RangeError('an entity codec needs at least one field');
|
|
243
|
+
const names = new Set(entries.map(([name]) => name));
|
|
244
|
+
const offsets = [];
|
|
245
|
+
let size = 0;
|
|
246
|
+
for (const [, field] of entries) {
|
|
247
|
+
offsets.push(size);
|
|
248
|
+
size += field.size;
|
|
249
|
+
}
|
|
250
|
+
const fingerprint = fnv(`v${BINARY_WIRE_VERSION};${entries.map(([name, field]) => `${name}:${field.spec}`).join(';')}`);
|
|
251
|
+
const blended = entries.filter(([, field]) => field.blend !== undefined);
|
|
252
|
+
return {
|
|
253
|
+
size,
|
|
254
|
+
fingerprint,
|
|
255
|
+
write(entity, view, at) {
|
|
256
|
+
const record = entity;
|
|
257
|
+
for (const key in record) {
|
|
258
|
+
if (Object.prototype.hasOwnProperty.call(record, key) && !names.has(key)) {
|
|
259
|
+
throw new TypeError(`field '${key}' is not in the codec's schema`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
entries.forEach(([name, field], i) => {
|
|
263
|
+
try {
|
|
264
|
+
field.write(view, at + offsets[i], record[name]);
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
throw new TypeError(`field '${name}': ${error.message}`);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
},
|
|
271
|
+
read(view, at) {
|
|
272
|
+
const out = {};
|
|
273
|
+
entries.forEach(([name, field], i) => {
|
|
274
|
+
out[name] = field.read(view, at + offsets[i]);
|
|
275
|
+
});
|
|
276
|
+
return out;
|
|
277
|
+
},
|
|
278
|
+
near(a, b) {
|
|
279
|
+
const left = a;
|
|
280
|
+
const right = b;
|
|
281
|
+
return entries.every(([name, field]) => field.near(left[name], right[name]));
|
|
282
|
+
},
|
|
283
|
+
interpolate(from, to, alpha) {
|
|
284
|
+
const out = lerpNumbers(from, to, alpha);
|
|
285
|
+
if (blended.length === 0 || out === null || typeof out !== 'object')
|
|
286
|
+
return out;
|
|
287
|
+
const left = from;
|
|
288
|
+
const right = to;
|
|
289
|
+
const result = out;
|
|
290
|
+
for (const [name, field] of blended) {
|
|
291
|
+
const a = left[name];
|
|
292
|
+
const b = right[name];
|
|
293
|
+
if (typeof a === 'number' && typeof b === 'number')
|
|
294
|
+
result[name] = field.blend(a, b, alpha);
|
|
295
|
+
}
|
|
296
|
+
return out;
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
// ── The binary snapshot frame ──────────────────────────────────────────
|
|
301
|
+
/** First byte of a binary netcode frame. Never `{` (0x7b), so it cannot be JSON. */
|
|
302
|
+
export const BINARY_FRAME_MAGIC = 0xb1;
|
|
303
|
+
/** Kind byte: a snapshot. */
|
|
304
|
+
const KIND_SNAPSHOT = 1;
|
|
305
|
+
/**
|
|
306
|
+
* Kind byte: a snapshot stamped with an assignment epoch (a varint after
|
|
307
|
+
* the tick). A kind of its own, so a decoder that predates epochs refuses
|
|
308
|
+
* the frame rather than reading the epoch as the time.
|
|
309
|
+
*/
|
|
310
|
+
const KIND_SNAPSHOT_EPOCH = 2;
|
|
311
|
+
/**
|
|
312
|
+
* Kind bytes: the same two, with the host's instance (a 32-bit number,
|
|
313
|
+
* four bytes) right after the kind byte. Wire version 2.
|
|
314
|
+
*/
|
|
315
|
+
const KIND_SNAPSHOT_INSTANCE = 3;
|
|
316
|
+
const KIND_SNAPSHOT_EPOCH_INSTANCE = 4;
|
|
317
|
+
/** The label's 32-bit FNV-1a: a binary frame's stand-in for the JSON `n`. */
|
|
318
|
+
export function labelHash(label) {
|
|
319
|
+
let hash = 0x811c9dc5;
|
|
320
|
+
for (let i = 0; i < label.length; i += 1)
|
|
321
|
+
hash = Math.imul(hash ^ label.charCodeAt(i), 0x01000193);
|
|
322
|
+
return hash >>> 0;
|
|
323
|
+
}
|
|
324
|
+
const utf8 = new TextEncoder();
|
|
325
|
+
const fromUtf8 = new TextDecoder('utf-8', { fatal: true });
|
|
326
|
+
function varintSize(value) {
|
|
327
|
+
let size = 1;
|
|
328
|
+
while (value >= 0x80) {
|
|
329
|
+
value = Math.floor(value / 128);
|
|
330
|
+
size += 1;
|
|
331
|
+
}
|
|
332
|
+
return size;
|
|
333
|
+
}
|
|
334
|
+
function putVarint(bytes, at, value) {
|
|
335
|
+
while (value >= 0x80) {
|
|
336
|
+
bytes[at++] = (value % 128) | 0x80;
|
|
337
|
+
value = Math.floor(value / 128);
|
|
338
|
+
}
|
|
339
|
+
bytes[at++] = value;
|
|
340
|
+
return at;
|
|
341
|
+
}
|
|
342
|
+
/** `[value, next offset]`, or `null` past the end / over 2^53. */
|
|
343
|
+
function getVarint(bytes, at) {
|
|
344
|
+
let value = 0;
|
|
345
|
+
let scale = 1;
|
|
346
|
+
for (let i = 0; i < 8; i += 1) {
|
|
347
|
+
if (at >= bytes.length)
|
|
348
|
+
return null;
|
|
349
|
+
const byte = bytes[at++];
|
|
350
|
+
value += (byte & 0x7f) * scale;
|
|
351
|
+
if ((byte & 0x80) === 0)
|
|
352
|
+
return Number.isSafeInteger(value) ? [value, at] : null;
|
|
353
|
+
scale *= 128;
|
|
354
|
+
}
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
/** The binary size of one snapshot frame, without building it. */
|
|
358
|
+
export function binarySnapshotSize(frame, names, indexes, entitySize) {
|
|
359
|
+
let size = 1 + 4 + 1 + varintSize(frame.tick) + 8 + varintSize(frame.ack) + varintSize(frame.iv ?? 0);
|
|
360
|
+
if (frame.instance !== undefined)
|
|
361
|
+
size += 4;
|
|
362
|
+
if (frame.ep !== undefined)
|
|
363
|
+
size += varintSize(frame.ep);
|
|
364
|
+
size += varintSize(frame.of ?? 0) + varintSize(frame.c ?? 0);
|
|
365
|
+
let count = 0;
|
|
366
|
+
let nameCount = 0;
|
|
367
|
+
for (const [index, name] of names) {
|
|
368
|
+
nameCount += 1;
|
|
369
|
+
size += varintSize(index) + varintSize(name.length) + name.length;
|
|
370
|
+
}
|
|
371
|
+
size += varintSize(nameCount);
|
|
372
|
+
for (const index of indexes) {
|
|
373
|
+
count += 1;
|
|
374
|
+
size += varintSize(index) + entitySize;
|
|
375
|
+
}
|
|
376
|
+
return size + varintSize(count);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Build one binary snapshot frame. `names` are UTF-8 ids by index;
|
|
380
|
+
* `entities` are by index. Throws if the codec refuses an entity.
|
|
381
|
+
*/
|
|
382
|
+
export function encodeBinarySnapshot(label, frame, names, entities, codec) {
|
|
383
|
+
const instance = frame.instance;
|
|
384
|
+
if (instance !== undefined && !(Number.isInteger(instance) && instance >= 0 && instance <= 0xffffffff)) {
|
|
385
|
+
throw new RangeError(`binary snapshot: instance ${String(instance)} is not a 32-bit unsigned integer`);
|
|
386
|
+
}
|
|
387
|
+
const size = binarySnapshotSize(frame, names, entities.keys(), codec.size);
|
|
388
|
+
const bytes = new Uint8Array(size);
|
|
389
|
+
const view = new DataView(bytes.buffer);
|
|
390
|
+
bytes[0] = BINARY_FRAME_MAGIC;
|
|
391
|
+
view.setUint32(1, labelHash(label));
|
|
392
|
+
let at = 6;
|
|
393
|
+
if (instance === undefined) {
|
|
394
|
+
bytes[5] = frame.ep === undefined ? KIND_SNAPSHOT : KIND_SNAPSHOT_EPOCH;
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
bytes[5] = frame.ep === undefined ? KIND_SNAPSHOT_INSTANCE : KIND_SNAPSHOT_EPOCH_INSTANCE;
|
|
398
|
+
view.setUint32(6, instance);
|
|
399
|
+
at = 10;
|
|
400
|
+
}
|
|
401
|
+
at = putVarint(bytes, at, frame.tick);
|
|
402
|
+
if (frame.ep !== undefined)
|
|
403
|
+
at = putVarint(bytes, at, frame.ep);
|
|
404
|
+
view.setFloat64(at, frame.t);
|
|
405
|
+
at += 8;
|
|
406
|
+
at = putVarint(bytes, at, frame.ack);
|
|
407
|
+
at = putVarint(bytes, at, frame.iv ?? 0);
|
|
408
|
+
at = putVarint(bytes, at, frame.of ?? 0);
|
|
409
|
+
at = putVarint(bytes, at, frame.c ?? 0);
|
|
410
|
+
at = putVarint(bytes, at, names.size);
|
|
411
|
+
for (const [index, name] of names) {
|
|
412
|
+
at = putVarint(bytes, at, index);
|
|
413
|
+
at = putVarint(bytes, at, name.length);
|
|
414
|
+
bytes.set(name, at);
|
|
415
|
+
at += name.length;
|
|
416
|
+
}
|
|
417
|
+
at = putVarint(bytes, at, entities.size);
|
|
418
|
+
for (const [index, entity] of entities) {
|
|
419
|
+
at = putVarint(bytes, at, index);
|
|
420
|
+
codec.write(entity, view, at);
|
|
421
|
+
at += codec.size;
|
|
422
|
+
}
|
|
423
|
+
if (at !== size)
|
|
424
|
+
throw new Error(`binary snapshot: wrote ${at} of ${size} bytes`);
|
|
425
|
+
return bytes;
|
|
426
|
+
}
|
|
427
|
+
/** True if `payload` is a binary netcode frame (of any label). */
|
|
428
|
+
export function isBinaryFrame(payload) {
|
|
429
|
+
return payload !== undefined && payload.length > 0 && payload[0] === BINARY_FRAME_MAGIC;
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Read a binary snapshot for `label`, or `null` for another label, another
|
|
433
|
+
* kind, or anything malformed (truncated, trailing bytes, bad chunk fields,
|
|
434
|
+
* a duplicate index, a name that is not UTF-8, an entity the codec will not
|
|
435
|
+
* read, such as a NaN `float32()`).
|
|
436
|
+
*/
|
|
437
|
+
export function decodeBinarySnapshot(payload, label, codec) {
|
|
438
|
+
if (!isBinaryFrame(payload) || payload.length < 6)
|
|
439
|
+
return null;
|
|
440
|
+
const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength);
|
|
441
|
+
const kind = payload[5];
|
|
442
|
+
if (view.getUint32(1) !== labelHash(label))
|
|
443
|
+
return null;
|
|
444
|
+
if (kind !== KIND_SNAPSHOT && kind !== KIND_SNAPSHOT_EPOCH && kind !== KIND_SNAPSHOT_INSTANCE && kind !== KIND_SNAPSHOT_EPOCH_INSTANCE) {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
let instance;
|
|
448
|
+
let start = 6;
|
|
449
|
+
if (kind === KIND_SNAPSHOT_INSTANCE || kind === KIND_SNAPSHOT_EPOCH_INSTANCE) {
|
|
450
|
+
if (payload.length < 10)
|
|
451
|
+
return null;
|
|
452
|
+
instance = view.getUint32(6);
|
|
453
|
+
start = 10;
|
|
454
|
+
}
|
|
455
|
+
const varint = (at) => getVarint(payload, at);
|
|
456
|
+
let read = varint(start);
|
|
457
|
+
if (read === null)
|
|
458
|
+
return null;
|
|
459
|
+
const [tick] = read;
|
|
460
|
+
let at = read[1];
|
|
461
|
+
let ep;
|
|
462
|
+
if (kind === KIND_SNAPSHOT_EPOCH || kind === KIND_SNAPSHOT_EPOCH_INSTANCE) {
|
|
463
|
+
read = varint(at);
|
|
464
|
+
if (read === null)
|
|
465
|
+
return null;
|
|
466
|
+
[ep, at] = read;
|
|
467
|
+
}
|
|
468
|
+
if (at + 8 > payload.length)
|
|
469
|
+
return null;
|
|
470
|
+
const t = view.getFloat64(at);
|
|
471
|
+
if (!Number.isFinite(t))
|
|
472
|
+
return null;
|
|
473
|
+
at += 8;
|
|
474
|
+
const fields = [];
|
|
475
|
+
for (let i = 0; i < 4; i += 1) {
|
|
476
|
+
read = varint(at);
|
|
477
|
+
if (read === null)
|
|
478
|
+
return null;
|
|
479
|
+
fields.push(read[0]);
|
|
480
|
+
at = read[1];
|
|
481
|
+
}
|
|
482
|
+
const [ack, iv, of, c] = fields;
|
|
483
|
+
if (of !== 0 && (of > 256 || c >= of))
|
|
484
|
+
return null;
|
|
485
|
+
if (of === 0 && c !== 0)
|
|
486
|
+
return null;
|
|
487
|
+
read = varint(at);
|
|
488
|
+
if (read === null)
|
|
489
|
+
return null;
|
|
490
|
+
const names = new Map();
|
|
491
|
+
let nameCount = read[0];
|
|
492
|
+
at = read[1];
|
|
493
|
+
for (; nameCount > 0; nameCount -= 1) {
|
|
494
|
+
const index = varint(at);
|
|
495
|
+
if (index === null)
|
|
496
|
+
return null;
|
|
497
|
+
const length = varint(index[1]);
|
|
498
|
+
if (length === null || length[1] + length[0] > payload.length)
|
|
499
|
+
return null;
|
|
500
|
+
let name;
|
|
501
|
+
try {
|
|
502
|
+
name = fromUtf8.decode(payload.subarray(length[1], length[1] + length[0]));
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
if (names.has(index[0]))
|
|
508
|
+
return null;
|
|
509
|
+
names.set(index[0], name);
|
|
510
|
+
at = length[1] + length[0];
|
|
511
|
+
}
|
|
512
|
+
read = varint(at);
|
|
513
|
+
if (read === null)
|
|
514
|
+
return null;
|
|
515
|
+
let count = read[0];
|
|
516
|
+
at = read[1];
|
|
517
|
+
const entities = new Map();
|
|
518
|
+
for (; count > 0; count -= 1) {
|
|
519
|
+
const index = varint(at);
|
|
520
|
+
if (index === null || index[1] + codec.size > payload.length || entities.has(index[0]))
|
|
521
|
+
return null;
|
|
522
|
+
try {
|
|
523
|
+
entities.set(index[0], codec.read(view, index[1]));
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
at = index[1] + codec.size;
|
|
529
|
+
}
|
|
530
|
+
if (at !== payload.length)
|
|
531
|
+
return null;
|
|
532
|
+
return {
|
|
533
|
+
tick,
|
|
534
|
+
t,
|
|
535
|
+
ack,
|
|
536
|
+
...(iv > 0 ? { iv } : {}),
|
|
537
|
+
...(of > 0 ? { c, of } : {}),
|
|
538
|
+
...(ep === undefined ? {} : { ep }),
|
|
539
|
+
...(instance === undefined ? {} : { instance }),
|
|
540
|
+
names,
|
|
541
|
+
entities,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
/** UTF-8 of an id, for {@link encodeBinarySnapshot}'s names. */
|
|
545
|
+
export function nameBytes(id) {
|
|
546
|
+
return utf8.encode(id);
|
|
547
|
+
}
|
package/dist/netcode/host.d.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* ```
|
|
18
18
|
*/
|
|
19
19
|
import { type Frame, type NetcodeTransport, type Now } from './wire.js';
|
|
20
|
+
import { type EntityCodec } from './codec.js';
|
|
20
21
|
/** One input as `step` receives it. */
|
|
21
22
|
export interface TickInput<I> {
|
|
22
23
|
readonly seq: number;
|
|
@@ -85,6 +86,30 @@ export interface HostNetcodeOptions<E, I> {
|
|
|
85
86
|
readonly playerTimeoutMs?: number;
|
|
86
87
|
/** `false` to drive ticks yourself with {@link HostNetcode.tick}. Default `true`. */
|
|
87
88
|
readonly autoTick?: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Compact snapshots (`codec.ts`): players whose `joinNetcode` has a codec
|
|
91
|
+
* with the same fingerprint receive binary snapshots; everyone else JSON.
|
|
92
|
+
* An entity the codec refuses sends that player JSON for that tick
|
|
93
|
+
* (`dropped['codec-write-failed']`).
|
|
94
|
+
*/
|
|
95
|
+
readonly codec?: EntityCodec<E>;
|
|
96
|
+
/**
|
|
97
|
+
* Sharded matches (`joinShards`): the assignment epoch this host is at,
|
|
98
|
+
* read every tick. Each snapshot carries it (`ep`), so a player draws
|
|
99
|
+
* this host's entities only at the epoch it has installed; an input
|
|
100
|
+
* frame stamped with another epoch is dropped (`dropped['stale-epoch']`).
|
|
101
|
+
* An input frame with no epoch is applied, as from any plain player.
|
|
102
|
+
*/
|
|
103
|
+
epoch?(): number;
|
|
104
|
+
/**
|
|
105
|
+
* Sharded matches: whether this host still holds its lease (`leaseWatch`
|
|
106
|
+
* in `assignment.ts`), asked every tick. While `false` the host neither
|
|
107
|
+
* simulates nor sends — a host cut off from the lobby must stop before
|
|
108
|
+
* the lobby gives its entities to another — and inputs that arrive are
|
|
109
|
+
* dropped (`dropped['lease-lost']`), not saved for later. Clock pings are
|
|
110
|
+
* still answered.
|
|
111
|
+
*/
|
|
112
|
+
live?(): boolean;
|
|
88
113
|
readonly now?: Now;
|
|
89
114
|
}
|
|
90
115
|
/** The running host. */
|
|
@@ -101,6 +126,38 @@ export interface HostNetcode<E> {
|
|
|
101
126
|
* never further back than `maxRewindMs` before now.
|
|
102
127
|
*/
|
|
103
128
|
rewind(seen: number): Rewound<E>;
|
|
129
|
+
/**
|
|
130
|
+
* Stop taking `peer`'s inputs, and say how far they were applied: the
|
|
131
|
+
* half of a planned move (sharded matches, S5) that pairs with the
|
|
132
|
+
* entity's frozen state. Call it between ticks — the state you freeze in
|
|
133
|
+
* the same synchronous turn then contains exactly inputs `1..lastApplied`.
|
|
134
|
+
* Later inputs from `peer` are dropped unapplied and unacknowledged
|
|
135
|
+
* (`dropped['released']`), so the player keeps them for the new owner.
|
|
136
|
+
*/
|
|
137
|
+
releasePlayer(peer: string): {
|
|
138
|
+
readonly lastApplied: number;
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* Take `peer`'s inputs from `lastApplied + 1` on: the other half of a
|
|
142
|
+
* planned move. Inputs at or below it — already in the state the entity
|
|
143
|
+
* arrived with — are never applied here.
|
|
144
|
+
*
|
|
145
|
+
* The boundary holds until the player arrives, however late: a player
|
|
146
|
+
* that times out (`playerTimeoutMs`) before its first frame, or that
|
|
147
|
+
* `authorize` refuses for now, keeps it for when it is admitted (the
|
|
148
|
+
* refusal is counted in `dropped['adopt-unauthorized']`).
|
|
149
|
+
*
|
|
150
|
+
* `{ resume: true }` is the crash-recovery form (S6), for an entity
|
|
151
|
+
* restored from an observation with no boundary to hand over: the first
|
|
152
|
+
* input frame from `peer` sets the boundary at its newest input and none
|
|
153
|
+
* of it is applied. The player's pending inputs are dropped (they were
|
|
154
|
+
* made against a state that was rolled back) and it carries on from there.
|
|
155
|
+
*/
|
|
156
|
+
adoptPlayer(peer: string, from: {
|
|
157
|
+
readonly lastApplied: number;
|
|
158
|
+
} | {
|
|
159
|
+
readonly resume: true;
|
|
160
|
+
}): void;
|
|
104
161
|
/** Frames dropped and why. */
|
|
105
162
|
readonly dropped: Readonly<Record<string, number>>;
|
|
106
163
|
/** The last error a send or stream open raised, for diagnosis; `null` if none. */
|
|
@@ -113,6 +170,6 @@ export interface HostNetcode<E> {
|
|
|
113
170
|
* {@link MAX_SNAPSHOT_CHUNKS}; past that the chunks are sent over-size and
|
|
114
171
|
* ride as fragments).
|
|
115
172
|
*/
|
|
116
|
-
export declare function snapshotFrames<E>(label: string, tick: number, time: number, ack: number, entities: Readonly<Record<string, E>>, maxBytes: number, interestVersion?: number): Frame<E, never>[];
|
|
173
|
+
export declare function snapshotFrames<E>(label: string, tick: number, time: number, ack: number, entities: Readonly<Record<string, E>>, maxBytes: number, interestVersion?: number, epoch?: number): Frame<E, never>[];
|
|
117
174
|
/** Start the authoritative netcode host. */
|
|
118
175
|
export declare function hostNetcode<E, I>(options: HostNetcodeOptions<E, I>): HostNetcode<E>;
|