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 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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,6 +113,29 @@ 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">
|
||||||
|
{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
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"poker-seat-avatar poker-console-avatar",
|
"poker-seat-avatar poker-console-avatar",
|
||||||
|
|
@ -119,6 +150,12 @@ export function PokerActionConsole({
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-semibold">
|
<p className="truncate text-sm font-semibold">
|
||||||
{viewerPlayer?.name ?? "You"}
|
{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>
|
<span className="ml-1 text-xs text-white/55">(you)</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="font-mono text-xs tabular-nums text-white/65">
|
<p className="font-mono text-xs tabular-nums text-white/65">
|
||||||
|
|
@ -137,9 +174,12 @@ export function PokerActionConsole({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="poker-console-economy">
|
<div className="poker-console-economy">
|
||||||
|
{!spectator && (
|
||||||
<div className="poker-chip-readout">
|
<div className="poker-chip-readout">
|
||||||
<span className="poker-chip-stack" aria-hidden="true" />
|
<span className="poker-chip-stack" aria-hidden="true" />
|
||||||
<span className="poker-console-readout-copy">
|
<span className="poker-console-readout-copy">
|
||||||
|
|
@ -149,6 +189,12 @@ export function PokerActionConsole({
|
||||||
</strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
||||||
|
|
|
||||||
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 { 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 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
|
D
|
||||||
</span>
|
</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.smallBlindSeat && <Badge variant="secondary">SB</Badge>}
|
||||||
{player.seatId === view.bigBlindSeat && <Badge variant="secondary">BB</Badge>}
|
{player.seatId === view.bigBlindSeat && <Badge variant="secondary">BB</Badge>}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -230,11 +242,23 @@ export function PokerFeltCanvas({
|
||||||
</div>
|
</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
|
<PokerActionToolbar
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
onSnapshot={onSnapshot}
|
onSnapshot={onSnapshot}
|
||||||
onActionFeedback={onActionFeedback}
|
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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 { 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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue