/** * 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[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[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"); }); });