Show explicit blackjack settlements across the stack
All checks were successful
CI / Deploy / test (push) Successful in 1m18s
CI / Deploy / deploy (push) Successful in 1m4s

- Replace round payout multipliers with per-player settlement amounts
- Update blackjack panel to display wager, payout, and net results
This commit is contained in:
syntaxbullet
2026-04-10 11:03:58 +02:00
parent f796cac6be
commit de15cb4206
11 changed files with 754 additions and 674 deletions

View File

@@ -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 });
});
});

View File

@@ -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<PlayerHand, "bet" | "result">, 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<PlayerHand, "bet" | "result">, 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<string, PlayerSeat>, betAmount: number): Record<string, RoundSettlement> {
const settlements: Record<string, RoundSettlement> = {};
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<string, PlayerSeat> = {};
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<string, PlayerSeat>): Record<string, number> {
const payouts: Record<string, number> = {};
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<BlackjackState, BlackjackAction> = {
const visibleDealer = dealerVisibleHand(state);
const seatsView: Record<string, PlayerSeatView> = {};
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<BlackjackState, BlackjackAction> = {
const visibleDealer = dealerVisibleHand(state);
const seatsView: Record<string, PlayerSeatView> = {};
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<Bla
const dealtState = dealRound(newState);
if (dealtState.phase === "resolved") {
// All players got blackjack — round is already over
return { ok: true, state: dealtState, roundPayouts: calculateRoundPayouts(dealtState.seats) };
return { ok: true, state: dealtState, roundSettlements: calculateRoundSettlements(dealtState.seats, dealtState.betAmount) };
}
return { ok: true, state: dealtState };
}
@@ -602,7 +616,7 @@ function handleHit(state: BlackjackState, playerId: string): GameResult<Blackjac
}
if (newState.phase === "resolved") {
return { ok: true, state: newState, roundPayouts: calculateRoundPayouts(newState.seats) };
return { ok: true, state: newState, roundSettlements: calculateRoundSettlements(newState.seats, newState.betAmount) };
}
return { ok: true, state: newState };
}
@@ -627,7 +641,7 @@ function handleStand(state: BlackjackState, playerId: string): GameResult<Blackj
newState = advanceTurn(newState);
if (newState.phase === "resolved") {
return { ok: true, state: newState, roundPayouts: calculateRoundPayouts(newState.seats) };
return { ok: true, state: newState, roundSettlements: calculateRoundSettlements(newState.seats, newState.betAmount) };
}
return { ok: true, state: newState };
}
@@ -707,7 +721,7 @@ function handleSplit(state: BlackjackState, playerId: string): GameResult<Blackj
}
if (newState.phase === "resolved") {
return { ok: true, state: newState, roundPayouts: calculateRoundPayouts(newState.seats) };
return { ok: true, state: newState, roundSettlements: calculateRoundSettlements(newState.seats, newState.betAmount) };
}
return { ok: true, state: newState };
}
@@ -749,7 +763,7 @@ function handleDoubleDown(state: BlackjackState, playerId: string): GameResult<B
newState = advanceTurn(newState);
if (newState.phase === "resolved") {
return { ok: true, state: newState, roundPayouts: calculateRoundPayouts(newState.seats) };
return { ok: true, state: newState, roundSettlements: calculateRoundSettlements(newState.seats, newState.betAmount) };
}
return { ok: true, state: newState };
}

View File

@@ -65,6 +65,9 @@ export interface PlayerHandView {
result: "win" | "blackjack" | "push" | "lose" | null;
resultReason: string | null;
bet: number;
wager: number;
payout: number | null;
net: number | null;
fromSplit: boolean;
}
@@ -72,6 +75,8 @@ export interface PlayerSeatView {
hands: PlayerHandView[];
activeHandIndex: number;
hasBet: boolean;
totalWager: number;
roundNet: number | null;
/** Cumulative PnL from all previous rounds. */
cumulativePnl: number;
}

View File

@@ -21,8 +21,14 @@ export interface GamePlugin<TState = unknown, TAction = unknown> {
isSpectatorAction?(action: TAction): boolean;
}
export interface RoundSettlement {
wager: number;
payout: number;
net: number;
}
export type GameResult<TState> =
| { ok: true; state: TState; roundPayouts?: Record<string, number> }
| { ok: true; state: TState; roundSettlements?: Record<string, RoundSettlement> }
| { ok: false; error: string };
export type GameOverResult = {