poker-extval 3.2.1 → 3.3.1

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 +74 -3
  2. package/evaluator.js +281 -54
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -7,6 +7,7 @@ A comprehensive poker hand evaluation library for Node.js that evaluates poker h
7
7
  - **Comprehensive Hand Evaluation**: Evaluates all standard poker hand types (Royal Flush, Straight Flush, Four of a Kind, Full House, Flush, Straight, Three of a Kind, Two Pair, Pair, High Card)
8
8
  - **Strict Mode (Default)**: Only marks the strongest made hand and stronger draws, preventing weaker hands from being marked when stronger ones exist
9
9
  - **Detailed Categorization**: Categorizes pairs (Top, Middle, Bottom), two pairs, and three of a kind by position
10
+ - **Overcard Categorization**: With hole cards supplied, classifies them against the board (two overcards, overcard and middle card, overcard and undercard, two middle cards, middle and undercard, two undercards)
10
11
  - **Draw Detection**: Identifies flush draws, backdoor flush draws, open-ended straight draws, and inside straight draws
11
12
  - **Key Cards and Kicker Cards**: Provides detailed card arrays showing which cards form each hand type and which are kickers
12
13
  - **Wheel Straight Support**: Handles Ace-low straights (A-2-3-4-5) correctly
@@ -56,6 +57,50 @@ const evaluation = evaluator.evaluation;
56
57
  const nonStrictEvaluator = new HandEvaluator(['As', 'Ah', 'Kd', 'Kc', 'Qs'], false);
57
58
  ```
58
59
 
60
+ ### Overcards
61
+
62
+ The overcard categorizations describe your **hole cards** relative to the **board**, so the evaluator needs to know which two of the cards are yours. Pass them as the third argument:
63
+
64
+ ```javascript
65
+ const { evaluateHand } = require('poker-extval');
66
+
67
+ const board = ['Ks', 'Qh', '8d', '3c'];
68
+ const holeCards = ['Ad', 'Jh'];
69
+
70
+ const result = evaluateHand([...board, ...holeCards], true, holeCards);
71
+ const evaluation = result[Object.keys(result)[0]];
72
+
73
+ evaluation.isOvercard; // true (the ace beats every board card)
74
+ evaluation.isOvercardAndMiddleCard; // true (ace is an overcard, jack sits between 3 and K)
75
+ evaluation.isTwoOvercards; // false
76
+ evaluation.keyCards.isOvercard; // ['Ad']
77
+ ```
78
+
79
+ Each hole card is classified against the board:
80
+
81
+ | Classification | Meaning |
82
+ | --- | --- |
83
+ | Overcard | Rank is higher than every board card |
84
+ | Middle card | Rank sits strictly between the highest and lowest board ranks |
85
+ | Undercard | Rank is lower than every board card |
86
+ | Paired | Rank matches a board card, so it belongs to none of the above |
87
+
88
+ The two classifications combine into one (and only one) of the six categories below. If either hole card pairs the board, none of the six is true — though `isOvercard` can still be true on its own.
89
+
90
+ ```javascript
91
+ // Board: K Q 8 3
92
+ evaluateHand([...board, 'Ad', 'Kc'], true, ['Ad', 'Kc']); // isOvercard only (the king pairs the board)
93
+ evaluateHand([...board, 'Jd', 'Th'], true, ['Jd', 'Th']); // isTwoMiddleCards
94
+ evaluateHand([...board, '4d', '2h'], true, ['4d', '2h']); // isMiddleAndUndercard (the 4 beats the 3)
95
+ evaluateHand([...board, '2d', '2h'], true, ['2d', '2h']); // isTwoUndercards
96
+ ```
97
+
98
+ **Notes:**
99
+ - Hole cards are optional. Without them every overcard property is `false`.
100
+ - Supplying hole cards also restricts draws to those that use at least one hole card (see [Draws](#draws)).
101
+ - Both hole cards must appear in the `cards` array, but they do **not** have to appear in the 5-card combination being evaluated — the classification is a property of the whole hand and is identical across every combination.
102
+ - These categorizations describe the hole cards, not the made hand, so **strict mode does not filter them**. A hand that flops a straight with A-J on a K-Q-T-3 board reports both `isStraight` and `isOvercardAndMiddleCard`.
103
+
59
104
  ### Strict Mode
60
105
 
61
106
  By default, the evaluator uses **strict mode** which only marks the strongest made hand and stronger draws as true. This prevents weaker hands from being marked when a stronger hand exists.
@@ -146,6 +191,19 @@ The `evaluateHand()` function returns an object where:
146
191
  - `isMiddleThreeOfAKind` - Three of a kind is neither highest nor lowest
147
192
  - `isBottomThreeOfAKind` - Three of a kind is the lowest rank
148
193
 
194
+ #### Overcard Categorization (requires `holeCards`; not filtered by strict mode)
195
+ - `isOvercard` - At least one hole card ranks above every board card
196
+ - `isTwoOvercards` - Both hole cards rank above every board card
197
+ - `isOvercardAndMiddleCard` - One overcard, one middle card
198
+ - `isOvercardAndUndercard` - One overcard, one undercard
199
+ - `isTwoMiddleCards` - Both hole cards sit strictly between the highest and lowest board ranks
200
+ - `isMiddleAndUndercard` - One middle card, one undercard
201
+ - `isTwoUndercards` - Both hole cards rank below every board card
202
+
203
+ #### Other Categorization
204
+ - `isUnderPair` - Pocket pair ranked below every other card in the hand (requires 6+ cards)
205
+ - `isComboDraw` - Both a flush draw (not backdoor) and a straight draw
206
+
149
207
  #### Draws
150
208
  - `isFlushDraw` - Four cards of the same suit (one away from flush)
151
209
  - `isBackdoorFlushDraw` - Three cards of the same suit (two away from flush)
@@ -153,6 +211,8 @@ The `evaluateHand()` function returns an object where:
153
211
  - `isInsideStraightDraw` - One card away from straight (needed card in middle)
154
212
  - `isStraightDraw` - Any type of straight draw (open-ended or inside, standard or wheel)
155
213
 
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
+
156
216
  #### Wheel Straight Support
157
217
  - `isStraightWheel` - Wheel straight (A-2-3-4-5)
158
218
  - `isOpenEndedStraightDrawWheel` - Open-ended draw using wheel ranking
@@ -162,6 +222,8 @@ The `evaluateHand()` function returns an object where:
162
222
  - `keyCards` - Object with keys for each hand type/draw, values are arrays of cards (sorted) that constitute that specific hand type or draw. False hand types have empty arrays `[]`.
163
223
  - `kickerCards` - Object with keys for each hand type/draw, values are arrays of cards (sorted) that don't constitute that specific hand type or draw. False hand types have empty arrays `[]`, except `isHighCard` which contains all 5 cards when true.
164
224
 
225
+ **Note**: For the overcard categorizations, `keyCards` holds the qualifying hole cards and `kickerCards` holds the remaining cards of the **full hand** (the board, plus any hole card that is not a key card) rather than the remaining cards of the 5-card combination.
226
+
165
227
  **Note**: Each hand type has its own `keyCards` and `kickerCards` entries. For example, a hand with both a pair and a flush draw will have separate entries in `keyCards.isPair`, `keyCards.isFlushDraw`, `kickerCards.isPair`, and `kickerCards.isFlushDraw`.
166
228
 
167
229
  ### Example Response
@@ -352,6 +414,7 @@ The evaluator validates input and throws descriptive errors for:
352
414
  - Invalid rank (not A, K, Q, J, T, 9-2)
353
415
  - Invalid suit (not s, h, d, c)
354
416
  - Duplicate cards
417
+ - Hole cards that are not an array of exactly 2 distinct cards drawn from `cards`
355
418
 
356
419
  Example:
357
420
  ```javascript
@@ -370,23 +433,25 @@ Run the test suite:
370
433
  npm test
371
434
  ```
372
435
 
373
- The test suite includes 572+ tests covering:
436
+ The test suite includes 700+ tests covering:
374
437
  - All hand types
375
438
  - Edge cases
376
439
  - Invalid input handling
377
440
  - Wheel straights
378
441
  - Draw detection
379
442
  - Categorization logic
443
+ - Overcard categorization and hole card validation
380
444
 
381
445
  ## API Reference
382
446
 
383
- ### `evaluateHand(cards, strict)`
447
+ ### `evaluateHand(cards, strict, holeCards)`
384
448
 
385
449
  Main function to evaluate poker hands.
386
450
 
387
451
  **Parameters:**
388
452
  - `cards` (string[]): Array of 5 or more card strings
389
453
  - `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`).
454
+ - `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`).
390
455
 
391
456
  **Returns:**
392
457
  - Object mapping sorted hand strings to evaluation objects
@@ -399,7 +464,7 @@ Main function to evaluate poker hands.
399
464
  #### Constructor
400
465
 
401
466
  ```javascript
402
- new HandEvaluator(cards, strict)
467
+ new HandEvaluator(cards, strict, holeCards)
403
468
  ```
404
469
 
405
470
  Creates a new HandEvaluator instance.
@@ -407,14 +472,20 @@ Creates a new HandEvaluator instance.
407
472
  **Parameters:**
408
473
  - `cards` (string[]): Array of 5 or more card strings
409
474
  - `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`).
475
+ - `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`).
410
476
 
411
477
  **Properties:**
412
478
  - `evaluation` - Object mapping sorted hand strings to evaluation objects (same format as `evaluateHand()` return value)
413
479
  - `cards` - Array of input cards
414
480
  - `strict` - Boolean indicating whether strict mode is enabled
481
+ - `holeCards` - Array of the 2 hole cards, or `null` if none were supplied
415
482
 
416
483
  **Note**: All internal methods are private. Use the `evaluation` property to access evaluation results.
417
484
 
485
+ ## Releasing
486
+
487
+ Pushing to `main` publishes to npm via GitHub Actions (`.github/workflows/publish.yml`), using npm trusted publishing so no token is stored. Every push to `main` is released: the workflow runs the tests, bumps the patch version, commits the bump back to `main`, tags it `v<version>` and publishes. There is no need to bump the version by hand for patch releases. For a minor or major release, bump it in your change (`npm version minor|major --no-git-tag-version`); a version that is not on npm yet is published as is, without an extra bump.
488
+
418
489
  ## License
419
490
 
420
491
  MIT License
package/evaluator.js CHANGED
@@ -19,6 +19,23 @@
19
19
  * const result = evaluator.evaluation;
20
20
  * // result contains 6 key-value pairs, one for each 5-card combination
21
21
  */
22
+ /**
23
+ * Evaluation properties that categorize the player's hole cards relative to the board.
24
+ * These describe the two hole cards rather than a 5-card hand, so their kicker cards are
25
+ * drawn from the full hand (the board) rather than from the 5-card combination.
26
+ *
27
+ * @constant {Set<string>}
28
+ */
29
+ const HOLE_CARD_CATEGORIES = new Set([
30
+ 'isOvercard',
31
+ 'isTwoOvercards',
32
+ 'isOvercardAndMiddleCard',
33
+ 'isOvercardAndUndercard',
34
+ 'isTwoMiddleCards',
35
+ 'isMiddleAndUndercard',
36
+ 'isTwoUndercards'
37
+ ]);
38
+
22
39
  class HandEvaluator {
23
40
  /**
24
41
  * Creates an instance of HandEvaluator.
@@ -28,8 +45,12 @@ class HandEvaluator {
28
45
  * - s is suit: s (spades), h (hearts), d (diamonds), c (clubs)
29
46
  * @param {boolean} [strict=true] - If true, only the strongest made hand and stronger draws are marked as true.
30
47
  * If false, all applicable hand types are marked (current behavior).
48
+ * @param {string[]|null} [holeCards=null] - Optional array of exactly 2 cards identifying the player's
49
+ * hole cards. Required for the overcard categorizations (isOvercard, isTwoOvercards, etc.); without it
50
+ * those properties are always false. Both cards must be present in `cards`.
31
51
  * @throws {Error} If cards is not an array, has fewer than 5 cards, contains invalid format,
32
52
  * invalid rank, invalid suit, or duplicate cards
53
+ * @throws {Error} If holeCards is provided but is not an array of exactly 2 distinct cards drawn from `cards`
33
54
  * @example
34
55
  * const evaluator = new HandEvaluator(['As', 'Ks', 'Qs', 'Js', 'Ts']);
35
56
  * @example
@@ -38,15 +59,65 @@ class HandEvaluator {
38
59
  * @example
39
60
  * // Non-strict mode - all applicable hands marked
40
61
  * const nonStrictEvaluator = new HandEvaluator(['As', 'Ah', 'Kd', 'Kc', 'Qs'], false);
62
+ * @example
63
+ * // With hole cards - enables overcard categorization
64
+ * const withHoleCards = new HandEvaluator(['Ks', 'Qh', '8d', '3c', 'Ad', 'Jh'], true, ['Ad', 'Jh']);
41
65
  */
42
- constructor(cards, strict = true, originalCards = null) {
66
+ constructor(cards, strict = true, holeCards = null, originalCards = null) {
43
67
  this.#validateCards(cards);
44
68
  this.cards = cards;
45
69
  this.strict = strict;
46
70
  this.originalCards = originalCards || (cards.length > 5 ? cards : null);
71
+ if (holeCards === null || holeCards === undefined) {
72
+ this.holeCards = null;
73
+ } else {
74
+ this.#validateHoleCards(holeCards, this.originalCards || cards);
75
+ this.holeCards = holeCards;
76
+ }
47
77
  this.evaluation = this.#evaluateAllCombinations();
48
78
  }
49
79
 
80
+ /**
81
+ * Validates the optional hole cards array.
82
+ * Hole cards must be exactly 2 distinct cards that appear in the full hand.
83
+ *
84
+ * @private
85
+ * @param {string[]} holeCards - Array of hole card strings to validate
86
+ * @param {string[]} fullHand - The complete set of cards the hole cards must be drawn from
87
+ * @throws {Error} If holeCards is not an array
88
+ * @throws {Error} If holeCards does not contain exactly 2 cards
89
+ * @throws {Error} If any hole card is not a string
90
+ * @throws {Error} If duplicate hole cards are found
91
+ * @throws {Error} If a hole card is not present in the full hand
92
+ */
93
+ #validateHoleCards(holeCards, fullHand) {
94
+ if (!Array.isArray(holeCards)) {
95
+ throw new Error('Hole cards must be an array');
96
+ }
97
+ if (holeCards.length !== 2) {
98
+ throw new Error(`Expected exactly 2 hole cards, received ${holeCards.length}`);
99
+ }
100
+
101
+ const handSet = new Set(fullHand);
102
+ const seenHoleCards = new Set();
103
+
104
+ for (let i = 0; i < holeCards.length; i++) {
105
+ const card = holeCards[i];
106
+
107
+ if (typeof card !== 'string') {
108
+ throw new Error(`Hole card at index ${i} must be a string`);
109
+ }
110
+ if (seenHoleCards.has(card)) {
111
+ throw new Error(`Duplicate hole card found: "${card}"`);
112
+ }
113
+ seenHoleCards.add(card);
114
+
115
+ if (!handSet.has(card)) {
116
+ throw new Error(`Hole card not found in cards: "${card}"`);
117
+ }
118
+ }
119
+ }
120
+
50
121
  /**
51
122
  * Validates the input cards array for correct format, length, and uniqueness.
52
123
  *
@@ -201,6 +272,7 @@ class HandEvaluator {
201
272
  this.#isStraightDraw();
202
273
  this.#isComboDraw();
203
274
  this.#isUnderPair();
275
+ this.#isOvercards();
204
276
  this.#isHighCard();
205
277
 
206
278
  // Add keyCards and kickerCards before restoring original evaluation
@@ -418,7 +490,7 @@ class HandEvaluator {
418
490
  const result = {};
419
491
  for (const combination of combinations) {
420
492
  const key = this.#formatHandKey(combination);
421
- const tempEvaluator = new HandEvaluator(combination, this.strict, this.originalCards);
493
+ const tempEvaluator = new HandEvaluator(combination, this.strict, this.holeCards, this.originalCards);
422
494
  const evaluationKey = Object.keys(tempEvaluator.evaluation)[0];
423
495
  result[key] = tempEvaluator.evaluation[evaluationKey];
424
496
  }
@@ -882,7 +954,8 @@ class HandEvaluator {
882
954
 
883
955
  /**
884
956
  * Determines if the hand has four cards of the same suit (one card away from a flush).
885
- * Returns false if the hand is already a flush.
957
+ * Returns false if the hand is already a flush. When hole cards are supplied, at least one
958
+ * of the four suited cards must be a hole card.
886
959
  *
887
960
  * @returns {boolean} True if the hand is a flush draw, false otherwise
888
961
  * @example
@@ -894,12 +967,13 @@ class HandEvaluator {
894
967
  if (this.evaluation.isFlush === undefined) this.#isFlush();
895
968
  if (this.evaluation.isFlush) return this.evaluation.isFlushDraw = false;
896
969
  const suits = this.#countSuits();
897
- return this.evaluation.isFlushDraw = Object.values(suits).some(count => count === 4);
970
+ return this.evaluation.isFlushDraw = Object.keys(suits).some(s => suits[s] === 4 && this.#usesHoleCard(this.#getCardsOfSuit(s)));
898
971
  }
899
972
 
900
973
  /**
901
974
  * Determines if the hand has three cards of the same suit (two cards away from a flush).
902
- * Returns false if the hand is already a flush or flush draw.
975
+ * Returns false if the hand is already a flush or flush draw. When hole cards are supplied,
976
+ * at least one of the three suited cards must be a hole card.
903
977
  *
904
978
  * @returns {boolean} True if the hand is a backdoor flush draw, false otherwise
905
979
  * @example
@@ -912,7 +986,7 @@ class HandEvaluator {
912
986
  if (this.evaluation.isFlushDraw === undefined) this.#isFlushDraw();
913
987
  if (this.evaluation.isFlush || this.evaluation.isFlushDraw) return this.evaluation.isBackdoorFlushDraw = false;
914
988
  const suits = this.#countSuits();
915
- return this.evaluation.isBackdoorFlushDraw = Object.values(suits).some(count => count === 3);
989
+ return this.evaluation.isBackdoorFlushDraw = Object.keys(suits).some(s => suits[s] === 3 && this.#usesHoleCard(this.#getCardsOfSuit(s)));
916
990
  }
917
991
 
918
992
  // ==================== STRAIGHT EVALUATION ====================
@@ -1008,6 +1082,7 @@ class HandEvaluator {
1008
1082
  /**
1009
1083
  * Determines if the hand is an open-ended straight draw (one card away from a straight on either end).
1010
1084
  * 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.
1011
1086
  * Returns false if the hand already contains pairs, two pair, three of a kind, full house, four of a kind, or a straight.
1012
1087
  *
1013
1088
  * @returns {boolean} True if the hand is an open-ended straight draw, false otherwise
@@ -1028,8 +1103,7 @@ class HandEvaluator {
1028
1103
  if (this.#isOpenEndedStraightDrawWheel()) return this.evaluation.isOpenEndedStraightDraw = true;
1029
1104
  return this.evaluation.isOpenEndedStraightDraw = false;
1030
1105
  }
1031
- this.#sort();
1032
- const result = this.#getDistance(this.cards.slice(0,4)) === 3 || this.#getDistance(this.cards.slice(1,5)) === 3;
1106
+ const result = this.#findStraightDrawWindow([3], false) !== null;
1033
1107
  if (result) return this.evaluation.isOpenEndedStraightDraw = true;
1034
1108
  // Check wheel equivalent
1035
1109
  if (this.#isOpenEndedStraightDrawWheel()) return this.evaluation.isOpenEndedStraightDraw = true;
@@ -1052,13 +1126,13 @@ class HandEvaluator {
1052
1126
  if (this.evaluation.isFourOfAKind === undefined) this.#isFourOfAKind();
1053
1127
  if (this.evaluation.isStraightWheel === undefined) this.#isStraightWheel();
1054
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;
1055
- this.#sortWheel();
1056
- return this.evaluation.isOpenEndedStraightDrawWheel = this.#getDistanceWheel(this.cards.slice(0,4)) === 3 || this.#getDistanceWheel(this.cards.slice(1,5)) === 3;
1129
+ return this.evaluation.isOpenEndedStraightDrawWheel = this.#findStraightDrawWindow([3], true) !== null;
1057
1130
  }
1058
1131
 
1059
1132
  /**
1060
1133
  * Determines if the hand is an inside straight draw (one card away from a straight, but the needed card is in the middle).
1061
1134
  * 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.
1062
1136
  * Returns false if the hand already contains pairs, two pair, three of a kind, full house, four of a kind, or a straight.
1063
1137
  *
1064
1138
  * @returns {boolean} True if the hand is an inside straight draw, false otherwise
@@ -1080,8 +1154,7 @@ class HandEvaluator {
1080
1154
  return this.evaluation.isInsideStraightDraw = false;
1081
1155
  }
1082
1156
 
1083
- this.#sort();
1084
- const result = this.#getDistance(this.cards.slice(0,4)) === 4 || this.#getDistance(this.cards.slice(1,5)) === 4;
1157
+ const result = this.#findStraightDrawWindow([4], false) !== null;
1085
1158
  if (result) return this.evaluation.isInsideStraightDraw = true;
1086
1159
  // Check wheel equivalent
1087
1160
  if (this.#isInsideStraightDrawWheel()) return this.evaluation.isInsideStraightDraw = true;
@@ -1105,8 +1178,7 @@ class HandEvaluator {
1105
1178
  if (this.evaluation.isStraightWheel === undefined) this.#isStraightWheel();
1106
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;
1107
1180
 
1108
- this.#sortWheel();
1109
- return this.evaluation.isInsideStraightDrawWheel = this.#getDistanceWheel(this.cards.slice(0,4)) === 4 || this.#getDistanceWheel(this.cards.slice(1,5)) === 4;
1181
+ return this.evaluation.isInsideStraightDrawWheel = this.#findStraightDrawWindow([4], true) !== null;
1110
1182
  }
1111
1183
 
1112
1184
  /**
@@ -1382,8 +1454,7 @@ class HandEvaluator {
1382
1454
  if (!this.evaluation.isFlushDraw) return [];
1383
1455
  const suits = this.#countSuits();
1384
1456
  const flushSuit = Object.keys(suits).find(s => suits[s] === 4);
1385
- return this.cards.filter(c => c.slice(-1) === flushSuit)
1386
- .sort((a, b) => this.#getRankValue(b) - this.#getRankValue(a));
1457
+ return this.#getCardsOfSuit(flushSuit);
1387
1458
  }
1388
1459
 
1389
1460
  /**
@@ -1396,8 +1467,7 @@ class HandEvaluator {
1396
1467
  if (!this.evaluation.isBackdoorFlushDraw) return [];
1397
1468
  const suits = this.#countSuits();
1398
1469
  const flushSuit = Object.keys(suits).find(s => suits[s] === 3);
1399
- return this.cards.filter(c => c.slice(-1) === flushSuit)
1400
- .sort((a, b) => this.#getRankValue(b) - this.#getRankValue(a));
1470
+ return this.#getCardsOfSuit(flushSuit);
1401
1471
  }
1402
1472
 
1403
1473
  /**
@@ -1408,7 +1478,7 @@ class HandEvaluator {
1408
1478
  */
1409
1479
  #getKeyCardsForOpenEndedStraightDraw() {
1410
1480
  if (!this.evaluation.isOpenEndedStraightDraw) return [];
1411
- return this.#getStraightDrawCards();
1481
+ return this.#getStraightDrawCards([3]);
1412
1482
  }
1413
1483
 
1414
1484
  /**
@@ -1419,7 +1489,7 @@ class HandEvaluator {
1419
1489
  */
1420
1490
  #getKeyCardsForInsideStraightDraw() {
1421
1491
  if (!this.evaluation.isInsideStraightDraw) return [];
1422
- return this.#getStraightDrawCards();
1492
+ return this.#getStraightDrawCards([4]);
1423
1493
  }
1424
1494
 
1425
1495
  /**
@@ -1460,6 +1530,32 @@ class HandEvaluator {
1460
1530
  return this.#getKeyCardsForPair();
1461
1531
  }
1462
1532
 
1533
+ /**
1534
+ * Gets key cards for the general overcard property (the hole cards that beat the whole board).
1535
+ *
1536
+ * @private
1537
+ * @returns {string[]} Array of 1 or 2 overcards if isOvercard, empty array otherwise
1538
+ */
1539
+ #getKeyCardsForOvercard() {
1540
+ if (!this.evaluation.isOvercard) return [];
1541
+ const classifications = this.#classifyHoleCards();
1542
+ return this.holeCards
1543
+ .filter((card, i) => classifications[i] === 'over')
1544
+ .sort((a, b) => this.#getRankValue(b) - this.#getRankValue(a));
1545
+ }
1546
+
1547
+ /**
1548
+ * Gets key cards for a two-card hole card categorization (both hole cards, sorted high to low).
1549
+ *
1550
+ * @private
1551
+ * @param {string} category - The evaluation property name (e.g. 'isTwoOvercards')
1552
+ * @returns {string[]} Array of both hole cards if the category is true, empty array otherwise
1553
+ */
1554
+ #getKeyCardsForHoleCardCategory(category) {
1555
+ if (!this.evaluation[category]) return [];
1556
+ return [...this.holeCards].sort((a, b) => this.#getRankValue(b) - this.#getRankValue(a));
1557
+ }
1558
+
1463
1559
  /**
1464
1560
  * Gets key cards for wheel straight (5 cards forming wheel).
1465
1561
  *
@@ -1479,7 +1575,7 @@ class HandEvaluator {
1479
1575
  */
1480
1576
  #getKeyCardsForOpenEndedStraightDrawWheel() {
1481
1577
  if (!this.evaluation.isOpenEndedStraightDrawWheel) return [];
1482
- return this.#getStraightDrawCards();
1578
+ return this.#getStraightDrawCards([3]);
1483
1579
  }
1484
1580
 
1485
1581
  /**
@@ -1490,7 +1586,7 @@ class HandEvaluator {
1490
1586
  */
1491
1587
  #getKeyCardsForInsideStraightDrawWheel() {
1492
1588
  if (!this.evaluation.isInsideStraightDrawWheel) return [];
1493
- return this.#getStraightDrawCards();
1589
+ return this.#getStraightDrawCards([4]);
1494
1590
  }
1495
1591
 
1496
1592
  /**
@@ -1539,6 +1635,13 @@ class HandEvaluator {
1539
1635
  isInsideStraightDrawWheel: this.#getKeyCardsForInsideStraightDrawWheel(),
1540
1636
  isComboDraw: this.#getKeyCardsForComboDraw(),
1541
1637
  isUnderPair: this.#getKeyCardsForUnderPair(),
1638
+ isOvercard: this.#getKeyCardsForOvercard(),
1639
+ isTwoOvercards: this.#getKeyCardsForHoleCardCategory('isTwoOvercards'),
1640
+ isOvercardAndMiddleCard: this.#getKeyCardsForHoleCardCategory('isOvercardAndMiddleCard'),
1641
+ isOvercardAndUndercard: this.#getKeyCardsForHoleCardCategory('isOvercardAndUndercard'),
1642
+ isTwoMiddleCards: this.#getKeyCardsForHoleCardCategory('isTwoMiddleCards'),
1643
+ isMiddleAndUndercard: this.#getKeyCardsForHoleCardCategory('isMiddleAndUndercard'),
1644
+ isTwoUndercards: this.#getKeyCardsForHoleCardCategory('isTwoUndercards'),
1542
1645
  isHighCard: this.#getKeyCardsForHighCard()
1543
1646
  };
1544
1647
  }
@@ -1559,6 +1662,14 @@ class HandEvaluator {
1559
1662
  result[handType] = sortedCards;
1560
1663
  } else if (keyCards.length === 0) {
1561
1664
  result[handType] = [];
1665
+ } else if (HOLE_CARD_CATEGORIES.has(handType)) {
1666
+ // Hole card categorizations are evaluated against the board, which may include cards
1667
+ // outside the current 5-card combination, so kickers come from the full hand.
1668
+ const keyCardsSet = new Set(keyCards);
1669
+ const fullHand = this.originalCards || this.cards;
1670
+ result[handType] = fullHand
1671
+ .filter(c => !keyCardsSet.has(c))
1672
+ .sort((a, b) => this.#getRankValue(b) - this.#getRankValue(a));
1562
1673
  } else {
1563
1674
  const keyCardsSet = new Set(keyCards);
1564
1675
  result[handType] = this.cards
@@ -1572,41 +1683,64 @@ class HandEvaluator {
1572
1683
 
1573
1684
  /**
1574
1685
  * Helper method to extract the 4 cards that form a straight draw.
1575
- * Handles both standard and wheel straight draws.
1686
+ * Handles both standard and wheel straight draws, preferring a standard draw.
1687
+ * When hole cards are supplied, only draws that use at least one hole card are returned.
1576
1688
  *
1577
1689
  * @private
1690
+ * @param {number[]} [distances=[3, 4]] - Accepted rank spans for the 4 drawing cards
1691
+ * (3 = open-ended, 4 = inside)
1578
1692
  * @returns {string[]} Array of 4 cards forming the draw, sorted from highest to lowest
1579
1693
  */
1580
- #getStraightDrawCards() {
1581
- // Try standard straight draw
1582
- this.#sort();
1583
- const firstFour = this.cards.slice(0, 4);
1584
- const lastFour = this.cards.slice(1, 5);
1585
-
1586
- const firstFourDist = this.#getDistance(firstFour);
1587
- if (firstFourDist === 3 || firstFourDist === 4) {
1588
- return [...firstFour].sort((a, b) => this.#getRankValue(b) - this.#getRankValue(a));
1589
- }
1590
- const lastFourDist = this.#getDistance(lastFour);
1591
- if (lastFourDist === 3 || lastFourDist === 4) {
1592
- return [...lastFour].sort((a, b) => this.#getRankValue(b) - this.#getRankValue(a));
1593
- }
1594
-
1595
- // Try wheel straight draw
1596
- this.#sortWheel();
1597
- const firstFourWheel = this.cards.slice(0, 4);
1598
- const lastFourWheel = this.cards.slice(1, 5);
1599
-
1600
- const firstFourDistWheel = this.#getDistanceWheel(firstFourWheel);
1601
- if (firstFourDistWheel === 3 || firstFourDistWheel === 4) {
1602
- return [...firstFourWheel].sort((a, b) => this.#getRankValueWheel(b) - this.#getRankValueWheel(a));
1603
- }
1604
- const lastFourDistWheel = this.#getDistanceWheel(lastFourWheel);
1605
- if (lastFourDistWheel === 3 || lastFourDistWheel === 4) {
1606
- return [...lastFourWheel].sort((a, b) => this.#getRankValueWheel(b) - this.#getRankValueWheel(a));
1694
+ #getStraightDrawCards(distances = [3, 4]) {
1695
+ return this.#findStraightDrawWindow(distances, false) || this.#findStraightDrawWindow(distances, true) || [];
1696
+ }
1697
+
1698
+ /**
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.
1702
+ *
1703
+ * @private
1704
+ * @param {number[]} distances - Accepted rank spans for the 4 cards (3 = open-ended, 4 = inside)
1705
+ * @param {boolean} wheel - If true, ranks the Ace low (for wheel draws)
1706
+ * @returns {string[]|null} Array of 4 cards sorted from highest to lowest, or null if none match
1707
+ */
1708
+ #findStraightDrawWindow(distances, wheel) {
1709
+ const rankValue = wheel ? c => this.#getRankValueWheel(c) : c => this.#getRankValue(c);
1710
+ 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));
1715
+ }
1607
1716
  }
1608
-
1609
- return [];
1717
+ return null;
1718
+ }
1719
+
1720
+ /**
1721
+ * Gets the cards of a given suit in the hand, sorted from highest to lowest rank.
1722
+ *
1723
+ * @private
1724
+ * @param {string} suit - Suit character (s, h, d, c)
1725
+ * @returns {string[]} Cards of that suit
1726
+ */
1727
+ #getCardsOfSuit(suit) {
1728
+ return this.cards.filter(c => c.slice(-1) === suit)
1729
+ .sort((a, b) => this.#getRankValue(b) - this.#getRankValue(a));
1730
+ }
1731
+
1732
+ /**
1733
+ * Determines whether a draw's cards include at least one of the player's hole cards.
1734
+ * A draw made entirely of board cards is shared by every player, so when hole cards are
1735
+ * supplied it does not count as the player's draw. Without hole cards every draw qualifies.
1736
+ *
1737
+ * @private
1738
+ * @param {string[]} cards - The cards forming the draw
1739
+ * @returns {boolean} True if no hole cards were supplied or a hole card is among the cards
1740
+ */
1741
+ #usesHoleCard(cards) {
1742
+ if (!this.holeCards) return true;
1743
+ return cards.some(c => this.holeCards.includes(c));
1610
1744
  }
1611
1745
 
1612
1746
  // ==================== HIGH CARD ====================
@@ -1667,6 +1801,95 @@ class HandEvaluator {
1667
1801
  return null;
1668
1802
  }
1669
1803
 
1804
+ // ==================== OVERCARDS ====================
1805
+
1806
+ /**
1807
+ * Classifies each hole card relative to the board (every card in the full hand that is not a
1808
+ * hole card). A card is an "overcard" when its rank beats every board card, an "undercard" when
1809
+ * every board card beats it, and a "middle card" when it falls strictly between the highest and
1810
+ * lowest board ranks. A hole card whose rank matches any board rank pairs the board and is
1811
+ * classified as "paired", which belongs to none of the three categories.
1812
+ *
1813
+ * @private
1814
+ * @returns {string[]|null} Array of 2 classifications ('over', 'middle', 'under' or 'paired')
1815
+ * parallel to this.holeCards, or null if hole cards were not supplied
1816
+ * @example
1817
+ * // Board: K Q 8 3, hole cards: A J
1818
+ * this.#classifyHoleCards(); // Returns ['over', 'middle']
1819
+ */
1820
+ #classifyHoleCards() {
1821
+ if (!this.holeCards || this.holeCards.length !== 2) return null;
1822
+
1823
+ const fullHand = this.originalCards || this.cards;
1824
+ const holeCardSet = new Set(this.holeCards);
1825
+ const board = fullHand.filter(c => !holeCardSet.has(c));
1826
+ if (board.length < 3) return null;
1827
+
1828
+ const boardRanks = new Set(board.map(c => c.slice(0, -1)));
1829
+ const boardValues = board.map(c => this.#getRankValue(c));
1830
+ const highestBoard = Math.max(...boardValues);
1831
+ const lowestBoard = Math.min(...boardValues);
1832
+
1833
+ return this.holeCards.map(card => {
1834
+ if (boardRanks.has(card.slice(0, -1))) return 'paired';
1835
+ const value = this.#getRankValue(card);
1836
+ if (value > highestBoard) return 'over';
1837
+ if (value < lowestBoard) return 'under';
1838
+ return 'middle';
1839
+ });
1840
+ }
1841
+
1842
+ /**
1843
+ * Counts how many hole cards fall into each classification.
1844
+ *
1845
+ * @private
1846
+ * @returns {Object} Object with 'over', 'middle', 'under' and 'paired' counts
1847
+ */
1848
+ #getHoleCardCounts() {
1849
+ const counts = { over: 0, middle: 0, under: 0, paired: 0 };
1850
+ const classifications = this.#classifyHoleCards();
1851
+ if (classifications) {
1852
+ for (const classification of classifications) counts[classification]++;
1853
+ }
1854
+ return counts;
1855
+ }
1856
+
1857
+ /**
1858
+ * Determines how the player's hole cards rank against the board and sets every overcard
1859
+ * categorization property. Requires hole cards to have been supplied; without them all
1860
+ * overcard properties are false.
1861
+ *
1862
+ * These categorizations describe the hole cards themselves, not the made hand, so they are
1863
+ * identical across every 5-card combination of a hand and are not filtered by strict mode.
1864
+ *
1865
+ * Sets: isOvercard, isTwoOvercards, isOvercardAndMiddleCard, isOvercardAndUndercard,
1866
+ * isTwoMiddleCards, isMiddleAndUndercard, isTwoUndercards.
1867
+ *
1868
+ * @returns {boolean} True if at least one hole card is an overcard, false otherwise
1869
+ * @example
1870
+ * // Board: K Q 8 3, Hand: AJ
1871
+ * const evaluator = new HandEvaluator(['Ks', 'Qh', '8d', '3c', 'Ad', 'Jh'], true, ['Ad', 'Jh']);
1872
+ * // isOvercard: true, isOvercardAndMiddleCard: true
1873
+ * @example
1874
+ * // Board: 9 7 4 2, Hand: AK (both hole cards beat every board card)
1875
+ * const twoOvercards = new HandEvaluator(['9s', '7h', '4d', '2c', 'Ad', 'Kh'], true, ['Ad', 'Kh']);
1876
+ * // isTwoOvercards: true
1877
+ */
1878
+ #isOvercards() {
1879
+ if (this.evaluation.isOvercard !== undefined) return this.evaluation.isOvercard;
1880
+
1881
+ const counts = this.#getHoleCardCounts();
1882
+
1883
+ this.evaluation.isTwoOvercards = counts.over === 2;
1884
+ this.evaluation.isOvercardAndMiddleCard = counts.over === 1 && counts.middle === 1;
1885
+ this.evaluation.isOvercardAndUndercard = counts.over === 1 && counts.under === 1;
1886
+ this.evaluation.isTwoMiddleCards = counts.middle === 2;
1887
+ this.evaluation.isMiddleAndUndercard = counts.middle === 1 && counts.under === 1;
1888
+ this.evaluation.isTwoUndercards = counts.under === 2;
1889
+
1890
+ return this.evaluation.isOvercard = counts.over > 0;
1891
+ }
1892
+
1670
1893
  /**
1671
1894
  * Determines if the hand is high card only (no pairs, two pair, three of a kind, full house,
1672
1895
  * four of a kind, flush, or straight).
@@ -1696,10 +1919,14 @@ class HandEvaluator {
1696
1919
  * @param {string[]} cards - Array of 5 or more card strings in format "Rs" where R is rank (A, K, Q, J, T, 9, 8, 7, 6, 5, 4, 3, 2) and s is suit (s, h, d, c)
1697
1920
  * @param {boolean} [strict=true] - If true, only the strongest made hand and stronger draws are marked as true.
1698
1921
  * If false, all applicable hand types are marked.
1922
+ * @param {string[]|null} [holeCards=null] - Optional array of exactly 2 cards identifying the player's hole cards.
1923
+ * Required for the overcard categorizations (isOvercard, isTwoOvercards, isOvercardAndMiddleCard,
1924
+ * isOvercardAndUndercard, isTwoMiddleCards, isMiddleAndUndercard, isTwoUndercards); without it those
1925
+ * properties are always false. Both cards must be present in `cards`.
1699
1926
  * @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
1700
1927
  */
1701
- function evaluateHand(cards, strict = true) {
1702
- const evaluator = new HandEvaluator(cards, strict);
1928
+ function evaluateHand(cards, strict = true, holeCards = null) {
1929
+ const evaluator = new HandEvaluator(cards, strict, holeCards);
1703
1930
  return evaluator.evaluation;
1704
1931
  }
1705
1932
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poker-extval",
3
- "version": "3.2.1",
3
+ "version": "3.3.1",
4
4
  "description": "Poker hand evaluator with comprehensive test suite",
5
5
  "main": "evaluator.js",
6
6
  "files": [