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:
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user