From f9f5afaec9e830e68809521eed3dd5b56424549c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Thu, 12 Mar 2026 11:13:07 +0100 Subject: [PATCH] add game manager for predictions, dishes in-memory state Co-Authored-By: Claude Opus 4.6 --- packages/server/src/games/game-manager.ts | 189 ++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 packages/server/src/games/game-manager.ts diff --git a/packages/server/src/games/game-manager.ts b/packages/server/src/games/game-manager.ts new file mode 100644 index 0000000..1613ec9 --- /dev/null +++ b/packages/server/src/games/game-manager.ts @@ -0,0 +1,189 @@ +import { randomUUID } from "node:crypto" +import type { Prediction, Dish, DishGuess, GameState, Lineup } from "@celebrate-esc/shared" +import lineupData from "../../data/esc-2026.json" + +const lineup: Lineup = lineupData as Lineup +const countryCodes = new Set(lineup.countries.map((c) => c.code)) + +interface InternalDish { + id: string + name: string + correctCountry: string + revealed: boolean +} + +interface InternalDishGuess { + dishId: string + playerId: string + guessedCountry: string +} + +export class GameManager { + private predictions = new Map() // playerId → prediction + private dishes: InternalDish[] = [] + private dishGuesses: InternalDishGuess[] = [] + private locked = false + + getLineup(): Lineup { + return lineup + } + + isValidCountry(code: string): boolean { + return countryCodes.has(code) + } + + // ─── Predictions ──────────────────────────────────────────────── + + arePredictionsLocked(): boolean { + return this.locked + } + + lockPredictions(): void { + this.locked = true + } + + submitPrediction( + playerId: string, + predictedWinner: string, + top3: string[], + nulPointsPick: string, + ): { success: true } | { error: string } { + if (this.locked) return { error: "Predictions are locked" } + + // Validate all countries exist + const allPicks = [predictedWinner, ...top3, nulPointsPick] + for (const code of allPicks) { + if (!this.isValidCountry(code)) return { error: `Invalid country: ${code}` } + } + + // Winner must not be in top3 + if (top3.includes(predictedWinner)) { + return { error: "Winner cannot also be in top 3" } + } + + // No duplicates in top3 + if (new Set(top3).size !== 3) { + return { error: "Top 3 must be unique countries" } + } + + this.predictions.set(playerId, { playerId, predictedWinner, top3, nulPointsPick }) + return { success: true } + } + + getPrediction(playerId: string): Prediction | null { + return this.predictions.get(playerId) ?? null + } + + getAllPredictions(): Map { + return this.predictions + } + + // ─── Dishes ───────────────────────────────────────────────────── + + addDish(name: string, correctCountry: string): { dish: InternalDish } | { error: string } { + if (!this.isValidCountry(correctCountry)) { + return { error: `Invalid country: ${correctCountry}` } + } + + const dish: InternalDish = { + id: randomUUID(), + name, + correctCountry, + revealed: false, + } + this.dishes.push(dish) + return { dish } + } + + getDishes(): InternalDish[] { + return this.dishes + } + + submitDishGuess( + playerId: string, + dishId: string, + guessedCountry: string, + ): { success: true } | { error: string } { + if (!this.isValidCountry(guessedCountry)) { + return { error: `Invalid country: ${guessedCountry}` } + } + + const dish = this.dishes.find((d) => d.id === dishId) + if (!dish) return { error: "Dish not found" } + if (dish.revealed) return { error: "Dish already revealed" } + + // Replace existing guess for same player+dish + this.dishGuesses = this.dishGuesses.filter( + (g) => !(g.playerId === playerId && g.dishId === dishId), + ) + this.dishGuesses.push({ dishId, playerId, guessedCountry }) + return { success: true } + } + + getDishGuesses(playerId: string): DishGuess[] { + return this.dishGuesses + .filter((g) => g.playerId === playerId) + .map((g) => ({ dishId: g.dishId, playerId: g.playerId, guessedCountry: g.guessedCountry })) + } + + revealDishes(): InternalDish[] { + for (const dish of this.dishes) { + dish.revealed = true + } + return this.dishes + } + + getDishResults( + playerLookup: Map, + ): { dish: Dish; guesses: { playerId: string; displayName: string; guessedCountry: string; correct: boolean }[] }[] { + return this.dishes.map((dish) => ({ + dish: { id: dish.id, name: dish.name, correctCountry: dish.correctCountry, revealed: dish.revealed }, + guesses: this.dishGuesses + .filter((g) => g.dishId === dish.id) + .map((g) => ({ + playerId: g.playerId, + displayName: playerLookup.get(g.playerId) ?? "Unknown", + guessedCountry: g.guessedCountry, + correct: g.guessedCountry === dish.correctCountry, + })), + })) + } + + areAllDishesRevealed(): boolean { + return this.dishes.length > 0 && this.dishes.every((d) => d.revealed) + } + + // ─── State for client ─────────────────────────────────────────── + + getGameStateForPlayer(playerId: string, playerLookup: Map): GameState { + return { + lineup, + myPrediction: this.getPrediction(playerId), + predictionsLocked: this.locked, + dishes: this.dishes.map((d) => ({ + id: d.id, + name: d.name, + correctCountry: d.revealed ? d.correctCountry : "", + revealed: d.revealed, + })), + myDishGuesses: this.getDishGuesses(playerId), + dishResults: this.areAllDishesRevealed() ? this.getDishResults(playerLookup) : null, + } + } + + getGameStateForDisplay(playerLookup: Map): GameState { + return { + lineup, + myPrediction: null, + predictionsLocked: this.locked, + dishes: this.dishes.map((d) => ({ + id: d.id, + name: d.name, + correctCountry: d.revealed ? d.correctCountry : "", + revealed: d.revealed, + })), + myDishGuesses: [], + dishResults: this.areAllDishesRevealed() ? this.getDishResults(playerLookup) : null, + } + } +}