poker-bitvaljs 1.5.2 → 1.7.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.
package/bitval.js CHANGED
@@ -1,3 +1,16 @@
1
+ // Where bitval-worker.js lives. Resolved against this script's own URL when it
2
+ // is loaded with a <script> tag (so it works from node_modules / a CDN path,
3
+ // not just next to the page); otherwise relative to the page. Override per
4
+ // instance with `bitval.workerUrl = '...'` (e.g. when bundling).
5
+ const _BITVAL_WORKER_URL = (function () {
6
+ try {
7
+ if (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) {
8
+ return new URL('bitval-worker.js', document.currentScript.src).href;
9
+ }
10
+ } catch (e) { /* fall through */ }
11
+ return './bitval-worker.js';
12
+ })();
13
+
1
14
  class XorShift32 {
2
15
  constructor(seed = null) {
3
16
  this.state = seed !== null ? seed : Math.floor(Math.random() * 2147483647) || 1;
@@ -15,6 +28,14 @@ class XorShift32 {
15
28
 
16
29
  class BitVal {
17
30
  constructor() {
31
+ this.workerUrl = _BITVAL_WORKER_URL;
32
+ // Opt-in Monte Carlo mode: sample boards once and evaluate every hand on
33
+ // each board (see _compareRangeSharedBoards). Off = per-matchup sampling.
34
+ this.mcSharedBoards = false;
35
+ // Exact flop/turn jobs at least this big (unique hands x runouts) are split
36
+ // across workers when workers are enabled.
37
+ this._EXACT_WORKER_THRESHOLD = 300000;
38
+
18
39
  // Suit constants
19
40
  this._DIAMOND = 3n;
20
41
  this._HEART = 2n;
@@ -175,10 +196,33 @@ class BitVal {
175
196
  if (top < 0 && (m & (1 << 12)) && (m & 0b1111) === 0b1111) top = 3; // wheel
176
197
  this._STRAIGHT13[m] = top;
177
198
  }
199
+ // Kicker tables (replace the per-eval "peel the highest bit" loops):
200
+ // _TOP5V13[m] = top five ranks of m packed as r1*65536+r2*4096+r3*256+r4*16+r5
201
+ // (flush / high card value; fewer ranks just sum fewer terms).
202
+ // _KEEP2_13[m] / _KEEP3_13[m] = m with only its top 2 / top 3 bits kept, so
203
+ // _TOP5V13[_KEEP3_13[m]] >> 4 = k1*4096+k2*256+k3*16 (pair kickers).
204
+ this._TOP5V13 = new Int32Array(8192);
205
+ this._KEEP2_13 = new Uint16Array(8192);
206
+ this._KEEP3_13 = new Uint16Array(8192);
207
+ for (let m = 0; m < 8192; m++) {
208
+ let v = 0, shift = 65536, n = 0, keep = 0;
209
+ for (let r = 12; r >= 0 && n < 5; r--) {
210
+ if (m & (1 << r)) {
211
+ v += r * shift; shift >>= 4; n++; keep |= 1 << r;
212
+ if (n === 2) this._KEEP2_13[m] = keep;
213
+ if (n === 3) this._KEEP3_13[m] = keep;
214
+ }
215
+ }
216
+ if (n < 2) this._KEEP2_13[m] = keep;
217
+ if (n < 3) this._KEEP3_13[m] = keep;
218
+ this._TOP5V13[m] = v;
219
+ }
178
220
 
179
221
  // Preflop exact-equity table (loaded on demand via loadPreflopTable). The 24
180
222
  // suit permutations canonicalize a matchup for lookup (card index = rank*4+suit).
181
- this._pfMap = null; this._pfWin = null; this._pfTie = null; this._pfTotal = 0;
223
+ // Lookup is an open-addressing hash in typed arrays (_pfKeys/_pfSlot).
224
+ this._pfSlot = null; this._pfKeys = null; this._pfMask = 0; this._pfShift = 0;
225
+ this._pfWin = null; this._pfTie = null; this._pfTotal = 0;
182
226
  this._S4 = (function () { const p = a => a.length <= 1 ? [a] : a.flatMap((x, i) => p(a.slice(0, i).concat(a.slice(i + 1))).map(r => [x, ...r])); return p([0, 1, 2, 3]); })();
183
227
  }
184
228
 
@@ -301,9 +345,37 @@ class BitVal {
301
345
  }
302
346
 
303
347
  /**
304
- * Flattens a BigInt combo array into a single Int32Array of suit rank masks
305
- * (4 entries per combo: s, h, d, c). One contiguous buffer instead of N small
306
- * arrays — better cache locality, no per-combo GC. Cached on `setup`.
348
+ * All k-card runouts (k = 0..2) from the cards NOT in `exclude` (a {lo, hi}
349
+ * card bitset), as one flat Int32Array with 4 suit rank masks per runout
350
+ * (s, h, d, c) — the layout the exact kernel reads. Built from card indices
351
+ * directly (no BigInt).
352
+ * @private
353
+ */
354
+ _runoutSuits(exclude, k) {
355
+ const cards = [];
356
+ for (let c = 0; c < 52; c++) if (!(c < 32 ? exclude.lo & (1 << c) : exclude.hi & (1 << (c - 32)))) cards.push(c);
357
+ const n = cards.length;
358
+ if (k === 0) return new Int32Array(4);
359
+ if (k === 1) {
360
+ const out = new Int32Array(n * 4);
361
+ for (let i = 0; i < n; i++) out[i * 4 + (cards[i] & 3)] = 1 << (cards[i] >> 2);
362
+ return out;
363
+ }
364
+ const out = new Int32Array((n * (n - 1) / 2) * 4);
365
+ let o = 0;
366
+ for (let i = 0; i < n; i++) {
367
+ const ci = cards[i];
368
+ for (let j = i + 1; j < n; j++, o += 4) {
369
+ const cj = cards[j];
370
+ out[o + (ci & 3)] |= 1 << (ci >> 2);
371
+ out[o + (cj & 3)] |= 1 << (cj >> 2);
372
+ }
373
+ }
374
+ return out;
375
+ }
376
+
377
+ /**
378
+ * Converts BigInt runout masks to the 4-suit-masks-per-runout layout.
307
379
  * @private
308
380
  */
309
381
  _buildComboSuits(comboArray) {
@@ -362,11 +434,7 @@ class BitVal {
362
434
  }
363
435
 
364
436
  // Flush
365
- if (fm) {
366
- let m = fm, v = 5 * 1048576, shift = 65536;
367
- for (let n = 0; n < 5; n++) { const r = this._high(m); v += r * shift; m &= ~(1 << r); shift >>= 4; }
368
- return v;
369
- }
437
+ if (fm) return 5 * 1048576 + this._TOP5V13[fm];
370
438
 
371
439
  // Straight
372
440
  {
@@ -377,10 +445,7 @@ class BitVal {
377
445
  // Trips
378
446
  if (trips) {
379
447
  const t = this._high(trips);
380
- let rest = all & ~(1 << t);
381
- const k1 = this._high(rest); rest &= ~(1 << k1);
382
- const k2 = this._high(rest);
383
- return 3 * 1048576 + t * 65536 + k1 * 4096 + k2 * 256;
448
+ return 3 * 1048576 + t * 65536 + (this._TOP5V13[this._KEEP2_13[all & ~(1 << t)]] >> 4);
384
449
  }
385
450
 
386
451
  // Two pair
@@ -394,19 +459,11 @@ class BitVal {
394
459
  // One pair
395
460
  if (pairs) {
396
461
  const p = this._high(pairs);
397
- let rest = all & ~(1 << p);
398
- const k1 = this._high(rest); rest &= ~(1 << k1);
399
- const k2 = this._high(rest); rest &= ~(1 << k2);
400
- const k3 = this._high(rest);
401
- return 1 * 1048576 + p * 65536 + k1 * 4096 + k2 * 256 + k3 * 16;
462
+ return 1 * 1048576 + p * 65536 + (this._TOP5V13[this._KEEP3_13[all & ~(1 << p)]] >> 4);
402
463
  }
403
464
 
404
465
  // High card
405
- {
406
- let m = all, v = 0, shift = 65536;
407
- for (let n = 0; n < 5; n++) { const r = this._high(m); v += r * shift; m &= ~(1 << r); shift >>= 4; }
408
- return v;
409
- }
466
+ return this._TOP5V13[all];
410
467
  }
411
468
 
412
469
  /**
@@ -453,15 +510,11 @@ class BitVal {
453
510
  * @returns {Object} { win, tie, lose, ...handStrengths } - Results object
454
511
  */
455
512
  simulate(iterations, numberOfBoardCards = 5, hero = [], villain = [], board = [], deadCards = []) {
456
- let numberOfCardsToDeal = (numberOfBoardCards + 2) - (hero.length + board.length);
457
- this.xorShift = new XorShift32();
513
+ const numberOfCardsToDeal = numberOfBoardCards - board.length;
514
+ const xorShift = this.xorShift = new XorShift32();
458
515
 
459
516
  const allDeadCards = [...hero, ...villain, ...board, ...deadCards];
460
- deadCards = this.getBitMasked(allDeadCards);
461
- numberOfCardsToDeal = numberOfBoardCards - board.length;
462
- hero = this.getBitMasked(hero);
463
- villain = this.getBitMasked(villain);
464
- board = this.getBitMasked(board);
517
+ const deadMask = this.getBitMasked(allDeadCards);
465
518
 
466
519
  const availableCards = 52 - new Set(allDeadCards).size;
467
520
  const exhaustiveCombinations = this.combinations(availableCards, numberOfCardsToDeal);
@@ -473,13 +526,8 @@ class BitVal {
473
526
  // for larger (>=3 cards to come) scenarios.
474
527
  const isExhaustive = numberOfCardsToDeal >= 0 && numberOfCardsToDeal <= 2;
475
528
  iterations = isExhaustive ? exhaustiveCombinations : Math.min(iterations, exhaustiveCombinations);
476
- let comboArray = null;
477
- if (isExhaustive) {
478
- const availableMasks = this._getAvailableCardMasksByLookUp(deadCards);
479
- comboArray = this._getCombinations(availableMasks, numberOfCardsToDeal);
480
- }
481
529
 
482
- let result = {
530
+ let result = {
483
531
  "win": 0,
484
532
  "tie": 0,
485
533
  "lose": 0,
@@ -494,34 +542,44 @@ class BitVal {
494
542
  "Straight Flush": 0
495
543
  };
496
544
 
497
- for (let i = 0; i < iterations; i++) {
498
- let _board = isExhaustive && comboArray
499
- ? board | comboArray[i]
500
- : this.deal(board, deadCards, numberOfCardsToDeal) | board;
501
-
502
- let [hero_eval, hero_tiebreaker] = this.evaluate(hero | _board);
503
- let [villain_eval, villain_tiebreaker] = this.evaluate(villain | _board);
545
+ // BigInt-free hot loop (same engine as compareRange): cards are four 13-bit
546
+ // suit rank masks and _eval7 orders hands identically to evaluate().
547
+ const hs = this._suitsOfCards(hero), vs = this._suitsOfCards(villain), bs = this._suitsOfCards(board);
548
+ const hs0 = hs[0] | bs[0], hs1 = hs[1] | bs[1], hs2 = hs[2] | bs[2], hs3 = hs[3] | bs[3];
549
+ const vs0 = vs[0] | bs[0], vs1 = vs[1] | bs[1], vs2 = vs[2] | bs[2], vs3 = vs[3] | bs[3];
550
+ let win = 0, tie = 0, lose = 0;
504
551
 
505
- if (hero_eval > villain_eval) {
506
- result["win"]++;
507
- continue;
508
- }
509
- if (hero_eval < villain_eval) {
510
- result["lose"]++;
511
- continue;
512
- }
513
- if (hero_tiebreaker > villain_tiebreaker) {
514
- result["win"]++;
515
- continue;
552
+ if (isExhaustive) {
553
+ const combos = this._runoutSuits(this._cardsBitset(allDeadCards), numberOfCardsToDeal);
554
+ const n = Math.min(iterations, combos.length >> 2);
555
+ for (let i = 0; i < n; i++) {
556
+ const b = i << 2, d0 = combos[b], d1 = combos[b + 1], d2 = combos[b + 2], d3 = combos[b + 3];
557
+ const hE = this._eval7(hs0 | d0, hs1 | d1, hs2 | d2, hs3 | d3);
558
+ const vE = this._eval7(vs0 | d0, vs1 | d1, vs2 | d2, vs3 | d3);
559
+ if (hE > vE) win++; else if (vE > hE) lose++; else tie++;
516
560
  }
517
- if (hero_tiebreaker < villain_tiebreaker) {
518
- result["lose"]++;
519
- continue;
561
+ } else {
562
+ // Partial Fisher-Yates draw from the packed pool of live cards.
563
+ const pool = this._availableSuitCards(deadMask), poolLen = pool.length;
564
+ const nDeal = poolLen < numberOfCardsToDeal ? 0 : numberOfCardsToDeal;
565
+ let st = xorShift.state;
566
+ for (let i = 0; i < iterations; i++) {
567
+ let d0 = 0, d1 = 0, d2 = 0, d3 = 0;
568
+ for (let k = 0; k < nDeal; k++) {
569
+ st ^= st << 13; st ^= st >> 17; st ^= st << 5;
570
+ const j = k + Math.abs(st) % (poolLen - k);
571
+ const card = pool[j]; pool[j] = pool[k]; pool[k] = card;
572
+ const bit = card & 0xFFFF, suit = card >> 16;
573
+ if (suit === 0) d0 |= bit; else if (suit === 1) d1 |= bit; else if (suit === 2) d2 |= bit; else d3 |= bit;
574
+ }
575
+ const hE = this._eval7(hs0 | d0, hs1 | d1, hs2 | d2, hs3 | d3);
576
+ const vE = this._eval7(vs0 | d0, vs1 | d1, vs2 | d2, vs3 | d3);
577
+ if (hE > vE) win++; else if (vE > hE) lose++; else tie++;
520
578
  }
521
-
522
- result["tie"]++
579
+ xorShift.state = st;
523
580
  }
524
-
581
+
582
+ result.win = win; result.tie = tie; result.lose = lose;
525
583
  return result;
526
584
  }
527
585
 
@@ -551,7 +609,7 @@ class BitVal {
551
609
  // Preflop exact fast path: if the preflop equity table is loaded and this is
552
610
  // a full-board preflop query with no dead cards, look up every matchup's
553
611
  // exact equity instead of Monte Carlo. Instant and exact.
554
- if (this._pfMap && boardCards.length === 0 && deadCards.length === 0 && numberOfBoardCards === 5) {
612
+ if (this._pfSlot && boardCards.length === 0 && deadCards.length === 0 && numberOfBoardCards === 5) {
555
613
  const r = this._compareRangePreflopExact(heroHands, villainHands);
556
614
  if (progressCallback) progressCallback(1, 1, 'Complete');
557
615
  return r;
@@ -579,18 +637,34 @@ class BitVal {
579
637
  if (view.getUint32(0, true) !== 0x50464551) throw new Error('invalid preflop table (bad magic)');
580
638
  const count = view.getUint32(4, true);
581
639
  this._pfTotal = view.getUint32(8, true);
582
- this._pfWin = new Uint32Array(count);
583
- this._pfTie = new Uint32Array(count);
584
- this._pfMap = new Map();
640
+ const win = new Uint32Array(count), tie = new Uint32Array(count);
641
+ // Open-addressing hash (linear probing, load factor <= 0.5): key -> row.
642
+ let bits = 1; while ((1 << bits) < count * 2) bits++;
643
+ const size = 1 << bits, mask = size - 1, shift = 32 - bits;
644
+ const keys = new Uint32Array(size), slot = new Int32Array(size).fill(-1);
585
645
  for (let i = 0; i < count; i++) {
586
- const o = 12 + i * 12;
587
- this._pfMap.set(view.getUint32(o, true), i);
588
- this._pfWin[i] = view.getUint32(o + 4, true);
589
- this._pfTie[i] = view.getUint32(o + 8, true);
590
- }
646
+ const o = 12 + i * 12, key = view.getUint32(o, true);
647
+ win[i] = view.getUint32(o + 4, true);
648
+ tie[i] = view.getUint32(o + 8, true);
649
+ let h = Math.imul(key, 0x9E3779B1) >>> shift;
650
+ while (slot[h] !== -1) h = (h + 1) & mask;
651
+ keys[h] = key; slot[h] = i;
652
+ }
653
+ this._pfWin = win; this._pfTie = tie;
654
+ this._pfKeys = keys; this._pfSlot = slot; this._pfMask = mask; this._pfShift = shift;
591
655
  return count;
592
656
  }
593
657
 
658
+ /** Row index of a canonical preflop key in the loaded table, or -1. @private */
659
+ _pfLookup(key) {
660
+ const keys = this._pfKeys, slot = this._pfSlot, mask = this._pfMask;
661
+ let h = Math.imul(key, 0x9E3779B1) >>> this._pfShift;
662
+ for (let i = slot[h]; i !== -1; h = (h + 1) & mask, i = slot[h]) {
663
+ if (keys[h] === key) return i;
664
+ }
665
+ return -1;
666
+ }
667
+
594
668
  /** Card index (0..51) = rankIndex*4 + suitIndex, for a 2-char card string. @private */
595
669
  _cardIndex(card) { return this._N_RANK[card[0]] * 4 + this._N_SUIT[card[1]]; }
596
670
 
@@ -620,14 +694,39 @@ class BitVal {
620
694
  const heroC = [], villC = [];
621
695
  for (const h of heroHands) heroC.push(this._cardIndex(h.slice(0, 2)), this._cardIndex(h.slice(2, 4)));
622
696
  for (const v of villainHands) villC.push(this._cardIndex(v.slice(0, 2)), this._cardIndex(v.slice(2, 4)));
697
+ // The canonical key ((a*52+b)*52+c)*52+d is minimized over the 24 suit
698
+ // permutations; it's lexicographic in (hero part, villain part), so only
699
+ // the permutations that minimize the hero part can win (2 for offsuit or
700
+ // pairs, 6 for suited). Find those once per hero, then minimize the villain
701
+ // part over just them — same key as _preflopCanonKey, ~4-12x less work.
702
+ const S4 = this._S4, pWin = this._pfWin, pTie = this._pfTie;
703
+ const perms = new Int32Array(24 * 4); let np = 0;
623
704
  let win = 0, tie = 0, lose = 0;
624
705
  for (let hi = 0; hi < heroC.length; hi += 2) {
625
706
  const h1 = heroC[hi], h2 = heroC[hi + 1];
707
+ let bestH = Infinity; np = 0;
708
+ for (let pi = 0; pi < 24; pi++) {
709
+ const p = S4[pi];
710
+ const m1 = (h1 & ~3) | p[h1 & 3], m2 = (h2 & ~3) | p[h2 & 3];
711
+ const hk = m1 < m2 ? m1 * 52 + m2 : m2 * 52 + m1;
712
+ if (hk > bestH) continue;
713
+ if (hk < bestH) { bestH = hk; np = 0; }
714
+ perms[np * 4] = p[0]; perms[np * 4 + 1] = p[1]; perms[np * 4 + 2] = p[2]; perms[np * 4 + 3] = p[3]; np++;
715
+ }
716
+ const hBase = bestH * 2704;
626
717
  for (let vi = 0; vi < villC.length; vi += 2) {
627
718
  const v1 = villC[vi], v2 = villC[vi + 1];
628
719
  if (h1 === v1 || h1 === v2 || h2 === v1 || h2 === v2) continue; // conflicting hole cards
629
- const idx = this._pfMap.get(this._preflopCanonKey(h1, h2, v1, v2));
630
- const w = this._pfWin[idx], t = this._pfTie[idx];
720
+ let bestV = 2704;
721
+ const b1 = v1 & ~3, s1 = v1 & 3, b2 = v2 & ~3, s2 = v2 & 3;
722
+ for (let k = 0; k < np; k++) {
723
+ const q = k * 4, m1 = b1 | perms[q + s1], m2 = b2 | perms[q + s2];
724
+ const vk = m1 < m2 ? m1 * 52 + m2 : m2 * 52 + m1;
725
+ if (vk < bestV) bestV = vk;
726
+ }
727
+ const idx = this._pfLookup(hBase + bestV);
728
+ if (idx < 0) throw new Error('preflop table is missing a matchup (corrupt or incomplete table)');
729
+ const w = pWin[idx], t = pTie[idx];
631
730
  win += w; tie += t; lose += total - w - t;
632
731
  }
633
732
  }
@@ -888,100 +987,102 @@ class BitVal {
888
987
  // PRIVATE - Range Comparison Helpers
889
988
  // ============================================
890
989
 
990
+ /**
991
+ * Parses a 2-card hand string into card indices (0..51, rank*4+suit) and a
992
+ * split 64-bit card bitset (lo = cards 0..31, hi = cards 32..51) so overlap
993
+ * tests are plain Number ANDs instead of BigInt work.
994
+ * @private
995
+ */
996
+ _parseHand(hand) {
997
+ const a = this._cardIndex(hand), b = this._cardIndex(hand.substring(2, 4));
998
+ const lo = (a < 32 ? 1 << a : 0) | (b < 32 ? 1 << b : 0);
999
+ const hi = (a >= 32 ? 1 << (a - 32) : 0) | (b >= 32 ? 1 << (b - 32) : 0);
1000
+ return { a, b, lo, hi };
1001
+ }
1002
+
1003
+ /**
1004
+ * Card bitset (lo/hi halves) for an array of card strings.
1005
+ * @private
1006
+ */
1007
+ _cardsBitset(cards) {
1008
+ let lo = 0, hi = 0;
1009
+ for (const c of cards) { const i = this._cardIndex(c); if (i < 32) lo |= 1 << i; else hi |= 1 << (i - 32); }
1010
+ return { lo, hi };
1011
+ }
1012
+
891
1013
  /**
892
1014
  * Generates all valid matchups between hero and villain hands, excluding overlaps.
1015
+ * Each hand is parsed once; the O(H*V) overlap test runs on Number bitsets.
893
1016
  * @private
894
1017
  */
895
1018
  _generateValidMatchups(heroHands, villainHands, boardCards = []) {
896
1019
  const valid = [];
897
- const boardMask = boardCards.length > 0 ? this.getBitMasked(boardCards) : 0n;
1020
+ const board = this._cardsBitset(boardCards);
1021
+ const nV = villainHands.length;
1022
+ const vLo = new Int32Array(nV), vHi = new Int32Array(nV), vOk = new Uint8Array(nV), vMask = new Array(nV);
1023
+ for (let j = 0; j < nV; j++) {
1024
+ const p = this._parseHand(villainHands[j]);
1025
+ vLo[j] = p.lo; vHi[j] = p.hi;
1026
+ vOk[j] = ((p.lo & board.lo) | (p.hi & board.hi)) === 0 ? 1 : 0;
1027
+ vMask[j] = vOk[j] ? this.getBitMasked(this._handStringToCards(villainHands[j])) : 0n;
1028
+ }
898
1029
  for (const h of heroHands) {
1030
+ const p = this._parseHand(h);
1031
+ if ((p.lo & board.lo) | (p.hi & board.hi)) continue;
899
1032
  const hMask = this.getBitMasked(this._handStringToCards(h));
900
- if (boardMask && (hMask & boardMask) !== 0n) continue;
901
- for (const v of villainHands) {
902
- const vMask = this.getBitMasked(this._handStringToCards(v));
903
- if ((vMask & boardMask) !== 0n || (hMask & vMask) !== 0n) continue;
904
- valid.push({ heroHand: h, villainHand: v, heroMask: hMask, villainMask: vMask });
1033
+ for (let j = 0; j < nV; j++) {
1034
+ if (!vOk[j] || (p.lo & vLo[j]) | (p.hi & vHi[j])) continue;
1035
+ valid.push({ heroHand: h, villainHand: villainHands[j], heroMask: hMask, villainMask: vMask[j] });
905
1036
  }
906
1037
  }
907
1038
  return valid;
908
1039
  }
909
1040
 
910
1041
  /**
911
- * Calculates valid matchup counts per canonical key pair.
912
- * @private
913
- */
914
- _calculateValidCounts(validMatchups, hCanon, vCanon, boardCards, numberOfBoardCards) {
915
- const counts = new Map();
916
- const boardSuitCounts = boardCards.length > 0 ? this._getBoardSuitCounts(boardCards) : null;
917
- for (const { heroHand, villainHand } of validMatchups) {
918
- const hKey = this._getCanonicalKey(heroHand, boardCards, numberOfBoardCards, boardSuitCounts);
919
- const vKey = this._getCanonicalKey(villainHand, boardCards, numberOfBoardCards, boardSuitCounts);
920
- const key = `${hKey}:${vKey}`;
921
- counts.set(key, (counts.get(key) || 0) + 1);
922
- }
923
- return counts;
924
- }
925
-
926
- /**
927
- * Sets up range comparison by generating valid matchups and canonical mappings.
1042
+ * Sets up range comparison. The per-pair matchup list is only materialized
1043
+ * (lazily, via the `validMatchups` getter) by the paths that need it — the
1044
+ * Monte Carlo and unoptimized paths. The exact exhaustive path works straight
1045
+ * from the hand strings (see _buildExhaustiveWork).
928
1046
  * @private
929
1047
  */
930
1048
  _compareRangeSetup(heroHands, villainHands, boardCards, deadCards, numberOfBoardCards, iterations, optimize) {
931
- // Generate valid matchups (always needed)
932
- const validMatchups = this._generateValidMatchups(heroHands, villainHands, boardCards);
933
-
934
- // Calculate canonical mappings only if optimization is enabled
935
- let hCanon = null;
936
- let vCanon = null;
937
- let validCounts = null;
938
- if (optimize) {
939
- hCanon = this._flattenHandsToCanonical(heroHands, boardCards, numberOfBoardCards);
940
- vCanon = this._flattenHandsToCanonical(villainHands, boardCards, numberOfBoardCards);
941
- validCounts = this._calculateValidCounts(validMatchups, hCanon, vCanon, boardCards, numberOfBoardCards);
942
- }
943
-
944
1049
  // Calculate bitmasks
945
1050
  const boardMask = this.getBitMasked(boardCards);
946
1051
  const deadCardsMask = this.getBitMasked([...boardCards, ...deadCards]);
947
-
1052
+
948
1053
  // Calculate cards to deal and determine if exhaustive enumeration is possible.
949
1054
  // 0 cards to come (a complete board) is exhaustive too: exactly one runout.
950
1055
  const numberOfCardsToDeal = numberOfBoardCards - boardCards.length;
951
- const canBeExhaustive = numberOfCardsToDeal >= 0 && numberOfCardsToDeal <= 2;
952
-
953
- // Calculate available cards using bitmask (more efficient than Set)
954
- const availableCards = 52 - this.countBits(deadCardsMask);
955
-
956
- // Determine if we should use exhaustive enumeration
957
- const exhaustiveCombinations = canBeExhaustive
958
- ? this.combinations(availableCards, numberOfCardsToDeal)
959
- : Infinity;
1056
+ const isExhaustive = numberOfCardsToDeal >= 0 && numberOfCardsToDeal <= 2;
1057
+
960
1058
  // For <=2 cards to come the full runout space is tiny (<=1326), so always
961
1059
  // enumerate it exactly instead of Monte-Carloing a lower requested count.
962
1060
  // Exhaustive is exact and deterministic; the requested iteration count only
963
1061
  // matters for larger (>=3 cards to come, e.g. preflop) scenarios.
964
- const isExhaustive = canBeExhaustive && exhaustiveCombinations < Infinity;
965
- iterations = isExhaustive ? exhaustiveCombinations : Math.min(iterations, exhaustiveCombinations);
966
-
967
- // Generate combo array only if exhaustive
968
- const comboArray = isExhaustive
969
- ? this._getCombinations(this._getAvailableCardMasksByLookUp(deadCardsMask), numberOfCardsToDeal)
1062
+ // Runouts as suit masks, built from card indices (no BigInt).
1063
+ const comboSuits = isExhaustive
1064
+ ? this._runoutSuits(this._cardsBitset([...boardCards, ...deadCards]), numberOfCardsToDeal)
970
1065
  : null;
971
-
972
- return {
973
- hCanon,
974
- vCanon,
975
- boardMask,
976
- deadCardsMask,
977
- iterations,
978
- isExhaustive,
979
- comboArray,
980
- numberOfCardsToDeal,
981
- validMatchups,
982
- validCounts,
983
- boardCards,
984
- numberOfBoardCards
1066
+ if (isExhaustive) iterations = comboSuits.length >> 2;
1067
+
1068
+ const self = this;
1069
+ return {
1070
+ heroHands,
1071
+ villainHands,
1072
+ boardMask,
1073
+ deadCardsMask,
1074
+ iterations,
1075
+ isExhaustive,
1076
+ comboArray: null, // BigInt runouts are no longer built; see _comboSuits
1077
+ _comboSuits: comboSuits,
1078
+ numberOfCardsToDeal,
1079
+ boardCards,
1080
+ deadCards,
1081
+ numberOfBoardCards,
1082
+ _validMatchups: null,
1083
+ get validMatchups() {
1084
+ return this._validMatchups || (this._validMatchups = self._generateValidMatchups(heroHands, villainHands, boardCards));
1085
+ }
985
1086
  };
986
1087
  }
987
1088
 
@@ -1001,8 +1102,8 @@ class BitVal {
1001
1102
  isExhaustive: setup.isExhaustive,
1002
1103
  numberOfCardsToDeal: setup.numberOfCardsToDeal,
1003
1104
  mcTarget: setup.mcTarget
1004
- // Note: hCanon, vCanon, validMatchups, validCounts are not needed in workers
1005
- // as matchups are pre-serialized and sent separately
1105
+ // Note: validMatchups is not needed in workers; matchups are
1106
+ // pre-serialized and sent separately
1006
1107
  };
1007
1108
  }
1008
1109
 
@@ -1048,7 +1149,76 @@ class BitVal {
1048
1149
  * Optimized for browser performance with adaptive yielding and inlined hot loop.
1049
1150
  * @private
1050
1151
  */
1152
+ /**
1153
+ * Synchronous single-matchup evaluation (no progress / yielding). Used by the
1154
+ * sequential path when there's no progress callback and by the Web Worker,
1155
+ * so both run the same tight loop with the RNG state held in a local.
1156
+ * @private
1157
+ */
1158
+ _evaluateMatchupSync(heroMask, villainMask, setup) {
1159
+ let win = 0, tie = 0, lose = 0;
1160
+ const hs = this._suitsFromBigMask(heroMask), vs = this._suitsFromBigMask(villainMask);
1161
+ const bb = setup._boardSuits || (setup._boardSuits = this._suitsFromBigMask(setup.boardMask));
1162
+ const hs0 = hs[0], hs1 = hs[1], hs2 = hs[2], hs3 = hs[3];
1163
+ const vs0 = vs[0], vs1 = vs[1], vs2 = vs[2], vs3 = vs[3];
1164
+ const bb0 = bb[0], bb1 = bb[1], bb2 = bb[2], bb3 = bb[3];
1165
+
1166
+ if (setup.isExhaustive) {
1167
+ const comboSuits = setup._comboSuits || (setup._comboSuits = this._buildComboSuits(setup.comboArray));
1168
+ const n = comboSuits.length >> 2;
1169
+ const m0 = hs0 | vs0, m1 = hs1 | vs1, m2 = hs2 | vs2, m3 = hs3 | vs3;
1170
+ for (let i = 0; i < n; i++) {
1171
+ const b = i << 2;
1172
+ const c0 = comboSuits[b], c1 = comboSuits[b + 1], c2 = comboSuits[b + 2], c3 = comboSuits[b + 3];
1173
+ if ((c0 & m0) | (c1 & m1) | (c2 & m2) | (c3 & m3)) continue; // runout uses a hole card
1174
+ const d0 = bb0 | c0, d1 = bb1 | c1, d2 = bb2 | c2, d3 = bb3 | c3;
1175
+ const hE = this._eval7(hs0 | d0, hs1 | d1, hs2 | d2, hs3 | d3);
1176
+ const vE = this._eval7(vs0 | d0, vs1 | d1, vs2 | d2, vs3 | d3);
1177
+ if (hE > vE) win++; else if (vE > hE) lose++; else tie++;
1178
+ }
1179
+ return { matchupWin: win, matchupTie: tie, matchupLose: lose };
1180
+ }
1181
+
1182
+ const xorShift = this.xorShift = new XorShift32();
1183
+ const pool = this._availableSuitCards(heroMask | villainMask | setup.deadCardsMask), poolLen = pool.length;
1184
+ const nDeal = setup.numberOfCardsToDeal;
1185
+ const iterations = setup.iterations;
1186
+ const mcTarget = setup.mcTarget > 0 ? setup.mcTarget / 100 : 0;
1187
+ let ran = iterations, st = xorShift.state;
1188
+ for (let i = 0; i < iterations; i++) {
1189
+ let d0 = bb0, d1 = bb1, d2 = bb2, d3 = bb3;
1190
+ for (let k = 0; k < nDeal; k++) {
1191
+ st ^= st << 13; st ^= st >> 17; st ^= st << 5;
1192
+ const j = k + Math.abs(st) % (poolLen - k);
1193
+ const card = pool[j]; pool[j] = pool[k]; pool[k] = card;
1194
+ const bit = card & 0xFFFF, suit = card >> 16;
1195
+ if (suit === 0) d0 |= bit; else if (suit === 1) d1 |= bit; else if (suit === 2) d2 |= bit; else d3 |= bit;
1196
+ }
1197
+ const hE = this._eval7(hs0 | d0, hs1 | d1, hs2 | d2, hs3 | d3);
1198
+ const vE = this._eval7(vs0 | d0, vs1 | d1, vs2 | d2, vs3 | d3);
1199
+ if (hE > vE) win++; else if (vE > hE) lose++; else tie++;
1200
+
1201
+ // Monte Carlo early-stop check (every 4096 draws, past a warm-up).
1202
+ if (mcTarget && (i & 4095) === 4095 && i >= 8191) {
1203
+ const n = i + 1, p = (win + tie * 0.5) / n;
1204
+ if (1.96 * Math.sqrt(p * (1 - p) / n) < mcTarget) { ran = n; break; }
1205
+ }
1206
+ }
1207
+ xorShift.state = st;
1208
+
1209
+ // If early-stopped, scale counts back to the requested iteration count so
1210
+ // every matchup is weighted equally in the aggregate.
1211
+ if (ran < iterations && ran > 0) {
1212
+ const s = iterations / ran;
1213
+ win = Math.round(win * s); tie = Math.round(tie * s); lose = Math.round(lose * s);
1214
+ }
1215
+ return { matchupWin: win, matchupTie: tie, matchupLose: lose };
1216
+ }
1217
+
1051
1218
  async _evaluateMatchup(heroMask, villainMask, setup, progressCallback = null, matchupIndex = 0, totalMatchups = 1) {
1219
+ // No progress reporting: run the tight synchronous loop (no per-iteration
1220
+ // yield checks, RNG in a local).
1221
+ if (!progressCallback) return this._evaluateMatchupSync(heroMask, villainMask, setup);
1052
1222
  let win = 0, tie = 0, lose = 0;
1053
1223
  const deadMask = heroMask | villainMask | setup.deadCardsMask;
1054
1224
 
@@ -1097,10 +1267,13 @@ class BitVal {
1097
1267
  for (let i = 0; i < iterations; i++) {
1098
1268
  // Build the runout as suit rank-masks: precomputed combo (exhaustive) or
1099
1269
  // a Fisher-Yates draw from the available pool (Monte Carlo).
1100
- let d0, d1, d2, d3;
1270
+ let d0, d1, d2, d3, live = true;
1101
1271
  if (comboSuits) {
1102
1272
  const b = i << 2;
1103
- d0 = bb0 | comboSuits[b]; d1 = bb1 | comboSuits[b + 1]; d2 = bb2 | comboSuits[b + 2]; d3 = bb3 | comboSuits[b + 3];
1273
+ const c0 = comboSuits[b], c1 = comboSuits[b + 1], c2 = comboSuits[b + 2], c3 = comboSuits[b + 3];
1274
+ // Skip runouts that use a hole card (impossible deals).
1275
+ live = ((c0 & (hs0 | vs0)) | (c1 & (hs1 | vs1)) | (c2 & (hs2 | vs2)) | (c3 & (hs3 | vs3))) === 0;
1276
+ d0 = bb0 | c0; d1 = bb1 | c1; d2 = bb2 | c2; d3 = bb3 | c3;
1104
1277
  } else {
1105
1278
  d0 = bb0; d1 = bb1; d2 = bb2; d3 = bb3;
1106
1279
  for (let k = 0; k < nDeal; k++) {
@@ -1114,11 +1287,13 @@ class BitVal {
1114
1287
  }
1115
1288
  }
1116
1289
 
1117
- const hE = this._eval7(hs0 | d0, hs1 | d1, hs2 | d2, hs3 | d3);
1118
- const vE = this._eval7(vs0 | d0, vs1 | d1, vs2 | d2, vs3 | d3);
1119
- if (hE > vE) win++;
1120
- else if (vE > hE) lose++;
1121
- else tie++;
1290
+ if (live) {
1291
+ const hE = this._eval7(hs0 | d0, hs1 | d1, hs2 | d2, hs3 | d3);
1292
+ const vE = this._eval7(vs0 | d0, vs1 | d1, vs2 | d2, vs3 | d3);
1293
+ if (hE > vE) win++;
1294
+ else if (vE > hE) lose++;
1295
+ else tie++;
1296
+ }
1122
1297
 
1123
1298
  // Monte Carlo early-stop check (every 4096 draws, past a warm-up).
1124
1299
  if (mcTarget && (i & 4095) === 4095 && i >= 8191) {
@@ -1228,6 +1403,62 @@ class BitVal {
1228
1403
  return { win, tie, lose };
1229
1404
  }
1230
1405
 
1406
+ /**
1407
+ * Groups the valid (hero, villain) matchups by canonical key pair for the
1408
+ * Monte Carlo path. Canonical keys depend only on the hand (and board), so
1409
+ * they're computed once per hand string, and pairs are enumerated with Number
1410
+ * card bitsets — no per-pair objects or string keys. Each group keeps the
1411
+ * first matchup (hero-major order) as its representative. Cached on `setup`.
1412
+ * @private
1413
+ */
1414
+ _groupMonteCarloMatchups(setup) {
1415
+ if (setup._mcGroups) return setup._mcGroups;
1416
+ const { heroHands, villainHands, boardCards, numberOfBoardCards } = setup;
1417
+ const board = this._cardsBitset(boardCards);
1418
+ const boardSuitCounts = boardCards.length > 0 ? this._getBoardSuitCounts(boardCards) : null;
1419
+ const keyIds = new Map(), keyNames = [];
1420
+ const keyId = (hand) => {
1421
+ const k = this._getCanonicalKey(hand, boardCards, numberOfBoardCards, boardSuitCounts);
1422
+ let id = keyIds.get(k);
1423
+ if (id === undefined) { id = keyNames.length; keyIds.set(k, id); keyNames.push(k); }
1424
+ return id;
1425
+ };
1426
+ const parseSide = (hands) => {
1427
+ const n = hands.length, lo = new Int32Array(n), hi = new Int32Array(n), id = new Int32Array(n), ok = new Uint8Array(n);
1428
+ for (let i = 0; i < n; i++) {
1429
+ const p = this._parseHand(hands[i]);
1430
+ lo[i] = p.lo; hi[i] = p.hi;
1431
+ ok[i] = ((p.lo & board.lo) | (p.hi & board.hi)) === 0 ? 1 : 0;
1432
+ if (ok[i]) id[i] = keyId(hands[i]);
1433
+ }
1434
+ return { lo, hi, id, ok, n };
1435
+ };
1436
+ const H = parseSide(heroHands), V = parseSide(villainHands);
1437
+ const nK = keyNames.length;
1438
+ const groupOf = new Map(), groups = [];
1439
+ for (let h = 0; h < H.n; h++) {
1440
+ if (!H.ok[h]) continue;
1441
+ const hl = H.lo[h], hh = H.hi[h], hk = H.id[h] * nK;
1442
+ for (let v = 0; v < V.n; v++) {
1443
+ if (!V.ok[v] || (hl & V.lo[v]) | (hh & V.hi[v])) continue;
1444
+ const gk = hk + V.id[v];
1445
+ let g = groupOf.get(gk);
1446
+ if (g === undefined) {
1447
+ const heroHand = heroHands[h], villainHand = villainHands[v];
1448
+ const heroKey = keyNames[H.id[h]], villainKey = keyNames[V.id[v]];
1449
+ g = {
1450
+ key: `${heroKey}:${villainKey}`, validCount: 0, heroKey, villainKey, heroHand, villainHand,
1451
+ heroMask: this.getBitMasked(this._handStringToCards(heroHand)),
1452
+ villainMask: this.getBitMasked(this._handStringToCards(villainHand))
1453
+ };
1454
+ groupOf.set(gk, g); groups.push(g);
1455
+ }
1456
+ g.validCount++;
1457
+ }
1458
+ }
1459
+ return (setup._mcGroups = groups);
1460
+ }
1461
+
1231
1462
  /**
1232
1463
  * Compares ranges with canonical key caching optimization (sequential version).
1233
1464
  * @private
@@ -1237,29 +1468,16 @@ class BitVal {
1237
1468
  let lastProgressTime = 0;
1238
1469
  const progressUpdateInterval = 100; // Update progress at most every 100ms
1239
1470
 
1240
- // Pre-calculate board suit counts once (constant for all matchups)
1241
- const boardSuitCounts = setup.boardCards.length > 0
1242
- ? this._getBoardSuitCounts(setup.boardCards)
1243
- : null;
1244
-
1245
1471
  // Monte Carlo (>=3 cards to come): canonical grouping + independent
1246
1472
  // per-matchup sampling (each matchup deals its own boards, so runouts can't
1247
1473
  // be shared). Grouping only pays off here, where a matchup is expensive.
1248
1474
  if (!setup.isExhaustive) {
1249
- const matchupGroups = new Map();
1250
- for (const matchup of setup.validMatchups) {
1251
- const mHKey = this._getCanonicalKey(matchup.heroHand, setup.boardCards, setup.numberOfBoardCards, boardSuitCounts);
1252
- const mVKey = this._getCanonicalKey(matchup.villainHand, setup.boardCards, setup.numberOfBoardCards, boardSuitCounts);
1253
- const key = `${mHKey}:${mVKey}`;
1254
- let g = matchupGroups.get(key);
1255
- if (!g) { g = { heroKey: mHKey, villainKey: mVKey, representative: matchup, count: 0 }; matchupGroups.set(key, g); }
1256
- g.count++;
1257
- }
1258
- const totalMatchups = matchupGroups.size;
1475
+ const groups = this._groupMonteCarloMatchups(setup);
1476
+ const totalMatchups = groups.length;
1259
1477
  let matchupIndex = 0;
1260
- for (const [key, group] of matchupGroups) {
1261
- const validCount = group.count;
1262
- const validPair = group.representative;
1478
+ for (const group of groups) {
1479
+ const validCount = group.validCount;
1480
+ const validPair = group;
1263
1481
  const { matchupWin, matchupTie, matchupLose } = await this._evaluateMatchup(
1264
1482
  validPair.heroMask, validPair.villainMask, setup, progressCallback, matchupIndex, totalMatchups
1265
1483
  );
@@ -1285,9 +1503,8 @@ class BitVal {
1285
1503
  }
1286
1504
 
1287
1505
  // Exhaustive: EXACT evaluate-once. Build the shared work structure and run
1288
- // the kernel over all runouts. The Web Worker path (_compareRangeOptimized)
1289
- // runs the SAME kernel over runout slices, so workers and sequential agree
1290
- // byte-for-byte.
1506
+ // the kernel over all runouts (on the main thread for both the worker and
1507
+ // non-worker paths, so they agree byte-for-byte).
1291
1508
  const work = this._buildExhaustiveWork(setup);
1292
1509
  const comboSuits = setup._comboSuits || (setup._comboSuits = this._buildComboSuits(setup.comboArray));
1293
1510
  const nRun = comboSuits.length >> 2;
@@ -1297,72 +1514,183 @@ class BitVal {
1297
1514
  }
1298
1515
 
1299
1516
  /**
1300
- * Builds the reusable per-run work for the exact exhaustive kernel: unique
1301
- * concrete hero/villain suit masks, the valid-pair list, and (for large
1302
- * fields) the card-conflict CSR. Everything is transferable typed arrays so
1303
- * it can be serialized to workers unchanged.
1517
+ * Builds the reusable per-run work for the exact exhaustive kernel straight
1518
+ * from the hand strings (no per-pair objects, no BigInt). Hands are
1519
+ * deduplicated into a UNION of hero+villain concrete hands — each evaluated
1520
+ * once per runout even when it appears in both ranges — with per-hand hero
1521
+ * and villain multiplicities, so duplicates count exactly as they do in the
1522
+ * per-pair unoptimized computation. Small fields get an explicit valid-pair
1523
+ * list; large fields are resolved by the counting sweep in _runExhaustive.
1524
+ * Everything is typed arrays so it could be shipped to workers unchanged.
1304
1525
  * @private
1305
1526
  */
1306
1527
  _buildExhaustiveWork(setup) {
1307
- const heroIdx = new Map(), villIdx = new Map();
1308
- const heroSuit = [], villSuit = [], pairH = [], pairV = [];
1309
- for (const m of setup.validMatchups) {
1310
- let hi = heroIdx.get(m.heroMask);
1311
- if (hi === undefined) { hi = heroSuit.length >> 2; heroIdx.set(m.heroMask, hi); const s = this._suitsFromBigMask(m.heroMask); heroSuit.push(s[0], s[1], s[2], s[3]); }
1312
- let vi = villIdx.get(m.villainMask);
1313
- if (vi === undefined) { vi = villSuit.length >> 2; villIdx.set(m.villainMask, vi); const s = this._suitsFromBigMask(m.villainMask); villSuit.push(s[0], s[1], s[2], s[3]); }
1314
- pairH.push(hi); pairV.push(vi);
1315
- }
1316
- const H = heroSuit.length >> 2, V = villSuit.length >> 2, P = pairH.length;
1317
- const hSuit = Int32Array.from(heroSuit), vSuit = Int32Array.from(villSuit);
1318
- const pHa = Int32Array.from(pairH), pVa = Int32Array.from(pairV);
1528
+ const board = this._cardsBitset(setup.boardCards);
1529
+ const uIndex = new Int32Array(52 * 52).fill(-1);
1530
+ const uSuitArr = [], uAArr = [], uBArr = [], uLoArr = [], uHiArr = [];
1531
+ const addSide = (hands) => {
1532
+ const u = [];
1533
+ for (const hand of hands) {
1534
+ const p = this._parseHand(hand);
1535
+ if ((p.lo & board.lo) | (p.hi & board.hi)) continue; // conflicts with board
1536
+ const key = p.a < p.b ? p.a * 52 + p.b : p.b * 52 + p.a;
1537
+ let ui = uIndex[key];
1538
+ if (ui < 0) {
1539
+ ui = uIndex[key] = uAArr.length;
1540
+ const s = this._suitsOfCards([hand.substring(0, 2), hand.substring(2, 4)]);
1541
+ uSuitArr.push(s[0], s[1], s[2], s[3]);
1542
+ uAArr.push(p.a); uBArr.push(p.b); uLoArr.push(p.lo); uHiArr.push(p.hi);
1543
+ }
1544
+ u.push(ui);
1545
+ }
1546
+ return u;
1547
+ };
1548
+ const heroU = addSide(setup.heroHands);
1549
+ const villU = addSide(setup.villainHands);
1550
+ const U = uAArr.length;
1551
+ const uSuit = Int32Array.from(uSuitArr), uA = Int32Array.from(uAArr), uB = Int32Array.from(uBArr);
1552
+ const hMult = new Int32Array(U), vMult = new Int32Array(U);
1553
+ for (const u of heroU) hMult[u]++;
1554
+ for (const u of villU) vMult[u]++;
1555
+ const nH = heroU.length, nV = villU.length;
1319
1556
  const bb = setup._boardSuits || (setup._boardSuits = this._suitsFromBigMask(setup.boardMask));
1320
1557
 
1321
- const useCount = H >= 16 && V >= 16;
1322
- let confList = null, confOff = null;
1558
+ // cardU[cardUOff[c]..cardUOff[c+1]) = union hands holding card c, used to
1559
+ // drop hands whose hole cards appear in a runout (that deal is impossible).
1560
+ const cardUOff = new Int32Array(53);
1561
+ for (let u = 0; u < U; u++) { cardUOff[uA[u] + 1]++; cardUOff[uB[u] + 1]++; }
1562
+ for (let c = 0; c < 52; c++) cardUOff[c + 1] += cardUOff[c];
1563
+ const cardU = new Int32Array(U * 2), cfill = cardUOff.slice(0, 52);
1564
+ for (let u = 0; u < U; u++) { cardU[cfill[uA[u]]++] = u; cardU[cfill[uB[u]]++] = u; }
1565
+
1566
+ const useCount = nH >= 16 && nV >= 16;
1567
+ let pHa = null, pVa = null, P = 0, cardTotal = null;
1323
1568
  if (useCount) {
1324
- const confListArr = [];
1325
- confOff = new Int32Array(H + 1);
1326
- for (let h = 0; h < H; h++) {
1327
- const q = h << 2, a0 = hSuit[q], a1 = hSuit[q + 1], a2 = hSuit[q + 2], a3 = hSuit[q + 3];
1328
- for (let v = 0; v < V; v++) { const r = v << 2; if ((a0 & vSuit[r]) | (a1 & vSuit[r + 1]) | (a2 & vSuit[r + 2]) | (a3 & vSuit[r + 3])) confListArr.push(v); }
1329
- confOff[h + 1] = confListArr.length;
1569
+ // cardTotal[c] = number of villain entries holding card c.
1570
+ cardTotal = new Int32Array(52);
1571
+ for (let u = 0; u < U; u++) { cardTotal[uA[u]] += vMult[u]; cardTotal[uB[u]] += vMult[u]; }
1572
+ } else {
1573
+ const ph = [], pv = [];
1574
+ for (const h of heroU) {
1575
+ const hl = uLoArr[h], hh = uHiArr[h];
1576
+ for (const v of villU) {
1577
+ if ((hl & uLoArr[v]) | (hh & uHiArr[v])) continue;
1578
+ ph.push(h); pv.push(v);
1579
+ }
1330
1580
  }
1331
- confList = Int32Array.from(confListArr);
1581
+ pHa = Int32Array.from(ph); pVa = Int32Array.from(pv); P = pHa.length;
1332
1582
  }
1333
- return { hSuit, vSuit, pHa, pVa, confList, confOff, useCount, bb0: bb[0], bb1: bb[1], bb2: bb[2], bb3: bb[3], H, V, P };
1583
+ return {
1584
+ uSuit, uA, uB, hMult, vMult, U, nH, nV, useCount, pHa, pVa, P, cardTotal, cardUOff, cardU,
1585
+ bb0: bb[0], bb1: bb[1], bb2: bb[2], bb3: bb[3]
1586
+ };
1334
1587
  }
1335
1588
 
1336
1589
  /**
1337
- * Exact exhaustive kernel over runouts [runStart, runEnd). Evaluates each
1338
- * unique concrete hand once per runout, then resolves matchups by direct
1339
- * comparison (small fields) or sorted-villain binary-search counting with
1340
- * card-conflict correction (large fields). Byte-identical to unoptimized.
1590
+ * Exact exhaustive kernel over runouts [runStart, runEnd). Hands holding a
1591
+ * card dealt in the runout are dropped for that runout; every other unique
1592
+ * concrete hand is evaluated once. Matchups are then resolved by direct
1593
+ * comparison (small fields) or, for large fields, a radix sort of the live
1594
+ * hands by value followed by a linear sweep over equal-value groups that
1595
+ * keeps running villain counts in total and per hole card (to subtract
1596
+ * villains sharing a card with the hero). Byte-identical to unoptimized.
1597
+ * `comboSuits` holds 4 suit rank masks per runout (any number of cards).
1341
1598
  * @private
1342
1599
  */
1343
1600
  _runExhaustive(work, comboSuits, runStart, runEnd) {
1344
- const { hSuit, vSuit, pHa, pVa, confList, confOff, useCount, bb0, bb1, bb2, bb3, H, V, P } = work;
1345
- const heroVals = new Int32Array(H), villVals = new Int32Array(V);
1346
- const sortedV = useCount ? new Int32Array(V) : null;
1601
+ const { uSuit, uA, uB, hMult, vMult, U, nV, useCount, pHa, pVa, P, cardTotal, cardUOff, cardU, bb0, bb1, bb2, bb3 } = work;
1602
+ const uVals = new Int32Array(U);
1603
+ // dead[u] === i <=> hand u holds a card dealt in runout i, so it takes no
1604
+ // part in that runout (neither as hero nor as villain).
1605
+ const dead = new Int32Array(U).fill(-1);
1606
+ const cardTot = useCount ? new Int32Array(52) : null;
1347
1607
  let win = 0, tie = 0, lose = 0;
1608
+
1609
+ // Large-field scratch: live hand list, radix-sort buffers and counters,
1610
+ // and per-card villain counts strictly below / equal to the current value.
1611
+ const live = useCount ? new Int32Array(U) : null;
1612
+ const tmp = useCount ? new Int32Array(U) : null;
1613
+ const order = useCount ? new Int32Array(U) : null;
1614
+ const cnt = useCount ? new Int32Array(3 * 257) : null;
1615
+ const lessC = useCount ? new Int32Array(52) : null;
1616
+ const eqC = useCount ? new Int32Array(52) : null;
1617
+
1348
1618
  for (let i = runStart; i < runEnd; i++) {
1349
1619
  const b = i << 2;
1620
+
1621
+ // Mark hands holding a runout card; for counting, also remove those
1622
+ // villains from the field total and the per-card totals.
1623
+ let nVi = nV;
1624
+ if (useCount) cardTot.set(cardTotal);
1625
+ for (let s = 0; s < 4; s++) {
1626
+ let m = comboSuits[b + s];
1627
+ while (m) {
1628
+ const low = m & -m; m ^= low;
1629
+ const c = (31 - Math.clz32(low)) * 4 + s;
1630
+ for (let k = cardUOff[c], e = cardUOff[c + 1]; k < e; k++) {
1631
+ const u = cardU[k];
1632
+ if (dead[u] === i) continue;
1633
+ dead[u] = i;
1634
+ if (useCount) { const vm = vMult[u]; if (vm) { nVi -= vm; cardTot[uA[u]] -= vm; cardTot[uB[u]] -= vm; } }
1635
+ }
1636
+ }
1637
+ }
1638
+
1639
+ // Evaluate the live hands only.
1350
1640
  const d0 = bb0 | comboSuits[b], d1 = bb1 | comboSuits[b + 1], d2 = bb2 | comboSuits[b + 2], d3 = bb3 | comboSuits[b + 3];
1351
- for (let h = 0; h < H; h++) { const q = h << 2; heroVals[h] = this._eval7(hSuit[q] | d0, hSuit[q + 1] | d1, hSuit[q + 2] | d2, hSuit[q + 3] | d3); }
1352
- for (let v = 0; v < V; v++) { const q = v << 2; villVals[v] = this._eval7(vSuit[q] | d0, vSuit[q + 1] | d1, vSuit[q + 2] | d2, vSuit[q + 3] | d3); }
1353
- if (useCount) {
1354
- sortedV.set(villVals);
1355
- sortedV.sort();
1356
- for (let h = 0; h < H; h++) {
1357
- const x = heroVals[h];
1358
- let lo = 0, hb = V; while (lo < hb) { const mid = (lo + hb) >> 1; if (sortedV[mid] < x) lo = mid + 1; else hb = mid; }
1359
- let up = lo; hb = V; while (up < hb) { const mid = (up + hb) >> 1; if (sortedV[mid] <= x) up = mid + 1; else hb = mid; }
1360
- let wH = lo, tH = up - lo, lH = V - up;
1361
- for (let ci = confOff[h], end = confOff[h + 1]; ci < end; ci++) { const cv = villVals[confList[ci]]; if (cv < x) wH--; else if (cv === x) tH--; else lH--; }
1362
- win += wH; tie += tH; lose += lH;
1641
+ for (let u = 0; u < U; u++) {
1642
+ if (dead[u] === i) continue;
1643
+ const q = u << 2;
1644
+ uVals[u] = this._eval7(uSuit[q] | d0, uSuit[q + 1] | d1, uSuit[q + 2] | d2, uSuit[q + 3] | d3);
1645
+ }
1646
+
1647
+ if (!useCount) {
1648
+ for (let p = 0; p < P; p++) {
1649
+ const hu = pHa[p], vu = pVa[p];
1650
+ if (dead[hu] === i || dead[vu] === i) continue;
1651
+ const hv = uVals[hu], vv = uVals[vu]; if (hv > vv) win++; else if (vv > hv) lose++; else tie++;
1363
1652
  }
1364
- } else {
1365
- for (let p = 0; p < P; p++) { const hv = heroVals[pHa[p]], vv = villVals[pVa[p]]; if (hv > vv) win++; else if (vv > hv) lose++; else tie++; }
1653
+ continue;
1654
+ }
1655
+
1656
+ // LSD radix sort of the live hands by value (values < 2^24: 3 x 8 bits).
1657
+ let L = 0;
1658
+ cnt.fill(0);
1659
+ for (let u = 0; u < U; u++) {
1660
+ if (dead[u] === i) continue;
1661
+ live[L++] = u;
1662
+ const v = uVals[u];
1663
+ cnt[(v & 255) + 1]++; cnt[257 + ((v >> 8) & 255) + 1]++; cnt[514 + ((v >> 16) & 255) + 1]++;
1664
+ }
1665
+ for (let o = 0; o < 771; o += 257) for (let r = 0; r < 256; r++) cnt[o + r + 1] += cnt[o + r];
1666
+ for (let k = 0; k < L; k++) { const u = live[k]; tmp[cnt[uVals[u] & 255]++] = u; }
1667
+ for (let k = 0; k < L; k++) { const u = tmp[k]; live[cnt[257 + ((uVals[u] >> 8) & 255)]++] = u; }
1668
+ for (let k = 0; k < L; k++) { const u = live[k]; order[cnt[514 + ((uVals[u] >> 16) & 255)]++] = u; }
1669
+ lessC.fill(0);
1670
+
1671
+ let below = 0; // villain entries with value < current group value
1672
+ for (let g = 0; g < L;) {
1673
+ const x = uVals[order[g]];
1674
+ let e = g + 1; while (e < L && uVals[order[e]] === x) e++;
1675
+ // Villain counts at exactly x: total and per card.
1676
+ let eqTotal = 0;
1677
+ for (let k = g; k < e; k++) { const u = order[k], m = vMult[u]; if (m) { eqTotal += m; eqC[uA[u]] += m; eqC[uB[u]] += m; } }
1678
+ const above = nVi - below - eqTotal;
1679
+ // Heroes at x. A villain holding both hero cards (the identical hand)
1680
+ // is subtracted on each card, so add it back once (it ties at x).
1681
+ for (let k = g; k < e; k++) {
1682
+ const u = order[k], m = hMult[u];
1683
+ if (!m) continue;
1684
+ const a = uA[u], c = uB[u];
1685
+ const w = below - lessC[a] - lessC[c];
1686
+ const t = eqTotal - eqC[a] - eqC[c] + vMult[u];
1687
+ const l = above - (cardTot[a] - lessC[a] - eqC[a]) - (cardTot[c] - lessC[c] - eqC[c]);
1688
+ win += w * m; tie += t * m; lose += l * m;
1689
+ }
1690
+ // Fold this group into the running "below" counts.
1691
+ for (let k = g; k < e; k++) { const u = order[k], m = vMult[u]; if (m) { lessC[uA[u]] += m; lessC[uB[u]] += m; eqC[uA[u]] = 0; eqC[uB[u]] = 0; } }
1692
+ below += eqTotal;
1693
+ g = e;
1366
1694
  }
1367
1695
  }
1368
1696
  return { win, tie, lose };
@@ -1374,98 +1702,67 @@ class BitVal {
1374
1702
  * @private
1375
1703
  */
1376
1704
  async _compareRangeOptimized(setup, progressCallback = null, progressInterval = 100, useWorkers = true) {
1377
- // Exhaustive (flop/turn): run the EXACT kernel on the main thread. It's now
1378
- // fast enough (~tens of ms) that this is the right choice, and it makes the
1379
- // worker and non-worker paths agree byte-for-byte (fixes the exact-vs-
1380
- // approximate split that per-canonical-group workers had). Splitting runouts
1381
- // across workers was a net loss here (structured-cloning the pair/conflict
1382
- // work to every worker dwarfs the compute) — true off-thread parallelism of
1383
- // this path needs SharedArrayBuffer (COOP/COEP), left as a follow-up. The
1384
- // reusable _runExhaustive / _buildExhaustiveWork are kept for that.
1705
+ // Exhaustive (flop/turn): the EXACT kernel. Large jobs are split by runout
1706
+ // across the worker pool (the work is a few small typed arrays, cheap to
1707
+ // post); small ones stay on the main thread where they're faster than the
1708
+ // messaging. Totals are integer sums, so both agree byte-for-byte.
1385
1709
  if (setup.isExhaustive) {
1710
+ if (useWorkers && typeof Worker !== 'undefined') {
1711
+ const work = this._buildExhaustiveWork(setup);
1712
+ const comboSuits = setup._comboSuits;
1713
+ const nRun = comboSuits.length >> 2;
1714
+ if (work.U * nRun >= this._EXACT_WORKER_THRESHOLD) {
1715
+ const nw = (typeof navigator !== 'undefined' && navigator.hardwareConcurrency) || 4;
1716
+ // Runouts cost about the same, so one chunk per worker (fewer posts).
1717
+ const chunk = Math.max(1, Math.ceil(nRun / nw));
1718
+ let next = 0, done = 0, win = 0, tie = 0, lose = 0;
1719
+ await this._runKernelBatches(work,
1720
+ () => { if (next >= nRun) return null; const a = next; next = Math.min(nRun, next + chunk); return comboSuits.slice(a * 4, next * 4); },
1721
+ (r, n) => { win += r.win; tie += r.tie; lose += r.lose; done += n; if (progressCallback) progressCallback(done, nRun, `Evaluating runouts ${done}/${nRun}`); },
1722
+ true, progressCallback);
1723
+ if (progressCallback) progressCallback(nRun, nRun, 'Complete');
1724
+ return { win, tie, lose };
1725
+ }
1726
+ }
1386
1727
  return await this._compareRangeOptimizedSequential(setup, progressCallback, progressInterval);
1387
1728
  }
1388
1729
 
1389
- // Pre-calculate board suit counts once (constant for all matchups)
1390
- const boardSuitCounts = setup.boardCards.length > 0
1391
- ? this._getBoardSuitCounts(setup.boardCards)
1392
- : null;
1393
-
1394
- // Group validMatchups by canonical key pair
1395
- // This ensures we only process matchups that actually exist, avoiding Cartesian product issues
1396
- const matchupGroups = new Map();
1397
- for (const matchup of setup.validMatchups) {
1398
- const mHKey = this._getCanonicalKey(
1399
- matchup.heroHand,
1400
- setup.boardCards,
1401
- setup.numberOfBoardCards,
1402
- boardSuitCounts
1403
- );
1404
- const mVKey = this._getCanonicalKey(
1405
- matchup.villainHand,
1406
- setup.boardCards,
1407
- setup.numberOfBoardCards,
1408
- boardSuitCounts
1409
- );
1410
- const key = `${mHKey}:${mVKey}`;
1411
-
1412
- if (!matchupGroups.has(key)) {
1413
- matchupGroups.set(key, {
1414
- heroKey: mHKey,
1415
- villainKey: mVKey,
1416
- representative: matchup,
1417
- count: 0
1418
- });
1419
- }
1420
- matchupGroups.get(key).count++;
1730
+ // Opt-in shared-board Monte Carlo.
1731
+ if (this.mcSharedBoards) {
1732
+ return await this._compareRangeSharedBoards(setup, progressCallback, useWorkers);
1421
1733
  }
1422
-
1423
- // Collect all matchups to evaluate (only those that actually exist)
1424
- const matchupsToEvaluate = [];
1425
- for (const [key, group] of matchupGroups) {
1426
- const validPair = group.representative;
1427
- matchupsToEvaluate.push({
1428
- key,
1429
- validCount: group.count,
1430
- heroKey: group.heroKey,
1431
- heroHand: validPair.heroHand,
1432
- heroMask: validPair.heroMask,
1433
- villainKey: group.villainKey,
1434
- villainHand: validPair.villainHand,
1435
- villainMask: validPair.villainMask
1436
- });
1437
- }
1438
-
1439
- // Calculate total matchups (number of unique canonical key pairs)
1734
+
1735
+ // Group validMatchups by canonical key pair (only matchups that exist).
1736
+ const matchupsToEvaluate = this._groupMonteCarloMatchups(setup);
1440
1737
  const totalMatchups = matchupsToEvaluate.length;
1441
-
1738
+
1442
1739
  // Check if workers should be used
1443
- const shouldUseWorkers = useWorkers &&
1444
- typeof Worker !== 'undefined' &&
1445
- matchupsToEvaluate.length >= 4;
1446
-
1447
- if (!shouldUseWorkers) {
1448
- // Fallback to sequential execution
1740
+ const shouldUseWorkers = useWorkers &&
1741
+ typeof Worker !== 'undefined' &&
1742
+ totalMatchups >= 4;
1743
+ let workers = null;
1744
+ if (shouldUseWorkers) {
1745
+ const numWorkers = Math.min((typeof navigator !== 'undefined' && navigator.hardwareConcurrency) || 4, totalMatchups);
1746
+ // Persistent pool (created once, reused across calls). Creation can throw
1747
+ // synchronously (e.g. a cross-origin worker URL); fall back to sequential.
1748
+ try { workers = this._acquireWorkers(numWorkers); } catch (e) { workers = null; }
1749
+ }
1750
+ if (!workers || workers.length === 0) {
1449
1751
  return await this._compareRangeOptimizedSequential(setup, progressCallback, progressInterval);
1450
1752
  }
1451
-
1452
- // Use Web Workers for parallelization
1453
- const numWorkers = Math.min(navigator.hardwareConcurrency || 4, matchupsToEvaluate.length);
1454
- const batchSize = Math.ceil(matchupsToEvaluate.length / numWorkers);
1455
- const batches = [];
1456
- for (let i = 0; i < matchupsToEvaluate.length; i += batchSize) {
1457
- batches.push(matchupsToEvaluate.slice(i, i + batchSize));
1458
- }
1753
+ const numWorkers = workers.length;
1459
1754
 
1460
- // Serialize setup for workers
1755
+ // Dynamic dispatch: hand out small chunks to whichever worker is free.
1756
+ // With Monte Carlo early-stop, matchup cost varies a lot (lopsided matchups
1757
+ // converge in ~8k draws, close ones run the full count), so equal static
1758
+ // batches left most workers idle behind the slowest one.
1759
+ const chunkSize = Math.max(1, Math.ceil(totalMatchups / (numWorkers * 8)));
1461
1760
  const serializedSetup = this._serializeSetup(setup);
1462
-
1463
- // Acquire persistent workers from the pool (created once, reused across calls).
1464
- // Previously a fresh Worker was spawned and terminated per compareRange call,
1465
- // paying thread-spawn + importScripts('./bitval.js') parse on every query.
1466
- const workers = this._acquireWorkers(batches.length);
1467
- const promises = [];
1468
- let completedMatchups = 0;
1761
+ let next = 0, completedMatchups = 0, win = 0, tie = 0, lose = 0, alive = numWorkers;
1762
+ const addResult = (r) => {
1763
+ win += r.matchupWin * r.validCount; tie += r.matchupTie * r.validCount; lose += r.matchupLose * r.validCount;
1764
+ completedMatchups++;
1765
+ };
1469
1766
 
1470
1767
  // Progress tracking
1471
1768
  let lastProgressTime = 0;
@@ -1478,69 +1775,161 @@ class BitVal {
1478
1775
  }
1479
1776
  }, progressUpdateInterval) : null;
1480
1777
 
1481
- for (let i = 0; i < batches.length; i++) {
1482
- const worker = workers[i];
1483
-
1484
- const promise = new Promise((resolve, reject) => {
1485
- // One-shot handlers per dispatch; cleared on settle so the pooled worker
1486
- // is clean for the next call. (Assumes compareRange calls don't overlap.)
1487
- const cleanup = () => { worker.onmessage = null; worker.onerror = null; };
1488
- worker.onmessage = (e) => {
1489
- cleanup();
1490
- if (e.data.success) {
1491
- completedMatchups += e.data.results.length;
1492
- resolve(e.data.results);
1493
- } else {
1494
- reject(new Error(e.data.error || 'Worker error'));
1778
+ // Each worker loop pulls the next chunk until none are left. A worker that
1779
+ // crashes is dropped from the pool and its in-flight chunk is evaluated on
1780
+ // the main thread, so one bad worker never loses work or fails the call.
1781
+ const runWorker = (worker, workerId) => new Promise((resolve, reject) => {
1782
+ let current = null;
1783
+ const cleanup = () => { worker.onmessage = null; worker.onerror = null; };
1784
+ const dispatch = () => {
1785
+ if (next >= totalMatchups) { cleanup(); resolve(); return; }
1786
+ const start = next; next = Math.min(totalMatchups, next + chunkSize);
1787
+ current = matchupsToEvaluate.slice(start, next);
1788
+ worker.postMessage({ matchups: current.map(m => this._serializeMatchup(m)), setupData: serializedSetup, workerId });
1789
+ };
1790
+ // One handler set per call; cleared on settle so the pooled worker is
1791
+ // clean for the next call. (Assumes compareRange calls don't overlap.)
1792
+ worker.onmessage = (e) => {
1793
+ if (!e.data.success) { cleanup(); reject(new Error(e.data.error || 'Worker error')); return; }
1794
+ for (const r of e.data.results) addResult(r);
1795
+ current = null;
1796
+ dispatch();
1797
+ };
1798
+ worker.onerror = (error) => {
1799
+ if (error && error.preventDefault) error.preventDefault();
1800
+ cleanup();
1801
+ this._discardWorker(worker);
1802
+ try {
1803
+ if (current) for (const m of current) addResult({ validCount: m.validCount, ...this._evaluateMatchupSync(m.heroMask, m.villainMask, setup) });
1804
+ current = null;
1805
+ // Last worker of this call died: finish the remaining chunks here.
1806
+ if (--alive === 0) {
1807
+ while (next < totalMatchups) { const m = matchupsToEvaluate[next++]; addResult({ validCount: m.validCount, ...this._evaluateMatchupSync(m.heroMask, m.villainMask, setup) }); }
1495
1808
  }
1496
- };
1497
- worker.onerror = (error) => {
1498
- cleanup();
1499
- // A crashed worker can't be reused - drop it from the pool.
1500
- this._discardWorker(worker);
1501
- reject(error);
1502
- };
1503
-
1504
- // Serialize matchups for this batch
1505
- const serializedBatch = batches[i].map(m => this._serializeMatchup(m));
1506
-
1507
- worker.postMessage({
1508
- matchups: serializedBatch,
1509
- setupData: serializedSetup,
1510
- workerId: i
1511
- });
1512
- });
1809
+ resolve();
1810
+ } catch (err) { reject(err); }
1811
+ };
1812
+ dispatch();
1813
+ });
1513
1814
 
1514
- promises.push(promise);
1515
- }
1516
-
1517
- // Wait for all workers and aggregate results
1518
- let win = 0, tie = 0, lose = 0;
1519
1815
  try {
1520
- const allResults = await Promise.all(promises);
1521
-
1522
- for (const batchResults of allResults) {
1523
- for (const result of batchResults) {
1524
- win += result.matchupWin * result.validCount;
1525
- tie += result.matchupTie * result.validCount;
1526
- lose += result.matchupLose * result.validCount;
1527
- }
1528
- }
1816
+ await Promise.all(workers.map((w, i) => runWorker(w, i)));
1529
1817
  } finally {
1530
- // Clean up progress interval
1531
- if (progressIntervalId) {
1532
- clearInterval(progressIntervalId);
1533
- }
1534
-
1818
+ if (progressIntervalId) clearInterval(progressIntervalId);
1535
1819
  // Workers are NOT terminated - they stay in the pool for the next call.
1536
1820
  // Callers can free them explicitly with terminateWorkerPool().
1821
+ if (progressCallback) progressCallback(totalMatchups, totalMatchups, 'Complete');
1822
+ }
1823
+
1824
+ return { win, tie, lose };
1825
+ }
1537
1826
 
1538
- // Final progress update
1539
- if (progressCallback) {
1540
- progressCallback(totalMatchups, totalMatchups, 'Complete');
1827
+ /**
1828
+ * Runs the exact kernel (_runExhaustive) over batches of runouts — on the
1829
+ * worker pool when allowed and available, otherwise on the main thread.
1830
+ * `nextBatch()` returns an Int32Array of runouts (4 suit masks each) or null
1831
+ * when there's nothing more to do (it may stop early); `onResult(r, n)` gets
1832
+ * each batch's { win, tie, lose } and its runout count. Batches are handed
1833
+ * out to whichever worker is free; a worker that fails is dropped and its
1834
+ * batch (and, if none are left, the rest) runs on the main thread.
1835
+ * @private
1836
+ */
1837
+ async _runKernelBatches(work, nextBatch, onResult, useWorkers, progressCallback = null) {
1838
+ const runLocal = (b) => onResult(this._runExhaustive(work, b, 0, b.length >> 2), b.length >> 2);
1839
+ let workers = null;
1840
+ if (useWorkers && typeof Worker !== 'undefined') {
1841
+ const n = (typeof navigator !== 'undefined' && navigator.hardwareConcurrency) || 4;
1842
+ try { workers = this._acquireWorkers(n); } catch (e) { workers = null; }
1843
+ }
1844
+ if (!workers || workers.length === 0) {
1845
+ let b;
1846
+ while ((b = nextBatch())) {
1847
+ runLocal(b);
1848
+ // Keep the page responsive between batches when someone is watching.
1849
+ if (progressCallback) await new Promise(resolve => setTimeout(resolve, 0));
1541
1850
  }
1542
- }
1851
+ return;
1852
+ }
1853
+ let alive = workers.length;
1854
+ await Promise.all(workers.map((worker) => new Promise((resolve, reject) => {
1855
+ let current = null;
1856
+ const cleanup = () => { worker.onmessage = null; worker.onerror = null; };
1857
+ const dispatch = () => {
1858
+ current = nextBatch();
1859
+ if (!current) { cleanup(); resolve(); return; }
1860
+ worker.postMessage({ type: 'kernel', work, boards: current });
1861
+ };
1862
+ worker.onmessage = (e) => {
1863
+ if (!e.data.success) { cleanup(); reject(new Error(e.data.error || 'Worker error')); return; }
1864
+ onResult(e.data.result, current.length >> 2);
1865
+ dispatch();
1866
+ };
1867
+ worker.onerror = (error) => {
1868
+ if (error && error.preventDefault) error.preventDefault();
1869
+ cleanup();
1870
+ this._discardWorker(worker);
1871
+ try {
1872
+ if (current) runLocal(current);
1873
+ if (--alive === 0) { let b; while ((b = nextBatch())) runLocal(b); }
1874
+ resolve();
1875
+ } catch (err) { reject(err); }
1876
+ };
1877
+ dispatch();
1878
+ })));
1879
+ }
1543
1880
 
1881
+ /**
1882
+ * Opt-in Monte Carlo (bitval.mcSharedBoards = true): sample boards once from
1883
+ * the live deck and run the exact kernel over them, so every hand is
1884
+ * evaluated once per board and ALL valid pairs are counted on every board.
1885
+ * Hands that collide with a sampled board sit that board out, which is
1886
+ * exactly conditional sampling per matchup, so the equity is unbiased (and
1887
+ * there's no canonical grouping). `iterations` caps the number of boards;
1888
+ * sampling stops early once the batch-means 95% CI half-width of the
1889
+ * aggregate equity is below `mcTarget` percentage points.
1890
+ * @private
1891
+ */
1892
+ async _compareRangeSharedBoards(setup, progressCallback = null, useWorkers = true) {
1893
+ const work = this._buildExhaustiveWork(setup);
1894
+ const ex = this._cardsBitset([...setup.boardCards, ...(setup.deadCards || [])]);
1895
+ const deck = [];
1896
+ for (let c = 0; c < 52; c++) if (!(c < 32 ? ex.lo & (1 << c) : ex.hi & (1 << (c - 32)))) deck.push(c);
1897
+ const pool = Int32Array.from(deck), poolLen = pool.length;
1898
+ const k = setup.numberOfCardsToDeal, total = setup.iterations;
1899
+ if (k > poolLen || total <= 0) return { win: 0, tie: 0, lose: 0 };
1900
+ const batchSize = Math.max(64, Math.min(1024, Math.ceil(total / 16)));
1901
+ const target = setup.mcTarget > 0 ? setup.mcTarget / 100 : 0;
1902
+ let st = new XorShift32().state;
1903
+ let issued = 0, done = 0, stop = false, win = 0, tie = 0, lose = 0;
1904
+ let nb = 0, sumEq = 0, sumEq2 = 0;
1905
+
1906
+ const nextBatch = () => {
1907
+ if (stop || issued >= total) return null;
1908
+ const n = Math.min(batchSize, total - issued);
1909
+ issued += n;
1910
+ const out = new Int32Array(n * 4);
1911
+ for (let i = 0; i < n; i++) {
1912
+ for (let j = 0; j < k; j++) { // partial Fisher-Yates over the live deck
1913
+ st ^= st << 13; st ^= st >> 17; st ^= st << 5;
1914
+ const r = j + Math.abs(st) % (poolLen - j);
1915
+ const c = pool[r]; pool[r] = pool[j]; pool[j] = c;
1916
+ out[i * 4 + (c & 3)] |= 1 << (c >> 2);
1917
+ }
1918
+ }
1919
+ return out;
1920
+ };
1921
+ const onResult = (r, n) => {
1922
+ win += r.win; tie += r.tie; lose += r.lose; done += n;
1923
+ const t = r.win + r.tie + r.lose;
1924
+ if (t > 0) { const e = (r.win + r.tie / 2) / t; nb++; sumEq += e; sumEq2 += e * e; }
1925
+ if (target && nb >= 8) {
1926
+ const mean = sumEq / nb, v = Math.max(0, (sumEq2 - nb * mean * mean) / (nb - 1));
1927
+ if (1.96 * Math.sqrt(v / nb) < target) stop = true;
1928
+ }
1929
+ if (progressCallback) progressCallback(done, total, `Sampled ${done.toLocaleString()} boards`);
1930
+ };
1931
+ await this._runKernelBatches(work, nextBatch, onResult, useWorkers, progressCallback);
1932
+ if (progressCallback) progressCallback(total, total, 'Complete');
1544
1933
  return { win, tie, lose };
1545
1934
  }
1546
1935
 
@@ -1553,7 +1942,7 @@ class BitVal {
1553
1942
  _acquireWorkers(count) {
1554
1943
  if (!this._workerPool) this._workerPool = [];
1555
1944
  while (this._workerPool.length < count) {
1556
- this._workerPool.push(new Worker('./bitval-worker.js'));
1945
+ this._workerPool.push(new Worker(this.workerUrl));
1557
1946
  }
1558
1947
  return this._workerPool.slice(0, count);
1559
1948
  }
@@ -1646,26 +2035,6 @@ class BitVal {
1646
2035
  }
1647
2036
  }
1648
2037
 
1649
- /**
1650
- * Flattens hands to canonical keys for caching optimization.
1651
- * @private
1652
- */
1653
- _flattenHandsToCanonical(hands, boardCards = [], numberOfBoardCards = 5) {
1654
- const canonicalMap = new Map();
1655
- const canonicalToHand = new Map();
1656
- const boardSuitCounts = boardCards.length > 0 ? this._getBoardSuitCounts(boardCards) : null;
1657
-
1658
- for (const hand of hands) {
1659
- const key = this._getCanonicalKey(hand, boardCards, numberOfBoardCards, boardSuitCounts);
1660
- canonicalMap.set(key, (canonicalMap.get(key) || 0) + 1);
1661
- if (!canonicalToHand.has(key)) {
1662
- canonicalToHand.set(key, hand);
1663
- }
1664
- }
1665
-
1666
- return { canonicalMap, canonicalToHand };
1667
- }
1668
-
1669
2038
  /**
1670
2039
  * Gets board suit counts for flush potential calculation.
1671
2040
  * @private