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>
201 lines
7.1 KiB
TypeScript
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>
|
|
);
|
|
}
|