diff --git a/packages/server/src/games/game-manager.ts b/packages/server/src/games/game-manager.ts index 3bdcd84..0952639 100644 --- a/packages/server/src/games/game-manager.ts +++ b/packages/server/src/games/game-manager.ts @@ -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 + } | null = null + + private juryResults: JuryResult[] = [] + private juryScores = new Map() + + 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 { diff --git a/packages/server/tests/game-manager.test.ts b/packages/server/tests/game-manager.test.ts index a38328b..b0c0f86 100644 --- a/packages/server/tests/game-manager.test.ts +++ b/packages/server/tests/game-manager.test.ts @@ -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")