@aethervtt/shard-net 0.0.0-stage → 0.3.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/server.js ADDED
@@ -0,0 +1,1002 @@
1
+ import {
2
+ ByteReader,
3
+ ByteWriter,
4
+ ChildOf,
5
+ createRowCodec,
6
+ onRemove,
7
+ ShardError
8
+ } from "@aethervtt/shard-core";
9
+ import { FixedTime, LogResource } from "@aethervtt/shard-runtime";
10
+ import {
11
+ Focus,
12
+ NetId,
13
+ NetRole,
14
+ PeerJoined,
15
+ PeerLeft,
16
+ Peers,
17
+ PlayerSaveFailed,
18
+ Relevance,
19
+ Replicated
20
+ } from "./components.js";
21
+ import { RelevanceSet } from "./interest.js";
22
+ import { NumberList } from "./vec.js";
23
+ import {
24
+ badMessage,
25
+ describeManifest,
26
+ Fragmenter,
27
+ header,
28
+ Msg,
29
+ PROTOCOL_VERSION,
30
+ Section
31
+ } from "./wire.js";
32
+ const PACKET_RING = 64;
33
+ class ServerCommand {
34
+ def;
35
+ peer;
36
+ seq;
37
+ data;
38
+ arrivedTick;
39
+ status = "pending";
40
+ error;
41
+ answer;
42
+ constructor(def, peer, seq, data, arrivedTick, answer) {
43
+ this.def = def;
44
+ this.peer = peer;
45
+ this.seq = seq;
46
+ this.data = data;
47
+ this.arrivedTick = arrivedTick;
48
+ this.answer = answer;
49
+ }
50
+ accept() {
51
+ if (this.status !== "pending") return;
52
+ this.status = "accepted";
53
+ this.answer(this);
54
+ }
55
+ reject(code, message = code, hint) {
56
+ if (this.status !== "pending") return;
57
+ this.status = "rejected";
58
+ this.error = new ShardError(code, message, hint === void 0 ? {} : { hint });
59
+ this.answer(this);
60
+ }
61
+ }
62
+ class PeerState {
63
+ id;
64
+ info;
65
+ connection;
66
+ fragmenter = new Fragmenter();
67
+ inbox = [];
68
+ known = /* @__PURE__ */ new Map();
69
+ relevant = new RelevanceSet();
70
+ /** Last tick's relevant set, for hysteresis (`knows`). */
71
+ previous = new RelevanceSet();
72
+ focus = [];
73
+ packets = [];
74
+ answers = [];
75
+ nextSeq = 1;
76
+ closed;
77
+ /** When the join started (config.now), for the timeout. */
78
+ joinStarted = 0;
79
+ /** Command rate limit: tokens refill at commandsPerSecond. */
80
+ tokens;
81
+ lastRefill = 0;
82
+ stats = {
83
+ bytesSent: 0,
84
+ bytesByComponent: {},
85
+ deferred: 0,
86
+ resends: 0,
87
+ commands: 0,
88
+ rejected: 0
89
+ };
90
+ constructor(connection, id, tick, tokens) {
91
+ this.id = id;
92
+ this.connection = connection;
93
+ this.tokens = tokens;
94
+ this.info = {
95
+ id,
96
+ state: "handshake",
97
+ rtt: 0,
98
+ token: "",
99
+ user: void 0,
100
+ joinedTick: tick
101
+ };
102
+ for (let i = 0; i < PACKET_RING; i++) {
103
+ this.packets.push({
104
+ seq: 0,
105
+ acked: true,
106
+ netIds: new NumberList(),
107
+ comps: new NumberList(),
108
+ ticks: new NumberList()
109
+ });
110
+ }
111
+ }
112
+ /** Whether the peer had the entity after the last network tick. */
113
+ knows(entity) {
114
+ return this.previous.has(entity);
115
+ }
116
+ }
117
+ class NetServer {
118
+ world;
119
+ config;
120
+ peers = /* @__PURE__ */ new Map();
121
+ /** The same peers as an array, so per-frame loops don't allocate iterators. */
122
+ peerList = [];
123
+ byNetId = /* @__PURE__ */ new Map();
124
+ commands = [];
125
+ /** Recent commands for `net.commands`. */
126
+ recentCommands = [];
127
+ recentEvents = [];
128
+ nextNetId = 1;
129
+ nextPeer = 1;
130
+ /** Joins whose async accept/load finished, applied in the next receive. */
131
+ settled = [];
132
+ saveFailures = [];
133
+ stores = /* @__PURE__ */ new Set();
134
+ incoming = [];
135
+ events = [];
136
+ codecs;
137
+ commandCodecs;
138
+ eventCodecs;
139
+ commandIndex = /* @__PURE__ */ new Map();
140
+ eventIndex = /* @__PURE__ */ new Map();
141
+ reader = new ByteReader();
142
+ out = new ByteWriter(4096);
143
+ state = new ByteWriter(4096);
144
+ spawns = new ByteWriter(1024);
145
+ sets = new ByteWriter(4096);
146
+ removes = new ByteWriter(256);
147
+ despawns = new ByteWriter(256);
148
+ tail = new ByteWriter(1024);
149
+ current;
150
+ pendingRef = false;
151
+ despawnCount = 0;
152
+ lastNetTick = -1;
153
+ unassigned;
154
+ replicatedQuery;
155
+ focusQuery;
156
+ relevanceQuery;
157
+ newEntities = [];
158
+ candidates = new NumberList();
159
+ scores = new NumberList();
160
+ order = new Uint32Array(64);
161
+ despawnKnown;
162
+ listener;
163
+ unsubscribe;
164
+ constructor(world, config) {
165
+ this.world = world;
166
+ this.config = config;
167
+ const mapping = {
168
+ toWire: (e) => this.toWire(e),
169
+ fromWire: (id) => this.fromWire(id)
170
+ };
171
+ const m = config.manifest;
172
+ this.codecs = m.components.map(
173
+ (c) => createRowCodec(c.def, { quantize: c.quantize, entities: mapping })
174
+ );
175
+ this.commandCodecs = m.commands.map((c) => createRowCodec(c.schema, { entities: mapping }));
176
+ this.eventCodecs = m.events.map((e) => createRowCodec(e.schema, { entities: mapping }));
177
+ m.commands.forEach((c, i) => {
178
+ this.commandIndex.set(c.name, i);
179
+ });
180
+ m.events.forEach((e, i) => {
181
+ this.eventIndex.set(e.name, i);
182
+ });
183
+ this.unassigned = world.query({ with: [Replicated], without: [NetId] });
184
+ this.replicatedQuery = world.query({ with: [Replicated] });
185
+ this.focusQuery = world.query({ with: [Focus] });
186
+ this.relevanceQuery = world.query({ with: [Relevance, Replicated] });
187
+ this.despawnKnown = (known) => {
188
+ const peer = this.current;
189
+ if (this.world.isAlive(known.entity) && peer.relevant.has(known.entity)) return;
190
+ this.despawns.varuint(known.netId);
191
+ this.despawnCount++;
192
+ peer.known.delete(known.netId);
193
+ };
194
+ this.unsubscribe = world.observe(onRemoveNetId, (event) => {
195
+ const id = readNetId(world, event.entity);
196
+ if (id !== void 0) this.byNetId.delete(id);
197
+ });
198
+ world.insertResource(Peers, /* @__PURE__ */ new Map());
199
+ }
200
+ attach(listener) {
201
+ this.listener = listener;
202
+ listener.onConnection((connection) => {
203
+ const tick = this.world.resource(FixedTime).tick;
204
+ const peer = new PeerState(connection, this.nextPeer++, tick, this.config.commandsPerSecond);
205
+ connection.onMessage((channel, bytes) => peer.inbox.push({ channel, bytes }));
206
+ connection.onClose((reason) => {
207
+ peer.closed = reason?.message ?? "disconnected";
208
+ });
209
+ this.incoming.push(peer);
210
+ });
211
+ }
212
+ /** Saves every connected player (0090), closes their connections, and waits for the stores. */
213
+ async dispose() {
214
+ for (const peer of [...this.peerList]) {
215
+ if (peer.info.state === "connected") {
216
+ this.sendDisconnect(peer, "server stopped");
217
+ this.drop(peer, "server stopped");
218
+ } else peer.connection.close("server stopped");
219
+ }
220
+ await this.flush();
221
+ this.unsubscribe?.();
222
+ await this.listener?.close();
223
+ }
224
+ /** Resolves when every player store in flight has settled. */
225
+ async flush() {
226
+ while (this.stores.size > 0) await Promise.allSettled([...this.stores]);
227
+ }
228
+ // --- entity ids ----------------------------------------------------------
229
+ toWire(entity) {
230
+ const world = this.world;
231
+ if (!world.isAlive(entity) || !world.has(entity, NetId)) return 0;
232
+ const id = world.entityTable(entity).column(NetId, "id")[world.entityRow(entity)];
233
+ const peer = this.current;
234
+ if (!peer || peer.known.has(id)) return id;
235
+ if (peer.relevant.has(entity)) {
236
+ this.pendingRef = true;
237
+ return id;
238
+ }
239
+ return 0;
240
+ }
241
+ fromWire(id) {
242
+ if (id === 0) return null;
243
+ const peer = this.current;
244
+ if (peer && !peer.known.has(id)) return void 0;
245
+ return this.byNetId.get(id);
246
+ }
247
+ netIdOf(entity) {
248
+ return readNetId(this.world, entity);
249
+ }
250
+ // --- receiving -----------------------------------------------------------
251
+ receive() {
252
+ const world = this.world;
253
+ const tick = world.resource(FixedTime).tick;
254
+ for (let i = 0; i < this.incoming.length; i++) {
255
+ const peer = this.incoming[i];
256
+ this.peers.set(peer.id, peer);
257
+ this.peerList.push(peer);
258
+ }
259
+ this.incoming.length = 0;
260
+ this.settleJoins();
261
+ for (const f of this.saveFailures.splice(0)) {
262
+ world.send(PlayerSaveFailed, {
263
+ peer: f.peer,
264
+ user: f.user,
265
+ code: f.error.code,
266
+ message: f.error.message
267
+ });
268
+ }
269
+ const peers = this.peerList;
270
+ for (let p = 0; p < peers.length; p++) {
271
+ const peer = peers[p];
272
+ peer.connection.pump?.();
273
+ this.current = peer;
274
+ const inbox = peer.inbox;
275
+ for (let i = 0; i < inbox.length; i++) {
276
+ try {
277
+ this.handle(peer, inbox[i].bytes, tick);
278
+ } catch (err) {
279
+ const error = err instanceof ShardError ? err : badMessage(String(err));
280
+ this.refuse(peer, error);
281
+ }
282
+ if (peer.closed) break;
283
+ }
284
+ inbox.length = 0;
285
+ this.current = void 0;
286
+ if (peer.closed) {
287
+ this.drop(peer, peer.closed);
288
+ p--;
289
+ }
290
+ }
291
+ }
292
+ handle(peer, bytes, tick) {
293
+ const r = this.reader;
294
+ r.reset(bytes);
295
+ const kind = r.u8();
296
+ const sentTick = r.u32();
297
+ if (kind === Msg.fragment) {
298
+ const whole = peer.fragmenter.receive(r);
299
+ if (whole) this.handle(peer, whole, tick);
300
+ return;
301
+ }
302
+ if (kind === Msg.hello) {
303
+ this.hello(peer, r, tick);
304
+ return;
305
+ }
306
+ if (peer.info.state !== "connected") throw badMessage("message before handshake");
307
+ switch (kind) {
308
+ case Msg.ping: {
309
+ const clientTime = r.f64();
310
+ header(this.out, Msg.pong, tick);
311
+ this.out.f64(clientTime);
312
+ this.out.u32(sentTick);
313
+ peer.connection.send("unreliable", this.out.finish());
314
+ return;
315
+ }
316
+ case Msg.ack:
317
+ this.ack(peer, r);
318
+ return;
319
+ case Msg.command:
320
+ this.command(peer, r, tick);
321
+ return;
322
+ case Msg.disconnect:
323
+ peer.closed = r.remaining > 0 ? r.string() : "left";
324
+ return;
325
+ default:
326
+ throw badMessage(`unknown kind ${kind}`);
327
+ }
328
+ }
329
+ hello(peer, r, tick) {
330
+ const protocol = r.u16();
331
+ const hash = r.u32();
332
+ const token = r.string();
333
+ const types = JSON.parse(r.string());
334
+ const m = this.config.manifest;
335
+ if (protocol !== PROTOCOL_VERSION) {
336
+ this.refuse(
337
+ peer,
338
+ new ShardError(
339
+ "net/schema-mismatch",
340
+ `Protocol ${protocol}, server runs ${PROTOCOL_VERSION}`
341
+ )
342
+ );
343
+ return;
344
+ }
345
+ if (hash !== m.hash) {
346
+ const mine = describeManifest(m);
347
+ const differs = mine.find((line) => !types.includes(line)) ?? types.find((line) => !mine.includes(line));
348
+ this.refuse(
349
+ peer,
350
+ new ShardError(
351
+ "net/schema-mismatch",
352
+ `Client and server disagree on ${differs ?? "their replicated types"}`,
353
+ { hint: "Run the same build of the game on both sides." }
354
+ )
355
+ );
356
+ return;
357
+ }
358
+ let taken = 0;
359
+ for (const p of this.peerList) if (p.info.state !== "handshake") taken++;
360
+ if (taken >= this.config.maxPeers) {
361
+ this.refuse(peer, new ShardError("net/full", "The server is full"));
362
+ return;
363
+ }
364
+ peer.info.state = "joining";
365
+ peer.info.token = token;
366
+ peer.joinStarted = this.config.now();
367
+ this.join(peer, token, tick);
368
+ }
369
+ // --- joining and leaving (0090) -------------------------------------------
370
+ join(peer, token, tick) {
371
+ let result;
372
+ try {
373
+ result = this.config.accept?.(peer.id, token, this.world);
374
+ } catch (err) {
375
+ this.finishJoin(peer, { peer, error: failure(err, "net/accept-failed") }, tick);
376
+ return;
377
+ }
378
+ if (!isPromise(result) && !this.config.players?.load) {
379
+ this.finishJoin(peer, outcome(peer, result), tick);
380
+ return;
381
+ }
382
+ void this.joinAsync(peer, token, result);
383
+ }
384
+ async joinAsync(peer, token, result) {
385
+ let accepted;
386
+ try {
387
+ accepted = await result;
388
+ } catch (err) {
389
+ this.settled.push({ peer, error: failure(err, "net/accept-failed") });
390
+ return;
391
+ }
392
+ const first = outcome(peer, accepted);
393
+ const load = this.config.players?.load;
394
+ if (first.error || !load || peer.closed) {
395
+ this.settled.push(first);
396
+ return;
397
+ }
398
+ try {
399
+ const data = await load({ peer: peer.id, user: first.user, token });
400
+ this.settled.push({ peer, user: first.user, data });
401
+ } catch (err) {
402
+ this.settled.push({ peer, error: failure(err, "net/load-failed") });
403
+ }
404
+ }
405
+ /** Applies finished joins, and times out the slow ones. */
406
+ settleJoins() {
407
+ const tick = this.world.resource(FixedTime).tick;
408
+ for (const result of this.settled.splice(0)) this.finishJoin(result.peer, result, tick);
409
+ const now = this.config.now();
410
+ for (const peer of this.peerList) {
411
+ if (peer.info.state === "joining" && !peer.closed && now - peer.joinStarted > this.config.joinTimeout) {
412
+ this.refuse(
413
+ peer,
414
+ new ShardError(
415
+ "net/join-timeout",
416
+ `Joining took over ${this.config.joinTimeout / 1e3} s`,
417
+ {
418
+ hint: "accept and players.load must settle within joinTimeout."
419
+ }
420
+ )
421
+ );
422
+ }
423
+ }
424
+ }
425
+ finishJoin(peer, result, tick) {
426
+ if (peer.closed || peer.info.state !== "joining" || !this.peers.has(peer.id)) return;
427
+ if (result.error) {
428
+ this.refuse(peer, result.error);
429
+ return;
430
+ }
431
+ const user = result.user;
432
+ peer.info.user = user;
433
+ try {
434
+ this.config.players?.spawn?.(this.world, {
435
+ peer: peer.id,
436
+ user,
437
+ token: peer.info.token,
438
+ data: result.data
439
+ });
440
+ } catch (err) {
441
+ this.refuse(peer, failure(err, "net/spawn-failed"));
442
+ return;
443
+ }
444
+ peer.info.state = "connected";
445
+ peer.info.joinedTick = tick;
446
+ this.world.resource(Peers).set(peer.id, peer.info);
447
+ header(this.out, Msg.welcome, tick);
448
+ this.out.varuint(peer.id);
449
+ this.out.u16(this.config.ticksPerNet);
450
+ peer.connection.send("reliable", this.out.finish());
451
+ this.world.send(PeerJoined, { peer: peer.id, user });
452
+ }
453
+ ownedBy(peer) {
454
+ const owned = [];
455
+ for (const table of this.replicatedQuery.tables) {
456
+ const owner = table.column(Replicated, "owner");
457
+ for (let row = 0; row < table.count; row++) {
458
+ if (owner[row] === peer) owned.push(table.entities[row]);
459
+ }
460
+ }
461
+ return owned;
462
+ }
463
+ /** Captures a connected player's data and starts its store. Undefined without hooks. */
464
+ save(peer) {
465
+ const state = this.peers.get(peer);
466
+ if (state?.info.state !== "connected") {
467
+ throw new ShardError("net/not-connected", `Peer ${peer} isn't connected`);
468
+ }
469
+ return this.captureAndStore(state, this.ownedBy(peer));
470
+ }
471
+ captureAndStore(peer, owned) {
472
+ const players = this.config.players;
473
+ if (!players?.capture && !players?.store) return void 0;
474
+ const user = peer.info.user;
475
+ let data;
476
+ try {
477
+ data = players.capture?.(this.world, { peer: peer.id, user, owned });
478
+ } catch (err) {
479
+ this.saveFailed(peer.id, user, failure(err, "net/save-failed"));
480
+ return void 0;
481
+ }
482
+ if (!players.store) return void 0;
483
+ let stored;
484
+ try {
485
+ stored = Promise.resolve(players.store({ peer: peer.id, user, data }));
486
+ } catch (err) {
487
+ stored = Promise.reject(err);
488
+ }
489
+ const tracked = stored.then(
490
+ () => void 0,
491
+ (err) => this.saveFailed(peer.id, user, failure(err, "net/save-failed"))
492
+ );
493
+ this.stores.add(tracked);
494
+ void tracked.finally(() => this.stores.delete(tracked));
495
+ return stored;
496
+ }
497
+ saveFailed(peer, user, error) {
498
+ this.saveFailures.push({ peer, user, error });
499
+ this.world.tryResource(LogResource)?.error(error);
500
+ }
501
+ /** A connected peer left: save it, despawn what it owned, tell the game. */
502
+ leave(peer, reason) {
503
+ const owned = this.ownedBy(peer.id);
504
+ this.captureAndStore(peer, owned);
505
+ if (this.config.players && (this.config.players.despawn ?? true)) {
506
+ for (const e of owned) if (this.world.isAlive(e)) this.world.despawn(e);
507
+ }
508
+ this.world.send(PeerLeft, { peer: peer.id, reason, user: peer.info.user });
509
+ }
510
+ refuse(peer, error) {
511
+ header(this.out, Msg.refused, this.world.resource(FixedTime).tick);
512
+ this.out.string(error.code);
513
+ this.out.string(error.message);
514
+ this.out.string(error.hint ?? "");
515
+ peer.connection.send("reliable", this.out.finish());
516
+ peer.connection.close(error.message);
517
+ peer.closed = error.code;
518
+ }
519
+ drop(peer, reason) {
520
+ this.peers.delete(peer.id);
521
+ const index = this.peerList.indexOf(peer);
522
+ if (index !== -1) this.peerList.splice(index, 1);
523
+ const wasConnected = this.world.resource(Peers).delete(peer.id);
524
+ for (const cmd of this.commands) if (cmd.peer === peer.id) cmd.reject("net/peer-left");
525
+ if (wasConnected) this.leave(peer, reason);
526
+ peer.info.state = "handshake";
527
+ }
528
+ sendDisconnect(peer, reason) {
529
+ header(this.out, Msg.disconnect, this.world.resource(FixedTime).tick);
530
+ this.out.string(reason);
531
+ peer.connection.send("reliable", this.out.finish());
532
+ peer.connection.close(reason);
533
+ }
534
+ /** Disconnects a peer (`net.disconnect`). */
535
+ disconnect(peer, reason = "disconnected by the server") {
536
+ const state = this.peers.get(peer);
537
+ if (!state) return false;
538
+ this.sendDisconnect(state, reason);
539
+ this.drop(state, reason);
540
+ return true;
541
+ }
542
+ ack(peer, r) {
543
+ const latest = r.varuint();
544
+ const bits = r.u32();
545
+ const rtt = r.f64();
546
+ if (rtt > 0) peer.info.rtt = rtt;
547
+ for (let i = 0; i <= 32; i++) {
548
+ if (i > 0 && (bits & 1 << i - 1) === 0) continue;
549
+ const seq = latest - i;
550
+ if (seq <= 0) break;
551
+ const record = peer.packets[seq % PACKET_RING];
552
+ if (record.seq !== seq || record.acked) continue;
553
+ record.acked = true;
554
+ for (let k = 0; k < record.netIds.length; k++) {
555
+ const known = peer.known.get(record.netIds.at(k));
556
+ if (!known) continue;
557
+ const c = record.comps.at(k);
558
+ const t = record.ticks.at(k);
559
+ if (t > known.acked[c]) known.acked[c] = t;
560
+ }
561
+ }
562
+ }
563
+ command(peer, r, tick) {
564
+ const seq = r.varuint();
565
+ const index = r.varuint();
566
+ const def = this.config.manifest.commands[index];
567
+ peer.stats.commands++;
568
+ const answer = (cmd2) => this.answer(cmd2);
569
+ if (!def) {
570
+ const cmd2 = new ServerCommand(unknownDef, peer.id, seq, {}, tick, answer);
571
+ cmd2.reject("net/unknown-command", `Command #${index} isn't in the server's manifest`);
572
+ return;
573
+ }
574
+ const now = this.config.now();
575
+ const rate = this.config.commandsPerSecond;
576
+ peer.tokens = Math.min(rate, peer.tokens + (now - peer.lastRefill) / 1e3 * rate);
577
+ peer.lastRefill = now;
578
+ const data = this.commandCodecs[index].decode(r);
579
+ const cmd = new ServerCommand(def, peer.id, seq, data ?? {}, tick, answer);
580
+ this.remember(cmd);
581
+ if (peer.tokens < 1) {
582
+ cmd.reject("net/command-rate", `Over ${rate} commands per second`);
583
+ return;
584
+ }
585
+ peer.tokens--;
586
+ if (data === void 0) {
587
+ cmd.reject("net/unknown-net-id", "The command names an entity you do not have");
588
+ return;
589
+ }
590
+ const errors = def.schema.validate(def.schema.serialize(data));
591
+ if (errors.length > 0) {
592
+ const e = errors[0];
593
+ cmd.reject(e.code, e.message, e.hint);
594
+ return;
595
+ }
596
+ this.commands.push(cmd);
597
+ }
598
+ remember(cmd) {
599
+ this.recentCommands.push(cmd);
600
+ if (this.recentCommands.length > 64) this.recentCommands.shift();
601
+ }
602
+ /** Delivers a command from the server's own world (single-player, or a host's local player). */
603
+ local(def, data, onAnswer) {
604
+ const tick = this.world.resource(FixedTime).tick;
605
+ const cmd = new ServerCommand(def, 0, 0, data, tick, onAnswer);
606
+ const errors = def.schema.validate(def.schema.serialize(data));
607
+ this.remember(cmd);
608
+ if (errors.length > 0) cmd.reject(errors[0].code, errors[0].message, errors[0].hint);
609
+ else this.commands.push(cmd);
610
+ return cmd;
611
+ }
612
+ answer(cmd) {
613
+ if (cmd.peer === 0) return;
614
+ const peer = this.peers.get(cmd.peer);
615
+ if (cmd.status === "rejected") {
616
+ if (peer) peer.stats.rejected++;
617
+ }
618
+ peer?.answers.push({ seq: cmd.seq, error: cmd.error });
619
+ }
620
+ /** Commands of one type still waiting for an answer. */
621
+ pending(def) {
622
+ const out = [];
623
+ for (const cmd of this.commands) if (cmd.def === def && cmd.status === "pending") out.push(cmd);
624
+ return out;
625
+ }
626
+ // --- events --------------------------------------------------------------
627
+ sendEvent(def, data, to) {
628
+ const index = this.eventIndex.get(def.name);
629
+ if (index === void 0) {
630
+ throw new ShardError("net/unknown-event", `${def.name} isn't in netPlugin({ events })`, {
631
+ hint: "List every net event in the plugin options on both sides."
632
+ });
633
+ }
634
+ this.events.push({ index, data, to });
635
+ const tick = this.world.resource(FixedTime).tick;
636
+ this.recentEvents.push({
637
+ name: def.name,
638
+ tick,
639
+ to: typeof to === "function" ? "filter" : String(to)
640
+ });
641
+ if (this.recentEvents.length > 64) this.recentEvents.shift();
642
+ this.world.send(def.event, { data, tick });
643
+ }
644
+ commandIndexOf(def) {
645
+ return this.commandIndex.get(def.name);
646
+ }
647
+ // --- sending -------------------------------------------------------------
648
+ /** Runs in Last: answers unhandled commands, and on network ticks sends state to every peer. */
649
+ send() {
650
+ const world = this.world;
651
+ const fixed = world.resource(FixedTime);
652
+ for (const cmd of this.commands) {
653
+ if (cmd.status === "pending" && fixed.tick > cmd.arrivedTick) {
654
+ cmd.reject("net/unhandled-command", `No system handled ${cmd.def.name}`);
655
+ }
656
+ }
657
+ if (this.commands.length > 0) {
658
+ let n = 0;
659
+ for (const cmd of this.commands) if (cmd.status === "pending") this.commands[n++] = cmd;
660
+ this.commands.length = n;
661
+ }
662
+ const netTick = Math.floor(fixed.tick / this.config.ticksPerNet);
663
+ if (netTick === this.lastNetTick) return;
664
+ this.lastNetTick = netTick;
665
+ world.resource(NetRole).tick = fixed.tick;
666
+ this.assignIds();
667
+ this.collectFocus();
668
+ this.config.interest.prepare?.(world);
669
+ for (let p = 0; p < this.peerList.length; p++) {
670
+ const peer = this.peerList[p];
671
+ if (peer.info.state !== "connected" || peer.closed) continue;
672
+ this.current = peer;
673
+ this.sendTo(peer, fixed.tick);
674
+ this.current = void 0;
675
+ }
676
+ this.events.length = 0;
677
+ }
678
+ assignIds() {
679
+ const list = this.newEntities;
680
+ list.length = 0;
681
+ for (const table of this.unassigned.tables) {
682
+ for (let row = 0; row < table.count; row++) list.push(table.entities[row]);
683
+ }
684
+ for (const e of list) {
685
+ const id = this.nextNetId++;
686
+ this.world.add(e, NetId, { id });
687
+ this.byNetId.set(id, e);
688
+ }
689
+ }
690
+ collectFocus() {
691
+ for (let p = 0; p < this.peerList.length; p++) this.peerList[p].focus.length = 0;
692
+ for (const table of this.replicatedQuery.tables) {
693
+ const owner = table.column(Replicated, "owner");
694
+ for (let row = 0; row < table.count; row++) {
695
+ if (owner[row] !== 0) this.peers.get(owner[row])?.focus.push(table.entities[row]);
696
+ }
697
+ }
698
+ for (const table of this.focusQuery.tables) {
699
+ const peerId = table.column(Focus, "peer");
700
+ for (let row = 0; row < table.count; row++) {
701
+ this.peers.get(peerId[row])?.focus.push(table.entities[row]);
702
+ }
703
+ }
704
+ }
705
+ keepRelevant = (e) => {
706
+ const world = this.world;
707
+ if (!world.isAlive(e) || !world.has(e, NetId)) return false;
708
+ if (!world.has(e, ChildOf)) return true;
709
+ const parent = world.entityTable(e).column(ChildOf, "parent")[world.entityRow(e)];
710
+ return parent < 0 || !world.isAlive(parent) || !world.has(parent, Replicated) || this.current.relevant.has(parent);
711
+ };
712
+ relevance(peer) {
713
+ const world = this.world;
714
+ const set = peer.previous;
715
+ peer.previous = peer.relevant;
716
+ peer.relevant = set;
717
+ set.clear();
718
+ this.config.interest.update(world, peer, set);
719
+ for (const table of this.relevanceQuery.tables) {
720
+ const always = table.column(Relevance, "always");
721
+ for (let row = 0; row < table.count; row++) {
722
+ if (always[row] === 1) set.add(table.entities[row]);
723
+ }
724
+ }
725
+ for (let pass = 0; pass < 8; pass++) {
726
+ const before = set.size;
727
+ set.retain(this.keepRelevant);
728
+ if (set.size === before) break;
729
+ }
730
+ }
731
+ sendTo(peer, tick) {
732
+ const world = this.world;
733
+ const config = this.config;
734
+ const comps = config.manifest.components;
735
+ this.relevance(peer);
736
+ this.spawns.reset();
737
+ this.sets.reset();
738
+ this.removes.reset();
739
+ this.despawns.reset();
740
+ let spawnCount = 0;
741
+ let setCount = 0;
742
+ let removeCount = 0;
743
+ this.despawnCount = 0;
744
+ peer.known.forEach(this.despawnKnown);
745
+ const despawnCount = this.despawnCount;
746
+ const resendAfter = Math.max(
747
+ config.ticksPerNet * 2,
748
+ Math.ceil(peer.info.rtt / 1e3 * (1 / world.resource(FixedTime).step)) + config.ticksPerNet
749
+ );
750
+ const candidates = this.candidates;
751
+ const scores = this.scores;
752
+ candidates.clear();
753
+ scores.clear();
754
+ const relevant = peer.relevant;
755
+ for (let i = 0; i < relevant.size; i++) {
756
+ const e = relevant.at(i);
757
+ const table = world.entityTable(e);
758
+ const row = world.entityRow(e);
759
+ const netId = table.column(NetId, "id")[row];
760
+ const known = peer.known.get(netId);
761
+ const priority = table.has(Relevance) ? table.column(Relevance, "priority")[row] : 1;
762
+ if (!known) {
763
+ candidates.push(i);
764
+ scores.push(priority * 1e6);
765
+ continue;
766
+ }
767
+ let due = false;
768
+ for (let c = 0; c < comps.length; c++) {
769
+ const entry = comps[c];
770
+ const has = table.has(entry.def);
771
+ const visible = has && (!entry.visibleTo || entry.visibleTo(world, peer.id, e));
772
+ if (!visible) {
773
+ if (known.present[c]) {
774
+ this.removes.varuint(netId);
775
+ this.removes.varuint(c);
776
+ removeCount++;
777
+ known.present[c] = 0;
778
+ known.sent[c] = 0;
779
+ known.acked[c] = 0;
780
+ }
781
+ continue;
782
+ }
783
+ const changed = table.changedTicks(entry.def)[row];
784
+ if (changed <= known.acked[c] && known.present[c]) continue;
785
+ if (changed > known.sent[c] || !known.present[c] || tick - known.sentAt[c] >= resendAfter) {
786
+ due = true;
787
+ }
788
+ }
789
+ if (due) {
790
+ candidates.push(i);
791
+ scores.push(priority * (tick - known.lastSent + 1));
792
+ }
793
+ }
794
+ const count = candidates.length;
795
+ if (this.order.length < count) this.order = new Uint32Array(count * 2);
796
+ const order = this.order;
797
+ for (let i = 0; i < count; i++) order[i] = i;
798
+ if (Number.isFinite(config.bytesPerTick)) {
799
+ const s = scores.data;
800
+ order.subarray(0, count).sort((a, b) => s[b] - s[a]);
801
+ }
802
+ const state = this.state;
803
+ header(state, Msg.state, tick);
804
+ const seq = peer.nextSeq;
805
+ state.u32(seq);
806
+ const countAt = state.length;
807
+ state.u32(0);
808
+ let stateEntries = 0;
809
+ const record = peer.packets[seq % PACKET_RING];
810
+ record.netIds.clear();
811
+ record.comps.clear();
812
+ record.ticks.clear();
813
+ const stateLimit = config.maxMessage - 64;
814
+ let bytes = 0;
815
+ let deferred = 0;
816
+ for (let k = 0; k < count; k++) {
817
+ if (bytes >= config.bytesPerTick || state.length >= stateLimit) {
818
+ deferred = count - k;
819
+ break;
820
+ }
821
+ const e = relevant.at(candidates.at(order[k]));
822
+ const table = world.entityTable(e);
823
+ const row = world.entityRow(e);
824
+ const netId = table.column(NetId, "id")[row];
825
+ let known = peer.known.get(netId);
826
+ const startBytes = this.spawns.length + this.sets.length + state.length;
827
+ if (!known) {
828
+ known = makeKnown(netId, e, comps.length);
829
+ peer.known.set(netId, known);
830
+ this.spawns.varuint(netId);
831
+ const prefab = table.column(Replicated, "prefab")[row];
832
+ this.spawns.string(prefab ?? "");
833
+ spawnCount++;
834
+ }
835
+ for (let c = 0; c < comps.length; c++) {
836
+ const entry = comps[c];
837
+ if (!table.has(entry.def)) continue;
838
+ if (entry.visibleTo && !entry.visibleTo(world, peer.id, e)) continue;
839
+ const changed = table.changedTicks(entry.def)[row];
840
+ const present = known.present[c] === 1;
841
+ if (present && changed <= known.acked[c]) continue;
842
+ const resend = present && changed <= known.sent[c];
843
+ if (resend && tick - known.sentAt[c] < resendAfter) continue;
844
+ let toReliable = !present || entry.channel === "reliable";
845
+ let w = toReliable ? this.sets : state;
846
+ let before = w.length;
847
+ w.varuint(netId);
848
+ w.varuint(c);
849
+ this.pendingRef = false;
850
+ this.codecs[c].encodeRow(table, row, w);
851
+ if (!toReliable && w.length > config.maxMessage) {
852
+ state.length = before;
853
+ toReliable = true;
854
+ w = this.sets;
855
+ before = w.length;
856
+ w.varuint(netId);
857
+ w.varuint(c);
858
+ this.pendingRef = false;
859
+ this.codecs[c].encodeRow(table, row, w);
860
+ }
861
+ const size = w.length - before;
862
+ peer.stats.bytesByComponent[entry.def.name] = (peer.stats.bytesByComponent[entry.def.name] ?? 0) + size;
863
+ if (resend) peer.stats.resends++;
864
+ known.present[c] = 1;
865
+ known.sentAt[c] = tick;
866
+ if (toReliable) {
867
+ setCount++;
868
+ known.sent[c] = changed;
869
+ known.acked[c] = this.pendingRef ? 0 : changed;
870
+ } else {
871
+ stateEntries++;
872
+ known.sent[c] = changed;
873
+ if (this.pendingRef) known.acked[c] = 0;
874
+ record.netIds.push(netId);
875
+ record.comps.push(c);
876
+ record.ticks.push(this.pendingRef ? 0 : changed);
877
+ }
878
+ }
879
+ known.lastSent = tick;
880
+ bytes += this.spawns.length + this.sets.length + state.length - startBytes;
881
+ }
882
+ peer.stats.deferred = deferred;
883
+ const tail = this.tail;
884
+ tail.reset();
885
+ let tailSections = 0;
886
+ if (peer.answers.length > 0) {
887
+ tail.u8(Section.answer);
888
+ tail.varuint(peer.answers.length);
889
+ for (const a of peer.answers) {
890
+ tail.varuint(a.seq);
891
+ tail.string(a.error?.code ?? "");
892
+ tail.string(a.error?.message ?? "");
893
+ tail.string(a.error?.hint ?? "");
894
+ }
895
+ peer.answers.length = 0;
896
+ tailSections++;
897
+ }
898
+ let eventCount = 0;
899
+ for (const ev of this.events) if (isRecipient(ev.to, peer.id)) eventCount++;
900
+ if (eventCount > 0) {
901
+ tail.u8(Section.event);
902
+ tail.varuint(eventCount);
903
+ for (const ev of this.events) {
904
+ if (!isRecipient(ev.to, peer.id)) continue;
905
+ tail.varuint(ev.index);
906
+ this.eventCodecs[ev.index].encode(ev.data, tail);
907
+ }
908
+ tailSections++;
909
+ }
910
+ if (spawnCount + setCount + removeCount + despawnCount + tailSections > 0) {
911
+ const out = this.out;
912
+ header(out, Msg.reliable, tick);
913
+ if (spawnCount > 0) section(out, Section.spawn, spawnCount, this.spawns);
914
+ if (setCount > 0) section(out, Section.set, setCount, this.sets);
915
+ if (removeCount > 0) section(out, Section.remove, removeCount, this.removes);
916
+ if (despawnCount > 0) section(out, Section.despawn, despawnCount, this.despawns);
917
+ out.bytes(tail.finish());
918
+ out.u8(Section.end);
919
+ peer.stats.bytesSent += out.length;
920
+ peer.fragmenter.send(peer.connection, "reliable", out.finish(), config.maxMessage);
921
+ }
922
+ if (stateEntries > 0) {
923
+ state.patchU32(countAt, stateEntries);
924
+ record.seq = seq;
925
+ record.acked = false;
926
+ peer.nextSeq++;
927
+ peer.stats.bytesSent += state.length;
928
+ peer.connection.send("unreliable", state.finish());
929
+ }
930
+ }
931
+ /** Describes one entity for `net.inspect`. */
932
+ inspect(entity) {
933
+ const world = this.world;
934
+ const replicated = world.tryGet(entity, Replicated);
935
+ const netId = readNetId(world, entity);
936
+ const peers = {};
937
+ for (const peer of this.peers.values()) {
938
+ const known = netId === void 0 ? void 0 : peer.known.get(netId);
939
+ const redacted = [];
940
+ for (const entry of this.config.manifest.components) {
941
+ if (world.has(entity, entry.def) && entry.visibleTo && !entry.visibleTo(world, peer.id, entity)) {
942
+ redacted.push(entry.def.name);
943
+ }
944
+ }
945
+ peers[String(peer.id)] = {
946
+ relevant: peer.relevant.has(entity),
947
+ known: known !== void 0,
948
+ redacted,
949
+ lastSentTick: known?.lastSent ?? null
950
+ };
951
+ }
952
+ return {
953
+ netId: netId ?? null,
954
+ owner: replicated?.owner ?? null,
955
+ prefab: replicated?.prefab ?? null,
956
+ peers
957
+ };
958
+ }
959
+ }
960
+ const unknownDef = { kind: "net-command", name: "net/Unknown" };
961
+ function outcome(peer, result) {
962
+ if (result instanceof ShardError) return { peer, error: result };
963
+ return { peer, user: result?.user };
964
+ }
965
+ function isPromise(value) {
966
+ return typeof value?.then === "function";
967
+ }
968
+ function failure(err, code) {
969
+ if (err instanceof ShardError) return err;
970
+ const message = err instanceof Error ? err.message : String(err);
971
+ return new ShardError(code, message, { cause: err });
972
+ }
973
+ const onRemoveNetId = onRemove(NetId);
974
+ function readNetId(world, entity) {
975
+ if (!world.isAlive(entity) || !world.has(entity, NetId)) return void 0;
976
+ return world.entityTable(entity).column(NetId, "id")[world.entityRow(entity)];
977
+ }
978
+ function makeKnown(netId, entity, count) {
979
+ return {
980
+ netId,
981
+ entity,
982
+ present: new Uint8Array(count),
983
+ sent: new Uint32Array(count),
984
+ acked: new Uint32Array(count),
985
+ sentAt: new Float64Array(count),
986
+ lastSent: 0
987
+ };
988
+ }
989
+ function section(out, kind, count, body) {
990
+ out.u8(kind);
991
+ out.varuint(count);
992
+ out.bytes(body.finish());
993
+ }
994
+ function isRecipient(to, peer) {
995
+ if (to === "all") return true;
996
+ if (typeof to === "number") return to === peer;
997
+ return to(peer);
998
+ }
999
+ export {
1000
+ NetServer,
1001
+ ServerCommand
1002
+ };