1
0
Fork 0
polaris-task-force/tests/int/poker-engine.int.spec.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

764 lines
29 KiB
TypeScript

/**
* Pure engine tests for the No-Limit Texas Hold'em rules engine.
*
* These are intentionally database-free: no Payload, no Next.js imports, no
* setup or teardown. Everything runs against the pure modules under
* src/lib/minigames/poker with injected, deterministic randomness.
*/
import { describe, expect, it } from "vitest";
import {
BIG_BLIND,
SMALL_BLIND,
STARTING_STACK,
TURN_TIMEOUT_MS,
applyAction,
advanceState,
buildPrivateView,
buildPublicView,
compareHandRanks,
createDeck,
createShuffledDeck,
createTableState,
evaluateHand,
getLegalActions,
mulberry32,
resolveShowdown,
type Card,
type PokerActionResult,
type PokerSeatId,
type PokerTableState,
type Rank,
type Suit,
} from "@/lib/minigames/poker";
const c = (rank: Rank, suit: Suit): Card => ({ rank, suit });
function makeTable(
names: string[],
stacks?: number[],
seed = 1,
): PokerTableState {
const players = names.map((name, index) => ({
name,
stack: stacks?.[index],
}));
return createTableState(players, {}, mulberry32(seed));
}
function act(
state: PokerTableState,
seatId: PokerSeatId,
action: Parameters<typeof applyAction>[2],
amount?: number,
): PokerTableState {
const result = applyAction(state, seatId, action, amount);
if (!result.success) throw new Error(`Unexpected action failure: ${result.error}`);
return result.state;
}
function expectFailure(
state: PokerTableState,
seatId: PokerSeatId,
action: Parameters<typeof applyAction>[2],
amount?: number,
messagePart?: string,
): void {
const result = applyAction(state, seatId, action, amount);
expect(result.success).toBe(false);
if (!result.success && messagePart) {
expect(result.error).toContain(messagePart);
}
}
/** Minimal hand-built river state for direct showdown resolution tests. */
function showdownState(
players: ReadonlyArray<{ hole: Card[]; committed: number; folded?: boolean }>,
community: Card[],
buttonSeat = 0,
): PokerTableState {
const totalCommitted = players.reduce((sum, p) => sum + p.committed, 0);
return {
config: { startingStack: 1000, smallBlind: 10, bigBlind: 20, turnTimeoutMs: TURN_TIMEOUT_MS },
seed: 0,
handNumber: 1,
players: players.map((p, index) => ({
seatId: index,
name: `P${index}`,
stack: 1000 - p.committed,
committed: 0,
totalCommitted: p.committed,
holeCards: p.hole,
folded: p.folded ?? false,
allIn: false,
hasActed: true,
eliminated: false,
})),
buttonSeat,
smallBlindSeat: 1,
bigBlindSeat: 2,
street: "river",
phase: "betting",
collectedPot: totalCommitted,
communityCards: community,
deck: [],
currentBet: 0,
minRaise: 20,
lastFullRaiseBet: 0,
toActSeat: null,
showdown: null,
finalPlacements: null,
};
}
/**
* Deterministic driver: check when free, raise to 100 (5 BB) when raising is
* legal, call moderate bets, fold to oversized ones. Chips move fast enough
* to finish the game while still spanning many hands.
*/
function driveGame(seed: number, playerCount: number): PokerTableState {
const totalChips = playerCount * STARTING_STACK;
let state = createTableState(
Array.from({ length: playerCount }, (_, index) => ({ name: `Player${index + 1}` })),
{},
mulberry32(seed),
);
let steps = 0;
while (state.phase !== "game-complete") {
steps += 1;
if (steps > 50000) throw new Error("Game did not terminate within the step budget.");
if (state.phase === "betting" && state.toActSeat !== null) {
const seat = state.toActSeat;
const legal = getLegalActions(state, seat);
let result: PokerActionResult;
if (legal.canCheck) {
result = applyAction(state, seat, "check");
} else if (legal.canRaise && state.currentBet < 60) {
const target = Math.min(Math.max(legal.minRaiseTo, 100), legal.maxRaiseTo);
result = applyAction(state, seat, "raise", target);
} else if (legal.canCall && legal.callAmount <= 300) {
result = applyAction(state, seat, "call");
} else {
result = applyAction(state, seat, "fold");
}
if (!result.success) throw new Error(`Driver action failed: ${result.error}`);
state = result.state;
} else {
state = advanceState(state);
}
if (state.phase === "showdown" || state.phase === "hand-complete") {
const stackSum = state.players.reduce((sum, player) => sum + player.stack, 0);
expect(stackSum).toBe(totalChips);
}
}
return state;
}
describe("deck", () => {
it("creates a 52-card deck with no duplicates", () => {
const deck = createDeck();
expect(deck).toHaveLength(52);
const keys = new Set(deck.map((card) => `${card.rank}${card.suit}`));
expect(keys.size).toBe(52);
});
it("shuffles deterministically for a given seed and hand number", () => {
const a = createShuffledDeck(1234, 1);
const b = createShuffledDeck(1234, 1);
const otherHand = createShuffledDeck(1234, 2);
const otherSeed = createShuffledDeck(1235, 1);
expect(a).toEqual(b);
expect(a).not.toEqual(otherHand);
expect(a).not.toEqual(otherSeed);
expect(a).toHaveLength(52);
expect(new Set(a.map((card) => `${card.rank}${card.suit}`)).size).toBe(52);
});
});
describe("hand evaluator", () => {
it("recognizes a royal flush as an ace-high straight flush", () => {
const evaluation = evaluateHand([
c(14, "s"), c(13, "s"), c(12, "s"), c(11, "s"), c(10, "s"),
]);
expect(evaluation.rank.category).toBe(8);
expect(evaluation.rank.tiebreakers).toEqual([14]);
});
it("recognizes a wheel straight flush as five high", () => {
const evaluation = evaluateHand([c(14, "h"), c(2, "h"), c(3, "h"), c(4, "h"), c(5, "h")]);
expect(evaluation.rank.category).toBe(8);
expect(evaluation.rank.tiebreakers).toEqual([5]);
});
it("finds the best 5 of 7 for a straight flush over a flush and a pair", () => {
const evaluation = evaluateHand([
c(9, "h"), c(10, "h"), c(11, "h"), c(12, "h"), c(13, "h"), c(14, "h"), c(2, "c"),
]);
expect(evaluation.rank.category).toBe(8);
expect(evaluation.rank.tiebreakers).toEqual([14]);
expect(evaluation.best).toHaveLength(5);
});
it("scores quads with the best kicker from seven cards", () => {
const evaluation = evaluateHand([
c(9, "c"), c(9, "d"), c(9, "h"), c(9, "s"), c(14, "c"), c(2, "d"), c(3, "c"),
]);
expect(evaluation.rank.category).toBe(7);
expect(evaluation.rank.tiebreakers).toEqual([9, 14]);
});
it("scores a full house choosing the highest trips and pair", () => {
const evaluation = evaluateHand([
c(9, "c"), c(9, "d"), c(9, "h"), c(14, "c"), c(14, "d"), c(5, "d"), c(5, "c"),
]);
expect(evaluation.rank.category).toBe(6);
expect(evaluation.rank.tiebreakers).toEqual([9, 14]);
});
it("picks the best five cards for a flush from seven", () => {
const evaluation = evaluateHand([
c(14, "h"), c(9, "h"), c(5, "h"), c(2, "h"), c(11, "h"), c(3, "c"), c(3, "d"),
]);
expect(evaluation.rank.category).toBe(5);
expect(evaluation.rank.tiebreakers).toEqual([14, 11, 9, 5, 2]);
});
it("finds a straight in seven cards that also contain a pair", () => {
const evaluation = evaluateHand([
c(5, "c"), c(6, "d"), c(7, "h"), c(8, "s"), c(9, "c"), c(9, "d"), c(2, "c"),
]);
expect(evaluation.rank.category).toBe(4);
expect(evaluation.rank.tiebreakers).toEqual([9]);
});
it("scores the wheel as a five-high straight, not ace high", () => {
const evaluation = evaluateHand([
c(14, "c"), c(2, "d"), c(3, "h"), c(4, "s"), c(5, "c"), c(13, "h"), c(13, "d"),
]);
expect(evaluation.rank.category).toBe(4);
expect(evaluation.rank.tiebreakers).toEqual([5]);
});
it("breaks two pair ties with the fifth card kicker", () => {
const queenKicker = evaluateHand([
c(14, "h"), c(14, "d"), c(13, "c"), c(13, "d"), c(12, "h"), c(2, "c"), c(3, "d"),
]);
const jackKicker = evaluateHand([
c(14, "s"), c(14, "c"), c(13, "h"), c(13, "s"), c(11, "h"), c(2, "d"), c(3, "c"),
]);
expect(queenKicker.rank.category).toBe(2);
expect(compareHandRanks(queenKicker.rank, jackKicker.rank)).toBeGreaterThan(0);
});
it("breaks one pair ties through all three kickers in order", () => {
const queenKicker = evaluateHand([c(9, "h"), c(9, "d"), c(14, "c"), c(13, "c"), c(12, "c")]);
const jackKicker = evaluateHand([c(9, "s"), c(9, "c"), c(14, "d"), c(13, "d"), c(11, "d")]);
expect(compareHandRanks(queenKicker.rank, jackKicker.rank)).toBeGreaterThan(0);
});
it("ties identical ranks across suits, flushes included", () => {
const spades = evaluateHand([c(14, "s"), c(13, "s"), c(12, "s"), c(11, "s"), c(9, "s")]);
const hearts = evaluateHand([c(14, "h"), c(13, "h"), c(12, "h"), c(11, "h"), c(9, "h")]);
expect(compareHandRanks(spades.rank, hearts.rank)).toBe(0);
});
it("orders categories from high card to straight flush", () => {
const high = evaluateHand([c(14, "c"), c(13, "d"), c(11, "h"), c(9, "s"), c(4, "c")]);
const pair = evaluateHand([c(4, "h"), c(4, "d"), c(14, "c"), c(13, "d"), c(11, "h")]);
const twoPair = evaluateHand([c(14, "h"), c(14, "d"), c(13, "c"), c(13, "d"), c(11, "h")]);
const trips = evaluateHand([c(14, "h"), c(14, "d"), c(14, "c"), c(13, "d"), c(11, "h")]);
const straight = evaluateHand([c(14, "c"), c(13, "d"), c(12, "h"), c(11, "s"), c(10, "c")]);
const flush = evaluateHand([c(14, "c"), c(12, "c"), c(10, "c"), c(8, "c"), c(6, "c")]);
const fullHouse = evaluateHand([c(3, "h"), c(3, "d"), c(3, "c"), c(2, "d"), c(2, "h")]);
const quads = evaluateHand([c(3, "h"), c(3, "d"), c(3, "c"), c(3, "s"), c(2, "h")]);
const straightFlush = evaluateHand([c(9, "c"), c(8, "c"), c(7, "c"), c(6, "c"), c(5, "c")]);
const ordered = [high, pair, twoPair, trips, straight, flush, fullHouse, quads, straightFlush];
for (let i = 0; i < ordered.length - 1; i += 1) {
const lower = ordered[i];
const higher = ordered[i + 1];
expect(lower && higher && compareHandRanks(lower.rank, higher.rank)).toBeLessThan(0);
}
});
it("rejects inputs that are not five to seven cards", () => {
expect(() => evaluateHand([c(2, "s"), c(3, "s"), c(4, "s"), c(5, "s")])).toThrow();
});
});
describe("table creation and blinds", () => {
it("rejects tables outside the 2 to 4 player range", () => {
expect(() => createTableState([{ name: "Solo" }], {}, mulberry32(1))).toThrow();
expect(() =>
createTableState(
[{ name: "A" }, { name: "B" }, { name: "C" }, { name: "D" }, { name: "E" }],
{},
mulberry32(1),
),
).toThrow();
});
it("posts blinds correctly for a 4-handed table and sets UTG action", () => {
const state = makeTable(["A", "B", "C", "D"]);
expect(state.handNumber).toBe(1);
expect(state.buttonSeat).toBe(0);
expect(state.smallBlindSeat).toBe(1);
expect(state.bigBlindSeat).toBe(2);
expect(state.players[1].committed).toBe(SMALL_BLIND);
expect(state.players[2].committed).toBe(BIG_BLIND);
expect(state.players[1].stack).toBe(STARTING_STACK - SMALL_BLIND);
expect(state.players[2].stack).toBe(STARTING_STACK - BIG_BLIND);
expect(state.currentBet).toBe(BIG_BLIND);
expect(state.minRaise).toBe(BIG_BLIND);
expect(state.toActSeat).toBe(3);
expect(state.street).toBe("preflop");
expect(state.phase).toBe("betting");
expect(state.config.turnTimeoutMs).toBe(TURN_TIMEOUT_MS);
});
it("gives every player two hole cards and keeps the deck consistent", () => {
const state = makeTable(["A", "B", "C", "D"]);
for (const player of state.players) {
expect(player.holeCards).toHaveLength(2);
}
// 52 deck minus 8 dealt cards.
expect(state.deck).toHaveLength(44);
const allCards = [
...state.players.flatMap((player) => player.holeCards),
...state.deck,
];
expect(allCards).toHaveLength(52);
});
it("does not mutate the state passed to applyAction", () => {
const state = makeTable(["A", "B"]);
const snapshot = JSON.parse(JSON.stringify(state)) as PokerTableState;
act(state, 0, "call");
expect(JSON.parse(JSON.stringify(state))).toEqual(snapshot);
});
});
describe("heads-up button and blind quirks", () => {
it("has the button post the small blind and act first preflop", () => {
const state = makeTable(["Button", "Other"]);
expect(state.buttonSeat).toBe(0);
expect(state.smallBlindSeat).toBe(0);
expect(state.bigBlindSeat).toBe(1);
expect(state.players[0].committed).toBe(SMALL_BLIND);
expect(state.players[1].committed).toBe(BIG_BLIND);
expect(state.toActSeat).toBe(0);
});
it("has the non-button act first and the button act last postflop", () => {
let state = makeTable(["Button", "Other"]);
state = act(state, 0, "call");
expect(state.toActSeat).toBe(1);
state = act(state, 1, "check");
expect(state.street).toBe("flop");
expect(state.toActSeat).toBe(1);
state = act(state, 1, "check");
expect(state.toActSeat).toBe(0);
state = act(state, 0, "check");
expect(state.street).toBe("turn");
expect(state.toActSeat).toBe(1);
state = act(state, 1, "check");
state = act(state, 0, "check");
expect(state.street).toBe("river");
expect(state.toActSeat).toBe(1);
// River action resolves to a showdown with the full board.
state = act(state, 1, "bet", 50);
state = act(state, 0, "call");
expect(state.phase).toBe("showdown");
expect(state.communityCards).toHaveLength(5);
expect(state.showdown).not.toBeNull();
expect(state.showdown?.pots[0]?.amount).toBe(140);
});
});
describe("legal betting: min-raise and re-raise", () => {
it("enforces minimum bet and minimum raise sizes", () => {
let state = makeTable(["A", "B", "C"]);
const legal = getLegalActions(state, 0);
expect(legal.canRaise).toBe(true);
expect(legal.minRaiseTo).toBe(40);
expect(legal.maxRaiseTo).toBe(STARTING_STACK);
expectFailure(state, 0, "raise", 30, "minimum raise");
state = act(state, 0, "raise", 40);
expect(state.currentBet).toBe(40);
expect(state.minRaise).toBe(20);
expectFailure(state, 1, "raise", 50, "minimum raise");
state = act(state, 1, "raise", 60);
expect(state.currentBet).toBe(60);
expect(state.minRaise).toBe(20);
state = act(state, 2, "call");
state = act(state, 0, "call");
expect(state.street).toBe("flop");
expect(state.collectedPot).toBe(180);
expect(state.currentBet).toBe(0);
expect(state.minRaise).toBe(BIG_BLIND);
// Postflop: opening bet of 20, then a raise must go to at least 40.
const betLegal = getLegalActions(state, 1);
expect(betLegal.canBet).toBe(true);
expect(betLegal.minBetTo).toBe(20);
expectFailure(state, 1, "bet", 15, "minimum bet");
state = act(state, 1, "bet", 20);
expectFailure(state, 2, "raise", 35, "minimum raise");
state = act(state, 2, "raise", 40);
state = act(state, 0, "fold");
state = act(state, 1, "call");
expect(state.street).toBe("turn");
expect(state.toActSeat).toBe(1);
});
it("rejects illegal actions: out of turn, check facing a bet, bet over a bet, over-shove", () => {
let state = makeTable(["A", "B", "C"]);
expectFailure(state, 1, "call", undefined, "turn");
state = act(state, 0, "raise", 40);
expectFailure(state, 1, "check", undefined, "Cannot check");
expectFailure(state, 1, "bet", 80, "raise instead");
expectFailure(state, 1, "raise", 2000, "exceeds your stack");
expectFailure(state, 9, "fold", undefined, "turn");
});
it("rejects actions once the game is complete", () => {
const state = driveGame(42, 2);
expect(state.phase).toBe("game-complete");
expectFailure(state, 0, "check", undefined, "not accepting actions");
expect(advanceState(state)).toEqual(state);
});
});
describe("short all-in raises do not reopen betting", () => {
it("lets a short all-in stand but blocks re-raises from players who acted", () => {
let state = makeTable(["A", "B", "C"], [1000, 1000, 150]);
state = act(state, 0, "raise", 100);
expect(state.minRaise).toBe(80);
state = act(state, 1, "call");
// C has 130 behind plus 20 posted: all-in to 150 is a raise of only 50.
state = act(state, 2, "raise", 150);
expect(state.currentBet).toBe(150);
expect(state.minRaise).toBe(80);
expect(state.lastFullRaiseBet).toBe(100);
const legalA = getLegalActions(state, 0);
expect(legalA.canRaise).toBe(false);
expect(legalA.canCall).toBe(true);
expect(legalA.callAmount).toBe(50);
expectFailure(state, 0, "raise", 230, "Raising is not available");
state = act(state, 0, "call");
const legalB = getLegalActions(state, 1);
expect(legalB.canRaise).toBe(false);
// B folds: the two live stacks (A matched 150, C all-in at 150) plus B's
// dead 100 go to a single showdown pot of 400.
state = act(state, 1, "fold");
expect(state.phase).toBe("showdown");
expect(state.showdown?.pots).toHaveLength(1);
expect(state.showdown?.pots[0]?.amount).toBe(400);
const stackSum = state.players.reduce((sum, player) => sum + player.stack, 0);
expect(stackSum).toBe(2150);
});
it("still allows a full-sized all-in raise to reopen the betting", () => {
let state = makeTable(["A", "B", "C"], [1000, 1000, 400]);
state = act(state, 0, "raise", 100);
state = act(state, 1, "call");
// C all-in to 400: increment 300 over 100, well above the 80 min-raise.
state = act(state, 2, "raise", 400);
expect(state.currentBet).toBe(400);
expect(state.minRaise).toBe(300);
expect(state.lastFullRaiseBet).toBe(400);
const legalA = getLegalActions(state, 0);
expect(legalA.canRaise).toBe(true);
expect(legalA.minRaiseTo).toBe(700);
});
});
describe("side pots", () => {
it("builds layered side pots with exact chip math on a 3-way all-in", () => {
let state = makeTable(["A", "B", "C"], [1000, 500, 200]);
state = act(state, 0, "raise", 500);
state = act(state, 1, "call"); // all-in for 500
state = act(state, 2, "call"); // all-in for 200
expect(state.phase).toBe("showdown");
const pots = state.showdown?.pots ?? [];
expect(pots).toHaveLength(2);
expect(pots[0]?.amount).toBe(600);
expect([...(pots[0]?.eligibleSeats ?? [])].sort()).toEqual([0, 1, 2]);
expect(pots[1]?.amount).toBe(600);
expect([...(pots[1]?.eligibleSeats ?? [])].sort()).toEqual([0, 1]);
// Expected winners derived independently from the evaluator.
const evaluations = [0, 1, 2].map((seat) =>
evaluateHand([
...(state.players[seat]?.holeCards ?? []),
...state.communityCards,
]),
);
for (let i = 0; i < evaluations.length - 1; i += 1) {
const a = evaluations[i];
const b = evaluations[i + 1];
expect(a && b && compareHandRanks(a.rank, b.rank)).not.toBe(0);
}
const bestOf = (seats: number[]): number =>
seats.reduce((best, seat) => {
const bestEval = evaluations[best];
const seatEval = evaluations[seat];
if (bestEval && seatEval && compareHandRanks(seatEval.rank, bestEval.rank) > 0) {
return seat;
}
return best;
}, seats[0] ?? 0);
const mainWinner = bestOf([0, 1, 2]);
const sideWinner = bestOf([0, 1]);
const expectedWon = [0, 0, 0];
expectedWon[mainWinner] += 600;
expectedWon[sideWinner] += 600;
const results = state.showdown?.results ?? [];
for (const seat of [0, 1, 2]) {
const result = results[seat];
expect(result?.chipsWon).toBe(expectedWon[seat]);
expect(result?.netChange).toBe(expectedWon[seat] - (state.players[seat]?.totalCommitted ?? 0));
expect(result?.handRank).toEqual(evaluations[seat]?.rank);
}
expect(pots[0]?.winnerSeats).toEqual([mainWinner]);
expect(pots[1]?.winnerSeats).toEqual([sideWinner]);
const stacks = state.players.map((player) => player.stack);
expect(stacks[0]).toBe(500 + expectedWon[0]);
expect(stacks[1]).toBe(expectedWon[1]);
expect(stacks[2]).toBe(expectedWon[2]);
expect(stacks.reduce((sum, value) => sum + value, 0)).toBe(1700);
});
it("splits a pot between tied hands and ranks the loser below", () => {
const community = [c(2, "h"), c(2, "d"), c(7, "c"), c(9, "s"), c(3, "d")];
const state = showdownState(
[
{ hole: [c(14, "h"), c(12, "d")], committed: 100 },
{ hole: [c(14, "s"), c(12, "c")], committed: 100 },
{ hole: [c(14, "d"), c(11, "c")], committed: 100 },
],
community,
);
const outcome = resolveShowdown(state);
expect(outcome.pots).toHaveLength(1);
expect(outcome.pots[0]?.amount).toBe(300);
expect([...(outcome.pots[0]?.winnerSeats ?? [])].sort()).toEqual([0, 1]);
const results = outcome.results;
expect(results[0]?.chipsWon).toBe(150);
expect(results[1]?.chipsWon).toBe(150);
expect(results[2]?.chipsWon).toBe(0);
expect(results[0]?.netChange).toBe(50);
expect(results[1]?.netChange).toBe(50);
expect(results[2]?.netChange).toBe(-100);
expect(results[0]?.placement).toBe(1);
expect(results[1]?.placement).toBe(1);
expect(results[2]?.placement).toBe(3);
});
it("gives the odd chip to the first winner left of the button", () => {
const community = [c(2, "h"), c(2, "d"), c(7, "c"), c(9, "s"), c(3, "d")];
const state = showdownState(
[
{ hole: [c(14, "h"), c(12, "d")], committed: 100 },
{ hole: [c(14, "s"), c(12, "c")], committed: 100 },
{ hole: [c(14, "c"), c(12, "h")], committed: 100 },
{ hole: [c(14, "d"), c(11, "c")], committed: 100 },
],
community,
0,
);
const outcome = resolveShowdown(state);
expect(outcome.pots[0]?.amount).toBe(400);
// Three-way tie over a 400 pot: 133 each plus one odd chip, paid to the
// first winner left of the button (seat 1 when the button is seat 0).
expect(outcome.results[1]?.chipsWon).toBe(134);
expect(outcome.results[2]?.chipsWon).toBe(133);
expect(outcome.results[0]?.chipsWon).toBe(133);
expect(outcome.results[3]?.chipsWon).toBe(0);
});
it("compares kickers when one player outkicks a tied pair", () => {
const community = [c(2, "h"), c(2, "d"), c(7, "c"), c(9, "s"), c(3, "d")];
const state = showdownState(
[
{ hole: [c(14, "h"), c(12, "d")], committed: 100 },
{ hole: [c(14, "s"), c(11, "c")], committed: 100 },
],
community,
);
const outcome = resolveShowdown(state);
expect(outcome.pots[0]?.winnerSeats).toEqual([0]);
expect(outcome.results[0]?.chipsWon).toBe(200);
expect(outcome.results[1]?.chipsWon).toBe(0);
expect(outcome.results[0]?.placement).toBe(1);
expect(outcome.results[1]?.placement).toBe(2);
});
});
describe("fold-out and winner-takes-all", () => {
it("awards the pot to the last live player when everyone else folds", () => {
let state = makeTable(["A", "B", "C"]);
state = act(state, 0, "fold");
state = act(state, 1, "fold");
expect(state.phase).toBe("hand-complete");
expect(state.toActSeat).toBeNull();
expect(state.showdown?.pots[0]?.amount).toBe(30);
expect(state.showdown?.pots[0]?.winnerSeats).toEqual([2]);
expect(state.players.map((player) => player.stack)).toEqual([1000, 990, 1010]);
const results = state.showdown?.results ?? [];
expect(results.map((result) => result.netChange)).toEqual([0, -10, 10]);
expect(results.map((result) => result.placement)).toEqual([2, 2, 1]);
// Hole cards stay hidden on a fold-out even in the public view.
const view = buildPublicView(state, null);
expect(view.players.every((player) => player.holeCards === null)).toBe(true);
});
it("runs a heads-up all-in to a winner-takes-all showdown", () => {
let state = makeTable(["A", "B"]);
state = act(state, 0, "raise", 1000);
state = act(state, 1, "call");
expect(state.phase).toBe("showdown");
expect(state.showdown?.pots[0]?.amount).toBe(2000);
const evaluations = [0, 1].map((seat) =>
evaluateHand([
...(state.players[seat]?.holeCards ?? []),
...state.communityCards,
]),
);
const first = evaluations[0];
const second = evaluations[1];
expect(first && second && compareHandRanks(first.rank, second.rank)).not.toBe(0);
const winner = first && second && compareHandRanks(first.rank, second.rank) > 0 ? 0 : 1;
expect(state.showdown?.pots[0]?.winnerSeats).toEqual([winner]);
expect(state.players[winner]?.stack).toBe(2000);
expect(state.players[1 - winner]?.stack).toBe(0);
const results = state.showdown?.results ?? [];
expect(results[winner]?.netChange).toBe(1000);
expect(results[1 - winner]?.netChange).toBe(-1000);
expect(results[winner]?.placement).toBe(1);
// The busted player is eliminated and the game ends on the next advance.
state = advanceState(state);
expect(state.phase).toBe("game-complete");
expect(state.finalPlacements).not.toBeNull();
expect(state.finalPlacements?.find((p) => p.seatId === winner)?.placement).toBe(1);
expect(state.finalPlacements?.find((p) => p.seatId === 1 - winner)?.placement).toBe(2);
});
});
describe("views", () => {
it("hides hole cards in public views except the viewer's own", () => {
const state = makeTable(["A", "B", "C"]);
const spectator = buildPublicView(state, null);
expect(spectator.players.every((player) => player.holeCards === null)).toBe(true);
expect(spectator.players.every((player) => player.cardCount === 2)).toBe(true);
const seat0 = buildPublicView(state, 0);
expect(seat0.players[0]?.holeCards).toEqual(state.players[0]?.holeCards);
expect(seat0.players[1]?.holeCards).toBeNull();
});
it("exposes legal actions and turn state in the private view", () => {
const state = makeTable(["A", "B", "C"]);
const view = buildPrivateView(state, 0);
expect(view.viewerSeat).toBe(0);
expect(view.yourHoleCards).toEqual(state.players[0]?.holeCards);
expect(view.yourTurn).toBe(true);
expect(view.legalActions.canCall).toBe(true);
// Seat 0 is the button and UTG preflop in a 3-handed game: no blind
// posted yet, so a full call costs the big blind.
expect(view.legalActions.callAmount).toBe(BIG_BLIND);
expect(view.legalActions.canRaise).toBe(true);
const notTurn = buildPrivateView(state, 1);
expect(notTurn.yourTurn).toBe(false);
expect(notTurn.legalActions.canCheck).toBe(false);
expect(notTurn.legalActions.canFold).toBe(false);
});
it("reveals live hands to everyone at showdown", () => {
let state = makeTable(["A", "B"]);
state = act(state, 0, "raise", 1000);
state = act(state, 1, "call");
expect(state.phase).toBe("showdown");
const view = buildPublicView(state, null);
for (const player of view.players) {
expect(player.holeCards).toEqual(state.players[player.seatId]?.holeCards);
}
});
});
describe("state progression", () => {
it("is a no-op while a betting round is still open", () => {
const state = makeTable(["A", "B", "C"]);
expect(advanceState(state)).toEqual(state);
});
it("starts the next hand with the button moved and busted players out", () => {
let state = makeTable(["A", "B", "C"], [1000, 1000, 30]);
// C posted the big blind from a 30 stack and has only 10 behind, so the
// 500 raise puts them all-in for 30 total. A and B still have live stacks
// and continue betting against each other on later streets.
state = act(state, 0, "raise", 500);
state = act(state, 1, "call");
state = act(state, 2, "call");
expect(state.street).toBe("flop");
state = act(state, 1, "fold");
state = act(state, 0, "check");
expect(state.phase).toBe("showdown");
const bustedAfterHand = state.players.filter((player) => player.stack <= 0).length;
state = advanceState(state);
expect(state.phase).toBe("betting");
expect(state.handNumber).toBe(2);
expect(state.players.filter((player) => player.eliminated)).toHaveLength(bustedAfterHand);
const eligible = state.players.filter((player) => !player.eliminated);
for (const seat of [state.buttonSeat, state.smallBlindSeat, state.bigBlindSeat]) {
expect(eligible.some((player) => player.seatId === seat)).toBe(true);
}
if (eligible.length === 2) {
expect(state.smallBlindSeat).toBe(state.buttonSeat);
}
});
});
describe("full scripted games", () => {
it("plays a 4-handed game to a single winner with exact chip conservation", () => {
const state = driveGame(42, 4);
expect(state.phase).toBe("game-complete");
expect(state.finalPlacements).not.toBeNull();
const stacks = state.players.map((player) => player.stack);
expect(stacks.reduce((sum, value) => sum + value, 0)).toBe(4000);
const winners = state.players.filter((player) => player.stack > 0);
expect(winners).toHaveLength(1);
expect(winners[0]?.stack).toBe(4000);
const winnerPlacement = state.finalPlacements?.find(
(p) => p.seatId === winners[0]?.seatId,
);
expect(winnerPlacement?.placement).toBe(1);
const placements = (state.finalPlacements ?? []).map((p) => p.placement);
expect(placements[0]).toBe(1);
expect(state.handNumber).toBeGreaterThan(1);
});
it("plays a 3-handed game to a single winner from a different seed", () => {
const state = driveGame(1337, 3);
expect(state.phase).toBe("game-complete");
const stacks = state.players.map((player) => player.stack);
expect(stacks.reduce((sum, value) => sum + value, 0)).toBe(3000);
const winners = state.players.filter((player) => player.stack > 0);
expect(winners).toHaveLength(1);
expect(winners[0]?.stack).toBe(3000);
expect(state.finalPlacements).toHaveLength(3);
});
});