dankgrinder 7.77.0 → 7.78.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
@@ -3,6 +3,7 @@ const Redis = require('ioredis');
3
3
  const commands = require('./commands');
4
4
  const { setDashboardActive, isCV2, ensureCV2, stripAnsi } = require('./commands/utils');
5
5
  const rawLogger = require('./rawLogger');
6
+ const terminal = require('./terminal');
6
7
  const {
7
8
  BloomFilter, RingBuffer, TokenBucket, EMA, SlidingWindowCounter,
8
9
  AhoCorasick, LRUCache, StringPool, AsyncBatchQueue, JitterBackoff,
@@ -1623,6 +1624,8 @@ class AccountWorker {
1623
1624
  // ── Death / lifesaver detection in command responses ──
1624
1625
  if (resultLower.includes('you died') || resultLower.includes('lifesaver protected')) {
1625
1626
  this.log('error', `DEATH DETECTED during ${cmdName}!`);
1627
+ terminal.flashEvent('death', `💀 ${this.username} died — ${lsCount === 0 ? '0 lifesavers!' : (lsCount > 0 ? `${lsCount} lifesavers left` : 'check DM')}`);
1628
+ terminal.markWorkerDirty(this.idx);
1626
1629
  // Check for lifesaver count in the response
1627
1630
  const lsMatch = result.match(/(\d+)\s*life\s*saver/i);
1628
1631
  const lsCount = lsMatch ? parseInt(lsMatch[1]) : -1;
@@ -1639,6 +1642,8 @@ class AccountWorker {
1639
1642
  await redis.set(`dkg:lifesavers:${this.account.id}`, String(lsCount), 'EX', 86400);
1640
1643
  this.log('warn', `Lifesaver used! ${lsCount} remaining.`);
1641
1644
  if (lsCount <= 2) {
1645
+ terminal.flashEvent('lowls', `⚠ ${this.username} low lifesavers: ${lsCount} left`);
1646
+ terminal.markWorkerDirty(this.idx);
1642
1647
  sendWebhook('LOW LIFESAVERS', `**${this.username}** has only **${lsCount}** lifesaver(s) left!`, 0xfbbf24);
1643
1648
  }
1644
1649
  }
@@ -1758,6 +1763,8 @@ class AccountWorker {
1758
1763
  this.setStatus(formattedResult);
1759
1764
  await sendLog(this.username, cmdName, result, 'success');
1760
1765
  reportEarnings(this.account.id, this.username, earned, spent, cmdName);
1766
+ terminal.markWorkerDirty(this.idx);
1767
+ if (earned > 0) terminal.flashEvent('success', `⚔ ${this.username} ${cmdName}: +⏣ ${earned.toLocaleString()}`);
1761
1768
 
1762
1769
  // Auto-sell fish every 5 fishing rounds
1763
1770
  if (cmdName === 'fish') {
@@ -2844,6 +2851,8 @@ async function start(apiKey, apiUrl, opts = {}) {
2844
2851
  w.log?.('error', `DEATH in DMs! 0 lifesavers — disabling crime/search`);
2845
2852
  w.setCooldown?.('crime', 86400);
2846
2853
  w.setCooldown?.('search', 86400);
2854
+ terminal.flashEvent('death', `💀 ${w.username} DEATH in DM — 0 lifesavers!`);
2855
+ terminal.markWorkerDirty(w.idx);
2847
2856
  sendWebhook?.('DEATH ALERT (DM)', `**${w.username}** died in DMs! **0 lifesavers!**\nCrime/search auto-disabled.`, 0xef4444);
2848
2857
  } else {
2849
2858
  w.log?.('warn', `DEATH in DMs — ${event.lifesaversLeft} lifesavers remaining`);
@@ -2861,6 +2870,8 @@ async function start(apiKey, apiUrl, opts = {}) {
2861
2870
  if (event.type === 'levelup') {
2862
2871
  if (event.to > 0) {
2863
2872
  w._level = event.to;
2873
+ terminal.flashEvent('levelup', `⬆️ ${w.username} leveled up to Lv.${event.to}`);
2874
+ terminal.markWorkerDirty(w.idx);
2864
2875
  }
2865
2876
  }
2866
2877
  }
@@ -2876,8 +2887,12 @@ async function start(apiKey, apiUrl, opts = {}) {
2876
2887
  console.log(` ${checks.join(' ')}`);
2877
2888
  console.log('');
2878
2889
 
2890
+ // ── Terminal renderer init ─────────────────────────────────────
2891
+ terminal.setVersion(PKG_VERSION);
2892
+ terminal.init({ workers, startTime });
2893
+ terminal.startPhase(`Connecting ${accounts.length} accounts to Discord`);
2894
+
2879
2895
  // ── Phase 1: Login accounts ─────────────────────────────────────────
2880
- console.log(` ${rgb(52, 211, 153)}✓${c.reset} Logging in ${accounts.length} account(s)...`);
2881
2896
  const parsedGapMin = Number.parseInt(String(process.env.LOGIN_GAP_MIN_MS || '50'), 10);
2882
2897
  const parsedGapMax = Number.parseInt(String(process.env.LOGIN_GAP_MAX_MS || '150'), 10);
2883
2898
  const LOGIN_GAP_MIN_MS = Number.isFinite(parsedGapMin) && parsedGapMin >= 0 ? parsedGapMin : 50;
@@ -2894,6 +2909,7 @@ async function start(apiKey, apiUrl, opts = {}) {
2894
2909
  workers.push(worker);
2895
2910
  workerMap.set(acc.id, worker);
2896
2911
  await worker.start();
2912
+ terminal.updateProgress(workers.length, accounts.length);
2897
2913
  }));
2898
2914
  if (i + BATCH_SIZE < accounts.length) await new Promise(r => setTimeout(r, randomLoginGap()));
2899
2915
  hintGC();
@@ -2902,8 +2918,9 @@ async function start(apiKey, apiUrl, opts = {}) {
2902
2918
  const loginDone = workers.filter(w => !w._tokenInvalid && w.channel).length;
2903
2919
  const invalidWorkers = workers.filter(w => w._tokenInvalid);
2904
2920
  const timedOutWorkers = workers.filter(w => !w.channel && !w._tokenInvalid);
2905
- console.log(` ${rgb(52, 211, 153)}✓${c.reset} ${loginDone}/${accounts.length} accounts connected`);
2921
+ terminal.endPhase(`${loginDone}/${accounts.length} accounts connected`, true);
2906
2922
  if (invalidWorkers.length > 0) {
2923
+ terminal.flashEvent('error', `${invalidWorkers.length} accounts have INVALID tokens`);
2907
2924
  log('warn', `${rgb(239, 68, 68)}${invalidWorkers.length} account(s) have INVALID tokens`);
2908
2925
  for (const w of invalidWorkers) log('error', ` ${w.account.label || w.account.id} — token invalid or expired`);
2909
2926
  }
@@ -2912,24 +2929,31 @@ async function start(apiKey, apiUrl, opts = {}) {
2912
2929
  const activeWorkers = workers.filter(w => !w._tokenInvalid);
2913
2930
 
2914
2931
  // ── Phase 2: Inventory check ─────────────────────────────────────
2915
- console.log(` ${rgb(34, 211, 238)}·${c.reset} Checking inventory (${activeWorkers.length} accounts)...`);
2932
+ terminal.startPhase('Checking inventory');
2933
+ terminal.updateProgress(0, activeWorkers.length);
2916
2934
  let invDone = 0, invFailed = 0;
2917
2935
  await Promise.all(activeWorkers.map(async (w) => {
2918
2936
  try { await w.checkInventory({ force: true, requireComplete: true, maxAttempts: 3, silent: true }); }
2919
2937
  catch { invFailed++; return; }
2920
2938
  invDone++;
2939
+ terminal.updateProgress(invDone, activeWorkers.length);
2921
2940
  }));
2922
2941
 
2923
2942
  if (invFailed > 0) {
2943
+ terminal.endPhase(`Inventory: ${invFailed} failed`, false);
2924
2944
  console.log(` ${rgb(239, 68, 68)}✗${c.reset} Inventory: ${invFailed} failed — not starting grind loops`);
2925
2945
  return;
2926
2946
  }
2927
- console.log(` ${rgb(52, 211, 153)}✓${c.reset} Inventory: ${invDone}/${activeWorkers.length} clear`);
2947
+ terminal.endPhase(`Inventory: ${invDone} clear`);
2928
2948
 
2929
2949
  // ── Phase 2.5: Balance check ────────────────────────────────────
2930
- console.log(` ${rgb(251, 191, 36)}·${c.reset} Checking balance (${activeWorkers.length} accounts)...`);
2950
+ terminal.startPhase('Checking balance');
2951
+ terminal.updateProgress(0, activeWorkers.length);
2952
+ let balDone = 0;
2931
2953
  await Promise.all(activeWorkers.map(async (w) => {
2932
2954
  try { await w.checkBalance(true); } catch {}
2955
+ balDone++;
2956
+ terminal.updateProgress(balDone, activeWorkers.length);
2933
2957
  }));
2934
2958
 
2935
2959
  let totalWallet = 0, totalBank = 0, noLifesaverAccounts = [];
@@ -2938,13 +2962,16 @@ async function start(apiKey, apiUrl, opts = {}) {
2938
2962
  totalBank += w.stats?.bankBalance || 0;
2939
2963
  if (w._lifesavers === 0) noLifesaverAccounts.push(w.username || w.account.label);
2940
2964
  }
2941
- console.log(` ${rgb(52, 211, 153)}✓${c.reset} Balance: ${c.green}⏣ ${(totalWallet + totalBank).toLocaleString()}${c.reset} ${c.dim}(wallet: ⏣ ${totalWallet.toLocaleString()} + bank: ⏣ ${totalBank.toLocaleString()})${c.reset}`);
2965
+ terminal.endPhase(`Balance: ⏣ ${(totalWallet + totalBank).toLocaleString()}`);
2942
2966
  if (noLifesaverAccounts.length > 0) {
2967
+ terminal.flashEvent('warn', `${noLifesaverAccounts.length} accounts have 0 LIFESAVERS`);
2943
2968
  console.log(` ${rgb(239, 68, 68)}⚠${c.reset} ${noLifesaverAccounts.length} account(s) have 0 LIFESAVERS — crime/search disabled`);
2944
2969
  }
2945
2970
 
2946
2971
  // ── Phase 2.75: DM history check ────────────────────────────────
2947
- console.log(` ${rgb(139, 92, 246)}·${c.reset} Checking DM history...`);
2972
+ terminal.startPhase('Checking DM history');
2973
+ terminal.updateProgress(0, activeWorkers.length);
2974
+ let dmDone = 0;
2948
2975
  let dmDeaths = 0, dmLevelUps = 0, dmNoLs = [], dmUnknown = [];
2949
2976
  for (const w of activeWorkers) {
2950
2977
  try {
@@ -2956,8 +2983,11 @@ async function start(apiKey, apiUrl, opts = {}) {
2956
2983
  if (dm.currentLevel > 0) w._level = dm.currentLevel;
2957
2984
  if (dm.lifesavers >= 0) w._lifesavers = dm.lifesavers;
2958
2985
  } catch {}
2986
+ dmDone++;
2987
+ terminal.updateProgress(dmDone, activeWorkers.length);
2959
2988
  }
2960
2989
  if (dmNoLs.length > 0) {
2990
+ terminal.flashEvent('warn', `No lifesavers: ${dmNoLs.join(', ')}`);
2961
2991
  log('warn', `⚠ No lifesavers: ${dmNoLs.join(', ')}`);
2962
2992
  for (const w of activeWorkers) {
2963
2993
  if (dmNoLs.includes(w.username) && redis) {
@@ -2975,17 +3005,16 @@ async function start(apiKey, apiUrl, opts = {}) {
2975
3005
  if (dmDeaths > 0) dmSummaryParts.push(`${dmDeaths} deaths`);
2976
3006
  if (dmLevelUps > 0) dmSummaryParts.push(`${dmLevelUps} level-ups`);
2977
3007
  if (dmUnknown.length > 0) dmSummaryParts.push(`${dmUnknown.length} pending`);
2978
- console.log(` ${rgb(52, 211, 153)}✓${c.reset} DM check: ${dmSummaryParts.length > 0 ? dmSummaryParts.join(', ') : 'clean'}`);
3008
+ terminal.endPhase(`DM check: ${dmSummaryParts.length > 0 ? dmSummaryParts.join(', ') : 'clean'}`);
2979
3009
 
2980
3010
  // ── Phase 3: Start grind loops ───────────────────────────────────
2981
- console.log(` ${rgb(139, 92, 246)}>>>${c.reset} Starting grind loops...`);
2982
- // Phase 3: Start all grind loops (only for valid workers)
3011
+ terminal.startPhase(`🚀 Launching ${activeWorkers.length} grinders!`);
2983
3012
  for (const w of activeWorkers) {
2984
3013
  if (!shutdownCalled) w.grindLoop();
2985
3014
  }
2986
-
2987
- console.log(` ${rgb(52, 211, 153)}✓${c.reset} All grind loops started — ${activeWorkers.length} accounts active`);
2988
- console.log(` v${PKG_VERSION} | press Ctrl+C to stop`);
3015
+ terminal.endPhase(`${activeWorkers.length} grinders active`);
3016
+ terminal.setWorkers(workers);
3017
+ terminal.setActive();
2989
3018
 
2990
3019
  // Cluster heartbeat — lets other nodes see this node is alive
2991
3020
  if (CLUSTER_ENABLED) {
@@ -3052,35 +3081,26 @@ async function start(apiKey, apiUrl, opts = {}) {
3052
3081
  sigintHandled = true;
3053
3082
  shutdownCalled = true;
3054
3083
  setDashboardActive(false);
3055
- process.stdout.write('\x1b[?25h'); // show cursor
3056
-
3057
- const tw = process.stdout.columns || 80;
3058
- const sepBar = rgb(139, 92, 246) + c.bold + '─'.repeat(tw - 4) + c.reset;
3059
- console.log('');
3060
- console.log(` ${rgb(251, 191, 36)}${c.bold}Session Summary${c.reset}`);
3061
- console.log(sepBar);
3062
3084
 
3063
- // Collect stats from all workers (including rotated-out ones)
3064
- let finalCoins = 0;
3065
- let finalCmds = 0;
3085
+ // Collect stats for summary
3086
+ let finalCoins = 0, finalCmds = 0, totalSuccess = 0;
3066
3087
  for (const wk of workers) {
3067
- const rate = wk.stats.commands > 0 ? ((wk.stats.successes / wk.stats.commands) * 100).toFixed(0) : 0;
3068
- console.log(
3069
- ` ${wk.color}${c.bold}${(wk.username || '?').padEnd(18)}${c.reset}` +
3070
- ` ${rgb(52, 211, 153)}+⏣ ${wk.stats.coins.toLocaleString().padStart(8)}${c.reset}` +
3071
- ` ${c.dim}${wk.stats.commands.toString().padStart(4)} cmds${c.reset}` +
3072
- ` ${c.dim}${rate}% success${c.reset}`
3073
- );
3074
3088
  finalCoins += wk.stats.coins || 0;
3075
3089
  finalCmds += wk.stats.commands || 0;
3090
+ totalSuccess += wk.stats.successes || 0;
3076
3091
  }
3077
- console.log(sepBar);
3078
-
3079
3092
  const memFinal = Math.round((process.memoryUsage?.rss?.() ?? process.memoryUsage().rss) / 1048576);
3080
- const avgEarn = globalEarningsEMA.get();
3081
- const cpm = globalCmdRate.getRate().toFixed(1);
3082
- console.log(` ${rgb(251, 191, 36)}${c.bold}Total: +⏣ ${finalCoins.toLocaleString()}${c.reset} ${c.dim}in ${formatUptime()} | ${finalCmds} cmds | ~${cpm} cmd/m | ${memFinal}MB | avg earn ⏣ ${Math.round(avgEarn)}${c.reset}`);
3083
- console.log('');
3093
+ const uptimeMs = Date.now() - startTime;
3094
+
3095
+ // Show beautiful session summary via terminal renderer
3096
+ terminal.shutdown({
3097
+ totalCoins: finalCoins,
3098
+ totalCmds: finalCmds,
3099
+ totalSuccess: finalCmds > 0 ? ((totalSuccess / finalCmds) * 100).toFixed(0) : 0,
3100
+ workers,
3101
+ uptime: uptimeMs,
3102
+ memMB: memFinal,
3103
+ });
3084
3104
 
3085
3105
  // Release all cluster claims before stopping workers
3086
3106
  const releasePromises = workers.map(wk => releaseClaim(wk.account.id).catch(() => {}));
@@ -3096,22 +3116,14 @@ async function start(apiKey, apiUrl, opts = {}) {
3096
3116
 
3097
3117
  const totalRecoveries = workers.reduce((sum, wk) => sum + (wk._totalRecoveries || 0), 0);
3098
3118
  const totalDisconnects = workers.reduce((sum, wk) => sum + (wk._disconnectCount || 0), 0);
3099
- const totalRateLimits = workers.reduce((sum, wk) => sum + (wk._rateLimitHits || 0), 0);
3100
3119
 
3101
3120
  const webhookMsg = `+⏣ ${finalCoins.toLocaleString()} | ${finalCmds} cmds | ${formatUptime()}` +
3102
3121
  (totalRecoveries > 0 ? ` | ${totalRecoveries} auto-recoveries` : '') +
3103
3122
  (CLUSTER_ENABLED ? ` | node: ${NODE_ID.substring(0, 12)}` : '');
3104
3123
  sendWebhook('Session Ended', webhookMsg, 0x8b5cf6);
3105
3124
 
3106
- if (totalRecoveries > 0 || totalDisconnects > 0) {
3107
- console.log(` ${c.dim}Recovery stats: ${totalRecoveries} auto-recoveries, ${totalDisconnects} disconnects, ${totalRateLimits} rate limits${c.reset}`);
3108
- }
3109
- if (CLUSTER_ENABLED) {
3110
- console.log(` ${c.dim}Cluster node: ${NODE_ID} — claims released${c.reset}`);
3111
- }
3112
-
3113
3125
  if (redis) { try { redis.disconnect(); } catch {} }
3114
- setTimeout(() => process.exit(0), 1500);
3126
+ setTimeout(() => process.exit(0), 500);
3115
3127
  });
3116
3128
  }
3117
3129
 
@@ -0,0 +1,820 @@
1
+ /**
2
+ * terminal.js — Modern animated terminal renderer for DankGrinder
3
+ *
4
+ * Design goals:
5
+ * - Single-line-per-account rows (scales to 10k+ accounts)
6
+ * - Virtual window: only renders visible slice of accounts
7
+ * - 4 FPS render loop with dirty-row tracking (no flicker)
8
+ * - Graceful degradation: falls back to console.log if not a TTY
9
+ * - Graceful mode: Ctrl+C always restores cursor + shows summary
10
+ *
11
+ * Layout (fixed row heights, live-updated):
12
+ *
13
+ * ┌─ DANKGRINDER v7.77.0 ─────────────────────────────────────────────────────┐
14
+ * │ ⏱ 00:32 ⬡ 5 accounts ⏣ 47,230 ⚡ 12 cmd/m 📊 89% success │
15
+ * ├─ ACCOUNTS ──────────────────────────────────────────────────────────────────┤
16
+ * │ 💎 alice_99 ⏣ 12,450 L:24 ♥3 34cmds 🟢 grinding ⚔ adventure │
17
+ * │ 💎 bob_trades ⏣ 8,920 L:18 ♥5 28cmds 🟢 grinding 🐟 fishing │
18
+ * │ 💎 crypto_king ⏣ 5,100 L:31 ♥0 19cmds 🟡 depositing 💰 beg │
19
+ * │ 💎 diamond_h.. ⏣ 3,780 L:12 ♥8 14cmds 🟢 grinding 🌾 farm │
20
+ * │ 💎 moon_wallet ⏣ 1,200 L:9 ♥2 8cmds 🔴 paused ⚠ captcha │
21
+ * ├─ EVENTS ────────────────────────────────────────────────────────────────────┤
22
+ * │ 00:32 ⚔ alice_99 adventure: +⏣ 850 │
23
+ * │ 00:31 💀 crypto_king DEATH — 0 lifesavers! crime/search disabled │
24
+ * │ 00:30 ⬆️ bob_trades leveled up to Lv.19 │
25
+ * │ 00:28 🌾 diamond_hands farm: +⏣ 2,100 │
26
+ * └──────────────────────────────────────────────────────────────────────────────┘
27
+ */
28
+
29
+ const READY = (() => {
30
+ try {
31
+ return process.stdout.isTTY && !process.env.NO_TERM;
32
+ } catch (_) { return false; }
33
+ })();
34
+
35
+ // ── ANSI Helpers ────────────────────────────────────────────────────────────
36
+
37
+ const A = {
38
+ reset: '\x1b[0m',
39
+ bold: '\x1b[1m',
40
+ dim: '\x1b[2m',
41
+ italic: '\x1b[3m',
42
+
43
+ black: '\x1b[30m',
44
+ red: '\x1b[31m',
45
+ green: '\x1b[32m',
46
+ yellow: '\x1b[33m',
47
+ blue: '\x1b[34m',
48
+ magenta: '\x1b[35m',
49
+ cyan: '\x1b[36m',
50
+ white: '\x1b[37m',
51
+
52
+ bgBlack: '\x1b[40m',
53
+ bgRed: '\x1b[41m',
54
+ bgGreen: '\x1b[42m',
55
+ bgYellow: '\x1b[43m',
56
+ bgBlue: '\x1b[44m',
57
+ bgMagenta: '\x1b[45m',
58
+ bgCyan: '\x1b[46m',
59
+ bgWhite: '\x1b[47m',
60
+
61
+ // 256-color RGB shortcuts
62
+ rgb: (r, g, b) => `\x1b[38;2;${r};${g};${b}m`,
63
+
64
+ // Cursor
65
+ save: '\x1b7',
66
+ restore: '\x1b8',
67
+ hide: '\x1b[?25l',
68
+ show: '\x1b[?25h',
69
+ up: (n = 1) => `\x1b[${n}A`,
70
+ down: (n = 1) => `\x1b[${n}B`,
71
+ right: (n = 1) => `\x1b[${n}C`,
72
+ left: (n = 1) => `\x1b[${n}D`,
73
+ col: (n) => `\x1b[${n}G`,
74
+ clear: '\x1b[2J',
75
+ clearLine: '\x1b[2K',
76
+ home: '\x1b[H',
77
+
78
+ // 256-color palette
79
+ purple: '\x1b[38;5;141m', // #8b5cf6
80
+ pink: '\x1b[38;5;205m', // #ff5c93
81
+ orange: '\x1b[38;5;214m', // #ff9f43
82
+ teal: '\x1b[38;5;44m', // #2dd4bf
83
+ lime: '\x1b[38;5;82m', // #4cd137
84
+ crimson: '\x1b[38;5;196m', // #ff4757
85
+ slate: '\x1b[38;5;245m', // #a0a0b0
86
+ gold: '\x1b[38;5;220m', // #ffc233
87
+ emerald: '\x1b[38;5;48m', // #2ed573
88
+ };
89
+
90
+ // ── Color scheme ────────────────────────────────────────────────────────────
91
+
92
+ const C = {
93
+ header: A.purple,
94
+ headerDim: A.rgb(100, 70, 180),
95
+ border: A.rgb(60, 50, 90),
96
+ borderDim: A.rgb(40, 35, 60),
97
+
98
+ rowDefault: A.white,
99
+ rowAlt: A.rgb(30, 28, 45),
100
+ rowHighlight: A.rgb(25, 23, 40),
101
+
102
+ name: A.rgb(255, 255, 255),
103
+ nameDim: A.slate,
104
+
105
+ coins: A.gold,
106
+ level: A.cyan,
107
+ lifesavers: A.pink,
108
+ lifesaversLow: A.crimson,
109
+ lifesaversMid: A.orange,
110
+
111
+ statusActive: A.emerald, // 🟢 grinding
112
+ statusPaused: A.crimson, // 🔴 paused
113
+ statusWarning: A.orange, // 🟡 warning
114
+ statusOffline: A.slate, // ⚫ offline
115
+ statusConnecting: A.yellow, // 🟡 connecting
116
+
117
+ cmdSuccess: A.emerald,
118
+ cmdError: A.crimson,
119
+ cmdEvent: A.purple,
120
+ cmdWarn: A.orange,
121
+
122
+ statLabel: A.slate,
123
+ statValue: A.white,
124
+
125
+ headerBg: A.rgb(20, 15, 35),
126
+ rowBg1: '',
127
+ rowBg2: A.rgb(25, 22, 38),
128
+ rowBgPaused: A.rgb(40, 15, 20),
129
+ rowBgWarning: A.rgb(40, 35, 15),
130
+ rowBgDeath: A.rgb(50, 15, 15),
131
+ };
132
+
133
+ // ── Spinner frames ──────────────────────────────────────────────────────────
134
+
135
+ const SPINNERS = {
136
+ dots: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠿'],
137
+ moon: ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'],
138
+ arrows: ['←', '↖', '↑', '↗', '→', '↘', '↓', '↙'],
139
+ pulse: ['▓', '▒', '░', '▒'],
140
+ };
141
+
142
+ // ── Terminal Renderer ────────────────────────────────────────────────────────
143
+
144
+ class Terminal {
145
+ constructor() {
146
+ this.workers = [];
147
+ this.events = []; // recent event feed (last N events)
148
+ this.MAX_EVENTS = 5;
149
+
150
+ this.phaseName = '';
151
+ this.phaseFrame = 0;
152
+ this.phaseTimer = null;
153
+
154
+ this.dirtyWorkers = new Set();
155
+ this.dirtyEvents = false;
156
+ this.dirtyStats = true;
157
+ this._renderTimer = null;
158
+ this._startTime = 0;
159
+
160
+ // Virtual window state
161
+ this.windowStart = 0;
162
+ this.windowSize = 10;
163
+ this.scrollLocked = false; // true = auto-follow most active worker
164
+
165
+ this._lineCount = 0;
166
+ this._active = false;
167
+ this._shutdown = false;
168
+
169
+ // Row offsets (set during render)
170
+ this._headerRow = 0;
171
+ this._statsRow = 0;
172
+ this._accountsRow = 0;
173
+ this._eventsRow = 0;
174
+ this._footerRow = 0;
175
+
176
+ // ── Scroll position state ──
177
+ this._followWorkerIdx = -1; // -1 = follow newest active worker
178
+
179
+ this._w = 80;
180
+ this._h = 24;
181
+ this._resizeTimer = null;
182
+ this._col = (n) => `\x1b[${n}G`;
183
+ }
184
+
185
+ // ── Public API ──────────────────────────────────────────────────────────
186
+
187
+ init(opts = {}) {
188
+ if (opts.startTime) this._startTime = opts.startTime;
189
+ this.workers = opts.workers || [];
190
+ this._updateSize();
191
+
192
+ if (READY) {
193
+ process.stdout.write(A.hide);
194
+ process.stdout.on('resize', this._onResize.bind(this));
195
+ this._drawStartupScreen();
196
+ } else {
197
+ console.log(`${C.header}🚀 DankGrinder starting...${A.reset}`);
198
+ }
199
+ }
200
+
201
+ setVersion(v) { this._version = v; }
202
+
203
+ startPhase(name) {
204
+ this.phaseName = name;
205
+ this.phaseFrame = 0;
206
+ if (!READY) {
207
+ console.log(` ${A.dim}⟳${A.reset} ${name}...`);
208
+ return;
209
+ }
210
+ if (this.phaseTimer) clearInterval(this.phaseTimer);
211
+ this.phaseTimer = setInterval(() => {
212
+ this.phaseFrame = (this.phaseFrame + 1) % SPINNERS.dots.length;
213
+ this._redrawPhaseSpinner();
214
+ }, 120);
215
+ this._redrawPhaseSpinner();
216
+ }
217
+
218
+ updateProgress(done, total) {
219
+ if (!READY) return;
220
+ this._writePhaseProgress(done, total);
221
+ }
222
+
223
+ endPhase(name, ok = true) {
224
+ if (this.phaseTimer) { clearInterval(this.phaseTimer); this.phaseTimer = null; }
225
+ if (!READY) {
226
+ const icon = ok ? `${C.header}✓${A.reset}` : `${A.crimson}✗${A.reset}`;
227
+ console.log(` ${icon} ${name}`);
228
+ return;
229
+ }
230
+ const icon = ok ? `${C.header}✓${A.reset}` : `${A.crimson}✗${A.reset}`;
231
+ const frame = SPINNERS.dots[0];
232
+ const cols = this._w;
233
+ const label = ` ${frame} ${name}`;
234
+ const pad = cols - this._ansiLen(label) - 4;
235
+ process.stdout.write(
236
+ A.save +
237
+ this._cursor(2, 1) +
238
+ A.clearLine +
239
+ `${A.save}${A.home}` +
240
+ `${label}${pad > 0 ? ' '.repeat(pad) : ''}` +
241
+ `${this._rpad('', cols - this._ansiLen(` ${icon} ${name}`) - 2)}${icon} ${name}` +
242
+ A.restore
243
+ );
244
+ // Clear spinner line
245
+ process.stdout.write(this._cursor(3, 1) + A.clearLine);
246
+ }
247
+
248
+ flashEvent(type, msg) {
249
+ const now = new Date();
250
+ const ts = `${A.dim}${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}${A.reset}`;
251
+ this.events.unshift({ ts, type, msg, id: Date.now() });
252
+ if (this.events.length > this.MAX_EVENTS) this.events.pop();
253
+ this.dirtyEvents = true;
254
+ }
255
+
256
+ setWorkers(workers) {
257
+ this.workers = workers;
258
+ this.dirtyWorkers = new Set(workers.map((_, i) => i));
259
+ this.dirtyStats = true;
260
+ }
261
+
262
+ markWorkerDirty(idx) {
263
+ this.dirtyWorkers.add(idx);
264
+ }
265
+
266
+ setActive() {
267
+ if (this._active) return;
268
+ this._active = true;
269
+ if (!READY) return;
270
+
271
+ // Clear startup, draw live view
272
+ process.stdout.write(A.clear + A.home);
273
+ this._drawLiveView();
274
+ this._startRenderLoop();
275
+ }
276
+
277
+ scrollBy(delta) {
278
+ if (!READY || this._shutdown) return;
279
+ const max = Math.max(0, this.workers.length - this.windowSize);
280
+ this.windowStart = Math.max(0, Math.min(max, this.windowStart + delta));
281
+ this._followWorkerIdx = -1; // manual scroll cancels auto-follow
282
+ this.dirtyWorkers = new Set();
283
+ for (let i = this.windowStart; i < this.windowStart + this.windowSize; i++) {
284
+ if (i < this.workers.length) this.dirtyWorkers.add(i);
285
+ }
286
+ }
287
+
288
+ followWorker(idx) {
289
+ this._followWorkerIdx = idx;
290
+ this.scrollLocked = true;
291
+ // Ensure the worker is visible
292
+ if (idx < this.windowStart) {
293
+ this.windowStart = idx;
294
+ } else if (idx >= this.windowStart + this.windowSize) {
295
+ this.windowStart = idx - this.windowSize + 1;
296
+ }
297
+ }
298
+
299
+ shutdown(summary = {}) {
300
+ this._shutdown = true;
301
+ if (this._renderTimer) { clearInterval(this._renderTimer); this._renderTimer = null; }
302
+ if (this._phaseTimer) { clearInterval(this._phaseTimer); this._phaseTimer = null; }
303
+
304
+ process.stdout.write(A.show);
305
+
306
+ if (!READY) {
307
+ this._printSummaryPlain(summary);
308
+ return;
309
+ }
310
+
311
+ // Move to clean area below any existing output
312
+ const cols = this._w;
313
+ const { totalCoins = 0, totalCmds = 0, totalSuccess = 0,
314
+ workers = [], uptime = 0, memMB = 0 } = summary;
315
+
316
+ const b = C.border;
317
+ const h = C.header;
318
+ const g = C.statValue;
319
+ const dim = A.dim;
320
+ const r = A.reset;
321
+
322
+ const sep = (c) => `${b}${c.repeat(cols - 2)}${r}`;
323
+ const bar = `${b}${'─'.repeat(cols - 2)}${r}`;
324
+ const icon = (e) => `${h}${e}${r}`;
325
+
326
+ let out = '';
327
+ out += `${A.clear}${A.home}`;
328
+ out += `${A.save}`;
329
+
330
+ // Box title
331
+ out += `${this._at(1, 1)}${bar}`;
332
+ out += `${this._at(2, 1)}${b} ${h}${A.bold}⬡ DANKGRINDER — Session Summary${r} ${b}${'─'.repeat(Math.max(0, cols - 37))}${r}`;
333
+ out += `${this._at(3, 1)}${bar}`;
334
+
335
+ // Per-account summary
336
+ let row = 4;
337
+ for (const wk of workers) {
338
+ const coins = `+⏣ ${(wk.stats?.coins || 0).toLocaleString()}`;
339
+ const cmds = `${wk.stats?.commands || 0}cmds`;
340
+ const rate = wk.stats?.commands > 0
341
+ ? `${((wk.stats.successes / wk.stats.commands) * 100).toFixed(0)}%`
342
+ : '0%';
343
+ const ls = wk._lifesavers ?? '?';
344
+ const lv = wk._level ?? '?';
345
+ const line = ` ${icon('💎')} ${g}${A.bold}${(wk.username || '?').padEnd(18)}${r} ${C.coins}${coins}${r} ${dim}${cmds}${r} ${g}${rate} OK${r} ${C.level}Lv.${lv}${r} ${C.lifesavers}♥${ls}${r}`;
346
+ out += `${this._at(row++, 1)}${b}${this._rpad(line, cols - 2)}${r}`;
347
+ }
348
+
349
+ row++; // blank row
350
+ out += `${this._at(row++, 1)}${bar}`;
351
+
352
+ // Totals
353
+ const totalLine = ` ${icon('💰')} ${h}${A.bold}Total:${r} ${C.coins}${A.bold}⏣ ${totalCoins.toLocaleString()}${r} ${dim}${totalCmds} cmds${r} ${g}${totalSuccess}% OK${r} ${dim}${this._fmtUptime(uptime)}${r} ${dim}${memMB}MB${r}`;
354
+ out += `${this._at(row++, 1)}${b}${this._rpad(totalLine, cols - 2)}${r}`;
355
+ out += `${this._at(row++, 1)}${sep('─')}`;
356
+ out += `${A.restore}`;
357
+ out += `${A.show}`;
358
+
359
+ process.stdout.write(out);
360
+ setTimeout(() => {}, 100);
361
+ }
362
+
363
+ // ── Internal ────────────────────────────────────────────────────────────
364
+
365
+ _updateSize() {
366
+ try {
367
+ this._w = process.stdout.columns || 80;
368
+ this._h = process.stdout.rows || 24;
369
+ // Window size: leave room for header (3 rows) + stats (2) + events footer (3) + border (2)
370
+ this.windowSize = Math.max(3, this._h - 12);
371
+ } catch (_) {
372
+ this._w = 80; this._h = 24; this.windowSize = 10;
373
+ }
374
+ }
375
+
376
+ _onResize() {
377
+ if (this._resizeTimer) clearTimeout(this._resizeTimer);
378
+ this._resizeTimer = setTimeout(() => {
379
+ this._updateSize();
380
+ if (this._active) {
381
+ this._clearScreen();
382
+ this._drawLiveView();
383
+ }
384
+ }, 100);
385
+ }
386
+
387
+ _ansiLen(s) {
388
+ // Fast ANSI strip — just count escape sequences by looking for \x1b[
389
+ let len = 0;
390
+ let i = 0;
391
+ const str = String(s);
392
+ while (i < str.length) {
393
+ if (str.charCodeAt(i) === 0x1b && str[i + 1] === '[') {
394
+ let j = i + 2;
395
+ while (j < str.length && str[j] !== 'm') j++;
396
+ i = j + 1;
397
+ } else {
398
+ len++;
399
+ i++;
400
+ }
401
+ }
402
+ return len;
403
+ }
404
+
405
+ _rpad(s, width) {
406
+ const len = this._ansiLen(s);
407
+ const pad = width > len ? width - len : 0;
408
+ return s + (pad > 0 ? ' '.repeat(pad) : '');
409
+ }
410
+
411
+ _cursor(row, col) {
412
+ // Position cursor at absolute row/col (1-indexed)
413
+ return `\x1b[${row};${col}H`;
414
+ }
415
+
416
+ _at(row, col) {
417
+ return `\x1b[${row};${col}H`;
418
+ }
419
+
420
+ _write(str) {
421
+ process.stdout.write(str);
422
+ }
423
+
424
+ _ansi(s, code) { return `${code}${s}${A.reset}`; }
425
+ _bold(s) { return `${A.bold}${s}${A.reset}`; }
426
+ _dim(s) { return `${A.dim}${s}${A.reset}`; }
427
+
428
+ _fmtUptime(ms) {
429
+ if (!ms) return '0s';
430
+ const s = Math.floor(ms / 1000);
431
+ if (s < 60) return `${s}s`;
432
+ const m = Math.floor(s / 60);
433
+ if (m < 60) return `${m}m ${s % 60}s`;
434
+ const h = Math.floor(m / 60);
435
+ if (h < 24) return `${h}h ${m % 60}m`;
436
+ const d = Math.floor(h / 24);
437
+ return `${d}d ${h % 24}h`;
438
+ }
439
+
440
+ _fmtCoins(n) {
441
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
442
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
443
+ return String(n);
444
+ }
445
+
446
+ _buildAccountRow(wk, idx) {
447
+ const w = this._w;
448
+ // Row layout (all in one line):
449
+ // #N 💎 username ⏣coins Lv.N ♥N Ncmds ● status ⚔ current_cmd
450
+ // We need to fit within `w` columns, truncate username if needed
451
+
452
+ const num = `${idx + 1}.`.padEnd(3);
453
+ const username = (wk.username || '?').substring(0, 16).padEnd(17);
454
+ const coins = `⏣${this._fmtCoins(wk.stats?.coins || 0)}`.padEnd(8);
455
+ const level = `Lv.${wk._level ?? '?'}`.padEnd(5);
456
+ const ls = wk._lifesavers ?? '?';
457
+ const lifesaversColor = ls === 0 ? C.lifesaversLow
458
+ : ls <= 2 ? C.lifesaversMid
459
+ : C.lifesavers;
460
+ const lifesavers = `${lifesaversColor}♥${ls}`.padEnd(4);
461
+ const cmds = `${wk.stats?.commands || 0}cmds`.padEnd(7);
462
+
463
+ // Status dot + text
464
+ let statusDot, statusText, rowBg;
465
+ if (!wk.running || wk._tokenInvalid) {
466
+ statusDot = '⚫'; statusText = 'offline'; rowBg = C.rowBgPaused;
467
+ } else if (wk.paused) {
468
+ statusDot = '🔴'; statusText = 'paused'; rowBg = C.rowBgPaused;
469
+ } else if (wk.dashboardPaused) {
470
+ statusDot = '🟠'; statusText = 'dashboard'; rowBg = C.rowBgWarning;
471
+ } else {
472
+ statusDot = '🟢'; statusText = 'grinding'; rowBg = '';
473
+ }
474
+
475
+ // Current command
476
+ const cmd = (wk.lastStatus || '').substring(0, 20).padEnd(21);
477
+ const successRate = wk.stats?.commands > 0
478
+ ? `${((wk.stats.successes / wk.stats.commands) * 100).toFixed(0)}%`
479
+ : '0%';
480
+
481
+ const parts = [
482
+ `${C.header}${num}${A.reset}`,
483
+ `${C.name}${username}${A.reset}`,
484
+ `${C.coins}${coins}${A.reset}`,
485
+ `${C.level}${level}${A.reset}`,
486
+ `${lifesaversColor}${lifesavers}${A.reset}`,
487
+ `${C.statValue}${cmds}${A.reset}`,
488
+ `${C.statValue}${successRate}`.padEnd(5) + A.reset,
489
+ `${statusDot} ${statusText}`.padEnd(14),
490
+ `${this._dim(cmd)}`,
491
+ ];
492
+
493
+ // Join and pad to screen width
494
+ const line = parts.join(' ');
495
+ return this._rpad(line, w);
496
+ }
497
+
498
+ _drawStartupScreen() {
499
+ const w = this._w;
500
+ const b = C.border;
501
+ const h = C.header;
502
+ const g = C.statValue;
503
+ const dim = A.dim;
504
+ const r = A.reset;
505
+
506
+ const sep = (c) => `${b}${c.repeat(w - 2)}${r}`;
507
+
508
+ // Figure out how many rows we need for the spinner
509
+ const spinnerRow = 3;
510
+ const progressRow = 5;
511
+ const readyRow = 7;
512
+
513
+ let out = '';
514
+ out += `${A.clear}${A.home}`;
515
+
516
+ // Title box
517
+ const titleText = ` ⬡ DANKGRINDER v${this._version || '?'} `;
518
+ const titlePad = w - 2 - this._ansiLen(titleText);
519
+ out += `${this._at(1, 1)}${sep('─')}`;
520
+ out += `${this._at(2, 1)}${b} ${h}${A.bold}${titleText}${r}${b}${'─'.repeat(Math.max(0, titlePad))}${r}`;
521
+ out += `${this._at(3, 1)}${sep('─')}`;
522
+
523
+ // Spinner + phase text
524
+ out += `${this._at(5, 1)}${b}${' '.repeat(w - 2)}${r}`;
525
+ out += `${this._at(6, 1)}${b}${' '.repeat(w - 2)}${r}`;
526
+
527
+ // Footer hint
528
+ out += `${this._at(8, 1)}${sep('─')}`;
529
+ const hint = `${dim}Starting up...${r}`;
530
+ out += `${this._at(9, 1)}${b} ${hint}${' '.repeat(Math.max(0, w - 2 - this._ansiLen(hint)))}${r}`;
531
+ out += `${this._at(10, 1)}${sep('─')}`;
532
+
533
+ this._lineCount = 10;
534
+ this._write(out);
535
+ }
536
+
537
+ _redrawPhaseSpinner() {
538
+ if (!READY) return;
539
+ const frame = SPINNERS.dots[this.phaseFrame];
540
+ const label = this.phaseName;
541
+ const w = this._w;
542
+
543
+ const line = ` ${frame} ${label}...`;
544
+ const pad = w - this._ansiLen(line) - 2;
545
+ const padded = line + (pad > 0 ? ' '.repeat(pad) : '');
546
+
547
+ const b = C.border;
548
+ const r = A.reset;
549
+ this._write(
550
+ `${A.save}` +
551
+ `${this._cursor(6, 1)}` +
552
+ `${b}${padded}${r}` +
553
+ `${A.restore}`
554
+ );
555
+ }
556
+
557
+ _writePhaseProgress(done, total) {
558
+ if (!READY) {
559
+ console.log(` ${A.dim} → ${done}/${total}${A.reset}`);
560
+ return;
561
+ }
562
+ const w = this._w;
563
+ const b = C.border;
564
+ const h = C.header;
565
+ const dim = A.dim;
566
+ const r = A.reset;
567
+ const barLen = Math.max(10, w - 30);
568
+ const filled = Math.round((done / total) * barLen);
569
+ const empty = barLen - filled;
570
+
571
+ const bar = `${h}${'█'.repeat(filled)}${dim}${'░'.repeat(empty)}${r}`;
572
+ const label = ` ${done}/${total} `;
573
+ const line = `${bar} ${label}`;
574
+ const pad = w - this._ansiLen(line) - 2;
575
+
576
+ this._write(
577
+ `${A.save}` +
578
+ `${this._cursor(6, 1)}` +
579
+ `${b}${line}${' '.repeat(Math.max(0, pad))}${r}` +
580
+ `${A.restore}`
581
+ );
582
+ }
583
+
584
+ _clearScreen() {
585
+ this._write(`${A.clear}${A.home}`);
586
+ }
587
+
588
+ _drawLiveView() {
589
+ if (!READY) return;
590
+ this._drawHeader();
591
+ this._drawAccounts();
592
+ this._drawEvents();
593
+ this._drawFooter();
594
+ }
595
+
596
+ _drawHeader() {
597
+ const w = this._w;
598
+ const b = C.border;
599
+ const h = C.header;
600
+ const g = C.statValue;
601
+ const dim = A.dim;
602
+ const r = A.reset;
603
+
604
+ const sep = `${b}${'─'.repeat(w - 2)}${r}`;
605
+ const title = ` ⬡ DANKGRINDER v${this._version || '?'} `;
606
+ const titlePad = w - 2 - this._ansiLen(title);
607
+
608
+ this._headerRow = 1;
609
+ this._write(`${this._at(1, 1)}${sep}`);
610
+ this._write(`${this._at(2, 1)}${b} ${h}${A.bold}${title}${r}${b}${'─'.repeat(Math.max(0, titlePad))}${r}`);
611
+ this._write(`${this._at(3, 1)}${sep}`);
612
+ this._statsRow = 4;
613
+
614
+ // Stats bar
615
+ const stats = this._buildStatsLine();
616
+ this._write(`${this._at(4, 1)}${b}${this._rpad(' ' + stats, w - 2)}${r}`);
617
+ this._accountsRow = 5;
618
+ this._write(`${this._at(5, 1)}${sep}`);
619
+ }
620
+
621
+ _buildStatsLine() {
622
+ const g = C.statValue;
623
+ const dim = A.dim;
624
+ const h = C.header;
625
+ const coins = C.coins;
626
+
627
+ let totalCoins = 0, totalCmds = 0, totalSuccess = 0, totalLs = 0;
628
+ let pausedCount = 0, activeCount = 0;
629
+
630
+ for (const wk of this.workers) {
631
+ totalCoins += wk.stats?.coins || 0;
632
+ totalCmds += wk.stats?.commands || 0;
633
+ totalSuccess += wk.stats?.successes || 0;
634
+ if (wk._lifesavers != null) totalLs += wk._lifesavers;
635
+ if (!wk.running || wk._tokenInvalid) {}
636
+ else if (wk.paused || wk.dashboardPaused) pausedCount++;
637
+ else activeCount++;
638
+ }
639
+
640
+ const uptime = this._fmtUptime(Date.now() - (this._startTime || Date.now()));
641
+ const rate = totalCmds > 0 ? ((totalSuccess / totalCmds) * 100).toFixed(0) : 0;
642
+
643
+ return [
644
+ `${dim}⏱${A.reset} ${g}${uptime}${A.reset}`,
645
+ `${dim}⬡${A.reset} ${g}${this.workers.length}${A.reset} ${dim}accounts${A.reset}`,
646
+ `${coins}⏣${A.reset} ${g}${totalCoins.toLocaleString()}${A.reset}`,
647
+ `${dim}⚡${A.reset} ${g}${totalCmds}${A.reset} ${dim}cmds${A.reset}`,
648
+ `${dim}📊${A.reset} ${g}${rate}%${A.reset}`,
649
+ `${C.lifesavers}♥${A.reset} ${g}${totalLs}${A.reset}`,
650
+ `${h}🟢${A.reset} ${g}${activeCount}${A.reset} ${h}🔴${A.reset} ${g}${pausedCount}${A.reset}`,
651
+ ].join(' ');
652
+ }
653
+
654
+ _drawAccounts() {
655
+ const w = this._w;
656
+ const b = C.border;
657
+ const dim = A.dim;
658
+ const r = A.reset;
659
+ const sep = `${b}${'─'.repeat(w - 2)}${r}`;
660
+
661
+ const visible = this.workers.slice(this.windowStart, this.windowStart + this.windowSize);
662
+
663
+ // Column header
664
+ const cols = [
665
+ `${C.header}#${A.reset}`,
666
+ `${C.header}ACCOUNT${A.reset}`,
667
+ `${C.header}COINS${A.reset}`,
668
+ `${C.header}LV${A.reset}`,
669
+ `${C.header}♥${A.reset}`,
670
+ `${C.header}CMDS${A.reset}`,
671
+ `${C.header}OK%${A.reset}`,
672
+ `${C.header}STATUS${A.reset}`,
673
+ `${C.header}CURRENT${A.reset}`,
674
+ ].join(' ');
675
+ this._write(`${this._at(this._accountsRow, 1)}${b}${this._rpad(' ' + cols, w - 2)}${r}`);
676
+ this._write(`${this._at(this._accountsRow + 1, 1)}${sep}`);
677
+
678
+ // Worker rows
679
+ for (let i = 0; i < this.windowSize; i++) {
680
+ const row = this._accountsRow + 2 + i;
681
+ if (row > this._h - 4) break;
682
+
683
+ if (i < visible.length) {
684
+ const wk = visible[i];
685
+ const line = this._buildAccountRow(wk, this.windowStart + i);
686
+ this._write(`${this._at(row, 1)}${b}${line}${r}`);
687
+ } else {
688
+ this._write(`${this._at(row, 1)}${b}${' '.repeat(w - 2)}${r}`);
689
+ }
690
+ }
691
+ this._eventsRow = this._accountsRow + 2 + this.windowSize + 1;
692
+ }
693
+
694
+ _drawEvents() {
695
+ const w = this._w;
696
+ const b = C.border;
697
+ const dim = A.dim;
698
+ const r = A.reset;
699
+ const sep = `${b}${'─'.repeat(w - 2)}${r}`;
700
+
701
+ if (this._eventsRow > this._h - 4) return;
702
+ this._write(`${this._at(this._eventsRow, 1)}${sep}`);
703
+
704
+ const visibleEvents = this.events.slice(0, Math.min(this.MAX_EVENTS, this._h - this._eventsRow - 3));
705
+ for (let i = 0; i < visibleEvents.length; i++) {
706
+ const row = this._eventsRow + 1 + i;
707
+ if (row > this._h - 2) break;
708
+ const evt = visibleEvents[i];
709
+ const typeColor = evt.type === 'error' || evt.type === 'death' ? C.cmdError
710
+ : evt.type === 'warn' || evt.type === 'lowls' ? C.cmdWarn
711
+ : evt.type === 'success' || evt.type === 'levelup' ? C.cmdSuccess
712
+ : C.cmdEvent;
713
+
714
+ const line = ` ${dim}${evt.ts}${A.reset} ${typeColor}${evt.msg}${A.reset}`;
715
+ this._write(`${this._at(row, 1)}${b}${this._rpad(line, w - 2)}${r}`);
716
+ }
717
+ this._footerRow = this._eventsRow + 1 + visibleEvents.length;
718
+ }
719
+
720
+ _drawFooter() {
721
+ const w = this._w;
722
+ const b = C.border;
723
+ const dim = A.dim;
724
+ const r = A.reset;
725
+ const sep = `${b}${'─'.repeat(w - 2)}${r}`;
726
+
727
+ if (this._footerRow > this._h - 1) this._footerRow = this._h - 2;
728
+ this._write(`${this._at(this._footerRow, 1)}${sep}`);
729
+
730
+ const hint = `${dim}↑↓ scroll · j/k navigate · Ctrl+C quit${r}`;
731
+ this._write(`${this._at(this._footerRow + 1, 1)}${b} ${hint}${' '.repeat(Math.max(0, w - 2 - this._ansiLen(hint)))}${r}`);
732
+ }
733
+
734
+ _render() {
735
+ if (!READY || this._shutdown || !this._active) return;
736
+
737
+ // Auto-follow newest active worker
738
+ if (this._followWorkerIdx >= 0 && this._followWorkerIdx < this.workers.length) {
739
+ const target = this._followWorkerIdx;
740
+ if (target < this.windowStart) {
741
+ this.windowStart = target;
742
+ this.dirtyWorkers = new Set();
743
+ for (let i = this.windowStart; i < this.windowStart + this.windowSize; i++) {
744
+ if (i < this.workers.length) this.dirtyWorkers.add(i);
745
+ }
746
+ } else if (target >= this.windowStart + this.windowSize) {
747
+ this.windowStart = target - this.windowSize + 1;
748
+ this.dirtyWorkers = new Set();
749
+ for (let i = this.windowStart; i < this.windowStart + this.windowSize; i++) {
750
+ if (i < this.workers.length) this.dirtyWorkers.add(i);
751
+ }
752
+ }
753
+ }
754
+
755
+ // Redraw everything on resize changes
756
+ if (this.dirtyStats) {
757
+ this._drawHeader();
758
+ this.dirtyWorkers = new Set(this.workers.map((_, i) => i));
759
+ }
760
+
761
+ if (this.dirtyWorkers.size > 0) {
762
+ const w = this._w;
763
+ const b = C.border;
764
+ const r = A.reset;
765
+ const sep = `${b}${'─'.repeat(w - 2)}${r}`;
766
+
767
+ for (const idx of this.dirtyWorkers) {
768
+ const localIdx = idx - this.windowStart;
769
+ const row = this._accountsRow + 2 + localIdx;
770
+ if (row < this._accountsRow + 2 || row > this._h - 4) continue;
771
+
772
+ if (idx < this.workers.length) {
773
+ const line = this._buildAccountRow(this.workers[idx], idx);
774
+ this._write(`${this._at(row, 1)}${b}${line}${r}`);
775
+ } else {
776
+ this._write(`${this._at(row, 1)}${b}${' '.repeat(w - 2)}${r}`);
777
+ }
778
+ }
779
+ }
780
+
781
+ if (this.dirtyEvents) {
782
+ this._drawEvents();
783
+ this._drawFooter();
784
+ }
785
+
786
+ this.dirtyWorkers.clear();
787
+ this.dirtyEvents = false;
788
+ this.dirtyStats = false;
789
+ }
790
+
791
+ _startRenderLoop() {
792
+ if (this._renderTimer) clearInterval(this._renderTimer);
793
+ this._renderTimer = setInterval(() => this._render(), 250); // 4 FPS
794
+ }
795
+
796
+ _printSummaryPlain(summary) {
797
+ const { totalCoins = 0, totalCmds = 0, totalSuccess = 0,
798
+ workers = [], uptime = 0, memMB = 0 } = summary;
799
+ const b = '═'.repeat(60);
800
+ console.log('');
801
+ console.log(` ${'═'.repeat(60)}`);
802
+ console.log(` ⬡ DANKGRINDER — Session Summary`);
803
+ console.log(` ${'─'.repeat(60)}`);
804
+ for (const wk of workers) {
805
+ const rate = wk.stats?.commands > 0
806
+ ? `${((wk.stats.successes / wk.stats.commands) * 100).toFixed(0)}%`
807
+ : '0%';
808
+ console.log(
809
+ ` 💎 ${(wk.username || '?').padEnd(18)} +⏣ ${(wk.stats?.coins || 0).toLocaleString().padStart(8)} ` +
810
+ `${(wk.stats?.commands || 0)}cmds ${rate} ♥${wk._lifesavers ?? '?'} Lv.${wk._level ?? '?'}`
811
+ );
812
+ }
813
+ console.log(` ${'─'.repeat(60)}`);
814
+ console.log(` 💰 Total: ⏣ ${totalCoins.toLocaleString()} ${totalCmds}cmds ${totalSuccess}%OK ${this._fmtUptime(uptime)} ${memMB}MB`);
815
+ console.log(` ${'═'.repeat(60)}`);
816
+ console.log('');
817
+ }
818
+ }
819
+
820
+ module.exports = new Terminal();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dankgrinder",
3
- "version": "7.77.0",
3
+ "version": "7.78.0",
4
4
  "description": "Dank Memer automation engine — grind coins while you sleep",
5
5
  "bin": {
6
6
  "dankgrinder": "bin/dankgrinder.js"