dankgrinder 4.0.0 → 4.1.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.
@@ -0,0 +1,615 @@
1
+ /**
2
+ * Fish command handler (Dank Memer v2 fishing).
3
+ *
4
+ * The fishing minigame works as follows:
5
+ * 1. "pls fish catch" → fishing dashboard with "Go Fishing" button
6
+ * 2. Click "Go Fishing" → 3x3 grid image + 9 "Catch" buttons
7
+ * 3. Grid image shows mines (dark patches) and safe water
8
+ * 4. Click safe "Catch" buttons to catch fish, avoid mines
9
+ * 5. Result screen with "Fish Again" / "Go Back" buttons
10
+ * 6. Click "Fish Again" to loop without re-sending the command
11
+ *
12
+ * Image analysis uses sharp to detect dark pixel clusters (mines)
13
+ * in each grid cell. Safe cells have ~0% dark pixels; mine cells
14
+ * have >6% dark pixels with lower average brightness.
15
+ */
16
+
17
+ const {
18
+ LOG, c, getFullText, parseCoins, getAllButtons, safeClickButton,
19
+ logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay,
20
+ } = require('./utils');
21
+ const { downloadImage, extractImageUrl, findSafeCells } = require('./fishVision');
22
+
23
+ const { DANK_MEMER_ID } = require('./utils');
24
+
25
+ async function refetchMsg(channel, msgId) {
26
+ try { return await channel.messages.fetch(msgId); } catch { return null; }
27
+ }
28
+
29
+ /**
30
+ * Sell all fish from buckets via "pls fish buckets" → "Sell All Fish" button.
31
+ * The sell flow has a confirmation step with "Sell for X Coins" / "Sell for Y Tokens".
32
+ * Clicking "Sell All Fish" edits the message to show confirmation, then we click the token sell.
33
+ * @param {string} [sellFor='tokens'] - 'tokens' or 'coins'
34
+ * @returns {Promise<boolean>} true if sold successfully
35
+ */
36
+ async function sellAllFish({ channel, waitForDankMemer, sellFor = 'tokens' }) {
37
+ LOG.info('[fish] Selling all fish from buckets...');
38
+ await channel.send('pls fish buckets');
39
+ let msg = await waitForDankMemer(8000);
40
+ if (!msg) { LOG.warn('[fish] No response to pls fish buckets'); return false; }
41
+ let fresh = await refetchMsg(channel, msg.id);
42
+ if (fresh) msg = fresh;
43
+
44
+ const sellAllBtn = getAllButtons(msg).find(b =>
45
+ !b.disabled && (b.customId || '').includes('fish-buckets-sell-all')
46
+ );
47
+ if (!sellAllBtn) {
48
+ LOG.info('[fish] No "Sell All Fish" button (bucket may be empty)');
49
+ return false;
50
+ }
51
+
52
+ // Click "Sell All Fish" — this edits the message to show confirmation
53
+ await humanDelay(80, 200);
54
+ try {
55
+ await safeClickButton(msg, sellAllBtn);
56
+ } catch (e) {
57
+ LOG.error(`[fish] Sell All Fish click error: ${e.message}`);
58
+ return false;
59
+ }
60
+
61
+ // Wait for confirmation screen (message update with "Sell for X Coins/Tokens")
62
+ await sleep(1500);
63
+ let confirmMsg = null;
64
+ // Check for update event first
65
+ for (let i = 0; i < 4; i++) {
66
+ const upd = await waitForDankMemer(3000);
67
+ if (!upd) break;
68
+ const btns = getAllButtons(upd);
69
+ if (btns.some(b => (b.label || '').toLowerCase().includes('sell for'))) {
70
+ confirmMsg = upd;
71
+ break;
72
+ }
73
+ }
74
+ // Fallback: refetch the message
75
+ if (!confirmMsg) {
76
+ confirmMsg = await refetchMsg(channel, msg.id);
77
+ }
78
+ if (!confirmMsg) { LOG.warn('[fish] No sell confirmation'); return false; }
79
+
80
+ // Find the sell confirmation button
81
+ const confirmBtns = getAllButtons(confirmMsg);
82
+ let sellBtn;
83
+ if (sellFor === 'tokens') {
84
+ // Prefer "Sell for X Tokens" button
85
+ sellBtn = confirmBtns.find(b =>
86
+ !b.disabled && (b.label || '').toLowerCase().includes('sell for') && (b.label || '').toLowerCase().includes('token')
87
+ );
88
+ }
89
+ if (!sellBtn) {
90
+ // Fallback: "Sell for X Coins"
91
+ sellBtn = confirmBtns.find(b =>
92
+ !b.disabled && (b.label || '').toLowerCase().includes('sell for') && (b.label || '').toLowerCase().includes('coin')
93
+ );
94
+ }
95
+ if (!sellBtn) {
96
+ // Last fallback: any green/primary sell button
97
+ sellBtn = confirmBtns.find(b =>
98
+ !b.disabled && (b.label || '').toLowerCase().includes('sell for')
99
+ );
100
+ }
101
+
102
+ if (!sellBtn) {
103
+ LOG.warn('[fish] No sell confirmation button found');
104
+ LOG.debug(`[fish] Buttons: ${confirmBtns.map(b => `"${b.label}"`).join(', ')}`);
105
+ return false;
106
+ }
107
+
108
+ LOG.info(`[fish] Confirming: "${sellBtn.label}"`);
109
+ await humanDelay(80, 200);
110
+ try {
111
+ await safeClickButton(confirmMsg, sellBtn);
112
+ await sleep(1500);
113
+ // Drain events from sell
114
+ for (let d = 0; d < 3; d++) {
115
+ const ev = await waitForDankMemer(2000);
116
+ if (!ev) break;
117
+ }
118
+ LOG.success('[fish] Sold all fish from buckets');
119
+ return true;
120
+ } catch (e) {
121
+ LOG.error(`[fish] Sell confirmation error: ${e.message}`);
122
+ return false;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Parse cooldown timestamp from text.
128
+ */
129
+ function parseFishCooldown(text) {
130
+ const m = text.match(/<t:(\d+):R>/);
131
+ if (m) {
132
+ const diff = parseInt(m[1]) - Math.floor(Date.now() / 1000);
133
+ return diff > 0 ? diff : null;
134
+ }
135
+ return null;
136
+ }
137
+
138
+ /**
139
+ * Wait for the Catch grid OR a direct result (e.g. "nothing to catch").
140
+ * After clicking Go Fishing / Fish Again, the animation goes:
141
+ * loading text (0 buttons) → EITHER Catch grid (9 btns) OR direct result (2 btns)
142
+ * Returns { type: 'grid', msg } or { type: 'result', msg } or null.
143
+ */
144
+ async function waitForFishingOutcome({ channel, msgId, waitForDankMemer }) {
145
+ const hasCatch = (msg) => getAllButtons(msg).some(b => (b.label || '').toLowerCase() === 'catch');
146
+ const hasResult = (msg) => {
147
+ const btns = getAllButtons(msg);
148
+ return btns.some(b => (b.customId || '').includes('fish-catch-back')) ||
149
+ btns.some(b => (b.label || '').toLowerCase() === 'fish again');
150
+ };
151
+
152
+ for (let i = 0; i < 10; i++) {
153
+ const upd = await waitForDankMemer(2000);
154
+ if (upd) {
155
+ if (hasCatch(upd)) return { type: 'grid', msg: upd };
156
+ if (hasResult(upd)) return { type: 'result', msg: upd };
157
+ }
158
+ // Also check via refetch each iteration
159
+ const rf = await refetchMsg(channel, msgId);
160
+ if (rf) {
161
+ if (hasCatch(rf)) return { type: 'grid', msg: rf };
162
+ if (hasResult(rf)) return { type: 'result', msg: rf };
163
+ }
164
+ }
165
+ return null;
166
+ }
167
+
168
+ /**
169
+ * Wait for the result screen (Go Back / Fish Again buttons).
170
+ */
171
+ async function waitForResult({ channel, msgId, waitForDankMemer }) {
172
+ for (let i = 0; i < 6; i++) {
173
+ const upd = await waitForDankMemer(5000);
174
+ if (!upd) {
175
+ const rf = await refetchMsg(channel, msgId);
176
+ if (rf) {
177
+ const btns = getAllButtons(rf);
178
+ const hasResult = btns.some(b => (b.customId || '').includes('fish-catch-back') || (b.label || '').toLowerCase() === 'fish again');
179
+ if (hasResult) return rf;
180
+ }
181
+ await sleep(2000);
182
+ return await refetchMsg(channel, msgId);
183
+ }
184
+ const btns = getAllButtons(upd);
185
+ const hasResult = btns.some(b => (b.customId || '').includes('fish-catch-back') || (b.label || '').toLowerCase() === 'fish again');
186
+ if (hasResult) return upd;
187
+ }
188
+ return await refetchMsg(channel, msgId);
189
+ }
190
+
191
+ /**
192
+ * Parse the result of a fishing round.
193
+ */
194
+ function parseFishResult(text) {
195
+ const tl = text.toLowerCase();
196
+ const cd = parseFishCooldown(text);
197
+ const headerMatch = text.match(/###\s*(.+?)(?:\s*-#|$)/);
198
+ const header = headerMatch ? headerMatch[1].trim() : '';
199
+
200
+ if (tl.includes('nothing to catch') || tl.includes('no fish')) {
201
+ return { outcome: 'nothing', header, cd };
202
+ }
203
+ if (tl.includes('mine') || tl.includes('triggered')) {
204
+ return { outcome: 'mine', header, cd };
205
+ }
206
+ if (tl.includes('got away') || tl.includes('escaped')) {
207
+ return { outcome: 'got_away', header, cd };
208
+ }
209
+ if (tl.includes('caught')) {
210
+ const fishMatch = text.match(/caught\s+(?:a\s+)?(.+?)(?:\s*[!.]|$)/i);
211
+ return { outcome: 'caught', fish: fishMatch ? fishMatch[1].substring(0, 40) : 'a fish', header, cd };
212
+ }
213
+ return { outcome: 'unknown', header: header || text.substring(0, 60), cd };
214
+ }
215
+
216
+ /**
217
+ * Single fishing round: analyze grid, click safe cells, return result.
218
+ * @param {object} gameMsg - Message with MEDIA_GALLERY + Catch buttons
219
+ * @param {object} channel
220
+ * @param {string} msgId
221
+ * @param {function} waitForDankMemer
222
+ * @returns {Promise<{outcome, header, cd, clickedCount}>}
223
+ */
224
+ async function playFishRound({ gameMsg, channel, msgId, waitForDankMemer }) {
225
+ // Extract and analyze the grid image
226
+ const imgUrl = extractImageUrl(gameMsg.components);
227
+ if (!imgUrl) {
228
+ LOG.warn('[fish] No grid image URL');
229
+ return { outcome: 'no_image', header: 'no image', cd: null, clickedCount: 0 };
230
+ }
231
+
232
+ let target = null;
233
+ try {
234
+ const imgBuf = await downloadImage(imgUrl);
235
+ const analysis = await findSafeCells(imgBuf);
236
+ const { fishCell, mineCells, allCells } = analysis;
237
+
238
+ // Log grid classification
239
+ const cellMap = allCells.map(c => `[${c.col},${c.row}]=${c.type}${c.type !== 'empty' ? `(fill=${c.fillRatio})` : ''}`);
240
+ LOG.info(`[fish] Grid: ${cellMap.filter(s => s.includes('fish')).length} fish, ${mineCells.length} mines`);
241
+ LOG.debug(`[fish] ${cellMap.join(' ')}`);
242
+
243
+ // Target the fish cell specifically for best catch rate
244
+ if (fishCell) {
245
+ target = { col: fishCell.col, row: fishCell.row };
246
+ LOG.info(`[fish] Fish detected at [${target.col},${target.row}]`);
247
+ } else {
248
+ // Fallback: pick a safe (non-mine) cell
249
+ const safe = allCells.filter(c => c.type !== 'mine');
250
+ target = safe.length > 0 ? { col: safe[0].col, row: safe[0].row } : null;
251
+ LOG.warn('[fish] No fish detected, picking safe cell');
252
+ }
253
+ } catch (e) {
254
+ LOG.error(`[fish] Image analysis failed: ${e.message}`);
255
+ // Fall back to clicking center cell
256
+ target = { col: 1, row: 1 };
257
+ }
258
+
259
+ if (!target) {
260
+ LOG.warn('[fish] No target cell found!');
261
+ return { outcome: 'no_target', header: 'no target', cd: null, clickedCount: 0 };
262
+ }
263
+
264
+ const catchBtns = getAllButtons(gameMsg).filter(b => (b.label || '').toLowerCase() === 'catch' && !b.disabled);
265
+ const btn = catchBtns.find(b => {
266
+ const m = (b.customId || '').match(/:(\d+):(\d+)$/);
267
+ return m && parseInt(m[1]) === target.col && parseInt(m[2]) === target.row;
268
+ });
269
+
270
+ if (!btn) {
271
+ LOG.warn(`[fish] Catch button [${target.col},${target.row}] not found`);
272
+ return { outcome: 'no_button', header: 'button not found', cd: null, clickedCount: 0 };
273
+ }
274
+
275
+ LOG.info(`[fish] Clicking safe cell [${target.col},${target.row}]`);
276
+ await humanDelay(80, 200);
277
+ try {
278
+ await safeClickButton(gameMsg, btn);
279
+ } catch (e) {
280
+ LOG.error(`[fish] Catch click error: ${e.message}`);
281
+ return { outcome: 'click_error', header: e.message, cd: null, clickedCount: 0 };
282
+ }
283
+
284
+ // Wait for result screen
285
+ const resultMsg = await waitForResult({ channel, msgId, waitForDankMemer });
286
+ if (!resultMsg) return { outcome: 'no_result', header: 'no result', cd: null, clickedCount: 1 };
287
+
288
+ logMsg(resultMsg, 'fish-result');
289
+ const text = getFullText(resultMsg);
290
+ const result = parseFishResult(text);
291
+ result.clickedCount = 1;
292
+ return result;
293
+ }
294
+
295
+ /**
296
+ * Main fish handler — single round (for grinder integration).
297
+ * @param {object} opts
298
+ * @param {object} opts.channel
299
+ * @param {function} opts.waitForDankMemer
300
+ * @returns {Promise<{result: string, coins: number, nextCooldownSec?: number}>}
301
+ */
302
+ async function runFish({ channel, waitForDankMemer }) {
303
+ LOG.cmd(`${c.white}${c.bold}pls fish catch${c.reset}`);
304
+
305
+ await channel.send('pls fish catch');
306
+ let response = await waitForDankMemer(10000);
307
+ if (!response) return { result: 'no response', coins: 0 };
308
+
309
+ if (isHoldTight(response)) {
310
+ const reason = getHoldTightReason(response);
311
+ LOG.warn(`[fish] Hold Tight${reason ? ` (reason: /${reason})` : ''} — waiting 30s`);
312
+ await sleep(30000);
313
+ return { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason };
314
+ }
315
+
316
+ logMsg(response, 'fish');
317
+ const text = getFullText(response);
318
+
319
+ // Check for cooldown
320
+ const cd = parseFishCooldown(text);
321
+ if (cd && (text.toLowerCase().includes('fish again') || text.toLowerCase().includes('can fish'))) {
322
+ LOG.info(`[fish] On cooldown — ${cd}s`);
323
+ return { result: `fish cooldown (${cd}s)`, coins: 0, nextCooldownSec: cd };
324
+ }
325
+
326
+ // Re-fetch for hydrated CV2 components
327
+ const msgId = response.id;
328
+ const fresh = await refetchMsg(channel, msgId);
329
+ if (fresh) response = fresh;
330
+
331
+ // Find "Go Fishing" button
332
+ const goFishBtn = getAllButtons(response).find(b =>
333
+ !b.disabled && ((b.customId || '').includes('fish-catch-fish') ||
334
+ (b.label || '').toLowerCase().includes('go fishing'))
335
+ );
336
+
337
+ if (!goFishBtn) {
338
+ LOG.warn('[fish] No "Go Fishing" button');
339
+ return { result: text.substring(0, 60) || 'no button', coins: 0 };
340
+ }
341
+
342
+ // Click "Go Fishing"
343
+ LOG.info('[fish] Clicking "Go Fishing"...');
344
+ await humanDelay();
345
+ try { await safeClickButton(response, goFishBtn); } catch (e) {
346
+ LOG.error(`[fish] Go Fishing click error: ${e.message}`);
347
+ return { result: 'click error', coins: 0 };
348
+ }
349
+
350
+ // Wait for Catch grid OR direct result
351
+ const outcome = await waitForFishingOutcome({ channel, msgId, waitForDankMemer });
352
+ if (!outcome) {
353
+ LOG.warn('[fish] No fishing outcome');
354
+ return { result: 'no outcome', coins: 0 };
355
+ }
356
+
357
+ let roundResult;
358
+ if (outcome.type === 'grid') {
359
+ roundResult = await playFishRound({ gameMsg: outcome.msg, channel, msgId, waitForDankMemer });
360
+ } else {
361
+ logMsg(outcome.msg, 'fish-result');
362
+ const resultText = getFullText(outcome.msg);
363
+ roundResult = parseFishResult(resultText);
364
+ roundResult.clickedCount = 0;
365
+ }
366
+ return formatRoundResult(roundResult);
367
+ }
368
+
369
+ /**
370
+ * Fish-only grinding loop — keeps fishing without re-sending the command.
371
+ * Clicks "Fish Again" after each round. Stops when bucket is full or cooldown.
372
+ * @param {object} opts
373
+ * @param {object} opts.channel
374
+ * @param {function} opts.waitForDankMemer
375
+ * @param {number} [opts.maxRounds=50] - Max rounds per session
376
+ * @param {function} [opts.onRound] - Callback per round: (roundNum, result) => void
377
+ * @param {AbortSignal} [opts.signal] - Signal to stop early
378
+ * @returns {Promise<{totalRounds, totalCaught, totalMines, results: Array}>}
379
+ */
380
+ async function runFishLoop({ channel, waitForDankMemer, maxRounds = 50, onRound, signal }) {
381
+ LOG.cmd(`${c.white}${c.bold}pls fish catch${c.reset} (loop mode, max ${maxRounds} rounds)`);
382
+
383
+ await channel.send('pls fish catch');
384
+ let response = await waitForDankMemer(10000);
385
+ if (!response) return { totalRounds: 0, totalCaught: 0, totalMines: 0, results: [] };
386
+
387
+ if (isHoldTight(response)) {
388
+ const reason = getHoldTightReason(response);
389
+ LOG.warn(`[fish] Hold Tight — waiting 30s`);
390
+ await sleep(30000);
391
+ return { totalRounds: 0, totalCaught: 0, totalMines: 0, results: [] };
392
+ }
393
+
394
+ // Refetch for hydrated components
395
+ let msgId = response.id;
396
+ const fresh = await refetchMsg(channel, msgId);
397
+ if (fresh) response = fresh;
398
+
399
+ // Check cooldown — wait for it instead of returning
400
+ const text = getFullText(response);
401
+ const cd = parseFishCooldown(text);
402
+ if (cd && cd > 0) {
403
+ LOG.info(`[fish] On cooldown — ${cd}s, waiting...`);
404
+ await sleep(cd * 1000 + 1000);
405
+ response = await refetchMsg(channel, msgId);
406
+ if (!response) return { totalRounds: 0, totalCaught: 0, totalMines: 0, results: [] };
407
+ }
408
+
409
+ // Click "Go Fishing" to start
410
+ const goFishBtn = getAllButtons(response).find(b =>
411
+ !b.disabled && ((b.customId || '').includes('fish-catch-fish') ||
412
+ (b.label || '').toLowerCase().includes('go fishing'))
413
+ );
414
+ if (!goFishBtn) {
415
+ LOG.warn('[fish] No Go Fishing button on dashboard');
416
+ return { totalRounds: 0, totalCaught: 0, totalMines: 0, results: [] };
417
+ }
418
+
419
+ // Sell all fish from buckets before starting to ensure space
420
+ LOG.info('[fish] Clearing bucket before starting...');
421
+ await sellAllFish({ channel, waitForDankMemer });
422
+
423
+ // Re-send command to get fresh dashboard after sell
424
+ await channel.send('pls fish catch');
425
+ let newDash = await waitForDankMemer(8000);
426
+ if (newDash) {
427
+ let freshDash = await refetchMsg(channel, newDash.id);
428
+ if (freshDash) newDash = freshDash;
429
+ msgId = newDash.id;
430
+ response = newDash;
431
+
432
+ // Wait for any cooldown on fresh dashboard
433
+ const newCd = parseFishCooldown(getFullText(response));
434
+ if (newCd && newCd > 0) {
435
+ LOG.info(`[fish] Cooldown after sell — ${newCd}s, waiting...`);
436
+ await sleep(newCd * 1000 + 1000);
437
+ response = await refetchMsg(channel, msgId);
438
+ if (!response) return { totalRounds: 0, totalCaught: 0, totalMines: 0, results: [] };
439
+ }
440
+ }
441
+
442
+ LOG.info('[fish] Starting fishing loop...');
443
+ await humanDelay();
444
+ const goFishBtn2 = getAllButtons(response).find(b =>
445
+ !b.disabled && ((b.customId || '').includes('fish-catch-fish') ||
446
+ (b.label || '').toLowerCase().includes('go fishing'))
447
+ );
448
+ if (!goFishBtn2) {
449
+ LOG.warn('[fish] No Go Fishing button after sell');
450
+ return { totalRounds: 0, totalCaught: 0, totalMines: 0, results: [] };
451
+ }
452
+ try { await safeClickButton(response, goFishBtn2); } catch (e) {
453
+ LOG.error(`[fish] Go Fishing click error: ${e.message}`);
454
+ return { totalRounds: 0, totalCaught: 0, totalMines: 0, results: [] };
455
+ }
456
+
457
+ const results = [];
458
+ let totalCaught = 0, totalMines = 0;
459
+
460
+ for (let round = 0; round < maxRounds; round++) {
461
+ if (signal?.aborted) break;
462
+
463
+ // Wait for Catch grid OR direct result (e.g. "nothing to catch")
464
+ const outcome = await waitForFishingOutcome({ channel, msgId, waitForDankMemer });
465
+ if (!outcome) {
466
+ LOG.warn(`[fish] Round ${round + 1}: no fishing outcome`);
467
+ break;
468
+ }
469
+
470
+ let roundResult;
471
+ if (outcome.type === 'grid') {
472
+ // Normal: grid appeared, play the round
473
+ roundResult = await playFishRound({ gameMsg: outcome.msg, channel, msgId, waitForDankMemer });
474
+ } else {
475
+ // Direct result without grid (e.g. "nothing to catch", instant mine)
476
+ logMsg(outcome.msg, 'fish-result');
477
+ const text = getFullText(outcome.msg);
478
+ roundResult = parseFishResult(text);
479
+ roundResult.clickedCount = 0;
480
+ LOG.debug(`[fish] Direct result: ${roundResult.outcome}`);
481
+ }
482
+ results.push(roundResult);
483
+
484
+ if (roundResult.outcome === 'caught') totalCaught++;
485
+ if (roundResult.outcome === 'mine') totalMines++;
486
+
487
+ const formatted = formatRoundResult(roundResult);
488
+ LOG.info(`[fish] Round ${round + 1}: ${formatted.result}`);
489
+ if (onRound) onRound(round + 1, roundResult);
490
+
491
+ // Result screen has: Go Back, Open Buckets, Sell Creature (coins/tokens),
492
+ // and "Fish Again" (SECTION accessory button).
493
+ // NOTE: Sell Creature from result screen triggers EPHEMERAL confirmation
494
+ // which selfbots can't interact with. Use "pls fish buckets" → "Sell All Fish"
495
+ // instead (every N rounds or when bucket is full).
496
+
497
+ await sleep(500);
498
+
499
+ // Check if bucket is full — sell all from buckets to free space
500
+ const resultMsg = await refetchMsg(channel, msgId);
501
+ if (resultMsg) {
502
+ const resultText = getFullText(resultMsg).toLowerCase();
503
+ if (resultText.includes('no more bucket space')) {
504
+ LOG.warn('[fish] Bucket full — selling all from buckets');
505
+ await sellAllFish({ channel, waitForDankMemer });
506
+ }
507
+ }
508
+
509
+ // Wait for cooldown
510
+ if (roundResult.cd && roundResult.cd > 0) {
511
+ LOG.info(`[fish] Cooldown — ${roundResult.cd}s, waiting...`);
512
+ await sleep(roundResult.cd * 1000 + 1000);
513
+ }
514
+
515
+ // Click "Fish Again" — try multiple approaches
516
+ let fishAgainClicked = false;
517
+
518
+ // Drain stale events accumulated during cooldown/sell wait
519
+ for (let d = 0; d < 10; d++) {
520
+ const stale = await waitForDankMemer(300);
521
+ if (!stale) break;
522
+ }
523
+
524
+ // Approach 1: Refetch the catch result message and click Fish Again
525
+ for (let retry = 0; retry < 3 && !fishAgainClicked; retry++) {
526
+ const currentMsg = await refetchMsg(channel, msgId);
527
+ if (!currentMsg) break;
528
+ const fishAgainBtn = getAllButtons(currentMsg).find(b =>
529
+ !b.disabled && (b.customId || '').includes('fish-catch-fish')
530
+ );
531
+ if (fishAgainBtn) {
532
+ await humanDelay(80, 200);
533
+ try {
534
+ await safeClickButton(currentMsg, fishAgainBtn);
535
+ fishAgainClicked = true;
536
+ } catch (e) {
537
+ LOG.debug(`[fish] Fish Again attempt ${retry + 1} error: ${e.message}`);
538
+ await sleep(2000);
539
+ }
540
+ } else {
541
+ // Maybe still on CD — check and wait
542
+ const txt = getFullText(currentMsg);
543
+ const remainCd = parseFishCooldown(txt);
544
+ if (remainCd && remainCd > 0) {
545
+ LOG.debug(`[fish] Still on CD (${remainCd}s), waiting...`);
546
+ await sleep(remainCd * 1000 + 1000);
547
+ } else {
548
+ await sleep(2000);
549
+ }
550
+ }
551
+ }
552
+
553
+ // Approach 2: If Fish Again not found, re-send "pls fish catch" fresh
554
+ if (!fishAgainClicked) {
555
+ LOG.debug('[fish] Fish Again not found, re-sending pls fish catch');
556
+ await channel.send('pls fish catch');
557
+ let newResp = await waitForDankMemer(8000);
558
+ if (!newResp) { LOG.warn('[fish] No response to re-send'); break; }
559
+ let freshResp = await refetchMsg(channel, newResp.id);
560
+ if (freshResp) newResp = freshResp;
561
+ msgId = newResp.id;
562
+
563
+ // Wait for any remaining cooldown
564
+ const txt = getFullText(newResp);
565
+ const remainCd = parseFishCooldown(txt);
566
+ if (remainCd && remainCd > 0) {
567
+ await sleep(remainCd * 1000 + 1000);
568
+ newResp = await refetchMsg(channel, msgId);
569
+ if (!newResp) break;
570
+ }
571
+
572
+ const goFishBtn3 = getAllButtons(newResp).find(b =>
573
+ !b.disabled && (b.customId || '').includes('fish-catch-fish')
574
+ );
575
+ if (goFishBtn3) {
576
+ await humanDelay(80, 200);
577
+ try {
578
+ await safeClickButton(newResp, goFishBtn3);
579
+ fishAgainClicked = true;
580
+ } catch (e) {
581
+ LOG.debug(`[fish] Go Fishing re-send error: ${e.message}`);
582
+ }
583
+ }
584
+ }
585
+
586
+ if (!fishAgainClicked) {
587
+ LOG.info('[fish] Could not start next round — stopping loop');
588
+ break;
589
+ }
590
+ }
591
+
592
+ LOG.success(`[fish] Fishing loop done: ${results.length} rounds, ${totalCaught} caught, ${totalMines} mines`);
593
+ return { totalRounds: results.length, totalCaught, totalMines, results };
594
+ }
595
+
596
+ /**
597
+ * Format a round result into {result, coins, nextCooldownSec}.
598
+ */
599
+ function formatRoundResult(r) {
600
+ const coins = 0; // Fish don't give coins directly; value is from selling
601
+ switch (r.outcome) {
602
+ case 'caught':
603
+ return { result: `fish → caught ${r.fish || 'a fish'}`, coins, nextCooldownSec: r.cd };
604
+ case 'mine':
605
+ return { result: 'fish → triggered mine', coins, nextCooldownSec: r.cd };
606
+ case 'nothing':
607
+ return { result: 'fish → nothing', coins, nextCooldownSec: r.cd };
608
+ case 'got_away':
609
+ return { result: 'fish → got away', coins, nextCooldownSec: r.cd };
610
+ default:
611
+ return { result: `fish → ${r.header || r.outcome}`, coins, nextCooldownSec: r.cd };
612
+ }
613
+ }
614
+
615
+ module.exports = { runFish, runFishLoop, sellAllFish };