/** * 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(); 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; }