dankgrinder 7.1.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,
@@ -474,8 +475,8 @@ function getVisionRepairPlan(actionName, score) {
474
475
  }
475
476
  if (actionName === 'hoe') {
476
477
  const planted = score.ratios?.planted || 0;
477
- const wet = score.ratios?.wet || 0;
478
- if ((planted + wet) >= 0.45) {
478
+ const tilled = score.ratios?.tilled || 0;
479
+ if (planted >= 0.30 || tilled >= 0.30) {
479
480
  // Farm likely already progressed beyond hoe stage.
480
481
  return null;
481
482
  }
@@ -488,10 +489,9 @@ function getVisionRepairPlan(actionName, score) {
488
489
  function inferNextActionFromScore(actionName, score) {
489
490
  if (!score) return null;
490
491
  const planted = score.ratios?.planted || 0;
491
- const wet = score.ratios?.wet || 0;
492
492
  const tilled = score.ratios?.tilled || 0;
493
493
 
494
- if (actionName === 'hoe' && (planted + wet) >= 0.45) {
494
+ if (actionName === 'hoe' && (planted + tilled) >= 0.45) {
495
495
  return 'water';
496
496
  }
497
497
  if (actionName === 'water' && planted >= 0.45) {
@@ -515,16 +515,14 @@ async function inferPreferredActionFromImage(msg) {
515
515
  const slots = Math.max(1, (analysis?.rows || 3) * (analysis?.cols || 3));
516
516
  const ratios = {
517
517
  tilled: (analysis?.counts?.tilled || 0) / slots,
518
- wet: (analysis?.counts?.wet || 0) / slots,
519
518
  planted: (analysis?.counts?.planted || 0) / slots,
520
519
  unknown: (analysis?.counts?.unknown || 0) / slots,
521
520
  };
522
521
 
523
522
  let suggested = null;
524
- // Image-first phase routing.
525
- if (ratios.planted >= 0.45) suggested = 'plant';
526
- else if (ratios.wet >= 0.35) suggested = 'plant';
527
- else if (ratios.tilled >= 0.35) suggested = 'water';
523
+ // Image-first phase routing (3-state: tilled/planted/unknown, no separate wet state).
524
+ if (ratios.planted >= 0.30) suggested = 'plant';
525
+ else if (ratios.tilled >= 0.30) suggested = 'water';
528
526
  else suggested = 'hoe';
529
527
 
530
528
  let dbg = null;
@@ -541,7 +539,6 @@ async function inferPreferredActionFromImage(msg) {
541
539
 
542
540
  LOG.info(`[farm:phase-image] url=${url} grid=${gridToString(analysis)} counts=${JSON.stringify(analysis.counts)} conf=${analysis.avgConfidence} suggested=${suggested} ratios=${JSON.stringify({
543
541
  tilled: +ratios.tilled.toFixed(3),
544
- wet: +ratios.wet.toFixed(3),
545
542
  planted: +ratios.planted.toFixed(3),
546
543
  unknown: +ratios.unknown.toFixed(3),
547
544
  })}`);
@@ -942,6 +939,17 @@ function pickFarmActionButton(msg, text) {
942
939
  return null;
943
940
  }
944
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
+
945
953
  async function clickAndCapture({ channel, waitForDankMemer, response, button, tag, timeoutMs = 9000 }) {
946
954
  const baseline = brief(getFullText(response), 400);
947
955
  const clickRes = await safeClickButton(response, button);
@@ -951,7 +959,30 @@ async function clickAndCapture({ channel, waitForDankMemer, response, button, ta
951
959
  delete response._lastInteractionAck;
952
960
  }
953
961
 
954
- 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;
955
986
  if (!post && response.id) post = await waitForEditedMessage(channel, response.id, baseline, timeoutMs);
956
987
  if (!post) post = await waitForDankMemer(timeoutMs);
957
988
 
@@ -960,17 +991,23 @@ async function clickAndCapture({ channel, waitForDankMemer, response, button, ta
960
991
 
961
992
  // Capture any additional immediate callback message (often ephemeral-like
962
993
  // "only you can see this" notices) that may be separate from edited CV2 post.
994
+ let extraMsg = null;
963
995
  try {
964
996
  const side = await waitForDankMemer(1500);
965
997
  if (side && side.id !== post.id) {
966
998
  logEphemeralLike(`${tag}-post-extra`, side);
967
- post._farmExtraInteraction = side;
999
+ extraMsg = side;
968
1000
  }
969
1001
  } catch {}
970
1002
 
971
1003
  if (isCV2(post)) await ensureCV2(post);
972
1004
  logMsg(post, tag);
973
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;
974
1011
  return post;
975
1012
  }
976
1013
 
@@ -1117,31 +1154,30 @@ async function findNextFarmActionFromManage(msg, text, currentAction, imageAnaly
1117
1154
  const slots = Math.max(1, (imageAnalysis.rows || 3) * (imageAnalysis.cols || 3));
1118
1155
  const ratios = {
1119
1156
  tilled: (imageAnalysis.counts?.tilled || 0) / slots,
1120
- wet: (imageAnalysis.counts?.wet || 0) / slots,
1121
1157
  planted: (imageAnalysis.counts?.planted || 0) / slots,
1122
1158
  unknown: (imageAnalysis.counts?.unknown || 0) / slots,
1123
1159
  };
1124
1160
 
1125
- // Phase-aware selection: pick the earliest incomplete phase.
1126
- // Harvest when planted >= 45%.
1127
- if (ratios.planted >= 0.45) {
1161
+ // Phase-aware selection: pick the earliest incomplete phase (3-state model).
1162
+ // Harvest when planted >= 30%.
1163
+ if (ratios.planted >= 0.30) {
1128
1164
  const btn = managedActions.harvest || btns.find(b => hasAny(b, ['harvest', 'reap', 'collect']));
1129
1165
  if (btn) return { action: 'harvest', button: btn, reason: `harvest-ready-vision(planted=${ratios.planted.toFixed(2)})` };
1130
1166
  }
1131
- // Plant when wet >= 45% or tilled >= 45%.
1132
- if (ratios.wet >= 0.45 || ratios.tilled >= 0.45) {
1167
+ // Plant when tilled >= 30%.
1168
+ if (ratios.tilled >= 0.30) {
1133
1169
  const btn = managedActions.plant || btns.find(b => hasAny(b, ['plant', 'seed', 'sow']));
1134
- if (btn) return { action: 'plant', button: btn, reason: `plant-vision(wet=${ratios.wet.toFixed(2)},tilled=${ratios.tilled.toFixed(2)})` };
1170
+ if (btn) return { action: 'plant', button: btn, reason: `plant-vision(tilled=${ratios.tilled.toFixed(2)})` };
1135
1171
  }
1136
- // Water when we have tilled tiles but not enough wet.
1137
- if (ratios.tilled >= 0.35 && ratios.wet < 0.35) {
1172
+ // Water when we have tilled tiles.
1173
+ if (ratios.tilled >= 0.15) {
1138
1174
  const btn = managedActions.water || btns.find(b => hasAny(b, ['water', 'watering']));
1139
- if (btn) return { action: 'water', button: btn, reason: `water-vision(tilled=${ratios.tilled.toFixed(2)},wet=${ratios.wet.toFixed(2)})` };
1175
+ if (btn) return { action: 'water', button: btn, reason: `water-vision(tilled=${ratios.tilled.toFixed(2)})` };
1140
1176
  }
1141
1177
  // Hoe when farm is mostly unknown/empty.
1142
- if (ratios.tilled < 0.35 && ratios.wet < 0.35 && ratios.planted < 0.35) {
1178
+ if (ratios.tilled < 0.15 && ratios.planted < 0.30) {
1143
1179
  const btn = managedActions.hoe || btns.find(b => hasAny(b, ['hoe', 'till']));
1144
- if (btn) return { action: 'hoe', button: btn, reason: `hoe-vision(t=${ratios.tilled.toFixed(2)},w=${ratios.wet.toFixed(2)},p=${ratios.planted.toFixed(2)})` };
1180
+ if (btn) return { action: 'hoe', button: btn, reason: `hoe-vision(t=${ratios.tilled.toFixed(2)},p=${ratios.planted.toFixed(2)})` };
1145
1181
  }
1146
1182
  }
1147
1183
 
@@ -1177,11 +1213,20 @@ async function advancePastConfirmation(response, waitForDankMemer) {
1177
1213
  if (!response) return null;
1178
1214
 
1179
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'];
1180
1220
  const MAX_PAGES = 3;
1181
1221
 
1182
1222
  for (let page = 0; page < MAX_PAGES; page++) {
1183
1223
  const btns = getAllButtons(response).filter(b => !b.disabled);
1184
- 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
+ }
1185
1230
 
1186
1231
  if (!confirmBtn) {
1187
1232
  // No more confirmation buttons — return the current screen (should be manage menu)
@@ -1197,6 +1242,18 @@ async function advancePastConfirmation(response, waitForDankMemer) {
1197
1242
  try {
1198
1243
  clicked = await safeClickButton(response, confirmBtn);
1199
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
+ }
1200
1257
  } catch (e) {
1201
1258
  LOG.warn(`[farm:confirm] click failed on page ${page}: ${e.message}`);
1202
1259
  return response;
@@ -1240,7 +1297,7 @@ async function advancePastConfirmation(response, waitForDankMemer) {
1240
1297
  // ── Single-cycle farm orchestrator ──────────────────────────────────────────
1241
1298
  // Sends `pls farm view` once, then completes the full hoe→water→plant→harvest
1242
1299
  // cycle by looping on the returned manage menu — no additional command sends.
1243
- async function runFarm({ channel, waitForDankMemer, client, redis, accountId }) {
1300
+ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, forceRun }) {
1244
1301
  LOG.cmd(`${c.white}${c.bold}pls farm view${c.reset}`);
1245
1302
 
1246
1303
  await channel.send('pls farm view');
@@ -1296,7 +1353,7 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
1296
1353
  if (textHasHarvestReady) LOG.info(`[farm] DETECTED harvest-ready in text (growReadySec=${growReadySec})`);
1297
1354
  if (growReadySec > 0) LOG.info(`[farm] GROW-QUEUE DEBUG: lower=${lower.slice(0, 400)}`);
1298
1355
  LOG.info(`[farm] grow-ready parse=${growReadySec == null ? 'none' : `${growReadySec}s`} redis_recovery=${redisRecoveryMs != null ? `${redisRecoveryMs}ms` : 'n/a'}`);
1299
- if (!redisRecovered && growReadySec && growReadySec > 20) {
1356
+ if (!forceRun && !redisRecovered && growReadySec && growReadySec > 20) {
1300
1357
  await inferPreferredActionFromImage(response);
1301
1358
  // Re-check every 30s instead of waiting the full grow duration.
1302
1359
  // This ensures the next harvest cycle starts as soon as crops are ready.
@@ -1433,6 +1490,30 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
1433
1490
  label.includes('plant all') || label.includes('harvest all') || label.includes('confirm')) && b.disabled;
1434
1491
  });
1435
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
+ }
1436
1517
  const currentIdx = FARM_PHASE_ORDER.indexOf(action);
1437
1518
  const nextPhase = FARM_PHASE_ORDER[currentIdx + 1] || null;
1438
1519
  LOG.info(`[farm:cycle:${cycleDepth}] ${action} all-btn disabled (no-op) — next=${nextPhase || 'done'}`);
@@ -1460,12 +1541,98 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
1460
1541
  // Step 5: advance past any confirmation screens back to the manage menu
1461
1542
  cycleResponse = await advancePastConfirmation(lastApplyResp, waitForDankMemer);
1462
1543
  if (!cycleResponse) { LOG.warn('[farm:cycle] confirmation advance returned null'); break; }
1544
+ if (isCV2(cycleResponse)) await ensureCV2(cycleResponse);
1463
1545
  actionsTaken++;
1464
1546
  lastAction = action;
1465
1547
  text = getFullText(cycleResponse);
1466
1548
  clean = brief(text, 600);
1467
1549
  logFarmState('after-confirm', cycleResponse);
1468
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
+
1469
1636
  // If confirmation screen is identical to post-confirmation screen,
1470
1637
  // check whether we're on a planted confirmation (Dank Memer planted crops
1471
1638
  // and shows "ready at X"). In that case, click "Back" to return to the
@@ -1537,17 +1704,38 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
1537
1704
  const score = scoreResult?.score || null;
1538
1705
  const nextAction = inferNextActionFromScore(action, score);
1539
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
+
1540
1727
  // Handle rejection messages that indicate wrong phase.
1541
- const extraText = String(stripAnsi(getFullText(cycleResponse?._farmExtraInteraction) || '')).toLowerCase();
1542
- if (action === 'plant' && /only\s+plant\s+seeds\s+on\s+an\s+empty\s+tile|tilled\s+and\s+watered/.test(extraText)) {
1543
- 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';
1544
1731
  lastAction = null;
1545
1732
  cycleDepth++;
1546
1733
  await sleep(400);
1547
1734
  continue;
1548
1735
  }
1549
- 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)) {
1550
1737
  LOG.warn('[farm:cycle] Hoe rejected — moving to water phase.');
1738
+ forcedNextAction = 'water';
1551
1739
  lastAction = 'hoe';
1552
1740
  cycleDepth++;
1553
1741
  await sleep(400);
@@ -1556,6 +1744,22 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
1556
1744
 
1557
1745
  if (!nextAction) {
1558
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
+ }
1559
1763
  break;
1560
1764
  }
1561
1765