dankgrinder 7.83.0 → 8.2.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,7 +3,6 @@ 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');
7
6
  const {
8
7
  BloomFilter, RingBuffer, TokenBucket, EMA, SlidingWindowCounter,
9
8
  AhoCorasick, LRUCache, StringPool, AsyncBatchQueue, JitterBackoff,
@@ -130,8 +129,6 @@ let API_URL = '';
130
129
  let REDIS_URL = process.env.REDIS_URL || '';
131
130
  let redis = null;
132
131
  let workers = [];
133
- let startTime = 0;
134
- let shutdownCalled = false;
135
132
 
136
133
  // ── Cluster Mode Config ──────────────────────────────────────
137
134
  // NODE_ID uniquely identifies this process in a multi-node cluster.
@@ -339,17 +336,6 @@ function colorBanner() {
339
336
 
340
337
  // ── Simple Logging ─────────────────────────────────────────────
341
338
  function log(type, msg, label) {
342
- // Route grinding logs through terminal flash events when active
343
- if (terminal._active) {
344
- const clean = stripAnsi(String(msg || '')).substring(0, 120);
345
- const tagClean = stripAnsi(String(label || ''));
346
- terminal.flashEvent(
347
- type === 'error' ? 'death' : type === 'warn' ? 'warn' : 'info',
348
- `${tagClean ? tagClean + ' ' : ''}${clean}`
349
- );
350
- return;
351
- }
352
-
353
339
  const colorIcons = {
354
340
  info: `${c.dim}·${c.reset}`, success: `${rgb(52, 211, 153)}✓${c.reset}`,
355
341
  error: `${rgb(239, 68, 68)}✗${c.reset}`, warn: `${rgb(251, 191, 36)}!${c.reset}`,
@@ -1635,8 +1621,6 @@ class AccountWorker {
1635
1621
  // ── Death / lifesaver detection in command responses ──
1636
1622
  if (resultLower.includes('you died') || resultLower.includes('lifesaver protected')) {
1637
1623
  this.log('error', `DEATH DETECTED during ${cmdName}!`);
1638
- terminal.flashEvent('death', `💀 ${this.username} died — ${lsCount === 0 ? '0 lifesavers!' : (lsCount > 0 ? `${lsCount} lifesavers left` : 'check DM')}`);
1639
- terminal.markWorkerDirty(this.idx);
1640
1624
  // Check for lifesaver count in the response
1641
1625
  const lsMatch = result.match(/(\d+)\s*life\s*saver/i);
1642
1626
  const lsCount = lsMatch ? parseInt(lsMatch[1]) : -1;
@@ -1653,8 +1637,6 @@ class AccountWorker {
1653
1637
  await redis.set(`dkg:lifesavers:${this.account.id}`, String(lsCount), 'EX', 86400);
1654
1638
  this.log('warn', `Lifesaver used! ${lsCount} remaining.`);
1655
1639
  if (lsCount <= 2) {
1656
- terminal.flashEvent('lowls', `⚠ ${this.username} low lifesavers: ${lsCount} left`);
1657
- terminal.markWorkerDirty(this.idx);
1658
1640
  sendWebhook('LOW LIFESAVERS', `**${this.username}** has only **${lsCount}** lifesaver(s) left!`, 0xfbbf24);
1659
1641
  }
1660
1642
  }
@@ -1774,8 +1756,6 @@ class AccountWorker {
1774
1756
  this.setStatus(formattedResult);
1775
1757
  await sendLog(this.username, cmdName, result, 'success');
1776
1758
  reportEarnings(this.account.id, this.username, earned, spent, cmdName);
1777
- terminal.markWorkerDirty(this.idx);
1778
- if (earned > 0) terminal.flashEvent('success', `⚔ ${this.username} ${cmdName}: +⏣ ${earned.toLocaleString()}`);
1779
1759
 
1780
1760
  // Auto-sell fish every 5 fishing rounds
1781
1761
  if (cmdName === 'fish') {
@@ -2759,8 +2739,12 @@ async function start(apiKey, apiUrl, opts = {}) {
2759
2739
  API_KEY = apiKey;
2760
2740
  API_URL = apiUrl || process.env.DANKGRINDER_URL || 'http://localhost:3000';
2761
2741
  const CLOUD_MODE = opts.cloud === true;
2762
- startTime = Date.now();
2763
2742
 
2743
+ if (CLOUD_MODE) {
2744
+ // In cloud mode, API_KEY is the CLOUD_ADMIN_KEY — not used for user auth.
2745
+ // Per-account keys are fetched per-account from /api/cloud/grinders.
2746
+ console.log('🌥️ Starting in CLOUD MODE — grinding all cloud-enabled accounts');
2747
+ }
2764
2748
  REDIS_URL = process.env.REDIS_URL || '';
2765
2749
  WEBHOOK_URL = process.env.WEBHOOK_URL || '';
2766
2750
 
@@ -2768,14 +2752,15 @@ async function start(apiKey, apiUrl, opts = {}) {
2768
2752
  let hasZlib = false;
2769
2753
  try { require('zlib-sync'); hasZlib = true; } catch {}
2770
2754
 
2771
- // Init terminal FIRST — captures all subsequent console.log output
2772
- terminal.setVersion(PKG_VERSION);
2773
- terminal.init({ workers: [], startTime });
2774
-
2775
- if (CLOUD_MODE) {
2776
- console.log(`${rgb(139, 92, 246)}🌥️ Starting in CLOUD MODE — grinding all cloud-enabled accounts${c.reset}`);
2777
- }
2778
- terminal.startPhase('Fetching accounts...');
2755
+ console.log(colorBanner());
2756
+ console.log(
2757
+ ` ${rgb(139, 92, 246)}v${PKG_VERSION}${c.reset}` +
2758
+ ` ${c.dim}·${c.reset} ${c.white}${AccountWorker.COMMAND_MAP.length} Commands${c.reset}` +
2759
+ ` ${c.dim}·${c.reset} ${rgb(34, 211, 238)}${CLOUD_MODE ? 'Cloud Mode' : (CLUSTER_ENABLED ? 'Cluster Mode' : 'Standalone')}${c.reset}` +
2760
+ ` ${c.dim}·${c.reset} ${rgb(52, 211, 153)}Auto-Recovery${c.reset}` +
2761
+ ` ${c.dim}·${c.reset} ${rgb(251, 191, 36)}Loss Limiter${c.reset}`
2762
+ );
2763
+ log('info', `${c.dim}Fetching accounts...${c.reset}`);
2779
2764
 
2780
2765
  const fetchOpts = CLOUD_MODE ? { cloud: true } : {};
2781
2766
  let data = await fetchConfig(4, 2000, fetchOpts);
@@ -2786,11 +2771,9 @@ async function start(apiKey, apiUrl, opts = {}) {
2786
2771
  data = await fetchConfig(4, 2000, fetchOpts);
2787
2772
  }
2788
2773
  if (data && data.error) {
2789
- terminal.endPhase(`API error: ${data.error}`, false);
2790
2774
  log('error', `${data.error}`);
2791
2775
  return;
2792
2776
  }
2793
- terminal.endPhase(`API connected — ${data.accounts?.length || 0} accounts`);
2794
2777
 
2795
2778
  // Cloud mode: post heartbeat every 30s
2796
2779
  if (CLOUD_MODE) {
@@ -2835,6 +2818,10 @@ async function start(apiKey, apiUrl, opts = {}) {
2835
2818
  }
2836
2819
  }
2837
2820
 
2821
+ const checks = [];
2822
+ checks.push(`${rgb(52, 211, 153)}✓${c.reset} ${c.white}API${c.reset}`);
2823
+ if (REDIS_URL) checks.push(redis ? `${rgb(52, 211, 153)}✓${c.reset} ${c.white}Redis${c.reset}` : `${rgb(251, 191, 36)}○${c.reset} ${c.dim}Redis (connecting...)${c.reset}`);
2824
+
2838
2825
  // Init rawLogger Redis (uses same URL — logs all raw gateway data)
2839
2826
  if (REDIS_URL) {
2840
2827
  rawLogger.init(redis);
@@ -2854,8 +2841,6 @@ async function start(apiKey, apiUrl, opts = {}) {
2854
2841
  w.log?.('error', `DEATH in DMs! 0 lifesavers — disabling crime/search`);
2855
2842
  w.setCooldown?.('crime', 86400);
2856
2843
  w.setCooldown?.('search', 86400);
2857
- terminal.flashEvent('death', `💀 ${w.username} DEATH in DM — 0 lifesavers!`);
2858
- terminal.markWorkerDirty(w.idx);
2859
2844
  sendWebhook?.('DEATH ALERT (DM)', `**${w.username}** died in DMs! **0 lifesavers!**\nCrime/search auto-disabled.`, 0xef4444);
2860
2845
  } else {
2861
2846
  w.log?.('warn', `DEATH in DMs — ${event.lifesaversLeft} lifesavers remaining`);
@@ -2873,20 +2858,23 @@ async function start(apiKey, apiUrl, opts = {}) {
2873
2858
  if (event.type === 'levelup') {
2874
2859
  if (event.to > 0) {
2875
2860
  w._level = event.to;
2876
- terminal.flashEvent('levelup', `⬆️ ${w.username} leveled up to Lv.${event.to}`);
2877
- terminal.markWorkerDirty(w.idx);
2878
2861
  }
2879
2862
  }
2880
2863
  }
2881
2864
  });
2865
+ checks.push(`${rgb(52, 211, 153)}✓${c.reset} ${c.white}RawLog${c.reset}`);
2882
2866
  }
2883
-
2884
- // ── Terminal renderer init ─────────────────────────────────────
2885
- terminal.setVersion(PKG_VERSION);
2886
- terminal.init({ workers, startTime });
2887
- terminal.startPhase(`Connecting ${accounts.length} accounts to Discord`);
2867
+ if (hasZlib) checks.push(`${rgb(52, 211, 153)}✓${c.reset} ${c.white}zlib${c.reset}`);
2868
+ if (WEBHOOK_URL) checks.push(`${rgb(52, 211, 153)}✓${c.reset} ${c.white}Webhook${c.reset}`);
2869
+ if (CLUSTER_ENABLED) {
2870
+ checks.push(`${rgb(52, 211, 153)}✓${c.reset} ${rgb(34, 211, 238)}Cluster${c.reset} ${c.dim}(${NODE_ID.substring(0, 12)})${c.reset}`);
2871
+ }
2872
+ checks.push(`${rgb(52, 211, 153)}✓${c.reset} ${c.white}${accounts.length} Account${accounts.length > 1 ? 's' : ''}${c.reset}`);
2873
+ console.log(` ${checks.join(' ')}`);
2874
+ console.log('');
2888
2875
 
2889
2876
  // ── Phase 1: Login accounts ─────────────────────────────────────────
2877
+ console.log(` ${rgb(52, 211, 153)}✓${c.reset} Logging in ${accounts.length} account(s)...`);
2890
2878
  const parsedGapMin = Number.parseInt(String(process.env.LOGIN_GAP_MIN_MS || '50'), 10);
2891
2879
  const parsedGapMax = Number.parseInt(String(process.env.LOGIN_GAP_MAX_MS || '150'), 10);
2892
2880
  const LOGIN_GAP_MIN_MS = Number.isFinite(parsedGapMin) && parsedGapMin >= 0 ? parsedGapMin : 50;
@@ -2903,7 +2891,6 @@ async function start(apiKey, apiUrl, opts = {}) {
2903
2891
  workers.push(worker);
2904
2892
  workerMap.set(acc.id, worker);
2905
2893
  await worker.start();
2906
- terminal.updateProgress(workers.length, accounts.length);
2907
2894
  }));
2908
2895
  if (i + BATCH_SIZE < accounts.length) await new Promise(r => setTimeout(r, randomLoginGap()));
2909
2896
  hintGC();
@@ -2912,9 +2899,8 @@ async function start(apiKey, apiUrl, opts = {}) {
2912
2899
  const loginDone = workers.filter(w => !w._tokenInvalid && w.channel).length;
2913
2900
  const invalidWorkers = workers.filter(w => w._tokenInvalid);
2914
2901
  const timedOutWorkers = workers.filter(w => !w.channel && !w._tokenInvalid);
2915
- terminal.endPhase(`${loginDone}/${accounts.length} accounts connected`, true);
2902
+ console.log(` ${rgb(52, 211, 153)}✓${c.reset} ${loginDone}/${accounts.length} accounts connected`);
2916
2903
  if (invalidWorkers.length > 0) {
2917
- terminal.flashEvent('error', `${invalidWorkers.length} accounts have INVALID tokens`);
2918
2904
  log('warn', `${rgb(239, 68, 68)}${invalidWorkers.length} account(s) have INVALID tokens`);
2919
2905
  for (const w of invalidWorkers) log('error', ` ${w.account.label || w.account.id} — token invalid or expired`);
2920
2906
  }
@@ -2923,31 +2909,24 @@ async function start(apiKey, apiUrl, opts = {}) {
2923
2909
  const activeWorkers = workers.filter(w => !w._tokenInvalid);
2924
2910
 
2925
2911
  // ── Phase 2: Inventory check ─────────────────────────────────────
2926
- terminal.startPhase('Checking inventory');
2927
- terminal.updateProgress(0, activeWorkers.length);
2912
+ console.log(` ${rgb(34, 211, 238)}·${c.reset} Checking inventory (${activeWorkers.length} accounts)...`);
2928
2913
  let invDone = 0, invFailed = 0;
2929
2914
  await Promise.all(activeWorkers.map(async (w) => {
2930
2915
  try { await w.checkInventory({ force: true, requireComplete: true, maxAttempts: 3, silent: true }); }
2931
2916
  catch { invFailed++; return; }
2932
2917
  invDone++;
2933
- terminal.updateProgress(invDone, activeWorkers.length);
2934
2918
  }));
2935
2919
 
2936
2920
  if (invFailed > 0) {
2937
- terminal.endPhase(`Inventory: ${invFailed} failed`, false);
2938
2921
  console.log(` ${rgb(239, 68, 68)}✗${c.reset} Inventory: ${invFailed} failed — not starting grind loops`);
2939
2922
  return;
2940
2923
  }
2941
- terminal.endPhase(`Inventory: ${invDone} clear`);
2924
+ console.log(` ${rgb(52, 211, 153)}✓${c.reset} Inventory: ${invDone}/${activeWorkers.length} clear`);
2942
2925
 
2943
2926
  // ── Phase 2.5: Balance check ────────────────────────────────────
2944
- terminal.startPhase('Checking balance');
2945
- terminal.updateProgress(0, activeWorkers.length);
2946
- let balDone = 0;
2927
+ console.log(` ${rgb(251, 191, 36)}·${c.reset} Checking balance (${activeWorkers.length} accounts)...`);
2947
2928
  await Promise.all(activeWorkers.map(async (w) => {
2948
2929
  try { await w.checkBalance(true); } catch {}
2949
- balDone++;
2950
- terminal.updateProgress(balDone, activeWorkers.length);
2951
2930
  }));
2952
2931
 
2953
2932
  let totalWallet = 0, totalBank = 0, noLifesaverAccounts = [];
@@ -2956,16 +2935,13 @@ async function start(apiKey, apiUrl, opts = {}) {
2956
2935
  totalBank += w.stats?.bankBalance || 0;
2957
2936
  if (w._lifesavers === 0) noLifesaverAccounts.push(w.username || w.account.label);
2958
2937
  }
2959
- terminal.endPhase(`Balance: ⏣ ${(totalWallet + totalBank).toLocaleString()}`);
2938
+ 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}`);
2960
2939
  if (noLifesaverAccounts.length > 0) {
2961
- terminal.flashEvent('warn', `${noLifesaverAccounts.length} accounts have 0 LIFESAVERS`);
2962
2940
  console.log(` ${rgb(239, 68, 68)}⚠${c.reset} ${noLifesaverAccounts.length} account(s) have 0 LIFESAVERS — crime/search disabled`);
2963
2941
  }
2964
2942
 
2965
2943
  // ── Phase 2.75: DM history check ────────────────────────────────
2966
- terminal.startPhase('Checking DM history');
2967
- terminal.updateProgress(0, activeWorkers.length);
2968
- let dmDone = 0;
2944
+ console.log(` ${rgb(139, 92, 246)}·${c.reset} Checking DM history...`);
2969
2945
  let dmDeaths = 0, dmLevelUps = 0, dmNoLs = [], dmUnknown = [];
2970
2946
  for (const w of activeWorkers) {
2971
2947
  try {
@@ -2977,11 +2953,8 @@ async function start(apiKey, apiUrl, opts = {}) {
2977
2953
  if (dm.currentLevel > 0) w._level = dm.currentLevel;
2978
2954
  if (dm.lifesavers >= 0) w._lifesavers = dm.lifesavers;
2979
2955
  } catch {}
2980
- dmDone++;
2981
- terminal.updateProgress(dmDone, activeWorkers.length);
2982
2956
  }
2983
2957
  if (dmNoLs.length > 0) {
2984
- terminal.flashEvent('warn', `No lifesavers: ${dmNoLs.join(', ')}`);
2985
2958
  log('warn', `⚠ No lifesavers: ${dmNoLs.join(', ')}`);
2986
2959
  for (const w of activeWorkers) {
2987
2960
  if (dmNoLs.includes(w.username) && redis) {
@@ -2999,16 +2972,17 @@ async function start(apiKey, apiUrl, opts = {}) {
2999
2972
  if (dmDeaths > 0) dmSummaryParts.push(`${dmDeaths} deaths`);
3000
2973
  if (dmLevelUps > 0) dmSummaryParts.push(`${dmLevelUps} level-ups`);
3001
2974
  if (dmUnknown.length > 0) dmSummaryParts.push(`${dmUnknown.length} pending`);
3002
- terminal.endPhase(`DM check: ${dmSummaryParts.length > 0 ? dmSummaryParts.join(', ') : 'clean'}`);
2975
+ console.log(` ${rgb(52, 211, 153)}✓${c.reset} DM check: ${dmSummaryParts.length > 0 ? dmSummaryParts.join(', ') : 'clean'}`);
3003
2976
 
3004
2977
  // ── Phase 3: Start grind loops ───────────────────────────────────
3005
- terminal.startPhase(`🚀 Launching ${activeWorkers.length} grinders!`);
2978
+ console.log(` ${rgb(139, 92, 246)}>>>${c.reset} Starting grind loops...`);
2979
+ // Phase 3: Start all grind loops (only for valid workers)
3006
2980
  for (const w of activeWorkers) {
3007
2981
  if (!shutdownCalled) w.grindLoop();
3008
2982
  }
3009
- terminal.endPhase(`${activeWorkers.length} grinders active`);
3010
- terminal.setWorkers(workers);
3011
- terminal.setActive();
2983
+
2984
+ console.log(` ${rgb(52, 211, 153)}✓${c.reset} All grind loops started — ${activeWorkers.length} accounts active`);
2985
+ console.log(` v${PKG_VERSION} | press Ctrl+C to stop`);
3012
2986
 
3013
2987
  // Cluster heartbeat — lets other nodes see this node is alive
3014
2988
  if (CLUSTER_ENABLED) {
@@ -3075,26 +3049,34 @@ async function start(apiKey, apiUrl, opts = {}) {
3075
3049
  sigintHandled = true;
3076
3050
  shutdownCalled = true;
3077
3051
  setDashboardActive(false);
3052
+ process.stdout.write(c.show);
3078
3053
 
3079
- // Collect stats for summary
3080
- let finalCoins = 0, finalCmds = 0, totalSuccess = 0;
3054
+ const sepBar = rgb(139, 92, 246) + c.bold + '═'.repeat(tw) + c.reset;
3055
+ console.log('');
3056
+ console.log(` ${rgb(251, 191, 36)}${c.bold}Session Summary${c.reset}`);
3057
+ console.log(sepBar);
3058
+
3059
+ // Collect stats from all workers (including rotated-out ones)
3060
+ let finalCoins = 0;
3061
+ let finalCmds = 0;
3081
3062
  for (const wk of workers) {
3063
+ const rate = wk.stats.commands > 0 ? ((wk.stats.successes / wk.stats.commands) * 100).toFixed(0) : 0;
3064
+ console.log(
3065
+ ` ${wk.color}${c.bold}${(wk.username || '?').padEnd(18)}${c.reset}` +
3066
+ ` ${rgb(52, 211, 153)}+⏣ ${wk.stats.coins.toLocaleString().padStart(8)}${c.reset}` +
3067
+ ` ${c.dim}${wk.stats.commands.toString().padStart(4)} cmds${c.reset}` +
3068
+ ` ${c.dim}${rate}% success${c.reset}`
3069
+ );
3082
3070
  finalCoins += wk.stats.coins || 0;
3083
3071
  finalCmds += wk.stats.commands || 0;
3084
- totalSuccess += wk.stats.successes || 0;
3085
3072
  }
3073
+ console.log(sepBar);
3074
+
3086
3075
  const memFinal = Math.round((process.memoryUsage?.rss?.() ?? process.memoryUsage().rss) / 1048576);
3087
- const uptimeMs = Date.now() - startTime;
3088
-
3089
- // Show beautiful session summary via terminal renderer
3090
- terminal.shutdown({
3091
- totalCoins: finalCoins,
3092
- totalCmds: finalCmds,
3093
- totalSuccess: finalCmds > 0 ? ((totalSuccess / finalCmds) * 100).toFixed(0) : 0,
3094
- workers,
3095
- uptime: uptimeMs,
3096
- memMB: memFinal,
3097
- });
3076
+ const avgEarn = globalEarningsEMA.get();
3077
+ const cpm = globalCmdRate.getRate().toFixed(1);
3078
+ 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}`);
3079
+ console.log('');
3098
3080
 
3099
3081
  // Release all cluster claims before stopping workers
3100
3082
  const releasePromises = workers.map(wk => releaseClaim(wk.account.id).catch(() => {}));
@@ -3110,14 +3092,22 @@ async function start(apiKey, apiUrl, opts = {}) {
3110
3092
 
3111
3093
  const totalRecoveries = workers.reduce((sum, wk) => sum + (wk._totalRecoveries || 0), 0);
3112
3094
  const totalDisconnects = workers.reduce((sum, wk) => sum + (wk._disconnectCount || 0), 0);
3095
+ const totalRateLimits = workers.reduce((sum, wk) => sum + (wk._rateLimitHits || 0), 0);
3113
3096
 
3114
3097
  const webhookMsg = `+⏣ ${finalCoins.toLocaleString()} | ${finalCmds} cmds | ${formatUptime()}` +
3115
3098
  (totalRecoveries > 0 ? ` | ${totalRecoveries} auto-recoveries` : '') +
3116
3099
  (CLUSTER_ENABLED ? ` | node: ${NODE_ID.substring(0, 12)}` : '');
3117
3100
  sendWebhook('Session Ended', webhookMsg, 0x8b5cf6);
3118
3101
 
3102
+ if (totalRecoveries > 0 || totalDisconnects > 0) {
3103
+ console.log(` ${c.dim}Recovery stats: ${totalRecoveries} auto-recoveries, ${totalDisconnects} disconnects, ${totalRateLimits} rate limits${c.reset}`);
3104
+ }
3105
+ if (CLUSTER_ENABLED) {
3106
+ console.log(` ${c.dim}Cluster node: ${NODE_ID} — claims released${c.reset}`);
3107
+ }
3108
+
3119
3109
  if (redis) { try { redis.disconnect(); } catch {} }
3120
- setTimeout(() => process.exit(0), 500);
3110
+ setTimeout(() => process.exit(0), 1500);
3121
3111
  });
3122
3112
  }
3123
3113
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dankgrinder",
3
- "version": "7.83.0",
3
+ "version": "8.2.0",
4
4
  "description": "Dank Memer automation engine — grind coins while you sleep",
5
5
  "bin": {
6
6
  "dankgrinder": "bin/dankgrinder.js"
package/lib/terminal.js DELETED
@@ -1,720 +0,0 @@
1
- /**
2
- * terminal.js — Polished animated terminal renderer for DankGrinder
3
- *
4
- * Features:
5
- * - Animated startup phases with multi-element spinners
6
- * - Live leaderboard with coin-based rank medals (🥇🥈🥉)
7
- * - Accounts stay in fixed positions, medals update live
8
- * - Pulsing status indicators for active accounts
9
- * - Dark theme: deep purple borders, vibrant accent colors
10
- * - 4 FPS render loop, dirty-row only, no flicker
11
- * - Graceful fallback: plain console.log if not a TTY
12
- */
13
-
14
- 'use strict';
15
-
16
- const READY = (() => {
17
- try { return !!process.stdout.isTTY && !process.env.NO_TERM; } catch (_) { return false; }
18
- })();
19
-
20
- // ── ANSI helpers ────────────────────────────────────────────────────────────
21
-
22
- const A = {
23
- reset: '\x1b[0m',
24
- bold: '\x1b[1m',
25
- dim: '\x1b[2m',
26
- rgb: (r, g, b) => `\x1b[38;2;${r};${g};${b}m`,
27
- eraseAll: '\x1b[3J\x1b[2J\x1b[H',
28
- clearLine: '\x1b[2K',
29
- save: '\x1b7',
30
- restore: '\x1b8',
31
- hide: '\x1b[?25l',
32
- show: '\x1b[?25h',
33
- };
34
-
35
- // ── Palette ─────────────────────────────────────────────────────────────────
36
-
37
- const C = {
38
- border: A.rgb(55, 42, 95),
39
- borderDim: A.rgb(38, 28, 65),
40
- borderMid: A.rgb(80, 65, 130),
41
-
42
- text: A.rgb(205, 200, 230),
43
- textDim: A.rgb(90, 85, 115),
44
- textFaint: A.rgb(55, 50, 75),
45
-
46
- purple: A.rgb(167, 139, 250), // vivid lavender
47
- cyan: A.rgb(34, 211, 238), // vivid cyan
48
- gold: A.rgb(251, 191, 36), // vivid gold
49
- green: A.rgb(52, 211, 153), // vivid green
50
- pink: A.rgb(244, 114, 182), // vivid pink
51
- orange: A.rgb(251, 146, 60), // vivid orange
52
- red: A.rgb(248, 113, 113), // vivid red
53
- blue: A.rgb(96, 165, 250), // vivid blue
54
-
55
- // Rank medal colors
56
- rank1: A.rgb(255, 215, 0),
57
- rank2: A.rgb(192, 192, 192),
58
- rank3: A.rgb(205, 127, 50),
59
-
60
- // Per-account accent colors (cycles)
61
- ACCT: [
62
- A.rgb(167, 139, 250), // lavender
63
- A.rgb(103, 232, 249), // sky cyan
64
- A.rgb(253, 186, 116), // peach
65
- A.rgb(167, 243, 208), // mint
66
- A.rgb(252, 165, 201), // rose
67
- A.rgb(165, 243, 252), // light cyan
68
- A.rgb(196, 181, 253), // light purple
69
- A.rgb(147, 226, 226), // light teal
70
- ],
71
- };
72
-
73
- // ── Box-drawing ─────────────────────────────────────────────────────────────
74
-
75
- const TL='╭', TR='╮', BL='╰', BR='╯', H='─', V='│';
76
-
77
- // ── Spinner frames ──────────────────────────────────────────────────────────
78
-
79
- // Dot spinner for phase labels
80
- const SPIN_DOTS = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠿'];
81
-
82
- // Block spinner for progress
83
- const SPIN_BLOCK = ['▏','▎','�','▌','▋','▊','▉','▊'];
84
-
85
- // Pulse frames for active indicators
86
- const PULSE = ['●', '◉', '◎', '○'];
87
-
88
- // ── stdout capture ──────────────────────────────────────────────────────────
89
-
90
- let _origWrite = null;
91
- let _captureActive = false;
92
- let _captureBuf = [];
93
-
94
- function _capWrite(chunk) {
95
- if (_captureActive) { _captureBuf.push(String(chunk)); return; }
96
- return _origWrite.call(process.stdout, chunk);
97
- }
98
-
99
- // ── ANSI utils ─────────────────────────────────────────────────────────────
100
-
101
- function ansiLen(s) {
102
- let len = 0, i = 0;
103
- const str = String(s);
104
- while (i < str.length) {
105
- if (str.charCodeAt(i) === 0x1b && str[i+1] === '[') {
106
- let j = i+2;
107
- while (j < str.length && str[j] !== 'm') j++;
108
- i = j + 1;
109
- } else { len++; i++; }
110
- }
111
- return len;
112
- }
113
-
114
- function rpad(s, w) { return s + ' '.repeat(Math.max(0, w - ansiLen(s))); }
115
-
116
- // ── Rank helpers ───────────────────────────────────────────────────────────
117
-
118
- const MEDALS = ['🥇','🥈','🥉'];
119
-
120
- function coinRank(wk, workers) {
121
- const c = wk.stats?.coins || 0;
122
- let r = 1;
123
- for (const w of workers) {
124
- if ((w.stats?.coins || 0) > c) r++;
125
- }
126
- return r;
127
- }
128
-
129
- // ── Terminal ───────────────────────────────────────────────────────────────
130
-
131
- class Terminal {
132
- constructor() {
133
- this.workers = [];
134
- this.events = [];
135
- this.MAX_EVENTS = 3;
136
-
137
- this.phase = '';
138
- this.phaseFrame = 0;
139
- this.phaseTimer = null;
140
- this.phaseDone = 0;
141
- this.phaseTotal = 0;
142
-
143
- this.dirtyWorkers = new Set();
144
- this.dirtyStats = true;
145
- this._renderTimer = null;
146
- this._startTime = 0;
147
- this._active = false;
148
- this._shutdown = false;
149
- this._origLog = null;
150
-
151
- this._w = 110;
152
- this._h = 35;
153
- this.windowStart = 0;
154
- this.windowSize = 8;
155
-
156
- // Pulse animation state
157
- this._pulseFrame = 0;
158
- this._pulseTimer = null;
159
- }
160
-
161
- // ── Public API ──────────────────────────────────────────────────────────
162
-
163
- init(opts = {}) {
164
- this._startTime = opts.startTime || Date.now();
165
- this.workers = opts.workers || [];
166
- this._updateSize();
167
-
168
- if (READY) {
169
- _origWrite = process.stdout.write.bind(process.stdout);
170
- process.stdout.write = _capWrite;
171
- _captureActive = true;
172
- _captureBuf = [];
173
- this._origLog = console.log;
174
- console.log = (...args) => {
175
- const s = args.join(' ');
176
- if (this._active) {
177
- const clean = s.replace(/\x1b\[[0-9;]*m/g, '').substring(0, 100);
178
- if (clean.trim()) this.flashEvent('info', clean);
179
- } else {
180
- _capWrite(s + '\n');
181
- }
182
- };
183
- process.stdout.on('resize', () => this._onResize());
184
- this._drawStartupScreen();
185
- }
186
- }
187
-
188
- setVersion(v) { this._version = v; }
189
-
190
- startPhase(name) {
191
- this.phase = name;
192
- this.phaseDone = 0;
193
- this.phaseTotal = 0;
194
- this.phaseFrame = 0;
195
- if (!READY) {
196
- process.stdout.write(`\n ⏳ ${name}\n`);
197
- return;
198
- }
199
- if (this.phaseTimer) clearInterval(this.phaseTimer);
200
- this.phaseTimer = setInterval(() => {
201
- this.phaseFrame = (this.phaseFrame + 1) % SPIN_DOTS.length;
202
- this._renderPhase();
203
- }, 80);
204
- this._renderPhase();
205
- }
206
-
207
- updateProgress(done, total) {
208
- this.phaseDone = done;
209
- this.phaseTotal = total;
210
- if (!READY) return;
211
- this._renderProgress();
212
- }
213
-
214
- endPhase(name, ok = true) {
215
- if (this.phaseTimer) { clearInterval(this.phaseTimer); this.phaseTimer = null; }
216
- if (!READY) {
217
- const icon = ok ? `${C.green}✓${A.reset}` : `${C.red}✗${A.reset}`;
218
- console.log(` ${icon} ${name}`);
219
- return;
220
- }
221
- const icon = ok ? `${C.green}✓${A.reset}` : `${C.red}✗${A.reset}`;
222
- const label = `${icon} ${name}`;
223
- // Write to rows 5 (phase) and 7 (progress) with result
224
- const w = this._w;
225
- const V = C.border;
226
- const line = rpad(` ${label}`, w - 3);
227
- this._write(
228
- `${A.save}` +
229
- this._at(5, 1) + A.clearLine +
230
- `${V} ${line} ${V}${A.reset}` +
231
- this._at(7, 1) + A.clearLine +
232
- `${V} ${rpad('', w - 3)} ${V}${A.reset}` +
233
- A.restore
234
- );
235
- }
236
-
237
- flashEvent(type, msg) {
238
- const now = new Date();
239
- const ts = `${C.textDim}${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}${A.reset}`;
240
- this.events.unshift({ ts, type, msg, id: Date.now() });
241
- if (this.events.length > this.MAX_EVENTS) this.events.pop();
242
- this.dirtyEvents = true;
243
- }
244
-
245
- setWorkers(workers) {
246
- this.workers = workers;
247
- this.dirtyWorkers = new Set(workers.map((_, i) => i));
248
- this.dirtyStats = true;
249
- }
250
-
251
- markWorkerDirty(idx) {
252
- this.dirtyWorkers = new Set(this.workers.map((_, i) => i));
253
- this.dirtyStats = true;
254
- }
255
-
256
- setActive() {
257
- if (this._active) return;
258
- this._active = true;
259
-
260
- if (READY) {
261
- _captureActive = false;
262
- if (_origWrite) { process.stdout.write = _origWrite; _origWrite = null; }
263
- if (this._origLog) { console.log = this._origLog; this._origLog = null; }
264
- _captureBuf = [];
265
-
266
- this._write(A.eraseAll + A.hide);
267
- this._drawLiveView();
268
- this.dirtyWorkers.clear();
269
- this.dirtyEvents = false;
270
- this.dirtyStats = false;
271
-
272
- // Start pulse animation for active status indicators
273
- if (this._pulseTimer) clearInterval(this._pulseTimer);
274
- this._pulseTimer = setInterval(() => {
275
- this._pulseFrame = (this._pulseFrame + 1) % PULSE.length;
276
- this.dirtyStats = true; // pulse affects account rows
277
- }, 400);
278
-
279
- this._startRenderLoop();
280
- } else {
281
- if (this._origLog) { console.log = this._origLog; this._origLog = null; }
282
- }
283
- }
284
-
285
- shutdown(summary = {}) {
286
- this._shutdown = true;
287
- if (this._renderTimer) { clearInterval(this._renderTimer); this._renderTimer = null; }
288
- if (this._pulseTimer) { clearInterval(this._pulseTimer); this._pulseTimer = null; }
289
- if (this.phaseTimer) { clearInterval(this.phaseTimer); this.phaseTimer = null; }
290
-
291
- if (READY && _origWrite) { process.stdout.write = _origWrite; _origWrite = null; }
292
- if (this._origLog) { console.log = this._origLog; this._origLog = null; }
293
- this._write(A.show);
294
-
295
- const { totalCoins = 0, totalCmds = 0, totalSuccess = 0,
296
- workers = [], uptime = 0, memMB = 0 } = summary;
297
-
298
- const w = this._w;
299
- let out = A.eraseAll;
300
-
301
- // Top bar
302
- out += this._boxTop();
303
- out += this._statsBar();
304
- out += this._sep();
305
-
306
- // Column headers
307
- out += `${this._row(4, this._colHdr())}`;
308
- out += this._sep();
309
-
310
- // Account rows
311
- let row = 5;
312
- for (let i = 0; i < workers.length && row < this._h - 4; i++) {
313
- out += `${this._row(row++, this._accountLine(workers[i], i, workers))}`;
314
- }
315
-
316
- out += this._sep();
317
- row++;
318
- out += `${this._row(row++, this._totalLine(totalCoins, totalCmds, totalSuccess, uptime, memMB))}`;
319
- out += this._boxBot();
320
-
321
- this._write(out);
322
- }
323
-
324
- // ── Startup Screen ──────────────────────────────────────────────────────
325
-
326
- _drawStartupScreen() {
327
- const w = this._w;
328
- let out = A.eraseAll;
329
-
330
- // Top border
331
- out += `${this._at(1,1)}${C.border}${TL}${'─'.repeat(w-2)}${TR}${A.reset}\n`;
332
-
333
- // Version title
334
- const title = ` ⬡ DANKGRINDER v${this._version || '?'} `;
335
- out += `${this._at(2,1)}${C.border}${V} ${C.purple}${A.bold}${title}${rpad('', w - ansiLen(title) - 4)}${V}${A.reset}\n`;
336
-
337
- // Subtitle with version info
338
- const sub = `${C.textDim}24 commands · Auto-Recovery · Loss Limiter${A.reset}`;
339
- out += `${this._at(3,1)}${C.border}${V} ${sub}${rpad('', w - ansiLen(sub) - 4)}${V}${A.reset}\n`;
340
-
341
- out += `${this._at(4,1)}${C.border}${V}${'─'.repeat(w-2)}${V}${A.reset}\n`;
342
-
343
- // Spinner + phase label (row 5)
344
- out += `${this._at(5,1)}${C.border}${V}${rpad('', w-2)}${V}${A.reset}\n`;
345
-
346
- // Progress bar (row 7)
347
- out += `${this._at(7,1)}${C.border}${V}${rpad('', w-2)}${V}${A.reset}\n`;
348
-
349
- // Checkmarks / status area (row 9)
350
- out += `${this._at(9,1)}${C.border}${V}${rpad('', w-2)}${V}${A.reset}\n`;
351
-
352
- // Spacer
353
- out += `${this._at(11,1)}${C.border}${V}${rpad('', w-2)}${V}${A.reset}\n`;
354
-
355
- // Bottom border
356
- out += `${this._at(12,1)}${C.border}${BL}${'─'.repeat(w-2)}${BR}${A.reset}\n`;
357
-
358
- // Footer
359
- const hint = `${C.textDim}Initializing...${A.reset}`;
360
- out += `${this._at(13,1)}${C.border}${V} ${hint}${rpad('', w - ansiLen(hint) - 4)}${V}${A.reset}\n`;
361
-
362
- this._write(out);
363
- }
364
-
365
- _renderPhase() {
366
- if (!READY || !this.phase) return;
367
- const w = this._w;
368
- const V = C.border;
369
- const dot = SPIN_DOTS[this.phaseFrame];
370
- const label = ` ${dot} ${this.phase} `;
371
- const line = rpad(label, w - 3);
372
- this._write(
373
- `${A.save}` +
374
- `${this._at(5,1)}${A.clearLine}` +
375
- `${V} ${line} ${V}${A.reset}` +
376
- A.restore
377
- );
378
- }
379
-
380
- _renderProgress() {
381
- if (!READY) return;
382
- const w = this._w;
383
- const V = C.border;
384
- const { done, total } = { done: this.phaseDone, total: this.phaseTotal };
385
- const barW = Math.max(16, w - 40);
386
- const filled = total > 0 ? Math.round((done / total) * barW) : 0;
387
- const block = SPIN_BLOCK[this.phaseFrame % SPIN_BLOCK.length];
388
-
389
- // Filled bar with gradient: gold on left, purple on right
390
- const filledPart = filled > 0
391
- ? `${C.gold}${'█'.repeat(Math.max(1, filled - 1))}${C.green}${block}${A.reset}`
392
- : '';
393
- const emptyPart = barW - filled > 0
394
- ? `${C.borderDim}${'░'.repeat(Math.max(0, barW - filled))}${A.reset}`
395
- : '';
396
-
397
- const pct = total > 0 ? `${Math.round((done / total) * 100)}%` : '';
398
- const label = ` ${pct} `;
399
- const bar = `${filledPart}${emptyPart}${C.textDim}${label}${A.reset}`;
400
- const line = rpad(` ${bar}`, w - 3);
401
-
402
- this._write(
403
- `${A.save}` +
404
- `${this._at(7,1)}${A.clearLine}` +
405
- `${V} ${line} ${V}${A.reset}` +
406
- A.restore
407
- );
408
- }
409
-
410
- // ── Live View ─────────────────────────────────────────────────────────
411
-
412
- _drawLiveView() {
413
- let out = A.eraseAll;
414
- out += this._boxTop();
415
- out += this._statsBar();
416
- out += this._sep();
417
- out += `${this._row(4, this._colHdr())}`;
418
- out += this._sep();
419
- this._topRow = 5;
420
- out += this._accountRows();
421
- const sepRow = this._topRow + Math.min(this.windowSize, this.workers.length);
422
- out += `${this._at(sepRow,1)}${C.border}${V}${'─'.repeat(this._w-2)}${V}${A.reset}\n`;
423
- this._eventRow = sepRow + 1;
424
- out += this._eventFeed();
425
- out += this._boxBot();
426
- this._write(out);
427
- }
428
-
429
- _boxTop() {
430
- const w = this._w;
431
- let o = '';
432
- o += `${this._at(1,1)}${C.border}${TL}${'─'.repeat(w-2)}${TR}${A.reset}\n`;
433
- const title = ` ⬡ DANKGRINDER v${this._version || '?'} `;
434
- o += `${this._at(2,1)}${C.border}${V} ${C.purple}${A.bold}${title}${rpad('', w - ansiLen(title) - 4)}${V}${A.reset}\n`;
435
- o += `${this._at(3,1)}${C.border}${V}${'─'.repeat(w-2)}${V}${A.reset}\n`;
436
- return o;
437
- }
438
-
439
- _boxBot() {
440
- const w = this._w;
441
- const hint = `${C.textDim}↑↓ scroll Ctrl+C quit${A.reset}`;
442
- let o = '';
443
- o += `${this._at(this._footerRow,1)}${C.border}${BL}${'─'.repeat(w-2)}${BR}${A.reset}\n`;
444
- o += `${this._at(this._footerRow+1,1)}${C.border}${V} ${hint}${rpad('', w - ansiLen(hint) - 4)}${V}${A.reset}\n`;
445
- return o;
446
- }
447
-
448
- _sep() {
449
- const w = this._w;
450
- return `${C.border}${V}${'─'.repeat(w-2)}${V}${A.reset}\n`;
451
- }
452
-
453
- _row(r, content) {
454
- const w = this._w;
455
- return `${this._at(r,1)}${C.border}${V} ${content}${rpad('', w - ansiLen(content) - 4)} ${V}${A.reset}\n`;
456
- }
457
-
458
- _statsBar() {
459
- const w = this._w;
460
- const stats = this._buildStats();
461
- return `${this._row(4, stats)}`;
462
- }
463
-
464
- _buildStats() {
465
- let totalCoins = 0, totalCmds = 0, totalSuccess = 0, totalLs = 0;
466
- let paused = 0, active = 0;
467
-
468
- for (const wk of this.workers) {
469
- totalCoins += wk.stats?.coins || 0;
470
- totalCmds += wk.stats?.commands || 0;
471
- totalSuccess += wk.stats?.successes|| 0;
472
- if (wk._lifesavers != null) totalLs += wk._lifesavers;
473
- if (wk.running && !wk._tokenInvalid) {
474
- if (wk.paused || wk.dashboardPaused) paused++; else active++;
475
- }
476
- }
477
- const uptime = this._fmtUptime(Date.now() - this._startTime);
478
- const rate = totalCmds > 0 ? `${((totalSuccess / totalCmds) * 100).toFixed(0)}%` : '0%';
479
-
480
- const items = [
481
- [`⏱`, uptime, C.textDim],
482
- [`⬡`, `${this.workers.length} accounts`, C.textDim],
483
- [`⏣`, totalCoins.toLocaleString(), C.gold],
484
- [`⚡`, `${totalCmds} cmds`, C.textDim],
485
- [`📊`, `${rate} ok`, C.textDim],
486
- [`♥`, `${totalLs}`, C.pink],
487
- [`🟢`, `${active}`, C.green],
488
- [`🔴`, `${paused}`, C.red],
489
- ];
490
-
491
- return items.map(([icon, val, col]) =>
492
- `${C.textDim}${icon}${A.reset} ${col}${val}${A.reset}`
493
- ).join(` ${C.borderDim}│${A.reset} `);
494
- }
495
-
496
- _colHdr() {
497
- const cols = [
498
- `${C.purple}#`,
499
- `${C.purple}ACCOUNT`,
500
- `${C.purple}COINS`,
501
- `${C.purple}LV`,
502
- `${C.purple}♥`,
503
- `${C.purple}OK%`,
504
- `${C.purple}STATUS`,
505
- ];
506
- return cols.join(' ');
507
- }
508
-
509
- _accountRows() {
510
- let out = '';
511
- for (let i = 0; i < this.windowSize; i++) {
512
- const wkIdx = this.windowStart + i;
513
- const row = this._topRow + i;
514
- if (row > this._h - 5) break;
515
- if (wkIdx < this.workers.length) {
516
- out += `${this._row(row, this._accountLine(this.workers[wkIdx], wkIdx, this.workers))}`;
517
- } else {
518
- out += `${this._row(row, '')}`;
519
- }
520
- }
521
- return out;
522
- }
523
-
524
- _accountLine(wk, idx, workers) {
525
- const rank = coinRank(wk, workers);
526
- const pos = rank - 1; // 0-indexed
527
- const isActive = wk.running && !wk._tokenInvalid && !wk.paused && !wk.dashboardPaused;
528
- const ls = wk._lifesavers ?? '?';
529
- const rate = wk.stats?.commands > 0
530
- ? `${((wk.stats.successes / wk.stats.commands) * 100).toFixed(0)}%`
531
- : '0%';
532
-
533
- // Rank badge
534
- const medal = pos < 3 ? MEDALS[pos] : null;
535
- const rankColor = pos === 0 ? C.rank1 : pos === 1 ? C.rank2 : pos === 2 ? C.rank3 : null;
536
- const acctColor = C.ACCT[idx % C.ACCT.length];
537
-
538
- // Color based on rank (top 3 get medal color, rest get account color)
539
- const mainColor = isActive
540
- ? (rankColor || acctColor)
541
- : C.textFaint;
542
-
543
- const dimColor = isActive ? C.textDim : C.textFaint;
544
- const goldColor = isActive ? C.gold : C.textDim;
545
- const cyanColor = isActive ? C.cyan : C.textDim;
546
- const lsColor = isActive
547
- ? (ls === 0 ? C.red : ls <= 2 ? C.orange : C.pink)
548
- : C.textDim;
549
-
550
- // Status with pulse for active
551
- let dot, statusText;
552
- if (!wk.running || wk._tokenInvalid) {
553
- dot = `${C.textFaint}⚫${A.reset}`; statusText = `${C.textFaint}offline${A.reset}`;
554
- } else if (wk.paused || wk.dashboardPaused) {
555
- dot = `${C.red}🔴${A.reset}`; statusText = `${C.red}paused${A.reset}`;
556
- } else {
557
- const pulse = PULSE[this._pulseFrame];
558
- dot = `${C.green}${pulse}${A.reset}`; statusText = `${C.green}active${A.reset}`;
559
- }
560
-
561
- // Account name — truncate with care
562
- const rawName = wk.username || '?';
563
- const nameDisplay = rawName.length > 20
564
- ? rawName.substring(0, 17) + '...'
565
- : rawName;
566
-
567
- // Coins display
568
- const coins = (wk.stats?.coins || 0).toLocaleString();
569
- const sign = (wk.stats?.coins || 0) >= 0 ? '+' : '';
570
- const coinDisplay = `${goldColor}${sign}⏣${coins}${A.reset}`;
571
-
572
- // Current command (minimal)
573
- const cmd = (wk.lastStatus || '—').replace(/\x1b\[[0-9;]*m/g, '').substring(0, 14);
574
-
575
- // Build rank badge
576
- const rankBadge = medal
577
- ? `${rankColor}${A.bold}${medal}${A.reset}`
578
- : `${dimColor}${rank}th${A.reset}`;
579
-
580
- // Account name with subtle glow for top 3
581
- const nameBadge = pos < 3
582
- ? `${mainColor}${A.bold}${nameDisplay}${A.reset}`
583
- : `${mainColor}${nameDisplay}${A.reset}`;
584
-
585
- const parts = [
586
- rankBadge,
587
- nameBadge,
588
- coinDisplay,
589
- `${cyanColor}Lv.${String(wk._level ?? '?').padStart(3)}${A.reset}`,
590
- `${lsColor}♥${String(ls).padStart(2)}${A.reset}`,
591
- `${dimColor}${rate.padStart(5)}${A.reset}`,
592
- `${dot} ${statusText}`,
593
- `${dimColor}${cmd}`,
594
- ];
595
-
596
- return parts.join(' ');
597
- }
598
-
599
- _eventFeed() {
600
- let out = '';
601
- const visible = this.events.slice(0, Math.min(this.MAX_EVENTS, this._h - this._eventRow - 3));
602
- for (let i = 0; i < visible.length; i++) {
603
- const row = this._eventRow + i;
604
- if (row > this._h - 3) break;
605
- const e = visible[i];
606
- const color = e.type === 'death' ? C.red
607
- : e.type === 'lowls' ? C.orange
608
- : e.type === 'levelup'? C.cyan
609
- : e.type === 'success'? C.green
610
- : C.textDim;
611
- out += this._row(row, ` ${e.ts} ${color}${e.msg}${A.reset}`);
612
- }
613
- this._footerRow = this._eventRow + visible.length;
614
- return out;
615
- }
616
-
617
- _totalLine(totalCoins, totalCmds, totalSuccess, uptime, memMB) {
618
- const rate = totalCmds > 0 ? `${((totalSuccess / totalCmds) * 100).toFixed(0)}%` : '0%';
619
- const items = [
620
- [`💰`, `${C.gold}${A.bold}TOTAL:${A.reset}`, `${C.gold}${A.bold}⏣${totalCoins.toLocaleString()}${A.reset}`],
621
- [`⚡`, `${C.textDim}${totalCmds} cmds${A.reset}`, `${C.textDim}${rate} ok${A.reset}`],
622
- [`⏱`, `${C.textDim}${this._fmtUptime(uptime)}${A.reset}`, `${C.textDim}${memMB}MB${A.reset}`],
623
- ];
624
- return items.map(([, label, val]) => `${label} ${val}`).join(' ');
625
- }
626
-
627
- // ── Render loop ───────────────────────────────────────────────────────
628
-
629
- _render() {
630
- if (!READY || this._shutdown || !this._active) return;
631
-
632
- if (this.dirtyStats) {
633
- // Update stats bar (row 4)
634
- const stats = this._buildStats();
635
- const w = this._w;
636
- const V = C.border;
637
- this._write(
638
- `${this._at(4,1)}${V} ${rpad(stats, w-4)} ${V}${A.reset}`
639
- );
640
- // Re-draw account rows to update pulse/status
641
- for (let i = 0; i < this.windowSize; i++) {
642
- const wkIdx = this.windowStart + i;
643
- const row = this._topRow + i;
644
- if (row > this._h - 5) break;
645
- if (wkIdx < this.workers.length) {
646
- const line = this._accountLine(this.workers[wkIdx], wkIdx, this.workers);
647
- this._write(`${this._at(row,1)}${C.border}${V} ${rpad(line, w-4)} ${V}${A.reset}`);
648
- }
649
- }
650
- this.dirtyStats = false;
651
- }
652
-
653
- if (this.dirtyWorkers.size > 0) {
654
- const w = this._w;
655
- const V = C.border;
656
- for (const wkIdx of this.dirtyWorkers) {
657
- const localRow = wkIdx - this.windowStart;
658
- const row = this._topRow + localRow;
659
- if (row < this._topRow || row > this._h - 5) continue;
660
- if (wkIdx < this.workers.length) {
661
- const line = this._accountLine(this.workers[wkIdx], wkIdx, this.workers);
662
- this._write(`${this._at(row,1)}${C.border}${V} ${rpad(line, w-4)} ${V}${A.reset}`);
663
- } else {
664
- this._write(`${this._at(row,1)}${C.border}${V}${rpad('', w-2)}${V}${A.reset}`);
665
- }
666
- }
667
- }
668
-
669
- if (this.dirtyEvents) {
670
- this._write(this._eventFeed());
671
- }
672
-
673
- this.dirtyWorkers.clear();
674
- this.dirtyEvents = false;
675
- }
676
-
677
- _startRenderLoop() {
678
- if (this._renderTimer) clearInterval(this._renderTimer);
679
- this._renderTimer = setInterval(() => this._render(), 250);
680
- }
681
-
682
- // ── Internals ──────────────────────────────────────────────────────────
683
-
684
- _updateSize() {
685
- try {
686
- this._w = process.stdout.columns || 110;
687
- this._h = process.stdout.rows || 35;
688
- this.windowSize = Math.max(4, this._h - 11);
689
- } catch (_) {
690
- this._w = 110; this._h = 35; this.windowSize = 17;
691
- }
692
- }
693
-
694
- _onResize() {
695
- clearTimeout(this._resizeTimer);
696
- this._resizeTimer = setTimeout(() => {
697
- this._updateSize();
698
- if (this._active) {
699
- this._write(A.eraseAll);
700
- this._drawLiveView();
701
- }
702
- }, 100);
703
- }
704
-
705
- _at(r, c) { return `\x1b[${r};${c}H`; }
706
- _write(s) { if (s) process.stdout.write(s); }
707
-
708
- _fmtUptime(ms) {
709
- if (!ms) return '0s';
710
- const s = Math.floor(ms / 1000);
711
- if (s < 60) return `${s}s`;
712
- const m = Math.floor(s / 60);
713
- if (m < 60) return `${m}m ${s%60}s`;
714
- const h = Math.floor(m / 60);
715
- if (h < 24) return `${h}h ${m%60}m`;
716
- return `${Math.floor(h/24)}d ${h%24}h`;
717
- }
718
- }
719
-
720
- module.exports = new Terminal();