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:
parent
c5defa9e70
commit
34dcefec54
13 changed files with 861 additions and 72 deletions
|
|
@ -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<T = undefined> {
|
||||
success: boolean;
|
||||
|
|
@ -105,7 +110,70 @@ export async function forceExpirePokerTable(tableId: string): Promise<ActionResu
|
|||
export async function joinPokerTable(tableId: string): Promise<ActionResult<PokerSnapshot>> {
|
||||
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<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 };
|
||||
} catch (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>> {
|
||||
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<ActionResult<und
|
|||
export async function startPokerGame(tableId: string): Promise<ActionResult<PokerSnapshot>> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,6 +113,29 @@ export function PokerActionConsole({
|
|||
aria-label="Your poker action console"
|
||||
>
|
||||
<div className="poker-console-player">
|
||||
{spectator ? (
|
||||
<>
|
||||
<span
|
||||
className="poker-seat-avatar poker-console-avatar poker-seat-avatar-open"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</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">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",
|
||||
|
|
@ -119,6 +150,12 @@ export function PokerActionConsole({
|
|||
<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">
|
||||
|
|
@ -137,9 +174,12 @@ export function PokerActionConsole({
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="poker-console-economy">
|
||||
{!spectator && (
|
||||
<div className="poker-chip-readout">
|
||||
<span className="poker-chip-stack" aria-hidden="true" />
|
||||
<span className="poker-console-readout-copy">
|
||||
|
|
@ -149,6 +189,12 @@ export function PokerActionConsole({
|
|||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{spectator && seatsOpen > 0 && onJoin && (
|
||||
<Button type="button" className="poker-chip poker-chip-call" onClick={onJoin}>
|
||||
Join next hand
|
||||
</Button>
|
||||
)}
|
||||
{view.phase === "betting" && (
|
||||
<div className="poker-console-timer">
|
||||
<PokerClock
|
||||
|
|
|
|||
316
src/components/frontend/minigames/poker/PokerDevPanel.tsx
Normal file
316
src/components/frontend/minigames/poker/PokerDevPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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({
|
|||
<div
|
||||
key={player.seatId}
|
||||
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,
|
||||
)}
|
||||
>
|
||||
|
|
@ -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 && (
|
||||
<div
|
||||
|
|
@ -139,15 +149,17 @@ export function PokerFeltCanvas({
|
|||
))}
|
||||
</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 && (
|
||||
<span className="poker-dealer-button flex size-6 items-center justify-center rounded-full border text-xs font-bold">
|
||||
<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">
|
||||
D
|
||||
</span>
|
||||
)}
|
||||
{(player.seatId === view.smallBlindSeat || player.seatId === view.bigBlindSeat) && (
|
||||
<div className="pointer-events-none absolute -bottom-2 right-2 z-20 flex gap-1">
|
||||
{player.seatId === view.smallBlindSeat && <Badge variant="secondary">SB</Badge>}
|
||||
{player.seatId === view.bigBlindSeat && <Badge variant="secondary">BB</Badge>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -230,11 +242,23 @@ export function PokerFeltCanvas({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{actionLog.length > 0 && (
|
||||
<div className="poker-action-log" aria-label="Recent actions" aria-live="polite">
|
||||
{actionLog.map((entry) => (
|
||||
<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 && (
|
||||
<div className="poker-popup-stack poker-popup-viewer-spot" aria-hidden="true">
|
||||
|
|
@ -268,6 +292,9 @@ export function PokerFeltCanvas({
|
|||
handLabel={handLabel}
|
||||
handTier={handTier}
|
||||
hostSeatId={snapshot.hostSeatId}
|
||||
spectator={spectator}
|
||||
onJoin={onJoin}
|
||||
isSuperuser={superuserIds?.includes(Number(snapshot.seats[view.viewerSeat])) ?? false}
|
||||
starting={actionProps.starting}
|
||||
leaving={actionProps.leaving}
|
||||
onStart={actionProps.onStart}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Card as UiCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { PokerCardStyleToggle } from "./PokerCardStyleToggle";
|
||||
import { PokerDevPanel } from "./PokerDevPanel";
|
||||
import { PokerFeltCanvas, type SeatedSnapshot } from "./PokerFeltCanvas";
|
||||
import { PokerSoundToggle } from "./PokerSoundToggle";
|
||||
import type { PokerSoundControls } from "./usePokerSounds";
|
||||
import type { ActivePokerPopup } from "./usePokerPopups";
|
||||
import type { PokerActionLogEntry } from "./pokerPopups";
|
||||
import { RenameDialogGuard } from "./RenameDialogGuard";
|
||||
|
||||
interface PokerGameTableProps {
|
||||
|
|
@ -21,8 +23,12 @@ interface PokerGameTableProps {
|
|||
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
|
||||
readonly onStart: () => Promise<void>;
|
||||
readonly onLeave: () => Promise<void>;
|
||||
readonly onJoin: () => Promise<void>;
|
||||
readonly sound: PokerSoundControls;
|
||||
readonly popups: readonly ActivePokerPopup[];
|
||||
readonly actionLog: readonly PokerActionLogEntry[];
|
||||
readonly isSuperuser: boolean;
|
||||
readonly superuserIds: readonly number[];
|
||||
}
|
||||
|
||||
const PHASE_LABELS: Record<PokerPhase, string> = {
|
||||
|
|
@ -37,8 +43,12 @@ export function PokerGameTable({
|
|||
onSnapshot,
|
||||
onStart,
|
||||
onLeave,
|
||||
onJoin,
|
||||
sound,
|
||||
popups,
|
||||
actionLog,
|
||||
isSuperuser,
|
||||
superuserIds,
|
||||
}: PokerGameTableProps) {
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
|
|
@ -83,6 +93,7 @@ export function PokerGameTable({
|
|||
};
|
||||
|
||||
const tableName = snapshot.name ?? `Table ${snapshot.tableId}`;
|
||||
const spectator = Boolean(snapshot.spectator);
|
||||
const feltCanvas = (
|
||||
<PokerFeltCanvas
|
||||
snapshot={snapshot}
|
||||
|
|
@ -98,6 +109,10 @@ export function PokerGameTable({
|
|||
}}
|
||||
onActionFeedback={sound.playUiClick}
|
||||
popups={popups}
|
||||
actionLog={actionLog}
|
||||
spectator={spectator}
|
||||
onJoin={onJoin}
|
||||
superuserIds={superuserIds}
|
||||
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 min-w-0 items-center gap-2">
|
||||
<span className="truncate font-semibold text-white">{tableName}</span>
|
||||
{spectator && <Badge variant="secondary">Spectating</Badge>}
|
||||
<Badge variant="outline">{PHASE_LABELS[view.phase]}</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{isSuperuser && <PokerDevPanel tableId={snapshot.tableId} view={view} />}
|
||||
<PokerCardStyleToggle />
|
||||
<PokerSoundToggle
|
||||
muted={sound.muted}
|
||||
|
|
@ -137,7 +154,7 @@ export function PokerGameTable({
|
|||
onClick={() => void leave()}
|
||||
disabled={leaving}
|
||||
>
|
||||
{leaving ? "Leaving..." : "Leave table"}
|
||||
{leaving ? "Leaving..." : spectator ? "Stop watching" : "Leave table"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -160,6 +177,7 @@ export function PokerGameTable({
|
|||
currentName={snapshot.name}
|
||||
onSnapshot={onSnapshot}
|
||||
/>
|
||||
{spectator && <Badge variant="secondary">Spectating</Badge>}
|
||||
<Badge variant="outline">{PHASE_LABELS[view.phase]}</Badge>
|
||||
</div>
|
||||
<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">
|
||||
v{snapshot.version}
|
||||
</span>
|
||||
{isSuperuser && <PokerDevPanel tableId={snapshot.tableId} view={view} />}
|
||||
<PokerCardStyleToggle />
|
||||
<PokerSoundToggle
|
||||
muted={sound.muted}
|
||||
|
|
@ -189,7 +208,7 @@ export function PokerGameTable({
|
|||
</Button>
|
||||
{view.phase !== "game-complete" && (
|
||||
<Button type="button" variant="outline" onClick={() => void leave()} disabled={leaving}>
|
||||
{leaving ? "Leaving..." : "Leave table"}
|
||||
{leaving ? "Leaving..." : spectator ? "Stop watching" : "Leave table"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</span>
|
||||
<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}
|
||||
{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
|
||||
className={cn(
|
||||
|
|
@ -116,7 +122,7 @@ export function PokerSeat({
|
|||
aria-label={`Current bet ${player.committed.toLocaleString()}`}
|
||||
>
|
||||
<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()}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -22,9 +22,12 @@ interface PokerTableProps {
|
|||
readonly tableError: string | null;
|
||||
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
|
||||
readonly onJoin: () => Promise<void>;
|
||||
readonly onSpectate: () => Promise<void>;
|
||||
readonly onStart: () => Promise<void>;
|
||||
readonly onLeave: () => Promise<void>;
|
||||
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<void>;
|
||||
readonly onSpectate: () => Promise<void>;
|
||||
readonly onStart: () => Promise<void>;
|
||||
readonly onLeave: () => Promise<void>;
|
||||
readonly onBack: () => void;
|
||||
|
|
@ -170,6 +175,11 @@ function WaitingTable({
|
|||
<Button type="button" onClick={() => void onJoin()} disabled={full}>
|
||||
{full ? "Table full" : "Join an open seat"}
|
||||
</Button>
|
||||
{snapshot.inGame && (
|
||||
<Button type="button" variant="outline" onClick={() => void onSpectate()}>
|
||||
Spectate
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={onBack}>
|
||||
Back to lobby
|
||||
</Button>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<PokerPopupEvent["tone"], number> = {
|
||||
action: POPUP_LIFETIME_MS,
|
||||
|
|
@ -20,8 +26,12 @@ const POPUP_LIFETIMES_MS: Record<PokerPopupEvent["tone"], number> = {
|
|||
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 [actionLog, setActionLog] = useState<readonly PokerActionLogEntry[]>([]);
|
||||
const previousSnapshotRef = useRef<PokerSoundSnapshot | null>(null);
|
||||
const seenKeysRef = useRef<Set<string>>(new Set());
|
||||
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
150
tests/int/poker-dev-controls.int.spec.ts
Normal file
150
tests/int/poker-dev-controls.int.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue