1
0
Fork 0
polaris-task-force/src/components/frontend/minigames/poker/PokerFeltCanvas.tsx
Z8MB1E 1b0e4ed074 feat(poker): grade hand ranks on a progressive style ladder
Every made hand maps to a presentation tier from high card (no styling) through brass, silver, and gold pairs to a sapphire straight flush and a royal flush prismatic treatment. The live hand hint carries the tier, so the you-have placard glows with your hand's rank, and royals now read as royal flush.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 17:30:03 -04:00

278 lines
10 KiB
TypeScript

"use client";
import { cn } from "@/lib/utils";
import type { Card, PublicPlayerView } from "@/lib/minigames/poker";
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
import { Badge } from "@/components/ui/badge";
import { Card as UiCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CardView } from "./CardView";
import { PokerActionConsole } from "./PokerActionConsole";
import { PokerActionToolbar } from "./PokerActionToolbar";
import { PokerClock } from "./PokerClock";
import { PokerDeckOrigin } from "./PokerDeckOrigin";
import { PokerPopupBubble } from "./PokerPopups";
import { PokerSeat } from "./PokerSeat";
import type { ActivePokerPopup } from "./usePokerPopups";
import { isTopSeatPosition, seatPosition } from "./seatPosition";
export type SeatedSnapshot = PokerSnapshot & {
readonly you: NonNullable<PokerSnapshot["you"]>;
};
export interface FeltCanvasActionProps {
readonly starting: boolean;
readonly leaving: boolean;
readonly onStart: () => void;
readonly onLeave: () => void;
}
interface PokerFeltCanvasProps {
readonly snapshot: SeatedSnapshot;
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
readonly handLabel: string | null;
readonly handTier: number | null;
readonly bestCardKeys: ReadonlySet<string>;
readonly actionProps: FeltCanvasActionProps;
readonly onActionFeedback: () => void;
readonly popups: readonly ActivePokerPopup[];
readonly fullscreen?: boolean;
}
function cardKey(card: Card): string {
return `${card.rank}-${card.suit}`;
}
function signedAmount(value: number): string {
return `${value >= 0 ? "+" : ""}${value.toLocaleString()}`;
}
function PlacementBoard({ view }: { readonly view: SeatedSnapshot["you"] }) {
const placements = [...(view.finalPlacements ?? [])].sort((a, b) => a.placement - b.placement);
return (
<UiCard className="w-full max-w-sm rounded-none border-white/20 bg-black/55 text-white">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm uppercase tracking-wider">Final placements</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<ol className="flex flex-col gap-2">
{placements.map((placement) => {
const player = view.players.find((item) => item.seatId === placement.seatId);
return (
<li
key={placement.seatId}
className="flex items-center justify-between gap-3 text-sm"
>
<span className="font-mono text-muted-foreground">{placement.placement}.</span>
<span className="min-w-0 flex-1 truncate font-medium">
{player?.name ?? `Seat ${placement.seatId + 1}`}
</span>
<span className="font-mono tabular-nums text-muted-foreground">
{player?.stack.toLocaleString() ?? "0"}
</span>
</li>
);
})}
</ol>
</CardContent>
</UiCard>
);
}
export function PokerFeltCanvas({
snapshot,
onSnapshot,
handLabel,
handTier,
bestCardKeys,
actionProps,
onActionFeedback,
popups,
fullscreen = false,
}: PokerFeltCanvasProps) {
const view = snapshot.you;
const winnerSeats = new Set(view.showdown?.pots.flatMap((pot) => pot.winnerSeats) ?? []);
const result = view.showdown?.results.find((item) => item.seatId === view.viewerSeat);
const hasHint = handLabel !== null;
const opponents = view.players.filter((player) => player.seatId !== view.viewerSeat);
const viewerPlayer = view.players.find((player) => player.seatId === view.viewerSeat);
const popupsForSeat = (seatId: number): readonly ActivePokerPopup[] =>
popups.filter((popup) => popup.seatId === seatId && popup.tone !== "leave");
const viewerPopups = popupsForSeat(view.viewerSeat);
const tableAnnouncements = popups.filter((popup) => popup.tone === "leave");
return (
<div className="poker-table-stage">
<div
className={cn(
"poker-felt-table relative min-h-[36rem] overflow-hidden p-4 sm:min-h-[42rem]",
fullscreen && "poker-felt-fullscreen",
)}
>
{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-36 max-w-[34%] sm:w-52",
positionClass,
)}
>
<PokerSeat
player={player}
isCurrentUser={false}
isActing={view.phase === "betting" && player.seatId === view.toActSeat}
isWinner={winnerSeats.has(player.seatId)}
showHoleCards={view.phase !== "hand-complete" && view.phase !== "game-complete"}
/>
{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) => (
<PokerPopupBubble key={popup.key} popup={popup} />
))}
</div>
)}
<div className="pointer-events-none absolute -top-2.5 left-1/2 z-20 flex -translate-x-1/2 gap-1">
{player.seatId === view.buttonSeat && (
<span className="poker-dealer-button flex size-6 items-center justify-center rounded-full border text-xs font-bold">
D
</span>
)}
{player.seatId === view.smallBlindSeat && <Badge variant="secondary">SB</Badge>}
{player.seatId === view.bigBlindSeat && <Badge variant="secondary">BB</Badge>}
</div>
</div>
);
})}
<div className="poker-table-center absolute left-1/2 top-1/2 z-10 flex w-[min(26rem,72%)] -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-2 text-white">
<PokerDeckOrigin />
<div className="poker-pot-plaque">
<span className="poker-console-kicker">Pot</span>
<strong className="font-mono text-lg tabular-nums">
{view.potTotal.toLocaleString()}
</strong>
</div>
<div
className="poker-community-cards flex flex-wrap justify-center gap-1"
aria-label="Community cards"
>
{view.communityCards.length > 0 ? (
view.communityCards.map((card, index) => {
const isBestCard = bestCardKeys.has(cardKey(card));
return (
<CardView
key={`${card.rank}-${card.suit}`}
card={card}
size="md"
dealIndex={index + 1}
highlighted={hasHint && isBestCard}
dimmed={hasHint && !isBestCard}
className="poker-card-community"
/>
);
})
) : (
<span className="font-mono text-xs uppercase tracking-[0.2em] text-white/55">
Community cards
</span>
)}
</div>
{view.phase === "betting" && (
<div className="poker-table-turn-clock">
<PokerClock deadline={snapshot.turnDeadline} totalMs={view.config.turnTimeoutMs} />
</div>
)}
{(view.phase === "showdown" || view.phase === "hand-complete") && (
<div
className="flex w-full flex-col items-center gap-1 border border-white/15 bg-black/35 px-3 py-2 text-center"
aria-live="polite"
>
<p className="font-semibold">
{view.phase === "showdown" ? "Showdown" : "Hand complete"}
</p>
<p className="text-sm text-white/70">
{result
? `Your result: ${signedAmount(result.netChange)} chips`
: "The hand is settled."}
</p>
{snapshot.nextHandAt && (
<PokerClock deadline={snapshot.nextHandAt} mode="text" label="Next hand in" />
)}
</div>
)}
</div>
{view.phase !== "game-complete" && view.yourHoleCards.length > 0 && (
<div className="poker-viewer-cards" aria-label="Your hole cards">
{view.yourHoleCards.map((card, index) => (
<CardView
key={`felt-hole-${index}`}
card={card}
size="md"
dealIndex={index + 1}
highlighted={hasHint && bestCardKeys.has(cardKey(card))}
dimmed={hasHint && !bestCardKeys.has(cardKey(card))}
className={cn(
index === 0 ? "poker-card-viewer-left" : "poker-card-viewer-right",
viewerPlayer?.folded && "poker-card-folded",
)}
/>
))}
</div>
)}
<PokerActionToolbar
snapshot={snapshot}
onSnapshot={onSnapshot}
onActionFeedback={onActionFeedback}
/>
{viewerPopups.length > 0 && (
<div className="poker-popup-stack poker-popup-viewer-spot" aria-hidden="true">
{viewerPopups.map((popup) => (
<PokerPopupBubble 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) => (
<PokerPopupBubble key={popup.key} popup={popup} />
))}
</div>
)}
{view.phase === "game-complete" && (
<div className="poker-placement-board absolute left-1/2 top-1/2 z-20 flex w-[min(24rem,calc(100%-2rem))] -translate-x-1/2 -translate-y-1/2 justify-center">
<PlacementBoard view={view} />
</div>
)}
</div>
<PokerActionConsole
snapshot={snapshot}
handLabel={handLabel}
handTier={handTier}
hostSeatId={snapshot.hostSeatId}
starting={actionProps.starting}
leaving={actionProps.leaving}
onStart={actionProps.onStart}
onLeave={actionProps.onLeave}
/>
</div>
);
}