1
0
Fork 0
polaris-task-force/tests/int/blackjack-engine.int.spec.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

546 lines
21 KiB
TypeScript

/**
* Pure engine tests for the blackjack rules engine.
*
* These are intentionally database-free: no Payload, no Next.js imports, no
* setup or teardown. Everything runs against the pure modules under
* src/lib/minigames/blackjack with injected, deterministic randomness.
*/
import { describe, expect, it } from "vitest";
import {
BLACKJACK_PAYOUT,
MAX_HANDS,
MAX_PLAYERS,
MIN_BET,
MIN_PLAYERS,
SHOE_DECKS,
STARTING_BANKROLL,
advanceState,
applyAction,
buildPrivateView,
createShuffledShoe,
createShoe,
createTableState,
dealerShouldHit,
getLegalActions,
handValue,
insuranceMaxFor,
isNaturalBlackjack,
isTenValue,
mulberry32,
needsShuffle,
settleRound,
shoePenetrationFloor,
sitOut,
startNextRound,
type BlackjackTableState,
type Card,
type Rank,
type Suit,
} from "@/lib/minigames/blackjack";
const c = (rank: Rank, suit: Suit): Card => ({ rank, suit });
/** Craft a shoe whose first cards come out in exactly the given order. */
function shoeOf(...cards: Card[]): Card[] {
return [...cards, ...createShoe().slice(0, 40)];
}
function makeTable(bankrolls?: number[], seed = 1): BlackjackTableState {
return createTableState(
(bankrolls ?? [STARTING_BANKROLL]).map((bankroll, index) => ({
name: `P${index + 1}`,
bankroll,
})),
{},
mulberry32(seed),
);
}
function soloTable(shoe: Card[], bankroll = STARTING_BANKROLL): BlackjackTableState {
const base = createTableState([{ name: "Solo", bankroll }], {}, mulberry32(1));
return { ...base, shoe };
}
function act(
state: BlackjackTableState,
seatId: number,
action: Parameters<typeof applyAction>[2],
amount?: number,
): BlackjackTableState {
const result = applyAction(state, seatId, action, amount);
if (!result.success) throw new Error(`Unexpected action failure: ${result.error}`);
return result.state;
}
function expectFailure(
state: BlackjackTableState,
seatId: number,
action: Parameters<typeof applyAction>[2],
amount?: number,
messagePart?: string,
): void {
const result = applyAction(state, seatId, action, amount);
expect(result.success).toBe(false);
if (!result.success && messagePart) {
expect(result.error).toContain(messagePart);
}
}
/** Bet (defaults to the table minimum) and return the dealt-to state. */
function bet(state: BlackjackTableState, seat = state.toActSeat ?? 0, amount = MIN_BET) {
return act(state, seat, "bet", amount);
}
describe("shoe composition and reshuffle", () => {
it("builds a 6-deck shoe with 24 of every rank", () => {
const shoe = createShoe();
expect(shoe).toHaveLength(SHOE_DECKS * 52);
for (let rank = 2; rank <= 14; rank += 1) {
const count = shoe.filter((card) => card.rank === rank).length;
expect(count).toBe(24);
}
});
it("shuffles deterministically from a seed", () => {
const a = createShuffledShoe(7, 1);
const b = createShuffledShoe(7, 1);
const other = createShuffledShoe(8, 1);
expect(a).toEqual(b);
expect(a).not.toEqual(other);
});
it("flags a reshuffle once the shoe drops below the penetration floor", () => {
const floor = shoePenetrationFloor();
expect(floor).toBe(Math.floor(SHOE_DECKS * 52 * 0.25));
expect(needsShuffle(createShoe().slice(0, floor - 1))).toBe(true);
expect(needsShuffle(createShoe().slice(0, floor))).toBe(false);
expect(needsShuffle(createShoe())).toBe(false);
});
});
describe("hand values", () => {
it("counts aces as 11 while the total stays at or under 21", () => {
expect(handValue([c(14, "s"), c(6, "h")])).toEqual({ total: 17, soft: true });
expect(handValue([c(14, "s"), c(14, "h")])).toEqual({ total: 12, soft: true });
});
it("degrades aces to 1 when 11 would bust", () => {
expect(handValue([c(14, "s"), c(10, "h"), c(6, "d")])).toEqual({ total: 17, soft: false });
// Three aces: two degrade to 1, one still counts as 11 (a soft 13).
expect(handValue([c(14, "s"), c(14, "h"), c(14, "d")])).toEqual({ total: 13, soft: true });
});
it("reports bust totals over 21", () => {
expect(handValue([c(10, "s"), c(10, "h"), c(5, "d")]).total).toBe(25);
});
it("detects natural blackjacks (two cards totalling 21 only)", () => {
expect(isNaturalBlackjack([c(14, "s"), c(13, "h")])).toBe(true);
expect(isNaturalBlackjack([c(10, "s"), c(9, "h"), c(2, "d")])).toBe(false);
expect(isNaturalBlackjack([c(10, "s"), c(9, "h")])).toBe(false);
});
it("classifies ten-value ranks for the peek rule", () => {
expect(isTenValue(10)).toBe(true);
expect(isTenValue(13)).toBe(true);
expect(isTenValue(14)).toBe(false);
expect(isTenValue(9)).toBe(false);
});
it("stands the dealer on any 17 including soft 17 (S17)", () => {
expect(dealerShouldHit([c(10, "s"), c(7, "h")])).toBe(false);
expect(dealerShouldHit([c(14, "s"), c(7, "h")])).toBe(false);
expect(dealerShouldHit([c(14, "s"), c(3, "h"), c(3, "d")])).toBe(false);
expect(dealerShouldHit([c(10, "s"), c(6, "h")])).toBe(true);
expect(dealerShouldHit([c(14, "s"), c(5, "h")])).toBe(true);
});
});
describe("betting phase", () => {
it("deals as soon as the only player bets", () => {
const state = bet(soloTable(shoeOf(c(10, "s"), c(6, "h"), c(9, "s"), c(7, "d"))));
expect(state.phase).toBe("player");
expect(state.players[0]?.hands[0]?.cards).toHaveLength(2);
expect(state.dealer.cards).toHaveLength(2);
});
it("walks the betting turn across multiple players before dealing", () => {
let state = makeTable([STARTING_BANKROLL, STARTING_BANKROLL]);
expect(state.phase).toBe("betting");
expect(state.toActSeat).toBe(0);
state = bet(state);
expect(state.toActSeat).toBe(1);
expect(state.players[0]?.hasBet).toBe(true);
expect(state.players[1]?.hasBet).toBe(false);
state = bet(state);
expect(state.phase).not.toBe("betting");
expect(state.players[0]?.hands[0]?.cards).toHaveLength(2);
expect(state.players[1]?.hands[0]?.cards).toHaveLength(2);
});
it("rejects bets outside the limits, non-integers, and repeats", () => {
const state = makeTable([50, STARTING_BANKROLL]);
expectFailure(state, 0, "bet", MIN_BET - 1, "minimum bet");
expectFailure(state, 0, "bet", 501, "maximum bet");
expectFailure(state, 0, "bet", 10.5, "whole chips");
expectFailure(state, 0, "bet", 100, "bankroll");
const afterBet = bet(state);
expectFailure(afterBet, 0, "bet", MIN_BET, "turn");
});
it("sits a player out and continues the round without them", () => {
const state = makeTable([STARTING_BANKROLL, STARTING_BANKROLL]);
const sitResult = sitOut(state, 0);
if (!sitResult.success) throw new Error(sitResult.error);
const afterSit = sitResult.state;
expect(afterSit.players[0]?.sittingOut).toBe(true);
expect(afterSit.toActSeat).toBe(1);
const dealt = bet(afterSit);
expect(dealt.players[0]?.hands).toHaveLength(0);
expect(dealt.players[0]?.bankroll).toBe(STARTING_BANKROLL);
expect(dealt.players[1]?.hands[0]?.cards).toHaveLength(2);
expect(dealt.phase).toBe("player");
});
});
describe("player actions", () => {
it("stands and settles against the dealer", () => {
// Player 19 stands; dealer 6+10 = 16 draws the crafted 4 and lands on 20.
const state = bet(soloTable(shoeOf(c(10, "s"), c(6, "h"), c(9, "s"), c(10, "d"), c(4, "s"))));
expect(state.phase).toBe("player");
const settled = act(state, 0, "stand");
expect(settled.phase).toBe("round-complete");
const hand = settled.players[0]?.hands[0];
expect(hand?.outcome).toBe("lose");
expect(hand?.netChange).toBe(-MIN_BET);
expect(settled.players[0]?.bankroll).toBe(STARTING_BANKROLL - MIN_BET);
expect(settled.dealer.holeRevealed).toBe(true);
expect(settled.roundResult?.dealerTotal).toBe(20);
});
it("hits to a made hand and auto-stands on 21", () => {
const state = bet(soloTable(shoeOf(c(5, "s"), c(10, "h"), c(6, "s"), c(7, "d"), c(10, "d"))));
// 5 + 6 = 11; hit the crafted 10 -> 21 auto-stands.
const hit = act(state, 0, "hit");
expect(hit.players[0]?.hands[0]?.status).toBe("stood");
expect(handValue(hit.players[0]?.hands[0]?.cards ?? []).total).toBe(21);
});
it("busts on a hit and loses the stake", () => {
const state = bet(soloTable(shoeOf(c(10, "s"), c(10, "h"), c(9, "s"), c(7, "d"), c(5, "s"))));
const hit = act(state, 0, "hit");
expect(hit.players[0]?.hands[0]?.status).toBe("bust");
expect(hit.players[0]?.bankroll).toBe(STARTING_BANKROLL - MIN_BET);
expect(hit.phase).toBe("round-complete");
});
it("rejects acting out of turn", () => {
const state = makeTable([STARTING_BANKROLL, STARTING_BANKROLL]);
const dealt = bet(bet(state));
expectFailure(dealt, 1, "hit", undefined, "turn");
expect(getLegalActions(dealt, 0).canHit).toBe(true);
});
});
describe("double down", () => {
it("doubles the stake, draws one card, and auto-stands", () => {
const state = bet(soloTable(shoeOf(c(5, "s"), c(10, "h"), c(6, "s"), c(7, "d"), c(5, "s"))));
const doubled = act(state, 0, "double");
const hand = doubled.players[0]?.hands[0];
expect(hand?.bet).toBe(MIN_BET * 2);
expect(hand?.doubled).toBe(true);
expect(hand?.cards).toHaveLength(3);
expect(hand?.status).toBe("stood");
expect(doubled.players[0]?.bankroll).toBe(STARTING_BANKROLL - MIN_BET * 2);
});
it("refuses doubles without the bankroll to cover the extra stake", () => {
const poor = bet(
soloTable(shoeOf(c(10, "s"), c(6, "h"), c(9, "s"), c(7, "d")), MIN_BET + 5),
);
expectFailure(poor, 0, "double", undefined, "bankroll");
});
});
describe("splitting pairs", () => {
it("splits a pair into two funded hands and plays each", () => {
const state = bet(
soloTable(shoeOf(c(8, "s"), c(6, "h"), c(8, "h"), c(10, "d"), c(4, "s"), c(5, "s"))),
);
const split = act(state, 0, "split");
const hands = split.players[0]?.hands ?? [];
expect(hands).toHaveLength(2);
expect(hands[0]?.cards).toEqual([c(8, "s"), c(4, "s")]);
expect(hands[1]?.cards).toEqual([c(8, "h"), c(5, "s")]);
expect(split.players[0]?.bankroll).toBe(STARTING_BANKROLL - MIN_BET * 2);
expect(split.toActSeat).toBe(0);
expect(split.activeHandIndex).toBe(0);
// Play hand one to a stand, then hand two becomes active.
const stoodA = act(split, 0, "stand");
expect(stoodA.activeHandIndex).toBe(1);
const stoodB = act(stoodA, 0, "stand");
expect(stoodB.phase).toBe("round-complete");
expect(stoodB.roundResult?.hands).toHaveLength(2);
});
it("refuses non-matching ranks and overdrafted splits", () => {
const notPair = bet(soloTable(shoeOf(c(8, "s"), c(6, "h"), c(9, "h"), c(10, "d"))));
expectFailure(notPair, 0, "split", undefined, "matching ranks");
const poor = bet(
soloTable(shoeOf(c(8, "s"), c(6, "h"), c(8, "h"), c(10, "d")), MIN_BET + 5),
);
expectFailure(poor, 0, "split", undefined, "bankroll");
});
it("caps splitting at four hands", () => {
// Every split hand draws another 8 so the active hand keeps being a pair.
let state = bet(
soloTable(
shoeOf(
c(8, "s"), c(6, "h"), c(8, "h"), c(10, "d"),
c(8, "d"), c(3, "s"),
c(8, "c"), c(2, "s"),
c(8, "s"), c(2, "h"),
),
),
);
state = act(state, 0, "split");
expect(state.players[0]?.hands).toHaveLength(2);
state = act(state, 0, "split");
expect(state.players[0]?.hands).toHaveLength(3);
state = act(state, 0, "split");
expect(state.players[0]?.hands).toHaveLength(MAX_HANDS);
expectFailure(state, 0, "split", undefined);
});
it("gives split aces one card each and auto-stands both", () => {
const state = bet(
soloTable(shoeOf(c(14, "s"), c(6, "h"), c(14, "h"), c(10, "d"), c(9, "s"), c(5, "s"))),
);
const split = act(state, 0, "split");
const hands = split.players[0]?.hands ?? [];
expect(hands[0]?.splitAces).toBe(true);
expect(hands[0]?.status).toBe("stood");
expect(hands[1]?.status).toBe("stood");
expect(hands[0]?.cards).toHaveLength(2);
expect(hands[1]?.cards).toHaveLength(2);
expect(split.phase).toBe("round-complete");
});
});
describe("insurance and dealer peek", () => {
it("offers insurance only on an ace upcard and caps it at half the bet", () => {
const state = bet(soloTable(shoeOf(c(10, "s"), c(14, "h"), c(9, "s"), c(9, "d"))));
expect(state.phase).toBe("insurance");
expect(state.toActSeat).toBe(0);
expect(insuranceMaxFor(MIN_BET)).toBe(Math.floor(MIN_BET / 2));
expectFailure(state, 0, "insurance", Math.floor(MIN_BET / 2) + 1, "capped at");
const insured = act(state, 0, "insurance", Math.floor(MIN_BET / 2));
expect(insured.players[0]?.insuranceBet).toBe(Math.floor(MIN_BET / 2));
expect(insured.players[0]?.bankroll).toBe(
STARTING_BANKROLL - MIN_BET - Math.floor(MIN_BET / 2),
);
// The dealer's hole card (9) is no blackjack, so play continues.
expect(insured.phase).toBe("player");
});
it("pays insurance 2:1 when the dealer has blackjack and pushes naturals", () => {
const state = bet(soloTable(shoeOf(c(14, "s"), c(14, "h"), c(13, "s"), c(13, "h"))));
expect(state.phase).toBe("insurance");
// After the insurance answer the peek settles the round immediately:
// the player's natural pushes (stake back) and insurance pays 2:1.
const insured = act(state, 0, "insurance", 5);
expect(insured.phase).toBe("round-complete");
expect(insured.roundResult?.dealerHasBlackjack).toBe(true);
expect(insured.players[0]?.hands[0]?.outcome).toBe("push");
// 1000 - 10 (bet) - 5 (insurance) + 10 (push stake) + 5 + 10 (insurance 2:1)
expect(insured.players[0]?.bankroll).toBe(1010);
});
it("settles immediately on a ten-value upcard peek without insurance", () => {
const state = bet(soloTable(shoeOf(c(10, "s"), c(10, "h"), c(9, "s"), c(14, "d"))));
expect(state.phase).toBe("round-complete");
expect(state.roundResult?.dealerHasBlackjack).toBe(true);
expect(state.players[0]?.hands[0]?.outcome).toBe("lose");
expect(state.players[0]?.bankroll).toBe(STARTING_BANKROLL - MIN_BET);
});
it("declines insurance and continues the round", () => {
const state = bet(soloTable(shoeOf(c(10, "s"), c(14, "h"), c(9, "s"), c(6, "d"))));
expect(state.phase).toBe("insurance");
const declined = act(state, 0, "decline-insurance");
expect(declined.players[0]?.insuranceBet).toBe(0);
expect(declined.phase).toBe("player");
});
});
describe("settlement and payouts", () => {
it("pays a natural blackjack 3:2", () => {
const state = bet(soloTable(shoeOf(c(14, "s"), c(5, "h"), c(13, "s"), c(9, "d"))));
expect(state.phase).toBe("round-complete");
const hand = state.players[0]?.hands[0];
expect(hand?.outcome).toBe("blackjack");
expect(hand?.netChange).toBe(Math.floor(MIN_BET * BLACKJACK_PAYOUT));
expect(state.roundResult?.hands[0]?.payout).toBe(Math.floor(MIN_BET * BLACKJACK_PAYOUT));
expect(state.players[0]?.bankroll).toBe(
STARTING_BANKROLL - MIN_BET + MIN_BET + Math.floor(MIN_BET * BLACKJACK_PAYOUT),
);
expect(state.players[0]?.blackjacksHit).toBe(1);
expect(state.players[0]?.roundsWon).toBe(1);
});
it("pushes equal totals and returns the stake", () => {
// Player stands on 20 (10+K); dealer 10+K also makes 20 (draws nothing).
const state = bet(
soloTable(shoeOf(c(10, "s"), c(10, "h"), c(13, "s"), c(13, "h"), c(2, "s"))),
);
expect(state.phase).toBe("player");
const settled = act(state, 0, "stand");
const hand = settled.players[0]?.hands[0];
expect(hand?.outcome).toBe("push");
expect(hand?.netChange).toBe(0);
expect(settled.players[0]?.bankroll).toBe(STARTING_BANKROLL);
expect(settled.players[0]?.roundsWon).toBe(0);
});
it("pays a regular win 1:1", () => {
// Player 20 stands; dealer 16 draws the crafted 7 and busts.
const state = bet(soloTable(shoeOf(c(10, "s"), c(10, "h"), c(10, "s"), c(6, "h"), c(7, "s"))));
const settled = act(state, 0, "stand");
const hand = settled.players[0]?.hands[0];
expect(hand?.outcome).toBe("win");
expect(hand?.netChange).toBe(MIN_BET);
expect(settled.players[0]?.bankroll).toBe(STARTING_BANKROLL + MIN_BET);
expect(settled.roundResult?.dealerBusted).toBe(true);
});
it("flags a seat bankrupt once the bankroll drops below the table minimum", () => {
// Player 16 loses to a dealer 17; the 15-chip bankroll drops to 5.
const state = bet(soloTable(shoeOf(c(9, "s"), c(10, "h"), c(7, "d"), c(7, "s")), 15));
const settled = act(state, 0, "stand");
expect(settled.players[0]?.bankroll).toBe(5);
expect(settled.players[0]?.bankrupt).toBe(true);
});
it("starts the next round from a settled state and resets per-round data", () => {
const state = bet(soloTable(shoeOf(c(10, "s"), c(6, "h"), c(9, "s"), c(7, "d"), c(4, "s"))));
const settled = act(state, 0, "stand");
const next = startNextRound(settled);
expect(next.roundNumber).toBe(2);
expect(next.phase).toBe("betting");
expect(next.toActSeat).toBe(0);
expect(next.players[0]?.hands).toHaveLength(0);
expect(next.players[0]?.hasBet).toBe(false);
expect(next.roundResult).toBeNull();
expect(next.dealer.cards).toHaveLength(0);
});
it("drops bankrupt and listed seats when the next round starts", () => {
const state = bet(soloTable(shoeOf(c(10, "s"), c(10, "h"), c(9, "s"), c(7, "d")), 15));
const settled = act(state, 0, "stand");
const next = startNextRound(settled, [0]);
expect(next.players).toHaveLength(0);
expect(next.toActSeat).toBeNull();
});
});
describe("views and legal actions", () => {
it("hides the dealer hole card until reveal", () => {
const state = bet(soloTable(shoeOf(c(10, "s"), c(6, "h"), c(9, "s"), c(7, "d"))));
const view = buildPrivateView(state, 0);
expect(view.dealer.cards).toHaveLength(1);
expect(view.dealer.holeHidden).toBe(true);
expect(view.dealer.total).toBeNull();
const settled = act(state, 0, "stand");
expect(buildPrivateView(settled, 0).dealer.holeHidden).toBe(false);
});
it("exposes turn-scoped legal actions", () => {
const betting = makeTable([STARTING_BANKROLL, STARTING_BANKROLL]);
expect(getLegalActions(betting, 0).canBet).toBe(true);
expect(getLegalActions(betting, 0).maxBet).toBe(500);
expect(getLegalActions(betting, 1).canBet).toBe(false);
const state = bet(soloTable(shoeOf(c(8, "s"), c(6, "h"), c(8, "h"), c(10, "d"))));
const actions = getLegalActions(state, 0);
expect(actions.canHit).toBe(true);
expect(actions.canStand).toBe(true);
expect(actions.canDouble).toBe(true);
expect(actions.canSplit).toBe(true);
expect(actions.canInsure).toBe(false);
const spectator = buildPrivateView(state, -1);
expect(spectator.viewerSeat).toBe(-1);
expect(spectator.yourTurn).toBe(false);
});
it("does not mutate the state on idempotent advances", () => {
const state = bet(soloTable(shoeOf(c(10, "s"), c(6, "h"), c(9, "s"), c(7, "d"))));
expect(advanceState(state)).toEqual(state);
});
});
describe("settleRound direct resolution", () => {
it("resolves hand-built states with known totals", () => {
const base = makeTable([1000, 1000], 3);
const playerFor = (name: string, cards: Card[], betAmount: number) => ({
seatId: 0,
name,
bankroll: 1000 - betAmount,
hands: [
{
cards,
bet: betAmount,
status: "stood" as const,
doubled: false,
fromSplit: false,
splitAces: false,
outcome: null,
netChange: null,
},
],
insuranceBet: 0,
insuranceAnswered: false,
hasBet: true,
sittingOut: false,
bankrupt: false,
roundsPlayed: 0,
roundsWon: 0,
blackjacksHit: 0,
});
const a = playerFor("A", [c(10, "s"), c(10, "h")], 50);
const b = { ...playerFor("B", [c(10, "d"), c(6, "s")], 20), seatId: 1 as const };
const withHands: BlackjackTableState = {
...base,
players: [a, b],
dealer: { cards: [c(9, "h"), c(10, "c")], holeRevealed: true },
phase: "player",
toActSeat: null,
};
const settled = settleRound(withHands);
expect(settled.players[0]?.bankroll).toBe(1000 + 50);
expect(settled.players[1]?.bankroll).toBe(1000 - 20);
expect(settled.roundResult?.hands).toHaveLength(2);
expect(settled.roundResult?.hands[0]?.outcome).toBe("win");
expect(settled.roundResult?.hands[1]?.outcome).toBe("lose");
});
});
describe("table limits", () => {
it("supports solo through six players and rejects beyond", () => {
expect(MIN_PLAYERS).toBe(1);
expect(MAX_PLAYERS).toBe(6);
const six = createTableState(
Array.from({ length: 6 }, (_, index) => ({ name: `P${index + 1}` })),
{},
mulberry32(2),
);
expect(six.players).toHaveLength(6);
expect(() =>
createTableState(
Array.from({ length: 7 }, (_, index) => ({ name: `P${index + 1}` })),
{},
mulberry32(2),
),
).toThrow("1 to 6 players");
});
});