1
0
Fork 0
polaris-task-force/src/components/frontend/minigames/blackjack/BlackjackActionConsole.tsx
Z8MB1E 474aff734d 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>
2026-09-14 15:38:05 -04:00

201 lines
7.1 KiB
TypeScript

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