dankgrinder 7.1.0 → 7.7.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.
@@ -82,12 +82,11 @@ function extractFarmImageUrl(msg) {
82
82
  function colorStatsForPixel(r, g, b) {
83
83
  const sum = r + g + b;
84
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;
85
+ // Grayscale: removes hue confusion between dusty-brown and green
86
+ const gray = Math.round(r * 0.299 + g * 0.587 + b * 0.114);
87
+ const soil = r >= g && b < 30;
89
88
  const whiteish = brightness > 210 && Math.abs(r - g) < 20 && Math.abs(g - b) < 20;
90
- return { brightness, greenStrong, blueStrong, brownish, dark, whiteish };
89
+ return { brightness, gray, soil, whiteish };
91
90
  }
92
91
 
93
92
  function clamp01(n) {
@@ -95,59 +94,79 @@ function clamp01(n) {
95
94
  }
96
95
 
97
96
  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) } };
97
+ const {
98
+ harvestPct, plantPct, soilPct,
99
+ avgBrightness, grayStd,
100
+ brightGrayPct, darkGrayPct,
101
+ avgSat, avgR, avgG, avgB,
102
+ gMinusR, discordUiPct,
103
+ } = features;
104
+
105
+ // If Discord UI dominates this cell, the stats are meaningless
106
+ if (discordUiPct > 0.5) {
107
+ return { state: 'unknown', confidence: 0.1, scores: { harvest: 0, planted: 0, soil: 0 } };
135
108
  }
136
109
 
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
- };
110
+ // Actual farm pixel analysis from live screenshot:
111
+ // Water cells: sat=0.15-0.20, B=14-21, darkPct=64-91%, G-R=-20 to +4
112
+ // Tilled cells: sat=0.22-0.25, B=12-17, darkPct=64-81%, G-R=-8 to +11
113
+ // Harvest cells: sat=0.22-0.25, B=12-17, darkPct=64-73%, G-R=+4 to +11, harv=41-46%
114
+ //
115
+ // Key insight: sat < 0.20 + B > 14 = water cells
116
+ // Higher sat (0.22+) + harvestPixels > 40% = harvest crops
117
+ // Higher sat (0.22+) + harv < 40% = tilled soil
118
+
119
+ const isLowSatWater = avgSat < 0.20 && avgB > 14; // wet/dark soil
120
+ const isHighSatTilled = avgSat >= 0.20; // tilled/planted soil
121
+ const isBright = brightGrayPct > 0.15; // has bright crop pixels
122
+ const isDarkUniform = darkGrayPct > 0.55 && grayStd < 20; // very dark uniform (wet soil)
123
+
124
+ // ── HARVEST: Bright crops + high green + sat ≥ 0.20 ──
125
+ // Growing/ready crops: sat 0.22+, G-R > 5, harvestPixels > 40%
126
+ if (avgSat >= 0.20 && gMinusR > 5 && harvestPct > 0.40 && isBright) {
127
+ const excess = gMinusR - 5;
128
+ const confidence = clamp01(0.5 + excess * 0.06);
129
+ return { state: 'harvest', confidence: +confidence.toFixed(3), scores: { harvest: +harvestPct.toFixed(3), planted: +plantPct.toFixed(3), soil: +soilPct.toFixed(3) } };
130
+ }
131
+
132
+ // ── WATER: Low saturation + blue dominant (wet soil) ──
133
+ // sat < 0.20 AND B > 14 catches actual wet cells from live screenshot
134
+ if (isLowSatWater || isDarkUniform) {
135
+ return { state: 'water', confidence: +clamp01(0.5 + darkGrayPct * 0.3).toFixed(3), scores: { harvest: +harvestPct.toFixed(3), planted: +plantPct.toFixed(3), soil: +soilPct.toFixed(3) } };
136
+ }
137
+
138
+ // ── TILLED: Higher saturation (≥ 0.20) = soil/debris ──
139
+ // sat ≥ 0.20 covers tilled dirt, debris, and planted cells
140
+ if (isHighSatTilled) {
141
+ // Planted if some harvest pixels and some brightness
142
+ if (plantPct > 0.15 || gMinusR > 1) {
143
+ const confidence = clamp01(0.3 + avgSat * 0.8 + brightGrayPct * 0.5);
144
+ return { state: 'planted', confidence: +confidence.toFixed(3), scores: { harvest: +harvestPct.toFixed(3), planted: +plantPct.toFixed(3), soil: +soilPct.toFixed(3) } };
145
+ }
146
+ const confidence = clamp01((avgSat - 0.15) * 1.5);
147
+ return { state: 'tilled', confidence: +confidence.toFixed(3), scores: { harvest: +harvestPct.toFixed(3), planted: +plantPct.toFixed(3), soil: +soilPct.toFixed(3) } };
148
+ }
149
+
150
+ // ── UNKNOWN: Low signal / empty ──
151
+ const signal = Math.max(harvestPct, plantPct, soilPct, avgSat);
152
+ const confidence = signal * 1.5;
153
+ return { state: 'unknown', confidence: +confidence.toFixed(3), scores: { harvest: +harvestPct.toFixed(3), planted: +plantPct.toFixed(3), soil: +soilPct.toFixed(3) } };
146
154
  }
147
155
 
148
156
  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;
157
+ // Convert to grayscale to eliminate hue confusion between dusty-brown and green
158
+ const grayBuf = await sharp(imgBuffer).grayscale().raw().toBuffer({ resolveWithObject: true });
159
+ const { data: grayData, info: grayInfo } = grayBuf;
160
+ const { width, height, channels } = grayInfo;
161
+
162
+ // Also get original for saturation analysis
163
+ const origBuf = await sharp(imgBuffer).raw().toBuffer({ resolveWithObject: true });
164
+ const { data: origData, info: origInfo } = origBuf;
165
+
166
+ if (origInfo.width !== width || origInfo.height !== height) {
167
+ throw new Error('Grayscale/Original dimension mismatch — should not happen');
168
+ }
169
+
151
170
  const cellW = Math.floor(width / cols);
152
171
  const cellH = Math.floor(height / rows);
153
172
 
@@ -159,65 +178,103 @@ async function analyzeFarmGrid(imgBuffer, cols = 3, rows = 3) {
159
178
  const ex = Math.min(sx + cellW, width);
160
179
  const ey = Math.min(sy + cellH, height);
161
180
 
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;
181
+ // Use 30% vertical padding to avoid Discord UI bar bleeding into top/bottom rows
182
+ const padX = Math.floor((ex - sx) * 0.20);
183
+ const padY = Math.floor((ey - sy) * 0.30);
184
+ const csx = sx + padX;
185
+ const csy = sy + padY;
186
+ const cex = ex - padX;
187
+ const cey = ey - padY;
169
188
 
170
189
  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;
190
+ let harvestPx = 0;
191
+ let plantPx = 0;
192
+ let soilPx = 0;
193
+ let graySum = 0, graySqSum = 0;
194
+ let brightGrayPx = 0;
195
+ let darkGrayPx = 0;
196
+ let rSum = 0, gSum = 0, bSum = 0, satSum = 0;
197
+ let discordUiPx = 0;
198
+ const rawCellPixels = (cex - csx) * (cey - csy);
177
199
 
178
200
  for (let y = csy; y < cey; y++) {
179
201
  for (let x = csx; x < cex; x++) {
180
202
  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);
203
+ const r = origData[idx];
204
+ const g = origData[idx + 1];
205
+ const b = origData[idx + 2];
206
+ const gray = grayData[idx];
207
+
208
+ const maxC = Math.max(r, g, b);
209
+ const minC = Math.min(r, g, b);
210
+ const sat = maxC > 0 ? (maxC - minC) / 255 : 0;
211
+
212
+ // Skip Discord UI blue bar: very low saturation + blue dominant
213
+ if (b > 80 && sat < 0.08) {
214
+ discordUiPx++;
215
+ continue;
216
+ }
217
+
185
218
  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++;
219
+ graySum += gray;
220
+ graySqSum += gray * gray;
221
+ rSum += r; gSum += g; bSum += b;
222
+ satSum += sat;
223
+
224
+ if (g > r * 1.25) harvestPx++;
225
+ else if (g > r * 1.08) plantPx++;
226
+ if (r >= g) soilPx++;
227
+ if (gray > 100) brightGrayPx++;
228
+ if (gray < 65) darkGrayPx++;
192
229
  }
193
230
  }
194
231
 
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 });
232
+ const harvestPct = total > 0 ? harvestPx / total : 0;
233
+ const plantPct = total > 0 ? plantPx / total : 0;
234
+ const soilPct = total > 0 ? soilPx / total : 0;
235
+ const grayMean = total > 0 ? graySum / total : 0;
236
+ const grayVar = total > 0 ? (graySqSum / total) - (grayMean * grayMean) : 0;
237
+ const grayStd = Math.sqrt(Math.max(0, grayVar));
238
+ const avgSat = total > 0 ? satSum / total : 0;
239
+ const avgR = total > 0 ? rSum / total : 0;
240
+ const avgG = total > 0 ? gSum / total : 0;
241
+ const brightGrayPct = total > 0 ? brightGrayPx / total : 0;
242
+ const darkGrayPct = total > 0 ? darkGrayPx / total : 0;
243
+ const gMinusR = avgG - avgR;
244
+ const avgB = total > 0 ? bSum / total : 0;
245
+ const discordUiPct = rawCellPixels > 0 ? discordUiPx / rawCellPixels : 0;
246
+
247
+ const classified = classifyCell({
248
+ harvestPct, plantPct, soilPct,
249
+ avgR, avgG, avgB,
250
+ avgBrightness: grayMean,
251
+ avgSat, grayStd,
252
+ brightGrayPct, darkGrayPct,
253
+ gMinusR,
254
+ discordUiPct,
255
+ });
203
256
 
204
257
  cells.push({
205
- row,
206
- col,
258
+ row, col,
207
259
  state: classified.state,
208
260
  confidence: classified.confidence,
209
261
  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),
262
+ harvestPct: +harvestPct.toFixed(3),
263
+ plantPct: +plantPct.toFixed(3),
264
+ soilPct: +soilPct.toFixed(3),
265
+ avgBrightness: +grayMean.toFixed(1),
266
+ avgSat: +avgSat.toFixed(3),
267
+ grayStd: +grayStd.toFixed(1),
268
+ brightGrayPct: +brightGrayPct.toFixed(3),
269
+ darkGrayPct: +darkGrayPct.toFixed(3),
270
+ avgR: +avgR.toFixed(1),
271
+ avgG: +avgG.toFixed(1),
272
+ avgB: +avgB.toFixed(1),
216
273
  });
217
274
  }
218
275
  }
219
276
 
220
- const counts = { tilled: 0, wet: 0, planted: 0, unknown: 0 };
277
+ const counts = { harvest: 0, water: 0, tilled: 0, planted: 0, unknown: 0 };
221
278
  for (const c of cells) counts[c.state] = (counts[c.state] || 0) + 1;
222
279
 
223
280
  const avgConfidence = cells.length > 0
@@ -228,7 +285,7 @@ async function analyzeFarmGrid(imgBuffer, cols = 3, rows = 3) {
228
285
  }
229
286
 
230
287
  function gridToString(analysis) {
231
- const icon = { tilled: 'T', wet: 'W', planted: 'P', unknown: '?' };
288
+ const icon = { harvest: 'H', water: 'W', tilled: 'T', planted: 'P', unknown: '?' };
232
289
  const rows = [];
233
290
  for (let r = 0; r < (analysis?.rows || 0); r++) {
234
291
  const line = [];
@@ -243,93 +300,93 @@ function gridToString(analysis) {
243
300
 
244
301
  function evaluateActionNeed(actionName, analysis) {
245
302
  const c = analysis?.counts || {};
246
- const tilled = c.tilled || 0;
247
- const wet = c.wet || 0;
303
+ const harvest = c.harvest || 0;
248
304
  const planted = c.planted || 0;
305
+ const water = c.water || 0;
306
+ const tilled = (c.tilled || 0) + (c.soil || 0); // soil = tilled cells
249
307
  const unknown = c.unknown || 0;
250
308
  const conf = analysis?.avgConfidence || 0;
251
309
  const slots = 9;
252
310
 
253
311
  // Avoid aggressive repeats when confidence is too low.
254
- if (conf < 0.22) return false;
312
+ if (conf < 0.20) return false;
255
313
 
256
- // Conservative rules: only request repeat if we have clear evidence.
314
+ // Rules (4-state model: harvest/water/tilled/planted):
315
+ // - hoe: repeat if farm has no soil (all harvest/unknown)
316
+ // - water: repeat if farm has tilled but not watered (R<58, not water)
317
+ // - plant: repeat if farm has watered tiles but no planted
318
+ // - harvest: repeat if any harvest-ready crops visible
257
319
  if (actionName === 'hoe') {
258
- return (tilled + wet + planted) < slots && unknown < 6;
320
+ return harvest < slots && unknown < 6;
259
321
  }
260
322
  if (actionName === 'water') {
261
- // If still many plain-tilled (not watered/planted), water likely incomplete.
262
- return tilled > 0 && (wet + planted) < slots;
323
+ return tilled > 0 && water < slots;
263
324
  }
264
325
  if (actionName === 'plant') {
265
- return planted < slots && (tilled + wet) > 0;
326
+ return planted < slots && (water + tilled) > 0;
266
327
  }
267
328
  if (actionName === 'harvest') {
268
- // If many planted remain, harvest may still be pending.
269
- return planted > 0;
329
+ return harvest > 0;
270
330
  }
271
-
272
- // Fertilizer optional; do not force repeats.
273
331
  return false;
274
332
  }
275
333
 
276
334
  function evaluateActionScores(actionName, analysis) {
277
335
  const c = analysis?.counts || {};
278
336
  const slots = Math.max(1, (analysis?.rows || 3) * (analysis?.cols || 3));
279
- const tilled = c.tilled || 0;
280
- const wet = c.wet || 0;
337
+ const harvest = c.harvest || 0;
281
338
  const planted = c.planted || 0;
339
+ const water = c.water || 0;
340
+ const tilled = (c.tilled || 0) + (c.soil || 0); // soil = tilled cells
282
341
  const unknown = c.unknown || 0;
283
342
  const conf = analysis?.avgConfidence || 0;
284
343
 
285
344
  const ratios = {
286
- tilled: tilled / slots,
287
- wet: wet / slots,
345
+ harvest: harvest / slots,
288
346
  planted: planted / slots,
347
+ water: water / slots,
348
+ tilled: tilled / slots,
289
349
  unknown: unknown / slots,
290
350
  };
291
351
 
292
352
  const clamp = (n) => Math.max(0, Math.min(1, n));
293
- const withConf = (base, mult = 0.25) => clamp(base * (0.82 + (conf * mult)));
353
+ const withConf = (base) => clamp(base * (0.85 + conf * 0.3));
294
354
 
295
355
  let score = 0;
296
- let threshold = 0.8;
356
+ let threshold = 0.7;
297
357
  let reason = 'ok';
298
358
 
299
359
  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);
360
+ // Hoe is good if farm has mostly soil, not harvest-ready crops
361
+ const base = (ratios.tilled * 0.7) + (ratios.water * 0.7) + (ratios.planted * 0.5) - (ratios.harvest * 0.9) - (ratios.unknown * 0.3);
302
362
  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';
363
+ threshold = 0.40;
364
+ if (ratios.unknown > 0.5) reason = 'too_many_unknown_cells';
365
+ else if (ratios.harvest >= 0.5) reason = 'farm_has_crops_to_harvest';
366
+ else if (ratios.harvest + ratios.planted < 0.3) reason = 'farm_needs_hoe';
306
367
  } 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);
368
+ // Water is good if there are tilled (dry) tiles to water
369
+ const base = (ratios.tilled * 0.95) - (ratios.water * 0.5) - (ratios.harvest * 0.5) - (ratios.unknown * 0.2);
309
370
  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';
371
+ threshold = 0.40;
372
+ if (ratios.harvest >= 0.5) reason = 'farm_has_crops_to_harvest';
373
+ else if (ratios.tilled < 0.3) reason = 'no_tilled_tiles';
318
374
  } 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);
375
+ // Plant should end with growing crops on watered soil
376
+ const base = (ratios.planted * 0.95) - (ratios.harvest * 0.1) - (ratios.tilled * 0.2) - (ratios.unknown * 0.1);
321
377
  score = withConf(base);
322
- threshold = 0.72;
323
- if (ratios.planted < 0.6) reason = 'not_enough_planted_tiles';
378
+ threshold = 0.50;
379
+ if (ratios.harvest >= 0.5) reason = 'farm_ready_to_harvest';
380
+ else if (ratios.water + ratios.tilled < 0.3) reason = 'farm_not_watered';
324
381
  } 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);
382
+ // Harvest is good if harvest-ready tiles dominate
383
+ const base = (ratios.harvest * 0.95) - (ratios.tilled * 0.15) - (ratios.planted * 0.1) - (ratios.unknown * 0.1);
327
384
  score = withConf(base);
328
- threshold = 0.68;
329
- if (ratios.planted > 0.34) reason = 'too_many_unharvested_plants';
385
+ threshold = 0.40;
386
+ if (ratios.harvest < 0.3) reason = 'no_crops_to_harvest';
330
387
  } else {
331
- score = withConf(0.7 - (ratios.unknown * 0.2));
332
- threshold = 0.7;
388
+ score = withConf(0.6 - ratios.unknown * 0.3);
389
+ threshold = 0.6;
333
390
  }
334
391
 
335
392
  const matched = conf >= 0.2 && score >= threshold;
@@ -341,11 +398,12 @@ function evaluateActionScores(actionName, analysis) {
341
398
  threshold: +threshold.toFixed(3),
342
399
  confidence: +conf.toFixed(3),
343
400
  reason,
344
- counts: { tilled, wet, planted, unknown, slots },
401
+ counts: { harvest, planted, water, tilled, unknown, slots },
345
402
  ratios: {
346
- tilled: +ratios.tilled.toFixed(3),
347
- wet: +ratios.wet.toFixed(3),
403
+ harvest: +ratios.harvest.toFixed(3),
348
404
  planted: +ratios.planted.toFixed(3),
405
+ water: +ratios.water.toFixed(3),
406
+ tilled: +ratios.tilled.toFixed(3),
349
407
  unknown: +ratios.unknown.toFixed(3),
350
408
  },
351
409
  };
@@ -494,9 +494,19 @@ async function ensureCV2(msg, force = false) {
494
494
  if (rawParsed && rawParsed.components?.length > 0) {
495
495
  msg._cv2 = rawParsed.components;
496
496
  msg._cv2text = rawParsed.cv2Text || _extractCV2Text(rawParsed.components).trim();
497
- msg._cv2buttons = rawParsed.buttons?.length > 0
497
+ const topLevel = rawParsed.buttons?.length > 0
498
498
  ? rawParsed.buttons.map(b => ({ type: 'BUTTON', label: b.label, customId: b.customId, style: b.style, url: null, disabled: b.disabled, emoji: b.emoji, _raw: b }))
499
- : _extractCV2Buttons(rawParsed.components);
499
+ : [];
500
+ const nested = _extractCV2Buttons(rawParsed.components);
501
+ // Merge, deduplicating by customId so all buttons are captured (top-level cv2Buttons
502
+ // omits some nested "All" buttons that live inside ACTION_ROW containers).
503
+ const seen = new Set();
504
+ const merged = [...topLevel, ...nested].filter(b => {
505
+ const id = b.customId || b.custom_id || '';
506
+ if (!id || seen.has(id)) return false;
507
+ seen.add(id); return true;
508
+ });
509
+ msg._cv2buttons = merged;
500
510
  msg._cv2EditedTs = msgEditedTs;
501
511
  cv2Cache.set(msg.id, { components: rawParsed.components, editedTimestamp: msgEditedTs });
502
512
  return msg;
@@ -558,7 +568,7 @@ async function clickCV2Button(msg, customId) {
558
568
  const gId = msg.guildId || msg.guild?.id;
559
569
  if (!token) throw new Error('No token for CV2 click');
560
570
  const sessionId = msg.client?.ws?.shards?.first?.()?.sessionId;
561
- const nonce = `${BigInt(Date.now() - 1420070400000) << 22n}`;
571
+ const nonce = `${(BigInt(Date.now() - 1420070400000) << 22n)}`;
562
572
  const payloadObj = {
563
573
  type: 3,
564
574
  application_id: String(msg.applicationId || DANK_MEMER_ID),
@@ -574,7 +584,11 @@ async function clickCV2Button(msg, customId) {
574
584
  const resp = await _httpPost('https://discord.com/api/v9/interactions', {
575
585
  Authorization: token, 'Content-Type': 'application/json',
576
586
  }, payload);
577
- if (resp.status >= 400) throw new Error(`CV2 click ${resp.status}: ${resp.body.substring(0, 200)}`);
587
+ if (resp.status >= 400) {
588
+ const errMsg = `CV2 click ${resp.status}: ${resp.body.substring(0, 300)}`;
589
+ LOG.warn(`[cv2] ${errMsg}`);
590
+ throw new Error(errMsg);
591
+ }
578
592
 
579
593
  let parsed = null;
580
594
  try {
package/lib/grinder.js CHANGED
@@ -644,7 +644,11 @@ function renderDashboard() {
644
644
  if (ls === 0) lsStr = `${R}♥0${c.reset}`;
645
645
  else if (ls != null && ls <= 2) lsStr = `${Y}♥${ls}${c.reset}`;
646
646
  else if (ls != null) lsStr = `${G}♥${ls}${c.reset}`;
647
- else lsStr = `${D}♥?${c.reset}`;
647
+ else {
648
+ // Unknown — pulse to show it's still being determined
649
+ const pulse = PULSE_CHARS[Math.floor(Date.now() / 400) % PULSE_CHARS.length];
650
+ lsStr = `${D}${pulse}♥?${c.reset}`;
651
+ }
648
652
 
649
653
  // ── Level indicator (fixed width so value changes don't jitter) ──
650
654
  const lvl = wk._level || 0;
@@ -1839,9 +1843,15 @@ class AccountWorker {
1839
1843
  }
1840
1844
  }
1841
1845
 
1842
- // ── Lifesaver protection: skip crime/search if 0 lifesavers ──
1846
+ // ── Lifesaver protection: skip crime/search if 0 lifesavers or unknown ──
1843
1847
  if (cmdName === 'crime' || cmdName === 'search') {
1844
- // Fast path: check in-memory lifesaver count (set from inv + DM check)
1848
+ // Unknown (null/undefined): DMs haven't confirmed safety skip for safety.
1849
+ if (this._lifesavers == null) {
1850
+ this.log('warn', `[${cmdName}] SKIPPED — lifesavers unknown (safety hold)`);
1851
+ await this.setCooldown(cmdName, 600);
1852
+ return;
1853
+ }
1854
+ // Zero: depleted — disable for a long period.
1845
1855
  if (this._lifesavers === 0) {
1846
1856
  this.log('warn', `[${cmdName}] SKIPPED — 0 lifesavers (in-memory)`);
1847
1857
  await this.setCooldown(cmdName, 3600);
@@ -3125,19 +3135,43 @@ async function start(apiKey, apiUrl) {
3125
3135
  // Init rawLogger Redis (uses same URL — logs all raw gateway data)
3126
3136
  if (REDIS_URL) {
3127
3137
  rawLogger.init(REDIS_URL).catch(() => {});
3128
- // Listen for DM death events across all accounts
3138
+ // Listen for DM events across all accounts — update worker state + dashboard LIVE
3129
3139
  rawLogger.onDmEvent((event, raw) => {
3130
- if (event.type === 'death' && event.lifesaversLeft === 0) {
3131
- const channelId = raw.channel_id;
3132
- // Find which worker uses this DM channel and disable their crime/search
3133
- for (const w of workers) {
3134
- if (w.client?.user?.dmChannel?.id === channelId || w.channel?.id) {
3135
- w.log?.('error', `DEATH in DMs! 0 lifesavers — disabling crime/search`);
3136
- w.setCooldown?.('crime', 86400);
3137
- w.setCooldown?.('search', 86400);
3140
+ const channelId = raw.channel_id;
3141
+ for (const w of workers) {
3142
+ const isThisWorker = w.client?.user?.dmChannel?.id === channelId;
3143
+ if (!isThisWorker && w.channel?.id !== channelId) continue;
3144
+
3145
+ if (event.type === 'death') {
3146
+ // Update worker's lifesaver count so dashboard ♥ updates in real time
3147
+ if (event.lifesaversLeft >= 0) {
3148
+ const prev = w._lifesavers;
3149
+ w._lifesavers = event.lifesaversLeft;
3150
+ if (event.lifesaversLeft === 0) {
3151
+ w.log?.('error', `DEATH in DMs! 0 lifesavers — disabling crime/search`);
3152
+ w.setCooldown?.('crime', 86400);
3153
+ w.setCooldown?.('search', 86400);
3154
+ sendWebhook?.('DEATH ALERT (DM)', `**${w.username}** died in DMs! **0 lifesavers!**\nCrime/search auto-disabled.`, 0xef4444);
3155
+ } else {
3156
+ w.log?.('warn', `DEATH in DMs — ${event.lifesaversLeft} lifesavers remaining`);
3157
+ if (prev !== event.lifesaversLeft) {
3158
+ w.setCooldown?.('crime', 60);
3159
+ w.setCooldown?.('search', 60);
3160
+ }
3161
+ if (event.lifesaversLeft <= 2) {
3162
+ sendWebhook?.('LOW LIFESAVERS', `**${w.username}** has only **${event.lifesaversLeft}** lifesaver(s) left!`, 0xfbbf24);
3163
+ }
3164
+ }
3165
+ }
3166
+ scheduleRender();
3167
+ }
3168
+
3169
+ if (event.type === 'levelup') {
3170
+ if (event.to > 0) {
3171
+ w._level = event.to;
3172
+ scheduleRender();
3138
3173
  }
3139
3174
  }
3140
- sendWebhook?.('DEATH ALERT (DM)', `Account died! **0 lifesavers!**\nCrime/search auto-disabled.`, 0xef4444);
3141
3175
  }
3142
3176
  });
3143
3177
  checks.push(`${rgb(52, 211, 153)}✓${c.reset} ${c.white}RawLog${c.reset}`);
@@ -3457,14 +3491,16 @@ async function start(apiKey, apiUrl) {
3457
3491
 
3458
3492
 
3459
3493
  // Phase 2.75: Check DM history for deaths/level-ups (sequential, fast)
3460
- console.log(` ${rgb(139, 92, 246)}${BRAILLE_SPIN[0]}${c.reset} ${c.dim}Checking DM history...${c.reset}`);
3461
- let dmDeaths = 0, dmLevelUps = 0, dmNoLs = [];
3494
+ const dmCheckPulse = BRAILLE_SPIN[Math.floor(Date.now() / 80) % BRAILLE_SPIN.length];
3495
+ console.log(` ${rgb(139, 92, 246)}${dmCheckPulse}${c.reset} ${c.dim}Checking DM history...${c.reset}`);
3496
+ let dmDeaths = 0, dmLevelUps = 0, dmNoLs = [], dmUnknown = [];
3462
3497
  for (const w of activeWorkers) {
3463
3498
  try {
3464
3499
  const dm = await w.checkDmHistory();
3465
3500
  if (dm.deaths > 0) dmDeaths += dm.deaths;
3466
3501
  if (dm.levelUps > 0) dmLevelUps += dm.levelUps;
3467
3502
  if (dm.lifesavers === 0) dmNoLs.push(w.username);
3503
+ if (dm.lifesavers === -1) dmUnknown.push(w.username);
3468
3504
  // Store level and lifesaver for dashboard
3469
3505
  if (dm.currentLevel > 0) w._level = dm.currentLevel;
3470
3506
  if (dm.lifesavers >= 0) w._lifesavers = dm.lifesavers;
@@ -3474,6 +3510,10 @@ async function start(apiKey, apiUrl) {
3474
3510
  if (dm.lifesavers >= 0) {
3475
3511
  const lc = dm.lifesavers === 0 ? rgb(239, 68, 68) : dm.lifesavers <= 2 ? rgb(251, 191, 36) : rgb(52, 211, 153);
3476
3512
  parts.push(`${lc}♥${dm.lifesavers}${c.reset}`);
3513
+ } else {
3514
+ // Unknown lifesavers — pulse to show pending
3515
+ const pulse = PULSE_CHARS[Math.floor(Date.now() / 400) % PULSE_CHARS.length];
3516
+ parts.push(`${D}${pulse}♥?${c.reset}`);
3477
3517
  }
3478
3518
  if (parts.length > 0) {
3479
3519
  console.log(` ${c.dim}├${c.reset} ${c.bold}${w.username}${c.reset} ${parts.join(' ')}`);
@@ -3492,7 +3532,16 @@ async function start(apiKey, apiUrl) {
3492
3532
  }
3493
3533
  }
3494
3534
  }
3495
- console.log(` ${rgb(52, 211, 153)}✓${c.reset} ${c.bold}DM check${c.reset} ${c.dim}${dmDeaths} deaths, ${dmLevelUps} level-ups found${c.reset}`);
3535
+ if (dmUnknown.length > 0) {
3536
+ console.log(` ${rgb(251, 191, 36)}⚠${c.reset} ${c.dim}Lifesavers unknown — live DM monitor active:${c.reset} ${dmUnknown.join(', ')}`);
3537
+ // Crime/search on these accounts will be skipped via safety hold until the live
3538
+ // DM gateway listener detects a death (→ sets count) or confirms clean.
3539
+ }
3540
+ const dmSummaryParts = [];
3541
+ if (dmDeaths > 0) dmSummaryParts.push(`${dmDeaths} deaths`);
3542
+ if (dmLevelUps > 0) dmSummaryParts.push(`${dmLevelUps} level-ups`);
3543
+ if (dmUnknown.length > 0) dmSummaryParts.push(`${dmUnknown.length} pending`);
3544
+ console.log(` ${rgb(52, 211, 153)}✓${c.reset} ${c.bold}DM check${c.reset} ${dmSummaryParts.length > 0 ? c.dim + dmSummaryParts.join(', ') + c.reset : c.dim + 'clean — no deaths or level-ups' + c.reset}`);
3496
3545
  console.log('');
3497
3546
 
3498
3547
  console.log(` ${rgb(139, 92, 246)}${c.bold}>>>${c.reset} ${gradientText('Starting grind loops...', [139, 92, 246], [52, 211, 153])}`);