dankgrinder 6.25.0 → 6.34.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();
@@ -41,42 +85,59 @@ function parseFarmGrowReadySec(text) {
41
85
  const lower = clean.toLowerCase();
42
86
 
43
87
  // If crops are already harvestable, don't queue waiting.
44
- if (/ready to harvest|harvest ready|can be harvested|wilt/.test(lower)) return 0;
88
+ if (/ready to harvest|harvest ready|can be harvested|wilts?/i.test(lower)) return 0;
89
+
90
+ // If the farm is empty / shows plantable / needs tilling, no grow queue needed.
91
+ // This handles the post-harvest state where Dank Memer shows
92
+ // "ready in 26m" for the NEXT planting rather than current crops.
93
+ const farmIsEmpty = /seems? pretty empty|pretty empty|empty\.{0,3}|start.*hoe|plant.*hoe|need.*hoe/i.test(lower);
94
+ if (farmIsEmpty) return null;
45
95
 
46
96
  const now = Math.floor(Date.now() / 1000);
47
97
 
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);
98
+ // Only treat timestamps as "growing" if they appear on the SAME LINE as
99
+ // grow/seed/crop context — NOT standalone timestamps that might be the
100
+ // next planting cooldown.
101
+ const lines = clean.split('\n');
102
+ let best = null;
103
+
104
+ for (const line of lines) {
105
+ const lineLower = line.toLowerCase();
106
+ // Only look for timestamps in lines that mention growing context
107
+ const isGrowingLine = /grow|seed|crop|plant|harvest|ripe/i.test(lineLower);
108
+
109
+ const tsMatches = [...line.matchAll(/<t:(\d+):R>/g)];
110
+ for (const match of tsMatches) {
111
+ const t = parseInt(match[1], 10);
112
+ if (!Number.isFinite(t) || t <= now) continue;
113
+ const sec = t - now;
114
+
115
+ // On "growing" lines, accept the timestamp as grow queue
116
+ if (isGrowingLine) {
117
+ if (best === null || sec < best) best = sec;
118
+ }
119
+ }
120
+
121
+ // Text patterns — only on growing context lines
122
+ if (isGrowingLine) {
123
+ for (const re of [
124
+ [/(?:will\s+be\s+)?ready\s+in\s+(\d+)\s*second/i, 1],
125
+ [/(?:will\s+be\s+)?ready\s+in\s+(\d+)\s*minute/i, 60],
126
+ [/(?:will\s+be\s+)?ready\s+in\s+(\d+)\s*hour/i, 3600],
127
+ [/ready\s+in\s+(\d+)\s*second/i, 1],
128
+ [/ready\s+in\s+(\d+)\s*minute/i, 60],
129
+ [/ready\s+in\s+(\d+)\s*hour/i, 3600],
130
+ ]) {
131
+ const m = line.match(re[0]);
132
+ if (m) {
133
+ const sec = Math.max(5, parseInt(m[1], 10) * re[1]);
134
+ if (best === null || sec < best) best = sec;
135
+ }
136
+ }
60
137
  }
61
138
  }
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
139
 
79
- return null;
140
+ return best;
80
141
  }
81
142
 
82
143
  const FARM_RESTOCK_ORDER = ['hoe', 'watering can', 'seeds', 'water bucket'];
@@ -88,6 +149,12 @@ const FARM_BUY_ALIASES = Object.freeze({
88
149
  });
89
150
 
90
151
  const FARM_TOTAL_SLOTS = 9;
152
+ const FARM_PHASE_ORDER = ['hoe', 'water', 'plant', 'harvest'];
153
+ // Seed preference: random among potato/beans group, else carrot, else highest-qty fallback
154
+ const SEED_PRIORITY_GROUPS = [
155
+ ['potato', 'beans', 'bean'], // random pick when available
156
+ ['carrot'], // fallback
157
+ ];
91
158
 
92
159
  function parseMissingFarmItem(text) {
93
160
  const lower = String(stripAnsi(text || '')).toLowerCase();
@@ -480,12 +547,22 @@ function chooseSeedOption(menu) {
480
547
  const withQty = options.map(o => {
481
548
  const label = String(o.label || '');
482
549
  const m = label.match(/\((\d{1,5})\)/);
483
- return { option: o, qty: m ? parseInt(m[1], 10) : 0, isDefault: !!o.default };
550
+ return { option: o, qty: m ? parseInt(m[1], 10) : 0, label };
484
551
  });
485
552
 
486
- const defaultOpt = withQty.find(x => x.isDefault && x.qty > 0);
487
- if (defaultOpt) return defaultOpt.option;
553
+ // Try each priority group in order
554
+ for (const group of SEED_PRIORITY_GROUPS) {
555
+ const matches = withQty.filter(x =>
556
+ x.qty > 0 && group.some(s => x.label.toLowerCase().includes(s))
557
+ );
558
+ if (matches.length > 0) {
559
+ const pick = matches[Math.floor(Math.random() * matches.length)];
560
+ LOG.info(`[farm:plant] seed group=[${group.join('/')}] picked="${pick.label}" qty=${pick.qty}`);
561
+ return pick.option;
562
+ }
563
+ }
488
564
 
565
+ // Fallback: highest qty
489
566
  const best = withQty.sort((a, b) => b.qty - a.qty)[0];
490
567
  return best?.option || options[0];
491
568
  }
@@ -976,7 +1053,175 @@ async function waitForEditedMessage(channel, messageId, baselineText, timeoutMs
976
1053
  return null;
977
1054
  }
978
1055
 
979
- async function runFarm({ channel, waitForDankMemer, client, _buyRetryDepth = 0, _visionRetryDepth = 0, _preferredAction = null }) {
1056
+ // Advance past confirmation screens that appear after "Apply All".
1057
+ // Dank Memer shows things like "Hoe All (9)" then "Continue" / "Confirm".
1058
+ // Returns the next manage-menu screen (or the last confirmation screen if nothing to click).
1059
+ // Determine the next action to take from the manage menu, given what the
1060
+ // farm image vision says about the current state.
1061
+ // Returns { action, button, reason } where action is null when the cycle is done.
1062
+ async function findNextFarmActionFromManage(msg, text, currentAction, imageAnalysis, forcedNextAction) {
1063
+ const btns = getAllButtons(msg).filter(b => !b.disabled && !isNavOrUtilityButton(b));
1064
+ const allBtns = getAllButtons(msg); // include disabled for state detection
1065
+ if (btns.length === 0) return { action: null, button: null, reason: 'no-buttons' };
1066
+
1067
+ const managedActions = getManageActionButtons(msg);
1068
+ const lower = String(stripAnsi(text || '')).toLowerCase();
1069
+
1070
+ // If we were explicitly told to try a specific action (e.g., advancing from hoe→water),
1071
+ // skip the empty-farm default. If that action's button is also disabled,
1072
+ // cascade through the rest of the phase order.
1073
+ if (forcedNextAction) {
1074
+ const phaseOrder = FARM_PHASE_ORDER;
1075
+ const forcedIdx = phaseOrder.indexOf(forcedNextAction);
1076
+ // Try forced action first, then cascade through remaining phases
1077
+ for (let i = forcedIdx; i < phaseOrder.length; i++) {
1078
+ const phase = phaseOrder[i];
1079
+ const btn = managedActions[phase] || btns.find(b => hasAny(b, [phase]));
1080
+ if (btn) return { action: phase, button: btn, reason: `forced(${forcedNextAction}→${phase})` };
1081
+ }
1082
+ }
1083
+
1084
+ // If farm is empty, always start with hoe.
1085
+ if (/seems pretty empty|pretty empty|empty\.{0,3}/i.test(lower)) {
1086
+ const btn = managedActions.hoe || btns.find(b => hasAny(b, ['hoe', 'till']));
1087
+ if (btn) return { action: 'hoe', button: btn, reason: 'farm-empty' };
1088
+ }
1089
+
1090
+ // If crops are ready to harvest, go straight to harvest.
1091
+ if (/ready to harvest|harvest ready|can be harvested|wilt/i.test(lower)) {
1092
+ const btn = managedActions.harvest || btns.find(b => hasAny(b, ['harvest', 'reap', 'collect']));
1093
+ if (btn) return { action: 'harvest', button: btn, reason: 'harvest-ready-text' };
1094
+ }
1095
+
1096
+ // Use image vision ratios if available.
1097
+ if (imageAnalysis) {
1098
+ const slots = Math.max(1, (imageAnalysis.rows || 3) * (imageAnalysis.cols || 3));
1099
+ const ratios = {
1100
+ tilled: (imageAnalysis.counts?.tilled || 0) / slots,
1101
+ wet: (imageAnalysis.counts?.wet || 0) / slots,
1102
+ planted: (imageAnalysis.counts?.planted || 0) / slots,
1103
+ unknown: (imageAnalysis.counts?.unknown || 0) / slots,
1104
+ };
1105
+
1106
+ // Phase-aware selection: pick the earliest incomplete phase.
1107
+ // Harvest when planted >= 45%.
1108
+ if (ratios.planted >= 0.45) {
1109
+ const btn = managedActions.harvest || btns.find(b => hasAny(b, ['harvest', 'reap', 'collect']));
1110
+ if (btn) return { action: 'harvest', button: btn, reason: `harvest-ready-vision(planted=${ratios.planted.toFixed(2)})` };
1111
+ }
1112
+ // Plant when wet >= 45% or tilled >= 45%.
1113
+ if (ratios.wet >= 0.45 || ratios.tilled >= 0.45) {
1114
+ const btn = managedActions.plant || btns.find(b => hasAny(b, ['plant', 'seed', 'sow']));
1115
+ if (btn) return { action: 'plant', button: btn, reason: `plant-vision(wet=${ratios.wet.toFixed(2)},tilled=${ratios.tilled.toFixed(2)})` };
1116
+ }
1117
+ // Water when we have tilled tiles but not enough wet.
1118
+ if (ratios.tilled >= 0.35 && ratios.wet < 0.35) {
1119
+ const btn = managedActions.water || btns.find(b => hasAny(b, ['water', 'watering']));
1120
+ if (btn) return { action: 'water', button: btn, reason: `water-vision(tilled=${ratios.tilled.toFixed(2)},wet=${ratios.wet.toFixed(2)})` };
1121
+ }
1122
+ // Hoe when farm is mostly unknown/empty.
1123
+ if (ratios.tilled < 0.35 && ratios.wet < 0.35 && ratios.planted < 0.35) {
1124
+ const btn = managedActions.hoe || btns.find(b => hasAny(b, ['hoe', 'till']));
1125
+ 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)})` };
1126
+ }
1127
+ }
1128
+
1129
+ // Disabled-all detection: if the "All" for a phase is already disabled,
1130
+ // that phase is done — move to the next one.
1131
+ const PHASE_ALL_DISABLED = [
1132
+ { action: 'hoe', word: 'hoe all' },
1133
+ { action: 'water', word: 'water all' },
1134
+ { action: 'plant', word: 'plant all' },
1135
+ { action: 'harvest', word: 'harvest all' },
1136
+ ];
1137
+
1138
+ // Find the first non-disabled action based on its All button state.
1139
+ for (const phase of FARM_PHASE_ORDER) {
1140
+ if (currentAction && FARM_PHASE_ORDER.indexOf(currentAction) >= FARM_PHASE_ORDER.indexOf(phase)) continue;
1141
+ const def = PHASE_ALL_DISABLED.find(p => p.action === phase);
1142
+ if (!def) continue;
1143
+ const allDisabled = allBtns.some(b => b.disabled && buttonHay(b).includes(def.word));
1144
+ if (!allDisabled) {
1145
+ const btn = managedActions[phase];
1146
+ if (btn) return { action: phase, button: btn, reason: `all-enabled-${phase}` };
1147
+ }
1148
+ }
1149
+
1150
+ // Fallback: use the existing priority logic.
1151
+ const picked = pickFarmActionButton(msg, text);
1152
+ if (picked?.button) return { action: picked.action, button: picked.button, reason: 'fallback-priority' };
1153
+
1154
+ return { action: null, button: null, reason: 'no-action-found' };
1155
+ }
1156
+
1157
+ async function advancePastConfirmation(response, waitForDankMemer) {
1158
+ if (!response) return null;
1159
+
1160
+ const CONFIRM_WORDS = ['continue', 'confirm', 'confirm all', 'done', 'back to farm', 'back', 'close'];
1161
+ const MAX_PAGES = 3;
1162
+
1163
+ for (let page = 0; page < MAX_PAGES; page++) {
1164
+ const btns = getAllButtons(response).filter(b => !b.disabled);
1165
+ const confirmBtn = btns.find(b => CONFIRM_WORDS.some(w => buttonHay(b).includes(w)));
1166
+
1167
+ if (!confirmBtn) {
1168
+ // No more confirmation buttons — return the current screen (should be manage menu)
1169
+ LOG.info(`[farm:confirm] no confirm btn on page ${page}, returning current screen`);
1170
+ return response;
1171
+ }
1172
+
1173
+ LOG.info(`[farm:confirm] page ${page}: clicking "${confirmBtn.label || '-'}"`);
1174
+ await humanDelay(80, 220);
1175
+
1176
+ const baseline = brief(getFullText(response), 400);
1177
+ let clicked;
1178
+ try {
1179
+ clicked = await safeClickButton(response, confirmBtn);
1180
+ logEphemeralLike(`confirm-page-${page}`, clicked);
1181
+ } catch (e) {
1182
+ LOG.warn(`[farm:confirm] click failed on page ${page}: ${e.message}`);
1183
+ return response;
1184
+ }
1185
+
1186
+ let next = clicked || null;
1187
+ if (!next && response.id) {
1188
+ next = await waitForEditedMessage(response.channel || (await response.fetchChannel?.()), response.id, baseline, 8000);
1189
+ }
1190
+ if (!next) {
1191
+ next = await waitForDankMemer(7000);
1192
+ }
1193
+
1194
+ if (!next) {
1195
+ LOG.warn(`[farm:confirm] no response after confirm click page ${page}`);
1196
+ return response;
1197
+ }
1198
+
1199
+ if (isCV2(next)) await ensureCV2(next);
1200
+ logMsg(next, `confirm-page-${page}`);
1201
+ logFarmState(`confirm-page-${page}`, next);
1202
+
1203
+ // If we got the same message back with no changes, stop looping
1204
+ if (next.id === response.id) {
1205
+ const afterText = brief(getFullText(next), 200);
1206
+ const btnsAfter = getAllButtons(next);
1207
+ const hasConfirmBtn = btnsAfter.some(b => CONFIRM_WORDS.some(w => buttonHay(b).includes(w)));
1208
+ if (!hasConfirmBtn) {
1209
+ LOG.info(`[farm:confirm] screen unchanged after page ${page} click, returning`);
1210
+ return next;
1211
+ }
1212
+ }
1213
+
1214
+ response = next;
1215
+ }
1216
+
1217
+ LOG.info(`[farm:confirm] max confirmation pages (${MAX_PAGES}) reached`);
1218
+ return response;
1219
+ }
1220
+
1221
+ // ── Single-cycle farm orchestrator ──────────────────────────────────────────
1222
+ // Sends `pls farm view` once, then completes the full hoe→water→plant→harvest
1223
+ // cycle by looping on the returned manage menu — no additional command sends.
1224
+ async function runFarm({ channel, waitForDankMemer, client, redis, accountId }) {
980
1225
  LOG.cmd(`${c.white}${c.bold}pls farm view${c.reset}`);
981
1226
 
982
1227
  await channel.send('pls farm view');
@@ -1002,550 +1247,382 @@ async function runFarm({ channel, waitForDankMemer, client, _buyRetryDepth = 0,
1002
1247
  let text = getFullText(response);
1003
1248
  let clean = brief(text, 600);
1004
1249
  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
1250
 
1251
+ // Subcommand required — retry once.
1028
1252
  if (lower.includes('must specify a subcommand')) {
1029
- LOG.warn('[farm] Subcommand required response detected; retrying with "pls farm view"');
1253
+ LOG.warn('[farm] Subcommand required; retrying "pls farm view"');
1030
1254
  await channel.send('pls farm view');
1031
1255
  const retry = await waitForDankMemer(12000);
1032
- if (!retry) return { result: 'no response after farm view retry', coins: 0, nextCooldownSec: 120 };
1256
+ if (!retry) return { result: 'no response after farm view retry', coins: 0, nextCooldownSec: 120 };
1033
1257
  response = retry;
1034
1258
  if (isCV2(response)) await ensureCV2(response);
1035
1259
  logMsg(response, 'farm-retry-view');
1036
1260
  logFarmState('retry-view', response);
1037
- logFarmDeepState('retry-view', response);
1038
1261
  text = getFullText(response);
1039
1262
  clean = brief(text, 600);
1040
- farmVision = analyzeFarmState({ msg: response, text });
1041
1263
  }
1042
1264
 
1043
1265
  const cd = parseFarmCooldownSec(text);
1044
1266
  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 };
1267
+ return { result: `farm cooldown (${Math.ceil((cd || 10) / 60)}m)`, coins: 0, nextCooldownSec: cd || 10 };
1047
1268
  }
1048
1269
 
1049
- // Queue grow phase: if crops are still growing, wait until next ready window.
1270
+ // Queue grow phase — don't enter the cycle if crops are still growing.
1271
+ // But check Redis: if we recently harvested, crops may be ready now even if
1272
+ // the game message shows a long cooldown (Dank Memer plants immediately after harvest).
1273
+ const redisRecoveryMs = await getHarvestRecoveryMs(redis, accountId);
1274
+ const redisRecovered = redisRecoveryMs === 0;
1050
1275
  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.
1276
+ const textHasHarvestReady = /ready to harvest|harvest ready|can be harvested|wilts?/i.test(lower);
1277
+ if (textHasHarvestReady) LOG.info(`[farm] DETECTED harvest-ready in text (growReadySec=${growReadySec})`);
1278
+ if (growReadySec > 0) LOG.info(`[farm] GROW-QUEUE DEBUG: lower=${lower.slice(0, 400)}`);
1279
+ LOG.info(`[farm] grow-ready parse=${growReadySec == null ? 'none' : `${growReadySec}s`} redis_recovery=${redisRecoveryMs != null ? `${redisRecoveryMs}ms` : 'n/a'}`);
1280
+ if (!redisRecovered && growReadySec && growReadySec > 20) {
1054
1281
  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
- };
1282
+ // Re-check every 30s instead of waiting the full grow duration.
1283
+ // This ensures the next harvest cycle starts as soon as crops are ready.
1284
+ const waitSec = Math.min(30, Math.min(6 * 3600, growReadySec + 2));
1285
+ LOG.info(`[farm] crops growing (~${Math.ceil(growReadySec / 60)}m remaining); re-checking in ${waitSec}s`);
1286
+ return { result: `farm grow queue (${Math.ceil(growReadySec / 60)}m)`, coins: 0, nextCooldownSec: waitSec, skipReason: 'farm_grow_queue' };
1063
1287
  }
1064
1288
 
1065
- // Step 1: go to farm manage mode first (where Hoe/Water/Plant/etc live)
1289
+ // Check for missing items on the initial farm view buy and retry once.
1290
+ const farmVision0 = analyzeFarmState({ msg: response, text });
1291
+ LOG.info(`[farm:vision] stage=${farmVision0.stage}${farmVision0.missing ? ` missing=${farmVision0.missing}` : ''}`);
1292
+ if (farmVision0.missing) {
1293
+ const bought = await tryBuyFarmItem({ missing: farmVision0.missing, channel, waitForDankMemer, client });
1294
+ if (!bought.ok) {
1295
+ return { result: `need ${farmVision0.missing} (buy failed)`, coins: 0, nextCooldownSec: 3600, skipReason: 'farm_missing_item' };
1296
+ }
1297
+ LOG.success(`[farm] Auto-bought ${bought.itemName}. Retrying farm flow...`);
1298
+ await sleep(1200);
1299
+ // Retry once with the same flow.
1300
+ await channel.send('pls farm view');
1301
+ response = await waitForDankMemer(12000);
1302
+ if (!response) return { result: 'no response after retry', coins: 0, nextCooldownSec: 90 };
1303
+ if (isCV2(response)) await ensureCV2(response);
1304
+ text = getFullText(response);
1305
+ clean = brief(text, 600);
1306
+ }
1307
+
1308
+ // ── Open the manage menu ───────────────────────────────────────────────────
1066
1309
  const manageBtn = findFarmButton(response, ['manage', 'farm-farm:manage']);
1067
1310
  if (manageBtn) {
1068
1311
  LOG.info('[farm] Opening Manage menu');
1069
1312
  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;
1313
+ const managed = await clickAndCapture({ channel, waitForDankMemer, response, button: manageBtn, tag: 'farm-manage' });
1314
+ if (managed) {
1315
+ response = managed;
1108
1316
  text = getFullText(response);
1109
1317
  clean = brief(text, 600);
1318
+ logFarmDeepState('after-manage', response);
1110
1319
  }
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
1320
  }
1139
1321
 
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
- }
1322
+ // ── Initial image analysis to bootstrap cycle entry ────────────────────────
1323
+ let initialAnalysis = null;
1324
+ try {
1325
+ const url = extractFarmImageUrl(response);
1326
+ if (url) {
1327
+ const buf = await downloadImage(url);
1328
+ const grid = await analyzeFarmGrid(buf);
1329
+ initialAnalysis = grid;
1330
+ LOG.info(`[farm] initial image grid=${gridToString(grid)} counts=${JSON.stringify(grid.counts)} conf=${grid.avgConfidence}`);
1165
1331
  }
1332
+ } catch (e) {
1333
+ LOG.info(`[farm] initial image analysis failed: ${e.message}`);
1166
1334
  }
1167
1335
 
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
- }
1336
+ // ── Cycle loop: hoe water plant harvest ─────────────────────────────
1337
+ let cycleDepth = 0;
1338
+ let lastAction = null;
1339
+ let cycleResponse = response;
1340
+ let actionsTaken = 0; // Track how many farm actions were actually executed
1341
+ let forcedNextAction = null; // When advancing a phase, force the next action check
1342
+ let lastApplyResp = null; // Track the last apply response for coin/cooldown parsing
1180
1343
 
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 });
1344
+ while (cycleDepth < 5) {
1345
+ let actionResult;
1346
+ try {
1347
+ actionResult = await findNextFarmActionFromManage(cycleResponse, text, lastAction, initialAnalysis, forcedNextAction);
1348
+ } catch (e) {
1349
+ LOG.warn(`[farm:cycle] findNextFarmActionFromManage error: ${e.message}`);
1350
+ break;
1198
1351
  }
1199
- return { result: `auto-bought ${boughtItems.join(', ')}`, coins: 0, nextCooldownSec: 10 };
1200
- }
1352
+ const { action, button, reason } = actionResult;
1353
+ forcedNextAction = null;
1201
1354
 
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
- }
1355
+ if (!action || !button) {
1356
+ LOG.info(`[farm:cycle:${cycleDepth}] no action found (reason=${reason}) breaking cycle`);
1357
+ break;
1358
+ }
1224
1359
 
1225
- let phaseScorePreApply = null;
1360
+ LOG.info(`[farm:cycle:${cycleDepth}] action=${action} reason=${reason} btn="${button.label || '-'}"`);
1226
1361
 
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,
1362
+ // Step 1: click the action tab (Hoe / Water / Plant / Harvest)
1363
+ const actionResp = await clickAndCapture({
1364
+ channel, waitForDankMemer, response: cycleResponse,
1365
+ button, tag: `farm-cycle-${action}`, timeoutMs: 10000,
1237
1366
  });
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);
1367
+ if (!actionResp) { LOG.warn('[farm:cycle] action click returned null'); break; }
1368
+ cycleResponse = actionResp;
1369
+ text = getFullText(cycleResponse);
1370
+ clean = brief(text, 600);
1371
+ logFarmState(`after-${action}-tab`, cycleResponse);
1372
+
1373
+ // Step 2: buy missing items before applying
1374
+ const missing = parseMissingFarmItem(text);
1375
+ if (missing) {
1376
+ const ok = await tryBuyFarmItem({ missing, channel, waitForDankMemer, client });
1377
+ if (!ok) {
1378
+ return { result: `need ${missing} (buy failed)`, coins: 0, nextCooldownSec: 3600, skipReason: 'farm_missing_item' };
1379
+ }
1380
+ LOG.success(`[farm] Bought ${missing}. Retrying cycle...`);
1381
+ await sleep(1200);
1382
+ // Restart the whole cycle after buying
1383
+ await channel.send('pls farm view');
1384
+ const re = await waitForDankMemer(12000);
1385
+ if (!re) return { result: 'no response after restock retry', coins: 0, nextCooldownSec: 90 };
1386
+ if (isCV2(re)) await ensureCV2(re);
1387
+ const mb = findFarmButton(re, ['manage', 'farm-farm:manage']);
1388
+ if (mb) {
1389
+ const mr = await clickAndCapture({ channel, waitForDankMemer, response: re, button: mb, tag: 'farm-manage-restock' });
1390
+ if (mr) { cycleResponse = mr; text = getFullText(cycleResponse); clean = brief(text, 600); }
1391
+ } else { cycleResponse = re; text = getFullText(cycleResponse); clean = brief(text, 600); }
1392
+ }
1245
1393
 
1246
- // Phase checkpoint #1: image score before applying "All".
1247
- phaseScorePreApply = await capturePhaseVisionScore({
1248
- msg: response,
1249
- actionName,
1250
- phaseTag: `${actionName}-pre-apply`,
1251
- });
1394
+ // Step 3: seed selection for plant phase
1395
+ if (action === 'plant') {
1396
+ const seedPick = await ensurePlantSeedSelected({ response: cycleResponse, waitForDankMemer, channel });
1397
+ if (seedPick?.response) { cycleResponse = seedPick.response; text = getFullText(cycleResponse); clean = brief(text, 600); }
1398
+ LOG.info(`[farm:cycle:${cycleDepth}] plant seed selection selected=${!!seedPick?.selected} reason=${seedPick?.reason || 'unknown'}`);
1252
1399
  }
1253
- } catch (e) {
1254
- LOG.error(`[farm] Click failed: ${e.message}`);
1255
- }
1256
1400
 
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
- }
1401
+ // Step 4: click the "All" button to apply to all tiles
1402
+ const allBtn = findFarmButton(cycleResponse, [
1403
+ ' all', 'all ', 'all)', 'all:', ':all',
1404
+ 'hoe all', 'water all', 'plant all', 'harvest all', 'confirm all',
1405
+ ]);
1406
+
1407
+ // If no enabled "All" button check if it's disabled (farm is empty for this phase).
1408
+ // If disabled, treat as a no-op and set forcedNextAction for the next iteration.
1409
+ if (!allBtn) {
1410
+ const allBtnsInclDisabled = getAllButtons(cycleResponse);
1411
+ const disabledAllBtn = allBtnsInclDisabled.find(b => {
1412
+ const label = String(b.label || '').toLowerCase();
1413
+ return (label.includes('all') || label.includes('hoe all') || label.includes('water all') ||
1414
+ label.includes('plant all') || label.includes('harvest all') || label.includes('confirm')) && b.disabled;
1415
+ });
1416
+ if (disabledAllBtn) {
1417
+ const currentIdx = FARM_PHASE_ORDER.indexOf(action);
1418
+ const nextPhase = FARM_PHASE_ORDER[currentIdx + 1] || null;
1419
+ LOG.info(`[farm:cycle:${cycleDepth}] ${action} all-btn disabled (no-op) — next=${nextPhase || 'done'}`);
1420
+ actionsTaken++;
1421
+ lastAction = action;
1422
+ forcedNextAction = nextPhase;
1423
+ cycleDepth++;
1424
+ if (!nextPhase) break;
1425
+ initialAnalysis = null;
1426
+ await sleep(300);
1427
+ continue;
1274
1428
  }
1429
+ LOG.info(`[farm:cycle:${cycleDepth}] no All button for ${action} — breaking cycle`);
1430
+ break;
1275
1431
  }
1276
- }
1277
-
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
- }
1284
1432
 
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 || '?'}"`);
1433
+ LOG.info(`[farm:cycle:${cycleDepth}] applying ${action} with "${allBtn.label || '?'}"`);
1294
1434
  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
- }
1435
+ lastApplyResp = await clickAndCapture({
1436
+ channel, waitForDankMemer, response: cycleResponse,
1437
+ button: allBtn, tag: `farm-cycle-${action}-apply`,
1438
+ });
1439
+ if (!applyResp) { LOG.warn('[farm:cycle] All click returned null'); break; }
1440
+
1441
+ // Step 5: advance past any confirmation screens back to the manage menu
1442
+ cycleResponse = await advancePastConfirmation(applyResp, waitForDankMemer);
1443
+ if (!cycleResponse) { LOG.warn('[farm:cycle] confirmation advance returned null'); break; }
1444
+ actionsTaken++;
1445
+ lastAction = action;
1446
+ text = getFullText(cycleResponse);
1447
+ clean = brief(text, 600);
1448
+ logFarmState('after-confirm', cycleResponse);
1449
+
1450
+ // If confirmation screen is identical to post-confirmation screen,
1451
+ // check whether we're on a planted confirmation (Dank Memer planted crops
1452
+ // and shows "ready at X"). In that case, click "Back" to return to the
1453
+ // farm view, then continue to harvest.
1454
+ const confirmText = brief(getFullText(lastApplyResp), 200);
1455
+ const afterText = brief(getFullText(cycleResponse), 200);
1456
+ const afterLower = afterText.toLowerCase();
1457
+ const isPlantedConfirmation = /potato|seeds ready|planted|crop.*ready|ready.*\d/i.test(afterLower);
1458
+ const afterBtns = getAllButtons(cycleResponse);
1459
+ const backBtn = afterBtns.find(b => !b.disabled && /back|back to farm/i.test(String(b.label || '').toLowerCase()));
1460
+
1461
+ if (afterText === confirmText) {
1462
+ if (isPlantedConfirmation && backBtn) {
1463
+ // Plant was successful click "Back" to return to farm view, then proceed.
1464
+ LOG.info(`[farm:cycle:${cycleDepth}] plant confirmed (crops planted) — clicking Back`);
1465
+ await humanDelay(80, 200);
1466
+ const backResp = await clickAndCapture({
1467
+ channel, waitForDankMemer, response: cycleResponse,
1468
+ button: backBtn, tag: `farm-cycle-${action}-back`,
1469
+ });
1470
+ if (backResp) {
1471
+ cycleResponse = backResp;
1472
+ text = getFullText(cycleResponse);
1473
+ clean = brief(text, 600);
1474
+ logFarmState('after-plant-back', cycleResponse);
1447
1475
  }
1476
+ // Plant succeeded — advance to harvest.
1477
+ const currentIdx = FARM_PHASE_ORDER.indexOf(action);
1478
+ const nextPhase = FARM_PHASE_ORDER[currentIdx + 1] || null;
1479
+ LOG.info(`[farm:cycle:${cycleDepth}] plant done, advancing to ${nextPhase || 'done'}`);
1480
+ forcedNextAction = nextPhase;
1481
+ if (!nextPhase) break;
1482
+ cycleDepth++;
1483
+ lastAction = nextPhase;
1484
+ initialAnalysis = null;
1485
+ await sleep(400);
1486
+ continue;
1487
+ }
1488
+ // Truly a no-op (e.g., hoe-ing already-tilled soil).
1489
+ const currentIdx = FARM_PHASE_ORDER.indexOf(action);
1490
+ const nextPhase = FARM_PHASE_ORDER[currentIdx + 1] || null;
1491
+ LOG.info(`[farm:cycle:${cycleDepth}] ${action} was no-op — next=${nextPhase || 'done'}`);
1492
+ forcedNextAction = nextPhase;
1493
+ if (!nextPhase) break;
1494
+ cycleDepth++;
1495
+ lastAction = nextPhase;
1496
+ initialAnalysis = null;
1497
+ await sleep(300);
1498
+ continue;
1499
+ }
1500
+ text = getFullText(cycleResponse);
1501
+ clean = brief(text, 600);
1502
+ logFarmState('after-confirm', cycleResponse);
1448
1503
 
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
- }
1504
+ // Step 6: run image analysis to decide next action
1505
+ let imgAnalysis = null;
1506
+ try {
1507
+ const url = extractFarmImageUrl(cycleResponse);
1508
+ if (url) {
1509
+ const buf = await downloadImage(url);
1510
+ imgAnalysis = await analyzeFarmGrid(buf);
1511
+ LOG.info(`[farm:cycle:${cycleDepth}:img] grid=${gridToString(imgAnalysis)} counts=${JSON.stringify(imgAnalysis.counts)} conf=${imgAnalysis.avgConfidence}`);
1463
1512
  }
1464
1513
  } 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
- }
1514
+ LOG.info(`[farm:cycle:${cycleDepth}:img] failed: ${e.message}`);
1515
+ }
1487
1516
 
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';
1517
+ const scoreResult = await capturePhaseVisionScore({ msg: cycleResponse, actionName: action, phaseTag: `farm-cycle-${action}` });
1518
+ const score = scoreResult?.score || null;
1519
+ const nextAction = inferNextActionFromScore(action, score);
1520
+
1521
+ // Handle rejection messages that indicate wrong phase.
1522
+ const extraText = String(stripAnsi(getFullText(cycleResponse?._farmExtraInteraction) || '')).toLowerCase();
1523
+ if (action === 'plant' && /only\s+plant\s+seeds\s+on\s+an\s+empty\s+tile|tilled\s+and\s+watered/.test(extraText)) {
1524
+ LOG.warn('[farm:cycle] Plant rejected — need hoe first. Restarting from hoe.');
1525
+ lastAction = null;
1526
+ cycleDepth++;
1527
+ await sleep(400);
1528
+ continue;
1529
+ }
1530
+ if (action === 'hoe' && /can only use.*hoe.*empty tile|after a harvest/.test(extraText)) {
1531
+ LOG.warn('[farm:cycle] Hoe rejected — moving to water phase.');
1532
+ lastAction = 'hoe';
1533
+ cycleDepth++;
1534
+ await sleep(400);
1535
+ continue;
1536
+ }
1493
1537
 
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
- }
1538
+ if (!nextAction) {
1539
+ LOG.info(`[farm:cycle:${cycleDepth}] ${action} done no next action (score=${score?.score ?? '?'}/${score?.threshold ?? '?'}, conf=${score?.confidence ?? '?'})`);
1540
+ break;
1510
1541
  }
1511
- LOG.info('[farm] Only "Pick Slots" available; skipping slot-specific interaction this tick');
1542
+
1543
+ LOG.info(`[farm:cycle:${cycleDepth}] next action = ${nextAction} (after ${action})`);
1544
+ lastAction = action;
1545
+ cycleDepth++;
1546
+ initialAnalysis = imgAnalysis; // pass updated analysis to next iteration
1547
+ await sleep(300);
1512
1548
  }
1513
1549
 
1514
- const coins = parseCoins(text);
1550
+ // ── Parse final result ─────────────────────────────────────────────────────
1551
+ // If the cycle ended with a planted confirmation (harvest→plant→confirmed),
1552
+ // the coins were shown on lastApplyResp (harvest confirmation). Parse from there too.
1553
+ let coins = parseCoins(text);
1554
+ if (coins <= 0 && lastApplyResp) {
1555
+ const applyCoins = parseCoins(getFullText(lastApplyResp));
1556
+ if (applyCoins > 0) coins = applyCoins;
1557
+ }
1515
1558
  let nextCd = parseFarmCooldownSec(text) || 30;
1516
1559
  const growReadyEnd = parseFarmGrowReadySec(text);
1560
+ // If the final screen is a planted confirmation, use its wilt timestamp as the
1561
+ // grow queue timer — the crops are planted and will be ready at that time.
1562
+ const finalLower = brief(text, 300).toLowerCase();
1563
+ const isFinalPlanted = /potato|seeds ready|planted|crop.*ready|ready.*\d/i.test(finalLower);
1564
+ if (isFinalPlanted && growReadyEnd === null) {
1565
+ // Crops were planted. The wilt timestamp in the planted confirmation tells us
1566
+ // when to harvest next. Set cooldown to that time, capped at a reasonable max.
1567
+ const plantedCd = parseFarmCooldownSec(text);
1568
+ if (plantedCd && plantedCd > 0) {
1569
+ nextCd = Math.max(nextCd, Math.min(6 * 3600, plantedCd + 2));
1570
+ }
1571
+ }
1517
1572
  if (Number.isFinite(growReadyEnd) && growReadyEnd > 0) {
1518
1573
  nextCd = Math.max(nextCd, Math.min(6 * 3600, growReadyEnd + 2));
1519
1574
  }
1520
1575
  const missingEnd = parseMissingFarmItem(text);
1521
1576
  if (missingEnd) {
1522
- LOG.warn(`[farm] Missing ${c.bold}${missingEnd}${c.reset} after action — will retry in 1h`);
1577
+ LOG.warn(`[farm] Missing ${c.bold}${missingEnd}${c.reset} after cycle — will retry in 1h`);
1523
1578
  return { result: `need ${missingEnd}`, coins: 0, nextCooldownSec: 3600, skipReason: 'farm_missing_item' };
1524
1579
  }
1525
-
1526
1580
  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
1581
  if (/seems pretty empty|pretty empty|farm #\d+/i.test(String(stripAnsi(text || '')))) {
1532
1582
  nextCd = Math.max(nextCd, 300);
1533
1583
  }
1534
1584
  }
1535
1585
 
1536
- // Phase checkpoint #final: final image score for this action tick.
1537
- await capturePhaseVisionScore({
1538
- msg: response,
1539
- actionName,
1540
- phaseTag: `${actionName}-final`,
1541
- });
1586
+ await capturePhaseVisionScore({ msg: cycleResponse, actionName: lastAction || 'harvest', phaseTag: 'farm-final' });
1542
1587
 
1543
1588
  if (coins > 0) {
1544
1589
  LOG.coin(`[farm] ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`);
1545
- return { result: `farm ${actionName} +⏣ ${coins.toLocaleString()}`, coins, nextCooldownSec: nextCd };
1590
+ // After a harvest, Dank Memer auto-plants new crops. Record harvest time
1591
+ // in Redis so the next run knows when crops should be ready.
1592
+ // Also: if the farm shows "empty" or "manage" state (no grow timestamp),
1593
+ // set a short re-check window instead of trusting a potentially stale grow queue.
1594
+ const afterHarvestState = analyzeFarmState({ msg: cycleResponse, text });
1595
+ const farmIsHarvested = afterHarvestState.stage === 'overview'
1596
+ || /seems? pretty empty|empty\.{0,3}|hoe|water|plant|harvest/i.test(text);
1597
+ if (farmIsHarvested) {
1598
+ // Farm was harvested and is back to manage/empty state.
1599
+ // Re-check in 30s to start the next cycle (hoe→water→plant→harvest).
1600
+ nextCd = Math.min(nextCd, 30);
1601
+ LOG.info(`[farm] harvest complete (+⏣ ${coins.toLocaleString()}), farm is ${afterHarvestState.stage} — re-checking in 30s`);
1602
+ }
1603
+ // Record in Redis for cross-instance awareness
1604
+ let growDurMs = null;
1605
+ if (Number.isFinite(growReadyEnd) && growReadyEnd > 0) {
1606
+ growDurMs = Math.max(15 * 60 * 1000, growReadyEnd * 1000);
1607
+ }
1608
+ await recordHarvestInRedis(redis, accountId, growDurMs);
1609
+ return { result: `farm harvest → +⏣ ${coins.toLocaleString()}`, coins, nextCooldownSec: nextCd };
1610
+ }
1611
+
1612
+ // No coins earned — determine if the cycle actually did work or the farm was idle.
1613
+ if (actionsTaken > 0) {
1614
+ // Cycle ran but no coins — farm was likely already empty (pre-harvested).
1615
+ // Set short re-check so we catch the next grow cycle.
1616
+ const growEnd = parseFarmGrowReadySec(text);
1617
+ if (growEnd && growEnd > 0) {
1618
+ nextCd = Math.max(nextCd, Math.min(30, growEnd + 2));
1619
+ } else {
1620
+ nextCd = Math.min(nextCd, 30);
1621
+ }
1622
+ return { result: `farm cycle done (${actionsTaken} action${actionsTaken > 1 ? 's' : ''})`, coins: 0, nextCooldownSec: nextCd };
1546
1623
  }
1547
1624
 
1548
- return { result: clean || 'farm done', coins: 0, nextCooldownSec: nextCd };
1625
+ return { result: clean ? `farm ${clean.slice(0, 50)}` : 'farm done', coins: 0, nextCooldownSec: nextCd };
1549
1626
  }
1550
1627
 
1551
1628
  module.exports = { runFarm };