1
0
Fork 0
polaris-task-force/src/lib/minigames/blackjack/actions.ts
Z8MB1E 474aff734d feat(blackjack): add blackjack minigame with realtime table wiring
Engine, felt, controls, sounds, card skins, and dev popups; SSE events in the realtime hook, nav and command palette entries, stats migration, and the six blackjack test suites.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-14 15:38:05 -04:00

360 lines
12 KiB
TypeScript

/**
* Player action application for blackjack rounds.
* Pure: validates legality, produces the next state, and auto-advances the
* round whenever the action unblocks progression (next bettor, next hand,
* dealer play, or settle).
*/
import { MAX_BET } from "./constants";
import { advanceState, createEmptyHand, drawCards } from "./engine";
import { canDoubleHand, canSplitHand, handValue, insuranceMaxFor } from "./rules";
import type {
BlackjackActionKind,
BlackjackHand,
BlackjackPlayerState,
BlackjackSeatId,
BlackjackTableState,
} from "./types";
export type BlackjackActionResult =
| { readonly success: true; readonly state: BlackjackTableState }
| { readonly success: false; readonly error: string };
function failure(error: string): BlackjackActionResult {
return { success: false, error };
}
function playerAt(
state: BlackjackTableState,
seatId: BlackjackSeatId,
): BlackjackPlayerState | null {
return state.players[seatId] ?? null;
}
function handAt(
player: BlackjackPlayerState,
handIndex: number,
): BlackjackHand | null {
return player.hands[handIndex] ?? null;
}
function placeBet(
state: BlackjackTableState,
seatId: BlackjackSeatId,
amount: number | undefined,
): BlackjackActionResult {
const player = playerAt(state, seatId);
if (!player) return failure("No player occupies that seat.");
if (player.hasBet) return failure("You have already bet this round.");
if (player.sittingOut) return failure("You sat out this round.");
if (player.bankrupt) return failure("Your bankroll cannot cover the table minimum.");
if (amount === undefined) return failure("A bet requires an amount.");
if (!Number.isInteger(amount)) return failure("Bets must be whole chips.");
if (amount < state.config.minBet) {
return failure(`The minimum bet is ${state.config.minBet}.`);
}
if (amount > state.config.maxBet) {
return failure(`The maximum bet is ${state.config.maxBet}.`);
}
if (amount > player.bankroll) {
return failure("The bet exceeds your bankroll.");
}
const updated: BlackjackPlayerState = {
...player,
bankroll: player.bankroll - amount,
hands: [createEmptyHand(amount)],
hasBet: true,
};
const next: BlackjackTableState = {
...state,
players: state.players.map((candidate) =>
candidate.seatId === seatId ? updated : candidate,
),
};
return { success: true, state: advanceState(next) };
}
function takeInsurance(
state: BlackjackTableState,
seatId: BlackjackSeatId,
amount: number | undefined,
): BlackjackActionResult {
const player = playerAt(state, seatId);
if (!player) return failure("No player occupies that seat.");
if (!player.hasBet) return failure("Insurance requires a bet this round.");
if (player.insuranceAnswered) {
return failure("You have already answered the insurance offer.");
}
const cap = Math.min(insuranceMaxFor(player.hands[0]?.bet ?? 0), player.bankroll);
if (cap <= 0) return failure("Insurance is not available right now.");
if (amount === undefined) return failure("Insurance requires an amount.");
if (!Number.isInteger(amount) || amount < 1) {
return failure("Insurance must be at least one whole chip.");
}
if (amount > cap) return failure(`Insurance is capped at ${cap}.`);
const updated: BlackjackPlayerState = {
...player,
bankroll: player.bankroll - amount,
insuranceBet: amount,
insuranceAnswered: true,
};
const next: BlackjackTableState = {
...state,
players: state.players.map((candidate) =>
candidate.seatId === seatId ? updated : candidate,
),
};
return { success: true, state: advanceState(next) };
}
function declineInsurance(
state: BlackjackTableState,
seatId: BlackjackSeatId,
): BlackjackActionResult {
const player = playerAt(state, seatId);
if (!player) return failure("No player occupies that seat.");
if (!player.hasBet) return failure("Insurance requires a bet this round.");
if (player.insuranceAnswered) {
return failure("You have already answered the insurance offer.");
}
const updated: BlackjackPlayerState = { ...player, insuranceAnswered: true };
const next: BlackjackTableState = {
...state,
players: state.players.map((candidate) =>
candidate.seatId === seatId ? updated : candidate,
),
};
return { success: true, state: advanceState(next) };
}
/** Draw one card onto a hand and resolve 21/bust (or forced stand) immediately. */
function drawToHand(
state: BlackjackTableState,
player: BlackjackPlayerState,
handIndex: number,
hand: BlackjackHand,
autoStand: boolean,
): { state: BlackjackTableState; player: BlackjackPlayerState; hand: BlackjackHand } {
const { cards, shoe } = drawCards(state, 1);
const card = cards[0];
const withCard: BlackjackHand = card ? { ...hand, cards: [...hand.cards, card] } : hand;
const total = handValue(withCard.cards).total;
const status =
total > 21
? ("bust" as const)
: total === 21 || autoStand
? ("stood" as const)
: withCard.status;
const resolved: BlackjackHand = { ...withCard, status };
const updatedPlayer: BlackjackPlayerState = {
...player,
hands: player.hands.map((candidate, index) => (index === handIndex ? resolved : candidate)),
};
const updatedState: BlackjackTableState = {
...state,
shoe,
players: state.players.map((candidate) =>
candidate.seatId === player.seatId ? updatedPlayer : candidate,
),
};
return { state: updatedState, player: updatedPlayer, hand: resolved };
}
function playHand(
state: BlackjackTableState,
seatId: BlackjackSeatId,
action: "hit" | "stand" | "double" | "split",
): BlackjackActionResult {
const player = playerAt(state, seatId);
if (!player) return failure("No player occupies that seat.");
const handIndex = state.activeHandIndex;
const hand = handAt(player, handIndex);
if (!hand || hand.status !== "active") {
return failure("That hand is not awaiting an action.");
}
if (action === "stand") {
const stood: BlackjackHand = { ...hand, status: "stood" };
const updatedPlayer: BlackjackPlayerState = {
...player,
hands: player.hands.map((candidate, index) => (index === handIndex ? stood : candidate)),
};
const next: BlackjackTableState = {
...state,
players: state.players.map((candidate) =>
candidate.seatId === seatId ? updatedPlayer : candidate,
),
};
return { success: true, state: advanceState(next) };
}
if (action === "hit") {
const drawn = drawToHand(state, player, handIndex, hand, false);
return { success: true, state: advanceState(drawn.state) };
}
if (action === "double") {
if (!canDoubleHand(hand, player.bankroll)) {
if (hand.cards.length !== 2) {
return failure("Doubling is only allowed on the first two cards.");
}
return failure("Doubling requires your bankroll to cover the extra stake.");
}
const extraStake = hand.bet;
const reduced: BlackjackPlayerState = { ...player, bankroll: player.bankroll - extraStake };
const doubled: BlackjackHand = { ...hand, bet: hand.bet * 2, doubled: true };
const staged: BlackjackTableState = {
...state,
players: state.players.map((candidate) =>
candidate.seatId === seatId
? {
...reduced,
hands: reduced.hands.map((candidateHand, index) =>
index === handIndex ? doubled : candidateHand,
),
}
: candidate,
),
};
const stagedPlayer = playerAt(staged, seatId);
if (!stagedPlayer) return failure("No player occupies that seat.");
const stagedHand = handAt(stagedPlayer, handIndex);
if (!stagedHand) return failure("That hand is not awaiting an action.");
// Doubling draws exactly one card and the hand then stands (or busts).
const drawn = drawToHand(staged, stagedPlayer, handIndex, stagedHand, true);
return { success: true, state: advanceState(drawn.state) };
}
// Split: matching ranks already verified by canSplitHand.
if (!canSplitHand(player.hands, hand, player.bankroll)) {
if (hand.cards.length !== 2 || hand.cards[0].rank !== hand.cards[1].rank) {
return failure("Splitting requires a pair of matching ranks.");
}
if (hand.splitAces) {
return failure("Split aces cannot be split again.");
}
return failure("Splitting requires your bankroll to cover the second stake.");
}
const secondStake = hand.bet;
const reduced: BlackjackPlayerState = { ...player, bankroll: player.bankroll - secondStake };
const { cards, shoe } = drawCards(state, 2);
const [cardA, cardB] = cards;
if (!cardA || !cardB) {
return failure("The shoe is out of cards; try again in a moment.");
}
const splitAces = hand.cards[0].rank === 14;
const autoStatus = splitAces ? ("stood" as const) : ("active" as const);
const handA: BlackjackHand = {
...createEmptyHand(secondStake),
cards: [hand.cards[0], cardA],
fromSplit: true,
splitAces,
status: handValue([hand.cards[0], cardA]).total === 21 ? ("stood" as const) : autoStatus,
};
const handB: BlackjackHand = {
...createEmptyHand(secondStake),
cards: [hand.cards[1], cardB],
fromSplit: true,
splitAces,
status: handValue([hand.cards[1], cardB]).total === 21 ? ("stood" as const) : autoStatus,
};
const splitHands = [
...player.hands.slice(0, handIndex),
handA,
handB,
...player.hands.slice(handIndex + 1),
];
const updatedPlayer: BlackjackPlayerState = { ...reduced, hands: splitHands };
const next: BlackjackTableState = {
...state,
shoe,
activeHandIndex: handIndex,
players: state.players.map((candidate) =>
candidate.seatId === seatId ? updatedPlayer : candidate,
),
};
return { success: true, state: advanceState(next) };
}
/**
* Apply an action for `seatId`. Bet amounts are whole chips within the table
* limits; insurance is capped at half the main bet. On success the state is
* auto-advanced (next bettor, next hand, dealer play, or settle).
*/
export function applyAction(
state: BlackjackTableState,
seatId: BlackjackSeatId,
action: BlackjackActionKind,
amount?: number,
): BlackjackActionResult {
const player = playerAt(state, seatId);
if (!player) return failure("No player occupies that seat.");
if (player.bankrupt && action !== "decline-insurance") {
return failure("That seat is out of chips.");
}
switch (action) {
case "bet": {
if (state.phase !== "betting") {
return failure("The table is not accepting bets right now.");
}
if (state.toActSeat !== seatId) {
return failure("It is not that seat's turn to bet.");
}
return placeBet(state, seatId, amount);
}
case "insurance":
case "decline-insurance": {
if (state.phase !== "insurance") {
return failure("Insurance is not being offered right now.");
}
if (state.toActSeat !== seatId) {
return failure("It is not that seat's turn to answer the insurance offer.");
}
return action === "insurance"
? takeInsurance(state, seatId, amount)
: declineInsurance(state, seatId);
}
case "hit":
case "stand":
case "double":
case "split": {
if (state.phase !== "player") {
return failure("The table is not accepting hand actions right now.");
}
if (state.toActSeat !== seatId) {
return failure("It is not that seat's turn to act.");
}
return playHand(state, seatId, action);
}
default: {
const exhaustive: never = action;
return failure(`Unknown action: ${String(exhaustive)}`);
}
}
}
/**
* Mark a seat as sitting out the current betting round. Used by the registry
* turn-timer timeout so an idle player does not stall the table.
*/
export function sitOut(
state: BlackjackTableState,
seatId: BlackjackSeatId,
): BlackjackActionResult {
if (state.phase !== "betting") {
return failure("The table is not accepting bets right now.");
}
const player = playerAt(state, seatId);
if (!player) return failure("No player occupies that seat.");
if (player.hasBet) return failure("You have already bet this round.");
if (player.sittingOut) return failure("You are already sitting out this round.");
const updated: BlackjackPlayerState = { ...player, sittingOut: true };
const next: BlackjackTableState = {
...state,
players: state.players.map((candidate) =>
candidate.seatId === seatId ? updated : candidate,
),
};
return { success: true, state: advanceState(next) };
}