Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
764 lines
29 KiB
TypeScript
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);
|
|
});
|
|
});
|