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.
- package/README.md +32 -2
- package/evaluator.js +264 -8
- 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
|
|
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
|
-
|
|
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
|
|