1
0
Fork 0

feat(poker): add sit-and-go tables UI, felt styles, realtime events and navigation

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-11 03:25:43 -04:00
parent 6968ee0abc
commit 1fb0165c12
22 changed files with 2902 additions and 5 deletions

View file

@ -0,0 +1,170 @@
"use server";
/**
* Canonical server-action surface for the poker minigame.
* The signatures below are the pinned contract shared with the UI layer.
* All table logic lives in the registry (src/lib/minigames/poker/tables.ts).
*/
import config from "@payload-config";
import { getPayload } from "payload";
import type { PokerActionKind, PrivatePlayerView } from "@/lib/minigames/poker/types";
import { isSuperuser } from "@/utils/access-control/hasPermission";
import {
applyPlayerAction,
createTable,
forceExpireTable,
getSnapshotFor,
joinTable,
leaveTable,
listSummaries,
renameTable,
startGame,
type PokerSnapshot,
type PokerTableSummary,
} from "@/lib/minigames/poker/tables";
export type { PokerSnapshot, PokerTableSummary };
interface ActionResult<T = undefined> {
success: boolean;
error?: string;
data?: T;
}
async function authenticate() {
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { headers } = await import("next/headers");
const hdrs = await headers();
const { user } = await payload.auth({
headers: hdrs,
canSetHeaders: false,
});
if (!user) {
throw new Error("Unauthorized");
}
return { payload, user };
}
function toError(error: unknown): { success: false; error: string } {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
interface UserLike {
id: number;
displayName?: string | null;
username?: string | null;
}
function userName(user: UserLike): string {
if (typeof user.displayName === "string" && user.displayName) return user.displayName;
if (typeof user.username === "string" && user.username) return user.username;
return `Player ${user.id}`;
}
export async function createPokerTable(): Promise<ActionResult<{ tableId: string }>> {
try {
const { user } = await authenticate();
const tableId = createTable(Number(user.id), userName(user));
return { success: true, data: { tableId } };
} catch (error) {
return toError(error);
}
}
export async function listPokerTables(): Promise<ActionResult<PokerTableSummary[]>> {
try {
await authenticate();
return { success: true, data: listSummaries() };
} catch (error) {
return toError(error);
}
}
export async function forceExpirePokerTable(tableId: string): Promise<ActionResult<undefined>> {
try {
const { payload, user } = await authenticate();
const allowed = await isSuperuser(payload, user);
if (!allowed) {
return { success: false, error: "Only administrators can force-end tables." };
}
forceExpireTable(tableId, payload);
return { success: true };
} catch (error) {
return toError(error);
}
}
export async function joinPokerTable(tableId: string): Promise<ActionResult<PokerSnapshot>> {
try {
const { user } = await authenticate();
const snapshot = joinTable(tableId, Number(user.id), userName(user));
return { success: true, data: snapshot };
} catch (error) {
return toError(error);
}
}
export async function leavePokerTable(tableId: string): Promise<ActionResult<undefined>> {
try {
const { user } = await authenticate();
leaveTable(tableId, Number(user.id));
return { success: true };
} catch (error) {
return toError(error);
}
}
export async function startPokerGame(tableId: string): Promise<ActionResult<PokerSnapshot>> {
try {
const { user } = await authenticate();
const snapshot = startGame(tableId, Number(user.id));
return { success: true, data: snapshot };
} catch (error) {
return toError(error);
}
}
export async function renamePokerTable(
tableId: string,
rawName: string,
): Promise<ActionResult<PokerSnapshot>> {
try {
const { user } = await authenticate();
const snapshot = renameTable(tableId, Number(user.id), rawName);
return { success: true, data: snapshot };
} catch (error) {
return toError(error);
}
}
export async function fetchPokerTable(tableId: string): Promise<ActionResult<PokerSnapshot>> {
try {
const { user } = await authenticate();
const snapshot = getSnapshotFor(tableId, Number(user.id));
return { success: true, data: snapshot };
} catch (error) {
return toError(error);
}
}
export async function pokerAction(
tableId: string,
kind: PokerActionKind,
amount?: number,
): Promise<ActionResult<PokerSnapshot>> {
try {
const { user } = await authenticate();
const snapshot = applyPlayerAction(tableId, Number(user.id), kind, amount);
return { success: true, data: snapshot };
} catch (error) {
return toError(error);
}
}

View file

@ -0,0 +1,72 @@
import type { Metadata } from "next";
import config from "@payload-config";
import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers";
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";
export const metadata: Metadata = {
title: "Poker: Polaris Task Force",
description: "A multiplayer Texas Hold'em training table for Polaris Task Force operators.",
};
interface PokerPageProps {
searchParams: Promise<{
table?: string | string[];
}>;
}
export default async function PokerPage({ searchParams }: PokerPageProps) {
const headers = await nextHeaders();
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers, canSetHeaders: false });
if (!user) {
redirect(`/login?returnTo=${encodeURIComponent("/minigames/poker")}`);
}
const superuser = await isSuperuser(payload, user);
const profileRes = await payload.find({
collection: "profiles",
where: { user: { equals: Number(user.id) } },
limit: 1,
depth: 0,
overrideAccess: true,
select: {
progression: {
minigames: true,
},
},
});
const stats = readPokerStats(profileRes.docs[0]);
const params = await searchParams;
const tableValue = params.table;
const initialTableId = Array.isArray(tableValue) ? tableValue[0] : tableValue;
return (
<div className="flex flex-col gap-6 p-5">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Spade className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Poker</h1>
</div>
<p className="text-sm text-muted-foreground">
Sit down for a multiplayer Texas Hold&apos;em game. Read the table, manage your stack, and
outlast the field.
</p>
</div>
<PokerArena
initialStats={stats}
currentUserId={Number(user.id)}
initialTableId={initialTableId}
isSuperuser={superuser}
/>
</div>
);
}

View file

@ -1130,3 +1130,187 @@
opacity: 0; opacity: 0;
} }
} }
/* Poker surface: the felt is the game's one custom material. */
.poker-felt-table {
--poker-felt-deep: #071c15;
--poker-felt-mid: #0e3b29;
--poker-felt-light: #1c5b3d;
--poker-rim: #06130f;
--poker-trim: #b08a44;
isolation: isolate;
border: 1px solid var(--poker-trim);
border-radius: 50% / 38%;
background:
radial-gradient(
circle at 50% 44%,
color-mix(in srgb, var(--poker-felt-light) 72%, transparent),
transparent 55%
),
radial-gradient(ellipse at 50% 50%, var(--poker-felt-mid) 0%, var(--poker-felt-deep) 78%);
box-shadow:
inset 0 0 0 0.5rem var(--poker-rim),
inset 0 0 2.5rem color-mix(in srgb, var(--poker-felt-deep) 80%, black),
0 0.75rem 2rem rgb(0 0 0 / 35%);
}
.poker-felt-table::after {
position: absolute;
inset: 0.85rem;
z-index: -1;
border: 1px solid color-mix(in srgb, var(--poker-trim) 52%, transparent);
border-radius: 50% / 38%;
content: "";
pointer-events: none;
}
.poker-seat-north {
top: 2.5%;
left: 50%;
transform: translateX(-50%);
}
.poker-seat-east {
top: 50%;
right: 2%;
transform: translateY(-50%);
}
.poker-seat-south {
bottom: 2.5%;
left: 50%;
transform: translateX(-50%);
}
.poker-seat-south-strip {
bottom: 1.5%;
left: 50%;
width: min(30rem, 88%);
transform: translateX(-50%);
}
.poker-felt-fullscreen {
min-height: max(76vh, 36rem);
}
.poker-seat-west {
top: 50%;
left: 2%;
transform: translateY(-50%);
}
.poker-seat-panel {
border-radius: 0.5rem;
background: color-mix(in srgb, var(--background) 88%, transparent);
}
.poker-waiting-seat {
border-color: color-mix(in srgb, white 24%, transparent);
border-radius: 0.25rem;
background: rgb(0 0 0 / 28%);
color: white;
}
.poker-waiting-seat-occupied {
border-color: color-mix(in srgb, var(--poker-trim) 62%, transparent);
}
.poker-card {
perspective: 45rem;
vertical-align: middle;
}
.poker-card-inner {
position: relative;
display: block;
width: 100%;
height: 100%;
transform: rotateY(180deg);
transform-style: preserve-3d;
transition: transform 260ms ease;
}
.poker-card-is-face-up {
transform: rotateY(0deg);
}
.poker-card-face {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
backface-visibility: hidden;
border: 1px solid color-mix(in srgb, white 36%, transparent);
}
.poker-card-front {
flex-direction: column;
justify-content: space-between;
padding: 0.25rem;
background: var(--card);
color: var(--foreground);
}
.poker-card-center {
font-size: 1.25rem;
line-height: 1;
}
.poker-card-back {
transform: rotateY(180deg);
background-color: #101a33;
background-image:
linear-gradient(45deg, rgb(190 42 58 / 34%) 25%, transparent 25%),
linear-gradient(-45deg, rgb(190 42 58 / 34%) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgb(190 42 58 / 34%) 75%),
linear-gradient(-45deg, transparent 75%, rgb(190 42 58 / 34%) 75%);
background-position:
0 0,
0.25rem 0,
0.25rem -0.25rem,
-0.25rem 0.25rem;
background-size: 0.5rem 0.5rem;
}
.poker-card-deal {
animation: poker-card-deal-in 260ms ease both;
}
.poker-countdown-fill {
min-width: 0;
}
@keyframes poker-card-deal-in {
from {
opacity: 0;
transform: translateY(-0.5rem) scale(0.92);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (max-width: 640px) {
.poker-felt-table::after {
inset: 0.65rem;
}
.poker-seat-east {
right: 1%;
}
.poker-seat-west {
left: 1%;
}
}
@media (prefers-reduced-motion: reduce) {
.poker-card-inner,
.poker-card-deal {
animation: none;
transition: none;
}
}

View file

@ -25,6 +25,7 @@ import {
Radio, Radio,
Bomb, Bomb,
ScrollText, ScrollText,
Spade,
} from "lucide-react"; } from "lucide-react";
export interface NavigationCommand { export interface NavigationCommand {
@ -39,8 +40,22 @@ export interface NavigationCommand {
} }
export const navigationCommands: NavigationCommand[] = [ export const navigationCommands: NavigationCommand[] = [
{ id: "nav:dashboard", label: "Dashboard", icon: Bolt, href: "/", keywords: ["home", "main"], shortcut: "G D" }, {
{ id: "nav:roster", label: "Roster", icon: Users, href: "/roster", keywords: ["members", "personnel"], shortcut: "G R" }, id: "nav:dashboard",
label: "Dashboard",
icon: Bolt,
href: "/",
keywords: ["home", "main"],
shortcut: "G D",
},
{
id: "nav:roster",
label: "Roster",
icon: Users,
href: "/roster",
keywords: ["members", "personnel"],
shortcut: "G R",
},
{ {
id: "nav:transfers", id: "nav:transfers",
label: "Transfers", label: "Transfers",
@ -255,6 +270,13 @@ export const navigationCommands: NavigationCommand[] = [
href: "/minigames/opord", href: "/minigames/opord",
keywords: ["minigame", "briefing", "memory", "operations order", "training"], keywords: ["minigame", "briefing", "memory", "operations order", "training"],
}, },
{
id: "nav:poker",
label: "Poker",
icon: Spade,
href: "/minigames/poker",
keywords: ["minigame", "poker", "cards", "holdem", "training"],
},
{ {
id: "nav:account", id: "nav:account",
label: "Account", label: "Account",

View file

@ -11,6 +11,7 @@ import {
LogOut, LogOut,
Mic, Mic,
Radio, Radio,
Spade,
Users2, Users2,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@ -173,6 +174,12 @@ export function NavUser({
OPORD Recall OPORD Recall
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/minigames/poker">
<Spade />
Poker
</Link>
</DropdownMenuItem>
</DropdownMenuSubContent> </DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
</DropdownMenuGroup> </DropdownMenuGroup>

View file

@ -0,0 +1,193 @@
"use client";
import { useEffect, useState } from "react";
import type { PokerActionKind, PokerLegalActions } from "@/lib/minigames/poker";
import { pokerAction, type PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
interface BettingControlsProps {
readonly tableId: string;
readonly snapshotVersion: number;
readonly legalActions: PokerLegalActions;
readonly potTotal: number;
readonly yourTurn: boolean;
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
}
function clampAmount(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, Math.round(value)));
}
function amountLabel(value: number): string {
return value.toLocaleString();
}
export function BettingControls({
tableId,
snapshotVersion,
legalActions,
potTotal,
yourTurn,
onSnapshot,
}: BettingControlsProps) {
const [pendingAction, setPendingAction] = useState<PokerActionKind | null>(null);
const [error, setError] = useState<string | null>(null);
const [amount, setAmount] = useState(legalActions.minBetTo);
const wagerKind: PokerActionKind | null = legalActions.canBet
? "bet"
: legalActions.canRaise
? "raise"
: null;
const wagerMin = legalActions.canBet ? legalActions.minBetTo : legalActions.minRaiseTo;
const wagerMax = legalActions.canBet ? legalActions.maxBetTo : legalActions.maxRaiseTo;
const canAct = yourTurn && pendingAction === null;
useEffect(() => {
setAmount((current) => clampAmount(current, wagerMin, wagerMax));
}, [wagerMax, wagerMin]);
useEffect(() => {
setPendingAction(null);
setError(null);
}, [snapshotVersion]);
const submit = async (kind: PokerActionKind, wager?: number) => {
setError(null);
setPendingAction(kind);
try {
const result = await pokerAction(tableId, kind, wager);
if (!result.success) {
setError(result.error ?? "That action was rejected by the table.");
setPendingAction(null);
return;
}
if (result.data) onSnapshot(result.data);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not send the table action.");
setPendingAction(null);
}
};
const chooseQuickAmount = (choice: "min" | "half" | "pot" | "all") => {
const nextAmount =
choice === "min"
? wagerMin
: choice === "half"
? Math.round(potTotal / 2)
: choice === "pot"
? potTotal
: wagerMax;
setAmount(clampAmount(nextAmount, wagerMin, wagerMax));
};
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="destructive"
disabled={!canAct || !legalActions.canFold}
onClick={() => void submit("fold")}
>
Fold
</Button>
<Button
type="button"
variant="outline"
disabled={!canAct || !legalActions.canCheck}
onClick={() => void submit("check")}
>
Check
</Button>
<Button
type="button"
variant="secondary"
disabled={!canAct || !legalActions.canCall}
onClick={() => void submit("call")}
>
Call {amountLabel(legalActions.callAmount)}
</Button>
</div>
{wagerKind && wagerMax >= wagerMin && (
<Card className="rounded-none">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm">
{wagerKind === "bet" ? "Bet size" : "Raise to"}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 p-4 pt-0">
<div className="flex items-center gap-3">
<input
type="range"
min={wagerMin}
max={wagerMax}
step={1}
value={amount}
onChange={(event) =>
setAmount(clampAmount(Number(event.target.value), wagerMin, wagerMax))
}
disabled={!canAct}
className="h-2 min-w-0 flex-1 cursor-pointer accent-primary disabled:cursor-not-allowed"
aria-label={`${wagerKind === "bet" ? "Bet" : "Raise"} amount`}
/>
<Input
type="number"
min={wagerMin}
max={wagerMax}
step={1}
value={amount}
onChange={(event) =>
setAmount(clampAmount(Number(event.target.value), wagerMin, wagerMax))
}
disabled={!canAct}
className="w-24 font-mono tabular-nums"
aria-label="Wager amount"
/>
</div>
<div className="flex flex-wrap gap-2">
{(["min", "half", "pot", "all"] as const).map((choice) => (
<Button
key={choice}
type="button"
variant="outline"
size="sm"
disabled={!canAct}
onClick={() => chooseQuickAmount(choice)}
>
{choice === "min"
? "Min"
: choice === "half"
? "1/2 pot"
: choice === "pot"
? "Pot"
: "All-in"}
</Button>
))}
<Button
type="button"
className="ml-auto"
disabled={!canAct}
onClick={() => void submit(wagerKind, amount)}
>
{wagerKind === "bet" ? "Bet" : "Raise"} {amountLabel(amount)}
</Button>
</div>
</CardContent>
</Card>
)}
<div className="flex min-h-5 items-center justify-between gap-3 text-xs" aria-live="polite">
{!yourTurn && <span className="text-muted-foreground">Waiting for the table to act.</span>}
{pendingAction && (
<span className="text-muted-foreground">Waiting for the table update...</span>
)}
{error && <span className="text-destructive">{error}</span>}
</div>
</div>
);
}

View file

@ -0,0 +1,104 @@
"use client";
import { cn } from "@/lib/utils";
import type { Card, Rank, Suit } from "@/lib/minigames/poker";
const RANK_LABELS: Record<Rank, string> = {
2: "2",
3: "3",
4: "4",
5: "5",
6: "6",
7: "7",
8: "8",
9: "9",
10: "10",
11: "J",
12: "Q",
13: "K",
14: "A",
};
const SUIT_SYMBOLS: Record<Suit, string> = {
s: "♠",
h: "♥",
d: "♦",
c: "♣",
};
const RED_SUITS = new Set<Suit>(["h", "d"]);
interface CardViewProps {
readonly card: Card | null;
readonly faceUp?: boolean;
readonly size?: "mini" | "sm" | "md";
readonly dealIndex?: number;
readonly className?: string;
readonly highlighted?: boolean;
readonly dimmed?: boolean;
}
export function CardView({
card,
faceUp = true,
size = "md",
dealIndex = 0,
className,
highlighted = false,
dimmed = false,
}: CardViewProps) {
const revealed = card !== null && faceUp;
const label = card
? `${RANK_LABELS[card.rank]} of ${
card.suit === "s"
? "spades"
: card.suit === "h"
? "hearts"
: card.suit === "d"
? "diamonds"
: "clubs"
}`
: "Face-down card";
return (
<span
className={cn(
"poker-card inline-block shrink-0",
size === "mini" && "h-10 w-7",
size === "sm" && "h-14 w-10",
size === "md" && "h-16 w-12 sm:h-20 sm:w-14",
dealIndex > 0 && "poker-card-deal",
highlighted &&
"relative z-10 brightness-110 ring-2 ring-amber-300 shadow-lg shadow-amber-300/35",
dimmed && "opacity-40",
className,
)}
style={dealIndex > 0 ? { animationDelay: `${dealIndex * 60}ms` } : undefined}
aria-label={label}
>
<span className={cn("poker-card-inner", revealed && "poker-card-is-face-up")}>
<span className="poker-card-face poker-card-front" aria-hidden={!revealed}>
{card && (
<>
<span
className={cn(
"flex flex-col items-center leading-none",
RED_SUITS.has(card.suit) && "text-rose-400",
)}
>
<span className="font-semibold">{RANK_LABELS[card.rank]}</span>
<span>{SUIT_SYMBOLS[card.suit]}</span>
</span>
<span
className={cn("poker-card-center", RED_SUITS.has(card.suit) && "text-rose-400")}
>
{SUIT_SYMBOLS[card.suit]}
</span>
</>
)}
</span>
<span className="poker-card-face poker-card-back" aria-hidden={revealed} />
</span>
</span>
);
}

View file

@ -0,0 +1,312 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
fetchPokerTable,
joinPokerTable,
leavePokerTable,
startPokerGame,
type PokerSnapshot,
} from "@/app/(frontend)/minigames/poker/actions";
import type { PokerPhase } from "@/lib/minigames/poker";
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PokerGuide } from "./PokerGuide";
import {
POKER_STATE_EVENT,
POKER_TABLES_EVENT,
POKER_TERMINATED_EVENT,
readPokerStateEvent,
readPokerTerminatedEvent,
} from "./events";
import { PokerLeaderboard } from "./PokerLeaderboard";
import { PokerLobby } from "./PokerLobby";
import { PokerTable } from "./PokerTable";
import type { PokerStats } from "./types";
interface PokerArenaProps {
readonly initialStats: PokerStats;
readonly currentUserId: number | string;
readonly initialTableId?: string;
readonly isSuperuser: boolean;
}
type ArenaView = "lobby" | "table";
const STAT_ITEMS = [
["Games", "gamesPlayed"],
["Wins", "wins"],
["Hands won", "handsWon"],
["Best chips", "bestScore"],
["XP earned", "xpEarned"],
] as const satisfies readonly (readonly [string, keyof PokerStats])[];
export function PokerArena({
initialStats,
currentUserId,
initialTableId,
isSuperuser,
}: PokerArenaProps) {
const [view, setView] = useState<ArenaView>(initialTableId ? "table" : "lobby");
const [snapshot, setSnapshot] = useState<PokerSnapshot | null>(null);
const [activeTableId, setActiveTableId] = useState<string | null>(initialTableId ?? null);
const [tableError, setTableError] = useState<string | null>(null);
const [loading, setLoading] = useState(Boolean(initialTableId));
const [terminated, setTerminated] = useState<string | null>(null);
const [leaderboardRefreshKey, setLeaderboardRefreshKey] = useState(0);
const [lobbyRefreshKey, setLobbyRefreshKey] = useState(0);
const lastPhaseRef = useRef<PokerPhase | null>(null);
const syncedTableIdRef = useRef<string | null>(null);
const applySnapshot = useCallback((next: PokerSnapshot) => {
setSnapshot((current) => {
if (current && next.version <= current.version) return current;
return next;
});
setActiveTableId(next.tableId);
setTerminated((current) => (current !== next.tableId ? null : current));
if (syncedTableIdRef.current !== next.tableId) {
syncedTableIdRef.current = next.tableId;
window.history.replaceState(
null,
"",
`/minigames/poker?table=${encodeURIComponent(next.tableId)}`,
);
}
setView("table");
setTableError(null);
setLoading(false);
const nextPhase = next.you?.phase ?? null;
if (nextPhase === "game-complete" && lastPhaseRef.current !== "game-complete") {
setLeaderboardRefreshKey((key) => key + 1);
}
lastPhaseRef.current = nextPhase;
}, []);
useEffect(() => {
if (!initialTableId) {
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
void fetchPokerTable(initialTableId)
.then((result) => {
if (cancelled) return;
if (result.success && result.data) {
applySnapshot(result.data);
} else {
setTableError(result.error ?? "This poker table was not found.");
setLoading(false);
}
})
.catch((caught: unknown) => {
if (cancelled) return;
setTableError(
caught instanceof Error ? caught.message : "This poker table could not be loaded.",
);
setLoading(false);
});
return () => {
cancelled = true;
};
}, [applySnapshot, initialTableId]);
useEffect(() => {
const handleState = (event: Event) => {
const detail = readPokerStateEvent(event);
if (!detail || detail.tableId !== activeTableId) return;
applySnapshot(detail.snapshot);
};
const handleTables = () => setLobbyRefreshKey((key) => key + 1);
const handleTerminated = (event: Event) => {
const detail = readPokerTerminatedEvent(event);
if (!detail || detail.tableId !== activeTableId) return;
setTerminated(detail.tableId);
};
window.addEventListener(POKER_STATE_EVENT, handleState);
window.addEventListener(POKER_TABLES_EVENT, handleTables);
window.addEventListener(POKER_TERMINATED_EVENT, handleTerminated);
return () => {
window.removeEventListener(POKER_STATE_EVENT, handleState);
window.removeEventListener(POKER_TABLES_EVENT, handleTables);
window.removeEventListener(POKER_TERMINATED_EVENT, handleTerminated);
};
}, [activeTableId, applySnapshot]);
// Polls are the correctness path: the in-memory SSE bus cannot reliably
// deliver server-action pushes (dev per-bundle module duplication). The
// SSE push stays as an instant bonus; applySnapshot dedupes by version.
useEffect(() => {
if (view !== "table" || activeTableId === null || terminated === activeTableId) return;
let cancelled = false;
const poll = window.setInterval(() => {
void fetchPokerTable(activeTableId)
.then((result) => {
if (cancelled) return;
if (result.success && result.data) applySnapshot(result.data);
})
.catch(() => undefined);
}, 1250);
return () => {
cancelled = true;
window.clearInterval(poll);
};
}, [activeTableId, terminated, view, applySnapshot]);
const resetToLobby = useCallback(() => {
setView("lobby");
setSnapshot(null);
setActiveTableId(null);
setTableError(null);
setLoading(false);
setTerminated(null);
lastPhaseRef.current = null;
if (syncedTableIdRef.current !== null) {
syncedTableIdRef.current = null;
window.history.replaceState(null, "", "/minigames/poker");
}
}, []);
const leaveTable = useCallback(async () => {
const seated = snapshot?.seats.some((id) => String(id) === String(currentUserId)) ?? false;
if (activeTableId && seated) {
try {
const result = await leavePokerTable(activeTableId);
if (!result.success) {
setTableError(result.error ?? "Could not leave the poker table.");
return;
}
} catch (caught) {
setTableError(
caught instanceof Error ? caught.message : "Could not leave the poker table.",
);
return;
}
}
resetToLobby();
}, [activeTableId, currentUserId, resetToLobby, snapshot]);
const joinCurrentTable = useCallback(async () => {
if (!activeTableId) return;
setLoading(true);
setTableError(null);
try {
const result = await joinPokerTable(activeTableId);
if (result.success && result.data) {
applySnapshot(result.data);
} else {
setTableError(result.error ?? "Could not join the poker table.");
setLoading(false);
}
} catch (caught) {
setTableError(caught instanceof Error ? caught.message : "Could not join the poker table.");
setLoading(false);
}
}, [activeTableId, applySnapshot]);
const startCurrentTable = useCallback(async () => {
if (!activeTableId) return;
try {
const result = await startPokerGame(activeTableId);
if (result.success && result.data) {
applySnapshot(result.data);
} else {
setTableError(result.error ?? "Could not start the poker game.");
}
} catch (caught) {
setTableError(caught instanceof Error ? caught.message : "Could not start the poker game.");
}
}, [activeTableId, applySnapshot]);
const activePlayers = useRealtimePresence(
"poker",
view === "table" && activeTableId !== null,
true,
);
return (
<div className="flex flex-col gap-6">
<Card className="rounded-none">
<CardHeader className="border-b p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle className="text-sm">Your poker record</CardTitle>
<p className="text-sm text-muted-foreground">
Results are recorded when a sit-and-go ends.
</p>
</div>
<PokerGuide />
</div>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3 p-4 sm:grid-cols-5">
{STAT_ITEMS.map(([label, key]) => (
<div key={key} className="flex flex-col gap-1 border border-border p-3">
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</span>
<span className="font-mono text-lg font-semibold tabular-nums">
{initialStats[key].toLocaleString()}
</span>
</div>
))}
</CardContent>
</Card>
{tableError && snapshot && (
<p className="text-sm text-destructive" role="alert">
{tableError}
</p>
)}
<div className="grid min-w-0 gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(22rem,28rem)]">
<section className="min-w-0">
{view === "lobby" ? (
<PokerLobby
refreshKey={lobbyRefreshKey}
onOpenTable={applySnapshot}
isSuperuser={isSuperuser}
/>
) : terminated === activeTableId ? (
<Card className="rounded-none border-destructive/40" role="alert">
<CardHeader className="border-b border-destructive/20 p-4">
<CardTitle className="text-base text-destructive">
Game ended by an administrator
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col items-start gap-4 p-4">
<p className="text-sm text-muted-foreground">
An administrator forcefully ended this poker table.
</p>
<Button type="button" variant="outline" onClick={resetToLobby}>
Back to lobby
</Button>
</CardContent>
</Card>
) : (
<PokerTable
snapshot={snapshot}
currentUserId={currentUserId}
loading={loading}
tableError={tableError}
onSnapshot={applySnapshot}
onJoin={joinCurrentTable}
onStart={startCurrentTable}
onLeave={leaveTable}
onBack={resetToLobby}
/>
)}
</section>
<PokerLeaderboard
refreshKey={leaderboardRefreshKey}
currentUserId={currentUserId}
activePlayers={activePlayers}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,61 @@
"use client";
import { useEffect, useState } from "react";
interface PokerClockProps {
readonly deadline: string | null;
readonly totalMs?: number;
readonly label?: string;
readonly mode?: "bar" | "text";
}
export function PokerClock({
deadline,
totalMs = 0,
label = "Time remaining",
mode = "bar",
}: PokerClockProps) {
const deadlineMs = deadline ? Date.parse(deadline) : Number.NaN;
const [remainingMs, setRemainingMs] = useState(() =>
Number.isFinite(deadlineMs) ? Math.max(0, deadlineMs - Date.now()) : 0,
);
useEffect(() => {
if (!Number.isFinite(deadlineMs)) {
setRemainingMs(0);
return;
}
const update = () => setRemainingMs(Math.max(0, deadlineMs - Date.now()));
update();
const interval = window.setInterval(update, 250);
return () => window.clearInterval(interval);
}, [deadlineMs]);
if (!deadline || !Number.isFinite(deadlineMs)) return null;
const seconds = Math.ceil(remainingMs / 1000);
if (mode === "text") {
return (
<span className="font-mono text-xs tabular-nums text-muted-foreground" role="timer">
{label} {seconds}s
</span>
);
}
const percentage = totalMs > 0 ? Math.min(100, (remainingMs / totalMs) * 100) : 0;
return (
<div className="flex w-full flex-col gap-1" aria-label={`${label}: ${seconds} seconds`}>
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>{label}</span>
<span className="font-mono tabular-nums">{seconds}s</span>
</div>
<div className="h-1 overflow-hidden rounded-full bg-muted" aria-hidden="true">
<div
className="h-full bg-primary poker-countdown-fill"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,388 @@
"use client";
import { createPortal } from "react-dom";
import { useEffect, useMemo, useState } from "react";
import { Maximize2, Minimize2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Card, PokerPhase, PublicPlayerView } from "@/lib/minigames/poker";
import { describeHandStrength } from "@/lib/minigames/poker/handHint";
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card as UiCard,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { BettingControls } from "./BettingControls";
import { CardView } from "./CardView";
import { PokerClock } from "./PokerClock";
import { PokerSeat } from "./PokerSeat";
import { RenameDialogGuard } from "./RenameDialogGuard";
import { seatPosition } from "./seatPosition";
type SeatedSnapshot = PokerSnapshot & {
readonly you: NonNullable<PokerSnapshot["you"]>;
};
interface PokerGameTableProps {
readonly snapshot: SeatedSnapshot;
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
readonly onStart: () => Promise<void>;
readonly onLeave: () => Promise<void>;
}
interface FeltCanvasProps {
readonly snapshot: SeatedSnapshot;
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
readonly handLabel: string | null;
readonly bestCardKeys: ReadonlySet<string>;
readonly fullscreen?: boolean;
}
interface TableActionButtonsProps {
readonly view: SeatedSnapshot["you"];
readonly hostSeatId: number | null;
readonly starting: boolean;
readonly leaving: boolean;
readonly onStart: () => void;
readonly onLeave: () => void;
}
const PHASE_LABELS: Record<PokerPhase, string> = {
betting: "Betting",
showdown: "Showdown",
"hand-complete": "Hand complete",
"game-complete": "Game complete",
};
function cardKey(card: Card): string {
return `${card.rank}-${card.suit}`;
}
function signedAmount(value: number): string {
return `${value >= 0 ? "+" : ""}${value.toLocaleString()}`;
}
function PlacementBoard({ view }: { readonly view: SeatedSnapshot["you"] }) {
const placements = [...(view.finalPlacements ?? [])].sort((a, b) => a.placement - b.placement);
return (
<UiCard className="w-full max-w-sm rounded-none border-white/20 bg-black/55 text-white">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm uppercase tracking-wider">Final placements</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<ol className="flex flex-col gap-2">
{placements.map((placement) => {
const player = view.players.find((item) => item.seatId === placement.seatId);
return (
<li
key={placement.seatId}
className="flex items-center justify-between gap-3 text-sm"
>
<span className="font-mono text-muted-foreground">{placement.placement}.</span>
<span className="min-w-0 flex-1 truncate font-medium">
{player?.name ?? `Seat ${placement.seatId + 1}`}
</span>
<span className="font-mono tabular-nums text-muted-foreground">
{player?.stack.toLocaleString() ?? "0"}
</span>
</li>
);
})}
</ol>
</CardContent>
</UiCard>
);
}
function TableActionButtons({
view,
hostSeatId,
starting,
leaving,
onStart,
onLeave,
}: TableActionButtonsProps) {
return (
<>
{view.phase === "game-complete" && view.viewerSeat === hostSeatId && (
<Button type="button" onClick={onStart} disabled={starting}>
{starting ? "Starting..." : "Start new game"}
</Button>
)}
<Button type="button" variant="outline" onClick={onLeave} disabled={leaving}>
{leaving ? "Leaving..." : "Leave table"}
</Button>
</>
);
}
function FeltCanvas({
snapshot,
onSnapshot,
handLabel,
bestCardKeys,
fullscreen = false,
}: FeltCanvasProps) {
const view = snapshot.you;
const winnerSeats = new Set(view.showdown?.pots.flatMap((pot) => pot.winnerSeats) ?? []);
const result = view.showdown?.results.find((item) => item.seatId === view.viewerSeat);
const showHoleCards = view.phase !== "hand-complete" && view.phase !== "game-complete";
const hasHint = handLabel !== null;
return (
<div
className={cn(
"poker-felt-table relative min-h-[36rem] overflow-hidden p-4 sm:min-h-[42rem]",
fullscreen && "poker-felt-fullscreen",
)}
>
{view.players.map((player: PublicPlayerView) => {
const isViewer = player.seatId === view.viewerSeat;
return (
<div
key={player.seatId}
className={cn(
"absolute z-10",
isViewer ? "poker-seat-south-strip" : "w-36 max-w-[44%] sm:w-52",
!isViewer && seatPosition(player.seatId),
)}
>
<PokerSeat
player={player}
isCurrentUser={isViewer}
isActing={view.phase === "betting" && player.seatId === view.toActSeat}
isWinner={winnerSeats.has(player.seatId)}
showHoleCards={showHoleCards}
handLabel={isViewer ? handLabel : null}
cardKeySet={isViewer ? bestCardKeys : undefined}
compact={isViewer}
/>
<div className="pointer-events-none absolute -top-2 right-2 z-20 flex gap-1">
{player.seatId === view.buttonSeat && (
<span className="flex size-6 items-center justify-center rounded-full border border-amber-200 bg-amber-300 text-xs font-bold text-black">
D
</span>
)}
{player.seatId === view.smallBlindSeat && <Badge variant="secondary">SB</Badge>}
{player.seatId === view.bigBlindSeat && <Badge variant="secondary">BB</Badge>}
</div>
</div>
);
})}
<div className="absolute left-1/2 top-1/2 z-10 flex w-[min(24rem,80%)] -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-3 text-white">
{view.phase === "betting" && (
<PokerClock deadline={snapshot.turnDeadline} totalMs={view.config.turnTimeoutMs} />
)}
<div className="flex flex-wrap justify-center gap-1.5" aria-label="Community cards">
{view.communityCards.length > 0 ? (
view.communityCards.map((card, index) => {
const isBestCard = bestCardKeys.has(cardKey(card));
return (
<CardView
key={`${card.rank}-${card.suit}`}
card={card}
size="md"
dealIndex={index}
highlighted={hasHint && isBestCard}
dimmed={hasHint && !isBestCard}
/>
);
})
) : (
<span className="font-mono text-xs uppercase tracking-[0.2em] text-white/55">
Community cards
</span>
)}
</div>
<div className="flex flex-col items-center gap-1">
<span className="font-mono text-xs uppercase tracking-[0.2em] text-white/60">Pot</span>
<span className="font-mono text-2xl font-semibold tabular-nums">
{view.potTotal.toLocaleString()}
</span>
</div>
{(view.phase === "showdown" || view.phase === "hand-complete") && (
<div
className="flex w-full flex-col items-center gap-1 border border-white/15 bg-black/35 px-3 py-2 text-center"
aria-live="polite"
>
<p className="font-semibold">
{view.phase === "showdown" ? "Showdown" : "Hand complete"}
</p>
<p className="text-sm text-white/70">
{result
? `Your result: ${signedAmount(result.netChange)} chips`
: "The hand is settled."}
</p>
{snapshot.nextHandAt && (
<PokerClock deadline={snapshot.nextHandAt} mode="text" label="Next hand in" />
)}
</div>
)}
</div>
{view.phase !== "game-complete" && (
<div className="absolute bottom-[13%] left-1/2 z-30 max-h-[45%] w-[min(44rem,calc(100%-2rem))] -translate-x-1/2 overflow-y-auto rounded-none border border-white/15 bg-black/60 p-3 backdrop-blur-sm">
<BettingControls
tableId={snapshot.tableId}
snapshotVersion={snapshot.version}
legalActions={view.legalActions}
potTotal={view.potTotal}
yourTurn={view.yourTurn}
onSnapshot={onSnapshot}
/>
</div>
)}
{view.phase === "game-complete" && (
<div className="absolute left-1/2 top-1/2 z-20 flex w-[min(24rem,calc(100%-2rem))] -translate-x-1/2 -translate-y-1/2 justify-center">
<PlacementBoard view={view} />
</div>
)}
</div>
);
}
export function PokerGameTable({ snapshot, onSnapshot, onStart, onLeave }: PokerGameTableProps) {
const [starting, setStarting] = useState(false);
const [leaving, setLeaving] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [mounted, setMounted] = useState(false);
const view = snapshot.you;
const handHint = useMemo(
() =>
view.phase === "game-complete"
? null
: describeHandStrength(view.yourHoleCards, view.communityCards),
[view.communityCards, view.phase, view.yourHoleCards],
);
const bestCardKeys = useMemo(() => new Set(handHint?.bestCardKeys ?? []), [handHint]);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!fullscreen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setFullscreen(false);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [fullscreen]);
const start = async () => {
setStarting(true);
await onStart();
setStarting(false);
};
const leave = async () => {
setLeaving(true);
await onLeave();
setLeaving(false);
};
const tableName = snapshot.name ?? `Table ${snapshot.tableId}`;
const feltCanvas = (
<FeltCanvas
snapshot={snapshot}
onSnapshot={onSnapshot}
handLabel={handHint?.label ?? null}
bestCardKeys={bestCardKeys}
fullscreen={fullscreen}
/>
);
if (fullscreen) {
if (!mounted) return null;
return createPortal(
<div className="fixed inset-0 z-50 flex flex-col gap-3 overflow-auto bg-black/92 p-4 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">
<span className="truncate font-semibold text-white">{tableName}</span>
<Badge variant="outline">{PHASE_LABELS[view.phase]}</Badge>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
{view.phase === "game-complete" && view.viewerSeat === snapshot.hostSeatId && (
<Button type="button" onClick={() => void start()} disabled={starting}>
{starting ? "Starting..." : "Start new game"}
</Button>
)}
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setFullscreen(false)}
aria-label="Exit fullscreen table"
title="Exit fullscreen table"
>
<Minimize2 aria-hidden="true" />
</Button>
<Button type="button" variant="outline" onClick={() => void leave()} disabled={leaving}>
{leaving ? "Leaving..." : "Leave table"}
</Button>
</div>
</div>
{feltCanvas}
</div>,
document.body,
);
}
return (
<UiCard className="rounded-none">
<CardHeader className="flex-row items-start justify-between gap-4 border-b p-4">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-base">{tableName}</CardTitle>
<RenameDialogGuard
canRename={view.viewerSeat === snapshot.hostSeatId}
tableId={snapshot.tableId}
currentName={snapshot.name}
onSnapshot={onSnapshot}
/>
<Badge variant="outline">{PHASE_LABELS[view.phase]}</Badge>
</div>
<p className="font-mono text-xs tabular-nums text-muted-foreground">
Hand {view.handNumber} · Blinds {view.config.smallBlind}/{view.config.bigBlind}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="font-mono text-xs tabular-nums text-muted-foreground">
v{snapshot.version}
</span>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setFullscreen(true)}
aria-label="Enter fullscreen table"
title="Enter fullscreen table"
>
<Maximize2 aria-hidden="true" />
</Button>
</div>
</CardHeader>
<CardContent className="p-4">{feltCanvas}</CardContent>
<CardFooter className="flex flex-wrap justify-end gap-2 border-t p-4">
<TableActionButtons
view={view}
hostSeatId={snapshot.hostSeatId}
starting={starting}
leaving={leaving}
onStart={() => void start()}
onLeave={() => void leave()}
/>
</CardFooter>
</UiCard>
);
}

View file

@ -0,0 +1,161 @@
"use client";
import { BookOpen } from "lucide-react";
import type { Card, Rank, Suit } from "@/lib/minigames/poker";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { CardView } from "./CardView";
interface PokerRanking {
readonly name: string;
readonly example: readonly Card[];
readonly description: string;
}
function card(rank: Rank, suit: Suit): Card {
return { rank, suit };
}
const RANKINGS: readonly PokerRanking[] = [
{
name: "High card",
example: [card(14, "s"), card(13, "h"), card(9, "d"), card(5, "c"), card(2, "s")],
description: "No matching ranks, the highest card wins.",
},
{
name: "One pair",
example: [card(9, "s"), card(9, "h"), card(13, "d"), card(7, "c"), card(3, "s")],
description: "Two cards share the same rank.",
},
{
name: "Two pair",
example: [card(11, "s"), card(11, "h"), card(4, "d"), card(4, "c"), card(14, "s")],
description: "Two different pairs plus a fifth card.",
},
{
name: "Three of a kind",
example: [card(7, "s"), card(7, "h"), card(7, "d"), card(13, "c"), card(2, "s")],
description: "Three cards share the same rank.",
},
{
name: "Straight",
example: [card(5, "s"), card(6, "h"), card(7, "d"), card(8, "c"), card(9, "s")],
description: "Five cards run in rank order.",
},
{
name: "Flush",
example: [card(14, "h"), card(10, "h"), card(7, "h"), card(4, "h"), card(2, "h")],
description: "Five cards share the same suit.",
},
{
name: "Full house",
example: [card(13, "s"), card(13, "h"), card(13, "d"), card(8, "c"), card(8, "s")],
description: "Three of one rank and two of another.",
},
{
name: "Four of a kind",
example: [card(3, "s"), card(3, "h"), card(3, "d"), card(3, "c"), card(14, "s")],
description: "Four cards share the same rank.",
},
{
name: "Straight flush",
example: [card(5, "s"), card(6, "s"), card(7, "s"), card(8, "s"), card(9, "s")],
description: "Five cards run in order and share a suit.",
},
{
name: "Royal flush",
example: [card(10, "h"), card(11, "h"), card(12, "h"), card(13, "h"), card(14, "h")],
description: "The ace-high straight flush, the strongest hand.",
},
];
export function PokerGuide() {
return (
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm">
<BookOpen />
How to play
</Button>
</DialogTrigger>
<DialogContent className="max-h-[85dvh] max-w-4xl overflow-y-auto rounded-none">
<DialogHeader>
<DialogTitle>How to play poker</DialogTitle>
<DialogDescription>
Build the best five-card hand from your hole cards and the community cards.
</DialogDescription>
</DialogHeader>
<section className="flex flex-col gap-3">
<h2 className="text-sm font-semibold uppercase tracking-wider">Hand rankings</h2>
<ol className="grid gap-3 sm:grid-cols-2">
{RANKINGS.map((ranking, index) => (
<li key={ranking.name} className="flex items-center gap-3 border border-border p-3">
<span className="w-5 shrink-0 font-mono text-xs text-muted-foreground">
{index + 1}
</span>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-medium">{ranking.name}</span>
<div className="flex gap-0.5" aria-label={`${ranking.name} example`}>
{ranking.example.map((exampleCard, cardIndex) => (
<CardView
key={`${exampleCard.rank}-${exampleCard.suit}`}
card={exampleCard}
size="mini"
dealIndex={cardIndex}
/>
))}
</div>
</div>
<p className="text-xs text-muted-foreground">{ranking.description}</p>
</div>
</li>
))}
</ol>
</section>
<section className="flex flex-col gap-3 border-t border-border pt-4">
<h2 className="text-sm font-semibold uppercase tracking-wider">Betting basics</h2>
<ul className="grid gap-2 text-sm text-muted-foreground sm:grid-cols-2">
<li>
<span className="font-medium text-foreground">Blinds:</span> small and big blinds seed
every hand.
</li>
<li>
<span className="font-medium text-foreground">Check:</span> pass when no call is due.
</li>
<li>
<span className="font-medium text-foreground">Call:</span> match the current bet.
</li>
<li>
<span className="font-medium text-foreground">Bet:</span> open the betting on a
street.
</li>
<li>
<span className="font-medium text-foreground">Raise:</span> increase an existing bet.
</li>
<li>
<span className="font-medium text-foreground">Fold:</span> give up the hand.
</li>
<li>
<span className="font-medium text-foreground">Min-raise:</span> meet the server&apos;s
minimum raise.
</li>
<li>
<span className="font-medium text-foreground">All-in:</span> commit your remaining
stack.
</li>
</ul>
</section>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,237 @@
"use client";
import { useEffect, useState } from "react";
import { Trophy } from "lucide-react";
import type { PresenceParticipant } from "@/hooks/useRealtimePresence";
import { cn } from "@/lib/utils";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
interface ApiUser {
readonly id: number;
readonly username?: string | null;
readonly displayName?: string | null;
}
interface ApiProfile {
readonly id: number;
readonly profileTitle?: string | null;
readonly user?: ApiUser | number | null;
readonly progression?: {
readonly minigames?: {
readonly poker?: {
readonly gamesPlayed?: number | null;
readonly wins?: number | null;
readonly bestScore?: number | null;
readonly xpEarned?: number | null;
} | null;
} | null;
} | null;
}
interface LeaderboardRow {
readonly id: number;
readonly userId: number | null;
readonly name: string;
readonly gamesPlayed: number;
readonly wins: number;
readonly bestScore: number;
readonly xpEarned: number;
readonly isCurrentUser: boolean;
readonly isOutsideTopTen?: boolean;
}
interface PokerLeaderboardProps {
readonly refreshKey: number;
readonly currentUserId?: number | string;
readonly activePlayers: readonly PresenceParticipant[];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function readProfiles(value: unknown): readonly ApiProfile[] {
if (!isRecord(value) || !Array.isArray(value.docs)) return [];
return value.docs.filter((doc): doc is ApiProfile => {
if (!isRecord(doc) || typeof doc.id !== "number") return false;
const user = doc.user;
return user === null || user === undefined || typeof user === "number" || isRecord(user);
});
}
function rankClass(index: number): string {
if (index === 0) return "font-bold text-amber-400";
if (index === 1) return "font-semibold text-slate-300";
if (index === 2) return "font-semibold text-amber-700";
return "text-muted-foreground";
}
export function PokerLeaderboard({
refreshKey,
currentUserId,
activePlayers,
}: PokerLeaderboardProps) {
const [rows, setRows] = useState<readonly LeaderboardRow[] | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
setFailed(false);
const params = new URLSearchParams({
sort: "-progression.minigames.poker.bestScore",
limit: "100",
depth: "1",
});
fetch(`/api/profiles?${params.toString()}`)
.then(async (response) => {
if (!response.ok) throw new Error("leaderboard fetch failed");
return readProfiles(await response.json());
})
.then((profiles) => {
if (cancelled) return;
const mapped = profiles.map((profile): LeaderboardRow => {
const poker = profile.progression?.minigames?.poker ?? {};
const user =
typeof profile.user === "object" && profile.user !== null ? profile.user : null;
return {
id: profile.id,
userId: user?.id ?? null,
name:
user?.displayName ||
profile.profileTitle ||
user?.username ||
`Player #${profile.id}`,
gamesPlayed: poker.gamesPlayed ?? 0,
wins: poker.wins ?? 0,
bestScore: poker.bestScore ?? 0,
xpEarned: poker.xpEarned ?? 0,
isCurrentUser: user ? String(user.id) === String(currentUserId) : false,
};
});
const visible = mapped.filter((row) => row.bestScore > 0).slice(0, 10);
const current = mapped.find((row) => row.isCurrentUser);
if (current && !visible.some((row) => row.id === current.id)) {
visible.push({ ...current, isOutsideTopTen: true });
}
setRows(visible);
})
.catch(() => {
if (!cancelled) setFailed(true);
});
return () => {
cancelled = true;
};
}, [currentUserId, refreshKey]);
return (
<Card className="rounded-none">
<CardHeader className="border-b p-4">
<div className="flex items-center gap-2">
<Trophy className="size-4 text-amber-400" />
<CardTitle className="text-sm">Poker leaderboard</CardTitle>
</div>
<CardDescription>Top 10 by best chip total</CardDescription>
</CardHeader>
<CardContent className="p-0">
<div
className="border-b border-border px-4 py-2 text-xs text-muted-foreground"
aria-live="polite"
>
<span className="font-medium text-emerald-400">{activePlayers.length}</span>{" "}
{activePlayers.length === 1 ? "player is" : "players are"} at the tables
{activePlayers.length > 0 && (
<span className="ml-1 text-foreground">
({activePlayers.map((player) => player.name).join(", ")})
</span>
)}
</div>
{failed ? (
<p className="px-4 py-6 text-sm text-muted-foreground">Could not load the leaderboard.</p>
) : rows === null ? (
<div className="flex flex-col gap-2 p-4">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
) : rows.length === 0 ? (
<p className="px-4 py-6 text-sm text-muted-foreground">
No completed games yet. Win the first table.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Player</TableHead>
<TableHead className="text-right">Wins</TableHead>
<TableHead className="text-right">Games</TableHead>
<TableHead className="text-right">Best chips</TableHead>
<TableHead className="text-right">XP</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, index) => (
<TableRow
key={row.id}
className={cn(
row.isCurrentUser && "bg-accent/40",
row.isOutsideTopTen && "opacity-70",
)}
title={
row.isOutsideTopTen ? "Player is not currently ranked in the top 10" : undefined
}
>
<TableCell>
<span className={rankClass(index)}>
{row.isOutsideTopTen ? "-" : index + 1}
</span>
</TableCell>
<TableCell className="font-medium">
<span className="flex items-center gap-2">
<span className="max-w-40 truncate">{row.name}</span>
{row.isCurrentUser && (
<span className="text-xs text-muted-foreground">(you)</span>
)}
{row.userId !== null &&
activePlayers.some((player) => player.id === row.userId) && (
<span
className="size-2 rounded-full bg-emerald-400"
title="Playing now"
aria-label="Playing now"
/>
)}
</span>
</TableCell>
<TableCell className="text-right font-mono tabular-nums text-muted-foreground">
{row.wins}
</TableCell>
<TableCell className="text-right font-mono tabular-nums text-muted-foreground">
{row.gamesPlayed}
</TableCell>
<TableCell className="text-right font-mono font-semibold tabular-nums">
{row.bestScore.toLocaleString()}
</TableCell>
<TableCell className="text-right font-mono tabular-nums text-muted-foreground">
{row.xpEarned}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,319 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Ban, ClipboardCopy, CirclePlus, LoaderCircle, RefreshCw, Users } from "lucide-react";
import { cn } from "@/lib/utils";
import {
createPokerTable,
forceExpirePokerTable,
joinPokerTable,
listPokerTables,
type PokerSnapshot,
type PokerTableSummary,
} from "@/app/(frontend)/minigames/poker/actions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { POKER_TABLES_EVENT, readPokerTablesEvent } from "./events";
interface PokerLobbyProps {
readonly refreshKey: number;
readonly onOpenTable: (snapshot: PokerSnapshot) => void;
readonly isSuperuser?: boolean;
}
function createdLabel(createdAt: string): string {
const date = new Date(createdAt);
return Number.isNaN(date.getTime())
? "Recently created"
: date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
}
export function PokerLobby({ refreshKey, onOpenTable, isSuperuser = false }: PokerLobbyProps) {
const [tables, setTables] = useState<readonly PokerTableSummary[]>([]);
const [loading, setLoading] = useState(true);
const [pendingTable, setPendingTable] = useState<string | null>(null);
const [forceEndingTable, setForceEndingTable] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [copiedTable, setCopiedTable] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const loadTables = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await listPokerTables();
if (!result.success) {
setError(result.error ?? "Could not load poker tables.");
return;
}
setTables(result.data ?? []);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not load poker tables.");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadTables();
}, [loadTables, refreshKey]);
useEffect(() => {
const handleTables = (event: Event) => {
const detail = readPokerTablesEvent(event);
if (detail) setTables(detail.tables);
};
window.addEventListener(POKER_TABLES_EVENT, handleTables);
// Poll fallback for the same SSE-reliability reason as PokerArena.
const poll = window.setInterval(() => void loadTables(), 3000);
return () => {
window.removeEventListener(POKER_TABLES_EVENT, handleTables);
window.clearInterval(poll);
};
}, [loadTables]);
const join = async (tableId: string) => {
setPendingTable(tableId);
setError(null);
try {
const result = await joinPokerTable(tableId);
if (result.success && result.data) {
onOpenTable(result.data);
} else {
setError(result.error ?? "Could not join that table.");
}
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not join that table.");
} finally {
setPendingTable(null);
}
};
const forceEnd = async (tableId: string) => {
setForceEndingTable(tableId);
setError(null);
try {
const result = await forceExpirePokerTable(tableId);
if (!result.success) {
setError(result.error ?? "Could not force-end the poker table.");
}
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not force-end the poker table.");
} finally {
setForceEndingTable(null);
}
};
const create = async () => {
setCreating(true);
setError(null);
try {
const created = await createPokerTable();
if (!created.success || !created.data) {
setError(created.error ?? "Could not create a poker table.");
return;
}
await join(created.data.tableId);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not create a poker table.");
} finally {
setCreating(false);
}
};
const copyLink = async (tableId: string) => {
if (!navigator.clipboard) {
setError("Clipboard access is not available in this browser.");
return;
}
try {
await navigator.clipboard.writeText(
`${window.location.origin}/minigames/poker?table=${encodeURIComponent(tableId)}`,
);
setCopiedTable(tableId);
window.setTimeout(
() => setCopiedTable((current) => (current === tableId ? null : current)),
1600,
);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not copy the table link.");
}
};
return (
<Card className="rounded-none">
<CardHeader className="flex-row items-start justify-between gap-4 border-b p-4">
<div className="flex flex-col gap-1">
<CardTitle className="text-base">Poker lobby</CardTitle>
<CardDescription>Join an open table or create a room for your squad.</CardDescription>
</div>
<div className="flex shrink-0 gap-2">
<Button
type="button"
variant="outline"
size="icon"
onClick={() => void loadTables()}
disabled={loading}
aria-label="Refresh poker tables"
title="Refresh tables"
>
<RefreshCw className={cn(loading && "animate-spin motion-reduce:animate-none")} />
</Button>
<Button type="button" onClick={() => void create()} disabled={creating}>
{creating ? (
<LoaderCircle className="animate-spin motion-reduce:animate-none" />
) : (
<CirclePlus />
)}
{creating ? "Creating..." : "Create table"}
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
{error && (
<p className="border-b border-border px-4 py-3 text-sm text-destructive">{error}</p>
)}
{loading && tables.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
Scanning for open tables...
</p>
) : tables.length === 0 ? (
<div className="flex flex-col items-center gap-3 px-4 py-12 text-center">
<Users className="size-8 text-muted-foreground" />
<div className="flex flex-col gap-1">
<p className="font-medium">No active tables</p>
<p className="text-sm text-muted-foreground">
Create the first room and invite your unit.
</p>
</div>
<Button
type="button"
variant="outline"
onClick={() => void create()}
disabled={creating}
>
Create a table
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Table</TableHead>
<TableHead>Players</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tables.map((table) => {
const full = table.seatedCount >= table.maxSeats;
const joining = pendingTable === table.tableId;
return (
<TableRow key={table.tableId}>
<TableCell>
<div className="flex flex-col gap-1">
<span className={cn("text-sm font-semibold", !table.name && "font-mono")}>
{table.name ?? table.tableId}
</span>
<span className="text-xs text-muted-foreground">
Host {table.hostName} · {createdLabel(table.createdAt)}
</span>
</div>
</TableCell>
<TableCell className="font-mono tabular-nums text-muted-foreground">
{table.seatedCount}/{table.maxSeats}
</TableCell>
<TableCell>
<Badge variant={table.inGame ? "secondary" : "outline"}>
{table.inGame ? "In progress" : full ? "Full" : "Open"}
</Badge>
</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="icon"
onClick={() => void copyLink(table.tableId)}
aria-label={`Copy link for ${table.name ?? table.tableId}`}
title="Copy share link"
>
<ClipboardCopy />
</Button>
{isSuperuser && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
disabled={forceEndingTable !== null}
aria-label={`Force-end ${table.name ?? table.tableId}`}
title="Force-end table"
>
<Ban aria-hidden="true" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="rounded-none">
<AlertDialogHeader>
<AlertDialogTitle>Force-end this table?</AlertDialogTitle>
<AlertDialogDescription>
Seated players will be notified.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => void forceEnd(table.tableId)}
>
Force-end table
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<Button
type="button"
size="sm"
onClick={() => void join(table.tableId)}
disabled={full || joining}
>
{joining ? "Joining..." : table.inGame ? "Join" : "Join table"}
</Button>
</div>
{copiedTable === table.tableId && (
<p className="mt-1 text-right text-xs text-emerald-500">Link copied</p>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,102 @@
"use client";
import { cn } from "@/lib/utils";
import type { Card, PublicPlayerView } from "@/lib/minigames/poker";
import { Badge } from "@/components/ui/badge";
import { CardView } from "./CardView";
interface PokerSeatProps {
readonly player: PublicPlayerView;
readonly isCurrentUser: boolean;
readonly isActing: boolean;
readonly isWinner: boolean;
readonly showHoleCards: boolean;
readonly handLabel?: string | null;
readonly cardKeySet?: ReadonlySet<string>;
readonly compact?: boolean;
}
function cardKey(card: Card): string {
return `${card.rank}-${card.suit}`;
}
export function PokerSeat({
player,
isCurrentUser,
isActing,
isWinner,
showHoleCards,
handLabel = null,
cardKeySet,
compact = false,
}: PokerSeatProps) {
const cards = showHoleCards ? player.holeCards : null;
const cardCount = player.cardCount > 0 ? player.cardCount : 2;
const faceCards = cards ?? Array.from({ length: cardCount }, () => null);
return (
<div
className={cn(
"poker-seat-panel flex flex-col gap-2 border p-3 text-left backdrop-blur-sm",
compact && "flex-row flex-wrap items-center gap-2 p-2 sm:flex-nowrap sm:gap-3",
player.folded && "opacity-50",
player.eliminated && "opacity-40",
isActing && "ring-2 ring-emerald-400/90",
isCurrentUser && "border-ring",
isWinner && "ring-2 ring-amber-300",
)}
aria-label={`${player.name}, seat ${player.seatId + 1}`}
>
<div className={cn("flex items-start justify-between gap-2", compact && "min-w-0 flex-1")}>
<div className="min-w-0">
<p className="truncate text-sm font-semibold">
{player.name}
{isCurrentUser && <span className="ml-1 text-xs text-muted-foreground">(you)</span>}
</p>
<p className="font-mono text-xs tabular-nums text-muted-foreground">
Stack {player.stack.toLocaleString()}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{player.allIn && <Badge variant="destructive">All-in</Badge>}
{player.folded && <Badge variant="outline">Folded</Badge>}
{isWinner && <Badge variant="secondary">Winner</Badge>}
</div>
</div>
{handLabel && (
<span
className={cn(
"w-fit max-w-full border border-amber-300/40 bg-amber-300/10 px-2 py-1 text-xs text-amber-200",
compact && "max-w-[12rem] truncate",
)}
aria-live="polite"
>
You have: {handLabel}
</span>
)}
<div className={cn("flex items-end justify-between gap-2", compact && "shrink-0")}>
<div className="flex gap-1" aria-label="Hole cards">
{faceCards.map((card, index) => (
<CardView
key={`${player.seatId}-hole-${index}`}
card={card}
faceUp={card !== null}
size={compact ? "mini" : "sm"}
dealIndex={index}
highlighted={card !== null && cardKeySet?.has(cardKey(card))}
dimmed={card !== null && cardKeySet !== undefined && !cardKeySet.has(cardKey(card))}
/>
))}
</div>
<div className="text-right">
<p className="font-mono text-xs tabular-nums text-muted-foreground">
{player.committed > 0 ? `Bet ${player.committed.toLocaleString()}` : "No bet"}
</p>
{isActing && <p className="text-xs font-semibold text-emerald-300">Acting</p>}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,219 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { PokerGameTable } from "./PokerGameTable";
import { RenameTableDialog } from "./RenameTableDialog";
import { seatPosition } from "./seatPosition";
interface PokerTableProps {
readonly snapshot: PokerSnapshot | null;
readonly currentUserId: number | string;
readonly loading: boolean;
readonly tableError: string | null;
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
readonly onJoin: () => Promise<void>;
readonly onStart: () => Promise<void>;
readonly onLeave: () => Promise<void>;
readonly onBack: () => void;
}
type SeatedSnapshot = PokerSnapshot & {
readonly you: NonNullable<PokerSnapshot["you"]>;
};
function isSeatedSnapshot(snapshot: PokerSnapshot): snapshot is SeatedSnapshot {
return snapshot.you !== null;
}
function WaitingTable({
snapshot,
currentUserId,
onSnapshot,
onJoin,
onStart,
onLeave,
onBack,
}: {
readonly snapshot: PokerSnapshot;
readonly currentUserId: number | string;
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
readonly onJoin: () => Promise<void>;
readonly onStart: () => Promise<void>;
readonly onLeave: () => Promise<void>;
readonly onBack: () => void;
}) {
const [starting, setStarting] = useState(false);
const [leaving, setLeaving] = useState(false);
const viewerSeat = snapshot.seats.findIndex((id) => String(id) === String(currentUserId));
const seated = viewerSeat >= 0;
const full = snapshot.seats.length > 0 && snapshot.seats.every((id) => id !== null);
const isHost = seated && snapshot.hostSeatId === viewerSeat;
const start = async () => {
setStarting(true);
await onStart();
setStarting(false);
};
const leave = async () => {
setLeaving(true);
await onLeave();
setLeaving(false);
};
return (
<Card className="rounded-none">
<CardHeader className="flex-row items-start justify-between gap-4 border-b p-4">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<CardTitle className="text-base">
{snapshot.name ?? `Table ${snapshot.tableId}`}
</CardTitle>
{isHost && (
<RenameTableDialog
tableId={snapshot.tableId}
currentName={snapshot.name}
onSnapshot={onSnapshot}
/>
)}
</div>
<p className="text-sm text-muted-foreground">
{seated
? "You have a seat reserved."
: "Join an open seat to receive a private hand view."}
</p>
</div>
<Badge variant="outline">Waiting room</Badge>
</CardHeader>
<CardContent className="p-4">
<div className="poker-felt-table relative min-h-[28rem] overflow-hidden p-4 sm:min-h-[34rem]">
{snapshot.seats.map((userId, seatId) => (
<div
key={seatId}
className={cn(
"poker-waiting-seat absolute flex flex-col gap-1 border px-3 py-2 text-xs",
seatPosition(seatId),
userId !== null && "poker-waiting-seat-occupied",
)}
>
<span className="font-semibold">Seat {seatId + 1}</span>
<span className="text-white/70">
{userId === null
? "Open seat"
: String(userId) === String(currentUserId)
? "You"
: "Occupied"}
</span>
</div>
))}
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-2 text-center text-white">
<span className="font-mono text-xs uppercase tracking-[0.2em] text-white/60">
Polaris Hold&apos;em
</span>
<span className="text-sm text-white/80">
{seated ? "Waiting for the host to start" : "Open seating"}
</span>
</div>
</div>
</CardContent>
<CardFooter className="flex flex-wrap justify-end gap-2 border-t p-4">
{seated ? (
<>
{isHost && (
<Button type="button" onClick={() => void start()} disabled={starting}>
{starting ? "Starting..." : "Start game"}
</Button>
)}
<Button type="button" variant="outline" onClick={() => void leave()} disabled={leaving}>
{leaving ? "Leaving..." : "Leave table"}
</Button>
</>
) : (
<>
<Button type="button" onClick={() => void onJoin()} disabled={full}>
{full ? "Table full" : "Join an open seat"}
</Button>
<Button type="button" variant="outline" onClick={onBack}>
Back to lobby
</Button>
</>
)}
</CardFooter>
</Card>
);
}
export function PokerTable({
snapshot,
currentUserId,
loading,
tableError,
onSnapshot,
onJoin,
onStart,
onLeave,
onBack,
}: PokerTableProps) {
if (loading && !snapshot) {
return (
<Card className="rounded-none">
<CardHeader className="gap-3 p-4">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent className="p-4 pt-0">
<Skeleton className="h-[28rem] w-full" />
</CardContent>
</Card>
);
}
if (!snapshot) {
return (
<Card className="rounded-none">
<CardHeader className="p-4">
<CardTitle className="text-base">Table unavailable</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<p className="text-sm text-destructive">
{tableError ?? "This table could not be loaded."}
</p>
</CardContent>
<CardFooter className="border-t p-4">
<Button type="button" variant="outline" onClick={onBack}>
Back to lobby
</Button>
</CardFooter>
</Card>
);
}
if (!isSeatedSnapshot(snapshot)) {
return (
<WaitingTable
snapshot={snapshot}
currentUserId={currentUserId}
onSnapshot={onSnapshot}
onJoin={onJoin}
onStart={onStart}
onLeave={onLeave}
onBack={onBack}
/>
);
}
return (
<PokerGameTable
snapshot={snapshot}
onSnapshot={onSnapshot}
onStart={onStart}
onLeave={onLeave}
/>
);
}

View file

@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import { Pencil } from "lucide-react";
import { MAX_TABLE_NAME_LENGTH } from "@/lib/minigames/poker/constants";
import { renamePokerTable, type PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
interface RenameDialogGuardProps {
readonly canRename: boolean;
readonly tableId: string;
readonly currentName: string | null;
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
}
export function RenameDialogGuard({
canRename,
tableId,
currentName,
onSnapshot,
}: RenameDialogGuardProps) {
const [open, setOpen] = useState(false);
const [value, setValue] = useState(currentName ?? "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!canRename) return null;
const save = async () => {
setSaving(true);
setError(null);
try {
const result = await renamePokerTable(tableId, value);
if (!result.success || !result.data) {
setError(result.error ?? "Could not rename the table.");
return;
}
onSnapshot(result.data);
setOpen(false);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not rename the table.");
} finally {
setSaving(false);
}
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (next) setValue(currentName ?? "");
setOpen(next);
}}
>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Rename table"
title="Rename table"
>
<Pencil className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-sm rounded-none">
<DialogHeader>
<DialogTitle>Name this table</DialogTitle>
<DialogDescription>
The name shows in the lobby and at the table. Share links stay the same.
</DialogDescription>
</DialogHeader>
<Input
value={value}
onChange={(event) => setValue(event.target.value.slice(0, MAX_TABLE_NAME_LENGTH))}
placeholder="Friday night hold'em"
maxLength={MAX_TABLE_NAME_LENGTH}
aria-label="Table name"
onKeyDown={(event) => {
if (event.key === "Enter") void save();
}}
/>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="button" onClick={() => void save()} disabled={saving}>
{saving ? "Saving..." : "Save name"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,97 @@
"use client";
import { useState } from "react";
import { Pencil } from "lucide-react";
import { MAX_TABLE_NAME_LENGTH } from "@/lib/minigames/poker/constants";
import { renamePokerTable, type PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
interface RenameTableDialogProps {
readonly tableId: string;
readonly currentName: string | null;
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
}
export function RenameTableDialog({ tableId, currentName, onSnapshot }: RenameTableDialogProps) {
const [open, setOpen] = useState(false);
const [value, setValue] = useState(currentName ?? "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const save = async () => {
setSaving(true);
setError(null);
try {
const result = await renamePokerTable(tableId, value);
if (!result.success || !result.data) {
setError(result.error ?? "Could not rename the table.");
return;
}
onSnapshot(result.data);
setOpen(false);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not rename the table.");
} finally {
setSaving(false);
}
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (next) setValue(currentName ?? "");
setOpen(next);
}}
>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Rename table"
title="Rename table"
>
<Pencil className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-sm rounded-none">
<DialogHeader>
<DialogTitle>Name this table</DialogTitle>
<DialogDescription>
The name shows in the lobby and at the table. Share links stay the same.
</DialogDescription>
</DialogHeader>
<Input
value={value}
onChange={(event) => setValue(event.target.value.slice(0, MAX_TABLE_NAME_LENGTH))}
placeholder="Friday night hold'em"
maxLength={MAX_TABLE_NAME_LENGTH}
aria-label="Table name"
onKeyDown={(event) => {
if (event.key === "Enter") void save();
}}
/>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="button" onClick={() => void save()} disabled={saving}>
{saving ? "Saving..." : "Save name"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,76 @@
import type { PokerSnapshot, PokerTableSummary } from "@/app/(frontend)/minigames/poker/actions";
export { POKER_STATE_EVENT, POKER_TABLES_EVENT, POKER_TERMINATED_EVENT } from "@/hooks/useGameTick";
export interface PokerStateEventDetail {
readonly tableId: string;
readonly snapshot: PokerSnapshot;
}
export interface PokerTablesEventDetail {
readonly tables: readonly PokerTableSummary[];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isTableSummary(value: unknown): value is PokerTableSummary {
if (!isRecord(value)) return false;
return (
typeof value.tableId === "string" &&
typeof value.hostName === "string" &&
typeof value.seatedCount === "number" &&
typeof value.maxSeats === "number" &&
typeof value.inGame === "boolean" &&
typeof value.createdAt === "string"
);
}
function isPokerSnapshot(value: unknown): value is PokerSnapshot {
if (!isRecord(value)) return false;
const seats = value.seats;
return (
typeof value.tableId === "string" &&
typeof value.version === "number" &&
(value.turnDeadline === null || typeof value.turnDeadline === "string") &&
(value.nextHandAt === null || typeof value.nextHandAt === "string") &&
(value.you === null || isRecord(value.you)) &&
Array.isArray(seats) &&
seats.every((seat) => seat === null || typeof seat === "number") &&
(value.hostSeatId === null || typeof value.hostSeatId === "number")
);
}
export function readPokerStateEvent(event: Event): PokerStateEventDetail | null {
if (!(event instanceof CustomEvent) || !isRecord(event.detail)) return null;
const detail = event.detail;
if (typeof detail.tableId !== "string" || !isPokerSnapshot(detail.snapshot)) return null;
return {
tableId: detail.tableId,
snapshot: detail.snapshot,
};
}
export function readPokerTablesEvent(event: Event): PokerTablesEventDetail | null {
if (!(event instanceof CustomEvent) || !isRecord(event.detail)) return null;
const rawTables = event.detail.tables;
if (!Array.isArray(rawTables)) return null;
const tables: PokerTableSummary[] = [];
for (const table of rawTables) {
if (!isTableSummary(table)) return null;
tables.push(table);
}
return { tables };
}
export function readPokerTerminatedEvent(event: Event): { tableId: string; reason: string } | null {
if (!(event instanceof CustomEvent) || !isRecord(event.detail)) return null;
const detail = event.detail;
if (typeof detail.tableId !== "string") return null;
return {
tableId: detail.tableId,
reason: typeof detail.reason === "string" ? detail.reason : "force-ended",
};
}

View file

@ -0,0 +1,10 @@
const SEAT_POSITION_CLASSES = [
"poker-seat-south",
"poker-seat-west",
"poker-seat-north",
"poker-seat-east",
] as const;
export function seatPosition(seatId: number): string {
return SEAT_POSITION_CLASSES[seatId % SEAT_POSITION_CLASSES.length] ?? SEAT_POSITION_CLASSES[0];
}

View file

@ -0,0 +1,10 @@
export interface PokerStats {
readonly gamesPlayed: number;
readonly wins: number;
readonly secondPlaces: number;
readonly thirdPlaces: number;
readonly fourthPlaces: number;
readonly handsWon: number;
readonly bestScore: number;
readonly xpEarned: number;
}

View file

@ -6,8 +6,14 @@ import {
GAME_EVENT_EVENT, GAME_EVENT_EVENT,
GAME_TICK_EVENT, GAME_TICK_EVENT,
NOTIFICATION_EVENT, NOTIFICATION_EVENT,
POKER_STATE_EVENT,
POKER_TABLES_EVENT,
POKER_TERMINATED_EVENT,
PROFILE_UPDATE_EVENT, PROFILE_UPDATE_EVENT,
type GameTickDetail, type GameTickDetail,
type PokerStateDetail,
type PokerTablesDetail,
type PokerTerminatedDetail,
type ProfileUpdateDetail, type ProfileUpdateDetail,
} from "@/hooks/useGameTick"; } from "@/hooks/useGameTick";
@ -38,9 +44,7 @@ export function GameTickRealtime() {
// ignore malformed payload // ignore malformed payload
} }
refresh(); refresh();
window.dispatchEvent( window.dispatchEvent(new CustomEvent(GAME_TICK_EVENT, { detail }));
new CustomEvent(GAME_TICK_EVENT, { detail }),
);
}); });
source.addEventListener("profile-update", (event) => { source.addEventListener("profile-update", (event) => {
let detail: ProfileUpdateDetail = {}; let detail: ProfileUpdateDetail = {};
@ -59,6 +63,33 @@ export function GameTickRealtime() {
source.addEventListener("notification", (event) => { source.addEventListener("notification", (event) => {
window.dispatchEvent(new CustomEvent(NOTIFICATION_EVENT, { detail: event.data })); window.dispatchEvent(new CustomEvent(NOTIFICATION_EVENT, { detail: event.data }));
}); });
source.addEventListener("poker-state", (event) => {
let detail: PokerStateDetail = {};
try {
detail = JSON.parse(event.data ?? "{}");
} catch {
// ignore malformed payload
}
window.dispatchEvent(new CustomEvent(POKER_STATE_EVENT, { detail }));
});
source.addEventListener("poker-tables", (event) => {
let detail: PokerTablesDetail = {};
try {
detail = JSON.parse(event.data ?? "{}");
} catch {
// ignore malformed payload
}
window.dispatchEvent(new CustomEvent(POKER_TABLES_EVENT, { detail }));
});
source.addEventListener("poker-terminated", (event) => {
let detail: PokerTerminatedDetail = {};
try {
detail = JSON.parse(event.data ?? "{}");
} catch {
// ignore malformed payload
}
window.dispatchEvent(new CustomEvent(POKER_TERMINATED_EVENT, { detail }));
});
return () => { return () => {
if (refreshTimer) clearTimeout(refreshTimer); if (refreshTimer) clearTimeout(refreshTimer);
source.close(); source.close();

View file

@ -1,12 +1,16 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { PokerSnapshot, PokerTableSummary } from "@/app/(frontend)/minigames/poker/actions";
export const GAME_TICK_EVENT = "ptf:game-tick"; export const GAME_TICK_EVENT = "ptf:game-tick";
export const PROFILE_UPDATE_EVENT = "ptf:profile-update"; export const PROFILE_UPDATE_EVENT = "ptf:profile-update";
export const GAME_EVENT_EVENT = "ptf:game-event"; export const GAME_EVENT_EVENT = "ptf:game-event";
export const NOTIFICATION_EVENT = "ptf:notification"; export const NOTIFICATION_EVENT = "ptf:notification";
export const SESSION_EXPIRED_EVENT = "ptf:session-expired"; export const SESSION_EXPIRED_EVENT = "ptf:session-expired";
export const POKER_STATE_EVENT = "ptf:poker-state";
export const POKER_TABLES_EVENT = "ptf:poker-tables";
export const POKER_TERMINATED_EVENT = "ptf:poker-terminated";
export interface GameTickDetail { export interface GameTickDetail {
processedAt?: string; processedAt?: string;
@ -17,6 +21,20 @@ export interface ProfileUpdateDetail {
xpGained?: number; xpGained?: number;
} }
export interface PokerStateDetail {
tableId?: string;
snapshot?: PokerSnapshot;
}
export interface PokerTablesDetail {
tables?: PokerTableSummary[];
}
export interface PokerTerminatedDetail {
tableId?: string;
reason?: string;
}
export function useGameTick(onTick?: () => void): number { export function useGameTick(onTick?: () => void): number {
const [count, setCount] = useState(0); const [count, setCount] = useState(0);
const callbackRef = useRef(onTick); const callbackRef = useRef(onTick);