dankgrinder 4.9.9 → 5.0.1

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.
@@ -39,6 +39,9 @@ const {
39
39
  safeClickButton, isHoldTight, logMsg,
40
40
  } = require('./utils');
41
41
 
42
+ const RE_DISCORD_TIMESTAMP = /<t:(\d+)(?::[tTdDfFR])?>/;
43
+ const RE_ADVENTURE_AGAIN_LABEL = /adventure again in (\d+)\s*(minute|min|hour|second)/;
44
+
42
45
  // ── Adventure type rotation (cycle through all types each run) ────
43
46
  let lastAdventureIndex = -1;
44
47
 
@@ -102,8 +105,9 @@ function isAdventureDone(msg) {
102
105
  }
103
106
 
104
107
  // ── Safe choices: prefer non-destructive options ─────────────────
105
- const SAFE_KEYWORDS = ['flee', 'run', 'hide', 'avoid', 'ignore', 'leave', 'walk away', 'back away', 'retreat', 'skip'];
106
- const RISKY_KEYWORDS = ['reach', 'grab', 'fight', 'attack', 'steal', 'open', 'touch', 'eat', 'drink'];
108
+ const SAFE_KEYWORDS = Object.freeze(['flee', 'run', 'hide', 'avoid', 'ignore', 'leave', 'walk away', 'back away', 'retreat', 'skip']);
109
+ const RISKY_KEYWORDS = Object.freeze(['reach', 'grab', 'fight', 'attack', 'steal', 'open', 'touch', 'eat', 'drink']);
110
+ const ADVENTURE_PREFERRED_TYPES = Object.freeze(['space', 'out west']);
107
111
 
108
112
  function pickSafeChoice(choices) {
109
113
  if (choices.length === 0) return null;
@@ -114,7 +118,8 @@ function pickSafeChoice(choices) {
114
118
 
115
119
  // Prefer safe keywords
116
120
  for (let i = 0; i < labels.length; i++) {
117
- for (const kw of SAFE_KEYWORDS) {
121
+ for (let k = 0; k < SAFE_KEYWORDS.length; k++) {
122
+ const kw = SAFE_KEYWORDS[k];
118
123
  if (labels[i].includes(kw)) {
119
124
  LOG.info(`[adventure] Picking safe option: "${choices[i].label}" (matched: ${kw})`);
120
125
  return choices[i];
@@ -235,9 +240,11 @@ async function playAdventureRounds(channel, msg) {
235
240
 
236
241
  // Parse rewards from Adventure Progress embed
237
242
  let rewards = '-';
238
- for (const e of current.embeds || []) {
239
- for (const f of e.fields || []) {
240
- if (f.name === 'Rewards') rewards = f.value;
243
+ const embeds = current.embeds || [];
244
+ for (let ei = 0; ei < embeds.length; ei++) {
245
+ const fields = embeds[ei].fields || [];
246
+ for (let fi = 0; fi < fields.length; fi++) {
247
+ if (fields[fi].name === 'Rewards') rewards = fields[fi].value;
241
248
  }
242
249
  }
243
250
  LOG.info(`[adventure] Rewards: ${rewards}`);
@@ -282,7 +289,7 @@ async function runAdventure({ channel, waitForDankMemer, client }) {
282
289
  // ── 1) Check cooldown via Unix timestamp <t:UNIX:t> ────────
283
290
  // Format: "You can start another adventure at <t:1774415487:t> (<t:1774415487:R>)"
284
291
  let cooldownSec = 0;
285
- const tsMatch = text.match(/<t:(\d+)(?::[tTdDfFR])?>/);
292
+ const tsMatch = text.match(RE_DISCORD_TIMESTAMP);
286
293
  if (tsMatch) {
287
294
  const unixTarget = parseInt(tsMatch[1]);
288
295
  const nowUnix = Math.floor(Date.now() / 1000);
@@ -341,7 +348,9 @@ async function runAdventure({ channel, waitForDankMemer, client }) {
341
348
  let menuRowIdx = -1;
342
349
  for (let i = 0; i < (response.components || []).length; i++) {
343
350
  const row = response.components[i];
344
- for (const comp of row.components || []) {
351
+ const rowComps = row.components || [];
352
+ for (let j = 0; j < rowComps.length; j++) {
353
+ const comp = rowComps[j];
345
354
  if (comp.type === 'STRING_SELECT' || comp.type === 3) { menuRowIdx = i; break; }
346
355
  }
347
356
  if (menuRowIdx >= 0) break;
@@ -349,9 +358,8 @@ async function runAdventure({ channel, waitForDankMemer, client }) {
349
358
 
350
359
  const menu = response.components[menuRowIdx]?.components[0];
351
360
  const options = menu?.options || [];
352
- const PREFERRED = ['space', 'out west'];
353
361
  const preferred = options.filter(o =>
354
- PREFERRED.some(kw => (o.label || '').toLowerCase().includes(kw) || (o.value || '').toLowerCase().includes(kw))
362
+ ADVENTURE_PREFERRED_TYPES.some(kw => (o.label || '').toLowerCase().includes(kw) || (o.value || '').toLowerCase().includes(kw))
355
363
  );
356
364
  const pool = preferred.length > 0 ? preferred : options;
357
365
 
@@ -433,7 +441,7 @@ function buildResult(finalText, coins, interactions, rewards, msg) {
433
441
 
434
442
  // 1) Best: Unix timestamp <t:UNIX:t> in final text or embed
435
443
  const allText = msg ? getFullText(msg) : finalText;
436
- const tsMatch = allText.match(/<t:(\d+)(?::[tTdDfFR])?>/);
444
+ const tsMatch = allText.match(RE_DISCORD_TIMESTAMP);
437
445
  if (tsMatch) {
438
446
  const unixTarget = parseInt(tsMatch[1]);
439
447
  const nowUnix = Math.floor(Date.now() / 1000);
@@ -443,10 +451,13 @@ function buildResult(finalText, coins, interactions, rewards, msg) {
443
451
 
444
452
  // 2) Fallback: "Adventure again in X minutes" button label
445
453
  if (!nextCooldownSec && msg) {
446
- for (const row of msg.components || []) {
447
- for (const comp of row.components || []) {
454
+ const rows = msg.components || [];
455
+ for (let ri = 0; ri < rows.length; ri++) {
456
+ const comps = rows[ri].components || [];
457
+ for (let ci = 0; ci < comps.length; ci++) {
458
+ const comp = comps[ci];
448
459
  const label = (comp.label || '').toLowerCase();
449
- const btnMatch = label.match(/adventure again in (\d+)\s*(minute|min|hour|second)/);
460
+ const btnMatch = label.match(RE_ADVENTURE_AGAIN_LABEL);
450
461
  if (btnMatch) {
451
462
  nextCooldownSec = parseInt(btnMatch[1]);
452
463
  const unit = btnMatch[2].toLowerCase();
@@ -5,6 +5,8 @@
5
5
 
6
6
  const { LOG, c, getFullText, parseCoins, logMsg, isHoldTight, getHoldTightReason, sleep } = require('./utils');
7
7
 
8
+ const RE_NEWLINE = /\n/g;
9
+
8
10
  /**
9
11
  * @param {object} opts
10
12
  * @param {object} opts.channel
@@ -38,7 +40,7 @@ async function runBeg({ channel, waitForDankMemer }) {
38
40
  return { result: `beg → ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`, coins };
39
41
  }
40
42
 
41
- LOG.info(`[beg] ${text.substring(0, 80).replace(/\n/g, ' ')}`);
43
+ LOG.info(`[beg] ${text.substring(0, 80).replace(RE_NEWLINE, ' ')}`);
42
44
  return { result: text.substring(0, 60) || 'done', coins: 0 };
43
45
  }
44
46
 
@@ -8,35 +8,50 @@ const {
8
8
  logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay, ensureCV2,
9
9
  } = require('./utils');
10
10
 
11
+ const RE_BACKTICK_SCORE = /`\s*(\d+)\s*`/;
12
+ const RE_BJ_FACE_GLOBAL = /bjFace(\w+?):/g;
13
+ const RE_BJ_FACE_SUFFIX = /[RB]$/;
14
+ const RE_NET_LINE = /Net:\s*\*{0,2}\s*[⏣]\s*\*{0,2}([+-]?[\d,]+)/i;
15
+ const RE_WINNINGS_LINE = /Winnings:\s*\*{0,2}\s*[⏣]\s*\*{0,2}([\d,]+)/i;
16
+ const RE_COMMA = /,/g;
17
+ const COURT_RANKS = Object.freeze(['K', 'Q', 'J']);
18
+
11
19
  function parsePlayerTotal(msg) {
12
20
  // Score is in embed field: ` 18 ` (backtick-wrapped number in Player field)
13
- for (const embed of msg?.embeds || []) {
14
- for (const field of embed.fields || []) {
21
+ const embeds = msg?.embeds || [];
22
+ for (let ei = 0; ei < embeds.length; ei++) {
23
+ const fields = embeds[ei].fields || [];
24
+ for (let fi = 0; fi < fields.length; fi++) {
25
+ const field = fields[fi];
15
26
  if (field.name?.toLowerCase().includes('player')) {
16
- const m = field.value.match(/`\s*(\d+)\s*`/);
27
+ const m = field.value.match(RE_BACKTICK_SCORE);
17
28
  if (m) return parseInt(m[1]);
18
29
  }
19
30
  }
20
31
  }
21
32
  const text = typeof msg === 'string' ? msg : getFullText(msg);
22
- const m = text.match(/`\s*(\d+)\s*`/);
33
+ const m = text.match(RE_BACKTICK_SCORE);
23
34
  return m ? parseInt(m[1]) : 0;
24
35
  }
25
36
 
26
37
  function parseDealerUpcard(msg) {
27
38
  // Dealer field: revealed total ` 18 ` or hidden ` ? `
28
39
  // Upcard from emoji: bjFaceKB=K(10), bjFaceAR=A(11), bjFace7R=7
29
- for (const embed of msg?.embeds || []) {
30
- for (const field of embed.fields || []) {
40
+ const embeds = msg?.embeds || [];
41
+ for (let ei = 0; ei < embeds.length; ei++) {
42
+ const fields = embeds[ei].fields || [];
43
+ for (let fi = 0; fi < fields.length; fi++) {
44
+ const field = fields[fi];
31
45
  if (field.name?.toLowerCase().includes('dealer')) {
32
- const totalMatch = field.value.match(/`\s*(\d+)\s*`/);
46
+ const totalMatch = field.value.match(RE_BACKTICK_SCORE);
33
47
  if (totalMatch) return parseInt(totalMatch[1]);
34
- const faces = [...field.value.matchAll(/bjFace(\w+?):/g)];
35
- for (const [, face] of faces) {
48
+ const faces = [...field.value.matchAll(RE_BJ_FACE_GLOBAL)];
49
+ for (let fii = 0; fii < faces.length; fii++) {
50
+ const face = faces[fii][1];
36
51
  if (face === 'Unknown') continue;
37
- const v = face.replace(/[RB]$/, '');
52
+ const v = face.replace(RE_BJ_FACE_SUFFIX, '');
38
53
  if (v === 'A') return 11;
39
- if (['K', 'Q', 'J'].includes(v)) return 10;
54
+ if (COURT_RANKS.includes(v)) return 10;
40
55
  const n = parseInt(v);
41
56
  if (!isNaN(n)) return n;
42
57
  }
@@ -47,10 +62,10 @@ function parseDealerUpcard(msg) {
47
62
  }
48
63
 
49
64
  function parseNetCoins(text) {
50
- const netMatch = text.match(/Net:\s*\*{0,2}\s*[⏣]\s*\*{0,2}([+-]?[\d,]+)/i);
51
- if (netMatch) return parseInt(netMatch[1].replace(/,/g, ''));
52
- const winMatch = text.match(/Winnings:\s*\*{0,2}\s*[⏣]\s*\*{0,2}([\d,]+)/i);
53
- if (winMatch) return parseInt(winMatch[1].replace(/,/g, ''));
65
+ const netMatch = text.match(RE_NET_LINE);
66
+ if (netMatch) return parseInt(netMatch[1].replace(RE_COMMA, ''));
67
+ const winMatch = text.match(RE_WINNINGS_LINE);
68
+ if (winMatch) return parseInt(winMatch[1].replace(RE_COMMA, ''));
54
69
  return 0;
55
70
  }
56
71
 
@@ -114,7 +129,7 @@ async function runBlackjack({ channel, waitForDankMemer, betAmount = 5000 }) {
114
129
  if (!targetBtn) break;
115
130
 
116
131
  LOG.info(`[bj] You:${playerTotal} Dealer:${dealerUpcard} → ${targetBtn.label}`);
117
- await humanDelay(400, 900);
132
+ await humanDelay(150, 400);
118
133
 
119
134
  try {
120
135
  const followUp = await safeClickButton(current, targetBtn);
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * Crime command handler.
3
3
  * Send "pls crime", pick a safe option, click it, parse coins.
4
+ *
5
+ * Advanced techniques:
6
+ * Trie – O(k) prefix matching for safe/risky crime options
7
+ * VoseAlias – O(1) weighted random sampling with adaptive learning
8
+ * LRUCache – per-option earnings tracking for better choices over time
4
9
  */
5
10
 
6
11
  const {
@@ -8,17 +13,32 @@ const {
8
13
  logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay,
9
14
  isCV2, ensureCV2,
10
15
  } = require('./utils');
16
+ const { Trie, VoseAlias, LRUCache } = require('../structures');
11
17
 
12
- const SAFE_CRIME_OPTIONS = [
18
+ const SAFE_CRIME_OPTIONS = Object.freeze([
13
19
  'tax evasion', 'fraud', 'cybercrime', 'hacking', 'identity theft',
14
20
  'money laundering', 'tax fraud', 'insurance fraud', 'scam',
15
- ];
16
- const SAFE_CRIME_SET = new Set(SAFE_CRIME_OPTIONS);
21
+ ]);
17
22
 
18
- const RISKY_CRIME_OPTIONS = new Set([
23
+ const RISKY_CRIME_OPTIONS = Object.freeze([
19
24
  'murder', 'arson', 'assault', 'kidnap', 'terrorism',
20
25
  ]);
21
26
 
27
+ // Trie for O(k) safe option matching (replaces O(n) Set iteration)
28
+ const safeCrimeTrie = new Trie();
29
+ for (const opt of SAFE_CRIME_OPTIONS) safeCrimeTrie.insert(opt, opt);
30
+
31
+ const riskyCrimeTrie = new Trie();
32
+ for (const opt of RISKY_CRIME_OPTIONS) {
33
+ riskyCrimeTrie.insert(opt, opt);
34
+ }
35
+
36
+ // Adaptive earnings tracker — learn which crimes pay best
37
+ const crimeEarnings = new LRUCache(32);
38
+
39
+ function isSafeCrime(label) { return safeCrimeTrie.containsInText(label).length > 0; }
40
+ function isRiskyCrime(label) { return riskyCrimeTrie.containsInText(label).length > 0; }
41
+
22
42
  function pickSafeButton(buttons, customSafe) {
23
43
  if (!buttons || buttons.length === 0) return null;
24
44
  const clickable = buttons.filter(b => !b.disabled);
@@ -32,17 +52,18 @@ function pickSafeButton(buttons, customSafe) {
32
52
  }
33
53
  }
34
54
 
35
- for (const btn of clickable) {
36
- const label = (btn.label || '').toLowerCase();
37
- for (const s of SAFE_CRIME_SET) { if (label.includes(s)) return btn; }
55
+ // Trie-based safe option detection + VoseAlias weighted sampling
56
+ const safeButtons = clickable.filter(b => isSafeCrime((b.label || '').toLowerCase()));
57
+ if (safeButtons.length > 0) {
58
+ const weights = safeButtons.map(b => {
59
+ const past = crimeEarnings.get((b.label || '').toLowerCase());
60
+ return past ? past + 1 : 1;
61
+ });
62
+ const alias = new VoseAlias(weights);
63
+ return safeButtons[alias.sample()];
38
64
  }
39
65
 
40
- const safe = clickable.filter(b => {
41
- const label = (b.label || '').toLowerCase();
42
- for (const r of RISKY_CRIME_OPTIONS) { if (label.includes(r)) return false; }
43
- return true;
44
- });
45
-
66
+ const safe = clickable.filter(b => !isRiskyCrime((b.label || '').toLowerCase()));
46
67
  const pool = safe.length > 0 ? safe : clickable;
47
68
  return pool[Math.floor(Math.random() * pool.length)];
48
69
  }
@@ -102,6 +123,9 @@ async function runCrime({ channel, waitForDankMemer, safeAnswers }) {
102
123
  logMsg(followUp, 'crime-result');
103
124
  const text = getFullText(followUp);
104
125
  const coins = parseCoins(text);
126
+ const crimeKey = (btn.label || '').toLowerCase();
127
+ const prev = crimeEarnings.get(crimeKey) || 0;
128
+ crimeEarnings.set(crimeKey, prev + coins);
105
129
  if (coins > 0) {
106
130
  LOG.coin(`[crime] ${btn.label} → ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`);
107
131
  return { result: `${btn.label} → +⏣ ${coins.toLocaleString()}`, coins };
@@ -3,6 +3,16 @@ const {
3
3
  safeClickButton, logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay,
4
4
  } = require('./utils');
5
5
 
6
+ const RE_BLOCK_SPLIT = /\n\n+/;
7
+ const RE_DROP_NAME_BOLD = /\*\*(.+?)\*\*/;
8
+ const RE_COST_EMBED = /Cost:\s*[⏣💰]?\s*([\d,]+)/i;
9
+ const RE_STOCK = /Stock:\s*(\d+|Infinite)/i;
10
+ const RE_DROP_TIMESTAMP = /<t:(\d+):[tTdDfFR]>/;
11
+ const RE_COMMA = /,/g;
12
+ const RE_DROP_NAME_FALLBACK = /(\w[\w\s']+)/;
13
+ const RE_COST_LINE = /Cost:\s*[⏣💰o]?\s*([\d,]+)/i;
14
+ const RE_WHITESPACE_UNDERSCORE = /\s+/g;
15
+
6
16
  async function runDrops({ channel, waitForDankMemer, redis, accountId }) {
7
17
  LOG.cmd(`${c.white}${c.bold}pls drops${c.reset}`);
8
18
 
@@ -22,18 +32,21 @@ async function runDrops({ channel, waitForDankMemer, redis, accountId }) {
22
32
 
23
33
  const drops = [];
24
34
 
25
- for (const embed of response.embeds || []) {
35
+ const embedList = response.embeds || [];
36
+ for (let ei = 0; ei < embedList.length; ei++) {
37
+ const embed = embedList[ei];
26
38
  const desc = embed.description || '';
27
- const items = desc.split(/\n\n+/);
28
- for (const block of items) {
29
- const nameMatch = block.match(/\*\*(.+?)\*\*/);
30
- const costMatch = block.match(/Cost:\s*[⏣💰]?\s*([\d,]+)/i);
31
- const stockMatch = block.match(/Stock:\s*(\d+|Infinite)/i);
32
- const dateMatch = block.match(/<t:(\d+):[tTdDfFR]>/);
39
+ const items = desc.split(RE_BLOCK_SPLIT);
40
+ for (let bi = 0; bi < items.length; bi++) {
41
+ const block = items[bi];
42
+ const nameMatch = block.match(RE_DROP_NAME_BOLD);
43
+ const costMatch = block.match(RE_COST_EMBED);
44
+ const stockMatch = block.match(RE_STOCK);
45
+ const dateMatch = block.match(RE_DROP_TIMESTAMP);
33
46
  if (nameMatch) {
34
47
  drops.push({
35
48
  name: nameMatch[1],
36
- cost: costMatch ? parseInt(costMatch[1].replace(/,/g, '')) : 0,
49
+ cost: costMatch ? parseInt(costMatch[1].replace(RE_COMMA, '')) : 0,
37
50
  stock: stockMatch ? (stockMatch[1] === 'Infinite' ? Infinity : parseInt(stockMatch[1])) : 0,
38
51
  dropTimestamp: dateMatch ? parseInt(dateMatch[1]) : 0,
39
52
  });
@@ -47,10 +60,10 @@ async function runDrops({ channel, waitForDankMemer, redis, accountId }) {
47
60
  const line = textBlocks[i];
48
61
  if (line.includes('Cost:') && i > 0) {
49
62
  const prevLine = textBlocks[i - 1];
50
- const nameMatch = prevLine.match(/\*\*(.+?)\*\*/) || prevLine.match(/(\w[\w\s']+)/);
51
- const costMatch = line.match(/Cost:\s*[⏣💰o]?\s*([\d,]+)/i);
63
+ const nameMatch = prevLine.match(RE_DROP_NAME_BOLD) || prevLine.match(RE_DROP_NAME_FALLBACK);
64
+ const costMatch = line.match(RE_COST_LINE);
52
65
  if (nameMatch && costMatch) {
53
- const cost = parseInt(costMatch[1].replace(/,/g, ''));
66
+ const cost = parseInt(costMatch[1].replace(RE_COMMA, ''));
54
67
  const existing = drops.find(d => d.name === nameMatch[1]);
55
68
  if (!existing && cost > 0) {
56
69
  drops.push({ name: nameMatch[1], cost, stock: 0, dropTimestamp: 0 });
@@ -64,9 +77,10 @@ async function runDrops({ channel, waitForDankMemer, redis, accountId }) {
64
77
  try {
65
78
  await redis.set('dkg:drops:latest', JSON.stringify(drops), 'EX', 172800);
66
79
  // Schedule individual drops with their timestamps
67
- for (const drop of drops) {
80
+ for (let di = 0; di < drops.length; di++) {
81
+ const drop = drops[di];
68
82
  if (drop.dropTimestamp > 0) {
69
- const key = `dkg:drops:schedule:${drop.name.replace(/\s+/g, '_').toLowerCase()}`;
83
+ const key = `dkg:drops:schedule:${drop.name.replace(RE_WHITESPACE_UNDERSCORE, '_').toLowerCase()}`;
70
84
  await redis.set(key, JSON.stringify(drop), 'EX', 172800);
71
85
  }
72
86
  }
@@ -77,7 +91,8 @@ async function runDrops({ channel, waitForDankMemer, redis, accountId }) {
77
91
  const now = Math.floor(Date.now() / 1000);
78
92
  const buttons = getAllButtons(response);
79
93
  let boughtCount = 0;
80
- for (const drop of drops) {
94
+ for (let di = 0; di < drops.length; di++) {
95
+ const drop = drops[di];
81
96
  if (drop.stock > 0 && (drop.dropTimestamp === 0 || drop.dropTimestamp <= now)) {
82
97
  const buyBtn = buttons.find(b =>
83
98
  !b.disabled && b.label && (
@@ -22,6 +22,11 @@ const { downloadImage, extractImageUrl, findSafeCells } = require('./fishVision'
22
22
 
23
23
  const { DANK_MEMER_ID } = require('./utils');
24
24
 
25
+ const RE_FISH_COOLDOWN_TS = /<t:(\d+):R>/;
26
+ const RE_FISH_HEADER = /###\s*(.+?)(?:\s*-#|$)/;
27
+ const RE_CAUGHT_PHRASE = /caught\s+(?:a\s+)?(.+?)(?:\s*[!.]|$)/i;
28
+ const RE_CATCH_CUSTOM_ID_COORDS = /:(\d+):(\d+)$/;
29
+
25
30
  async function refetchMsg(channel, msgId) {
26
31
  try { return await channel.messages.fetch(msgId); } catch { return null; }
27
32
  }
@@ -127,7 +132,7 @@ async function sellAllFish({ channel, waitForDankMemer, sellFor = 'tokens' }) {
127
132
  * Parse cooldown timestamp from text.
128
133
  */
129
134
  function parseFishCooldown(text) {
130
- const m = text.match(/<t:(\d+):R>/);
135
+ const m = text.match(RE_FISH_COOLDOWN_TS);
131
136
  if (m) {
132
137
  const diff = parseInt(m[1]) - Math.floor(Date.now() / 1000);
133
138
  return diff > 0 ? diff : null;
@@ -194,7 +199,7 @@ async function waitForResult({ channel, msgId, waitForDankMemer }) {
194
199
  function parseFishResult(text) {
195
200
  const tl = text.toLowerCase();
196
201
  const cd = parseFishCooldown(text);
197
- const headerMatch = text.match(/###\s*(.+?)(?:\s*-#|$)/);
202
+ const headerMatch = text.match(RE_FISH_HEADER);
198
203
  const header = headerMatch ? headerMatch[1].trim() : '';
199
204
 
200
205
  if (tl.includes('nothing to catch') || tl.includes('no fish')) {
@@ -207,7 +212,7 @@ function parseFishResult(text) {
207
212
  return { outcome: 'got_away', header, cd };
208
213
  }
209
214
  if (tl.includes('caught')) {
210
- const fishMatch = text.match(/caught\s+(?:a\s+)?(.+?)(?:\s*[!.]|$)/i);
215
+ const fishMatch = text.match(RE_CAUGHT_PHRASE);
211
216
  return { outcome: 'caught', fish: fishMatch ? fishMatch[1].substring(0, 40) : 'a fish', header, cd };
212
217
  }
213
218
  return { outcome: 'unknown', header: header || text.substring(0, 60), cd };
@@ -263,7 +268,7 @@ async function playFishRound({ gameMsg, channel, msgId, waitForDankMemer }) {
263
268
 
264
269
  const catchBtns = getAllButtons(gameMsg).filter(b => (b.label || '').toLowerCase() === 'catch' && !b.disabled);
265
270
  const btn = catchBtns.find(b => {
266
- const m = (b.customId || '').match(/:(\d+):(\d+)$/);
271
+ const m = (b.customId || '').match(RE_CATCH_CUSTOM_ID_COORDS);
267
272
  return m && parseInt(m[1]) === target.col && parseInt(m[2]) === target.row;
268
273
  });
269
274
 
@@ -1,43 +1,102 @@
1
1
  /**
2
2
  * Gambling command handlers.
3
3
  * Covers: cointoss (CV2), roulette, slots, snakeeyes
4
- * Each handler is tailored to the actual Dank Memer response format.
4
+ *
5
+ * Advanced techniques:
6
+ * Kelly Criterion – optimal bet sizing based on observed win rate
7
+ * EMA – exponential moving average for smoothed win probability
8
+ * AhoCorasick – O(n) single-pass min-bet detection
9
+ * SlidingWindow – time-based win/loss rate tracking
5
10
  */
6
11
 
7
12
  const {
8
13
  LOG, c, getFullText, parseCoins, parseNetCoins, getAllButtons, safeClickButton,
9
14
  logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay, ensureCV2,
10
15
  } = require('./utils');
16
+ const { EMA, AhoCorasick, SlidingWindowCounter } = require('../structures');
17
+
18
+ // ── Per-game EMA win-rate tracker ────────────────────────────
19
+ // α=0.2 gives recent results ~5x more weight than old ones
20
+ const winRateEMA = {
21
+ cointoss: new EMA(0.2),
22
+ roulette: new EMA(0.2),
23
+ slots: new EMA(0.2),
24
+ snakeeyes: new EMA(0.2),
25
+ };
26
+
27
+ // ── Sliding window: wins in last 5 minutes per game ──────────
28
+ const winWindow = {
29
+ cointoss: new SlidingWindowCounter(300000),
30
+ roulette: new SlidingWindowCounter(300000),
31
+ slots: new SlidingWindowCounter(300000),
32
+ snakeeyes: new SlidingWindowCounter(300000),
33
+ };
34
+ const totalWindow = {
35
+ cointoss: new SlidingWindowCounter(300000),
36
+ roulette: new SlidingWindowCounter(300000),
37
+ slots: new SlidingWindowCounter(300000),
38
+ snakeeyes: new SlidingWindowCounter(300000),
39
+ };
40
+
41
+ // ── Kelly Criterion ──────────────────────────────────────────
42
+ // f* = (bp - q) / b where:
43
+ // b = net odds (payout ratio, e.g., 1.0 for even money)
44
+ // p = probability of winning (from EMA)
45
+ // q = 1 - p
46
+ // Returns fraction of bankroll to bet (0 = don't bet, capped at 0.25)
47
+ function kellyFraction(winProb, odds = 1.0) {
48
+ if (winProb <= 0 || winProb >= 1) return 0.1;
49
+ const q = 1 - winProb;
50
+ const f = (odds * winProb - q) / odds;
51
+ return Math.max(0, Math.min(f, 0.25));
52
+ }
53
+
54
+ // ── Aho-Corasick for min-bet detection ───────────────────────
55
+ // Single O(n) pass instead of 3 separate .includes() calls
56
+ const minBetDetector = new AhoCorasick();
57
+ minBetDetector.addPattern("can't bet less than", 'minbet');
58
+ minBetDetector.addPattern('cannot bet less than', 'minbet');
59
+ minBetDetector.addPattern('minimum bet', 'minbet');
60
+ minBetDetector.build();
11
61
 
12
62
  function parseResult(text, cmdName) {
13
63
  const net = parseNetCoins(text);
64
+ const game = cmdName;
14
65
  if (net > 0) {
66
+ if (winRateEMA[game]) winRateEMA[game].update(1);
67
+ if (winWindow[game]) winWindow[game].increment();
68
+ if (totalWindow[game]) totalWindow[game].increment();
15
69
  LOG.coin(`[${cmdName}] ${c.green}+⏣ ${net.toLocaleString()}${c.reset}`);
16
70
  return { result: `${cmdName} → +⏣ ${net.toLocaleString()}`, coins: net };
17
71
  }
18
72
  if (net < 0) {
73
+ if (winRateEMA[game]) winRateEMA[game].update(0);
74
+ if (totalWindow[game]) totalWindow[game].increment();
19
75
  LOG.warn(`[${cmdName}] ${c.red}-⏣ ${Math.abs(net).toLocaleString()}${c.reset}`);
20
76
  return { result: `${cmdName} → -⏣ ${Math.abs(net).toLocaleString()}`, coins: 0, lost: Math.abs(net) };
21
77
  }
22
78
  const coins = parseCoins(text);
23
79
  if (coins > 0) {
80
+ if (winRateEMA[game]) winRateEMA[game].update(1);
81
+ if (winWindow[game]) winWindow[game].increment();
82
+ if (totalWindow[game]) totalWindow[game].increment();
24
83
  LOG.coin(`[${cmdName}] ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`);
25
84
  return { result: `${cmdName} → +⏣ ${coins.toLocaleString()}`, coins };
26
85
  }
86
+ if (totalWindow[game]) totalWindow[game].increment();
27
87
  return { result: `${cmdName} done`, coins: 0 };
28
88
  }
29
89
 
90
+ const RE_LESS_THAN = /less than\s*\*?\*?[⏣o]?\s*([\d,]+)/i;
91
+ const RE_FIRST_NUM = /(\d[\d,]+)/;
92
+
30
93
  function checkMinBet(text) {
31
- const lower = text.toLowerCase();
32
- if (lower.includes("can't bet less than") || lower.includes('cannot bet less than') || lower.includes('minimum bet')) {
33
- const m = text.match(/less than\s*\*?\*?[⏣o]?\s*([\d,]+)/i) || text.match(/(\d[\d,]+)/);
34
- if (m) return parseInt(m[1].replace(/,/g, ''));
35
- return -1;
36
- }
37
- return 0;
94
+ if (!minBetDetector.hasAny(text)) return 0;
95
+ const m = text.match(RE_LESS_THAN) || text.match(RE_FIRST_NUM);
96
+ if (m) return parseInt(m[1].replace(/,/g, ''));
97
+ return -1;
38
98
  }
39
99
 
40
- // Filter utility buttons (null labels, warnings, refresh)
41
100
  function gameButtons(msg) {
42
101
  return getAllButtons(msg).filter(b =>
43
102
  !b.disabled && b.label && b.label !== 'null' &&
@@ -89,15 +148,14 @@ async function runCointoss({ channel, waitForDankMemer, betAmount = 10000 }) {
89
148
  const choice = Math.random() < 0.5 ? 'head' : 'tail';
90
149
  const btn = btns.find(b => (b.label || '').toLowerCase().includes(choice)) || btns[0];
91
150
  LOG.info(`[cointoss] Picking "${btn.label}"`);
92
- await humanDelay(200, 500);
151
+ await humanDelay(80, 200);
93
152
  try {
94
153
  const followUp = await safeClickButton(response, btn);
95
154
  if (followUp) {
96
155
  await ensureCV2(followUp);
97
156
  return parseResult(getFullText(followUp), 'cointoss');
98
157
  }
99
- // CV2 click returns null — wait for message update, clear cache, refetch
100
- await sleep(2500);
158
+ await sleep(1500);
101
159
  response._cv2 = null;
102
160
  response._cv2text = null;
103
161
  response._cv2buttons = null;
@@ -110,11 +168,13 @@ async function runCointoss({ channel, waitForDankMemer, betAmount = 10000 }) {
110
168
  }
111
169
 
112
170
  /**
113
- * Roulette — embed format. Always pick Red (best even-money odds ~48.6%).
171
+ * Roulette — random color: red ~47.5%, black ~47.5%, green ~5% (rare).
114
172
  * Sometimes auto-bets if color is in command, sometimes shows buttons.
115
173
  */
116
174
  async function runRoulette({ channel, waitForDankMemer, betAmount = 10000 }) {
117
- const cmd = `pls roulette ${betAmount} red`;
175
+ const roll = Math.random();
176
+ const color = roll < 0.475 ? 'red' : roll < 0.95 ? 'black' : 'green';
177
+ const cmd = `pls roulette ${betAmount} ${color}`;
118
178
  LOG.cmd(`${c.white}${c.bold}${cmd}${c.reset}`);
119
179
  await channel.send(cmd);
120
180
  let response = await waitForDankMemer(10000);
@@ -124,16 +184,15 @@ async function runRoulette({ channel, waitForDankMemer, betAmount = 10000 }) {
124
184
 
125
185
  logMsg(response, 'roulette');
126
186
 
127
- // Check if buttons appeared (manual pick needed)
128
187
  const btns = gameButtons(response).filter(b =>
129
188
  !(b.customId || b.custom_id || '').includes('bet')
130
189
  );
131
- const redBtn = btns.find(b => (b.label || '').toLowerCase() === 'red');
132
- if (redBtn) {
133
- LOG.info(`[roulette] Picking "Red"`);
134
- await humanDelay(200, 500);
190
+ const colorBtn = btns.find(b => (b.label || '').toLowerCase() === color);
191
+ if (colorBtn) {
192
+ LOG.info(`[roulette] Picking "${colorBtn.label}"`);
193
+ await humanDelay(100, 300);
135
194
  try {
136
- const followUp = await safeClickButton(response, redBtn);
195
+ const followUp = await safeClickButton(response, colorBtn);
137
196
  if (followUp) {
138
197
  response = followUp;
139
198
  logMsg(response, 'roulette-after-pick');
@@ -141,10 +200,9 @@ async function runRoulette({ channel, waitForDankMemer, betAmount = 10000 }) {
141
200
  } catch (e) { LOG.error(`[roulette] Click error: ${e.message}`); }
142
201
  }
143
202
 
144
- // Wait for spin animation to complete
145
203
  let text = getFullText(response);
146
204
  if (text.toLowerCase().includes('spinning') || text.toLowerCase().includes('rolling') || text.toLowerCase().includes('pick a color')) {
147
- await sleep(3500);
205
+ await sleep(2000);
148
206
  try {
149
207
  const final = await channel.messages.fetch(response.id);
150
208
  if (final) text = getFullText(final);
@@ -169,9 +227,8 @@ async function runSlots({ channel, waitForDankMemer, betAmount = 10000 }) {
169
227
  logMsg(response, 'slots');
170
228
  const text = getFullText(response);
171
229
 
172
- // Slots auto-spins — if still animating, wait and refetch
173
230
  if (text.toLowerCase().includes('spinning')) {
174
- await sleep(3500);
231
+ await sleep(2000);
175
232
  try {
176
233
  const final = await channel.messages.fetch(response.id);
177
234
  if (final) return parseResult(getFullText(final), 'slots');
@@ -196,9 +253,8 @@ async function runSnakeeyes({ channel, waitForDankMemer, betAmount = 10000 }) {
196
253
  logMsg(response, 'snakeeyes');
197
254
  const text = getFullText(response);
198
255
 
199
- // Snakeeyes auto-rolls — if still animating, wait and refetch
200
256
  if (text.toLowerCase().includes('rolling')) {
201
- await sleep(3500);
257
+ await sleep(2000);
202
258
  try {
203
259
  const final = await channel.messages.fetch(response.id);
204
260
  if (final) return parseResult(getFullText(final), 'snakeeyes');
@@ -208,4 +264,7 @@ async function runSnakeeyes({ channel, waitForDankMemer, betAmount = 10000 }) {
208
264
  return parseResult(text, 'snakeeyes');
209
265
  }
210
266
 
211
- module.exports = { runCointoss, runRoulette, runSlots, runSnakeeyes };
267
+ module.exports = {
268
+ runCointoss, runRoulette, runSlots, runSnakeeyes,
269
+ kellyFraction, winRateEMA, winWindow, totalWindow,
270
+ };