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