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
});
});

View File

@@ -0,0 +1,395 @@
import type { GamePlugin, GameResult, GameOverResult } from "../types";
import type {
BlackjackState, BlackjackAction, BlackjackPlayerView,
BlackjackSpectatorView, PlayerHandView, Card, Suit, Rank, PlayerHand,
} from "./blackjack.types";
// ── Card helpers ──
const SUITS: Suit[] = ["hearts", "diamonds", "clubs", "spades"];
const RANKS: Rank[] = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"];
function createDeck(): Card[] {
const deck: Card[] = [];
for (const suit of SUITS) {
for (const rank of RANKS) {
deck.push({ suit, rank });
}
}
return deck;
}
function shuffleDeck(deck: Card[]): Card[] {
const shuffled = [...deck];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i]!, shuffled[j]!] = [shuffled[j]!, shuffled[i]!];
}
return shuffled;
}
function cardValue(rank: Rank): number {
if (rank === "A") return 11;
if (["K", "Q", "J"].includes(rank)) return 10;
return parseInt(rank);
}
/** Calculate the best hand value (accounting for soft aces). */
export function handValue(hand: Card[]): number {
let total = 0;
let aces = 0;
for (const card of hand) {
total += cardValue(card.rank);
if (card.rank === "A") aces++;
}
while (total > 21 && aces > 0) {
total -= 10;
aces--;
}
return total;
}
function isBust(hand: Card[]): boolean {
return handValue(hand) > 21;
}
function isNaturalBlackjack(hand: Card[]): boolean {
return hand.length === 2 && handValue(hand) === 21;
}
function drawCard(deck: Card[]): [Card, Card[]] {
return [deck[0]!, deck.slice(1)];
}
/** Play dealer hand: hit until 17+. */
function playDealerHand(dealerHand: Card[], deck: Card[]): { dealerHand: Card[]; deck: Card[] } {
let hand = [...dealerHand];
let remaining = [...deck];
while (handValue(hand) < 17) {
const [card, rest] = drawCard(remaining);
hand = [...hand, card];
remaining = rest;
}
return { dealerHand: hand, deck: remaining };
}
/** Find the next player who still needs to act (skip blackjack/bust/stood). */
function findNextActiveIndex(state: BlackjackState, afterIndex: number): number {
for (let i = afterIndex + 1; i < state.turnOrder.length; i++) {
const hand = state.hands[state.turnOrder[i]!];
if (hand && hand.status === "playing") return i;
}
return -1; // no more active players
}
/** Resolve all hands against the dealer, returning updated hands. */
function resolveAllHands(
hands: Record<string, PlayerHand>,
dealerHand: Card[],
): Record<string, PlayerHand> {
const dealerVal = handValue(dealerHand);
const dealerBust = isBust(dealerHand);
const resolved: Record<string, PlayerHand> = {};
for (const [id, hand] of Object.entries(hands)) {
// Already resolved (natural blackjack checked at deal time, bust on hit)
if (hand.result) {
resolved[id] = hand;
continue;
}
const playerVal = handValue(hand.cards);
if (hand.status === "bust") {
resolved[id] = { ...hand, result: "lose", resultReason: "Player busts" };
} else if (hand.status === "blackjack") {
// Natural blackjack vs dealer blackjack
if (isNaturalBlackjack(dealerHand)) {
resolved[id] = { ...hand, result: "push", resultReason: "Both have Blackjack" };
} else {
resolved[id] = { ...hand, result: "blackjack", resultReason: "Blackjack!" };
}
} else if (dealerBust) {
resolved[id] = { ...hand, result: "win", resultReason: "Dealer busts" };
} else if (playerVal > dealerVal) {
resolved[id] = { ...hand, result: "win", resultReason: "Higher hand" };
} else if (playerVal < dealerVal) {
resolved[id] = { ...hand, result: "lose", resultReason: "Dealer has higher hand" };
} else {
resolved[id] = { ...hand, result: "push", resultReason: "Push" };
}
}
return resolved;
}
/** Build the dealer hand for views — hole card hidden during player_turns. */
function dealerVisibleHand(state: BlackjackState): Card[] {
if (state.phase === "resolved") return state.dealerHand;
return [state.dealerHand[0]!, { suit: "spades", rank: "?" as Rank }];
}
/** Convert internal PlayerHand to a view. */
function toHandView(hand: PlayerHand): PlayerHandView {
return {
cards: hand.cards,
value: handValue(hand.cards),
status: hand.status,
result: hand.result,
resultReason: hand.resultReason,
};
}
/** Transition to dealer turn + resolve, or just resolve if all busted/blackjacked. */
function finishPlayerTurns(state: BlackjackState): BlackjackState {
// Check if any player stood (dealer needs to play)
const anyStood = Object.values(state.hands).some(h => h.status === "stood");
let dealerHand = state.dealerHand;
let deck = state.deck;
if (anyStood) {
const dealer = playDealerHand(dealerHand, deck);
dealerHand = dealer.dealerHand;
deck = dealer.deck;
}
const resolvedHands = resolveAllHands(state.hands, dealerHand);
return {
...state,
deck,
dealerHand,
hands: resolvedHands,
activePlayerIndex: -1,
phase: "resolved",
};
}
// ── Plugin ──
export const blackjackPlugin: GamePlugin<BlackjackState, BlackjackAction> = {
slug: "blackjack",
name: "Blackjack",
minPlayers: 1,
maxPlayers: 6,
manualStart: true,
createInitialState(players: string[], _options?: Record<string, unknown>): BlackjackState {
let deck = shuffleDeck(createDeck());
const turnOrder = [...players];
const hands: Record<string, PlayerHand> = {};
// Deal 2 cards to each player
for (const pid of turnOrder) {
const cards: Card[] = [];
let card: Card;
[card, deck] = drawCard(deck); cards.push(card);
[card, deck] = drawCard(deck); cards.push(card);
hands[pid] = {
cards,
status: isNaturalBlackjack(cards) ? "blackjack" : "playing",
result: null,
resultReason: null,
};
}
// Deal 2 cards to dealer
const dealerHand: Card[] = [];
let card: Card;
[card, deck] = drawCard(deck); dealerHand.push(card);
[card, deck] = drawCard(deck); dealerHand.push(card);
let state: BlackjackState = {
deck,
dealerHand,
hands,
turnOrder,
activePlayerIndex: 0,
phase: "player_turns",
};
// Skip to first player that needs to act (skip natural blackjacks)
const firstActive = findNextActiveIndex(state, -1);
state.activePlayerIndex = firstActive;
// If no players need to act (all natural blackjacks), go straight to resolution
if (firstActive === -1) {
return finishPlayerTurns(state);
}
return state;
},
handleAction(state: BlackjackState, action: BlackjackAction, playerId: string): GameResult<BlackjackState> {
if (state.phase === "resolved") return { ok: false, error: "Game is already over" };
const activeId = state.turnOrder[state.activePlayerIndex];
if (playerId !== activeId) return { ok: false, error: "It's not your turn" };
const hand = state.hands[playerId];
if (!hand || hand.status !== "playing") return { ok: false, error: "You cannot act" };
switch (action.type) {
case "hit": {
const [card, remaining] = drawCard(state.deck);
const newCards = [...hand.cards, card];
const bust = isBust(newCards);
const got21 = handValue(newCards) === 21;
const newHand: PlayerHand = {
...hand,
cards: newCards,
status: bust ? "bust" : got21 ? "stood" : "playing",
result: bust ? "lose" : null,
resultReason: bust ? "Player busts" : null,
};
const newHands = { ...state.hands, [playerId]: newHand };
let newState: BlackjackState = { ...state, deck: remaining, hands: newHands };
// Advance turn if bust or auto-stood on 21
if (bust || got21) {
const nextIdx = findNextActiveIndex(newState, state.activePlayerIndex);
if (nextIdx === -1) {
return { ok: true, state: finishPlayerTurns(newState) };
}
newState = { ...newState, activePlayerIndex: nextIdx };
}
return { ok: true, state: newState };
}
case "stand": {
const newHand: PlayerHand = { ...hand, status: "stood" };
const newHands = { ...state.hands, [playerId]: newHand };
let newState: BlackjackState = { ...state, hands: newHands };
const nextIdx = findNextActiveIndex(newState, state.activePlayerIndex);
if (nextIdx === -1) {
return { ok: true, state: finishPlayerTurns(newState) };
}
newState = { ...newState, activePlayerIndex: nextIdx };
return { ok: true, state: newState };
}
default:
return { ok: false, error: "Unknown action type" };
}
},
getPlayerView(state: BlackjackState, playerId: string): BlackjackPlayerView {
const visibleDealer = dealerVisibleHand(state);
const handsView: Record<string, PlayerHandView> = {};
for (const [id, hand] of Object.entries(state.hands)) {
handsView[id] = toHandView(hand);
}
const activeId = state.activePlayerIndex >= 0 ? state.turnOrder[state.activePlayerIndex] ?? null : null;
return {
dealerHand: visibleDealer,
dealerVisibleValue: cardValue(state.dealerHand[0]!.rank),
dealerFullValue: state.phase === "resolved" ? handValue(state.dealerHand) : null,
hands: handsView,
turnOrder: state.turnOrder,
activePlayerId: activeId,
myPlayerId: playerId,
phase: state.phase,
canAct: activeId === playerId && state.phase === "player_turns",
};
},
getSpectatorView(state: BlackjackState): BlackjackSpectatorView {
const visibleDealer = dealerVisibleHand(state);
const handsView: Record<string, PlayerHandView> = {};
for (const [id, hand] of Object.entries(state.hands)) {
handsView[id] = toHandView(hand);
}
const activeId = state.activePlayerIndex >= 0 ? state.turnOrder[state.activePlayerIndex] ?? null : null;
return {
dealerHand: visibleDealer,
dealerVisibleValue: cardValue(state.dealerHand[0]!.rank),
dealerFullValue: state.phase === "resolved" ? handValue(state.dealerHand) : null,
hands: handsView,
turnOrder: state.turnOrder,
activePlayerId: activeId,
phase: state.phase,
};
},
isGameOver(state: BlackjackState): GameOverResult | null {
if (state.phase !== "resolved") return null;
const payouts: Record<string, number> = {};
for (const [id, hand] of Object.entries(state.hands)) {
switch (hand.result) {
case "blackjack":
payouts[id] = 2.5; // 3:2 payout
break;
case "win":
payouts[id] = 2; // 1:1 payout
break;
case "push":
payouts[id] = 1; // refund
break;
// "lose" / null → no payout
}
}
// Find a "winner" for the room summary — pick any winning player, or null
const winner = Object.entries(state.hands).find(
([, h]) => h.result === "blackjack" || h.result === "win"
)?.[0] ?? null;
const wins = Object.values(state.hands).filter(h => h.result === "win" || h.result === "blackjack").length;
const losses = Object.values(state.hands).filter(h => h.result === "lose").length;
const pushes = Object.values(state.hands).filter(h => h.result === "push").length;
const parts: string[] = [];
if (wins > 0) parts.push(`${wins} win${wins > 1 ? "s" : ""}`);
if (losses > 0) parts.push(`${losses} loss${losses > 1 ? "es" : ""}`);
if (pushes > 0) parts.push(`${pushes} push${pushes > 1 ? "es" : ""}`);
return {
winner,
reason: parts.join(", ") || "Game over",
payouts,
};
},
onPlayerDisconnect(state: BlackjackState, playerId: string): BlackjackState {
if (state.phase === "resolved") return state;
const hand = state.hands[playerId];
if (!hand || hand.status !== "playing") return state;
// Mark disconnected player as bust
const newHands = {
...state.hands,
[playerId]: { ...hand, status: "bust" as const, result: "lose" as const, resultReason: "Disconnected" },
};
let newState: BlackjackState = { ...state, hands: newHands };
// Check if the disconnected player was the active player
const activeId = state.turnOrder[state.activePlayerIndex];
if (activeId === playerId) {
const nextIdx = findNextActiveIndex(newState, state.activePlayerIndex);
if (nextIdx === -1) {
return finishPlayerTurns(newState);
}
newState = { ...newState, activePlayerIndex: nextIdx };
}
// Check if all players are now done
const anyPlaying = Object.values(newState.hands).some(h => h.status === "playing");
if (!anyPlaying && newState.phase === "player_turns") {
return finishPlayerTurns(newState);
}
return newState;
},
};

View File

@@ -0,0 +1,65 @@
export type Suit = "hearts" | "diamonds" | "clubs" | "spades";
export type Rank = "A" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10" | "J" | "Q" | "K";
export interface Card {
suit: Suit;
rank: Rank;
}
// ── Per-player hand state ──
export interface PlayerHand {
cards: Card[];
status: "playing" | "stood" | "bust" | "blackjack";
result: "win" | "blackjack" | "push" | "lose" | null;
resultReason: string | null;
}
// ── Game state ──
export interface BlackjackState {
deck: Card[];
dealerHand: Card[];
hands: Record<string, PlayerHand>;
turnOrder: string[];
activePlayerIndex: number; // index into turnOrder, -1 when no active player
phase: "player_turns" | "resolved";
}
// ── Actions ──
export type BlackjackAction =
| { type: "hit" }
| { type: "stand" };
// ── Views ──
export interface PlayerHandView {
cards: Card[];
value: number;
status: "playing" | "stood" | "bust" | "blackjack";
result: "win" | "blackjack" | "push" | "lose" | null;
resultReason: string | null;
}
export interface BlackjackPlayerView {
dealerHand: Card[];
dealerVisibleValue: number;
dealerFullValue: number | null;
hands: Record<string, PlayerHandView>;
turnOrder: string[];
activePlayerId: string | null;
myPlayerId: string;
phase: "player_turns" | "resolved";
canAct: boolean;
}
export interface BlackjackSpectatorView {
dealerHand: Card[];
dealerVisibleValue: number;
dealerFullValue: number | null;
hands: Record<string, PlayerHandView>;
turnOrder: string[];
activePlayerId: string | null;
phase: "player_turns" | "resolved";
}

View File

@@ -3,6 +3,8 @@ export interface GamePlugin<TState = unknown, TAction = unknown> {
name: string;
minPlayers: number;
maxPlayers: number;
/** If true, the host must explicitly start the game instead of auto-starting when full. */
manualStart?: boolean;
createInitialState(players: string[], options?: Record<string, unknown>): TState;
handleAction(state: TState, action: TAction, playerId: string): GameResult<TState>;
@@ -20,4 +22,11 @@ export type GameResult<TState> =
export type GameOverResult = {
winner: string | null;
reason: string;
/**
* Per-player payout overrides as multipliers of betAmount.
* When set, settleBets uses these instead of default pot logic.
* e.g. { "player123": 2 } means player123 receives betAmount * 2.
* An empty object means no payouts (house wins, bets forfeit).
*/
payouts?: Record<string, number>;
};