Files
esc/docs/superpowers/plans/2026-03-12-prediction-scoring.md
2026-03-12 21:45:17 +01:00

26 KiB

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:

// ─── Actual Results ─────────────────────────────────────────────────

export const actualResultsSchema = z.object({
	winner: z.string(),
	second: z.string(),
	third: z.string(),
	last: z.string(),
})

export type ActualResults = z.infer<typeof actualResultsSchema>

Update leaderboardEntrySchema to add predictionPoints:

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:

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
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:

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:

export const clientMessage = z.discriminatedUnion("type", [
	joinRoomMessage,
	reconnectMessage,
	advanceActMessage,
	revertActMessage,
	endRoomMessage,
	submitPredictionMessage,
	openJuryVoteMessage,
	closeJuryVoteMessage,
	submitJuryVoteMessage,
	tapBingoSquareMessage,
	submitActualResultsMessage,
])
  • Step 2: Commit
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:

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:

// ─── Prediction Scoring ─────────────────────────────────────────
private actualResults: { winner: string; second: string; third: string; last: string } | null = null

Add methods after getBingoScore:

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:

private buildLeaderboard(
	playerIds: string[],
	displayNames: Record<string, string>,
): { 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
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):

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
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:

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<Record<SlotKey, string | null>>(() => {
		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<string | null>(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 (
		<Card>
			<CardHeader>
				<CardTitle>Actual Results</CardTitle>
			</CardHeader>
			<CardContent className="flex flex-col gap-4">
				<div className="flex flex-col gap-2">
					{SLOTS.map((slot) => {
						const code = slots[slot.key]
						const entry = code ? findEntry(code) : null
						return (
							<div
								key={slot.key}
								className={`flex items-center justify-between rounded-md border p-2 ${
									code ? "border-primary/30 bg-primary/5" : "border-dashed"
								}`}
							>
								<div className="flex items-center gap-2">
									<span className="w-20 text-xs font-medium text-muted-foreground">{slot.label}</span>
									{entry ? (
										<span className="text-sm">{formatEntry(entry)}</span>
									) : (
										<span className="text-sm text-muted-foreground">Tap an entry below</span>
									)}
								</div>
								{code && (
									<button
										type="button"
										onClick={() => removeFromSlot(slot.key)}
										className="text-muted-foreground hover:text-foreground"
										aria-label={`Remove ${slot.label}`}
									>
										
									</button>
								)}
							</div>
						)
					})}
				</div>

				{allFilled && (
					<Button
						onClick={() =>
							onSubmit({
								winner: slots.winner!,
								second: slots.second!,
								third: slots.third!,
								last: slots.last!,
							})
						}
					>
						{existingResults ? "Update Results" : "Submit Results"}
					</Button>
				)}

				<div className="flex flex-col gap-1">
					<h4 className="text-sm font-medium text-muted-foreground">Entries</h4>
					{entries.map((entry) => {
						const isAssigned = assignedCodes.has(entry.country.code)
						const isPickerOpen = pickerForEntry === entry.country.code
						return (
							<div key={entry.country.code}>
								<button
									type="button"
									disabled={isAssigned}
									onClick={() => {
										if (emptySlots.length === 1) {
											assignToSlot(entry.country.code, emptySlots[0]!.key)
										} else {
											setPickerForEntry(isPickerOpen ? null : entry.country.code)
										}
									}}
									className={`w-full rounded-md border px-3 py-2 text-left text-sm transition-colors ${
										isAssigned
											? "border-transparent bg-muted/50 text-muted-foreground line-through opacity-50"
											: isPickerOpen
												? "border-primary bg-primary/5"
												: "hover:bg-muted"
									}`}
								>
									{formatEntry(entry)}
								</button>
								{isPickerOpen && !isAssigned && (
									<div className="mt-1 ml-4 flex gap-1">
										{emptySlots.map((slot) => (
											<button
												type="button"
												key={slot.key}
												onClick={() => assignToSlot(entry.country.code, slot.key)}
												className="rounded-md border px-2 py-1 text-xs hover:bg-primary hover:text-primary-foreground"
											>
												{slot.label}
											</button>
										))}
									</div>
								)}
							</div>
						)
					})}
				</div>
			</CardContent>
		</Card>
	)
}
  • Step 2: Commit
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:

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:

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:

return (
	<Card>
		<CardHeader>
			<CardTitle>Your Predictions {actualResults ? "(scored)" : "(locked)"}</CardTitle>
		</CardHeader>
		<CardContent className="flex flex-col gap-2">
			{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 (
					<div key={slot.key} className="flex items-center gap-2 rounded-md border p-2">
						<span className="w-20 text-xs font-medium text-muted-foreground">{slot.label}</span>
						<span className="text-sm">{entry ? formatEntry(entry) : "—"}</span>
						{isCorrect !== null && (
							<span className={isCorrect ? "ml-auto text-green-600" : "ml-auto text-red-500"}>
								{isCorrect ? "✓" : "✗"}
							</span>
						)}
					</div>
				)
			})}
		</CardContent>
	</Card>
)
  • Step 2: Commit
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:

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:

<div className="flex items-center gap-3 text-xs text-muted-foreground">
	<span title="Prediction points">P:{resultsEntered ? entry.predictionPoints : "?"}</span>
	<span title="Jury points">J:{entry.juryPoints}</span>
	<span title="Bingo points">B:{entry.bingoPoints}</span>
	<span className="text-sm font-bold text-foreground">{entry.totalPoints}</span>
</div>

Update the scoring explanation ul to add predictions:

<ul className="flex flex-col gap-0.5">
	<li><strong>P</strong> = Prediction points  25 for correct winner, 10 each for 2nd/3rd, 15 for last place</li>
	<li><strong>J</strong> = Jury points  rate each act 1-12, earn up to 5 pts per round for matching the group consensus</li>
	<li><strong>B</strong> = Bingo points  2 pts per tapped trope + 10 bonus for a full bingo line</li>
</ul>
  • Step 2: Commit
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 <Leaderboard> 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:

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:

{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && (
	<Leaderboard entries={gameState.leaderboard} />
)}

Add before it:

{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && (
	<ActualResultsForm
		entries={gameState.lineup.entries}
		existingResults={gameState.actualResults}
		onSubmit={(results) => 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:

{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && (
	<Leaderboard entries={gameState.leaderboard} />
)}

Add before that block (in the Play tab):

{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && gameState.myPrediction && (
	<PredictionsForm
		entries={gameState.lineup.entries}
		existingPrediction={gameState.myPrediction}
		locked={true}
		actualResults={gameState.actualResults}
		onSubmit={() => {}}
	/>
)}
  • Step 3: Commit
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:

{gameState && room.currentAct === "scoring" && (
	<Leaderboard entries={gameState.leaderboard} />
)}

Add before it:

{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && gameState.myPrediction && (
	<PredictionsForm
		entries={gameState.lineup.entries}
		existingPrediction={gameState.myPrediction}
		locked={true}
		actualResults={gameState.actualResults}
		onSubmit={() => {}}
	/>
)}
  • Step 2: Commit
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:

{gameState?.actualResults && (
	<Card>
		<CardHeader>
			<CardTitle>Actual Results</CardTitle>
		</CardHeader>
		<CardContent className="flex flex-col gap-1 text-sm">
			{[
				{ 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 (
					<div key={label} className="flex items-center gap-2">
						<span className="w-16 text-xs font-medium text-muted-foreground">{label}</span>
						<span>{entry ? `${entry.country.flag} ${entry.country.name}` : code}</span>
					</div>
				)
			})}
		</CardContent>
	</Card>
)}

Add this import at the top of the file (these are not currently imported in the display route):

import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
  • Step 3: Commit
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