1
0
Fork 0

feat(blackjack): add blackjack minigame with realtime table wiring

Engine, felt, controls, sounds, card skins, and dev popups; SSE events in the realtime hook, nav and command palette entries, stats migration, and the six blackjack test suites.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-14 15:38:05 -04:00
parent 34dcefec54
commit 474aff734d
53 changed files with 35516 additions and 0 deletions

View file

@ -0,0 +1,182 @@
"use server";
/**
* Server-action surface for the blackjack minigame.
* All table logic lives in the registry (src/lib/minigames/blackjack/tables.ts).
*/
import config from "@payload-config";
import { getPayload } from "payload";
import type { BlackjackActionKind } from "@/lib/minigames/blackjack/types";
import { isSuperuser } from "@/utils/access-control/hasPermission";
import {
applyBlackjackAction,
createTable,
forceExpireTable,
getSnapshotFor,
joinTableWithBuyIn,
leaveTableWithBuyIn,
listSummaries,
renameTable,
spectateTable,
startGameWithBuyIn,
type BlackjackSnapshot,
type BlackjackTableSummary,
} from "@/lib/minigames/blackjack/tables";
export type { BlackjackSnapshot, BlackjackTableSummary };
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 createBlackjackTable(): 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 listBlackjackTables(): Promise<ActionResult<BlackjackTableSummary[]>> {
try {
await authenticate();
return { success: true, data: listSummaries() };
} catch (error) {
return toError(error);
}
}
export async function forceExpireBlackjackTable(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 joinBlackjackTable(tableId: string): Promise<ActionResult<BlackjackSnapshot>> {
try {
const { user } = await authenticate();
const snapshot = await joinTableWithBuyIn(tableId, Number(user.id), userName(user));
return { success: true, data: snapshot };
} catch (error) {
return toError(error);
}
}
export async function spectateBlackjackTable(
tableId: string,
): Promise<ActionResult<BlackjackSnapshot>> {
try {
const { user } = await authenticate();
const snapshot = spectateTable(tableId, Number(user.id));
return { success: true, data: snapshot };
} catch (error) {
return toError(error);
}
}
export async function leaveBlackjackTable(tableId: string): Promise<ActionResult<undefined>> {
try {
const { user } = await authenticate();
await leaveTableWithBuyIn(tableId, Number(user.id));
return { success: true };
} catch (error) {
return toError(error);
}
}
export async function startBlackjackGame(tableId: string): Promise<ActionResult<BlackjackSnapshot>> {
try {
const { user } = await authenticate();
const snapshot = await startGameWithBuyIn(tableId, Number(user.id));
return { success: true, data: snapshot };
} catch (error) {
return toError(error);
}
}
export async function renameBlackjackTable(
tableId: string,
rawName: string,
): Promise<ActionResult<BlackjackSnapshot>> {
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 fetchBlackjackTable(tableId: string): Promise<ActionResult<BlackjackSnapshot>> {
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 blackjackAction(
tableId: string,
kind: BlackjackActionKind,
amount?: number,
): Promise<ActionResult<BlackjackSnapshot>> {
try {
const { user } = await authenticate();
const snapshot = applyBlackjackAction(tableId, Number(user.id), kind, amount);
return { success: true, data: snapshot };
} catch (error) {
return toError(error);
}
}

View file

@ -0,0 +1,84 @@
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 { Club } from "lucide-react";
import { BlackjackArena } from "@/components/frontend/minigames/blackjack/BlackjackArena";
import { readBlackjackStats } from "@/lib/minigames/blackjack/stats";
import { isSuperuser, superuserUserIds } from "@/utils/access-control/hasPermission";
export const metadata: Metadata = {
title: "Blackjack: Polaris Task Force",
description: "A multiplayer blackjack table for Polaris Task Force operators, solo or with up to six players.",
};
interface BlackjackPageProps {
searchParams: Promise<{
table?: string | string[];
}>;
}
export default async function BlackjackPage({ searchParams }: BlackjackPageProps) {
const headers = await nextHeaders();
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers, canSetHeaders: false });
if (!user) {
redirect(`/login?returnTo=${encodeURIComponent("/minigames/blackjack")}`);
}
const superuser = await isSuperuser(payload, user);
const superuserIds = await superuserUserIds(payload);
const profileRes = await payload.find({
collection: "profiles",
where: { user: { equals: Number(user.id) } },
limit: 1,
depth: 0,
overrideAccess: true,
select: {
progression: {
minigames: true,
chipBank: true,
chipBankRefilledAt: true,
},
},
});
const stats = readBlackjackStats(profileRes.docs[0]);
const progressionDoc = (profileRes.docs[0]?.progression ?? {}) as {
chipBank?: number | null;
chipBankRefilledAt?: string | null;
};
const initialChipBank = progressionDoc.chipBank ?? 0;
const initialChipTopUpAt = progressionDoc.chipBankRefilledAt ?? null;
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">
<Club className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Blackjack</h1>
</div>
<p className="text-sm text-muted-foreground">
Take a seat at the table, solo or with up to five others. Beat the dealer to 21 without
going bust.
</p>
</div>
<BlackjackArena
initialStats={stats}
currentUserId={Number(user.id)}
initialTableId={initialTableId}
isSuperuser={superuser}
superuserIds={superuserIds}
initialChipBank={initialChipBank}
initialChipTopUpAt={initialChipTopUpAt}
/>
</div>
);
}

View file

@ -26,6 +26,7 @@ import {
Bomb, Bomb,
ScrollText, ScrollText,
Spade, Spade,
Club,
} from "lucide-react"; } from "lucide-react";
export interface NavigationCommand { export interface NavigationCommand {
@ -277,6 +278,13 @@ export const navigationCommands: NavigationCommand[] = [
href: "/minigames/poker", href: "/minigames/poker",
keywords: ["minigame", "poker", "cards", "holdem", "training"], keywords: ["minigame", "poker", "cards", "holdem", "training"],
}, },
{
id: "nav:blackjack",
label: "Blackjack",
icon: Club,
href: "/minigames/blackjack",
keywords: ["minigame", "blackjack", "21", "cards", "training"],
},
{ {
id: "nav:account", id: "nav:account",
label: "Account", label: "Account",

View file

@ -12,6 +12,7 @@ import {
Mic, Mic,
Radio, Radio,
Spade, Spade,
Club,
Users2, Users2,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@ -180,6 +181,12 @@ export function NavUser({
Poker Poker
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/minigames/blackjack">
<Club />
Blackjack
</Link>
</DropdownMenuItem>
</DropdownMenuSubContent> </DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
</DropdownMenuGroup> </DropdownMenuGroup>

View file

@ -0,0 +1,201 @@
"use client";
import { Eye, Shield } from "lucide-react";
import { cn } from "@/lib/utils";
import type { PublicPlayerView } from "@/lib/minigames/blackjack";
import { handValue } from "@/lib/minigames/blackjack";
import type { BlackjackSnapshot } from "@/app/(frontend)/minigames/blackjack/actions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { BlackjackClock } from "./BlackjackClock";
type SeatedSnapshot = BlackjackSnapshot & {
readonly you: NonNullable<BlackjackSnapshot["you"]>;
};
const PHASE_COPY: Record<string, string> = {
betting: "Place your bet",
insurance: "Insurance offered",
player: "Play your hand",
dealer: "Dealer plays",
"round-complete": "Round settled",
};
interface BlackjackActionConsoleProps {
readonly snapshot: SeatedSnapshot;
readonly hostSeatId: number | null;
readonly spectator?: boolean;
readonly onJoin?: () => void;
readonly isSuperuser?: boolean;
readonly starting: boolean;
readonly leaving: boolean;
readonly onStart: () => void;
readonly onLeave: () => void;
}
export function BlackjackActionConsole({
snapshot,
hostSeatId,
spectator = false,
onJoin,
isSuperuser = false,
starting,
leaving,
onStart,
onLeave,
}: BlackjackActionConsoleProps) {
const view = snapshot.you;
const viewerPlayer: PublicPlayerView | undefined = view.players.find(
(player) => player.seatId === view.viewerSeat,
);
const seatsOpen = snapshot.seats.filter((seat) => seat === null).length;
const chipsInPlay = viewerPlayer?.hands.reduce((sum, hand) => sum + hand.bet, 0) ?? 0;
const viewerInitial = (viewerPlayer?.name ?? "You").trim().charAt(0).toUpperCase() || "?";
const yourHandTotals = view.yourHands.map((hand) => handValue(hand.cards).total);
const isHost = view.viewerSeat === hostSeatId;
return (
<section
className={cn(
"poker-action-console",
view.phase === "round-complete" && "poker-action-console-complete",
view.phase !== "round-complete" && !view.yourTurn && "poker-action-console-waiting",
)}
aria-label="Your blackjack action console"
>
<div className="poker-console-player">
{spectator ? (
<>
<span
className="poker-seat-avatar poker-console-avatar poker-seat-avatar-open"
aria-hidden="true"
>
<Eye className="size-4" />
</span>
<div className="poker-console-identity">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">Spectating</p>
<p className="text-xs text-muted-foreground">
{seatsOpen > 0
? `${seatsOpen} seat${seatsOpen > 1 ? "s" : ""} open`
: "No open seats"}
</p>
</div>
</div>
</div>
</>
) : (
<>
<span
className={cn(
"poker-seat-avatar poker-console-avatar",
`poker-seat-avatar-tone-${view.viewerSeat % 4}`,
)}
aria-hidden="true"
>
{viewerInitial}
</span>
<div className="poker-console-identity">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">
{viewerPlayer?.name ?? "You"}
{isSuperuser && (
<Shield
aria-label="Superuser"
className="ml-1 inline size-3.5 text-amber-300"
/>
)}
<span className="ml-1 text-xs text-white/55">(you)</span>
</p>
<p className="font-mono text-xs tabular-nums text-white/65">
Bankroll {viewerPlayer?.bankroll.toLocaleString() ?? "0"}
</p>
</div>
<div className="poker-console-role-row" aria-label="Your table roles">
{isHost && <Badge variant="secondary">Host</Badge>}
{viewerPlayer && viewerPlayer.bankrupt && (
<Badge variant="secondary">Busted</Badge>
)}
</div>
</div>
{yourHandTotals.length > 0 && (
<span className="poker-hand-placard">
You have:{" "}
{yourHandTotals
.map((total, index) =>
yourHandTotals.length > 1 ? `H${index + 1} ${total}` : String(total),
)
.join(" / ")}
</span>
)}
</div>
</>
)}
</div>
<div className="poker-console-economy">
{!spectator && (
<div className="poker-chip-readout">
<span className="poker-chip-stack" aria-hidden="true" />
<span className="poker-console-readout-copy">
<span className="poker-console-kicker">Chips in play</span>
<strong className="font-mono text-sm tabular-nums">
{chipsInPlay.toLocaleString()}
</strong>
</span>
</div>
)}
{spectator && seatsOpen > 0 && onJoin && (
<Button type="button" className="poker-chip poker-chip-call" onClick={onJoin}>
Join the table
</Button>
)}
{view.phase !== "round-complete" && (
<div className="poker-console-timer">
<BlackjackClock
deadline={snapshot.turnDeadline}
totalMs={view.config.turnTimeoutMs}
label={PHASE_COPY[view.phase] ?? "Act"}
/>
</div>
)}
</div>
{view.phase === "round-complete" && (
<div className="poker-console-actions">
<div className="poker-complete-actions">
<div className="poker-complete-copy">
<span className="poker-console-kicker">Round settled</span>
<span className="text-sm text-white/75">
The next betting round starts automatically.
</span>
</div>
<div className="flex flex-wrap justify-end gap-2">
{snapshot.nextRoundAt && (
<BlackjackClock
deadline={snapshot.nextRoundAt}
mode="text"
label="Next round in"
/>
)}
{!spectator && (
<Button
type="button"
variant="outline"
className="poker-chip poker-chip-call"
onClick={onLeave}
disabled={leaving}
>
{leaving ? "Leaving..." : "Leave table"}
</Button>
)}
</div>
</div>
</div>
)}
{starting && <span className="sr-only">Starting</span>}
</section>
);
}

View file

@ -0,0 +1,96 @@
"use client";
import type { BlackjackSnapshot } from "@/app/(frontend)/minigames/blackjack/actions";
import { BlackjackChipStack } from "./BlackjackChipStack";
import { BlackjackControls } from "./BlackjackControls";
type SeatedSnapshot = BlackjackSnapshot & {
readonly you: NonNullable<BlackjackSnapshot["you"]>;
};
interface BlackjackActionToolbarProps {
readonly snapshot: SeatedSnapshot;
readonly onSnapshot: (snapshot: BlackjackSnapshot) => void;
readonly onActionFeedback: () => void;
}
/**
* Minimal felt overlay: one bottom-centered column holding the bankroll
* pill plus either the waiting pill or the acting player's controls.
* Rendering them as a single stack keeps the pair collision-free at every
* phase; on a settled round only the bankroll pill remains.
*/
export function BlackjackActionToolbar({
snapshot,
onSnapshot,
onActionFeedback,
}: BlackjackActionToolbarProps) {
const view = snapshot.you;
const viewerPlayer = view.players.find((player) => player.seatId === view.viewerSeat);
const viewerBet = viewerPlayer?.hands.reduce((sum, hand) => sum + hand.bet, 0) ?? 0;
const settled = view.phase === "round-complete";
const bankrollPill = viewerPlayer && (
<span
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/55 px-4 py-1.5 backdrop-blur-sm"
aria-label="Your bankroll"
>
<BlackjackChipStack amount={viewerPlayer.bankroll} />
<span className="text-[0.65rem] font-medium uppercase tracking-[0.16em] text-white/55">
Bankroll
</span>
<span className="font-mono text-sm font-semibold tabular-nums text-white/90">
{viewerPlayer.bankroll.toLocaleString()}
</span>
{viewerBet > 0 && (
<>
<span className="h-3.5 w-px bg-white/25" aria-hidden="true" />
<span className="text-[0.65rem] font-medium uppercase tracking-[0.16em] text-white/55">
Bet
</span>
<span className="font-mono text-sm tabular-nums text-white/85">
{viewerBet.toLocaleString()}
</span>
</>
)}
</span>
);
if (settled) {
return (
<div className="pointer-events-none absolute inset-x-0 bottom-5 z-30 flex justify-center">
{bankrollPill}
</div>
);
}
return (
<div className="absolute inset-x-0 bottom-5 z-30 flex justify-center">
<div className="flex flex-col items-center gap-2">
{bankrollPill}
{!view.yourTurn ? (
<span
className="rounded-full border border-white/15 bg-black/55 px-4 py-1.5 text-sm text-white/85 backdrop-blur-sm"
role="status"
>
{(() => {
const actingPlayer = view.players.find(
(player) => player.seatId === view.activeSeat,
);
return actingPlayer ? `Waiting for ${actingPlayer.name}` : "Waiting for the table";
})()}
</span>
) : (
<BlackjackControls
tableId={snapshot.tableId}
snapshotVersion={snapshot.version}
legalActions={view.legalActions}
yourTurn={view.yourTurn}
onSnapshot={onSnapshot}
onActionFeedback={onActionFeedback}
/>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,350 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
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 { Button } from "@/components/ui/button";
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 [terminated, setTerminated] = useState<string | null>(null);
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);
setTerminated((current) => (current !== next.tableId ? null : current));
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;
setTerminated(detail.tableId);
};
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 || terminated === activeTableId) return;
let cancelled = false;
const poll = window.setInterval(() => {
void fetchBlackjackTable(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);
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}
/>
) : 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 blackjack table.
</p>
<Button type="button" variant="outline" onClick={resetToLobby}>
Back to lobby
</Button>
</CardContent>
</Card>
) : (
<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>
);
}

View file

@ -0,0 +1,59 @@
"use client";
import { Images, Moon, Sun } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
BLACKJACK_CARD_STYLE_OPTIONS,
normalizeCardStyle,
useCardStyle,
type BlackjackCardStyle,
} from "./cardStyle";
const STYLE_ICONS: Record<BlackjackCardStyle, LucideIcon> = {
light: Sun,
dark: Moon,
classic: Images,
};
export function BlackjackCardStyleToggle() {
const { style, setStyle } = useCardStyle();
const Icon = STYLE_ICONS[style];
const label =
BLACKJACK_CARD_STYLE_OPTIONS.find((option) => option.value === style)?.label ?? "Light";
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
aria-label={`Card style: ${label}`}
title={`Card style: ${label}`}
>
<Icon aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={style}
onValueChange={(value) => setStyle(normalizeCardStyle(value))}
>
{BLACKJACK_CARD_STYLE_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,132 @@
"use client";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
/**
* Renders an amount as a pile of casino chips: denomination-colored CSS
* discs stacked into piles, popping in as the amount grows. Purely visual;
* the exact number always rides alongside in a text label.
*/
interface ChipDenomination {
readonly value: number;
readonly tone: string;
readonly label: string;
}
const DENOMINATIONS: readonly ChipDenomination[] = [
{ value: 1000, tone: "bj-chip-gold", label: "1k" },
{ value: 500, tone: "bj-chip-purple", label: "500" },
{ value: 100, tone: "bj-chip-black", label: "100" },
{ value: 25, tone: "bj-chip-green", label: "25" },
{ value: 10, tone: "bj-chip-blue", label: "10" },
{ value: 5, tone: "bj-chip-red", label: "5" },
{ value: 1, tone: "bj-chip-white", label: "1" },
];
/** Max chips drawn per pile; larger counts compress into the same pile. */
const MAX_CHIPS_PER_PILE = 5;
export interface FlightChip {
readonly key: string;
readonly tone: string;
}
/**
* Denomination breakdown for the flight animation: greedy largest-first,
* capped so big payouts do not spawn endless chips (excess is dropped; the
* exact number always rides in a text label).
*/
export function chipFlightChips(amount: number, maxChips = 8): FlightChip[] {
const chips: FlightChip[] = [];
let remaining = Math.max(0, Math.floor(amount));
for (const denom of DENOMINATIONS) {
const count = Math.min(Math.floor(remaining / denom.value), maxChips - chips.length);
for (let index = 0; index < count; index += 1) {
chips.push({ key: `${denom.label}-${index}`, tone: denom.tone });
}
remaining -= count * denom.value;
if (chips.length >= maxChips) break;
}
if (chips.length === 0 && remaining > 0) {
chips.push({ key: "1-0", tone: "bj-chip-white" });
}
return chips;
}
/** Must match the .bj-chip height + vertical step in styles.css. */
const CHIP_HEIGHT_REM = 0.95;
const CHIP_STEP_REM = 0.3;
interface ChipPile {
readonly key: string;
readonly tone: string;
/** Discs actually drawn (capped by MAX_CHIPS_PER_PILE). */
readonly count: number;
/** True denomination count, used for the hover tooltip. */
readonly trueCount: number;
readonly value: number;
}
function pileUp(amount: number): ChipPile[] {
const piles: ChipPile[] = [];
let remaining = Math.max(0, Math.floor(amount));
for (const denom of DENOMINATIONS) {
const trueCount = Math.floor(remaining / denom.value);
const drawn = Math.min(MAX_CHIPS_PER_PILE, trueCount);
remaining -= trueCount * denom.value;
if (drawn > 0) {
piles.push({
key: denom.label,
tone: denom.tone,
count: drawn,
trueCount,
value: denom.value,
});
}
}
// Amounts below the smallest chip still show a single chip marker.
if (piles.length === 0 && remaining > 0) {
piles.push({ key: "1", tone: "bj-chip-white", count: 1, trueCount: 1, value: 1 });
}
return piles;
}
export function BlackjackChipStack({
amount,
className,
}: {
readonly amount: number;
readonly className?: string;
}) {
if (amount <= 0) return null;
const piles = pileUp(amount);
return (
<TooltipProvider delayDuration={150}>
<span className={cn("bj-chip-stack", className)} aria-hidden="true">
{piles.map((pile) => (
<Tooltip key={pile.key}>
<TooltipTrigger asChild>
<span
className="bj-chip-pile"
style={{ height: `${CHIP_HEIGHT_REM + (pile.count - 1) * CHIP_STEP_REM}rem` }}
>
{Array.from({ length: pile.count }, (_, chipIndex) => (
<span
key={`${pile.key}-${chipIndex}`}
className={cn("bj-chip", pile.tone)}
style={{ bottom: `${chipIndex * CHIP_STEP_REM}rem`, zIndex: chipIndex + 1 }}
/>
))}
</span>
</TooltipTrigger>
<TooltipContent>
{pile.trueCount} × {pile.value}
</TooltipContent>
</Tooltip>
))}
</span>
</TooltipProvider>
);
}

View file

@ -0,0 +1,61 @@
"use client";
import { useEffect, useState } from "react";
interface BlackjackClockProps {
readonly deadline: string | null;
readonly totalMs?: number;
readonly label?: string;
readonly mode?: "bar" | "text";
}
export function BlackjackClock({
deadline,
totalMs = 0,
label = "Time remaining",
mode = "bar",
}: BlackjackClockProps) {
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-sm 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-sm text-muted-foreground">
<span>{label}</span>
<span className="font-mono tabular-nums">{seconds}s</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted" aria-hidden="true">
<div
className="h-full bg-primary poker-countdown-fill"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,198 @@
"use client";
import { useEffect, useState } from "react";
import type { BlackjackActionKind, BlackjackLegalActions } from "@/lib/minigames/blackjack";
import {
blackjackAction,
type BlackjackSnapshot,
} from "@/app/(frontend)/minigames/blackjack/actions";
interface BlackjackControlsProps {
readonly tableId: string;
readonly snapshotVersion: number;
readonly legalActions: BlackjackLegalActions;
readonly yourTurn: boolean;
readonly onSnapshot: (snapshot: BlackjackSnapshot) => void;
readonly onActionFeedback?: () => void;
}
const BET_STEP = 5;
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)));
}
/**
* Minimal felt-overlay controls: one glass pill row per phase, no panels.
*/
export function BlackjackControls({
tableId,
snapshotVersion,
legalActions,
yourTurn,
onSnapshot,
onActionFeedback,
}: BlackjackControlsProps) {
const [pendingAction, setPendingAction] = useState<BlackjackActionKind | null>(null);
const [error, setError] = useState<string | null>(null);
const [amount, setAmount] = useState(legalActions.minBet);
const betting = legalActions.phase === "betting";
const insuring = legalActions.phase === "insurance";
const playing = legalActions.phase === "player";
const betMax = Math.max(legalActions.minBet, legalActions.maxBet);
const canAct = yourTurn && pendingAction === null;
useEffect(() => {
setAmount((current) => clampAmount(current, legalActions.minBet, betMax));
}, [betMax, legalActions.minBet]);
useEffect(() => {
setPendingAction(null);
setError(null);
}, [snapshotVersion]);
const submit = async (kind: BlackjackActionKind, wager?: number) => {
setError(null);
setPendingAction(kind);
try {
const result = await blackjackAction(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 submitFromControl = (kind: BlackjackActionKind, wager?: number) => {
onActionFeedback?.();
void submit(kind, wager);
};
const step = (direction: 1 | -1) => {
setAmount((current) =>
clampAmount(current + direction * BET_STEP, legalActions.minBet, betMax),
);
};
return (
<div className="flex flex-col items-center gap-1.5">
<div className="flex items-center gap-1.5 rounded-full border border-white/15 bg-black/55 px-2.5 py-1.5 backdrop-blur-sm">
{betting && (
<>
<button
type="button"
className="rounded-full px-3 py-1.5 text-sm text-white/65 transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
disabled={!canAct}
onClick={() => submitFromControl("decline-insurance")}
>
Sit out
</button>
<span className="h-5 w-px bg-white/20" aria-hidden="true" />
<span
className="text-[0.65rem] font-medium uppercase tracking-[0.16em] text-white/55"
aria-hidden="true"
>
Bet
</span>
<button
type="button"
className="flex size-8 items-center justify-center rounded-full border border-white/20 text-base text-white/85 transition-colors hover:border-white/40 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
disabled={!canAct || amount <= legalActions.minBet}
onClick={() => step(-1)}
aria-label="Decrease bet"
>
&minus;
</button>
<span className="min-w-12 text-center font-mono text-base font-semibold tabular-nums text-white">
{amount.toLocaleString()}
</span>
<button
type="button"
className="flex size-8 items-center justify-center rounded-full border border-white/20 text-base text-white/85 transition-colors hover:border-white/40 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
disabled={!canAct || amount >= betMax}
onClick={() => step(1)}
aria-label="Increase bet"
>
+
</button>
<button
type="button"
className="poker-chip poker-chip-wager ml-1"
disabled={!canAct || !legalActions.canBet || betMax < legalActions.minBet}
onClick={() => submitFromControl("bet", amount)}
>
Bet
</button>
</>
)}
{insuring && (
<>
<button
type="button"
className="rounded-full px-3 py-1.5 text-sm text-white/65 transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
disabled={!canAct}
onClick={() => submitFromControl("decline-insurance")}
>
No insurance
</button>
<button
type="button"
className="poker-chip poker-chip-wager ml-1"
disabled={!canAct || !legalActions.canInsure}
onClick={() => submitFromControl("insurance", legalActions.insuranceMax)}
>
Insure {legalActions.insuranceMax.toLocaleString()}
</button>
</>
)}
{playing && (
<>
<button
type="button"
className="poker-chip poker-chip-call disabled:cursor-not-allowed disabled:opacity-40"
disabled={!canAct || !legalActions.canStand}
onClick={() => submitFromControl("stand")}
>
Stand
</button>
<button
type="button"
className="poker-chip poker-chip-wager disabled:cursor-not-allowed disabled:opacity-40"
disabled={!canAct || !legalActions.canHit}
onClick={() => submitFromControl("hit")}
>
Hit
</button>
<button
type="button"
className="poker-chip poker-chip-call disabled:cursor-not-allowed disabled:opacity-40"
disabled={!canAct || !legalActions.canDouble}
onClick={() => submitFromControl("double")}
>
Double
</button>
<button
type="button"
className="poker-chip poker-chip-call disabled:cursor-not-allowed disabled:opacity-40"
disabled={!canAct || !legalActions.canSplit}
onClick={() => submitFromControl("split")}
>
Split
</button>
</>
)}
</div>
<div className="flex h-5 items-center gap-3 text-sm" aria-live="polite">
{pendingAction && <span className="text-white/60">Table update...</span>}
{error && <span className="text-destructive">{error}</span>}
</div>
</div>
);
}

View file

@ -0,0 +1,373 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { handValue, type Card, type PublicPlayerView } from "@/lib/minigames/blackjack";
import type { BlackjackSnapshot } from "@/app/(frontend)/minigames/blackjack/actions";
import { Badge } from "@/components/ui/badge";
import { CardView } from "./CardView";
import { BlackjackActionToolbar } from "./BlackjackActionToolbar";
import { BlackjackChipStack, chipFlightChips, type FlightChip } from "./BlackjackChipStack";
import { BlackjackClock } from "./BlackjackClock";
import { BlackjackShoeOrigin } from "./BlackjackShoeOrigin";
import { BlackjackPopupBubble } from "./BlackjackPopups";
import type { BlackjackActionLogEntry } from "./blackjackPopups";
import { BlackjackSeat } from "./BlackjackSeat";
import type { ActiveBlackjackPopup } from "./useBlackjackPopups";
import { isTopSeatPosition, seatPosition } from "./seatPosition";
export type SeatedSnapshot = BlackjackSnapshot & {
readonly you: NonNullable<BlackjackSnapshot["you"]>;
};
interface BlackjackFeltCanvasProps {
readonly snapshot: SeatedSnapshot;
readonly onSnapshot: (snapshot: BlackjackSnapshot) => void;
readonly onActionFeedback: () => void;
readonly popups: readonly ActiveBlackjackPopup[];
readonly actionLog: readonly BlackjackActionLogEntry[];
readonly spectator?: boolean;
readonly onJoin?: () => void;
readonly superuserIds?: readonly number[];
readonly fullscreen?: boolean;
}
function cardKey(card: Card): string {
return `${card.rank}-${card.suit}`;
}
function signedAmount(value: number): string {
return `${value >= 0 ? "+" : ""}${value.toLocaleString()}`;
}
export function BlackjackFeltCanvas({
snapshot,
onSnapshot,
onActionFeedback,
popups,
actionLog,
spectator = false,
onJoin,
superuserIds,
fullscreen = false,
}: BlackjackFeltCanvasProps) {
const view = snapshot.you;
const result = view.roundResult;
const viewerPlayer = view.players.find((player) => player.seatId === view.viewerSeat);
const winnerSeats = new Set(
(result?.hands ?? []).filter((hand) => hand.netChange > 0).map((hand) => hand.seatId),
);
const opponents = view.players.filter((player) => player.seatId !== view.viewerSeat);
const popupsForSeat = (seatId: number): readonly ActiveBlackjackPopup[] =>
popups.filter((popup) => popup.seatId === seatId && popup.tone !== "leave");
const viewerPopups = popupsForSeat(view.viewerSeat);
const tableAnnouncements = popups.filter((popup) => popup.tone === "leave");
const liveTurn = view.phase !== "round-complete";
// Chip flight: on the settle transition, chips launch one after another
// from the hand spot with denomination colors matching the money moved:
// payouts (and pushes) toward the bankroll pill, lost bets up to the
// dealer. A mixed round flies both directions at once.
const previousSettleRoundRef = useRef<number | null>(null);
const [chipFlights, setChipFlights] = useState<
{ key: number; direction: "win" | "loss"; chips: FlightChip[] }[]
>([]);
useEffect(() => {
if (view.phase !== "round-complete" || !view.roundResult) {
previousSettleRoundRef.current = null;
return;
}
if (previousSettleRoundRef.current === view.roundNumber) return;
previousSettleRoundRef.current = view.roundNumber;
// Result rows carry payouts but not the stake; the hands on the players
// view still hold each hand's bet at settle time.
const handsBySeat = new Map(view.players.map((player) => [player.seatId, player.hands]));
let incoming = 0;
let outgoing = 0;
let touched = 0;
for (const hand of view.roundResult.hands) {
if (hand.seatId !== view.viewerSeat) continue;
touched += 1;
const bet = handsBySeat.get(hand.seatId)?.[hand.handIndex]?.bet ?? 0;
if (hand.netChange > 0) incoming += bet + hand.netChange;
else if (hand.netChange < 0) outgoing += bet;
else incoming += bet;
}
for (const insurance of view.roundResult.insurance) {
if (insurance.seatId !== view.viewerSeat) continue;
if (insurance.won) incoming += insurance.amount * 3;
else outgoing += insurance.amount;
}
if (touched === 0) return;
const flights: { key: number; direction: "win" | "loss"; chips: FlightChip[] }[] = [];
if (incoming > 0) {
flights.push({ key: Date.now(), direction: "win", chips: chipFlightChips(incoming) });
}
if (outgoing > 0) {
flights.push({ key: Date.now() + 1, direction: "loss", chips: chipFlightChips(outgoing) });
}
if (flights.length === 0) return;
setChipFlights(flights);
const timer = setTimeout(() => setChipFlights([]), 2400);
return () => clearTimeout(timer);
}, [view.phase, view.roundNumber, view.roundResult, view.viewerSeat]);
return (
<div className="poker-table-stage">
<div
className={cn(
"poker-felt-table blackjack-felt relative aspect-[4/3] overflow-hidden p-4",
fullscreen ? "blackjack-felt-fullscreen" : "min-h-[40rem] w-full max-w-[56rem]",
)}
>
{opponents.map((player: PublicPlayerView, opponentIndex) => {
const positionClass = seatPosition(opponentIndex, opponents.length);
return (
<div
key={player.seatId}
className={cn(
"poker-seat-slot absolute z-10 w-44 max-w-[34%] sm:w-60",
positionClass,
)}
>
<BlackjackSeat
player={player}
isCurrentUser={false}
isActing={liveTurn && player.seatId === view.activeSeat}
isWinner={winnerSeats.has(player.seatId)}
isSuperuser={superuserIds?.includes(Number(snapshot.seats[player.seatId])) ?? false}
/>
{popupsForSeat(player.seatId).length > 0 && (
<div
className={cn(
"poker-popup-stack",
isTopSeatPosition(positionClass) && "poker-popup-stack-below",
)}
aria-hidden="true"
>
{popupsForSeat(player.seatId).map((popup) => (
<BlackjackPopupBubble key={popup.key} popup={popup} />
))}
</div>
)}
</div>
);
})}
<div className="poker-table-center absolute left-1/2 top-[26%] z-10 flex w-[min(26rem,72%)] -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-2 text-white">
<div className="flex items-start gap-3" aria-label="Dealer hand">
<BlackjackShoeOrigin />
<div className="flex flex-col items-center gap-1">
<div className="flex flex-wrap justify-center gap-1">
{view.dealer.cards.length > 0 ? (
view.dealer.cards.map((card, index) => (
<CardView
key={`dealer-${cardKey(card)}-${index}`}
card={card}
faceUp
size="md"
dealIndex={index + 1}
/>
))
) : (
<span className="font-mono text-sm uppercase tracking-[0.2em] text-white/60">
Dealer
</span>
)}
{view.dealer.holeHidden && view.dealer.cards.length > 0 && (
<CardView card={null} faceUp={false} size="md" />
)}
</div>
{view.dealer.total !== null && (
<Badge variant="outline" className="font-mono text-sm tabular-nums">
Dealer {view.dealer.total}
</Badge>
)}
{view.dealer.holeHidden && view.dealer.cards.length > 0 && (
<span className="font-mono text-sm uppercase tracking-[0.2em] text-white/60">
Dealer showing{" "}
{view.dealer.cards[0]?.rank === 14 ? "ace" : (view.dealer.cards[0]?.rank ?? "")}
</span>
)}
</div>
</div>
<div className="poker-pot-plaque">
<span className="poker-console-kicker">Round</span>
<strong className="font-mono text-lg tabular-nums">{view.roundNumber}</strong>
<span className="poker-console-kicker">
Bets {view.config.minBet.toLocaleString()}-{view.config.maxBet.toLocaleString()}
</span>
</div>
{liveTurn && (
<div className="poker-table-turn-clock">
<BlackjackClock
deadline={snapshot.turnDeadline}
totalMs={view.config.turnTimeoutMs}
/>
</div>
)}
</div>
{view.phase !== "round-complete" && view.yourHands.length > 0 && (
<div className="poker-viewer-cards" aria-label="Your hands">
{view.yourHands.map((hand, handIndex) => {
const statusLabel =
hand.doubled && hand.status === "active"
? " · Doubled"
: hand.status === "bust"
? " · Bust"
: hand.status === "blackjack"
? " · Blackjack!"
: "";
return (
<div
key={`felt-hand-${handIndex}`}
className={cn(
"flex flex-col items-center gap-1",
hand.status === "bust" && "opacity-45",
)}
>
<div className="flex">
{hand.cards.map((card, cardIndex) => (
<CardView
key={`felt-hand-${handIndex}-${cardIndex}`}
card={card}
size="md"
dealIndex={cardIndex + 1}
className={cn(
cardIndex === 0 ? "poker-card-viewer-left" : "poker-card-viewer-right",
view.activeHandIndex !== handIndex &&
hand.status === "active" &&
"opacity-60",
)}
/>
))}
</div>
<Badge variant="outline" className="font-mono text-sm tabular-nums">
{handValue(hand.cards).total}
{statusLabel}
</Badge>
<BlackjackChipStack amount={hand.bet} className="pointer-events-auto" />
</div>
);
})}
</div>
)}
{actionLog.length > 0 && (
<div className="poker-action-log" aria-label="Recent actions" aria-live="polite">
{actionLog.map((entry) => (
<p key={entry.key} className={`poker-action-log-line poker-log-${entry.tone}`}>
{entry.text}
</p>
))}
</div>
)}
{!spectator && (
<BlackjackActionToolbar
snapshot={snapshot}
onSnapshot={onSnapshot}
onActionFeedback={onActionFeedback}
/>
)}
{chipFlights.map((flight) => (
<div
key={flight.key}
className={cn("bj-chip-flight", flight.direction === "loss" && "bj-chip-flight-out")}
aria-hidden="true"
>
{flight.chips.map((chip, chipIndex) => (
<span
key={chip.key}
className={cn("bj-chip", chip.tone)}
style={{ animationDelay: `${chipIndex * 130}ms` }}
/>
))}
</div>
))}
{viewerPopups.length > 0 && (
<div className="poker-popup-stack poker-popup-viewer-spot" aria-hidden="true">
{viewerPopups.map((popup) => (
<BlackjackPopupBubble key={popup.key} popup={popup} />
))}
</div>
)}
{tableAnnouncements.length > 0 && (
<div
className="poker-popup-stack poker-popup-announcements"
role="status"
aria-live="polite"
>
{tableAnnouncements.map((popup) => (
<BlackjackPopupBubble key={popup.key} popup={popup} />
))}
</div>
)}
{view.phase === "round-complete" && result && (
<div
className="absolute left-1/2 top-1/2 z-30 flex max-w-[80%] -translate-x-1/2 -translate-y-1/2 justify-center"
aria-live="polite"
>
<div className="flex max-w-[90%] flex-col items-center gap-1.5 rounded border border-white/15 bg-black/55 px-5 py-2.5 backdrop-blur-sm">
<p className="font-mono text-sm uppercase tracking-wider text-white/80">
{result.hands.length === 0 && view.dealer.cards.length === 0
? "No bets this round"
: result.dealerHasBlackjack
? "Dealer blackjack"
: result.dealerBusted
? `Dealer busts at ${result.dealerTotal}`
: `Dealer stands at ${result.dealerTotal}`}
</p>
{result.hands.length > 0 && (
<div className="flex flex-wrap items-center justify-center gap-x-5 gap-y-1 text-sm">
{result.hands.map((hand) => {
const player = view.players.find((item) => item.seatId === hand.seatId);
return (
<span
key={`${hand.seatId}-${hand.handIndex}`}
className="flex items-center gap-1.5"
>
{hand.netChange > 0 && <BlackjackChipStack amount={hand.netChange} />}
<span className="text-white/90">
{player?.name ?? `Seat ${hand.seatId + 1}`}
</span>
<span className="font-mono tabular-nums text-white/60">{hand.total}</span>
<span
className={cn(
"font-mono text-[0.95rem] tabular-nums",
hand.netChange > 0 && "text-emerald-400",
hand.netChange < 0 && "text-rose-400",
hand.netChange === 0 && "text-white/50",
)}
>
{hand.outcome === "blackjack" ? "BJ " : ""}
{signedAmount(hand.netChange)}
</span>
</span>
);
})}
</div>
)}
{snapshot.nextRoundAt && (
<BlackjackClock deadline={snapshot.nextRoundAt} mode="text" label="Next round in" />
)}
</div>
</div>
)}
{viewerPlayer === undefined && onJoin && (
<div className="absolute inset-x-0 bottom-24 z-20 flex justify-center">
<button type="button" className="poker-chip poker-chip-call" onClick={onJoin}>
Take a seat
</button>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,186 @@
"use client";
import { createPortal } from "react-dom";
import { useEffect, useState } from "react";
import { Maximize2, Minimize2 } from "lucide-react";
import type { BlackjackPhase } from "@/lib/minigames/blackjack";
import type { BlackjackSnapshot } from "@/app/(frontend)/minigames/blackjack/actions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card as UiCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { BlackjackCardStyleToggle } from "./BlackjackCardStyleToggle";
import { BlackjackFeltCanvas, type SeatedSnapshot } from "./BlackjackFeltCanvas";
import { BlackjackSoundToggle } from "./BlackjackSoundToggle";
import type { BlackjackSoundControls } from "./useBlackjackSounds";
import type { ActiveBlackjackPopup } from "./useBlackjackPopups";
import type { BlackjackActionLogEntry } from "./blackjackPopups";
import { RenameDialogGuard } from "./RenameDialogGuard";
interface BlackjackGameTableProps {
readonly snapshot: SeatedSnapshot;
readonly onSnapshot: (snapshot: BlackjackSnapshot) => void;
readonly onLeave: () => Promise<void>;
readonly onJoin: () => Promise<void>;
readonly sound: BlackjackSoundControls;
readonly popups: readonly ActiveBlackjackPopup[];
readonly actionLog: readonly BlackjackActionLogEntry[];
readonly isSuperuser: boolean;
readonly superuserIds: readonly number[];
}
const PHASE_LABELS: Record<BlackjackPhase, string> = {
betting: "Betting",
insurance: "Insurance",
player: "In play",
dealer: "Dealer plays",
"round-complete": "Round settled",
};
export function BlackjackGameTable({
snapshot,
onSnapshot,
onLeave,
onJoin,
sound,
popups,
actionLog,
isSuperuser,
superuserIds,
}: BlackjackGameTableProps) {
const [leaving, setLeaving] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [mounted, setMounted] = useState(false);
const view = snapshot.you;
const viewerPlayer = view.players.find((player) => player.seatId === view.viewerSeat);
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 leave = async () => {
setLeaving(true);
await onLeave();
setLeaving(false);
};
const tableName = snapshot.name ?? `Table ${snapshot.tableId}`;
const spectator = Boolean(snapshot.spectator);
const feltCanvas = (
<BlackjackFeltCanvas
snapshot={snapshot}
onSnapshot={onSnapshot}
onActionFeedback={sound.playUiClick}
popups={popups}
actionLog={actionLog}
spectator={spectator}
onJoin={onJoin}
superuserIds={superuserIds}
fullscreen={fullscreen}
/>
);
if (fullscreen) {
if (!mounted) return null;
return createPortal(
<div className="fixed inset-0 z-50 flex flex-col gap-3 overflow-hidden 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>
{spectator && <Badge variant="secondary">Spectating</Badge>}
<Badge variant="outline">{PHASE_LABELS[view.phase]}</Badge>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<BlackjackCardStyleToggle />
<BlackjackSoundToggle
muted={sound.muted}
volume={sound.volume}
winSound={sound.winSound}
onToggleMuted={sound.toggleMuted}
onVolumeChange={sound.setVolume}
onWinSoundChange={sound.setWinSound}
/>
<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..." : spectator ? "Stop watching" : "Leave table"}
</Button>
</div>
</div>
<div className="relative min-h-0 flex-1">{feltCanvas}</div>
</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}
/>
{spectator && <Badge variant="secondary">Spectating</Badge>}
<Badge variant="outline">{PHASE_LABELS[view.phase]}</Badge>
</div>
<p className="font-mono text-xs tabular-nums text-muted-foreground">
Round {view.roundNumber} · Bets {view.config.minBet.toLocaleString()}-
{view.config.maxBet.toLocaleString()}
{viewerPlayer && ` · Bankroll ${viewerPlayer.bankroll.toLocaleString()}`}
</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>
<BlackjackCardStyleToggle />
<BlackjackSoundToggle
muted={sound.muted}
volume={sound.volume}
winSound={sound.winSound}
onToggleMuted={sound.toggleMuted}
onVolumeChange={sound.setVolume}
onWinSoundChange={sound.setWinSound}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setFullscreen(true)}
aria-label="Enter fullscreen table"
title="Enter fullscreen table"
>
<Maximize2 aria-hidden="true" />
</Button>
<Button type="button" variant="outline" onClick={() => void leave()} disabled={leaving}>
{leaving ? "Leaving..." : spectator ? "Stop watching" : "Leave table"}
</Button>
</div>
</CardHeader>
<CardContent className="p-4">{feltCanvas}</CardContent>
</UiCard>
);
}

View file

@ -0,0 +1,87 @@
"use client";
import { BookOpen } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
const RULES: readonly { readonly title: string; readonly lines: readonly string[] }[] = [
{
title: "The goal",
lines: [
"Beat the dealer's hand without going over 21.",
"Cards count face value; face cards are 10 and an ace is 11 or 1, whichever helps.",
"A two-card 21 on the deal is a natural blackjack.",
],
},
{
title: "The round",
lines: [
"Betting: place a wager (10 to 500 chips) or sit the round out.",
"Insurance: when the dealer shows an ace you may insure up to half your bet.",
"Play: hit, stand, double down, or split matching pairs, one hand at a time.",
"The dealer then draws to 17 and stands on all 17s, soft included.",
],
},
{
title: "Payouts",
lines: [
"Natural blackjack pays 3 to 2.",
"A regular win pays 1 to 1; a tie pushes your bet back.",
"Insurance pays 2 to 1 when the dealer has blackjack.",
],
},
{
title: "Table rules",
lines: [
"Split up to four hands; split aces receive one card each and stand.",
"Doubling is allowed on the first two cards of any hand, including after a split.",
"A 21 made after splitting counts as a plain 21, not a natural.",
"A bankroll below the table minimum ends your session with stats recorded.",
],
},
];
export function BlackjackGuide() {
return (
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm">
<BookOpen aria-hidden="true" />
House rules
</Button>
</DialogTrigger>
<DialogContent className="max-h-[80vh] max-w-lg overflow-auto rounded-none">
<DialogHeader>
<DialogTitle>Blackjack house rules</DialogTitle>
<DialogDescription>
Six-deck shoe, dealer stands on all 17s, blackjack pays 3 to 2.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
{RULES.map((section) => (
<div key={section.title} className="flex flex-col gap-1">
<p className="text-sm font-semibold">{section.title}</p>
<ul className="flex flex-col gap-1 text-sm text-muted-foreground">
{section.lines.map((line) => (
<li key={line} className="flex gap-2">
<span aria-hidden="true" className="text-muted-foreground/60">
-
</span>
<span>{line}</span>
</li>
))}
</ul>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,240 @@
"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 blackjack?: {
readonly gamesPlayed?: number | null;
readonly roundsWon?: number | null;
readonly blackjacksHit?: 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 roundsWon: number;
readonly blackjacksHit: number;
readonly bestScore: number;
readonly xpEarned: number;
readonly isCurrentUser: boolean;
readonly isOutsideTopTen?: boolean;
}
interface BlackjackLeaderboardProps {
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 BlackjackLeaderboard({
refreshKey,
currentUserId,
activePlayers,
}: BlackjackLeaderboardProps) {
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.blackjack.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 blackjack = profile.progression?.minigames?.blackjack ?? {};
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: blackjack.gamesPlayed ?? 0,
roundsWon: blackjack.roundsWon ?? 0,
blackjacksHit: blackjack.blackjacksHit ?? 0,
bestScore: blackjack.bestScore ?? 0,
xpEarned: blackjack.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">Blackjack leaderboard</CardTitle>
</div>
<CardDescription>Top 10 by best bankroll</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 finished sessions yet. Take the first seat.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Player</TableHead>
<TableHead className="text-right">Rounds</TableHead>
<TableHead className="text-right">21s</TableHead>
<TableHead className="text-right">Best bankroll</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.roundsWon}
</TableCell>
<TableCell className="text-right font-mono tabular-nums text-muted-foreground">
{row.blackjacksHit}
</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>
);
}

View file

@ -0,0 +1,325 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Ban, ClipboardCopy, CirclePlus, LoaderCircle, RefreshCw, Users } from "lucide-react";
import { cn } from "@/lib/utils";
import {
createBlackjackTable,
forceExpireBlackjackTable,
joinBlackjackTable,
listBlackjackTables,
type BlackjackSnapshot,
type BlackjackTableSummary,
} from "@/app/(frontend)/minigames/blackjack/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 { BLACKJACK_TABLES_EVENT, readBlackjackTablesEvent } from "./events";
interface BlackjackLobbyProps {
readonly refreshKey: number;
readonly onOpenTable: (snapshot: BlackjackSnapshot) => 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 BlackjackLobby({ refreshKey, onOpenTable, isSuperuser = false }: BlackjackLobbyProps) {
const [tables, setTables] = useState<readonly BlackjackTableSummary[] | null>(null);
const [refreshing, setRefreshing] = useState(false);
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 () => {
try {
const result = await listBlackjackTables();
if (!result.success) {
setError(result.error ?? "Could not load blackjack tables.");
return;
}
setError(null);
setTables(result.data ?? []);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not load blackjack tables.");
}
}, []);
const manualRefresh = useCallback(async () => {
setRefreshing(true);
try {
await loadTables();
} finally {
setRefreshing(false);
}
}, [loadTables]);
useEffect(() => {
void loadTables();
}, [loadTables, refreshKey]);
useEffect(() => {
const handleTables = (event: Event) => {
const detail = readBlackjackTablesEvent(event);
if (detail) setTables(detail.tables);
};
window.addEventListener(BLACKJACK_TABLES_EVENT, handleTables);
// Poll fallback for the same SSE-reliability reason as BlackjackArena.
const poll = window.setInterval(() => void loadTables(), 3000);
return () => {
window.removeEventListener(BLACKJACK_TABLES_EVENT, handleTables);
window.clearInterval(poll);
};
}, [loadTables]);
const join = async (tableId: string) => {
setPendingTable(tableId);
setError(null);
try {
const result = await joinBlackjackTable(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 forceExpireBlackjackTable(tableId);
if (!result.success) {
setError(result.error ?? "Could not force-end the blackjack table.");
}
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not force-end the blackjack table.");
} finally {
setForceEndingTable(null);
}
};
const create = async () => {
setCreating(true);
setError(null);
try {
const created = await createBlackjackTable();
if (!created.success || !created.data) {
setError(created.error ?? "Could not create a blackjack table.");
return;
}
await join(created.data.tableId);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not create a blackjack 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/blackjack?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">Blackjack lobby</CardTitle>
<CardDescription>Join an open table or deal yourself in, solo or with a squad.</CardDescription>
</div>
<div className="flex shrink-0 gap-2">
<Button
type="button"
variant="outline"
size="icon"
onClick={() => void manualRefresh()}
disabled={refreshing}
aria-label="Refresh blackjack tables"
title="Refresh tables"
>
<RefreshCw className={cn(refreshing && "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>
)}
{tables === null ? (
<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 take on the dealer.
</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>
);
}

View file

@ -0,0 +1,12 @@
"use client";
import { cn } from "@/lib/utils";
import type { ActiveBlackjackPopup } from "./useBlackjackPopups";
export function BlackjackPopupBubble({ popup }: { readonly popup: ActiveBlackjackPopup }) {
return (
<span aria-hidden="true" className={cn("poker-popup", `poker-popup-${popup.tone}`)}>
{popup.text}
</span>
);
}

View file

@ -0,0 +1,152 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Shield } from "lucide-react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import type { PublicPlayerView } from "@/lib/minigames/blackjack";
import { handValue } from "@/lib/minigames/blackjack";
import { CardView } from "./CardView";
import { BlackjackChipStack } from "./BlackjackChipStack";
interface BlackjackSeatProps {
readonly player: PublicPlayerView;
readonly isCurrentUser: boolean;
readonly isActing: boolean;
readonly isWinner: boolean;
readonly isSuperuser?: boolean;
}
type StackFlash = "up" | "down" | null;
const STACK_FLASH_MS = 900;
const STATUS_LABELS: Record<string, string> = {
active: "Playing",
stood: "Stand",
bust: "Bust",
blackjack: "Blackjack",
};
export function BlackjackSeat({
player,
isCurrentUser,
isActing,
isWinner,
isSuperuser = false,
}: BlackjackSeatProps) {
const initial = player.name.trim().charAt(0).toUpperCase() || "?";
const totalBet = player.hands.reduce((sum, hand) => sum + hand.bet, 0);
const handStatus = player.hands[0]?.status ?? null;
const statusLabel = player.left
? "Left"
: player.bankrupt
? "Out of chips"
: isActing
? "Acting"
: handStatus
? (STATUS_LABELS[handStatus] ?? null)
: player.sittingOut
? "Sitting out"
: null;
const [stackFlash, setStackFlash] = useState<StackFlash>(null);
const previousStackRef = useRef<number | null>(null);
useEffect(() => {
const previousStack = previousStackRef.current;
previousStackRef.current = player.bankroll;
if (previousStack === null || previousStack === player.bankroll) {
return undefined;
}
setStackFlash(player.bankroll > previousStack ? "up" : "down");
const timer = setTimeout(() => setStackFlash(null), STACK_FLASH_MS);
return () => clearTimeout(timer);
}, [player.bankroll]);
return (
<div
className={cn(
"poker-seat-panel mx-auto flex w-max flex-col items-center gap-1.5 px-2 py-1.5 backdrop-blur-sm",
(player.left || player.bankrupt) && "opacity-55",
isActing && "poker-seat-active",
isCurrentUser && "border-ring",
isWinner && "ring-2 ring-amber-300",
)}
aria-label={`${player.name}, seat ${player.seatId + 1}, bankroll ${player.bankroll.toLocaleString()}`}
>
<div className="flex items-center gap-2">
<span
className={cn("poker-seat-avatar", `poker-seat-avatar-tone-${player.seatId % 4}`)}
aria-hidden="true"
>
{initial}
</span>
<div className="poker-seat-nameplate min-w-0">
<p className="flex items-center gap-1 truncate text-sm font-medium leading-tight text-muted-foreground">
{player.name}
{isSuperuser && (
<Shield aria-label="Superuser" className="size-3.5 shrink-0 text-amber-300" />
)}
{isCurrentUser && <span className="shrink-0">(you)</span>}
</p>
<p
className={cn(
"poker-seat-stack",
stackFlash === "up" && "poker-seat-stack-flash-up",
stackFlash === "down" && "poker-seat-stack-flash-down",
)}
>
<span className="poker-seat-stack-chip" aria-hidden="true" />
{player.bankroll.toLocaleString()}
</p>
{(statusLabel || isWinner) && (
<div className="poker-seat-status-row">
{statusLabel && <span className="poker-seat-action-badge">{statusLabel}</span>}
{isWinner && (
<span className="poker-seat-action-badge poker-seat-action-winner">Winner</span>
)}
</div>
)}
</div>
{totalBet > 0 && (
<div className="poker-seat-commit" aria-label={`Total bet ${totalBet.toLocaleString()}`}>
<span className="text-[0.65rem] font-medium uppercase tracking-[0.16em] text-white/55">
Bet
</span>
<BlackjackChipStack amount={totalBet} />
<span className="font-mono text-sm tabular-nums text-white/90">
{totalBet.toLocaleString()}
</span>
</div>
)}
</div>
{player.hands.map((hand, handIndex) => (
<div
key={`${player.seatId}-hand-${handIndex}`}
className={cn(
"poker-seat-reveal",
hand.status === "bust" && "opacity-45",
hand.status === "stood" && player.hands.length > 1 && "opacity-80",
)}
aria-label={`Hand ${handIndex + 1}: ${handValue(hand.cards).total}${
hand.doubled ? ", doubled" : ""
}`}
>
{hand.cards.map((card, cardIndex) => (
<CardView
key={`${player.seatId}-hand-${handIndex}-card-${cardIndex}`}
card={card}
faceUp
size="mini"
dealIndex={cardIndex + 1}
/>
))}
<Badge variant="outline" className="font-mono text-sm tabular-nums">
{handValue(hand.cards).total}
</Badge>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,11 @@
"use client";
export function BlackjackShoeOrigin() {
return (
<div className="poker-deck-origin" aria-hidden="true">
<span className="poker-deck-card poker-deck-card-back" />
<span className="poker-deck-card poker-deck-card-middle" />
<span className="poker-deck-card poker-deck-card-top" />
</div>
);
}

View file

@ -0,0 +1,63 @@
"use client";
import { AudioWaveform, Coins, Volume2, VolumeX } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { BlackjackWinSoundMode } from "./blackjackSound";
interface BlackjackSoundToggleProps {
readonly muted: boolean;
readonly volume: number;
readonly winSound: BlackjackWinSoundMode;
readonly onToggleMuted: () => void;
readonly onVolumeChange: (volume: number) => void;
readonly onWinSoundChange: (mode: BlackjackWinSoundMode) => void;
}
export function BlackjackSoundToggle({
muted,
volume,
winSound,
onToggleMuted,
onVolumeChange,
onWinSoundChange,
}: BlackjackSoundToggleProps) {
const nextWinSound = winSound === "chaching" ? "procedural" : "chaching";
const winSoundLabel =
winSound === "chaching"
? "Win sound: cash register (click for procedural)"
: "Win sound: procedural fanfare (click for cash register)";
return (
<div className="poker-sound-toggle" aria-label="Blackjack sound controls">
<Button
type="button"
variant="outline"
size="icon"
onClick={onToggleMuted}
aria-label={muted ? "Unmute blackjack sounds" : "Mute blackjack sounds"}
title={muted ? "Unmute blackjack sounds" : "Mute blackjack sounds"}
>
{muted ? <VolumeX aria-hidden="true" /> : <Volume2 aria-hidden="true" />}
</Button>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => onWinSoundChange(nextWinSound)}
aria-label={winSoundLabel}
title={winSoundLabel}
>
{winSound === "chaching" ? <Coins aria-hidden="true" /> : <AudioWaveform aria-hidden="true" />}
</Button>
<input
type="range"
min={0}
max={1}
step={0.05}
value={muted ? 0 : volume}
onChange={(event) => onVolumeChange(Number(event.target.value))}
aria-label="Blackjack sound volume"
className="poker-sound-volume"
/>
</div>
);
}

View file

@ -0,0 +1,285 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import type { BlackjackSnapshot } from "@/app/(frontend)/minigames/blackjack/actions";
import { MIN_PLAYERS } from "@/lib/minigames/blackjack";
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 { BlackjackCardStyleProvider } from "./cardStyle";
import { BlackjackGameTable } from "./BlackjackGameTable";
import { RenameTableDialog } from "./RenameTableDialog";
import { useBlackjackPopups } from "./useBlackjackPopups";
import { useBlackjackSounds } from "./useBlackjackSounds";
import { seatPosition } from "./seatPosition";
interface BlackjackTableProps {
readonly snapshot: BlackjackSnapshot | null;
readonly currentUserId: number | string;
readonly loading: boolean;
readonly tableError: string | null;
readonly onSnapshot: (snapshot: BlackjackSnapshot) => void;
readonly onJoin: () => Promise<void>;
readonly onSpectate: () => Promise<void>;
readonly onStart: () => Promise<void>;
readonly onLeave: () => Promise<void>;
readonly onBack: () => void;
readonly isSuperuser: boolean;
readonly superuserIds: readonly number[];
}
type SeatedSnapshot = BlackjackSnapshot & {
readonly you: NonNullable<BlackjackSnapshot["you"]>;
};
function isSeatedSnapshot(snapshot: BlackjackSnapshot): snapshot is SeatedSnapshot {
return snapshot.you !== null;
}
function WaitingTable({
snapshot,
currentUserId,
onSnapshot,
onJoin,
onSpectate,
onStart,
onLeave,
onBack,
}: {
readonly snapshot: BlackjackSnapshot;
readonly currentUserId: number | string;
readonly onSnapshot: (snapshot: BlackjackSnapshot) => void;
readonly onJoin: () => Promise<void>;
readonly onSpectate: () => 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 seatedCount = snapshot.seats.filter((id) => id !== null).length;
const enoughPlayers = seatedCount >= MIN_PLAYERS;
const isHost = seated && snapshot.hostSeatId === viewerSeat;
const opponentCount = snapshot.seats.length - (seated ? 1 : 0);
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. Solo players can start right away."
: "Join an open seat to play against the dealer."}
</p>
</div>
<Badge variant="outline">Waiting room</Badge>
</CardHeader>
<CardContent className="p-4">
<div className="poker-felt-table relative min-h-[30rem] overflow-hidden p-4 sm:min-h-[36rem]">
{snapshot.seats.map((userId, seatId) => (
<div
key={seatId}
className={cn(
"poker-waiting-seat absolute flex flex-col items-center gap-1 border px-3 py-2 text-xs",
seatId === viewerSeat
? "poker-waiting-seat-south"
: seatPosition(
snapshot.seats
.slice(0, seatId)
.filter((_, previousSeatId) => previousSeatId !== viewerSeat).length,
opponentCount,
),
userId !== null && "poker-waiting-seat-occupied",
)}
>
<span
className={cn(
"poker-seat-avatar",
userId === null ? "poker-seat-avatar-open" : "poker-seat-avatar-tone-0",
)}
aria-hidden="true"
>
{userId === null ? "?" : String(userId).charAt(0).toUpperCase()}
</span>
<span className="poker-waiting-name font-semibold">
{userId === null ? `Seat ${seatId + 1}` : (snapshot.names[seatId] ?? `Seat ${seatId + 1}`)}
</span>
<span className="poker-waiting-status 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 Blackjack
</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 || !enoughPlayers}
>
{starting
? "Starting..."
: enoughPlayers
? "Start game"
: "You need at least one player to start"}
</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>
{snapshot.inGame && (
<Button type="button" variant="outline" onClick={() => void onSpectate()}>
Spectate
</Button>
)}
<Button type="button" variant="outline" onClick={onBack}>
Back to lobby
</Button>
</>
)}
</CardFooter>
</Card>
);
}
function BlackjackTableInner({
snapshot,
currentUserId,
loading,
tableError,
onSnapshot,
onJoin,
onSpectate,
onStart,
onLeave,
onBack,
isSuperuser,
superuserIds,
}: BlackjackTableProps) {
// Sound + popup transitions must live here, ABOVE the waiting/game split:
// the hooks have to observe the waiting-room-to-first-round transition,
// which happens before the game table (and its seat view) mounts.
const sound = useBlackjackSounds(snapshot);
const { popups, actionLog } = useBlackjackPopups(snapshot);
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}
onSpectate={onSpectate}
onStart={onStart}
onLeave={onLeave}
onBack={onBack}
/>
);
}
return (
<BlackjackGameTable
snapshot={snapshot}
onSnapshot={onSnapshot}
onLeave={onLeave}
onJoin={onJoin}
sound={sound}
popups={popups}
actionLog={actionLog}
isSuperuser={isSuperuser}
superuserIds={superuserIds}
/>
);
}
export function BlackjackTable(props: BlackjackTableProps) {
return (
<BlackjackCardStyleProvider>
<BlackjackTableInner {...props} />
</BlackjackCardStyleProvider>
);
}

View file

@ -0,0 +1,123 @@
"use client";
import { cn } from "@/lib/utils";
import type { Card, Rank, Suit } from "@/lib/minigames/blackjack";
import { mapCardToSvg, useCardStyle } from "./cardStyle";
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 { style: cardStyle } = useCardStyle();
const lightFace = cardStyle === "light";
const classicFace = cardStyle === "classic";
const revealed = card !== null && faceUp;
const redSuitClass = lightFace ? "text-rose-600" : "text-rose-400";
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="poker-card-motion">
<span className={cn("poker-card-inner", revealed && "poker-card-is-face-up")}>
<span
className={cn(
"poker-card-face poker-card-front gap-1",
lightFace && "poker-card-face--light",
classicFace && "poker-card-front--svg",
)}
aria-hidden={!revealed}
>
{card && classicFace && (
<img src={mapCardToSvg(card)} alt="" draggable={false} className="poker-card-svg" />
)}
{card && !classicFace && (
<>
<span
className={cn(
"flex flex-col items-center leading-none",
RED_SUITS.has(card.suit) && redSuitClass,
)}
>
<span className="font-semibold">{RANK_LABELS[card.rank]}</span>
<span>{SUIT_SYMBOLS[card.suit]}</span>
</span>
{size !== "mini" && (
<span
className={cn("poker-card-center", RED_SUITS.has(card.suit) && redSuitClass)}
>
{SUIT_SYMBOLS[card.suit]}
</span>
)}
</>
)}
</span>
<span className="poker-card-face poker-card-back" aria-hidden={revealed} />
</span>
</span>
</span>
);
}

View file

@ -0,0 +1,107 @@
"use client";
import { useState } from "react";
import { Pencil } from "lucide-react";
import { MAX_TABLE_NAME_LENGTH } from "@/lib/minigames/blackjack/constants";
import {
renameBlackjackTable,
type BlackjackSnapshot,
} from "@/app/(frontend)/minigames/blackjack/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: BlackjackSnapshot) => 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 renameBlackjackTable(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 blackjack"
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>
);
}

View file

@ -0,0 +1,100 @@
"use client";
import { useState } from "react";
import { Pencil } from "lucide-react";
import { MAX_TABLE_NAME_LENGTH } from "@/lib/minigames/blackjack/constants";
import {
renameBlackjackTable,
type BlackjackSnapshot,
} from "@/app/(frontend)/minigames/blackjack/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: BlackjackSnapshot) => 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 renameBlackjackTable(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 blackjack"
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>
);
}

View file

@ -0,0 +1,176 @@
import type { PublicPlayerView } from "@/lib/minigames/blackjack";
import type { BlackjackSoundSnapshot } from "./blackjackSoundEvents";
export type BlackjackPopupTone = "action" | "wager" | "win" | "fold" | "leave";
export interface BlackjackPopupEvent {
readonly key: string;
readonly seatId: number;
readonly text: string;
readonly tone: BlackjackPopupTone;
/** ms to wait before this popup appears, default 0. */
readonly delay?: number;
}
export interface BlackjackActionLogEntry {
readonly key: string;
readonly text: string;
readonly tone: BlackjackPopupTone;
}
/**
* Compose the action-log line for a popup event, joining the acting player's
* name from the snapshot's player list (leavers are already named).
*/
export function actionLogText(
event: BlackjackPopupEvent,
players: readonly PublicPlayerView[],
): string {
if (event.tone === "leave") return event.text;
const actor = players.find((player) => player.seatId === event.seatId);
const name = actor?.name ?? `Seat ${event.seatId + 1}`;
if (event.tone === "win") return `${name} wins ${event.text}`;
if (event.tone === "fold") return `${name}: ${event.text}`;
return `${name}: ${event.text}`;
}
function playerBySeat(
players: readonly PublicPlayerView[],
seatId: number,
): PublicPlayerView | null {
return players.find((player) => player.seatId === seatId) ?? null;
}
export function popupEventsForTransition(
previous: BlackjackSoundSnapshot | null,
next: BlackjackSoundSnapshot,
): readonly BlackjackPopupEvent[] {
if (!previous || previous.version === next.version) return [];
const previousView = previous.you;
const nextView = next.you;
if (!nextView) return [];
if (!previousView) return [];
const events: BlackjackPopupEvent[] = [];
const transitionKey = `${next.version}:${nextView.roundNumber}:${nextView.phase}`;
const sameRound = previousView.roundNumber === nextView.roundNumber;
const samePhase = previousView.phase === nextView.phase;
if (sameRound && samePhase && nextView.phase !== "round-complete") {
for (const nextPlayer of nextView.players) {
const previousPlayer = playerBySeat(previousView.players, nextPlayer.seatId);
if (!previousPlayer) continue;
if (!previousPlayer.hasBet && nextPlayer.hasBet && nextPlayer.hands.length > 0) {
events.push({
key: `${transitionKey}:popup:bet:${nextPlayer.seatId}`,
seatId: nextPlayer.seatId,
text: `Bet +${(nextPlayer.hands[0]?.bet ?? 0).toLocaleString()}`,
tone: "wager",
});
continue;
}
if (previousPlayer.insuranceBet === 0 && nextPlayer.insuranceBet > 0) {
events.push({
key: `${transitionKey}:popup:insurance:${nextPlayer.seatId}`,
seatId: nextPlayer.seatId,
text: `Insurance +${nextPlayer.insuranceBet.toLocaleString()}`,
tone: "wager",
});
}
if (nextPlayer.hands.length > previousPlayer.hands.length) {
events.push({
key: `${transitionKey}:popup:split:${nextPlayer.seatId}`,
seatId: nextPlayer.seatId,
text: "Split",
tone: "action",
});
}
const previousDoubled = previousPlayer.hands.some((hand) => hand.doubled);
const nextDoubled = nextPlayer.hands.some((hand) => hand.doubled);
if (!previousDoubled && nextDoubled) {
const doubledHand = nextPlayer.hands.find((hand) => hand.doubled);
events.push({
key: `${transitionKey}:popup:double:${nextPlayer.seatId}`,
seatId: nextPlayer.seatId,
text: `Double +${(doubledHand?.bet ?? 0).toLocaleString()}`,
tone: "wager",
});
}
nextPlayer.hands.forEach((hand, handIndex) => {
const previousHand = previousPlayer.hands[handIndex];
if (previousHand?.status === hand.status) return;
if (hand.status === "bust") {
events.push({
key: `${transitionKey}:popup:bust:${nextPlayer.seatId}:${handIndex}`,
seatId: nextPlayer.seatId,
text: "Bust",
tone: "fold",
});
} else if (hand.status === "blackjack") {
events.push({
key: `${transitionKey}:popup:blackjack:${nextPlayer.seatId}:${handIndex}`,
seatId: nextPlayer.seatId,
text: "Blackjack!",
tone: "win",
});
}
});
}
}
// Leaving is any of: a vanished seat (removed between rounds), a mid-round
// leaver whose view flips the left flag immediately, or a bankrupt seat.
for (const previousPlayer of previousView.players) {
const nextPlayer = playerBySeat(nextView.players, previousPlayer.seatId);
if (!nextPlayer) {
events.push({
key: `${transitionKey}:popup:leave:${previousPlayer.seatId}`,
seatId: previousPlayer.seatId,
text: `${previousPlayer.name} left the table`,
tone: "leave",
});
continue;
}
const leftBefore = previousPlayer.left || previousPlayer.bankrupt;
const leftNow = nextPlayer.left || nextPlayer.bankrupt;
if (leftNow && !leftBefore) {
events.push({
key: `${transitionKey}:popup:leave:${previousPlayer.seatId}`,
seatId: previousPlayer.seatId,
text: nextPlayer.bankrupt
? `${previousPlayer.name} is out of chips`
: `${previousPlayer.name} left the table`,
tone: "leave",
});
}
}
// A round settles exactly once: every seat ahead of the house gets one
// aggregate "+N" popup, never twice.
if (nextView.phase === "round-complete" && previousView.phase !== "round-complete" && nextView.roundResult) {
const netBySeat = new Map<number, number>();
for (const hand of nextView.roundResult.hands) {
netBySeat.set(hand.seatId, (netBySeat.get(hand.seatId) ?? 0) + hand.netChange);
}
for (const insurance of nextView.roundResult.insurance) {
const delta = insurance.won ? insurance.amount * 2 : -insurance.amount;
netBySeat.set(insurance.seatId, (netBySeat.get(insurance.seatId) ?? 0) + delta);
}
for (const [seatId, net] of netBySeat) {
if (net <= 0) continue;
events.push({
key: `${transitionKey}:popup:win:${seatId}:${net}`,
seatId,
text: `+${net.toLocaleString()}`,
tone: "win",
});
}
}
return events;
}

View file

@ -0,0 +1,348 @@
import type { BlackjackSoundEvent } from "./blackjackSoundEvents";
export const DEFAULT_BLACKJACK_SOUND_VOLUME = 0.28;
/** Sampled award sound; decoded once and routed through the master gain. */
export const CHACHING_SAMPLE_URL = "/assets/chaching.mp3";
export type BlackjackWinSoundMode = "chaching" | "procedural";
type OscillatorShape = OscillatorType;
function assertNever(value: never): never {
throw new Error(`Unknown blackjack sound event: ${JSON.stringify(value)}`);
}
function clampVolume(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_BLACKJACK_SOUND_VOLUME;
return Math.min(1, Math.max(0, value));
}
export class BlackjackSoundEngine {
private context: AudioContext | null = null;
private masterGain: GainNode | null = null;
private jazzTimer: number | null = null;
private jazzRunning = false;
private jazzNextAt = 0;
private muted = false;
private volume = DEFAULT_BLACKJACK_SOUND_VOLUME;
private winSoundMode: BlackjackWinSoundMode = "chaching";
private chachingBuffer: AudioBuffer | null = null;
private chachingLoading = false;
public setMuted(muted: boolean): void {
this.muted = muted;
this.setMasterGain();
if (muted) {
this.stopJazzSchedule();
} else if (this.context) {
this.startJazzSchedule();
}
}
public setVolume(volume: number): void {
this.volume = clampVolume(volume);
this.setMasterGain();
}
public setWinSound(mode: BlackjackWinSoundMode): void {
this.winSoundMode = mode;
if (mode === "chaching") this.ensureChachingBuffer();
}
public unlock(): void {
const context = this.ensureContext();
if (!context) return;
if (context.state === "suspended") {
void context.resume().catch(() => undefined);
}
if (this.winSoundMode === "chaching") this.ensureChachingBuffer();
if (!this.muted) this.startJazzSchedule();
}
public playUiClick(): void {
this.unlock();
const context = this.context;
if (!context || this.muted) return;
this.scheduleTone(720, 0.045, 0.045, context.currentTime, "triangle");
}
public playEvent(event: BlackjackSoundEvent): void {
const context = this.context;
if (!context || this.muted) return;
switch (event.type) {
case "round-start":
this.playRoundStart(context.currentTime);
return;
case "turn-gained":
this.playTurnGained(context.currentTime);
return;
case "turn-lost":
this.scheduleTone(330, 0.12, 0.07, context.currentTime, "sine");
return;
case "cards-dealt":
this.playCardsDealt(event.cardCount, context.currentTime);
return;
case "wager":
this.playWager(context.currentTime);
return;
case "insurance":
this.playInsurance(context.currentTime);
return;
case "bust":
this.playBust(context.currentTime);
return;
case "blackjack":
this.playBlackjackFanfare(context.currentTime);
return;
case "settle":
this.playSettle(context.currentTime);
return;
case "hand-result":
this.playHandResult(event.result, context.currentTime);
return;
default:
return assertNever(event);
}
}
public dispose(): void {
this.stopJazzSchedule();
const context = this.context;
this.context = null;
this.masterGain = null;
if (context && context.state !== "closed") void context.close().catch(() => undefined);
}
private ensureContext(): AudioContext | null {
if (this.context) return this.context;
if (typeof window === "undefined" || typeof window.AudioContext !== "function") return null;
try {
const context = new window.AudioContext();
const masterGain = context.createGain();
masterGain.connect(context.destination);
this.context = context;
this.masterGain = masterGain;
this.setMasterGain();
return context;
} catch (error) {
if (error instanceof Error) return null;
return null;
}
}
private setMasterGain(): void {
if (!this.masterGain || !this.context) return;
const target = this.muted ? 0 : this.volume;
this.masterGain.gain.setTargetAtTime(target, this.context.currentTime, 0.035);
}
private scheduleTone(
frequency: number,
duration: number,
level: number,
start: number,
shape: OscillatorShape,
detune = 0,
): void {
const context = this.context;
const masterGain = this.masterGain;
if (!context || !masterGain) return;
const oscillator = context.createOscillator();
const gain = context.createGain();
oscillator.type = shape;
oscillator.frequency.setValueAtTime(frequency, start);
oscillator.detune.setValueAtTime(detune, start);
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(Math.max(0.0001, level), start + 0.012);
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
oscillator.connect(gain);
gain.connect(masterGain);
oscillator.start(start);
oscillator.stop(start + duration + 0.04);
}
private scheduleNoise(duration: number, level: number, start: number, highPass = 900): void {
const context = this.context;
const masterGain = this.masterGain;
if (!context || !masterGain) return;
const frameCount = Math.max(1, Math.floor(context.sampleRate * duration));
const buffer = context.createBuffer(1, frameCount, context.sampleRate);
const channel = buffer.getChannelData(0);
for (let index = 0; index < channel.length; index += 1) {
channel[index] = Math.random() * 2 - 1;
}
const source = context.createBufferSource();
const filter = context.createBiquadFilter();
const gain = context.createGain();
source.buffer = buffer;
filter.type = "highpass";
filter.frequency.setValueAtTime(highPass, start);
gain.gain.setValueAtTime(level, start);
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
source.connect(filter);
filter.connect(gain);
gain.connect(masterGain);
source.start(start);
source.stop(start + duration + 0.04);
}
private playTurnGained(start: number): void {
this.scheduleTone(660, 0.16, 0.08, start, "sine");
this.scheduleTone(880, 0.2, 0.07, start + 0.11, "sine");
}
private playCardsDealt(cardCount: number, start: number): void {
for (let index = 0; index < cardCount; index += 1) {
const offset = index * 0.075;
this.scheduleNoise(0.12, 0.035, start + offset, 1200);
this.scheduleTone(440 + index * 35, 0.08, 0.025, start + offset, "triangle");
}
}
private playRoundStart(start: number): void {
this.scheduleNoise(0.1, 0.03, start, 1400);
this.scheduleTone(494, 0.14, 0.05, start + 0.05, "triangle");
}
private playWager(start: number): void {
this.scheduleNoise(0.07, 0.08, start, 650);
this.scheduleTone(540, 0.1, 0.055, start + 0.035, "triangle");
}
private playInsurance(start: number): void {
this.scheduleTone(587, 0.09, 0.05, start, "sine");
this.scheduleTone(494, 0.12, 0.045, start + 0.08, "sine");
}
private playBust(start: number): void {
this.scheduleTone(311, 0.14, 0.06, start, "sawtooth");
this.scheduleTone(233, 0.22, 0.05, start + 0.1, "sawtooth");
this.scheduleNoise(0.16, 0.03, start, 500);
}
private playBlackjackFanfare(start: number): void {
[523, 659, 784].forEach((frequency, index) => {
this.scheduleTone(frequency, 0.22, 0.06, start + index * 0.09, "triangle");
});
}
private playSettle(start: number): void {
this.scheduleNoise(0.18, 0.04, start, 700);
this.scheduleTone(392, 0.3, 0.04, start, "sine");
}
private playHandResult(result: "win" | "loss" | "push", start: number): void {
if (result === "win") {
if (this.winSoundMode === "chaching") {
this.playChachingSample(start);
return;
}
this.scheduleTone(523, 0.22, 0.06, start, "triangle");
this.scheduleTone(659, 0.25, 0.06, start + 0.12, "triangle");
this.scheduleTone(784, 0.34, 0.07, start + 0.24, "triangle");
return;
}
if (result === "push") {
this.scheduleTone(440, 0.18, 0.045, start, "sine");
this.scheduleTone(440, 0.18, 0.04, start + 0.14, "sine");
return;
}
this.scheduleTone(330, 0.24, 0.055, start, "sine");
this.scheduleTone(247, 0.34, 0.05, start + 0.16, "sine");
}
/**
* Decode chaching.mp3 once into an AudioBuffer so the sample plays through
* the master gain (mute and volume apply like any other sound). Any
* failure leaves the buffer null and wins fall back to the procedural
* fanfare.
*/
private ensureChachingBuffer(): void {
if (this.chachingBuffer || this.chachingLoading) return;
const context = this.ensureContext();
if (!context) return;
if (typeof window === "undefined" || typeof window.fetch !== "function") return;
this.chachingLoading = true;
window
.fetch(CHACHING_SAMPLE_URL)
.then((response) => {
if (!response.ok) throw new Error(`chaching sample fetch failed: ${response.status}`);
return response.arrayBuffer();
})
.then((data) => context.decodeAudioData(data))
.then((buffer) => {
this.chachingBuffer = buffer;
})
.catch(() => {
this.chachingBuffer = null;
})
.finally(() => {
this.chachingLoading = false;
});
}
private playChachingSample(start: number): void {
const context = this.context;
const masterGain = this.masterGain;
const buffer = this.chachingBuffer;
if (!context || !masterGain || !buffer) {
// Sample not loaded yet (or failed to decode): procedural fallback.
this.scheduleTone(523, 0.22, 0.06, start, "triangle");
this.scheduleTone(659, 0.25, 0.06, start + 0.12, "triangle");
this.scheduleTone(784, 0.34, 0.07, start + 0.24, "triangle");
return;
}
const source = context.createBufferSource();
source.buffer = buffer;
source.connect(masterGain);
source.start(start);
}
private startJazzSchedule(): void {
if (this.jazzRunning || !this.context || this.muted) return;
this.jazzRunning = true;
this.jazzNextAt = Math.max(this.context.currentTime + 0.08, this.jazzNextAt);
this.scheduleJazzBar();
}
private scheduleJazzBar(): void {
const context = this.context;
if (!context || !this.jazzRunning || this.muted) return;
const beat = 60 / 92;
const start = Math.max(context.currentTime + 0.04, this.jazzNextAt);
const chordSets = [
[261.63, 329.63, 392],
[220, 277.18, 329.63],
[246.94, 311.13, 369.99],
[196, 246.94, 293.66],
];
const chord = chordSets[Math.floor(start / (beat * 4)) % chordSets.length] ?? chordSets[0];
chord.forEach((frequency, index) => {
this.scheduleTone(frequency, beat * 2.8, 0.012, start, "sine", index * 4 - 4);
});
for (let beatIndex = 0; beatIndex < 4; beatIndex += 1) {
const beatStart = start + beatIndex * beat;
this.scheduleTone(82.41 + beatIndex * 3, beat * 0.7, 0.018, beatStart, "triangle");
this.scheduleNoise(0.045, 0.012, beatStart + beat * 0.5, 3200);
this.scheduleNoise(0.035, 0.009, beatStart + beat * 0.75, 4200);
}
this.jazzNextAt = start + beat * 4;
this.jazzTimer = window.setTimeout(() => this.scheduleJazzBar(), beat * 4 * 1000 - 80);
}
private stopJazzSchedule(): void {
if (this.jazzTimer !== null) window.clearTimeout(this.jazzTimer);
this.jazzTimer = null;
this.jazzRunning = false;
}
}

View file

@ -0,0 +1,190 @@
import type { BlackjackSnapshot } from "@/app/(frontend)/minigames/blackjack/actions";
import type { PublicPlayerView } from "@/lib/minigames/blackjack";
type BlackjackSoundView = NonNullable<BlackjackSnapshot["you"]>;
export type BlackjackSoundSnapshot = Pick<BlackjackSnapshot, "version"> & {
readonly you: Pick<
BlackjackSoundView,
| "roundNumber"
| "phase"
| "players"
| "dealer"
| "roundResult"
| "viewerSeat"
| "yourTurn"
| "yourHands"
> | null;
};
type BlackjackSoundYou = NonNullable<BlackjackSoundSnapshot["you"]>;
export type BlackjackSoundEvent =
| { readonly type: "round-start"; readonly key: string; readonly roundNumber: number }
| { readonly type: "turn-gained"; readonly key: string }
| { readonly type: "turn-lost"; readonly key: string }
| {
readonly type: "cards-dealt";
readonly key: string;
readonly cardCount: number;
}
| {
readonly type: "wager";
readonly key: string;
readonly amount: number;
readonly seatId: number;
}
| { readonly type: "insurance"; readonly key: string; readonly seatId: number }
| { readonly type: "bust"; readonly key: string; readonly seatId: number }
| { readonly type: "blackjack"; readonly key: string; readonly seatId: number }
| { readonly type: "settle"; readonly key: string }
| {
readonly type: "hand-result";
readonly key: string;
readonly result: "win" | "loss" | "push";
readonly netChange: number;
};
function totalCardCount(view: BlackjackSoundYou): number {
let count = view.dealer.cards.length;
for (const player of view.players) {
for (const hand of player.hands) count += hand.cards.length;
}
return count;
}
function handStatuses(view: BlackjackSoundYou): Map<number, string[]> {
const statuses = new Map<number, string[]>();
for (const player of view.players) {
statuses.set(player.seatId, player.hands.map((hand) => hand.status));
}
return statuses;
}
function viewerRoundNet(view: BlackjackSoundYou): number | null {
const result = view.roundResult;
if (!result) return null;
let net = 0;
let hands = 0;
for (const hand of result.hands) {
if (hand.seatId !== view.viewerSeat) continue;
net += hand.netChange;
hands += 1;
}
if (hands === 0) return null;
for (const insurance of result.insurance) {
if (insurance.seatId !== view.viewerSeat) continue;
net += insurance.won ? insurance.amount * 2 : -insurance.amount;
}
return net;
}
export function soundEventsForTransition(
previous: BlackjackSoundSnapshot | null,
next: BlackjackSoundSnapshot,
): readonly BlackjackSoundEvent[] {
if (!previous || previous.version === next.version) return [];
const previousView = previous.you;
const nextView = next.you;
if (!nextView) return [];
const events: BlackjackSoundEvent[] = [];
const transitionKey = `${next.version}:${nextView.roundNumber}:${nextView.phase}`;
if (
nextView.phase === "betting" &&
(previousView === null || nextView.roundNumber > previousView.roundNumber)
) {
events.push({
type: "round-start",
key: `${transitionKey}:round-start:${nextView.roundNumber}`,
roundNumber: nextView.roundNumber,
});
return events;
}
if (!previousView) return events;
if (previousView.yourTurn !== nextView.yourTurn) {
events.push({
type: nextView.yourTurn ? "turn-gained" : "turn-lost",
key: `${transitionKey}:turn:${nextView.yourTurn ? "gained" : "lost"}:${nextView.phase}`,
});
}
// Cards hitting the felt: initial deal, hits, doubles, splits, and the
// dealer's draws all show up as a total-card-count increase.
const previousCards = totalCardCount(previousView);
const nextCards = totalCardCount(nextView);
const dealt = nextCards - previousCards;
if (dealt > 0) {
events.push({
type: "cards-dealt",
key: `${transitionKey}:cards:${previousCards}:${nextCards}`,
cardCount: Math.min(6, dealt),
});
}
const sameRound = previousView.roundNumber === nextView.roundNumber;
if (sameRound && previousView.phase === nextView.phase) {
for (const nextPlayer of nextView.players) {
const previousPlayer = previousView.players.find(
(candidate) => candidate.seatId === nextPlayer.seatId,
);
if (!previousPlayer) continue;
if (!previousPlayer.hasBet && nextPlayer.hasBet && nextPlayer.hands.length > 0) {
events.push({
type: "wager",
key: `${transitionKey}:wager:${nextPlayer.seatId}:${nextPlayer.hands[0]?.bet ?? 0}`,
amount: nextPlayer.hands[0]?.bet ?? 0,
seatId: nextPlayer.seatId,
});
}
if (previousPlayer.insuranceBet === 0 && nextPlayer.insuranceBet > 0) {
events.push({
type: "insurance",
key: `${transitionKey}:insurance:${nextPlayer.seatId}`,
seatId: nextPlayer.seatId,
});
}
const previousStatuses = handStatuses(previousView).get(nextPlayer.seatId) ?? [];
nextPlayer.hands.forEach((hand, handIndex) => {
const previousStatus = previousStatuses[handIndex];
if (previousStatus === hand.status) return;
if (hand.status === "bust") {
events.push({
type: "bust",
key: `${transitionKey}:bust:${nextPlayer.seatId}:${handIndex}`,
seatId: nextPlayer.seatId,
});
} else if (hand.status === "blackjack") {
events.push({
type: "blackjack",
key: `${transitionKey}:blackjack:${nextPlayer.seatId}:${handIndex}`,
seatId: nextPlayer.seatId,
});
}
});
}
}
if (nextView.phase === "round-complete" && previousView.phase !== "round-complete") {
events.push({ type: "settle", key: `${transitionKey}:settle` });
const net = viewerRoundNet(nextView);
if (net !== null) {
const result = net > 0 ? "win" : net < 0 ? "loss" : "push";
events.push({
type: "hand-result",
key: `${transitionKey}:result:${result}:${net}`,
result,
netChange: net,
});
}
}
return events;
}
export type { PublicPlayerView };

View file

@ -0,0 +1,91 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import type { Card } from "@/lib/minigames/blackjack";
export type BlackjackCardStyle = "light" | "dark" | "classic";
// Shared with the poker table on purpose: one card-style preference for both
// card games, so switching skins in one applies to the other.
const CARD_STYLE_STORAGE_KEY = "poker-card-style";
const RANK_FILE_PREFIXES: Record<number, string> = {
11: "jack",
12: "queen",
13: "king",
14: "ace",
};
const SUIT_FILE_SUFFIXES = {
s: "spades",
h: "hearts",
d: "diamonds",
c: "clubs",
} as const;
export function mapCardToSvg(card: Card): string {
const prefix = RANK_FILE_PREFIXES[card.rank] ?? String(card.rank);
const suffix = SUIT_FILE_SUFFIXES[card.suit];
return `/images/assets/playing-cards/${prefix}_of_${suffix}.svg`;
}
export function normalizeCardStyle(value: unknown): BlackjackCardStyle {
return value === "dark" || value === "classic" ? value : "light";
}
export const BLACKJACK_CARD_STYLE_OPTIONS: readonly {
readonly value: BlackjackCardStyle;
readonly label: string;
}[] = [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "classic", label: "Classic SVG" },
];
interface CardStyleContextValue {
readonly style: BlackjackCardStyle;
readonly setStyle: (style: BlackjackCardStyle) => void;
}
const CardStyleContext = createContext<CardStyleContextValue>({
style: "light",
setStyle: () => {},
});
export function BlackjackCardStyleProvider({ children }: { readonly children: ReactNode }) {
const [style, setStyleState] = useState<BlackjackCardStyle>("light");
useEffect(() => {
try {
const stored = window.localStorage.getItem(CARD_STYLE_STORAGE_KEY);
if (stored !== null) setStyleState(normalizeCardStyle(stored));
} catch {
// storage unavailable (private mode, disabled): keep the default
}
}, []);
const setStyle = useCallback((next: BlackjackCardStyle) => {
setStyleState(next);
try {
window.localStorage.setItem(CARD_STYLE_STORAGE_KEY, next);
} catch {
// non-fatal: the choice just will not persist
}
}, []);
const value = useMemo(() => ({ style, setStyle }), [style, setStyle]);
return <CardStyleContext.Provider value={value}>{children}</CardStyleContext.Provider>;
}
export function useCardStyle(): CardStyleContextValue {
return useContext(CardStyleContext);
}

View file

@ -0,0 +1,88 @@
import type {
BlackjackSnapshot,
BlackjackTableSummary,
} from "@/app/(frontend)/minigames/blackjack/actions";
export {
BLACKJACK_STATE_EVENT,
BLACKJACK_TABLES_EVENT,
BLACKJACK_TERMINATED_EVENT,
} from "@/hooks/useGameTick";
export interface BlackjackStateEventDetail {
readonly tableId: string;
readonly snapshot: BlackjackSnapshot;
}
export interface BlackjackTablesEventDetail {
readonly tables: readonly BlackjackTableSummary[];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isTableSummary(value: unknown): value is BlackjackTableSummary {
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 isBlackjackSnapshot(value: unknown): value is BlackjackSnapshot {
if (!isRecord(value)) return false;
const seats = value.seats;
const names = value.names;
return (
typeof value.tableId === "string" &&
typeof value.version === "number" &&
(value.turnDeadline === null || typeof value.turnDeadline === "string") &&
(value.nextRoundAt === null || typeof value.nextRoundAt === "string") &&
(value.you === null || isRecord(value.you)) &&
Array.isArray(seats) &&
seats.every((seat) => seat === null || typeof seat === "number") &&
Array.isArray(names) &&
names.every((name) => name === null || typeof name === "string") &&
(value.hostSeatId === null || typeof value.hostSeatId === "number")
);
}
export function readBlackjackStateEvent(event: Event): BlackjackStateEventDetail | null {
if (!(event instanceof CustomEvent) || !isRecord(event.detail)) return null;
const detail = event.detail;
if (typeof detail.tableId !== "string" || !isBlackjackSnapshot(detail.snapshot)) return null;
return {
tableId: detail.tableId,
snapshot: detail.snapshot,
};
}
export function readBlackjackTablesEvent(event: Event): BlackjackTablesEventDetail | null {
if (!(event instanceof CustomEvent) || !isRecord(event.detail)) return null;
const rawTables = event.detail.tables;
if (!Array.isArray(rawTables)) return null;
const tables: BlackjackTableSummary[] = [];
for (const table of rawTables) {
if (!isTableSummary(table)) return null;
tables.push(table);
}
return { tables };
}
export function readBlackjackTerminatedEvent(
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",
};
}

View file

@ -0,0 +1,41 @@
// Blackjack seat arcs: every player seat sits on the bottom two-thirds of
// the felt and the top third stays clear for the dealer. Six rim slots
// (three per side: corner 30%, mid 50%, low 70% of the felt height, bulging
// outward at the middle to follow the ellipse) keep every panel inside the
// curved edge with even vertical rhythm. The viewer is pinned bottom-center,
// so odd counts put the extra seat on the right side. Order is right-to-left,
// casino "first base" first.
const ARC_POSITION_CLASSES = [
["blackjack-seat-arc-corner-right"],
["blackjack-seat-arc-corner-right", "blackjack-seat-arc-corner-left"],
["blackjack-seat-arc-mid-right", "blackjack-seat-arc-mid-left", "blackjack-seat-arc-low-right"],
[
"blackjack-seat-arc-corner-right",
"blackjack-seat-arc-low-right",
"blackjack-seat-arc-low-left",
"blackjack-seat-arc-corner-left",
],
[
"blackjack-seat-arc-corner-right",
"blackjack-seat-arc-mid-right",
"blackjack-seat-arc-low-right",
"blackjack-seat-arc-low-left",
"blackjack-seat-arc-corner-left",
],
] as const;
export function seatPosition(opponentIndex: number, opponentCount: number): string {
const normalizedCount = Math.min(ARC_POSITION_CLASSES.length, Math.max(1, opponentCount));
const positions = ARC_POSITION_CLASSES[normalizedCount - 1] ?? ARC_POSITION_CLASSES[0];
const normalizedIndex = Math.min(positions.length - 1, Math.max(0, opponentIndex));
return positions[normalizedIndex] ?? positions[0] ?? "blackjack-seat-arc-corner-right";
}
const TOP_SEAT_CLASSES = new Set<string>([
"blackjack-seat-arc-corner-right",
"blackjack-seat-arc-corner-left",
]);
export function isTopSeatPosition(positionClass: string): boolean {
return TOP_SEAT_CLASSES.has(positionClass);
}

View file

@ -0,0 +1,7 @@
export interface BlackjackStats {
readonly gamesPlayed: number;
readonly roundsWon: number;
readonly blackjacksHit: number;
readonly bestScore: number;
readonly xpEarned: number;
}

View file

@ -0,0 +1,111 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { BlackjackSnapshot } from "@/app/(frontend)/minigames/blackjack/actions";
import {
actionLogText,
popupEventsForTransition,
type BlackjackActionLogEntry,
type BlackjackPopupEvent,
} from "./blackjackPopups";
import type { BlackjackSoundSnapshot } from "./blackjackSoundEvents";
export type ActiveBlackjackPopup = BlackjackPopupEvent;
const POPUP_LIFETIME_MS = 2000;
const LEAVE_POPUP_LIFETIME_MS = 3000;
const MAX_ACTIVE_POPUPS = 12;
const MAX_SEEN_KEYS = 256;
const MAX_LOG_ENTRIES = 5;
const POPUP_LIFETIMES_MS: Record<BlackjackPopupEvent["tone"], number> = {
action: POPUP_LIFETIME_MS,
wager: POPUP_LIFETIME_MS,
win: POPUP_LIFETIME_MS,
fold: POPUP_LIFETIME_MS,
leave: LEAVE_POPUP_LIFETIME_MS,
};
export function useBlackjackPopups(snapshot: BlackjackSnapshot | null): {
popups: readonly ActiveBlackjackPopup[];
actionLog: readonly BlackjackActionLogEntry[];
} {
const [popups, setPopups] = useState<readonly ActiveBlackjackPopup[]>([]);
const [actionLog, setActionLog] = useState<readonly BlackjackActionLogEntry[]>([]);
const previousSnapshotRef = useRef<BlackjackSoundSnapshot | null>(null);
const seenKeysRef = useRef<Set<string>>(new Set());
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
useEffect(() => {
if (!snapshot) {
previousSnapshotRef.current = null;
return;
}
const previousSnapshot = previousSnapshotRef.current;
if (previousSnapshot) {
const fresh = popupEventsForTransition(previousSnapshot, snapshot).filter((event) => {
if (seenKeysRef.current.has(event.key)) return false;
seenKeysRef.current.add(event.key);
return true;
});
while (seenKeysRef.current.size > MAX_SEEN_KEYS) {
const oldest = seenKeysRef.current.values().next().value;
if (typeof oldest !== "string") break;
seenKeysRef.current.delete(oldest);
}
if (fresh.length > 0) {
const immediate: ActiveBlackjackPopup[] = [];
for (const event of fresh) {
const delay = event.delay ?? 0;
if (delay > 0) {
timersRef.current.push(
setTimeout(() => {
setPopups((current) => [...current, event].slice(-MAX_ACTIVE_POPUPS));
timersRef.current.push(
setTimeout(() => {
setPopups((current) => current.filter((popup) => popup.key !== event.key));
}, POPUP_LIFETIMES_MS[event.tone]),
);
}, delay),
);
} else {
immediate.push(event);
timersRef.current.push(
setTimeout(() => {
setPopups((current) => current.filter((popup) => popup.key !== event.key));
}, POPUP_LIFETIMES_MS[event.tone]),
);
}
}
if (immediate.length > 0) {
setPopups((current) => [...current, ...immediate].slice(-MAX_ACTIVE_POPUPS));
}
const nextView = snapshot.you;
if (nextView) {
setActionLog((current) =>
[
...fresh
.map((event) => ({
key: event.key,
text: actionLogText(event, nextView.players),
tone: event.tone,
}))
.reverse(),
...current,
].slice(0, MAX_LOG_ENTRIES),
);
}
}
}
previousSnapshotRef.current = snapshot;
}, [snapshot]);
useEffect(() => {
return () => {
for (const timer of timersRef.current) clearTimeout(timer);
timersRef.current = [];
};
}, []);
return { popups, actionLog };
}

View file

@ -0,0 +1,195 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { BlackjackSnapshot } from "@/app/(frontend)/minigames/blackjack/actions";
import { soundEventsForTransition, type BlackjackSoundSnapshot } from "./blackjackSoundEvents";
import {
DEFAULT_BLACKJACK_SOUND_VOLUME,
BlackjackSoundEngine,
type BlackjackWinSoundMode,
} from "./blackjackSound";
const SOUND_SETTINGS_KEY = "polaris:blackjack-sound-settings";
export interface BlackjackSoundSettings {
readonly muted: boolean;
readonly volume: number;
readonly winSound: BlackjackWinSoundMode;
}
export interface UseBlackjackSoundsResult extends BlackjackSoundSettings {
readonly toggleMuted: () => void;
readonly setVolume: (volume: number) => void;
/** Switches the win sound mode and previews it (hears what you chose). */
readonly setWinSound: (mode: BlackjackWinSoundMode) => void;
readonly playUiClick: () => void;
}
const DEFAULT_SOUND_SETTINGS: BlackjackSoundSettings = {
muted: false,
volume: DEFAULT_BLACKJACK_SOUND_VOLUME,
winSound: "chaching",
};
export type BlackjackSoundControls = ReturnType<typeof useBlackjackSounds>;
function isStoredSettings(
value: unknown,
): value is { readonly muted: unknown; readonly volume: unknown; readonly winSound?: unknown } {
return typeof value === "object" && value !== null && "muted" in value && "volume" in value;
}
function isWinSoundMode(value: unknown): value is BlackjackWinSoundMode {
return value === "chaching" || value === "procedural";
}
function clampVolume(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_BLACKJACK_SOUND_VOLUME;
return Math.min(1, Math.max(0, value));
}
function readStoredSettings(): BlackjackSoundSettings {
if (typeof window === "undefined") return DEFAULT_SOUND_SETTINGS;
try {
const raw = window.localStorage.getItem(SOUND_SETTINGS_KEY);
if (!raw) return DEFAULT_SOUND_SETTINGS;
const parsed: unknown = JSON.parse(raw);
if (
!isStoredSettings(parsed) ||
typeof parsed.muted !== "boolean" ||
typeof parsed.volume !== "number"
) {
return DEFAULT_SOUND_SETTINGS;
}
return {
muted: parsed.muted,
volume: clampVolume(parsed.volume),
winSound: isWinSoundMode(parsed.winSound) ? parsed.winSound : DEFAULT_SOUND_SETTINGS.winSound,
};
} catch (error) {
if (error instanceof Error) return DEFAULT_SOUND_SETTINGS;
return DEFAULT_SOUND_SETTINGS;
}
}
function writeStoredSettings(settings: BlackjackSoundSettings): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(SOUND_SETTINGS_KEY, JSON.stringify(settings));
} catch (error) {
if (error instanceof Error) return;
}
}
export function useBlackjackSounds(snapshot: BlackjackSnapshot | null): UseBlackjackSoundsResult {
const [settings, setSettings] = useState<BlackjackSoundSettings>(DEFAULT_SOUND_SETTINGS);
// Mirror of settings the setters can update synchronously: ensureEngine
// must never re-assert a stale mode between setSettings and the re-render.
const settingsRef = useRef<BlackjackSoundSettings>(DEFAULT_SOUND_SETTINGS);
settingsRef.current = settings;
const engineRef = useRef<BlackjackSoundEngine | null>(null);
const previousSnapshotRef = useRef<BlackjackSoundSnapshot | null>(null);
const playedEventKeysRef = useRef<Set<string>>(new Set());
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const [settingsLoaded, setSettingsLoaded] = useState(false);
const ensureEngine = useCallback((): BlackjackSoundEngine => {
const engine = engineRef.current ?? new BlackjackSoundEngine();
engineRef.current = engine;
engine.setVolume(settingsRef.current.volume);
engine.setMuted(settingsRef.current.muted);
engine.setWinSound(settingsRef.current.winSound);
engine.unlock();
return engine;
}, []);
const playUiClick = useCallback(() => {
ensureEngine().playUiClick();
}, [ensureEngine]);
const toggleMuted = useCallback(() => {
const nextMuted = !settingsRef.current.muted;
settingsRef.current = { ...settingsRef.current, muted: nextMuted };
setSettings((current) => ({ ...current, muted: nextMuted }));
ensureEngine();
}, [ensureEngine]);
const setVolume = useCallback(
(volume: number) => {
const nextVolume = clampVolume(volume);
settingsRef.current = { ...settingsRef.current, volume: nextVolume };
setSettings((current) => ({ ...current, volume: nextVolume }));
ensureEngine();
},
[ensureEngine],
);
const setWinSound = useCallback(
(mode: BlackjackWinSoundMode) => {
settingsRef.current = { ...settingsRef.current, winSound: mode };
setSettings((current) => ({ ...current, winSound: mode }));
ensureEngine().playEvent({
type: "hand-result",
key: `preview:${Date.now()}`,
result: "win",
netChange: 0,
});
},
[ensureEngine],
);
useEffect(() => {
setSettings(readStoredSettings());
setSettingsLoaded(true);
}, []);
useEffect(() => {
if (settingsLoaded) writeStoredSettings(settings);
}, [settings, settingsLoaded]);
useEffect(() => {
if (!snapshot) {
previousSnapshotRef.current = null;
return;
}
const previousSnapshot = previousSnapshotRef.current;
if (previousSnapshot) {
const events = soundEventsForTransition(previousSnapshot, snapshot);
const engine = engineRef.current;
if (engine) {
for (const event of events) {
if (playedEventKeysRef.current.has(event.key)) continue;
playedEventKeysRef.current.add(event.key);
engine.playEvent(event);
}
}
while (playedEventKeysRef.current.size > 256) {
const oldest = playedEventKeysRef.current.values().next().value;
if (typeof oldest !== "string") break;
playedEventKeysRef.current.delete(oldest);
}
}
previousSnapshotRef.current = snapshot;
}, [snapshot]);
useEffect(() => {
return () => {
for (const timer of timersRef.current) clearTimeout(timer);
timersRef.current = [];
engineRef.current?.dispose();
engineRef.current = null;
};
}, []);
return {
muted: settings.muted,
volume: settings.volume,
winSound: settings.winSound,
toggleMuted,
setVolume,
setWinSound,
playUiClick,
};
}

View file

@ -3,6 +3,9 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
BLACKJACK_STATE_EVENT,
BLACKJACK_TABLES_EVENT,
BLACKJACK_TERMINATED_EVENT,
GAME_EVENT_EVENT, GAME_EVENT_EVENT,
GAME_TICK_EVENT, GAME_TICK_EVENT,
NOTIFICATION_EVENT, NOTIFICATION_EVENT,
@ -10,6 +13,9 @@ import {
POKER_TABLES_EVENT, POKER_TABLES_EVENT,
POKER_TERMINATED_EVENT, POKER_TERMINATED_EVENT,
PROFILE_UPDATE_EVENT, PROFILE_UPDATE_EVENT,
type BlackjackStateDetail,
type BlackjackTablesDetail,
type BlackjackTerminatedDetail,
type GameTickDetail, type GameTickDetail,
type PokerStateDetail, type PokerStateDetail,
type PokerTablesDetail, type PokerTablesDetail,
@ -90,6 +96,33 @@ export function GameTickRealtime() {
} }
window.dispatchEvent(new CustomEvent(POKER_TERMINATED_EVENT, { detail })); window.dispatchEvent(new CustomEvent(POKER_TERMINATED_EVENT, { detail }));
}); });
source.addEventListener("blackjack-state", (event) => {
let detail: BlackjackStateDetail = {};
try {
detail = JSON.parse(event.data ?? "{}");
} catch {
// ignore malformed payload
}
window.dispatchEvent(new CustomEvent(BLACKJACK_STATE_EVENT, { detail }));
});
source.addEventListener("blackjack-tables", (event) => {
let detail: BlackjackTablesDetail = {};
try {
detail = JSON.parse(event.data ?? "{}");
} catch {
// ignore malformed payload
}
window.dispatchEvent(new CustomEvent(BLACKJACK_TABLES_EVENT, { detail }));
});
source.addEventListener("blackjack-terminated", (event) => {
let detail: BlackjackTerminatedDetail = {};
try {
detail = JSON.parse(event.data ?? "{}");
} catch {
// ignore malformed payload
}
window.dispatchEvent(new CustomEvent(BLACKJACK_TERMINATED_EVENT, { detail }));
});
return () => { return () => {
if (refreshTimer) clearTimeout(refreshTimer); if (refreshTimer) clearTimeout(refreshTimer);
source.close(); source.close();

View file

@ -1,6 +1,10 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type {
BlackjackSnapshot,
BlackjackTableSummary,
} from "@/app/(frontend)/minigames/blackjack/actions";
import type { PokerSnapshot, PokerTableSummary } from "@/app/(frontend)/minigames/poker/actions"; import type { PokerSnapshot, PokerTableSummary } from "@/app/(frontend)/minigames/poker/actions";
export const GAME_TICK_EVENT = "ptf:game-tick"; export const GAME_TICK_EVENT = "ptf:game-tick";
@ -11,6 +15,9 @@ export const SESSION_EXPIRED_EVENT = "ptf:session-expired";
export const POKER_STATE_EVENT = "ptf:poker-state"; export const POKER_STATE_EVENT = "ptf:poker-state";
export const POKER_TABLES_EVENT = "ptf:poker-tables"; export const POKER_TABLES_EVENT = "ptf:poker-tables";
export const POKER_TERMINATED_EVENT = "ptf:poker-terminated"; export const POKER_TERMINATED_EVENT = "ptf:poker-terminated";
export const BLACKJACK_STATE_EVENT = "ptf:blackjack-state";
export const BLACKJACK_TABLES_EVENT = "ptf:blackjack-tables";
export const BLACKJACK_TERMINATED_EVENT = "ptf:blackjack-terminated";
export interface GameTickDetail { export interface GameTickDetail {
processedAt?: string; processedAt?: string;
@ -19,6 +26,8 @@ export interface GameTickDetail {
export interface ProfileUpdateDetail { export interface ProfileUpdateDetail {
reason?: string; reason?: string;
xpGained?: number; xpGained?: number;
/** Absolute new chip-pool balance; present on chip-affecting updates. */
chipBank?: number;
} }
export interface PokerStateDetail { export interface PokerStateDetail {
@ -35,6 +44,20 @@ export interface PokerTerminatedDetail {
reason?: string; reason?: string;
} }
export interface BlackjackStateDetail {
tableId?: string;
snapshot?: BlackjackSnapshot;
}
export interface BlackjackTablesDetail {
tables?: BlackjackTableSummary[];
}
export interface BlackjackTerminatedDetail {
tableId?: string;
reason?: string;
}
export function useGameTick(onTick?: () => void): number { export function useGameTick(onTick?: () => void): number {
const [count, setCount] = useState(0); const [count, setCount] = useState(0);
const callbackRef = useRef(onTick); const callbackRef = useRef(onTick);

View file

@ -0,0 +1,360 @@
/**
* Player action application for blackjack rounds.
* Pure: validates legality, produces the next state, and auto-advances the
* round whenever the action unblocks progression (next bettor, next hand,
* dealer play, or settle).
*/
import { MAX_BET } from "./constants";
import { advanceState, createEmptyHand, drawCards } from "./engine";
import { canDoubleHand, canSplitHand, handValue, insuranceMaxFor } from "./rules";
import type {
BlackjackActionKind,
BlackjackHand,
BlackjackPlayerState,
BlackjackSeatId,
BlackjackTableState,
} from "./types";
export type BlackjackActionResult =
| { readonly success: true; readonly state: BlackjackTableState }
| { readonly success: false; readonly error: string };
function failure(error: string): BlackjackActionResult {
return { success: false, error };
}
function playerAt(
state: BlackjackTableState,
seatId: BlackjackSeatId,
): BlackjackPlayerState | null {
return state.players[seatId] ?? null;
}
function handAt(
player: BlackjackPlayerState,
handIndex: number,
): BlackjackHand | null {
return player.hands[handIndex] ?? null;
}
function placeBet(
state: BlackjackTableState,
seatId: BlackjackSeatId,
amount: number | undefined,
): BlackjackActionResult {
const player = playerAt(state, seatId);
if (!player) return failure("No player occupies that seat.");
if (player.hasBet) return failure("You have already bet this round.");
if (player.sittingOut) return failure("You sat out this round.");
if (player.bankrupt) return failure("Your bankroll cannot cover the table minimum.");
if (amount === undefined) return failure("A bet requires an amount.");
if (!Number.isInteger(amount)) return failure("Bets must be whole chips.");
if (amount < state.config.minBet) {
return failure(`The minimum bet is ${state.config.minBet}.`);
}
if (amount > state.config.maxBet) {
return failure(`The maximum bet is ${state.config.maxBet}.`);
}
if (amount > player.bankroll) {
return failure("The bet exceeds your bankroll.");
}
const updated: BlackjackPlayerState = {
...player,
bankroll: player.bankroll - amount,
hands: [createEmptyHand(amount)],
hasBet: true,
};
const next: BlackjackTableState = {
...state,
players: state.players.map((candidate) =>
candidate.seatId === seatId ? updated : candidate,
),
};
return { success: true, state: advanceState(next) };
}
function takeInsurance(
state: BlackjackTableState,
seatId: BlackjackSeatId,
amount: number | undefined,
): BlackjackActionResult {
const player = playerAt(state, seatId);
if (!player) return failure("No player occupies that seat.");
if (!player.hasBet) return failure("Insurance requires a bet this round.");
if (player.insuranceAnswered) {
return failure("You have already answered the insurance offer.");
}
const cap = Math.min(insuranceMaxFor(player.hands[0]?.bet ?? 0), player.bankroll);
if (cap <= 0) return failure("Insurance is not available right now.");
if (amount === undefined) return failure("Insurance requires an amount.");
if (!Number.isInteger(amount) || amount < 1) {
return failure("Insurance must be at least one whole chip.");
}
if (amount > cap) return failure(`Insurance is capped at ${cap}.`);
const updated: BlackjackPlayerState = {
...player,
bankroll: player.bankroll - amount,
insuranceBet: amount,
insuranceAnswered: true,
};
const next: BlackjackTableState = {
...state,
players: state.players.map((candidate) =>
candidate.seatId === seatId ? updated : candidate,
),
};
return { success: true, state: advanceState(next) };
}
function declineInsurance(
state: BlackjackTableState,
seatId: BlackjackSeatId,
): BlackjackActionResult {
const player = playerAt(state, seatId);
if (!player) return failure("No player occupies that seat.");
if (!player.hasBet) return failure("Insurance requires a bet this round.");
if (player.insuranceAnswered) {
return failure("You have already answered the insurance offer.");
}
const updated: BlackjackPlayerState = { ...player, insuranceAnswered: true };
const next: BlackjackTableState = {
...state,
players: state.players.map((candidate) =>
candidate.seatId === seatId ? updated : candidate,
),
};
return { success: true, state: advanceState(next) };
}
/** Draw one card onto a hand and resolve 21/bust (or forced stand) immediately. */
function drawToHand(
state: BlackjackTableState,
player: BlackjackPlayerState,
handIndex: number,
hand: BlackjackHand,
autoStand: boolean,
): { state: BlackjackTableState; player: BlackjackPlayerState; hand: BlackjackHand } {
const { cards, shoe } = drawCards(state, 1);
const card = cards[0];
const withCard: BlackjackHand = card ? { ...hand, cards: [...hand.cards, card] } : hand;
const total = handValue(withCard.cards).total;
const status =
total > 21
? ("bust" as const)
: total === 21 || autoStand
? ("stood" as const)
: withCard.status;
const resolved: BlackjackHand = { ...withCard, status };
const updatedPlayer: BlackjackPlayerState = {
...player,
hands: player.hands.map((candidate, index) => (index === handIndex ? resolved : candidate)),
};
const updatedState: BlackjackTableState = {
...state,
shoe,
players: state.players.map((candidate) =>
candidate.seatId === player.seatId ? updatedPlayer : candidate,
),
};
return { state: updatedState, player: updatedPlayer, hand: resolved };
}
function playHand(
state: BlackjackTableState,
seatId: BlackjackSeatId,
action: "hit" | "stand" | "double" | "split",
): BlackjackActionResult {
const player = playerAt(state, seatId);
if (!player) return failure("No player occupies that seat.");
const handIndex = state.activeHandIndex;
const hand = handAt(player, handIndex);
if (!hand || hand.status !== "active") {
return failure("That hand is not awaiting an action.");
}
if (action === "stand") {
const stood: BlackjackHand = { ...hand, status: "stood" };
const updatedPlayer: BlackjackPlayerState = {
...player,
hands: player.hands.map((candidate, index) => (index === handIndex ? stood : candidate)),
};
const next: BlackjackTableState = {
...state,
players: state.players.map((candidate) =>
candidate.seatId === seatId ? updatedPlayer : candidate,
),
};
return { success: true, state: advanceState(next) };
}
if (action === "hit") {
const drawn = drawToHand(state, player, handIndex, hand, false);
return { success: true, state: advanceState(drawn.state) };
}
if (action === "double") {
if (!canDoubleHand(hand, player.bankroll)) {
if (hand.cards.length !== 2) {
return failure("Doubling is only allowed on the first two cards.");
}
return failure("Doubling requires your bankroll to cover the extra stake.");
}
const extraStake = hand.bet;
const reduced: BlackjackPlayerState = { ...player, bankroll: player.bankroll - extraStake };
const doubled: BlackjackHand = { ...hand, bet: hand.bet * 2, doubled: true };
const staged: BlackjackTableState = {
...state,
players: state.players.map((candidate) =>
candidate.seatId === seatId
? {
...reduced,
hands: reduced.hands.map((candidateHand, index) =>
index === handIndex ? doubled : candidateHand,
),
}
: candidate,
),
};
const stagedPlayer = playerAt(staged, seatId);
if (!stagedPlayer) return failure("No player occupies that seat.");
const stagedHand = handAt(stagedPlayer, handIndex);
if (!stagedHand) return failure("That hand is not awaiting an action.");
// Doubling draws exactly one card and the hand then stands (or busts).
const drawn = drawToHand(staged, stagedPlayer, handIndex, stagedHand, true);
return { success: true, state: advanceState(drawn.state) };
}
// Split: matching ranks already verified by canSplitHand.
if (!canSplitHand(player.hands, hand, player.bankroll)) {
if (hand.cards.length !== 2 || hand.cards[0].rank !== hand.cards[1].rank) {
return failure("Splitting requires a pair of matching ranks.");
}
if (hand.splitAces) {
return failure("Split aces cannot be split again.");
}
return failure("Splitting requires your bankroll to cover the second stake.");
}
const secondStake = hand.bet;
const reduced: BlackjackPlayerState = { ...player, bankroll: player.bankroll - secondStake };
const { cards, shoe } = drawCards(state, 2);
const [cardA, cardB] = cards;
if (!cardA || !cardB) {
return failure("The shoe is out of cards; try again in a moment.");
}
const splitAces = hand.cards[0].rank === 14;
const autoStatus = splitAces ? ("stood" as const) : ("active" as const);
const handA: BlackjackHand = {
...createEmptyHand(secondStake),
cards: [hand.cards[0], cardA],
fromSplit: true,
splitAces,
status: handValue([hand.cards[0], cardA]).total === 21 ? ("stood" as const) : autoStatus,
};
const handB: BlackjackHand = {
...createEmptyHand(secondStake),
cards: [hand.cards[1], cardB],
fromSplit: true,
splitAces,
status: handValue([hand.cards[1], cardB]).total === 21 ? ("stood" as const) : autoStatus,
};
const splitHands = [
...player.hands.slice(0, handIndex),
handA,
handB,
...player.hands.slice(handIndex + 1),
];
const updatedPlayer: BlackjackPlayerState = { ...reduced, hands: splitHands };
const next: BlackjackTableState = {
...state,
shoe,
activeHandIndex: handIndex,
players: state.players.map((candidate) =>
candidate.seatId === seatId ? updatedPlayer : candidate,
),
};
return { success: true, state: advanceState(next) };
}
/**
* Apply an action for `seatId`. Bet amounts are whole chips within the table
* limits; insurance is capped at half the main bet. On success the state is
* auto-advanced (next bettor, next hand, dealer play, or settle).
*/
export function applyAction(
state: BlackjackTableState,
seatId: BlackjackSeatId,
action: BlackjackActionKind,
amount?: number,
): BlackjackActionResult {
const player = playerAt(state, seatId);
if (!player) return failure("No player occupies that seat.");
if (player.bankrupt && action !== "decline-insurance") {
return failure("That seat is out of chips.");
}
switch (action) {
case "bet": {
if (state.phase !== "betting") {
return failure("The table is not accepting bets right now.");
}
if (state.toActSeat !== seatId) {
return failure("It is not that seat's turn to bet.");
}
return placeBet(state, seatId, amount);
}
case "insurance":
case "decline-insurance": {
if (state.phase !== "insurance") {
return failure("Insurance is not being offered right now.");
}
if (state.toActSeat !== seatId) {
return failure("It is not that seat's turn to answer the insurance offer.");
}
return action === "insurance"
? takeInsurance(state, seatId, amount)
: declineInsurance(state, seatId);
}
case "hit":
case "stand":
case "double":
case "split": {
if (state.phase !== "player") {
return failure("The table is not accepting hand actions right now.");
}
if (state.toActSeat !== seatId) {
return failure("It is not that seat's turn to act.");
}
return playHand(state, seatId, action);
}
default: {
const exhaustive: never = action;
return failure(`Unknown action: ${String(exhaustive)}`);
}
}
}
/**
* Mark a seat as sitting out the current betting round. Used by the registry
* turn-timer timeout so an idle player does not stall the table.
*/
export function sitOut(
state: BlackjackTableState,
seatId: BlackjackSeatId,
): BlackjackActionResult {
if (state.phase !== "betting") {
return failure("The table is not accepting bets right now.");
}
const player = playerAt(state, seatId);
if (!player) return failure("No player occupies that seat.");
if (player.hasBet) return failure("You have already bet this round.");
if (player.sittingOut) return failure("You are already sitting out this round.");
const updated: BlackjackPlayerState = { ...player, sittingOut: true };
const next: BlackjackTableState = {
...state,
players: state.players.map((candidate) =>
candidate.seatId === seatId ? updated : candidate,
),
};
return { success: true, state: advanceState(next) };
}

View file

@ -0,0 +1,51 @@
/**
* Tunable constants for the blackjack engine.
* Pure data, no imports: safe for client and server.
*/
/** Play chips each player starts a session with. */
export const STARTING_BANKROLL = 1000;
/**
* Chips each player must buy in with from their persistent chip pool when a
* session starts. The buy-in returns to the pool (plus or minus winnings)
* when the session settles; blackjack tables play at half poker's stakes.
*/
export const BUY_IN = 500;
/** Table minimum bet per hand. */
export const MIN_BET = 10;
/** Table maximum bet per hand. */
export const MAX_BET = 500;
/**
* How long a player has to act, in milliseconds. The engine itself never
* enforces this (it is pure and synchronous); the table registry reads it to
* run turn timers and auto-act for stale players (sit out / decline / stand).
*/
export const TURN_TIMEOUT_MS = 30_000;
/** Supported player count: solo play through 6-handed tables. */
export const MIN_PLAYERS = 1;
export const MAX_PLAYERS = 6;
export const MAX_TABLE_NAME_LENGTH = 48;
/** Number of standard 52-card decks in the shoe. */
export const SHOE_DECKS = 6;
/** Reshuffle the shoe between rounds once this fraction of cards remains. */
export const RESHUFFLE_PENETRATION = 0.25;
/** Maximum hands a player may hold at once (3 splits). */
export const MAX_HANDS = 4;
/** Natural blackjack pays 3 to 2. */
export const BLACKJACK_PAYOUT = 1.5;
/** Insurance pays 2 to 1 (stake returned plus twice the stake on a win). */
export const INSURANCE_PAYOUT = 2;
/** Dealer stands on all 17s, soft included (S17). */
export const DEALER_STAND_TOTAL = 17;

View file

@ -0,0 +1,545 @@
/**
* Core table lifecycle: session creation and round progression for
* blackjack. Action application lives in actions.ts.
*
* Pure and server-authoritative: every function takes a state and returns a
* new state (never mutates), all randomness flows from the table seed, and
* nothing here imports Payload, Next.js, or process. The registry wrapper
* holds states, runs turn timers, and exposes views.
*/
import {
MAX_BET,
MAX_PLAYERS,
MIN_BET,
MIN_PLAYERS,
STARTING_BANKROLL,
TURN_TIMEOUT_MS,
} from "./constants";
import {
blackjackWinAmount,
dealerShouldHit,
handValue,
insuranceMaxFor,
insuranceWinAmount,
isNaturalBlackjack,
isTenValue,
} from "./rules";
import {
createShuffledShoe,
createShoe,
mulberry32,
needsShuffle,
shoeSeed,
shuffleShoe,
} from "./shoe";
import type {
BlackjackHand,
BlackjackPlayerState,
BlackjackRoundHandResult,
BlackjackSeatId,
BlackjackTableConfig,
BlackjackTableState,
Card,
RoundOutcome,
} from "./types";
export interface BlackjackPlayerInit {
readonly name: string;
/** Overrides the table's starting bankroll for this seat (tests/scenarios). */
readonly bankroll?: number;
}
export interface CreateTableOptions {
readonly startingBankroll?: number;
readonly minBet?: number;
readonly maxBet?: number;
}
/** A fresh, empty hand carrying only its stake (filled in when cards land). */
export function createEmptyHand(bet: number): BlackjackHand {
return {
cards: [],
bet,
status: "active",
doubled: false,
fromSplit: false,
splitAces: false,
outcome: null,
netChange: null,
};
}
const emptyHand = createEmptyHand;
/**
* Draw `count` cards from the shoe. Defensive: an exhausted shoe (never
* expected with a 6-deck shoe and penetration reshuffles) is refilled with a
* fresh deterministic shuffle so the engine can never stall.
*/
export function drawCards(
source: Pick<BlackjackTableState, "shoe" | "seed" | "roundNumber">,
count: number,
): { cards: Card[]; shoe: readonly Card[] } {
let shoe = [...source.shoe];
const cards: Card[] = [];
for (let i = 0; i < count; i += 1) {
if (shoe.length === 0) {
shoe = shuffleShoe(
createShoe(),
mulberry32(shoeSeed(source.seed, source.roundNumber + 4096 + cards.length)),
);
}
const card = shoe[0];
if (card) {
cards.push(card);
shoe = shoe.slice(1);
}
}
return { cards, shoe };
}
/** Whether the seat still owes a bet this round. */
function needsBet(player: BlackjackPlayerState): boolean {
return !player.bankrupt && !player.hasBet && !player.sittingOut;
}
/** Largest legal insurance stake for a seated player right now. */
function insuranceCapFor(player: BlackjackPlayerState): number {
const mainBet = player.hands[0]?.bet ?? 0;
return Math.min(insuranceMaxFor(mainBet), player.bankroll);
}
/** True when the dealer's upcard allows a blackjack peek (ace or ten-value). */
function dealerPeekApplies(dealerCards: readonly Card[]): boolean {
const upcard = dealerCards[0];
return upcard !== undefined && (upcard.rank === 14 || isTenValue(upcard.rank));
}
/** Deal two cards to every bettor plus the dealer, then open the round. */
function dealInitialHands(state: BlackjackTableState): BlackjackTableState {
const bettors = state.players.filter((player) => player.hasBet && !player.sittingOut);
if (bettors.length === 0) {
// Nobody wagered anything: record an empty round and rest until the
// registry's next-round timer starts the following betting phase.
const empty: RoundOutcome = {
roundNumber: state.roundNumber,
dealerTotal: 0,
dealerBusted: false,
dealerHasBlackjack: false,
hands: [],
insurance: [],
};
return {
...state,
toActSeat: null,
activeHandIndex: 0,
phase: "round-complete",
roundResult: empty,
};
}
// Casino dealing order: first card to each bettor, dealer upcard, second
// card to each bettor, dealer hole card.
const { cards, shoe } = drawCards(state, bettors.length * 2 + 2);
let cursor = 0;
const firstCards = new Map<number, Card>();
for (const player of bettors) {
const card = cards[cursor];
cursor += 1;
if (card) firstCards.set(player.seatId, card);
}
const dealerUp = cards[cursor];
cursor += 1;
const secondCards = new Map<number, Card>();
for (const player of bettors) {
const card = cards[cursor];
cursor += 1;
if (card) secondCards.set(player.seatId, card);
}
const dealerHole = cards[cursor];
if (!dealerUp || !dealerHole) {
// Defensive: a shoe this depleted should be impossible; rest the round.
return { ...state, toActSeat: null, phase: "round-complete" };
}
const players = state.players.map((player) => {
const first = firstCards.get(player.seatId);
const second = secondCards.get(player.seatId);
if (!first || !second || player.hands.length === 0) return player;
const dealt: BlackjackHand = { ...player.hands[0], cards: [first, second] };
const status = isNaturalBlackjack([first, second]) ? ("blackjack" as const) : ("active" as const);
const hands = [dealt, ...player.hands.slice(1)].map((hand, index) =>
index === 0 ? { ...hand, status } : hand,
);
return { ...player, hands };
});
const withCards: BlackjackTableState = {
...state,
players,
shoe,
dealer: { cards: [dealerUp, dealerHole], holeRevealed: false },
toActSeat: null,
};
// Auto-decline insurance for players who cannot fund even one chip of it.
const prepared = withCards.players.map((player) => {
if (!player.hasBet || player.insuranceAnswered) return player;
if (insuranceCapFor(player) > 0) return player;
return { ...player, insuranceAnswered: true };
});
const preparedState: BlackjackTableState = { ...withCards, players: prepared };
if (dealerUp.rank === 14) {
const offerTo = preparedState.players.find(
(player) => player.hasBet && !player.insuranceAnswered,
);
if (offerTo) {
return { ...preparedState, phase: "insurance", toActSeat: offerTo.seatId };
}
}
return afterDeals(preparedState);
}
/** All insurance decisions are in: peek for dealer blackjack or play on. */
function afterDeals(state: BlackjackTableState): BlackjackTableState {
if (dealerPeekApplies(state.dealer.cards) && isNaturalBlackjack(state.dealer.cards)) {
return settleRound(state);
}
return openPlayerPhase(state);
}
/** First (seat, hand) pair that still owes an action, in playing order. */
function findActionableHand(
state: BlackjackTableState,
): { seatId: BlackjackSeatId; handIndex: number } | null {
for (const player of state.players) {
if (player.bankrupt || player.sittingOut || !player.hasBet) continue;
for (let handIndex = 0; handIndex < player.hands.length; handIndex += 1) {
if (player.hands[handIndex]?.status === "active") {
return { seatId: player.seatId, handIndex };
}
}
}
return null;
}
function openPlayerPhase(state: BlackjackTableState): BlackjackTableState {
const actionable = findActionableHand(state);
if (!actionable) {
return runDealerAndSettle(state);
}
return {
...state,
phase: "player",
toActSeat: actionable.seatId,
activeHandIndex: actionable.handIndex,
};
}
function advanceBetting(state: BlackjackTableState): BlackjackTableState {
const current = state.toActSeat;
if (current !== null) {
const actor = state.players[current];
if (actor && needsBet(actor)) return state;
}
const startIndex = current === null ? 0 : current + 1;
for (let seat = startIndex; seat < state.players.length; seat += 1) {
const player = state.players[seat];
if (player && needsBet(player)) {
return { ...state, toActSeat: seat };
}
}
return dealInitialHands({ ...state, toActSeat: null });
}
function advanceInsurance(state: BlackjackTableState): BlackjackTableState {
const current = state.toActSeat;
if (current !== null) {
const actor = state.players[current];
if (actor && actor.hasBet && !actor.insuranceAnswered && insuranceCapFor(actor) > 0) {
return state;
}
}
const startIndex = current === null ? 0 : current + 1;
for (let seat = startIndex; seat < state.players.length; seat += 1) {
const player = state.players[seat];
if (player && player.hasBet && !player.insuranceAnswered && insuranceCapFor(player) > 0) {
return { ...state, toActSeat: seat };
}
}
return afterDeals(state);
}
function advancePlayer(state: BlackjackTableState): BlackjackTableState {
const actionable = findActionableHand(state);
if (!actionable) return runDealerAndSettle(state);
if (actionable.seatId === state.toActSeat && actionable.handIndex === state.activeHandIndex) {
return state;
}
return {
...state,
toActSeat: actionable.seatId,
activeHandIndex: actionable.handIndex,
};
}
/** Dealer reveals the hole card, draws to 17 (S17), then the round settles. */
function runDealerAndSettle(state: BlackjackTableState): BlackjackTableState {
// The dealer only draws while some played-out hand can still lose or win
// by comparison: all-bust tables and all-natural tables just reveal.
const anyStoodHands = state.players.some(
(player) =>
player.hasBet &&
!player.sittingOut &&
player.hands.some((hand) => hand.status === "stood"),
);
let next: BlackjackTableState = {
...state,
dealer: { ...state.dealer, holeRevealed: true },
};
// Practical draw cap: a dealer under 17 never needs more than a handful of
// cards from a freshly shuffled multi-deck shoe.
let guard = 0;
while (anyStoodHands && dealerShouldHit(next.dealer.cards) && guard < 16) {
const { cards, shoe } = drawCards(next, 1);
const card = cards[0];
if (!card) break;
next = { ...next, shoe, dealer: { ...next.dealer, cards: [...next.dealer.cards, card] } };
guard += 1;
}
return settleRound(next);
}
/**
* Resolve every live hand against the dealer, credit payouts, bump session
* counters, flag bankrupts, and attach the round result. Also used for the
* dealer-blackjack peek settle: the dealer's two cards already contain the
* natural, so no draw happens and non-natural hands simply lose.
*/
export function settleRound(state: BlackjackTableState): BlackjackTableState {
const dealerTotal = handValue(state.dealer.cards).total;
const dealerBusted = dealerTotal > 21;
const dealerHasBlackjack = isNaturalBlackjack(state.dealer.cards);
const handResults: BlackjackRoundHandResult[] = [];
const creditBySeat = new Map<number, number>();
const netBySeat = new Map<number, number>();
const naturalsBySeat = new Map<number, number>();
const activeSeats = new Set<number>();
let players = state.players.map((player) => {
if (!player.hasBet || player.sittingOut) return player;
activeSeats.add(player.seatId);
let stakeReturns = 0;
let winnings = 0;
let net = 0;
let naturals = 0;
const hands = player.hands.map((hand, handIndex) => {
const total = handValue(hand.cards).total;
let outcome: "win" | "lose" | "push" | "blackjack";
let payout = 0;
if (hand.status === "blackjack") {
naturals += 1;
outcome = dealerHasBlackjack ? "push" : "blackjack";
if (outcome === "blackjack") payout = blackjackWinAmount(hand.bet);
} else if (hand.status === "bust") {
outcome = "lose";
} else if (dealerHasBlackjack) {
// Dealer blackjack (peek settle) beats everything except naturals.
outcome = "lose";
} else if (dealerBusted || total > dealerTotal) {
outcome = "win";
payout = hand.bet;
} else if (total < dealerTotal) {
outcome = "lose";
} else {
outcome = "push";
}
const netChange = outcome === "lose" ? -hand.bet : outcome === "push" ? 0 : payout;
net += netChange;
if (outcome !== "lose") stakeReturns += hand.bet;
if (outcome === "win" || outcome === "blackjack") winnings += payout;
handResults.push({ seatId: player.seatId, handIndex, total, outcome, payout, netChange });
return { ...hand, outcome, netChange };
});
let insuranceCredit = 0;
if (player.insuranceBet > 0) {
if (dealerHasBlackjack) {
insuranceCredit = player.insuranceBet + insuranceWinAmount(player.insuranceBet);
net += insuranceWinAmount(player.insuranceBet);
} else {
net -= player.insuranceBet;
}
}
creditBySeat.set(player.seatId, stakeReturns + winnings + insuranceCredit);
netBySeat.set(player.seatId, net);
naturalsBySeat.set(player.seatId, naturals);
return { ...player, hands };
});
players = players.map((player) => {
if (!activeSeats.has(player.seatId)) return player;
return {
...player,
bankroll: player.bankroll + (creditBySeat.get(player.seatId) ?? 0),
roundsPlayed: player.roundsPlayed + 1,
roundsWon: player.roundsWon + ((netBySeat.get(player.seatId) ?? 0) > 0 ? 1 : 0),
blackjacksHit: player.blackjacksHit + (naturalsBySeat.get(player.seatId) ?? 0),
};
});
// Below the table minimum there is no way to bet again: the seat is done.
players = players.map((player) =>
!player.bankrupt && player.bankroll < state.config.minBet
? { ...player, bankrupt: true }
: player,
);
const insuranceResults: RoundOutcome["insurance"] = players
.filter((player) => player.insuranceBet > 0)
.map((player) => ({
seatId: player.seatId,
amount: player.insuranceBet,
won: dealerHasBlackjack,
}));
const roundResult: RoundOutcome = {
roundNumber: state.roundNumber,
dealerTotal,
dealerBusted,
dealerHasBlackjack,
hands: handResults,
insurance: insuranceResults,
};
return {
...state,
players,
phase: "round-complete",
toActSeat: null,
activeHandIndex: 0,
roundResult,
};
}
/**
* Progress the state whenever it can progress on its own:
* - betting: advance to the next seat that owes a bet, or deal;
* - insurance: advance to the next unanswered bettor, or peek/play;
* - player: advance to the next actionable hand, or run the dealer out;
* - round-complete: resting until the registry starts the next round.
* Idempotent: calling it on a resting state returns that state unchanged.
*/
export function advanceState(state: BlackjackTableState): BlackjackTableState {
switch (state.phase) {
case "betting":
return advanceBetting(state);
case "insurance":
return advanceInsurance(state);
case "player":
return advancePlayer(state);
case "dealer":
return runDealerAndSettle(state);
case "round-complete":
return state;
default: {
const exhaustive: never = state.phase;
void exhaustive;
return state;
}
}
}
/**
* Start the next betting round from a round-complete state. Seats whose
* array indexes appear in `dropSeatIds` are removed first, together with any
* bankrupt seats. The registry must apply the exact same index filter to its
* seat map so the seatId === array-index invariant holds. Reshuffles the
* shoe when it is below the penetration floor.
*/
export function startNextRound(
state: BlackjackTableState,
dropSeatIds: readonly number[] = [],
): BlackjackTableState {
const dropped = new Set(dropSeatIds);
const players = state.players
.filter((player, index) => !dropped.has(index) && !player.bankrupt)
.map((player, index) => ({ ...player, seatId: index }));
const nextRoundNumber = state.roundNumber + 1;
const shoe = needsShuffle(state.shoe)
? createShuffledShoe(state.seed, nextRoundNumber)
: state.shoe;
const resetPlayers = players.map((player) => ({
...player,
hands: [],
insuranceBet: 0,
insuranceAnswered: false,
hasBet: false,
sittingOut: false,
}));
const nextToAct = resetPlayers.findIndex((player) => !player.bankrupt);
return {
...state,
players: resetPlayers,
shoe,
dealer: { cards: [], holeRevealed: false },
roundNumber: nextRoundNumber,
phase: "betting",
toActSeat: nextToAct >= 0 ? nextToAct : null,
activeHandIndex: 0,
roundResult: null,
};
}
export function createTableState(
players: readonly BlackjackPlayerInit[],
options: CreateTableOptions = {},
rng: () => number = Math.random,
): BlackjackTableState {
if (players.length < MIN_PLAYERS || players.length > MAX_PLAYERS) {
throw new Error(`Blackjack tables support ${MIN_PLAYERS} to ${MAX_PLAYERS} players.`);
}
const startingBankroll = options.startingBankroll ?? STARTING_BANKROLL;
const minBet = options.minBet ?? MIN_BET;
const maxBet = options.maxBet ?? MAX_BET;
if (minBet <= 0 || maxBet < minBet) {
throw new Error("Bet limits must be positive and the maximum must be at least the minimum.");
}
const config: BlackjackTableConfig = {
startingBankroll,
minBet,
maxBet,
turnTimeoutMs: TURN_TIMEOUT_MS,
};
const seed = Math.floor(rng() * 0x100000000) >>> 0;
const seated: BlackjackPlayerState[] = players.map((player, index) => ({
seatId: index,
name: player.name,
bankroll: player.bankroll ?? startingBankroll,
hands: [],
insuranceBet: 0,
insuranceAnswered: false,
hasBet: false,
sittingOut: false,
bankrupt: false,
roundsPlayed: 0,
roundsWon: 0,
blackjacksHit: 0,
}));
return {
config,
seed,
roundNumber: 1,
players: seated,
shoe: createShuffledShoe(seed, 1),
dealer: { cards: [], holeRevealed: false },
phase: "betting",
toActSeat: seated.length > 0 ? 0 : null,
activeHandIndex: 0,
roundResult: null,
};
}

View file

@ -0,0 +1,13 @@
/**
* Public API of the pure blackjack engine.
* Everything exported here is client-safe: no Payload, Next.js, process, or
* database imports, and no module-level mutable state.
*/
export * from "./constants";
export * from "./types";
export * from "./shoe";
export * from "./rules";
export * from "./engine";
export * from "./actions";
export * from "./views";

View file

@ -0,0 +1,98 @@
/**
* Blackjack rules helpers: hand values, natural detection, action legality,
* and dealer draw logic. Pure: every function takes data and returns data.
*/
import { BLACKJACK_PAYOUT, DEALER_STAND_TOTAL, INSURANCE_PAYOUT, MAX_HANDS } from "./constants";
import type { BlackjackHand, Card, Rank } from "./types";
export interface HandValue {
/** Best total at or under 21 when possible; otherwise the bust total. */
readonly total: number;
/** True when an ace is still counted as 11 (a "soft" hand). */
readonly soft: boolean;
}
/**
* Best hand total: aces count 11 while the total stays at or under 21, then
* degrade to 1. A hand that exceeds 21 reports its hard (all aces = 1) total.
*/
export function handValue(cards: readonly Card[]): HandValue {
let total = 0;
let aces = 0;
for (const card of cards) {
if (card.rank === 14) {
aces += 1;
total += 11;
} else if (card.rank >= 10) {
total += 10;
} else {
total += card.rank;
}
}
while (total > 21 && aces > 0) {
total -= 10;
aces -= 1;
}
return { total, soft: aces > 0 };
}
/** A natural blackjack: exactly two cards totalling 21 (aces + a ten-value). */
export function isNaturalBlackjack(cards: readonly Card[]): boolean {
return cards.length === 2 && handValue(cards).total === 21;
}
/** Ten-value card for peek rules: 10, J, Q, K (rank 14 is the ace). */
export function isTenValue(rank: Rank): boolean {
return rank >= 10 && rank <= 13;
}
/**
* Dealer draw rule (S17): draw while under 17, stand on any 17 including
* soft 17.
*/
export function dealerShouldHit(cards: readonly Card[]): boolean {
return handValue(cards).total < DEALER_STAND_TOTAL;
}
/** The hand is still awaiting an action. */
export function isHandLive(hand: BlackjackHand): boolean {
return hand.status === "active";
}
/** Double is allowed on the first two cards of any live hand. */
export function canDoubleHand(hand: BlackjackHand, bankroll: number): boolean {
return hand.status === "active" && hand.cards.length === 2 && bankroll >= hand.bet;
}
/**
* Split is allowed on a live two-card hand of matching ranks while the player
* still has room for another hand and the bankroll covers the second stake.
* Aces from a previous split can never be re-split.
*/
export function canSplitHand(
hands: readonly BlackjackHand[],
hand: BlackjackHand,
bankroll: number,
): boolean {
if (hand.status !== "active" || hand.cards.length !== 2) return false;
if (hand.cards[0].rank !== hand.cards[1].rank) return false;
if (hand.splitAces) return false;
if (hands.length >= MAX_HANDS) return false;
return bankroll >= hand.bet;
}
/** Winnings for a natural blackjack (stake returned separately). */
export function blackjackWinAmount(bet: number): number {
return Math.floor(bet * BLACKJACK_PAYOUT);
}
/** Winnings for a winning insurance wager (stake returned separately). */
export function insuranceWinAmount(amount: number): number {
return amount * INSURANCE_PAYOUT;
}
/** Largest legal insurance stake for a main bet (up to half the bet). */
export function insuranceMaxFor(bet: number): number {
return Math.floor(bet / 2);
}

View file

@ -0,0 +1,75 @@
/**
* Shoe creation and deterministic shuffling for the blackjack engine.
* Pure: randomness is always injected; no module-level state.
*/
import { SHOE_DECKS, RESHUFFLE_PENETRATION } from "./constants";
import type { Card, Rank, Suit } from "./types";
const SUITS: readonly Suit[] = ["s", "h", "d", "c"];
const MIN_RANK: Rank = 2;
const MAX_RANK: Rank = 14;
const CARDS_PER_DECK = 52;
/** A fresh, ordered multi-deck shoe (order is deterministic before shuffling). */
export function createShoe(decks: number = SHOE_DECKS): Card[] {
const shoe: Card[] = [];
for (let deckIndex = 0; deckIndex < decks; deckIndex += 1) {
for (const suit of SUITS) {
for (let rank = MIN_RANK; rank <= MAX_RANK; rank += 1) {
shoe.push({ rank: rank as Rank, suit });
}
}
}
return shoe;
}
/**
* Small, fast seeded PRNG (mulberry32). Used so a whole multi-round session
* can be replayed from a single integer seed stored in the table state.
*/
export function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Fisher-Yates shuffle; returns a new array, never mutates the input. */
export function shuffleShoe(cards: readonly Card[], rng: () => number): Card[] {
const shoe = [...cards];
for (let i = shoe.length - 1; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const tmp = shoe[i];
shoe[i] = shoe[j];
shoe[j] = tmp;
}
return shoe;
}
const GOLDEN_RATIO_32 = 0x9e3779b9;
/** Per-round seed derived from the table's base seed and the round number. */
export function shoeSeed(baseSeed: number, roundNumber: number): number {
return (baseSeed + Math.imul(roundNumber, GOLDEN_RATIO_32)) >>> 0;
}
/** The exact shuffled shoe used for a given round of a given session seed. */
export function createShuffledShoe(baseSeed: number, roundNumber: number): Card[] {
return shuffleShoe(createShoe(), mulberry32(shoeSeed(baseSeed, roundNumber)));
}
/** Minimum card count the shoe must hold before the next reshuffle. */
export function shoePenetrationFloor(): number {
return Math.floor(SHOE_DECKS * CARDS_PER_DECK * RESHUFFLE_PENETRATION);
}
/** True when the shoe is low enough that the next round needs a reshuffle. */
export function needsShuffle(shoe: readonly Card[]): boolean {
return shoe.length < shoePenetrationFloor();
}

View file

@ -0,0 +1,186 @@
/**
* Blackjack XP + profile-stats awarding (Payload-dependent, server-only).
* The table registry calls awardBlackjackResult when a session ends (player
* leave, bankrupt removal, or table disposal).
*/
import type { Payload } from "payload";
import { broadcastToUser } from "@/lib/realtime/bus";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import { STARTING_BANKROLL } from "./constants";
export interface BlackjackSessionResult {
userId: number;
/** Rounds the player actually bet into during the session. */
roundsPlayed: number;
/** Rounds that ended with the player ahead (net > 0). */
roundsWon: number;
/** Natural blackjacks hit during the session. */
blackjacksHit: number;
/** Bankroll the player finished the session with. */
finalBankroll: number;
}
export interface BlackjackStats {
gamesPlayed: number;
roundsWon: number;
blackjacksHit: number;
bestScore: number;
xpEarned: number;
}
/**
* XP rework in progress: when false, finished sessions still record games
* played, rounds won, blackjacks hit, and best bankroll, but xpEarned is
* untouched and no XP push fires. Flip to true to resume profit-based XP
* payouts.
*/
export const BLACKJACK_XP_ENABLED = false;
/** Hard cap for a single session's XP payout. */
export const BLACKJACK_XP_CAP = 50;
/**
* XP per finished session, scaled by profit over the starting bankroll:
* 5 XP for finishing a session, plus 10 XP per full 25% profit bracket
* (>=25% -> +10, >=50% -> +20, and so on). Tunable here only; the registry
* and UI must not hardcode XP values.
*/
export function xpForProfit(finalBankroll: number): number {
const profit = finalBankroll - STARTING_BANKROLL;
const base = 5;
const brackets = profit > 0 ? Math.floor(profit / (STARTING_BANKROLL * 0.25)) : 0;
return Math.min(base + brackets * 10, BLACKJACK_XP_CAP);
}
/**
* Defensive reader for a profile's progression.minigames.blackjack stats.
* Accepts anything (profiles may predate the blackjack group) and returns a
* fully-populated BlackjackStats with zeros for missing values.
*/
export function readBlackjackStats(profile: unknown): BlackjackStats {
const blackjack =
(profile as { progression?: { minigames?: { blackjack?: Partial<BlackjackStats> } } })
?.progression?.minigames?.blackjack ?? {};
return {
gamesPlayed: blackjack.gamesPlayed ?? 0,
roundsWon: blackjack.roundsWon ?? 0,
blackjacksHit: blackjack.blackjacksHit ?? 0,
bestScore: blackjack.bestScore ?? 0,
xpEarned: blackjack.xpEarned ?? 0,
};
}
/**
* Pure stat transition for one finished session: bumps gamesPlayed,
* accumulates rounds won and blackjacks hit, keeps the best bankroll, and
* adds the session XP.
*/
export function computeBlackjackStatsDelta(
current: BlackjackStats,
result: BlackjackSessionResult,
): BlackjackStats {
return {
gamesPlayed: current.gamesPlayed + 1,
roundsWon: current.roundsWon + result.roundsWon,
blackjacksHit: current.blackjacksHit + result.blackjacksHit,
bestScore: Math.max(current.bestScore, result.finalBankroll),
xpEarned: current.xpEarned + xpForProfit(result.finalBankroll),
};
}
/**
* Awards XP and updates progression.minigames.blackjack for every player in
* a finished blackjack session. Emits the profile-update SSE push and a
* single game event log entry (EventTypes.MinigameBlackjack). Never throws:
* per-user failures are logged and skipped so one broken profile cannot
* block the table.
*/
export async function awardBlackjackResult(
payload: Payload,
results: BlackjackSessionResult[],
): Promise<{ xpByUser: Record<number, number> }> {
if (results.length === 0) {
return { xpByUser: {} };
}
const xpByUser: Record<number, number> = {};
for (const result of results) {
const xp = BLACKJACK_XP_ENABLED ? xpForProfit(result.finalBankroll) : 0;
xpByUser[result.userId] = xp;
try {
const profileRes = await payload.find({
collection: "profiles",
where: { user: { equals: result.userId } },
limit: 1,
depth: 0,
overrideAccess: true,
});
const profile = profileRes.docs[0];
if (!profile) {
payload.logger.error(`Blackjack stats: no profile found for user ${result.userId}`);
continue;
}
const current = readBlackjackStats(profile);
const delta = computeBlackjackStatsDelta(current, result);
const next: BlackjackStats = BLACKJACK_XP_ENABLED
? delta
: { ...delta, xpEarned: current.xpEarned };
const progression = (profile.progression ?? {}) as Record<string, unknown>;
const minigames = (progression.minigames ?? {}) as Record<string, unknown>;
// The final bankroll returns to the player's persistent chip pool: the
// table only moves chips between the pool and the felt, never destroys
// them.
const chipBank =
Math.max(0, ((progression.chipBank as number | undefined) ?? 0) + result.finalBankroll);
await payload.update({
collection: "profiles",
id: profile.id,
data: {
progression: {
...progression,
chipBank,
minigames: {
...minigames,
blackjack: next,
},
},
},
overrideAccess: true,
depth: 0,
});
broadcastToUser("profile-update", result.userId, {
reason: "xp",
xpGained: xp,
chipBank,
profileId: profile.id,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
payload.logger.error(
`Blackjack stats: failed to award result for user ${result.userId}: ${message}`,
);
}
}
const first = results[0];
await emitGameEvent(payload, {
type: EventTypes.MinigameBlackjack,
message: `finished a blackjack session (${first.roundsPlayed} round${first.roundsPlayed === 1 ? "" : "s"} played)`,
actor: first.userId,
targetCollection: "users",
targetId: first.userId,
data: {
results,
xpByUser,
},
});
return { xpByUser };
}

View file

@ -0,0 +1,896 @@
/**
* Server-side multiplayer registry for the blackjack minigame.
*
* Holds live table records in a module-level Map (same pattern as
* src/lib/realtime/bus.ts). SINGLE-INSTANCE ONLY: table state lives in
* process memory, so this will not work across multiple server instances;
* the SSE pipeline it pushes through has the same limitation.
*
* The pure engine (./engine, ./actions) never touches timers or I/O. This
* wrapper owns turn timeouts, next-round pauses, lobby broadcasts, session
* stat awards, and the per-user snapshot pushes. Seat map invariant:
* seatMap is padded to MAX_PLAYERS slots with trailing nulls, so engine
* player i always corresponds to seatMap[i] (drops reindex both together).
*/
import { getPayload } from "payload";
import type { Payload } from "payload";
import { broadcast, broadcastToUser } from "@/lib/realtime/bus";
import { notifyUser } from "@/lib/notifications";
import { creditChips, readChipBankState, spendChipBuyIn } from "@/lib/minigames/chips";
import { applyAction, sitOut } from "./actions";
import {
BUY_IN,
MAX_PLAYERS,
MAX_TABLE_NAME_LENGTH,
MIN_PLAYERS,
TURN_TIMEOUT_MS,
} from "./constants";
import { createTableState, startNextRound } from "./engine";
import { generateTableName } from "@/lib/minigames/tableNames";
import { awardBlackjackResult, type BlackjackSessionResult } from "./stats";
import type {
BlackjackActionKind,
BlackjackPlayerState,
BlackjackSeatId,
BlackjackTableState,
PrivatePlayerView,
} from "./types";
import { buildPrivateView } from "./views";
/** Maximum number of live tables; table creation rejects beyond this. */
export const MAX_TABLES = 20;
/** Pause after a round settles before the next betting phase auto-starts. */
export const NEXT_ROUND_DELAY_MS = 3000;
/** A table with no client interaction for this long is swept as abandoned. */
export const TABLE_EXPIRY_MS = 5 * 60_000;
const TABLE_SWEEP_INTERVAL_MS = 30_000;
export interface BlackjackTableSummary {
tableId: string;
name: string | null;
hostName: string;
seatedCount: number;
maxSeats: number;
inGame: boolean;
createdAt: string;
}
export interface BlackjackSnapshot {
tableId: string;
name: string | null;
version: number;
/** ISO timestamp when the current turn auto-acts; null when no live turn. */
turnDeadline: string | null;
/** ISO timestamp when the next betting round auto-starts after the pause. */
nextRoundAt: string | null;
/** Full table state plus the viewer's options; null when not seated. */
you: PrivatePlayerView | null;
/** seatId to userId (null = empty seat); lets the UI map names and hosts. */
seats: (number | null)[];
/** seatId to display name (null = empty or unknown); drives lobby seat labels. */
names: (string | null)[];
hostSeatId: number | null;
/** True when the viewer is watching without a seat (you is a spectator view). */
spectator?: boolean;
/** Whether a game is currently running (useful for viewers without a seat). */
inGame?: boolean;
}
interface PendingJoin {
userId: number;
name: string;
}
interface BlackjackTableRecord {
id: string;
name: string | null;
version: number;
hostUserId: number | null;
/** Engine seat order, padded to MAX_PLAYERS with trailing nulls. */
seatMap: (number | null)[];
state: BlackjackTableState | null;
inGame: boolean;
/** Users already awarded their session stats this game (idempotency). */
awardedUsers: Set<number>;
/** Users whose buy-in is currently staked on the table (pool deducted). */
chipBoughtIn: Set<number>;
/** Users whose chips have been settled back to their pool (idempotency). */
chipSettled: Set<number>;
/** Pending joiners who already paid the buy-in and wait to seat next round. */
pendingChipPaid: Set<number>;
turnTimer: ReturnType<typeof setTimeout> | null;
/** Version the pending turn timer was scheduled for (stale timers bail). */
turnTimerVersion: number | null;
turnDeadlineAt: string | null;
nextRoundTimer: ReturnType<typeof setTimeout> | null;
nextRoundAt: string | null;
pendingJoins: PendingJoin[];
awayUsers: Set<number>;
/** Users who left mid-game; kept in seatMap so the UI can show "left". */
leftUsers: Set<number>;
/** Users watching the running game without a seat. */
spectators: Set<number>;
userNames: Map<number, string>;
createdAt: string;
/** Last client interaction (poll, action, join/leave/start); drives expiry. */
lastActivityAt: number;
}
// Anchored on globalThis because Next.js dev gives each client session its
// own copy of server-action modules; a plain module-level Map would fork per
// session and tables would vanish between users. Single-process still.
type BlackjackTablesStore = Map<string, BlackjackTableRecord>;
interface BlackjackGlobalRegistry {
__blackjackTables?: BlackjackTablesStore;
}
const globalRegistry = globalThis as typeof globalThis & BlackjackGlobalRegistry;
const tables: BlackjackTablesStore = (globalRegistry.__blackjackTables ??= new Map());
function isSeatedAt(record: BlackjackTableRecord, userId: number): boolean {
return record.seatMap.includes(userId) && !record.leftUsers.has(userId);
}
function seatedCountOf(record: BlackjackTableRecord): number {
let count = 0;
for (const id of record.seatMap) {
if (id !== null && !record.leftUsers.has(id)) count += 1;
}
return count;
}
function clearTurnTimer(record: BlackjackTableRecord): void {
if (record.turnTimer) clearTimeout(record.turnTimer);
record.turnTimer = null;
record.turnTimerVersion = null;
record.turnDeadlineAt = null;
}
/**
* Lobby mutations (join/leave) bump the version without touching game state;
* re-key the pending turn timer so it stays valid instead of going stale.
*/
function rekeyTurnTimer(record: BlackjackTableRecord): void {
if (record.turnTimer !== null) record.turnTimerVersion = record.version;
}
function scheduleTurnTimer(record: BlackjackTableRecord): void {
clearTurnTimer(record);
const state = record.state;
if (!state || state.toActSeat === null) return;
if (state.phase !== "betting" && state.phase !== "insurance" && state.phase !== "player") {
return;
}
record.turnTimerVersion = record.version;
record.turnDeadlineAt = new Date(Date.now() + TURN_TIMEOUT_MS).toISOString();
record.turnTimer = setTimeout(() => onTurnTimeout(record), TURN_TIMEOUT_MS);
}
/** Turn timeout: sit out / decline insurance / stand for whoever is acting. */
function onTurnTimeout(record: BlackjackTableRecord): void {
const scheduledVersion = record.turnTimerVersion;
record.turnTimer = null;
record.turnTimerVersion = null;
record.turnDeadlineAt = null;
if (scheduledVersion === null || scheduledVersion !== record.version) return;
const state = record.state;
if (!state || state.toActSeat === null) return;
const seat = state.toActSeat;
let result;
if (state.phase === "betting") {
result = sitOut(state, seat);
} else if (state.phase === "insurance") {
result = applyAction(state, seat, "decline-insurance");
} else if (state.phase === "player") {
result = applyAction(state, seat, "stand");
} else {
return;
}
if (!result.success) return;
record.state = result.state;
record.version += 1;
afterMutation(record);
}
function scheduleNextRound(record: BlackjackTableRecord): void {
if (record.nextRoundTimer) clearTimeout(record.nextRoundTimer);
record.nextRoundAt = new Date(Date.now() + NEXT_ROUND_DELAY_MS).toISOString();
record.nextRoundTimer = setTimeout(() => onNextRoundTimeout(record), NEXT_ROUND_DELAY_MS);
}
function clearNextRoundTimer(record: BlackjackTableRecord): void {
if (record.nextRoundTimer) clearTimeout(record.nextRoundTimer);
record.nextRoundTimer = null;
record.nextRoundAt = null;
}
function createFreshPlayer(seatId: BlackjackSeatId, name: string): BlackjackPlayerState {
return {
seatId,
name,
bankroll: BUY_IN,
hands: [],
insuranceBet: 0,
insuranceAnswered: false,
hasBet: false,
sittingOut: false,
bankrupt: false,
roundsPlayed: 0,
roundsWon: 0,
blackjacksHit: 0,
};
}
/**
* Next-round pause over: apply pending joins, drop leavers and bankrupts
* (with a session-stat award for each), and start the next betting phase.
*/
function onNextRoundTimeout(record: BlackjackTableRecord): void {
record.nextRoundTimer = null;
const current = record.state;
if (!current) return;
const dropIndexes: number[] = [];
const dropUsers: number[] = [];
current.players.forEach((player, index) => {
const userId = record.seatMap[index];
if (userId === null) return;
if (record.leftUsers.has(userId) || player.bankrupt) {
dropIndexes.push(index);
dropUsers.push(userId);
}
});
void awardSessionResults(record, dropUsers);
const survivors = record.seatMap.filter(
(id, index) => !dropIndexes.includes(index) && id !== null,
);
record.seatMap = [...survivors];
while (record.seatMap.length < MAX_PLAYERS) record.seatMap.push(null);
let next = startNextRound(current, dropIndexes);
for (const pending of record.pendingJoins) {
// Paid pending joiners bring their buy-in into the engine now.
if (record.pendingChipPaid.has(pending.userId)) {
record.pendingChipPaid.delete(pending.userId);
record.chipBoughtIn.add(pending.userId);
}
const slot = record.seatMap.indexOf(null);
if (slot < 0) break;
record.seatMap[slot] = pending.userId;
next = {
...next,
players: [...next.players, createFreshPlayer(next.players.length, pending.name)],
};
}
record.pendingJoins = [];
// Fresh joiners have no recorded session yet; nothing else to clean up.
record.state = next;
record.version += 1;
afterMutation(record);
}
/** In-app bell notification: solo player keeps the table to themselves. */
async function notifySoloPlayer(record: BlackjackTableRecord): Promise<void> {
const lastUserId = record.seatMap.find(
(id): id is number => id !== null && !record.leftUsers.has(id),
);
if (lastUserId === undefined) return;
try {
const config = (await import("@payload-config")).default;
const payload = await getPayload({ config });
await notifyUser(payload, {
userId: lastUserId,
type: "blackjack:everyone-left",
title: "Everyone left the table",
message: "The other players left. You can keep playing blackjack solo.",
link: "/minigames/blackjack",
});
} catch {
// Payload unavailable in this runtime; nothing to notify.
}
}
/** Fire-and-forget XP/stats award; never blocks the table. */
async function awardSessionResults(
record: BlackjackTableRecord,
userIds: readonly number[],
): Promise<void> {
const state = record.state;
if (!state) return;
const results: BlackjackSessionResult[] = [];
const refunds: { userId: number; amount: number }[] = [];
for (const userId of userIds) {
if (record.awardedUsers.has(userId) || record.chipSettled.has(userId)) continue;
const seatIndex = record.seatMap.indexOf(userId);
if (seatIndex < 0 || seatIndex >= state.players.length) continue;
const player = state.players[seatIndex];
if (!player) continue;
if (player.roundsPlayed <= 0) {
// Never played a hand: the untouched buy-in returns to the pool.
record.chipSettled.add(userId);
refunds.push({ userId, amount: player.bankroll });
continue;
}
record.awardedUsers.add(userId);
// Settled up front: awardBlackjackResult credits the final bankroll back
// to the player's pool inside its profile update.
record.chipSettled.add(userId);
results.push({
userId,
roundsPlayed: player.roundsPlayed,
roundsWon: player.roundsWon,
blackjacksHit: player.blackjacksHit,
finalBankroll: player.bankroll,
});
}
if (results.length === 0 && refunds.length === 0) return;
try {
const config = (await import("@payload-config")).default;
const payload = await getPayload({ config });
try {
if (results.length > 0) {
await awardBlackjackResult(payload, results);
}
for (const refund of refunds) {
if (refund.amount <= 0) continue;
try {
await creditChips(payload, refund.userId, refund.amount);
} catch (error) {
payload.logger.error(
`[blackjack] Failed to settle chips for user ${refund.userId}: ${error}`,
);
}
}
} catch (error) {
payload.logger.error(
`[blackjack] Failed to award session results for table ${record.id}: ${error}`,
);
}
} catch {
// Payload unavailable in this runtime; skip awarding.
}
}
/** Award every not-yet-awarded seated player (disposal / force expiry). */
function awardAllRemaining(record: BlackjackTableRecord): void {
const userIds = record.seatMap.filter((id): id is number => id !== null);
void awardSessionResults(record, userIds);
}
/** Shared post-mutation pipeline: timers, award triggers, push. */
function afterMutation(record: BlackjackTableRecord): void {
const state = record.state;
if (state && state.phase === "round-complete") {
clearTurnTimer(record);
scheduleNextRound(record);
} else {
scheduleTurnTimer(record);
}
pushTable(record);
}
function buildSnapshot(record: BlackjackTableRecord, userId: number): BlackjackSnapshot {
const seatIndex = record.seatMap.indexOf(userId);
const seated = seatIndex >= 0 && !record.leftUsers.has(userId);
const spectator = !seated && record.spectators.has(userId);
const state = record.state;
const inEngine = seated && state !== null && seatIndex < state.players.length;
let base = state && inEngine ? buildPrivateView(state, seatIndex) : null;
if (base === null && spectator && state) {
// Spectators receive the public view with no seat: the synthetic viewer
// seat (-1) never matches a real player, so all seats render as others.
base = buildPrivateView(state, -1);
}
const you =
base === null
? null
: {
...base,
players: base.players.map((player) => {
const playerUserId = record.seatMap[player.seatId];
if (playerUserId === null || !record.leftUsers.has(playerUserId)) return player;
return { ...player, left: true };
}),
};
const hostSeat = record.hostUserId !== null ? record.seatMap.indexOf(record.hostUserId) : -1;
return {
tableId: record.id,
name: record.name,
version: record.version,
turnDeadline: record.turnDeadlineAt,
nextRoundAt: record.nextRoundAt,
you,
seats: [...record.seatMap],
names: record.seatMap.map((id) => (id === null ? null : (record.userNames.get(id) ?? null))),
hostSeatId: hostSeat >= 0 ? hostSeat : null,
spectator: spectator || undefined,
inGame: record.inGame || undefined,
};
}
/** Push a per-user snapshot to every seated user and spectator, then refresh the lobby. */
function pushTable(record: BlackjackTableRecord): void {
for (const userId of record.seatMap) {
if (userId === null || record.leftUsers.has(userId)) continue;
broadcastToUser("blackjack-state", userId, {
tableId: record.id,
snapshot: buildSnapshot(record, userId),
});
}
for (const spectatorId of record.spectators) {
broadcastToUser("blackjack-state", spectatorId, {
tableId: record.id,
snapshot: buildSnapshot(record, spectatorId),
});
}
broadcast("blackjack-tables", { tables: listSummaries() });
}
/**
* Remove the table entirely. Session stats are awarded for every player who
* played at least one round and has not been awarded yet, so closed tabs and
* emptied tables still record their sessions.
*/
function disposeTable(record: BlackjackTableRecord): void {
clearTurnTimer(record);
clearNextRoundTimer(record);
awardAllRemaining(record);
void settleUnsettledChips(record);
tables.delete(record.id);
broadcast("blackjack-tables", { tables: listSummaries() });
}
/** Payload handle, or null when the config cannot load (tests, early boot). */
async function getPayloadOrNull(): Promise<Payload | null> {
try {
const config = (await import("@payload-config")).default;
return await getPayload({ config });
} catch {
return null;
}
}
/**
* Credit the current bankroll (or an unseated pending joiner's full buy-in)
* back to the pool for every bought-in player not yet settled. Marks each
* user settled synchronously so concurrent teardowns cannot double-credit.
*/
async function settleUnsettledChips(record: BlackjackTableRecord): Promise<void> {
const candidates = [...record.chipBoughtIn, ...record.pendingChipPaid];
const state = record.state;
const credits: { userId: number; amount: number }[] = [];
for (const userId of candidates) {
if (record.chipSettled.has(userId)) continue;
const seatIndex = record.seatMap.indexOf(userId);
const player = state && seatIndex >= 0 ? state.players[seatIndex] : undefined;
const amount = player ? player.bankroll : BUY_IN;
record.chipSettled.add(userId);
credits.push({ userId, amount });
}
if (credits.length === 0) return;
const payload = await getPayloadOrNull();
if (!payload) return;
for (const credit of credits) {
if (credit.amount <= 0) continue;
try {
await creditChips(payload, credit.userId, credit.amount);
} catch (error) {
payload.logger.error(
`[blackjack] Failed to settle chips for user ${credit.userId}: ${error}`,
);
}
}
}
/** In-app bell notification when a pending joiner cannot cover the buy-in. */
async function notifyBuyInFailed(record: BlackjackTableRecord, userId: number): Promise<void> {
try {
const payload = await getPayloadOrNull();
if (!payload) return;
await notifyUser(payload, {
userId,
type: "blackjack:buy-in-failed",
title: "Not enough chips",
message: `You need ${BUY_IN} chips to join the next round. Play the other minigames to earn more chips.`,
link: "/minigames/blackjack",
});
} catch {
// Notifications are best-effort.
}
}
/**
* Buy-in-aware wrappers used by the server actions. The sync core above
* (startGame/joinTable/leaveTable) stays registry-pure for tests; pool
* accounting lives here where a Payload handle is available.
*/
/**
* Start a session: settle any unsettled chips from a previous session on
* this record, collect the buy-in from every seated player (all-or-nothing),
* then start. Throws without moving chips when anyone cannot cover.
*/
export async function startGameWithBuyIn(
tableId: string,
userId: number,
): Promise<BlackjackSnapshot> {
const record = tables.get(tableId);
if (!record) throw new Error("Table not found.");
await settleUnsettledChips(record);
const seated = record.seatMap.filter(
(id): id is number => id !== null && !record.leftUsers.has(id),
);
const payload = await getPayloadOrNull();
const paid: number[] = [];
try {
if (payload) {
for (const seatedId of seated) {
const bank = await readChipBankState(payload, seatedId);
if (!bank) continue; // Untracked user (no profile): plays without pool accounting.
if (bank.chipBank < BUY_IN) {
const name = record.userNames.get(seatedId) ?? `Player ${seatedId}`;
throw new Error(
`${name} needs ${BUY_IN} chips to sit down and has ${bank.chipBank}. Play the other minigames to earn more chips.`,
);
}
}
for (const seatedId of seated) {
const result = await spendChipBuyIn(payload, seatedId, BUY_IN);
if (result.settled) paid.push(seatedId);
}
}
const snapshot = startGame(tableId, userId);
record.chipBoughtIn = new Set(paid);
record.chipSettled = new Set();
record.pendingChipPaid = new Set();
return snapshot;
} catch (error) {
if (paid.length > 0 && payload) {
for (const paidId of paid) {
try {
await creditChips(payload, paidId, BUY_IN);
} catch {
// Best-effort refund.
}
}
}
throw error;
}
}
/**
* Join a table: mid-game joins pay the buy-in up front (pendingChipPaid) so
* they can seat at the next round; the payment refunds if the join fails.
*/
export async function joinTableWithBuyIn(
tableId: string,
userId: number,
displayName: string,
): Promise<BlackjackSnapshot> {
const record = tables.get(tableId);
if (!record) throw new Error("Table not found.");
if (record.inGame && !record.pendingChipPaid.has(userId)) {
const payload = await getPayloadOrNull();
if (payload) {
const bank = await readChipBankState(payload, userId);
if (bank && bank.chipBank < BUY_IN) {
throw new Error(
`You need ${BUY_IN} chips to join this table mid-game and have ${bank.chipBank}. Play the other minigames to earn more chips.`,
);
}
if (bank) {
await spendChipBuyIn(payload, userId, BUY_IN);
record.pendingChipPaid.add(userId);
try {
return joinTable(tableId, userId, displayName);
} catch (error) {
record.pendingChipPaid.delete(userId);
try {
await creditChips(payload, userId, BUY_IN);
} catch {
// Best-effort refund.
}
throw error;
}
}
}
}
return joinTable(tableId, userId, displayName);
}
/** Leave a table: pending joiners who paid but never seated get their buy-in back. */
export async function leaveTableWithBuyIn(tableId: string, userId: number): Promise<void> {
const record = tables.get(tableId);
if (!record) throw new Error("Table not found.");
const wasPending = record.pendingChipPaid.has(userId);
leaveTable(tableId, userId);
if (!wasPending) return;
record.pendingChipPaid.delete(userId);
const payload = await getPayloadOrNull();
if (!payload) return;
try {
await creditChips(payload, userId, BUY_IN);
} catch (error) {
payload.logger.error(
`[blackjack] Failed to refund pending buy-in for user ${userId}: ${error}`,
);
}
}
export function forceExpireTable(tableId: string, payload: Payload): void {
const record = tables.get(tableId);
if (!record) throw new Error("Table not found.");
for (const userId of record.seatMap) {
if (userId === null || record.leftUsers.has(userId)) continue;
broadcastToUser("blackjack-terminated", userId, {
tableId: record.id,
reason: "force-ended",
});
try {
void notifyUser(payload, {
userId,
type: "blackjack:force-ended",
title: "Blackjack game ended",
message: "An administrator ended your blackjack game.",
link: "/minigames/blackjack",
});
} catch (error) {
payload.logger.error(
`[blackjack] Failed to notify user ${userId} for table ${record.id}: ${error}`,
);
}
}
disposeTable(record);
}
/** Tables with nobody seated (everyone left) are removed immediately. */
function scheduleDisposalIfEmpty(record: BlackjackTableRecord): void {
if (seatedCountOf(record) > 0) return;
disposeTable(record);
}
function promoteHost(record: BlackjackTableRecord): void {
const nextHost = record.seatMap.find((id) => id !== null && !record.leftUsers.has(id));
record.hostUserId = nextHost ?? null;
}
export function createTable(userId: number, displayName: string): string {
if (tables.size >= MAX_TABLES) {
throw new Error("The maximum number of blackjack tables exists. Try again later.");
}
const seatMap: (number | null)[] = [userId];
while (seatMap.length < MAX_PLAYERS) seatMap.push(null);
const record: BlackjackTableRecord = {
id: crypto.randomUUID(),
name: generateTableName(),
version: 1,
hostUserId: userId,
seatMap,
state: null,
inGame: false,
awardedUsers: new Set(),
chipBoughtIn: new Set(),
chipSettled: new Set(),
pendingChipPaid: new Set(),
turnTimer: null,
turnTimerVersion: null,
turnDeadlineAt: null,
nextRoundTimer: null,
nextRoundAt: null,
pendingJoins: [],
awayUsers: new Set(),
leftUsers: new Set(),
spectators: new Set(),
userNames: new Map([[userId, displayName]]),
createdAt: new Date().toISOString(),
lastActivityAt: Date.now(),
};
tables.set(record.id, record);
pushTable(record);
return record.id;
}
export function listSummaries(): BlackjackTableSummary[] {
return [...tables.values()].map((record) => ({
tableId: record.id,
name: record.name,
hostName:
record.hostUserId !== null
? (record.userNames.get(record.hostUserId) ?? "Unknown")
: "Unknown",
seatedCount: seatedCountOf(record),
maxSeats: MAX_PLAYERS,
inGame: record.inGame,
createdAt: record.createdAt,
}));
}
export function getSnapshotFor(tableId: string, userId: number): BlackjackSnapshot {
const record = tables.get(tableId);
if (!record) throw new Error("Table not found.");
record.lastActivityAt = Date.now();
return buildSnapshot(record, userId);
}
export function joinTable(tableId: string, userId: number, displayName: string): BlackjackSnapshot {
const record = tables.get(tableId);
if (!record) throw new Error("Table not found.");
if (record.leftUsers.has(userId)) {
throw new Error("You already left this table. You can rejoin when a new game starts.");
}
if (isSeatedAt(record, userId)) return buildSnapshot(record, userId);
for (const other of tables.values()) {
if (other.id !== record.id && isSeatedAt(other, userId)) {
throw new Error("You are already seated at another table. Leave it first.");
}
}
const slot = record.seatMap.indexOf(null);
if (slot < 0) throw new Error("The table is full.");
record.spectators.delete(userId);
record.seatMap[slot] = userId;
record.userNames.set(userId, displayName);
record.lastActivityAt = Date.now();
if (record.inGame) record.pendingJoins.push({ userId, name: displayName });
scheduleDisposalIfEmpty(record);
record.version += 1;
rekeyTurnTimer(record);
pushTable(record);
return buildSnapshot(record, userId);
}
/** Register a spectator on a running game; they get the public view with no seat. */
export function spectateTable(tableId: string, userId: number): BlackjackSnapshot {
const record = tables.get(tableId);
if (!record) throw new Error("Table not found.");
if (isSeatedAt(record, userId)) return buildSnapshot(record, userId);
if (!record.inGame || !record.state) {
throw new Error("No game is running at this table.");
}
record.spectators.add(userId);
record.lastActivityAt = Date.now();
return buildSnapshot(record, userId);
}
export function leaveTable(tableId: string, userId: number): void {
const record = tables.get(tableId);
if (!record) throw new Error("Table not found.");
if (record.spectators.delete(userId)) {
// Spectators just stop watching; no seat logic applies.
record.lastActivityAt = Date.now();
return;
}
if (!isSeatedAt(record, userId)) throw new Error("You are not seated at this table.");
if (record.inGame) {
// Mid-game: keep the seat visible; the player is dropped (and their
// session awarded) when the next betting round starts.
record.leftUsers.add(userId);
record.awayUsers.add(userId);
} else {
const seatIndex = record.seatMap.indexOf(userId);
record.seatMap = record.seatMap.filter((_, index) => index !== seatIndex);
while (record.seatMap.length < MAX_PLAYERS) record.seatMap.push(null);
record.pendingJoins = record.pendingJoins.filter((pending) => pending.userId !== userId);
}
if (record.hostUserId === userId) promoteHost(record);
record.lastActivityAt = Date.now();
record.version += 1;
rekeyTurnTimer(record);
scheduleDisposalIfEmpty(record);
if (record.inGame && seatedCountOf(record) === 1) {
void notifySoloPlayer(record);
}
pushTable(record);
}
export function startGame(tableId: string, userId: number): BlackjackSnapshot {
const record = tables.get(tableId);
if (!record) throw new Error("Table not found.");
if (record.hostUserId !== userId) throw new Error("Only the host can start the game.");
const seated = record.seatMap.filter(
(id): id is number => id !== null && !record.leftUsers.has(id),
);
if (seated.length < MIN_PLAYERS) {
throw new Error("At least one seated player is needed to start the game.");
}
// Compact the seat map for the new game: leavers are out, order preserved.
// Pad a COPY: aliasing `seated` here once spawned phantom "Player null" seats.
const padded: (number | null)[] = [...seated];
while (padded.length < MAX_PLAYERS) padded.push(null);
record.seatMap = padded;
record.leftUsers.clear();
record.awayUsers.clear();
record.pendingJoins = [];
record.awardedUsers = new Set();
record.nextRoundAt = null;
if (record.nextRoundTimer) {
clearTimeout(record.nextRoundTimer);
record.nextRoundTimer = null;
}
record.state = createTableState(
seated.map((id) => ({ name: record.userNames.get(id) ?? `Player ${id}` })),
{ startingBankroll: BUY_IN },
undefined,
);
record.inGame = true;
record.lastActivityAt = Date.now();
record.version += 1;
afterMutation(record);
return buildSnapshot(record, userId);
}
export function renameTable(tableId: string, userId: number, rawName: string): BlackjackSnapshot {
const record = tables.get(tableId);
if (!record) throw new Error("Table not found.");
if (record.hostUserId !== userId) throw new Error("Only the host can rename the table.");
const name = rawName.trim().replace(/\s+/g, " ").slice(0, MAX_TABLE_NAME_LENGTH).trim();
record.name = name.length > 0 ? name : null;
record.lastActivityAt = Date.now();
record.version += 1;
pushTable(record);
return buildSnapshot(record, userId);
}
export function applyBlackjackAction(
tableId: string,
userId: number,
kind: BlackjackActionKind,
amount?: number,
): BlackjackSnapshot {
const record = tables.get(tableId);
if (!record) throw new Error("Table not found.");
const state = record.state;
if (!state) throw new Error("The game has not started yet.");
if (record.leftUsers.has(userId)) throw new Error("You are not seated at this table.");
const seatIndex = record.seatMap.indexOf(userId);
if (seatIndex < 0) throw new Error("You are not seated at this table.");
if (seatIndex >= state.players.length) {
throw new Error("You will join the game at the next round.");
}
const result = applyAction(state, seatIndex, kind, amount);
if (!result.success) throw new Error(result.error);
record.state = result.state;
record.lastActivityAt = Date.now();
record.version += 1;
afterMutation(record);
return buildSnapshot(record, userId);
}
/**
* Sweeps tables whose last client interaction is older than TABLE_EXPIRY_MS:
* tabs closed without leaving, abandoned waiting rooms, gone games. Session
* stats are awarded on disposal. Safe to run from several module copies in
* dev because the store is shared via globalThis and disposal is idempotent.
*/
function sweepExpiredTables(): void {
const now = Date.now();
let expired = false;
for (const record of tables.values()) {
if (now - record.lastActivityAt <= TABLE_EXPIRY_MS) continue;
disposeTable(record);
expired = true;
}
if (expired) broadcast("blackjack-tables", { tables: listSummaries() });
}
const sweepInterval = setInterval(sweepExpiredTables, TABLE_SWEEP_INTERVAL_MS);
sweepInterval.unref?.();
/**
* Test-only helper: dispose every table without awards so vitest files can
* start each run against a clean registry.
*/
export function clearTablesForTests(): void {
for (const record of tables.values()) {
clearTurnTimer(record);
clearNextRoundTimer(record);
}
tables.clear();
}

View file

@ -0,0 +1,215 @@
/**
* Shared types for the blackjack engine.
*
* Every type is plain JSON-serializable data: the whole table state can be
* stored in a registry Map, sent over SSE, and rebuilt on the client without
* any transformation. No Payload, Next.js, or process imports here.
*/
export type Suit = "s" | "h" | "d" | "c";
/** 2 through 10, then 11 = jack, 12 = queen, 13 = king, 14 = ace. */
export type Rank = 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14;
export interface Card {
readonly rank: Rank;
readonly suit: Suit;
}
/** Seat index into `BlackjackTableState.players`; seatId === array index. */
export type BlackjackSeatId = number;
export type BlackjackActionKind =
| "bet"
| "insurance"
| "decline-insurance"
| "hit"
| "stand"
| "double"
| "split";
/**
* Lifecycle of a round:
* - "betting": players place (or skip) their bets one at a time.
* - "insurance": dealer shows an ace; players with a bet take or decline.
* - "player": players act on their hands (hit/stand/double/split).
* - "dealer": transient; the dealer draws and the round settles immediately.
* - "round-complete": results attached; the registry pauses before the next
* betting round. Rounds repeat indefinitely until the session ends.
*/
export type BlackjackPhase = "betting" | "insurance" | "player" | "dealer" | "round-complete";
/** Per-hand lifecycle within a round. */
export type BlackjackHandStatus = "active" | "stood" | "bust" | "blackjack";
export type BlackjackOutcome = "win" | "lose" | "push" | "blackjack";
export interface BlackjackTableConfig {
readonly startingBankroll: number;
readonly minBet: number;
readonly maxBet: number;
readonly turnTimeoutMs: number;
}
export interface BlackjackHand {
readonly cards: readonly Card[];
/** Stake deducted from the bankroll when the hand was created. */
readonly bet: number;
readonly status: BlackjackHandStatus;
readonly doubled: boolean;
/** True when this hand was created by a split (never counts as natural). */
readonly fromSplit: boolean;
/** True when this hand came from splitting aces (one card, auto-stood). */
readonly splitAces: boolean;
/** Attached at settle; null while the round is live. */
readonly outcome: BlackjackOutcome | null;
/** Winnings excluding the returned stake (negative on a loss). */
readonly netChange: number | null;
}
export interface BlackjackPlayerState {
readonly seatId: BlackjackSeatId;
readonly name: string;
/** Chips not currently wagered; bets are deducted immediately. */
readonly bankroll: number;
readonly hands: readonly BlackjackHand[];
/** Insurance stake for the current round (deducted when taken). */
readonly insuranceBet: number;
/** Whether the player answered the insurance offer this round. */
readonly insuranceAnswered: boolean;
/** True once the player bet this round. */
readonly hasBet: boolean;
/** True when the player skipped (or timed out of) the current round. */
readonly sittingOut: boolean;
/** Bankroll fell below the table minimum; removed at the next round start. */
readonly bankrupt: boolean;
/** Session counters, accumulated across rounds for the stats award. */
readonly roundsPlayed: number;
readonly roundsWon: number;
readonly blackjacksHit: number;
}
export interface DealerState {
readonly cards: readonly Card[];
/** True once the hole card has been revealed (peek or dealer play). */
readonly holeRevealed: boolean;
}
export interface BlackjackRoundHandResult {
readonly seatId: BlackjackSeatId;
readonly handIndex: number;
readonly total: number;
readonly outcome: BlackjackOutcome;
/** Winnings excluding the returned stake (0 on losses and pushes). */
readonly payout: number;
/** payout minus bet on wins; negative bet on losses; 0 on pushes. */
readonly netChange: number;
}
export interface BlackjackRoundInsuranceResult {
readonly seatId: BlackjackSeatId;
readonly amount: number;
readonly won: boolean;
}
export interface RoundOutcome {
readonly roundNumber: number;
readonly dealerTotal: number;
readonly dealerBusted: boolean;
readonly dealerHasBlackjack: boolean;
readonly hands: readonly BlackjackRoundHandResult[];
readonly insurance: readonly BlackjackRoundInsuranceResult[];
}
export interface BlackjackTableState {
readonly config: BlackjackTableConfig;
/**
* Base seed for shoe shuffling. Each round derives its own seed from this
* (see shoe.ts), so a session is fully reproducible from the seed alone.
*/
readonly seed: number;
/** 1-based round counter; 1 is set by createTableState. */
readonly roundNumber: number;
readonly players: readonly BlackjackPlayerState[];
readonly shoe: readonly Card[];
readonly dealer: DealerState;
readonly phase: BlackjackPhase;
/** Seat that must act (bet, answer insurance, or play a hand). */
readonly toActSeat: BlackjackSeatId | null;
/** Index of the acting player's hand currently awaiting an action. */
readonly activeHandIndex: number;
readonly roundResult: RoundOutcome | null;
}
/** What the acting player is allowed to do right now. */
export interface BlackjackLegalActions {
readonly seatId: BlackjackSeatId;
readonly phase: BlackjackPhase;
readonly activeHandIndex: number;
readonly canBet: boolean;
readonly minBet: number;
/** Table max capped by the bankroll (what can actually be wagered). */
readonly maxBet: number;
readonly canHit: boolean;
readonly canStand: boolean;
readonly canDouble: boolean;
readonly canSplit: boolean;
readonly canInsure: boolean;
readonly insuranceMax: number;
}
export interface PublicHandView {
readonly cards: readonly Card[];
readonly bet: number;
readonly status: BlackjackHandStatus;
readonly doubled: boolean;
readonly fromSplit: boolean;
readonly splitAces: boolean;
readonly total: number;
readonly outcome: BlackjackOutcome | null;
readonly netChange: number | null;
}
export interface PublicPlayerView {
readonly seatId: BlackjackSeatId;
readonly name: string;
readonly bankroll: number;
readonly hands: readonly PublicHandView[];
readonly insuranceBet: number;
readonly insuranceAnswered: boolean;
readonly hasBet: boolean;
readonly sittingOut: boolean;
readonly bankrupt: boolean;
/** True from the moment the user leaves mid-round (registry sets this). */
readonly left: boolean;
readonly roundsPlayed: number;
readonly roundsWon: number;
readonly blackjacksHit: number;
}
export interface PublicDealerView {
/** Only the upcard while the hole card is hidden. */
readonly cards: readonly Card[];
readonly holeHidden: boolean;
/** Visible total (null while the hole card is hidden). */
readonly total: number | null;
readonly hasBlackjack: boolean | null;
}
export interface PublicTableView {
readonly players: readonly PublicPlayerView[];
readonly dealer: PublicDealerView;
readonly phase: BlackjackPhase;
readonly roundNumber: number;
readonly activeSeat: BlackjackSeatId | null;
readonly activeHandIndex: number;
readonly config: BlackjackTableConfig;
readonly roundResult: RoundOutcome | null;
}
export interface PrivatePlayerView extends PublicTableView {
readonly viewerSeat: BlackjackSeatId;
readonly yourHands: readonly PublicHandView[];
readonly legalActions: BlackjackLegalActions;
readonly yourTurn: boolean;
}

View file

@ -0,0 +1,157 @@
/**
* Projection helpers that turn the full table state into safe views.
*
* The full state (including the shoe order and the dealer hole card) must
* never leave the server; clients only ever receive these views. Player
* cards are all face up at a blackjack table, so the only hidden card is the
* dealer's hole card until it is revealed. Spectators use the synthetic
* viewer seat -1, which never matches a real player.
*/
import { canDoubleHand, canSplitHand, handValue, insuranceMaxFor } from "./rules";
import type {
BlackjackLegalActions,
BlackjackSeatId,
BlackjackTableState,
PrivatePlayerView,
PublicDealerView,
PublicHandView,
PublicPlayerView,
PublicTableView,
} from "./types";
/** Largest legal insurance stake right now: capped at half the bet and the bankroll. */
function insuranceCapFor(mainBet: number, bankroll: number): number {
return Math.min(insuranceMaxFor(mainBet), bankroll);
}
/** What the acting player is allowed to do right now. */
export function getLegalActions(
state: BlackjackTableState,
seatId: BlackjackSeatId,
): BlackjackLegalActions {
const player = state.players[seatId];
const isTurn = state.toActSeat === seatId;
const phase = state.phase;
const hand = player?.hands[state.activeHandIndex] ?? null;
const canBet =
phase === "betting" &&
isTurn &&
player !== undefined &&
!player.bankrupt &&
!player.hasBet &&
!player.sittingOut;
const maxBet = Math.min(state.config.maxBet, player?.bankroll ?? 0);
const insuranceCap = insuranceCapFor(player?.hands[0]?.bet ?? 0, player?.bankroll ?? 0);
const canInsure =
phase === "insurance" &&
isTurn &&
player !== undefined &&
player.hasBet &&
!player.insuranceAnswered &&
insuranceCap > 0;
return {
seatId,
phase,
activeHandIndex: state.activeHandIndex,
canBet,
minBet: state.config.minBet,
maxBet,
canHit: phase === "player" && isTurn && hand?.status === "active",
canStand: phase === "player" && isTurn && hand?.status === "active",
canDouble:
phase === "player" && isTurn && hand !== null && canDoubleHand(hand, player?.bankroll ?? 0),
canSplit:
phase === "player" &&
isTurn &&
hand !== null &&
canSplitHand(player?.hands ?? [], hand, player?.bankroll ?? 0),
canInsure,
insuranceMax: canInsure ? insuranceCap : 0,
};
}
function projectHand(hand: BlackjackTableState["players"][number]["hands"][number]): PublicHandView {
return {
cards: hand.cards,
bet: hand.bet,
status: hand.status,
doubled: hand.doubled,
fromSplit: hand.fromSplit,
splitAces: hand.splitAces,
total: handValue(hand.cards).total,
outcome: hand.outcome,
netChange: hand.netChange,
};
}
function projectPlayer(
player: BlackjackTableState["players"][number],
): PublicPlayerView {
return {
seatId: player.seatId,
name: player.name,
bankroll: player.bankroll,
hands: player.hands.map(projectHand),
insuranceBet: player.insuranceBet,
insuranceAnswered: player.insuranceAnswered,
hasBet: player.hasBet,
sittingOut: player.sittingOut,
bankrupt: player.bankrupt,
// The engine has no leave concept; the registry layer overrides this
// for users recorded in leftUsers (see buildSnapshot in tables.ts).
left: false,
roundsPlayed: player.roundsPlayed,
roundsWon: player.roundsWon,
blackjacksHit: player.blackjacksHit,
};
}
function projectDealer(dealer: BlackjackTableState["dealer"]): PublicDealerView {
const cards = dealer.holeRevealed ? dealer.cards : dealer.cards.slice(0, 1);
return {
cards,
holeHidden: !dealer.holeRevealed,
total: dealer.holeRevealed ? handValue(dealer.cards).total : null,
hasBlackjack: dealer.holeRevealed ? handValue(dealer.cards).total === 21 && dealer.cards.length === 2 : null,
};
}
/**
* Public view of the table. Player cards are always face up; the dealer's
* hole card stays hidden until reveal. The viewer seat exists only so the
* registry can mark the caller's own flags in buildSnapshot.
*/
export function buildPublicView(
state: BlackjackTableState,
_viewerSeat: BlackjackSeatId | null,
): PublicTableView {
return {
players: state.players.map(projectPlayer),
dealer: projectDealer(state.dealer),
phase: state.phase,
roundNumber: state.roundNumber,
activeSeat: state.toActSeat,
activeHandIndex: state.activeHandIndex,
config: state.config,
roundResult: state.roundResult,
};
}
/** Everything one seated player is allowed to know, including their options. */
export function buildPrivateView(
state: BlackjackTableState,
seatId: BlackjackSeatId,
): PrivatePlayerView {
const publicView = buildPublicView(state, seatId);
const player = state.players[seatId];
return {
...publicView,
viewerSeat: seatId,
yourHands: player?.hands.map(projectHand) ?? [],
legalActions: getLegalActions(state, seatId),
yourTurn: state.toActSeat === seatId && state.phase !== "round-complete",
};
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,31 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TYPE "public"."enum_users_preferences_notifications_muted_types" ADD VALUE 'blackjack:force-ended';
ALTER TYPE "public"."enum_users_preferences_notifications_muted_types" ADD VALUE 'blackjack:everyone-left';
ALTER TYPE "public"."enum_users_preferences_discord_muted_types" ADD VALUE 'blackjack:force-ended';
ALTER TYPE "public"."enum_users_preferences_discord_muted_types" ADD VALUE 'blackjack:everyone-left';
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_blackjack_games_played" numeric DEFAULT 0;
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_blackjack_rounds_won" numeric DEFAULT 0;
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_blackjack_blackjacks_hit" numeric DEFAULT 0;
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_blackjack_best_score" numeric DEFAULT 0;
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_blackjack_xp_earned" numeric DEFAULT 0;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "users_preferences_notifications_muted_types" ALTER COLUMN "value" SET DATA TYPE text;
DROP TYPE "public"."enum_users_preferences_notifications_muted_types";
CREATE TYPE "public"."enum_users_preferences_notifications_muted_types" AS ENUM('market:offer', 'market:counter', 'market:accept', 'market:reject', 'market:withdrawn', 'market:closed', 'market:sold', 'market:expired', 'award:granted', 'account:discord-request', 'finance:deposit', 'finance:withdraw', 'finance:transfer', 'shipment:completed', 'ticket:created', 'ticket:assigned', 'ticket:reply', 'ticket:status', 'poker:force-ended', 'poker:everyone-left');
ALTER TABLE "users_preferences_notifications_muted_types" ALTER COLUMN "value" SET DATA TYPE "public"."enum_users_preferences_notifications_muted_types" USING "value"::"public"."enum_users_preferences_notifications_muted_types";
ALTER TABLE "users_preferences_discord_muted_types" ALTER COLUMN "value" SET DATA TYPE text;
DROP TYPE "public"."enum_users_preferences_discord_muted_types";
CREATE TYPE "public"."enum_users_preferences_discord_muted_types" AS ENUM('market:offer', 'market:counter', 'market:accept', 'market:reject', 'market:withdrawn', 'market:closed', 'market:sold', 'market:expired', 'award:granted', 'account:discord-request', 'finance:deposit', 'finance:withdraw', 'finance:transfer', 'shipment:completed', 'ticket:created', 'ticket:assigned', 'ticket:reply', 'ticket:status', 'poker:force-ended', 'poker:everyone-left');
ALTER TABLE "users_preferences_discord_muted_types" ALTER COLUMN "value" SET DATA TYPE "public"."enum_users_preferences_discord_muted_types" USING "value"::"public"."enum_users_preferences_discord_muted_types";
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_blackjack_games_played";
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_blackjack_rounds_won";
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_blackjack_blackjacks_hit";
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_blackjack_best_score";
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_blackjack_xp_earned";`)
}

View file

@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import type { Card } from "@/lib/minigames/blackjack";
import { mapCardToSvg, normalizeCardStyle } from "@/components/frontend/minigames/blackjack/cardStyle";
function cardOf(rank: Card["rank"], suit: Card["suit"]): Card {
return { rank, suit };
}
describe("mapCardToSvg", () => {
it("maps number cards to their numbered asset path", () => {
expect(mapCardToSvg(cardOf(7, "h"))).toBe("/images/assets/playing-cards/7_of_hearts.svg");
expect(mapCardToSvg(cardOf(10, "s"))).toBe("/images/assets/playing-cards/10_of_spades.svg");
expect(mapCardToSvg(cardOf(2, "c"))).toBe("/images/assets/playing-cards/2_of_clubs.svg");
});
it("maps face ranks to word asset paths", () => {
expect(mapCardToSvg(cardOf(11, "d"))).toBe("/images/assets/playing-cards/jack_of_diamonds.svg");
expect(mapCardToSvg(cardOf(12, "c"))).toBe("/images/assets/playing-cards/queen_of_clubs.svg");
expect(mapCardToSvg(cardOf(13, "h"))).toBe("/images/assets/playing-cards/king_of_hearts.svg");
expect(mapCardToSvg(cardOf(14, "s"))).toBe("/images/assets/playing-cards/ace_of_spades.svg");
});
it("covers all four suits for a spot-check rank", () => {
expect(mapCardToSvg(cardOf(9, "s"))).toBe("/images/assets/playing-cards/9_of_spades.svg");
expect(mapCardToSvg(cardOf(9, "h"))).toBe("/images/assets/playing-cards/9_of_hearts.svg");
expect(mapCardToSvg(cardOf(9, "d"))).toBe("/images/assets/playing-cards/9_of_diamonds.svg");
expect(mapCardToSvg(cardOf(9, "c"))).toBe("/images/assets/playing-cards/9_of_clubs.svg");
});
});
describe("normalizeCardStyle", () => {
it("accepts the three known style values", () => {
expect(normalizeCardStyle("light")).toBe("light");
expect(normalizeCardStyle("dark")).toBe("dark");
expect(normalizeCardStyle("classic")).toBe("classic");
});
it("falls back to light for unknown or missing values", () => {
expect(normalizeCardStyle("bogus")).toBe("light");
expect(normalizeCardStyle(null)).toBe("light");
expect(normalizeCardStyle(undefined)).toBe("light");
expect(normalizeCardStyle(42)).toBe("light");
expect(normalizeCardStyle("")).toBe("light");
});
});

View file

@ -0,0 +1,546 @@
/**
* Pure engine tests for the blackjack rules engine.
*
* These are intentionally database-free: no Payload, no Next.js imports, no
* setup or teardown. Everything runs against the pure modules under
* src/lib/minigames/blackjack with injected, deterministic randomness.
*/
import { describe, expect, it } from "vitest";
import {
BLACKJACK_PAYOUT,
MAX_HANDS,
MAX_PLAYERS,
MIN_BET,
MIN_PLAYERS,
SHOE_DECKS,
STARTING_BANKROLL,
advanceState,
applyAction,
buildPrivateView,
createShuffledShoe,
createShoe,
createTableState,
dealerShouldHit,
getLegalActions,
handValue,
insuranceMaxFor,
isNaturalBlackjack,
isTenValue,
mulberry32,
needsShuffle,
settleRound,
shoePenetrationFloor,
sitOut,
startNextRound,
type BlackjackTableState,
type Card,
type Rank,
type Suit,
} from "@/lib/minigames/blackjack";
const c = (rank: Rank, suit: Suit): Card => ({ rank, suit });
/** Craft a shoe whose first cards come out in exactly the given order. */
function shoeOf(...cards: Card[]): Card[] {
return [...cards, ...createShoe().slice(0, 40)];
}
function makeTable(bankrolls?: number[], seed = 1): BlackjackTableState {
return createTableState(
(bankrolls ?? [STARTING_BANKROLL]).map((bankroll, index) => ({
name: `P${index + 1}`,
bankroll,
})),
{},
mulberry32(seed),
);
}
function soloTable(shoe: Card[], bankroll = STARTING_BANKROLL): BlackjackTableState {
const base = createTableState([{ name: "Solo", bankroll }], {}, mulberry32(1));
return { ...base, shoe };
}
function act(
state: BlackjackTableState,
seatId: number,
action: Parameters<typeof applyAction>[2],
amount?: number,
): BlackjackTableState {
const result = applyAction(state, seatId, action, amount);
if (!result.success) throw new Error(`Unexpected action failure: ${result.error}`);
return result.state;
}
function expectFailure(
state: BlackjackTableState,
seatId: number,
action: Parameters<typeof applyAction>[2],
amount?: number,
messagePart?: string,
): void {
const result = applyAction(state, seatId, action, amount);
expect(result.success).toBe(false);
if (!result.success && messagePart) {
expect(result.error).toContain(messagePart);
}
}
/** Bet (defaults to the table minimum) and return the dealt-to state. */
function bet(state: BlackjackTableState, seat = state.toActSeat ?? 0, amount = MIN_BET) {
return act(state, seat, "bet", amount);
}
describe("shoe composition and reshuffle", () => {
it("builds a 6-deck shoe with 24 of every rank", () => {
const shoe = createShoe();
expect(shoe).toHaveLength(SHOE_DECKS * 52);
for (let rank = 2; rank <= 14; rank += 1) {
const count = shoe.filter((card) => card.rank === rank).length;
expect(count).toBe(24);
}
});
it("shuffles deterministically from a seed", () => {
const a = createShuffledShoe(7, 1);
const b = createShuffledShoe(7, 1);
const other = createShuffledShoe(8, 1);
expect(a).toEqual(b);
expect(a).not.toEqual(other);
});
it("flags a reshuffle once the shoe drops below the penetration floor", () => {
const floor = shoePenetrationFloor();
expect(floor).toBe(Math.floor(SHOE_DECKS * 52 * 0.25));
expect(needsShuffle(createShoe().slice(0, floor - 1))).toBe(true);
expect(needsShuffle(createShoe().slice(0, floor))).toBe(false);
expect(needsShuffle(createShoe())).toBe(false);
});
});
describe("hand values", () => {
it("counts aces as 11 while the total stays at or under 21", () => {
expect(handValue([c(14, "s"), c(6, "h")])).toEqual({ total: 17, soft: true });
expect(handValue([c(14, "s"), c(14, "h")])).toEqual({ total: 12, soft: true });
});
it("degrades aces to 1 when 11 would bust", () => {
expect(handValue([c(14, "s"), c(10, "h"), c(6, "d")])).toEqual({ total: 17, soft: false });
// Three aces: two degrade to 1, one still counts as 11 (a soft 13).
expect(handValue([c(14, "s"), c(14, "h"), c(14, "d")])).toEqual({ total: 13, soft: true });
});
it("reports bust totals over 21", () => {
expect(handValue([c(10, "s"), c(10, "h"), c(5, "d")]).total).toBe(25);
});
it("detects natural blackjacks (two cards totalling 21 only)", () => {
expect(isNaturalBlackjack([c(14, "s"), c(13, "h")])).toBe(true);
expect(isNaturalBlackjack([c(10, "s"), c(9, "h"), c(2, "d")])).toBe(false);
expect(isNaturalBlackjack([c(10, "s"), c(9, "h")])).toBe(false);
});
it("classifies ten-value ranks for the peek rule", () => {
expect(isTenValue(10)).toBe(true);
expect(isTenValue(13)).toBe(true);
expect(isTenValue(14)).toBe(false);
expect(isTenValue(9)).toBe(false);
});
it("stands the dealer on any 17 including soft 17 (S17)", () => {
expect(dealerShouldHit([c(10, "s"), c(7, "h")])).toBe(false);
expect(dealerShouldHit([c(14, "s"), c(7, "h")])).toBe(false);
expect(dealerShouldHit([c(14, "s"), c(3, "h"), c(3, "d")])).toBe(false);
expect(dealerShouldHit([c(10, "s"), c(6, "h")])).toBe(true);
expect(dealerShouldHit([c(14, "s"), c(5, "h")])).toBe(true);
});
});
describe("betting phase", () => {
it("deals as soon as the only player bets", () => {
const state = bet(soloTable(shoeOf(c(10, "s"), c(6, "h"), c(9, "s"), c(7, "d"))));
expect(state.phase).toBe("player");
expect(state.players[0]?.hands[0]?.cards).toHaveLength(2);
expect(state.dealer.cards).toHaveLength(2);
});
it("walks the betting turn across multiple players before dealing", () => {
let state = makeTable([STARTING_BANKROLL, STARTING_BANKROLL]);
expect(state.phase).toBe("betting");
expect(state.toActSeat).toBe(0);
state = bet(state);
expect(state.toActSeat).toBe(1);
expect(state.players[0]?.hasBet).toBe(true);
expect(state.players[1]?.hasBet).toBe(false);
state = bet(state);
expect(state.phase).not.toBe("betting");
expect(state.players[0]?.hands[0]?.cards).toHaveLength(2);
expect(state.players[1]?.hands[0]?.cards).toHaveLength(2);
});
it("rejects bets outside the limits, non-integers, and repeats", () => {
const state = makeTable([50, STARTING_BANKROLL]);
expectFailure(state, 0, "bet", MIN_BET - 1, "minimum bet");
expectFailure(state, 0, "bet", 501, "maximum bet");
expectFailure(state, 0, "bet", 10.5, "whole chips");
expectFailure(state, 0, "bet", 100, "bankroll");
const afterBet = bet(state);
expectFailure(afterBet, 0, "bet", MIN_BET, "turn");
});
it("sits a player out and continues the round without them", () => {
const state = makeTable([STARTING_BANKROLL, STARTING_BANKROLL]);
const sitResult = sitOut(state, 0);
if (!sitResult.success) throw new Error(sitResult.error);
const afterSit = sitResult.state;
expect(afterSit.players[0]?.sittingOut).toBe(true);
expect(afterSit.toActSeat).toBe(1);
const dealt = bet(afterSit);
expect(dealt.players[0]?.hands).toHaveLength(0);
expect(dealt.players[0]?.bankroll).toBe(STARTING_BANKROLL);
expect(dealt.players[1]?.hands[0]?.cards).toHaveLength(2);
expect(dealt.phase).toBe("player");
});
});
describe("player actions", () => {
it("stands and settles against the dealer", () => {
// Player 19 stands; dealer 6+10 = 16 draws the crafted 4 and lands on 20.
const state = bet(soloTable(shoeOf(c(10, "s"), c(6, "h"), c(9, "s"), c(10, "d"), c(4, "s"))));
expect(state.phase).toBe("player");
const settled = act(state, 0, "stand");
expect(settled.phase).toBe("round-complete");
const hand = settled.players[0]?.hands[0];
expect(hand?.outcome).toBe("lose");
expect(hand?.netChange).toBe(-MIN_BET);
expect(settled.players[0]?.bankroll).toBe(STARTING_BANKROLL - MIN_BET);
expect(settled.dealer.holeRevealed).toBe(true);
expect(settled.roundResult?.dealerTotal).toBe(20);
});
it("hits to a made hand and auto-stands on 21", () => {
const state = bet(soloTable(shoeOf(c(5, "s"), c(10, "h"), c(6, "s"), c(7, "d"), c(10, "d"))));
// 5 + 6 = 11; hit the crafted 10 -> 21 auto-stands.
const hit = act(state, 0, "hit");
expect(hit.players[0]?.hands[0]?.status).toBe("stood");
expect(handValue(hit.players[0]?.hands[0]?.cards ?? []).total).toBe(21);
});
it("busts on a hit and loses the stake", () => {
const state = bet(soloTable(shoeOf(c(10, "s"), c(10, "h"), c(9, "s"), c(7, "d"), c(5, "s"))));
const hit = act(state, 0, "hit");
expect(hit.players[0]?.hands[0]?.status).toBe("bust");
expect(hit.players[0]?.bankroll).toBe(STARTING_BANKROLL - MIN_BET);
expect(hit.phase).toBe("round-complete");
});
it("rejects acting out of turn", () => {
const state = makeTable([STARTING_BANKROLL, STARTING_BANKROLL]);
const dealt = bet(bet(state));
expectFailure(dealt, 1, "hit", undefined, "turn");
expect(getLegalActions(dealt, 0).canHit).toBe(true);
});
});
describe("double down", () => {
it("doubles the stake, draws one card, and auto-stands", () => {
const state = bet(soloTable(shoeOf(c(5, "s"), c(10, "h"), c(6, "s"), c(7, "d"), c(5, "s"))));
const doubled = act(state, 0, "double");
const hand = doubled.players[0]?.hands[0];
expect(hand?.bet).toBe(MIN_BET * 2);
expect(hand?.doubled).toBe(true);
expect(hand?.cards).toHaveLength(3);
expect(hand?.status).toBe("stood");
expect(doubled.players[0]?.bankroll).toBe(STARTING_BANKROLL - MIN_BET * 2);
});
it("refuses doubles without the bankroll to cover the extra stake", () => {
const poor = bet(
soloTable(shoeOf(c(10, "s"), c(6, "h"), c(9, "s"), c(7, "d")), MIN_BET + 5),
);
expectFailure(poor, 0, "double", undefined, "bankroll");
});
});
describe("splitting pairs", () => {
it("splits a pair into two funded hands and plays each", () => {
const state = bet(
soloTable(shoeOf(c(8, "s"), c(6, "h"), c(8, "h"), c(10, "d"), c(4, "s"), c(5, "s"))),
);
const split = act(state, 0, "split");
const hands = split.players[0]?.hands ?? [];
expect(hands).toHaveLength(2);
expect(hands[0]?.cards).toEqual([c(8, "s"), c(4, "s")]);
expect(hands[1]?.cards).toEqual([c(8, "h"), c(5, "s")]);
expect(split.players[0]?.bankroll).toBe(STARTING_BANKROLL - MIN_BET * 2);
expect(split.toActSeat).toBe(0);
expect(split.activeHandIndex).toBe(0);
// Play hand one to a stand, then hand two becomes active.
const stoodA = act(split, 0, "stand");
expect(stoodA.activeHandIndex).toBe(1);
const stoodB = act(stoodA, 0, "stand");
expect(stoodB.phase).toBe("round-complete");
expect(stoodB.roundResult?.hands).toHaveLength(2);
});
it("refuses non-matching ranks and overdrafted splits", () => {
const notPair = bet(soloTable(shoeOf(c(8, "s"), c(6, "h"), c(9, "h"), c(10, "d"))));
expectFailure(notPair, 0, "split", undefined, "matching ranks");
const poor = bet(
soloTable(shoeOf(c(8, "s"), c(6, "h"), c(8, "h"), c(10, "d")), MIN_BET + 5),
);
expectFailure(poor, 0, "split", undefined, "bankroll");
});
it("caps splitting at four hands", () => {
// Every split hand draws another 8 so the active hand keeps being a pair.
let state = bet(
soloTable(
shoeOf(
c(8, "s"), c(6, "h"), c(8, "h"), c(10, "d"),
c(8, "d"), c(3, "s"),
c(8, "c"), c(2, "s"),
c(8, "s"), c(2, "h"),
),
),
);
state = act(state, 0, "split");
expect(state.players[0]?.hands).toHaveLength(2);
state = act(state, 0, "split");
expect(state.players[0]?.hands).toHaveLength(3);
state = act(state, 0, "split");
expect(state.players[0]?.hands).toHaveLength(MAX_HANDS);
expectFailure(state, 0, "split", undefined);
});
it("gives split aces one card each and auto-stands both", () => {
const state = bet(
soloTable(shoeOf(c(14, "s"), c(6, "h"), c(14, "h"), c(10, "d"), c(9, "s"), c(5, "s"))),
);
const split = act(state, 0, "split");
const hands = split.players[0]?.hands ?? [];
expect(hands[0]?.splitAces).toBe(true);
expect(hands[0]?.status).toBe("stood");
expect(hands[1]?.status).toBe("stood");
expect(hands[0]?.cards).toHaveLength(2);
expect(hands[1]?.cards).toHaveLength(2);
expect(split.phase).toBe("round-complete");
});
});
describe("insurance and dealer peek", () => {
it("offers insurance only on an ace upcard and caps it at half the bet", () => {
const state = bet(soloTable(shoeOf(c(10, "s"), c(14, "h"), c(9, "s"), c(9, "d"))));
expect(state.phase).toBe("insurance");
expect(state.toActSeat).toBe(0);
expect(insuranceMaxFor(MIN_BET)).toBe(Math.floor(MIN_BET / 2));
expectFailure(state, 0, "insurance", Math.floor(MIN_BET / 2) + 1, "capped at");
const insured = act(state, 0, "insurance", Math.floor(MIN_BET / 2));
expect(insured.players[0]?.insuranceBet).toBe(Math.floor(MIN_BET / 2));
expect(insured.players[0]?.bankroll).toBe(
STARTING_BANKROLL - MIN_BET - Math.floor(MIN_BET / 2),
);
// The dealer's hole card (9) is no blackjack, so play continues.
expect(insured.phase).toBe("player");
});
it("pays insurance 2:1 when the dealer has blackjack and pushes naturals", () => {
const state = bet(soloTable(shoeOf(c(14, "s"), c(14, "h"), c(13, "s"), c(13, "h"))));
expect(state.phase).toBe("insurance");
// After the insurance answer the peek settles the round immediately:
// the player's natural pushes (stake back) and insurance pays 2:1.
const insured = act(state, 0, "insurance", 5);
expect(insured.phase).toBe("round-complete");
expect(insured.roundResult?.dealerHasBlackjack).toBe(true);
expect(insured.players[0]?.hands[0]?.outcome).toBe("push");
// 1000 - 10 (bet) - 5 (insurance) + 10 (push stake) + 5 + 10 (insurance 2:1)
expect(insured.players[0]?.bankroll).toBe(1010);
});
it("settles immediately on a ten-value upcard peek without insurance", () => {
const state = bet(soloTable(shoeOf(c(10, "s"), c(10, "h"), c(9, "s"), c(14, "d"))));
expect(state.phase).toBe("round-complete");
expect(state.roundResult?.dealerHasBlackjack).toBe(true);
expect(state.players[0]?.hands[0]?.outcome).toBe("lose");
expect(state.players[0]?.bankroll).toBe(STARTING_BANKROLL - MIN_BET);
});
it("declines insurance and continues the round", () => {
const state = bet(soloTable(shoeOf(c(10, "s"), c(14, "h"), c(9, "s"), c(6, "d"))));
expect(state.phase).toBe("insurance");
const declined = act(state, 0, "decline-insurance");
expect(declined.players[0]?.insuranceBet).toBe(0);
expect(declined.phase).toBe("player");
});
});
describe("settlement and payouts", () => {
it("pays a natural blackjack 3:2", () => {
const state = bet(soloTable(shoeOf(c(14, "s"), c(5, "h"), c(13, "s"), c(9, "d"))));
expect(state.phase).toBe("round-complete");
const hand = state.players[0]?.hands[0];
expect(hand?.outcome).toBe("blackjack");
expect(hand?.netChange).toBe(Math.floor(MIN_BET * BLACKJACK_PAYOUT));
expect(state.roundResult?.hands[0]?.payout).toBe(Math.floor(MIN_BET * BLACKJACK_PAYOUT));
expect(state.players[0]?.bankroll).toBe(
STARTING_BANKROLL - MIN_BET + MIN_BET + Math.floor(MIN_BET * BLACKJACK_PAYOUT),
);
expect(state.players[0]?.blackjacksHit).toBe(1);
expect(state.players[0]?.roundsWon).toBe(1);
});
it("pushes equal totals and returns the stake", () => {
// Player stands on 20 (10+K); dealer 10+K also makes 20 (draws nothing).
const state = bet(
soloTable(shoeOf(c(10, "s"), c(10, "h"), c(13, "s"), c(13, "h"), c(2, "s"))),
);
expect(state.phase).toBe("player");
const settled = act(state, 0, "stand");
const hand = settled.players[0]?.hands[0];
expect(hand?.outcome).toBe("push");
expect(hand?.netChange).toBe(0);
expect(settled.players[0]?.bankroll).toBe(STARTING_BANKROLL);
expect(settled.players[0]?.roundsWon).toBe(0);
});
it("pays a regular win 1:1", () => {
// Player 20 stands; dealer 16 draws the crafted 7 and busts.
const state = bet(soloTable(shoeOf(c(10, "s"), c(10, "h"), c(10, "s"), c(6, "h"), c(7, "s"))));
const settled = act(state, 0, "stand");
const hand = settled.players[0]?.hands[0];
expect(hand?.outcome).toBe("win");
expect(hand?.netChange).toBe(MIN_BET);
expect(settled.players[0]?.bankroll).toBe(STARTING_BANKROLL + MIN_BET);
expect(settled.roundResult?.dealerBusted).toBe(true);
});
it("flags a seat bankrupt once the bankroll drops below the table minimum", () => {
// Player 16 loses to a dealer 17; the 15-chip bankroll drops to 5.
const state = bet(soloTable(shoeOf(c(9, "s"), c(10, "h"), c(7, "d"), c(7, "s")), 15));
const settled = act(state, 0, "stand");
expect(settled.players[0]?.bankroll).toBe(5);
expect(settled.players[0]?.bankrupt).toBe(true);
});
it("starts the next round from a settled state and resets per-round data", () => {
const state = bet(soloTable(shoeOf(c(10, "s"), c(6, "h"), c(9, "s"), c(7, "d"), c(4, "s"))));
const settled = act(state, 0, "stand");
const next = startNextRound(settled);
expect(next.roundNumber).toBe(2);
expect(next.phase).toBe("betting");
expect(next.toActSeat).toBe(0);
expect(next.players[0]?.hands).toHaveLength(0);
expect(next.players[0]?.hasBet).toBe(false);
expect(next.roundResult).toBeNull();
expect(next.dealer.cards).toHaveLength(0);
});
it("drops bankrupt and listed seats when the next round starts", () => {
const state = bet(soloTable(shoeOf(c(10, "s"), c(10, "h"), c(9, "s"), c(7, "d")), 15));
const settled = act(state, 0, "stand");
const next = startNextRound(settled, [0]);
expect(next.players).toHaveLength(0);
expect(next.toActSeat).toBeNull();
});
});
describe("views and legal actions", () => {
it("hides the dealer hole card until reveal", () => {
const state = bet(soloTable(shoeOf(c(10, "s"), c(6, "h"), c(9, "s"), c(7, "d"))));
const view = buildPrivateView(state, 0);
expect(view.dealer.cards).toHaveLength(1);
expect(view.dealer.holeHidden).toBe(true);
expect(view.dealer.total).toBeNull();
const settled = act(state, 0, "stand");
expect(buildPrivateView(settled, 0).dealer.holeHidden).toBe(false);
});
it("exposes turn-scoped legal actions", () => {
const betting = makeTable([STARTING_BANKROLL, STARTING_BANKROLL]);
expect(getLegalActions(betting, 0).canBet).toBe(true);
expect(getLegalActions(betting, 0).maxBet).toBe(500);
expect(getLegalActions(betting, 1).canBet).toBe(false);
const state = bet(soloTable(shoeOf(c(8, "s"), c(6, "h"), c(8, "h"), c(10, "d"))));
const actions = getLegalActions(state, 0);
expect(actions.canHit).toBe(true);
expect(actions.canStand).toBe(true);
expect(actions.canDouble).toBe(true);
expect(actions.canSplit).toBe(true);
expect(actions.canInsure).toBe(false);
const spectator = buildPrivateView(state, -1);
expect(spectator.viewerSeat).toBe(-1);
expect(spectator.yourTurn).toBe(false);
});
it("does not mutate the state on idempotent advances", () => {
const state = bet(soloTable(shoeOf(c(10, "s"), c(6, "h"), c(9, "s"), c(7, "d"))));
expect(advanceState(state)).toEqual(state);
});
});
describe("settleRound direct resolution", () => {
it("resolves hand-built states with known totals", () => {
const base = makeTable([1000, 1000], 3);
const playerFor = (name: string, cards: Card[], betAmount: number) => ({
seatId: 0,
name,
bankroll: 1000 - betAmount,
hands: [
{
cards,
bet: betAmount,
status: "stood" as const,
doubled: false,
fromSplit: false,
splitAces: false,
outcome: null,
netChange: null,
},
],
insuranceBet: 0,
insuranceAnswered: false,
hasBet: true,
sittingOut: false,
bankrupt: false,
roundsPlayed: 0,
roundsWon: 0,
blackjacksHit: 0,
});
const a = playerFor("A", [c(10, "s"), c(10, "h")], 50);
const b = { ...playerFor("B", [c(10, "d"), c(6, "s")], 20), seatId: 1 as const };
const withHands: BlackjackTableState = {
...base,
players: [a, b],
dealer: { cards: [c(9, "h"), c(10, "c")], holeRevealed: true },
phase: "player",
toActSeat: null,
};
const settled = settleRound(withHands);
expect(settled.players[0]?.bankroll).toBe(1000 + 50);
expect(settled.players[1]?.bankroll).toBe(1000 - 20);
expect(settled.roundResult?.hands).toHaveLength(2);
expect(settled.roundResult?.hands[0]?.outcome).toBe("win");
expect(settled.roundResult?.hands[1]?.outcome).toBe("lose");
});
});
describe("table limits", () => {
it("supports solo through six players and rejects beyond", () => {
expect(MIN_PLAYERS).toBe(1);
expect(MAX_PLAYERS).toBe(6);
const six = createTableState(
Array.from({ length: 6 }, (_, index) => ({ name: `P${index + 1}` })),
{},
mulberry32(2),
);
expect(six.players).toHaveLength(6);
expect(() =>
createTableState(
Array.from({ length: 7 }, (_, index) => ({ name: `P${index + 1}` })),
{},
mulberry32(2),
),
).toThrow("1 to 6 players");
});
});

View file

@ -0,0 +1,247 @@
import { describe, expect, it } from "vitest";
import type { PublicDealerView, PublicPlayerView } from "@/lib/minigames/blackjack";
import {
actionLogText,
popupEventsForTransition,
type BlackjackPopupEvent,
} from "@/components/frontend/minigames/blackjack/blackjackPopups";
import {
isTopSeatPosition,
seatPosition,
} from "@/components/frontend/minigames/blackjack/seatPosition";
import type { BlackjackSoundSnapshot } from "@/components/frontend/minigames/blackjack/blackjackSoundEvents";
function hand(
overrides: Partial<PublicPlayerView["hands"][number]> = {},
): PublicPlayerView["hands"][number] {
return {
cards: [],
bet: 25,
status: "active",
doubled: false,
fromSplit: false,
splitAces: false,
total: 21,
outcome: null,
netChange: null,
...overrides,
};
}
function player(seatId: number, overrides: Partial<PublicPlayerView> = {}): PublicPlayerView {
return {
seatId,
name: `Player ${seatId + 1}`,
bankroll: 1000,
hands: [],
insuranceBet: 0,
insuranceAnswered: false,
hasBet: false,
sittingOut: false,
bankrupt: false,
left: false,
roundsPlayed: 0,
roundsWon: 0,
blackjacksHit: 0,
...overrides,
};
}
function dealerView(overrides: Partial<PublicDealerView> = {}): PublicDealerView {
return {
cards: [],
holeHidden: true,
total: null,
hasBlackjack: null,
...overrides,
};
}
function view(
overrides: Partial<BlackjackSoundSnapshot["you"]> = {},
): BlackjackSoundSnapshot["you"] {
return {
roundNumber: 1,
phase: "betting",
players: [player(0), player(1)],
dealer: dealerView(),
roundResult: null,
viewerSeat: 0,
yourTurn: false,
yourHands: [],
...overrides,
};
}
function snapshot(
version: number,
nextView: BlackjackSoundSnapshot["you"] = view(),
): BlackjackSoundSnapshot {
return { version, you: nextView };
}
function bySeat(events: readonly BlackjackPopupEvent[], seatId: number): BlackjackPopupEvent | undefined {
return events.find((event) => event.seatId === seatId);
}
describe("blackjack popup transition events", () => {
it("labels a placed bet with its amount over the acting seat", () => {
const events = popupEventsForTransition(
snapshot(1, view()),
snapshot(
2,
view({ players: [player(0, { hasBet: true, hands: [hand({ bet: 25 })] }), player(1)] }),
),
);
const bettor = bySeat(events, 0);
expect(bettor?.tone).toBe("wager");
expect(bettor?.text).toBe("Bet +25");
});
it("labels insurance, splits, and doubles over the acting seat", () => {
const events = popupEventsForTransition(
snapshot(
1,
view({
players: [
player(0, { hasBet: true, hands: [hand()] }),
player(1, { hasBet: true, hands: [hand()] }),
],
}),
),
snapshot(
2,
view({
players: [
player(0, {
hasBet: true,
insuranceBet: 5,
hands: [hand({ doubled: true, bet: 50 })],
}),
player(1, { hasBet: true, hands: [hand(), hand({ fromSplit: true })] }),
],
}),
),
);
expect(events.some((event) => event.text === "Insurance +5")).toBe(true);
expect(events.some((event) => event.text.startsWith("Double +"))).toBe(true);
const splitter = events.find((event) => event.seatId === 1 && event.text === "Split");
expect(splitter?.tone).toBe("action");
});
it("labels busts and naturals as hand-status changes", () => {
const events = popupEventsForTransition(
snapshot(
1,
view({
phase: "player",
players: [player(0, { hasBet: true, hands: [hand()] }), player(1, { hasBet: true, hands: [hand()] })],
}),
),
snapshot(
2,
view({
phase: "player",
players: [
player(0, { hasBet: true, hands: [hand({ status: "bust" })] }),
player(1, { hasBet: true, hands: [hand({ status: "blackjack" })] }),
],
}),
),
);
expect(bySeat(events, 0)?.text).toBe("Bust");
expect(bySeat(events, 0)?.tone).toBe("fold");
expect(bySeat(events, 1)?.text).toBe("Blackjack!");
expect(bySeat(events, 1)?.tone).toBe("win");
});
it("fires one aggregated win popup per seat when a round settles", () => {
const settledView = view({
phase: "round-complete",
roundResult: {
roundNumber: 1,
dealerTotal: 22,
dealerBusted: true,
dealerHasBlackjack: false,
hands: [
{
seatId: 0,
handIndex: 0,
total: 20,
outcome: "win",
payout: 50,
netChange: 50,
},
{
seatId: 1,
handIndex: 0,
total: 18,
outcome: "win",
payout: 25,
netChange: 25,
},
],
insurance: [],
},
});
const events = popupEventsForTransition(snapshot(1, view()), snapshot(2, settledView));
expect(bySeat(events, 0)?.text).toBe("+50");
expect(bySeat(events, 0)?.tone).toBe("win");
expect(bySeat(events, 1)?.text).toBe("+25");
});
it("announces leavers and bankrupt players with leave tone", () => {
const events = popupEventsForTransition(
snapshot(1, view()),
snapshot(
2,
view({
players: [player(0, { left: true }), player(1, { bankrupt: true })],
}),
),
);
expect(bySeat(events, 0)?.tone).toBe("leave");
expect(bySeat(events, 0)?.text).toBe("Player 1 left the table");
expect(bySeat(events, 1)?.text).toBe("Player 2 is out of chips");
});
it("returns no events for the same version", () => {
const current = snapshot(3, view());
expect(popupEventsForTransition(current, current)).toEqual([]);
});
});
describe("actionLogText", () => {
it("prefixes the acting player's name", () => {
const players = [player(0, { name: "Harlan" })];
expect(
actionLogText({ key: "k", seatId: 0, text: "Bet +25", tone: "wager" }, players),
).toBe("Harlan: Bet +25");
expect(
actionLogText({ key: "k", seatId: 0, text: "+50", tone: "win" }, players),
).toBe("Harlan wins +50");
expect(
actionLogText({ key: "k", seatId: 0, text: "Gone", tone: "leave" }, players),
).toBe("Gone");
expect(
actionLogText({ key: "k", seatId: 4, text: "Bet +25", tone: "wager" }, players),
).toBe("Seat 5: Bet +25");
});
});
describe("seatPosition", () => {
it("spreads one through five opponents across the bottom two-thirds arc", () => {
expect(seatPosition(0, 1)).toBe("blackjack-seat-arc-corner-right");
expect(seatPosition(0, 2)).toBe("blackjack-seat-arc-corner-right");
expect(seatPosition(1, 2)).toBe("blackjack-seat-arc-corner-left");
expect(seatPosition(2, 5)).toBe("blackjack-seat-arc-low-right");
expect(seatPosition(4, 5)).toBe("blackjack-seat-arc-corner-left");
});
it("classifies the dealer-adjacent corner seats for popup placement", () => {
expect(isTopSeatPosition("blackjack-seat-arc-corner-right")).toBe(true);
expect(isTopSeatPosition("blackjack-seat-arc-corner-left")).toBe(true);
expect(isTopSeatPosition("blackjack-seat-arc-low-right")).toBe(false);
expect(isTopSeatPosition("blackjack-seat-arc-mid-left")).toBe(false);
});
});

View file

@ -0,0 +1,198 @@
import { describe, expect, it } from "vitest";
import type { Card, PublicPlayerView } from "@/lib/minigames/blackjack";
import {
soundEventsForTransition,
type BlackjackSoundEvent,
type BlackjackSoundSnapshot,
} from "@/components/frontend/minigames/blackjack/blackjackSoundEvents";
const card = (rank: Card["rank"], suit: Card["suit"]): Card => ({ rank, suit });
function player(seatId: number, overrides: Partial<PublicPlayerView> = {}): PublicPlayerView {
return {
seatId,
name: `Player ${seatId + 1}`,
bankroll: 1000,
hands: [],
insuranceBet: 0,
insuranceAnswered: false,
hasBet: false,
sittingOut: false,
bankrupt: false,
left: false,
roundsPlayed: 0,
roundsWon: 0,
blackjacksHit: 0,
...overrides,
};
}
function hand(
overrides: Partial<PublicPlayerView["hands"][number]> = {},
): PublicPlayerView["hands"][number] {
return {
cards: [],
bet: 25,
status: "active",
doubled: false,
fromSplit: false,
splitAces: false,
total: 21,
outcome: null,
netChange: null,
...overrides,
};
}
function view(
overrides: Partial<BlackjackSoundSnapshot["you"]> = {},
): BlackjackSoundSnapshot["you"] {
return {
roundNumber: 1,
phase: "betting",
players: [player(0), player(1)],
dealer: { cards: [], holeHidden: true, total: null, hasBlackjack: null },
roundResult: null,
viewerSeat: 0,
yourTurn: false,
yourHands: [],
...overrides,
};
}
function snapshot(
version: number,
nextView: BlackjackSoundSnapshot["you"] = view(),
): BlackjackSoundSnapshot {
return { version, you: nextView };
}
function byType(events: readonly BlackjackSoundEvent[], type: BlackjackSoundEvent["type"]) {
return events.find((event) => event.type === type);
}
describe("blackjack sound transition events", () => {
it("plays a round-start chime when the round counter advances", () => {
const events = soundEventsForTransition(
snapshot(1, view({ roundNumber: 1, phase: "round-complete" })),
snapshot(2, view({ roundNumber: 2, phase: "betting" })),
);
expect(byType(events, "round-start")).toBeDefined();
});
it("announces turn changes", () => {
const events = soundEventsForTransition(
snapshot(1, view({ yourTurn: false })),
snapshot(2, view({ yourTurn: true, phase: "player" })),
);
expect(byType(events, "turn-gained")).toBeDefined();
const back = soundEventsForTransition(
snapshot(2, view({ yourTurn: true, phase: "player" })),
snapshot(3, view({ yourTurn: false, phase: "player" })),
);
expect(byType(back, "turn-lost")).toBeDefined();
});
it("announces cards hitting the felt", () => {
const dealtCards = [card(10, "s"), card(9, "h")];
const events = soundEventsForTransition(
snapshot(1, view()),
snapshot(
2,
view({
phase: "player",
players: [player(0, { hasBet: true, hands: [hand({ cards: dealtCards })] }), player(1)],
dealer: { cards: [card(6, "d"), card(7, "c")], holeHidden: true, total: null, hasBlackjack: null },
}),
),
);
const deal = byType(events, "cards-dealt");
expect(deal).toBeDefined();
if (deal?.type === "cards-dealt") expect(deal.cardCount).toBe(4);
});
it("announces a wager and a bust", () => {
const events = soundEventsForTransition(
snapshot(1, view({ players: [player(0, { hands: [hand()] }), player(1)] })),
snapshot(
2,
view({
players: [player(0, { hasBet: true, hands: [hand({ cards: [card(5, "s"), card(6, "s")] })] }), player(1)],
}),
),
);
expect(byType(events, "wager")).toBeDefined();
const bustEvents = soundEventsForTransition(
snapshot(
3,
view({
phase: "player",
players: [player(0, { hasBet: true, hands: [hand({ cards: [card(5, "s"), card(6, "s")] })] }), player(1)],
}),
),
snapshot(
4,
view({
phase: "player",
players: [
player(0, {
hasBet: true,
hands: [hand({ status: "bust", cards: [card(5, "s"), card(6, "s"), card(10, "d")] })],
}),
player(1),
],
}),
),
);
expect(byType(bustEvents, "bust")).toBeDefined();
});
it("settles with a viewer result event", () => {
const settled = view({
phase: "round-complete",
roundResult: {
roundNumber: 1,
dealerTotal: 19,
dealerBusted: false,
dealerHasBlackjack: false,
hands: [
{ seatId: 0, handIndex: 0, total: 20, outcome: "win", payout: 25, netChange: 25 },
],
insurance: [],
},
});
const events = soundEventsForTransition(snapshot(1, view()), snapshot(2, settled));
expect(byType(events, "settle")).toBeDefined();
const result = byType(events, "hand-result");
expect(result).toBeDefined();
if (result?.type === "hand-result") {
expect(result.result).toBe("win");
expect(result.netChange).toBe(25);
}
});
it("reports a push result on a tie", () => {
const settled = view({
phase: "round-complete",
roundResult: {
roundNumber: 1,
dealerTotal: 20,
dealerBusted: false,
dealerHasBlackjack: false,
hands: [{ seatId: 0, handIndex: 0, total: 20, outcome: "push", payout: 0, netChange: 0 }],
insurance: [],
},
});
const events = soundEventsForTransition(snapshot(1, view()), snapshot(2, settled));
const result = byType(events, "hand-result");
expect(result).toBeDefined();
if (result?.type === "hand-result") expect(result.result).toBe("push");
});
it("returns no events for the same version", () => {
const current = snapshot(5, view());
expect(soundEventsForTransition(current, current)).toEqual([]);
});
});

View file

@ -0,0 +1,133 @@
import { describe, expect, it } from "vitest";
import {
BLACKJACK_XP_ENABLED,
BLACKJACK_XP_CAP,
computeBlackjackStatsDelta,
readBlackjackStats,
xpForProfit,
type BlackjackSessionResult,
type BlackjackStats,
} from "@/lib/minigames/blackjack/stats";
function result(overrides: Partial<BlackjackSessionResult> = {}): BlackjackSessionResult {
return {
userId: 1,
roundsPlayed: 10,
roundsWon: 4,
blackjacksHit: 1,
finalBankroll: 1250,
...overrides,
};
}
function current(overrides: Partial<BlackjackStats> = {}): BlackjackStats {
return {
gamesPlayed: 0,
roundsWon: 0,
blackjacksHit: 0,
bestScore: 0,
xpEarned: 0,
...overrides,
};
}
describe("xpForProfit", () => {
it("awards the base XP for a breakeven or losing session", () => {
expect(xpForProfit(1000)).toBe(5);
expect(xpForProfit(500)).toBe(5);
});
it("awards no bracket bonus below a full 25% profit", () => {
expect(xpForProfit(1100)).toBe(5);
expect(xpForProfit(1249)).toBe(5);
});
it("awards 10 XP per full 25% profit bracket", () => {
expect(xpForProfit(1250)).toBe(15);
expect(xpForProfit(1500)).toBe(25);
expect(xpForProfit(1750)).toBe(35);
expect(xpForProfit(2000)).toBe(45);
});
it("caps the payout at the session cap", () => {
expect(BLACKJACK_XP_CAP).toBe(50);
expect(xpForProfit(2500)).toBe(BLACKJACK_XP_CAP);
expect(xpForProfit(100000)).toBe(BLACKJACK_XP_CAP);
});
});
describe("BLACKJACK_XP_ENABLED", () => {
it("is disabled by default until the XP system is reworked", () => {
expect(BLACKJACK_XP_ENABLED).toBe(false);
});
});
describe("computeBlackjackStatsDelta", () => {
it("increments gamesPlayed by exactly one", () => {
const next = computeBlackjackStatsDelta(current({ gamesPlayed: 7 }), result());
expect(next.gamesPlayed).toBe(8);
});
it("accumulates rounds won and blackjacks hit", () => {
const next = computeBlackjackStatsDelta(
current({ roundsWon: 10, blackjacksHit: 2 }),
result({ roundsWon: 4, blackjacksHit: 1 }),
);
expect(next.roundsWon).toBe(14);
expect(next.blackjacksHit).toBe(3);
});
it("keeps bestScore when the final bankroll is lower", () => {
const next = computeBlackjackStatsDelta(
current({ bestScore: 9000 }),
result({ finalBankroll: 1250 }),
);
expect(next.bestScore).toBe(9000);
});
it("raises bestScore when the final bankroll is higher", () => {
const next = computeBlackjackStatsDelta(
current({ bestScore: 1000 }),
result({ finalBankroll: 2400 }),
);
expect(next.bestScore).toBe(2400);
});
it("adds the profit XP to the existing xpEarned total", () => {
const next = computeBlackjackStatsDelta(
current({ xpEarned: 100 }),
result({ finalBankroll: 1500 }),
);
expect(next.xpEarned).toBe(125);
});
it("does not mutate the current stats object", () => {
const before = current({ gamesPlayed: 1, xpEarned: 125 });
computeBlackjackStatsDelta(before, result());
expect(before).toEqual(current({ gamesPlayed: 1, xpEarned: 125 }));
});
});
describe("readBlackjackStats", () => {
it("reads blackjack stats from a profile that also carries sibling games", () => {
const profile = {
progression: {
minigames: {
poker: { gamesPlayed: 12, xpEarned: 40 },
blackjack: { gamesPlayed: 3, roundsWon: 5, blackjacksHit: 2, bestScore: 1500, xpEarned: 15 },
},
},
};
expect(readBlackjackStats(profile)).toEqual(
current({ gamesPlayed: 3, roundsWon: 5, blackjacksHit: 2, bestScore: 1500, xpEarned: 15 }),
);
});
it("returns all zeros when the blackjack group is missing", () => {
expect(readBlackjackStats({ progression: { minigames: {} } })).toEqual(current());
});
it("returns all zeros for a nullish profile", () => {
expect(readBlackjackStats(undefined)).toEqual(current());
});
});

View file

@ -0,0 +1,189 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
applyBlackjackAction,
clearTablesForTests,
createTable,
getSnapshotFor,
joinTable,
leaveTable,
renameTable,
spectateTable,
startGame,
} from "@/lib/minigames/blackjack/tables";
/**
* Regression tests for the blackjack registry: game start, seating, lobby
* rules, and snapshots. Database-free except for the fire-and-forget award
* path, which skips users who never played a round.
*/
beforeEach(() => {
clearTablesForTests();
});
describe("blackjack table registry: startGame", () => {
it("starts a solo game immediately against the dealer", () => {
const tableId = createTable(9001, "Host");
const snapshot = startGame(tableId, 9001);
expect(snapshot.inGame).toBe(true);
expect(snapshot.you?.phase).toBe("betting");
expect(snapshot.you?.activeSeat).toBe(0);
expect(snapshot.you?.yourTurn).toBe(true);
expect(snapshot.you?.legalActions.canBet).toBe(true);
});
it("starts a six-player game with every seat backed by a user", () => {
const tableId = createTable(9101, "Host");
joinTable(tableId, 9102, "Second");
joinTable(tableId, 9103, "Third");
joinTable(tableId, 9104, "Fourth");
joinTable(tableId, 9105, "Fifth");
joinTable(tableId, 9106, "Sixth");
const snapshot = startGame(tableId, 9101);
const seatedIds = snapshot.seats.filter((id) => id !== null);
expect(seatedIds).toHaveLength(6);
const players = snapshot.you?.players ?? [];
expect(players).toHaveLength(6);
for (const player of players) {
expect(player.name).not.toBe("Player null");
expect(player.name.length).toBeGreaterThan(0);
const userId = snapshot.seats[player.seatId];
expect(userId).not.toBeNull();
}
expect(snapshot.seats).toHaveLength(6);
});
it("keeps the seat map padded to six with trailing nulls", () => {
const tableId = createTable(9201, "Host");
joinTable(tableId, 9202, "Second");
startGame(tableId, 9201);
const snapshot = getSnapshotFor(tableId, 9201);
expect(snapshot.seats).toEqual([9201, 9202, null, null, null, null]);
});
});
describe("blackjack table registry: lobby rules", () => {
it("rejects a second table for a user already seated elsewhere", () => {
const first = createTable(9301, "Host");
createTable(9302, "Other");
expect(() => joinTable(first, 9302, "Other")).toThrow("already seated at another table");
});
it("lets the host start with a single player and rejects unstarted actions", () => {
const tableId = createTable(9401, "Host");
expect(() => applyBlackjackAction(tableId, 9401, "bet", 10)).toThrow(
"The game has not started yet.",
);
startGame(tableId, 9401);
expect(() => startGame(tableId, 9999)).toThrow("Only the host can start the game.");
});
it("queues mid-game joins for the next round", () => {
const tableId = createTable(9501, "Host");
joinTable(tableId, 9502, "Second");
startGame(tableId, 9501);
const latecomer = joinTable(tableId, 9503, "Latecomer");
// Pending joiners have a seat in the map but no engine hand until the
// next round starts, so their snapshot carries no private view yet.
expect(latecomer.you).toBeNull();
expect(latecomer.seats).toContain(9503);
expect(() => applyBlackjackAction(tableId, 9503, "bet", 10)).toThrow(
"You will join the game at the next round.",
);
});
it("spectates a running game with the public view and no seat", () => {
const tableId = createTable(9601, "Host");
joinTable(tableId, 9602, "Guest");
startGame(tableId, 9601);
const snapshot = spectateTable(tableId, 9603);
expect(snapshot.spectator).toBe(true);
expect(snapshot.you?.viewerSeat).toBe(-1);
expect(snapshot.you?.players).toHaveLength(2);
expect(snapshot.you?.yourTurn).toBe(false);
expect(snapshot.you?.legalActions.canBet).toBe(false);
expect(() => spectateTable(createTable(9604, "Idle"), 9603)).toThrow(
"No game is running at this table.",
);
});
it("compacts the seat map and promotes the host when leaving pre-game", () => {
const tableId = createTable(9701, "Host");
joinTable(tableId, 9702, "Second");
joinTable(tableId, 9703, "Third");
leaveTable(tableId, 9701);
const snapshot = getSnapshotFor(tableId, 9702);
expect(snapshot.seats).toEqual([9702, 9703, null, null, null, null]);
expect(snapshot.hostSeatId).toBe(0);
});
it("marks mid-game leavers as left in every snapshot", () => {
const tableId = createTable(9801, "Host");
joinTable(tableId, 9802, "Guest");
startGame(tableId, 9801);
leaveTable(tableId, 9802);
const host = getSnapshotFor(tableId, 9801);
expect(host.you?.players.find((player) => player.seatId === 1)?.left).toBe(true);
expect(host.you?.players.find((player) => player.seatId === 0)?.left).toBe(false);
});
});
describe("blackjack table registry: gameplay through the registry", () => {
it("runs a solo betting round end to end", () => {
const tableId = createTable(9901, "Host");
startGame(tableId, 9901);
let afterBet = applyBlackjackAction(tableId, 9901, "bet", 25);
// The shoe is random: an ace upcard opens an insurance offer, and a
// dealt natural settles the round on the spot. Walk whichever path comes.
if (afterBet.you?.phase === "insurance") {
afterBet = applyBlackjackAction(tableId, 9901, "decline-insurance");
}
if (afterBet.you?.phase === "player") {
afterBet = applyBlackjackAction(tableId, 9901, "stand");
}
expect(afterBet.you?.phase).toBe("round-complete");
expect(afterBet.you?.yourHands).toHaveLength(1);
expect(afterBet.you?.yourHands[0]?.bet).toBe(25);
expect(afterBet.version).toBeGreaterThan(1);
expect(afterBet.you?.roundResult).not.toBeNull();
expect(afterBet.you?.roundResult?.hands).toHaveLength(1);
expect(afterBet.nextRoundAt).not.toBeNull();
// The next round has not auto-started yet (the registry timer owns that).
expect(() => applyBlackjackAction(tableId, 9901, "bet", 10)).toThrow(
"not accepting bets",
);
});
it("validates bet limits through the registry", () => {
const tableId = createTable(9911, "Host");
startGame(tableId, 9911);
expect(() => applyBlackjackAction(tableId, 9911, "bet", 9)).toThrow("minimum bet");
expect(() => applyBlackjackAction(tableId, 9911, "bet", 501)).toThrow("maximum bet");
});
});
describe("blackjack table registry: table management", () => {
it("renames tables as host with length truncation", () => {
const tableId = createTable(9921, "Host");
const renamed = renameTable(tableId, 9921, " High Roller Room ");
expect(renamed.name).toBe("High Roller Room");
const longName = "X".repeat(60);
expect(renameTable(tableId, 9921, longName).name).toHaveLength(48);
expect(() => renameTable(tableId, 9999, "Nope")).toThrow("Only the host");
});
it("caps live tables at the registry maximum", () => {
for (let index = 0; index < 20; index += 1) {
createTable(10000 + index, `Host ${index}`);
}
expect(() => createTable(10999, "Overflow")).toThrow("maximum number of blackjack tables");
});
it("disposes a table once everyone leaves", () => {
const tableId = createTable(11001, "Host");
leaveTable(tableId, 11001);
expect(() => getSnapshotFor(tableId, 11001)).toThrow("Table not found.");
});
});