337 lines
11 KiB
TypeScript
337 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import {
|
|
fetchBlackjackTable,
|
|
joinBlackjackTable,
|
|
leaveBlackjackTable,
|
|
spectateBlackjackTable,
|
|
startBlackjackGame,
|
|
type BlackjackSnapshot,
|
|
} from "@/app/(frontend)/minigames/blackjack/actions";
|
|
import type { BlackjackPhase } from "@/lib/minigames/blackjack";
|
|
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { ChipBankCard } from "@/components/frontend/minigames/ChipBankCard";
|
|
import { BlackjackGuide } from "./BlackjackGuide";
|
|
import {
|
|
BLACKJACK_STATE_EVENT,
|
|
BLACKJACK_TABLES_EVENT,
|
|
BLACKJACK_TERMINATED_EVENT,
|
|
readBlackjackStateEvent,
|
|
readBlackjackTerminatedEvent,
|
|
} from "./events";
|
|
import { BlackjackLeaderboard } from "./BlackjackLeaderboard";
|
|
import { BlackjackLobby } from "./BlackjackLobby";
|
|
import { BlackjackTable } from "./BlackjackTable";
|
|
import type { BlackjackStats } from "./types";
|
|
|
|
interface BlackjackArenaProps {
|
|
readonly initialStats: BlackjackStats;
|
|
readonly currentUserId: number | string;
|
|
readonly initialTableId?: string;
|
|
readonly isSuperuser: boolean;
|
|
readonly superuserIds: readonly number[];
|
|
readonly initialChipBank: number;
|
|
readonly initialChipTopUpAt: string | null;
|
|
}
|
|
|
|
type ArenaView = "lobby" | "table";
|
|
|
|
const STAT_ITEMS = [
|
|
["Sessions", "gamesPlayed"],
|
|
["Rounds won", "roundsWon"],
|
|
["Blackjacks", "blackjacksHit"],
|
|
["Best bankroll", "bestScore"],
|
|
["XP earned", "xpEarned"],
|
|
] as const satisfies readonly (readonly [string, keyof BlackjackStats])[];
|
|
|
|
export function BlackjackArena({
|
|
initialStats,
|
|
currentUserId,
|
|
initialTableId,
|
|
isSuperuser,
|
|
superuserIds,
|
|
initialChipBank,
|
|
initialChipTopUpAt,
|
|
}: BlackjackArenaProps) {
|
|
const [view, setView] = useState<ArenaView>(initialTableId ? "table" : "lobby");
|
|
const [snapshot, setSnapshot] = useState<BlackjackSnapshot | null>(null);
|
|
const [activeTableId, setActiveTableId] = useState<string | null>(initialTableId ?? null);
|
|
const [tableError, setTableError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(Boolean(initialTableId));
|
|
const [leaderboardRefreshKey, setLeaderboardRefreshKey] = useState(0);
|
|
const [lobbyRefreshKey, setLobbyRefreshKey] = useState(0);
|
|
const lastRoundRef = useRef<number | null>(null);
|
|
const syncedTableIdRef = useRef<string | null>(null);
|
|
|
|
const applySnapshot = useCallback((next: BlackjackSnapshot) => {
|
|
setSnapshot((current) => {
|
|
if (current && next.version <= current.version) return current;
|
|
return next;
|
|
});
|
|
setActiveTableId(next.tableId);
|
|
if (syncedTableIdRef.current !== next.tableId) {
|
|
syncedTableIdRef.current = next.tableId;
|
|
window.history.replaceState(
|
|
null,
|
|
"",
|
|
`/minigames/blackjack?table=${encodeURIComponent(next.tableId)}`,
|
|
);
|
|
}
|
|
setView("table");
|
|
setTableError(null);
|
|
setLoading(false);
|
|
|
|
const nextRound = next.you?.roundNumber ?? null;
|
|
if (nextRound !== null && lastRoundRef.current !== null && nextRound !== lastRoundRef.current) {
|
|
setLeaderboardRefreshKey((key) => key + 1);
|
|
}
|
|
lastRoundRef.current = nextRound;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!initialTableId) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
void fetchBlackjackTable(initialTableId)
|
|
.then((result) => {
|
|
if (cancelled) return;
|
|
if (result.success && result.data) {
|
|
applySnapshot(result.data);
|
|
} else {
|
|
setTableError(result.error ?? "This blackjack table was not found.");
|
|
setLoading(false);
|
|
}
|
|
})
|
|
.catch((caught: unknown) => {
|
|
if (cancelled) return;
|
|
setTableError(
|
|
caught instanceof Error ? caught.message : "This blackjack table could not be loaded.",
|
|
);
|
|
setLoading(false);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [applySnapshot, initialTableId]);
|
|
|
|
useEffect(() => {
|
|
const handleState = (event: Event) => {
|
|
const detail = readBlackjackStateEvent(event);
|
|
if (!detail || detail.tableId !== activeTableId) return;
|
|
applySnapshot(detail.snapshot);
|
|
};
|
|
const handleTables = () => setLobbyRefreshKey((key) => key + 1);
|
|
const handleTerminated = (event: Event) => {
|
|
const detail = readBlackjackTerminatedEvent(event);
|
|
if (!detail || detail.tableId !== activeTableId) return;
|
|
toast.info("An administrator ended your blackjack game.");
|
|
resetToLobby();
|
|
};
|
|
window.addEventListener(BLACKJACK_STATE_EVENT, handleState);
|
|
window.addEventListener(BLACKJACK_TABLES_EVENT, handleTables);
|
|
window.addEventListener(BLACKJACK_TERMINATED_EVENT, handleTerminated);
|
|
return () => {
|
|
window.removeEventListener(BLACKJACK_STATE_EVENT, handleState);
|
|
window.removeEventListener(BLACKJACK_TABLES_EVENT, handleTables);
|
|
window.removeEventListener(BLACKJACK_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) return;
|
|
let cancelled = false;
|
|
const poll = window.setInterval(() => {
|
|
void fetchBlackjackTable(activeTableId)
|
|
.then((result) => {
|
|
if (cancelled) return;
|
|
if (result.success && result.data) {
|
|
applySnapshot(result.data);
|
|
} else if (result.error === "Table not found.") {
|
|
toast.info("An administrator ended your blackjack game.");
|
|
resetToLobby();
|
|
}
|
|
})
|
|
.catch(() => undefined);
|
|
}, 1250);
|
|
return () => {
|
|
cancelled = true;
|
|
window.clearInterval(poll);
|
|
};
|
|
}, [activeTableId, view, applySnapshot]);
|
|
|
|
const resetToLobby = useCallback(() => {
|
|
setView("lobby");
|
|
setSnapshot(null);
|
|
setActiveTableId(null);
|
|
setTableError(null);
|
|
setLoading(false);
|
|
lastRoundRef.current = null;
|
|
if (syncedTableIdRef.current !== null) {
|
|
syncedTableIdRef.current = null;
|
|
window.history.replaceState(null, "", "/minigames/blackjack");
|
|
}
|
|
}, []);
|
|
|
|
const leaveTable = useCallback(async () => {
|
|
const seated = snapshot?.seats.some((id) => String(id) === String(currentUserId)) ?? false;
|
|
if (activeTableId && seated) {
|
|
try {
|
|
window.dispatchEvent(new Event("chipBank:showSpinner"));
|
|
const result = await leaveBlackjackTable(activeTableId);
|
|
if (!result.success) {
|
|
setTableError(result.error ?? "Could not leave the blackjack table.");
|
|
return;
|
|
}
|
|
} catch (caught) {
|
|
setTableError(
|
|
caught instanceof Error ? caught.message : "Could not leave the blackjack table.",
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
resetToLobby();
|
|
}, [activeTableId, currentUserId, resetToLobby, snapshot]);
|
|
|
|
const joinCurrentTable = useCallback(async () => {
|
|
if (!activeTableId) return;
|
|
setLoading(true);
|
|
setTableError(null);
|
|
try {
|
|
window.dispatchEvent(new Event("chipBank:showSpinner"));
|
|
const result = await joinBlackjackTable(activeTableId);
|
|
if (result.success && result.data) {
|
|
applySnapshot(result.data);
|
|
} else {
|
|
setTableError(result.error ?? "Could not join the blackjack table.");
|
|
setLoading(false);
|
|
}
|
|
} catch (caught) {
|
|
setTableError(
|
|
caught instanceof Error ? caught.message : "Could not join the blackjack table.",
|
|
);
|
|
setLoading(false);
|
|
}
|
|
}, [activeTableId, applySnapshot]);
|
|
|
|
const spectateCurrentTable = useCallback(async () => {
|
|
if (!activeTableId) return;
|
|
setLoading(true);
|
|
setTableError(null);
|
|
try {
|
|
const result = await spectateBlackjackTable(activeTableId);
|
|
if (result.success && result.data) {
|
|
applySnapshot(result.data);
|
|
} else {
|
|
setTableError(result.error ?? "Could not spectate this table.");
|
|
setLoading(false);
|
|
}
|
|
} catch (caught: unknown) {
|
|
setTableError(caught instanceof Error ? caught.message : "Could not spectate this table.");
|
|
setLoading(false);
|
|
}
|
|
}, [activeTableId, applySnapshot]);
|
|
|
|
const startCurrentTable = useCallback(async () => {
|
|
if (!activeTableId) return;
|
|
try {
|
|
window.dispatchEvent(new Event("chipBank:showSpinner"));
|
|
const result = await startBlackjackGame(activeTableId);
|
|
if (result.success && result.data) {
|
|
applySnapshot(result.data);
|
|
} else {
|
|
setTableError(result.error ?? "Could not start the blackjack game.");
|
|
}
|
|
} catch (caught) {
|
|
setTableError(
|
|
caught instanceof Error ? caught.message : "Could not start the blackjack game.",
|
|
);
|
|
}
|
|
}, [activeTableId, applySnapshot]);
|
|
|
|
const activePlayers = useRealtimePresence(
|
|
"blackjack",
|
|
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 blackjack record</CardTitle>
|
|
<p className="text-sm text-muted-foreground">
|
|
Results are recorded when a session ends.
|
|
</p>
|
|
</div>
|
|
<BlackjackGuide />
|
|
</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" ? (
|
|
<BlackjackLobby
|
|
refreshKey={lobbyRefreshKey}
|
|
onOpenTable={applySnapshot}
|
|
isSuperuser={isSuperuser}
|
|
/>
|
|
) : (
|
|
<BlackjackTable
|
|
snapshot={snapshot}
|
|
currentUserId={currentUserId}
|
|
loading={loading}
|
|
tableError={tableError}
|
|
onSnapshot={applySnapshot}
|
|
onJoin={joinCurrentTable}
|
|
onSpectate={spectateCurrentTable}
|
|
onStart={startCurrentTable}
|
|
onLeave={leaveTable}
|
|
onBack={resetToLobby}
|
|
isSuperuser={isSuperuser}
|
|
superuserIds={superuserIds}
|
|
/>
|
|
)}
|
|
</section>
|
|
<BlackjackLeaderboard
|
|
refreshKey={leaderboardRefreshKey}
|
|
currentUserId={currentUserId}
|
|
activePlayers={activePlayers}
|
|
/>
|
|
</div>
|
|
|
|
<ChipBankCard initialChipBank={initialChipBank} initialChipTopUpAt={initialChipTopUpAt} />
|
|
</div>
|
|
);
|
|
}
|