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>
360 lines
12 KiB
TypeScript
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) };
|
|
}
|