poker-bitvaljs 1.0.0 → 1.1.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.
Files changed (3) hide show
  1. package/README.md +67 -6
  2. package/bitval.js +964 -254
  3. package/package.json +1 -1
package/bitval.js CHANGED
@@ -13,12 +13,55 @@ class XorShift32 {
13
13
  }
14
14
  }
15
15
 
16
+ /**
17
+ * Fastest auto-clearing cache with minimal overhead.
18
+ * Uses bitwise counter check to avoid expensive modulo operations.
19
+ */
20
+ class FastestAutoClearingCache {
21
+ constructor(maxSize = 16000000) {
22
+ this.maxSize = maxSize;
23
+ this.clearThreshold = Math.floor(maxSize * 0.95); // Clear at 95% capacity
24
+ this.cache = new Map();
25
+ this.counter = 0;
26
+ }
27
+
28
+ has(key) {
29
+ return this.cache.has(key); // No overhead on hot path
30
+ }
31
+
32
+ get(key) {
33
+ return this.cache.get(key); // No overhead on hot path
34
+ }
35
+
36
+ set(key, value) {
37
+ // Bitwise check is faster than modulo - check every ~1 million operations
38
+ // 0xFFFFF = 1,048,576 (2^20)
39
+ if ((++this.counter & 0xFFFFF) === 0) {
40
+ if (this.cache.size >= this.clearThreshold) {
41
+ this.cache.clear();
42
+ }
43
+ }
44
+ this.cache.set(key, value);
45
+ }
46
+
47
+ clear() {
48
+ this.cache.clear();
49
+ }
50
+
51
+ get size() {
52
+ return this.cache.size;
53
+ }
54
+ }
55
+
16
56
  class BitVal {
17
57
  constructor() {
58
+ // Suit constants
18
59
  this._DIAMOND = 3n;
19
60
  this._HEART = 2n;
20
61
  this._CLUB = 1n;
21
62
  this._SPADE = 0n;
63
+
64
+ // Rank constants
22
65
  this._ACE = (1n << 52n) | 1n;
23
66
  this._KING = 1n << 48n;
24
67
  this._QUEEN = 1n << 44n;
@@ -33,6 +76,7 @@ class BitVal {
33
76
  this._THREE = 1n << 8n;
34
77
  this._TWO = 1n << 4n;
35
78
 
79
+ // Card masks lookup table
36
80
  this.CARD_MASKS = {
37
81
  'As': this._ACE << this._SPADE,
38
82
  'Ah': this._ACE << this._HEART,
@@ -90,6 +134,7 @@ class BitVal {
90
134
 
91
135
  this.ALL_CARD_MASKS = Object.values(this.CARD_MASKS);
92
136
 
137
+ // Hand strength score constants
93
138
  this.PAIR_SCORE = 1n << 56n;
94
139
  this.TWO_PAIRS_SCORE = 1n << 57n;
95
140
  this.TRIPS_SCORE = 1n << 58n;
@@ -99,11 +144,13 @@ class BitVal {
99
144
  this.QUADS_SCORE = 1n << 62n;
100
145
  this.STRAIGHT_FLUSH_SCORE = 1n << 63n;
101
146
 
147
+ // Bit manipulation constants
102
148
  this.BIT_1 = BigInt("0b00010001000100010001000100010001000100010001000100010001");
103
149
  this.BIT_2 = BigInt("0b00100010001000100010001000100010001000100010001000100010");
104
150
  this.BIT_3 = BigInt("0b01000100010001000100010001000100010001000100010001000100");
105
151
  this.BIT_4 = BigInt("0b10001000100010001000100010001000100010001000100010001000");
106
152
 
153
+ // Rank masks for kicker extraction
107
154
  this.RANK_MASKS = [
108
155
  240n,
109
156
  3840n,
@@ -138,6 +185,67 @@ class BitVal {
138
185
  };
139
186
  }
140
187
 
188
+ // ============================================
189
+ // PUBLIC API - Core Hand Evaluation
190
+ // ============================================
191
+
192
+ /**
193
+ * Evaluates a poker hand represented as a BigInt bitmask.
194
+ * @param {BigInt} handMask - Bitmask representing 5-7 cards
195
+ * @returns {Array} [evaluation, kickers] - Evaluation score and kicker bits
196
+ */
197
+ evaluate(hand) {
198
+ let response = 0n;
199
+ if (response = this._bitStraightFlush(hand)) {
200
+ return [response | this.STRAIGHT_FLUSH_SCORE, null];
201
+ }
202
+
203
+ if (response = this._bitQuads(hand)) {
204
+ let quadsRanksMask = this._getRankMaskFromBitmask(response);
205
+ let kickers = this._extractKickers(hand, quadsRanksMask, 1);
206
+ return [(response | this.QUADS_SCORE), kickers];
207
+ }
208
+
209
+ let trips = this._bitTrips(hand) & (0xFFFFFFFFFFFF0n | (1n << 52n));
210
+ let pairs = this._bitPairs(hand) & (0xFFFFFFFFFFFF0n | (1n << 52n));
211
+
212
+ if ((trips && pairs && trips ^ pairs) || this.countBits(trips) > 1) {
213
+ return [trips | this.FULL_HOUSE_SCORE, this.stripBits(pairs & ~trips, 1)];
214
+ }
215
+
216
+ if (response = this._bitFlush(hand)) {
217
+ return [response | this.FLUSH_SCORE, null];
218
+ }
219
+
220
+ if (response = this._bitStraight(hand)) {
221
+ return [response | this.STRAIGHT_SCORE, null];
222
+ }
223
+
224
+ if (response = trips) {
225
+ let tripsRanksMask = this._getRankMaskFromBitmask(response);
226
+ let kickers = this._extractKickers(hand, tripsRanksMask, 2);
227
+ return [(response | this.TRIPS_SCORE), kickers];
228
+ }
229
+
230
+ if (response = pairs) {
231
+ if (this.countBits(response) > 1) {
232
+ let pairRanksMask = this._getTwoPairRanksMask(response);
233
+ let kickers = this._extractKickers(hand, pairRanksMask, 1);
234
+ return [(response | this.TWO_PAIRS_SCORE), kickers];
235
+ }
236
+ let pairRanksMask = this._getRankMaskFromBitmask(response);
237
+ let kickers = this._extractKickers(hand, pairRanksMask, 3);
238
+ return [(response | this.PAIR_SCORE), kickers];
239
+ }
240
+
241
+ return [this.stripBits(this.normalize(hand), 5), null];
242
+ }
243
+
244
+ /**
245
+ * Gets the human-readable hand strength from an evaluation mask.
246
+ * @param {BigInt} handMask - Evaluation mask with score bits
247
+ * @returns {String} Hand strength name
248
+ */
141
249
  getHandStrengthFromMask(handMask) {
142
250
  if (handMask & this.PAIR_SCORE) {
143
251
  return "Pair";
@@ -166,6 +274,16 @@ class BitVal {
166
274
  return "High Card";
167
275
  }
168
276
 
277
+ /**
278
+ * Simulates a single hand vs hand matchup using Monte Carlo or exhaustive enumeration.
279
+ * @param {Number} iterations - Number of simulations
280
+ * @param {Number} numberOfBoardCards - Total board cards (default: 5)
281
+ * @param {Array} hero - Hero's hole cards as array of strings
282
+ * @param {Array} villain - Villain's hole cards as array of strings
283
+ * @param {Array} board - Board cards as array of strings
284
+ * @param {Array} deadCards - Dead cards as array of strings
285
+ * @returns {Object} { win, tie, lose, ...handStrengths } - Results object
286
+ */
169
287
  simulate(iterations, numberOfBoardCards = 5, hero = [], villain = [], board = [], deadCards = []) {
170
288
  let numberOfCardsToDeal = (numberOfBoardCards + 2) - (hero.length + board.length);
171
289
  this.xorShift = new XorShift32();
@@ -234,78 +352,38 @@ class BitVal {
234
352
  return result;
235
353
  }
236
354
 
237
- deal(hand, deadCards = 0n, numberOfCards = 7) {
238
- if (numberOfCards < 1) return hand;
239
- let deck = ~(hand | deadCards);
240
- let card;
241
-
242
- for (let i = 0; i < numberOfCards; i++) {
243
- do {
244
- card = this.ALL_CARD_MASKS[this.xorShift.next(52)];
245
- } while ((card & deck) !== card);
246
-
247
- hand |= card;
248
- deck &= ~card;
249
- }
250
-
251
- return hand;
252
- }
253
-
254
- _extractKickers(hand, madeHandRanksMask, numKickers) {
255
- const kickersOnly = hand & ~madeHandRanksMask;
256
- let kickerValue = 0n;
257
- let kickersFound = 0;
258
-
259
- for (let i = 12n; i >= 0n && kickersFound < numKickers; i--) {
260
- const rankMask = this.RANK_MASKS[i];
261
- if (kickersOnly & rankMask) {
262
- kickerValue |= (1n << (i + 13n));
263
- kickersFound++;
264
- }
265
- }
266
-
267
- return kickerValue;
268
- }
269
-
270
- _getRankMaskFromBitmask(bitmask) {
271
- return (this.RANK_MASK_LOOKUP[bitmask & 1n] || 0n) |
272
- (this.RANK_MASK_LOOKUP[bitmask & (1n << 52n)] || 0n) |
273
- (this.RANK_MASK_LOOKUP[bitmask] || 0n);
274
- }
275
-
276
- _getTwoPairRanksMask(pairsBitmask) {
277
- const firstBit = pairsBitmask & -pairsBitmask;
278
- const firstMask = this._getRankMaskFromBitmask(firstBit);
279
-
280
- const remaining = pairsBitmask & ~firstBit;
281
- const secondBit = remaining & -remaining;
282
- const secondMask = this._getRankMaskFromBitmask(secondBit);
283
-
284
- return firstMask | secondMask;
285
- }
286
-
287
- normalize(hand, bitShift = 4) {
288
- let shift_bits = ((hand | hand >> 1n | hand >> 2n | hand >> 3n) & this.BIT_1) >> 4n;
289
- let normal_bits = 0n;
290
- bitShift = BigInt(bitShift);
291
-
292
- for (let i = 0n; i < 13n; i++) {
293
- let bit = (shift_bits & (1n << i * bitShift)) >> i * bitShift;
294
- normal_bits += bit << i;
295
- }
296
- return normal_bits;
355
+ // ============================================
356
+ // PUBLIC API - Range Comparison
357
+ // ============================================
358
+
359
+ /**
360
+ * Compares two ranges of hands with optional canonical key caching optimization.
361
+ * @param {Array} heroHands - Array of hero hand strings (e.g., ['AsAh', 'AsAd'])
362
+ * @param {Array} villainHands - Array of villain hand strings
363
+ * @param {Array} boardCards - Board cards as strings (default: [])
364
+ * @param {Array} deadCards - Dead cards as strings (default: [])
365
+ * @param {Number} numberOfBoardCards - Total board cards (default: 5)
366
+ * @param {Number} iterations - Number of simulations per matchup (default: 10000)
367
+ * @param {Boolean} optimize - Use canonical key caching (default: true)
368
+ * @param {Function} progressCallback - Optional progress callback (current, total, message)
369
+ * @param {Number} progressInterval - Update progress every N matchups (default: 100)
370
+ * @param {Boolean} useWorkers - Use Web Workers for parallelization (default: true)
371
+ * @returns {Promise<Object>} { win, tie, lose } - Results object
372
+ */
373
+ async compareRange(heroHands, villainHands, boardCards = [], deadCards = [], numberOfBoardCards = 5, iterations = 10000, optimize = true, progressCallback = null, progressInterval = 100, useWorkers = true) {
374
+ const setup = this._compareRangeSetup(heroHands, villainHands, boardCards, deadCards, numberOfBoardCards, iterations, optimize);
375
+ return optimize ? await this._compareRangeOptimized(setup, progressCallback, progressInterval, useWorkers) : await this._compareRangeUnoptimized(heroHands, villainHands, setup, progressCallback, progressInterval);
297
376
  }
298
377
 
299
- stripBits(hand, desiredSizeOfMask = 5) {
300
- let numberOfBitsToStrip = this.countBits(hand) - desiredSizeOfMask;
301
-
302
- while (numberOfBitsToStrip > 0) {
303
- hand &= (hand - 1n);
304
- numberOfBitsToStrip--;
305
- }
306
- return hand;
307
- }
378
+ // ============================================
379
+ // PUBLIC API - Utility Methods
380
+ // ============================================
308
381
 
382
+ /**
383
+ * Converts an array of card strings to a BigInt bitmask.
384
+ * @param {Array} cards - Array of card strings (e.g., ['As', 'Kh', 'Qd'])
385
+ * @returns {BigInt} Bitmask representing the cards
386
+ */
309
387
  getBitMasked(cards) {
310
388
  let sf_mask = 0n;
311
389
 
@@ -316,6 +394,11 @@ class BitVal {
316
394
  return sf_mask;
317
395
  }
318
396
 
397
+ /**
398
+ * Counts the number of set bits in a bitmask.
399
+ * @param {BigInt} handMask - Bitmask to count
400
+ * @returns {Number} Number of set bits
401
+ */
319
402
  countBits(handMask) {
320
403
  let count = 0;
321
404
 
@@ -326,6 +409,12 @@ class BitVal {
326
409
  return count;
327
410
  }
328
411
 
412
+ /**
413
+ * Calculates combinations C(n, k).
414
+ * @param {Number} n - Total items
415
+ * @param {Number} k - Items to choose
416
+ * @returns {Number} Number of combinations
417
+ */
329
418
  combinations(n, k) {
330
419
  if (k > n || k < 0) return 0;
331
420
  if (k === 0 || k === n) return 1;
@@ -337,81 +426,78 @@ class BitVal {
337
426
  return Math.floor(result);
338
427
  }
339
428
 
340
- _getAvailableCardMasksByLookUp(deadCards) {
341
- let masks = [];
342
- let deck = ~deadCards;
343
-
344
- for (const card in this.CARD_MASKS) {
345
- const cardMask = this.CARD_MASKS[card];
346
- if ((cardMask & deck) === cardMask) {
347
- masks.push(cardMask);
348
- }
349
- }
350
-
351
- return masks;
352
- }
429
+ /**
430
+ * Normalizes a hand bitmask by extracting rank information.
431
+ * @param {BigInt} hand - Hand bitmask
432
+ * @param {Number} bitShift - Bit shift amount (default: 4)
433
+ * @returns {BigInt} Normalized bitmask
434
+ */
435
+ normalize(hand, bitShift = 4) {
436
+ let shift_bits = ((hand | hand >> 1n | hand >> 2n | hand >> 3n) & this.BIT_1) >> 4n;
437
+ let normal_bits = 0n;
438
+ bitShift = BigInt(bitShift);
353
439
 
354
- _getCombinations(availableMasks, k) {
355
- if (k === 1) return availableMasks;
356
- if (k === 2) {
357
- let combos = [];
358
- for (let i = 0; i < availableMasks.length; i++) {
359
- for (let j = i + 1; j < availableMasks.length; j++) {
360
- combos.push(availableMasks[i] | availableMasks[j]);
361
- }
362
- }
363
- return combos;
440
+ for (let i = 0n; i < 13n; i++) {
441
+ let bit = (shift_bits & (1n << i * bitShift)) >> i * bitShift;
442
+ normal_bits += bit << i;
364
443
  }
365
- return [];
444
+ return normal_bits;
366
445
  }
367
446
 
368
- evaluate(hand) {
369
- let response = 0n;
370
- if (response = this._bitStraightFlush(hand)) {
371
- return [response | this.STRAIGHT_FLUSH_SCORE, null];
372
- }
373
-
374
- if (response = this._bitQuads(hand)) {
375
- let quadsRanksMask = this._getRankMaskFromBitmask(response);
376
- let kickers = this._extractKickers(hand, quadsRanksMask, 1);
377
- return [(response | this.QUADS_SCORE), kickers];
378
- }
379
-
380
- let trips = this._bitTrips(hand) & (0xFFFFFFFFFFFF0n | (1n << 52n));
381
- let pairs = this._bitPairs(hand) & (0xFFFFFFFFFFFF0n | (1n << 52n));
382
-
383
- if ((trips && pairs && trips ^ pairs) || this.countBits(trips) > 1) {
384
- return [trips | this.FULL_HOUSE_SCORE, this.stripBits(pairs & ~trips, 1)];
385
- }
386
-
387
- if (response = this._bitFlush(hand)) {
388
- return [response | this.FLUSH_SCORE, null];
389
- }
390
-
391
- if (response = this._bitStraight(hand)) {
392
- return [response | this.STRAIGHT_SCORE, null];
393
- }
447
+ /**
448
+ * Strips bits from a hand mask to reduce it to desired size.
449
+ * @param {BigInt} hand - Hand bitmask
450
+ * @param {Number} desiredSizeOfMask - Desired number of bits (default: 5)
451
+ * @returns {BigInt} Stripped bitmask
452
+ */
453
+ stripBits(hand, desiredSizeOfMask = 5) {
454
+ let numberOfBitsToStrip = this.countBits(hand) - desiredSizeOfMask;
394
455
 
395
- if (response = trips) {
396
- let tripsRanksMask = this._getRankMaskFromBitmask(response);
397
- let kickers = this._extractKickers(hand, tripsRanksMask, 2);
398
- return [(response | this.TRIPS_SCORE), kickers];
456
+ while (numberOfBitsToStrip > 0) {
457
+ hand &= (hand - 1n);
458
+ numberOfBitsToStrip--;
399
459
  }
460
+ return hand;
461
+ }
400
462
 
401
- if (response = pairs) {
402
- if (this.countBits(response) > 1) {
403
- let pairRanksMask = this._getTwoPairRanksMask(response);
404
- let kickers = this._extractKickers(hand, pairRanksMask, 1);
405
- return [(response | this.TWO_PAIRS_SCORE), kickers];
406
- }
407
- let pairRanksMask = this._getRankMaskFromBitmask(response);
408
- let kickers = this._extractKickers(hand, pairRanksMask, 3);
409
- return [(response | this.PAIR_SCORE), kickers];
463
+ /**
464
+ * Deals random cards from the deck, excluding dead cards.
465
+ * Optimized version that pre-computes available cards to avoid collision loops.
466
+ * @param {BigInt} hand - Current hand bitmask
467
+ * @param {BigInt} deadCards - Dead cards bitmask (default: 0n)
468
+ * @param {Number} numberOfCards - Number of cards to deal (default: 7)
469
+ * @returns {BigInt} Hand bitmask with new cards added
470
+ */
471
+ deal(hand, deadCards = 0n, numberOfCards = 7) {
472
+ if (numberOfCards < 1) return hand;
473
+
474
+ // Pre-compute available cards once (major optimization - eliminates do-while collision loop)
475
+ const availableCards = this._getAvailableCardMasksByLookUp(hand | deadCards);
476
+ const availableCount = availableCards.length;
477
+
478
+ if (availableCount < numberOfCards) return hand;
479
+
480
+ // Use Fisher-Yates style selection with direct indexing (much faster than do-while)
481
+ for (let i = 0; i < numberOfCards; i++) {
482
+ const randomIndex = this.xorShift.next(availableCount - i);
483
+ const card = availableCards[randomIndex];
484
+ hand |= card;
485
+
486
+ // Swap selected card with last unselected card (Fisher-Yates shuffle)
487
+ availableCards[randomIndex] = availableCards[availableCount - 1 - i];
410
488
  }
411
489
 
412
- return [this.stripBits(this.normalize(hand), 5), null];
490
+ return hand;
413
491
  }
414
492
 
493
+ // ============================================
494
+ // PRIVATE - Core Bitwise Evaluation Helpers
495
+ // ============================================
496
+
497
+ /**
498
+ * Detects pairs in a hand using bitwise operations.
499
+ * @private
500
+ */
415
501
  _bitPairs(hand) {
416
502
  let pairs = (
417
503
  ((this.BIT_1 & hand) << 1n) & (this.BIT_2 & hand) |
@@ -424,6 +510,10 @@ class BitVal {
424
510
  return this.stripBits(pairs, 2);
425
511
  }
426
512
 
513
+ /**
514
+ * Detects trips (three of a kind) in a hand using bitwise operations.
515
+ * @private
516
+ */
427
517
  _bitTrips(hand) {
428
518
  let a = (((this.BIT_1 & hand) << 1n & (this.BIT_2 & hand)) << 1n & (this.BIT_3 & hand)) >> 2n;
429
519
  let b = (((this.BIT_2 & hand) << 1n & (this.BIT_3 & hand)) << 1n & (this.BIT_4 & hand)) >> 3n;
@@ -432,6 +522,10 @@ class BitVal {
432
522
  return this.stripBits((a | b | c | d) & this.BIT_1, 1);
433
523
  }
434
524
 
525
+ /**
526
+ * Detects a flush in a hand using bitwise operations.
527
+ * @private
528
+ */
435
529
  _bitFlush(hand) {
436
530
  let i = 0n;
437
531
  while (i < 4) {
@@ -443,6 +537,10 @@ class BitVal {
443
537
  return 0n;
444
538
  }
445
539
 
540
+ /**
541
+ * Detects a straight in a hand using bitwise operations.
542
+ * @private
543
+ */
446
544
  _bitStraight(hand) {
447
545
  hand = (hand | hand >> 1n | hand >> 2n | hand >> 3n) & this.BIT_1;
448
546
 
@@ -455,6 +553,10 @@ class BitVal {
455
553
  return this.stripBits(hand, 1);
456
554
  }
457
555
 
556
+ /**
557
+ * Detects quads (four of a kind) in a hand using bitwise operations.
558
+ * @private
559
+ */
458
560
  _bitQuads(hand) {
459
561
  return ((((
460
562
  (hand & this.BIT_1) << 1n &
@@ -464,6 +566,10 @@ class BitVal {
464
566
  & this.BIT_4) >> 3n;
465
567
  }
466
568
 
569
+ /**
570
+ * Detects a straight flush in a hand using bitwise operations.
571
+ * @private
572
+ */
467
573
  _bitStraightFlush(hand) {
468
574
  hand = hand &
469
575
  (hand << 4n) &
@@ -474,61 +580,59 @@ class BitVal {
474
580
  return this.stripBits(hand, 1);
475
581
  }
476
582
 
477
- _getBoardSuitCounts(boardCards) {
478
- const suitCounts = new Map();
479
- for (const card of boardCards) {
480
- const suit = card[1];
481
- suitCounts.set(suit, (suitCounts.get(suit) || 0) + 1);
583
+ /**
584
+ * Extracts kicker bits from a hand given the made hand rank mask.
585
+ * @private
586
+ */
587
+ _extractKickers(hand, madeHandRanksMask, numKickers) {
588
+ const kickersOnly = hand & ~madeHandRanksMask;
589
+ let kickerValue = 0n;
590
+ let kickersFound = 0;
591
+
592
+ for (let i = 12n; i >= 0n && kickersFound < numKickers; i--) {
593
+ const rankMask = this.RANK_MASKS[i];
594
+ if (kickersOnly & rankMask) {
595
+ kickerValue |= (1n << (i + 13n));
596
+ kickersFound++;
597
+ }
482
598
  }
483
- return suitCounts;
484
- }
485
599
 
486
- _getCardsToCome(boardCards, numberOfBoardCards) {
487
- return numberOfBoardCards - boardCards.length;
600
+ return kickerValue;
488
601
  }
489
602
 
490
- _suitHasFlushPotential(suit, boardSuitCounts, cardsToCome) {
491
- return (boardSuitCounts.get(suit) || 0) + cardsToCome >= 4;
603
+ /**
604
+ * Gets the rank mask from a bitmask for kicker extraction.
605
+ * @private
606
+ */
607
+ _getRankMaskFromBitmask(bitmask) {
608
+ return (this.RANK_MASK_LOOKUP[bitmask & 1n] || 0n) |
609
+ (this.RANK_MASK_LOOKUP[bitmask & (1n << 52n)] || 0n) |
610
+ (this.RANK_MASK_LOOKUP[bitmask] || 0n);
492
611
  }
493
612
 
494
- _getCanonicalKey(hand, boardCards = [], numberOfBoardCards = 5, boardSuitCounts = null) {
495
- const r1 = hand[0], r2 = hand[2], s1 = hand[1], s2 = hand[3];
496
- const rankOrder = 'AKQJT98765432';
497
- const r1Idx = rankOrder.indexOf(r1), r2Idx = rankOrder.indexOf(r2);
498
- const high = r1Idx < r2Idx ? r1 : r2, low = r1Idx < r2Idx ? r2 : r1;
499
- const highSuit = r1Idx < r2Idx ? s1 : s2, lowSuit = r1Idx < r2Idx ? s2 : s1;
500
- if (boardCards.length === 0) {
501
- if (r1 === r2) return high + low;
502
- return high + low + (s1 === s2 ? 's' : 'o');
503
- }
504
- if (!boardSuitCounts) boardSuitCounts = this._getBoardSuitCounts(boardCards);
505
- const cardsToCome = this._getCardsToCome(boardCards, numberOfBoardCards);
506
- if (r1 === r2) {
507
- const s1Pot = this._suitHasFlushPotential(s1, boardSuitCounts, cardsToCome);
508
- const s2Pot = this._suitHasFlushPotential(s2, boardSuitCounts, cardsToCome);
509
- if (s1Pot) return high + low + s1;
510
- if (s2Pot) return high + low + s2;
511
- return high + low;
512
- }
513
- const highPot = this._suitHasFlushPotential(highSuit, boardSuitCounts, cardsToCome);
514
- const lowPot = this._suitHasFlushPotential(lowSuit, boardSuitCounts, cardsToCome);
515
- return (highPot ? high + highSuit : high) + (lowPot ? low + lowSuit : low);
516
- }
613
+ /**
614
+ * Gets the rank mask for two pairs.
615
+ * @private
616
+ */
617
+ _getTwoPairRanksMask(pairsBitmask) {
618
+ const firstBit = pairsBitmask & -pairsBitmask;
619
+ const firstMask = this._getRankMaskFromBitmask(firstBit);
517
620
 
518
- _flattenHandsToCanonical(hands, boardCards = [], numberOfBoardCards = 5) {
519
- const canonicalMap = new Map();
520
- const canonicalToHand = new Map();
521
- const boardSuitCounts = boardCards.length > 0 ? this._getBoardSuitCounts(boardCards) : null;
522
- for (const hand of hands) {
523
- const key = this._getCanonicalKey(hand, boardCards, numberOfBoardCards, boardSuitCounts);
524
- canonicalMap.set(key, (canonicalMap.get(key) || 0) + 1);
525
- if (!canonicalToHand.has(key)) {
526
- canonicalToHand.set(key, hand);
527
- }
528
- }
529
- return { canonicalMap, canonicalToHand };
621
+ const remaining = pairsBitmask & ~firstBit;
622
+ const secondBit = remaining & -remaining;
623
+ const secondMask = this._getRankMaskFromBitmask(secondBit);
624
+
625
+ return firstMask | secondMask;
530
626
  }
531
627
 
628
+ // ============================================
629
+ // PRIVATE - Range Comparison Helpers
630
+ // ============================================
631
+
632
+ /**
633
+ * Generates all valid matchups between hero and villain hands, excluding overlaps.
634
+ * @private
635
+ */
532
636
  _generateValidMatchups(heroHands, villainHands, boardCards = []) {
533
637
  const valid = [];
534
638
  const boardMask = boardCards.length > 0 ? this.getBitMasked(boardCards) : 0n;
@@ -544,6 +648,10 @@ class BitVal {
544
648
  return valid;
545
649
  }
546
650
 
651
+ /**
652
+ * Calculates valid matchup counts per canonical key pair.
653
+ * @private
654
+ */
547
655
  _calculateValidCounts(validMatchups, hCanon, vCanon, boardCards, numberOfBoardCards) {
548
656
  const counts = new Map();
549
657
  const boardSuitCounts = boardCards.length > 0 ? this._getBoardSuitCounts(boardCards) : null;
@@ -556,147 +664,749 @@ class BitVal {
556
664
  return counts;
557
665
  }
558
666
 
667
+ /**
668
+ * Sets up range comparison by generating valid matchups and canonical mappings.
669
+ * @private
670
+ */
559
671
  _compareRangeSetup(heroHands, villainHands, boardCards, deadCards, numberOfBoardCards, iterations, optimize) {
672
+ // Generate valid matchups (always needed)
560
673
  const validMatchups = this._generateValidMatchups(heroHands, villainHands, boardCards);
561
- const hCanon = optimize ? this._flattenHandsToCanonical(heroHands, boardCards, numberOfBoardCards) : null;
562
- const vCanon = optimize ? this._flattenHandsToCanonical(villainHands, boardCards, numberOfBoardCards) : null;
563
- const validCounts = optimize ? this._calculateValidCounts(validMatchups, hCanon, vCanon, boardCards, numberOfBoardCards) : null;
674
+
675
+ // Calculate canonical mappings only if optimization is enabled
676
+ let hCanon = null;
677
+ let vCanon = null;
678
+ let validCounts = null;
679
+ if (optimize) {
680
+ hCanon = this._flattenHandsToCanonical(heroHands, boardCards, numberOfBoardCards);
681
+ vCanon = this._flattenHandsToCanonical(villainHands, boardCards, numberOfBoardCards);
682
+ validCounts = this._calculateValidCounts(validMatchups, hCanon, vCanon, boardCards, numberOfBoardCards);
683
+ }
684
+
685
+ // Calculate bitmasks
564
686
  const boardMask = this.getBitMasked(boardCards);
565
- const allDeadCards = [...boardCards, ...deadCards];
566
- const deadCardsMask = this.getBitMasked(allDeadCards);
687
+ const deadCardsMask = this.getBitMasked([...boardCards, ...deadCards]);
688
+
689
+ // Calculate cards to deal and determine if exhaustive enumeration is possible
567
690
  const numberOfCardsToDeal = numberOfBoardCards - boardCards.length;
568
- const availableCards = 52 - new Set(allDeadCards).size;
569
- const exhaustiveCombinations = numberOfCardsToDeal > 0 && numberOfCardsToDeal <= 2 ? this.combinations(availableCards, numberOfCardsToDeal) : Infinity;
691
+ const canBeExhaustive = numberOfCardsToDeal > 0 && numberOfCardsToDeal <= 2;
692
+
693
+ // Calculate available cards using bitmask (more efficient than Set)
694
+ const availableCards = 52 - this.countBits(deadCardsMask);
695
+
696
+ // Determine if we should use exhaustive enumeration
697
+ const exhaustiveCombinations = canBeExhaustive
698
+ ? this.combinations(availableCards, numberOfCardsToDeal)
699
+ : Infinity;
570
700
  iterations = Math.min(iterations, exhaustiveCombinations);
571
- const isExhaustive = iterations === exhaustiveCombinations && numberOfCardsToDeal > 0 && numberOfCardsToDeal <= 2 && exhaustiveCombinations < Infinity;
572
- const comboArray = isExhaustive ? this._getCombinations(this._getAvailableCardMasksByLookUp(deadCardsMask), numberOfCardsToDeal) : null;
573
- return { hCanon, vCanon, boardMask, deadCardsMask, iterations, isExhaustive, comboArray, numberOfCardsToDeal, validMatchups, validCounts, boardCards, numberOfBoardCards };
701
+ const isExhaustive = canBeExhaustive && iterations === exhaustiveCombinations && exhaustiveCombinations < Infinity;
702
+
703
+ // Generate combo array only if exhaustive
704
+ const comboArray = isExhaustive
705
+ ? this._getCombinations(this._getAvailableCardMasksByLookUp(deadCardsMask), numberOfCardsToDeal)
706
+ : null;
707
+
708
+ return {
709
+ hCanon,
710
+ vCanon,
711
+ boardMask,
712
+ deadCardsMask,
713
+ iterations,
714
+ isExhaustive,
715
+ comboArray,
716
+ numberOfCardsToDeal,
717
+ validMatchups,
718
+ validCounts,
719
+ boardCards,
720
+ numberOfBoardCards
721
+ };
574
722
  }
575
723
 
576
- _getCachedEvaluation(canonicalKey, originalHand, completeBoard, evalCache) {
577
- const cacheKey = canonicalKey + completeBoard.toString();
578
- if (evalCache.has(cacheKey)) return evalCache.get(cacheKey);
579
- const handMask = this.getBitMasked(this._handStringToCards(originalHand));
580
- const evaluation = this.evaluate(handMask | completeBoard);
581
- evalCache.set(cacheKey, evaluation);
582
- return evaluation;
724
+ /**
725
+ * Serializes setup object for worker communication (converts BigInts to strings).
726
+ * Note: Maps and complex objects are not sent to workers - workers only need basic setup data.
727
+ * @private
728
+ */
729
+ _serializeSetup(setup) {
730
+ return {
731
+ boardMask: setup.boardMask.toString(),
732
+ deadCardsMask: setup.deadCardsMask.toString(),
733
+ comboArray: setup.comboArray ? setup.comboArray.map(m => m.toString()) : null,
734
+ boardCards: setup.boardCards,
735
+ numberOfBoardCards: setup.numberOfBoardCards,
736
+ iterations: setup.iterations,
737
+ isExhaustive: setup.isExhaustive,
738
+ numberOfCardsToDeal: setup.numberOfCardsToDeal
739
+ // Note: hCanon, vCanon, validMatchups, validCounts are not needed in workers
740
+ // as matchups are pre-serialized and sent separately
741
+ };
583
742
  }
584
743
 
585
- _compareEvaluations(heroEval, heroKicker, villainEval, villainKicker) {
586
- if (heroEval > villainEval || (heroEval === villainEval && heroKicker > villainKicker)) return 1;
587
- if (villainEval > heroEval || (villainEval === heroEval && villainKicker > heroKicker)) return -1;
588
- return 0;
744
+ /**
745
+ * Deserializes setup data from worker (converts string BigInts back to BigInt).
746
+ * @private
747
+ */
748
+ _deserializeSetup(setupData) {
749
+ return {
750
+ ...setupData,
751
+ boardMask: BigInt(setupData.boardMask),
752
+ deadCardsMask: BigInt(setupData.deadCardsMask),
753
+ comboArray: setupData.comboArray ? setupData.comboArray.map(s => BigInt(s)) : null
754
+ };
755
+ }
756
+
757
+ /**
758
+ * Serializes matchup data for worker communication (converts BigInt masks to strings).
759
+ * @private
760
+ */
761
+ _serializeMatchup(matchup) {
762
+ return {
763
+ ...matchup,
764
+ heroMask: matchup.heroMask.toString(),
765
+ villainMask: matchup.villainMask.toString()
766
+ };
589
767
  }
590
768
 
769
+ /**
770
+ * Deserializes matchup data from worker (converts string BigInts back to BigInt).
771
+ * @private
772
+ */
773
+ _deserializeMatchup(matchupData) {
774
+ return {
775
+ ...matchupData,
776
+ heroMask: BigInt(matchupData.heroMask),
777
+ villainMask: BigInt(matchupData.villainMask)
778
+ };
779
+ }
780
+
781
+ /**
782
+ * Evaluates a single matchup with optional caching.
783
+ * Optimized for browser performance with adaptive yielding and inlined hot loop.
784
+ * @private
785
+ */
591
786
  async _evaluateMatchup(heroMask, villainMask, setup, evalCache = null, progressCallback = null, matchupIndex = 0, totalMatchups = 1) {
592
787
  let win = 0, tie = 0, lose = 0;
593
788
  const deadMask = heroMask | villainMask | setup.deadCardsMask;
594
- if (!setup.isExhaustive) this.xorShift = new XorShift32();
595
789
 
596
- // For exhaustive, generate comboArray excluding THIS matchup's hero/villain cards
790
+ // Initialize random number generator for Monte Carlo (not needed for exhaustive)
791
+ if (!setup.isExhaustive) {
792
+ this.xorShift = new XorShift32();
793
+ }
794
+
795
+ // Prepare for exhaustive enumeration if applicable
597
796
  let comboArray = null;
598
797
  let iterations = setup.iterations;
599
798
  if (setup.isExhaustive) {
600
- const matchupDeadMask = heroMask | villainMask | setup.deadCardsMask;
601
- const availableMasks = this._getAvailableCardMasksByLookUp(matchupDeadMask);
799
+ const availableMasks = this._getAvailableCardMasksByLookUp(deadMask);
602
800
  comboArray = this._getCombinations(availableMasks, setup.numberOfCardsToDeal);
603
801
  iterations = comboArray.length;
604
802
  }
605
803
 
606
- const yieldInterval = 1000;
804
+ // Main evaluation loop - optimized with inlined functions and adaptive yielding
805
+ const yieldInterval = 5000; // Increased from 1000 - reduces async overhead while maintaining responsiveness
806
+ let lastProgressTime = 0;
807
+ const progressUpdateInterval = 100; // Update progress at most every 100ms
808
+
607
809
  for (let i = 0; i < iterations; i++) {
608
- const board = setup.isExhaustive && comboArray ? setup.boardMask | comboArray[i] : this.deal(setup.boardMask, deadMask, setup.numberOfCardsToDeal) | setup.boardMask;
609
- const [hEval, hKick] = evalCache ? this._getCachedEvaluation(evalCache.heroKey, evalCache.heroHand, board, evalCache.cache) : this.evaluate(heroMask | board);
610
- const [vEval, vKick] = evalCache ? this._getCachedEvaluation(evalCache.villainKey, evalCache.villainHand, board, evalCache.cache) : this.evaluate(villainMask | board);
611
- const result = this._compareEvaluations(hEval, hKick, vEval, vKick);
612
- if (result === 1) win++; else if (result === -1) lose++; else tie++;
810
+ // Generate board: use pre-computed combo for exhaustive, or deal randomly
811
+ const board = setup.isExhaustive
812
+ ? setup.boardMask | comboArray[i]
813
+ : this.deal(setup.boardMask, deadMask, setup.numberOfCardsToDeal) | setup.boardMask;
814
+
815
+ // Evaluate both hands - inlined for performance (no function call overhead)
816
+ let hEval, hKick, vEval, vKick;
817
+ if (evalCache) {
818
+ [hEval, hKick] = this._getCachedEvaluation(evalCache.heroKey, evalCache.heroHand, board, evalCache.cache);
819
+ [vEval, vKick] = this._getCachedEvaluation(evalCache.villainKey, evalCache.villainHand, board, evalCache.cache);
820
+ } else {
821
+ [hEval, hKick] = this.evaluate(heroMask | board);
822
+ [vEval, vKick] = this.evaluate(villainMask | board);
823
+ }
824
+
825
+ // Compare and accumulate results - inlined for performance
826
+ if (hEval > vEval || (hEval === vEval && hKick > vKick)) {
827
+ win++;
828
+ } else if (vEval > hEval || (vEval === hEval && vKick > hKick)) {
829
+ lose++;
830
+ } else {
831
+ tie++;
832
+ }
833
+
834
+ // Adaptive yielding: less frequent but still maintains UI responsiveness
613
835
  if (progressCallback && i > 0 && i % yieldInterval === 0) {
614
- const total = totalMatchups * iterations;
615
- const current = matchupIndex * iterations + i;
616
- progressCallback(current, total, `Matchup ${matchupIndex + 1}/${totalMatchups}: ${i.toLocaleString()}/${iterations.toLocaleString()}`);
617
- await new Promise(resolve => setTimeout(resolve, 0));
836
+ const now = Date.now();
837
+ const shouldUpdateProgress = now - lastProgressTime >= progressUpdateInterval;
838
+
839
+ // Use modern browser APIs for better scheduling when available
840
+ if (typeof scheduler !== 'undefined' && scheduler.postTask) {
841
+ await scheduler.postTask(() => {
842
+ if (shouldUpdateProgress) {
843
+ const total = totalMatchups * iterations;
844
+ const current = matchupIndex * iterations + i;
845
+ progressCallback(current, total, `Matchup ${matchupIndex + 1}/${totalMatchups}: ${i.toLocaleString()}/${iterations.toLocaleString()}`);
846
+ lastProgressTime = now;
847
+ }
848
+ }, { priority: 'user-blocking' });
849
+ } else if (typeof requestIdleCallback !== 'undefined') {
850
+ await new Promise(resolve => {
851
+ requestIdleCallback(() => {
852
+ if (shouldUpdateProgress) {
853
+ const total = totalMatchups * iterations;
854
+ const current = matchupIndex * iterations + i;
855
+ progressCallback(current, total, `Matchup ${matchupIndex + 1}/${totalMatchups}: ${i.toLocaleString()}/${iterations.toLocaleString()}`);
856
+ lastProgressTime = now;
857
+ }
858
+ resolve();
859
+ }, { timeout: 1 });
860
+ });
861
+ } else {
862
+ // Fallback to setTimeout
863
+ await new Promise(resolve => setTimeout(resolve, 0));
864
+ if (shouldUpdateProgress) {
865
+ const total = totalMatchups * iterations;
866
+ const current = matchupIndex * iterations + i;
867
+ progressCallback(current, total, `Matchup ${matchupIndex + 1}/${totalMatchups}: ${i.toLocaleString()}/${iterations.toLocaleString()}`);
868
+ lastProgressTime = now;
869
+ }
870
+ }
618
871
  }
619
872
  }
873
+
620
874
  return { matchupWin: win, matchupTie: tie, matchupLose: lose };
621
875
  }
622
876
 
623
- async _compareRangeUnoptimized(heroHands, villainHands, setup, progressCallback = null) {
877
+ /**
878
+ * Compares ranges without optimization (evaluates all matchups individually).
879
+ * @private
880
+ */
881
+ async _compareRangeUnoptimized(heroHands, villainHands, setup, progressCallback = null, progressInterval = 100) {
624
882
  let win = 0, tie = 0, lose = 0;
625
883
  const total = setup.validMatchups.length;
884
+ let lastProgressTime = 0;
885
+ const progressUpdateInterval = 100; // Update progress at most every 100ms
886
+
626
887
  for (let i = 0; i < total; i++) {
627
888
  const { heroMask, villainMask } = setup.validMatchups[i];
628
889
  const { matchupWin, matchupTie, matchupLose } = await this._evaluateMatchup(heroMask, villainMask, setup, null, progressCallback, i, total);
629
890
  win += matchupWin; tie += matchupTie; lose += matchupLose;
630
- if (progressCallback && i % 10 === 0) {
631
- progressCallback(i, total, `Evaluating ${i}/${total}`);
632
- await new Promise(resolve => setTimeout(resolve, 0));
891
+
892
+ if (progressCallback && i % progressInterval === 0) {
893
+ const now = Date.now();
894
+ const shouldUpdateProgress = now - lastProgressTime >= progressUpdateInterval;
895
+
896
+ // Use modern browser APIs for better scheduling when available
897
+ if (typeof scheduler !== 'undefined' && scheduler.postTask) {
898
+ await scheduler.postTask(() => {
899
+ if (shouldUpdateProgress) {
900
+ progressCallback(i, total, `Evaluating ${i}/${total}`);
901
+ lastProgressTime = now;
902
+ }
903
+ }, { priority: 'user-blocking' });
904
+ } else if (typeof requestIdleCallback !== 'undefined') {
905
+ await new Promise(resolve => {
906
+ requestIdleCallback(() => {
907
+ if (shouldUpdateProgress) {
908
+ progressCallback(i, total, `Evaluating ${i}/${total}`);
909
+ lastProgressTime = now;
910
+ }
911
+ resolve();
912
+ }, { timeout: 1 });
913
+ });
914
+ } else {
915
+ // Fallback to setTimeout
916
+ await new Promise(resolve => setTimeout(resolve, 0));
917
+ if (shouldUpdateProgress) {
918
+ progressCallback(i, total, `Evaluating ${i}/${total}`);
919
+ lastProgressTime = now;
920
+ }
921
+ }
633
922
  }
634
923
  }
635
924
  if (progressCallback) progressCallback(total, total, 'Complete');
636
925
  return { win, tie, lose };
637
926
  }
638
927
 
639
- async _compareRangeOptimized(setup, progressCallback = null) {
928
+ /**
929
+ * Compares ranges with canonical key caching optimization (sequential version).
930
+ * @private
931
+ */
932
+ async _compareRangeOptimizedSequential(setup, progressCallback = null, progressInterval = 100) {
640
933
  let win = 0, tie = 0, lose = 0;
641
- const evalCache = new Map();
642
- const totalMatchups = setup.hCanon.canonicalMap.size * setup.vCanon.canonicalMap.size;
934
+ const evalCache = new FastestAutoClearingCache(16000000);
935
+ let lastProgressTime = 0;
936
+ const progressUpdateInterval = 100; // Update progress at most every 100ms
937
+
938
+ // Pre-calculate board suit counts once (constant for all matchups)
939
+ const boardSuitCounts = setup.boardCards.length > 0
940
+ ? this._getBoardSuitCounts(setup.boardCards)
941
+ : null;
942
+
943
+ // Pre-build lookup map: canonical key pair -> valid matchup
944
+ // This eliminates the O(n) find() call inside nested loops, reducing complexity
945
+ // from O(heroCanon × villainCanon × validMatchups) to O(validMatchups + heroCanon × villainCanon)
946
+ const canonicalToMatchup = new Map();
947
+ for (const matchup of setup.validMatchups) {
948
+ const mHKey = this._getCanonicalKey(
949
+ matchup.heroHand,
950
+ setup.boardCards,
951
+ setup.numberOfBoardCards,
952
+ boardSuitCounts
953
+ );
954
+ const mVKey = this._getCanonicalKey(
955
+ matchup.villainHand,
956
+ setup.boardCards,
957
+ setup.numberOfBoardCards,
958
+ boardSuitCounts
959
+ );
960
+ const key = `${mHKey}:${mVKey}`;
961
+ if (!canonicalToMatchup.has(key)) {
962
+ canonicalToMatchup.set(key, matchup);
963
+ }
964
+ }
965
+
966
+ // Calculate accurate total matchups (excluding invalid pairs with validCount=0)
967
+ let totalMatchups = 0;
968
+ for (const [hKey] of setup.hCanon.canonicalMap) {
969
+ for (const [vKey] of setup.vCanon.canonicalMap) {
970
+ const key = `${hKey}:${vKey}`;
971
+ if ((setup.validCounts.get(key) || 0) > 0) {
972
+ totalMatchups++;
973
+ }
974
+ }
975
+ }
976
+
643
977
  let matchupIndex = 0;
644
- for (const [hKey, hMult] of setup.hCanon.canonicalMap) {
645
- const hHand = setup.hCanon.canonicalToHand.get(hKey);
646
- const hMask = this.getBitMasked(this._handStringToCards(hHand));
647
- for (const [vKey, vMult] of setup.vCanon.canonicalMap) {
648
- const vHand = setup.vCanon.canonicalToHand.get(vKey);
649
- const vMask = this.getBitMasked(this._handStringToCards(vHand));
978
+ for (const [hKey] of setup.hCanon.canonicalMap) {
979
+ for (const [vKey] of setup.vCanon.canonicalMap) {
650
980
  const key = `${hKey}:${vKey}`;
651
981
  const validCount = setup.validCounts.get(key) || 0;
652
- if (validCount === 0) {
653
- matchupIndex++;
654
- continue;
655
- }
656
- // ALWAYS find a valid pair from validMatchups to ensure no board overlap
657
- const boardSuitCounts = setup.boardCards.length > 0 ? this._getBoardSuitCounts(setup.boardCards) : null;
658
- const validPair = setup.validMatchups.find(m => {
659
- const mHKey = this._getCanonicalKey(m.heroHand, setup.boardCards, setup.numberOfBoardCards, boardSuitCounts);
660
- const mVKey = this._getCanonicalKey(m.villainHand, setup.boardCards, setup.numberOfBoardCards, boardSuitCounts);
661
- return mHKey === hKey && mVKey === vKey;
662
- });
982
+
983
+ if (validCount === 0) continue;
984
+
985
+ // O(1) lookup instead of O(n) find() - major performance improvement
986
+ const validPair = canonicalToMatchup.get(key);
663
987
  if (!validPair) {
664
988
  console.error(`[DIAG] ERROR: No valid pair found for ${hKey}:${vKey} despite validCount=${validCount}`);
665
- matchupIndex++;
666
989
  continue;
667
990
  }
668
- const evalHand = validPair.heroHand;
669
- const evalVillain = validPair.villainHand;
670
- const evalHMask = validPair.heroMask;
671
- const evalVMask = validPair.villainMask;
672
- const cache = { heroKey: hKey, heroHand: evalHand, villainKey: vKey, villainHand: evalVillain, cache: evalCache };
673
- const { matchupWin, matchupTie, matchupLose } = await this._evaluateMatchup(evalHMask, evalVMask, setup, cache, progressCallback, matchupIndex, totalMatchups);
674
- win += matchupWin * validCount; tie += matchupTie * validCount; lose += matchupLose * validCount;
991
+
992
+ const cache = {
993
+ heroKey: hKey,
994
+ heroHand: validPair.heroHand,
995
+ villainKey: vKey,
996
+ villainHand: validPair.villainHand,
997
+ cache: evalCache
998
+ };
999
+
1000
+ const { matchupWin, matchupTie, matchupLose } = await this._evaluateMatchup(
1001
+ validPair.heroMask,
1002
+ validPair.villainMask,
1003
+ setup,
1004
+ cache,
1005
+ progressCallback,
1006
+ matchupIndex,
1007
+ totalMatchups
1008
+ );
1009
+
1010
+ win += matchupWin * validCount;
1011
+ tie += matchupTie * validCount;
1012
+ lose += matchupLose * validCount;
1013
+
675
1014
  matchupIndex++;
676
- if (progressCallback && matchupIndex % 10 === 0) {
677
- progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`);
678
- await new Promise(resolve => setTimeout(resolve, 0));
1015
+ if (progressCallback && matchupIndex % progressInterval === 0) {
1016
+ const now = Date.now();
1017
+ const shouldUpdateProgress = now - lastProgressTime >= progressUpdateInterval;
1018
+
1019
+ // Use modern browser APIs for better scheduling when available
1020
+ if (typeof scheduler !== 'undefined' && scheduler.postTask) {
1021
+ await scheduler.postTask(() => {
1022
+ if (shouldUpdateProgress) {
1023
+ progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`);
1024
+ lastProgressTime = now;
1025
+ }
1026
+ }, { priority: 'user-blocking' });
1027
+ } else if (typeof requestIdleCallback !== 'undefined') {
1028
+ await new Promise(resolve => {
1029
+ requestIdleCallback(() => {
1030
+ if (shouldUpdateProgress) {
1031
+ progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`);
1032
+ lastProgressTime = now;
1033
+ }
1034
+ resolve();
1035
+ }, { timeout: 1 });
1036
+ });
1037
+ } else {
1038
+ // Fallback to setTimeout
1039
+ await new Promise(resolve => setTimeout(resolve, 0));
1040
+ if (shouldUpdateProgress) {
1041
+ progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`);
1042
+ lastProgressTime = now;
1043
+ }
1044
+ }
679
1045
  }
680
1046
  }
681
1047
  }
1048
+
682
1049
  if (progressCallback) progressCallback(matchupIndex, matchupIndex, 'Complete');
683
1050
  return { win, tie, lose };
684
1051
  }
685
1052
 
1053
+ /**
1054
+ * Compares ranges with canonical key caching optimization.
1055
+ * Supports Web Workers for parallelization when enabled.
1056
+ * @private
1057
+ */
1058
+ async _compareRangeOptimized(setup, progressCallback = null, progressInterval = 100, useWorkers = true) {
1059
+ // Pre-calculate board suit counts once (constant for all matchups)
1060
+ const boardSuitCounts = setup.boardCards.length > 0
1061
+ ? this._getBoardSuitCounts(setup.boardCards)
1062
+ : null;
1063
+
1064
+ // Pre-build lookup map: canonical key pair -> valid matchup
1065
+ const canonicalToMatchup = new Map();
1066
+ for (const matchup of setup.validMatchups) {
1067
+ const mHKey = this._getCanonicalKey(
1068
+ matchup.heroHand,
1069
+ setup.boardCards,
1070
+ setup.numberOfBoardCards,
1071
+ boardSuitCounts
1072
+ );
1073
+ const mVKey = this._getCanonicalKey(
1074
+ matchup.villainHand,
1075
+ setup.boardCards,
1076
+ setup.numberOfBoardCards,
1077
+ boardSuitCounts
1078
+ );
1079
+ const key = `${mHKey}:${mVKey}`;
1080
+ if (!canonicalToMatchup.has(key)) {
1081
+ canonicalToMatchup.set(key, matchup);
1082
+ }
1083
+ }
1084
+
1085
+ // Calculate accurate total matchups (excluding invalid pairs with validCount=0)
1086
+ let totalMatchups = 0;
1087
+ for (const [hKey] of setup.hCanon.canonicalMap) {
1088
+ for (const [vKey] of setup.vCanon.canonicalMap) {
1089
+ const key = `${hKey}:${vKey}`;
1090
+ if ((setup.validCounts.get(key) || 0) > 0) {
1091
+ totalMatchups++;
1092
+ }
1093
+ }
1094
+ }
1095
+
1096
+ // Collect all matchups to evaluate
1097
+ const matchupsToEvaluate = [];
1098
+ for (const [hKey] of setup.hCanon.canonicalMap) {
1099
+ for (const [vKey] of setup.vCanon.canonicalMap) {
1100
+ const key = `${hKey}:${vKey}`;
1101
+ const validCount = setup.validCounts.get(key) || 0;
1102
+
1103
+ if (validCount === 0) continue;
1104
+
1105
+ const validPair = canonicalToMatchup.get(key);
1106
+ if (!validPair) {
1107
+ console.error(`[DIAG] ERROR: No valid pair found for ${hKey}:${vKey} despite validCount=${validCount}`);
1108
+ continue;
1109
+ }
1110
+
1111
+ matchupsToEvaluate.push({
1112
+ key,
1113
+ validCount,
1114
+ heroKey: hKey,
1115
+ heroHand: validPair.heroHand,
1116
+ heroMask: validPair.heroMask,
1117
+ villainKey: vKey,
1118
+ villainHand: validPair.villainHand,
1119
+ villainMask: validPair.villainMask
1120
+ });
1121
+ }
1122
+ }
1123
+
1124
+ // Check if workers should be used
1125
+ const shouldUseWorkers = useWorkers &&
1126
+ typeof Worker !== 'undefined' &&
1127
+ matchupsToEvaluate.length >= 4;
1128
+
1129
+ if (!shouldUseWorkers) {
1130
+ // Fallback to sequential execution
1131
+ return await this._compareRangeOptimizedSequential(setup, progressCallback, progressInterval);
1132
+ }
1133
+
1134
+ // Use Web Workers for parallelization
1135
+ const numWorkers = Math.min(navigator.hardwareConcurrency || 4, matchupsToEvaluate.length);
1136
+ const batchSize = Math.ceil(matchupsToEvaluate.length / numWorkers);
1137
+ const batches = [];
1138
+ for (let i = 0; i < matchupsToEvaluate.length; i += batchSize) {
1139
+ batches.push(matchupsToEvaluate.slice(i, i + batchSize));
1140
+ }
1141
+
1142
+ // Serialize setup for workers
1143
+ const serializedSetup = this._serializeSetup(setup);
1144
+
1145
+ // Create workers and distribute work
1146
+ const workers = [];
1147
+ const promises = [];
1148
+ let completedMatchups = 0;
1149
+
1150
+ // Progress tracking
1151
+ let lastProgressTime = 0;
1152
+ const progressUpdateInterval = 100;
1153
+ const progressIntervalId = progressCallback ? setInterval(() => {
1154
+ const now = Date.now();
1155
+ if (now - lastProgressTime >= progressUpdateInterval) {
1156
+ progressCallback(completedMatchups, totalMatchups, `Evaluating with ${numWorkers} workers: ${completedMatchups}/${totalMatchups}`);
1157
+ lastProgressTime = now;
1158
+ }
1159
+ }, progressUpdateInterval) : null;
1160
+
1161
+ for (let i = 0; i < batches.length; i++) {
1162
+ const worker = new Worker('bitval-worker.js');
1163
+ workers.push(worker);
1164
+
1165
+ const promise = new Promise((resolve, reject) => {
1166
+ worker.onmessage = (e) => {
1167
+ if (e.data.success) {
1168
+ completedMatchups += e.data.results.length;
1169
+ resolve(e.data.results);
1170
+ } else {
1171
+ reject(new Error(e.data.error || 'Worker error'));
1172
+ }
1173
+ worker.terminate();
1174
+ };
1175
+ worker.onerror = (error) => {
1176
+ reject(error);
1177
+ worker.terminate();
1178
+ };
1179
+
1180
+ // Serialize matchups for this batch
1181
+ const serializedBatch = batches[i].map(m => this._serializeMatchup(m));
1182
+
1183
+ worker.postMessage({
1184
+ matchups: serializedBatch,
1185
+ setupData: serializedSetup,
1186
+ workerId: i
1187
+ });
1188
+ });
1189
+
1190
+ promises.push(promise);
1191
+ }
1192
+
1193
+ // Wait for all workers and aggregate results
1194
+ let win = 0, tie = 0, lose = 0;
1195
+ try {
1196
+ const allResults = await Promise.all(promises);
1197
+
1198
+ for (const batchResults of allResults) {
1199
+ for (const result of batchResults) {
1200
+ win += result.matchupWin * result.validCount;
1201
+ tie += result.matchupTie * result.validCount;
1202
+ lose += result.matchupLose * result.validCount;
1203
+ }
1204
+ }
1205
+ } finally {
1206
+ // Clean up progress interval
1207
+ if (progressIntervalId) {
1208
+ clearInterval(progressIntervalId);
1209
+ }
1210
+
1211
+ // Ensure all workers are terminated
1212
+ workers.forEach(worker => {
1213
+ try {
1214
+ worker.terminate();
1215
+ } catch (e) {
1216
+ // Ignore termination errors
1217
+ }
1218
+ });
1219
+
1220
+ // Final progress update
1221
+ if (progressCallback) {
1222
+ progressCallback(totalMatchups, totalMatchups, 'Complete');
1223
+ }
1224
+ }
1225
+
1226
+ return { win, tie, lose };
1227
+ }
1228
+
1229
+ /**
1230
+ * Gets cached evaluation or evaluates and caches the result.
1231
+ * @private
1232
+ */
1233
+ _getCachedEvaluation(canonicalKey, originalHand, completeBoard, evalCache) {
1234
+ const cacheKey = canonicalKey + completeBoard.toString();
1235
+ if (evalCache.has(cacheKey)) return evalCache.get(cacheKey);
1236
+ const handMask = this.getBitMasked(this._handStringToCards(originalHand));
1237
+ const evaluation = this.evaluate(handMask | completeBoard);
1238
+ evalCache.set(cacheKey, evaluation);
1239
+ return evaluation;
1240
+ }
1241
+
1242
+ /**
1243
+ * Compares two hand evaluations to determine winner.
1244
+ * @private
1245
+ */
1246
+ _compareEvaluations(heroEval, heroKicker, villainEval, villainKicker) {
1247
+ if (heroEval > villainEval || (heroEval === villainEval && heroKicker > villainKicker)) return 1;
1248
+ if (villainEval > heroEval || (villainEval === heroEval && villainKicker > heroKicker)) return -1;
1249
+ return 0;
1250
+ }
1251
+
1252
+ /**
1253
+ * Gets canonical key for a hand based on board context (for caching optimization).
1254
+ * @private
1255
+ */
1256
+ _getCanonicalKey(hand, boardCards = [], numberOfBoardCards = 5, boardSuitCounts = null) {
1257
+ const r1 = hand[0], r2 = hand[2], s1 = hand[1], s2 = hand[3];
1258
+ const rankOrder = 'AKQJT98765432';
1259
+ const r1Idx = rankOrder.indexOf(r1), r2Idx = rankOrder.indexOf(r2);
1260
+ const high = r1Idx < r2Idx ? r1 : r2, low = r1Idx < r2Idx ? r2 : r1;
1261
+ const highSuit = r1Idx < r2Idx ? s1 : s2, lowSuit = r1Idx < r2Idx ? s2 : s1;
1262
+ if (boardCards.length === 0) {
1263
+ if (r1 === r2) return high + low;
1264
+ return high + low + (s1 === s2 ? 's' : 'o');
1265
+ }
1266
+ if (!boardSuitCounts) boardSuitCounts = this._getBoardSuitCounts(boardCards);
1267
+ const cardsToCome = this._getCardsToCome(boardCards, numberOfBoardCards);
1268
+ if (r1 === r2) {
1269
+ const s1Pot = this._suitHasFlushPotential(s1, boardSuitCounts, cardsToCome);
1270
+ const s2Pot = this._suitHasFlushPotential(s2, boardSuitCounts, cardsToCome);
1271
+ if (s1Pot) return high + low + s1;
1272
+ if (s2Pot) return high + low + s2;
1273
+ return high + low;
1274
+ }
1275
+
1276
+ // Postflop non-pairs: use 'x' notation to remove ambiguity
1277
+ const isSuited = s1 === s2;
1278
+ const highPot = this._suitHasFlushPotential(highSuit, boardSuitCounts, cardsToCome);
1279
+ const lowPot = this._suitHasFlushPotential(lowSuit, boardSuitCounts, cardsToCome);
1280
+
1281
+ if (isSuited) {
1282
+ // Both cards same suit
1283
+ if (highPot) {
1284
+ return high + highSuit + low + lowSuit; // e.g., "AsKs" (both spades)
1285
+ } else {
1286
+ return high + low + 's'; // e.g., "AKs" (suited, no flush potential)
1287
+ }
1288
+ } else {
1289
+ // Offsuit hands
1290
+ if (highPot && !lowPot) {
1291
+ // Only high has flush potential
1292
+ return high + highSuit + low + 'x'; // e.g., "AsKx" (A is spade, K is not)
1293
+ } else if (!highPot && lowPot) {
1294
+ // Only low has flush potential
1295
+ return high + 'x' + low + lowSuit; // e.g., "AxKs" (K is spade, A is not)
1296
+ } else if (highPot && lowPot) {
1297
+ // Both have flush potential (different suits)
1298
+ return high + highSuit + low + lowSuit; // e.g., "AsKh" (both suits specified, no x)
1299
+ } else {
1300
+ // Neither has flush potential
1301
+ return high + low + 'o'; // e.g., "AKo"
1302
+ }
1303
+ }
1304
+ }
1305
+
1306
+ /**
1307
+ * Flattens hands to canonical keys for caching optimization.
1308
+ * @private
1309
+ */
1310
+ _flattenHandsToCanonical(hands, boardCards = [], numberOfBoardCards = 5) {
1311
+ const canonicalMap = new Map();
1312
+ const canonicalToHand = new Map();
1313
+ const boardSuitCounts = boardCards.length > 0 ? this._getBoardSuitCounts(boardCards) : null;
1314
+ for (const hand of hands) {
1315
+ const key = this._getCanonicalKey(hand, boardCards, numberOfBoardCards, boardSuitCounts);
1316
+ canonicalMap.set(key, (canonicalMap.get(key) || 0) + 1);
1317
+ if (!canonicalToHand.has(key)) {
1318
+ canonicalToHand.set(key, hand);
1319
+ }
1320
+ }
1321
+ return { canonicalMap, canonicalToHand };
1322
+ }
1323
+
1324
+ /**
1325
+ * Gets board suit counts for flush potential calculation.
1326
+ * @private
1327
+ */
1328
+ _getBoardSuitCounts(boardCards) {
1329
+ const suitCounts = new Map();
1330
+ for (const card of boardCards) {
1331
+ const suit = card[1];
1332
+ suitCounts.set(suit, (suitCounts.get(suit) || 0) + 1);
1333
+ }
1334
+ return suitCounts;
1335
+ }
1336
+
1337
+ /**
1338
+ * Gets number of cards to come.
1339
+ * @private
1340
+ */
1341
+ _getCardsToCome(boardCards, numberOfBoardCards) {
1342
+ return numberOfBoardCards - boardCards.length;
1343
+ }
1344
+
1345
+ /**
1346
+ * Checks if a suit has flush potential given board and cards to come.
1347
+ * @private
1348
+ */
1349
+ _suitHasFlushPotential(suit, boardSuitCounts, cardsToCome) {
1350
+ return (boardSuitCounts.get(suit) || 0) + cardsToCome >= 4;
1351
+ }
1352
+
1353
+ // ============================================
1354
+ // PRIVATE - Utility Helpers
1355
+ // ============================================
1356
+
1357
+ /**
1358
+ * Converts a hand string to an array of card strings.
1359
+ * @private
1360
+ */
686
1361
  _handStringToCards(hand) {
687
1362
  return [hand.substring(0, 2), hand.substring(2, 4)];
688
1363
  }
689
1364
 
1365
+ /**
1366
+ * Gets available card masks by lookup, excluding dead cards.
1367
+ * @private
1368
+ */
1369
+ _getAvailableCardMasksByLookUp(deadCards) {
1370
+ let masks = [];
1371
+ let deck = ~deadCards;
1372
+
1373
+ for (const card in this.CARD_MASKS) {
1374
+ const cardMask = this.CARD_MASKS[card];
1375
+ if ((cardMask & deck) === cardMask) {
1376
+ masks.push(cardMask);
1377
+ }
1378
+ }
1379
+
1380
+ return masks;
1381
+ }
1382
+
1383
+ /**
1384
+ * Gets combinations of available card masks.
1385
+ * @private
1386
+ */
1387
+ _getCombinations(availableMasks, k) {
1388
+ if (k === 1) return availableMasks;
1389
+ if (k === 2) {
1390
+ let combos = [];
1391
+ for (let i = 0; i < availableMasks.length; i++) {
1392
+ for (let j = i + 1; j < availableMasks.length; j++) {
1393
+ combos.push(availableMasks[i] | availableMasks[j]);
1394
+ }
1395
+ }
1396
+ return combos;
1397
+ }
1398
+ return [];
1399
+ }
1400
+
1401
+ /**
1402
+ * Simulates a single matchup (legacy method, uses simulate internally).
1403
+ * @private
1404
+ */
690
1405
  _simulateMatchup(heroHand, villainHand, board, deadCards, numberOfBoardCards, iterations) {
691
1406
  const heroCards = this._handStringToCards(heroHand);
692
1407
  const villainCards = this._handStringToCards(villainHand);
693
1408
  return this.simulate(iterations, numberOfBoardCards, heroCards, villainCards, board, deadCards);
694
1409
  }
695
-
696
- async compareRange(heroHands, villainHands, boardCards = [], deadCards = [], numberOfBoardCards = 5, iterations = 10000, optimize = true, progressCallback = null) {
697
- const setup = this._compareRangeSetup(heroHands, villainHands, boardCards, deadCards, numberOfBoardCards, iterations, optimize);
698
- return optimize ? await this._compareRangeOptimized(setup, progressCallback) : await this._compareRangeUnoptimized(heroHands, villainHands, setup, progressCallback);
699
- }
700
1410
  }
701
1411
 
702
1412
  if (typeof module !== 'undefined' && module.exports) {