dankgrinder 6.8.1 → 6.14.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.
@@ -1,21 +1,22 @@
1
1
  /**
2
2
  * HighLow command handler.
3
3
  * Strategy: if hint > 50 → lower, if hint < 50 → higher, if exactly 50 → jackpot.
4
+ * Fast clicks with retry.
4
5
  */
5
6
 
6
7
  const {
7
8
  LOG, c, getFullText, parseCoins, getAllButtons, safeClickButton,
8
- logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay,
9
+ logMsg, isHoldTight, getHoldTightReason, sleep,
9
10
  } = require('./utils');
10
11
 
11
- // Pre-compiled regex — avoid V8 recompilation on every call
12
12
  const RE_HINT_BOLD = /hint.*?\*\*(\d+)\*\*/i;
13
13
  const RE_NUMBER_BOLD = /number.*?\*\*(\d+)\*\*/i;
14
14
  const RE_HINT_PLAIN = /hint.*?(\d+)/i;
15
15
  const RE_NUMBER_PLAIN = /number.*?(\d+)/i;
16
16
  const RE_STANDALONE_NUM = /\b(\d{1,3})\b/g;
17
17
  const RE_HL_NET = /Net:\s*\*{0,2}\s*[⏣]\s*\*{0,2}([+-]?[\d,]+)/i;
18
- const RE_HL_WIN = /Winnings:\s*\*{0,2}\s*[⏣]\s*\*{0,2}([\d,]+)/i;
18
+ const RE_HL_WIN = /(?:Winnings|won)\s*\*{0,2}\s*[⏣]?\s*\*{0,2}([\d,]+)/i;
19
+ const RE_HL_WON = /won\s+[⏣]\s*([\d,]+)/i;
19
20
 
20
21
  function parseHintNumber(text) {
21
22
  const hintMatch = text.match(RE_HINT_BOLD)
@@ -23,11 +24,13 @@ function parseHintNumber(text) {
23
24
  || text.match(RE_HINT_PLAIN)
24
25
  || text.match(RE_NUMBER_PLAIN);
25
26
  if (hintMatch) return parseInt(hintMatch[1]);
27
+
28
+ // Fallback: find a number 1-100 in the text
26
29
  RE_STANDALONE_NUM.lastIndex = 0;
27
30
  const nums = text.match(RE_STANDALONE_NUM);
28
31
  if (nums) {
29
- for (let i = 0; i < nums.length; i++) {
30
- const v = parseInt(nums[i]);
32
+ for (const n of nums) {
33
+ const v = parseInt(n);
31
34
  if (v >= 1 && v <= 100) return v;
32
35
  }
33
36
  }
@@ -35,6 +38,9 @@ function parseHintNumber(text) {
35
38
  }
36
39
 
37
40
  function parseNetCoins(text) {
41
+ // Try "You won ⏣ 6,303" first
42
+ const wonMatch = text.match(RE_HL_WON);
43
+ if (wonMatch) return parseInt(wonMatch[1].replace(/,/g, ''));
38
44
  const netMatch = text.match(RE_HL_NET);
39
45
  if (netMatch) return parseInt(netMatch[1].replace(/,/g, ''));
40
46
  const winMatch = text.match(RE_HL_WIN);
@@ -42,12 +48,31 @@ function parseNetCoins(text) {
42
48
  return 0;
43
49
  }
44
50
 
51
+ // Fast click with retry
52
+ async function clickFast(msg, btn, retries = 2) {
53
+ for (let attempt = 0; attempt <= retries; attempt++) {
54
+ try {
55
+ await sleep(50 + Math.random() * 100); // 50-150ms
56
+ const result = await safeClickButton(msg, btn);
57
+ if (result) return result;
58
+ } catch (e) {
59
+ if (attempt === retries) {
60
+ LOG.error(`[hl] Click failed: ${e.message}`);
61
+ return null;
62
+ }
63
+ await sleep(200);
64
+ }
65
+ }
66
+ return null;
67
+ }
68
+
45
69
  async function playHighLow(response, depth = 0) {
46
70
  if (!response || depth > 5) return { result: 'done', coins: 0 };
47
71
 
48
72
  const text = getFullText(response);
49
73
  const buttons = getAllButtons(response);
50
74
 
75
+ // Game over — no buttons or all disabled
51
76
  if (buttons.length === 0 || buttons.every(b => b.disabled)) {
52
77
  const net = parseNetCoins(text);
53
78
  const coins = net > 0 ? net : parseCoins(text);
@@ -64,7 +89,7 @@ async function playHighLow(response, depth = 0) {
64
89
  } else if (hint < 50) {
65
90
  targetBtn = buttons.find(b => (b.label || '').toLowerCase().includes('higher'));
66
91
  } else {
67
- // Exactly 50 try jackpot, fallback to higher (slightly better odds)
92
+ // 50 = jackpot attempt, fallback to higher
68
93
  targetBtn = buttons.find(b => (b.label || '').toLowerCase().includes('jackpot'))
69
94
  || buttons.find(b => (b.label || '').toLowerCase().includes('higher'));
70
95
  }
@@ -72,7 +97,7 @@ async function playHighLow(response, depth = 0) {
72
97
  LOG.info(`[hl] Hint: ${hint} → ${targetBtn?.label || '?'}`);
73
98
  } else {
74
99
  targetBtn = buttons.find(b => !b.disabled) || buttons[0];
75
- LOG.info(`[hl] No hint parsed → ${targetBtn?.label || '?'}`);
100
+ LOG.info(`[hl] No hint → ${targetBtn?.label || '?'}`);
76
101
  }
77
102
 
78
103
  if (!targetBtn || targetBtn.disabled) {
@@ -80,27 +105,22 @@ async function playHighLow(response, depth = 0) {
80
105
  return { result: 'buttons disabled', coins };
81
106
  }
82
107
 
83
- await humanDelay(150, 400);
84
-
85
- try {
86
- const followUp = await safeClickButton(response, targetBtn);
87
- if (followUp) {
88
- logMsg(followUp, `hl-round-${depth}`);
89
- const fText = getFullText(followUp);
90
-
91
- const moreButtons = getAllButtons(followUp);
92
- if (moreButtons.length >= 2 && !moreButtons.every(b => b.disabled)) {
93
- return playHighLow(followUp, depth + 1);
94
- }
108
+ const followUp = await clickFast(response, targetBtn);
109
+ if (followUp) {
110
+ logMsg(followUp, `hl-round-${depth}`);
111
+ const fText = getFullText(followUp);
95
112
 
96
- // Final round parse net earnings
97
- const net = parseNetCoins(fText);
98
- const coins = net > 0 ? net : parseCoins(fText);
99
- const lost = net < 0 ? Math.abs(net) : 0;
100
- return { result: `${targetBtn.label} → ${coins > 0 ? '+⏣ ' + coins.toLocaleString() : 'done'}`, coins, lost };
113
+ // Check if there are more rounds
114
+ const moreButtons = getAllButtons(followUp);
115
+ if (moreButtons.length >= 2 && !moreButtons.every(b => b.disabled)) {
116
+ return playHighLow(followUp, depth + 1);
101
117
  }
102
- } catch (e) {
103
- LOG.error(`[hl] Click error: ${e.message}`);
118
+
119
+ // Final round — parse result
120
+ const net = parseNetCoins(fText);
121
+ const coins = net > 0 ? net : parseCoins(fText);
122
+ const lost = net < 0 ? Math.abs(net) : 0;
123
+ return { result: `${targetBtn.label} → ${coins > 0 ? '+⏣ ' + coins.toLocaleString() : 'done'}`, coins, lost };
104
124
  }
105
125
 
106
126
  return { result: 'done', coins: 0 };
@@ -110,7 +130,7 @@ async function runHighLow({ channel, waitForDankMemer }) {
110
130
  LOG.cmd(`${c.white}${c.bold}pls hl${c.reset}`);
111
131
 
112
132
  await channel.send('pls hl');
113
- const response = await waitForDankMemer(10000);
133
+ const response = await waitForDankMemer(12000);
114
134
 
115
135
  if (!response) {
116
136
  LOG.warn('[hl] No response');
@@ -119,7 +139,7 @@ async function runHighLow({ channel, waitForDankMemer }) {
119
139
 
120
140
  if (isHoldTight(response)) {
121
141
  const reason = getHoldTightReason(response);
122
- LOG.warn(`[hl] Hold Tight${reason ? ` (reason: /${reason})` : ''} — waiting 30s`);
142
+ LOG.warn(`[hl] Hold Tight${reason ? ` (/${reason})` : ''} — waiting 30s`);
123
143
  await sleep(30000);
124
144
  return { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason };
125
145
  }
@@ -1,26 +1,87 @@
1
1
  /**
2
2
  * Hunt command handler.
3
3
  * Send "pls hunt", detect if rifle is missing, auto-buy if needed.
4
+ * Handles dragon fireball dodge minigame (Left/Middle/Right).
4
5
  */
5
6
 
6
7
  const {
7
- LOG, c, getFullText, parseCoins, logMsg, isHoldTight, getHoldTightReason, sleep, needsItem,
8
+ LOG, c, getFullText, parseCoins, getAllButtons, safeClickButton,
9
+ logMsg, isHoldTight, getHoldTightReason, sleep, needsItem,
8
10
  isCV2, ensureCV2, stripAnsi,
9
11
  } = require('./utils');
10
12
  const { buyItem } = require('./shop');
11
13
 
14
+ // Fast click with retry
15
+ async function clickFast(msg, btn, retries = 2) {
16
+ for (let attempt = 0; attempt <= retries; attempt++) {
17
+ try {
18
+ await sleep(50 + Math.random() * 100);
19
+ const result = await safeClickButton(msg, btn);
20
+ if (result) return result;
21
+ } catch (e) {
22
+ if (attempt === retries) {
23
+ LOG.error(`[hunt] Click failed: ${e.message}`);
24
+ return null;
25
+ }
26
+ await sleep(200);
27
+ }
28
+ }
29
+ return null;
30
+ }
31
+
12
32
  /**
13
- * @param {object} opts
14
- * @param {object} opts.channel
15
- * @param {function} opts.waitForDankMemer
16
- * @param {object} [opts.client] - Discord client for modal handling
17
- * @returns {Promise<{result: string, coins: number, needsRifle: boolean}>}
33
+ * Dragon fireball dodge minigame.
34
+ * The embed description has emoji layout showing where the dragon is.
35
+ * Dragon can be on left, middle, or right — pick a different lane.
36
+ *
37
+ * Pattern: Dragon emoji in the description indicates its position.
38
+ * - If dragon is on the left side → pick Middle or Right
39
+ * - If dragon is on the right side → pick Left or Middle
40
+ * - If dragon is in the middle → pick Left or Right
41
+ * - Default: pick randomly
18
42
  */
43
+ function detectDragonPosition(text) {
44
+ const lower = (text || '').toLowerCase();
45
+ // Look at the line with the dragon emoji
46
+ // Format: # <emptyspace><emptyspace><Dragon> (right)
47
+ // Format: # <Dragon><emptyspace><emptyspace> (left)
48
+ // Format: # <emptyspace><Dragon><emptyspace> (middle)
49
+ const lines = text.split('\n');
50
+ for (const line of lines) {
51
+ if (!line.includes('Dragon') && !line.includes('dragon') && !line.includes('Fireball') && !line.includes('fireball')) continue;
52
+ // Count position of dragon relative to emptyspace
53
+ const parts = line.split(/[<>]+/).filter(p => p.includes('Dragon') || p.includes('emptyspace'));
54
+ let dragonIdx = -1;
55
+ let total = 0;
56
+ for (let i = 0; i < parts.length; i++) {
57
+ if (parts[i].includes('Dragon') || parts[i].includes('dragon')) dragonIdx = total;
58
+ total++;
59
+ }
60
+ if (dragonIdx === 0) return 'left';
61
+ if (dragonIdx >= total - 1) return 'right';
62
+ return 'middle';
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function pickDodgeLane(dragonPos, buttons) {
68
+ const labels = buttons.map(b => (b.label || '').toLowerCase());
69
+ const leftBtn = buttons.find(b => (b.label || '').toLowerCase() === 'left');
70
+ const midBtn = buttons.find(b => (b.label || '').toLowerCase() === 'middle');
71
+ const rightBtn = buttons.find(b => (b.label || '').toLowerCase() === 'right');
72
+
73
+ if (dragonPos === 'left') return midBtn || rightBtn || buttons[1] || buttons[0];
74
+ if (dragonPos === 'right') return leftBtn || midBtn || buttons[0] || buttons[1];
75
+ if (dragonPos === 'middle') return leftBtn || rightBtn || buttons[0] || buttons[2];
76
+ // Unknown position — pick randomly from non-middle (safer)
77
+ return buttons[Math.floor(Math.random() * buttons.length)];
78
+ }
79
+
19
80
  async function runHunt({ channel, waitForDankMemer, client }) {
20
81
  LOG.cmd(`${c.white}${c.bold}pls hunt${c.reset}`);
21
82
 
22
83
  await channel.send('pls hunt');
23
- const response = await waitForDankMemer(10000);
84
+ const response = await waitForDankMemer(12000);
24
85
 
25
86
  if (!response) {
26
87
  LOG.warn('[hunt] No response');
@@ -29,7 +90,7 @@ async function runHunt({ channel, waitForDankMemer, client }) {
29
90
 
30
91
  if (isHoldTight(response)) {
31
92
  const reason = getHoldTightReason(response);
32
- LOG.warn(`[hunt] Hold Tight${reason ? ` (reason: /${reason})` : ''} — waiting 30s`);
93
+ LOG.warn(`[hunt] Hold Tight${reason ? ` (/${reason})` : ''} — waiting 30s`);
33
94
  await sleep(30000);
34
95
  return { result: `hold tight (${reason || 'unknown'})`, coins: 0, needsRifle: false, holdTightReason: reason };
35
96
  }
@@ -39,47 +100,107 @@ async function runHunt({ channel, waitForDankMemer, client }) {
39
100
  const text = getFullText(response);
40
101
  const cleanText = stripAnsi(text).replace(/\s+/g, ' ').trim();
41
102
  const textLower = cleanText.toLowerCase();
42
- const missing = needsItem(cleanText);
43
103
 
104
+ // Check for missing rifle
105
+ const missing = needsItem(cleanText);
44
106
  const rifleMissing = missing === 'hunting rifle'
45
- || /(?:don[']?t have|do not have|need|needs|requires?|missing|must have|you need|lack)\s+(?:a\s+)?(?:hunting\s+)?rifle/.test(textLower)
107
+ || /(?:don['']?t have|do not have|need|needs|requires?|missing|must have|you need|lack)\s+(?:a\s+)?(?:hunting\s+)?rifle/.test(textLower)
46
108
  || ((textLower.includes('following items') || textLower.includes('missing items')) && textLower.includes('rifle'));
47
109
 
48
- // Check if we need a rifle
49
110
  if (rifleMissing) {
50
- LOG.warn('[hunt] No rifle! Attempting to buy...');
51
-
52
- const bought = await buyItem({
53
- channel, waitForDankMemer, client,
54
- itemName: 'Hunting Rifle',
55
- quantity: 1,
56
- });
57
-
111
+ LOG.warn('[hunt] No rifle! Buying...');
112
+ const bought = await buyItem({ channel, waitForDankMemer, client, itemName: 'Hunting Rifle', quantity: 1 });
58
113
  if (bought) {
59
- LOG.success('[hunt] Rifle purchased! Re-running hunt...');
60
- // Drain stale shop messages
61
- while (await waitForDankMemer(1500)) { /* drain */ }
114
+ LOG.success('[hunt] Rifle bought! Retrying...');
115
+ while (await waitForDankMemer(1500)) {}
62
116
  await sleep(1000);
63
-
64
117
  await channel.send('pls hunt');
65
- const r2 = await waitForDankMemer(10000);
118
+ const r2 = await waitForDankMemer(12000);
66
119
  if (r2) {
67
120
  if (isCV2(r2)) await ensureCV2(r2);
68
121
  logMsg(r2, 'hunt-retry');
69
- const t2 = getFullText(r2);
70
- const c2 = parseCoins(t2);
71
- if (c2 > 0) {
72
- LOG.coin(`[hunt] ${c.green}+⏣ ${c2.toLocaleString()}${c.reset}`);
73
- return { result: `hunt → +⏣ ${c2.toLocaleString()}`, coins: c2, needsRifle: false };
74
- }
75
- return { result: stripAnsi(t2).replace(/\s+/g, ' ').trim().substring(0, 60), coins: 0, needsRifle: false };
122
+ // Handle minigame on retry too
123
+ return await handleHuntResponse(r2, channel, waitForDankMemer);
76
124
  }
77
125
  return { result: 'no response after rifle buy', coins: 0, needsRifle: false };
78
126
  }
79
-
80
127
  return { result: 'need rifle (buy failed)', coins: 0, needsRifle: true };
81
128
  }
82
129
 
130
+ return await handleHuntResponse(response, channel, waitForDankMemer);
131
+ }
132
+
133
+ async function handleHuntResponse(response, channel, waitForDankMemer) {
134
+ const text = getFullText(response);
135
+ const cleanText = stripAnsi(text).replace(/\s+/g, ' ').trim();
136
+ const textLower = cleanText.toLowerCase();
137
+
138
+ // Check for minigame buttons (dragon dodge, etc.)
139
+ const buttons = getAllButtons(response).filter(b => !b.disabled && b.label);
140
+ if (buttons.length >= 2) {
141
+ const labels = buttons.map(b => (b.label || '').toLowerCase());
142
+ const isDragonDodge = textLower.includes('dragon') || textLower.includes('fireball') || textLower.includes('dodge');
143
+ const isLaneGame = labels.includes('left') || labels.includes('middle') || labels.includes('right');
144
+
145
+ if (isDragonDodge && isLaneGame) {
146
+ // Dragon fireball dodge
147
+ const dragonPos = detectDragonPosition(text);
148
+ const btn = pickDodgeLane(dragonPos, buttons);
149
+ LOG.info(`[hunt] Dragon at ${dragonPos || '?'} → dodging "${btn.label}"`);
150
+ const followUp = await clickFast(response, btn);
151
+ if (followUp) {
152
+ logMsg(followUp, 'hunt-dodge');
153
+ const fText = getFullText(followUp);
154
+ const coins = parseCoins(fText);
155
+ if (coins > 0) {
156
+ LOG.coin(`[hunt] ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`);
157
+ return { result: `hunt dodge → +⏣ ${coins.toLocaleString()}`, coins, needsRifle: false };
158
+ }
159
+ return { result: stripAnsi(fText).replace(/\s+/g, ' ').trim().substring(0, 60) || 'dodged', coins: 0, needsRifle: false };
160
+ }
161
+ // Click didn't return followup — wait for edit
162
+ await sleep(2000);
163
+ try {
164
+ const edited = await channel.messages.fetch(response.id);
165
+ if (edited) {
166
+ const eText = getFullText(edited);
167
+ const coins = parseCoins(eText);
168
+ if (coins > 0) {
169
+ LOG.coin(`[hunt] ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`);
170
+ return { result: `hunt dodge → +⏣ ${coins.toLocaleString()}`, coins, needsRifle: false };
171
+ }
172
+ return { result: stripAnsi(eText).replace(/\s+/g, ' ').trim().substring(0, 60) || 'dodged', coins: 0, needsRifle: false };
173
+ }
174
+ } catch {}
175
+ } else {
176
+ // Generic minigame — click first available button
177
+ const btn = buttons[0];
178
+ LOG.info(`[hunt] Minigame → clicking "${btn.label}"`);
179
+ const followUp = await clickFast(response, btn);
180
+ if (followUp) {
181
+ logMsg(followUp, 'hunt-minigame');
182
+ const fText = getFullText(followUp);
183
+ const coins = parseCoins(fText);
184
+ return { result: stripAnsi(fText).replace(/\s+/g, ' ').trim().substring(0, 60), coins: coins || 0, needsRifle: false };
185
+ }
186
+ }
187
+ }
188
+
189
+ // Wait for message update (hunt animates sometimes)
190
+ await sleep(2000);
191
+ try {
192
+ const edited = await channel.messages.fetch(response.id);
193
+ if (edited) {
194
+ const eText = getFullText(edited);
195
+ const coins = parseCoins(eText);
196
+ if (coins > 0) {
197
+ LOG.coin(`[hunt] ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`);
198
+ return { result: `hunt → +⏣ ${coins.toLocaleString()}`, coins, needsRifle: false };
199
+ }
200
+ return { result: stripAnsi(eText).replace(/\s+/g, ' ').trim().substring(0, 60) || 'done', coins: 0, needsRifle: false };
201
+ }
202
+ } catch {}
203
+
83
204
  const coins = parseCoins(cleanText);
84
205
  if (coins > 0) {
85
206
  LOG.coin(`[hunt] ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`);
@@ -12,8 +12,8 @@ const {
12
12
  const levelCache = {};
13
13
  const CACHE_TTL = 10 * 60 * 1000; // 10 minutes
14
14
 
15
- const RE_PROFILE_LEVEL = /(?:level|lvl)\s*:?\s*(\d+)/i;
16
- const RE_PROFILE_PRESTIGE_LEVEL = /prestige\s+\d+\s+level\s+(\d+)/i;
15
+ const RE_PROFILE_LEVEL = /(?:level|lvl)\s*:?\s*`?(\d+)`?/i;
16
+ const RE_PROFILE_PRESTIGE_LEVEL = /prestige\s+\d+\s+level\s+`?(\d+)`?/i;
17
17
 
18
18
  /**
19
19
  * Parse level from profile response text.
@@ -77,9 +77,10 @@ async function getPlayerLevel({ channel, waitForDankMemer, accountId = 'default'
77
77
  if (level !== null) {
78
78
  LOG.info(`[profile] Level: ${c.bold}${level}${c.reset}`);
79
79
  levelCache[accountId] = { level, checkedAt: Date.now() };
80
- // Persist to Redis for 10 min
80
+ // Persist to Redis — longer TTL for higher levels since they only go up
81
81
  if (redis) {
82
- try { await redis.set(`dkg:level:${accountId}`, String(level), 'EX', 600); } catch {}
82
+ const ttl = level >= 25 ? 2592000 : 600; // 30 days if ≥25, 10 min otherwise
83
+ try { await redis.set(`dkg:level:${accountId}`, String(level), 'EX', ttl); } catch {}
83
84
  }
84
85
  } else {
85
86
  LOG.warn(`[profile] Could not parse level from: ${text.substring(0, 100)}`);
@@ -130,6 +130,14 @@ async function runSearch({ channel, waitForDankMemer, safeAnswers }) {
130
130
  const locKey = (btn.label || '').toLowerCase();
131
131
  const prev = locationEarnings.get(locKey) || 0;
132
132
  locationEarnings.set(locKey, prev + coins);
133
+
134
+ // Check for death
135
+ const textLower = text.toLowerCase();
136
+ if (textLower.includes('you died') || textLower.includes('lifesaver protected')) {
137
+ LOG.error(`[search] DEATH DETECTED! Lifesaver may have been used.`);
138
+ return { result: `you died during search`, coins: 0, died: true };
139
+ }
140
+
133
141
  if (coins > 0) {
134
142
  LOG.coin(`[search] ${btn.label} → ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`);
135
143
  return { result: `${btn.label} → +⏣ ${coins.toLocaleString()}`, coins };
@@ -11,6 +11,7 @@
11
11
 
12
12
  const https = require('https');
13
13
  const { AhoCorasick, LRUCache, StringPool, ObjectPool } = require('../structures');
14
+ const rawLogger = require('../rawLogger');
14
15
 
15
16
  // ── HTTPS Keep-Alive Agent ───────────────────────────────────
16
17
  // At 10K accounts, every CV2 fetch without keep-alive creates a new
@@ -470,17 +471,29 @@ async function ensureCV2(msg, force = false) {
470
471
  delete msg._cv2EditedTs;
471
472
  cv2Cache.delete(msg.id);
472
473
  }
474
+
475
+ // ── PRIMARY: Use raw gateway logger (always has CV2 data) ──
476
+ const rawParsed = rawLogger.getRawMessage(msg.id);
477
+ if (rawParsed && rawParsed.components?.length > 0) {
478
+ msg._cv2 = rawParsed.components;
479
+ msg._cv2text = rawParsed.cv2Text || _extractCV2Text(rawParsed.components).trim();
480
+ msg._cv2buttons = rawParsed.buttons?.length > 0
481
+ ? 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 }))
482
+ : _extractCV2Buttons(rawParsed.components);
483
+ msg._cv2EditedTs = msgEditedTs;
484
+ cv2Cache.set(msg.id, { components: rawParsed.components, editedTimestamp: msgEditedTs });
485
+ return msg;
486
+ }
487
+
488
+ // ── FALLBACK: LRU cache ──
473
489
  const token = msg.client?.token;
474
490
  const chId = msg.channelId || msg.channel?.id;
475
491
  if (!token || !chId) return msg;
476
492
 
477
- // LRU cache hit — O(1) lookup avoids redundant HTTP fetches
478
493
  const cached = cv2Cache.get(msg.id);
479
494
  if (cached && !force) {
480
495
  const cachedComponents = Array.isArray(cached) ? cached : cached.components;
481
496
  const cachedEditedTs = Array.isArray(cached) ? null : (cached.editedTimestamp || null);
482
-
483
- // If message has been edited since cache snapshot, ignore stale cache.
484
497
  if (!msgEditedTs || cachedEditedTs === msgEditedTs) {
485
498
  msg._cv2 = cachedComponents;
486
499
  msg._cv2text = _extractCV2Text(cachedComponents).trim();
@@ -490,6 +503,7 @@ async function ensureCV2(msg, force = false) {
490
503
  }
491
504
  }
492
505
 
506
+ // ── LAST RESORT: HTTP fetch (may fail for selfbot tokens) ──
493
507
  let raw = null;
494
508
  try {
495
509
  raw = await _httpGet(
@@ -501,11 +515,13 @@ async function ensureCV2(msg, force = false) {
501
515
  }
502
516
 
503
517
  if (!raw || !raw.components) {
504
- const msgs = await _httpGet(
505
- `https://discord.com/api/v9/channels/${chId}/messages?limit=5&around=${msg.id}`,
506
- { Authorization: token }
507
- );
508
- raw = Array.isArray(msgs) ? msgs.find(m => m.id === msg.id) : null;
518
+ try {
519
+ const msgs = await _httpGet(
520
+ `https://discord.com/api/v9/channels/${chId}/messages?limit=5&around=${msg.id}`,
521
+ { Authorization: token }
522
+ );
523
+ raw = Array.isArray(msgs) ? msgs.find(m => m.id === msg.id) : null;
524
+ } catch { raw = null; }
509
525
  }
510
526
 
511
527
  if (raw?.components) {
@@ -27,6 +27,25 @@ const RE_WORK_COOLDOWN_TS = /<t:(\d+)(?::[tTdDfFR])?>/g;
27
27
  const RE_WORK_COOLDOWN_MINUTES = /(\d+)\s*minute/i;
28
28
  const RE_WORK_COOLDOWN_HOURS = /(\d+)\s*hour/i;
29
29
 
30
+ // Color-word pair parser for color memory minigame
31
+ // Format: <:Black:123> `toddler` <:Green:456> `pacifier` <:White:789> `behave`
32
+ const RE_COLOR_WORD = /<:(\w+):\d+>\s*`([^`]+)`/g;
33
+ const KNOWN_COLORS = new Set(['black', 'green', 'white', 'yellow', 'red', 'blue', 'purple', 'orange', 'pink']);
34
+
35
+ function parseColorWordPairs(text) {
36
+ const pairs = {};
37
+ let m;
38
+ const re = new RegExp(RE_COLOR_WORD.source, 'g');
39
+ while ((m = re.exec(text)) !== null) {
40
+ const color = m[1].toLowerCase();
41
+ const word = m[2].toLowerCase().trim();
42
+ if (KNOWN_COLORS.has(color)) {
43
+ pairs[word] = color;
44
+ }
45
+ }
46
+ return pairs;
47
+ }
48
+
30
49
  function normalizeLower(text) {
31
50
  return String(text || '')
32
51
  .normalize('NFKC')
@@ -285,6 +304,63 @@ async function handleRepeatWord({ current, wordOrder }) {
285
304
  return current;
286
305
  }
287
306
 
307
+ /**
308
+ * Handle the "Color Memory" minigame.
309
+ * Phase 1: Shows color-word pairs like <:Black:123> `toddler` <:Green:456> `pacifier`
310
+ * Phase 2: "What color was next to the word `behave`?" with color buttons
311
+ *
312
+ * We parse the pairs from Phase 1's raw embed description, then answer Phase 2.
313
+ */
314
+ async function handleColorMemory({ channel, current, colorWordPairs, waitForDankMemer }) {
315
+ LOG.info(`[work] Color pairs: ${JSON.stringify(colorWordPairs)}`);
316
+ const msgId = current.id;
317
+
318
+ // Wait for Phase 2 — "What color was next to the word X?"
319
+ let phase2 = await waitForDankMemer(8000);
320
+ if (!phase2) phase2 = await refetchMsg(channel, msgId);
321
+ if (!phase2) {
322
+ LOG.warn('[work] No Phase 2 for color memory');
323
+ return current;
324
+ }
325
+
326
+ logMsg(phase2, 'work-color-phase2');
327
+ current = phase2;
328
+
329
+ const p2Text = getFullText(current);
330
+ const p2Lower = p2Text.toLowerCase();
331
+
332
+ // Extract the word being asked about: "What color was next to the word `behave`?"
333
+ const wordMatch = p2Text.match(/word\s*`([^`]+)`/i);
334
+ if (!wordMatch) {
335
+ LOG.warn('[work] Could not parse asked word from color memory');
336
+ return await handleGenericMinigame({ channel, current, waitForDankMemer });
337
+ }
338
+
339
+ const askedWord = wordMatch[1].toLowerCase().trim();
340
+ const correctColor = colorWordPairs[askedWord];
341
+ LOG.info(`[work] Asked: "${askedWord}" → color: "${correctColor || '?'}"`);
342
+
343
+ if (correctColor) {
344
+ const buttons = getAllButtons(current);
345
+ const btn = buttons.find(b => !b.disabled && (b.label || '').toLowerCase() === correctColor);
346
+ if (btn) {
347
+ LOG.info(`[work] Clicking "${btn.label}"`);
348
+ await sleep(50 + Math.random() * 100);
349
+ try {
350
+ const result = await safeClickButton(current, btn);
351
+ if (result) return result;
352
+ } catch (e) { LOG.error(`[work] Color click error: ${e.message}`); }
353
+ // Fallback: wait for update
354
+ await sleep(1500);
355
+ const updated = await refetchMsg(channel, msgId);
356
+ if (updated) return updated;
357
+ }
358
+ }
359
+
360
+ // Fallback: generic click
361
+ return await handleGenericMinigame({ channel, current, waitForDankMemer });
362
+ }
363
+
288
364
  /**
289
365
  * Handle generic button-clicking minigame (color match, emoji match, etc.)
290
366
  * Falls back to clicking buttons in display order if we can't determine the game type.
@@ -385,7 +461,17 @@ async function runWorkShift({ channel, waitForDankMemer }) {
385
461
  const tLower = text.toLowerCase();
386
462
  const wordOrder = parseMemoryOrder(text);
387
463
 
388
- if (wordOrder.length > 0 && tLower.includes('remember')) {
464
+ // Get raw embed description (preserves <:Color:ID> emoji format for color memory)
465
+ const rawEmbedDesc = (current.embeds?.[0]?.description || current.embeds?.[0]?.data?.description || '');
466
+ const colorWordPairs = parseColorWordPairs(rawEmbedDesc);
467
+ const hasColorPairs = Object.keys(colorWordPairs).length >= 2;
468
+
469
+ if (hasColorPairs && (tLower.includes('color') || tLower.includes('look at each color') || tLower.includes('closely'))) {
470
+ // Color Memory minigame — Phase 1: memorize color-word pairs
471
+ LOG.info(`[work] Minigame: Color Memory (${Object.keys(colorWordPairs).length} pairs)`);
472
+ current = await handleColorMemory({ channel, current, colorWordPairs, waitForDankMemer });
473
+
474
+ } else if (wordOrder.length > 0 && tLower.includes('remember')) {
389
475
  // Word Memory minigame — Phase 1: memorize order
390
476
  LOG.info(`[work] Minigame: Word Memory (${wordOrder.length} words)`);
391
477
  current = await handleWordMemory({ channel, current, wordOrder, waitForDankMemer });
@@ -408,13 +494,15 @@ async function runWorkShift({ channel, waitForDankMemer }) {
408
494
  current = phase2;
409
495
  logMsg(current, 'work-phase2');
410
496
  const p2Text = getFullText(current).toLowerCase();
497
+ const p2RawDesc = (current.embeds?.[0]?.description || current.embeds?.[0]?.data?.description || '');
411
498
 
412
- if (p2Text.includes('correct order') || p2Text.includes('click the buttons')) {
413
- // Word memory Phase 2 arrived — we have the word order from Phase 1
499
+ // Check if Phase 2 is a color memory question
500
+ if (p2Text.includes('what color') && hasColorPairs) {
501
+ current = await handleColorMemory({ channel, current: phase2, colorWordPairs, waitForDankMemer: async () => null });
502
+ } else if (p2Text.includes('correct order') || p2Text.includes('click the buttons')) {
414
503
  if (wordOrder.length > 0) {
415
504
  current = await handleWordMemory({ channel, current, wordOrder, waitForDankMemer });
416
505
  } else {
417
- // Re-parse from Phase 2 if word order was in Phase 1 content
418
506
  current = await handleGenericMinigame({ channel, current, waitForDankMemer });
419
507
  }
420
508
  } else {