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