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.
- package/lib/commands/farm.js +581 -504
- package/lib/grinder.js +145 -238
- package/package.json +1 -1
package/lib/commands/farm.js
CHANGED
|
@@ -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|
|
|
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
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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,
|
|
550
|
+
return { option: o, qty: m ? parseInt(m[1], 10) : 0, label };
|
|
484
551
|
});
|
|
485
552
|
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1052
|
-
if (
|
|
1053
|
-
|
|
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
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
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
|
-
//
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
const
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
const
|
|
1147
|
-
|
|
1148
|
-
|
|
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
|
-
//
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
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
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
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
|
-
|
|
1200
|
-
|
|
1352
|
+
const { action, button, reason } = actionResult;
|
|
1353
|
+
forcedNextAction = null;
|
|
1201
1354
|
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
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
|
-
|
|
1360
|
+
LOG.info(`[farm:cycle:${cycleDepth}] action=${action} reason=${reason} btn="${button.label || '-'}"`);
|
|
1226
1361
|
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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 (
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
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
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
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
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
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.
|
|
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
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
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
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1625
|
+
return { result: clean ? `farm ${clean.slice(0, 50)}` : 'farm done', coins: 0, nextCooldownSec: nextCd };
|
|
1549
1626
|
}
|
|
1550
1627
|
|
|
1551
1628
|
module.exports = { runFarm };
|