poker-extval 3.3.2 → 3.3.4

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 +8 -3
  2. package/evaluator.js +172 -13
  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
@@ -201,14 +206,14 @@ The `evaluateHand()` function returns an object where:
201
206
  - `isTwoUndercards` - Both hole cards rank below every board card
202
207
 
203
208
  #### Other Categorization
204
- - `isUnderPair` - Pocket pair ranked below every other card in the hand (requires 6+ cards)
209
+ - `isUnderPair` - Pocket pair ranked below every board card. With `holeCards` this works on any street, including the flop; without them the board is unknown, so it requires 6+ cards and means a pair ranked below every other card
205
210
  - `isComboDraw` - Both a flush draw (not backdoor) and a straight draw
206
211
 
207
212
  #### Draws
208
213
  - `isFlushDraw` - Four cards of the same suit (one away from flush)
209
214
  - `isBackdoorFlushDraw` - Three cards of the same suit (two away from flush)
210
- - `isOpenEndedStraightDraw` - One card away from straight on either end
211
- - `isInsideStraightDraw` - One card away from straight (needed card in middle)
215
+ - `isOpenEndedStraightDraw` - Four in a row that a card at either end completes (e.g. 9-8-7-6). A-K-Q-J and A-2-3-4 complete at one end only, so they count as inside draws
216
+ - `isInsideStraightDraw` - One card away from a straight that only one rank completes (a gap in the middle, or A-K-Q-J / A-2-3-4)
212
217
  - `isStraightDraw` - Any type of straight draw (open-ended or inside, standard or wheel)
213
218
 
214
219
  **Note**: When `holeCards` are supplied, every draw (including `isComboDraw` and the wheel draws) must use at least one hole card. A draw made entirely of board cards is shared by every player, so it is not reported: 2-2 on a 9-8-7-6 board is not an open-ended straight draw, and A♦-Q♦ on a K♠-7♠-2♠ flop is not a backdoor flush draw. Without `holeCards` the evaluator cannot tell the board from the hand, so draws are detected from all cards.
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.
@@ -65,7 +90,8 @@ class HandEvaluator {
65
90
  */
66
91
  constructor(cards, strict = true, holeCards = null, originalCards = null) {
67
92
  this.#validateCards(cards);
68
- this.cards = cards;
93
+ // Copy so that in-place sorting during evaluation never reorders the caller's array
94
+ this.cards = [...cards];
69
95
  this.strict = strict;
70
96
  this.originalCards = originalCards || (cards.length > 5 ? cards : null);
71
97
  if (holeCards === null || holeCards === undefined) {
@@ -271,6 +297,7 @@ class HandEvaluator {
271
297
  this.#isInsideStraightDraw();
272
298
  this.#isStraightDraw();
273
299
  this.#isComboDraw();
300
+ this.#clearDrawsForStreet();
274
301
  this.#isUnderPair();
275
302
  this.#isOvercards();
276
303
  this.#isHighCard();
@@ -370,11 +397,13 @@ class HandEvaluator {
370
397
  }
371
398
 
372
399
  /**
373
- * Gets sorted key card ranks for an evaluation.
400
+ * Gets key card ranks for an evaluation, in tiebreak order.
401
+ * Ranks are grouped by how often they appear (so a full house compares its three of a kind
402
+ * before its pair), then sorted descending. In a wheel straight (A-2-3-4-5) the Ace counts low.
374
403
  *
375
404
  * @private
376
405
  * @param {Object} evaluation - The evaluation object
377
- * @returns {number[]} Array of rank values sorted descending
406
+ * @returns {number[]} Array of rank values in tiebreak order
378
407
  */
379
408
  #getKeyCardRanks(evaluation) {
380
409
  const handStrength = {
@@ -384,8 +413,11 @@ class HandEvaluator {
384
413
  };
385
414
  for (const [hand] of Object.entries(handStrength)) {
386
415
  if (evaluation[hand]) {
387
- const keyCards = evaluation.keyCards[hand] || [];
388
- return keyCards.map(c => this.#getRankValue(c)).sort((a, b) => b - a);
416
+ const ranks = (evaluation.keyCards[hand] || []).map(c => this.#getRankValue(c));
417
+ const isWheel = (hand === 'isStraight' || hand === 'isStraightFlush') && ranks.includes(13) && ranks.includes(4);
418
+ const values = isWheel ? ranks.map(r => r === 13 ? 0 : r) : ranks;
419
+ const counts = values.reduce((acc, r) => (acc[r] = (acc[r] || 0) + 1, acc), {});
420
+ return values.sort((a, b) => counts[b] - counts[a] || b - a);
389
421
  }
390
422
  }
391
423
  return [];
@@ -457,7 +489,7 @@ class HandEvaluator {
457
489
  for (const [key, evaluation] of Object.entries(result)) {
458
490
  const strength = this.#getHandStrength(evaluation);
459
491
  const bestStrength = bestEval ? this.#getHandStrength(bestEval) : 0;
460
- if (!bestEval || strength > bestStrength || (strength === bestStrength && this.#compareEvaluations(evaluation, bestEval) > 0)) {
492
+ if (!bestEval || strength > bestStrength || (strength === bestStrength && this.#compareEvaluations(evaluation, bestEval) < 0)) {
461
493
  bestKey = key;
462
494
  bestEval = evaluation;
463
495
  }
@@ -495,7 +527,70 @@ class HandEvaluator {
495
527
  result[key] = tempEvaluator.evaluation[evaluationKey];
496
528
  }
497
529
 
498
- return this.strict ? this.#findBestCombination(result) : result;
530
+ if (!this.strict) return result;
531
+ const best = this.#findBestCombination(result);
532
+ this.#mergeDraws(Object.values(best)[0], result);
533
+ return best;
534
+ }
535
+
536
+ /**
537
+ * Removes draws that can no longer complete, based on how many cards the full hand holds:
538
+ * 5 cards is the flop (two cards to come), 6 is the turn (one card to come, so a backdoor
539
+ * draw cannot complete) and 7 or more is the river (no cards to come, so there are no draws).
540
+ *
541
+ * @private
542
+ */
543
+ #clearDrawsForStreet() {
544
+ const cardCount = (this.originalCards || this.cards).length;
545
+ if (cardCount >= 7) {
546
+ for (const draw of DRAW_PROPERTIES) this.evaluation[draw] = false;
547
+ } else if (cardCount === 6) {
548
+ this.evaluation.isBackdoorFlushDraw = false;
549
+ }
550
+ }
551
+
552
+ /**
553
+ * In strict mode with 6+ cards only the best 5-card combination is returned, but a draw can
554
+ * use cards outside it (e.g. the hole card that makes four to a flush). Copies onto the best
555
+ * evaluation every draw found in any combination, with that combination's key and kicker
556
+ * cards, then drops draws the made hand already beats and recomputes the combo draw for the
557
+ * whole hand.
558
+ *
559
+ * @private
560
+ * @param {Object} best - The evaluation of the best combination (modified in place)
561
+ * @param {Object<string, Object>} result - Evaluations of every combination
562
+ */
563
+ #mergeDraws(best, result) {
564
+ const clear = draw => {
565
+ best[draw] = false;
566
+ best.keyCards[draw] = [];
567
+ best.kickerCards[draw] = [];
568
+ };
569
+ for (const draw of DRAW_PROPERTIES) {
570
+ if (draw === 'isComboDraw' || best[draw]) continue;
571
+ const source = Object.values(result).find(evaluation => evaluation[draw]);
572
+ if (!source) continue;
573
+ best[draw] = true;
574
+ best.keyCards[draw] = source.keyCards[draw];
575
+ best.kickerCards[draw] = source.kickerCards[draw];
576
+ }
577
+
578
+ // A made straight or better needs no straight draw; a made flush or better needs no flush draw
579
+ const strongest = this.#getHandStrength(best);
580
+ if (strongest >= 5) STRAIGHT_DRAW_PROPERTIES.forEach(clear);
581
+ if (strongest >= 6) ['isFlushDraw', 'isBackdoorFlushDraw'].forEach(clear);
582
+
583
+ if (best.isComboDraw || !(best.isFlushDraw && best.isStraightDraw)) {
584
+ if (!(best.isFlushDraw && best.isStraightDraw)) clear('isComboDraw');
585
+ return;
586
+ }
587
+ best.isComboDraw = true;
588
+ const keyCards = [...new Set([...best.keyCards.isFlushDraw, ...best.keyCards.isStraightDraw])]
589
+ .sort((a, b) => this.#getRankValue(b) - this.#getRankValue(a));
590
+ best.keyCards.isComboDraw = keyCards;
591
+ best.kickerCards.isComboDraw = this.cards
592
+ .filter(c => !keyCards.includes(c))
593
+ .sort((a, b) => this.#getRankValue(b) - this.#getRankValue(a));
499
594
  }
500
595
 
501
596
  /**
@@ -791,6 +886,8 @@ class HandEvaluator {
791
886
  if (this.evaluation.isTopPair !== undefined) return this.evaluation.isTopPair;
792
887
  if (this.evaluation.isPair === undefined) this.#isPair();
793
888
  if (!this.evaluation.isPair) return this.evaluation.isTopPair = false;
889
+ const position = this.#getBoardPosition(this.#getPairRank());
890
+ if (position !== null) return this.evaluation.isTopPair = position === 'top';
794
891
  const rankCounts = this.#getRankCounts();
795
892
  return this.evaluation.isTopPair = rankCounts[this.cards[0].slice(0, -1)] === 2;
796
893
  }
@@ -809,6 +906,8 @@ class HandEvaluator {
809
906
  if (this.evaluation.isPair === undefined) this.#isPair();
810
907
  if (this.evaluation.isTwoPair === undefined) this.#isTwoPair();
811
908
  if (!this.evaluation.isPair || this.evaluation.isTwoPair) return this.evaluation.isMiddlePair = false;
909
+ const position = this.#getBoardPosition(this.#getPairRank());
910
+ if (position !== null) return this.evaluation.isMiddlePair = position === 'middle';
812
911
  const ranks = this.#getRankCountsSorted();
813
912
  return this.evaluation.isMiddlePair = ranks.length === 4 && (ranks[1].count === 2 || ranks[2].count === 2);
814
913
  }
@@ -826,6 +925,8 @@ class HandEvaluator {
826
925
  if (this.evaluation.isBottomPair !== undefined) return this.evaluation.isBottomPair;
827
926
  if (this.evaluation.isPair === undefined) this.#isPair();
828
927
  if (!this.evaluation.isPair) return this.evaluation.isBottomPair = false;
928
+ const position = this.#getBoardPosition(this.#getPairRank());
929
+ if (position !== null) return this.evaluation.isBottomPair = position === 'bottom';
829
930
  const rankCounts = this.#getRankCounts();
830
931
  return this.evaluation.isBottomPair = rankCounts[this.cards[this.cards.length - 1].slice(0, -1)] === 2;
831
932
  }
@@ -898,6 +999,8 @@ class HandEvaluator {
898
999
  if (this.evaluation.isTopThreeOfAKind !== undefined) return this.evaluation.isTopThreeOfAKind;
899
1000
  if (this.evaluation.isThreeOfAKind === undefined) this.#isThreeOfAKind();
900
1001
  if (!this.evaluation.isThreeOfAKind) return this.evaluation.isTopThreeOfAKind = false;
1002
+ const position = this.#getBoardPosition(this.#getTripsRank());
1003
+ if (position !== null) return this.evaluation.isTopThreeOfAKind = position === 'top';
901
1004
  const rankCounts = this.#getRankCounts();
902
1005
  return this.evaluation.isTopThreeOfAKind = rankCounts[this.cards[0].slice(0, -1)] === 3;
903
1006
  }
@@ -915,6 +1018,8 @@ class HandEvaluator {
915
1018
  if (this.evaluation.isMiddleThreeOfAKind !== undefined) return this.evaluation.isMiddleThreeOfAKind;
916
1019
  if (this.evaluation.isThreeOfAKind === undefined) this.#isThreeOfAKind();
917
1020
  if (!this.evaluation.isThreeOfAKind) return this.evaluation.isMiddleThreeOfAKind = false;
1021
+ const position = this.#getBoardPosition(this.#getTripsRank());
1022
+ if (position !== null) return this.evaluation.isMiddleThreeOfAKind = position === 'middle';
918
1023
  const ranks = this.#getRankCountsSorted();
919
1024
  return this.evaluation.isMiddleThreeOfAKind = ranks.length === 3 && ranks[1].count === 3;
920
1025
  }
@@ -932,6 +1037,8 @@ class HandEvaluator {
932
1037
  if (this.evaluation.isBottomThreeOfAKind !== undefined) return this.evaluation.isBottomThreeOfAKind;
933
1038
  if (this.evaluation.isThreeOfAKind === undefined) this.#isThreeOfAKind();
934
1039
  if (!this.evaluation.isThreeOfAKind) return this.evaluation.isBottomThreeOfAKind = false;
1040
+ const position = this.#getBoardPosition(this.#getTripsRank());
1041
+ if (position !== null) return this.evaluation.isBottomThreeOfAKind = position === 'bottom';
935
1042
  const rankCounts = this.#getRankCounts();
936
1043
  return this.evaluation.isBottomThreeOfAKind = rankCounts[this.cards[this.cards.length - 1].slice(0, -1)] === 3;
937
1044
  }
@@ -1088,8 +1195,8 @@ class HandEvaluator {
1088
1195
  *
1089
1196
  * @returns {boolean} True if the hand is an open-ended straight draw, false otherwise
1090
1197
  * @example
1091
- * const evaluator = new HandEvaluator(['As', 'Ks', 'Qs', 'Js', '9h']);
1092
- * evaluator.isOpenEndedStraightDraw(); // Returns true (needs T or 9 to complete straight)
1198
+ * const evaluator = new HandEvaluator(['Ks', 'Qs', 'Js', 'Ts', '4h']);
1199
+ * evaluator.isOpenEndedStraightDraw(); // Returns true (needs A or 9 to complete straight)
1093
1200
  */
1094
1201
  #isOpenEndedStraightDraw() {
1095
1202
  if (this.evaluation.isOpenEndedStraightDraw !== undefined) return this.evaluation.isOpenEndedStraightDraw;
@@ -1113,7 +1220,7 @@ class HandEvaluator {
1113
1220
 
1114
1221
  /**
1115
1222
  * Determines if the hand is an open-ended straight draw using wheel ranking.
1116
- * Checks for wheel-equivalent open-ended draws (e.g., A-2-3-4 needs 5 or 2).
1223
+ * Checks for wheel-equivalent open-ended draws. A-2-3-4 needs only a 5, so it counts as an inside draw.
1117
1224
  * Paired cards count once. Returns false if the hand is already a wheel straight.
1118
1225
  *
1119
1226
  * @returns {boolean} True if the hand is a wheel-equivalent open-ended straight draw, false otherwise
@@ -1700,7 +1807,9 @@ class HandEvaluator {
1700
1807
  /**
1701
1808
  * Finds 4 cards of distinct, rank-adjacent ranks whose rank span is one of the given distances.
1702
1809
  * Paired cards count once (a hole card is preferred as the representative of its rank), so a
1703
- * paired hand such as 8-8-6-5-4 still finds its 8-6-5-4 draw. When hole cards are supplied, a
1810
+ * paired hand such as 8-8-6-5-4 still finds its 8-6-5-4 draw. One-ended draws (A-K-Q-J and
1811
+ * A-2-3-4) count as inside draws, since only one rank completes them. Wheel windows must
1812
+ * contain the Ace. When hole cards are supplied, a
1704
1813
  * window whose ranks are all already on the board is skipped, since a draw the board gives
1705
1814
  * every player is not the player's draw.
1706
1815
  *
@@ -1723,7 +1832,12 @@ class HandEvaluator {
1723
1832
  }
1724
1833
  for (let i = 0; i + 4 <= distinct.length; i++) {
1725
1834
  const window = distinct.slice(i, i + 4);
1726
- const distance = rankValue(window[0]) - rankValue(window[3]);
1835
+ // Wheel ranking only matters when the Ace plays low; other windows are standard draws
1836
+ if (wheel && !window.some(c => c.startsWith('A'))) continue;
1837
+ let distance = rankValue(window[0]) - rankValue(window[3]);
1838
+ // Four in a row capped by the Ace (A-K-Q-J or A-2-3-4) completes at one end only,
1839
+ // so it has the same 4 outs as an inside draw
1840
+ if (distance === 3 && window.some(c => c.startsWith('A'))) distance = 4;
1727
1841
  if (distances.includes(distance) && this.#addsRankToBoard(window)) return window;
1728
1842
  }
1729
1843
  return null;
@@ -1795,7 +1909,9 @@ class HandEvaluator {
1795
1909
 
1796
1910
  /**
1797
1911
  * Determines if the hand contains an under pair (pocket pair smaller than all board cards).
1798
- * Only applicable when evaluating 6+ cards where the pair rank is smaller than all other cards.
1912
+ * With hole cards supplied, the hole cards must be a pocket pair ranked below every board card;
1913
+ * this works on any street, including a 5-card flop. Without hole cards the board is unknown, so
1914
+ * it applies only to 6+ cards where the pair rank is smaller than all other cards.
1799
1915
  *
1800
1916
  * @returns {boolean} True if the hand is an under pair, false otherwise
1801
1917
  * @example
@@ -1806,6 +1922,13 @@ class HandEvaluator {
1806
1922
  #isUnderPair() {
1807
1923
  if (this.evaluation.isUnderPair !== undefined) return this.evaluation.isUnderPair;
1808
1924
  if (this.evaluation.isPair === undefined) this.#isPair();
1925
+ if (this.evaluation.isPair && this.holeCards) {
1926
+ const [first, second] = this.holeCards;
1927
+ const holeRank = first.slice(0, -1);
1928
+ if (second.slice(0, -1) !== holeRank || this.#getPairRank() !== holeRank) return this.evaluation.isUnderPair = false;
1929
+ const board = (this.originalCards || this.cards).filter(c => !this.holeCards.includes(c));
1930
+ return this.evaluation.isUnderPair = board.every(c => this.#getRankValue(c) > this.#getRankValue(first));
1931
+ }
1809
1932
  if (!this.evaluation.isPair || !this.originalCards || this.originalCards.length < 6) {
1810
1933
  return this.evaluation.isUnderPair = false;
1811
1934
  }
@@ -1829,6 +1952,42 @@ class HandEvaluator {
1829
1952
  }
1830
1953
  return null;
1831
1954
  }
1955
+
1956
+ /**
1957
+ * Gets the rank of the three of a kind in the current hand.
1958
+ *
1959
+ * @private
1960
+ * @returns {string|null} The rank of the three of a kind, or null if none exists
1961
+ */
1962
+ #getTripsRank() {
1963
+ const rankCounts = this.#getRankCounts();
1964
+ return Object.keys(rankCounts).find(rank => rankCounts[rank] === 3) || null;
1965
+ }
1966
+
1967
+ /**
1968
+ * Classifies a paired (or tripled) rank against the whole board, including board cards outside
1969
+ * the current 5-card combination. Requires hole cards: without them the board is unknown and
1970
+ * positions fall back to the 5-card combination.
1971
+ *
1972
+ * - 'top': the rank is at or above the highest board card (top pair, or a pocket pair above the board)
1973
+ * - 'bottom': the rank is at or below the lowest board card
1974
+ * - 'middle': anything in between
1975
+ * - 'none': no hole card has this rank, so the pair or trips belongs to the board
1976
+ *
1977
+ * @private
1978
+ * @param {string|null} rank - The rank to classify
1979
+ * @returns {string|null} The position, or null if no hole cards were supplied
1980
+ */
1981
+ #getBoardPosition(rank) {
1982
+ if (!this.holeCards) return null;
1983
+ if (!rank || !this.holeCards.some(c => c.slice(0, -1) === rank)) return 'none';
1984
+ const fullHand = this.originalCards || this.cards;
1985
+ const boardValues = fullHand.filter(c => !this.holeCards.includes(c)).map(c => this.#getRankValue(c));
1986
+ const value = this.#getRankValue(rank + 's');
1987
+ if (value >= Math.max(...boardValues)) return 'top';
1988
+ if (value <= Math.min(...boardValues)) return 'bottom';
1989
+ return 'middle';
1990
+ }
1832
1991
 
1833
1992
  // ==================== OVERCARDS ====================
1834
1993
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poker-extval",
3
- "version": "3.3.2",
3
+ "version": "3.3.4",
4
4
  "description": "Poker hand evaluator with comprehensive test suite",
5
5
  "main": "evaluator.js",
6
6
  "files": [