dankgrinder 5.16.0 → 5.20.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,
@@ -218,10 +218,13 @@ async function runInventory({ channel, waitForDankMemer, client, accountId, redi
218
218
  const allItems = [];
219
219
  let { page, total } = parsePageInfo(response);
220
220
  LOG.info(`[inv] Page ${page}/${total}`);
221
+ const visitedPages = new Set([page]);
221
222
 
222
223
  allItems.push(...parseInventoryPage(response));
223
224
 
224
- while (page < total) {
225
+ let guard = 0;
226
+ while (page < total && guard < Math.max(20, total + 6)) {
227
+ guard++;
225
228
  const buttons = getAllButtons(response);
226
229
  const enabled = buttons.filter(b => !b.disabled);
227
230
 
@@ -231,13 +234,43 @@ async function runInventory({ channel, waitForDankMemer, client, accountId, redi
231
234
  return m ? parseInt(m[1], 10) : null;
232
235
  };
233
236
 
234
- let nextBtn = enabled.find((b) => {
235
- const id = String(b.customId || '');
236
- if (!/paginator-inventory-list/i.test(id)) return false;
237
- if (!/setpage/i.test(id)) return false;
238
- return parseTargetPage(id) === page;
237
+ const classifyButton = (b) => {
238
+ const id = String(b.customId || '').toLowerCase();
239
+ const label = String(b.label || '').toLowerCase();
240
+ const emoji = String(b.emoji?.name || '').toLowerCase();
241
+ return { id, label, emoji };
242
+ };
243
+
244
+ const directNext = enabled.find((b) => {
245
+ const { id, label, emoji } = classifyButton(b);
246
+ if (id.includes('last') || label === '⏭' || emoji.includes('doubleright')) return false;
247
+ return id.includes('next')
248
+ || label.includes('next')
249
+ || label === '▶'
250
+ || label === '→'
251
+ || emoji.includes('arrowright');
239
252
  });
240
253
 
254
+ let nextBtn = directNext || null;
255
+
256
+ if (!nextBtn) {
257
+ const paginatorCandidates = enabled
258
+ .map((b) => ({ b, id: String(b.customId || ''), target: parseTargetPage(b.customId || '') }))
259
+ .filter((x) => /paginator-inventory-list/i.test(x.id) && /setpage/i.test(x.id) && Number.isInteger(x.target));
260
+
261
+ // Prefer the smallest target greater than current page.
262
+ // Fallback to target===current page for legacy/offset paginator ids.
263
+ const gtCurrent = paginatorCandidates
264
+ .filter(x => x.target > page)
265
+ .sort((a, b) => a.target - b.target);
266
+ if (gtCurrent.length > 0) {
267
+ nextBtn = gtCurrent[0].b;
268
+ } else {
269
+ const eqCurrent = paginatorCandidates.find(x => x.target === page);
270
+ if (eqCurrent) nextBtn = eqCurrent.b;
271
+ }
272
+ }
273
+
241
274
  if (!nextBtn) {
242
275
  nextBtn = enabled.find((b) => {
243
276
  const id = (b.customId || '').toLowerCase();
@@ -256,35 +289,13 @@ async function runInventory({ channel, waitForDankMemer, client, accountId, redi
256
289
 
257
290
  await humanDelay(200, 400);
258
291
 
259
- // Set up messageUpdate listener BEFORE clicking so we don't miss the update
260
- const msgId = response.id;
261
- const updatePromise = new Promise((resolve) => {
262
- const timeout = setTimeout(() => {
263
- client.removeListener('messageUpdate', handler);
264
- resolve(null);
265
- }, 10000);
266
- const handler = (_, m) => {
267
- if (m.id === msgId) {
268
- clearTimeout(timeout);
269
- client.removeListener('messageUpdate', handler);
270
- resolve(m);
271
- }
272
- };
273
- client.on('messageUpdate', handler);
274
- });
275
-
276
292
  try {
277
- await safeClickButton(response, nextBtn);
293
+ const clicked = await safeClickButton(response, nextBtn);
294
+ if (clicked) response = clicked;
278
295
  } catch {
279
296
  break;
280
297
  }
281
298
 
282
- // Wait for actual message update from Discord (page content change)
283
- const updated = await updatePromise;
284
- if (updated) {
285
- response = updated;
286
- }
287
-
288
299
  // Force-refetch CV2 with retries until page actually changes
289
300
  delete response._cv2;
290
301
  delete response._cv2text;
@@ -293,12 +304,18 @@ async function runInventory({ channel, waitForDankMemer, client, accountId, redi
293
304
  let pageChanged = false;
294
305
  for (let attempt = 0; attempt < 4; attempt++) {
295
306
  await sleep(attempt === 0 ? 600 : 1200);
307
+ try {
308
+ const fresh = await channel.messages.fetch(response.id);
309
+ if (fresh) response = fresh;
310
+ } catch {}
296
311
  if (isCV2(response)) await ensureCV2(response, true);
297
312
  const pageInfo = parsePageInfo(response);
298
- if (pageInfo.page > page) {
313
+ if (pageInfo.page > page && !visitedPages.has(pageInfo.page)) {
299
314
  page = pageInfo.page;
300
315
  total = pageInfo.total;
316
+ visitedPages.add(page);
301
317
  pageChanged = true;
318
+ LOG.info(`[inv] Page ${page}/${total}`);
302
319
  break;
303
320
  }
304
321
  // Clear CV2 cache again for next retry
@@ -323,7 +340,7 @@ async function runInventory({ channel, waitForDankMemer, client, accountId, redi
323
340
  }
324
341
 
325
342
  const items = Object.values(itemMap);
326
- LOG.success(`[inv] Found ${items.length} unique items across ${total} pages`);
343
+ LOG.success(`[inv] Found ${items.length} unique items across ${visitedPages.size}/${total} pages`);
327
344
 
328
345
  const { totalValue, totalMarket } = await enrichItems(items);
329
346
  LOG.info(`[inv] Net value: ${c.bold}${c.green}⏣ ${totalValue.toLocaleString()}${c.reset} Market: ${c.bold}⏣ ${totalMarket.toLocaleString()}${c.reset}`);