add game manager for predictions, dishes in-memory state
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
189
packages/server/src/games/game-manager.ts
Normal file
189
packages/server/src/games/game-manager.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user