- Replace round payout multipliers with per-player settlement amounts - Update blackjack panel to display wager, payout, and net results
983 lines
39 KiB
TypeScript
983 lines
39 KiB
TypeScript
import { describe, it, expect } from "bun:test";
|
|
import { blackjackPlugin } from "./blackjack.plugin";
|
|
import { handValue } from "./blackjack.plugin";
|
|
import type { BlackjackState, BlackjackPlayerView, BlackjackSpectatorView, Card, PlayerHand, PlayerSeat } from "./blackjack.types";
|
|
|
|
// ── Helpers ──
|
|
|
|
function makeCard(rank: string, suit: string = "spades"): Card {
|
|
return { rank, suit } as Card;
|
|
}
|
|
|
|
function playingHand(cards: Card[], bet = 1, fromSplit = false): PlayerHand {
|
|
return { cards, status: "playing", result: null, resultReason: null, bet, fromSplit };
|
|
}
|
|
|
|
function stoodHand(cards: Card[], bet = 1, fromSplit = false): PlayerHand {
|
|
return { cards, status: "stood", result: null, resultReason: null, bet, fromSplit };
|
|
}
|
|
|
|
function blackjackHand(cards: Card[]): PlayerHand {
|
|
return { cards, status: "blackjack", result: null, resultReason: null, bet: 1, fromSplit: false };
|
|
}
|
|
|
|
function makeSeat(hands: PlayerHand[], activeHandIndex = 0, hasBet = true): PlayerSeat {
|
|
return { hands, activeHandIndex, hasBet, cumulativePnl: 0 };
|
|
}
|
|
|
|
/** Create a rigged state for deterministic testing. */
|
|
function riggedState(overrides: {
|
|
seats: Record<string, PlayerSeat>;
|
|
dealerHand: Card[];
|
|
turnOrder: string[];
|
|
deck?: Card[];
|
|
activePlayerIndex?: number;
|
|
phase?: "betting" | "player_turns" | "resolved";
|
|
roundNumber?: number;
|
|
betAmount?: number;
|
|
}): BlackjackState {
|
|
return {
|
|
deck: overrides.deck ?? [makeCard("5"), makeCard("6"), makeCard("7"), makeCard("8"), makeCard("9"), makeCard("10")],
|
|
dealerHand: overrides.dealerHand,
|
|
seats: overrides.seats,
|
|
turnOrder: overrides.turnOrder,
|
|
activePlayerIndex: overrides.activePlayerIndex ?? 0,
|
|
phase: overrides.phase ?? "player_turns",
|
|
roundNumber: overrides.roundNumber ?? 1,
|
|
betAmount: overrides.betAmount ?? 0,
|
|
};
|
|
}
|
|
|
|
// ── handValue tests ──
|
|
|
|
describe("handValue", () => {
|
|
it("calculates simple hand values", () => {
|
|
expect(handValue([makeCard("5"), makeCard("10")])).toBe(15);
|
|
expect(handValue([makeCard("K"), makeCard("Q")])).toBe(20);
|
|
});
|
|
|
|
it("treats ace as 11 when possible", () => {
|
|
expect(handValue([makeCard("A"), makeCard("10")])).toBe(21);
|
|
});
|
|
|
|
it("treats ace as 1 when 11 would bust", () => {
|
|
expect(handValue([makeCard("A"), makeCard("10"), makeCard("5")])).toBe(16);
|
|
});
|
|
|
|
it("handles multiple aces", () => {
|
|
expect(handValue([makeCard("A"), makeCard("A")])).toBe(12);
|
|
expect(handValue([makeCard("A"), makeCard("A"), makeCard("9")])).toBe(21);
|
|
});
|
|
});
|
|
|
|
// ── Plugin metadata ──
|
|
|
|
describe("blackjackPlugin metadata", () => {
|
|
it("has correct slug and name", () => {
|
|
expect(blackjackPlugin.slug).toBe("blackjack");
|
|
expect(blackjackPlugin.name).toBe("Blackjack");
|
|
});
|
|
|
|
it("supports 1-6 players with manual start", () => {
|
|
expect(blackjackPlugin.minPlayers).toBe(1);
|
|
expect(blackjackPlugin.maxPlayers).toBe(6);
|
|
expect(blackjackPlugin.manualStart).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── createInitialState ──
|
|
|
|
describe("createInitialState", () => {
|
|
it("starts in betting phase with empty seats", () => {
|
|
const state = blackjackPlugin.createInitialState(["p1", "p2"]);
|
|
expect(state.phase).toBe("betting");
|
|
expect(state.roundNumber).toBe(1);
|
|
expect(state.turnOrder).toEqual(["p1", "p2"]);
|
|
expect(state.seats["p1"]!.hasBet).toBe(false);
|
|
expect(state.seats["p1"]!.hands.length).toBe(0);
|
|
expect(state.seats["p2"]!.hasBet).toBe(false);
|
|
expect(state.dealerHand.length).toBe(0);
|
|
});
|
|
|
|
it("creates a full 52-card deck", () => {
|
|
const state = blackjackPlugin.createInitialState(["p1"]);
|
|
expect(state.deck.length).toBe(52);
|
|
});
|
|
|
|
it("works with a single player", () => {
|
|
const state = blackjackPlugin.createInitialState(["solo"]);
|
|
expect(state.turnOrder).toEqual(["solo"]);
|
|
expect(state.seats["solo"]).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ── getActionCost ──
|
|
|
|
describe("getActionCost", () => {
|
|
it("returns 1 for place_bet", () => {
|
|
const state = blackjackPlugin.createInitialState(["p1"]);
|
|
expect(blackjackPlugin.getActionCost!(state, { type: "place_bet" }, "p1")).toBe(1);
|
|
});
|
|
|
|
it("returns 1 for split", () => {
|
|
const state = blackjackPlugin.createInitialState(["p1"]);
|
|
expect(blackjackPlugin.getActionCost!(state, { type: "split" }, "p1")).toBe(1);
|
|
});
|
|
|
|
it("returns 1 for double_down", () => {
|
|
const state = blackjackPlugin.createInitialState(["p1"]);
|
|
expect(blackjackPlugin.getActionCost!(state, { type: "double_down" }, "p1")).toBe(1);
|
|
});
|
|
|
|
it("returns 0 for hit, stand, leave_table, sit_down", () => {
|
|
const state = blackjackPlugin.createInitialState(["p1"]);
|
|
expect(blackjackPlugin.getActionCost!(state, { type: "hit" }, "p1")).toBe(0);
|
|
expect(blackjackPlugin.getActionCost!(state, { type: "stand" }, "p1")).toBe(0);
|
|
expect(blackjackPlugin.getActionCost!(state, { type: "leave_table" }, "p1")).toBe(0);
|
|
expect(blackjackPlugin.getActionCost!(state, { type: "sit_down" }, "p1")).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ── isSpectatorAction ──
|
|
|
|
describe("isSpectatorAction", () => {
|
|
it("returns true for sit_down", () => {
|
|
expect(blackjackPlugin.isSpectatorAction!({ type: "sit_down" })).toBe(true);
|
|
});
|
|
|
|
it("returns false for other actions", () => {
|
|
expect(blackjackPlugin.isSpectatorAction!({ type: "hit" })).toBe(false);
|
|
expect(blackjackPlugin.isSpectatorAction!({ type: "place_bet" })).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── place_bet & round lifecycle ──
|
|
|
|
describe("place_bet", () => {
|
|
it("marks player as having bet", () => {
|
|
const state = blackjackPlugin.createInitialState(["p1", "p2"]);
|
|
const result = blackjackPlugin.handleAction(state, { type: "place_bet" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.state.seats["p1"]!.hasBet).toBe(true);
|
|
expect(result.state.seats["p2"]!.hasBet).toBe(false);
|
|
expect(result.state.phase).toBe("betting");
|
|
});
|
|
|
|
it("auto-deals when all players have bet", () => {
|
|
const state = blackjackPlugin.createInitialState(["p1", "p2"]);
|
|
const r1 = blackjackPlugin.handleAction(state, { type: "place_bet" }, "p1");
|
|
expect(r1.ok).toBe(true);
|
|
if (!r1.ok) return;
|
|
|
|
const r2 = blackjackPlugin.handleAction(r1.state, { type: "place_bet" }, "p2");
|
|
expect(r2.ok).toBe(true);
|
|
if (!r2.ok) return;
|
|
expect(r2.state.phase).toBe("player_turns");
|
|
expect(r2.state.seats["p1"]!.hands.length).toBe(1); // 1 hand with 2 cards
|
|
expect(r2.state.seats["p1"]!.hands[0]!.cards.length).toBe(2);
|
|
expect(r2.state.dealerHand.length).toBe(2);
|
|
});
|
|
|
|
it("single player auto-deals immediately", () => {
|
|
const state = blackjackPlugin.createInitialState(["p1"]);
|
|
const result = blackjackPlugin.handleAction(state, { type: "place_bet" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
// Should have dealt — either in player_turns or resolved (if blackjack)
|
|
expect(["player_turns", "resolved"]).toContain(result.state.phase);
|
|
});
|
|
|
|
it("rejects double bet", () => {
|
|
const state = blackjackPlugin.createInitialState(["p1", "p2"]);
|
|
const r1 = blackjackPlugin.handleAction(state, { type: "place_bet" }, "p1");
|
|
if (!r1.ok) return;
|
|
const r2 = blackjackPlugin.handleAction(r1.state, { type: "place_bet" }, "p1");
|
|
expect(r2.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects bet during player_turns", () => {
|
|
const state = riggedState({
|
|
seats: { "p1": makeSeat([playingHand([makeCard("5"), makeCard("6")])]) },
|
|
dealerHand: [makeCard("7"), makeCard("8")],
|
|
turnOrder: ["p1"],
|
|
phase: "player_turns",
|
|
});
|
|
const result = blackjackPlugin.handleAction(state, { type: "place_bet" }, "p1");
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it("transitions from resolved to new round on first bet", () => {
|
|
const state = riggedState({
|
|
seats: {
|
|
"p1": makeSeat([{ ...stoodHand([makeCard("K"), makeCard("9")]), result: "win", resultReason: "Higher hand" }], -1),
|
|
},
|
|
dealerHand: [makeCard("7"), makeCard("8")],
|
|
turnOrder: ["p1"],
|
|
phase: "resolved",
|
|
roundNumber: 1,
|
|
});
|
|
|
|
const result = blackjackPlugin.handleAction(state, { type: "place_bet" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
// Single player, bet placed → should auto-deal
|
|
expect(result.state.roundNumber).toBe(2);
|
|
expect(["player_turns", "resolved"]).toContain(result.state.phase);
|
|
});
|
|
});
|
|
|
|
// ── hit ──
|
|
|
|
describe("handleAction — hit", () => {
|
|
it("adds a card to active player's hand", () => {
|
|
const state = riggedState({
|
|
seats: { "p1": makeSeat([playingHand([makeCard("5"), makeCard("6")])]) },
|
|
dealerHand: [makeCard("K"), makeCard("7")],
|
|
turnOrder: ["p1"],
|
|
});
|
|
|
|
const result = blackjackPlugin.handleAction(state, { type: "hit" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.state.seats["p1"]!.hands[0]!.cards.length).toBe(3);
|
|
});
|
|
|
|
it("busts player and advances turn", () => {
|
|
const state = riggedState({
|
|
seats: {
|
|
"p1": makeSeat([playingHand([makeCard("K"), makeCard("Q")])]),
|
|
"p2": makeSeat([playingHand([makeCard("5"), makeCard("6")])], -1),
|
|
},
|
|
dealerHand: [makeCard("7"), makeCard("8")],
|
|
turnOrder: ["p1", "p2"],
|
|
deck: [makeCard("5")], // K+Q+5 = 25 → bust
|
|
});
|
|
|
|
const result = blackjackPlugin.handleAction(state, { type: "hit" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.state.seats["p1"]!.hands[0]!.status).toBe("bust");
|
|
expect(result.state.seats["p1"]!.hands[0]!.result).toBe("lose");
|
|
expect(result.state.activePlayerIndex).toBe(1);
|
|
});
|
|
|
|
it("auto-stands on 21", () => {
|
|
const state = riggedState({
|
|
seats: {
|
|
"p1": makeSeat([playingHand([makeCard("6"), makeCard("5")])]),
|
|
"p2": makeSeat([playingHand([makeCard("7"), makeCard("8")])], -1),
|
|
},
|
|
dealerHand: [makeCard("9"), makeCard("10")],
|
|
turnOrder: ["p1", "p2"],
|
|
deck: [makeCard("10")], // 6+5+10 = 21
|
|
});
|
|
|
|
const result = blackjackPlugin.handleAction(state, { type: "hit" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.state.seats["p1"]!.hands[0]!.status).toBe("stood");
|
|
expect(result.state.activePlayerIndex).toBe(1);
|
|
});
|
|
|
|
it("rejects action from non-active player", () => {
|
|
const state = riggedState({
|
|
seats: {
|
|
"p1": makeSeat([playingHand([makeCard("K"), makeCard("9")])]),
|
|
"p2": makeSeat([playingHand([makeCard("5"), makeCard("6")])], -1),
|
|
},
|
|
dealerHand: [makeCard("7"), makeCard("8")],
|
|
turnOrder: ["p1", "p2"],
|
|
});
|
|
const result = blackjackPlugin.handleAction(state, { type: "hit" }, "p2");
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── stand ──
|
|
|
|
describe("handleAction — stand", () => {
|
|
it("resolves round when last player stands", () => {
|
|
const state = riggedState({
|
|
seats: { "p1": makeSeat([playingHand([makeCard("K"), makeCard("9")])]) },
|
|
dealerHand: [makeCard("7"), makeCard("8")],
|
|
turnOrder: ["p1"],
|
|
deck: [makeCard("2")], // dealer: 15 + 2 = 17
|
|
betAmount: 10,
|
|
});
|
|
|
|
const result = blackjackPlugin.handleAction(state, { type: "stand" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.state.phase).toBe("resolved");
|
|
expect(result.state.seats["p1"]!.hands[0]!.result).toBe("win"); // 19 > 17
|
|
expect(result.roundSettlements).toBeDefined();
|
|
expect(result.roundSettlements!["p1"]).toEqual({ wager: 10, payout: 20, net: 10 });
|
|
});
|
|
|
|
it("dealer busts — all standing players win", () => {
|
|
const state = riggedState({
|
|
seats: {
|
|
"p1": makeSeat([playingHand([makeCard("K"), makeCard("8")])]),
|
|
"p2": makeSeat([stoodHand([makeCard("7"), makeCard("9")])], -1),
|
|
},
|
|
dealerHand: [makeCard("6"), makeCard("9")],
|
|
turnOrder: ["p1", "p2"],
|
|
deck: [makeCard("K")], // dealer: 6+9+K = 25 → bust
|
|
});
|
|
|
|
const result = blackjackPlugin.handleAction(state, { type: "stand" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.state.phase).toBe("resolved");
|
|
expect(result.state.seats["p1"]!.hands[0]!.result).toBe("win");
|
|
expect(result.state.seats["p2"]!.hands[0]!.result).toBe("win");
|
|
});
|
|
|
|
it("mixed results — win, lose, push", () => {
|
|
const state = riggedState({
|
|
seats: {
|
|
"p1": makeSeat([stoodHand([makeCard("K"), makeCard("9")])], -1), // 19
|
|
"p2": makeSeat([stoodHand([makeCard("7"), makeCard("8")])], -1), // 15
|
|
"p3": makeSeat([playingHand([makeCard("Q"), makeCard("8")])]), // 18 — active
|
|
},
|
|
dealerHand: [makeCard("K"), makeCard("8")], // 18
|
|
turnOrder: ["p1", "p2", "p3"],
|
|
activePlayerIndex: 2,
|
|
});
|
|
|
|
const result = blackjackPlugin.handleAction(state, { type: "stand" }, "p3");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.state.seats["p1"]!.hands[0]!.result).toBe("win"); // 19 > 18
|
|
expect(result.state.seats["p2"]!.hands[0]!.result).toBe("lose"); // 15 < 18
|
|
expect(result.state.seats["p3"]!.hands[0]!.result).toBe("push"); // 18 = 18
|
|
});
|
|
});
|
|
|
|
// ── split ──
|
|
|
|
describe("handleAction — split", () => {
|
|
it("splits paired cards into two hands", () => {
|
|
const state = riggedState({
|
|
seats: { "p1": makeSeat([playingHand([makeCard("8", "hearts"), makeCard("8", "clubs")])]) },
|
|
dealerHand: [makeCard("7"), makeCard("Q")],
|
|
turnOrder: ["p1"],
|
|
deck: [makeCard("3"), makeCard("5"), makeCard("K")],
|
|
});
|
|
|
|
const result = blackjackPlugin.handleAction(state, { type: "split" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.state.seats["p1"]!.hands.length).toBe(2);
|
|
expect(result.state.seats["p1"]!.hands[0]!.cards.length).toBe(2); // 8 + drawn card
|
|
expect(result.state.seats["p1"]!.hands[1]!.cards.length).toBe(2); // 8 + drawn card
|
|
expect(result.state.seats["p1"]!.hands[0]!.fromSplit).toBe(true);
|
|
expect(result.state.seats["p1"]!.hands[1]!.fromSplit).toBe(true);
|
|
expect(result.state.seats["p1"]!.hands[0]!.bet).toBe(1);
|
|
expect(result.state.seats["p1"]!.hands[1]!.bet).toBe(1);
|
|
});
|
|
|
|
it("rejects split on non-paired cards", () => {
|
|
const state = riggedState({
|
|
seats: { "p1": makeSeat([playingHand([makeCard("8"), makeCard("9")])]) },
|
|
dealerHand: [makeCard("7"), makeCard("Q")],
|
|
turnOrder: ["p1"],
|
|
});
|
|
const result = blackjackPlugin.handleAction(state, { type: "split" }, "p1");
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects split when already at max hands", () => {
|
|
const state = riggedState({
|
|
seats: {
|
|
"p1": makeSeat([
|
|
playingHand([makeCard("8"), makeCard("8")]),
|
|
stoodHand([makeCard("K"), makeCard("7")], 1, true),
|
|
stoodHand([makeCard("9"), makeCard("6")], 1, true),
|
|
stoodHand([makeCard("J"), makeCard("5")], 1, true),
|
|
]),
|
|
},
|
|
dealerHand: [makeCard("7"), makeCard("Q")],
|
|
turnOrder: ["p1"],
|
|
});
|
|
const result = blackjackPlugin.handleAction(state, { type: "split" }, "p1");
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it("split aces auto-stand both hands", () => {
|
|
const state = riggedState({
|
|
seats: { "p1": makeSeat([playingHand([makeCard("A", "hearts"), makeCard("A", "clubs")])]) },
|
|
dealerHand: [makeCard("7"), makeCard("Q")],
|
|
turnOrder: ["p1"],
|
|
deck: [makeCard("5"), makeCard("K"), makeCard("2")], // extra for dealer
|
|
});
|
|
|
|
const result = blackjackPlugin.handleAction(state, { type: "split" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.state.seats["p1"]!.hands[0]!.status).toBe("stood");
|
|
expect(result.state.seats["p1"]!.hands[1]!.status).toBe("stood");
|
|
// Should have advanced to resolution since both auto-stood
|
|
expect(result.state.phase).toBe("resolved");
|
|
});
|
|
|
|
it("21 on split hand is not blackjack (no 2.5x payout)", () => {
|
|
const state = riggedState({
|
|
seats: { "p1": makeSeat([playingHand([makeCard("A", "hearts"), makeCard("A", "clubs")])]) },
|
|
dealerHand: [makeCard("7"), makeCard("Q")],
|
|
turnOrder: ["p1"],
|
|
deck: [makeCard("K"), makeCard("5"), makeCard("2")], // first split hand: A+K = 21
|
|
});
|
|
|
|
const result = blackjackPlugin.handleAction(state, { type: "split" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
// The hand should be "stood" not "blackjack"
|
|
const hand1 = result.state.seats["p1"]!.hands[0]!;
|
|
expect(hand1.status).toBe("stood"); // not "blackjack"
|
|
});
|
|
|
|
it("plays through split hands in order", () => {
|
|
const state = riggedState({
|
|
seats: { "p1": makeSeat([playingHand([makeCard("8", "hearts"), makeCard("8", "clubs")])]) },
|
|
dealerHand: [makeCard("7"), makeCard("Q")],
|
|
turnOrder: ["p1"],
|
|
deck: [makeCard("3"), makeCard("5"), makeCard("2"), makeCard("K"), makeCard("6")],
|
|
});
|
|
|
|
const splitResult = blackjackPlugin.handleAction(state, { type: "split" }, "p1");
|
|
expect(splitResult.ok).toBe(true);
|
|
if (!splitResult.ok) return;
|
|
// Should be playing first hand
|
|
expect(splitResult.state.seats["p1"]!.activeHandIndex).toBe(0);
|
|
|
|
// Stand on first hand → should move to second hand
|
|
const standResult = blackjackPlugin.handleAction(splitResult.state, { type: "stand" }, "p1");
|
|
expect(standResult.ok).toBe(true);
|
|
if (!standResult.ok) return;
|
|
expect(standResult.state.seats["p1"]!.activeHandIndex).toBe(1);
|
|
expect(standResult.state.phase).toBe("player_turns");
|
|
});
|
|
|
|
it("allows splitting face cards with same value", () => {
|
|
const state = riggedState({
|
|
seats: { "p1": makeSeat([playingHand([makeCard("K"), makeCard("Q")])]) },
|
|
dealerHand: [makeCard("7"), makeCard("8")],
|
|
turnOrder: ["p1"],
|
|
deck: [makeCard("3"), makeCard("5"), makeCard("K")],
|
|
});
|
|
const result = blackjackPlugin.handleAction(state, { type: "split" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── double down ──
|
|
|
|
describe("handleAction — double_down", () => {
|
|
it("draws one card, doubles bet, and auto-stands", () => {
|
|
const state = riggedState({
|
|
seats: { "p1": makeSeat([playingHand([makeCard("5"), makeCard("6")])]) },
|
|
dealerHand: [makeCard("7"), makeCard("8")],
|
|
turnOrder: ["p1"],
|
|
deck: [makeCard("9"), makeCard("2")], // draw 9 → 20, dealer hits
|
|
betAmount: 10,
|
|
});
|
|
|
|
const result = blackjackPlugin.handleAction(state, { type: "double_down" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
const hand = result.state.seats["p1"]!.hands[0]!;
|
|
expect(hand.cards.length).toBe(3);
|
|
expect(hand.bet).toBe(2);
|
|
expect(hand.status).toBe("stood");
|
|
expect(result.state.phase).toBe("resolved"); // single player, auto-resolves
|
|
});
|
|
|
|
it("busts on double down", () => {
|
|
const state = riggedState({
|
|
seats: { "p1": makeSeat([playingHand([makeCard("K"), makeCard("6")])]) },
|
|
dealerHand: [makeCard("7"), makeCard("8")],
|
|
turnOrder: ["p1"],
|
|
deck: [makeCard("K")], // K+6+K = 26 → bust
|
|
});
|
|
|
|
const result = blackjackPlugin.handleAction(state, { type: "double_down" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.state.seats["p1"]!.hands[0]!.status).toBe("bust");
|
|
expect(result.state.seats["p1"]!.hands[0]!.result).toBe("lose");
|
|
expect(result.state.seats["p1"]!.hands[0]!.bet).toBe(2);
|
|
});
|
|
|
|
it("rejects double after hitting (3+ cards)", () => {
|
|
const state = riggedState({
|
|
seats: { "p1": makeSeat([playingHand([makeCard("3"), makeCard("4"), makeCard("2")])]) },
|
|
dealerHand: [makeCard("7"), makeCard("8")],
|
|
turnOrder: ["p1"],
|
|
});
|
|
const result = blackjackPlugin.handleAction(state, { type: "double_down" }, "p1");
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it("doubled win pays 2x the doubled bet", () => {
|
|
const state = riggedState({
|
|
seats: { "p1": makeSeat([playingHand([makeCard("5"), makeCard("6")])]) },
|
|
dealerHand: [makeCard("7"), makeCard("8")],
|
|
turnOrder: ["p1"],
|
|
deck: [makeCard("9"), makeCard("2")], // p1: 20, dealer: 15+2 = 17
|
|
betAmount: 10,
|
|
});
|
|
|
|
const result = blackjackPlugin.handleAction(state, { type: "double_down" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.roundSettlements).toBeDefined();
|
|
expect(result.roundSettlements!["p1"]).toEqual({ wager: 20, payout: 40, net: 20 });
|
|
});
|
|
});
|
|
|
|
// ── leave_table ──
|
|
|
|
describe("handleAction — leave_table", () => {
|
|
it("removes player during betting phase", () => {
|
|
const state = blackjackPlugin.createInitialState(["p1", "p2"]);
|
|
const result = blackjackPlugin.handleAction(state, { type: "leave_table" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.state.turnOrder).toEqual(["p2"]);
|
|
expect(result.state.seats["p1"]).toBeUndefined();
|
|
});
|
|
|
|
it("removes player during their turn and advances", () => {
|
|
const state = riggedState({
|
|
seats: {
|
|
"p1": makeSeat([playingHand([makeCard("5"), makeCard("6")])]),
|
|
"p2": makeSeat([playingHand([makeCard("7"), makeCard("8")])], -1),
|
|
},
|
|
dealerHand: [makeCard("9"), makeCard("10")],
|
|
turnOrder: ["p1", "p2"],
|
|
});
|
|
|
|
const result = blackjackPlugin.handleAction(state, { type: "leave_table" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.state.turnOrder).toEqual(["p2"]);
|
|
expect(result.state.activePlayerIndex).toBe(0);
|
|
});
|
|
|
|
it("last player leaving triggers isGameOver", () => {
|
|
const state = blackjackPlugin.createInitialState(["p1"]);
|
|
const result = blackjackPlugin.handleAction(state, { type: "leave_table" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.state.turnOrder.length).toBe(0);
|
|
const gameOver = blackjackPlugin.isGameOver!(result.state);
|
|
expect(gameOver).not.toBeNull();
|
|
expect(gameOver!.reason).toContain("All players left");
|
|
});
|
|
|
|
it("rejects leave from non-seated player", () => {
|
|
const state = blackjackPlugin.createInitialState(["p1"]);
|
|
const result = blackjackPlugin.handleAction(state, { type: "leave_table" }, "p2");
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── sit_down ──
|
|
|
|
describe("handleAction — sit_down", () => {
|
|
it("adds player during betting phase", () => {
|
|
const state = blackjackPlugin.createInitialState(["p1"]);
|
|
const result = blackjackPlugin.handleAction(state, { type: "sit_down" }, "p2");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.state.turnOrder).toEqual(["p1", "p2"]);
|
|
expect(result.state.seats["p2"]).toBeDefined();
|
|
expect(result.state.seats["p2"]!.hasBet).toBe(false);
|
|
});
|
|
|
|
it("rejects sit_down during player_turns", () => {
|
|
const state = riggedState({
|
|
seats: { "p1": makeSeat([playingHand([makeCard("5"), makeCard("6")])]) },
|
|
dealerHand: [makeCard("7"), makeCard("8")],
|
|
turnOrder: ["p1"],
|
|
phase: "player_turns",
|
|
});
|
|
const result = blackjackPlugin.handleAction(state, { type: "sit_down" }, "p2");
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects sit_down when table is full", () => {
|
|
const state = blackjackPlugin.createInitialState(["p1", "p2", "p3", "p4", "p5", "p6"]);
|
|
const result = blackjackPlugin.handleAction(state, { type: "sit_down" }, "p7");
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects sit_down if already seated", () => {
|
|
const state = blackjackPlugin.createInitialState(["p1"]);
|
|
const result = blackjackPlugin.handleAction(state, { type: "sit_down" }, "p1");
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── round lifecycle ──
|
|
|
|
describe("round lifecycle", () => {
|
|
it("full round: bet → play → resolve → bet again", () => {
|
|
const state = blackjackPlugin.createInitialState(["p1"]);
|
|
expect(state.phase).toBe("betting");
|
|
|
|
// Place bet → auto-deals
|
|
const betResult = blackjackPlugin.handleAction(state, { type: "place_bet" }, "p1");
|
|
expect(betResult.ok).toBe(true);
|
|
if (!betResult.ok) return;
|
|
|
|
// Play the hand (stand immediately)
|
|
let current = betResult.state;
|
|
if (current.phase === "player_turns") {
|
|
const standResult = blackjackPlugin.handleAction(current, { type: "stand" }, "p1");
|
|
expect(standResult.ok).toBe(true);
|
|
if (!standResult.ok) return;
|
|
current = standResult.state;
|
|
}
|
|
expect(current.phase).toBe("resolved");
|
|
expect(current.roundNumber).toBe(1);
|
|
|
|
// Place bet again → starts round 2
|
|
const nextBetResult = blackjackPlugin.handleAction(current, { type: "place_bet" }, "p1");
|
|
expect(nextBetResult.ok).toBe(true);
|
|
if (!nextBetResult.ok) return;
|
|
expect(nextBetResult.state.roundNumber).toBe(2);
|
|
});
|
|
});
|
|
|
|
// ── Views ──
|
|
|
|
describe("getPlayerView", () => {
|
|
it("hides dealer hole card during player turns", () => {
|
|
const state = riggedState({
|
|
seats: { "p1": makeSeat([playingHand([makeCard("K"), makeCard("5")])]) },
|
|
dealerHand: [makeCard("7"), makeCard("Q")],
|
|
turnOrder: ["p1"],
|
|
});
|
|
|
|
const view = blackjackPlugin.getPlayerView(state, "p1") as BlackjackPlayerView;
|
|
expect(view.dealerHand[0]!.rank).toBe("7");
|
|
expect(view.dealerHand[1]!.rank as string).toBe("?");
|
|
expect(view.dealerFullValue).toBeNull();
|
|
});
|
|
|
|
it("reveals dealer hand when resolved", () => {
|
|
const state = riggedState({
|
|
seats: { "p1": makeSeat([{ ...stoodHand([makeCard("K"), makeCard("5")]), result: "lose", resultReason: "Dealer wins" }], -1) },
|
|
dealerHand: [makeCard("7"), makeCard("Q")],
|
|
turnOrder: ["p1"],
|
|
phase: "resolved",
|
|
activePlayerIndex: -1,
|
|
});
|
|
|
|
const view = blackjackPlugin.getPlayerView(state, "p1") as BlackjackPlayerView;
|
|
expect(view.dealerHand[1]!.rank).toBe("Q");
|
|
expect(view.dealerFullValue).toBe(17);
|
|
});
|
|
|
|
it("canAct is true only for active player", () => {
|
|
const state = riggedState({
|
|
seats: {
|
|
"p1": makeSeat([playingHand([makeCard("5"), makeCard("6")])]),
|
|
"p2": makeSeat([playingHand([makeCard("7"), makeCard("8")])], -1),
|
|
},
|
|
dealerHand: [makeCard("9"), makeCard("10")],
|
|
turnOrder: ["p1", "p2"],
|
|
});
|
|
|
|
const view1 = blackjackPlugin.getPlayerView(state, "p1") as BlackjackPlayerView;
|
|
expect(view1.canAct).toBe(true);
|
|
|
|
const view2 = blackjackPlugin.getPlayerView(state, "p2") as BlackjackPlayerView;
|
|
expect(view2.canAct).toBe(false);
|
|
});
|
|
|
|
it("shows canSplit for paired cards", () => {
|
|
const state = riggedState({
|
|
seats: { "p1": makeSeat([playingHand([makeCard("8", "hearts"), makeCard("8", "clubs")])]) },
|
|
dealerHand: [makeCard("7"), makeCard("Q")],
|
|
turnOrder: ["p1"],
|
|
});
|
|
const view = blackjackPlugin.getPlayerView(state, "p1") as BlackjackPlayerView;
|
|
expect(view.canSplit).toBe(true);
|
|
expect(view.canDoubleDown).toBe(true);
|
|
});
|
|
|
|
it("shows seats with multi-hand info", () => {
|
|
const state = riggedState({
|
|
seats: {
|
|
"p1": makeSeat([
|
|
playingHand([makeCard("8"), makeCard("3")], 1, true),
|
|
stoodHand([makeCard("8"), makeCard("K")], 1, true),
|
|
]),
|
|
},
|
|
dealerHand: [makeCard("7"), makeCard("Q")],
|
|
turnOrder: ["p1"],
|
|
});
|
|
const view = blackjackPlugin.getPlayerView(state, "p1") as BlackjackPlayerView;
|
|
expect(view.seats["p1"]!.hands.length).toBe(2);
|
|
expect(view.seats["p1"]!.hands[0]!.fromSplit).toBe(true);
|
|
});
|
|
|
|
it("includes roundNumber in view", () => {
|
|
const state = riggedState({
|
|
seats: { "p1": makeSeat([playingHand([makeCard("5"), makeCard("6")])]) },
|
|
dealerHand: [makeCard("7"), makeCard("Q")],
|
|
turnOrder: ["p1"],
|
|
roundNumber: 3,
|
|
});
|
|
const view = blackjackPlugin.getPlayerView(state, "p1") as BlackjackPlayerView;
|
|
expect(view.roundNumber).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe("getSpectatorView", () => {
|
|
it("hides dealer hole card during player turns", () => {
|
|
const state = riggedState({
|
|
seats: { "p1": makeSeat([playingHand([makeCard("K"), makeCard("5")])]) },
|
|
dealerHand: [makeCard("7"), makeCard("Q")],
|
|
turnOrder: ["p1"],
|
|
});
|
|
const view = blackjackPlugin.getSpectatorView(state) as BlackjackSpectatorView;
|
|
expect(view.dealerHand[1]!.rank as string).toBe("?");
|
|
});
|
|
|
|
it("shows all seats and turnOrder", () => {
|
|
const state = riggedState({
|
|
seats: {
|
|
"p1": makeSeat([playingHand([makeCard("K"), makeCard("5")])]),
|
|
"p2": makeSeat([playingHand([makeCard("7"), makeCard("8")])], -1),
|
|
},
|
|
dealerHand: [makeCard("9"), makeCard("10")],
|
|
turnOrder: ["p1", "p2"],
|
|
});
|
|
const view = blackjackPlugin.getSpectatorView(state) as BlackjackSpectatorView;
|
|
expect(Object.keys(view.seats).length).toBe(2);
|
|
expect(view.turnOrder).toEqual(["p1", "p2"]);
|
|
});
|
|
});
|
|
|
|
// ── isGameOver ──
|
|
|
|
describe("isGameOver", () => {
|
|
it("returns null when players are seated", () => {
|
|
const state = blackjackPlugin.createInitialState(["p1"]);
|
|
expect(blackjackPlugin.isGameOver!(state)).toBeNull();
|
|
});
|
|
|
|
it("returns result when all players have left", () => {
|
|
const state = blackjackPlugin.createInitialState(["p1"]);
|
|
const result = blackjackPlugin.handleAction(state, { type: "leave_table" }, "p1");
|
|
if (!result.ok) return;
|
|
const gameOver = blackjackPlugin.isGameOver!(result.state);
|
|
expect(gameOver).not.toBeNull();
|
|
expect(gameOver!.reason).toContain("All players left");
|
|
expect(gameOver!.payouts).toEqual({});
|
|
});
|
|
|
|
it("returns null during resolved phase with players still seated", () => {
|
|
const state = riggedState({
|
|
seats: { "p1": makeSeat([{ ...stoodHand([makeCard("K"), makeCard("9")]), result: "win", resultReason: "Win" }], -1) },
|
|
dealerHand: [makeCard("7"), makeCard("8")],
|
|
turnOrder: ["p1"],
|
|
phase: "resolved",
|
|
activePlayerIndex: -1,
|
|
});
|
|
expect(blackjackPlugin.isGameOver!(state)).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── onPlayerDisconnect ──
|
|
|
|
describe("onPlayerDisconnect", () => {
|
|
it("removes disconnected player and adjusts turn", () => {
|
|
const state = riggedState({
|
|
seats: {
|
|
"p1": makeSeat([playingHand([makeCard("5"), makeCard("6")])]),
|
|
"p2": makeSeat([playingHand([makeCard("7"), makeCard("8")])], -1),
|
|
},
|
|
dealerHand: [makeCard("9"), makeCard("10")],
|
|
turnOrder: ["p1", "p2"],
|
|
});
|
|
|
|
const newState = blackjackPlugin.onPlayerDisconnect!(state, "p1");
|
|
expect(newState.turnOrder).toEqual(["p2"]);
|
|
expect(newState.seats["p1"]).toBeUndefined();
|
|
});
|
|
|
|
it("resolves round if disconnected player was last active", () => {
|
|
const state = riggedState({
|
|
seats: { "p1": makeSeat([playingHand([makeCard("5"), makeCard("6")])]) },
|
|
dealerHand: [makeCard("9"), makeCard("10")],
|
|
turnOrder: ["p1"],
|
|
});
|
|
|
|
const newState = blackjackPlugin.onPlayerDisconnect!(state, "p1");
|
|
// Player removed — no players left
|
|
expect(newState.turnOrder.length).toBe(0);
|
|
});
|
|
|
|
it("does nothing for non-seated player", () => {
|
|
const state = blackjackPlugin.createInitialState(["p1"]);
|
|
const newState = blackjackPlugin.onPlayerDisconnect!(state, "p2");
|
|
expect(newState.turnOrder).toEqual(["p1"]);
|
|
});
|
|
});
|
|
|
|
// ── Multi-hand payout tests ──
|
|
|
|
describe("multi-hand payouts", () => {
|
|
it("split with one win and one loss settles to flat net", () => {
|
|
const state = riggedState({
|
|
seats: {
|
|
"p1": makeSeat([
|
|
stoodHand([makeCard("8"), makeCard("K")], 1, true),
|
|
playingHand([makeCard("8"), makeCard("5")], 1, true),
|
|
], 1),
|
|
},
|
|
dealerHand: [makeCard("10"), makeCard("7")], // 17
|
|
turnOrder: ["p1"],
|
|
activePlayerIndex: 0,
|
|
betAmount: 10,
|
|
});
|
|
|
|
const result = blackjackPlugin.handleAction(state, { type: "stand" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
|
|
expect(result.roundSettlements!["p1"]).toEqual({ wager: 20, payout: 20, net: 0 });
|
|
});
|
|
|
|
it("doubled hand win pays 4x betAmount", () => {
|
|
const state = riggedState({
|
|
seats: { "p1": makeSeat([playingHand([makeCard("5"), makeCard("6")])]) },
|
|
dealerHand: [makeCard("6"), makeCard("8")],
|
|
turnOrder: ["p1"],
|
|
deck: [makeCard("8"), makeCard("K")], // p1: 5+6+8=19, dealer: 6+8+K=24 bust
|
|
betAmount: 10,
|
|
});
|
|
|
|
const result = blackjackPlugin.handleAction(state, { type: "double_down" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
// bet=2, win → payout = 2*2 = 4
|
|
expect(result.roundSettlements!["p1"]).toEqual({ wager: 20, payout: 40, net: 20 });
|
|
});
|
|
|
|
it("doubled hand push refunds 2x betAmount", () => {
|
|
const state = riggedState({
|
|
seats: { "p1": makeSeat([playingHand([makeCard("9"), makeCard("8")])]) },
|
|
dealerHand: [makeCard("K"), makeCard("10")],
|
|
turnOrder: ["p1"],
|
|
deck: [makeCard("3")], // p1: 9+8+3=20, dealer: 20 → push
|
|
betAmount: 10,
|
|
});
|
|
|
|
const result = blackjackPlugin.handleAction(state, { type: "double_down" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
// bet=2, push → payout = 2*1 = 2
|
|
expect(result.roundSettlements!["p1"]).toEqual({ wager: 20, payout: 20, net: 0 });
|
|
});
|
|
});
|
|
|
|
// ── Cumulative PnL tests ──
|
|
|
|
describe("cumulative PnL", () => {
|
|
it("initializes cumulativePnl to 0 for new players", () => {
|
|
const state = blackjackPlugin.createInitialState(["p1"]);
|
|
expect(state.seats["p1"]!.cumulativePnl).toBe(0);
|
|
});
|
|
|
|
it("tracks cumulative PnL through multiple rounds", () => {
|
|
// Start a new game
|
|
let state = blackjackPlugin.createInitialState(["p1"]);
|
|
|
|
// Round 1: Win with bet of 1 AU (2x payout = 2, net profit = +1)
|
|
// Use rigged state to guarantee a win
|
|
let result = blackjackPlugin.handleAction(state, { type: "place_bet" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
|
|
// Ensure player can act
|
|
let roundState = result.state;
|
|
if (roundState.phase === "player_turns") {
|
|
// Stand to let dealer play
|
|
result = blackjackPlugin.handleAction(roundState, { type: "stand" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
}
|
|
|
|
// Check PnL after round 1 - should have at least processed the payout
|
|
// Note: exact value depends on cards, but it should be processed
|
|
const round1Pnl = result.state.seats["p1"]!.cumulativePnl;
|
|
expect(result.state.phase).toBe("resolved");
|
|
|
|
// Round 2: Place bet again
|
|
result = blackjackPlugin.handleAction(result.state, { type: "place_bet" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
roundState = result.state;
|
|
|
|
// Stand to resolve
|
|
if (roundState.phase === "player_turns") {
|
|
result = blackjackPlugin.handleAction(roundState, { type: "stand" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
}
|
|
|
|
// Check cumulative PnL after round 2
|
|
const round2Pnl = result.state.seats["p1"]!.cumulativePnl;
|
|
expect(result.state.phase).toBe("resolved");
|
|
});
|
|
|
|
it("includes cumulativePnl in view", () => {
|
|
const state = blackjackPlugin.createInitialState(["p1"]);
|
|
// Manually set a cumulativePnl for testing
|
|
const customState = {
|
|
...state,
|
|
seats: { "p1": { ...state.seats["p1"]!, cumulativePnl: 50 } },
|
|
};
|
|
|
|
const view = blackjackPlugin.getPlayerView(customState, "p1") as BlackjackPlayerView;
|
|
expect(view.myCumulativePnl).toBe(50);
|
|
});
|
|
|
|
it("persists cumulativePnl when player stays seated through rounds", () => {
|
|
// Simulate a full round where player wins, then verify cumulativePnl is preserved
|
|
let state = blackjackPlugin.createInitialState(["p1"]);
|
|
|
|
// Place bet and play a round
|
|
let result = blackjackPlugin.handleAction(state, { type: "place_bet" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
|
|
// Stand to resolve
|
|
if (result.state.phase === "player_turns") {
|
|
result = blackjackPlugin.handleAction(result.state, { type: "stand" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
}
|
|
|
|
// Check PnL after first round
|
|
const firstRoundPnl = result.state.seats["p1"]!.cumulativePnl;
|
|
expect(result.state.phase).toBe("resolved");
|
|
|
|
// Place bet again to start next round
|
|
result = blackjackPlugin.handleAction(result.state, { type: "place_bet" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
|
|
// cumulativePnl should be preserved from the previous round
|
|
expect(result.state.seats["p1"]!.cumulativePnl).toBe(firstRoundPnl);
|
|
expect(result.state.roundNumber).toBe(2);
|
|
});
|
|
});
|