From 1fb0165c12952e2ca6465d2cb86d5a1e20804124 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Fri, 11 Sep 2026 03:25:43 -0400 Subject: [PATCH] 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 --- src/app/(frontend)/minigames/poker/actions.ts | 170 ++++++++ src/app/(frontend)/minigames/poker/page.tsx | 72 ++++ src/app/(frontend)/styles.css | 184 +++++++++ .../command-palette/commands/navigation.ts | 26 +- src/components/frontend/blocks/NavUser.tsx | 7 + .../minigames/poker/BettingControls.tsx | 193 +++++++++ .../frontend/minigames/poker/CardView.tsx | 104 +++++ .../frontend/minigames/poker/PokerArena.tsx | 312 ++++++++++++++ .../frontend/minigames/poker/PokerClock.tsx | 61 +++ .../minigames/poker/PokerGameTable.tsx | 388 ++++++++++++++++++ .../frontend/minigames/poker/PokerGuide.tsx | 161 ++++++++ .../minigames/poker/PokerLeaderboard.tsx | 237 +++++++++++ .../frontend/minigames/poker/PokerLobby.tsx | 319 ++++++++++++++ .../frontend/minigames/poker/PokerSeat.tsx | 102 +++++ .../frontend/minigames/poker/PokerTable.tsx | 219 ++++++++++ .../minigames/poker/RenameDialogGuard.tsx | 104 +++++ .../minigames/poker/RenameTableDialog.tsx | 97 +++++ .../frontend/minigames/poker/events.ts | 76 ++++ .../frontend/minigames/poker/seatPosition.ts | 10 + .../frontend/minigames/poker/types.ts | 10 + .../frontend/realtime/GameTickRealtime.tsx | 37 +- src/hooks/useGameTick.ts | 18 + 22 files changed, 2902 insertions(+), 5 deletions(-) create mode 100644 src/app/(frontend)/minigames/poker/actions.ts create mode 100644 src/app/(frontend)/minigames/poker/page.tsx create mode 100644 src/components/frontend/minigames/poker/BettingControls.tsx create mode 100644 src/components/frontend/minigames/poker/CardView.tsx create mode 100644 src/components/frontend/minigames/poker/PokerArena.tsx create mode 100644 src/components/frontend/minigames/poker/PokerClock.tsx create mode 100644 src/components/frontend/minigames/poker/PokerGameTable.tsx create mode 100644 src/components/frontend/minigames/poker/PokerGuide.tsx create mode 100644 src/components/frontend/minigames/poker/PokerLeaderboard.tsx create mode 100644 src/components/frontend/minigames/poker/PokerLobby.tsx create mode 100644 src/components/frontend/minigames/poker/PokerSeat.tsx create mode 100644 src/components/frontend/minigames/poker/PokerTable.tsx create mode 100644 src/components/frontend/minigames/poker/RenameDialogGuard.tsx create mode 100644 src/components/frontend/minigames/poker/RenameTableDialog.tsx create mode 100644 src/components/frontend/minigames/poker/events.ts create mode 100644 src/components/frontend/minigames/poker/seatPosition.ts create mode 100644 src/components/frontend/minigames/poker/types.ts diff --git a/src/app/(frontend)/minigames/poker/actions.ts b/src/app/(frontend)/minigames/poker/actions.ts new file mode 100644 index 0000000..f55e8d1 --- /dev/null +++ b/src/app/(frontend)/minigames/poker/actions.ts @@ -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 { + 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> { + 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> { + try { + await authenticate(); + return { success: true, data: listSummaries() }; + } catch (error) { + return toError(error); + } +} + +export async function forceExpirePokerTable(tableId: string): Promise> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + try { + const { user } = await authenticate(); + const snapshot = applyPlayerAction(tableId, Number(user.id), kind, amount); + return { success: true, data: snapshot }; + } catch (error) { + return toError(error); + } +} diff --git a/src/app/(frontend)/minigames/poker/page.tsx b/src/app/(frontend)/minigames/poker/page.tsx new file mode 100644 index 0000000..cace5a3 --- /dev/null +++ b/src/app/(frontend)/minigames/poker/page.tsx @@ -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 ( +
+
+
+ +

Poker

+
+

+ Sit down for a multiplayer Texas Hold'em game. Read the table, manage your stack, and + outlast the field. +

+
+ + +
+ ); +} diff --git a/src/app/(frontend)/styles.css b/src/app/(frontend)/styles.css index ffa88c5..93ea881 100644 --- a/src/app/(frontend)/styles.css +++ b/src/app/(frontend)/styles.css @@ -1130,3 +1130,187 @@ 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; + } +} diff --git a/src/components/command-palette/commands/navigation.ts b/src/components/command-palette/commands/navigation.ts index b5792ea..0223a26 100644 --- a/src/components/command-palette/commands/navigation.ts +++ b/src/components/command-palette/commands/navigation.ts @@ -25,6 +25,7 @@ import { Radio, Bomb, ScrollText, + Spade, } from "lucide-react"; export interface NavigationCommand { @@ -39,8 +40,22 @@ export interface 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", label: "Transfers", @@ -255,6 +270,13 @@ export const navigationCommands: NavigationCommand[] = [ href: "/minigames/opord", 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", label: "Account", diff --git a/src/components/frontend/blocks/NavUser.tsx b/src/components/frontend/blocks/NavUser.tsx index 315b9b0..e01e525 100644 --- a/src/components/frontend/blocks/NavUser.tsx +++ b/src/components/frontend/blocks/NavUser.tsx @@ -11,6 +11,7 @@ import { LogOut, Mic, Radio, + Spade, Users2, } from "lucide-react"; import Link from "next/link"; @@ -173,6 +174,12 @@ export function NavUser({ OPORD Recall + + + + Poker + + diff --git a/src/components/frontend/minigames/poker/BettingControls.tsx b/src/components/frontend/minigames/poker/BettingControls.tsx new file mode 100644 index 0000000..e9558dd --- /dev/null +++ b/src/components/frontend/minigames/poker/BettingControls.tsx @@ -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(null); + const [error, setError] = useState(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 ( +
+
+ + + +
+ + {wagerKind && wagerMax >= wagerMin && ( + + + + {wagerKind === "bet" ? "Bet size" : "Raise to"} + + + +
+ + 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`} + /> + + setAmount(clampAmount(Number(event.target.value), wagerMin, wagerMax)) + } + disabled={!canAct} + className="w-24 font-mono tabular-nums" + aria-label="Wager amount" + /> +
+
+ {(["min", "half", "pot", "all"] as const).map((choice) => ( + + ))} + +
+
+
+ )} + +
+ {!yourTurn && Waiting for the table to act.} + {pendingAction && ( + Waiting for the table update... + )} + {error && {error}} +
+
+ ); +} diff --git a/src/components/frontend/minigames/poker/CardView.tsx b/src/components/frontend/minigames/poker/CardView.tsx new file mode 100644 index 0000000..e7df31a --- /dev/null +++ b/src/components/frontend/minigames/poker/CardView.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import type { Card, Rank, Suit } from "@/lib/minigames/poker"; + +const RANK_LABELS: Record = { + 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 = { + s: "♠", + h: "♥", + d: "♦", + c: "♣", +}; + +const RED_SUITS = new Set(["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 ( + 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} + > + + + {card && ( + <> + + {RANK_LABELS[card.rank]} + {SUIT_SYMBOLS[card.suit]} + + + {SUIT_SYMBOLS[card.suit]} + + + )} + + + + + ); +} diff --git a/src/components/frontend/minigames/poker/PokerArena.tsx b/src/components/frontend/minigames/poker/PokerArena.tsx new file mode 100644 index 0000000..f504869 --- /dev/null +++ b/src/components/frontend/minigames/poker/PokerArena.tsx @@ -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(initialTableId ? "table" : "lobby"); + const [snapshot, setSnapshot] = useState(null); + const [activeTableId, setActiveTableId] = useState(initialTableId ?? null); + const [tableError, setTableError] = useState(null); + const [loading, setLoading] = useState(Boolean(initialTableId)); + const [terminated, setTerminated] = useState(null); + const [leaderboardRefreshKey, setLeaderboardRefreshKey] = useState(0); + const [lobbyRefreshKey, setLobbyRefreshKey] = useState(0); + const lastPhaseRef = useRef(null); + const syncedTableIdRef = useRef(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 ( +
+ + +
+
+ Your poker record +

+ Results are recorded when a sit-and-go ends. +

+
+ +
+
+ + {STAT_ITEMS.map(([label, key]) => ( +
+ + {label} + + + {initialStats[key].toLocaleString()} + +
+ ))} +
+
+ + {tableError && snapshot && ( +

+ {tableError} +

+ )} + +
+
+ {view === "lobby" ? ( + + ) : terminated === activeTableId ? ( + + + + Game ended by an administrator + + + +

+ An administrator forcefully ended this poker table. +

+ +
+
+ ) : ( + + )} +
+ +
+
+ ); +} diff --git a/src/components/frontend/minigames/poker/PokerClock.tsx b/src/components/frontend/minigames/poker/PokerClock.tsx new file mode 100644 index 0000000..4ce36d4 --- /dev/null +++ b/src/components/frontend/minigames/poker/PokerClock.tsx @@ -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 ( + + {label} {seconds}s + + ); + } + + const percentage = totalMs > 0 ? Math.min(100, (remainingMs / totalMs) * 100) : 0; + return ( +
+
+ {label} + {seconds}s +
+ + ); +} diff --git a/src/components/frontend/minigames/poker/PokerGameTable.tsx b/src/components/frontend/minigames/poker/PokerGameTable.tsx new file mode 100644 index 0000000..dd35b3a --- /dev/null +++ b/src/components/frontend/minigames/poker/PokerGameTable.tsx @@ -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; +}; + +interface PokerGameTableProps { + readonly snapshot: SeatedSnapshot; + readonly onSnapshot: (snapshot: PokerSnapshot) => void; + readonly onStart: () => Promise; + readonly onLeave: () => Promise; +} + +interface FeltCanvasProps { + readonly snapshot: SeatedSnapshot; + readonly onSnapshot: (snapshot: PokerSnapshot) => void; + readonly handLabel: string | null; + readonly bestCardKeys: ReadonlySet; + 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 = { + 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 ( + + + Final placements + + +
    + {placements.map((placement) => { + const player = view.players.find((item) => item.seatId === placement.seatId); + return ( +
  1. + {placement.placement}. + + {player?.name ?? `Seat ${placement.seatId + 1}`} + + + {player?.stack.toLocaleString() ?? "0"} + +
  2. + ); + })} +
+
+
+ ); +} + +function TableActionButtons({ + view, + hostSeatId, + starting, + leaving, + onStart, + onLeave, +}: TableActionButtonsProps) { + return ( + <> + {view.phase === "game-complete" && view.viewerSeat === hostSeatId && ( + + )} + + + ); +} + +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 ( +
+ {view.players.map((player: PublicPlayerView) => { + const isViewer = player.seatId === view.viewerSeat; + return ( +
+ +
+ {player.seatId === view.buttonSeat && ( + + D + + )} + {player.seatId === view.smallBlindSeat && SB} + {player.seatId === view.bigBlindSeat && BB} +
+
+ ); + })} + +
+ {view.phase === "betting" && ( + + )} +
+ {view.communityCards.length > 0 ? ( + view.communityCards.map((card, index) => { + const isBestCard = bestCardKeys.has(cardKey(card)); + return ( + + ); + }) + ) : ( + + Community cards + + )} +
+
+ Pot + + {view.potTotal.toLocaleString()} + +
+ + {(view.phase === "showdown" || view.phase === "hand-complete") && ( +
+

+ {view.phase === "showdown" ? "Showdown" : "Hand complete"} +

+

+ {result + ? `Your result: ${signedAmount(result.netChange)} chips` + : "The hand is settled."} +

+ {snapshot.nextHandAt && ( + + )} +
+ )} +
+ + {view.phase !== "game-complete" && ( +
+ +
+ )} + + {view.phase === "game-complete" && ( +
+ +
+ )} +
+ ); +} + +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 = ( + + ); + + if (fullscreen) { + if (!mounted) return null; + + return createPortal( +
+
+
+ {tableName} + {PHASE_LABELS[view.phase]} +
+
+ {view.phase === "game-complete" && view.viewerSeat === snapshot.hostSeatId && ( + + )} + + +
+
+ {feltCanvas} +
, + document.body, + ); + } + + return ( + + +
+
+ {tableName} + + {PHASE_LABELS[view.phase]} +
+

+ Hand {view.handNumber} · Blinds {view.config.smallBlind}/{view.config.bigBlind} +

+
+
+ + v{snapshot.version} + + +
+
+ {feltCanvas} + + void start()} + onLeave={() => void leave()} + /> + +
+ ); +} diff --git a/src/components/frontend/minigames/poker/PokerGuide.tsx b/src/components/frontend/minigames/poker/PokerGuide.tsx new file mode 100644 index 0000000..b9b6306 --- /dev/null +++ b/src/components/frontend/minigames/poker/PokerGuide.tsx @@ -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 ( + + + + + + + How to play poker + + Build the best five-card hand from your hole cards and the community cards. + + + +
+

Hand rankings

+
    + {RANKINGS.map((ranking, index) => ( +
  1. + + {index + 1} + +
    +
    + {ranking.name} +
    + {ranking.example.map((exampleCard, cardIndex) => ( + + ))} +
    +
    +

    {ranking.description}

    +
    +
  2. + ))} +
+
+ +
+

Betting basics

+
    +
  • + Blinds: small and big blinds seed + every hand. +
  • +
  • + Check: pass when no call is due. +
  • +
  • + Call: match the current bet. +
  • +
  • + Bet: open the betting on a + street. +
  • +
  • + Raise: increase an existing bet. +
  • +
  • + Fold: give up the hand. +
  • +
  • + Min-raise: meet the server's + minimum raise. +
  • +
  • + All-in: commit your remaining + stack. +
  • +
+
+
+
+ ); +} diff --git a/src/components/frontend/minigames/poker/PokerLeaderboard.tsx b/src/components/frontend/minigames/poker/PokerLeaderboard.tsx new file mode 100644 index 0000000..a213159 --- /dev/null +++ b/src/components/frontend/minigames/poker/PokerLeaderboard.tsx @@ -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 { + 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(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 ( + + +
+ + Poker leaderboard +
+ Top 10 by best chip total +
+ +
+ {activePlayers.length}{" "} + {activePlayers.length === 1 ? "player is" : "players are"} at the tables + {activePlayers.length > 0 && ( + + ({activePlayers.map((player) => player.name).join(", ")}) + + )} +
+ + {failed ? ( +

Could not load the leaderboard.

+ ) : rows === null ? ( +
+ + + +
+ ) : rows.length === 0 ? ( +

+ No completed games yet. Win the first table. +

+ ) : ( + + + + # + Player + Wins + Games + Best chips + XP + + + + {rows.map((row, index) => ( + + + + {row.isOutsideTopTen ? "-" : index + 1} + + + + + {row.name} + {row.isCurrentUser && ( + (you) + )} + {row.userId !== null && + activePlayers.some((player) => player.id === row.userId) && ( + + )} + + + + {row.wins} + + + {row.gamesPlayed} + + + {row.bestScore.toLocaleString()} + + + {row.xpEarned} + + + ))} + +
+ )} +
+
+ ); +} diff --git a/src/components/frontend/minigames/poker/PokerLobby.tsx b/src/components/frontend/minigames/poker/PokerLobby.tsx new file mode 100644 index 0000000..7a071c1 --- /dev/null +++ b/src/components/frontend/minigames/poker/PokerLobby.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [pendingTable, setPendingTable] = useState(null); + const [forceEndingTable, setForceEndingTable] = useState(null); + const [creating, setCreating] = useState(false); + const [copiedTable, setCopiedTable] = useState(null); + const [error, setError] = useState(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 ( + + +
+ Poker lobby + Join an open table or create a room for your squad. +
+
+ + +
+
+ + {error && ( +

{error}

+ )} + {loading && tables.length === 0 ? ( +

+ Scanning for open tables... +

+ ) : tables.length === 0 ? ( +
+ +
+

No active tables

+

+ Create the first room and invite your unit. +

+
+ +
+ ) : ( + + + + Table + Players + Status + Actions + + + + {tables.map((table) => { + const full = table.seatedCount >= table.maxSeats; + const joining = pendingTable === table.tableId; + return ( + + +
+ + {table.name ?? table.tableId} + + + Host {table.hostName} · {createdLabel(table.createdAt)} + +
+
+ + {table.seatedCount}/{table.maxSeats} + + + + {table.inGame ? "In progress" : full ? "Full" : "Open"} + + + +
+ + {isSuperuser && ( + + + + + + + Force-end this table? + + Seated players will be notified. + + + + Cancel + void forceEnd(table.tableId)} + > + Force-end table + + + + + )} + +
+ {copiedTable === table.tableId && ( +

Link copied

+ )} +
+
+ ); + })} +
+
+ )} +
+
+ ); +} diff --git a/src/components/frontend/minigames/poker/PokerSeat.tsx b/src/components/frontend/minigames/poker/PokerSeat.tsx new file mode 100644 index 0000000..56f8875 --- /dev/null +++ b/src/components/frontend/minigames/poker/PokerSeat.tsx @@ -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; + 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 ( +
+
+
+

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

+

+ Stack {player.stack.toLocaleString()} +

+
+
+ {player.allIn && All-in} + {player.folded && Folded} + {isWinner && Winner} +
+
+ + {handLabel && ( + + You have: {handLabel} + + )} + +
+
+ {faceCards.map((card, index) => ( + + ))} +
+
+

+ {player.committed > 0 ? `Bet ${player.committed.toLocaleString()}` : "No bet"} +

+ {isActing &&

Acting

} +
+
+
+ ); +} diff --git a/src/components/frontend/minigames/poker/PokerTable.tsx b/src/components/frontend/minigames/poker/PokerTable.tsx new file mode 100644 index 0000000..d66c146 --- /dev/null +++ b/src/components/frontend/minigames/poker/PokerTable.tsx @@ -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; + readonly onStart: () => Promise; + readonly onLeave: () => Promise; + readonly onBack: () => void; +} + +type SeatedSnapshot = PokerSnapshot & { + readonly you: NonNullable; +}; + +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; + readonly onStart: () => Promise; + readonly onLeave: () => Promise; + 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 ( + + +
+
+ + {snapshot.name ?? `Table ${snapshot.tableId}`} + + {isHost && ( + + )} +
+

+ {seated + ? "You have a seat reserved." + : "Join an open seat to receive a private hand view."} +

+
+ Waiting room +
+ +
+ {snapshot.seats.map((userId, seatId) => ( +
+ Seat {seatId + 1} + + {userId === null + ? "Open seat" + : String(userId) === String(currentUserId) + ? "You" + : "Occupied"} + +
+ ))} +
+ + Polaris Hold'em + + + {seated ? "Waiting for the host to start" : "Open seating"} + +
+
+
+ + {seated ? ( + <> + {isHost && ( + + )} + + + ) : ( + <> + + + + )} + +
+ ); +} + +export function PokerTable({ + snapshot, + currentUserId, + loading, + tableError, + onSnapshot, + onJoin, + onStart, + onLeave, + onBack, +}: PokerTableProps) { + if (loading && !snapshot) { + return ( + + + + + + + + + + ); + } + + if (!snapshot) { + return ( + + + Table unavailable + + +

+ {tableError ?? "This table could not be loaded."} +

+
+ + + +
+ ); + } + + if (!isSeatedSnapshot(snapshot)) { + return ( + + ); + } + + return ( + + ); +} diff --git a/src/components/frontend/minigames/poker/RenameDialogGuard.tsx b/src/components/frontend/minigames/poker/RenameDialogGuard.tsx new file mode 100644 index 0000000..11aeb18 --- /dev/null +++ b/src/components/frontend/minigames/poker/RenameDialogGuard.tsx @@ -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(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 ( + { + if (next) setValue(currentName ?? ""); + setOpen(next); + }} + > + + + + + + Name this table + + The name shows in the lobby and at the table. Share links stay the same. + + + 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 &&

{error}

} + + + + +
+
+ ); +} diff --git a/src/components/frontend/minigames/poker/RenameTableDialog.tsx b/src/components/frontend/minigames/poker/RenameTableDialog.tsx new file mode 100644 index 0000000..0deebe2 --- /dev/null +++ b/src/components/frontend/minigames/poker/RenameTableDialog.tsx @@ -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(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 ( + { + if (next) setValue(currentName ?? ""); + setOpen(next); + }} + > + + + + + + Name this table + + The name shows in the lobby and at the table. Share links stay the same. + + + 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 &&

{error}

} + + + + +
+
+ ); +} diff --git a/src/components/frontend/minigames/poker/events.ts b/src/components/frontend/minigames/poker/events.ts new file mode 100644 index 0000000..7055766 --- /dev/null +++ b/src/components/frontend/minigames/poker/events.ts @@ -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 { + 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", + }; +} diff --git a/src/components/frontend/minigames/poker/seatPosition.ts b/src/components/frontend/minigames/poker/seatPosition.ts new file mode 100644 index 0000000..cb0e0e0 --- /dev/null +++ b/src/components/frontend/minigames/poker/seatPosition.ts @@ -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]; +} diff --git a/src/components/frontend/minigames/poker/types.ts b/src/components/frontend/minigames/poker/types.ts new file mode 100644 index 0000000..5a0a42b --- /dev/null +++ b/src/components/frontend/minigames/poker/types.ts @@ -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; +} diff --git a/src/components/frontend/realtime/GameTickRealtime.tsx b/src/components/frontend/realtime/GameTickRealtime.tsx index 2fe8a77..93554f7 100644 --- a/src/components/frontend/realtime/GameTickRealtime.tsx +++ b/src/components/frontend/realtime/GameTickRealtime.tsx @@ -6,8 +6,14 @@ import { GAME_EVENT_EVENT, GAME_TICK_EVENT, NOTIFICATION_EVENT, + POKER_STATE_EVENT, + POKER_TABLES_EVENT, + POKER_TERMINATED_EVENT, PROFILE_UPDATE_EVENT, type GameTickDetail, + type PokerStateDetail, + type PokerTablesDetail, + type PokerTerminatedDetail, type ProfileUpdateDetail, } from "@/hooks/useGameTick"; @@ -38,9 +44,7 @@ export function GameTickRealtime() { // ignore malformed payload } refresh(); - window.dispatchEvent( - new CustomEvent(GAME_TICK_EVENT, { detail }), - ); + window.dispatchEvent(new CustomEvent(GAME_TICK_EVENT, { detail })); }); source.addEventListener("profile-update", (event) => { let detail: ProfileUpdateDetail = {}; @@ -59,6 +63,33 @@ export function GameTickRealtime() { source.addEventListener("notification", (event) => { 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 () => { if (refreshTimer) clearTimeout(refreshTimer); source.close(); diff --git a/src/hooks/useGameTick.ts b/src/hooks/useGameTick.ts index 10a50fe..f5f931c 100644 --- a/src/hooks/useGameTick.ts +++ b/src/hooks/useGameTick.ts @@ -1,12 +1,16 @@ "use client"; 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 PROFILE_UPDATE_EVENT = "ptf:profile-update"; export const GAME_EVENT_EVENT = "ptf:game-event"; export const NOTIFICATION_EVENT = "ptf:notification"; 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 { processedAt?: string; @@ -17,6 +21,20 @@ export interface ProfileUpdateDetail { 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 { const [count, setCount] = useState(0); const callbackRef = useRef(onTick);