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