diff --git a/api/src/games/GameServer.ts b/api/src/games/GameServer.ts index bca6199..1545ef5 100644 --- a/api/src/games/GameServer.ts +++ b/api/src/games/GameServer.ts @@ -88,32 +88,30 @@ export class GameServer { this.publishRoomListUpdate(); }); - this.roomManager.emitter.on("round:settled", async ({ roomId, roundPayouts }) => { + this.roomManager.emitter.on("round:settled", async ({ roomId, roundSettlements }) => { const room = this.roomManager.getRoom(roomId); if (!room || room.betAmount <= 0) return; - const betAmount = room.betAmount; const gameName = gameRegistry.get(room.gameSlug)?.name ?? "Game"; - const payoutDetails: Record = {}; + const settlementDetails: typeof roundSettlements = {}; - for (const [playerId, multiplier] of Object.entries(roundPayouts)) { - // roundPayout contains multipliers (e.g., win=2, blackjack=2.5, push=1) - const grossAmount = Math.floor(betAmount * multiplier); - const netProfit = grossAmount - betAmount; + for (const [playerId, settlement] of Object.entries(roundSettlements)) { try { - await economyService.modifyUserBalance( - playerId, - BigInt(grossAmount), - TransactionType.GAME_WIN, - `${gameName} round payout (room ${roomId.slice(0, 8)})`, - ); - payoutDetails[playerId] = { net: netProfit }; + if (settlement.payout > 0) { + await economyService.modifyUserBalance( + playerId, + BigInt(settlement.payout), + TransactionType.GAME_WIN, + `${gameName} round payout (room ${roomId.slice(0, 8)})`, + ); + } + settlementDetails[playerId] = settlement; } catch (err) { logger.error("web", `Round payout failed for ${playerId} in room ${roomId}: ${err}`); } } - if (Object.keys(payoutDetails).length > 0) { - this.publish(`room:${roomId}`, { type: "ROUND_SETTLED", roomId, payouts: payoutDetails }); + if (Object.keys(settlementDetails).length > 0) { + this.publish(`room:${roomId}`, { type: "ROUND_SETTLED", roomId, settlements: settlementDetails }); } }); diff --git a/api/src/games/RoomManager.ts b/api/src/games/RoomManager.ts index d7da3df..015e10a 100644 --- a/api/src/games/RoomManager.ts +++ b/api/src/games/RoomManager.ts @@ -1,6 +1,7 @@ import mitt from "mitt"; import { gameRegistry } from "@shared/games/registry"; import type { Room, RoomSummary } from "./types"; +import type { RoundSettlement } from "@shared/games/types"; const ROOM_CONFIG = { WAITING_CLEANUP_MS: 60_000, @@ -9,7 +10,7 @@ const ROOM_CONFIG = { } as const; type ActionResult = - | { ok: true; state: unknown; gameOver: { winner: string | null; reason: string } | null; roundPayouts?: Record } + | { ok: true; state: unknown; gameOver: { winner: string | null; reason: string } | null; roundSettlements?: Record } | { ok: false; error: string }; type CreateResult = { ok: true; roomId: string } | { ok: false; error: string }; @@ -24,7 +25,7 @@ type RoomEvents = { "game:started": { roomId: string; spectatorView: unknown; playerViews: Map }; "game:updated": { roomId: string; spectatorView: unknown; playerViews: Map }; "game:ended": { roomId: string; winner: string | null; reason: string; payouts?: Record }; - "round:settled": { roomId: string; roundPayouts: Record }; + "round:settled": { roomId: string; roundSettlements: Record }; "player:left": { roomId: string; playerId: string }; "room:deleted": { roomId: string }; "room:list:changed": void; @@ -138,8 +139,8 @@ export class RoomManager { this.emitter.emit("game:updated", { roomId, spectatorView, playerViews }); // Emit round payouts for mid-game settlement (continuous-play games) - if (result.roundPayouts && !gameOver) { - this.emitter.emit("round:settled", { roomId, roundPayouts: result.roundPayouts }); + if (result.roundSettlements && !gameOver) { + this.emitter.emit("round:settled", { roomId, roundSettlements: result.roundSettlements }); } if (gameOver) { @@ -147,7 +148,7 @@ export class RoomManager { this.emitter.emit("room:list:changed"); } - return { ok: true, state: room.state, gameOver, roundPayouts: result.roundPayouts }; + return { ok: true, state: room.state, gameOver, roundSettlements: result.roundSettlements }; } leaveRoom(roomId: string, playerId: string): void { diff --git a/api/src/games/types.ts b/api/src/games/types.ts index 74c5ea1..073a7a7 100644 --- a/api/src/games/types.ts +++ b/api/src/games/types.ts @@ -1,3 +1,4 @@ +import type { RoundSettlement } from "@shared/games/types"; import { z } from "zod"; export interface Room { @@ -59,6 +60,6 @@ export type GameWsServerMessage = | { type: "GAME_ENDED"; roomId: string; winner: string | null; reason: string; payout?: { amount: number; refunded?: boolean } } | { type: "ROOM_CREATED"; roomId: string; gameSlug: string } | { type: "JOIN_RESULT"; roomId: string; joinedAs: "player" | "spectator"; roomStatus: "waiting" | "playing" | "finished"; players: PlayerInfo[]; spectators: PlayerInfo[]; state?: unknown; roomOptions?: { betAmount?: number; timeControl?: string } } - | { type: "ROUND_SETTLED"; roomId: string; payouts: Record } + | { type: "ROUND_SETTLED"; roomId: string; settlements: Record } | { type: "SESSION_REPLACED"; roomId: string } | { type: "ERROR"; message: string }; diff --git a/panel/src/games/blackjack/BlackjackGame.tsx b/panel/src/games/blackjack/BlackjackGame.tsx index 9a4a077..ec6b452 100644 --- a/panel/src/games/blackjack/BlackjackGame.tsx +++ b/panel/src/games/blackjack/BlackjackGame.tsx @@ -1,8 +1,20 @@ -import { useMemo, useState, useEffect, useCallback } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { CSSProperties, ReactNode } from "react"; +import { + AlertCircle, + ArrowLeftRight, + Check, + ChevronsUp, + Coins, + Eye, + Hand, + type LucideIcon, + LogOut, + Plus, + Square, + Trophy, +} from "lucide-react"; import type { GameUIProps } from "../registry"; -import { Hand, Square, ArrowLeftRight, ChevronsUp, LogOut, Check, Coins, Trophy, AlertCircle } from "lucide-react"; - -// ── Types matching server views ── interface Card { suit: "hearts" | "diamonds" | "clubs" | "spades"; @@ -16,6 +28,9 @@ interface PlayerHandView { result: "win" | "blackjack" | "push" | "lose" | null; resultReason: string | null; bet: number; + wager: number; + payout: number | null; + net: number | null; fromSplit: boolean; } @@ -23,7 +38,8 @@ interface PlayerSeatView { hands: PlayerHandView[]; activeHandIndex: number; hasBet: boolean; - /** Cumulative PnL from all previous rounds. */ + totalWager: number; + roundNet: number | null; cumulativePnl: number; } @@ -44,7 +60,6 @@ interface PlayerView extends BlackjackViewBase { canAct: boolean; canSplit: boolean; canDoubleDown: boolean; - /** Cumulative PnL for the current player. */ myCumulativePnl: number; } @@ -52,270 +67,367 @@ function isPlayerView(state: unknown): state is PlayerView { return typeof state === "object" && state !== null && "canAct" in state; } -// ── Card image mapping ── - const RANK_TO_FILENAME: Record = { - "A": "ace", "2": "2", "3": "3", "4": "4", "5": "5", - "6": "6", "7": "7", "8": "8", "9": "9", "10": "10", - "J": "jack", "Q": "queen", "K": "king", + A: "ace", + "2": "2", + "3": "3", + "4": "4", + "5": "5", + "6": "6", + "7": "7", + "8": "8", + "9": "9", + "10": "10", + J: "jack", + Q: "queen", + K: "king", }; +const CARD_FACE_STYLE = { + backfaceVisibility: "hidden", + WebkitBackfaceVisibility: "hidden", +} satisfies CSSProperties; + function cardImageUrl(card: Card): string { const rank = RANK_TO_FILENAME[card.rank] ?? card.rank; return `/cards/${rank}_of_${card.suit}.svg`; } -// ── Seat layout positions (percentage-based for desktop arc) ── +function formatAu(value: number): string { + return `${value} AU`; +} -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; +function formatSignedAu(value: number): string { + return `${value > 0 ? "+" : ""}${value} AU`; +} -// ── Card component ── +function phaseLabel(phase: BlackjackViewBase["phase"]): string { + switch (phase) { + case "betting": + return "Betting Open"; + case "player_turns": + return "Hands In Play"; + case "resolved": + return "Round Settled"; + } +} -function PlayingCard({ card, faceDown, size = "normal" }: { +function phaseDescription( + phase: BlackjackViewBase["phase"], + activePlayerName: string | null, + betAmount: number, +): string { + if (phase === "betting") { + return betAmount > 0 + ? `Table stake is ${formatAu(betAmount)}. Splits and doubles add the same amount.` + : "Free play table. Lock in when you are ready."; + } + if (phase === "player_turns") { + return activePlayerName ? `${activePlayerName} is on the clock.` : "Hands are live."; + } + return "Dealer has finished drawing. Review the results and queue the next round."; +} + +function toneClasses(value: number): string { + if (value > 0) return "border-emerald-400/30 bg-emerald-500/10 text-emerald-300"; + if (value < 0) return "border-red-400/30 bg-red-500/10 text-red-300"; + return "border-white/10 bg-white/5 text-white/70"; +} + +function statusChip(status: PlayerHandView["status"]): { label: string; className: string } { + switch (status) { + case "blackjack": + return { label: "Blackjack", className: "border-yellow-400/30 bg-yellow-400/15 text-yellow-100" }; + case "bust": + return { label: "Bust", className: "border-red-400/30 bg-red-500/15 text-red-200" }; + case "stood": + return { label: "Standing", className: "border-blue-400/30 bg-blue-500/15 text-blue-100" }; + case "playing": + return { label: "Playing", className: "border-emerald-400/30 bg-emerald-500/15 text-emerald-100" }; + } +} + +function resultChip(result: PlayerHandView["result"]): { label: string; className: string; icon: LucideIcon } | null { + switch (result) { + case "blackjack": + return { label: "Paid 3:2", className: "border-yellow-400/30 bg-yellow-400/15 text-yellow-100", icon: Trophy }; + case "win": + return { label: "Win", className: "border-emerald-400/30 bg-emerald-500/15 text-emerald-100", icon: ChevronsUp }; + case "push": + return { label: "Push", className: "border-blue-400/30 bg-blue-500/15 text-blue-100", icon: Check }; + case "lose": + return { label: "Lose", className: "border-red-400/30 bg-red-500/15 text-red-100", icon: AlertCircle }; + default: + return null; + } +} + +function PlayingCard({ + card, + faceDown = false, + size = "normal", + index = 0, +}: { card: Card; faceDown?: boolean; - size?: "small" | "normal"; + size?: "compact" | "normal"; + index?: number; }) { - const isFaceDown = faceDown || card.rank === "?"; - 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]"; + const isHidden = faceDown || card.rank === "?"; + const cardSize = size === "compact" + ? "h-[4.6rem] w-[3.2rem] sm:h-[5rem] sm:w-[3.5rem]" + : "h-[5.4rem] w-[3.8rem] sm:h-[6.25rem] sm:w-[4.4rem]"; return ( -
- {isFaceDown ? ( - Card back - ) : ( - {`${card.rank} - )} +
+
+
+ Card back +
+
+ {!isHidden && ( + {`${card.rank} + )} +
+
); } -// ── Card fan (overlapping cards) ── - -function CardFan({ cards, faceDown, size = "normal" }: { +function CardFan({ + cards, + hiddenIndex = -1, + size = "normal", +}: { cards: Card[]; - faceDown?: boolean; - size?: "small" | "normal"; + hiddenIndex?: number; + size?: "compact" | "normal"; }) { + const overlap = size === "compact" ? "-ml-5 sm:-ml-6" : "-ml-6 sm:-ml-7"; + return ( -
- {cards.map((card, i) => ( -
0 ? "-ml-5 sm:-ml-6" : ""}> - +
+ {cards.map((card, index) => ( +
+
))}
); } -// ── Value badge --- - -function ValueBadge({ value, status }: { value: number; status?: string }) { - const colorClass = - value === 21 || status === "blackjack" - ? "bg-gradient-to-r from-emerald-500 to-emerald-600 text-white font-bold shadow-sm" - : value > 21 || status === "bust" - ? "bg-gradient-to-r from-red-500 to-red-600 text-white font-bold shadow-sm" - : "bg-white/10 backdrop-blur-sm text-white border border-white/20"; - +function InfoPill({ label, value }: { label: string; value: string }) { return ( - - {value} - - ); -} - -// ── Result badge ── - -function ResultBadge({ result, reason }: { result: string; reason: string | null }) { - const config: Record = { - blackjack: { - label: "Blackjack!", - color: "bg-gradient-to-r from-yellow-400 to-amber-500 text-white font-bold shadow-sm", - icon: - }, - win: { - label: "Win", - color: "bg-gradient-to-r from-emerald-500 to-emerald-600 text-white font-bold shadow-sm", - icon: - }, - push: { - label: "Push", - color: "bg-gradient-to-r from-blue-500 to-blue-600 text-white font-bold shadow-sm", - icon: - }, - lose: { - label: "Lose", - color: "bg-gradient-to-r from-red-500 to-red-600 text-white font-bold shadow-sm", - icon: - }, - }; - const c = config[result] ?? { label: "-", color: "bg-white/10 text-white/50" }; - - return ( - - {c.icon} - {c.label} - - ); -} - -// ── Bet chip indicator --- - -function BetChip({ bet, betAmount }: { bet: number; betAmount: number }) { - if (betAmount <= 0) return null; - const total = bet * betAmount; - return ( -
- - {total} +
+ {label} + {value}
); } -// ── Single hand display --- +function MetricCard({ label, value, hint, tone = "neutral" }: { + label: string; + value: string; + hint?: string; + tone?: "neutral" | "positive" | "negative"; +}) { + const toneClass = tone === "positive" + ? "border-emerald-400/30 bg-emerald-500/10" + : tone === "negative" + ? "border-red-400/30 bg-red-500/10" + : "border-white/10 bg-white/5"; -function HandDisplay({ hand, isActive, betAmount, size = "normal" }: { + return ( +
+
{label}
+
{value}
+ {hint &&
{hint}
} +
+ ); +} + +function HandPanel({ + hand, + handIndex, + isActive, + showMoney, +}: { hand: PlayerHandView; + handIndex: number; isActive: boolean; - betAmount: number; - size?: "small" | "normal"; + showMoney: boolean; }) { - return ( -
- -
- - {hand.bet > 1 && } - {hand.result && } -
-
- ); -} - -// ── 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; - - // Calculate current round PnL for the display (only needed before results are shown) - const currentRoundPnl = seat.hands.reduce((sum, h) => { - if (!h.result) return sum; // Not yet resolved - const handWin = h.bet * betAmount; - if (h.result === "win" || h.result === "blackjack") return sum + (handWin - betAmount); // Net profit - if (h.result === "push") return sum; // Push returns bet, so net is 0 - return sum - betAmount; // Lose loses the bet amount - }, 0); - const totalPnl = seat.cumulativePnl + currentRoundPnl; - - // Determine PnL color theme - const getPnlTheme = () => { - if (totalPnl > 0) return "emerald"; - if (totalPnl < 0) return "red"; - return "blue"; - }; - - const pnlColor = getPnlTheme(); - const isMeStyle = isActivePlayer && isMe ? "ring-2 ring-primary/50 bg-gradient-to-br from-white/10 to-white/5" : isActivePlayer ? "ring-1 ring-primary/30 bg-primary/5" : "ring-1 ring-white/10 bg-white/[0.02]"; + const status = statusChip(hand.status); + const outcome = resultChip(hand.result); + const net = hand.net ?? 0; return ( -
- {/* Player label with avatar and active indicator */} -
-
- {playerName[0]?.toUpperCase() ?? "?"} - {isMe && } +
+
+
+ {hand.fromSplit ? `Split Hand ${handIndex + 1}` : `Hand ${handIndex + 1}`} +
+ + {status.label} +
- - {playerName}{isMe ? " (You)" : ""} - - {isActivePlayer && ( - +
+ {hand.value} +
+
+ +
+ = 4 ? "compact" : "normal"} /> +
+ +
+ {outcome && ( + + + {outcome.label} + + )} + {hand.resultReason && ( + {hand.resultReason} )}
- {/* Bet status (during betting phase, no hands yet) */} - {!hasHands && ( -
- {hasBet ? <> Ready : "Waiting..."} + {showMoney && ( +
+
+
Wager
+
{formatAu(hand.wager)}
+
+
+
Payout
+
{formatAu(hand.payout ?? 0)}
+
+
+
Net
+
{formatSignedAu(net)}
+
)} +
+ ); +} - {/* Hands */} - {hasHands && ( -
1 ? "flex-row" : "flex-col"} overflow-x-auto sm:overflow-visible pb-1`}> - {seat.hands.map((hand, hi) => ( - 0; + const roundNet = seat.roundNet ?? 0; + + return ( +
+
+
+
+ {playerName[0]?.toUpperCase() ?? "?"} +
+
+
+
{playerName}
+ {isMe && You} + {isActive && Turn} +
+
+ {!hasHands && ( + + {seat.hasBet ? "Bet Locked" : "Waiting"} + + )} + {betAmount > 0 && seat.totalWager > 0 && Wager {formatAu(seat.totalWager)}} +
+
+
+ +
+
Total P&L
+
+ {formatSignedAu(seat.cumulativePnl)} +
+
+
+ + {hasHands ? ( +
+ {seat.hands.map((hand, handIndex) => ( + 0} /> ))}
+ ) : ( +
+ {seat.hasBet + ? "Ready for the deal." + : betAmount > 0 + ? `Waiting for ${formatAu(betAmount)} buy-in.` + : "Waiting for player to ready up."} +
)} - {/* Bet amount indicator */} - {hasHands && betAmount > 0 && ( - sum + h.bet, 0)} betAmount={betAmount} /> - )} - - {/* Cumulative PnL indicator */} - {(seat.cumulativePnl !== 0 || hasHands) && ( -
0 - ? `bg-emerald-500/10 border-emerald-500/30 text-emerald-400` - : totalPnl < 0 - ? `bg-red-500/10 border-red-500/30 text-red-400` - : `bg-blue-500/10 border-blue-500/30 text-blue-400` - }`}> - {totalPnl > 0 && } - {totalPnl < 0 && } - 0 ? "text-emerald-400" : totalPnl < 0 ? "text-red-400" : "text-blue-300"}`}> - {totalPnl > 0 ? "+" : ""}{totalPnl} AU + {seat.roundNet !== null && ( +
+ Round + + {formatSignedAu(roundNet)}
)} @@ -323,169 +435,117 @@ function Seat({ seat, playerName, isMe, isActivePlayer, activeHandIdx, betAmount ); } -// ── Empty seat ── - -function EmptySeat({ canSit, onSit }: { canSit: boolean; onSit: () => void }) { +function EmptySeatCard({ onSit }: { onSit: () => void }) { return ( -
-
- + + - ) : ( - Empty - )} -
+
Open Seat
+
+ Sit down during betting to join the next hand. +
+ ); } -// ── Dealer area ── - -function DealerArea({ dealerHand, visibleValue, fullValue }: { - dealerHand: Card[]; +function DealerPanel({ hand, visibleValue, fullValue }: { + hand: Card[]; visibleValue: number; fullValue: number | null; }) { + const hiddenIndex = fullValue === null && hand.length > 1 ? 1 : -1; + const shownValue = fullValue ?? visibleValue; const isBust = fullValue !== null && fullValue > 21; return ( -
-
= 17 - ? "bg-white/20 backdrop-blur-sm text-white" - : "bg-white/10 backdrop-blur-sm text-white/80" - }`}> - Dealer - {fullValue !== null && ( - - {fullValue} - - )} -
- -
- ); -} - -// ── Round result banner --- - -function RoundResultBanner({ roundNumber, roundResult, myPlayerId, betAmount, myCumulativePnl }: { - roundNumber: number; - roundResult: GameUIProps["roundResult"]; - myPlayerId: string; - betAmount: number; - myCumulativePnl: number; -}) { - const myPayout = roundResult?.payouts?.[myPlayerId]; - // Calculate this round's net result - const roundNet = myPayout?.net ?? 0; - // Current balance after this round (includes round result + cumulative from before) - const currentBalance = myCumulativePnl; - - return ( -
-
- Round {roundNumber} -
- {betAmount > 0 && ( -
0 - ? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30" - : roundNet < 0 - ? "bg-red-500/20 text-red-400 border border-red-500/30" - : "bg-blue-500/20 text-blue-400 border border-blue-500/30" - }`}> - - {roundNet > 0 ? "+" : ""}{roundNet} AU - +
+
+
+
Dealer
+
+ {fullValue === null ? "Hole card hidden" : isBust ? "Dealer busts" : "House stands"}
- )} -
-
- Total: -
0 - ? "bg-emerald-500/10 border-emerald-500/30" - : currentBalance < 0 - ? "bg-red-500/10 border-red-500/30" - : "bg-blue-500/10 border-blue-500/30" +
+
- {currentBalance > 0 && } - {currentBalance < 0 && } - 0 - ? "text-emerald-400" - : currentBalance < 0 - ? "text-red-400" - : "text-blue-300" - }`}> - {currentBalance > 0 ? "+" : ""}{currentBalance} AU - + {fullValue === null ? `${shownValue} + hidden` : shownValue}
+ +
+ +
); } -// ── Main Component ── +function SidebarCard({ title, subtitle, children }: { + title: string; + subtitle?: string; + children: ReactNode; +}) { + return ( +
+
{title}
+ {subtitle &&
{subtitle}
} +
{children}
+
+ ); +} 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 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 isResolved = view.phase === "resolved"; 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]); + const getPlayerName = useMemo(() => { + const names = new Map(players.map(player => [player.discordId, player.username])); + return (playerId: string) => names.get(playerId) ?? playerId.slice(0, 8); + }, [players]); + + const seatedPlayers = useMemo(() => ( + view.turnOrder.map(playerId => ({ + playerId, + seat: view.seats[playerId], + name: getPlayerName(playerId), + })).filter((entry): entry is { playerId: string; seat: PlayerSeatView; name: string } => Boolean(entry.seat)) + ), [getPlayerName, view.seats, view.turnOrder]); + + const activePlayerName = view.activePlayerId ? getPlayerName(view.activePlayerId) : null; + const mySettlement = roundResult?.settlements?.[myPlayerId] + ?? (mySeat && mySeat.roundNet !== null + ? { + wager: mySeat.totalWager, + payout: mySeat.totalWager + mySeat.roundNet, + net: mySeat.roundNet, + } + : null); + const totalPnl = playerView?.myCumulativePnl ?? mySeat?.cumulativePnl ?? 0; - // 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); + if (!isResolved) { + setShowNextRound(false); + return; } - setShowNextRound(false); + + const timer = setTimeout(() => setShowNextRound(true), 1800); + return () => clearTimeout(timer); }, [isResolved, view.roundNumber]); const handlePlaceBet = useCallback(() => onAction({ type: "place_bet" }), [onAction]); @@ -496,252 +556,219 @@ export function BlackjackGame({ state, myPlayerId, isSpectator, onAction, player const handleLeave = useCallback(() => onAction({ type: "leave_table" }), [onAction]); const handleSit = useCallback(() => onAction({ type: "sit_down" }), [onAction]); + const primaryButtonClass = "inline-flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-sm font-semibold text-on-primary transition hover:opacity-90"; + const secondaryButtonClass = "inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/10"; + return ( -
- {/* Leave table button */} - {!isSpectator && ( -
- -
- )} +
+
+
+
- {/* Table felt */} -
+
+
+
+
Aurora Casino
+
Blackjack
+

+ {phaseDescription(view.phase, activePlayerName, betAmount)} +

+
- {/* ── Desktop layout (md+): absolute positioned seats ── */} -
- {/* Dealer — top center */} -
- -
- - {/* Table text */} -
-
- Blackjack +
+ + + + 0 ? formatAu(betAmount) : "Free"} /> + {!isSpectator && ( + + )} +
- {isBetting && ( -
- Round {view.roundNumber} — Place your bets -
- )} - {isResolved && ( -
- Round {view.roundNumber} — Results -
- )} -
- {/* Seats in arc */} - {seatSlots.map((slot, i) => ( -
- {slot.playerId && slot.seat ? ( - + +
+ +
+
+
+ + {isPlaying && activePlayerName ? `${activePlayerName} to act` : phaseLabel(view.phase)} +
+
+ {betAmount > 0 + ? "Push refunds the wager. Blackjack pays 3:2." + : "Free table mode does not affect balance."} +
+
+
+ +
+ {seatedPlayers.map(({ playerId, seat, name }) => ( + - ) : ( - - )} + ))} + {canSitDown && }
- ))} +
- {/* ── Mobile layout ( - {/* Dealer */} -
- -
- - {/* Divider */} -
- - {/* Phase label */} - {isBetting && ( -
- Round {view.roundNumber} — Place your bets -
- )} - {isResolved && ( -
- Round {view.roundNumber} — Results -
- )} - - {/* Player seats */} -
- {seatSlots.map((slot, i) => { - if (!slot.playerId || !slot.seat) { - if (canSitDown && i === view.turnOrder.length) { - return ( -
- -
- ); - } - return null; - } - return ( -
- + {!isSpectator && ( + + {mySeat ? ( +
+ 0 ? formatAu(betAmount) : "Free"} hint={betAmount > 0 ? "Applied per new hand." : "Practice table."} /> + betAmount ? "Split or double included." : "Current buy-in."} /> + 0 ? "positive" : totalPnl < 0 ? "negative" : "neutral"} />
- ); - })} -
+ ) : ( +
+ Leave and rejoin if you need to reclaim a missing seat. +
+ )} + + )} + + + {mySettlement ? ( +
+ + + 0 ? "positive" : mySettlement.net < 0 ? "negative" : "neutral"} + /> +
+ ) : ( +
+ No result locked in yet. +
+ )} +
+ + + {!isSpectator && isBetting && !myBetPlaced && ( + + )} + + {!isSpectator && isBetting && myBetPlaced && ( +
+ Your wager is locked. Waiting for the rest of the table. +
+ )} + + {!isSpectator && isPlaying && playerView?.canAct && ( +
+ + + {playerView.canSplit && ( + + )} + {playerView.canDoubleDown && ( + + )} +
+ )} + + {!isSpectator && isPlaying && playerView && !playerView.canAct && ( +
+ {activePlayerName ? `Waiting for ${activePlayerName}.` : "Waiting for the table."} +
+ )} + + {!isSpectator && isResolved && showNextRound && ( + + )} + + {!isSpectator && isResolved && !showNextRound && ( +
+ Settlement syncing. Next round opens in a moment. +
+ )} + + {isSpectator && canSitDown && ( + + )} + + {isSpectator && !canSitDown && ( +
+ {activePlayerName ? `${activePlayerName} is currently acting.` : "Table is in motion."} +
+ )} +
- - {/* Round result banner */} - {isResolved && roundResult && ( - - )} - - {/* ── Action bar ── */} - {!isSpectator && ( -
- {/* Betting phase: place bet button */} - {isBetting && !myBetPlaced && ( - - )} - - {/* Betting phase: waiting for others */} - {isBetting && myBetPlaced && ( -
- Waiting for other players to bet... -
- )} - - {/* Playing phase: action buttons */} - {isPlaying && playerView?.canAct && ( -
- - - {playerView.canSplit && ( - - )} - {playerView.canDoubleDown && ( - - )} -
- )} - - {/* Playing phase: waiting for other player */} - {isPlaying && playerView && !playerView.canAct && ( -
- {view.activePlayerId - ? `Waiting for ${getPlayerName(view.activePlayerId)}...` - : "Waiting..."} -
- )} - - {/* Resolved phase: next round button */} - {isResolved && showNextRound && ( - - )} - - {/* Resolved phase: waiting for countdown */} - {isResolved && !showNextRound && ( -
- Round complete — next round starting soon... -
- )} -
- )} - - {/* Spectator indicator */} - {isSpectator && !canSitDown && ( -
- {isPlaying && view.activePlayerId - ? `${getPlayerName(view.activePlayerId)}'s turn` - : "Watching..."} -
- )} - - {/* Spectator sit-down prompt */} - {canSitDown && ( - - )}
); } diff --git a/panel/src/games/registry.ts b/panel/src/games/registry.ts index e1f945d..6651855 100644 --- a/panel/src/games/registry.ts +++ b/panel/src/games/registry.ts @@ -6,7 +6,7 @@ export interface GameUIProps { isSpectator: boolean; onAction: (action: unknown) => void; players: { discordId: string; username: string }[]; - roundResult?: { payouts: Record } | null; + roundResult?: { settlements: Record } | null; roomOptions?: { betAmount?: number; timeControl?: string }; } diff --git a/panel/src/index.css b/panel/src/index.css index 528a7c5..39bb749 100644 --- a/panel/src/index.css +++ b/panel/src/index.css @@ -103,3 +103,18 @@ body { .animate-in.slide-in-from-bottom-4 { animation: slideInFromBottom 0.3s ease-out forwards; } + +@keyframes blackjack-card-deal { + from { + opacity: 0; + transform: translateY(16px) scale(0.94) rotate(-5deg); + } + to { + opacity: 1; + transform: translateY(0) scale(1) rotate(0deg); + } +} + +.blackjack-card-deal { + animation: blackjack-card-deal 320ms cubic-bezier(0.16, 1, 0.3, 1) both; +} diff --git a/panel/src/lib/useGameRoom.ts b/panel/src/lib/useGameRoom.ts index d7b91ee..02b02ca 100644 --- a/panel/src/lib/useGameRoom.ts +++ b/panel/src/lib/useGameRoom.ts @@ -8,7 +8,7 @@ interface PlayerInfo { } interface RoundResult { - payouts: Record; + settlements: Record; } interface GameRoomState { @@ -99,7 +99,15 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe case "GAME_UPDATE": // Broadcast with spectator view — only update state for spectators - setState(prev => prev.isSpectator ? { ...prev, gameState: msg.state } : prev); + setState(prev => { + if (!prev.isSpectator) return prev; + const phase = (msg.state as any)?.phase; + return { + ...prev, + gameState: msg.state, + roundResult: phase === "betting" ? null : prev.roundResult, + }; + }); break; case "PLAYER_JOINED": @@ -135,7 +143,7 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe case "ROUND_SETTLED": setState(prev => ({ ...prev, - roundResult: { payouts: msg.payouts }, + roundResult: { settlements: msg.settlements }, })); break; diff --git a/shared/games/blackjack/blackjack.plugin.test.ts b/shared/games/blackjack/blackjack.plugin.test.ts index 2528ac2..9656f74 100644 --- a/shared/games/blackjack/blackjack.plugin.test.ts +++ b/shared/games/blackjack/blackjack.plugin.test.ts @@ -303,6 +303,7 @@ describe("handleAction — stand", () => { dealerHand: [makeCard("7"), makeCard("8")], turnOrder: ["p1"], deck: [makeCard("2")], // dealer: 15 + 2 = 17 + betAmount: 10, }); const result = blackjackPlugin.handleAction(state, { type: "stand" }, "p1"); @@ -310,8 +311,8 @@ describe("handleAction — stand", () => { if (!result.ok) return; expect(result.state.phase).toBe("resolved"); expect(result.state.seats["p1"]!.hands[0]!.result).toBe("win"); // 19 > 17 - expect(result.roundPayouts).toBeDefined(); - expect(result.roundPayouts!["p1"]).toBe(2); // 1:1 win + expect(result.roundSettlements).toBeDefined(); + expect(result.roundSettlements!["p1"]).toEqual({ wager: 10, payout: 20, net: 10 }); }); it("dealer busts — all standing players win", () => { @@ -480,6 +481,7 @@ describe("handleAction — double_down", () => { dealerHand: [makeCard("7"), makeCard("8")], turnOrder: ["p1"], deck: [makeCard("9"), makeCard("2")], // draw 9 → 20, dealer hits + betAmount: 10, }); const result = blackjackPlugin.handleAction(state, { type: "double_down" }, "p1"); @@ -524,13 +526,14 @@ describe("handleAction — double_down", () => { dealerHand: [makeCard("7"), makeCard("8")], turnOrder: ["p1"], deck: [makeCard("9"), makeCard("2")], // p1: 20, dealer: 15+2 = 17 + betAmount: 10, }); const result = blackjackPlugin.handleAction(state, { type: "double_down" }, "p1"); expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.roundPayouts).toBeDefined(); - expect(result.roundPayouts!["p1"]).toBe(4); // bet=2, win=2*2=4 + expect(result.roundSettlements).toBeDefined(); + expect(result.roundSettlements!["p1"]).toEqual({ wager: 20, payout: 40, net: 20 }); }); }); @@ -831,25 +834,25 @@ describe("onPlayerDisconnect", () => { // ── Multi-hand payout tests ── describe("multi-hand payouts", () => { - it("split with one win and one loss", () => { - // Set up a resolved state with split hands + it("split with one win and one loss settles to flat net", () => { const state = riggedState({ seats: { "p1": makeSeat([ - { ...stoodHand([makeCard("8"), makeCard("K")], 1, true), result: "win", resultReason: "Higher hand" }, // 18 wins - { ...stoodHand([makeCard("8"), makeCard("5")], 1, true), result: "lose", resultReason: "Lower hand" }, // 13 loses - ], -1), + stoodHand([makeCard("8"), makeCard("K")], 1, true), + playingHand([makeCard("8"), makeCard("5")], 1, true), + ], 1), }, - dealerHand: [makeCard("7"), makeCard("Q")], // 17 + dealerHand: [makeCard("10"), makeCard("7")], // 17 turnOrder: ["p1"], - phase: "resolved", - activePlayerIndex: -1, + activePlayerIndex: 0, + betAmount: 10, }); - // We can't easily test calculateRoundPayouts directly, but we can verify through - // a round that resolves. Let's set up a playable scenario instead. - // The payout for p1 should be: win(1*2) + lose(0) = 2 - // This is verified through the stand/resolve flow in the actual game + const result = blackjackPlugin.handleAction(state, { type: "stand" }, "p1"); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.roundSettlements!["p1"]).toEqual({ wager: 20, payout: 20, net: 0 }); }); it("doubled hand win pays 4x betAmount", () => { @@ -858,13 +861,14 @@ describe("multi-hand payouts", () => { dealerHand: [makeCard("6"), makeCard("8")], turnOrder: ["p1"], deck: [makeCard("8"), makeCard("K")], // p1: 5+6+8=19, dealer: 6+8+K=24 bust + betAmount: 10, }); const result = blackjackPlugin.handleAction(state, { type: "double_down" }, "p1"); expect(result.ok).toBe(true); if (!result.ok) return; // bet=2, win → payout = 2*2 = 4 - expect(result.roundPayouts!["p1"]).toBe(4); + expect(result.roundSettlements!["p1"]).toEqual({ wager: 20, payout: 40, net: 20 }); }); it("doubled hand push refunds 2x betAmount", () => { @@ -873,13 +877,14 @@ describe("multi-hand payouts", () => { dealerHand: [makeCard("K"), makeCard("10")], turnOrder: ["p1"], deck: [makeCard("3")], // p1: 9+8+3=20, dealer: 20 → push + betAmount: 10, }); const result = blackjackPlugin.handleAction(state, { type: "double_down" }, "p1"); expect(result.ok).toBe(true); if (!result.ok) return; // bet=2, push → payout = 2*1 = 2 - expect(result.roundPayouts!["p1"]).toBe(2); + expect(result.roundSettlements!["p1"]).toEqual({ wager: 20, payout: 20, net: 0 }); }); }); diff --git a/shared/games/blackjack/blackjack.plugin.ts b/shared/games/blackjack/blackjack.plugin.ts index c726d1a..84c19c6 100644 --- a/shared/games/blackjack/blackjack.plugin.ts +++ b/shared/games/blackjack/blackjack.plugin.ts @@ -1,4 +1,4 @@ -import type { GamePlugin, GameResult, GameOverResult } from "../types"; +import type { GamePlugin, GameResult, GameOverResult, RoundSettlement } from "../types"; import type { BlackjackState, BlackjackAction, BlackjackPlayerView, BlackjackSpectatorView, PlayerHandView, PlayerSeatView, @@ -174,7 +174,49 @@ function resolveHand(hand: PlayerHand, dealerHand: Card[]): PlayerHand { return { ...hand, result: "push", resultReason: "Push" }; } -/** Transition to dealer turn + resolve all hands. Returns round payouts. */ +function calculateHandPayout(hand: Pick, betAmount: number): number { + if (betAmount <= 0 || hand.result === null) return 0; + + switch (hand.result) { + case "blackjack": + return Math.round(hand.bet * betAmount * 2.5); + case "win": + return hand.bet * betAmount * 2; + case "push": + return hand.bet * betAmount; + case "lose": + return 0; + } +} + +function calculateHandNet(hand: Pick, betAmount: number): number | null { + if (hand.result === null) return null; + const wager = hand.bet * betAmount; + return calculateHandPayout(hand, betAmount) - wager; +} + +function calculateSeatRoundSettlement(seat: PlayerSeat, betAmount: number): RoundSettlement { + const wager = seat.hands.reduce((sum, hand) => sum + (hand.bet * betAmount), 0); + const payout = seat.hands.reduce((sum, hand) => sum + calculateHandPayout(hand, betAmount), 0); + + return { + wager, + payout, + net: payout - wager, + }; +} + +function calculateRoundSettlements(seats: Record, betAmount: number): Record { + const settlements: Record = {}; + + for (const [playerId, seat] of Object.entries(seats)) { + settlements[playerId] = calculateSeatRoundSettlement(seat, betAmount); + } + + return settlements; +} + +/** Transition to dealer turn + resolve all hands. */ function finishPlayerTurns(state: BlackjackState): BlackjackState { const anyStood = Object.values(state.seats).some( seat => seat.hands.some(h => h.status === "stood" || h.status === "blackjack"), @@ -191,23 +233,14 @@ function finishPlayerTurns(state: BlackjackState): BlackjackState { const resolvedSeats: Record = {}; for (const [id, seat] of Object.entries(state.seats)) { - // First resolve the hands const resolvedHands = seat.hands.map(h => resolveHand(h, dealerHand)); - - // Calculate round payouts as multipliers - const roundPayout = calculateRoundPayouts({ [id]: { ...seat, hands: resolvedHands } }); - const multiplier = roundPayout[id] ?? 0; - // Calculate total bet amount for this player - const roundBetTotal = seat.hands.reduce((sum, h) => sum + (h.bet * state.betAmount), 0); - // Calculate actual payout amount and net profit - const roundPayoutAmount = Math.round(multiplier * state.betAmount); - const roundNetPnl = roundBetTotal > 0 ? roundPayoutAmount - roundBetTotal : 0; - + const settlement = calculateSeatRoundSettlement({ ...seat, hands: resolvedHands }, state.betAmount); + resolvedSeats[id] = { ...seat, activeHandIndex: -1, hands: resolvedHands, - cumulativePnl: (seat.cumulativePnl ?? 0) + roundNetPnl, + cumulativePnl: (seat.cumulativePnl ?? 0) + settlement.net, }; } @@ -221,34 +254,6 @@ function finishPlayerTurns(state: BlackjackState): BlackjackState { }; } -/** Calculate round payouts as multipliers of betAmount. */ -function calculateRoundPayouts(seats: Record): Record { - const payouts: Record = {}; - - for (const [playerId, seat] of Object.entries(seats)) { - let playerPayout = 0; - for (const hand of seat.hands) { - switch (hand.result) { - case "blackjack": - playerPayout += hand.bet * 2.5; // 3:2 payout - break; - case "win": - playerPayout += hand.bet * 2; // 1:1 payout - break; - case "push": - playerPayout += hand.bet * 1; // refund - break; - // "lose" / null → 0 - } - } - if (playerPayout > 0) { - payouts[playerId] = playerPayout; - } - } - - return payouts; -} - /** Build the dealer hand for views — hole card hidden during player_turns/betting. */ function dealerVisibleHand(state: BlackjackState): Card[] { if (state.phase === "resolved") return state.dealerHand; @@ -257,7 +262,7 @@ function dealerVisibleHand(state: BlackjackState): Card[] { } /** Convert internal PlayerHand to a view. */ -function toHandView(hand: PlayerHand): PlayerHandView { +function toHandView(hand: PlayerHand, betAmount: number): PlayerHandView { return { cards: hand.cards, value: handValue(hand.cards), @@ -265,16 +270,25 @@ function toHandView(hand: PlayerHand): PlayerHandView { result: hand.result, resultReason: hand.resultReason, bet: hand.bet, + wager: hand.bet * betAmount, + payout: hand.result === null ? null : calculateHandPayout(hand, betAmount), + net: calculateHandNet(hand, betAmount), fromSplit: hand.fromSplit, }; } /** Convert internal PlayerSeat to a view. */ -function toSeatView(seat: PlayerSeat): PlayerSeatView { +function toSeatView(seat: PlayerSeat, betAmount: number): PlayerSeatView { + const settlement = seat.hands.every(hand => hand.result !== null) + ? calculateSeatRoundSettlement(seat, betAmount) + : null; + return { - hands: seat.hands.map(toHandView), + hands: seat.hands.map(hand => toHandView(hand, betAmount)), activeHandIndex: seat.activeHandIndex, hasBet: seat.hasBet, + totalWager: seat.hands.reduce((sum, hand) => sum + (hand.bet * betAmount), 0), + roundNet: settlement?.net ?? null, cumulativePnl: seat.cumulativePnl, }; } @@ -425,7 +439,7 @@ export const blackjackPlugin: GamePlugin = { const visibleDealer = dealerVisibleHand(state); const seatsView: Record = {}; for (const [id, seat] of Object.entries(state.seats)) { - seatsView[id] = toSeatView(seat); + seatsView[id] = toSeatView(seat, state.betAmount); } const activeId = state.activePlayerIndex >= 0 @@ -465,7 +479,7 @@ export const blackjackPlugin: GamePlugin = { const visibleDealer = dealerVisibleHand(state); const seatsView: Record = {}; for (const [id, seat] of Object.entries(state.seats)) { - seatsView[id] = toSeatView(seat); + seatsView[id] = toSeatView(seat, state.betAmount); } const activeId = state.activePlayerIndex >= 0 @@ -559,7 +573,7 @@ function handlePlaceBet(state: BlackjackState, playerId: string): GameResult { isSpectatorAction?(action: TAction): boolean; } +export interface RoundSettlement { + wager: number; + payout: number; + net: number; +} + export type GameResult = - | { ok: true; state: TState; roundPayouts?: Record } + | { ok: true; state: TState; roundSettlements?: Record } | { ok: false; error: string }; export type GameOverResult = {