diff --git a/packages/server/src/games/game-manager.ts b/packages/server/src/games/game-manager.ts index 5139665..4d2bb82 100644 --- a/packages/server/src/games/game-manager.ts +++ b/packages/server/src/games/game-manager.ts @@ -1,4 +1,4 @@ -import type { Prediction, GameState, Lineup, JuryRound, JuryResult, QuizQuestion } from "@celebrate-esc/shared" +import type { Prediction, GameState, Lineup, JuryRound, JuryResult, QuizQuestion, CompletedBingoCard } from "@celebrate-esc/shared" import lineupData from "../../data/esc-2025.json" import scoringConfig from "../../data/scoring.json" import tropesData from "../../data/bingo-tropes.json" @@ -164,9 +164,11 @@ export class GameManager { }>() private bingoAnnouncements: { playerId: string; displayName: string }[] = [] private announcedBingo = new Set() + private completedBingoCards: CompletedBingoCard[] = [] generateBingoCards(playerIds: string[]): void { for (const playerId of playerIds) { + if (this.bingoCards.has(playerId)) continue const shuffled = [...tropes].sort(() => Math.random() - 0.5) const selected = shuffled.slice(0, 16) this.bingoCards.set(playerId, { @@ -180,18 +182,31 @@ export class GameManager { } } + generateBingoCardForPlayer(playerId: string): void { + if (this.bingoCards.has(playerId)) return + 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 } { + tapBingoSquare(playerId: string, tropeId: string): { success: true; hasBingo: boolean; isNewBingo: 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 + if (square.tapped) return { success: true, hasBingo: card.hasBingo, isNewBingo: false } + square.tapped = true + const hadBingo = card.hasBingo card.hasBingo = this.checkBingo(card.squares) - return { success: true, hasBingo: card.hasBingo } + const isNewBingo = card.hasBingo && !hadBingo + return { success: true, hasBingo: card.hasBingo, isNewBingo } } private checkBingo(squares: { tapped: boolean }[]): boolean { @@ -207,8 +222,14 @@ export class GameManager { } addBingoAnnouncement(playerId: string, displayName: string): boolean { - if (this.announcedBingo.has(playerId)) return false - this.announcedBingo.add(playerId) + // Count how many bingos this player already announced + const count = this.bingoAnnouncements.filter((a) => a.playerId === playerId).length + // Count how many bingo-detected cards this player has (completed + current if hasBingo) + const completedCount = this.completedBingoCards.filter((c) => c.playerId === playerId).length + const activeCard = this.bingoCards.get(playerId) + const totalBingos = completedCount + (activeCard?.hasBingo ? 1 : 0) + // Only announce if there are more bingos than announcements + if (count >= totalBingos) return false this.bingoAnnouncements.push({ playerId, displayName }) return true } @@ -218,12 +239,50 @@ export class GameManager { } 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 + let totalTapped = 0 + let totalBonuses = 0 + // Count completed cards (moved here on redraw) + const completed = this.completedBingoCards.filter((c) => c.playerId === playerId) + for (const c of completed) { + totalTapped += c.card.squares.filter((s) => s.tapped).length + totalBonuses += scoringConfig.bingo_full_bonus + } + // Count active card (never overlaps with completed — card moves on redraw) + const activeCard = this.bingoCards.get(playerId) + if (activeCard) { + totalTapped += activeCard.squares.filter((s) => s.tapped).length + if (activeCard.hasBingo) totalBonuses += scoringConfig.bingo_full_bonus + } + return totalTapped * scoringConfig.bingo_per_square + totalBonuses + } + + requestNewBingoCard(playerId: string, displayName: string): { success: true } | { error: string } { + const currentCard = this.bingoCards.get(playerId) + if (!currentCard || !currentCard.hasBingo) { + return { error: "No completed bingo card to replace" } + } + // Move current card to completedBingoCards + this.completedBingoCards.push({ + playerId, + displayName, + card: { squares: currentCard.squares.map((s) => ({ ...s })), hasBingo: true }, + completedAt: new Date().toISOString(), + }) + // Generate new card excluding tropes from the just-completed card + const excludeIds = new Set(currentCard.squares.map((s) => s.tropeId)) + const available = tropes.filter((t) => !excludeIds.has(t.id)) + const pool = available.length >= 16 ? available : tropes + const shuffled = [...pool].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, + }) + return { success: true } + } + + getCompletedBingoCards(): CompletedBingoCard[] { + return this.completedBingoCards } // ─── Quiz ──────────────────────────────────────────────────────── @@ -444,6 +503,7 @@ export class GameManager { myJuryVote: this.getPlayerJuryVote(playerId), myBingoCard: this.getBingoCard(playerId), bingoAnnouncements: this.bingoAnnouncements, + completedBingoCards: this.completedBingoCards, currentQuizQuestion: this.buildQuizQuestionForPlayer(playerId, displayNames ?? {}), myQuizBuzzStatus: this.getQuizBuzzStatus(playerId), actualResults: this.actualResults, @@ -465,6 +525,7 @@ export class GameManager { myJuryVote: null, myBingoCard: null, bingoAnnouncements: this.bingoAnnouncements, + completedBingoCards: this.completedBingoCards, currentQuizQuestion: this.buildQuizQuestionForDisplay(displayNames ?? {}), myQuizBuzzStatus: null, actualResults: this.actualResults, diff --git a/packages/server/tests/game-manager.test.ts b/packages/server/tests/game-manager.test.ts index 5f28fe9..1e77142 100644 --- a/packages/server/tests/game-manager.test.ts +++ b/packages/server/tests/game-manager.test.ts @@ -112,13 +112,14 @@ describe("GameManager", () => { expect(result).toEqual({ error: "No bingo card found" }) }) - it("allows untapping a square", () => { + it("does not untap a square (tap is one-way)", () => { 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) + // Second tap is idempotent — square stays tapped + expect(gm.getBingoCard("p1")!.squares[0]!.tapped).toBe(true) }) it("detects bingo on a completed row", () => { @@ -152,15 +153,16 @@ describe("GameManager", () => { expect(result).toMatchObject({ success: true, hasBingo: true }) }) - it("revokes bingo when a completing square is untapped", () => { + it("bingo persists after re-tapping a completing square (tap is one-way)", () => { 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) + // Re-tapping is a no-op — bingo stays gm.tapBingoSquare("p1", card.squares[0]!.tropeId) - expect(gm.getBingoCard("p1")!.hasBingo).toBe(false) + expect(gm.getBingoCard("p1")!.hasBingo).toBe(true) }) it("does not duplicate bingo announcements on re-bingo", () => {