add jury voting logic to GameManager with tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user