poker-bitvaljs 1.2.0 → 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/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;
@@ -13,48 +26,10 @@ class XorShift32 {
13
26
  }
14
27
  }
15
28
 
16
- /**
17
- * Fastest auto-clearing cache with minimal overhead.
18
- * Uses bitwise counter check to avoid expensive modulo operations.
19
- */
20
- class FastestAutoClearingCache {
21
- constructor(maxSize = 16000000) {
22
- this.maxSize = maxSize;
23
- this.clearThreshold = Math.floor(maxSize * 0.95); // Clear at 95% capacity
24
- this.cache = new Map();
25
- this.counter = 0;
26
- }
27
-
28
- has(key) {
29
- return this.cache.has(key); // No overhead on hot path
30
- }
31
-
32
- get(key) {
33
- return this.cache.get(key); // No overhead on hot path
34
- }
35
-
36
- set(key, value) {
37
- // Bitwise check is faster than modulo - check every ~1 million operations
38
- // 0xFFFFF = 1,048,576 (2^20)
39
- if ((++this.counter & 0xFFFFF) === 0) {
40
- if (this.cache.size >= this.clearThreshold) {
41
- this.cache.clear();
42
- }
43
- }
44
- this.cache.set(key, value);
45
- }
46
-
47
- clear() {
48
- this.cache.clear();
49
- }
50
-
51
- get size() {
52
- return this.cache.size;
53
- }
54
- }
55
-
56
29
  class BitVal {
57
30
  constructor() {
31
+ this.workerUrl = _BITVAL_WORKER_URL;
32
+
58
33
  // Suit constants
59
34
  this._DIAMOND = 3n;
60
35
  this._HEART = 2n;
@@ -183,6 +158,64 @@ class BitVal {
183
158
  4503599627370496n: 67553994410557455n,
184
159
  1n: 67553994410557455n
185
160
  };
161
+
162
+ // ---- BigInt-free fast path (Number evaluator) lookups ----
163
+ // Rank index 0..12 = 2..A; suit index 0..3. A card set is four 13-bit rank
164
+ // masks (one per suit); evaluation and set-union run entirely on Numbers.
165
+ this._N_RANK = { '2': 0, '3': 1, '4': 2, '5': 3, '6': 4, '7': 5, '8': 6, '9': 7, 'T': 8, 'J': 9, 'Q': 10, 'K': 11, 'A': 12 };
166
+ this._N_SUIT = { s: 0, h: 1, d: 2, c: 3 };
167
+ this.CARD_SUITS = {}; // 'As' -> { suit, bit }
168
+ this._MASK_TO_SUIT = new Map(); // single-card BigInt mask -> { suit, bit }
169
+ for (const card in this.CARD_MASKS) {
170
+ const sr = { suit: this._N_SUIT[card[1]], bit: 1 << this._N_RANK[card[0]] };
171
+ this.CARD_SUITS[card] = sr;
172
+ this._MASK_TO_SUIT.set(this.CARD_MASKS[card], sr);
173
+ }
174
+
175
+ // 13-bit rank-mask lookup tables for the fast evaluator. Each is 8192
176
+ // entries (~8-16 KB total), built once here, cache-resident at runtime.
177
+ // _POPC13[m] = number of set bits (suit length / rank count).
178
+ // _STRAIGHT13[m] = top rank (4..12) of the best straight, 3 for the wheel,
179
+ // or -1 for none. Replaces the per-eval straight scan loop.
180
+ this._POPC13 = new Uint8Array(8192);
181
+ this._STRAIGHT13 = new Int8Array(8192);
182
+ for (let m = 0; m < 8192; m++) {
183
+ let c = 0, x = m;
184
+ while (x) { x &= x - 1; c++; }
185
+ this._POPC13[m] = c;
186
+ let run = 0, top = -1;
187
+ for (let r = 12; r >= 0; r--) {
188
+ if (m & (1 << r)) { if (++run >= 5) { top = r + 4; break; } } else run = 0;
189
+ }
190
+ if (top < 0 && (m & (1 << 12)) && (m & 0b1111) === 0b1111) top = 3; // wheel
191
+ this._STRAIGHT13[m] = top;
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
+ }
214
+
215
+ // Preflop exact-equity table (loaded on demand via loadPreflopTable). The 24
216
+ // suit permutations canonicalize a matchup for lookup (card index = rank*4+suit).
217
+ this._pfMap = null; this._pfWin = null; this._pfTie = null; this._pfTotal = 0;
218
+ 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]); })();
186
219
  }
187
220
 
188
221
  // ============================================
@@ -241,6 +274,162 @@ class BitVal {
241
274
  return [this.stripBits(this.normalize(hand), 5), null];
242
275
  }
243
276
 
277
+ // ============================================
278
+ // FAST PATH - BigInt-free (Number) evaluator
279
+ // ============================================
280
+
281
+ /**
282
+ * Population count of a 13-bit rank mask.
283
+ * @private
284
+ */
285
+ _popc(m) { return this._POPC13[m]; }
286
+
287
+ /**
288
+ * Index (0..12) of the highest set bit of a non-zero 13-bit rank mask.
289
+ * @private
290
+ */
291
+ _high(m) { return 31 - Math.clz32(m); }
292
+
293
+ /**
294
+ * Top rank (4..12) of the best 5-card straight in a 13-bit rank mask, or -1.
295
+ * Handles the A-2-3-4-5 wheel (returns 3, i.e. a five-high straight).
296
+ * @private
297
+ */
298
+ _straightTop(m) { return this._STRAIGHT13[m]; }
299
+
300
+ /**
301
+ * Builds the four 13-bit suit rank masks for an array of card strings.
302
+ * @private
303
+ */
304
+ _suitsOfCards(cards) {
305
+ const suits = [0, 0, 0, 0];
306
+ for (let i = 0; i < cards.length; i++) {
307
+ const sr = this.CARD_SUITS[cards[i]];
308
+ suits[sr.suit] |= sr.bit;
309
+ }
310
+ return suits;
311
+ }
312
+
313
+ /**
314
+ * Decomposes a BigInt card-set mask into four 13-bit suit rank masks.
315
+ * Used once per matchup / combo, never in the innermost loop.
316
+ * @private
317
+ */
318
+ _suitsFromBigMask(bigMask) {
319
+ const suits = [0, 0, 0, 0];
320
+ for (const [mask, sr] of this._MASK_TO_SUIT) {
321
+ if ((bigMask & mask) === mask) suits[sr.suit] |= sr.bit;
322
+ }
323
+ return suits;
324
+ }
325
+
326
+ /**
327
+ * Available cards (as {suit, bit}) not present in the given BigInt dead mask.
328
+ * Built once per matchup for the Monte Carlo dealing loop.
329
+ * @private
330
+ */
331
+ _availableSuitCards(deadMask) {
332
+ const out = [];
333
+ for (const [mask, sr] of this._MASK_TO_SUIT) {
334
+ if ((deadMask & mask) === 0n) out.push((sr.suit << 16) | sr.bit);
335
+ }
336
+ return Int32Array.from(out); // packed: suit in bits 16-17, rank bit in low 13
337
+ }
338
+
339
+ /**
340
+ * Flattens a BigInt combo array into a single Int32Array of suit rank masks
341
+ * (4 entries per combo: s, h, d, c). One contiguous buffer instead of N small
342
+ * arrays — better cache locality, no per-combo GC. Cached on `setup`.
343
+ * @private
344
+ */
345
+ _buildComboSuits(comboArray) {
346
+ const out = new Int32Array(comboArray.length * 4);
347
+ for (let i = 0; i < comboArray.length; i++) {
348
+ const s = this._suitsFromBigMask(comboArray[i]);
349
+ const b = i * 4;
350
+ out[b] = s[0]; out[b + 1] = s[1]; out[b + 2] = s[2]; out[b + 3] = s[3];
351
+ }
352
+ return out;
353
+ }
354
+
355
+ /**
356
+ * Evaluates a 5-7 card hand given its four 13-bit suit rank masks and returns
357
+ * a single Number whose ordering is identical to `evaluate()` (score+kickers).
358
+ * Higher = stronger. No BigInt, no allocation.
359
+ * @private
360
+ */
361
+ _eval7(s, h, d, c) {
362
+ const all = s | h | d | c;
363
+ const POPC = this._POPC13, STR = this._STRAIGHT13;
364
+
365
+ // Flush suit (>=5 of one suit), reused for straight-flush and flush.
366
+ let fm = 0;
367
+ if (POPC[s] >= 5) fm = s;
368
+ else if (POPC[h] >= 5) fm = h;
369
+ else if (POPC[d] >= 5) fm = d;
370
+ else if (POPC[c] >= 5) fm = c;
371
+
372
+ // Straight flush
373
+ if (fm) {
374
+ const t = STR[fm];
375
+ if (t >= 0) return 8 * 1048576 + t * 65536;
376
+ }
377
+
378
+ // Per-rank suit-count masks (exact via boolean suit combinations)
379
+ const four = s & h & d & c;
380
+ const threePlus = (s & h & d) | (s & h & c) | (s & d & c) | (h & d & c);
381
+ const twoPlus = (s & h) | (s & d) | (s & c) | (h & d) | (h & c) | (d & c);
382
+ const trips = threePlus & ~four; // exactly three
383
+ const pairs = twoPlus & ~threePlus; // exactly two
384
+
385
+ // Quads
386
+ if (four) {
387
+ const q = this._high(four);
388
+ const k = this._high(all & ~(1 << q));
389
+ return 7 * 1048576 + q * 65536 + k * 4096;
390
+ }
391
+
392
+ // Full house (a trip plus another trip or a pair)
393
+ if (trips) {
394
+ const t1 = this._high(trips);
395
+ const restTrips = trips & ~(1 << t1);
396
+ const p = restTrips ? this._high(restTrips) : (pairs ? this._high(pairs) : -1);
397
+ if (p >= 0) return 6 * 1048576 + t1 * 65536 + p * 4096;
398
+ }
399
+
400
+ // Flush
401
+ if (fm) return 5 * 1048576 + this._TOP5V13[fm];
402
+
403
+ // Straight
404
+ {
405
+ const t = STR[all];
406
+ if (t >= 0) return 4 * 1048576 + t * 65536;
407
+ }
408
+
409
+ // Trips
410
+ if (trips) {
411
+ const t = this._high(trips);
412
+ return 3 * 1048576 + t * 65536 + (this._TOP5V13[this._KEEP2_13[all & ~(1 << t)]] >> 4);
413
+ }
414
+
415
+ // Two pair
416
+ if (pairs && (pairs & (pairs - 1))) {
417
+ const p1 = this._high(pairs);
418
+ const p2 = this._high(pairs & ~(1 << p1));
419
+ const k = this._high(all & ~(1 << p1) & ~(1 << p2));
420
+ return 2 * 1048576 + p1 * 65536 + p2 * 4096 + k * 256;
421
+ }
422
+
423
+ // One pair
424
+ if (pairs) {
425
+ const p = this._high(pairs);
426
+ return 1 * 1048576 + p * 65536 + (this._TOP5V13[this._KEEP3_13[all & ~(1 << p)]] >> 4);
427
+ }
428
+
429
+ // High card
430
+ return this._TOP5V13[all];
431
+ }
432
+
244
433
  /**
245
434
  * Gets the human-readable hand strength from an evaluation mask.
246
435
  * @param {BigInt} handMask - Evaluation mask with score bits
@@ -285,28 +474,24 @@ class BitVal {
285
474
  * @returns {Object} { win, tie, lose, ...handStrengths } - Results object
286
475
  */
287
476
  simulate(iterations, numberOfBoardCards = 5, hero = [], villain = [], board = [], deadCards = []) {
288
- let numberOfCardsToDeal = (numberOfBoardCards + 2) - (hero.length + board.length);
289
- this.xorShift = new XorShift32();
477
+ const numberOfCardsToDeal = numberOfBoardCards - board.length;
478
+ const xorShift = this.xorShift = new XorShift32();
290
479
 
291
480
  const allDeadCards = [...hero, ...villain, ...board, ...deadCards];
292
- deadCards = this.getBitMasked(allDeadCards);
293
- numberOfCardsToDeal = numberOfBoardCards - board.length;
294
- hero = this.getBitMasked(hero);
295
- villain = this.getBitMasked(villain);
296
- board = this.getBitMasked(board);
481
+ const deadMask = this.getBitMasked(allDeadCards);
297
482
 
298
483
  const availableCards = 52 - new Set(allDeadCards).size;
299
484
  const exhaustiveCombinations = this.combinations(availableCards, numberOfCardsToDeal);
300
- iterations = Math.min(iterations, exhaustiveCombinations);
301
485
 
302
- const isExhaustive = iterations === exhaustiveCombinations && numberOfCardsToDeal > 0 && numberOfCardsToDeal <= 2;
303
- let comboArray = null;
304
- if (isExhaustive) {
305
- const availableMasks = this._getAvailableCardMasksByLookUp(deadCards);
306
- comboArray = this._getCombinations(availableMasks, numberOfCardsToDeal);
307
- }
486
+ // For <=2 cards to come the full runout space is tiny (<=1326), so always
487
+ // enumerate it exactly instead of Monte-Carloing a low iteration count.
488
+ // 0 cards to come (a complete board) is also exhaustive: exactly one runout.
489
+ // Exhaustive is exact and deterministic; the iteration count only matters
490
+ // for larger (>=3 cards to come) scenarios.
491
+ const isExhaustive = numberOfCardsToDeal >= 0 && numberOfCardsToDeal <= 2;
492
+ iterations = isExhaustive ? exhaustiveCombinations : Math.min(iterations, exhaustiveCombinations);
308
493
 
309
- let result = {
494
+ let result = {
310
495
  "win": 0,
311
496
  "tie": 0,
312
497
  "lose": 0,
@@ -321,34 +506,44 @@ class BitVal {
321
506
  "Straight Flush": 0
322
507
  };
323
508
 
324
- for (let i = 0; i < iterations; i++) {
325
- let _board = isExhaustive && comboArray
326
- ? board | comboArray[i]
327
- : this.deal(board, deadCards, numberOfCardsToDeal) | board;
328
-
329
- let [hero_eval, hero_tiebreaker] = this.evaluate(hero | _board);
330
- 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;
331
515
 
332
- if (hero_eval > villain_eval) {
333
- result["win"]++;
334
- continue;
335
- }
336
- if (hero_eval < villain_eval) {
337
- result["lose"]++;
338
- continue;
339
- }
340
- if (hero_tiebreaker > villain_tiebreaker) {
341
- result["win"]++;
342
- 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++;
343
524
  }
344
- if (hero_tiebreaker < villain_tiebreaker) {
345
- result["lose"]++;
346
- 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++;
347
542
  }
348
-
349
- result["tie"]++
543
+ xorShift.state = st;
350
544
  }
351
-
545
+
546
+ result.win = win; result.tie = tie; result.lose = lose;
352
547
  return result;
353
548
  }
354
549
 
@@ -368,13 +563,99 @@ class BitVal {
368
563
  * @param {Function} progressCallback - Optional progress callback (current, total, message)
369
564
  * @param {Number} progressInterval - Update progress every N matchups (default: 100)
370
565
  * @param {Boolean} useWorkers - Use Web Workers for parallelization (default: true)
566
+ * @param {Number} mcTargetPct - Monte Carlo early-stop target: stop sampling a
567
+ * matchup once its 95% CI half-width is within this many equity percentage
568
+ * points (default: 0.3). Pass 0 to force the full `iterations`. Only affects
569
+ * Monte Carlo (preflop); the exact flop/turn and preflop-table paths ignore it.
371
570
  * @returns {Promise<Object>} { win, tie, lose } - Results object
372
571
  */
373
- async compareRange(heroHands, villainHands, boardCards = [], deadCards = [], numberOfBoardCards = 5, iterations = 10000, optimize = true, progressCallback = null, progressInterval = 100, useWorkers = true) {
572
+ async compareRange(heroHands, villainHands, boardCards = [], deadCards = [], numberOfBoardCards = 5, iterations = 10000, optimize = true, progressCallback = null, progressInterval = 100, useWorkers = true, mcTargetPct = 0.3) {
573
+ // Preflop exact fast path: if the preflop equity table is loaded and this is
574
+ // a full-board preflop query with no dead cards, look up every matchup's
575
+ // exact equity instead of Monte Carlo. Instant and exact.
576
+ if (this._pfMap && boardCards.length === 0 && deadCards.length === 0 && numberOfBoardCards === 5) {
577
+ const r = this._compareRangePreflopExact(heroHands, villainHands);
578
+ if (progressCallback) progressCallback(1, 1, 'Complete');
579
+ return r;
580
+ }
374
581
  const setup = this._compareRangeSetup(heroHands, villainHands, boardCards, deadCards, numberOfBoardCards, iterations, optimize);
582
+ // Monte Carlo early-stop target (95% CI half-width, in equity percentage
583
+ // points). Default on; pass 0 to force full `iterations`. Ignored by the
584
+ // exact (exhaustive) path. Only affects the remaining MC case (preflop with
585
+ // dead cards, or preflop with no table loaded).
586
+ setup.mcTarget = mcTargetPct;
375
587
  return optimize ? await this._compareRangeOptimized(setup, progressCallback, progressInterval, useWorkers) : await this._compareRangeUnoptimized(heroHands, villainHands, setup, progressCallback, progressInterval);
376
588
  }
377
589
 
590
+ /**
591
+ * Loads the exact preflop equity table (produced by bench/gen-preflop-table.js).
592
+ * Accepts an ArrayBuffer (browser fetch) or Node Buffer. Enables the preflop
593
+ * fast path in compareRange.
594
+ * @param {ArrayBuffer|Uint8Array|Buffer} buffer
595
+ * @returns {Number} number of canonical matchups loaded
596
+ */
597
+ loadPreflopTable(buffer) {
598
+ const ab = buffer instanceof ArrayBuffer ? buffer : buffer.buffer;
599
+ const off = buffer instanceof ArrayBuffer ? 0 : (buffer.byteOffset || 0);
600
+ const view = new DataView(ab, off, buffer.byteLength !== undefined ? buffer.byteLength : ab.byteLength);
601
+ if (view.getUint32(0, true) !== 0x50464551) throw new Error('invalid preflop table (bad magic)');
602
+ const count = view.getUint32(4, true);
603
+ this._pfTotal = view.getUint32(8, true);
604
+ this._pfWin = new Uint32Array(count);
605
+ this._pfTie = new Uint32Array(count);
606
+ this._pfMap = new Map();
607
+ for (let i = 0; i < count; i++) {
608
+ const o = 12 + i * 12;
609
+ this._pfMap.set(view.getUint32(o, true), i);
610
+ this._pfWin[i] = view.getUint32(o + 4, true);
611
+ this._pfTie[i] = view.getUint32(o + 8, true);
612
+ }
613
+ return count;
614
+ }
615
+
616
+ /** Card index (0..51) = rankIndex*4 + suitIndex, for a 2-char card string. @private */
617
+ _cardIndex(card) { return this._N_RANK[card[0]] * 4 + this._N_SUIT[card[1]]; }
618
+
619
+ /** Suit-isomorphic canonical key of a hero-vs-villain matchup (card indices). @private */
620
+ _preflopCanonKey(h1, h2, v1, v2) {
621
+ let best = Infinity;
622
+ const S4 = this._S4;
623
+ for (let pi = 0; pi < 24; pi++) {
624
+ const p = S4[pi];
625
+ const mh1 = (h1 & ~3) | p[h1 & 3], mh2 = (h2 & ~3) | p[h2 & 3];
626
+ const mv1 = (v1 & ~3) | p[v1 & 3], mv2 = (v2 & ~3) | p[v2 & 3];
627
+ const a = mh1 < mh2 ? mh1 : mh2, b = mh1 < mh2 ? mh2 : mh1;
628
+ const c = mv1 < mv2 ? mv1 : mv2, d = mv1 < mv2 ? mv2 : mv1;
629
+ const key = ((a * 52 + b) * 52 + c) * 52 + d;
630
+ if (key < best) best = key;
631
+ }
632
+ return best;
633
+ }
634
+
635
+ /**
636
+ * Exact preflop range-vs-range via the loaded table: sum each valid (hero,
637
+ * villain) matchup's exact win/tie/lose over all C(48,5) boards.
638
+ * @private
639
+ */
640
+ _compareRangePreflopExact(heroHands, villainHands) {
641
+ const total = this._pfTotal;
642
+ const heroC = [], villC = [];
643
+ for (const h of heroHands) heroC.push(this._cardIndex(h.slice(0, 2)), this._cardIndex(h.slice(2, 4)));
644
+ for (const v of villainHands) villC.push(this._cardIndex(v.slice(0, 2)), this._cardIndex(v.slice(2, 4)));
645
+ let win = 0, tie = 0, lose = 0;
646
+ for (let hi = 0; hi < heroC.length; hi += 2) {
647
+ const h1 = heroC[hi], h2 = heroC[hi + 1];
648
+ for (let vi = 0; vi < villC.length; vi += 2) {
649
+ const v1 = villC[vi], v2 = villC[vi + 1];
650
+ if (h1 === v1 || h1 === v2 || h2 === v1 || h2 === v2) continue; // conflicting hole cards
651
+ const idx = this._pfMap.get(this._preflopCanonKey(h1, h2, v1, v2));
652
+ const w = this._pfWin[idx], t = this._pfTie[idx];
653
+ win += w; tie += t; lose += total - w - t;
654
+ }
655
+ }
656
+ return { win, tie, lose };
657
+ }
658
+
378
659
  // ============================================
379
660
  // PUBLIC API - Utility Methods
380
661
  // ============================================
@@ -629,95 +910,103 @@ class BitVal {
629
910
  // PRIVATE - Range Comparison Helpers
630
911
  // ============================================
631
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
+
632
936
  /**
633
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.
634
939
  * @private
635
940
  */
636
941
  _generateValidMatchups(heroHands, villainHands, boardCards = []) {
637
942
  const valid = [];
638
- 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
+ }
639
952
  for (const h of heroHands) {
953
+ const p = this._parseHand(h);
954
+ if ((p.lo & board.lo) | (p.hi & board.hi)) continue;
640
955
  const hMask = this.getBitMasked(this._handStringToCards(h));
641
- if (boardMask && (hMask & boardMask) !== 0n) continue;
642
- for (const v of villainHands) {
643
- const vMask = this.getBitMasked(this._handStringToCards(v));
644
- if ((vMask & boardMask) !== 0n || (hMask & vMask) !== 0n) continue;
645
- 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] });
646
959
  }
647
960
  }
648
961
  return valid;
649
962
  }
650
963
 
651
964
  /**
652
- * Calculates valid matchup counts per canonical key pair.
653
- * @private
654
- */
655
- _calculateValidCounts(validMatchups, hCanon, vCanon, boardCards, numberOfBoardCards) {
656
- const counts = new Map();
657
- const boardSuitCounts = boardCards.length > 0 ? this._getBoardSuitCounts(boardCards) : null;
658
- for (const { heroHand, villainHand } of validMatchups) {
659
- const hKey = this._getCanonicalKey(heroHand, boardCards, numberOfBoardCards, boardSuitCounts);
660
- const vKey = this._getCanonicalKey(villainHand, boardCards, numberOfBoardCards, boardSuitCounts);
661
- const key = `${hKey}:${vKey}`;
662
- counts.set(key, (counts.get(key) || 0) + 1);
663
- }
664
- return counts;
665
- }
666
-
667
- /**
668
- * 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).
669
969
  * @private
670
970
  */
671
971
  _compareRangeSetup(heroHands, villainHands, boardCards, deadCards, numberOfBoardCards, iterations, optimize) {
672
- // Generate valid matchups (always needed)
673
- const validMatchups = this._generateValidMatchups(heroHands, villainHands, boardCards);
674
-
675
- // Calculate canonical mappings only if optimization is enabled
676
- let hCanon = null;
677
- let vCanon = null;
678
- let validCounts = null;
679
- if (optimize) {
680
- hCanon = this._flattenHandsToCanonical(heroHands, boardCards, numberOfBoardCards);
681
- vCanon = this._flattenHandsToCanonical(villainHands, boardCards, numberOfBoardCards);
682
- validCounts = this._calculateValidCounts(validMatchups, hCanon, vCanon, boardCards, numberOfBoardCards);
683
- }
684
-
685
972
  // Calculate bitmasks
686
973
  const boardMask = this.getBitMasked(boardCards);
687
974
  const deadCardsMask = this.getBitMasked([...boardCards, ...deadCards]);
688
-
689
- // Calculate cards to deal and determine if exhaustive enumeration is possible
975
+
976
+ // Calculate cards to deal and determine if exhaustive enumeration is possible.
977
+ // 0 cards to come (a complete board) is exhaustive too: exactly one runout.
690
978
  const numberOfCardsToDeal = numberOfBoardCards - boardCards.length;
691
- const canBeExhaustive = numberOfCardsToDeal > 0 && numberOfCardsToDeal <= 2;
692
-
693
- // Calculate available cards using bitmask (more efficient than Set)
694
- const availableCards = 52 - this.countBits(deadCardsMask);
695
-
696
- // Determine if we should use exhaustive enumeration
697
- const exhaustiveCombinations = canBeExhaustive
698
- ? this.combinations(availableCards, numberOfCardsToDeal)
699
- : Infinity;
700
- iterations = Math.min(iterations, exhaustiveCombinations);
701
- const isExhaustive = canBeExhaustive && iterations === exhaustiveCombinations && exhaustiveCombinations < Infinity;
702
-
979
+ const isExhaustive = numberOfCardsToDeal >= 0 && numberOfCardsToDeal <= 2;
980
+
981
+ // For <=2 cards to come the full runout space is tiny (<=1326), so always
982
+ // enumerate it exactly instead of Monte-Carloing a lower requested count.
983
+ // Exhaustive is exact and deterministic; the requested iteration count only
984
+ // matters for larger (>=3 cards to come, e.g. preflop) scenarios.
985
+ if (isExhaustive) {
986
+ iterations = this.combinations(52 - this.countBits(deadCardsMask), numberOfCardsToDeal);
987
+ }
988
+
703
989
  // Generate combo array only if exhaustive
704
- const comboArray = isExhaustive
705
- ? this._getCombinations(this._getAvailableCardMasksByLookUp(deadCardsMask), numberOfCardsToDeal)
990
+ const comboArray = isExhaustive
991
+ ? this._getCombinations(this._getAvailableCardMasksByLookUp(deadCardsMask), numberOfCardsToDeal)
706
992
  : null;
707
-
708
- return {
709
- hCanon,
710
- vCanon,
711
- boardMask,
712
- deadCardsMask,
713
- iterations,
714
- isExhaustive,
715
- comboArray,
716
- numberOfCardsToDeal,
717
- validMatchups,
718
- validCounts,
719
- boardCards,
720
- numberOfBoardCards
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
+ }
721
1010
  };
722
1011
  }
723
1012
 
@@ -735,9 +1024,10 @@ class BitVal {
735
1024
  numberOfBoardCards: setup.numberOfBoardCards,
736
1025
  iterations: setup.iterations,
737
1026
  isExhaustive: setup.isExhaustive,
738
- numberOfCardsToDeal: setup.numberOfCardsToDeal
739
- // Note: hCanon, vCanon, validMatchups, validCounts are not needed in workers
740
- // as matchups are pre-serialized and sent separately
1027
+ numberOfCardsToDeal: setup.numberOfCardsToDeal,
1028
+ mcTarget: setup.mcTarget
1029
+ // Note: validMatchups is not needed in workers; matchups are
1030
+ // pre-serialized and sent separately
741
1031
  };
742
1032
  }
743
1033
 
@@ -783,55 +1073,158 @@ class BitVal {
783
1073
  * Optimized for browser performance with adaptive yielding and inlined hot loop.
784
1074
  * @private
785
1075
  */
786
- async _evaluateMatchup(heroMask, villainMask, setup, evalCache = null, progressCallback = null, matchupIndex = 0, totalMatchups = 1) {
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
+
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);
787
1146
  let win = 0, tie = 0, lose = 0;
788
1147
  const deadMask = heroMask | villainMask | setup.deadCardsMask;
789
- const matchupDeadMask = heroMask | villainMask; // Only matchup-level dead cards (excludes setup.deadCardsMask)
790
-
1148
+
791
1149
  // Initialize random number generator for Monte Carlo (not needed for exhaustive)
792
1150
  if (!setup.isExhaustive) {
793
1151
  this.xorShift = new XorShift32();
794
1152
  }
795
1153
 
796
- // Prepare for exhaustive enumeration if applicable
797
- let comboArray = null;
1154
+ // Prepare BigInt-free (Number) representations for the hot loop. The old
1155
+ // path built a BigInt board and called the BigInt evaluate() twice per
1156
+ // iteration; here cards are four 13-bit suit rank masks and _eval7 does the
1157
+ // work with no BigInt and no allocation. The board suit-masks and the combo
1158
+ // suit-masks are constant across matchups, so they're computed once and
1159
+ // cached on `setup`.
798
1160
  let iterations = setup.iterations;
1161
+ const heroSuits = this._suitsFromBigMask(heroMask);
1162
+ const villainSuits = this._suitsFromBigMask(villainMask);
1163
+ const boardBaseSuits = setup._boardSuits || (setup._boardSuits = this._suitsFromBigMask(setup.boardMask));
1164
+ const hs0 = heroSuits[0], hs1 = heroSuits[1], hs2 = heroSuits[2], hs3 = heroSuits[3];
1165
+ const vs0 = villainSuits[0], vs1 = villainSuits[1], vs2 = villainSuits[2], vs3 = villainSuits[3];
1166
+ const bb0 = boardBaseSuits[0], bb1 = boardBaseSuits[1], bb2 = boardBaseSuits[2], bb3 = boardBaseSuits[3];
1167
+
1168
+ let comboSuits = null; // flat Int32Array, 4 entries per combo
1169
+ let pool = null, poolLen = 0; // packed Int32Array of available cards
1170
+ const nDeal = setup.numberOfCardsToDeal;
799
1171
  if (setup.isExhaustive) {
800
- // Reuse pre-computed comboArray from setup (already computed in _compareRangeSetup)
801
- comboArray = setup.comboArray;
802
- iterations = comboArray.length;
1172
+ comboSuits = setup._comboSuits || (setup._comboSuits = this._buildComboSuits(setup.comboArray));
1173
+ iterations = comboSuits.length >> 2;
1174
+ } else {
1175
+ pool = this._availableSuitCards(deadMask);
1176
+ poolLen = pool.length;
803
1177
  }
804
-
805
- // Main evaluation loop - optimized with inlined functions and adaptive yielding
806
- const yieldInterval = 5000; // Increased from 1000 - reduces async overhead while maintaining responsiveness
1178
+
1179
+ // Main evaluation loop - adaptive yielding for UI responsiveness
1180
+ const yieldInterval = 5000;
807
1181
  let lastProgressTime = 0;
808
1182
  const progressUpdateInterval = 100; // Update progress at most every 100ms
809
-
1183
+
1184
+ // Monte Carlo early-stop: stop once this matchup's 95% CI half-width is
1185
+ // within the target (lopsided matchups converge fast). Never exceeds the
1186
+ // requested iterations; disabled for exact enumeration and when mcTarget<=0.
1187
+ const mcTarget = setup.isExhaustive ? 0 : (setup.mcTarget > 0 ? setup.mcTarget / 100 : 0);
1188
+ const reqIters = iterations;
1189
+ let ran = iterations;
1190
+
810
1191
  for (let i = 0; i < iterations; i++) {
811
- // Generate board: use pre-computed combo for exhaustive, or deal randomly
812
- const board = setup.isExhaustive
813
- ? setup.boardMask | comboArray[i]
814
- : this.deal(setup.boardMask, deadMask, setup.numberOfCardsToDeal) | setup.boardMask;
815
-
816
- // Evaluate both hands - inlined for performance (no function call overhead)
817
- let hEval, hKick, vEval, vKick;
818
- if (evalCache) {
819
- [hEval, hKick] = this._getCachedEvaluation(evalCache.heroKey, evalCache.heroHand, board, evalCache.cache, matchupDeadMask);
820
- [vEval, vKick] = this._getCachedEvaluation(evalCache.villainKey, evalCache.villainHand, board, evalCache.cache, matchupDeadMask);
1192
+ // Build the runout as suit rank-masks: precomputed combo (exhaustive) or
1193
+ // a Fisher-Yates draw from the available pool (Monte Carlo).
1194
+ let d0, d1, d2, d3, live = true;
1195
+ if (comboSuits) {
1196
+ const b = i << 2;
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;
821
1201
  } else {
822
- [hEval, hKick] = this.evaluate(heroMask | board);
823
- [vEval, vKick] = this.evaluate(villainMask | board);
1202
+ d0 = bb0; d1 = bb1; d2 = bb2; d3 = bb3;
1203
+ for (let k = 0; k < nDeal; k++) {
1204
+ const j = k + this.xorShift.next(poolLen - k);
1205
+ const tmp = pool[k]; pool[k] = pool[j]; pool[j] = tmp;
1206
+ const p = pool[k], bit = p & 0xFFFF, suit = p >> 16;
1207
+ if (suit === 0) d0 |= bit;
1208
+ else if (suit === 1) d1 |= bit;
1209
+ else if (suit === 2) d2 |= bit;
1210
+ else d3 |= bit;
1211
+ }
824
1212
  }
825
-
826
- // Compare and accumulate results - inlined for performance
827
- if (hEval > vEval || (hEval === vEval && hKick > vKick)) {
828
- win++;
829
- } else if (vEval > hEval || (vEval === hEval && vKick > hKick)) {
830
- lose++;
831
- } else {
832
- tie++;
1213
+
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++;
833
1220
  }
834
-
1221
+
1222
+ // Monte Carlo early-stop check (every 4096 draws, past a warm-up).
1223
+ if (mcTarget && (i & 4095) === 4095 && i >= 8191) {
1224
+ const n = i + 1, p = (win + tie * 0.5) / n;
1225
+ if (1.96 * Math.sqrt(p * (1 - p) / n) < mcTarget) { ran = n; break; }
1226
+ }
1227
+
835
1228
  // Adaptive yielding: less frequent but still maintains UI responsiveness
836
1229
  if (progressCallback && i > 0 && i % yieldInterval === 0) {
837
1230
  const now = Date.now();
@@ -871,7 +1264,15 @@ class BitVal {
871
1264
  }
872
1265
  }
873
1266
  }
874
-
1267
+
1268
+ // If early-stopped, scale counts back to the requested iteration count so
1269
+ // the aggregate (which sums raw counts across matchups) weights every
1270
+ // matchup equally regardless of how early it converged.
1271
+ if (ran < reqIters && ran > 0) {
1272
+ const s = reqIters / ran;
1273
+ win = Math.round(win * s); tie = Math.round(tie * s); lose = Math.round(lose * s);
1274
+ }
1275
+
875
1276
  return { matchupWin: win, matchupTie: tie, matchupLose: lose };
876
1277
  }
877
1278
 
@@ -887,7 +1288,7 @@ class BitVal {
887
1288
 
888
1289
  for (let i = 0; i < total; i++) {
889
1290
  const { heroMask, villainMask } = setup.validMatchups[i];
890
- const { matchupWin, matchupTie, matchupLose } = await this._evaluateMatchup(heroMask, villainMask, setup, null, progressCallback, i, total);
1291
+ const { matchupWin, matchupTie, matchupLose } = await this._evaluateMatchup(heroMask, villainMask, setup, progressCallback, i, total);
891
1292
  win += matchupWin; tie += matchupTie; lose += matchupLose;
892
1293
 
893
1294
  if (progressCallback && i % progressInterval === 0) {
@@ -926,116 +1327,276 @@ class BitVal {
926
1327
  return { win, tie, lose };
927
1328
  }
928
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
+
929
1386
  /**
930
1387
  * Compares ranges with canonical key caching optimization (sequential version).
931
1388
  * @private
932
1389
  */
933
1390
  async _compareRangeOptimizedSequential(setup, progressCallback = null, progressInterval = 100) {
934
1391
  let win = 0, tie = 0, lose = 0;
935
- const evalCache = new FastestAutoClearingCache(16000000);
936
1392
  let lastProgressTime = 0;
937
1393
  const progressUpdateInterval = 100; // Update progress at most every 100ms
938
1394
 
939
- // Pre-calculate board suit counts once (constant for all matchups)
940
- const boardSuitCounts = setup.boardCards.length > 0
941
- ? this._getBoardSuitCounts(setup.boardCards)
942
- : null;
943
-
944
- // Group validMatchups by canonical key pair
945
- // This ensures we only process matchups that actually exist, avoiding Cartesian product issues
946
- const matchupGroups = new Map();
947
- for (const matchup of setup.validMatchups) {
948
- const mHKey = this._getCanonicalKey(
949
- matchup.heroHand,
950
- setup.boardCards,
951
- setup.numberOfBoardCards,
952
- boardSuitCounts
953
- );
954
- const mVKey = this._getCanonicalKey(
955
- matchup.villainHand,
956
- setup.boardCards,
957
- setup.numberOfBoardCards,
958
- boardSuitCounts
959
- );
960
- const key = `${mHKey}:${mVKey}`;
961
-
962
- if (!matchupGroups.has(key)) {
963
- matchupGroups.set(key, {
964
- heroKey: mHKey,
965
- villainKey: mVKey,
966
- representative: matchup,
967
- count: 0
968
- });
1395
+ // Monte Carlo (>=3 cards to come): canonical grouping + independent
1396
+ // per-matchup sampling (each matchup deals its own boards, so runouts can't
1397
+ // be shared). Grouping only pays off here, where a matchup is expensive.
1398
+ if (!setup.isExhaustive) {
1399
+ const groups = this._groupMonteCarloMatchups(setup);
1400
+ const totalMatchups = groups.length;
1401
+ let matchupIndex = 0;
1402
+ for (const group of groups) {
1403
+ const validCount = group.validCount;
1404
+ const validPair = group;
1405
+ const { matchupWin, matchupTie, matchupLose } = await this._evaluateMatchup(
1406
+ validPair.heroMask, validPair.villainMask, setup, progressCallback, matchupIndex, totalMatchups
1407
+ );
1408
+ win += matchupWin * validCount;
1409
+ tie += matchupTie * validCount;
1410
+ lose += matchupLose * validCount;
1411
+ matchupIndex++;
1412
+ if (progressCallback && matchupIndex % progressInterval === 0) {
1413
+ const now = Date.now();
1414
+ const shouldUpdateProgress = now - lastProgressTime >= progressUpdateInterval;
1415
+ if (typeof scheduler !== 'undefined' && scheduler.postTask) {
1416
+ await scheduler.postTask(() => { if (shouldUpdateProgress) { progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`); lastProgressTime = now; } }, { priority: 'user-blocking' });
1417
+ } else if (typeof requestIdleCallback !== 'undefined') {
1418
+ await new Promise(resolve => { requestIdleCallback(() => { if (shouldUpdateProgress) { progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`); lastProgressTime = now; } resolve(); }, { timeout: 1 }); });
1419
+ } else {
1420
+ await new Promise(resolve => setTimeout(resolve, 0));
1421
+ if (shouldUpdateProgress) { progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`); lastProgressTime = now; }
1422
+ }
1423
+ }
969
1424
  }
970
- matchupGroups.get(key).count++;
1425
+ if (progressCallback) progressCallback(matchupIndex, matchupIndex, 'Complete');
1426
+ return { win, tie, lose };
971
1427
  }
972
-
973
- // Calculate accurate total matchups (number of unique canonical key pairs)
974
- const totalMatchups = matchupGroups.size;
975
-
976
- let matchupIndex = 0;
977
- // Iterate over actual matchup groups (not Cartesian product)
978
- for (const [key, group] of matchupGroups) {
979
- const validCount = group.count;
980
- const validPair = group.representative;
981
-
982
- const cache = {
983
- heroKey: group.heroKey,
984
- heroHand: validPair.heroHand,
985
- villainKey: group.villainKey,
986
- villainHand: validPair.villainHand,
987
- cache: evalCache
988
- };
989
-
990
- const { matchupWin, matchupTie, matchupLose } = await this._evaluateMatchup(
991
- validPair.heroMask,
992
- validPair.villainMask,
993
- setup,
994
- cache,
995
- progressCallback,
996
- matchupIndex,
997
- totalMatchups
998
- );
999
-
1000
- win += matchupWin * validCount;
1001
- tie += matchupTie * validCount;
1002
- lose += matchupLose * validCount;
1003
-
1004
- matchupIndex++;
1005
- if (progressCallback && matchupIndex % progressInterval === 0) {
1006
- const now = Date.now();
1007
- const shouldUpdateProgress = now - lastProgressTime >= progressUpdateInterval;
1008
-
1009
- // Use modern browser APIs for better scheduling when available
1010
- if (typeof scheduler !== 'undefined' && scheduler.postTask) {
1011
- await scheduler.postTask(() => {
1012
- if (shouldUpdateProgress) {
1013
- progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`);
1014
- lastProgressTime = now;
1015
- }
1016
- }, { priority: 'user-blocking' });
1017
- } else if (typeof requestIdleCallback !== 'undefined') {
1018
- await new Promise(resolve => {
1019
- requestIdleCallback(() => {
1020
- if (shouldUpdateProgress) {
1021
- progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`);
1022
- lastProgressTime = now;
1023
- }
1024
- resolve();
1025
- }, { timeout: 1 });
1026
- });
1027
- } else {
1028
- // Fallback to setTimeout
1029
- await new Promise(resolve => setTimeout(resolve, 0));
1030
- if (shouldUpdateProgress) {
1031
- progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`);
1032
- lastProgressTime = now;
1428
+
1429
+ // Exhaustive: EXACT evaluate-once. Build the shared work structure and run
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).
1432
+ const work = this._buildExhaustiveWork(setup);
1433
+ const comboSuits = setup._comboSuits || (setup._comboSuits = this._buildComboSuits(setup.comboArray));
1434
+ const nRun = comboSuits.length >> 2;
1435
+ const r = this._runExhaustive(work, comboSuits, 0, nRun);
1436
+ if (progressCallback) progressCallback(nRun, nRun, 'Complete');
1437
+ return r;
1438
+ }
1439
+
1440
+ /**
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.
1449
+ * @private
1450
+ */
1451
+ _buildExhaustiveWork(setup) {
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;
1480
+ const bb = setup._boardSuits || (setup._boardSuits = this._suitsFromBigMask(setup.boardMask));
1481
+
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;
1492
+ if (useCount) {
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
+ }
1504
+ }
1505
+ pHa = Int32Array.from(ph); pVa = Int32Array.from(pv); P = pHa.length;
1506
+ }
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
+ };
1511
+ }
1512
+
1513
+ /**
1514
+ * Exact exhaustive kernel over runouts [runStart, runEnd). Evaluates each
1515
+ * unique concrete hand once per runout, then resolves matchups by direct
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.
1520
+ * @private
1521
+ */
1522
+ _runExhaustive(work, comboSuits, runStart, runEnd) {
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;
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
+
1538
+ for (let i = runStart; i < runEnd; i++) {
1539
+ const b = i << 2;
1540
+ const d0 = bb0 | comboSuits[b], d1 = bb1 | comboSuits[b + 1], d2 = bb2 | comboSuits[b + 2], d3 = bb3 | comboSuits[b + 3];
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; } }
1033
1557
  }
1034
1558
  }
1035
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;
1598
+ }
1036
1599
  }
1037
-
1038
- if (progressCallback) progressCallback(matchupIndex, matchupIndex, 'Complete');
1039
1600
  return { win, tie, lose };
1040
1601
  }
1041
1602
 
@@ -1045,85 +1606,50 @@ class BitVal {
1045
1606
  * @private
1046
1607
  */
1047
1608
  async _compareRangeOptimized(setup, progressCallback = null, progressInterval = 100, useWorkers = true) {
1048
- // Pre-calculate board suit counts once (constant for all matchups)
1049
- const boardSuitCounts = setup.boardCards.length > 0
1050
- ? this._getBoardSuitCounts(setup.boardCards)
1051
- : null;
1052
-
1053
- // Group validMatchups by canonical key pair
1054
- // This ensures we only process matchups that actually exist, avoiding Cartesian product issues
1055
- const matchupGroups = new Map();
1056
- for (const matchup of setup.validMatchups) {
1057
- const mHKey = this._getCanonicalKey(
1058
- matchup.heroHand,
1059
- setup.boardCards,
1060
- setup.numberOfBoardCards,
1061
- boardSuitCounts
1062
- );
1063
- const mVKey = this._getCanonicalKey(
1064
- matchup.villainHand,
1065
- setup.boardCards,
1066
- setup.numberOfBoardCards,
1067
- boardSuitCounts
1068
- );
1069
- const key = `${mHKey}:${mVKey}`;
1070
-
1071
- if (!matchupGroups.has(key)) {
1072
- matchupGroups.set(key, {
1073
- heroKey: mHKey,
1074
- villainKey: mVKey,
1075
- representative: matchup,
1076
- count: 0
1077
- });
1078
- }
1079
- matchupGroups.get(key).count++;
1080
- }
1081
-
1082
- // Collect all matchups to evaluate (only those that actually exist)
1083
- const matchupsToEvaluate = [];
1084
- for (const [key, group] of matchupGroups) {
1085
- const validPair = group.representative;
1086
- matchupsToEvaluate.push({
1087
- key,
1088
- validCount: group.count,
1089
- heroKey: group.heroKey,
1090
- heroHand: validPair.heroHand,
1091
- heroMask: validPair.heroMask,
1092
- villainKey: group.villainKey,
1093
- villainHand: validPair.villainHand,
1094
- villainMask: validPair.villainMask
1095
- });
1609
+ // Exhaustive (flop/turn): run the EXACT kernel on the main thread. It's now
1610
+ // fast enough (~tens of ms) that this is the right choice, and it makes the
1611
+ // worker and non-worker paths agree byte-for-byte (fixes the exact-vs-
1612
+ // approximate split that per-canonical-group workers had). Splitting runouts
1613
+ // across workers was a net loss here (structured-cloning the pair/conflict
1614
+ // work to every worker dwarfs the compute) — true off-thread parallelism of
1615
+ // this path needs SharedArrayBuffer (COOP/COEP), left as a follow-up. The
1616
+ // reusable _runExhaustive / _buildExhaustiveWork are kept for that.
1617
+ if (setup.isExhaustive) {
1618
+ return await this._compareRangeOptimizedSequential(setup, progressCallback, progressInterval);
1096
1619
  }
1097
-
1098
- // Calculate total matchups (number of unique canonical key pairs)
1620
+
1621
+ // Group validMatchups by canonical key pair (only matchups that exist).
1622
+ const matchupsToEvaluate = this._groupMonteCarloMatchups(setup);
1099
1623
  const totalMatchups = matchupsToEvaluate.length;
1100
-
1624
+
1101
1625
  // Check if workers should be used
1102
- const shouldUseWorkers = useWorkers &&
1103
- typeof Worker !== 'undefined' &&
1104
- matchupsToEvaluate.length >= 4;
1105
-
1106
- if (!shouldUseWorkers) {
1107
- // Fallback to sequential execution
1108
- return await this._compareRangeOptimizedSequential(setup, progressCallback, progressInterval);
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; }
1109
1635
  }
1110
-
1111
- // Use Web Workers for parallelization
1112
- const numWorkers = Math.min(navigator.hardwareConcurrency || 4, matchupsToEvaluate.length);
1113
- const batchSize = Math.ceil(matchupsToEvaluate.length / numWorkers);
1114
- const batches = [];
1115
- for (let i = 0; i < matchupsToEvaluate.length; i += batchSize) {
1116
- batches.push(matchupsToEvaluate.slice(i, i + batchSize));
1636
+ if (!workers || workers.length === 0) {
1637
+ return await this._compareRangeOptimizedSequential(setup, progressCallback, progressInterval);
1117
1638
  }
1118
-
1119
- // Serialize setup for workers
1639
+ const numWorkers = workers.length;
1640
+
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)));
1120
1646
  const serializedSetup = this._serializeSetup(setup);
1121
-
1122
- // Create workers and distribute work
1123
- const workers = [];
1124
- const promises = [];
1125
- let completedMatchups = 0;
1126
-
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
+ };
1652
+
1127
1653
  // Progress tracking
1128
1654
  let lastProgressTime = 0;
1129
1655
  const progressUpdateInterval = 100;
@@ -1134,108 +1660,92 @@ class BitVal {
1134
1660
  lastProgressTime = now;
1135
1661
  }
1136
1662
  }, progressUpdateInterval) : null;
1137
-
1138
- for (let i = 0; i < batches.length; i++) {
1139
- const worker = new Worker('./bitval-worker.js');
1140
- workers.push(worker);
1141
-
1142
- const promise = new Promise((resolve, reject) => {
1143
- worker.onmessage = (e) => {
1144
- if (e.data.success) {
1145
- completedMatchups += e.data.results.length;
1146
- resolve(e.data.results);
1147
- } else {
1148
- reject(new Error(e.data.error || 'Worker error'));
1663
+
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) }); }
1149
1694
  }
1150
- worker.terminate();
1151
- };
1152
- worker.onerror = (error) => {
1153
- reject(error);
1154
- worker.terminate();
1155
- };
1156
-
1157
- // Serialize matchups for this batch
1158
- const serializedBatch = batches[i].map(m => this._serializeMatchup(m));
1159
-
1160
- worker.postMessage({
1161
- matchups: serializedBatch,
1162
- setupData: serializedSetup,
1163
- workerId: i
1164
- });
1165
- });
1166
-
1167
- promises.push(promise);
1168
- }
1169
-
1170
- // Wait for all workers and aggregate results
1171
- let win = 0, tie = 0, lose = 0;
1695
+ resolve();
1696
+ } catch (err) { reject(err); }
1697
+ };
1698
+ dispatch();
1699
+ });
1700
+
1172
1701
  try {
1173
- const allResults = await Promise.all(promises);
1174
-
1175
- for (const batchResults of allResults) {
1176
- for (const result of batchResults) {
1177
- win += result.matchupWin * result.validCount;
1178
- tie += result.matchupTie * result.validCount;
1179
- lose += result.matchupLose * result.validCount;
1180
- }
1181
- }
1702
+ await Promise.all(workers.map((w, i) => runWorker(w, i)));
1182
1703
  } finally {
1183
- // Clean up progress interval
1184
- if (progressIntervalId) {
1185
- clearInterval(progressIntervalId);
1186
- }
1187
-
1188
- // Ensure all workers are terminated
1189
- workers.forEach(worker => {
1190
- try {
1191
- worker.terminate();
1192
- } catch (e) {
1193
- // Ignore termination errors
1194
- }
1195
- });
1196
-
1197
- // Final progress update
1198
- if (progressCallback) {
1199
- progressCallback(totalMatchups, totalMatchups, 'Complete');
1200
- }
1704
+ if (progressIntervalId) clearInterval(progressIntervalId);
1705
+ // Workers are NOT terminated - they stay in the pool for the next call.
1706
+ // Callers can free them explicitly with terminateWorkerPool().
1707
+ if (progressCallback) progressCallback(totalMatchups, totalMatchups, 'Complete');
1201
1708
  }
1202
-
1709
+
1203
1710
  return { win, tie, lose };
1204
1711
  }
1205
1712
 
1206
1713
  /**
1207
- * Generates a numeric hash key from canonical key and board for faster cache lookups.
1714
+ * Returns `count` persistent workers, lazily growing the pool as needed.
1715
+ * Reused across compareRange calls so thread-spawn + importScripts parse is
1716
+ * paid once rather than per query.
1208
1717
  * @private
1209
1718
  */
1210
- _getCacheKeyHash(canonicalKey, board, matchupDeadMask = 0n) {
1211
- // Hash the canonical key string
1212
- let hash = 0;
1213
- for (let i = 0; i < canonicalKey.length; i++) {
1214
- hash = ((hash << 5) - hash) + canonicalKey.charCodeAt(i);
1215
- hash = hash & hash; // Convert to 32-bit integer
1216
- }
1217
- // Combine with board bits (use lower 32 bits of BigInt for fast XOR)
1218
- const boardBits = Number(board & 0xFFFFFFFFn);
1219
- hash = hash ^ boardBits;
1220
-
1221
- // Include matchup dead mask to prevent cross-matchup cache collisions
1222
- // This ensures different matchups (with different dead cards) don't share cache entries
1223
- const deadBits = Number(matchupDeadMask & 0xFFFFFFFFn);
1224
- return hash ^ deadBits;
1719
+ _acquireWorkers(count) {
1720
+ if (!this._workerPool) this._workerPool = [];
1721
+ while (this._workerPool.length < count) {
1722
+ this._workerPool.push(new Worker(this.workerUrl));
1723
+ }
1724
+ return this._workerPool.slice(0, count);
1225
1725
  }
1226
1726
 
1227
1727
  /**
1228
- * Gets cached evaluation or evaluates and caches the result.
1229
- * Uses numeric hash keys for faster cache lookups.
1728
+ * Removes a (typically crashed) worker from the pool and terminates it.
1230
1729
  * @private
1231
1730
  */
1232
- _getCachedEvaluation(canonicalKey, originalHand, completeBoard, evalCache, matchupDeadMask = 0n) {
1233
- const cacheKey = this._getCacheKeyHash(canonicalKey, completeBoard, matchupDeadMask);
1234
- if (evalCache.has(cacheKey)) return evalCache.get(cacheKey);
1235
- const handMask = this.getBitMasked(this._handStringToCards(originalHand));
1236
- const evaluation = this.evaluate(handMask | completeBoard);
1237
- evalCache.set(cacheKey, evaluation);
1238
- return evaluation;
1731
+ _discardWorker(worker) {
1732
+ if (this._workerPool) {
1733
+ const idx = this._workerPool.indexOf(worker);
1734
+ if (idx !== -1) this._workerPool.splice(idx, 1);
1735
+ }
1736
+ try { worker.terminate(); } catch (e) { /* ignore */ }
1737
+ }
1738
+
1739
+ /**
1740
+ * Terminates and clears the persistent worker pool. Call when done with an
1741
+ * instance (e.g. on teardown) to free the worker threads.
1742
+ */
1743
+ terminateWorkerPool() {
1744
+ if (!this._workerPool) return;
1745
+ for (const worker of this._workerPool) {
1746
+ try { worker.terminate(); } catch (e) { /* ignore */ }
1747
+ }
1748
+ this._workerPool = null;
1239
1749
  }
1240
1750
 
1241
1751
  /**
@@ -1302,26 +1812,6 @@ class BitVal {
1302
1812
  }
1303
1813
  }
1304
1814
 
1305
- /**
1306
- * Flattens hands to canonical keys for caching optimization.
1307
- * @private
1308
- */
1309
- _flattenHandsToCanonical(hands, boardCards = [], numberOfBoardCards = 5) {
1310
- const canonicalMap = new Map();
1311
- const canonicalToHand = new Map();
1312
- const boardSuitCounts = boardCards.length > 0 ? this._getBoardSuitCounts(boardCards) : null;
1313
-
1314
- for (const hand of hands) {
1315
- const key = this._getCanonicalKey(hand, boardCards, numberOfBoardCards, boardSuitCounts);
1316
- canonicalMap.set(key, (canonicalMap.get(key) || 0) + 1);
1317
- if (!canonicalToHand.has(key)) {
1318
- canonicalToHand.set(key, hand);
1319
- }
1320
- }
1321
-
1322
- return { canonicalMap, canonicalToHand };
1323
- }
1324
-
1325
1815
  /**
1326
1816
  * Gets board suit counts for flush potential calculation.
1327
1817
  * @private
@@ -1386,6 +1876,7 @@ class BitVal {
1386
1876
  * @private
1387
1877
  */
1388
1878
  _getCombinations(availableMasks, k) {
1879
+ if (k === 0) return [0n]; // complete board: exactly one runout (the empty draw)
1389
1880
  if (k === 1) return availableMasks;
1390
1881
  if (k === 2) {
1391
1882
  let combos = [];