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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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,
|
||||
Bomb,
|
||||
ScrollText,
|
||||
Spade,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface NavigationCommand {
|
||||
|
|
@ -39,8 +40,22 @@ export interface NavigationCommand {
|
|||
}
|
||||
|
||||
export const navigationCommands: NavigationCommand[] = [
|
||||
{ id: "nav:dashboard", label: "Dashboard", icon: Bolt, href: "/", keywords: ["home", "main"], shortcut: "G D" },
|
||||
{ id: "nav:roster", label: "Roster", icon: Users, href: "/roster", keywords: ["members", "personnel"], shortcut: "G R" },
|
||||
{
|
||||
id: "nav:dashboard",
|
||||
label: "Dashboard",
|
||||
icon: Bolt,
|
||||
href: "/",
|
||||
keywords: ["home", "main"],
|
||||
shortcut: "G D",
|
||||
},
|
||||
{
|
||||
id: "nav:roster",
|
||||
label: "Roster",
|
||||
icon: Users,
|
||||
href: "/roster",
|
||||
keywords: ["members", "personnel"],
|
||||
shortcut: "G R",
|
||||
},
|
||||
{
|
||||
id: "nav:transfers",
|
||||
label: "Transfers",
|
||||
|
|
@ -255,6 +270,13 @@ export const navigationCommands: NavigationCommand[] = [
|
|||
href: "/minigames/opord",
|
||||
keywords: ["minigame", "briefing", "memory", "operations order", "training"],
|
||||
},
|
||||
{
|
||||
id: "nav:poker",
|
||||
label: "Poker",
|
||||
icon: Spade,
|
||||
href: "/minigames/poker",
|
||||
keywords: ["minigame", "poker", "cards", "holdem", "training"],
|
||||
},
|
||||
{
|
||||
id: "nav:account",
|
||||
label: "Account",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
LogOut,
|
||||
Mic,
|
||||
Radio,
|
||||
Spade,
|
||||
Users2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
|
@ -173,6 +174,12 @@ export function NavUser({
|
|||
OPORD Recall
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/minigames/poker">
|
||||
<Spade />
|
||||
Poker
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</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_TICK_EVENT,
|
||||
NOTIFICATION_EVENT,
|
||||
POKER_STATE_EVENT,
|
||||
POKER_TABLES_EVENT,
|
||||
POKER_TERMINATED_EVENT,
|
||||
PROFILE_UPDATE_EVENT,
|
||||
type GameTickDetail,
|
||||
type PokerStateDetail,
|
||||
type PokerTablesDetail,
|
||||
type PokerTerminatedDetail,
|
||||
type ProfileUpdateDetail,
|
||||
} from "@/hooks/useGameTick";
|
||||
|
||||
|
|
@ -38,9 +44,7 @@ export function GameTickRealtime() {
|
|||
// ignore malformed payload
|
||||
}
|
||||
refresh();
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(GAME_TICK_EVENT, { detail }),
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent(GAME_TICK_EVENT, { detail }));
|
||||
});
|
||||
source.addEventListener("profile-update", (event) => {
|
||||
let detail: ProfileUpdateDetail = {};
|
||||
|
|
@ -59,6 +63,33 @@ export function GameTickRealtime() {
|
|||
source.addEventListener("notification", (event) => {
|
||||
window.dispatchEvent(new CustomEvent(NOTIFICATION_EVENT, { detail: event.data }));
|
||||
});
|
||||
source.addEventListener("poker-state", (event) => {
|
||||
let detail: PokerStateDetail = {};
|
||||
try {
|
||||
detail = JSON.parse(event.data ?? "{}");
|
||||
} catch {
|
||||
// ignore malformed payload
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent(POKER_STATE_EVENT, { detail }));
|
||||
});
|
||||
source.addEventListener("poker-tables", (event) => {
|
||||
let detail: PokerTablesDetail = {};
|
||||
try {
|
||||
detail = JSON.parse(event.data ?? "{}");
|
||||
} catch {
|
||||
// ignore malformed payload
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent(POKER_TABLES_EVENT, { detail }));
|
||||
});
|
||||
source.addEventListener("poker-terminated", (event) => {
|
||||
let detail: PokerTerminatedDetail = {};
|
||||
try {
|
||||
detail = JSON.parse(event.data ?? "{}");
|
||||
} catch {
|
||||
// ignore malformed payload
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent(POKER_TERMINATED_EVENT, { detail }));
|
||||
});
|
||||
return () => {
|
||||
if (refreshTimer) clearTimeout(refreshTimer);
|
||||
source.close();
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { PokerSnapshot, PokerTableSummary } from "@/app/(frontend)/minigames/poker/actions";
|
||||
|
||||
export const GAME_TICK_EVENT = "ptf:game-tick";
|
||||
export const PROFILE_UPDATE_EVENT = "ptf:profile-update";
|
||||
export const GAME_EVENT_EVENT = "ptf:game-event";
|
||||
export const NOTIFICATION_EVENT = "ptf:notification";
|
||||
export const SESSION_EXPIRED_EVENT = "ptf:session-expired";
|
||||
export const POKER_STATE_EVENT = "ptf:poker-state";
|
||||
export const POKER_TABLES_EVENT = "ptf:poker-tables";
|
||||
export const POKER_TERMINATED_EVENT = "ptf:poker-terminated";
|
||||
|
||||
export interface GameTickDetail {
|
||||
processedAt?: string;
|
||||
|
|
@ -17,6 +21,20 @@ export interface ProfileUpdateDetail {
|
|||
xpGained?: number;
|
||||
}
|
||||
|
||||
export interface PokerStateDetail {
|
||||
tableId?: string;
|
||||
snapshot?: PokerSnapshot;
|
||||
}
|
||||
|
||||
export interface PokerTablesDetail {
|
||||
tables?: PokerTableSummary[];
|
||||
}
|
||||
|
||||
export interface PokerTerminatedDetail {
|
||||
tableId?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function useGameTick(onTick?: () => void): number {
|
||||
const [count, setCount] = useState(0);
|
||||
const callbackRef = useRef(onTick);
|
||||
|
|
|
|||
Loading…
Reference in a new issue