From c7a11e80d31bd4d27a35976b63feec92a95fe3b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Thu, 12 Mar 2026 22:14:28 +0100 Subject: [PATCH] add prediction scoring to GameManager with tests Co-Authored-By: Claude Opus 4.6 --- packages/server/src/games/game-manager.ts | 32 ++++++- packages/server/tests/game-manager.test.ts | 98 ++++++++++++++++++++++ 2 files changed, 128 insertions(+), 2 deletions(-) diff --git a/packages/server/src/games/game-manager.ts b/packages/server/src/games/game-manager.ts index eed34ad..58ac398 100644 --- a/packages/server/src/games/game-manager.ts +++ b/packages/server/src/games/game-manager.ts @@ -11,6 +11,7 @@ const countryCodes = new Set(lineup.entries.map((e) => e.country.code)) export class GameManager { private predictions = new Map() // playerId → prediction private locked = false + private actualResults: { winner: string; second: string; third: string; last: string } | null = null getLineup(): Lineup { return lineup @@ -222,6 +223,29 @@ export class GameManager { return score } + // ─── Prediction Scoring ───────────────────────────────────────── + + setActualResults(winner: string, second: string, third: string, last: string): void { + this.actualResults = { winner, second, third, last } + } + + getActualResults(): { winner: string; second: string; third: string; last: string } | null { + return this.actualResults + } + + getPredictionScore(playerId: string): number { + if (!this.actualResults) return 0 + const prediction = this.predictions.get(playerId) + if (!prediction) return 0 + + let score = 0 + if (prediction.first === this.actualResults.winner) score += scoringConfig.prediction_winner + if (prediction.second === this.actualResults.second) score += scoringConfig.prediction_top3 + if (prediction.third === this.actualResults.third) score += scoringConfig.prediction_top3 + if (prediction.last === this.actualResults.last) score += scoringConfig.prediction_nul_points + return score + } + // ─── State for client ─────────────────────────────────────────── private buildPredictionSubmitted(playerIds: string[]): Record { @@ -247,6 +271,7 @@ export class GameManager { myJuryVote: this.getPlayerJuryVote(playerId), myBingoCard: this.getBingoCard(playerId), bingoAnnouncements: this.bingoAnnouncements, + actualResults: this.actualResults, leaderboard: this.buildLeaderboard(allPlayerIds, displayNames ?? {}), } } @@ -265,6 +290,7 @@ export class GameManager { myJuryVote: null, myBingoCard: null, bingoAnnouncements: this.bingoAnnouncements, + actualResults: this.actualResults, leaderboard: this.buildLeaderboard(allPlayerIds, displayNames ?? {}), } } @@ -272,17 +298,19 @@ export class GameManager { private buildLeaderboard( playerIds: string[], displayNames: Record, - ): { playerId: string; displayName: string; juryPoints: number; bingoPoints: number; totalPoints: number }[] { + ): { playerId: string; displayName: string; juryPoints: number; bingoPoints: number; predictionPoints: number; totalPoints: number }[] { return playerIds .map((id) => { const juryPoints = this.getJuryScore(id) const bingoPoints = this.getBingoScore(id) + const predictionPoints = this.getPredictionScore(id) return { playerId: id, displayName: displayNames[id] ?? "Unknown", juryPoints, bingoPoints, - totalPoints: juryPoints + bingoPoints, + predictionPoints, + totalPoints: juryPoints + bingoPoints + predictionPoints, } }) .sort((a, b) => b.totalPoints - a.totalPoints) diff --git a/packages/server/tests/game-manager.test.ts b/packages/server/tests/game-manager.test.ts index f0c3890..5f28fe9 100644 --- a/packages/server/tests/game-manager.test.ts +++ b/packages/server/tests/game-manager.test.ts @@ -339,6 +339,104 @@ describe("GameManager", () => { }) }) + describe("prediction scoring", () => { + it("returns 0 for all when no actual results set", () => { + const gm = new GameManager() + gm.submitPrediction("p1", "SE", "IT", "FR", "GB") + expect(gm.getPredictionScore("p1")).toBe(0) + }) + + it("scores correct winner", () => { + const gm = new GameManager() + gm.submitPrediction("p1", "SE", "IT", "FR", "GB") + gm.setActualResults("SE", "CH", "DE", "AL") + expect(gm.getPredictionScore("p1")).toBe(25) + }) + + it("scores correct second place", () => { + const gm = new GameManager() + gm.submitPrediction("p1", "NO", "IT", "FR", "GB") + gm.setActualResults("SE", "IT", "DE", "AL") + expect(gm.getPredictionScore("p1")).toBe(10) + }) + + it("scores correct third place", () => { + const gm = new GameManager() + gm.submitPrediction("p1", "NO", "DK", "FR", "GB") + gm.setActualResults("SE", "IT", "FR", "AL") + expect(gm.getPredictionScore("p1")).toBe(10) + }) + + it("scores correct last place", () => { + const gm = new GameManager() + gm.submitPrediction("p1", "NO", "DK", "FI", "GB") + gm.setActualResults("SE", "IT", "FR", "GB") + expect(gm.getPredictionScore("p1")).toBe(15) + }) + + it("scores perfect prediction (all correct)", () => { + const gm = new GameManager() + gm.submitPrediction("p1", "SE", "IT", "FR", "GB") + gm.setActualResults("SE", "IT", "FR", "GB") + expect(gm.getPredictionScore("p1")).toBe(60) + }) + + it("scores 0 for all wrong", () => { + const gm = new GameManager() + gm.submitPrediction("p1", "NO", "DK", "FI", "EE") + gm.setActualResults("SE", "IT", "FR", "GB") + expect(gm.getPredictionScore("p1")).toBe(0) + }) + + it("returns 0 for player with no prediction", () => { + const gm = new GameManager() + gm.setActualResults("SE", "IT", "FR", "GB") + expect(gm.getPredictionScore("p1")).toBe(0) + }) + + it("getActualResults returns null before setting", () => { + const gm = new GameManager() + expect(gm.getActualResults()).toBeNull() + }) + + it("getActualResults returns results after setting", () => { + const gm = new GameManager() + gm.setActualResults("SE", "IT", "FR", "GB") + expect(gm.getActualResults()).toEqual({ winner: "SE", second: "IT", third: "FR", last: "GB" }) + }) + + it("setActualResults overwrites previous results", () => { + const gm = new GameManager() + gm.submitPrediction("p1", "SE", "IT", "FR", "GB") + gm.setActualResults("NO", "DK", "FI", "EE") + expect(gm.getPredictionScore("p1")).toBe(0) + gm.setActualResults("SE", "IT", "FR", "GB") + expect(gm.getPredictionScore("p1")).toBe(60) + }) + + it("prediction points appear in leaderboard", () => { + const gm = new GameManager() + gm.submitPrediction("p1", "SE", "IT", "FR", "GB") + gm.setActualResults("SE", "IT", "FR", "GB") + const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Alice" }) + expect(state.leaderboard[0]!.predictionPoints).toBe(60) + expect(state.leaderboard[0]!.totalPoints).toBe(60) + }) + + it("actualResults included in game state", () => { + const gm = new GameManager() + gm.setActualResults("SE", "IT", "FR", "GB") + const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Alice" }) + expect(state.actualResults).toEqual({ winner: "SE", second: "IT", third: "FR", last: "GB" }) + }) + + it("actualResults null in game state when not set", () => { + const gm = new GameManager() + const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Alice" }) + expect(state.actualResults).toBeNull() + }) + }) + describe("getGameStateForDisplay", () => { it("returns null myPrediction", () => { gm.submitPrediction("p1", "SE", "DE", "IT", "GB")