add bingo completion logic: tap-only, card storage, scoring across cards, redraw

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 11:25:30 +01:00
parent 25f61d456c
commit dda8c4a2ef
2 changed files with 79 additions and 16 deletions

View File

@@ -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<string>()
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,

View File

@@ -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", () => {