dankgrinder 5.16.0 → 5.19.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.
@@ -0,0 +1,437 @@
1
+ /**
2
+ * Farm Vision — image-based 3x3 farm grid analysis.
3
+ *
4
+ * Similar to fishVision, this computes per-cell features and classifies each
5
+ * tile with score + confidence.
6
+ *
7
+ * States:
8
+ * - tilled : mostly brown soil
9
+ * - wet : blue/dark wet soil signature
10
+ * - planted : strong green crop signature
11
+ * - unknown : low-confidence fallback
12
+ */
13
+
14
+ const sharp = require('sharp');
15
+ const https = require('https');
16
+ const http = require('http');
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ function downloadImage(url) {
21
+ return new Promise((resolve, reject) => {
22
+ const proto = url.startsWith('https') ? https : http;
23
+ const req = proto.get(url, (res) => {
24
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
25
+ return downloadImage(res.headers.location).then(resolve, reject);
26
+ }
27
+ const chunks = [];
28
+ res.on('data', c => chunks.push(c));
29
+ res.on('end', () => resolve(Buffer.concat(chunks)));
30
+ res.on('error', reject);
31
+ });
32
+ req.on('error', reject);
33
+ req.setTimeout(12000, () => { req.destroy(); reject(new Error('farm image download timeout')); });
34
+ });
35
+ }
36
+
37
+ function extractImageUrlFromComponents(components) {
38
+ for (const item of components || []) {
39
+ if (!item) continue;
40
+ const direct = item?.media?.url || item?.media?.proxy_url || item?.media?.proxyURL;
41
+ if (direct) return direct;
42
+
43
+ const allItems = [
44
+ ...(Array.isArray(item?.items) ? item.items : []),
45
+ ...(Array.isArray(item?.data?.items) ? item.data.items : []),
46
+ ];
47
+ for (const it of allItems) {
48
+ const u = it?.media?.url || it?.media?.proxy_url || it?.media?.proxyURL || it?.url || null;
49
+ if (u) return u;
50
+ }
51
+
52
+ if (item.components) {
53
+ const nested = extractImageUrlFromComponents(item.components);
54
+ if (nested) return nested;
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+
60
+ function extractFarmImageUrl(msg) {
61
+ const attachmentUrl = (() => {
62
+ const a = msg?.attachments;
63
+ if (!a) return null;
64
+ if (Array.isArray(a) && a[0]) return a[0].url || a[0].proxyURL || null;
65
+ if (typeof a.values === 'function') {
66
+ const first = a.values().next()?.value;
67
+ return first?.url || first?.proxyURL || null;
68
+ }
69
+ return null;
70
+ })();
71
+ if (attachmentUrl) return attachmentUrl;
72
+
73
+ const embedUrl = msg?.embeds?.[0]?.image?.url || msg?.embeds?.[0]?.thumbnail?.url;
74
+ if (embedUrl) return embedUrl;
75
+
76
+ const cv2Url = extractImageUrlFromComponents(msg?._cv2);
77
+ if (cv2Url) return cv2Url;
78
+
79
+ return extractImageUrlFromComponents(msg?.components);
80
+ }
81
+
82
+ function colorStatsForPixel(r, g, b) {
83
+ const sum = r + g + b;
84
+ const brightness = sum / 3;
85
+ const greenStrong = g > r * 1.12 && g > b * 1.1;
86
+ const blueStrong = b > r * 1.08 && b > g * 1.02;
87
+ const brownish = r > g * 1.03 && g > b * 1.03 && r > 45 && g > 28 && b < 95;
88
+ const dark = brightness < 56;
89
+ const whiteish = brightness > 210 && Math.abs(r - g) < 20 && Math.abs(g - b) < 20;
90
+ return { brightness, greenStrong, blueStrong, brownish, dark, whiteish };
91
+ }
92
+
93
+ function clamp01(n) {
94
+ return Math.max(0, Math.min(1, n));
95
+ }
96
+
97
+ function classifyCell(features) {
98
+ const { greenPct, bluePct, brownPct, darkPct, avgBrightness } = features;
99
+
100
+ // Score bands tuned for Dank farm palette.
101
+ const scorePlanted =
102
+ (greenPct * 1.9) +
103
+ (clamp01((120 - avgBrightness) / 120) * 0.2) -
104
+ (bluePct * 0.4);
105
+
106
+ const scoreWet =
107
+ (bluePct * 1.7) +
108
+ (darkPct * 0.7) +
109
+ (clamp01((105 - avgBrightness) / 105) * 0.15) -
110
+ (greenPct * 0.35);
111
+
112
+ const scoreTilled =
113
+ (brownPct * 1.9) +
114
+ (clamp01((125 - avgBrightness) / 125) * 0.1) -
115
+ (greenPct * 0.25) -
116
+ (bluePct * 0.2);
117
+
118
+ const scored = [
119
+ ['planted', scorePlanted],
120
+ ['wet', scoreWet],
121
+ ['tilled', scoreTilled],
122
+ ].sort((a, b) => b[1] - a[1]);
123
+
124
+ const [bestState, bestScore] = scored[0];
125
+ const secondScore = scored[1][1];
126
+ const margin = bestScore - secondScore;
127
+
128
+ // Confidence from score margin + minimum signal level.
129
+ const signal = Math.max(greenPct, bluePct, brownPct);
130
+ const confidence = clamp01((margin * 2.1) + (signal * 0.6));
131
+
132
+ // Low-signal cells become unknown.
133
+ if (signal < 0.085 || confidence < 0.20) {
134
+ return { state: 'unknown', confidence: +confidence.toFixed(3), scores: { planted: +scorePlanted.toFixed(3), wet: +scoreWet.toFixed(3), tilled: +scoreTilled.toFixed(3) } };
135
+ }
136
+
137
+ return {
138
+ state: bestState,
139
+ confidence: +confidence.toFixed(3),
140
+ scores: {
141
+ planted: +scorePlanted.toFixed(3),
142
+ wet: +scoreWet.toFixed(3),
143
+ tilled: +scoreTilled.toFixed(3),
144
+ },
145
+ };
146
+ }
147
+
148
+ async function analyzeFarmGrid(imgBuffer, cols = 3, rows = 3) {
149
+ const { data, info } = await sharp(imgBuffer).raw().toBuffer({ resolveWithObject: true });
150
+ const { width, height, channels } = info;
151
+ const cellW = Math.floor(width / cols);
152
+ const cellH = Math.floor(height / rows);
153
+
154
+ const cells = [];
155
+ for (let row = 0; row < rows; row++) {
156
+ for (let col = 0; col < cols; col++) {
157
+ const sx = col * cellW;
158
+ const sy = row * cellH;
159
+ const ex = Math.min(sx + cellW, width);
160
+ const ey = Math.min(sy + cellH, height);
161
+
162
+ // Sample center region only (ignore grass borders around each tile).
163
+ const padX = Math.floor((ex - sx) * 0.18);
164
+ const padY = Math.floor((ey - sy) * 0.18);
165
+ const csx = sx + padX;
166
+ const csy = sy + padY;
167
+ const cex = ex - padX;
168
+ const cey = ey - padY;
169
+
170
+ let total = 0;
171
+ let greenPx = 0;
172
+ let bluePx = 0;
173
+ let brownPx = 0;
174
+ let darkPx = 0;
175
+ let whitePx = 0;
176
+ let brightSum = 0;
177
+
178
+ for (let y = csy; y < cey; y++) {
179
+ for (let x = csx; x < cex; x++) {
180
+ const idx = (y * width + x) * channels;
181
+ const r = data[idx];
182
+ const g = data[idx + 1];
183
+ const b = data[idx + 2];
184
+ const s = colorStatsForPixel(r, g, b);
185
+ total++;
186
+ brightSum += s.brightness;
187
+ if (s.greenStrong) greenPx++;
188
+ if (s.blueStrong) bluePx++;
189
+ if (s.brownish) brownPx++;
190
+ if (s.dark) darkPx++;
191
+ if (s.whiteish) whitePx++;
192
+ }
193
+ }
194
+
195
+ const greenPct = greenPx / total;
196
+ const bluePct = bluePx / total;
197
+ const brownPct = brownPx / total;
198
+ const darkPct = darkPx / total;
199
+ const whitePct = whitePx / total;
200
+ const avgBrightness = brightSum / total;
201
+
202
+ const classified = classifyCell({ greenPct, bluePct, brownPct, darkPct, avgBrightness, whitePct });
203
+
204
+ cells.push({
205
+ row,
206
+ col,
207
+ state: classified.state,
208
+ confidence: classified.confidence,
209
+ scores: classified.scores,
210
+ greenPct: +greenPct.toFixed(3),
211
+ bluePct: +bluePct.toFixed(3),
212
+ brownPct: +brownPct.toFixed(3),
213
+ darkPct: +darkPct.toFixed(3),
214
+ whitePct: +whitePct.toFixed(3),
215
+ avgBrightness: +avgBrightness.toFixed(1),
216
+ });
217
+ }
218
+ }
219
+
220
+ const counts = { tilled: 0, wet: 0, planted: 0, unknown: 0 };
221
+ for (const c of cells) counts[c.state] = (counts[c.state] || 0) + 1;
222
+
223
+ const avgConfidence = cells.length > 0
224
+ ? +(cells.reduce((s, c) => s + (c.confidence || 0), 0) / cells.length).toFixed(3)
225
+ : 0;
226
+
227
+ return { width, height, rows, cols, cells, counts, avgConfidence };
228
+ }
229
+
230
+ function gridToString(analysis) {
231
+ const icon = { tilled: 'T', wet: 'W', planted: 'P', unknown: '?' };
232
+ const rows = [];
233
+ for (let r = 0; r < (analysis?.rows || 0); r++) {
234
+ const line = [];
235
+ for (let c = 0; c < (analysis?.cols || 0); c++) {
236
+ const cell = (analysis?.cells || []).find(x => x.row === r && x.col === c);
237
+ line.push(icon[cell?.state || 'unknown']);
238
+ }
239
+ rows.push(line.join(' '));
240
+ }
241
+ return rows.join(' | ');
242
+ }
243
+
244
+ function evaluateActionNeed(actionName, analysis) {
245
+ const c = analysis?.counts || {};
246
+ const tilled = c.tilled || 0;
247
+ const wet = c.wet || 0;
248
+ const planted = c.planted || 0;
249
+ const unknown = c.unknown || 0;
250
+ const conf = analysis?.avgConfidence || 0;
251
+ const slots = 9;
252
+
253
+ // Avoid aggressive repeats when confidence is too low.
254
+ if (conf < 0.22) return false;
255
+
256
+ // Conservative rules: only request repeat if we have clear evidence.
257
+ if (actionName === 'hoe') {
258
+ return (tilled + wet + planted) < slots && unknown < 6;
259
+ }
260
+ if (actionName === 'water') {
261
+ // If still many plain-tilled (not watered/planted), water likely incomplete.
262
+ return tilled > 0 && (wet + planted) < slots;
263
+ }
264
+ if (actionName === 'plant') {
265
+ return planted < slots && (tilled + wet) > 0;
266
+ }
267
+ if (actionName === 'harvest') {
268
+ // If many planted remain, harvest may still be pending.
269
+ return planted > 0;
270
+ }
271
+
272
+ // Fertilizer optional; do not force repeats.
273
+ return false;
274
+ }
275
+
276
+ function evaluateActionScores(actionName, analysis) {
277
+ const c = analysis?.counts || {};
278
+ const slots = Math.max(1, (analysis?.rows || 3) * (analysis?.cols || 3));
279
+ const tilled = c.tilled || 0;
280
+ const wet = c.wet || 0;
281
+ const planted = c.planted || 0;
282
+ const unknown = c.unknown || 0;
283
+ const conf = analysis?.avgConfidence || 0;
284
+
285
+ const ratios = {
286
+ tilled: tilled / slots,
287
+ wet: wet / slots,
288
+ planted: planted / slots,
289
+ unknown: unknown / slots,
290
+ };
291
+
292
+ const clamp = (n) => Math.max(0, Math.min(1, n));
293
+ const withConf = (base, mult = 0.25) => clamp(base * (0.82 + (conf * mult)));
294
+
295
+ let score = 0;
296
+ let threshold = 0.8;
297
+ let reason = 'ok';
298
+
299
+ if (actionName === 'hoe') {
300
+ // Hoe is considered good if farm has mostly actionable soil/crops and little unknown noise.
301
+ const base = (ratios.tilled * 0.9) + (ratios.wet * 0.52) + (ratios.planted * 0.4) - (ratios.unknown * 0.32);
302
+ score = withConf(base);
303
+ threshold = 0.68;
304
+ if (ratios.unknown > 0.45) reason = 'too_many_unknown_cells';
305
+ else if (ratios.tilled < 0.45 && (ratios.wet + ratios.planted) < 0.35) reason = 'not_enough_tilled_or_progressed_tiles';
306
+ } else if (actionName === 'water') {
307
+ // After watering, we want wet/planted dominance and minimal plain-tilled.
308
+ const base = (ratios.wet * 0.92) + (ratios.planted * 0.34) - (ratios.tilled * 0.48) - (ratios.unknown * 0.22);
309
+ score = withConf(base);
310
+ threshold = 0.7;
311
+ if (ratios.planted >= 0.45) {
312
+ // If many planted tiles are already visible, watering phase is effectively done.
313
+ score = Math.max(score, withConf(0.86));
314
+ reason = 'progressed_to_plant_phase';
315
+ }
316
+ if (ratios.wet < 0.45 && ratios.planted < 0.2) reason = 'not_enough_wet_tiles';
317
+ else if (ratios.tilled > 0.4) reason = 'too_many_dry_tilled_tiles';
318
+ } else if (actionName === 'plant') {
319
+ // Plant stage should end with mostly planted tiles.
320
+ const base = (ratios.planted * 0.96) + (ratios.wet * 0.12) - (ratios.tilled * 0.48) - (ratios.unknown * 0.2);
321
+ score = withConf(base);
322
+ threshold = 0.72;
323
+ if (ratios.planted < 0.6) reason = 'not_enough_planted_tiles';
324
+ } else if (actionName === 'harvest') {
325
+ // Harvest should reduce planted tiles; tilled/wet should dominate after collection.
326
+ const base = ((ratios.tilled + ratios.wet) * 0.86) - (ratios.planted * 0.76) - (ratios.unknown * 0.18);
327
+ score = withConf(base);
328
+ threshold = 0.68;
329
+ if (ratios.planted > 0.34) reason = 'too_many_unharvested_plants';
330
+ } else {
331
+ score = withConf(0.7 - (ratios.unknown * 0.2));
332
+ threshold = 0.7;
333
+ }
334
+
335
+ const matched = conf >= 0.2 && score >= threshold;
336
+
337
+ return {
338
+ action: actionName,
339
+ matched,
340
+ score: +score.toFixed(3),
341
+ threshold: +threshold.toFixed(3),
342
+ confidence: +conf.toFixed(3),
343
+ reason,
344
+ counts: { tilled, wet, planted, unknown, slots },
345
+ ratios: {
346
+ tilled: +ratios.tilled.toFixed(3),
347
+ wet: +ratios.wet.toFixed(3),
348
+ planted: +ratios.planted.toFixed(3),
349
+ unknown: +ratios.unknown.toFixed(3),
350
+ },
351
+ };
352
+ }
353
+
354
+ async function dumpFarmVisionDebug({
355
+ imgBuffer,
356
+ analysis,
357
+ actionName = 'unknown',
358
+ sourceUrl = null,
359
+ }) {
360
+ const root = path.resolve(__dirname, '../../tmp-cv2-dumps/farm-vision');
361
+ const stamp = `${Date.now()}-${actionName}`;
362
+ const dir = path.join(root, stamp);
363
+ fs.mkdirSync(dir, { recursive: true });
364
+
365
+ const sourcePath = path.join(dir, 'source.png');
366
+ await sharp(imgBuffer).png().toFile(sourcePath);
367
+
368
+ const cols = analysis?.cols || 3;
369
+ const rows = analysis?.rows || 3;
370
+ const width = analysis?.width || 0;
371
+ const height = analysis?.height || 0;
372
+ const cellW = Math.floor(width / cols);
373
+ const cellH = Math.floor(height / rows);
374
+
375
+ const tiles = [];
376
+ for (let row = 0; row < rows; row++) {
377
+ for (let col = 0; col < cols; col++) {
378
+ const sx = col * cellW;
379
+ const sy = row * cellH;
380
+ const ex = Math.min(sx + cellW, width);
381
+ const ey = Math.min(sy + cellH, height);
382
+ const w = Math.max(1, ex - sx);
383
+ const h = Math.max(1, ey - sy);
384
+
385
+ const cell = (analysis?.cells || []).find(c => c.row === row && c.col === col) || null;
386
+ const state = cell?.state || 'unknown';
387
+ const conf = Number.isFinite(cell?.confidence) ? cell.confidence : 0;
388
+ const tileName = `tile-r${row + 1}-c${col + 1}-${state}-c${String(conf).replace('.', '_')}.png`;
389
+ const tilePath = path.join(dir, tileName);
390
+
391
+ await sharp(imgBuffer)
392
+ .extract({ left: sx, top: sy, width: w, height: h })
393
+ .png()
394
+ .toFile(tilePath);
395
+
396
+ tiles.push({
397
+ row,
398
+ col,
399
+ state,
400
+ confidence: conf,
401
+ path: tilePath,
402
+ bbox: { x: sx, y: sy, w, h },
403
+ features: cell || null,
404
+ });
405
+ }
406
+ }
407
+
408
+ const manifestPath = path.join(dir, 'manifest.json');
409
+ const manifest = {
410
+ createdAt: new Date().toISOString(),
411
+ actionName,
412
+ sourceUrl,
413
+ image: { width, height, rows, cols },
414
+ counts: analysis?.counts || null,
415
+ avgConfidence: analysis?.avgConfidence ?? null,
416
+ sourcePath,
417
+ tiles,
418
+ };
419
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
420
+
421
+ return {
422
+ dir,
423
+ sourcePath,
424
+ manifestPath,
425
+ tileCount: tiles.length,
426
+ };
427
+ }
428
+
429
+ module.exports = {
430
+ downloadImage,
431
+ extractFarmImageUrl,
432
+ analyzeFarmGrid,
433
+ gridToString,
434
+ evaluateActionNeed,
435
+ evaluateActionScores,
436
+ dumpFarmVisionDebug,
437
+ };
@@ -8,6 +8,7 @@ const { runBeg } = require('./beg');
8
8
  const { runSearch, SAFE_SEARCH_LOCATIONS } = require('./search');
9
9
  const { runCrime, SAFE_CRIME_OPTIONS } = require('./crime');
10
10
  const { runHighLow } = require('./highlow');
11
+ const { runFarm } = require('./farm');
11
12
  const { runHunt } = require('./hunt');
12
13
  const { runDig } = require('./dig');
13
14
  const { runFish, sellAllFish } = require('./fish');
@@ -32,6 +33,7 @@ module.exports = {
32
33
  runSearch,
33
34
  runCrime,
34
35
  runHighLow,
36
+ runFarm,
35
37
  runHunt,
36
38
  runDig,
37
39
  runFish,