poker-bitvaljs 1.2.0 → 1.5.2
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 +48 -27
- package/bitval-worker.js +66 -71
- package/bitval.js +575 -230
- package/package.json +11 -2
package/README.md
CHANGED
|
@@ -6,11 +6,15 @@ High-performance poker hand evaluator optimized for browser environments, using
|
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
- **Fast hand evaluation** using
|
|
10
|
-
|
|
11
|
-
- **
|
|
12
|
-
-
|
|
13
|
-
- **
|
|
9
|
+
- **Fast hand evaluation** using four 13-bit suit lanes and plain 32-bit integers
|
|
10
|
+
(no BigInt in the hot path) — ~28–70× faster than the previous BigInt engine
|
|
11
|
+
- **Exact range vs range equity** on the flop and turn via evaluate-once with
|
|
12
|
+
counting (byte-identical to brute force, no approximation)
|
|
13
|
+
- **Exact, instant preflop** via an optional precomputed equity table
|
|
14
|
+
(`loadPreflopTable`) — replaces Monte Carlo entirely for preflop
|
|
15
|
+
- **Monte Carlo** with confidence-interval early-stop for the remaining sampled
|
|
16
|
+
case (preflop with dead cards)
|
|
17
|
+
- **Web Workers** for the Monte Carlo path
|
|
14
18
|
- **Progress callbacks** with configurable update intervals
|
|
15
19
|
- **Browser and Node.js compatible**
|
|
16
20
|
|
|
@@ -157,9 +161,9 @@ Simulates a single hand vs hand matchup.
|
|
|
157
161
|
**Returns:**
|
|
158
162
|
- `{ win, tie, lose }`: Results object
|
|
159
163
|
|
|
160
|
-
### `compareRange(heroHands, villainHands, boardCards, deadCards, numberOfBoardCards, iterations, optimize, progressCallback, progressInterval, useWorkers)`
|
|
164
|
+
### `compareRange(heroHands, villainHands, boardCards, deadCards, numberOfBoardCards, iterations, optimize, progressCallback, progressInterval, useWorkers, mcTargetPct)`
|
|
161
165
|
|
|
162
|
-
Compares two ranges of hands
|
|
166
|
+
Compares two ranges of hands.
|
|
163
167
|
|
|
164
168
|
**Parameters:**
|
|
165
169
|
- `heroHands` (Array): Array of hero hand strings (e.g., `['AsAh', 'AsAd']`)
|
|
@@ -167,40 +171,57 @@ Compares two ranges of hands with optional optimization and Web Workers parallel
|
|
|
167
171
|
- `boardCards` (Array): Board cards as strings (default: `[]`)
|
|
168
172
|
- `deadCards` (Array): Dead cards as strings (default: `[]`)
|
|
169
173
|
- `numberOfBoardCards` (Number): Total board cards (default: `5`)
|
|
170
|
-
- `iterations` (Number):
|
|
171
|
-
- `optimize` (Boolean): Use
|
|
174
|
+
- `iterations` (Number): Monte Carlo samples per matchup (default: `10000`). Ignored on the flop/turn (exhaustive) and preflop-table paths, which are exact.
|
|
175
|
+
- `optimize` (Boolean): Use the optimized path (default: `true`). On the flop/turn this is **exact** (byte-identical to `optimize: false`), just much faster.
|
|
172
176
|
- `progressCallback` (Function): Optional callback `(current, total, message) => {}` (default: `null`)
|
|
173
177
|
- `progressInterval` (Number): Update progress callback every N matchups (default: `100`)
|
|
174
|
-
- `useWorkers` (Boolean): Use Web Workers for
|
|
178
|
+
- `useWorkers` (Boolean): Use Web Workers for the Monte Carlo path (default: `true`). The exact flop/turn path runs on the main thread.
|
|
179
|
+
- `mcTargetPct` (Number): Monte Carlo early-stop target — stop sampling a matchup once its 95% confidence-interval half-width is within this many equity percentage points (default: `0.3`). Pass `0` to force the full `iterations`. Only affects the Monte Carlo (preflop) path.
|
|
175
180
|
|
|
176
181
|
**Returns:**
|
|
177
182
|
- `Promise<{ win, tie, lose }>`: Results object
|
|
178
183
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
### `loadPreflopTable(buffer)`
|
|
185
|
+
|
|
186
|
+
Loads the precomputed exact preflop equity table and enables the preflop fast
|
|
187
|
+
path in `compareRange` (empty board, no dead cards). Accepts an `ArrayBuffer`
|
|
188
|
+
(browser) or Node `Buffer`. Generate the table with `npm run gen:preflop`.
|
|
189
|
+
|
|
190
|
+
```javascript
|
|
191
|
+
// Browser
|
|
192
|
+
const buf = await (await fetch('./bench/preflop-table.bin')).arrayBuffer();
|
|
193
|
+
bitval.loadPreflopTable(buf);
|
|
194
|
+
|
|
195
|
+
// Node
|
|
196
|
+
const fs = require('fs');
|
|
197
|
+
bitval.loadPreflopTable(fs.readFileSync('bench/preflop-table.bin'));
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Without a table loaded, preflop falls back to Monte Carlo — nothing else changes.
|
|
186
201
|
|
|
187
202
|
## Performance
|
|
188
203
|
|
|
189
|
-
- **
|
|
190
|
-
-
|
|
191
|
-
-
|
|
192
|
-
|
|
193
|
-
-
|
|
194
|
-
|
|
204
|
+
- **No BigInt in the hot path** — cards are four 13-bit suit lanes evaluated with
|
|
205
|
+
plain 32-bit integer ops. ~28–70× faster than the previous BigInt engine.
|
|
206
|
+
- **Flop/turn: exact and fast.** Each unique concrete hand is evaluated once per
|
|
207
|
+
runout; wide range-vs-range is resolved by counting. No ±0.5% approximation.
|
|
208
|
+
- **Preflop: exact table lookup** (with `loadPreflopTable`) instead of Monte
|
|
209
|
+
Carlo — effectively instant.
|
|
210
|
+
- **Monte Carlo** (only preflop with dead cards) uses confidence-interval
|
|
211
|
+
early-stop and Web Workers.
|
|
212
|
+
|
|
213
|
+
Reproduce the benchmarks with `node bench/pre-vs-post.js` and `npm run bench`;
|
|
214
|
+
correctness with `npm test` (and `npm run test:full` for the exhaustive proofs).
|
|
195
215
|
|
|
196
216
|
**Test performance and benchmark online:** [https://darraghgeo.github.io/poker-bitvaljs/](https://darraghgeo.github.io/poker-bitvaljs/)
|
|
197
217
|
|
|
198
218
|
### Performance Tips
|
|
199
219
|
|
|
200
|
-
-
|
|
201
|
-
-
|
|
202
|
-
-
|
|
203
|
-
|
|
220
|
+
- Call `loadPreflopTable` once at startup for exact, instant preflop equity.
|
|
221
|
+
- Keep `optimize: true` (default) — on the flop/turn it is exact and much faster.
|
|
222
|
+
- Lower `mcTargetPct` (or set `0`) for higher-precision Monte Carlo; raise it for
|
|
223
|
+
faster, looser preflop-with-dead-cards estimates.
|
|
224
|
+
- Adjust `progressInterval` to balance UI responsiveness vs. performance.
|
|
204
225
|
|
|
205
226
|
## Testing Locally
|
|
206
227
|
|
package/bitval-worker.js
CHANGED
|
@@ -36,103 +36,98 @@ function deserializeMatchup(matchupData) {
|
|
|
36
36
|
* Evaluates a single matchup in the worker context
|
|
37
37
|
* Similar to _evaluateMatchup but without progress callbacks
|
|
38
38
|
*/
|
|
39
|
-
async function evaluateMatchupInWorker(heroMask, villainMask, setup,
|
|
39
|
+
async function evaluateMatchupInWorker(heroMask, villainMask, setup, bitval) {
|
|
40
40
|
let win = 0, tie = 0, lose = 0;
|
|
41
41
|
const deadMask = heroMask | villainMask | setup.deadCardsMask;
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
|
|
44
43
|
// Initialize random number generator for Monte Carlo (not needed for exhaustive)
|
|
45
44
|
if (!setup.isExhaustive) {
|
|
46
45
|
bitval.xorShift = new XorShift32();
|
|
47
46
|
}
|
|
48
47
|
|
|
49
|
-
//
|
|
50
|
-
|
|
48
|
+
// BigInt-free (Number) hot loop, mirroring BitVal._evaluateMatchup. Cards are
|
|
49
|
+
// four 13-bit suit rank masks; bitval._eval7 does the evaluation with no BigInt.
|
|
51
50
|
let iterations = setup.iterations;
|
|
51
|
+
const heroSuits = bitval._suitsFromBigMask(heroMask);
|
|
52
|
+
const villainSuits = bitval._suitsFromBigMask(villainMask);
|
|
53
|
+
const boardBaseSuits = bitval._suitsFromBigMask(setup.boardMask);
|
|
54
|
+
const hs0 = heroSuits[0], hs1 = heroSuits[1], hs2 = heroSuits[2], hs3 = heroSuits[3];
|
|
55
|
+
const vs0 = villainSuits[0], vs1 = villainSuits[1], vs2 = villainSuits[2], vs3 = villainSuits[3];
|
|
56
|
+
const bb0 = boardBaseSuits[0], bb1 = boardBaseSuits[1], bb2 = boardBaseSuits[2], bb3 = boardBaseSuits[3];
|
|
57
|
+
|
|
58
|
+
let comboSuits = null; // flat Int32Array, 4 entries per combo
|
|
59
|
+
let pool = null, poolLen = 0; // packed Int32Array of available cards
|
|
60
|
+
const nDeal = setup.numberOfCardsToDeal;
|
|
52
61
|
if (setup.isExhaustive) {
|
|
53
|
-
// Use comboArray from setup if available, otherwise compute it
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
62
|
+
// Use comboArray from setup if available, otherwise compute it.
|
|
63
|
+
let comboArray = (setup.comboArray && setup.comboArray.length > 0)
|
|
64
|
+
? setup.comboArray
|
|
65
|
+
: bitval._getCombinations(bitval._getAvailableCardMasksByLookUp(deadMask), nDeal);
|
|
66
|
+
comboSuits = bitval._buildComboSuits(comboArray);
|
|
67
|
+
iterations = comboSuits.length >> 2;
|
|
68
|
+
} else {
|
|
69
|
+
pool = bitval._availableSuitCards(deadMask);
|
|
70
|
+
poolLen = pool.length;
|
|
63
71
|
}
|
|
64
|
-
|
|
72
|
+
|
|
73
|
+
// Monte Carlo early-stop (mirrors BitVal._evaluateMatchup).
|
|
74
|
+
const mcTarget = setup.isExhaustive ? 0 : (setup.mcTarget > 0 ? setup.mcTarget / 100 : 0);
|
|
75
|
+
const reqIters = iterations;
|
|
76
|
+
let ran = iterations;
|
|
77
|
+
|
|
65
78
|
// Main evaluation loop
|
|
66
79
|
for (let i = 0; i < iterations; i++) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
// Evaluate both hands
|
|
73
|
-
let hEval, hKick, vEval, vKick;
|
|
74
|
-
if (cacheInfo && evalCache) {
|
|
75
|
-
[hEval, hKick] = bitval._getCachedEvaluation(cacheInfo.heroKey, cacheInfo.heroHand, board, evalCache, matchupDeadMask);
|
|
76
|
-
[vEval, vKick] = bitval._getCachedEvaluation(cacheInfo.villainKey, cacheInfo.villainHand, board, evalCache, matchupDeadMask);
|
|
80
|
+
let d0, d1, d2, d3;
|
|
81
|
+
if (comboSuits) {
|
|
82
|
+
const b = i << 2;
|
|
83
|
+
d0 = bb0 | comboSuits[b]; d1 = bb1 | comboSuits[b + 1]; d2 = bb2 | comboSuits[b + 2]; d3 = bb3 | comboSuits[b + 3];
|
|
77
84
|
} else {
|
|
78
|
-
|
|
79
|
-
|
|
85
|
+
d0 = bb0; d1 = bb1; d2 = bb2; d3 = bb3;
|
|
86
|
+
for (let kk = 0; kk < nDeal; kk++) {
|
|
87
|
+
const j = kk + bitval.xorShift.next(poolLen - kk);
|
|
88
|
+
const tmp = pool[kk]; pool[kk] = pool[j]; pool[j] = tmp;
|
|
89
|
+
const p = pool[kk], bit = p & 0xFFFF, suit = p >> 16;
|
|
90
|
+
if (suit === 0) d0 |= bit;
|
|
91
|
+
else if (suit === 1) d1 |= bit;
|
|
92
|
+
else if (suit === 2) d2 |= bit;
|
|
93
|
+
else d3 |= bit;
|
|
94
|
+
}
|
|
80
95
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
tie
|
|
96
|
+
const hE = bitval._eval7(hs0 | d0, hs1 | d1, hs2 | d2, hs3 | d3);
|
|
97
|
+
const vE = bitval._eval7(vs0 | d0, vs1 | d1, vs2 | d2, vs3 | d3);
|
|
98
|
+
if (hE > vE) win++;
|
|
99
|
+
else if (vE > hE) lose++;
|
|
100
|
+
else tie++;
|
|
101
|
+
|
|
102
|
+
if (mcTarget && (i & 4095) === 4095 && i >= 8191) {
|
|
103
|
+
const n = i + 1, p = (win + tie * 0.5) / n;
|
|
104
|
+
if (1.96 * Math.sqrt(p * (1 - p) / n) < mcTarget) { ran = n; break; }
|
|
89
105
|
}
|
|
90
106
|
}
|
|
91
|
-
|
|
107
|
+
|
|
108
|
+
if (ran < reqIters && ran > 0) {
|
|
109
|
+
const s = reqIters / ran;
|
|
110
|
+
win = Math.round(win * s); tie = Math.round(tie * s); lose = Math.round(lose * s);
|
|
111
|
+
}
|
|
112
|
+
|
|
92
113
|
return { matchupWin: win, matchupTie: tie, matchupLose: lose };
|
|
93
114
|
}
|
|
94
115
|
|
|
95
|
-
//
|
|
116
|
+
// Reused BitVal instance (holds the lookup tables; cheap to keep around).
|
|
117
|
+
const _bitval = new BitVal();
|
|
118
|
+
|
|
119
|
+
// Worker message handler. Workers handle the Monte Carlo path only; the
|
|
120
|
+
// exhaustive exact path runs on the main thread (see BitVal._compareRangeOptimized).
|
|
96
121
|
self.onmessage = async function(e) {
|
|
97
122
|
try {
|
|
98
123
|
const { matchups, setupData, workerId } = e.data;
|
|
99
|
-
|
|
100
|
-
// Deserialize setup
|
|
101
124
|
const setup = deserializeSetup(setupData);
|
|
102
|
-
|
|
103
|
-
// Create BitVal instance and cache for this worker (reused for all matchups)
|
|
104
|
-
const bitval = new BitVal();
|
|
105
|
-
const evalCache = new FastestAutoClearingCache(16000000);
|
|
106
|
-
|
|
107
125
|
const results = [];
|
|
108
|
-
|
|
109
|
-
// Evaluate each matchup in the batch
|
|
126
|
+
|
|
110
127
|
for (const matchupData of matchups) {
|
|
111
128
|
const matchup = deserializeMatchup(matchupData);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const cacheInfo = matchup.heroKey ? {
|
|
115
|
-
heroKey: matchup.heroKey,
|
|
116
|
-
heroHand: matchup.heroHand,
|
|
117
|
-
villainKey: matchup.villainKey,
|
|
118
|
-
villainHand: matchup.villainHand
|
|
119
|
-
} : null;
|
|
120
|
-
|
|
121
|
-
// Evaluate matchup
|
|
122
|
-
const result = await evaluateMatchupInWorker(
|
|
123
|
-
matchup.heroMask,
|
|
124
|
-
matchup.villainMask,
|
|
125
|
-
setup,
|
|
126
|
-
evalCache,
|
|
127
|
-
cacheInfo,
|
|
128
|
-
bitval
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
results.push({
|
|
132
|
-
key: matchup.key,
|
|
133
|
-
validCount: matchup.validCount,
|
|
134
|
-
...result
|
|
135
|
-
});
|
|
129
|
+
const result = await evaluateMatchupInWorker(matchup.heroMask, matchup.villainMask, setup, _bitval);
|
|
130
|
+
results.push({ key: matchup.key, validCount: matchup.validCount, ...result });
|
|
136
131
|
}
|
|
137
132
|
|
|
138
133
|
// Send results back to main thread
|
package/bitval.js
CHANGED
|
@@ -13,46 +13,6 @@ class XorShift32 {
|
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
* Fastest auto-clearing cache with minimal overhead.
|
|
18
|
-
* Uses bitwise counter check to avoid expensive modulo operations.
|
|
19
|
-
*/
|
|
20
|
-
class FastestAutoClearingCache {
|
|
21
|
-
constructor(maxSize = 16000000) {
|
|
22
|
-
this.maxSize = maxSize;
|
|
23
|
-
this.clearThreshold = Math.floor(maxSize * 0.95); // Clear at 95% capacity
|
|
24
|
-
this.cache = new Map();
|
|
25
|
-
this.counter = 0;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
has(key) {
|
|
29
|
-
return this.cache.has(key); // No overhead on hot path
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
get(key) {
|
|
33
|
-
return this.cache.get(key); // No overhead on hot path
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
set(key, value) {
|
|
37
|
-
// Bitwise check is faster than modulo - check every ~1 million operations
|
|
38
|
-
// 0xFFFFF = 1,048,576 (2^20)
|
|
39
|
-
if ((++this.counter & 0xFFFFF) === 0) {
|
|
40
|
-
if (this.cache.size >= this.clearThreshold) {
|
|
41
|
-
this.cache.clear();
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
this.cache.set(key, value);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
clear() {
|
|
48
|
-
this.cache.clear();
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
get size() {
|
|
52
|
-
return this.cache.size;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
16
|
class BitVal {
|
|
57
17
|
constructor() {
|
|
58
18
|
// Suit constants
|
|
@@ -183,6 +143,43 @@ class BitVal {
|
|
|
183
143
|
4503599627370496n: 67553994410557455n,
|
|
184
144
|
1n: 67553994410557455n
|
|
185
145
|
};
|
|
146
|
+
|
|
147
|
+
// ---- BigInt-free fast path (Number evaluator) lookups ----
|
|
148
|
+
// Rank index 0..12 = 2..A; suit index 0..3. A card set is four 13-bit rank
|
|
149
|
+
// masks (one per suit); evaluation and set-union run entirely on Numbers.
|
|
150
|
+
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 };
|
|
151
|
+
this._N_SUIT = { s: 0, h: 1, d: 2, c: 3 };
|
|
152
|
+
this.CARD_SUITS = {}; // 'As' -> { suit, bit }
|
|
153
|
+
this._MASK_TO_SUIT = new Map(); // single-card BigInt mask -> { suit, bit }
|
|
154
|
+
for (const card in this.CARD_MASKS) {
|
|
155
|
+
const sr = { suit: this._N_SUIT[card[1]], bit: 1 << this._N_RANK[card[0]] };
|
|
156
|
+
this.CARD_SUITS[card] = sr;
|
|
157
|
+
this._MASK_TO_SUIT.set(this.CARD_MASKS[card], sr);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 13-bit rank-mask lookup tables for the fast evaluator. Each is 8192
|
|
161
|
+
// entries (~8-16 KB total), built once here, cache-resident at runtime.
|
|
162
|
+
// _POPC13[m] = number of set bits (suit length / rank count).
|
|
163
|
+
// _STRAIGHT13[m] = top rank (4..12) of the best straight, 3 for the wheel,
|
|
164
|
+
// or -1 for none. Replaces the per-eval straight scan loop.
|
|
165
|
+
this._POPC13 = new Uint8Array(8192);
|
|
166
|
+
this._STRAIGHT13 = new Int8Array(8192);
|
|
167
|
+
for (let m = 0; m < 8192; m++) {
|
|
168
|
+
let c = 0, x = m;
|
|
169
|
+
while (x) { x &= x - 1; c++; }
|
|
170
|
+
this._POPC13[m] = c;
|
|
171
|
+
let run = 0, top = -1;
|
|
172
|
+
for (let r = 12; r >= 0; r--) {
|
|
173
|
+
if (m & (1 << r)) { if (++run >= 5) { top = r + 4; break; } } else run = 0;
|
|
174
|
+
}
|
|
175
|
+
if (top < 0 && (m & (1 << 12)) && (m & 0b1111) === 0b1111) top = 3; // wheel
|
|
176
|
+
this._STRAIGHT13[m] = top;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Preflop exact-equity table (loaded on demand via loadPreflopTable). The 24
|
|
180
|
+
// suit permutations canonicalize a matchup for lookup (card index = rank*4+suit).
|
|
181
|
+
this._pfMap = null; this._pfWin = null; this._pfTie = null; this._pfTotal = 0;
|
|
182
|
+
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
183
|
}
|
|
187
184
|
|
|
188
185
|
// ============================================
|
|
@@ -241,6 +238,177 @@ class BitVal {
|
|
|
241
238
|
return [this.stripBits(this.normalize(hand), 5), null];
|
|
242
239
|
}
|
|
243
240
|
|
|
241
|
+
// ============================================
|
|
242
|
+
// FAST PATH - BigInt-free (Number) evaluator
|
|
243
|
+
// ============================================
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Population count of a 13-bit rank mask.
|
|
247
|
+
* @private
|
|
248
|
+
*/
|
|
249
|
+
_popc(m) { return this._POPC13[m]; }
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Index (0..12) of the highest set bit of a non-zero 13-bit rank mask.
|
|
253
|
+
* @private
|
|
254
|
+
*/
|
|
255
|
+
_high(m) { return 31 - Math.clz32(m); }
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Top rank (4..12) of the best 5-card straight in a 13-bit rank mask, or -1.
|
|
259
|
+
* Handles the A-2-3-4-5 wheel (returns 3, i.e. a five-high straight).
|
|
260
|
+
* @private
|
|
261
|
+
*/
|
|
262
|
+
_straightTop(m) { return this._STRAIGHT13[m]; }
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Builds the four 13-bit suit rank masks for an array of card strings.
|
|
266
|
+
* @private
|
|
267
|
+
*/
|
|
268
|
+
_suitsOfCards(cards) {
|
|
269
|
+
const suits = [0, 0, 0, 0];
|
|
270
|
+
for (let i = 0; i < cards.length; i++) {
|
|
271
|
+
const sr = this.CARD_SUITS[cards[i]];
|
|
272
|
+
suits[sr.suit] |= sr.bit;
|
|
273
|
+
}
|
|
274
|
+
return suits;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Decomposes a BigInt card-set mask into four 13-bit suit rank masks.
|
|
279
|
+
* Used once per matchup / combo, never in the innermost loop.
|
|
280
|
+
* @private
|
|
281
|
+
*/
|
|
282
|
+
_suitsFromBigMask(bigMask) {
|
|
283
|
+
const suits = [0, 0, 0, 0];
|
|
284
|
+
for (const [mask, sr] of this._MASK_TO_SUIT) {
|
|
285
|
+
if ((bigMask & mask) === mask) suits[sr.suit] |= sr.bit;
|
|
286
|
+
}
|
|
287
|
+
return suits;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Available cards (as {suit, bit}) not present in the given BigInt dead mask.
|
|
292
|
+
* Built once per matchup for the Monte Carlo dealing loop.
|
|
293
|
+
* @private
|
|
294
|
+
*/
|
|
295
|
+
_availableSuitCards(deadMask) {
|
|
296
|
+
const out = [];
|
|
297
|
+
for (const [mask, sr] of this._MASK_TO_SUIT) {
|
|
298
|
+
if ((deadMask & mask) === 0n) out.push((sr.suit << 16) | sr.bit);
|
|
299
|
+
}
|
|
300
|
+
return Int32Array.from(out); // packed: suit in bits 16-17, rank bit in low 13
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Flattens a BigInt combo array into a single Int32Array of suit rank masks
|
|
305
|
+
* (4 entries per combo: s, h, d, c). One contiguous buffer instead of N small
|
|
306
|
+
* arrays — better cache locality, no per-combo GC. Cached on `setup`.
|
|
307
|
+
* @private
|
|
308
|
+
*/
|
|
309
|
+
_buildComboSuits(comboArray) {
|
|
310
|
+
const out = new Int32Array(comboArray.length * 4);
|
|
311
|
+
for (let i = 0; i < comboArray.length; i++) {
|
|
312
|
+
const s = this._suitsFromBigMask(comboArray[i]);
|
|
313
|
+
const b = i * 4;
|
|
314
|
+
out[b] = s[0]; out[b + 1] = s[1]; out[b + 2] = s[2]; out[b + 3] = s[3];
|
|
315
|
+
}
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Evaluates a 5-7 card hand given its four 13-bit suit rank masks and returns
|
|
321
|
+
* a single Number whose ordering is identical to `evaluate()` (score+kickers).
|
|
322
|
+
* Higher = stronger. No BigInt, no allocation.
|
|
323
|
+
* @private
|
|
324
|
+
*/
|
|
325
|
+
_eval7(s, h, d, c) {
|
|
326
|
+
const all = s | h | d | c;
|
|
327
|
+
const POPC = this._POPC13, STR = this._STRAIGHT13;
|
|
328
|
+
|
|
329
|
+
// Flush suit (>=5 of one suit), reused for straight-flush and flush.
|
|
330
|
+
let fm = 0;
|
|
331
|
+
if (POPC[s] >= 5) fm = s;
|
|
332
|
+
else if (POPC[h] >= 5) fm = h;
|
|
333
|
+
else if (POPC[d] >= 5) fm = d;
|
|
334
|
+
else if (POPC[c] >= 5) fm = c;
|
|
335
|
+
|
|
336
|
+
// Straight flush
|
|
337
|
+
if (fm) {
|
|
338
|
+
const t = STR[fm];
|
|
339
|
+
if (t >= 0) return 8 * 1048576 + t * 65536;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Per-rank suit-count masks (exact via boolean suit combinations)
|
|
343
|
+
const four = s & h & d & c;
|
|
344
|
+
const threePlus = (s & h & d) | (s & h & c) | (s & d & c) | (h & d & c);
|
|
345
|
+
const twoPlus = (s & h) | (s & d) | (s & c) | (h & d) | (h & c) | (d & c);
|
|
346
|
+
const trips = threePlus & ~four; // exactly three
|
|
347
|
+
const pairs = twoPlus & ~threePlus; // exactly two
|
|
348
|
+
|
|
349
|
+
// Quads
|
|
350
|
+
if (four) {
|
|
351
|
+
const q = this._high(four);
|
|
352
|
+
const k = this._high(all & ~(1 << q));
|
|
353
|
+
return 7 * 1048576 + q * 65536 + k * 4096;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Full house (a trip plus another trip or a pair)
|
|
357
|
+
if (trips) {
|
|
358
|
+
const t1 = this._high(trips);
|
|
359
|
+
const restTrips = trips & ~(1 << t1);
|
|
360
|
+
const p = restTrips ? this._high(restTrips) : (pairs ? this._high(pairs) : -1);
|
|
361
|
+
if (p >= 0) return 6 * 1048576 + t1 * 65536 + p * 4096;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// 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
|
+
}
|
|
370
|
+
|
|
371
|
+
// Straight
|
|
372
|
+
{
|
|
373
|
+
const t = STR[all];
|
|
374
|
+
if (t >= 0) return 4 * 1048576 + t * 65536;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Trips
|
|
378
|
+
if (trips) {
|
|
379
|
+
const t = this._high(trips);
|
|
380
|
+
let rest = all & ~(1 << t);
|
|
381
|
+
const k1 = this._high(rest); rest &= ~(1 << k1);
|
|
382
|
+
const k2 = this._high(rest);
|
|
383
|
+
return 3 * 1048576 + t * 65536 + k1 * 4096 + k2 * 256;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Two pair
|
|
387
|
+
if (pairs && (pairs & (pairs - 1))) {
|
|
388
|
+
const p1 = this._high(pairs);
|
|
389
|
+
const p2 = this._high(pairs & ~(1 << p1));
|
|
390
|
+
const k = this._high(all & ~(1 << p1) & ~(1 << p2));
|
|
391
|
+
return 2 * 1048576 + p1 * 65536 + p2 * 4096 + k * 256;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// One pair
|
|
395
|
+
if (pairs) {
|
|
396
|
+
const p = this._high(pairs);
|
|
397
|
+
let rest = all & ~(1 << p);
|
|
398
|
+
const k1 = this._high(rest); rest &= ~(1 << k1);
|
|
399
|
+
const k2 = this._high(rest); rest &= ~(1 << k2);
|
|
400
|
+
const k3 = this._high(rest);
|
|
401
|
+
return 1 * 1048576 + p * 65536 + k1 * 4096 + k2 * 256 + k3 * 16;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// 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
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
244
412
|
/**
|
|
245
413
|
* Gets the human-readable hand strength from an evaluation mask.
|
|
246
414
|
* @param {BigInt} handMask - Evaluation mask with score bits
|
|
@@ -297,9 +465,14 @@ class BitVal {
|
|
|
297
465
|
|
|
298
466
|
const availableCards = 52 - new Set(allDeadCards).size;
|
|
299
467
|
const exhaustiveCombinations = this.combinations(availableCards, numberOfCardsToDeal);
|
|
300
|
-
iterations = Math.min(iterations, exhaustiveCombinations);
|
|
301
468
|
|
|
302
|
-
|
|
469
|
+
// For <=2 cards to come the full runout space is tiny (<=1326), so always
|
|
470
|
+
// enumerate it exactly instead of Monte-Carloing a low iteration count.
|
|
471
|
+
// 0 cards to come (a complete board) is also exhaustive: exactly one runout.
|
|
472
|
+
// Exhaustive is exact and deterministic; the iteration count only matters
|
|
473
|
+
// for larger (>=3 cards to come) scenarios.
|
|
474
|
+
const isExhaustive = numberOfCardsToDeal >= 0 && numberOfCardsToDeal <= 2;
|
|
475
|
+
iterations = isExhaustive ? exhaustiveCombinations : Math.min(iterations, exhaustiveCombinations);
|
|
303
476
|
let comboArray = null;
|
|
304
477
|
if (isExhaustive) {
|
|
305
478
|
const availableMasks = this._getAvailableCardMasksByLookUp(deadCards);
|
|
@@ -368,13 +541,99 @@ class BitVal {
|
|
|
368
541
|
* @param {Function} progressCallback - Optional progress callback (current, total, message)
|
|
369
542
|
* @param {Number} progressInterval - Update progress every N matchups (default: 100)
|
|
370
543
|
* @param {Boolean} useWorkers - Use Web Workers for parallelization (default: true)
|
|
544
|
+
* @param {Number} mcTargetPct - Monte Carlo early-stop target: stop sampling a
|
|
545
|
+
* matchup once its 95% CI half-width is within this many equity percentage
|
|
546
|
+
* points (default: 0.3). Pass 0 to force the full `iterations`. Only affects
|
|
547
|
+
* Monte Carlo (preflop); the exact flop/turn and preflop-table paths ignore it.
|
|
371
548
|
* @returns {Promise<Object>} { win, tie, lose } - Results object
|
|
372
549
|
*/
|
|
373
|
-
async compareRange(heroHands, villainHands, boardCards = [], deadCards = [], numberOfBoardCards = 5, iterations = 10000, optimize = true, progressCallback = null, progressInterval = 100, useWorkers = true) {
|
|
550
|
+
async compareRange(heroHands, villainHands, boardCards = [], deadCards = [], numberOfBoardCards = 5, iterations = 10000, optimize = true, progressCallback = null, progressInterval = 100, useWorkers = true, mcTargetPct = 0.3) {
|
|
551
|
+
// Preflop exact fast path: if the preflop equity table is loaded and this is
|
|
552
|
+
// a full-board preflop query with no dead cards, look up every matchup's
|
|
553
|
+
// exact equity instead of Monte Carlo. Instant and exact.
|
|
554
|
+
if (this._pfMap && boardCards.length === 0 && deadCards.length === 0 && numberOfBoardCards === 5) {
|
|
555
|
+
const r = this._compareRangePreflopExact(heroHands, villainHands);
|
|
556
|
+
if (progressCallback) progressCallback(1, 1, 'Complete');
|
|
557
|
+
return r;
|
|
558
|
+
}
|
|
374
559
|
const setup = this._compareRangeSetup(heroHands, villainHands, boardCards, deadCards, numberOfBoardCards, iterations, optimize);
|
|
560
|
+
// Monte Carlo early-stop target (95% CI half-width, in equity percentage
|
|
561
|
+
// points). Default on; pass 0 to force full `iterations`. Ignored by the
|
|
562
|
+
// exact (exhaustive) path. Only affects the remaining MC case (preflop with
|
|
563
|
+
// dead cards, or preflop with no table loaded).
|
|
564
|
+
setup.mcTarget = mcTargetPct;
|
|
375
565
|
return optimize ? await this._compareRangeOptimized(setup, progressCallback, progressInterval, useWorkers) : await this._compareRangeUnoptimized(heroHands, villainHands, setup, progressCallback, progressInterval);
|
|
376
566
|
}
|
|
377
567
|
|
|
568
|
+
/**
|
|
569
|
+
* Loads the exact preflop equity table (produced by bench/gen-preflop-table.js).
|
|
570
|
+
* Accepts an ArrayBuffer (browser fetch) or Node Buffer. Enables the preflop
|
|
571
|
+
* fast path in compareRange.
|
|
572
|
+
* @param {ArrayBuffer|Uint8Array|Buffer} buffer
|
|
573
|
+
* @returns {Number} number of canonical matchups loaded
|
|
574
|
+
*/
|
|
575
|
+
loadPreflopTable(buffer) {
|
|
576
|
+
const ab = buffer instanceof ArrayBuffer ? buffer : buffer.buffer;
|
|
577
|
+
const off = buffer instanceof ArrayBuffer ? 0 : (buffer.byteOffset || 0);
|
|
578
|
+
const view = new DataView(ab, off, buffer.byteLength !== undefined ? buffer.byteLength : ab.byteLength);
|
|
579
|
+
if (view.getUint32(0, true) !== 0x50464551) throw new Error('invalid preflop table (bad magic)');
|
|
580
|
+
const count = view.getUint32(4, true);
|
|
581
|
+
this._pfTotal = view.getUint32(8, true);
|
|
582
|
+
this._pfWin = new Uint32Array(count);
|
|
583
|
+
this._pfTie = new Uint32Array(count);
|
|
584
|
+
this._pfMap = new Map();
|
|
585
|
+
for (let i = 0; i < count; i++) {
|
|
586
|
+
const o = 12 + i * 12;
|
|
587
|
+
this._pfMap.set(view.getUint32(o, true), i);
|
|
588
|
+
this._pfWin[i] = view.getUint32(o + 4, true);
|
|
589
|
+
this._pfTie[i] = view.getUint32(o + 8, true);
|
|
590
|
+
}
|
|
591
|
+
return count;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Card index (0..51) = rankIndex*4 + suitIndex, for a 2-char card string. @private */
|
|
595
|
+
_cardIndex(card) { return this._N_RANK[card[0]] * 4 + this._N_SUIT[card[1]]; }
|
|
596
|
+
|
|
597
|
+
/** Suit-isomorphic canonical key of a hero-vs-villain matchup (card indices). @private */
|
|
598
|
+
_preflopCanonKey(h1, h2, v1, v2) {
|
|
599
|
+
let best = Infinity;
|
|
600
|
+
const S4 = this._S4;
|
|
601
|
+
for (let pi = 0; pi < 24; pi++) {
|
|
602
|
+
const p = S4[pi];
|
|
603
|
+
const mh1 = (h1 & ~3) | p[h1 & 3], mh2 = (h2 & ~3) | p[h2 & 3];
|
|
604
|
+
const mv1 = (v1 & ~3) | p[v1 & 3], mv2 = (v2 & ~3) | p[v2 & 3];
|
|
605
|
+
const a = mh1 < mh2 ? mh1 : mh2, b = mh1 < mh2 ? mh2 : mh1;
|
|
606
|
+
const c = mv1 < mv2 ? mv1 : mv2, d = mv1 < mv2 ? mv2 : mv1;
|
|
607
|
+
const key = ((a * 52 + b) * 52 + c) * 52 + d;
|
|
608
|
+
if (key < best) best = key;
|
|
609
|
+
}
|
|
610
|
+
return best;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Exact preflop range-vs-range via the loaded table: sum each valid (hero,
|
|
615
|
+
* villain) matchup's exact win/tie/lose over all C(48,5) boards.
|
|
616
|
+
* @private
|
|
617
|
+
*/
|
|
618
|
+
_compareRangePreflopExact(heroHands, villainHands) {
|
|
619
|
+
const total = this._pfTotal;
|
|
620
|
+
const heroC = [], villC = [];
|
|
621
|
+
for (const h of heroHands) heroC.push(this._cardIndex(h.slice(0, 2)), this._cardIndex(h.slice(2, 4)));
|
|
622
|
+
for (const v of villainHands) villC.push(this._cardIndex(v.slice(0, 2)), this._cardIndex(v.slice(2, 4)));
|
|
623
|
+
let win = 0, tie = 0, lose = 0;
|
|
624
|
+
for (let hi = 0; hi < heroC.length; hi += 2) {
|
|
625
|
+
const h1 = heroC[hi], h2 = heroC[hi + 1];
|
|
626
|
+
for (let vi = 0; vi < villC.length; vi += 2) {
|
|
627
|
+
const v1 = villC[vi], v2 = villC[vi + 1];
|
|
628
|
+
if (h1 === v1 || h1 === v2 || h2 === v1 || h2 === v2) continue; // conflicting hole cards
|
|
629
|
+
const idx = this._pfMap.get(this._preflopCanonKey(h1, h2, v1, v2));
|
|
630
|
+
const w = this._pfWin[idx], t = this._pfTie[idx];
|
|
631
|
+
win += w; tie += t; lose += total - w - t;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return { win, tie, lose };
|
|
635
|
+
}
|
|
636
|
+
|
|
378
637
|
// ============================================
|
|
379
638
|
// PUBLIC API - Utility Methods
|
|
380
639
|
// ============================================
|
|
@@ -686,19 +945,24 @@ class BitVal {
|
|
|
686
945
|
const boardMask = this.getBitMasked(boardCards);
|
|
687
946
|
const deadCardsMask = this.getBitMasked([...boardCards, ...deadCards]);
|
|
688
947
|
|
|
689
|
-
// Calculate cards to deal and determine if exhaustive enumeration is possible
|
|
948
|
+
// Calculate cards to deal and determine if exhaustive enumeration is possible.
|
|
949
|
+
// 0 cards to come (a complete board) is exhaustive too: exactly one runout.
|
|
690
950
|
const numberOfCardsToDeal = numberOfBoardCards - boardCards.length;
|
|
691
|
-
const canBeExhaustive = numberOfCardsToDeal
|
|
951
|
+
const canBeExhaustive = numberOfCardsToDeal >= 0 && numberOfCardsToDeal <= 2;
|
|
692
952
|
|
|
693
953
|
// Calculate available cards using bitmask (more efficient than Set)
|
|
694
954
|
const availableCards = 52 - this.countBits(deadCardsMask);
|
|
695
955
|
|
|
696
956
|
// Determine if we should use exhaustive enumeration
|
|
697
|
-
const exhaustiveCombinations = canBeExhaustive
|
|
698
|
-
? this.combinations(availableCards, numberOfCardsToDeal)
|
|
957
|
+
const exhaustiveCombinations = canBeExhaustive
|
|
958
|
+
? this.combinations(availableCards, numberOfCardsToDeal)
|
|
699
959
|
: Infinity;
|
|
700
|
-
|
|
701
|
-
|
|
960
|
+
// For <=2 cards to come the full runout space is tiny (<=1326), so always
|
|
961
|
+
// enumerate it exactly instead of Monte-Carloing a lower requested count.
|
|
962
|
+
// Exhaustive is exact and deterministic; the requested iteration count only
|
|
963
|
+
// matters for larger (>=3 cards to come, e.g. preflop) scenarios.
|
|
964
|
+
const isExhaustive = canBeExhaustive && exhaustiveCombinations < Infinity;
|
|
965
|
+
iterations = isExhaustive ? exhaustiveCombinations : Math.min(iterations, exhaustiveCombinations);
|
|
702
966
|
|
|
703
967
|
// Generate combo array only if exhaustive
|
|
704
968
|
const comboArray = isExhaustive
|
|
@@ -735,7 +999,8 @@ class BitVal {
|
|
|
735
999
|
numberOfBoardCards: setup.numberOfBoardCards,
|
|
736
1000
|
iterations: setup.iterations,
|
|
737
1001
|
isExhaustive: setup.isExhaustive,
|
|
738
|
-
numberOfCardsToDeal: setup.numberOfCardsToDeal
|
|
1002
|
+
numberOfCardsToDeal: setup.numberOfCardsToDeal,
|
|
1003
|
+
mcTarget: setup.mcTarget
|
|
739
1004
|
// Note: hCanon, vCanon, validMatchups, validCounts are not needed in workers
|
|
740
1005
|
// as matchups are pre-serialized and sent separately
|
|
741
1006
|
};
|
|
@@ -783,55 +1048,84 @@ class BitVal {
|
|
|
783
1048
|
* Optimized for browser performance with adaptive yielding and inlined hot loop.
|
|
784
1049
|
* @private
|
|
785
1050
|
*/
|
|
786
|
-
async _evaluateMatchup(heroMask, villainMask, setup,
|
|
1051
|
+
async _evaluateMatchup(heroMask, villainMask, setup, progressCallback = null, matchupIndex = 0, totalMatchups = 1) {
|
|
787
1052
|
let win = 0, tie = 0, lose = 0;
|
|
788
1053
|
const deadMask = heroMask | villainMask | setup.deadCardsMask;
|
|
789
|
-
|
|
790
|
-
|
|
1054
|
+
|
|
791
1055
|
// Initialize random number generator for Monte Carlo (not needed for exhaustive)
|
|
792
1056
|
if (!setup.isExhaustive) {
|
|
793
1057
|
this.xorShift = new XorShift32();
|
|
794
1058
|
}
|
|
795
1059
|
|
|
796
|
-
// Prepare for
|
|
797
|
-
|
|
1060
|
+
// Prepare BigInt-free (Number) representations for the hot loop. The old
|
|
1061
|
+
// path built a BigInt board and called the BigInt evaluate() twice per
|
|
1062
|
+
// iteration; here cards are four 13-bit suit rank masks and _eval7 does the
|
|
1063
|
+
// work with no BigInt and no allocation. The board suit-masks and the combo
|
|
1064
|
+
// suit-masks are constant across matchups, so they're computed once and
|
|
1065
|
+
// cached on `setup`.
|
|
798
1066
|
let iterations = setup.iterations;
|
|
1067
|
+
const heroSuits = this._suitsFromBigMask(heroMask);
|
|
1068
|
+
const villainSuits = this._suitsFromBigMask(villainMask);
|
|
1069
|
+
const boardBaseSuits = setup._boardSuits || (setup._boardSuits = this._suitsFromBigMask(setup.boardMask));
|
|
1070
|
+
const hs0 = heroSuits[0], hs1 = heroSuits[1], hs2 = heroSuits[2], hs3 = heroSuits[3];
|
|
1071
|
+
const vs0 = villainSuits[0], vs1 = villainSuits[1], vs2 = villainSuits[2], vs3 = villainSuits[3];
|
|
1072
|
+
const bb0 = boardBaseSuits[0], bb1 = boardBaseSuits[1], bb2 = boardBaseSuits[2], bb3 = boardBaseSuits[3];
|
|
1073
|
+
|
|
1074
|
+
let comboSuits = null; // flat Int32Array, 4 entries per combo
|
|
1075
|
+
let pool = null, poolLen = 0; // packed Int32Array of available cards
|
|
1076
|
+
const nDeal = setup.numberOfCardsToDeal;
|
|
799
1077
|
if (setup.isExhaustive) {
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
1078
|
+
comboSuits = setup._comboSuits || (setup._comboSuits = this._buildComboSuits(setup.comboArray));
|
|
1079
|
+
iterations = comboSuits.length >> 2;
|
|
1080
|
+
} else {
|
|
1081
|
+
pool = this._availableSuitCards(deadMask);
|
|
1082
|
+
poolLen = pool.length;
|
|
803
1083
|
}
|
|
804
|
-
|
|
805
|
-
// Main evaluation loop -
|
|
806
|
-
const yieldInterval = 5000;
|
|
1084
|
+
|
|
1085
|
+
// Main evaluation loop - adaptive yielding for UI responsiveness
|
|
1086
|
+
const yieldInterval = 5000;
|
|
807
1087
|
let lastProgressTime = 0;
|
|
808
1088
|
const progressUpdateInterval = 100; // Update progress at most every 100ms
|
|
809
|
-
|
|
1089
|
+
|
|
1090
|
+
// Monte Carlo early-stop: stop once this matchup's 95% CI half-width is
|
|
1091
|
+
// within the target (lopsided matchups converge fast). Never exceeds the
|
|
1092
|
+
// requested iterations; disabled for exact enumeration and when mcTarget<=0.
|
|
1093
|
+
const mcTarget = setup.isExhaustive ? 0 : (setup.mcTarget > 0 ? setup.mcTarget / 100 : 0);
|
|
1094
|
+
const reqIters = iterations;
|
|
1095
|
+
let ran = iterations;
|
|
1096
|
+
|
|
810
1097
|
for (let i = 0; i < iterations; i++) {
|
|
811
|
-
//
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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);
|
|
1098
|
+
// Build the runout as suit rank-masks: precomputed combo (exhaustive) or
|
|
1099
|
+
// a Fisher-Yates draw from the available pool (Monte Carlo).
|
|
1100
|
+
let d0, d1, d2, d3;
|
|
1101
|
+
if (comboSuits) {
|
|
1102
|
+
const b = i << 2;
|
|
1103
|
+
d0 = bb0 | comboSuits[b]; d1 = bb1 | comboSuits[b + 1]; d2 = bb2 | comboSuits[b + 2]; d3 = bb3 | comboSuits[b + 3];
|
|
821
1104
|
} else {
|
|
822
|
-
|
|
823
|
-
|
|
1105
|
+
d0 = bb0; d1 = bb1; d2 = bb2; d3 = bb3;
|
|
1106
|
+
for (let k = 0; k < nDeal; k++) {
|
|
1107
|
+
const j = k + this.xorShift.next(poolLen - k);
|
|
1108
|
+
const tmp = pool[k]; pool[k] = pool[j]; pool[j] = tmp;
|
|
1109
|
+
const p = pool[k], bit = p & 0xFFFF, suit = p >> 16;
|
|
1110
|
+
if (suit === 0) d0 |= bit;
|
|
1111
|
+
else if (suit === 1) d1 |= bit;
|
|
1112
|
+
else if (suit === 2) d2 |= bit;
|
|
1113
|
+
else d3 |= bit;
|
|
1114
|
+
}
|
|
824
1115
|
}
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
1116
|
+
|
|
1117
|
+
const hE = this._eval7(hs0 | d0, hs1 | d1, hs2 | d2, hs3 | d3);
|
|
1118
|
+
const vE = this._eval7(vs0 | d0, vs1 | d1, vs2 | d2, vs3 | d3);
|
|
1119
|
+
if (hE > vE) win++;
|
|
1120
|
+
else if (vE > hE) lose++;
|
|
1121
|
+
else tie++;
|
|
1122
|
+
|
|
1123
|
+
// Monte Carlo early-stop check (every 4096 draws, past a warm-up).
|
|
1124
|
+
if (mcTarget && (i & 4095) === 4095 && i >= 8191) {
|
|
1125
|
+
const n = i + 1, p = (win + tie * 0.5) / n;
|
|
1126
|
+
if (1.96 * Math.sqrt(p * (1 - p) / n) < mcTarget) { ran = n; break; }
|
|
833
1127
|
}
|
|
834
|
-
|
|
1128
|
+
|
|
835
1129
|
// Adaptive yielding: less frequent but still maintains UI responsiveness
|
|
836
1130
|
if (progressCallback && i > 0 && i % yieldInterval === 0) {
|
|
837
1131
|
const now = Date.now();
|
|
@@ -871,7 +1165,15 @@ class BitVal {
|
|
|
871
1165
|
}
|
|
872
1166
|
}
|
|
873
1167
|
}
|
|
874
|
-
|
|
1168
|
+
|
|
1169
|
+
// If early-stopped, scale counts back to the requested iteration count so
|
|
1170
|
+
// the aggregate (which sums raw counts across matchups) weights every
|
|
1171
|
+
// matchup equally regardless of how early it converged.
|
|
1172
|
+
if (ran < reqIters && ran > 0) {
|
|
1173
|
+
const s = reqIters / ran;
|
|
1174
|
+
win = Math.round(win * s); tie = Math.round(tie * s); lose = Math.round(lose * s);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
875
1177
|
return { matchupWin: win, matchupTie: tie, matchupLose: lose };
|
|
876
1178
|
}
|
|
877
1179
|
|
|
@@ -887,7 +1189,7 @@ class BitVal {
|
|
|
887
1189
|
|
|
888
1190
|
for (let i = 0; i < total; i++) {
|
|
889
1191
|
const { heroMask, villainMask } = setup.validMatchups[i];
|
|
890
|
-
const { matchupWin, matchupTie, matchupLose } = await this._evaluateMatchup(heroMask, villainMask, setup,
|
|
1192
|
+
const { matchupWin, matchupTie, matchupLose } = await this._evaluateMatchup(heroMask, villainMask, setup, progressCallback, i, total);
|
|
891
1193
|
win += matchupWin; tie += matchupTie; lose += matchupLose;
|
|
892
1194
|
|
|
893
1195
|
if (progressCallback && i % progressInterval === 0) {
|
|
@@ -932,7 +1234,6 @@ class BitVal {
|
|
|
932
1234
|
*/
|
|
933
1235
|
async _compareRangeOptimizedSequential(setup, progressCallback = null, progressInterval = 100) {
|
|
934
1236
|
let win = 0, tie = 0, lose = 0;
|
|
935
|
-
const evalCache = new FastestAutoClearingCache(16000000);
|
|
936
1237
|
let lastProgressTime = 0;
|
|
937
1238
|
const progressUpdateInterval = 100; // Update progress at most every 100ms
|
|
938
1239
|
|
|
@@ -941,101 +1242,129 @@ class BitVal {
|
|
|
941
1242
|
? this._getBoardSuitCounts(setup.boardCards)
|
|
942
1243
|
: null;
|
|
943
1244
|
|
|
944
|
-
//
|
|
945
|
-
//
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
const
|
|
949
|
-
|
|
950
|
-
setup.boardCards,
|
|
951
|
-
setup.numberOfBoardCards,
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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
|
-
});
|
|
1245
|
+
// Monte Carlo (>=3 cards to come): canonical grouping + independent
|
|
1246
|
+
// per-matchup sampling (each matchup deals its own boards, so runouts can't
|
|
1247
|
+
// be shared). Grouping only pays off here, where a matchup is expensive.
|
|
1248
|
+
if (!setup.isExhaustive) {
|
|
1249
|
+
const matchupGroups = new Map();
|
|
1250
|
+
for (const matchup of setup.validMatchups) {
|
|
1251
|
+
const mHKey = this._getCanonicalKey(matchup.heroHand, setup.boardCards, setup.numberOfBoardCards, boardSuitCounts);
|
|
1252
|
+
const mVKey = this._getCanonicalKey(matchup.villainHand, setup.boardCards, setup.numberOfBoardCards, boardSuitCounts);
|
|
1253
|
+
const key = `${mHKey}:${mVKey}`;
|
|
1254
|
+
let g = matchupGroups.get(key);
|
|
1255
|
+
if (!g) { g = { heroKey: mHKey, villainKey: mVKey, representative: matchup, count: 0 }; matchupGroups.set(key, g); }
|
|
1256
|
+
g.count++;
|
|
969
1257
|
}
|
|
970
|
-
matchupGroups.
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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;
|
|
1258
|
+
const totalMatchups = matchupGroups.size;
|
|
1259
|
+
let matchupIndex = 0;
|
|
1260
|
+
for (const [key, group] of matchupGroups) {
|
|
1261
|
+
const validCount = group.count;
|
|
1262
|
+
const validPair = group.representative;
|
|
1263
|
+
const { matchupWin, matchupTie, matchupLose } = await this._evaluateMatchup(
|
|
1264
|
+
validPair.heroMask, validPair.villainMask, setup, progressCallback, matchupIndex, totalMatchups
|
|
1265
|
+
);
|
|
1266
|
+
win += matchupWin * validCount;
|
|
1267
|
+
tie += matchupTie * validCount;
|
|
1268
|
+
lose += matchupLose * validCount;
|
|
1269
|
+
matchupIndex++;
|
|
1270
|
+
if (progressCallback && matchupIndex % progressInterval === 0) {
|
|
1271
|
+
const now = Date.now();
|
|
1272
|
+
const shouldUpdateProgress = now - lastProgressTime >= progressUpdateInterval;
|
|
1273
|
+
if (typeof scheduler !== 'undefined' && scheduler.postTask) {
|
|
1274
|
+
await scheduler.postTask(() => { if (shouldUpdateProgress) { progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`); lastProgressTime = now; } }, { priority: 'user-blocking' });
|
|
1275
|
+
} else if (typeof requestIdleCallback !== 'undefined') {
|
|
1276
|
+
await new Promise(resolve => { requestIdleCallback(() => { if (shouldUpdateProgress) { progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`); lastProgressTime = now; } resolve(); }, { timeout: 1 }); });
|
|
1277
|
+
} else {
|
|
1278
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
1279
|
+
if (shouldUpdateProgress) { progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`); lastProgressTime = now; }
|
|
1033
1280
|
}
|
|
1034
1281
|
}
|
|
1035
1282
|
}
|
|
1283
|
+
if (progressCallback) progressCallback(matchupIndex, matchupIndex, 'Complete');
|
|
1284
|
+
return { win, tie, lose };
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// Exhaustive: EXACT evaluate-once. Build the shared work structure and run
|
|
1288
|
+
// the kernel over all runouts. The Web Worker path (_compareRangeOptimized)
|
|
1289
|
+
// runs the SAME kernel over runout slices, so workers and sequential agree
|
|
1290
|
+
// byte-for-byte.
|
|
1291
|
+
const work = this._buildExhaustiveWork(setup);
|
|
1292
|
+
const comboSuits = setup._comboSuits || (setup._comboSuits = this._buildComboSuits(setup.comboArray));
|
|
1293
|
+
const nRun = comboSuits.length >> 2;
|
|
1294
|
+
const r = this._runExhaustive(work, comboSuits, 0, nRun);
|
|
1295
|
+
if (progressCallback) progressCallback(nRun, nRun, 'Complete');
|
|
1296
|
+
return r;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* Builds the reusable per-run work for the exact exhaustive kernel: unique
|
|
1301
|
+
* concrete hero/villain suit masks, the valid-pair list, and (for large
|
|
1302
|
+
* fields) the card-conflict CSR. Everything is transferable typed arrays so
|
|
1303
|
+
* it can be serialized to workers unchanged.
|
|
1304
|
+
* @private
|
|
1305
|
+
*/
|
|
1306
|
+
_buildExhaustiveWork(setup) {
|
|
1307
|
+
const heroIdx = new Map(), villIdx = new Map();
|
|
1308
|
+
const heroSuit = [], villSuit = [], pairH = [], pairV = [];
|
|
1309
|
+
for (const m of setup.validMatchups) {
|
|
1310
|
+
let hi = heroIdx.get(m.heroMask);
|
|
1311
|
+
if (hi === undefined) { hi = heroSuit.length >> 2; heroIdx.set(m.heroMask, hi); const s = this._suitsFromBigMask(m.heroMask); heroSuit.push(s[0], s[1], s[2], s[3]); }
|
|
1312
|
+
let vi = villIdx.get(m.villainMask);
|
|
1313
|
+
if (vi === undefined) { vi = villSuit.length >> 2; villIdx.set(m.villainMask, vi); const s = this._suitsFromBigMask(m.villainMask); villSuit.push(s[0], s[1], s[2], s[3]); }
|
|
1314
|
+
pairH.push(hi); pairV.push(vi);
|
|
1315
|
+
}
|
|
1316
|
+
const H = heroSuit.length >> 2, V = villSuit.length >> 2, P = pairH.length;
|
|
1317
|
+
const hSuit = Int32Array.from(heroSuit), vSuit = Int32Array.from(villSuit);
|
|
1318
|
+
const pHa = Int32Array.from(pairH), pVa = Int32Array.from(pairV);
|
|
1319
|
+
const bb = setup._boardSuits || (setup._boardSuits = this._suitsFromBigMask(setup.boardMask));
|
|
1320
|
+
|
|
1321
|
+
const useCount = H >= 16 && V >= 16;
|
|
1322
|
+
let confList = null, confOff = null;
|
|
1323
|
+
if (useCount) {
|
|
1324
|
+
const confListArr = [];
|
|
1325
|
+
confOff = new Int32Array(H + 1);
|
|
1326
|
+
for (let h = 0; h < H; h++) {
|
|
1327
|
+
const q = h << 2, a0 = hSuit[q], a1 = hSuit[q + 1], a2 = hSuit[q + 2], a3 = hSuit[q + 3];
|
|
1328
|
+
for (let v = 0; v < V; v++) { const r = v << 2; if ((a0 & vSuit[r]) | (a1 & vSuit[r + 1]) | (a2 & vSuit[r + 2]) | (a3 & vSuit[r + 3])) confListArr.push(v); }
|
|
1329
|
+
confOff[h + 1] = confListArr.length;
|
|
1330
|
+
}
|
|
1331
|
+
confList = Int32Array.from(confListArr);
|
|
1332
|
+
}
|
|
1333
|
+
return { hSuit, vSuit, pHa, pVa, confList, confOff, useCount, bb0: bb[0], bb1: bb[1], bb2: bb[2], bb3: bb[3], H, V, P };
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* Exact exhaustive kernel over runouts [runStart, runEnd). Evaluates each
|
|
1338
|
+
* unique concrete hand once per runout, then resolves matchups by direct
|
|
1339
|
+
* comparison (small fields) or sorted-villain binary-search counting with
|
|
1340
|
+
* card-conflict correction (large fields). Byte-identical to unoptimized.
|
|
1341
|
+
* @private
|
|
1342
|
+
*/
|
|
1343
|
+
_runExhaustive(work, comboSuits, runStart, runEnd) {
|
|
1344
|
+
const { hSuit, vSuit, pHa, pVa, confList, confOff, useCount, bb0, bb1, bb2, bb3, H, V, P } = work;
|
|
1345
|
+
const heroVals = new Int32Array(H), villVals = new Int32Array(V);
|
|
1346
|
+
const sortedV = useCount ? new Int32Array(V) : null;
|
|
1347
|
+
let win = 0, tie = 0, lose = 0;
|
|
1348
|
+
for (let i = runStart; i < runEnd; i++) {
|
|
1349
|
+
const b = i << 2;
|
|
1350
|
+
const d0 = bb0 | comboSuits[b], d1 = bb1 | comboSuits[b + 1], d2 = bb2 | comboSuits[b + 2], d3 = bb3 | comboSuits[b + 3];
|
|
1351
|
+
for (let h = 0; h < H; h++) { const q = h << 2; heroVals[h] = this._eval7(hSuit[q] | d0, hSuit[q + 1] | d1, hSuit[q + 2] | d2, hSuit[q + 3] | d3); }
|
|
1352
|
+
for (let v = 0; v < V; v++) { const q = v << 2; villVals[v] = this._eval7(vSuit[q] | d0, vSuit[q + 1] | d1, vSuit[q + 2] | d2, vSuit[q + 3] | d3); }
|
|
1353
|
+
if (useCount) {
|
|
1354
|
+
sortedV.set(villVals);
|
|
1355
|
+
sortedV.sort();
|
|
1356
|
+
for (let h = 0; h < H; h++) {
|
|
1357
|
+
const x = heroVals[h];
|
|
1358
|
+
let lo = 0, hb = V; while (lo < hb) { const mid = (lo + hb) >> 1; if (sortedV[mid] < x) lo = mid + 1; else hb = mid; }
|
|
1359
|
+
let up = lo; hb = V; while (up < hb) { const mid = (up + hb) >> 1; if (sortedV[mid] <= x) up = mid + 1; else hb = mid; }
|
|
1360
|
+
let wH = lo, tH = up - lo, lH = V - up;
|
|
1361
|
+
for (let ci = confOff[h], end = confOff[h + 1]; ci < end; ci++) { const cv = villVals[confList[ci]]; if (cv < x) wH--; else if (cv === x) tH--; else lH--; }
|
|
1362
|
+
win += wH; tie += tH; lose += lH;
|
|
1363
|
+
}
|
|
1364
|
+
} else {
|
|
1365
|
+
for (let p = 0; p < P; p++) { const hv = heroVals[pHa[p]], vv = villVals[pVa[p]]; if (hv > vv) win++; else if (vv > hv) lose++; else tie++; }
|
|
1366
|
+
}
|
|
1036
1367
|
}
|
|
1037
|
-
|
|
1038
|
-
if (progressCallback) progressCallback(matchupIndex, matchupIndex, 'Complete');
|
|
1039
1368
|
return { win, tie, lose };
|
|
1040
1369
|
}
|
|
1041
1370
|
|
|
@@ -1045,9 +1374,21 @@ class BitVal {
|
|
|
1045
1374
|
* @private
|
|
1046
1375
|
*/
|
|
1047
1376
|
async _compareRangeOptimized(setup, progressCallback = null, progressInterval = 100, useWorkers = true) {
|
|
1377
|
+
// Exhaustive (flop/turn): run the EXACT kernel on the main thread. It's now
|
|
1378
|
+
// fast enough (~tens of ms) that this is the right choice, and it makes the
|
|
1379
|
+
// worker and non-worker paths agree byte-for-byte (fixes the exact-vs-
|
|
1380
|
+
// approximate split that per-canonical-group workers had). Splitting runouts
|
|
1381
|
+
// across workers was a net loss here (structured-cloning the pair/conflict
|
|
1382
|
+
// work to every worker dwarfs the compute) — true off-thread parallelism of
|
|
1383
|
+
// this path needs SharedArrayBuffer (COOP/COEP), left as a follow-up. The
|
|
1384
|
+
// reusable _runExhaustive / _buildExhaustiveWork are kept for that.
|
|
1385
|
+
if (setup.isExhaustive) {
|
|
1386
|
+
return await this._compareRangeOptimizedSequential(setup, progressCallback, progressInterval);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1048
1389
|
// Pre-calculate board suit counts once (constant for all matchups)
|
|
1049
|
-
const boardSuitCounts = setup.boardCards.length > 0
|
|
1050
|
-
? this._getBoardSuitCounts(setup.boardCards)
|
|
1390
|
+
const boardSuitCounts = setup.boardCards.length > 0
|
|
1391
|
+
? this._getBoardSuitCounts(setup.boardCards)
|
|
1051
1392
|
: null;
|
|
1052
1393
|
|
|
1053
1394
|
// Group validMatchups by canonical key pair
|
|
@@ -1115,15 +1456,17 @@ class BitVal {
|
|
|
1115
1456
|
for (let i = 0; i < matchupsToEvaluate.length; i += batchSize) {
|
|
1116
1457
|
batches.push(matchupsToEvaluate.slice(i, i + batchSize));
|
|
1117
1458
|
}
|
|
1118
|
-
|
|
1459
|
+
|
|
1119
1460
|
// Serialize setup for workers
|
|
1120
1461
|
const serializedSetup = this._serializeSetup(setup);
|
|
1121
|
-
|
|
1122
|
-
//
|
|
1123
|
-
|
|
1462
|
+
|
|
1463
|
+
// Acquire persistent workers from the pool (created once, reused across calls).
|
|
1464
|
+
// Previously a fresh Worker was spawned and terminated per compareRange call,
|
|
1465
|
+
// paying thread-spawn + importScripts('./bitval.js') parse on every query.
|
|
1466
|
+
const workers = this._acquireWorkers(batches.length);
|
|
1124
1467
|
const promises = [];
|
|
1125
1468
|
let completedMatchups = 0;
|
|
1126
|
-
|
|
1469
|
+
|
|
1127
1470
|
// Progress tracking
|
|
1128
1471
|
let lastProgressTime = 0;
|
|
1129
1472
|
const progressUpdateInterval = 100;
|
|
@@ -1134,44 +1477,48 @@ class BitVal {
|
|
|
1134
1477
|
lastProgressTime = now;
|
|
1135
1478
|
}
|
|
1136
1479
|
}, progressUpdateInterval) : null;
|
|
1137
|
-
|
|
1480
|
+
|
|
1138
1481
|
for (let i = 0; i < batches.length; i++) {
|
|
1139
|
-
const worker =
|
|
1140
|
-
|
|
1141
|
-
|
|
1482
|
+
const worker = workers[i];
|
|
1483
|
+
|
|
1142
1484
|
const promise = new Promise((resolve, reject) => {
|
|
1485
|
+
// One-shot handlers per dispatch; cleared on settle so the pooled worker
|
|
1486
|
+
// is clean for the next call. (Assumes compareRange calls don't overlap.)
|
|
1487
|
+
const cleanup = () => { worker.onmessage = null; worker.onerror = null; };
|
|
1143
1488
|
worker.onmessage = (e) => {
|
|
1489
|
+
cleanup();
|
|
1144
1490
|
if (e.data.success) {
|
|
1145
1491
|
completedMatchups += e.data.results.length;
|
|
1146
1492
|
resolve(e.data.results);
|
|
1147
1493
|
} else {
|
|
1148
1494
|
reject(new Error(e.data.error || 'Worker error'));
|
|
1149
1495
|
}
|
|
1150
|
-
worker.terminate();
|
|
1151
1496
|
};
|
|
1152
1497
|
worker.onerror = (error) => {
|
|
1498
|
+
cleanup();
|
|
1499
|
+
// A crashed worker can't be reused - drop it from the pool.
|
|
1500
|
+
this._discardWorker(worker);
|
|
1153
1501
|
reject(error);
|
|
1154
|
-
worker.terminate();
|
|
1155
1502
|
};
|
|
1156
|
-
|
|
1503
|
+
|
|
1157
1504
|
// Serialize matchups for this batch
|
|
1158
1505
|
const serializedBatch = batches[i].map(m => this._serializeMatchup(m));
|
|
1159
|
-
|
|
1506
|
+
|
|
1160
1507
|
worker.postMessage({
|
|
1161
1508
|
matchups: serializedBatch,
|
|
1162
1509
|
setupData: serializedSetup,
|
|
1163
1510
|
workerId: i
|
|
1164
1511
|
});
|
|
1165
1512
|
});
|
|
1166
|
-
|
|
1513
|
+
|
|
1167
1514
|
promises.push(promise);
|
|
1168
1515
|
}
|
|
1169
|
-
|
|
1516
|
+
|
|
1170
1517
|
// Wait for all workers and aggregate results
|
|
1171
1518
|
let win = 0, tie = 0, lose = 0;
|
|
1172
1519
|
try {
|
|
1173
1520
|
const allResults = await Promise.all(promises);
|
|
1174
|
-
|
|
1521
|
+
|
|
1175
1522
|
for (const batchResults of allResults) {
|
|
1176
1523
|
for (const result of batchResults) {
|
|
1177
1524
|
win += result.matchupWin * result.validCount;
|
|
@@ -1184,58 +1531,55 @@ class BitVal {
|
|
|
1184
1531
|
if (progressIntervalId) {
|
|
1185
1532
|
clearInterval(progressIntervalId);
|
|
1186
1533
|
}
|
|
1187
|
-
|
|
1188
|
-
//
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
worker.terminate();
|
|
1192
|
-
} catch (e) {
|
|
1193
|
-
// Ignore termination errors
|
|
1194
|
-
}
|
|
1195
|
-
});
|
|
1196
|
-
|
|
1534
|
+
|
|
1535
|
+
// Workers are NOT terminated - they stay in the pool for the next call.
|
|
1536
|
+
// Callers can free them explicitly with terminateWorkerPool().
|
|
1537
|
+
|
|
1197
1538
|
// Final progress update
|
|
1198
1539
|
if (progressCallback) {
|
|
1199
1540
|
progressCallback(totalMatchups, totalMatchups, 'Complete');
|
|
1200
1541
|
}
|
|
1201
1542
|
}
|
|
1202
|
-
|
|
1543
|
+
|
|
1203
1544
|
return { win, tie, lose };
|
|
1204
1545
|
}
|
|
1205
1546
|
|
|
1206
1547
|
/**
|
|
1207
|
-
*
|
|
1548
|
+
* Returns `count` persistent workers, lazily growing the pool as needed.
|
|
1549
|
+
* Reused across compareRange calls so thread-spawn + importScripts parse is
|
|
1550
|
+
* paid once rather than per query.
|
|
1208
1551
|
* @private
|
|
1209
1552
|
*/
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
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;
|
|
1553
|
+
_acquireWorkers(count) {
|
|
1554
|
+
if (!this._workerPool) this._workerPool = [];
|
|
1555
|
+
while (this._workerPool.length < count) {
|
|
1556
|
+
this._workerPool.push(new Worker('./bitval-worker.js'));
|
|
1557
|
+
}
|
|
1558
|
+
return this._workerPool.slice(0, count);
|
|
1225
1559
|
}
|
|
1226
1560
|
|
|
1227
1561
|
/**
|
|
1228
|
-
*
|
|
1229
|
-
* Uses numeric hash keys for faster cache lookups.
|
|
1562
|
+
* Removes a (typically crashed) worker from the pool and terminates it.
|
|
1230
1563
|
* @private
|
|
1231
1564
|
*/
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1565
|
+
_discardWorker(worker) {
|
|
1566
|
+
if (this._workerPool) {
|
|
1567
|
+
const idx = this._workerPool.indexOf(worker);
|
|
1568
|
+
if (idx !== -1) this._workerPool.splice(idx, 1);
|
|
1569
|
+
}
|
|
1570
|
+
try { worker.terminate(); } catch (e) { /* ignore */ }
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
/**
|
|
1574
|
+
* Terminates and clears the persistent worker pool. Call when done with an
|
|
1575
|
+
* instance (e.g. on teardown) to free the worker threads.
|
|
1576
|
+
*/
|
|
1577
|
+
terminateWorkerPool() {
|
|
1578
|
+
if (!this._workerPool) return;
|
|
1579
|
+
for (const worker of this._workerPool) {
|
|
1580
|
+
try { worker.terminate(); } catch (e) { /* ignore */ }
|
|
1581
|
+
}
|
|
1582
|
+
this._workerPool = null;
|
|
1239
1583
|
}
|
|
1240
1584
|
|
|
1241
1585
|
/**
|
|
@@ -1386,6 +1730,7 @@ class BitVal {
|
|
|
1386
1730
|
* @private
|
|
1387
1731
|
*/
|
|
1388
1732
|
_getCombinations(availableMasks, k) {
|
|
1733
|
+
if (k === 0) return [0n]; // complete board: exactly one runout (the empty draw)
|
|
1389
1734
|
if (k === 1) return availableMasks;
|
|
1390
1735
|
if (k === 2) {
|
|
1391
1736
|
let combos = [];
|
package/package.json
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "poker-bitvaljs",
|
|
3
|
-
"version": "1.2
|
|
3
|
+
"version": "1.5.2",
|
|
4
4
|
"description": "High-performance poker hand evaluator optimized for browser environments, using bitwise operations for rapid poker hand analysis and equity calculations.",
|
|
5
5
|
"main": "bitval.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "
|
|
7
|
+
"test": "node bench/correctness.js && node bench/determinism.js && node bench/exact-optimize.js 300 && node bench/eval-equivalence.js 1000000",
|
|
8
|
+
"test:full": "node bench/eval-equivalence.js --full && node bench/engine-differential.js && node bench/exact-optimize.js 2000",
|
|
9
|
+
"test:equiv": "node bench/eval-equivalence.js",
|
|
10
|
+
"test:exact": "node bench/exact-optimize.js",
|
|
11
|
+
"test:differential": "node bench/engine-differential.js",
|
|
12
|
+
"test:preflop": "node bench/preflop-validate.js",
|
|
13
|
+
"gen:preflop": "node bench/gen-preflop-table.js --out bench/preflop-table.bin",
|
|
14
|
+
"bench": "node bench/bench.js",
|
|
15
|
+
"bench:correct": "node bench/correctness.js",
|
|
16
|
+
"bench:determinism": "node bench/determinism.js"
|
|
8
17
|
},
|
|
9
18
|
"keywords": [
|
|
10
19
|
"poker",
|