From c5defa9e70392d913984e087f277765e6f19d89b Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Mon, 14 Sep 2026 15:37:19 -0400 Subject: [PATCH] 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 --- src/lib/minigames/poker/constants.ts | 7 + src/lib/minigames/poker/engine.ts | 40 +++- src/lib/minigames/poker/handSetup.ts | 12 +- src/lib/minigames/poker/stats.ts | 19 +- src/lib/minigames/poker/tables.ts | 339 ++++++++++++++++++++++++++- src/lib/minigames/poker/types.ts | 4 + src/lib/minigames/tableNames.ts | 67 ++++++ tests/int/poker-engine.int.spec.ts | 99 +++++--- tests/int/table-names.int.spec.ts | 35 +++ 9 files changed, 572 insertions(+), 50 deletions(-) create mode 100644 src/lib/minigames/tableNames.ts create mode 100644 tests/int/table-names.int.spec.ts diff --git a/src/lib/minigames/poker/constants.ts b/src/lib/minigames/poker/constants.ts index a8bf493..1d5eda3 100644 --- a/src/lib/minigames/poker/constants.ts +++ b/src/lib/minigames/poker/constants.ts @@ -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; diff --git a/src/lib/minigames/poker/engine.ts b/src/lib/minigames/poker/engine.ts index c5c9e76..ee56a6a 100644 --- a/src/lib/minigames/poker/engine.ts +++ b/src/lib/minigames/poker/engine.ts @@ -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. diff --git a/src/lib/minigames/poker/handSetup.ts b/src/lib/minigames/poker/handSetup.ts index 155e334..a02f3d9 100644 --- a/src/lib/minigames/poker/handSetup.ts +++ b/src/lib/minigames/poker/handSetup.ts @@ -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: {}, }; } diff --git a/src/lib/minigames/poker/stats.ts b/src/lib/minigames/poker/stats.ts index 74d363c..c82c8fc 100644 --- a/src/lib/minigames/poker/stats.ts +++ b/src/lib/minigames/poker/stats.ts @@ -136,6 +136,11 @@ export async function awardPokerGameResult( : { ...delta, xpEarned: current.xpEarned }; const progression = (profile.progression ?? {}) as Record; const minigames = (progression.minigames ?? {}) as Record; + // 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( diff --git a/src/lib/minigames/poker/tables.ts b/src/lib/minigames/poker/tables.ts index 4841cbe..c4d34e7 100644 --- a/src/lib/minigames/poker/tables.ts +++ b/src/lib/minigames/poker/tables.ts @@ -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; + /** Users whose chips have been settled back to their pool (idempotency). */ + chipSettled: Set; + /** Pending joiners who already paid the buy-in and wait to seat next hand. */ + pendingChipPaid: Set; handsWonThisGame: Record; /** handNumber already counted into handsWonThisGame (prevents double counts). */ countedHandNumber: number | null; @@ -104,6 +118,8 @@ interface PokerTableRecord { awayUsers: Set; /** Users who left mid-game; kept in seatMap so the UI can show "left". */ leftUsers: Set; + /** Users watching the running game without a seat. */ + spectators: Set; userNames: Map; 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 { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, diff --git a/src/lib/minigames/poker/types.ts b/src/lib/minigames/poker/types.ts index 9f01b44..eb19b89 100644 --- a/src/lib/minigames/poker/types.ts +++ b/src/lib/minigames/poker/types.ts @@ -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>; } /** Comparison result of a 5-card poker hand, category first then kickers. */ diff --git a/src/lib/minigames/tableNames.ts b/src/lib/minigames/tableNames.ts new file mode 100644 index 0000000..9116ea1 --- /dev/null +++ b/src/lib/minigames/tableNames.ts @@ -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"; +} diff --git a/tests/int/poker-engine.int.spec.ts b/tests/int/poker-engine.int.spec.ts index e26b58c..55cbb95 100644 --- a/tests/int/poker-engine.int.spec.ts +++ b/tests/int/poker-engine.int.spec.ts @@ -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); diff --git a/tests/int/table-names.int.spec.ts b/tests/int/table-names.int.spec.ts new file mode 100644 index 0000000..c4575ca --- /dev/null +++ b/tests/int/table-names.int.spec.ts @@ -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()); + } + }); +});