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:
parent
91bdd433d5
commit
6968ee0abc
19 changed files with 3300 additions and 0 deletions
146
src/lib/minigames/poker/actions.ts
Normal file
146
src/lib/minigames/poker/actions.ts
Normal 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 };
|
||||
}
|
||||
187
src/lib/minigames/poker/betting.ts
Normal file
187
src/lib/minigames/poker/betting.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
24
src/lib/minigames/poker/constants.ts
Normal file
24
src/lib/minigames/poker/constants.ts
Normal 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;
|
||||
60
src/lib/minigames/poker/deck.ts
Normal file
60
src/lib/minigames/poker/deck.ts
Normal 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)));
|
||||
}
|
||||
235
src/lib/minigames/poker/engine.ts
Normal file
235
src/lib/minigames/poker/engine.ts
Normal 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);
|
||||
}
|
||||
133
src/lib/minigames/poker/evaluator.ts
Normal file
133
src/lib/minigames/poker/evaluator.ts
Normal 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;
|
||||
}
|
||||
97
src/lib/minigames/poker/handHint.ts
Normal file
97
src/lib/minigames/poker/handHint.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
153
src/lib/minigames/poker/handSetup.ts
Normal file
153
src/lib/minigames/poker/handSetup.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
17
src/lib/minigames/poker/index.ts
Normal file
17
src/lib/minigames/poker/index.ts
Normal 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";
|
||||
62
src/lib/minigames/poker/pots.ts
Normal file
62
src/lib/minigames/poker/pots.ts
Normal 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;
|
||||
}
|
||||
116
src/lib/minigames/poker/showdown.ts
Normal file
116
src/lib/minigames/poker/showdown.ts
Normal 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 };
|
||||
}
|
||||
174
src/lib/minigames/poker/stats.ts
Normal file
174
src/lib/minigames/poker/stats.ts
Normal 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 };
|
||||
}
|
||||
579
src/lib/minigames/poker/tables.ts
Normal file
579
src/lib/minigames/poker/tables.ts
Normal 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?.();
|
||||
194
src/lib/minigames/poker/types.ts
Normal file
194
src/lib/minigames/poker/types.ts
Normal 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;
|
||||
}
|
||||
85
src/lib/minigames/poker/views.ts
Normal file
85
src/lib/minigames/poker/views.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
764
tests/int/poker-engine.int.spec.ts
Normal file
764
tests/int/poker-engine.int.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
47
tests/int/poker-hand-hint.int.spec.ts
Normal file
47
tests/int/poker-hand-hint.int.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
169
tests/int/poker-stats.int.spec.ts
Normal file
169
tests/int/poker-stats.int.spec.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
58
tests/int/poker-tables.int.spec.ts
Normal file
58
tests/int/poker-tables.int.spec.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue