From 8372769c9b03e0ebbff89fbd1fe3564d7a767f3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Thu, 12 Mar 2026 21:45:17 +0100 Subject: [PATCH] add prediction scoring implementation plan Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-12-prediction-scoring.md | 866 ++++++++++++++++++ 1 file changed, 866 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-12-prediction-scoring.md diff --git a/docs/superpowers/plans/2026-03-12-prediction-scoring.md b/docs/superpowers/plans/2026-03-12-prediction-scoring.md new file mode 100644 index 0000000..6b938eb --- /dev/null +++ b/docs/superpowers/plans/2026-03-12-prediction-scoring.md @@ -0,0 +1,866 @@ +# Prediction Scoring Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow the host to enter actual ESC results and score player predictions against them. + +**Architecture:** Extend `GameManager` with actual results storage and prediction scoring. Add a new WS message type `submit_actual_results`. Extend `GameState`, `LeaderboardEntry` schemas with prediction data. Add a host-side form and player-side result indicators. + +**Tech Stack:** Zod, Hono WebSocket, React, shadcn/ui, Vitest + +--- + +## File Structure + +| Action | File | Responsibility | +|--------|------|---------------| +| Modify | `packages/shared/src/game-types.ts` | Add `actualResultsSchema`, `predictionPoints` to leaderboard, `actualResults` to game state | +| Modify | `packages/shared/src/ws-messages.ts` | Add `submit_actual_results` client message | +| Modify | `packages/server/src/games/game-manager.ts` | `setActualResults`, `getPredictionScore`, update `buildLeaderboard` | +| Modify | `packages/server/src/ws/handler.ts` | Handle `submit_actual_results` | +| Modify | `packages/server/tests/game-manager.test.ts` | Tests for prediction scoring | +| Create | `packages/client/src/components/actual-results-form.tsx` | Host form to enter actual ESC results | +| Modify | `packages/client/src/components/predictions-form.tsx` | Show correct/incorrect markers when results are in | +| Modify | `packages/client/src/components/leaderboard.tsx` | Add P: column, update scoring explanation | +| Modify | `packages/client/src/routes/host.$roomCode.tsx` | Show `ActualResultsForm` in scoring/ended | +| Modify | `packages/client/src/routes/play.$roomCode.tsx` | Pass `actualResults` to predictions form in scoring/ended | +| Modify | `packages/client/src/routes/display.$roomCode.tsx` | Show actual results summary | + +--- + +## Chunk 1: Prediction Scoring + +### Task 1: Extend shared types + +**Files:** +- Modify: `packages/shared/src/game-types.ts` + +- [ ] **Step 1: Add `actualResultsSchema` and extend `LeaderboardEntry` and `GameState`** + +In `packages/shared/src/game-types.ts`, add after the `Prediction` type block: + +```ts +// ─── Actual Results ───────────────────────────────────────────────── + +export const actualResultsSchema = z.object({ + winner: z.string(), + second: z.string(), + third: z.string(), + last: z.string(), +}) + +export type ActualResults = z.infer +``` + +Update `leaderboardEntrySchema` to add `predictionPoints`: + +```ts +export const leaderboardEntrySchema = z.object({ + playerId: z.string(), + displayName: z.string(), + juryPoints: z.number(), + bingoPoints: z.number(), + predictionPoints: z.number(), + totalPoints: z.number(), +}) +``` + +Update `gameStateSchema` to add `actualResults`: + +```ts +export const gameStateSchema = z.object({ + lineup: lineupSchema, + myPrediction: predictionSchema.nullable(), + predictionsLocked: z.boolean(), + predictionSubmitted: z.record(z.string(), z.boolean()), + // Jury + currentJuryRound: juryRoundSchema.nullable(), + juryResults: z.array(juryResultSchema), + myJuryVote: z.number().nullable(), + // Bingo + myBingoCard: bingoCardSchema.nullable(), + bingoAnnouncements: z.array(z.object({ + playerId: z.string(), + displayName: z.string(), + })), + // Predictions + actualResults: actualResultsSchema.nullable(), + // Leaderboard + leaderboard: z.array(leaderboardEntrySchema), +}) +``` + +- [ ] **Step 2: Verify build** + +Run: `bun run --filter './packages/shared' build 2>&1 || echo 'no build script, check tsc'` +Expected: No type errors in shared package + +- [ ] **Step 3: Commit** + +```bash +git add packages/shared/src/game-types.ts +git commit -m "add actual results schema, prediction points to leaderboard and game state" +``` + +### Task 2: Add WS message type + +**Files:** +- Modify: `packages/shared/src/ws-messages.ts` + +- [ ] **Step 1: Add `submitActualResultsMessage`** + +After the `tapBingoSquareMessage` definition, add: + +```ts +export const submitActualResultsMessage = z.object({ + type: z.literal("submit_actual_results"), + winner: z.string(), + second: z.string(), + third: z.string(), + last: z.string(), +}) +``` + +Add it to the `clientMessage` discriminated union array: + +```ts +export const clientMessage = z.discriminatedUnion("type", [ + joinRoomMessage, + reconnectMessage, + advanceActMessage, + revertActMessage, + endRoomMessage, + submitPredictionMessage, + openJuryVoteMessage, + closeJuryVoteMessage, + submitJuryVoteMessage, + tapBingoSquareMessage, + submitActualResultsMessage, +]) +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/shared/src/ws-messages.ts +git commit -m "add submit_actual_results WS message type" +``` + +### Task 3: Add prediction scoring to GameManager — tests first + +**Files:** +- Modify: `packages/server/tests/game-manager.test.ts` + +- [ ] **Step 1: Write failing tests for prediction scoring** + +Add a new `describe("prediction scoring")` block at the end of the test file: + +```ts +describe("prediction scoring", () => { + it("returns 0 for all when no actual results set", () => { + const gm = new GameManager() + gm.submitPrediction("p1", "SE", "IT", "FR", "UK") + expect(gm.getPredictionScore("p1")).toBe(0) + }) + + it("scores correct winner", () => { + const gm = new GameManager() + gm.submitPrediction("p1", "SE", "IT", "FR", "UK") + gm.setActualResults("SE", "CH", "DE", "AL") + expect(gm.getPredictionScore("p1")).toBe(25) // prediction_winner + }) + + it("scores correct second place", () => { + const gm = new GameManager() + gm.submitPrediction("p1", "XX", "IT", "FR", "UK") + gm.setActualResults("SE", "IT", "DE", "AL") + expect(gm.getPredictionScore("p1")).toBe(10) // prediction_top3 + }) + + it("scores correct third place", () => { + const gm = new GameManager() + gm.submitPrediction("p1", "XX", "YY", "FR", "UK") + gm.setActualResults("SE", "IT", "FR", "AL") + expect(gm.getPredictionScore("p1")).toBe(10) // prediction_top3 + }) + + it("scores correct last place", () => { + const gm = new GameManager() + gm.submitPrediction("p1", "XX", "YY", "ZZ", "UK") + gm.setActualResults("SE", "IT", "FR", "UK") + expect(gm.getPredictionScore("p1")).toBe(15) // prediction_nul_points + }) + + it("scores perfect prediction (all correct)", () => { + const gm = new GameManager() + gm.submitPrediction("p1", "SE", "IT", "FR", "UK") + gm.setActualResults("SE", "IT", "FR", "UK") + expect(gm.getPredictionScore("p1")).toBe(60) // 25 + 10 + 10 + 15 + }) + + it("scores 0 for all wrong", () => { + const gm = new GameManager() + gm.submitPrediction("p1", "AA", "BB", "CC", "DD") + gm.setActualResults("SE", "IT", "FR", "UK") + expect(gm.getPredictionScore("p1")).toBe(0) + }) + + it("returns 0 for player with no prediction", () => { + const gm = new GameManager() + gm.setActualResults("SE", "IT", "FR", "UK") + 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", "UK") + expect(gm.getActualResults()).toEqual({ winner: "SE", second: "IT", third: "FR", last: "UK" }) + }) + + it("setActualResults overwrites previous results", () => { + const gm = new GameManager() + gm.submitPrediction("p1", "SE", "IT", "FR", "UK") + gm.setActualResults("AA", "BB", "CC", "DD") + expect(gm.getPredictionScore("p1")).toBe(0) + gm.setActualResults("SE", "IT", "FR", "UK") + expect(gm.getPredictionScore("p1")).toBe(60) + }) + + it("prediction points appear in leaderboard", () => { + const gm = new GameManager() + gm.submitPrediction("p1", "SE", "IT", "FR", "UK") + gm.setActualResults("SE", "IT", "FR", "UK") + 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", "UK") + const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Alice" }) + expect(state.actualResults).toEqual({ winner: "SE", second: "IT", third: "FR", last: "UK" }) + }) + + 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() + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun test` +Expected: New tests FAIL (methods don't exist yet) + +### Task 4: Implement prediction scoring in GameManager + +**Files:** +- Modify: `packages/server/src/games/game-manager.ts` + +- [ ] **Step 1: Add actual results storage and scoring methods** + +Add a new private field after `private bingoAnnouncements`: + +```ts +// ─── Prediction Scoring ───────────────────────────────────────── +private actualResults: { winner: string; second: string; third: string; last: string } | null = null +``` + +Add methods after `getBingoScore`: + +```ts +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 +} +``` + +- [ ] **Step 2: Update `buildLeaderboard` to include prediction points** + +Change the `buildLeaderboard` method's return type and body: + +```ts +private buildLeaderboard( + playerIds: string[], + displayNames: Record, +): { 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, + predictionPoints, + totalPoints: juryPoints + bingoPoints + predictionPoints, + } + }) + .sort((a, b) => b.totalPoints - a.totalPoints) +} +``` + +- [ ] **Step 3: Add `actualResults` to both game state builder methods** + +In `getGameStateForPlayer`, add `actualResults: this.actualResults,` after the `bingoAnnouncements` line. + +In `getGameStateForDisplay`, add `actualResults: this.actualResults,` after the `bingoAnnouncements` line. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test` +Expected: All tests PASS (60 existing + 13 new = 73) + +- [ ] **Step 5: Commit** + +```bash +git add packages/server/src/games/game-manager.ts packages/server/tests/game-manager.test.ts +git commit -m "add prediction scoring to GameManager with tests" +``` + +### Task 5: Add WS handler for submit_actual_results + +**Files:** +- Modify: `packages/server/src/ws/handler.ts` + +- [ ] **Step 1: Add handler case** + +In the `switch (msg.type)` block, add a new case before the closing `}` of the switch (after `tap_bingo_square`): + +```ts +case "submit_actual_results": { + if (!sessionId) { + sendError(ws, "Not joined") + return + } + const room = roomManager.getRoom(roomCode) + if (room?.currentAct !== "scoring" && room?.currentAct !== "ended") { + sendError(ws, "Results can only be entered during Scoring or Ended") + return + } + if (!roomManager.isHost(roomCode, sessionId)) { + sendError(ws, "Only the host can enter actual results") + return + } + const gm = roomManager.getGameManager(roomCode) + if (!gm) { + sendError(ws, "Room not found") + return + } + const allPicks = [msg.winner, msg.second, msg.third, msg.last] + for (const code of allPicks) { + if (!gm.isValidCountry(code)) { + sendError(ws, `Invalid country: ${code}`) + return + } + } + if (new Set(allPicks).size !== 4) { + sendError(ws, "All 4 picks must be different countries") + return + } + gm.setActualResults(msg.winner, msg.second, msg.third, msg.last) + broadcastGameStateToAll(roomCode) + break +} +``` + +- [ ] **Step 2: Run tests and verify client builds** + +Run: `bun test` +Expected: All 73 tests pass + +Run: `bun run --filter './packages/client' build` +Expected: Build succeeds (client doesn't use the new types yet, but shared types must compile) + +- [ ] **Step 3: Commit** + +```bash +git add packages/server/src/ws/handler.ts +git commit -m "add submit_actual_results WS handler with validation" +``` + +### Task 6: Create ActualResultsForm component + +**Files:** +- Create: `packages/client/src/components/actual-results-form.tsx` + +- [ ] **Step 1: Create the component** + +This reuses the same slot-picker pattern as `PredictionsForm`. Create `packages/client/src/components/actual-results-form.tsx`: + +```tsx +import { useState } from "react" +import type { Entry, ActualResults } from "@celebrate-esc/shared" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" + +type SlotKey = "winner" | "second" | "third" | "last" + +const SLOTS: { key: SlotKey; label: string }[] = [ + { key: "winner", label: "Winner" }, + { key: "second", label: "2nd Place" }, + { key: "third", label: "3rd Place" }, + { key: "last", label: "Last Place" }, +] + +function formatEntry(entry: Entry): string { + return `${entry.country.flag} ${entry.artist} — ${entry.song}` +} + +interface ActualResultsFormProps { + entries: Entry[] + existingResults: ActualResults | null + onSubmit: (results: { winner: string; second: string; third: string; last: string }) => void +} + +export function ActualResultsForm({ entries, existingResults, onSubmit }: ActualResultsFormProps) { + const [slots, setSlots] = useState>(() => { + if (existingResults) { + return { + winner: existingResults.winner, + second: existingResults.second, + third: existingResults.third, + last: existingResults.last, + } + } + return { winner: null, second: null, third: null, last: null } + }) + const [pickerForEntry, setPickerForEntry] = useState(null) + + const assignedCodes = new Set(Object.values(slots).filter(Boolean)) + const emptySlots = SLOTS.filter((s) => !slots[s.key]) + const allFilled = SLOTS.every((s) => slots[s.key]) + + function findEntry(code: string): Entry | undefined { + return entries.find((e) => e.country.code === code) + } + + function assignToSlot(entryCode: string, slotKey: SlotKey) { + setSlots((prev) => ({ ...prev, [slotKey]: entryCode })) + setPickerForEntry(null) + } + + function removeFromSlot(slotKey: SlotKey) { + setSlots((prev) => ({ ...prev, [slotKey]: null })) + } + + return ( + + + Actual Results + + +
+ {SLOTS.map((slot) => { + const code = slots[slot.key] + const entry = code ? findEntry(code) : null + return ( +
+
+ {slot.label} + {entry ? ( + {formatEntry(entry)} + ) : ( + Tap an entry below + )} +
+ {code && ( + + )} +
+ ) + })} +
+ + {allFilled && ( + + )} + +
+

Entries

+ {entries.map((entry) => { + const isAssigned = assignedCodes.has(entry.country.code) + const isPickerOpen = pickerForEntry === entry.country.code + return ( +
+ + {isPickerOpen && !isAssigned && ( +
+ {emptySlots.map((slot) => ( + + ))} +
+ )} +
+ ) + })} +
+
+
+ ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/client/src/components/actual-results-form.tsx +git commit -m "add ActualResultsForm component" +``` + +### Task 7: Update PredictionsForm to show correct/incorrect markers + +**Files:** +- Modify: `packages/client/src/components/predictions-form.tsx` + +- [ ] **Step 1: Add `actualResults` prop and markers** + +Update the `PredictionsFormProps` interface to add: + +```ts +import type { Entry, Prediction, ActualResults } from "@celebrate-esc/shared" + +interface PredictionsFormProps { + entries: Entry[] + existingPrediction: Prediction | null + locked: boolean + actualResults?: ActualResults | null + onSubmit: (prediction: { first: string; second: string; third: string; last: string }) => void +} +``` + +Update the function signature: + +```ts +export function PredictionsForm({ entries, existingPrediction, locked, actualResults, onSubmit }: PredictionsFormProps) { +``` + +In the locked state when `existingPrediction` exists (the block starting at line 67), update the rendered slot items to show correctness. Replace the existing locked-with-prediction return block with: + +```tsx +return ( + + + Your Predictions {actualResults ? "(scored)" : "(locked)"} + + + {SLOTS.map((slot) => { + const entry = findEntry(existingPrediction[slot.key]) + const isCorrect = actualResults + ? slot.key === "first" ? existingPrediction.first === actualResults.winner + : slot.key === "second" ? existingPrediction.second === actualResults.second + : slot.key === "third" ? existingPrediction.third === actualResults.third + : existingPrediction.last === actualResults.last + : null + return ( +
+ {slot.label} + {entry ? formatEntry(entry) : "—"} + {isCorrect !== null && ( + + {isCorrect ? "✓" : "✗"} + + )} +
+ ) + })} +
+
+) +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/client/src/components/predictions-form.tsx +git commit -m "show correct/incorrect markers on locked predictions when results are in" +``` + +### Task 8: Update Leaderboard component + +**Files:** +- Modify: `packages/client/src/components/leaderboard.tsx` + +- [ ] **Step 1: Add `resultsEntered` prop and P: column** + +Add a `resultsEntered` boolean prop to `LeaderboardProps`: + +```ts +interface LeaderboardProps { + entries: LeaderboardEntry[] + resultsEntered?: boolean +} + +export function Leaderboard({ entries, resultsEntered }: LeaderboardProps) { +``` + +In the score display section (the `div` with `gap-3 text-xs`), add `P:` before `J:`. Show `P:?` when results are not yet entered: + +```tsx +
+ P:{resultsEntered ? entry.predictionPoints : "?"} + J:{entry.juryPoints} + B:{entry.bingoPoints} + {entry.totalPoints} +
+``` + +Update the scoring explanation `ul` to add predictions: + +```tsx +
    +
  • P = Prediction points — 25 for correct winner, 10 each for 2nd/3rd, 15 for last place
  • +
  • J = Jury points — rate each act 1-12, earn up to 5 pts per round for matching the group consensus
  • +
  • B = Bingo points — 2 pts per tapped trope + 10 bonus for a full bingo line
  • +
+``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/client/src/components/leaderboard.tsx +git commit -m "add prediction points to leaderboard display and explanation" +``` + +**Note for Tasks 9, 10, 11:** All `` usages must pass `resultsEntered={!!gameState.actualResults}` or `resultsEntered={!!gameState?.actualResults}`. + +### Task 9: Wire up host route + +**Files:** +- Modify: `packages/client/src/routes/host.$roomCode.tsx` + +- [ ] **Step 1: Import and add ActualResultsForm** + +Add import at top: + +```ts +import { ActualResultsForm } from "@/components/actual-results-form" +``` + +In the Host tab's `CardContent`, add the `ActualResultsForm` after the jury host block and before the leaderboard block. Find the line: + +```tsx +{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && ( + +)} +``` + +Add before it: + +```tsx +{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && ( + send({ type: "submit_actual_results", ...results })} + /> +)} +``` + +- [ ] **Step 2: Also pass `actualResults` to PredictionsForm in the Play tab** + +In the Play tab, update the predictions block for scoring/ended to show locked predictions with results. Find: + +```tsx +{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && ( + +)} +``` + +Add before that block (in the Play tab): + +```tsx +{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && gameState.myPrediction && ( + {}} + /> +)} +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/client/src/routes/host.$roomCode.tsx +git commit -m "wire ActualResultsForm and prediction results in host route" +``` + +### Task 10: Wire up player route + +**Files:** +- Modify: `packages/client/src/routes/play.$roomCode.tsx` + +- [ ] **Step 1: Show scored predictions in scoring/ended** + +In the player view, find the scoring act block: + +```tsx +{gameState && room.currentAct === "scoring" && ( + +)} +``` + +Add before it: + +```tsx +{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && gameState.myPrediction && ( + {}} + /> +)} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/client/src/routes/play.$roomCode.tsx +git commit -m "show scored predictions in player route during scoring/ended" +``` + +### Task 11: Update display route + +**Files:** +- Modify: `packages/client/src/routes/display.$roomCode.tsx` + +- [ ] **Step 1: Read current display route** + +Read `packages/client/src/routes/display.$roomCode.tsx` to understand current structure. + +- [ ] **Step 2: Add actual results summary to display** + +When actual results are entered and the act is scoring/ended, show a summary card. The exact placement depends on the current display route structure. Add in the scoring/ended section: + +```tsx +{gameState?.actualResults && ( + + + Actual Results + + + {[ + { label: "Winner", code: gameState.actualResults.winner }, + { label: "2nd", code: gameState.actualResults.second }, + { label: "3rd", code: gameState.actualResults.third }, + { label: "Last", code: gameState.actualResults.last }, + ].map(({ label, code }) => { + const entry = gameState.lineup.entries.find((e) => e.country.code === code) + return ( +
+ {label} + {entry ? `${entry.country.flag} ${entry.country.name}` : code} +
+ ) + })} +
+
+)} +``` + +Add this import at the top of the file (these are not currently imported in the display route): + +```ts +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/client/src/routes/display.$roomCode.tsx +git commit -m "show actual results summary on display in scoring/ended" +``` + +### Task 12: Final verification + +- [ ] **Step 1: Run all tests** + +Run: `bun test` +Expected: All 73 tests pass + +- [ ] **Step 2: Build client** + +Run: `bun run --filter './packages/client' build` +Expected: Build succeeds with 0 errors + +- [ ] **Step 3: Commit any remaining fixes if needed**