diff --git a/packages/server/src/games/__tests__/game-manager-bingo.test.ts b/packages/server/src/games/__tests__/game-manager-bingo.test.ts new file mode 100644 index 0000000..4ea1d81 --- /dev/null +++ b/packages/server/src/games/__tests__/game-manager-bingo.test.ts @@ -0,0 +1,270 @@ +import { describe, it, expect, beforeEach } from "bun:test" +import { GameManager } from "../game-manager" + +describe("Bingo", () => { + let gm: GameManager + + beforeEach(() => { + gm = new GameManager() + }) + + describe("generateBingoCards", () => { + it("should create a 16-square card for each player", () => { + gm.generateBingoCards(["p1", "p2"]) + const card1 = gm.getBingoCard("p1") + const card2 = gm.getBingoCard("p2") + expect(card1).not.toBeNull() + expect(card1!.squares).toHaveLength(16) + expect(card1!.hasBingo).toBe(false) + expect(card2).not.toBeNull() + expect(card2!.squares).toHaveLength(16) + }) + + it("should return null for unknown player", () => { + expect(gm.getBingoCard("unknown")).toBeNull() + }) + }) + + describe("tapBingoSquare", () => { + beforeEach(() => { + gm.generateBingoCards(["p1"]) + }) + + it("should mark a square as tapped", () => { + const card = gm.getBingoCard("p1")! + const tropeId = card.squares[0]!.tropeId + const result = gm.tapBingoSquare("p1", tropeId) + expect(result).toHaveProperty("success", true) + expect(card.squares[0]!.tapped).toBe(true) + }) + + it("should be tap-only (not toggle)", () => { + const card = gm.getBingoCard("p1")! + const tropeId = card.squares[0]!.tropeId + gm.tapBingoSquare("p1", tropeId) + expect(card.squares[0]!.tapped).toBe(true) + // Tap again — should stay tapped + const result = gm.tapBingoSquare("p1", tropeId) + expect(result).toHaveProperty("success", true) + expect(card.squares[0]!.tapped).toBe(true) + }) + + it("should error for unknown player", () => { + const result = gm.tapBingoSquare("unknown", "trope1") + expect(result).toHaveProperty("error") + }) + + it("should error for trope not on card", () => { + const result = gm.tapBingoSquare("p1", "nonexistent-trope") + expect(result).toHaveProperty("error") + }) + }) + + describe("bingo detection", () => { + beforeEach(() => { + gm.generateBingoCards(["p1"]) + }) + + it("should detect a completed row", () => { + const card = gm.getBingoCard("p1")! + // Tap first row (indices 0-3) + for (let i = 0; i < 4; i++) { + const result = gm.tapBingoSquare("p1", card.squares[i]!.tropeId) + if (i < 3) { + expect((result as any).hasBingo).toBe(false) + } else { + expect((result as any).hasBingo).toBe(true) + expect((result as any).isNewBingo).toBe(true) + } + } + }) + + it("should detect a completed column", () => { + const card = gm.getBingoCard("p1")! + // Tap first column (indices 0, 4, 8, 12) + for (const i of [0, 4, 8, 12]) { + gm.tapBingoSquare("p1", card.squares[i]!.tropeId) + } + expect(card.hasBingo).toBe(true) + }) + + it("should detect a completed diagonal", () => { + const card = gm.getBingoCard("p1")! + // Tap main diagonal (indices 0, 5, 10, 15) + for (const i of [0, 5, 10, 15]) { + gm.tapBingoSquare("p1", card.squares[i]!.tropeId) + } + expect(card.hasBingo).toBe(true) + }) + + it("should detect anti-diagonal", () => { + const card = gm.getBingoCard("p1")! + // Tap anti-diagonal (indices 3, 6, 9, 12) + for (const i of [3, 6, 9, 12]) { + gm.tapBingoSquare("p1", card.squares[i]!.tropeId) + } + expect(card.hasBingo).toBe(true) + }) + }) + + describe("bingo completion flow", () => { + beforeEach(() => { + gm.generateBingoCards(["p1"]) + }) + + it("should NOT move card to completedBingoCards on bingo detection (only on redraw)", () => { + const card = gm.getBingoCard("p1")! + // Complete first row — sets hasBingo but does NOT move card + for (let i = 0; i < 4; i++) { + gm.tapBingoSquare("p1", card.squares[i]!.tropeId) + } + expect(card.hasBingo).toBe(true) + expect(gm.getCompletedBingoCards()).toHaveLength(0) + }) + + it("should move card to completedBingoCards on requestNewBingoCard", () => { + const card = gm.getBingoCard("p1")! + // Complete first row + for (let i = 0; i < 4; i++) { + gm.tapBingoSquare("p1", card.squares[i]!.tropeId) + } + // Redraw moves the completed card + gm.requestNewBingoCard("p1", "Player 1") + const completed = gm.getCompletedBingoCards() + expect(completed).toHaveLength(1) + expect(completed[0]!.playerId).toBe("p1") + expect(completed[0]!.displayName).toBe("Player 1") + expect(completed[0]!.card.hasBingo).toBe(true) + expect(completed[0]!.completedAt).toBeTruthy() + }) + }) + + describe("requestNewBingoCard", () => { + beforeEach(() => { + gm.generateBingoCards(["p1"]) + }) + + it("should error if card has no bingo", () => { + const result = gm.requestNewBingoCard("p1", "Player 1") + expect(result).toHaveProperty("error") + }) + + it("should generate a fresh card after bingo", () => { + const card = gm.getBingoCard("p1")! + const originalTropes = card.squares.map((s) => s.tropeId) + // Complete first row + for (let i = 0; i < 4; i++) { + gm.tapBingoSquare("p1", card.squares[i]!.tropeId) + } + const result = gm.requestNewBingoCard("p1", "Player 1") + expect(result).toHaveProperty("success", true) + const newCard = gm.getBingoCard("p1")! + expect(newCard.hasBingo).toBe(false) + expect(newCard.squares.every((s) => !s.tapped)).toBe(true) + expect(newCard.squares).toHaveLength(16) + }) + }) + + describe("getBingoScore — accumulation across cards", () => { + beforeEach(() => { + gm.generateBingoCards(["p1"]) + }) + + it("should score tapped squares on active card", () => { + const card = gm.getBingoCard("p1")! + gm.tapBingoSquare("p1", card.squares[0]!.tropeId) + gm.tapBingoSquare("p1", card.squares[1]!.tropeId) + // 2 tapped squares * 2 points = 4 + expect(gm.getBingoScore("p1")).toBe(4) + }) + + it("should include bingo bonus on completed card", () => { + const card = gm.getBingoCard("p1")! + // Complete first row (4 squares) + for (let i = 0; i < 4; i++) { + gm.tapBingoSquare("p1", card.squares[i]!.tropeId) + } + // 4 tapped * 2 = 8, plus 10 bonus = 18 + expect(gm.getBingoScore("p1")).toBe(18) + }) + + it("should accumulate scores across completed + new card", () => { + const card = gm.getBingoCard("p1")! + // Complete first row (4 squares) — triggers completion + for (let i = 0; i < 4; i++) { + gm.tapBingoSquare("p1", card.squares[i]!.tropeId) + } + // Request new card + gm.requestNewBingoCard("p1", "Player 1") + const newCard = gm.getBingoCard("p1")! + // Tap 2 squares on new card + gm.tapBingoSquare("p1", newCard.squares[0]!.tropeId) + gm.tapBingoSquare("p1", newCard.squares[1]!.tropeId) + // Old card: 4 tapped * 2 = 8 + 10 bonus = 18 + // New card: 2 tapped * 2 = 4 + // Total: 22 + expect(gm.getBingoScore("p1")).toBe(22) + }) + + it("should return 0 for unknown player", () => { + expect(gm.getBingoScore("unknown")).toBe(0) + }) + }) + + describe("addBingoAnnouncement — multiple per player", () => { + beforeEach(() => { + gm.generateBingoCards(["p1"]) + }) + + it("should announce first bingo", () => { + const card = gm.getBingoCard("p1")! + for (let i = 0; i < 4; i++) { + gm.tapBingoSquare("p1", card.squares[i]!.tropeId) + } + const isNew = gm.addBingoAnnouncement("p1", "Player 1") + expect(isNew).toBe(true) + }) + + it("should not re-announce same bingo", () => { + const card = gm.getBingoCard("p1")! + for (let i = 0; i < 4; i++) { + gm.tapBingoSquare("p1", card.squares[i]!.tropeId) + } + gm.addBingoAnnouncement("p1", "Player 1") + const isNew = gm.addBingoAnnouncement("p1", "Player 1") + expect(isNew).toBe(false) + }) + + it("should announce second bingo after redraw", () => { + const card = gm.getBingoCard("p1")! + for (let i = 0; i < 4; i++) { + gm.tapBingoSquare("p1", card.squares[i]!.tropeId) + } + gm.addBingoAnnouncement("p1", "Player 1") + // Redraw + gm.requestNewBingoCard("p1", "Player 1") + const newCard = gm.getBingoCard("p1")! + // Complete first row of new card + for (let i = 0; i < 4; i++) { + gm.tapBingoSquare("p1", newCard.squares[i]!.tropeId) + } + const isNew = gm.addBingoAnnouncement("p1", "Player 1") + expect(isNew).toBe(true) + expect(gm.getBingoAnnouncements()).toHaveLength(2) + }) + }) + + describe("game state includes completedBingoCards", () => { + it("should include completedBingoCards in player game state", () => { + gm.generateBingoCards(["p1"]) + const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Player 1" }) + expect(state.completedBingoCards).toEqual([]) + }) + + it("should include completedBingoCards in display game state", () => { + gm.generateBingoCards(["p1"]) + const state = gm.getGameStateForDisplay(["p1"], { p1: "Player 1" }) + expect(state.completedBingoCards).toEqual([]) + }) + }) +})