@kikorin/netcode 1.0.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/index.cjs ADDED
@@ -0,0 +1,1072 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ ChangeTracker: () => ChangeTracker,
24
+ HEADER_SIZE: () => HEADER_SIZE,
25
+ InterestGroup: () => InterestGroup,
26
+ LeadElector: () => LeadElector,
27
+ MessageFlag: () => MessageFlag,
28
+ MessageType: () => MessageType,
29
+ PeerNet: () => PeerNet,
30
+ PriorityQueue: () => PriorityQueue,
31
+ decodeDeltaPayload: () => decodeDeltaPayload,
32
+ decodeHeader: () => decodeHeader,
33
+ decodeJson: () => decodeJson,
34
+ decodeMessage: () => decodeMessage,
35
+ encodeDeltaPayload: () => encodeDeltaPayload,
36
+ encodeHeader: () => encodeHeader,
37
+ encodeJson: () => encodeJson,
38
+ encodeMessage: () => encodeMessage
39
+ });
40
+ module.exports = __toCommonJS(index_exports);
41
+
42
+ // src/types.ts
43
+ var HEADER_SIZE = 8;
44
+ var MessageType = /* @__PURE__ */ ((MessageType3) => {
45
+ MessageType3[MessageType3["Handshake"] = 1] = "Handshake";
46
+ MessageType3[MessageType3["Subscribe"] = 2] = "Subscribe";
47
+ MessageType3[MessageType3["Unsubscribe"] = 3] = "Unsubscribe";
48
+ MessageType3[MessageType3["DeltaUpdate"] = 4] = "DeltaUpdate";
49
+ MessageType3[MessageType3["FullSync"] = 5] = "FullSync";
50
+ MessageType3[MessageType3["Ack"] = 6] = "Ack";
51
+ MessageType3[MessageType3["Ping"] = 7] = "Ping";
52
+ MessageType3[MessageType3["Pong"] = 8] = "Pong";
53
+ MessageType3[MessageType3["LeadClaim"] = 9] = "LeadClaim";
54
+ MessageType3[MessageType3["LeadYield"] = 10] = "LeadYield";
55
+ MessageType3[MessageType3["PeerList"] = 11] = "PeerList";
56
+ MessageType3[MessageType3["GameEvent"] = 12] = "GameEvent";
57
+ return MessageType3;
58
+ })(MessageType || {});
59
+ var MessageFlag = /* @__PURE__ */ ((MessageFlag3) => {
60
+ MessageFlag3[MessageFlag3["None"] = 0] = "None";
61
+ MessageFlag3[MessageFlag3["Reliable"] = 1] = "Reliable";
62
+ MessageFlag3[MessageFlag3["Compressed"] = 2] = "Compressed";
63
+ MessageFlag3[MessageFlag3["Fragmented"] = 4] = "Fragmented";
64
+ return MessageFlag3;
65
+ })(MessageFlag || {});
66
+
67
+ // src/change-tracker.ts
68
+ var ChangeTracker = class {
69
+ constructor() {
70
+ this._schemas = /* @__PURE__ */ new Map();
71
+ // Flat snapshot per entity (reused across components)
72
+ this._snapshots = /* @__PURE__ */ new Map();
73
+ this._dirtySet = /* @__PURE__ */ new Set();
74
+ }
75
+ registerComponent(schema) {
76
+ if (this._schemas.has(schema.id)) {
77
+ throw new Error(`Component id ${schema.id} already registered`);
78
+ }
79
+ this._schemas.set(schema.id, schema);
80
+ }
81
+ unregisterComponent(componentId) {
82
+ this._schemas.delete(componentId);
83
+ for (const snap of this._snapshots.values()) {
84
+ snap.delete(componentId);
85
+ }
86
+ }
87
+ markDirty(entityId) {
88
+ this._dirtySet.add(entityId);
89
+ }
90
+ markDirtyBatch(entities) {
91
+ for (const eid of entities) this._dirtySet.add(eid);
92
+ }
93
+ get dirtyCount() {
94
+ return this._dirtySet.size;
95
+ }
96
+ /**
97
+ * Compute deltas for a subset of entities and update the snapshot.
98
+ * Only entities that were marked dirty are compared; clean entities are skipped.
99
+ * Clears dirty flags for all entities in the provided list.
100
+ */
101
+ flush(entities) {
102
+ const deltas = [];
103
+ for (const eid of entities) {
104
+ if (!this._dirtySet.has(eid)) continue;
105
+ let entitySnap = this._snapshots.get(eid);
106
+ if (!entitySnap) {
107
+ entitySnap = /* @__PURE__ */ new Map();
108
+ this._snapshots.set(eid, entitySnap);
109
+ }
110
+ for (const [cid, schema] of this._schemas) {
111
+ const fieldCount = schema.fields.length;
112
+ let compSnap = entitySnap.get(cid);
113
+ if (!compSnap) {
114
+ compSnap = new Float64Array(fieldCount).fill(NaN);
115
+ entitySnap.set(cid, compSnap);
116
+ }
117
+ for (let fi = 0; fi < fieldCount; fi++) {
118
+ const field = schema.fields[fi];
119
+ const cur = field.array[eid];
120
+ if (!Object.is(compSnap[fi], cur)) {
121
+ compSnap[fi] = cur;
122
+ deltas.push({ entityId: eid, componentId: cid, fieldId: fi, value: cur });
123
+ }
124
+ }
125
+ }
126
+ this._dirtySet.delete(eid);
127
+ }
128
+ return deltas;
129
+ }
130
+ /**
131
+ * Force-flush all registered fields for the given entities, ignoring dirty state.
132
+ * Use when a new peer joins and needs a full state sync.
133
+ */
134
+ fullSnapshot(entities) {
135
+ const deltas = [];
136
+ for (const eid of entities) {
137
+ for (const [cid, schema] of this._schemas) {
138
+ for (let fi = 0; fi < schema.fields.length; fi++) {
139
+ deltas.push({
140
+ entityId: eid,
141
+ componentId: cid,
142
+ fieldId: fi,
143
+ value: schema.fields[fi].array[eid]
144
+ });
145
+ }
146
+ }
147
+ }
148
+ return deltas;
149
+ }
150
+ /**
151
+ * Invalidate the snapshot for an entity (e.g. after it is destroyed and respawned).
152
+ * Next flush will treat all fields as changed.
153
+ */
154
+ invalidateEntity(entityId) {
155
+ this._snapshots.delete(entityId);
156
+ this._dirtySet.add(entityId);
157
+ }
158
+ clearDirtyAll() {
159
+ this._dirtySet.clear();
160
+ }
161
+ };
162
+
163
+ // src/connection-pool.ts
164
+ var _ConnectionPool = class _ConnectionPool {
165
+ constructor() {
166
+ this._peer = null;
167
+ this._conns = /* @__PURE__ */ new Map();
168
+ this._handlers = /* @__PURE__ */ new Set();
169
+ this._disconnectHandlers = /* @__PURE__ */ new Set();
170
+ // Reconnect state: peerId → current delay ms
171
+ this._reconnectDelay = /* @__PURE__ */ new Map();
172
+ this._reconnectHandles = /* @__PURE__ */ new Map();
173
+ }
174
+ setPeer(peer) {
175
+ this._peer = peer;
176
+ peer.on("connection", (conn) => this._registerConn(conn.peer, conn));
177
+ }
178
+ async connect(peerId) {
179
+ const existing = this._conns.get(peerId);
180
+ if (existing?.state === "open") return;
181
+ if (existing?.state === "pending") return existing.openPromise;
182
+ if (!this._peer) throw new Error("Peer not initialized \u2014 call setPeer() first");
183
+ const conn = this._peer.connect(peerId, { reliable: false, serialization: "binary" });
184
+ this._registerConn(peerId, conn);
185
+ return this._conns.get(peerId).openPromise;
186
+ }
187
+ send(peerId, data) {
188
+ const managed = this._conns.get(peerId);
189
+ if (!managed) {
190
+ void this.connect(peerId).then(() => this.send(peerId, data)).catch(() => {
191
+ });
192
+ return;
193
+ }
194
+ if (managed.state === "open") {
195
+ managed.conn.send(data);
196
+ } else {
197
+ managed.queue.push(data);
198
+ }
199
+ }
200
+ isOpen(peerId) {
201
+ return this._conns.get(peerId)?.state === "open";
202
+ }
203
+ disconnect(peerId) {
204
+ clearTimeout(this._reconnectHandles.get(peerId));
205
+ this._reconnectHandles.delete(peerId);
206
+ this._reconnectDelay.delete(peerId);
207
+ const managed = this._conns.get(peerId);
208
+ if (managed) {
209
+ managed.conn.close();
210
+ this._conns.delete(peerId);
211
+ }
212
+ }
213
+ onData(handler) {
214
+ this._handlers.add(handler);
215
+ return () => this._handlers.delete(handler);
216
+ }
217
+ /** Fires when a connection closes unexpectedly (not from a local disconnect() call). */
218
+ onDisconnect(handler) {
219
+ this._disconnectHandlers.add(handler);
220
+ return () => this._disconnectHandlers.delete(handler);
221
+ }
222
+ dispose() {
223
+ for (const h of this._reconnectHandles.values()) clearTimeout(h);
224
+ this._reconnectHandles.clear();
225
+ for (const m of this._conns.values()) m.conn.close();
226
+ this._conns.clear();
227
+ this._peer?.destroy();
228
+ this._peer = null;
229
+ }
230
+ // ---------------------------------------------------------------------------
231
+ _registerConn(peerId, conn) {
232
+ const existing = this._conns.get(peerId);
233
+ if (existing?.state === "open") return;
234
+ let resolve;
235
+ let reject;
236
+ const openPromise = new Promise((res, rej) => {
237
+ resolve = res;
238
+ reject = rej;
239
+ });
240
+ const managed = { conn, state: "pending", queue: [], resolve, reject, openPromise };
241
+ this._conns.set(peerId, managed);
242
+ conn.on("open", () => {
243
+ managed.state = "open";
244
+ this._reconnectDelay.delete(peerId);
245
+ clearTimeout(this._reconnectHandles.get(peerId));
246
+ this._reconnectHandles.delete(peerId);
247
+ for (const buf of managed.queue) conn.send(buf);
248
+ managed.queue = [];
249
+ resolve();
250
+ });
251
+ conn.on("data", (raw) => {
252
+ const buf = raw instanceof ArrayBuffer ? raw : raw instanceof Uint8Array ? raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength) : null;
253
+ if (!buf) return;
254
+ for (const h of this._handlers) h(buf, peerId);
255
+ });
256
+ conn.on("close", () => {
257
+ managed.state = "closed";
258
+ for (const h of this._disconnectHandlers) h(peerId);
259
+ this._scheduleReconnect(peerId);
260
+ });
261
+ conn.on("error", (err) => {
262
+ managed.state = "error";
263
+ reject(err);
264
+ this._scheduleReconnect(peerId);
265
+ });
266
+ }
267
+ _scheduleReconnect(peerId) {
268
+ if (!this._peer) return;
269
+ const prev = this._reconnectDelay.get(peerId) ?? _ConnectionPool.BASE_DELAY / 2;
270
+ const delay = Math.min(prev * 2, _ConnectionPool.MAX_DELAY);
271
+ this._reconnectDelay.set(peerId, delay);
272
+ clearTimeout(this._reconnectHandles.get(peerId));
273
+ const handle = setTimeout(() => {
274
+ if (this._conns.get(peerId)?.state !== "open") {
275
+ void this.connect(peerId).catch(() => {
276
+ });
277
+ }
278
+ }, delay);
279
+ this._reconnectHandles.set(peerId, handle);
280
+ }
281
+ };
282
+ _ConnectionPool.BASE_DELAY = 500;
283
+ _ConnectionPool.MAX_DELAY = 3e4;
284
+ var ConnectionPool = _ConnectionPool;
285
+
286
+ // src/message-codec.ts
287
+ var POOL_BUFFER_SIZE = 65536;
288
+ var POOL_MAX = 16;
289
+ var _pool = [];
290
+ function acquireBuffer() {
291
+ return _pool.pop() ?? new ArrayBuffer(POOL_BUFFER_SIZE);
292
+ }
293
+ function releaseBuffer(buf) {
294
+ if (buf.byteLength === POOL_BUFFER_SIZE && _pool.length < POOL_MAX) {
295
+ _pool.push(buf);
296
+ }
297
+ }
298
+ function encodeHeader(view, type, flags, seq, ack, payloadLen) {
299
+ view.setUint8(0, type);
300
+ view.setUint8(1, flags);
301
+ view.setUint16(2, seq, true);
302
+ view.setUint16(4, ack, true);
303
+ view.setUint16(6, payloadLen, true);
304
+ }
305
+ function decodeHeader(view) {
306
+ return {
307
+ type: view.getUint8(0),
308
+ flags: view.getUint8(1),
309
+ seq: view.getUint16(2, true),
310
+ ack: view.getUint16(4, true),
311
+ payloadLen: view.getUint16(6, true)
312
+ };
313
+ }
314
+ function encodeMessage(msg) {
315
+ const payloadLen = msg.payload.byteLength;
316
+ const total = HEADER_SIZE + payloadLen;
317
+ const buf = total <= POOL_BUFFER_SIZE ? acquireBuffer() : new ArrayBuffer(total);
318
+ encodeHeader(new DataView(buf), msg.type, msg.flags, msg.seq, msg.ack, payloadLen);
319
+ new Uint8Array(buf, HEADER_SIZE, payloadLen).set(new Uint8Array(msg.payload));
320
+ const out = buf.slice(0, total);
321
+ releaseBuffer(buf);
322
+ return out;
323
+ }
324
+ function decodeMessage(buf) {
325
+ const view = new DataView(buf);
326
+ const { type, flags, seq, ack, payloadLen } = decodeHeader(view);
327
+ return {
328
+ type,
329
+ flags,
330
+ seq,
331
+ ack,
332
+ payload: buf.slice(HEADER_SIZE, HEADER_SIZE + payloadLen)
333
+ };
334
+ }
335
+ function encodeDeltaPayload(groupId, deltas) {
336
+ const enc = new TextEncoder();
337
+ const groupIdBytes = enc.encode(groupId);
338
+ const byEntity = /* @__PURE__ */ new Map();
339
+ for (const { entityId, componentId, fieldId, value } of deltas) {
340
+ let byComp = byEntity.get(entityId);
341
+ if (!byComp) {
342
+ byComp = /* @__PURE__ */ new Map();
343
+ byEntity.set(entityId, byComp);
344
+ }
345
+ let fields = byComp.get(componentId);
346
+ if (!fields) {
347
+ fields = [];
348
+ byComp.set(componentId, fields);
349
+ }
350
+ fields.push([fieldId, value]);
351
+ }
352
+ const maxSize = 1 + groupIdBytes.length + 2 + deltas.length * 12;
353
+ const buf = maxSize <= POOL_BUFFER_SIZE ? acquireBuffer() : new ArrayBuffer(maxSize);
354
+ const view = new DataView(buf);
355
+ let off = 0;
356
+ view.setUint8(off, groupIdBytes.length);
357
+ off += 1;
358
+ new Uint8Array(buf, off, groupIdBytes.length).set(groupIdBytes);
359
+ off += groupIdBytes.length;
360
+ view.setUint16(off, byEntity.size, true);
361
+ off += 2;
362
+ for (const [eid, byComp] of byEntity) {
363
+ view.setUint32(off, eid, true);
364
+ off += 4;
365
+ view.setUint8(off, byComp.size);
366
+ off += 1;
367
+ for (const [cid, fields] of byComp) {
368
+ view.setUint8(off, cid);
369
+ off += 1;
370
+ view.setUint8(off, fields.length);
371
+ off += 1;
372
+ for (const [fid, val] of fields) {
373
+ view.setUint8(off, fid);
374
+ off += 1;
375
+ view.setFloat32(off, val, true);
376
+ off += 4;
377
+ }
378
+ }
379
+ }
380
+ const out = buf.slice(0, off);
381
+ releaseBuffer(buf);
382
+ return out;
383
+ }
384
+ function decodeDeltaPayload(buf) {
385
+ const view = new DataView(buf);
386
+ const dec = new TextDecoder();
387
+ let off = 0;
388
+ const groupIdLen = view.getUint8(off);
389
+ off += 1;
390
+ const groupId = dec.decode(buf.slice(off, off + groupIdLen));
391
+ off += groupIdLen;
392
+ const entityCount = view.getUint16(off, true);
393
+ off += 2;
394
+ const deltas = [];
395
+ for (let e = 0; e < entityCount; e++) {
396
+ const entityId = view.getUint32(off, true);
397
+ off += 4;
398
+ const componentCount = view.getUint8(off);
399
+ off += 1;
400
+ for (let c = 0; c < componentCount; c++) {
401
+ const componentId = view.getUint8(off);
402
+ off += 1;
403
+ const fieldCount = view.getUint8(off);
404
+ off += 1;
405
+ for (let f = 0; f < fieldCount; f++) {
406
+ const fieldId = view.getUint8(off);
407
+ off += 1;
408
+ const value = view.getFloat32(off, true);
409
+ off += 4;
410
+ deltas.push({ entityId, componentId, fieldId, value });
411
+ }
412
+ }
413
+ }
414
+ return { groupId, deltas };
415
+ }
416
+ var _enc = new TextEncoder();
417
+ var _dec = new TextDecoder();
418
+ function encodeJson(obj) {
419
+ return _enc.encode(JSON.stringify(obj)).buffer;
420
+ }
421
+ function decodeJson(buf) {
422
+ return JSON.parse(_dec.decode(buf));
423
+ }
424
+
425
+ // src/lead-election.ts
426
+ function fnv1a(s) {
427
+ let h = 2166136261;
428
+ for (let i = 0; i < s.length; i++) {
429
+ h ^= s.charCodeAt(i);
430
+ h = Math.imul(h, 16777619) >>> 0;
431
+ }
432
+ return h;
433
+ }
434
+ var LeadElector = class {
435
+ constructor(strategy = "min-id") {
436
+ this._loadInfo = /* @__PURE__ */ new Map();
437
+ this._strategy = strategy;
438
+ }
439
+ updateLoadInfo(info) {
440
+ this._loadInfo.set(info.peerId, info);
441
+ }
442
+ removeLoadInfo(peerId) {
443
+ this._loadInfo.delete(peerId);
444
+ }
445
+ electLead(groupId, candidates) {
446
+ if (candidates.length === 0) throw new Error("No candidates for election");
447
+ if (candidates.length === 1) return candidates[0];
448
+ switch (this._strategy) {
449
+ case "min-id":
450
+ return this._minId(candidates);
451
+ case "hash-ring":
452
+ return this._hashRing(groupId, candidates);
453
+ case "load-balanced":
454
+ return this._loadBalanced(candidates);
455
+ }
456
+ }
457
+ wouldReelect(groupId, currentLead, candidates) {
458
+ try {
459
+ return this.electLead(groupId, candidates) !== currentLead;
460
+ } catch {
461
+ return true;
462
+ }
463
+ }
464
+ _minId(candidates) {
465
+ return candidates.reduce((best, id) => id < best ? id : best);
466
+ }
467
+ _hashRing(groupId, candidates) {
468
+ const target = fnv1a(groupId);
469
+ const ring = [];
470
+ for (const peerId of candidates) {
471
+ for (let v = 0; v < 20; v++) {
472
+ ring.push({ pos: fnv1a(`${peerId}:vn${v}`), peerId });
473
+ }
474
+ }
475
+ ring.sort((a, b) => a.pos < b.pos ? -1 : a.pos > b.pos ? 1 : 0);
476
+ return (ring.find((n) => n.pos >= target) ?? ring[0]).peerId;
477
+ }
478
+ _loadBalanced(candidates) {
479
+ let best = candidates[0];
480
+ let bestScore = Infinity;
481
+ for (const peerId of candidates) {
482
+ const info = this._loadInfo.get(peerId);
483
+ const score = info ? info.connectionCount * 2 + info.leadGroupCount * 5 : 0;
484
+ if (score < bestScore) {
485
+ bestScore = score;
486
+ best = peerId;
487
+ }
488
+ }
489
+ return best;
490
+ }
491
+ };
492
+
493
+ // src/interest-group.ts
494
+ var InterestGroup = class {
495
+ constructor(config, localPeerId) {
496
+ this._peers = /* @__PURE__ */ new Map();
497
+ this._leadId = null;
498
+ this._sendFn = null;
499
+ this._deltaHandlers = /* @__PURE__ */ new Set();
500
+ this._pending = [];
501
+ this._tickHandle = null;
502
+ this._seq = 0;
503
+ this._ack = 0;
504
+ this.id = config.id;
505
+ this._config = {
506
+ id: config.id,
507
+ maxEntities: config.maxEntities ?? 4096,
508
+ tickRateMs: config.tickRateMs ?? 50,
509
+ electionStrategy: config.electionStrategy ?? "min-id"
510
+ };
511
+ this._localPeerId = localPeerId;
512
+ this._elector = new LeadElector(this._config.electionStrategy);
513
+ }
514
+ // ---------------------------------------------------------------------------
515
+ // Accessors
516
+ // ---------------------------------------------------------------------------
517
+ get leadId() {
518
+ return this._leadId;
519
+ }
520
+ get isLead() {
521
+ return this._leadId === this._localPeerId;
522
+ }
523
+ get peerCount() {
524
+ return this._peers.size;
525
+ }
526
+ getPeer(peerId) {
527
+ return this._peers.get(peerId);
528
+ }
529
+ get peerIds() {
530
+ return new Set(this._peers.keys());
531
+ }
532
+ // ---------------------------------------------------------------------------
533
+ // Wiring
534
+ // ---------------------------------------------------------------------------
535
+ setSendFn(fn) {
536
+ this._sendFn = fn;
537
+ }
538
+ // ---------------------------------------------------------------------------
539
+ // Membership
540
+ // ---------------------------------------------------------------------------
541
+ addPeer(peerId) {
542
+ if (this._peers.has(peerId)) return;
543
+ this._peers.set(peerId, {
544
+ peerId,
545
+ isLead: false,
546
+ joinedAt: Date.now(),
547
+ lastSeenAt: Date.now(),
548
+ rttMs: 0
549
+ });
550
+ this._reelect();
551
+ }
552
+ removePeer(peerId) {
553
+ if (!this._peers.has(peerId)) return;
554
+ this._peers.delete(peerId);
555
+ this._elector.removeLoadInfo(peerId);
556
+ if (this._leadId === peerId) {
557
+ this._leadId = null;
558
+ this._reelect();
559
+ }
560
+ }
561
+ // ---------------------------------------------------------------------------
562
+ // Delta publishing
563
+ // ---------------------------------------------------------------------------
564
+ /** Queue deltas to be sent on the next tick flush */
565
+ publishDeltas(deltas) {
566
+ if (deltas.length > 0) this._pending.push(...deltas);
567
+ }
568
+ /** Send all pending deltas immediately (called by the tick interval) */
569
+ flush() {
570
+ if (this._pending.length === 0 || !this._sendFn) return;
571
+ const payload = encodeDeltaPayload(this.id, this._pending);
572
+ this._pending = [];
573
+ const msg = {
574
+ type: 4 /* DeltaUpdate */,
575
+ flags: 0 /* None */,
576
+ seq: this._nextSeq(),
577
+ ack: this._ack,
578
+ payload
579
+ };
580
+ if (this.isLead) {
581
+ for (const peerId of this._peers.keys()) {
582
+ this._sendFn(peerId, msg);
583
+ }
584
+ } else if (this._leadId !== null) {
585
+ this._sendFn(this._leadId, msg);
586
+ }
587
+ }
588
+ // ---------------------------------------------------------------------------
589
+ // Inbound message dispatch
590
+ // ---------------------------------------------------------------------------
591
+ handleMessage(msg, fromPeer) {
592
+ const state = this._peers.get(fromPeer);
593
+ if (state) state.lastSeenAt = Date.now();
594
+ this._ack = msg.seq;
595
+ switch (msg.type) {
596
+ case 4 /* DeltaUpdate */:
597
+ this._onDeltaUpdate(msg, fromPeer);
598
+ break;
599
+ case 5 /* FullSync */:
600
+ this._onFullSync(msg, fromPeer);
601
+ break;
602
+ case 9 /* LeadClaim */:
603
+ this._onLeadClaim(msg);
604
+ break;
605
+ }
606
+ }
607
+ // ---------------------------------------------------------------------------
608
+ // Handler registration
609
+ // ---------------------------------------------------------------------------
610
+ onDelta(handler) {
611
+ this._deltaHandlers.add(handler);
612
+ return () => this._deltaHandlers.delete(handler);
613
+ }
614
+ // ---------------------------------------------------------------------------
615
+ // Tick control
616
+ // ---------------------------------------------------------------------------
617
+ startTick() {
618
+ if (this._tickHandle !== null) return;
619
+ this._tickHandle = setInterval(() => this.flush(), this._config.tickRateMs);
620
+ }
621
+ stopTick() {
622
+ if (this._tickHandle !== null) {
623
+ clearInterval(this._tickHandle);
624
+ this._tickHandle = null;
625
+ }
626
+ }
627
+ dispose() {
628
+ this.stopTick();
629
+ this._deltaHandlers.clear();
630
+ this._peers.clear();
631
+ }
632
+ // ---------------------------------------------------------------------------
633
+ // Private
634
+ // ---------------------------------------------------------------------------
635
+ _reelect() {
636
+ if (this._peers.size === 0) {
637
+ this._leadId = this._localPeerId;
638
+ return;
639
+ }
640
+ const candidates = [this._localPeerId, ...this._peers.keys()];
641
+ const newLead = this._elector.electLead(this.id, candidates);
642
+ if (newLead !== this._leadId) {
643
+ this._leadId = newLead;
644
+ if (newLead === this._localPeerId) {
645
+ this._broadcastLeadClaim();
646
+ }
647
+ }
648
+ }
649
+ _broadcastLeadClaim() {
650
+ if (!this._sendFn) return;
651
+ const payload = encodeJson({ groupId: this.id, leadId: this._localPeerId });
652
+ const msg = {
653
+ type: 9 /* LeadClaim */,
654
+ flags: 1 /* Reliable */,
655
+ seq: this._nextSeq(),
656
+ ack: this._ack,
657
+ payload
658
+ };
659
+ for (const peerId of this._peers.keys()) {
660
+ this._sendFn(peerId, msg);
661
+ }
662
+ }
663
+ _onDeltaUpdate(msg, fromPeer) {
664
+ let groupId;
665
+ let deltas;
666
+ try {
667
+ ;
668
+ ({ groupId, deltas } = decodeDeltaPayload(msg.payload));
669
+ } catch {
670
+ return;
671
+ }
672
+ if (groupId !== this.id) return;
673
+ if (this.isLead && this._sendFn) {
674
+ for (const peerId of this._peers.keys()) {
675
+ if (peerId !== fromPeer) this._sendFn(peerId, msg);
676
+ }
677
+ }
678
+ for (const h of this._deltaHandlers) h(deltas, this.id, fromPeer);
679
+ }
680
+ _onFullSync(msg, fromPeer) {
681
+ let groupId;
682
+ let deltas;
683
+ try {
684
+ ;
685
+ ({ groupId, deltas } = decodeDeltaPayload(msg.payload));
686
+ } catch {
687
+ return;
688
+ }
689
+ if (groupId !== this.id) return;
690
+ for (const h of this._deltaHandlers) h(deltas, this.id, fromPeer);
691
+ }
692
+ _onLeadClaim(msg) {
693
+ try {
694
+ const { groupId, leadId } = decodeJson(msg.payload);
695
+ if (groupId !== this.id || typeof leadId !== "string") return;
696
+ if (this._peers.has(leadId) || leadId === this._localPeerId) {
697
+ this._leadId = leadId;
698
+ for (const [pid, state] of this._peers) state.isLead = pid === leadId;
699
+ }
700
+ } catch {
701
+ }
702
+ }
703
+ _nextSeq() {
704
+ return this._seq = this._seq + 1 & 65535;
705
+ }
706
+ };
707
+
708
+ // src/peer-net.ts
709
+ var PeerNet = class {
710
+ constructor(config) {
711
+ this._groups = /* @__PURE__ */ new Map();
712
+ this._tracker = new ChangeTracker();
713
+ this._seq = 0;
714
+ this._ack = 0;
715
+ this._rttMs = 50;
716
+ this._gameEventHandlers = [];
717
+ this.localPeerId = config.peerId;
718
+ this._config = {
719
+ peerId: config.peerId,
720
+ rttAlpha: config.rttAlpha ?? 0.125
721
+ };
722
+ this._pool = new ConnectionPool();
723
+ this._pool.onData((buf, from) => this._onRawData(buf, from));
724
+ this._pool.onDisconnect((peerId) => {
725
+ for (const group of this._groups.values()) group.removePeer(peerId);
726
+ });
727
+ }
728
+ // ---------------------------------------------------------------------------
729
+ // PeerJS wiring
730
+ // ---------------------------------------------------------------------------
731
+ /** Attach the live PeerJS Peer instance after it opens. */
732
+ attachPeer(peer) {
733
+ this._pool.setPeer(peer);
734
+ }
735
+ // ---------------------------------------------------------------------------
736
+ // Component schema
737
+ // ---------------------------------------------------------------------------
738
+ registerComponent(schema) {
739
+ this._tracker.registerComponent(schema);
740
+ }
741
+ unregisterComponent(componentId) {
742
+ this._tracker.unregisterComponent(componentId);
743
+ }
744
+ // ---------------------------------------------------------------------------
745
+ // Group management
746
+ // ---------------------------------------------------------------------------
747
+ createGroup(config) {
748
+ const existing = this._groups.get(config.id);
749
+ if (existing) return existing;
750
+ const group = new InterestGroup(config, this.localPeerId);
751
+ group.setSendFn((to, msg) => this._send(to, msg));
752
+ group.startTick();
753
+ this._groups.set(config.id, group);
754
+ return group;
755
+ }
756
+ destroyGroup(groupId) {
757
+ const group = this._groups.get(groupId);
758
+ if (!group) return;
759
+ group.dispose();
760
+ this._groups.delete(groupId);
761
+ }
762
+ getGroup(groupId) {
763
+ return this._groups.get(groupId);
764
+ }
765
+ // ---------------------------------------------------------------------------
766
+ // Peer connections
767
+ // ---------------------------------------------------------------------------
768
+ async connectPeer(peerId) {
769
+ await this._pool.connect(peerId);
770
+ }
771
+ disconnectPeer(peerId) {
772
+ this._pool.disconnect(peerId);
773
+ for (const group of this._groups.values()) {
774
+ group.removePeer(peerId);
775
+ }
776
+ }
777
+ onPeerDisconnect(handler) {
778
+ return this._pool.onDisconnect(handler);
779
+ }
780
+ sendGameEvent(peerId, payload) {
781
+ this._send(peerId, {
782
+ type: 12 /* GameEvent */,
783
+ flags: 0 /* None */,
784
+ seq: this._nextSeq(),
785
+ ack: this._ack,
786
+ payload
787
+ });
788
+ }
789
+ onGameEvent(handler) {
790
+ this._gameEventHandlers.push(handler);
791
+ return () => {
792
+ const idx = this._gameEventHandlers.indexOf(handler);
793
+ if (idx !== -1) this._gameEventHandlers.splice(idx, 1);
794
+ };
795
+ }
796
+ // ---------------------------------------------------------------------------
797
+ // Group subscriptions
798
+ // ---------------------------------------------------------------------------
799
+ /**
800
+ * Subscribe the local peer to a group and introduce remote peers.
801
+ * Sends a Subscribe control message to each remote peer.
802
+ */
803
+ joinGroup(groupId, remotePeers) {
804
+ const group = this._requireGroup(groupId);
805
+ for (const p of remotePeers) group.addPeer(p);
806
+ this._sendSubscribe(groupId, remotePeers);
807
+ }
808
+ /**
809
+ * Leave a group: notify remote peers and remove local state.
810
+ */
811
+ leaveGroup(groupId) {
812
+ const group = this._groups.get(groupId);
813
+ if (!group) return;
814
+ const peers = [...group.peerIds];
815
+ this._sendUnsubscribe(groupId, peers);
816
+ for (const p of peers) group.removePeer(p);
817
+ }
818
+ // ---------------------------------------------------------------------------
819
+ // Delta pipeline
820
+ // ---------------------------------------------------------------------------
821
+ markEntityDirty(entityId) {
822
+ this._tracker.markDirty(entityId);
823
+ }
824
+ markEntitiesDirty(entities) {
825
+ this._tracker.markDirtyBatch(entities);
826
+ }
827
+ invalidateEntity(entityId) {
828
+ this._tracker.invalidateEntity(entityId);
829
+ }
830
+ /**
831
+ * Compute deltas for dirty entities and push them into the group's send queue.
832
+ * The group's tick interval handles the actual sending.
833
+ *
834
+ * Call once per game tick, after ECS systems run.
835
+ */
836
+ flushGroupDeltas(groupId, entities) {
837
+ const group = this._groups.get(groupId);
838
+ if (!group) return;
839
+ const deltas = this._tracker.flush(entities);
840
+ if (deltas.length > 0) group.publishDeltas(deltas);
841
+ }
842
+ /**
843
+ * Send a full-state sync to a newly joined peer for a given group.
844
+ * Generates a snapshot of all registered component fields for all provided entities.
845
+ */
846
+ sendFullSync(groupId, toPeer, entities) {
847
+ const group = this._groups.get(groupId);
848
+ if (!group) return;
849
+ const deltas = this._tracker.fullSnapshot(entities);
850
+ if (deltas.length === 0) return;
851
+ const payload = encodeDeltaPayload(groupId, deltas);
852
+ this._send(toPeer, {
853
+ type: 5 /* FullSync */,
854
+ flags: 1 /* Reliable */,
855
+ seq: this._nextSeq(),
856
+ ack: this._ack,
857
+ payload
858
+ });
859
+ }
860
+ onGroupDelta(groupId, handler) {
861
+ return this._requireGroup(groupId).onDelta(handler);
862
+ }
863
+ // ---------------------------------------------------------------------------
864
+ // Diagnostics
865
+ // ---------------------------------------------------------------------------
866
+ get rttMs() {
867
+ return this._rttMs;
868
+ }
869
+ get dirtyEntityCount() {
870
+ return this._tracker.dirtyCount;
871
+ }
872
+ ping(peerId) {
873
+ const buf = new ArrayBuffer(8);
874
+ new DataView(buf).setFloat64(0, performance.now(), true);
875
+ this._send(peerId, {
876
+ type: 7 /* Ping */,
877
+ flags: 0 /* None */,
878
+ seq: this._nextSeq(),
879
+ ack: this._ack,
880
+ payload: buf
881
+ });
882
+ }
883
+ // ---------------------------------------------------------------------------
884
+ // Teardown
885
+ // ---------------------------------------------------------------------------
886
+ dispose() {
887
+ for (const group of this._groups.values()) group.dispose();
888
+ this._groups.clear();
889
+ this._pool.dispose();
890
+ }
891
+ // ---------------------------------------------------------------------------
892
+ // Private routing
893
+ // ---------------------------------------------------------------------------
894
+ _onRawData(buf, from) {
895
+ let msg;
896
+ try {
897
+ msg = decodeMessage(buf);
898
+ } catch {
899
+ return;
900
+ }
901
+ this._ack = msg.seq;
902
+ switch (msg.type) {
903
+ case 2 /* Subscribe */: {
904
+ this._onSubscribe(msg, from);
905
+ break;
906
+ }
907
+ case 3 /* Unsubscribe */: {
908
+ this._onUnsubscribe(msg, from);
909
+ break;
910
+ }
911
+ case 4 /* DeltaUpdate */:
912
+ case 5 /* FullSync */:
913
+ case 9 /* LeadClaim */: {
914
+ for (const group of this._groups.values()) {
915
+ if (group.peerIds.has(from)) group.handleMessage(msg, from);
916
+ }
917
+ break;
918
+ }
919
+ case 12 /* GameEvent */: {
920
+ for (const h of this._gameEventHandlers) h(msg.payload, from);
921
+ break;
922
+ }
923
+ case 7 /* Ping */: {
924
+ this._send(from, {
925
+ type: 8 /* Pong */,
926
+ flags: 0 /* None */,
927
+ seq: this._nextSeq(),
928
+ ack: this._ack,
929
+ payload: msg.payload
930
+ });
931
+ break;
932
+ }
933
+ case 8 /* Pong */: {
934
+ try {
935
+ const sent = new DataView(msg.payload).getFloat64(0, true);
936
+ const rtt = performance.now() - sent;
937
+ this._rttMs += this._config.rttAlpha * (rtt - this._rttMs);
938
+ } catch {
939
+ }
940
+ break;
941
+ }
942
+ }
943
+ }
944
+ _onSubscribe(msg, from) {
945
+ try {
946
+ const { groupId } = decodeJson(msg.payload);
947
+ if (typeof groupId === "string") this._groups.get(groupId)?.addPeer(from);
948
+ } catch {
949
+ }
950
+ }
951
+ _onUnsubscribe(msg, from) {
952
+ try {
953
+ const { groupId } = decodeJson(msg.payload);
954
+ if (typeof groupId === "string") this._groups.get(groupId)?.removePeer(from);
955
+ } catch {
956
+ }
957
+ }
958
+ _sendSubscribe(groupId, peers) {
959
+ const payload = encodeJson({ groupId });
960
+ const msg = {
961
+ type: 2 /* Subscribe */,
962
+ flags: 1 /* Reliable */,
963
+ seq: this._nextSeq(),
964
+ ack: this._ack,
965
+ payload
966
+ };
967
+ for (const p of peers) this._send(p, msg);
968
+ }
969
+ _sendUnsubscribe(groupId, peers) {
970
+ const payload = encodeJson({ groupId });
971
+ const msg = {
972
+ type: 3 /* Unsubscribe */,
973
+ flags: 1 /* Reliable */,
974
+ seq: this._nextSeq(),
975
+ ack: this._ack,
976
+ payload
977
+ };
978
+ for (const p of peers) this._send(p, msg);
979
+ }
980
+ _send(to, msg) {
981
+ this._pool.send(to, encodeMessage(msg));
982
+ }
983
+ _nextSeq() {
984
+ return this._seq = this._seq + 1 & 65535;
985
+ }
986
+ _requireGroup(groupId) {
987
+ const group = this._groups.get(groupId);
988
+ if (!group) throw new Error(`Interest group "${groupId}" not found \u2014 call createGroup() first`);
989
+ return group;
990
+ }
991
+ };
992
+
993
+ // src/priority-queue.ts
994
+ var PriorityQueue = class {
995
+ constructor() {
996
+ this._heap = [];
997
+ this._seq = 0;
998
+ }
999
+ get size() {
1000
+ return this._heap.length;
1001
+ }
1002
+ get isEmpty() {
1003
+ return this._heap.length === 0;
1004
+ }
1005
+ push(value, priority) {
1006
+ const entry = { priority, seq: this._seq++, value };
1007
+ this._heap.push(entry);
1008
+ this._bubbleUp(this._heap.length - 1);
1009
+ }
1010
+ pop() {
1011
+ if (this._heap.length === 0) return void 0;
1012
+ const top = this._heap[0];
1013
+ const last = this._heap.pop();
1014
+ if (this._heap.length > 0) {
1015
+ this._heap[0] = last;
1016
+ this._siftDown(0);
1017
+ }
1018
+ return top.value;
1019
+ }
1020
+ peek() {
1021
+ return this._heap[0]?.value;
1022
+ }
1023
+ clear() {
1024
+ this._heap = [];
1025
+ }
1026
+ _lt(a, b) {
1027
+ return a.priority !== b.priority ? a.priority < b.priority : a.seq < b.seq;
1028
+ }
1029
+ _bubbleUp(i) {
1030
+ while (i > 0) {
1031
+ const parent = i - 1 >> 1;
1032
+ if (this._lt(this._heap[i], this._heap[parent])) {
1033
+ ;
1034
+ [this._heap[i], this._heap[parent]] = [this._heap[parent], this._heap[i]];
1035
+ i = parent;
1036
+ } else break;
1037
+ }
1038
+ }
1039
+ _siftDown(i) {
1040
+ const n = this._heap.length;
1041
+ for (; ; ) {
1042
+ let min = i;
1043
+ const l = (i << 1) + 1;
1044
+ const r = l + 1;
1045
+ if (l < n && this._lt(this._heap[l], this._heap[min])) min = l;
1046
+ if (r < n && this._lt(this._heap[r], this._heap[min])) min = r;
1047
+ if (min === i) break;
1048
+ [this._heap[i], this._heap[min]] = [this._heap[min], this._heap[i]];
1049
+ i = min;
1050
+ }
1051
+ }
1052
+ };
1053
+ // Annotate the CommonJS export names for ESM import in node:
1054
+ 0 && (module.exports = {
1055
+ ChangeTracker,
1056
+ HEADER_SIZE,
1057
+ InterestGroup,
1058
+ LeadElector,
1059
+ MessageFlag,
1060
+ MessageType,
1061
+ PeerNet,
1062
+ PriorityQueue,
1063
+ decodeDeltaPayload,
1064
+ decodeHeader,
1065
+ decodeJson,
1066
+ decodeMessage,
1067
+ encodeDeltaPayload,
1068
+ encodeHeader,
1069
+ encodeJson,
1070
+ encodeMessage
1071
+ });
1072
+ //# sourceMappingURL=index.cjs.map