Files
aurorabot/shared/games/blackjack/blackjack.plugin.ts
syntaxbullet ef78a85b9c
Some checks failed
Deploy to Production / test (push) Failing after 39s
feat(games): implement blackjack game plugin with manual start and custom payouts
Adds a full blackjack game with dealer AI, hit/stand/double-down actions,
and per-player payout multipliers (house-edge model). Extends the game
framework with manualStart support and a START_GAME WebSocket message so
hosts can begin when ready. Generalizes bet settlement transaction
descriptions from chess-specific to game-agnostic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 18:48:25 +02:00

396 lines
14 KiB
TypeScript

import type { GamePlugin, GameResult, GameOverResult } from "../types";
import type {
BlackjackState, BlackjackAction, BlackjackPlayerView,
BlackjackSpectatorView, PlayerHandView, Card, Suit, Rank, PlayerHand,
} from "./blackjack.types";
// ── 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)];
}
/** 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 still needs to act (skip blackjack/bust/stood). */
function findNextActiveIndex(state: BlackjackState, afterIndex: number): number {
for (let i = afterIndex + 1; i < state.turnOrder.length; i++) {
const hand = state.hands[state.turnOrder[i]!];
if (hand && hand.status === "playing") return i;
}
return -1; // no more active players
}
/** Resolve all hands against the dealer, returning updated hands. */
function resolveAllHands(
hands: Record<string, PlayerHand>,
dealerHand: Card[],
): Record<string, PlayerHand> {
const dealerVal = handValue(dealerHand);
const dealerBust = isBust(dealerHand);
const resolved: Record<string, PlayerHand> = {};
for (const [id, hand] of Object.entries(hands)) {
// Already resolved (natural blackjack checked at deal time, bust on hit)
if (hand.result) {
resolved[id] = hand;
continue;
}
const playerVal = handValue(hand.cards);
if (hand.status === "bust") {
resolved[id] = { ...hand, result: "lose", resultReason: "Player busts" };
} else if (hand.status === "blackjack") {
// Natural blackjack vs dealer blackjack
if (isNaturalBlackjack(dealerHand)) {
resolved[id] = { ...hand, result: "push", resultReason: "Both have Blackjack" };
} else {
resolved[id] = { ...hand, result: "blackjack", resultReason: "Blackjack!" };
}
} else if (dealerBust) {
resolved[id] = { ...hand, result: "win", resultReason: "Dealer busts" };
} else if (playerVal > dealerVal) {
resolved[id] = { ...hand, result: "win", resultReason: "Higher hand" };
} else if (playerVal < dealerVal) {
resolved[id] = { ...hand, result: "lose", resultReason: "Dealer has higher hand" };
} else {
resolved[id] = { ...hand, result: "push", resultReason: "Push" };
}
}
return resolved;
}
/** Build the dealer hand for views — hole card hidden during player_turns. */
function dealerVisibleHand(state: BlackjackState): Card[] {
if (state.phase === "resolved") return state.dealerHand;
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,
};
}
/** Transition to dealer turn + resolve, or just resolve if all busted/blackjacked. */
function finishPlayerTurns(state: BlackjackState): BlackjackState {
// Check if any player stood (dealer needs to play)
const anyStood = Object.values(state.hands).some(h => h.status === "stood");
let dealerHand = state.dealerHand;
let deck = state.deck;
if (anyStood) {
const dealer = playDealerHand(dealerHand, deck);
dealerHand = dealer.dealerHand;
deck = dealer.deck;
}
const resolvedHands = resolveAllHands(state.hands, dealerHand);
return {
...state,
deck,
dealerHand,
hands: resolvedHands,
activePlayerIndex: -1,
phase: "resolved",
};
}
// ── 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 {
let deck = shuffleDeck(createDeck());
const turnOrder = [...players];
const hands: Record<string, PlayerHand> = {};
// Deal 2 cards to each player
for (const pid of turnOrder) {
const cards: Card[] = [];
let card: Card;
[card, deck] = drawCard(deck); cards.push(card);
[card, deck] = drawCard(deck); cards.push(card);
hands[pid] = {
cards,
status: isNaturalBlackjack(cards) ? "blackjack" : "playing",
result: null,
resultReason: null,
};
}
// 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 state: BlackjackState = {
deck,
dealerHand,
hands,
turnOrder,
activePlayerIndex: 0,
phase: "player_turns",
};
// Skip to first player that needs to act (skip natural blackjacks)
const firstActive = findNextActiveIndex(state, -1);
state.activePlayerIndex = firstActive;
// If no players need to act (all natural blackjacks), go straight to resolution
if (firstActive === -1) {
return finishPlayerTurns(state);
}
return state;
},
handleAction(state: BlackjackState, action: BlackjackAction, playerId: string): GameResult<BlackjackState> {
if (state.phase === "resolved") return { ok: false, error: "Game is already over" };
const activeId = state.turnOrder[state.activePlayerIndex];
if (playerId !== activeId) return { ok: false, error: "It's not your turn" };
const hand = state.hands[playerId];
if (!hand || hand.status !== "playing") return { ok: false, error: "You cannot act" };
switch (action.type) {
case "hit": {
const [card, remaining] = drawCard(state.deck);
const newCards = [...hand.cards, card];
const bust = isBust(newCards);
const got21 = handValue(newCards) === 21;
const newHand: PlayerHand = {
...hand,
cards: newCards,
status: bust ? "bust" : got21 ? "stood" : "playing",
result: bust ? "lose" : null,
resultReason: bust ? "Player busts" : null,
};
const newHands = { ...state.hands, [playerId]: newHand };
let newState: BlackjackState = { ...state, deck: remaining, hands: newHands };
// Advance turn if bust or auto-stood on 21
if (bust || got21) {
const nextIdx = findNextActiveIndex(newState, state.activePlayerIndex);
if (nextIdx === -1) {
return { ok: true, state: finishPlayerTurns(newState) };
}
newState = { ...newState, activePlayerIndex: nextIdx };
}
return { ok: true, state: newState };
}
case "stand": {
const newHand: PlayerHand = { ...hand, status: "stood" };
const newHands = { ...state.hands, [playerId]: newHand };
let newState: BlackjackState = { ...state, hands: newHands };
const nextIdx = findNextActiveIndex(newState, state.activePlayerIndex);
if (nextIdx === -1) {
return { ok: true, state: finishPlayerTurns(newState) };
}
newState = { ...newState, activePlayerIndex: nextIdx };
return { ok: true, state: newState };
}
default:
return { ok: false, error: "Unknown action type" };
}
},
getPlayerView(state: BlackjackState, playerId: string): BlackjackPlayerView {
const visibleDealer = dealerVisibleHand(state);
const handsView: Record<string, PlayerHandView> = {};
for (const [id, hand] of Object.entries(state.hands)) {
handsView[id] = toHandView(hand);
}
const activeId = state.activePlayerIndex >= 0 ? state.turnOrder[state.activePlayerIndex] ?? null : null;
return {
dealerHand: visibleDealer,
dealerVisibleValue: cardValue(state.dealerHand[0]!.rank),
dealerFullValue: state.phase === "resolved" ? handValue(state.dealerHand) : null,
hands: handsView,
turnOrder: state.turnOrder,
activePlayerId: activeId,
myPlayerId: playerId,
phase: state.phase,
canAct: activeId === playerId && state.phase === "player_turns",
};
},
getSpectatorView(state: BlackjackState): BlackjackSpectatorView {
const visibleDealer = dealerVisibleHand(state);
const handsView: Record<string, PlayerHandView> = {};
for (const [id, hand] of Object.entries(state.hands)) {
handsView[id] = toHandView(hand);
}
const activeId = state.activePlayerIndex >= 0 ? state.turnOrder[state.activePlayerIndex] ?? null : null;
return {
dealerHand: visibleDealer,
dealerVisibleValue: cardValue(state.dealerHand[0]!.rank),
dealerFullValue: state.phase === "resolved" ? handValue(state.dealerHand) : null,
hands: handsView,
turnOrder: state.turnOrder,
activePlayerId: activeId,
phase: state.phase,
};
},
isGameOver(state: BlackjackState): GameOverResult | null {
if (state.phase !== "resolved") return null;
const payouts: Record<string, number> = {};
for (const [id, hand] of Object.entries(state.hands)) {
switch (hand.result) {
case "blackjack":
payouts[id] = 2.5; // 3:2 payout
break;
case "win":
payouts[id] = 2; // 1:1 payout
break;
case "push":
payouts[id] = 1; // refund
break;
// "lose" / null → no payout
}
}
// Find a "winner" for the room summary — pick any winning player, or null
const winner = Object.entries(state.hands).find(
([, h]) => h.result === "blackjack" || h.result === "win"
)?.[0] ?? null;
const wins = Object.values(state.hands).filter(h => h.result === "win" || h.result === "blackjack").length;
const losses = Object.values(state.hands).filter(h => h.result === "lose").length;
const pushes = Object.values(state.hands).filter(h => h.result === "push").length;
const parts: string[] = [];
if (wins > 0) parts.push(`${wins} win${wins > 1 ? "s" : ""}`);
if (losses > 0) parts.push(`${losses} loss${losses > 1 ? "es" : ""}`);
if (pushes > 0) parts.push(`${pushes} push${pushes > 1 ? "es" : ""}`);
return {
winner,
reason: parts.join(", ") || "Game over",
payouts,
};
},
onPlayerDisconnect(state: BlackjackState, playerId: string): BlackjackState {
if (state.phase === "resolved") return state;
const hand = state.hands[playerId];
if (!hand || hand.status !== "playing") return state;
// Mark disconnected player as bust
const newHands = {
...state.hands,
[playerId]: { ...hand, status: "bust" as const, result: "lose" as const, resultReason: "Disconnected" },
};
let newState: BlackjackState = { ...state, hands: newHands };
// Check if the disconnected player was the active player
const activeId = state.turnOrder[state.activePlayerIndex];
if (activeId === playerId) {
const nextIdx = findNextActiveIndex(newState, state.activePlayerIndex);
if (nextIdx === -1) {
return finishPlayerTurns(newState);
}
newState = { ...newState, activePlayerIndex: nextIdx };
}
// Check if all players are now done
const anyPlaying = Object.values(newState.hands).some(h => h.status === "playing");
if (!anyPlaying && newState.phase === "player_turns") {
return finishPlayerTurns(newState);
}
return newState;
},
};