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:
parent
4d08122464
commit
c5defa9e70
9 changed files with 572 additions and 50 deletions
|
|
@ -6,6 +6,13 @@
|
||||||
/** Play chips each player starts a game with. */
|
/** Play chips each player starts a game with. */
|
||||||
export const STARTING_STACK = 1000;
|
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 SMALL_BLIND = 10;
|
||||||
|
|
||||||
export const BIG_BLIND = 20;
|
export const BIG_BLIND = 20;
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,18 @@ import {
|
||||||
postflopFirstToAct,
|
postflopFirstToAct,
|
||||||
sumCommitted,
|
sumCommitted,
|
||||||
} from "./betting";
|
} 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 { startHand } from "./handSetup";
|
||||||
import { resolveShowdown } from "./showdown";
|
import { resolveShowdown } from "./showdown";
|
||||||
import type {
|
import type {
|
||||||
|
Card,
|
||||||
PokerPlayerShowdownResult,
|
PokerPlayerShowdownResult,
|
||||||
PokerPlayerState,
|
PokerPlayerState,
|
||||||
PokerSeatId,
|
PokerSeatId,
|
||||||
|
|
@ -110,21 +118,37 @@ function resolveAndPay(state: PokerTableState): PokerTableState {
|
||||||
function runOutAndShowdown(state: PokerTableState): PokerTableState {
|
function runOutAndShowdown(state: PokerTableState): PokerTableState {
|
||||||
let community = [...state.communityCards];
|
let community = [...state.communityCards];
|
||||||
let deck = [...state.deck];
|
let deck = [...state.deck];
|
||||||
|
let queuedBoard = [...state.queuedBoard];
|
||||||
while (community.length < 5) {
|
while (community.length < 5) {
|
||||||
const toDeal = community.length === 0 ? 3 : 1;
|
const nextCard = queuedBoard.shift() ?? deck.shift();
|
||||||
community = [...community, ...deck.slice(0, toDeal)];
|
if (!nextCard) break;
|
||||||
deck = deck.slice(toDeal);
|
community.push(nextCard);
|
||||||
}
|
}
|
||||||
const withBoard: PokerTableState = {
|
const withBoard: PokerTableState = {
|
||||||
...state,
|
...state,
|
||||||
communityCards: community,
|
communityCards: community,
|
||||||
deck,
|
deck,
|
||||||
|
queuedBoard,
|
||||||
street: "river",
|
street: "river",
|
||||||
toActSeat: null,
|
toActSeat: null,
|
||||||
};
|
};
|
||||||
return resolveAndPay(withBoard);
|
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"];
|
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 streetIndex = STREET_ORDER.indexOf(next.street);
|
||||||
const street = STREET_ORDER[streetIndex + 1] ?? "river";
|
const street = STREET_ORDER[streetIndex + 1] ?? "river";
|
||||||
const cardsToDeal = street === "flop" ? 3 : 1;
|
const cardsToDeal = street === "flop" ? 3 : 1;
|
||||||
|
const draw = drawBoardCards(next, cardsToDeal);
|
||||||
return {
|
return {
|
||||||
...next,
|
...next,
|
||||||
street,
|
street,
|
||||||
communityCards: [...next.communityCards, ...next.deck.slice(0, cardsToDeal)],
|
communityCards: [...next.communityCards, ...draw.cards],
|
||||||
deck: next.deck.slice(cardsToDeal),
|
deck: draw.deck,
|
||||||
|
queuedBoard: draw.queuedBoard,
|
||||||
toActSeat: postflopFirstToAct(next),
|
toActSeat: postflopFirstToAct(next),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -228,6 +254,8 @@ export function createTableState(
|
||||||
toActSeat: null,
|
toActSeat: null,
|
||||||
showdown: null,
|
showdown: null,
|
||||||
finalPlacements: null,
|
finalPlacements: null,
|
||||||
|
queuedBoard: [],
|
||||||
|
queuedHoles: {},
|
||||||
};
|
};
|
||||||
// Hand 1 starts immediately: the "hand-complete" resting phase makes
|
// Hand 1 starts immediately: the "hand-complete" resting phase makes
|
||||||
// advanceState deal it, which also moves the button to the first seat.
|
// advanceState deal it, which also moves the button to the first seat.
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,16 @@ export function startHand(state: PokerTableState): PokerTableState {
|
||||||
});
|
});
|
||||||
|
|
||||||
const dealt = dealHoleCards(reset, buttonSeat, createShuffledDeck(state.seed, handNumber));
|
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 === smallBlindSeat) return postBlind(player, state.config.smallBlind);
|
||||||
if (player.seatId === bigBlindSeat) return postBlind(player, state.config.bigBlind);
|
if (player.seatId === bigBlindSeat) return postBlind(player, state.config.bigBlind);
|
||||||
return player;
|
return player;
|
||||||
|
|
@ -149,5 +158,6 @@ export function startHand(state: PokerTableState): PokerTableState {
|
||||||
toActSeat,
|
toActSeat,
|
||||||
showdown: null,
|
showdown: null,
|
||||||
finalPlacements: null,
|
finalPlacements: null,
|
||||||
|
queuedHoles: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,11 @@ export async function awardPokerGameResult(
|
||||||
: { ...delta, xpEarned: current.xpEarned };
|
: { ...delta, xpEarned: current.xpEarned };
|
||||||
const progression = (profile.progression ?? {}) as Record<string, unknown>;
|
const progression = (profile.progression ?? {}) as Record<string, unknown>;
|
||||||
const minigames = (progression.minigames ?? {}) 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({
|
await payload.update({
|
||||||
collection: "profiles",
|
collection: "profiles",
|
||||||
|
|
@ -143,6 +148,7 @@ export async function awardPokerGameResult(
|
||||||
data: {
|
data: {
|
||||||
progression: {
|
progression: {
|
||||||
...progression,
|
...progression,
|
||||||
|
chipBank,
|
||||||
minigames: {
|
minigames: {
|
||||||
...minigames,
|
...minigames,
|
||||||
poker: next,
|
poker: next,
|
||||||
|
|
@ -153,13 +159,12 @@ export async function awardPokerGameResult(
|
||||||
depth: 0,
|
depth: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (xp > 0) {
|
broadcastToUser("profile-update", result.userId, {
|
||||||
broadcastToUser("profile-update", result.userId, {
|
reason: "xp",
|
||||||
reason: "xp",
|
xpGained: xp,
|
||||||
xpGained: xp,
|
chipBank,
|
||||||
profileId: profile.id,
|
profileId: profile.id,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
payload.logger.error(
|
payload.logger.error(
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,12 @@ import { getPayload } from "payload";
|
||||||
import type { Payload } from "payload";
|
import type { Payload } from "payload";
|
||||||
import { broadcast, broadcastToUser } from "@/lib/realtime/bus";
|
import { broadcast, broadcastToUser } from "@/lib/realtime/bus";
|
||||||
import { notifyUser } from "@/lib/notifications";
|
import { notifyUser } from "@/lib/notifications";
|
||||||
|
import { creditChips, readChipBankState, spendChipBuyIn } from "@/lib/minigames/chips";
|
||||||
|
import { generateTableName } from "@/lib/minigames/tableNames";
|
||||||
import { applyAction } from "./actions";
|
import { applyAction } from "./actions";
|
||||||
import { getLegalActions } from "./betting";
|
import { getLegalActions } from "./betting";
|
||||||
import {
|
import {
|
||||||
|
BUY_IN,
|
||||||
MAX_PLAYERS,
|
MAX_PLAYERS,
|
||||||
MAX_TABLE_NAME_LENGTH,
|
MAX_TABLE_NAME_LENGTH,
|
||||||
MIN_PLAYERS,
|
MIN_PLAYERS,
|
||||||
|
|
@ -33,6 +36,7 @@ import {
|
||||||
import { advanceState, awardFoldOut, createTableState } from "./engine";
|
import { advanceState, awardFoldOut, createTableState } from "./engine";
|
||||||
import { awardPokerGameResult, type PokerPlacementResult } from "./stats";
|
import { awardPokerGameResult, type PokerPlacementResult } from "./stats";
|
||||||
import type {
|
import type {
|
||||||
|
Card,
|
||||||
PokerActionKind,
|
PokerActionKind,
|
||||||
PokerPlayerState,
|
PokerPlayerState,
|
||||||
PokerSeatId,
|
PokerSeatId,
|
||||||
|
|
@ -75,6 +79,10 @@ export interface PokerSnapshot {
|
||||||
/** seatId to userId (null = empty seat); lets the UI map names and hosts. */
|
/** seatId to userId (null = empty seat); lets the UI map names and hosts. */
|
||||||
seats: (number | null)[];
|
seats: (number | null)[];
|
||||||
hostSeatId: 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 {
|
interface PendingJoin {
|
||||||
|
|
@ -91,6 +99,12 @@ interface PokerTableRecord {
|
||||||
seatMap: (number | null)[];
|
seatMap: (number | null)[];
|
||||||
state: PokerTableState | null;
|
state: PokerTableState | null;
|
||||||
inGame: boolean;
|
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>;
|
handsWonThisGame: Record<number, number>;
|
||||||
/** handNumber already counted into handsWonThisGame (prevents double counts). */
|
/** handNumber already counted into handsWonThisGame (prevents double counts). */
|
||||||
countedHandNumber: number | null;
|
countedHandNumber: number | null;
|
||||||
|
|
@ -104,6 +118,8 @@ interface PokerTableRecord {
|
||||||
awayUsers: Set<number>;
|
awayUsers: Set<number>;
|
||||||
/** Users who left mid-game; kept in seatMap so the UI can show "left". */
|
/** Users who left mid-game; kept in seatMap so the UI can show "left". */
|
||||||
leftUsers: Set<number>;
|
leftUsers: Set<number>;
|
||||||
|
/** Users watching the running game without a seat. */
|
||||||
|
spectators: Set<number>;
|
||||||
userNames: Map<number, string>;
|
userNames: Map<number, string>;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
/** Last client interaction (poll, action, join/leave/start); drives expiry. */
|
/** Last client interaction (poll, action, join/leave/start); drives expiry. */
|
||||||
|
|
@ -193,6 +209,11 @@ function onNextHandTimeout(record: PokerTableRecord): void {
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
let next = current;
|
let next = current;
|
||||||
for (const pending of record.pendingJoins) {
|
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 = {
|
||||||
...next,
|
...next,
|
||||||
players: [...next.players, createFreshPlayer(next.players.length, pending.name)],
|
players: [...next.players, createFreshPlayer(next.players.length, pending.name)],
|
||||||
|
|
@ -210,7 +231,7 @@ function createFreshPlayer(seatId: PokerSeatId, name: string): PokerPlayerState
|
||||||
return {
|
return {
|
||||||
seatId,
|
seatId,
|
||||||
name,
|
name,
|
||||||
stack: STARTING_STACK,
|
stack: BUY_IN,
|
||||||
committed: 0,
|
committed: 0,
|
||||||
totalCommitted: 0,
|
totalCommitted: 0,
|
||||||
holeCards: [],
|
holeCards: [],
|
||||||
|
|
@ -228,6 +249,8 @@ function eliminateLeavers(state: PokerTableState, record: PokerTableRecord): Pok
|
||||||
const userId = record.seatMap[index];
|
const userId = record.seatMap[index];
|
||||||
if (userId === null || !record.leftUsers.has(userId) || player.eliminated) return player;
|
if (userId === null || !record.leftUsers.has(userId) || player.eliminated) return player;
|
||||||
changed = true;
|
changed = true;
|
||||||
|
// Forfeited stacks stay burned: a mid-game leaver's buy-in is not returned.
|
||||||
|
record.chipSettled.add(userId);
|
||||||
return {
|
return {
|
||||||
...player,
|
...player,
|
||||||
eliminated: true,
|
eliminated: true,
|
||||||
|
|
@ -305,6 +328,9 @@ async function awardGameResults(record: PokerTableRecord): Promise<void> {
|
||||||
const userId = record.seatMap[placement.seatId];
|
const userId = record.seatMap[placement.seatId];
|
||||||
const player = state.players[placement.seatId];
|
const player = state.players[placement.seatId];
|
||||||
if (userId === null || !player) continue;
|
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({
|
results.push({
|
||||||
userId,
|
userId,
|
||||||
placement: placement.placement,
|
placement: placement.placement,
|
||||||
|
|
@ -346,9 +372,16 @@ function afterMutation(record: PokerTableRecord): void {
|
||||||
function buildSnapshot(record: PokerTableRecord, userId: number): PokerSnapshot {
|
function buildSnapshot(record: PokerTableRecord, userId: number): PokerSnapshot {
|
||||||
const seatIndex = record.seatMap.indexOf(userId);
|
const seatIndex = record.seatMap.indexOf(userId);
|
||||||
const seated = seatIndex >= 0 && !record.leftUsers.has(userId);
|
const seated = seatIndex >= 0 && !record.leftUsers.has(userId);
|
||||||
|
const spectator = !seated && record.spectators.has(userId);
|
||||||
const state = record.state;
|
const state = record.state;
|
||||||
const inEngine = seated && state !== null && seatIndex < state.players.length;
|
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 =
|
const you =
|
||||||
base === null
|
base === null
|
||||||
? null
|
? null
|
||||||
|
|
@ -370,10 +403,12 @@ function buildSnapshot(record: PokerTableRecord, userId: number): PokerSnapshot
|
||||||
you,
|
you,
|
||||||
seats: [...record.seatMap],
|
seats: [...record.seatMap],
|
||||||
hostSeatId: hostSeat >= 0 ? hostSeat : null,
|
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 {
|
function pushTable(record: PokerTableRecord): void {
|
||||||
for (const userId of record.seatMap) {
|
for (const userId of record.seatMap) {
|
||||||
if (userId === null || record.leftUsers.has(userId)) continue;
|
if (userId === null || record.leftUsers.has(userId)) continue;
|
||||||
|
|
@ -382,16 +417,193 @@ function pushTable(record: PokerTableRecord): void {
|
||||||
snapshot: buildSnapshot(record, userId),
|
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() });
|
broadcast("poker-tables", { tables: listSummaries() });
|
||||||
}
|
}
|
||||||
|
|
||||||
function disposeTable(record: PokerTableRecord): void {
|
function disposeTable(record: PokerTableRecord): void {
|
||||||
clearTurnTimer(record);
|
clearTurnTimer(record);
|
||||||
clearNextHandTimer(record);
|
clearNextHandTimer(record);
|
||||||
|
void settleUnsettledChips(record);
|
||||||
tables.delete(record.id);
|
tables.delete(record.id);
|
||||||
broadcast("poker-tables", { tables: listSummaries() });
|
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 {
|
export function forceExpireTable(tableId: string, payload: Payload): void {
|
||||||
const record = tables.get(tableId);
|
const record = tables.get(tableId);
|
||||||
if (!record) throw new Error("Table not found.");
|
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);
|
while (seatMap.length < MAX_PLAYERS) seatMap.push(null);
|
||||||
const record: PokerTableRecord = {
|
const record: PokerTableRecord = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
name: null,
|
name: generateTableName(),
|
||||||
version: 1,
|
version: 1,
|
||||||
hostUserId: userId,
|
hostUserId: userId,
|
||||||
seatMap,
|
seatMap,
|
||||||
state: null,
|
state: null,
|
||||||
inGame: false,
|
inGame: false,
|
||||||
|
chipBoughtIn: new Set(),
|
||||||
|
chipSettled: new Set(),
|
||||||
|
pendingChipPaid: new Set(),
|
||||||
handsWonThisGame: {},
|
handsWonThisGame: {},
|
||||||
countedHandNumber: null,
|
countedHandNumber: null,
|
||||||
turnTimer: null,
|
turnTimer: null,
|
||||||
|
|
@ -457,6 +672,7 @@ export function createTable(userId: number, displayName: string): string {
|
||||||
pendingJoins: [],
|
pendingJoins: [],
|
||||||
awayUsers: new Set(),
|
awayUsers: new Set(),
|
||||||
leftUsers: new Set(),
|
leftUsers: new Set(),
|
||||||
|
spectators: new Set(),
|
||||||
userNames: new Map([[userId, displayName]]),
|
userNames: new Map([[userId, displayName]]),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
lastActivityAt: Date.now(),
|
lastActivityAt: Date.now(),
|
||||||
|
|
@ -502,6 +718,7 @@ export function joinTable(tableId: string, userId: number, displayName: string):
|
||||||
}
|
}
|
||||||
const slot = record.seatMap.indexOf(null);
|
const slot = record.seatMap.indexOf(null);
|
||||||
if (slot < 0) throw new Error("The table is full.");
|
if (slot < 0) throw new Error("The table is full.");
|
||||||
|
record.spectators.delete(userId);
|
||||||
record.seatMap[slot] = userId;
|
record.seatMap[slot] = userId;
|
||||||
record.userNames.set(userId, displayName);
|
record.userNames.set(userId, displayName);
|
||||||
record.lastActivityAt = Date.now();
|
record.lastActivityAt = Date.now();
|
||||||
|
|
@ -513,9 +730,27 @@ export function joinTable(tableId: string, userId: number, displayName: string):
|
||||||
return buildSnapshot(record, userId);
|
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 {
|
export function leaveTable(tableId: string, userId: number): void {
|
||||||
const record = tables.get(tableId);
|
const record = tables.get(tableId);
|
||||||
if (!record) throw new Error("Table not found.");
|
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 (!isSeatedAt(record, userId)) throw new Error("You are not seated at this table.");
|
||||||
if (record.inGame) {
|
if (record.inGame) {
|
||||||
// Mid-game: keep the seat visible, forfeit the stack at the next hand.
|
// 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);
|
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(
|
export function applyPlayerAction(
|
||||||
tableId: string,
|
tableId: string,
|
||||||
userId: number,
|
userId: number,
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,10 @@ export interface PokerTableState {
|
||||||
readonly showdown: ShowdownOutcome | null;
|
readonly showdown: ShowdownOutcome | null;
|
||||||
/** Set once the game completes: tournament placement per seat. */
|
/** Set once the game completes: tournament placement per seat. */
|
||||||
readonly finalPlacements: readonly PokerGamePlacement[] | null;
|
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. */
|
/** Comparison result of a 5-card poker hand, category first then kickers. */
|
||||||
|
|
|
||||||
67
src/lib/minigames/tableNames.ts
Normal file
67
src/lib/minigames/tableNames.ts
Normal 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";
|
||||||
|
}
|
||||||
|
|
@ -34,11 +34,7 @@ import {
|
||||||
|
|
||||||
const c = (rank: Rank, suit: Suit): Card => ({ rank, suit });
|
const c = (rank: Rank, suit: Suit): Card => ({ rank, suit });
|
||||||
|
|
||||||
function makeTable(
|
function makeTable(names: string[], stacks?: number[], seed = 1): PokerTableState {
|
||||||
names: string[],
|
|
||||||
stacks?: number[],
|
|
||||||
seed = 1,
|
|
||||||
): PokerTableState {
|
|
||||||
const players = names.map((name, index) => ({
|
const players = names.map((name, index) => ({
|
||||||
name,
|
name,
|
||||||
stack: stacks?.[index],
|
stack: stacks?.[index],
|
||||||
|
|
@ -108,6 +104,8 @@ function showdownState(
|
||||||
toActSeat: null,
|
toActSeat: null,
|
||||||
showdown: null,
|
showdown: null,
|
||||||
finalPlacements: null,
|
finalPlacements: null,
|
||||||
|
queuedBoard: [],
|
||||||
|
queuedHoles: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,9 +175,7 @@ describe("deck", () => {
|
||||||
|
|
||||||
describe("hand evaluator", () => {
|
describe("hand evaluator", () => {
|
||||||
it("recognizes a royal flush as an ace-high straight flush", () => {
|
it("recognizes a royal flush as an ace-high straight flush", () => {
|
||||||
const evaluation = evaluateHand([
|
const evaluation = evaluateHand([c(14, "s"), c(13, "s"), c(12, "s"), c(11, "s"), c(10, "s")]);
|
||||||
c(14, "s"), c(13, "s"), c(12, "s"), c(11, "s"), c(10, "s"),
|
|
||||||
]);
|
|
||||||
expect(evaluation.rank.category).toBe(8);
|
expect(evaluation.rank.category).toBe(8);
|
||||||
expect(evaluation.rank.tiebreakers).toEqual([14]);
|
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", () => {
|
it("finds the best 5 of 7 for a straight flush over a flush and a pair", () => {
|
||||||
const evaluation = evaluateHand([
|
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.category).toBe(8);
|
||||||
expect(evaluation.rank.tiebreakers).toEqual([14]);
|
expect(evaluation.rank.tiebreakers).toEqual([14]);
|
||||||
|
|
@ -201,7 +203,13 @@ describe("hand evaluator", () => {
|
||||||
|
|
||||||
it("scores quads with the best kicker from seven cards", () => {
|
it("scores quads with the best kicker from seven cards", () => {
|
||||||
const evaluation = evaluateHand([
|
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.category).toBe(7);
|
||||||
expect(evaluation.rank.tiebreakers).toEqual([9, 14]);
|
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", () => {
|
it("scores a full house choosing the highest trips and pair", () => {
|
||||||
const evaluation = evaluateHand([
|
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.category).toBe(6);
|
||||||
expect(evaluation.rank.tiebreakers).toEqual([9, 14]);
|
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", () => {
|
it("picks the best five cards for a flush from seven", () => {
|
||||||
const evaluation = evaluateHand([
|
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.category).toBe(5);
|
||||||
expect(evaluation.rank.tiebreakers).toEqual([14, 11, 9, 5, 2]);
|
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", () => {
|
it("finds a straight in seven cards that also contain a pair", () => {
|
||||||
const evaluation = evaluateHand([
|
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.category).toBe(4);
|
||||||
expect(evaluation.rank.tiebreakers).toEqual([9]);
|
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", () => {
|
it("scores the wheel as a five-high straight, not ace high", () => {
|
||||||
const evaluation = evaluateHand([
|
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.category).toBe(4);
|
||||||
expect(evaluation.rank.tiebreakers).toEqual([5]);
|
expect(evaluation.rank.tiebreakers).toEqual([5]);
|
||||||
|
|
@ -241,10 +273,22 @@ describe("hand evaluator", () => {
|
||||||
|
|
||||||
it("breaks two pair ties with the fifth card kicker", () => {
|
it("breaks two pair ties with the fifth card kicker", () => {
|
||||||
const queenKicker = evaluateHand([
|
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([
|
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(queenKicker.rank.category).toBe(2);
|
||||||
expect(compareHandRanks(queenKicker.rank, jackKicker.rank)).toBeGreaterThan(0);
|
expect(compareHandRanks(queenKicker.rank, jackKicker.rank)).toBeGreaterThan(0);
|
||||||
|
|
@ -322,10 +366,7 @@ describe("table creation and blinds", () => {
|
||||||
}
|
}
|
||||||
// 52 deck minus 8 dealt cards.
|
// 52 deck minus 8 dealt cards.
|
||||||
expect(state.deck).toHaveLength(44);
|
expect(state.deck).toHaveLength(44);
|
||||||
const allCards = [
|
const allCards = [...state.players.flatMap((player) => player.holeCards), ...state.deck];
|
||||||
...state.players.flatMap((player) => player.holeCards),
|
|
||||||
...state.deck,
|
|
||||||
];
|
|
||||||
expect(allCards).toHaveLength(52);
|
expect(allCards).toHaveLength(52);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -495,10 +536,7 @@ describe("side pots", () => {
|
||||||
|
|
||||||
// Expected winners derived independently from the evaluator.
|
// Expected winners derived independently from the evaluator.
|
||||||
const evaluations = [0, 1, 2].map((seat) =>
|
const evaluations = [0, 1, 2].map((seat) =>
|
||||||
evaluateHand([
|
evaluateHand([...(state.players[seat]?.holeCards ?? []), ...state.communityCards]),
|
||||||
...(state.players[seat]?.holeCards ?? []),
|
|
||||||
...state.communityCards,
|
|
||||||
]),
|
|
||||||
);
|
);
|
||||||
for (let i = 0; i < evaluations.length - 1; i += 1) {
|
for (let i = 0; i < evaluations.length - 1; i += 1) {
|
||||||
const a = evaluations[i];
|
const a = evaluations[i];
|
||||||
|
|
@ -524,7 +562,9 @@ describe("side pots", () => {
|
||||||
for (const seat of [0, 1, 2]) {
|
for (const seat of [0, 1, 2]) {
|
||||||
const result = results[seat];
|
const result = results[seat];
|
||||||
expect(result?.chipsWon).toBe(expectedWon[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(result?.handRank).toEqual(evaluations[seat]?.rank);
|
||||||
}
|
}
|
||||||
expect(pots[0]?.winnerSeats).toEqual([mainWinner]);
|
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);
|
expect(state.showdown?.pots[0]?.amount).toBe(2000);
|
||||||
|
|
||||||
const evaluations = [0, 1].map((seat) =>
|
const evaluations = [0, 1].map((seat) =>
|
||||||
evaluateHand([
|
evaluateHand([...(state.players[seat]?.holeCards ?? []), ...state.communityCards]),
|
||||||
...(state.players[seat]?.holeCards ?? []),
|
|
||||||
...state.communityCards,
|
|
||||||
]),
|
|
||||||
);
|
);
|
||||||
const first = evaluations[0];
|
const first = evaluations[0];
|
||||||
const second = evaluations[1];
|
const second = evaluations[1];
|
||||||
|
|
@ -742,9 +779,7 @@ describe("full scripted games", () => {
|
||||||
const winners = state.players.filter((player) => player.stack > 0);
|
const winners = state.players.filter((player) => player.stack > 0);
|
||||||
expect(winners).toHaveLength(1);
|
expect(winners).toHaveLength(1);
|
||||||
expect(winners[0]?.stack).toBe(4000);
|
expect(winners[0]?.stack).toBe(4000);
|
||||||
const winnerPlacement = state.finalPlacements?.find(
|
const winnerPlacement = state.finalPlacements?.find((p) => p.seatId === winners[0]?.seatId);
|
||||||
(p) => p.seatId === winners[0]?.seatId,
|
|
||||||
);
|
|
||||||
expect(winnerPlacement?.placement).toBe(1);
|
expect(winnerPlacement?.placement).toBe(1);
|
||||||
const placements = (state.finalPlacements ?? []).map((p) => p.placement);
|
const placements = (state.finalPlacements ?? []).map((p) => p.placement);
|
||||||
expect(placements[0]).toBe(1);
|
expect(placements[0]).toBe(1);
|
||||||
|
|
|
||||||
35
tests/int/table-names.int.spec.ts
Normal file
35
tests/int/table-names.int.spec.ts
Normal 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());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue