dankgrinder 6.17.0 → 6.21.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
@@ -487,7 +487,7 @@ function renderDashboard() {
487
487
  lines.push(bEmpty);
488
488
 
489
489
  // ═══════════════════════════════════════════════════════════════
490
- // STATS PANEL
490
+ // STATS PANEL (split: left = metrics, right = big trend)
491
491
  // ═══════════════════════════════════════════════════════════════
492
492
  lines.push(bSep);
493
493
  lines.push(bEmpty);
@@ -495,32 +495,44 @@ function renderDashboard() {
495
495
  // Earnings sparkline data
496
496
  const now = Date.now();
497
497
  if (now - lastEarningsSample > 8000) { earningsHistory.push(totalCoins); lastEarningsSample = now; }
498
- const sparkW = Math.min(30, Math.floor(iw * 0.3));
499
- const spark = drawSparkline(earningsHistory.toArray(), sparkW);
500
-
501
- // Row 1: Balance + Earned
502
498
  const elapsedHrs = (Date.now() - startTime) / 3_600_000;
503
499
  const perHr = elapsedHrs > 0.01 ? Math.round(totalCoins / elapsedHrs) : 0;
504
500
  const peakFlag = isNewHigh ? ` ${R}${c.bold}* NEW HIGH *${c.reset}` : '';
505
501
 
506
- lines.push(bRow(` ${Au}⟐${c.reset} ${D}BALANCE${c.reset} ${c.bold}${Au}⏣ ${formatCoins(totalBalance)}${c.reset} ${G}▲${c.reset} ${D}EARNED${c.reset} ${c.bold}${G}+⏣ ${formatCoins(totalCoins)}${c.reset} ${D}(${c.reset}${G}${formatCoins(perHr)}/h${c.reset}${D})${c.reset}${peakFlag}`));
507
-
508
- // Row 2: Peak + Trend sparkline
509
- lines.push(bRow(` ${O}★${c.reset} ${D}PEAK${c.reset} ${c.bold}${O}⏣ ${formatCoins(sessionPeakCoins)}${c.reset} ${A}~${c.reset} ${D}TREND${c.reset} ${spark}`));
510
-
511
- // Row 3: Commands + Success + Rate + Uptime
502
+ // Left column: fixed metrics (left-aligned)
512
503
  const cpmVal = globalCmdRate.getRate().toFixed(1);
513
504
  const srColor = successRate >= 95 ? G : successRate >= 80 ? Y : R;
514
- const srBarW = Math.min(15, Math.floor(iw * 0.12));
505
+ const srBarW = Math.min(15, Math.floor(iw * 0.1));
515
506
  const srBar = progressBar(successRate, 100, srBarW, successRate >= 95 ? [52, 211, 153] : successRate >= 80 ? [251, 191, 36] : [239, 68, 68]);
516
- lines.push(bRow(` ${B}◆${c.reset} ${D}CMDS${c.reset} ${c.bold}${B}${totalCommands}${c.reset} ${srColor}${successRate}%${c.reset} ${srBar} ${Cy}${cpmVal}${c.reset}${D}/min${c.reset} ${Y}◷${c.reset} ${D}UP${c.reset} ${c.bold}${Y}${formatUptime()}${c.reset}`));
517
-
518
- // Row 4: Memory
519
507
  const memMB = Math.round((process.memoryUsage?.rss?.() ?? process.memoryUsage().rss) / 1048576);
520
508
  const memCol = memMB > 900 ? [239, 68, 68] : memMB > 600 ? [251, 191, 36] : [52, 211, 153];
521
- const memBarW = Math.min(20, Math.floor(iw * 0.15));
509
+ const memBarW = Math.min(15, Math.floor(iw * 0.1));
522
510
  const memBar = progressBar(memMB, 1024, memBarW, memCol, [40, 40, 55]);
523
- lines.push(bRow(` ${D}≡${c.reset} ${D}MEM${c.reset} ${rgb(memCol[0], memCol[1], memCol[2])}${c.bold}${memMB}MB${c.reset} ${memBar}`));
511
+
512
+ // Right column: big trend sparkline
513
+ const sparkW = Math.floor(iw * 0.42);
514
+ const spark = drawSparkline(earningsHistory.toArray(), sparkW);
515
+
516
+ // Build split rows
517
+ const leftHalf = Math.floor(iw * 0.52);
518
+
519
+ // Row 1: Balance + EARNED | TREND (big sparkline)
520
+ const leftRow1 = `${Au}⟐${c.reset} ${D}BALANCE${c.reset} ${c.bold}${Au}⏣ ${formatCoins(totalBalance)}${c.reset} ${G}▲${c.reset} ${D}EARNED${c.reset} ${c.bold}${G}+⏣ ${formatCoins(totalCoins)}${c.reset}${peakFlag}`;
521
+ const rightRow1 = `${A}~${c.reset} ${D}TREND${c.reset} ${spark}`;
522
+ const combined1 = `${leftRow1}${' '.repeat(Math.max(2, leftHalf - leftRow1.replace(RE, '').length))}${rightRow1}`;
523
+ lines.push(bRow(` ${combined1}`));
524
+
525
+ // Row 2: Peak (left)
526
+ const peakRow = `${O}★${c.reset} ${D}PEAK${c.reset} ${c.bold}${O}⏣ ${formatCoins(sessionPeakCoins)}${c.reset}`;
527
+ lines.push(bRow(` ${peakRow}${' '.repeat(Math.max(2, leftHalf - peakRow.replace(RE, '').length))}${Y}◷${c.reset} ${D}UP${c.reset} ${c.bold}${Y}${formatUptime()}${c.reset}`));
528
+
529
+ // Row 3: CMDS (left) + success bar + rate
530
+ const cmdsRow = `${B}◆${c.reset} ${D}CMDS${c.reset} ${c.bold}${B}${totalCommands}${c.reset} ${srColor}${successRate}%${c.reset} ${srBar} ${Cy}${cpmVal}${c.reset}${D}/min${c.reset}`;
531
+ lines.push(bRow(` ${cmdsRow}`));
532
+
533
+ // Row 4: MEM (left)
534
+ const memRow = `${D}≡${c.reset} ${D}MEM${c.reset} ${rgb(memCol[0], memCol[1], memCol[2])}${c.bold}${memMB}MB${c.reset} ${memBar}`;
535
+ lines.push(bRow(` ${memRow}`));
524
536
 
525
537
  lines.push(bEmpty);
526
538
 
@@ -1499,7 +1511,7 @@ class AccountWorker {
1499
1511
  }
1500
1512
  }
1501
1513
 
1502
- async checkBalance() {
1514
+ async checkBalance(silent = false) {
1503
1515
  const prefix = this.account.use_slash ? '/' : 'pls';
1504
1516
  const sentAt = Date.now();
1505
1517
 
@@ -1507,219 +1519,129 @@ class AccountWorker {
1507
1519
  if (!t) return false;
1508
1520
  const lower = t.toLowerCase();
1509
1521
  return lower.includes('balance') || lower.includes('balances') || lower.includes('global rank')
1510
- || lower.includes('wallet') || /<a?:coin:\d+>/.test(t) || /<a?:bank:\d+>/.test(t);
1511
- };
1512
-
1513
- const readBalanceText = async (msg, forceCV2 = false) => {
1514
- if (!msg) return '';
1515
- const needsCv2 = forceCV2
1516
- || isCV2(msg)
1517
- || (Array.isArray(msg.components) && msg.components.length > 0
1518
- && (!msg.content || msg.content.length === 0)
1519
- && (!msg.embeds || msg.embeds.length === 0));
1520
- if (needsCv2) await ensureCV2(msg, forceCV2);
1521
- return stripAnsi(getFullText(msg)).replace(/\s+/g, ' ').trim();
1522
- };
1523
-
1524
- const findRecentBalanceMessage = async () => {
1525
- if (!this.channel?.messages?.fetch) return null;
1526
- for (let attempt = 0; attempt < 6; attempt++) {
1527
- try {
1528
- const recent = await this.channel.messages.fetch({ limit: 12 });
1529
- const candidates = [...recent.values()].filter((m) =>
1530
- m?.author?.id === DANK_MEMER_ID && (m.createdTimestamp || 0) >= sentAt - 10000
1531
- );
1532
- for (const m of candidates) {
1533
- const t = await readBalanceText(m, true);
1534
- if (looksLikeBalance(t)) return m;
1535
- }
1536
- } catch {}
1537
- await new Promise((r) => setTimeout(r, 700));
1538
- }
1539
- return null;
1522
+ || lower.includes('wallet') || /<a?:coin:\d+>/i.test(t) || /<a?:bank:\d+>/i.test(t);
1540
1523
  };
1541
1524
 
1525
+ // Fast path: send command, wait 3s, read from rawLogger
1542
1526
  if (this.account.use_slash && this.channel?.sendSlash) {
1543
1527
  await this.channel.sendSlash(DANK_MEMER_ID, 'balance').catch(() => this.channel.send('/balance'));
1544
1528
  } else {
1545
1529
  await this.channel.send(`${prefix} bal`);
1546
1530
  }
1547
- let response = await this.waitForDankMemer(12000);
1531
+ await new Promise(r => setTimeout(r, 3000));
1548
1532
 
1549
- // Fallback for slash setup: try legacy prefix if no slash response.
1550
- if (!response && this.account.use_slash) {
1551
- await this.channel.send('pls bal');
1552
- response = await this.waitForDankMemer(12000);
1533
+ // Try rawLogger first it captures CV2 text from gateway instantly
1534
+ let text = '';
1535
+ const rawData = rawLogger.getLastRaw(this.channel?.id);
1536
+ if (rawData && rawData.cv2Text && looksLikeBalance(rawData.cv2Text)) {
1537
+ text = rawData.cv2Text;
1538
+ } else if (rawData && rawData.allText && looksLikeBalance(rawData.allText)) {
1539
+ text = rawData.allText;
1553
1540
  }
1554
1541
 
1555
- if (response) {
1556
- let text = await readBalanceText(response);
1557
-
1558
- // Dank Memer sometimes sends empty first payload then edits in the full card.
1559
- if ((!text || !looksLikeBalance(text)) && response.id) {
1560
- const edited = await this.waitForMessageUpdate(response.id, 8000);
1561
- if (edited) {
1562
- text = await readBalanceText(edited, true);
1563
- response = edited;
1564
- }
1542
+ // If rawLogger didn't capture it, fall back to waitForDankMemer
1543
+ if (!text || !looksLikeBalance(text)) {
1544
+ let response = await this.waitForDankMemer(8000);
1545
+ if (!response && this.account.use_slash) {
1546
+ await this.channel.send('pls bal');
1547
+ response = await this.waitForDankMemer(8000);
1565
1548
  }
1566
-
1567
- // If we received a stale/irrelevant update, fetch same message fresh.
1568
- if ((!text || !looksLikeBalance(text)) && response.id && this.channel?.messages?.fetch) {
1569
- const fetched = await Promise.resolve(this.channel.messages.fetch(response.id)).catch(() => null);
1570
- if (fetched) {
1571
- const fetchedText = await readBalanceText(fetched, true);
1572
- if (fetchedText) {
1573
- text = fetchedText;
1574
- response = fetched;
1575
- }
1576
- }
1549
+ if (response) {
1550
+ if (isCV2(response)) await ensureCV2(response);
1551
+ text = stripAnsi(getFullText(response)).replace(/\s+/g, ' ').trim();
1577
1552
  }
1578
-
1579
- // Fallback: scan latest Dank messages right after command send.
1553
+ // One more rawLogger check after the wait
1580
1554
  if (!text || !looksLikeBalance(text)) {
1581
- const recentBalance = await findRecentBalanceMessage();
1582
- if (recentBalance) {
1583
- text = await readBalanceText(recentBalance, true);
1584
- response = recentBalance;
1585
- }
1586
- }
1587
-
1588
- // Last resort: wait for CV2 content propagation then re-fetch
1589
- if ((!text || !looksLikeBalance(text)) && response.id && this.channel?.messages?.fetch) {
1590
- await new Promise(r => setTimeout(r, 3000));
1591
- try {
1592
- const fresh = await this.channel.messages.fetch(response.id);
1593
- if (fresh) {
1594
- const freshText = await readBalanceText(fresh, true);
1595
- if (freshText && looksLikeBalance(freshText)) {
1596
- text = freshText;
1597
- response = fresh;
1598
- }
1599
- }
1600
- } catch {}
1601
- }
1602
-
1603
- // Absolute last: re-scan channel messages after the extra wait
1604
- if (!text || !looksLikeBalance(text)) {
1605
- const recentBalance2 = await findRecentBalanceMessage();
1606
- if (recentBalance2) {
1607
- text = await readBalanceText(recentBalance2, true);
1608
- response = recentBalance2;
1609
- }
1610
- }
1611
-
1612
- // Raw logger fallback — CV2 text is captured directly from gateway
1613
- if (!text || !looksLikeBalance(text)) {
1614
- const rawData = rawLogger.getLastRaw(this.channel?.id);
1615
- if (rawData && rawData.cv2Text) {
1616
- const rawText = rawData.cv2Text;
1617
- if (looksLikeBalance(rawText)) {
1618
- text = rawText;
1619
- this.log('debug', 'Balance: using rawLogger CV2 text fallback');
1620
- }
1621
- }
1622
- // Also try from Redis raw message
1623
- if ((!text || !looksLikeBalance(text)) && response?.id) {
1624
- try {
1625
- const rawMsg = await rawLogger.getMsg(response.id);
1626
- if (rawMsg?.allText && looksLikeBalance(rawMsg.allText)) {
1627
- text = rawMsg.allText;
1628
- this.log('debug', 'Balance: using rawLogger Redis fallback');
1629
- }
1630
- } catch {}
1631
- }
1632
- }
1633
-
1634
- if (!text) {
1635
- this.log('warn', 'Balance response was empty after waiting for update');
1636
- return;
1637
- }
1638
-
1639
- if (!looksLikeBalance(text)) {
1640
- this.log('warn', `Balance response did not look like balance card: "${text.substring(0, 140)}"`);
1641
- return;
1555
+ const raw2 = rawLogger.getLastRaw(this.channel?.id);
1556
+ if (raw2?.cv2Text && looksLikeBalance(raw2.cv2Text)) text = raw2.cv2Text;
1557
+ else if (raw2?.allText && looksLikeBalance(raw2.allText)) text = raw2.allText;
1642
1558
  }
1559
+ }
1643
1560
 
1644
- let wallet = 0;
1645
- let bank = 0;
1646
- let matched = '';
1561
+ if (!text || !looksLikeBalance(text)) {
1562
+ this.log('warn', 'Balance: no data after all attempts');
1563
+ return;
1564
+ }
1647
1565
 
1648
- // CV2 format: <:Coin:ID> 3,272,896 and <:Bank:ID> 275,000 / 275,000
1566
+ // Parse wallet and bank
1567
+ let wallet = 0, bank = 0, matched = '';
1649
1568
  const coinMatch = text.match(/<a?:Coin:\d+>\s*([\d,]+)/i);
1650
1569
  const bankEmojiMatch = text.match(/<a?:Bank:\d+>\s*([\d,]+)/i);
1651
1570
  const bankSlashMatch = text.match(/(?:<a?:Bank:\d+>\s*)?([\d,]+)\s*\/\s*[\d,]+/i);
1652
1571
 
1653
- // Legacy embed format: Wallet: ⏣ 1,234,567
1654
- const walletMatch = text.match(/wallet[:\s]*\*{0,2}\s*[⏣💰]?\s*\*{0,2}\s*([\d,]+)/i);
1655
- const bankTextMatch = text.match(/bank[:\s]*\*{0,2}\s*[⏣💰]?\s*\*{0,2}\s*([\d,]+)/i);
1572
+ // Legacy embed format: Wallet: ⏣ 1,234,567
1573
+ const walletMatch = text.match(/wallet[:\s]*\*{0,2}\s*[⏣💰]?\s*\*{0,2}\s*([\d,]+)/i);
1574
+ const bankTextMatch = text.match(/bank[:\s]*\*{0,2}\s*[⏣💰]?\s*\*{0,2}\s*([\d,]+)/i);
1656
1575
 
1657
- // Fallback: any numbers near ⏣ or just plain numbers in CV2 text
1658
- const allNums = [...text.matchAll(/(?:⏣\s*)?(\d[\d,]*\d)/g)].map(m => parseInt(m[1].replace(/,/g, ''), 10)).filter(n => n > 0);
1576
+ // Fallback: any numbers near ⏣ or just plain numbers in CV2 text
1577
+ const allNums = [...text.matchAll(/(?:⏣\s*)?(\d[\d,]*\d)/g)].map(m => parseInt(m[1].replace(/,/g, ''), 10)).filter(n => n > 0);
1659
1578
 
1660
- if (coinMatch) {
1661
- wallet = parseInt(coinMatch[1].replace(/,/g, ''), 10);
1662
- matched = 'cv2-emoji';
1663
- } else if (walletMatch) {
1664
- wallet = parseInt(walletMatch[1].replace(/,/g, ''), 10);
1665
- matched = 'legacy-wallet';
1666
- } else if (allNums.length > 0) {
1667
- wallet = Math.max(...allNums);
1668
- matched = 'fallback-nums';
1669
- }
1579
+ if (coinMatch) {
1580
+ wallet = parseInt(coinMatch[1].replace(/,/g, ''), 10);
1581
+ matched = 'cv2-emoji';
1582
+ } else if (walletMatch) {
1583
+ wallet = parseInt(walletMatch[1].replace(/,/g, ''), 10);
1584
+ matched = 'legacy-wallet';
1585
+ } else if (allNums.length > 0) {
1586
+ wallet = Math.max(...allNums);
1587
+ matched = 'fallback-nums';
1588
+ }
1670
1589
 
1671
- if (bankEmojiMatch) {
1672
- bank = parseInt(bankEmojiMatch[1].replace(/,/g, ''), 10);
1673
- matched += matched ? '+bank-emoji' : 'bank-emoji';
1674
- } else if (bankTextMatch) {
1675
- bank = parseInt(bankTextMatch[1].replace(/,/g, ''), 10);
1676
- matched += matched ? '+bank-text' : 'bank-text';
1677
- } else if (bankSlashMatch) {
1678
- bank = parseInt(bankSlashMatch[1].replace(/,/g, ''), 10);
1679
- matched += matched ? '+bank-slash' : 'bank-slash';
1680
- }
1590
+ if (bankEmojiMatch) {
1591
+ bank = parseInt(bankEmojiMatch[1].replace(/,/g, ''), 10);
1592
+ matched += matched ? '+bank-emoji' : 'bank-emoji';
1593
+ } else if (bankTextMatch) {
1594
+ bank = parseInt(bankTextMatch[1].replace(/,/g, ''), 10);
1595
+ matched += matched ? '+bank-text' : 'bank-text';
1596
+ } else if (bankSlashMatch) {
1597
+ bank = parseInt(bankSlashMatch[1].replace(/,/g, ''), 10);
1598
+ matched += matched ? '+bank-slash' : 'bank-slash';
1599
+ }
1681
1600
 
1682
- if (wallet === 0 && bank === 0) {
1683
- this.log('warn', `Balance parse returned 0 — raw text: "${text.substring(0, 200)}"`);
1684
- // Don't overwrite a known-good balance with 0
1685
- if (this.stats.balance > 0 || this.stats.bankBalance > 0) return;
1686
- }
1601
+ if (wallet === 0 && bank === 0) {
1602
+ this.log('warn', `Balance parse returned 0 — raw text: "${text.substring(0, 200)}"`);
1603
+ // Don't overwrite a known-good balance with 0
1604
+ if (this.stats.balance > 0 || this.stats.bankBalance > 0) return;
1605
+ }
1687
1606
 
1688
1607
  this.stats.balance = wallet;
1689
1608
  this.stats.bankBalance = bank;
1690
- this.log('bal', `Wallet: ${c.bold}${c.green}⏣ ${wallet.toLocaleString()}${c.reset} Bank: ${c.bold}${c.cyan}⏣ ${bank.toLocaleString()}${c.reset} Total: ${c.bold}⏣ ${(wallet + bank).toLocaleString()}${c.reset} ${c.dim}(${matched || 'none'})${c.reset}`);
1609
+ if (!silent) this.log('bal', `Wallet: ${c.bold}${c.green}⏣ ${wallet.toLocaleString()}${c.reset} Bank: ${c.bold}${c.cyan}⏣ ${bank.toLocaleString()}${c.reset} Total: ${c.bold}⏣ ${(wallet + bank).toLocaleString()}${c.reset}`);
1691
1610
 
1692
- // Store in Redis for persistence
1693
- if (redis) {
1694
- try {
1695
- await redis.set(`dkg:bal:${this.account.id}`, JSON.stringify({ wallet, bank, ts: Date.now() }));
1696
- } catch {}
1697
- }
1698
-
1699
- // Always report to dashboard API
1611
+ // Store in Redis for persistence
1612
+ if (redis) {
1700
1613
  try {
1701
- await fetch(`${API_URL}/api/grinder/status`, {
1702
- method: 'POST',
1703
- headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
1704
- body: JSON.stringify({
1705
- account_id: this.account.id,
1706
- balance: wallet,
1707
- bank_balance: bank,
1708
- total_balance: wallet + bank,
1709
- lifesavers: this._lifesavers ?? null,
1710
- }),
1711
- });
1712
- } catch { /* silent */ }
1614
+ await redis.set(`dkg:bal:${this.account.id}`, JSON.stringify({ wallet, bank, ts: Date.now() }));
1615
+ } catch {}
1713
1616
  }
1617
+
1618
+ // Always report to dashboard API
1619
+ try {
1620
+ await fetch(`${API_URL}/api/grinder/status`, {
1621
+ method: 'POST',
1622
+ headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
1623
+ body: JSON.stringify({
1624
+ account_id: this.account.id,
1625
+ balance: wallet,
1626
+ bank_balance: bank,
1627
+ total_balance: wallet + bank,
1628
+ lifesavers: this._lifesavers ?? null,
1629
+ }),
1630
+ });
1631
+ } catch { /* silent */ }
1714
1632
  }
1715
1633
 
1716
- // ── Check DM History for deaths/level-ups ──────────────────
1634
+ // ── Check DM History for deaths/level-ups (with retry) ─────
1717
1635
  async checkDmHistory() {
1718
- try {
1719
- const dankUser = await this.client.users.fetch(DANK_MEMER_ID);
1720
- const dm = await dankUser.createDM();
1721
- this._dmChannelId = dm.id;
1722
- const recent = await dm.messages.fetch({ limit: 20 });
1636
+ const maxRetries = 3;
1637
+ const delays = [1000, 2000, 4000];
1638
+ let lastError;
1639
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
1640
+ try {
1641
+ const dankUser = await this.client.users.fetch(DANK_MEMER_ID);
1642
+ const dm = await dankUser.createDM();
1643
+ this._dmChannelId = dm.id;
1644
+ const recent = await dm.messages.fetch({ limit: 20 });
1723
1645
 
1724
1646
  let deaths = 0, levelUps = 0, currentLevel = 0, lastLifesaverCount = -1;
1725
1647
  for (const [, msg] of recent) {
@@ -1773,11 +1695,16 @@ class AccountWorker {
1773
1695
  }
1774
1696
  }
1775
1697
 
1776
- return { deaths, levelUps, currentLevel, lifesavers: lastLifesaverCount, dmChannelId: dm.id };
1777
- } catch (e) {
1778
- this.log('debug', `DM check failed: ${e.message}`);
1779
- return { deaths: 0, levelUps: 0, currentLevel: 0, lifesavers: -1 };
1698
+ return { deaths, levelUps, currentLevel, lifesavers: lastLifesaverCount, dmChannelId: dm.id };
1699
+ } catch (e) {
1700
+ lastError = e;
1701
+ if (attempt < maxRetries - 1) {
1702
+ await new Promise(r => setTimeout(r, delays[attempt]));
1703
+ }
1704
+ }
1780
1705
  }
1706
+ this.log('debug', `DM check failed after ${maxRetries} attempts: ${lastError.message}`);
1707
+ return { deaths: 0, levelUps: 0, currentLevel: 0, lifesavers: -1 };
1781
1708
  }
1782
1709
 
1783
1710
  // ── Run Single Command ──────────────────────────────────────
@@ -1805,20 +1732,68 @@ class AccountWorker {
1805
1732
  if (shutdownCalled || !this.running) return;
1806
1733
  this.stats.commands++;
1807
1734
 
1735
+ // ── Monthly: only run if balance ≥ 18M (advancements requirement) ──
1736
+ if (cmdName === 'monthly') {
1737
+ const totalBal = (this.stats.balance || 0) + (this.stats.bankBalance || 0);
1738
+ if (totalBal < 18_000_000) {
1739
+ this.log('warn', `[monthly] SKIPPED — balance ${(totalBal / 1e6).toFixed(1)}M < 18M`);
1740
+ await this.setCooldown(cmdName, 86400);
1741
+ return;
1742
+ }
1743
+ // Check if disabled (not premium / no advancement)
1744
+ if (redis) {
1745
+ try {
1746
+ const disabled = await redis.get(`dkg:disabled:${this.account.id}:monthly`);
1747
+ if (disabled) {
1748
+ this.log('warn', `[monthly] SKIPPED — disabled (needs advancement purchase)`);
1749
+ return;
1750
+ }
1751
+ } catch {}
1752
+ }
1753
+ }
1754
+
1755
+ // ── Daily/Monthly: check if already claimed today (avoid wasting a command) ──
1756
+ if (cmdName === 'daily' || cmdName === 'monthly') {
1757
+ if (redis) {
1758
+ try {
1759
+ const done = await redis.get(`dkg:done:${this.account.id}:${cmdName}`);
1760
+ if (done) {
1761
+ const ttl = await redis.ttl(`dkg:done:${this.account.id}:${cmdName}`);
1762
+ this.log('info', `[${cmdName}] already claimed — ${Math.ceil(ttl / 60)}min left`);
1763
+ await this.setCooldown(cmdName, Math.max(60, ttl));
1764
+ return;
1765
+ }
1766
+ } catch {}
1767
+ }
1768
+ }
1769
+
1770
+ // ── Deposit: max once per hour, only when enabled ──
1771
+ if (cmdName === 'dep max') {
1772
+ if (this._lastDepositAt && Date.now() - this._lastDepositAt < 3600_000) {
1773
+ return; // silently skip — too soon
1774
+ }
1775
+ }
1776
+
1808
1777
  // ── Lifesaver protection: skip crime/search if 0 lifesavers ──
1809
1778
  if (cmdName === 'crime' || cmdName === 'search') {
1779
+ // Fast path: check in-memory lifesaver count (set from inv + DM check)
1780
+ if (this._lifesavers === 0) {
1781
+ this.log('warn', `[${cmdName}] SKIPPED — 0 lifesavers (in-memory)`);
1782
+ await this.setCooldown(cmdName, 3600);
1783
+ return;
1784
+ }
1810
1785
  const noLifesaver = await rawLogger.hasNoLifesaverAlert(this.channel?.id);
1811
1786
  if (noLifesaver) {
1812
1787
  this.log('warn', `[${cmdName}] SKIPPED — no lifesavers! (death detected in DMs)`);
1813
- await this.setCooldown(cmdName, 3600); // block for 1 hour
1788
+ await this.setCooldown(cmdName, 3600);
1814
1789
  return;
1815
1790
  }
1816
- // Also check Redis key for lifesaver count
1817
1791
  if (redis) {
1818
1792
  try {
1819
1793
  const lsCount = await redis.get(`dkg:lifesavers:${this.account.id}`);
1820
1794
  if (lsCount === '0') {
1821
- this.log('warn', `[${cmdName}] SKIPPED — 0 lifesavers cached`);
1795
+ this._lifesavers = 0;
1796
+ this.log('warn', `[${cmdName}] SKIPPED — 0 lifesavers (Redis)`);
1822
1797
  await this.setCooldown(cmdName, 3600);
1823
1798
  return;
1824
1799
  }
@@ -1885,7 +1860,7 @@ class AccountWorker {
1885
1860
  // PostMemes / command-specific cooldown from response
1886
1861
  if (resultLower.includes('cannot post another meme') || resultLower.includes('dead meme')) {
1887
1862
  const minMatch = result.match(/(\d+)\s*minute/i);
1888
- const cdSec = minMatch ? parseInt(minMatch[1]) * 60 : 120;
1863
+ const cdSec = minMatch ? parseInt(minMatch[1]) * 60 + 30 : 150; // dead meme = N min + 30s buffer
1889
1864
  this.log('warn', `${cmdName} on cooldown: ${cdSec}s`);
1890
1865
  await this.setCooldown(cmdName, cdSec);
1891
1866
  return;
@@ -1965,11 +1940,13 @@ class AccountWorker {
1965
1940
  return;
1966
1941
  }
1967
1942
 
1968
- // Premium-only command detection — disable for 24h
1943
+ // Premium-only command detection — disable permanently
1969
1944
  if (resultLower.includes('only available on premium') || resultLower.includes('premium') ||
1970
- resultLower.includes('buy the ability to use this command')) {
1971
- this.log('warn', `${cmdName} requires premium — skipping for 24h`);
1972
- await this.setCooldown(cmdName, 86400);
1945
+ resultLower.includes('buy the ability to use this command') ||
1946
+ resultLower.includes('advancements upgrades')) {
1947
+ this.log('warn', `${cmdName} requires premium/advancement — DISABLED`);
1948
+ await this.setCooldown(cmdName, 2592000); // 30 days
1949
+ if (redis) try { await redis.set(`dkg:disabled:${this.account.id}:${cmdName}`, '1', 'EX', 2592000); } catch {}
1973
1950
  return;
1974
1951
  }
1975
1952
 
@@ -2076,9 +2053,12 @@ class AccountWorker {
2076
2053
  }
2077
2054
  }
2078
2055
 
2079
- // Smart auto-deposit: when wallet exceeds threshold, deposit to protect from robbery
2080
- if (earned > 0 && this.stats.balance > this._autoDepositThreshold) {
2056
+ // Smart auto-deposit: max once per hour, only if deposit is enabled
2057
+ const depositEnabled = this.account.cmd_deposit !== false;
2058
+ const depositCooldownOk = !this._lastDepositAt || Date.now() - this._lastDepositAt >= 3600_000;
2059
+ if (depositEnabled && depositCooldownOk && earned > 0 && this.stats.balance > this._autoDepositThreshold) {
2081
2060
  this.log('info', `Wallet ⏣ ${this.stats.balance.toLocaleString()} exceeds threshold — auto-depositing`);
2061
+ this._lastDepositAt = Date.now();
2082
2062
  try {
2083
2063
  await this.channel.send('pls dep max');
2084
2064
  await this.waitForDankMemer(6000);
@@ -2172,10 +2152,10 @@ class AccountWorker {
2172
2152
  { key: 'cmd_work', cmd: 'work shift', cdKey: 'cd_work', defaultCd: 1800, priority: 3 },
2173
2153
  // Time-gated (run ASAP when available)
2174
2154
  { key: 'cmd_daily', cmd: 'daily', cdKey: 'cd_daily', defaultCd: 86400, priority: 10 },
2175
- { key: 'cmd_weekly', cmd: 'weekly', cdKey: 'cd_weekly', defaultCd: 604800, priority: 10 },
2155
+ // weekly removed premium only, not available for free users
2176
2156
  { key: 'cmd_monthly', cmd: 'monthly', cdKey: 'cd_monthly', defaultCd: 2592000,priority: 10 },
2177
2157
  // Financial safety
2178
- { key: 'cmd_deposit', cmd: 'dep max', cdKey: 'cd_deposit', defaultCd: 120, priority: 8 },
2158
+ { key: 'cmd_deposit', cmd: 'dep max', cdKey: 'cd_deposit', defaultCd: 3600, priority: 8 },
2179
2159
  { key: 'cmd_drops', cmd: 'drops', cdKey: 'cd_drops', defaultCd: 86400, priority: 2 },
2180
2160
  // Alert is NOT scheduled — it's reactive (listener-based, see grindLoop)
2181
2161
  ].map(Object.freeze);
@@ -3034,25 +3014,82 @@ async function start(apiKey, apiUrl) {
3034
3014
  console.log(` ${checks.join(' ')}`);
3035
3015
  console.log('');
3036
3016
 
3037
- // ── Animated loading bar helper ──────────────────────────────
3038
- const barW = Math.min(40, (process.stdout.columns || 80) - 30);
3039
- let loginDone = 0;
3040
- const drawLoginProgress = () => {
3041
- const pct = accounts.length > 0 ? loginDone / accounts.length : 0;
3042
- const filled = Math.round(pct * barW);
3043
- const empty = barW - filled;
3044
- const spin = BRAILLE_SPIN[Math.floor(Date.now() / 80) % BRAILLE_SPIN.length];
3045
- const bar = rgb(139, 92, 246) + '█'.repeat(filled) + rgb(50, 50, 70) + '░'.repeat(empty) + c.reset;
3046
- const pctStr = `${Math.round(pct * 100)}%`;
3047
- process.stdout.write(`\r ${rgb(139, 92, 246)}${spin}${c.reset} ${c.dim}Logging in...${c.reset} ${bar} ${c.bold}${rgb(52, 211, 153)}${loginDone}${c.reset}${c.dim}/${c.reset}${c.white}${accounts.length}${c.reset} ${c.dim}${pctStr}${c.reset} `);
3017
+ // ── Per-account inline login UI ──────────────────────────────
3018
+ // Track login state per account for inline rendering
3019
+ const loginStates = accounts.map((acc, i) => ({
3020
+ name: acc.label || acc.id || '?',
3021
+ done: false,
3022
+ failed: false,
3023
+ worker: null,
3024
+ workerIdx: i,
3025
+ }));
3026
+
3027
+ // Column widths (visible chars)
3028
+ const terminalW = process.stdout.columns || 90;
3029
+ const colNum = 4; // " # "
3030
+ const colSts = 3; // "ST "
3031
+ const colName = Math.min(24, Math.max(12, Math.floor(terminalW * 0.25)));
3032
+ const colGuild = Math.min(20, Math.max(8, Math.floor(terminalW * 0.2)));
3033
+ const colCmds = 10; // " 20 cmds"
3034
+ const totalVis = colNum + colSts + colName + colGuild + colCmds + 8;
3035
+
3036
+ // Print header + all account lines (initial pending state)
3037
+ console.log(` ${'─'.repeat(totalVis)}`);
3038
+ for (let i = 0; i < loginStates.length; i++) {
3039
+ const s = loginStates[i];
3040
+ const num = `${c.dim}${(i + 1).toString().padStart(colNum - 1)}${c.reset} `;
3041
+ const name = s.name.substring(0, colName).padEnd(colName);
3042
+ console.log(`${num}${c.dim}PN${c.reset} ${name} ${c.dim}${'···'.padEnd(colGuild)}${c.reset} ${c.dim}···${c.reset}`);
3043
+ }
3044
+ console.log(` ${'─'.repeat(totalVis)}`);
3045
+
3046
+ // Draw pending spinners
3047
+ const drawLoginSpinners = () => {
3048
+ for (let i = 0; i < loginStates.length; i++) {
3049
+ const s = loginStates[i];
3050
+ if (s.done || s.failed) continue;
3051
+ const spin = BRAILLE_SPIN[Math.floor(Date.now() / 80) % BRAILLE_SPIN.length];
3052
+ const num = `${c.dim}${(i + 1).toString().padStart(colNum - 1)}${c.reset} `;
3053
+ const name = s.name.substring(0, colName).padEnd(colName);
3054
+ const guild = 'logging in...'.substring(0, colGuild).padEnd(colGuild);
3055
+ process.stdout.write(`\x1b[${i + 2};0H\x1b[2K ${num}${rgb(139, 92, 246)}${spin}${c.reset} ${name} ${c.dim}${guild}${c.reset} ${c.dim}···${c.reset}`);
3056
+ }
3057
+ process.stdout.write(`\x1b[${loginStates.length + 3};0H`);
3048
3058
  };
3059
+ const loginSpinnerInterval = setInterval(drawLoginSpinners, 80);
3060
+
3061
+ // Update a line when an account finishes
3062
+ const finalizeLoginLine = (idx, worker) => {
3063
+ const s = loginStates[idx];
3064
+ if (s.done || s.failed) return;
3065
+ s.done = true;
3066
+ s.worker = worker;
3067
+
3068
+ const num = `${c.dim}${(idx + 1).toString().padStart(colNum - 1)}${c.reset} `;
3069
+ const name = (worker.username || s.name || '?').substring(0, colName).padEnd(colName);
3070
+
3071
+ let sts, guild, cmds;
3072
+ if (worker._tokenInvalid) {
3073
+ sts = `${rgb(239, 68, 68)}✗${c.reset}`;
3074
+ guild = 'INVALID'.padEnd(colGuild);
3075
+ cmds = '···';
3076
+ s.failed = true;
3077
+ } else if (worker.channel) {
3078
+ sts = `${rgb(52, 211, 153)}✓${c.reset}`;
3079
+ const gn = (worker.channel.guild?.name || worker.channel.guild?.id || 'DM').substring(0, colGuild);
3080
+ guild = gn.padEnd(colGuild);
3081
+ cmds = `${worker.stats?.commands || 0}cmds`;
3082
+ } else {
3083
+ sts = `${rgb(251, 146, 60)}⏳${c.reset}`;
3084
+ guild = 'timeout'.padEnd(colGuild);
3085
+ cmds = '···';
3086
+ }
3049
3087
 
3050
- // Progress animation timer
3051
- const progressInterval = setInterval(drawLoginProgress, 80);
3088
+ const line = ` ${num}${sts} ${name} ${guild} ${cmds}`;
3089
+ process.stdout.write(`\x1b[${idx + 2};0H\x1b[2K${line}`);
3090
+ };
3052
3091
 
3053
- // Phase 1: Login all accounts (optimized for speed)
3054
- const LOGIN_PROGRESS_EVERY = 10;
3055
- // Reduced delays: 50-150ms between logins (faster startup for 1k+ accounts)
3092
+ // Phase 1: Login in batches of 10
3056
3093
  const parsedGapMin = Number.parseInt(String(process.env.LOGIN_GAP_MIN_MS || '50'), 10);
3057
3094
  const parsedGapMax = Number.parseInt(String(process.env.LOGIN_GAP_MAX_MS || '150'), 10);
3058
3095
  const LOGIN_GAP_MIN_MS = Number.isFinite(parsedGapMin) && parsedGapMin >= 0 ? parsedGapMin : 50;
@@ -3063,16 +3100,12 @@ async function start(apiKey, apiUrl) {
3063
3100
  return LOGIN_GAP_MIN_MS + Math.floor(Math.random() * (LOGIN_GAP_MAX_MS - LOGIN_GAP_MIN_MS + 1));
3064
3101
  };
3065
3102
 
3066
- // Parallel login in batches of 10 to avoid rate limits while being fast
3067
- // Within each batch, stagger logins by 100-600ms to avoid gateway flood
3068
3103
  const BATCH_SIZE = 10;
3069
3104
  for (let i = 0; i < accounts.length; i += BATCH_SIZE) {
3070
3105
  if (shutdownCalled) break;
3071
3106
  const batch = accounts.slice(i, Math.min(i + BATCH_SIZE, accounts.length));
3072
3107
 
3073
- // Staggered parallel login: fire each login with a small jitter delay
3074
3108
  await Promise.all(batch.map(async (acc, idx) => {
3075
- // Stagger within batch: 0ms for first, 100-600ms for subsequent
3076
3109
  if (idx > 0) {
3077
3110
  const jitter = 100 + Math.floor(Math.random() * 500);
3078
3111
  await new Promise(r => setTimeout(r, jitter));
@@ -3080,30 +3113,29 @@ async function start(apiKey, apiUrl) {
3080
3113
  const worker = new AccountWorker(acc, i + idx);
3081
3114
  workers.push(worker);
3082
3115
  workerMap.set(acc.id, worker);
3116
+ loginStates[i + idx].worker = worker;
3083
3117
  await worker.start();
3084
- loginDone++;
3118
+ finalizeLoginLine(i + idx, worker);
3085
3119
  }));
3086
3120
 
3087
- // Small gap between batches
3088
3121
  if (i + BATCH_SIZE < accounts.length) {
3089
- const gapMs = randomLoginGap();
3090
- await new Promise(r => setTimeout(r, gapMs));
3122
+ await new Promise(r => setTimeout(r, randomLoginGap()));
3091
3123
  }
3092
3124
 
3093
3125
  hintGC();
3094
3126
  }
3095
3127
 
3096
- clearInterval(progressInterval);
3097
- // Clear the progress line and show done
3098
- process.stdout.write(`\r${c.clearLine}`);
3099
- console.log(` ${rgb(52, 211, 153)}✓${c.reset} ${c.bold}Login complete${c.reset} ${rgb(52, 211, 153)}${loginDone}/${accounts.length}${c.reset} ${c.dim}accounts connected${c.reset}`);
3100
- console.log('');
3128
+ clearInterval(loginSpinnerInterval);
3129
+ process.stdout.write(`\x1b[${loginStates.length + 3};0H`);
3101
3130
 
3102
- // Login summary: show invalid tokens clearly
3131
+ // Final summary
3132
+ const loginDone = workers.filter(w => !w._tokenInvalid && w.channel).length;
3103
3133
  const invalidWorkers = workers.filter(w => w._tokenInvalid);
3104
3134
  const timedOutWorkers = workers.filter(w => !w.channel && !w._tokenInvalid);
3135
+ console.log(` ${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}`);
3136
+ console.log('');
3137
+
3105
3138
  if (invalidWorkers.length > 0) {
3106
- console.log('');
3107
3139
  log('warn', `${rgb(239, 68, 68)}${invalidWorkers.length} account(s) have INVALID tokens:${c.reset}`);
3108
3140
  for (const w of invalidWorkers) {
3109
3141
  log('error', ` ✗ ${w.account.label || w.account.id} — token is invalid or expired`);
@@ -3117,85 +3149,176 @@ async function start(apiKey, apiUrl) {
3117
3149
  // Filter out workers with invalid tokens from grinding
3118
3150
  const activeWorkers = workers.filter(w => !w._tokenInvalid);
3119
3151
 
3120
- // Phase 2: Run inventory on ALL valid accounts in parallel (must complete before grinding)
3121
- console.log(` ${rgb(139, 92, 246)}${BRAILLE_SPIN[0]}${c.reset} ${c.dim}Checking inventory for ${c.reset}${c.bold}${activeWorkers.length}${c.reset}${c.dim} accounts...${c.reset}`);
3122
-
3123
- // Animated inventory progress
3124
- let invDone = 0;
3125
- let invFailed = 0;
3126
- const total = activeWorkers.length;
3127
- const invBarW = Math.min(40, (process.stdout.columns || 80) - 30);
3128
-
3129
- const drawInvProgress = () => {
3130
- const pct = total > 0 ? invDone / total : 0;
3131
- const filled = Math.round(pct * invBarW);
3132
- const empty = invBarW - filled;
3133
- const spin = BRAILLE_SPIN[Math.floor(Date.now() / 80) % BRAILLE_SPIN.length];
3134
- const bar = rgb(34, 211, 238) + '█'.repeat(filled) + rgb(50, 50, 70) + '░'.repeat(empty) + c.reset;
3135
- process.stdout.write(`\r ${rgb(34, 211, 238)}${spin}${c.reset} ${c.dim}Inventory...${c.reset} ${bar} ${c.bold}${rgb(52, 211, 153)}${invDone}${c.reset}${c.dim}/${c.reset}${c.white}${total}${c.reset} ${c.dim}${Math.round(pct * 100)}%${c.reset} `);
3152
+ // ── Phase 2: Inventory check with per-account inline rendering ─────────────────
3153
+ const invStates = activeWorkers.map((w, i) => ({
3154
+ name: w.username || w.account.label || '?',
3155
+ idx: i,
3156
+ done: false,
3157
+ failed: false,
3158
+ items: 0,
3159
+ value: 0,
3160
+ attempt: 0,
3161
+ worker: w,
3162
+ }));
3163
+
3164
+ const tw2 = process.stdout.columns || 90;
3165
+ const iColNum = 4;
3166
+ const iColName = Math.min(22, Math.max(10, Math.floor(tw2 * 0.22)));
3167
+ const iColItems = 8;
3168
+ const iColVal = 14;
3169
+ const iColTries = 10;
3170
+ const iTotalVis = iColNum + iColName + iColItems + iColVal + iColTries + 10;
3171
+
3172
+ console.log(` ${'─'.repeat(iTotalVis)}`);
3173
+ for (let i = 0; i < invStates.length; i++) {
3174
+ const s = invStates[i];
3175
+ const num = `${c.dim}${(i + 1).toString().padStart(iColNum - 1)}${c.reset} `;
3176
+ const name = s.name.substring(0, iColName).padEnd(iColName);
3177
+ console.log(`${num}${c.dim}··${c.reset} ${name} ${c.dim}${'checking...'.padEnd(iColItems)}${c.reset} ${c.dim}${'··'.padEnd(iColVal)}${c.reset} ${c.dim}${''.padEnd(iColTries)}${c.reset}`);
3178
+ }
3179
+ console.log(` ${'─'.repeat(iTotalVis)}`);
3180
+
3181
+ let invDone = 0, invFailed = 0;
3182
+
3183
+ const drawInvSpinners = () => {
3184
+ for (let i = 0; i < invStates.length; i++) {
3185
+ const s = invStates[i];
3186
+ if (s.done || s.failed) continue;
3187
+ const spin = BRAILLE_SPIN[Math.floor(Date.now() / 80) % BRAILLE_SPIN.length];
3188
+ const num = `${c.dim}${(i + 1).toString().padStart(iColNum - 1)}${c.reset} `;
3189
+ const name = s.name.substring(0, iColName).padEnd(iColName);
3190
+ const items = 'checking'.substring(0, iColItems).padEnd(iColItems);
3191
+ const val = ''.padEnd(iColVal);
3192
+ const tries = s.attempt > 0 ? `try ${s.attempt}/3` : '';
3193
+ process.stdout.write(`\x1b[${i + 2};0H\x1b[2K ${num}${rgb(34, 211, 238)}${spin}${c.reset} ${name} ${c.dim}${items}${c.reset} ${c.dim}${val}${c.reset} ${c.dim}${tries}${c.reset}`);
3194
+ }
3195
+ process.stdout.write(`\x1b[${invStates.length + 3};0H`);
3136
3196
  };
3197
+ const invSpinnerInterval = setInterval(drawInvSpinners, 80);
3198
+
3199
+ const finalizeInvLine = (idx, invRes) => {
3200
+ const s = invStates[idx];
3201
+ if (s.done || s.failed) return;
3202
+ if (invRes?.ok) {
3203
+ s.done = true;
3204
+ invDone++;
3205
+ } else {
3206
+ s.failed = true;
3207
+ invFailed++;
3208
+ }
3137
3209
 
3138
- const invProgressInterval = setInterval(drawInvProgress, 80);
3210
+ const num = `${c.dim}${(idx + 1).toString().padStart(iColNum - 1)}${c.reset} `;
3211
+ const name = s.name.substring(0, iColName).padEnd(iColName);
3212
+ const items = `${invRes?.items?.length || 0}`.padEnd(iColItems);
3213
+ const val = invRes?.ok
3214
+ ? `${c.green}⏣${(invRes.totalValue || 0).toLocaleString()}${c.reset}`.padEnd(iColVal + 3)
3215
+ : `${c.dim}··${c.reset}`.padEnd(iColVal);
3216
+ const sts = invRes?.ok ? `${rgb(52, 211, 153)}✓${c.reset}` : `${rgb(239, 68, 68)}✗${c.reset}`;
3217
+
3218
+ const line = ` ${num}${sts} ${name} ${items} ${val}`;
3219
+ process.stdout.write(`\x1b[${idx + 2};0H\x1b[2K${line}`);
3220
+ };
3139
3221
 
3140
3222
  await Promise.all(activeWorkers.map(async (w, i) => {
3141
3223
  try {
3142
- const invRes = await w.checkInventory({
3143
- force: true,
3144
- startupProgress: { current: i + 1, total },
3145
- requireComplete: true,
3146
- maxAttempts: 3,
3147
- });
3148
- if (invRes?.ok) invDone++;
3149
- else invFailed++;
3224
+ const invRes = await w.checkInventory({ force: true, requireComplete: true, maxAttempts: 3 });
3225
+ finalizeInvLine(i, invRes);
3150
3226
  } catch {
3151
- invFailed++;
3227
+ finalizeInvLine(i, { ok: false });
3152
3228
  }
3153
3229
  }));
3154
3230
 
3155
- clearInterval(invProgressInterval);
3156
- process.stdout.write(`\r${c.clearLine}`);
3231
+ clearInterval(invSpinnerInterval);
3232
+ process.stdout.write(`\x1b[${invStates.length + 3};0H`);
3157
3233
 
3158
- // Final summary
3159
3234
  if (invFailed > 0) {
3160
- 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}${total}${c.reset} done, ${rgb(239, 68, 68)}${invFailed} failed${c.reset}`);
3235
+ 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}`);
3161
3236
  log('error', `${c.red}Not starting grind loops — ${invFailed} accounts failed inventory.${c.reset}`);
3162
3237
  return;
3163
3238
  }
3164
3239
 
3165
- console.log(` ${rgb(52, 211, 153)}✓${c.reset} ${c.bold}Inventory complete${c.reset} ${rgb(52, 211, 153)}${invDone}/${total}${c.reset} ${c.dim}all clear${c.reset}`);
3240
+ 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}`);
3166
3241
  console.log('');
3167
3242
 
3168
- // Phase 2.5: Check balance for ALL accounts sequentially (CV2 needs raw logger timing)
3169
- console.log(` ${rgb(139, 92, 246)}${BRAILLE_SPIN[0]}${c.reset} ${c.dim}Checking balance for ${c.reset}${c.bold}${activeWorkers.length}${c.reset}${c.dim} accounts...${c.reset}`);
3243
+ // ── Phase 2.5: Balance check with inline per-account rendering ─────────────────
3244
+ const balStates = activeWorkers.map((w, i) => ({
3245
+ name: w.username || w.account.label || '?',
3246
+ idx: i,
3247
+ done: false,
3248
+ wallet: 0,
3249
+ bank: 0,
3250
+ worker: w,
3251
+ }));
3252
+
3253
+ const bColNum = 4;
3254
+ const bColName = Math.min(22, Math.max(10, Math.floor(tw2 * 0.22)));
3255
+ const bColWallet = 14;
3256
+ const bColBank = 14;
3257
+ const bColTotal = 14;
3258
+ const bColLs = 4;
3259
+ const bTotalVis = bColNum + bColName + bColWallet + bColBank + bColTotal + bColLs + 12;
3260
+
3261
+ console.log(` ${'─'.repeat(bTotalVis)}`);
3262
+ for (let i = 0; i < balStates.length; i++) {
3263
+ const s = balStates[i];
3264
+ const num = `${c.dim}${(i + 1).toString().padStart(bColNum - 1)}${c.reset} `;
3265
+ const name = s.name.substring(0, bColName).padEnd(bColName);
3266
+ console.log(`${num}${c.dim}··${c.reset} ${name} ${c.dim}${'checking'.padEnd(bColWallet)}${c.reset} ${c.dim}${'··'.padEnd(bColBank)}${c.reset} ${c.dim}${'··'.padEnd(bColTotal)}${c.reset} ${c.dim}♥?${c.reset}`);
3267
+ }
3268
+ console.log(` ${'─'.repeat(bTotalVis)}`);
3170
3269
 
3171
3270
  let balDone = 0;
3172
- const balProgressInterval = setInterval(() => {
3173
- const spin = BRAILLE_SPIN[Math.floor(Date.now() / 80) % BRAILLE_SPIN.length];
3174
- process.stdout.write(`\r ${rgb(34, 211, 238)}${spin}${c.reset} ${c.dim}Balance...${c.reset} ${c.bold}${rgb(52, 211, 153)}${balDone}${c.reset}${c.dim}/${c.reset}${c.white}${activeWorkers.length}${c.reset} `);
3175
- }, 80);
3176
3271
 
3177
- // Run in parallel
3272
+ const drawBalSpinners = () => {
3273
+ for (let i = 0; i < balStates.length; i++) {
3274
+ const s = balStates[i];
3275
+ if (s.done) continue;
3276
+ const spin = BRAILLE_SPIN[Math.floor(Date.now() / 80) % BRAILLE_SPIN.length];
3277
+ const num = `${c.dim}${(i + 1).toString().padStart(bColNum - 1)}${c.reset} `;
3278
+ const name = s.name.substring(0, bColName).padEnd(bColName);
3279
+ process.stdout.write(`\x1b[${i + 2};0H\x1b[2K ${num}${rgb(251, 191, 36)}${spin}${c.reset} ${name} ${c.dim}${'checking'.padEnd(bColWallet)}${c.reset} ${c.dim}${'··'.padEnd(bColBank)}${c.reset} ${c.dim}${'··'.padEnd(bColTotal)}${c.reset} ${c.dim}♥?${c.reset}`);
3280
+ }
3281
+ process.stdout.write(`\x1b[${balStates.length + 3};0H`);
3282
+ };
3283
+ const balSpinnerInterval = setInterval(drawBalSpinners, 80);
3284
+
3285
+ const finalizeBalLine = (idx, w) => {
3286
+ const s = balStates[idx];
3287
+ if (s.done) return;
3288
+ s.done = true;
3289
+ s.wallet = w.stats?.balance || 0;
3290
+ s.bank = w.stats?.bankBalance || 0;
3291
+ balDone++;
3292
+
3293
+ const num = `${c.dim}${(idx + 1).toString().padStart(bColNum - 1)}${c.reset} `;
3294
+ const name = (w.username || s.name || '?').substring(0, bColName).padEnd(bColName);
3295
+ const ls = w._lifesavers ?? '?';
3296
+ const lsColor = ls === 0 ? rgb(239, 68, 68) : ls <= 2 ? rgb(251, 191, 36) : rgb(52, 211, 153);
3297
+ const wallet = `${c.green}⏣${(s.wallet).toLocaleString()}${c.reset}`.padEnd(bColWallet + 3);
3298
+ const bank = `${c.cyan}⏣${(s.bank).toLocaleString()}${c.reset}`.padEnd(bColBank + 3);
3299
+ const total = `${c.bold}⏣${(s.wallet + s.bank).toLocaleString()}${c.reset}`.padEnd(bColTotal + 3);
3300
+
3301
+ const line = ` ${num}${rgb(52, 211, 153)}✓${c.reset} ${name} ${wallet} ${bank} ${total} ${lsColor}♥${ls}${c.reset}`;
3302
+ process.stdout.write(`\x1b[${idx + 2};0H\x1b[2K${line}`);
3303
+ };
3304
+
3178
3305
  await Promise.all(activeWorkers.map(async w => {
3179
3306
  try {
3180
- await w.checkBalance();
3181
- balDone++;
3307
+ await w.checkBalance(true); // silent: don't spam console during inline rendering
3182
3308
  } catch {}
3309
+ const idx = balStates.findIndex(s => s.worker === w);
3310
+ if (idx >= 0) finalizeBalLine(idx, w);
3183
3311
  }));
3184
3312
 
3185
- clearInterval(balProgressInterval);
3186
- process.stdout.write(`\r${c.clearLine}`);
3313
+ clearInterval(balSpinnerInterval);
3314
+ process.stdout.write(`\x1b[${balStates.length + 3};0H`);
3187
3315
 
3188
- // Show balance + lifesaver summary for each account
3316
+ // Balance summary
3189
3317
  let totalWallet = 0, totalBank = 0, noLifesaverAccounts = [];
3190
- for (const w of activeWorkers) {
3191
- const wallet = w.stats?.balance || 0;
3192
- const bank = w.stats?.bankBalance || 0;
3193
- const ls = w._lifesavers ?? '?';
3194
- totalWallet += wallet;
3195
- totalBank += bank;
3196
- const lsColor = ls === 0 ? rgb(239, 68, 68) : ls <= 2 ? rgb(251, 191, 36) : rgb(52, 211, 153);
3197
- console.log(` ${c.dim}├${c.reset} ${c.bold}${w.username}${c.reset} Wallet: ${c.green}⏣ ${wallet.toLocaleString()}${c.reset} Bank: ${c.cyan}⏣ ${bank.toLocaleString()}${c.reset} Total: ${c.bold}⏣ ${(wallet + bank).toLocaleString()}${c.reset} LS: ${lsColor}${ls}${c.reset}`);
3198
- if (ls === 0) noLifesaverAccounts.push(w.username);
3318
+ for (const s of balStates) {
3319
+ totalWallet += s.wallet;
3320
+ totalBank += s.bank;
3321
+ if (s.worker._lifesavers === 0) noLifesaverAccounts.push(s.name);
3199
3322
  }
3200
3323
  console.log(` ${rgb(52, 211, 153)}✓${c.reset} ${c.bold}Balance${c.reset} Total: ${c.bold}${c.green}⏣ ${(totalWallet + totalBank).toLocaleString()}${c.reset} ${c.dim}(wallet: ⏣ ${totalWallet.toLocaleString()} + bank: ⏣ ${totalBank.toLocaleString()})${c.reset}`);
3201
3324