@carverjs/multiplayer 0.0.1

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.
Files changed (43) hide show
  1. package/dist/NetworkManager-DrKM2tEx.d.mts +369 -0
  2. package/dist/NetworkManager-nvVAOr1O.d.ts +369 -0
  3. package/dist/chunk-3KT73N2S.mjs +655 -0
  4. package/dist/chunk-3KT73N2S.mjs.map +1 -0
  5. package/dist/chunk-EO3YNPRQ.mjs +817 -0
  6. package/dist/chunk-EO3YNPRQ.mjs.map +1 -0
  7. package/dist/chunk-UD6FDZMX.mjs +581 -0
  8. package/dist/chunk-UD6FDZMX.mjs.map +1 -0
  9. package/dist/firebase-CPu87KA0.d.ts +100 -0
  10. package/dist/firebase-PE6MxGdJ.d.mts +100 -0
  11. package/dist/index.d.mts +316 -0
  12. package/dist/index.d.ts +316 -0
  13. package/dist/index.js +3817 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/index.mjs +1743 -0
  16. package/dist/index.mjs.map +1 -0
  17. package/dist/strategy.d.mts +7 -0
  18. package/dist/strategy.d.ts +7 -0
  19. package/dist/strategy.js +619 -0
  20. package/dist/strategy.js.map +1 -0
  21. package/dist/strategy.mjs +11 -0
  22. package/dist/strategy.mjs.map +1 -0
  23. package/dist/sync.d.mts +212 -0
  24. package/dist/sync.d.ts +212 -0
  25. package/dist/sync.js +845 -0
  26. package/dist/sync.js.map +1 -0
  27. package/dist/sync.mjs +11 -0
  28. package/dist/sync.mjs.map +1 -0
  29. package/dist/transport.d.mts +159 -0
  30. package/dist/transport.d.ts +159 -0
  31. package/dist/transport.js +1274 -0
  32. package/dist/transport.js.map +1 -0
  33. package/dist/transport.mjs +19 -0
  34. package/dist/transport.mjs.map +1 -0
  35. package/dist/types-5LHBOW08.d.mts +74 -0
  36. package/dist/types-5LHBOW08.d.ts +74 -0
  37. package/dist/types.d.mts +2 -0
  38. package/dist/types.d.ts +2 -0
  39. package/dist/types.js +19 -0
  40. package/dist/types.js.map +1 -0
  41. package/dist/types.mjs +1 -0
  42. package/dist/types.mjs.map +1 -0
  43. package/package.json +73 -0
package/dist/index.js ADDED
@@ -0,0 +1,3817 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var src_exports = {};
32
+ __export(src_exports, {
33
+ DebugOverlay: () => DebugOverlay,
34
+ FirebaseStrategy: () => FirebaseStrategy,
35
+ InterestManager: () => InterestManager,
36
+ MqttStrategy: () => MqttStrategy,
37
+ MultiplayerBridge: () => MultiplayerBridge,
38
+ MultiplayerProvider: () => MultiplayerProvider,
39
+ NetworkSimulator: () => NetworkSimulator,
40
+ useHost: () => useHost,
41
+ useLobby: () => useLobby,
42
+ useMultiplayer: () => useMultiplayer,
43
+ useNetworkEvents: () => useNetworkEvents,
44
+ useNetworkState: () => useNetworkState,
45
+ usePlayers: () => usePlayers,
46
+ useRoom: () => useRoom
47
+ });
48
+ module.exports = __toCommonJS(src_exports);
49
+
50
+ // src/components/MultiplayerProvider.ts
51
+ var import_react2 = require("react");
52
+
53
+ // src/transport/strategy/utils.ts
54
+ function generatePeerId() {
55
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
56
+ const bytes = new Uint8Array(20);
57
+ crypto.getRandomValues(bytes);
58
+ let id = "";
59
+ for (let i = 0; i < 20; i++) {
60
+ id += chars[bytes[i] % chars.length];
61
+ }
62
+ return id;
63
+ }
64
+ function mqttTopics(appId, roomId, peerId) {
65
+ const base = `carver/${appId}`;
66
+ return {
67
+ /** Lobby wildcard: subscribe to discover all room announcements */
68
+ lobbyWildcard: `${base}/lobby/+`,
69
+ /** Single room lobby entry */
70
+ roomLobbyEntry: `${base}/lobby/${roomId}`,
71
+ /** Wildcard for all peer presence in a room */
72
+ roomPresenceWildcard: `${base}/room/${roomId}/presence/+`,
73
+ /** This peer's presence topic */
74
+ peerPresence: peerId ? `${base}/room/${roomId}/presence/${peerId}` : "",
75
+ /** This peer's signal inbox */
76
+ peerSignalInbox: peerId ? `${base}/room/${roomId}/signal/${peerId}` : ""
77
+ };
78
+ }
79
+ function firebasePaths(appId, roomId, peerId) {
80
+ const base = `${appId}/__carver__`;
81
+ return {
82
+ lobby: `${base}/lobby`,
83
+ roomLobbyEntry: `${base}/lobby/${roomId}`,
84
+ peers: `${base}/rooms/${roomId}/peers`,
85
+ peerPresence: peerId ? `${base}/rooms/${roomId}/peers/${peerId}` : "",
86
+ peerSignalInbox: peerId ? `${base}/rooms/${roomId}/signals/${peerId}` : ""
87
+ };
88
+ }
89
+ var DEFAULT_MQTT_BROKERS = [
90
+ "wss://broker.emqx.io:8084/mqtt",
91
+ "wss://test.mosquitto.org:8081/mqtt"
92
+ ];
93
+ var ROOM_ANNOUNCE_EXPIRY_MS = 3e4;
94
+ var ROOM_ANNOUNCE_INTERVAL_MS = 1e4;
95
+ var PRESENCE_HEARTBEAT_MS = 5e3;
96
+ var PEER_EXPIRY_MS = PRESENCE_HEARTBEAT_MS * 3;
97
+ var PRESENCE_WARMUP_DELAYS_MS = [200, 500, 1500];
98
+ function removeFromArray(arr, item) {
99
+ const idx = arr.indexOf(item);
100
+ if (idx >= 0) {
101
+ arr.splice(idx, 1);
102
+ return true;
103
+ }
104
+ return false;
105
+ }
106
+
107
+ // src/transport/strategy/mqtt.ts
108
+ var MqttStrategy = class {
109
+ constructor(appId, config = { type: "mqtt" }) {
110
+ this._client = null;
111
+ this._roomId = null;
112
+ this._peerMeta = {};
113
+ /** Monotonic counter to detect stale leaveRoom completions */
114
+ this._joinGeneration = 0;
115
+ // Lazy init
116
+ this._initPromise = null;
117
+ // Callbacks
118
+ this._onPeerDiscovered = [];
119
+ this._onPeerLeft = [];
120
+ this._onSignal = [];
121
+ this._onLobby = [];
122
+ // State
123
+ this._knownPeers = /* @__PURE__ */ new Map();
124
+ this._lobbyRooms = /* @__PURE__ */ new Map();
125
+ this._presenceTimer = null;
126
+ this._warmupTimers = [];
127
+ this._lobbyAnnounceTimer = null;
128
+ this._peerExpiryTimer = null;
129
+ this._lobbySubscribed = false;
130
+ this._destroyed = false;
131
+ this.selfId = generatePeerId();
132
+ this._appId = appId;
133
+ this._config = config;
134
+ }
135
+ // ── Public API ──
136
+ async init() {
137
+ return this._ensureInit();
138
+ }
139
+ async joinRoom(roomId, peerMeta) {
140
+ await this._ensureInit();
141
+ if (!this._client) throw new Error("MQTT client not available");
142
+ this._joinGeneration++;
143
+ this._roomId = roomId;
144
+ this._peerMeta = peerMeta;
145
+ const topics = mqttTopics(this._appId, roomId, this.selfId);
146
+ await new Promise((resolve, reject) => {
147
+ this._client.subscribe(
148
+ [topics.roomPresenceWildcard, topics.peerSignalInbox],
149
+ { qos: 1 },
150
+ (err) => err ? reject(err) : resolve()
151
+ );
152
+ });
153
+ this._publishPresence();
154
+ for (const delay of PRESENCE_WARMUP_DELAYS_MS) {
155
+ this._warmupTimers.push(setTimeout(() => this._publishPresence(), delay));
156
+ }
157
+ this._presenceTimer = setInterval(() => this._publishPresence(), PRESENCE_HEARTBEAT_MS);
158
+ this._peerExpiryTimer = setInterval(() => this._checkPeerExpiry(), PRESENCE_HEARTBEAT_MS);
159
+ }
160
+ async leaveRoom() {
161
+ if (!this._client || !this._roomId) return;
162
+ const generation = this._joinGeneration;
163
+ const topics = mqttTopics(this._appId, this._roomId, this.selfId);
164
+ this._clearRoomTimers();
165
+ this._client.publish(topics.peerPresence, "", { retain: true, qos: 1 });
166
+ this._client.unsubscribe([topics.roomPresenceWildcard, topics.peerSignalInbox]);
167
+ this._knownPeers.clear();
168
+ if (this._joinGeneration === generation) {
169
+ this._roomId = null;
170
+ }
171
+ }
172
+ signal(targetPeerId, data) {
173
+ if (!this._client || !this._roomId) return;
174
+ const targetTopic = `carver/${this._appId}/room/${this._roomId}/signal/${targetPeerId}`;
175
+ this._client.publish(
176
+ targetTopic,
177
+ JSON.stringify({ from: this.selfId, data, ts: Date.now() }),
178
+ { qos: 1 }
179
+ );
180
+ }
181
+ subscribeToLobby(cb) {
182
+ this._onLobby.push(cb);
183
+ if (!this._lobbySubscribed) {
184
+ this._lobbySubscribed = true;
185
+ this._ensureInit().then(() => {
186
+ if (this._client && !this._destroyed) {
187
+ const lobbyTopic = mqttTopics(this._appId, "", "").lobbyWildcard;
188
+ this._client.subscribe(lobbyTopic, { qos: 0 });
189
+ }
190
+ });
191
+ }
192
+ return () => {
193
+ removeFromArray(this._onLobby, cb);
194
+ };
195
+ }
196
+ announceRoom(announcement) {
197
+ if (!this._client) return;
198
+ const topic = mqttTopics(this._appId, announcement.roomId, "").roomLobbyEntry;
199
+ announcement.lastSeen = Date.now();
200
+ this._client.publish(topic, JSON.stringify(announcement), { retain: true, qos: 1 });
201
+ if (this._lobbyAnnounceTimer) clearInterval(this._lobbyAnnounceTimer);
202
+ this._lobbyAnnounceTimer = setInterval(() => {
203
+ announcement.lastSeen = Date.now();
204
+ this._client?.publish(topic, JSON.stringify(announcement), { retain: true, qos: 1 });
205
+ }, ROOM_ANNOUNCE_INTERVAL_MS);
206
+ }
207
+ removeRoomAnnouncement(roomId) {
208
+ if (!this._client) return;
209
+ const topic = mqttTopics(this._appId, roomId, "").roomLobbyEntry;
210
+ this._client.publish(topic, "", { retain: true, qos: 1 });
211
+ if (this._lobbyAnnounceTimer) {
212
+ clearInterval(this._lobbyAnnounceTimer);
213
+ this._lobbyAnnounceTimer = null;
214
+ }
215
+ }
216
+ onPeerDiscovered(cb) {
217
+ this._onPeerDiscovered.push(cb);
218
+ return () => {
219
+ removeFromArray(this._onPeerDiscovered, cb);
220
+ };
221
+ }
222
+ onPeerLeft(cb) {
223
+ this._onPeerLeft.push(cb);
224
+ return () => {
225
+ removeFromArray(this._onPeerLeft, cb);
226
+ };
227
+ }
228
+ onSignal(cb) {
229
+ this._onSignal.push(cb);
230
+ return () => {
231
+ removeFromArray(this._onSignal, cb);
232
+ };
233
+ }
234
+ destroy() {
235
+ this._destroyed = true;
236
+ this._clearRoomTimers();
237
+ if (this._client && this._roomId) {
238
+ const topics = mqttTopics(this._appId, this._roomId, this.selfId);
239
+ this._client.publish(topics.peerPresence, "", { retain: true, qos: 1 });
240
+ }
241
+ this._client?.end(true);
242
+ this._client = null;
243
+ this._knownPeers.clear();
244
+ this._lobbyRooms.clear();
245
+ this._onPeerDiscovered = [];
246
+ this._onPeerLeft = [];
247
+ this._onSignal = [];
248
+ this._onLobby = [];
249
+ }
250
+ // ── Private ──
251
+ _ensureInit() {
252
+ if (!this._initPromise) {
253
+ this._initPromise = this._doInit();
254
+ }
255
+ return this._initPromise;
256
+ }
257
+ async _doInit() {
258
+ const mqtt = await import("mqtt");
259
+ const brokers = this._config.brokerUrls ?? DEFAULT_MQTT_BROKERS;
260
+ const brokerUrl = brokers[Math.floor(Math.random() * brokers.length)];
261
+ return new Promise((resolve, reject) => {
262
+ const connectFn = mqtt.default?.connect ?? mqtt.connect;
263
+ this._client = connectFn(brokerUrl, {
264
+ clientId: `carver_${this.selfId}`,
265
+ clean: true,
266
+ connectTimeout: 1e4,
267
+ keepalive: 30
268
+ });
269
+ this._client.on("connect", () => {
270
+ if (!this._destroyed) resolve();
271
+ });
272
+ this._client.on("error", (err) => {
273
+ if (!this._initPromise) return;
274
+ reject(err);
275
+ });
276
+ this._client.on("message", (topic, payload) => {
277
+ this._handleMessage(topic, payload);
278
+ });
279
+ });
280
+ }
281
+ _publishPresence() {
282
+ if (!this._client || !this._roomId) return;
283
+ const topics = mqttTopics(this._appId, this._roomId, this.selfId);
284
+ this._client.publish(
285
+ topics.peerPresence,
286
+ JSON.stringify({ peerId: this.selfId, meta: this._peerMeta, ts: Date.now() }),
287
+ { retain: true, qos: 1 }
288
+ );
289
+ }
290
+ _handleMessage(topic, payload) {
291
+ const raw = payload.toString();
292
+ const presenceMatch = topic.match(/\/room\/[^/]+\/presence\/([^/]+)$/);
293
+ if (presenceMatch) {
294
+ const peerId = presenceMatch[1];
295
+ if (peerId === this.selfId) return;
296
+ if (!raw) {
297
+ if (this._knownPeers.has(peerId)) {
298
+ this._knownPeers.delete(peerId);
299
+ for (const cb of this._onPeerLeft) cb(peerId);
300
+ }
301
+ return;
302
+ }
303
+ try {
304
+ const msg = JSON.parse(raw);
305
+ const isNew = !this._knownPeers.has(peerId);
306
+ this._knownPeers.set(peerId, { meta: msg.meta ?? {}, lastSeen: msg.ts ?? Date.now() });
307
+ if (isNew) {
308
+ for (const cb of this._onPeerDiscovered) cb(peerId, msg.meta ?? {});
309
+ }
310
+ } catch {
311
+ }
312
+ return;
313
+ }
314
+ const signalMatch = topic.match(/\/room\/[^/]+\/signal\/([^/]+)$/);
315
+ if (signalMatch) {
316
+ try {
317
+ const msg = JSON.parse(raw);
318
+ if (msg.from && msg.from !== this.selfId) {
319
+ for (const cb of this._onSignal) cb(msg.from, msg.data);
320
+ }
321
+ } catch {
322
+ }
323
+ return;
324
+ }
325
+ const lobbyMatch = topic.match(/\/lobby\/([^/]+)$/);
326
+ if (lobbyMatch) {
327
+ const roomId = lobbyMatch[1];
328
+ if (!raw) {
329
+ this._lobbyRooms.delete(roomId);
330
+ } else {
331
+ try {
332
+ const ann = JSON.parse(raw);
333
+ if (Date.now() - ann.lastSeen < ROOM_ANNOUNCE_EXPIRY_MS) {
334
+ this._lobbyRooms.set(roomId, ann);
335
+ } else {
336
+ this._lobbyRooms.delete(roomId);
337
+ }
338
+ } catch {
339
+ }
340
+ }
341
+ const rooms = Array.from(this._lobbyRooms.values());
342
+ for (const cb of this._onLobby) cb(rooms);
343
+ }
344
+ }
345
+ _checkPeerExpiry() {
346
+ const now = Date.now();
347
+ for (const [peerId, data] of this._knownPeers) {
348
+ if (now - data.lastSeen > PEER_EXPIRY_MS) {
349
+ this._knownPeers.delete(peerId);
350
+ for (const cb of this._onPeerLeft) cb(peerId);
351
+ }
352
+ }
353
+ }
354
+ _clearRoomTimers() {
355
+ if (this._presenceTimer) {
356
+ clearInterval(this._presenceTimer);
357
+ this._presenceTimer = null;
358
+ }
359
+ for (const t of this._warmupTimers) clearTimeout(t);
360
+ this._warmupTimers = [];
361
+ if (this._lobbyAnnounceTimer) {
362
+ clearInterval(this._lobbyAnnounceTimer);
363
+ this._lobbyAnnounceTimer = null;
364
+ }
365
+ if (this._peerExpiryTimer) {
366
+ clearInterval(this._peerExpiryTimer);
367
+ this._peerExpiryTimer = null;
368
+ }
369
+ }
370
+ };
371
+
372
+ // src/transport/strategy/firebase.ts
373
+ var FirebaseStrategy = class {
374
+ constructor(appId, config) {
375
+ this._db = null;
376
+ this._firebaseApp = null;
377
+ this._ownApp = false;
378
+ this._roomId = null;
379
+ this._peerMeta = {};
380
+ /** Monotonic counter to detect stale leaveRoom completions */
381
+ this._joinGeneration = 0;
382
+ // Lazy init
383
+ this._initPromise = null;
384
+ // Firebase module references (filled after dynamic import)
385
+ this._fb = null;
386
+ // Unsubscribe handles for Firebase listeners
387
+ this._listeners = [];
388
+ // Callbacks
389
+ this._onPeerDiscovered = [];
390
+ this._onPeerLeft = [];
391
+ this._onSignal = [];
392
+ this._onLobby = [];
393
+ // State
394
+ this._knownPeers = /* @__PURE__ */ new Set();
395
+ this._lobbyAnnounceTimer = null;
396
+ this._destroyed = false;
397
+ this.selfId = generatePeerId();
398
+ this._appId = appId;
399
+ this._config = config;
400
+ }
401
+ // ── Public API ──
402
+ async init() {
403
+ return this._ensureInit();
404
+ }
405
+ async joinRoom(roomId, peerMeta) {
406
+ await this._ensureInit();
407
+ if (!this._db || !this._fb) throw new Error("Firebase not initialized");
408
+ this._joinGeneration++;
409
+ this._roomId = roomId;
410
+ this._peerMeta = peerMeta;
411
+ const { ref, set, onChildAdded, onChildRemoved, onDisconnect, remove } = this._fb;
412
+ const paths = firebasePaths(this._appId, roomId, this.selfId);
413
+ await remove(ref(this._db, paths.peerSignalInbox)).catch(() => {
414
+ });
415
+ const presenceRef = ref(this._db, paths.peerPresence);
416
+ await set(presenceRef, {
417
+ peerId: this.selfId,
418
+ meta: peerMeta,
419
+ ts: Date.now()
420
+ });
421
+ onDisconnect(presenceRef).remove();
422
+ const peersRef = ref(this._db, paths.peers);
423
+ const addedUnsub = onChildAdded(peersRef, (snapshot) => {
424
+ const data = snapshot.val();
425
+ if (!data || data.peerId === this.selfId) return;
426
+ if (!this._knownPeers.has(data.peerId)) {
427
+ this._knownPeers.add(data.peerId);
428
+ for (const cb of this._onPeerDiscovered) cb(data.peerId, data.meta ?? {});
429
+ }
430
+ });
431
+ this._listeners.push(() => addedUnsub());
432
+ const removedUnsub = onChildRemoved(peersRef, (snapshot) => {
433
+ const data = snapshot.val();
434
+ const peerId = data?.peerId ?? snapshot.key;
435
+ if (peerId && this._knownPeers.has(peerId)) {
436
+ this._knownPeers.delete(peerId);
437
+ for (const cb of this._onPeerLeft) cb(peerId);
438
+ }
439
+ });
440
+ this._listeners.push(() => removedUnsub());
441
+ const signalRef = ref(this._db, paths.peerSignalInbox);
442
+ const signalUnsub = onChildAdded(signalRef, (snapshot) => {
443
+ const msg = snapshot.val();
444
+ if (!msg || msg.from === this.selfId) return;
445
+ for (const cb of this._onSignal) cb(msg.from, msg.data);
446
+ remove(snapshot.ref);
447
+ });
448
+ this._listeners.push(() => signalUnsub());
449
+ }
450
+ async leaveRoom() {
451
+ if (!this._db || !this._fb || !this._roomId) return;
452
+ const leavingRoomId = this._roomId;
453
+ const generation = this._joinGeneration;
454
+ const { ref, remove } = this._fb;
455
+ const paths = firebasePaths(this._appId, leavingRoomId, this.selfId);
456
+ for (const unsub of this._listeners) unsub();
457
+ this._listeners = [];
458
+ await Promise.all([
459
+ remove(ref(this._db, paths.peerPresence)),
460
+ remove(ref(this._db, paths.peerSignalInbox))
461
+ ]).catch(() => {
462
+ });
463
+ if (this._lobbyAnnounceTimer) {
464
+ clearInterval(this._lobbyAnnounceTimer);
465
+ this._lobbyAnnounceTimer = null;
466
+ }
467
+ this._knownPeers.clear();
468
+ if (this._joinGeneration === generation) {
469
+ this._roomId = null;
470
+ }
471
+ }
472
+ signal(targetPeerId, data) {
473
+ if (!this._db || !this._fb || !this._roomId) return;
474
+ const { ref, push } = this._fb;
475
+ const inboxPath = firebasePaths(this._appId, this._roomId, targetPeerId).peerSignalInbox;
476
+ push(ref(this._db, inboxPath), {
477
+ from: this.selfId,
478
+ data: sanitizeForFirebase(data),
479
+ ts: Date.now()
480
+ });
481
+ }
482
+ subscribeToLobby(cb) {
483
+ this._onLobby.push(cb);
484
+ this._ensureInit().then(() => {
485
+ if (!this._db || !this._fb || this._destroyed) return;
486
+ const { ref, onValue } = this._fb;
487
+ const paths = firebasePaths(this._appId, "", "");
488
+ const lobbyRef = ref(this._db, paths.lobby);
489
+ const unsub = onValue(lobbyRef, (snapshot) => {
490
+ const data = snapshot.val();
491
+ if (!data) {
492
+ for (const lcb of this._onLobby) lcb([]);
493
+ return;
494
+ }
495
+ const now = Date.now();
496
+ const rooms = Object.values(data).filter(
497
+ (r) => r && now - (r.lastSeen ?? 0) < ROOM_ANNOUNCE_EXPIRY_MS
498
+ );
499
+ for (const lcb of this._onLobby) lcb(rooms);
500
+ });
501
+ this._listeners.push(() => unsub());
502
+ });
503
+ return () => {
504
+ removeFromArray(this._onLobby, cb);
505
+ };
506
+ }
507
+ announceRoom(announcement) {
508
+ if (!this._db || !this._fb) return;
509
+ const { ref, set } = this._fb;
510
+ const paths = firebasePaths(this._appId, announcement.roomId, "");
511
+ announcement.lastSeen = Date.now();
512
+ set(ref(this._db, paths.roomLobbyEntry), announcement);
513
+ if (this._lobbyAnnounceTimer) clearInterval(this._lobbyAnnounceTimer);
514
+ this._lobbyAnnounceTimer = setInterval(() => {
515
+ announcement.lastSeen = Date.now();
516
+ set(ref(this._db, paths.roomLobbyEntry), announcement);
517
+ }, ROOM_ANNOUNCE_INTERVAL_MS);
518
+ }
519
+ removeRoomAnnouncement(roomId) {
520
+ if (!this._db || !this._fb) return;
521
+ const { ref, remove } = this._fb;
522
+ remove(ref(this._db, firebasePaths(this._appId, roomId, "").roomLobbyEntry));
523
+ if (this._lobbyAnnounceTimer) {
524
+ clearInterval(this._lobbyAnnounceTimer);
525
+ this._lobbyAnnounceTimer = null;
526
+ }
527
+ }
528
+ onPeerDiscovered(cb) {
529
+ this._onPeerDiscovered.push(cb);
530
+ return () => {
531
+ removeFromArray(this._onPeerDiscovered, cb);
532
+ };
533
+ }
534
+ onPeerLeft(cb) {
535
+ this._onPeerLeft.push(cb);
536
+ return () => {
537
+ removeFromArray(this._onPeerLeft, cb);
538
+ };
539
+ }
540
+ onSignal(cb) {
541
+ this._onSignal.push(cb);
542
+ return () => {
543
+ removeFromArray(this._onSignal, cb);
544
+ };
545
+ }
546
+ destroy() {
547
+ this._destroyed = true;
548
+ for (const unsub of this._listeners) unsub();
549
+ this._listeners = [];
550
+ if (this._lobbyAnnounceTimer) {
551
+ clearInterval(this._lobbyAnnounceTimer);
552
+ this._lobbyAnnounceTimer = null;
553
+ }
554
+ if (this._db && this._fb && this._roomId) {
555
+ const { ref, remove } = this._fb;
556
+ const paths = firebasePaths(this._appId, this._roomId, this.selfId);
557
+ remove(ref(this._db, paths.peerPresence)).catch(() => {
558
+ });
559
+ remove(ref(this._db, paths.peerSignalInbox)).catch(() => {
560
+ });
561
+ }
562
+ if (this._ownApp && this._firebaseApp) {
563
+ import("firebase/app").then(({ deleteApp }) => {
564
+ deleteApp(this._firebaseApp).catch(() => {
565
+ });
566
+ });
567
+ }
568
+ this._db = null;
569
+ this._firebaseApp = null;
570
+ this._fb = null;
571
+ this._knownPeers.clear();
572
+ this._onPeerDiscovered = [];
573
+ this._onPeerLeft = [];
574
+ this._onSignal = [];
575
+ this._onLobby = [];
576
+ }
577
+ // ── Private ──
578
+ _ensureInit() {
579
+ if (!this._initPromise) {
580
+ this._initPromise = this._doInit();
581
+ }
582
+ return this._initPromise;
583
+ }
584
+ async _doInit() {
585
+ const { initializeApp, getApps } = await import("firebase/app");
586
+ const {
587
+ getDatabase,
588
+ ref,
589
+ set,
590
+ push,
591
+ remove,
592
+ onValue,
593
+ onChildAdded,
594
+ onChildRemoved,
595
+ onDisconnect
596
+ } = await import("firebase/database");
597
+ this._fb = { ref, set, push, remove, onValue, onChildAdded, onChildRemoved, onDisconnect };
598
+ if (this._config.firebaseApp) {
599
+ this._firebaseApp = this._config.firebaseApp;
600
+ this._ownApp = false;
601
+ } else {
602
+ const appName = `carver_${this._appId}`;
603
+ const existing = getApps().find((a) => a.name === appName);
604
+ if (existing) {
605
+ this._firebaseApp = existing;
606
+ this._ownApp = false;
607
+ } else {
608
+ this._firebaseApp = initializeApp({ databaseURL: this._config.databaseURL }, appName);
609
+ this._ownApp = true;
610
+ }
611
+ }
612
+ this._db = getDatabase(this._firebaseApp);
613
+ }
614
+ };
615
+ function sanitizeForFirebase(obj) {
616
+ if (obj === null) return "__null__";
617
+ if (Array.isArray(obj)) return obj.map(sanitizeForFirebase);
618
+ if (typeof obj === "object" && obj !== null) {
619
+ const result = {};
620
+ for (const [key, value] of Object.entries(obj)) {
621
+ result[key] = value === null ? "__null__" : sanitizeForFirebase(value);
622
+ }
623
+ return result;
624
+ }
625
+ return obj;
626
+ }
627
+
628
+ // src/core/TickKeeper.ts
629
+ var _TickKeeper = class _TickKeeper {
630
+ constructor(tickRate = 60) {
631
+ this._accumulator = 0;
632
+ this._tick = 0;
633
+ this._serverTick = 0;
634
+ this._alpha = 0;
635
+ this._timeScale = 1;
636
+ this._tickRate = tickRate;
637
+ this._tickDelta = 1 / tickRate;
638
+ }
639
+ get tick() {
640
+ return this._tick;
641
+ }
642
+ get serverTick() {
643
+ return this._serverTick;
644
+ }
645
+ get tickDelta() {
646
+ return this._tickDelta;
647
+ }
648
+ get tickRate() {
649
+ return this._tickRate;
650
+ }
651
+ /** Interpolation alpha for rendering between ticks (0-1) */
652
+ get alpha() {
653
+ return this._alpha;
654
+ }
655
+ /** Current time scale (affected by drift correction) */
656
+ get timeScale() {
657
+ return this._timeScale;
658
+ }
659
+ /** Ticks ahead of server (positive = ahead, negative = behind) */
660
+ get drift() {
661
+ return this._tick - this._serverTick;
662
+ }
663
+ /** Update server tick from received snapshot */
664
+ setServerTick(serverTick) {
665
+ this._serverTick = serverTick;
666
+ this._updateDriftCorrection();
667
+ }
668
+ /**
669
+ * Accumulate time and return the number of fixed ticks to process.
670
+ * Call this once per render frame with the raw frame delta.
671
+ */
672
+ update(rawDelta) {
673
+ const maxDelta = this._tickDelta * 8;
674
+ const delta = Math.min(rawDelta, maxDelta) * this._timeScale;
675
+ this._accumulator += delta;
676
+ let ticksThisFrame = 0;
677
+ while (this._accumulator >= this._tickDelta) {
678
+ this._accumulator -= this._tickDelta;
679
+ this._tick++;
680
+ ticksThisFrame++;
681
+ }
682
+ this._alpha = this._accumulator / this._tickDelta;
683
+ return ticksThisFrame;
684
+ }
685
+ /** Reset to initial state */
686
+ reset() {
687
+ this._accumulator = 0;
688
+ this._tick = 0;
689
+ this._serverTick = 0;
690
+ this._alpha = 0;
691
+ this._timeScale = 1;
692
+ }
693
+ /** Set tick rate (updates tickDelta accordingly) */
694
+ setTickRate(rate) {
695
+ this._tickRate = rate;
696
+ this._tickDelta = 1 / rate;
697
+ }
698
+ _updateDriftCorrection() {
699
+ const drift = this.drift;
700
+ if (drift < _TickKeeper.DRIFT_BEHIND_THRESHOLD) {
701
+ this._timeScale = _TickKeeper.SPEED_UP_SCALE;
702
+ } else if (drift > _TickKeeper.DRIFT_AHEAD_THRESHOLD) {
703
+ this._timeScale = _TickKeeper.SLOW_DOWN_SCALE;
704
+ } else {
705
+ this._timeScale = _TickKeeper.NORMAL_SCALE;
706
+ }
707
+ }
708
+ };
709
+ // Drift correction zones
710
+ _TickKeeper.DRIFT_BEHIND_THRESHOLD = -5;
711
+ _TickKeeper.DRIFT_AHEAD_THRESHOLD = 5;
712
+ _TickKeeper.SPEED_UP_SCALE = 1.5;
713
+ _TickKeeper.SLOW_DOWN_SCALE = 0.1;
714
+ _TickKeeper.NORMAL_SCALE = 1;
715
+ var TickKeeper = _TickKeeper;
716
+
717
+ // src/core/codec.ts
718
+ var import_msgpackr = require("msgpackr");
719
+ var DEFAULT_THRESHOLDS = {
720
+ position: 0.01,
721
+ rotation: 1e-3,
722
+ velocity: 0.05,
723
+ custom: "strict"
724
+ };
725
+ var SnapshotBuffer = class {
726
+ constructor(capacity = 120) {
727
+ this._capacity = capacity;
728
+ this._buffer = /* @__PURE__ */ new Map();
729
+ }
730
+ /** Store a snapshot at the given tick */
731
+ store(tick, entities) {
732
+ this._buffer.set(tick, entities);
733
+ if (this._buffer.size > this._capacity) {
734
+ const sortedTicks = Array.from(this._buffer.keys()).sort((a, b) => a - b);
735
+ const toRemove = sortedTicks.length - this._capacity;
736
+ for (let i = 0; i < toRemove; i++) {
737
+ this._buffer.delete(sortedTicks[i]);
738
+ }
739
+ }
740
+ }
741
+ /** Get a snapshot at the given tick */
742
+ get(tick) {
743
+ return this._buffer.get(tick);
744
+ }
745
+ /** Clear all stored snapshots */
746
+ clear() {
747
+ this._buffer.clear();
748
+ }
749
+ };
750
+ var Codec = class {
751
+ constructor(options) {
752
+ this._thresholds = { ...DEFAULT_THRESHOLDS, ...options?.thresholds };
753
+ this._quantize = options?.quantize;
754
+ this._is2D = options?.is2D ?? false;
755
+ }
756
+ /** Serialize entity states to binary (msgpackr) */
757
+ serialize(entities) {
758
+ const quantized = this._quantize ? entities.map((e) => this._quantizeEntity(e)) : entities;
759
+ return (0, import_msgpackr.pack)(quantized);
760
+ }
761
+ /** Deserialize binary to entity states */
762
+ deserialize(data) {
763
+ return (0, import_msgpackr.unpack)(data);
764
+ }
765
+ /**
766
+ * Compute delta: only include entities that changed beyond thresholds
767
+ * since the baseline snapshot.
768
+ * Returns null if nothing changed.
769
+ */
770
+ computeDelta(current, baseline) {
771
+ if (!baseline) {
772
+ return Array.from(current.values());
773
+ }
774
+ const changed = [];
775
+ for (const [id, entity] of current) {
776
+ const prev = baseline.get(id);
777
+ if (!prev || this._hasChanged(entity, prev)) {
778
+ changed.push(entity);
779
+ }
780
+ }
781
+ for (const id of baseline.keys()) {
782
+ if (!current.has(id)) {
783
+ if (this._is2D) {
784
+ changed.push({ id, x: 0, y: 0, a: 0, vx: 0, vy: 0, va: 0, c: { __removed: true } });
785
+ } else {
786
+ changed.push({ id, x: 0, y: 0, z: 0, qx: 0, qy: 0, qz: 0, qw: 1, vx: 0, vy: 0, vz: 0, wx: 0, wy: 0, wz: 0, c: { __removed: true } });
787
+ }
788
+ }
789
+ }
790
+ return changed.length > 0 ? changed : null;
791
+ }
792
+ /** Serialize a delta snapshot packet */
793
+ serializeDelta(tick, baseTick, current, baseline) {
794
+ const delta = this.computeDelta(current, baseline);
795
+ if (!delta) return null;
796
+ const packet = {
797
+ t: tick,
798
+ b: baseline ? baseTick : -1,
799
+ // -1 = keyframe
800
+ s: this.serialize(delta)
801
+ };
802
+ return (0, import_msgpackr.pack)(packet);
803
+ }
804
+ /** Deserialize a snapshot packet */
805
+ deserializePacket(data) {
806
+ const packet = (0, import_msgpackr.unpack)(data);
807
+ return {
808
+ tick: packet.t,
809
+ baseTick: packet.b,
810
+ entities: this.deserialize(packet.s)
811
+ };
812
+ }
813
+ _hasChanged(current, prev) {
814
+ const t = this._thresholds;
815
+ if (Math.abs(current.x - prev.x) > t.position) return true;
816
+ if (Math.abs(current.y - prev.y) > t.position) return true;
817
+ if ("z" in current && "z" in prev) {
818
+ const c = current;
819
+ const p = prev;
820
+ if (Math.abs(c.z - p.z) > t.position) return true;
821
+ if (Math.abs(c.qx - p.qx) > t.rotation) return true;
822
+ if (Math.abs(c.qy - p.qy) > t.rotation) return true;
823
+ if (Math.abs(c.qz - p.qz) > t.rotation) return true;
824
+ if (Math.abs(c.qw - p.qw) > t.rotation) return true;
825
+ if (Math.abs(c.vx - p.vx) > t.velocity) return true;
826
+ if (Math.abs(c.vy - p.vy) > t.velocity) return true;
827
+ if (Math.abs(c.vz - p.vz) > t.velocity) return true;
828
+ if (Math.abs(c.wx - p.wx) > t.velocity) return true;
829
+ if (Math.abs(c.wy - p.wy) > t.velocity) return true;
830
+ if (Math.abs(c.wz - p.wz) > t.velocity) return true;
831
+ } else {
832
+ const c = current;
833
+ const p = prev;
834
+ if (Math.abs(c.a - p.a) > t.rotation) return true;
835
+ if (Math.abs(c.vx - p.vx) > t.velocity) return true;
836
+ if (Math.abs(c.vy - p.vy) > t.velocity) return true;
837
+ if (Math.abs(c.va - p.va) > t.velocity) return true;
838
+ }
839
+ if (current.c || prev.c) {
840
+ const cc = current.c ?? {};
841
+ const pc = prev.c ?? {};
842
+ const allKeys = /* @__PURE__ */ new Set([...Object.keys(cc), ...Object.keys(pc)]);
843
+ for (const key of allKeys) {
844
+ if (t.custom === "strict") {
845
+ if (cc[key] !== pc[key]) return true;
846
+ } else {
847
+ const diff = typeof cc[key] === "number" && typeof pc[key] === "number" ? Math.abs(cc[key] - pc[key]) : cc[key] === pc[key] ? 0 : 1;
848
+ if (diff > t.custom) return true;
849
+ }
850
+ }
851
+ }
852
+ return false;
853
+ }
854
+ _quantizeEntity(entity) {
855
+ const q = this._quantize;
856
+ const result = { ...entity };
857
+ if (q.position !== void 0) {
858
+ const m = Math.pow(10, q.position);
859
+ result.x = Math.round(result.x * m) / m;
860
+ result.y = Math.round(result.y * m) / m;
861
+ if ("z" in result) {
862
+ result.z = Math.round(result.z * m) / m;
863
+ }
864
+ }
865
+ if (q.rotation !== void 0) {
866
+ const m = Math.pow(10, q.rotation);
867
+ if ("a" in result) {
868
+ result.a = Math.round(result.a * m) / m;
869
+ } else if ("qx" in result) {
870
+ const r = result;
871
+ r.qx = Math.round(r.qx * m) / m;
872
+ r.qy = Math.round(r.qy * m) / m;
873
+ r.qz = Math.round(r.qz * m) / m;
874
+ r.qw = Math.round(r.qw * m) / m;
875
+ }
876
+ }
877
+ if (q.velocity !== void 0) {
878
+ const m = Math.pow(10, q.velocity);
879
+ result.vx = Math.round(result.vx * m) / m;
880
+ result.vy = Math.round(result.vy * m) / m;
881
+ if ("vz" in result) {
882
+ const r = result;
883
+ r.vz = Math.round(r.vz * m) / m;
884
+ r.wx = Math.round(r.wx * m) / m;
885
+ r.wy = Math.round(r.wy * m) / m;
886
+ r.wz = Math.round(r.wz * m) / m;
887
+ }
888
+ if ("va" in result) {
889
+ result.va = Math.round(result.va * m) / m;
890
+ }
891
+ }
892
+ return result;
893
+ }
894
+ };
895
+
896
+ // src/core/NetworkManager.ts
897
+ var NetworkManager = class {
898
+ constructor(options = {}) {
899
+ // Transport
900
+ this._transport = null;
901
+ this._connectionState = "disconnected";
902
+ // Room state
903
+ this._room = null;
904
+ this._players = /* @__PURE__ */ new Map();
905
+ // Sync state
906
+ this._syncMode = "snapshot";
907
+ this._networkQuality = "good";
908
+ // Change listeners
909
+ this._connectionListeners = [];
910
+ this._playerListeners = [];
911
+ this._roomListeners = [];
912
+ this._errorListeners = [];
913
+ this._options = options;
914
+ this._syncMode = options.mode ?? "snapshot";
915
+ this._tickKeeper = new TickKeeper(options.tickRate ?? 60);
916
+ this._codec = new Codec({
917
+ thresholds: options.deltaThresholds,
918
+ quantize: options.quantize
919
+ });
920
+ this._snapshotBuffer = new SnapshotBuffer();
921
+ }
922
+ // -- Getters --
923
+ get transport() {
924
+ return this._transport;
925
+ }
926
+ get connectionState() {
927
+ return this._connectionState;
928
+ }
929
+ get room() {
930
+ return this._room;
931
+ }
932
+ get players() {
933
+ return this._players;
934
+ }
935
+ get selfId() {
936
+ return this._transport?.peerId || null;
937
+ }
938
+ get isHost() {
939
+ return this._transport?.isHost ?? false;
940
+ }
941
+ get hostId() {
942
+ return this._transport?.hostId ?? null;
943
+ }
944
+ get syncMode() {
945
+ return this._syncMode;
946
+ }
947
+ get tickKeeper() {
948
+ return this._tickKeeper;
949
+ }
950
+ get codec() {
951
+ return this._codec;
952
+ }
953
+ get snapshotBuffer() {
954
+ return this._snapshotBuffer;
955
+ }
956
+ get networkQuality() {
957
+ return this._networkQuality;
958
+ }
959
+ get options() {
960
+ return this._options;
961
+ }
962
+ // -- Transport management --
963
+ setTransport(transport) {
964
+ this._transport = transport;
965
+ transport.onPeerJoin((peerId) => {
966
+ if (!this._players.has(peerId)) {
967
+ this._players.set(peerId, {
968
+ peerId,
969
+ displayName: `Player-${peerId.slice(0, 4)}`,
970
+ isHost: peerId === transport.hostId,
971
+ isSelf: false,
972
+ isReady: false,
973
+ isConnected: true,
974
+ metadata: {},
975
+ latencyMs: 0,
976
+ joinedAt: Date.now()
977
+ });
978
+ }
979
+ this._notifyPlayerListeners();
980
+ });
981
+ transport.onPeerUpdated((player) => {
982
+ this._players.set(player.peerId, {
983
+ ...player,
984
+ isSelf: player.peerId === this.selfId
985
+ });
986
+ this._notifyPlayerListeners();
987
+ });
988
+ transport.onPeerLeave((peerId) => {
989
+ this._players.delete(peerId);
990
+ this._notifyPlayerListeners();
991
+ });
992
+ transport.onHostChanged((_newHostId) => {
993
+ this._notifyRoomListeners();
994
+ });
995
+ }
996
+ // -- Connection state --
997
+ setConnectionState(state) {
998
+ this._connectionState = state;
999
+ for (const listener of this._connectionListeners) listener(state);
1000
+ }
1001
+ onConnectionStateChange(cb) {
1002
+ this._connectionListeners.push(cb);
1003
+ return () => {
1004
+ const idx = this._connectionListeners.indexOf(cb);
1005
+ if (idx >= 0) this._connectionListeners.splice(idx, 1);
1006
+ };
1007
+ }
1008
+ // -- Room state --
1009
+ setRoom(room) {
1010
+ this._room = room;
1011
+ this._notifyRoomListeners();
1012
+ }
1013
+ onRoomChange(cb) {
1014
+ this._roomListeners.push(cb);
1015
+ return () => {
1016
+ const idx = this._roomListeners.indexOf(cb);
1017
+ if (idx >= 0) this._roomListeners.splice(idx, 1);
1018
+ };
1019
+ }
1020
+ // -- Players --
1021
+ setPlayers(players) {
1022
+ this._players.clear();
1023
+ for (const p of players) {
1024
+ this._players.set(p.peerId, p);
1025
+ }
1026
+ this._notifyPlayerListeners();
1027
+ }
1028
+ updatePlayer(player) {
1029
+ this._players.set(player.peerId, player);
1030
+ this._notifyPlayerListeners();
1031
+ }
1032
+ removePlayer(peerId) {
1033
+ this._players.delete(peerId);
1034
+ this._notifyPlayerListeners();
1035
+ }
1036
+ onPlayersChange(cb) {
1037
+ this._playerListeners.push(cb);
1038
+ return () => {
1039
+ const idx = this._playerListeners.indexOf(cb);
1040
+ if (idx >= 0) this._playerListeners.splice(idx, 1);
1041
+ };
1042
+ }
1043
+ // -- Errors --
1044
+ emitError(error) {
1045
+ for (const listener of this._errorListeners) listener(error);
1046
+ }
1047
+ onError(cb) {
1048
+ this._errorListeners.push(cb);
1049
+ return () => {
1050
+ const idx = this._errorListeners.indexOf(cb);
1051
+ if (idx >= 0) this._errorListeners.splice(idx, 1);
1052
+ };
1053
+ }
1054
+ // -- Network quality --
1055
+ setNetworkQuality(quality) {
1056
+ this._networkQuality = quality;
1057
+ }
1058
+ // -- Sync options --
1059
+ updateOptions(options) {
1060
+ this._options = options;
1061
+ if (options.mode) this._syncMode = options.mode;
1062
+ if (options.tickRate) this._tickKeeper.setTickRate(options.tickRate);
1063
+ if (options.deltaThresholds || options.quantize) {
1064
+ this._codec = new Codec({
1065
+ thresholds: options.deltaThresholds,
1066
+ quantize: options.quantize
1067
+ });
1068
+ }
1069
+ }
1070
+ // -- Cleanup --
1071
+ destroy() {
1072
+ this._transport?.disconnect();
1073
+ this._transport = null;
1074
+ this._connectionState = "disconnected";
1075
+ this._room = null;
1076
+ this._players.clear();
1077
+ this._tickKeeper.reset();
1078
+ this._snapshotBuffer.clear();
1079
+ this._connectionListeners = [];
1080
+ this._playerListeners = [];
1081
+ this._roomListeners = [];
1082
+ this._errorListeners = [];
1083
+ }
1084
+ // -- Private --
1085
+ _notifyPlayerListeners() {
1086
+ for (const listener of this._playerListeners) listener();
1087
+ }
1088
+ _notifyRoomListeners() {
1089
+ for (const listener of this._roomListeners) listener();
1090
+ }
1091
+ };
1092
+
1093
+ // src/core/MultiplayerContext.ts
1094
+ var import_react = require("react");
1095
+ var MultiplayerContext = (0, import_react.createContext)(null);
1096
+ function useMultiplayerContext() {
1097
+ const ctx = (0, import_react.useContext)(MultiplayerContext);
1098
+ if (!ctx) {
1099
+ throw new Error("useMultiplayerContext must be used inside a <MultiplayerProvider>.");
1100
+ }
1101
+ return ctx;
1102
+ }
1103
+
1104
+ // src/components/MultiplayerProvider.ts
1105
+ function createStrategy(appId, config) {
1106
+ if (!config || config.type === "mqtt") {
1107
+ return new MqttStrategy(appId, config ?? { type: "mqtt" });
1108
+ }
1109
+ if (config.type === "firebase") {
1110
+ return new FirebaseStrategy(appId, config);
1111
+ }
1112
+ throw new Error(`Unknown strategy type: ${config.type}`);
1113
+ }
1114
+ function MultiplayerProvider({
1115
+ appId,
1116
+ strategy: strategyConfig,
1117
+ iceServers,
1118
+ children
1119
+ }) {
1120
+ const managerRef = (0, import_react2.useRef)(null);
1121
+ const strategyRef = (0, import_react2.useRef)(null);
1122
+ if (!managerRef.current) {
1123
+ managerRef.current = new NetworkManager();
1124
+ }
1125
+ if (!strategyRef.current) {
1126
+ strategyRef.current = createStrategy(appId, strategyConfig);
1127
+ }
1128
+ (0, import_react2.useEffect)(() => {
1129
+ return () => {
1130
+ strategyRef.current?.destroy();
1131
+ strategyRef.current = null;
1132
+ managerRef.current?.destroy();
1133
+ managerRef.current = null;
1134
+ };
1135
+ }, []);
1136
+ const value = {
1137
+ appId,
1138
+ strategy: strategyRef.current,
1139
+ iceServers,
1140
+ networkManager: managerRef.current
1141
+ };
1142
+ return (0, import_react2.createElement)(MultiplayerContext.Provider, { value }, children);
1143
+ }
1144
+
1145
+ // src/components/MultiplayerBridge.ts
1146
+ var import_react3 = require("react");
1147
+ function MultiplayerBridge({ children }) {
1148
+ const ctx = (0, import_react3.useContext)(MultiplayerContext);
1149
+ if (!ctx) return (0, import_react3.createElement)("group", null, children);
1150
+ return (0, import_react3.createElement)(MultiplayerContext.Provider, { value: ctx }, children);
1151
+ }
1152
+
1153
+ // src/hooks/useRoom.ts
1154
+ var import_react4 = require("react");
1155
+
1156
+ // src/transport/webrtc/ice.ts
1157
+ var DEFAULT_STUN_SERVERS = [
1158
+ { urls: "stun:stun.l.google.com:19302" },
1159
+ { urls: "stun:stun1.l.google.com:19302" },
1160
+ { urls: "stun:stun2.l.google.com:19302" },
1161
+ { urls: "stun:stun.cloudflare.com:3478" }
1162
+ ];
1163
+ function buildICEConfig(options) {
1164
+ const servers = options?.iceServers && options.iceServers.length > 0 ? options.iceServers : DEFAULT_STUN_SERVERS;
1165
+ return {
1166
+ iceServers: servers,
1167
+ iceCandidatePoolSize: 10,
1168
+ iceTransportPolicy: options?.iceTransportPolicy ?? "all"
1169
+ };
1170
+ }
1171
+
1172
+ // src/transport/webrtc/peer.ts
1173
+ var PeerConnection = class {
1174
+ constructor(peerId, config, events) {
1175
+ this._channels = /* @__PURE__ */ new Map();
1176
+ this._state = "connecting";
1177
+ this._remoteDescriptionSet = false;
1178
+ this._pendingCandidates = [];
1179
+ this.peerId = peerId;
1180
+ this._events = events;
1181
+ this._connection = new RTCPeerConnection(config);
1182
+ this._connection.onicecandidate = (e) => {
1183
+ if (e.candidate) {
1184
+ this._events.onIceCandidate(e.candidate);
1185
+ }
1186
+ };
1187
+ this._connection.oniceconnectionstatechange = () => {
1188
+ this._updateState();
1189
+ };
1190
+ this._connection.onconnectionstatechange = () => {
1191
+ this._updateState();
1192
+ };
1193
+ this._connection.ondatachannel = (e) => {
1194
+ const channel = e.channel;
1195
+ this._channels.set(channel.label, channel);
1196
+ this._events.onDataChannel(channel);
1197
+ };
1198
+ }
1199
+ get state() {
1200
+ return this._state;
1201
+ }
1202
+ get connection() {
1203
+ return this._connection;
1204
+ }
1205
+ _updateState() {
1206
+ const iceState = this._connection.iceConnectionState;
1207
+ const connState = this._connection.connectionState;
1208
+ let newState;
1209
+ if (connState === "connected" || iceState === "connected") {
1210
+ newState = "connected";
1211
+ } else if (connState === "failed" || iceState === "failed") {
1212
+ newState = "failed";
1213
+ } else if (connState === "closed" || iceState === "closed" || iceState === "disconnected") {
1214
+ newState = "disconnected";
1215
+ } else {
1216
+ newState = "connecting";
1217
+ }
1218
+ if (newState !== this._state) {
1219
+ this._state = newState;
1220
+ this._events.onStateChange(newState);
1221
+ }
1222
+ }
1223
+ async createOffer() {
1224
+ const offer = await this._connection.createOffer();
1225
+ await this._connection.setLocalDescription(offer);
1226
+ return offer;
1227
+ }
1228
+ async handleOffer(offer) {
1229
+ await this._connection.setRemoteDescription(new RTCSessionDescription(offer));
1230
+ this._remoteDescriptionSet = true;
1231
+ await this._flushPendingCandidates();
1232
+ const answer = await this._connection.createAnswer();
1233
+ await this._connection.setLocalDescription(answer);
1234
+ return answer;
1235
+ }
1236
+ async handleAnswer(answer) {
1237
+ await this._connection.setRemoteDescription(new RTCSessionDescription(answer));
1238
+ this._remoteDescriptionSet = true;
1239
+ await this._flushPendingCandidates();
1240
+ }
1241
+ async addIceCandidate(candidate) {
1242
+ if (!this._remoteDescriptionSet) {
1243
+ this._pendingCandidates.push(candidate);
1244
+ return;
1245
+ }
1246
+ try {
1247
+ await this._connection.addIceCandidate(new RTCIceCandidate(candidate));
1248
+ } catch {
1249
+ }
1250
+ }
1251
+ async _flushPendingCandidates() {
1252
+ const candidates = this._pendingCandidates;
1253
+ this._pendingCandidates = [];
1254
+ for (const c of candidates) {
1255
+ try {
1256
+ await this._connection.addIceCandidate(new RTCIceCandidate(c));
1257
+ } catch {
1258
+ }
1259
+ }
1260
+ }
1261
+ createDataChannel(name, options) {
1262
+ const existing = this._channels.get(name);
1263
+ if (existing && existing.readyState !== "closed") {
1264
+ return existing;
1265
+ }
1266
+ const dcOptions = {};
1267
+ if (options?.reliable === false) {
1268
+ dcOptions.ordered = options?.ordered ?? false;
1269
+ dcOptions.maxRetransmits = options?.maxRetransmits ?? 0;
1270
+ } else {
1271
+ dcOptions.ordered = options?.ordered ?? true;
1272
+ }
1273
+ const channel = this._connection.createDataChannel(name, dcOptions);
1274
+ this._channels.set(name, channel);
1275
+ return channel;
1276
+ }
1277
+ getDataChannel(name) {
1278
+ return this._channels.get(name);
1279
+ }
1280
+ close() {
1281
+ for (const channel of this._channels.values()) {
1282
+ try {
1283
+ channel.close();
1284
+ } catch {
1285
+ }
1286
+ }
1287
+ this._channels.clear();
1288
+ this._pendingCandidates = [];
1289
+ this._remoteDescriptionSet = false;
1290
+ try {
1291
+ this._connection.close();
1292
+ } catch {
1293
+ }
1294
+ this._state = "disconnected";
1295
+ }
1296
+ };
1297
+
1298
+ // src/transport/webrtc/WebRTCTransport.ts
1299
+ var ROOM_CONTROL_CHANNEL = "carver:room-control";
1300
+ function electHost(peerIds) {
1301
+ return [...peerIds].sort()[0];
1302
+ }
1303
+ var WebRTCTransport = class {
1304
+ /**
1305
+ * @param strategy Shared SignalingStrategy instance (managed by MultiplayerProvider)
1306
+ * @param iceServers Optional ICE servers (STUN + TURN). Defaults to public STUN.
1307
+ * @param iceTransportPolicy 'all' (default) or 'relay' (force TURN only).
1308
+ */
1309
+ constructor(strategy, iceServers, iceTransportPolicy) {
1310
+ this._peers = /* @__PURE__ */ new Map();
1311
+ this._peerSet = /* @__PURE__ */ new Set();
1312
+ this._hostId = "";
1313
+ this._isHost = false;
1314
+ this._callbacks = {
1315
+ onPeerJoin: [],
1316
+ onPeerLeave: [],
1317
+ onPeerUpdated: [],
1318
+ onHostChanged: []
1319
+ };
1320
+ this._roomUpdatedCallbacks = [];
1321
+ this._channels = /* @__PURE__ */ new Map();
1322
+ this._rateLimitConfig = { maxMessagesPerSecond: 60, windowMs: 1e3 };
1323
+ this._rateLimitCounters = /* @__PURE__ */ new Map();
1324
+ this._connected = false;
1325
+ this._room = null;
1326
+ this._playerMap = /* @__PURE__ */ new Map();
1327
+ this._initialPeers = [];
1328
+ this._strategyUnsubs = [];
1329
+ this._strategy = strategy;
1330
+ this._peerId = strategy.selfId;
1331
+ this._iceConfig = buildICEConfig({ iceServers, iceTransportPolicy });
1332
+ }
1333
+ // ── CarverTransport getters ──
1334
+ get peerId() {
1335
+ return this._peerId;
1336
+ }
1337
+ get peers() {
1338
+ return this._peerSet;
1339
+ }
1340
+ get hostId() {
1341
+ return this._hostId;
1342
+ }
1343
+ get isHost() {
1344
+ return this._isHost;
1345
+ }
1346
+ get room() {
1347
+ return this._room ?? void 0;
1348
+ }
1349
+ get initialPlayers() {
1350
+ return this._initialPeers;
1351
+ }
1352
+ // ── Event registration ──
1353
+ onPeerJoin(cb) {
1354
+ this._callbacks.onPeerJoin.push(cb);
1355
+ }
1356
+ onPeerLeave(cb) {
1357
+ this._callbacks.onPeerLeave.push(cb);
1358
+ }
1359
+ onPeerUpdated(cb) {
1360
+ this._callbacks.onPeerUpdated.push(cb);
1361
+ }
1362
+ onRoomUpdated(cb) {
1363
+ this._roomUpdatedCallbacks.push(cb);
1364
+ }
1365
+ onHostChanged(cb) {
1366
+ this._callbacks.onHostChanged.push(cb);
1367
+ }
1368
+ // ── Channel management ──
1369
+ createChannel(name, options) {
1370
+ const existing = this._channels.get(name);
1371
+ if (existing) {
1372
+ return {
1373
+ send: (data, target) => this._sendOnChannel(name, data, target),
1374
+ onReceive: (cb) => {
1375
+ existing.receivers.push(cb);
1376
+ },
1377
+ close: () => {
1378
+ this._channels.delete(name);
1379
+ }
1380
+ };
1381
+ }
1382
+ const state = {
1383
+ name,
1384
+ options: options ?? { reliable: true, ordered: true },
1385
+ receivers: []
1386
+ };
1387
+ this._channels.set(name, state);
1388
+ if (this._connected) {
1389
+ for (const peer of this._peers.values()) {
1390
+ this._createDataChannelOnPeer(peer, name, state.options);
1391
+ }
1392
+ }
1393
+ return {
1394
+ send: (data, target) => this._sendOnChannel(name, data, target),
1395
+ onReceive: (cb) => {
1396
+ state.receivers.push(cb);
1397
+ },
1398
+ close: () => {
1399
+ this._channels.delete(name);
1400
+ }
1401
+ };
1402
+ }
1403
+ // ── Connect / Disconnect ──
1404
+ async connect(roomId, config) {
1405
+ if (config?.iceServers) {
1406
+ this._iceConfig = buildICEConfig({
1407
+ iceServers: config.iceServers,
1408
+ iceTransportPolicy: config.iceTransportPolicy
1409
+ });
1410
+ }
1411
+ this._setupRoomControlChannel();
1412
+ this._preRegisterChannel("carver:events", { reliable: true, ordered: true });
1413
+ this._preRegisterChannel("carver:snapshots", { reliable: false, ordered: false });
1414
+ this._preRegisterChannel("carver:acks", { reliable: true, ordered: true });
1415
+ this._preRegisterChannel("carver:inputs", { reliable: true, ordered: true });
1416
+ this._preRegisterChannel("carver:network-state", { reliable: true, ordered: true });
1417
+ this._strategyUnsubs.push(
1418
+ this._strategy.onPeerDiscovered((peerId, meta) => {
1419
+ this._onStrategyPeerDiscovered(peerId, meta);
1420
+ })
1421
+ );
1422
+ this._strategyUnsubs.push(
1423
+ this._strategy.onPeerLeft((peerId) => {
1424
+ this._removePeer(peerId);
1425
+ this._playerMap.delete(peerId);
1426
+ this._electAndSetHost();
1427
+ for (const cb of this._callbacks.onPeerLeave) cb(peerId);
1428
+ })
1429
+ );
1430
+ this._strategyUnsubs.push(
1431
+ this._strategy.onSignal((fromPeerId, data) => {
1432
+ this._handleSignal(fromPeerId, data);
1433
+ })
1434
+ );
1435
+ await this._strategy.joinRoom(roomId, {
1436
+ displayName: config?.displayName,
1437
+ ...config?.playerMetadata ?? {}
1438
+ });
1439
+ const selfPlayer = {
1440
+ peerId: this._peerId,
1441
+ displayName: config?.displayName ?? `Player-${this._peerId.slice(0, 4)}`,
1442
+ isHost: false,
1443
+ isSelf: true,
1444
+ isReady: false,
1445
+ isConnected: true,
1446
+ metadata: config?.playerMetadata ?? {},
1447
+ latencyMs: 0,
1448
+ joinedAt: Date.now()
1449
+ };
1450
+ this._playerMap.set(this._peerId, selfPlayer);
1451
+ this._electAndSetHost();
1452
+ this._room = {
1453
+ id: roomId,
1454
+ name: roomId,
1455
+ hostId: this._hostId,
1456
+ playerCount: this._playerMap.size,
1457
+ maxPlayers: config?.maxPlayers ?? 8,
1458
+ isPrivate: false,
1459
+ metadata: {},
1460
+ createdAt: Date.now(),
1461
+ state: "lobby"
1462
+ };
1463
+ this._initialPeers = Array.from(this._playerMap.values());
1464
+ this._connected = true;
1465
+ }
1466
+ disconnect() {
1467
+ this._connected = false;
1468
+ for (const unsub of this._strategyUnsubs) unsub();
1469
+ this._strategyUnsubs = [];
1470
+ for (const peer of this._peers.values()) peer.close();
1471
+ this._peers.clear();
1472
+ this._peerSet.clear();
1473
+ this._channels.clear();
1474
+ this._rateLimitCounters.clear();
1475
+ this._playerMap.clear();
1476
+ this._strategy.leaveRoom().catch(() => {
1477
+ });
1478
+ this._hostId = "";
1479
+ this._isHost = false;
1480
+ this._room = null;
1481
+ }
1482
+ /** Expose strategy for lobby hooks */
1483
+ get strategy() {
1484
+ return this._strategy;
1485
+ }
1486
+ // ── Channel pre-registration ──
1487
+ /**
1488
+ * Register a channel name and options without creating data channels yet.
1489
+ * When _connectToPeer runs, it iterates this._channels and creates data
1490
+ * channels for every registered name in the initial WebRTC offer.
1491
+ * Later, when EventSync/SnapshotSync call createChannel(), the idempotent
1492
+ * check returns the pre-registered entry and they just attach receivers.
1493
+ */
1494
+ _preRegisterChannel(name, options) {
1495
+ if (this._channels.has(name)) return;
1496
+ this._channels.set(name, { name, options, receivers: [] });
1497
+ }
1498
+ // ── Room management (over WebRTC data channels) ──
1499
+ setReady(ready) {
1500
+ this._sendControlMessage({ type: "request-ready", ready });
1501
+ }
1502
+ setMetadata(metadata) {
1503
+ this._sendControlMessage({ type: "request-metadata", metadata });
1504
+ }
1505
+ setRoomMetadata(metadata) {
1506
+ if (!this._isHost) return;
1507
+ this._sendControlMessage({ type: "request-room-metadata", metadata });
1508
+ }
1509
+ kick(peerId, reason) {
1510
+ if (!this._isHost) return;
1511
+ this._broadcastControlMessage({ type: "kick", peerId, reason });
1512
+ }
1513
+ transferHost(peerId) {
1514
+ if (!this._isHost) return;
1515
+ this._sendControlMessage({ type: "request-transfer-host", peerId });
1516
+ }
1517
+ setRoomState(state) {
1518
+ if (!this._isHost) return;
1519
+ this._sendControlMessage({ type: "request-room-state", state });
1520
+ }
1521
+ setMaxPlayers(n) {
1522
+ if (!this._isHost) return;
1523
+ this._sendControlMessage({ type: "request-max-players", maxPlayers: n });
1524
+ }
1525
+ lockRoom() {
1526
+ if (!this._isHost) return;
1527
+ this._sendControlMessage({ type: "request-lock" });
1528
+ }
1529
+ unlockRoom() {
1530
+ if (!this._isHost) return;
1531
+ this._sendControlMessage({ type: "request-unlock" });
1532
+ }
1533
+ /** No-op: lobby uses strategy.subscribeToLobby() directly */
1534
+ requestRoomList() {
1535
+ }
1536
+ // ── Private: Strategy callbacks ──
1537
+ _onStrategyPeerDiscovered(peerId, meta) {
1538
+ this._connectToPeer(peerId);
1539
+ this._peerSet.add(peerId);
1540
+ const player = {
1541
+ peerId,
1542
+ displayName: meta.displayName ?? `Player-${peerId.slice(0, 4)}`,
1543
+ isHost: false,
1544
+ isSelf: false,
1545
+ isReady: false,
1546
+ isConnected: true,
1547
+ metadata: meta,
1548
+ latencyMs: 0,
1549
+ joinedAt: Date.now()
1550
+ };
1551
+ this._playerMap.set(peerId, player);
1552
+ this._electAndSetHost();
1553
+ for (const cb of this._callbacks.onPeerJoin) cb(peerId);
1554
+ for (const cb of this._callbacks.onPeerUpdated) cb(player);
1555
+ }
1556
+ // ── Private: Room control channel ──
1557
+ _setupRoomControlChannel() {
1558
+ const ch = this.createChannel(ROOM_CONTROL_CHANNEL, {
1559
+ reliable: true,
1560
+ ordered: true
1561
+ });
1562
+ ch.onReceive((msg, peerId) => {
1563
+ this._handleControlMessage(msg, peerId);
1564
+ });
1565
+ }
1566
+ _handleControlMessage(msg, fromPeerId) {
1567
+ switch (msg.type) {
1568
+ case "player-updated": {
1569
+ this._playerMap.set(msg.player.peerId, msg.player);
1570
+ for (const cb of this._callbacks.onPeerUpdated) cb(msg.player);
1571
+ break;
1572
+ }
1573
+ case "room-updated": {
1574
+ if (this._room) {
1575
+ Object.assign(this._room, msg.room);
1576
+ for (const cb of this._roomUpdatedCallbacks) cb(this._room);
1577
+ }
1578
+ break;
1579
+ }
1580
+ case "kick": {
1581
+ if (msg.peerId === this._peerId) {
1582
+ this.disconnect();
1583
+ }
1584
+ break;
1585
+ }
1586
+ case "host-changed": {
1587
+ this._hostId = msg.newHostId;
1588
+ this._isHost = msg.newHostId === this._peerId;
1589
+ for (const cb of this._callbacks.onHostChanged) cb(msg.newHostId);
1590
+ break;
1591
+ }
1592
+ case "sync-state": {
1593
+ this._room = msg.room;
1594
+ for (const p of msg.players) {
1595
+ this._playerMap.set(p.peerId, { ...p, isSelf: p.peerId === this._peerId });
1596
+ for (const cb of this._callbacks.onPeerUpdated) cb(p);
1597
+ }
1598
+ for (const cb of this._roomUpdatedCallbacks) cb(msg.room);
1599
+ break;
1600
+ }
1601
+ // Host processes requests from peers
1602
+ case "request-ready": {
1603
+ if (!this._isHost) break;
1604
+ const p = this._playerMap.get(fromPeerId);
1605
+ if (p) {
1606
+ p.isReady = msg.ready;
1607
+ this._broadcastControlMessage({ type: "player-updated", player: p });
1608
+ }
1609
+ break;
1610
+ }
1611
+ case "request-metadata": {
1612
+ if (!this._isHost) break;
1613
+ const pm = this._playerMap.get(fromPeerId);
1614
+ if (pm) {
1615
+ pm.metadata = { ...pm.metadata, ...msg.metadata };
1616
+ this._broadcastControlMessage({ type: "player-updated", player: pm });
1617
+ }
1618
+ break;
1619
+ }
1620
+ case "request-room-metadata": {
1621
+ if (!this._isHost || !this._room) break;
1622
+ this._room.metadata = { ...this._room.metadata, ...msg.metadata };
1623
+ this._broadcastControlMessage({ type: "room-updated", room: this._room });
1624
+ break;
1625
+ }
1626
+ case "request-room-state": {
1627
+ if (!this._isHost || !this._room) break;
1628
+ this._room.state = msg.state;
1629
+ this._broadcastControlMessage({ type: "room-updated", room: this._room });
1630
+ break;
1631
+ }
1632
+ case "request-max-players": {
1633
+ if (!this._isHost || !this._room) break;
1634
+ this._room.maxPlayers = msg.maxPlayers;
1635
+ this._broadcastControlMessage({ type: "room-updated", room: this._room });
1636
+ break;
1637
+ }
1638
+ case "request-lock": {
1639
+ if (!this._isHost || !this._room) break;
1640
+ this._room.locked = true;
1641
+ this._broadcastControlMessage({ type: "room-updated", room: this._room });
1642
+ break;
1643
+ }
1644
+ case "request-unlock": {
1645
+ if (!this._isHost || !this._room) break;
1646
+ this._room.locked = false;
1647
+ this._broadcastControlMessage({ type: "room-updated", room: this._room });
1648
+ break;
1649
+ }
1650
+ case "request-transfer-host": {
1651
+ if (!this._isHost) break;
1652
+ this._hostId = msg.peerId;
1653
+ this._isHost = false;
1654
+ this._broadcastControlMessage({ type: "host-changed", newHostId: msg.peerId });
1655
+ break;
1656
+ }
1657
+ }
1658
+ }
1659
+ _sendControlMessage(msg) {
1660
+ if (this._isHost && msg.type.startsWith("request-")) {
1661
+ this._handleControlMessage(msg, this._peerId);
1662
+ return;
1663
+ }
1664
+ if (this._hostId && this._hostId !== this._peerId) {
1665
+ this._sendOnChannel(ROOM_CONTROL_CHANNEL, msg, this._hostId);
1666
+ }
1667
+ }
1668
+ _broadcastControlMessage(msg) {
1669
+ this._sendOnChannel(ROOM_CONTROL_CHANNEL, msg);
1670
+ this._handleControlMessage(msg, this._peerId);
1671
+ }
1672
+ // ── Private: Host election ──
1673
+ _electAndSetHost() {
1674
+ const allIds = [this._peerId, ...this._peerSet];
1675
+ const newHostId = electHost(allIds);
1676
+ const changed = newHostId !== this._hostId;
1677
+ this._hostId = newHostId;
1678
+ this._isHost = newHostId === this._peerId;
1679
+ for (const [id, p] of this._playerMap) {
1680
+ p.isHost = id === newHostId;
1681
+ }
1682
+ if (this._room) {
1683
+ this._room.hostId = newHostId;
1684
+ this._room.playerCount = this._playerMap.size;
1685
+ }
1686
+ if (changed) {
1687
+ for (const cb of this._callbacks.onHostChanged) cb(newHostId);
1688
+ }
1689
+ }
1690
+ // ── Private: WebRTC peer management ──
1691
+ _connectToPeer(peerId) {
1692
+ if (this._peers.has(peerId)) return;
1693
+ const peer = new PeerConnection(peerId, this._iceConfig, {
1694
+ onStateChange: (state) => {
1695
+ if (state === "connected" && this._isHost && this._room) {
1696
+ const syncMsg = {
1697
+ type: "sync-state",
1698
+ room: this._room,
1699
+ players: Array.from(this._playerMap.values())
1700
+ };
1701
+ setTimeout(() => {
1702
+ this._sendOnChannel(ROOM_CONTROL_CHANNEL, syncMsg, peerId);
1703
+ }, 100);
1704
+ }
1705
+ if (state === "failed" || state === "disconnected") {
1706
+ this._removePeer(peerId);
1707
+ this._playerMap.delete(peerId);
1708
+ this._electAndSetHost();
1709
+ for (const cb of this._callbacks.onPeerLeave) cb(peerId);
1710
+ }
1711
+ },
1712
+ onDataChannel: (channel) => {
1713
+ this._setupDataChannelReceiver(channel, peerId);
1714
+ },
1715
+ onIceCandidate: (candidate) => {
1716
+ this._strategy.signal(peerId, { type: "ice-candidate", candidate: candidate.toJSON() });
1717
+ }
1718
+ });
1719
+ this._peers.set(peerId, peer);
1720
+ this._peerSet.add(peerId);
1721
+ if (this._peerId < peerId) {
1722
+ for (const [name, state] of this._channels) {
1723
+ this._createDataChannelOnPeer(peer, name, state.options);
1724
+ }
1725
+ peer.createOffer().then((offer) => {
1726
+ this._strategy.signal(peerId, { type: "offer", sdp: offer });
1727
+ });
1728
+ }
1729
+ }
1730
+ async _handleSignal(peerId, data) {
1731
+ try {
1732
+ const signal = data;
1733
+ let peer = this._peers.get(peerId);
1734
+ if (signal.type === "offer") {
1735
+ if (!peer) {
1736
+ this._connectToPeer(peerId);
1737
+ peer = this._peers.get(peerId);
1738
+ }
1739
+ const answer = await peer.handleOffer(signal.sdp);
1740
+ this._strategy.signal(peerId, { type: "answer", sdp: answer });
1741
+ } else if (signal.type === "answer" && peer) {
1742
+ await peer.handleAnswer(signal.sdp);
1743
+ } else if (signal.type === "ice-candidate" && peer) {
1744
+ await peer.addIceCandidate(signal.candidate);
1745
+ }
1746
+ } catch (err) {
1747
+ if (typeof console !== "undefined") {
1748
+ console.error("[CarverJS] Signal handling failed:", err);
1749
+ }
1750
+ }
1751
+ }
1752
+ // ── Private: Data channel helpers ──
1753
+ _createDataChannelOnPeer(peer, name, options) {
1754
+ const channel = peer.createDataChannel(name, options);
1755
+ this._setupDataChannelReceiver(channel, peer.peerId);
1756
+ }
1757
+ _setupDataChannelReceiver(dataChannel, peerId) {
1758
+ const channelName = dataChannel.label;
1759
+ dataChannel.onmessage = (event) => {
1760
+ if (!this._checkRateLimit(peerId)) return;
1761
+ const channelState = this._channels.get(channelName);
1762
+ if (!channelState) return;
1763
+ try {
1764
+ const data = typeof event.data === "string" ? JSON.parse(event.data) : event.data;
1765
+ for (const receiver of channelState.receivers) receiver(data, peerId);
1766
+ } catch {
1767
+ }
1768
+ };
1769
+ }
1770
+ _sendOnChannel(channelName, data, target) {
1771
+ const serialized = typeof data === "object" && data !== null && !(data instanceof ArrayBuffer) && !(data instanceof Uint8Array) ? JSON.stringify(data) : data;
1772
+ const targets = target ? Array.isArray(target) ? target : [target] : Array.from(this._peers.keys());
1773
+ for (const pid of targets) {
1774
+ const peer = this._peers.get(pid);
1775
+ const ch = peer?.getDataChannel(channelName);
1776
+ if (ch?.readyState === "open") {
1777
+ try {
1778
+ ch.send(serialized);
1779
+ } catch {
1780
+ }
1781
+ }
1782
+ }
1783
+ }
1784
+ _removePeer(peerId) {
1785
+ const peer = this._peers.get(peerId);
1786
+ if (peer) {
1787
+ peer.close();
1788
+ this._peers.delete(peerId);
1789
+ }
1790
+ this._peerSet.delete(peerId);
1791
+ this._rateLimitCounters.delete(peerId);
1792
+ }
1793
+ _checkRateLimit(peerId) {
1794
+ const now = Date.now();
1795
+ let c = this._rateLimitCounters.get(peerId);
1796
+ if (!c || now >= c.resetAt) {
1797
+ c = { count: 0, resetAt: now + this._rateLimitConfig.windowMs };
1798
+ this._rateLimitCounters.set(peerId, c);
1799
+ }
1800
+ c.count++;
1801
+ return c.count <= this._rateLimitConfig.maxMessagesPerSecond;
1802
+ }
1803
+ };
1804
+
1805
+ // src/hooks/useRoom.ts
1806
+ function useRoom(roomId, options) {
1807
+ const { strategy, iceServers, networkManager } = useMultiplayerContext();
1808
+ const [connectionState, setConnectionState] = (0, import_react4.useState)("disconnected");
1809
+ const [isHost, setIsHost] = (0, import_react4.useState)(false);
1810
+ const [hostId, setHostId] = (0, import_react4.useState)(null);
1811
+ const [selfId, setSelfId] = (0, import_react4.useState)(null);
1812
+ const [currentRoomId, setCurrentRoomId] = (0, import_react4.useState)(null);
1813
+ const [error, setError] = (0, import_react4.useState)(null);
1814
+ const [transport, setTransport] = (0, import_react4.useState)(null);
1815
+ const [room, setRoom] = (0, import_react4.useState)(null);
1816
+ const reconnectAttemptsRef = (0, import_react4.useRef)(0);
1817
+ const maxReconnectAttempts = options?.reconnectAttempts ?? 3;
1818
+ const optionsRef = (0, import_react4.useRef)(options);
1819
+ optionsRef.current = options;
1820
+ const createTransport = (0, import_react4.useCallback)(() => {
1821
+ const opt = optionsRef.current;
1822
+ if (opt?.transport && typeof opt.transport === "object" && "connect" in opt.transport) {
1823
+ return opt.transport;
1824
+ }
1825
+ const servers = opt?.iceServers ?? iceServers;
1826
+ const policy = opt?.privacy === "relay" ? "relay" : "all";
1827
+ return new WebRTCTransport(strategy, servers, policy);
1828
+ }, [strategy, iceServers]);
1829
+ const transportRef = (0, import_react4.useRef)(null);
1830
+ const doJoin = (0, import_react4.useCallback)(async (targetRoomId, joinOptions) => {
1831
+ if (transportRef.current) {
1832
+ transportRef.current.disconnect();
1833
+ transportRef.current = null;
1834
+ }
1835
+ const t = createTransport();
1836
+ transportRef.current = t;
1837
+ try {
1838
+ setError(null);
1839
+ setConnectionState("connecting");
1840
+ networkManager.setConnectionState("connecting");
1841
+ setTransport(t);
1842
+ networkManager.setTransport(t);
1843
+ t.onPeerJoin(() => {
1844
+ });
1845
+ t.onPeerLeave(() => {
1846
+ });
1847
+ t.onHostChanged((newHostId) => {
1848
+ setHostId(newHostId);
1849
+ setIsHost(t.peerId === newHostId);
1850
+ optionsRef.current?.onHostMigration?.(newHostId);
1851
+ });
1852
+ if ("onRoomUpdated" in t && typeof t.onRoomUpdated === "function") {
1853
+ t.onRoomUpdated((updatedRoom) => {
1854
+ networkManager.setRoom(updatedRoom);
1855
+ });
1856
+ }
1857
+ await t.connect(targetRoomId, {
1858
+ displayName: optionsRef.current?.displayName,
1859
+ playerMetadata: optionsRef.current?.playerMetadata,
1860
+ password: joinOptions?.password ?? optionsRef.current?.password,
1861
+ iceServers: optionsRef.current?.iceServers,
1862
+ iceTransportPolicy: optionsRef.current?.privacy === "relay" ? "relay" : "all"
1863
+ });
1864
+ if (transportRef.current !== t) return;
1865
+ setCurrentRoomId(targetRoomId);
1866
+ setSelfId(t.peerId);
1867
+ setHostId(t.hostId);
1868
+ setIsHost(t.isHost);
1869
+ setConnectionState("connected");
1870
+ networkManager.setConnectionState("connected");
1871
+ if (t.room) {
1872
+ networkManager.setRoom(t.room);
1873
+ }
1874
+ if (t.initialPlayers) {
1875
+ const players = t.initialPlayers.map((p) => ({
1876
+ ...p,
1877
+ isSelf: p.peerId === t.peerId
1878
+ }));
1879
+ networkManager.setPlayers(players);
1880
+ }
1881
+ reconnectAttemptsRef.current = 0;
1882
+ optionsRef.current?.onConnected?.();
1883
+ } catch (err) {
1884
+ if (transportRef.current !== t) return;
1885
+ const carverError = {
1886
+ code: "CONNECTION_FAILED",
1887
+ message: err instanceof Error ? err.message : "Connection failed",
1888
+ recoverable: reconnectAttemptsRef.current < maxReconnectAttempts
1889
+ };
1890
+ setError(carverError);
1891
+ networkManager.emitError(carverError);
1892
+ setConnectionState("disconnected");
1893
+ networkManager.setConnectionState("disconnected");
1894
+ optionsRef.current?.onError?.(carverError);
1895
+ }
1896
+ }, [createTransport, networkManager, maxReconnectAttempts]);
1897
+ const leave = (0, import_react4.useCallback)(() => {
1898
+ if (transportRef.current) {
1899
+ transportRef.current.disconnect();
1900
+ transportRef.current = null;
1901
+ }
1902
+ setTransport(null);
1903
+ setConnectionState("disconnected");
1904
+ setCurrentRoomId(null);
1905
+ setSelfId(null);
1906
+ setHostId(null);
1907
+ setIsHost(false);
1908
+ setError(null);
1909
+ networkManager.setConnectionState("disconnected");
1910
+ optionsRef.current?.onDisconnected?.("user_left");
1911
+ }, [networkManager]);
1912
+ const setReady = (0, import_react4.useCallback)((ready) => {
1913
+ transport?.setReady?.(ready);
1914
+ const selfPlayer = networkManager.players.get(transport?.peerId ?? "");
1915
+ if (selfPlayer) {
1916
+ networkManager.updatePlayer({ ...selfPlayer, isReady: ready });
1917
+ }
1918
+ }, [transport, networkManager]);
1919
+ const setMetadata = (0, import_react4.useCallback)((meta) => {
1920
+ transport?.setMetadata?.(meta);
1921
+ }, [transport]);
1922
+ const setRoomMetadata = (0, import_react4.useCallback)((meta) => {
1923
+ transport?.setRoomMetadata?.(meta);
1924
+ }, [transport]);
1925
+ (0, import_react4.useEffect)(() => {
1926
+ if (roomId) {
1927
+ doJoin(roomId);
1928
+ }
1929
+ return () => {
1930
+ if (transportRef.current) {
1931
+ transportRef.current.disconnect();
1932
+ transportRef.current = null;
1933
+ }
1934
+ };
1935
+ }, [roomId, doJoin]);
1936
+ (0, import_react4.useEffect)(() => {
1937
+ const unsub = networkManager.onRoomChange(() => {
1938
+ setRoom(networkManager.room);
1939
+ });
1940
+ setRoom(networkManager.room);
1941
+ return unsub;
1942
+ }, [networkManager]);
1943
+ return {
1944
+ roomId: currentRoomId,
1945
+ connectionState,
1946
+ isHost,
1947
+ hostId,
1948
+ selfId,
1949
+ room,
1950
+ error,
1951
+ join: doJoin,
1952
+ leave,
1953
+ setReady,
1954
+ setMetadata,
1955
+ setRoomMetadata,
1956
+ transport
1957
+ };
1958
+ }
1959
+
1960
+ // src/hooks/useLobby.ts
1961
+ var import_react5 = require("react");
1962
+ function announcementToRoom(ann) {
1963
+ return {
1964
+ id: ann.roomId,
1965
+ name: ann.name,
1966
+ hostId: ann.hostId,
1967
+ playerCount: ann.playerCount,
1968
+ maxPlayers: ann.maxPlayers,
1969
+ gameMode: ann.gameMode,
1970
+ isPrivate: ann.isPrivate,
1971
+ metadata: ann.metadata,
1972
+ createdAt: ann.createdAt,
1973
+ state: "lobby"
1974
+ };
1975
+ }
1976
+ function useLobby(options) {
1977
+ const { strategy } = useMultiplayerContext();
1978
+ const [rooms, setRooms] = (0, import_react5.useState)([]);
1979
+ const [isLoading, setIsLoading] = (0, import_react5.useState)(true);
1980
+ const [error, setError] = (0, import_react5.useState)(null);
1981
+ const optionsRef = (0, import_react5.useRef)(options);
1982
+ optionsRef.current = options;
1983
+ const filterRooms = (0, import_react5.useCallback)((roomList) => {
1984
+ const filter = optionsRef.current?.filter;
1985
+ if (!filter) return roomList;
1986
+ return roomList.filter((room) => {
1987
+ if (filter.maxPlayers !== void 0 && room.maxPlayers > filter.maxPlayers) return false;
1988
+ if (filter.gameMode !== void 0 && room.gameMode !== filter.gameMode) return false;
1989
+ if (filter.hasPassword !== void 0 && room.isPrivate !== filter.hasPassword) return false;
1990
+ return true;
1991
+ });
1992
+ }, []);
1993
+ (0, import_react5.useEffect)(() => {
1994
+ setIsLoading(true);
1995
+ setError(null);
1996
+ const unsub = strategy.subscribeToLobby((announcements) => {
1997
+ const converted = announcements.map(announcementToRoom);
1998
+ setRooms(filterRooms(converted));
1999
+ setIsLoading(false);
2000
+ });
2001
+ const timeout = setTimeout(() => setIsLoading(false), 3e3);
2002
+ return () => {
2003
+ unsub();
2004
+ clearTimeout(timeout);
2005
+ };
2006
+ }, [strategy, filterRooms]);
2007
+ const refresh = (0, import_react5.useCallback)(() => {
2008
+ setIsLoading(true);
2009
+ setTimeout(() => setIsLoading(false), 1e3);
2010
+ }, []);
2011
+ const createRoom = (0, import_react5.useCallback)(async (config) => {
2012
+ const roomId = `${config.name.toLowerCase().replace(/\s+/g, "-")}-${Date.now().toString(36)}`;
2013
+ const announcement = {
2014
+ roomId,
2015
+ name: config.name,
2016
+ hostId: strategy.selfId,
2017
+ playerCount: 0,
2018
+ maxPlayers: config.maxPlayers ?? 8,
2019
+ gameMode: config.metadata?.gameMode,
2020
+ isPrivate: config.isPrivate ?? false,
2021
+ metadata: config.metadata ?? {},
2022
+ createdAt: Date.now(),
2023
+ lastSeen: Date.now()
2024
+ };
2025
+ strategy.announceRoom(announcement);
2026
+ return roomId;
2027
+ }, [strategy]);
2028
+ return {
2029
+ rooms,
2030
+ isLoading,
2031
+ error,
2032
+ refresh,
2033
+ createRoom
2034
+ };
2035
+ }
2036
+
2037
+ // src/hooks/usePlayers.ts
2038
+ var import_react6 = require("react");
2039
+ function usePlayers() {
2040
+ const { networkManager } = useMultiplayerContext();
2041
+ const [players, setPlayers] = (0, import_react6.useState)([]);
2042
+ const [, setVersion] = (0, import_react6.useState)(0);
2043
+ (0, import_react6.useEffect)(() => {
2044
+ const unsubscribe = networkManager.onPlayersChange(() => {
2045
+ setPlayers(Array.from(networkManager.players.values()));
2046
+ setVersion((v) => v + 1);
2047
+ });
2048
+ setPlayers(Array.from(networkManager.players.values()));
2049
+ return unsubscribe;
2050
+ }, [networkManager]);
2051
+ const self = players.find((p) => p.isSelf) ?? null;
2052
+ const host = players.find((p) => p.isHost) ?? null;
2053
+ const allReady = players.length > 0 && players.every((p) => p.isReady);
2054
+ const getPlayer = (0, import_react6.useCallback)(
2055
+ (peerId) => players.find((p) => p.peerId === peerId),
2056
+ [players]
2057
+ );
2058
+ return {
2059
+ players,
2060
+ self,
2061
+ host,
2062
+ count: players.length,
2063
+ allReady,
2064
+ getPlayer
2065
+ };
2066
+ }
2067
+
2068
+ // src/hooks/useHost.ts
2069
+ var import_react7 = require("react");
2070
+ function useHost() {
2071
+ const { networkManager } = useMultiplayerContext();
2072
+ const getTransport = (0, import_react7.useCallback)(() => {
2073
+ const transport = networkManager.transport;
2074
+ if (!transport || !networkManager.isHost) return null;
2075
+ return transport;
2076
+ }, [networkManager]);
2077
+ const kick = (0, import_react7.useCallback)((peerId, reason) => {
2078
+ getTransport()?.kick?.(peerId, reason);
2079
+ }, [getTransport]);
2080
+ const transferHost = (0, import_react7.useCallback)((peerId) => {
2081
+ getTransport()?.transferHost?.(peerId);
2082
+ }, [getTransport]);
2083
+ const setRoomState = (0, import_react7.useCallback)((state) => {
2084
+ getTransport()?.setRoomState?.(state);
2085
+ }, [getTransport]);
2086
+ const setMaxPlayers = (0, import_react7.useCallback)((n) => {
2087
+ getTransport()?.setMaxPlayers?.(n);
2088
+ }, [getTransport]);
2089
+ const lockRoom = (0, import_react7.useCallback)(() => {
2090
+ getTransport()?.lockRoom?.();
2091
+ }, [getTransport]);
2092
+ const unlockRoom = (0, import_react7.useCallback)(() => {
2093
+ getTransport()?.unlockRoom?.();
2094
+ }, [getTransport]);
2095
+ return {
2096
+ kick,
2097
+ transferHost,
2098
+ setRoomState,
2099
+ setMaxPlayers,
2100
+ lockRoom,
2101
+ unlockRoom
2102
+ };
2103
+ }
2104
+
2105
+ // src/hooks/useMultiplayer.ts
2106
+ var import_react8 = require("react");
2107
+ var import_fiber = require("@react-three/fiber");
2108
+ var import_systems = require("@carverjs/core/systems");
2109
+
2110
+ // src/sync/EventSync.ts
2111
+ var EventSync = class {
2112
+ constructor(transport, options) {
2113
+ this._handlers = /* @__PURE__ */ new Map();
2114
+ this._transport = transport;
2115
+ this._hostValidation = options?.hostValidation ?? false;
2116
+ this._channel = transport.createChannel("carver:events", {
2117
+ reliable: true,
2118
+ ordered: true
2119
+ });
2120
+ this._channel.onReceive((rawData, peerId) => {
2121
+ try {
2122
+ const packet = typeof rawData === "string" ? JSON.parse(rawData) : rawData;
2123
+ if (this._hostValidation && this._transport.isHost && packet.sender !== this._transport.peerId) {
2124
+ const targets = Array.from(this._transport.peers).filter((p) => p !== peerId);
2125
+ if (targets.length > 0) {
2126
+ this._channel.send(JSON.stringify(packet), targets);
2127
+ }
2128
+ }
2129
+ if (this._hostValidation && !this._transport.isHost && peerId !== this._transport.hostId) {
2130
+ return;
2131
+ }
2132
+ const handlers = this._handlers.get(packet.type);
2133
+ if (handlers) {
2134
+ for (const handler of handlers) {
2135
+ handler(packet.payload, packet.sender);
2136
+ }
2137
+ }
2138
+ } catch {
2139
+ }
2140
+ });
2141
+ }
2142
+ /**
2143
+ * Send a typed event to a specific peer or all peers.
2144
+ */
2145
+ sendEvent(type, payload, target) {
2146
+ const packet = {
2147
+ type,
2148
+ payload,
2149
+ sender: this._transport.peerId,
2150
+ target
2151
+ };
2152
+ const serialized = JSON.stringify(packet);
2153
+ if (this._hostValidation && !this._transport.isHost) {
2154
+ this._channel.send(serialized, this._transport.hostId);
2155
+ } else if (target) {
2156
+ this._channel.send(serialized, target);
2157
+ } else {
2158
+ this._channel.send(serialized);
2159
+ }
2160
+ }
2161
+ /**
2162
+ * Broadcast a typed event to all connected peers.
2163
+ */
2164
+ broadcast(type, payload) {
2165
+ this.sendEvent(type, payload);
2166
+ }
2167
+ /**
2168
+ * Register a handler for a specific event type.
2169
+ * Returns an unsubscribe function.
2170
+ */
2171
+ onEvent(type, callback) {
2172
+ let handlers = this._handlers.get(type);
2173
+ if (!handlers) {
2174
+ handlers = [];
2175
+ this._handlers.set(type, handlers);
2176
+ }
2177
+ handlers.push(callback);
2178
+ return () => {
2179
+ const arr = this._handlers.get(type);
2180
+ if (arr) {
2181
+ const idx = arr.indexOf(callback);
2182
+ if (idx >= 0) arr.splice(idx, 1);
2183
+ if (arr.length === 0) this._handlers.delete(type);
2184
+ }
2185
+ };
2186
+ }
2187
+ /**
2188
+ * Clean up the event channel.
2189
+ */
2190
+ destroy() {
2191
+ this._channel.close();
2192
+ this._handlers.clear();
2193
+ }
2194
+ };
2195
+
2196
+ // src/core/HostAuthority.ts
2197
+ var HostAuthority = class {
2198
+ constructor(transport, codec, snapshotBuffer, options) {
2199
+ this._tick = 0;
2200
+ this._broadcastAccumulator = 0;
2201
+ // Per-client last ACK'd tick for delta compression
2202
+ this._clientBaselines = /* @__PURE__ */ new Map();
2203
+ // Per-client last keyframe tick for scheduling
2204
+ this._clientLastKeyframeTick = /* @__PURE__ */ new Map();
2205
+ // Interest management callback (optional)
2206
+ this._interestFilter = null;
2207
+ this._transport = transport;
2208
+ this._codec = codec;
2209
+ this._snapshotBuffer = snapshotBuffer;
2210
+ this._broadcastRate = options?.broadcastRate ?? 20;
2211
+ this._keyframeInterval = options?.keyframeInterval ?? 300;
2212
+ this._snapshotChannel = transport.createChannel(
2213
+ "carver:snapshots",
2214
+ {
2215
+ reliable: false,
2216
+ ordered: false,
2217
+ maxRetransmits: 0
2218
+ }
2219
+ );
2220
+ this._ackChannel = transport.createChannel("carver:acks", {
2221
+ reliable: true,
2222
+ ordered: true
2223
+ });
2224
+ this._ackChannel.onReceive((data, peerId) => {
2225
+ try {
2226
+ const ackTick = typeof data === "string" ? parseInt(data, 10) : data;
2227
+ if (ackTick === -1) {
2228
+ this._clientBaselines.delete(peerId);
2229
+ } else {
2230
+ this._clientBaselines.set(peerId, ackTick);
2231
+ }
2232
+ } catch {
2233
+ }
2234
+ });
2235
+ transport.onPeerJoin((peerId) => {
2236
+ this._clientBaselines.delete(peerId);
2237
+ });
2238
+ transport.onPeerLeave((peerId) => {
2239
+ this._clientBaselines.delete(peerId);
2240
+ });
2241
+ }
2242
+ /** Set optional interest management filter */
2243
+ setInterestFilter(filter) {
2244
+ this._interestFilter = filter;
2245
+ }
2246
+ /**
2247
+ * Called every fixed tick by the sync engine.
2248
+ * Collects entity states and decides whether to broadcast.
2249
+ */
2250
+ tick(currentTick, entities, delta) {
2251
+ this._tick = currentTick;
2252
+ this._snapshotBuffer.store(currentTick, new Map(entities));
2253
+ this._broadcastAccumulator += delta;
2254
+ const broadcastInterval = 1 / this._broadcastRate;
2255
+ if (this._broadcastAccumulator < broadcastInterval) return;
2256
+ this._broadcastAccumulator -= broadcastInterval;
2257
+ for (const peerId of this._transport.peers) {
2258
+ this._broadcastToClient(peerId, currentTick, entities);
2259
+ }
2260
+ }
2261
+ /** Force a keyframe broadcast to all clients (e.g., after host migration) */
2262
+ forceKeyframe(currentTick, entities) {
2263
+ this._clientBaselines.clear();
2264
+ this._clientLastKeyframeTick.clear();
2265
+ this._snapshotBuffer.store(currentTick, new Map(entities));
2266
+ for (const peerId of this._transport.peers) {
2267
+ this._broadcastToClient(peerId, currentTick, entities);
2268
+ }
2269
+ }
2270
+ destroy() {
2271
+ this._snapshotChannel.close();
2272
+ this._ackChannel.close();
2273
+ this._clientBaselines.clear();
2274
+ this._clientLastKeyframeTick.clear();
2275
+ }
2276
+ _broadcastToClient(peerId, currentTick, entities) {
2277
+ let clientEntities = entities;
2278
+ if (this._interestFilter) {
2279
+ clientEntities = /* @__PURE__ */ new Map();
2280
+ for (const [id, entity] of entities) {
2281
+ if (this._interestFilter(id, peerId)) {
2282
+ clientEntities.set(id, entity);
2283
+ }
2284
+ }
2285
+ }
2286
+ const clientBaseTick = this._clientBaselines.get(peerId);
2287
+ const clientLastKeyframe = this._clientLastKeyframeTick.get(peerId) ?? 0;
2288
+ const needsKeyframe = clientBaseTick === void 0 || currentTick - clientLastKeyframe >= this._keyframeInterval;
2289
+ let baseline;
2290
+ if (!needsKeyframe && clientBaseTick !== void 0) {
2291
+ baseline = this._snapshotBuffer.get(clientBaseTick);
2292
+ }
2293
+ if (needsKeyframe) {
2294
+ this._clientLastKeyframeTick.set(peerId, currentTick);
2295
+ }
2296
+ const packet = this._codec.serializeDelta(
2297
+ currentTick,
2298
+ needsKeyframe ? -1 : clientBaseTick ?? -1,
2299
+ clientEntities,
2300
+ baseline
2301
+ );
2302
+ if (packet) {
2303
+ this._snapshotChannel.send(packet, peerId);
2304
+ }
2305
+ }
2306
+ };
2307
+
2308
+ // src/core/ClientReceiver.ts
2309
+ var ClientReceiver = class {
2310
+ constructor(transport, codec, options) {
2311
+ // Snapshot buffer (ring buffer of last N snapshots)
2312
+ this._buffer = [];
2313
+ // Current interpolated state
2314
+ this._interpolatedState = /* @__PURE__ */ new Map();
2315
+ // Network quality tracking
2316
+ this._lastSnapshotTime = 0;
2317
+ this._networkQuality = "good";
2318
+ this._packetLossCount = 0;
2319
+ this._packetCount = 0;
2320
+ // Entity state: full accumulated state from keyframes + deltas
2321
+ this._fullState = /* @__PURE__ */ new Map();
2322
+ this._transport = transport;
2323
+ this._codec = codec;
2324
+ this._bufferSize = options?.bufferSize ?? 3;
2325
+ this._method = options?.method ?? "hermite";
2326
+ this._extrapolateMs = options?.extrapolateMs ?? 250;
2327
+ this._is2D = options?.is2D ?? false;
2328
+ this._snapshotChannel = transport.createChannel(
2329
+ "carver:snapshots",
2330
+ {
2331
+ reliable: false,
2332
+ ordered: false,
2333
+ maxRetransmits: 0
2334
+ }
2335
+ );
2336
+ this._ackChannel = transport.createChannel("carver:acks", {
2337
+ reliable: true,
2338
+ ordered: true
2339
+ });
2340
+ this._snapshotChannel.onReceive((data) => {
2341
+ this._handleSnapshot(data);
2342
+ });
2343
+ }
2344
+ /** Get the current interpolated entity states */
2345
+ get state() {
2346
+ return this._interpolatedState;
2347
+ }
2348
+ get networkQuality() {
2349
+ return this._networkQuality;
2350
+ }
2351
+ /**
2352
+ * Called every render frame to interpolate between buffered snapshots.
2353
+ * @param renderTime - current render time in ms
2354
+ */
2355
+ interpolate(renderTime) {
2356
+ if (this._buffer.length < 2) {
2357
+ return this._buffer.length > 0 ? this._buffer[this._buffer.length - 1].entities : this._interpolatedState;
2358
+ }
2359
+ const interpDelay = (this._bufferSize - 1) * (1e3 / 20);
2360
+ const targetTime = renderTime - interpDelay;
2361
+ let from = null;
2362
+ let to = null;
2363
+ for (let i = 0; i < this._buffer.length - 1; i++) {
2364
+ if (this._buffer[i].receivedAt <= targetTime && this._buffer[i + 1].receivedAt > targetTime) {
2365
+ from = this._buffer[i];
2366
+ to = this._buffer[i + 1];
2367
+ break;
2368
+ }
2369
+ }
2370
+ if (!from || !to) {
2371
+ const latest = this._buffer[this._buffer.length - 1];
2372
+ const timeSinceLatest = renderTime - latest.receivedAt;
2373
+ if (timeSinceLatest > this._extrapolateMs) {
2374
+ this._updateNetworkQuality("poor");
2375
+ return latest.entities;
2376
+ }
2377
+ if (this._buffer.length >= 2) {
2378
+ from = this._buffer[this._buffer.length - 2];
2379
+ to = latest;
2380
+ this._updateNetworkQuality("degraded");
2381
+ } else {
2382
+ return latest.entities;
2383
+ }
2384
+ } else {
2385
+ this._updateNetworkQuality("good");
2386
+ }
2387
+ const range = to.receivedAt - from.receivedAt;
2388
+ const t = range > 0 ? Math.min(1, Math.max(0, (targetTime - from.receivedAt) / range)) : 1;
2389
+ const result = /* @__PURE__ */ new Map();
2390
+ const allIds = /* @__PURE__ */ new Set([...from.entities.keys(), ...to.entities.keys()]);
2391
+ for (const id of allIds) {
2392
+ const fromEntity = from.entities.get(id);
2393
+ const toEntity = to.entities.get(id);
2394
+ if (toEntity && toEntity.c?.__removed) continue;
2395
+ if (fromEntity && toEntity) {
2396
+ result.set(id, this._interpolateEntity(fromEntity, toEntity, t));
2397
+ } else if (toEntity) {
2398
+ result.set(id, toEntity);
2399
+ }
2400
+ }
2401
+ this._interpolatedState = result;
2402
+ return result;
2403
+ }
2404
+ /** Request a keyframe from the host */
2405
+ requestKeyframe() {
2406
+ this._ackChannel.send("-1");
2407
+ }
2408
+ destroy() {
2409
+ this._snapshotChannel.close();
2410
+ this._ackChannel.close();
2411
+ this._buffer = [];
2412
+ this._interpolatedState.clear();
2413
+ this._fullState.clear();
2414
+ }
2415
+ _handleSnapshot(data) {
2416
+ try {
2417
+ const { tick, baseTick, entities } = this._codec.deserializePacket(data);
2418
+ const now = performance.now();
2419
+ if (baseTick === -1) {
2420
+ this._fullState.clear();
2421
+ for (const entity of entities) {
2422
+ this._fullState.set(entity.id, entity);
2423
+ }
2424
+ } else {
2425
+ for (const entity of entities) {
2426
+ if (entity.c?.__removed) {
2427
+ this._fullState.delete(entity.id);
2428
+ } else {
2429
+ this._fullState.set(entity.id, entity);
2430
+ }
2431
+ }
2432
+ }
2433
+ this._buffer.push({
2434
+ tick,
2435
+ entities: new Map(this._fullState),
2436
+ receivedAt: now
2437
+ });
2438
+ while (this._buffer.length > this._bufferSize * 2) {
2439
+ this._buffer.shift();
2440
+ }
2441
+ this._ackChannel.send(String(tick));
2442
+ this._lastSnapshotTime = now;
2443
+ this._packetCount++;
2444
+ } catch {
2445
+ this._packetLossCount++;
2446
+ }
2447
+ }
2448
+ _interpolateEntity(from, to, t) {
2449
+ if (this._is2D || !("z" in from)) {
2450
+ return this._interpolateEntity2D(
2451
+ from,
2452
+ to,
2453
+ t
2454
+ );
2455
+ }
2456
+ return this._interpolateEntity3D(
2457
+ from,
2458
+ to,
2459
+ t
2460
+ );
2461
+ }
2462
+ _interpolateEntity2D(from, to, t) {
2463
+ if (this._method === "hermite") {
2464
+ return {
2465
+ id: to.id,
2466
+ x: hermite(from.x, from.vx, to.x, to.vx, t),
2467
+ y: hermite(from.y, from.vy, to.y, to.vy, t),
2468
+ a: lerpAngle(from.a, to.a, t),
2469
+ vx: lerp(from.vx, to.vx, t),
2470
+ vy: lerp(from.vy, to.vy, t),
2471
+ va: lerp(from.va, to.va, t),
2472
+ c: interpolateCustom(from.c, to.c, t)
2473
+ };
2474
+ }
2475
+ return {
2476
+ id: to.id,
2477
+ x: lerp(from.x, to.x, t),
2478
+ y: lerp(from.y, to.y, t),
2479
+ a: lerpAngle(from.a, to.a, t),
2480
+ vx: lerp(from.vx, to.vx, t),
2481
+ vy: lerp(from.vy, to.vy, t),
2482
+ va: lerp(from.va, to.va, t),
2483
+ c: interpolateCustom(from.c, to.c, t)
2484
+ };
2485
+ }
2486
+ _interpolateEntity3D(from, to, t) {
2487
+ const [qx, qy, qz, qw] = slerp(
2488
+ from.qx,
2489
+ from.qy,
2490
+ from.qz,
2491
+ from.qw,
2492
+ to.qx,
2493
+ to.qy,
2494
+ to.qz,
2495
+ to.qw,
2496
+ t
2497
+ );
2498
+ if (this._method === "hermite") {
2499
+ return {
2500
+ id: to.id,
2501
+ x: hermite(from.x, from.vx, to.x, to.vx, t),
2502
+ y: hermite(from.y, from.vy, to.y, to.vy, t),
2503
+ z: hermite(from.z, from.vz, to.z, to.vz, t),
2504
+ qx,
2505
+ qy,
2506
+ qz,
2507
+ qw,
2508
+ vx: lerp(from.vx, to.vx, t),
2509
+ vy: lerp(from.vy, to.vy, t),
2510
+ vz: lerp(from.vz, to.vz, t),
2511
+ wx: lerp(from.wx, to.wx, t),
2512
+ wy: lerp(from.wy, to.wy, t),
2513
+ wz: lerp(from.wz, to.wz, t),
2514
+ c: interpolateCustom(from.c, to.c, t)
2515
+ };
2516
+ }
2517
+ return {
2518
+ id: to.id,
2519
+ x: lerp(from.x, to.x, t),
2520
+ y: lerp(from.y, to.y, t),
2521
+ z: lerp(from.z, to.z, t),
2522
+ qx,
2523
+ qy,
2524
+ qz,
2525
+ qw,
2526
+ vx: lerp(from.vx, to.vx, t),
2527
+ vy: lerp(from.vy, to.vy, t),
2528
+ vz: lerp(from.vz, to.vz, t),
2529
+ wx: lerp(from.wx, to.wx, t),
2530
+ wy: lerp(from.wy, to.wy, t),
2531
+ wz: lerp(from.wz, to.wz, t),
2532
+ c: interpolateCustom(from.c, to.c, t)
2533
+ };
2534
+ }
2535
+ _updateNetworkQuality(quality) {
2536
+ this._networkQuality = quality;
2537
+ }
2538
+ };
2539
+ function lerp(a, b, t) {
2540
+ return a + (b - a) * t;
2541
+ }
2542
+ function lerpAngle(a, b, t) {
2543
+ let diff = b - a;
2544
+ while (diff > Math.PI) diff -= Math.PI * 2;
2545
+ while (diff < -Math.PI) diff += Math.PI * 2;
2546
+ return a + diff * t;
2547
+ }
2548
+ function hermite(p0, v0, p1, v1, t) {
2549
+ const t2 = t * t;
2550
+ const t3 = t2 * t;
2551
+ return (2 * t3 - 3 * t2 + 1) * p0 + (t3 - 2 * t2 + t) * v0 + (-2 * t3 + 3 * t2) * p1 + (t3 - t2) * v1;
2552
+ }
2553
+ function slerp(ax, ay, az, aw, bx, by, bz, bw, t) {
2554
+ let dot = ax * bx + ay * by + az * bz + aw * bw;
2555
+ if (dot < 0) {
2556
+ bx = -bx;
2557
+ by = -by;
2558
+ bz = -bz;
2559
+ bw = -bw;
2560
+ dot = -dot;
2561
+ }
2562
+ if (dot > 0.9995) {
2563
+ return [lerp(ax, bx, t), lerp(ay, by, t), lerp(az, bz, t), lerp(aw, bw, t)];
2564
+ }
2565
+ const theta = Math.acos(Math.min(1, Math.max(-1, dot)));
2566
+ const sinTheta = Math.sin(theta);
2567
+ const wa = Math.sin((1 - t) * theta) / sinTheta;
2568
+ const wb = Math.sin(t * theta) / sinTheta;
2569
+ return [
2570
+ ax * wa + bx * wb,
2571
+ ay * wa + by * wb,
2572
+ az * wa + bz * wb,
2573
+ aw * wa + bw * wb
2574
+ ];
2575
+ }
2576
+ function interpolateCustom(from, to, t) {
2577
+ if (!from && !to) return void 0;
2578
+ if (!from) return to;
2579
+ if (!to) return from;
2580
+ const result = {};
2581
+ const allKeys = /* @__PURE__ */ new Set([...Object.keys(from), ...Object.keys(to)]);
2582
+ for (const key of allKeys) {
2583
+ const fromVal = from[key];
2584
+ const toVal = to[key];
2585
+ if (typeof fromVal === "number" && typeof toVal === "number") {
2586
+ result[key] = lerp(fromVal, toVal, t);
2587
+ } else {
2588
+ result[key] = t >= 0.5 ? toVal : fromVal;
2589
+ }
2590
+ }
2591
+ return result;
2592
+ }
2593
+
2594
+ // src/sync/SnapshotSync.ts
2595
+ var SnapshotSync = class {
2596
+ constructor(transport, codec, snapshotBuffer, options) {
2597
+ this._hostAuthority = null;
2598
+ this._clientReceiver = null;
2599
+ this._transport = transport;
2600
+ this._codec = codec;
2601
+ this._snapshotBuffer = snapshotBuffer;
2602
+ if (transport.isHost) {
2603
+ this._hostAuthority = new HostAuthority(
2604
+ transport,
2605
+ codec,
2606
+ snapshotBuffer,
2607
+ {
2608
+ broadcastRate: options?.broadcastRate,
2609
+ keyframeInterval: options?.keyframeInterval
2610
+ }
2611
+ );
2612
+ } else {
2613
+ this._clientReceiver = new ClientReceiver(transport, codec, {
2614
+ bufferSize: options?.bufferSize,
2615
+ method: options?.interpolationMethod,
2616
+ extrapolateMs: options?.extrapolateMs,
2617
+ is2D: options?.is2D
2618
+ });
2619
+ }
2620
+ }
2621
+ get isHost() {
2622
+ return this._hostAuthority !== null;
2623
+ }
2624
+ get hostAuthority() {
2625
+ return this._hostAuthority;
2626
+ }
2627
+ get clientReceiver() {
2628
+ return this._clientReceiver;
2629
+ }
2630
+ /** Host: called every fixed tick to potentially broadcast state */
2631
+ hostTick(tick, entities, delta) {
2632
+ this._hostAuthority?.tick(tick, entities, delta);
2633
+ }
2634
+ /** Client: called every render frame to interpolate */
2635
+ clientInterpolate(renderTime) {
2636
+ return this._clientReceiver?.interpolate(renderTime) ?? /* @__PURE__ */ new Map();
2637
+ }
2638
+ /** Set interest filter on host authority */
2639
+ setInterestFilter(filter) {
2640
+ this._hostAuthority?.setInterestFilter(filter);
2641
+ }
2642
+ /** Handle host migration: switch from client to host mode */
2643
+ promoteToHost(options) {
2644
+ this._clientReceiver?.destroy();
2645
+ this._clientReceiver = null;
2646
+ this._hostAuthority = new HostAuthority(
2647
+ this._transport,
2648
+ this._codec,
2649
+ this._snapshotBuffer,
2650
+ {
2651
+ broadcastRate: options?.broadcastRate,
2652
+ keyframeInterval: options?.keyframeInterval
2653
+ }
2654
+ );
2655
+ }
2656
+ /** Handle host migration: switch from host to client mode */
2657
+ demoteToClient(options) {
2658
+ this._hostAuthority?.destroy();
2659
+ this._hostAuthority = null;
2660
+ this._clientReceiver = new ClientReceiver(
2661
+ this._transport,
2662
+ this._codec,
2663
+ {
2664
+ bufferSize: options?.bufferSize,
2665
+ method: options?.interpolationMethod,
2666
+ extrapolateMs: options?.extrapolateMs,
2667
+ is2D: options?.is2D
2668
+ }
2669
+ );
2670
+ }
2671
+ destroy() {
2672
+ this._hostAuthority?.destroy();
2673
+ this._clientReceiver?.destroy();
2674
+ this._hostAuthority = null;
2675
+ this._clientReceiver = null;
2676
+ }
2677
+ };
2678
+
2679
+ // src/sync/PredictionSync.ts
2680
+ var import_msgpackr2 = require("msgpackr");
2681
+ var DEFAULT_OPTIONS = {
2682
+ maxRewindTicks: 15,
2683
+ errorSmoothingDecay: 0.85,
2684
+ maxErrorPerFrame: 5,
2685
+ snapThreshold: 15,
2686
+ lagCompensation: false
2687
+ };
2688
+ var PredictionSync = class {
2689
+ constructor(transport, codec, tickKeeper, options) {
2690
+ // Input buffer: ring buffer of recent inputs keyed by tick
2691
+ this._inputBuffer = [];
2692
+ // Per-client last processed input tick (host-side tracking)
2693
+ this._clientLastProcessedTick = /* @__PURE__ */ new Map();
2694
+ // Predicted state (client-side)
2695
+ this._predictedState = /* @__PURE__ */ new Map();
2696
+ // Error correction vectors per entity
2697
+ this._errorCorrections = /* @__PURE__ */ new Map();
2698
+ // Server state (last received authoritative snapshot)
2699
+ this._serverState = /* @__PURE__ */ new Map();
2700
+ this._serverTick = 0;
2701
+ // Physics step callback (provided by developer)
2702
+ this._onPhysicsStep = null;
2703
+ // Own input for current tick
2704
+ this._currentInput = null;
2705
+ this._transport = transport;
2706
+ this._codec = codec;
2707
+ this._tickKeeper = tickKeeper;
2708
+ this._options = { ...DEFAULT_OPTIONS, ...options };
2709
+ this._isHost = transport.isHost;
2710
+ this._inputChannel = transport.createChannel("carver:inputs", {
2711
+ reliable: true,
2712
+ ordered: true
2713
+ });
2714
+ this._stateChannel = transport.createChannel("carver:pred-state", {
2715
+ reliable: false,
2716
+ ordered: false,
2717
+ maxRetransmits: 0
2718
+ });
2719
+ this._ackChannel = transport.createChannel("carver:pred-acks", {
2720
+ reliable: true,
2721
+ ordered: true
2722
+ });
2723
+ if (this._isHost) {
2724
+ this._setupHostListeners();
2725
+ } else {
2726
+ this._setupClientListeners();
2727
+ }
2728
+ }
2729
+ /** Set the physics step callback (required for rollback re-simulation) */
2730
+ setPhysicsStep(cb) {
2731
+ this._onPhysicsStep = cb;
2732
+ }
2733
+ /** Set the current input for this tick (client-side) */
2734
+ setInput(input) {
2735
+ this._currentInput = input;
2736
+ }
2737
+ /**
2738
+ * Called every fixed tick on the client.
2739
+ * Applies input locally (prediction), buffers it, and sends to host.
2740
+ */
2741
+ clientTick(tick) {
2742
+ if (this._isHost) return;
2743
+ if (this._currentInput !== null) {
2744
+ this._inputBuffer.push({ tick, input: this._currentInput });
2745
+ const packet = {
2746
+ t: tick,
2747
+ i: this._currentInput,
2748
+ p: this._transport.peerId
2749
+ };
2750
+ this._inputChannel.send(JSON.stringify(packet));
2751
+ if (this._onPhysicsStep) {
2752
+ const inputs = /* @__PURE__ */ new Map();
2753
+ inputs.set(this._transport.peerId, this._currentInput);
2754
+ this._onPhysicsStep(inputs, tick, false);
2755
+ }
2756
+ this._currentInput = null;
2757
+ }
2758
+ const minTick = tick - this._options.maxRewindTicks * 2;
2759
+ while (this._inputBuffer.length > 0 && this._inputBuffer[0].tick < minTick) {
2760
+ this._inputBuffer.shift();
2761
+ }
2762
+ }
2763
+ /**
2764
+ * Called every fixed tick on the host.
2765
+ * Processes received inputs and broadcasts authoritative state.
2766
+ */
2767
+ hostTick(tick, entities, _delta) {
2768
+ if (!this._isHost) return;
2769
+ const stateArray = Array.from(entities.values());
2770
+ const data = this._codec.serialize(stateArray);
2771
+ for (const peerId of this._transport.peers) {
2772
+ const lastTick = this._clientLastProcessedTick.get(peerId) ?? -1;
2773
+ const packet = {
2774
+ t: tick,
2775
+ s: data,
2776
+ li: lastTick
2777
+ };
2778
+ this._stateChannel.send((0, import_msgpackr2.pack)(packet), peerId);
2779
+ }
2780
+ }
2781
+ /**
2782
+ * Called every render frame on the client to apply visual error smoothing.
2783
+ * Returns the corrected entity states.
2784
+ */
2785
+ applyErrorSmoothing(entities) {
2786
+ const result = /* @__PURE__ */ new Map();
2787
+ const decay = this._options.errorSmoothingDecay;
2788
+ for (const [id, entity] of entities) {
2789
+ const correction = this._errorCorrections.get(id);
2790
+ if (!correction) {
2791
+ result.set(id, entity);
2792
+ continue;
2793
+ }
2794
+ const corrected = { ...entity };
2795
+ corrected.x += correction.x;
2796
+ corrected.y += correction.y;
2797
+ if ("z" in corrected) {
2798
+ corrected.z += correction.z;
2799
+ }
2800
+ correction.x *= decay;
2801
+ correction.y *= decay;
2802
+ correction.z *= decay;
2803
+ const mag = Math.abs(correction.x) + Math.abs(correction.y) + Math.abs(correction.z);
2804
+ if (mag < 1e-3) {
2805
+ this._errorCorrections.delete(id);
2806
+ }
2807
+ result.set(id, corrected);
2808
+ }
2809
+ return result;
2810
+ }
2811
+ get predictedState() {
2812
+ return this._predictedState;
2813
+ }
2814
+ get serverTick() {
2815
+ return this._serverTick;
2816
+ }
2817
+ destroy() {
2818
+ this._inputChannel.close();
2819
+ this._stateChannel.close();
2820
+ this._ackChannel.close();
2821
+ this._inputBuffer = [];
2822
+ this._predictedState.clear();
2823
+ this._errorCorrections.clear();
2824
+ this._serverState.clear();
2825
+ }
2826
+ // ── Private: Host-side ──
2827
+ _setupHostListeners() {
2828
+ this._inputChannel.onReceive((rawData, peerId) => {
2829
+ try {
2830
+ const packet = JSON.parse(rawData);
2831
+ if (this._onPhysicsStep) {
2832
+ const inputs = /* @__PURE__ */ new Map();
2833
+ inputs.set(peerId, packet.i);
2834
+ this._onPhysicsStep(inputs, packet.t, false);
2835
+ }
2836
+ const prevTick = this._clientLastProcessedTick.get(peerId) ?? -1;
2837
+ this._clientLastProcessedTick.set(peerId, Math.max(prevTick, packet.t));
2838
+ } catch (err) {
2839
+ if (typeof console !== "undefined") console.debug("[CarverJS] Malformed input packet:", err);
2840
+ }
2841
+ });
2842
+ }
2843
+ // ── Private: Client-side ──
2844
+ _setupClientListeners() {
2845
+ this._stateChannel.onReceive((data) => {
2846
+ try {
2847
+ const packet = (0, import_msgpackr2.unpack)(data);
2848
+ const entities = this._codec.deserialize(packet.s);
2849
+ const serverTick = packet.t;
2850
+ const lastInputTick = packet.li;
2851
+ this._serverTick = serverTick;
2852
+ this._tickKeeper.setServerTick(serverTick);
2853
+ this._serverState.clear();
2854
+ for (const entity of entities) {
2855
+ this._serverState.set(entity.id, entity);
2856
+ }
2857
+ this._reconcile(lastInputTick);
2858
+ } catch (err) {
2859
+ if (typeof console !== "undefined") console.debug("[CarverJS] Malformed state packet:", err);
2860
+ }
2861
+ });
2862
+ }
2863
+ _reconcile(lastInputTick) {
2864
+ this._inputBuffer = this._inputBuffer.filter((entry) => entry.tick > lastInputTick);
2865
+ let needsRollback = false;
2866
+ let maxError = 0;
2867
+ for (const [id, serverEntity] of this._serverState) {
2868
+ const predicted = this._predictedState.get(id);
2869
+ if (!predicted) continue;
2870
+ const error = this._computeError(predicted, serverEntity);
2871
+ maxError = Math.max(maxError, error);
2872
+ if (error > this._options.maxErrorPerFrame) {
2873
+ needsRollback = true;
2874
+ }
2875
+ }
2876
+ if (maxError > this._options.snapThreshold) {
2877
+ this._predictedState = new Map(this._serverState);
2878
+ this._errorCorrections.clear();
2879
+ return;
2880
+ }
2881
+ if (needsRollback) {
2882
+ const oldPositions = /* @__PURE__ */ new Map();
2883
+ for (const [id, entity] of this._predictedState) {
2884
+ oldPositions.set(id, {
2885
+ x: entity.x,
2886
+ y: entity.y,
2887
+ z: "z" in entity ? entity.z : 0
2888
+ });
2889
+ }
2890
+ this._predictedState = new Map(this._serverState);
2891
+ if (this._onPhysicsStep) {
2892
+ for (const entry of this._inputBuffer) {
2893
+ const inputs = /* @__PURE__ */ new Map();
2894
+ inputs.set(this._transport.peerId, entry.input);
2895
+ this._onPhysicsStep(inputs, entry.tick, true);
2896
+ }
2897
+ }
2898
+ for (const [id, newEntity] of this._predictedState) {
2899
+ const oldPos = oldPositions.get(id);
2900
+ if (oldPos) {
2901
+ this._errorCorrections.set(id, {
2902
+ x: oldPos.x - newEntity.x,
2903
+ y: oldPos.y - newEntity.y,
2904
+ z: oldPos.z - ("z" in newEntity ? newEntity.z : 0)
2905
+ });
2906
+ }
2907
+ }
2908
+ }
2909
+ }
2910
+ _computeError(predicted, server) {
2911
+ const dx = predicted.x - server.x;
2912
+ const dy = predicted.y - server.y;
2913
+ let dz = 0;
2914
+ if ("z" in predicted && "z" in server) {
2915
+ dz = predicted.z - server.z;
2916
+ }
2917
+ return Math.sqrt(dx * dx + dy * dy + dz * dz);
2918
+ }
2919
+ };
2920
+
2921
+ // src/core/NetworkSimulator.ts
2922
+ var NetworkSimulator = class {
2923
+ constructor(options) {
2924
+ this.sentCount = 0;
2925
+ this.droppedCount = 0;
2926
+ this.latencySum = 0;
2927
+ // running sum of applied latencies
2928
+ /** Active timeout handles so we can cancel them on destroy() */
2929
+ this.pending = /* @__PURE__ */ new Set();
2930
+ const opts = options ?? {};
2931
+ this.latencyMs = opts.latencyMs ?? 0;
2932
+ this.packetLoss = opts.packetLoss ?? 0;
2933
+ this.jitterMs = opts.jitterMs ?? 0;
2934
+ }
2935
+ /* ---- public API ---- */
2936
+ /** Update simulation parameters at runtime. */
2937
+ setOptions(options) {
2938
+ if (options.latencyMs !== void 0) this.latencyMs = options.latencyMs;
2939
+ if (options.packetLoss !== void 0) this.packetLoss = options.packetLoss;
2940
+ if (options.jitterMs !== void 0) this.jitterMs = options.jitterMs;
2941
+ }
2942
+ /**
2943
+ * Wrap an existing send function so that every call goes through the
2944
+ * simulated network conditions (latency, jitter, packet loss).
2945
+ */
2946
+ wrapSend(originalSend) {
2947
+ return (data, target) => {
2948
+ if (Math.random() < this.packetLoss) {
2949
+ this.droppedCount++;
2950
+ return;
2951
+ }
2952
+ this.sentCount++;
2953
+ const jitter = this.jitterMs > 0 ? Math.random() * 2 * this.jitterMs - this.jitterMs : 0;
2954
+ const delay = Math.max(0, this.latencyMs + jitter);
2955
+ this.latencySum += delay;
2956
+ if (delay === 0) {
2957
+ originalSend(data, target);
2958
+ } else {
2959
+ const handle = setTimeout(() => {
2960
+ this.pending.delete(handle);
2961
+ originalSend(data, target);
2962
+ }, delay);
2963
+ this.pending.add(handle);
2964
+ }
2965
+ };
2966
+ }
2967
+ /** Current statistics snapshot. */
2968
+ get stats() {
2969
+ return {
2970
+ sentCount: this.sentCount,
2971
+ droppedCount: this.droppedCount,
2972
+ avgLatencyMs: this.sentCount > 0 ? this.latencySum / this.sentCount : 0
2973
+ };
2974
+ }
2975
+ /** Cancel all pending delayed sends and clean up. */
2976
+ destroy() {
2977
+ for (const handle of this.pending) {
2978
+ clearTimeout(handle);
2979
+ }
2980
+ this.pending.clear();
2981
+ }
2982
+ };
2983
+
2984
+ // src/hooks/useMultiplayer.ts
2985
+ var Z_THRESHOLD = 0.01;
2986
+ function detect2D(actors) {
2987
+ for (const [, ref] of actors) {
2988
+ if (Math.abs(ref.object3D.position.z) > Z_THRESHOLD) return false;
2989
+ }
2990
+ return true;
2991
+ }
2992
+ function readEntityState2D(ref) {
2993
+ const pos = ref.object3D.position;
2994
+ const rot = ref.object3D.rotation;
2995
+ const rb = ref.rigidBody;
2996
+ let vx = 0;
2997
+ let vy = 0;
2998
+ let va = 0;
2999
+ if (rb) {
3000
+ try {
3001
+ const lv = rb.linvel();
3002
+ vx = lv.x;
3003
+ vy = lv.y;
3004
+ } catch {
3005
+ }
3006
+ try {
3007
+ const av = rb.angvel();
3008
+ va = typeof av === "number" ? av : av?.z ?? 0;
3009
+ } catch {
3010
+ }
3011
+ }
3012
+ const nc = ref.userData.networked;
3013
+ const custom = nc?.custom;
3014
+ return {
3015
+ id: ref.id,
3016
+ x: pos.x,
3017
+ y: pos.y,
3018
+ a: rot.z,
3019
+ vx,
3020
+ vy,
3021
+ va,
3022
+ ...custom ? { c: custom } : {}
3023
+ };
3024
+ }
3025
+ function readEntityState3D(ref) {
3026
+ const pos = ref.object3D.position;
3027
+ const quat = ref.object3D.quaternion;
3028
+ const rb = ref.rigidBody;
3029
+ let vx = 0;
3030
+ let vy = 0;
3031
+ let vz = 0;
3032
+ let wx = 0;
3033
+ let wy = 0;
3034
+ let wz = 0;
3035
+ if (rb) {
3036
+ try {
3037
+ const lv = rb.linvel();
3038
+ vx = lv.x;
3039
+ vy = lv.y;
3040
+ vz = lv.z ?? 0;
3041
+ } catch {
3042
+ }
3043
+ try {
3044
+ const av = rb.angvel();
3045
+ wx = av.x ?? 0;
3046
+ wy = av.y ?? 0;
3047
+ wz = av.z ?? 0;
3048
+ } catch {
3049
+ }
3050
+ }
3051
+ const nc = ref.userData.networked;
3052
+ const custom = nc?.custom;
3053
+ return {
3054
+ id: ref.id,
3055
+ x: pos.x,
3056
+ y: pos.y,
3057
+ z: pos.z,
3058
+ qx: quat.x,
3059
+ qy: quat.y,
3060
+ qz: quat.z,
3061
+ qw: quat.w,
3062
+ vx,
3063
+ vy,
3064
+ vz,
3065
+ wx,
3066
+ wy,
3067
+ wz,
3068
+ ...custom ? { c: custom } : {}
3069
+ };
3070
+ }
3071
+ function buildEntityMap(actors, is2D) {
3072
+ const entities = /* @__PURE__ */ new Map();
3073
+ for (const [id, ref] of actors) {
3074
+ const nc = ref.userData.networked;
3075
+ if (nc && nc.sync === false) continue;
3076
+ entities.set(id, is2D ? readEntityState2D(ref) : readEntityState3D(ref));
3077
+ }
3078
+ return entities;
3079
+ }
3080
+ function applyState2D(ref, state) {
3081
+ ref.object3D.position.set(state.x, state.y, 0);
3082
+ ref.object3D.rotation.z = state.a;
3083
+ if (ref.rigidBody) {
3084
+ try {
3085
+ if (typeof ref.rigidBody.setTranslation === "function") {
3086
+ ref.rigidBody.setTranslation({ x: state.x, y: state.y }, true);
3087
+ }
3088
+ if (typeof ref.rigidBody.setRotation === "function") {
3089
+ ref.rigidBody.setRotation(state.a, true);
3090
+ }
3091
+ } catch {
3092
+ }
3093
+ }
3094
+ }
3095
+ function applyState3D(ref, state) {
3096
+ ref.object3D.position.set(state.x, state.y, state.z);
3097
+ ref.object3D.quaternion.set(state.qx, state.qy, state.qz, state.qw);
3098
+ if (ref.rigidBody) {
3099
+ try {
3100
+ if (typeof ref.rigidBody.setTranslation === "function") {
3101
+ ref.rigidBody.setTranslation({ x: state.x, y: state.y, z: state.z }, true);
3102
+ }
3103
+ if (typeof ref.rigidBody.setRotation === "function") {
3104
+ ref.rigidBody.setRotation(
3105
+ { x: state.qx, y: state.qy, z: state.qz, w: state.qw },
3106
+ true
3107
+ );
3108
+ }
3109
+ } catch {
3110
+ }
3111
+ }
3112
+ }
3113
+ function applyEntityState(ref, state, is2D) {
3114
+ if (is2D) {
3115
+ applyState2D(ref, state);
3116
+ } else {
3117
+ applyState3D(ref, state);
3118
+ }
3119
+ }
3120
+ function applyStatesToActors(states, registry, is2D) {
3121
+ for (const [id, state] of states) {
3122
+ if (state.c && state.c.__removed) continue;
3123
+ const ref = registry.get(id);
3124
+ if (!ref) continue;
3125
+ applyEntityState(ref, state, is2D);
3126
+ }
3127
+ }
3128
+ function useMultiplayer(options = {}) {
3129
+ const { networkManager } = useMultiplayerContext();
3130
+ const mode = options.mode ?? networkManager.syncMode;
3131
+ const eventSyncRef = (0, import_react8.useRef)(null);
3132
+ const snapshotSyncRef = (0, import_react8.useRef)(null);
3133
+ const predictionSyncRef = (0, import_react8.useRef)(null);
3134
+ const networkSimulatorRef = (0, import_react8.useRef)(null);
3135
+ const is2DRef = (0, import_react8.useRef)(null);
3136
+ const [isActive, setIsActive] = (0, import_react8.useState)(false);
3137
+ const [networkQuality, setNetworkQuality] = (0, import_react8.useState)("good");
3138
+ const [tick, setTick] = (0, import_react8.useState)(0);
3139
+ const [serverTick, setServerTick] = (0, import_react8.useState)(0);
3140
+ const [drift, setDrift] = (0, import_react8.useState)(0);
3141
+ const actorRegistry = (0, import_react8.useRef)((0, import_systems.getActorRegistry)());
3142
+ const wasHostRef = (0, import_react8.useRef)(null);
3143
+ const buildSnapshotOpts = (0, import_react8.useCallback)(() => {
3144
+ return {
3145
+ broadcastRate: options.broadcastRate,
3146
+ keyframeInterval: options.keyframeInterval,
3147
+ bufferSize: options.interpolation?.bufferSize,
3148
+ interpolationMethod: options.interpolation?.method,
3149
+ extrapolateMs: options.interpolation?.extrapolateMs,
3150
+ is2D: is2DRef.current ?? true
3151
+ };
3152
+ }, [
3153
+ options.broadcastRate,
3154
+ options.keyframeInterval,
3155
+ options.interpolation?.bufferSize,
3156
+ options.interpolation?.method,
3157
+ options.interpolation?.extrapolateMs
3158
+ ]);
3159
+ (0, import_react8.useEffect)(() => {
3160
+ const transport = networkManager.transport;
3161
+ if (!transport) return;
3162
+ const debugOpts = options.debug;
3163
+ let simulator = null;
3164
+ if (debugOpts?.simulatedLatencyMs || debugOpts?.simulatedPacketLoss) {
3165
+ simulator = new NetworkSimulator({
3166
+ latencyMs: debugOpts.simulatedLatencyMs,
3167
+ packetLoss: debugOpts.simulatedPacketLoss
3168
+ });
3169
+ networkSimulatorRef.current = simulator;
3170
+ const origCreateChannel = transport.createChannel.bind(transport);
3171
+ transport.createChannel = (name, channelOpts) => {
3172
+ const ch = origCreateChannel(name, channelOpts);
3173
+ const wrappedSend = simulator.wrapSend(ch.send.bind(ch));
3174
+ return { ...ch, send: wrappedSend };
3175
+ };
3176
+ }
3177
+ eventSyncRef.current = new EventSync(transport);
3178
+ if (mode === "snapshot" || mode === "prediction") {
3179
+ snapshotSyncRef.current = new SnapshotSync(
3180
+ transport,
3181
+ networkManager.codec,
3182
+ networkManager.snapshotBuffer,
3183
+ buildSnapshotOpts()
3184
+ );
3185
+ }
3186
+ if (mode === "prediction") {
3187
+ predictionSyncRef.current = new PredictionSync(
3188
+ transport,
3189
+ networkManager.codec,
3190
+ networkManager.tickKeeper,
3191
+ options.prediction
3192
+ );
3193
+ if (options.onPhysicsStep) {
3194
+ predictionSyncRef.current.setPhysicsStep(options.onPhysicsStep);
3195
+ }
3196
+ }
3197
+ wasHostRef.current = networkManager.isHost;
3198
+ setIsActive(true);
3199
+ const unsubHostChanged = (() => {
3200
+ const onHostChanged = (newHostId) => {
3201
+ const amNewHost = newHostId === transport.peerId;
3202
+ const wasPreviouslyHost = wasHostRef.current;
3203
+ wasHostRef.current = amNewHost;
3204
+ if (amNewHost && !wasPreviouslyHost) {
3205
+ snapshotSyncRef.current?.promoteToHost(buildSnapshotOpts());
3206
+ } else if (!amNewHost && wasPreviouslyHost) {
3207
+ snapshotSyncRef.current?.demoteToClient(buildSnapshotOpts());
3208
+ }
3209
+ };
3210
+ transport.onHostChanged(onHostChanged);
3211
+ return () => {
3212
+ };
3213
+ })();
3214
+ return () => {
3215
+ unsubHostChanged();
3216
+ eventSyncRef.current?.destroy();
3217
+ snapshotSyncRef.current?.destroy();
3218
+ predictionSyncRef.current?.destroy();
3219
+ networkSimulatorRef.current?.destroy();
3220
+ eventSyncRef.current = null;
3221
+ snapshotSyncRef.current = null;
3222
+ predictionSyncRef.current = null;
3223
+ networkSimulatorRef.current = null;
3224
+ setIsActive(false);
3225
+ };
3226
+ }, [networkManager, mode, buildSnapshotOpts, options.prediction, options.onPhysicsStep, options.debug]);
3227
+ (0, import_react8.useEffect)(() => {
3228
+ const unsub = networkManager.onConnectionStateChange(() => {
3229
+ setNetworkQuality(networkManager.networkQuality);
3230
+ });
3231
+ return unsub;
3232
+ }, [networkManager]);
3233
+ (0, import_fiber.useFrame)((_state, delta) => {
3234
+ const transport = networkManager.transport;
3235
+ if (!transport) return;
3236
+ const tickKeeper = networkManager.tickKeeper;
3237
+ const isHost = networkManager.isHost;
3238
+ if (is2DRef.current === null) {
3239
+ const networked = actorRegistry.current.getNetworked();
3240
+ if (networked.size > 0) {
3241
+ is2DRef.current = detect2D(networked);
3242
+ }
3243
+ }
3244
+ const is2D = is2DRef.current ?? true;
3245
+ if (mode === "events") return;
3246
+ const ticksThisFrame = tickKeeper.update(delta);
3247
+ for (let i = 0; i < ticksThisFrame; i++) {
3248
+ const currentTick = tickKeeper.tick - (ticksThisFrame - 1 - i);
3249
+ if (isHost) {
3250
+ const networked = actorRegistry.current.getNetworked();
3251
+ const entities = buildEntityMap(networked, is2D);
3252
+ if (mode === "snapshot" || mode === "prediction") {
3253
+ snapshotSyncRef.current?.hostTick(currentTick, entities, tickKeeper.tickDelta);
3254
+ }
3255
+ if (mode === "prediction") {
3256
+ predictionSyncRef.current?.hostTick(currentTick, entities, tickKeeper.tickDelta);
3257
+ }
3258
+ } else {
3259
+ if (mode === "prediction" && predictionSyncRef.current) {
3260
+ predictionSyncRef.current.clientTick(currentTick);
3261
+ }
3262
+ }
3263
+ }
3264
+ if (!isHost) {
3265
+ if (mode === "snapshot" && snapshotSyncRef.current) {
3266
+ const renderTime = performance.now();
3267
+ const interpolated = snapshotSyncRef.current.clientInterpolate(renderTime);
3268
+ applyStatesToActors(interpolated, actorRegistry.current, is2D);
3269
+ } else if (mode === "prediction" && predictionSyncRef.current) {
3270
+ const predicted = predictionSyncRef.current.predictedState;
3271
+ const smoothed = predictionSyncRef.current.applyErrorSmoothing(predicted);
3272
+ applyStatesToActors(smoothed, actorRegistry.current, is2D);
3273
+ }
3274
+ }
3275
+ if (ticksThisFrame > 0) {
3276
+ const newTick = tickKeeper.tick;
3277
+ const newServerTick = tickKeeper.serverTick;
3278
+ const newDrift = tickKeeper.drift;
3279
+ const newQuality = networkManager.networkQuality;
3280
+ setTick((prev) => prev !== newTick ? newTick : prev);
3281
+ setServerTick((prev) => prev !== newServerTick ? newServerTick : prev);
3282
+ setDrift((prev) => prev !== newDrift ? newDrift : prev);
3283
+ setNetworkQuality((prev) => prev !== newQuality ? newQuality : prev);
3284
+ }
3285
+ }, -55);
3286
+ return {
3287
+ isActive,
3288
+ networkQuality,
3289
+ tick,
3290
+ serverTick,
3291
+ drift,
3292
+ syncEngine: mode
3293
+ };
3294
+ }
3295
+
3296
+ // src/hooks/useNetworkEvents.ts
3297
+ var import_react9 = require("react");
3298
+ function useNetworkEvents(options) {
3299
+ const { networkManager } = useMultiplayerContext();
3300
+ const eventSyncRef = (0, import_react9.useRef)(null);
3301
+ const pendingRef = (0, import_react9.useRef)([]);
3302
+ const drainedUnsubsRef = (0, import_react9.useRef)([]);
3303
+ (0, import_react9.useEffect)(() => {
3304
+ const transport = networkManager.transport;
3305
+ if (!transport) return;
3306
+ const eventSync = new EventSync(transport, {
3307
+ hostValidation: options?.hostValidation
3308
+ });
3309
+ eventSyncRef.current = eventSync;
3310
+ const unsubs = [];
3311
+ for (const entry of pendingRef.current) {
3312
+ const unsub = eventSync.onEvent(entry.type, entry.callback);
3313
+ entry.unsub = unsub;
3314
+ unsubs.push(unsub);
3315
+ }
3316
+ pendingRef.current = [];
3317
+ drainedUnsubsRef.current = unsubs;
3318
+ return () => {
3319
+ for (const unsub of drainedUnsubsRef.current) {
3320
+ unsub();
3321
+ }
3322
+ drainedUnsubsRef.current = [];
3323
+ eventSync.destroy();
3324
+ eventSyncRef.current = null;
3325
+ };
3326
+ }, [networkManager.transport, options?.hostValidation]);
3327
+ const sendEvent = (0, import_react9.useCallback)((type, payload, target) => {
3328
+ eventSyncRef.current?.sendEvent(type, payload, target);
3329
+ }, []);
3330
+ const broadcast = (0, import_react9.useCallback)((type, payload) => {
3331
+ eventSyncRef.current?.broadcast(type, payload);
3332
+ }, []);
3333
+ const onEvent = (0, import_react9.useCallback)((type, callback) => {
3334
+ const castCallback = callback;
3335
+ if (eventSyncRef.current) {
3336
+ return eventSyncRef.current.onEvent(type, castCallback);
3337
+ }
3338
+ const entry = { type, callback: castCallback, unsub: null };
3339
+ pendingRef.current.push(entry);
3340
+ return () => {
3341
+ if (entry.unsub) {
3342
+ entry.unsub();
3343
+ drainedUnsubsRef.current = drainedUnsubsRef.current.filter((u) => u !== entry.unsub);
3344
+ } else {
3345
+ pendingRef.current = pendingRef.current.filter((e) => e !== entry);
3346
+ }
3347
+ };
3348
+ }, []);
3349
+ return { sendEvent, broadcast, onEvent };
3350
+ }
3351
+
3352
+ // src/hooks/useNetworkState.ts
3353
+ var import_react10 = require("react");
3354
+ var CHANNEL_NAME = "carver:network-state";
3355
+ function useNetworkState() {
3356
+ const { networkManager } = useMultiplayerContext();
3357
+ const [entities, setEntities] = (0, import_react10.useState)(
3358
+ () => /* @__PURE__ */ new Map()
3359
+ );
3360
+ const entitiesRef = (0, import_react10.useRef)(entities);
3361
+ entitiesRef.current = entities;
3362
+ const channelRef = (0, import_react10.useRef)(null);
3363
+ const replaceEntities = (0, import_react10.useCallback)(
3364
+ (updater) => {
3365
+ setEntities((prev) => {
3366
+ const next = updater(prev);
3367
+ entitiesRef.current = next;
3368
+ return next;
3369
+ });
3370
+ },
3371
+ []
3372
+ );
3373
+ const applySpawn = (0, import_react10.useCallback)(
3374
+ (id, state) => {
3375
+ replaceEntities((prev) => {
3376
+ const next = new Map(prev);
3377
+ next.set(id, state);
3378
+ return next;
3379
+ });
3380
+ },
3381
+ [replaceEntities]
3382
+ );
3383
+ const applyDespawn = (0, import_react10.useCallback)(
3384
+ (id) => {
3385
+ replaceEntities((prev) => {
3386
+ if (!prev.has(id)) return prev;
3387
+ const next = new Map(prev);
3388
+ next.delete(id);
3389
+ return next;
3390
+ });
3391
+ },
3392
+ [replaceEntities]
3393
+ );
3394
+ (0, import_react10.useEffect)(() => {
3395
+ const transport = networkManager.transport;
3396
+ if (!transport) return;
3397
+ const channel = transport.createChannel(CHANNEL_NAME, {
3398
+ reliable: true,
3399
+ ordered: true
3400
+ });
3401
+ channelRef.current = channel;
3402
+ channel.onReceive((msg, _peerId) => {
3403
+ switch (msg.action) {
3404
+ // --- Spawn broadcast (sent by host to everyone) ---
3405
+ case "spawn": {
3406
+ if (msg.state) {
3407
+ applySpawn(msg.id, msg.state);
3408
+ }
3409
+ break;
3410
+ }
3411
+ // --- Despawn broadcast (sent by host to everyone) ---
3412
+ case "despawn": {
3413
+ applyDespawn(msg.id);
3414
+ break;
3415
+ }
3416
+ // --- Request-spawn (client -> host only) ---
3417
+ case "request-spawn": {
3418
+ if (!transport.isHost) break;
3419
+ if (entitiesRef.current.has(msg.id)) break;
3420
+ const config = msg.state ?? {};
3421
+ const newState = {
3422
+ id: msg.id,
3423
+ x: 0,
3424
+ y: 0,
3425
+ a: 0,
3426
+ vx: 0,
3427
+ vy: 0,
3428
+ va: 0,
3429
+ ...config,
3430
+ // Ensure id is authoritative.
3431
+ ...config.id !== void 0 ? {} : {}
3432
+ };
3433
+ newState.id = msg.id;
3434
+ applySpawn(msg.id, newState);
3435
+ channel.send({ action: "spawn", id: msg.id, state: newState });
3436
+ break;
3437
+ }
3438
+ }
3439
+ });
3440
+ return () => {
3441
+ channel.close();
3442
+ channelRef.current = null;
3443
+ };
3444
+ }, [networkManager.transport, applySpawn, applyDespawn]);
3445
+ const spawn = (0, import_react10.useCallback)(
3446
+ (id, initialState) => {
3447
+ const transport = networkManager.transport;
3448
+ if (!transport) {
3449
+ console.warn("[useNetworkState] spawn called before transport is ready.");
3450
+ return;
3451
+ }
3452
+ if (!transport.isHost) {
3453
+ console.warn(
3454
+ "[useNetworkState] spawn is host-only. Use requestSpawn() from a client."
3455
+ );
3456
+ return;
3457
+ }
3458
+ if (entitiesRef.current.has(id)) {
3459
+ console.warn(
3460
+ `[useNetworkState] entity "${id}" already exists. Ignoring spawn.`
3461
+ );
3462
+ return;
3463
+ }
3464
+ const state = { ...initialState, id };
3465
+ applySpawn(id, state);
3466
+ channelRef.current?.send({ action: "spawn", id, state });
3467
+ },
3468
+ [networkManager, applySpawn]
3469
+ );
3470
+ const despawn = (0, import_react10.useCallback)(
3471
+ (id) => {
3472
+ const transport = networkManager.transport;
3473
+ if (!transport) {
3474
+ console.warn("[useNetworkState] despawn called before transport is ready.");
3475
+ return;
3476
+ }
3477
+ if (!transport.isHost) {
3478
+ console.warn("[useNetworkState] despawn is host-only.");
3479
+ return;
3480
+ }
3481
+ if (!entitiesRef.current.has(id)) {
3482
+ console.warn(
3483
+ `[useNetworkState] entity "${id}" does not exist. Ignoring despawn.`
3484
+ );
3485
+ return;
3486
+ }
3487
+ applyDespawn(id);
3488
+ channelRef.current?.send({ action: "despawn", id });
3489
+ },
3490
+ [networkManager, applyDespawn]
3491
+ );
3492
+ const requestSpawn = (0, import_react10.useCallback)(
3493
+ (id, config) => {
3494
+ const transport = networkManager.transport;
3495
+ if (!transport) {
3496
+ console.warn(
3497
+ "[useNetworkState] requestSpawn called before transport is ready."
3498
+ );
3499
+ return;
3500
+ }
3501
+ if (transport.isHost) {
3502
+ if (entitiesRef.current.has(id)) {
3503
+ console.warn(
3504
+ `[useNetworkState] entity "${id}" already exists. Ignoring requestSpawn.`
3505
+ );
3506
+ return;
3507
+ }
3508
+ const newState = {
3509
+ id,
3510
+ x: 0,
3511
+ y: 0,
3512
+ a: 0,
3513
+ vx: 0,
3514
+ vy: 0,
3515
+ va: 0,
3516
+ ...config
3517
+ };
3518
+ newState.id = id;
3519
+ applySpawn(id, newState);
3520
+ channelRef.current?.send({ action: "spawn", id, state: newState });
3521
+ return;
3522
+ }
3523
+ channelRef.current?.send(
3524
+ { action: "request-spawn", id, state: config },
3525
+ transport.hostId
3526
+ );
3527
+ },
3528
+ [networkManager, applySpawn]
3529
+ );
3530
+ const getState = (0, import_react10.useCallback)(
3531
+ (id) => {
3532
+ return entitiesRef.current.get(id);
3533
+ },
3534
+ []
3535
+ );
3536
+ const setState = (0, import_react10.useCallback)(
3537
+ (id, partialState) => {
3538
+ const transport = networkManager.transport;
3539
+ if (!transport) {
3540
+ console.warn("[useNetworkState] setState called before transport is ready.");
3541
+ return;
3542
+ }
3543
+ if (!transport.isHost) {
3544
+ console.warn("[useNetworkState] setState is host-only.");
3545
+ return;
3546
+ }
3547
+ const existing = entitiesRef.current.get(id);
3548
+ if (!existing) {
3549
+ console.warn(
3550
+ `[useNetworkState] entity "${id}" does not exist. Cannot setState.`
3551
+ );
3552
+ return;
3553
+ }
3554
+ const updated = {
3555
+ ...existing,
3556
+ ...partialState,
3557
+ id
3558
+ // id is immutable
3559
+ };
3560
+ replaceEntities((prev) => {
3561
+ const next = new Map(prev);
3562
+ next.set(id, updated);
3563
+ return next;
3564
+ });
3565
+ channelRef.current?.send({ action: "spawn", id, state: updated });
3566
+ },
3567
+ [networkManager, replaceEntities]
3568
+ );
3569
+ return {
3570
+ spawn,
3571
+ despawn,
3572
+ requestSpawn,
3573
+ getState,
3574
+ setState,
3575
+ entities
3576
+ };
3577
+ }
3578
+
3579
+ // src/core/DebugOverlay.ts
3580
+ var QUALITY_COLORS = {
3581
+ good: "#00ff00",
3582
+ degraded: "#ffff00",
3583
+ poor: "#ff4444"
3584
+ };
3585
+ var DebugOverlay = class {
3586
+ constructor(options) {
3587
+ this.visible = true;
3588
+ this.lastHTML = "";
3589
+ const opts = options ?? {};
3590
+ this.keyboardToggle = opts.keyboardToggle ?? "F3";
3591
+ this.el = document.createElement("div");
3592
+ const pos = opts.position ?? "top-right";
3593
+ const posStyles = this.positionStyles(pos);
3594
+ Object.assign(this.el.style, {
3595
+ position: "fixed",
3596
+ ...posStyles,
3597
+ background: "rgba(0,0,0,0.75)",
3598
+ color: "#00ff00",
3599
+ fontFamily: "'Courier New', monospace",
3600
+ fontSize: "11px",
3601
+ padding: "8px",
3602
+ borderRadius: "4px",
3603
+ zIndex: "99999",
3604
+ pointerEvents: "none",
3605
+ whiteSpace: "pre",
3606
+ lineHeight: "1.4",
3607
+ minWidth: "200px",
3608
+ boxSizing: "border-box"
3609
+ });
3610
+ document.body.appendChild(this.el);
3611
+ this.handleKey = (e) => {
3612
+ if (e.key === this.keyboardToggle) {
3613
+ e.preventDefault();
3614
+ this.toggle();
3615
+ }
3616
+ };
3617
+ window.addEventListener("keydown", this.handleKey);
3618
+ }
3619
+ /* ---- public API ---- */
3620
+ update(stats) {
3621
+ if (!this.visible) return;
3622
+ const latencyDisplay = this.formatLatency(stats.latencyMs);
3623
+ const qualityColor = QUALITY_COLORS[stats.networkQuality] ?? "#00ff00";
3624
+ const bwIn = (stats.bandwidthIn / 1024).toFixed(1);
3625
+ const bwOut = (stats.bandwidthOut / 1024).toFixed(1);
3626
+ const loss = (stats.packetLossRate * 100).toFixed(1);
3627
+ const html = `<b style="color:#00ccff">== NET DEBUG ==</b>
3628
+ tick ${stats.tick}
3629
+ srv tick ${stats.serverTick}
3630
+ drift ${stats.drift}
3631
+ latency ${latencyDisplay} ms
3632
+ loss ${loss}%
3633
+ bw in ${bwIn} KB/s
3634
+ bw out ${bwOut} KB/s
3635
+ quality <span style="color:${qualityColor}">${stats.networkQuality}</span>
3636
+ peers ${stats.peerCount}
3637
+ host ${stats.isHost ? "YES" : "NO"}
3638
+ sync ${stats.syncMode}`;
3639
+ if (html !== this.lastHTML) {
3640
+ this.el.innerHTML = html;
3641
+ this.lastHTML = html;
3642
+ }
3643
+ }
3644
+ show() {
3645
+ this.visible = true;
3646
+ this.el.style.display = "block";
3647
+ }
3648
+ hide() {
3649
+ this.visible = false;
3650
+ this.el.style.display = "none";
3651
+ }
3652
+ toggle() {
3653
+ if (this.visible) {
3654
+ this.hide();
3655
+ } else {
3656
+ this.show();
3657
+ }
3658
+ }
3659
+ destroy() {
3660
+ window.removeEventListener("keydown", this.handleKey);
3661
+ this.el.remove();
3662
+ }
3663
+ /* ---- helpers ---- */
3664
+ positionStyles(pos) {
3665
+ switch (pos) {
3666
+ case "top-left":
3667
+ return { top: "8px", left: "8px" };
3668
+ case "top-right":
3669
+ return { top: "8px", right: "8px" };
3670
+ case "bottom-left":
3671
+ return { bottom: "8px", left: "8px" };
3672
+ case "bottom-right":
3673
+ return { bottom: "8px", right: "8px" };
3674
+ }
3675
+ }
3676
+ formatLatency(latency) {
3677
+ if (typeof latency === "number") {
3678
+ return latency.toFixed(1);
3679
+ }
3680
+ if (latency.size === 0) return "\u2014";
3681
+ let sum = 0;
3682
+ latency.forEach((v) => sum += v);
3683
+ return (sum / latency.size).toFixed(1);
3684
+ }
3685
+ };
3686
+
3687
+ // src/core/InterestManager.ts
3688
+ var InterestManager = class {
3689
+ constructor(options) {
3690
+ this._cellSize = options?.cellSize ?? 50;
3691
+ this._defaultRadius = options?.defaultRadius ?? 200;
3692
+ this._alwaysRelevant = new Set(options?.alwaysRelevant ?? []);
3693
+ this._cells = /* @__PURE__ */ new Map();
3694
+ this._entityPositions = /* @__PURE__ */ new Map();
3695
+ }
3696
+ // ── Public API ──
3697
+ /**
3698
+ * Rebuild the spatial hash from the authoritative entity map.
3699
+ * Called once per host tick before any relevance queries.
3700
+ */
3701
+ updateEntities(entities) {
3702
+ this._cells.clear();
3703
+ this._entityPositions.clear();
3704
+ for (const [id, entity] of entities) {
3705
+ const z = "z" in entity ? entity.z : 0;
3706
+ const pos = { x: entity.x, y: entity.y, z };
3707
+ this._entityPositions.set(id, pos);
3708
+ const key = this._cellKey(pos.x, pos.y, pos.z);
3709
+ let bucket = this._cells.get(key);
3710
+ if (!bucket) {
3711
+ bucket = /* @__PURE__ */ new Set();
3712
+ this._cells.set(key, bucket);
3713
+ }
3714
+ bucket.add(id);
3715
+ }
3716
+ }
3717
+ /**
3718
+ * Return the set of entity ids relevant to a single client.
3719
+ *
3720
+ * Relevance is the *union* of:
3721
+ * 1. Entities whose cell overlaps the client's bounding sphere
3722
+ * 2. Entities in the `alwaysRelevant` set
3723
+ * 3. Entities owned by this client
3724
+ */
3725
+ getRelevantEntities(clientPosition, clientId, owners, overrideRadius) {
3726
+ const radius = overrideRadius ?? this._defaultRadius;
3727
+ const cx = clientPosition.x;
3728
+ const cy = clientPosition.y;
3729
+ const cz = clientPosition.z ?? 0;
3730
+ const result = /* @__PURE__ */ new Set();
3731
+ const minCellX = Math.floor((cx - radius) / this._cellSize);
3732
+ const maxCellX = Math.floor((cx + radius) / this._cellSize);
3733
+ const minCellY = Math.floor((cy - radius) / this._cellSize);
3734
+ const maxCellY = Math.floor((cy + radius) / this._cellSize);
3735
+ const minCellZ = Math.floor((cz - radius) / this._cellSize);
3736
+ const maxCellZ = Math.floor((cz + radius) / this._cellSize);
3737
+ for (let ix = minCellX; ix <= maxCellX; ix++) {
3738
+ for (let iy = minCellY; iy <= maxCellY; iy++) {
3739
+ for (let iz = minCellZ; iz <= maxCellZ; iz++) {
3740
+ const key = `${ix},${iy},${iz}`;
3741
+ const bucket = this._cells.get(key);
3742
+ if (bucket) {
3743
+ for (const entityId of bucket) {
3744
+ result.add(entityId);
3745
+ }
3746
+ }
3747
+ }
3748
+ }
3749
+ }
3750
+ for (const id of this._alwaysRelevant) {
3751
+ if (this._entityPositions.has(id)) {
3752
+ result.add(id);
3753
+ }
3754
+ }
3755
+ for (const [entityId, ownerId] of owners) {
3756
+ if (ownerId === clientId && this._entityPositions.has(entityId)) {
3757
+ result.add(entityId);
3758
+ }
3759
+ }
3760
+ return result;
3761
+ }
3762
+ /**
3763
+ * Build a filter callback compatible with
3764
+ * `HostAuthority.setInterestFilter`.
3765
+ *
3766
+ * The returned function closes over a single relevance pass for every
3767
+ * known client so that per-entity filtering during broadcast is a cheap
3768
+ * `Set.has` lookup.
3769
+ *
3770
+ * @param clientPositions peerId -> position of that client's camera/player
3771
+ * @param owners entityId -> ownerPeerId
3772
+ */
3773
+ createFilter(clientPositions, owners) {
3774
+ const relevanceSets = /* @__PURE__ */ new Map();
3775
+ for (const [peerId, pos] of clientPositions) {
3776
+ relevanceSets.set(
3777
+ peerId,
3778
+ this.getRelevantEntities(pos, peerId, owners)
3779
+ );
3780
+ }
3781
+ return (entityId, peerId) => {
3782
+ const set = relevanceSets.get(peerId);
3783
+ if (!set) return true;
3784
+ return set.has(entityId);
3785
+ };
3786
+ }
3787
+ /** Remove all data from the grid. */
3788
+ clear() {
3789
+ this._cells.clear();
3790
+ this._entityPositions.clear();
3791
+ }
3792
+ // ── Private helpers ──
3793
+ _cellKey(x, y, z) {
3794
+ const cx = Math.floor(x / this._cellSize);
3795
+ const cy = Math.floor(y / this._cellSize);
3796
+ const cz = Math.floor(z / this._cellSize);
3797
+ return `${cx},${cy},${cz}`;
3798
+ }
3799
+ };
3800
+ // Annotate the CommonJS export names for ESM import in node:
3801
+ 0 && (module.exports = {
3802
+ DebugOverlay,
3803
+ FirebaseStrategy,
3804
+ InterestManager,
3805
+ MqttStrategy,
3806
+ MultiplayerBridge,
3807
+ MultiplayerProvider,
3808
+ NetworkSimulator,
3809
+ useHost,
3810
+ useLobby,
3811
+ useMultiplayer,
3812
+ useNetworkEvents,
3813
+ useNetworkState,
3814
+ usePlayers,
3815
+ useRoom
3816
+ });
3817
+ //# sourceMappingURL=index.js.map