1
0
Fork 0
polaris-task-force/src/components/frontend/minigames/poker/PokerArena.tsx
2026-09-11 03:25:43 -04:00

312 lines
10 KiB
TypeScript

"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>
);
}