dankgrinder 7.7.0 → 7.11.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.
@@ -33,7 +33,7 @@ if (args.includes('--version') || args.includes('-v')) {
33
33
  process.exit(0);
34
34
  }
35
35
 
36
- let apiKey = process.env.DANKGRINDER_KEY || '';
36
+ let apiKey = process.env.DANKGRINDER_KEY || process.env.GRINDER_API_KEY || '';
37
37
  let apiUrl = '';
38
38
  let redisUrl = '';
39
39
 
@@ -43,7 +43,7 @@ for (let i = 0; i < args.length; i++) {
43
43
  if (args[i] === '--redis' && args[i + 1]) redisUrl = args[i + 1];
44
44
  }
45
45
 
46
- apiUrl = apiUrl || process.env.DANKGRINDER_URL || DEFAULT_URL;
46
+ apiUrl = apiUrl || process.env.DANKGRINDER_URL || process.env.GRINDER_URL || DEFAULT_URL;
47
47
  if (redisUrl) process.env.REDIS_URL = redisUrl;
48
48
 
49
49
  // Keep process alive on transient discord interaction fetch failures.
@@ -5,15 +5,9 @@ const {
5
5
  } = require('./utils');
6
6
  const { buyItem, buyItemsBatch } = require('./shop');
7
7
  const rawLogger = require('../../lib/rawLogger');
8
- const {
9
- downloadImage,
10
- extractFarmImageUrl,
11
- analyzeFarmGrid,
12
- gridToString,
13
- evaluateActionNeed,
14
- evaluateActionScores,
15
- dumpFarmVisionDebug,
16
- } = require('./farmVision');
8
+ // NOTE: Vision/ML image analysis is disabled — farm decisions are purely based on
9
+ // ephemeral responses and text detection. Re-enable by importing:
10
+ // const { downloadImage, extractFarmImageUrl, analyzeFarmGrid, gridToString } = require('./farmVision');
17
11
 
18
12
  const RE_TS = /<t:(\d+):R>/;
19
13
  const RE_MIN = /(\d+)\s*minute/i;
@@ -414,6 +408,7 @@ function logEphemeralLike(tag, payload) {
414
408
  else LOG.info(line);
415
409
  }
416
410
 
411
+ /* DISABLED — vision-based image analysis for action scoring
417
412
  async function analyzeFarmImageForAction(msg, actionName) {
418
413
  try {
419
414
  const url = extractFarmImageUrl(msg);
@@ -486,6 +481,9 @@ function getVisionRepairPlan(actionName, score) {
486
481
  return null;
487
482
  }
488
483
 
484
+ /* DISABLED — vision-based action scoring (no longer used)
485
+ // ── Vision-based next-action inference ─────────────────────────────────────────
486
+ // Rely purely on ephemeral responses instead of ML image analysis.
489
487
  function inferNextActionFromScore(actionName, score) {
490
488
  if (!score) return null;
491
489
  const planted = score.ratios?.planted || 0;
@@ -505,7 +503,9 @@ function inferNextActionFromScore(actionName, score) {
505
503
  }
506
504
  return null;
507
505
  }
506
+ */
508
507
 
508
+ /* DISABLED — vision-based preferred action inference
509
509
  async function inferPreferredActionFromImage(msg) {
510
510
  try {
511
511
  const url = extractFarmImageUrl(msg);
@@ -518,43 +518,18 @@ async function inferPreferredActionFromImage(msg) {
518
518
  planted: (analysis?.counts?.planted || 0) / slots,
519
519
  unknown: (analysis?.counts?.unknown || 0) / slots,
520
520
  };
521
-
522
521
  let suggested = null;
523
- // Image-first phase routing (3-state: tilled/planted/unknown, no separate wet state).
524
522
  if (ratios.planted >= 0.30) suggested = 'plant';
525
523
  else if (ratios.tilled >= 0.30) suggested = 'water';
526
524
  else suggested = 'hoe';
527
-
528
- let dbg = null;
529
- try {
530
- dbg = await dumpFarmVisionDebug({
531
- imgBuffer: buf,
532
- analysis,
533
- actionName: `phase-${suggested || 'unknown'}`,
534
- sourceUrl: url,
535
- });
536
- } catch (e) {
537
- LOG.warn(`[farm:phase-image] debug dump failed: ${e.message}`);
538
- }
539
-
540
- LOG.info(`[farm:phase-image] url=${url} grid=${gridToString(analysis)} counts=${JSON.stringify(analysis.counts)} conf=${analysis.avgConfidence} suggested=${suggested} ratios=${JSON.stringify({
541
- tilled: +ratios.tilled.toFixed(3),
542
- planted: +ratios.planted.toFixed(3),
543
- unknown: +ratios.unknown.toFixed(3),
544
- })}`);
545
- if (dbg) {
546
- const tileSummary = (analysis.cells || [])
547
- .map(c => `r${c.row + 1}c${c.col + 1}:${c.state}:${c.confidence}`)
548
- .join(' | ');
549
- LOG.info(`[farm:phase-image] debug dir=${dbg.dir} source=${dbg.sourcePath} manifest=${dbg.manifestPath} tiles=${dbg.tileCount}`);
550
- LOG.info(`[farm:phase-image] tile-summary ${tileSummary}`);
551
- }
525
+ LOG.info(`[farm:phase-image] url=${url} grid=${gridToString(analysis)} suggested=${suggested}`);
552
526
  return suggested;
553
527
  } catch (e) {
554
528
  LOG.warn(`[farm:phase-image] inference failed: ${e.message}`);
555
529
  return null;
556
530
  }
557
531
  }
532
+ */
558
533
 
559
534
  function chooseSeedOption(menu) {
560
535
  const options = (menu?.options || []).filter(o => o && o.value);
@@ -642,11 +617,11 @@ async function ensurePlantSeedSelected({ response, waitForDankMemer, channel })
642
617
  }
643
618
  }
644
619
 
620
+ /* DISABLED — vision-based phase scoring
645
621
  async function capturePhaseVisionScore({ msg, actionName, phaseTag }) {
646
622
  if (!msg || !['hoe', 'water', 'plant', 'harvest'].includes(actionName)) {
647
623
  return null;
648
624
  }
649
-
650
625
  const check = await analyzeFarmImageForAction(msg, actionName);
651
626
  const s = check?.score;
652
627
  if (s) {
@@ -656,6 +631,7 @@ async function capturePhaseVisionScore({ msg, actionName, phaseTag }) {
656
631
  }
657
632
  return check;
658
633
  }
634
+ */
659
635
 
660
636
  function mergeBuyPlan(plan) {
661
637
  const m = new Map();
@@ -1115,10 +1091,22 @@ async function waitForEditedMessage(channel, messageId, baselineText, timeoutMs
1115
1091
  // Determine the next action to take from the manage menu, given what the
1116
1092
  // farm image vision says about the current state.
1117
1093
  // Returns { action, button, reason } where action is null when the cycle is done.
1118
- async function findNextFarmActionFromManage(msg, text, currentAction, imageAnalysis, forcedNextAction) {
1094
+ async function findNextFarmActionFromManage(msg, text, currentAction, forcedNextAction) {
1119
1095
  const btns = getAllButtons(msg).filter(b => !b.disabled && !isNavOrUtilityButton(b));
1120
1096
  const allBtns = getAllButtons(msg); // include disabled for state detection
1121
- if (btns.length === 0) return { action: null, button: null, reason: 'no-buttons' };
1097
+ // If no non-utility buttons remain, check if any action tabs exist.
1098
+ // This handles confirmation screens where Manage/Rename/Change Skin are all
1099
+ // filtered as nav buttons — the manage menu's action tabs (Hoe/Water/Plant/Harvest)
1100
+ // may still be accessible directly or via the manage button.
1101
+ if (btns.length === 0) {
1102
+ const actionTabs = allBtns.filter(b => !b.disabled && hasAny(b, ['hoe', 'water', 'plant', 'harvest', 'fertiliz']));
1103
+ if (actionTabs.length > 0) {
1104
+ // Action tabs exist but are buried behind a confirmation/nav screen.
1105
+ // The caller will need to navigate back to the manage menu.
1106
+ return { action: null, button: null, reason: 'confirmation-screen', actionTabs };
1107
+ }
1108
+ return { action: null, button: null, reason: 'no-buttons' };
1109
+ }
1122
1110
 
1123
1111
  const managedActions = getManageActionButtons(msg);
1124
1112
  const lower = String(stripAnsi(text || '')).toLowerCase();
@@ -1149,6 +1137,7 @@ async function findNextFarmActionFromManage(msg, text, currentAction, imageAnaly
1149
1137
  if (btn) return { action: 'harvest', button: btn, reason: 'harvest-ready-text' };
1150
1138
  }
1151
1139
 
1140
+ /* DISABLED — vision-based image analysis routing
1152
1141
  // Use image vision ratios if available.
1153
1142
  if (imageAnalysis) {
1154
1143
  const slots = Math.max(1, (imageAnalysis.rows || 3) * (imageAnalysis.cols || 3));
@@ -1157,7 +1146,6 @@ async function findNextFarmActionFromManage(msg, text, currentAction, imageAnaly
1157
1146
  planted: (imageAnalysis.counts?.planted || 0) / slots,
1158
1147
  unknown: (imageAnalysis.counts?.unknown || 0) / slots,
1159
1148
  };
1160
-
1161
1149
  // Phase-aware selection: pick the earliest incomplete phase (3-state model).
1162
1150
  // Harvest when planted >= 30%.
1163
1151
  if (ratios.planted >= 0.30) {
@@ -1180,6 +1168,7 @@ async function findNextFarmActionFromManage(msg, text, currentAction, imageAnaly
1180
1168
  if (btn) return { action: 'hoe', button: btn, reason: `hoe-vision(t=${ratios.tilled.toFixed(2)},p=${ratios.planted.toFixed(2)})` };
1181
1169
  }
1182
1170
  }
1171
+ */
1183
1172
 
1184
1173
  // Disabled-all detection: if the "All" for a phase is already disabled,
1185
1174
  // that phase is done — move to the next one.
@@ -1238,6 +1227,12 @@ async function advancePastConfirmation(response, waitForDankMemer) {
1238
1227
  await humanDelay(80, 220);
1239
1228
 
1240
1229
  const baseline = brief(getFullText(response), 400);
1230
+ // NOTE: We do NOT register an ephemeral listener here.
1231
+ // The outer listener (registered by the caller) captures ALL ephemerals
1232
+ // including harvest results. Registering a second listener would clear
1233
+ // the listeners array after the first fires, preventing the outer
1234
+ // listener from seeing the ephemeral.
1235
+
1241
1236
  let clicked;
1242
1237
  try {
1243
1238
  clicked = await safeClickButton(response, confirmBtn);
@@ -1353,12 +1348,22 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1353
1348
  if (textHasHarvestReady) LOG.info(`[farm] DETECTED harvest-ready in text (growReadySec=${growReadySec})`);
1354
1349
  if (growReadySec > 0) LOG.info(`[farm] GROW-QUEUE DEBUG: lower=${lower.slice(0, 400)}`);
1355
1350
  LOG.info(`[farm] grow-ready parse=${growReadySec == null ? 'none' : `${growReadySec}s`} redis_recovery=${redisRecoveryMs != null ? `${redisRecoveryMs}ms` : 'n/a'}`);
1356
- if (!forceRun && !redisRecovered && growReadySec && growReadySec > 20) {
1357
- await inferPreferredActionFromImage(response);
1358
- // Re-check every 30s instead of waiting the full grow duration.
1359
- // This ensures the next harvest cycle starts as soon as crops are ready.
1360
- const waitSec = Math.min(30, Math.min(6 * 3600, growReadySec + 2));
1361
- LOG.info(`[farm] crops growing (~${Math.ceil(growReadySec / 60)}m remaining); re-checking in ${waitSec}s`);
1351
+ if (!redisRecovered && growReadySec && growReadySec > 20) {
1352
+ // Grow timer is active — schedule re-check based on how close crops are.
1353
+ // Graduated wait: short when nearly ready, longer when far out.
1354
+ // This avoids spamming Discord with farm commands when crops have hours left.
1355
+ let waitSec;
1356
+ if (growReadySec <= 300) {
1357
+ // < 5 min: check every minute (crops nearly ready)
1358
+ waitSec = 60;
1359
+ } else if (growReadySec <= 3600) {
1360
+ // 5-60 min: check every 5 minutes
1361
+ waitSec = 300;
1362
+ } else {
1363
+ // > 1 hour: check every 15 minutes (long grow, no rush)
1364
+ waitSec = 900;
1365
+ }
1366
+ LOG.info(`[farm] crops growing (~${Math.ceil(growReadySec / 60)}m remaining); re-checking in ${waitSec / 60}m`);
1362
1367
  return { result: `farm grow queue (${Math.ceil(growReadySec / 60)}m)`, coins: 0, nextCooldownSec: waitSec, skipReason: 'farm_grow_queue' };
1363
1368
  }
1364
1369
 
@@ -1395,19 +1400,6 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1395
1400
  }
1396
1401
  }
1397
1402
 
1398
- // ── Initial image analysis to bootstrap cycle entry ────────────────────────
1399
- let initialAnalysis = null;
1400
- try {
1401
- const url = extractFarmImageUrl(response);
1402
- if (url) {
1403
- const buf = await downloadImage(url);
1404
- const grid = await analyzeFarmGrid(buf);
1405
- initialAnalysis = grid;
1406
- LOG.info(`[farm] initial image grid=${gridToString(grid)} counts=${JSON.stringify(grid.counts)} conf=${grid.avgConfidence}`);
1407
- }
1408
- } catch (e) {
1409
- LOG.info(`[farm] initial image analysis failed: ${e.message}`);
1410
- }
1411
1403
 
1412
1404
  // ── Cycle loop: hoe → water → plant → harvest ─────────────────────────────
1413
1405
  let cycleDepth = 0;
@@ -1416,11 +1408,14 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1416
1408
  let actionsTaken = 0; // Track how many farm actions were actually executed
1417
1409
  let forcedNextAction = null; // When advancing a phase, force the next action check
1418
1410
  let lastApplyResp = null; // Track the last apply response for coin/cooldown parsing
1411
+ let lastRejectedAction = null; // Track last rejected action to break empty-farm loops
1419
1412
 
1420
1413
  while (cycleDepth < 5) {
1414
+ // Reset per-cycle state
1415
+ let justRejected = false;
1421
1416
  let actionResult;
1422
1417
  try {
1423
- actionResult = await findNextFarmActionFromManage(cycleResponse, text, lastAction, initialAnalysis, forcedNextAction);
1418
+ actionResult = await findNextFarmActionFromManage(cycleResponse, text, lastAction, forcedNextAction);
1424
1419
  } catch (e) {
1425
1420
  LOG.warn(`[farm:cycle] findNextFarmActionFromManage error: ${e.message}`);
1426
1421
  break;
@@ -1429,6 +1424,23 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1429
1424
  forcedNextAction = null;
1430
1425
 
1431
1426
  if (!action || !button) {
1427
+ if (reason === 'confirmation-screen') {
1428
+ // We're on a confirmation/nav screen with no actionable buttons visible.
1429
+ // Re-enter the manage menu to get action tabs back.
1430
+ LOG.info(`[farm:cycle:${cycleDepth}] confirmation screen — re-entering manage menu`);
1431
+ const mb = findFarmButton(cycleResponse, ['manage', 'farm-farm:manage']);
1432
+ if (mb) {
1433
+ const mr = await clickAndCapture({ channel, waitForDankMemer, response: cycleResponse, button: mb, tag: `farm-cycle-confirm-manage` });
1434
+ if (mr) {
1435
+ if (isCV2(mr)) await ensureCV2(mr);
1436
+ cycleResponse = mr;
1437
+ text = getFullText(cycleResponse);
1438
+ clean = brief(text, 600);
1439
+ }
1440
+ }
1441
+ await sleep(300);
1442
+ continue;
1443
+ }
1432
1444
  LOG.info(`[farm:cycle:${cycleDepth}] no action found (reason=${reason}) — breaking cycle`);
1433
1445
  break;
1434
1446
  }
@@ -1490,39 +1502,48 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1490
1502
  label.includes('plant all') || label.includes('harvest all') || label.includes('confirm')) && b.disabled;
1491
1503
  });
1492
1504
  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.
1505
+ // Farm state check: "Hoe All" disabled could mean (a) no Hoe item OR (b) no tiles to work.
1506
+ // Check the farm text if the farm is empty, there's nothing to hoe/water/plant,
1507
+ // so buying is pointless. Skip buy and treat as a no-op phase.
1508
+ const farmEmpty = /pretty empty|seems empty|empty\.{0,3}/i.test(clean);
1509
+ const currentIdx = FARM_PHASE_ORDER.indexOf(action);
1510
+ const nextPhase = FARM_PHASE_ORDER[currentIdx + 1] || null;
1511
+ if (farmEmpty) {
1512
+ LOG.info(`[farm:cycle:${cycleDepth}] ${action} all-btn disabled but farm is empty — skipping ${action}, advancing to ${nextPhase || 'done'}`);
1513
+ lastAction = action;
1514
+ forcedNextAction = nextPhase;
1515
+ cycleDepth++;
1516
+ if (!nextPhase) break;
1517
+ await sleep(300);
1518
+ continue;
1519
+ }
1520
+ // Farm has tiles but all-btn disabled — try to buy the item.
1495
1521
  const ITEM_FOR_ACTION = { hoe: 'Hoe', water: 'Watering Can', plant: 'Seeds' };
1496
1522
  const missingItem = ITEM_FOR_ACTION[action];
1497
1523
  if (missingItem) {
1498
- LOG.info(`[farm:cycle:${cycleDepth}] ${action} all-btn disabled — trying to buy ${missingItem}`);
1524
+ LOG.info(`[farm:cycle:${cycleDepth}] ${action} all-btn disabled (farm not empty) — trying to buy ${missingItem}`);
1499
1525
  const bought = await tryBuyFarmItem({ missing: missingItem, channel, waitForDankMemer, client });
1500
1526
  if (bought.ok) {
1501
1527
  LOG.success(`[farm:cycle:${cycleDepth}] Bought ${bought.itemName} — retrying ${action}`);
1502
1528
  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;
1529
+ // Refresh the manage menu to pick up the new button state
1530
+ const mb = findFarmButton(cycleResponse, ['manage', 'farm-farm:manage']);
1531
+ if (mb) {
1532
+ const mr = await clickAndCapture({ channel, waitForDankMemer, response: cycleResponse, button: mb, tag: 'farm-buy-retry-manage' });
1533
+ if (mr) { if (isCV2(mr)) await ensureCV2(mr); cycleResponse = mr; text = getFullText(cycleResponse); clean = brief(text, 600); }
1534
+ }
1512
1535
  continue;
1513
1536
  } else {
1514
1537
  LOG.warn(`[farm:cycle:${cycleDepth}] Could not buy ${missingItem} — skipping to next phase`);
1515
1538
  }
1516
1539
  }
1517
- const currentIdx = FARM_PHASE_ORDER.indexOf(action);
1518
- const nextPhase = FARM_PHASE_ORDER[currentIdx + 1] || null;
1540
+
1519
1541
  LOG.info(`[farm:cycle:${cycleDepth}] ${action} all-btn disabled (no-op) — next=${nextPhase || 'done'}`);
1520
1542
  actionsTaken++;
1521
1543
  lastAction = action;
1522
1544
  forcedNextAction = nextPhase;
1523
1545
  cycleDepth++;
1524
1546
  if (!nextPhase) break;
1525
- initialAnalysis = null;
1526
1547
  await sleep(300);
1527
1548
  continue;
1528
1549
  }
@@ -1539,20 +1560,116 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1539
1560
  if (!lastApplyResp) { LOG.warn('[farm:cycle] All click returned null'); break; }
1540
1561
 
1541
1562
  // Step 5: advance past any confirmation screens back to the manage menu
1563
+ // Register the ephemeral listener BEFORE advancePastConfirmation so we capture
1564
+ // the confirmation ephemeral (e.g., harvest "You harvested:") which arrives
1565
+ // asynchronously after the HTTP response.
1566
+ let confirmEphemeral = null;
1567
+ rawLogger.onNextEphemeral((parsed) => {
1568
+ confirmEphemeral = parsed;
1569
+ });
1542
1570
  cycleResponse = await advancePastConfirmation(lastApplyResp, waitForDankMemer);
1543
1571
  if (!cycleResponse) { LOG.warn('[farm:cycle] confirmation advance returned null'); break; }
1544
1572
  if (isCV2(cycleResponse)) await ensureCV2(cycleResponse);
1573
+ // Ephemeral fires asynchronously after HTTP response — wait briefly for it to arrive
1574
+ // before the harvest block checks cycleResponse._capturedEphemeral.
1575
+ await sleep(200);
1576
+ if (confirmEphemeral) cycleResponse._capturedEphemeral = confirmEphemeral;
1577
+ else if (cycleResponse?._capturedEphemeral) { /* already set by advancePastConfirmation */ }
1545
1578
  actionsTaken++;
1546
1579
  lastAction = action;
1547
1580
  text = getFullText(cycleResponse);
1548
1581
  clean = brief(text, 600);
1549
1582
  logFarmState('after-confirm', cycleResponse);
1550
1583
 
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).
1584
+ // ── Rejection ephemeral: detect "wrong phase" errors and cascade ─────────────
1585
+ // advancePastConfirmation registers no ephemeral listener (confirmation ephemerals
1586
+ // are captured by the outer listener below). The action's ephemeral (from clicking
1587
+ // the action tab) remains on lastApplyResp._capturedEphemeral.
1555
1588
  {
1589
+ const ephem = lastApplyResp?._capturedEphemeral;
1590
+ const ephemText = (ephem?.cv2Text || ephem?.allText || '').toLowerCase();
1591
+
1592
+ // Helper: re-enter the manage menu from the farm view so the next
1593
+ // findNextFarmActionFromManage iteration has buttons to work with.
1594
+ async function reenterManage() {
1595
+ const allBtns = getAllButtons(cycleResponse);
1596
+ const mb = findFarmButton(cycleResponse, ['manage', 'farm-farm:manage']);
1597
+ LOG.info(`[farm:cycle:${cycleDepth}:reenter] findFarmButton result: ${mb ? mb.label + ' / ' + mb.customId : 'NULL'}`);
1598
+ if (!mb) {
1599
+ LOG.warn(`[farm:cycle:${cycleDepth}:reenter] no Manage button found! available btns: ${allBtns.map(b => b.label).join(', ')}`);
1600
+ return false;
1601
+ }
1602
+ const mr = await clickAndCapture({ channel, waitForDankMemer, response: cycleResponse, button: mb, tag: `farm-cycle-reject-manage` });
1603
+ LOG.info(`[farm:cycle:${cycleDepth}:reenter] clickAndCapture result: ${mr ? 'got response' : 'NULL'}`);
1604
+ if (!mr) return false;
1605
+ if (isCV2(mr)) await ensureCV2(mr);
1606
+ cycleResponse = mr;
1607
+ const postBtns = getAllButtons(cycleResponse);
1608
+ LOG.info(`[farm:cycle:${cycleDepth}:reenter] post-manage btns: ${postBtns.length}, ${postBtns.map(b => b.label).join(', ')}`);
1609
+ return true;
1610
+ }
1611
+
1612
+ // "You can only use a Hoe on empty tiles or after a harvest" → skip to water
1613
+ if (action === 'hoe' && /can only use.*hoe.*empty|only use.*hoe.*empty/.test(ephemText)) {
1614
+ LOG.warn(`[farm:cycle:${cycleDepth}:reject] Hoe rejected: tiles not empty — cascading to water`);
1615
+ const currentIdx = FARM_PHASE_ORDER.indexOf(action);
1616
+ const nextPhase = FARM_PHASE_ORDER[currentIdx + 1] || null;
1617
+ forcedNextAction = nextPhase;
1618
+ lastAction = action;
1619
+ justRejected = true;
1620
+ cycleDepth++;
1621
+ if (!nextPhase) break;
1622
+ await reenterManage();
1623
+ await sleep(300);
1624
+ continue;
1625
+ }
1626
+
1627
+ // "You can only use a Watering Can on tilled tiles" (not hoed) → cascade forward to plant
1628
+ // Do NOT cascade back to hoe — that creates an infinite hoe↔water oscillation.
1629
+ if (action === 'water' && (ephemText.includes('can only use') || ephemText.includes('can only water'))) {
1630
+ LOG.warn(`[farm:cycle:${cycleDepth}:reject] Water rejected (${ephemText.slice(0, 100)}) — cascading to plant`);
1631
+ const currentIdx = FARM_PHASE_ORDER.indexOf(action);
1632
+ const nextPhase = FARM_PHASE_ORDER[currentIdx + 1] || null;
1633
+ forcedNextAction = nextPhase;
1634
+ lastAction = action;
1635
+ justRejected = true;
1636
+ // Break oscillation guard: if we bounced back here from plant, stop.
1637
+ if (lastRejectedAction === 'plant' && action === 'water') {
1638
+ LOG.warn(`[farm:cycle:${cycleDepth}:reject] hoe→water→plant oscillation detected — breaking`);
1639
+ break;
1640
+ }
1641
+ cycleDepth++;
1642
+ if (!nextPhase) break;
1643
+ await reenterManage();
1644
+ await sleep(300);
1645
+ continue;
1646
+ }
1647
+
1648
+ // "You can only plant seeds on an empty tile that is tilled and watered" → cascade to harvest
1649
+ // Do NOT cascade back to hoe — that creates an infinite hoe↔water oscillation.
1650
+ if (action === 'plant' && /can only plant|can only use.*seed|plant.*not.*water/.test(ephemText)) {
1651
+ LOG.warn(`[farm:cycle:${cycleDepth}:reject] Plant rejected (${ephemText.slice(0, 100)}) — cascading to harvest`);
1652
+ const currentIdx = FARM_PHASE_ORDER.indexOf(action);
1653
+ const nextPhase = FARM_PHASE_ORDER[currentIdx + 1] || null;
1654
+ forcedNextAction = nextPhase;
1655
+ lastAction = action;
1656
+ justRejected = true;
1657
+ cycleDepth++;
1658
+ if (!nextPhase) {
1659
+ // Plant is last phase — advance to harvest in next cycle.
1660
+ await sleep(300);
1661
+ continue;
1662
+ }
1663
+ await reenterManage();
1664
+ await sleep(300);
1665
+ continue;
1666
+ }
1667
+ }
1668
+
1669
+ // ── Farm text detection: determine next action from farm state ─────────────────
1670
+ // Skip entirely if a rejection just fired — the rejection cascade handles the
1671
+ // next action; farm-text should not override forcedNextAction.
1672
+ if (!justRejected) {
1556
1673
  const postBtns = getAllButtons(cycleResponse);
1557
1674
  const postActionBtns = postBtns.filter(b => !b.disabled && !isNavOrUtilityButton(b));
1558
1675
  const postHasActionTabs = postActionBtns.some(b => hasAny(b, ['hoe', 'water', 'plant', 'harvest', 'fertiliz']));
@@ -1562,6 +1679,14 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1562
1679
  const isEmpty = /pretty empty|seems empty|empty\.{0,3}/i.test(postLower) && !hasSeedsReady && !hasHarvestReady;
1563
1680
  if (!postHasActionTabs && !isEmpty) {
1564
1681
  // Farm has crops (planted/growing). Force harvest.
1682
+ // BUT: if the current action was 'harvest' and we're on a planted confirmation
1683
+ // screen (no action tabs, not empty), it means nothing was harvested.
1684
+ // Breaking here lets the grow queue handle the planted crops.
1685
+ const justHarvested = (action === 'harvest');
1686
+ if (justHarvested) {
1687
+ LOG.info(`[farm:cycle:${cycleDepth}] farm-text: nothing harvested (planted confirmation) — breaking, grow queue will handle`);
1688
+ break;
1689
+ }
1565
1690
  // cycleResponse is still the farm view (no action tabs) after advancePastConfirmation.
1566
1691
  // Re-enter the manage menu so findNextFarmActionFromManage has buttons to work with.
1567
1692
  const mb = findFarmButton(cycleResponse, ['manage', 'farm-farm:manage']);
@@ -1578,12 +1703,13 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1578
1703
  forcedNextAction = 'harvest';
1579
1704
  lastAction = null;
1580
1705
  cycleDepth++;
1581
- initialAnalysis = null;
1582
1706
  await sleep(300);
1583
1707
  continue;
1584
1708
  }
1585
1709
  if (!postHasActionTabs && isEmpty) {
1586
- // Farm is empty after the action. Re-enter manage menu before forcing hoe.
1710
+ // Farm is empty after the action. Re-enter manage menu and check if there's
1711
+ // actually anything to work. If all "All" buttons are still disabled, the
1712
+ // farm is truly empty — nothing to hoe/water/plant, break the cycle.
1587
1713
  const mb = findFarmButton(cycleResponse, ['manage', 'farm-farm:manage']);
1588
1714
  if (mb) {
1589
1715
  LOG.info(`[farm:cycle:${cycleDepth}] farm-text: re-entering manage menu (empty farm)`);
@@ -1595,10 +1721,19 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1595
1721
  clean = brief(text, 600);
1596
1722
  }
1597
1723
  }
1724
+ // Double-check: after re-entering manage, if hoe/water/plant all-btn are all still
1725
+ // disabled, the farm is truly empty — no tiles to work. Break the cycle.
1726
+ const allBtnsNow = getAllButtons(cycleResponse);
1727
+ const hoeAllEnabled = allBtnsNow.some(b => !b.disabled && buttonHay(b).includes('hoe all'));
1728
+ const waterAllEnabled = allBtnsNow.some(b => !b.disabled && buttonHay(b).includes('water all'));
1729
+ const plantAllEnabled = allBtnsNow.some(b => !b.disabled && buttonHay(b).includes('plant all'));
1730
+ if (!hoeAllEnabled && !waterAllEnabled && !plantAllEnabled) {
1731
+ LOG.info(`[farm:cycle:${cycleDepth}] all action all-btns still disabled after manage re-entry — farm is truly empty, breaking`);
1732
+ break;
1733
+ }
1598
1734
  forcedNextAction = 'hoe';
1599
1735
  lastAction = null;
1600
1736
  cycleDepth++;
1601
- initialAnalysis = null;
1602
1737
  await sleep(300);
1603
1738
  continue;
1604
1739
  }
@@ -1607,22 +1742,18 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1607
1742
 
1608
1743
  // ── Harvest ephemeral: parse "You harvested: Nothing" to detect no-op ──────────
1609
1744
  if (action === 'harvest') {
1610
- const ephemeral = lastApplyResp?._capturedEphemeral;
1745
+ const ephemeral = cycleResponse?._capturedEphemeral || lastApplyResp?._capturedEphemeral;
1611
1746
  if (ephemeral) {
1612
1747
  const cv2t = ephemeral.cv2Text || '';
1613
1748
  const allt = ephemeral.allText || '';
1614
1749
  const harvestText = cv2t || allt;
1615
- LOG.info(`[farm:cycle:${cycleDepth}:harvest-ephemeral] "${harvestText.slice(0, 300)}"`);
1750
+ LOG.info(`[farm:cycle:${cycleDepth}:harvest-ephemeral] cv2Text="${cv2t.slice(0,200)}" allText="${allt.slice(0,200)}"`);
1616
1751
  // 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;
1752
+ // Clear forcedNextAction and break farm is done.
1753
+ if (/nothing|0\s*x\s*\w|no\s+crops|\bnone\b|^you harvested\s*$/im.test(harvestText) || (/\byou harvested\b/i.test(harvestText) && /\b(nothing|none)\b/i.test(harvestText))) {
1754
+ LOG.info(`[farm:cycle:${cycleDepth}:harvest-ephemeral] Nothing harvested — farm cycle complete`);
1755
+ forcedNextAction = null;
1756
+ break;
1626
1757
  }
1627
1758
  // Something was actually harvested — parse what and log it.
1628
1759
  const itemMatches = [...harvestText.matchAll(/-?\s*(\d+)\s*x?\s*([A-Za-z]+)/g)];
@@ -1660,6 +1791,9 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1660
1791
  logFarmState('after-plant-back', cycleResponse);
1661
1792
  }
1662
1793
  // Plant succeeded — advance to harvest.
1794
+ // cycleResponse is now the farm view (after clicking Back).
1795
+ // Re-enter manage so findNextFarmActionFromManage has buttons on the next loop.
1796
+ await reenterManage();
1663
1797
  const currentIdx = FARM_PHASE_ORDER.indexOf(action);
1664
1798
  const nextPhase = FARM_PHASE_ORDER[currentIdx + 1] || null;
1665
1799
  LOG.info(`[farm:cycle:${cycleDepth}] plant done, advancing to ${nextPhase || 'done'}`);
@@ -1667,7 +1801,6 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1667
1801
  if (!nextPhase) break;
1668
1802
  cycleDepth++;
1669
1803
  lastAction = nextPhase;
1670
- initialAnalysis = null;
1671
1804
  await sleep(400);
1672
1805
  continue;
1673
1806
  }
@@ -1679,7 +1812,6 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1679
1812
  if (!nextPhase) break;
1680
1813
  cycleDepth++;
1681
1814
  lastAction = nextPhase;
1682
- initialAnalysis = null;
1683
1815
  await sleep(300);
1684
1816
  continue;
1685
1817
  }
@@ -1687,22 +1819,11 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1687
1819
  clean = brief(text, 600);
1688
1820
  logFarmState('after-confirm', cycleResponse);
1689
1821
 
1690
- // Step 6: run image analysis to decide next action
1691
- let imgAnalysis = null;
1692
- try {
1693
- const url = extractFarmImageUrl(cycleResponse);
1694
- if (url) {
1695
- const buf = await downloadImage(url);
1696
- imgAnalysis = await analyzeFarmGrid(buf);
1697
- LOG.info(`[farm:cycle:${cycleDepth}:img] grid=${gridToString(imgAnalysis)} counts=${JSON.stringify(imgAnalysis.counts)} conf=${imgAnalysis.avgConfidence}`);
1698
- }
1699
- } catch (e) {
1700
- LOG.info(`[farm:cycle:${cycleDepth}:img] failed: ${e.message}`);
1701
- }
1702
-
1703
- const scoreResult = await capturePhaseVisionScore({ msg: cycleResponse, actionName: action, phaseTag: `farm-cycle-${action}` });
1704
- const score = scoreResult?.score || null;
1705
- const nextAction = inferNextActionFromScore(action, score);
1822
+ // Step 6: NO image analysis rely purely on ephemeral + text for next action decision
1823
+ // let imgAnalysis = null; // DISABLED vision
1824
+ // const scoreResult = await capturePhaseVisionScore({ msg: cycleResponse, actionName: action, phaseTag: `farm-cycle-${action}` });
1825
+ // const nextAction = inferNextActionFromScore(action, scoreResult?.score || null); // DISABLED
1826
+ const nextAction = null; // DISABLED — purely ephemeral-based flow
1706
1827
 
1707
1828
  // Collect all error/rejection text sources:
1708
1829
  // 1. Extra follow-up messages from farm actions
@@ -1754,7 +1875,7 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1754
1875
  forcedNextAction = 'harvest';
1755
1876
  cycleDepth++;
1756
1877
  lastAction = 'water';
1757
- initialAnalysis = null;
1878
+
1758
1879
  await sleep(300);
1759
1880
  continue;
1760
1881
  }
@@ -1766,7 +1887,7 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1766
1887
  LOG.info(`[farm:cycle:${cycleDepth}] next action = ${nextAction} (after ${action})`);
1767
1888
  lastAction = action;
1768
1889
  cycleDepth++;
1769
- initialAnalysis = imgAnalysis; // pass updated analysis to next iteration
1890
+
1770
1891
  await sleep(300);
1771
1892
  }
1772
1893
 
@@ -1806,7 +1927,7 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1806
1927
  }
1807
1928
  }
1808
1929
 
1809
- await capturePhaseVisionScore({ msg: cycleResponse, actionName: lastAction || 'harvest', phaseTag: 'farm-final' });
1930
+
1810
1931
 
1811
1932
  if (coins > 0) {
1812
1933
  LOG.coin(`[farm] ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`);
package/lib/grinder.js CHANGED
@@ -3214,10 +3214,12 @@ async function start(apiKey, apiUrl) {
3214
3214
  loginLines.push(` ${'─'.repeat(loginVis)}`);
3215
3215
  for (const l of loginLines) console.log(l);
3216
3216
 
3217
- // Dynamically capture the starting row of the login table via DSR
3217
+ // Dynamically capture the starting row of the login table via DSR.
3218
+ // Write MARKER to stderr (not stdout) to avoid PTY cooked-mode echoing
3219
+ // of the visible "@MARKER@@" text portion, which was causing the DSR
3220
+ // response to be swallowed or delayed.
3218
3221
  let loginBaseRow = 1;
3219
3222
  const captureLoginRow = () => new Promise(resolve => {
3220
- process.stdout.write(MARKER);
3221
3223
  const chunks = [];
3222
3224
  const handler = (chunk) => {
3223
3225
  chunks.push(chunk);
@@ -3230,6 +3232,8 @@ async function start(apiKey, apiUrl) {
3230
3232
  }
3231
3233
  };
3232
3234
  process.stdin.on('data', handler);
3235
+ // Write to stderr so PTY doesn't echo the visible MARKER text to stdout
3236
+ process.stderr.write(MARKER);
3233
3237
  setTimeout(resolve, 50);
3234
3238
  });
3235
3239
  await captureLoginRow();
@@ -3331,7 +3335,7 @@ async function start(apiKey, apiUrl) {
3331
3335
  const invVis = 7 + iColNum + iColName + iColItems + iColVal + 12;
3332
3336
 
3333
3337
  // Print a unique marker, query its position, then overwrite it with the table
3334
- process.stdout.write(MARKER);
3338
+ // Set up stdin handler BEFORE writing MARKER (same fix as Phase 1 — avoids race)
3335
3339
  let invBaseRow = 1;
3336
3340
  const captureRow = () => new Promise(resolve => {
3337
3341
  const chunks = [];
@@ -3346,6 +3350,8 @@ async function start(apiKey, apiUrl) {
3346
3350
  }
3347
3351
  };
3348
3352
  process.stdin.on('data', handler);
3353
+ // Write to stderr so PTY doesn't echo the visible MARKER text to stdout
3354
+ process.stderr.write(MARKER);
3349
3355
  setTimeout(resolve, 50);
3350
3356
  });
3351
3357
  await captureRow();
@@ -3414,7 +3420,7 @@ async function start(apiKey, apiUrl) {
3414
3420
  const balVis = 7 + bColNum + bColName + bColWallet + bColBank + bColTotal + bColLs + 14;
3415
3421
 
3416
3422
  // Capture starting row for balance phase
3417
- process.stdout.write(MARKER);
3423
+ // Set up stdin handler BEFORE writing MARKER (same fix — avoids race + PTY echo)
3418
3424
  let balBaseRow = 1;
3419
3425
  const balCaptureRow = () => new Promise(resolve => {
3420
3426
  const chunks = [];
@@ -3429,6 +3435,8 @@ async function start(apiKey, apiUrl) {
3429
3435
  }
3430
3436
  };
3431
3437
  process.stdin.on('data', handler);
3438
+ // Write to stderr so PTY doesn't echo the visible MARKER text to stdout
3439
+ process.stderr.write(MARKER);
3432
3440
  setTimeout(resolve, 50);
3433
3441
  });
3434
3442
  await balCaptureRow();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dankgrinder",
3
- "version": "7.7.0",
3
+ "version": "7.11.0",
4
4
  "description": "Dank Memer automation engine — grind coins while you sleep",
5
5
  "bin": {
6
6
  "dankgrinder": "bin/dankgrinder.js"