poker-irongrid 1.0.0 → 1.2.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 CHANGED
@@ -11,9 +11,9 @@ npm install poker-irongrid
11
11
  ## Peer Dependencies
12
12
 
13
13
  - `svelte`: ^5.0.0
14
- - `poker-bitvaljs`: ^1.0.1 (for equity evaluation)
15
- - `poker-extval`: ^3.2.0 (for hand evaluation)
16
- - `poker-rangeman`: ^3.3.2 (for range parsing and filtering)
14
+ - `poker-bitvaljs`: ^1.13.0 (for equity evaluation)
15
+ - `poker-extval`: ^6.0.0-beta.6 (for hand evaluation)
16
+ - `poker-rangeman`: ^4.0.0-beta.11 (for range parsing and filtering)
17
17
 
18
18
  ## Quick Start
19
19
 
@@ -84,7 +84,8 @@ npm install poker-irongrid
84
84
  | `colors` | `Array<string>` | `[]` | Color palette for highlighting hands (HSL format recommended) |
85
85
  | `defaultColor` | `string` | `'hsl(0, 0%, 100%)'` | Default background color for uncolored hands |
86
86
  | `defaultSelectedColor` | `string` | `'hsl(0, 0%, 85%)'` | Default color for selected hands without explicit color |
87
- | `evaluator` | `boolean` | `false` | Enable equity evaluation feature (requires hero cards and hands on grid) |
87
+ | `evaluator` | `boolean` | `false` | Show hero's live equity against the hands on the grid (requires hero cards) |
88
+ | `preflopTableUrl` | `string \| null` | `null` | URL of poker-bitvaljs's `bench/preflop-table.bin` for exact preflop equity (Monte Carlo without it) |
88
89
 
89
90
  #### Initial State Props
90
91
 
@@ -216,12 +217,22 @@ The component includes comprehensive filtering options:
216
217
 
217
218
  ### Equity Evaluation
218
219
 
219
- When `evaluator={true}`:
220
- - Calculate hero equity vs. villain range
221
- - Requires: 2 hero cards and hands on grid
222
- - Uses Monte Carlo simulation (10,000 iterations)
223
- - Displays results as percentages
224
- - Progress indicator during evaluation
220
+ When `evaluator={true}`, hero's equity is calculated live (asynchronously) whenever the hero cards,
221
+ board, dead cards or the hands on the grid change; there is no button to press.
222
+ - The summary shows hero vs. villain equity against every selected or colored combo
223
+ - The color buttons' **Eq** mode shows hero's equity against each color's hands
224
+ - Both come from one poker-bitvaljs `compareRangeHands` call: exact on the flop, turn and river
225
+ (a few ms), Monte Carlo preflop and on 1-2 card boards
226
+ - Pass `preflopTableUrl` for exact, near-instant preflop equity (the table is ~1.1 MB, fetched once):
227
+
228
+ ```svelte
229
+ <script>
230
+ import PokerGridPanel from 'poker-irongrid';
231
+ import preflopTableUrl from 'poker-bitvaljs/bench/preflop-table.bin?url'; // Vite
232
+ </script>
233
+
234
+ <PokerGridPanel evaluator={true} {preflopTableUrl} />
235
+ ```
225
236
 
226
237
  ### Responsive Design
227
238
 
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Loads poker-bitvaljs's preflop table (bench/preflop-table.bin, ~1.1 MB) once, for exact, near-instant
3
+ * preflop equity; without it preflop equity is a Monte Carlo estimate (within about ±0.3%).
4
+ * @param {string} url - Where the app serves the table
5
+ * @returns {Promise<boolean>} Whether the table loaded
6
+ */
7
+ export function loadPreflopTable(url: string): Promise<boolean>;
1
8
  export class PokerGridManager {
2
9
  constructor(options?: {});
3
10
  hands: any;
@@ -114,6 +121,14 @@ export class PokerGridManager {
114
121
  * @returns {Promise<Object>} Filtered hands object
115
122
  */
116
123
  getFilteredHandsByCriteria(criteria: string[], boardCards?: string[], hands?: any): Promise<any>;
124
+ /**
125
+ * Keep the combos of a hands object that pass a test.
126
+ * @param {Object} hands - Hands object
127
+ * @param {(c1: string, c2: string) => boolean} test - Gets the two cards of each combo
128
+ * @param {string[]} deadCards - Combos holding these cards are left out
129
+ * @returns {Object} Matching hands
130
+ */
131
+ filterCombos(hands: any, test: (c1: string, c2: string) => boolean, deadCards?: string[]): any;
117
132
  /**
118
133
  * Get hands matching a rank.
119
134
  * @param {string} rank - Rank (A, K, Q, etc.)
@@ -123,7 +138,7 @@ export class PokerGridManager {
123
138
  */
124
139
  getHandsByRank(rank: string, hands?: any, deadCards?: string[]): any;
125
140
  /**
126
- * Get hands matching a suit.
141
+ * Get hands holding at least one card of a suit.
127
142
  * @param {string} suitName - Suit name (clubs, diamonds, hearts, spades)
128
143
  * @param {Object} hands - Hands object
129
144
  * @param {string[]} deadCards - Dead cards to exclude
@@ -153,7 +168,7 @@ export class PokerGridManager {
153
168
  */
154
169
  getSuitedHands(hands?: any, deadCards?: string[]): any;
155
170
  /**
156
- * Get offsuit hands.
171
+ * Get offsuit hands (different suits; includes pocket pairs).
157
172
  * @param {Object} hands - Hands object
158
173
  * @param {string[]} deadCards - Dead cards to exclude
159
174
  * @returns {Object} Matching hands
@@ -185,6 +200,15 @@ export class PokerGridManager {
185
200
  * @returns {string|null} Hand strength type
186
201
  */
187
202
  getHandStrengthType(hand: string, boardCards?: string[]): string | null;
203
+ strengthCacheBoard: any;
204
+ strengthCache: Map<any, any>;
205
+ /**
206
+ * Uncached hand strength type (see getHandStrengthType).
207
+ * @param {string} hand - Hand code
208
+ * @param {string[]} boardCards - Board cards
209
+ * @returns {string|null} Hand strength type
210
+ */
211
+ computeHandStrengthType(hand: string, boardCards: string[]): string | null;
188
212
  /**
189
213
  * Get hands by hand strength.
190
214
  * @param {string} targetStrength - Strength type
@@ -218,15 +242,29 @@ export class PokerGridManager {
218
242
  */
219
243
  removeColor(hands: any, color: string): any;
220
244
  /**
221
- * Calculate color equities.
245
+ * Hero's equity against the villain hands on the grid, overall and per color, from one
246
+ * poker-bitvaljs compareRangeHands call (each villain combo's result against hero's hand).
247
+ * Villain hands are the combos that are selected or colored; hands holding a dead, board or
248
+ * hero card are left out.
222
249
  * @param {Object} hands - Hands object
223
- * @param {string[]} heroCards - Hero cards
224
- * @param {string[]} boardCards - Board cards
225
- * @param {string[]} deadCards - Dead cards
250
+ * @param {string[]} heroCards - Hero cards (two)
251
+ * @param {string[]} boardCards - Board cards (0-5)
252
+ * @param {string[]} deadCards - Dead cards (may include hero and board cards)
226
253
  * @param {string[]} colors - Colors array
254
+ * @returns {Promise<{hero: number|null, villain: number|null, combos: number, byColor: Object}|null>}
255
+ * Equities in percent (null when hero meets no villain combo); null without two hero cards.
256
+ */
257
+ calculateEquity(hands?: any, heroCards?: string[], boardCards?: string[], deadCards?: string[], colors?: string[]): Promise<{
258
+ hero: number | null;
259
+ villain: number | null;
260
+ combos: number;
261
+ byColor: any;
262
+ } | null>;
263
+ /**
264
+ * Hero's equity against each color's hands, rounded (0 when a color has no hands).
227
265
  * @returns {Promise<Object>} Color equities object
228
266
  */
229
- calculateColorEquities(hands?: any, heroCards?: string[], boardCards?: string[], deadCards?: string[], colors?: string[]): Promise<any>;
267
+ calculateColorEquities(hands?: any, heroCards?: any, boardCards?: any, deadCards?: any, colors?: any): Promise<any>;
230
268
  /**
231
269
  * Apply predefined range.
232
270
  * @param {string} rangeName - Range name
@@ -11,6 +11,50 @@ import { SUITS } from './utils/constants.js';
11
11
  // Handle CommonJS export
12
12
  const BitVal = BitValModule?.default || BitValModule;
13
13
 
14
+ /** One shared evaluator: poker-bitvaljs keeps its Web Workers between calls. */
15
+ let sharedBitVal = null;
16
+ function getBitVal() {
17
+ if (!sharedBitVal) {
18
+ sharedBitVal = new BitVal();
19
+ // Monte Carlo spots (preflop, 1-2 card boards) sample boards once for every hand: much faster
20
+ sharedBitVal.mcSharedBoards = true;
21
+ }
22
+ return sharedBitVal;
23
+ }
24
+
25
+ let preflopTableLoad = null;
26
+ /**
27
+ * Loads poker-bitvaljs's preflop table (bench/preflop-table.bin, ~1.1 MB) once, for exact, near-instant
28
+ * preflop equity; without it preflop equity is a Monte Carlo estimate (within about ±0.3%).
29
+ * @param {string} url - Where the app serves the table
30
+ * @returns {Promise<boolean>} Whether the table loaded
31
+ */
32
+ export function loadPreflopTable(url) {
33
+ if (!url) return Promise.resolve(false);
34
+ preflopTableLoad ||= fetch(url)
35
+ .then(response => {
36
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
37
+ return response.arrayBuffer();
38
+ })
39
+ .then(buffer => {
40
+ getBitVal().loadPreflopTable(buffer);
41
+ return true;
42
+ })
43
+ .catch(error => {
44
+ console.warn('poker-irongrid: preflop table not loaded, using Monte Carlo preflop equity', error);
45
+ return false;
46
+ });
47
+ return preflopTableLoad;
48
+ }
49
+
50
+ /** Order-independent key for a two-card combo ('AcKd' and 'KdAc' match). */
51
+ const comboKey = (combo) => {
52
+ const a = combo.substring(0, 2);
53
+ const b = combo.substring(2, 4);
54
+ return a < b ? a + b : b + a;
55
+ };
56
+ const isCombo = (hand) => typeof hand === 'string' && hand.length === 4;
57
+
14
58
  // Criteria mappings for RangeManager.hitsHand()
15
59
  const strengthCriteriaMap = {
16
60
  'onePair': ['Pair'],
@@ -278,9 +322,13 @@ export class PokerGridManager {
278
322
  * @returns {Object} Matching hands object
279
323
  */
280
324
  rangeManagerToMatching(rmResult, hands = this.hands) {
325
+ // Match by both cards, whatever order poker-rangeman lists them in
326
+ const keyed = new Map();
327
+ Object.keys(hands || {}).forEach(h => { if (isCombo(h)) keyed.set(comboKey(h), h); });
281
328
  const matching = {};
282
329
  rmResult.toArray().forEach(hand => {
283
- if (hands[hand]) matching[hand] = hands[hand];
330
+ const original = keyed.get(comboKey(hand));
331
+ if (original) matching[original] = hands[original];
284
332
  });
285
333
  return matching;
286
334
  }
@@ -308,6 +356,25 @@ export class PokerGridManager {
308
356
  }
309
357
  }
310
358
 
359
+ /**
360
+ * Keep the combos of a hands object that pass a test.
361
+ * @param {Object} hands - Hands object
362
+ * @param {(c1: string, c2: string) => boolean} test - Gets the two cards of each combo
363
+ * @param {string[]} deadCards - Combos holding these cards are left out
364
+ * @returns {Object} Matching hands
365
+ */
366
+ filterCombos(hands, test, deadCards = []) {
367
+ const matching = {};
368
+ Object.keys(hands || {}).forEach(h => {
369
+ if (!isCombo(h)) return;
370
+ const c1 = h.substring(0, 2);
371
+ const c2 = h.substring(2, 4);
372
+ if (deadCards.includes(c1) || deadCards.includes(c2)) return;
373
+ if (test(c1, c2)) matching[h] = hands[h];
374
+ });
375
+ return matching;
376
+ }
377
+
311
378
  /**
312
379
  * Get hands matching a rank.
313
380
  * @param {string} rank - Rank (A, K, Q, etc.)
@@ -316,20 +383,11 @@ export class PokerGridManager {
316
383
  * @returns {Object} Matching hands
317
384
  */
318
385
  getHandsByRank(rank, hands = this.hands, deadCards = []) {
319
- const allHands = Object.keys(hands);
320
- if (allHands.length === 0) return {};
321
- try {
322
- const rankRange = `${rank}Xs,${rank}Xo`;
323
- const currentRm = this.getHandsRangeManager(hands, deadCards);
324
- const matched = currentRm.intersect(rankRange);
325
- return this.rangeManagerToMatching(matched, hands);
326
- } catch (error) {
327
- return {};
328
- }
386
+ return this.filterCombos(hands, (c1, c2) => c1[0] === rank || c2[0] === rank, deadCards);
329
387
  }
330
388
 
331
389
  /**
332
- * Get hands matching a suit.
390
+ * Get hands holding at least one card of a suit.
333
391
  * @param {string} suitName - Suit name (clubs, diamonds, hearts, spades)
334
392
  * @param {Object} hands - Hands object
335
393
  * @param {string[]} deadCards - Dead cards to exclude
@@ -338,15 +396,7 @@ export class PokerGridManager {
338
396
  getHandsBySuit(suitName, hands = this.hands, deadCards = []) {
339
397
  const suitCode = SUITS.find(s => s.name === suitName)?.code;
340
398
  if (!suitCode) return {};
341
- const allHands = Object.keys(hands);
342
- if (allHands.length === 0) return {};
343
- try {
344
- const currentRm = this.getHandsRangeManager(hands, deadCards);
345
- const matched = currentRm.hasSuit(suitCode);
346
- return this.rangeManagerToMatching(matched, hands);
347
- } catch (error) {
348
- return {};
349
- }
399
+ return this.filterCombos(hands, (c1, c2) => c1[1] === suitCode || c2[1] === suitCode, deadCards);
350
400
  }
351
401
 
352
402
  /**
@@ -359,15 +409,7 @@ export class PokerGridManager {
359
409
  getHandsByDoubleSuit(suitName, hands = this.hands, deadCards = []) {
360
410
  const suitCode = SUITS.find(s => s.name === suitName)?.code;
361
411
  if (!suitCode) return {};
362
- const allHands = Object.keys(hands);
363
- if (allHands.length === 0) return {};
364
- try {
365
- const currentRm = this.getHandsRangeManager(hands, deadCards);
366
- const matched = currentRm.suitedOf(suitCode);
367
- return this.rangeManagerToMatching(matched, hands);
368
- } catch (error) {
369
- return {};
370
- }
412
+ return this.filterCombos(hands, (c1, c2) => c1[1] === suitCode && c2[1] === suitCode, deadCards);
371
413
  }
372
414
 
373
415
  /**
@@ -377,15 +419,7 @@ export class PokerGridManager {
377
419
  * @returns {Object} Matching hands
378
420
  */
379
421
  getPairedHands(hands = this.hands, deadCards = []) {
380
- const allHands = Object.keys(hands);
381
- if (allHands.length === 0) return {};
382
- try {
383
- const currentRm = this.getHandsRangeManager(hands, deadCards);
384
- const matched = currentRm.intersect('22+');
385
- return this.rangeManagerToMatching(matched, hands);
386
- } catch (error) {
387
- return {};
388
- }
422
+ return this.filterCombos(hands, (c1, c2) => c1[0] === c2[0], deadCards);
389
423
  }
390
424
 
391
425
  /**
@@ -395,46 +429,17 @@ export class PokerGridManager {
395
429
  * @returns {Object} Matching hands
396
430
  */
397
431
  getSuitedHands(hands = this.hands, deadCards = []) {
398
- const allHands = Object.keys(hands);
399
- if (allHands.length === 0) return {};
400
- try {
401
- const currentRm = this.getHandsRangeManager(hands, deadCards);
402
- // Use method chaining to combine all suited hands
403
- let allSuited = new RangeManager([]);
404
- ['c', 'd', 'h', 's'].forEach(suit => {
405
- allSuited = allSuited.include(currentRm.suitedOf(suit));
406
- });
407
- return this.rangeManagerToMatching(allSuited, hands);
408
- } catch (error) {
409
- return {};
410
- }
432
+ return this.filterCombos(hands, (c1, c2) => c1[1] === c2[1], deadCards);
411
433
  }
412
434
 
413
435
  /**
414
- * Get offsuit hands.
436
+ * Get offsuit hands (different suits; includes pocket pairs).
415
437
  * @param {Object} hands - Hands object
416
438
  * @param {string[]} deadCards - Dead cards to exclude
417
439
  * @returns {Object} Matching hands
418
440
  */
419
441
  getOffsuitHands(hands = this.hands, deadCards = []) {
420
- const allHands = Object.keys(hands);
421
- if (allHands.length === 0) return {};
422
- try {
423
- const currentRm = this.getHandsRangeManager(hands, deadCards);
424
- // Use method chaining to combine all offsuit hands
425
- // For offsuit: get hands that have the suit but exclude suited hands
426
- let allOffsuit = new RangeManager([]);
427
- ['c', 'd', 'h', 's'].forEach(suit => {
428
- // Get hands that have this suit, then exclude suited hands
429
- const hasSuit = currentRm.hasSuit(suit);
430
- const suited = currentRm.suitedOf(suit);
431
- const offsuit = hasSuit.exclude(suited);
432
- allOffsuit = allOffsuit.include(offsuit);
433
- });
434
- return this.rangeManagerToMatching(allOffsuit, hands);
435
- } catch (error) {
436
- return {};
437
- }
442
+ return this.filterCombos(hands, (c1, c2) => c1[1] !== c2[1], deadCards);
438
443
  }
439
444
 
440
445
  /**
@@ -485,6 +490,24 @@ export class PokerGridManager {
485
490
  */
486
491
  getHandStrengthType(hand, boardCards = this.boardCards) {
487
492
  if (!boardCards || boardCards.length < 3) return null;
493
+ const boardKey = boardCards.join('');
494
+ if (this.strengthCacheBoard !== boardKey) {
495
+ this.strengthCacheBoard = boardKey;
496
+ this.strengthCache = new Map();
497
+ }
498
+ if (this.strengthCache.has(hand)) return this.strengthCache.get(hand);
499
+ const type = this.computeHandStrengthType(hand, boardCards);
500
+ this.strengthCache.set(hand, type);
501
+ return type;
502
+ }
503
+
504
+ /**
505
+ * Uncached hand strength type (see getHandStrengthType).
506
+ * @param {string} hand - Hand code
507
+ * @param {string[]} boardCards - Board cards
508
+ * @returns {string|null} Hand strength type
509
+ */
510
+ computeHandStrengthType(hand, boardCards) {
488
511
  try {
489
512
  const handCard1 = hand.substring(0, 2);
490
513
  const handCard2 = hand.substring(2, 4);
@@ -507,7 +530,6 @@ export class PokerGridManager {
507
530
  return null;
508
531
  } catch (error) {
509
532
  return null;
510
- return null;
511
533
  }
512
534
  }
513
535
 
@@ -630,83 +652,79 @@ export class PokerGridManager {
630
652
  }
631
653
 
632
654
  /**
633
- * Calculate color equities.
655
+ * Hero's equity against the villain hands on the grid, overall and per color, from one
656
+ * poker-bitvaljs compareRangeHands call (each villain combo's result against hero's hand).
657
+ * Villain hands are the combos that are selected or colored; hands holding a dead, board or
658
+ * hero card are left out.
634
659
  * @param {Object} hands - Hands object
635
- * @param {string[]} heroCards - Hero cards
636
- * @param {string[]} boardCards - Board cards
637
- * @param {string[]} deadCards - Dead cards
660
+ * @param {string[]} heroCards - Hero cards (two)
661
+ * @param {string[]} boardCards - Board cards (0-5)
662
+ * @param {string[]} deadCards - Dead cards (may include hero and board cards)
638
663
  * @param {string[]} colors - Colors array
639
- * @returns {Promise<Object>} Color equities object
664
+ * @returns {Promise<{hero: number|null, villain: number|null, combos: number, byColor: Object}|null>}
665
+ * Equities in percent (null when hero meets no villain combo); null without two hero cards.
640
666
  */
641
- async calculateColorEquities(hands = this.hands, heroCards = this.heroCards, boardCards = this.boardCards, deadCards = this.deadCards, colors = this.colors) {
642
- if (heroCards?.length !== 2) {
643
- return {};
644
- }
667
+ async calculateEquity(hands = this.hands, heroCards = this.heroCards, boardCards = this.boardCards, deadCards = this.deadCards, colors = this.colors) {
668
+ if (heroCards?.length !== 2) return null;
669
+ const board = boardCards || [];
670
+ const otherDead = (deadCards || []).filter(card => !heroCards.includes(card) && !board.includes(card));
671
+ const blocked = new Set([...heroCards, ...board, ...otherDead]);
672
+
673
+ const villainHands = Object.keys(hands || {}).filter(h =>
674
+ isCombo(h) &&
675
+ (this.hasExplicitColor(hands[h]) || this.isSelected(hands[h])) &&
676
+ !blocked.has(h.substring(0, 2)) && !blocked.has(h.substring(2, 4))
677
+ );
645
678
 
646
- try {
647
- const coloredHands = Object.keys(hands).filter(h =>
648
- Array.isArray(hands[h]) && hands[h].some(item => typeof item === 'string' && item.startsWith('hsl('))
649
- );
650
-
651
- if (coloredHands.length === 0) {
652
- const equities = {};
653
- colors.forEach(c => equities[c] = 0);
654
- return equities;
655
- }
679
+ const byColor = {};
680
+ (colors || []).forEach(c => { byColor[c] = null; });
681
+ if (villainHands.length === 0) return { hero: null, villain: null, combos: 0, byColor };
656
682
 
657
- const bitval = new BitVal();
658
- const heroHand = heroCards[0] + heroCards[1];
659
- const heroHands = [heroHand];
660
- const numberOfBoardCards = 5;
661
-
662
- const deadCardsFromInput = (deadCards || []).filter(card =>
663
- !(heroCards || []).includes(card) && !(boardCards || []).includes(card)
664
- );
665
- const deadCardsForBitval = [...heroCards, ...deadCardsFromInput];
666
-
667
- const boardCardsCount = (boardCards || []).length;
668
- const optimize = boardCardsCount < 3;
683
+ // Preflop, wait for the table if one is loading (otherwise the result would be Monte Carlo)
684
+ if (board.length === 0 && preflopTableLoad) await preflopTableLoad;
669
685
 
670
- const equities = {};
671
-
672
- for (let i = 0; i < colors.length; i++) {
673
- const color = colors[i];
674
-
675
- const colorHands = coloredHands.filter(h =>
676
- hands[h].some(item => item === color)
677
- );
686
+ // compareRangeHands(hero, villain, board, dead, numberOfBoardCards, iterations, optimize,
687
+ // progressCallback, progressInterval, useWorkers): exact on the flop/turn/river, Monte Carlo otherwise
688
+ const result = await getBitVal().compareRangeHands(
689
+ [heroCards[0] + heroCards[1]], villainHands, board, otherDead, 5, 20000, true, null, 100, true
690
+ );
678
691
 
679
- if (colorHands.length === 0) {
680
- equities[color] = 0;
681
- continue;
682
- }
692
+ // Villain rows are each villain combo's own win/tie/lose against hero
693
+ const rowOf = new Map();
694
+ result.villain.hands.forEach((h, i) => rowOf.set(comboKey(h), i));
695
+ const heroEquityAgainst = (combos) => {
696
+ let heroShare = 0;
697
+ let total = 0;
698
+ combos.forEach(h => {
699
+ const i = rowOf.get(comboKey(h));
700
+ if (i === undefined) return;
701
+ const win = result.villain.win[i];
702
+ const tie = result.villain.tie[i];
703
+ const lose = result.villain.lose[i];
704
+ heroShare += lose + tie / 2;
705
+ total += win + tie + lose;
706
+ });
707
+ return total > 0 ? (heroShare / total) * 100 : null;
708
+ };
683
709
 
684
- try {
685
- const result = await bitval.compareRange(
686
- heroHands,
687
- colorHands,
688
- boardCards || [],
689
- deadCardsForBitval,
690
- numberOfBoardCards,
691
- 10000,
692
- optimize,
693
- null
694
- );
710
+ const hero = heroEquityAgainst(villainHands);
711
+ (colors || []).forEach(color => {
712
+ const colorHands = villainHands.filter(h => hands[h].includes(color));
713
+ byColor[color] = colorHands.length > 0 ? heroEquityAgainst(colorHands) : null;
714
+ });
695
715
 
696
- const total = result.win + result.tie + result.lose;
697
- const heroEquity = total > 0 ? ((result.win + result.tie / 2) / total) * 100 : 0;
698
- equities[color] = Math.round(heroEquity);
699
- } catch (error) {
700
- return;
701
- equities[color] = 0;
702
- }
703
- }
704
-
705
- return equities;
706
- } catch (error) {
707
- return;
708
- return {};
709
- }
716
+ return { hero, villain: hero === null ? null : 100 - hero, combos: villainHands.length, byColor };
717
+ }
718
+
719
+ /**
720
+ * Hero's equity against each color's hands, rounded (0 when a color has no hands).
721
+ * @returns {Promise<Object>} Color equities object
722
+ */
723
+ async calculateColorEquities(hands = this.hands, heroCards = this.heroCards, boardCards = this.boardCards, deadCards = this.deadCards, colors = this.colors) {
724
+ const equity = await this.calculateEquity(hands, heroCards, boardCards, deadCards, colors);
725
+ const equities = {};
726
+ (colors || []).forEach(c => { equities[c] = Math.round(equity?.byColor?.[c] ?? 0); });
727
+ return equities;
710
728
  }
711
729
 
712
730
  // ==================== Range Methods ====================
@@ -1037,47 +1055,24 @@ export class PokerGridManager {
1037
1055
  if (activeHands.length === 0) return false;
1038
1056
 
1039
1057
  try {
1040
- const activeRm = new RangeManager(activeHands);
1041
-
1058
+ const active = {};
1059
+ activeHands.forEach(h => { active[h] = hands[h]; });
1060
+ const allMatch = (matching) => Object.keys(matching).length === activeHands.length;
1061
+
1042
1062
  switch (filterType) {
1043
1063
  case 'rank':
1044
- // Use standard notation: KK,K2s+,K2o+ to check all hands with this rank
1045
- const rankRange = `${filterValue}${filterValue},${filterValue}2s+,${filterValue}2o+`;
1046
- return activeRm.intersect(rankRange).size() === activeHands.length;
1047
-
1064
+ return allMatch(this.getHandsByRank(filterValue, active));
1048
1065
  case 'suit':
1049
- const suitCode = SUITS.find(s => s.name === filterValue)?.code;
1050
- if (!suitCode) return false;
1051
- return activeRm.hasSuit(suitCode).size() === activeHands.length;
1052
-
1066
+ return allMatch(this.getHandsBySuit(filterValue, active));
1053
1067
  case 'doubleSuit':
1054
- const doubleSuitCode = SUITS.find(s => s.name === filterValue)?.code;
1055
- if (!doubleSuitCode) return false;
1056
- return activeRm.suitedOf(doubleSuitCode).size() === activeHands.length;
1057
-
1068
+ return allMatch(this.getHandsByDoubleSuit(filterValue, active));
1058
1069
  case 'paired':
1059
- return activeRm.intersect('22+').size() === activeHands.length;
1060
-
1070
+ return allMatch(this.getPairedHands(active));
1061
1071
  case 'suited':
1062
- // Check if all active hands are suited in at least one suit
1063
- return ['c', 'd', 'h', 's'].some(suit =>
1064
- activeRm.suitedOf(suit).size() === activeHands.length
1065
- );
1066
-
1072
+ return allMatch(this.getSuitedHands(active));
1067
1073
  case 'unsuited':
1068
- // Check if all active hands are offsuit (have different suits)
1069
- // For offsuit: get hands that have a suit but exclude suited hands
1070
- return ['c', 'd', 'h', 's'].some(suit => {
1071
- try {
1072
- const hasSuit = activeRm.hasSuit(suit);
1073
- const suited = activeRm.suitedOf(suit);
1074
- const offsuit = hasSuit.exclude(suited);
1075
- return offsuit.size() === activeHands.length;
1076
- } catch (error) {
1077
- return false;
1078
- }
1079
- });
1080
-
1074
+ return allMatch(this.getOffsuitHands(active));
1075
+
1081
1076
  case 'colored':
1082
1077
  return activeHands.every(h =>
1083
1078
  Array.isArray(hands[h]) && hands[h].some(item => typeof item === 'string' && item.startsWith('hsl('))
@@ -1123,7 +1118,6 @@ export class PokerGridManager {
1123
1118
  }
1124
1119
  } catch (error) {
1125
1120
  return false;
1126
- return false;
1127
1121
  }
1128
1122
  }
1129
1123