poker-extval 3.3.3 → 3.4.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.
Files changed (3) hide show
  1. package/README.md +32 -2
  2. package/evaluator.js +264 -8
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -126,6 +126,9 @@ const result = evaluateHand(['As', 'Ah', 'Kd', 'Kc', 'Qs'], false);
126
126
  - Descriptive labels (isTopPair, etc.) are only true if they match the strongest hand
127
127
  - Made flush or stronger cannot have straight draws (flush > straight)
128
128
  - Draws can coexist when no made hand exists (flush draw + straight draw is valid)
129
+ - With 6 or more cards, draws are collected from **every** 5-card combination, not just the best one, so a draw using a card outside the best five is still reported. Straight draws are dropped once a straight or better is made, and flush draws once a flush or better is made. `isComboDraw` is true when the whole hand has both a flush draw and a straight draw.
130
+
131
+ **Draws by street**: the number of cards sets the street. With 5 cards (the flop) every draw is possible; with 6 (the turn) there is no `isBackdoorFlushDraw`, since one card to come cannot complete it; with 7 (the river) there are no draws at all. This applies in both modes.
129
132
 
130
133
  ### Evaluating Multiple Card Hands
131
134
 
@@ -191,6 +194,8 @@ The `evaluateHand()` function returns an object where:
191
194
  - `isMiddleThreeOfAKind` - Three of a kind is neither highest nor lowest
192
195
  - `isBottomThreeOfAKind` - Three of a kind is the lowest rank
193
196
 
197
+ **Note**: With `holeCards`, pair and three of a kind positions are judged against the **whole board**, including board cards left out of the 5-card combination: top means at or above the highest board card (so a pocket pair above the board is top pair), bottom means at or below the lowest, middle is anything between. A pair or three of a kind made only of board cards gets no position. Without `holeCards` the board is unknown, so positions are judged within the 5-card combination. Two pair positions are always judged within the 5-card combination.
198
+
194
199
  #### Overcard Categorization (requires `holeCards`; not filtered by strict mode)
195
200
  - `isOvercard` - At least one hole card ranks above every board card
196
201
  - `isTwoOvercards` - Both hole cards rank above every board card
@@ -446,7 +451,7 @@ The test suite includes 700+ tests covering:
446
451
 
447
452
  ## API Reference
448
453
 
449
- ### `evaluateHand(cards, strict, holeCards)`
454
+ ### `evaluateHand(cards, strict, holeCards, villainRange)`
450
455
 
451
456
  Main function to evaluate poker hands.
452
457
 
@@ -454,6 +459,7 @@ Main function to evaluate poker hands.
454
459
  - `cards` (string[]): Array of 5 or more card strings
455
460
  - `strict` (boolean, optional): If `true`, only the strongest made hand and stronger draws are marked. If `false`, all applicable hand types are marked (default: `true`).
456
461
  - `holeCards` (string[] | null, optional): Exactly 2 cards identifying the player's hole cards, both of which must appear in `cards`. Required for the overcard categorizations; without it those properties are always `false` (default: `null`).
462
+ - `villainRange` (string[] | null, optional): Villain hands as exact combos (`'AhKd'`) or shorthand (`'QQ'`, `'AKs'`, `'AKo'`, `'AK'`, `'TT+'`, `'ATs+'`, `'ATo+'`, `'AT+'`). When supplied, each evaluation gets a `villainRange` property; see [Villain Range Blockers](#villain-range-blockers) (default: `null`).
457
463
 
458
464
  **Returns:**
459
465
  - Object mapping sorted hand strings to evaluation objects
@@ -466,7 +472,7 @@ Main function to evaluate poker hands.
466
472
  #### Constructor
467
473
 
468
474
  ```javascript
469
- new HandEvaluator(cards, strict, holeCards)
475
+ new HandEvaluator(cards, strict, holeCards, villainRange)
470
476
  ```
471
477
 
472
478
  Creates a new HandEvaluator instance.
@@ -475,6 +481,7 @@ Creates a new HandEvaluator instance.
475
481
  - `cards` (string[]): Array of 5 or more card strings
476
482
  - `strict` (boolean, optional): If `true`, only the strongest made hand and stronger draws are marked. If `false`, all applicable hand types are marked (default: `true`).
477
483
  - `holeCards` (string[] | null, optional): Exactly 2 cards identifying the player's hole cards, both of which must appear in `cards`. Required for the overcard categorizations (default: `null`).
484
+ - `villainRange` (string[] | null, optional): Villain hands as exact combos (`'AhKd'`) or shorthand (`'QQ'`, `'AKs'`, `'AKo'`, `'AK'`, `'TT+'`, `'ATs+'`, `'ATo+'`, `'AT+'`). When supplied, each evaluation gets a `villainRange` property; see [Villain Range Blockers](#villain-range-blockers) (default: `null`).
478
485
 
479
486
  **Properties:**
480
487
  - `evaluation` - Object mapping sorted hand strings to evaluation objects (same format as `evaluateHand()` return value)
@@ -482,6 +489,29 @@ Creates a new HandEvaluator instance.
482
489
  - `strict` - Boolean indicating whether strict mode is enabled
483
490
  - `holeCards` - Array of the 2 hole cards, or `null` if none were supplied
484
491
 
492
+ ### Villain Range Blockers
493
+
494
+ Pass a villain range as the fourth argument to see which of its combos the known cards rule out:
495
+
496
+ ```javascript
497
+ const board = ['Kh', '8d', '4c'];
498
+ const holeCards = ['Ah', 'Js'];
499
+ const result = evaluateHand([...board, ...holeCards], true, holeCards, ['AKs', 'QQ', 'JsTs']);
500
+ const { villainRange } = Object.values(result)[0];
501
+
502
+ villainRange.allBlocks;
503
+ // { unblocked: ['AsKs', 'AdKd', 'AcKc', 'QsQh', 'QsQd', 'QsQc', 'QhQd', 'QhQc', 'QdQc'],
504
+ // blocked: ['AhKh', 'JsTs'] }
505
+ ```
506
+
507
+ The result has three views, each `{ unblocked: [], blocked: [] }` of exact combos:
508
+
509
+ - `boardBlocks` - blocked by a board card (every card in `cards` that is not a hole card)
510
+ - `heroBlocks` - blocked by one of the hole cards
511
+ - `allBlocks` - blocked by any known card; `unblocked` here is what villain can actually hold
512
+
513
+ Shorthand is expanded to combos, so a partly blocked entry such as `AKs` is split across both lists. Combos are written high rank first (`'AhKd'`, and `'QsQh'` for pairs with suits ordered s, h, d, c), duplicates are removed, and input order is kept. Without `holeCards`, every card is treated as board, so `heroBlocks` blocks nothing. In non-strict mode every combination carries the same `villainRange`. The property is omitted when no range is supplied, and an invalid entry throws.
514
+
485
515
  **Note**: All internal methods are private. Use the `evaluation` property to access evaluation results.
486
516
 
487
517
  ## Releasing
package/evaluator.js CHANGED
@@ -36,6 +36,31 @@ const HOLE_CARD_CATEGORIES = new Set([
36
36
  'isTwoUndercards'
37
37
  ]);
38
38
 
39
+ /**
40
+ * Draw properties. On 6+ cards they describe the whole hand rather than one 5-card combination,
41
+ * so strict mode collects them from every combination (see #mergeDraws).
42
+ *
43
+ * @constant {string[]}
44
+ */
45
+ const DRAW_PROPERTIES = [
46
+ 'isFlushDraw',
47
+ 'isBackdoorFlushDraw',
48
+ 'isOpenEndedStraightDraw',
49
+ 'isInsideStraightDraw',
50
+ 'isStraightDraw',
51
+ 'isOpenEndedStraightDrawWheel',
52
+ 'isInsideStraightDrawWheel',
53
+ 'isComboDraw'
54
+ ];
55
+
56
+ const STRAIGHT_DRAW_PROPERTIES = [
57
+ 'isOpenEndedStraightDraw',
58
+ 'isInsideStraightDraw',
59
+ 'isStraightDraw',
60
+ 'isOpenEndedStraightDrawWheel',
61
+ 'isInsideStraightDrawWheel'
62
+ ];
63
+
39
64
  class HandEvaluator {
40
65
  /**
41
66
  * Creates an instance of HandEvaluator.
@@ -48,9 +73,14 @@ class HandEvaluator {
48
73
  * @param {string[]|null} [holeCards=null] - Optional array of exactly 2 cards identifying the player's
49
74
  * hole cards. Required for the overcard categorizations (isOvercard, isTwoOvercards, etc.); without it
50
75
  * those properties are always false. Both cards must be present in `cards`.
76
+ * @param {string[]|null} [villainRange=null] - Optional array of villain hands, as exact combos ('AhKd') or
77
+ * shorthand ('QQ', 'AKs', 'AKo', 'AK', 'TT+', 'ATs+', 'ATo+', 'AT+'). When supplied, every evaluation
78
+ * gets a `villainRange` property listing which combos the known cards block (see #classifyVillainRange).
79
+ * @param {string[]|null} [originalCards=null] - Internal: the full hand when evaluating one 5-card combination
51
80
  * @throws {Error} If cards is not an array, has fewer than 5 cards, contains invalid format,
52
81
  * invalid rank, invalid suit, or duplicate cards
53
82
  * @throws {Error} If holeCards is provided but is not an array of exactly 2 distinct cards drawn from `cards`
83
+ * @throws {Error} If villainRange is provided but is not an array of valid combos or shorthand
54
84
  * @example
55
85
  * const evaluator = new HandEvaluator(['As', 'Ks', 'Qs', 'Js', 'Ts']);
56
86
  * @example
@@ -63,7 +93,7 @@ class HandEvaluator {
63
93
  * // With hole cards - enables overcard categorization
64
94
  * const withHoleCards = new HandEvaluator(['Ks', 'Qh', '8d', '3c', 'Ad', 'Jh'], true, ['Ad', 'Jh']);
65
95
  */
66
- constructor(cards, strict = true, holeCards = null, originalCards = null) {
96
+ constructor(cards, strict = true, holeCards = null, villainRange = null, originalCards = null) {
67
97
  this.#validateCards(cards);
68
98
  // Copy so that in-place sorting during evaluation never reorders the caller's array
69
99
  this.cards = [...cards];
@@ -75,7 +105,117 @@ class HandEvaluator {
75
105
  this.#validateHoleCards(holeCards, this.originalCards || cards);
76
106
  this.holeCards = holeCards;
77
107
  }
108
+ const villainCombos = villainRange === null || villainRange === undefined ? null : this.#parseVillainRange(villainRange);
78
109
  this.evaluation = this.#evaluateAllCombinations();
110
+ if (villainCombos) {
111
+ const classification = this.#classifyVillainRange(villainCombos);
112
+ for (const evaluation of Object.values(this.evaluation)) evaluation.villainRange = classification;
113
+ }
114
+ }
115
+
116
+ // ==================== VILLAIN RANGE ====================
117
+
118
+ /**
119
+ * Expands a villain range into exact two-card combos, in input order and without duplicates.
120
+ * Each combo is written high rank first, with suits ordered s, h, d, c for pairs (e.g. 'AhKd', 'QsQh').
121
+ *
122
+ * Accepted entries:
123
+ * - Exact combo: 'AhKd' (either card order)
124
+ * - Pair: 'QQ' (6 combos); 'TT+' adds every higher pair
125
+ * - Suited: 'AKs' (4 combos); offsuit: 'AKo' (12 combos); either: 'AK' (16 combos)
126
+ * - Plus forms keep the first rank and raise the second up to one below it: 'ATs+' is ATs, AJs, AQs, AKs
127
+ *
128
+ * @private
129
+ * @param {string[]} villainRange - Range entries to expand
130
+ * @returns {string[]} Exact combos
131
+ * @throws {Error} If villainRange is not an array or an entry is not a valid combo or shorthand
132
+ */
133
+ #parseVillainRange(villainRange) {
134
+ if (!Array.isArray(villainRange)) {
135
+ throw new Error('Villain range must be an array');
136
+ }
137
+ const ranks = 'AKQJT98765432';
138
+ const suits = 'shdc';
139
+ const combos = [];
140
+ const seen = new Set();
141
+ const add = (first, second) => {
142
+ // High rank first; for equal ranks, order suits s, h, d, c
143
+ const order = c => ranks.indexOf(c[0]) * 4 + suits.indexOf(c[1]);
144
+ const [a, b] = order(first) <= order(second) ? [first, second] : [second, first];
145
+ if (!seen.has(a + b)) {
146
+ seen.add(a + b);
147
+ combos.push(a + b);
148
+ }
149
+ };
150
+ const addRanks = (high, low, type) => {
151
+ for (const s1 of suits) {
152
+ for (const s2 of suits) {
153
+ if (high === low ? suits.indexOf(s2) <= suits.indexOf(s1) : (type === 's' && s1 !== s2) || (type === 'o' && s1 === s2)) continue;
154
+ add(high + s1, low + s2);
155
+ }
156
+ }
157
+ };
158
+
159
+ villainRange.forEach((entry, i) => {
160
+ const invalid = () => new Error(`Invalid villain range entry at index ${i}: "${entry}"`);
161
+ if (typeof entry !== 'string') throw invalid();
162
+
163
+ const exact = entry.match(/^([AKQJT2-9])([shdc])([AKQJT2-9])([shdc])$/);
164
+ if (exact) {
165
+ const [first, second] = [exact[1] + exact[2], exact[3] + exact[4]];
166
+ if (first === second) throw invalid();
167
+ add(first, second);
168
+ return;
169
+ }
170
+
171
+ const shorthand = entry.match(/^([AKQJT2-9])([AKQJT2-9])([so]?)(\+?)$/);
172
+ if (!shorthand) throw invalid();
173
+ const [, r1, r2, type, plus] = shorthand;
174
+ const [high, low] = ranks.indexOf(r1) <= ranks.indexOf(r2) ? [r1, r2] : [r2, r1];
175
+ if (high === low) {
176
+ if (type) throw invalid();
177
+ const lows = plus ? ranks.slice(0, ranks.indexOf(high) + 1) : high;
178
+ for (const rank of lows) addRanks(rank, rank);
179
+ return;
180
+ }
181
+ const lows = plus ? ranks.slice(ranks.indexOf(high) + 1, ranks.indexOf(low) + 1) : low;
182
+ for (const rank of lows) addRanks(high, rank, type);
183
+ });
184
+ return combos;
185
+ }
186
+
187
+ /**
188
+ * Splits villain combos by which known cards block them. A combo is blocked when it shares a
189
+ * card with the blockers, so villain cannot hold it.
190
+ *
191
+ * - boardBlocks: blocked by the board (every card that is not a hole card)
192
+ * - heroBlocks: blocked by the hole cards (always unblocked without hole cards)
193
+ * - allBlocks: blocked by any known card
194
+ *
195
+ * Without hole cards every card passed is treated as board.
196
+ *
197
+ * @private
198
+ * @param {string[]} combos - Exact combos from #parseVillainRange
199
+ * @returns {{boardBlocks: Object, heroBlocks: Object, allBlocks: Object}} Each with `unblocked` and `blocked` arrays
200
+ */
201
+ #classifyVillainRange(combos) {
202
+ const fullHand = this.originalCards || this.cards;
203
+ const heroCards = new Set(this.holeCards || []);
204
+ const boardCards = new Set(fullHand.filter(c => !heroCards.has(c)));
205
+ const allCards = new Set(fullHand);
206
+ const split = blockers => {
207
+ const result = { unblocked: [], blocked: [] };
208
+ for (const combo of combos) {
209
+ const isBlocked = blockers.has(combo.slice(0, 2)) || blockers.has(combo.slice(2));
210
+ result[isBlocked ? 'blocked' : 'unblocked'].push(combo);
211
+ }
212
+ return result;
213
+ };
214
+ return {
215
+ boardBlocks: split(boardCards),
216
+ heroBlocks: split(heroCards),
217
+ allBlocks: split(allCards)
218
+ };
79
219
  }
80
220
 
81
221
  /**
@@ -271,7 +411,11 @@ class HandEvaluator {
271
411
  this.#isOpenEndedStraightDraw();
272
412
  this.#isInsideStraightDraw();
273
413
  this.#isStraightDraw();
414
+ // Always set the wheel flags; the standard draw checks above only evaluate them as a fallback
415
+ this.#isOpenEndedStraightDrawWheel();
416
+ this.#isInsideStraightDrawWheel();
274
417
  this.#isComboDraw();
418
+ this.#clearDrawsForStreet();
275
419
  this.#isUnderPair();
276
420
  this.#isOvercards();
277
421
  this.#isHighCard();
@@ -345,9 +489,7 @@ class HandEvaluator {
345
489
 
346
490
  // Filter draws: made flush or stronger cannot have straight draws
347
491
  if (strongest >= 6) {
348
- evaluation.isStraightDraw = false;
349
- evaluation.isOpenEndedStraightDraw = false;
350
- evaluation.isInsideStraightDraw = false;
492
+ for (const draw of STRAIGHT_DRAW_PROPERTIES) evaluation[draw] = false;
351
493
  }
352
494
  }
353
495
 
@@ -496,12 +638,75 @@ class HandEvaluator {
496
638
  const result = {};
497
639
  for (const combination of combinations) {
498
640
  const key = this.#formatHandKey(combination);
499
- const tempEvaluator = new HandEvaluator(combination, this.strict, this.holeCards, this.originalCards);
641
+ const tempEvaluator = new HandEvaluator(combination, this.strict, this.holeCards, null, this.originalCards);
500
642
  const evaluationKey = Object.keys(tempEvaluator.evaluation)[0];
501
643
  result[key] = tempEvaluator.evaluation[evaluationKey];
502
644
  }
503
645
 
504
- return this.strict ? this.#findBestCombination(result) : result;
646
+ if (!this.strict) return result;
647
+ const best = this.#findBestCombination(result);
648
+ this.#mergeDraws(Object.values(best)[0], result);
649
+ return best;
650
+ }
651
+
652
+ /**
653
+ * Removes draws that can no longer complete, based on how many cards the full hand holds:
654
+ * 5 cards is the flop (two cards to come), 6 is the turn (one card to come, so a backdoor
655
+ * draw cannot complete) and 7 or more is the river (no cards to come, so there are no draws).
656
+ *
657
+ * @private
658
+ */
659
+ #clearDrawsForStreet() {
660
+ const cardCount = (this.originalCards || this.cards).length;
661
+ if (cardCount >= 7) {
662
+ for (const draw of DRAW_PROPERTIES) this.evaluation[draw] = false;
663
+ } else if (cardCount === 6) {
664
+ this.evaluation.isBackdoorFlushDraw = false;
665
+ }
666
+ }
667
+
668
+ /**
669
+ * In strict mode with 6+ cards only the best 5-card combination is returned, but a draw can
670
+ * use cards outside it (e.g. the hole card that makes four to a flush). Copies onto the best
671
+ * evaluation every draw found in any combination, with that combination's key and kicker
672
+ * cards, then drops draws the made hand already beats and recomputes the combo draw for the
673
+ * whole hand.
674
+ *
675
+ * @private
676
+ * @param {Object} best - The evaluation of the best combination (modified in place)
677
+ * @param {Object<string, Object>} result - Evaluations of every combination
678
+ */
679
+ #mergeDraws(best, result) {
680
+ const clear = draw => {
681
+ best[draw] = false;
682
+ best.keyCards[draw] = [];
683
+ best.kickerCards[draw] = [];
684
+ };
685
+ for (const draw of DRAW_PROPERTIES) {
686
+ if (draw === 'isComboDraw' || best[draw]) continue;
687
+ const source = Object.values(result).find(evaluation => evaluation[draw]);
688
+ if (!source) continue;
689
+ best[draw] = true;
690
+ best.keyCards[draw] = source.keyCards[draw];
691
+ best.kickerCards[draw] = source.kickerCards[draw];
692
+ }
693
+
694
+ // A made straight or better needs no straight draw; a made flush or better needs no flush draw
695
+ const strongest = this.#getHandStrength(best);
696
+ if (strongest >= 5) STRAIGHT_DRAW_PROPERTIES.forEach(clear);
697
+ if (strongest >= 6) ['isFlushDraw', 'isBackdoorFlushDraw'].forEach(clear);
698
+
699
+ if (best.isComboDraw || !(best.isFlushDraw && best.isStraightDraw)) {
700
+ if (!(best.isFlushDraw && best.isStraightDraw)) clear('isComboDraw');
701
+ return;
702
+ }
703
+ best.isComboDraw = true;
704
+ const keyCards = [...new Set([...best.keyCards.isFlushDraw, ...best.keyCards.isStraightDraw])]
705
+ .sort((a, b) => this.#getRankValue(b) - this.#getRankValue(a));
706
+ best.keyCards.isComboDraw = keyCards;
707
+ best.kickerCards.isComboDraw = this.cards
708
+ .filter(c => !keyCards.includes(c))
709
+ .sort((a, b) => this.#getRankValue(b) - this.#getRankValue(a));
505
710
  }
506
711
 
507
712
  /**
@@ -797,6 +1002,8 @@ class HandEvaluator {
797
1002
  if (this.evaluation.isTopPair !== undefined) return this.evaluation.isTopPair;
798
1003
  if (this.evaluation.isPair === undefined) this.#isPair();
799
1004
  if (!this.evaluation.isPair) return this.evaluation.isTopPair = false;
1005
+ const position = this.#getBoardPosition(this.#getPairRank());
1006
+ if (position !== null) return this.evaluation.isTopPair = position === 'top';
800
1007
  const rankCounts = this.#getRankCounts();
801
1008
  return this.evaluation.isTopPair = rankCounts[this.cards[0].slice(0, -1)] === 2;
802
1009
  }
@@ -815,6 +1022,8 @@ class HandEvaluator {
815
1022
  if (this.evaluation.isPair === undefined) this.#isPair();
816
1023
  if (this.evaluation.isTwoPair === undefined) this.#isTwoPair();
817
1024
  if (!this.evaluation.isPair || this.evaluation.isTwoPair) return this.evaluation.isMiddlePair = false;
1025
+ const position = this.#getBoardPosition(this.#getPairRank());
1026
+ if (position !== null) return this.evaluation.isMiddlePair = position === 'middle';
818
1027
  const ranks = this.#getRankCountsSorted();
819
1028
  return this.evaluation.isMiddlePair = ranks.length === 4 && (ranks[1].count === 2 || ranks[2].count === 2);
820
1029
  }
@@ -832,6 +1041,8 @@ class HandEvaluator {
832
1041
  if (this.evaluation.isBottomPair !== undefined) return this.evaluation.isBottomPair;
833
1042
  if (this.evaluation.isPair === undefined) this.#isPair();
834
1043
  if (!this.evaluation.isPair) return this.evaluation.isBottomPair = false;
1044
+ const position = this.#getBoardPosition(this.#getPairRank());
1045
+ if (position !== null) return this.evaluation.isBottomPair = position === 'bottom';
835
1046
  const rankCounts = this.#getRankCounts();
836
1047
  return this.evaluation.isBottomPair = rankCounts[this.cards[this.cards.length - 1].slice(0, -1)] === 2;
837
1048
  }
@@ -904,6 +1115,8 @@ class HandEvaluator {
904
1115
  if (this.evaluation.isTopThreeOfAKind !== undefined) return this.evaluation.isTopThreeOfAKind;
905
1116
  if (this.evaluation.isThreeOfAKind === undefined) this.#isThreeOfAKind();
906
1117
  if (!this.evaluation.isThreeOfAKind) return this.evaluation.isTopThreeOfAKind = false;
1118
+ const position = this.#getBoardPosition(this.#getTripsRank());
1119
+ if (position !== null) return this.evaluation.isTopThreeOfAKind = position === 'top';
907
1120
  const rankCounts = this.#getRankCounts();
908
1121
  return this.evaluation.isTopThreeOfAKind = rankCounts[this.cards[0].slice(0, -1)] === 3;
909
1122
  }
@@ -921,6 +1134,8 @@ class HandEvaluator {
921
1134
  if (this.evaluation.isMiddleThreeOfAKind !== undefined) return this.evaluation.isMiddleThreeOfAKind;
922
1135
  if (this.evaluation.isThreeOfAKind === undefined) this.#isThreeOfAKind();
923
1136
  if (!this.evaluation.isThreeOfAKind) return this.evaluation.isMiddleThreeOfAKind = false;
1137
+ const position = this.#getBoardPosition(this.#getTripsRank());
1138
+ if (position !== null) return this.evaluation.isMiddleThreeOfAKind = position === 'middle';
924
1139
  const ranks = this.#getRankCountsSorted();
925
1140
  return this.evaluation.isMiddleThreeOfAKind = ranks.length === 3 && ranks[1].count === 3;
926
1141
  }
@@ -938,6 +1153,8 @@ class HandEvaluator {
938
1153
  if (this.evaluation.isBottomThreeOfAKind !== undefined) return this.evaluation.isBottomThreeOfAKind;
939
1154
  if (this.evaluation.isThreeOfAKind === undefined) this.#isThreeOfAKind();
940
1155
  if (!this.evaluation.isThreeOfAKind) return this.evaluation.isBottomThreeOfAKind = false;
1156
+ const position = this.#getBoardPosition(this.#getTripsRank());
1157
+ if (position !== null) return this.evaluation.isBottomThreeOfAKind = position === 'bottom';
941
1158
  const rankCounts = this.#getRankCounts();
942
1159
  return this.evaluation.isBottomThreeOfAKind = rankCounts[this.cards[this.cards.length - 1].slice(0, -1)] === 3;
943
1160
  }
@@ -1851,6 +2068,42 @@ class HandEvaluator {
1851
2068
  }
1852
2069
  return null;
1853
2070
  }
2071
+
2072
+ /**
2073
+ * Gets the rank of the three of a kind in the current hand.
2074
+ *
2075
+ * @private
2076
+ * @returns {string|null} The rank of the three of a kind, or null if none exists
2077
+ */
2078
+ #getTripsRank() {
2079
+ const rankCounts = this.#getRankCounts();
2080
+ return Object.keys(rankCounts).find(rank => rankCounts[rank] === 3) || null;
2081
+ }
2082
+
2083
+ /**
2084
+ * Classifies a paired (or tripled) rank against the whole board, including board cards outside
2085
+ * the current 5-card combination. Requires hole cards: without them the board is unknown and
2086
+ * positions fall back to the 5-card combination.
2087
+ *
2088
+ * - 'top': the rank is at or above the highest board card (top pair, or a pocket pair above the board)
2089
+ * - 'bottom': the rank is at or below the lowest board card
2090
+ * - 'middle': anything in between
2091
+ * - 'none': no hole card has this rank, so the pair or trips belongs to the board
2092
+ *
2093
+ * @private
2094
+ * @param {string|null} rank - The rank to classify
2095
+ * @returns {string|null} The position, or null if no hole cards were supplied
2096
+ */
2097
+ #getBoardPosition(rank) {
2098
+ if (!this.holeCards) return null;
2099
+ if (!rank || !this.holeCards.some(c => c.slice(0, -1) === rank)) return 'none';
2100
+ const fullHand = this.originalCards || this.cards;
2101
+ const boardValues = fullHand.filter(c => !this.holeCards.includes(c)).map(c => this.#getRankValue(c));
2102
+ const value = this.#getRankValue(rank + 's');
2103
+ if (value >= Math.max(...boardValues)) return 'top';
2104
+ if (value <= Math.min(...boardValues)) return 'bottom';
2105
+ return 'middle';
2106
+ }
1854
2107
 
1855
2108
  // ==================== OVERCARDS ====================
1856
2109
 
@@ -1974,10 +2227,13 @@ class HandEvaluator {
1974
2227
  * Required for the overcard categorizations (isOvercard, isTwoOvercards, isOvercardAndMiddleCard,
1975
2228
  * isOvercardAndUndercard, isTwoMiddleCards, isMiddleAndUndercard, isTwoUndercards); without it those
1976
2229
  * properties are always false. Both cards must be present in `cards`.
2230
+ * @param {string[]|null} [villainRange=null] - Optional villain hands as exact combos ('AhKd') or shorthand
2231
+ * ('QQ', 'AKs', 'AKo', 'AK', 'TT+', 'ATs+'). When supplied, each evaluation includes
2232
+ * `villainRange: { boardBlocks, heroBlocks, allBlocks }`, each `{ unblocked: [], blocked: [] }` of exact combos.
1977
2233
  * @returns {Object} Object mapping sorted hand strings (e.g., "As, Ks, Qs, Js, Ts") to evaluation objects with boolean fields for all hand types and draws
1978
2234
  */
1979
- function evaluateHand(cards, strict = true, holeCards = null) {
1980
- const evaluator = new HandEvaluator(cards, strict, holeCards);
2235
+ function evaluateHand(cards, strict = true, holeCards = null, villainRange = null) {
2236
+ const evaluator = new HandEvaluator(cards, strict, holeCards, villainRange);
1981
2237
  return evaluator.evaluation;
1982
2238
  }
1983
2239
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poker-extval",
3
- "version": "3.3.3",
3
+ "version": "3.4.0",
4
4
  "description": "Poker hand evaluator with comprehensive test suite",
5
5
  "main": "evaluator.js",
6
6
  "files": [