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,
|
Bomb,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Spade,
|
Spade,
|
||||||
|
Club,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export interface NavigationCommand {
|
export interface NavigationCommand {
|
||||||
|
|
@ -277,6 +278,13 @@ export const navigationCommands: NavigationCommand[] = [
|
||||||
href: "/minigames/poker",
|
href: "/minigames/poker",
|
||||||
keywords: ["minigame", "poker", "cards", "holdem", "training"],
|
keywords: ["minigame", "poker", "cards", "holdem", "training"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "nav:blackjack",
|
||||||
|
label: "Blackjack",
|
||||||
|
icon: Club,
|
||||||
|
href: "/minigames/blackjack",
|
||||||
|
keywords: ["minigame", "blackjack", "21", "cards", "training"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "nav:account",
|
id: "nav:account",
|
||||||
label: "Account",
|
label: "Account",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
Mic,
|
Mic,
|
||||||
Radio,
|
Radio,
|
||||||
Spade,
|
Spade,
|
||||||
|
Club,
|
||||||
Users2,
|
Users2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
@ -180,6 +181,12 @@ export function NavUser({
|
||||||
Poker
|
Poker
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/minigames/blackjack">
|
||||||
|
<Club />
|
||||||
|
Blackjack
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
|
|
|
||||||
|
|
@ -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 { useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
|
BLACKJACK_STATE_EVENT,
|
||||||
|
BLACKJACK_TABLES_EVENT,
|
||||||
|
BLACKJACK_TERMINATED_EVENT,
|
||||||
GAME_EVENT_EVENT,
|
GAME_EVENT_EVENT,
|
||||||
GAME_TICK_EVENT,
|
GAME_TICK_EVENT,
|
||||||
NOTIFICATION_EVENT,
|
NOTIFICATION_EVENT,
|
||||||
|
|
@ -10,6 +13,9 @@ import {
|
||||||
POKER_TABLES_EVENT,
|
POKER_TABLES_EVENT,
|
||||||
POKER_TERMINATED_EVENT,
|
POKER_TERMINATED_EVENT,
|
||||||
PROFILE_UPDATE_EVENT,
|
PROFILE_UPDATE_EVENT,
|
||||||
|
type BlackjackStateDetail,
|
||||||
|
type BlackjackTablesDetail,
|
||||||
|
type BlackjackTerminatedDetail,
|
||||||
type GameTickDetail,
|
type GameTickDetail,
|
||||||
type PokerStateDetail,
|
type PokerStateDetail,
|
||||||
type PokerTablesDetail,
|
type PokerTablesDetail,
|
||||||
|
|
@ -90,6 +96,33 @@ export function GameTickRealtime() {
|
||||||
}
|
}
|
||||||
window.dispatchEvent(new CustomEvent(POKER_TERMINATED_EVENT, { detail }));
|
window.dispatchEvent(new CustomEvent(POKER_TERMINATED_EVENT, { detail }));
|
||||||
});
|
});
|
||||||
|
source.addEventListener("blackjack-state", (event) => {
|
||||||
|
let detail: BlackjackStateDetail = {};
|
||||||
|
try {
|
||||||
|
detail = JSON.parse(event.data ?? "{}");
|
||||||
|
} catch {
|
||||||
|
// ignore malformed payload
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent(BLACKJACK_STATE_EVENT, { detail }));
|
||||||
|
});
|
||||||
|
source.addEventListener("blackjack-tables", (event) => {
|
||||||
|
let detail: BlackjackTablesDetail = {};
|
||||||
|
try {
|
||||||
|
detail = JSON.parse(event.data ?? "{}");
|
||||||
|
} catch {
|
||||||
|
// ignore malformed payload
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent(BLACKJACK_TABLES_EVENT, { detail }));
|
||||||
|
});
|
||||||
|
source.addEventListener("blackjack-terminated", (event) => {
|
||||||
|
let detail: BlackjackTerminatedDetail = {};
|
||||||
|
try {
|
||||||
|
detail = JSON.parse(event.data ?? "{}");
|
||||||
|
} catch {
|
||||||
|
// ignore malformed payload
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent(BLACKJACK_TERMINATED_EVENT, { detail }));
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
if (refreshTimer) clearTimeout(refreshTimer);
|
if (refreshTimer) clearTimeout(refreshTimer);
|
||||||
source.close();
|
source.close();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type {
|
||||||
|
BlackjackSnapshot,
|
||||||
|
BlackjackTableSummary,
|
||||||
|
} from "@/app/(frontend)/minigames/blackjack/actions";
|
||||||
import type { PokerSnapshot, PokerTableSummary } from "@/app/(frontend)/minigames/poker/actions";
|
import type { PokerSnapshot, PokerTableSummary } from "@/app/(frontend)/minigames/poker/actions";
|
||||||
|
|
||||||
export const GAME_TICK_EVENT = "ptf:game-tick";
|
export const GAME_TICK_EVENT = "ptf:game-tick";
|
||||||
|
|
@ -11,6 +15,9 @@ export const SESSION_EXPIRED_EVENT = "ptf:session-expired";
|
||||||
export const POKER_STATE_EVENT = "ptf:poker-state";
|
export const POKER_STATE_EVENT = "ptf:poker-state";
|
||||||
export const POKER_TABLES_EVENT = "ptf:poker-tables";
|
export const POKER_TABLES_EVENT = "ptf:poker-tables";
|
||||||
export const POKER_TERMINATED_EVENT = "ptf:poker-terminated";
|
export const POKER_TERMINATED_EVENT = "ptf:poker-terminated";
|
||||||
|
export const BLACKJACK_STATE_EVENT = "ptf:blackjack-state";
|
||||||
|
export const BLACKJACK_TABLES_EVENT = "ptf:blackjack-tables";
|
||||||
|
export const BLACKJACK_TERMINATED_EVENT = "ptf:blackjack-terminated";
|
||||||
|
|
||||||
export interface GameTickDetail {
|
export interface GameTickDetail {
|
||||||
processedAt?: string;
|
processedAt?: string;
|
||||||
|
|
@ -19,6 +26,8 @@ export interface GameTickDetail {
|
||||||
export interface ProfileUpdateDetail {
|
export interface ProfileUpdateDetail {
|
||||||
reason?: string;
|
reason?: string;
|
||||||
xpGained?: number;
|
xpGained?: number;
|
||||||
|
/** Absolute new chip-pool balance; present on chip-affecting updates. */
|
||||||
|
chipBank?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PokerStateDetail {
|
export interface PokerStateDetail {
|
||||||
|
|
@ -35,6 +44,20 @@ export interface PokerTerminatedDetail {
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BlackjackStateDetail {
|
||||||
|
tableId?: string;
|
||||||
|
snapshot?: BlackjackSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlackjackTablesDetail {
|
||||||
|
tables?: BlackjackTableSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlackjackTerminatedDetail {
|
||||||
|
tableId?: string;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function useGameTick(onTick?: () => void): number {
|
export function useGameTick(onTick?: () => void): number {
|
||||||
const [count, setCount] = useState(0);
|
const [count, setCount] = useState(0);
|
||||||
const callbackRef = useRef(onTick);
|
const callbackRef = useRef(onTick);
|
||||||
|
|
|
||||||
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