- Replace round payout multipliers with per-player settlement amounts - Update blackjack panel to display wager, payout, and net results
859 lines
29 KiB
TypeScript
859 lines
29 KiB
TypeScript
import type { GamePlugin, GameResult, GameOverResult, RoundSettlement } from "../types";
|
|
import type {
|
|
BlackjackState, BlackjackAction, BlackjackPlayerView,
|
|
BlackjackSpectatorView, PlayerHandView, PlayerSeatView,
|
|
Card, Suit, Rank, PlayerHand, PlayerSeat,
|
|
} from "./blackjack.types";
|
|
|
|
// ── Constants ──
|
|
|
|
const MAX_HANDS_PER_PLAYER = 4; // max 3 splits
|
|
const RESHUFFLE_THRESHOLD = 15; // reshuffle when deck drops below this
|
|
|
|
// ── Card helpers ──
|
|
|
|
const SUITS: Suit[] = ["hearts", "diamonds", "clubs", "spades"];
|
|
const RANKS: Rank[] = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"];
|
|
|
|
function createDeck(): Card[] {
|
|
const deck: Card[] = [];
|
|
for (const suit of SUITS) {
|
|
for (const rank of RANKS) {
|
|
deck.push({ suit, rank });
|
|
}
|
|
}
|
|
return deck;
|
|
}
|
|
|
|
function shuffleDeck(deck: Card[]): Card[] {
|
|
const shuffled = [...deck];
|
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[shuffled[i]!, shuffled[j]!] = [shuffled[j]!, shuffled[i]!];
|
|
}
|
|
return shuffled;
|
|
}
|
|
|
|
function cardValue(rank: Rank): number {
|
|
if (rank === "A") return 11;
|
|
if (["K", "Q", "J"].includes(rank)) return 10;
|
|
return parseInt(rank);
|
|
}
|
|
|
|
/** Calculate the best hand value (accounting for soft aces). */
|
|
export function handValue(hand: Card[]): number {
|
|
let total = 0;
|
|
let aces = 0;
|
|
for (const card of hand) {
|
|
total += cardValue(card.rank);
|
|
if (card.rank === "A") aces++;
|
|
}
|
|
while (total > 21 && aces > 0) {
|
|
total -= 10;
|
|
aces--;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
function isBust(hand: Card[]): boolean {
|
|
return handValue(hand) > 21;
|
|
}
|
|
|
|
function isNaturalBlackjack(hand: Card[]): boolean {
|
|
return hand.length === 2 && handValue(hand) === 21;
|
|
}
|
|
|
|
function drawCard(deck: Card[]): [Card, Card[]] {
|
|
return [deck[0]!, deck.slice(1)];
|
|
}
|
|
|
|
/** Check if two cards can be split (same rank). */
|
|
function canSplitHand(hand: PlayerHand, totalHands: number): boolean {
|
|
if (hand.cards.length !== 2) return false;
|
|
if (hand.status !== "playing") return false;
|
|
if (totalHands >= MAX_HANDS_PER_PLAYER) return false;
|
|
return cardValue(hand.cards[0]!.rank) === cardValue(hand.cards[1]!.rank);
|
|
}
|
|
|
|
/** Check if a hand can be doubled down (exactly 2 cards, still playing). */
|
|
function canDoubleHand(hand: PlayerHand): boolean {
|
|
return hand.cards.length === 2 && hand.status === "playing";
|
|
}
|
|
|
|
/** Play dealer hand: hit until 17+. */
|
|
function playDealerHand(dealerHand: Card[], deck: Card[]): { dealerHand: Card[]; deck: Card[] } {
|
|
let hand = [...dealerHand];
|
|
let remaining = [...deck];
|
|
while (handValue(hand) < 17) {
|
|
const [card, rest] = drawCard(remaining);
|
|
hand = [...hand, card];
|
|
remaining = rest;
|
|
}
|
|
return { dealerHand: hand, deck: remaining };
|
|
}
|
|
|
|
/** Find the next player who has a "playing" hand, starting after the given index. */
|
|
function findNextActivePlayer(state: BlackjackState, afterIndex: number): { playerIndex: number; handIndex: number } {
|
|
for (let i = afterIndex + 1; i < state.turnOrder.length; i++) {
|
|
const seat = state.seats[state.turnOrder[i]!];
|
|
if (!seat) continue;
|
|
const hi = seat.hands.findIndex(h => h.status === "playing");
|
|
if (hi !== -1) return { playerIndex: i, handIndex: hi };
|
|
}
|
|
return { playerIndex: -1, handIndex: -1 };
|
|
}
|
|
|
|
/** Advance to the next hand within the current player, or to the next player. */
|
|
function advanceTurn(state: BlackjackState): BlackjackState {
|
|
const activeId = state.turnOrder[state.activePlayerIndex];
|
|
if (!activeId) return finishPlayerTurns(state);
|
|
|
|
const seat = state.seats[activeId];
|
|
if (!seat) return finishPlayerTurns(state);
|
|
|
|
// Try next hand in current player's seat
|
|
for (let hi = seat.activeHandIndex + 1; hi < seat.hands.length; hi++) {
|
|
if (seat.hands[hi]!.status === "playing") {
|
|
return {
|
|
...state,
|
|
seats: {
|
|
...state.seats,
|
|
[activeId]: { ...seat, activeHandIndex: hi },
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
// No more hands for this player — move to next player
|
|
const next = findNextActivePlayer(state, state.activePlayerIndex);
|
|
if (next.playerIndex === -1) {
|
|
return finishPlayerTurns(state);
|
|
}
|
|
|
|
const nextId = state.turnOrder[next.playerIndex]!;
|
|
const nextSeat = state.seats[nextId]!;
|
|
return {
|
|
...state,
|
|
activePlayerIndex: next.playerIndex,
|
|
seats: {
|
|
...state.seats,
|
|
[activeId]: { ...seat, activeHandIndex: -1 },
|
|
[nextId]: { ...nextSeat, activeHandIndex: next.handIndex },
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Resolve a single hand against the dealer. */
|
|
function resolveHand(hand: PlayerHand, dealerHand: Card[]): PlayerHand {
|
|
if (hand.result) return hand; // already resolved (bust, etc.)
|
|
|
|
const dealerVal = handValue(dealerHand);
|
|
const dealerBust = isBust(dealerHand);
|
|
const playerVal = handValue(hand.cards);
|
|
|
|
if (hand.status === "bust") {
|
|
return { ...hand, result: "lose", resultReason: "Player busts" };
|
|
}
|
|
|
|
if (hand.status === "blackjack") {
|
|
if (isNaturalBlackjack(dealerHand)) {
|
|
return { ...hand, result: "push", resultReason: "Both have Blackjack" };
|
|
}
|
|
return { ...hand, result: "blackjack", resultReason: "Blackjack!" };
|
|
}
|
|
|
|
if (dealerBust) {
|
|
return { ...hand, result: "win", resultReason: "Dealer busts" };
|
|
}
|
|
if (playerVal > dealerVal) {
|
|
return { ...hand, result: "win", resultReason: "Higher hand" };
|
|
}
|
|
if (playerVal < dealerVal) {
|
|
return { ...hand, result: "lose", resultReason: "Dealer has higher hand" };
|
|
}
|
|
return { ...hand, result: "push", resultReason: "Push" };
|
|
}
|
|
|
|
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"),
|
|
);
|
|
|
|
let dealerHand = state.dealerHand;
|
|
let deck = state.deck;
|
|
|
|
if (anyStood) {
|
|
const dealer = playDealerHand(dealerHand, deck);
|
|
dealerHand = dealer.dealerHand;
|
|
deck = dealer.deck;
|
|
}
|
|
|
|
const resolvedSeats: Record<string, PlayerSeat> = {};
|
|
for (const [id, seat] of Object.entries(state.seats)) {
|
|
const resolvedHands = seat.hands.map(h => resolveHand(h, dealerHand));
|
|
const settlement = calculateSeatRoundSettlement({ ...seat, hands: resolvedHands }, state.betAmount);
|
|
|
|
resolvedSeats[id] = {
|
|
...seat,
|
|
activeHandIndex: -1,
|
|
hands: resolvedHands,
|
|
cumulativePnl: (seat.cumulativePnl ?? 0) + settlement.net,
|
|
};
|
|
}
|
|
|
|
return {
|
|
...state,
|
|
deck,
|
|
dealerHand,
|
|
seats: resolvedSeats,
|
|
activePlayerIndex: -1,
|
|
phase: "resolved",
|
|
};
|
|
}
|
|
|
|
/** 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;
|
|
if (state.dealerHand.length === 0) return [];
|
|
return [state.dealerHand[0]!, { suit: "spades", rank: "?" as Rank }];
|
|
}
|
|
|
|
/** Convert internal PlayerHand to a view. */
|
|
function toHandView(hand: PlayerHand, betAmount: number): PlayerHandView {
|
|
return {
|
|
cards: hand.cards,
|
|
value: handValue(hand.cards),
|
|
status: hand.status,
|
|
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, betAmount: number): PlayerSeatView {
|
|
const settlement = seat.hands.every(hand => hand.result !== null)
|
|
? calculateSeatRoundSettlement(seat, betAmount)
|
|
: null;
|
|
|
|
return {
|
|
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,
|
|
};
|
|
}
|
|
|
|
/** Deal cards to all seated players and the dealer. */
|
|
function dealRound(state: BlackjackState): BlackjackState {
|
|
let deck = state.deck.length < RESHUFFLE_THRESHOLD
|
|
? shuffleDeck(createDeck())
|
|
: [...state.deck];
|
|
|
|
const seats: Record<string, PlayerSeat> = {};
|
|
|
|
// Deal 2 cards to each player
|
|
for (const pid of state.turnOrder) {
|
|
const cards: Card[] = [];
|
|
let card: Card;
|
|
[card, deck] = drawCard(deck); cards.push(card);
|
|
[card, deck] = drawCard(deck); cards.push(card);
|
|
|
|
const isBlackjack = isNaturalBlackjack(cards);
|
|
seats[pid] = {
|
|
hands: [{
|
|
cards,
|
|
status: isBlackjack ? "blackjack" : "playing",
|
|
result: null,
|
|
resultReason: null,
|
|
bet: 1,
|
|
fromSplit: false,
|
|
}],
|
|
activeHandIndex: -1,
|
|
hasBet: true,
|
|
cumulativePnl: state.seats[pid]?.cumulativePnl ?? 0,
|
|
};
|
|
}
|
|
|
|
// Deal 2 cards to dealer
|
|
const dealerHand: Card[] = [];
|
|
let card: Card;
|
|
[card, deck] = drawCard(deck); dealerHand.push(card);
|
|
[card, deck] = drawCard(deck); dealerHand.push(card);
|
|
|
|
let newState: BlackjackState = {
|
|
...state,
|
|
deck,
|
|
dealerHand,
|
|
seats,
|
|
activePlayerIndex: 0,
|
|
phase: "player_turns",
|
|
};
|
|
|
|
// Find first player that needs to act (skip natural blackjacks)
|
|
const first = findNextActivePlayer(newState, -1);
|
|
if (first.playerIndex === -1) {
|
|
// All players have blackjack — resolve immediately
|
|
return finishPlayerTurns(newState);
|
|
}
|
|
|
|
newState.activePlayerIndex = first.playerIndex;
|
|
const firstId = newState.turnOrder[first.playerIndex]!;
|
|
newState.seats = {
|
|
...newState.seats,
|
|
[firstId]: { ...newState.seats[firstId]!, activeHandIndex: first.handIndex },
|
|
};
|
|
|
|
return newState;
|
|
}
|
|
|
|
/** Get the active player's active hand, or null. */
|
|
function getActiveHand(state: BlackjackState): { playerId: string; seat: PlayerSeat; hand: PlayerHand } | null {
|
|
if (state.phase !== "player_turns" || state.activePlayerIndex < 0) return null;
|
|
const playerId = state.turnOrder[state.activePlayerIndex];
|
|
if (!playerId) return null;
|
|
const seat = state.seats[playerId];
|
|
if (!seat || seat.activeHandIndex < 0) return null;
|
|
const hand = seat.hands[seat.activeHandIndex];
|
|
if (!hand) return null;
|
|
return { playerId, seat, hand };
|
|
}
|
|
|
|
// ── Plugin ──
|
|
|
|
export const blackjackPlugin: GamePlugin<BlackjackState, BlackjackAction> = {
|
|
slug: "blackjack",
|
|
name: "Blackjack",
|
|
minPlayers: 1,
|
|
maxPlayers: 6,
|
|
manualStart: true,
|
|
|
|
createInitialState(players: string[], options?: Record<string, unknown>): BlackjackState {
|
|
const betAmount = typeof options?.betAmount === 'number' ? options.betAmount : 0;
|
|
const seats: Record<string, PlayerSeat> = {};
|
|
for (const pid of players) {
|
|
seats[pid] = {
|
|
hands: [],
|
|
activeHandIndex: -1,
|
|
hasBet: false,
|
|
cumulativePnl: 0,
|
|
};
|
|
}
|
|
|
|
return {
|
|
deck: shuffleDeck(createDeck()),
|
|
dealerHand: [],
|
|
seats,
|
|
turnOrder: [...players],
|
|
activePlayerIndex: -1,
|
|
phase: "betting",
|
|
roundNumber: 1,
|
|
betAmount,
|
|
};
|
|
},
|
|
|
|
getActionCost(state: BlackjackState, action: BlackjackAction, _playerId: string): number {
|
|
switch (action.type) {
|
|
case "place_bet": return 1;
|
|
case "split": return 1;
|
|
case "double_down": return 1;
|
|
default: return 0;
|
|
}
|
|
},
|
|
|
|
isSpectatorAction(action: BlackjackAction): boolean {
|
|
return action.type === "sit_down";
|
|
},
|
|
|
|
handleAction(state: BlackjackState, action: BlackjackAction, playerId: string): GameResult<BlackjackState> {
|
|
switch (action.type) {
|
|
case "place_bet":
|
|
return handlePlaceBet(state, playerId);
|
|
case "hit":
|
|
return handleHit(state, playerId);
|
|
case "stand":
|
|
return handleStand(state, playerId);
|
|
case "split":
|
|
return handleSplit(state, playerId);
|
|
case "double_down":
|
|
return handleDoubleDown(state, playerId);
|
|
case "leave_table":
|
|
return handleLeaveTable(state, playerId);
|
|
case "sit_down":
|
|
return handleSitDown(state, playerId);
|
|
default:
|
|
return { ok: false, error: "Unknown action type" };
|
|
}
|
|
},
|
|
|
|
getPlayerView(state: BlackjackState, playerId: string): BlackjackPlayerView {
|
|
const visibleDealer = dealerVisibleHand(state);
|
|
const seatsView: Record<string, PlayerSeatView> = {};
|
|
for (const [id, seat] of Object.entries(state.seats)) {
|
|
seatsView[id] = toSeatView(seat, state.betAmount);
|
|
}
|
|
|
|
const activeId = state.activePlayerIndex >= 0
|
|
? state.turnOrder[state.activePlayerIndex] ?? null
|
|
: null;
|
|
|
|
const isMyTurn = activeId === playerId && state.phase === "player_turns";
|
|
const mySeat = state.seats[playerId];
|
|
const myActiveHand = mySeat && mySeat.activeHandIndex >= 0
|
|
? mySeat.hands[mySeat.activeHandIndex] ?? null
|
|
: null;
|
|
|
|
// Determine active hand index for the view
|
|
const activeHandIdx = activeId && state.seats[activeId]
|
|
? state.seats[activeId]!.activeHandIndex
|
|
: -1;
|
|
|
|
return {
|
|
dealerHand: visibleDealer,
|
|
dealerVisibleValue: state.dealerHand.length > 0 ? cardValue(state.dealerHand[0]!.rank) : 0,
|
|
dealerFullValue: state.phase === "resolved" ? handValue(state.dealerHand) : null,
|
|
seats: seatsView,
|
|
turnOrder: state.turnOrder,
|
|
activePlayerId: activeId,
|
|
activeHandIndex: activeHandIdx,
|
|
myPlayerId: playerId,
|
|
phase: state.phase,
|
|
canAct: isMyTurn,
|
|
canSplit: isMyTurn && myActiveHand !== null && mySeat !== undefined && canSplitHand(myActiveHand, mySeat.hands.length),
|
|
canDoubleDown: isMyTurn && myActiveHand !== null && canDoubleHand(myActiveHand),
|
|
roundNumber: state.roundNumber,
|
|
myCumulativePnl: mySeat?.cumulativePnl ?? 0,
|
|
};
|
|
},
|
|
|
|
getSpectatorView(state: BlackjackState): BlackjackSpectatorView {
|
|
const visibleDealer = dealerVisibleHand(state);
|
|
const seatsView: Record<string, PlayerSeatView> = {};
|
|
for (const [id, seat] of Object.entries(state.seats)) {
|
|
seatsView[id] = toSeatView(seat, state.betAmount);
|
|
}
|
|
|
|
const activeId = state.activePlayerIndex >= 0
|
|
? state.turnOrder[state.activePlayerIndex] ?? null
|
|
: null;
|
|
|
|
const activeHandIdx = activeId && state.seats[activeId]
|
|
? state.seats[activeId]!.activeHandIndex
|
|
: -1;
|
|
|
|
return {
|
|
dealerHand: visibleDealer,
|
|
dealerVisibleValue: state.dealerHand.length > 0 ? cardValue(state.dealerHand[0]!.rank) : 0,
|
|
dealerFullValue: state.phase === "resolved" ? handValue(state.dealerHand) : null,
|
|
seats: seatsView,
|
|
turnOrder: state.turnOrder,
|
|
activePlayerId: activeId,
|
|
activeHandIndex: activeHandIdx,
|
|
phase: state.phase,
|
|
roundNumber: state.roundNumber,
|
|
};
|
|
},
|
|
|
|
isGameOver(state: BlackjackState): GameOverResult | null {
|
|
if (state.turnOrder.length === 0) {
|
|
return { winner: null, reason: "All players left the table", payouts: {} };
|
|
}
|
|
return null;
|
|
},
|
|
|
|
onPlayerDisconnect(state: BlackjackState, playerId: string): BlackjackState {
|
|
return removePlayer(state, playerId);
|
|
},
|
|
};
|
|
|
|
// ── Action handlers ──
|
|
|
|
function handlePlaceBet(state: BlackjackState, playerId: string): GameResult<BlackjackState> {
|
|
if (state.phase !== "betting" && state.phase !== "resolved") {
|
|
return { ok: false, error: "Cannot place bets right now" };
|
|
}
|
|
if (!state.turnOrder.includes(playerId)) {
|
|
return { ok: false, error: "You are not seated at this table" };
|
|
}
|
|
|
|
let newState = { ...state };
|
|
|
|
// Transition from resolved to betting for a new round
|
|
if (state.phase === "resolved") {
|
|
const deck = state.deck.length < RESHUFFLE_THRESHOLD
|
|
? shuffleDeck(createDeck())
|
|
: state.deck;
|
|
|
|
const resetSeats: Record<string, PlayerSeat> = {};
|
|
for (const pid of state.turnOrder) {
|
|
// Preserve cumulativePnl from previous round
|
|
resetSeats[pid] = {
|
|
hands: [],
|
|
activeHandIndex: -1,
|
|
hasBet: false,
|
|
cumulativePnl: state.seats[pid]?.cumulativePnl ?? 0,
|
|
};
|
|
}
|
|
|
|
newState = {
|
|
...state,
|
|
deck,
|
|
dealerHand: [],
|
|
seats: resetSeats,
|
|
activePlayerIndex: -1,
|
|
phase: "betting",
|
|
roundNumber: state.roundNumber + 1,
|
|
};
|
|
}
|
|
|
|
const seat = newState.seats[playerId];
|
|
if (!seat) return { ok: false, error: "Seat not found" };
|
|
if (seat.hasBet) return { ok: false, error: "You have already placed your bet" };
|
|
|
|
newState = {
|
|
...newState,
|
|
seats: {
|
|
...newState.seats,
|
|
[playerId]: { ...seat, hasBet: true },
|
|
},
|
|
};
|
|
|
|
// Check if all seated players have bet
|
|
const allBet = newState.turnOrder.every(pid => newState.seats[pid]?.hasBet);
|
|
if (allBet) {
|
|
const dealtState = dealRound(newState);
|
|
if (dealtState.phase === "resolved") {
|
|
// All players got blackjack — round is already over
|
|
return { ok: true, state: dealtState, roundSettlements: calculateRoundSettlements(dealtState.seats, dealtState.betAmount) };
|
|
}
|
|
return { ok: true, state: dealtState };
|
|
}
|
|
|
|
return { ok: true, state: newState };
|
|
}
|
|
|
|
function handleHit(state: BlackjackState, playerId: string): GameResult<BlackjackState> {
|
|
const active = getActiveHand(state);
|
|
if (!active) return { ok: false, error: "Game is not in play" };
|
|
if (active.playerId !== playerId) return { ok: false, error: "It's not your turn" };
|
|
|
|
const [card, remaining] = drawCard(state.deck);
|
|
const newCards = [...active.hand.cards, card];
|
|
const bust = isBust(newCards);
|
|
const got21 = handValue(newCards) === 21;
|
|
|
|
const newHand: PlayerHand = {
|
|
...active.hand,
|
|
cards: newCards,
|
|
status: bust ? "bust" : got21 ? "stood" : "playing",
|
|
result: bust ? "lose" : null,
|
|
resultReason: bust ? "Player busts" : null,
|
|
};
|
|
|
|
const newHands = [...active.seat.hands];
|
|
newHands[active.seat.activeHandIndex] = newHand;
|
|
|
|
let newState: BlackjackState = {
|
|
...state,
|
|
deck: remaining,
|
|
seats: {
|
|
...state.seats,
|
|
[playerId]: { ...active.seat, hands: newHands },
|
|
},
|
|
};
|
|
|
|
if (bust || got21) {
|
|
newState = advanceTurn(newState);
|
|
}
|
|
|
|
if (newState.phase === "resolved") {
|
|
return { ok: true, state: newState, roundSettlements: calculateRoundSettlements(newState.seats, newState.betAmount) };
|
|
}
|
|
return { ok: true, state: newState };
|
|
}
|
|
|
|
function handleStand(state: BlackjackState, playerId: string): GameResult<BlackjackState> {
|
|
const active = getActiveHand(state);
|
|
if (!active) return { ok: false, error: "Game is not in play" };
|
|
if (active.playerId !== playerId) return { ok: false, error: "It's not your turn" };
|
|
|
|
const newHand: PlayerHand = { ...active.hand, status: "stood" };
|
|
const newHands = [...active.seat.hands];
|
|
newHands[active.seat.activeHandIndex] = newHand;
|
|
|
|
let newState: BlackjackState = {
|
|
...state,
|
|
seats: {
|
|
...state.seats,
|
|
[playerId]: { ...active.seat, hands: newHands },
|
|
},
|
|
};
|
|
|
|
newState = advanceTurn(newState);
|
|
|
|
if (newState.phase === "resolved") {
|
|
return { ok: true, state: newState, roundSettlements: calculateRoundSettlements(newState.seats, newState.betAmount) };
|
|
}
|
|
return { ok: true, state: newState };
|
|
}
|
|
|
|
function handleSplit(state: BlackjackState, playerId: string): GameResult<BlackjackState> {
|
|
const active = getActiveHand(state);
|
|
if (!active) return { ok: false, error: "Game is not in play" };
|
|
if (active.playerId !== playerId) return { ok: false, error: "It's not your turn" };
|
|
|
|
if (!canSplitHand(active.hand, active.seat.hands.length)) {
|
|
return { ok: false, error: "Cannot split this hand" };
|
|
}
|
|
|
|
const [card1, deck1] = drawCard(state.deck);
|
|
const [card2, deck2] = drawCard(deck1);
|
|
|
|
const isAceSplit = active.hand.cards[0]!.rank === "A";
|
|
|
|
const hand1Cards = [active.hand.cards[0]!, card1];
|
|
const hand2Cards = [active.hand.cards[1]!, card2];
|
|
|
|
// Split aces: auto-stand, and 21 on split is not blackjack
|
|
const hand1Status = isAceSplit ? "stood" as const
|
|
: handValue(hand1Cards) === 21 ? "stood" as const
|
|
: "playing" as const;
|
|
const hand2Status = isAceSplit ? "stood" as const
|
|
: handValue(hand2Cards) === 21 ? "stood" as const
|
|
: "playing" as const;
|
|
|
|
const hand1: PlayerHand = {
|
|
cards: hand1Cards,
|
|
status: hand1Status,
|
|
result: null,
|
|
resultReason: null,
|
|
bet: 1,
|
|
fromSplit: true,
|
|
};
|
|
|
|
const hand2: PlayerHand = {
|
|
cards: hand2Cards,
|
|
status: hand2Status,
|
|
result: null,
|
|
resultReason: null,
|
|
bet: 1,
|
|
fromSplit: true,
|
|
};
|
|
|
|
const newHands = [...active.seat.hands];
|
|
newHands.splice(active.seat.activeHandIndex, 1, hand1, hand2);
|
|
|
|
// Find the first playable hand starting from the split position
|
|
let newActiveHandIndex = active.seat.activeHandIndex;
|
|
if (hand1.status !== "playing") {
|
|
if (hand2.status !== "playing") {
|
|
newActiveHandIndex = -1; // both auto-stood
|
|
} else {
|
|
newActiveHandIndex = active.seat.activeHandIndex + 1;
|
|
}
|
|
}
|
|
|
|
let newState: BlackjackState = {
|
|
...state,
|
|
deck: deck2,
|
|
seats: {
|
|
...state.seats,
|
|
[playerId]: {
|
|
...active.seat,
|
|
hands: newHands,
|
|
activeHandIndex: newActiveHandIndex,
|
|
},
|
|
},
|
|
};
|
|
|
|
// If both split hands auto-stood (aces), advance turn
|
|
if (newActiveHandIndex === -1 || newHands[newActiveHandIndex]?.status !== "playing") {
|
|
newState = advanceTurn(newState);
|
|
}
|
|
|
|
if (newState.phase === "resolved") {
|
|
return { ok: true, state: newState, roundSettlements: calculateRoundSettlements(newState.seats, newState.betAmount) };
|
|
}
|
|
return { ok: true, state: newState };
|
|
}
|
|
|
|
function handleDoubleDown(state: BlackjackState, playerId: string): GameResult<BlackjackState> {
|
|
const active = getActiveHand(state);
|
|
if (!active) return { ok: false, error: "Game is not in play" };
|
|
if (active.playerId !== playerId) return { ok: false, error: "It's not your turn" };
|
|
|
|
if (!canDoubleHand(active.hand)) {
|
|
return { ok: false, error: "Cannot double down on this hand" };
|
|
}
|
|
|
|
const [card, remaining] = drawCard(state.deck);
|
|
const newCards = [...active.hand.cards, card];
|
|
const bust = isBust(newCards);
|
|
|
|
const newHand: PlayerHand = {
|
|
...active.hand,
|
|
cards: newCards,
|
|
bet: 2,
|
|
status: bust ? "bust" : "stood",
|
|
result: bust ? "lose" : null,
|
|
resultReason: bust ? "Player busts" : null,
|
|
};
|
|
|
|
const newHands = [...active.seat.hands];
|
|
newHands[active.seat.activeHandIndex] = newHand;
|
|
|
|
let newState: BlackjackState = {
|
|
...state,
|
|
deck: remaining,
|
|
seats: {
|
|
...state.seats,
|
|
[playerId]: { ...active.seat, hands: newHands },
|
|
},
|
|
};
|
|
|
|
newState = advanceTurn(newState);
|
|
|
|
if (newState.phase === "resolved") {
|
|
return { ok: true, state: newState, roundSettlements: calculateRoundSettlements(newState.seats, newState.betAmount) };
|
|
}
|
|
return { ok: true, state: newState };
|
|
}
|
|
|
|
function handleLeaveTable(state: BlackjackState, playerId: string): GameResult<BlackjackState> {
|
|
if (!state.turnOrder.includes(playerId)) {
|
|
return { ok: false, error: "You are not seated at this table" };
|
|
}
|
|
|
|
const newState = removePlayer(state, playerId);
|
|
return { ok: true, state: newState };
|
|
}
|
|
|
|
function handleSitDown(state: BlackjackState, playerId: string): GameResult<BlackjackState> {
|
|
if (state.phase !== "betting") {
|
|
return { ok: false, error: "You can only sit down during the betting phase" };
|
|
}
|
|
if (state.turnOrder.includes(playerId)) {
|
|
return { ok: false, error: "You are already seated" };
|
|
}
|
|
if (state.turnOrder.length >= 6) {
|
|
return { ok: false, error: "Table is full" };
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
state: {
|
|
...state,
|
|
turnOrder: [...state.turnOrder, playerId],
|
|
seats: {
|
|
...state.seats,
|
|
[playerId]: {
|
|
hands: [],
|
|
activeHandIndex: -1,
|
|
hasBet: false,
|
|
cumulativePnl: 0,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Remove a player from the table, handling mid-round cleanup. */
|
|
function removePlayer(state: BlackjackState, playerId: string): BlackjackState {
|
|
if (!state.turnOrder.includes(playerId)) return state;
|
|
|
|
const playerIdx = state.turnOrder.indexOf(playerId);
|
|
const newTurnOrder = state.turnOrder.filter(id => id !== playerId);
|
|
const { [playerId]: _, ...remainingSeats } = state.seats;
|
|
|
|
let newState: BlackjackState = {
|
|
...state,
|
|
turnOrder: newTurnOrder,
|
|
seats: remainingSeats,
|
|
};
|
|
|
|
// Adjust activePlayerIndex if we're in player_turns
|
|
if (state.phase === "player_turns") {
|
|
const wasActive = state.activePlayerIndex === playerIdx;
|
|
|
|
if (wasActive) {
|
|
// The leaving player was the active player — find next
|
|
// Since we removed the player, the index effectively points to the next player
|
|
const adjustedIndex = playerIdx >= newTurnOrder.length ? newTurnOrder.length - 1 : playerIdx;
|
|
newState.activePlayerIndex = adjustedIndex;
|
|
|
|
// Try to find a playing hand from this adjusted index onward
|
|
const next = findNextActivePlayer(
|
|
{ ...newState, activePlayerIndex: adjustedIndex - 1 },
|
|
adjustedIndex - 1,
|
|
);
|
|
|
|
if (next.playerIndex === -1) {
|
|
if (newTurnOrder.length > 0) {
|
|
newState = finishPlayerTurns(newState);
|
|
}
|
|
} else {
|
|
newState.activePlayerIndex = next.playerIndex;
|
|
const nextId = newTurnOrder[next.playerIndex]!;
|
|
newState.seats = {
|
|
...newState.seats,
|
|
[nextId]: { ...newState.seats[nextId]!, activeHandIndex: next.handIndex },
|
|
};
|
|
}
|
|
} else if (playerIdx < state.activePlayerIndex) {
|
|
// Player was before the active player — shift index down
|
|
newState.activePlayerIndex = state.activePlayerIndex - 1;
|
|
}
|
|
}
|
|
|
|
return newState;
|
|
}
|