1
0
Fork 0

feat(poker): engine upgrades, named tables, and stat extensions

Poker lib layer: engine, tables, stats, hand setup, shared table names, with engine tests.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-14 15:37:19 -04:00
parent 4d08122464
commit c5defa9e70
9 changed files with 572 additions and 50 deletions

View file

@ -6,6 +6,13 @@
/** Play chips each player starts a game with. */
export const STARTING_STACK = 1000;
/**
* Chips each player must buy in with from their persistent chip pool when a
* game starts. The buy-in returns to the pool (plus or minus winnings) when
* the game settles.
*/
export const BUY_IN = STARTING_STACK;
export const SMALL_BLIND = 10;
export const BIG_BLIND = 20;

View file

@ -16,10 +16,18 @@ import {
postflopFirstToAct,
sumCommitted,
} from "./betting";
import { BIG_BLIND, MAX_PLAYERS, MIN_PLAYERS, SMALL_BLIND, STARTING_STACK, TURN_TIMEOUT_MS } from "./constants";
import {
BIG_BLIND,
MAX_PLAYERS,
MIN_PLAYERS,
SMALL_BLIND,
STARTING_STACK,
TURN_TIMEOUT_MS,
} from "./constants";
import { startHand } from "./handSetup";
import { resolveShowdown } from "./showdown";
import type {
Card,
PokerPlayerShowdownResult,
PokerPlayerState,
PokerSeatId,
@ -110,21 +118,37 @@ function resolveAndPay(state: PokerTableState): PokerTableState {
function runOutAndShowdown(state: PokerTableState): PokerTableState {
let community = [...state.communityCards];
let deck = [...state.deck];
let queuedBoard = [...state.queuedBoard];
while (community.length < 5) {
const toDeal = community.length === 0 ? 3 : 1;
community = [...community, ...deck.slice(0, toDeal)];
deck = deck.slice(toDeal);
const nextCard = queuedBoard.shift() ?? deck.shift();
if (!nextCard) break;
community.push(nextCard);
}
const withBoard: PokerTableState = {
...state,
communityCards: community,
deck,
queuedBoard,
street: "river",
toActSeat: null,
};
return resolveAndPay(withBoard);
}
/** Draw `count` board cards: dev-queued cards first, then the shuffled deck. */
function drawBoardCards(
state: PokerTableState,
count: number,
): { cards: Card[]; queuedBoard: readonly Card[]; deck: readonly Card[] } {
const fromQueue = state.queuedBoard.slice(0, count);
const fromDeck = state.deck.slice(0, count - fromQueue.length);
return {
cards: [...fromQueue, ...fromDeck],
queuedBoard: state.queuedBoard.slice(fromQueue.length),
deck: state.deck.slice(fromDeck.length),
};
}
const STREET_ORDER: readonly PokerStreet[] = ["preflop", "flop", "turn", "river"];
/**
@ -167,11 +191,13 @@ export function advanceState(state: PokerTableState): PokerTableState {
const streetIndex = STREET_ORDER.indexOf(next.street);
const street = STREET_ORDER[streetIndex + 1] ?? "river";
const cardsToDeal = street === "flop" ? 3 : 1;
const draw = drawBoardCards(next, cardsToDeal);
return {
...next,
street,
communityCards: [...next.communityCards, ...next.deck.slice(0, cardsToDeal)],
deck: next.deck.slice(cardsToDeal),
communityCards: [...next.communityCards, ...draw.cards],
deck: draw.deck,
queuedBoard: draw.queuedBoard,
toActSeat: postflopFirstToAct(next),
};
}
@ -228,6 +254,8 @@ export function createTableState(
toActSeat: null,
showdown: null,
finalPlacements: null,
queuedBoard: [],
queuedHoles: {},
};
// Hand 1 starts immediately: the "hand-complete" resting phase makes
// advanceState deal it, which also moves the button to the first seat.

View file

@ -120,7 +120,16 @@ export function startHand(state: PokerTableState): PokerTableState {
});
const dealt = dealHoleCards(reset, buttonSeat, createShuffledDeck(state.seed, handNumber));
const afterBlinds = dealt.players.map((player) => {
// Dev hole queues replace a dealt player's two cards for this hand only.
const queuedHoles = state.queuedHoles ?? {};
const players = dealt.players.map((player) => {
const queued = queuedHoles[player.seatId];
if (queued && queued.length === 2 && player.holeCards.length === 2) {
return { ...player, holeCards: [...queued] };
}
return player;
});
const afterBlinds = 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;
@ -149,5 +158,6 @@ export function startHand(state: PokerTableState): PokerTableState {
toActSeat,
showdown: null,
finalPlacements: null,
queuedHoles: {},
};
}

View file

@ -136,6 +136,11 @@ export async function awardPokerGameResult(
: { ...delta, xpEarned: current.xpEarned };
const progression = (profile.progression ?? {}) as Record<string, unknown>;
const minigames = (progression.minigames ?? {}) as Record<string, unknown>;
// The final stack returns to the player's persistent chip pool: the
// table only moves chips between the pool and the felt, never destroys
// them.
const chipBank =
Math.max(0, ((progression.chipBank as number | undefined) ?? 0) + result.finalChips);
await payload.update({
collection: "profiles",
@ -143,6 +148,7 @@ export async function awardPokerGameResult(
data: {
progression: {
...progression,
chipBank,
minigames: {
...minigames,
poker: next,
@ -153,13 +159,12 @@ export async function awardPokerGameResult(
depth: 0,
});
if (xp > 0) {
broadcastToUser("profile-update", result.userId, {
reason: "xp",
xpGained: xp,
profileId: profile.id,
});
}
broadcastToUser("profile-update", result.userId, {
reason: "xp",
xpGained: xp,
chipBank,
profileId: profile.id,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
payload.logger.error(

View file

@ -21,9 +21,12 @@ import { getPayload } from "payload";
import type { Payload } from "payload";
import { broadcast, broadcastToUser } from "@/lib/realtime/bus";
import { notifyUser } from "@/lib/notifications";
import { creditChips, readChipBankState, spendChipBuyIn } from "@/lib/minigames/chips";
import { generateTableName } from "@/lib/minigames/tableNames";
import { applyAction } from "./actions";
import { getLegalActions } from "./betting";
import {
BUY_IN,
MAX_PLAYERS,
MAX_TABLE_NAME_LENGTH,
MIN_PLAYERS,
@ -33,6 +36,7 @@ import {
import { advanceState, awardFoldOut, createTableState } from "./engine";
import { awardPokerGameResult, type PokerPlacementResult } from "./stats";
import type {
Card,
PokerActionKind,
PokerPlayerState,
PokerSeatId,
@ -75,6 +79,10 @@ export interface PokerSnapshot {
/** seatId to userId (null = empty seat); lets the UI map names and hosts. */
seats: (number | null)[];
hostSeatId: number | null;
/** True when the viewer is watching without a seat (you is a spectator view). */
spectator?: boolean;
/** Whether a game is currently running (useful for viewers without a seat). */
inGame?: boolean;
}
interface PendingJoin {
@ -91,6 +99,12 @@ interface PokerTableRecord {
seatMap: (number | null)[];
state: PokerTableState | null;
inGame: boolean;
/** Users whose buy-in is currently staked on the table (pool deducted). */
chipBoughtIn: Set<number>;
/** Users whose chips have been settled back to their pool (idempotency). */
chipSettled: Set<number>;
/** Pending joiners who already paid the buy-in and wait to seat next hand. */
pendingChipPaid: Set<number>;
handsWonThisGame: Record<number, number>;
/** handNumber already counted into handsWonThisGame (prevents double counts). */
countedHandNumber: number | null;
@ -104,6 +118,8 @@ interface PokerTableRecord {
awayUsers: Set<number>;
/** Users who left mid-game; kept in seatMap so the UI can show "left". */
leftUsers: Set<number>;
/** Users watching the running game without a seat. */
spectators: Set<number>;
userNames: Map<number, string>;
createdAt: string;
/** Last client interaction (poll, action, join/leave/start); drives expiry. */
@ -193,6 +209,11 @@ function onNextHandTimeout(record: PokerTableRecord): void {
if (!current) return;
let next = current;
for (const pending of record.pendingJoins) {
// Paid pending joiners bring their buy-in into the engine now.
if (record.pendingChipPaid.has(pending.userId)) {
record.pendingChipPaid.delete(pending.userId);
record.chipBoughtIn.add(pending.userId);
}
next = {
...next,
players: [...next.players, createFreshPlayer(next.players.length, pending.name)],
@ -210,7 +231,7 @@ function createFreshPlayer(seatId: PokerSeatId, name: string): PokerPlayerState
return {
seatId,
name,
stack: STARTING_STACK,
stack: BUY_IN,
committed: 0,
totalCommitted: 0,
holeCards: [],
@ -228,6 +249,8 @@ function eliminateLeavers(state: PokerTableState, record: PokerTableRecord): Pok
const userId = record.seatMap[index];
if (userId === null || !record.leftUsers.has(userId) || player.eliminated) return player;
changed = true;
// Forfeited stacks stay burned: a mid-game leaver's buy-in is not returned.
record.chipSettled.add(userId);
return {
...player,
eliminated: true,
@ -305,6 +328,9 @@ async function awardGameResults(record: PokerTableRecord): Promise<void> {
const userId = record.seatMap[placement.seatId];
const player = state.players[placement.seatId];
if (userId === null || !player) continue;
// Settled up front: awardPokerGameResult credits the final stack back to
// the player's pool inside its profile update.
record.chipSettled.add(userId);
results.push({
userId,
placement: placement.placement,
@ -346,9 +372,16 @@ function afterMutation(record: PokerTableRecord): void {
function buildSnapshot(record: PokerTableRecord, userId: number): PokerSnapshot {
const seatIndex = record.seatMap.indexOf(userId);
const seated = seatIndex >= 0 && !record.leftUsers.has(userId);
const spectator = !seated && record.spectators.has(userId);
const state = record.state;
const inEngine = seated && state !== null && seatIndex < state.players.length;
const base = state && inEngine ? buildPrivateView(state, seatIndex) : null;
let base = state && inEngine ? buildPrivateView(state, seatIndex) : null;
if (base === null && spectator && state) {
// Spectators receive the public view with no seat: hole cards stay hidden
// except for live showdown reveals, and the synthetic viewer seat (-1)
// never matches a real player, so all seats render as opponents.
base = buildPrivateView(state, -1);
}
const you =
base === null
? null
@ -370,10 +403,12 @@ function buildSnapshot(record: PokerTableRecord, userId: number): PokerSnapshot
you,
seats: [...record.seatMap],
hostSeatId: hostSeat >= 0 ? hostSeat : null,
spectator: spectator || undefined,
inGame: record.inGame || undefined,
};
}
/** Push a per-user snapshot to every seated user, then refresh the lobby. */
/** Push a per-user snapshot to every seated user and spectator, then refresh the lobby. */
function pushTable(record: PokerTableRecord): void {
for (const userId of record.seatMap) {
if (userId === null || record.leftUsers.has(userId)) continue;
@ -382,16 +417,193 @@ function pushTable(record: PokerTableRecord): void {
snapshot: buildSnapshot(record, userId),
});
}
for (const spectatorId of record.spectators) {
broadcastToUser("poker-state", spectatorId, {
tableId: record.id,
snapshot: buildSnapshot(record, spectatorId),
});
}
broadcast("poker-tables", { tables: listSummaries() });
}
function disposeTable(record: PokerTableRecord): void {
clearTurnTimer(record);
clearNextHandTimer(record);
void settleUnsettledChips(record);
tables.delete(record.id);
broadcast("poker-tables", { tables: listSummaries() });
}
/** Payload handle, or null when the config cannot load (tests, early boot). */
async function getPayloadOrNull(): Promise<Payload | null> {
try {
const config = (await import("@payload-config")).default;
return await getPayload({ config });
} catch {
return null;
}
}
/**
* Credit the current stack (or an unseated pending joiner's full buy-in)
* back to the pool for every bought-in player not yet settled. Marks each
* user settled synchronously so concurrent teardowns cannot double-credit.
*/
async function settleUnsettledChips(record: PokerTableRecord): Promise<void> {
const candidates = [...record.chipBoughtIn, ...record.pendingChipPaid];
const state = record.state;
const credits: { userId: number; amount: number }[] = [];
for (const userId of candidates) {
if (record.chipSettled.has(userId)) continue;
const seatIndex = record.seatMap.indexOf(userId);
const player = state && seatIndex >= 0 ? state.players[seatIndex] : undefined;
const amount = player ? player.stack : BUY_IN;
record.chipSettled.add(userId);
credits.push({ userId, amount });
}
if (credits.length === 0) return;
const payload = await getPayloadOrNull();
if (!payload) return;
for (const credit of credits) {
if (credit.amount <= 0) continue;
try {
await creditChips(payload, credit.userId, credit.amount);
} catch (error) {
payload.logger.error(`[poker] Failed to settle chips for user ${credit.userId}: ${error}`);
}
}
}
/** In-app bell notification when a pending joiner cannot cover the buy-in. */
async function notifyBuyInFailed(record: PokerTableRecord, userId: number): Promise<void> {
try {
const payload = await getPayloadOrNull();
if (!payload) return;
await notifyUser(payload, {
userId,
type: "poker:buy-in-failed",
title: "Not enough chips",
message: `You need ${BUY_IN} chips to join the next poker hand. Play the other minigames to earn more chips.`,
link: "/minigames/poker",
});
} catch {
// Notifications are best-effort.
}
}
/**
* Buy-in-aware wrappers used by the server actions. The sync core above
* (startGame/joinTable/leaveTable) stays registry-pure for tests; pool
* accounting lives here where a Payload handle is available.
*/
/**
* Start a game: settle any unsettled chips from a previous game on this
* record, collect the buy-in from every seated player (all-or-nothing), then
* start. Throws without moving chips when anyone cannot cover.
*/
export async function startGameWithBuyIn(tableId: string, userId: number): Promise<PokerSnapshot> {
const record = tables.get(tableId);
if (!record) throw new Error("Table not found.");
await settleUnsettledChips(record);
const seated = record.seatMap.filter(
(id): id is number => id !== null && !record.leftUsers.has(id),
);
const payload = await getPayloadOrNull();
const paid: number[] = [];
try {
if (payload) {
for (const seatedId of seated) {
const bank = await readChipBankState(payload, seatedId);
if (!bank) continue; // Untracked user (no profile): plays without pool accounting.
if (bank.chipBank < BUY_IN) {
const name = record.userNames.get(seatedId) ?? `Player ${seatedId}`;
throw new Error(
`${name} needs ${BUY_IN} chips to sit down and has ${bank.chipBank}. Play the other minigames to earn more chips.`,
);
}
}
for (const seatedId of seated) {
const result = await spendChipBuyIn(payload, seatedId, BUY_IN);
if (result.settled) paid.push(seatedId);
}
}
const snapshot = startGame(tableId, userId);
record.chipBoughtIn = new Set(paid);
record.chipSettled = new Set();
record.pendingChipPaid = new Set();
return snapshot;
} catch (error) {
if (paid.length > 0 && payload) {
for (const paidId of paid) {
try {
await creditChips(payload, paidId, BUY_IN);
} catch {
// Best-effort refund.
}
}
}
throw error;
}
}
/**
* Join a table: mid-game joins pay the buy-in up front (pendingChipPaid) so
* they can seat at the next hand; the payment refunds if the join fails.
*/
export async function joinTableWithBuyIn(
tableId: string,
userId: number,
displayName: string,
): Promise<PokerSnapshot> {
const record = tables.get(tableId);
if (!record) throw new Error("Table not found.");
if (record.inGame && !record.pendingChipPaid.has(userId)) {
const payload = await getPayloadOrNull();
if (payload) {
const bank = await readChipBankState(payload, userId);
if (bank && bank.chipBank < BUY_IN) {
throw new Error(
`You need ${BUY_IN} chips to join this table mid-game and have ${bank.chipBank}. Play the other minigames to earn more chips.`,
);
}
if (bank) {
await spendChipBuyIn(payload, userId, BUY_IN);
record.pendingChipPaid.add(userId);
try {
return joinTable(tableId, userId, displayName);
} catch (error) {
record.pendingChipPaid.delete(userId);
try {
await creditChips(payload, userId, BUY_IN);
} catch {
// Best-effort refund.
}
throw error;
}
}
}
}
return joinTable(tableId, userId, displayName);
}
/** Leave a table: pending joiners who paid but never seated get their buy-in back. */
export async function leaveTableWithBuyIn(tableId: string, userId: number): Promise<void> {
const record = tables.get(tableId);
if (!record) throw new Error("Table not found.");
const wasPending = record.pendingChipPaid.has(userId);
leaveTable(tableId, userId);
if (!wasPending) return;
record.pendingChipPaid.delete(userId);
const payload = await getPayloadOrNull();
if (!payload) return;
try {
await creditChips(payload, userId, BUY_IN);
} catch (error) {
payload.logger.error(`[poker] Failed to refund pending buy-in for user ${userId}: ${error}`);
}
}
export function forceExpireTable(tableId: string, payload: Payload): void {
const record = tables.get(tableId);
if (!record) throw new Error("Table not found.");
@ -441,12 +653,15 @@ export function createTable(userId: number, displayName: string): string {
while (seatMap.length < MAX_PLAYERS) seatMap.push(null);
const record: PokerTableRecord = {
id: crypto.randomUUID(),
name: null,
name: generateTableName(),
version: 1,
hostUserId: userId,
seatMap,
state: null,
inGame: false,
chipBoughtIn: new Set(),
chipSettled: new Set(),
pendingChipPaid: new Set(),
handsWonThisGame: {},
countedHandNumber: null,
turnTimer: null,
@ -457,6 +672,7 @@ export function createTable(userId: number, displayName: string): string {
pendingJoins: [],
awayUsers: new Set(),
leftUsers: new Set(),
spectators: new Set(),
userNames: new Map([[userId, displayName]]),
createdAt: new Date().toISOString(),
lastActivityAt: Date.now(),
@ -502,6 +718,7 @@ export function joinTable(tableId: string, userId: number, displayName: string):
}
const slot = record.seatMap.indexOf(null);
if (slot < 0) throw new Error("The table is full.");
record.spectators.delete(userId);
record.seatMap[slot] = userId;
record.userNames.set(userId, displayName);
record.lastActivityAt = Date.now();
@ -513,9 +730,27 @@ export function joinTable(tableId: string, userId: number, displayName: string):
return buildSnapshot(record, userId);
}
/** Register a spectator on a running game; they get the public view with no seat. */
export function spectateTable(tableId: string, userId: number): PokerSnapshot {
const record = tables.get(tableId);
if (!record) throw new Error("Table not found.");
if (isSeatedAt(record, userId)) return buildSnapshot(record, userId);
if (!record.inGame || !record.state) {
throw new Error("No game is running at this table.");
}
record.spectators.add(userId);
record.lastActivityAt = Date.now();
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 (record.spectators.delete(userId)) {
// Spectators just stop watching; no seat logic applies.
record.lastActivityAt = Date.now();
return;
}
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.
@ -587,6 +822,102 @@ export function renameTable(tableId: string, userId: number, rawName: string): P
return buildSnapshot(record, userId);
}
const VALID_SUITS = new Set(["s", "h", "d", "c"]);
function isQueuableCard(card: unknown): card is Card {
return (
typeof card === "object" &&
card !== null &&
"rank" in card &&
"suit" in card &&
typeof (card as Card).rank === "number" &&
Number.isInteger((card as Card).rank) &&
(card as Card).rank >= 2 &&
(card as Card).rank <= 14 &&
typeof (card as Card).suit === "string" &&
VALID_SUITS.has((card as Card).suit)
);
}
function devStateOrThrow(
record: PokerTableRecord,
cards: unknown,
maxLength: number,
): PokerTableState {
const state = record.state;
if (!state) throw new Error("The game has not started yet.");
if (!Array.isArray(cards) || cards.length === 0 || cards.length > maxLength) {
throw new Error(`Queue must contain 1 to ${maxLength} cards.`);
}
if (!cards.every(isQueuableCard)) throw new Error("Invalid card in queue.");
return state;
}
/** Dev controls: queue the next community cards (first card hits the board first). */
export function setBoardQueue(tableId: string, userId: number, cards: Card[]): PokerSnapshot {
const record = tables.get(tableId);
if (!record) throw new Error("Table not found.");
const state = devStateOrThrow(record, cards, 5);
if (state.communityCards.length + cards.length > 5) {
throw new Error("Board queue exceeds the remaining community cards.");
}
record.state = { ...state, queuedBoard: [...cards] };
record.lastActivityAt = Date.now();
record.version += 1;
afterMutation(record);
return buildSnapshot(record, userId);
}
/** Dev controls: queue the two hole cards a seat receives at the next hand. */
export function setHoleQueue(
tableId: string,
userId: number,
seatId: number,
cards: Card[],
): PokerSnapshot {
const record = tables.get(tableId);
if (!record) throw new Error("Table not found.");
const state = devStateOrThrow(record, cards, 2);
if (!state.players[seatId] || state.players[seatId].eliminated) {
throw new Error("No such player at the table.");
}
record.state = {
...state,
queuedHoles: { ...state.queuedHoles, [seatId]: [...cards] },
};
record.lastActivityAt = Date.now();
record.version += 1;
afterMutation(record);
return buildSnapshot(record, userId);
}
/** Dev controls: add (or, with a negative delta, remove) a player's chips. */
export function adjustStack(
tableId: string,
userId: number,
seatId: number,
delta: 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.");
const player = state.players[seatId];
if (!player || player.eliminated) throw new Error("No such player at the table.");
if (!Number.isInteger(delta) || delta === 0) {
throw new Error("Adjustment must be a non-zero whole number.");
}
const nextStack = Math.max(0, player.stack + delta);
record.state = {
...state,
players: state.players.map((p) => (p.seatId === seatId ? { ...p, stack: nextStack } : p)),
};
record.lastActivityAt = Date.now();
record.version += 1;
afterMutation(record);
return buildSnapshot(record, userId);
}
export function applyPlayerAction(
tableId: string,
userId: number,

View file

@ -92,6 +92,10 @@ export interface PokerTableState {
readonly showdown: ShowdownOutcome | null;
/** Set once the game completes: tournament placement per seat. */
readonly finalPlacements: readonly PokerGamePlacement[] | null;
/** Dev-queued community cards; the next board draws consume these first. */
readonly queuedBoard: readonly Card[];
/** Dev-queued hole cards per seat, dealt to that seat at the next hand start. */
readonly queuedHoles: Readonly<Record<number, readonly Card[]>>;
}
/** Comparison result of a 5-card poker hand, category first then kickers. */

View file

@ -0,0 +1,67 @@
/**
* Human-readable default table names for the multiplayer card games.
* Pure module (client-safe, injectable rng): tables get a codename like
* "Velvet Meridian" at creation instead of a raw UUID; hosts can still
* rename them afterwards.
*/
const ADJECTIVES: readonly string[] = [
"Amber",
"Aurora",
"Cobalt",
"Crimson",
"Emerald",
"Ember",
"Gilded",
"Golden",
"Ivory",
"Iron",
"Jade",
"Midnight",
"Onyx",
"Rustic",
"Scarlet",
"Shadow",
"Silent",
"Velvet",
"Vesper",
];
const NOUNS: readonly string[] = [
"Ace",
"Anvil",
"Bastion",
"Comet",
"Crown",
"Diamond",
"Falcon",
"Harbor",
"Herald",
"Juniper",
"Keystone",
"Lantern",
"Marlin",
"Meridian",
"Nomad",
"Osprey",
"Relay",
"Sable",
"Spire",
"Vanguard",
];
function pick(list: readonly string[], rng: () => number): string {
const index = Math.floor(rng() * list.length) % list.length;
const value = list[index];
return value ?? list[0] ?? "Table";
}
/**
* Generate a default table title ("Amber Falcon"). Falls back to a plain
* count if the word lists are somehow empty (defensive; they are static).
*/
export function generateTableName(rng: () => number = Math.random): string {
const adjective = pick(ADJECTIVES, rng);
const noun = pick(NOUNS, rng);
return `${adjective} ${noun}`.trim() || "Card Table";
}

View file

@ -34,11 +34,7 @@ import {
const c = (rank: Rank, suit: Suit): Card => ({ rank, suit });
function makeTable(
names: string[],
stacks?: number[],
seed = 1,
): PokerTableState {
function makeTable(names: string[], stacks?: number[], seed = 1): PokerTableState {
const players = names.map((name, index) => ({
name,
stack: stacks?.[index],
@ -108,6 +104,8 @@ function showdownState(
toActSeat: null,
showdown: null,
finalPlacements: null,
queuedBoard: [],
queuedHoles: {},
};
}
@ -177,9 +175,7 @@ describe("deck", () => {
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"),
]);
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]);
});
@ -192,7 +188,13 @@ describe("hand evaluator", () => {
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"),
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]);
@ -201,7 +203,13 @@ describe("hand evaluator", () => {
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"),
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]);
@ -209,7 +217,13 @@ describe("hand evaluator", () => {
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"),
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]);
@ -217,7 +231,13 @@ describe("hand evaluator", () => {
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"),
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]);
@ -225,7 +245,13 @@ describe("hand evaluator", () => {
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"),
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]);
@ -233,7 +259,13 @@ describe("hand evaluator", () => {
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"),
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]);
@ -241,10 +273,22 @@ describe("hand evaluator", () => {
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"),
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"),
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);
@ -322,10 +366,7 @@ describe("table creation and blinds", () => {
}
// 52 deck minus 8 dealt cards.
expect(state.deck).toHaveLength(44);
const allCards = [
...state.players.flatMap((player) => player.holeCards),
...state.deck,
];
const allCards = [...state.players.flatMap((player) => player.holeCards), ...state.deck];
expect(allCards).toHaveLength(52);
});
@ -495,10 +536,7 @@ describe("side pots", () => {
// Expected winners derived independently from the evaluator.
const evaluations = [0, 1, 2].map((seat) =>
evaluateHand([
...(state.players[seat]?.holeCards ?? []),
...state.communityCards,
]),
evaluateHand([...(state.players[seat]?.holeCards ?? []), ...state.communityCards]),
);
for (let i = 0; i < evaluations.length - 1; i += 1) {
const a = evaluations[i];
@ -524,7 +562,9 @@ describe("side pots", () => {
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?.netChange).toBe(
expectedWon[seat] - (state.players[seat]?.totalCommitted ?? 0),
);
expect(result?.handRank).toEqual(evaluations[seat]?.rank);
}
expect(pots[0]?.winnerSeats).toEqual([mainWinner]);
@ -629,10 +669,7 @@ describe("fold-out and winner-takes-all", () => {
expect(state.showdown?.pots[0]?.amount).toBe(2000);
const evaluations = [0, 1].map((seat) =>
evaluateHand([
...(state.players[seat]?.holeCards ?? []),
...state.communityCards,
]),
evaluateHand([...(state.players[seat]?.holeCards ?? []), ...state.communityCards]),
);
const first = evaluations[0];
const second = evaluations[1];
@ -742,9 +779,7 @@ describe("full scripted games", () => {
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,
);
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);

View file

@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { generateTableName } from "@/lib/minigames/tableNames";
import { MAX_TABLE_NAME_LENGTH } from "@/lib/minigames/blackjack";
/**
* Default table titles: created tables start with a human codename
* ("Amber Falcon") instead of the raw table UUID. Both card game
* registries share this generator.
*/
describe("generateTableName", () => {
it("produces a two-word title from the word lists", () => {
const name = generateTableName(() => 0);
expect(name).toBe("Amber Ace");
});
it("varies with the rng but stays reproducible per seed", () => {
const a = generateTableName(() => 0.999);
const b = generateTableName(() => 0.999);
const c = generateTableName(() => 0.5);
expect(a).toBe(b);
expect(c).not.toBe("Amber Ace");
expect(a.split(" ")).toHaveLength(2);
expect(c.split(" ")).toHaveLength(2);
});
it("stays within the table-name length limit and needs no trimming", () => {
for (let seed = 0; seed < 40; seed += 1) {
const name = generateTableName();
expect(name.length).toBeGreaterThan(0);
expect(name.length).toBeLessThanOrEqual(MAX_TABLE_NAME_LENGTH);
expect(name).toBe(name.trim());
}
});
});