From b79ddb9679c583aa27def3ec94f6186dd1e9ffa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Thu, 12 Mar 2026 19:50:07 +0100 Subject: [PATCH] add bingo logic to GameManager with tests Co-Authored-By: Claude Opus 4.6 --- packages/server/src/games/game-manager.ts | 73 +++++++++++++ packages/server/tests/game-manager.test.ts | 116 +++++++++++++++++++++ 2 files changed, 189 insertions(+) diff --git a/packages/server/src/games/game-manager.ts b/packages/server/src/games/game-manager.ts index 0952639..8e61fa7 100644 --- a/packages/server/src/games/game-manager.ts +++ b/packages/server/src/games/game-manager.ts @@ -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() + private bingoAnnouncements: { playerId: string; displayName: string }[] = [] + private announcedBingo = new Set() + + 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 { diff --git a/packages/server/tests/game-manager.test.ts b/packages/server/tests/game-manager.test.ts index b0c0f86..a9627a7 100644 --- a/packages/server/tests/game-manager.test.ts +++ b/packages/server/tests/game-manager.test.ts @@ -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")