dankgrinder 6.46.0 → 7.6.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/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 = [];