1
0
Fork 0

feat(poker): felt UI popups, named table display, and dev controls

Table components, popup system, dev panel, and the actions/page wiring, with popup, table, and dev-control tests.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-14 15:37:19 -04:00
parent c5defa9e70
commit 34dcefec54
13 changed files with 861 additions and 72 deletions

View file

@ -8,23 +8,28 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; 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 { isSuperuser } from "@/utils/access-control/hasPermission";
import { import {
adjustStack,
applyPlayerAction, applyPlayerAction,
createTable, createTable,
forceExpireTable, forceExpireTable,
getSnapshotFor, getSnapshotFor,
joinTable, joinTableWithBuyIn,
leaveTable, leaveTableWithBuyIn,
spectateTable,
listSummaries, listSummaries,
renameTable, renameTable,
startGame, setBoardQueue,
setHoleQueue,
startGameWithBuyIn,
type PokerSnapshot, type PokerSnapshot,
type PokerTableSummary, type PokerTableSummary,
} from "@/lib/minigames/poker/tables"; } from "@/lib/minigames/poker/tables";
export type { PokerSnapshot, PokerTableSummary }; export type { PokerSnapshot, PokerTableSummary };
export type PokerCardInput = Card;
interface ActionResult<T = undefined> { interface ActionResult<T = undefined> {
success: boolean; success: boolean;
@ -105,7 +110,70 @@ export async function forceExpirePokerTable(tableId: string): Promise<ActionResu
export async function joinPokerTable(tableId: string): Promise<ActionResult<PokerSnapshot>> { export async function joinPokerTable(tableId: string): Promise<ActionResult<PokerSnapshot>> {
try { try {
const { user } = await authenticate(); 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<ActionResult<PokerSnapshot>> {
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<ActionResult<PokerSnapshot>> {
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<ActionResult<PokerSnapshot>> {
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<ActionResult<PokerSnapshot>> {
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 }; return { success: true, data: snapshot };
} catch (error) { } catch (error) {
return toError(error); return toError(error);
@ -115,7 +183,7 @@ export async function joinPokerTable(tableId: string): Promise<ActionResult<Poke
export async function leavePokerTable(tableId: string): Promise<ActionResult<undefined>> { export async function leavePokerTable(tableId: string): Promise<ActionResult<undefined>> {
try { try {
const { user } = await authenticate(); const { user } = await authenticate();
leaveTable(tableId, Number(user.id)); await leaveTableWithBuyIn(tableId, Number(user.id));
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
return toError(error); return toError(error);
@ -125,7 +193,7 @@ export async function leavePokerTable(tableId: string): Promise<ActionResult<und
export async function startPokerGame(tableId: string): Promise<ActionResult<PokerSnapshot>> { export async function startPokerGame(tableId: string): Promise<ActionResult<PokerSnapshot>> {
try { try {
const { user } = await authenticate(); const { user } = await authenticate();
const snapshot = startGame(tableId, Number(user.id)); const snapshot = await startGameWithBuyIn(tableId, Number(user.id));
return { success: true, data: snapshot }; return { success: true, data: snapshot };
} catch (error) { } catch (error) {
return toError(error); return toError(error);

View file

@ -6,7 +6,7 @@ import { redirect } from "next/navigation";
import { Spade } from "lucide-react"; import { Spade } from "lucide-react";
import { PokerArena } from "@/components/frontend/minigames/poker/PokerArena"; import { PokerArena } from "@/components/frontend/minigames/poker/PokerArena";
import { readPokerStats } from "@/lib/minigames/poker/stats"; 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 = { export const metadata: Metadata = {
title: "Poker: Polaris Task Force", title: "Poker: Polaris Task Force",
@ -29,6 +29,7 @@ export default async function PokerPage({ searchParams }: PokerPageProps) {
} }
const superuser = await isSuperuser(payload, user); const superuser = await isSuperuser(payload, user);
const superuserIds = await superuserUserIds(payload);
const profileRes = await payload.find({ const profileRes = await payload.find({
collection: "profiles", collection: "profiles",
@ -39,11 +40,19 @@ export default async function PokerPage({ searchParams }: PokerPageProps) {
select: { select: {
progression: { progression: {
minigames: true, minigames: true,
chipBank: true,
chipBankRefilledAt: true,
}, },
}, },
}); });
const stats = readPokerStats(profileRes.docs[0]); 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 params = await searchParams;
const tableValue = params.table; const tableValue = params.table;
const initialTableId = Array.isArray(tableValue) ? tableValue[0] : tableValue; const initialTableId = Array.isArray(tableValue) ? tableValue[0] : tableValue;
@ -66,6 +75,9 @@ export default async function PokerPage({ searchParams }: PokerPageProps) {
currentUserId={Number(user.id)} currentUserId={Number(user.id)}
initialTableId={initialTableId} initialTableId={initialTableId}
isSuperuser={superuser} isSuperuser={superuser}
superuserIds={superuserIds}
initialChipBank={initialChipBank}
initialChipTopUpAt={initialChipTopUpAt}
/> />
</div> </div>
); );

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { Eye, Shield } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { PublicPlayerView } from "@/lib/minigames/poker"; import type { PublicPlayerView } from "@/lib/minigames/poker";
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions"; import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
@ -16,6 +17,9 @@ interface PokerActionConsoleProps {
readonly handLabel: string | null; readonly handLabel: string | null;
readonly handTier: number | null; readonly handTier: number | null;
readonly hostSeatId: number | null; readonly hostSeatId: number | null;
readonly spectator?: boolean;
readonly onJoin?: () => void;
readonly isSuperuser?: boolean;
readonly starting: boolean; readonly starting: boolean;
readonly leaving: boolean; readonly leaving: boolean;
readonly onStart: () => void; readonly onStart: () => void;
@ -83,6 +87,9 @@ export function PokerActionConsole({
handLabel, handLabel,
handTier, handTier,
hostSeatId, hostSeatId,
spectator = false,
onJoin,
isSuperuser = false,
starting, starting,
leaving, leaving,
onStart, onStart,
@ -92,6 +99,7 @@ export function PokerActionConsole({
const viewerPlayer: PublicPlayerView | undefined = view.players.find( const viewerPlayer: PublicPlayerView | undefined = view.players.find(
(player) => player.seatId === view.viewerSeat, (player) => player.seatId === view.viewerSeat,
); );
const seatsOpen = snapshot.seats.filter((seat) => seat === null).length;
const chipsInPlay = viewerPlayer?.committed ?? 0; const chipsInPlay = viewerPlayer?.committed ?? 0;
const viewerInitial = (viewerPlayer?.name ?? "You").trim().charAt(0).toUpperCase() || "?"; const viewerInitial = (viewerPlayer?.name ?? "You").trim().charAt(0).toUpperCase() || "?";
@ -105,50 +113,88 @@ export function PokerActionConsole({
aria-label="Your poker action console" aria-label="Your poker action console"
> >
<div className="poker-console-player"> <div className="poker-console-player">
<span {spectator ? (
className={cn( <>
"poker-seat-avatar poker-console-avatar",
`poker-seat-avatar-tone-${view.viewerSeat % 4}`,
)}
aria-hidden="true"
>
{viewerInitial}
</span>
<div className="poker-console-identity">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">
{viewerPlayer?.name ?? "You"}
<span className="ml-1 text-xs text-white/55">(you)</span>
</p>
<p className="font-mono text-xs tabular-nums text-white/65">
Stack {viewerPlayer?.stack.toLocaleString() ?? "0"}
</p>
</div>
<ViewerRoleBadges view={view} />
</div>
{handLabel && (
<span <span
className={`poker-hand-placard${ className="poker-seat-avatar poker-console-avatar poker-seat-avatar-open"
handTier && handTier > 0 ? ` poker-rank-tier-${handTier}` : "" aria-hidden="true"
}`}
> >
You have: {handLabel} <Eye className="size-4" />
</span> </span>
)} <div className="poker-console-identity">
</div> <div className="flex min-w-0 items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">Spectating</p>
<p className="text-xs text-muted-foreground">
{seatsOpen > 0
? `${seatsOpen} seat${seatsOpen > 1 ? "s" : ""} open`
: "No open seats"}
</p>
</div>
</div>
</div>
</>
) : (
<>
<span
className={cn(
"poker-seat-avatar poker-console-avatar",
`poker-seat-avatar-tone-${view.viewerSeat % 4}`,
)}
aria-hidden="true"
>
{viewerInitial}
</span>
<div className="poker-console-identity">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">
{viewerPlayer?.name ?? "You"}
{isSuperuser && (
<Shield
aria-label="Superuser"
className="ml-1 inline size-3.5 text-amber-300"
/>
)}
<span className="ml-1 text-xs text-white/55">(you)</span>
</p>
<p className="font-mono text-xs tabular-nums text-white/65">
Stack {viewerPlayer?.stack.toLocaleString() ?? "0"}
</p>
</div>
<ViewerRoleBadges view={view} />
</div>
{handLabel && (
<span
className={`poker-hand-placard${
handTier && handTier > 0 ? ` poker-rank-tier-${handTier}` : ""
}`}
>
You have: {handLabel}
</span>
)}
</div>
</>
)}
</div> </div>
<div className="poker-console-economy"> <div className="poker-console-economy">
<div className="poker-chip-readout"> {!spectator && (
<span className="poker-chip-stack" aria-hidden="true" /> <div className="poker-chip-readout">
<span className="poker-console-readout-copy"> <span className="poker-chip-stack" aria-hidden="true" />
<span className="poker-console-kicker">Chips in play</span> <span className="poker-console-readout-copy">
<strong className="font-mono text-sm tabular-nums"> <span className="poker-console-kicker">Chips in play</span>
{chipsInPlay.toLocaleString()} <strong className="font-mono text-sm tabular-nums">
</strong> {chipsInPlay.toLocaleString()}
</span> </strong>
</div> </span>
</div>
)}
{spectator && seatsOpen > 0 && onJoin && (
<Button type="button" className="poker-chip poker-chip-call" onClick={onJoin}>
Join next hand
</Button>
)}
{view.phase === "betting" && ( {view.phase === "betting" && (
<div className="poker-console-timer"> <div className="poker-console-timer">
<PokerClock <PokerClock

View file

@ -0,0 +1,316 @@
"use client";
import { useState } from "react";
import { Shield, Trash2 } from "lucide-react";
import type { Card, Rank, Suit } from "@/lib/minigames/poker";
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
import {
adjustPokerStack,
setPokerBoardQueue,
setPokerHoleQueue,
} from "@/app/(frontend)/minigames/poker/actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const RANK_OPTIONS: readonly { value: Rank; label: string }[] = [
{ value: 2, label: "2" },
{ value: 3, label: "3" },
{ value: 4, label: "4" },
{ value: 5, label: "5" },
{ value: 6, label: "6" },
{ value: 7, label: "7" },
{ value: 8, label: "8" },
{ value: 9, label: "9" },
{ value: 10, label: "10" },
{ value: 11, label: "Jack" },
{ value: 12, label: "Queen" },
{ value: 13, label: "King" },
{ value: 14, label: "Ace" },
];
const SUIT_OPTIONS: readonly { value: Suit; label: string }[] = [
{ value: "s", label: "Spades" },
{ value: "h", label: "Hearts" },
{ value: "d", label: "Diamonds" },
{ value: "c", label: "Clubs" },
];
function cardLabel(card: Card): string {
const rank =
RANK_OPTIONS.find((option) => 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 (
<div className="flex items-center gap-1.5">
<span className="w-10 text-xs text-muted-foreground">{label}</span>
<Select
value={value ? String(value.rank) : ""}
onValueChange={(next) => onChange(value ? { ...value, rank: Number(next) as Rank } : null)}
>
<SelectTrigger className="h-8 w-20 text-xs">
<SelectValue placeholder="Rank" />
</SelectTrigger>
<SelectContent>
{RANK_OPTIONS.map((option) => (
<SelectItem key={option.value} value={String(option.value)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={value?.suit ?? ""}
onValueChange={(next) => onChange(value ? { ...value, suit: next as Suit } : null)}
>
<SelectTrigger className="h-8 w-24 text-xs">
<SelectValue placeholder="Suit" />
</SelectTrigger>
<SelectContent>
{SUIT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
export function PokerDevPanel({
tableId,
view,
}: {
readonly tableId: string;
readonly view: NonNullable<PokerSnapshot["you"]>;
}) {
const [open, setOpen] = useState(false);
const [boardQueue, setBoardQueue] = useState<Card[]>([]);
const [boardDraft, setBoardDraft] = useState<Card | null>(null);
const [seatId, setSeatId] = useState<string>("");
const [holeA, setHoleA] = useState<Card | null>(null);
const [holeB, setHoleB] = useState<Card | null>(null);
const [adjustSeat, setAdjustSeat] = useState<string>("");
const [adjustAmount, setAdjustAmount] = useState<string>("");
const [error, setError] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
aria-label="Developer controls"
title="Developer controls"
>
<Shield aria-hidden="true" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-[85dvh] max-w-lg overflow-y-auto rounded-none">
<DialogHeader>
<DialogTitle>Developer controls</DialogTitle>
<DialogDescription>
Queue upcoming cards or adjust stacks. Queues apply at the next hand; board cards hit
the felt in order.
</DialogDescription>
</DialogHeader>
{error && <p className="text-sm text-destructive">{error}</p>}
<section className="flex flex-col gap-2 border border-border p-3">
<h3 className="text-xs font-semibold uppercase tracking-wider">Board queue</h3>
<div className="flex flex-col gap-1.5">
<CardPicker
label="Card"
value={boardDraft}
onChange={(card) => setBoardDraft(card ?? { rank: 2, suit: "s" })}
/>
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
disabled={boardQueue.length >= boardSlotsLeft}
onClick={() => {
if (!boardDraft) return;
setBoardQueue((current) => [...current, boardDraft]);
setBoardDraft(null);
}}
>
Add to queue ({boardSlotsLeft} slot{boardSlotsLeft === 1 ? "" : "s"} left)
</Button>
</div>
{boardQueue.length > 0 && (
<ul className="flex flex-col gap-1">
{boardQueue.map((card, index) => (
<li
key={`${card.rank}-${card.suit}-${index}`}
className="flex items-center gap-2 text-xs"
>
<span className="font-mono text-muted-foreground">{index + 1}.</span>
<span>{cardLabel(card)}</span>
<button
type="button"
aria-label={`Remove ${cardLabel(card)}`}
className="ml-auto text-muted-foreground hover:text-destructive"
onClick={() =>
setBoardQueue((current) => current.filter((_, i) => i !== index))
}
>
<Trash2 className="size-3.5" />
</button>
</li>
))}
</ul>
)}
<Button
type="button"
size="sm"
className="w-fit"
onClick={queueBoard}
disabled={boardQueue.length === 0}
>
Set board queue
</Button>
</section>
<section className="flex flex-col gap-2 border border-border p-3">
<h3 className="text-xs font-semibold uppercase tracking-wider">Next hand hole cards</h3>
<Select value={seatId} onValueChange={setSeatId}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Player" />
</SelectTrigger>
<SelectContent>
{view.players
.filter((player) => !player.eliminated)
.map((player) => (
<SelectItem key={player.seatId} value={String(player.seatId)}>
{player.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex flex-col gap-1.5">
<CardPicker label="Card 1" value={holeA} onChange={setHoleA} />
<CardPicker label="Card 2" value={holeB} onChange={setHoleB} />
</div>
<Button
type="button"
size="sm"
className="w-fit"
onClick={queueHole}
disabled={seatId === "" || !holeA || !holeB}
>
Queue hand
</Button>
</section>
<section className="flex flex-col gap-2 border border-border p-3">
<h3 className="text-xs font-semibold uppercase tracking-wider">Stack adjustment</h3>
<Select value={adjustSeat} onValueChange={setAdjustSeat}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Player" />
</SelectTrigger>
<SelectContent>
{view.players
.filter((player) => !player.eliminated)
.map((player) => (
<SelectItem key={player.seatId} value={String(player.seatId)}>
{player.name} ({player.stack.toLocaleString()})
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
step={1}
placeholder="Chips"
value={adjustAmount}
onChange={(event) => setAdjustAmount(event.target.value)}
className="h-8 w-28 text-xs"
/>
<Button type="button" variant="outline" size="sm" onClick={() => adjust(1)}>
Add
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => adjust(-1)}>
Subtract
</Button>
</div>
</section>
</DialogContent>
</Dialog>
);
}

View file

@ -11,6 +11,7 @@ import { PokerActionToolbar } from "./PokerActionToolbar";
import { PokerClock } from "./PokerClock"; import { PokerClock } from "./PokerClock";
import { PokerDeckOrigin } from "./PokerDeckOrigin"; import { PokerDeckOrigin } from "./PokerDeckOrigin";
import { PokerPopupBubble } from "./PokerPopups"; import { PokerPopupBubble } from "./PokerPopups";
import type { PokerActionLogEntry } from "./pokerPopups";
import { PokerSeat } from "./PokerSeat"; import { PokerSeat } from "./PokerSeat";
import type { ActivePokerPopup } from "./usePokerPopups"; import type { ActivePokerPopup } from "./usePokerPopups";
import { isTopSeatPosition, seatPosition } from "./seatPosition"; import { isTopSeatPosition, seatPosition } from "./seatPosition";
@ -35,6 +36,10 @@ interface PokerFeltCanvasProps {
readonly actionProps: FeltCanvasActionProps; readonly actionProps: FeltCanvasActionProps;
readonly onActionFeedback: () => void; readonly onActionFeedback: () => void;
readonly popups: readonly ActivePokerPopup[]; readonly popups: readonly ActivePokerPopup[];
readonly actionLog: readonly PokerActionLogEntry[];
readonly spectator?: boolean;
readonly onJoin?: () => void;
readonly superuserIds?: readonly number[];
readonly fullscreen?: boolean; readonly fullscreen?: boolean;
} }
@ -88,6 +93,10 @@ export function PokerFeltCanvas({
actionProps, actionProps,
onActionFeedback, onActionFeedback,
popups, popups,
actionLog,
spectator = false,
onJoin,
superuserIds,
fullscreen = false, fullscreen = false,
}: PokerFeltCanvasProps) { }: PokerFeltCanvasProps) {
const view = snapshot.you; const view = snapshot.you;
@ -115,7 +124,7 @@ export function PokerFeltCanvas({
<div <div
key={player.seatId} key={player.seatId}
className={cn( className={cn(
"poker-seat-slot absolute z-10 w-36 max-w-[34%] sm:w-52", "poker-seat-slot absolute z-10 w-44 max-w-[34%] sm:w-60",
positionClass, positionClass,
)} )}
> >
@ -125,6 +134,7 @@ export function PokerFeltCanvas({
isActing={view.phase === "betting" && player.seatId === view.toActSeat} isActing={view.phase === "betting" && player.seatId === view.toActSeat}
isWinner={winnerSeats.has(player.seatId)} isWinner={winnerSeats.has(player.seatId)}
showHoleCards={view.phase !== "hand-complete" && view.phase !== "game-complete"} showHoleCards={view.phase !== "hand-complete" && view.phase !== "game-complete"}
isSuperuser={superuserIds?.includes(Number(snapshot.seats[player.seatId])) ?? false}
/> />
{popupsForSeat(player.seatId).length > 0 && ( {popupsForSeat(player.seatId).length > 0 && (
<div <div
@ -139,15 +149,17 @@ export function PokerFeltCanvas({
))} ))}
</div> </div>
)} )}
<div className="pointer-events-none absolute -top-2.5 left-1/2 z-20 flex -translate-x-1/2 gap-1"> {player.seatId === view.buttonSeat && (
{player.seatId === view.buttonSeat && ( <span className="poker-dealer-button pointer-events-none absolute -bottom-2 left-2 z-20 flex size-6 items-center justify-center rounded-full border text-xs font-bold">
<span className="poker-dealer-button flex size-6 items-center justify-center rounded-full border text-xs font-bold"> D
D </span>
</span> )}
)} {(player.seatId === view.smallBlindSeat || player.seatId === view.bigBlindSeat) && (
{player.seatId === view.smallBlindSeat && <Badge variant="secondary">SB</Badge>} <div className="pointer-events-none absolute -bottom-2 right-2 z-20 flex gap-1">
{player.seatId === view.bigBlindSeat && <Badge variant="secondary">BB</Badge>} {player.seatId === view.smallBlindSeat && <Badge variant="secondary">SB</Badge>}
</div> {player.seatId === view.bigBlindSeat && <Badge variant="secondary">BB</Badge>}
</div>
)}
</div> </div>
); );
})} })}
@ -230,11 +242,23 @@ export function PokerFeltCanvas({
</div> </div>
)} )}
<PokerActionToolbar {actionLog.length > 0 && (
snapshot={snapshot} <div className="poker-action-log" aria-label="Recent actions" aria-live="polite">
onSnapshot={onSnapshot} {actionLog.map((entry) => (
onActionFeedback={onActionFeedback} <p key={entry.key} className={`poker-action-log-line poker-log-${entry.tone}`}>
/> {entry.text}
</p>
))}
</div>
)}
{!spectator && (
<PokerActionToolbar
snapshot={snapshot}
onSnapshot={onSnapshot}
onActionFeedback={onActionFeedback}
/>
)}
{viewerPopups.length > 0 && ( {viewerPopups.length > 0 && (
<div className="poker-popup-stack poker-popup-viewer-spot" aria-hidden="true"> <div className="poker-popup-stack poker-popup-viewer-spot" aria-hidden="true">
@ -268,6 +292,9 @@ export function PokerFeltCanvas({
handLabel={handLabel} handLabel={handLabel}
handTier={handTier} handTier={handTier}
hostSeatId={snapshot.hostSeatId} hostSeatId={snapshot.hostSeatId}
spectator={spectator}
onJoin={onJoin}
isSuperuser={superuserIds?.includes(Number(snapshot.seats[view.viewerSeat])) ?? false}
starting={actionProps.starting} starting={actionProps.starting}
leaving={actionProps.leaving} leaving={actionProps.leaving}
onStart={actionProps.onStart} onStart={actionProps.onStart}

View file

@ -10,10 +10,12 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card as UiCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card as UiCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PokerCardStyleToggle } from "./PokerCardStyleToggle"; import { PokerCardStyleToggle } from "./PokerCardStyleToggle";
import { PokerDevPanel } from "./PokerDevPanel";
import { PokerFeltCanvas, type SeatedSnapshot } from "./PokerFeltCanvas"; import { PokerFeltCanvas, type SeatedSnapshot } from "./PokerFeltCanvas";
import { PokerSoundToggle } from "./PokerSoundToggle"; import { PokerSoundToggle } from "./PokerSoundToggle";
import type { PokerSoundControls } from "./usePokerSounds"; import type { PokerSoundControls } from "./usePokerSounds";
import type { ActivePokerPopup } from "./usePokerPopups"; import type { ActivePokerPopup } from "./usePokerPopups";
import type { PokerActionLogEntry } from "./pokerPopups";
import { RenameDialogGuard } from "./RenameDialogGuard"; import { RenameDialogGuard } from "./RenameDialogGuard";
interface PokerGameTableProps { interface PokerGameTableProps {
@ -21,8 +23,12 @@ interface PokerGameTableProps {
readonly onSnapshot: (snapshot: PokerSnapshot) => void; readonly onSnapshot: (snapshot: PokerSnapshot) => void;
readonly onStart: () => Promise<void>; readonly onStart: () => Promise<void>;
readonly onLeave: () => Promise<void>; readonly onLeave: () => Promise<void>;
readonly onJoin: () => Promise<void>;
readonly sound: PokerSoundControls; readonly sound: PokerSoundControls;
readonly popups: readonly ActivePokerPopup[]; readonly popups: readonly ActivePokerPopup[];
readonly actionLog: readonly PokerActionLogEntry[];
readonly isSuperuser: boolean;
readonly superuserIds: readonly number[];
} }
const PHASE_LABELS: Record<PokerPhase, string> = { const PHASE_LABELS: Record<PokerPhase, string> = {
@ -37,8 +43,12 @@ export function PokerGameTable({
onSnapshot, onSnapshot,
onStart, onStart,
onLeave, onLeave,
onJoin,
sound, sound,
popups, popups,
actionLog,
isSuperuser,
superuserIds,
}: PokerGameTableProps) { }: PokerGameTableProps) {
const [starting, setStarting] = useState(false); const [starting, setStarting] = useState(false);
const [leaving, setLeaving] = useState(false); const [leaving, setLeaving] = useState(false);
@ -83,6 +93,7 @@ export function PokerGameTable({
}; };
const tableName = snapshot.name ?? `Table ${snapshot.tableId}`; const tableName = snapshot.name ?? `Table ${snapshot.tableId}`;
const spectator = Boolean(snapshot.spectator);
const feltCanvas = ( const feltCanvas = (
<PokerFeltCanvas <PokerFeltCanvas
snapshot={snapshot} snapshot={snapshot}
@ -98,6 +109,10 @@ export function PokerGameTable({
}} }}
onActionFeedback={sound.playUiClick} onActionFeedback={sound.playUiClick}
popups={popups} popups={popups}
actionLog={actionLog}
spectator={spectator}
onJoin={onJoin}
superuserIds={superuserIds}
fullscreen={fullscreen} fullscreen={fullscreen}
/> />
); );
@ -110,9 +125,11 @@ export function PokerGameTable({
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border border-white/15 bg-black/60 p-3 backdrop-blur-sm"> <div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border border-white/15 bg-black/60 p-3 backdrop-blur-sm">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<span className="truncate font-semibold text-white">{tableName}</span> <span className="truncate font-semibold text-white">{tableName}</span>
{spectator && <Badge variant="secondary">Spectating</Badge>}
<Badge variant="outline">{PHASE_LABELS[view.phase]}</Badge> <Badge variant="outline">{PHASE_LABELS[view.phase]}</Badge>
</div> </div>
<div className="flex flex-wrap items-center justify-end gap-2"> <div className="flex flex-wrap items-center justify-end gap-2">
{isSuperuser && <PokerDevPanel tableId={snapshot.tableId} view={view} />}
<PokerCardStyleToggle /> <PokerCardStyleToggle />
<PokerSoundToggle <PokerSoundToggle
muted={sound.muted} muted={sound.muted}
@ -137,7 +154,7 @@ export function PokerGameTable({
onClick={() => void leave()} onClick={() => void leave()}
disabled={leaving} disabled={leaving}
> >
{leaving ? "Leaving..." : "Leave table"} {leaving ? "Leaving..." : spectator ? "Stop watching" : "Leave table"}
</Button> </Button>
)} )}
</div> </div>
@ -160,6 +177,7 @@ export function PokerGameTable({
currentName={snapshot.name} currentName={snapshot.name}
onSnapshot={onSnapshot} onSnapshot={onSnapshot}
/> />
{spectator && <Badge variant="secondary">Spectating</Badge>}
<Badge variant="outline">{PHASE_LABELS[view.phase]}</Badge> <Badge variant="outline">{PHASE_LABELS[view.phase]}</Badge>
</div> </div>
<p className="font-mono text-xs tabular-nums text-muted-foreground"> <p className="font-mono text-xs tabular-nums text-muted-foreground">
@ -170,6 +188,7 @@ export function PokerGameTable({
<span className="font-mono text-xs tabular-nums text-muted-foreground"> <span className="font-mono text-xs tabular-nums text-muted-foreground">
v{snapshot.version} v{snapshot.version}
</span> </span>
{isSuperuser && <PokerDevPanel tableId={snapshot.tableId} view={view} />}
<PokerCardStyleToggle /> <PokerCardStyleToggle />
<PokerSoundToggle <PokerSoundToggle
muted={sound.muted} muted={sound.muted}
@ -189,7 +208,7 @@ export function PokerGameTable({
</Button> </Button>
{view.phase !== "game-complete" && ( {view.phase !== "game-complete" && (
<Button type="button" variant="outline" onClick={() => void leave()} disabled={leaving}> <Button type="button" variant="outline" onClick={() => void leave()} disabled={leaving}>
{leaving ? "Leaving..." : "Leave table"} {leaving ? "Leaving..." : spectator ? "Stop watching" : "Leave table"}
</Button> </Button>
)} )}
</div> </div>

View file

@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Shield } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { PublicPlayerView } from "@/lib/minigames/poker"; import type { PublicPlayerView } from "@/lib/minigames/poker";
import { CardView } from "./CardView"; import { CardView } from "./CardView";
@ -11,6 +12,7 @@ interface PokerSeatProps {
readonly isActing: boolean; readonly isActing: boolean;
readonly isWinner: boolean; readonly isWinner: boolean;
readonly showHoleCards: boolean; readonly showHoleCards: boolean;
readonly isSuperuser?: boolean;
} }
type StackFlash = "up" | "down" | null; type StackFlash = "up" | "down" | null;
@ -23,6 +25,7 @@ export function PokerSeat({
isActing, isActing,
isWinner, isWinner,
showHoleCards, showHoleCards,
isSuperuser = false,
}: PokerSeatProps) { }: PokerSeatProps) {
const revealed = showHoleCards ? player.holeCards : null; const revealed = showHoleCards ? player.holeCards : null;
const inHand = showHoleCards && !player.folded && !player.eliminated && !player.left; 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", "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.folded || player.left) && "opacity-55",
player.eliminated && "opacity-40", player.eliminated && "opacity-40",
isActing && "poker-seat-active ring-2 ring-emerald-400/90", isActing && "poker-seat-active",
isCurrentUser && "border-ring", isCurrentUser && "border-ring",
isWinner && "ring-2 ring-amber-300", isWinner && "ring-2 ring-amber-300",
)} )}
@ -71,9 +74,12 @@ export function PokerSeat({
{initial} {initial}
</span> </span>
<div className="poker-seat-nameplate min-w-0"> <div className="poker-seat-nameplate min-w-0">
<p className="truncate text-[0.65rem] font-medium leading-tight text-muted-foreground"> <p className="flex items-center gap-1 truncate text-xs font-medium leading-tight text-muted-foreground">
{player.name} {player.name}
{isCurrentUser && <span className="ml-1">(you)</span>} {isSuperuser && (
<Shield aria-label="Superuser" className="size-3 shrink-0 text-amber-300" />
)}
{isCurrentUser && <span className="shrink-0">(you)</span>}
</p> </p>
<p <p
className={cn( className={cn(
@ -116,7 +122,7 @@ export function PokerSeat({
aria-label={`Current bet ${player.committed.toLocaleString()}`} aria-label={`Current bet ${player.committed.toLocaleString()}`}
> >
<span className="poker-seat-commit-chip" aria-hidden="true" /> <span className="poker-seat-commit-chip" aria-hidden="true" />
<span className="font-mono text-[0.65rem] tabular-nums"> <span className="font-mono text-[0.75rem] tabular-nums">
{player.committed.toLocaleString()} {player.committed.toLocaleString()}
</span> </span>
</div> </div>

View file

@ -22,9 +22,12 @@ interface PokerTableProps {
readonly tableError: string | null; readonly tableError: string | null;
readonly onSnapshot: (snapshot: PokerSnapshot) => void; readonly onSnapshot: (snapshot: PokerSnapshot) => void;
readonly onJoin: () => Promise<void>; readonly onJoin: () => Promise<void>;
readonly onSpectate: () => Promise<void>;
readonly onStart: () => Promise<void>; readonly onStart: () => Promise<void>;
readonly onLeave: () => Promise<void>; readonly onLeave: () => Promise<void>;
readonly onBack: () => void; readonly onBack: () => void;
readonly isSuperuser: boolean;
readonly superuserIds: readonly number[];
} }
type SeatedSnapshot = PokerSnapshot & { type SeatedSnapshot = PokerSnapshot & {
@ -40,6 +43,7 @@ function WaitingTable({
currentUserId, currentUserId,
onSnapshot, onSnapshot,
onJoin, onJoin,
onSpectate,
onStart, onStart,
onLeave, onLeave,
onBack, onBack,
@ -48,6 +52,7 @@ function WaitingTable({
readonly currentUserId: number | string; readonly currentUserId: number | string;
readonly onSnapshot: (snapshot: PokerSnapshot) => void; readonly onSnapshot: (snapshot: PokerSnapshot) => void;
readonly onJoin: () => Promise<void>; readonly onJoin: () => Promise<void>;
readonly onSpectate: () => Promise<void>;
readonly onStart: () => Promise<void>; readonly onStart: () => Promise<void>;
readonly onLeave: () => Promise<void>; readonly onLeave: () => Promise<void>;
readonly onBack: () => void; readonly onBack: () => void;
@ -170,6 +175,11 @@ function WaitingTable({
<Button type="button" onClick={() => void onJoin()} disabled={full}> <Button type="button" onClick={() => void onJoin()} disabled={full}>
{full ? "Table full" : "Join an open seat"} {full ? "Table full" : "Join an open seat"}
</Button> </Button>
{snapshot.inGame && (
<Button type="button" variant="outline" onClick={() => void onSpectate()}>
Spectate
</Button>
)}
<Button type="button" variant="outline" onClick={onBack}> <Button type="button" variant="outline" onClick={onBack}>
Back to lobby Back to lobby
</Button> </Button>
@ -187,15 +197,18 @@ function PokerTableInner({
tableError, tableError,
onSnapshot, onSnapshot,
onJoin, onJoin,
onSpectate,
onStart, onStart,
onLeave, onLeave,
onBack, onBack,
isSuperuser,
superuserIds,
}: PokerTableProps) { }: PokerTableProps) {
// Sound + popup transitions must live here, ABOVE the waiting/game split: // Sound + popup transitions must live here, ABOVE the waiting/game split:
// the hooks have to observe the waiting-room-to-first-hand transition, // the hooks have to observe the waiting-room-to-first-hand transition,
// which happens before the game table (and its seat view) mounts. // which happens before the game table (and its seat view) mounts.
const sound = usePokerSounds(snapshot); const sound = usePokerSounds(snapshot);
const popups = usePokerPopups(snapshot); const { popups, actionLog } = usePokerPopups(snapshot);
if (loading && !snapshot) { if (loading && !snapshot) {
return ( return (
@ -238,6 +251,7 @@ function PokerTableInner({
currentUserId={currentUserId} currentUserId={currentUserId}
onSnapshot={onSnapshot} onSnapshot={onSnapshot}
onJoin={onJoin} onJoin={onJoin}
onSpectate={onSpectate}
onStart={onStart} onStart={onStart}
onLeave={onLeave} onLeave={onLeave}
onBack={onBack} onBack={onBack}
@ -251,8 +265,12 @@ function PokerTableInner({
onSnapshot={onSnapshot} onSnapshot={onSnapshot}
onStart={onStart} onStart={onStart}
onLeave={onLeave} onLeave={onLeave}
onJoin={onJoin}
sound={sound} sound={sound}
popups={popups} popups={popups}
actionLog={actionLog}
isSuperuser={isSuperuser}
superuserIds={superuserIds}
/> />
); );
} }

View file

@ -12,6 +12,27 @@ export interface PokerPopupEvent {
readonly delay?: number; 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( function playerBySeat(
players: readonly PublicPlayerView[], players: readonly PublicPlayerView[],
seatId: number, seatId: number,

View file

@ -2,15 +2,21 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions"; 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"; import type { PokerSoundSnapshot } from "./pokerSoundEvents";
export type ActivePokerPopup = PokerPopupEvent; export type ActivePokerPopup = PokerPopupEvent;
const POPUP_LIFETIME_MS = 1500; const POPUP_LIFETIME_MS = 2000;
const LEAVE_POPUP_LIFETIME_MS = 3000; const LEAVE_POPUP_LIFETIME_MS = 3000;
const MAX_ACTIVE_POPUPS = 12; const MAX_ACTIVE_POPUPS = 12;
const MAX_SEEN_KEYS = 256; const MAX_SEEN_KEYS = 256;
const MAX_LOG_ENTRIES = 5;
const POPUP_LIFETIMES_MS: Record<PokerPopupEvent["tone"], number> = { const POPUP_LIFETIMES_MS: Record<PokerPopupEvent["tone"], number> = {
action: POPUP_LIFETIME_MS, action: POPUP_LIFETIME_MS,
@ -20,8 +26,12 @@ const POPUP_LIFETIMES_MS: Record<PokerPopupEvent["tone"], number> = {
leave: LEAVE_POPUP_LIFETIME_MS, 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<readonly ActivePokerPopup[]>([]); const [popups, setPopups] = useState<readonly ActivePokerPopup[]>([]);
const [actionLog, setActionLog] = useState<readonly PokerActionLogEntry[]>([]);
const previousSnapshotRef = useRef<PokerSoundSnapshot | null>(null); const previousSnapshotRef = useRef<PokerSoundSnapshot | null>(null);
const seenKeysRef = useRef<Set<string>>(new Set()); const seenKeysRef = useRef<Set<string>>(new Set());
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]); const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
@ -70,6 +80,21 @@ export function usePokerPopups(snapshot: PokerSnapshot | null): readonly ActiveP
if (immediate.length > 0) { if (immediate.length > 0) {
setPopups((current) => [...current, ...immediate].slice(-MAX_ACTIVE_POPUPS)); 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; previousSnapshotRef.current = snapshot;
@ -82,5 +107,5 @@ export function usePokerPopups(snapshot: PokerSnapshot | null): readonly ActiveP
}; };
}, []); }, []);
return popups; return { popups, actionLog };
} }

View file

@ -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<Pick<PokerTableState, "queuedBoard" | "queuedHoles">>,
): PokerTableState {
return { ...state, ...queues };
}
/** Apply an action and unwrap the result; throws on illegal actions. */
function act(
state: PokerTableState,
seatId: number,
action: Parameters<typeof applyAction>[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();
});
});

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { PublicPlayerView, ShowdownOutcome } from "@/lib/minigames/poker"; import type { PublicPlayerView, ShowdownOutcome } from "@/lib/minigames/poker";
import { import {
actionLogText,
popupEventsForTransition, popupEventsForTransition,
type PokerPopupEvent, type PokerPopupEvent,
} from "@/components/frontend/minigames/poker/pokerPopups"; } 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", () => { describe("isTopSeatPosition", () => {
it("marks top-row arc classes as top seats", () => { it("marks top-row arc classes as top seats", () => {
expect(isTopSeatPosition("poker-seat-arc-center")).toBe(true); expect(isTopSeatPosition("poker-seat-arc-center")).toBe(true);

View file

@ -5,6 +5,7 @@ import {
getSnapshotFor, getSnapshotFor,
joinTable, joinTable,
leaveTable, leaveTable,
spectateTable,
startGame, startGame,
} from "@/lib/minigames/poker/tables"; } from "@/lib/minigames/poker/tables";
@ -99,3 +100,53 @@ describe("poker table registry: last player standing", () => {
expect(third.you).toBeNull(); 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();
});
});