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
actualResultsSchemaand extendLeaderboardEntryandGameState
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
buildLeaderboardto 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
actualResultsto 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
actualResultsprop 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
resultsEnteredprop 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
actualResultsto 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