Some checks failed
Deploy to Production / test (push) Failing after 39s
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>
396 lines
14 KiB
TypeScript
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;
|
|
},
|
|
};
|