dankgrinder 7.6.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.
@@ -4,15 +4,10 @@ const {
4
4
  isCV2, ensureCV2, stripAnsi, needsItem, clickCV2SelectMenu,
5
5
  } = require('./utils');
6
6
  const { buyItem, buyItemsBatch } = require('./shop');
7
- const {
8
- downloadImage,
9
- extractFarmImageUrl,
10
- analyzeFarmGrid,
11
- gridToString,
12
- evaluateActionNeed,
13
- evaluateActionScores,
14
- dumpFarmVisionDebug,
15
- } = require('./farmVision');
7
+ const rawLogger = require('../../lib/rawLogger');
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');
16
11
 
17
12
  const RE_TS = /<t:(\d+):R>/;
18
13
  const RE_MIN = /(\d+)\s*minute/i;
@@ -413,6 +408,7 @@ function logEphemeralLike(tag, payload) {
413
408
  else LOG.info(line);
414
409
  }
415
410
 
411
+ /* DISABLED — vision-based image analysis for action scoring
416
412
  async function analyzeFarmImageForAction(msg, actionName) {
417
413
  try {
418
414
  const url = extractFarmImageUrl(msg);
@@ -485,6 +481,9 @@ function getVisionRepairPlan(actionName, score) {
485
481
  return null;
486
482
  }
487
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.
488
487
  function inferNextActionFromScore(actionName, score) {
489
488
  if (!score) return null;
490
489
  const planted = score.ratios?.planted || 0;
@@ -504,7 +503,9 @@ function inferNextActionFromScore(actionName, score) {
504
503
  }
505
504
  return null;
506
505
  }
506
+ */
507
507
 
508
+ /* DISABLED — vision-based preferred action inference
508
509
  async function inferPreferredActionFromImage(msg) {
509
510
  try {
510
511
  const url = extractFarmImageUrl(msg);
@@ -517,43 +518,18 @@ async function inferPreferredActionFromImage(msg) {
517
518
  planted: (analysis?.counts?.planted || 0) / slots,
518
519
  unknown: (analysis?.counts?.unknown || 0) / slots,
519
520
  };
520
-
521
521
  let suggested = null;
522
- // Image-first phase routing (3-state: tilled/planted/unknown, no separate wet state).
523
522
  if (ratios.planted >= 0.30) suggested = 'plant';
524
523
  else if (ratios.tilled >= 0.30) suggested = 'water';
525
524
  else suggested = 'hoe';
526
-
527
- let dbg = null;
528
- try {
529
- dbg = await dumpFarmVisionDebug({
530
- imgBuffer: buf,
531
- analysis,
532
- actionName: `phase-${suggested || 'unknown'}`,
533
- sourceUrl: url,
534
- });
535
- } catch (e) {
536
- LOG.warn(`[farm:phase-image] debug dump failed: ${e.message}`);
537
- }
538
-
539
- LOG.info(`[farm:phase-image] url=${url} grid=${gridToString(analysis)} counts=${JSON.stringify(analysis.counts)} conf=${analysis.avgConfidence} suggested=${suggested} ratios=${JSON.stringify({
540
- tilled: +ratios.tilled.toFixed(3),
541
- planted: +ratios.planted.toFixed(3),
542
- unknown: +ratios.unknown.toFixed(3),
543
- })}`);
544
- if (dbg) {
545
- const tileSummary = (analysis.cells || [])
546
- .map(c => `r${c.row + 1}c${c.col + 1}:${c.state}:${c.confidence}`)
547
- .join(' | ');
548
- LOG.info(`[farm:phase-image] debug dir=${dbg.dir} source=${dbg.sourcePath} manifest=${dbg.manifestPath} tiles=${dbg.tileCount}`);
549
- LOG.info(`[farm:phase-image] tile-summary ${tileSummary}`);
550
- }
525
+ LOG.info(`[farm:phase-image] url=${url} grid=${gridToString(analysis)} suggested=${suggested}`);
551
526
  return suggested;
552
527
  } catch (e) {
553
528
  LOG.warn(`[farm:phase-image] inference failed: ${e.message}`);
554
529
  return null;
555
530
  }
556
531
  }
532
+ */
557
533
 
558
534
  function chooseSeedOption(menu) {
559
535
  const options = (menu?.options || []).filter(o => o && o.value);
@@ -641,11 +617,11 @@ async function ensurePlantSeedSelected({ response, waitForDankMemer, channel })
641
617
  }
642
618
  }
643
619
 
620
+ /* DISABLED — vision-based phase scoring
644
621
  async function capturePhaseVisionScore({ msg, actionName, phaseTag }) {
645
622
  if (!msg || !['hoe', 'water', 'plant', 'harvest'].includes(actionName)) {
646
623
  return null;
647
624
  }
648
-
649
625
  const check = await analyzeFarmImageForAction(msg, actionName);
650
626
  const s = check?.score;
651
627
  if (s) {
@@ -655,6 +631,7 @@ async function capturePhaseVisionScore({ msg, actionName, phaseTag }) {
655
631
  }
656
632
  return check;
657
633
  }
634
+ */
658
635
 
659
636
  function mergeBuyPlan(plan) {
660
637
  const m = new Map();
@@ -938,6 +915,17 @@ function pickFarmActionButton(msg, text) {
938
915
  return null;
939
916
  }
940
917
 
918
+ // Captures the ephemeral interaction response content from a CV2 ack object.
919
+ // Dank Memer sends error/success text in the interaction ACK (type 4/7) content field.
920
+ function captureCvvAckContent(ack) {
921
+ if (!ack) return '';
922
+ const flags = ack?.flags ?? ack?.data?.flags ?? 0;
923
+ const content = String(stripAnsi(ack?.content || ack?.data?.content || '')).trim();
924
+ if (!content) return '';
925
+ const isEphemeral = (flags & 64) !== 0;
926
+ return `[ephemeral=${isEphemeral}] ${content}`;
927
+ }
928
+
941
929
  async function clickAndCapture({ channel, waitForDankMemer, response, button, tag, timeoutMs = 9000 }) {
942
930
  const baseline = brief(getFullText(response), 400);
943
931
  const clickRes = await safeClickButton(response, button);
@@ -947,7 +935,30 @@ async function clickAndCapture({ channel, waitForDankMemer, response, button, ta
947
935
  delete response._lastInteractionAck;
948
936
  }
949
937
 
950
- let post = clickRes || null;
938
+ // CV2 HTTP fallback returns the interaction ACK (not the updated message).
939
+ // If clickRes looks like an ack rather than a message, extract it and wait for
940
+ // the edited message instead.
941
+ const isAckObject = clickRes && typeof clickRes === 'object' && !clickRes.id && (clickRes.interactionStatus || clickRes.interactionType);
942
+ const capturedAck = isAckObject ? clickRes : null;
943
+
944
+ // Register a one-time callback to capture ephemeral responses (e.g. "You harvested: Nothing")
945
+ // from the raw gateway packet — the only place where ephemeral embeds/CV2 text are preserved.
946
+ let capturedEphemeral = null;
947
+ rawLogger.onNextEphemeral((parsed) => {
948
+ capturedEphemeral = parsed;
949
+ const cv2t = parsed.cv2Text || '';
950
+ const allt = parsed.allText || '';
951
+ const txt = cv2t || allt;
952
+ if (txt.includes('harvested')) LOG.info(`${tag}-ephemeral harvest: ${txt.slice(0, 200)}`);
953
+ else LOG.info(`${tag}-ephemeral ${txt.slice(0, 200)}`);
954
+ });
955
+
956
+ // Extra grace period: the ephemeral often arrives after safeClickButton resolves
957
+ // (the library may return the stale message immediately, before the gateway delivers the
958
+ // ephemeral). This ensures we capture it even when it arrives late.
959
+ await sleep(1500);
960
+
961
+ let post = (!isAckObject && clickRes) ? clickRes : null;
951
962
  if (!post && response.id) post = await waitForEditedMessage(channel, response.id, baseline, timeoutMs);
952
963
  if (!post) post = await waitForDankMemer(timeoutMs);
953
964
 
@@ -956,17 +967,23 @@ async function clickAndCapture({ channel, waitForDankMemer, response, button, ta
956
967
 
957
968
  // Capture any additional immediate callback message (often ephemeral-like
958
969
  // "only you can see this" notices) that may be separate from edited CV2 post.
970
+ let extraMsg = null;
959
971
  try {
960
972
  const side = await waitForDankMemer(1500);
961
973
  if (side && side.id !== post.id) {
962
974
  logEphemeralLike(`${tag}-post-extra`, side);
963
- post._farmExtraInteraction = side;
975
+ extraMsg = side;
964
976
  }
965
977
  } catch {}
966
978
 
967
979
  if (isCV2(post)) await ensureCV2(post);
968
980
  logMsg(post, tag);
969
981
  logFarmState(tag, post);
982
+
983
+ // Attach captured ack and extra message so callers can access them.
984
+ post._capturedAck = capturedAck;
985
+ post._farmExtraMsg = extraMsg;
986
+ post._capturedEphemeral = capturedEphemeral;
970
987
  return post;
971
988
  }
972
989
 
@@ -1074,10 +1091,22 @@ async function waitForEditedMessage(channel, messageId, baselineText, timeoutMs
1074
1091
  // Determine the next action to take from the manage menu, given what the
1075
1092
  // farm image vision says about the current state.
1076
1093
  // Returns { action, button, reason } where action is null when the cycle is done.
1077
- async function findNextFarmActionFromManage(msg, text, currentAction, imageAnalysis, forcedNextAction) {
1094
+ async function findNextFarmActionFromManage(msg, text, currentAction, forcedNextAction) {
1078
1095
  const btns = getAllButtons(msg).filter(b => !b.disabled && !isNavOrUtilityButton(b));
1079
1096
  const allBtns = getAllButtons(msg); // include disabled for state detection
1080
- 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
+ }
1081
1110
 
1082
1111
  const managedActions = getManageActionButtons(msg);
1083
1112
  const lower = String(stripAnsi(text || '')).toLowerCase();
@@ -1108,6 +1137,7 @@ async function findNextFarmActionFromManage(msg, text, currentAction, imageAnaly
1108
1137
  if (btn) return { action: 'harvest', button: btn, reason: 'harvest-ready-text' };
1109
1138
  }
1110
1139
 
1140
+ /* DISABLED — vision-based image analysis routing
1111
1141
  // Use image vision ratios if available.
1112
1142
  if (imageAnalysis) {
1113
1143
  const slots = Math.max(1, (imageAnalysis.rows || 3) * (imageAnalysis.cols || 3));
@@ -1116,7 +1146,6 @@ async function findNextFarmActionFromManage(msg, text, currentAction, imageAnaly
1116
1146
  planted: (imageAnalysis.counts?.planted || 0) / slots,
1117
1147
  unknown: (imageAnalysis.counts?.unknown || 0) / slots,
1118
1148
  };
1119
-
1120
1149
  // Phase-aware selection: pick the earliest incomplete phase (3-state model).
1121
1150
  // Harvest when planted >= 30%.
1122
1151
  if (ratios.planted >= 0.30) {
@@ -1139,6 +1168,7 @@ async function findNextFarmActionFromManage(msg, text, currentAction, imageAnaly
1139
1168
  if (btn) return { action: 'hoe', button: btn, reason: `hoe-vision(t=${ratios.tilled.toFixed(2)},p=${ratios.planted.toFixed(2)})` };
1140
1169
  }
1141
1170
  }
1171
+ */
1142
1172
 
1143
1173
  // Disabled-all detection: if the "All" for a phase is already disabled,
1144
1174
  // that phase is done — move to the next one.
@@ -1172,11 +1202,20 @@ async function advancePastConfirmation(response, waitForDankMemer) {
1172
1202
  if (!response) return null;
1173
1203
 
1174
1204
  const CONFIRM_WORDS = ['continue', 'confirm', 'confirm all', 'done', 'back to farm', 'back', 'close'];
1205
+ // Also treat "All" buttons as confirmation actions (e.g. "Hoe All", "Water All").
1206
+ // Skip the plain "back" word to avoid matching the Back arrow button; prefer explicit
1207
+ // confirm words first, then fall back to "All" buttons as confirmation.
1208
+ const ALL_AS_CONFIRM = ['hoe all', 'water all', 'plant all', 'harvest all', 'fertilize all'];
1175
1209
  const MAX_PAGES = 3;
1176
1210
 
1177
1211
  for (let page = 0; page < MAX_PAGES; page++) {
1178
1212
  const btns = getAllButtons(response).filter(b => !b.disabled);
1179
- const confirmBtn = btns.find(b => CONFIRM_WORDS.some(w => buttonHay(b).includes(w)));
1213
+ let confirmBtn = btns.find(b => CONFIRM_WORDS.some(w => buttonHay(b).includes(w)));
1214
+ // Also treat "All" buttons as confirmation (e.g. clicking "Plant All" again on the
1215
+ // confirmation screen advances past it). But prefer explicit confirm words first.
1216
+ if (!confirmBtn) {
1217
+ confirmBtn = btns.find(b => ALL_AS_CONFIRM.some(w => buttonHay(b).includes(w)));
1218
+ }
1180
1219
 
1181
1220
  if (!confirmBtn) {
1182
1221
  // No more confirmation buttons — return the current screen (should be manage menu)
@@ -1188,10 +1227,28 @@ async function advancePastConfirmation(response, waitForDankMemer) {
1188
1227
  await humanDelay(80, 220);
1189
1228
 
1190
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
+
1191
1236
  let clicked;
1192
1237
  try {
1193
1238
  clicked = await safeClickButton(response, confirmBtn);
1194
1239
  logEphemeralLike(`confirm-page-${page}`, clicked);
1240
+ // Preserve CV2 ack so cycle can check for ephemeral errors.
1241
+ if (clicked?._lastInteractionAck) {
1242
+ response._lastInteractionAck = clicked._lastInteractionAck;
1243
+ }
1244
+ // Also capture ephemeral ack content for logging
1245
+ const ackContent = captureCvvAckContent(clicked);
1246
+ if (ackContent) {
1247
+ const flags = clicked?.flags ?? 0;
1248
+ const isEphemeral = (flags & 64) !== 0;
1249
+ const level = isEphemeral ? LOG.warn : LOG.info;
1250
+ level(`[farm:confirm:${page}] ephemeral=${isEphemeral} ack="${ackContent.slice(0, 300)}"`);
1251
+ }
1195
1252
  } catch (e) {
1196
1253
  LOG.warn(`[farm:confirm] click failed on page ${page}: ${e.message}`);
1197
1254
  return response;
@@ -1235,7 +1292,7 @@ async function advancePastConfirmation(response, waitForDankMemer) {
1235
1292
  // ── Single-cycle farm orchestrator ──────────────────────────────────────────
1236
1293
  // Sends `pls farm view` once, then completes the full hoe→water→plant→harvest
1237
1294
  // cycle by looping on the returned manage menu — no additional command sends.
1238
- async function runFarm({ channel, waitForDankMemer, client, redis, accountId }) {
1295
+ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, forceRun }) {
1239
1296
  LOG.cmd(`${c.white}${c.bold}pls farm view${c.reset}`);
1240
1297
 
1241
1298
  await channel.send('pls farm view');
@@ -1292,11 +1349,21 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
1292
1349
  if (growReadySec > 0) LOG.info(`[farm] GROW-QUEUE DEBUG: lower=${lower.slice(0, 400)}`);
1293
1350
  LOG.info(`[farm] grow-ready parse=${growReadySec == null ? 'none' : `${growReadySec}s`} redis_recovery=${redisRecoveryMs != null ? `${redisRecoveryMs}ms` : 'n/a'}`);
1294
1351
  if (!redisRecovered && growReadySec && growReadySec > 20) {
1295
- await inferPreferredActionFromImage(response);
1296
- // Re-check every 30s instead of waiting the full grow duration.
1297
- // This ensures the next harvest cycle starts as soon as crops are ready.
1298
- const waitSec = Math.min(30, Math.min(6 * 3600, growReadySec + 2));
1299
- LOG.info(`[farm] crops growing (~${Math.ceil(growReadySec / 60)}m remaining); re-checking in ${waitSec}s`);
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`);
1300
1367
  return { result: `farm grow queue (${Math.ceil(growReadySec / 60)}m)`, coins: 0, nextCooldownSec: waitSec, skipReason: 'farm_grow_queue' };
1301
1368
  }
1302
1369
 
@@ -1333,19 +1400,6 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
1333
1400
  }
1334
1401
  }
1335
1402
 
1336
- // ── Initial image analysis to bootstrap cycle entry ────────────────────────
1337
- let initialAnalysis = null;
1338
- try {
1339
- const url = extractFarmImageUrl(response);
1340
- if (url) {
1341
- const buf = await downloadImage(url);
1342
- const grid = await analyzeFarmGrid(buf);
1343
- initialAnalysis = grid;
1344
- LOG.info(`[farm] initial image grid=${gridToString(grid)} counts=${JSON.stringify(grid.counts)} conf=${grid.avgConfidence}`);
1345
- }
1346
- } catch (e) {
1347
- LOG.info(`[farm] initial image analysis failed: ${e.message}`);
1348
- }
1349
1403
 
1350
1404
  // ── Cycle loop: hoe → water → plant → harvest ─────────────────────────────
1351
1405
  let cycleDepth = 0;
@@ -1354,11 +1408,14 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
1354
1408
  let actionsTaken = 0; // Track how many farm actions were actually executed
1355
1409
  let forcedNextAction = null; // When advancing a phase, force the next action check
1356
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
1357
1412
 
1358
1413
  while (cycleDepth < 5) {
1414
+ // Reset per-cycle state
1415
+ let justRejected = false;
1359
1416
  let actionResult;
1360
1417
  try {
1361
- actionResult = await findNextFarmActionFromManage(cycleResponse, text, lastAction, initialAnalysis, forcedNextAction);
1418
+ actionResult = await findNextFarmActionFromManage(cycleResponse, text, lastAction, forcedNextAction);
1362
1419
  } catch (e) {
1363
1420
  LOG.warn(`[farm:cycle] findNextFarmActionFromManage error: ${e.message}`);
1364
1421
  break;
@@ -1367,6 +1424,23 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
1367
1424
  forcedNextAction = null;
1368
1425
 
1369
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
+ }
1370
1444
  LOG.info(`[farm:cycle:${cycleDepth}] no action found (reason=${reason}) — breaking cycle`);
1371
1445
  break;
1372
1446
  }
@@ -1428,15 +1502,48 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
1428
1502
  label.includes('plant all') || label.includes('harvest all') || label.includes('confirm')) && b.disabled;
1429
1503
  });
1430
1504
  if (disabledAllBtn) {
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);
1431
1509
  const currentIdx = FARM_PHASE_ORDER.indexOf(action);
1432
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.
1521
+ const ITEM_FOR_ACTION = { hoe: 'Hoe', water: 'Watering Can', plant: 'Seeds' };
1522
+ const missingItem = ITEM_FOR_ACTION[action];
1523
+ if (missingItem) {
1524
+ LOG.info(`[farm:cycle:${cycleDepth}] ${action} all-btn disabled (farm not empty) — trying to buy ${missingItem}`);
1525
+ const bought = await tryBuyFarmItem({ missing: missingItem, channel, waitForDankMemer, client });
1526
+ if (bought.ok) {
1527
+ LOG.success(`[farm:cycle:${cycleDepth}] Bought ${bought.itemName} — retrying ${action}`);
1528
+ await sleep(1200);
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
+ }
1535
+ continue;
1536
+ } else {
1537
+ LOG.warn(`[farm:cycle:${cycleDepth}] Could not buy ${missingItem} — skipping to next phase`);
1538
+ }
1539
+ }
1540
+
1433
1541
  LOG.info(`[farm:cycle:${cycleDepth}] ${action} all-btn disabled (no-op) — next=${nextPhase || 'done'}`);
1434
1542
  actionsTaken++;
1435
1543
  lastAction = action;
1436
1544
  forcedNextAction = nextPhase;
1437
1545
  cycleDepth++;
1438
1546
  if (!nextPhase) break;
1439
- initialAnalysis = null;
1440
1547
  await sleep(300);
1441
1548
  continue;
1442
1549
  }
@@ -1453,14 +1560,210 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
1453
1560
  if (!lastApplyResp) { LOG.warn('[farm:cycle] All click returned null'); break; }
1454
1561
 
1455
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
+ });
1456
1570
  cycleResponse = await advancePastConfirmation(lastApplyResp, waitForDankMemer);
1457
1571
  if (!cycleResponse) { LOG.warn('[farm:cycle] confirmation advance returned null'); break; }
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 */ }
1458
1578
  actionsTaken++;
1459
1579
  lastAction = action;
1460
1580
  text = getFullText(cycleResponse);
1461
1581
  clean = brief(text, 600);
1462
1582
  logFarmState('after-confirm', cycleResponse);
1463
1583
 
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.
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) {
1673
+ const postBtns = getAllButtons(cycleResponse);
1674
+ const postActionBtns = postBtns.filter(b => !b.disabled && !isNavOrUtilityButton(b));
1675
+ const postHasActionTabs = postActionBtns.some(b => hasAny(b, ['hoe', 'water', 'plant', 'harvest', 'fertiliz']));
1676
+ const postLower = clean.toLowerCase();
1677
+ const hasSeedsReady = /seeds ready|seeds.*ready|planted|crop.*ready/i.test(postLower);
1678
+ const hasHarvestReady = /ready to harvest|harvest ready|can be harvest|can harvest|wilt/i.test(postLower);
1679
+ const isEmpty = /pretty empty|seems empty|empty\.{0,3}/i.test(postLower) && !hasSeedsReady && !hasHarvestReady;
1680
+ if (!postHasActionTabs && !isEmpty) {
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
+ }
1690
+ // cycleResponse is still the farm view (no action tabs) after advancePastConfirmation.
1691
+ // Re-enter the manage menu so findNextFarmActionFromManage has buttons to work with.
1692
+ const mb = findFarmButton(cycleResponse, ['manage', 'farm-farm:manage']);
1693
+ if (mb) {
1694
+ LOG.info(`[farm:cycle:${cycleDepth}] farm-text: re-entering manage menu to force harvest`);
1695
+ const mr = await clickAndCapture({ channel, waitForDankMemer, response: cycleResponse, button: mb, tag: 'farm-farmtext-harvest-manage' });
1696
+ if (mr) {
1697
+ if (isCV2(mr)) await ensureCV2(mr);
1698
+ cycleResponse = mr;
1699
+ text = getFullText(cycleResponse);
1700
+ clean = brief(text, 600);
1701
+ }
1702
+ }
1703
+ forcedNextAction = 'harvest';
1704
+ lastAction = null;
1705
+ cycleDepth++;
1706
+ await sleep(300);
1707
+ continue;
1708
+ }
1709
+ if (!postHasActionTabs && isEmpty) {
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.
1713
+ const mb = findFarmButton(cycleResponse, ['manage', 'farm-farm:manage']);
1714
+ if (mb) {
1715
+ LOG.info(`[farm:cycle:${cycleDepth}] farm-text: re-entering manage menu (empty farm)`);
1716
+ const mr = await clickAndCapture({ channel, waitForDankMemer, response: cycleResponse, button: mb, tag: 'farm-farmtext-hoe-manage' });
1717
+ if (mr) {
1718
+ if (isCV2(mr)) await ensureCV2(mr);
1719
+ cycleResponse = mr;
1720
+ text = getFullText(cycleResponse);
1721
+ clean = brief(text, 600);
1722
+ }
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
+ }
1734
+ forcedNextAction = 'hoe';
1735
+ lastAction = null;
1736
+ cycleDepth++;
1737
+ await sleep(300);
1738
+ continue;
1739
+ }
1740
+ // Otherwise: we're on manage menu or have action tabs — proceed to find next action.
1741
+ }
1742
+
1743
+ // ── Harvest ephemeral: parse "You harvested: Nothing" to detect no-op ──────────
1744
+ if (action === 'harvest') {
1745
+ const ephemeral = cycleResponse?._capturedEphemeral || lastApplyResp?._capturedEphemeral;
1746
+ if (ephemeral) {
1747
+ const cv2t = ephemeral.cv2Text || '';
1748
+ const allt = ephemeral.allText || '';
1749
+ const harvestText = cv2t || allt;
1750
+ LOG.info(`[farm:cycle:${cycleDepth}:harvest-ephemeral] cv2Text="${cv2t.slice(0,200)}" allText="${allt.slice(0,200)}"`);
1751
+ // If nothing was harvested, cells are now empty (post-harvest debris).
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;
1757
+ }
1758
+ // Something was actually harvested — parse what and log it.
1759
+ const itemMatches = [...harvestText.matchAll(/-?\s*(\d+)\s*x?\s*([A-Za-z]+)/g)];
1760
+ if (itemMatches.length > 0) {
1761
+ const items = itemMatches.map(m => `${m[1]}x ${m[2]}`).join(', ');
1762
+ LOG.info(`[farm:cycle:${cycleDepth}:harvest-ephemeral] Harvested: ${items}`);
1763
+ }
1764
+ }
1765
+ }
1766
+
1464
1767
  // If confirmation screen is identical to post-confirmation screen,
1465
1768
  // check whether we're on a planted confirmation (Dank Memer planted crops
1466
1769
  // and shows "ready at X"). In that case, click "Back" to return to the
@@ -1488,6 +1791,9 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
1488
1791
  logFarmState('after-plant-back', cycleResponse);
1489
1792
  }
1490
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();
1491
1797
  const currentIdx = FARM_PHASE_ORDER.indexOf(action);
1492
1798
  const nextPhase = FARM_PHASE_ORDER[currentIdx + 1] || null;
1493
1799
  LOG.info(`[farm:cycle:${cycleDepth}] plant done, advancing to ${nextPhase || 'done'}`);
@@ -1495,7 +1801,6 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
1495
1801
  if (!nextPhase) break;
1496
1802
  cycleDepth++;
1497
1803
  lastAction = nextPhase;
1498
- initialAnalysis = null;
1499
1804
  await sleep(400);
1500
1805
  continue;
1501
1806
  }
@@ -1507,7 +1812,6 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
1507
1812
  if (!nextPhase) break;
1508
1813
  cycleDepth++;
1509
1814
  lastAction = nextPhase;
1510
- initialAnalysis = null;
1511
1815
  await sleep(300);
1512
1816
  continue;
1513
1817
  }
@@ -1515,34 +1819,44 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
1515
1819
  clean = brief(text, 600);
1516
1820
  logFarmState('after-confirm', cycleResponse);
1517
1821
 
1518
- // Step 6: run image analysis to decide next action
1519
- let imgAnalysis = null;
1520
- try {
1521
- const url = extractFarmImageUrl(cycleResponse);
1522
- if (url) {
1523
- const buf = await downloadImage(url);
1524
- imgAnalysis = await analyzeFarmGrid(buf);
1525
- LOG.info(`[farm:cycle:${cycleDepth}:img] grid=${gridToString(imgAnalysis)} counts=${JSON.stringify(imgAnalysis.counts)} conf=${imgAnalysis.avgConfidence}`);
1526
- }
1527
- } catch (e) {
1528
- LOG.info(`[farm:cycle:${cycleDepth}:img] failed: ${e.message}`);
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
1827
+
1828
+ // Collect all error/rejection text sources:
1829
+ // 1. Extra follow-up messages from farm actions
1830
+ // 2. CV2 interaction ACK (ephemeral or success/error response)
1831
+ // 3. The main message text after the action
1832
+ const ackContent = captureCvvAckContent(lastApplyResp?._capturedAck);
1833
+ const extraContent = lastApplyResp?._farmExtraMsg
1834
+ ? String(stripAnsi(getFullText(lastApplyResp._farmExtraMsg) || '')).replace(/\s+/g, ' ').trim()
1835
+ : '';
1836
+ const postContent = String(stripAnsi(getFullText(lastApplyResp) || '')).replace(/\s+/g, ' ').trim();
1837
+
1838
+ if (ackContent || extraContent) {
1839
+ const flags = lastApplyResp?._capturedAck?.flags ?? 0;
1840
+ const isEphemeral = (flags & 64) !== 0;
1841
+ const level = isEphemeral ? LOG.warn : LOG.info;
1842
+ level(`[farm:cycle:${cycleDepth}:ephemeral] ephemeral=${isEphemeral} ack="${ackContent.slice(0, 300)}" extra="${extraContent.slice(0, 200)}"`);
1529
1843
  }
1530
1844
 
1531
- const scoreResult = await capturePhaseVisionScore({ msg: cycleResponse, actionName: action, phaseTag: `farm-cycle-${action}` });
1532
- const score = scoreResult?.score || null;
1533
- const nextAction = inferNextActionFromScore(action, score);
1845
+ // Build combined error text for rejection detection
1846
+ const allErrorText = [extraContent, ackContent].filter(Boolean).join(' ');
1534
1847
 
1535
1848
  // Handle rejection messages that indicate wrong phase.
1536
- const extraText = String(stripAnsi(getFullText(cycleResponse?._farmExtraInteraction) || '')).toLowerCase();
1537
- if (action === 'plant' && /only\s+plant\s+seeds\s+on\s+an\s+empty\s+tile|tilled\s+and\s+watered/.test(extraText)) {
1538
- LOG.warn('[farm:cycle] Plant rejected — need hoe first. Restarting from hoe.');
1849
+ 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)) {
1850
+ LOG.warn(`[farm:cycle] Plant rejected need hoe+water first. Restarting from hoe. err="${allErrorText.slice(0, 200)}"`);
1851
+ forcedNextAction = 'hoe';
1539
1852
  lastAction = null;
1540
1853
  cycleDepth++;
1541
1854
  await sleep(400);
1542
1855
  continue;
1543
1856
  }
1544
- if (action === 'hoe' && /can only use.*hoe.*empty tile|after a harvest/.test(extraText)) {
1857
+ if (action === 'hoe' && /can only use.*hoe.*empty tile|after a harvest/.test(allErrorText)) {
1545
1858
  LOG.warn('[farm:cycle] Hoe rejected — moving to water phase.');
1859
+ forcedNextAction = 'water';
1546
1860
  lastAction = 'hoe';
1547
1861
  cycleDepth++;
1548
1862
  await sleep(400);
@@ -1551,13 +1865,29 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
1551
1865
 
1552
1866
  if (!nextAction) {
1553
1867
  LOG.info(`[farm:cycle:${cycleDepth}] ${action} done — no next action (score=${score?.score ?? '?'}/${score?.threshold ?? '?'}, conf=${score?.confidence ?? '?'})`);
1868
+ // Enforce strict phase ordering: after water → harvest. Never route to plant from image analysis.
1869
+ if (action === 'water') {
1870
+ const harvestIdx = FARM_PHASE_ORDER.indexOf('harvest');
1871
+ if (harvestIdx >= 0) {
1872
+ const harvestBtn = managedActions.harvest || getAllButtons(cycleResponse).find(b => hasAny(b, ['harvest', 'reap', 'collect']));
1873
+ if (harvestBtn) {
1874
+ LOG.info(`[farm:cycle:${cycleDepth}] enforcing harvest after water (strict phase order)`);
1875
+ forcedNextAction = 'harvest';
1876
+ cycleDepth++;
1877
+ lastAction = 'water';
1878
+
1879
+ await sleep(300);
1880
+ continue;
1881
+ }
1882
+ }
1883
+ }
1554
1884
  break;
1555
1885
  }
1556
1886
 
1557
1887
  LOG.info(`[farm:cycle:${cycleDepth}] next action = ${nextAction} (after ${action})`);
1558
1888
  lastAction = action;
1559
1889
  cycleDepth++;
1560
- initialAnalysis = imgAnalysis; // pass updated analysis to next iteration
1890
+
1561
1891
  await sleep(300);
1562
1892
  }
1563
1893
 
@@ -1597,7 +1927,7 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
1597
1927
  }
1598
1928
  }
1599
1929
 
1600
- await capturePhaseVisionScore({ msg: cycleResponse, actionName: lastAction || 'harvest', phaseTag: 'farm-final' });
1930
+
1601
1931
 
1602
1932
  if (coins > 0) {
1603
1933
  LOG.coin(`[farm] ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`);