1
0
Fork 0

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>
This commit is contained in:
Jason Fraley 2026-09-11 03:25:43 -04:00
parent 91bdd433d5
commit 6968ee0abc
19 changed files with 3300 additions and 0 deletions

View file

@ -0,0 +1,146 @@
/**
* Player action application for No-Limit Hold'em betting rounds.
* Pure: validates legality, produces the next state, and auto-advances the
* hand whenever the action completes a betting round or ends it.
*/
import {
commitChips,
getLegalActions,
isRoundComplete,
livePlayers,
nextToAct,
} from "./betting";
import { advanceState, awardFoldOut } from "./engine";
import type {
PokerActionKind,
PokerSeatId,
PokerTableState,
} from "./types";
export type PokerActionResult =
| { readonly success: true; readonly state: PokerTableState }
| { readonly success: false; readonly error: string };
function failure(error: string): PokerActionResult {
return { success: false, error };
}
/**
* Apply a betting action for `seatId`. Bet and raise amounts are the player's
* TOTAL commitment for the current street ("raise to X"); a bet/raise equal
* to the player's max is an all-in. Calling always calls the maximum the
* stack allows. On success the state is auto-advanced (next street, showdown,
* or next hand) whenever the action completes a round or ends the hand.
*/
export function applyAction(
state: PokerTableState,
seatId: PokerSeatId,
action: PokerActionKind,
amount?: number,
): PokerActionResult {
if (state.phase !== "betting") {
return failure("The hand is not accepting actions right now.");
}
if (state.toActSeat !== seatId) {
return failure("It is not that seat's turn to act.");
}
const player = state.players[seatId];
if (!player) return failure("No player occupies that seat.");
if (player.folded) return failure("That player has already folded.");
if (player.allIn) return failure("That player is all-in and cannot act.");
if (player.eliminated) return failure("That player is eliminated.");
let updated = player;
let currentBet = state.currentBet;
let minRaise = state.minRaise;
let lastFullRaiseBet = state.lastFullRaiseBet;
switch (action) {
case "fold":
updated = { ...player, folded: true, hasActed: true };
break;
case "check":
if (state.currentBet !== player.committed) {
return failure("Cannot check: there is a bet to call.");
}
updated = { ...player, hasActed: true };
break;
case "call": {
if (state.currentBet <= player.committed) {
return failure("Cannot call: there is no bet to call.");
}
updated = commitChips(player, Math.min(state.currentBet - player.committed, player.stack));
break;
}
case "bet":
case "raise": {
const legal = getLegalActions(state, seatId);
if (action === "bet" && !legal.canBet) {
return failure("Cannot bet: there is already a bet this street; raise instead.");
}
if (action === "raise" && !legal.canRaise) {
return failure("Raising is not available right now.");
}
if (amount === undefined) {
return failure(`A ${action} requires an amount (total commitment for this street).`);
}
if (!Number.isInteger(amount) || amount <= player.committed) {
return failure("The amount must be an integer above your current commitment.");
}
if (amount > legal.maxBetTo) {
return failure("The amount exceeds your stack.");
}
if (action === "raise" && amount <= state.currentBet) {
return failure("A raise must exceed the current bet.");
}
const isAllIn = amount === legal.maxBetTo;
if (action === "bet") {
if (!isAllIn && amount < legal.minBetTo) {
return failure(`The minimum bet is ${legal.minBetTo}.`);
}
// Any opening bet sets the raise increment for the street.
minRaise = Math.max(amount - player.committed, state.minRaise);
lastFullRaiseBet = amount;
} else {
const increment = amount - state.currentBet;
if (!isAllIn && increment < state.minRaise) {
return failure(`The minimum raise is to ${legal.minRaiseTo}.`);
}
if (increment >= state.minRaise) {
// Full raise (including an all-in that happens to be full-sized):
// resets the minimum raise and reopens the betting.
minRaise = increment;
lastFullRaiseBet = amount;
}
// A short all-in raise leaves minRaise and lastFullRaiseBet alone so
// players who already acted cannot re-raise.
}
currentBet = amount;
updated = commitChips(player, amount - player.committed);
break;
}
default: {
const exhaustive: never = action;
return failure(`Unknown action: ${String(exhaustive)}`);
}
}
const players = state.players.map((p, index) => (index === seatId ? updated : p));
let next: PokerTableState = {
...state,
players,
currentBet,
minRaise,
lastFullRaiseBet,
};
if (livePlayers(next).length === 1) {
return { success: true, state: awardFoldOut(next) };
}
if (isRoundComplete(next)) {
return { success: true, state: advanceState(next) };
}
next = { ...next, toActSeat: nextToAct(next, seatId) };
return { success: true, state: next };
}

View file

@ -0,0 +1,187 @@
/**
* Betting legality for No-Limit Hold'em: whose turn it is, what actions are
* available, min-raise rules, and the short-all-in "does not reopen betting"
* rule. Pure functions over the table state.
*/
import { BIG_BLIND } from "./constants";
import type {
PokerLegalActions,
PokerPlayerState,
PokerSeatId,
PokerTableState,
} from "./types";
/** A player who can still take actions in the current betting round. */
export function canAct(player: PokerPlayerState): boolean {
return !player.eliminated && !player.folded && !player.allIn;
}
/** Players still eligible to win chips in the current hand. */
export function livePlayers(state: PokerTableState): PokerPlayerState[] {
return state.players.filter((player) => !player.eliminated && !player.folded);
}
/** Players who can still take actions in the current betting round. */
export function actingPlayers(state: PokerTableState): PokerPlayerState[] {
return state.players.filter((player) => canAct(player));
}
/**
* First seat after `afterSeat` (wrapping) that can act, or null when nobody
* can. Skips folded, all-in, and eliminated players.
*/
export function nextActorFrom(
players: readonly PokerPlayerState[],
afterSeat: PokerSeatId,
): PokerSeatId | null {
const count = players.length;
for (let step = 1; step <= count; step += 1) {
const seat = (afterSeat + step) % count;
const player = players[seat];
if (player && canAct(player)) return seat;
}
return null;
}
export function nextToAct(state: PokerTableState, afterSeat: PokerSeatId): PokerSeatId | null {
return nextActorFrom(state.players, afterSeat);
}
/**
* First to act preflop. Heads-up quirk: the button (who posts the small
* blind) acts first. Multi-way: the seat after the big blind.
*/
export function preflopFirstToAct(
players: readonly PokerPlayerState[],
buttonSeat: PokerSeatId,
bigBlindSeat: PokerSeatId,
headsUp: boolean,
): PokerSeatId | null {
if (headsUp) {
const button = players[buttonSeat];
if (button && canAct(button)) return buttonSeat;
}
return nextActorFrom(players, bigBlindSeat);
}
/** First to act postflop: the first live seat left of the button. */
export function postflopFirstToAct(state: PokerTableState): PokerSeatId | null {
return nextToAct(state, state.buttonSeat);
}
/**
* A betting round is complete when every player who can act has acted and
* matched the current bet. (All-in players are exempt: they have nothing
* left to match with.)
*/
export function isRoundComplete(state: PokerTableState): boolean {
return actingPlayers(state).every(
(player) => player.hasActed && player.committed === state.currentBet,
);
}
const NO_ACTIONS: Omit<PokerLegalActions, "seatId"> = {
canFold: false,
canCheck: false,
canCall: false,
callAmount: 0,
canBet: false,
minBetTo: 0,
maxBetTo: 0,
canRaise: false,
minRaiseTo: 0,
maxRaiseTo: 0,
};
/**
* Legal actions for `seatId` right now. All bet/raise amounts are expressed
* as the player's TOTAL commitment for the current street ("raise to X").
*/
export function getLegalActions(state: PokerTableState, seatId: PokerSeatId): PokerLegalActions {
const player = state.players[seatId];
if (
!player ||
state.phase !== "betting" ||
state.toActSeat !== seatId ||
!canAct(player)
) {
return { seatId, ...NO_ACTIONS };
}
const bigBlind = state.config.bigBlind;
const { stack, committed } = player;
const maxTo = committed + stack;
const callAmount = Math.min(state.currentBet - committed, stack);
const canCheck = state.currentBet === committed;
const canCall = state.currentBet > committed;
const canBet = state.currentBet === 0 && stack > 0;
const minBetTo = canBet ? Math.min(committed + bigBlind, maxTo) : 0;
// A short all-in raise (below min-raise) must not reopen the betting for
// players who already acted: they may only call or fold. A player may
// raise when the current bet came from a full raise, or when they have
// not acted yet this street.
const raiseUnlocked = state.currentBet === state.lastFullRaiseBet || !player.hasActed;
const canRaise =
state.currentBet > 0 && stack > 0 && maxTo > state.currentBet && raiseUnlocked;
const minRaiseTo = canRaise ? Math.min(state.currentBet + state.minRaise, maxTo) : 0;
return {
seatId,
canFold: true,
canCheck,
canCall,
callAmount: canCall ? callAmount : 0,
canBet,
minBetTo,
maxBetTo: maxTo,
canRaise,
minRaiseTo,
maxRaiseTo: maxTo,
};
}
/** Total chips committed on the current street across all players. */
export function sumCommitted(state: PokerTableState): number {
return state.players.reduce((total, player) => total + player.committed, 0);
}
/** Move chips from a player's stack into their current-street commitment. */
export function commitChips(player: PokerPlayerState, pay: number): PokerPlayerState {
const stack = player.stack - pay;
return {
...player,
stack,
committed: player.committed + pay,
totalCommitted: player.totalCommitted + pay,
allIn: player.allIn || stack === 0,
hasActed: true,
};
}
/**
* Post a blind: commits chips but does NOT count as having acted, so the
* player keeps their preflop option to check or raise.
*/
export function postBlind(player: PokerPlayerState, blind: number): PokerPlayerState {
const pay = Math.min(blind, player.stack);
const stack = player.stack - pay;
return {
...player,
stack,
committed: pay,
totalCommitted: pay,
allIn: stack === 0,
hasActed: false,
};
}
/** Clear street-level betting state after a round completes. */
export function collectBets(players: readonly PokerPlayerState[]): PokerPlayerState[] {
return players.map((player) => ({
...player,
committed: 0,
hasActed: false,
}));
}

View file

@ -0,0 +1,24 @@
/**
* Tunable constants for the No-Limit Texas Hold'em poker engine.
* Pure data, no imports: safe for client and server.
*/
/** Play chips each player starts a game with. */
export const STARTING_STACK = 1000;
export const SMALL_BLIND = 10;
export const BIG_BLIND = 20;
/**
* How long a player has to act, in milliseconds. The engine itself never
* enforces this (it is pure and synchronous); the future table-registry
* wrapper reads it to run turn timers and auto-fold stale players.
*/
export const TURN_TIMEOUT_MS = 30_000;
/** Supported player count: heads-up through 4-handed. */
export const MIN_PLAYERS = 2;
export const MAX_PLAYERS = 4;
export const MAX_TABLE_NAME_LENGTH = 48;

View file

@ -0,0 +1,60 @@
/**
* Deck creation and deterministic shuffling for the poker engine.
* Pure: randomness is always injected; no module-level state.
*/
import type { Card, Rank, Suit } from "./types";
const SUITS: readonly Suit[] = ["s", "h", "d", "c"];
const MIN_RANK: Rank = 2;
const MAX_RANK: Rank = 14;
/** A fresh, ordered 52-card deck (order is deterministic before shuffling). */
export function createDeck(): Card[] {
const deck: Card[] = [];
for (const suit of SUITS) {
for (let rank = MIN_RANK; rank <= MAX_RANK; rank += 1) {
deck.push({ rank: rank as Rank, suit });
}
}
return deck;
}
/**
* Small, fast seeded PRNG (mulberry32). Used so a whole multi-hand game can
* be replayed from a single integer seed stored in the table state.
*/
export function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Fisher-Yates shuffle; returns a new array, never mutates the input. */
export function shuffleDeck(cards: readonly Card[], rng: () => number): Card[] {
const deck = [...cards];
for (let i = deck.length - 1; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const tmp = deck[i];
deck[i] = deck[j];
deck[j] = tmp;
}
return deck;
}
const GOLDEN_RATIO_32 = 0x9e3779b9;
/** Per-hand seed derived from the table's base seed and the hand number. */
export function handSeed(baseSeed: number, handNumber: number): number {
return (baseSeed + Math.imul(handNumber, GOLDEN_RATIO_32)) >>> 0;
}
/** The exact shuffled deck used for a given hand of a given game seed. */
export function createShuffledDeck(baseSeed: number, handNumber: number): Card[] {
return shuffleDeck(createDeck(), mulberry32(handSeed(baseSeed, handNumber)));
}

View file

@ -0,0 +1,235 @@
/**
* Core table lifecycle: table creation and state progression across streets
* and hands. Action application lives in actions.ts; hand setup in
* handSetup.ts.
*
* Pure and server-authoritative: every function takes a state and returns a
* new state (never mutates), all randomness flows from the table seed, and
* nothing here imports Payload, Next.js, or process. A later wrapper will
* hold states in a registry, run turn timers, and expose views.
*/
import {
collectBets,
isRoundComplete,
livePlayers,
postflopFirstToAct,
sumCommitted,
} from "./betting";
import { BIG_BLIND, MAX_PLAYERS, MIN_PLAYERS, SMALL_BLIND, STARTING_STACK, TURN_TIMEOUT_MS } from "./constants";
import { startHand } from "./handSetup";
import { resolveShowdown } from "./showdown";
import type {
PokerPlayerShowdownResult,
PokerPlayerState,
PokerSeatId,
PokerStreet,
PokerTableConfig,
PokerTableState,
PokerGamePlacement,
ShowdownOutcome,
} from "./types";
export interface PokerPlayerInit {
readonly name: string;
/** Overrides the table's starting stack for this seat (for tests/scenarios). */
readonly stack?: number;
}
export interface CreateTableOptions {
readonly startingStack?: number;
readonly smallBlind?: number;
readonly bigBlind?: number;
}
/** Award the whole pot to the last live player without a showdown. */
export function awardFoldOut(state: PokerTableState): PokerTableState {
const live = livePlayers(state);
const winner = live[0];
if (!winner) return state;
const potAmount = state.collectedPot + sumCommitted(state);
const collected = collectBets(state.players);
const players = collected.map((player) =>
player.seatId === winner.seatId ? { ...player, stack: player.stack + potAmount } : player,
);
const results: PokerPlayerShowdownResult[] = state.players
.filter((player) => !player.eliminated)
.map((player) => {
const isWinner = player.seatId === winner.seatId;
const chipsWon = isWinner ? potAmount : 0;
return {
seatId: player.seatId,
handRank: null,
bestHand: null,
chipsWon,
netChange: chipsWon - player.totalCommitted,
placement: isWinner ? 1 : 2,
};
});
const showdown: ShowdownOutcome = {
pots: [
{
potIndex: 0,
amount: potAmount,
winnerSeats: [winner.seatId],
eligibleSeats: [winner.seatId],
},
],
results,
};
return {
...state,
players,
collectedPot: 0,
phase: "hand-complete",
toActSeat: null,
showdown,
};
}
/** Resolve a showdown and credit each winner's chipsWon to their stack. */
function resolveAndPay(state: PokerTableState): PokerTableState {
const outcome = resolveShowdown(state);
const winnings = new Map<PokerSeatId, number>();
for (const result of outcome.results) winnings.set(result.seatId, result.chipsWon);
const players = state.players.map((player) => ({
...player,
stack: player.stack + (winnings.get(player.seatId) ?? 0),
}));
return {
...state,
players,
collectedPot: 0,
phase: "showdown",
toActSeat: null,
showdown: outcome,
};
}
/** Deal the remaining community cards and resolve the showdown. */
function runOutAndShowdown(state: PokerTableState): PokerTableState {
let community = [...state.communityCards];
let deck = [...state.deck];
while (community.length < 5) {
const toDeal = community.length === 0 ? 3 : 1;
community = [...community, ...deck.slice(0, toDeal)];
deck = deck.slice(toDeal);
}
const withBoard: PokerTableState = {
...state,
communityCards: community,
deck,
street: "river",
toActSeat: null,
};
return resolveAndPay(withBoard);
}
const STREET_ORDER: readonly PokerStreet[] = ["preflop", "flop", "turn", "river"];
/**
* Progress the state whenever it can progress on its own:
* - mid-betting-round: no-op until the round completes;
* - round complete: collect bets, deal the next street (or run the board out
* when betting is impossible), or resolve the showdown on the river;
* - hand over: start the next hand, or finish the game when one player holds
* every chip.
* Idempotent: calling it on a resting state returns that state unchanged.
*/
export function advanceState(state: PokerTableState): PokerTableState {
if (state.phase === "game-complete") return state;
if (state.phase === "showdown" || state.phase === "hand-complete") {
return startHand(state);
}
if (!isRoundComplete(state)) return state;
const collected = state.collectedPot + sumCommitted(state);
const next: PokerTableState = {
...state,
players: collectBets(state.players),
collectedPot: collected,
currentBet: 0,
minRaise: state.config.bigBlind,
lastFullRaiseBet: 0,
toActSeat: null,
};
const live = livePlayers(next);
if (live.length <= 1) return awardFoldOut(next);
const actors = live.filter((player) => !player.allIn);
if (actors.length <= 1) return runOutAndShowdown(next);
if (next.street === "river") {
return resolveAndPay(next);
}
const streetIndex = STREET_ORDER.indexOf(next.street);
const street = STREET_ORDER[streetIndex + 1] ?? "river";
const cardsToDeal = street === "flop" ? 3 : 1;
return {
...next,
street,
communityCards: [...next.communityCards, ...next.deck.slice(0, cardsToDeal)],
deck: next.deck.slice(cardsToDeal),
toActSeat: postflopFirstToAct(next),
};
}
export function createTableState(
players: readonly PokerPlayerInit[],
options: CreateTableOptions = {},
rng: () => number = Math.random,
): PokerTableState {
if (players.length < MIN_PLAYERS || players.length > MAX_PLAYERS) {
throw new Error(`Poker tables support ${MIN_PLAYERS} to ${MAX_PLAYERS} players.`);
}
const startingStack = options.startingStack ?? STARTING_STACK;
const smallBlind = options.smallBlind ?? SMALL_BLIND;
const bigBlind = options.bigBlind ?? BIG_BLIND;
if (smallBlind <= 0 || bigBlind < smallBlind) {
throw new Error("Blinds must be positive and the big blind must be at least the small blind.");
}
const config: PokerTableConfig = {
startingStack,
smallBlind,
bigBlind,
turnTimeoutMs: TURN_TIMEOUT_MS,
};
const seed = Math.floor(rng() * 0x100000000) >>> 0;
const seated: PokerPlayerState[] = players.map((player, index) => ({
seatId: index,
name: player.name,
stack: player.stack ?? startingStack,
committed: 0,
totalCommitted: 0,
holeCards: [],
folded: true,
allIn: false,
hasActed: false,
eliminated: false,
}));
const initial: PokerTableState = {
config,
seed,
handNumber: 0,
players: seated,
buttonSeat: seated.length - 1,
smallBlindSeat: 0,
bigBlindSeat: 1,
street: "preflop",
phase: "hand-complete",
collectedPot: 0,
communityCards: [],
deck: [],
currentBet: 0,
minRaise: bigBlind,
lastFullRaiseBet: 0,
toActSeat: null,
showdown: null,
finalPlacements: null,
};
// Hand 1 starts immediately: the "hand-complete" resting phase makes
// advanceState deal it, which also moves the button to the first seat.
return advanceState(initial);
}

View file

@ -0,0 +1,133 @@
/**
* 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;
}

View file

@ -0,0 +1,97 @@
import { HAND_CATEGORIES, HAND_CATEGORY_NAMES, evaluateHand } from "./evaluator";
import type { Card, Rank } from "./types";
export interface HandStrengthHint {
readonly label: string;
readonly bestCardKeys: string[];
}
const RANK_NAMES: Record<Rank, string> = {
2: "Deuce",
3: "Three",
4: "Four",
5: "Five",
6: "Six",
7: "Seven",
8: "Eight",
9: "Nine",
10: "Ten",
11: "Jack",
12: "Queen",
13: "King",
14: "Ace",
};
function cardKey(card: Card): string {
return `${card.rank}-${card.suit}`;
}
function rankName(rank: number): string {
return RANK_NAMES[rank as Rank] ?? "Unknown";
}
function pluralRankName(rank: number): string {
const name = rankName(rank);
return name === "Six" ? "Sixes" : `${name}s`;
}
function highCardLabel(rank: number): string {
return `${rankName(rank)} high`;
}
function capitalize(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1);
}
function postflopLabel(category: number, tiebreakers: readonly number[]): string {
const first = tiebreakers[0] ?? 0;
switch (category) {
case HAND_CATEGORIES.HIGH_CARD:
return `High card, ${highCardLabel(first)}`;
case HAND_CATEGORIES.PAIR:
return `Pair of ${pluralRankName(first)}`;
case HAND_CATEGORIES.TWO_PAIR:
return `Two pair, ${pluralRankName(first)} and ${pluralRankName(tiebreakers[1] ?? 0)}`;
case HAND_CATEGORIES.TRIPS:
return `Three of a kind, ${pluralRankName(first)}`;
case HAND_CATEGORIES.STRAIGHT:
return `Straight, ${highCardLabel(first)}`;
case HAND_CATEGORIES.FLUSH:
return `Flush, ${highCardLabel(first)}`;
case HAND_CATEGORIES.FULL_HOUSE:
return `Full house, ${pluralRankName(first)} over ${pluralRankName(tiebreakers[1] ?? 0)}`;
case HAND_CATEGORIES.QUADS:
return `Four of a kind, ${pluralRankName(first)}`;
case HAND_CATEGORIES.STRAIGHT_FLUSH:
return `Straight flush, ${highCardLabel(first)}`;
default:
return capitalize(HAND_CATEGORY_NAMES[category] ?? "unknown hand");
}
}
export function describeHandStrength(
holeCards: readonly Card[],
communityCards: readonly Card[],
): HandStrengthHint | null {
if (holeCards.length === 0) return null;
if (communityCards.length === 0) {
const highCard = holeCards.reduce((best, card) => (card.rank > best.rank ? card : best));
const pair = holeCards.length >= 2 && holeCards[0]?.rank === holeCards[1]?.rank;
return {
label: pair
? `Pair of ${pluralRankName(highCard.rank)}`
: `High card ${rankName(highCard.rank)}`,
bestCardKeys: pair ? holeCards.map(cardKey) : [cardKey(highCard)],
};
}
const combined = [...holeCards, ...communityCards];
if (combined.length < 5 || combined.length > 7) return null;
const evaluation = evaluateHand(combined);
return {
label: postflopLabel(evaluation.rank.category, evaluation.rank.tiebreakers),
bestCardKeys: evaluation.best.map(cardKey),
};
}

View file

@ -0,0 +1,153 @@
/**
* Hand setup and teardown: dealing, blind posting, button movement, and
* final game placement. Pure functions over the table state.
*/
import { postBlind, preflopFirstToAct } from "./betting";
import { createShuffledDeck } from "./deck";
import type {
Card,
PokerGamePlacement,
PokerPlayerState,
PokerSeatId,
PokerTableState,
} from "./types";
export function nextEligibleSeat(
players: readonly PokerPlayerState[],
afterSeat: PokerSeatId,
): PokerSeatId {
const count = players.length;
for (let step = 1; step <= count; step += 1) {
const seat = (afterSeat + step) % count;
const player = players[seat];
if (player && !player.eliminated && player.stack > 0) return seat;
}
return afterSeat;
}
function dealHoleCards(
players: readonly PokerPlayerState[],
buttonSeat: PokerSeatId,
deck: readonly Card[],
): { players: PokerPlayerState[]; deck: readonly Card[] } {
const holeCards = new Map<PokerSeatId, Card[]>();
let deckIndex = 0;
// Deal starts left of the button, two cards per eligible player.
for (let round = 0; round < 2; round += 1) {
let seat = buttonSeat;
for (let step = 0; step < players.length; step += 1) {
seat = (seat + 1) % players.length;
const player = players[seat];
if (!player || player.eliminated || player.stack <= 0) continue;
const card = deck[deckIndex];
deckIndex += 1;
const hand = holeCards.get(seat) ?? [];
if (card) hand.push(card);
holeCards.set(seat, hand);
}
}
return {
players: players.map((player) => ({
...player,
holeCards: holeCards.get(player.seatId) ?? [],
})),
deck: deck.slice(deckIndex),
};
}
function finishGame(state: PokerTableState): PokerTableState {
const players = state.players.map((player) =>
player.stack <= 0 ? { ...player, eliminated: true } : player,
);
const ranked = [...players].sort((a, b) => b.stack - a.stack || a.seatId - b.seatId);
const placements = new Map<PokerSeatId, number>();
let current = 0;
let previousStack = -1;
ranked.forEach((player, index) => {
if (player.stack !== previousStack) {
current = index + 1;
previousStack = player.stack;
}
placements.set(player.seatId, current);
});
const finalPlacements: PokerGamePlacement[] = players.map((player) => ({
seatId: player.seatId,
placement: placements.get(player.seatId) ?? 0,
}));
return {
...state,
players,
phase: "game-complete",
toActSeat: null,
finalPlacements,
};
}
/** Start the next hand: move the button, post blinds, deal, set preflop action. */
export function startHand(state: PokerTableState): PokerTableState {
const eligible = state.players.filter((player) => player.stack > 0);
if (eligible.length <= 1) return finishGame(state);
const handNumber = state.handNumber + 1;
const buttonSeat = nextEligibleSeat(state.players, state.buttonSeat);
const headsUp = eligible.length === 2;
const smallBlindSeat = headsUp ? buttonSeat : nextEligibleSeat(state.players, buttonSeat);
const bigBlindSeat = nextEligibleSeat(state.players, smallBlindSeat);
const reset = state.players.map((player) => {
if (player.stack <= 0) {
return {
...player,
eliminated: true,
folded: true,
allIn: false,
committed: 0,
totalCommitted: 0,
hasActed: false,
holeCards: [],
};
}
return {
...player,
folded: false,
allIn: false,
committed: 0,
totalCommitted: 0,
hasActed: false,
holeCards: [],
};
});
const dealt = dealHoleCards(reset, buttonSeat, createShuffledDeck(state.seed, handNumber));
const afterBlinds = dealt.players.map((player) => {
if (player.seatId === smallBlindSeat) return postBlind(player, state.config.smallBlind);
if (player.seatId === bigBlindSeat) return postBlind(player, state.config.bigBlind);
return player;
});
const smallBlindPosted = afterBlinds[smallBlindSeat]?.committed ?? 0;
const bigBlindPosted = afterBlinds[bigBlindSeat]?.committed ?? 0;
const currentBet = Math.max(smallBlindPosted, bigBlindPosted);
const toActSeat = preflopFirstToAct(afterBlinds, buttonSeat, bigBlindSeat, headsUp);
return {
...state,
handNumber,
players: afterBlinds,
buttonSeat,
smallBlindSeat,
bigBlindSeat,
street: "preflop",
phase: "betting",
collectedPot: 0,
communityCards: [],
deck: dealt.deck,
currentBet,
minRaise: state.config.bigBlind,
lastFullRaiseBet: currentBet,
toActSeat,
showdown: null,
finalPlacements: null,
};
}

View file

@ -0,0 +1,17 @@
/**
* Public API of the pure No-Limit Texas Hold'em poker engine.
* Everything exported here is client-safe: no Payload, Next.js, process, or
* database imports, and no module-level mutable state.
*/
export * from "./constants";
export * from "./types";
export * from "./deck";
export * from "./evaluator";
export * from "./pots";
export * from "./betting";
export * from "./handSetup";
export * from "./showdown";
export * from "./views";
export * from "./engine";
export * from "./actions";

View file

@ -0,0 +1,62 @@
/**
* Layered side-pot construction from per-player hand contributions.
*
* Pure math over `totalCommitted`: pot boundaries are the distinct
* contribution levels of players who are still eligible to win (not folded).
* Folded players' chips fill the pots up to their contribution level; any
* dead money a folder contributed above the highest live level rolls into
* the top pot.
*/
import type { PokerPlayerState, PokerSeatId } from "./types";
export interface PokerSidePot {
readonly amount: number;
/** Seats whose hands compete for this pot (non-folded, contribution >= level). */
readonly eligibleSeats: readonly PokerSeatId[];
}
export function buildSidePots(players: readonly PokerPlayerState[]): PokerSidePot[] {
const contributors = players.filter((player) => player.totalCommitted > 0);
if (contributors.length === 0) return [];
const live = contributors.filter((player) => !player.folded);
const levels = Array.from(new Set(live.map((player) => player.totalCommitted))).sort(
(a, b) => a - b,
);
const pots: PokerSidePot[] = [];
let previousLevel = 0;
for (const level of levels) {
let amount = 0;
for (const player of contributors) {
amount += Math.min(player.totalCommitted, level) - Math.min(player.totalCommitted, previousLevel);
}
if (amount > 0) {
const eligibleSeats = live
.filter((player) => player.totalCommitted >= level)
.map((player) => player.seatId);
pots.push({ amount, eligibleSeats });
}
previousLevel = level;
}
// Dead money above the highest live level (a folder who put in more than
// every live player's all-in) belongs to the top pot's winners.
let excess = 0;
for (const player of contributors) {
excess += player.totalCommitted - Math.min(player.totalCommitted, previousLevel);
}
if (excess > 0) {
const top = pots[pots.length - 1];
if (top) {
pots[pots.length - 1] = {
amount: top.amount + excess,
eligibleSeats: top.eligibleSeats,
};
} else {
pots.push({ amount: excess, eligibleSeats: live.map((player) => player.seatId) });
}
}
return pots;
}

View file

@ -0,0 +1,116 @@
/**
* Showdown resolution: evaluate live hands, layer side pots, award each pot
* to the best eligible hand (splitting ties), and produce per-player
* outcomes with placements, chips won, and net change.
*/
import { compareHandRanks, evaluateHand, type PokerHandEvaluation } from "./evaluator";
import { buildSidePots } from "./pots";
import type {
PokerPlayerShowdownResult,
PokerPotResult,
PokerSeatId,
PokerTableState,
ShowdownOutcome,
} from "./types";
/** Odd-chip ordering: seats are paid starting left of the button. */
function oddChipOrder(
seats: readonly PokerSeatId[],
buttonSeat: PokerSeatId,
tableSize: number,
): PokerSeatId[] {
return [...seats].sort(
(a, b) =>
((a - buttonSeat - 1 + tableSize * 2) % tableSize) -
((b - buttonSeat - 1 + tableSize * 2) % tableSize),
);
}
export function resolveShowdown(state: PokerTableState): ShowdownOutcome {
const inHand = state.players.filter((player) => !player.eliminated);
const live = inHand.filter((player) => !player.folded);
const evaluations = new Map<PokerSeatId, PokerHandEvaluation>();
for (const player of live) {
evaluations.set(
player.seatId,
evaluateHand([...player.holeCards, ...state.communityCards]),
);
}
const pots = buildSidePots(state.players);
const winnings = new Map<PokerSeatId, number>();
const potResults: PokerPotResult[] = [];
pots.forEach((pot, potIndex) => {
let best: PokerHandEvaluation | null = null;
for (const seat of pot.eligibleSeats) {
const evaluation = evaluations.get(seat);
if (evaluation && (!best || compareHandRanks(evaluation.rank, best.rank) > 0)) {
best = evaluation;
}
}
const winners = best
? pot.eligibleSeats.filter((seat) => {
const evaluation = evaluations.get(seat);
return evaluation !== undefined && compareHandRanks(evaluation.rank, best.rank) === 0;
})
: [];
const ordered = oddChipOrder(winners, state.buttonSeat, state.players.length);
const share = ordered.length > 0 ? Math.floor(pot.amount / ordered.length) : 0;
let remainder = ordered.length > 0 ? pot.amount - share * ordered.length : 0;
for (const seat of ordered) {
let won = share;
if (remainder > 0) {
won += 1;
remainder -= 1;
}
winnings.set(seat, (winnings.get(seat) ?? 0) + won);
}
potResults.push({
potIndex,
amount: pot.amount,
winnerSeats: ordered,
eligibleSeats: pot.eligibleSeats,
});
});
// Showdown placements: live hands ranked best first, ties sharing the
// better placement; folded players rank below every live hand.
const liveOrdered = [...live].sort((a, b) => {
const ea = evaluations.get(a.seatId);
const eb = evaluations.get(b.seatId);
if (!ea || !eb) return 0;
return compareHandRanks(eb.rank, ea.rank);
});
const placements = new Map<PokerSeatId, number>();
let currentPlacement = 0;
let previous: PokerHandEvaluation | null = null;
liveOrdered.forEach((player, index) => {
const evaluation = evaluations.get(player.seatId);
if (!evaluation) return;
if (!previous || compareHandRanks(evaluation.rank, previous.rank) !== 0) {
currentPlacement = index + 1;
}
placements.set(player.seatId, currentPlacement);
previous = evaluation;
});
const foldedPlacement = live.length + 1;
const results: PokerPlayerShowdownResult[] = inHand.map((player) => {
const evaluation = evaluations.get(player.seatId) ?? null;
const chipsWon = winnings.get(player.seatId) ?? 0;
return {
seatId: player.seatId,
handRank: evaluation?.rank ?? null,
bestHand: evaluation?.best ?? null,
chipsWon,
netChange: chipsWon - player.totalCommitted,
placement: player.folded ? foldedPlacement : (placements.get(player.seatId) ?? foldedPlacement),
};
});
return { pots: potResults, results };
}

View file

@ -0,0 +1,174 @@
/**
* Poker XP + profile-stats awarding (Payload-dependent, server-only).
* Signatures below are the pinned contract consumed by the table registry;
* the database-backed body of awardPokerGameResult is filled in by the
* stats implementation pass.
*/
import type { Payload } from "payload";
import { broadcastToUser } from "@/lib/realtime/bus";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
export interface PokerPlacementResult {
userId: number;
/** 1 = winner. */
placement: number;
/** Chip total the player finished the game with. */
finalChips: number;
/** Hands this player won outright during the game. */
handsWon: number;
}
export interface PokerStats {
gamesPlayed: number;
wins: number;
secondPlaces: number;
thirdPlaces: number;
fourthPlaces: number;
handsWon: number;
bestScore: number;
xpEarned: number;
}
/**
* XP per placement, scaled by table size:
* 1st = 40x, 2nd = 20x, 3rd = 10x, 4th = 5x (times playerCount).
* Tunable here only; the registry and UI must not hardcode XP values.
*/
export function xpForPlacement(placement: number, playerCount: number): number {
const multiplier =
placement === 1 ? 40 : placement === 2 ? 20 : placement === 3 ? 10 : 5;
return multiplier * playerCount;
}
/**
* Defensive reader for a profile's progression.minigames.poker stats.
* Accepts anything (profiles may predate the poker group) and returns a
* fully-populated PokerStats with zeros for missing values.
*/
export function readPokerStats(profile: unknown): PokerStats {
const poker =
(profile as { progression?: { minigames?: { poker?: Partial<PokerStats> } } })
?.progression?.minigames?.poker ?? {};
return {
gamesPlayed: poker.gamesPlayed ?? 0,
wins: poker.wins ?? 0,
secondPlaces: poker.secondPlaces ?? 0,
thirdPlaces: poker.thirdPlaces ?? 0,
fourthPlaces: poker.fourthPlaces ?? 0,
handsWon: poker.handsWon ?? 0,
bestScore: poker.bestScore ?? 0,
xpEarned: poker.xpEarned ?? 0,
};
}
/**
* Pure stat transition for one finished sit-and-go entry: bumps gamesPlayed,
* the placement counter matching the result's placement, accumulates hands
* won, keeps the best chip total, and adds the placement XP.
*/
export function computePokerStatsDelta(
current: PokerStats,
result: PokerPlacementResult,
playerCount: number,
): PokerStats {
const placement = result.placement;
return {
gamesPlayed: current.gamesPlayed + 1,
wins: current.wins + (placement === 1 ? 1 : 0),
secondPlaces: current.secondPlaces + (placement === 2 ? 1 : 0),
thirdPlaces: current.thirdPlaces + (placement === 3 ? 1 : 0),
fourthPlaces: current.fourthPlaces + (placement === 4 ? 1 : 0),
handsWon: current.handsWon + result.handsWon,
bestScore: Math.max(current.bestScore, result.finalChips),
xpEarned: current.xpEarned + xpForPlacement(placement, playerCount),
};
}
/**
* Awards XP and updates progression.minigames.poker for every player in a
* completed sit-and-go. Emits the profile-update SSE push and a single
* game event log entry (EventTypes.MinigamePoker). Never throws: per-user
* failures are logged and skipped so one broken profile cannot block the
* table.
*/
export async function awardPokerGameResult(
payload: Payload,
results: PokerPlacementResult[],
): Promise<{ xpByUser: Record<number, number> }> {
if (results.length === 0) {
return { xpByUser: {} };
}
const playerCount = results.length;
const xpByUser: Record<number, number> = {};
for (const result of results) {
const xp = xpForPlacement(result.placement, playerCount);
xpByUser[result.userId] = xp;
try {
const profileRes = await payload.find({
collection: "profiles",
where: { user: { equals: result.userId } },
limit: 1,
depth: 0,
overrideAccess: true,
});
const profile = profileRes.docs[0];
if (!profile) {
payload.logger.error(`Poker stats: no profile found for user ${result.userId}`);
continue;
}
const current = readPokerStats(profile);
const next = computePokerStatsDelta(current, result, playerCount);
const progression = (profile.progression ?? {}) as Record<string, unknown>;
const minigames = (progression.minigames ?? {}) as Record<string, unknown>;
await payload.update({
collection: "profiles",
id: profile.id,
data: {
progression: {
...progression,
minigames: {
...minigames,
poker: next,
},
},
},
overrideAccess: true,
depth: 0,
});
broadcastToUser("profile-update", result.userId, {
reason: "xp",
xpGained: xp,
profileId: profile.id,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
payload.logger.error(
`Poker stats: failed to award result for user ${result.userId}: ${message}`,
);
}
}
const winner = results.find((r) => r.placement === 1) ?? results[0];
await emitGameEvent(payload, {
type: EventTypes.MinigamePoker,
message: `finished a poker sit-and-go (1st of ${playerCount})`,
actor: winner.userId,
targetCollection: "users",
targetId: winner.userId,
data: {
placements: results,
xpByUser,
playerCount,
},
});
return { xpByUser };
}

View file

@ -0,0 +1,579 @@
/**
* Server-side multiplayer registry for the poker minigame.
*
* Holds live table records in a module-level Map (same pattern as
* src/lib/realtime/bus.ts). SINGLE-INSTANCE ONLY: table state lives in
* process memory, so this will not work across multiple server instances;
* the SSE pipeline it pushes through has the same limitation.
*
* The pure engine (./engine, ./actions) never touches timers or I/O. This
* wrapper owns turn timeouts, next-hand pauses, lobby broadcasts, and the
* per-user snapshot pushes. Seat map invariant: seatMap is padded to
* MAX_PLAYERS slots with trailing nulls, so engine player i always
* corresponds to seatMap[i] (appends never shift existing indices).
*
* allow: SIZE_OK: single cohesive registry module; the task pins this exact
* file path as the shared server-side surface for three parallel agents, so
* it cannot be split without breaking the ownership contract.
*/
import { getPayload } from "payload";
import type { Payload } from "payload";
import { broadcast, broadcastToUser } from "@/lib/realtime/bus";
import { notifyUser } from "@/lib/notifications";
import { applyAction } from "./actions";
import { getLegalActions } from "./betting";
import {
MAX_PLAYERS,
MAX_TABLE_NAME_LENGTH,
MIN_PLAYERS,
STARTING_STACK,
TURN_TIMEOUT_MS,
} from "./constants";
import { advanceState, createTableState } from "./engine";
import { awardPokerGameResult, type PokerPlacementResult } from "./stats";
import type {
PokerActionKind,
PokerPlayerState,
PokerSeatId,
PokerTableState,
PrivatePlayerView,
} from "./types";
import { buildPrivateView } from "./views";
/** Maximum number of live tables; table creation rejects beyond this. */
export const MAX_TABLES = 20;
/** Pause after a hand result before the next hand auto-starts. */
export const NEXT_HAND_DELAY_MS = 6000;
/** A table with no client interaction for this long is swept as abandoned. */
export const TABLE_EXPIRY_MS = 5 * 60_000;
const TABLE_SWEEP_INTERVAL_MS = 30_000;
export interface PokerTableSummary {
tableId: string;
name: string | null;
hostName: string;
seatedCount: number;
maxSeats: number;
inGame: boolean;
createdAt: string;
}
export interface PokerSnapshot {
tableId: string;
name: string | null;
version: number;
/** ISO timestamp when the current turn auto-acts; null when no live turn. */
turnDeadline: string | null;
/** ISO timestamp when the next hand auto-starts after a hand result pause. */
nextHandAt: string | null;
/** Full table state plus the viewer's private cards and legal actions; null when not seated. */
you: PrivatePlayerView | null;
/** seatId to userId (null = empty seat); lets the UI map names and hosts. */
seats: (number | null)[];
hostSeatId: number | null;
}
interface PendingJoin {
userId: number;
name: string;
}
interface PokerTableRecord {
id: string;
name: string | null;
version: number;
hostUserId: number | null;
/** Engine seat order, padded to MAX_PLAYERS with trailing nulls. */
seatMap: (number | null)[];
state: PokerTableState | null;
inGame: boolean;
handsWonThisGame: Record<number, number>;
/** handNumber already counted into handsWonThisGame (prevents double counts). */
countedHandNumber: number | null;
turnTimer: ReturnType<typeof setTimeout> | null;
/** Version the pending turn timer was scheduled for (stale timers bail). */
turnTimerVersion: number | null;
turnDeadlineAt: string | null;
nextHandTimer: ReturnType<typeof setTimeout> | null;
nextHandAt: string | null;
pendingJoins: PendingJoin[];
awayUsers: Set<number>;
/** Users who left mid-game; kept in seatMap so the UI can show "left". */
leftUsers: Set<number>;
userNames: Map<number, string>;
createdAt: string;
/** Last client interaction (poll, action, join/leave/start); drives expiry. */
lastActivityAt: number;
}
// Anchored on globalThis because Next.js dev gives each client session its
// own copy of server-action modules; a plain module-level Map would fork per
// session and tables would vanish between users. Single-process still.
type PokerTablesStore = Map<string, PokerTableRecord>;
interface PokerGlobalRegistry {
__pokerTables?: PokerTablesStore;
}
const globalRegistry = globalThis as typeof globalThis & PokerGlobalRegistry;
const tables: PokerTablesStore = (globalRegistry.__pokerTables ??= new Map());
function isSeatedAt(record: PokerTableRecord, userId: number): boolean {
return record.seatMap.includes(userId) && !record.leftUsers.has(userId);
}
function seatedCountOf(record: PokerTableRecord): number {
let count = 0;
for (const id of record.seatMap) {
if (id !== null && !record.leftUsers.has(id)) count += 1;
}
return count;
}
function clearTurnTimer(record: PokerTableRecord): void {
if (record.turnTimer) clearTimeout(record.turnTimer);
record.turnTimer = null;
record.turnTimerVersion = null;
record.turnDeadlineAt = null;
}
/**
* Lobby mutations (join/leave) bump the version without touching game state;
* re-key the pending turn timer so it stays valid instead of going stale.
*/
function rekeyTurnTimer(record: PokerTableRecord): void {
if (record.turnTimer !== null) record.turnTimerVersion = record.version;
}
function scheduleTurnTimer(record: PokerTableRecord): void {
clearTurnTimer(record);
const state = record.state;
if (!state || state.phase !== "betting" || state.toActSeat === null) return;
record.turnTimerVersion = record.version;
record.turnDeadlineAt = new Date(Date.now() + TURN_TIMEOUT_MS).toISOString();
record.turnTimer = setTimeout(() => onTurnTimeout(record), TURN_TIMEOUT_MS);
}
/** Turn timeout: auto check/fold for whoever is acting (away or disconnected). */
function onTurnTimeout(record: PokerTableRecord): void {
const scheduledVersion = record.turnTimerVersion;
record.turnTimer = null;
record.turnTimerVersion = null;
record.turnDeadlineAt = null;
if (scheduledVersion === null || scheduledVersion !== record.version) return;
const state = record.state;
if (!state || state.phase !== "betting" || state.toActSeat === null) return;
const seat = state.toActSeat;
const legal = getLegalActions(state, seat);
const result = applyAction(state, seat, legal.canCheck ? "check" : "fold");
if (!result.success) return;
record.state = result.state;
record.version += 1;
afterMutation(record);
}
function scheduleNextHand(record: PokerTableRecord): void {
if (record.nextHandTimer) clearTimeout(record.nextHandTimer);
record.nextHandAt = new Date(Date.now() + NEXT_HAND_DELAY_MS).toISOString();
record.nextHandTimer = setTimeout(() => onNextHandTimeout(record), NEXT_HAND_DELAY_MS);
}
function clearNextHandTimer(record: PokerTableRecord): void {
if (record.nextHandTimer) clearTimeout(record.nextHandTimer);
record.nextHandTimer = null;
record.nextHandAt = null;
}
/** Next-hand pause over: apply pending joins, drop leavers, deal the next hand. */
function onNextHandTimeout(record: PokerTableRecord): void {
record.nextHandTimer = null;
const current = record.state;
if (!current) return;
let next = current;
for (const pending of record.pendingJoins) {
next = {
...next,
players: [...next.players, createFreshPlayer(next.players.length, pending.name)],
};
}
record.pendingJoins = [];
next = eliminateLeavers(next, record);
record.state = advanceState(next);
record.nextHandAt = null;
record.version += 1;
afterMutation(record);
}
function createFreshPlayer(seatId: PokerSeatId, name: string): PokerPlayerState {
return {
seatId,
name,
stack: STARTING_STACK,
committed: 0,
totalCommitted: 0,
holeCards: [],
folded: true,
allIn: false,
hasActed: false,
eliminated: false,
};
}
/** Mid-game leavers forfeit their stack and sit out from the next hand on. */
function eliminateLeavers(state: PokerTableState, record: PokerTableRecord): PokerTableState {
let changed = false;
const players = state.players.map((player, index) => {
const userId = record.seatMap[index];
if (userId === null || !record.leftUsers.has(userId) || player.eliminated) return player;
changed = true;
return {
...player,
eliminated: true,
folded: true,
stack: 0,
committed: 0,
totalCommitted: 0,
holeCards: [],
};
});
return changed ? { ...state, players } : state;
}
/** Count outright hand winners once per hand (both fold-outs and showdowns). */
function countHandWins(record: PokerTableRecord): void {
const state = record.state;
if (!state || !state.showdown) return;
if (state.phase === "betting") return;
if (record.countedHandNumber === state.handNumber) return;
record.countedHandNumber = state.handNumber;
for (const result of state.showdown.results) {
if (result.netChange <= 0) continue;
const userId = record.seatMap[result.seatId];
if (userId === null) continue;
record.handsWonThisGame[userId] = (record.handsWonThisGame[userId] ?? 0) + 1;
}
}
/** Fire-and-forget XP/stats award on game completion; never blocks the table. */
async function awardGameResults(record: PokerTableRecord): Promise<void> {
const state = record.state;
if (!state || !state.finalPlacements) return;
const results: PokerPlacementResult[] = [];
for (const placement of state.finalPlacements) {
const userId = record.seatMap[placement.seatId];
const player = state.players[placement.seatId];
if (userId === null || !player) continue;
results.push({
userId,
placement: placement.placement,
finalChips: player.stack,
handsWon: record.handsWonThisGame[userId] ?? 0,
});
}
try {
const config = (await import("@payload-config")).default;
const payload = await getPayload({ config });
try {
await awardPokerGameResult(payload, results);
} catch (error) {
payload.logger.error(`[poker] Failed to award game results for table ${record.id}: ${error}`);
}
} catch {
// Payload unavailable in this runtime; skip awarding.
}
}
/** Shared post-mutation pipeline: timers, hand-win counting, award, push. */
function afterMutation(record: PokerTableRecord): void {
countHandWins(record);
const state = record.state;
if (state && state.phase === "game-complete") {
clearTurnTimer(record);
clearNextHandTimer(record);
record.inGame = false;
void awardGameResults(record);
} else if (state && (state.phase === "hand-complete" || state.phase === "showdown")) {
clearTurnTimer(record);
scheduleNextHand(record);
} else {
scheduleTurnTimer(record);
}
pushTable(record);
}
function buildSnapshot(record: PokerTableRecord, userId: number): PokerSnapshot {
const seatIndex = record.seatMap.indexOf(userId);
const seated = seatIndex >= 0 && !record.leftUsers.has(userId);
const state = record.state;
const inEngine = seated && state !== null && seatIndex < state.players.length;
const you = state && inEngine ? buildPrivateView(state, seatIndex) : null;
const hostSeat = record.hostUserId !== null ? record.seatMap.indexOf(record.hostUserId) : -1;
return {
tableId: record.id,
name: record.name,
version: record.version,
turnDeadline: record.turnDeadlineAt,
nextHandAt: record.nextHandAt,
you,
seats: [...record.seatMap],
hostSeatId: hostSeat >= 0 ? hostSeat : null,
};
}
/** Push a per-user snapshot to every seated user, then refresh the lobby. */
function pushTable(record: PokerTableRecord): void {
for (const userId of record.seatMap) {
if (userId === null || record.leftUsers.has(userId)) continue;
broadcastToUser("poker-state", userId, {
tableId: record.id,
snapshot: buildSnapshot(record, userId),
});
}
broadcast("poker-tables", { tables: listSummaries() });
}
function disposeTable(record: PokerTableRecord): void {
clearTurnTimer(record);
clearNextHandTimer(record);
tables.delete(record.id);
broadcast("poker-tables", { tables: listSummaries() });
}
export function forceExpireTable(tableId: string, payload: Payload): void {
const record = tables.get(tableId);
if (!record) throw new Error("Table not found.");
for (const userId of record.seatMap) {
if (userId === null || record.leftUsers.has(userId)) continue;
broadcastToUser("poker-terminated", userId, {
tableId: record.id,
reason: "force-ended",
});
try {
void notifyUser(payload, {
userId,
type: "poker:force-ended",
title: "Poker game ended",
message: "An administrator ended your poker game.",
link: "/minigames/poker",
});
} catch (error) {
payload.logger.error(
`[poker] Failed to notify user ${userId} for table ${record.id}: ${error}`,
);
}
}
disposeTable(record);
}
/** Tables with nobody seated (everyone left) are removed immediately. */
function scheduleDisposalIfEmpty(record: PokerTableRecord): void {
if (seatedCountOf(record) > 0) return;
disposeTable(record);
}
function promoteHost(record: PokerTableRecord): void {
const nextHost = record.seatMap.find((id) => id !== null && !record.leftUsers.has(id));
record.hostUserId = nextHost ?? null;
}
export function createTable(userId: number, displayName: string): string {
if (tables.size >= MAX_TABLES) {
throw new Error("The maximum number of poker tables exists. Try again later.");
}
const seatMap: (number | null)[] = [userId];
while (seatMap.length < MAX_PLAYERS) seatMap.push(null);
const record: PokerTableRecord = {
id: crypto.randomUUID(),
name: null,
version: 1,
hostUserId: userId,
seatMap,
state: null,
inGame: false,
handsWonThisGame: {},
countedHandNumber: null,
turnTimer: null,
turnTimerVersion: null,
turnDeadlineAt: null,
nextHandTimer: null,
nextHandAt: null,
pendingJoins: [],
awayUsers: new Set(),
leftUsers: new Set(),
userNames: new Map([[userId, displayName]]),
createdAt: new Date().toISOString(),
lastActivityAt: Date.now(),
};
tables.set(record.id, record);
pushTable(record);
return record.id;
}
export function listSummaries(): PokerTableSummary[] {
return [...tables.values()].map((record) => ({
tableId: record.id,
name: record.name,
hostName:
record.hostUserId !== null
? (record.userNames.get(record.hostUserId) ?? "Unknown")
: "Unknown",
seatedCount: seatedCountOf(record),
maxSeats: MAX_PLAYERS,
inGame: record.inGame,
createdAt: record.createdAt,
}));
}
export function getSnapshotFor(tableId: string, userId: number): PokerSnapshot {
const record = tables.get(tableId);
if (!record) throw new Error("Table not found.");
record.lastActivityAt = Date.now();
return buildSnapshot(record, userId);
}
export function joinTable(tableId: string, userId: number, displayName: string): PokerSnapshot {
const record = tables.get(tableId);
if (!record) throw new Error("Table not found.");
if (record.leftUsers.has(userId)) {
throw new Error("You already left this table. You can rejoin when a new game starts.");
}
if (isSeatedAt(record, userId)) return buildSnapshot(record, userId);
for (const other of tables.values()) {
if (other.id !== record.id && isSeatedAt(other, userId)) {
throw new Error("You are already seated at another table. Leave it first.");
}
}
const slot = record.seatMap.indexOf(null);
if (slot < 0) throw new Error("The table is full.");
record.seatMap[slot] = userId;
record.userNames.set(userId, displayName);
record.lastActivityAt = Date.now();
if (record.inGame) record.pendingJoins.push({ userId, name: displayName });
scheduleDisposalIfEmpty(record);
record.version += 1;
rekeyTurnTimer(record);
pushTable(record);
return buildSnapshot(record, userId);
}
export function leaveTable(tableId: string, userId: number): void {
const record = tables.get(tableId);
if (!record) throw new Error("Table not found.");
if (!isSeatedAt(record, userId)) throw new Error("You are not seated at this table.");
if (record.inGame) {
// Mid-game: keep the seat visible, forfeit the stack at the next hand.
record.leftUsers.add(userId);
record.awayUsers.add(userId);
} else {
const seatIndex = record.seatMap.indexOf(userId);
record.seatMap = record.seatMap.filter((_, index) => index !== seatIndex);
while (record.seatMap.length < MAX_PLAYERS) record.seatMap.push(null);
record.pendingJoins = record.pendingJoins.filter((pending) => pending.userId !== userId);
}
if (record.hostUserId === userId) promoteHost(record);
record.lastActivityAt = Date.now();
record.version += 1;
rekeyTurnTimer(record);
scheduleDisposalIfEmpty(record);
pushTable(record);
}
export function startGame(tableId: string, userId: number): PokerSnapshot {
const record = tables.get(tableId);
if (!record) throw new Error("Table not found.");
if (record.hostUserId !== userId) throw new Error("Only the host can start the game.");
const seated = record.seatMap.filter(
(id): id is number => id !== null && !record.leftUsers.has(id),
);
if (seated.length < MIN_PLAYERS) {
throw new Error("At least two seated players are needed to start the game.");
}
// Compact the seat map for the new game: leavers are out, order preserved.
// Pad a COPY: aliasing `seated` here once spawned phantom "Player null" seats.
const padded: (number | null)[] = [...seated];
while (padded.length < MAX_PLAYERS) padded.push(null);
record.seatMap = padded;
record.leftUsers.clear();
record.awayUsers.clear();
record.pendingJoins = [];
record.handsWonThisGame = {};
record.countedHandNumber = null;
record.nextHandAt = null;
if (record.nextHandTimer) {
clearTimeout(record.nextHandTimer);
record.nextHandTimer = null;
}
record.state = createTableState(
seated.map((id) => ({ name: record.userNames.get(id) ?? `Player ${id}` })),
{},
undefined,
);
record.inGame = true;
record.lastActivityAt = Date.now();
record.version += 1;
afterMutation(record);
return buildSnapshot(record, userId);
}
export function renameTable(tableId: string, userId: number, rawName: string): PokerSnapshot {
const record = tables.get(tableId);
if (!record) throw new Error("Table not found.");
if (record.hostUserId !== userId) throw new Error("Only the host can rename the table.");
const name = rawName.trim().replace(/\s+/g, " ").slice(0, MAX_TABLE_NAME_LENGTH).trim();
record.name = name.length > 0 ? name : null;
record.lastActivityAt = Date.now();
record.version += 1;
pushTable(record);
return buildSnapshot(record, userId);
}
export function applyPlayerAction(
tableId: string,
userId: number,
kind: PokerActionKind,
amount?: number,
): PokerSnapshot {
const record = tables.get(tableId);
if (!record) throw new Error("Table not found.");
const state = record.state;
if (!state) throw new Error("The game has not started yet.");
if (record.leftUsers.has(userId)) throw new Error("You are not seated at this table.");
const seatIndex = record.seatMap.indexOf(userId);
if (seatIndex < 0) throw new Error("You are not seated at this table.");
if (seatIndex >= state.players.length) {
throw new Error("You will join the game at the next hand.");
}
const result = applyAction(state, seatIndex, kind, amount);
if (!result.success) throw new Error(result.error);
record.state = result.state;
record.lastActivityAt = Date.now();
record.version += 1;
afterMutation(record);
return buildSnapshot(record, userId);
}
/**
* Sweeps tables whose last client interaction is older than TABLE_EXPIRY_MS:
* tabs closed without leaving, abandoned waiting rooms, gone games. Safe to
* run from several module copies in dev because the store is shared via
* globalThis and disposal is idempotent.
*/
function sweepExpiredTables(): void {
const now = Date.now();
let expired = false;
for (const record of tables.values()) {
if (now - record.lastActivityAt <= TABLE_EXPIRY_MS) continue;
disposeTable(record);
expired = true;
}
if (expired) broadcast("poker-tables", { tables: listSummaries() });
}
const sweepInterval = setInterval(sweepExpiredTables, TABLE_SWEEP_INTERVAL_MS);
sweepInterval.unref?.();

View file

@ -0,0 +1,194 @@
/**
* Shared types for the No-Limit Texas Hold'em poker engine.
*
* Every type is plain JSON-serializable data: the whole table state can be
* stored in a registry Map, sent over SSE, and rebuilt on the client without
* any transformation. No Payload, Next.js, or process imports here.
*/
export type Suit = "s" | "h" | "d" | "c";
/** 2 through 10, then 11 = jack, 12 = queen, 13 = king, 14 = ace. */
export type Rank = 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14;
export interface Card {
readonly rank: Rank;
readonly suit: Suit;
}
/** Seat index into `PokerTableState.players`; seatId === array index. */
export type PokerSeatId = number;
export type PokerActionKind = "fold" | "check" | "call" | "bet" | "raise";
export type PokerStreet = "preflop" | "flop" | "turn" | "river";
/**
* Lifecycle of the table:
* - "betting": a hand is in progress and waiting for actions.
* - "showdown": the hand ended with a showdown; results are attached.
* - "hand-complete": the hand ended without a showdown (fold out); results
* are attached but hole cards stay hidden.
* - "game-complete": one player holds every chip; placements are attached.
*/
export type PokerPhase = "betting" | "showdown" | "hand-complete" | "game-complete";
export interface PokerTableConfig {
readonly startingStack: number;
readonly smallBlind: number;
readonly bigBlind: number;
readonly turnTimeoutMs: number;
}
export interface PokerPlayerState {
readonly seatId: PokerSeatId;
readonly name: string;
/** Chips not yet committed; deducted immediately as bets are made. */
readonly stack: number;
/** Chips committed to the current betting street. */
readonly committed: number;
/** Chips committed to the whole hand so far; drives side-pot layering. */
readonly totalCommitted: number;
readonly holeCards: readonly Card[];
readonly folded: boolean;
readonly allIn: boolean;
/** Whether the player has acted during the current betting street. */
readonly hasActed: boolean;
/** Busted players sit out; set when their stack reaches 0. */
readonly eliminated: boolean;
}
export interface PokerTableState {
readonly config: PokerTableConfig;
/**
* Base seed for deck shuffling. Each hand derives its own seed from this
* (see deck.ts), so a game is fully reproducible from the seed alone.
*/
readonly seed: number;
/** 1-based hand counter; 0 means no hand has started yet. */
readonly handNumber: number;
readonly players: readonly PokerPlayerState[];
readonly buttonSeat: PokerSeatId;
readonly smallBlindSeat: PokerSeatId;
readonly bigBlindSeat: PokerSeatId;
readonly street: PokerStreet;
readonly phase: PokerPhase;
/** Chips collected from completed streets; live bets live in `committed`. */
readonly collectedPot: number;
readonly communityCards: readonly Card[];
/** Remaining deck, top of deck first. */
readonly deck: readonly Card[];
/** Highest total commitment on the current street. */
readonly currentBet: number;
/** Minimum raise increment (size of the last full raise, at least one BB). */
readonly minRaise: number;
/**
* currentBet as of the last full raise (or the street's opening bet level).
* A short all-in raise raises currentBet without touching this, which is
* what prevents the raise from reopening action for players who acted.
*/
readonly lastFullRaiseBet: number;
readonly toActSeat: PokerSeatId | null;
readonly showdown: ShowdownOutcome | null;
/** Set once the game completes: tournament placement per seat. */
readonly finalPlacements: readonly PokerGamePlacement[] | null;
}
/** Comparison result of a 5-card poker hand, category first then kickers. */
export interface HandRank {
/** 0 = high card through 8 = straight flush (royal is an ace-high SF). */
readonly category: number;
/** Descending ranks used to break ties within the category. */
readonly tiebreakers: readonly number[];
}
export interface PokerPotResult {
/** 0 is the main pot, higher indexes are side pots. */
readonly potIndex: number;
readonly amount: number;
readonly winnerSeats: readonly PokerSeatId[];
/** Seats whose hands competed for this pot (useful for side-pot labels). */
readonly eligibleSeats: readonly PokerSeatId[];
}
export interface PokerPlayerShowdownResult {
readonly seatId: PokerSeatId;
/** Null when the player folded (or the hand ended without a showdown). */
readonly handRank: HandRank | null;
/** The best 5 cards actually played; null when folded. */
readonly bestHand: readonly Card[] | null;
readonly chipsWon: number;
/** chipsWon minus what the player put in this hand. */
readonly netChange: number;
/** 1 = best hand at showdown; folded players rank below all live hands. */
readonly placement: number;
}
export interface ShowdownOutcome {
readonly pots: readonly PokerPotResult[];
/** One entry per player dealt into the hand (eliminated players excluded). */
readonly results: readonly PokerPlayerShowdownResult[];
}
export interface PokerGamePlacement {
readonly seatId: PokerSeatId;
readonly placement: number;
}
/** What the acting player is allowed to do right now. */
export interface PokerLegalActions {
readonly seatId: PokerSeatId;
readonly canFold: boolean;
readonly canCheck: boolean;
readonly canCall: boolean;
/** Chips needed to call (capped at the player's stack). */
readonly callAmount: number;
readonly canBet: boolean;
/** Total street commitment required to open the betting. */
readonly minBetTo: number;
readonly maxBetTo: number;
readonly canRaise: boolean;
/** Total street commitment required for a legal (non-all-in) raise. */
readonly minRaiseTo: number;
readonly maxRaiseTo: number;
}
export interface PublicPlayerView {
readonly seatId: PokerSeatId;
readonly name: string;
readonly stack: number;
readonly committed: number;
readonly totalCommitted: number;
readonly folded: boolean;
readonly allIn: boolean;
readonly hasActed: boolean;
readonly eliminated: boolean;
/** Populated only for the viewer or for live hands at showdown. */
readonly holeCards: readonly Card[] | null;
readonly cardCount: number;
}
export interface PublicTableView {
readonly players: readonly PublicPlayerView[];
readonly buttonSeat: PokerSeatId;
readonly smallBlindSeat: PokerSeatId;
readonly bigBlindSeat: PokerSeatId;
readonly street: PokerStreet;
readonly phase: PokerPhase;
/** Collected pot plus live street bets. */
readonly potTotal: number;
readonly communityCards: readonly Card[];
readonly currentBet: number;
readonly toActSeat: PokerSeatId | null;
readonly handNumber: number;
readonly config: PokerTableConfig;
readonly showdown: ShowdownOutcome | null;
readonly finalPlacements: readonly PokerGamePlacement[] | null;
}
export interface PrivatePlayerView extends PublicTableView {
readonly viewerSeat: PokerSeatId;
readonly yourHoleCards: readonly Card[];
readonly legalActions: PokerLegalActions;
readonly yourTurn: boolean;
}

View file

@ -0,0 +1,85 @@
/**
* Projection helpers that turn the full table state into safe views.
*
* The full state (including every hole card and the deck order) must never
* leave the server; clients only ever receive these views. Hole cards are
* hidden except for the viewer's own seats and for live hands at showdown.
*/
import { getLegalActions } from "./betting";
import type {
Card,
PokerSeatId,
PokerTableState,
PrivatePlayerView,
PublicPlayerView,
PublicTableView,
} from "./types";
function sumCommitted(state: PokerTableState): number {
return state.players.reduce((total, player) => total + player.committed, 0);
}
function projectPlayer(
state: PokerTableState,
player: PokerTableState["players"][number],
viewerSeat: PokerSeatId | null,
): PublicPlayerView {
const isViewer = viewerSeat !== null && player.seatId === viewerSeat;
const reveal = (state.phase === "showdown" && !player.folded) || isViewer;
const holeCards: readonly Card[] | null = reveal && player.holeCards.length > 0
? player.holeCards
: null;
return {
seatId: player.seatId,
name: player.name,
stack: player.stack,
committed: player.committed,
totalCommitted: player.totalCommitted,
folded: player.folded,
allIn: player.allIn,
hasActed: player.hasActed,
eliminated: player.eliminated,
holeCards,
cardCount: player.holeCards.length,
};
}
/**
* Public view of the table. Pass `viewerSeat` to also reveal that seat's own
* hole cards; pass null for a fully spectator-safe view.
*/
export function buildPublicView(
state: PokerTableState,
viewerSeat: PokerSeatId | null,
): PublicTableView {
return {
players: state.players.map((player) => projectPlayer(state, player, viewerSeat)),
buttonSeat: state.buttonSeat,
smallBlindSeat: state.smallBlindSeat,
bigBlindSeat: state.bigBlindSeat,
street: state.street,
phase: state.phase,
potTotal: state.collectedPot + sumCommitted(state),
communityCards: state.communityCards,
currentBet: state.currentBet,
toActSeat: state.toActSeat,
handNumber: state.handNumber,
config: state.config,
showdown: state.showdown,
finalPlacements: state.finalPlacements,
};
}
/** Everything one seated player is allowed to know, including their options. */
export function buildPrivateView(state: PokerTableState, seatId: PokerSeatId): PrivatePlayerView {
const viewer = state.players[seatId];
const publicView = buildPublicView(state, seatId);
return {
...publicView,
viewerSeat: seatId,
yourHoleCards: viewer?.holeCards ?? [],
legalActions: getLegalActions(state, seatId),
yourTurn: state.phase === "betting" && state.toActSeat === seatId,
};
}

View file

@ -0,0 +1,764 @@
/**
* 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);
});
});

View file

@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { describeHandStrength } from "@/lib/minigames/poker/handHint";
import type { Card, Rank, Suit } from "@/lib/minigames/poker";
const c = (rank: Rank, suit: Suit): Card => ({ rank, suit });
describe("poker hand strength hints", () => {
it("describes a preflop pair", () => {
expect(describeHandStrength([c(13, "s"), c(13, "h")], [])).toEqual({
label: "Pair of Kings",
bestCardKeys: ["13-s", "13-h"],
});
});
it("describes a preflop high card", () => {
expect(describeHandStrength([c(14, "s"), c(9, "h")], [])).toEqual({
label: "High card Ace",
bestCardKeys: ["14-s"],
});
});
it("describes flop two pair and returns the exact best cards", () => {
const hint = describeHandStrength([c(13, "s"), c(9, "h")], [c(13, "d"), c(9, "c"), c(2, "s")]);
expect(hint?.label).toBe("Two pair, Kings and Nines");
expect(new Set(hint?.bestCardKeys)).toEqual(new Set(["13-s", "9-h", "13-d", "9-c", "2-s"]));
});
it("describes a river straight that uses one hole card", () => {
const hint = describeHandStrength(
[c(6, "s"), c(14, "h")],
[c(2, "c"), c(3, "d"), c(4, "h"), c(5, "s"), c(13, "c")],
);
expect(hint?.label).toBe("Straight, Six high");
expect(new Set(hint?.bestCardKeys)).toEqual(new Set(["6-s", "2-c", "3-d", "4-h", "5-s"]));
});
it("labels a wheel straight as five high", () => {
expect(
describeHandStrength(
[c(14, "s"), c(13, "h")],
[c(2, "c"), c(3, "d"), c(4, "h"), c(5, "s"), c(9, "c")],
)?.label,
).toBe("Straight, Five high");
});
});

View file

@ -0,0 +1,169 @@
import { describe, expect, it } from "vitest";
import {
computePokerStatsDelta,
readPokerStats,
xpForPlacement,
type PokerPlacementResult,
type PokerStats,
} from "@/lib/minigames/poker/stats";
function result(overrides: Partial<PokerPlacementResult> = {}): PokerPlacementResult {
return { userId: 1, placement: 1, finalChips: 5000, handsWon: 3, ...overrides };
}
function current(overrides: Partial<PokerStats> = {}): PokerStats {
return {
gamesPlayed: 0,
wins: 0,
secondPlaces: 0,
thirdPlaces: 0,
fourthPlaces: 0,
handsWon: 0,
bestScore: 0,
xpEarned: 0,
...overrides,
};
}
describe("xpForPlacement", () => {
it("scales 1st place at 40x the player count", () => {
expect(xpForPlacement(1, 2)).toBe(80);
expect(xpForPlacement(1, 3)).toBe(120);
expect(xpForPlacement(1, 4)).toBe(160);
});
it("scales 2nd place at 20x the player count", () => {
expect(xpForPlacement(2, 2)).toBe(40);
expect(xpForPlacement(2, 3)).toBe(60);
expect(xpForPlacement(2, 4)).toBe(80);
});
it("scales 3rd place at 10x the player count", () => {
expect(xpForPlacement(3, 2)).toBe(20);
expect(xpForPlacement(3, 3)).toBe(30);
expect(xpForPlacement(3, 4)).toBe(40);
});
it("scales 4th place at 5x the player count", () => {
expect(xpForPlacement(4, 2)).toBe(10);
expect(xpForPlacement(4, 3)).toBe(15);
expect(xpForPlacement(4, 4)).toBe(20);
});
});
describe("computePokerStatsDelta", () => {
it("increments gamesPlayed by exactly one", () => {
const next = computePokerStatsDelta(current({ gamesPlayed: 7 }), result(), 4);
expect(next.gamesPlayed).toBe(8);
});
it("increments the wins counter for 1st place only", () => {
const next = computePokerStatsDelta(current({ wins: 2 }), result({ placement: 1 }), 4);
expect(next.wins).toBe(3);
expect(next.secondPlaces).toBe(0);
expect(next.thirdPlaces).toBe(0);
expect(next.fourthPlaces).toBe(0);
});
it("increments the secondPlaces counter for 2nd place only", () => {
const next = computePokerStatsDelta(
current({ secondPlaces: 1 }),
result({ placement: 2 }),
4,
);
expect(next.secondPlaces).toBe(2);
expect(next.wins).toBe(0);
expect(next.thirdPlaces).toBe(0);
expect(next.fourthPlaces).toBe(0);
});
it("increments the thirdPlaces counter for 3rd place only", () => {
const next = computePokerStatsDelta(
current({ thirdPlaces: 4 }),
result({ placement: 3 }),
4,
);
expect(next.thirdPlaces).toBe(5);
expect(next.wins).toBe(0);
expect(next.secondPlaces).toBe(0);
expect(next.fourthPlaces).toBe(0);
});
it("increments the fourthPlaces counter for 4th place only", () => {
const next = computePokerStatsDelta(
current({ fourthPlaces: 1 }),
result({ placement: 4 }),
4,
);
expect(next.fourthPlaces).toBe(2);
expect(next.wins).toBe(0);
expect(next.secondPlaces).toBe(0);
expect(next.thirdPlaces).toBe(0);
});
it("accumulates handsWon on top of the existing total", () => {
const next = computePokerStatsDelta(
current({ handsWon: 10 }),
result({ handsWon: 4 }),
4,
);
expect(next.handsWon).toBe(14);
});
it("keeps bestScore when the final chip total is lower", () => {
const next = computePokerStatsDelta(
current({ bestScore: 9000 }),
result({ finalChips: 5000 }),
4,
);
expect(next.bestScore).toBe(9000);
});
it("raises bestScore when the final chip total is higher", () => {
const next = computePokerStatsDelta(
current({ bestScore: 3000 }),
result({ finalChips: 7500 }),
4,
);
expect(next.bestScore).toBe(7500);
});
it("adds the placement XP to the existing xpEarned total", () => {
const next = computePokerStatsDelta(
current({ xpEarned: 100 }),
result({ placement: 2 }),
3,
);
expect(next.xpEarned).toBe(160);
});
it("does not mutate the current stats object", () => {
const before = current({ gamesPlayed: 1, wins: 1, xpEarned: 160 });
computePokerStatsDelta(before, result(), 4);
expect(before).toEqual(current({ gamesPlayed: 1, wins: 1, xpEarned: 160 }));
});
});
describe("readPokerStats", () => {
it("reads poker stats from a profile that also carries sibling game stats", () => {
const profile = {
progression: {
minigames: {
flappyBird: { gamesPlayed: 12, xpEarned: 40 },
poker: { gamesPlayed: 3, wins: 1, xpEarned: 200 },
},
},
};
expect(readPokerStats(profile)).toEqual(
current({ gamesPlayed: 3, wins: 1, xpEarned: 200 }),
);
});
it("returns all zeros when the poker group is missing", () => {
expect(readPokerStats({ progression: { minigames: {} } })).toEqual(current());
});
it("returns all zeros for a nullish profile", () => {
expect(readPokerStats(undefined)).toEqual(current());
});
});

View file

@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { createTable, joinTable, startGame } from "@/lib/minigames/poker/tables";
/**
* Regression tests for the registry's game-start path. Guards against the
* seat-map aliasing bug that once spawned phantom "Player null" engine seats
* when a table started with fewer than four players.
*/
describe("poker table registry: startGame", () => {
it("starts a heads-up game with exactly the two seated players", () => {
const tableId = createTable(9001, "Host");
joinTable(tableId, 9002, "Guest");
const snapshot = startGame(tableId, 9001);
const seatedIds = snapshot.seats.filter((id) => id !== null);
expect(seatedIds).toHaveLength(2);
const players = snapshot.you?.players ?? [];
expect(players).toHaveLength(2);
for (const player of players) {
expect(player.name).not.toBe("Player null");
expect(player.name.length).toBeGreaterThan(0);
expect(player.eliminated).toBe(false);
}
});
it("starts a three-handed game with exactly the three seated players", () => {
const tableId = createTable(9101, "Host");
joinTable(tableId, 9102, "Second");
joinTable(tableId, 9103, "Third");
const snapshot = startGame(tableId, 9101);
const seatedIds = snapshot.seats.filter((id) => id !== null);
expect(seatedIds).toHaveLength(3);
const players = snapshot.you?.players ?? [];
expect(players).toHaveLength(3);
const names = players.map((player) => player.name);
expect(names).not.toContain("Player null");
expect(new Set(names).size).toBe(3);
});
it("keeps every engine player backed by a real seated user", () => {
const tableId = createTable(9201, "Host");
joinTable(tableId, 9202, "Second");
const snapshot = startGame(tableId, 9201);
const players = snapshot.you?.players ?? [];
for (const player of players) {
const userId = snapshot.seats[player.seatId];
expect(userId).not.toBeNull();
expect(typeof userId).toBe("number");
}
});
});