Show explicit blackjack settlements across the stack
- Replace round payout multipliers with per-player settlement amounts - Update blackjack panel to display wager, payout, and net results
This commit is contained in:
@@ -88,32 +88,30 @@ export class GameServer {
|
|||||||
this.publishRoomListUpdate();
|
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);
|
const room = this.roomManager.getRoom(roomId);
|
||||||
if (!room || room.betAmount <= 0) return;
|
if (!room || room.betAmount <= 0) return;
|
||||||
const betAmount = room.betAmount;
|
|
||||||
const gameName = gameRegistry.get(room.gameSlug)?.name ?? "Game";
|
const gameName = gameRegistry.get(room.gameSlug)?.name ?? "Game";
|
||||||
const payoutDetails: Record<string, { net: number }> = {};
|
const settlementDetails: typeof roundSettlements = {};
|
||||||
|
|
||||||
for (const [playerId, multiplier] of Object.entries(roundPayouts)) {
|
for (const [playerId, settlement] of Object.entries(roundSettlements)) {
|
||||||
// roundPayout contains multipliers (e.g., win=2, blackjack=2.5, push=1)
|
|
||||||
const grossAmount = Math.floor(betAmount * multiplier);
|
|
||||||
const netProfit = grossAmount - betAmount;
|
|
||||||
try {
|
try {
|
||||||
await economyService.modifyUserBalance(
|
if (settlement.payout > 0) {
|
||||||
playerId,
|
await economyService.modifyUserBalance(
|
||||||
BigInt(grossAmount),
|
playerId,
|
||||||
TransactionType.GAME_WIN,
|
BigInt(settlement.payout),
|
||||||
`${gameName} round payout (room ${roomId.slice(0, 8)})`,
|
TransactionType.GAME_WIN,
|
||||||
);
|
`${gameName} round payout (room ${roomId.slice(0, 8)})`,
|
||||||
payoutDetails[playerId] = { net: netProfit };
|
);
|
||||||
|
}
|
||||||
|
settlementDetails[playerId] = settlement;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error("web", `Round payout failed for ${playerId} in room ${roomId}: ${err}`);
|
logger.error("web", `Round payout failed for ${playerId} in room ${roomId}: ${err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(payoutDetails).length > 0) {
|
if (Object.keys(settlementDetails).length > 0) {
|
||||||
this.publish(`room:${roomId}`, { type: "ROUND_SETTLED", roomId, payouts: payoutDetails });
|
this.publish(`room:${roomId}`, { type: "ROUND_SETTLED", roomId, settlements: settlementDetails });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import mitt from "mitt";
|
import mitt from "mitt";
|
||||||
import { gameRegistry } from "@shared/games/registry";
|
import { gameRegistry } from "@shared/games/registry";
|
||||||
import type { Room, RoomSummary } from "./types";
|
import type { Room, RoomSummary } from "./types";
|
||||||
|
import type { RoundSettlement } from "@shared/games/types";
|
||||||
|
|
||||||
const ROOM_CONFIG = {
|
const ROOM_CONFIG = {
|
||||||
WAITING_CLEANUP_MS: 60_000,
|
WAITING_CLEANUP_MS: 60_000,
|
||||||
@@ -9,7 +10,7 @@ const ROOM_CONFIG = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type ActionResult =
|
type ActionResult =
|
||||||
| { ok: true; state: unknown; gameOver: { winner: string | null; reason: string } | null; roundPayouts?: Record<string, number> }
|
| { ok: true; state: unknown; gameOver: { winner: string | null; reason: string } | null; roundSettlements?: Record<string, RoundSettlement> }
|
||||||
| { ok: false; error: string };
|
| { ok: false; error: string };
|
||||||
|
|
||||||
type CreateResult = { ok: true; roomId: string } | { 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<string, unknown> };
|
"game:started": { roomId: string; spectatorView: unknown; playerViews: Map<string, unknown> };
|
||||||
"game:updated": { roomId: string; spectatorView: unknown; playerViews: Map<string, unknown> };
|
"game:updated": { roomId: string; spectatorView: unknown; playerViews: Map<string, unknown> };
|
||||||
"game:ended": { roomId: string; winner: string | null; reason: string; payouts?: Record<string, number> };
|
"game:ended": { roomId: string; winner: string | null; reason: string; payouts?: Record<string, number> };
|
||||||
"round:settled": { roomId: string; roundPayouts: Record<string, number> };
|
"round:settled": { roomId: string; roundSettlements: Record<string, RoundSettlement> };
|
||||||
"player:left": { roomId: string; playerId: string };
|
"player:left": { roomId: string; playerId: string };
|
||||||
"room:deleted": { roomId: string };
|
"room:deleted": { roomId: string };
|
||||||
"room:list:changed": void;
|
"room:list:changed": void;
|
||||||
@@ -138,8 +139,8 @@ export class RoomManager {
|
|||||||
this.emitter.emit("game:updated", { roomId, spectatorView, playerViews });
|
this.emitter.emit("game:updated", { roomId, spectatorView, playerViews });
|
||||||
|
|
||||||
// Emit round payouts for mid-game settlement (continuous-play games)
|
// Emit round payouts for mid-game settlement (continuous-play games)
|
||||||
if (result.roundPayouts && !gameOver) {
|
if (result.roundSettlements && !gameOver) {
|
||||||
this.emitter.emit("round:settled", { roomId, roundPayouts: result.roundPayouts });
|
this.emitter.emit("round:settled", { roomId, roundSettlements: result.roundSettlements });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gameOver) {
|
if (gameOver) {
|
||||||
@@ -147,7 +148,7 @@ export class RoomManager {
|
|||||||
this.emitter.emit("room:list:changed");
|
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 {
|
leaveRoom(roomId: string, playerId: string): void {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { RoundSettlement } from "@shared/games/types";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export interface Room {
|
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: "GAME_ENDED"; roomId: string; winner: string | null; reason: string; payout?: { amount: number; refunded?: boolean } }
|
||||||
| { type: "ROOM_CREATED"; roomId: string; gameSlug: string }
|
| { 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: "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<string, { net: number }> }
|
| { type: "ROUND_SETTLED"; roomId: string; settlements: Record<string, RoundSettlement> }
|
||||||
| { type: "SESSION_REPLACED"; roomId: string }
|
| { type: "SESSION_REPLACED"; roomId: string }
|
||||||
| { type: "ERROR"; message: string };
|
| { type: "ERROR"; message: string };
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ export interface GameUIProps {
|
|||||||
isSpectator: boolean;
|
isSpectator: boolean;
|
||||||
onAction: (action: unknown) => void;
|
onAction: (action: unknown) => void;
|
||||||
players: { discordId: string; username: string }[];
|
players: { discordId: string; username: string }[];
|
||||||
roundResult?: { payouts: Record<string, { net: number }> } | null;
|
roundResult?: { settlements: Record<string, { wager: number; payout: number; net: number }> } | null;
|
||||||
roomOptions?: { betAmount?: number; timeControl?: string };
|
roomOptions?: { betAmount?: number; timeControl?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,3 +103,18 @@ body {
|
|||||||
.animate-in.slide-in-from-bottom-4 {
|
.animate-in.slide-in-from-bottom-4 {
|
||||||
animation: slideInFromBottom 0.3s ease-out forwards;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface PlayerInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface RoundResult {
|
interface RoundResult {
|
||||||
payouts: Record<string, { net: number }>;
|
settlements: Record<string, { wager: number; payout: number; net: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GameRoomState {
|
interface GameRoomState {
|
||||||
@@ -99,7 +99,15 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe
|
|||||||
|
|
||||||
case "GAME_UPDATE":
|
case "GAME_UPDATE":
|
||||||
// Broadcast with spectator view — only update state for spectators
|
// 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;
|
break;
|
||||||
|
|
||||||
case "PLAYER_JOINED":
|
case "PLAYER_JOINED":
|
||||||
@@ -135,7 +143,7 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe
|
|||||||
case "ROUND_SETTLED":
|
case "ROUND_SETTLED":
|
||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
roundResult: { payouts: msg.payouts },
|
roundResult: { settlements: msg.settlements },
|
||||||
}));
|
}));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@@ -303,6 +303,7 @@ describe("handleAction — stand", () => {
|
|||||||
dealerHand: [makeCard("7"), makeCard("8")],
|
dealerHand: [makeCard("7"), makeCard("8")],
|
||||||
turnOrder: ["p1"],
|
turnOrder: ["p1"],
|
||||||
deck: [makeCard("2")], // dealer: 15 + 2 = 17
|
deck: [makeCard("2")], // dealer: 15 + 2 = 17
|
||||||
|
betAmount: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = blackjackPlugin.handleAction(state, { type: "stand" }, "p1");
|
const result = blackjackPlugin.handleAction(state, { type: "stand" }, "p1");
|
||||||
@@ -310,8 +311,8 @@ describe("handleAction — stand", () => {
|
|||||||
if (!result.ok) return;
|
if (!result.ok) return;
|
||||||
expect(result.state.phase).toBe("resolved");
|
expect(result.state.phase).toBe("resolved");
|
||||||
expect(result.state.seats["p1"]!.hands[0]!.result).toBe("win"); // 19 > 17
|
expect(result.state.seats["p1"]!.hands[0]!.result).toBe("win"); // 19 > 17
|
||||||
expect(result.roundPayouts).toBeDefined();
|
expect(result.roundSettlements).toBeDefined();
|
||||||
expect(result.roundPayouts!["p1"]).toBe(2); // 1:1 win
|
expect(result.roundSettlements!["p1"]).toEqual({ wager: 10, payout: 20, net: 10 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("dealer busts — all standing players win", () => {
|
it("dealer busts — all standing players win", () => {
|
||||||
@@ -480,6 +481,7 @@ describe("handleAction — double_down", () => {
|
|||||||
dealerHand: [makeCard("7"), makeCard("8")],
|
dealerHand: [makeCard("7"), makeCard("8")],
|
||||||
turnOrder: ["p1"],
|
turnOrder: ["p1"],
|
||||||
deck: [makeCard("9"), makeCard("2")], // draw 9 → 20, dealer hits
|
deck: [makeCard("9"), makeCard("2")], // draw 9 → 20, dealer hits
|
||||||
|
betAmount: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = blackjackPlugin.handleAction(state, { type: "double_down" }, "p1");
|
const result = blackjackPlugin.handleAction(state, { type: "double_down" }, "p1");
|
||||||
@@ -524,13 +526,14 @@ describe("handleAction — double_down", () => {
|
|||||||
dealerHand: [makeCard("7"), makeCard("8")],
|
dealerHand: [makeCard("7"), makeCard("8")],
|
||||||
turnOrder: ["p1"],
|
turnOrder: ["p1"],
|
||||||
deck: [makeCard("9"), makeCard("2")], // p1: 20, dealer: 15+2 = 17
|
deck: [makeCard("9"), makeCard("2")], // p1: 20, dealer: 15+2 = 17
|
||||||
|
betAmount: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = blackjackPlugin.handleAction(state, { type: "double_down" }, "p1");
|
const result = blackjackPlugin.handleAction(state, { type: "double_down" }, "p1");
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (!result.ok) return;
|
if (!result.ok) return;
|
||||||
expect(result.roundPayouts).toBeDefined();
|
expect(result.roundSettlements).toBeDefined();
|
||||||
expect(result.roundPayouts!["p1"]).toBe(4); // bet=2, win=2*2=4
|
expect(result.roundSettlements!["p1"]).toEqual({ wager: 20, payout: 40, net: 20 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -831,25 +834,25 @@ describe("onPlayerDisconnect", () => {
|
|||||||
// ── Multi-hand payout tests ──
|
// ── Multi-hand payout tests ──
|
||||||
|
|
||||||
describe("multi-hand payouts", () => {
|
describe("multi-hand payouts", () => {
|
||||||
it("split with one win and one loss", () => {
|
it("split with one win and one loss settles to flat net", () => {
|
||||||
// Set up a resolved state with split hands
|
|
||||||
const state = riggedState({
|
const state = riggedState({
|
||||||
seats: {
|
seats: {
|
||||||
"p1": makeSeat([
|
"p1": makeSeat([
|
||||||
{ ...stoodHand([makeCard("8"), makeCard("K")], 1, true), result: "win", resultReason: "Higher hand" }, // 18 wins
|
stoodHand([makeCard("8"), makeCard("K")], 1, true),
|
||||||
{ ...stoodHand([makeCard("8"), makeCard("5")], 1, true), result: "lose", resultReason: "Lower hand" }, // 13 loses
|
playingHand([makeCard("8"), makeCard("5")], 1, true),
|
||||||
], -1),
|
], 1),
|
||||||
},
|
},
|
||||||
dealerHand: [makeCard("7"), makeCard("Q")], // 17
|
dealerHand: [makeCard("10"), makeCard("7")], // 17
|
||||||
turnOrder: ["p1"],
|
turnOrder: ["p1"],
|
||||||
phase: "resolved",
|
activePlayerIndex: 0,
|
||||||
activePlayerIndex: -1,
|
betAmount: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
// We can't easily test calculateRoundPayouts directly, but we can verify through
|
const result = blackjackPlugin.handleAction(state, { type: "stand" }, "p1");
|
||||||
// a round that resolves. Let's set up a playable scenario instead.
|
expect(result.ok).toBe(true);
|
||||||
// The payout for p1 should be: win(1*2) + lose(0) = 2
|
if (!result.ok) return;
|
||||||
// This is verified through the stand/resolve flow in the actual game
|
|
||||||
|
expect(result.roundSettlements!["p1"]).toEqual({ wager: 20, payout: 20, net: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("doubled hand win pays 4x betAmount", () => {
|
it("doubled hand win pays 4x betAmount", () => {
|
||||||
@@ -858,13 +861,14 @@ describe("multi-hand payouts", () => {
|
|||||||
dealerHand: [makeCard("6"), makeCard("8")],
|
dealerHand: [makeCard("6"), makeCard("8")],
|
||||||
turnOrder: ["p1"],
|
turnOrder: ["p1"],
|
||||||
deck: [makeCard("8"), makeCard("K")], // p1: 5+6+8=19, dealer: 6+8+K=24 bust
|
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");
|
const result = blackjackPlugin.handleAction(state, { type: "double_down" }, "p1");
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (!result.ok) return;
|
if (!result.ok) return;
|
||||||
// bet=2, win → payout = 2*2 = 4
|
// 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", () => {
|
it("doubled hand push refunds 2x betAmount", () => {
|
||||||
@@ -873,13 +877,14 @@ describe("multi-hand payouts", () => {
|
|||||||
dealerHand: [makeCard("K"), makeCard("10")],
|
dealerHand: [makeCard("K"), makeCard("10")],
|
||||||
turnOrder: ["p1"],
|
turnOrder: ["p1"],
|
||||||
deck: [makeCard("3")], // p1: 9+8+3=20, dealer: 20 → push
|
deck: [makeCard("3")], // p1: 9+8+3=20, dealer: 20 → push
|
||||||
|
betAmount: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = blackjackPlugin.handleAction(state, { type: "double_down" }, "p1");
|
const result = blackjackPlugin.handleAction(state, { type: "double_down" }, "p1");
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (!result.ok) return;
|
if (!result.ok) return;
|
||||||
// bet=2, push → payout = 2*1 = 2
|
// bet=2, push → payout = 2*1 = 2
|
||||||
expect(result.roundPayouts!["p1"]).toBe(2);
|
expect(result.roundSettlements!["p1"]).toEqual({ wager: 20, payout: 20, net: 0 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { GamePlugin, GameResult, GameOverResult } from "../types";
|
import type { GamePlugin, GameResult, GameOverResult, RoundSettlement } from "../types";
|
||||||
import type {
|
import type {
|
||||||
BlackjackState, BlackjackAction, BlackjackPlayerView,
|
BlackjackState, BlackjackAction, BlackjackPlayerView,
|
||||||
BlackjackSpectatorView, PlayerHandView, PlayerSeatView,
|
BlackjackSpectatorView, PlayerHandView, PlayerSeatView,
|
||||||
@@ -174,7 +174,49 @@ function resolveHand(hand: PlayerHand, dealerHand: Card[]): PlayerHand {
|
|||||||
return { ...hand, result: "push", resultReason: "Push" };
|
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 {
|
function finishPlayerTurns(state: BlackjackState): BlackjackState {
|
||||||
const anyStood = Object.values(state.seats).some(
|
const anyStood = Object.values(state.seats).some(
|
||||||
seat => seat.hands.some(h => h.status === "stood" || h.status === "blackjack"),
|
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> = {};
|
const resolvedSeats: Record<string, PlayerSeat> = {};
|
||||||
for (const [id, seat] of Object.entries(state.seats)) {
|
for (const [id, seat] of Object.entries(state.seats)) {
|
||||||
// First resolve the hands
|
|
||||||
const resolvedHands = seat.hands.map(h => resolveHand(h, dealerHand));
|
const resolvedHands = seat.hands.map(h => resolveHand(h, dealerHand));
|
||||||
|
const settlement = calculateSeatRoundSettlement({ ...seat, hands: resolvedHands }, state.betAmount);
|
||||||
// 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;
|
|
||||||
|
|
||||||
resolvedSeats[id] = {
|
resolvedSeats[id] = {
|
||||||
...seat,
|
...seat,
|
||||||
activeHandIndex: -1,
|
activeHandIndex: -1,
|
||||||
hands: resolvedHands,
|
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. */
|
/** Build the dealer hand for views — hole card hidden during player_turns/betting. */
|
||||||
function dealerVisibleHand(state: BlackjackState): Card[] {
|
function dealerVisibleHand(state: BlackjackState): Card[] {
|
||||||
if (state.phase === "resolved") return state.dealerHand;
|
if (state.phase === "resolved") return state.dealerHand;
|
||||||
@@ -257,7 +262,7 @@ function dealerVisibleHand(state: BlackjackState): Card[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Convert internal PlayerHand to a view. */
|
/** Convert internal PlayerHand to a view. */
|
||||||
function toHandView(hand: PlayerHand): PlayerHandView {
|
function toHandView(hand: PlayerHand, betAmount: number): PlayerHandView {
|
||||||
return {
|
return {
|
||||||
cards: hand.cards,
|
cards: hand.cards,
|
||||||
value: handValue(hand.cards),
|
value: handValue(hand.cards),
|
||||||
@@ -265,16 +270,25 @@ function toHandView(hand: PlayerHand): PlayerHandView {
|
|||||||
result: hand.result,
|
result: hand.result,
|
||||||
resultReason: hand.resultReason,
|
resultReason: hand.resultReason,
|
||||||
bet: hand.bet,
|
bet: hand.bet,
|
||||||
|
wager: hand.bet * betAmount,
|
||||||
|
payout: hand.result === null ? null : calculateHandPayout(hand, betAmount),
|
||||||
|
net: calculateHandNet(hand, betAmount),
|
||||||
fromSplit: hand.fromSplit,
|
fromSplit: hand.fromSplit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Convert internal PlayerSeat to a view. */
|
/** 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 {
|
return {
|
||||||
hands: seat.hands.map(toHandView),
|
hands: seat.hands.map(hand => toHandView(hand, betAmount)),
|
||||||
activeHandIndex: seat.activeHandIndex,
|
activeHandIndex: seat.activeHandIndex,
|
||||||
hasBet: seat.hasBet,
|
hasBet: seat.hasBet,
|
||||||
|
totalWager: seat.hands.reduce((sum, hand) => sum + (hand.bet * betAmount), 0),
|
||||||
|
roundNet: settlement?.net ?? null,
|
||||||
cumulativePnl: seat.cumulativePnl,
|
cumulativePnl: seat.cumulativePnl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -425,7 +439,7 @@ export const blackjackPlugin: GamePlugin<BlackjackState, BlackjackAction> = {
|
|||||||
const visibleDealer = dealerVisibleHand(state);
|
const visibleDealer = dealerVisibleHand(state);
|
||||||
const seatsView: Record<string, PlayerSeatView> = {};
|
const seatsView: Record<string, PlayerSeatView> = {};
|
||||||
for (const [id, seat] of Object.entries(state.seats)) {
|
for (const [id, seat] of Object.entries(state.seats)) {
|
||||||
seatsView[id] = toSeatView(seat);
|
seatsView[id] = toSeatView(seat, state.betAmount);
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeId = state.activePlayerIndex >= 0
|
const activeId = state.activePlayerIndex >= 0
|
||||||
@@ -465,7 +479,7 @@ export const blackjackPlugin: GamePlugin<BlackjackState, BlackjackAction> = {
|
|||||||
const visibleDealer = dealerVisibleHand(state);
|
const visibleDealer = dealerVisibleHand(state);
|
||||||
const seatsView: Record<string, PlayerSeatView> = {};
|
const seatsView: Record<string, PlayerSeatView> = {};
|
||||||
for (const [id, seat] of Object.entries(state.seats)) {
|
for (const [id, seat] of Object.entries(state.seats)) {
|
||||||
seatsView[id] = toSeatView(seat);
|
seatsView[id] = toSeatView(seat, state.betAmount);
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeId = state.activePlayerIndex >= 0
|
const activeId = state.activePlayerIndex >= 0
|
||||||
@@ -559,7 +573,7 @@ function handlePlaceBet(state: BlackjackState, playerId: string): GameResult<Bla
|
|||||||
const dealtState = dealRound(newState);
|
const dealtState = dealRound(newState);
|
||||||
if (dealtState.phase === "resolved") {
|
if (dealtState.phase === "resolved") {
|
||||||
// All players got blackjack — round is already over
|
// 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 };
|
return { ok: true, state: dealtState };
|
||||||
}
|
}
|
||||||
@@ -602,7 +616,7 @@ function handleHit(state: BlackjackState, playerId: string): GameResult<Blackjac
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newState.phase === "resolved") {
|
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 };
|
return { ok: true, state: newState };
|
||||||
}
|
}
|
||||||
@@ -627,7 +641,7 @@ function handleStand(state: BlackjackState, playerId: string): GameResult<Blackj
|
|||||||
newState = advanceTurn(newState);
|
newState = advanceTurn(newState);
|
||||||
|
|
||||||
if (newState.phase === "resolved") {
|
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 };
|
return { ok: true, state: newState };
|
||||||
}
|
}
|
||||||
@@ -707,7 +721,7 @@ function handleSplit(state: BlackjackState, playerId: string): GameResult<Blackj
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newState.phase === "resolved") {
|
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 };
|
return { ok: true, state: newState };
|
||||||
}
|
}
|
||||||
@@ -749,7 +763,7 @@ function handleDoubleDown(state: BlackjackState, playerId: string): GameResult<B
|
|||||||
newState = advanceTurn(newState);
|
newState = advanceTurn(newState);
|
||||||
|
|
||||||
if (newState.phase === "resolved") {
|
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 };
|
return { ok: true, state: newState };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ export interface PlayerHandView {
|
|||||||
result: "win" | "blackjack" | "push" | "lose" | null;
|
result: "win" | "blackjack" | "push" | "lose" | null;
|
||||||
resultReason: string | null;
|
resultReason: string | null;
|
||||||
bet: number;
|
bet: number;
|
||||||
|
wager: number;
|
||||||
|
payout: number | null;
|
||||||
|
net: number | null;
|
||||||
fromSplit: boolean;
|
fromSplit: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +75,8 @@ export interface PlayerSeatView {
|
|||||||
hands: PlayerHandView[];
|
hands: PlayerHandView[];
|
||||||
activeHandIndex: number;
|
activeHandIndex: number;
|
||||||
hasBet: boolean;
|
hasBet: boolean;
|
||||||
|
totalWager: number;
|
||||||
|
roundNet: number | null;
|
||||||
/** Cumulative PnL from all previous rounds. */
|
/** Cumulative PnL from all previous rounds. */
|
||||||
cumulativePnl: number;
|
cumulativePnl: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,14 @@ export interface GamePlugin<TState = unknown, TAction = unknown> {
|
|||||||
isSpectatorAction?(action: TAction): boolean;
|
isSpectatorAction?(action: TAction): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RoundSettlement {
|
||||||
|
wager: number;
|
||||||
|
payout: number;
|
||||||
|
net: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type GameResult<TState> =
|
export type GameResult<TState> =
|
||||||
| { ok: true; state: TState; roundPayouts?: Record<string, number> }
|
| { ok: true; state: TState; roundSettlements?: Record<string, RoundSettlement> }
|
||||||
| { ok: false; error: string };
|
| { ok: false; error: string };
|
||||||
|
|
||||||
export type GameOverResult = {
|
export type GameOverResult = {
|
||||||
|
|||||||
Reference in New Issue
Block a user