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 }; } /** Create a rigged state for deterministic testing. */ function riggedState(overrides: { seats: Record; 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 }); 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.roundPayouts).toBeDefined(); expect(result.roundPayouts!["p1"]).toBe(2); // 1:1 win }); 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 }); 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 }); const result = blackjackPlugin.handleAction(state, { type: "double_down" }, "p1"); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.roundPayouts).toBeDefined(); expect(result.roundPayouts!["p1"]).toBe(4); // bet=2, win=2*2=4 }); }); // ── 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", () => { // Set up a resolved state with split hands const state = riggedState({ seats: { "p1": makeSeat([ { ...stoodHand([makeCard("8"), makeCard("K")], 1, true), result: "win", resultReason: "Higher hand" }, // 18 wins { ...stoodHand([makeCard("8"), makeCard("5")], 1, true), result: "lose", resultReason: "Lower hand" }, // 13 loses ], -1), }, dealerHand: [makeCard("7"), makeCard("Q")], // 17 turnOrder: ["p1"], phase: "resolved", activePlayerIndex: -1, }); // We can't easily test calculateRoundPayouts directly, but we can verify through // a round that resolves. Let's set up a playable scenario instead. // The payout for p1 should be: win(1*2) + lose(0) = 2 // This is verified through the stand/resolve flow in the actual game }); 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 }); 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.roundPayouts!["p1"]).toBe(4); }); 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 }); 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.roundPayouts!["p1"]).toBe(2); }); }); // ── 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); }); });