add jury voting logic to GameManager with tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 19:49:17 +01:00
parent e48ee2ca35
commit 0703364945
2 changed files with 192 additions and 1 deletions

View File

@@ -1,5 +1,6 @@
import type { Prediction, GameState, Lineup } from "@celebrate-esc/shared"
import type { Prediction, GameState, Lineup, JuryRound, JuryResult } from "@celebrate-esc/shared"
import lineupData from "../../data/esc-2025.json"
import scoringConfig from "../../data/scoring.json"
const lineup: Lineup = lineupData as Lineup
const countryCodes = new Set(lineup.entries.map((e) => e.country.code))
@@ -60,6 +61,94 @@ export class GameManager {
return this.predictions.has(playerId)
}
// ─── Jury Voting ────────────────────────────────────────────────
private currentJuryRound: {
id: string
countryCode: string
countryName: string
countryFlag: string
votes: Map<string, number>
} | null = null
private juryResults: JuryResult[] = []
private juryScores = new Map<string, number>()
openJuryRound(
countryCode: string,
countryName: string,
countryFlag: string,
): { success: true } | { error: string } {
if (this.currentJuryRound) return { error: "A jury round is already open" }
this.currentJuryRound = {
id: crypto.randomUUID(),
countryCode,
countryName,
countryFlag,
votes: new Map(),
}
return { success: true }
}
submitJuryVote(playerId: string, rating: number): { success: true } | { error: string } {
if (!this.currentJuryRound) return { error: "No jury round is open" }
if (rating < 1 || rating > 12) return { error: "Rating must be between 1 and 12" }
this.currentJuryRound.votes.set(playerId, rating)
return { success: true }
}
getPlayerJuryVote(playerId: string): number | null {
if (!this.currentJuryRound) return null
return this.currentJuryRound.votes.get(playerId) ?? null
}
closeJuryRound(): JuryResult | { error: string } {
if (!this.currentJuryRound) return { error: "No jury round is open" }
const round = this.currentJuryRound
const votes = Array.from(round.votes.values())
const averageRating = votes.length > 0
? Math.round((votes.reduce((a, b) => a + b, 0) / votes.length) * 10) / 10
: 0
const maxPts = scoringConfig.jury_max_per_round
for (const [playerId, rating] of round.votes) {
const diff = Math.abs(rating - averageRating)
const pts = Math.max(0, maxPts - Math.round(diff))
this.juryScores.set(playerId, (this.juryScores.get(playerId) ?? 0) + pts)
}
const result: JuryResult = {
countryCode: round.countryCode,
countryName: round.countryName,
countryFlag: round.countryFlag,
averageRating,
totalVotes: votes.length,
}
this.juryResults.push(result)
this.currentJuryRound = null
return result
}
getCurrentJuryRound(): JuryRound | null {
if (!this.currentJuryRound) return null
return {
id: this.currentJuryRound.id,
countryCode: this.currentJuryRound.countryCode,
countryName: this.currentJuryRound.countryName,
countryFlag: this.currentJuryRound.countryFlag,
status: "open",
}
}
getJuryResults(): JuryResult[] {
return this.juryResults
}
getJuryScore(playerId: string): number {
return this.juryScores.get(playerId) ?? 0
}
// ─── State for client ───────────────────────────────────────────
private buildPredictionSubmitted(playerIds: string[]): Record<string, boolean> {

View File

@@ -87,6 +87,108 @@ describe("GameManager", () => {
})
})
describe("jury voting", () => {
it("opens a jury round", () => {
const result = gm.openJuryRound("SE", "Sweden", "🇸🇪")
expect(result).toEqual({ success: true })
const round = gm.getCurrentJuryRound()
expect(round).not.toBeNull()
expect(round!.countryCode).toBe("SE")
expect(round!.status).toBe("open")
})
it("rejects opening when a round is already open", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
const result = gm.openJuryRound("DE", "Germany", "🇩🇪")
expect(result).toEqual({ error: "A jury round is already open" })
})
it("accepts a valid jury vote", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
const result = gm.submitJuryVote("p1", 8)
expect(result).toEqual({ success: true })
})
it("rejects jury vote when no round is open", () => {
const result = gm.submitJuryVote("p1", 8)
expect(result).toEqual({ error: "No jury round is open" })
})
it("rejects jury vote outside 1-12 range", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
const result = gm.submitJuryVote("p1", 0)
expect(result).toEqual({ error: "Rating must be between 1 and 12" })
})
it("allows overwriting a jury vote in the same round", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 8)
gm.submitJuryVote("p1", 10)
const result = gm.closeJuryRound()
expect("averageRating" in result && result.averageRating).toBe(10)
})
it("closes a jury round and computes average", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 8)
gm.submitJuryVote("p2", 10)
const result = gm.closeJuryRound()
expect(result).toMatchObject({
countryCode: "SE",
averageRating: 9,
totalVotes: 2,
})
})
it("rejects close when no round is open", () => {
const result = gm.closeJuryRound()
expect(result).toEqual({ error: "No jury round is open" })
})
it("handles closing a round with zero votes", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
const result = gm.closeJuryRound()
expect(result).toMatchObject({
countryCode: "SE",
averageRating: 0,
totalVotes: 0,
})
})
it("accumulates results across rounds", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 10)
gm.closeJuryRound()
gm.openJuryRound("DE", "Germany", "🇩🇪")
gm.submitJuryVote("p1", 6)
gm.closeJuryRound()
expect(gm.getJuryResults()).toHaveLength(2)
})
it("computes jury scores based on closeness to average", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 10) // avg will be 10, diff=0, score=5
gm.submitJuryVote("p2", 10) // diff=0, score=5
gm.closeJuryRound()
expect(gm.getJuryScore("p1")).toBe(5)
expect(gm.getJuryScore("p2")).toBe(5)
gm.openJuryRound("DE", "Germany", "🇩🇪")
gm.submitJuryVote("p1", 4) // avg=(4+10)/2=7, diff=3, score=2
gm.submitJuryVote("p2", 10) // diff=3, score=2
gm.closeJuryRound()
expect(gm.getJuryScore("p1")).toBe(7) // 5+2
expect(gm.getJuryScore("p2")).toBe(7) // 5+2
})
it("returns the player's current vote for a round", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
expect(gm.getPlayerJuryVote("p1")).toBeNull()
gm.submitJuryVote("p1", 7)
expect(gm.getPlayerJuryVote("p1")).toBe(7)
})
})
describe("getGameStateForDisplay", () => {
it("returns null myPrediction", () => {
gm.submitPrediction("p1", "SE", "DE", "IT", "GB")