diff --git a/src/app/(frontend)/minigames/poker/actions.ts b/src/app/(frontend)/minigames/poker/actions.ts index f55e8d1..ace523b 100644 --- a/src/app/(frontend)/minigames/poker/actions.ts +++ b/src/app/(frontend)/minigames/poker/actions.ts @@ -8,23 +8,28 @@ import config from "@payload-config"; import { getPayload } from "payload"; -import type { PokerActionKind, PrivatePlayerView } from "@/lib/minigames/poker/types"; +import type { Card, PokerActionKind, PrivatePlayerView } from "@/lib/minigames/poker/types"; import { isSuperuser } from "@/utils/access-control/hasPermission"; import { + adjustStack, applyPlayerAction, createTable, forceExpireTable, getSnapshotFor, - joinTable, - leaveTable, + joinTableWithBuyIn, + leaveTableWithBuyIn, + spectateTable, listSummaries, renameTable, - startGame, + setBoardQueue, + setHoleQueue, + startGameWithBuyIn, type PokerSnapshot, type PokerTableSummary, } from "@/lib/minigames/poker/tables"; export type { PokerSnapshot, PokerTableSummary }; +export type PokerCardInput = Card; interface ActionResult { success: boolean; @@ -105,7 +110,70 @@ export async function forceExpirePokerTable(tableId: string): Promise> { try { const { user } = await authenticate(); - const snapshot = joinTable(tableId, Number(user.id), userName(user)); + const snapshot = await joinTableWithBuyIn(tableId, Number(user.id), userName(user)); + return { success: true, data: snapshot }; + } catch (error) { + return toError(error); + } +} + +export async function spectatePokerTable(tableId: string): Promise> { + try { + const { user } = await authenticate(); + const snapshot = spectateTable(tableId, Number(user.id)); + return { success: true, data: snapshot }; + } catch (error) { + return toError(error); + } +} + +export async function setPokerBoardQueue( + tableId: string, + cards: PokerCardInput[], +): Promise> { + try { + const { payload, user } = await authenticate(); + const allowed = await isSuperuser(payload, user); + if (!allowed) { + return { success: false, error: "Only administrators can use dev controls." }; + } + const snapshot = setBoardQueue(tableId, Number(user.id), cards); + return { success: true, data: snapshot }; + } catch (error) { + return toError(error); + } +} + +export async function setPokerHoleQueue( + tableId: string, + seatId: number, + cards: PokerCardInput[], +): Promise> { + try { + const { payload, user } = await authenticate(); + const allowed = await isSuperuser(payload, user); + if (!allowed) { + return { success: false, error: "Only administrators can use dev controls." }; + } + const snapshot = setHoleQueue(tableId, Number(user.id), seatId, cards); + return { success: true, data: snapshot }; + } catch (error) { + return toError(error); + } +} + +export async function adjustPokerStack( + tableId: string, + seatId: number, + delta: number, +): Promise> { + try { + const { payload, user } = await authenticate(); + const allowed = await isSuperuser(payload, user); + if (!allowed) { + return { success: false, error: "Only administrators can use dev controls." }; + } + const snapshot = adjustStack(tableId, Number(user.id), seatId, delta); return { success: true, data: snapshot }; } catch (error) { return toError(error); @@ -115,7 +183,7 @@ export async function joinPokerTable(tableId: string): Promise> { try { const { user } = await authenticate(); - leaveTable(tableId, Number(user.id)); + await leaveTableWithBuyIn(tableId, Number(user.id)); return { success: true }; } catch (error) { return toError(error); @@ -125,7 +193,7 @@ export async function leavePokerTable(tableId: string): Promise> { try { const { user } = await authenticate(); - const snapshot = startGame(tableId, Number(user.id)); + const snapshot = await startGameWithBuyIn(tableId, Number(user.id)); return { success: true, data: snapshot }; } catch (error) { return toError(error); diff --git a/src/app/(frontend)/minigames/poker/page.tsx b/src/app/(frontend)/minigames/poker/page.tsx index cace5a3..462b894 100644 --- a/src/app/(frontend)/minigames/poker/page.tsx +++ b/src/app/(frontend)/minigames/poker/page.tsx @@ -6,7 +6,7 @@ import { redirect } from "next/navigation"; import { Spade } from "lucide-react"; import { PokerArena } from "@/components/frontend/minigames/poker/PokerArena"; import { readPokerStats } from "@/lib/minigames/poker/stats"; -import { isSuperuser } from "@/utils/access-control/hasPermission"; +import { isSuperuser, superuserUserIds } from "@/utils/access-control/hasPermission"; export const metadata: Metadata = { title: "Poker: Polaris Task Force", @@ -29,6 +29,7 @@ export default async function PokerPage({ searchParams }: PokerPageProps) { } const superuser = await isSuperuser(payload, user); + const superuserIds = await superuserUserIds(payload); const profileRes = await payload.find({ collection: "profiles", @@ -39,11 +40,19 @@ export default async function PokerPage({ searchParams }: PokerPageProps) { select: { progression: { minigames: true, + chipBank: true, + chipBankRefilledAt: true, }, }, }); const stats = readPokerStats(profileRes.docs[0]); + const progressionDoc = (profileRes.docs[0]?.progression ?? {}) as { + chipBank?: number | null; + chipBankRefilledAt?: string | null; + }; + const initialChipBank = progressionDoc.chipBank ?? 0; + const initialChipTopUpAt = progressionDoc.chipBankRefilledAt ?? null; const params = await searchParams; const tableValue = params.table; const initialTableId = Array.isArray(tableValue) ? tableValue[0] : tableValue; @@ -66,6 +75,9 @@ export default async function PokerPage({ searchParams }: PokerPageProps) { currentUserId={Number(user.id)} initialTableId={initialTableId} isSuperuser={superuser} + superuserIds={superuserIds} + initialChipBank={initialChipBank} + initialChipTopUpAt={initialChipTopUpAt} /> ); diff --git a/src/components/frontend/minigames/poker/PokerActionConsole.tsx b/src/components/frontend/minigames/poker/PokerActionConsole.tsx index 27f7597..cc04298 100644 --- a/src/components/frontend/minigames/poker/PokerActionConsole.tsx +++ b/src/components/frontend/minigames/poker/PokerActionConsole.tsx @@ -1,5 +1,6 @@ "use client"; +import { Eye, Shield } from "lucide-react"; import { cn } from "@/lib/utils"; import type { PublicPlayerView } from "@/lib/minigames/poker"; import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions"; @@ -16,6 +17,9 @@ interface PokerActionConsoleProps { readonly handLabel: string | null; readonly handTier: number | null; readonly hostSeatId: number | null; + readonly spectator?: boolean; + readonly onJoin?: () => void; + readonly isSuperuser?: boolean; readonly starting: boolean; readonly leaving: boolean; readonly onStart: () => void; @@ -83,6 +87,9 @@ export function PokerActionConsole({ handLabel, handTier, hostSeatId, + spectator = false, + onJoin, + isSuperuser = false, starting, leaving, onStart, @@ -92,6 +99,7 @@ export function PokerActionConsole({ const viewerPlayer: PublicPlayerView | undefined = view.players.find( (player) => player.seatId === view.viewerSeat, ); + const seatsOpen = snapshot.seats.filter((seat) => seat === null).length; const chipsInPlay = viewerPlayer?.committed ?? 0; const viewerInitial = (viewerPlayer?.name ?? "You").trim().charAt(0).toUpperCase() || "?"; @@ -105,50 +113,88 @@ export function PokerActionConsole({ aria-label="Your poker action console" >
- -
-
-
-

- {viewerPlayer?.name ?? "You"} - (you) -

-

- Stack {viewerPlayer?.stack.toLocaleString() ?? "0"} -

-
- -
- {handLabel && ( + {spectator ? ( + <> 0 ? ` poker-rank-tier-${handTier}` : "" - }`} + className="poker-seat-avatar poker-console-avatar poker-seat-avatar-open" + aria-hidden="true" > - You have: {handLabel} + - )} -
+
+
+
+

Spectating

+

+ {seatsOpen > 0 + ? `${seatsOpen} seat${seatsOpen > 1 ? "s" : ""} open` + : "No open seats"} +

+
+
+
+ + ) : ( + <> + +
+
+
+

+ {viewerPlayer?.name ?? "You"} + {isSuperuser && ( + + )} + (you) +

+

+ Stack {viewerPlayer?.stack.toLocaleString() ?? "0"} +

+
+ +
+ {handLabel && ( + 0 ? ` poker-rank-tier-${handTier}` : "" + }`} + > + You have: {handLabel} + + )} +
+ + )}
-
-
+ {!spectator && ( +
+
+ )} + {spectator && seatsOpen > 0 && onJoin && ( + + )} {view.phase === "betting" && (
option.value === card.rank)?.label ?? String(card.rank); + const suit = SUIT_OPTIONS.find((option) => option.value === card.suit)?.label ?? card.suit; + return `${rank} ${suit}`; +} + +function CardPicker({ + label, + value, + onChange, +}: { + readonly label: string; + readonly value: Card | null; + readonly onChange: (card: Card | null) => void; +}) { + return ( +
+ {label} + + +
+ ); +} + +export function PokerDevPanel({ + tableId, + view, +}: { + readonly tableId: string; + readonly view: NonNullable; +}) { + const [open, setOpen] = useState(false); + const [boardQueue, setBoardQueue] = useState([]); + const [boardDraft, setBoardDraft] = useState(null); + const [seatId, setSeatId] = useState(""); + const [holeA, setHoleA] = useState(null); + const [holeB, setHoleB] = useState(null); + const [adjustSeat, setAdjustSeat] = useState(""); + const [adjustAmount, setAdjustAmount] = useState(""); + const [error, setError] = useState(null); + + const run = async (action: () => Promise<{ success: boolean; error?: string }>) => { + const result = await action(); + if (!result.success) setError(result.error ?? "The dev action failed."); + else setError(null); + }; + + const queueBoard = () => { + if (boardQueue.length === 0) return; + void run(async () => { + const result = await setPokerBoardQueue(tableId, boardQueue); + if (result.success) setBoardQueue([]); + return result; + }); + }; + + const queueHole = () => { + if (seatId === "" || !holeA || !holeB) return; + void run(async () => { + const result = await setPokerHoleQueue(tableId, Number(seatId), [holeA, holeB]); + if (result.success) { + setHoleA(null); + setHoleB(null); + } + return result; + }); + }; + + const adjust = (sign: 1 | -1) => { + if (adjustSeat === "") return; + const amount = Number(adjustAmount); + if (!Number.isInteger(amount) || amount <= 0) { + setError("Enter a positive whole number of chips."); + return; + } + void run(async () => { + const result = await adjustPokerStack(tableId, Number(adjustSeat), sign * amount); + if (result.success) setAdjustAmount(""); + return result; + }); + }; + + const boardSlotsLeft = 5 - view.communityCards.length - boardQueue.length; + + return ( + + + + + + + Developer controls + + Queue upcoming cards or adjust stacks. Queues apply at the next hand; board cards hit + the felt in order. + + + + {error &&

{error}

} + +
+

Board queue

+
+ setBoardDraft(card ?? { rank: 2, suit: "s" })} + /> + +
+ {boardQueue.length > 0 && ( +
    + {boardQueue.map((card, index) => ( +
  • + {index + 1}. + {cardLabel(card)} + +
  • + ))} +
+ )} + +
+ +
+

Next hand hole cards

+ +
+ + +
+ +
+ +
+

Stack adjustment

+ +
+ setAdjustAmount(event.target.value)} + className="h-8 w-28 text-xs" + /> + + +
+
+
+
+ ); +} diff --git a/src/components/frontend/minigames/poker/PokerFeltCanvas.tsx b/src/components/frontend/minigames/poker/PokerFeltCanvas.tsx index 4149b02..718d756 100644 --- a/src/components/frontend/minigames/poker/PokerFeltCanvas.tsx +++ b/src/components/frontend/minigames/poker/PokerFeltCanvas.tsx @@ -11,6 +11,7 @@ import { PokerActionToolbar } from "./PokerActionToolbar"; import { PokerClock } from "./PokerClock"; import { PokerDeckOrigin } from "./PokerDeckOrigin"; import { PokerPopupBubble } from "./PokerPopups"; +import type { PokerActionLogEntry } from "./pokerPopups"; import { PokerSeat } from "./PokerSeat"; import type { ActivePokerPopup } from "./usePokerPopups"; import { isTopSeatPosition, seatPosition } from "./seatPosition"; @@ -35,6 +36,10 @@ interface PokerFeltCanvasProps { readonly actionProps: FeltCanvasActionProps; readonly onActionFeedback: () => void; readonly popups: readonly ActivePokerPopup[]; + readonly actionLog: readonly PokerActionLogEntry[]; + readonly spectator?: boolean; + readonly onJoin?: () => void; + readonly superuserIds?: readonly number[]; readonly fullscreen?: boolean; } @@ -88,6 +93,10 @@ export function PokerFeltCanvas({ actionProps, onActionFeedback, popups, + actionLog, + spectator = false, + onJoin, + superuserIds, fullscreen = false, }: PokerFeltCanvasProps) { const view = snapshot.you; @@ -115,7 +124,7 @@ export function PokerFeltCanvas({
@@ -125,6 +134,7 @@ export function PokerFeltCanvas({ isActing={view.phase === "betting" && player.seatId === view.toActSeat} isWinner={winnerSeats.has(player.seatId)} showHoleCards={view.phase !== "hand-complete" && view.phase !== "game-complete"} + isSuperuser={superuserIds?.includes(Number(snapshot.seats[player.seatId])) ?? false} /> {popupsForSeat(player.seatId).length > 0 && (
)} -
- {player.seatId === view.buttonSeat && ( - - D - - )} - {player.seatId === view.smallBlindSeat && SB} - {player.seatId === view.bigBlindSeat && BB} -
+ {player.seatId === view.buttonSeat && ( + + D + + )} + {(player.seatId === view.smallBlindSeat || player.seatId === view.bigBlindSeat) && ( +
+ {player.seatId === view.smallBlindSeat && SB} + {player.seatId === view.bigBlindSeat && BB} +
+ )}
); })} @@ -230,11 +242,23 @@ export function PokerFeltCanvas({
)} - + {actionLog.length > 0 && ( +
+ {actionLog.map((entry) => ( +

+ {entry.text} +

+ ))} +
+ )} + + {!spectator && ( + + )} {viewerPopups.length > 0 && ( diff --git a/src/components/frontend/minigames/poker/PokerSeat.tsx b/src/components/frontend/minigames/poker/PokerSeat.tsx index 80a9495..c64cb12 100644 --- a/src/components/frontend/minigames/poker/PokerSeat.tsx +++ b/src/components/frontend/minigames/poker/PokerSeat.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import { Shield } from "lucide-react"; import { cn } from "@/lib/utils"; import type { PublicPlayerView } from "@/lib/minigames/poker"; import { CardView } from "./CardView"; @@ -11,6 +12,7 @@ interface PokerSeatProps { readonly isActing: boolean; readonly isWinner: boolean; readonly showHoleCards: boolean; + readonly isSuperuser?: boolean; } type StackFlash = "up" | "down" | null; @@ -23,6 +25,7 @@ export function PokerSeat({ isActing, isWinner, showHoleCards, + isSuperuser = false, }: PokerSeatProps) { const revealed = showHoleCards ? player.holeCards : null; const inHand = showHoleCards && !player.folded && !player.eliminated && !player.left; @@ -58,7 +61,7 @@ export function PokerSeat({ "poker-seat-panel mx-auto flex w-max items-center gap-2 px-2 py-1.5 backdrop-blur-sm", (player.folded || player.left) && "opacity-55", player.eliminated && "opacity-40", - isActing && "poker-seat-active ring-2 ring-emerald-400/90", + isActing && "poker-seat-active", isCurrentUser && "border-ring", isWinner && "ring-2 ring-amber-300", )} @@ -71,9 +74,12 @@ export function PokerSeat({ {initial}
-

+

{player.name} - {isCurrentUser && (you)} + {isSuperuser && ( + + )} + {isCurrentUser && (you)}

diff --git a/src/components/frontend/minigames/poker/PokerTable.tsx b/src/components/frontend/minigames/poker/PokerTable.tsx index 5c708b0..3fd929c 100644 --- a/src/components/frontend/minigames/poker/PokerTable.tsx +++ b/src/components/frontend/minigames/poker/PokerTable.tsx @@ -22,9 +22,12 @@ interface PokerTableProps { readonly tableError: string | null; readonly onSnapshot: (snapshot: PokerSnapshot) => void; readonly onJoin: () => Promise; + readonly onSpectate: () => Promise; readonly onStart: () => Promise; readonly onLeave: () => Promise; readonly onBack: () => void; + readonly isSuperuser: boolean; + readonly superuserIds: readonly number[]; } type SeatedSnapshot = PokerSnapshot & { @@ -40,6 +43,7 @@ function WaitingTable({ currentUserId, onSnapshot, onJoin, + onSpectate, onStart, onLeave, onBack, @@ -48,6 +52,7 @@ function WaitingTable({ readonly currentUserId: number | string; readonly onSnapshot: (snapshot: PokerSnapshot) => void; readonly onJoin: () => Promise; + readonly onSpectate: () => Promise; readonly onStart: () => Promise; readonly onLeave: () => Promise; readonly onBack: () => void; @@ -170,6 +175,11 @@ function WaitingTable({ + {snapshot.inGame && ( + + )} @@ -187,15 +197,18 @@ function PokerTableInner({ tableError, onSnapshot, onJoin, + onSpectate, onStart, onLeave, onBack, + isSuperuser, + superuserIds, }: PokerTableProps) { // Sound + popup transitions must live here, ABOVE the waiting/game split: // the hooks have to observe the waiting-room-to-first-hand transition, // which happens before the game table (and its seat view) mounts. const sound = usePokerSounds(snapshot); - const popups = usePokerPopups(snapshot); + const { popups, actionLog } = usePokerPopups(snapshot); if (loading && !snapshot) { return ( @@ -238,6 +251,7 @@ function PokerTableInner({ currentUserId={currentUserId} onSnapshot={onSnapshot} onJoin={onJoin} + onSpectate={onSpectate} onStart={onStart} onLeave={onLeave} onBack={onBack} @@ -251,8 +265,12 @@ function PokerTableInner({ onSnapshot={onSnapshot} onStart={onStart} onLeave={onLeave} + onJoin={onJoin} sound={sound} popups={popups} + actionLog={actionLog} + isSuperuser={isSuperuser} + superuserIds={superuserIds} /> ); } diff --git a/src/components/frontend/minigames/poker/pokerPopups.ts b/src/components/frontend/minigames/poker/pokerPopups.ts index 6376801..b946308 100644 --- a/src/components/frontend/minigames/poker/pokerPopups.ts +++ b/src/components/frontend/minigames/poker/pokerPopups.ts @@ -12,6 +12,27 @@ export interface PokerPopupEvent { readonly delay?: number; } +export interface PokerActionLogEntry { + readonly key: string; + readonly text: string; + readonly tone: PokerPopupTone; +} + +/** + * Compose the action-log line for a popup event, joining the acting player's + * name from the snapshot's player list (leavers are already named). + */ +export function actionLogText( + event: PokerPopupEvent, + players: readonly PublicPlayerView[], +): string { + if (event.tone === "leave") return event.text; + const actor = players.find((player) => player.seatId === event.seatId); + const name = actor?.name ?? `Seat ${event.seatId + 1}`; + if (event.tone === "win") return `${name} wins ${event.text}`; + return `${name}: ${event.text}`; +} + function playerBySeat( players: readonly PublicPlayerView[], seatId: number, diff --git a/src/components/frontend/minigames/poker/usePokerPopups.ts b/src/components/frontend/minigames/poker/usePokerPopups.ts index 5a0c734..cc8ca11 100644 --- a/src/components/frontend/minigames/poker/usePokerPopups.ts +++ b/src/components/frontend/minigames/poker/usePokerPopups.ts @@ -2,15 +2,21 @@ import { useEffect, useRef, useState } from "react"; import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions"; -import { popupEventsForTransition, type PokerPopupEvent } from "./pokerPopups"; +import { + actionLogText, + popupEventsForTransition, + type PokerActionLogEntry, + type PokerPopupEvent, +} from "./pokerPopups"; import type { PokerSoundSnapshot } from "./pokerSoundEvents"; export type ActivePokerPopup = PokerPopupEvent; -const POPUP_LIFETIME_MS = 1500; +const POPUP_LIFETIME_MS = 2000; const LEAVE_POPUP_LIFETIME_MS = 3000; const MAX_ACTIVE_POPUPS = 12; const MAX_SEEN_KEYS = 256; +const MAX_LOG_ENTRIES = 5; const POPUP_LIFETIMES_MS: Record = { action: POPUP_LIFETIME_MS, @@ -20,8 +26,12 @@ const POPUP_LIFETIMES_MS: Record = { leave: LEAVE_POPUP_LIFETIME_MS, }; -export function usePokerPopups(snapshot: PokerSnapshot | null): readonly ActivePokerPopup[] { +export function usePokerPopups(snapshot: PokerSnapshot | null): { + popups: readonly ActivePokerPopup[]; + actionLog: readonly PokerActionLogEntry[]; +} { const [popups, setPopups] = useState([]); + const [actionLog, setActionLog] = useState([]); const previousSnapshotRef = useRef(null); const seenKeysRef = useRef>(new Set()); const timersRef = useRef[]>([]); @@ -70,6 +80,21 @@ export function usePokerPopups(snapshot: PokerSnapshot | null): readonly ActiveP if (immediate.length > 0) { setPopups((current) => [...current, ...immediate].slice(-MAX_ACTIVE_POPUPS)); } + const nextView = snapshot.you; + if (nextView) { + setActionLog((current) => + [ + ...fresh + .map((event) => ({ + key: event.key, + text: actionLogText(event, nextView.players), + tone: event.tone, + })) + .reverse(), + ...current, + ].slice(0, MAX_LOG_ENTRIES), + ); + } } } previousSnapshotRef.current = snapshot; @@ -82,5 +107,5 @@ export function usePokerPopups(snapshot: PokerSnapshot | null): readonly ActiveP }; }, []); - return popups; + return { popups, actionLog }; } diff --git a/tests/int/poker-dev-controls.int.spec.ts b/tests/int/poker-dev-controls.int.spec.ts new file mode 100644 index 0000000..5bc7d64 --- /dev/null +++ b/tests/int/poker-dev-controls.int.spec.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { + advanceState, + applyAction, + createTableState, + type PokerTableState, +} from "@/lib/minigames/poker"; +import type { Card } from "@/lib/minigames/poker"; +import { + adjustStack, + createTable, + getSnapshotFor, + joinTable, + setBoardQueue, + setHoleQueue, + startGame, +} from "@/lib/minigames/poker/tables"; + +function card(rank: Card["rank"], suit: Card["suit"]): Card { + return { rank, suit }; +} + +function withQueues( + state: PokerTableState, + queues: Partial>, +): PokerTableState { + return { ...state, ...queues }; +} + +/** Apply an action and unwrap the result; throws on illegal actions. */ +function act( + state: PokerTableState, + seatId: number, + action: Parameters[2], + amount?: number, +): PokerTableState { + const result = applyAction(state, seatId, action, amount); + if (!result.success) throw new Error(result.error); + return result.state; +} + +describe("dev board queue", () => { + it("deals the queued flop and turn cards in order before falling back to the deck", () => { + let state = createTableState([{ name: "A" }, { name: "B" }], {}, () => 0.5); + const queued = [card(14, "s"), card(13, "s"), card(12, "s"), card(11, "s"), card(10, "s")]; + state = withQueues(state, { queuedBoard: queued }); + + // Preflop: the small blind calls to the big blind, the big blind checks, + // which completes the round and deals the flop from the queue. + state = act(state, 0, "call", undefined); + state = act(state, 1, "check", undefined); + + expect(state.communityCards).toEqual([card(14, "s"), card(13, "s"), card(12, "s")]); + expect(state.queuedBoard).toEqual([card(11, "s"), card(10, "s")]); + + // Flop: both check, dealing the turn from the queue. + state = act(state, 1, "check", undefined); + state = act(state, 0, "check", undefined); + + expect(state.communityCards[3]).toEqual(card(11, "s")); + expect(state.queuedBoard).toEqual([card(10, "s")]); + }); + + it("consumes queued cards inside runOutAndShowdown when betting is impossible", () => { + let state = createTableState( + [ + { name: "A", stack: 50 }, + { name: "B", stack: 50 }, + ], + {}, + () => 0.5, + ); + state = withQueues(state, { + queuedBoard: [card(5, "h"), card(6, "h"), card(7, "h"), card(8, "h"), card(9, "h")], + }); + + // Two quick all-ins leave nobody able to act: the board runs out. + state = act(state, 0, "raise", 50); + state = act(state, 1, "call", undefined); + + expect(state.communityCards).toEqual([ + card(5, "h"), + card(6, "h"), + card(7, "h"), + card(8, "h"), + card(9, "h"), + ]); + expect(state.queuedBoard).toEqual([]); + }); +}); + +describe("dev hole queues", () => { + it("replaces the next hand's hole cards for queued seats only", () => { + let state = createTableState([{ name: "A" }, { name: "B" }, { name: "C" }], {}, () => 0.5); + // Queue while hand 1 is live, then fold it out so hand 2 deals fresh. + state = withQueues(state, { + queuedHoles: { + 0: [card(14, "s"), card(14, "h")], + 1: [card(2, "c"), card(3, "c")], + }, + }); + state = act(state, 0, "fold", undefined); + state = act(state, 1, "fold", undefined); + const dealt = advanceState(state); + + expect(dealt.players[0].holeCards).toEqual([card(14, "s"), card(14, "h")]); + expect(dealt.players[1].holeCards).toEqual([card(2, "c"), card(3, "c")]); + expect(dealt.players[2].holeCards.length).toBe(2); + expect(dealt.queuedHoles).toEqual({}); + }); +}); + +describe("dev stack adjustment", () => { + it("adds and subtracts chips, clamped at zero", () => { + const tableId = createTable(9601, "Host"); + joinTable(tableId, 9602, "Guest"); + startGame(tableId, 9601); + + let snapshot = getSnapshotFor(tableId, 9601); + const hostSeat = snapshot.you?.viewerSeat ?? 0; + const before = snapshot.you?.players.find((p) => p.seatId === hostSeat)?.stack ?? 0; + + adjustStack(tableId, 9601, hostSeat, 250); + snapshot = getSnapshotFor(tableId, 9601); + expect(snapshot.you?.players.find((p) => p.seatId === hostSeat)?.stack).toBe(before + 250); + + adjustStack(tableId, 9601, hostSeat, -(before + 250) - 500); + snapshot = getSnapshotFor(tableId, 9601); + expect(snapshot.you?.players.find((p) => p.seatId === hostSeat)?.stack).toBe(0); + }); + + it("rejects invalid seats and oversized queues", () => { + const tableId = createTable(9611, "Host"); + joinTable(tableId, 9612, "Guest"); + startGame(tableId, 9611); + + expect(() => + setBoardQueue(tableId, 9611, [ + card(2, "s"), + card(3, "s"), + card(4, "s"), + card(5, "s"), + card(6, "s"), + card(7, "s"), + ]), + ).toThrow(); + expect(() => setHoleQueue(tableId, 9611, 9, [card(2, "s"), card(3, "s")])).toThrow(); + expect(() => adjustStack(tableId, 9611, 9, 100)).toThrow(); + }); +}); diff --git a/tests/int/poker-popups.int.spec.ts b/tests/int/poker-popups.int.spec.ts index 3280936..9e9e8a3 100644 --- a/tests/int/poker-popups.int.spec.ts +++ b/tests/int/poker-popups.int.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { PublicPlayerView, ShowdownOutcome } from "@/lib/minigames/poker"; import { + actionLogText, popupEventsForTransition, type PokerPopupEvent, } from "@/components/frontend/minigames/poker/pokerPopups"; @@ -301,6 +302,35 @@ describe("player leave detection", () => { }); }); +describe("action log text", () => { + const players = [player(0, { name: "Dev" }), player(1, { name: "J. Fraley" })]; + + it("joins the actor's name for actions and wagers", () => { + const event = (text: string, tone: PokerPopupEvent["tone"]): PokerPopupEvent => ({ + key: "k", + seatId: 0, + text, + tone, + }); + expect(actionLogText(event("Fold", "fold"), players)).toBe("Dev: Fold"); + expect(actionLogText(event("Check", "action"), players)).toBe("Dev: Check"); + expect(actionLogText(event("Raise +60", "wager"), players)).toBe("Dev: Raise +60"); + expect(actionLogText(event("+120", "win"), players)).toBe("Dev wins +120"); + }); + + it("names a leaver from the event text and falls back to the seat number", () => { + const leave: PokerPopupEvent = { + key: "k", + seatId: 1, + text: "J. Fraley left the table", + tone: "leave", + }; + expect(actionLogText(leave, players)).toBe("J. Fraley left the table"); + const ghost: PokerPopupEvent = { key: "k", seatId: 3, text: "Fold", tone: "fold" }; + expect(actionLogText(ghost, players)).toBe("Seat 4: Fold"); + }); +}); + describe("isTopSeatPosition", () => { it("marks top-row arc classes as top seats", () => { expect(isTopSeatPosition("poker-seat-arc-center")).toBe(true); diff --git a/tests/int/poker-tables.int.spec.ts b/tests/int/poker-tables.int.spec.ts index fcde141..9c409d0 100644 --- a/tests/int/poker-tables.int.spec.ts +++ b/tests/int/poker-tables.int.spec.ts @@ -5,6 +5,7 @@ import { getSnapshotFor, joinTable, leaveTable, + spectateTable, startGame, } from "@/lib/minigames/poker/tables"; @@ -99,3 +100,53 @@ describe("poker table registry: last player standing", () => { expect(third.you).toBeNull(); }); }); + +describe("poker table registry: spectators", () => { + it("gives a running game's spectator the public view with no seat and no actions", () => { + const tableId = createTable(9501, "Host"); + joinTable(tableId, 9502, "Guest"); + startGame(tableId, 9501); + + const snapshot = spectateTable(tableId, 9503); + expect(snapshot.spectator).toBe(true); + expect(snapshot.you?.viewerSeat).toBe(-1); + expect(snapshot.you?.players).toHaveLength(2); + expect(snapshot.you?.yourHoleCards).toHaveLength(0); + expect(snapshot.you?.yourTurn).toBe(false); + expect(snapshot.you?.legalActions.canFold).toBe(false); + expect(snapshot.you?.legalActions.canCheck).toBe(false); + expect(snapshot.you?.legalActions.canRaise).toBe(false); + }); + + it("refuses to spectate a table without a running game", () => { + const tableId = createTable(9511, "Host"); + expect(() => spectateTable(tableId, 9512)).toThrow("No game is running at this table."); + }); + + it("drops the spectator flag when they take a seat through a mid-game join", () => { + const tableId = createTable(9521, "Host"); + joinTable(tableId, 9522, "Guest"); + startGame(tableId, 9521); + + spectateTable(tableId, 9523); + joinTable(tableId, 9523, "Third"); + + const snapshot = getSnapshotFor(tableId, 9523); + expect(snapshot.spectator).toBeUndefined(); + const seatIndex = snapshot.seats.findIndex((id) => id === 9523); + expect(seatIndex).toBeGreaterThanOrEqual(0); + }); + + it("stops watching on leave and returns to no seat", () => { + const tableId = createTable(9531, "Host"); + joinTable(tableId, 9532, "Guest"); + startGame(tableId, 9531); + + spectateTable(tableId, 9533); + leaveTable(tableId, 9533); + + const snapshot = getSnapshotFor(tableId, 9533); + expect(snapshot.spectator).toBeUndefined(); + expect(snapshot.you).toBeNull(); + }); +});