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.
package/README.md CHANGED
@@ -1,16 +1,16 @@
1
1
  # DankGrinder CLI
2
2
 
3
- Dank Memer automation engine — grind coins while you sleep.
3
+ Dank Memer automation engine — multi-account grinding with live TUI dashboard.
4
4
 
5
5
  ## Installation & Usage
6
6
 
7
7
  ```bash
8
8
  # Run directly (no install needed)
9
- npx dankgrinder --key dkg_your_api_key
9
+ npx dankgrinder --key dkg_your_api_key --url https://your-dashboard.com
10
10
 
11
11
  # Or install globally
12
12
  npm install -g dankgrinder
13
- dankgrinder --key dkg_your_api_key
13
+ dankgrinder --key dkg_your_api_key --url https://your-dashboard.com
14
14
  ```
15
15
 
16
16
  ## Options
@@ -18,30 +18,39 @@ dankgrinder --key dkg_your_api_key
18
18
  | Flag | Description | Default |
19
19
  |------|-------------|---------|
20
20
  | `--key <key>` | Your DankGrinder API key (required) | — |
21
- | `--url <url>` | API server URL | `http://localhost:3000` |
21
+ | `--url <url>` | API server URL (required) | |
22
22
  | `--help` | Show help | — |
23
23
  | `--version` | Show version | — |
24
24
 
25
25
  ## Setup
26
26
 
27
27
  1. Sign up at your DankGrinder dashboard
28
- 2. Go to **Config** → set your Discord token and channel ID
29
- 3. Enable the commands you want to automate
28
+ 2. Go to **Accounts** → add your Discord account(s) with token and channel ID
29
+ 3. Enable the commands you want to automate per account
30
30
  4. Go to **API Keys** → create a new key
31
- 5. Run: `npx dankgrinder --key dkg_your_key`
31
+ 5. Run: `npx dankgrinder --key dkg_your_key --url https://your-dashboard.com`
32
32
 
33
- ## Supported Commands
33
+ ## Supported Commands (30)
34
34
 
35
- - `pls hunt` Hunt animals for coins
36
- - `pls dig` — Dig for treasure
37
- - `pls beg` Beg for coins
38
- - `pls search` Search locations
39
- - `pls hl` Play highlow
40
- - `pls crime` Commit crime
41
- - `pls pm` — Post memes
35
+ | Category | Commands |
36
+ |----------|----------|
37
+ | **Grinding** | hunt, dig, fish, beg, search, crime, postmemes |
38
+ | **Games** | highlow, blackjack, coinflip, roulette, slots, snakeeyes, trivia, scratch |
39
+ | **Economy** | daily, weekly, monthly, work shift, stream, adventure, deposit |
40
+ | **Utility** | farm, tidy, use, drops, alert |
41
+
42
+ ## Features
43
+
44
+ - Multi-account support with per-account config
45
+ - Redis-backed cooldowns per command per account
46
+ - Live TUI dashboard with real-time stats
47
+ - Auto-buy tools (shovel, fishing pole, rifle) when missing
48
+ - Smart button/interaction handling for all mini-games
49
+ - Hold Tight detection with automatic cooldown management
42
50
 
43
51
  ## Requirements
44
52
 
45
53
  - Node.js 18+
46
54
  - A DankGrinder account with API key
47
- - Discord token configured in dashboard
55
+ - Discord token(s) configured in dashboard
56
+ - Redis (optional, for persistent cooldowns via `REDIS_URL` env var)
@@ -6,30 +6,20 @@ const args = process.argv.slice(2);
6
6
 
7
7
  if (args.includes('--help') || args.includes('-h')) {
8
8
  console.log(`
9
- \x1b[35m╔══════════════════════════════════════╗
10
- ║ \x1b[1mDankGrinder CLI v2.0\x1b[0m\x1b[35m ║
11
- ║ Multi-Account Automation Engine ║
12
- ╚══════════════════════════════════════╝\x1b[0m
13
-
14
- \x1b[1mUsage:\x1b[0m
15
- npx dankgrinder --key <API_KEY> [options]
16
-
17
- \x1b[1mOptions:\x1b[0m
18
- --key <key> Your DankGrinder API key (required)
19
- --url <url> API server URL (default: http://localhost:3000)
20
- --help, -h Show this help message
21
- --version, -v Show version
22
-
23
- \x1b[1mExamples:\x1b[0m
24
- npx dankgrinder --key dkg_abc123def456
25
- npx dankgrinder --key dkg_abc123def456 --url https://myserver.com
26
-
27
- \x1b[1mSetup:\x1b[0m
28
- 1. Sign up at your DankGrinder dashboard
29
- 2. Go to the Accounts page and add your Discord accounts
30
- 3. Configure per-account commands and cooldowns
31
- 4. Generate an API key from Auth Tokens
32
- 5. Run this CLI — it spawns one worker per active account
9
+ \x1b[1m\x1b[35mDANK\x1b[36mGRINDER\x1b[0m \x1b[2mv4.0\x1b[0m
10
+
11
+ \x1b[1mUsage:\x1b[0m
12
+ npx dankgrinder --key <API_KEY> --url <DASHBOARD_URL>
13
+
14
+ \x1b[1mOptions:\x1b[0m
15
+ --key <key> API key from dashboard (or env DANKGRINDER_KEY)
16
+ --url <url> Dashboard URL (or env DANKGRINDER_URL)
17
+ --help, -h Show this help
18
+ --version, -v Show version
19
+
20
+ \x1b[1mExamples:\x1b[0m
21
+ npx dankgrinder --key dkg_abc123 --url https://myapp.up.railway.app
22
+ DANKGRINDER_KEY=dkg_abc123 DANKGRINDER_URL=https://... npx dankgrinder
33
23
  `);
34
24
  process.exit(0);
35
25
  }
@@ -40,8 +30,8 @@ if (args.includes('--version') || args.includes('-v')) {
40
30
  process.exit(0);
41
31
  }
42
32
 
43
- let apiKey = '';
44
- let apiUrl = 'http://localhost:3000';
33
+ let apiKey = process.env.DANKGRINDER_KEY || '';
34
+ let apiUrl = process.env.DANKGRINDER_URL || '';
45
35
 
46
36
  for (let i = 0; i < args.length; i++) {
47
37
  if (args[i] === '--key' && args[i + 1]) apiKey = args[i + 1];
@@ -51,9 +41,9 @@ for (let i = 0; i < args.length; i++) {
51
41
  if (!apiKey) {
52
42
  console.error('\x1b[31m✗ Missing API key.\x1b[0m');
53
43
  console.error('');
54
- console.error(' Usage: npx dankgrinder --key <YOUR_API_KEY>');
44
+ console.error(' Usage: npx dankgrinder --key <YOUR_API_KEY> --url <DASHBOARD_URL>');
55
45
  console.error('');
56
- console.error(' Get your API key from the DankGrinder dashboard.');
46
+ console.error(' Or set env vars: DANKGRINDER_KEY, DANKGRINDER_URL');
57
47
  console.error(' Run \x1b[36mnpx dankgrinder --help\x1b[0m for more info.');
58
48
  process.exit(1);
59
49
  }
@@ -0,0 +1,502 @@
1
+ /**
2
+ * Adventure command handler.
3
+ *
4
+ * Confirmed flow from live debugging:
5
+ * ─────────────────────────────────────────────────────────────
6
+ * 1. "pls adventure"
7
+ * → SELECT_MENU: adventure types (space, west, halloween, museum, brazil, etc.)
8
+ * → "Start" BUTTON (customId: adventure-backpack:ID:TYPE, style=SUCCESS)
9
+ * → If no ticket: Start is disabled
10
+ *
11
+ * 2. Click Start
12
+ * → Equip screen: item grid buttons (null labels, emojis) + "Start" + "Equip All" + "Cancel"
13
+ * → Click "Equip All" then "Start"
14
+ *
15
+ * 3. Adventure rounds (5 interactions shown in footer like "🚀 - ⦾ - ⦾ - ⦾ - ⦾"):
16
+ * Each round has:
17
+ * • Embed with event description + footer showing progress
18
+ * • Backpack btn (emoji=Backpack, customId=adventure-progress:ID) — view-only
19
+ * • ArrowRightui btn (customId=adventure-next:ID) — advance to next interaction
20
+ * • Sometimes: choice buttons (customId=adventure-option:ID:N:M) like "Reach for it" / "Flee"
21
+ *
22
+ * When choices are present → ArrowRightui is DISABLED until a choice is made.
23
+ * After choosing → ArrowRightui becomes enabled. Click it to advance.
24
+ * When no choices → ArrowRightui is enabled. Click it to advance directly.
25
+ *
26
+ * 4. After last interaction:
27
+ * → "Adventure Progress" embed with Backpack, Rewards, Interactions fields
28
+ * → No clickable buttons → adventure is done
29
+ *
30
+ * CRITICAL: safeClickButton()/clickButton() returns an interaction object,
31
+ * NOT the updated message. Must re-fetch via channel.messages.fetch(msg.id)
32
+ * to see the updated state after each click.
33
+ * ─────────────────────────────────────────────────────────────
34
+ */
35
+
36
+ const {
37
+ LOG, c, sleep, humanDelay, getFullText, parseCoins, parseBalance,
38
+ getAllButtons, getAllSelectMenus, findButton, findSelectMenuOption,
39
+ safeClickButton, isHoldTight, logMsg, dumpMessage, needsItem,
40
+ } = require('./utils');
41
+ const { buyItem } = require('./shop');
42
+
43
+ // ── Adventure type rotation (cycle through all types each run) ────
44
+ let lastAdventureIndex = -1;
45
+
46
+ // ── Helpers ──────────────────────────────────────────────────────
47
+
48
+ /** Re-fetch a message to get its current state after an interaction edit */
49
+ async function refetchMsg(channel, msgId) {
50
+ try {
51
+ return await channel.messages.fetch(msgId);
52
+ } catch (e) {
53
+ LOG.error(`[adventure] Re-fetch failed: ${e.message}`);
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /** Click a button on msg, then re-fetch msg to get updated state */
59
+ async function clickAndRefetch(channel, msg, btn) {
60
+ try {
61
+ await safeClickButton(msg, btn);
62
+ } catch (e) {
63
+ LOG.error(`[adventure] Click error: ${e.message}`);
64
+ return null;
65
+ }
66
+ // Dank Memer edits the message after a button click
67
+ await sleep(500);
68
+ return await refetchMsg(channel, msg.id);
69
+ }
70
+
71
+ /** Parse footer progress like "🚀 - ⦾ - ⦾ - ⦾ - ⦾" → { current: 1, total: 5 } */
72
+ function parseProgress(msg) {
73
+ const footer = msg.embeds?.[0]?.footer?.text || '';
74
+ // Count total markers and find the rocket position
75
+ const markers = footer.split(' - ');
76
+ if (markers.length < 2) return null;
77
+ const current = markers.findIndex(m => m.includes('🚀')) + 1;
78
+ return { current, total: markers.length };
79
+ }
80
+
81
+ /** Get all choice buttons (adventure-option) that are clickable */
82
+ function getChoiceButtons(msg) {
83
+ return getAllButtons(msg).filter(b =>
84
+ !b.disabled && (b.customId || '').includes('adventure-option')
85
+ );
86
+ }
87
+
88
+ /** Get the "next" arrow button */
89
+ function getNextButton(msg) {
90
+ return getAllButtons(msg).find(b =>
91
+ (b.customId || '').includes('adventure-next')
92
+ );
93
+ }
94
+
95
+ /** Check if msg is the final "Adventure Progress" summary */
96
+ function isAdventureDone(msg) {
97
+ const text = getFullText(msg).toLowerCase();
98
+ const title = msg.embeds?.[0]?.title || '';
99
+ if (title === 'Adventure Progress') return true;
100
+ // Also done if no clickable buttons at all
101
+ const clickable = getAllButtons(msg).filter(b => !b.disabled);
102
+ if (clickable.length === 0) return true;
103
+ return false;
104
+ }
105
+
106
+ // ── Safe choices: prefer non-destructive options ─────────────────
107
+ const SAFE_KEYWORDS = ['flee', 'run', 'hide', 'avoid', 'ignore', 'leave', 'walk away', 'back away', 'retreat', 'skip'];
108
+ const RISKY_KEYWORDS = ['reach', 'grab', 'fight', 'attack', 'steal', 'open', 'touch', 'eat', 'drink'];
109
+
110
+ function pickSafeChoice(choices) {
111
+ if (choices.length === 0) return null;
112
+ if (choices.length === 1) return choices[0];
113
+
114
+ // Check labels for safe/risky keywords
115
+ const labels = choices.map(b => (b.label || '').toLowerCase());
116
+
117
+ // Prefer safe keywords
118
+ for (let i = 0; i < labels.length; i++) {
119
+ for (const kw of SAFE_KEYWORDS) {
120
+ if (labels[i].includes(kw)) {
121
+ LOG.info(`[adventure] Picking safe option: "${choices[i].label}" (matched: ${kw})`);
122
+ return choices[i];
123
+ }
124
+ }
125
+ }
126
+
127
+ // Avoid risky keywords
128
+ const nonRisky = choices.filter((b, i) => {
129
+ return !RISKY_KEYWORDS.some(kw => labels[i].includes(kw));
130
+ });
131
+ if (nonRisky.length > 0) {
132
+ const pick = nonRisky[Math.floor(Math.random() * nonRisky.length)];
133
+ LOG.info(`[adventure] Picking non-risky option: "${pick.label}"`);
134
+ return pick;
135
+ }
136
+
137
+ // Fallback: random
138
+ const pick = choices[Math.floor(Math.random() * choices.length)];
139
+ LOG.info(`[adventure] Picking random option: "${pick.label}"`);
140
+ return pick;
141
+ }
142
+
143
+ // ── Play through all adventure rounds ────────────────────────────
144
+ async function playAdventureRounds(channel, msg) {
145
+ let current = msg;
146
+ let interactions = 0;
147
+ const MAX_INTERACTIONS = 30;
148
+
149
+ while (interactions < MAX_INTERACTIONS) {
150
+ interactions++;
151
+
152
+ const progress = parseProgress(current);
153
+ const fullText = getFullText(current);
154
+ const choices = getChoiceButtons(current);
155
+ const nextBtn = getNextButton(current);
156
+ const done = isAdventureDone(current);
157
+
158
+ if (progress) {
159
+ LOG.info(`[adventure] Interaction ${progress.current}/${progress.total}: "${fullText.substring(0, 80).trim()}"`);
160
+ } else {
161
+ LOG.info(`[adventure] Step ${interactions}: "${fullText.substring(0, 80).trim()}"`);
162
+ }
163
+
164
+ if (done) {
165
+ LOG.success(`[adventure] Adventure finished after ${interactions} steps`);
166
+ break;
167
+ }
168
+
169
+ // ── If there are choices, pick one first ─────────────────
170
+ if (choices.length > 0) {
171
+ LOG.info(`[adventure] ${choices.length} choices: [${choices.map(b => `"${b.label}"`).join(', ')}]`);
172
+ const choice = pickSafeChoice(choices);
173
+ if (choice) {
174
+ LOG.info(`[adventure] → Choosing: "${choice.label}"`);
175
+ await sleep(200);
176
+ const afterChoice = await clickAndRefetch(channel, current, choice);
177
+ if (afterChoice) {
178
+ current = afterChoice;
179
+ logMsg(current, `adv-choice-${interactions}`);
180
+
181
+ // Check if adventure ended after this choice
182
+ if (isAdventureDone(current)) {
183
+ LOG.success(`[adventure] Adventure ended after choice at step ${interactions}`);
184
+ break;
185
+ }
186
+ } else {
187
+ LOG.warn(`[adventure] No response after choice click`);
188
+ break;
189
+ }
190
+ }
191
+ }
192
+
193
+ // ── Click "Next" arrow to advance ────────────────────────
194
+ const nextBtnNow = getNextButton(current);
195
+ if (nextBtnNow && !nextBtnNow.disabled) {
196
+ LOG.debug(`[adventure] Clicking Next arrow...`);
197
+ await sleep(200);
198
+ const afterNext = await clickAndRefetch(channel, current, nextBtnNow);
199
+ if (afterNext) {
200
+ current = afterNext;
201
+ logMsg(current, `adv-round-${interactions}`);
202
+ } else {
203
+ LOG.warn(`[adventure] No response after Next click`);
204
+ break;
205
+ }
206
+ } else if (nextBtnNow && nextBtnNow.disabled) {
207
+ // Next is disabled but no choices found — might be loading
208
+ LOG.debug(`[adventure] Next disabled, no choices — waiting...`);
209
+ await sleep(600);
210
+ const refreshed = await refetchMsg(channel, current.id);
211
+ if (refreshed) {
212
+ current = refreshed;
213
+ // Don't increment interactions, just retry
214
+ interactions--;
215
+ } else {
216
+ break;
217
+ }
218
+ } else {
219
+ // No next button at all
220
+ LOG.debug(`[adventure] No Next button — checking if done`);
221
+ if (isAdventureDone(current)) break;
222
+ // Wait and re-fetch as Dank Memer might still be editing
223
+ await sleep(1500);
224
+ const refreshed = await refetchMsg(channel, current.id);
225
+ if (refreshed) {
226
+ current = refreshed;
227
+ if (isAdventureDone(current)) break;
228
+ interactions--;
229
+ } else {
230
+ break;
231
+ }
232
+ }
233
+ }
234
+
235
+ // Parse final results
236
+ const finalText = getFullText(current);
237
+ const coins = parseCoins(finalText);
238
+
239
+ // Parse rewards from Adventure Progress embed
240
+ let rewards = '-';
241
+ for (const e of current.embeds || []) {
242
+ for (const f of e.fields || []) {
243
+ if (f.name === 'Rewards') rewards = f.value;
244
+ }
245
+ }
246
+ LOG.info(`[adventure] Rewards: ${rewards}`);
247
+
248
+ return { text: finalText, coins, interactions, rewards, finalMsg: current };
249
+ }
250
+
251
+ // ── Main: runAdventure ───────────────────────────────────────────
252
+
253
+ /**
254
+ * @param {object} opts
255
+ * @param {object} opts.channel - Discord channel
256
+ * @param {function} opts.waitForDankMemer - Waits for Dank Memer response
257
+ * @param {object} [opts.client] - Discord client (for modal handling in shop)
258
+ * @returns {Promise<{result: string, coins: number, nextCooldownSec: number|null}>}
259
+ */
260
+ async function runAdventure({ channel, waitForDankMemer, client }) {
261
+ LOG.cmd(`${c.white}${c.bold}pls adventure${c.reset}`);
262
+
263
+ // Step 1: Send the command
264
+ await channel.send('pls adventure');
265
+ let response = await waitForDankMemer(12000);
266
+
267
+ if (!response) {
268
+ LOG.warn('[adventure] No response from Dank Memer');
269
+ return { result: 'no response', coins: 0, nextCooldownSec: null };
270
+ }
271
+
272
+ // Check for Hold Tight
273
+ if (isHoldTight(response)) {
274
+ LOG.warn('[adventure] Hold Tight — waiting 30s');
275
+ await sleep(30000);
276
+ return { result: 'hold tight', coins: 0, nextCooldownSec: 35 };
277
+ }
278
+
279
+ logMsg(response, 'adventure-initial');
280
+
281
+ const text = getFullText(response);
282
+ const textLower = text.toLowerCase();
283
+ const allButtons = getAllButtons(response);
284
+
285
+ // ── 1) Check cooldown via Unix timestamp <t:UNIX:t> ────────
286
+ // Format: "You can start another adventure at <t:1774415487:t> (<t:1774415487:R>)"
287
+ let cooldownSec = 0;
288
+ const tsMatch = text.match(/<t:(\d+)(?::[tTdDfFR])?>/);
289
+ if (tsMatch) {
290
+ const unixTarget = parseInt(tsMatch[1]);
291
+ const nowUnix = Math.floor(Date.now() / 1000);
292
+ cooldownSec = Math.max(0, unixTarget - nowUnix);
293
+ if (cooldownSec > 0) {
294
+ LOG.warn(`[adventure] On cooldown — next at ${new Date(unixTarget * 1000).toLocaleTimeString()} (${cooldownSec}s / ${Math.ceil(cooldownSec / 60)}min)`);
295
+ }
296
+ }
297
+
298
+ // ── 2) Check ticket need from button label ─────────────────
299
+ // Button: "Start (1 Adventure Ticket)" with emoji=AdventureTicket
300
+ // Footer: "You need adventure tickets to start the adventure!"
301
+ const startBtn = allButtons.find(b => (b.customId || '').includes('adventure-backpack'));
302
+ const startLabel = (startBtn?.label || '').toLowerCase();
303
+ const needsTicket = textLower.includes('you need adventure tickets') ||
304
+ (startLabel.includes('ticket') && startBtn?.disabled);
305
+
306
+ LOG.debug(`[adventure] startBtn="${startBtn?.label}" disabled=${startBtn?.disabled}, needsTicket=${needsTicket}, cooldown=${cooldownSec}s`);
307
+
308
+ // ── 3) If on cooldown, return with exact seconds ───────────
309
+ if (cooldownSec > 5) {
310
+ // Even if we also need a ticket, cooldown takes priority — we can buy ticket later
311
+ if (needsTicket) {
312
+ LOG.info(`[adventure] Need ticket + on cooldown. Will buy ticket after cooldown.`);
313
+ }
314
+ return { result: 'cooldown', coins: 0, nextCooldownSec: cooldownSec + 3 };
315
+ }
316
+
317
+ // ── 4) If we need a ticket, try to buy one ─────────────────
318
+ if (needsTicket) {
319
+ LOG.warn(`[adventure] Need adventure ticket! Attempting to buy...`);
320
+
321
+ // Ticket costs 250,000 coins — check balance first to avoid wasting time in shop
322
+ const TICKET_COST = 250000;
323
+ let currentBalance = 0;
324
+ await channel.send('pls bal');
325
+ const balMsg = await waitForDankMemer(8000);
326
+ if (balMsg) {
327
+ currentBalance = parseBalance(balMsg);
328
+ LOG.info(`[adventure] Balance: ${c.yellow}⏣ ${currentBalance.toLocaleString()}${c.reset} (ticket costs ⏣ ${TICKET_COST.toLocaleString()})`);
329
+ }
330
+
331
+ if (currentBalance < TICKET_COST) {
332
+ LOG.warn(`[adventure] Not enough coins for ticket (⏣ ${currentBalance.toLocaleString()} < ⏣ ${TICKET_COST.toLocaleString()}). Grind more first.`);
333
+ return { result: `need ticket (⏣ ${currentBalance.toLocaleString()}/${TICKET_COST.toLocaleString()})`, coins: 0, nextCooldownSec: 120 };
334
+ }
335
+
336
+ const bought = await buyItem({
337
+ channel, waitForDankMemer, client,
338
+ itemName: 'Adventure Ticket', quantity: 1,
339
+ });
340
+
341
+ if (!bought) {
342
+ LOG.error('[adventure] Could not buy adventure ticket from shop.');
343
+ return { result: 'need ticket (buy failed)', coins: 0, nextCooldownSec: 120 };
344
+ }
345
+
346
+ LOG.success('[adventure] Tickets purchased! Re-running adventure...');
347
+ await sleep(1500);
348
+
349
+ await channel.send('pls adventure');
350
+ response = await waitForDankMemer(12000);
351
+ if (!response) return { result: 'no response after ticket buy', coins: 0, nextCooldownSec: null };
352
+ logMsg(response, 'adventure-after-buy');
353
+ }
354
+
355
+ // ── Check if we're already mid-adventure (no select menu) ──
356
+ const menus = getAllSelectMenus(response);
357
+ const hasNextBtn = allButtons.some(b => (b.customId || '').includes('adventure-next'));
358
+
359
+ if (hasNextBtn && menus.length === 0) {
360
+ // Already mid-adventure from a previous run — jump straight to rounds
361
+ LOG.info('[adventure] Resuming mid-adventure...');
362
+ const { text: finalText, coins, interactions, rewards, finalMsg } = await playAdventureRounds(channel, response);
363
+ return buildResult(finalText, coins, interactions, rewards, finalMsg);
364
+ }
365
+
366
+ // ── Select adventure type from dropdown ─────────────────────
367
+ if (menus.length > 0) {
368
+ // Re-fetch message to get hydrated components (minValues/maxValues)
369
+ const freshMsg = await channel.messages.fetch(response.id).catch(() => null);
370
+ if (freshMsg) response = freshMsg;
371
+
372
+ // Find the select menu row index
373
+ let menuRowIdx = -1;
374
+ for (let i = 0; i < (response.components || []).length; i++) {
375
+ const row = response.components[i];
376
+ for (const comp of row.components || []) {
377
+ if (comp.type === 'STRING_SELECT' || comp.type === 3) { menuRowIdx = i; break; }
378
+ }
379
+ if (menuRowIdx >= 0) break;
380
+ }
381
+
382
+ const menu = response.components[menuRowIdx]?.components[0];
383
+ const options = menu?.options || [];
384
+ if (options.length > 0 && menuRowIdx >= 0) {
385
+ lastAdventureIndex = (lastAdventureIndex + 1) % options.length;
386
+ const opt = options[lastAdventureIndex];
387
+ LOG.info(`[adventure] Selecting [${lastAdventureIndex + 1}/${options.length}]: "${opt.label}"`);
388
+ try {
389
+ const selectResult = await response.selectMenu(menuRowIdx, [opt.value]);
390
+ if (selectResult) {
391
+ response = selectResult;
392
+ logMsg(response, 'adventure-selected');
393
+ }
394
+ } catch (e) {
395
+ LOG.error(`[adventure] Select error: ${e.message}`);
396
+ }
397
+ await sleep(300);
398
+ }
399
+ }
400
+
401
+ // ── Click "Start" button ───────────────────────────────────
402
+ let updatedButtons = getAllButtons(response);
403
+ let startButton = updatedButtons.find(b =>
404
+ (b.label || '').toLowerCase() === 'start' ||
405
+ (b.customId || '').includes('adventure-backpack')
406
+ );
407
+
408
+ if (startButton && !startButton.disabled) {
409
+ LOG.info(`[adventure] Clicking "Start"...`);
410
+ const afterStart = await clickAndRefetch(channel, response, startButton);
411
+ if (afterStart) {
412
+ response = afterStart;
413
+ logMsg(response, 'adventure-after-start');
414
+ }
415
+ } else if (startButton && startButton.disabled) {
416
+ LOG.warn('[adventure] Start button disabled (no ticket?)');
417
+ return { result: 'start disabled (no ticket)', coins: 0, nextCooldownSec: 60 };
418
+ }
419
+
420
+ // ── Handle equip screen ────────────────────────────────────
421
+ updatedButtons = getAllButtons(response);
422
+ const equipAllBtn = updatedButtons.find(b => !b.disabled && (b.label || '').toLowerCase() === 'equip all');
423
+ const equipStartBtn = updatedButtons.find(b => !b.disabled && (b.label || '').toLowerCase() === 'start');
424
+ const hasCancelBtn = updatedButtons.some(b => !b.disabled && (b.label || '').toLowerCase() === 'cancel');
425
+
426
+ if (equipStartBtn && (equipAllBtn || hasCancelBtn)) {
427
+ LOG.info('[adventure] Equip screen detected');
428
+
429
+ if (equipAllBtn) {
430
+ LOG.info('[adventure] Equipping all items...');
431
+ const afterEquip = await clickAndRefetch(channel, response, equipAllBtn);
432
+ if (afterEquip) {
433
+ response = afterEquip;
434
+ logMsg(response, 'adventure-after-equip');
435
+ }
436
+ }
437
+
438
+ // Click Start to begin the actual adventure
439
+ const newStart = getAllButtons(response).find(b =>
440
+ !b.disabled && (b.label || '').toLowerCase() === 'start'
441
+ );
442
+ if (newStart) {
443
+ LOG.info('[adventure] Starting adventure...');
444
+ const afterStart2 = await clickAndRefetch(channel, response, newStart);
445
+ if (afterStart2) {
446
+ response = afterStart2;
447
+ logMsg(response, 'adventure-started');
448
+ }
449
+ }
450
+ }
451
+
452
+ // ── Play through all adventure rounds ──────────────────────
453
+ const { text: finalText, coins, interactions, rewards, finalMsg } = await playAdventureRounds(channel, response);
454
+ return buildResult(finalText, coins, interactions, rewards, finalMsg);
455
+ }
456
+
457
+ // ── Build result object with cooldown parsing ────────────────────
458
+ function buildResult(finalText, coins, interactions, rewards, msg) {
459
+ let nextCooldownSec = null;
460
+
461
+ // 1) Best: Unix timestamp <t:UNIX:t> in final text or embed
462
+ const allText = msg ? getFullText(msg) : finalText;
463
+ const tsMatch = allText.match(/<t:(\d+)(?::[tTdDfFR])?>/);
464
+ if (tsMatch) {
465
+ const unixTarget = parseInt(tsMatch[1]);
466
+ const nowUnix = Math.floor(Date.now() / 1000);
467
+ nextCooldownSec = Math.max(5, unixTarget - nowUnix);
468
+ LOG.info(`[adventure] Next at ${new Date(unixTarget * 1000).toLocaleTimeString()} (${c.yellow}${nextCooldownSec}s${c.reset})`);
469
+ }
470
+
471
+ // 2) Fallback: "Adventure again in X minutes" button label
472
+ if (!nextCooldownSec && msg) {
473
+ for (const row of msg.components || []) {
474
+ for (const comp of row.components || []) {
475
+ const label = (comp.label || '').toLowerCase();
476
+ const btnMatch = label.match(/adventure again in (\d+)\s*(minute|min|hour|second)/);
477
+ if (btnMatch) {
478
+ nextCooldownSec = parseInt(btnMatch[1]);
479
+ const unit = btnMatch[2].toLowerCase();
480
+ if (unit.startsWith('min')) nextCooldownSec *= 60;
481
+ if (unit.startsWith('hour')) nextCooldownSec *= 3600;
482
+ LOG.info(`[adventure] Next in ${c.yellow}${btnMatch[1]} ${btnMatch[2]}s${c.reset} (from button)`);
483
+ }
484
+ }
485
+ }
486
+ }
487
+
488
+ if (!nextCooldownSec) nextCooldownSec = 300;
489
+
490
+ let result;
491
+ if (coins > 0) {
492
+ result = `adventure (${interactions} interactions) → ${c.green}+⏣ ${coins.toLocaleString()}${c.reset} | Rewards: ${rewards}`;
493
+ LOG.coin(`[adventure] Earned ${c.green}${c.bold}⏣ ${coins.toLocaleString()}${c.reset}`);
494
+ } else {
495
+ result = `adventure done (${interactions} interactions) | Rewards: ${rewards}`;
496
+ LOG.info(`[adventure] Completed ${interactions} interactions`);
497
+ }
498
+
499
+ return { result, coins, nextCooldownSec };
500
+ }
501
+
502
+ module.exports = { runAdventure };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Beg command handler.
3
+ * Simple command: send "pls beg", parse coins from response.
4
+ */
5
+
6
+ const { LOG, c, getFullText, parseCoins, logMsg, isHoldTight, getHoldTightReason, sleep } = require('./utils');
7
+
8
+ /**
9
+ * @param {object} opts
10
+ * @param {object} opts.channel
11
+ * @param {function} opts.waitForDankMemer
12
+ * @returns {Promise<{result: string, coins: number}>}
13
+ */
14
+ async function runBeg({ channel, waitForDankMemer }) {
15
+ LOG.cmd(`${c.white}${c.bold}pls beg${c.reset}`);
16
+
17
+ await channel.send('pls beg');
18
+ const response = await waitForDankMemer(10000);
19
+
20
+ if (!response) {
21
+ LOG.warn('[beg] No response');
22
+ return { result: 'no response', coins: 0 };
23
+ }
24
+
25
+ if (isHoldTight(response)) {
26
+ const reason = getHoldTightReason(response);
27
+ LOG.warn(`[beg] Hold Tight${reason ? ` (reason: /${reason})` : ''} — waiting 30s`);
28
+ await sleep(30000);
29
+ return { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason };
30
+ }
31
+
32
+ logMsg(response, 'beg');
33
+ const text = getFullText(response);
34
+ const coins = parseCoins(text);
35
+
36
+ if (coins > 0) {
37
+ LOG.coin(`[beg] ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`);
38
+ return { result: `beg → ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`, coins };
39
+ }
40
+
41
+ LOG.info(`[beg] ${text.substring(0, 80).replace(/\n/g, ' ')}`);
42
+ return { result: text.substring(0, 60) || 'done', coins: 0 };
43
+ }
44
+
45
+ module.exports = { runBeg };