feat(games): refactor blackjack for continuous play, split/double, and table UI
Some checks failed
Deploy to Production / test (push) Failing after 32s

Transform blackjack from single-round to continuous-play table sessions with
round lifecycle (betting → playing → resolved → betting), split/double down
actions, per-hand bet tracking, leave/join table mid-session, and a responsive
felt-style table UI with arc-positioned player seats.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
syntaxbullet
2026-04-06 12:41:49 +02:00
parent ef78a85b9c
commit a36c05994c
12 changed files with 1907 additions and 554 deletions

View File

@@ -42,7 +42,7 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
const {
gameState, players, spectators, roomStatus,
isSpectator, gameOver, error, sendAction, leaveRoom, sessionReplaced, rejoin, fillRoom, startGame, roomOptions,
isSpectator, gameOver, roundResult, error, sendAction, leaveRoom, sessionReplaced, rejoin, fillRoom, startGame, roomOptions,
} = useGameRoom(roomId!, userId, role, preferAs);
const betAmount = roomOptions.betAmount ?? 0;
@@ -219,6 +219,8 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
isSpectator={isSpectator}
onAction={sendAction}
players={players}
roundResult={roundResult}
roomOptions={roomOptions}
/>
)}
</div>

View File

@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { useMemo, useState, useEffect, useCallback } from "react";
import type { GameUIProps } from "../registry";
import { Hand } from "lucide-react";
import { Hand, Square, ArrowLeftRight, ChevronsUp, LogOut, Check, Coins } from "lucide-react";
// ── Types matching server views ──
@@ -15,25 +15,35 @@ interface PlayerHandView {
status: "playing" | "stood" | "bust" | "blackjack";
result: "win" | "blackjack" | "push" | "lose" | null;
resultReason: string | null;
bet: number;
fromSplit: boolean;
}
interface PlayerSeatView {
hands: PlayerHandView[];
activeHandIndex: number;
hasBet: boolean;
}
interface BlackjackViewBase {
dealerHand: Card[];
dealerVisibleValue: number;
dealerFullValue: number | null;
hands: Record<string, PlayerHandView>;
seats: Record<string, PlayerSeatView>;
turnOrder: string[];
activePlayerId: string | null;
phase: "player_turns" | "resolved";
activeHandIndex: number;
phase: "betting" | "player_turns" | "resolved";
roundNumber: number;
}
interface PlayerView extends BlackjackViewBase {
myPlayerId: string;
canAct: boolean;
canSplit: boolean;
canDoubleDown: boolean;
}
interface SpectatorView extends BlackjackViewBase {}
function isPlayerView(state: unknown): state is PlayerView {
return typeof state === "object" && state !== null && "canAct" in state;
}
@@ -51,16 +61,31 @@ function cardImageUrl(card: Card): string {
return `/cards/${rank}_of_${card.suit}.svg`;
}
// ── Card rendering ──
// ── Seat layout positions (percentage-based for desktop arc) ──
function PlayingCard({ card, faceDown, compact }: { card: Card; faceDown?: boolean; compact?: boolean }) {
const SEAT_POSITIONS = [
{ left: "10%", top: "72%" }, // seat 1 — bottom-left
{ left: "2%", top: "42%" }, // seat 2 — mid-left
{ left: "16%", top: "16%" }, // seat 3 — top-left
{ left: "84%", top: "16%" }, // seat 4 — top-right (mirrored)
{ left: "98%", top: "42%" }, // seat 5 — mid-right
{ left: "90%", top: "72%" }, // seat 6 — bottom-right
] as const;
// ── Card component ──
function PlayingCard({ card, faceDown, size = "normal" }: {
card: Card;
faceDown?: boolean;
size?: "small" | "normal";
}) {
const isFaceDown = faceDown || card.rank === "?";
const sizeClass = compact
? "w-14 h-20 sm:w-16 sm:h-[5.5rem]"
: "w-18 h-25 sm:w-20 sm:h-28";
const sizeClass = size === "small"
? "w-9 h-[3.25rem] sm:w-11 sm:h-[3.875rem]"
: "w-12 h-[4.25rem] sm:w-14 sm:h-[5rem]";
return (
<div className={`relative ${sizeClass} rounded-lg overflow-hidden shadow-lg shrink-0`}>
<div className={`relative ${sizeClass} rounded-md overflow-hidden shadow-md shrink-0`}>
{isFaceDown ? (
<img
src="/cards/back.png"
@@ -72,7 +97,7 @@ function PlayingCard({ card, faceDown, compact }: { card: Card; faceDown?: boole
<img
src={cardImageUrl(card)}
alt={`${card.rank} of ${card.suit}`}
className="w-full h-full object-contain bg-white rounded-lg"
className="w-full h-full object-contain bg-white rounded-md"
draggable={false}
/>
)}
@@ -80,18 +105,36 @@ function PlayingCard({ card, faceDown, compact }: { card: Card; faceDown?: boole
);
}
// ── Card fan (overlapping cards) ──
function CardFan({ cards, faceDown, size = "normal" }: {
cards: Card[];
faceDown?: boolean;
size?: "small" | "normal";
}) {
return (
<div className="flex">
{cards.map((card, i) => (
<div key={`${card.suit}-${card.rank}-${i}`} className={i > 0 ? "-ml-5 sm:-ml-6" : ""}>
<PlayingCard card={card} faceDown={faceDown} size={size} />
</div>
))}
</div>
);
}
// ── Value badge ──
function ValueBadge({ value, status }: { value: number; status?: string }) {
const colorClass =
value === 21 || status === "blackjack"
? "bg-success/15 text-success font-bold"
? "bg-emerald-500/20 text-emerald-400 font-bold"
: value > 21 || status === "bust"
? "bg-destructive/15 text-destructive font-bold"
: "bg-card text-text-secondary";
? "bg-red-500/20 text-red-400 font-bold"
: "bg-black/30 text-white/80";
return (
<span className={`text-xs font-mono px-2 py-0.5 rounded-md ${colorClass}`}>
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${colorClass}`}>
{value}
</span>
);
@@ -101,158 +144,496 @@ function ValueBadge({ value, status }: { value: number; status?: string }) {
function ResultBadge({ result, reason }: { result: string; reason: string | null }) {
const config: Record<string, { label: string; color: string }> = {
blackjack: { label: "Blackjack!", color: "bg-warning/15 text-warning" },
win: { label: "Win", color: "bg-success/15 text-success" },
push: { label: "Push", color: "bg-info/15 text-info" },
lose: { label: "Lose", color: "bg-destructive/15 text-destructive" },
blackjack: { label: "Blackjack!", color: "bg-yellow-500/20 text-yellow-300" },
win: { label: "Win", color: "bg-emerald-500/20 text-emerald-400" },
push: { label: "Push", color: "bg-blue-500/20 text-blue-400" },
lose: { label: "Lose", color: "bg-red-500/20 text-red-400" },
};
const c = config[result] ?? { label: "", color: "bg-card text-text-tertiary" };
const c = config[result] ?? { label: "-", color: "bg-black/20 text-white/50" };
return (
<div className={`rounded-lg px-2.5 py-1 text-xs font-semibold ${c.color}`} title={reason ?? undefined}>
<span className={`rounded px-1.5 py-0.5 text-[10px] font-semibold ${c.color}`} title={reason ?? undefined}>
{c.label}
</span>
);
}
// ── Bet chip indicator ──
function BetChip({ bet, betAmount }: { bet: number; betAmount: number }) {
if (betAmount <= 0) return null;
const total = bet * betAmount;
return (
<div className="flex items-center gap-0.5 bg-yellow-500/20 text-yellow-300 rounded px-1.5 py-0.5">
<Coins className="w-2.5 h-2.5" />
<span className="text-[9px] font-bold">{total}</span>
</div>
);
}
// ── Player hand section ──
// ── Single hand display ──
function PlayerHandSection({ hand, playerName, isActive, isMe, compact }: {
function HandDisplay({ hand, isActive, betAmount, size = "normal" }: {
hand: PlayerHandView;
playerName: string;
isActive: boolean;
isMe: boolean;
compact: boolean;
betAmount: number;
size?: "small" | "normal";
}) {
return (
<div className={`rounded-xl px-3 py-3 transition-colors ${
isActive ? "bg-primary/5 ring-1 ring-primary/20" : "bg-card/50"
<div className={`flex flex-col items-center gap-1 rounded-lg px-1.5 py-1 transition-all ${
isActive ? "ring-1 ring-primary/40 bg-primary/5" : ""
}`}>
{/* Header */}
<div className="flex items-center justify-between gap-2 mb-2">
<div className="flex items-center gap-2 min-w-0">
<span className={`text-sm font-label font-semibold truncate ${
isActive ? "text-primary" : "text-text-tertiary"
}`}>
{playerName}{isMe ? " (You)" : ""}
</span>
{isActive && (
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse shrink-0" />
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
<ValueBadge value={hand.value} status={hand.status} />
{hand.result && <ResultBadge result={hand.result} reason={hand.resultReason} />}
</div>
<CardFan cards={hand.cards} size={size} />
<div className="flex items-center gap-1 flex-wrap justify-center">
<ValueBadge value={hand.value} status={hand.status} />
{hand.bet > 1 && <BetChip bet={hand.bet} betAmount={betAmount} />}
{hand.result && <ResultBadge result={hand.result} reason={hand.resultReason} />}
</div>
{/* Cards */}
<div className="flex gap-1.5 flex-wrap">
{hand.cards.map((card, i) => (
<PlayingCard key={`${card.suit}-${card.rank}-${i}`} card={card} compact={compact} />
))}
</div>
);
}
// ── Player seat ──
function Seat({ seat, playerName, isMe, isActivePlayer, activeHandIdx, betAmount }: {
seat: PlayerSeatView;
playerName: string;
isMe: boolean;
isActivePlayer: boolean;
activeHandIdx: number;
betAmount: number;
}) {
const hasBet = seat.hasBet;
const hasHands = seat.hands.length > 0;
const compact = seat.hands.length > 2;
return (
<div className={`flex flex-col items-center gap-1.5 transition-all ${
isActivePlayer ? "scale-105" : ""
}`}>
{/* Player label */}
<div className="flex items-center gap-1.5">
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold ${
isActivePlayer ? "bg-primary/30 text-primary ring-2 ring-primary/50" : "bg-white/10 text-white/70"
}`}>
{playerName[0]?.toUpperCase() ?? "?"}
</div>
<span className={`text-xs font-medium truncate max-w-20 ${
isActivePlayer ? "text-primary" : "text-white/70"
}`}>
{playerName}{isMe ? "" : ""}
</span>
{isActivePlayer && (
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
)}
</div>
{/* Bet status (during betting phase, no hands yet) */}
{!hasHands && (
<div className={`flex items-center gap-1 text-[10px] rounded-full px-2 py-0.5 ${
hasBet ? "bg-emerald-500/20 text-emerald-400" : "bg-white/5 text-white/40"
}`}>
{hasBet ? <><Check className="w-2.5 h-2.5" /> Ready</> : "Waiting..."}
</div>
)}
{/* Hands */}
{hasHands && (
<div className={`flex gap-1 ${seat.hands.length > 1 ? "flex-row" : "flex-col"}`}>
{seat.hands.map((hand, hi) => (
<HandDisplay
key={hi}
hand={hand}
isActive={isActivePlayer && hi === activeHandIdx}
betAmount={betAmount}
size={compact ? "small" : "normal"}
/>
))}
</div>
)}
{/* Bet amount indicator */}
{hasHands && betAmount > 0 && (
<BetChip bet={seat.hands.reduce((sum, h) => sum + h.bet, 0)} betAmount={betAmount} />
)}
</div>
);
}
// ── Empty seat ──
function EmptySeat({ canSit, onSit }: { canSit: boolean; onSit: () => void }) {
return (
<div className="flex flex-col items-center gap-1.5">
<div className={`w-6 h-6 rounded-full border border-dashed flex items-center justify-center text-[10px] ${
canSit ? "border-white/30 text-white/30" : "border-white/10 text-white/10"
}`}>
+
</div>
{canSit ? (
<button
onClick={onSit}
className="text-[10px] text-primary/70 hover:text-primary transition-colors"
>
Sit
</button>
) : (
<span className="text-[10px] text-white/15">Empty</span>
)}
</div>
);
}
// ── Dealer area ──
function DealerArea({ dealerHand, visibleValue, fullValue }: {
dealerHand: Card[];
visibleValue: number;
fullValue: number | null;
}) {
if (dealerHand.length === 0) {
return (
<div className="flex flex-col items-center gap-1.5">
<span className="text-xs font-semibold text-white/50">Dealer</span>
<div className="w-12 h-[4.25rem] sm:w-14 sm:h-[5rem] rounded-md border border-dashed border-white/10" />
</div>
);
}
const displayValue = fullValue ?? visibleValue;
const isBust = fullValue !== null && fullValue > 21;
return (
<div className="flex flex-col items-center gap-1.5">
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-white/60">Dealer</span>
{displayValue > 0 && (
<ValueBadge value={displayValue} status={isBust ? "bust" : undefined} />
)}
</div>
<CardFan cards={dealerHand} />
</div>
);
}
// ── Round result banner ──
function RoundResultBanner({ roundNumber, roundResult, myPlayerId, betAmount }: {
roundNumber: number;
roundResult: GameUIProps["roundResult"];
myPlayerId: string;
betAmount: number;
}) {
const myPayout = roundResult?.payouts[myPlayerId];
return (
<div className="flex items-center justify-center gap-3 bg-black/30 rounded-xl px-4 py-2">
<span className="text-xs text-white/50">Round {roundNumber}</span>
{myPayout && betAmount > 0 && (
<span className={`text-xs font-semibold ${
myPayout.net > betAmount ? "text-emerald-400" : myPayout.net === betAmount ? "text-blue-400" : "text-white/60"
}`}>
+{myPayout.net} AU
</span>
)}
</div>
);
}
// ── Main Component ──
export function BlackjackGame({ state, myPlayerId, isSpectator, onAction, players }: GameUIProps) {
const view = state as PlayerView | SpectatorView;
export function BlackjackGame({ state, myPlayerId, isSpectator, onAction, players, roundResult, roomOptions }: GameUIProps) {
const view = state as PlayerView | BlackjackViewBase;
const playerView = isPlayerView(state) ? state : null;
const betAmount = roomOptions?.betAmount ?? 0;
const isResolved = view.phase === "resolved";
const compact = view.turnOrder.length > 3;
const isBetting = view.phase === "betting";
const isPlaying = view.phase === "player_turns";
const getPlayerName = useMemo(() => {
const nameMap = new Map(players.map(p => [p.discordId, p.username]));
return (id: string) => nameMap.get(id) ?? id.slice(0, 8);
}, [players]);
const mySeat = view.seats[myPlayerId];
const myBetPlaced = mySeat?.hasBet ?? false;
// Determine who can sit (spectators during betting phase)
const canSitDown = isSpectator && isBetting && view.turnOrder.length < 6;
// Build the seat map for rendering (up to 6 seats)
const seatSlots = useMemo(() => {
const slots: Array<{ playerId: string | null; seat: PlayerSeatView | null }> = [];
// Fill with seated players
for (const pid of view.turnOrder) {
slots.push({ playerId: pid, seat: view.seats[pid] ?? null });
}
// Fill remaining with empty seats up to 6
while (slots.length < 6) {
slots.push({ playerId: null, seat: null });
}
return slots;
}, [view.turnOrder, view.seats]);
// Auto-place bet after resolved phase (with delay for UX)
const [showNextRound, setShowNextRound] = useState(false);
useEffect(() => {
if (isResolved) {
const timer = setTimeout(() => setShowNextRound(true), 2000);
return () => clearTimeout(timer);
}
setShowNextRound(false);
}, [isResolved, view.roundNumber]);
const handlePlaceBet = useCallback(() => onAction({ type: "place_bet" }), [onAction]);
const handleHit = useCallback(() => onAction({ type: "hit" }), [onAction]);
const handleStand = useCallback(() => onAction({ type: "stand" }), [onAction]);
const handleSplit = useCallback(() => onAction({ type: "split" }), [onAction]);
const handleDouble = useCallback(() => onAction({ type: "double_down" }), [onAction]);
const handleLeave = useCallback(() => onAction({ type: "leave_table" }), [onAction]);
const handleSit = useCallback(() => onAction({ type: "sit_down" }), [onAction]);
return (
<div className="flex flex-col gap-4 max-w-2xl mx-auto">
{/* Dealer section */}
<div className="rounded-xl bg-card/50 px-3 py-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-label font-semibold text-text-tertiary">
Dealer
</span>
{(view.dealerFullValue !== null || view.dealerVisibleValue) && (
<ValueBadge
value={view.dealerFullValue ?? view.dealerVisibleValue}
status={view.dealerFullValue !== null && view.dealerFullValue > 21 ? "bust" : undefined}
/>
)}
</div>
<div className="flex gap-1.5 flex-wrap">
{view.dealerHand.map((card, i) => (
<PlayingCard
key={`dealer-${card.suit}-${card.rank}-${i}`}
card={card}
faceDown={card.rank === "?"}
compact={compact}
/>
))}
</div>
</div>
{/* Divider */}
<div className="h-px bg-border" />
{/* Player hands */}
<div className="space-y-2">
{view.turnOrder.map(pid => {
const hand = view.hands[pid];
if (!hand) return null;
const isActive = view.activePlayerId === pid;
const isMe = !isSpectator && pid === myPlayerId;
return (
<PlayerHandSection
key={pid}
hand={hand}
playerName={getPlayerName(pid)}
isActive={isActive}
isMe={isMe}
compact={compact}
/>
);
})}
</div>
{/* Action buttons (only for current player on their turn) */}
{!isSpectator && !isResolved && playerView?.canAct && (
<div className="flex gap-3">
<div className="flex flex-col gap-3 w-full max-w-4xl mx-auto">
{/* Leave table button */}
{!isSpectator && (
<div className="flex justify-end">
<button
onClick={() => onAction({ type: "hit" })}
className="flex-1 flex items-center justify-center gap-2 rounded-xl bg-primary text-on-primary px-4 py-3 text-sm font-label font-semibold hover:opacity-90 transition-colors"
onClick={handleLeave}
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium bg-white/5 text-white/50 hover:text-white/80 hover:bg-white/10 transition-colors"
>
<Hand className="w-4 h-4" /> Hit
</button>
<button
onClick={() => onAction({ type: "stand" })}
className="flex-1 flex items-center justify-center gap-2 rounded-xl bg-raised text-foreground px-4 py-3 text-sm font-label font-semibold hover:bg-surface-container-high transition-colors"
>
Stand
<LogOut className="w-3 h-3" /> Leave Table
</button>
</div>
)}
{/* Waiting for other player */}
{!isSpectator && !isResolved && playerView && !playerView.canAct && (
<div className="rounded-xl bg-card px-4 py-2.5 text-sm text-text-tertiary text-center">
{view.activePlayerId
? `Waiting for ${getPlayerName(view.activePlayerId)}...`
: "Waiting..."}
{/* Table felt */}
<div className="relative bg-gradient-to-b from-emerald-800 to-emerald-900 rounded-2xl sm:rounded-[2rem] border-2 sm:border-4 border-emerald-950/60 shadow-[inset_0_0_60px_rgba(0,0,0,0.3)] overflow-hidden">
{/* ── Desktop layout (md+): absolute positioned seats ── */}
<div className="hidden md:block relative" style={{ aspectRatio: "16/10" }}>
{/* Dealer — top center */}
<div className="absolute top-[6%] left-1/2 -translate-x-1/2 z-10">
<DealerArea
dealerHand={view.dealerHand}
visibleValue={view.dealerVisibleValue}
fullValue={view.dealerFullValue}
/>
</div>
{/* Table text */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-center pointer-events-none select-none">
<div className="text-white/[0.06] font-display text-4xl font-bold tracking-widest uppercase">
Blackjack
</div>
{isBetting && (
<div className="text-white/20 text-xs mt-1">
Round {view.roundNumber} Place your bets
</div>
)}
{isResolved && (
<div className="text-white/20 text-xs mt-1">
Round {view.roundNumber} Results
</div>
)}
</div>
{/* Seats in arc */}
{seatSlots.map((slot, i) => (
<div
key={i}
className="absolute -translate-x-1/2 -translate-y-1/2 z-10"
style={{ left: SEAT_POSITIONS[i]!.left, top: SEAT_POSITIONS[i]!.top }}
>
{slot.playerId && slot.seat ? (
<Seat
seat={slot.seat}
playerName={getPlayerName(slot.playerId)}
isMe={slot.playerId === myPlayerId}
isActivePlayer={view.activePlayerId === slot.playerId}
activeHandIdx={view.activePlayerId === slot.playerId ? view.activeHandIndex : -1}
betAmount={betAmount}
/>
) : (
<EmptySeat canSit={canSitDown} onSit={handleSit} />
)}
</div>
))}
</div>
{/* ── Mobile layout (<md): vertical stack ── */}
<div className="md:hidden flex flex-col gap-4 p-4">
{/* Dealer */}
<DealerArea
dealerHand={view.dealerHand}
visibleValue={view.dealerVisibleValue}
fullValue={view.dealerFullValue}
/>
{/* Divider */}
<div className="h-px bg-white/10" />
{/* Phase label */}
{isBetting && (
<div className="text-center text-white/30 text-xs">
Round {view.roundNumber} Place your bets
</div>
)}
{isResolved && (
<div className="text-center text-white/30 text-xs">
Round {view.roundNumber} Results
</div>
)}
{/* Player seats */}
<div className="space-y-3">
{seatSlots.map((slot, i) => {
if (!slot.playerId || !slot.seat) {
if (canSitDown && i === view.turnOrder.length) {
return (
<div key={i} className="flex justify-center">
<EmptySeat canSit={true} onSit={handleSit} />
</div>
);
}
return null;
}
return (
<div
key={slot.playerId}
className={`rounded-xl px-3 py-2 transition-colors ${
view.activePlayerId === slot.playerId
? "bg-white/5 ring-1 ring-primary/20"
: "bg-white/[0.02]"
}`}
>
<Seat
seat={slot.seat}
playerName={getPlayerName(slot.playerId)}
isMe={slot.playerId === myPlayerId}
isActivePlayer={view.activePlayerId === slot.playerId}
activeHandIdx={view.activePlayerId === slot.playerId ? view.activeHandIndex : -1}
betAmount={betAmount}
/>
</div>
);
})}
</div>
</div>
</div>
{/* Round result banner */}
{isResolved && roundResult && (
<RoundResultBanner
roundNumber={view.roundNumber}
roundResult={roundResult}
myPlayerId={myPlayerId}
betAmount={betAmount}
/>
)}
{/* ── Action bar ── */}
{!isSpectator && (
<div className="flex flex-col gap-2">
{/* Betting phase: place bet button */}
{isBetting && !myBetPlaced && (
<button
onClick={handlePlaceBet}
className="w-full flex items-center justify-center gap-2 rounded-xl bg-primary text-on-primary px-4 py-3 text-sm font-label font-semibold hover:opacity-90 transition-colors"
>
<Coins className="w-4 h-4" />
{betAmount > 0 ? `Place Bet (${betAmount} AU)` : "Ready Up"}
</button>
)}
{/* Betting phase: waiting for others */}
{isBetting && myBetPlaced && (
<div className="rounded-xl bg-card px-4 py-2.5 text-sm text-text-tertiary text-center">
Waiting for other players to bet...
</div>
)}
{/* Playing phase: action buttons */}
{isPlaying && playerView?.canAct && (
<div className="flex gap-2">
<button
onClick={handleHit}
className="flex-1 flex items-center justify-center gap-2 rounded-xl bg-primary text-on-primary px-4 py-3 text-sm font-label font-semibold hover:opacity-90 transition-colors"
>
<Hand className="w-4 h-4" /> Hit
</button>
<button
onClick={handleStand}
className="flex-1 flex items-center justify-center gap-2 rounded-xl bg-raised text-foreground px-4 py-3 text-sm font-label font-semibold hover:bg-surface-container-high transition-colors"
>
<Square className="w-3.5 h-3.5" /> Stand
</button>
{playerView.canSplit && (
<button
onClick={handleSplit}
className="flex items-center justify-center gap-2 rounded-xl bg-raised text-foreground px-4 py-3 text-sm font-label font-semibold hover:bg-surface-container-high transition-colors"
>
<ArrowLeftRight className="w-4 h-4" /> Split
</button>
)}
{playerView.canDoubleDown && (
<button
onClick={handleDouble}
className="flex items-center justify-center gap-2 rounded-xl bg-raised text-foreground px-4 py-3 text-sm font-label font-semibold hover:bg-surface-container-high transition-colors"
>
<ChevronsUp className="w-4 h-4" /> Double
</button>
)}
</div>
)}
{/* Playing phase: waiting for other player */}
{isPlaying && playerView && !playerView.canAct && (
<div className="rounded-xl bg-card px-4 py-2.5 text-sm text-text-tertiary text-center">
{view.activePlayerId
? `Waiting for ${getPlayerName(view.activePlayerId)}...`
: "Waiting..."}
</div>
)}
{/* Resolved phase: next round button */}
{isResolved && showNextRound && (
<button
onClick={handlePlaceBet}
className="w-full flex items-center justify-center gap-2 rounded-xl bg-primary text-on-primary px-4 py-3 text-sm font-label font-semibold hover:opacity-90 transition-colors"
>
<Coins className="w-4 h-4" />
{betAmount > 0 ? `Next Round (${betAmount} AU)` : "Next Round"}
</button>
)}
{/* Resolved phase: waiting for countdown */}
{isResolved && !showNextRound && (
<div className="rounded-xl bg-card px-4 py-2.5 text-sm text-text-tertiary text-center">
Round complete next round starting soon...
</div>
)}
</div>
)}
{/* Spectator indicator */}
{isSpectator && !isResolved && (
{isSpectator && !canSitDown && (
<div className="rounded-xl bg-card px-4 py-2.5 text-xs text-text-tertiary text-center">
{view.activePlayerId
{isPlaying && view.activePlayerId
? `${getPlayerName(view.activePlayerId)}'s turn`
: "Watching..."}
</div>
)}
{/* Spectator sit-down prompt */}
{canSitDown && (
<button
onClick={handleSit}
className="w-full flex items-center justify-center gap-2 rounded-xl bg-primary/10 text-primary px-4 py-3 text-sm font-label font-semibold hover:bg-primary/20 transition-colors"
>
Sit Down & Join
</button>
)}
</div>
);
}

View File

@@ -6,6 +6,8 @@ export interface GameUIProps {
isSpectator: boolean;
onAction: (action: unknown) => void;
players: { discordId: string; username: string }[];
roundResult?: { payouts: Record<string, { net: number }> } | null;
roomOptions?: { betAmount?: number };
}
export interface GameUIPlugin {

View File

@@ -7,6 +7,10 @@ interface PlayerInfo {
username: string;
}
interface RoundResult {
payouts: Record<string, { net: number }>;
}
interface GameRoomState {
gameState: unknown;
players: PlayerInfo[];
@@ -14,6 +18,7 @@ interface GameRoomState {
roomStatus: "connecting" | "waiting" | "playing" | "finished" | "not_found";
isSpectator: boolean;
gameOver: { winner: string | null; reason: string; payout?: { amount: number; refunded?: boolean } } | null;
roundResult: RoundResult | null;
error: string | null;
sessionReplaced: boolean;
roomOptions: { betAmount?: number };
@@ -36,6 +41,7 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe
roomStatus: "connecting",
isSpectator: false,
gameOver: null,
roundResult: null,
error: null,
sessionReplaced: false,
roomOptions: {},
@@ -64,11 +70,17 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe
case "GAME_STATE":
// Authoritative player view — sent directly to this player
setState(prev => ({
...prev,
gameState: msg.state,
roomStatus: prev.roomStatus === "finished" ? "finished" : "playing",
}));
setState(prev => {
// Clear round result when a new betting phase starts
const phase = (msg.state as any)?.phase;
const roundResult = phase === "betting" ? null : prev.roundResult;
return {
...prev,
gameState: msg.state,
roundResult,
roomStatus: prev.roomStatus === "finished" ? "finished" : "playing",
};
});
break;
case "GAME_STARTED":
@@ -116,6 +128,13 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe
}));
break;
case "ROUND_SETTLED":
setState(prev => ({
...prev,
roundResult: { payouts: msg.payouts },
}));
break;
case "GAME_ENDED":
setState(prev => ({
...prev,