Some checks failed
Deploy to Production / test (push) Failing after 39s
Adds a full blackjack game with dealer AI, hit/stand/double-down actions, and per-player payout multipliers (house-edge model). Extends the game framework with manualStart support and a START_GAME WebSocket message so hosts can begin when ready. Generalizes bet settlement transaction descriptions from chess-specific to game-agnostic. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
472 lines
18 KiB
TypeScript
472 lines
18 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 } from "./blackjack.types";
|
|
|
|
// ── Helpers ──
|
|
|
|
function makeCard(rank: string, suit: string = "spades"): Card {
|
|
return { rank, suit } as Card;
|
|
}
|
|
|
|
/** Create a rigged multiplayer state for deterministic testing. */
|
|
function riggedState(overrides: Partial<BlackjackState> & {
|
|
hands: Record<string, PlayerHand>;
|
|
dealerHand: Card[];
|
|
turnOrder: string[];
|
|
}): BlackjackState {
|
|
return {
|
|
deck: overrides.deck ?? [makeCard("5"), makeCard("6"), makeCard("7"), makeCard("8"), makeCard("9"), makeCard("10")],
|
|
dealerHand: overrides.dealerHand,
|
|
hands: overrides.hands,
|
|
turnOrder: overrides.turnOrder,
|
|
activePlayerIndex: overrides.activePlayerIndex ?? 0,
|
|
phase: overrides.phase ?? "player_turns",
|
|
};
|
|
}
|
|
|
|
function playingHand(cards: Card[]): PlayerHand {
|
|
return { cards, status: "playing", result: null, resultReason: null };
|
|
}
|
|
|
|
function blackjackHand(cards: Card[]): PlayerHand {
|
|
return { cards, status: "blackjack", result: null, resultReason: null };
|
|
}
|
|
|
|
// ── 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("deals 2 cards to each player and dealer", () => {
|
|
const state = blackjackPlugin.createInitialState(["p1", "p2", "p3"]);
|
|
expect(state.hands["p1"]!.cards.length).toBe(2);
|
|
expect(state.hands["p2"]!.cards.length).toBe(2);
|
|
expect(state.hands["p3"]!.cards.length).toBe(2);
|
|
expect(state.dealerHand.length).toBe(2);
|
|
expect(state.turnOrder).toEqual(["p1", "p2", "p3"]);
|
|
});
|
|
|
|
it("removes dealt cards from deck", () => {
|
|
const state = blackjackPlugin.createInitialState(["p1", "p2"]);
|
|
// 52 - (2*2 players + 2 dealer) = 46
|
|
expect(state.deck.length).toBe(46);
|
|
});
|
|
|
|
it("works with a single player", () => {
|
|
const state = blackjackPlugin.createInitialState(["solo"]);
|
|
expect(state.hands["solo"]!.cards.length).toBe(2);
|
|
expect(state.turnOrder).toEqual(["solo"]);
|
|
});
|
|
});
|
|
|
|
// ── Turn order ──
|
|
|
|
describe("turn order", () => {
|
|
it("active player is the first non-blackjack player", () => {
|
|
const state = riggedState({
|
|
hands: {
|
|
"p1": blackjackHand([makeCard("A"), makeCard("K")]),
|
|
"p2": playingHand([makeCard("5"), makeCard("6")]),
|
|
"p3": playingHand([makeCard("7"), makeCard("8")]),
|
|
},
|
|
dealerHand: [makeCard("9"), makeCard("10")],
|
|
turnOrder: ["p1", "p2", "p3"],
|
|
activePlayerIndex: 1, // p1 skipped (blackjack)
|
|
});
|
|
|
|
// p1 is blackjack so active should be p2 (index 1)
|
|
const view = blackjackPlugin.getPlayerView(state, "p2") as BlackjackPlayerView;
|
|
expect(view.activePlayerId).toBe("p2");
|
|
expect(view.canAct).toBe(true);
|
|
});
|
|
|
|
it("advances to next player on stand", () => {
|
|
const state = riggedState({
|
|
hands: {
|
|
"p1": playingHand([makeCard("K"), makeCard("9")]),
|
|
"p2": playingHand([makeCard("5"), makeCard("6")]),
|
|
},
|
|
dealerHand: [makeCard("7"), makeCard("8")],
|
|
turnOrder: ["p1", "p2"],
|
|
activePlayerIndex: 0,
|
|
});
|
|
|
|
const result = blackjackPlugin.handleAction(state, { type: "stand" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.state.activePlayerIndex).toBe(1);
|
|
expect(result.state.phase).toBe("player_turns");
|
|
});
|
|
|
|
it("rejects action from non-active player", () => {
|
|
const state = riggedState({
|
|
hands: {
|
|
"p1": playingHand([makeCard("K"), makeCard("9")]),
|
|
"p2": playingHand([makeCard("5"), makeCard("6")]),
|
|
},
|
|
dealerHand: [makeCard("7"), makeCard("8")],
|
|
turnOrder: ["p1", "p2"],
|
|
activePlayerIndex: 0,
|
|
});
|
|
|
|
const result = blackjackPlugin.handleAction(state, { type: "hit" }, "p2");
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── handleAction: hit ──
|
|
|
|
describe("handleAction — hit", () => {
|
|
it("adds a card to active player's hand", () => {
|
|
const state = riggedState({
|
|
hands: {
|
|
"p1": playingHand([makeCard("5"), makeCard("6")]),
|
|
},
|
|
dealerHand: [makeCard("K"), makeCard("7")],
|
|
turnOrder: ["p1"],
|
|
activePlayerIndex: 0,
|
|
});
|
|
|
|
const result = blackjackPlugin.handleAction(state, { type: "hit" }, "p1");
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.state.hands["p1"]!.cards.length).toBe(3);
|
|
});
|
|
|
|
it("busts player and advances turn", () => {
|
|
const state = riggedState({
|
|
hands: {
|
|
"p1": playingHand([makeCard("K"), makeCard("Q")]),
|
|
"p2": playingHand([makeCard("5"), makeCard("6")]),
|
|
},
|
|
dealerHand: [makeCard("7"), makeCard("8")],
|
|
turnOrder: ["p1", "p2"],
|
|
activePlayerIndex: 0,
|
|
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.hands["p1"]!.status).toBe("bust");
|
|
expect(result.state.hands["p1"]!.result).toBe("lose");
|
|
expect(result.state.activePlayerIndex).toBe(1); // advanced to p2
|
|
});
|
|
|
|
it("auto-stands on 21 and advances turn", () => {
|
|
const state = riggedState({
|
|
hands: {
|
|
"p1": playingHand([makeCard("6"), makeCard("5")]),
|
|
"p2": playingHand([makeCard("7"), makeCard("8")]),
|
|
},
|
|
dealerHand: [makeCard("9"), makeCard("10")],
|
|
turnOrder: ["p1", "p2"],
|
|
activePlayerIndex: 0,
|
|
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.hands["p1"]!.status).toBe("stood");
|
|
expect(result.state.activePlayerIndex).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ── handleAction: stand ──
|
|
|
|
describe("handleAction — stand", () => {
|
|
it("resolves game when last player stands", () => {
|
|
const state = riggedState({
|
|
hands: {
|
|
"p1": playingHand([makeCard("K"), makeCard("9")]),
|
|
},
|
|
dealerHand: [makeCard("7"), makeCard("8")],
|
|
turnOrder: ["p1"],
|
|
activePlayerIndex: 0,
|
|
deck: [makeCard("2")], // dealer: 15 + 2 = 17 → stands
|
|
});
|
|
|
|
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.hands["p1"]!.result).toBe("win");
|
|
});
|
|
|
|
it("dealer busts — all standing players win", () => {
|
|
const state = riggedState({
|
|
hands: {
|
|
"p1": playingHand([makeCard("K"), makeCard("8")]),
|
|
"p2": { ...playingHand([makeCard("7"), makeCard("9")]), status: "stood" as const },
|
|
},
|
|
dealerHand: [makeCard("6"), makeCard("9")],
|
|
turnOrder: ["p1", "p2"],
|
|
activePlayerIndex: 0,
|
|
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.hands["p1"]!.result).toBe("win");
|
|
expect(result.state.hands["p2"]!.result).toBe("win");
|
|
});
|
|
|
|
it("mixed results — one wins, one loses, one pushes", () => {
|
|
const state = riggedState({
|
|
hands: {
|
|
"p1": { ...playingHand([makeCard("K"), makeCard("9")]), status: "stood" as const }, // 19
|
|
"p2": { ...playingHand([makeCard("7"), makeCard("8")]), status: "stood" as const }, // 15
|
|
"p3": playingHand([makeCard("Q"), makeCard("8")]), // 18 — active
|
|
},
|
|
dealerHand: [makeCard("K"), makeCard("8")], // 18 → stands
|
|
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.hands["p1"]!.result).toBe("win"); // 19 > 18
|
|
expect(result.state.hands["p2"]!.result).toBe("lose"); // 15 < 18
|
|
expect(result.state.hands["p3"]!.result).toBe("push"); // 18 = 18
|
|
});
|
|
});
|
|
|
|
// ── getPlayerView ──
|
|
|
|
describe("getPlayerView", () => {
|
|
it("hides dealer hole card during player turns", () => {
|
|
const state = riggedState({
|
|
hands: { "p1": 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({
|
|
hands: { "p1": { ...playingHand([makeCard("K"), makeCard("5")]), status: "stood" as const, result: "lose" as const, resultReason: "Dealer wins" } },
|
|
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({
|
|
hands: {
|
|
"p1": playingHand([makeCard("5"), makeCard("6")]),
|
|
"p2": playingHand([makeCard("7"), makeCard("8")]),
|
|
},
|
|
dealerHand: [makeCard("9"), makeCard("10")],
|
|
turnOrder: ["p1", "p2"],
|
|
activePlayerIndex: 0,
|
|
});
|
|
|
|
const view1 = blackjackPlugin.getPlayerView(state, "p1") as BlackjackPlayerView;
|
|
expect(view1.canAct).toBe(true);
|
|
expect(view1.myPlayerId).toBe("p1");
|
|
|
|
const view2 = blackjackPlugin.getPlayerView(state, "p2") as BlackjackPlayerView;
|
|
expect(view2.canAct).toBe(false);
|
|
});
|
|
|
|
it("includes all player hands in view", () => {
|
|
const state = riggedState({
|
|
hands: {
|
|
"p1": playingHand([makeCard("5"), makeCard("6")]),
|
|
"p2": playingHand([makeCard("7"), makeCard("8")]),
|
|
},
|
|
dealerHand: [makeCard("9"), makeCard("10")],
|
|
turnOrder: ["p1", "p2"],
|
|
});
|
|
|
|
const view = blackjackPlugin.getPlayerView(state, "p1") as BlackjackPlayerView;
|
|
expect(Object.keys(view.hands).length).toBe(2);
|
|
expect(view.hands["p1"]!.value).toBe(11);
|
|
expect(view.hands["p2"]!.value).toBe(15);
|
|
});
|
|
});
|
|
|
|
// ── getSpectatorView ──
|
|
|
|
describe("getSpectatorView", () => {
|
|
it("hides dealer hole card during player turns", () => {
|
|
const state = riggedState({
|
|
hands: { "p1": 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 player hands", () => {
|
|
const state = riggedState({
|
|
hands: {
|
|
"p1": playingHand([makeCard("K"), makeCard("5")]),
|
|
"p2": playingHand([makeCard("7"), makeCard("8")]),
|
|
},
|
|
dealerHand: [makeCard("9"), makeCard("10")],
|
|
turnOrder: ["p1", "p2"],
|
|
});
|
|
|
|
const view = blackjackPlugin.getSpectatorView(state) as BlackjackSpectatorView;
|
|
expect(Object.keys(view.hands).length).toBe(2);
|
|
expect(view.turnOrder).toEqual(["p1", "p2"]);
|
|
});
|
|
});
|
|
|
|
// ── isGameOver ──
|
|
|
|
describe("isGameOver", () => {
|
|
it("returns null when game is in progress", () => {
|
|
const state = riggedState({
|
|
hands: { "p1": playingHand([makeCard("5"), makeCard("6")]) },
|
|
dealerHand: [makeCard("7"), makeCard("8")],
|
|
turnOrder: ["p1"],
|
|
});
|
|
expect(blackjackPlugin.isGameOver!(state)).toBeNull();
|
|
});
|
|
|
|
it("returns per-player payouts for mixed results", () => {
|
|
const state = riggedState({
|
|
hands: {
|
|
"p1": { cards: [makeCard("A"), makeCard("K")], status: "blackjack" as const, result: "blackjack" as const, resultReason: "Blackjack!" },
|
|
"p2": { cards: [makeCard("K"), makeCard("9")], status: "stood" as const, result: "win" as const, resultReason: "Higher hand" },
|
|
"p3": { cards: [makeCard("7"), makeCard("8")], status: "stood" as const, result: "lose" as const, resultReason: "Lower hand" },
|
|
"p4": { cards: [makeCard("K"), makeCard("8")], status: "stood" as const, result: "push" as const, resultReason: "Push" },
|
|
},
|
|
dealerHand: [makeCard("K"), makeCard("8")],
|
|
turnOrder: ["p1", "p2", "p3", "p4"],
|
|
phase: "resolved",
|
|
activePlayerIndex: -1,
|
|
});
|
|
|
|
const result = blackjackPlugin.isGameOver!(state)!;
|
|
expect(result).not.toBeNull();
|
|
expect(result.payouts?.["p1"]).toBe(2.5); // blackjack
|
|
expect(result.payouts?.["p2"]).toBe(2); // win
|
|
expect(result.payouts?.["p3"]).toBeUndefined(); // loss
|
|
expect(result.payouts?.["p4"]).toBe(1); // push
|
|
});
|
|
|
|
it("generates a summary reason", () => {
|
|
const state = riggedState({
|
|
hands: {
|
|
"p1": { cards: [makeCard("K"), makeCard("9")], status: "stood" as const, result: "win" as const, resultReason: "Win" },
|
|
"p2": { cards: [makeCard("7"), makeCard("8")], status: "bust" as const, result: "lose" as const, resultReason: "Bust" },
|
|
},
|
|
dealerHand: [makeCard("K"), makeCard("8")],
|
|
turnOrder: ["p1", "p2"],
|
|
phase: "resolved",
|
|
activePlayerIndex: -1,
|
|
});
|
|
|
|
const result = blackjackPlugin.isGameOver!(state)!;
|
|
expect(result.reason).toContain("1 win");
|
|
expect(result.reason).toContain("1 loss");
|
|
});
|
|
});
|
|
|
|
// ── onPlayerDisconnect ──
|
|
|
|
describe("onPlayerDisconnect", () => {
|
|
it("marks disconnected player as bust and advances turn", () => {
|
|
const state = riggedState({
|
|
hands: {
|
|
"p1": playingHand([makeCard("5"), makeCard("6")]),
|
|
"p2": playingHand([makeCard("7"), makeCard("8")]),
|
|
},
|
|
dealerHand: [makeCard("9"), makeCard("10")],
|
|
turnOrder: ["p1", "p2"],
|
|
activePlayerIndex: 0,
|
|
});
|
|
|
|
const newState = blackjackPlugin.onPlayerDisconnect!(state, "p1");
|
|
expect(newState.hands["p1"]!.status).toBe("bust");
|
|
expect(newState.hands["p1"]!.result).toBe("lose");
|
|
expect(newState.activePlayerIndex).toBe(1); // advanced to p2
|
|
});
|
|
|
|
it("resolves game if disconnected player was last active", () => {
|
|
const state = riggedState({
|
|
hands: {
|
|
"p1": playingHand([makeCard("5"), makeCard("6")]),
|
|
},
|
|
dealerHand: [makeCard("9"), makeCard("10")],
|
|
turnOrder: ["p1"],
|
|
activePlayerIndex: 0,
|
|
});
|
|
|
|
const newState = blackjackPlugin.onPlayerDisconnect!(state, "p1");
|
|
expect(newState.phase).toBe("resolved");
|
|
expect(newState.hands["p1"]!.result).toBe("lose");
|
|
});
|
|
|
|
it("does nothing for already-resolved game", () => {
|
|
const state = riggedState({
|
|
hands: {
|
|
"p1": { cards: [makeCard("K"), makeCard("9")], status: "stood" as const, result: "win" as const, resultReason: "Win" },
|
|
},
|
|
dealerHand: [makeCard("7"), makeCard("8")],
|
|
turnOrder: ["p1"],
|
|
phase: "resolved",
|
|
activePlayerIndex: -1,
|
|
});
|
|
|
|
const newState = blackjackPlugin.onPlayerDisconnect!(state, "p1");
|
|
expect(newState.hands["p1"]!.result).toBe("win"); // unchanged
|
|
});
|
|
});
|