1
0
Fork 0
polaris-task-force/src/lib/minigames/poker/engine.ts
Z8MB1E c5defa9e70 feat(poker): engine upgrades, named tables, and stat extensions
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>
2026-09-14 15:37:19 -04:00

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);
}