dankgrinder 6.46.0 → 7.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.
@@ -1,20 +1,17 @@
1
1
  /**
2
2
  * Beg command handler.
3
3
  * Simple command: send "pls beg", parse coins from response.
4
- * Detects: positive coins, Life Saver drops, negative (no coins).
5
4
  */
6
5
 
7
6
  const { LOG, c, getFullText, parseCoins, logMsg, isHoldTight, getHoldTightReason, sleep } = require('./utils');
8
7
 
9
8
  const RE_NEWLINE = /\n/g;
10
- const RE_LIFE_SAVER = /life\s*saver/i;
11
- const RE_BEG_COINS = /you\s+received\s*:\s*[\s\S]{0,200}?([\d,]+)/i;
12
9
 
13
10
  /**
14
11
  * @param {object} opts
15
12
  * @param {object} opts.channel
16
13
  * @param {function} opts.waitForDankMemer
17
- * @returns {Promise<{result: string, coins: number, lifeSaver?: boolean}>}
14
+ * @returns {Promise<{result: string, coins: number}>}
18
15
  */
19
16
  async function runBeg({ channel, waitForDankMemer }) {
20
17
  LOG.cmd(`${c.white}${c.bold}pls beg${c.reset}`);
@@ -36,30 +33,11 @@ async function runBeg({ channel, waitForDankMemer }) {
36
33
 
37
34
  logMsg(response, 'beg');
38
35
  const text = getFullText(response);
39
-
40
- // Extract coins: prefer "You received" pattern for beg responses
41
- let coins = 0;
42
- const begMatch = text.match(RE_BEG_COINS);
43
- if (begMatch) {
44
- coins = parseInt(begMatch[1].replace(/,/g, ''), 10) || 0;
45
- }
46
- // Fallback to general parseCoins
47
- if (coins === 0) {
48
- coins = parseCoins(text);
49
- }
50
-
51
- // Detect Life Saver drops
52
- const lifeSaver = RE_LIFE_SAVER.test(text);
36
+ const coins = parseCoins(text);
53
37
 
54
38
  if (coins > 0) {
55
- const lsNote = lifeSaver ? ` + ${c.cyan}Life Saver${c.reset}` : '';
56
- LOG.coin(`[beg] ${c.green}+⏣ ${coins.toLocaleString()}${lsNote}${c.reset}`);
57
- return { result: `beg → ${c.green}+⏣ ${coins.toLocaleString()}${lifeSaver ? ' + Life Saver' : ''}${c.reset}`, coins, lifeSaver };
58
- }
59
-
60
- if (lifeSaver) {
61
- LOG.coin(`[beg] ${c.cyan}Life Saver${c.reset} (no coins)`);
62
- return { result: 'beg → Life Saver', coins: 0, lifeSaver };
39
+ LOG.coin(`[beg] ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`);
40
+ return { result: `beg ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`, coins };
63
41
  }
64
42
 
65
43
  LOG.info(`[beg] ${text.substring(0, 80).replace(RE_NEWLINE, ' ')}`);
@@ -11,27 +11,10 @@ const sharp = require('sharp');
11
11
  const https = require('https');
12
12
  const http = require('http');
13
13
 
14
- // Allowed CDN hostnames for image downloads (prevents SSRF).
15
- const ALLOWED_IMAGE_HOSTS = new Set([
16
- 'cdn.discordapp.com',
17
- 'media.discordapp.net',
18
- 'images.discordapp.net',
19
- ]);
20
-
21
- const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MB cap — prevents memory exhaustion
22
-
23
14
  /**
24
15
  * Download an image from a URL and return as Buffer.
25
- * Only allows Discord CDN URLs and enforces a max size limit.
26
16
  */
27
17
  function downloadImage(url) {
28
- // ── SSRF check ──────────────────────────────────────────────
29
- let hostname;
30
- try { hostname = new URL(url).hostname; } catch { return Promise.reject(new Error('invalid URL')); }
31
- if (!ALLOWED_IMAGE_HOSTS.has(hostname)) {
32
- return Promise.reject(new Error(`disallowed host: ${hostname}`));
33
- }
34
-
35
18
  return new Promise((resolve, reject) => {
36
19
  const proto = url.startsWith('https') ? https : http;
37
20
  const req = proto.get(url, res => {
@@ -39,16 +22,7 @@ function downloadImage(url) {
39
22
  return downloadImage(res.headers.location).then(resolve, reject);
40
23
  }
41
24
  const chunks = [];
42
- let bytesReceived = 0;
43
- res.on('data', c => {
44
- bytesReceived += c.length;
45
- if (bytesReceived > MAX_IMAGE_BYTES) {
46
- req.destroy();
47
- reject(new Error(`image too large: ${bytesReceived} bytes (max ${MAX_IMAGE_BYTES})`));
48
- return;
49
- }
50
- chunks.push(c);
51
- });
25
+ res.on('data', c => chunks.push(c));
52
26
  res.on('end', () => resolve(Buffer.concat(chunks)));
53
27
  res.on('error', reject);
54
28
  });
@@ -13,6 +13,7 @@ const { runHunt } = require('./hunt');
13
13
  const { runDig } = require('./dig');
14
14
  const { runFish, sellAllFish } = require('./fish');
15
15
  const { runPostMemes } = require('./postmemes');
16
+ const { runScratch } = require('./scratch');
16
17
  const { runBlackjack } = require('./blackjack');
17
18
  const { runTrivia, triviaDB } = require('./trivia');
18
19
  const { runWorkShift } = require('./work');
@@ -38,6 +39,7 @@ module.exports = {
38
39
  runFish,
39
40
  sellAllFish,
40
41
  runPostMemes,
42
+ runScratch,
41
43
  runBlackjack,
42
44
  runTrivia,
43
45
  runWorkShift,
@@ -198,7 +198,7 @@ async function runInventory({ channel, waitForDankMemer, client, accountId, redi
198
198
  LOG.cmd(`${c.white}${c.bold}pls inv${c.reset}`);
199
199
 
200
200
  await channel.send('pls inv');
201
- let response = await waitForDankMemer(15000);
201
+ let response = await waitForDankMemer(10000);
202
202
 
203
203
  if (!response) {
204
204
  LOG.warn('[inv] No response');
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Scratch command handler.
3
+ * Send "pls scratch", click through scratch card buttons.
4
+ * Requires level 25 — checks profile level before running.
5
+ */
6
+
7
+ const {
8
+ LOG, c, getFullText, parseCoins, getAllButtons, safeClickButton,
9
+ logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay,
10
+ } = require('./utils');
11
+ const { meetsLevelRequirement } = require('./profile');
12
+
13
+ /**
14
+ * @param {object} opts
15
+ * @param {object} opts.channel
16
+ * @param {function} opts.waitForDankMemer
17
+ * @param {string} [opts.accountId]
18
+ * @param {object} [opts.redis]
19
+ * @returns {Promise<{result: string, coins: number}>}
20
+ */
21
+ async function runScratch({ channel, waitForDankMemer, accountId, redis }) {
22
+ // Check level 25 requirement before wasting a command
23
+ const canRun = await meetsLevelRequirement({ channel, waitForDankMemer, accountId, redis }, 25);
24
+ if (!canRun) {
25
+ LOG.warn(`[scratch] Skipped — need level 25`);
26
+ return { result: 'skipped (need level 25)', coins: 0, skipReason: 'level' };
27
+ }
28
+ LOG.cmd(`${c.white}${c.bold}pls scratch${c.reset}`);
29
+
30
+ await channel.send('pls scratch');
31
+ const response = await waitForDankMemer(10000);
32
+
33
+ if (!response) {
34
+ LOG.warn('[scratch] No response');
35
+ return { result: 'no response', coins: 0 };
36
+ }
37
+
38
+ if (isHoldTight(response)) {
39
+ const reason = getHoldTightReason(response);
40
+ LOG.warn(`[scratch] Hold Tight${reason ? ` (reason: /${reason})` : ''} — waiting 30s`);
41
+ await sleep(30000);
42
+ return { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason };
43
+ }
44
+
45
+ logMsg(response, 'scratch');
46
+ const buttons = getAllButtons(response);
47
+
48
+ if (buttons.length === 0) {
49
+ const text = getFullText(response);
50
+ const coins = parseCoins(text);
51
+ if (coins > 0) {
52
+ LOG.coin(`[scratch] ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`);
53
+ return { result: `scratch → +⏣ ${coins.toLocaleString()}`, coins };
54
+ }
55
+ return { result: text.substring(0, 60) || 'done', coins: 0 };
56
+ }
57
+
58
+ // Click through all scratch card cells
59
+ let lastResponse = response;
60
+ let clickCount = 0;
61
+ for (let i = 0; i < Math.min(buttons.length, 9); i++) {
62
+ const btn = buttons[i];
63
+ if (btn && !btn.disabled) {
64
+ await humanDelay(300, 700);
65
+ try {
66
+ const followUp = await safeClickButton(lastResponse, btn);
67
+ if (followUp) { lastResponse = followUp; clickCount++; }
68
+ } catch { break; }
69
+ }
70
+ }
71
+
72
+ const finalText = getFullText(lastResponse);
73
+ const coins = parseCoins(finalText);
74
+
75
+ if (coins > 0) {
76
+ LOG.coin(`[scratch] ${clickCount} clicks → ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`);
77
+ return { result: `scratch (${clickCount} clicks) → +⏣ ${coins.toLocaleString()}`, coins };
78
+ }
79
+
80
+ return { result: `scratch done (${clickCount} clicks)`, coins: 0 };
81
+ }
82
+
83
+ module.exports = { runScratch };
@@ -558,8 +558,7 @@ async function clickCV2Button(msg, customId) {
558
558
  const gId = msg.guildId || msg.guild?.id;
559
559
  if (!token) throw new Error('No token for CV2 click');
560
560
  const sessionId = msg.client?.ws?.shards?.first?.()?.sessionId;
561
- const { randomUUID } = require('crypto');
562
- const nonce = randomUUID();
561
+ const nonce = `${BigInt(Date.now() - 1420070400000) << 22n}`;
563
562
  const payloadObj = {
564
563
  type: 3,
565
564
  application_id: String(msg.applicationId || DANK_MEMER_ID),
@@ -604,8 +603,7 @@ async function clickCV2SelectMenu(msg, customId, values = []) {
604
603
  const gId = msg.guildId || msg.guild?.id;
605
604
  if (!token) throw new Error('No token for CV2 select');
606
605
  const sessionId = msg.client?.ws?.shards?.first?.()?.sessionId;
607
- const { randomUUID } = require('crypto');
608
- const nonce = randomUUID();
606
+ const nonce = `${BigInt(Date.now() - 1420070400000) << 22n}`;
609
607
  const payloadObj = {
610
608
  type: 3,
611
609
  application_id: String(msg.applicationId || DANK_MEMER_ID),
package/lib/grinder.js CHANGED
@@ -5,7 +5,7 @@ const { setDashboardActive, isCV2, ensureCV2, stripAnsi } = require('./commands/
5
5
  const rawLogger = require('./rawLogger');
6
6
  const {
7
7
  BloomFilter, RingBuffer, TokenBucket, EMA, SlidingWindowCounter,
8
- AhoCorasick, LRUCache, StringPool, AsyncBatchQueue,
8
+ AhoCorasick, LRUCache, StringPool, AsyncBatchQueue, JitterBackoff,
9
9
  } = require('./structures');
10
10
  const PKG_VERSION = require('../package.json').version;
11
11
 
@@ -279,9 +279,9 @@ function boxTop(w, color) { return color + BOX.dtl + BOX.dh.repeat(w - 2) + BOX.
279
279
  function boxMid(w, color) { return color + BOX.tee + BOX.h.repeat(w - 2) + BOX.teeR + c.reset; }
280
280
  function boxBot(w, color) { return color + BOX.dbl + BOX.dh.repeat(w - 2) + BOX.dbr + c.reset; }
281
281
  function boxLine(content, w, color) {
282
- const stripped = stripAnsi(content);
282
+ const stripped = content.replace(/\x1b\[[0-9;]*m/g, '');
283
283
  const pad = Math.max(0, w - 4 - stripped.length);
284
- return color + BOX.dv + c.reset + ' ' + stripAnsi(content) + ' '.repeat(pad) + ' ' + color + BOX.dv + c.reset;
284
+ return color + BOX.dv + c.reset + ' ' + content + ' '.repeat(pad) + ' ' + color + BOX.dv + c.reset;
285
285
  }
286
286
  function thinLine(w) { return ' ' + c.dim + BOX.h.repeat(w - 4) + c.reset; }
287
287
 
@@ -868,8 +868,8 @@ const CMD_NAMES_CLEAN = {
868
868
  bj: 'Blackjack', blackjack: 'Blackjack', hl: 'High Low', pm: 'Post Memes', postmemes: 'Post Memes',
869
869
  ct: 'Coin Toss', cointoss: 'Coin Toss', se: 'Snake Eyes', snakeeyes: 'Snake Eyes',
870
870
  hunt: 'Hunt', dig: 'Dig', fish: 'Fish', beg: 'Beg', search: 'Search', crime: 'Crime',
871
- tidy: 'Tidy', farm: 'Farm', daily: 'Daily', monthly: 'Monthly',
872
- adventure: 'Adventure', trivia: 'Trivia', stream: 'Stream',
871
+ tidy: 'Tidy', farm: 'Farm', daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly',
872
+ scratch: 'Scratch', adventure: 'Adventure', trivia: 'Trivia', stream: 'Stream',
873
873
  drops: 'Drops', use: 'Use Item', dep: 'Deposit', deposit: 'Deposit', inv: 'Inventory',
874
874
  work: 'Work', stream: 'Stream', roulette: 'Roulette', slots: 'Slots',
875
875
  };
@@ -1490,9 +1490,7 @@ class AccountWorker {
1490
1490
  totalValue: result.totalValue || 0,
1491
1491
  }),
1492
1492
  });
1493
- } catch (err) {
1494
- console.error(`[${this.username}] inventory API error:`, err?.message || err);
1495
- }
1493
+ } catch {}
1496
1494
 
1497
1495
  return {
1498
1496
  ok: true,
@@ -1569,9 +1567,7 @@ class AccountWorker {
1569
1567
  raw_text: result.rawText || '',
1570
1568
  }),
1571
1569
  });
1572
- } catch (err) {
1573
- console.error(`[${this.username}] profile API error:`, err?.message || err);
1574
- }
1570
+ } catch {}
1575
1571
 
1576
1572
  return { ok: true, ...result };
1577
1573
  } catch (e) {
@@ -1780,48 +1776,22 @@ class AccountWorker {
1780
1776
  // Each modular command handler sends the command, waits for response,
1781
1777
  // handles Hold Tight / cooldowns / item-buying internally.
1782
1778
  async runCommand(cmdName, prefix) {
1783
- // Slash commands use '/cmd' without a space between slash and subcommand
1784
- // (e.g. '/dep max', '/work shift'). Legacy prefix uses 'pls cmd'.
1785
- const SLASH_CMD = new Map([
1786
- ['dep max', '/dep max'],
1787
- ['with max', '/with max'],
1788
- ['work shift', '/work shift'],
1789
- ['monthly', '/monthly'],
1790
- ]);
1779
+ let cmdString;
1791
1780
  const bjBet = Math.max(5000, this.account.bet_amount || 5000);
1792
1781
  const gambBet = Math.max(10000, this.account.bet_amount || 10000);
1793
1782
 
1794
- let cmdString;
1795
- if (prefix === '/') {
1796
- const slashVariant = SLASH_CMD.get(cmdName);
1797
- if (slashVariant) {
1798
- cmdString = slashVariant;
1799
- } else if (cmdName === 'blackjack') {
1800
- cmdString = `/bj ${bjBet}`;
1801
- } else if (cmdName === 'cointoss') {
1802
- cmdString = `/cointoss ${gambBet}`;
1803
- } else if (cmdName === 'roulette') {
1804
- cmdString = `/roulette ${gambBet}`;
1805
- } else if (cmdName === 'slots') {
1806
- cmdString = `/slots ${gambBet}`;
1807
- } else if (cmdName === 'snakeeyes') {
1808
- cmdString = `/snakeeyes ${gambBet}`;
1809
- } else {
1810
- cmdString = `/${cmdName}`;
1811
- }
1812
- } else {
1813
- switch (cmdName) {
1814
- case 'dep max': cmdString = `${prefix} dep max`; break;
1815
- case 'with max': cmdString = `${prefix} with max`; break;
1816
- case 'blackjack': cmdString = `${prefix} bj ${bjBet}`; break;
1817
- case 'cointoss': cmdString = `${prefix} cointoss ${gambBet}`; break;
1818
- case 'roulette': cmdString = `${prefix} roulette ${gambBet}`; break;
1819
- case 'slots': cmdString = `${prefix} slots ${gambBet}`; break;
1820
- case 'snakeeyes': cmdString = `${prefix} snakeeyes ${gambBet}`; break;
1821
- case 'work shift': cmdString = `${prefix} work shift`; break;
1822
- case 'monthly': cmdString = `${prefix} monthly`; break;
1823
- default: cmdString = `${prefix} ${cmdName}`;
1824
- }
1783
+ switch (cmdName) {
1784
+ case 'dep max': cmdString = `${prefix} dep max`; break;
1785
+ case 'with max': cmdString = `${prefix} with max`; break;
1786
+ case 'blackjack': cmdString = `${prefix} bj ${bjBet}`; break;
1787
+ case 'cointoss': cmdString = `${prefix} cointoss ${gambBet}`; break;
1788
+ case 'roulette': cmdString = `${prefix} roulette ${gambBet}`; break;
1789
+ case 'slots': cmdString = `${prefix} slots ${gambBet}`; break;
1790
+ case 'snakeeyes': cmdString = `${prefix} snakeeyes ${gambBet}`; break;
1791
+ case 'work shift': cmdString = `${prefix} work shift`; break;
1792
+ case 'weekly': cmdString = `${prefix} weekly`; break;
1793
+ case 'monthly': cmdString = `${prefix} monthly`; break;
1794
+ default: cmdString = `${prefix} ${cmdName}`;
1825
1795
  }
1826
1796
 
1827
1797
  if (shutdownCalled || !this.running) return;
@@ -1919,6 +1889,7 @@ class AccountWorker {
1919
1889
  case 'hunt': cmdResult = await commands.runHunt(cmdOpts); break;
1920
1890
  case 'dig': cmdResult = await commands.runDig(cmdOpts); break;
1921
1891
  case 'fish': cmdResult = await commands.runFish(cmdOpts); break;
1892
+ case 'scratch': cmdResult = await commands.runScratch(cmdOpts); break;
1922
1893
  case 'adventure': cmdResult = await commands.runAdventure(cmdOpts); break;
1923
1894
  case 'blackjack': cmdResult = await commands.runBlackjack(cmdOpts); break;
1924
1895
  case 'trivia': cmdResult = await commands.runTrivia(cmdOpts); break;
@@ -1973,9 +1944,7 @@ class AccountWorker {
1973
1944
  headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
1974
1945
  body: JSON.stringify({ account_id: this.account.id, active: false }),
1975
1946
  });
1976
- } catch (err) {
1977
- console.error(`[${this.username}] status deactivation API error:`, err?.message || err);
1978
- }
1947
+ } catch {}
1979
1948
  await sendLog(this.username, cmdString, 'VERIFICATION — account deactivated', 'error');
1980
1949
  sendWebhook('CAPTCHA ALERT', `**${this.username}** needs verification!\nCommand: \`${cmdName}\`\nSolve in Discord and re-enable from dashboard.`, 0xef4444);
1981
1950
  return;
@@ -2026,6 +1995,16 @@ class AccountWorker {
2026
1995
  return;
2027
1996
  }
2028
1997
 
1998
+ // Died flag from crime/search handler (death detected in the command response)
1999
+ if (cmdResult.died) {
2000
+ this.log('error', `${cmdName} → DIED! Checking lifesaver count...`);
2001
+ // The DM will come separately with the actual death details
2002
+ // For now, be cautious — set a short cooldown and let DM listener handle the rest
2003
+ await this.setCooldown('crime', 300); // 5 min cooldown to check DMs
2004
+ await this.setCooldown('search', 300);
2005
+ return;
2006
+ }
2007
+
2029
2008
  // Premium-only command detection — disable permanently
2030
2009
  if (resultLower.includes('only available on premium') || resultLower.includes('premium') ||
2031
2010
  resultLower.includes('buy the ability to use this command') ||
@@ -2044,9 +2023,8 @@ class AccountWorker {
2044
2023
  const timeMatch = result.match(/<t:(\d+):R>/);
2045
2024
  let waitSec;
2046
2025
  if (timeMatch) {
2047
- // Discord <t:TS:R> format: :R = relative seconds from NOW (not Unix ms).
2048
- // The captured number IS already the number of seconds to wait.
2049
- waitSec = Math.max(60, parseInt(timeMatch[1]));
2026
+ const nextAvail = parseInt(timeMatch[1]) * 1000;
2027
+ waitSec = Math.max(60, Math.ceil((nextAvail - Date.now()) / 1000));
2050
2028
  } else {
2051
2029
  const defaultWaits = { daily: 86400, weekly: 604800, monthly: 2592000 };
2052
2030
  waitSec = defaultWaits[cmdName] || 86400;
@@ -2226,13 +2204,13 @@ class AccountWorker {
2226
2204
 
2227
2205
  // ── Command Map (shared across ticks, used to build the heap) ──
2228
2206
  // Priority: higher = runs first when multiple commands are ready simultaneously.
2229
- // 10 = time-gated (daily/monthly — never miss),
2207
+ // 10 = time-gated (daily/weekly/monthly — never miss),
2230
2208
  // 8 = financial safety (deposit),
2231
2209
  // 7 = gambling fast-cycle (2-3s CD — run MOST often),
2232
2210
  // 6 = fast grinders (10s CD),
2233
2211
  // 5 = medium grinders (20-40s CD),
2234
2212
  // 4 = resource grinders (hunt/dig — need items),
2235
- // 3 = interactive/long CD (adventure/stream/work),
2213
+ // 3 = interactive/long CD (adventure/stream/work/scratch),
2236
2214
  // 2 = utility (drops/use/tidy)
2237
2215
  static COMMAND_MAP = [
2238
2216
  // Gambling — 2-3s CD, highest frequency
@@ -2243,7 +2221,7 @@ class AccountWorker {
2243
2221
  { key: 'cmd_snakeeyes', cmd: 'snakeeyes', cdKey: 'cd_snakeeyes', defaultCd: 3, priority: 7 },
2244
2222
  // Fast grinders — 10s CD
2245
2223
  { key: 'cmd_hl', cmd: 'hl', cdKey: 'cd_hl', defaultCd: 10, priority: 6 },
2246
- { key: 'cmd_farm', cmd: 'farm', cdKey: 'cd_farm', defaultCd: 30, priority: 4 },
2224
+ { key: 'cmd_farm', cmd: 'farm', cdKey: 'cd_farm', defaultCd: 30, priority: 4 },
2247
2225
  { key: 'cmd_trivia', cmd: 'trivia', cdKey: 'cd_trivia', defaultCd: 10, priority: 6 },
2248
2226
  { key: 'cmd_use', cmd: 'use', cdKey: 'cd_use', defaultCd: 10, priority: 2 },
2249
2227
  // Medium grinders — 20-25s CD
@@ -2254,19 +2232,20 @@ class AccountWorker {
2254
2232
  { key: 'cmd_search', cmd: 'search', cdKey: 'cd_search', defaultCd: 25, priority: 5 },
2255
2233
  // Slow grinders — 40s CD
2256
2234
  { key: 'cmd_beg', cmd: 'beg', cdKey: 'cd_beg', defaultCd: 40, priority: 5 },
2257
- { key: 'cmd_crime', cmd: 'crime', cdKey: 'cd_crime', defaultCd: 40, priority: 5 },
2258
- { key: 'cmd_tidy', cmd: 'tidy', cdKey: 'cd_tidy', defaultCd: 40, priority: 2 },
2235
+ { key: 'cmd_crime', cmd: 'crime', cdKey: 'cd_crime', defaultCd: 40, priority: 5 },
2236
+ { key: 'cmd_tidy', cmd: 'tidy', cdKey: 'cd_tidy', defaultCd: 40, priority: 2 },
2259
2237
  // Interactive — response-driven CD (handler sets nextCooldownSec)
2260
- { key: 'cmd_adventure', cmd: 'adventure', cdKey: 'cd_adventure', defaultCd: 300, priority: 3 },
2261
- { key: 'cmd_stream', cmd: 'stream', cdKey: 'cd_stream', defaultCd: 600, priority: 3 },
2262
- { key: 'cmd_work', cmd: 'work shift', cdKey: 'cd_work', defaultCd: 1800, priority: 3 },
2238
+ { key: 'cmd_adventure', cmd: 'adventure', cdKey: 'cd_adventure', defaultCd: 300, priority: 3 },
2239
+ { key: 'cmd_stream', cmd: 'stream', cdKey: 'cd_stream', defaultCd: 600, priority: 3 },
2240
+ // scratch removed requires voting which can't be automated
2241
+ { key: 'cmd_work', cmd: 'work shift', cdKey: 'cd_work', defaultCd: 1800, priority: 3 },
2263
2242
  // Time-gated (run ASAP when available)
2264
- { key: 'cmd_daily', cmd: 'daily', cdKey: 'cd_daily', defaultCd: 86400, priority: 10 },
2265
- // monthly — premium only
2266
- { key: 'cmd_monthly', cmd: 'monthly', cdKey: 'cd_monthly', defaultCd: 2592000, priority: 10 },
2243
+ { key: 'cmd_daily', cmd: 'daily', cdKey: 'cd_daily', defaultCd: 86400, priority: 10 },
2244
+ // weekly removed — premium only, not available for free users
2245
+ { key: 'cmd_monthly', cmd: 'monthly', cdKey: 'cd_monthly', defaultCd: 2592000,priority: 10 },
2267
2246
  // Financial safety
2268
- { key: 'cmd_deposit', cmd: 'dep max', cdKey: 'cd_deposit', defaultCd: 3600, priority: 8 },
2269
- { key: 'cmd_drops', cmd: 'drops', cdKey: 'cd_drops', defaultCd: 86400, priority: 2 },
2247
+ { key: 'cmd_deposit', cmd: 'dep max', cdKey: 'cd_deposit', defaultCd: 3600, priority: 8 },
2248
+ { key: 'cmd_drops', cmd: 'drops', cdKey: 'cd_drops', defaultCd: 86400, priority: 2 },
2270
2249
  // Alert is NOT scheduled — it's reactive (listener-based, see grindLoop)
2271
2250
  ].map(Object.freeze);
2272
2251
 
@@ -2543,58 +2522,91 @@ class AccountWorker {
2543
2522
  return;
2544
2523
  }
2545
2524
 
2546
- // ── Scan entire queue for ready commands ─────────────────────
2547
- // Previously we only peeked the top item, missing other commands that
2548
- // were already ready while a slower command was at the top of the heap.
2549
- // Now we drain the queue and separate ready vs. waiting commands.
2550
- // All ready commands fire immediately — no priority, FIFO order.
2551
- const readyItems = [];
2552
- const waitingItems = [];
2553
- while (this.commandQueue.size > 0) {
2554
- const item = this.commandQueue.pop();
2555
- if (!item) break;
2556
- if (item.nextRunAt <= now) {
2557
- readyItems.push(item);
2558
- } else {
2559
- waitingItems.push(item);
2560
- }
2525
+ const top = this.commandQueue.peek();
2526
+ if (top.nextRunAt > now) {
2527
+ const waitMs = Math.min(top.nextRunAt - now, 2000);
2528
+ this.setStatus('cooldown...');
2529
+ this.tickTimeout = setTimeout(() => this.tick(), waitMs);
2530
+ return;
2561
2531
  }
2562
2532
 
2563
- // Re-insert waiting items back into the queue (they still have time to wait)
2564
- for (const item of waitingItems) {
2565
- this.commandQueue.push(item);
2533
+ const item = this.commandQueue.pop();
2534
+ if (!item) {
2535
+ this.tickTimeout = setTimeout(() => this.tick(), 1000);
2536
+ return;
2566
2537
  }
2567
2538
 
2568
- if (readyItems.length === 0) {
2569
- // Nothing ready — wait until the soonest command becomes available.
2570
- let minWaitMs = 5000; // cap at 5s to stay responsive
2571
- for (const item of waitingItems) {
2572
- const diff = item.nextRunAt - now;
2573
- if (diff < minWaitMs) minWaitMs = diff;
2574
- }
2575
- this.setStatus('cooldown...');
2576
- this.tickTimeout = setTimeout(() => this.tick(), Math.max(100, Math.min(minWaitMs, 5000)));
2539
+ const ready = await this.isCooldownReady(item.cmd);
2540
+ if (!ready) {
2541
+ const cd = (this.account[item.info.cdKey] || item.info.defaultCd);
2542
+ item.nextRunAt = now + cd * 1000;
2543
+ if (this.commandQueue) this.commandQueue.push(item);
2544
+ this.tickTimeout = setTimeout(() => this.tick(), 100);
2577
2545
  return;
2578
2546
  }
2579
2547
 
2580
- // FIFO execute commands in the order they became ready.
2581
- // Commands that aren't picked this tick go back into the queue for next tick.
2582
- const item = readyItems[0];
2548
+ // Skip time-gated commands if already claimed (in-memory + Redis)
2549
+ if (item.cmd === 'daily' || item.cmd === 'weekly' || item.cmd === 'monthly' || item.cmd === 'drops') {
2550
+ const memExpiry = this.doneToday.get(item.cmd);
2551
+ if (memExpiry && Date.now() < memExpiry) {
2552
+ item.nextRunAt = memExpiry;
2553
+ if (this.commandQueue) this.commandQueue.push(item);
2554
+ this.tickTimeout = setTimeout(() => this.tick(), 100);
2555
+ return;
2556
+ }
2557
+ if (redis) {
2558
+ try {
2559
+ const done = await redis.get(`dkg:done:${this.account.id}:${item.cmd}`);
2560
+ if (done) {
2561
+ const ttlMap = { daily: 86400, weekly: 604800, monthly: 2592000, drops: 86400 };
2562
+ const ttl = ttlMap[item.cmd] || 86400;
2563
+ const expiry = now + ttl * 1000;
2564
+ this.doneToday.set(item.cmd, expiry);
2565
+ item.nextRunAt = expiry;
2566
+ if (this.commandQueue) this.commandQueue.push(item);
2567
+ this.tickTimeout = setTimeout(() => this.tick(), 100);
2568
+ return;
2569
+ }
2570
+ } catch {}
2571
+ }
2572
+ }
2583
2573
 
2584
- // Any remaining ready items that we didn't execute go back immediately
2585
- // so they run in the next tick without waiting for the slow top item.
2586
- for (let i = 1; i < readyItems.length; i++) {
2587
- this.commandQueue.push(readyItems[i]);
2574
+ // Smart gambling: skip gamble commands while loss-paused
2575
+ const GAMBLE_SET = new Set(['blackjack', 'cointoss', 'roulette', 'slots', 'snakeeyes']);
2576
+ if (GAMBLE_SET.has(item.cmd) && this._gamblePausedUntil > now) {
2577
+ item.nextRunAt = this._gamblePausedUntil;
2578
+ if (this.commandQueue) this.commandQueue.push(item);
2579
+ this.setStatus(`gamble paused (${Math.ceil((this._gamblePausedUntil - now) / 1000)}s)`);
2580
+ this.tickTimeout = setTimeout(() => this.tick(), 100);
2581
+ return;
2582
+ }
2583
+ if (GAMBLE_SET.has(item.cmd) && this._gamblePausedUntil > 0 && this._gamblePausedUntil <= now) {
2584
+ this._gamblePausedUntil = 0;
2585
+ this._gambleLossStreak = 0;
2586
+ this._gambleSessionLoss = 0;
2587
+ this.log('info', 'Gambling pause expired — resuming bets');
2588
+ }
2589
+
2590
+ // TokenBucket rate limiter: prevent Discord 429s by throttling commands
2591
+ if (!this._rateLimiter.consume(1)) {
2592
+ const waitMs = this._rateLimiter.waitTime(1);
2593
+ this.setStatus(`rate throttle (${Math.ceil(waitMs / 1000)}s)`);
2594
+ if (this.commandQueue) this.commandQueue.push(item);
2595
+ this.tickTimeout = setTimeout(() => this.tick(), waitMs);
2596
+ return;
2588
2597
  }
2589
2598
 
2590
- // Anti-detection: per-account jitter + micro-pauses for this command
2599
+ this._cmdRate.increment();
2600
+ this.busy = true;
2591
2601
  const cd = (this.account[item.info.cdKey] || item.info.defaultCd);
2602
+ // Anti-detection: per-account jitter with varying patterns
2592
2603
  const patternMod = this._activePattern;
2593
2604
  const jitterBase = cd <= 5
2594
2605
  ? 0.3 + Math.random() * 0.7
2595
2606
  : cd <= 20
2596
2607
  ? 0.5 + Math.random() * 1.5
2597
2608
  : 1 + Math.random() * 2;
2609
+ // Add human-like micro-pauses (occasionally take longer, simulating distraction)
2598
2610
  const microPause = Math.random() < 0.08 ? 1.5 + Math.random() * 3 : 0;
2599
2611
  const totalWait = cd + jitterBase + microPause;
2600
2612
 
@@ -2606,32 +2618,6 @@ class AccountWorker {
2606
2618
  await new Promise(r => setTimeout(r, minGap - timeSinceLastCmd));
2607
2619
  }
2608
2620
 
2609
- // TokenBucket rate limiter — prevent Discord 429s
2610
- if (!this._rateLimiter.consume(1)) {
2611
- const waitMs = this._rateLimiter.waitTime(1);
2612
- this.setStatus(`rate throttle (${Math.ceil(waitMs / 1000)}s)`);
2613
- if (this.commandQueue) this.commandQueue.push(item);
2614
- this.tickTimeout = setTimeout(() => this.tick(), waitMs);
2615
- return;
2616
- }
2617
-
2618
- // Startup delay: don't send commands for the first 30s after grindLoop() starts.
2619
- // This prevents flooding Dank Memer during the Phase 2 inventory check which
2620
- // sends pls inv for all accounts simultaneously after login.
2621
- if (this._startupDelayUntil && now < this._startupDelayUntil) {
2622
- const waitMs = this._startupDelayUntil - now;
2623
- this.setStatus('warming up...');
2624
- item.nextRunAt = now + waitMs + 1000;
2625
- if (this.commandQueue) this.commandQueue.push(item);
2626
- this.tickTimeout = setTimeout(() => this.tick(), waitMs + 1000);
2627
- return;
2628
- }
2629
-
2630
- this.busy = true;
2631
-
2632
- // ── Run command (with interactive retry) ───────────────────
2633
- // Commands run ONE BY ONE — sequential execution, no concurrency within this account.
2634
- // Each runCommand() call waits for Dank Memer's Discord response before returning.
2635
2621
  const prefix = this.account.use_slash ? '/' : 'pls';
2636
2622
  this.setStatus(formatCommandName(item.cmd));
2637
2623
 
@@ -2645,40 +2631,15 @@ class AccountWorker {
2645
2631
  next_run_at: nextItemRun?.nextRunAt || null,
2646
2632
  });
2647
2633
 
2648
- // Interactive commands (button-click): retry up to 3 times on failure.
2649
- // Non-interactive commands run once.
2650
- const INTERACTIVE_CMDS = new Set(['hl', 'blackjack', 'trivia', 'adventure', 'stream', 'fish', 'farm', 'work shift']);
2651
- const isInteractive = INTERACTIVE_CMDS.has(item.cmd);
2652
- const maxAttempts = isInteractive ? 3 : 1;
2653
- let attempt = 0;
2654
- let lastError = null;
2655
- let earned = 0;
2656
- while (attempt < maxAttempts) {
2657
- attempt++;
2658
- try {
2659
- const beforeCoins = this.stats.coins;
2660
- await this.runCommand(item.cmd, prefix);
2661
- earned = this.stats.coins - beforeCoins;
2662
- lastError = null;
2663
- this.lastCommandRun = Date.now();
2664
- break; // success
2665
- } catch (err) {
2666
- lastError = err;
2667
- this.log('warn', `${item.cmd} attempt ${attempt}/${maxAttempts} failed: ${err.message}`);
2668
- if (attempt < maxAttempts) {
2669
- await new Promise(r => setTimeout(r, 1000 + Math.random() * 1000));
2670
- }
2671
- }
2672
- }
2673
- if (lastError) {
2674
- this.log('error', `${item.cmd} failed after ${maxAttempts} attempts — skipping`);
2675
- }
2676
-
2677
- this._cmdRate.increment();
2634
+ const beforeCoins = this.stats.coins;
2635
+ await this.runCommand(item.cmd, prefix);
2636
+ const earned = this.stats.coins - beforeCoins;
2678
2637
 
2679
- // Grace period for interactive commands — Dank Memer needs time to process
2680
- // the interaction before accepting the next command.
2681
- if (isInteractive) {
2638
+ // Grace period for interactive (button-click) commands — Dank Memer
2639
+ // needs time to process the interaction before accepting the next command.
2640
+ // Without this, the next command gets "Hold Tight" errors.
2641
+ const INTERACTIVE_CMDS = new Set(['hl', 'blackjack', 'trivia', 'scratch', 'adventure', 'stream', 'fish', 'farm', 'work shift']);
2642
+ if (INTERACTIVE_CMDS.has(item.cmd)) {
2682
2643
  await new Promise(r => setTimeout(r, 2500 + Math.random() * 1500));
2683
2644
  }
2684
2645
 
@@ -2696,6 +2657,8 @@ class AccountWorker {
2696
2657
  this.failStreak = 0;
2697
2658
  }
2698
2659
 
2660
+ this.lastCommandRun = Date.now();
2661
+
2699
2662
  // Exponential backoff: if too many consecutive failures, slow down
2700
2663
  const backoffMultiplier = this.failStreak > 5 ? Math.min(this.failStreak - 4, 5) : 1;
2701
2664
  // Minimum 5s cooldown for failed commands to prevent rapid-fire retries
@@ -2767,11 +2730,6 @@ class AccountWorker {
2767
2730
  this.failStreak = 0;
2768
2731
  this.cycleCount = 0;
2769
2732
  this.lastCommandRun = 0;
2770
- // Delay first command by 30s to avoid competing with Phase 2 inventory check
2771
- // which sends pls inv for all accounts simultaneously after login.
2772
- // Without this, the grind loop floods Dank Memer with commands during the
2773
- // login surge, triggering rate-limits that cause Phase 2 inventory to fail.
2774
- this._startupDelayUntil = Date.now() + 30000;
2775
2733
  await this._loadLearnedCooldowns();
2776
2734
  this.commandQueue = await this.buildCommandQueue();
2777
2735
  this.lastHealthCheck = Date.now();
@@ -2875,9 +2833,7 @@ class AccountWorker {
2875
2833
  headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
2876
2834
  body: JSON.stringify({ action_id: action.id }),
2877
2835
  });
2878
- } catch (err) {
2879
- console.error(`[${this.username}] delete pending action error:`, err?.message || err);
2880
- }
2836
+ } catch {}
2881
2837
  }
2882
2838
  if (action.action === 'check_profile' && !this.busy) {
2883
2839
  this.log('info', 'Dashboard requested profile check');
@@ -2888,15 +2844,11 @@ class AccountWorker {
2888
2844
  headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
2889
2845
  body: JSON.stringify({ action_id: action.id }),
2890
2846
  });
2891
- } catch (err) {
2892
- console.error(`[${this.username}] delete pending action error:`, err?.message || err);
2893
- }
2847
+ } catch {}
2894
2848
  }
2895
2849
  }
2896
2850
  }
2897
- } catch (err) {
2898
- console.error(`[${this.username}] refreshConfig error:`, err?.message || err);
2899
- }
2851
+ } catch { /* silent */ }
2900
2852
  }
2901
2853
 
2902
2854
  async start() {
@@ -2946,8 +2898,9 @@ class AccountWorker {
2946
2898
  { key: 'cmd_fish', l: 'fish' }, { key: 'cmd_beg', l: 'beg' },
2947
2899
  { key: 'cmd_search', l: 'search' }, { key: 'cmd_hl', l: 'hl' },
2948
2900
  { key: 'cmd_crime', l: 'crime' }, { key: 'cmd_pm', l: 'pm' },
2949
- { key: 'cmd_daily', l: 'daily' }, { key: 'cmd_monthly', l: 'monthly' },
2950
- { key: 'cmd_work', l: 'work' }, { key: 'cmd_stream', l: 'stream' },
2901
+ { key: 'cmd_daily', l: 'daily' }, { key: 'cmd_weekly', l: 'weekly' },
2902
+ { key: 'cmd_monthly', l: 'monthly' }, { key: 'cmd_work', l: 'work' },
2903
+ { key: 'cmd_stream', l: 'stream' }, { key: 'cmd_scratch', l: 'scratch' },
2951
2904
  { key: 'cmd_adventure', l: 'adv' }, { key: 'cmd_farm', l: 'farm' },
2952
2905
  { key: 'cmd_tidy', l: 'tidy' }, { key: 'cmd_blackjack', l: 'bj' },
2953
2906
  { key: 'cmd_cointoss', l: 'toss' }, { key: 'cmd_roulette', l: 'roul' },
@@ -3172,38 +3125,19 @@ async function start(apiKey, apiUrl) {
3172
3125
  // Init rawLogger Redis (uses same URL — logs all raw gateway data)
3173
3126
  if (REDIS_URL) {
3174
3127
  rawLogger.init(REDIS_URL).catch(() => {});
3175
- // Live DM listener: detect deaths and level-ups in real-time across all accounts
3128
+ // Listen for DM death events across all accounts
3176
3129
  rawLogger.onDmEvent((event, raw) => {
3177
- const dmChannelId = raw.channel_id;
3178
-
3179
- // Find which worker owns this DM channel
3180
- const worker = workers.find(w => w._dmChannelId === dmChannelId);
3181
- if (!worker) return;
3182
-
3183
- if (event.type === 'death') {
3184
- const lsLeft = event.lifesaversLeft;
3185
-
3186
- if (lsLeft === 0) {
3187
- // 0 lifesavers — disable crime/search immediately
3188
- worker.log?.('error', `DEATH in DMs! 0 lifesavers — disabling crime/search`);
3189
- worker.setCooldown?.('crime', 86400);
3190
- worker.setCooldown?.('search', 86400);
3191
- worker._lifesavers = 0;
3192
- sendWebhook?.('DEATH ALERT (DM)', `**${worker.username}** died! **0 lifesavers!**\nCrime/search auto-disabled.`, 0xef4444);
3193
- } else if (lsLeft > 0) {
3194
- // Lifesaver(s) used — update count in real-time
3195
- worker._lifesavers = lsLeft;
3196
- worker.log?.('warn', `Lifesaver used! ${lsLeft} remaining.`);
3197
- if (lsLeft <= 2) {
3198
- sendWebhook?.('LOW LIFESAVERS', `**${worker.username}** died! Only **${lsLeft}** lifesaver(s) left!`, 0xfbbf24);
3130
+ if (event.type === 'death' && event.lifesaversLeft === 0) {
3131
+ const channelId = raw.channel_id;
3132
+ // Find which worker uses this DM channel and disable their crime/search
3133
+ for (const w of workers) {
3134
+ if (w.client?.user?.dmChannel?.id === channelId || w.channel?.id) {
3135
+ w.log?.('error', `DEATH in DMs! 0 lifesavers — disabling crime/search`);
3136
+ w.setCooldown?.('crime', 86400);
3137
+ w.setCooldown?.('search', 86400);
3199
3138
  }
3200
3139
  }
3201
- } else if (event.type === 'levelup') {
3202
- // Level up — update in-memory level
3203
- if (event.to > 0) {
3204
- worker._level = event.to;
3205
- worker.log?.('info', `Level up! Now level ${event.to}.`);
3206
- }
3140
+ sendWebhook?.('DEATH ALERT (DM)', `Account died! **0 lifesavers!**\nCrime/search auto-disabled.`, 0xef4444);
3207
3141
  }
3208
3142
  });
3209
3143
  checks.push(`${rgb(52, 211, 153)}✓${c.reset} ${c.white}RawLog${c.reset}`);
@@ -3217,21 +3151,39 @@ async function start(apiKey, apiUrl) {
3217
3151
  console.log(` ${checks.join(' ')}`);
3218
3152
  console.log('');
3219
3153
 
3220
- // ── Phase 1: Login — inline table with per-row updates ─────────
3154
+ // ── Phase 1: Login with per-account inline rendering ─────────────────────────
3221
3155
  const startupTw = process.stdout.columns || 90;
3222
- const colNum = 4;
3223
- const colSts = 3;
3156
+ const colNum = 4; // " #"
3157
+ const colSts = 3; // "ST"
3224
3158
  const colName = Math.min(24, Math.max(12, Math.floor(startupTw * 0.25)));
3225
3159
  const colGuild = Math.min(18, Math.max(8, Math.floor(startupTw * 0.2)));
3226
3160
  const colCmds = 8;
3227
3161
  const loginVis = colNum + colSts + colName + colGuild + colCmds + 10;
3228
3162
 
3229
- // Use DSR to find starting row, then use explicit row numbers for all table writes.
3230
- // This avoids relying on cursor tracking via \n which varies by terminal.
3231
- let tableTopRow = 1;
3232
- let pendingSet = new Set(Array.from({ length: accounts.length }, (_, i) => i));
3233
- const captureTopRow = () => new Promise(resolve => {
3234
- process.stdout.write('\x1b[6n');
3163
+ const loginStates = accounts.map((acc, i) => ({
3164
+ name: acc.label || acc.id || '?',
3165
+ done: false,
3166
+ failed: false,
3167
+ worker: null,
3168
+ }));
3169
+
3170
+ let loginLines = [];
3171
+ loginLines.push(` ${'─'.repeat(loginVis)}`);
3172
+ for (let i = 0; i < loginStates.length; i++) {
3173
+ const s = loginStates[i];
3174
+ const num = `${c.dim}${(i + 1).toString().padStart(colNum - 1)}${c.reset}`;
3175
+ const name = s.name.substring(0, colName).padEnd(colName);
3176
+ const guild = c.dim + '···'.padEnd(colGuild) + c.reset;
3177
+ const cmds = c.dim + '···'.padEnd(colCmds) + c.reset;
3178
+ loginLines.push(` ${num} ${c.dim}··${c.reset} ${name} ${guild} ${cmds}`);
3179
+ }
3180
+ loginLines.push(` ${'─'.repeat(loginVis)}`);
3181
+ for (const l of loginLines) console.log(l);
3182
+
3183
+ // Dynamically capture the starting row of the login table via DSR
3184
+ let loginBaseRow = 1;
3185
+ const captureLoginRow = () => new Promise(resolve => {
3186
+ process.stdout.write(MARKER);
3235
3187
  const chunks = [];
3236
3188
  const handler = (chunk) => {
3237
3189
  chunks.push(chunk);
@@ -3239,52 +3191,51 @@ async function start(apiKey, apiUrl) {
3239
3191
  const m = raw.match(/\x1b\[(\d+);\d+R/);
3240
3192
  if (m) {
3241
3193
  process.stdin.removeListener('data', handler);
3242
- tableTopRow = parseInt(m[1], 10);
3194
+ loginBaseRow = parseInt(m[1], 10) + 1;
3243
3195
  resolve();
3244
3196
  }
3245
3197
  };
3246
3198
  process.stdin.on('data', handler);
3247
3199
  setTimeout(resolve, 50);
3248
3200
  });
3249
- await captureTopRow();
3250
-
3251
- // Absolute row numbers for all table elements (calculated from captured top row)
3252
- const borderTopRow = tableTopRow; // border
3253
- const dataStartRow = tableTopRow + 1; // first account row
3254
- const borderBotRow = tableTopRow + accounts.length + 1; // bottom border
3255
- const bottomRow = borderBotRow + 1; // cursor final position after table
3256
-
3257
- // Print initial table using explicit row positioning
3258
- process.stdout.write(`\x1b[${borderTopRow};1H ${'─'.repeat(loginVis)}`);
3259
- for (let i = 0; i < accounts.length; i++) {
3260
- const row = dataStartRow + i;
3261
- const name = (accounts[i].label || accounts[i].id || '?').substring(0, colName).padEnd(colName);
3262
- const num = `${c.dim}${(i + 1).toString().padStart(colNum - 1)}${c.reset}`;
3263
- process.stdout.write(`\x1b[${row};1H ${num} ${c.dim}··${c.reset} ${name} ${c.dim}${'···'.padEnd(colGuild)}${c.reset} ${c.dim}${'···'.padEnd(colCmds)}${c.reset}\x1b[K`);
3264
- }
3265
- process.stdout.write(`\x1b[${borderBotRow};1H ${'─'.repeat(loginVis)}\x1b[K`);
3201
+ await captureLoginRow();
3202
+
3203
+ let loginPending = new Array(accounts.length).fill(true);
3204
+ const moveToRow = (row) => process.stdout.write(`\x1b[${row};1H`);
3266
3205
 
3267
- // Spinner: updates rows inline using absolute row numbers
3268
3206
  const drawLoginSpinners = () => {
3269
- for (const i of pendingSet) {
3207
+ for (let i = 0; i < loginPending.length; i++) {
3208
+ if (!loginPending[i]) continue;
3270
3209
  const spin = BRAILLE_SPIN[Math.floor(Date.now() / 80) % BRAILLE_SPIN.length];
3271
- const name = (accounts[i].label || accounts[i].id || '?').substring(0, colName).padEnd(colName);
3272
3210
  const num = `${c.dim}${(i + 1).toString().padStart(colNum - 1)}${c.reset}`;
3273
- process.stdout.write(`\x1b[${dataStartRow + i};1H ${num} ${rgb(139, 92, 246)}${spin}${c.reset} ${name} ${c.dim}${'logging in...'.substring(0, colGuild)}${c.reset} ${c.dim}${'···'.padEnd(colCmds)}${c.reset}\x1b[K`);
3274
- }
3211
+ const name = loginStates[i].name.substring(0, colName).padEnd(colName);
3212
+ const guild = c.dim + 'logging in...'.substring(0, colGuild) + c.reset;
3213
+ const cmds = c.dim + '···'.padEnd(colCmds) + c.reset;
3214
+ const row = loginBaseRow + 1 + i; // +1 skips the top border line
3215
+ moveToRow(row);
3216
+ process.stdout.write(` ${num} ${rgb(139, 92, 246)}${spin}${c.reset} ${name} ${guild} ${cmds}\x1b[K`);
3217
+ }
3218
+ // Move cursor back to bottom to avoid overwriting the bottom border
3219
+ const lastRow = loginBaseRow + 1 + accounts.length + 1;
3220
+ moveToRow(lastRow);
3275
3221
  };
3276
- const loginSpinner = setInterval(drawLoginSpinners, 80);
3222
+ const loginSpinnerInterval = setInterval(drawLoginSpinners, 80);
3223
+
3224
+ const finalizeLoginLine = (idx, worker) => {
3225
+ if (!loginPending[idx]) return;
3226
+ loginPending[idx] = false;
3227
+ const s = loginStates[idx];
3228
+ s.done = true;
3229
+ s.worker = worker;
3277
3230
 
3278
- const finalizeLoginRow = (idx, worker) => {
3279
- if (!pendingSet.has(idx)) return;
3280
- pendingSet.delete(idx);
3281
3231
  const num = `${c.dim}${(idx + 1).toString().padStart(colNum - 1)}${c.reset}`;
3282
- const name = (worker.username || accounts[idx].label || accounts[idx].id || '?').substring(0, colName).padEnd(colName);
3232
+ const name = (worker.username || s.name || '?').substring(0, colName).padEnd(colName);
3283
3233
  let sts, guild, cmds;
3284
3234
  if (worker._tokenInvalid) {
3285
3235
  sts = `${rgb(239, 68, 68)}✗${c.reset}`;
3286
3236
  guild = 'INVALID'.padEnd(colGuild);
3287
3237
  cmds = '···'.padEnd(colCmds);
3238
+ s.failed = true;
3288
3239
  } else if (worker.channel) {
3289
3240
  sts = `${rgb(52, 211, 153)}✓${c.reset}`;
3290
3241
  const gn = (worker.channel.guild?.name || worker.channel.guild?.id || 'DM').substring(0, colGuild);
@@ -3295,7 +3246,9 @@ async function start(apiKey, apiUrl) {
3295
3246
  guild = 'timeout'.padEnd(colGuild);
3296
3247
  cmds = '···'.padEnd(colCmds);
3297
3248
  }
3298
- process.stdout.write(`\x1b[${dataStartRow + idx};1H ${num} ${sts} ${name} ${c.dim}${guild}${c.reset} ${c.dim}${cmds}${c.reset}\x1b[K`);
3249
+ const row = loginBaseRow + 1 + idx; // +1 skips the top border line
3250
+ moveToRow(row);
3251
+ process.stdout.write(` ${num} ${sts} ${name} ${c.dim}${guild}${c.reset} ${c.dim}${cmds}${c.reset}\x1b[K`);
3299
3252
  };
3300
3253
 
3301
3254
  const parsedGapMin = Number.parseInt(String(process.env.LOGIN_GAP_MIN_MS || '50'), 10);
@@ -3313,34 +3266,58 @@ async function start(apiKey, apiUrl) {
3313
3266
  const worker = new AccountWorker(acc, i + idx);
3314
3267
  workers.push(worker);
3315
3268
  workerMap.set(acc.id, worker);
3269
+ loginStates[i + idx].worker = worker;
3316
3270
  await worker.start();
3317
- finalizeLoginRow(i + idx, worker);
3271
+ finalizeLoginLine(i + idx, worker);
3318
3272
  }));
3319
3273
  if (i + BATCH_SIZE < accounts.length) await new Promise(r => setTimeout(r, randomLoginGap()));
3320
3274
  hintGC();
3321
3275
  }
3322
3276
 
3323
- clearInterval(loginSpinner);
3277
+ clearInterval(loginSpinnerInterval);
3278
+ const loginDone = workers.filter(w => !w._tokenInvalid && w.channel).length;
3324
3279
  const invalidWorkers = workers.filter(w => w._tokenInvalid);
3325
3280
  const timedOutWorkers = workers.filter(w => !w.channel && !w._tokenInvalid);
3326
- const activeWorkers = workers.filter(w => !w._tokenInvalid);
3327
- const loginDone = activeWorkers.filter(w => w.channel).length;
3328
- // Clear bottom border row, move to new line, print login complete
3329
- process.stdout.write(`\x1b[${borderBotRow};1H\x1b[2K\n ${rgb(52, 211, 153)}✓${c.reset} ${c.bold}Login complete${c.reset} ${rgb(52, 211, 153)}${loginDone}${c.reset}${c.dim}/${c.reset}${c.white}${accounts.length}${c.reset} ${c.dim}accounts connected${c.reset}\n`);
3281
+ console.log(`\r\x1b[2K ${rgb(52, 211, 153)}✓${c.reset} ${c.bold}Login complete${c.reset} ${rgb(52, 211, 153)}${loginDone}${c.reset}${c.dim}/${c.reset}${c.white}${accounts.length}${c.reset} ${c.dim}accounts connected${c.reset}`);
3282
+ console.log('');
3330
3283
  if (invalidWorkers.length > 0) {
3331
- console.log(` ${rgb(239, 68, 68)}✗${c.reset} ${c.bold}${c.red}${invalidWorkers.length} INVALID token(s):${c.reset} ${invalidWorkers.map(w => w.account.label || w.account.id).join(', ')}`);
3284
+ log('warn', `${rgb(239, 68, 68)}${invalidWorkers.length} account(s) have INVALID tokens:${c.reset}`);
3285
+ for (const w of invalidWorkers) log('error', ` ✗ ${w.account.label || w.account.id} — token is invalid or expired`);
3286
+ console.log('');
3332
3287
  }
3333
3288
  if (timedOutWorkers.length > 0) log('warn', `${timedOutWorkers.length} account(s) timed out during login (will retry in background)`);
3334
- console.log('');
3335
3289
 
3336
- // ── Phase 2: Inventory check — clean sequential table ─────────
3290
+ const activeWorkers = workers.filter(w => !w._tokenInvalid);
3337
3291
 
3292
+ // ── Phase 2: Inventory check — spinner for pending count, results inline ─────────
3338
3293
  const iColNum = 4;
3339
3294
  const iColName = Math.min(22, Math.max(12, Math.floor(startupTw * 0.22)));
3340
3295
  const iColItems = 8;
3341
3296
  const iColVal = 16;
3342
3297
  const invVis = 7 + iColNum + iColName + iColItems + iColVal + 12;
3343
3298
 
3299
+ // Print a unique marker, query its position, then overwrite it with the table
3300
+ process.stdout.write(MARKER);
3301
+ let invBaseRow = 1;
3302
+ const captureRow = () => new Promise(resolve => {
3303
+ const chunks = [];
3304
+ const handler = (chunk) => {
3305
+ chunks.push(chunk);
3306
+ const raw = chunks.join('');
3307
+ const m = raw.match(/\x1b\[(\d+);\d+R/);
3308
+ if (m) {
3309
+ process.stdin.removeListener('data', handler);
3310
+ invBaseRow = parseInt(m[1], 10) + 1; // +1: first account row is after marker
3311
+ resolve();
3312
+ }
3313
+ };
3314
+ process.stdin.on('data', handler);
3315
+ setTimeout(resolve, 50);
3316
+ });
3317
+ await captureRow();
3318
+
3319
+ // Now print the inventory table starting at invBaseRow
3320
+ const invMoveToRow = (row) => process.stdout.write(`\x1b[${row};1H`);
3344
3321
  console.log(` ${'─'.repeat(invVis)}`);
3345
3322
  for (let i = 0; i < activeWorkers.length; i++) {
3346
3323
  const w = activeWorkers[i];
@@ -3350,31 +3327,40 @@ async function start(apiKey, apiUrl) {
3350
3327
  }
3351
3328
  console.log(` ${'─'.repeat(invVis)}`);
3352
3329
 
3353
- const invResults = await Promise.all(activeWorkers.map(async (w, i) => {
3354
- try {
3355
- return await w.checkInventory({ force: true, requireComplete: true, maxAttempts: 5, silent: true });
3356
- } catch {
3357
- return { ok: false };
3358
- }
3359
- }));
3330
+ let invDone = 0, invFailed = 0, invPending = activeWorkers.length;
3331
+ const drawInvProgress = () => {
3332
+ if (invPending === 0) return;
3333
+ const spin = BRAILLE_SPIN[Math.floor(Date.now() / 80) % BRAILLE_SPIN.length];
3334
+ const pct = activeWorkers.length > 0 ? ((activeWorkers.length - invPending) / activeWorkers.length) : 0;
3335
+ const barW = Math.min(20, startupTw - 40);
3336
+ const filled = Math.round(pct * barW);
3337
+ const bar = rgb(34, 211, 238) + '█'.repeat(filled) + rgb(50, 50, 70) + '░'.repeat(barW - filled) + c.reset;
3338
+ const pctStr = `${Math.round(pct * 100)}%`;
3339
+ invMoveToRow(invBaseRow);
3340
+ process.stdout.write(` ${rgb(34, 211, 238)}${spin}${c.reset} ${c.dim}Inventory...${c.reset} ${bar} ${c.bold}${rgb(52, 211, 153)}${activeWorkers.length - invPending}${c.reset}${c.dim}/${c.reset}${c.white}${activeWorkers.length}${c.reset} ${c.dim}${pctStr}${c.reset} \x1b[K`);
3341
+ };
3342
+ const invSpinnerInterval = setInterval(drawInvProgress, 80);
3360
3343
 
3361
- let invDone = 0, invFailed = 0;
3362
- // Re-print table with results
3363
- console.log(` ${'─'.repeat(invVis)}`);
3364
- for (let i = 0; i < activeWorkers.length; i++) {
3365
- const invRes = invResults[i] || { ok: false };
3366
- const w = activeWorkers[i];
3344
+ await Promise.all(activeWorkers.map(async (w, i) => {
3367
3345
  const num = `${c.dim}${(i + 1).toString().padStart(iColNum - 1)}${c.reset}`;
3368
3346
  const name = (w.username || w.account.label || '?').substring(0, iColName).padEnd(iColName);
3347
+ let invRes;
3348
+ try { invRes = await w.checkInventory({ force: true, requireComplete: true, maxAttempts: 3, silent: true }); }
3349
+ catch { invRes = { ok: false }; }
3350
+ invPending--;
3369
3351
  const items = invRes?.ok ? (invRes.result?.items?.length || 0) : 0;
3370
3352
  const val = invRes?.ok ? (invRes.result?.totalValue || 0) : 0;
3371
3353
  const sts = invRes?.ok ? `${rgb(52, 211, 153)}✓${c.reset}` : `${rgb(239, 68, 68)}✗${c.reset}`;
3372
3354
  const itemStr = `${items}`.padEnd(iColItems);
3373
3355
  const valStr = invRes?.ok ? `${c.green}⏣${val.toLocaleString()}${c.reset}` : `${c.dim}···${c.reset}`;
3374
- console.log(` ${num} ${sts} ${name} ${itemStr} ${valStr.padEnd(iColVal + 5)}`);
3356
+ const row = invBaseRow + 1 + i;
3357
+ invMoveToRow(row);
3358
+ process.stdout.write(` ${num} ${sts} ${name} ${itemStr} ${valStr.padEnd(iColVal + 5)}\x1b[K`);
3375
3359
  if (invRes?.ok) invDone++; else invFailed++;
3376
- }
3377
- console.log(` ${'─'.repeat(invVis)}`);
3360
+ }));
3361
+
3362
+ clearInterval(invSpinnerInterval);
3363
+ process.stdout.write(`\r\x1b[2K`);
3378
3364
 
3379
3365
  if (invFailed > 0) {
3380
3366
  console.log(` ${rgb(239, 68, 68)}✗${c.reset} ${c.bold}Inventory incomplete${c.reset} ${rgb(52, 211, 153)}${invDone}${c.reset}${c.dim}/${c.reset}${c.white}${activeWorkers.length}${c.reset} ${c.dim}done, ${rgb(239, 68, 68)}${invFailed} failed${c.reset}`);
@@ -3384,7 +3370,7 @@ async function start(apiKey, apiUrl) {
3384
3370
  console.log(` ${rgb(52, 211, 153)}✓${c.reset} ${c.bold}Inventory complete${c.reset} ${rgb(52, 211, 153)}${invDone}/${activeWorkers.length}${c.reset} ${c.dim}all clear${c.reset}`);
3385
3371
  console.log('');
3386
3372
 
3387
- // ── Phase 2.5: Balance check — clean sequential table ─────────
3373
+ // ── Phase 2.5: Balance check — inline table, single spinner for progress ─────────
3388
3374
  const bColNum = 4;
3389
3375
  const bColName = Math.min(22, Math.max(12, Math.floor(startupTw * 0.22)));
3390
3376
  const bColWallet = 12;
@@ -3393,6 +3379,27 @@ async function start(apiKey, apiUrl) {
3393
3379
  const bColLs = 4;
3394
3380
  const balVis = 7 + bColNum + bColName + bColWallet + bColBank + bColTotal + bColLs + 14;
3395
3381
 
3382
+ // Capture starting row for balance phase
3383
+ process.stdout.write(MARKER);
3384
+ let balBaseRow = 1;
3385
+ const balCaptureRow = () => new Promise(resolve => {
3386
+ const chunks = [];
3387
+ const handler = (chunk) => {
3388
+ chunks.push(chunk);
3389
+ const raw = chunks.join('');
3390
+ const m = raw.match(/\x1b\[(\d+);\d+R/);
3391
+ if (m) {
3392
+ process.stdin.removeListener('data', handler);
3393
+ balBaseRow = parseInt(m[1], 10) + 1;
3394
+ resolve();
3395
+ }
3396
+ };
3397
+ process.stdin.on('data', handler);
3398
+ setTimeout(resolve, 50);
3399
+ });
3400
+ await balCaptureRow();
3401
+
3402
+ const balMoveToRow = (row) => process.stdout.write(`\x1b[${row};1H`);
3396
3403
  console.log(` ${'─'.repeat(balVis)}`);
3397
3404
  for (let i = 0; i < activeWorkers.length; i++) {
3398
3405
  const w = activeWorkers[i];
@@ -3402,14 +3409,22 @@ async function start(apiKey, apiUrl) {
3402
3409
  }
3403
3410
  console.log(` ${'─'.repeat(balVis)}`);
3404
3411
 
3405
- await Promise.all(activeWorkers.map(async (w) => {
3406
- try { await w.checkBalance(true); } catch {}
3407
- }));
3412
+ let balDone = 0, balPending = activeWorkers.length;
3413
+ const drawBalProgress = () => {
3414
+ if (balPending === 0) return;
3415
+ const spin = BRAILLE_SPIN[Math.floor(Date.now() / 80) % BRAILLE_SPIN.length];
3416
+ const pct = activeWorkers.length > 0 ? ((activeWorkers.length - balPending) / activeWorkers.length) : 0;
3417
+ const barW = Math.min(20, startupTw - 40);
3418
+ const filled = Math.round(pct * barW);
3419
+ const bar = rgb(251, 191, 36) + '█'.repeat(filled) + rgb(50, 50, 70) + '░'.repeat(barW - filled) + c.reset;
3420
+ balMoveToRow(balBaseRow);
3421
+ process.stdout.write(` ${rgb(251, 191, 36)}${spin}${c.reset} ${c.dim}Balance...${c.reset} ${bar} ${c.bold}${rgb(52, 211, 153)}${activeWorkers.length - balPending}${c.reset}${c.dim}/${c.reset}${c.white}${activeWorkers.length}${c.reset} \x1b[K`);
3422
+ };
3423
+ const balSpinnerInterval = setInterval(drawBalProgress, 80);
3408
3424
 
3409
- // Re-print table with results
3410
- console.log(` ${'─'.repeat(balVis)}`);
3411
- for (let i = 0; i < activeWorkers.length; i++) {
3412
- const w = activeWorkers[i];
3425
+ await Promise.all(activeWorkers.map(async (w, i) => {
3426
+ try { await w.checkBalance(true); } catch {}
3427
+ balPending--;
3413
3428
  const num = `${c.dim}${(i + 1).toString().padStart(bColNum - 1)}${c.reset}`;
3414
3429
  const name = (w.username || w.account.label || '?').substring(0, bColName).padEnd(bColName);
3415
3430
  const wallet = w.stats?.balance || 0;
@@ -3419,9 +3434,14 @@ async function start(apiKey, apiUrl) {
3419
3434
  const walletStr = `${c.green}⏣${wallet.toLocaleString()}${c.reset}`;
3420
3435
  const bankStr = `${c.cyan}⏣${bank.toLocaleString()}${c.reset}`;
3421
3436
  const totalStr = `${c.bold}⏣${(wallet + bank).toLocaleString()}${c.reset}`;
3422
- console.log(` ${num} ${rgb(52, 211, 153)}✓${c.reset} ${name} ${walletStr.padEnd(bColWallet + 4)} ${bankStr.padEnd(bColBank + 4)} ${totalStr.padEnd(bColTotal + 3)} ${lsColor}♥${ls}${c.reset}`);
3423
- }
3424
- console.log(` ${'─'.repeat(balVis)}`);
3437
+ const row = balBaseRow + 1 + i;
3438
+ balMoveToRow(row);
3439
+ process.stdout.write(` ${num} ${rgb(52, 211, 153)}✓${c.reset} ${name} ${walletStr.padEnd(bColWallet + 4)} ${bankStr.padEnd(bColBank + 4)} ${totalStr.padEnd(bColTotal + 3)} ${lsColor}♥${ls}${c.reset}\x1b[K`);
3440
+ balDone++;
3441
+ }));
3442
+
3443
+ clearInterval(balSpinnerInterval);
3444
+ process.stdout.write(`\r\x1b[2K`);
3425
3445
 
3426
3446
  let totalWallet = 0, totalBank = 0, noLifesaverAccounts = [];
3427
3447
  for (const w of activeWorkers) {
@@ -3435,6 +3455,7 @@ async function start(apiKey, apiUrl) {
3435
3455
  }
3436
3456
  console.log('');
3437
3457
 
3458
+
3438
3459
  // Phase 2.75: Check DM history for deaths/level-ups (sequential, fast)
3439
3460
  console.log(` ${rgb(139, 92, 246)}${BRAILLE_SPIN[0]}${c.reset} ${c.dim}Checking DM history...${c.reset}`);
3440
3461
  let dmDeaths = 0, dmLevelUps = 0, dmNoLs = [];
package/lib/rawLogger.js CHANGED
@@ -151,26 +151,24 @@ function detectCommand(d) {
151
151
  // Non-gambling CV2
152
152
  if (cv2Text.includes('fishing') || cv2Text.includes('fisherfolk')) return 'fish';
153
153
  if (cv2Text.includes('deposit') || cv2Text.includes('bank account')) return 'deposit';
154
- if (cv2Text.includes('begging') || cv2Text.includes('imagine begging') || cv2Text.includes('wumpus gives you') || (cv2Text.includes('you received') && !cv2Text.includes('search') && !cv2Text.includes('hunt') && !cv2Text.includes('dig'))) return 'beg';
155
- if (cv2Text.includes('hunting') || cv2Text.includes('went hunting') || cv2Text.includes('hunting rifle') || cv2Text.includes('your aim was so bad') || cv2Text.includes('animals laughed') || cv2Text.includes('animals attacked') || cv2Text.includes('barely escaped') || cv2Text.includes('fell asleep in a tree') || cv2Text.includes('caught nothing') || cv2Text.includes('brought back literally nothing') || cv2Text.includes('rifle broke') || (cv2Text.includes('brought back') && (cv2Text.includes('deer') || cv2Text.includes('wolf') || cv2Text.includes('bear') || cv2Text.includes('boar') || cv2Text.includes('lion') || cv2Text.includes('rabbit') || cv2Text.includes('squirrel') || cv2Text.includes('moose') || cv2Text.includes('bird') || cv2Text.includes('elk') || cv2Text.includes('hunting') || cv2Text.includes('capybara')))) return 'hunt';
156
- if (cv2Text.includes('digging') || cv2Text.includes('found nothing while') || cv2Text.includes('you dig') || cv2Text.includes('dug in the dirt') || (cv2Text.includes('brought back') && (cv2Text.includes('ant') || cv2Text.includes('worm') || cv2Text.includes('stickbug') || cv2Text.includes('ladybug')))) return 'dig';
154
+ if (cv2Text.includes('begging') || cv2Text.includes('imagine begging')) return 'beg';
155
+ if (cv2Text.includes('hunting') || cv2Text.includes('went hunting') || cv2Text.includes('hunting rifle') || cv2Text.includes('your aim was so bad') || cv2Text.includes('animals laughed') || cv2Text.includes('animals attacked') || cv2Text.includes('barely escaped') || cv2Text.includes('fell asleep in a tree') || cv2Text.includes('caught nothing') || cv2Text.includes('brought back literally nothing') || cv2Text.includes('rifle broke')) return 'hunt';
156
+ if (cv2Text.includes('digging') || cv2Text.includes('found nothing while') || cv2Text.includes('you dig') || cv2Text.includes('dug in the dirt') || cv2Text.includes('brought back') && (cv2Text.includes('ant') || cv2Text.includes('worm') || cv2Text.includes('stickbug') || cv2Text.includes('ladybug'))) return 'dig';
157
157
  if (cv2Text.includes('great work') || cv2Text.includes('for your shift') || cv2Text.includes('working as') || cv2Text.includes('work shift') || cv2Text.includes('what color was') || cv2Text.includes('remember words order') || cv2Text.includes('remember the colors') || cv2Text.includes('remember the emojis') || cv2Text.includes('what word was repeated') || cv2Text.includes('unscramble') || cv2Text.includes('remember the number') || cv2Text.includes('click the buttons in correct order') || cv2Text.includes('babysitter') || cv2Text.includes('click the matching')) return 'work';
158
- // Quest completions (before generic daily/weekly)
159
- if (cv2Text.includes('locations') && (cv2Text.includes('inventory locations') || cv2Text.includes('found locations') || cv2Text.includes('completed your') || cv2Text.includes('locations remaining'))) return 'search';
160
158
  if (cv2Text.includes('weekly')) return 'weekly';
161
159
  if (cv2Text.includes('daily')) return 'daily';
162
- // Only match inventory if it looks like an actual inventory display (header format or sellable indicator)
163
- if (cv2Text.includes('inventory') && (cv2Text.includes('###') || cv2Text.includes('sellable') || cv2Text.includes('<:reply:'))) return 'inventory';
160
+ if (cv2Text.includes('inventory')) return 'inventory';
164
161
  if (cv2Text.includes('profile') || cv2Text.includes('level:')) return 'profile';
165
162
  if (cv2Text.includes('balances') && cv2Text.includes('global rank')) return 'balance';
166
163
 
167
164
  // Check content text (plain message content)
168
165
  const contentText = (d.content || '').toLowerCase();
169
166
  if (contentText.includes('balances') && contentText.includes('global rank')) return 'balance';
170
- if (contentText.includes('your aim was so bad') || contentText.includes('animals laughed') || contentText.includes('imagine going into the woods')) return 'hunt';
167
+ if (contentText.includes('your aim was so bad') || contentText.includes('animals laughed')) return 'hunt';
168
+ if (contentText.includes('imagine going into the woods')) return 'hunt';
171
169
  if (contentText.includes('you ran an ad for') && contentText.includes('received')) return 'stream';
172
170
  if (contentText.includes('you can\'t interact with your stream')) return 'stream';
173
- if (contentText.includes('you dug in the dirt') || (contentText.includes('found nothing while digging') && (contentText.includes('dug') || contentText.includes('dirt')))) return 'dig';
171
+ if (contentText.includes('you dug in the dirt') || contentText.includes('found nothing while digging')) return 'dig';
174
172
 
175
173
  // Check embed text
176
174
  const embedText = extractEmbedText(d.embeds).toLowerCase();
@@ -199,8 +197,8 @@ function detectCommand(d) {
199
197
  return 'search';
200
198
  }
201
199
  // Hunt / dig
202
- if (embedText.includes('hunting') || embedText.includes('came back with') || embedText.includes('hunting rifle') || embedText.includes('dragon\'s fireball') || embedText.includes('dodge the') || embedText.includes('went hunting') || embedText.includes('your aim was so bad') || embedText.includes('animals laughed') || embedText.includes('animals attacked') || embedText.includes('barely escaped') || embedText.includes('fell asleep in a tree') || embedText.includes('caught nothing') || embedText.includes('brought back literally nothing') || embedText.includes('rifle broke') || embedText.includes('imagine going into the woods') || (embedText.includes('brought back') && (embedText.includes('deer') || embedText.includes('wolf') || embedText.includes('bear') || embedText.includes('boar') || embedText.includes('lion') || embedText.includes('rabbit') || embedText.includes('squirrel') || embedText.includes('moose') || embedText.includes('bird') || embedText.includes('elk') || embedText.includes('capybara')))) return 'hunt';
203
- if (embedText.includes('digging') || embedText.includes('you dig') || embedText.includes('dug in the dirt') || embedText.includes('found nothing while') || embedText.includes('what are the odds lol') || (embedText.includes('brought back') && (embedText.includes('ant') || embedText.includes('worm') || embedText.includes('stickbug') || embedText.includes('ladybug')))) return 'dig';
200
+ if (embedText.includes('hunting') || embedText.includes('came back with') || embedText.includes('hunting rifle') || embedText.includes('dragon\'s fireball') || embedText.includes('dodge the') || embedText.includes('went hunting') || embedText.includes('your aim was so bad') || embedText.includes('animals laughed') || embedText.includes('animals attacked') || embedText.includes('barely escaped') || embedText.includes('fell asleep in a tree') || embedText.includes('caught nothing') || embedText.includes('brought back literally nothing') || embedText.includes('rifle broke') || embedText.includes('imagine going into the woods')) return 'hunt';
201
+ if (embedText.includes('digging') || embedText.includes('you dig') || embedText.includes('dug in the dirt') || embedText.includes('found nothing while') || embedText.includes('what are the odds lol') || embedText.includes('brought back') && (embedText.includes('ant') || embedText.includes('worm') || embedText.includes('stickbug') || embedText.includes('ladybug'))) return 'dig';
204
202
  // Work — match both minigame prompt AND completion
205
203
  if (embedText.includes('work') && (embedText.includes('shift') || embedText.includes('mini-game') || embedText.includes('color') || embedText.includes('what color') || embedText.includes('babysitter') || embedText.includes('great work') || embedText.includes('for your shift'))) return 'work';
206
204
  if (embedText.includes('you were given') && embedText.includes('shift')) return 'work';
@@ -209,7 +207,7 @@ function detectCommand(d) {
209
207
  // Postmemes
210
208
  if (embedText.includes('pick a meme') || embedText.includes('meme posting')) return 'postmemes';
211
209
  // Stream
212
- if (embedText.includes('stream manager') || embedText.includes('go live') || embedText.includes('what game do you want to stream') || embedText.includes('you ran an ad for') || (embedText.includes('you received') && embedText.includes('from your sponsors')) || (embedText.includes('### chat') && embedText.includes('hasanbabi'))) return 'stream';
210
+ if (embedText.includes('stream manager') || embedText.includes('go live') || embedText.includes('what game do you want to stream') || embedText.includes('you ran an ad for') || embedText.includes('you received') && embedText.includes('from your sponsors') || embedText.includes('### chat') && embedText.includes('hasanbabi')) return 'stream';
213
211
  if (embedText.includes('you can\'t interact with your stream') || embedText.includes('stream can last')) return 'stream';
214
212
  // Deposit
215
213
  if (embedText.includes('deposited') && embedText.includes('bank balance')) return 'deposit';
@@ -230,7 +228,7 @@ function detectCommand(d) {
230
228
  // Farm
231
229
  if (embedText.includes('farm') && (embedText.includes('harvest') || embedText.includes('plant') || embedText.includes('hoe') || embedText.includes('water'))) return 'farm';
232
230
  // Beg
233
- if (embedText.includes('begging') || embedText.includes('wumpus gives you') || embedText.includes('life saver') || embedText.includes('lifesaver')) return 'beg';
231
+ if (embedText.includes('begging')) return 'beg';
234
232
  // Daily/weekly quest
235
233
  if (embedText.includes('daily quest')) return 'daily';
236
234
  // Fish
package/lib/structures.js CHANGED
@@ -443,25 +443,6 @@ class MinHeap {
443
443
 
444
444
  peek() { return this.heap[0]; }
445
445
 
446
- /**
447
- * Remove a specific node from the heap by reference.
448
- * Used by CommandScheduler.unschedule() to cancel a pending scheduled command.
449
- * O(n) — linear scan since we store node references in a Map.
450
- */
451
- remove(node) {
452
- const idx = this.heap.indexOf(node);
453
- if (idx === -1) return false;
454
- const last = this.heap.pop();
455
- if (idx < this.heap.length) {
456
- this.heap[idx] = last;
457
- // Bubble up or sink down depending on where the removed item was
458
- if (this._bubbleUp(idx) === idx) {
459
- this._sinkDown(idx);
460
- }
461
- }
462
- return true;
463
- }
464
-
465
446
  _bubbleUp(i) {
466
447
  while (i > 0) {
467
448
  const parent = (i - 1) >>> 1;
@@ -699,6 +680,31 @@ class AsyncBatchQueue {
699
680
  destroy() { if (this._timer) clearTimeout(this._timer); this.queue.length = 0; }
700
681
  }
701
682
 
683
+ // ═══════════════════════════════════════════════════════════════
684
+ // JitterBackoff – Decorrelated jitter (AWS-style), O(1)
685
+ // Standard exponential backoff causes "thundering herd" when 10K
686
+ // accounts retry simultaneously. Decorrelated jitter spreads them:
687
+ // sleep = min(cap, random_between(base, sleep_prev * 3))
688
+ // This is the recommended strategy from AWS Architecture Blog.
689
+ // ═══════════════════════════════════════════════════════════════
690
+ class JitterBackoff {
691
+ constructor(baseMs = 1000, capMs = 30000) {
692
+ this.baseMs = baseMs;
693
+ this.capMs = capMs;
694
+ this._sleep = baseMs;
695
+ this.attempt = 0;
696
+ }
697
+
698
+ next() {
699
+ this._sleep = Math.min(this.capMs, this.baseMs + Math.random() * (this._sleep * 3 - this.baseMs));
700
+ this.attempt++;
701
+ return this._sleep;
702
+ }
703
+
704
+ reset() { this._sleep = this.baseMs; this.attempt = 0; }
705
+ get current() { return this._sleep; }
706
+ }
707
+
702
708
  module.exports = {
703
709
  BloomFilter,
704
710
  LRUCache,
@@ -715,4 +721,5 @@ module.exports = {
715
721
  ObjectPool,
716
722
  TimerWheel,
717
723
  AsyncBatchQueue,
724
+ JitterBackoff,
718
725
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dankgrinder",
3
- "version": "6.46.0",
3
+ "version": "7.1.0",
4
4
  "description": "Dank Memer automation engine — grind coins while you sleep",
5
5
  "bin": {
6
6
  "dankgrinder": "bin/dankgrinder.js"