feat(games): refactor blackjack for continuous play, split/double, and table UI
Some checks failed
Deploy to Production / test (push) Failing after 32s
Some checks failed
Deploy to Production / test (push) Failing after 32s
Transform blackjack from single-round to continuous-play table sessions with round lifecycle (betting → playing → resolved → betting), split/double down actions, per-hand bet tracking, leave/join table mid-session, and a responsive felt-style table UI with arc-positioned player seats. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,15 @@
|
||||
import type { GamePlugin, GameResult, GameOverResult } from "../types";
|
||||
import type {
|
||||
BlackjackState, BlackjackAction, BlackjackPlayerView,
|
||||
BlackjackSpectatorView, PlayerHandView, Card, Suit, Rank, PlayerHand,
|
||||
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"];
|
||||
@@ -61,6 +67,19 @@ 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];
|
||||
@@ -73,59 +92,154 @@ function playDealerHand(dealerHand: Card[], deck: Card[]): { dealerHand: Card[];
|
||||
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 {
|
||||
/** 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 hand = state.hands[state.turnOrder[i]!];
|
||||
if (hand && hand.status === "playing") return 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 -1; // no more active players
|
||||
return { playerIndex: -1, handIndex: -1 };
|
||||
}
|
||||
|
||||
/** Resolve all hands against the dealer, returning updated hands. */
|
||||
function resolveAllHands(
|
||||
hands: Record<string, PlayerHand>,
|
||||
dealerHand: Card[],
|
||||
): Record<string, PlayerHand> {
|
||||
/** 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 resolved: Record<string, PlayerHand> = {};
|
||||
const playerVal = handValue(hand.cards);
|
||||
|
||||
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;
|
||||
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!" };
|
||||
}
|
||||
|
||||
const playerVal = handValue(hand.cards);
|
||||
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" };
|
||||
}
|
||||
|
||||
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!" };
|
||||
/** 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<string, PlayerSeat> = {};
|
||||
for (const [id, seat] of Object.entries(state.seats)) {
|
||||
resolvedSeats[id] = {
|
||||
...seat,
|
||||
activeHandIndex: -1,
|
||||
hands: seat.hands.map(h => resolveHand(h, dealerHand)),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
deck,
|
||||
dealerHand,
|
||||
seats: resolvedSeats,
|
||||
activePlayerIndex: -1,
|
||||
phase: "resolved",
|
||||
};
|
||||
}
|
||||
|
||||
/** Calculate round payouts as multipliers of betAmount. */
|
||||
function calculateRoundPayouts(seats: Record<string, PlayerSeat>): Record<string, number> {
|
||||
const payouts: Record<string, number> = {};
|
||||
|
||||
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
|
||||
}
|
||||
} 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" };
|
||||
}
|
||||
if (playerPayout > 0) {
|
||||
payouts[playerId] = playerPayout;
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
return payouts;
|
||||
}
|
||||
|
||||
/** Build the dealer hand for views — hole card hidden during player_turns. */
|
||||
/** 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 }];
|
||||
}
|
||||
|
||||
@@ -137,33 +251,92 @@ function toHandView(hand: PlayerHand): PlayerHandView {
|
||||
status: hand.status,
|
||||
result: hand.result,
|
||||
resultReason: hand.resultReason,
|
||||
bet: hand.bet,
|
||||
fromSplit: hand.fromSplit,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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");
|
||||
/** Convert internal PlayerSeat to a view. */
|
||||
function toSeatView(seat: PlayerSeat): PlayerSeatView {
|
||||
return {
|
||||
hands: seat.hands.map(toHandView),
|
||||
activeHandIndex: seat.activeHandIndex,
|
||||
hasBet: seat.hasBet,
|
||||
};
|
||||
}
|
||||
|
||||
let dealerHand = state.dealerHand;
|
||||
let deck = state.deck;
|
||||
/** 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];
|
||||
|
||||
if (anyStood) {
|
||||
const dealer = playDealerHand(dealerHand, deck);
|
||||
dealerHand = dealer.dealerHand;
|
||||
deck = dealer.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,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedHands = resolveAllHands(state.hands, dealerHand);
|
||||
// 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);
|
||||
|
||||
return {
|
||||
let newState: BlackjackState = {
|
||||
...state,
|
||||
deck,
|
||||
dealerHand,
|
||||
hands: resolvedHands,
|
||||
activePlayerIndex: -1,
|
||||
phase: "resolved",
|
||||
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 ──
|
||||
@@ -176,104 +349,55 @@ export const blackjackPlugin: GamePlugin<BlackjackState, BlackjackAction> = {
|
||||
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,
|
||||
const seats: Record<string, PlayerSeat> = {};
|
||||
for (const pid of players) {
|
||||
seats[pid] = {
|
||||
hands: [],
|
||||
activeHandIndex: -1,
|
||||
hasBet: false,
|
||||
};
|
||||
}
|
||||
|
||||
// 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",
|
||||
return {
|
||||
deck: shuffleDeck(createDeck()),
|
||||
dealerHand: [],
|
||||
seats,
|
||||
turnOrder: [...players],
|
||||
activePlayerIndex: -1,
|
||||
phase: "betting",
|
||||
roundNumber: 1,
|
||||
};
|
||||
},
|
||||
|
||||
// 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);
|
||||
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;
|
||||
}
|
||||
},
|
||||
|
||||
return state;
|
||||
isSpectatorAction(action: BlackjackAction): boolean {
|
||||
return action.type === "sit_down";
|
||||
},
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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" };
|
||||
}
|
||||
@@ -281,115 +405,418 @@ export const blackjackPlugin: GamePlugin<BlackjackState, BlackjackAction> = {
|
||||
|
||||
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 seatsView: Record<string, PlayerSeatView> = {};
|
||||
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 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: cardValue(state.dealerHand[0]!.rank),
|
||||
dealerVisibleValue: state.dealerHand.length > 0 ? cardValue(state.dealerHand[0]!.rank) : 0,
|
||||
dealerFullValue: state.phase === "resolved" ? handValue(state.dealerHand) : null,
|
||||
hands: handsView,
|
||||
seats: seatsView,
|
||||
turnOrder: state.turnOrder,
|
||||
activePlayerId: activeId,
|
||||
activeHandIndex: activeHandIdx,
|
||||
myPlayerId: playerId,
|
||||
phase: state.phase,
|
||||
canAct: activeId === playerId && state.phase === "player_turns",
|
||||
canAct: isMyTurn,
|
||||
canSplit: isMyTurn && myActiveHand !== null && canSplitHand(myActiveHand, mySeat!.hands.length),
|
||||
canDoubleDown: isMyTurn && myActiveHand !== null && canDoubleHand(myActiveHand),
|
||||
roundNumber: state.roundNumber,
|
||||
};
|
||||
},
|
||||
|
||||
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 seatsView: Record<string, PlayerSeatView> = {};
|
||||
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 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: cardValue(state.dealerHand[0]!.rank),
|
||||
dealerVisibleValue: state.dealerHand.length > 0 ? cardValue(state.dealerHand[0]!.rank) : 0,
|
||||
dealerFullValue: state.phase === "resolved" ? handValue(state.dealerHand) : null,
|
||||
hands: handsView,
|
||||
seats: seatsView,
|
||||
turnOrder: state.turnOrder,
|
||||
activePlayerId: activeId,
|
||||
activeHandIndex: activeHandIdx,
|
||||
phase: state.phase,
|
||||
roundNumber: state.roundNumber,
|
||||
};
|
||||
},
|
||||
|
||||
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
|
||||
}
|
||||
if (state.turnOrder.length === 0) {
|
||||
return { winner: null, reason: "All players left the table", payouts: {} };
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
return null;
|
||||
},
|
||||
|
||||
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;
|
||||
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) {
|
||||
resetSeats[pid] = {
|
||||
hands: [],
|
||||
activeHandIndex: -1,
|
||||
hasBet: false,
|
||||
};
|
||||
}
|
||||
|
||||
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<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, roundPayouts: calculateRoundPayouts(newState.seats) };
|
||||
}
|
||||
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, roundPayouts: calculateRoundPayouts(newState.seats) };
|
||||
}
|
||||
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, roundPayouts: calculateRoundPayouts(newState.seats) };
|
||||
}
|
||||
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, roundPayouts: calculateRoundPayouts(newState.seats) };
|
||||
}
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
@@ -6,13 +6,27 @@ export interface Card {
|
||||
rank: Rank;
|
||||
}
|
||||
|
||||
// ── Per-player hand state ──
|
||||
// ── Per-hand state ──
|
||||
|
||||
export interface PlayerHand {
|
||||
cards: Card[];
|
||||
status: "playing" | "stood" | "bust" | "blackjack";
|
||||
result: "win" | "blackjack" | "push" | "lose" | null;
|
||||
resultReason: string | null;
|
||||
/** Bet multiplier of room betAmount (1 = normal, 2 = doubled). */
|
||||
bet: number;
|
||||
/** Whether this hand was created via a split. */
|
||||
fromSplit: boolean;
|
||||
}
|
||||
|
||||
// ── Per-player seat ──
|
||||
|
||||
export interface PlayerSeat {
|
||||
hands: PlayerHand[];
|
||||
/** Index of the hand currently being played, -1 when not active. */
|
||||
activeHandIndex: number;
|
||||
/** Whether the player has placed their bet for the current round. */
|
||||
hasBet: boolean;
|
||||
}
|
||||
|
||||
// ── Game state ──
|
||||
@@ -20,17 +34,23 @@ export interface PlayerHand {
|
||||
export interface BlackjackState {
|
||||
deck: Card[];
|
||||
dealerHand: Card[];
|
||||
hands: Record<string, PlayerHand>;
|
||||
seats: Record<string, PlayerSeat>;
|
||||
turnOrder: string[];
|
||||
activePlayerIndex: number; // index into turnOrder, -1 when no active player
|
||||
phase: "player_turns" | "resolved";
|
||||
activePlayerIndex: number;
|
||||
phase: "betting" | "player_turns" | "resolved";
|
||||
roundNumber: number;
|
||||
}
|
||||
|
||||
// ── Actions ──
|
||||
|
||||
export type BlackjackAction =
|
||||
| { type: "hit" }
|
||||
| { type: "stand" };
|
||||
| { type: "stand" }
|
||||
| { type: "split" }
|
||||
| { type: "double_down" }
|
||||
| { type: "place_bet" }
|
||||
| { type: "leave_table" }
|
||||
| { type: "sit_down" };
|
||||
|
||||
// ── Views ──
|
||||
|
||||
@@ -40,26 +60,40 @@ export interface PlayerHandView {
|
||||
status: "playing" | "stood" | "bust" | "blackjack";
|
||||
result: "win" | "blackjack" | "push" | "lose" | null;
|
||||
resultReason: string | null;
|
||||
bet: number;
|
||||
fromSplit: boolean;
|
||||
}
|
||||
|
||||
export interface PlayerSeatView {
|
||||
hands: PlayerHandView[];
|
||||
activeHandIndex: number;
|
||||
hasBet: boolean;
|
||||
}
|
||||
|
||||
export interface BlackjackPlayerView {
|
||||
dealerHand: Card[];
|
||||
dealerVisibleValue: number;
|
||||
dealerFullValue: number | null;
|
||||
hands: Record<string, PlayerHandView>;
|
||||
seats: Record<string, PlayerSeatView>;
|
||||
turnOrder: string[];
|
||||
activePlayerId: string | null;
|
||||
activeHandIndex: number;
|
||||
myPlayerId: string;
|
||||
phase: "player_turns" | "resolved";
|
||||
phase: "betting" | "player_turns" | "resolved";
|
||||
canAct: boolean;
|
||||
canSplit: boolean;
|
||||
canDoubleDown: boolean;
|
||||
roundNumber: number;
|
||||
}
|
||||
|
||||
export interface BlackjackSpectatorView {
|
||||
dealerHand: Card[];
|
||||
dealerVisibleValue: number;
|
||||
dealerFullValue: number | null;
|
||||
hands: Record<string, PlayerHandView>;
|
||||
seats: Record<string, PlayerSeatView>;
|
||||
turnOrder: string[];
|
||||
activePlayerId: string | null;
|
||||
phase: "player_turns" | "resolved";
|
||||
activeHandIndex: number;
|
||||
phase: "betting" | "player_turns" | "resolved";
|
||||
roundNumber: number;
|
||||
}
|
||||
|
||||
@@ -13,10 +13,16 @@ export interface GamePlugin<TState = unknown, TAction = unknown> {
|
||||
|
||||
isGameOver?(state: TState): GameOverResult | null;
|
||||
onPlayerDisconnect?(state: TState, playerId: string): TState;
|
||||
|
||||
/** Cost of an action in betAmount units (0 = free). Checked before handleAction. */
|
||||
getActionCost?(state: TState, action: TAction, playerId: string): number;
|
||||
|
||||
/** Whether a spectator can send this action (e.g., sit_down to join mid-session). */
|
||||
isSpectatorAction?(action: TAction): boolean;
|
||||
}
|
||||
|
||||
export type GameResult<TState> =
|
||||
| { ok: true; state: TState }
|
||||
| { ok: true; state: TState; roundPayouts?: Record<string, number> }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type GameOverResult = {
|
||||
|
||||
Reference in New Issue
Block a user