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>
546 lines
21 KiB
TypeScript
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");
|
|
});
|
|
});
|