add game manager for predictions, dishes in-memory state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 11:13:07 +01:00
parent 63d1893d6c
commit f9f5afaec9

View File

@@ -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<string, Prediction>() // 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<string, Prediction> {
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<string, string>,
): { 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<string, string>): 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<string, string>): 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,
}
}
}