import type { GamePlugin, GameResult, GameOverResult } 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" }; } /** Transition to dealer turn + resolve all hands. Returns round payouts. */ 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 = {}; 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 roundPayoutMoney = roundPayout[id] ?? 0; // Subtract the total bet amount to get net profit/loss const roundBetTotal = seat.hands.reduce((sum, h) => sum + (h.bet * state.betAmount), 0); const roundNetPnl = roundPayoutMoney ? roundPayoutMoney - roundBetTotal : -roundBetTotal; resolvedSeats[id] = { ...seat, activeHandIndex: -1, hands: resolvedHands, cumulativePnl: (seat.cumulativePnl ?? 0) + roundNetPnl, }; } return { ...state, deck, dealerHand, seats: resolvedSeats, activePlayerIndex: -1, phase: "resolved", }; } /** Calculate round payouts as multipliers of betAmount. */ function calculateRoundPayouts(seats: Record): Record { const payouts: Record = {}; 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; 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): PlayerHandView { return { cards: hand.cards, value: handValue(hand.cards), status: hand.status, result: hand.result, resultReason: hand.resultReason, bet: hand.bet, fromSplit: hand.fromSplit, }; } /** Convert internal PlayerSeat to a view. */ function toSeatView(seat: PlayerSeat): PlayerSeatView { return { hands: seat.hands.map(toHandView), activeHandIndex: seat.activeHandIndex, hasBet: seat.hasBet, 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 = {}; // 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 = { slug: "blackjack", name: "Blackjack", minPlayers: 1, maxPlayers: 6, manualStart: true, createInitialState(players: string[], options?: Record): BlackjackState { const betAmount = typeof options?.betAmount === 'number' ? options.betAmount : 0; const seats: Record = {}; 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 { 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 = {}; for (const [id, seat] of Object.entries(state.seats)) { seatsView[id] = toSeatView(seat); } 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; // 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 && 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 = {}; for (const [id, seat] of Object.entries(state.seats)) { seatsView[id] = toSeatView(seat); } 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 { 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 = {}; 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, roundPayouts: calculateRoundPayouts(dealtState.seats) }; } return { ok: true, state: dealtState }; } return { ok: true, state: newState }; } function handleHit(state: BlackjackState, playerId: string): GameResult { 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, roundPayouts: calculateRoundPayouts(newState.seats) }; } return { ok: true, state: newState }; } function handleStand(state: BlackjackState, playerId: string): GameResult { 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, roundPayouts: calculateRoundPayouts(newState.seats) }; } return { ok: true, state: newState }; } function handleSplit(state: BlackjackState, playerId: string): GameResult { 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, roundPayouts: calculateRoundPayouts(newState.seats) }; } return { ok: true, state: newState }; } function handleDoubleDown(state: BlackjackState, playerId: string): GameResult { 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, roundPayouts: calculateRoundPayouts(newState.seats) }; } return { ok: true, state: newState }; } function handleLeaveTable(state: BlackjackState, playerId: string): GameResult { 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 { 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; }