diff --git a/panel/src/games/blackjack/BlackjackGame.tsx b/panel/src/games/blackjack/BlackjackGame.tsx index 39430ae..6f88f8a 100644 --- a/panel/src/games/blackjack/BlackjackGame.tsx +++ b/panel/src/games/blackjack/BlackjackGame.tsx @@ -23,6 +23,8 @@ interface PlayerSeatView { hands: PlayerHandView[]; activeHandIndex: number; hasBet: boolean; + /** Cumulative PnL from all previous rounds. */ + cumulativePnl: number; } interface BlackjackViewBase { @@ -42,6 +44,8 @@ interface PlayerView extends BlackjackViewBase { canAct: boolean; canSplit: boolean; canDoubleDown: boolean; + /** Cumulative PnL for the current player. */ + myCumulativePnl: number; } function isPlayerView(state: unknown): state is PlayerView { @@ -207,6 +211,16 @@ function Seat({ seat, playerName, isMe, isActivePlayer, activeHandIdx, betAmount const hasHands = seat.hands.length > 0; const compact = seat.hands.length > 2; + // Calculate current round PnL for the display + 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; + if (h.result === "push") return sum; // Push returns bet + return sum - handWin; // Lose + }, 0); + const totalPnl = seat.cumulativePnl + currentRoundPnl; + return (
0 && ( sum + h.bet, 0)} betAmount={betAmount} /> )} + + {/* Cumulative PnL indicator */} + {(seat.cumulativePnl !== 0 || hasHands) && ( +
0 + ? "bg-emerald-500/20 text-emerald-400" + : totalPnl < 0 + ? "bg-red-500/20 text-red-400" + : "bg-blue-500/20 text-blue-300" + }`}> + {totalPnl > 0 && } + {totalPnl < 0 && } + {totalPnl > 0 ? "+" : ""}{totalPnl} AU +
+ )}
); } @@ -318,24 +347,37 @@ function DealerArea({ dealerHand, visibleValue, fullValue }: { // ── Round result banner ── -function RoundResultBanner({ roundNumber, roundResult, myPlayerId, betAmount }: { +function RoundResultBanner({ roundNumber, roundResult, myPlayerId, betAmount, myCumulativePnl }: { roundNumber: number; roundResult: GameUIProps["roundResult"]; myPlayerId: string; betAmount: number; + myCumulativePnl: number; }) { - const myPayout = roundResult?.payouts[myPlayerId]; + 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} - {myPayout && betAmount > 0 && ( - betAmount ? "text-emerald-400" : myPayout.net === betAmount ? "text-blue-400" : "text-white/60" +
+ {myPayout && betAmount > 0 && ( + betAmount ? "text-emerald-400" : myPayout.net === betAmount ? "text-blue-400" : "text-white/60" + }`}> + Round: {myPayout.net > 0 ? "+" : ""}{myPayout.net} AU + + )} +
+ 0 ? "text-emerald-400" : currentBalance < 0 ? "text-red-400" : "text-blue-300" }`}> - +{myPayout.net} AU + Total: {currentBalance > 0 ? "+" : ""}{currentBalance} AU - )} +
); } @@ -358,6 +400,15 @@ export function BlackjackGame({ state, myPlayerId, isSpectator, onAction, player const mySeat = view.seats[myPlayerId]; const myBetPlaced = mySeat?.hasBet ?? false; + // Calculate current balance (if betting) or just track profit/loss + const balance = betAmount > 0 && mySeat?.hands.length === 0 + ? 0 // Not yet bet for this round + : mySeat?.hands.reduce((sum, h) => sum + (h.result === "win" || h.result === "blackjack" + ? h.bet * betAmount + : h.result === "push" ? h.bet * betAmount + : 0), 0) - (myBetPlaced ? mySeat.hands.reduce((sum, h) => sum + h.bet * betAmount, 0) : 0); + const myPnl = (mySeat?.cumulativePnl ?? 0) + balance; + // Determine who can sit (spectators during betting phase) const canSitDown = isSpectator && isBetting && view.turnOrder.length < 6; @@ -529,6 +580,7 @@ export function BlackjackGame({ state, myPlayerId, isSpectator, onAction, player roundResult={roundResult} myPlayerId={myPlayerId} betAmount={betAmount} + myCumulativePnl={(view as PlayerView)?.myCumulativePnl ?? 0} /> )} diff --git a/shared/games/blackjack/blackjack.plugin.test.ts b/shared/games/blackjack/blackjack.plugin.test.ts index 8534b83..49da3c6 100644 --- a/shared/games/blackjack/blackjack.plugin.test.ts +++ b/shared/games/blackjack/blackjack.plugin.test.ts @@ -880,3 +880,96 @@ describe("multi-hand payouts", () => { expect(result.roundPayouts!["p1"]).toBe(2); }); }); + +// ── Cumulative PnL tests ── + +describe("cumulative PnL", () => { + it("initializes cumulativePnl to 0 for new players", () => { + const state = blackjackPlugin.createInitialState(["p1"]); + expect(state.seats["p1"]!.cumulativePnl).toBe(0); + }); + + it("tracks cumulative PnL through multiple rounds", () => { + // Start a new game + let state = blackjackPlugin.createInitialState(["p1"]); + + // Round 1: Win with bet of 1 AU (2x payout = 2, net profit = +1) + // Use rigged state to guarantee a win + let result = blackjackPlugin.handleAction(state, { type: "place_bet" }, "p1"); + expect(result.ok).toBe(true); + if (!result.ok) return; + + // Ensure player can act + let roundState = result.state; + if (roundState.phase === "player_turns") { + // Stand to let dealer play + result = blackjackPlugin.handleAction(roundState, { type: "stand" }, "p1"); + expect(result.ok).toBe(true); + if (!result.ok) return; + } + + // Check PnL after round 1 - should have at least processed the payout + // Note: exact value depends on cards, but it should be processed + const round1Pnl = result.state.seats["p1"]!.cumulativePnl; + expect(result.state.phase).toBe("resolved"); + + // Round 2: Place bet again + result = blackjackPlugin.handleAction(result.state, { type: "place_bet" }, "p1"); + expect(result.ok).toBe(true); + if (!result.ok) return; + roundState = result.state; + + // Stand to resolve + if (roundState.phase === "player_turns") { + result = blackjackPlugin.handleAction(roundState, { type: "stand" }, "p1"); + expect(result.ok).toBe(true); + if (!result.ok) return; + } + + // Check cumulative PnL after round 2 + const round2Pnl = result.state.seats["p1"]!.cumulativePnl; + expect(result.state.phase).toBe("resolved"); + }); + + it("includes cumulativePnl in view", () => { + const state = blackjackPlugin.createInitialState(["p1"]); + // Manually set a cumulativePnl for testing + const customState = { + ...state, + seats: { "p1": { ...state.seats["p1"]!, cumulativePnl: 50 } }, + }; + + const view = blackjackPlugin.getPlayerView(customState, "p1") as BlackjackPlayerView; + expect(view.myCumulativePnl).toBe(50); + }); + + it("persists cumulativePnl when player stays seated through rounds", () => { + // Simulate a full round where player wins, then verify cumulativePnl is preserved + let state = blackjackPlugin.createInitialState(["p1"]); + + // Place bet and play a round + let result = blackjackPlugin.handleAction(state, { type: "place_bet" }, "p1"); + expect(result.ok).toBe(true); + if (!result.ok) return; + + // Stand to resolve + if (result.state.phase === "player_turns") { + result = blackjackPlugin.handleAction(result.state, { type: "stand" }, "p1"); + expect(result.ok).toBe(true); + if (!result.ok) return; + } + + // Check PnL after first round + const firstRoundPnl = result.state.seats["p1"]!.cumulativePnl; + expect(result.state.phase).toBe("resolved"); + + // Place bet again to start next round + result = blackjackPlugin.handleAction(result.state, { type: "place_bet" }, "p1"); + expect(result.ok).toBe(true); + if (!result.ok) return; + + // cumulativePnl should be preserved from the previous round + expect(result.state.seats["p1"]!.cumulativePnl).toBe(firstRoundPnl); + expect(result.state.roundNumber).toBe(2); + }); +}); diff --git a/shared/games/blackjack/blackjack.plugin.ts b/shared/games/blackjack/blackjack.plugin.ts index 890098d..447c65a 100644 --- a/shared/games/blackjack/blackjack.plugin.ts +++ b/shared/games/blackjack/blackjack.plugin.ts @@ -191,10 +191,21 @@ 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)); + + // Then calculate PnL based on resolved hands + const roundPayout = calculateRoundPayouts({ [id]: { ...seat, hands: resolvedHands } }); + const roundPnl = roundPayout[id] ?? 0; + // Subtract the total bet amount to get net profit/loss + const roundBetTotal = seat.hands.reduce((sum, h) => sum + h.bet, 0); + const roundNetPnl = roundPayout[id] ? roundPayout[id] - roundBetTotal : -roundBetTotal; + resolvedSeats[id] = { ...seat, activeHandIndex: -1, - hands: seat.hands.map(h => resolveHand(h, dealerHand)), + hands: resolvedHands, + cumulativePnl: (seat.cumulativePnl ?? 0) + roundNetPnl, }; } @@ -262,6 +273,7 @@ function toSeatView(seat: PlayerSeat): PlayerSeatView { hands: seat.hands.map(toHandView), activeHandIndex: seat.activeHandIndex, hasBet: seat.hasBet, + cumulativePnl: seat.cumulativePnl, }; } @@ -292,6 +304,7 @@ function dealRound(state: BlackjackState): BlackjackState { }], activeHandIndex: -1, hasBet: true, + cumulativePnl: state.seats[pid]?.cumulativePnl ?? 0, }; } @@ -355,6 +368,7 @@ export const blackjackPlugin: GamePlugin = { hands: [], activeHandIndex: -1, hasBet: false, + cumulativePnl: 0, }; } @@ -439,6 +453,7 @@ export const blackjackPlugin: GamePlugin = { canSplit: isMyTurn && myActiveHand !== null && canSplitHand(myActiveHand, mySeat!.hands.length), canDoubleDown: isMyTurn && myActiveHand !== null && canDoubleHand(myActiveHand), roundNumber: state.roundNumber, + myCumulativePnl: mySeat?.cumulativePnl ?? 0, }; }, @@ -502,10 +517,12 @@ function handlePlaceBet(state: BlackjackState, playerId: string): GameResult = {}; for (const pid of state.turnOrder) { + // Preserve cumulativePnl from previous round resetSeats[pid] = { hands: [], activeHandIndex: -1, hasBet: false, + cumulativePnl: state.seats[pid]?.cumulativePnl ?? 0, }; } @@ -764,6 +781,7 @@ function handleSitDown(state: BlackjackState, playerId: string): GameResult