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. */
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
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 });
|
||||
|
||||
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);
|
||||
|
|
|
|||
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