action-engine-js 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/LICENSE +45 -0
  2. package/README.md +348 -0
  3. package/actionengine/3rdparty/goblin/goblin.js +9609 -0
  4. package/actionengine/3rdparty/goblin/goblin.min.js +5 -0
  5. package/actionengine/camera/actioncamera.js +90 -0
  6. package/actionengine/camera/cameracollisionhandler.js +69 -0
  7. package/actionengine/character/actioncharacter.js +360 -0
  8. package/actionengine/character/actioncharacter3D.js +61 -0
  9. package/actionengine/core/app.js +430 -0
  10. package/actionengine/debug/basedebugpanel.js +858 -0
  11. package/actionengine/display/canvasmanager.js +75 -0
  12. package/actionengine/display/gl/programmanager.js +570 -0
  13. package/actionengine/display/gl/shaders/lineshader.js +118 -0
  14. package/actionengine/display/gl/shaders/objectshader.js +1756 -0
  15. package/actionengine/display/gl/shaders/particleshader.js +43 -0
  16. package/actionengine/display/gl/shaders/shadowshader.js +319 -0
  17. package/actionengine/display/gl/shaders/spriteshader.js +100 -0
  18. package/actionengine/display/gl/shaders/watershader.js +67 -0
  19. package/actionengine/display/graphics/actionmodel3D.js +191 -0
  20. package/actionengine/display/graphics/actionsprite3D.js +230 -0
  21. package/actionengine/display/graphics/lighting/actiondirectionalshadowlight.js +864 -0
  22. package/actionengine/display/graphics/lighting/actionlight.js +211 -0
  23. package/actionengine/display/graphics/lighting/actionomnidirectionalshadowlight.js +862 -0
  24. package/actionengine/display/graphics/lighting/lightingconstants.js +263 -0
  25. package/actionengine/display/graphics/lighting/lightmanager.js +789 -0
  26. package/actionengine/display/graphics/renderableobject.js +44 -0
  27. package/actionengine/display/graphics/renderers/actionrenderer2D.js +341 -0
  28. package/actionengine/display/graphics/renderers/actionrenderer3D/actionrenderer3D.js +655 -0
  29. package/actionengine/display/graphics/renderers/actionrenderer3D/canvasmanager3D.js +82 -0
  30. package/actionengine/display/graphics/renderers/actionrenderer3D/debugrenderer3D.js +493 -0
  31. package/actionengine/display/graphics/renderers/actionrenderer3D/objectrenderer3D.js +790 -0
  32. package/actionengine/display/graphics/renderers/actionrenderer3D/spriteRenderer3D.js +266 -0
  33. package/actionengine/display/graphics/renderers/actionrenderer3D/sunrenderer3D.js +140 -0
  34. package/actionengine/display/graphics/renderers/actionrenderer3D/waterrenderer3D.js +173 -0
  35. package/actionengine/display/graphics/renderers/actionrenderer3D/weatherrenderer3D.js +87 -0
  36. package/actionengine/display/graphics/texture/proceduraltexture.js +192 -0
  37. package/actionengine/display/graphics/texture/texturemanager.js +242 -0
  38. package/actionengine/display/graphics/texture/textureregistry.js +177 -0
  39. package/actionengine/input/actionscrollablearea.js +1405 -0
  40. package/actionengine/input/inputhandler.js +1647 -0
  41. package/actionengine/math/geometry/geometrybuilder.js +161 -0
  42. package/actionengine/math/geometry/glbexporter.js +364 -0
  43. package/actionengine/math/geometry/glbloader.js +722 -0
  44. package/actionengine/math/geometry/modelcodegenerator.js +97 -0
  45. package/actionengine/math/geometry/triangle.js +33 -0
  46. package/actionengine/math/geometry/triangleutils.js +34 -0
  47. package/actionengine/math/mathutils.js +25 -0
  48. package/actionengine/math/matrix4.js +785 -0
  49. package/actionengine/math/physics/actionphysics.js +108 -0
  50. package/actionengine/math/physics/actionphysicsobject3D.js +164 -0
  51. package/actionengine/math/physics/actionphysicsworld3D.js +238 -0
  52. package/actionengine/math/physics/actionraycast.js +129 -0
  53. package/actionengine/math/physics/shapes/actionphysicsbox3D.js +158 -0
  54. package/actionengine/math/physics/shapes/actionphysicscapsule3D.js +200 -0
  55. package/actionengine/math/physics/shapes/actionphysicscompoundshape3D.js +147 -0
  56. package/actionengine/math/physics/shapes/actionphysicscone3D.js +126 -0
  57. package/actionengine/math/physics/shapes/actionphysicsconvexshape3D.js +72 -0
  58. package/actionengine/math/physics/shapes/actionphysicscylinder3D.js +117 -0
  59. package/actionengine/math/physics/shapes/actionphysicsmesh3D.js +74 -0
  60. package/actionengine/math/physics/shapes/actionphysicsplane3D.js +100 -0
  61. package/actionengine/math/physics/shapes/actionphysicssphere3D.js +95 -0
  62. package/actionengine/math/quaternion.js +61 -0
  63. package/actionengine/math/vector2.js +277 -0
  64. package/actionengine/math/vector3.js +318 -0
  65. package/actionengine/math/viewfrustum.js +136 -0
  66. package/actionengine/network/ACTIONNETREADME.md +810 -0
  67. package/actionengine/network/client/ActionNetManager.js +802 -0
  68. package/actionengine/network/client/ActionNetManagerGUI.js +1709 -0
  69. package/actionengine/network/client/ActionNetManagerP2P.js +1537 -0
  70. package/actionengine/network/client/SyncSystem.js +422 -0
  71. package/actionengine/network/p2p/ActionNetPeer.js +142 -0
  72. package/actionengine/network/p2p/ActionNetTrackerClient.js +623 -0
  73. package/actionengine/network/p2p/DataConnection.js +282 -0
  74. package/actionengine/network/p2p/README.md +510 -0
  75. package/actionengine/network/p2p/example.html +502 -0
  76. package/actionengine/network/server/ActionNetServer.js +577 -0
  77. package/actionengine/network/server/ActionNetServerSSL.js +579 -0
  78. package/actionengine/network/server/ActionNetServerUtils.js +458 -0
  79. package/actionengine/network/server/SERVERREADME.md +314 -0
  80. package/actionengine/network/server/package-lock.json +35 -0
  81. package/actionengine/network/server/package.json +13 -0
  82. package/actionengine/network/server/start.bat +27 -0
  83. package/actionengine/network/server/start.sh +25 -0
  84. package/actionengine/network/server/startwss.bat +27 -0
  85. package/actionengine/sound/audiomanager.js +1589 -0
  86. package/actionengine/sound/soundfont/ACTIONSOUNDFONT_README.md +205 -0
  87. package/actionengine/sound/soundfont/actionparser.js +718 -0
  88. package/actionengine/sound/soundfont/actionreverb.js +252 -0
  89. package/actionengine/sound/soundfont/actionsoundfont.js +543 -0
  90. package/actionengine/sound/soundfont/sf2playerlicence.txt +29 -0
  91. package/actionengine/sound/soundfont/soundfont.js +2 -0
  92. package/dist/action-engine.min.js +328 -0
  93. package/package.json +35 -0
@@ -0,0 +1,1537 @@
1
+ /**
2
+ * ActionNetManagerP2P - P2P network manager using ActionNetP2P library (dual WebRTC channels)
3
+ *
4
+ * Two-phase connection model:
5
+ * Phase 1 (Signaling): ActionNetPeer's data channel for handshakes, room status, WebRTC signaling (offer/answer/ICE)
6
+ * Phase 2 (Game): Separate RTCPeerConnection for game data (created manually on acceptJoin/joinRoom)
7
+ *
8
+ * ARCHITECTURE:
9
+ * - ActionNetTrackerClient: Peer discovery
10
+ * - ActionNetPeer: Signaling channel (built-in data channel)
11
+ * - Manual RTCPeerConnection: Game data channel
12
+ * - SyncSystem: Game state synchronization (via game data channel)
13
+ *
14
+ * USAGE:
15
+ * ```javascript
16
+ * const net = new ActionNetManagerP2P({ debug: true });
17
+ *
18
+ * // Join a game (create or find)
19
+ * net.joinGame('tetris-1v1');
20
+ *
21
+ * // Listen for discovered rooms
22
+ * net.on('roomList', (rooms) => {
23
+ * console.log('Available rooms:', rooms);
24
+ * });
25
+ *
26
+ * // Join a host's room
27
+ * net.joinRoom(hostPeerId).then(() => {
28
+ * // Connected! Game channel ready
29
+ * const dataChannel = net.getDataChannel();
30
+ * });
31
+ * ```
32
+ */
33
+ class ActionNetManagerP2P {
34
+ constructor(config = {}) {
35
+ this.config = {
36
+ debug: config.debug || false,
37
+ gameId: config.gameId || 'game-id-00000',
38
+ broadcastInterval: config.broadcastInterval || 1000,
39
+ staleThreshold: config.staleThreshold || 1000,
40
+ maxPlayers: config.maxPlayers || 2,
41
+ iceServers: config.iceServers || [
42
+ { urls: "stun:stun.l.google.com:19302" },
43
+ { urls: "stun:stun1.l.google.com:19302" }
44
+ ],
45
+ numwant: config.numwant || 50,
46
+ announceInterval: config.announceInterval || 5000,
47
+ maxAnnounceInterval: config.maxAnnounceInterval || 120000,
48
+ backoffMultiplier: config.backoffMultiplier || 1.1
49
+ };
50
+
51
+ // State
52
+ this.currentGameId = null;
53
+ this.peerId = null;
54
+ this.username = "Anonymous";
55
+ this.isHost = false;
56
+ this.currentRoomPeerId = null;
57
+
58
+ // ActionNetP2P
59
+ this.tracker = null;
60
+ this.infohash = null;
61
+
62
+ // Peer connections: peerId -> { peer: ActionNetPeer, status, pc: RTCPeerConnection, channel: RTCDataChannel }
63
+ this.peerConnections = new Map();
64
+
65
+ // Game data channel (the one NetworkSession uses)
66
+ this.dataChannel = null;
67
+
68
+ // Room tracking
69
+ this.discoveredRooms = new Map();
70
+ this.roomStatusInterval = null;
71
+ this.staleRoomCleanupInterval = null;
72
+ this.connectedUsers = [];
73
+ this.userListVersion = 0;
74
+
75
+ // Event handlers
76
+ this.handlers = new Map();
77
+
78
+ // Connection abort
79
+ this.connectionAbortController = null;
80
+
81
+ this.log('Initialized ActionNetManagerP2P');
82
+ }
83
+
84
+ /**
85
+ * Register event handler
86
+ */
87
+ on(event, handler) {
88
+ if (!this.handlers.has(event)) {
89
+ this.handlers.set(event, []);
90
+ }
91
+ this.handlers.get(event).push(handler);
92
+ }
93
+
94
+ /**
95
+ * Unregister event handler
96
+ */
97
+ off(event, handler) {
98
+ if (!this.handlers.has(event)) return;
99
+ const handlers = this.handlers.get(event);
100
+ const index = handlers.indexOf(handler);
101
+ if (index !== -1) {
102
+ handlers.splice(index, 1);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Emit event
108
+ */
109
+ emit(event, ...args) {
110
+ if (!this.handlers.has(event)) return;
111
+ this.handlers.get(event).forEach(h => {
112
+ try {
113
+ h(...args);
114
+ } catch (e) {
115
+ this.log(`Error in ${event} handler: ${e.message}`, 'error');
116
+ }
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Logging utility
122
+ */
123
+ log(msg, level = 'info') {
124
+ if (this.config.debug) {
125
+ console.log(`[ActionNetManagerP2P] ${msg}`);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Join a game (start peer discovery)
131
+ */
132
+ async joinGame(gameId, username = 'Anonymous') {
133
+ // Create abort controller for this connection attempt
134
+ this.connectionAbortController = new AbortController();
135
+ const signal = this.connectionAbortController.signal;
136
+
137
+ // Check if already aborted
138
+ if (signal.aborted) {
139
+ throw new Error('Connection cancelled');
140
+ }
141
+
142
+ // Listen for abort signal
143
+ signal.addEventListener('abort', () => {
144
+ // Will be caught by startConnection's catch block
145
+ });
146
+
147
+ try {
148
+ this.currentGameId = gameId;
149
+ this.username = username;
150
+ this.peerId = this.generatePeerId();
151
+
152
+ this.log(`Joining game: ${gameId} as ${this.peerId}`);
153
+
154
+ // Generate infohash from game ID
155
+ this.infohash = await this.gameidToHash(gameId);
156
+ this.log(`Game ID hash (infohash): ${this.infohash}`);
157
+
158
+ // Check abort signal
159
+ if (signal.aborted) throw new Error('Connection cancelled');
160
+
161
+ // Fetch tracker list
162
+ const trackerUrls = await this.fetchTrackerList();
163
+ this.log(`Using ${trackerUrls.length} trackers for discovery`);
164
+
165
+ // Create tracker client
166
+ this.tracker = new ActionNetTrackerClient(trackerUrls, this.infohash, this.peerId, {
167
+ debug: this.config.debug,
168
+ numwant: this.config.numwant,
169
+ announceInterval: this.config.announceInterval,
170
+ maxAnnounceInterval: this.config.maxAnnounceInterval,
171
+ backoffMultiplier: this.config.backoffMultiplier,
172
+ iceServers: this.config.iceServers
173
+ });
174
+
175
+ // Handle DataConnection (ActionNetPeer signaling + negotiated RTCPeerConnection)
176
+ this.tracker.on('connection', (connection) => {
177
+ const peerId = connection.remotePeerId;
178
+ this.log(`DataConnection established with peer: ${peerId}`);
179
+
180
+ // Store connection
181
+ if (!this.peerConnections.has(peerId)) {
182
+ this.peerConnections.set(peerId, {
183
+ connection: connection,
184
+ status: 'signaling',
185
+ pc: null,
186
+ channel: null
187
+ });
188
+
189
+ // Listen for signaling messages through DataConnection
190
+ connection.on('data', (data) => {
191
+ try {
192
+ let message;
193
+ if (typeof data === 'object') {
194
+ message = data;
195
+ } else if (typeof data === 'string') {
196
+ message = JSON.parse(data);
197
+ } else {
198
+ message = JSON.parse(data.toString());
199
+ }
200
+ this.handleSignalingMessage(peerId, message);
201
+ } catch (e) {
202
+ this.log(`Error parsing signaling message: ${e.message}`, 'error');
203
+ }
204
+ });
205
+
206
+ // Send initial handshake through DataConnection
207
+ connection.send({
208
+ type: 'handshake',
209
+ peerId: this.peerId,
210
+ gameId: this.currentGameId,
211
+ username: this.username
212
+ });
213
+
214
+ // If we're a host, broadcast room status
215
+ if (this.isHost) {
216
+ connection.send({
217
+ type: 'roomStatus',
218
+ peerId: this.peerId,
219
+ username: this.username,
220
+ hosting: true,
221
+ gameType: this.currentGameId,
222
+ maxPlayers: this.config.maxPlayers,
223
+ currentPlayers: this.connectedUsers.length,
224
+ slots: this.config.maxPlayers - this.connectedUsers.length
225
+ });
226
+ }
227
+ }
228
+ });
229
+
230
+ // Handle tracker ready
231
+ this.tracker.on('ready', () => {
232
+ this.log('Tracker ready, discovering peers...');
233
+ });
234
+
235
+ // Handle tracker updates
236
+ this.tracker.on('update', (data) => {
237
+ this.log(`Tracker: ${data.complete} seeders, ${data.incomplete} leechers`);
238
+ });
239
+
240
+ // Handle peer connection failure
241
+ this.tracker.on('peer-failed', (data) => {
242
+ const peerId = data.id;
243
+ this.log(`Peer connection failed: ${peerId}`, 'error');
244
+ const peerData = this.peerConnections.get(peerId);
245
+ if (peerData) {
246
+ peerData.status = 'failed';
247
+ }
248
+ });
249
+
250
+ // Handle peer disconnection (refresh, browser close, etc.)
251
+ this.tracker.on('peer-disconnected', (data) => {
252
+ const peerId = data.id;
253
+ this.log(`Peer disconnected: ${peerId}`);
254
+
255
+ const peerData = this.peerConnections.get(peerId);
256
+ if (peerData) {
257
+ if (peerData.channel) {
258
+ peerData.channel.close();
259
+ }
260
+ if (peerData.pc) {
261
+ peerData.pc.close();
262
+ }
263
+ this.peerConnections.delete(peerId);
264
+ }
265
+
266
+ // Clean up discovered room
267
+ this.removeDiscoveredRoom(peerId);
268
+
269
+ // If this was the active game connection, handle disconnect
270
+ if (this.currentRoomPeerId === peerId) {
271
+ this.dataChannel = null;
272
+ this.emit('leftRoom', peerId);
273
+
274
+ // Guest: host disconnected
275
+ if (!this.isHost) {
276
+ this.emit('hostLeft', { peerId: peerId });
277
+ } else {
278
+ // Host: guest disconnected - remove from user list
279
+ this.removeUser(peerId);
280
+ this.emit('guestLeft', { peerId: peerId });
281
+ }
282
+ } else if (this.isHost && this.isInRoom()) {
283
+ // Host in a room: a guest (not current connection) disconnected
284
+ this.removeUser(peerId);
285
+ this.emit('guestLeft', { peerId: peerId });
286
+ }
287
+ });
288
+
289
+ // Handle tracker errors
290
+ this.tracker.on('error', (err) => {
291
+ this.log(`Tracker error: ${err.message}`, 'error');
292
+ });
293
+
294
+ // Connect to tracker
295
+ try {
296
+ await this.tracker.connect();
297
+ this.log('Connected to tracker');
298
+ } catch (error) {
299
+ this.log(`Failed to connect to tracker: ${error.message}`, 'error');
300
+ throw error;
301
+ }
302
+
303
+ // Start broadcasting room status
304
+ this.startRoomBroadcast();
305
+
306
+ // Check abort signal
307
+ if (signal.aborted) throw new Error('Connection cancelled');
308
+
309
+ // Start stale room cleanup
310
+ this.startStaleRoomCleanup();
311
+
312
+ // Emit connected event
313
+ this.emit('connected');
314
+ this.connectionAbortController = null; // Clear abort controller
315
+ } catch (error) {
316
+ // Check if it was a cancellation
317
+ if (signal.aborted || error.message === 'Connection cancelled') {
318
+ this.connectionAbortController = null;
319
+ throw error;
320
+ }
321
+ throw error;
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Handle signaling messages from peer (through ActionNetPeer's data channel)
327
+ */
328
+ handleSignalingMessage(peerId, message) {
329
+ const peerData = this.peerConnections.get(peerId);
330
+ if (!peerData) {
331
+ this.log(`No peer data for ${peerId}`);
332
+ return;
333
+ }
334
+
335
+ this.log(`Signaling message from ${peerId}: ${message.type}`);
336
+
337
+ switch (message.type) {
338
+ case 'handshake':
339
+ this.handleHandshake(peerId, message);
340
+ break;
341
+ case 'roomStatus':
342
+ this.handleRoomStatus(peerId, message);
343
+ break;
344
+ case 'joinRequest':
345
+ this.handleJoinRequest(peerId, message);
346
+ break;
347
+ case 'offer':
348
+ this.handleOffer(peerId, message);
349
+ break;
350
+ case 'answer':
351
+ this.handleAnswer(peerId, message);
352
+ break;
353
+ case 'ice-candidate':
354
+ this.handleIceCandidate(peerId, message);
355
+ break;
356
+ case 'userList':
357
+ this.handleUserList(peerId, message);
358
+ break;
359
+ case 'joinAccepted':
360
+ this.log(`Received joinAccepted from ${peerId}`);
361
+ this.emit('joinAccepted', message);
362
+ break;
363
+ case 'joinRejected':
364
+ this.log(`Received joinRejected from ${peerId}`);
365
+ this.emit('joinRejected', message);
366
+ break;
367
+ case 'hostLeft':
368
+ this.log(`Host left: ${peerId}`);
369
+ if (this.currentRoomPeerId === peerId) {
370
+ this.dataChannel = null;
371
+ this.removeUser(peerId);
372
+ this.emit('hostLeft', { peerId: peerId });
373
+ }
374
+ break;
375
+ case 'guestLeft':
376
+ this.log(`Guest left: ${peerId}`);
377
+ if (this.isHost) {
378
+ this.removeUser(peerId);
379
+ // Clean up peer connection for potential rejoin
380
+ const peerData = this.peerConnections.get(peerId);
381
+ if (peerData) {
382
+ if (peerData.channel) peerData.channel.close();
383
+ if (peerData.pc) peerData.pc.close();
384
+ // Reset state but keep peer connection for signaling
385
+ peerData.pc = null;
386
+ peerData.channel = null;
387
+ peerData.status = 'signaling';
388
+ peerData._joinRequested = false;
389
+ peerData._joinAccepted = false;
390
+ peerData._joinUsername = null;
391
+ }
392
+ this.emit('guestLeft', { peerId: peerId });
393
+ }
394
+ break;
395
+ default:
396
+ this.log(`Unknown signaling message: ${message.type}`);
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Handle handshake
402
+ */
403
+ handleHandshake(peerId, message) {
404
+ this.log(`Handshake from ${peerId}`);
405
+
406
+ // Validate game ID matches
407
+ if (message.gameId !== this.currentGameId) {
408
+ this.log(`Handshake validation failed: peer on different game`, 'error');
409
+ return;
410
+ }
411
+
412
+ // If we're hosting, send room status back
413
+ if (this.isHost) {
414
+ const peerData = this.peerConnections.get(peerId);
415
+ if (peerData && peerData.connection) {
416
+ peerData.connection.send({
417
+ type: 'roomStatus',
418
+ peerId: this.peerId,
419
+ username: this.username,
420
+ hosting: true,
421
+ gameType: this.currentGameId,
422
+ maxPlayers: this.config.maxPlayers,
423
+ currentPlayers: this.connectedUsers.length,
424
+ slots: this.config.maxPlayers - this.connectedUsers.length
425
+ });
426
+ }
427
+ }
428
+
429
+ this.emit('peerHandshook', {
430
+ peerId: peerId,
431
+ username: message.username
432
+ });
433
+ }
434
+
435
+ /**
436
+ * Handle room status message
437
+ */
438
+ handleRoomStatus(peerId, message) {
439
+ this.log(`Room status from ${peerId}: ${message.currentPlayers}/${message.maxPlayers} players`);
440
+
441
+ const roomInfo = {
442
+ peerId: message.peerId,
443
+ username: message.username,
444
+ hosting: message.hosting,
445
+ gameType: message.gameType,
446
+ maxPlayers: message.maxPlayers,
447
+ currentPlayers: message.currentPlayers,
448
+ slots: message.slots,
449
+ lastSeen: Date.now()
450
+ };
451
+
452
+ this.discoveredRooms.set(peerId, roomInfo);
453
+ this.emit('roomList', Array.from(this.discoveredRooms.values()));
454
+ }
455
+
456
+ /**
457
+ * Handle join request
458
+ */
459
+ handleJoinRequest(peerId, message) {
460
+ this.log(`Join request from ${peerId}: ${message.username}`);
461
+
462
+ if (!this.isHost) {
463
+ this.log(`Not hosting, rejecting join request`, 'error');
464
+ return;
465
+ }
466
+
467
+ const peerData = this.peerConnections.get(peerId);
468
+
469
+ // Check if room is full BEFORE storing join state
470
+ if (this.connectedUsers.length >= this.config.maxPlayers) {
471
+ if (peerData && peerData.connection) {
472
+ peerData.connection.send({
473
+ type: 'joinRejected',
474
+ peerId: this.peerId,
475
+ reason: 'Room is full'
476
+ });
477
+ }
478
+ // Clean up join state to prevent further processing
479
+ if (peerData) {
480
+ peerData._joinRequested = false;
481
+ peerData._joinAccepted = false;
482
+ peerData._joinUsername = null;
483
+ }
484
+ this.emit('joinRejected', {
485
+ peerId: peerId,
486
+ reason: 'Room is full'
487
+ });
488
+ return;
489
+ }
490
+
491
+ // Store join request info for acceptJoin
492
+ if (peerData) {
493
+ peerData._joinUsername = message.username;
494
+ peerData._joinRequested = true; // Mark that join was requested
495
+ }
496
+
497
+ // Emit join request event for application logging/hooks
498
+ this.emit('joinRequest', {
499
+ peerId: peerId,
500
+ username: message.username
501
+ });
502
+ // Note: actual acceptance happens in handleOffer when WebRTC is ready
503
+ }
504
+
505
+ /**
506
+ * Accept a join request (host side)
507
+ * Sends acceptance message - RTCPeerConnection created in handleOffer
508
+ * NOTE: User is NOT added to connectedUsers yet - they're added when data channel opens
509
+ */
510
+ acceptJoin(peerId) {
511
+ this.log(`Accepting join from ${peerId}`);
512
+
513
+ const peerData = this.peerConnections.get(peerId);
514
+ if (!peerData) {
515
+ throw new Error(`No peer connection for ${peerId}`);
516
+ }
517
+
518
+ // Check if room is full before accepting
519
+ if (this.connectedUsers.length >= this.config.maxPlayers) {
520
+ this.log(`Cannot accept join from ${peerId}: room is full`, 'error');
521
+ peerData.connection.send({
522
+ type: 'joinRejected',
523
+ peerId: this.peerId,
524
+ reason: 'Room is full'
525
+ });
526
+ return;
527
+ }
528
+
529
+ // Send joinAccepted through signaling channel
530
+ // RTCPeerConnection will be created in handleOffer when offer arrives
531
+ // User will be added to connectedUsers only when data channel opens
532
+ peerData.connection.send({
533
+ type: 'joinAccepted',
534
+ peerId: this.peerId,
535
+ users: this.connectedUsers
536
+ });
537
+ }
538
+
539
+ /**
540
+ * Handle WebRTC offer (responder side - host receiving offer from joiner)
541
+ *
542
+ * Waits for ICE gathering to complete before sending answer.
543
+ */
544
+ async handleOffer(peerId, message) {
545
+ this.log(`Offer from ${peerId}`);
546
+
547
+ const peerData = this.peerConnections.get(peerId);
548
+ if (!peerData) return;
549
+
550
+ // If this peer requested to join, auto-accept now that we have the offer
551
+ if (peerData._joinRequested && !peerData._joinAccepted) {
552
+ peerData._joinAccepted = true;
553
+ this.acceptJoin(peerId);
554
+ }
555
+
556
+ // Create RTCPeerConnection if needed
557
+ if (!peerData.pc) {
558
+ peerData.pc = new RTCPeerConnection({
559
+ iceServers: this.config.iceServers
560
+ });
561
+
562
+ peerData.pc.onicecandidate = (evt) => {
563
+ if (evt.candidate) {
564
+ this.sendSignalingMessage(peerId, {
565
+ type: 'ice-candidate',
566
+ candidate: evt.candidate
567
+ });
568
+ }
569
+ };
570
+
571
+ peerData.pc.ondatachannel = (evt) => {
572
+ this.log(`Game data channel received from ${peerId}`);
573
+ peerData.channel = evt.channel;
574
+ this.setupGameDataChannel(peerId, evt.channel);
575
+ };
576
+ }
577
+
578
+ try {
579
+ // Set remote description and create answer
580
+ await peerData.pc.setRemoteDescription({ type: 'offer', sdp: message.sdp });
581
+ const answer = await peerData.pc.createAnswer();
582
+ await peerData.pc.setLocalDescription(answer);
583
+
584
+ // Wait for ICE gathering to complete before sending answer
585
+ const pc = peerData.pc;
586
+ if (pc.iceGatheringState === 'complete') {
587
+ this.log(`ICE gathering complete, sending answer`);
588
+ } else {
589
+ this.log(`Waiting for ICE gathering to complete...`);
590
+ await new Promise((resolveGather) => {
591
+ const gatherTimeoutHandle = setTimeout(() => {
592
+ pc.removeEventListener('icegatheringstatechange', onGatherStateChange);
593
+ this.log(`ICE gathering timeout - sending answer with partial candidates`);
594
+ resolveGather();
595
+ }, 3000);
596
+
597
+ const onGatherStateChange = () => {
598
+ this.log(`ICE gathering state: ${pc.iceGatheringState}`);
599
+ if (pc.iceGatheringState === 'complete') {
600
+ clearTimeout(gatherTimeoutHandle);
601
+ pc.removeEventListener('icegatheringstatechange', onGatherStateChange);
602
+ this.log(`ICE gathering complete`);
603
+ resolveGather();
604
+ }
605
+ };
606
+
607
+ pc.addEventListener('icegatheringstatechange', onGatherStateChange);
608
+ });
609
+ }
610
+
611
+ // Send answer through signaling channel with complete SDP
612
+ this.sendSignalingMessage(peerId, {
613
+ type: 'answer',
614
+ sdp: peerData.pc.localDescription.sdp
615
+ });
616
+ } catch (error) {
617
+ this.log(`Error handling offer from ${peerId}: ${error.message}`, 'error');
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Handle WebRTC answer (initiator side - joiner receiving answer from host)
623
+ */
624
+ async handleAnswer(peerId, message) {
625
+ this.log(`Answer from ${peerId}`);
626
+
627
+ const peerData = this.peerConnections.get(peerId);
628
+ if (!peerData || !peerData.pc) return;
629
+
630
+ await peerData.pc.setRemoteDescription({ type: 'answer', sdp: message.sdp });
631
+ }
632
+
633
+ /**
634
+ * Handle ICE candidate
635
+ */
636
+ async handleIceCandidate(peerId, message) {
637
+ const peerData = this.peerConnections.get(peerId);
638
+ if (!peerData || !peerData.pc) return;
639
+
640
+ try {
641
+ await peerData.pc.addIceCandidate(new RTCIceCandidate(message.candidate));
642
+ } catch (error) {
643
+ this.log(`ICE candidate error (non-fatal): ${error.message}`, 'error');
644
+ }
645
+ }
646
+
647
+ /**
648
+ * Send signaling message through DataConnection
649
+ */
650
+ sendSignalingMessage(peerId, message) {
651
+ const peerData = this.peerConnections.get(peerId);
652
+ if (!peerData || !peerData.connection) {
653
+ this.log(`Cannot send signaling message: no connection for ${peerId}`, 'error');
654
+ return;
655
+ }
656
+
657
+ try {
658
+ this.log(`Sending signaling message to ${peerId}: ${message.type}`);
659
+ peerData.connection.send(message);
660
+ } catch (e) {
661
+ this.log(`Error sending signaling message to ${peerId}: ${e.message}`, 'error');
662
+ }
663
+ }
664
+
665
+ /**
666
+ * Setup game data channel handlers
667
+ */
668
+ setupGameDataChannel(peerId, channel) {
669
+ const peerData = this.peerConnections.get(peerId);
670
+ if (!peerData) return;
671
+
672
+ channel.onopen = () => {
673
+ this.log(`Game data channel opened with ${peerId}`);
674
+ peerData.status = 'gameConnected';
675
+
676
+ // Host: Add user to connected users NOW that we have a real connection
677
+ if (this.isHost) {
678
+ // Only add if join was accepted (not rejected)
679
+ if (peerData._joinAccepted && !this.connectedUsers.some(u => u.id === peerId)) {
680
+ this.addUser({
681
+ id: peerId,
682
+ username: peerData._joinUsername || 'Player',
683
+ isHost: false
684
+ });
685
+ }
686
+ }
687
+
688
+ // If this is our game connection (joiner), emit
689
+ if (peerId === this.currentRoomPeerId) {
690
+ this.dataChannel = channel;
691
+ this.emit('joinedRoom', {
692
+ peerId: peerId,
693
+ dataChannel: channel
694
+ });
695
+ }
696
+
697
+ // If host and this is first joiner, set up game sync
698
+ if (this.isHost && !this.dataChannel) {
699
+ this.log(`Host: setting up game sync with first joiner ${peerId}`);
700
+ this.dataChannel = channel;
701
+ this.emit('joinedRoom', {
702
+ peerId: peerId,
703
+ dataChannel: channel
704
+ });
705
+ }
706
+
707
+ // Host: broadcast user list now that peer is connected
708
+ if (this.isHost) {
709
+ this.broadcastUserList();
710
+ }
711
+ };
712
+
713
+ channel.onclose = () => {
714
+ this.log(`Game data channel closed with ${peerId}`);
715
+ peerData.status = 'disconnected';
716
+
717
+ // Only handle if this is the active channel (prevents stale closes from rejoin)
718
+ const isActiveChannel = (channel === this.dataChannel);
719
+
720
+ if (isActiveChannel) {
721
+ this.dataChannel = null;
722
+
723
+ // Remove user from list (handles refresh/disconnect)
724
+ this.removeUser(peerId);
725
+
726
+ // Notify about disconnect
727
+ if (!this.isHost) {
728
+ // Guest: host disconnected
729
+ this.emit('hostLeft', { peerId: peerId });
730
+ } else {
731
+ // Host: guest disconnected
732
+ this.emit('guestLeft', { peerId: peerId });
733
+ }
734
+ }
735
+
736
+ // Host: Clean up pending join flags so they can retry
737
+ if (this.isHost) {
738
+ peerData._joinRequested = false;
739
+ peerData._joinAccepted = false;
740
+ peerData._joinUsername = null;
741
+ }
742
+ };
743
+
744
+ channel.onerror = (evt) => {
745
+ this.log(`Game data channel error with ${peerId}`, 'error');
746
+ };
747
+
748
+ // Note: onmessage is set up by NetworkSession to handle sync messages
749
+ // Don't set it here as it would overwrite NetworkSession's handler
750
+ }
751
+
752
+ /**
753
+ * Generate unique display name (matching server-side behavior)
754
+ */
755
+ generateUniqueDisplayName(username, excludeId = null) {
756
+ const allDisplayNames = this.connectedUsers
757
+ .filter(u => u.id !== excludeId)
758
+ .map(u => u.displayName);
759
+
760
+ const countMap = {};
761
+
762
+ // Count existing instances of this username (for display name generation)
763
+ const allUsernames = this.connectedUsers
764
+ .filter(u => u.id !== excludeId)
765
+ .map(u => u.username);
766
+ allUsernames.forEach(name => {
767
+ countMap[name] = (countMap[name] || 0) + 1;
768
+ });
769
+
770
+ const existingCount = countMap[username] || 0;
771
+ let displayName = existingCount === 0 ? username : `${username} (${existingCount})`;
772
+
773
+ // Ensure the generated display name is unique
774
+ let counter = existingCount;
775
+ while (allDisplayNames.includes(displayName)) {
776
+ counter++;
777
+ displayName = `${username} (${counter})`;
778
+ }
779
+
780
+ return displayName;
781
+ }
782
+
783
+ /**
784
+ * Initialize user list
785
+ */
786
+ initializeUserList() {
787
+ this.connectedUsers = [];
788
+ this.userListVersion = 0;
789
+
790
+ this.addUser({
791
+ id: this.peerId,
792
+ username: this.username,
793
+ isHost: this.isHost
794
+ });
795
+ }
796
+
797
+ /**
798
+ * Add user to connected list
799
+ */
800
+ addUser(user) {
801
+ if (this.connectedUsers.some(u => u.id === user.id)) {
802
+ return;
803
+ }
804
+
805
+ // Generate unique display name if not provided
806
+ if (!user.displayName) {
807
+ user.displayName = this.generateUniqueDisplayName(user.username, user.id);
808
+ }
809
+
810
+ this.connectedUsers.push(user);
811
+ this.userListVersion++;
812
+ this.log(`User joined: ${user.displayName} (${user.id})`);
813
+
814
+ this.emit('userJoined', user);
815
+ this.emit('userList', this.connectedUsers);
816
+ }
817
+
818
+ /**
819
+ * Remove user from connected list
820
+ */
821
+ removeUser(userId) {
822
+ const index = this.connectedUsers.findIndex(u => u.id === userId);
823
+ if (index === -1) return;
824
+
825
+ const user = this.connectedUsers[index];
826
+ this.connectedUsers.splice(index, 1);
827
+ this.userListVersion++;
828
+ this.log(`User left: ${user.displayName} (${user.id})`);
829
+
830
+ this.emit('userLeft', user);
831
+ this.emit('userList', this.connectedUsers);
832
+
833
+ if (this.isHost) {
834
+ this.broadcastUserList();
835
+ }
836
+ }
837
+
838
+ /**
839
+ * Remove discovered room
840
+ */
841
+ removeDiscoveredRoom(peerId) {
842
+ if (this.discoveredRooms.has(peerId)) {
843
+ this.discoveredRooms.delete(peerId);
844
+ this.log(`Removed discovered room from ${peerId}`);
845
+ this.emit('roomList', Array.from(this.discoveredRooms.values()));
846
+ }
847
+ }
848
+
849
+ /**
850
+ * Get connected users
851
+ */
852
+ getConnectedUsers() {
853
+ return [...this.connectedUsers];
854
+ }
855
+
856
+ /**
857
+ * Broadcast user list to all peers
858
+ */
859
+ broadcastUserList() {
860
+ for (const [peerId, peerData] of this.peerConnections) {
861
+ if (peerData.status === 'gameConnected' && peerData.channel && peerData.channel.readyState === 'open') {
862
+ peerData.channel.send(JSON.stringify({
863
+ type: 'userList',
864
+ users: this.connectedUsers,
865
+ version: this.userListVersion
866
+ }));
867
+ }
868
+ }
869
+ }
870
+
871
+ /**
872
+ * Handle user list message
873
+ */
874
+ handleUserList(peerId, message) {
875
+ this.log(`User list from ${peerId}: ${message.users.length} users`);
876
+ this.connectedUsers = message.users;
877
+ this.userListVersion = message.version;
878
+ this.emit('userList', this.connectedUsers);
879
+ }
880
+
881
+ /**
882
+ * Step 1: Initiate connection setup (create RTCPeerConnection, data channel, etc)
883
+ */
884
+ async initiateConnection(hostPeerId) {
885
+ this.log(`Initiating connection to ${hostPeerId}`);
886
+
887
+ const peerData = this.peerConnections.get(hostPeerId);
888
+ if (!peerData) {
889
+ throw new Error(`No connection to host ${hostPeerId}`);
890
+ }
891
+
892
+ this.currentRoomPeerId = hostPeerId;
893
+ this.isHost = false;
894
+ this.initializeUserList();
895
+
896
+ // Create RTCPeerConnection for game data
897
+ const pc = new RTCPeerConnection({
898
+ iceServers: this.config.iceServers
899
+ });
900
+
901
+ peerData.pc = pc;
902
+
903
+ pc.onicecandidate = (evt) => {
904
+ if (evt.candidate) {
905
+ this.sendSignalingMessage(hostPeerId, {
906
+ type: 'ice-candidate',
907
+ candidate: evt.candidate
908
+ });
909
+ }
910
+ };
911
+
912
+ pc.onicegatheringstatechange = () => {
913
+ this.log(`ICE gathering state with ${hostPeerId}: ${pc.iceGatheringState}`);
914
+ };
915
+
916
+ pc.onconnectionstatechange = () => {
917
+ this.log(`Connection state with ${hostPeerId}: ${pc.connectionState}`);
918
+ };
919
+
920
+ // Send join request through signaling channel
921
+ peerData.connection.send({
922
+ type: 'joinRequest',
923
+ peerId: this.peerId,
924
+ username: this.username
925
+ });
926
+ }
927
+
928
+ /**
929
+ * Step 2: Create and send WebRTC offer (with complete ICE gathering)
930
+ *
931
+ * Waits for ICE gathering to complete before sending offer.
932
+ * This ensures the SDP includes the best available candidate addresses,
933
+ * which is crucial for constrained networks (mobile on LTE, CGNAT, etc).
934
+ */
935
+ async sendOffer(hostPeerId, timeout = 10000) {
936
+ this.log(`Sending offer to ${hostPeerId}`);
937
+
938
+ const peerData = this.peerConnections.get(hostPeerId);
939
+ if (!peerData || !peerData.pc) {
940
+ throw new Error(`Connection not initialized for ${hostPeerId}`);
941
+ }
942
+
943
+ return new Promise(async (resolve, reject) => {
944
+ const timeoutHandle = setTimeout(() => {
945
+ reject(new Error('Offer creation timeout'));
946
+ }, timeout);
947
+
948
+ try {
949
+ const pc = peerData.pc;
950
+
951
+ // Create data channel NOW, before creating offer
952
+ if (!peerData.channel) {
953
+ const channel = pc.createDataChannel('game', { ordered: true });
954
+ peerData.channel = channel;
955
+ this.setupGameDataChannel(hostPeerId, channel);
956
+ this.log(`Created data channel for ${hostPeerId}`);
957
+ }
958
+
959
+ // Create offer
960
+ const offer = await pc.createOffer();
961
+ await pc.setLocalDescription(offer);
962
+
963
+ // Wait for ICE gathering to complete before sending
964
+ // This gives us all available candidate addresses upfront
965
+ if (pc.iceGatheringState === 'complete') {
966
+ // Already complete
967
+ this.log(`ICE gathering complete, sending offer`);
968
+ } else {
969
+ // Wait for completion
970
+ this.log(`Waiting for ICE gathering to complete...`);
971
+ await new Promise((resolveGather, rejectGather) => {
972
+ const gatherTimeoutHandle = setTimeout(() => {
973
+ pc.removeEventListener('icegatheringstatechange', onGatherStateChange);
974
+ // Fallback: send after timeout even if not complete (like ActionNetPeer does)
975
+ this.log(`ICE gathering timeout - sending offer with partial candidates`);
976
+ resolveGather();
977
+ }, 3000);
978
+
979
+ const onGatherStateChange = () => {
980
+ this.log(`ICE gathering state: ${pc.iceGatheringState}`);
981
+ if (pc.iceGatheringState === 'complete') {
982
+ clearTimeout(gatherTimeoutHandle);
983
+ pc.removeEventListener('icegatheringstatechange', onGatherStateChange);
984
+ this.log(`ICE gathering complete`);
985
+ resolveGather();
986
+ }
987
+ };
988
+
989
+ pc.addEventListener('icegatheringstatechange', onGatherStateChange);
990
+ });
991
+ }
992
+
993
+ // Now send the offer with complete (or best-effort) SDP
994
+ this.sendSignalingMessage(hostPeerId, {
995
+ type: 'offer',
996
+ sdp: pc.localDescription.sdp
997
+ });
998
+
999
+ clearTimeout(timeoutHandle);
1000
+ resolve();
1001
+ } catch (error) {
1002
+ clearTimeout(timeoutHandle);
1003
+ reject(error);
1004
+ }
1005
+ });
1006
+ }
1007
+
1008
+ /**
1009
+ * Step 3: Wait for host to accept the join request
1010
+ */
1011
+ async waitForAcceptance(hostPeerId, timeout = 10000) {
1012
+ this.log(`Waiting for host ${hostPeerId} to accept`);
1013
+
1014
+ return new Promise((resolve, reject) => {
1015
+ const timeoutHandle = setTimeout(() => {
1016
+ this.off('joinAccepted', onJoinAccepted);
1017
+ this.off('joinRejected', onJoinRejected);
1018
+ reject(new Error('Join request timeout'));
1019
+ }, timeout);
1020
+
1021
+ const onJoinAccepted = (data) => {
1022
+ if (data.peerId === hostPeerId) {
1023
+ clearTimeout(timeoutHandle);
1024
+ this.off('joinAccepted', onJoinAccepted);
1025
+ this.off('joinRejected', onJoinRejected);
1026
+ this.log(`Join accepted by host`);
1027
+
1028
+ // Update connected users from host
1029
+ this.connectedUsers = data.users || [];
1030
+
1031
+ // Guest: make sure we're in the user list (host adds us when channel opens, but we need to know now)
1032
+ if (!this.connectedUsers.some(u => u.id === this.peerId)) {
1033
+ this.connectedUsers.push({
1034
+ id: this.peerId,
1035
+ username: this.username,
1036
+ isHost: false,
1037
+ displayName: this.username
1038
+ });
1039
+ }
1040
+
1041
+ resolve(data);
1042
+ }
1043
+ };
1044
+
1045
+ const onJoinRejected = (data) => {
1046
+ if (data.peerId === hostPeerId) {
1047
+ clearTimeout(timeoutHandle);
1048
+ this.off('joinAccepted', onJoinAccepted);
1049
+ this.off('joinRejected', onJoinRejected);
1050
+ reject(new Error(data.reason || 'Join request rejected'));
1051
+ }
1052
+ };
1053
+
1054
+ this.on('joinAccepted', onJoinAccepted);
1055
+ this.on('joinRejected', onJoinRejected);
1056
+ });
1057
+ }
1058
+
1059
+ /**
1060
+ * Step 4: Wait for game data channel to actually open
1061
+ */
1062
+ async openGameChannel(hostPeerId, timeout = 15000) {
1063
+ this.log(`Waiting for game channel with ${hostPeerId}`);
1064
+
1065
+ const peerData = this.peerConnections.get(hostPeerId);
1066
+ if (!peerData || !peerData.pc || !peerData.channel) {
1067
+ throw new Error(`Channel not initialized for ${hostPeerId}`);
1068
+ }
1069
+
1070
+ const pc = peerData.pc;
1071
+ const channel = peerData.channel;
1072
+
1073
+ // Wait for data channel to open (ignore peer connection state, like DataConnection does)
1074
+ const channelReady = new Promise((resolve, reject) => {
1075
+ if (channel.readyState === 'open') {
1076
+ resolve();
1077
+ return;
1078
+ }
1079
+
1080
+ const timeoutHandle = setTimeout(() => {
1081
+ channel.removeEventListener('open', onChannelOpen);
1082
+ channel.removeEventListener('error', onChannelError);
1083
+ reject(new Error(`Data channel timeout (state: ${channel.readyState})`));
1084
+ }, timeout);
1085
+
1086
+ const onChannelOpen = () => {
1087
+ clearTimeout(timeoutHandle);
1088
+ channel.removeEventListener('open', onChannelOpen);
1089
+ channel.removeEventListener('error', onChannelError);
1090
+ resolve();
1091
+ };
1092
+
1093
+ const onChannelError = (evt) => {
1094
+ clearTimeout(timeoutHandle);
1095
+ channel.removeEventListener('open', onChannelOpen);
1096
+ channel.removeEventListener('error', onChannelError);
1097
+ reject(new Error(`Data channel error: ${evt.error?.message || 'unknown'}`));
1098
+ };
1099
+
1100
+ channel.addEventListener('open', onChannelOpen);
1101
+ channel.addEventListener('error', onChannelError);
1102
+ });
1103
+
1104
+ // Wait for data channel to open
1105
+ await channelReady;
1106
+ }
1107
+
1108
+ /**
1109
+ * Join a host's room (convenience method - calls all steps in sequence)
1110
+ */
1111
+ async joinRoom(hostPeerId) {
1112
+ this.log(`Joining room hosted by ${hostPeerId}`);
1113
+ this.emit('joinStarted', { hostPeerId });
1114
+
1115
+ try {
1116
+ await this.initiateConnection(hostPeerId);
1117
+ await this.sendOffer(hostPeerId);
1118
+ this.emit('offerSent', { hostPeerId });
1119
+
1120
+ await this.waitForAcceptance(hostPeerId);
1121
+ this.emit('acceptedByHost', { hostPeerId });
1122
+ this.emit('channelOpening', { hostPeerId });
1123
+
1124
+ await this.openGameChannel(hostPeerId);
1125
+ this.emit('channelConnected', { hostPeerId });
1126
+
1127
+ this.emit('joinedRoom', {
1128
+ peerId: hostPeerId,
1129
+ dataChannel: this.peerConnections.get(hostPeerId).channel
1130
+ });
1131
+ } catch (error) {
1132
+ this.emit('joinFailed', { hostPeerId, reason: error.message });
1133
+ throw error;
1134
+ }
1135
+ }
1136
+
1137
+ /**
1138
+ * Create a room (become host)
1139
+ */
1140
+ createRoom() {
1141
+ this.log('Creating room');
1142
+ this.isHost = true;
1143
+ this.currentRoomPeerId = this.peerId;
1144
+ this.initializeUserList();
1145
+
1146
+ if (!this.roomStatusInterval) {
1147
+ this.startRoomBroadcast();
1148
+ }
1149
+
1150
+ this.emit('roomCreated');
1151
+ // Host immediately joins their own room
1152
+ this.emit('joinedRoom', {
1153
+ peerId: this.peerId,
1154
+ dataChannel: null // Host doesn't have a data channel with themselves
1155
+ });
1156
+ }
1157
+
1158
+ /**
1159
+ * Start room status broadcast
1160
+ * Only hosts broadcast their status
1161
+ */
1162
+ startRoomBroadcast() {
1163
+ if (this.roomStatusInterval) return;
1164
+
1165
+ this.roomStatusInterval = setInterval(() => {
1166
+ if (this.isHost && this.tracker) {
1167
+ for (const [peerId, peerData] of this.peerConnections) {
1168
+ if (peerData.connection) {
1169
+ peerData.connection.send({
1170
+ type: 'roomStatus',
1171
+ peerId: this.peerId,
1172
+ username: this.username,
1173
+ hosting: true,
1174
+ gameType: this.currentGameId,
1175
+ maxPlayers: this.config.maxPlayers,
1176
+ currentPlayers: this.connectedUsers.length,
1177
+ slots: this.config.maxPlayers - this.connectedUsers.length
1178
+ });
1179
+ }
1180
+ }
1181
+ }
1182
+ }, this.config.broadcastInterval);
1183
+ }
1184
+
1185
+ /**
1186
+ * Start stale room cleanup
1187
+ */
1188
+ startStaleRoomCleanup() {
1189
+ if (this.staleRoomCleanupInterval) return;
1190
+
1191
+ this.staleRoomCleanupInterval = setInterval(() => {
1192
+ const now = Date.now();
1193
+ const stale = [];
1194
+
1195
+ for (const [peerId, roomInfo] of this.discoveredRooms) {
1196
+ if (now - roomInfo.lastSeen > this.config.staleThreshold) {
1197
+ stale.push(peerId);
1198
+ }
1199
+ }
1200
+
1201
+ stale.forEach(peerId => {
1202
+ this.removeDiscoveredRoom(peerId);
1203
+ });
1204
+ }, this.config.staleThreshold);
1205
+ }
1206
+
1207
+ /**
1208
+ * Get active game data channel
1209
+ */
1210
+ getDataChannel() {
1211
+ return this.dataChannel;
1212
+ }
1213
+
1214
+ /**
1215
+ * Get discovered rooms
1216
+ */
1217
+ getAvailableRooms() {
1218
+ return Array.from(this.discoveredRooms.values());
1219
+ }
1220
+
1221
+ /**
1222
+ * Helper: generate random peer ID
1223
+ */
1224
+ generatePeerId() {
1225
+ return 'peer_' + Math.random().toString(36).substr(2, 9);
1226
+ }
1227
+
1228
+ /**
1229
+ * Helper: convert game ID to hash (infohash)
1230
+ */
1231
+ async gameidToHash(gameId) {
1232
+ const encoder = new TextEncoder();
1233
+ const data = encoder.encode(gameId);
1234
+ const hashBuffer = await crypto.subtle.digest('SHA-1', data);
1235
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
1236
+ return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
1237
+ }
1238
+
1239
+ /**
1240
+ * Fetch tracker list
1241
+ */
1242
+ async fetchTrackerList() {
1243
+ const hardcoded = [
1244
+ 'wss://tracker.openwebtorrent.com/',
1245
+ 'wss://tracker.btorrent.xyz/',
1246
+ 'wss://tracker.fastcast.nz/',
1247
+ 'wss://tracker.files.fm:7073/announce',
1248
+ 'wss://tracker.sloppyta.co/',
1249
+ 'wss://tracker.webtorrent.dev/',
1250
+ 'wss://tracker.novage.com.ua/',
1251
+ 'wss://tracker.magnetoo.io/',
1252
+ 'wss://tracker.ghostchu-services.top:443/announce',
1253
+ 'ws://tracker.ghostchu-services.top:80/announce',
1254
+ 'ws://tracker.files.fm:7072/announce'
1255
+ ];
1256
+
1257
+ const sources = [
1258
+ 'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_ws.txt',
1259
+ 'https://cdn.jsdelivr.net/gh/ngosang/trackerslist@master/trackers_all_ws.txt',
1260
+ 'https://ngosang.github.io/trackerslist/trackers_all_ws.txt'
1261
+ ];
1262
+
1263
+ const allFetched = [];
1264
+
1265
+ for (const source of sources) {
1266
+ try {
1267
+ const response = await fetch(source, { timeout: 5000 });
1268
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1269
+
1270
+ const text = await response.text();
1271
+ const fetched = text
1272
+ .split('\n')
1273
+ .map(t => t.trim())
1274
+ .filter(t => t && (t.startsWith('wss://') || t.startsWith('ws://')));
1275
+
1276
+ allFetched.push(...fetched);
1277
+ this.log(`Fetched ${fetched.length} trackers from ${source}`);
1278
+ } catch (e) {
1279
+ this.log(`Failed to fetch from ${source}: ${e.message}`, 'error');
1280
+ }
1281
+ }
1282
+
1283
+ const merged = [...new Set([...hardcoded, ...allFetched])];
1284
+ const newTrackers = merged.length - hardcoded.length;
1285
+
1286
+ this.log(`Tracker list: ${hardcoded.length} hardcoded + ${newTrackers} fetched = ${merged.length} total`);
1287
+ return merged;
1288
+ }
1289
+
1290
+ /**
1291
+ * Leave the current room
1292
+ */
1293
+ leaveRoom() {
1294
+ if (!this.currentRoomPeerId) {
1295
+ this.log('Not in a room');
1296
+ return;
1297
+ }
1298
+
1299
+ const leftRoomId = this.currentRoomPeerId;
1300
+ this.log(`Leaving room ${leftRoomId}`);
1301
+
1302
+ if (this.isHost) {
1303
+ // Host: notify all guests that host is leaving, then close connections
1304
+ this.log('Host leaving - notifying guests');
1305
+ for (const [peerId, peerData] of this.peerConnections) {
1306
+ // Send disconnect notification through signaling channel if available
1307
+ if (peerData.connection) {
1308
+ try {
1309
+ peerData.connection.send({
1310
+ type: 'hostLeft',
1311
+ peerId: this.peerId
1312
+ });
1313
+ } catch (e) {
1314
+ this.log(`Could not notify guest ${peerId}: ${e.message}`, 'error');
1315
+ }
1316
+ }
1317
+
1318
+ if (peerData.channel) {
1319
+ // Clear handlers before closing
1320
+ peerData.channel.onopen = null;
1321
+ peerData.channel.onclose = null;
1322
+ peerData.channel.onerror = null;
1323
+ peerData.channel.onmessage = null;
1324
+ peerData.channel.close();
1325
+ }
1326
+ if (peerData.pc) {
1327
+ peerData.pc.onicecandidate = null;
1328
+ peerData.pc.ondatachannel = null;
1329
+ peerData.pc.close();
1330
+ peerData.pc = null;
1331
+ }
1332
+ peerData.channel = null;
1333
+ peerData.status = 'signaling';
1334
+ // Clear join state flags
1335
+ peerData._joinRequested = false;
1336
+ peerData._joinAccepted = false;
1337
+ peerData._joinUsername = null;
1338
+ }
1339
+ this.isHost = false;
1340
+ if (this.roomStatusInterval) {
1341
+ clearInterval(this.roomStatusInterval);
1342
+ this.roomStatusInterval = null;
1343
+ }
1344
+ } else {
1345
+ // Guest: notify host, then close game connection
1346
+ const peerData = this.peerConnections.get(leftRoomId);
1347
+ if (peerData) {
1348
+ // Send disconnect notification
1349
+ if (peerData.connection) {
1350
+ try {
1351
+ peerData.connection.send({
1352
+ type: 'guestLeft',
1353
+ peerId: this.peerId
1354
+ });
1355
+ } catch (e) {
1356
+ this.log(`Could not notify host: ${e.message}`, 'error');
1357
+ }
1358
+ }
1359
+
1360
+ if (peerData.channel) {
1361
+ // Clear handlers before closing
1362
+ peerData.channel.onopen = null;
1363
+ peerData.channel.onclose = null;
1364
+ peerData.channel.onerror = null;
1365
+ peerData.channel.onmessage = null;
1366
+ peerData.channel.close();
1367
+ }
1368
+ if (peerData.pc) {
1369
+ peerData.pc.onicecandidate = null;
1370
+ peerData.pc.ondatachannel = null;
1371
+ peerData.pc.close();
1372
+ peerData.pc = null;
1373
+ }
1374
+ peerData.channel = null;
1375
+ peerData.status = 'signaling';
1376
+ // Clear join state flags
1377
+ peerData._joinRequested = false;
1378
+ peerData._joinAccepted = false;
1379
+ peerData._joinUsername = null;
1380
+ }
1381
+ }
1382
+
1383
+ this.dataChannel = null;
1384
+ this.currentRoomPeerId = null;
1385
+ this.connectedUsers = [];
1386
+
1387
+ this.emit('leftRoom', leftRoomId);
1388
+ }
1389
+
1390
+ /**
1391
+ * Disconnect from tracker
1392
+ */
1393
+ async disconnect() {
1394
+ this.log('Disconnecting');
1395
+
1396
+ // Abort pending connection attempt
1397
+ if (this.connectionAbortController) {
1398
+ this.connectionAbortController.abort();
1399
+ this.connectionAbortController = null;
1400
+ }
1401
+
1402
+ if (this.roomStatusInterval) {
1403
+ clearInterval(this.roomStatusInterval);
1404
+ this.roomStatusInterval = null;
1405
+ }
1406
+
1407
+ if (this.staleRoomCleanupInterval) {
1408
+ clearInterval(this.staleRoomCleanupInterval);
1409
+ this.staleRoomCleanupInterval = null;
1410
+ }
1411
+
1412
+ // Close all peer connections
1413
+ for (const peerData of this.peerConnections.values()) {
1414
+ if (peerData.channel) {
1415
+ peerData.channel.close();
1416
+ }
1417
+ if (peerData.pc) {
1418
+ peerData.pc.close();
1419
+ }
1420
+ }
1421
+
1422
+ this.peerConnections.clear();
1423
+ this.discoveredRooms.clear();
1424
+ this.connectedUsers = [];
1425
+
1426
+ if (this.tracker) {
1427
+ this.tracker.disconnect();
1428
+ this.tracker = null;
1429
+ }
1430
+
1431
+ this.dataChannel = null;
1432
+ this.currentRoomPeerId = null;
1433
+
1434
+ this.emit('disconnected');
1435
+ }
1436
+
1437
+ /**
1438
+ * Update method (for GUI compatibility)
1439
+ */
1440
+ update(deltaTime) {
1441
+ // P2P doesn't need active polling
1442
+ }
1443
+
1444
+ /**
1445
+ * Check if connected to P2P network
1446
+ */
1447
+ isConnected() {
1448
+ return this.tracker !== null;
1449
+ }
1450
+
1451
+ /**
1452
+ * Check if in a room
1453
+ */
1454
+ isInRoom() {
1455
+ return this.currentRoomPeerId !== null;
1456
+ }
1457
+
1458
+ /**
1459
+ * Check if current user is host
1460
+ */
1461
+ isCurrentUserHost() {
1462
+ return this.isHost;
1463
+ }
1464
+
1465
+ /**
1466
+ * Set username
1467
+ */
1468
+ setUsername(name) {
1469
+ if (!name || name.trim() === '' || name.length < 2) {
1470
+ return Promise.reject(new Error('Username must be at least 2 characters long'));
1471
+ }
1472
+
1473
+ if (!/^[a-zA-Z0-9_'-]+$/.test(name)) {
1474
+ return Promise.reject(new Error('Username can only contain letters, numbers, underscores, hyphens, and apostrophes'));
1475
+ }
1476
+
1477
+ const oldUsername = this.username;
1478
+ const oldDisplayName = this.connectedUsers.find(u => u.id === this.peerId)?.displayName;
1479
+
1480
+ this.username = name;
1481
+
1482
+ if (this.isInRoom()) {
1483
+ const selfIndex = this.connectedUsers.findIndex(u => u.id === this.peerId);
1484
+ if (selfIndex !== -1) {
1485
+ this.connectedUsers[selfIndex].username = name;
1486
+ // Regenerate display name with new username
1487
+ this.connectedUsers[selfIndex].displayName = this.generateUniqueDisplayName(name, this.peerId);
1488
+ }
1489
+ this.broadcastUserList();
1490
+ }
1491
+
1492
+ const newDisplayName = this.connectedUsers.find(u => u.id === this.peerId)?.displayName || name;
1493
+
1494
+ this.emit('usernameChanged', {
1495
+ oldUsername: oldUsername,
1496
+ newUsername: name,
1497
+ displayName: newDisplayName
1498
+ });
1499
+
1500
+ return Promise.resolve({
1501
+ oldUsername: oldUsername,
1502
+ newUsername: name,
1503
+ displayName: newDisplayName
1504
+ });
1505
+ }
1506
+
1507
+ /**
1508
+ * Get connected peer count (peers we've established connections to)
1509
+ */
1510
+ getConnectedPeerCount() {
1511
+ return this.peerConnections.size;
1512
+ }
1513
+
1514
+ /**
1515
+ * Get discovered peer count (total peers on tracker/DHT network)
1516
+ */
1517
+ getDiscoveredPeerCount() {
1518
+ return this.tracker ? this.tracker.getDiscoveredPeerCount() : 0;
1519
+ }
1520
+
1521
+ /**
1522
+ * Send message through game data channel
1523
+ */
1524
+ send(message) {
1525
+ if (!this.dataChannel || this.dataChannel.readyState !== 'open') {
1526
+ return false;
1527
+ }
1528
+
1529
+ try {
1530
+ this.dataChannel.send(JSON.stringify(message));
1531
+ return true;
1532
+ } catch (error) {
1533
+ this.log(`Error sending message: ${error.message}`, 'error');
1534
+ return false;
1535
+ }
1536
+ }
1537
+ }