Files
aurorabot/shared/games/blackjack/blackjack.plugin.ts
syntaxbullet 25a0bd3431
Some checks failed
Deploy to Production / test (push) Failing after 29s
Sign panel sessions and isolate test runs
- Replace in-memory auth sessions with signed cookies and signed OAuth state
- Add auth route coverage and update panel/web server wiring
- Switch test script to per-file Bun processes and clean up type checks
2026-04-09 21:44:05 +02:00

845 lines
28 KiB
TypeScript

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<string, PlayerSeat> = {};
for (const [id, seat] of Object.entries(state.seats)) {
// First resolve the hands
const resolvedHands = seat.hands.map(h => resolveHand(h, dealerHand));
// Calculate round payouts as multipliers
const roundPayout = calculateRoundPayouts({ [id]: { ...seat, hands: resolvedHands } });
const multiplier = roundPayout[id] ?? 0;
// Calculate total bet amount for this player
const roundBetTotal = seat.hands.reduce((sum, h) => sum + (h.bet * state.betAmount), 0);
// Calculate actual payout amount and net profit
const roundPayoutAmount = Math.round(multiplier * state.betAmount);
const roundNetPnl = roundBetTotal > 0 ? roundPayoutAmount - roundBetTotal : 0;
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<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
}
}
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<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,
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<BlackjackState, BlackjackAction> = {
slug: "blackjack",
name: "Blackjack",
minPlayers: 1,
maxPlayers: 6,
manualStart: true,
createInitialState(players: string[], options?: Record<string, unknown>): BlackjackState {
const betAmount = typeof options?.betAmount === 'number' ? options.betAmount : 0;
const seats: Record<string, PlayerSeat> = {};
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<BlackjackState> {
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<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 isMyTurn = activeId === playerId && state.phase === "player_turns";
const mySeat = state.seats[playerId];
const myActiveHand = mySeat && mySeat.activeHandIndex >= 0
? mySeat.hands[mySeat.activeHandIndex] ?? null
: 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 && mySeat !== undefined && 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<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 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<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) {
// 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<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,
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;
}