/** * Player action application for blackjack rounds. * Pure: validates legality, produces the next state, and auto-advances the * round whenever the action unblocks progression (next bettor, next hand, * dealer play, or settle). */ import { MAX_BET } from "./constants"; import { advanceState, createEmptyHand, drawCards } from "./engine"; import { canDoubleHand, canSplitHand, handValue, insuranceMaxFor } from "./rules"; import type { BlackjackActionKind, BlackjackHand, BlackjackPlayerState, BlackjackSeatId, BlackjackTableState, } from "./types"; export type BlackjackActionResult = | { readonly success: true; readonly state: BlackjackTableState } | { readonly success: false; readonly error: string }; function failure(error: string): BlackjackActionResult { return { success: false, error }; } function playerAt( state: BlackjackTableState, seatId: BlackjackSeatId, ): BlackjackPlayerState | null { return state.players[seatId] ?? null; } function handAt( player: BlackjackPlayerState, handIndex: number, ): BlackjackHand | null { return player.hands[handIndex] ?? null; } function placeBet( state: BlackjackTableState, seatId: BlackjackSeatId, amount: number | undefined, ): BlackjackActionResult { const player = playerAt(state, seatId); if (!player) return failure("No player occupies that seat."); if (player.hasBet) return failure("You have already bet this round."); if (player.sittingOut) return failure("You sat out this round."); if (player.bankrupt) return failure("Your bankroll cannot cover the table minimum."); if (amount === undefined) return failure("A bet requires an amount."); if (!Number.isInteger(amount)) return failure("Bets must be whole chips."); if (amount < state.config.minBet) { return failure(`The minimum bet is ${state.config.minBet}.`); } if (amount > state.config.maxBet) { return failure(`The maximum bet is ${state.config.maxBet}.`); } if (amount > player.bankroll) { return failure("The bet exceeds your bankroll."); } const updated: BlackjackPlayerState = { ...player, bankroll: player.bankroll - amount, hands: [createEmptyHand(amount)], hasBet: true, }; const next: BlackjackTableState = { ...state, players: state.players.map((candidate) => candidate.seatId === seatId ? updated : candidate, ), }; return { success: true, state: advanceState(next) }; } function takeInsurance( state: BlackjackTableState, seatId: BlackjackSeatId, amount: number | undefined, ): BlackjackActionResult { const player = playerAt(state, seatId); if (!player) return failure("No player occupies that seat."); if (!player.hasBet) return failure("Insurance requires a bet this round."); if (player.insuranceAnswered) { return failure("You have already answered the insurance offer."); } const cap = Math.min(insuranceMaxFor(player.hands[0]?.bet ?? 0), player.bankroll); if (cap <= 0) return failure("Insurance is not available right now."); if (amount === undefined) return failure("Insurance requires an amount."); if (!Number.isInteger(amount) || amount < 1) { return failure("Insurance must be at least one whole chip."); } if (amount > cap) return failure(`Insurance is capped at ${cap}.`); const updated: BlackjackPlayerState = { ...player, bankroll: player.bankroll - amount, insuranceBet: amount, insuranceAnswered: true, }; const next: BlackjackTableState = { ...state, players: state.players.map((candidate) => candidate.seatId === seatId ? updated : candidate, ), }; return { success: true, state: advanceState(next) }; } function declineInsurance( state: BlackjackTableState, seatId: BlackjackSeatId, ): BlackjackActionResult { const player = playerAt(state, seatId); if (!player) return failure("No player occupies that seat."); if (!player.hasBet) return failure("Insurance requires a bet this round."); if (player.insuranceAnswered) { return failure("You have already answered the insurance offer."); } const updated: BlackjackPlayerState = { ...player, insuranceAnswered: true }; const next: BlackjackTableState = { ...state, players: state.players.map((candidate) => candidate.seatId === seatId ? updated : candidate, ), }; return { success: true, state: advanceState(next) }; } /** Draw one card onto a hand and resolve 21/bust (or forced stand) immediately. */ function drawToHand( state: BlackjackTableState, player: BlackjackPlayerState, handIndex: number, hand: BlackjackHand, autoStand: boolean, ): { state: BlackjackTableState; player: BlackjackPlayerState; hand: BlackjackHand } { const { cards, shoe } = drawCards(state, 1); const card = cards[0]; const withCard: BlackjackHand = card ? { ...hand, cards: [...hand.cards, card] } : hand; const total = handValue(withCard.cards).total; const status = total > 21 ? ("bust" as const) : total === 21 || autoStand ? ("stood" as const) : withCard.status; const resolved: BlackjackHand = { ...withCard, status }; const updatedPlayer: BlackjackPlayerState = { ...player, hands: player.hands.map((candidate, index) => (index === handIndex ? resolved : candidate)), }; const updatedState: BlackjackTableState = { ...state, shoe, players: state.players.map((candidate) => candidate.seatId === player.seatId ? updatedPlayer : candidate, ), }; return { state: updatedState, player: updatedPlayer, hand: resolved }; } function playHand( state: BlackjackTableState, seatId: BlackjackSeatId, action: "hit" | "stand" | "double" | "split", ): BlackjackActionResult { const player = playerAt(state, seatId); if (!player) return failure("No player occupies that seat."); const handIndex = state.activeHandIndex; const hand = handAt(player, handIndex); if (!hand || hand.status !== "active") { return failure("That hand is not awaiting an action."); } if (action === "stand") { const stood: BlackjackHand = { ...hand, status: "stood" }; const updatedPlayer: BlackjackPlayerState = { ...player, hands: player.hands.map((candidate, index) => (index === handIndex ? stood : candidate)), }; const next: BlackjackTableState = { ...state, players: state.players.map((candidate) => candidate.seatId === seatId ? updatedPlayer : candidate, ), }; return { success: true, state: advanceState(next) }; } if (action === "hit") { const drawn = drawToHand(state, player, handIndex, hand, false); return { success: true, state: advanceState(drawn.state) }; } if (action === "double") { if (!canDoubleHand(hand, player.bankroll)) { if (hand.cards.length !== 2) { return failure("Doubling is only allowed on the first two cards."); } return failure("Doubling requires your bankroll to cover the extra stake."); } const extraStake = hand.bet; const reduced: BlackjackPlayerState = { ...player, bankroll: player.bankroll - extraStake }; const doubled: BlackjackHand = { ...hand, bet: hand.bet * 2, doubled: true }; const staged: BlackjackTableState = { ...state, players: state.players.map((candidate) => candidate.seatId === seatId ? { ...reduced, hands: reduced.hands.map((candidateHand, index) => index === handIndex ? doubled : candidateHand, ), } : candidate, ), }; const stagedPlayer = playerAt(staged, seatId); if (!stagedPlayer) return failure("No player occupies that seat."); const stagedHand = handAt(stagedPlayer, handIndex); if (!stagedHand) return failure("That hand is not awaiting an action."); // Doubling draws exactly one card and the hand then stands (or busts). const drawn = drawToHand(staged, stagedPlayer, handIndex, stagedHand, true); return { success: true, state: advanceState(drawn.state) }; } // Split: matching ranks already verified by canSplitHand. if (!canSplitHand(player.hands, hand, player.bankroll)) { if (hand.cards.length !== 2 || hand.cards[0].rank !== hand.cards[1].rank) { return failure("Splitting requires a pair of matching ranks."); } if (hand.splitAces) { return failure("Split aces cannot be split again."); } return failure("Splitting requires your bankroll to cover the second stake."); } const secondStake = hand.bet; const reduced: BlackjackPlayerState = { ...player, bankroll: player.bankroll - secondStake }; const { cards, shoe } = drawCards(state, 2); const [cardA, cardB] = cards; if (!cardA || !cardB) { return failure("The shoe is out of cards; try again in a moment."); } const splitAces = hand.cards[0].rank === 14; const autoStatus = splitAces ? ("stood" as const) : ("active" as const); const handA: BlackjackHand = { ...createEmptyHand(secondStake), cards: [hand.cards[0], cardA], fromSplit: true, splitAces, status: handValue([hand.cards[0], cardA]).total === 21 ? ("stood" as const) : autoStatus, }; const handB: BlackjackHand = { ...createEmptyHand(secondStake), cards: [hand.cards[1], cardB], fromSplit: true, splitAces, status: handValue([hand.cards[1], cardB]).total === 21 ? ("stood" as const) : autoStatus, }; const splitHands = [ ...player.hands.slice(0, handIndex), handA, handB, ...player.hands.slice(handIndex + 1), ]; const updatedPlayer: BlackjackPlayerState = { ...reduced, hands: splitHands }; const next: BlackjackTableState = { ...state, shoe, activeHandIndex: handIndex, players: state.players.map((candidate) => candidate.seatId === seatId ? updatedPlayer : candidate, ), }; return { success: true, state: advanceState(next) }; } /** * Apply an action for `seatId`. Bet amounts are whole chips within the table * limits; insurance is capped at half the main bet. On success the state is * auto-advanced (next bettor, next hand, dealer play, or settle). */ export function applyAction( state: BlackjackTableState, seatId: BlackjackSeatId, action: BlackjackActionKind, amount?: number, ): BlackjackActionResult { const player = playerAt(state, seatId); if (!player) return failure("No player occupies that seat."); if (player.bankrupt && action !== "decline-insurance") { return failure("That seat is out of chips."); } switch (action) { case "bet": { if (state.phase !== "betting") { return failure("The table is not accepting bets right now."); } if (state.toActSeat !== seatId) { return failure("It is not that seat's turn to bet."); } return placeBet(state, seatId, amount); } case "insurance": case "decline-insurance": { if (state.phase !== "insurance") { return failure("Insurance is not being offered right now."); } if (state.toActSeat !== seatId) { return failure("It is not that seat's turn to answer the insurance offer."); } return action === "insurance" ? takeInsurance(state, seatId, amount) : declineInsurance(state, seatId); } case "hit": case "stand": case "double": case "split": { if (state.phase !== "player") { return failure("The table is not accepting hand actions right now."); } if (state.toActSeat !== seatId) { return failure("It is not that seat's turn to act."); } return playHand(state, seatId, action); } default: { const exhaustive: never = action; return failure(`Unknown action: ${String(exhaustive)}`); } } } /** * Mark a seat as sitting out the current betting round. Used by the registry * turn-timer timeout so an idle player does not stall the table. */ export function sitOut( state: BlackjackTableState, seatId: BlackjackSeatId, ): BlackjackActionResult { if (state.phase !== "betting") { return failure("The table is not accepting bets right now."); } const player = playerAt(state, seatId); if (!player) return failure("No player occupies that seat."); if (player.hasBet) return failure("You have already bet this round."); if (player.sittingOut) return failure("You are already sitting out this round."); const updated: BlackjackPlayerState = { ...player, sittingOut: true }; const next: BlackjackTableState = { ...state, players: state.players.map((candidate) => candidate.seatId === seatId ? updated : candidate, ), }; return { success: true, state: advanceState(next) }; }