dankgrinder 7.6.0 → 7.11.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,16 +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
- // Crop green — harvest phase: large bright leaves, G significantly > R and B
86
- const greenStrong = g > r * 1.25 && g > b * 1.25 && g > 38;
87
- // Weeds/grass small scattered green, less intense than crop green
88
- const weedGreen = g > r * 1.12 && g > b * 1.1 && g <= 38;
89
- // Wet soil — rich dark brown, no blue, higher R than typical dry soil
90
- const wetSoil = r > 50 && r > g * 1.5 && b < 20 && brightness < 45;
91
- // Dry tilled soil — medium brown, debris textures
92
- const drySoil = r > 35 && r > g * 1.2 && b < 30;
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;
93
88
  const whiteish = brightness > 210 && Math.abs(r - g) < 20 && Math.abs(g - b) < 20;
94
- return { brightness, greenStrong, weedGreen, wetSoil, drySoil, whiteish };
89
+ return { brightness, gray, soil, whiteish };
95
90
  }
96
91
 
97
92
  function clamp01(n) {
@@ -99,41 +94,79 @@ function clamp01(n) {
99
94
  }
100
95
 
101
96
  function classifyCell(features) {
102
- const { greenPct, bluePct, brownPct, darkPct, avgBrightness } = features;
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 } };
108
+ }
103
109
 
104
- // ── HARVEST: Bright green mature crops (G >> R,B, G>38) ──
105
- // Detected by greenStrong pixel percentage crop leaves are vivid green
106
- if (greenPct >= 0.30) {
107
- const confidence = clamp01(0.5 + (greenPct - 0.30) * 2.0);
108
- return { state: 'planted', confidence: +confidence.toFixed(3), scores: { planted: +greenPct.toFixed(3), wet: +darkPct.toFixed(3), tilled: +brownPct.toFixed(3) } };
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) } };
109
130
  }
110
131
 
111
- // ── TILLED / WET: Brown soil no meaningful green or blue ──
112
- if (brownPct >= 0.15) {
113
- // High brown percentage = definitely soil. Both hoe (dry) and water (wet)
114
- // phases look like brown soil. We classify as 'tilled' the farming
115
- // logic handles water vs hoe state transition by phase context.
116
- const confidence = clamp01((brownPct - 0.15) * 2.5 + (1 - darkPct) * 0.3);
117
- return { state: 'tilled', confidence: +confidence.toFixed(3), scores: { planted: +greenPct.toFixed(3), wet: +darkPct.toFixed(3), tilled: +brownPct.toFixed(3) } };
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) } };
118
136
  }
119
137
 
120
- // ── PLANTED (early stage): Soil with green weeds ──
121
- // Cells with 5-30% green are likely soil with weeds/crops starting.
122
- // This is distinct from harvest (30%+) which is handled above.
123
- if (greenPct >= 0.05 && greenPct < 0.30) {
124
- const confidence = clamp01((greenPct - 0.05) * 3.0);
125
- return { state: 'planted', confidence: +confidence.toFixed(3), scores: { planted: +greenPct.toFixed(3), wet: +darkPct.toFixed(3), tilled: +brownPct.toFixed(3) } };
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) } };
126
148
  }
127
149
 
128
- // ── UNKNOWN: Low signal grass borders, artifacts, etc. ──
129
- const signal = Math.max(greenPct, bluePct, brownPct);
150
+ // ── UNKNOWN: Low signal / empty ──
151
+ const signal = Math.max(harvestPct, plantPct, soilPct, avgSat);
130
152
  const confidence = signal * 1.5;
131
- return { state: 'unknown', confidence: +confidence.toFixed(3), scores: { planted: +greenPct.toFixed(3), wet: +darkPct.toFixed(3), tilled: +brownPct.toFixed(3) } };
153
+ return { state: 'unknown', confidence: +confidence.toFixed(3), scores: { harvest: +harvestPct.toFixed(3), planted: +plantPct.toFixed(3), soil: +soilPct.toFixed(3) } };
132
154
  }
133
155
 
134
156
  async function analyzeFarmGrid(imgBuffer, cols = 3, rows = 3) {
135
- const { data, info } = await sharp(imgBuffer).raw().toBuffer({ resolveWithObject: true });
136
- 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
+
137
170
  const cellW = Math.floor(width / cols);
138
171
  const cellH = Math.floor(height / rows);
139
172
 
@@ -145,60 +178,103 @@ async function analyzeFarmGrid(imgBuffer, cols = 3, rows = 3) {
145
178
  const ex = Math.min(sx + cellW, width);
146
179
  const ey = Math.min(sy + cellH, height);
147
180
 
148
- // Sample center region only (ignore grass borders around each tile).
149
- const padX = Math.floor((ex - sx) * 0.20); // 20% padding to cut more grass
150
- const padY = Math.floor((ey - sy) * 0.20);
151
- const csx = sx + padX;
152
- const csy = sy + padY;
153
- const cex = ex - padX;
154
- 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;
155
188
 
156
189
  let total = 0;
157
- let greenPx = 0; // crop green: G > R*1.25 && G > B*1.25 && G > 38
158
- let brownPx = 0; // soil brown: R > G*1.2 && B < 30
159
- let darkPx = 0; // very dark: brightness < 56
160
- let whitePx = 0;
161
- 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);
162
199
 
163
200
  for (let y = csy; y < cey; y++) {
164
201
  for (let x = csx; x < cex; x++) {
165
202
  const idx = (y * width + x) * channels;
166
- const r = data[idx];
167
- const g = data[idx + 1];
168
- const b = data[idx + 2];
169
- 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
+
170
218
  total++;
171
- brightSum += s.brightness;
172
- if (s.greenStrong) greenPx++; // harvest green (vivid crop leaves)
173
- if (s.drySoil || s.wetSoil) brownPx++; // soil dry or wet
174
- if (s.dark) darkPx++;
175
- 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++;
176
229
  }
177
230
  }
178
231
 
179
- const greenPct = greenPx / total;
180
- const brownPct = brownPx / total;
181
- const darkPct = darkPx / total;
182
- const whitePct = whitePx / total;
183
- const avgBrightness = brightSum / total;
184
-
185
- const classified = classifyCell({ greenPct, brownPct, darkPct, avgBrightness });
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
+ });
186
256
 
187
257
  cells.push({
188
- row,
189
- col,
258
+ row, col,
190
259
  state: classified.state,
191
260
  confidence: classified.confidence,
192
261
  scores: classified.scores,
193
- greenPct: +greenPct.toFixed(3),
194
- brownPct: +brownPct.toFixed(3),
195
- darkPct: +darkPct.toFixed(3),
196
- 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),
197
273
  });
198
274
  }
199
275
  }
200
276
 
201
- const counts = { tilled: 0, planted: 0, unknown: 0 };
277
+ const counts = { harvest: 0, water: 0, tilled: 0, planted: 0, unknown: 0 };
202
278
  for (const c of cells) counts[c.state] = (counts[c.state] || 0) + 1;
203
279
 
204
280
  const avgConfidence = cells.length > 0
@@ -209,7 +285,7 @@ async function analyzeFarmGrid(imgBuffer, cols = 3, rows = 3) {
209
285
  }
210
286
 
211
287
  function gridToString(analysis) {
212
- const icon = { tilled: 'T', planted: 'P', unknown: '?' };
288
+ const icon = { harvest: 'H', water: 'W', tilled: 'T', planted: 'P', unknown: '?' };
213
289
  const rows = [];
214
290
  for (let r = 0; r < (analysis?.rows || 0); r++) {
215
291
  const line = [];
@@ -224,9 +300,10 @@ function gridToString(analysis) {
224
300
 
225
301
  function evaluateActionNeed(actionName, analysis) {
226
302
  const c = analysis?.counts || {};
227
- const tilled = c.tilled || 0;
228
- const wet = c.wet || 0;
303
+ const harvest = c.harvest || 0;
229
304
  const planted = c.planted || 0;
305
+ const water = c.water || 0;
306
+ const tilled = (c.tilled || 0) + (c.soil || 0); // soil = tilled cells
230
307
  const unknown = c.unknown || 0;
231
308
  const conf = analysis?.avgConfidence || 0;
232
309
  const slots = 9;
@@ -234,35 +311,41 @@ function evaluateActionNeed(actionName, analysis) {
234
311
  // Avoid aggressive repeats when confidence is too low.
235
312
  if (conf < 0.20) return false;
236
313
 
237
- // Simple rules:
238
- // - hoe: repeat until farm has enough tilled/planted soil
239
- // - water: skip (we don't distinguish wet vs tilled visually use phase context instead)
240
- // - plant: repeat until most cells show green crops
241
- // - harvest: repeat if any planted crops visible
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
242
319
  if (actionName === 'hoe') {
243
- return (tilled + planted) < slots && unknown < 6;
320
+ return harvest < slots && unknown < 6;
321
+ }
322
+ if (actionName === 'water') {
323
+ return tilled > 0 && water < slots;
244
324
  }
245
325
  if (actionName === 'plant') {
246
- return planted < slots && (tilled) > 0;
326
+ return planted < slots && (water + tilled) > 0;
247
327
  }
248
328
  if (actionName === 'harvest') {
249
- return planted > 0;
329
+ return harvest > 0;
250
330
  }
251
- // water and others: don't force repeats
252
331
  return false;
253
332
  }
254
333
 
255
334
  function evaluateActionScores(actionName, analysis) {
256
335
  const c = analysis?.counts || {};
257
336
  const slots = Math.max(1, (analysis?.rows || 3) * (analysis?.cols || 3));
258
- const tilled = c.tilled || 0;
337
+ const harvest = c.harvest || 0;
259
338
  const planted = c.planted || 0;
339
+ const water = c.water || 0;
340
+ const tilled = (c.tilled || 0) + (c.soil || 0); // soil = tilled cells
260
341
  const unknown = c.unknown || 0;
261
342
  const conf = analysis?.avgConfidence || 0;
262
343
 
263
344
  const ratios = {
264
- tilled: tilled / slots,
345
+ harvest: harvest / slots,
265
346
  planted: planted / slots,
347
+ water: water / slots,
348
+ tilled: tilled / slots,
266
349
  unknown: unknown / slots,
267
350
  };
268
351
 
@@ -274,24 +357,33 @@ function evaluateActionScores(actionName, analysis) {
274
357
  let reason = 'ok';
275
358
 
276
359
  if (actionName === 'hoe') {
277
- // Hoe is good if farm has mostly soil (tilled or planted)
278
- const base = (ratios.tilled * 0.9) + (ratios.planted * 0.5) - (ratios.unknown * 0.4);
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);
279
362
  score = withConf(base);
280
- threshold = 0.55;
363
+ threshold = 0.40;
281
364
  if (ratios.unknown > 0.5) reason = 'too_many_unknown_cells';
282
- else if (ratios.tilled + ratios.planted < 0.4) reason = 'farm_not_ready';
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';
367
+ } else if (actionName === 'water') {
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);
370
+ score = withConf(base);
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';
283
374
  } else if (actionName === 'plant') {
284
- // Plant should end with mostly planted cells
285
- const base = (ratios.planted * 0.98) - (ratios.tilled * 0.3) - (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);
286
377
  score = withConf(base);
287
- threshold = 0.65;
288
- if (ratios.planted < 0.5) 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';
289
381
  } else if (actionName === 'harvest') {
290
- // Harvest is good if planted tiles dominate
291
- const base = (ratios.planted * 0.9) - (ratios.tilled * 0.1) - (ratios.unknown * 0.2);
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);
292
384
  score = withConf(base);
293
- threshold = 0.5;
294
- if (ratios.planted < 0.3) reason = 'no_plants_to_harvest';
385
+ threshold = 0.40;
386
+ if (ratios.harvest < 0.3) reason = 'no_crops_to_harvest';
295
387
  } else {
296
388
  score = withConf(0.6 - ratios.unknown * 0.3);
297
389
  threshold = 0.6;
@@ -306,10 +398,12 @@ function evaluateActionScores(actionName, analysis) {
306
398
  threshold: +threshold.toFixed(3),
307
399
  confidence: +conf.toFixed(3),
308
400
  reason,
309
- counts: { tilled, planted, unknown, slots },
401
+ counts: { harvest, planted, water, tilled, unknown, slots },
310
402
  ratios: {
311
- tilled: +ratios.tilled.toFixed(3),
403
+ harvest: +ratios.harvest.toFixed(3),
312
404
  planted: +ratios.planted.toFixed(3),
405
+ water: +ratios.water.toFixed(3),
406
+ tilled: +ratios.tilled.toFixed(3),
313
407
  unknown: +ratios.unknown.toFixed(3),
314
408
  },
315
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;
@@ -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}`);
@@ -3180,10 +3214,12 @@ async function start(apiKey, apiUrl) {
3180
3214
  loginLines.push(` ${'─'.repeat(loginVis)}`);
3181
3215
  for (const l of loginLines) console.log(l);
3182
3216
 
3183
- // Dynamically capture the starting row of the login table via DSR
3217
+ // Dynamically capture the starting row of the login table via DSR.
3218
+ // Write MARKER to stderr (not stdout) to avoid PTY cooked-mode echoing
3219
+ // of the visible "@MARKER@@" text portion, which was causing the DSR
3220
+ // response to be swallowed or delayed.
3184
3221
  let loginBaseRow = 1;
3185
3222
  const captureLoginRow = () => new Promise(resolve => {
3186
- process.stdout.write(MARKER);
3187
3223
  const chunks = [];
3188
3224
  const handler = (chunk) => {
3189
3225
  chunks.push(chunk);
@@ -3196,6 +3232,8 @@ async function start(apiKey, apiUrl) {
3196
3232
  }
3197
3233
  };
3198
3234
  process.stdin.on('data', handler);
3235
+ // Write to stderr so PTY doesn't echo the visible MARKER text to stdout
3236
+ process.stderr.write(MARKER);
3199
3237
  setTimeout(resolve, 50);
3200
3238
  });
3201
3239
  await captureLoginRow();
@@ -3297,7 +3335,7 @@ async function start(apiKey, apiUrl) {
3297
3335
  const invVis = 7 + iColNum + iColName + iColItems + iColVal + 12;
3298
3336
 
3299
3337
  // Print a unique marker, query its position, then overwrite it with the table
3300
- process.stdout.write(MARKER);
3338
+ // Set up stdin handler BEFORE writing MARKER (same fix as Phase 1 — avoids race)
3301
3339
  let invBaseRow = 1;
3302
3340
  const captureRow = () => new Promise(resolve => {
3303
3341
  const chunks = [];
@@ -3312,6 +3350,8 @@ async function start(apiKey, apiUrl) {
3312
3350
  }
3313
3351
  };
3314
3352
  process.stdin.on('data', handler);
3353
+ // Write to stderr so PTY doesn't echo the visible MARKER text to stdout
3354
+ process.stderr.write(MARKER);
3315
3355
  setTimeout(resolve, 50);
3316
3356
  });
3317
3357
  await captureRow();
@@ -3380,7 +3420,7 @@ async function start(apiKey, apiUrl) {
3380
3420
  const balVis = 7 + bColNum + bColName + bColWallet + bColBank + bColTotal + bColLs + 14;
3381
3421
 
3382
3422
  // Capture starting row for balance phase
3383
- process.stdout.write(MARKER);
3423
+ // Set up stdin handler BEFORE writing MARKER (same fix — avoids race + PTY echo)
3384
3424
  let balBaseRow = 1;
3385
3425
  const balCaptureRow = () => new Promise(resolve => {
3386
3426
  const chunks = [];
@@ -3395,6 +3435,8 @@ async function start(apiKey, apiUrl) {
3395
3435
  }
3396
3436
  };
3397
3437
  process.stdin.on('data', handler);
3438
+ // Write to stderr so PTY doesn't echo the visible MARKER text to stdout
3439
+ process.stderr.write(MARKER);
3398
3440
  setTimeout(resolve, 50);
3399
3441
  });
3400
3442
  await balCaptureRow();
@@ -3457,14 +3499,16 @@ async function start(apiKey, apiUrl) {
3457
3499
 
3458
3500
 
3459
3501
  // 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 = [];
3502
+ const dmCheckPulse = BRAILLE_SPIN[Math.floor(Date.now() / 80) % BRAILLE_SPIN.length];
3503
+ console.log(` ${rgb(139, 92, 246)}${dmCheckPulse}${c.reset} ${c.dim}Checking DM history...${c.reset}`);
3504
+ let dmDeaths = 0, dmLevelUps = 0, dmNoLs = [], dmUnknown = [];
3462
3505
  for (const w of activeWorkers) {
3463
3506
  try {
3464
3507
  const dm = await w.checkDmHistory();
3465
3508
  if (dm.deaths > 0) dmDeaths += dm.deaths;
3466
3509
  if (dm.levelUps > 0) dmLevelUps += dm.levelUps;
3467
3510
  if (dm.lifesavers === 0) dmNoLs.push(w.username);
3511
+ if (dm.lifesavers === -1) dmUnknown.push(w.username);
3468
3512
  // Store level and lifesaver for dashboard
3469
3513
  if (dm.currentLevel > 0) w._level = dm.currentLevel;
3470
3514
  if (dm.lifesavers >= 0) w._lifesavers = dm.lifesavers;
@@ -3474,6 +3518,10 @@ async function start(apiKey, apiUrl) {
3474
3518
  if (dm.lifesavers >= 0) {
3475
3519
  const lc = dm.lifesavers === 0 ? rgb(239, 68, 68) : dm.lifesavers <= 2 ? rgb(251, 191, 36) : rgb(52, 211, 153);
3476
3520
  parts.push(`${lc}♥${dm.lifesavers}${c.reset}`);
3521
+ } else {
3522
+ // Unknown lifesavers — pulse to show pending
3523
+ const pulse = PULSE_CHARS[Math.floor(Date.now() / 400) % PULSE_CHARS.length];
3524
+ parts.push(`${D}${pulse}♥?${c.reset}`);
3477
3525
  }
3478
3526
  if (parts.length > 0) {
3479
3527
  console.log(` ${c.dim}├${c.reset} ${c.bold}${w.username}${c.reset} ${parts.join(' ')}`);
@@ -3492,7 +3540,16 @@ async function start(apiKey, apiUrl) {
3492
3540
  }
3493
3541
  }
3494
3542
  }
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}`);
3543
+ if (dmUnknown.length > 0) {
3544
+ console.log(` ${rgb(251, 191, 36)}⚠${c.reset} ${c.dim}Lifesavers unknown — live DM monitor active:${c.reset} ${dmUnknown.join(', ')}`);
3545
+ // Crime/search on these accounts will be skipped via safety hold until the live
3546
+ // DM gateway listener detects a death (→ sets count) or confirms clean.
3547
+ }
3548
+ const dmSummaryParts = [];
3549
+ if (dmDeaths > 0) dmSummaryParts.push(`${dmDeaths} deaths`);
3550
+ if (dmLevelUps > 0) dmSummaryParts.push(`${dmLevelUps} level-ups`);
3551
+ if (dmUnknown.length > 0) dmSummaryParts.push(`${dmUnknown.length} pending`);
3552
+ 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
3553
  console.log('');
3497
3554
 
3498
3555
  console.log(` ${rgb(139, 92, 246)}${c.bold}>>>${c.reset} ${gradientText('Starting grind loops...', [139, 92, 246], [52, 211, 153])}`);