feat(games): implement blackjack game plugin with manual start and custom payouts
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>
This commit is contained in:
syntaxbullet
2026-04-05 18:48:25 +02:00
parent f368da9e73
commit ef78a85b9c
67 changed files with 16234 additions and 20 deletions

View File

@@ -0,0 +1,471 @@
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
});
});