poker-bitvaljs 1.2.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -27
- package/bench/preflop-table.bin +0 -0
- package/bitval-worker.js +8 -93
- package/bitval.js +950 -459
- package/package.json +13 -2
package/bitval.js
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
// Where bitval-worker.js lives. Resolved against this script's own URL when it
|
|
2
|
+
// is loaded with a <script> tag (so it works from node_modules / a CDN path,
|
|
3
|
+
// not just next to the page); otherwise relative to the page. Override per
|
|
4
|
+
// instance with `bitval.workerUrl = '...'` (e.g. when bundling).
|
|
5
|
+
const _BITVAL_WORKER_URL = (function () {
|
|
6
|
+
try {
|
|
7
|
+
if (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) {
|
|
8
|
+
return new URL('bitval-worker.js', document.currentScript.src).href;
|
|
9
|
+
}
|
|
10
|
+
} catch (e) { /* fall through */ }
|
|
11
|
+
return './bitval-worker.js';
|
|
12
|
+
})();
|
|
13
|
+
|
|
1
14
|
class XorShift32 {
|
|
2
15
|
constructor(seed = null) {
|
|
3
16
|
this.state = seed !== null ? seed : Math.floor(Math.random() * 2147483647) || 1;
|
|
@@ -13,48 +26,10 @@ class XorShift32 {
|
|
|
13
26
|
}
|
|
14
27
|
}
|
|
15
28
|
|
|
16
|
-
/**
|
|
17
|
-
* Fastest auto-clearing cache with minimal overhead.
|
|
18
|
-
* Uses bitwise counter check to avoid expensive modulo operations.
|
|
19
|
-
*/
|
|
20
|
-
class FastestAutoClearingCache {
|
|
21
|
-
constructor(maxSize = 16000000) {
|
|
22
|
-
this.maxSize = maxSize;
|
|
23
|
-
this.clearThreshold = Math.floor(maxSize * 0.95); // Clear at 95% capacity
|
|
24
|
-
this.cache = new Map();
|
|
25
|
-
this.counter = 0;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
has(key) {
|
|
29
|
-
return this.cache.has(key); // No overhead on hot path
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
get(key) {
|
|
33
|
-
return this.cache.get(key); // No overhead on hot path
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
set(key, value) {
|
|
37
|
-
// Bitwise check is faster than modulo - check every ~1 million operations
|
|
38
|
-
// 0xFFFFF = 1,048,576 (2^20)
|
|
39
|
-
if ((++this.counter & 0xFFFFF) === 0) {
|
|
40
|
-
if (this.cache.size >= this.clearThreshold) {
|
|
41
|
-
this.cache.clear();
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
this.cache.set(key, value);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
clear() {
|
|
48
|
-
this.cache.clear();
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
get size() {
|
|
52
|
-
return this.cache.size;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
29
|
class BitVal {
|
|
57
30
|
constructor() {
|
|
31
|
+
this.workerUrl = _BITVAL_WORKER_URL;
|
|
32
|
+
|
|
58
33
|
// Suit constants
|
|
59
34
|
this._DIAMOND = 3n;
|
|
60
35
|
this._HEART = 2n;
|
|
@@ -183,6 +158,64 @@ class BitVal {
|
|
|
183
158
|
4503599627370496n: 67553994410557455n,
|
|
184
159
|
1n: 67553994410557455n
|
|
185
160
|
};
|
|
161
|
+
|
|
162
|
+
// ---- BigInt-free fast path (Number evaluator) lookups ----
|
|
163
|
+
// Rank index 0..12 = 2..A; suit index 0..3. A card set is four 13-bit rank
|
|
164
|
+
// masks (one per suit); evaluation and set-union run entirely on Numbers.
|
|
165
|
+
this._N_RANK = { '2': 0, '3': 1, '4': 2, '5': 3, '6': 4, '7': 5, '8': 6, '9': 7, 'T': 8, 'J': 9, 'Q': 10, 'K': 11, 'A': 12 };
|
|
166
|
+
this._N_SUIT = { s: 0, h: 1, d: 2, c: 3 };
|
|
167
|
+
this.CARD_SUITS = {}; // 'As' -> { suit, bit }
|
|
168
|
+
this._MASK_TO_SUIT = new Map(); // single-card BigInt mask -> { suit, bit }
|
|
169
|
+
for (const card in this.CARD_MASKS) {
|
|
170
|
+
const sr = { suit: this._N_SUIT[card[1]], bit: 1 << this._N_RANK[card[0]] };
|
|
171
|
+
this.CARD_SUITS[card] = sr;
|
|
172
|
+
this._MASK_TO_SUIT.set(this.CARD_MASKS[card], sr);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 13-bit rank-mask lookup tables for the fast evaluator. Each is 8192
|
|
176
|
+
// entries (~8-16 KB total), built once here, cache-resident at runtime.
|
|
177
|
+
// _POPC13[m] = number of set bits (suit length / rank count).
|
|
178
|
+
// _STRAIGHT13[m] = top rank (4..12) of the best straight, 3 for the wheel,
|
|
179
|
+
// or -1 for none. Replaces the per-eval straight scan loop.
|
|
180
|
+
this._POPC13 = new Uint8Array(8192);
|
|
181
|
+
this._STRAIGHT13 = new Int8Array(8192);
|
|
182
|
+
for (let m = 0; m < 8192; m++) {
|
|
183
|
+
let c = 0, x = m;
|
|
184
|
+
while (x) { x &= x - 1; c++; }
|
|
185
|
+
this._POPC13[m] = c;
|
|
186
|
+
let run = 0, top = -1;
|
|
187
|
+
for (let r = 12; r >= 0; r--) {
|
|
188
|
+
if (m & (1 << r)) { if (++run >= 5) { top = r + 4; break; } } else run = 0;
|
|
189
|
+
}
|
|
190
|
+
if (top < 0 && (m & (1 << 12)) && (m & 0b1111) === 0b1111) top = 3; // wheel
|
|
191
|
+
this._STRAIGHT13[m] = top;
|
|
192
|
+
}
|
|
193
|
+
// Kicker tables (replace the per-eval "peel the highest bit" loops):
|
|
194
|
+
// _TOP5V13[m] = top five ranks of m packed as r1*65536+r2*4096+r3*256+r4*16+r5
|
|
195
|
+
// (flush / high card value; fewer ranks just sum fewer terms).
|
|
196
|
+
// _KEEP2_13[m] / _KEEP3_13[m] = m with only its top 2 / top 3 bits kept, so
|
|
197
|
+
// _TOP5V13[_KEEP3_13[m]] >> 4 = k1*4096+k2*256+k3*16 (pair kickers).
|
|
198
|
+
this._TOP5V13 = new Int32Array(8192);
|
|
199
|
+
this._KEEP2_13 = new Uint16Array(8192);
|
|
200
|
+
this._KEEP3_13 = new Uint16Array(8192);
|
|
201
|
+
for (let m = 0; m < 8192; m++) {
|
|
202
|
+
let v = 0, shift = 65536, n = 0, keep = 0;
|
|
203
|
+
for (let r = 12; r >= 0 && n < 5; r--) {
|
|
204
|
+
if (m & (1 << r)) {
|
|
205
|
+
v += r * shift; shift >>= 4; n++; keep |= 1 << r;
|
|
206
|
+
if (n === 2) this._KEEP2_13[m] = keep;
|
|
207
|
+
if (n === 3) this._KEEP3_13[m] = keep;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (n < 2) this._KEEP2_13[m] = keep;
|
|
211
|
+
if (n < 3) this._KEEP3_13[m] = keep;
|
|
212
|
+
this._TOP5V13[m] = v;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Preflop exact-equity table (loaded on demand via loadPreflopTable). The 24
|
|
216
|
+
// suit permutations canonicalize a matchup for lookup (card index = rank*4+suit).
|
|
217
|
+
this._pfMap = null; this._pfWin = null; this._pfTie = null; this._pfTotal = 0;
|
|
218
|
+
this._S4 = (function () { const p = a => a.length <= 1 ? [a] : a.flatMap((x, i) => p(a.slice(0, i).concat(a.slice(i + 1))).map(r => [x, ...r])); return p([0, 1, 2, 3]); })();
|
|
186
219
|
}
|
|
187
220
|
|
|
188
221
|
// ============================================
|
|
@@ -241,6 +274,162 @@ class BitVal {
|
|
|
241
274
|
return [this.stripBits(this.normalize(hand), 5), null];
|
|
242
275
|
}
|
|
243
276
|
|
|
277
|
+
// ============================================
|
|
278
|
+
// FAST PATH - BigInt-free (Number) evaluator
|
|
279
|
+
// ============================================
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Population count of a 13-bit rank mask.
|
|
283
|
+
* @private
|
|
284
|
+
*/
|
|
285
|
+
_popc(m) { return this._POPC13[m]; }
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Index (0..12) of the highest set bit of a non-zero 13-bit rank mask.
|
|
289
|
+
* @private
|
|
290
|
+
*/
|
|
291
|
+
_high(m) { return 31 - Math.clz32(m); }
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Top rank (4..12) of the best 5-card straight in a 13-bit rank mask, or -1.
|
|
295
|
+
* Handles the A-2-3-4-5 wheel (returns 3, i.e. a five-high straight).
|
|
296
|
+
* @private
|
|
297
|
+
*/
|
|
298
|
+
_straightTop(m) { return this._STRAIGHT13[m]; }
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Builds the four 13-bit suit rank masks for an array of card strings.
|
|
302
|
+
* @private
|
|
303
|
+
*/
|
|
304
|
+
_suitsOfCards(cards) {
|
|
305
|
+
const suits = [0, 0, 0, 0];
|
|
306
|
+
for (let i = 0; i < cards.length; i++) {
|
|
307
|
+
const sr = this.CARD_SUITS[cards[i]];
|
|
308
|
+
suits[sr.suit] |= sr.bit;
|
|
309
|
+
}
|
|
310
|
+
return suits;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Decomposes a BigInt card-set mask into four 13-bit suit rank masks.
|
|
315
|
+
* Used once per matchup / combo, never in the innermost loop.
|
|
316
|
+
* @private
|
|
317
|
+
*/
|
|
318
|
+
_suitsFromBigMask(bigMask) {
|
|
319
|
+
const suits = [0, 0, 0, 0];
|
|
320
|
+
for (const [mask, sr] of this._MASK_TO_SUIT) {
|
|
321
|
+
if ((bigMask & mask) === mask) suits[sr.suit] |= sr.bit;
|
|
322
|
+
}
|
|
323
|
+
return suits;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Available cards (as {suit, bit}) not present in the given BigInt dead mask.
|
|
328
|
+
* Built once per matchup for the Monte Carlo dealing loop.
|
|
329
|
+
* @private
|
|
330
|
+
*/
|
|
331
|
+
_availableSuitCards(deadMask) {
|
|
332
|
+
const out = [];
|
|
333
|
+
for (const [mask, sr] of this._MASK_TO_SUIT) {
|
|
334
|
+
if ((deadMask & mask) === 0n) out.push((sr.suit << 16) | sr.bit);
|
|
335
|
+
}
|
|
336
|
+
return Int32Array.from(out); // packed: suit in bits 16-17, rank bit in low 13
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Flattens a BigInt combo array into a single Int32Array of suit rank masks
|
|
341
|
+
* (4 entries per combo: s, h, d, c). One contiguous buffer instead of N small
|
|
342
|
+
* arrays — better cache locality, no per-combo GC. Cached on `setup`.
|
|
343
|
+
* @private
|
|
344
|
+
*/
|
|
345
|
+
_buildComboSuits(comboArray) {
|
|
346
|
+
const out = new Int32Array(comboArray.length * 4);
|
|
347
|
+
for (let i = 0; i < comboArray.length; i++) {
|
|
348
|
+
const s = this._suitsFromBigMask(comboArray[i]);
|
|
349
|
+
const b = i * 4;
|
|
350
|
+
out[b] = s[0]; out[b + 1] = s[1]; out[b + 2] = s[2]; out[b + 3] = s[3];
|
|
351
|
+
}
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Evaluates a 5-7 card hand given its four 13-bit suit rank masks and returns
|
|
357
|
+
* a single Number whose ordering is identical to `evaluate()` (score+kickers).
|
|
358
|
+
* Higher = stronger. No BigInt, no allocation.
|
|
359
|
+
* @private
|
|
360
|
+
*/
|
|
361
|
+
_eval7(s, h, d, c) {
|
|
362
|
+
const all = s | h | d | c;
|
|
363
|
+
const POPC = this._POPC13, STR = this._STRAIGHT13;
|
|
364
|
+
|
|
365
|
+
// Flush suit (>=5 of one suit), reused for straight-flush and flush.
|
|
366
|
+
let fm = 0;
|
|
367
|
+
if (POPC[s] >= 5) fm = s;
|
|
368
|
+
else if (POPC[h] >= 5) fm = h;
|
|
369
|
+
else if (POPC[d] >= 5) fm = d;
|
|
370
|
+
else if (POPC[c] >= 5) fm = c;
|
|
371
|
+
|
|
372
|
+
// Straight flush
|
|
373
|
+
if (fm) {
|
|
374
|
+
const t = STR[fm];
|
|
375
|
+
if (t >= 0) return 8 * 1048576 + t * 65536;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Per-rank suit-count masks (exact via boolean suit combinations)
|
|
379
|
+
const four = s & h & d & c;
|
|
380
|
+
const threePlus = (s & h & d) | (s & h & c) | (s & d & c) | (h & d & c);
|
|
381
|
+
const twoPlus = (s & h) | (s & d) | (s & c) | (h & d) | (h & c) | (d & c);
|
|
382
|
+
const trips = threePlus & ~four; // exactly three
|
|
383
|
+
const pairs = twoPlus & ~threePlus; // exactly two
|
|
384
|
+
|
|
385
|
+
// Quads
|
|
386
|
+
if (four) {
|
|
387
|
+
const q = this._high(four);
|
|
388
|
+
const k = this._high(all & ~(1 << q));
|
|
389
|
+
return 7 * 1048576 + q * 65536 + k * 4096;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Full house (a trip plus another trip or a pair)
|
|
393
|
+
if (trips) {
|
|
394
|
+
const t1 = this._high(trips);
|
|
395
|
+
const restTrips = trips & ~(1 << t1);
|
|
396
|
+
const p = restTrips ? this._high(restTrips) : (pairs ? this._high(pairs) : -1);
|
|
397
|
+
if (p >= 0) return 6 * 1048576 + t1 * 65536 + p * 4096;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Flush
|
|
401
|
+
if (fm) return 5 * 1048576 + this._TOP5V13[fm];
|
|
402
|
+
|
|
403
|
+
// Straight
|
|
404
|
+
{
|
|
405
|
+
const t = STR[all];
|
|
406
|
+
if (t >= 0) return 4 * 1048576 + t * 65536;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Trips
|
|
410
|
+
if (trips) {
|
|
411
|
+
const t = this._high(trips);
|
|
412
|
+
return 3 * 1048576 + t * 65536 + (this._TOP5V13[this._KEEP2_13[all & ~(1 << t)]] >> 4);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Two pair
|
|
416
|
+
if (pairs && (pairs & (pairs - 1))) {
|
|
417
|
+
const p1 = this._high(pairs);
|
|
418
|
+
const p2 = this._high(pairs & ~(1 << p1));
|
|
419
|
+
const k = this._high(all & ~(1 << p1) & ~(1 << p2));
|
|
420
|
+
return 2 * 1048576 + p1 * 65536 + p2 * 4096 + k * 256;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// One pair
|
|
424
|
+
if (pairs) {
|
|
425
|
+
const p = this._high(pairs);
|
|
426
|
+
return 1 * 1048576 + p * 65536 + (this._TOP5V13[this._KEEP3_13[all & ~(1 << p)]] >> 4);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// High card
|
|
430
|
+
return this._TOP5V13[all];
|
|
431
|
+
}
|
|
432
|
+
|
|
244
433
|
/**
|
|
245
434
|
* Gets the human-readable hand strength from an evaluation mask.
|
|
246
435
|
* @param {BigInt} handMask - Evaluation mask with score bits
|
|
@@ -285,28 +474,24 @@ class BitVal {
|
|
|
285
474
|
* @returns {Object} { win, tie, lose, ...handStrengths } - Results object
|
|
286
475
|
*/
|
|
287
476
|
simulate(iterations, numberOfBoardCards = 5, hero = [], villain = [], board = [], deadCards = []) {
|
|
288
|
-
|
|
289
|
-
this.xorShift = new XorShift32();
|
|
477
|
+
const numberOfCardsToDeal = numberOfBoardCards - board.length;
|
|
478
|
+
const xorShift = this.xorShift = new XorShift32();
|
|
290
479
|
|
|
291
480
|
const allDeadCards = [...hero, ...villain, ...board, ...deadCards];
|
|
292
|
-
|
|
293
|
-
numberOfCardsToDeal = numberOfBoardCards - board.length;
|
|
294
|
-
hero = this.getBitMasked(hero);
|
|
295
|
-
villain = this.getBitMasked(villain);
|
|
296
|
-
board = this.getBitMasked(board);
|
|
481
|
+
const deadMask = this.getBitMasked(allDeadCards);
|
|
297
482
|
|
|
298
483
|
const availableCards = 52 - new Set(allDeadCards).size;
|
|
299
484
|
const exhaustiveCombinations = this.combinations(availableCards, numberOfCardsToDeal);
|
|
300
|
-
iterations = Math.min(iterations, exhaustiveCombinations);
|
|
301
485
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
486
|
+
// For <=2 cards to come the full runout space is tiny (<=1326), so always
|
|
487
|
+
// enumerate it exactly instead of Monte-Carloing a low iteration count.
|
|
488
|
+
// 0 cards to come (a complete board) is also exhaustive: exactly one runout.
|
|
489
|
+
// Exhaustive is exact and deterministic; the iteration count only matters
|
|
490
|
+
// for larger (>=3 cards to come) scenarios.
|
|
491
|
+
const isExhaustive = numberOfCardsToDeal >= 0 && numberOfCardsToDeal <= 2;
|
|
492
|
+
iterations = isExhaustive ? exhaustiveCombinations : Math.min(iterations, exhaustiveCombinations);
|
|
308
493
|
|
|
309
|
-
let result = {
|
|
494
|
+
let result = {
|
|
310
495
|
"win": 0,
|
|
311
496
|
"tie": 0,
|
|
312
497
|
"lose": 0,
|
|
@@ -321,34 +506,44 @@ class BitVal {
|
|
|
321
506
|
"Straight Flush": 0
|
|
322
507
|
};
|
|
323
508
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
let [villain_eval, villain_tiebreaker] = this.evaluate(villain | _board);
|
|
509
|
+
// BigInt-free hot loop (same engine as compareRange): cards are four 13-bit
|
|
510
|
+
// suit rank masks and _eval7 orders hands identically to evaluate().
|
|
511
|
+
const hs = this._suitsOfCards(hero), vs = this._suitsOfCards(villain), bs = this._suitsOfCards(board);
|
|
512
|
+
const hs0 = hs[0] | bs[0], hs1 = hs[1] | bs[1], hs2 = hs[2] | bs[2], hs3 = hs[3] | bs[3];
|
|
513
|
+
const vs0 = vs[0] | bs[0], vs1 = vs[1] | bs[1], vs2 = vs[2] | bs[2], vs3 = vs[3] | bs[3];
|
|
514
|
+
let win = 0, tie = 0, lose = 0;
|
|
331
515
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
if (hero_tiebreaker > villain_tiebreaker) {
|
|
341
|
-
result["win"]++;
|
|
342
|
-
continue;
|
|
516
|
+
if (isExhaustive) {
|
|
517
|
+
const combos = this._buildComboSuits(this._getCombinations(this._getAvailableCardMasksByLookUp(deadMask), numberOfCardsToDeal));
|
|
518
|
+
const n = Math.min(iterations, combos.length >> 2);
|
|
519
|
+
for (let i = 0; i < n; i++) {
|
|
520
|
+
const b = i << 2, d0 = combos[b], d1 = combos[b + 1], d2 = combos[b + 2], d3 = combos[b + 3];
|
|
521
|
+
const hE = this._eval7(hs0 | d0, hs1 | d1, hs2 | d2, hs3 | d3);
|
|
522
|
+
const vE = this._eval7(vs0 | d0, vs1 | d1, vs2 | d2, vs3 | d3);
|
|
523
|
+
if (hE > vE) win++; else if (vE > hE) lose++; else tie++;
|
|
343
524
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
525
|
+
} else {
|
|
526
|
+
// Partial Fisher-Yates draw from the packed pool of live cards.
|
|
527
|
+
const pool = this._availableSuitCards(deadMask), poolLen = pool.length;
|
|
528
|
+
const nDeal = poolLen < numberOfCardsToDeal ? 0 : numberOfCardsToDeal;
|
|
529
|
+
let st = xorShift.state;
|
|
530
|
+
for (let i = 0; i < iterations; i++) {
|
|
531
|
+
let d0 = 0, d1 = 0, d2 = 0, d3 = 0;
|
|
532
|
+
for (let k = 0; k < nDeal; k++) {
|
|
533
|
+
st ^= st << 13; st ^= st >> 17; st ^= st << 5;
|
|
534
|
+
const j = k + Math.abs(st) % (poolLen - k);
|
|
535
|
+
const card = pool[j]; pool[j] = pool[k]; pool[k] = card;
|
|
536
|
+
const bit = card & 0xFFFF, suit = card >> 16;
|
|
537
|
+
if (suit === 0) d0 |= bit; else if (suit === 1) d1 |= bit; else if (suit === 2) d2 |= bit; else d3 |= bit;
|
|
538
|
+
}
|
|
539
|
+
const hE = this._eval7(hs0 | d0, hs1 | d1, hs2 | d2, hs3 | d3);
|
|
540
|
+
const vE = this._eval7(vs0 | d0, vs1 | d1, vs2 | d2, vs3 | d3);
|
|
541
|
+
if (hE > vE) win++; else if (vE > hE) lose++; else tie++;
|
|
347
542
|
}
|
|
348
|
-
|
|
349
|
-
result["tie"]++
|
|
543
|
+
xorShift.state = st;
|
|
350
544
|
}
|
|
351
|
-
|
|
545
|
+
|
|
546
|
+
result.win = win; result.tie = tie; result.lose = lose;
|
|
352
547
|
return result;
|
|
353
548
|
}
|
|
354
549
|
|
|
@@ -368,13 +563,99 @@ class BitVal {
|
|
|
368
563
|
* @param {Function} progressCallback - Optional progress callback (current, total, message)
|
|
369
564
|
* @param {Number} progressInterval - Update progress every N matchups (default: 100)
|
|
370
565
|
* @param {Boolean} useWorkers - Use Web Workers for parallelization (default: true)
|
|
566
|
+
* @param {Number} mcTargetPct - Monte Carlo early-stop target: stop sampling a
|
|
567
|
+
* matchup once its 95% CI half-width is within this many equity percentage
|
|
568
|
+
* points (default: 0.3). Pass 0 to force the full `iterations`. Only affects
|
|
569
|
+
* Monte Carlo (preflop); the exact flop/turn and preflop-table paths ignore it.
|
|
371
570
|
* @returns {Promise<Object>} { win, tie, lose } - Results object
|
|
372
571
|
*/
|
|
373
|
-
async compareRange(heroHands, villainHands, boardCards = [], deadCards = [], numberOfBoardCards = 5, iterations = 10000, optimize = true, progressCallback = null, progressInterval = 100, useWorkers = true) {
|
|
572
|
+
async compareRange(heroHands, villainHands, boardCards = [], deadCards = [], numberOfBoardCards = 5, iterations = 10000, optimize = true, progressCallback = null, progressInterval = 100, useWorkers = true, mcTargetPct = 0.3) {
|
|
573
|
+
// Preflop exact fast path: if the preflop equity table is loaded and this is
|
|
574
|
+
// a full-board preflop query with no dead cards, look up every matchup's
|
|
575
|
+
// exact equity instead of Monte Carlo. Instant and exact.
|
|
576
|
+
if (this._pfMap && boardCards.length === 0 && deadCards.length === 0 && numberOfBoardCards === 5) {
|
|
577
|
+
const r = this._compareRangePreflopExact(heroHands, villainHands);
|
|
578
|
+
if (progressCallback) progressCallback(1, 1, 'Complete');
|
|
579
|
+
return r;
|
|
580
|
+
}
|
|
374
581
|
const setup = this._compareRangeSetup(heroHands, villainHands, boardCards, deadCards, numberOfBoardCards, iterations, optimize);
|
|
582
|
+
// Monte Carlo early-stop target (95% CI half-width, in equity percentage
|
|
583
|
+
// points). Default on; pass 0 to force full `iterations`. Ignored by the
|
|
584
|
+
// exact (exhaustive) path. Only affects the remaining MC case (preflop with
|
|
585
|
+
// dead cards, or preflop with no table loaded).
|
|
586
|
+
setup.mcTarget = mcTargetPct;
|
|
375
587
|
return optimize ? await this._compareRangeOptimized(setup, progressCallback, progressInterval, useWorkers) : await this._compareRangeUnoptimized(heroHands, villainHands, setup, progressCallback, progressInterval);
|
|
376
588
|
}
|
|
377
589
|
|
|
590
|
+
/**
|
|
591
|
+
* Loads the exact preflop equity table (produced by bench/gen-preflop-table.js).
|
|
592
|
+
* Accepts an ArrayBuffer (browser fetch) or Node Buffer. Enables the preflop
|
|
593
|
+
* fast path in compareRange.
|
|
594
|
+
* @param {ArrayBuffer|Uint8Array|Buffer} buffer
|
|
595
|
+
* @returns {Number} number of canonical matchups loaded
|
|
596
|
+
*/
|
|
597
|
+
loadPreflopTable(buffer) {
|
|
598
|
+
const ab = buffer instanceof ArrayBuffer ? buffer : buffer.buffer;
|
|
599
|
+
const off = buffer instanceof ArrayBuffer ? 0 : (buffer.byteOffset || 0);
|
|
600
|
+
const view = new DataView(ab, off, buffer.byteLength !== undefined ? buffer.byteLength : ab.byteLength);
|
|
601
|
+
if (view.getUint32(0, true) !== 0x50464551) throw new Error('invalid preflop table (bad magic)');
|
|
602
|
+
const count = view.getUint32(4, true);
|
|
603
|
+
this._pfTotal = view.getUint32(8, true);
|
|
604
|
+
this._pfWin = new Uint32Array(count);
|
|
605
|
+
this._pfTie = new Uint32Array(count);
|
|
606
|
+
this._pfMap = new Map();
|
|
607
|
+
for (let i = 0; i < count; i++) {
|
|
608
|
+
const o = 12 + i * 12;
|
|
609
|
+
this._pfMap.set(view.getUint32(o, true), i);
|
|
610
|
+
this._pfWin[i] = view.getUint32(o + 4, true);
|
|
611
|
+
this._pfTie[i] = view.getUint32(o + 8, true);
|
|
612
|
+
}
|
|
613
|
+
return count;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Card index (0..51) = rankIndex*4 + suitIndex, for a 2-char card string. @private */
|
|
617
|
+
_cardIndex(card) { return this._N_RANK[card[0]] * 4 + this._N_SUIT[card[1]]; }
|
|
618
|
+
|
|
619
|
+
/** Suit-isomorphic canonical key of a hero-vs-villain matchup (card indices). @private */
|
|
620
|
+
_preflopCanonKey(h1, h2, v1, v2) {
|
|
621
|
+
let best = Infinity;
|
|
622
|
+
const S4 = this._S4;
|
|
623
|
+
for (let pi = 0; pi < 24; pi++) {
|
|
624
|
+
const p = S4[pi];
|
|
625
|
+
const mh1 = (h1 & ~3) | p[h1 & 3], mh2 = (h2 & ~3) | p[h2 & 3];
|
|
626
|
+
const mv1 = (v1 & ~3) | p[v1 & 3], mv2 = (v2 & ~3) | p[v2 & 3];
|
|
627
|
+
const a = mh1 < mh2 ? mh1 : mh2, b = mh1 < mh2 ? mh2 : mh1;
|
|
628
|
+
const c = mv1 < mv2 ? mv1 : mv2, d = mv1 < mv2 ? mv2 : mv1;
|
|
629
|
+
const key = ((a * 52 + b) * 52 + c) * 52 + d;
|
|
630
|
+
if (key < best) best = key;
|
|
631
|
+
}
|
|
632
|
+
return best;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Exact preflop range-vs-range via the loaded table: sum each valid (hero,
|
|
637
|
+
* villain) matchup's exact win/tie/lose over all C(48,5) boards.
|
|
638
|
+
* @private
|
|
639
|
+
*/
|
|
640
|
+
_compareRangePreflopExact(heroHands, villainHands) {
|
|
641
|
+
const total = this._pfTotal;
|
|
642
|
+
const heroC = [], villC = [];
|
|
643
|
+
for (const h of heroHands) heroC.push(this._cardIndex(h.slice(0, 2)), this._cardIndex(h.slice(2, 4)));
|
|
644
|
+
for (const v of villainHands) villC.push(this._cardIndex(v.slice(0, 2)), this._cardIndex(v.slice(2, 4)));
|
|
645
|
+
let win = 0, tie = 0, lose = 0;
|
|
646
|
+
for (let hi = 0; hi < heroC.length; hi += 2) {
|
|
647
|
+
const h1 = heroC[hi], h2 = heroC[hi + 1];
|
|
648
|
+
for (let vi = 0; vi < villC.length; vi += 2) {
|
|
649
|
+
const v1 = villC[vi], v2 = villC[vi + 1];
|
|
650
|
+
if (h1 === v1 || h1 === v2 || h2 === v1 || h2 === v2) continue; // conflicting hole cards
|
|
651
|
+
const idx = this._pfMap.get(this._preflopCanonKey(h1, h2, v1, v2));
|
|
652
|
+
const w = this._pfWin[idx], t = this._pfTie[idx];
|
|
653
|
+
win += w; tie += t; lose += total - w - t;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return { win, tie, lose };
|
|
657
|
+
}
|
|
658
|
+
|
|
378
659
|
// ============================================
|
|
379
660
|
// PUBLIC API - Utility Methods
|
|
380
661
|
// ============================================
|
|
@@ -629,95 +910,103 @@ class BitVal {
|
|
|
629
910
|
// PRIVATE - Range Comparison Helpers
|
|
630
911
|
// ============================================
|
|
631
912
|
|
|
913
|
+
/**
|
|
914
|
+
* Parses a 2-card hand string into card indices (0..51, rank*4+suit) and a
|
|
915
|
+
* split 64-bit card bitset (lo = cards 0..31, hi = cards 32..51) so overlap
|
|
916
|
+
* tests are plain Number ANDs instead of BigInt work.
|
|
917
|
+
* @private
|
|
918
|
+
*/
|
|
919
|
+
_parseHand(hand) {
|
|
920
|
+
const a = this._cardIndex(hand), b = this._cardIndex(hand.substring(2, 4));
|
|
921
|
+
const lo = (a < 32 ? 1 << a : 0) | (b < 32 ? 1 << b : 0);
|
|
922
|
+
const hi = (a >= 32 ? 1 << (a - 32) : 0) | (b >= 32 ? 1 << (b - 32) : 0);
|
|
923
|
+
return { a, b, lo, hi };
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Card bitset (lo/hi halves) for an array of card strings.
|
|
928
|
+
* @private
|
|
929
|
+
*/
|
|
930
|
+
_cardsBitset(cards) {
|
|
931
|
+
let lo = 0, hi = 0;
|
|
932
|
+
for (const c of cards) { const i = this._cardIndex(c); if (i < 32) lo |= 1 << i; else hi |= 1 << (i - 32); }
|
|
933
|
+
return { lo, hi };
|
|
934
|
+
}
|
|
935
|
+
|
|
632
936
|
/**
|
|
633
937
|
* Generates all valid matchups between hero and villain hands, excluding overlaps.
|
|
938
|
+
* Each hand is parsed once; the O(H*V) overlap test runs on Number bitsets.
|
|
634
939
|
* @private
|
|
635
940
|
*/
|
|
636
941
|
_generateValidMatchups(heroHands, villainHands, boardCards = []) {
|
|
637
942
|
const valid = [];
|
|
638
|
-
const
|
|
943
|
+
const board = this._cardsBitset(boardCards);
|
|
944
|
+
const nV = villainHands.length;
|
|
945
|
+
const vLo = new Int32Array(nV), vHi = new Int32Array(nV), vOk = new Uint8Array(nV), vMask = new Array(nV);
|
|
946
|
+
for (let j = 0; j < nV; j++) {
|
|
947
|
+
const p = this._parseHand(villainHands[j]);
|
|
948
|
+
vLo[j] = p.lo; vHi[j] = p.hi;
|
|
949
|
+
vOk[j] = ((p.lo & board.lo) | (p.hi & board.hi)) === 0 ? 1 : 0;
|
|
950
|
+
vMask[j] = vOk[j] ? this.getBitMasked(this._handStringToCards(villainHands[j])) : 0n;
|
|
951
|
+
}
|
|
639
952
|
for (const h of heroHands) {
|
|
953
|
+
const p = this._parseHand(h);
|
|
954
|
+
if ((p.lo & board.lo) | (p.hi & board.hi)) continue;
|
|
640
955
|
const hMask = this.getBitMasked(this._handStringToCards(h));
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
if ((vMask & boardMask) !== 0n || (hMask & vMask) !== 0n) continue;
|
|
645
|
-
valid.push({ heroHand: h, villainHand: v, heroMask: hMask, villainMask: vMask });
|
|
956
|
+
for (let j = 0; j < nV; j++) {
|
|
957
|
+
if (!vOk[j] || (p.lo & vLo[j]) | (p.hi & vHi[j])) continue;
|
|
958
|
+
valid.push({ heroHand: h, villainHand: villainHands[j], heroMask: hMask, villainMask: vMask[j] });
|
|
646
959
|
}
|
|
647
960
|
}
|
|
648
961
|
return valid;
|
|
649
962
|
}
|
|
650
963
|
|
|
651
964
|
/**
|
|
652
|
-
*
|
|
653
|
-
*
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
const counts = new Map();
|
|
657
|
-
const boardSuitCounts = boardCards.length > 0 ? this._getBoardSuitCounts(boardCards) : null;
|
|
658
|
-
for (const { heroHand, villainHand } of validMatchups) {
|
|
659
|
-
const hKey = this._getCanonicalKey(heroHand, boardCards, numberOfBoardCards, boardSuitCounts);
|
|
660
|
-
const vKey = this._getCanonicalKey(villainHand, boardCards, numberOfBoardCards, boardSuitCounts);
|
|
661
|
-
const key = `${hKey}:${vKey}`;
|
|
662
|
-
counts.set(key, (counts.get(key) || 0) + 1);
|
|
663
|
-
}
|
|
664
|
-
return counts;
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
/**
|
|
668
|
-
* Sets up range comparison by generating valid matchups and canonical mappings.
|
|
965
|
+
* Sets up range comparison. The per-pair matchup list is only materialized
|
|
966
|
+
* (lazily, via the `validMatchups` getter) by the paths that need it — the
|
|
967
|
+
* Monte Carlo and unoptimized paths. The exact exhaustive path works straight
|
|
968
|
+
* from the hand strings (see _buildExhaustiveWork).
|
|
669
969
|
* @private
|
|
670
970
|
*/
|
|
671
971
|
_compareRangeSetup(heroHands, villainHands, boardCards, deadCards, numberOfBoardCards, iterations, optimize) {
|
|
672
|
-
// Generate valid matchups (always needed)
|
|
673
|
-
const validMatchups = this._generateValidMatchups(heroHands, villainHands, boardCards);
|
|
674
|
-
|
|
675
|
-
// Calculate canonical mappings only if optimization is enabled
|
|
676
|
-
let hCanon = null;
|
|
677
|
-
let vCanon = null;
|
|
678
|
-
let validCounts = null;
|
|
679
|
-
if (optimize) {
|
|
680
|
-
hCanon = this._flattenHandsToCanonical(heroHands, boardCards, numberOfBoardCards);
|
|
681
|
-
vCanon = this._flattenHandsToCanonical(villainHands, boardCards, numberOfBoardCards);
|
|
682
|
-
validCounts = this._calculateValidCounts(validMatchups, hCanon, vCanon, boardCards, numberOfBoardCards);
|
|
683
|
-
}
|
|
684
|
-
|
|
685
972
|
// Calculate bitmasks
|
|
686
973
|
const boardMask = this.getBitMasked(boardCards);
|
|
687
974
|
const deadCardsMask = this.getBitMasked([...boardCards, ...deadCards]);
|
|
688
|
-
|
|
689
|
-
// Calculate cards to deal and determine if exhaustive enumeration is possible
|
|
975
|
+
|
|
976
|
+
// Calculate cards to deal and determine if exhaustive enumeration is possible.
|
|
977
|
+
// 0 cards to come (a complete board) is exhaustive too: exactly one runout.
|
|
690
978
|
const numberOfCardsToDeal = numberOfBoardCards - boardCards.length;
|
|
691
|
-
const
|
|
692
|
-
|
|
693
|
-
//
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
//
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
const isExhaustive = canBeExhaustive && iterations === exhaustiveCombinations && exhaustiveCombinations < Infinity;
|
|
702
|
-
|
|
979
|
+
const isExhaustive = numberOfCardsToDeal >= 0 && numberOfCardsToDeal <= 2;
|
|
980
|
+
|
|
981
|
+
// For <=2 cards to come the full runout space is tiny (<=1326), so always
|
|
982
|
+
// enumerate it exactly instead of Monte-Carloing a lower requested count.
|
|
983
|
+
// Exhaustive is exact and deterministic; the requested iteration count only
|
|
984
|
+
// matters for larger (>=3 cards to come, e.g. preflop) scenarios.
|
|
985
|
+
if (isExhaustive) {
|
|
986
|
+
iterations = this.combinations(52 - this.countBits(deadCardsMask), numberOfCardsToDeal);
|
|
987
|
+
}
|
|
988
|
+
|
|
703
989
|
// Generate combo array only if exhaustive
|
|
704
|
-
const comboArray = isExhaustive
|
|
705
|
-
? this._getCombinations(this._getAvailableCardMasksByLookUp(deadCardsMask), numberOfCardsToDeal)
|
|
990
|
+
const comboArray = isExhaustive
|
|
991
|
+
? this._getCombinations(this._getAvailableCardMasksByLookUp(deadCardsMask), numberOfCardsToDeal)
|
|
706
992
|
: null;
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
993
|
+
|
|
994
|
+
const self = this;
|
|
995
|
+
return {
|
|
996
|
+
heroHands,
|
|
997
|
+
villainHands,
|
|
998
|
+
boardMask,
|
|
999
|
+
deadCardsMask,
|
|
1000
|
+
iterations,
|
|
1001
|
+
isExhaustive,
|
|
1002
|
+
comboArray,
|
|
1003
|
+
numberOfCardsToDeal,
|
|
1004
|
+
boardCards,
|
|
1005
|
+
numberOfBoardCards,
|
|
1006
|
+
_validMatchups: null,
|
|
1007
|
+
get validMatchups() {
|
|
1008
|
+
return this._validMatchups || (this._validMatchups = self._generateValidMatchups(heroHands, villainHands, boardCards));
|
|
1009
|
+
}
|
|
721
1010
|
};
|
|
722
1011
|
}
|
|
723
1012
|
|
|
@@ -735,9 +1024,10 @@ class BitVal {
|
|
|
735
1024
|
numberOfBoardCards: setup.numberOfBoardCards,
|
|
736
1025
|
iterations: setup.iterations,
|
|
737
1026
|
isExhaustive: setup.isExhaustive,
|
|
738
|
-
numberOfCardsToDeal: setup.numberOfCardsToDeal
|
|
739
|
-
|
|
740
|
-
//
|
|
1027
|
+
numberOfCardsToDeal: setup.numberOfCardsToDeal,
|
|
1028
|
+
mcTarget: setup.mcTarget
|
|
1029
|
+
// Note: validMatchups is not needed in workers; matchups are
|
|
1030
|
+
// pre-serialized and sent separately
|
|
741
1031
|
};
|
|
742
1032
|
}
|
|
743
1033
|
|
|
@@ -783,55 +1073,158 @@ class BitVal {
|
|
|
783
1073
|
* Optimized for browser performance with adaptive yielding and inlined hot loop.
|
|
784
1074
|
* @private
|
|
785
1075
|
*/
|
|
786
|
-
|
|
1076
|
+
/**
|
|
1077
|
+
* Synchronous single-matchup evaluation (no progress / yielding). Used by the
|
|
1078
|
+
* sequential path when there's no progress callback and by the Web Worker,
|
|
1079
|
+
* so both run the same tight loop with the RNG state held in a local.
|
|
1080
|
+
* @private
|
|
1081
|
+
*/
|
|
1082
|
+
_evaluateMatchupSync(heroMask, villainMask, setup) {
|
|
1083
|
+
let win = 0, tie = 0, lose = 0;
|
|
1084
|
+
const hs = this._suitsFromBigMask(heroMask), vs = this._suitsFromBigMask(villainMask);
|
|
1085
|
+
const bb = setup._boardSuits || (setup._boardSuits = this._suitsFromBigMask(setup.boardMask));
|
|
1086
|
+
const hs0 = hs[0], hs1 = hs[1], hs2 = hs[2], hs3 = hs[3];
|
|
1087
|
+
const vs0 = vs[0], vs1 = vs[1], vs2 = vs[2], vs3 = vs[3];
|
|
1088
|
+
const bb0 = bb[0], bb1 = bb[1], bb2 = bb[2], bb3 = bb[3];
|
|
1089
|
+
|
|
1090
|
+
if (setup.isExhaustive) {
|
|
1091
|
+
const comboSuits = setup._comboSuits || (setup._comboSuits = this._buildComboSuits(setup.comboArray));
|
|
1092
|
+
const n = comboSuits.length >> 2;
|
|
1093
|
+
const m0 = hs0 | vs0, m1 = hs1 | vs1, m2 = hs2 | vs2, m3 = hs3 | vs3;
|
|
1094
|
+
for (let i = 0; i < n; i++) {
|
|
1095
|
+
const b = i << 2;
|
|
1096
|
+
const c0 = comboSuits[b], c1 = comboSuits[b + 1], c2 = comboSuits[b + 2], c3 = comboSuits[b + 3];
|
|
1097
|
+
if ((c0 & m0) | (c1 & m1) | (c2 & m2) | (c3 & m3)) continue; // runout uses a hole card
|
|
1098
|
+
const d0 = bb0 | c0, d1 = bb1 | c1, d2 = bb2 | c2, d3 = bb3 | c3;
|
|
1099
|
+
const hE = this._eval7(hs0 | d0, hs1 | d1, hs2 | d2, hs3 | d3);
|
|
1100
|
+
const vE = this._eval7(vs0 | d0, vs1 | d1, vs2 | d2, vs3 | d3);
|
|
1101
|
+
if (hE > vE) win++; else if (vE > hE) lose++; else tie++;
|
|
1102
|
+
}
|
|
1103
|
+
return { matchupWin: win, matchupTie: tie, matchupLose: lose };
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const xorShift = this.xorShift = new XorShift32();
|
|
1107
|
+
const pool = this._availableSuitCards(heroMask | villainMask | setup.deadCardsMask), poolLen = pool.length;
|
|
1108
|
+
const nDeal = setup.numberOfCardsToDeal;
|
|
1109
|
+
const iterations = setup.iterations;
|
|
1110
|
+
const mcTarget = setup.mcTarget > 0 ? setup.mcTarget / 100 : 0;
|
|
1111
|
+
let ran = iterations, st = xorShift.state;
|
|
1112
|
+
for (let i = 0; i < iterations; i++) {
|
|
1113
|
+
let d0 = bb0, d1 = bb1, d2 = bb2, d3 = bb3;
|
|
1114
|
+
for (let k = 0; k < nDeal; k++) {
|
|
1115
|
+
st ^= st << 13; st ^= st >> 17; st ^= st << 5;
|
|
1116
|
+
const j = k + Math.abs(st) % (poolLen - k);
|
|
1117
|
+
const card = pool[j]; pool[j] = pool[k]; pool[k] = card;
|
|
1118
|
+
const bit = card & 0xFFFF, suit = card >> 16;
|
|
1119
|
+
if (suit === 0) d0 |= bit; else if (suit === 1) d1 |= bit; else if (suit === 2) d2 |= bit; else d3 |= bit;
|
|
1120
|
+
}
|
|
1121
|
+
const hE = this._eval7(hs0 | d0, hs1 | d1, hs2 | d2, hs3 | d3);
|
|
1122
|
+
const vE = this._eval7(vs0 | d0, vs1 | d1, vs2 | d2, vs3 | d3);
|
|
1123
|
+
if (hE > vE) win++; else if (vE > hE) lose++; else tie++;
|
|
1124
|
+
|
|
1125
|
+
// Monte Carlo early-stop check (every 4096 draws, past a warm-up).
|
|
1126
|
+
if (mcTarget && (i & 4095) === 4095 && i >= 8191) {
|
|
1127
|
+
const n = i + 1, p = (win + tie * 0.5) / n;
|
|
1128
|
+
if (1.96 * Math.sqrt(p * (1 - p) / n) < mcTarget) { ran = n; break; }
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
xorShift.state = st;
|
|
1132
|
+
|
|
1133
|
+
// If early-stopped, scale counts back to the requested iteration count so
|
|
1134
|
+
// every matchup is weighted equally in the aggregate.
|
|
1135
|
+
if (ran < iterations && ran > 0) {
|
|
1136
|
+
const s = iterations / ran;
|
|
1137
|
+
win = Math.round(win * s); tie = Math.round(tie * s); lose = Math.round(lose * s);
|
|
1138
|
+
}
|
|
1139
|
+
return { matchupWin: win, matchupTie: tie, matchupLose: lose };
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
async _evaluateMatchup(heroMask, villainMask, setup, progressCallback = null, matchupIndex = 0, totalMatchups = 1) {
|
|
1143
|
+
// No progress reporting: run the tight synchronous loop (no per-iteration
|
|
1144
|
+
// yield checks, RNG in a local).
|
|
1145
|
+
if (!progressCallback) return this._evaluateMatchupSync(heroMask, villainMask, setup);
|
|
787
1146
|
let win = 0, tie = 0, lose = 0;
|
|
788
1147
|
const deadMask = heroMask | villainMask | setup.deadCardsMask;
|
|
789
|
-
|
|
790
|
-
|
|
1148
|
+
|
|
791
1149
|
// Initialize random number generator for Monte Carlo (not needed for exhaustive)
|
|
792
1150
|
if (!setup.isExhaustive) {
|
|
793
1151
|
this.xorShift = new XorShift32();
|
|
794
1152
|
}
|
|
795
1153
|
|
|
796
|
-
// Prepare for
|
|
797
|
-
|
|
1154
|
+
// Prepare BigInt-free (Number) representations for the hot loop. The old
|
|
1155
|
+
// path built a BigInt board and called the BigInt evaluate() twice per
|
|
1156
|
+
// iteration; here cards are four 13-bit suit rank masks and _eval7 does the
|
|
1157
|
+
// work with no BigInt and no allocation. The board suit-masks and the combo
|
|
1158
|
+
// suit-masks are constant across matchups, so they're computed once and
|
|
1159
|
+
// cached on `setup`.
|
|
798
1160
|
let iterations = setup.iterations;
|
|
1161
|
+
const heroSuits = this._suitsFromBigMask(heroMask);
|
|
1162
|
+
const villainSuits = this._suitsFromBigMask(villainMask);
|
|
1163
|
+
const boardBaseSuits = setup._boardSuits || (setup._boardSuits = this._suitsFromBigMask(setup.boardMask));
|
|
1164
|
+
const hs0 = heroSuits[0], hs1 = heroSuits[1], hs2 = heroSuits[2], hs3 = heroSuits[3];
|
|
1165
|
+
const vs0 = villainSuits[0], vs1 = villainSuits[1], vs2 = villainSuits[2], vs3 = villainSuits[3];
|
|
1166
|
+
const bb0 = boardBaseSuits[0], bb1 = boardBaseSuits[1], bb2 = boardBaseSuits[2], bb3 = boardBaseSuits[3];
|
|
1167
|
+
|
|
1168
|
+
let comboSuits = null; // flat Int32Array, 4 entries per combo
|
|
1169
|
+
let pool = null, poolLen = 0; // packed Int32Array of available cards
|
|
1170
|
+
const nDeal = setup.numberOfCardsToDeal;
|
|
799
1171
|
if (setup.isExhaustive) {
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
1172
|
+
comboSuits = setup._comboSuits || (setup._comboSuits = this._buildComboSuits(setup.comboArray));
|
|
1173
|
+
iterations = comboSuits.length >> 2;
|
|
1174
|
+
} else {
|
|
1175
|
+
pool = this._availableSuitCards(deadMask);
|
|
1176
|
+
poolLen = pool.length;
|
|
803
1177
|
}
|
|
804
|
-
|
|
805
|
-
// Main evaluation loop -
|
|
806
|
-
const yieldInterval = 5000;
|
|
1178
|
+
|
|
1179
|
+
// Main evaluation loop - adaptive yielding for UI responsiveness
|
|
1180
|
+
const yieldInterval = 5000;
|
|
807
1181
|
let lastProgressTime = 0;
|
|
808
1182
|
const progressUpdateInterval = 100; // Update progress at most every 100ms
|
|
809
|
-
|
|
1183
|
+
|
|
1184
|
+
// Monte Carlo early-stop: stop once this matchup's 95% CI half-width is
|
|
1185
|
+
// within the target (lopsided matchups converge fast). Never exceeds the
|
|
1186
|
+
// requested iterations; disabled for exact enumeration and when mcTarget<=0.
|
|
1187
|
+
const mcTarget = setup.isExhaustive ? 0 : (setup.mcTarget > 0 ? setup.mcTarget / 100 : 0);
|
|
1188
|
+
const reqIters = iterations;
|
|
1189
|
+
let ran = iterations;
|
|
1190
|
+
|
|
810
1191
|
for (let i = 0; i < iterations; i++) {
|
|
811
|
-
//
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
[vEval, vKick] = this._getCachedEvaluation(evalCache.villainKey, evalCache.villainHand, board, evalCache.cache, matchupDeadMask);
|
|
1192
|
+
// Build the runout as suit rank-masks: precomputed combo (exhaustive) or
|
|
1193
|
+
// a Fisher-Yates draw from the available pool (Monte Carlo).
|
|
1194
|
+
let d0, d1, d2, d3, live = true;
|
|
1195
|
+
if (comboSuits) {
|
|
1196
|
+
const b = i << 2;
|
|
1197
|
+
const c0 = comboSuits[b], c1 = comboSuits[b + 1], c2 = comboSuits[b + 2], c3 = comboSuits[b + 3];
|
|
1198
|
+
// Skip runouts that use a hole card (impossible deals).
|
|
1199
|
+
live = ((c0 & (hs0 | vs0)) | (c1 & (hs1 | vs1)) | (c2 & (hs2 | vs2)) | (c3 & (hs3 | vs3))) === 0;
|
|
1200
|
+
d0 = bb0 | c0; d1 = bb1 | c1; d2 = bb2 | c2; d3 = bb3 | c3;
|
|
821
1201
|
} else {
|
|
822
|
-
|
|
823
|
-
|
|
1202
|
+
d0 = bb0; d1 = bb1; d2 = bb2; d3 = bb3;
|
|
1203
|
+
for (let k = 0; k < nDeal; k++) {
|
|
1204
|
+
const j = k + this.xorShift.next(poolLen - k);
|
|
1205
|
+
const tmp = pool[k]; pool[k] = pool[j]; pool[j] = tmp;
|
|
1206
|
+
const p = pool[k], bit = p & 0xFFFF, suit = p >> 16;
|
|
1207
|
+
if (suit === 0) d0 |= bit;
|
|
1208
|
+
else if (suit === 1) d1 |= bit;
|
|
1209
|
+
else if (suit === 2) d2 |= bit;
|
|
1210
|
+
else d3 |= bit;
|
|
1211
|
+
}
|
|
824
1212
|
}
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
lose++;
|
|
831
|
-
|
|
832
|
-
tie++;
|
|
1213
|
+
|
|
1214
|
+
if (live) {
|
|
1215
|
+
const hE = this._eval7(hs0 | d0, hs1 | d1, hs2 | d2, hs3 | d3);
|
|
1216
|
+
const vE = this._eval7(vs0 | d0, vs1 | d1, vs2 | d2, vs3 | d3);
|
|
1217
|
+
if (hE > vE) win++;
|
|
1218
|
+
else if (vE > hE) lose++;
|
|
1219
|
+
else tie++;
|
|
833
1220
|
}
|
|
834
|
-
|
|
1221
|
+
|
|
1222
|
+
// Monte Carlo early-stop check (every 4096 draws, past a warm-up).
|
|
1223
|
+
if (mcTarget && (i & 4095) === 4095 && i >= 8191) {
|
|
1224
|
+
const n = i + 1, p = (win + tie * 0.5) / n;
|
|
1225
|
+
if (1.96 * Math.sqrt(p * (1 - p) / n) < mcTarget) { ran = n; break; }
|
|
1226
|
+
}
|
|
1227
|
+
|
|
835
1228
|
// Adaptive yielding: less frequent but still maintains UI responsiveness
|
|
836
1229
|
if (progressCallback && i > 0 && i % yieldInterval === 0) {
|
|
837
1230
|
const now = Date.now();
|
|
@@ -871,7 +1264,15 @@ class BitVal {
|
|
|
871
1264
|
}
|
|
872
1265
|
}
|
|
873
1266
|
}
|
|
874
|
-
|
|
1267
|
+
|
|
1268
|
+
// If early-stopped, scale counts back to the requested iteration count so
|
|
1269
|
+
// the aggregate (which sums raw counts across matchups) weights every
|
|
1270
|
+
// matchup equally regardless of how early it converged.
|
|
1271
|
+
if (ran < reqIters && ran > 0) {
|
|
1272
|
+
const s = reqIters / ran;
|
|
1273
|
+
win = Math.round(win * s); tie = Math.round(tie * s); lose = Math.round(lose * s);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
875
1276
|
return { matchupWin: win, matchupTie: tie, matchupLose: lose };
|
|
876
1277
|
}
|
|
877
1278
|
|
|
@@ -887,7 +1288,7 @@ class BitVal {
|
|
|
887
1288
|
|
|
888
1289
|
for (let i = 0; i < total; i++) {
|
|
889
1290
|
const { heroMask, villainMask } = setup.validMatchups[i];
|
|
890
|
-
const { matchupWin, matchupTie, matchupLose } = await this._evaluateMatchup(heroMask, villainMask, setup,
|
|
1291
|
+
const { matchupWin, matchupTie, matchupLose } = await this._evaluateMatchup(heroMask, villainMask, setup, progressCallback, i, total);
|
|
891
1292
|
win += matchupWin; tie += matchupTie; lose += matchupLose;
|
|
892
1293
|
|
|
893
1294
|
if (progressCallback && i % progressInterval === 0) {
|
|
@@ -926,116 +1327,276 @@ class BitVal {
|
|
|
926
1327
|
return { win, tie, lose };
|
|
927
1328
|
}
|
|
928
1329
|
|
|
1330
|
+
/**
|
|
1331
|
+
* Groups the valid (hero, villain) matchups by canonical key pair for the
|
|
1332
|
+
* Monte Carlo path. Canonical keys depend only on the hand (and board), so
|
|
1333
|
+
* they're computed once per hand string, and pairs are enumerated with Number
|
|
1334
|
+
* card bitsets — no per-pair objects or string keys. Each group keeps the
|
|
1335
|
+
* first matchup (hero-major order) as its representative. Cached on `setup`.
|
|
1336
|
+
* @private
|
|
1337
|
+
*/
|
|
1338
|
+
_groupMonteCarloMatchups(setup) {
|
|
1339
|
+
if (setup._mcGroups) return setup._mcGroups;
|
|
1340
|
+
const { heroHands, villainHands, boardCards, numberOfBoardCards } = setup;
|
|
1341
|
+
const board = this._cardsBitset(boardCards);
|
|
1342
|
+
const boardSuitCounts = boardCards.length > 0 ? this._getBoardSuitCounts(boardCards) : null;
|
|
1343
|
+
const keyIds = new Map(), keyNames = [];
|
|
1344
|
+
const keyId = (hand) => {
|
|
1345
|
+
const k = this._getCanonicalKey(hand, boardCards, numberOfBoardCards, boardSuitCounts);
|
|
1346
|
+
let id = keyIds.get(k);
|
|
1347
|
+
if (id === undefined) { id = keyNames.length; keyIds.set(k, id); keyNames.push(k); }
|
|
1348
|
+
return id;
|
|
1349
|
+
};
|
|
1350
|
+
const parseSide = (hands) => {
|
|
1351
|
+
const n = hands.length, lo = new Int32Array(n), hi = new Int32Array(n), id = new Int32Array(n), ok = new Uint8Array(n);
|
|
1352
|
+
for (let i = 0; i < n; i++) {
|
|
1353
|
+
const p = this._parseHand(hands[i]);
|
|
1354
|
+
lo[i] = p.lo; hi[i] = p.hi;
|
|
1355
|
+
ok[i] = ((p.lo & board.lo) | (p.hi & board.hi)) === 0 ? 1 : 0;
|
|
1356
|
+
if (ok[i]) id[i] = keyId(hands[i]);
|
|
1357
|
+
}
|
|
1358
|
+
return { lo, hi, id, ok, n };
|
|
1359
|
+
};
|
|
1360
|
+
const H = parseSide(heroHands), V = parseSide(villainHands);
|
|
1361
|
+
const nK = keyNames.length;
|
|
1362
|
+
const groupOf = new Map(), groups = [];
|
|
1363
|
+
for (let h = 0; h < H.n; h++) {
|
|
1364
|
+
if (!H.ok[h]) continue;
|
|
1365
|
+
const hl = H.lo[h], hh = H.hi[h], hk = H.id[h] * nK;
|
|
1366
|
+
for (let v = 0; v < V.n; v++) {
|
|
1367
|
+
if (!V.ok[v] || (hl & V.lo[v]) | (hh & V.hi[v])) continue;
|
|
1368
|
+
const gk = hk + V.id[v];
|
|
1369
|
+
let g = groupOf.get(gk);
|
|
1370
|
+
if (g === undefined) {
|
|
1371
|
+
const heroHand = heroHands[h], villainHand = villainHands[v];
|
|
1372
|
+
const heroKey = keyNames[H.id[h]], villainKey = keyNames[V.id[v]];
|
|
1373
|
+
g = {
|
|
1374
|
+
key: `${heroKey}:${villainKey}`, validCount: 0, heroKey, villainKey, heroHand, villainHand,
|
|
1375
|
+
heroMask: this.getBitMasked(this._handStringToCards(heroHand)),
|
|
1376
|
+
villainMask: this.getBitMasked(this._handStringToCards(villainHand))
|
|
1377
|
+
};
|
|
1378
|
+
groupOf.set(gk, g); groups.push(g);
|
|
1379
|
+
}
|
|
1380
|
+
g.validCount++;
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
return (setup._mcGroups = groups);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
929
1386
|
/**
|
|
930
1387
|
* Compares ranges with canonical key caching optimization (sequential version).
|
|
931
1388
|
* @private
|
|
932
1389
|
*/
|
|
933
1390
|
async _compareRangeOptimizedSequential(setup, progressCallback = null, progressInterval = 100) {
|
|
934
1391
|
let win = 0, tie = 0, lose = 0;
|
|
935
|
-
const evalCache = new FastestAutoClearingCache(16000000);
|
|
936
1392
|
let lastProgressTime = 0;
|
|
937
1393
|
const progressUpdateInterval = 100; // Update progress at most every 100ms
|
|
938
1394
|
|
|
939
|
-
//
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
});
|
|
1395
|
+
// Monte Carlo (>=3 cards to come): canonical grouping + independent
|
|
1396
|
+
// per-matchup sampling (each matchup deals its own boards, so runouts can't
|
|
1397
|
+
// be shared). Grouping only pays off here, where a matchup is expensive.
|
|
1398
|
+
if (!setup.isExhaustive) {
|
|
1399
|
+
const groups = this._groupMonteCarloMatchups(setup);
|
|
1400
|
+
const totalMatchups = groups.length;
|
|
1401
|
+
let matchupIndex = 0;
|
|
1402
|
+
for (const group of groups) {
|
|
1403
|
+
const validCount = group.validCount;
|
|
1404
|
+
const validPair = group;
|
|
1405
|
+
const { matchupWin, matchupTie, matchupLose } = await this._evaluateMatchup(
|
|
1406
|
+
validPair.heroMask, validPair.villainMask, setup, progressCallback, matchupIndex, totalMatchups
|
|
1407
|
+
);
|
|
1408
|
+
win += matchupWin * validCount;
|
|
1409
|
+
tie += matchupTie * validCount;
|
|
1410
|
+
lose += matchupLose * validCount;
|
|
1411
|
+
matchupIndex++;
|
|
1412
|
+
if (progressCallback && matchupIndex % progressInterval === 0) {
|
|
1413
|
+
const now = Date.now();
|
|
1414
|
+
const shouldUpdateProgress = now - lastProgressTime >= progressUpdateInterval;
|
|
1415
|
+
if (typeof scheduler !== 'undefined' && scheduler.postTask) {
|
|
1416
|
+
await scheduler.postTask(() => { if (shouldUpdateProgress) { progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`); lastProgressTime = now; } }, { priority: 'user-blocking' });
|
|
1417
|
+
} else if (typeof requestIdleCallback !== 'undefined') {
|
|
1418
|
+
await new Promise(resolve => { requestIdleCallback(() => { if (shouldUpdateProgress) { progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`); lastProgressTime = now; } resolve(); }, { timeout: 1 }); });
|
|
1419
|
+
} else {
|
|
1420
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
1421
|
+
if (shouldUpdateProgress) { progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`); lastProgressTime = now; }
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
969
1424
|
}
|
|
970
|
-
|
|
1425
|
+
if (progressCallback) progressCallback(matchupIndex, matchupIndex, 'Complete');
|
|
1426
|
+
return { win, tie, lose };
|
|
971
1427
|
}
|
|
972
|
-
|
|
973
|
-
//
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1428
|
+
|
|
1429
|
+
// Exhaustive: EXACT evaluate-once. Build the shared work structure and run
|
|
1430
|
+
// the kernel over all runouts (on the main thread for both the worker and
|
|
1431
|
+
// non-worker paths, so they agree byte-for-byte).
|
|
1432
|
+
const work = this._buildExhaustiveWork(setup);
|
|
1433
|
+
const comboSuits = setup._comboSuits || (setup._comboSuits = this._buildComboSuits(setup.comboArray));
|
|
1434
|
+
const nRun = comboSuits.length >> 2;
|
|
1435
|
+
const r = this._runExhaustive(work, comboSuits, 0, nRun);
|
|
1436
|
+
if (progressCallback) progressCallback(nRun, nRun, 'Complete');
|
|
1437
|
+
return r;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
/**
|
|
1441
|
+
* Builds the reusable per-run work for the exact exhaustive kernel straight
|
|
1442
|
+
* from the hand strings (no per-pair objects, no BigInt). Hands are
|
|
1443
|
+
* deduplicated into a UNION of hero+villain concrete hands — each evaluated
|
|
1444
|
+
* once per runout even when it appears in both ranges — with per-hand hero
|
|
1445
|
+
* and villain multiplicities, so duplicates count exactly as they do in the
|
|
1446
|
+
* per-pair unoptimized computation. Small fields get an explicit valid-pair
|
|
1447
|
+
* list; large fields are resolved by the counting sweep in _runExhaustive.
|
|
1448
|
+
* Everything is typed arrays so it could be shipped to workers unchanged.
|
|
1449
|
+
* @private
|
|
1450
|
+
*/
|
|
1451
|
+
_buildExhaustiveWork(setup) {
|
|
1452
|
+
const board = this._cardsBitset(setup.boardCards);
|
|
1453
|
+
const uIndex = new Int32Array(52 * 52).fill(-1);
|
|
1454
|
+
const uSuitArr = [], uAArr = [], uBArr = [], uLoArr = [], uHiArr = [];
|
|
1455
|
+
const addSide = (hands) => {
|
|
1456
|
+
const u = [];
|
|
1457
|
+
for (const hand of hands) {
|
|
1458
|
+
const p = this._parseHand(hand);
|
|
1459
|
+
if ((p.lo & board.lo) | (p.hi & board.hi)) continue; // conflicts with board
|
|
1460
|
+
const key = p.a < p.b ? p.a * 52 + p.b : p.b * 52 + p.a;
|
|
1461
|
+
let ui = uIndex[key];
|
|
1462
|
+
if (ui < 0) {
|
|
1463
|
+
ui = uIndex[key] = uAArr.length;
|
|
1464
|
+
const s = this._suitsOfCards([hand.substring(0, 2), hand.substring(2, 4)]);
|
|
1465
|
+
uSuitArr.push(s[0], s[1], s[2], s[3]);
|
|
1466
|
+
uAArr.push(p.a); uBArr.push(p.b); uLoArr.push(p.lo); uHiArr.push(p.hi);
|
|
1467
|
+
}
|
|
1468
|
+
u.push(ui);
|
|
1469
|
+
}
|
|
1470
|
+
return u;
|
|
1471
|
+
};
|
|
1472
|
+
const heroU = addSide(setup.heroHands);
|
|
1473
|
+
const villU = addSide(setup.villainHands);
|
|
1474
|
+
const U = uAArr.length;
|
|
1475
|
+
const uSuit = Int32Array.from(uSuitArr), uA = Int32Array.from(uAArr), uB = Int32Array.from(uBArr);
|
|
1476
|
+
const hMult = new Int32Array(U), vMult = new Int32Array(U);
|
|
1477
|
+
for (const u of heroU) hMult[u]++;
|
|
1478
|
+
for (const u of villU) vMult[u]++;
|
|
1479
|
+
const nH = heroU.length, nV = villU.length;
|
|
1480
|
+
const bb = setup._boardSuits || (setup._boardSuits = this._suitsFromBigMask(setup.boardMask));
|
|
1481
|
+
|
|
1482
|
+
// cardU[cardUOff[c]..cardUOff[c+1]) = union hands holding card c, used to
|
|
1483
|
+
// drop hands whose hole cards appear in a runout (that deal is impossible).
|
|
1484
|
+
const cardUOff = new Int32Array(53);
|
|
1485
|
+
for (let u = 0; u < U; u++) { cardUOff[uA[u] + 1]++; cardUOff[uB[u] + 1]++; }
|
|
1486
|
+
for (let c = 0; c < 52; c++) cardUOff[c + 1] += cardUOff[c];
|
|
1487
|
+
const cardU = new Int32Array(U * 2), cfill = cardUOff.slice(0, 52);
|
|
1488
|
+
for (let u = 0; u < U; u++) { cardU[cfill[uA[u]]++] = u; cardU[cfill[uB[u]]++] = u; }
|
|
1489
|
+
|
|
1490
|
+
const useCount = nH >= 16 && nV >= 16;
|
|
1491
|
+
let pHa = null, pVa = null, P = 0, cardTotal = null;
|
|
1492
|
+
if (useCount) {
|
|
1493
|
+
// cardTotal[c] = number of villain entries holding card c.
|
|
1494
|
+
cardTotal = new Int32Array(52);
|
|
1495
|
+
for (let u = 0; u < U; u++) { cardTotal[uA[u]] += vMult[u]; cardTotal[uB[u]] += vMult[u]; }
|
|
1496
|
+
} else {
|
|
1497
|
+
const ph = [], pv = [];
|
|
1498
|
+
for (const h of heroU) {
|
|
1499
|
+
const hl = uLoArr[h], hh = uHiArr[h];
|
|
1500
|
+
for (const v of villU) {
|
|
1501
|
+
if ((hl & uLoArr[v]) | (hh & uHiArr[v])) continue;
|
|
1502
|
+
ph.push(h); pv.push(v);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
pHa = Int32Array.from(ph); pVa = Int32Array.from(pv); P = pHa.length;
|
|
1506
|
+
}
|
|
1507
|
+
return {
|
|
1508
|
+
uSuit, uA, uB, hMult, vMult, U, nH, nV, useCount, pHa, pVa, P, cardTotal, cardUOff, cardU,
|
|
1509
|
+
bb0: bb[0], bb1: bb[1], bb2: bb[2], bb3: bb[3]
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/**
|
|
1514
|
+
* Exact exhaustive kernel over runouts [runStart, runEnd). Evaluates each
|
|
1515
|
+
* unique concrete hand once per runout, then resolves matchups by direct
|
|
1516
|
+
* comparison (small fields) or, for large fields, a single sort of the
|
|
1517
|
+
* unique hands followed by a linear sweep over equal-value groups that keeps
|
|
1518
|
+
* running villain counts in total and per hole card (to subtract villains
|
|
1519
|
+
* sharing a card with the hero). Byte-identical to unoptimized.
|
|
1520
|
+
* @private
|
|
1521
|
+
*/
|
|
1522
|
+
_runExhaustive(work, comboSuits, runStart, runEnd) {
|
|
1523
|
+
const { uSuit, uA, uB, hMult, vMult, U, nV, useCount, pHa, pVa, P, cardTotal, cardUOff, cardU, bb0, bb1, bb2, bb3 } = work;
|
|
1524
|
+
const uVals = new Int32Array(U);
|
|
1525
|
+
// dead[u] === i <=> hand u holds a card dealt in runout i, so it takes no
|
|
1526
|
+
// part in that runout (neither as hero nor as villain).
|
|
1527
|
+
const dead = new Int32Array(U).fill(-1);
|
|
1528
|
+
const cardTot = useCount ? new Int32Array(52) : null;
|
|
1529
|
+
let win = 0, tie = 0, lose = 0;
|
|
1530
|
+
|
|
1531
|
+
// Large-field scratch: sort keys (value*U + u, exact in a double) and
|
|
1532
|
+
// per-card villain counts strictly below / equal to the current value.
|
|
1533
|
+
const keys = useCount ? new Float64Array(U) : null;
|
|
1534
|
+
const order = useCount ? new Int32Array(U) : null;
|
|
1535
|
+
const lessC = useCount ? new Int32Array(52) : null;
|
|
1536
|
+
const eqC = useCount ? new Int32Array(52) : null;
|
|
1537
|
+
|
|
1538
|
+
for (let i = runStart; i < runEnd; i++) {
|
|
1539
|
+
const b = i << 2;
|
|
1540
|
+
const d0 = bb0 | comboSuits[b], d1 = bb1 | comboSuits[b + 1], d2 = bb2 | comboSuits[b + 2], d3 = bb3 | comboSuits[b + 3];
|
|
1541
|
+
for (let u = 0; u < U; u++) { const q = u << 2; uVals[u] = this._eval7(uSuit[q] | d0, uSuit[q + 1] | d1, uSuit[q + 2] | d2, uSuit[q + 3] | d3); }
|
|
1542
|
+
|
|
1543
|
+
// Mark hands holding a runout card; for counting, also remove those
|
|
1544
|
+
// villains from the field total and the per-card totals.
|
|
1545
|
+
let nVi = nV;
|
|
1546
|
+
if (useCount) cardTot.set(cardTotal);
|
|
1547
|
+
for (let s = 0; s < 4; s++) {
|
|
1548
|
+
let m = comboSuits[b + s];
|
|
1549
|
+
while (m) {
|
|
1550
|
+
const low = m & -m; m ^= low;
|
|
1551
|
+
const c = (31 - Math.clz32(low)) * 4 + s;
|
|
1552
|
+
for (let k = cardUOff[c], e = cardUOff[c + 1]; k < e; k++) {
|
|
1553
|
+
const u = cardU[k];
|
|
1554
|
+
if (dead[u] === i) continue;
|
|
1555
|
+
dead[u] = i;
|
|
1556
|
+
if (useCount) { const vm = vMult[u]; if (vm) { nVi -= vm; cardTot[uA[u]] -= vm; cardTot[uB[u]] -= vm; } }
|
|
1033
1557
|
}
|
|
1034
1558
|
}
|
|
1035
1559
|
}
|
|
1560
|
+
|
|
1561
|
+
if (!useCount) {
|
|
1562
|
+
for (let p = 0; p < P; p++) {
|
|
1563
|
+
const hu = pHa[p], vu = pVa[p];
|
|
1564
|
+
if (dead[hu] === i || dead[vu] === i) continue;
|
|
1565
|
+
const hv = uVals[hu], vv = uVals[vu]; if (hv > vv) win++; else if (vv > hv) lose++; else tie++;
|
|
1566
|
+
}
|
|
1567
|
+
continue;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
for (let u = 0; u < U; u++) keys[u] = uVals[u] * U + u;
|
|
1571
|
+
keys.sort();
|
|
1572
|
+
for (let k = 0; k < U; k++) { const key = keys[k]; order[k] = key - Math.floor(key / U) * U; }
|
|
1573
|
+
lessC.fill(0);
|
|
1574
|
+
|
|
1575
|
+
let below = 0; // villain entries with value < current group value
|
|
1576
|
+
for (let g = 0; g < U;) {
|
|
1577
|
+
const x = uVals[order[g]];
|
|
1578
|
+
let e = g + 1; while (e < U && uVals[order[e]] === x) e++;
|
|
1579
|
+
// Villain counts at exactly x: total and per card.
|
|
1580
|
+
let eqTotal = 0;
|
|
1581
|
+
for (let k = g; k < e; k++) { const u = order[k]; if (dead[u] === i) continue; const m = vMult[u]; if (m) { eqTotal += m; eqC[uA[u]] += m; eqC[uB[u]] += m; } }
|
|
1582
|
+
const above = nVi - below - eqTotal;
|
|
1583
|
+
// Heroes at x. A villain holding both hero cards (the identical hand)
|
|
1584
|
+
// is subtracted on each card, so add it back once (it ties at x).
|
|
1585
|
+
for (let k = g; k < e; k++) {
|
|
1586
|
+
const u = order[k], m = hMult[u];
|
|
1587
|
+
if (!m || dead[u] === i) continue;
|
|
1588
|
+
const a = uA[u], c = uB[u];
|
|
1589
|
+
const w = below - lessC[a] - lessC[c];
|
|
1590
|
+
const t = eqTotal - eqC[a] - eqC[c] + vMult[u];
|
|
1591
|
+
const l = above - (cardTot[a] - lessC[a] - eqC[a]) - (cardTot[c] - lessC[c] - eqC[c]);
|
|
1592
|
+
win += w * m; tie += t * m; lose += l * m;
|
|
1593
|
+
}
|
|
1594
|
+
// Fold this group into the running "below" counts.
|
|
1595
|
+
for (let k = g; k < e; k++) { const u = order[k]; if (dead[u] === i) continue; const m = vMult[u]; if (m) { lessC[uA[u]] += m; lessC[uB[u]] += m; eqC[uA[u]] = 0; eqC[uB[u]] = 0; } }
|
|
1596
|
+
below += eqTotal;
|
|
1597
|
+
g = e;
|
|
1598
|
+
}
|
|
1036
1599
|
}
|
|
1037
|
-
|
|
1038
|
-
if (progressCallback) progressCallback(matchupIndex, matchupIndex, 'Complete');
|
|
1039
1600
|
return { win, tie, lose };
|
|
1040
1601
|
}
|
|
1041
1602
|
|
|
@@ -1045,85 +1606,50 @@ class BitVal {
|
|
|
1045
1606
|
* @private
|
|
1046
1607
|
*/
|
|
1047
1608
|
async _compareRangeOptimized(setup, progressCallback = null, progressInterval = 100, useWorkers = true) {
|
|
1048
|
-
//
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
//
|
|
1054
|
-
//
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
matchup.heroHand,
|
|
1059
|
-
setup.boardCards,
|
|
1060
|
-
setup.numberOfBoardCards,
|
|
1061
|
-
boardSuitCounts
|
|
1062
|
-
);
|
|
1063
|
-
const mVKey = this._getCanonicalKey(
|
|
1064
|
-
matchup.villainHand,
|
|
1065
|
-
setup.boardCards,
|
|
1066
|
-
setup.numberOfBoardCards,
|
|
1067
|
-
boardSuitCounts
|
|
1068
|
-
);
|
|
1069
|
-
const key = `${mHKey}:${mVKey}`;
|
|
1070
|
-
|
|
1071
|
-
if (!matchupGroups.has(key)) {
|
|
1072
|
-
matchupGroups.set(key, {
|
|
1073
|
-
heroKey: mHKey,
|
|
1074
|
-
villainKey: mVKey,
|
|
1075
|
-
representative: matchup,
|
|
1076
|
-
count: 0
|
|
1077
|
-
});
|
|
1078
|
-
}
|
|
1079
|
-
matchupGroups.get(key).count++;
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
// Collect all matchups to evaluate (only those that actually exist)
|
|
1083
|
-
const matchupsToEvaluate = [];
|
|
1084
|
-
for (const [key, group] of matchupGroups) {
|
|
1085
|
-
const validPair = group.representative;
|
|
1086
|
-
matchupsToEvaluate.push({
|
|
1087
|
-
key,
|
|
1088
|
-
validCount: group.count,
|
|
1089
|
-
heroKey: group.heroKey,
|
|
1090
|
-
heroHand: validPair.heroHand,
|
|
1091
|
-
heroMask: validPair.heroMask,
|
|
1092
|
-
villainKey: group.villainKey,
|
|
1093
|
-
villainHand: validPair.villainHand,
|
|
1094
|
-
villainMask: validPair.villainMask
|
|
1095
|
-
});
|
|
1609
|
+
// Exhaustive (flop/turn): run the EXACT kernel on the main thread. It's now
|
|
1610
|
+
// fast enough (~tens of ms) that this is the right choice, and it makes the
|
|
1611
|
+
// worker and non-worker paths agree byte-for-byte (fixes the exact-vs-
|
|
1612
|
+
// approximate split that per-canonical-group workers had). Splitting runouts
|
|
1613
|
+
// across workers was a net loss here (structured-cloning the pair/conflict
|
|
1614
|
+
// work to every worker dwarfs the compute) — true off-thread parallelism of
|
|
1615
|
+
// this path needs SharedArrayBuffer (COOP/COEP), left as a follow-up. The
|
|
1616
|
+
// reusable _runExhaustive / _buildExhaustiveWork are kept for that.
|
|
1617
|
+
if (setup.isExhaustive) {
|
|
1618
|
+
return await this._compareRangeOptimizedSequential(setup, progressCallback, progressInterval);
|
|
1096
1619
|
}
|
|
1097
|
-
|
|
1098
|
-
//
|
|
1620
|
+
|
|
1621
|
+
// Group validMatchups by canonical key pair (only matchups that exist).
|
|
1622
|
+
const matchupsToEvaluate = this._groupMonteCarloMatchups(setup);
|
|
1099
1623
|
const totalMatchups = matchupsToEvaluate.length;
|
|
1100
|
-
|
|
1624
|
+
|
|
1101
1625
|
// Check if workers should be used
|
|
1102
|
-
const shouldUseWorkers = useWorkers &&
|
|
1103
|
-
typeof Worker !== 'undefined' &&
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
if (
|
|
1107
|
-
|
|
1108
|
-
|
|
1626
|
+
const shouldUseWorkers = useWorkers &&
|
|
1627
|
+
typeof Worker !== 'undefined' &&
|
|
1628
|
+
totalMatchups >= 4;
|
|
1629
|
+
let workers = null;
|
|
1630
|
+
if (shouldUseWorkers) {
|
|
1631
|
+
const numWorkers = Math.min((typeof navigator !== 'undefined' && navigator.hardwareConcurrency) || 4, totalMatchups);
|
|
1632
|
+
// Persistent pool (created once, reused across calls). Creation can throw
|
|
1633
|
+
// synchronously (e.g. a cross-origin worker URL); fall back to sequential.
|
|
1634
|
+
try { workers = this._acquireWorkers(numWorkers); } catch (e) { workers = null; }
|
|
1109
1635
|
}
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
const numWorkers = Math.min(navigator.hardwareConcurrency || 4, matchupsToEvaluate.length);
|
|
1113
|
-
const batchSize = Math.ceil(matchupsToEvaluate.length / numWorkers);
|
|
1114
|
-
const batches = [];
|
|
1115
|
-
for (let i = 0; i < matchupsToEvaluate.length; i += batchSize) {
|
|
1116
|
-
batches.push(matchupsToEvaluate.slice(i, i + batchSize));
|
|
1636
|
+
if (!workers || workers.length === 0) {
|
|
1637
|
+
return await this._compareRangeOptimizedSequential(setup, progressCallback, progressInterval);
|
|
1117
1638
|
}
|
|
1118
|
-
|
|
1119
|
-
|
|
1639
|
+
const numWorkers = workers.length;
|
|
1640
|
+
|
|
1641
|
+
// Dynamic dispatch: hand out small chunks to whichever worker is free.
|
|
1642
|
+
// With Monte Carlo early-stop, matchup cost varies a lot (lopsided matchups
|
|
1643
|
+
// converge in ~8k draws, close ones run the full count), so equal static
|
|
1644
|
+
// batches left most workers idle behind the slowest one.
|
|
1645
|
+
const chunkSize = Math.max(1, Math.ceil(totalMatchups / (numWorkers * 8)));
|
|
1120
1646
|
const serializedSetup = this._serializeSetup(setup);
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1647
|
+
let next = 0, completedMatchups = 0, win = 0, tie = 0, lose = 0, alive = numWorkers;
|
|
1648
|
+
const addResult = (r) => {
|
|
1649
|
+
win += r.matchupWin * r.validCount; tie += r.matchupTie * r.validCount; lose += r.matchupLose * r.validCount;
|
|
1650
|
+
completedMatchups++;
|
|
1651
|
+
};
|
|
1652
|
+
|
|
1127
1653
|
// Progress tracking
|
|
1128
1654
|
let lastProgressTime = 0;
|
|
1129
1655
|
const progressUpdateInterval = 100;
|
|
@@ -1134,108 +1660,92 @@ class BitVal {
|
|
|
1134
1660
|
lastProgressTime = now;
|
|
1135
1661
|
}
|
|
1136
1662
|
}, progressUpdateInterval) : null;
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1663
|
+
|
|
1664
|
+
// Each worker loop pulls the next chunk until none are left. A worker that
|
|
1665
|
+
// crashes is dropped from the pool and its in-flight chunk is evaluated on
|
|
1666
|
+
// the main thread, so one bad worker never loses work or fails the call.
|
|
1667
|
+
const runWorker = (worker, workerId) => new Promise((resolve, reject) => {
|
|
1668
|
+
let current = null;
|
|
1669
|
+
const cleanup = () => { worker.onmessage = null; worker.onerror = null; };
|
|
1670
|
+
const dispatch = () => {
|
|
1671
|
+
if (next >= totalMatchups) { cleanup(); resolve(); return; }
|
|
1672
|
+
const start = next; next = Math.min(totalMatchups, next + chunkSize);
|
|
1673
|
+
current = matchupsToEvaluate.slice(start, next);
|
|
1674
|
+
worker.postMessage({ matchups: current.map(m => this._serializeMatchup(m)), setupData: serializedSetup, workerId });
|
|
1675
|
+
};
|
|
1676
|
+
// One handler set per call; cleared on settle so the pooled worker is
|
|
1677
|
+
// clean for the next call. (Assumes compareRange calls don't overlap.)
|
|
1678
|
+
worker.onmessage = (e) => {
|
|
1679
|
+
if (!e.data.success) { cleanup(); reject(new Error(e.data.error || 'Worker error')); return; }
|
|
1680
|
+
for (const r of e.data.results) addResult(r);
|
|
1681
|
+
current = null;
|
|
1682
|
+
dispatch();
|
|
1683
|
+
};
|
|
1684
|
+
worker.onerror = (error) => {
|
|
1685
|
+
if (error && error.preventDefault) error.preventDefault();
|
|
1686
|
+
cleanup();
|
|
1687
|
+
this._discardWorker(worker);
|
|
1688
|
+
try {
|
|
1689
|
+
if (current) for (const m of current) addResult({ validCount: m.validCount, ...this._evaluateMatchupSync(m.heroMask, m.villainMask, setup) });
|
|
1690
|
+
current = null;
|
|
1691
|
+
// Last worker of this call died: finish the remaining chunks here.
|
|
1692
|
+
if (--alive === 0) {
|
|
1693
|
+
while (next < totalMatchups) { const m = matchupsToEvaluate[next++]; addResult({ validCount: m.validCount, ...this._evaluateMatchupSync(m.heroMask, m.villainMask, setup) }); }
|
|
1149
1694
|
}
|
|
1150
|
-
|
|
1151
|
-
};
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
// Serialize matchups for this batch
|
|
1158
|
-
const serializedBatch = batches[i].map(m => this._serializeMatchup(m));
|
|
1159
|
-
|
|
1160
|
-
worker.postMessage({
|
|
1161
|
-
matchups: serializedBatch,
|
|
1162
|
-
setupData: serializedSetup,
|
|
1163
|
-
workerId: i
|
|
1164
|
-
});
|
|
1165
|
-
});
|
|
1166
|
-
|
|
1167
|
-
promises.push(promise);
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
// Wait for all workers and aggregate results
|
|
1171
|
-
let win = 0, tie = 0, lose = 0;
|
|
1695
|
+
resolve();
|
|
1696
|
+
} catch (err) { reject(err); }
|
|
1697
|
+
};
|
|
1698
|
+
dispatch();
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1172
1701
|
try {
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
for (const batchResults of allResults) {
|
|
1176
|
-
for (const result of batchResults) {
|
|
1177
|
-
win += result.matchupWin * result.validCount;
|
|
1178
|
-
tie += result.matchupTie * result.validCount;
|
|
1179
|
-
lose += result.matchupLose * result.validCount;
|
|
1180
|
-
}
|
|
1181
|
-
}
|
|
1702
|
+
await Promise.all(workers.map((w, i) => runWorker(w, i)));
|
|
1182
1703
|
} finally {
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
// Ensure all workers are terminated
|
|
1189
|
-
workers.forEach(worker => {
|
|
1190
|
-
try {
|
|
1191
|
-
worker.terminate();
|
|
1192
|
-
} catch (e) {
|
|
1193
|
-
// Ignore termination errors
|
|
1194
|
-
}
|
|
1195
|
-
});
|
|
1196
|
-
|
|
1197
|
-
// Final progress update
|
|
1198
|
-
if (progressCallback) {
|
|
1199
|
-
progressCallback(totalMatchups, totalMatchups, 'Complete');
|
|
1200
|
-
}
|
|
1704
|
+
if (progressIntervalId) clearInterval(progressIntervalId);
|
|
1705
|
+
// Workers are NOT terminated - they stay in the pool for the next call.
|
|
1706
|
+
// Callers can free them explicitly with terminateWorkerPool().
|
|
1707
|
+
if (progressCallback) progressCallback(totalMatchups, totalMatchups, 'Complete');
|
|
1201
1708
|
}
|
|
1202
|
-
|
|
1709
|
+
|
|
1203
1710
|
return { win, tie, lose };
|
|
1204
1711
|
}
|
|
1205
1712
|
|
|
1206
1713
|
/**
|
|
1207
|
-
*
|
|
1714
|
+
* Returns `count` persistent workers, lazily growing the pool as needed.
|
|
1715
|
+
* Reused across compareRange calls so thread-spawn + importScripts parse is
|
|
1716
|
+
* paid once rather than per query.
|
|
1208
1717
|
* @private
|
|
1209
1718
|
*/
|
|
1210
|
-
|
|
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;
|
|
1719
|
+
_acquireWorkers(count) {
|
|
1720
|
+
if (!this._workerPool) this._workerPool = [];
|
|
1721
|
+
while (this._workerPool.length < count) {
|
|
1722
|
+
this._workerPool.push(new Worker(this.workerUrl));
|
|
1723
|
+
}
|
|
1724
|
+
return this._workerPool.slice(0, count);
|
|
1225
1725
|
}
|
|
1226
1726
|
|
|
1227
1727
|
/**
|
|
1228
|
-
*
|
|
1229
|
-
* Uses numeric hash keys for faster cache lookups.
|
|
1728
|
+
* Removes a (typically crashed) worker from the pool and terminates it.
|
|
1230
1729
|
* @private
|
|
1231
1730
|
*/
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1731
|
+
_discardWorker(worker) {
|
|
1732
|
+
if (this._workerPool) {
|
|
1733
|
+
const idx = this._workerPool.indexOf(worker);
|
|
1734
|
+
if (idx !== -1) this._workerPool.splice(idx, 1);
|
|
1735
|
+
}
|
|
1736
|
+
try { worker.terminate(); } catch (e) { /* ignore */ }
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
/**
|
|
1740
|
+
* Terminates and clears the persistent worker pool. Call when done with an
|
|
1741
|
+
* instance (e.g. on teardown) to free the worker threads.
|
|
1742
|
+
*/
|
|
1743
|
+
terminateWorkerPool() {
|
|
1744
|
+
if (!this._workerPool) return;
|
|
1745
|
+
for (const worker of this._workerPool) {
|
|
1746
|
+
try { worker.terminate(); } catch (e) { /* ignore */ }
|
|
1747
|
+
}
|
|
1748
|
+
this._workerPool = null;
|
|
1239
1749
|
}
|
|
1240
1750
|
|
|
1241
1751
|
/**
|
|
@@ -1302,26 +1812,6 @@ class BitVal {
|
|
|
1302
1812
|
}
|
|
1303
1813
|
}
|
|
1304
1814
|
|
|
1305
|
-
/**
|
|
1306
|
-
* Flattens hands to canonical keys for caching optimization.
|
|
1307
|
-
* @private
|
|
1308
|
-
*/
|
|
1309
|
-
_flattenHandsToCanonical(hands, boardCards = [], numberOfBoardCards = 5) {
|
|
1310
|
-
const canonicalMap = new Map();
|
|
1311
|
-
const canonicalToHand = new Map();
|
|
1312
|
-
const boardSuitCounts = boardCards.length > 0 ? this._getBoardSuitCounts(boardCards) : null;
|
|
1313
|
-
|
|
1314
|
-
for (const hand of hands) {
|
|
1315
|
-
const key = this._getCanonicalKey(hand, boardCards, numberOfBoardCards, boardSuitCounts);
|
|
1316
|
-
canonicalMap.set(key, (canonicalMap.get(key) || 0) + 1);
|
|
1317
|
-
if (!canonicalToHand.has(key)) {
|
|
1318
|
-
canonicalToHand.set(key, hand);
|
|
1319
|
-
}
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
return { canonicalMap, canonicalToHand };
|
|
1323
|
-
}
|
|
1324
|
-
|
|
1325
1815
|
/**
|
|
1326
1816
|
* Gets board suit counts for flush potential calculation.
|
|
1327
1817
|
* @private
|
|
@@ -1386,6 +1876,7 @@ class BitVal {
|
|
|
1386
1876
|
* @private
|
|
1387
1877
|
*/
|
|
1388
1878
|
_getCombinations(availableMasks, k) {
|
|
1879
|
+
if (k === 0) return [0n]; // complete board: exactly one runout (the empty draw)
|
|
1389
1880
|
if (k === 1) return availableMasks;
|
|
1390
1881
|
if (k === 2) {
|
|
1391
1882
|
let combos = [];
|