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:
parent
6968ee0abc
commit
1fb0165c12
22 changed files with 2902 additions and 5 deletions
170
src/app/(frontend)/minigames/poker/actions.ts
Normal file
170
src/app/(frontend)/minigames/poker/actions.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/app/(frontend)/minigames/poker/page.tsx
Normal file
72
src/app/(frontend)/minigames/poker/page.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
193
src/components/frontend/minigames/poker/BettingControls.tsx
Normal file
193
src/components/frontend/minigames/poker/BettingControls.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
src/components/frontend/minigames/poker/CardView.tsx
Normal file
104
src/components/frontend/minigames/poker/CardView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
312
src/components/frontend/minigames/poker/PokerArena.tsx
Normal file
312
src/components/frontend/minigames/poker/PokerArena.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
src/components/frontend/minigames/poker/PokerClock.tsx
Normal file
61
src/components/frontend/minigames/poker/PokerClock.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
388
src/components/frontend/minigames/poker/PokerGameTable.tsx
Normal file
388
src/components/frontend/minigames/poker/PokerGameTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
src/components/frontend/minigames/poker/PokerGuide.tsx
Normal file
161
src/components/frontend/minigames/poker/PokerGuide.tsx
Normal 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's
|
||||||
|
minimum raise.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-medium text-foreground">All-in:</span> commit your remaining
|
||||||
|
stack.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
237
src/components/frontend/minigames/poker/PokerLeaderboard.tsx
Normal file
237
src/components/frontend/minigames/poker/PokerLeaderboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
319
src/components/frontend/minigames/poker/PokerLobby.tsx
Normal file
319
src/components/frontend/minigames/poker/PokerLobby.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
src/components/frontend/minigames/poker/PokerSeat.tsx
Normal file
102
src/components/frontend/minigames/poker/PokerSeat.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
src/components/frontend/minigames/poker/PokerTable.tsx
Normal file
219
src/components/frontend/minigames/poker/PokerTable.tsx
Normal 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'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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
src/components/frontend/minigames/poker/RenameDialogGuard.tsx
Normal file
104
src/components/frontend/minigames/poker/RenameDialogGuard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/components/frontend/minigames/poker/events.ts
Normal file
76
src/components/frontend/minigames/poker/events.ts
Normal 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
10
src/components/frontend/minigames/poker/seatPosition.ts
Normal file
10
src/components/frontend/minigames/poker/seatPosition.ts
Normal 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];
|
||||||
|
}
|
||||||
10
src/components/frontend/minigames/poker/types.ts
Normal file
10
src/components/frontend/minigames/poker/types.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue