1
0
Fork 0
polaris-task-force/src/lib/minigames/poker/evaluator.ts
Z8MB1E 6968ee0abc feat(poker): add poker engine, evaluation and stats libs with tests
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 03:25:43 -04:00

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;
}