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:
parent
34dcefec54
commit
474aff734d
53 changed files with 35516 additions and 0 deletions
182
src/app/(frontend)/minigames/blackjack/actions.ts
Normal file
182
src/app/(frontend)/minigames/blackjack/actions.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
84
src/app/(frontend)/minigames/blackjack/page.tsx
Normal file
84
src/app/(frontend)/minigames/blackjack/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ import {
|
|||
Bomb,
|
||||
ScrollText,
|
||||
Spade,
|
||||
Club,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface NavigationCommand {
|
||||
|
|
@ -277,6 +278,13 @@ export const navigationCommands: NavigationCommand[] = [
|
|||
href: "/minigames/poker",
|
||||
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",
|
||||
label: "Account",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
Mic,
|
||||
Radio,
|
||||
Spade,
|
||||
Club,
|
||||
Users2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
|
@ -180,6 +181,12 @@ export function NavUser({
|
|||
Poker
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/minigames/blackjack">
|
||||
<Club />
|
||||
Blackjack
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuGroup>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
350
src/components/frontend/minigames/blackjack/BlackjackArena.tsx
Normal file
350
src/components/frontend/minigames/blackjack/BlackjackArena.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
>
|
||||
−
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
325
src/components/frontend/minigames/blackjack/BlackjackLobby.tsx
Normal file
325
src/components/frontend/minigames/blackjack/BlackjackLobby.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
152
src/components/frontend/minigames/blackjack/BlackjackSeat.tsx
Normal file
152
src/components/frontend/minigames/blackjack/BlackjackSeat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
285
src/components/frontend/minigames/blackjack/BlackjackTable.tsx
Normal file
285
src/components/frontend/minigames/blackjack/BlackjackTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
src/components/frontend/minigames/blackjack/CardView.tsx
Normal file
123
src/components/frontend/minigames/blackjack/CardView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
176
src/components/frontend/minigames/blackjack/blackjackPopups.ts
Normal file
176
src/components/frontend/minigames/blackjack/blackjackPopups.ts
Normal 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;
|
||||
}
|
||||
348
src/components/frontend/minigames/blackjack/blackjackSound.ts
Normal file
348
src/components/frontend/minigames/blackjack/blackjackSound.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
91
src/components/frontend/minigames/blackjack/cardStyle.tsx
Normal file
91
src/components/frontend/minigames/blackjack/cardStyle.tsx
Normal 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);
|
||||
}
|
||||
88
src/components/frontend/minigames/blackjack/events.ts
Normal file
88
src/components/frontend/minigames/blackjack/events.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
41
src/components/frontend/minigames/blackjack/seatPosition.ts
Normal file
41
src/components/frontend/minigames/blackjack/seatPosition.ts
Normal 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);
|
||||
}
|
||||
7
src/components/frontend/minigames/blackjack/types.ts
Normal file
7
src/components/frontend/minigames/blackjack/types.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export interface BlackjackStats {
|
||||
readonly gamesPlayed: number;
|
||||
readonly roundsWon: number;
|
||||
readonly blackjacksHit: number;
|
||||
readonly bestScore: number;
|
||||
readonly xpEarned: number;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -3,6 +3,9 @@
|
|||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
BLACKJACK_STATE_EVENT,
|
||||
BLACKJACK_TABLES_EVENT,
|
||||
BLACKJACK_TERMINATED_EVENT,
|
||||
GAME_EVENT_EVENT,
|
||||
GAME_TICK_EVENT,
|
||||
NOTIFICATION_EVENT,
|
||||
|
|
@ -10,6 +13,9 @@ import {
|
|||
POKER_TABLES_EVENT,
|
||||
POKER_TERMINATED_EVENT,
|
||||
PROFILE_UPDATE_EVENT,
|
||||
type BlackjackStateDetail,
|
||||
type BlackjackTablesDetail,
|
||||
type BlackjackTerminatedDetail,
|
||||
type GameTickDetail,
|
||||
type PokerStateDetail,
|
||||
type PokerTablesDetail,
|
||||
|
|
@ -90,6 +96,33 @@ export function GameTickRealtime() {
|
|||
}
|
||||
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 () => {
|
||||
if (refreshTimer) clearTimeout(refreshTimer);
|
||||
source.close();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
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_TABLES_EVENT = "ptf:poker-tables";
|
||||
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 {
|
||||
processedAt?: string;
|
||||
|
|
@ -19,6 +26,8 @@ export interface GameTickDetail {
|
|||
export interface ProfileUpdateDetail {
|
||||
reason?: string;
|
||||
xpGained?: number;
|
||||
/** Absolute new chip-pool balance; present on chip-affecting updates. */
|
||||
chipBank?: number;
|
||||
}
|
||||
|
||||
export interface PokerStateDetail {
|
||||
|
|
@ -35,6 +44,20 @@ export interface PokerTerminatedDetail {
|
|||
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 {
|
||||
const [count, setCount] = useState(0);
|
||||
const callbackRef = useRef(onTick);
|
||||
|
|
|
|||
360
src/lib/minigames/blackjack/actions.ts
Normal file
360
src/lib/minigames/blackjack/actions.ts
Normal 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) };
|
||||
}
|
||||
51
src/lib/minigames/blackjack/constants.ts
Normal file
51
src/lib/minigames/blackjack/constants.ts
Normal 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;
|
||||
545
src/lib/minigames/blackjack/engine.ts
Normal file
545
src/lib/minigames/blackjack/engine.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
13
src/lib/minigames/blackjack/index.ts
Normal file
13
src/lib/minigames/blackjack/index.ts
Normal 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";
|
||||
98
src/lib/minigames/blackjack/rules.ts
Normal file
98
src/lib/minigames/blackjack/rules.ts
Normal 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);
|
||||
}
|
||||
75
src/lib/minigames/blackjack/shoe.ts
Normal file
75
src/lib/minigames/blackjack/shoe.ts
Normal 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();
|
||||
}
|
||||
186
src/lib/minigames/blackjack/stats.ts
Normal file
186
src/lib/minigames/blackjack/stats.ts
Normal 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 };
|
||||
}
|
||||
896
src/lib/minigames/blackjack/tables.ts
Normal file
896
src/lib/minigames/blackjack/tables.ts
Normal 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();
|
||||
}
|
||||
215
src/lib/minigames/blackjack/types.ts
Normal file
215
src/lib/minigames/blackjack/types.ts
Normal 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;
|
||||
}
|
||||
157
src/lib/minigames/blackjack/views.ts
Normal file
157
src/lib/minigames/blackjack/views.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
26786
src/migrations/20260912_071740_add_blackjack_minigame_stats.json
Normal file
26786
src/migrations/20260912_071740_add_blackjack_minigame_stats.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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";`)
|
||||
}
|
||||
45
tests/int/blackjack-card-skins.int.spec.ts
Normal file
45
tests/int/blackjack-card-skins.int.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
546
tests/int/blackjack-engine.int.spec.ts
Normal file
546
tests/int/blackjack-engine.int.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
247
tests/int/blackjack-popups.int.spec.ts
Normal file
247
tests/int/blackjack-popups.int.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
198
tests/int/blackjack-sound.int.spec.ts
Normal file
198
tests/int/blackjack-sound.int.spec.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
133
tests/int/blackjack-stats.int.spec.ts
Normal file
133
tests/int/blackjack-stats.int.spec.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
189
tests/int/blackjack-tables.int.spec.ts
Normal file
189
tests/int/blackjack-tables.int.spec.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue