poker-extval 3.3.1 → 3.3.3

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 +5 -3
  2. package/evaluator.js +78 -27
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -201,18 +201,20 @@ The `evaluateHand()` function returns an object where:
201
201
  - `isTwoUndercards` - Both hole cards rank below every board card
202
202
 
203
203
  #### Other Categorization
204
- - `isUnderPair` - Pocket pair ranked below every other card in the hand (requires 6+ cards)
204
+ - `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
205
  - `isComboDraw` - Both a flush draw (not backdoor) and a straight draw
206
206
 
207
207
  #### Draws
208
208
  - `isFlushDraw` - Four cards of the same suit (one away from flush)
209
209
  - `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)
210
+ - `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
211
+ - `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
212
  - `isStraightDraw` - Any type of straight draw (open-ended or inside, standard or wheel)
213
213
 
214
214
  **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.
215
215
 
216
+ **Note**: Straight draws count paired cards once, so a paired hand can still hold one: 5-4 on an 8-8-6 flop is a pair and an inside straight draw. With `holeCards`, a hole card that only pairs a board rank does not create a straight draw (9-2 on a 9-8-7-6 board has no straight draw of its own).
217
+
216
218
  #### Wheel Straight Support
217
219
  - `isStraightWheel` - Wheel straight (A-2-3-4-5)
218
220
  - `isOpenEndedStraightDrawWheel` - Open-ended draw using wheel ranking
package/evaluator.js CHANGED
@@ -65,7 +65,8 @@ class HandEvaluator {
65
65
  */
66
66
  constructor(cards, strict = true, holeCards = null, originalCards = null) {
67
67
  this.#validateCards(cards);
68
- this.cards = cards;
68
+ // Copy so that in-place sorting during evaluation never reorders the caller's array
69
+ this.cards = [...cards];
69
70
  this.strict = strict;
70
71
  this.originalCards = originalCards || (cards.length > 5 ? cards : null);
71
72
  if (holeCards === null || holeCards === undefined) {
@@ -370,11 +371,13 @@ class HandEvaluator {
370
371
  }
371
372
 
372
373
  /**
373
- * Gets sorted key card ranks for an evaluation.
374
+ * Gets key card ranks for an evaluation, in tiebreak order.
375
+ * Ranks are grouped by how often they appear (so a full house compares its three of a kind
376
+ * before its pair), then sorted descending. In a wheel straight (A-2-3-4-5) the Ace counts low.
374
377
  *
375
378
  * @private
376
379
  * @param {Object} evaluation - The evaluation object
377
- * @returns {number[]} Array of rank values sorted descending
380
+ * @returns {number[]} Array of rank values in tiebreak order
378
381
  */
379
382
  #getKeyCardRanks(evaluation) {
380
383
  const handStrength = {
@@ -384,8 +387,11 @@ class HandEvaluator {
384
387
  };
385
388
  for (const [hand] of Object.entries(handStrength)) {
386
389
  if (evaluation[hand]) {
387
- const keyCards = evaluation.keyCards[hand] || [];
388
- return keyCards.map(c => this.#getRankValue(c)).sort((a, b) => b - a);
390
+ const ranks = (evaluation.keyCards[hand] || []).map(c => this.#getRankValue(c));
391
+ const isWheel = (hand === 'isStraight' || hand === 'isStraightFlush') && ranks.includes(13) && ranks.includes(4);
392
+ const values = isWheel ? ranks.map(r => r === 13 ? 0 : r) : ranks;
393
+ const counts = values.reduce((acc, r) => (acc[r] = (acc[r] || 0) + 1, acc), {});
394
+ return values.sort((a, b) => counts[b] - counts[a] || b - a);
389
395
  }
390
396
  }
391
397
  return [];
@@ -457,7 +463,7 @@ class HandEvaluator {
457
463
  for (const [key, evaluation] of Object.entries(result)) {
458
464
  const strength = this.#getHandStrength(evaluation);
459
465
  const bestStrength = bestEval ? this.#getHandStrength(bestEval) : 0;
460
- if (!bestEval || strength > bestStrength || (strength === bestStrength && this.#compareEvaluations(evaluation, bestEval) > 0)) {
466
+ if (!bestEval || strength > bestStrength || (strength === bestStrength && this.#compareEvaluations(evaluation, bestEval) < 0)) {
461
467
  bestKey = key;
462
468
  bestEval = evaluation;
463
469
  }
@@ -1082,13 +1088,14 @@ class HandEvaluator {
1082
1088
  /**
1083
1089
  * Determines if the hand is an open-ended straight draw (one card away from a straight on either end).
1084
1090
  * Checks both standard and wheel-equivalent open-ended draws.
1085
- * When hole cards are supplied, the four drawing cards must include at least one hole card.
1086
- * Returns false if the hand already contains pairs, two pair, three of a kind, full house, four of a kind, or a straight.
1091
+ * Paired cards count once, so a paired hand can still hold a straight draw.
1092
+ * When hole cards are supplied, a hole card must add a rank the board does not already have.
1093
+ * Returns false if the hand is already a straight.
1087
1094
  *
1088
1095
  * @returns {boolean} True if the hand is an open-ended straight draw, false otherwise
1089
1096
  * @example
1090
- * const evaluator = new HandEvaluator(['As', 'Ks', 'Qs', 'Js', '9h']);
1091
- * evaluator.isOpenEndedStraightDraw(); // Returns true (needs T or 9 to complete straight)
1097
+ * const evaluator = new HandEvaluator(['Ks', 'Qs', 'Js', 'Ts', '4h']);
1098
+ * evaluator.isOpenEndedStraightDraw(); // Returns true (needs A or 9 to complete straight)
1092
1099
  */
1093
1100
  #isOpenEndedStraightDraw() {
1094
1101
  if (this.evaluation.isOpenEndedStraightDraw !== undefined) return this.evaluation.isOpenEndedStraightDraw;
@@ -1098,7 +1105,7 @@ class HandEvaluator {
1098
1105
  if (this.evaluation.isFullHouse === undefined) this.#isFullHouse();
1099
1106
  if (this.evaluation.isFourOfAKind === undefined) this.#isFourOfAKind();
1100
1107
  if (this.evaluation.isStraight === undefined) this.#isStraight();
1101
- if (this.evaluation.isPair || this.evaluation.isTwoPair || this.evaluation.isThreeOfAKind || this.evaluation.isFullHouse || this.evaluation.isFourOfAKind || this.evaluation.isStraight) {
1108
+ if (this.evaluation.isStraight) {
1102
1109
  // Check wheel equivalent before returning false
1103
1110
  if (this.#isOpenEndedStraightDrawWheel()) return this.evaluation.isOpenEndedStraightDraw = true;
1104
1111
  return this.evaluation.isOpenEndedStraightDraw = false;
@@ -1112,8 +1119,8 @@ class HandEvaluator {
1112
1119
 
1113
1120
  /**
1114
1121
  * Determines if the hand is an open-ended straight draw using wheel ranking.
1115
- * Checks for wheel-equivalent open-ended draws (e.g., A-2-3-4 needs 5 or 2).
1116
- * Returns false if the hand already contains pairs or higher, or is a wheel straight.
1122
+ * Checks for wheel-equivalent open-ended draws. A-2-3-4 needs only a 5, so it counts as an inside draw.
1123
+ * Paired cards count once. Returns false if the hand is already a wheel straight.
1117
1124
  *
1118
1125
  * @returns {boolean} True if the hand is a wheel-equivalent open-ended straight draw, false otherwise
1119
1126
  */
@@ -1125,15 +1132,16 @@ class HandEvaluator {
1125
1132
  if (this.evaluation.isFullHouse === undefined) this.#isFullHouse();
1126
1133
  if (this.evaluation.isFourOfAKind === undefined) this.#isFourOfAKind();
1127
1134
  if (this.evaluation.isStraightWheel === undefined) this.#isStraightWheel();
1128
- if (this.evaluation.isPair || this.evaluation.isTwoPair || this.evaluation.isThreeOfAKind || this.evaluation.isFullHouse || this.evaluation.isFourOfAKind || this.evaluation.isStraightWheel) return this.evaluation.isOpenEndedStraightDrawWheel = false;
1135
+ if (this.evaluation.isStraightWheel) return this.evaluation.isOpenEndedStraightDrawWheel = false;
1129
1136
  return this.evaluation.isOpenEndedStraightDrawWheel = this.#findStraightDrawWindow([3], true) !== null;
1130
1137
  }
1131
1138
 
1132
1139
  /**
1133
1140
  * Determines if the hand is an inside straight draw (one card away from a straight, but the needed card is in the middle).
1134
1141
  * Checks both standard and wheel-equivalent inside draws.
1135
- * When hole cards are supplied, the four drawing cards must include at least one hole card.
1136
- * Returns false if the hand already contains pairs, two pair, three of a kind, full house, four of a kind, or a straight.
1142
+ * Paired cards count once, so a paired hand can still hold a straight draw.
1143
+ * When hole cards are supplied, a hole card must add a rank the board does not already have.
1144
+ * Returns false if the hand is already a straight.
1137
1145
  *
1138
1146
  * @returns {boolean} True if the hand is an inside straight draw, false otherwise
1139
1147
  * @example
@@ -1148,7 +1156,7 @@ class HandEvaluator {
1148
1156
  if (this.evaluation.isFullHouse === undefined) this.#isFullHouse();
1149
1157
  if (this.evaluation.isFourOfAKind === undefined) this.#isFourOfAKind();
1150
1158
  if (this.evaluation.isStraight === undefined) this.#isStraight();
1151
- if (this.evaluation.isPair || this.evaluation.isTwoPair || this.evaluation.isThreeOfAKind || this.evaluation.isFullHouse || this.evaluation.isFourOfAKind || this.evaluation.isStraight) {
1159
+ if (this.evaluation.isStraight) {
1152
1160
  // Check wheel equivalent before returning false
1153
1161
  if (this.#isInsideStraightDrawWheel()) return this.evaluation.isInsideStraightDraw = true;
1154
1162
  return this.evaluation.isInsideStraightDraw = false;
@@ -1164,7 +1172,7 @@ class HandEvaluator {
1164
1172
  /**
1165
1173
  * Determines if the hand is an inside straight draw using wheel ranking.
1166
1174
  * Checks for wheel-equivalent inside draws (e.g., A-2-3-5 needs 4).
1167
- * Returns false if the hand already contains pairs or higher, or is a wheel straight.
1175
+ * Paired cards count once. Returns false if the hand is already a wheel straight.
1168
1176
  *
1169
1177
  * @returns {boolean} True if the hand is a wheel-equivalent inside straight draw, false otherwise
1170
1178
  */
@@ -1176,7 +1184,7 @@ class HandEvaluator {
1176
1184
  if (this.evaluation.isFullHouse === undefined) this.#isFullHouse();
1177
1185
  if (this.evaluation.isFourOfAKind === undefined) this.#isFourOfAKind();
1178
1186
  if (this.evaluation.isStraightWheel === undefined) this.#isStraightWheel();
1179
- if (this.evaluation.isPair || this.evaluation.isTwoPair || this.evaluation.isThreeOfAKind || this.evaluation.isFullHouse || this.evaluation.isFourOfAKind || this.evaluation.isStraightWheel) return this.evaluation.isInsideStraightDrawWheel = false;
1187
+ if (this.evaluation.isStraightWheel) return this.evaluation.isInsideStraightDrawWheel = false;
1180
1188
 
1181
1189
  return this.evaluation.isInsideStraightDrawWheel = this.#findStraightDrawWindow([4], true) !== null;
1182
1190
  }
@@ -1696,9 +1704,13 @@ class HandEvaluator {
1696
1704
  }
1697
1705
 
1698
1706
  /**
1699
- * Finds 4 rank-adjacent cards (the highest four or the lowest four) whose rank span is one of
1700
- * the given distances. When hole cards are supplied, a window made only of board cards is
1701
- * skipped, since a draw the board gives every player is not the player's draw.
1707
+ * Finds 4 cards of distinct, rank-adjacent ranks whose rank span is one of the given distances.
1708
+ * Paired cards count once (a hole card is preferred as the representative of its rank), so a
1709
+ * 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
1710
+ * A-2-3-4) count as inside draws, since only one rank completes them. Wheel windows must
1711
+ * contain the Ace. When hole cards are supplied, a
1712
+ * window whose ranks are all already on the board is skipped, since a draw the board gives
1713
+ * every player is not the player's draw.
1702
1714
  *
1703
1715
  * @private
1704
1716
  * @param {number[]} distances - Accepted rank spans for the 4 cards (3 = open-ended, 4 = inside)
@@ -1708,11 +1720,24 @@ class HandEvaluator {
1708
1720
  #findStraightDrawWindow(distances, wheel) {
1709
1721
  const rankValue = wheel ? c => this.#getRankValueWheel(c) : c => this.#getRankValue(c);
1710
1722
  if (wheel) this.#sortWheel(); else this.#sort();
1711
- for (const window of [this.cards.slice(0, 4), this.cards.slice(1, 5)]) {
1712
- const distance = wheel ? this.#getDistanceWheel(window) : this.#getDistance(window);
1713
- if (distances.includes(distance) && this.#usesHoleCard(window)) {
1714
- return [...window].sort((a, b) => rankValue(b) - rankValue(a));
1723
+ const distinct = [];
1724
+ for (const card of this.cards) {
1725
+ const last = distinct[distinct.length - 1];
1726
+ if (last && rankValue(last) === rankValue(card)) {
1727
+ if (this.holeCards && this.holeCards.includes(card)) distinct[distinct.length - 1] = card;
1728
+ continue;
1715
1729
  }
1730
+ distinct.push(card);
1731
+ }
1732
+ for (let i = 0; i + 4 <= distinct.length; i++) {
1733
+ const window = distinct.slice(i, i + 4);
1734
+ // Wheel ranking only matters when the Ace plays low; other windows are standard draws
1735
+ if (wheel && !window.some(c => c.startsWith('A'))) continue;
1736
+ let distance = rankValue(window[0]) - rankValue(window[3]);
1737
+ // Four in a row capped by the Ace (A-K-Q-J or A-2-3-4) completes at one end only,
1738
+ // so it has the same 4 outs as an inside draw
1739
+ if (distance === 3 && window.some(c => c.startsWith('A'))) distance = 4;
1740
+ if (distances.includes(distance) && this.#addsRankToBoard(window)) return window;
1716
1741
  }
1717
1742
  return null;
1718
1743
  }
@@ -1743,6 +1768,23 @@ class HandEvaluator {
1743
1768
  return cards.some(c => this.holeCards.includes(c));
1744
1769
  }
1745
1770
 
1771
+ /**
1772
+ * Determines whether a straight draw's cards include a rank the board does not already have.
1773
+ * A hole card that only pairs a board rank adds nothing to a straight draw, so when hole cards
1774
+ * are supplied the draw must contain a rank found only in the hole cards. Without hole cards
1775
+ * every draw qualifies.
1776
+ *
1777
+ * @private
1778
+ * @param {string[]} cards - The cards forming the draw
1779
+ * @returns {boolean} True if no hole cards were supplied or a drawing rank is missing from the board
1780
+ */
1781
+ #addsRankToBoard(cards) {
1782
+ if (!this.holeCards) return true;
1783
+ const fullHand = this.originalCards || this.cards;
1784
+ const boardRanks = new Set(fullHand.filter(c => !this.holeCards.includes(c)).map(c => c.slice(0, -1)));
1785
+ return cards.some(c => !boardRanks.has(c.slice(0, -1)));
1786
+ }
1787
+
1746
1788
  // ==================== HIGH CARD ====================
1747
1789
 
1748
1790
  /**
@@ -1766,7 +1808,9 @@ class HandEvaluator {
1766
1808
 
1767
1809
  /**
1768
1810
  * Determines if the hand contains an under pair (pocket pair smaller than all board cards).
1769
- * Only applicable when evaluating 6+ cards where the pair rank is smaller than all other cards.
1811
+ * With hole cards supplied, the hole cards must be a pocket pair ranked below every board card;
1812
+ * this works on any street, including a 5-card flop. Without hole cards the board is unknown, so
1813
+ * it applies only to 6+ cards where the pair rank is smaller than all other cards.
1770
1814
  *
1771
1815
  * @returns {boolean} True if the hand is an under pair, false otherwise
1772
1816
  * @example
@@ -1777,6 +1821,13 @@ class HandEvaluator {
1777
1821
  #isUnderPair() {
1778
1822
  if (this.evaluation.isUnderPair !== undefined) return this.evaluation.isUnderPair;
1779
1823
  if (this.evaluation.isPair === undefined) this.#isPair();
1824
+ if (this.evaluation.isPair && this.holeCards) {
1825
+ const [first, second] = this.holeCards;
1826
+ const holeRank = first.slice(0, -1);
1827
+ if (second.slice(0, -1) !== holeRank || this.#getPairRank() !== holeRank) return this.evaluation.isUnderPair = false;
1828
+ const board = (this.originalCards || this.cards).filter(c => !this.holeCards.includes(c));
1829
+ return this.evaluation.isUnderPair = board.every(c => this.#getRankValue(c) > this.#getRankValue(first));
1830
+ }
1780
1831
  if (!this.evaluation.isPair || !this.originalCards || this.originalCards.length < 6) {
1781
1832
  return this.evaluation.isUnderPair = false;
1782
1833
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poker-extval",
3
- "version": "3.3.1",
3
+ "version": "3.3.3",
4
4
  "description": "Poker hand evaluator with comprehensive test suite",
5
5
  "main": "evaluator.js",
6
6
  "files": [