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,623 @@
1
+ /**
2
+ * ActionNetTrackerClient - WebSocket Tracker for Peer Discovery
3
+ *
4
+ * Implements WebSocket tracker protocol for peer discovery via WebRTC offers/answers.
5
+ * Periodically announces with offers to discover peers, manages peer lifecycle.
6
+ */
7
+
8
+ class ActionNetTrackerClient {
9
+ constructor(trackerUrls, infohash, peerId, options = {}) {
10
+ // Accept either a single URL or array of URLs
11
+ this.trackerUrls = Array.isArray(trackerUrls) ? trackerUrls : [trackerUrls];
12
+ this.infohash = infohash;
13
+ this.peerId = peerId;
14
+ this.options = {
15
+ debug: options.debug || false,
16
+ numwant: options.numwant || 50,
17
+ announceInterval: options.announceInterval || 5000,
18
+ maxAnnounceInterval: options.maxAnnounceInterval || 120000,
19
+ backoffMultiplier: options.backoffMultiplier || 1.1,
20
+ ...options
21
+ };
22
+
23
+ this.trackers = new Map(); // trackerUrl -> { ws, announceInterval, currentInterval, announceCount }
24
+ this.discoveredPeerIds = new Set();
25
+ this.handlers = new Map();
26
+ this.messageCount = 0;
27
+ this.outgoingPeers = new Map(); // Track SimplePeer instances we created: offerId -> peer
28
+ this.pendingOffers = new Set(); // offerId of offers waiting for answers
29
+ this.connectedPeerIds = new Set(); // peerId of peers we've successfully connected to
30
+ this.discoveredCount = 0; // Total peers from tracker (seeders + leechers)
31
+ this.announceCount = 0; // Track total announces for backoff
32
+ }
33
+
34
+ /**
35
+ * Connect to all trackers and start announcing
36
+ * Continues even if some trackers fail to connect
37
+ */
38
+ async connect() {
39
+ const results = await Promise.allSettled(
40
+ this.trackerUrls.map(url => this._connectToTracker(url))
41
+ );
42
+
43
+ // Log any failed trackers but don't throw
44
+ results.forEach((result, index) => {
45
+ if (result.status === 'rejected') {
46
+ this.log(`Tracker connection failed: ${this.trackerUrls[index]} - ${result.reason.message}`, 'warn');
47
+ }
48
+ });
49
+
50
+ // Check if at least one tracker connected
51
+ if (this.trackers.size === 0) {
52
+ throw new Error('Failed to connect to any trackers');
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Connect to a single tracker
58
+ */
59
+ async _connectToTracker(trackerUrl) {
60
+ return new Promise((resolve, reject) => {
61
+ try {
62
+ const ws = new WebSocket(trackerUrl);
63
+ ws.binaryType = 'arraybuffer';
64
+
65
+ ws.onopen = async () => {
66
+ this.log(`Connected to tracker: ${trackerUrl}`);
67
+ try {
68
+ // Wait for first peer to be ready before announcing
69
+ await this._prepareFirstPeer();
70
+ this.log('First peer ready, starting announces');
71
+ this.startAnnouncing();
72
+ this.emit('ready');
73
+ } catch (error) {
74
+ this.log(`Error preparing first peer: ${error.message}`, 'error');
75
+ reject(error);
76
+ return;
77
+ }
78
+ resolve();
79
+ };
80
+
81
+ ws.onmessage = (evt) => {
82
+ this.messageCount++;
83
+ this.handleTrackerMessage(evt.data);
84
+ };
85
+
86
+ ws.onerror = (error) => {
87
+ const errorMsg = error?.message || String(error);
88
+ this.log(`Tracker error from ${trackerUrl}: ${errorMsg}`, 'error');
89
+ this.emit('error', new Error(errorMsg));
90
+ };
91
+
92
+ ws.onclose = () => {
93
+ this.log(`Disconnected from tracker: ${trackerUrl}`);
94
+ this.trackers.delete(trackerUrl);
95
+ if (this.trackers.size === 0) {
96
+ this.emit('close');
97
+ this.stop();
98
+ }
99
+ };
100
+
101
+ // Store tracker connection
102
+ this.trackers.set(trackerUrl, { ws, announceInterval: null, currentInterval: this.options.announceInterval, announceCount: 0 });
103
+
104
+ // Timeout for connection
105
+ setTimeout(() => {
106
+ if (ws.readyState !== WebSocket.OPEN) {
107
+ reject(new Error(`Tracker connection timeout: ${trackerUrl}`));
108
+ }
109
+ }, 5000);
110
+ } catch (error) {
111
+ this.log(`Failed to connect to ${trackerUrl}: ${error.message}`, 'error');
112
+ reject(error);
113
+ }
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Prepare first peer and wait for ICE to be ready
119
+ */
120
+ async _prepareFirstPeer() {
121
+ return new Promise((resolve, reject) => {
122
+ const offerId = this.generateOfferId();
123
+ const peer = new ActionNetPeer({
124
+ initiator: true,
125
+ trickle: false,
126
+ iceServers: this.options.iceServers || [
127
+ { urls: 'stun:stun.l.google.com:19302' }
128
+ ]
129
+ });
130
+
131
+ let resolved = false;
132
+
133
+ peer.on('signal', (data) => {
134
+ if (!resolved && data.type === 'offer') {
135
+ resolved = true;
136
+ // Store for later use
137
+ this.outgoingPeers.set(offerId, peer);
138
+
139
+ // Set timeout for this peer
140
+ const timeout = setTimeout(() => {
141
+ if (this.outgoingPeers.has(offerId)) {
142
+ this.outgoingPeers.delete(offerId);
143
+ if (!peer.destroyed) peer.destroy();
144
+ }
145
+ }, 50 * 1000);
146
+
147
+ peer._offerTimeout = timeout;
148
+ resolve();
149
+ }
150
+ });
151
+
152
+ peer.on('error', (err) => {
153
+ if (!resolved) {
154
+ resolved = true;
155
+ if (!peer.destroyed) peer.destroy();
156
+ reject(err);
157
+ }
158
+ });
159
+
160
+ // Timeout if peer takes too long
161
+ setTimeout(() => {
162
+ if (!resolved) {
163
+ resolved = true;
164
+ if (!peer.destroyed) peer.destroy();
165
+ reject(new Error('First peer ICE gathering timeout'));
166
+ }
167
+ }, 10000);
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Generate SDP offer using custom Peer class and store the peer
173
+ * Returns { offer, offerId, peer }
174
+ */
175
+ async generateOffer(offerId) {
176
+ return new Promise((resolve) => {
177
+ const peer = new ActionNetPeer({
178
+ initiator: true,
179
+ trickle: false,
180
+ localPeerId: this.peerId,
181
+ remotePeerId: 'tracker',
182
+ iceServers: this.options.iceServers || [
183
+ { urls: 'stun:stun.l.google.com:19302' }
184
+ ]
185
+ });
186
+
187
+ let offerGenerated = false;
188
+
189
+ peer.on('signal', (data) => {
190
+ if (!offerGenerated && data.type === 'offer') {
191
+ offerGenerated = true;
192
+ // Store the peer for later use when answer arrives
193
+ this.outgoingPeers.set(offerId, peer);
194
+ this.pendingOffers.add(offerId); // Mark as pending
195
+
196
+ // Set a timeout to clean up if answer never comes (2 announce cycles)
197
+ const timeout = setTimeout(() => {
198
+ if (this.outgoingPeers.has(offerId)) {
199
+ this.outgoingPeers.delete(offerId);
200
+ this.pendingOffers.delete(offerId);
201
+ if (!peer.destroyed) peer.destroy();
202
+ }
203
+ }, this.options.announceInterval * 2);
204
+
205
+ // Attach timeout to peer so we can clear it later
206
+ peer._offerTimeout = timeout;
207
+
208
+ resolve({ offer: data, offerId, peer });
209
+ }
210
+ });
211
+
212
+ peer.on('error', (err) => {
213
+ console.warn('Error generating offer:', err);
214
+ if (!peer.destroyed) peer.destroy();
215
+ if (this.outgoingPeers.has(offerId)) {
216
+ this.outgoingPeers.delete(offerId);
217
+ }
218
+ resolve(null);
219
+ });
220
+ });
221
+ }
222
+
223
+ /**
224
+ * Start periodic announcements with offers
225
+ */
226
+ startAnnouncing() {
227
+ // Announce immediately with offers (first peer is ready)
228
+ this.announceWithOffers();
229
+
230
+ // Then announce with offers at specified interval with backoff to all trackers
231
+ for (const [trackerUrl, trackerData] of this.trackers) {
232
+ if (trackerData.announceInterval) clearInterval(trackerData.announceInterval);
233
+
234
+ const scheduleNextAnnounce = () => {
235
+ trackerData.announceInterval = setTimeout(() => {
236
+ if (trackerData.ws && trackerData.ws.readyState === WebSocket.OPEN) {
237
+ trackerData.announceCount++;
238
+ this.announceWithOffers(trackerData.ws);
239
+
240
+ // Increase interval by backoff multiplier, cap at maxAnnounceInterval
241
+ trackerData.currentInterval = Math.min(
242
+ trackerData.currentInterval * this.options.backoffMultiplier,
243
+ this.options.maxAnnounceInterval
244
+ );
245
+
246
+ // Schedule next announce with new interval
247
+ scheduleNextAnnounce();
248
+ }
249
+ }, trackerData.currentInterval);
250
+ };
251
+
252
+ scheduleNextAnnounce();
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Generate offers and announce to all trackers
258
+ */
259
+ async announceWithOffers(targetWs = null) {
260
+ try {
261
+ // Don't generate new offers if we have pending unanswered ones
262
+ if (this.pendingOffers.size > 0) {
263
+ this.announce([], targetWs); // Just announce without new offers
264
+ return;
265
+ }
266
+
267
+ // Generate 1 offer
268
+ const offerId = this.generateOfferId();
269
+ const result = await this.generateOffer(offerId);
270
+
271
+ const offers = result && result.offer ? [{
272
+ offer_id: offerId,
273
+ offer: result.offer
274
+ }] : [];
275
+
276
+ this.announce(offers, targetWs);
277
+ } catch (error) {
278
+ this.log(`Error generating offer: ${error.message}`, 'error');
279
+ this.announce([], targetWs); // Announce without offers
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Generate random offer ID (20 bytes hex)
285
+ */
286
+ generateOfferId() {
287
+ const bytes = new Uint8Array(20);
288
+ crypto.getRandomValues(bytes);
289
+ return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
290
+ }
291
+
292
+ /**
293
+ * Send announce request to tracker(s) with offers
294
+ */
295
+ announce(offers = [], targetWs = null) {
296
+ // Build announce message
297
+ const request = {
298
+ action: 'announce',
299
+ info_hash: this.infohash,
300
+ peer_id: this.peerId,
301
+ numwant: this.options.numwant
302
+ };
303
+
304
+ // Add offers if we have them
305
+ if (offers.length > 0) {
306
+ request.offers = offers;
307
+ }
308
+
309
+ const msgStr = JSON.stringify(request);
310
+ console.log('[TrackerClient] Announcing:', msgStr);
311
+ this.log(`Announcing with ${offers.length} offers`);
312
+
313
+ // Send to specific tracker or all
314
+ if (targetWs) {
315
+ if (targetWs.readyState === WebSocket.OPEN) {
316
+ targetWs.send(msgStr);
317
+ }
318
+ } else {
319
+ // Announce to all connected trackers
320
+ for (const [, trackerData] of this.trackers) {
321
+ if (trackerData.ws && trackerData.ws.readyState === WebSocket.OPEN) {
322
+ trackerData.ws.send(msgStr);
323
+ }
324
+ }
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Handle tracker response (JSON format)
330
+ */
331
+ handleTrackerMessage(data) {
332
+ try {
333
+ let message;
334
+
335
+ if (data instanceof ArrayBuffer) {
336
+ const str = new TextDecoder().decode(data);
337
+ message = JSON.parse(str);
338
+ } else {
339
+ const str = typeof data === 'string' ? data : new TextDecoder().decode(data);
340
+ message = JSON.parse(str);
341
+ }
342
+
343
+ console.log(`[TrackerClient] MESSAGE #${this.messageCount}:`, message);
344
+
345
+ // Check for peer offer/answer FIRST before general announce response
346
+ // (tracker sends all of these with action: 'announce')
347
+ if (message.offer && message.peer_id) {
348
+ // Tracker is relaying a peer's offer
349
+ this.handlePeerOffer(message);
350
+ } else if (message.answer && message.peer_id) {
351
+ // Peer is answering our offer
352
+ this.handlePeerAnswer(message);
353
+ } else if (message['failure reason']) {
354
+ // Tracker sent a failure response
355
+ this.log(`Tracker failure: ${message['failure reason']}`, 'warn');
356
+ console.log('[TrackerClient] Tracker failure:', message['failure reason']);
357
+ } else if (message.action === 'announce') {
358
+ // Regular announce response (with stats)
359
+ this.handleAnnounceResponse(message);
360
+ } else if (message.action === 'scrape') {
361
+ // Scrape response
362
+ this.emit('scrape', message);
363
+ } else {
364
+ console.log('[TrackerClient] Unknown message type:', Object.keys(message).join(', '));
365
+ }
366
+ } catch (error) {
367
+ this.log(`Failed to parse tracker message: ${error.message}`, 'error');
368
+ console.error('[TrackerClient] Raw data:', data);
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Handle announce response (ACK from tracker with stats)
374
+ */
375
+ handleAnnounceResponse(response) {
376
+ console.log('[TrackerClient] Announce response:', response);
377
+
378
+ const complete = response.complete || 0;
379
+ const incomplete = response.incomplete || 0;
380
+ this.discoveredCount = complete + incomplete;
381
+
382
+ this.log(`Tracker stats: ${complete} seeders, ${incomplete} leechers (${this.discoveredCount} total)`);
383
+
384
+ this.emit('update', {
385
+ complete: complete,
386
+ incomplete: incomplete
387
+ });
388
+ }
389
+
390
+ /**
391
+ * Handle offer from another peer (tracker is relaying their offer)
392
+ */
393
+ handlePeerOffer(message) {
394
+ const peerId = message.peer_id;
395
+ const offerId = message.offer_id;
396
+ const offer = message.offer;
397
+
398
+ console.log('[TrackerClient] Received offer from peer:', peerId);
399
+
400
+ // Skip if we already have a connection to this peer
401
+ if (this.connectedPeerIds.has(peerId)) {
402
+ console.log('[TrackerClient] Already connected to peer:', peerId, '- ignoring offer');
403
+ return;
404
+ }
405
+
406
+ // Create responder Peer to handle this offer
407
+ const peer = new ActionNetPeer({
408
+ initiator: false,
409
+ trickle: false,
410
+ localPeerId: this.peerId,
411
+ remotePeerId: peerId,
412
+ iceServers: this.options.iceServers || [
413
+ { urls: 'stun:stun.l.google.com:19302' }
414
+ ]
415
+ });
416
+
417
+ // Store peer so we can send answer back
418
+ this.outgoingPeers.set(offerId, {
419
+ peer: peer,
420
+ peerId: peerId,
421
+ offerId: offerId
422
+ });
423
+
424
+ // Listen for answer signal
425
+ peer.on('signal', (data) => {
426
+ if (data.type === 'answer') {
427
+ console.log('[TrackerClient] Sending answer to peer:', peerId);
428
+ this.sendAnswer(offerId, peerId, data);
429
+ }
430
+ });
431
+
432
+ // Emit peer's internal DataConnection when tracker signaling is complete
433
+ peer.once('connect', () => {
434
+ this.connectedPeerIds.add(peerId);
435
+
436
+ // Emit peer event
437
+ this.emit('peer', {
438
+ id: peerId,
439
+ peer: peer,
440
+ source: 'tracker'
441
+ });
442
+
443
+ // ActionNetPeer has an internal DataConnection
444
+ // Emit that connection directly (no extra wrapper needed)
445
+ this.emit('connection', peer.connection);
446
+ });
447
+
448
+ peer.on('error', (err) => {
449
+ console.warn('[TrackerClient] Responder peer error:', err.message);
450
+ this.outgoingPeers.delete(offerId);
451
+ this.connectedPeerIds.delete(peerId);
452
+ this.emit('peer-failed', {
453
+ id: peerId,
454
+ error: err
455
+ });
456
+ });
457
+
458
+ peer.on('close', () => {
459
+ this.outgoingPeers.delete(offerId);
460
+ this.connectedPeerIds.delete(peerId);
461
+ this.emit('peer-disconnected', {
462
+ id: peerId
463
+ });
464
+ });
465
+
466
+ // Signal the offer
467
+ peer.signal(offer);
468
+ }
469
+
470
+ /**
471
+ * Handle answer from peer (responding to our offer)
472
+ */
473
+ handlePeerAnswer(message) {
474
+ const peerId = message.peer_id;
475
+ const offerId = message.offer_id;
476
+ const answer = message.answer;
477
+
478
+ console.log('[TrackerClient] Received answer from peer:', peerId);
479
+
480
+ // Check if we have the SimplePeer for this offer
481
+ const peer = this.outgoingPeers.get(offerId);
482
+ if (peer && !peer.destroyed) {
483
+ // Mark offer as answered (no longer pending)
484
+ this.pendingOffers.delete(offerId);
485
+
486
+ // Now we know the real remote peer ID, update the connection
487
+ if (peer.connection) {
488
+ peer.connection.remotePeerId = peerId;
489
+ }
490
+
491
+ // Signal the answer to the peer
492
+ peer.signal(answer);
493
+
494
+ // Clear the timeout since we got a response
495
+ if (peer._offerTimeout) {
496
+ clearTimeout(peer._offerTimeout);
497
+ peer._offerTimeout = null;
498
+ }
499
+
500
+ // Emit peer's internal DataConnection when tracker signaling is complete
501
+ peer.once('connect', () => {
502
+ // Mark this peer ID as connected
503
+ this.connectedPeerIds.add(peerId);
504
+
505
+ // Emit peer event
506
+ this.emit('peer', {
507
+ id: peerId,
508
+ peer: peer,
509
+ source: 'tracker'
510
+ });
511
+
512
+ // ActionNetPeer has an internal DataConnection
513
+ // Emit that connection directly (no extra wrapper needed)
514
+ this.emit('connection', peer.connection);
515
+ });
516
+
517
+ peer.on('error', (err) => {
518
+ console.warn('[TrackerClient] Peer error:', err.message);
519
+ this.outgoingPeers.delete(offerId);
520
+ this.connectedPeerIds.delete(peerId);
521
+ this.emit('peer-failed', {
522
+ id: peerId,
523
+ error: err
524
+ });
525
+ });
526
+
527
+ peer.on('close', () => {
528
+ this.outgoingPeers.delete(offerId);
529
+ this.connectedPeerIds.delete(peerId);
530
+ this.emit('peer-disconnected', {
531
+ id: peerId
532
+ });
533
+ });
534
+ } else {
535
+ console.warn('[TrackerClient] Got answer for unknown offer:', offerId);
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Send answer back to tracker(s) (responding to a peer's offer)
541
+ */
542
+ sendAnswer(offerId, peerId, answer) {
543
+ const response = {
544
+ action: 'announce',
545
+ info_hash: this.infohash,
546
+ peer_id: this.peerId,
547
+ to_peer_id: peerId,
548
+ offer_id: offerId,
549
+ answer: answer
550
+ };
551
+
552
+ const msgStr = JSON.stringify(response);
553
+ console.log('[TrackerClient] Sending answer:', msgStr);
554
+
555
+ // Send to all connected trackers
556
+ for (const [, trackerData] of this.trackers) {
557
+ if (trackerData.ws && trackerData.ws.readyState === WebSocket.OPEN) {
558
+ trackerData.ws.send(msgStr);
559
+ }
560
+ }
561
+ }
562
+
563
+ /**
564
+ * Stop announcing to all trackers
565
+ */
566
+ stop() {
567
+ for (const [, trackerData] of this.trackers) {
568
+ if (trackerData.announceInterval) {
569
+ clearInterval(trackerData.announceInterval);
570
+ trackerData.announceInterval = null;
571
+ }
572
+ }
573
+ }
574
+
575
+ /**
576
+ * Disconnect from all trackers
577
+ */
578
+ disconnect() {
579
+ this.stop();
580
+ for (const [, trackerData] of this.trackers) {
581
+ if (trackerData.ws) {
582
+ trackerData.ws.close();
583
+ }
584
+ }
585
+ this.trackers.clear();
586
+ }
587
+
588
+ /**
589
+ * Get discovered peer count (seeders + leechers from tracker)
590
+ */
591
+ getDiscoveredPeerCount() {
592
+ return this.discoveredCount;
593
+ }
594
+
595
+ /**
596
+ * Event handlers
597
+ */
598
+ on(event, handler) {
599
+ if (!this.handlers.has(event)) {
600
+ this.handlers.set(event, []);
601
+ }
602
+ this.handlers.get(event).push(handler);
603
+ }
604
+
605
+ emit(event, ...args) {
606
+ if (!this.handlers.has(event)) return;
607
+ for (const handler of this.handlers.get(event)) {
608
+ try {
609
+ handler(...args);
610
+ } catch (e) {
611
+ this.log(`Error in ${event} handler: ${e.message}`, 'error');
612
+ }
613
+ }
614
+ }
615
+
616
+ /**
617
+ * Logging
618
+ */
619
+ log(msg, level = 'info') {
620
+ if (!this.options.debug) return;
621
+ console.log(`[TrackerClient] ${msg}`);
622
+ }
623
+ }