poker-bitvaljs 1.5.2 → 1.6.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/README.md CHANGED
@@ -185,20 +185,29 @@ Compares two ranges of hands.
185
185
 
186
186
  Loads the precomputed exact preflop equity table and enables the preflop fast
187
187
  path in `compareRange` (empty board, no dead cards). Accepts an `ArrayBuffer`
188
- (browser) or Node `Buffer`. Generate the table with `npm run gen:preflop`.
188
+ (browser) or Node `Buffer`. The table (~1 MB) ships with the npm package at
189
+ `poker-bitvaljs/bench/preflop-table.bin`; regenerate it with `npm run gen:preflop`.
189
190
 
190
191
  ```javascript
191
- // Browser
192
- const buf = await (await fetch('./bench/preflop-table.bin')).arrayBuffer();
192
+ // Browser (serve the file from wherever you host the package)
193
+ const buf = await (await fetch('./node_modules/poker-bitvaljs/bench/preflop-table.bin')).arrayBuffer();
193
194
  bitval.loadPreflopTable(buf);
194
195
 
195
196
  // Node
196
197
  const fs = require('fs');
197
- bitval.loadPreflopTable(fs.readFileSync('bench/preflop-table.bin'));
198
+ bitval.loadPreflopTable(fs.readFileSync(require.resolve('poker-bitvaljs/bench/preflop-table.bin')));
198
199
  ```
199
200
 
200
201
  Without a table loaded, preflop falls back to Monte Carlo — nothing else changes.
201
202
 
203
+ ## Changes in 1.6.0
204
+
205
+ - **Flop/turn correctness fix:** exhaustive `compareRange` no longer counts
206
+ runouts that deal a player's own hole card. Flop/turn equities from earlier
207
+ versions were slightly off (typically 0–3 percentage points) and will change.
208
+ - Large range-vs-range flop/turn is ~10–100× faster; `simulate()` is ~45×
209
+ faster; the preflop table ships with the package.
210
+
202
211
  ## Performance
203
212
 
204
213
  - **No BigInt in the hot path** — cards are four 13-bit suit lanes evaluated with
@@ -222,6 +231,10 @@ correctness with `npm test` (and `npm run test:full` for the exhaustive proofs).
222
231
  - Lower `mcTargetPct` (or set `0`) for higher-precision Monte Carlo; raise it for
223
232
  faster, looser preflop-with-dead-cards estimates.
224
233
  - Adjust `progressInterval` to balance UI responsiveness vs. performance.
234
+ - Web Workers load `bitval-worker.js` from next to `bitval.js` (resolved from
235
+ the `<script>` tag's URL). If you bundle or relocate the files, set
236
+ `bitval.workerUrl = '/path/to/bitval-worker.js'`. If workers can't start,
237
+ `compareRange` falls back to the main thread.
225
238
 
226
239
  ## Testing Locally
227
240
 
Binary file
package/bitval-worker.js CHANGED
@@ -32,87 +32,6 @@ function deserializeMatchup(matchupData) {
32
32
  };
33
33
  }
34
34
 
35
- /**
36
- * Evaluates a single matchup in the worker context
37
- * Similar to _evaluateMatchup but without progress callbacks
38
- */
39
- async function evaluateMatchupInWorker(heroMask, villainMask, setup, bitval) {
40
- let win = 0, tie = 0, lose = 0;
41
- const deadMask = heroMask | villainMask | setup.deadCardsMask;
42
-
43
- // Initialize random number generator for Monte Carlo (not needed for exhaustive)
44
- if (!setup.isExhaustive) {
45
- bitval.xorShift = new XorShift32();
46
- }
47
-
48
- // BigInt-free (Number) hot loop, mirroring BitVal._evaluateMatchup. Cards are
49
- // four 13-bit suit rank masks; bitval._eval7 does the evaluation with no BigInt.
50
- let iterations = setup.iterations;
51
- const heroSuits = bitval._suitsFromBigMask(heroMask);
52
- const villainSuits = bitval._suitsFromBigMask(villainMask);
53
- const boardBaseSuits = bitval._suitsFromBigMask(setup.boardMask);
54
- const hs0 = heroSuits[0], hs1 = heroSuits[1], hs2 = heroSuits[2], hs3 = heroSuits[3];
55
- const vs0 = villainSuits[0], vs1 = villainSuits[1], vs2 = villainSuits[2], vs3 = villainSuits[3];
56
- const bb0 = boardBaseSuits[0], bb1 = boardBaseSuits[1], bb2 = boardBaseSuits[2], bb3 = boardBaseSuits[3];
57
-
58
- let comboSuits = null; // flat Int32Array, 4 entries per combo
59
- let pool = null, poolLen = 0; // packed Int32Array of available cards
60
- const nDeal = setup.numberOfCardsToDeal;
61
- if (setup.isExhaustive) {
62
- // Use comboArray from setup if available, otherwise compute it.
63
- let comboArray = (setup.comboArray && setup.comboArray.length > 0)
64
- ? setup.comboArray
65
- : bitval._getCombinations(bitval._getAvailableCardMasksByLookUp(deadMask), nDeal);
66
- comboSuits = bitval._buildComboSuits(comboArray);
67
- iterations = comboSuits.length >> 2;
68
- } else {
69
- pool = bitval._availableSuitCards(deadMask);
70
- poolLen = pool.length;
71
- }
72
-
73
- // Monte Carlo early-stop (mirrors BitVal._evaluateMatchup).
74
- const mcTarget = setup.isExhaustive ? 0 : (setup.mcTarget > 0 ? setup.mcTarget / 100 : 0);
75
- const reqIters = iterations;
76
- let ran = iterations;
77
-
78
- // Main evaluation loop
79
- for (let i = 0; i < iterations; i++) {
80
- let d0, d1, d2, d3;
81
- if (comboSuits) {
82
- const b = i << 2;
83
- d0 = bb0 | comboSuits[b]; d1 = bb1 | comboSuits[b + 1]; d2 = bb2 | comboSuits[b + 2]; d3 = bb3 | comboSuits[b + 3];
84
- } else {
85
- d0 = bb0; d1 = bb1; d2 = bb2; d3 = bb3;
86
- for (let kk = 0; kk < nDeal; kk++) {
87
- const j = kk + bitval.xorShift.next(poolLen - kk);
88
- const tmp = pool[kk]; pool[kk] = pool[j]; pool[j] = tmp;
89
- const p = pool[kk], bit = p & 0xFFFF, suit = p >> 16;
90
- if (suit === 0) d0 |= bit;
91
- else if (suit === 1) d1 |= bit;
92
- else if (suit === 2) d2 |= bit;
93
- else d3 |= bit;
94
- }
95
- }
96
- const hE = bitval._eval7(hs0 | d0, hs1 | d1, hs2 | d2, hs3 | d3);
97
- const vE = bitval._eval7(vs0 | d0, vs1 | d1, vs2 | d2, vs3 | d3);
98
- if (hE > vE) win++;
99
- else if (vE > hE) lose++;
100
- else tie++;
101
-
102
- if (mcTarget && (i & 4095) === 4095 && i >= 8191) {
103
- const n = i + 1, p = (win + tie * 0.5) / n;
104
- if (1.96 * Math.sqrt(p * (1 - p) / n) < mcTarget) { ran = n; break; }
105
- }
106
- }
107
-
108
- if (ran < reqIters && ran > 0) {
109
- const s = reqIters / ran;
110
- win = Math.round(win * s); tie = Math.round(tie * s); lose = Math.round(lose * s);
111
- }
112
-
113
- return { matchupWin: win, matchupTie: tie, matchupLose: lose };
114
- }
115
-
116
35
  // Reused BitVal instance (holds the lookup tables; cheap to keep around).
117
36
  const _bitval = new BitVal();
118
37
 
@@ -126,7 +45,8 @@ self.onmessage = async function(e) {
126
45
 
127
46
  for (const matchupData of matchups) {
128
47
  const matchup = deserializeMatchup(matchupData);
129
- const result = await evaluateMatchupInWorker(matchup.heroMask, matchup.villainMask, setup, _bitval);
48
+ // Same synchronous loop as the main thread (BitVal._evaluateMatchupSync).
49
+ const result = _bitval._evaluateMatchupSync(matchup.heroMask, matchup.villainMask, setup);
130
50
  results.push({ key: matchup.key, validCount: matchup.validCount, ...result });
131
51
  }
132
52
 
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,8 @@ class XorShift32 {
15
28
 
16
29
  class BitVal {
17
30
  constructor() {
31
+ this.workerUrl = _BITVAL_WORKER_URL;
32
+
18
33
  // Suit constants
19
34
  this._DIAMOND = 3n;
20
35
  this._HEART = 2n;
@@ -175,6 +190,27 @@ class BitVal {
175
190
  if (top < 0 && (m & (1 << 12)) && (m & 0b1111) === 0b1111) top = 3; // wheel
176
191
  this._STRAIGHT13[m] = top;
177
192
  }
193
+ // Kicker tables (replace the per-eval "peel the highest bit" loops):
194
+ // _TOP5V13[m] = top five ranks of m packed as r1*65536+r2*4096+r3*256+r4*16+r5
195
+ // (flush / high card value; fewer ranks just sum fewer terms).
196
+ // _KEEP2_13[m] / _KEEP3_13[m] = m with only its top 2 / top 3 bits kept, so
197
+ // _TOP5V13[_KEEP3_13[m]] >> 4 = k1*4096+k2*256+k3*16 (pair kickers).
198
+ this._TOP5V13 = new Int32Array(8192);
199
+ this._KEEP2_13 = new Uint16Array(8192);
200
+ this._KEEP3_13 = new Uint16Array(8192);
201
+ for (let m = 0; m < 8192; m++) {
202
+ let v = 0, shift = 65536, n = 0, keep = 0;
203
+ for (let r = 12; r >= 0 && n < 5; r--) {
204
+ if (m & (1 << r)) {
205
+ v += r * shift; shift >>= 4; n++; keep |= 1 << r;
206
+ if (n === 2) this._KEEP2_13[m] = keep;
207
+ if (n === 3) this._KEEP3_13[m] = keep;
208
+ }
209
+ }
210
+ if (n < 2) this._KEEP2_13[m] = keep;
211
+ if (n < 3) this._KEEP3_13[m] = keep;
212
+ this._TOP5V13[m] = v;
213
+ }
178
214
 
179
215
  // Preflop exact-equity table (loaded on demand via loadPreflopTable). The 24
180
216
  // suit permutations canonicalize a matchup for lookup (card index = rank*4+suit).
@@ -362,11 +398,7 @@ class BitVal {
362
398
  }
363
399
 
364
400
  // 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
- }
401
+ if (fm) return 5 * 1048576 + this._TOP5V13[fm];
370
402
 
371
403
  // Straight
372
404
  {
@@ -377,10 +409,7 @@ class BitVal {
377
409
  // Trips
378
410
  if (trips) {
379
411
  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;
412
+ return 3 * 1048576 + t * 65536 + (this._TOP5V13[this._KEEP2_13[all & ~(1 << t)]] >> 4);
384
413
  }
385
414
 
386
415
  // Two pair
@@ -394,19 +423,11 @@ class BitVal {
394
423
  // One pair
395
424
  if (pairs) {
396
425
  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;
426
+ return 1 * 1048576 + p * 65536 + (this._TOP5V13[this._KEEP3_13[all & ~(1 << p)]] >> 4);
402
427
  }
403
428
 
404
429
  // 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
- }
430
+ return this._TOP5V13[all];
410
431
  }
411
432
 
412
433
  /**
@@ -453,15 +474,11 @@ class BitVal {
453
474
  * @returns {Object} { win, tie, lose, ...handStrengths } - Results object
454
475
  */
455
476
  simulate(iterations, numberOfBoardCards = 5, hero = [], villain = [], board = [], deadCards = []) {
456
- let numberOfCardsToDeal = (numberOfBoardCards + 2) - (hero.length + board.length);
457
- this.xorShift = new XorShift32();
477
+ const numberOfCardsToDeal = numberOfBoardCards - board.length;
478
+ const xorShift = this.xorShift = new XorShift32();
458
479
 
459
480
  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);
481
+ const deadMask = this.getBitMasked(allDeadCards);
465
482
 
466
483
  const availableCards = 52 - new Set(allDeadCards).size;
467
484
  const exhaustiveCombinations = this.combinations(availableCards, numberOfCardsToDeal);
@@ -473,13 +490,8 @@ class BitVal {
473
490
  // for larger (>=3 cards to come) scenarios.
474
491
  const isExhaustive = numberOfCardsToDeal >= 0 && numberOfCardsToDeal <= 2;
475
492
  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
493
 
482
- let result = {
494
+ let result = {
483
495
  "win": 0,
484
496
  "tie": 0,
485
497
  "lose": 0,
@@ -494,34 +506,44 @@ class BitVal {
494
506
  "Straight Flush": 0
495
507
  };
496
508
 
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);
509
+ // BigInt-free hot loop (same engine as compareRange): cards are four 13-bit
510
+ // suit rank masks and _eval7 orders hands identically to evaluate().
511
+ const hs = this._suitsOfCards(hero), vs = this._suitsOfCards(villain), bs = this._suitsOfCards(board);
512
+ const hs0 = hs[0] | bs[0], hs1 = hs[1] | bs[1], hs2 = hs[2] | bs[2], hs3 = hs[3] | bs[3];
513
+ const vs0 = vs[0] | bs[0], vs1 = vs[1] | bs[1], vs2 = vs[2] | bs[2], vs3 = vs[3] | bs[3];
514
+ let win = 0, tie = 0, lose = 0;
504
515
 
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;
516
+ if (isExhaustive) {
517
+ const combos = this._buildComboSuits(this._getCombinations(this._getAvailableCardMasksByLookUp(deadMask), numberOfCardsToDeal));
518
+ const n = Math.min(iterations, combos.length >> 2);
519
+ for (let i = 0; i < n; i++) {
520
+ const b = i << 2, d0 = combos[b], d1 = combos[b + 1], d2 = combos[b + 2], d3 = combos[b + 3];
521
+ const hE = this._eval7(hs0 | d0, hs1 | d1, hs2 | d2, hs3 | d3);
522
+ const vE = this._eval7(vs0 | d0, vs1 | d1, vs2 | d2, vs3 | d3);
523
+ if (hE > vE) win++; else if (vE > hE) lose++; else tie++;
516
524
  }
517
- if (hero_tiebreaker < villain_tiebreaker) {
518
- result["lose"]++;
519
- continue;
525
+ } else {
526
+ // Partial Fisher-Yates draw from the packed pool of live cards.
527
+ const pool = this._availableSuitCards(deadMask), poolLen = pool.length;
528
+ const nDeal = poolLen < numberOfCardsToDeal ? 0 : numberOfCardsToDeal;
529
+ let st = xorShift.state;
530
+ for (let i = 0; i < iterations; i++) {
531
+ let d0 = 0, d1 = 0, d2 = 0, d3 = 0;
532
+ for (let k = 0; k < nDeal; k++) {
533
+ st ^= st << 13; st ^= st >> 17; st ^= st << 5;
534
+ const j = k + Math.abs(st) % (poolLen - k);
535
+ const card = pool[j]; pool[j] = pool[k]; pool[k] = card;
536
+ const bit = card & 0xFFFF, suit = card >> 16;
537
+ if (suit === 0) d0 |= bit; else if (suit === 1) d1 |= bit; else if (suit === 2) d2 |= bit; else d3 |= bit;
538
+ }
539
+ const hE = this._eval7(hs0 | d0, hs1 | d1, hs2 | d2, hs3 | d3);
540
+ const vE = this._eval7(vs0 | d0, vs1 | d1, vs2 | d2, vs3 | d3);
541
+ if (hE > vE) win++; else if (vE > hE) lose++; else tie++;
520
542
  }
521
-
522
- result["tie"]++
543
+ xorShift.state = st;
523
544
  }
524
-
545
+
546
+ result.win = win; result.tie = tie; result.lose = lose;
525
547
  return result;
526
548
  }
527
549
 
@@ -888,100 +910,103 @@ class BitVal {
888
910
  // PRIVATE - Range Comparison Helpers
889
911
  // ============================================
890
912
 
913
+ /**
914
+ * Parses a 2-card hand string into card indices (0..51, rank*4+suit) and a
915
+ * split 64-bit card bitset (lo = cards 0..31, hi = cards 32..51) so overlap
916
+ * tests are plain Number ANDs instead of BigInt work.
917
+ * @private
918
+ */
919
+ _parseHand(hand) {
920
+ const a = this._cardIndex(hand), b = this._cardIndex(hand.substring(2, 4));
921
+ const lo = (a < 32 ? 1 << a : 0) | (b < 32 ? 1 << b : 0);
922
+ const hi = (a >= 32 ? 1 << (a - 32) : 0) | (b >= 32 ? 1 << (b - 32) : 0);
923
+ return { a, b, lo, hi };
924
+ }
925
+
926
+ /**
927
+ * Card bitset (lo/hi halves) for an array of card strings.
928
+ * @private
929
+ */
930
+ _cardsBitset(cards) {
931
+ let lo = 0, hi = 0;
932
+ for (const c of cards) { const i = this._cardIndex(c); if (i < 32) lo |= 1 << i; else hi |= 1 << (i - 32); }
933
+ return { lo, hi };
934
+ }
935
+
891
936
  /**
892
937
  * Generates all valid matchups between hero and villain hands, excluding overlaps.
938
+ * Each hand is parsed once; the O(H*V) overlap test runs on Number bitsets.
893
939
  * @private
894
940
  */
895
941
  _generateValidMatchups(heroHands, villainHands, boardCards = []) {
896
942
  const valid = [];
897
- const boardMask = boardCards.length > 0 ? this.getBitMasked(boardCards) : 0n;
943
+ const board = this._cardsBitset(boardCards);
944
+ const nV = villainHands.length;
945
+ const vLo = new Int32Array(nV), vHi = new Int32Array(nV), vOk = new Uint8Array(nV), vMask = new Array(nV);
946
+ for (let j = 0; j < nV; j++) {
947
+ const p = this._parseHand(villainHands[j]);
948
+ vLo[j] = p.lo; vHi[j] = p.hi;
949
+ vOk[j] = ((p.lo & board.lo) | (p.hi & board.hi)) === 0 ? 1 : 0;
950
+ vMask[j] = vOk[j] ? this.getBitMasked(this._handStringToCards(villainHands[j])) : 0n;
951
+ }
898
952
  for (const h of heroHands) {
953
+ const p = this._parseHand(h);
954
+ if ((p.lo & board.lo) | (p.hi & board.hi)) continue;
899
955
  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 });
956
+ for (let j = 0; j < nV; j++) {
957
+ if (!vOk[j] || (p.lo & vLo[j]) | (p.hi & vHi[j])) continue;
958
+ valid.push({ heroHand: h, villainHand: villainHands[j], heroMask: hMask, villainMask: vMask[j] });
905
959
  }
906
960
  }
907
961
  return valid;
908
962
  }
909
963
 
910
964
  /**
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.
965
+ * Sets up range comparison. The per-pair matchup list is only materialized
966
+ * (lazily, via the `validMatchups` getter) by the paths that need it — the
967
+ * Monte Carlo and unoptimized paths. The exact exhaustive path works straight
968
+ * from the hand strings (see _buildExhaustiveWork).
928
969
  * @private
929
970
  */
930
971
  _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
972
  // Calculate bitmasks
945
973
  const boardMask = this.getBitMasked(boardCards);
946
974
  const deadCardsMask = this.getBitMasked([...boardCards, ...deadCards]);
947
-
975
+
948
976
  // Calculate cards to deal and determine if exhaustive enumeration is possible.
949
977
  // 0 cards to come (a complete board) is exhaustive too: exactly one runout.
950
978
  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;
979
+ const isExhaustive = numberOfCardsToDeal >= 0 && numberOfCardsToDeal <= 2;
980
+
960
981
  // For <=2 cards to come the full runout space is tiny (<=1326), so always
961
982
  // enumerate it exactly instead of Monte-Carloing a lower requested count.
962
983
  // Exhaustive is exact and deterministic; the requested iteration count only
963
984
  // 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
-
985
+ if (isExhaustive) {
986
+ iterations = this.combinations(52 - this.countBits(deadCardsMask), numberOfCardsToDeal);
987
+ }
988
+
967
989
  // Generate combo array only if exhaustive
968
- const comboArray = isExhaustive
969
- ? this._getCombinations(this._getAvailableCardMasksByLookUp(deadCardsMask), numberOfCardsToDeal)
990
+ const comboArray = isExhaustive
991
+ ? this._getCombinations(this._getAvailableCardMasksByLookUp(deadCardsMask), numberOfCardsToDeal)
970
992
  : 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
993
+
994
+ const self = this;
995
+ return {
996
+ heroHands,
997
+ villainHands,
998
+ boardMask,
999
+ deadCardsMask,
1000
+ iterations,
1001
+ isExhaustive,
1002
+ comboArray,
1003
+ numberOfCardsToDeal,
1004
+ boardCards,
1005
+ numberOfBoardCards,
1006
+ _validMatchups: null,
1007
+ get validMatchups() {
1008
+ return this._validMatchups || (this._validMatchups = self._generateValidMatchups(heroHands, villainHands, boardCards));
1009
+ }
985
1010
  };
986
1011
  }
987
1012
 
@@ -1001,8 +1026,8 @@ class BitVal {
1001
1026
  isExhaustive: setup.isExhaustive,
1002
1027
  numberOfCardsToDeal: setup.numberOfCardsToDeal,
1003
1028
  mcTarget: setup.mcTarget
1004
- // Note: hCanon, vCanon, validMatchups, validCounts are not needed in workers
1005
- // as matchups are pre-serialized and sent separately
1029
+ // Note: validMatchups is not needed in workers; matchups are
1030
+ // pre-serialized and sent separately
1006
1031
  };
1007
1032
  }
1008
1033
 
@@ -1048,7 +1073,76 @@ class BitVal {
1048
1073
  * Optimized for browser performance with adaptive yielding and inlined hot loop.
1049
1074
  * @private
1050
1075
  */
1076
+ /**
1077
+ * Synchronous single-matchup evaluation (no progress / yielding). Used by the
1078
+ * sequential path when there's no progress callback and by the Web Worker,
1079
+ * so both run the same tight loop with the RNG state held in a local.
1080
+ * @private
1081
+ */
1082
+ _evaluateMatchupSync(heroMask, villainMask, setup) {
1083
+ let win = 0, tie = 0, lose = 0;
1084
+ const hs = this._suitsFromBigMask(heroMask), vs = this._suitsFromBigMask(villainMask);
1085
+ const bb = setup._boardSuits || (setup._boardSuits = this._suitsFromBigMask(setup.boardMask));
1086
+ const hs0 = hs[0], hs1 = hs[1], hs2 = hs[2], hs3 = hs[3];
1087
+ const vs0 = vs[0], vs1 = vs[1], vs2 = vs[2], vs3 = vs[3];
1088
+ const bb0 = bb[0], bb1 = bb[1], bb2 = bb[2], bb3 = bb[3];
1089
+
1090
+ if (setup.isExhaustive) {
1091
+ const comboSuits = setup._comboSuits || (setup._comboSuits = this._buildComboSuits(setup.comboArray));
1092
+ const n = comboSuits.length >> 2;
1093
+ const m0 = hs0 | vs0, m1 = hs1 | vs1, m2 = hs2 | vs2, m3 = hs3 | vs3;
1094
+ for (let i = 0; i < n; i++) {
1095
+ const b = i << 2;
1096
+ const c0 = comboSuits[b], c1 = comboSuits[b + 1], c2 = comboSuits[b + 2], c3 = comboSuits[b + 3];
1097
+ if ((c0 & m0) | (c1 & m1) | (c2 & m2) | (c3 & m3)) continue; // runout uses a hole card
1098
+ const d0 = bb0 | c0, d1 = bb1 | c1, d2 = bb2 | c2, d3 = bb3 | c3;
1099
+ const hE = this._eval7(hs0 | d0, hs1 | d1, hs2 | d2, hs3 | d3);
1100
+ const vE = this._eval7(vs0 | d0, vs1 | d1, vs2 | d2, vs3 | d3);
1101
+ if (hE > vE) win++; else if (vE > hE) lose++; else tie++;
1102
+ }
1103
+ return { matchupWin: win, matchupTie: tie, matchupLose: lose };
1104
+ }
1105
+
1106
+ const xorShift = this.xorShift = new XorShift32();
1107
+ const pool = this._availableSuitCards(heroMask | villainMask | setup.deadCardsMask), poolLen = pool.length;
1108
+ const nDeal = setup.numberOfCardsToDeal;
1109
+ const iterations = setup.iterations;
1110
+ const mcTarget = setup.mcTarget > 0 ? setup.mcTarget / 100 : 0;
1111
+ let ran = iterations, st = xorShift.state;
1112
+ for (let i = 0; i < iterations; i++) {
1113
+ let d0 = bb0, d1 = bb1, d2 = bb2, d3 = bb3;
1114
+ for (let k = 0; k < nDeal; k++) {
1115
+ st ^= st << 13; st ^= st >> 17; st ^= st << 5;
1116
+ const j = k + Math.abs(st) % (poolLen - k);
1117
+ const card = pool[j]; pool[j] = pool[k]; pool[k] = card;
1118
+ const bit = card & 0xFFFF, suit = card >> 16;
1119
+ if (suit === 0) d0 |= bit; else if (suit === 1) d1 |= bit; else if (suit === 2) d2 |= bit; else d3 |= bit;
1120
+ }
1121
+ const hE = this._eval7(hs0 | d0, hs1 | d1, hs2 | d2, hs3 | d3);
1122
+ const vE = this._eval7(vs0 | d0, vs1 | d1, vs2 | d2, vs3 | d3);
1123
+ if (hE > vE) win++; else if (vE > hE) lose++; else tie++;
1124
+
1125
+ // Monte Carlo early-stop check (every 4096 draws, past a warm-up).
1126
+ if (mcTarget && (i & 4095) === 4095 && i >= 8191) {
1127
+ const n = i + 1, p = (win + tie * 0.5) / n;
1128
+ if (1.96 * Math.sqrt(p * (1 - p) / n) < mcTarget) { ran = n; break; }
1129
+ }
1130
+ }
1131
+ xorShift.state = st;
1132
+
1133
+ // If early-stopped, scale counts back to the requested iteration count so
1134
+ // every matchup is weighted equally in the aggregate.
1135
+ if (ran < iterations && ran > 0) {
1136
+ const s = iterations / ran;
1137
+ win = Math.round(win * s); tie = Math.round(tie * s); lose = Math.round(lose * s);
1138
+ }
1139
+ return { matchupWin: win, matchupTie: tie, matchupLose: lose };
1140
+ }
1141
+
1051
1142
  async _evaluateMatchup(heroMask, villainMask, setup, progressCallback = null, matchupIndex = 0, totalMatchups = 1) {
1143
+ // No progress reporting: run the tight synchronous loop (no per-iteration
1144
+ // yield checks, RNG in a local).
1145
+ if (!progressCallback) return this._evaluateMatchupSync(heroMask, villainMask, setup);
1052
1146
  let win = 0, tie = 0, lose = 0;
1053
1147
  const deadMask = heroMask | villainMask | setup.deadCardsMask;
1054
1148
 
@@ -1097,10 +1191,13 @@ class BitVal {
1097
1191
  for (let i = 0; i < iterations; i++) {
1098
1192
  // Build the runout as suit rank-masks: precomputed combo (exhaustive) or
1099
1193
  // a Fisher-Yates draw from the available pool (Monte Carlo).
1100
- let d0, d1, d2, d3;
1194
+ let d0, d1, d2, d3, live = true;
1101
1195
  if (comboSuits) {
1102
1196
  const b = i << 2;
1103
- d0 = bb0 | comboSuits[b]; d1 = bb1 | comboSuits[b + 1]; d2 = bb2 | comboSuits[b + 2]; d3 = bb3 | comboSuits[b + 3];
1197
+ const c0 = comboSuits[b], c1 = comboSuits[b + 1], c2 = comboSuits[b + 2], c3 = comboSuits[b + 3];
1198
+ // Skip runouts that use a hole card (impossible deals).
1199
+ live = ((c0 & (hs0 | vs0)) | (c1 & (hs1 | vs1)) | (c2 & (hs2 | vs2)) | (c3 & (hs3 | vs3))) === 0;
1200
+ d0 = bb0 | c0; d1 = bb1 | c1; d2 = bb2 | c2; d3 = bb3 | c3;
1104
1201
  } else {
1105
1202
  d0 = bb0; d1 = bb1; d2 = bb2; d3 = bb3;
1106
1203
  for (let k = 0; k < nDeal; k++) {
@@ -1114,11 +1211,13 @@ class BitVal {
1114
1211
  }
1115
1212
  }
1116
1213
 
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++;
1214
+ if (live) {
1215
+ const hE = this._eval7(hs0 | d0, hs1 | d1, hs2 | d2, hs3 | d3);
1216
+ const vE = this._eval7(vs0 | d0, vs1 | d1, vs2 | d2, vs3 | d3);
1217
+ if (hE > vE) win++;
1218
+ else if (vE > hE) lose++;
1219
+ else tie++;
1220
+ }
1122
1221
 
1123
1222
  // Monte Carlo early-stop check (every 4096 draws, past a warm-up).
1124
1223
  if (mcTarget && (i & 4095) === 4095 && i >= 8191) {
@@ -1228,6 +1327,62 @@ class BitVal {
1228
1327
  return { win, tie, lose };
1229
1328
  }
1230
1329
 
1330
+ /**
1331
+ * Groups the valid (hero, villain) matchups by canonical key pair for the
1332
+ * Monte Carlo path. Canonical keys depend only on the hand (and board), so
1333
+ * they're computed once per hand string, and pairs are enumerated with Number
1334
+ * card bitsets — no per-pair objects or string keys. Each group keeps the
1335
+ * first matchup (hero-major order) as its representative. Cached on `setup`.
1336
+ * @private
1337
+ */
1338
+ _groupMonteCarloMatchups(setup) {
1339
+ if (setup._mcGroups) return setup._mcGroups;
1340
+ const { heroHands, villainHands, boardCards, numberOfBoardCards } = setup;
1341
+ const board = this._cardsBitset(boardCards);
1342
+ const boardSuitCounts = boardCards.length > 0 ? this._getBoardSuitCounts(boardCards) : null;
1343
+ const keyIds = new Map(), keyNames = [];
1344
+ const keyId = (hand) => {
1345
+ const k = this._getCanonicalKey(hand, boardCards, numberOfBoardCards, boardSuitCounts);
1346
+ let id = keyIds.get(k);
1347
+ if (id === undefined) { id = keyNames.length; keyIds.set(k, id); keyNames.push(k); }
1348
+ return id;
1349
+ };
1350
+ const parseSide = (hands) => {
1351
+ const n = hands.length, lo = new Int32Array(n), hi = new Int32Array(n), id = new Int32Array(n), ok = new Uint8Array(n);
1352
+ for (let i = 0; i < n; i++) {
1353
+ const p = this._parseHand(hands[i]);
1354
+ lo[i] = p.lo; hi[i] = p.hi;
1355
+ ok[i] = ((p.lo & board.lo) | (p.hi & board.hi)) === 0 ? 1 : 0;
1356
+ if (ok[i]) id[i] = keyId(hands[i]);
1357
+ }
1358
+ return { lo, hi, id, ok, n };
1359
+ };
1360
+ const H = parseSide(heroHands), V = parseSide(villainHands);
1361
+ const nK = keyNames.length;
1362
+ const groupOf = new Map(), groups = [];
1363
+ for (let h = 0; h < H.n; h++) {
1364
+ if (!H.ok[h]) continue;
1365
+ const hl = H.lo[h], hh = H.hi[h], hk = H.id[h] * nK;
1366
+ for (let v = 0; v < V.n; v++) {
1367
+ if (!V.ok[v] || (hl & V.lo[v]) | (hh & V.hi[v])) continue;
1368
+ const gk = hk + V.id[v];
1369
+ let g = groupOf.get(gk);
1370
+ if (g === undefined) {
1371
+ const heroHand = heroHands[h], villainHand = villainHands[v];
1372
+ const heroKey = keyNames[H.id[h]], villainKey = keyNames[V.id[v]];
1373
+ g = {
1374
+ key: `${heroKey}:${villainKey}`, validCount: 0, heroKey, villainKey, heroHand, villainHand,
1375
+ heroMask: this.getBitMasked(this._handStringToCards(heroHand)),
1376
+ villainMask: this.getBitMasked(this._handStringToCards(villainHand))
1377
+ };
1378
+ groupOf.set(gk, g); groups.push(g);
1379
+ }
1380
+ g.validCount++;
1381
+ }
1382
+ }
1383
+ return (setup._mcGroups = groups);
1384
+ }
1385
+
1231
1386
  /**
1232
1387
  * Compares ranges with canonical key caching optimization (sequential version).
1233
1388
  * @private
@@ -1237,29 +1392,16 @@ class BitVal {
1237
1392
  let lastProgressTime = 0;
1238
1393
  const progressUpdateInterval = 100; // Update progress at most every 100ms
1239
1394
 
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
1395
  // Monte Carlo (>=3 cards to come): canonical grouping + independent
1246
1396
  // per-matchup sampling (each matchup deals its own boards, so runouts can't
1247
1397
  // be shared). Grouping only pays off here, where a matchup is expensive.
1248
1398
  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;
1399
+ const groups = this._groupMonteCarloMatchups(setup);
1400
+ const totalMatchups = groups.length;
1259
1401
  let matchupIndex = 0;
1260
- for (const [key, group] of matchupGroups) {
1261
- const validCount = group.count;
1262
- const validPair = group.representative;
1402
+ for (const group of groups) {
1403
+ const validCount = group.validCount;
1404
+ const validPair = group;
1263
1405
  const { matchupWin, matchupTie, matchupLose } = await this._evaluateMatchup(
1264
1406
  validPair.heroMask, validPair.villainMask, setup, progressCallback, matchupIndex, totalMatchups
1265
1407
  );
@@ -1285,9 +1427,8 @@ class BitVal {
1285
1427
  }
1286
1428
 
1287
1429
  // 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.
1430
+ // the kernel over all runouts (on the main thread for both the worker and
1431
+ // non-worker paths, so they agree byte-for-byte).
1291
1432
  const work = this._buildExhaustiveWork(setup);
1292
1433
  const comboSuits = setup._comboSuits || (setup._comboSuits = this._buildComboSuits(setup.comboArray));
1293
1434
  const nRun = comboSuits.length >> 2;
@@ -1297,72 +1438,163 @@ class BitVal {
1297
1438
  }
1298
1439
 
1299
1440
  /**
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.
1441
+ * Builds the reusable per-run work for the exact exhaustive kernel straight
1442
+ * from the hand strings (no per-pair objects, no BigInt). Hands are
1443
+ * deduplicated into a UNION of hero+villain concrete hands — each evaluated
1444
+ * once per runout even when it appears in both ranges — with per-hand hero
1445
+ * and villain multiplicities, so duplicates count exactly as they do in the
1446
+ * per-pair unoptimized computation. Small fields get an explicit valid-pair
1447
+ * list; large fields are resolved by the counting sweep in _runExhaustive.
1448
+ * Everything is typed arrays so it could be shipped to workers unchanged.
1304
1449
  * @private
1305
1450
  */
1306
1451
  _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);
1452
+ const board = this._cardsBitset(setup.boardCards);
1453
+ const uIndex = new Int32Array(52 * 52).fill(-1);
1454
+ const uSuitArr = [], uAArr = [], uBArr = [], uLoArr = [], uHiArr = [];
1455
+ const addSide = (hands) => {
1456
+ const u = [];
1457
+ for (const hand of hands) {
1458
+ const p = this._parseHand(hand);
1459
+ if ((p.lo & board.lo) | (p.hi & board.hi)) continue; // conflicts with board
1460
+ const key = p.a < p.b ? p.a * 52 + p.b : p.b * 52 + p.a;
1461
+ let ui = uIndex[key];
1462
+ if (ui < 0) {
1463
+ ui = uIndex[key] = uAArr.length;
1464
+ const s = this._suitsOfCards([hand.substring(0, 2), hand.substring(2, 4)]);
1465
+ uSuitArr.push(s[0], s[1], s[2], s[3]);
1466
+ uAArr.push(p.a); uBArr.push(p.b); uLoArr.push(p.lo); uHiArr.push(p.hi);
1467
+ }
1468
+ u.push(ui);
1469
+ }
1470
+ return u;
1471
+ };
1472
+ const heroU = addSide(setup.heroHands);
1473
+ const villU = addSide(setup.villainHands);
1474
+ const U = uAArr.length;
1475
+ const uSuit = Int32Array.from(uSuitArr), uA = Int32Array.from(uAArr), uB = Int32Array.from(uBArr);
1476
+ const hMult = new Int32Array(U), vMult = new Int32Array(U);
1477
+ for (const u of heroU) hMult[u]++;
1478
+ for (const u of villU) vMult[u]++;
1479
+ const nH = heroU.length, nV = villU.length;
1319
1480
  const bb = setup._boardSuits || (setup._boardSuits = this._suitsFromBigMask(setup.boardMask));
1320
1481
 
1321
- const useCount = H >= 16 && V >= 16;
1322
- let confList = null, confOff = null;
1482
+ // cardU[cardUOff[c]..cardUOff[c+1]) = union hands holding card c, used to
1483
+ // drop hands whose hole cards appear in a runout (that deal is impossible).
1484
+ const cardUOff = new Int32Array(53);
1485
+ for (let u = 0; u < U; u++) { cardUOff[uA[u] + 1]++; cardUOff[uB[u] + 1]++; }
1486
+ for (let c = 0; c < 52; c++) cardUOff[c + 1] += cardUOff[c];
1487
+ const cardU = new Int32Array(U * 2), cfill = cardUOff.slice(0, 52);
1488
+ for (let u = 0; u < U; u++) { cardU[cfill[uA[u]]++] = u; cardU[cfill[uB[u]]++] = u; }
1489
+
1490
+ const useCount = nH >= 16 && nV >= 16;
1491
+ let pHa = null, pVa = null, P = 0, cardTotal = null;
1323
1492
  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;
1493
+ // cardTotal[c] = number of villain entries holding card c.
1494
+ cardTotal = new Int32Array(52);
1495
+ for (let u = 0; u < U; u++) { cardTotal[uA[u]] += vMult[u]; cardTotal[uB[u]] += vMult[u]; }
1496
+ } else {
1497
+ const ph = [], pv = [];
1498
+ for (const h of heroU) {
1499
+ const hl = uLoArr[h], hh = uHiArr[h];
1500
+ for (const v of villU) {
1501
+ if ((hl & uLoArr[v]) | (hh & uHiArr[v])) continue;
1502
+ ph.push(h); pv.push(v);
1503
+ }
1330
1504
  }
1331
- confList = Int32Array.from(confListArr);
1505
+ pHa = Int32Array.from(ph); pVa = Int32Array.from(pv); P = pHa.length;
1332
1506
  }
1333
- return { hSuit, vSuit, pHa, pVa, confList, confOff, useCount, bb0: bb[0], bb1: bb[1], bb2: bb[2], bb3: bb[3], H, V, P };
1507
+ return {
1508
+ uSuit, uA, uB, hMult, vMult, U, nH, nV, useCount, pHa, pVa, P, cardTotal, cardUOff, cardU,
1509
+ bb0: bb[0], bb1: bb[1], bb2: bb[2], bb3: bb[3]
1510
+ };
1334
1511
  }
1335
1512
 
1336
1513
  /**
1337
1514
  * Exact exhaustive kernel over runouts [runStart, runEnd). Evaluates each
1338
1515
  * 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.
1516
+ * comparison (small fields) or, for large fields, a single sort of the
1517
+ * unique hands followed by a linear sweep over equal-value groups that keeps
1518
+ * running villain counts in total and per hole card (to subtract villains
1519
+ * sharing a card with the hero). Byte-identical to unoptimized.
1341
1520
  * @private
1342
1521
  */
1343
1522
  _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;
1523
+ const { uSuit, uA, uB, hMult, vMult, U, nV, useCount, pHa, pVa, P, cardTotal, cardUOff, cardU, bb0, bb1, bb2, bb3 } = work;
1524
+ const uVals = new Int32Array(U);
1525
+ // dead[u] === i <=> hand u holds a card dealt in runout i, so it takes no
1526
+ // part in that runout (neither as hero nor as villain).
1527
+ const dead = new Int32Array(U).fill(-1);
1528
+ const cardTot = useCount ? new Int32Array(52) : null;
1347
1529
  let win = 0, tie = 0, lose = 0;
1530
+
1531
+ // Large-field scratch: sort keys (value*U + u, exact in a double) and
1532
+ // per-card villain counts strictly below / equal to the current value.
1533
+ const keys = useCount ? new Float64Array(U) : null;
1534
+ const order = useCount ? new Int32Array(U) : null;
1535
+ const lessC = useCount ? new Int32Array(52) : null;
1536
+ const eqC = useCount ? new Int32Array(52) : null;
1537
+
1348
1538
  for (let i = runStart; i < runEnd; i++) {
1349
1539
  const b = i << 2;
1350
1540
  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;
1541
+ for (let u = 0; u < U; u++) { const q = u << 2; uVals[u] = this._eval7(uSuit[q] | d0, uSuit[q + 1] | d1, uSuit[q + 2] | d2, uSuit[q + 3] | d3); }
1542
+
1543
+ // Mark hands holding a runout card; for counting, also remove those
1544
+ // villains from the field total and the per-card totals.
1545
+ let nVi = nV;
1546
+ if (useCount) cardTot.set(cardTotal);
1547
+ for (let s = 0; s < 4; s++) {
1548
+ let m = comboSuits[b + s];
1549
+ while (m) {
1550
+ const low = m & -m; m ^= low;
1551
+ const c = (31 - Math.clz32(low)) * 4 + s;
1552
+ for (let k = cardUOff[c], e = cardUOff[c + 1]; k < e; k++) {
1553
+ const u = cardU[k];
1554
+ if (dead[u] === i) continue;
1555
+ dead[u] = i;
1556
+ if (useCount) { const vm = vMult[u]; if (vm) { nVi -= vm; cardTot[uA[u]] -= vm; cardTot[uB[u]] -= vm; } }
1557
+ }
1363
1558
  }
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++; }
1559
+ }
1560
+
1561
+ if (!useCount) {
1562
+ for (let p = 0; p < P; p++) {
1563
+ const hu = pHa[p], vu = pVa[p];
1564
+ if (dead[hu] === i || dead[vu] === i) continue;
1565
+ const hv = uVals[hu], vv = uVals[vu]; if (hv > vv) win++; else if (vv > hv) lose++; else tie++;
1566
+ }
1567
+ continue;
1568
+ }
1569
+
1570
+ for (let u = 0; u < U; u++) keys[u] = uVals[u] * U + u;
1571
+ keys.sort();
1572
+ for (let k = 0; k < U; k++) { const key = keys[k]; order[k] = key - Math.floor(key / U) * U; }
1573
+ lessC.fill(0);
1574
+
1575
+ let below = 0; // villain entries with value < current group value
1576
+ for (let g = 0; g < U;) {
1577
+ const x = uVals[order[g]];
1578
+ let e = g + 1; while (e < U && uVals[order[e]] === x) e++;
1579
+ // Villain counts at exactly x: total and per card.
1580
+ let eqTotal = 0;
1581
+ for (let k = g; k < e; k++) { const u = order[k]; if (dead[u] === i) continue; const m = vMult[u]; if (m) { eqTotal += m; eqC[uA[u]] += m; eqC[uB[u]] += m; } }
1582
+ const above = nVi - below - eqTotal;
1583
+ // Heroes at x. A villain holding both hero cards (the identical hand)
1584
+ // is subtracted on each card, so add it back once (it ties at x).
1585
+ for (let k = g; k < e; k++) {
1586
+ const u = order[k], m = hMult[u];
1587
+ if (!m || dead[u] === i) continue;
1588
+ const a = uA[u], c = uB[u];
1589
+ const w = below - lessC[a] - lessC[c];
1590
+ const t = eqTotal - eqC[a] - eqC[c] + vMult[u];
1591
+ const l = above - (cardTot[a] - lessC[a] - eqC[a]) - (cardTot[c] - lessC[c] - eqC[c]);
1592
+ win += w * m; tie += t * m; lose += l * m;
1593
+ }
1594
+ // Fold this group into the running "below" counts.
1595
+ for (let k = g; k < e; k++) { const u = order[k]; if (dead[u] === i) continue; const m = vMult[u]; if (m) { lessC[uA[u]] += m; lessC[uB[u]] += m; eqC[uA[u]] = 0; eqC[uB[u]] = 0; } }
1596
+ below += eqTotal;
1597
+ g = e;
1366
1598
  }
1367
1599
  }
1368
1600
  return { win, tie, lose };
@@ -1386,86 +1618,37 @@ class BitVal {
1386
1618
  return await this._compareRangeOptimizedSequential(setup, progressCallback, progressInterval);
1387
1619
  }
1388
1620
 
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++;
1421
- }
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)
1621
+ // Group validMatchups by canonical key pair (only matchups that exist).
1622
+ const matchupsToEvaluate = this._groupMonteCarloMatchups(setup);
1440
1623
  const totalMatchups = matchupsToEvaluate.length;
1441
-
1624
+
1442
1625
  // 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
1626
+ const shouldUseWorkers = useWorkers &&
1627
+ typeof Worker !== 'undefined' &&
1628
+ totalMatchups >= 4;
1629
+ let workers = null;
1630
+ if (shouldUseWorkers) {
1631
+ const numWorkers = Math.min((typeof navigator !== 'undefined' && navigator.hardwareConcurrency) || 4, totalMatchups);
1632
+ // Persistent pool (created once, reused across calls). Creation can throw
1633
+ // synchronously (e.g. a cross-origin worker URL); fall back to sequential.
1634
+ try { workers = this._acquireWorkers(numWorkers); } catch (e) { workers = null; }
1635
+ }
1636
+ if (!workers || workers.length === 0) {
1449
1637
  return await this._compareRangeOptimizedSequential(setup, progressCallback, progressInterval);
1450
1638
  }
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
- }
1639
+ const numWorkers = workers.length;
1459
1640
 
1460
- // Serialize setup for workers
1641
+ // Dynamic dispatch: hand out small chunks to whichever worker is free.
1642
+ // With Monte Carlo early-stop, matchup cost varies a lot (lopsided matchups
1643
+ // converge in ~8k draws, close ones run the full count), so equal static
1644
+ // batches left most workers idle behind the slowest one.
1645
+ const chunkSize = Math.max(1, Math.ceil(totalMatchups / (numWorkers * 8)));
1461
1646
  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;
1647
+ let next = 0, completedMatchups = 0, win = 0, tie = 0, lose = 0, alive = numWorkers;
1648
+ const addResult = (r) => {
1649
+ win += r.matchupWin * r.validCount; tie += r.matchupTie * r.validCount; lose += r.matchupLose * r.validCount;
1650
+ completedMatchups++;
1651
+ };
1469
1652
 
1470
1653
  // Progress tracking
1471
1654
  let lastProgressTime = 0;
@@ -1478,67 +1661,50 @@ class BitVal {
1478
1661
  }
1479
1662
  }, progressUpdateInterval) : null;
1480
1663
 
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'));
1664
+ // Each worker loop pulls the next chunk until none are left. A worker that
1665
+ // crashes is dropped from the pool and its in-flight chunk is evaluated on
1666
+ // the main thread, so one bad worker never loses work or fails the call.
1667
+ const runWorker = (worker, workerId) => new Promise((resolve, reject) => {
1668
+ let current = null;
1669
+ const cleanup = () => { worker.onmessage = null; worker.onerror = null; };
1670
+ const dispatch = () => {
1671
+ if (next >= totalMatchups) { cleanup(); resolve(); return; }
1672
+ const start = next; next = Math.min(totalMatchups, next + chunkSize);
1673
+ current = matchupsToEvaluate.slice(start, next);
1674
+ worker.postMessage({ matchups: current.map(m => this._serializeMatchup(m)), setupData: serializedSetup, workerId });
1675
+ };
1676
+ // One handler set per call; cleared on settle so the pooled worker is
1677
+ // clean for the next call. (Assumes compareRange calls don't overlap.)
1678
+ worker.onmessage = (e) => {
1679
+ if (!e.data.success) { cleanup(); reject(new Error(e.data.error || 'Worker error')); return; }
1680
+ for (const r of e.data.results) addResult(r);
1681
+ current = null;
1682
+ dispatch();
1683
+ };
1684
+ worker.onerror = (error) => {
1685
+ if (error && error.preventDefault) error.preventDefault();
1686
+ cleanup();
1687
+ this._discardWorker(worker);
1688
+ try {
1689
+ if (current) for (const m of current) addResult({ validCount: m.validCount, ...this._evaluateMatchupSync(m.heroMask, m.villainMask, setup) });
1690
+ current = null;
1691
+ // Last worker of this call died: finish the remaining chunks here.
1692
+ if (--alive === 0) {
1693
+ while (next < totalMatchups) { const m = matchupsToEvaluate[next++]; addResult({ validCount: m.validCount, ...this._evaluateMatchupSync(m.heroMask, m.villainMask, setup) }); }
1495
1694
  }
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));
1695
+ resolve();
1696
+ } catch (err) { reject(err); }
1697
+ };
1698
+ dispatch();
1699
+ });
1506
1700
 
1507
- worker.postMessage({
1508
- matchups: serializedBatch,
1509
- setupData: serializedSetup,
1510
- workerId: i
1511
- });
1512
- });
1513
-
1514
- promises.push(promise);
1515
- }
1516
-
1517
- // Wait for all workers and aggregate results
1518
- let win = 0, tie = 0, lose = 0;
1519
1701
  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
- }
1702
+ await Promise.all(workers.map((w, i) => runWorker(w, i)));
1529
1703
  } finally {
1530
- // Clean up progress interval
1531
- if (progressIntervalId) {
1532
- clearInterval(progressIntervalId);
1533
- }
1534
-
1704
+ if (progressIntervalId) clearInterval(progressIntervalId);
1535
1705
  // Workers are NOT terminated - they stay in the pool for the next call.
1536
1706
  // Callers can free them explicitly with terminateWorkerPool().
1537
-
1538
- // Final progress update
1539
- if (progressCallback) {
1540
- progressCallback(totalMatchups, totalMatchups, 'Complete');
1541
- }
1707
+ if (progressCallback) progressCallback(totalMatchups, totalMatchups, 'Complete');
1542
1708
  }
1543
1709
 
1544
1710
  return { win, tie, lose };
@@ -1553,7 +1719,7 @@ class BitVal {
1553
1719
  _acquireWorkers(count) {
1554
1720
  if (!this._workerPool) this._workerPool = [];
1555
1721
  while (this._workerPool.length < count) {
1556
- this._workerPool.push(new Worker('./bitval-worker.js'));
1722
+ this._workerPool.push(new Worker(this.workerUrl));
1557
1723
  }
1558
1724
  return this._workerPool.slice(0, count);
1559
1725
  }
@@ -1646,26 +1812,6 @@ class BitVal {
1646
1812
  }
1647
1813
  }
1648
1814
 
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
1815
  /**
1670
1816
  * Gets board suit counts for flush potential calculation.
1671
1817
  * @private
package/package.json CHANGED
@@ -1,15 +1,16 @@
1
1
  {
2
2
  "name": "poker-bitvaljs",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
4
  "description": "High-performance poker hand evaluator optimized for browser environments, using bitwise operations for rapid poker hand analysis and equity calculations.",
5
5
  "main": "bitval.js",
6
6
  "scripts": {
7
- "test": "node bench/correctness.js && node bench/determinism.js && node bench/exact-optimize.js 300 && node bench/eval-equivalence.js 1000000",
7
+ "test": "node bench/correctness.js && node bench/determinism.js && node bench/exact-optimize.js 300 && node bench/eval-equivalence.js 1000000 && node bench/simulate-equivalence.js",
8
8
  "test:full": "node bench/eval-equivalence.js --full && node bench/engine-differential.js && node bench/exact-optimize.js 2000",
9
9
  "test:equiv": "node bench/eval-equivalence.js",
10
10
  "test:exact": "node bench/exact-optimize.js",
11
11
  "test:differential": "node bench/engine-differential.js",
12
12
  "test:preflop": "node bench/preflop-validate.js",
13
+ "test:simulate": "node bench/simulate-equivalence.js",
13
14
  "gen:preflop": "node bench/gen-preflop-table.js --out bench/preflop-table.bin",
14
15
  "bench": "node bench/bench.js",
15
16
  "bench:correct": "node bench/correctness.js",
@@ -33,6 +34,7 @@
33
34
  "files": [
34
35
  "bitval.js",
35
36
  "bitval-worker.js",
37
+ "bench/preflop-table.bin",
36
38
  "README.md"
37
39
  ]
38
40
  }