poker-bitvaljs 1.1.2 → 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 +574 -284
- package/package.json +11 -2
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
|
// ============================================
|
|
@@ -672,13 +931,6 @@ class BitVal {
|
|
|
672
931
|
// Generate valid matchups (always needed)
|
|
673
932
|
const validMatchups = this._generateValidMatchups(heroHands, villainHands, boardCards);
|
|
674
933
|
|
|
675
|
-
// Log all valid matchups for debugging
|
|
676
|
-
console.log(`\n[Valid Matchups] Generated ${validMatchups.length} valid matchups:`);
|
|
677
|
-
for (let i = 0; i < validMatchups.length; i++) {
|
|
678
|
-
const m = validMatchups[i];
|
|
679
|
-
console.log(` ${i + 1}. Hero: ${m.heroHand} vs Villain: ${m.villainHand}`);
|
|
680
|
-
}
|
|
681
|
-
|
|
682
934
|
// Calculate canonical mappings only if optimization is enabled
|
|
683
935
|
let hCanon = null;
|
|
684
936
|
let vCanon = null;
|
|
@@ -693,19 +945,24 @@ class BitVal {
|
|
|
693
945
|
const boardMask = this.getBitMasked(boardCards);
|
|
694
946
|
const deadCardsMask = this.getBitMasked([...boardCards, ...deadCards]);
|
|
695
947
|
|
|
696
|
-
// 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.
|
|
697
950
|
const numberOfCardsToDeal = numberOfBoardCards - boardCards.length;
|
|
698
|
-
const canBeExhaustive = numberOfCardsToDeal
|
|
951
|
+
const canBeExhaustive = numberOfCardsToDeal >= 0 && numberOfCardsToDeal <= 2;
|
|
699
952
|
|
|
700
953
|
// Calculate available cards using bitmask (more efficient than Set)
|
|
701
954
|
const availableCards = 52 - this.countBits(deadCardsMask);
|
|
702
955
|
|
|
703
956
|
// Determine if we should use exhaustive enumeration
|
|
704
|
-
const exhaustiveCombinations = canBeExhaustive
|
|
705
|
-
? this.combinations(availableCards, numberOfCardsToDeal)
|
|
957
|
+
const exhaustiveCombinations = canBeExhaustive
|
|
958
|
+
? this.combinations(availableCards, numberOfCardsToDeal)
|
|
706
959
|
: Infinity;
|
|
707
|
-
|
|
708
|
-
|
|
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);
|
|
709
966
|
|
|
710
967
|
// Generate combo array only if exhaustive
|
|
711
968
|
const comboArray = isExhaustive
|
|
@@ -742,7 +999,8 @@ class BitVal {
|
|
|
742
999
|
numberOfBoardCards: setup.numberOfBoardCards,
|
|
743
1000
|
iterations: setup.iterations,
|
|
744
1001
|
isExhaustive: setup.isExhaustive,
|
|
745
|
-
numberOfCardsToDeal: setup.numberOfCardsToDeal
|
|
1002
|
+
numberOfCardsToDeal: setup.numberOfCardsToDeal,
|
|
1003
|
+
mcTarget: setup.mcTarget
|
|
746
1004
|
// Note: hCanon, vCanon, validMatchups, validCounts are not needed in workers
|
|
747
1005
|
// as matchups are pre-serialized and sent separately
|
|
748
1006
|
};
|
|
@@ -790,55 +1048,84 @@ class BitVal {
|
|
|
790
1048
|
* Optimized for browser performance with adaptive yielding and inlined hot loop.
|
|
791
1049
|
* @private
|
|
792
1050
|
*/
|
|
793
|
-
async _evaluateMatchup(heroMask, villainMask, setup,
|
|
1051
|
+
async _evaluateMatchup(heroMask, villainMask, setup, progressCallback = null, matchupIndex = 0, totalMatchups = 1) {
|
|
794
1052
|
let win = 0, tie = 0, lose = 0;
|
|
795
1053
|
const deadMask = heroMask | villainMask | setup.deadCardsMask;
|
|
796
|
-
|
|
797
|
-
|
|
1054
|
+
|
|
798
1055
|
// Initialize random number generator for Monte Carlo (not needed for exhaustive)
|
|
799
1056
|
if (!setup.isExhaustive) {
|
|
800
1057
|
this.xorShift = new XorShift32();
|
|
801
1058
|
}
|
|
802
1059
|
|
|
803
|
-
// Prepare for
|
|
804
|
-
|
|
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`.
|
|
805
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;
|
|
806
1077
|
if (setup.isExhaustive) {
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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;
|
|
810
1083
|
}
|
|
811
|
-
|
|
812
|
-
// Main evaluation loop -
|
|
813
|
-
const yieldInterval = 5000;
|
|
1084
|
+
|
|
1085
|
+
// Main evaluation loop - adaptive yielding for UI responsiveness
|
|
1086
|
+
const yieldInterval = 5000;
|
|
814
1087
|
let lastProgressTime = 0;
|
|
815
1088
|
const progressUpdateInterval = 100; // Update progress at most every 100ms
|
|
816
|
-
|
|
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
|
+
|
|
817
1097
|
for (let i = 0; i < iterations; i++) {
|
|
818
|
-
//
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
let hEval, hKick, vEval, vKick;
|
|
825
|
-
if (evalCache) {
|
|
826
|
-
[hEval, hKick] = this._getCachedEvaluation(evalCache.heroKey, evalCache.heroHand, board, evalCache.cache, matchupDeadMask);
|
|
827
|
-
[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];
|
|
828
1104
|
} else {
|
|
829
|
-
|
|
830
|
-
|
|
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
|
+
}
|
|
831
1115
|
}
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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; }
|
|
840
1127
|
}
|
|
841
|
-
|
|
1128
|
+
|
|
842
1129
|
// Adaptive yielding: less frequent but still maintains UI responsiveness
|
|
843
1130
|
if (progressCallback && i > 0 && i % yieldInterval === 0) {
|
|
844
1131
|
const now = Date.now();
|
|
@@ -878,7 +1165,15 @@ class BitVal {
|
|
|
878
1165
|
}
|
|
879
1166
|
}
|
|
880
1167
|
}
|
|
881
|
-
|
|
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
|
+
|
|
882
1177
|
return { matchupWin: win, matchupTie: tie, matchupLose: lose };
|
|
883
1178
|
}
|
|
884
1179
|
|
|
@@ -894,7 +1189,7 @@ class BitVal {
|
|
|
894
1189
|
|
|
895
1190
|
for (let i = 0; i < total; i++) {
|
|
896
1191
|
const { heroMask, villainMask } = setup.validMatchups[i];
|
|
897
|
-
const { matchupWin, matchupTie, matchupLose } = await this._evaluateMatchup(heroMask, villainMask, setup,
|
|
1192
|
+
const { matchupWin, matchupTie, matchupLose } = await this._evaluateMatchup(heroMask, villainMask, setup, progressCallback, i, total);
|
|
898
1193
|
win += matchupWin; tie += matchupTie; lose += matchupLose;
|
|
899
1194
|
|
|
900
1195
|
if (progressCallback && i % progressInterval === 0) {
|
|
@@ -939,7 +1234,6 @@ class BitVal {
|
|
|
939
1234
|
*/
|
|
940
1235
|
async _compareRangeOptimizedSequential(setup, progressCallback = null, progressInterval = 100) {
|
|
941
1236
|
let win = 0, tie = 0, lose = 0;
|
|
942
|
-
const evalCache = new FastestAutoClearingCache(16000000);
|
|
943
1237
|
let lastProgressTime = 0;
|
|
944
1238
|
const progressUpdateInterval = 100; // Update progress at most every 100ms
|
|
945
1239
|
|
|
@@ -948,118 +1242,129 @@ class BitVal {
|
|
|
948
1242
|
? this._getBoardSuitCounts(setup.boardCards)
|
|
949
1243
|
: null;
|
|
950
1244
|
|
|
951
|
-
//
|
|
952
|
-
//
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
const
|
|
957
|
-
matchup.heroHand,
|
|
958
|
-
setup.boardCards,
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
matchup.villainHand,
|
|
964
|
-
setup.boardCards,
|
|
965
|
-
setup.numberOfBoardCards,
|
|
966
|
-
boardSuitCounts
|
|
967
|
-
);
|
|
968
|
-
const key = `${mHKey}:${mVKey}`;
|
|
969
|
-
|
|
970
|
-
console.log(` Hero: ${matchup.heroHand} (${mHKey}) vs Villain: ${matchup.villainHand} (${mVKey}) -> Key: ${key}`);
|
|
971
|
-
|
|
972
|
-
if (!matchupGroups.has(key)) {
|
|
973
|
-
matchupGroups.set(key, {
|
|
974
|
-
heroKey: mHKey,
|
|
975
|
-
villainKey: mVKey,
|
|
976
|
-
representative: matchup,
|
|
977
|
-
count: 0
|
|
978
|
-
});
|
|
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++;
|
|
979
1257
|
}
|
|
980
|
-
matchupGroups.
|
|
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; }
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
if (progressCallback) progressCallback(matchupIndex, matchupIndex, 'Complete');
|
|
1284
|
+
return { win, tie, lose };
|
|
981
1285
|
}
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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);
|
|
985
1315
|
}
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
const
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
const
|
|
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
|
-
const
|
|
1020
|
-
const
|
|
1021
|
-
const
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
// Use modern browser APIs for better scheduling when available
|
|
1034
|
-
if (typeof scheduler !== 'undefined' && scheduler.postTask) {
|
|
1035
|
-
await scheduler.postTask(() => {
|
|
1036
|
-
if (shouldUpdateProgress) {
|
|
1037
|
-
progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`);
|
|
1038
|
-
lastProgressTime = now;
|
|
1039
|
-
}
|
|
1040
|
-
}, { priority: 'user-blocking' });
|
|
1041
|
-
} else if (typeof requestIdleCallback !== 'undefined') {
|
|
1042
|
-
await new Promise(resolve => {
|
|
1043
|
-
requestIdleCallback(() => {
|
|
1044
|
-
if (shouldUpdateProgress) {
|
|
1045
|
-
progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`);
|
|
1046
|
-
lastProgressTime = now;
|
|
1047
|
-
}
|
|
1048
|
-
resolve();
|
|
1049
|
-
}, { timeout: 1 });
|
|
1050
|
-
});
|
|
1051
|
-
} else {
|
|
1052
|
-
// Fallback to setTimeout
|
|
1053
|
-
await new Promise(resolve => setTimeout(resolve, 0));
|
|
1054
|
-
if (shouldUpdateProgress) {
|
|
1055
|
-
progressCallback(matchupIndex, totalMatchups, `Evaluating ${matchupIndex}`);
|
|
1056
|
-
lastProgressTime = now;
|
|
1057
|
-
}
|
|
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;
|
|
1058
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++; }
|
|
1059
1366
|
}
|
|
1060
1367
|
}
|
|
1061
|
-
|
|
1062
|
-
if (progressCallback) progressCallback(matchupIndex, matchupIndex, 'Complete');
|
|
1063
1368
|
return { win, tie, lose };
|
|
1064
1369
|
}
|
|
1065
1370
|
|
|
@@ -1069,15 +1374,26 @@ class BitVal {
|
|
|
1069
1374
|
* @private
|
|
1070
1375
|
*/
|
|
1071
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
|
+
|
|
1072
1389
|
// Pre-calculate board suit counts once (constant for all matchups)
|
|
1073
|
-
const boardSuitCounts = setup.boardCards.length > 0
|
|
1074
|
-
? this._getBoardSuitCounts(setup.boardCards)
|
|
1390
|
+
const boardSuitCounts = setup.boardCards.length > 0
|
|
1391
|
+
? this._getBoardSuitCounts(setup.boardCards)
|
|
1075
1392
|
: null;
|
|
1076
1393
|
|
|
1077
1394
|
// Group validMatchups by canonical key pair
|
|
1078
1395
|
// This ensures we only process matchups that actually exist, avoiding Cartesian product issues
|
|
1079
1396
|
const matchupGroups = new Map();
|
|
1080
|
-
console.log(`\n[Parallel - Grouping Matchups] Processing ${setup.validMatchups.length} valid matchups:`);
|
|
1081
1397
|
for (const matchup of setup.validMatchups) {
|
|
1082
1398
|
const mHKey = this._getCanonicalKey(
|
|
1083
1399
|
matchup.heroHand,
|
|
@@ -1093,8 +1409,6 @@ class BitVal {
|
|
|
1093
1409
|
);
|
|
1094
1410
|
const key = `${mHKey}:${mVKey}`;
|
|
1095
1411
|
|
|
1096
|
-
console.log(` Hero: ${matchup.heroHand} (${mHKey}) vs Villain: ${matchup.villainHand} (${mVKey}) -> Key: ${key}`);
|
|
1097
|
-
|
|
1098
1412
|
if (!matchupGroups.has(key)) {
|
|
1099
1413
|
matchupGroups.set(key, {
|
|
1100
1414
|
heroKey: mHKey,
|
|
@@ -1105,17 +1419,11 @@ class BitVal {
|
|
|
1105
1419
|
}
|
|
1106
1420
|
matchupGroups.get(key).count++;
|
|
1107
1421
|
}
|
|
1108
|
-
console.log(`\n[Parallel - Matchup Groups] Created ${matchupGroups.size} unique canonical key pairs:`);
|
|
1109
|
-
for (const [key, group] of matchupGroups) {
|
|
1110
|
-
console.log(` ${key}: ${group.count} matchup(s), example: ${group.representative.heroHand} vs ${group.representative.villainHand}`);
|
|
1111
|
-
}
|
|
1112
1422
|
|
|
1113
1423
|
// Collect all matchups to evaluate (only those that actually exist)
|
|
1114
1424
|
const matchupsToEvaluate = [];
|
|
1115
|
-
console.log(`\n[Parallel - Collecting Matchups] Building matchup list from ${matchupGroups.size} groups:`);
|
|
1116
1425
|
for (const [key, group] of matchupGroups) {
|
|
1117
1426
|
const validPair = group.representative;
|
|
1118
|
-
console.log(` Adding: ${key} (${group.count}x) - Hero: ${validPair.heroHand} vs Villain: ${validPair.villainHand}`);
|
|
1119
1427
|
matchupsToEvaluate.push({
|
|
1120
1428
|
key,
|
|
1121
1429
|
validCount: group.count,
|
|
@@ -1127,7 +1435,6 @@ class BitVal {
|
|
|
1127
1435
|
villainMask: validPair.villainMask
|
|
1128
1436
|
});
|
|
1129
1437
|
}
|
|
1130
|
-
console.log(`\n[Parallel - Matchups to Evaluate] Total: ${matchupsToEvaluate.length} unique matchups`);
|
|
1131
1438
|
|
|
1132
1439
|
// Calculate total matchups (number of unique canonical key pairs)
|
|
1133
1440
|
const totalMatchups = matchupsToEvaluate.length;
|
|
@@ -1149,15 +1456,17 @@ class BitVal {
|
|
|
1149
1456
|
for (let i = 0; i < matchupsToEvaluate.length; i += batchSize) {
|
|
1150
1457
|
batches.push(matchupsToEvaluate.slice(i, i + batchSize));
|
|
1151
1458
|
}
|
|
1152
|
-
|
|
1459
|
+
|
|
1153
1460
|
// Serialize setup for workers
|
|
1154
1461
|
const serializedSetup = this._serializeSetup(setup);
|
|
1155
|
-
|
|
1156
|
-
//
|
|
1157
|
-
|
|
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);
|
|
1158
1467
|
const promises = [];
|
|
1159
1468
|
let completedMatchups = 0;
|
|
1160
|
-
|
|
1469
|
+
|
|
1161
1470
|
// Progress tracking
|
|
1162
1471
|
let lastProgressTime = 0;
|
|
1163
1472
|
const progressUpdateInterval = 100;
|
|
@@ -1168,58 +1477,50 @@ class BitVal {
|
|
|
1168
1477
|
lastProgressTime = now;
|
|
1169
1478
|
}
|
|
1170
1479
|
}, progressUpdateInterval) : null;
|
|
1171
|
-
|
|
1480
|
+
|
|
1172
1481
|
for (let i = 0; i < batches.length; i++) {
|
|
1173
|
-
const worker =
|
|
1174
|
-
|
|
1175
|
-
|
|
1482
|
+
const worker = workers[i];
|
|
1483
|
+
|
|
1176
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; };
|
|
1177
1488
|
worker.onmessage = (e) => {
|
|
1489
|
+
cleanup();
|
|
1178
1490
|
if (e.data.success) {
|
|
1179
1491
|
completedMatchups += e.data.results.length;
|
|
1180
1492
|
resolve(e.data.results);
|
|
1181
1493
|
} else {
|
|
1182
1494
|
reject(new Error(e.data.error || 'Worker error'));
|
|
1183
1495
|
}
|
|
1184
|
-
worker.terminate();
|
|
1185
1496
|
};
|
|
1186
1497
|
worker.onerror = (error) => {
|
|
1498
|
+
cleanup();
|
|
1499
|
+
// A crashed worker can't be reused - drop it from the pool.
|
|
1500
|
+
this._discardWorker(worker);
|
|
1187
1501
|
reject(error);
|
|
1188
|
-
worker.terminate();
|
|
1189
1502
|
};
|
|
1190
|
-
|
|
1503
|
+
|
|
1191
1504
|
// Serialize matchups for this batch
|
|
1192
1505
|
const serializedBatch = batches[i].map(m => this._serializeMatchup(m));
|
|
1193
|
-
|
|
1506
|
+
|
|
1194
1507
|
worker.postMessage({
|
|
1195
1508
|
matchups: serializedBatch,
|
|
1196
1509
|
setupData: serializedSetup,
|
|
1197
1510
|
workerId: i
|
|
1198
1511
|
});
|
|
1199
1512
|
});
|
|
1200
|
-
|
|
1513
|
+
|
|
1201
1514
|
promises.push(promise);
|
|
1202
1515
|
}
|
|
1203
|
-
|
|
1516
|
+
|
|
1204
1517
|
// Wait for all workers and aggregate results
|
|
1205
1518
|
let win = 0, tie = 0, lose = 0;
|
|
1206
1519
|
try {
|
|
1207
1520
|
const allResults = await Promise.all(promises);
|
|
1208
|
-
|
|
1521
|
+
|
|
1209
1522
|
for (const batchResults of allResults) {
|
|
1210
1523
|
for (const result of batchResults) {
|
|
1211
|
-
// Log equity results for this matchup
|
|
1212
|
-
const totalIterations = result.matchupWin + result.matchupTie + result.matchupLose;
|
|
1213
|
-
const equity = totalIterations > 0 ? (result.matchupWin + result.matchupTie / 2) / totalIterations * 100 : 0;
|
|
1214
|
-
const weightedEquity = equity * result.validCount;
|
|
1215
|
-
|
|
1216
|
-
// Find the matchup details from matchupsToEvaluate
|
|
1217
|
-
const matchup = matchupsToEvaluate.find(m => m.key === result.key);
|
|
1218
|
-
const heroHand = matchup ? matchup.heroHand : 'Unknown';
|
|
1219
|
-
const villainHand = matchup ? matchup.villainHand : 'Unknown';
|
|
1220
|
-
|
|
1221
|
-
console.log(`[Equity] ${heroHand} vs ${villainHand} (${result.validCount}x): Win=${result.matchupWin}, Tie=${result.matchupTie}, Lose=${result.matchupLose}, Total=${totalIterations}, Equity=${equity.toFixed(2)}%, Weighted=${weightedEquity.toFixed(2)}%`);
|
|
1222
|
-
|
|
1223
1524
|
win += result.matchupWin * result.validCount;
|
|
1224
1525
|
tie += result.matchupTie * result.validCount;
|
|
1225
1526
|
lose += result.matchupLose * result.validCount;
|
|
@@ -1230,58 +1531,55 @@ class BitVal {
|
|
|
1230
1531
|
if (progressIntervalId) {
|
|
1231
1532
|
clearInterval(progressIntervalId);
|
|
1232
1533
|
}
|
|
1233
|
-
|
|
1234
|
-
//
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
worker.terminate();
|
|
1238
|
-
} catch (e) {
|
|
1239
|
-
// Ignore termination errors
|
|
1240
|
-
}
|
|
1241
|
-
});
|
|
1242
|
-
|
|
1534
|
+
|
|
1535
|
+
// Workers are NOT terminated - they stay in the pool for the next call.
|
|
1536
|
+
// Callers can free them explicitly with terminateWorkerPool().
|
|
1537
|
+
|
|
1243
1538
|
// Final progress update
|
|
1244
1539
|
if (progressCallback) {
|
|
1245
1540
|
progressCallback(totalMatchups, totalMatchups, 'Complete');
|
|
1246
1541
|
}
|
|
1247
1542
|
}
|
|
1248
|
-
|
|
1543
|
+
|
|
1249
1544
|
return { win, tie, lose };
|
|
1250
1545
|
}
|
|
1251
1546
|
|
|
1252
1547
|
/**
|
|
1253
|
-
*
|
|
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.
|
|
1254
1551
|
* @private
|
|
1255
1552
|
*/
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
}
|
|
1263
|
-
// Combine with board bits (use lower 32 bits of BigInt for fast XOR)
|
|
1264
|
-
const boardBits = Number(board & 0xFFFFFFFFn);
|
|
1265
|
-
hash = hash ^ boardBits;
|
|
1266
|
-
|
|
1267
|
-
// Include matchup dead mask to prevent cross-matchup cache collisions
|
|
1268
|
-
// This ensures different matchups (with different dead cards) don't share cache entries
|
|
1269
|
-
const deadBits = Number(matchupDeadMask & 0xFFFFFFFFn);
|
|
1270
|
-
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);
|
|
1271
1559
|
}
|
|
1272
1560
|
|
|
1273
1561
|
/**
|
|
1274
|
-
*
|
|
1275
|
-
* Uses numeric hash keys for faster cache lookups.
|
|
1562
|
+
* Removes a (typically crashed) worker from the pool and terminates it.
|
|
1276
1563
|
* @private
|
|
1277
1564
|
*/
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
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;
|
|
1285
1583
|
}
|
|
1286
1584
|
|
|
1287
1585
|
/**
|
|
@@ -1357,21 +1655,12 @@ class BitVal {
|
|
|
1357
1655
|
const canonicalToHand = new Map();
|
|
1358
1656
|
const boardSuitCounts = boardCards.length > 0 ? this._getBoardSuitCounts(boardCards) : null;
|
|
1359
1657
|
|
|
1360
|
-
console.log(`\n[Canonical Mapping] Processing ${hands.length} hands:`);
|
|
1361
1658
|
for (const hand of hands) {
|
|
1362
1659
|
const key = this._getCanonicalKey(hand, boardCards, numberOfBoardCards, boardSuitCounts);
|
|
1363
1660
|
canonicalMap.set(key, (canonicalMap.get(key) || 0) + 1);
|
|
1364
1661
|
if (!canonicalToHand.has(key)) {
|
|
1365
1662
|
canonicalToHand.set(key, hand);
|
|
1366
1663
|
}
|
|
1367
|
-
console.log(` Hand: ${hand} -> Canonical Key: ${key}`);
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
// Show summary of canonical key groupings
|
|
1371
|
-
console.log(`\n[Canonical Summary] ${canonicalMap.size} unique canonical keys from ${hands.length} hands:`);
|
|
1372
|
-
for (const [key, count] of canonicalMap.entries()) {
|
|
1373
|
-
const representativeHand = canonicalToHand.get(key);
|
|
1374
|
-
console.log(` ${key}: ${count} hand(s), example: ${representativeHand}`);
|
|
1375
1664
|
}
|
|
1376
1665
|
|
|
1377
1666
|
return { canonicalMap, canonicalToHand };
|
|
@@ -1441,6 +1730,7 @@ class BitVal {
|
|
|
1441
1730
|
* @private
|
|
1442
1731
|
*/
|
|
1443
1732
|
_getCombinations(availableMasks, k) {
|
|
1733
|
+
if (k === 0) return [0n]; // complete board: exactly one runout (the empty draw)
|
|
1444
1734
|
if (k === 1) return availableMasks;
|
|
1445
1735
|
if (k === 2) {
|
|
1446
1736
|
let combos = [];
|