add bingo completion tests: tap-only, detection, scoring, redraw, announcements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
270
packages/server/src/games/__tests__/game-manager-bingo.test.ts
Normal file
270
packages/server/src/games/__tests__/game-manager-bingo.test.ts
Normal file
@@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user