dankgrinder 7.6.0 → 7.7.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.
@@ -4,6 +4,7 @@ const {
4
4
  isCV2, ensureCV2, stripAnsi, needsItem, clickCV2SelectMenu,
5
5
  } = require('./utils');
6
6
  const { buyItem, buyItemsBatch } = require('./shop');
7
+ const rawLogger = require('../../lib/rawLogger');
7
8
  const {
8
9
  downloadImage,
9
10
  extractFarmImageUrl,
@@ -938,6 +939,17 @@ function pickFarmActionButton(msg, text) {
938
939
  return null;
939
940
  }
940
941
 
942
+ // Captures the ephemeral interaction response content from a CV2 ack object.
943
+ // Dank Memer sends error/success text in the interaction ACK (type 4/7) content field.
944
+ function captureCvvAckContent(ack) {
945
+ if (!ack) return '';
946
+ const flags = ack?.flags ?? ack?.data?.flags ?? 0;
947
+ const content = String(stripAnsi(ack?.content || ack?.data?.content || '')).trim();
948
+ if (!content) return '';
949
+ const isEphemeral = (flags & 64) !== 0;
950
+ return `[ephemeral=${isEphemeral}] ${content}`;
951
+ }
952
+
941
953
  async function clickAndCapture({ channel, waitForDankMemer, response, button, tag, timeoutMs = 9000 }) {
942
954
  const baseline = brief(getFullText(response), 400);
943
955
  const clickRes = await safeClickButton(response, button);
@@ -947,7 +959,30 @@ async function clickAndCapture({ channel, waitForDankMemer, response, button, ta
947
959
  delete response._lastInteractionAck;
948
960
  }
949
961
 
950
- let post = clickRes || null;
962
+ // CV2 HTTP fallback returns the interaction ACK (not the updated message).
963
+ // If clickRes looks like an ack rather than a message, extract it and wait for
964
+ // the edited message instead.
965
+ const isAckObject = clickRes && typeof clickRes === 'object' && !clickRes.id && (clickRes.interactionStatus || clickRes.interactionType);
966
+ const capturedAck = isAckObject ? clickRes : null;
967
+
968
+ // Register a one-time callback to capture ephemeral responses (e.g. "You harvested: Nothing")
969
+ // from the raw gateway packet — the only place where ephemeral embeds/CV2 text are preserved.
970
+ let capturedEphemeral = null;
971
+ rawLogger.onNextEphemeral((parsed) => {
972
+ capturedEphemeral = parsed;
973
+ const cv2t = parsed.cv2Text || '';
974
+ const allt = parsed.allText || '';
975
+ const txt = cv2t || allt;
976
+ if (txt.includes('harvested')) LOG.info(`${tag}-ephemeral harvest: ${txt.slice(0, 200)}`);
977
+ else LOG.info(`${tag}-ephemeral ${txt.slice(0, 200)}`);
978
+ });
979
+
980
+ // Extra grace period: the ephemeral often arrives after safeClickButton resolves
981
+ // (the library may return the stale message immediately, before the gateway delivers the
982
+ // ephemeral). This ensures we capture it even when it arrives late.
983
+ await sleep(1500);
984
+
985
+ let post = (!isAckObject && clickRes) ? clickRes : null;
951
986
  if (!post && response.id) post = await waitForEditedMessage(channel, response.id, baseline, timeoutMs);
952
987
  if (!post) post = await waitForDankMemer(timeoutMs);
953
988
 
@@ -956,17 +991,23 @@ async function clickAndCapture({ channel, waitForDankMemer, response, button, ta
956
991
 
957
992
  // Capture any additional immediate callback message (often ephemeral-like
958
993
  // "only you can see this" notices) that may be separate from edited CV2 post.
994
+ let extraMsg = null;
959
995
  try {
960
996
  const side = await waitForDankMemer(1500);
961
997
  if (side && side.id !== post.id) {
962
998
  logEphemeralLike(`${tag}-post-extra`, side);
963
- post._farmExtraInteraction = side;
999
+ extraMsg = side;
964
1000
  }
965
1001
  } catch {}
966
1002
 
967
1003
  if (isCV2(post)) await ensureCV2(post);
968
1004
  logMsg(post, tag);
969
1005
  logFarmState(tag, post);
1006
+
1007
+ // Attach captured ack and extra message so callers can access them.
1008
+ post._capturedAck = capturedAck;
1009
+ post._farmExtraMsg = extraMsg;
1010
+ post._capturedEphemeral = capturedEphemeral;
970
1011
  return post;
971
1012
  }
972
1013
 
@@ -1172,11 +1213,20 @@ async function advancePastConfirmation(response, waitForDankMemer) {
1172
1213
  if (!response) return null;
1173
1214
 
1174
1215
  const CONFIRM_WORDS = ['continue', 'confirm', 'confirm all', 'done', 'back to farm', 'back', 'close'];
1216
+ // Also treat "All" buttons as confirmation actions (e.g. "Hoe All", "Water All").
1217
+ // Skip the plain "back" word to avoid matching the Back arrow button; prefer explicit
1218
+ // confirm words first, then fall back to "All" buttons as confirmation.
1219
+ const ALL_AS_CONFIRM = ['hoe all', 'water all', 'plant all', 'harvest all', 'fertilize all'];
1175
1220
  const MAX_PAGES = 3;
1176
1221
 
1177
1222
  for (let page = 0; page < MAX_PAGES; page++) {
1178
1223
  const btns = getAllButtons(response).filter(b => !b.disabled);
1179
- const confirmBtn = btns.find(b => CONFIRM_WORDS.some(w => buttonHay(b).includes(w)));
1224
+ let confirmBtn = btns.find(b => CONFIRM_WORDS.some(w => buttonHay(b).includes(w)));
1225
+ // Also treat "All" buttons as confirmation (e.g. clicking "Plant All" again on the
1226
+ // confirmation screen advances past it). But prefer explicit confirm words first.
1227
+ if (!confirmBtn) {
1228
+ confirmBtn = btns.find(b => ALL_AS_CONFIRM.some(w => buttonHay(b).includes(w)));
1229
+ }
1180
1230
 
1181
1231
  if (!confirmBtn) {
1182
1232
  // No more confirmation buttons — return the current screen (should be manage menu)
@@ -1192,6 +1242,18 @@ async function advancePastConfirmation(response, waitForDankMemer) {
1192
1242
  try {
1193
1243
  clicked = await safeClickButton(response, confirmBtn);
1194
1244
  logEphemeralLike(`confirm-page-${page}`, clicked);
1245
+ // Preserve CV2 ack so cycle can check for ephemeral errors.
1246
+ if (clicked?._lastInteractionAck) {
1247
+ response._lastInteractionAck = clicked._lastInteractionAck;
1248
+ }
1249
+ // Also capture ephemeral ack content for logging
1250
+ const ackContent = captureCvvAckContent(clicked);
1251
+ if (ackContent) {
1252
+ const flags = clicked?.flags ?? 0;
1253
+ const isEphemeral = (flags & 64) !== 0;
1254
+ const level = isEphemeral ? LOG.warn : LOG.info;
1255
+ level(`[farm:confirm:${page}] ephemeral=${isEphemeral} ack="${ackContent.slice(0, 300)}"`);
1256
+ }
1195
1257
  } catch (e) {
1196
1258
  LOG.warn(`[farm:confirm] click failed on page ${page}: ${e.message}`);
1197
1259
  return response;
@@ -1235,7 +1297,7 @@ async function advancePastConfirmation(response, waitForDankMemer) {
1235
1297
  // ── Single-cycle farm orchestrator ──────────────────────────────────────────
1236
1298
  // Sends `pls farm view` once, then completes the full hoe→water→plant→harvest
1237
1299
  // cycle by looping on the returned manage menu — no additional command sends.
1238
- async function runFarm({ channel, waitForDankMemer, client, redis, accountId }) {
1300
+ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, forceRun }) {
1239
1301
  LOG.cmd(`${c.white}${c.bold}pls farm view${c.reset}`);
1240
1302
 
1241
1303
  await channel.send('pls farm view');
@@ -1291,7 +1353,7 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
1291
1353
  if (textHasHarvestReady) LOG.info(`[farm] DETECTED harvest-ready in text (growReadySec=${growReadySec})`);
1292
1354
  if (growReadySec > 0) LOG.info(`[farm] GROW-QUEUE DEBUG: lower=${lower.slice(0, 400)}`);
1293
1355
  LOG.info(`[farm] grow-ready parse=${growReadySec == null ? 'none' : `${growReadySec}s`} redis_recovery=${redisRecoveryMs != null ? `${redisRecoveryMs}ms` : 'n/a'}`);
1294
- if (!redisRecovered && growReadySec && growReadySec > 20) {
1356
+ if (!forceRun && !redisRecovered && growReadySec && growReadySec > 20) {
1295
1357
  await inferPreferredActionFromImage(response);
1296
1358
  // Re-check every 30s instead of waiting the full grow duration.
1297
1359
  // This ensures the next harvest cycle starts as soon as crops are ready.
@@ -1428,6 +1490,30 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
1428
1490
  label.includes('plant all') || label.includes('harvest all') || label.includes('confirm')) && b.disabled;
1429
1491
  });
1430
1492
  if (disabledAllBtn) {
1493
+ // For harvest: disabled means nothing to harvest — skip to hoe.
1494
+ // For hoe/water/plant: disabled means we don't have the item — try to buy it.
1495
+ const ITEM_FOR_ACTION = { hoe: 'Hoe', water: 'Watering Can', plant: 'Seeds' };
1496
+ const missingItem = ITEM_FOR_ACTION[action];
1497
+ if (missingItem) {
1498
+ LOG.info(`[farm:cycle:${cycleDepth}] ${action} all-btn disabled — trying to buy ${missingItem}`);
1499
+ const bought = await tryBuyFarmItem({ missing: missingItem, channel, waitForDankMemer, client });
1500
+ if (bought.ok) {
1501
+ LOG.success(`[farm:cycle:${cycleDepth}] Bought ${bought.itemName} — retrying ${action}`);
1502
+ await sleep(1200);
1503
+ // Restart the cycle from farm view to refresh button state
1504
+ await channel.send('pls farm view');
1505
+ const re = await waitForDankMemer(12000);
1506
+ if (!re) { LOG.warn('[farm:cycle] no response after buy retry'); break; }
1507
+ if (isCV2(re)) await ensureCV2(re);
1508
+ cycleResponse = re;
1509
+ text = getFullText(cycleResponse);
1510
+ clean = brief(text, 600);
1511
+ initialAnalysis = null;
1512
+ continue;
1513
+ } else {
1514
+ LOG.warn(`[farm:cycle:${cycleDepth}] Could not buy ${missingItem} — skipping to next phase`);
1515
+ }
1516
+ }
1431
1517
  const currentIdx = FARM_PHASE_ORDER.indexOf(action);
1432
1518
  const nextPhase = FARM_PHASE_ORDER[currentIdx + 1] || null;
1433
1519
  LOG.info(`[farm:cycle:${cycleDepth}] ${action} all-btn disabled (no-op) — next=${nextPhase || 'done'}`);
@@ -1455,12 +1541,98 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
1455
1541
  // Step 5: advance past any confirmation screens back to the manage menu
1456
1542
  cycleResponse = await advancePastConfirmation(lastApplyResp, waitForDankMemer);
1457
1543
  if (!cycleResponse) { LOG.warn('[farm:cycle] confirmation advance returned null'); break; }
1544
+ if (isCV2(cycleResponse)) await ensureCV2(cycleResponse);
1458
1545
  actionsTaken++;
1459
1546
  lastAction = action;
1460
1547
  text = getFullText(cycleResponse);
1461
1548
  clean = brief(text, 600);
1462
1549
  logFarmState('after-confirm', cycleResponse);
1463
1550
 
1551
+ // ── Farm text detection: determine next action from farm state ─────────────────
1552
+ // After confirmation advances, we may be on the farm view (no action tabs visible).
1553
+ // Use farm text patterns to determine what to do next — this is more reliable
1554
+ // than the button state check (which fails on the farm view).
1555
+ {
1556
+ const postBtns = getAllButtons(cycleResponse);
1557
+ const postActionBtns = postBtns.filter(b => !b.disabled && !isNavOrUtilityButton(b));
1558
+ const postHasActionTabs = postActionBtns.some(b => hasAny(b, ['hoe', 'water', 'plant', 'harvest', 'fertiliz']));
1559
+ const postLower = clean.toLowerCase();
1560
+ const hasSeedsReady = /seeds ready|seeds.*ready|planted|crop.*ready/i.test(postLower);
1561
+ const hasHarvestReady = /ready to harvest|harvest ready|can be harvest|can harvest|wilt/i.test(postLower);
1562
+ const isEmpty = /pretty empty|seems empty|empty\.{0,3}/i.test(postLower) && !hasSeedsReady && !hasHarvestReady;
1563
+ if (!postHasActionTabs && !isEmpty) {
1564
+ // Farm has crops (planted/growing). Force harvest.
1565
+ // cycleResponse is still the farm view (no action tabs) after advancePastConfirmation.
1566
+ // Re-enter the manage menu so findNextFarmActionFromManage has buttons to work with.
1567
+ const mb = findFarmButton(cycleResponse, ['manage', 'farm-farm:manage']);
1568
+ if (mb) {
1569
+ LOG.info(`[farm:cycle:${cycleDepth}] farm-text: re-entering manage menu to force harvest`);
1570
+ const mr = await clickAndCapture({ channel, waitForDankMemer, response: cycleResponse, button: mb, tag: 'farm-farmtext-harvest-manage' });
1571
+ if (mr) {
1572
+ if (isCV2(mr)) await ensureCV2(mr);
1573
+ cycleResponse = mr;
1574
+ text = getFullText(cycleResponse);
1575
+ clean = brief(text, 600);
1576
+ }
1577
+ }
1578
+ forcedNextAction = 'harvest';
1579
+ lastAction = null;
1580
+ cycleDepth++;
1581
+ initialAnalysis = null;
1582
+ await sleep(300);
1583
+ continue;
1584
+ }
1585
+ if (!postHasActionTabs && isEmpty) {
1586
+ // Farm is empty after the action. Re-enter manage menu before forcing hoe.
1587
+ const mb = findFarmButton(cycleResponse, ['manage', 'farm-farm:manage']);
1588
+ if (mb) {
1589
+ LOG.info(`[farm:cycle:${cycleDepth}] farm-text: re-entering manage menu (empty farm)`);
1590
+ const mr = await clickAndCapture({ channel, waitForDankMemer, response: cycleResponse, button: mb, tag: 'farm-farmtext-hoe-manage' });
1591
+ if (mr) {
1592
+ if (isCV2(mr)) await ensureCV2(mr);
1593
+ cycleResponse = mr;
1594
+ text = getFullText(cycleResponse);
1595
+ clean = brief(text, 600);
1596
+ }
1597
+ }
1598
+ forcedNextAction = 'hoe';
1599
+ lastAction = null;
1600
+ cycleDepth++;
1601
+ initialAnalysis = null;
1602
+ await sleep(300);
1603
+ continue;
1604
+ }
1605
+ // Otherwise: we're on manage menu or have action tabs — proceed to find next action.
1606
+ }
1607
+
1608
+ // ── Harvest ephemeral: parse "You harvested: Nothing" to detect no-op ──────────
1609
+ if (action === 'harvest') {
1610
+ const ephemeral = lastApplyResp?._capturedEphemeral;
1611
+ if (ephemeral) {
1612
+ const cv2t = ephemeral.cv2Text || '';
1613
+ const allt = ephemeral.allText || '';
1614
+ const harvestText = cv2t || allt;
1615
+ LOG.info(`[farm:cycle:${cycleDepth}:harvest-ephemeral] "${harvestText.slice(0, 300)}"`);
1616
+ // If nothing was harvested, cells are now empty (post-harvest debris).
1617
+ // Force the cycle to proceed to hoe rather than re-analyzing the same state.
1618
+ if (/nothing|0\s*x\s*\w|no\s+crops|\bnone\b/i.test(harvestText)) {
1619
+ LOG.info(`[farm:cycle:${cycleDepth}:harvest-ephemeral] Nothing harvested — forcing next action to hoe`);
1620
+ forcedNextAction = 'hoe';
1621
+ lastAction = 'harvest';
1622
+ cycleDepth++;
1623
+ initialAnalysis = null;
1624
+ await sleep(300);
1625
+ continue;
1626
+ }
1627
+ // Something was actually harvested — parse what and log it.
1628
+ const itemMatches = [...harvestText.matchAll(/-?\s*(\d+)\s*x?\s*([A-Za-z]+)/g)];
1629
+ if (itemMatches.length > 0) {
1630
+ const items = itemMatches.map(m => `${m[1]}x ${m[2]}`).join(', ');
1631
+ LOG.info(`[farm:cycle:${cycleDepth}:harvest-ephemeral] Harvested: ${items}`);
1632
+ }
1633
+ }
1634
+ }
1635
+
1464
1636
  // If confirmation screen is identical to post-confirmation screen,
1465
1637
  // check whether we're on a planted confirmation (Dank Memer planted crops
1466
1638
  // and shows "ready at X"). In that case, click "Back" to return to the
@@ -1532,17 +1704,38 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
1532
1704
  const score = scoreResult?.score || null;
1533
1705
  const nextAction = inferNextActionFromScore(action, score);
1534
1706
 
1707
+ // Collect all error/rejection text sources:
1708
+ // 1. Extra follow-up messages from farm actions
1709
+ // 2. CV2 interaction ACK (ephemeral or success/error response)
1710
+ // 3. The main message text after the action
1711
+ const ackContent = captureCvvAckContent(lastApplyResp?._capturedAck);
1712
+ const extraContent = lastApplyResp?._farmExtraMsg
1713
+ ? String(stripAnsi(getFullText(lastApplyResp._farmExtraMsg) || '')).replace(/\s+/g, ' ').trim()
1714
+ : '';
1715
+ const postContent = String(stripAnsi(getFullText(lastApplyResp) || '')).replace(/\s+/g, ' ').trim();
1716
+
1717
+ if (ackContent || extraContent) {
1718
+ const flags = lastApplyResp?._capturedAck?.flags ?? 0;
1719
+ const isEphemeral = (flags & 64) !== 0;
1720
+ const level = isEphemeral ? LOG.warn : LOG.info;
1721
+ level(`[farm:cycle:${cycleDepth}:ephemeral] ephemeral=${isEphemeral} ack="${ackContent.slice(0, 300)}" extra="${extraContent.slice(0, 200)}"`);
1722
+ }
1723
+
1724
+ // Build combined error text for rejection detection
1725
+ const allErrorText = [extraContent, ackContent].filter(Boolean).join(' ');
1726
+
1535
1727
  // Handle rejection messages that indicate wrong phase.
1536
- const extraText = String(stripAnsi(getFullText(cycleResponse?._farmExtraInteraction) || '')).toLowerCase();
1537
- if (action === 'plant' && /only\s+plant\s+seeds\s+on\s+an\s+empty\s+tile|tilled\s+and\s+watered/.test(extraText)) {
1538
- LOG.warn('[farm:cycle] Plant rejected — need hoe first. Restarting from hoe.');
1728
+ if (action === 'plant' && /only\s+plant\s+seeds\s+on\s+an\s+empty\s+tile|tilled\s+and\s+watered|can only plant seeds on an empty tile/.test(allErrorText)) {
1729
+ LOG.warn(`[farm:cycle] Plant rejected need hoe+water first. Restarting from hoe. err="${allErrorText.slice(0, 200)}"`);
1730
+ forcedNextAction = 'hoe';
1539
1731
  lastAction = null;
1540
1732
  cycleDepth++;
1541
1733
  await sleep(400);
1542
1734
  continue;
1543
1735
  }
1544
- if (action === 'hoe' && /can only use.*hoe.*empty tile|after a harvest/.test(extraText)) {
1736
+ if (action === 'hoe' && /can only use.*hoe.*empty tile|after a harvest/.test(allErrorText)) {
1545
1737
  LOG.warn('[farm:cycle] Hoe rejected — moving to water phase.');
1738
+ forcedNextAction = 'water';
1546
1739
  lastAction = 'hoe';
1547
1740
  cycleDepth++;
1548
1741
  await sleep(400);
@@ -1551,6 +1744,22 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
1551
1744
 
1552
1745
  if (!nextAction) {
1553
1746
  LOG.info(`[farm:cycle:${cycleDepth}] ${action} done — no next action (score=${score?.score ?? '?'}/${score?.threshold ?? '?'}, conf=${score?.confidence ?? '?'})`);
1747
+ // Enforce strict phase ordering: after water → harvest. Never route to plant from image analysis.
1748
+ if (action === 'water') {
1749
+ const harvestIdx = FARM_PHASE_ORDER.indexOf('harvest');
1750
+ if (harvestIdx >= 0) {
1751
+ const harvestBtn = managedActions.harvest || getAllButtons(cycleResponse).find(b => hasAny(b, ['harvest', 'reap', 'collect']));
1752
+ if (harvestBtn) {
1753
+ LOG.info(`[farm:cycle:${cycleDepth}] enforcing harvest after water (strict phase order)`);
1754
+ forcedNextAction = 'harvest';
1755
+ cycleDepth++;
1756
+ lastAction = 'water';
1757
+ initialAnalysis = null;
1758
+ await sleep(300);
1759
+ continue;
1760
+ }
1761
+ }
1762
+ }
1554
1763
  break;
1555
1764
  }
1556
1765
 
@@ -82,16 +82,11 @@ function extractFarmImageUrl(msg) {
82
82
  function colorStatsForPixel(r, g, b) {
83
83
  const sum = r + g + b;
84
84
  const brightness = sum / 3;
85
- // Crop green — harvest phase: large bright leaves, G significantly > R and B
86
- const greenStrong = g > r * 1.25 && g > b * 1.25 && g > 38;
87
- // Weeds/grass small scattered green, less intense than crop green
88
- const weedGreen = g > r * 1.12 && g > b * 1.1 && g <= 38;
89
- // Wet soil — rich dark brown, no blue, higher R than typical dry soil
90
- const wetSoil = r > 50 && r > g * 1.5 && b < 20 && brightness < 45;
91
- // Dry tilled soil — medium brown, debris textures
92
- const drySoil = r > 35 && r > g * 1.2 && b < 30;
85
+ // Grayscale: removes hue confusion between dusty-brown and green
86
+ const gray = Math.round(r * 0.299 + g * 0.587 + b * 0.114);
87
+ const soil = r >= g && b < 30;
93
88
  const whiteish = brightness > 210 && Math.abs(r - g) < 20 && Math.abs(g - b) < 20;
94
- return { brightness, greenStrong, weedGreen, wetSoil, drySoil, whiteish };
89
+ return { brightness, gray, soil, whiteish };
95
90
  }
96
91
 
97
92
  function clamp01(n) {
@@ -99,41 +94,79 @@ function clamp01(n) {
99
94
  }
100
95
 
101
96
  function classifyCell(features) {
102
- const { greenPct, bluePct, brownPct, darkPct, avgBrightness } = features;
97
+ const {
98
+ harvestPct, plantPct, soilPct,
99
+ avgBrightness, grayStd,
100
+ brightGrayPct, darkGrayPct,
101
+ avgSat, avgR, avgG, avgB,
102
+ gMinusR, discordUiPct,
103
+ } = features;
104
+
105
+ // If Discord UI dominates this cell, the stats are meaningless
106
+ if (discordUiPct > 0.5) {
107
+ return { state: 'unknown', confidence: 0.1, scores: { harvest: 0, planted: 0, soil: 0 } };
108
+ }
103
109
 
104
- // ── HARVEST: Bright green mature crops (G >> R,B, G>38) ──
105
- // Detected by greenStrong pixel percentage crop leaves are vivid green
106
- if (greenPct >= 0.30) {
107
- const confidence = clamp01(0.5 + (greenPct - 0.30) * 2.0);
108
- return { state: 'planted', confidence: +confidence.toFixed(3), scores: { planted: +greenPct.toFixed(3), wet: +darkPct.toFixed(3), tilled: +brownPct.toFixed(3) } };
110
+ // Actual farm pixel analysis from live screenshot:
111
+ // Water cells: sat=0.15-0.20, B=14-21, darkPct=64-91%, G-R=-20 to +4
112
+ // Tilled cells: sat=0.22-0.25, B=12-17, darkPct=64-81%, G-R=-8 to +11
113
+ // Harvest cells: sat=0.22-0.25, B=12-17, darkPct=64-73%, G-R=+4 to +11, harv=41-46%
114
+ //
115
+ // Key insight: sat < 0.20 + B > 14 = water cells
116
+ // Higher sat (0.22+) + harvestPixels > 40% = harvest crops
117
+ // Higher sat (0.22+) + harv < 40% = tilled soil
118
+
119
+ const isLowSatWater = avgSat < 0.20 && avgB > 14; // wet/dark soil
120
+ const isHighSatTilled = avgSat >= 0.20; // tilled/planted soil
121
+ const isBright = brightGrayPct > 0.15; // has bright crop pixels
122
+ const isDarkUniform = darkGrayPct > 0.55 && grayStd < 20; // very dark uniform (wet soil)
123
+
124
+ // ── HARVEST: Bright crops + high green + sat ≥ 0.20 ──
125
+ // Growing/ready crops: sat 0.22+, G-R > 5, harvestPixels > 40%
126
+ if (avgSat >= 0.20 && gMinusR > 5 && harvestPct > 0.40 && isBright) {
127
+ const excess = gMinusR - 5;
128
+ const confidence = clamp01(0.5 + excess * 0.06);
129
+ return { state: 'harvest', confidence: +confidence.toFixed(3), scores: { harvest: +harvestPct.toFixed(3), planted: +plantPct.toFixed(3), soil: +soilPct.toFixed(3) } };
109
130
  }
110
131
 
111
- // ── TILLED / WET: Brown soil no meaningful green or blue ──
112
- if (brownPct >= 0.15) {
113
- // High brown percentage = definitely soil. Both hoe (dry) and water (wet)
114
- // phases look like brown soil. We classify as 'tilled' the farming
115
- // logic handles water vs hoe state transition by phase context.
116
- const confidence = clamp01((brownPct - 0.15) * 2.5 + (1 - darkPct) * 0.3);
117
- return { state: 'tilled', confidence: +confidence.toFixed(3), scores: { planted: +greenPct.toFixed(3), wet: +darkPct.toFixed(3), tilled: +brownPct.toFixed(3) } };
132
+ // ── WATER: Low saturation + blue dominant (wet soil) ──
133
+ // sat < 0.20 AND B > 14 catches actual wet cells from live screenshot
134
+ if (isLowSatWater || isDarkUniform) {
135
+ return { state: 'water', confidence: +clamp01(0.5 + darkGrayPct * 0.3).toFixed(3), scores: { harvest: +harvestPct.toFixed(3), planted: +plantPct.toFixed(3), soil: +soilPct.toFixed(3) } };
118
136
  }
119
137
 
120
- // ── PLANTED (early stage): Soil with green weeds ──
121
- // Cells with 5-30% green are likely soil with weeds/crops starting.
122
- // This is distinct from harvest (30%+) which is handled above.
123
- if (greenPct >= 0.05 && greenPct < 0.30) {
124
- const confidence = clamp01((greenPct - 0.05) * 3.0);
125
- return { state: 'planted', confidence: +confidence.toFixed(3), scores: { planted: +greenPct.toFixed(3), wet: +darkPct.toFixed(3), tilled: +brownPct.toFixed(3) } };
138
+ // ── TILLED: Higher saturation ( 0.20) = soil/debris ──
139
+ // sat 0.20 covers tilled dirt, debris, and planted cells
140
+ if (isHighSatTilled) {
141
+ // Planted if some harvest pixels and some brightness
142
+ if (plantPct > 0.15 || gMinusR > 1) {
143
+ const confidence = clamp01(0.3 + avgSat * 0.8 + brightGrayPct * 0.5);
144
+ return { state: 'planted', confidence: +confidence.toFixed(3), scores: { harvest: +harvestPct.toFixed(3), planted: +plantPct.toFixed(3), soil: +soilPct.toFixed(3) } };
145
+ }
146
+ const confidence = clamp01((avgSat - 0.15) * 1.5);
147
+ return { state: 'tilled', confidence: +confidence.toFixed(3), scores: { harvest: +harvestPct.toFixed(3), planted: +plantPct.toFixed(3), soil: +soilPct.toFixed(3) } };
126
148
  }
127
149
 
128
- // ── UNKNOWN: Low signal grass borders, artifacts, etc. ──
129
- const signal = Math.max(greenPct, bluePct, brownPct);
150
+ // ── UNKNOWN: Low signal / empty ──
151
+ const signal = Math.max(harvestPct, plantPct, soilPct, avgSat);
130
152
  const confidence = signal * 1.5;
131
- return { state: 'unknown', confidence: +confidence.toFixed(3), scores: { planted: +greenPct.toFixed(3), wet: +darkPct.toFixed(3), tilled: +brownPct.toFixed(3) } };
153
+ return { state: 'unknown', confidence: +confidence.toFixed(3), scores: { harvest: +harvestPct.toFixed(3), planted: +plantPct.toFixed(3), soil: +soilPct.toFixed(3) } };
132
154
  }
133
155
 
134
156
  async function analyzeFarmGrid(imgBuffer, cols = 3, rows = 3) {
135
- const { data, info } = await sharp(imgBuffer).raw().toBuffer({ resolveWithObject: true });
136
- const { width, height, channels } = info;
157
+ // Convert to grayscale to eliminate hue confusion between dusty-brown and green
158
+ const grayBuf = await sharp(imgBuffer).grayscale().raw().toBuffer({ resolveWithObject: true });
159
+ const { data: grayData, info: grayInfo } = grayBuf;
160
+ const { width, height, channels } = grayInfo;
161
+
162
+ // Also get original for saturation analysis
163
+ const origBuf = await sharp(imgBuffer).raw().toBuffer({ resolveWithObject: true });
164
+ const { data: origData, info: origInfo } = origBuf;
165
+
166
+ if (origInfo.width !== width || origInfo.height !== height) {
167
+ throw new Error('Grayscale/Original dimension mismatch — should not happen');
168
+ }
169
+
137
170
  const cellW = Math.floor(width / cols);
138
171
  const cellH = Math.floor(height / rows);
139
172
 
@@ -145,60 +178,103 @@ async function analyzeFarmGrid(imgBuffer, cols = 3, rows = 3) {
145
178
  const ex = Math.min(sx + cellW, width);
146
179
  const ey = Math.min(sy + cellH, height);
147
180
 
148
- // Sample center region only (ignore grass borders around each tile).
149
- const padX = Math.floor((ex - sx) * 0.20); // 20% padding to cut more grass
150
- const padY = Math.floor((ey - sy) * 0.20);
151
- const csx = sx + padX;
152
- const csy = sy + padY;
153
- const cex = ex - padX;
154
- const cey = ey - padY;
181
+ // Use 30% vertical padding to avoid Discord UI bar bleeding into top/bottom rows
182
+ const padX = Math.floor((ex - sx) * 0.20);
183
+ const padY = Math.floor((ey - sy) * 0.30);
184
+ const csx = sx + padX;
185
+ const csy = sy + padY;
186
+ const cex = ex - padX;
187
+ const cey = ey - padY;
155
188
 
156
189
  let total = 0;
157
- let greenPx = 0; // crop green: G > R*1.25 && G > B*1.25 && G > 38
158
- let brownPx = 0; // soil brown: R > G*1.2 && B < 30
159
- let darkPx = 0; // very dark: brightness < 56
160
- let whitePx = 0;
161
- let brightSum = 0;
190
+ let harvestPx = 0;
191
+ let plantPx = 0;
192
+ let soilPx = 0;
193
+ let graySum = 0, graySqSum = 0;
194
+ let brightGrayPx = 0;
195
+ let darkGrayPx = 0;
196
+ let rSum = 0, gSum = 0, bSum = 0, satSum = 0;
197
+ let discordUiPx = 0;
198
+ const rawCellPixels = (cex - csx) * (cey - csy);
162
199
 
163
200
  for (let y = csy; y < cey; y++) {
164
201
  for (let x = csx; x < cex; x++) {
165
202
  const idx = (y * width + x) * channels;
166
- const r = data[idx];
167
- const g = data[idx + 1];
168
- const b = data[idx + 2];
169
- const s = colorStatsForPixel(r, g, b);
203
+ const r = origData[idx];
204
+ const g = origData[idx + 1];
205
+ const b = origData[idx + 2];
206
+ const gray = grayData[idx];
207
+
208
+ const maxC = Math.max(r, g, b);
209
+ const minC = Math.min(r, g, b);
210
+ const sat = maxC > 0 ? (maxC - minC) / 255 : 0;
211
+
212
+ // Skip Discord UI blue bar: very low saturation + blue dominant
213
+ if (b > 80 && sat < 0.08) {
214
+ discordUiPx++;
215
+ continue;
216
+ }
217
+
170
218
  total++;
171
- brightSum += s.brightness;
172
- if (s.greenStrong) greenPx++; // harvest green (vivid crop leaves)
173
- if (s.drySoil || s.wetSoil) brownPx++; // soil dry or wet
174
- if (s.dark) darkPx++;
175
- if (s.whiteish) whitePx++;
219
+ graySum += gray;
220
+ graySqSum += gray * gray;
221
+ rSum += r; gSum += g; bSum += b;
222
+ satSum += sat;
223
+
224
+ if (g > r * 1.25) harvestPx++;
225
+ else if (g > r * 1.08) plantPx++;
226
+ if (r >= g) soilPx++;
227
+ if (gray > 100) brightGrayPx++;
228
+ if (gray < 65) darkGrayPx++;
176
229
  }
177
230
  }
178
231
 
179
- const greenPct = greenPx / total;
180
- const brownPct = brownPx / total;
181
- const darkPct = darkPx / total;
182
- const whitePct = whitePx / total;
183
- const avgBrightness = brightSum / total;
184
-
185
- const classified = classifyCell({ greenPct, brownPct, darkPct, avgBrightness });
232
+ const harvestPct = total > 0 ? harvestPx / total : 0;
233
+ const plantPct = total > 0 ? plantPx / total : 0;
234
+ const soilPct = total > 0 ? soilPx / total : 0;
235
+ const grayMean = total > 0 ? graySum / total : 0;
236
+ const grayVar = total > 0 ? (graySqSum / total) - (grayMean * grayMean) : 0;
237
+ const grayStd = Math.sqrt(Math.max(0, grayVar));
238
+ const avgSat = total > 0 ? satSum / total : 0;
239
+ const avgR = total > 0 ? rSum / total : 0;
240
+ const avgG = total > 0 ? gSum / total : 0;
241
+ const brightGrayPct = total > 0 ? brightGrayPx / total : 0;
242
+ const darkGrayPct = total > 0 ? darkGrayPx / total : 0;
243
+ const gMinusR = avgG - avgR;
244
+ const avgB = total > 0 ? bSum / total : 0;
245
+ const discordUiPct = rawCellPixels > 0 ? discordUiPx / rawCellPixels : 0;
246
+
247
+ const classified = classifyCell({
248
+ harvestPct, plantPct, soilPct,
249
+ avgR, avgG, avgB,
250
+ avgBrightness: grayMean,
251
+ avgSat, grayStd,
252
+ brightGrayPct, darkGrayPct,
253
+ gMinusR,
254
+ discordUiPct,
255
+ });
186
256
 
187
257
  cells.push({
188
- row,
189
- col,
258
+ row, col,
190
259
  state: classified.state,
191
260
  confidence: classified.confidence,
192
261
  scores: classified.scores,
193
- greenPct: +greenPct.toFixed(3),
194
- brownPct: +brownPct.toFixed(3),
195
- darkPct: +darkPct.toFixed(3),
196
- avgBrightness: +avgBrightness.toFixed(1),
262
+ harvestPct: +harvestPct.toFixed(3),
263
+ plantPct: +plantPct.toFixed(3),
264
+ soilPct: +soilPct.toFixed(3),
265
+ avgBrightness: +grayMean.toFixed(1),
266
+ avgSat: +avgSat.toFixed(3),
267
+ grayStd: +grayStd.toFixed(1),
268
+ brightGrayPct: +brightGrayPct.toFixed(3),
269
+ darkGrayPct: +darkGrayPct.toFixed(3),
270
+ avgR: +avgR.toFixed(1),
271
+ avgG: +avgG.toFixed(1),
272
+ avgB: +avgB.toFixed(1),
197
273
  });
198
274
  }
199
275
  }
200
276
 
201
- const counts = { tilled: 0, planted: 0, unknown: 0 };
277
+ const counts = { harvest: 0, water: 0, tilled: 0, planted: 0, unknown: 0 };
202
278
  for (const c of cells) counts[c.state] = (counts[c.state] || 0) + 1;
203
279
 
204
280
  const avgConfidence = cells.length > 0
@@ -209,7 +285,7 @@ async function analyzeFarmGrid(imgBuffer, cols = 3, rows = 3) {
209
285
  }
210
286
 
211
287
  function gridToString(analysis) {
212
- const icon = { tilled: 'T', planted: 'P', unknown: '?' };
288
+ const icon = { harvest: 'H', water: 'W', tilled: 'T', planted: 'P', unknown: '?' };
213
289
  const rows = [];
214
290
  for (let r = 0; r < (analysis?.rows || 0); r++) {
215
291
  const line = [];
@@ -224,9 +300,10 @@ function gridToString(analysis) {
224
300
 
225
301
  function evaluateActionNeed(actionName, analysis) {
226
302
  const c = analysis?.counts || {};
227
- const tilled = c.tilled || 0;
228
- const wet = c.wet || 0;
303
+ const harvest = c.harvest || 0;
229
304
  const planted = c.planted || 0;
305
+ const water = c.water || 0;
306
+ const tilled = (c.tilled || 0) + (c.soil || 0); // soil = tilled cells
230
307
  const unknown = c.unknown || 0;
231
308
  const conf = analysis?.avgConfidence || 0;
232
309
  const slots = 9;
@@ -234,35 +311,41 @@ function evaluateActionNeed(actionName, analysis) {
234
311
  // Avoid aggressive repeats when confidence is too low.
235
312
  if (conf < 0.20) return false;
236
313
 
237
- // Simple rules:
238
- // - hoe: repeat until farm has enough tilled/planted soil
239
- // - water: skip (we don't distinguish wet vs tilled visually use phase context instead)
240
- // - plant: repeat until most cells show green crops
241
- // - harvest: repeat if any planted crops visible
314
+ // Rules (4-state model: harvest/water/tilled/planted):
315
+ // - hoe: repeat if farm has no soil (all harvest/unknown)
316
+ // - water: repeat if farm has tilled but not watered (R<58, not water)
317
+ // - plant: repeat if farm has watered tiles but no planted
318
+ // - harvest: repeat if any harvest-ready crops visible
242
319
  if (actionName === 'hoe') {
243
- return (tilled + planted) < slots && unknown < 6;
320
+ return harvest < slots && unknown < 6;
321
+ }
322
+ if (actionName === 'water') {
323
+ return tilled > 0 && water < slots;
244
324
  }
245
325
  if (actionName === 'plant') {
246
- return planted < slots && (tilled) > 0;
326
+ return planted < slots && (water + tilled) > 0;
247
327
  }
248
328
  if (actionName === 'harvest') {
249
- return planted > 0;
329
+ return harvest > 0;
250
330
  }
251
- // water and others: don't force repeats
252
331
  return false;
253
332
  }
254
333
 
255
334
  function evaluateActionScores(actionName, analysis) {
256
335
  const c = analysis?.counts || {};
257
336
  const slots = Math.max(1, (analysis?.rows || 3) * (analysis?.cols || 3));
258
- const tilled = c.tilled || 0;
337
+ const harvest = c.harvest || 0;
259
338
  const planted = c.planted || 0;
339
+ const water = c.water || 0;
340
+ const tilled = (c.tilled || 0) + (c.soil || 0); // soil = tilled cells
260
341
  const unknown = c.unknown || 0;
261
342
  const conf = analysis?.avgConfidence || 0;
262
343
 
263
344
  const ratios = {
264
- tilled: tilled / slots,
345
+ harvest: harvest / slots,
265
346
  planted: planted / slots,
347
+ water: water / slots,
348
+ tilled: tilled / slots,
266
349
  unknown: unknown / slots,
267
350
  };
268
351
 
@@ -274,24 +357,33 @@ function evaluateActionScores(actionName, analysis) {
274
357
  let reason = 'ok';
275
358
 
276
359
  if (actionName === 'hoe') {
277
- // Hoe is good if farm has mostly soil (tilled or planted)
278
- const base = (ratios.tilled * 0.9) + (ratios.planted * 0.5) - (ratios.unknown * 0.4);
360
+ // Hoe is good if farm has mostly soil, not harvest-ready crops
361
+ const base = (ratios.tilled * 0.7) + (ratios.water * 0.7) + (ratios.planted * 0.5) - (ratios.harvest * 0.9) - (ratios.unknown * 0.3);
279
362
  score = withConf(base);
280
- threshold = 0.55;
363
+ threshold = 0.40;
281
364
  if (ratios.unknown > 0.5) reason = 'too_many_unknown_cells';
282
- else if (ratios.tilled + ratios.planted < 0.4) reason = 'farm_not_ready';
365
+ else if (ratios.harvest >= 0.5) reason = 'farm_has_crops_to_harvest';
366
+ else if (ratios.harvest + ratios.planted < 0.3) reason = 'farm_needs_hoe';
367
+ } else if (actionName === 'water') {
368
+ // Water is good if there are tilled (dry) tiles to water
369
+ const base = (ratios.tilled * 0.95) - (ratios.water * 0.5) - (ratios.harvest * 0.5) - (ratios.unknown * 0.2);
370
+ score = withConf(base);
371
+ threshold = 0.40;
372
+ if (ratios.harvest >= 0.5) reason = 'farm_has_crops_to_harvest';
373
+ else if (ratios.tilled < 0.3) reason = 'no_tilled_tiles';
283
374
  } else if (actionName === 'plant') {
284
- // Plant should end with mostly planted cells
285
- const base = (ratios.planted * 0.98) - (ratios.tilled * 0.3) - (ratios.unknown * 0.2);
375
+ // Plant should end with growing crops on watered soil
376
+ const base = (ratios.planted * 0.95) - (ratios.harvest * 0.1) - (ratios.tilled * 0.2) - (ratios.unknown * 0.1);
286
377
  score = withConf(base);
287
- threshold = 0.65;
288
- if (ratios.planted < 0.5) reason = 'not_enough_planted_tiles';
378
+ threshold = 0.50;
379
+ if (ratios.harvest >= 0.5) reason = 'farm_ready_to_harvest';
380
+ else if (ratios.water + ratios.tilled < 0.3) reason = 'farm_not_watered';
289
381
  } else if (actionName === 'harvest') {
290
- // Harvest is good if planted tiles dominate
291
- const base = (ratios.planted * 0.9) - (ratios.tilled * 0.1) - (ratios.unknown * 0.2);
382
+ // Harvest is good if harvest-ready tiles dominate
383
+ const base = (ratios.harvest * 0.95) - (ratios.tilled * 0.15) - (ratios.planted * 0.1) - (ratios.unknown * 0.1);
292
384
  score = withConf(base);
293
- threshold = 0.5;
294
- if (ratios.planted < 0.3) reason = 'no_plants_to_harvest';
385
+ threshold = 0.40;
386
+ if (ratios.harvest < 0.3) reason = 'no_crops_to_harvest';
295
387
  } else {
296
388
  score = withConf(0.6 - ratios.unknown * 0.3);
297
389
  threshold = 0.6;
@@ -306,10 +398,12 @@ function evaluateActionScores(actionName, analysis) {
306
398
  threshold: +threshold.toFixed(3),
307
399
  confidence: +conf.toFixed(3),
308
400
  reason,
309
- counts: { tilled, planted, unknown, slots },
401
+ counts: { harvest, planted, water, tilled, unknown, slots },
310
402
  ratios: {
311
- tilled: +ratios.tilled.toFixed(3),
403
+ harvest: +ratios.harvest.toFixed(3),
312
404
  planted: +ratios.planted.toFixed(3),
405
+ water: +ratios.water.toFixed(3),
406
+ tilled: +ratios.tilled.toFixed(3),
313
407
  unknown: +ratios.unknown.toFixed(3),
314
408
  },
315
409
  };
@@ -494,9 +494,19 @@ async function ensureCV2(msg, force = false) {
494
494
  if (rawParsed && rawParsed.components?.length > 0) {
495
495
  msg._cv2 = rawParsed.components;
496
496
  msg._cv2text = rawParsed.cv2Text || _extractCV2Text(rawParsed.components).trim();
497
- msg._cv2buttons = rawParsed.buttons?.length > 0
497
+ const topLevel = rawParsed.buttons?.length > 0
498
498
  ? rawParsed.buttons.map(b => ({ type: 'BUTTON', label: b.label, customId: b.customId, style: b.style, url: null, disabled: b.disabled, emoji: b.emoji, _raw: b }))
499
- : _extractCV2Buttons(rawParsed.components);
499
+ : [];
500
+ const nested = _extractCV2Buttons(rawParsed.components);
501
+ // Merge, deduplicating by customId so all buttons are captured (top-level cv2Buttons
502
+ // omits some nested "All" buttons that live inside ACTION_ROW containers).
503
+ const seen = new Set();
504
+ const merged = [...topLevel, ...nested].filter(b => {
505
+ const id = b.customId || b.custom_id || '';
506
+ if (!id || seen.has(id)) return false;
507
+ seen.add(id); return true;
508
+ });
509
+ msg._cv2buttons = merged;
500
510
  msg._cv2EditedTs = msgEditedTs;
501
511
  cv2Cache.set(msg.id, { components: rawParsed.components, editedTimestamp: msgEditedTs });
502
512
  return msg;
@@ -574,7 +584,11 @@ async function clickCV2Button(msg, customId) {
574
584
  const resp = await _httpPost('https://discord.com/api/v9/interactions', {
575
585
  Authorization: token, 'Content-Type': 'application/json',
576
586
  }, payload);
577
- if (resp.status >= 400) throw new Error(`CV2 click ${resp.status}: ${resp.body.substring(0, 200)}`);
587
+ if (resp.status >= 400) {
588
+ const errMsg = `CV2 click ${resp.status}: ${resp.body.substring(0, 300)}`;
589
+ LOG.warn(`[cv2] ${errMsg}`);
590
+ throw new Error(errMsg);
591
+ }
578
592
 
579
593
  let parsed = null;
580
594
  try {
package/lib/grinder.js CHANGED
@@ -644,7 +644,11 @@ function renderDashboard() {
644
644
  if (ls === 0) lsStr = `${R}♥0${c.reset}`;
645
645
  else if (ls != null && ls <= 2) lsStr = `${Y}♥${ls}${c.reset}`;
646
646
  else if (ls != null) lsStr = `${G}♥${ls}${c.reset}`;
647
- else lsStr = `${D}♥?${c.reset}`;
647
+ else {
648
+ // Unknown — pulse to show it's still being determined
649
+ const pulse = PULSE_CHARS[Math.floor(Date.now() / 400) % PULSE_CHARS.length];
650
+ lsStr = `${D}${pulse}♥?${c.reset}`;
651
+ }
648
652
 
649
653
  // ── Level indicator (fixed width so value changes don't jitter) ──
650
654
  const lvl = wk._level || 0;
@@ -1839,9 +1843,15 @@ class AccountWorker {
1839
1843
  }
1840
1844
  }
1841
1845
 
1842
- // ── Lifesaver protection: skip crime/search if 0 lifesavers ──
1846
+ // ── Lifesaver protection: skip crime/search if 0 lifesavers or unknown ──
1843
1847
  if (cmdName === 'crime' || cmdName === 'search') {
1844
- // Fast path: check in-memory lifesaver count (set from inv + DM check)
1848
+ // Unknown (null/undefined): DMs haven't confirmed safety skip for safety.
1849
+ if (this._lifesavers == null) {
1850
+ this.log('warn', `[${cmdName}] SKIPPED — lifesavers unknown (safety hold)`);
1851
+ await this.setCooldown(cmdName, 600);
1852
+ return;
1853
+ }
1854
+ // Zero: depleted — disable for a long period.
1845
1855
  if (this._lifesavers === 0) {
1846
1856
  this.log('warn', `[${cmdName}] SKIPPED — 0 lifesavers (in-memory)`);
1847
1857
  await this.setCooldown(cmdName, 3600);
@@ -3125,19 +3135,43 @@ async function start(apiKey, apiUrl) {
3125
3135
  // Init rawLogger Redis (uses same URL — logs all raw gateway data)
3126
3136
  if (REDIS_URL) {
3127
3137
  rawLogger.init(REDIS_URL).catch(() => {});
3128
- // Listen for DM death events across all accounts
3138
+ // Listen for DM events across all accounts — update worker state + dashboard LIVE
3129
3139
  rawLogger.onDmEvent((event, raw) => {
3130
- if (event.type === 'death' && event.lifesaversLeft === 0) {
3131
- const channelId = raw.channel_id;
3132
- // Find which worker uses this DM channel and disable their crime/search
3133
- for (const w of workers) {
3134
- if (w.client?.user?.dmChannel?.id === channelId || w.channel?.id) {
3135
- w.log?.('error', `DEATH in DMs! 0 lifesavers — disabling crime/search`);
3136
- w.setCooldown?.('crime', 86400);
3137
- w.setCooldown?.('search', 86400);
3140
+ const channelId = raw.channel_id;
3141
+ for (const w of workers) {
3142
+ const isThisWorker = w.client?.user?.dmChannel?.id === channelId;
3143
+ if (!isThisWorker && w.channel?.id !== channelId) continue;
3144
+
3145
+ if (event.type === 'death') {
3146
+ // Update worker's lifesaver count so dashboard ♥ updates in real time
3147
+ if (event.lifesaversLeft >= 0) {
3148
+ const prev = w._lifesavers;
3149
+ w._lifesavers = event.lifesaversLeft;
3150
+ if (event.lifesaversLeft === 0) {
3151
+ w.log?.('error', `DEATH in DMs! 0 lifesavers — disabling crime/search`);
3152
+ w.setCooldown?.('crime', 86400);
3153
+ w.setCooldown?.('search', 86400);
3154
+ sendWebhook?.('DEATH ALERT (DM)', `**${w.username}** died in DMs! **0 lifesavers!**\nCrime/search auto-disabled.`, 0xef4444);
3155
+ } else {
3156
+ w.log?.('warn', `DEATH in DMs — ${event.lifesaversLeft} lifesavers remaining`);
3157
+ if (prev !== event.lifesaversLeft) {
3158
+ w.setCooldown?.('crime', 60);
3159
+ w.setCooldown?.('search', 60);
3160
+ }
3161
+ if (event.lifesaversLeft <= 2) {
3162
+ sendWebhook?.('LOW LIFESAVERS', `**${w.username}** has only **${event.lifesaversLeft}** lifesaver(s) left!`, 0xfbbf24);
3163
+ }
3164
+ }
3165
+ }
3166
+ scheduleRender();
3167
+ }
3168
+
3169
+ if (event.type === 'levelup') {
3170
+ if (event.to > 0) {
3171
+ w._level = event.to;
3172
+ scheduleRender();
3138
3173
  }
3139
3174
  }
3140
- sendWebhook?.('DEATH ALERT (DM)', `Account died! **0 lifesavers!**\nCrime/search auto-disabled.`, 0xef4444);
3141
3175
  }
3142
3176
  });
3143
3177
  checks.push(`${rgb(52, 211, 153)}✓${c.reset} ${c.white}RawLog${c.reset}`);
@@ -3457,14 +3491,16 @@ async function start(apiKey, apiUrl) {
3457
3491
 
3458
3492
 
3459
3493
  // Phase 2.75: Check DM history for deaths/level-ups (sequential, fast)
3460
- console.log(` ${rgb(139, 92, 246)}${BRAILLE_SPIN[0]}${c.reset} ${c.dim}Checking DM history...${c.reset}`);
3461
- let dmDeaths = 0, dmLevelUps = 0, dmNoLs = [];
3494
+ const dmCheckPulse = BRAILLE_SPIN[Math.floor(Date.now() / 80) % BRAILLE_SPIN.length];
3495
+ console.log(` ${rgb(139, 92, 246)}${dmCheckPulse}${c.reset} ${c.dim}Checking DM history...${c.reset}`);
3496
+ let dmDeaths = 0, dmLevelUps = 0, dmNoLs = [], dmUnknown = [];
3462
3497
  for (const w of activeWorkers) {
3463
3498
  try {
3464
3499
  const dm = await w.checkDmHistory();
3465
3500
  if (dm.deaths > 0) dmDeaths += dm.deaths;
3466
3501
  if (dm.levelUps > 0) dmLevelUps += dm.levelUps;
3467
3502
  if (dm.lifesavers === 0) dmNoLs.push(w.username);
3503
+ if (dm.lifesavers === -1) dmUnknown.push(w.username);
3468
3504
  // Store level and lifesaver for dashboard
3469
3505
  if (dm.currentLevel > 0) w._level = dm.currentLevel;
3470
3506
  if (dm.lifesavers >= 0) w._lifesavers = dm.lifesavers;
@@ -3474,6 +3510,10 @@ async function start(apiKey, apiUrl) {
3474
3510
  if (dm.lifesavers >= 0) {
3475
3511
  const lc = dm.lifesavers === 0 ? rgb(239, 68, 68) : dm.lifesavers <= 2 ? rgb(251, 191, 36) : rgb(52, 211, 153);
3476
3512
  parts.push(`${lc}♥${dm.lifesavers}${c.reset}`);
3513
+ } else {
3514
+ // Unknown lifesavers — pulse to show pending
3515
+ const pulse = PULSE_CHARS[Math.floor(Date.now() / 400) % PULSE_CHARS.length];
3516
+ parts.push(`${D}${pulse}♥?${c.reset}`);
3477
3517
  }
3478
3518
  if (parts.length > 0) {
3479
3519
  console.log(` ${c.dim}├${c.reset} ${c.bold}${w.username}${c.reset} ${parts.join(' ')}`);
@@ -3492,7 +3532,16 @@ async function start(apiKey, apiUrl) {
3492
3532
  }
3493
3533
  }
3494
3534
  }
3495
- console.log(` ${rgb(52, 211, 153)}✓${c.reset} ${c.bold}DM check${c.reset} ${c.dim}${dmDeaths} deaths, ${dmLevelUps} level-ups found${c.reset}`);
3535
+ if (dmUnknown.length > 0) {
3536
+ console.log(` ${rgb(251, 191, 36)}⚠${c.reset} ${c.dim}Lifesavers unknown — live DM monitor active:${c.reset} ${dmUnknown.join(', ')}`);
3537
+ // Crime/search on these accounts will be skipped via safety hold until the live
3538
+ // DM gateway listener detects a death (→ sets count) or confirms clean.
3539
+ }
3540
+ const dmSummaryParts = [];
3541
+ if (dmDeaths > 0) dmSummaryParts.push(`${dmDeaths} deaths`);
3542
+ if (dmLevelUps > 0) dmSummaryParts.push(`${dmLevelUps} level-ups`);
3543
+ if (dmUnknown.length > 0) dmSummaryParts.push(`${dmUnknown.length} pending`);
3544
+ console.log(` ${rgb(52, 211, 153)}✓${c.reset} ${c.bold}DM check${c.reset} ${dmSummaryParts.length > 0 ? c.dim + dmSummaryParts.join(', ') + c.reset : c.dim + 'clean — no deaths or level-ups' + c.reset}`);
3496
3545
  console.log('');
3497
3546
 
3498
3547
  console.log(` ${rgb(139, 92, 246)}${c.bold}>>>${c.reset} ${gradientText('Starting grind loops...', [139, 92, 246], [52, 211, 153])}`);
package/lib/rawLogger.js CHANGED
@@ -471,6 +471,19 @@ function verboseLog(event, parsed) {
471
471
  const dmListeners = [];
472
472
  function onDmEvent(fn) { dmListeners.push(fn); }
473
473
 
474
+ // ── Ephemeral message callbacks ──
475
+ const ephemeralListeners = [];
476
+ function onNextEphemeral(fn) { ephemeralListeners.push(fn); }
477
+
478
+ function _notifyEphemeral(parsed) {
479
+ if (!ephemeralListeners.length) return;
480
+ const listeners = [...ephemeralListeners];
481
+ ephemeralListeners.length = 0;
482
+ for (const fn of listeners) {
483
+ try { fn(parsed); } catch {}
484
+ }
485
+ }
486
+
474
487
  /**
475
488
  * Attach DM logger — monitors Dank Memer DMs for:
476
489
  * - Level ups: "You leveled up from level X to Y"
@@ -585,8 +598,15 @@ function attachRawLogger(client, opts = {}) {
585
598
 
586
599
  const event = packet.t === 'MESSAGE_CREATE' ? 'CREATE' : 'UPDATE';
587
600
  const parsed = store(d, event); // async but we don't await (fire-and-forget)
588
- if (parsed.then) parsed.then(p => verboseLog(event, p)).catch(() => {});
589
- else verboseLog(event, parsed);
601
+ if (parsed.then) {
602
+ parsed.then(p => {
603
+ verboseLog(event, p);
604
+ if (p?.isEphemeral) _notifyEphemeral(p);
605
+ }).catch(() => {});
606
+ } else {
607
+ verboseLog(event, parsed);
608
+ if (parsed?.isEphemeral) _notifyEphemeral(parsed);
609
+ }
590
610
  });
591
611
  }
592
612
 
@@ -673,6 +693,7 @@ module.exports = {
673
693
  attachRawLogger,
674
694
  attachDmLogger,
675
695
  onDmEvent,
696
+ onNextEphemeral,
676
697
  setVerbose,
677
698
  // Memory reads
678
699
  getRawMessage,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dankgrinder",
3
- "version": "7.6.0",
3
+ "version": "7.7.0",
4
4
  "description": "Dank Memer automation engine — grind coins while you sleep",
5
5
  "bin": {
6
6
  "dankgrinder": "bin/dankgrinder.js"