dankgrinder 7.7.0 → 7.12.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,15 @@ 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
1412
+ let lastRejectedAction2 = null; // Track previous rejected action to detect 3-in-a-row loops
1419
1413
 
1420
1414
  while (cycleDepth < 5) {
1415
+ // Reset per-cycle state
1416
+ let justRejected = false;
1421
1417
  let actionResult;
1422
1418
  try {
1423
- actionResult = await findNextFarmActionFromManage(cycleResponse, text, lastAction, initialAnalysis, forcedNextAction);
1419
+ actionResult = await findNextFarmActionFromManage(cycleResponse, text, lastAction, forcedNextAction);
1424
1420
  } catch (e) {
1425
1421
  LOG.warn(`[farm:cycle] findNextFarmActionFromManage error: ${e.message}`);
1426
1422
  break;
@@ -1429,6 +1425,23 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1429
1425
  forcedNextAction = null;
1430
1426
 
1431
1427
  if (!action || !button) {
1428
+ if (reason === 'confirmation-screen') {
1429
+ // We're on a confirmation/nav screen with no actionable buttons visible.
1430
+ // Re-enter the manage menu to get action tabs back.
1431
+ LOG.info(`[farm:cycle:${cycleDepth}] confirmation screen — re-entering manage menu`);
1432
+ const mb = findFarmButton(cycleResponse, ['manage', 'farm-farm:manage']);
1433
+ if (mb) {
1434
+ const mr = await clickAndCapture({ channel, waitForDankMemer, response: cycleResponse, button: mb, tag: `farm-cycle-confirm-manage` });
1435
+ if (mr) {
1436
+ if (isCV2(mr)) await ensureCV2(mr);
1437
+ cycleResponse = mr;
1438
+ text = getFullText(cycleResponse);
1439
+ clean = brief(text, 600);
1440
+ }
1441
+ }
1442
+ await sleep(300);
1443
+ continue;
1444
+ }
1432
1445
  LOG.info(`[farm:cycle:${cycleDepth}] no action found (reason=${reason}) — breaking cycle`);
1433
1446
  break;
1434
1447
  }
@@ -1490,39 +1503,48 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1490
1503
  label.includes('plant all') || label.includes('harvest all') || label.includes('confirm')) && b.disabled;
1491
1504
  });
1492
1505
  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.
1506
+ // Farm state check: "Hoe All" disabled could mean (a) no Hoe item OR (b) no tiles to work.
1507
+ // Check the farm text if the farm is empty, there's nothing to hoe/water/plant,
1508
+ // so buying is pointless. Skip buy and treat as a no-op phase.
1509
+ const farmEmpty = /pretty empty|seems empty|empty\.{0,3}/i.test(clean);
1510
+ const currentIdx = FARM_PHASE_ORDER.indexOf(action);
1511
+ const nextPhase = FARM_PHASE_ORDER[currentIdx + 1] || null;
1512
+ if (farmEmpty) {
1513
+ LOG.info(`[farm:cycle:${cycleDepth}] ${action} all-btn disabled but farm is empty — skipping ${action}, advancing to ${nextPhase || 'done'}`);
1514
+ lastAction = action;
1515
+ forcedNextAction = nextPhase;
1516
+ cycleDepth++;
1517
+ if (!nextPhase) break;
1518
+ await sleep(300);
1519
+ continue;
1520
+ }
1521
+ // Farm has tiles but all-btn disabled — try to buy the item.
1495
1522
  const ITEM_FOR_ACTION = { hoe: 'Hoe', water: 'Watering Can', plant: 'Seeds' };
1496
1523
  const missingItem = ITEM_FOR_ACTION[action];
1497
1524
  if (missingItem) {
1498
- LOG.info(`[farm:cycle:${cycleDepth}] ${action} all-btn disabled — trying to buy ${missingItem}`);
1525
+ LOG.info(`[farm:cycle:${cycleDepth}] ${action} all-btn disabled (farm not empty) — trying to buy ${missingItem}`);
1499
1526
  const bought = await tryBuyFarmItem({ missing: missingItem, channel, waitForDankMemer, client });
1500
1527
  if (bought.ok) {
1501
1528
  LOG.success(`[farm:cycle:${cycleDepth}] Bought ${bought.itemName} — retrying ${action}`);
1502
1529
  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;
1530
+ // Refresh the manage menu to pick up the new button state
1531
+ const mb = findFarmButton(cycleResponse, ['manage', 'farm-farm:manage']);
1532
+ if (mb) {
1533
+ const mr = await clickAndCapture({ channel, waitForDankMemer, response: cycleResponse, button: mb, tag: 'farm-buy-retry-manage' });
1534
+ if (mr) { if (isCV2(mr)) await ensureCV2(mr); cycleResponse = mr; text = getFullText(cycleResponse); clean = brief(text, 600); }
1535
+ }
1512
1536
  continue;
1513
1537
  } else {
1514
1538
  LOG.warn(`[farm:cycle:${cycleDepth}] Could not buy ${missingItem} — skipping to next phase`);
1515
1539
  }
1516
1540
  }
1517
- const currentIdx = FARM_PHASE_ORDER.indexOf(action);
1518
- const nextPhase = FARM_PHASE_ORDER[currentIdx + 1] || null;
1541
+
1519
1542
  LOG.info(`[farm:cycle:${cycleDepth}] ${action} all-btn disabled (no-op) — next=${nextPhase || 'done'}`);
1520
1543
  actionsTaken++;
1521
1544
  lastAction = action;
1522
1545
  forcedNextAction = nextPhase;
1523
1546
  cycleDepth++;
1524
1547
  if (!nextPhase) break;
1525
- initialAnalysis = null;
1526
1548
  await sleep(300);
1527
1549
  continue;
1528
1550
  }
@@ -1539,20 +1561,146 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1539
1561
  if (!lastApplyResp) { LOG.warn('[farm:cycle] All click returned null'); break; }
1540
1562
 
1541
1563
  // Step 5: advance past any confirmation screens back to the manage menu
1564
+ // Register the ephemeral listener BEFORE advancePastConfirmation so we capture
1565
+ // the confirmation ephemeral (e.g., harvest "You harvested:") which arrives
1566
+ // asynchronously after the HTTP response.
1567
+ let confirmEphemeral = null;
1568
+ rawLogger.onNextEphemeral((parsed) => {
1569
+ confirmEphemeral = parsed;
1570
+ });
1542
1571
  cycleResponse = await advancePastConfirmation(lastApplyResp, waitForDankMemer);
1543
1572
  if (!cycleResponse) { LOG.warn('[farm:cycle] confirmation advance returned null'); break; }
1544
1573
  if (isCV2(cycleResponse)) await ensureCV2(cycleResponse);
1574
+ // Ephemeral fires asynchronously after HTTP response — wait briefly for it to arrive
1575
+ // before the harvest block checks cycleResponse._capturedEphemeral.
1576
+ await sleep(200);
1577
+ if (confirmEphemeral) cycleResponse._capturedEphemeral = confirmEphemeral;
1578
+ else if (cycleResponse?._capturedEphemeral) { /* already set by advancePastConfirmation */ }
1545
1579
  actionsTaken++;
1546
1580
  lastAction = action;
1547
1581
  text = getFullText(cycleResponse);
1548
1582
  clean = brief(text, 600);
1549
1583
  logFarmState('after-confirm', cycleResponse);
1550
1584
 
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).
1585
+ // ── Rejection ephemeral: detect "wrong phase" errors and cascade ─────────────
1586
+ // advancePastConfirmation registers no ephemeral listener (confirmation ephemerals
1587
+ // are captured by the outer listener below). The action's ephemeral (from clicking
1588
+ // the action tab) remains on lastApplyResp._capturedEphemeral.
1555
1589
  {
1590
+ const ephem = lastApplyResp?._capturedEphemeral;
1591
+ const ephemText = (ephem?.cv2Text || ephem?.allText || '').toLowerCase();
1592
+
1593
+ // Generic "nothing to X" no-op: when an action completes but has nothing to act on,
1594
+ // cascade forward without treating it as a rejection. Also use this as a safety break
1595
+ // when the same action keeps getting rejected (stuck in a loop).
1596
+ if (
1597
+ // "Nothing to water/hoe/plant/harvest" — action succeeded but was a no-op
1598
+ /nothing to (water|hoe|plant|harvest)|no crops? (to |to )?(water|harvest)|0 x [a-z]|nothing to do/i.test(ephemText) ||
1599
+ // Safety: if the same action was rejected 3+ cycles in a row, something is stuck — break
1600
+ (lastRejectedAction === action && lastRejectedAction2 === action)
1601
+ ) {
1602
+ LOG.warn(`[farm:cycle:${cycleDepth}:reject] ${action} is a no-op or stuck loop (${ephemText.slice(0, 80)}) — cascading to next phase`);
1603
+ const currentIdx = FARM_PHASE_ORDER.indexOf(action);
1604
+ const nextPhase = FARM_PHASE_ORDER[currentIdx + 1] || null;
1605
+ lastRejectedAction2 = lastRejectedAction;
1606
+ lastRejectedAction = action;
1607
+ forcedNextAction = nextPhase;
1608
+ lastAction = action;
1609
+ justRejected = true;
1610
+ cycleDepth++;
1611
+ if (!nextPhase) { forcedNextAction = null; break; }
1612
+ await reenterManage();
1613
+ await sleep(300);
1614
+ continue;
1615
+ }
1616
+
1617
+ // Helper: re-enter the manage menu from the farm view so the next
1618
+ // findNextFarmActionFromManage iteration has buttons to work with.
1619
+ async function reenterManage() {
1620
+ const allBtns = getAllButtons(cycleResponse);
1621
+ const mb = findFarmButton(cycleResponse, ['manage', 'farm-farm:manage']);
1622
+ LOG.info(`[farm:cycle:${cycleDepth}:reenter] findFarmButton result: ${mb ? mb.label + ' / ' + mb.customId : 'NULL'}`);
1623
+ if (!mb) {
1624
+ LOG.warn(`[farm:cycle:${cycleDepth}:reenter] no Manage button found! available btns: ${allBtns.map(b => b.label).join(', ')}`);
1625
+ return false;
1626
+ }
1627
+ const mr = await clickAndCapture({ channel, waitForDankMemer, response: cycleResponse, button: mb, tag: `farm-cycle-reject-manage` });
1628
+ LOG.info(`[farm:cycle:${cycleDepth}:reenter] clickAndCapture result: ${mr ? 'got response' : 'NULL'}`);
1629
+ if (!mr) return false;
1630
+ if (isCV2(mr)) await ensureCV2(mr);
1631
+ cycleResponse = mr;
1632
+ const postBtns = getAllButtons(cycleResponse);
1633
+ LOG.info(`[farm:cycle:${cycleDepth}:reenter] post-manage btns: ${postBtns.length}, ${postBtns.map(b => b.label).join(', ')}`);
1634
+ return true;
1635
+ }
1636
+
1637
+ // "You can only use a Hoe on empty tiles or after a harvest" → skip to water
1638
+ if (action === 'hoe' && /can only use.*hoe.*empty|only use.*hoe.*empty/.test(ephemText)) {
1639
+ LOG.warn(`[farm:cycle:${cycleDepth}:reject] Hoe rejected: tiles not empty — cascading to water`);
1640
+ const currentIdx = FARM_PHASE_ORDER.indexOf(action);
1641
+ const nextPhase = FARM_PHASE_ORDER[currentIdx + 1] || null;
1642
+ lastRejectedAction2 = lastRejectedAction;
1643
+ lastRejectedAction = action;
1644
+ forcedNextAction = nextPhase;
1645
+ lastAction = action;
1646
+ justRejected = true;
1647
+ cycleDepth++;
1648
+ if (!nextPhase) break;
1649
+ await reenterManage();
1650
+ await sleep(300);
1651
+ continue;
1652
+ }
1653
+
1654
+ // "You can only use a Watering Can on tilled tiles" (not hoed) → cascade forward to plant
1655
+ // Do NOT cascade back to hoe — that creates an infinite hoe↔water oscillation.
1656
+ if (action === 'water' && (ephemText.includes('can only use') || ephemText.includes('can only water'))) {
1657
+ LOG.warn(`[farm:cycle:${cycleDepth}:reject] Water rejected (${ephemText.slice(0, 100)}) — cascading to plant`);
1658
+ const currentIdx = FARM_PHASE_ORDER.indexOf(action);
1659
+ const nextPhase = FARM_PHASE_ORDER[currentIdx + 1] || null;
1660
+ lastRejectedAction2 = lastRejectedAction;
1661
+ lastRejectedAction = action;
1662
+ forcedNextAction = nextPhase;
1663
+ lastAction = action;
1664
+ justRejected = true;
1665
+ // Break oscillation guard: if we bounced back here from plant, stop.
1666
+ if (lastRejectedAction2 === 'plant' && lastRejectedAction === 'water') {
1667
+ LOG.warn(`[farm:cycle:${cycleDepth}:reject] hoe→water→plant oscillation detected — breaking`);
1668
+ break;
1669
+ }
1670
+ cycleDepth++;
1671
+ if (!nextPhase) break;
1672
+ await reenterManage();
1673
+ await sleep(300);
1674
+ continue;
1675
+ }
1676
+
1677
+ // "You can only plant seeds on an empty tile that is tilled and watered" → cascade to harvest
1678
+ // Do NOT cascade back to hoe — that creates an infinite hoe↔water oscillation.
1679
+ if (action === 'plant' && /can only plant|can only use.*seed|plant.*not.*water/.test(ephemText)) {
1680
+ LOG.warn(`[farm:cycle:${cycleDepth}:reject] Plant rejected (${ephemText.slice(0, 100)}) — cascading to harvest`);
1681
+ const currentIdx = FARM_PHASE_ORDER.indexOf(action);
1682
+ const nextPhase = FARM_PHASE_ORDER[currentIdx + 1] || null;
1683
+ lastRejectedAction2 = lastRejectedAction;
1684
+ lastRejectedAction = action;
1685
+ forcedNextAction = nextPhase;
1686
+ lastAction = action;
1687
+ justRejected = true;
1688
+ cycleDepth++;
1689
+ if (!nextPhase) {
1690
+ // Plant is last phase — advance to harvest in next cycle.
1691
+ await sleep(300);
1692
+ continue;
1693
+ }
1694
+ await reenterManage();
1695
+ await sleep(300);
1696
+ continue;
1697
+ }
1698
+ }
1699
+
1700
+ // ── Farm text detection: determine next action from farm state ─────────────────
1701
+ // Skip entirely if a rejection just fired — the rejection cascade handles the
1702
+ // next action; farm-text should not override forcedNextAction.
1703
+ if (!justRejected) {
1556
1704
  const postBtns = getAllButtons(cycleResponse);
1557
1705
  const postActionBtns = postBtns.filter(b => !b.disabled && !isNavOrUtilityButton(b));
1558
1706
  const postHasActionTabs = postActionBtns.some(b => hasAny(b, ['hoe', 'water', 'plant', 'harvest', 'fertiliz']));
@@ -1562,6 +1710,14 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1562
1710
  const isEmpty = /pretty empty|seems empty|empty\.{0,3}/i.test(postLower) && !hasSeedsReady && !hasHarvestReady;
1563
1711
  if (!postHasActionTabs && !isEmpty) {
1564
1712
  // Farm has crops (planted/growing). Force harvest.
1713
+ // BUT: if the current action was 'harvest' and we're on a planted confirmation
1714
+ // screen (no action tabs, not empty), it means nothing was harvested.
1715
+ // Breaking here lets the grow queue handle the planted crops.
1716
+ const justHarvested = (action === 'harvest');
1717
+ if (justHarvested) {
1718
+ LOG.info(`[farm:cycle:${cycleDepth}] farm-text: nothing harvested (planted confirmation) — breaking, grow queue will handle`);
1719
+ break;
1720
+ }
1565
1721
  // cycleResponse is still the farm view (no action tabs) after advancePastConfirmation.
1566
1722
  // Re-enter the manage menu so findNextFarmActionFromManage has buttons to work with.
1567
1723
  const mb = findFarmButton(cycleResponse, ['manage', 'farm-farm:manage']);
@@ -1578,12 +1734,13 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1578
1734
  forcedNextAction = 'harvest';
1579
1735
  lastAction = null;
1580
1736
  cycleDepth++;
1581
- initialAnalysis = null;
1582
1737
  await sleep(300);
1583
1738
  continue;
1584
1739
  }
1585
1740
  if (!postHasActionTabs && isEmpty) {
1586
- // Farm is empty after the action. Re-enter manage menu before forcing hoe.
1741
+ // Farm is empty after the action. Re-enter manage menu and check if there's
1742
+ // actually anything to work. If all "All" buttons are still disabled, the
1743
+ // farm is truly empty — nothing to hoe/water/plant, break the cycle.
1587
1744
  const mb = findFarmButton(cycleResponse, ['manage', 'farm-farm:manage']);
1588
1745
  if (mb) {
1589
1746
  LOG.info(`[farm:cycle:${cycleDepth}] farm-text: re-entering manage menu (empty farm)`);
@@ -1595,10 +1752,19 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1595
1752
  clean = brief(text, 600);
1596
1753
  }
1597
1754
  }
1755
+ // Double-check: after re-entering manage, if hoe/water/plant all-btn are all still
1756
+ // disabled, the farm is truly empty — no tiles to work. Break the cycle.
1757
+ const allBtnsNow = getAllButtons(cycleResponse);
1758
+ const hoeAllEnabled = allBtnsNow.some(b => !b.disabled && buttonHay(b).includes('hoe all'));
1759
+ const waterAllEnabled = allBtnsNow.some(b => !b.disabled && buttonHay(b).includes('water all'));
1760
+ const plantAllEnabled = allBtnsNow.some(b => !b.disabled && buttonHay(b).includes('plant all'));
1761
+ if (!hoeAllEnabled && !waterAllEnabled && !plantAllEnabled) {
1762
+ LOG.info(`[farm:cycle:${cycleDepth}] all action all-btns still disabled after manage re-entry — farm is truly empty, breaking`);
1763
+ break;
1764
+ }
1598
1765
  forcedNextAction = 'hoe';
1599
1766
  lastAction = null;
1600
1767
  cycleDepth++;
1601
- initialAnalysis = null;
1602
1768
  await sleep(300);
1603
1769
  continue;
1604
1770
  }
@@ -1607,22 +1773,18 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1607
1773
 
1608
1774
  // ── Harvest ephemeral: parse "You harvested: Nothing" to detect no-op ──────────
1609
1775
  if (action === 'harvest') {
1610
- const ephemeral = lastApplyResp?._capturedEphemeral;
1776
+ const ephemeral = cycleResponse?._capturedEphemeral || lastApplyResp?._capturedEphemeral;
1611
1777
  if (ephemeral) {
1612
1778
  const cv2t = ephemeral.cv2Text || '';
1613
1779
  const allt = ephemeral.allText || '';
1614
1780
  const harvestText = cv2t || allt;
1615
- LOG.info(`[farm:cycle:${cycleDepth}:harvest-ephemeral] "${harvestText.slice(0, 300)}"`);
1781
+ LOG.info(`[farm:cycle:${cycleDepth}:harvest-ephemeral] cv2Text="${cv2t.slice(0,200)}" allText="${allt.slice(0,200)}"`);
1616
1782
  // 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;
1783
+ // Clear forcedNextAction and break farm is done.
1784
+ 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))) {
1785
+ LOG.info(`[farm:cycle:${cycleDepth}:harvest-ephemeral] Nothing harvested — farm cycle complete`);
1786
+ forcedNextAction = null;
1787
+ break;
1626
1788
  }
1627
1789
  // Something was actually harvested — parse what and log it.
1628
1790
  const itemMatches = [...harvestText.matchAll(/-?\s*(\d+)\s*x?\s*([A-Za-z]+)/g)];
@@ -1660,6 +1822,9 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1660
1822
  logFarmState('after-plant-back', cycleResponse);
1661
1823
  }
1662
1824
  // Plant succeeded — advance to harvest.
1825
+ // cycleResponse is now the farm view (after clicking Back).
1826
+ // Re-enter manage so findNextFarmActionFromManage has buttons on the next loop.
1827
+ await reenterManage();
1663
1828
  const currentIdx = FARM_PHASE_ORDER.indexOf(action);
1664
1829
  const nextPhase = FARM_PHASE_ORDER[currentIdx + 1] || null;
1665
1830
  LOG.info(`[farm:cycle:${cycleDepth}] plant done, advancing to ${nextPhase || 'done'}`);
@@ -1667,7 +1832,6 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1667
1832
  if (!nextPhase) break;
1668
1833
  cycleDepth++;
1669
1834
  lastAction = nextPhase;
1670
- initialAnalysis = null;
1671
1835
  await sleep(400);
1672
1836
  continue;
1673
1837
  }
@@ -1679,7 +1843,6 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1679
1843
  if (!nextPhase) break;
1680
1844
  cycleDepth++;
1681
1845
  lastAction = nextPhase;
1682
- initialAnalysis = null;
1683
1846
  await sleep(300);
1684
1847
  continue;
1685
1848
  }
@@ -1687,22 +1850,11 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1687
1850
  clean = brief(text, 600);
1688
1851
  logFarmState('after-confirm', cycleResponse);
1689
1852
 
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);
1853
+ // Step 6: NO image analysis rely purely on ephemeral + text for next action decision
1854
+ // let imgAnalysis = null; // DISABLED vision
1855
+ // const scoreResult = await capturePhaseVisionScore({ msg: cycleResponse, actionName: action, phaseTag: `farm-cycle-${action}` });
1856
+ // const nextAction = inferNextActionFromScore(action, scoreResult?.score || null); // DISABLED
1857
+ const nextAction = null; // DISABLED — purely ephemeral-based flow
1706
1858
 
1707
1859
  // Collect all error/rejection text sources:
1708
1860
  // 1. Extra follow-up messages from farm actions
@@ -1754,7 +1906,7 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1754
1906
  forcedNextAction = 'harvest';
1755
1907
  cycleDepth++;
1756
1908
  lastAction = 'water';
1757
- initialAnalysis = null;
1909
+
1758
1910
  await sleep(300);
1759
1911
  continue;
1760
1912
  }
@@ -1766,7 +1918,7 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1766
1918
  LOG.info(`[farm:cycle:${cycleDepth}] next action = ${nextAction} (after ${action})`);
1767
1919
  lastAction = action;
1768
1920
  cycleDepth++;
1769
- initialAnalysis = imgAnalysis; // pass updated analysis to next iteration
1921
+
1770
1922
  await sleep(300);
1771
1923
  }
1772
1924
 
@@ -1806,7 +1958,7 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1806
1958
  }
1807
1959
  }
1808
1960
 
1809
- await capturePhaseVisionScore({ msg: cycleResponse, actionName: lastAction || 'harvest', phaseTag: 'farm-final' });
1961
+
1810
1962
 
1811
1963
  if (coins > 0) {
1812
1964
  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.12.0",
4
4
  "description": "Dank Memer automation engine — grind coins while you sleep",
5
5
  "bin": {
6
6
  "dankgrinder": "bin/dankgrinder.js"