fix: correct blackjack PnL calculation and enhance UI
Some checks failed
Deploy to Production / test (push) Failing after 35s

- Fix incorrect PnL calculations where multipliers were treated as amounts
- Add proper net profit calculation: (multiplier × bet) - bet
- Update UI with gradient backgrounds, icons, and improved animations
- Add slide-in and pulse-slow keyframe animations
- Enhance dealer area with status-based styling
- Improve action buttons with colored gradients and hover effects
- Revise round result banner with better visual hierarchy
This commit is contained in:
syntaxbullet
2026-04-06 15:11:47 +02:00
parent 06c3891045
commit 034f2ead1c
4 changed files with 178 additions and 86 deletions

View File

@@ -95,13 +95,14 @@ export class GameServer {
const gameName = gameRegistry.get(room.gameSlug)?.name ?? "Game";
const payoutDetails: Record<string, { net: number }> = {};
for (const [playerId, grossPayout] of Object.entries(roundPayouts)) {
// roundPayout is already the gross payout amount (not a multiplier)
const netProfit = Math.floor(grossPayout) - betAmount; // Calculate net profit (gross payout minus original bet)
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;
try {
await economyService.modifyUserBalance(
playerId,
BigInt(grossPayout),
BigInt(grossAmount),
TransactionType.GAME_WIN,
`${gameName} round payout (room ${roomId.slice(0, 8)})`,
);

View File

@@ -1,6 +1,6 @@
import { useMemo, useState, useEffect, useCallback } from "react";
import type { GameUIProps } from "../registry";
import { Hand, Square, ArrowLeftRight, ChevronsUp, LogOut, Check, Coins } from "lucide-react";
import { Hand, Square, ArrowLeftRight, ChevronsUp, LogOut, Check, Coins, Trophy, AlertCircle } from "lucide-react";
// ── Types matching server views ──
@@ -127,18 +127,18 @@ function CardFan({ cards, faceDown, size = "normal" }: {
);
}
// ── Value badge ──
// ── Value badge ---
function ValueBadge({ value, status }: { value: number; status?: string }) {
const colorClass =
value === 21 || status === "blackjack"
? "bg-emerald-500/20 text-emerald-400 font-bold"
? "bg-gradient-to-r from-emerald-500 to-emerald-600 text-white font-bold shadow-sm"
: value > 21 || status === "bust"
? "bg-red-500/20 text-red-400 font-bold"
: "bg-black/30 text-white/80";
? "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";
return (
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${colorClass}`}>
<span className={`text-[10px] font-mono px-2 py-0.5 rounded-full ${colorClass}`}>
{value}
</span>
);
@@ -147,35 +147,52 @@ function ValueBadge({ value, status }: { value: number; status?: string }) {
// ── Result badge ──
function ResultBadge({ result, reason }: { result: string; reason: string | null }) {
const config: Record<string, { label: string; color: string }> = {
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 config: Record<string, { label: string; color: string; icon?: React.ReactNode }> = {
blackjack: {
label: "Blackjack!",
color: "bg-gradient-to-r from-yellow-400 to-amber-500 text-white font-bold shadow-sm",
icon: <Trophy className="w-3 h-3" />
},
win: {
label: "Win",
color: "bg-gradient-to-r from-emerald-500 to-emerald-600 text-white font-bold shadow-sm",
icon: <ChevronsUp className="w-3 h-3" />
},
push: {
label: "Push",
color: "bg-gradient-to-r from-blue-500 to-blue-600 text-white font-bold shadow-sm",
icon: <Check className="w-3 h-3" />
},
lose: {
label: "Lose",
color: "bg-gradient-to-r from-red-500 to-red-600 text-white font-bold shadow-sm",
icon: <AlertCircle className="w-3 h-3" />
},
};
const c = config[result] ?? { label: "-", color: "bg-black/20 text-white/50" };
const c = config[result] ?? { label: "-", color: "bg-white/10 text-white/50" };
return (
<span className={`rounded px-1.5 py-0.5 text-[10px] font-semibold ${c.color}`} title={reason ?? undefined}>
{c.label}
<span className={`flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] ${c.color}`} title={reason ?? undefined}>
{c.icon}
<span>{c.label}</span>
</span>
);
}
// ── Bet chip indicator ──
// ── 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">
<div className="flex items-center gap-1 bg-gradient-to-r from-yellow-500 to-yellow-600 text-white rounded-full px-2 py-0.5 shadow-md border border-yellow-400/30 animate-pulse-slow">
<Coins className="w-2.5 h-2.5" />
<span className="text-[9px] font-bold">{total}</span>
</div>
);
}
// ── Single hand display ──
// ── Single hand display ---
function HandDisplay({ hand, isActive, betAmount, size = "normal" }: {
hand: PlayerHandView;
@@ -184,11 +201,11 @@ function HandDisplay({ hand, isActive, betAmount, size = "normal" }: {
size?: "small" | "normal";
}) {
return (
<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" : ""
<div className={`flex flex-col items-center gap-2 rounded-xl px-1.5 py-2 transition-all duration-300 ${
isActive ? "ring-2 ring-primary/50 bg-white/5 scale-[1.02] shadow-lg" : "ring-1 ring-white/10 bg-white/[0.02]"
}`}>
<CardFan cards={hand.cards} size={size} />
<div className="flex items-center gap-1 flex-wrap justify-center">
<div className="flex items-center gap-2 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} />}
@@ -221,21 +238,34 @@ function Seat({ seat, playerName, isMe, isActivePlayer, activeHandIdx, betAmount
}, 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]";
return (
<div className={`flex flex-col items-center gap-1.5 transition-all ${
<div className={`flex flex-col items-center gap-2 transition-all duration-300 ${
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"
{/* Player label with avatar and active indicator */}
<div className="flex items-center gap-2">
<div className={`relative w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold ${
isActivePlayer
? "bg-gradient-to-br from-primary/40 to-primary/60 ring-2 ring-primary/70 shadow-lg"
: "bg-white/10 text-white/70 ring-1 ring-white/20"
}`}>
{playerName[0]?.toUpperCase() ?? "?"}
{isMe && <span className="absolute -top-1 -right-1 w-2 h-2 bg-primary rounded-full animate-pulse" />}
</div>
<span className={`text-xs font-medium truncate max-w-20 ${
isActivePlayer ? "text-primary" : "text-white/70"
<span className={`text-xs font-medium truncate max-w-24 ${
isActivePlayer ? "text-primary font-semibold" : isMe ? "text-white font-medium" : "text-white/60"
}`}>
{playerName}{isMe ? "" : ""}
{playerName}{isMe ? " (You)" : ""}
</span>
{isActivePlayer && (
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
@@ -244,8 +274,10 @@ function Seat({ seat, playerName, isMe, isActivePlayer, activeHandIdx, betAmount
{/* 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"
<div className={`flex items-center gap-1 text-[9px] rounded-full px-2 py-0.5 ${
hasBet
? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
: "bg-white/5 text-white/40 border border-white/10"
}`}>
{hasBet ? <><Check className="w-2.5 h-2.5" /> Ready</> : "Waiting..."}
</div>
@@ -273,16 +305,18 @@ function Seat({ seat, playerName, isMe, isActivePlayer, activeHandIdx, betAmount
{/* Cumulative PnL indicator */}
{(seat.cumulativePnl !== 0 || hasHands) && (
<div className={`flex items-center gap-1 text-xs font-bold rounded px-2 py-0.5 ${
<div className={`flex items-center gap-1.5 text-xs font-bold rounded-full px-3 py-1 border ${
totalPnl > 0
? "bg-emerald-500/20 text-emerald-400"
? `bg-emerald-500/10 border-emerald-500/30 text-emerald-400`
: totalPnl < 0
? "bg-red-500/20 text-red-400"
: "bg-blue-500/20 text-blue-300"
? `bg-red-500/10 border-red-500/30 text-red-400`
: `bg-blue-500/10 border-blue-500/30 text-blue-400`
}`}>
{totalPnl > 0 && <ChevronsUp className="w-3.5 h-3.5" />}
{totalPnl < 0 && <ChevronsUp className="w-3.5 h-3.5 rotate-180" />}
<span>{totalPnl > 0 ? "+" : ""}{totalPnl} AU</span>
<span className={`font-mono ${totalPnl > 0 ? "text-emerald-400" : totalPnl < 0 ? "text-red-400" : "text-blue-300"}`}>
{totalPnl > 0 ? "+" : ""}{totalPnl} AU
</span>
</div>
)}
</div>
@@ -320,24 +354,28 @@ function DealerArea({ dealerHand, visibleValue, fullValue }: {
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 className="flex flex-col items-center gap-2">
<div className={`flex items-center gap-2 px-3 py-1 rounded-full ${
fullValue === 21
? "bg-gradient-to-r from-yellow-500 to-amber-600 text-white shadow-lg"
: fullValue !== null && fullValue >= 17
? "bg-white/20 backdrop-blur-sm text-white"
: "bg-white/10 backdrop-blur-sm text-white/80"
}`}>
<span className="text-xs font-semibold">Dealer</span>
{fullValue !== null && (
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${
fullValue === 21
? "bg-white text-black font-bold"
: isBust
? "bg-red-500 text-white animate-bounce"
: "bg-black/30 text-white"
}`}>
{fullValue}
</span>
)}
</div>
<CardFan cards={dealerHand} />
@@ -345,7 +383,7 @@ function DealerArea({ dealerHand, visibleValue, fullValue }: {
);
}
// ── Round result banner ──
// ── Round result banner ---
function RoundResultBanner({ roundNumber, roundResult, myPlayerId, betAmount, myCumulativePnl }: {
roundNumber: number;
@@ -361,22 +399,45 @@ function RoundResultBanner({ roundNumber, roundResult, myPlayerId, betAmount, my
const currentBalance = myCumulativePnl;
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>
<div className="flex items-center justify-between gap-3 bg-gradient-to-r from-black/50 to-black/40 rounded-xl px-4 py-3 shadow-xl border border-white/10 animate-in fade-in slide-in-from-bottom-4 duration-300">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-white/50">Round {roundNumber}</span>
<div className="h-4 w-px bg-white/20" />
{betAmount > 0 && (
<span className={`text-xs font-semibold ${
roundNet > 0 ? "text-emerald-400" : roundNet < 0 ? "text-red-400" : "text-blue-300"
<div className={`flex items-center gap-1.5 text-xs font-semibold px-2 py-0.5 rounded-full ${
roundNet > 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"
}`}>
Round: {roundNet > 0 ? "+" : ""}{roundNet} AU
</span>
<span className="font-mono">
{roundNet > 0 ? "+" : ""}{roundNet} AU
</span>
</div>
)}
<div className="w-px h-4 bg-white/20" />
<span className={`text-xs font-bold ${
currentBalance > 0 ? "text-emerald-400" : currentBalance < 0 ? "text-red-400" : "text-blue-300"
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-white/40">Total:</span>
<div className={`flex items-center gap-1.5 px-3 py-1 rounded-full border ${
currentBalance > 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"
}`}>
Total: {currentBalance > 0 ? "+" : ""}{currentBalance} AU
</span>
{currentBalance > 0 && <ChevronsUp className="w-4 h-4 text-emerald-500" />}
{currentBalance < 0 && <ChevronsUp className="w-4 h-4 text-red-500 rotate-180" />}
<span className={`font-bold font-mono ${
currentBalance > 0
? "text-emerald-400"
: currentBalance < 0
? "text-red-400"
: "text-blue-300"
}`}>
{currentBalance > 0 ? "+" : ""}{currentBalance} AU
</span>
</div>
</div>
</div>
);
@@ -504,25 +565,27 @@ export function BlackjackGame({ state, myPlayerId, isSpectator, onAction, player
</div>
{/* ── Mobile layout (<md): vertical stack ── */}
<div className="md:hidden flex flex-col gap-4 p-4">
<div className="md:hidden flex flex-col gap-4 p-3 md:p-4">
{/* Dealer */}
<DealerArea
dealerHand={view.dealerHand}
visibleValue={view.dealerVisibleValue}
fullValue={view.dealerFullValue}
/>
<div className="bg-white/5 rounded-xl p-3">
<DealerArea
dealerHand={view.dealerHand}
visibleValue={view.dealerVisibleValue}
fullValue={view.dealerFullValue}
/>
</div>
{/* Divider */}
<div className="h-px bg-white/10" />
<div className="h-px bg-white/10 mx-3" />
{/* Phase label */}
{isBetting && (
<div className="text-center text-white/30 text-xs">
<div className="text-center text-white/40 text-xs font-medium">
Round {view.roundNumber} Place your bets
</div>
)}
{isResolved && (
<div className="text-center text-white/30 text-xs">
<div className="text-center text-white/40 text-xs font-medium">
Round {view.roundNumber} Results
</div>
)}
@@ -543,9 +606,9 @@ export function BlackjackGame({ state, myPlayerId, isSpectator, onAction, player
return (
<div
key={slot.playerId}
className={`rounded-xl px-3 py-2 transition-colors ${
className={`rounded-xl px-3 py-3 transition-all duration-200 ${
view.activePlayerId === slot.playerId
? "bg-white/5 ring-1 ring-primary/20"
? "ring-1 ring-primary/30 bg-white/[0.05]"
: "bg-white/[0.02]"
}`}
>
@@ -598,23 +661,23 @@ export function BlackjackGame({ state, myPlayerId, isSpectator, onAction, player
{/* Playing phase: action buttons */}
{isPlaying && playerView?.canAct && (
<div className="flex gap-2">
<div className="flex gap-2 flex-wrap">
<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"
className="flex-1 min-w-[80px] flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-blue-600 to-blue-700 text-white px-4 py-3.5 text-sm font-label font-semibold shadow-lg hover:shadow-blue-500/20 transition-all active:scale-[0.98]"
>
<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"
className="flex-1 min-w-[80px] flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-gray-600 to-gray-700 text-white px-4 py-3.5 text-sm font-label font-semibold shadow-lg hover:shadow-gray-500/20 transition-all active:scale-[0.98]"
>
<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"
className="flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-purple-600 to-indigo-700 text-white px-4 py-3.5 text-sm font-label font-semibold shadow-lg hover:shadow-purple-500/20 transition-all active:scale-[0.98]"
>
<ArrowLeftRight className="w-4 h-4" /> Split
</button>
@@ -622,7 +685,7 @@ export function BlackjackGame({ state, myPlayerId, isSpectator, onAction, player
{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"
className="flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-orange-500 to-red-600 text-white px-4 py-3.5 text-sm font-label font-semibold shadow-lg hover:shadow-orange-500/20 transition-all active:scale-[0.98]"
>
<ChevronsUp className="w-4 h-4" /> Double
</button>

View File

@@ -77,3 +77,29 @@ body {
* {
border-color: var(--color-border);
}
/* Custom Animations */
@keyframes pulse-slow {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.animate-pulse-slow {
animation: pulse-slow 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* Slide-in animation for result banner */
@keyframes slideInFromBottom {
from {
transform: translateY(1rem);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-in.slide-in-from-bottom-4 {
animation: slideInFromBottom 0.3s ease-out forwards;
}

View File

@@ -194,12 +194,14 @@ function finishPlayerTurns(state: BlackjackState): BlackjackState {
// First resolve the hands
const resolvedHands = seat.hands.map(h => resolveHand(h, dealerHand));
// Then calculate PnL based on resolved hands
// Calculate round payouts as multipliers
const roundPayout = calculateRoundPayouts({ [id]: { ...seat, hands: resolvedHands } });
const roundPayoutMoney = roundPayout[id] ?? 0;
// Subtract the total bet amount to get net profit/loss
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);
const roundNetPnl = roundPayoutMoney ? roundPayoutMoney - roundBetTotal : -roundBetTotal;
// Calculate actual payout amount and net profit
const roundPayoutAmount = Math.round(multiplier * state.betAmount);
const roundNetPnl = roundBetTotal > 0 ? roundPayoutAmount - roundBetTotal : 0;
resolvedSeats[id] = {
...seat,