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