add bingo logic to GameManager with tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import type { Prediction, GameState, Lineup, JuryRound, JuryResult } from "@celebrate-esc/shared"
|
||||
import lineupData from "../../data/esc-2025.json"
|
||||
import scoringConfig from "../../data/scoring.json"
|
||||
import tropesData from "../../data/bingo-tropes.json"
|
||||
|
||||
const tropes: { id: string; label: string }[] = tropesData
|
||||
|
||||
const lineup: Lineup = lineupData as Lineup
|
||||
const countryCodes = new Set(lineup.entries.map((e) => e.country.code))
|
||||
@@ -149,6 +152,76 @@ export class GameManager {
|
||||
return this.juryScores.get(playerId) ?? 0
|
||||
}
|
||||
|
||||
// ─── Bingo ──────────────────────────────────────────────────────
|
||||
|
||||
private bingoCards = new Map<string, {
|
||||
squares: { tropeId: string; label: string; tapped: boolean }[]
|
||||
hasBingo: boolean
|
||||
}>()
|
||||
private bingoAnnouncements: { playerId: string; displayName: string }[] = []
|
||||
private announcedBingo = new Set<string>()
|
||||
|
||||
generateBingoCards(playerIds: string[]): void {
|
||||
for (const playerId of playerIds) {
|
||||
const shuffled = [...tropes].sort(() => Math.random() - 0.5)
|
||||
const selected = shuffled.slice(0, 16)
|
||||
this.bingoCards.set(playerId, {
|
||||
squares: selected.map((t) => ({
|
||||
tropeId: t.id,
|
||||
label: t.label,
|
||||
tapped: false,
|
||||
})),
|
||||
hasBingo: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
getBingoCard(playerId: string): { squares: { tropeId: string; label: string; tapped: boolean }[]; hasBingo: boolean } | null {
|
||||
return this.bingoCards.get(playerId) ?? null
|
||||
}
|
||||
|
||||
tapBingoSquare(playerId: string, tropeId: string): { success: true; hasBingo: boolean } | { error: string } {
|
||||
const card = this.bingoCards.get(playerId)
|
||||
if (!card) return { error: "No bingo card found" }
|
||||
const square = card.squares.find((s) => s.tropeId === tropeId)
|
||||
if (!square) return { error: "Trope not on your card" }
|
||||
square.tapped = !square.tapped
|
||||
card.hasBingo = this.checkBingo(card.squares)
|
||||
return { success: true, hasBingo: card.hasBingo }
|
||||
}
|
||||
|
||||
private checkBingo(squares: { tapped: boolean }[]): boolean {
|
||||
for (let row = 0; row < 4; row++) {
|
||||
if (squares[row * 4]!.tapped && squares[row * 4 + 1]!.tapped && squares[row * 4 + 2]!.tapped && squares[row * 4 + 3]!.tapped) return true
|
||||
}
|
||||
for (let col = 0; col < 4; col++) {
|
||||
if (squares[col]!.tapped && squares[col + 4]!.tapped && squares[col + 8]!.tapped && squares[col + 12]!.tapped) return true
|
||||
}
|
||||
if (squares[0]!.tapped && squares[5]!.tapped && squares[10]!.tapped && squares[15]!.tapped) return true
|
||||
if (squares[3]!.tapped && squares[6]!.tapped && squares[9]!.tapped && squares[12]!.tapped) return true
|
||||
return false
|
||||
}
|
||||
|
||||
addBingoAnnouncement(playerId: string, displayName: string): boolean {
|
||||
if (this.announcedBingo.has(playerId)) return false
|
||||
this.announcedBingo.add(playerId)
|
||||
this.bingoAnnouncements.push({ playerId, displayName })
|
||||
return true
|
||||
}
|
||||
|
||||
getBingoAnnouncements(): { playerId: string; displayName: string }[] {
|
||||
return this.bingoAnnouncements
|
||||
}
|
||||
|
||||
getBingoScore(playerId: string): number {
|
||||
const card = this.bingoCards.get(playerId)
|
||||
if (!card) return 0
|
||||
const tappedCount = card.squares.filter((s) => s.tapped).length
|
||||
let score = tappedCount * scoringConfig.bingo_per_square
|
||||
if (card.hasBingo) score += scoringConfig.bingo_full_bonus
|
||||
return score
|
||||
}
|
||||
|
||||
// ─── State for client ───────────────────────────────────────────
|
||||
|
||||
private buildPredictionSubmitted(playerIds: string[]): Record<string, boolean> {
|
||||
|
||||
@@ -72,6 +72,122 @@ describe("GameManager", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("bingo", () => {
|
||||
it("generates a bingo card with 16 unique squares", () => {
|
||||
gm.generateBingoCards(["p1", "p2"])
|
||||
const card = gm.getBingoCard("p1")
|
||||
expect(card).not.toBeNull()
|
||||
expect(card!.squares).toHaveLength(16)
|
||||
expect(card!.hasBingo).toBe(false)
|
||||
const ids = card!.squares.map((s) => s.tropeId)
|
||||
expect(new Set(ids).size).toBe(16)
|
||||
})
|
||||
|
||||
it("generates different cards for different players", () => {
|
||||
gm.generateBingoCards(["p1", "p2"])
|
||||
const card1 = gm.getBingoCard("p1")!
|
||||
const card2 = gm.getBingoCard("p2")!
|
||||
const ids1 = card1.squares.map((s) => s.tropeId).sort()
|
||||
const ids2 = card2.squares.map((s) => s.tropeId).sort()
|
||||
expect(ids1).not.toEqual(ids2)
|
||||
})
|
||||
|
||||
it("taps a bingo square", () => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
const card = gm.getBingoCard("p1")!
|
||||
const tropeId = card.squares[0]!.tropeId
|
||||
const result = gm.tapBingoSquare("p1", tropeId)
|
||||
expect(result).toMatchObject({ success: true, hasBingo: false })
|
||||
expect(gm.getBingoCard("p1")!.squares[0]!.tapped).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects tap on unknown trope", () => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
const result = gm.tapBingoSquare("p1", "nonexistent")
|
||||
expect(result).toEqual({ error: "Trope not on your card" })
|
||||
})
|
||||
|
||||
it("rejects tap when no card exists", () => {
|
||||
const result = gm.tapBingoSquare("p1", "key-change")
|
||||
expect(result).toEqual({ error: "No bingo card found" })
|
||||
})
|
||||
|
||||
it("allows untapping a square", () => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
const card = gm.getBingoCard("p1")!
|
||||
const tropeId = card.squares[0]!.tropeId
|
||||
gm.tapBingoSquare("p1", tropeId)
|
||||
gm.tapBingoSquare("p1", tropeId)
|
||||
expect(gm.getBingoCard("p1")!.squares[0]!.tapped).toBe(false)
|
||||
})
|
||||
|
||||
it("detects bingo on a completed row", () => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
const card = gm.getBingoCard("p1")!
|
||||
let result: any
|
||||
for (let i = 0; i < 4; i++) {
|
||||
result = gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||||
}
|
||||
expect(result).toMatchObject({ success: true, hasBingo: true })
|
||||
expect(gm.getBingoCard("p1")!.hasBingo).toBe(true)
|
||||
})
|
||||
|
||||
it("detects bingo on a completed column", () => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
const card = gm.getBingoCard("p1")!
|
||||
let result: any
|
||||
for (const i of [0, 4, 8, 12]) {
|
||||
result = gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||||
}
|
||||
expect(result).toMatchObject({ success: true, hasBingo: true })
|
||||
})
|
||||
|
||||
it("detects bingo on a diagonal", () => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
const card = gm.getBingoCard("p1")!
|
||||
let result: any
|
||||
for (const i of [0, 5, 10, 15]) {
|
||||
result = gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||||
}
|
||||
expect(result).toMatchObject({ success: true, hasBingo: true })
|
||||
})
|
||||
|
||||
it("revokes bingo when a completing square is untapped", () => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
const card = gm.getBingoCard("p1")!
|
||||
for (let i = 0; i < 4; i++) {
|
||||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||||
}
|
||||
expect(gm.getBingoCard("p1")!.hasBingo).toBe(true)
|
||||
gm.tapBingoSquare("p1", card.squares[0]!.tropeId)
|
||||
expect(gm.getBingoCard("p1")!.hasBingo).toBe(false)
|
||||
})
|
||||
|
||||
it("does not duplicate bingo announcements on re-bingo", () => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
const card = gm.getBingoCard("p1")!
|
||||
for (let i = 0; i < 4; i++) {
|
||||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||||
}
|
||||
expect(gm.addBingoAnnouncement("p1", "Player 1")).toBe(true)
|
||||
gm.tapBingoSquare("p1", card.squares[0]!.tropeId) // untap
|
||||
gm.tapBingoSquare("p1", card.squares[0]!.tropeId) // re-tap → re-bingo
|
||||
expect(gm.addBingoAnnouncement("p1", "Player 1")).toBe(false)
|
||||
expect(gm.getBingoAnnouncements()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("computes bingo score", () => {
|
||||
gm.generateBingoCards(["p1"])
|
||||
const card = gm.getBingoCard("p1")!
|
||||
for (let i = 0; i < 4; i++) {
|
||||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||||
}
|
||||
gm.tapBingoSquare("p1", card.squares[4]!.tropeId)
|
||||
// 5 tapped * 2 + 10 bingo bonus = 20
|
||||
expect(gm.getBingoScore("p1")).toBe(20)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getGameStateForPlayer", () => {
|
||||
it("includes only the requesting player's prediction", () => {
|
||||
gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
|
||||
|
||||
Reference in New Issue
Block a user