Poker lib layer: engine, tables, stats, hand setup, shared table names, with engine tests. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
263 lines
7.7 KiB
TypeScript
263 lines
7.7 KiB
TypeScript
/**
|
|
* 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 {
|
|
Card,
|
|
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];
|
|
let queuedBoard = [...state.queuedBoard];
|
|
while (community.length < 5) {
|
|
const nextCard = queuedBoard.shift() ?? deck.shift();
|
|
if (!nextCard) break;
|
|
community.push(nextCard);
|
|
}
|
|
const withBoard: PokerTableState = {
|
|
...state,
|
|
communityCards: community,
|
|
deck,
|
|
queuedBoard,
|
|
street: "river",
|
|
toActSeat: null,
|
|
};
|
|
return resolveAndPay(withBoard);
|
|
}
|
|
|
|
/** Draw `count` board cards: dev-queued cards first, then the shuffled deck. */
|
|
function drawBoardCards(
|
|
state: PokerTableState,
|
|
count: number,
|
|
): { cards: Card[]; queuedBoard: readonly Card[]; deck: readonly Card[] } {
|
|
const fromQueue = state.queuedBoard.slice(0, count);
|
|
const fromDeck = state.deck.slice(0, count - fromQueue.length);
|
|
return {
|
|
cards: [...fromQueue, ...fromDeck],
|
|
queuedBoard: state.queuedBoard.slice(fromQueue.length),
|
|
deck: state.deck.slice(fromDeck.length),
|
|
};
|
|
}
|
|
|
|
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;
|
|
const draw = drawBoardCards(next, cardsToDeal);
|
|
return {
|
|
...next,
|
|
street,
|
|
communityCards: [...next.communityCards, ...draw.cards],
|
|
deck: draw.deck,
|
|
queuedBoard: draw.queuedBoard,
|
|
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,
|
|
queuedBoard: [],
|
|
queuedHoles: {},
|
|
};
|
|
// 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);
|
|
}
|