dankgrinder 6.25.0 → 6.27.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.
@@ -18,6 +18,50 @@ const RE_TS = /<t:(\d+):R>/;
18
18
  const RE_MIN = /(\d+)\s*minute/i;
19
19
  const RE_HR = /(\d+)\s*hour/i;
20
20
 
21
+ // Redis key helpers for farm state persistence across runs.
22
+ const FARM_KEYS = {
23
+ harvestTime: (accountId) => `dkg:farm:harvested_at:${accountId}`,
24
+ growDuration: (accountId) => `dkg:farm:grow_duration:${accountId}`,
25
+ lastCoins: (accountId) => `dkg:farm:last_coins:${accountId}`,
26
+ };
27
+
28
+ const DEFAULT_GROW_DURATION_MS = 25 * 60 * 1000; // 25 min typical grow time
29
+
30
+ // Stores harvest time in Redis so subsequent runs know when to re-check.
31
+ async function recordHarvestInRedis(redis, accountId, growDurationMs) {
32
+ if (!redis) return;
33
+ try {
34
+ const key = FARM_KEYS.harvestTime(accountId);
35
+ await redis.set(key, String(Date.now()), 'EX', 7200);
36
+ if (growDurationMs) {
37
+ await redis.set(FARM_KEYS.growDuration(accountId), String(growDurationMs), 'EX', 7200);
38
+ }
39
+ } catch {}
40
+ }
41
+
42
+ // Checks Redis for last harvest time. If crops should be ready by now,
43
+ // returns 0 so the cycle starts immediately instead of queuing.
44
+ async function getHarvestRecoveryMs(redis, accountId) {
45
+ if (!redis) return null;
46
+ try {
47
+ const key = FARM_KEYS.harvestTime(accountId);
48
+ const harvestedAt = await redis.get(key);
49
+ if (!harvestedAt) return null;
50
+
51
+ const growDurKey = FARM_KEYS.growDuration(accountId);
52
+ const growDurationMs = parseInt(await redis.get(growDurKey) || '', 10) || DEFAULT_GROW_DURATION_MS;
53
+ const readyAt = parseInt(harvestedAt, 10) + growDurationMs;
54
+ const remaining = readyAt - Date.now();
55
+
56
+ // Crops should be ready — return 0 so we skip the grow queue
57
+ if (remaining <= 0) return 0;
58
+ // Still growing — return remaining ms
59
+ return remaining;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
21
65
  function parseFarmCooldownSec(text) {
22
66
  const clean = String(text || '');
23
67
  const lower = clean.toLowerCase();
@@ -40,43 +84,74 @@ function parseFarmGrowReadySec(text) {
40
84
  const clean = String(stripAnsi(text || ''));
41
85
  const lower = clean.toLowerCase();
42
86
 
87
+ // TEMP DEBUG: log what we're parsing
88
+ const hasHarvestReady = /ready to harvest|harvest ready|can be harvested/i.test(lower);
89
+ const hasWilt = /\bwilt/i.test(lower);
90
+ if (hasHarvestReady || hasWilt) {
91
+ // eslint-disable-next-line no-console
92
+ console.log('[parseFarmGrowReadySec DEBUG] hasHarvestReady=' + hasHarvestReady + ' hasWilt=' + hasWilt + ' clean=' + clean.slice(0, 300));
93
+ }
94
+
43
95
  // If crops are already harvestable, don't queue waiting.
44
- if (/ready to harvest|harvest ready|can be harvested|wilt/.test(lower)) return 0;
96
+ if (/ready to harvest|harvest ready|can be harvested|wilt/i.test(lower)) {
97
+ // eslint-disable-next-line no-console
98
+ console.log('[parseFarmGrowReadySec] MATCH — returning 0 (lower=' + lower.slice(0, 200) + ')');
99
+ return 0;
100
+ }
101
+ // eslint-disable-next-line no-console
102
+ console.log('[parseFarmGrowReadySec] NO MATCH — checking timestamp parse (lower=' + lower.slice(0, 200) + ')');
103
+
104
+ // If the farm is empty / shows plantable / needs tilling, no grow queue needed.
105
+ // This handles the post-harvest state where Dank Memer shows
106
+ // "ready in 26m" for the NEXT planting rather than current crops.
107
+ const farmIsEmpty = /seems? pretty empty|pretty empty|empty\.{0,3}|start.*hoe|plant.*hoe|need.*hoe/i.test(lower);
108
+ if (farmIsEmpty) return null;
45
109
 
46
110
  const now = Math.floor(Date.now() / 1000);
47
111
 
48
- // Prefer explicit Discord relative timestamps that appear on lines with "ready".
49
- const tsNearReady = [];
50
- const re = /<t:(\d+):R>/ig;
51
- let m;
52
- while ((m = re.exec(clean)) !== null) {
53
- const t = parseInt(m[1], 10);
54
- if (!Number.isFinite(t) || t <= now) continue;
55
- const idx = m.index;
56
- const left = clean.slice(Math.max(0, idx - 80), idx).toLowerCase();
57
- const right = clean.slice(idx, Math.min(clean.length, idx + 30)).toLowerCase();
58
- if (/ready|grow|seed/.test(left) || /ready/.test(right)) {
59
- tsNearReady.push(t - now);
112
+ // Only treat timestamps as "growing" if they appear on the SAME LINE as
113
+ // grow/seed/crop context — NOT standalone timestamps that might be the
114
+ // next planting cooldown.
115
+ const lines = clean.split('\n');
116
+ let best = null;
117
+
118
+ for (const line of lines) {
119
+ const lineLower = line.toLowerCase();
120
+ // Only look for timestamps in lines that mention growing context
121
+ const isGrowingLine = /grow|seed|crop|plant|harvest|ripe/i.test(lineLower);
122
+
123
+ const tsMatches = [...line.matchAll(/<t:(\d+):R>/g)];
124
+ for (const match of tsMatches) {
125
+ const t = parseInt(match[1], 10);
126
+ if (!Number.isFinite(t) || t <= now) continue;
127
+ const sec = t - now;
128
+
129
+ // On "growing" lines, accept the timestamp as grow queue
130
+ if (isGrowingLine) {
131
+ if (best === null || sec < best) best = sec;
132
+ }
133
+ }
134
+
135
+ // Text patterns — only on growing context lines
136
+ if (isGrowingLine) {
137
+ for (const re of [
138
+ [/(?:will\s+be\s+)?ready\s+in\s+(\d+)\s*second/i, 1],
139
+ [/(?:will\s+be\s+)?ready\s+in\s+(\d+)\s*minute/i, 60],
140
+ [/(?:will\s+be\s+)?ready\s+in\s+(\d+)\s*hour/i, 3600],
141
+ [/ready\s+in\s+(\d+)\s*second/i, 1],
142
+ [/ready\s+in\s+(\d+)\s*minute/i, 60],
143
+ [/ready\s+in\s+(\d+)\s*hour/i, 3600],
144
+ ]) {
145
+ const m = line.match(re[0]);
146
+ if (m) {
147
+ const sec = Math.max(5, parseInt(m[1], 10) * re[1]);
148
+ if (best === null || sec < best) best = sec;
149
+ }
150
+ }
60
151
  }
61
152
  }
62
- if (tsNearReady.length > 0) return Math.max(5, Math.min(...tsNearReady));
63
-
64
- // Fallback textual patterns.
65
- const willReadyH = clean.match(/(?:will\s+be\s+)?ready\s+in\s+(\d+)\s*hour/i);
66
- if (willReadyH) return Math.max(5, parseInt(willReadyH[1], 10) * 3600);
67
- const willReadyM = clean.match(/(?:will\s+be\s+)?ready\s+in\s+(\d+)\s*minute/i);
68
- if (willReadyM) return Math.max(5, parseInt(willReadyM[1], 10) * 60);
69
- const willReadyS = clean.match(/(?:will\s+be\s+)?ready\s+in\s+(\d+)\s*second/i);
70
- if (willReadyS) return Math.max(5, parseInt(willReadyS[1], 10));
71
-
72
- const h = clean.match(/ready\s+in\s+(\d+)\s*hour/i);
73
- if (h) return Math.max(5, parseInt(h[1], 10) * 3600);
74
- const mi = clean.match(/ready\s+in\s+(\d+)\s*minute/i);
75
- if (mi) return Math.max(5, parseInt(mi[1], 10) * 60);
76
- const s = clean.match(/ready\s+in\s+(\d+)\s*second/i);
77
- if (s) return Math.max(5, parseInt(s[1], 10));
78
153
 
79
- return null;
154
+ return best;
80
155
  }
81
156
 
82
157
  const FARM_RESTOCK_ORDER = ['hoe', 'watering can', 'seeds', 'water bucket'];
@@ -88,6 +163,7 @@ const FARM_BUY_ALIASES = Object.freeze({
88
163
  });
89
164
 
90
165
  const FARM_TOTAL_SLOTS = 9;
166
+ const FARM_PHASE_ORDER = ['hoe', 'water', 'plant', 'harvest'];
91
167
 
92
168
  function parseMissingFarmItem(text) {
93
169
  const lower = String(stripAnsi(text || '')).toLowerCase();
@@ -976,12 +1052,169 @@ async function waitForEditedMessage(channel, messageId, baselineText, timeoutMs
976
1052
  return null;
977
1053
  }
978
1054
 
979
- async function runFarm({ channel, waitForDankMemer, client, _buyRetryDepth = 0, _visionRetryDepth = 0, _preferredAction = null }) {
1055
+ // Advance past confirmation screens that appear after "Apply All".
1056
+ // Dank Memer shows things like "Hoe All (9)" then "Continue" / "Confirm".
1057
+ // Returns the next manage-menu screen (or the last confirmation screen if nothing to click).
1058
+ // Determine the next action to take from the manage menu, given what the
1059
+ // farm image vision says about the current state.
1060
+ // Returns { action, button, reason } where action is null when the cycle is done.
1061
+ async function findNextFarmActionFromManage(msg, text, currentAction, imageAnalysis) {
1062
+ const btns = getAllButtons(msg).filter(b => !b.disabled && !isNavOrUtilityButton(b));
1063
+ const allBtns = getAllButtons(msg); // include disabled for state detection
1064
+ if (btns.length === 0) return { action: null, button: null, reason: 'no-buttons' };
1065
+
1066
+ const managedActions = getManageActionButtons(msg);
1067
+ const lower = String(stripAnsi(text || '')).toLowerCase();
1068
+
1069
+ // If farm is empty, always start with hoe.
1070
+ if (/seems pretty empty|pretty empty|empty\.{0,3}/i.test(lower)) {
1071
+ const btn = managedActions.hoe || btns.find(b => hasAny(b, ['hoe', 'till']));
1072
+ if (btn) return { action: 'hoe', button: btn, reason: 'farm-empty' };
1073
+ }
1074
+
1075
+ // If crops are ready to harvest, go straight to harvest.
1076
+ if (/ready to harvest|harvest ready|can be harvested|wilt/i.test(lower)) {
1077
+ const btn = managedActions.harvest || btns.find(b => hasAny(b, ['harvest', 'reap', 'collect']));
1078
+ if (btn) return { action: 'harvest', button: btn, reason: 'harvest-ready-text' };
1079
+ }
1080
+
1081
+ // Use image vision ratios if available.
1082
+ if (imageAnalysis) {
1083
+ const slots = Math.max(1, (imageAnalysis.rows || 3) * (imageAnalysis.cols || 3));
1084
+ const ratios = {
1085
+ tilled: (imageAnalysis.counts?.tilled || 0) / slots,
1086
+ wet: (imageAnalysis.counts?.wet || 0) / slots,
1087
+ planted: (imageAnalysis.counts?.planted || 0) / slots,
1088
+ unknown: (imageAnalysis.counts?.unknown || 0) / slots,
1089
+ };
1090
+
1091
+ // Phase-aware selection: pick the earliest incomplete phase.
1092
+ // Harvest when planted >= 45%.
1093
+ if (ratios.planted >= 0.45) {
1094
+ const btn = managedActions.harvest || btns.find(b => hasAny(b, ['harvest', 'reap', 'collect']));
1095
+ if (btn) return { action: 'harvest', button: btn, reason: `harvest-ready-vision(planted=${ratios.planted.toFixed(2)})` };
1096
+ }
1097
+ // Plant when wet >= 45% or tilled >= 45%.
1098
+ if (ratios.wet >= 0.45 || ratios.tilled >= 0.45) {
1099
+ const btn = managedActions.plant || btns.find(b => hasAny(b, ['plant', 'seed', 'sow']));
1100
+ if (btn) return { action: 'plant', button: btn, reason: `plant-vision(wet=${ratios.wet.toFixed(2)},tilled=${ratios.tilled.toFixed(2)})` };
1101
+ }
1102
+ // Water when we have tilled tiles but not enough wet.
1103
+ if (ratios.tilled >= 0.35 && ratios.wet < 0.35) {
1104
+ const btn = managedActions.water || btns.find(b => hasAny(b, ['water', 'watering']));
1105
+ if (btn) return { action: 'water', button: btn, reason: `water-vision(tilled=${ratios.tilled.toFixed(2)},wet=${ratios.wet.toFixed(2)})` };
1106
+ }
1107
+ // Hoe when farm is mostly unknown/empty.
1108
+ if (ratios.tilled < 0.35 && ratios.wet < 0.35 && ratios.planted < 0.35) {
1109
+ const btn = managedActions.hoe || btns.find(b => hasAny(b, ['hoe', 'till']));
1110
+ 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)})` };
1111
+ }
1112
+ }
1113
+
1114
+ // Disabled-all detection: if the "All" for a phase is already disabled,
1115
+ // that phase is done — move to the next one.
1116
+ const PHASE_ALL_DISABLED = [
1117
+ { action: 'hoe', word: 'hoe all' },
1118
+ { action: 'water', word: 'water all' },
1119
+ { action: 'plant', word: 'plant all' },
1120
+ { action: 'harvest', word: 'harvest all' },
1121
+ ];
1122
+
1123
+ // Find the first non-disabled action based on its All button state.
1124
+ for (const phase of FARM_PHASE_ORDER) {
1125
+ if (currentAction && FARM_PHASE_ORDER.indexOf(currentAction) >= FARM_PHASE_ORDER.indexOf(phase)) continue;
1126
+ const def = PHASE_ALL_DISABLED.find(p => p.action === phase);
1127
+ if (!def) continue;
1128
+ const allDisabled = allBtns.some(b => b.disabled && buttonHay(b).includes(def.word));
1129
+ if (!allDisabled) {
1130
+ const btn = managedActions[phase];
1131
+ if (btn) return { action: phase, button: btn, reason: `all-enabled-${phase}` };
1132
+ }
1133
+ }
1134
+
1135
+ // Fallback: use the existing priority logic.
1136
+ const picked = pickFarmActionButton(msg, text);
1137
+ if (picked?.button) return { action: picked.action, button: picked.button, reason: 'fallback-priority' };
1138
+
1139
+ return { action: null, button: null, reason: 'no-action-found' };
1140
+ }
1141
+
1142
+ async function advancePastConfirmation(response, waitForDankMemer) {
1143
+ if (!response) return null;
1144
+
1145
+ const CONFIRM_WORDS = ['continue', 'confirm', 'confirm all', 'done', 'back to farm', 'close'];
1146
+ const MAX_PAGES = 3;
1147
+
1148
+ for (let page = 0; page < MAX_PAGES; page++) {
1149
+ const btns = getAllButtons(response).filter(b => !b.disabled);
1150
+ const confirmBtn = btns.find(b => CONFIRM_WORDS.some(w => buttonHay(b).includes(w)));
1151
+
1152
+ if (!confirmBtn) {
1153
+ // No more confirmation buttons — return the current screen (should be manage menu)
1154
+ LOG.info(`[farm:confirm] no confirm btn on page ${page}, returning current screen`);
1155
+ return response;
1156
+ }
1157
+
1158
+ LOG.info(`[farm:confirm] page ${page}: clicking "${confirmBtn.label || '-'}"`);
1159
+ await humanDelay(80, 220);
1160
+
1161
+ const baseline = brief(getFullText(response), 400);
1162
+ let clicked;
1163
+ try {
1164
+ clicked = await safeClickButton(response, confirmBtn);
1165
+ logEphemeralLike(`confirm-page-${page}`, clicked);
1166
+ } catch (e) {
1167
+ LOG.warn(`[farm:confirm] click failed on page ${page}: ${e.message}`);
1168
+ return response;
1169
+ }
1170
+
1171
+ let next = clicked || null;
1172
+ if (!next && response.id) {
1173
+ next = await waitForEditedMessage(response.channel || (await response.fetchChannel?.()), response.id, baseline, 8000);
1174
+ }
1175
+ if (!next) {
1176
+ next = await waitForDankMemer(7000);
1177
+ }
1178
+
1179
+ if (!next) {
1180
+ LOG.warn(`[farm:confirm] no response after confirm click page ${page}`);
1181
+ return response;
1182
+ }
1183
+
1184
+ if (isCV2(next)) await ensureCV2(next);
1185
+ logMsg(next, `confirm-page-${page}`);
1186
+ logFarmState(`confirm-page-${page}`, next);
1187
+
1188
+ // If we got the same message back with no changes, stop looping
1189
+ if (next.id === response.id) {
1190
+ const afterText = brief(getFullText(next), 200);
1191
+ const btnsAfter = getAllButtons(next);
1192
+ const hasConfirmBtn = btnsAfter.some(b => CONFIRM_WORDS.some(w => buttonHay(b).includes(w)));
1193
+ if (!hasConfirmBtn) {
1194
+ LOG.info(`[farm:confirm] screen unchanged after page ${page} click, returning`);
1195
+ return next;
1196
+ }
1197
+ }
1198
+
1199
+ response = next;
1200
+ }
1201
+
1202
+ LOG.info(`[farm:confirm] max confirmation pages (${MAX_PAGES}) reached`);
1203
+ return response;
1204
+ }
1205
+
1206
+ // ── Single-cycle farm orchestrator ──────────────────────────────────────────
1207
+ // Sends `pls farm view` once, then completes the full hoe→water→plant→harvest
1208
+ // cycle by looping on the returned manage menu — no additional command sends.
1209
+ async function runFarm({ channel, waitForDankMemer, client, redis, accountId }) {
980
1210
  LOG.cmd(`${c.white}${c.bold}pls farm view${c.reset}`);
981
1211
 
982
1212
  await channel.send('pls farm view');
983
1213
  let response = await waitForDankMemer(12000);
984
1214
 
1215
+ // eslint-disable-next-line no-console
1216
+ console.log('[farm] runFarm entered — response=' + (response ? 'got-msg' : 'null'));
1217
+
985
1218
  if (!response) {
986
1219
  LOG.warn('[farm] No response');
987
1220
  return { result: 'no response', coins: 0, nextCooldownSec: 90 };
@@ -1002,515 +1235,257 @@ async function runFarm({ channel, waitForDankMemer, client, _buyRetryDepth = 0,
1002
1235
  let text = getFullText(response);
1003
1236
  let clean = brief(text, 600);
1004
1237
  const lower = clean.toLowerCase();
1005
- let farmVision = analyzeFarmState({ msg: response, text });
1006
- LOG.info(`[farm:vision] stage=${farmVision.stage}${farmVision.seedStock ? ` seed=${farmVision.seedStock.itemName}:${farmVision.seedStock.qty}` : ''}${farmVision.missing ? ` missing=${farmVision.missing}` : ''}`);
1007
-
1008
- if (farmVision.missing) {
1009
- const earlyMissingItems = [farmVision.missing];
1010
- LOG.warn(`[farm] Missing items: ${c.bold}${earlyMissingItems.join(', ')}${c.reset} — auto-buying...`);
1011
- const boughtItems = [];
1012
- for (const item of earlyMissingItems) {
1013
- const bought = await tryBuyFarmItem({ missing: item, channel, waitForDankMemer, client });
1014
- if (!bought.ok) {
1015
- LOG.warn(`[farm] Could not buy ${item} (insufficient coins or unavailable) — retrying in 1h`);
1016
- return { result: `need ${item} (buy failed)`, coins: 0, nextCooldownSec: 3600, skipReason: 'farm_missing_item' };
1017
- }
1018
- boughtItems.push(bought.itemName);
1019
- }
1020
- LOG.success(`[farm] Bought: ${boughtItems.join(', ')}. Retrying farm flow...`);
1021
- await sleep(1200);
1022
- if (_buyRetryDepth < 1) {
1023
- return runFarm({ channel, waitForDankMemer, client, _buyRetryDepth: _buyRetryDepth + 1, _visionRetryDepth });
1024
- }
1025
- return { result: `auto-bought ${boughtItems.join(', ')}`, coins: 0, nextCooldownSec: 10 };
1026
- }
1027
1238
 
1239
+ // Subcommand required — retry once.
1028
1240
  if (lower.includes('must specify a subcommand')) {
1029
- LOG.warn('[farm] Subcommand required response detected; retrying with "pls farm view"');
1241
+ LOG.warn('[farm] Subcommand required; retrying "pls farm view"');
1030
1242
  await channel.send('pls farm view');
1031
1243
  const retry = await waitForDankMemer(12000);
1032
- if (!retry) return { result: 'no response after farm view retry', coins: 0, nextCooldownSec: 120 };
1244
+ if (!retry) return { result: 'no response after farm view retry', coins: 0, nextCooldownSec: 120 };
1033
1245
  response = retry;
1034
1246
  if (isCV2(response)) await ensureCV2(response);
1035
1247
  logMsg(response, 'farm-retry-view');
1036
1248
  logFarmState('retry-view', response);
1037
- logFarmDeepState('retry-view', response);
1038
1249
  text = getFullText(response);
1039
1250
  clean = brief(text, 600);
1040
- farmVision = analyzeFarmState({ msg: response, text });
1041
1251
  }
1042
1252
 
1043
1253
  const cd = parseFarmCooldownSec(text);
1044
1254
  if (cd || lower.includes('already farmed') || lower.includes('farm again') || lower.includes('on cooldown')) {
1045
- const sec = cd || 10;
1046
- return { result: `farm cooldown (${Math.ceil(sec / 60)}m)`, coins: 0, nextCooldownSec: sec };
1255
+ return { result: `farm cooldown (${Math.ceil((cd || 10) / 60)}m)`, coins: 0, nextCooldownSec: cd || 10 };
1047
1256
  }
1048
1257
 
1049
- // Queue grow phase: if crops are still growing, wait until next ready window.
1258
+ // Queue grow phase — don't enter the cycle if crops are still growing.
1259
+ // But check Redis: if we recently harvested, crops may be ready now even if
1260
+ // the game message shows a long cooldown (Dank Memer plants immediately after harvest).
1261
+ const redisRecoveryMs = await getHarvestRecoveryMs(redis, accountId);
1262
+ const redisRecovered = redisRecoveryMs === 0;
1050
1263
  const growReadySec = parseFarmGrowReadySec(text);
1051
- LOG.info(`[farm] grow-ready parse=${growReadySec == null ? 'none' : `${growReadySec}s`}`);
1052
- if (growReadySec && growReadySec > 20) {
1053
- // Keep image-first diagnostics even while queuing.
1264
+ const textHasHarvestReady = /ready to harvest|harvest ready|can be harvested|wilt/i.test(lower);
1265
+ if (textHasHarvestReady) LOG.info(`[farm] DETECTED harvest-ready in text (growReadySec=${growReadySec})`);
1266
+ if (growReadySec > 0) LOG.info(`[farm] GROW-QUEUE DEBUG: lower=${lower.slice(0, 400)}`);
1267
+ LOG.info(`[farm] grow-ready parse=${growReadySec == null ? 'none' : `${growReadySec}s`} redis_recovery=${redisRecoveryMs != null ? `${redisRecoveryMs}ms` : 'n/a'}`);
1268
+ if (!redisRecovered && growReadySec && growReadySec > 20) {
1054
1269
  await inferPreferredActionFromImage(response);
1055
- const waitSec = Math.min(6 * 3600, growReadySec + 2);
1056
- LOG.info(`[farm] crops growing; queuing harvest check in ${waitSec}s`);
1057
- return {
1058
- result: `farm grow queue (${Math.ceil(waitSec / 60)}m)` ,
1059
- coins: 0,
1060
- nextCooldownSec: waitSec,
1061
- skipReason: 'farm_grow_queue',
1062
- };
1270
+ // Re-check every 30s instead of waiting the full grow duration.
1271
+ // This ensures the next harvest cycle starts as soon as crops are ready.
1272
+ const waitSec = Math.min(30, Math.min(6 * 3600, growReadySec + 2));
1273
+ LOG.info(`[farm] crops growing (~${Math.ceil(growReadySec / 60)}m remaining); re-checking in ${waitSec}s`);
1274
+ return { result: `farm grow queue (${Math.ceil(growReadySec / 60)}m)`, coins: 0, nextCooldownSec: waitSec, skipReason: 'farm_grow_queue' };
1063
1275
  }
1064
1276
 
1065
- // Step 1: go to farm manage mode first (where Hoe/Water/Plant/etc live)
1277
+ // Check for missing items on the initial farm view buy and retry once.
1278
+ // eslint-disable-next-line no-console
1279
+ console.log('[farm] at vision check — text=' + brief(text, 200));
1280
+ const farmVision0 = analyzeFarmState({ msg: response, text });
1281
+ LOG.info(`[farm:vision] stage=${farmVision0.stage}${farmVision0.missing ? ` missing=${farmVision0.missing}` : ''}`);
1282
+ if (farmVision0.missing) {
1283
+ const bought = await tryBuyFarmItem({ missing: farmVision0.missing, channel, waitForDankMemer, client });
1284
+ if (!bought.ok) {
1285
+ return { result: `need ${farmVision0.missing} (buy failed)`, coins: 0, nextCooldownSec: 3600, skipReason: 'farm_missing_item' };
1286
+ }
1287
+ LOG.success(`[farm] Auto-bought ${bought.itemName}. Retrying farm flow...`);
1288
+ await sleep(1200);
1289
+ // Retry once with the same flow.
1290
+ await channel.send('pls farm view');
1291
+ response = await waitForDankMemer(12000);
1292
+ if (!response) return { result: 'no response after retry', coins: 0, nextCooldownSec: 90 };
1293
+ if (isCV2(response)) await ensureCV2(response);
1294
+ text = getFullText(response);
1295
+ clean = brief(text, 600);
1296
+ }
1297
+
1298
+ // ── Open the manage menu ───────────────────────────────────────────────────
1299
+ // eslint-disable-next-line no-console
1300
+ console.log('[farm] at manage-btn check — text length=' + text.length + ' hasManage=' + (findFarmButton(response, ['manage']) ? 'yes' : 'no'));
1066
1301
  const manageBtn = findFarmButton(response, ['manage', 'farm-farm:manage']);
1067
1302
  if (manageBtn) {
1068
1303
  LOG.info('[farm] Opening Manage menu');
1069
1304
  await humanDelay(90, 260);
1070
- try {
1071
- const managed = await clickAndCapture({
1072
- channel,
1073
- waitForDankMemer,
1074
- response,
1075
- button: manageBtn,
1076
- tag: 'farm-manage',
1077
- });
1078
- if (managed) {
1079
- response = managed;
1080
- text = getFullText(response);
1081
- clean = brief(text, 600);
1082
- farmVision = analyzeFarmState({ msg: response, text });
1083
- LOG.info(`[farm:vision] stage=${farmVision.stage}${farmVision.seedStock ? ` seed=${farmVision.seedStock.itemName}:${farmVision.seedStock.qty}` : ''}${farmVision.missing ? ` missing=${farmVision.missing}` : ''}`);
1084
- logFarmDeepState('after-manage', response);
1085
- }
1086
- } catch (e) {
1087
- LOG.error(`[farm] Manage click failed: ${e.message}`);
1088
- }
1089
- }
1090
-
1091
- // Step 2: image-first phase suggestion. If we already know a target action,
1092
- // skip deep multi-action audit to avoid touching every phase every run.
1093
- const imageSuggestedAction = await inferPreferredActionFromImage(response);
1094
- const lowerNow = String(stripAnsi(text || '')).toLowerCase();
1095
- let textSuggestedAction = null;
1096
- if (/seems pretty empty|pretty empty|empty\.{0,3}/i.test(lowerNow)) {
1097
- textSuggestedAction = 'hoe';
1098
- } else if (/ready to harvest|harvest ready|can be harvested|wilt/i.test(lowerNow)) {
1099
- textSuggestedAction = 'harvest';
1100
- }
1101
- const harvestTextReady = /ready to harvest|can be harvested|harvest ready|wilt/i.test(String(stripAnsi(text || '')).toLowerCase());
1102
- const effectivePreferredAction = _preferredAction || textSuggestedAction || (harvestTextReady ? 'harvest' : imageSuggestedAction) || null;
1103
-
1104
- if (!effectivePreferredAction) {
1105
- const audited = await auditManageAndBuildBuyPlan({ response, channel, waitForDankMemer, client });
1106
- if (audited?.response) {
1107
- response = audited.response;
1305
+ const managed = await clickAndCapture({ channel, waitForDankMemer, response, button: manageBtn, tag: 'farm-manage' });
1306
+ if (managed) {
1307
+ response = managed;
1108
1308
  text = getFullText(response);
1109
1309
  clean = brief(text, 600);
1310
+ logFarmDeepState('after-manage', response);
1110
1311
  }
1111
- if ((audited?.buyPlan || []).length > 0) {
1112
- LOG.info(`[farm] audit buy plan: ${(audited.buyPlan || []).map(p => `${p.item} x${p.qty}`).join(', ')}`);
1113
- const batch = await buyItemsBatch({
1114
- channel,
1115
- waitForDankMemer,
1116
- client,
1117
- items: audited.buyPlan.map(s => ({ itemName: s.item, quantity: s.qty })),
1118
- });
1119
-
1120
- if (!batch.ok) {
1121
- const failed = (batch.results || []).find(r => !r.success);
1122
- const fItem = failed?.itemName || audited.buyPlan[0]?.item || 'item';
1123
- LOG.warn(`[farm] Batch buy failed for ${fItem} — retrying in 1h`);
1124
- return { result: `need ${fItem} (buy failed)`, coins: 0, nextCooldownSec: 3600, skipReason: 'farm_audit_restock_failed' };
1125
- }
1126
-
1127
- for (const r of (batch.results || [])) {
1128
- if (r.success) LOG.success(`[farm] Bought ${r.itemName} (${r.reason || 'ok'})`);
1129
- }
1130
- LOG.success('[farm] audit restock complete, retrying farm flow...');
1131
- await sleep(1200);
1132
- if (_buyRetryDepth < 2) {
1133
- return runFarm({ channel, waitForDankMemer, client, _buyRetryDepth: _buyRetryDepth + 1, _visionRetryDepth, _preferredAction: effectivePreferredAction });
1134
- }
1135
- }
1136
- } else {
1137
- LOG.info(`[farm] skipping deep audit; phase-directed flow using action=${effectivePreferredAction}`);
1138
1312
  }
1139
1313
 
1140
- // Step 3: select menu if farm presents crop/options
1141
- const menus = getAllSelectMenus(response);
1142
- if (menus.length > 0) {
1143
- const menu = menus[0];
1144
- const options = (menu.options || []).filter(o => !o.default);
1145
- if (options.length > 0) {
1146
- const pick = options[Math.floor(Math.random() * options.length)];
1147
- LOG.info(`[farm] Selecting option: "${pick.label}"`);
1148
- try {
1149
- await response.selectMenu(menu.customId || menu.custom_id || 0, [pick.value]);
1150
- const upd = await waitForDankMemer(7000);
1151
- if (upd) {
1152
- response = upd;
1153
- if (isCV2(response)) await ensureCV2(response);
1154
- logMsg(response, 'farm-after-select');
1155
- logFarmState('after-select', response);
1156
- logFarmDeepState('after-select', response);
1157
- text = getFullText(response);
1158
- clean = brief(text, 600);
1159
- farmVision = analyzeFarmState({ msg: response, text });
1160
- LOG.info(`[farm:vision] stage=${farmVision.stage}${farmVision.seedStock ? ` seed=${farmVision.seedStock.itemName}:${farmVision.seedStock.qty}` : ''}${farmVision.missing ? ` missing=${farmVision.missing}` : ''}`);
1161
- }
1162
- } catch (e) {
1163
- LOG.error(`[farm] Select failed: ${e.message}`);
1164
- }
1314
+ // ── Initial image analysis to bootstrap cycle entry ────────────────────────
1315
+ let initialAnalysis = null;
1316
+ try {
1317
+ const url = extractFarmImageUrl(response);
1318
+ if (url) {
1319
+ const buf = await downloadImage(url);
1320
+ const grid = await analyzeFarmGrid(buf);
1321
+ initialAnalysis = grid;
1322
+ LOG.info(`[farm] initial image grid=${gridToString(grid)} counts=${JSON.stringify(grid.counts)} conf=${grid.avgConfidence}`);
1165
1323
  }
1324
+ } catch (e) {
1325
+ LOG.info(`[farm] initial image analysis failed: ${e.message}`);
1166
1326
  }
1167
1327
 
1168
- // Step 4: choose and click farm action (harvest/hoe/water/plant/...)
1169
- const countRestock = await restockFromManageCounts({ response, channel, waitForDankMemer, client });
1170
- if (countRestock.failed) {
1171
- return countRestock;
1172
- }
1173
- if (countRestock.boughtAny) {
1174
- LOG.success('[farm] Manage restock complete. Retrying farm flow...');
1175
- await sleep(1200);
1176
- if (_buyRetryDepth < 2) {
1177
- return runFarm({ channel, waitForDankMemer, client, _buyRetryDepth: _buyRetryDepth + 1, _visionRetryDepth });
1178
- }
1179
- }
1328
+ // ── Cycle loop: hoe water plant harvest ─────────────────────────────
1329
+ let cycleDepth = 0;
1330
+ let lastAction = null;
1331
+ let cycleResponse = response;
1332
+ let actionsTaken = 0; // Track how many farm actions were actually executed
1180
1333
 
1181
- const allButtons = getAllButtons(response).filter(b => !b.disabled);
1182
- const missingAfterManage = parseMissingFarmItems(text);
1183
- if (missingAfterManage.length > 0) {
1184
- LOG.warn(`[farm] Missing in manage flow: ${c.bold}${missingAfterManage.join(', ')}${c.reset} — auto-buying...`);
1185
- const boughtItems = [];
1186
- for (const item of missingAfterManage) {
1187
- const bought = await tryBuyFarmItem({ missing: item, channel, waitForDankMemer, client });
1188
- if (!bought.ok) {
1189
- LOG.warn(`[farm] Could not buy ${item} — retrying in 1h`);
1190
- return { result: `need ${item} (buy failed)`, coins: 0, nextCooldownSec: 3600, skipReason: 'farm_missing_item' };
1191
- }
1192
- boughtItems.push(bought.itemName);
1193
- }
1194
- LOG.success(`[farm] Bought: ${boughtItems.join(', ')}. Retrying farm flow...`);
1195
- await sleep(1200);
1196
- if (_buyRetryDepth < 1) {
1197
- return runFarm({ channel, waitForDankMemer, client, _buyRetryDepth: _buyRetryDepth + 1, _visionRetryDepth });
1334
+ while (cycleDepth < 5) {
1335
+ // eslint-disable-next-line no-console
1336
+ console.log('[farm] cycle iteration ' + cycleDepth + ' — cycleResponse type=' + (cycleResponse && cycleResponse.constructor ? cycleResponse.constructor.name : typeof cycleResponse));
1337
+ let actionResult;
1338
+ try {
1339
+ actionResult = await findNextFarmActionFromManage(cycleResponse, text, lastAction, initialAnalysis);
1340
+ } catch (e) {
1341
+ // eslint-disable-next-line no-console
1342
+ console.log('[farm] findNextFarmActionFromManage ERROR:', e.message);
1343
+ break;
1198
1344
  }
1199
- return { result: `auto-bought ${boughtItems.join(', ')}`, coins: 0, nextCooldownSec: 10 };
1200
- }
1345
+ const { action, button, reason } = actionResult;
1346
+ // eslint-disable-next-line no-console
1347
+ console.log('[farm] findNextFarmActionFromManage result: action=' + action + ' reason=' + reason);
1201
1348
 
1202
- const preferredActions = getManageActionButtons(response);
1203
- const preferredBtn = effectivePreferredAction ? preferredActions[effectivePreferredAction] : null;
1204
- const pickedAction = preferredBtn
1205
- ? { action: effectivePreferredAction, button: preferredBtn }
1206
- : pickFarmActionButton(response, text);
1207
- const actionBtn = pickedAction?.button || allButtons.find(isFarmActionButton) || null;
1208
- const actionName = pickedAction?.action || 'action';
1209
- LOG.info(`[farm] action decision actionName=${actionName} preferred=${effectivePreferredAction || '-'} btn="${actionBtn?.label || '-'}"`);
1210
-
1211
- if (!actionBtn) {
1212
- const labels = allButtons.map(b => (b.label || '-')).filter(Boolean);
1213
- const mgmtOnly = allButtons.length > 0 && allButtons.every(isFarmManagementButton);
1214
- if (labels.length > 0) {
1215
- LOG.info(`[farm] No actionable farm button found. visible=[${labels.join(', ')}]`);
1216
- }
1217
- const fallbackCd = mgmtOnly ? 300 : 60;
1218
- return {
1219
- result: mgmtOnly ? 'farm setup/management only (no crop actions yet)' : (clean || 'farm no actionable buttons'),
1220
- coins: 0,
1221
- nextCooldownSec: fallbackCd,
1222
- };
1223
- }
1349
+ if (!action || !button) {
1350
+ LOG.info(`[farm:cycle:${cycleDepth}] no action found (reason=${reason}) breaking cycle`);
1351
+ break;
1352
+ }
1224
1353
 
1225
- let phaseScorePreApply = null;
1354
+ LOG.info(`[farm:cycle:${cycleDepth}] action=${action} reason=${reason} btn="${button.label || '-'}"`);
1226
1355
 
1227
- LOG.info(`[farm] Clicking ${actionName}: "${actionBtn.label || '?'}"`);
1228
- await humanDelay(100, 280);
1229
- try {
1230
- const post = await clickAndCapture({
1231
- channel,
1232
- waitForDankMemer,
1233
- response,
1234
- button: actionBtn,
1235
- tag: 'farm-followup',
1236
- timeoutMs: 10000,
1356
+ // Step 1: click the action tab (Hoe / Water / Plant / Harvest)
1357
+ const actionResp = await clickAndCapture({
1358
+ channel, waitForDankMemer, response: cycleResponse,
1359
+ button, tag: `farm-cycle-${action}`, timeoutMs: 10000,
1237
1360
  });
1238
- if (post) {
1239
- response = post;
1240
- text = getFullText(response);
1241
- clean = brief(text, 600);
1242
- farmVision = analyzeFarmState({ msg: response, text });
1243
- LOG.info(`[farm:vision] stage=${farmVision.stage}${farmVision.seedStock ? ` seed=${farmVision.seedStock.itemName}:${farmVision.seedStock.qty}` : ''}${farmVision.missing ? ` missing=${farmVision.missing}` : ''}`);
1244
- logFarmDeepState('after-action', response);
1245
-
1246
- // Phase checkpoint #1: image score before applying "All".
1247
- phaseScorePreApply = await capturePhaseVisionScore({
1248
- msg: response,
1249
- actionName,
1250
- phaseTag: `${actionName}-pre-apply`,
1251
- });
1361
+ if (!actionResp) { LOG.warn('[farm:cycle] action click returned null'); break; }
1362
+ cycleResponse = actionResp;
1363
+ text = getFullText(cycleResponse);
1364
+ clean = brief(text, 600);
1365
+ logFarmState(`after-${action}-tab`, cycleResponse);
1366
+
1367
+ // Step 2: buy missing items before applying
1368
+ const missing = parseMissingFarmItem(text);
1369
+ if (missing) {
1370
+ const ok = await tryBuyFarmItem({ missing, channel, waitForDankMemer, client });
1371
+ if (!ok) {
1372
+ return { result: `need ${missing} (buy failed)`, coins: 0, nextCooldownSec: 3600, skipReason: 'farm_missing_item' };
1373
+ }
1374
+ LOG.success(`[farm] Bought ${missing}. Retrying cycle...`);
1375
+ await sleep(1200);
1376
+ // Restart the whole cycle after buying
1377
+ await channel.send('pls farm view');
1378
+ const re = await waitForDankMemer(12000);
1379
+ if (!re) return { result: 'no response after restock retry', coins: 0, nextCooldownSec: 90 };
1380
+ if (isCV2(re)) await ensureCV2(re);
1381
+ const mb = findFarmButton(re, ['manage', 'farm-farm:manage']);
1382
+ if (mb) {
1383
+ const mr = await clickAndCapture({ channel, waitForDankMemer, response: re, button: mb, tag: 'farm-manage-restock' });
1384
+ if (mr) { cycleResponse = mr; text = getFullText(cycleResponse); clean = brief(text, 600); }
1385
+ } else { cycleResponse = re; text = getFullText(cycleResponse); clean = brief(text, 600); }
1252
1386
  }
1253
- } catch (e) {
1254
- LOG.error(`[farm] Click failed: ${e.message}`);
1255
- }
1256
1387
 
1257
- // Seed top-up: if we know selected seed quantity and it's below 9 slots, buy only deficit.
1258
- if (actionName === 'plant') {
1259
- const seedInfo = farmVision.seedStock;
1260
- if (seedInfo && Number.isFinite(seedInfo.qty)) {
1261
- const deficit = Math.max(0, FARM_TOTAL_SLOTS - seedInfo.qty);
1262
- if (deficit > 0) {
1263
- LOG.warn(`[farm] ${seedInfo.itemName}: have ${seedInfo.qty}, need ${FARM_TOTAL_SLOTS} → buying ${deficit}`);
1264
- const bought = await buyItem({ channel, waitForDankMemer, client, itemName: seedInfo.itemName, quantity: deficit });
1265
- if (!bought) {
1266
- LOG.warn(`[farm] Could not buy ${deficit}x ${seedInfo.itemName} — retrying in 1h`);
1267
- return { result: `need ${seedInfo.itemName} +${deficit} (buy failed)`, coins: 0, nextCooldownSec: 3600, skipReason: 'farm_seed_deficit' };
1268
- }
1269
- LOG.success(`[farm] Bought ${deficit}x ${seedInfo.itemName}. Retrying farm flow...`);
1270
- await sleep(1200);
1271
- if (_buyRetryDepth < 2) {
1272
- return runFarm({ channel, waitForDankMemer, client, _buyRetryDepth: _buyRetryDepth + 1, _visionRetryDepth });
1273
- }
1274
- }
1388
+ // Step 3: seed selection for plant phase
1389
+ if (action === 'plant') {
1390
+ const seedPick = await ensurePlantSeedSelected({ response: cycleResponse, waitForDankMemer, channel });
1391
+ if (seedPick?.response) { cycleResponse = seedPick.response; text = getFullText(cycleResponse); clean = brief(text, 600); }
1392
+ LOG.info(`[farm:cycle:${cycleDepth}] plant seed selection selected=${!!seedPick?.selected} reason=${seedPick?.reason || 'unknown'}`);
1275
1393
  }
1276
- }
1277
1394
 
1278
- // Step 5: in plant phase, choose a seed in dropdown first (enables Plant All in many states)
1279
- if (actionName === 'plant') {
1280
- const seedPick = await ensurePlantSeedSelected({ response, waitForDankMemer, channel });
1281
- if (seedPick?.response) response = seedPick.response;
1282
- LOG.info(`[farm:plant] seed selection result selected=${!!seedPick?.selected} reason=${seedPick?.reason || 'unknown'}`);
1283
- }
1395
+ // Step 4: click the "All" button to apply to all tiles
1396
+ const allBtn = findFarmButton(cycleResponse, [
1397
+ ' all', 'all ', 'all)', 'all:', ':all',
1398
+ 'hoe all', 'water all', 'plant all', 'harvest all', 'confirm all',
1399
+ ]);
1400
+ if (!allBtn) {
1401
+ LOG.info(`[farm:cycle:${cycleDepth}] no All button for ${action} — breaking cycle`);
1402
+ break;
1403
+ }
1284
1404
 
1285
- // Step 6: for action prompts, click ALL to apply on all tiles (preferred over pick slots)
1286
- const allBtn = findFarmButton(response, [
1287
- ' all', 'all ', 'all)', 'all:', ':all', 'hoe all', 'water all', 'plant all', 'harvest all', 'confirm all',
1288
- ]);
1289
- const pickSlotsBtn = findFarmButton(response, ['pick slot', 'pick slots']);
1290
- const disabledAllBtn = getDisabledAllButtonForAction(response, actionName);
1291
- LOG.info(`[farm] apply decision allBtn="${allBtn?.label || '-'}" pickSlots="${pickSlotsBtn?.label || '-'}"`);
1292
- if (allBtn) {
1293
- LOG.info(`[farm] Applying action with "${allBtn.label || '?'}"`);
1405
+ LOG.info(`[farm:cycle:${cycleDepth}] applying ${action} with "${allBtn.label || '?'}"`);
1294
1406
  await humanDelay(90, 260);
1295
- try {
1296
- const applied = await clickAndCapture({
1297
- channel,
1298
- waitForDankMemer,
1299
- response,
1300
- button: allBtn,
1301
- tag: 'farm-apply-all',
1302
- });
1303
- if (applied) {
1304
- response = applied;
1305
- text = getFullText(response);
1306
- clean = brief(text, 600);
1307
- farmVision = analyzeFarmState({ msg: response, text });
1308
- LOG.info(`[farm:vision] stage=${farmVision.stage}${farmVision.seedStock ? ` seed=${farmVision.seedStock.itemName}:${farmVision.seedStock.qty}` : ''}${farmVision.missing ? ` missing=${farmVision.missing}` : ''}`);
1309
- logFarmDeepState('after-apply', response);
1310
-
1311
- // Image + score verification: repeat once and repair missing tools/seeds if score still fails.
1312
- if (['hoe', 'water', 'plant', 'harvest'].includes(actionName)) {
1313
- const imgCheck = await capturePhaseVisionScore({
1314
- msg: response,
1315
- actionName,
1316
- phaseTag: `${actionName}-post-apply`,
1317
- });
1318
- const score = imgCheck.score || null;
1319
- const scoreMismatch = !!(score && !score.matched);
1320
- const nextActionFromScore = inferNextActionFromScore(actionName, score);
1321
-
1322
- if (scoreMismatch) {
1323
- LOG.warn(`[farm:vision-score] action=${actionName} score=${score.score}/${score.threshold} conf=${score.confidence} reason=${score.reason} ratios=${JSON.stringify(score.ratios)}`);
1324
- }
1325
-
1326
- if (nextActionFromScore && _visionRetryDepth < 12) {
1327
- LOG.info(`[farm] Vision suggests switching phase (${actionName} -> ${nextActionFromScore}) instead of retrying same action (depth=${_visionRetryDepth})`);
1328
- await sleep(700);
1329
- return runFarm({
1330
- channel,
1331
- waitForDankMemer,
1332
- client,
1333
- _buyRetryDepth,
1334
- _visionRetryDepth: _visionRetryDepth + 1,
1335
- _preferredAction: nextActionFromScore,
1336
- });
1337
- }
1338
-
1339
- if (imgCheck.shouldRepeat || scoreMismatch) {
1340
- const retryAllBtn = findFarmButton(response, [
1341
- ' all', 'all ', 'all)', 'all:', ':all',
1342
- `${actionName} all`,
1343
- 'hoe all', 'water all', 'plant all', 'harvest all',
1344
- ]);
1345
- if (retryAllBtn) {
1346
- LOG.info(`[farm] image/score check says incomplete; re-applying with "${retryAllBtn.label || '?'}"`);
1347
- const reapplied = await clickAndCapture({
1348
- channel,
1349
- waitForDankMemer,
1350
- response,
1351
- button: retryAllBtn,
1352
- tag: 'farm-apply-all-repeat',
1353
- });
1354
- if (reapplied) {
1355
- response = reapplied;
1356
- text = getFullText(response);
1357
- clean = brief(text, 600);
1358
- farmVision = analyzeFarmState({ msg: response, text });
1359
- logFarmDeepState('after-apply-repeat', response);
1360
-
1361
- const imgCheck2 = await capturePhaseVisionScore({
1362
- msg: response,
1363
- actionName,
1364
- phaseTag: `${actionName}-post-repeat`,
1365
- });
1366
- const score2 = imgCheck2.score || null;
1367
- if (score2 && !score2.matched) {
1368
- LOG.warn(`[farm:vision-score] post-repeat mismatch action=${actionName} score=${score2.score}/${score2.threshold} conf=${score2.confidence} reason=${score2.reason}`);
1369
-
1370
- if (actionName === 'hoe') {
1371
- const tilled2 = score2.ratios?.tilled || 0;
1372
- const progressed2 = (score2.ratios?.planted || 0) + (score2.ratios?.wet || 0);
1373
- if (tilled2 < 0.6 && progressed2 < 0.25) {
1374
- LOG.warn('[farm] Hoe did not till the whole farm; hoe is likely broken or missing.');
1375
- return {
1376
- result: 'need hoe (hoe not tilling whole farm)',
1377
- coins: 0,
1378
- nextCooldownSec: 3600,
1379
- skipReason: 'farm_hoe_broken',
1380
- };
1381
- }
1382
- }
1383
-
1384
- const nextAction = inferNextActionFromScore(actionName, score2);
1385
- if (nextAction && _visionRetryDepth < 12) {
1386
- LOG.info(`[farm] Vision indicates phase already advanced (${actionName} -> ${nextAction}); retrying with preferred action ${nextAction} (depth=${_visionRetryDepth})`);
1387
- await sleep(700);
1388
- return runFarm({
1389
- channel,
1390
- waitForDankMemer,
1391
- client,
1392
- _buyRetryDepth,
1393
- _visionRetryDepth: _visionRetryDepth + 1,
1394
- _preferredAction: nextAction,
1395
- });
1396
- }
1397
-
1398
- const repair = getVisionRepairPlan(actionName, score2);
1399
- if (repair && _visionRetryDepth < 5) {
1400
- LOG.warn(`[farm] Vision mismatch repair: buying ${repair.label} x${repair.quantity} and retrying`);
1401
- const bought = await buyItem({
1402
- channel,
1403
- waitForDankMemer,
1404
- client,
1405
- itemName: repair.itemName,
1406
- quantity: repair.quantity,
1407
- });
1408
- if (!bought) {
1409
- LOG.warn(`[farm] Could not buy vision-repair item ${repair.label} x${repair.quantity} — retrying in 1h`);
1410
- return {
1411
- result: `need ${repair.label} x${repair.quantity} (vision mismatch buy failed)`,
1412
- coins: 0,
1413
- nextCooldownSec: 3600,
1414
- skipReason: 'farm_vision_score_repair_failed',
1415
- };
1416
- }
1417
- await sleep(1100);
1418
- return runFarm({
1419
- channel,
1420
- waitForDankMemer,
1421
- client,
1422
- _buyRetryDepth: Math.min(_buyRetryDepth + 1, 2),
1423
- _visionRetryDepth: _visionRetryDepth + 1,
1424
- _preferredAction: null,
1425
- });
1426
- }
1427
- }
1428
- }
1429
- }
1430
- }
1431
- }
1432
-
1433
- const extraText = String(stripAnsi(getFullText(response?._farmExtraInteraction) || response?._farmExtraInteraction?.content || '')).toLowerCase();
1434
- if (actionName === 'plant' && /only\s+plant\s+seeds\s+on\s+an\s+empty\s+tile|tilled\s+and\s+watered/.test(extraText)) {
1435
- LOG.warn('[farm] Plant was rejected by Dank (needs empty+tilled+watered). Restarting phase flow at Hoe.');
1436
- if (_visionRetryDepth < 12) {
1437
- await sleep(700);
1438
- return runFarm({
1439
- channel,
1440
- waitForDankMemer,
1441
- client,
1442
- _buyRetryDepth,
1443
- _visionRetryDepth: _visionRetryDepth + 1,
1444
- _preferredAction: 'hoe',
1445
- });
1446
- }
1447
- }
1407
+ const applyResp = await clickAndCapture({
1408
+ channel, waitForDankMemer, response: cycleResponse,
1409
+ button: allBtn, tag: `farm-cycle-${action}-apply`,
1410
+ });
1411
+ if (!applyResp) { LOG.warn('[farm:cycle] All click returned null'); break; }
1412
+
1413
+ // Step 5: advance past any confirmation screens back to the manage menu
1414
+ // Capture the confirmation screen text so we can detect no-ops.
1415
+ const confirmationText = brief(getFullText(applyResp), 200);
1416
+ cycleResponse = await advancePastConfirmation(applyResp, waitForDankMemer);
1417
+ if (!cycleResponse) { LOG.warn('[farm:cycle] confirmation advance returned null'); break; }
1418
+ // Action was successfully applied — count it
1419
+ actionsTaken++;
1420
+ lastAction = action;
1421
+
1422
+ // If the confirmation screen text is the same as the post-confirmation text,
1423
+ // the action was a no-op (e.g., hoe-ing already-tilled soil). Advance to the next phase.
1424
+ const afterConfirmText = brief(getFullText(cycleResponse), 200);
1425
+ if (afterConfirmText === confirmationText) {
1426
+ const currentIdx = FARM_PHASE_ORDER.indexOf(action);
1427
+ const nextPhase = FARM_PHASE_ORDER[currentIdx + 1] || null;
1428
+ LOG.info(`[farm:cycle:${cycleDepth}] ${action} was no-op — advancing to ${nextPhase || 'done'}`);
1429
+ text = getFullText(cycleResponse);
1430
+ clean = brief(text, 600);
1431
+ if (!nextPhase) break;
1432
+ cycleDepth++;
1433
+ lastAction = nextPhase;
1434
+ initialAnalysis = null;
1435
+ await sleep(300);
1436
+ continue;
1437
+ }
1438
+ text = getFullText(cycleResponse);
1439
+ clean = brief(text, 600);
1440
+ logFarmState('after-confirm', cycleResponse);
1448
1441
 
1449
- if (actionName === 'hoe' && /can only use .*hoe.*empty tile|after a harvest/.test(extraText)) {
1450
- LOG.warn('[farm] Extra interaction says hoe can only be used on empty tiles; moving to Water phase');
1451
- if (_visionRetryDepth < 5) {
1452
- await sleep(700);
1453
- return runFarm({
1454
- channel,
1455
- waitForDankMemer,
1456
- client,
1457
- _buyRetryDepth,
1458
- _visionRetryDepth: _visionRetryDepth + 1,
1459
- _preferredAction: 'water',
1460
- });
1461
- }
1462
- }
1442
+ // Step 6: run image analysis to decide next action
1443
+ let imgAnalysis = null;
1444
+ try {
1445
+ const url = extractFarmImageUrl(cycleResponse);
1446
+ if (url) {
1447
+ const buf = await downloadImage(url);
1448
+ imgAnalysis = await analyzeFarmGrid(buf);
1449
+ LOG.info(`[farm:cycle:${cycleDepth}:img] grid=${gridToString(imgAnalysis)} counts=${JSON.stringify(imgAnalysis.counts)} conf=${imgAnalysis.avgConfidence}`);
1463
1450
  }
1464
1451
  } catch (e) {
1465
- LOG.error(`[farm] All click failed: ${e.message}`);
1466
- }
1467
- } else if (pickSlotsBtn) {
1468
- if (disabledAllBtn) {
1469
- if (actionName === 'plant') {
1470
- const preScore = phaseScorePreApply?.score || null;
1471
- const plantedRatio = preScore?.ratios?.planted || 0;
1472
- if ((preScore && preScore.matched) || plantedRatio >= 0.6) {
1473
- LOG.info('[farm] Plant All disabled but image shows planted state; skipping seed rebuy and moving to harvest phase');
1474
- if (_visionRetryDepth < 12) {
1475
- await sleep(700);
1476
- return runFarm({
1477
- channel,
1478
- waitForDankMemer,
1479
- client,
1480
- _buyRetryDepth,
1481
- _visionRetryDepth: _visionRetryDepth + 1,
1482
- _preferredAction: 'harvest',
1483
- });
1484
- }
1485
- }
1486
- }
1452
+ LOG.info(`[farm:cycle:${cycleDepth}:img] failed: ${e.message}`);
1453
+ }
1487
1454
 
1488
- LOG.warn(`[farm] ${disabledAllBtn.label} is disabled while Pick Slots exists; inferring missing requirement for ${actionName}`);
1489
- let inferredItem = null;
1490
- if (actionName === 'hoe') inferredItem = 'hoe';
1491
- else if (actionName === 'water') inferredItem = 'watering can';
1492
- else if (actionName === 'plant') inferredItem = 'seeds';
1455
+ const scoreResult = await capturePhaseVisionScore({ msg: cycleResponse, actionName: action, phaseTag: `farm-cycle-${action}` });
1456
+ const score = scoreResult?.score || null;
1457
+ const nextAction = inferNextActionFromScore(action, score);
1458
+
1459
+ // Handle rejection messages that indicate wrong phase.
1460
+ const extraText = String(stripAnsi(getFullText(cycleResponse?._farmExtraInteraction) || '')).toLowerCase();
1461
+ if (action === 'plant' && /only\s+plant\s+seeds\s+on\s+an\s+empty\s+tile|tilled\s+and\s+watered/.test(extraText)) {
1462
+ LOG.warn('[farm:cycle] Plant rejected — need hoe first. Restarting from hoe.');
1463
+ lastAction = null;
1464
+ cycleDepth++;
1465
+ await sleep(400);
1466
+ continue;
1467
+ }
1468
+ if (action === 'hoe' && /can only use.*hoe.*empty tile|after a harvest/.test(extraText)) {
1469
+ LOG.warn('[farm:cycle] Hoe rejected — moving to water phase.');
1470
+ lastAction = 'hoe';
1471
+ cycleDepth++;
1472
+ await sleep(400);
1473
+ continue;
1474
+ }
1493
1475
 
1494
- if (inferredItem) {
1495
- const qty = inferredItem === 'seeds' ? FARM_TOTAL_SLOTS : 1;
1496
- if (actionName === 'hoe') {
1497
- LOG.warn('[farm] Hoe All is not usable / full till failed — hoe is likely broken or missing.');
1498
- }
1499
- const ok = await buyItem({ channel, waitForDankMemer, client, itemName: inferredItem, quantity: qty });
1500
- if (!ok) {
1501
- LOG.warn(`[farm] Could not buy inferred requirement ${inferredItem} x${qty} — retrying in 1h`);
1502
- return { result: `need ${inferredItem} x${qty} (buy failed)`, coins: 0, nextCooldownSec: 3600, skipReason: 'farm_inferred_missing' };
1503
- }
1504
- LOG.success(`[farm] Bought inferred requirement ${inferredItem} x${qty}. Retrying farm flow...`);
1505
- await sleep(1200);
1506
- if (_buyRetryDepth < 2) {
1507
- return runFarm({ channel, waitForDankMemer, client, _buyRetryDepth: _buyRetryDepth + 1, _visionRetryDepth });
1508
- }
1509
- }
1476
+ if (!nextAction) {
1477
+ LOG.info(`[farm:cycle:${cycleDepth}] ${action} done no next action (score=${score?.score ?? '?'}/${score?.threshold ?? '?'}, conf=${score?.confidence ?? '?'})`);
1478
+ break;
1510
1479
  }
1511
- LOG.info('[farm] Only "Pick Slots" available; skipping slot-specific interaction this tick');
1480
+
1481
+ LOG.info(`[farm:cycle:${cycleDepth}] next action = ${nextAction} (after ${action})`);
1482
+ lastAction = action;
1483
+ cycleDepth++;
1484
+ initialAnalysis = imgAnalysis; // pass updated analysis to next iteration
1485
+ await sleep(300);
1512
1486
  }
1513
1487
 
1488
+ // ── Parse final result ─────────────────────────────────────────────────────
1514
1489
  const coins = parseCoins(text);
1515
1490
  let nextCd = parseFarmCooldownSec(text) || 30;
1516
1491
  const growReadyEnd = parseFarmGrowReadySec(text);
@@ -1519,33 +1494,55 @@ async function runFarm({ channel, waitForDankMemer, client, _buyRetryDepth = 0,
1519
1494
  }
1520
1495
  const missingEnd = parseMissingFarmItem(text);
1521
1496
  if (missingEnd) {
1522
- LOG.warn(`[farm] Missing ${c.bold}${missingEnd}${c.reset} after action — will retry in 1h`);
1497
+ LOG.warn(`[farm] Missing ${c.bold}${missingEnd}${c.reset} after cycle — will retry in 1h`);
1523
1498
  return { result: `need ${missingEnd}`, coins: 0, nextCooldownSec: 3600, skipReason: 'farm_missing_item' };
1524
1499
  }
1525
-
1526
1500
  if (coins <= 0) {
1527
- const finalVision = analyzeFarmState({ msg: response, text });
1528
- if (finalVision.stage === 'overview' || finalVision.stage === 'blocked-missing-item') {
1529
- nextCd = Math.max(nextCd, 300);
1530
- }
1531
1501
  if (/seems pretty empty|pretty empty|farm #\d+/i.test(String(stripAnsi(text || '')))) {
1532
1502
  nextCd = Math.max(nextCd, 300);
1533
1503
  }
1534
1504
  }
1535
1505
 
1536
- // Phase checkpoint #final: final image score for this action tick.
1537
- await capturePhaseVisionScore({
1538
- msg: response,
1539
- actionName,
1540
- phaseTag: `${actionName}-final`,
1541
- });
1506
+ await capturePhaseVisionScore({ msg: cycleResponse, actionName: lastAction || 'harvest', phaseTag: 'farm-final' });
1542
1507
 
1543
1508
  if (coins > 0) {
1544
1509
  LOG.coin(`[farm] ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`);
1545
- return { result: `farm ${actionName} +⏣ ${coins.toLocaleString()}`, coins, nextCooldownSec: nextCd };
1510
+ // After a harvest, Dank Memer auto-plants new crops. Record harvest time
1511
+ // in Redis so the next run knows when crops should be ready.
1512
+ // Also: if the farm shows "empty" or "manage" state (no grow timestamp),
1513
+ // set a short re-check window instead of trusting a potentially stale grow queue.
1514
+ const afterHarvestState = analyzeFarmState({ msg: cycleResponse, text });
1515
+ const farmIsHarvested = afterHarvestState.stage === 'overview'
1516
+ || /seems? pretty empty|empty\.{0,3}|hoe|water|plant|harvest/i.test(text);
1517
+ if (farmIsHarvested) {
1518
+ // Farm was harvested and is back to manage/empty state.
1519
+ // Re-check in 30s to start the next cycle (hoe→water→plant→harvest).
1520
+ nextCd = Math.min(nextCd, 30);
1521
+ LOG.info(`[farm] harvest complete (+⏣ ${coins.toLocaleString()}), farm is ${afterHarvestState.stage} — re-checking in 30s`);
1522
+ }
1523
+ // Record in Redis for cross-instance awareness
1524
+ let growDurMs = null;
1525
+ if (Number.isFinite(growReadyEnd) && growReadyEnd > 0) {
1526
+ growDurMs = Math.max(15 * 60 * 1000, growReadyEnd * 1000);
1527
+ }
1528
+ await recordHarvestInRedis(redis, accountId, growDurMs);
1529
+ return { result: `farm harvest → +⏣ ${coins.toLocaleString()}`, coins, nextCooldownSec: nextCd };
1530
+ }
1531
+
1532
+ // No coins earned — determine if the cycle actually did work or the farm was idle.
1533
+ if (actionsTaken > 0) {
1534
+ // Cycle ran but no coins — farm was likely already empty (pre-harvested).
1535
+ // Set short re-check so we catch the next grow cycle.
1536
+ const growEnd = parseFarmGrowReadySec(text);
1537
+ if (growEnd && growEnd > 0) {
1538
+ nextCd = Math.max(nextCd, Math.min(30, growEnd + 2));
1539
+ } else {
1540
+ nextCd = Math.min(nextCd, 30);
1541
+ }
1542
+ return { result: `farm cycle done (${actionsTaken} action${actionsTaken > 1 ? 's' : ''})`, coins: 0, nextCooldownSec: nextCd };
1546
1543
  }
1547
1544
 
1548
- return { result: clean || 'farm done', coins: 0, nextCooldownSec: nextCd };
1545
+ return { result: clean ? `farm ${clean.slice(0, 50)}` : 'farm done', coins: 0, nextCooldownSec: nextCd };
1549
1546
  }
1550
1547
 
1551
1548
  module.exports = { runFarm };