Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
133 lines
4.6 KiB
TypeScript
133 lines
4.6 KiB
TypeScript
/**
|
|
* 5-to-7 card hand evaluation for No-Limit Texas Hold'em.
|
|
*
|
|
* Pure and suit-independent: two hands with identical ranks tie regardless of
|
|
* suits (flushes included, since a flush's tiebreakers are ranks only).
|
|
*/
|
|
|
|
import type { Card, HandRank } from "./types";
|
|
|
|
export const HAND_CATEGORIES = {
|
|
HIGH_CARD: 0,
|
|
PAIR: 1,
|
|
TWO_PAIR: 2,
|
|
TRIPS: 3,
|
|
STRAIGHT: 4,
|
|
FLUSH: 5,
|
|
FULL_HOUSE: 6,
|
|
QUADS: 7,
|
|
STRAIGHT_FLUSH: 8,
|
|
} as const;
|
|
|
|
export const HAND_CATEGORY_NAMES: readonly string[] = [
|
|
"high card",
|
|
"pair",
|
|
"two pair",
|
|
"three of a kind",
|
|
"straight",
|
|
"flush",
|
|
"full house",
|
|
"four of a kind",
|
|
"straight flush",
|
|
];
|
|
|
|
export interface PokerHandEvaluation {
|
|
readonly rank: HandRank;
|
|
/** The exact 5 cards that produced the rank (best 5 of the input). */
|
|
readonly best: readonly Card[];
|
|
}
|
|
|
|
/** Positive when `a` beats `b`, negative when `b` beats `a`, 0 on a tie. */
|
|
export function compareHandRanks(a: HandRank, b: HandRank): number {
|
|
if (a.category !== b.category) return a.category - b.category;
|
|
const length = Math.max(a.tiebreakers.length, b.tiebreakers.length);
|
|
for (let i = 0; i < length; i += 1) {
|
|
const av = a.tiebreakers[i] ?? 0;
|
|
const bv = b.tiebreakers[i] ?? 0;
|
|
if (av !== bv) return av - bv;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Score exactly 5 cards into a HandRank. Internal helper; assumes the input
|
|
* length (the public entry point validates it).
|
|
*/
|
|
function score5(cards: readonly Card[]): HandRank {
|
|
const ranks = cards.map((card) => card.rank).sort((x, y) => y - x);
|
|
const isFlush = cards.every((card) => card.suit === cards[0].suit);
|
|
|
|
const counts = new Map<number, number>();
|
|
for (const rank of ranks) counts.set(rank, (counts.get(rank) ?? 0) + 1);
|
|
// Rank groups sorted by count descending, then rank descending, so
|
|
// groups[0] is always the most valuable matched set.
|
|
const groups = Array.from(counts.entries()).sort((x, y) => y[1] - x[1] || y[0] - x[0]);
|
|
const distinct = Array.from(counts.keys()).sort((x, y) => y - x);
|
|
|
|
let straightHigh: number | null = null;
|
|
if (distinct.length === 5) {
|
|
const top = distinct[0];
|
|
const second = distinct[1];
|
|
const bottom = distinct[4];
|
|
if (top !== undefined && second !== undefined && bottom !== undefined) {
|
|
if (top - bottom === 4) {
|
|
straightHigh = top;
|
|
} else if (top === 14 && second === 5) {
|
|
// Wheel: A-2-3-4-5 plays as a five-high straight.
|
|
straightHigh = 5;
|
|
}
|
|
}
|
|
}
|
|
|
|
const first = groups[0];
|
|
if (!first) throw new Error("score5 requires exactly five cards.");
|
|
const second = groups[1];
|
|
const firstRank = first[0];
|
|
const firstCount = first[1];
|
|
const secondRank = second?.[0] ?? 0;
|
|
const secondCount = second?.[1] ?? 0;
|
|
const groupRanksFrom = (skip: number): number[] => groups.slice(skip).map((group) => group[0]);
|
|
|
|
if (straightHigh !== null && isFlush) return { category: 8, tiebreakers: [straightHigh] };
|
|
if (firstCount === 4) return { category: 7, tiebreakers: [firstRank, secondRank] };
|
|
if (firstCount === 3 && secondCount === 2) {
|
|
return { category: 6, tiebreakers: [firstRank, secondRank] };
|
|
}
|
|
if (isFlush) return { category: 5, tiebreakers: ranks };
|
|
if (straightHigh !== null) return { category: 4, tiebreakers: [straightHigh] };
|
|
if (firstCount === 3) return { category: 3, tiebreakers: [firstRank, ...groupRanksFrom(1)] };
|
|
if (firstCount === 2 && secondCount === 2) {
|
|
return { category: 2, tiebreakers: [firstRank, secondRank, ...groupRanksFrom(2)] };
|
|
}
|
|
if (firstCount === 2) return { category: 1, tiebreakers: [firstRank, ...groupRanksFrom(1)] };
|
|
return { category: 0, tiebreakers: ranks };
|
|
}
|
|
|
|
/**
|
|
* Evaluate the best 5-card hand out of 5, 6, or 7 cards by scoring every
|
|
* 5-card combination (at most C(7,5) = 21) and keeping the maximum.
|
|
*/
|
|
export function evaluateHand(cards: readonly Card[]): PokerHandEvaluation {
|
|
if (cards.length < 5 || cards.length > 7) {
|
|
throw new Error(`evaluateHand expects 5 to 7 cards, received ${cards.length}.`);
|
|
}
|
|
let best: PokerHandEvaluation | null = null;
|
|
const n = cards.length;
|
|
for (let a = 0; a < n - 4; a += 1) {
|
|
for (let b = a + 1; b < n - 3; b += 1) {
|
|
for (let c = b + 1; c < n - 2; c += 1) {
|
|
for (let d = c + 1; d < n - 1; d += 1) {
|
|
for (let e = d + 1; e < n; e += 1) {
|
|
const hand = [cards[a], cards[b], cards[c], cards[d], cards[e]];
|
|
const rank = score5(hand);
|
|
if (!best || compareHandRanks(rank, best.rank) > 0) {
|
|
best = { rank, best: hand };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (!best) throw new Error("evaluateHand failed to score any 5-card combination.");
|
|
return best;
|
|
}
|