dankgrinder 8.100.0 → 8.101.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.
@@ -36,7 +36,7 @@
36
36
  const {
37
37
  LOG, c, sleep, humanDelay, getFullText, parseCoins,
38
38
  getAllButtons, getAllSelectMenus, findButton,
39
- safeClickButton, isHoldTight, logMsg,
39
+ safeClickButton, isHoldTight, logMsg, checkLevelLock,
40
40
  } = require('./utils');
41
41
 
42
42
  const RE_DISCORD_TIMESTAMP = /<t:(\d+)(?::[tTdDfFR])?>/g;
@@ -279,6 +279,10 @@ async function runAdventure({ channel, waitForDankMemer, client }) {
279
279
  return { result: 'hold tight', coins: 0, nextCooldownSec: 35 };
280
280
  }
281
281
 
282
+ // Level-lock detection — returns early if feature is gated
283
+ const lvLock = await checkLevelLock(response, 'adventure');
284
+ if (lvLock) return lvLock;
285
+
282
286
  logMsg(response, 'adventure-initial');
283
287
 
284
288
  const text = getFullText(response);
@@ -3,7 +3,7 @@
3
3
  * Simple command: send "pls beg", parse coins from response.
4
4
  */
5
5
 
6
- const { LOG, c, getFullText, parseCoins, logMsg, isHoldTight, getHoldTightReason, sleep } = require('./utils');
6
+ const { LOG, c, getFullText, parseCoins, logMsg, isHoldTight, getHoldTightReason, sleep, checkLevelLock } = require('./utils');
7
7
 
8
8
  const RE_NEWLINE = /\n/g;
9
9
 
@@ -31,6 +31,10 @@ async function runBeg({ channel, waitForDankMemer }) {
31
31
  return { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason };
32
32
  }
33
33
 
34
+ // Level-lock detection — returns early if feature is gated
35
+ const lvLock = await checkLevelLock(response, 'beg');
36
+ if (lvLock) return lvLock;
37
+
34
38
  logMsg(response, 'beg');
35
39
  const text = getFullText(response);
36
40
  const coins = parseCoins(text);
@@ -6,7 +6,7 @@
6
6
 
7
7
  const {
8
8
  LOG, c, getFullText, parseCoins, getAllButtons, safeClickButton,
9
- logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay, ensureCV2,
9
+ logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay, ensureCV2, checkLevelLock,
10
10
  } = require('./utils');
11
11
 
12
12
  const RE_BACKTICK_SCORE = /`\s*(\d+)\s*`/;
@@ -154,6 +154,10 @@ async function runBlackjack({ channel, waitForDankMemer, betAmount = 5000 }) {
154
154
  return { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason };
155
155
  }
156
156
 
157
+ // Level-lock detection — returns early if feature is gated
158
+ const lvLock = await checkLevelLock(current, 'bj');
159
+ if (lvLock) return lvLock;
160
+
157
161
  logMsg(current, 'bj');
158
162
 
159
163
  const MAX_ROUNDS = 10;
@@ -11,7 +11,7 @@
11
11
  const {
12
12
  LOG, c, getFullText, parseCoins, getAllButtons, safeClickButton,
13
13
  logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay,
14
- isCV2, ensureCV2,
14
+ isCV2, ensureCV2, checkLevelLock,
15
15
  } = require('./utils');
16
16
  const { Trie, VoseAlias, LRUCache } = require('../structures');
17
17
 
@@ -101,6 +101,10 @@ async function runCrime({ channel, waitForDankMemer, safeAnswers }) {
101
101
  return { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason };
102
102
  }
103
103
 
104
+ // Level-lock detection — returns early if feature is gated
105
+ const lvLock = await checkLevelLock(response, 'crime');
106
+ if (lvLock) return lvLock;
107
+
104
108
  if (isCV2(response)) await ensureCV2(response);
105
109
  logMsg(response, 'crime');
106
110
  let buttons = getAllButtons(response);
@@ -6,7 +6,7 @@
6
6
  const {
7
7
  LOG, c, getFullText, parseCoins, getAllButtons, safeClickButton, humanDelay,
8
8
  logMsg, isHoldTight, getHoldTightReason, sleep, needsItem,
9
- isCV2, ensureCV2, stripAnsi,
9
+ isCV2, ensureCV2, stripAnsi, checkLevelLock,
10
10
  } = require('./utils');
11
11
  const { buyItem } = require('./shop');
12
12
 
@@ -111,6 +111,10 @@ async function runDig({ channel, waitForDankMemer, client }) {
111
111
  return { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason };
112
112
  }
113
113
 
114
+ // Level-lock detection — returns early if feature is gated
115
+ const lvLock = await checkLevelLock(response, 'dig');
116
+ if (lvLock) return lvLock;
117
+
114
118
  if (isCV2(response)) await ensureCV2(response);
115
119
  logMsg(response, 'dig');
116
120
  const text = getFullText(response);
@@ -1,7 +1,7 @@
1
1
  const {
2
2
  LOG, c, getFullText, parseCoins, getAllButtons, getAllSelectMenus,
3
3
  safeClickButton, logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay,
4
- isCV2, ensureCV2, stripAnsi, needsItem, clickCV2SelectMenu,
4
+ isCV2, ensureCV2, stripAnsi, needsItem, clickCV2SelectMenu, checkLevelLock,
5
5
  } = require('./utils');
6
6
  const { buyItem, buyItemsBatch } = require('./shop');
7
7
  const rawLogger = require('../../lib/rawLogger');
@@ -1317,7 +1317,7 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1317
1317
 
1318
1318
  let text = getFullText(response);
1319
1319
  let clean = brief(text, 600);
1320
- const lower = clean.toLowerCase();
1320
+ let lower = clean.toLowerCase();
1321
1321
 
1322
1322
  // Subcommand required — retry once.
1323
1323
  if (lower.includes('must specify a subcommand')) {
@@ -1331,8 +1331,13 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1331
1331
  logFarmState('retry-view', response);
1332
1332
  text = getFullText(response);
1333
1333
  clean = brief(text, 600);
1334
+ lower = clean.toLowerCase();
1334
1335
  }
1335
1336
 
1337
+ // Level-locked detection parity with other handlers (pm/hl/etc).
1338
+ const lvLock = await checkLevelLock(response, 'farm');
1339
+ if (lvLock) return lvLock;
1340
+
1336
1341
  const cd = parseFarmCooldownSec(text);
1337
1342
  if (cd || lower.includes('already farmed') || lower.includes('farm again') || lower.includes('on cooldown')) {
1338
1343
  return { result: `farm cooldown (${Math.ceil((cd || 10) / 60)}m)`, coins: 0, nextCooldownSec: cd || 10 };
@@ -17,7 +17,7 @@
17
17
  const {
18
18
  LOG, c, getFullText, parseCoins, getAllButtons, safeClickButton,
19
19
  logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay,
20
- isCV2, ensureCV2,
20
+ isCV2, ensureCV2, checkLevelLock,
21
21
  } = require('./utils');
22
22
  const { downloadImage, extractImageUrl, findSafeCells } = require('./fishVision');
23
23
 
@@ -319,6 +319,10 @@ async function runFish({ channel, waitForDankMemer }) {
319
319
  return { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason };
320
320
  }
321
321
 
322
+ // Level-lock detection — returns early if feature is gated
323
+ const lvLock = await checkLevelLock(response, 'fish');
324
+ if (lvLock) return lvLock;
325
+
322
326
  logMsg(response, 'fish');
323
327
  const text = getFullText(response);
324
328
 
@@ -11,7 +11,7 @@
11
11
 
12
12
  const {
13
13
  LOG, c, getFullText, parseCoins, parseNetCoins, getAllButtons, safeClickButton,
14
- logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay, ensureCV2,
14
+ logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay, ensureCV2, checkLevelLock,
15
15
  } = require('./utils');
16
16
  const { EMA, AhoCorasick, SlidingWindowCounter } = require('../structures');
17
17
 
@@ -108,6 +108,11 @@ async function commonChecks(response, cmdName) {
108
108
  return { skip: true, ret: { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason } };
109
109
  }
110
110
  const text = getFullText(response);
111
+
112
+ // Level-locked detection — uses shared helper
113
+ const lvLock = await checkLevelLock(response, cmdName);
114
+ if (lvLock) return { skip: true, ret: lvLock };
115
+
111
116
  const minBet = checkMinBet(text);
112
117
  if (minBet !== 0) {
113
118
  const val = minBet > 0 ? minBet : 0;
@@ -6,7 +6,7 @@
6
6
 
7
7
  const {
8
8
  LOG, c, getFullText, parseCoins, getAllButtons, safeClickButton,
9
- logMsg, isHoldTight, getHoldTightReason, sleep,
9
+ logMsg, isHoldTight, getHoldTightReason, sleep, checkLevelLock,
10
10
  } = require('./utils');
11
11
 
12
12
  const RE_HINT_BOLD = /hint.*?\*\*(\d+)\*\*/i;
@@ -144,6 +144,10 @@ async function runHighLow({ channel, waitForDankMemer }) {
144
144
  return { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason };
145
145
  }
146
146
 
147
+ // Level-lock detection — returns early if feature is gated
148
+ const lvLock = await checkLevelLock(response, 'hl');
149
+ if (lvLock) return lvLock;
150
+
147
151
  logMsg(response, 'hl');
148
152
  const { result, coins, lost } = await playHighLow(response);
149
153
 
@@ -7,7 +7,7 @@
7
7
  const {
8
8
  LOG, c, getFullText, parseCoins, getAllButtons, safeClickButton,
9
9
  logMsg, isHoldTight, getHoldTightReason, sleep, needsItem,
10
- isCV2, ensureCV2, stripAnsi,
10
+ isCV2, ensureCV2, stripAnsi, checkLevelLock,
11
11
  } = require('./utils');
12
12
  const { buyItem } = require('./shop');
13
13
 
@@ -95,6 +95,10 @@ async function runHunt({ channel, waitForDankMemer, client }) {
95
95
  return { result: `hold tight (${reason || 'unknown'})`, coins: 0, needsRifle: false, holdTightReason: reason };
96
96
  }
97
97
 
98
+ // Level-lock detection — returns early if feature is gated
99
+ const lvLock = await checkLevelLock(response, 'hunt');
100
+ if (lvLock) return lvLock;
101
+
98
102
  if (isCV2(response)) await ensureCV2(response);
99
103
  logMsg(response, 'hunt');
100
104
  const text = getFullText(response);
@@ -15,7 +15,7 @@
15
15
  const {
16
16
  LOG, c, getFullText, parseCoins, getAllButtons, getAllSelectMenus,
17
17
  findButton, safeClickButton, logMsg, isHoldTight, getHoldTightReason,
18
- sleep, humanDelay,
18
+ sleep, humanDelay, checkLevelLock,
19
19
  } = require('./utils');
20
20
 
21
21
  const RE_COOLDOWN_MIN = /(\d+)\s*minute/i;
@@ -52,6 +52,10 @@ async function runPostMemes({ channel, waitForDankMemer }) {
52
52
  return { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason };
53
53
  }
54
54
 
55
+ // Level-lock detection — returns early if feature is gated
56
+ const lvLock = await checkLevelLock(response, 'pm');
57
+ if (lvLock) return lvLock;
58
+
55
59
  logMsg(response, 'pm');
56
60
 
57
61
  // Check for cooldown or direct text response (no select menus)
@@ -6,7 +6,7 @@
6
6
 
7
7
  const {
8
8
  LOG, c, getFullText, parseCoins, getAllButtons, safeClickButton,
9
- logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay,
9
+ logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay, checkLevelLock,
10
10
  } = require('./utils');
11
11
  const { meetsLevelRequirement } = require('./profile');
12
12
 
@@ -42,6 +42,10 @@ async function runScratch({ channel, waitForDankMemer, accountId, redis }) {
42
42
  return { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason };
43
43
  }
44
44
 
45
+ // Level-lock detection — returns early if feature is gated
46
+ const lvLock = await checkLevelLock(response, 'scratch');
47
+ if (lvLock) return lvLock;
48
+
45
49
  logMsg(response, 'scratch');
46
50
  const buttons = getAllButtons(response);
47
51
 
@@ -11,7 +11,7 @@
11
11
  const {
12
12
  LOG, c, getFullText, parseCoins, getAllButtons, safeClickButton,
13
13
  logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay,
14
- isCV2, ensureCV2,
14
+ isCV2, ensureCV2, checkLevelLock,
15
15
  } = require('./utils');
16
16
  const { VoseAlias, Trie, EMA, LRUCache } = require('../structures');
17
17
 
@@ -99,6 +99,10 @@ async function runSearch({ channel, waitForDankMemer, safeAnswers }) {
99
99
  return { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason };
100
100
  }
101
101
 
102
+ // Level-lock detection — returns early if feature is gated
103
+ const lvLock = await checkLevelLock(response, 'search');
104
+ if (lvLock) return lvLock;
105
+
102
106
  if (isCV2(response)) await ensureCV2(response);
103
107
  logMsg(response, 'search');
104
108
  let buttons = getAllButtons(response);
@@ -1,7 +1,7 @@
1
1
  const {
2
2
  LOG, c, getFullText, parseCoins, getAllButtons, getAllSelectMenus,
3
3
  safeClickButton, logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay, needsItem,
4
- isCV2, ensureCV2, stripAnsi, clickCV2Button,
4
+ isCV2, ensureCV2, stripAnsi, clickCV2Button, checkLevelLock,
5
5
  } = require('./utils');
6
6
  const { buyItem } = require('./shop');
7
7
 
@@ -209,6 +209,10 @@ async function runStream({ channel, waitForDankMemer, client }) {
209
209
  return { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason, nextCooldownSec: 30 };
210
210
  }
211
211
 
212
+ // Level-lock detection — returns early if feature is gated
213
+ const lvLock = await checkLevelLock(response, 'stream');
214
+ if (lvLock) return lvLock;
215
+
212
216
  await hydrate(response);
213
217
  logMsg(response, 'stream');
214
218
  let text = getFullText(response);
@@ -9,7 +9,7 @@
9
9
 
10
10
  const {
11
11
  LOG, c, getFullText, parseCoins, getAllButtons, safeClickButton,
12
- logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay,
12
+ logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay, checkLevelLock,
13
13
  } = require('./utils');
14
14
 
15
15
  const RE_TRIVIA_MD_BOLD = /\*\*/g;
@@ -94,6 +94,10 @@ async function runTrivia({ channel, waitForDankMemer, redis }) {
94
94
  return { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason };
95
95
  }
96
96
 
97
+ // Level-lock detection — returns early if feature is gated
98
+ const lvLock = await checkLevelLock(response, 'trivia');
99
+ if (lvLock) return lvLock;
100
+
97
101
  logMsg(response, 'trivia');
98
102
  const buttons = getAllButtons(response);
99
103
 
@@ -703,6 +703,58 @@ function needsItem(text) {
703
703
  return match;
704
704
  }
705
705
 
706
+ // ── Level-Lock Detection (shared across ALL command handlers) ─
707
+ // Dank Memer replies "You have not unlocked this feature yet!"
708
+ // with the required level (e.g. "Level 5"). This helper checks
709
+ // the response text and returns early if the command is gated.
710
+ //
711
+ // CRITICAL: The "not unlocked" message is almost always a CV2 message
712
+ // (Components V2, flags & 32768). getFullText() returns empty for CV2
713
+ // unless ensureCV2() has been called first to hydrate _cv2text.
714
+ // We also check rawLogger as a secondary source since it captures
715
+ // the raw gateway data including CV2 component text.
716
+ const RE_LEVEL_NUM = /level\s*(\d+)/i;
717
+
718
+ async function checkLevelLock(response, cmdName) {
719
+ if (!response) return null;
720
+
721
+ // 1. Hydrate CV2 if needed so getFullText() has content
722
+ if (isCV2(response)) {
723
+ try { await ensureCV2(response); } catch (e) { /* best effort */ }
724
+ }
725
+
726
+ // 2. Primary: getFullText (works for both embed and CV2 messages)
727
+ let text = getFullText(response);
728
+
729
+ // 3. Secondary: rawLogger captures gateway data for CV2 messages
730
+ // that ensureCV2 might have missed (timing, cache)
731
+ try {
732
+ const chId = response.channelId || response.channel?.id;
733
+ if (chId) {
734
+ const raw = rawLogger.getLastRaw(chId);
735
+ if (raw) {
736
+ const rawText = (raw.content || '') + ' ' + (raw.cv2Text || '') + ' ' + (raw.embedText || '');
737
+ text = text + ' ' + rawText;
738
+ }
739
+ }
740
+ // Also try by message ID
741
+ const rawById = rawLogger.getRawMessage(response.id);
742
+ if (rawById) {
743
+ const rawText2 = (rawById.content || '') + ' ' + (rawById.cv2Text || '') + ' ' + (rawById.embedText || '');
744
+ text = text + ' ' + rawText2;
745
+ }
746
+ } catch (e) { /* rawLogger not available in tests */ }
747
+
748
+ const lower = text.toLowerCase();
749
+ if (lower.includes('not unlocked') || lower.includes('have not unlocked')) {
750
+ const lvMatch = text.match(RE_LEVEL_NUM);
751
+ const targetLv = lvMatch ? parseInt(lvMatch[1]) : 5;
752
+ LOG.warn(`[${cmdName}] LEVEL LOCKED — requires Level ${targetLv} quests`);
753
+ return { result: `level locked (L${targetLv})`, coins: 0, levelLocked: targetLv };
754
+ }
755
+ return null;
756
+ }
757
+
706
758
  module.exports = {
707
759
  DANK_MEMER_ID,
708
760
  c,
@@ -729,6 +781,7 @@ module.exports = {
729
781
  ensureCV2,
730
782
  clickCV2Button,
731
783
  clickCV2SelectMenu,
784
+ checkLevelLock,
732
785
  // Shared structures and optimized constants
733
786
  strings,
734
787
  cv2Cache,
@@ -18,7 +18,7 @@
18
18
  const {
19
19
  LOG, c, getFullText, parseCoins, getAllButtons, safeClickButton,
20
20
  logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay,
21
- isCV2, ensureCV2, stripAnsi,
21
+ isCV2, ensureCV2, stripAnsi, checkLevelLock,
22
22
  } = require('./utils');
23
23
 
24
24
  const RE_MEMORY_BACKTICK_CHUNK = /`([^`]+)`/g;
@@ -421,6 +421,10 @@ async function runWorkShift({ channel, waitForDankMemer }) {
421
421
  return { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason, nextCooldownSec: 30 };
422
422
  }
423
423
 
424
+ // Level-lock detection — returns early if feature is gated
425
+ const lvLock = await checkLevelLock(current, 'work');
426
+ if (lvLock) return lvLock;
427
+
424
428
  if (isCV2(current)) await ensureCV2(current);
425
429
 
426
430
  logMsg(current, 'work');
package/lib/grinder.js CHANGED
@@ -671,6 +671,7 @@ class AccountWorker {
671
671
  this._levelQuestQueue = [];
672
672
  this._levelQuestDone = new Set();
673
673
  this._questBetOverride = null;
674
+ this._lastCommandMeta = null;
674
675
  this._commandRunning = false; // prevents grinding commands from overlapping with quest commands
675
676
  this._verifyLevelUnlock = null; // holds level to verify after quest completion
676
677
  this.commandQueue = null;
@@ -1385,6 +1386,7 @@ class AccountWorker {
1385
1386
  // Each modular command handler sends the command, waits for response,
1386
1387
  // handles Hold Tight / cooldowns / item-buying internally.
1387
1388
  async runCommand(cmdName, prefix) {
1389
+ this._lastCommandMeta = { cmdName, nonPlay: false, spent: 0, earned: 0, holdTightReason: null, newMinBet: null, levelLocked: null };
1388
1390
  let cmdString;
1389
1391
  const bjBet = Math.max(5000, this.account.bet_amount || 5000);
1390
1392
  const gambBet = Math.max(10000, this.account.bet_amount || 10000);
@@ -1526,6 +1528,7 @@ class AccountWorker {
1526
1528
 
1527
1529
  // Rate limit detection — progressive backoff based on frequency
1528
1530
  if (resultLower.includes('slow down') || resultLower.includes('rate limit') || resultLower.includes('too fast')) {
1531
+ this._lastCommandMeta.nonPlay = true;
1529
1532
  this._rateLimitHits++;
1530
1533
  // Progressive: 30s for first hit, then 60s, 120s, cap at 300s
1531
1534
  const cooldownSec = Math.min(30 * Math.pow(2, Math.min(this._rateLimitHits - 1, 3)), 300);
@@ -1546,11 +1549,12 @@ class AccountWorker {
1546
1549
  return;
1547
1550
  }
1548
1551
 
1549
- // Level-locked detection — check rawLogger (CV2/embed text) NOT just content
1552
+ // Level-locked detection — check rawLogger (CV2/embed text) AND result text
1550
1553
  const raw = rawLogger.getLastRaw(this.channel?.id);
1551
1554
  const rawAllText = (raw?.content || '') + '\n' + (raw?.cv2Text || '') + '\n' + (raw?.embedText || '');
1552
- if (rawAllText.toLowerCase().includes('not unlocked')) {
1553
- const lvMatch = rawAllText.match(/level\s*(\d+)/i);
1555
+ const allDetectionText = rawAllText + '\n' + result;
1556
+ if (allDetectionText.toLowerCase().includes('not unlocked') || allDetectionText.toLowerCase().includes('have not unlocked')) {
1557
+ const lvMatch = allDetectionText.match(/level\s*(\d+)/i);
1554
1558
  if (lvMatch) {
1555
1559
  const targetLv = parseInt(lvMatch[1]);
1556
1560
  cmdResult.levelLocked = targetLv;
@@ -1579,11 +1583,13 @@ class AccountWorker {
1579
1583
 
1580
1584
  // Min bet detection — "You can't bet less than 10,000" or "cannot bet less than ⏣ 5,000"
1581
1585
  if (resultLower.includes("can't bet less than") || resultLower.includes('cannot bet less than') || resultLower.includes('minimum bet')) {
1586
+ this._lastCommandMeta.nonPlay = true;
1582
1587
  const betMatch = result.match(/less than\s*\*?\*?\s*[⏣o]?\s*([\d,]+)/i) || result.match(/(\d[\d,]+)/);
1583
1588
  if (betMatch) {
1584
1589
  const minBet = parseInt(betMatch[1].replace(/,/g, ''));
1585
1590
  if (minBet > 0) {
1586
1591
  this.account.bet_amount = minBet;
1592
+ this._lastCommandMeta.newMinBet = minBet;
1587
1593
  this.log('info', `${cmdName} min bet raised → ⏣ ${minBet.toLocaleString()}`);
1588
1594
  }
1589
1595
  }
@@ -1591,6 +1597,8 @@ class AccountWorker {
1591
1597
  }
1592
1598
  // Also handle newMinBet from gamble handler
1593
1599
  if (cmdResult.newMinBet && cmdResult.newMinBet > (this.account.bet_amount || 0)) {
1600
+ this._lastCommandMeta.nonPlay = true;
1601
+ this._lastCommandMeta.newMinBet = cmdResult.newMinBet;
1594
1602
  this.account.bet_amount = cmdResult.newMinBet;
1595
1603
  this.log('info', `${cmdName} min bet raised → ⏣ ${cmdResult.newMinBet.toLocaleString()}`);
1596
1604
  return;
@@ -1676,6 +1684,8 @@ class AccountWorker {
1676
1684
 
1677
1685
  const earned = Math.max(0, cmdResult.coins || 0);
1678
1686
  const spent = Math.max(0, cmdResult.lost || 0);
1687
+ this._lastCommandMeta.earned = earned;
1688
+ this._lastCommandMeta.spent = spent;
1679
1689
  // Track net earnings (add wins, subtract losses)
1680
1690
  this.stats.coins += (earned - spent);
1681
1691
  if (cmdResult.nextCooldownSec) {
@@ -1719,6 +1729,8 @@ class AccountWorker {
1719
1729
 
1720
1730
  if (cmdResult.holdTightReason) {
1721
1731
  const reason = cmdResult.holdTightReason;
1732
+ this._lastCommandMeta.nonPlay = true;
1733
+ this._lastCommandMeta.holdTightReason = reason;
1722
1734
  const holdSec = 35;
1723
1735
  this.log('warn', `Hold Tight: /${reason} — ${holdSec}s global cooldown`);
1724
1736
  const reasonMap = { postmemes: 'pm', highlow: 'hl', blackjack: 'bj', 'work shift': 'work shift' };
@@ -1731,12 +1743,16 @@ class AccountWorker {
1731
1743
  // Detect level-locked command → start quest mode
1732
1744
  if (cmdResult.levelLocked) {
1733
1745
  const targetLv = cmdResult.levelLocked;
1746
+ this._lastCommandMeta.nonPlay = true;
1747
+ this._lastCommandMeta.levelLocked = targetLv;
1734
1748
  this.log('warn', `Command /${cmdName} is LOCKED at Level ${targetLv} — starting quest unlock flow`);
1735
1749
  if (this._startLevelQuests(targetLv)) {
1736
1750
  this.log('info', `[QUEST] Quest mode activated for Level ${targetLv} — grinding will resume after quests complete`);
1737
1751
  } else {
1738
1752
  this.log('warn', `[QUEST] Level ${targetLv} quests already done or not defined — grinding continues (may re-trigger)`);
1739
1753
  }
1754
+ // Don't count as success — skip normal post-command processing
1755
+ return;
1740
1756
  }
1741
1757
 
1742
1758
  this.stats.successes++;
@@ -1773,6 +1789,7 @@ class AccountWorker {
1773
1789
  } catch {}
1774
1790
  }
1775
1791
  } catch (err) {
1792
+ this._lastCommandMeta.nonPlay = true;
1776
1793
  this.stats.errors++;
1777
1794
  this.log('error', `${cmdString} failed: ${err.message}`);
1778
1795
  await sendLog(this.username, cmdName, err.message, 'error');
@@ -1900,9 +1917,10 @@ class AccountWorker {
1900
1917
  { cmd: 'shop sell common coin 1', times: 2 },
1901
1918
  ],
1902
1919
  5: [
1903
- { cmd: 'slots', times: 1, bet: 50000 },
1904
- { cmd: 'cointoss', times: 1, bet: 50000 },
1905
- { cmd: 'snakeeyes', times: 1, bet: 50000 },
1920
+ // "Lose 50,000 coins in /slots" etc — loseTarget means keep playing until cumulative losses hit 50k
1921
+ { cmd: 'slots', loseTarget: 50000, bet: 10000, times: 50 },
1922
+ { cmd: 'cointoss', loseTarget: 50000, bet: 10000, times: 50 },
1923
+ { cmd: 'snakeeyes', loseTarget: 50000, bet: 10000, times: 50 },
1906
1924
  ],
1907
1925
  };
1908
1926
 
@@ -1910,14 +1928,17 @@ class AccountWorker {
1910
1928
  if (this._levelQuestDone.has(targetLevel)) return false;
1911
1929
  const quests = AccountWorker.LEVEL_QUESTS[targetLevel];
1912
1930
  if (!quests || quests.length === 0) return false;
1913
- this._levelQuestQueue = quests.map(q => ({ ...q, level: targetLevel }));
1931
+ this._levelQuestQueue = quests.map(q => ({ ...q, level: targetLevel, _lostSoFar: 0 }));
1914
1932
  this._levelQuestActive = true;
1915
1933
  this._questBetOverride = null;
1916
1934
  // Clear commandQueue so no stale grinding commands fire after quests finish
1917
1935
  this.commandQueue = null;
1918
1936
  this._commandRunning = false; // cancel any in-flight grinding command
1919
1937
  this.log('info', `[QUEST] Level ${targetLevel} quests started — grinding PAUSED`);
1920
- const questList = this._levelQuestQueue.map(q => `"${q.cmd}" x${q.times}`).join(', ');
1938
+ const questList = this._levelQuestQueue.map(q => {
1939
+ if (q.loseTarget) return `"${q.cmd}" lose ⏣${q.loseTarget.toLocaleString()}`;
1940
+ return `"${q.cmd}" x${q.times}`;
1941
+ }).join(', ');
1921
1942
  this.log('info', `[QUEST] Quests: ${questList}`);
1922
1943
  return true;
1923
1944
  }
@@ -1959,7 +1980,8 @@ class AccountWorker {
1959
1980
  const ttlVal = ttlMap.get(key);
1960
1981
  if (Number.isFinite(ttlVal) && ttlVal > 0) {
1961
1982
  nextRunAt = now + ttlVal * 1000;
1962
- this._cooldownBloom.add(key);
1983
+ // Bloom uses accountId:cmd keys (not Redis key format)
1984
+ this._cooldownBloom.add(`${this.account.id}:${info.cmd}`);
1963
1985
  this.log('info', `Restored cooldown for ${info.cmd}: ${ttlVal}s remaining`);
1964
1986
  }
1965
1987
  heap.push({ cmd: info.cmd, nextRunAt, priority: info.priority, info });
@@ -2202,18 +2224,85 @@ class AccountWorker {
2202
2224
  // BLOCK: quest mode active — run quests only, no normal grinding
2203
2225
  if (this._levelQuestActive && this._levelQuestQueue.length > 0) {
2204
2226
  const quest = this._levelQuestQueue[0];
2205
- this._questBetOverride = quest.bet || null;
2227
+ if (quest.loseTarget) {
2228
+ const remaining = Math.max(0, quest.loseTarget - (quest._lostSoFar || 0));
2229
+ const floorBet = quest._minBet || quest.bet || 1;
2230
+ this._questBetOverride = Math.max(floorBet, remaining);
2231
+ } else {
2232
+ this._questBetOverride = quest.bet || null;
2233
+ }
2234
+ const questBetUsed = this._questBetOverride;
2206
2235
  const prefix = this.account.use_slash ? '/' : 'pls';
2207
- this.setStatus(`[L${quest.level}] QUEST ${quest.cmd} (${quest.times}x left)`);
2208
- this.lastStatus = `[L${quest.level}] ${quest.cmd}`;
2236
+
2237
+ if (quest.loseTarget) {
2238
+ // Loss-target quest: keep playing until cumulative losses >= loseTarget
2239
+ const remaining = quest.loseTarget - (quest._lostSoFar || 0);
2240
+ this.setStatus(`[L${quest.level}] QUEST ${quest.cmd} — lose ⏣${remaining.toLocaleString()} more`);
2241
+ this.lastStatus = `[L${quest.level}] ${quest.cmd} lose ${remaining}`;
2242
+ } else {
2243
+ this.setStatus(`[L${quest.level}] QUEST ${quest.cmd} (${quest.times}x left)`);
2244
+ this.lastStatus = `[L${quest.level}] ${quest.cmd}`;
2245
+ }
2246
+
2247
+ // Capture balance before the command
2248
+ const balBefore = this.stats.balance || 0;
2249
+
2209
2250
  this._commandRunning = true;
2210
- this.busy = true; // also set busy so grinding tick path blocks while quest runs
2251
+ this.busy = true;
2211
2252
  await this.runCommand(quest.cmd, prefix);
2253
+ const questMeta = this._lastCommandMeta || {};
2212
2254
  this._commandRunning = false;
2213
2255
  this.busy = false;
2214
2256
  this._questBetOverride = null;
2215
- quest.times--;
2216
- if (quest.times <= 0) this._levelQuestQueue.shift();
2257
+
2258
+ // Determine if quest step is complete
2259
+ let questStepDone = false;
2260
+
2261
+ if (quest.loseTarget) {
2262
+ // Track cumulative loss from balance diff
2263
+ const balAfter = this.stats.balance || 0;
2264
+ const balLoss = Math.max(0, balBefore - balAfter);
2265
+ const reportedLoss = Math.max(0, Number(questMeta.spent || 0));
2266
+ let lostThisRound = Math.max(balLoss, reportedLoss);
2267
+
2268
+ const nonPlay = !!questMeta.nonPlay;
2269
+ if (questMeta.newMinBet && Number.isFinite(questMeta.newMinBet) && questMeta.newMinBet > 0) {
2270
+ quest._minBet = Math.max(quest._minBet || 1, questMeta.newMinBet);
2271
+ }
2272
+
2273
+ // Fallback only for actual played rounds where parsed/observed loss was zero.
2274
+ if (!nonPlay && lostThisRound === 0 && (questBetUsed || quest.bet)) {
2275
+ lostThisRound = questBetUsed || quest.bet;
2276
+ }
2277
+
2278
+ if (!nonPlay) {
2279
+ quest._lostSoFar = (quest._lostSoFar || 0) + lostThisRound;
2280
+ }
2281
+
2282
+ this.log('info', `[QUEST] ${quest.cmd} — lost ⏣${quest._lostSoFar.toLocaleString()} / ⏣${quest.loseTarget.toLocaleString()}`);
2283
+ if (nonPlay) {
2284
+ this.log('warn', `[QUEST] ${quest.cmd} non-play response — progress unchanged`);
2285
+ }
2286
+
2287
+ if (quest._lostSoFar >= quest.loseTarget) {
2288
+ questStepDone = true;
2289
+ this.log('success', `[QUEST] ${quest.cmd} loss target reached! ⏣${quest._lostSoFar.toLocaleString()} lost`);
2290
+ }
2291
+
2292
+ // Safety attempts decrement only when a playable round happened.
2293
+ if (!nonPlay) quest.times--;
2294
+ if (quest.times <= 0) {
2295
+ questStepDone = true;
2296
+ this.log('info', `[QUEST] ${quest.cmd} max attempts reached — moving on`);
2297
+ }
2298
+ } else {
2299
+ // Normal times-based quest
2300
+ quest.times--;
2301
+ if (quest.times <= 0) questStepDone = true;
2302
+ }
2303
+
2304
+ if (questStepDone) this._levelQuestQueue.shift();
2305
+
2217
2306
  if (this._levelQuestQueue.length === 0) {
2218
2307
  this._levelQuestActive = false;
2219
2308
  this._levelQuestDone.add(quest.level);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dankgrinder",
3
- "version": "8.100.0",
3
+ "version": "8.101.0",
4
4
  "description": "Dank Memer automation engine — grind coins while you sleep",
5
5
  "bin": {
6
6
  "dankgrinder": "bin/dankgrinder.js"